Update 2026-07-10 14:10:16
This commit is contained in:
parent
0bb7d3ed83
commit
f638ca1e9e
12
PIPELINE.md
12
PIPELINE.md
@ -92,10 +92,18 @@ POST /api/v1/fleet/ingest/telemetry
|
|||||||
Authorization: Bearer <device_token>
|
Authorization: Bearer <device_token>
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
{ "sn": "r1_82",
|
{ "sn": "E39N4000Q6D7E70F", // the robot's REAL Unitree serial
|
||||||
"mac": "4c:bb:47:51:25:9a",
|
"mac": "4c:bb:47:51:25:9a",
|
||||||
"battery": 80,
|
"brand": "unitree",
|
||||||
|
"type": "humanoid", // humanoid | dog
|
||||||
|
"model": "r1", // r1 | g1 | go2
|
||||||
|
"battery": 62,
|
||||||
"charging": false,
|
"charging": false,
|
||||||
|
"battery_detail": { "voltage_v": 34.5, "current_a": -3.08,
|
||||||
|
"temp_c": 42, "soh": 99, "cycles": 10 },
|
||||||
|
"motor_temp": { "max": 49.0, "avg": 37.8, "min": 32.0 }, // null = not receiving
|
||||||
|
"storage": { "total_gb": 98.2, "free_gb": 58.5, "used_percent": 36.2,
|
||||||
|
"data_kb": 770.0 }, // data_kb only when STORAGE_DATA_PATH set
|
||||||
"status": "idle",
|
"status": "idle",
|
||||||
"position": { "x": 12.4, "y": 3.1 }, // or null when no localization source
|
"position": { "x": 12.4, "y": 3.1 }, // or null when no localization source
|
||||||
"faults": [],
|
"faults": [],
|
||||||
|
|||||||
@ -33,3 +33,11 @@ MOTOR_TEMP_MAX=85
|
|||||||
POLL_INTERVAL=2
|
POLL_INTERVAL=2
|
||||||
VERIFY_TLS=1
|
VERIFY_TLS=1
|
||||||
HTTP_TIMEOUT=10
|
HTTP_TIMEOUT=10
|
||||||
|
|
||||||
|
# ── robot identity card (shown on the dashboard) ─────────────────────────────
|
||||||
|
ROBOT_BRAND=unitree
|
||||||
|
ROBOT_TYPE=humanoid
|
||||||
|
ROBOT_MODEL=g1
|
||||||
|
# Optional: Sanad data dir whose size is reported in storage (as /host/<path>
|
||||||
|
# when the installer's read-only /:/host mount is used). Empty = omit.
|
||||||
|
STORAGE_DATA_PATH=
|
||||||
|
|||||||
@ -47,6 +47,7 @@ import argparse
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@ -85,6 +86,12 @@ class Config:
|
|||||||
server_url: str
|
server_url: str
|
||||||
device_token: str
|
device_token: str
|
||||||
sn: str
|
sn: str
|
||||||
|
name: str
|
||||||
|
brand: str
|
||||||
|
robot_type: str
|
||||||
|
model: str
|
||||||
|
storage_path: str
|
||||||
|
data_path: str
|
||||||
dds_interface: str
|
dds_interface: str
|
||||||
dds_domain: int
|
dds_domain: int
|
||||||
mac_interface: str
|
mac_interface: str
|
||||||
@ -109,6 +116,12 @@ class Config:
|
|||||||
return cls(
|
return cls(
|
||||||
server_url=server, device_token=token,
|
server_url=server, device_token=token,
|
||||||
sn=_env("SN", "g1_0000"),
|
sn=_env("SN", "g1_0000"),
|
||||||
|
name=_env("ROBOT_NAME", "") or _env("SN", "g1_0000"),
|
||||||
|
brand=_env("ROBOT_BRAND", "unitree"),
|
||||||
|
robot_type=_env("ROBOT_TYPE", "humanoid"),
|
||||||
|
model=_env("ROBOT_MODEL", "g1"),
|
||||||
|
storage_path=_env("STORAGE_PATH", ""),
|
||||||
|
data_path=_env("STORAGE_DATA_PATH", ""),
|
||||||
dds_interface=iface, dds_domain=int(_env("DDS_DOMAIN", "0")),
|
dds_interface=iface, dds_domain=int(_env("DDS_DOMAIN", "0")),
|
||||||
mac_interface=_env("MAC_INTERFACE", iface),
|
mac_interface=_env("MAC_INTERFACE", iface),
|
||||||
read_fsm=_env_bool("G1_READ_FSM", False),
|
read_fsm=_env_bool("G1_READ_FSM", False),
|
||||||
@ -141,6 +154,43 @@ def read_mac(interface: str) -> str:
|
|||||||
return ":".join(f"{(n >> b) & 0xff:02x}" for b in range(40, -1, -8))
|
return ":".join(f"{(n >> b) & 0xff:02x}" for b in range(40, -1, -8))
|
||||||
|
|
||||||
|
|
||||||
|
_data_size_cache: Dict[str, Any] = {"ts": 0.0, "kb": None}
|
||||||
|
|
||||||
|
|
||||||
|
def read_storage(cfg: Config) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Disk usage of the robot's root fs + optional Sanad data-dir size.
|
||||||
|
|
||||||
|
In docker, bind-mount the host root read-only at /host (the installer does)
|
||||||
|
so this reports the HOST disk, not the container overlay."""
|
||||||
|
root = cfg.storage_path or ("/host" if os.path.isdir("/host") else "/")
|
||||||
|
try:
|
||||||
|
du = shutil.disk_usage(root)
|
||||||
|
out: Dict[str, Any] = {
|
||||||
|
"total_gb": round(du.total / 1e9, 2),
|
||||||
|
"free_gb": round(du.free / 1e9, 2),
|
||||||
|
"used_percent": round(du.used / du.total * 100, 1),
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if cfg.data_path and os.path.isdir(cfg.data_path):
|
||||||
|
now = time.monotonic()
|
||||||
|
if _data_size_cache["kb"] is None or now - _data_size_cache["ts"] > 60:
|
||||||
|
try:
|
||||||
|
total = 0
|
||||||
|
for r, _, files in os.walk(cfg.data_path):
|
||||||
|
for f in files:
|
||||||
|
try:
|
||||||
|
total += os.path.getsize(os.path.join(r, f))
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
_data_size_cache.update(ts=now, kb=round(total / 1024, 1))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if _data_size_cache["kb"] is not None:
|
||||||
|
out["data_kb"] = _data_size_cache["kb"]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
class DDSReader:
|
class DDSReader:
|
||||||
"""Subscribes rt/lf/bmsstate + rt/lowstate (+ rt/lf/odommodestate for position).
|
"""Subscribes rt/lf/bmsstate + rt/lowstate (+ rt/lf/odommodestate for position).
|
||||||
Passive reads; the only RPC ever issued is GET_FSM_ID."""
|
Passive reads; the only RPC ever issued is GET_FSM_ID."""
|
||||||
@ -216,8 +266,40 @@ class DDSReader:
|
|||||||
try:
|
try:
|
||||||
soc = int(getattr(msg, "soc", 0) or 0)
|
soc = int(getattr(msg, "soc", 0) or 0)
|
||||||
cur_mA = int(getattr(msg, "current", 0) or 0)
|
cur_mA = int(getattr(msg, "current", 0) or 0)
|
||||||
|
# Pack voltage: prefer bmsvoltage[0] (mV); else sum of cell voltages.
|
||||||
|
volt_mv = 0
|
||||||
|
bv = getattr(msg, "bmsvoltage", None)
|
||||||
|
try:
|
||||||
|
if bv is not None and len(bv) and int(bv[0]):
|
||||||
|
volt_mv = int(bv[0])
|
||||||
|
except Exception:
|
||||||
|
volt_mv = 0
|
||||||
|
if not volt_mv:
|
||||||
|
cv = getattr(msg, "cell_vol", None)
|
||||||
|
if cv is not None:
|
||||||
|
try:
|
||||||
|
volt_mv = int(sum(int(x) for x in cv if x))
|
||||||
|
except Exception:
|
||||||
|
volt_mv = 0
|
||||||
|
# Max plausible pack temperature (int16 °C).
|
||||||
|
temp_c = None
|
||||||
|
tt = getattr(msg, "temperature", None)
|
||||||
|
if tt is not None:
|
||||||
|
try:
|
||||||
|
vals = [int(x) for x in tt if -40 <= int(x) <= 150]
|
||||||
|
if vals:
|
||||||
|
temp_c = max(vals)
|
||||||
|
except Exception:
|
||||||
|
temp_c = None
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._bms = {"soc": max(0, min(100, soc)), "current_a": round(cur_mA / 1000.0, 2)}
|
self._bms = {
|
||||||
|
"soc": max(0, min(100, soc)),
|
||||||
|
"current_a": round(cur_mA / 1000.0, 2),
|
||||||
|
"voltage_v": round(volt_mv / 1000.0, 1) if volt_mv else None,
|
||||||
|
"temp_c": temp_c,
|
||||||
|
"soh": int(getattr(msg, "soh", 0) or 0),
|
||||||
|
"cycles": int(getattr(msg, "cycle", 0) or 0),
|
||||||
|
}
|
||||||
self._bms_ts = time.monotonic()
|
self._bms_ts = time.monotonic()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@ -231,7 +313,8 @@ class DDSReader:
|
|||||||
if t is not None:
|
if t is not None:
|
||||||
try:
|
try:
|
||||||
vals = [float(x) for x in t] if hasattr(t, "__iter__") else [float(t)]
|
vals = [float(x) for x in t] if hasattr(t, "__iter__") else [float(t)]
|
||||||
temps.extend(v for v in vals if -40 <= v <= 200)
|
# 0 = slot not reporting (unpopulated motor), not a real temp
|
||||||
|
temps.extend(v for v in vals if 0 < v <= 200)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
dq = getattr(m, "dq", None)
|
dq = getattr(m, "dq", None)
|
||||||
@ -352,7 +435,8 @@ def build_telemetry(cfg: Config, mac: str, reader: Optional[DDSReader],
|
|||||||
pos: Optional[RosbridgePosition],
|
pos: Optional[RosbridgePosition],
|
||||||
sim: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
sim: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||||
if sim is not None:
|
if sim is not None:
|
||||||
snap = {"bms": {"soc": sim["battery"], "current_a": 0.5 if sim["charging"] else -0.3},
|
snap = {"bms": {"soc": sim["battery"], "current_a": 0.5 if sim["charging"] else -0.3,
|
||||||
|
"voltage_v": 47.5, "temp_c": 36, "soh": 100, "cycles": 45},
|
||||||
"low_age": 0.1, "temps": [sim.get("temp", 45)], "max_dq": sim.get("max_dq", 0.0),
|
"low_age": 0.1, "temps": [sim.get("temp", 45)], "max_dq": sim.get("max_dq", 0.0),
|
||||||
"xy": sim.get("position")}
|
"xy": sim.get("position")}
|
||||||
fsm = sim.get("fsm")
|
fsm = sim.get("fsm")
|
||||||
@ -366,13 +450,34 @@ def build_telemetry(cfg: Config, mac: str, reader: Optional[DDSReader],
|
|||||||
status = derive_status(cfg, snap, fsm)
|
status = derive_status(cfg, snap, fsm)
|
||||||
faults = derive_faults(cfg, snap)
|
faults = derive_faults(cfg, snap)
|
||||||
|
|
||||||
|
# Battery detail (voltage / current / pack temp / health / cycles).
|
||||||
|
battery_detail = None
|
||||||
|
if bms:
|
||||||
|
battery_detail = {"voltage_v": bms.get("voltage_v"), "current_a": bms.get("current_a"),
|
||||||
|
"temp_c": bms.get("temp_c"), "soh": bms.get("soh"),
|
||||||
|
"cycles": bms.get("cycles")}
|
||||||
|
|
||||||
|
# Motor temperature stats; null = temps not receiving.
|
||||||
|
temps = snap.get("temps") or []
|
||||||
|
motor_temp = ({"max": round(max(temps), 1), "avg": round(sum(temps) / len(temps), 1),
|
||||||
|
"min": round(min(temps), 1)} if temps else None)
|
||||||
|
|
||||||
position = snap.get("xy")
|
position = snap.get("xy")
|
||||||
if position is None and pos is not None:
|
if position is None and pos is not None:
|
||||||
position = pos.get()
|
position = pos.get()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"sn": cfg.sn, "mac": mac,
|
"sn": cfg.sn,
|
||||||
"battery": battery, "charging": charging, "status": status,
|
"name": cfg.name, # friendly display name (e.g. g1_58)
|
||||||
|
"mac": mac,
|
||||||
|
"brand": cfg.brand,
|
||||||
|
"type": cfg.robot_type, # humanoid | dog
|
||||||
|
"model": cfg.model, # r1 | g1 | go2
|
||||||
|
"battery": battery, "charging": charging,
|
||||||
|
"battery_detail": battery_detail,
|
||||||
|
"motor_temp": motor_temp, # null = not receiving
|
||||||
|
"storage": read_storage(cfg),
|
||||||
|
"status": status,
|
||||||
"position": position, "faults": faults, "ts": int(time.time()),
|
"position": position, "faults": faults, "ts": int(time.time()),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -27,3 +27,11 @@ MOTOR_TEMP_MAX=85
|
|||||||
POLL_INTERVAL=2
|
POLL_INTERVAL=2
|
||||||
VERIFY_TLS=1
|
VERIFY_TLS=1
|
||||||
HTTP_TIMEOUT=10
|
HTTP_TIMEOUT=10
|
||||||
|
|
||||||
|
# ── robot identity card (shown on the dashboard) ─────────────────────────────
|
||||||
|
ROBOT_BRAND=unitree
|
||||||
|
ROBOT_TYPE=dog
|
||||||
|
ROBOT_MODEL=go2
|
||||||
|
# Optional: Sanad data dir whose size is reported in storage (as /host/<path>
|
||||||
|
# when the installer's read-only /:/host mount is used). Empty = omit.
|
||||||
|
STORAGE_DATA_PATH=
|
||||||
|
|||||||
@ -40,6 +40,7 @@ import argparse
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@ -78,6 +79,12 @@ class Config:
|
|||||||
server_url: str
|
server_url: str
|
||||||
device_token: str
|
device_token: str
|
||||||
sn: str
|
sn: str
|
||||||
|
name: str
|
||||||
|
brand: str
|
||||||
|
robot_type: str
|
||||||
|
model: str
|
||||||
|
storage_path: str
|
||||||
|
data_path: str
|
||||||
dds_interface: str
|
dds_interface: str
|
||||||
dds_domain: int
|
dds_domain: int
|
||||||
mac_interface: str
|
mac_interface: str
|
||||||
@ -101,6 +108,12 @@ class Config:
|
|||||||
return cls(
|
return cls(
|
||||||
server_url=server, device_token=token,
|
server_url=server, device_token=token,
|
||||||
sn=_env("SN", "go2_0000"),
|
sn=_env("SN", "go2_0000"),
|
||||||
|
name=_env("ROBOT_NAME", "") or _env("SN", "go2_0000"),
|
||||||
|
brand=_env("ROBOT_BRAND", "unitree"),
|
||||||
|
robot_type=_env("ROBOT_TYPE", "dog"),
|
||||||
|
model=_env("ROBOT_MODEL", "go2"),
|
||||||
|
storage_path=_env("STORAGE_PATH", ""),
|
||||||
|
data_path=_env("STORAGE_DATA_PATH", ""),
|
||||||
dds_interface=iface, dds_domain=int(_env("DDS_DOMAIN", "0")),
|
dds_interface=iface, dds_domain=int(_env("DDS_DOMAIN", "0")),
|
||||||
mac_interface=_env("MAC_INTERFACE", iface),
|
mac_interface=_env("MAC_INTERFACE", iface),
|
||||||
position_source=_env("GO2_POSITION_SOURCE", "none").lower(),
|
position_source=_env("GO2_POSITION_SOURCE", "none").lower(),
|
||||||
@ -132,6 +145,43 @@ def read_mac(interface: str) -> str:
|
|||||||
return ":".join(f"{(n >> b) & 0xff:02x}" for b in range(40, -1, -8))
|
return ":".join(f"{(n >> b) & 0xff:02x}" for b in range(40, -1, -8))
|
||||||
|
|
||||||
|
|
||||||
|
_data_size_cache: Dict[str, Any] = {"ts": 0.0, "kb": None}
|
||||||
|
|
||||||
|
|
||||||
|
def read_storage(cfg: Config) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Disk usage of the robot's root fs + optional Sanad data-dir size.
|
||||||
|
|
||||||
|
In docker, bind-mount the host root read-only at /host (the installer does)
|
||||||
|
so this reports the HOST disk, not the container overlay."""
|
||||||
|
root = cfg.storage_path or ("/host" if os.path.isdir("/host") else "/")
|
||||||
|
try:
|
||||||
|
du = shutil.disk_usage(root)
|
||||||
|
out: Dict[str, Any] = {
|
||||||
|
"total_gb": round(du.total / 1e9, 2),
|
||||||
|
"free_gb": round(du.free / 1e9, 2),
|
||||||
|
"used_percent": round(du.used / du.total * 100, 1),
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if cfg.data_path and os.path.isdir(cfg.data_path):
|
||||||
|
now = time.monotonic()
|
||||||
|
if _data_size_cache["kb"] is None or now - _data_size_cache["ts"] > 60:
|
||||||
|
try:
|
||||||
|
total = 0
|
||||||
|
for r, _, files in os.walk(cfg.data_path):
|
||||||
|
for f in files:
|
||||||
|
try:
|
||||||
|
total += os.path.getsize(os.path.join(r, f))
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
_data_size_cache.update(ts=now, kb=round(total / 1024, 1))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if _data_size_cache["kb"] is not None:
|
||||||
|
out["data_kb"] = _data_size_cache["kb"]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
class DDSReader:
|
class DDSReader:
|
||||||
"""Subscribes rt/lowstate (unitree_go LowState_) and (optionally) sportmodestate.
|
"""Subscribes rt/lowstate (unitree_go LowState_) and (optionally) sportmodestate.
|
||||||
Battery comes from the nested LowState_.bms_state. Passive reads only."""
|
Battery comes from the nested LowState_.bms_state. Passive reads only."""
|
||||||
@ -181,8 +231,36 @@ class DDSReader:
|
|||||||
if bms is not None:
|
if bms is not None:
|
||||||
soc = int(getattr(bms, "soc", 0) or 0)
|
soc = int(getattr(bms, "soc", 0) or 0)
|
||||||
cur = int(getattr(bms, "current", 0) or 0) # mA
|
cur = int(getattr(bms, "current", 0) or 0) # mA
|
||||||
|
# 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
|
||||||
|
try:
|
||||||
|
pv = getattr(msg, "power_v", None)
|
||||||
|
if pv:
|
||||||
|
volt_v = round(float(pv), 1)
|
||||||
|
except Exception:
|
||||||
|
volt_v = None
|
||||||
|
temp_c = None
|
||||||
|
try:
|
||||||
|
ntc_vals = []
|
||||||
|
for attr in ("bq_ntc", "mcu_ntc"):
|
||||||
|
nt = getattr(bms, attr, None)
|
||||||
|
if nt is not None:
|
||||||
|
vals = [int(x) for x in nt] if hasattr(nt, "__iter__") else [int(nt)]
|
||||||
|
ntc_vals.extend(v for v in vals if -40 <= v <= 150)
|
||||||
|
if ntc_vals:
|
||||||
|
temp_c = max(ntc_vals)
|
||||||
|
except Exception:
|
||||||
|
temp_c = None
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._bms = {"soc": max(0, min(100, soc)), "current_a": round(cur / 1000.0, 2)}
|
self._bms = {
|
||||||
|
"soc": max(0, min(100, soc)),
|
||||||
|
"current_a": round(cur / 1000.0, 2),
|
||||||
|
"voltage_v": volt_v,
|
||||||
|
"temp_c": temp_c,
|
||||||
|
"soh": int(getattr(bms, "soh", 0) or 0),
|
||||||
|
"cycles": int(getattr(bms, "cycle", 0) or 0),
|
||||||
|
}
|
||||||
temps: List[float] = []
|
temps: List[float] = []
|
||||||
max_dq = 0.0
|
max_dq = 0.0
|
||||||
for m in (getattr(msg, "motor_state", None) or []):
|
for m in (getattr(msg, "motor_state", None) or []):
|
||||||
@ -190,7 +268,8 @@ class DDSReader:
|
|||||||
if t is not None:
|
if t is not None:
|
||||||
try:
|
try:
|
||||||
vals = [float(x) for x in t] if hasattr(t, "__iter__") else [float(t)]
|
vals = [float(x) for x in t] if hasattr(t, "__iter__") else [float(t)]
|
||||||
temps.extend(v for v in vals if -40 <= v <= 200)
|
# 0 = slot not reporting (unpopulated motor), not a real temp
|
||||||
|
temps.extend(v for v in vals if 0 < v <= 200)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
dq = getattr(m, "dq", None)
|
dq = getattr(m, "dq", None)
|
||||||
@ -296,7 +375,8 @@ def build_telemetry(cfg: Config, mac: str, reader: Optional[DDSReader],
|
|||||||
pos: Optional[RosbridgePosition],
|
pos: Optional[RosbridgePosition],
|
||||||
sim: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
sim: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||||
if sim is not None:
|
if sim is not None:
|
||||||
snap = {"bms": {"soc": sim["battery"], "current_a": 0.5 if sim["charging"] else -0.3},
|
snap = {"bms": {"soc": sim["battery"], "current_a": 0.5 if sim["charging"] else -0.3,
|
||||||
|
"voltage_v": 28.6, "temp_c": 36, "soh": 100, "cycles": 45},
|
||||||
"low_age": 0.1, "temps": [sim.get("temp", 45)], "max_dq": sim.get("max_dq", 0.0),
|
"low_age": 0.1, "temps": [sim.get("temp", 45)], "max_dq": sim.get("max_dq", 0.0),
|
||||||
"xy": sim.get("position")}
|
"xy": sim.get("position")}
|
||||||
else:
|
else:
|
||||||
@ -308,13 +388,34 @@ def build_telemetry(cfg: Config, mac: str, reader: Optional[DDSReader],
|
|||||||
status = derive_status(cfg, snap)
|
status = derive_status(cfg, snap)
|
||||||
faults = derive_faults(cfg, snap)
|
faults = derive_faults(cfg, snap)
|
||||||
|
|
||||||
|
# Battery detail (voltage / current / pack temp / health / cycles).
|
||||||
|
battery_detail = None
|
||||||
|
if bms:
|
||||||
|
battery_detail = {"voltage_v": bms.get("voltage_v"), "current_a": bms.get("current_a"),
|
||||||
|
"temp_c": bms.get("temp_c"), "soh": bms.get("soh"),
|
||||||
|
"cycles": bms.get("cycles")}
|
||||||
|
|
||||||
|
# Motor temperature stats; null = temps not receiving.
|
||||||
|
temps = snap.get("temps") or []
|
||||||
|
motor_temp = ({"max": round(max(temps), 1), "avg": round(sum(temps) / len(temps), 1),
|
||||||
|
"min": round(min(temps), 1)} if temps else None)
|
||||||
|
|
||||||
position = snap.get("xy")
|
position = snap.get("xy")
|
||||||
if position is None and pos is not None:
|
if position is None and pos is not None:
|
||||||
position = pos.get()
|
position = pos.get()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"sn": cfg.sn, "mac": mac,
|
"sn": cfg.sn,
|
||||||
"battery": battery, "charging": charging, "status": status,
|
"name": cfg.name, # friendly display name (e.g. go2_XX)
|
||||||
|
"mac": mac,
|
||||||
|
"brand": cfg.brand,
|
||||||
|
"type": cfg.robot_type, # humanoid | dog
|
||||||
|
"model": cfg.model, # r1 | g1 | go2
|
||||||
|
"battery": battery, "charging": charging,
|
||||||
|
"battery_detail": battery_detail,
|
||||||
|
"motor_temp": motor_temp, # null = not receiving
|
||||||
|
"storage": read_storage(cfg),
|
||||||
|
"status": status,
|
||||||
"position": position, "faults": faults, "ts": int(time.time()),
|
"position": position, "faults": faults, "ts": int(time.time()),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -33,3 +33,11 @@ MOTOR_TEMP_MAX=85
|
|||||||
POLL_INTERVAL=2
|
POLL_INTERVAL=2
|
||||||
VERIFY_TLS=1
|
VERIFY_TLS=1
|
||||||
HTTP_TIMEOUT=10
|
HTTP_TIMEOUT=10
|
||||||
|
|
||||||
|
# ── robot identity card (shown on the dashboard) ─────────────────────────────
|
||||||
|
ROBOT_BRAND=unitree
|
||||||
|
ROBOT_TYPE=humanoid
|
||||||
|
ROBOT_MODEL=r1
|
||||||
|
# Optional: Sanad data dir whose size is reported in storage (as /host/<path>
|
||||||
|
# when the installer's read-only /:/host mount is used). Empty = omit.
|
||||||
|
STORAGE_DATA_PATH=
|
||||||
|
|||||||
@ -59,6 +59,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
import socket
|
import socket
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
@ -104,6 +105,12 @@ class Config:
|
|||||||
server_url: str
|
server_url: str
|
||||||
device_token: str
|
device_token: str
|
||||||
sn: str
|
sn: str
|
||||||
|
name: str
|
||||||
|
brand: str
|
||||||
|
robot_type: str
|
||||||
|
model: str
|
||||||
|
storage_path: str
|
||||||
|
data_path: str
|
||||||
dds_interface: str
|
dds_interface: str
|
||||||
dds_domain: int
|
dds_domain: int
|
||||||
mac_interface: str
|
mac_interface: str
|
||||||
@ -129,6 +136,12 @@ class Config:
|
|||||||
server_url=server,
|
server_url=server,
|
||||||
device_token=token,
|
device_token=token,
|
||||||
sn=_env("SN", "r1_0000"),
|
sn=_env("SN", "r1_0000"),
|
||||||
|
name=_env("ROBOT_NAME", "") or _env("SN", "r1_0000"),
|
||||||
|
brand=_env("ROBOT_BRAND", "unitree"),
|
||||||
|
robot_type=_env("ROBOT_TYPE", "humanoid"),
|
||||||
|
model=_env("ROBOT_MODEL", "r1"),
|
||||||
|
storage_path=_env("STORAGE_PATH", ""),
|
||||||
|
data_path=_env("STORAGE_DATA_PATH", ""),
|
||||||
dds_interface=iface,
|
dds_interface=iface,
|
||||||
dds_domain=int(_env("DDS_DOMAIN", "0")),
|
dds_domain=int(_env("DDS_DOMAIN", "0")),
|
||||||
mac_interface=_env("MAC_INTERFACE", iface),
|
mac_interface=_env("MAC_INTERFACE", iface),
|
||||||
@ -169,6 +182,47 @@ def read_mac(interface: str) -> str:
|
|||||||
return ":".join(f"{(n >> b) & 0xff:02x}" for b in range(40, -1, -8))
|
return ":".join(f"{(n >> b) & 0xff:02x}" for b in range(40, -1, -8))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# storage (host disk usage; mount / at /host:ro in docker)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
_data_size_cache: Dict[str, Any] = {"ts": 0.0, "kb": None}
|
||||||
|
|
||||||
|
|
||||||
|
def read_storage(cfg: Config) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Disk usage of the robot's root fs + optional Sanad data-dir size.
|
||||||
|
|
||||||
|
In docker, bind-mount the host root read-only at /host (the installer does)
|
||||||
|
so this reports the HOST disk, not the container overlay."""
|
||||||
|
root = cfg.storage_path or ("/host" if os.path.isdir("/host") else "/")
|
||||||
|
try:
|
||||||
|
du = shutil.disk_usage(root)
|
||||||
|
out: Dict[str, Any] = {
|
||||||
|
"total_gb": round(du.total / 1e9, 2),
|
||||||
|
"free_gb": round(du.free / 1e9, 2),
|
||||||
|
"used_percent": round(du.used / du.total * 100, 1),
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
# data-dir size is a directory walk — cache it (refresh every 60 s)
|
||||||
|
if cfg.data_path and os.path.isdir(cfg.data_path):
|
||||||
|
now = time.monotonic()
|
||||||
|
if _data_size_cache["kb"] is None or now - _data_size_cache["ts"] > 60:
|
||||||
|
try:
|
||||||
|
total = 0
|
||||||
|
for r, _, files in os.walk(cfg.data_path):
|
||||||
|
for f in files:
|
||||||
|
try:
|
||||||
|
total += os.path.getsize(os.path.join(r, f))
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
_data_size_cache.update(ts=now, kb=round(total / 1024, 1))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if _data_size_cache["kb"] is not None:
|
||||||
|
out["data_kb"] = _data_size_cache["kb"]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# DDS reader (optional — degrades if unitree_sdk2py is absent)
|
# DDS reader (optional — degrades if unitree_sdk2py is absent)
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@ -242,11 +296,38 @@ class DDSReader:
|
|||||||
try:
|
try:
|
||||||
soc = int(getattr(msg, "soc", 0) or 0)
|
soc = int(getattr(msg, "soc", 0) or 0)
|
||||||
cur_mA = int(getattr(msg, "current", 0) or 0)
|
cur_mA = int(getattr(msg, "current", 0) or 0)
|
||||||
|
# Pack voltage: prefer bmsvoltage[0] (mV); else sum of cell voltages.
|
||||||
|
volt_mv = 0
|
||||||
|
bv = getattr(msg, "bmsvoltage", None)
|
||||||
|
try:
|
||||||
|
if bv is not None and len(bv) and int(bv[0]):
|
||||||
|
volt_mv = int(bv[0])
|
||||||
|
except Exception:
|
||||||
|
volt_mv = 0
|
||||||
|
if not volt_mv:
|
||||||
|
cv = getattr(msg, "cell_vol", None)
|
||||||
|
if cv is not None:
|
||||||
|
try:
|
||||||
|
volt_mv = int(sum(int(x) for x in cv if x))
|
||||||
|
except Exception:
|
||||||
|
volt_mv = 0
|
||||||
|
# Max plausible pack temperature (int16 °C).
|
||||||
|
temp_c = None
|
||||||
|
tt = getattr(msg, "temperature", None)
|
||||||
|
if tt is not None:
|
||||||
|
try:
|
||||||
|
vals = [int(x) for x in tt if -40 <= int(x) <= 150]
|
||||||
|
if vals:
|
||||||
|
temp_c = max(vals)
|
||||||
|
except Exception:
|
||||||
|
temp_c = None
|
||||||
batt = {
|
batt = {
|
||||||
"soc": max(0, min(100, soc)),
|
"soc": max(0, min(100, soc)),
|
||||||
"current_a": round(cur_mA / 1000.0, 2),
|
"current_a": round(cur_mA / 1000.0, 2),
|
||||||
|
"voltage_v": round(volt_mv / 1000.0, 1) if volt_mv else None,
|
||||||
|
"temp_c": temp_c,
|
||||||
"soh": int(getattr(msg, "soh", 0) or 0),
|
"soh": int(getattr(msg, "soh", 0) or 0),
|
||||||
"cycle": int(getattr(msg, "cycle", 0) or 0),
|
"cycles": int(getattr(msg, "cycle", 0) or 0),
|
||||||
}
|
}
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._bms = batt
|
self._bms = batt
|
||||||
@ -265,7 +346,8 @@ class DDSReader:
|
|||||||
try:
|
try:
|
||||||
# temperature may be a scalar or a small array (surface/winding)
|
# temperature may be a scalar or a small array (surface/winding)
|
||||||
vals = [float(x) for x in t] if hasattr(t, "__iter__") else [float(t)]
|
vals = [float(x) for x in t] if hasattr(t, "__iter__") else [float(t)]
|
||||||
temps.extend(v for v in vals if -40 <= v <= 200)
|
# 0 = slot not reporting (unpopulated motor), not a real temp
|
||||||
|
temps.extend(v for v in vals if 0 < v <= 200)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
dq = getattr(m, "dq", None)
|
dq = getattr(m, "dq", None)
|
||||||
@ -392,7 +474,8 @@ def build_telemetry(cfg: Config, mac: str, reader: Optional[DDSReader],
|
|||||||
pos: Optional[RosbridgePosition],
|
pos: Optional[RosbridgePosition],
|
||||||
sim: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
sim: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||||
if sim is not None:
|
if sim is not None:
|
||||||
snap = {"bms": {"soc": sim["battery"], "current_a": 0.5 if sim["charging"] else -0.3},
|
snap = {"bms": {"soc": sim["battery"], "current_a": 0.5 if sim["charging"] else -0.3,
|
||||||
|
"voltage_v": 47.5, "temp_c": 36, "soh": 100, "cycles": 45},
|
||||||
"bms_age": 0.1, "low_age": 0.1, "temps": [sim.get("temp", 45)], "max_dq": sim.get("max_dq", 0.0)}
|
"bms_age": 0.1, "low_age": 0.1, "temps": [sim.get("temp", 45)], "max_dq": sim.get("max_dq", 0.0)}
|
||||||
fsm = sim.get("fsm")
|
fsm = sim.get("fsm")
|
||||||
else:
|
else:
|
||||||
@ -405,6 +488,18 @@ def build_telemetry(cfg: Config, mac: str, reader: Optional[DDSReader],
|
|||||||
status = derive_status(cfg, snap, fsm)
|
status = derive_status(cfg, snap, fsm)
|
||||||
faults = derive_faults(cfg, snap)
|
faults = derive_faults(cfg, snap)
|
||||||
|
|
||||||
|
# Battery detail (voltage / current / pack temp / health / cycles).
|
||||||
|
battery_detail = None
|
||||||
|
if bms:
|
||||||
|
battery_detail = {"voltage_v": bms.get("voltage_v"), "current_a": bms.get("current_a"),
|
||||||
|
"temp_c": bms.get("temp_c"), "soh": bms.get("soh"),
|
||||||
|
"cycles": bms.get("cycles")}
|
||||||
|
|
||||||
|
# Motor temperature stats; null = temps not receiving.
|
||||||
|
temps = snap.get("temps") or []
|
||||||
|
motor_temp = ({"max": round(max(temps), 1), "avg": round(sum(temps) / len(temps), 1),
|
||||||
|
"min": round(min(temps), 1)} if temps else None)
|
||||||
|
|
||||||
position = None
|
position = None
|
||||||
if sim is not None:
|
if sim is not None:
|
||||||
position = sim.get("position")
|
position = sim.get("position")
|
||||||
@ -413,9 +508,16 @@ def build_telemetry(cfg: Config, mac: str, reader: Optional[DDSReader],
|
|||||||
|
|
||||||
payload: Dict[str, Any] = {
|
payload: Dict[str, Any] = {
|
||||||
"sn": cfg.sn,
|
"sn": cfg.sn,
|
||||||
|
"name": cfg.name, # friendly display name (e.g. r1_82)
|
||||||
"mac": mac,
|
"mac": mac,
|
||||||
|
"brand": cfg.brand,
|
||||||
|
"type": cfg.robot_type, # humanoid | dog
|
||||||
|
"model": cfg.model, # r1 | g1 | go2
|
||||||
"battery": battery, # null = couldn't read (heartbeat)
|
"battery": battery, # null = couldn't read (heartbeat)
|
||||||
"charging": charging,
|
"charging": charging,
|
||||||
|
"battery_detail": battery_detail,
|
||||||
|
"motor_temp": motor_temp, # null = not receiving
|
||||||
|
"storage": read_storage(cfg),
|
||||||
"status": status,
|
"status": status,
|
||||||
"position": position, # null when no localization source
|
"position": position, # null when no localization source
|
||||||
"faults": faults,
|
"faults": faults,
|
||||||
|
|||||||
@ -46,16 +46,24 @@ run_args(){
|
|||||||
-v /home/$USER_/$(rdir_of "$1")/web_data:/data/web_data:ro \
|
-v /home/$USER_/$(rdir_of "$1")/web_data:/data/web_data:ro \
|
||||||
-v /home/$USER_/$(rdir_of "$1")/state:/data/state"
|
-v /home/$USER_/$(rdir_of "$1")/state:/data/state"
|
||||||
else
|
else
|
||||||
echo "$base"
|
# telemetry agents: host root read-only at /host → real disk-usage stats
|
||||||
|
echo "$base -v /:/host:ro"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Resolve the SERVER_URL + VERIFY_TLS for a deploy: a full --server-url (real
|
||||||
|
# HTTPS fleet server, TLS verified) wins; otherwise http://<detected-ip>:<port>
|
||||||
|
# (the local test server, TLS off).
|
||||||
|
server_url(){ [ -n "$SERVER_URL_OVERRIDE" ] && echo "$SERVER_URL_OVERRIDE" || echo "http://$1:$PORT"; }
|
||||||
|
verify_tls(){ [ -n "$VERIFY_TLS_OPT" ] && echo "$VERIFY_TLS_OPT" || { [ -n "$SERVER_URL_OVERRIDE" ] && echo 1 || echo 0; }; }
|
||||||
|
|
||||||
# ---- write the robot-side .env ----
|
# ---- write the robot-side .env ----
|
||||||
push_env(){
|
push_env(){
|
||||||
local t="$1" sip="$2" rdir; rdir="$(rdir_of "$t")"
|
local t="$1" sip="$2" rdir surl vtls; rdir="$(rdir_of "$t")"
|
||||||
|
surl="$(server_url "$sip")"; vtls="$(verify_tls)"
|
||||||
if [ "$t" = g1 ]; then
|
if [ "$t" = g1 ]; then
|
||||||
rmt_in "cat > ~/$rdir/.env" <<EOF
|
rmt_in "cat > ~/$rdir/.env" <<EOF
|
||||||
SERVER_URL=http://$sip:$PORT
|
SERVER_URL=$surl
|
||||||
DEVICE_TOKEN=$TOKEN
|
DEVICE_TOKEN=$TOKEN
|
||||||
SN=$SN
|
SN=$SN
|
||||||
ROBOT=sanad
|
ROBOT=sanad
|
||||||
@ -63,18 +71,35 @@ MAPS_DIR=/data/maps
|
|||||||
DATA_DIR=/data/web_data
|
DATA_DIR=/data/web_data
|
||||||
STATE_DIR=/data/state
|
STATE_DIR=/data/state
|
||||||
MAP_UPLOAD_MODE=multipart
|
MAP_UPLOAD_MODE=multipart
|
||||||
VERIFY_TLS=0
|
VERIFY_TLS=$vtls
|
||||||
POLL_INTERVAL=30
|
POLL_INTERVAL=30
|
||||||
EOF
|
EOF
|
||||||
else
|
else
|
||||||
local iface=eth0; [ "$t" = r1 ] && iface=eth10
|
local iface=eth0; [ "$t" = r1 ] && iface=eth10
|
||||||
|
# identity: brand fixed; type/model from the agent kind; friendly display
|
||||||
|
# name defaults to <model>_<ip-last-octet> (e.g. r1_82, g1_58)
|
||||||
|
local btype=humanoid model="$t"
|
||||||
|
[ "$t" = g1t ] && model=g1
|
||||||
|
[ "$t" = go2 ] && btype=dog
|
||||||
|
local rname="${NAME:-${model}_${IP##*.}}"
|
||||||
|
# optional Sanad data dir (its size is shown on the dashboard) — probe
|
||||||
|
# the known per-robot locations; path is as seen through /host (ro mount)
|
||||||
|
local dpath=""
|
||||||
|
for c in "/home/$USER_/SanadR1/data" "/home/$USER_/sanad_deploy/Sanad_Package_4/data"; do
|
||||||
|
if rmt "test -d $c" 2>/dev/null; then dpath="/host$c"; break; fi
|
||||||
|
done
|
||||||
rmt_in "cat > ~/$rdir/.env" <<EOF
|
rmt_in "cat > ~/$rdir/.env" <<EOF
|
||||||
SERVER_URL=http://$sip:$PORT
|
SERVER_URL=$surl
|
||||||
DEVICE_TOKEN=$TOKEN
|
DEVICE_TOKEN=$TOKEN
|
||||||
SN=$SN
|
SN=$SN
|
||||||
|
ROBOT_NAME=$rname
|
||||||
|
ROBOT_BRAND=unitree
|
||||||
|
ROBOT_TYPE=$btype
|
||||||
|
ROBOT_MODEL=$model
|
||||||
|
STORAGE_DATA_PATH=$dpath
|
||||||
DDS_INTERFACE=$iface
|
DDS_INTERFACE=$iface
|
||||||
DDS_DOMAIN=0
|
DDS_DOMAIN=0
|
||||||
VERIFY_TLS=0
|
VERIFY_TLS=$vtls
|
||||||
POLL_INTERVAL=2
|
POLL_INTERVAL=2
|
||||||
EOF
|
EOF
|
||||||
fi
|
fi
|
||||||
@ -85,9 +110,11 @@ installed(){ # 0 = installed (unit file OR container present)
|
|||||||
}
|
}
|
||||||
|
|
||||||
do_install(){
|
do_install(){
|
||||||
local t="$1" img rdir unit sip; img="$(img_of "$t")"; rdir="$(rdir_of "$t")"; unit="$(unit_of "$t")"
|
local t="$1" img rdir unit sip=""; img="$(img_of "$t")"; rdir="$(rdir_of "$t")"; unit="$(unit_of "$t")"
|
||||||
sip="$(detect_server_ip)"; [ -n "$sip" ] || die "cannot detect server IP toward $IP (use --server-ip)"
|
if [ -z "$SERVER_URL_OVERRIDE" ]; then
|
||||||
echo "== INSTALL $t ($SN) on $IP → fleet server http://$sip:$PORT =="
|
sip="$(detect_server_ip)"; [ -n "$sip" ] || die "cannot detect server IP toward $IP (use --server-ip or --server-url)"
|
||||||
|
fi
|
||||||
|
echo "== INSTALL $t ($SN) on $IP -> fleet server $(server_url "$sip") =="
|
||||||
echo ">> rsync agent -> $USER_@$IP:~/$rdir/"
|
echo ">> rsync agent -> $USER_@$IP:~/$rdir/"
|
||||||
rsync -az --delete --exclude '.env' --exclude '__pycache__' --exclude '*.pyc' \
|
rsync -az --delete --exclude '.env' --exclude '__pycache__' --exclude '*.pyc' \
|
||||||
--exclude 'data/' --exclude 'state/' \
|
--exclude 'data/' --exclude 'state/' \
|
||||||
@ -195,13 +222,17 @@ PY
|
|||||||
}
|
}
|
||||||
|
|
||||||
# --------------------------- arg parsing --------------------------- #
|
# --------------------------- arg parsing --------------------------- #
|
||||||
CMD=""; ROBOT=""; IP=""; SERVER_IP=""; PORT=8799; TOKEN="test-token"; SN=""; USER_="unitree"; KEEP_SERVER=0
|
CMD=""; ROBOT=""; IP=""; SERVER_IP=""; PORT=8799; TOKEN="test-token"; SN=""; NAME=""; USER_="unitree"; KEEP_SERVER=0
|
||||||
|
SERVER_URL_OVERRIDE=""; VERIFY_TLS_OPT=""
|
||||||
POSA=()
|
POSA=()
|
||||||
while [ $# -gt 0 ]; do case "$1" in
|
while [ $# -gt 0 ]; do case "$1" in
|
||||||
--server-ip) SERVER_IP="$2"; shift 2;;
|
--server-ip) SERVER_IP="$2"; shift 2;;
|
||||||
|
--server-url) SERVER_URL_OVERRIDE="$2"; shift 2;; # full URL (real HTTPS fleet server) → VERIFY_TLS=1
|
||||||
|
--verify-tls) VERIFY_TLS_OPT="$2"; shift 2;;
|
||||||
--port) PORT="$2"; shift 2;;
|
--port) PORT="$2"; shift 2;;
|
||||||
--token) TOKEN="$2"; shift 2;;
|
--token) TOKEN="$2"; shift 2;;
|
||||||
--sn) SN="$2"; shift 2;;
|
--sn) SN="$2"; shift 2;;
|
||||||
|
--name) NAME="$2"; shift 2;; # friendly display name (default <model>_<last-octet>)
|
||||||
--user) USER_="$2"; shift 2;;
|
--user) USER_="$2"; shift 2;;
|
||||||
--keep-server) KEEP_SERVER=1; shift;;
|
--keep-server) KEEP_SERVER=1; shift;;
|
||||||
-h|--help) grep -E '^#( |$)' "$0" | sed 's/^# \{0,1\}//'; exit 0;;
|
-h|--help) grep -E '^#( |$)' "$0" | sed 's/^# \{0,1\}//'; exit 0;;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user