Update 2026-07-10 14:10:16

This commit is contained in:
kassam 2026-07-10 14:10:17 +04:00
parent 0bb7d3ed83
commit f638ca1e9e
8 changed files with 396 additions and 25 deletions

View File

@ -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": [],

View File

@ -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=

View File

@ -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()),
}

View File

@ -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=

View File

@ -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()),
}

View File

@ -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=

View File

@ -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,

View File

@ -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;;