Update 2026-07-14 09:34:44
This commit is contained in:
parent
420eb5dd58
commit
2ab8a46d44
@ -145,6 +145,8 @@ class Config:
|
||||
project_log_path: str
|
||||
project_log_label: str
|
||||
project_log_backfill: int
|
||||
project_log_exclude: str
|
||||
ros_distro: str
|
||||
# transport
|
||||
verify_tls: bool
|
||||
http_timeout: float
|
||||
@ -197,6 +199,11 @@ class Config:
|
||||
project_log_path=_env("PROJECT_LOG_PATH", ""),
|
||||
project_log_label=_env("PROJECT_LOG_LABEL", ""),
|
||||
project_log_backfill=int(_env("PROJECT_LOG_BACKFILL", "100")),
|
||||
# drop uvicorn/access-log noise so shipped lines match the project's
|
||||
# own LIVE LOGS panel exactly (app-logger lines + tracebacks only)
|
||||
project_log_exclude=_env("PROJECT_LOG_EXCLUDE",
|
||||
r"^(INFO|WARNING|ERROR|DEBUG|CRITICAL):\s"),
|
||||
ros_distro=_env("SOFTWARE_ROS", ""),
|
||||
verify_tls=_env_bool("VERIFY_TLS", True),
|
||||
http_timeout=float(_env("HTTP_TIMEOUT", "30")),
|
||||
)
|
||||
@ -232,6 +239,93 @@ def read_mac(interface: str) -> str:
|
||||
return ":".join(f"{(n >> b) & 0xff:02x}" for b in range(40, -1, -8))
|
||||
|
||||
|
||||
_AGENT_VERSION = "2026.07.13"
|
||||
|
||||
_software_cache: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
def read_software(cfg: Config) -> Dict[str, Any]:
|
||||
"""Robot software/OS card: ROS distro, host OS (via /host), kernel, arch.
|
||||
The container shares the HOST kernel; the host OS comes from
|
||||
/host/etc/os-release (the read-only /:/host mount)."""
|
||||
global _software_cache
|
||||
if _software_cache is not None:
|
||||
return _software_cache
|
||||
sw: Dict[str, Any] = {}
|
||||
# ROS distro: pinned via SOFTWARE_ROS, else detect host installs /opt/ros/*
|
||||
ros = cfg.ros_distro
|
||||
if not ros:
|
||||
try:
|
||||
for base in ("/host/opt/ros", "/opt/ros"):
|
||||
p = Path(base)
|
||||
if p.is_dir():
|
||||
distros = sorted(d.name for d in p.iterdir() if d.is_dir())
|
||||
if distros:
|
||||
ros = ",".join(distros)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
sw["ros"] = ros or None
|
||||
# host OS (firmware/OS card)
|
||||
os_name = os_ver = None
|
||||
for rel in ("/host/etc/os-release", "/etc/os-release"):
|
||||
try:
|
||||
kv = {}
|
||||
for line in Path(rel).read_text().splitlines():
|
||||
if "=" in line:
|
||||
k, _, v = line.partition("=")
|
||||
kv[k] = v.strip().strip('"')
|
||||
os_name = kv.get("PRETTY_NAME") or kv.get("NAME")
|
||||
os_ver = kv.get("VERSION_ID")
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
sw["os"] = os_name # e.g. "Ubuntu 20.04.6 LTS"
|
||||
sw["os_version"] = os_ver # e.g. "20.04"
|
||||
u = os.uname()
|
||||
sw["kernel"] = u.release # host kernel (shared with the container)
|
||||
sw["arch"] = u.machine # e.g. aarch64
|
||||
sw["python"] = ".".join(map(str, sys.version_info[:3]))
|
||||
sw["agent"] = f"{log.name} {_AGENT_VERSION}"
|
||||
_software_cache = sw
|
||||
return sw
|
||||
|
||||
|
||||
_firmware_cache: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
def read_firmware_static() -> Dict[str, Any]:
|
||||
"""Board-level firmware card (static, via /host): compute board model +
|
||||
Jetson L4T/BSP release + kernel. DDS adds live robot/bms fw versions."""
|
||||
global _firmware_cache
|
||||
if _firmware_cache is not None:
|
||||
return dict(_firmware_cache)
|
||||
fw: Dict[str, Any] = {}
|
||||
# board model, e.g. "NVIDIA Orin NX Developer Kit"
|
||||
for p in ("/host/sys/firmware/devicetree/base/model",
|
||||
"/host/proc/device-tree/model",
|
||||
"/sys/firmware/devicetree/base/model",
|
||||
"/proc/device-tree/model"):
|
||||
try:
|
||||
fw["board"] = Path(p).read_bytes().decode().strip("\x00 \n")
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
# Jetson L4T/BSP: "# R35 (release), REVISION: 3.1, ..." -> "R35.3.1"
|
||||
for p in ("/host/etc/nv_tegra_release", "/etc/nv_tegra_release"):
|
||||
try:
|
||||
head = Path(p).read_text().splitlines()[0]
|
||||
import re
|
||||
m = re.search(r"(R\d+).*?REVISION:\s*([\d.]+)", head)
|
||||
fw["l4t"] = f"{m.group(1)}.{m.group(2)}" if m else head.lstrip("# ").strip()
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
fw["kernel"] = os.uname().release
|
||||
_firmware_cache = fw
|
||||
return dict(fw)
|
||||
|
||||
|
||||
_data_size_cache: Dict[str, Any] = {"ts": 0.0, "kb": None}
|
||||
|
||||
|
||||
@ -285,6 +379,7 @@ class DDSReader:
|
||||
self._temps: List[float] = []
|
||||
self._max_dq = 0.0
|
||||
self._xy: Optional[Dict[str, float]] = None
|
||||
self._fw: Dict[str, Any] = {} # live fw versions (robot ctrl + bms)
|
||||
self._loco = None
|
||||
self.ok = False
|
||||
self._start()
|
||||
@ -372,6 +467,13 @@ class DDSReader:
|
||||
temp_c = max(vals)
|
||||
except Exception:
|
||||
temp_c = None
|
||||
# BMS firmware version (version_high.version_low)
|
||||
try:
|
||||
vh, vl = getattr(msg, "version_high", None), getattr(msg, "version_low", None)
|
||||
if vh is not None:
|
||||
self._fw["bms"] = f"{int(vh)}.{int(vl or 0)}"
|
||||
except Exception:
|
||||
pass
|
||||
with self._lock:
|
||||
self._bms = {
|
||||
"soc": max(0, min(100, soc)),
|
||||
@ -387,6 +489,14 @@ class DDSReader:
|
||||
|
||||
def _on_low(self, msg) -> None:
|
||||
try:
|
||||
# robot controller firmware version (LowState.version array)
|
||||
try:
|
||||
v = getattr(msg, "version", None)
|
||||
if v is not None and "robot" not in self._fw:
|
||||
vals = [int(x) for x in v] if hasattr(v, "__iter__") else [int(v)]
|
||||
self._fw["robot"] = ".".join(map(str, vals))
|
||||
except Exception:
|
||||
pass
|
||||
temps: List[float] = []
|
||||
max_dq = 0.0
|
||||
for m in (getattr(msg, "motor_state", None) or []):
|
||||
@ -429,6 +539,7 @@ class DDSReader:
|
||||
"temps": list(self._temps),
|
||||
"max_dq": self._max_dq,
|
||||
"xy": dict(self._xy) if self._xy else None,
|
||||
"fw": dict(self._fw),
|
||||
}
|
||||
|
||||
def fsm_id(self) -> Optional[int]:
|
||||
@ -1018,6 +1129,13 @@ class ProjectLogTail:
|
||||
self._cur: Optional[Path] = None
|
||||
self._pos = 0
|
||||
self._backfill = max(0, cfg.project_log_backfill)
|
||||
self._exclude = None
|
||||
if cfg.project_log_exclude:
|
||||
try:
|
||||
import re
|
||||
self._exclude = re.compile(cfg.project_log_exclude)
|
||||
except Exception:
|
||||
self._exclude = None
|
||||
self.active = False
|
||||
try:
|
||||
self._resolve(cfg)
|
||||
@ -1071,26 +1189,44 @@ class ProjectLogTail:
|
||||
|
||||
def _start(self, p: Path, label: str) -> None:
|
||||
self._cur = p
|
||||
self.label = label
|
||||
size = p.stat().st_size
|
||||
self._pos = size # default: only NEW lines ship
|
||||
# backfill: start N lines before EOF so recent history ships at startup
|
||||
self._pos = size # new lines ship from here on
|
||||
self._pending: List[str] = []
|
||||
# backfill: the last N RELEVANT lines (post-filter) from the last ~1 MB,
|
||||
# so the noise (access-log spam) doesn't eat the history window
|
||||
if self._backfill and size:
|
||||
try:
|
||||
take = min(size, 512 * 1024)
|
||||
take = min(size, 8 * 1024 * 1024)
|
||||
with p.open("rb") as f:
|
||||
f.seek(size - take)
|
||||
tail = f.read(take)
|
||||
parts = tail.splitlines(keepends=True)[-self._backfill:]
|
||||
self._pos = size - sum(len(x) for x in parts)
|
||||
raw = tail.decode("utf-8", "replace").splitlines()
|
||||
if take < size and raw:
|
||||
raw = raw[1:] # first line is a partial record — drop it
|
||||
for ln in raw:
|
||||
ln = ln.strip()
|
||||
if ln.startswith("{"):
|
||||
try:
|
||||
ln = (json.loads(ln).get("log") or "").rstrip()
|
||||
except Exception:
|
||||
pass
|
||||
if not ln:
|
||||
continue
|
||||
if self._exclude is not None and self._exclude.search(ln):
|
||||
continue
|
||||
self._pending.append(f"[{label}] {ln}")
|
||||
self._pending = self._pending[-self._backfill:]
|
||||
except Exception:
|
||||
self._pos = size
|
||||
self.label = label
|
||||
self._pending = []
|
||||
self.active = True
|
||||
|
||||
def poll(self) -> List[str]:
|
||||
"""New lines since last poll (docker json-log unwrapped), labeled."""
|
||||
"""New lines since last poll (docker json-log unwrapped), labeled.
|
||||
The startup backfill (last N relevant lines) is returned on first call."""
|
||||
if not self.active or self._cur is None:
|
||||
return []
|
||||
pend, self._pending = self._pending, []
|
||||
out: List[str] = []
|
||||
try:
|
||||
st = self._cur.stat()
|
||||
@ -1108,11 +1244,14 @@ class ProjectLogTail:
|
||||
ln = (json.loads(ln).get("log") or "").rstrip()
|
||||
except Exception:
|
||||
pass
|
||||
if ln:
|
||||
out.append(f"[{self.label}] {ln}")
|
||||
if not ln:
|
||||
continue
|
||||
if self._exclude is not None and self._exclude.search(ln):
|
||||
continue # access-log noise — not in the project's log panel
|
||||
out.append(f"[{self.label}] {ln}")
|
||||
except Exception as e:
|
||||
log.debug("project-log poll failed: %s", e)
|
||||
return out[-100:] # cap per cycle
|
||||
return pend + out[-100:] # backfill first, then ≤100 new/cycle
|
||||
|
||||
|
||||
_PROJECT_TAIL: Optional[ProjectLogTail] = None
|
||||
@ -1252,6 +1391,9 @@ def build_telemetry(cfg: Config, mac: str, reader: Optional[DDSReader],
|
||||
"brand": cfg.brand,
|
||||
"type": cfg.robot_type, # humanoid
|
||||
"model": cfg.model, # g1
|
||||
"software": read_software(cfg), # ros/os/kernel/arch/python/agent
|
||||
"firmware": {**read_firmware_static(), **(snap.get("fw") or {})},
|
||||
# board/l4t/kernel + live robot/bms fw versions
|
||||
"battery": battery, # null = couldn't read (heartbeat)
|
||||
"charging": charging,
|
||||
"battery_detail": battery_detail,
|
||||
|
||||
@ -128,6 +128,8 @@ class Config:
|
||||
project_log_path: str
|
||||
project_log_label: str
|
||||
project_log_backfill: int
|
||||
project_log_exclude: str
|
||||
ros_distro: str
|
||||
verify_tls: bool
|
||||
http_timeout: float
|
||||
|
||||
@ -176,6 +178,9 @@ class Config:
|
||||
project_log_path=_env("PROJECT_LOG_PATH", ""),
|
||||
project_log_label=_env("PROJECT_LOG_LABEL", ""),
|
||||
project_log_backfill=int(_env("PROJECT_LOG_BACKFILL", "100")),
|
||||
project_log_exclude=_env("PROJECT_LOG_EXCLUDE",
|
||||
r"^(INFO|WARNING|ERROR|DEBUG|CRITICAL):\s"),
|
||||
ros_distro=_env("SOFTWARE_ROS", ""),
|
||||
verify_tls=_env_bool("VERIFY_TLS", True),
|
||||
http_timeout=float(_env("HTTP_TIMEOUT", "10")),
|
||||
)
|
||||
@ -208,6 +213,93 @@ def read_mac(interface: str) -> str:
|
||||
return ":".join(f"{(n >> b) & 0xff:02x}" for b in range(40, -1, -8))
|
||||
|
||||
|
||||
_AGENT_VERSION = "2026.07.13"
|
||||
|
||||
_software_cache: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
def read_software(cfg: Config) -> Dict[str, Any]:
|
||||
"""Robot software/OS card: ROS distro, host OS (via /host), kernel, arch.
|
||||
The container shares the HOST kernel; the host OS comes from
|
||||
/host/etc/os-release (the read-only /:/host mount)."""
|
||||
global _software_cache
|
||||
if _software_cache is not None:
|
||||
return _software_cache
|
||||
sw: Dict[str, Any] = {}
|
||||
# ROS distro: pinned via SOFTWARE_ROS, else detect host installs /opt/ros/*
|
||||
ros = cfg.ros_distro
|
||||
if not ros:
|
||||
try:
|
||||
for base in ("/host/opt/ros", "/opt/ros"):
|
||||
p = Path(base)
|
||||
if p.is_dir():
|
||||
distros = sorted(d.name for d in p.iterdir() if d.is_dir())
|
||||
if distros:
|
||||
ros = ",".join(distros)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
sw["ros"] = ros or None
|
||||
# host OS (firmware/OS card)
|
||||
os_name = os_ver = None
|
||||
for rel in ("/host/etc/os-release", "/etc/os-release"):
|
||||
try:
|
||||
kv = {}
|
||||
for line in Path(rel).read_text().splitlines():
|
||||
if "=" in line:
|
||||
k, _, v = line.partition("=")
|
||||
kv[k] = v.strip().strip('"')
|
||||
os_name = kv.get("PRETTY_NAME") or kv.get("NAME")
|
||||
os_ver = kv.get("VERSION_ID")
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
sw["os"] = os_name # e.g. "Ubuntu 20.04.6 LTS"
|
||||
sw["os_version"] = os_ver # e.g. "20.04"
|
||||
u = os.uname()
|
||||
sw["kernel"] = u.release # host kernel (shared with the container)
|
||||
sw["arch"] = u.machine # e.g. aarch64
|
||||
sw["python"] = ".".join(map(str, sys.version_info[:3]))
|
||||
sw["agent"] = f"{log.name} {_AGENT_VERSION}"
|
||||
_software_cache = sw
|
||||
return sw
|
||||
|
||||
|
||||
_firmware_cache: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
def read_firmware_static() -> Dict[str, Any]:
|
||||
"""Board-level firmware card (static, via /host): compute board model +
|
||||
Jetson L4T/BSP release + kernel. DDS adds live robot/bms fw versions."""
|
||||
global _firmware_cache
|
||||
if _firmware_cache is not None:
|
||||
return dict(_firmware_cache)
|
||||
fw: Dict[str, Any] = {}
|
||||
# board model, e.g. "NVIDIA Orin NX Developer Kit"
|
||||
for p in ("/host/sys/firmware/devicetree/base/model",
|
||||
"/host/proc/device-tree/model",
|
||||
"/sys/firmware/devicetree/base/model",
|
||||
"/proc/device-tree/model"):
|
||||
try:
|
||||
fw["board"] = Path(p).read_bytes().decode().strip("\x00 \n")
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
# Jetson L4T/BSP: "# R35 (release), REVISION: 3.1, ..." -> "R35.3.1"
|
||||
for p in ("/host/etc/nv_tegra_release", "/etc/nv_tegra_release"):
|
||||
try:
|
||||
head = Path(p).read_text().splitlines()[0]
|
||||
import re
|
||||
m = re.search(r"(R\d+).*?REVISION:\s*([\d.]+)", head)
|
||||
fw["l4t"] = f"{m.group(1)}.{m.group(2)}" if m else head.lstrip("# ").strip()
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
fw["kernel"] = os.uname().release
|
||||
_firmware_cache = fw
|
||||
return dict(fw)
|
||||
|
||||
|
||||
_data_size_cache: Dict[str, Any] = {"ts": 0.0, "kb": None}
|
||||
|
||||
|
||||
@ -258,6 +350,7 @@ class DDSReader:
|
||||
self._temps: List[float] = []
|
||||
self._max_dq = 0.0
|
||||
self._xy: Optional[Dict[str, float]] = None
|
||||
self._fw: Dict[str, Any] = {} # live fw versions (robot ctrl + bms)
|
||||
self.ok = False
|
||||
self._start()
|
||||
|
||||
@ -290,10 +383,23 @@ class DDSReader:
|
||||
|
||||
def _on_low(self, msg) -> None:
|
||||
try:
|
||||
try:
|
||||
v = getattr(msg, "version", None)
|
||||
if v is not None and "robot" not in self._fw:
|
||||
vals = [int(x) for x in v] if hasattr(v, "__iter__") else [int(v)]
|
||||
self._fw["robot"] = ".".join(map(str, vals))
|
||||
except Exception:
|
||||
pass
|
||||
bms = getattr(msg, "bms_state", None) or getattr(msg, "bms", None)
|
||||
if bms is not None:
|
||||
soc = int(getattr(bms, "soc", 0) or 0)
|
||||
cur = int(getattr(bms, "current", 0) or 0) # mA
|
||||
try:
|
||||
vh, vl = getattr(bms, "version_high", None), getattr(bms, "version_low", None)
|
||||
if vh is not None:
|
||||
self._fw["bms"] = f"{int(vh)}.{int(vl or 0)}"
|
||||
except Exception:
|
||||
pass
|
||||
# Go2: pack voltage is LowState_.power_v (V); pack temp from the
|
||||
# BMS NTC sensors (bq_ntc / mcu_ntc, °C). All defensive getattrs.
|
||||
volt_v = None
|
||||
@ -367,6 +473,7 @@ class DDSReader:
|
||||
"temps": list(self._temps),
|
||||
"max_dq": self._max_dq,
|
||||
"xy": dict(self._xy) if self._xy else None,
|
||||
"fw": dict(self._fw),
|
||||
}
|
||||
|
||||
|
||||
@ -941,6 +1048,13 @@ class ProjectLogTail:
|
||||
self._cur: Optional[Path] = None
|
||||
self._pos = 0
|
||||
self._backfill = max(0, cfg.project_log_backfill)
|
||||
self._exclude = None
|
||||
if cfg.project_log_exclude:
|
||||
try:
|
||||
import re
|
||||
self._exclude = re.compile(cfg.project_log_exclude)
|
||||
except Exception:
|
||||
self._exclude = None
|
||||
self.active = False
|
||||
try:
|
||||
self._resolve(cfg)
|
||||
@ -994,26 +1108,44 @@ class ProjectLogTail:
|
||||
|
||||
def _start(self, p: Path, label: str) -> None:
|
||||
self._cur = p
|
||||
self.label = label
|
||||
size = p.stat().st_size
|
||||
self._pos = size # default: only NEW lines ship
|
||||
# backfill: start N lines before EOF so recent history ships at startup
|
||||
self._pos = size # new lines ship from here on
|
||||
self._pending: List[str] = []
|
||||
# backfill: the last N RELEVANT lines (post-filter) from the last ~1 MB,
|
||||
# so the noise (access-log spam) doesn't eat the history window
|
||||
if self._backfill and size:
|
||||
try:
|
||||
take = min(size, 512 * 1024)
|
||||
take = min(size, 8 * 1024 * 1024)
|
||||
with p.open("rb") as f:
|
||||
f.seek(size - take)
|
||||
tail = f.read(take)
|
||||
parts = tail.splitlines(keepends=True)[-self._backfill:]
|
||||
self._pos = size - sum(len(x) for x in parts)
|
||||
raw = tail.decode("utf-8", "replace").splitlines()
|
||||
if take < size and raw:
|
||||
raw = raw[1:] # first line is a partial record — drop it
|
||||
for ln in raw:
|
||||
ln = ln.strip()
|
||||
if ln.startswith("{"):
|
||||
try:
|
||||
ln = (json.loads(ln).get("log") or "").rstrip()
|
||||
except Exception:
|
||||
pass
|
||||
if not ln:
|
||||
continue
|
||||
if self._exclude is not None and self._exclude.search(ln):
|
||||
continue
|
||||
self._pending.append(f"[{label}] {ln}")
|
||||
self._pending = self._pending[-self._backfill:]
|
||||
except Exception:
|
||||
self._pos = size
|
||||
self.label = label
|
||||
self._pending = []
|
||||
self.active = True
|
||||
|
||||
def poll(self) -> List[str]:
|
||||
"""New lines since last poll (docker json-log unwrapped), labeled."""
|
||||
"""New lines since last poll (docker json-log unwrapped), labeled.
|
||||
The startup backfill (last N relevant lines) is returned on first call."""
|
||||
if not self.active or self._cur is None:
|
||||
return []
|
||||
pend, self._pending = self._pending, []
|
||||
out: List[str] = []
|
||||
try:
|
||||
st = self._cur.stat()
|
||||
@ -1031,11 +1163,14 @@ class ProjectLogTail:
|
||||
ln = (json.loads(ln).get("log") or "").rstrip()
|
||||
except Exception:
|
||||
pass
|
||||
if ln:
|
||||
out.append(f"[{self.label}] {ln}")
|
||||
if not ln:
|
||||
continue
|
||||
if self._exclude is not None and self._exclude.search(ln):
|
||||
continue # access-log noise — not in the project's log panel
|
||||
out.append(f"[{self.label}] {ln}")
|
||||
except Exception as e:
|
||||
log.debug("project-log poll failed: %s", e)
|
||||
return out[-100:] # cap per cycle
|
||||
return pend + out[-100:] # backfill first, then ≤100 new/cycle
|
||||
|
||||
|
||||
_PROJECT_TAIL: Optional[ProjectLogTail] = None
|
||||
|
||||
@ -145,6 +145,8 @@ class Config:
|
||||
project_log_path: str
|
||||
project_log_label: str
|
||||
project_log_backfill: int
|
||||
project_log_exclude: str
|
||||
ros_distro: str
|
||||
# transport
|
||||
verify_tls: bool
|
||||
http_timeout: float
|
||||
@ -197,6 +199,11 @@ class Config:
|
||||
project_log_path=_env("PROJECT_LOG_PATH", ""),
|
||||
project_log_label=_env("PROJECT_LOG_LABEL", ""),
|
||||
project_log_backfill=int(_env("PROJECT_LOG_BACKFILL", "100")),
|
||||
# drop uvicorn/access-log noise so shipped lines match the project's
|
||||
# own LIVE LOGS panel exactly (app-logger lines + tracebacks only)
|
||||
project_log_exclude=_env("PROJECT_LOG_EXCLUDE",
|
||||
r"^(INFO|WARNING|ERROR|DEBUG|CRITICAL):\s"),
|
||||
ros_distro=_env("SOFTWARE_ROS", ""),
|
||||
verify_tls=_env_bool("VERIFY_TLS", True),
|
||||
http_timeout=float(_env("HTTP_TIMEOUT", "30")),
|
||||
)
|
||||
@ -232,6 +239,93 @@ def read_mac(interface: str) -> str:
|
||||
return ":".join(f"{(n >> b) & 0xff:02x}" for b in range(40, -1, -8))
|
||||
|
||||
|
||||
_AGENT_VERSION = "2026.07.13"
|
||||
|
||||
_software_cache: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
def read_software(cfg: Config) -> Dict[str, Any]:
|
||||
"""Robot software/OS card: ROS distro, host OS (via /host), kernel, arch.
|
||||
The container shares the HOST kernel; the host OS comes from
|
||||
/host/etc/os-release (the read-only /:/host mount)."""
|
||||
global _software_cache
|
||||
if _software_cache is not None:
|
||||
return _software_cache
|
||||
sw: Dict[str, Any] = {}
|
||||
# ROS distro: pinned via SOFTWARE_ROS, else detect host installs /opt/ros/*
|
||||
ros = cfg.ros_distro
|
||||
if not ros:
|
||||
try:
|
||||
for base in ("/host/opt/ros", "/opt/ros"):
|
||||
p = Path(base)
|
||||
if p.is_dir():
|
||||
distros = sorted(d.name for d in p.iterdir() if d.is_dir())
|
||||
if distros:
|
||||
ros = ",".join(distros)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
sw["ros"] = ros or None
|
||||
# host OS (firmware/OS card)
|
||||
os_name = os_ver = None
|
||||
for rel in ("/host/etc/os-release", "/etc/os-release"):
|
||||
try:
|
||||
kv = {}
|
||||
for line in Path(rel).read_text().splitlines():
|
||||
if "=" in line:
|
||||
k, _, v = line.partition("=")
|
||||
kv[k] = v.strip().strip('"')
|
||||
os_name = kv.get("PRETTY_NAME") or kv.get("NAME")
|
||||
os_ver = kv.get("VERSION_ID")
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
sw["os"] = os_name # e.g. "Ubuntu 20.04.6 LTS"
|
||||
sw["os_version"] = os_ver # e.g. "20.04"
|
||||
u = os.uname()
|
||||
sw["kernel"] = u.release # host kernel (shared with the container)
|
||||
sw["arch"] = u.machine # e.g. aarch64
|
||||
sw["python"] = ".".join(map(str, sys.version_info[:3]))
|
||||
sw["agent"] = f"{log.name} {_AGENT_VERSION}"
|
||||
_software_cache = sw
|
||||
return sw
|
||||
|
||||
|
||||
_firmware_cache: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
def read_firmware_static() -> Dict[str, Any]:
|
||||
"""Board-level firmware card (static, via /host): compute board model +
|
||||
Jetson L4T/BSP release + kernel. DDS adds live robot/bms fw versions."""
|
||||
global _firmware_cache
|
||||
if _firmware_cache is not None:
|
||||
return dict(_firmware_cache)
|
||||
fw: Dict[str, Any] = {}
|
||||
# board model, e.g. "NVIDIA Orin NX Developer Kit"
|
||||
for p in ("/host/sys/firmware/devicetree/base/model",
|
||||
"/host/proc/device-tree/model",
|
||||
"/sys/firmware/devicetree/base/model",
|
||||
"/proc/device-tree/model"):
|
||||
try:
|
||||
fw["board"] = Path(p).read_bytes().decode().strip("\x00 \n")
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
# Jetson L4T/BSP: "# R35 (release), REVISION: 3.1, ..." -> "R35.3.1"
|
||||
for p in ("/host/etc/nv_tegra_release", "/etc/nv_tegra_release"):
|
||||
try:
|
||||
head = Path(p).read_text().splitlines()[0]
|
||||
import re
|
||||
m = re.search(r"(R\d+).*?REVISION:\s*([\d.]+)", head)
|
||||
fw["l4t"] = f"{m.group(1)}.{m.group(2)}" if m else head.lstrip("# ").strip()
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
fw["kernel"] = os.uname().release
|
||||
_firmware_cache = fw
|
||||
return dict(fw)
|
||||
|
||||
|
||||
_data_size_cache: Dict[str, Any] = {"ts": 0.0, "kb": None}
|
||||
|
||||
|
||||
@ -285,6 +379,7 @@ class DDSReader:
|
||||
self._temps: List[float] = []
|
||||
self._max_dq = 0.0
|
||||
self._xy: Optional[Dict[str, float]] = None
|
||||
self._fw: Dict[str, Any] = {} # live fw versions (robot ctrl + bms)
|
||||
self._loco = None
|
||||
self.ok = False
|
||||
self._start()
|
||||
@ -372,6 +467,13 @@ class DDSReader:
|
||||
temp_c = max(vals)
|
||||
except Exception:
|
||||
temp_c = None
|
||||
# BMS firmware version (version_high.version_low)
|
||||
try:
|
||||
vh, vl = getattr(msg, "version_high", None), getattr(msg, "version_low", None)
|
||||
if vh is not None:
|
||||
self._fw["bms"] = f"{int(vh)}.{int(vl or 0)}"
|
||||
except Exception:
|
||||
pass
|
||||
with self._lock:
|
||||
self._bms = {
|
||||
"soc": max(0, min(100, soc)),
|
||||
@ -387,6 +489,14 @@ class DDSReader:
|
||||
|
||||
def _on_low(self, msg) -> None:
|
||||
try:
|
||||
# robot controller firmware version (LowState.version array)
|
||||
try:
|
||||
v = getattr(msg, "version", None)
|
||||
if v is not None and "robot" not in self._fw:
|
||||
vals = [int(x) for x in v] if hasattr(v, "__iter__") else [int(v)]
|
||||
self._fw["robot"] = ".".join(map(str, vals))
|
||||
except Exception:
|
||||
pass
|
||||
temps: List[float] = []
|
||||
max_dq = 0.0
|
||||
for m in (getattr(msg, "motor_state", None) or []):
|
||||
@ -429,6 +539,7 @@ class DDSReader:
|
||||
"temps": list(self._temps),
|
||||
"max_dq": self._max_dq,
|
||||
"xy": dict(self._xy) if self._xy else None,
|
||||
"fw": dict(self._fw),
|
||||
}
|
||||
|
||||
def fsm_id(self) -> Optional[int]:
|
||||
@ -1018,6 +1129,13 @@ class ProjectLogTail:
|
||||
self._cur: Optional[Path] = None
|
||||
self._pos = 0
|
||||
self._backfill = max(0, cfg.project_log_backfill)
|
||||
self._exclude = None
|
||||
if cfg.project_log_exclude:
|
||||
try:
|
||||
import re
|
||||
self._exclude = re.compile(cfg.project_log_exclude)
|
||||
except Exception:
|
||||
self._exclude = None
|
||||
self.active = False
|
||||
try:
|
||||
self._resolve(cfg)
|
||||
@ -1071,26 +1189,44 @@ class ProjectLogTail:
|
||||
|
||||
def _start(self, p: Path, label: str) -> None:
|
||||
self._cur = p
|
||||
self.label = label
|
||||
size = p.stat().st_size
|
||||
self._pos = size # default: only NEW lines ship
|
||||
# backfill: start N lines before EOF so recent history ships at startup
|
||||
self._pos = size # new lines ship from here on
|
||||
self._pending: List[str] = []
|
||||
# backfill: the last N RELEVANT lines (post-filter) from the last ~1 MB,
|
||||
# so the noise (access-log spam) doesn't eat the history window
|
||||
if self._backfill and size:
|
||||
try:
|
||||
take = min(size, 512 * 1024)
|
||||
take = min(size, 8 * 1024 * 1024)
|
||||
with p.open("rb") as f:
|
||||
f.seek(size - take)
|
||||
tail = f.read(take)
|
||||
parts = tail.splitlines(keepends=True)[-self._backfill:]
|
||||
self._pos = size - sum(len(x) for x in parts)
|
||||
raw = tail.decode("utf-8", "replace").splitlines()
|
||||
if take < size and raw:
|
||||
raw = raw[1:] # first line is a partial record — drop it
|
||||
for ln in raw:
|
||||
ln = ln.strip()
|
||||
if ln.startswith("{"):
|
||||
try:
|
||||
ln = (json.loads(ln).get("log") or "").rstrip()
|
||||
except Exception:
|
||||
pass
|
||||
if not ln:
|
||||
continue
|
||||
if self._exclude is not None and self._exclude.search(ln):
|
||||
continue
|
||||
self._pending.append(f"[{label}] {ln}")
|
||||
self._pending = self._pending[-self._backfill:]
|
||||
except Exception:
|
||||
self._pos = size
|
||||
self.label = label
|
||||
self._pending = []
|
||||
self.active = True
|
||||
|
||||
def poll(self) -> List[str]:
|
||||
"""New lines since last poll (docker json-log unwrapped), labeled."""
|
||||
"""New lines since last poll (docker json-log unwrapped), labeled.
|
||||
The startup backfill (last N relevant lines) is returned on first call."""
|
||||
if not self.active or self._cur is None:
|
||||
return []
|
||||
pend, self._pending = self._pending, []
|
||||
out: List[str] = []
|
||||
try:
|
||||
st = self._cur.stat()
|
||||
@ -1108,11 +1244,14 @@ class ProjectLogTail:
|
||||
ln = (json.loads(ln).get("log") or "").rstrip()
|
||||
except Exception:
|
||||
pass
|
||||
if ln:
|
||||
out.append(f"[{self.label}] {ln}")
|
||||
if not ln:
|
||||
continue
|
||||
if self._exclude is not None and self._exclude.search(ln):
|
||||
continue # access-log noise — not in the project's log panel
|
||||
out.append(f"[{self.label}] {ln}")
|
||||
except Exception as e:
|
||||
log.debug("project-log poll failed: %s", e)
|
||||
return out[-100:] # cap per cycle
|
||||
return pend + out[-100:] # backfill first, then ≤100 new/cycle
|
||||
|
||||
|
||||
_PROJECT_TAIL: Optional[ProjectLogTail] = None
|
||||
@ -1252,6 +1391,9 @@ def build_telemetry(cfg: Config, mac: str, reader: Optional[DDSReader],
|
||||
"brand": cfg.brand,
|
||||
"type": cfg.robot_type, # humanoid
|
||||
"model": cfg.model, # g1
|
||||
"software": read_software(cfg), # ros/os/kernel/arch/python/agent
|
||||
"firmware": {**read_firmware_static(), **(snap.get("fw") or {})},
|
||||
# board/l4t/kernel + live robot/bms fw versions
|
||||
"battery": battery, # null = couldn't read (heartbeat)
|
||||
"charging": charging,
|
||||
"battery_detail": battery_detail,
|
||||
|
||||
@ -119,6 +119,7 @@ MAP_SELECT=all
|
||||
MAP_UPLOAD_MODE=multipart
|
||||
MAP_POLL_INTERVAL=30
|
||||
LOGS_INTERVAL=60
|
||||
SOFTWARE_ROS=foxy
|
||||
EOF
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user