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>
|
||||
Content-Type: application/json
|
||||
|
||||
{ "sn": "r1_82",
|
||||
{ "sn": "E39N4000Q6D7E70F", // the robot's REAL Unitree serial
|
||||
"mac": "4c:bb:47:51:25:9a",
|
||||
"battery": 80,
|
||||
"brand": "unitree",
|
||||
"type": "humanoid", // humanoid | dog
|
||||
"model": "r1", // r1 | g1 | go2
|
||||
"battery": 62,
|
||||
"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",
|
||||
"position": { "x": 12.4, "y": 3.1 }, // or null when no localization source
|
||||
"faults": [],
|
||||
|
||||
@ -33,3 +33,11 @@ MOTOR_TEMP_MAX=85
|
||||
POLL_INTERVAL=2
|
||||
VERIFY_TLS=1
|
||||
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 logging
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
@ -85,6 +86,12 @@ class Config:
|
||||
server_url: str
|
||||
device_token: str
|
||||
sn: str
|
||||
name: str
|
||||
brand: str
|
||||
robot_type: str
|
||||
model: str
|
||||
storage_path: str
|
||||
data_path: str
|
||||
dds_interface: str
|
||||
dds_domain: int
|
||||
mac_interface: str
|
||||
@ -109,6 +116,12 @@ class Config:
|
||||
return cls(
|
||||
server_url=server, device_token=token,
|
||||
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")),
|
||||
mac_interface=_env("MAC_INTERFACE", iface),
|
||||
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))
|
||||
|
||||
|
||||
_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:
|
||||
"""Subscribes rt/lf/bmsstate + rt/lowstate (+ rt/lf/odommodestate for position).
|
||||
Passive reads; the only RPC ever issued is GET_FSM_ID."""
|
||||
@ -216,8 +266,40 @@ class DDSReader:
|
||||
try:
|
||||
soc = int(getattr(msg, "soc", 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:
|
||||
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()
|
||||
except Exception:
|
||||
pass
|
||||
@ -231,7 +313,8 @@ class DDSReader:
|
||||
if t is not None:
|
||||
try:
|
||||
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:
|
||||
pass
|
||||
dq = getattr(m, "dq", None)
|
||||
@ -352,7 +435,8 @@ def build_telemetry(cfg: Config, mac: str, reader: Optional[DDSReader],
|
||||
pos: Optional[RosbridgePosition],
|
||||
sim: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
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),
|
||||
"xy": sim.get("position")}
|
||||
fsm = sim.get("fsm")
|
||||
@ -366,13 +450,34 @@ def build_telemetry(cfg: Config, mac: str, reader: Optional[DDSReader],
|
||||
status = derive_status(cfg, snap, fsm)
|
||||
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")
|
||||
if position is None and pos is not None:
|
||||
position = pos.get()
|
||||
|
||||
return {
|
||||
"sn": cfg.sn, "mac": mac,
|
||||
"battery": battery, "charging": charging, "status": status,
|
||||
"sn": cfg.sn,
|
||||
"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()),
|
||||
}
|
||||
|
||||
|
||||
@ -27,3 +27,11 @@ MOTOR_TEMP_MAX=85
|
||||
POLL_INTERVAL=2
|
||||
VERIFY_TLS=1
|
||||
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 logging
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
@ -78,6 +79,12 @@ class Config:
|
||||
server_url: str
|
||||
device_token: str
|
||||
sn: str
|
||||
name: str
|
||||
brand: str
|
||||
robot_type: str
|
||||
model: str
|
||||
storage_path: str
|
||||
data_path: str
|
||||
dds_interface: str
|
||||
dds_domain: int
|
||||
mac_interface: str
|
||||
@ -101,6 +108,12 @@ class Config:
|
||||
return cls(
|
||||
server_url=server, device_token=token,
|
||||
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")),
|
||||
mac_interface=_env("MAC_INTERFACE", iface),
|
||||
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))
|
||||
|
||||
|
||||
_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:
|
||||
"""Subscribes rt/lowstate (unitree_go LowState_) and (optionally) sportmodestate.
|
||||
Battery comes from the nested LowState_.bms_state. Passive reads only."""
|
||||
@ -181,8 +231,36 @@ class DDSReader:
|
||||
if bms is not None:
|
||||
soc = int(getattr(bms, "soc", 0) or 0)
|
||||
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:
|
||||
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] = []
|
||||
max_dq = 0.0
|
||||
for m in (getattr(msg, "motor_state", None) or []):
|
||||
@ -190,7 +268,8 @@ class DDSReader:
|
||||
if t is not None:
|
||||
try:
|
||||
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:
|
||||
pass
|
||||
dq = getattr(m, "dq", None)
|
||||
@ -296,7 +375,8 @@ def build_telemetry(cfg: Config, mac: str, reader: Optional[DDSReader],
|
||||
pos: Optional[RosbridgePosition],
|
||||
sim: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
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),
|
||||
"xy": sim.get("position")}
|
||||
else:
|
||||
@ -308,13 +388,34 @@ def build_telemetry(cfg: Config, mac: str, reader: Optional[DDSReader],
|
||||
status = derive_status(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")
|
||||
if position is None and pos is not None:
|
||||
position = pos.get()
|
||||
|
||||
return {
|
||||
"sn": cfg.sn, "mac": mac,
|
||||
"battery": battery, "charging": charging, "status": status,
|
||||
"sn": cfg.sn,
|
||||
"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()),
|
||||
}
|
||||
|
||||
|
||||
@ -33,3 +33,11 @@ MOTOR_TEMP_MAX=85
|
||||
POLL_INTERVAL=2
|
||||
VERIFY_TLS=1
|
||||
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 math
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
@ -104,6 +105,12 @@ class Config:
|
||||
server_url: str
|
||||
device_token: str
|
||||
sn: str
|
||||
name: str
|
||||
brand: str
|
||||
robot_type: str
|
||||
model: str
|
||||
storage_path: str
|
||||
data_path: str
|
||||
dds_interface: str
|
||||
dds_domain: int
|
||||
mac_interface: str
|
||||
@ -129,6 +136,12 @@ class Config:
|
||||
server_url=server,
|
||||
device_token=token,
|
||||
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_domain=int(_env("DDS_DOMAIN", "0")),
|
||||
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))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 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)
|
||||
# --------------------------------------------------------------------------- #
|
||||
@ -242,11 +296,38 @@ class DDSReader:
|
||||
try:
|
||||
soc = int(getattr(msg, "soc", 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 = {
|
||||
"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),
|
||||
"cycle": int(getattr(msg, "cycle", 0) or 0),
|
||||
"cycles": int(getattr(msg, "cycle", 0) or 0),
|
||||
}
|
||||
with self._lock:
|
||||
self._bms = batt
|
||||
@ -265,7 +346,8 @@ class DDSReader:
|
||||
try:
|
||||
# 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)]
|
||||
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:
|
||||
pass
|
||||
dq = getattr(m, "dq", None)
|
||||
@ -392,7 +474,8 @@ def build_telemetry(cfg: Config, mac: str, reader: Optional[DDSReader],
|
||||
pos: Optional[RosbridgePosition],
|
||||
sim: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
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)}
|
||||
fsm = sim.get("fsm")
|
||||
else:
|
||||
@ -405,6 +488,18 @@ def build_telemetry(cfg: Config, mac: str, reader: Optional[DDSReader],
|
||||
status = derive_status(cfg, snap, fsm)
|
||||
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
|
||||
if sim is not None:
|
||||
position = sim.get("position")
|
||||
@ -413,9 +508,16 @@ def build_telemetry(cfg: Config, mac: str, reader: Optional[DDSReader],
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"sn": cfg.sn,
|
||||
"name": cfg.name, # friendly display name (e.g. r1_82)
|
||||
"mac": mac,
|
||||
"brand": cfg.brand,
|
||||
"type": cfg.robot_type, # humanoid | dog
|
||||
"model": cfg.model, # r1 | g1 | go2
|
||||
"battery": battery, # null = couldn't read (heartbeat)
|
||||
"charging": charging,
|
||||
"battery_detail": battery_detail,
|
||||
"motor_temp": motor_temp, # null = not receiving
|
||||
"storage": read_storage(cfg),
|
||||
"status": status,
|
||||
"position": position, # null when no localization source
|
||||
"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")/state:/data/state"
|
||||
else
|
||||
echo "$base"
|
||||
# telemetry agents: host root read-only at /host → real disk-usage stats
|
||||
echo "$base -v /:/host:ro"
|
||||
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 ----
|
||||
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
|
||||
rmt_in "cat > ~/$rdir/.env" <<EOF
|
||||
SERVER_URL=http://$sip:$PORT
|
||||
SERVER_URL=$surl
|
||||
DEVICE_TOKEN=$TOKEN
|
||||
SN=$SN
|
||||
ROBOT=sanad
|
||||
@ -63,18 +71,35 @@ MAPS_DIR=/data/maps
|
||||
DATA_DIR=/data/web_data
|
||||
STATE_DIR=/data/state
|
||||
MAP_UPLOAD_MODE=multipart
|
||||
VERIFY_TLS=0
|
||||
VERIFY_TLS=$vtls
|
||||
POLL_INTERVAL=30
|
||||
EOF
|
||||
else
|
||||
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
|
||||
SERVER_URL=http://$sip:$PORT
|
||||
SERVER_URL=$surl
|
||||
DEVICE_TOKEN=$TOKEN
|
||||
SN=$SN
|
||||
ROBOT_NAME=$rname
|
||||
ROBOT_BRAND=unitree
|
||||
ROBOT_TYPE=$btype
|
||||
ROBOT_MODEL=$model
|
||||
STORAGE_DATA_PATH=$dpath
|
||||
DDS_INTERFACE=$iface
|
||||
DDS_DOMAIN=0
|
||||
VERIFY_TLS=0
|
||||
VERIFY_TLS=$vtls
|
||||
POLL_INTERVAL=2
|
||||
EOF
|
||||
fi
|
||||
@ -85,9 +110,11 @@ installed(){ # 0 = installed (unit file OR container present)
|
||||
}
|
||||
|
||||
do_install(){
|
||||
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)"
|
||||
echo "== INSTALL $t ($SN) on $IP → fleet server http://$sip:$PORT =="
|
||||
local t="$1" img rdir unit sip=""; img="$(img_of "$t")"; rdir="$(rdir_of "$t")"; unit="$(unit_of "$t")"
|
||||
if [ -z "$SERVER_URL_OVERRIDE" ]; then
|
||||
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/"
|
||||
rsync -az --delete --exclude '.env' --exclude '__pycache__' --exclude '*.pyc' \
|
||||
--exclude 'data/' --exclude 'state/' \
|
||||
@ -195,13 +222,17 @@ PY
|
||||
}
|
||||
|
||||
# --------------------------- 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=()
|
||||
while [ $# -gt 0 ]; do case "$1" in
|
||||
--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;;
|
||||
--token) TOKEN="$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;;
|
||||
--keep-server) KEEP_SERVER=1; shift;;
|
||||
-h|--help) grep -E '^#( |$)' "$0" | sed 's/^# \{0,1\}//'; exit 0;;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user