Update 2026-07-13 10:28:18

This commit is contained in:
kassam 2026-07-13 10:28:20 +04:00
parent f638ca1e9e
commit e45a03159b
18 changed files with 922 additions and 967 deletions

View File

@ -192,15 +192,29 @@ Flow:
### Options
**`install` requires the essentials to be entered explicitly** — the robot's real
serial, the token, and the server:
```bash
./fleet_install.sh install <type> <ip> --sn <robot-serial> --token <device-token> \
--server-url https://eco.yslootahrobotics.com [--post <ingest-path>] [--name <display>]
```
| option | default | meaning |
|---|---|---|
| `--sn NAME` | `<type>_<last-octet>` | robot's fleet id (the `sn` field) |
| `--server-ip IP` | auto (route toward robot) | fleet server the robot posts to |
| `--port N` | `8799` | fleet server port |
| `--sn SERIAL` | **required for install** | robot's REAL serial (keys the robot on the server), e.g. `E39N4000Q6D7E70F` |
| `--name NAME` | `<model>_<last-octet>` | friendly display name (e.g. `r1_82`, `g1_58`) |
| `--token TOK` | `test-token` | device bearer token |
| `--server-url URL` | — | full fleet server URL (`https://…`) → `VERIFY_TLS=1` |
| `--post PATH` | agent default | ingest POST path (telemetry endpoint; map endpoint for `g1`) |
| `--server-ip IP` | auto (route toward robot) | alternative: local test server by IP (`VERIFY_TLS=0`) |
| `--port N` | `8799` | test-server port (with `--server-ip`) |
| `--user USER` | `unitree` | SSH user on the robot |
| `--keep-server` | off | (test) leave the workstation test server running |
The interactive flow prompts for the same set: robot type → IP → **SN (required)**
→ display name → SERVER_URL-or-IP → token → POST endpoint.
---
## 7. The auto-start service (systemd)

View File

@ -1,19 +1,46 @@
# sanad_api_g1 — G1 fleet MAP uploader. Self-contained, no ROS.
# sanad_api_g1 — G1 fleet agent: TELEMETRY + MAP sync (DDS + web_nav3 map files).
#
# BUILD ON THE G1 JETSON (native arm64). DDS stack: prebuilt CycloneDDS (apt) +
# the CycloneDDS python binding (pip) + the vendored unitree_sdk2py wheel. Run with
# --network host so the robot's DDS is visible (see docker-compose.yml).
FROM python:3.10-slim-bookworm
# ca-certificates so outbound HTTPS to the fleet server validates.
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
# CycloneDDS C lib + idlc (bookworm ships 0.10.2 — matches the robot), build tools
# for the python binding, iproute2 for iface checks, libgomp1 for the TLS guard.
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential cmake \
cyclonedds-dev cyclonedds-tools \
iproute2 libgomp1 ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# aarch64 "static TLS block" guard (harmless on amd64).
ENV LD_PRELOAD=libgomp.so.1
# cyclonedds build helper looks for libddsc.so under $CYCLONEDDS_HOME/lib; Debian
# installs into the multiarch dir — symlink whatever arch built into /usr/lib.
ENV CYCLONEDDS_HOME=/usr
RUN set -e; for lib in libddsc.so libcycloneddsidl.so; do \
f=$(ls /usr/lib/*/"$lib" 2>/dev/null | head -1); \
[ -n "$f" ] && ln -sf "$f" /usr/lib/"$lib" || true; \
done
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# CycloneDDS python binding, compiled against the C lib above. 0.10.2's build
# helper imports wheel.bdist_wheel (removed in wheel>=0.46) → pin the toolchain.
RUN pip install --no-cache-dir "setuptools<80" "wheel<0.46" \
&& pip install --no-cache-dir --no-build-isolation cyclonedds==0.10.2
# Unitree SDK (vendored wheel — not on PyPI) + native crc lib (wheel omits it).
# Both arch crc libs are copied; unitree_sdk2py loads the one matching the image.
COPY vendor/unitree_sdk2py-*.whl /tmp/
RUN pip install --no-cache-dir --no-deps /tmp/unitree_sdk2py-*.whl
COPY vendor/crc_aarch64.so vendor/crc_amd64.so \
/usr/local/lib/python3.10/site-packages/unitree_sdk2py/utils/lib/
COPY sanad_api_g1.py .
# Writable state (upload fingerprints) — override with a volume in compose.
ENV STATE_DIR=/data/state
RUN mkdir -p /data/state
# Run as the loop by default; override the CMD for --once / --list / --dry-run.
ENV PYTHONUNBUFFERED=1 \
DDS_INTERFACE=eth0 \
DDS_DOMAIN=0
ENTRYPOINT ["python", "-u", "sanad_api_g1.py"]

View File

@ -1,2 +1,7 @@
# sanad_api_g1 map uploader — deliberately tiny (no ROS, no DDS).
# sanad_api_r1 telemetry agent.
requests>=2.31,<3
# only needed if R1_POSITION_SOURCE=rosbridge (reads /odom for position):
websocket-client>=1.6,<2
# DDS stack (cyclonedds + the vendored unitree_sdk2py wheel) is installed by the
# Dockerfile, not from here — see Dockerfile. The agent degrades to heartbeats if
# unitree_sdk2py is unavailable.

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +0,0 @@
.env
__pycache__/
*.pyc
README.md

View File

@ -1,43 +0,0 @@
# sanad_api_g1t — copy to .env and fill in. (G1 telemetry; sibling of the G1 map agent.)
# ── fleet server (REQUIRED — YS Lootah gives you these two) ──────────────────
SERVER_URL=https://fleet.example.com
DEVICE_TOKEN=REPLACE_WITH_DEVICE_TOKEN
# ── identity ─────────────────────────────────────────────────────────────────
# Use the SAME sn as the G1 map agent — it's the same physical robot.
SN=g1_7892
# ── DDS (reading robot state — unitree_hg, same family as R1) ────────────────
# G1 DDS link is usually eth0.
DDS_INTERFACE=eth0
DDS_DOMAIN=0
# MAC_INTERFACE=eth0
# ── status / position ────────────────────────────────────────────────────────
# Read the loco FSM for a richer status (READ-ONLY GET RPC, never moves).
# 0 = derive status from battery + joint motion (safe default).
G1_READ_FSM=0
# Position source:
# odom (default) — firmware leg-odometry x,y from rt/lf/odommodestate (drifts, resets at boot)
# rosbridge — map-frame /odom over rosbridge (needs the nav bringup + ROSBRIDGE_URL)
# none — omit position
G1_POSITION_SOURCE=odom
ROSBRIDGE_URL=ws://127.0.0.1:9090
# ── fault thresholds ─────────────────────────────────────────────────────────
LOW_SOC=15
MOTOR_TEMP_MAX=85
# ── cadence / transport ──────────────────────────────────────────────────────
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

@ -1,3 +0,0 @@
.env
__pycache__/
*.pyc

View File

@ -1,46 +0,0 @@
# sanad_api_g1t — G1 fleet telemetry agent (DDS: rt/lf/bmsstate + rt/lowstate + odom).
#
# BUILD ON THE G1 JETSON (native arm64). DDS stack: prebuilt CycloneDDS (apt) +
# the CycloneDDS python binding (pip) + the vendored unitree_sdk2py wheel. Run with
# --network host so the robot's DDS is visible (see docker-compose.yml).
FROM python:3.10-slim-bookworm
# CycloneDDS C lib + idlc (bookworm ships 0.10.2 — matches the robot), build tools
# for the python binding, iproute2 for iface checks, libgomp1 for the TLS guard.
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential cmake \
cyclonedds-dev cyclonedds-tools \
iproute2 libgomp1 ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# aarch64 "static TLS block" guard (harmless on amd64).
ENV LD_PRELOAD=libgomp.so.1
# cyclonedds build helper looks for libddsc.so under $CYCLONEDDS_HOME/lib; Debian
# installs into the multiarch dir — symlink whatever arch built into /usr/lib.
ENV CYCLONEDDS_HOME=/usr
RUN set -e; for lib in libddsc.so libcycloneddsidl.so; do \
f=$(ls /usr/lib/*/"$lib" 2>/dev/null | head -1); \
[ -n "$f" ] && ln -sf "$f" /usr/lib/"$lib" || true; \
done
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# CycloneDDS python binding, compiled against the C lib above. 0.10.2's build
# helper imports wheel.bdist_wheel (removed in wheel>=0.46) → pin the toolchain.
RUN pip install --no-cache-dir "setuptools<80" "wheel<0.46" \
&& pip install --no-cache-dir --no-build-isolation cyclonedds==0.10.2
# Unitree SDK (vendored wheel — not on PyPI) + native crc lib (wheel omits it).
# Both arch crc libs are copied; unitree_sdk2py loads the one matching the image.
COPY vendor/unitree_sdk2py-*.whl /tmp/
RUN pip install --no-cache-dir --no-deps /tmp/unitree_sdk2py-*.whl
COPY vendor/crc_aarch64.so vendor/crc_amd64.so \
/usr/local/lib/python3.10/site-packages/unitree_sdk2py/utils/lib/
COPY sanad_api_g1t.py .
ENV PYTHONUNBUFFERED=1 \
DDS_INTERFACE=eth0 \
DDS_DOMAIN=0
ENTRYPOINT ["python", "-u", "sanad_api_g1t.py"]

View File

@ -1,40 +0,0 @@
# sanad_api_g1t — G1 fleet **telemetry** agent
Streams the Unitree **G1**'s live status to the YS Lootah fleet server. It's the
**telemetry** sibling of the G1 **map** agent (`../g1`) — the G1 runs both, each
its own container/service, both keyed by the **same `sn`** (same physical robot).
```
POST {SERVER_URL}/api/v1/fleet/ingest/telemetry Authorization: Bearer <device_token>
{ "sn":"g1_7892", "mac":"…", "battery":74, "charging":false,
"status":"idle", "position":{"x":…,"y":…}|null, "faults":[], "ts":… }
```
Every ~2 s; a **heartbeat** (`battery:null, status:offline`) when DDS is silent.
## Data sources (G1, `unitree_hg` DDS — same family as R1)
| field | source |
|---|---|
| `battery`, `charging` | `rt/lf/bmsstate` (`BmsState_`): `soc` 0100; charging from `current` sign |
| `faults[]` | `rt/lowstate` (`LowState_`): motor temps + staleness |
| `position` `{x,y}` | **`rt/lf/odommodestate`** (`SportModeState_`) — firmware leg-odometry over DDS, no ROS (odom frame: drifts, resets at boot). Optional map-frame via `rosbridge`. |
| `status` | derived (charging/moving/idle/offline); optional loco **FSM** (`G1_READ_FSM=1`, ids **200** ready / 4 stand / 2 squat / 702 lie2stand — GET-only) |
| `mac` | primary NIC |
> **Safety:** read-only — never commands motion.
## Run / test
```bash
# via the fleet installer (adds a 'g1t' type):
../../fleet_install.sh install g1t <ip> --sn g1_7892
../../fleet_install.sh data g1t <ip>
# bare simulate (no robot):
SERVER_URL=… DEVICE_TOKEN=… SN=g1_7892 python3 sanad_api_g1t.py --simulate -v
```
Build on the G1 Jetson (arm64); bundles CycloneDDS + the vendored `unitree_sdk2py`
wheel (`vendor/`). `network_mode: host` for DDS. Coexists with the map agent and
with a running Sanad (DDS allows many readers of the same topics).

View File

@ -1,20 +0,0 @@
# sanad_api_g1t — standalone G1 fleet TELEMETRY agent.
# (The fleet installer deploys via docker build/run + systemd; this compose is
# for manual local use.)
#
# cp .env.example .env # SERVER_URL + DEVICE_TOKEN + SN + DDS_INTERFACE
# docker compose up -d --build
#
# Reads the G1's DDS state (rt/lf/bmsstate + rt/lowstate + rt/lf/odommodestate)
# and POSTs telemetry every ~2s. network_mode: host is REQUIRED for DDS.
services:
sanad-api-g1t:
build:
context: .
dockerfile: Dockerfile
image: "${SANAD_API_G1T_IMAGE:-sanad-api-g1t:latest}"
container_name: sanad-api-g1t
network_mode: host
restart: unless-stopped
env_file: .env
# command: ["--simulate"] # synthetic state, no robot

View File

@ -1,7 +0,0 @@
# sanad_api_r1 telemetry agent.
requests>=2.31,<3
# only needed if R1_POSITION_SOURCE=rosbridge (reads /odom for position):
websocket-client>=1.6,<2
# DDS stack (cyclonedds + the vendored unitree_sdk2py wheel) is installed by the
# Dockerfile, not from here — see Dockerfile. The agent degrades to heartbeats if
# unitree_sdk2py is unavailable.

View File

@ -1,572 +0,0 @@
#!/usr/bin/env python3
"""sanad_api_g1t — G1 fleet TELEMETRY agent.
Pushes the Unitree **G1**'s live status to the YS Lootah fleet server:
POST {SERVER_URL}/api/v1/fleet/ingest/telemetry (Bearer device token)
body: { "sn", "mac", "battery", "charging", "status", "position":{x,y}, "faults":[] }
This is the telemetry sibling of the G1 MAP uploader (sanad_api_g1) the G1 runs
both, each its own container/service, both keyed by the SAME `sn` (same robot).
DATA SOURCES (Unitree G1, unitree_hg DDS same family as the R1)
----------------------------------------------------------------
battery / charging : rt/lf/bmsstate (BmsState_) soc 0-100; charging = current>+0.05A
faults / liveness : rt/lowstate (LowState_) motor temps + message staleness
position {x,y} : rt/lf/odommodestate (SportModeState_, unitree_go idl)
firmware leg-odometry x,y over DDS, no ROS. Odom frame:
resets at boot, drifts. (Map-frame via rosbridge/TF optional.)
status : G1 loco FSM via GET RPC 7001 (ids 200 walk-ready / 4 stand /
2 squat / 702 lie2stand) READ-ONLY, optional (G1_READ_FSM=1).
Default derives status from BMS + motion.
mac : primary NIC hardware address.
NO ROS. DDS via unitree_sdk2py. Degrades to heartbeats if unitree_sdk2py is
unavailable; --simulate tests the upload path without a robot. Read-only: never
commands motion (only the GET_FSM_ID RPC is ever issued).
CONFIG environment (see .env.example)
---------------------------------------
SERVER_URL, DEVICE_TOKEN fleet base URL + bearer token (required)
SN this robot's fleet id (SAME as the G1 map agent) default g1_0000
DDS_INTERFACE robot network iface for DDS default eth0
DDS_DOMAIN DDS domain id default 0
MAC_INTERFACE iface whose MAC to report default = DDS_INTERFACE
G1_READ_FSM 1 = read loco FSM for status default 0
G1_POSITION_SOURCE odom | rosbridge | none default odom
ROSBRIDGE_URL ws://127.0.0.1:9090 (position) default ws://127.0.0.1:9090
LOW_SOC / MOTOR_TEMP_MAX fault thresholds default 15 / 85
POLL_INTERVAL seconds between posts default 2
VERIFY_TLS / HTTP_TIMEOUT TLS verify (1) / timeout (10)
CLI: --simulate | --once | --dry-run | --interval N | -v
"""
from __future__ import annotations
import argparse
import json
import logging
import os
import shutil
import sys
import threading
import time
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional
import requests
log = logging.getLogger("sanad_api_g1t")
def _load_dotenv(path: str = ".env") -> None:
p = Path(path)
if not p.exists():
return
for line in p.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, _, v = line.partition("=")
os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
def _env(name: str, default: str = "") -> str:
return os.environ.get(name, default).strip()
def _env_bool(name: str, default: bool) -> bool:
return _env(name, "1" if default else "0").lower() in ("1", "true", "yes", "on")
@dataclass
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
read_fsm: bool
position_source: str
rosbridge_url: str
low_soc: int
motor_temp_max: float
poll_interval: float
endpoint: str
verify_tls: bool
http_timeout: float
@classmethod
def from_env(cls) -> "Config":
server = _env("SERVER_URL").rstrip("/")
token = _env("DEVICE_TOKEN")
missing = [n for n, v in (("SERVER_URL", server), ("DEVICE_TOKEN", token)) if not v]
if missing:
raise SystemExit(f"[config] missing required env: {', '.join(missing)}")
iface = _env("DDS_INTERFACE", "eth0")
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),
position_source=_env("G1_POSITION_SOURCE", "odom").lower(),
rosbridge_url=_env("ROSBRIDGE_URL", "ws://127.0.0.1:9090"),
low_soc=int(_env("LOW_SOC", "15")),
motor_temp_max=float(_env("MOTOR_TEMP_MAX", "85")),
poll_interval=float(_env("POLL_INTERVAL", "2")),
endpoint=_env("TELEMETRY_ENDPOINT", "/api/v1/fleet/ingest/telemetry"),
verify_tls=_env_bool("VERIFY_TLS", True),
http_timeout=float(_env("HTTP_TIMEOUT", "10")),
)
def telemetry_url(self) -> str:
return self.server_url + self.endpoint
def auth_headers(self) -> Dict[str, str]:
return {"Authorization": f"Bearer {self.device_token}"}
def read_mac(interface: str) -> str:
p = Path(f"/sys/class/net/{interface}/address")
try:
mac = p.read_text().strip()
if mac and mac != "00:00:00:00:00:00":
return mac.lower()
except Exception:
pass
n = uuid.getnode()
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."""
def __init__(self, cfg: Config):
self.cfg = cfg
self._lock = threading.Lock()
self._bms: Optional[Dict[str, Any]] = None
self._bms_ts = 0.0
self._low_ts = 0.0
self._temps: List[float] = []
self._max_dq = 0.0
self._xy: Optional[Dict[str, float]] = None
self._loco = None
self.ok = False
self._start()
def _start(self) -> None:
try:
from unitree_sdk2py.core.channel import (
ChannelFactoryInitialize, ChannelSubscriber)
from unitree_sdk2py.idl.unitree_hg.msg.dds_ import LowState_
try:
from unitree_sdk2py.idl.unitree_hg.msg.dds_ import BmsState_
except Exception:
BmsState_ = None
SportModeState_ = None
if self.cfg.position_source == "odom":
try:
# G1 firmware publishes odom as unitree_go SportModeState_.
from unitree_sdk2py.idl.unitree_go.msg.dds_ import SportModeState_
except Exception:
SportModeState_ = None
except Exception as e:
log.warning("unitree_sdk2py unavailable (%s) — telemetry runs in heartbeat mode", e)
return
try:
ChannelFactoryInitialize(self.cfg.dds_domain, self.cfg.dds_interface)
self._low_sub = ChannelSubscriber("rt/lowstate", LowState_)
self._low_sub.Init(self._on_low, 10)
if BmsState_ is not None:
self._bms_sub = ChannelSubscriber("rt/lf/bmsstate", BmsState_)
self._bms_sub.Init(self._on_bms, 10)
else:
log.warning("BmsState_ not in this unitree_sdk2py — battery will be null")
if SportModeState_ is not None:
self._odom_sub = ChannelSubscriber("rt/lf/odommodestate", SportModeState_)
self._odom_sub.Init(self._on_odom, 10)
if self.cfg.read_fsm:
self._init_loco()
self.ok = True
log.info("DDS up: domain=%d iface=%s (bmsstate + lowstate%s)",
self.cfg.dds_domain, self.cfg.dds_interface,
" + odom" if SportModeState_ is not None else "")
except Exception as e:
log.warning("DDS init failed (%s) — heartbeat mode", e)
def _init_loco(self) -> None:
try:
from unitree_sdk2py.rpc.client import Client # type: ignore
except Exception as e:
log.warning("loco RPC client unavailable (%s) — status from BMS/motion only", e)
return
try:
c = Client("loco", 0); c.Init(); c.SetTimeout(3.0)
self._loco = c
log.info("loco FSM read enabled (GET-only, no motion)")
except Exception as e:
log.warning("loco client init failed (%s) — status from BMS/motion only", e)
self._loco = None
def _on_bms(self, msg) -> None:
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),
"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
def _on_low(self, msg) -> None:
try:
temps: List[float] = []
max_dq = 0.0
for m in (getattr(msg, "motor_state", None) or []):
t = getattr(m, "temperature", None)
if t is not None:
try:
vals = [float(x) for x in t] if hasattr(t, "__iter__") else [float(t)]
# 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)
if dq is not None:
try:
max_dq = max(max_dq, abs(float(dq)))
except Exception:
pass
with self._lock:
self._low_ts = time.monotonic()
self._temps = temps
self._max_dq = max_dq
except Exception:
pass
def _on_odom(self, msg) -> None:
try:
pos = getattr(msg, "position", None)
if pos is not None and len(pos) >= 2:
with self._lock:
self._xy = {"x": round(float(pos[0]), 3), "y": round(float(pos[1]), 3)}
except Exception:
pass
def snapshot(self) -> Dict[str, Any]:
with self._lock:
now = time.monotonic()
return {
"bms": dict(self._bms) if self._bms else None,
"low_age": (now - self._low_ts) if self._low_ts else None,
"temps": list(self._temps),
"max_dq": self._max_dq,
"xy": dict(self._xy) if self._xy else None,
}
def fsm_id(self) -> Optional[int]:
if not self._loco:
return None
try:
code, data = self._loco._Call(7001, "{}") # GET_FSM_ID — read-only
if code == 0 and data:
return int(json.loads(data).get("data", data)) if data.strip().startswith("{") else int(data)
except Exception as e:
log.debug("fsm read failed: %s", e)
return None
# G1 FSM ids (differ from the R1's): 200 balance/walk-ready, 4 StandUp, 2 Squat, 702 Lie2Stand.
_FSM_STATUS = {200: "ready", 4: "standing", 2: "squat", 702: "lie2stand"}
class RosbridgePosition:
def __init__(self, cfg: Config):
self.cfg = cfg
self._xy: Optional[Dict[str, float]] = None
self._lock = threading.Lock()
self._stop = False
try:
import websocket # noqa: F401
except Exception as e:
log.warning("websocket-client absent (%s) — rosbridge position disabled", e)
self._ok = False
return
self._ok = True
threading.Thread(target=self._run, daemon=True).start()
def _run(self) -> None:
import websocket
sub = json.dumps({"op": "subscribe", "topic": "/odom",
"type": "nav_msgs/Odometry", "throttle_rate": 500})
while not self._stop:
try:
ws = websocket.create_connection(self.cfg.rosbridge_url, timeout=5)
ws.send(sub)
while not self._stop:
msg = json.loads(ws.recv())
pos = (((msg.get("msg") or {}).get("pose") or {}).get("pose") or {}).get("position")
if pos:
with self._lock:
self._xy = {"x": round(float(pos["x"]), 3), "y": round(float(pos["y"]), 3)}
except Exception as e:
log.debug("rosbridge position reconnect: %s", e)
time.sleep(3)
def get(self) -> Optional[Dict[str, float]]:
with self._lock:
return dict(self._xy) if self._xy else None
def derive_faults(cfg: Config, snap: Dict[str, Any]) -> List[Dict[str, Any]]:
faults: List[Dict[str, Any]] = []
bms = snap.get("bms")
if bms and bms.get("soc", 100) <= cfg.low_soc:
faults.append({"code": "LOW_BATTERY", "severity": "warning", "message": f"battery {bms['soc']}%"})
temps = snap.get("temps") or []
if temps and max(temps) >= cfg.motor_temp_max:
faults.append({"code": "MOTOR_OVERTEMP", "severity": "warning", "message": f"motor temp {max(temps):.0f}C"})
if snap.get("low_age") is not None and snap["low_age"] > 3.0:
faults.append({"code": "COMMS_STALE", "severity": "critical", "message": f"no rt/lowstate for {snap['low_age']:.0f}s"})
return faults
def derive_status(cfg: Config, snap: Dict[str, Any], fsm: Optional[int]) -> str:
base = _FSM_STATUS.get(fsm) if fsm is not None else None
bms = snap.get("bms")
charging = bool(bms and bms.get("current_a", 0.0) > 0.05)
alive = snap.get("low_age") is not None and snap["low_age"] <= 3.0
if not alive and bms is None:
return "offline"
if charging:
return "charging"
if snap.get("max_dq", 0.0) > 0.15:
return "moving"
return base or "idle"
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,
"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")
else:
snap = reader.snapshot() if reader else {"bms": None, "low_age": None, "temps": [], "max_dq": 0.0, "xy": None}
fsm = reader.fsm_id() if (reader and cfg.read_fsm) else None
bms = snap.get("bms")
battery = bms["soc"] if bms else None
charging = bool(bms and bms.get("current_a", 0.0) > 0.05)
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,
"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()),
}
def post_telemetry(cfg: Config, payload: Dict[str, Any], session: requests.Session) -> bool:
try:
r = session.post(cfg.telemetry_url(), json=payload, headers=cfg.auth_headers(),
timeout=cfg.http_timeout, verify=cfg.verify_tls)
except requests.RequestException as e:
log.error("telemetry POST failed (transport): %s", e)
return False
if not r.ok:
log.error("telemetry POST failed: HTTP %s %s", r.status_code, r.text[:200])
return False
log.info("telemetry ok: battery=%s charging=%s status=%s pos=%s faults=%d -> HTTP %s",
payload["battery"], payload["charging"], payload["status"],
payload["position"], len(payload["faults"]), r.status_code)
return True
def _sim_state(i: int) -> Dict[str, Any]:
charging = (i % 6) in (0, 1)
battery = max(5, 90 - (i % 40))
moving = (i % 3) == 2 and not charging
return {"battery": battery, "charging": charging, "temp": 45 + (i % 10),
"max_dq": 0.4 if moving else 0.0, "fsm": 200 if moving else 4,
"position": {"x": round(1.0 + 0.1 * i, 2), "y": round(2.0 - 0.05 * i, 2)}}
def main(argv: Optional[List[str]] = None) -> int:
ap = argparse.ArgumentParser(description="G1 fleet telemetry agent")
ap.add_argument("--simulate", action="store_true")
ap.add_argument("--once", action="store_true")
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--interval", type=float, default=None)
ap.add_argument("-v", "--verbose", action="store_true")
args = ap.parse_args(argv)
logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s")
_load_dotenv()
cfg = Config.from_env()
if args.interval is not None:
cfg.poll_interval = args.interval
mac = read_mac(cfg.mac_interface)
log.info("sanad_api_g1t telemetry — sn=%s mac=%s server=%s iface=%s domain=%d pos=%s%s",
cfg.sn, mac, cfg.server_url, cfg.dds_interface, cfg.dds_domain,
cfg.position_source, " [SIMULATE]" if args.simulate else "")
reader = None
pos = None
if not args.simulate:
reader = DDSReader(cfg)
if cfg.position_source == "rosbridge":
pos = RosbridgePosition(cfg)
time.sleep(1.0)
session = requests.Session()
tick = 0
def one() -> None:
nonlocal tick
sim = _sim_state(tick) if args.simulate else None
payload = build_telemetry(cfg, mac, reader, pos, sim=sim)
if args.dry_run:
log.info("[dry-run] %s", json.dumps(payload))
else:
post_telemetry(cfg, payload, session)
tick += 1
if args.once:
one(); return 0
if args.dry_run:
for _ in range(3):
one(); time.sleep(min(cfg.poll_interval, 1.0))
return 0
log.info("loop every %.1fs (Ctrl-C to stop)", cfg.poll_interval)
while True:
try:
one()
except Exception as e:
log.exception("tick failed: %s", e)
try:
time.sleep(cfg.poll_interval)
except KeyboardInterrupt:
log.info("stopped"); return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -346,15 +346,16 @@ class RosbridgePosition:
def derive_faults(cfg: Config, snap: Dict[str, Any]) -> List[Dict[str, Any]]:
faults: List[Dict[str, Any]] = []
# STRINGS, not objects — the fleet ingest 500s on fault objects.
faults: List[str] = []
bms = snap.get("bms")
if bms and bms.get("soc", 100) <= cfg.low_soc:
faults.append({"code": "LOW_BATTERY", "severity": "warning", "message": f"battery {bms['soc']}%"})
faults.append(f"LOW_BATTERY: battery {bms['soc']}% (warning)")
temps = snap.get("temps") or []
if temps and max(temps) >= cfg.motor_temp_max:
faults.append({"code": "MOTOR_OVERTEMP", "severity": "warning", "message": f"motor temp {max(temps):.0f}C"})
faults.append(f"MOTOR_OVERTEMP: motor temp {max(temps):.0f}C (warning)")
if snap.get("low_age") is not None and snap["low_age"] > 3.0:
faults.append({"code": "COMMS_STALE", "severity": "critical", "message": f"no rt/lowstate for {snap['low_age']:.0f}s"})
faults.append(f"COMMS_STALE: no rt/lowstate for {snap['low_age']:.0f}s (critical)")
return faults

View File

@ -435,21 +435,17 @@ class RosbridgePosition:
# --------------------------------------------------------------------------- #
# telemetry assembly
# --------------------------------------------------------------------------- #
def derive_faults(cfg: Config, snap: Dict[str, Any]) -> List[Dict[str, Any]]:
faults: List[Dict[str, Any]] = []
def derive_faults(cfg: Config, snap: Dict[str, Any]) -> List[str]:
# STRINGS, not objects — the fleet ingest 500s on fault objects.
faults: List[str] = []
bms = snap.get("bms")
if bms and bms.get("soc", 100) <= cfg.low_soc:
faults.append({"code": "LOW_BATTERY", "severity": "warning",
"message": f"battery {bms['soc']}%"})
faults.append(f"LOW_BATTERY: battery {bms['soc']}% (warning)")
temps = snap.get("temps") or []
if temps:
hot = max(temps)
if hot >= cfg.motor_temp_max:
faults.append({"code": "MOTOR_OVERTEMP", "severity": "warning",
"message": f"motor temp {hot:.0f}C"})
if temps and max(temps) >= cfg.motor_temp_max:
faults.append(f"MOTOR_OVERTEMP: motor temp {max(temps):.0f}C (warning)")
if snap.get("low_age") is not None and snap["low_age"] > 3.0:
faults.append({"code": "COMMS_STALE", "severity": "critical",
"message": f"no rt/lowstate for {snap['low_age']:.0f}s"})
faults.append(f"COMMS_STALE: no rt/lowstate for {snap['low_age']:.0f}s (critical)")
return faults

View File

@ -6,11 +6,13 @@
# → pick robot type → enter IP → it detects whether the agent is already
# installed and offers the right actions (install / uninstall / data / ...).
#
# SCRIPTABLE:
# ./fleet_install.sh install <g1|r1|go2> <ip> [--sn NAME] [--server-ip IP] [--port N] [--token TOK] [--user U]
# SCRIPTABLE — install requires: ip, --sn, --token, --server-url (and optionally --post):
# ./fleet_install.sh install <g1|r1|go2> <ip> --sn <robot-serial> --token <device-token> \
# --server-url https://fleet.example.com [--post /api/v1/fleet/ingest/telemetry] \
# [--name NAME] [--user U]
# ./fleet_install.sh uninstall <g1|r1|go2> <ip> [--user U]
# ./fleet_install.sh status <g1|r1|go2> <ip>
# ./fleet_install.sh data <g1|r1|go2> <ip> # show the data it is sending
# ./fleet_install.sh data <g1|r1|go2> <ip> # show the data it is sending
# ./fleet_install.sh logs <g1|r1|go2> <ip>
# ./fleet_install.sh test <g1|r1|go2> <ip> [--server-ip IP] [--keep-server]
#
@ -22,15 +24,14 @@ set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
AGENTS="$SCRIPT_DIR/agents"
TYPES="g1 g1t r1 go2"
declare -A KIND=( [g1]="MAP uploader" [g1t]="TELEMETRY (G1, unitree_hg)" [r1]="TELEMETRY (unitree_hg)" [go2]="TELEMETRY (unitree_go)" )
TYPES="g1 r1 go2"
declare -A KIND=( [g1]="TELEMETRY + MAP (unitree_hg)" [r1]="TELEMETRY (unitree_hg)" [go2]="TELEMETRY (unitree_go)" )
die(){ echo "ERROR: $*" >&2; exit 1; }
img_of(){ echo "sanad-api-$1"; } # image + container + unit share this base
rdir_of(){ echo "sanad_api_$1"; }
unit_of(){ echo "sanad-api-$1.service"; }
# G1 map + G1 telemetry are the SAME physical robot → share one sn.
sn_default(){ case "$1" in g1|g1t) echo "g1_${IP##*.}";; *) echo "${1}_${IP##*.}";; esac; }
sn_default(){ echo "${1}_${IP##*.}"; }
SSH_OPTS=(-o BatchMode=yes -o ConnectTimeout=8 -o StrictHostKeyChecking=accept-new)
rmt(){ ssh -n "${SSH_OPTS[@]}" "$USER_@$IP" "$@"; } # -n: never read our stdin
@ -39,15 +40,33 @@ detect_server_ip(){ [ -n "$SERVER_IP" ] && { echo "$SERVER_IP"; return; }
ip -o route get "$IP" 2>/dev/null | grep -oP 'src \K\S+' | head -1; }
# ---- per-type docker create args (volumes differ) ----
# g1: find where THIS robot's Sanad dashboard keeps its maps/places on the host
# (native web_nav3 install, or Package_4's host-mounted nav data). Falls back to
# the agent's own dirs (agent then reports map: no_map until maps are exposed).
G1_MAPS_HOST=""; G1_DATA_HOST=""
probe_g1_dirs(){
G1_MAPS_HOST="/home/$USER_/$(rdir_of g1)/maps"
for c in "/home/$USER_/marcus_nav2_test/maps" \
"/home/$USER_/sanad_deploy/Sanad_Package_4/nav/data/ros"; do
if rmt "test -d $c" 2>/dev/null; then G1_MAPS_HOST="$c"; break; fi
done
G1_DATA_HOST="/home/$USER_/$(rdir_of g1)/web_data"
for c in "/home/$USER_/marcus_nav2_test/web/data" \
"/home/$USER_/sanad_deploy/Sanad_Package_4/nav/data/web"; do
if rmt "test -d $c" 2>/dev/null; then G1_DATA_HOST="$c"; break; fi
done
}
run_args(){
local base="--network host --env-file /home/$USER_/$(rdir_of "$1")/.env"
# all agents: host root read-only at /host → real disk-usage stats
local base="--network host --env-file /home/$USER_/$(rdir_of "$1")/.env -v /:/host:ro"
if [ "$1" = g1 ]; then
echo "$base -v /home/$USER_/$(rdir_of "$1")/maps:/data/maps:ro \
-v /home/$USER_/$(rdir_of "$1")/web_data:/data/web_data:ro \
-v /home/$USER_/$(rdir_of "$1")/state:/data/state"
# g1 also mounts the Sanad dashboard map stores + its upload-state dir
echo "$base -v ${G1_MAPS_HOST:-/home/$USER_/$(rdir_of g1)/maps}:/data/maps:ro \
-v ${G1_DATA_HOST:-/home/$USER_/$(rdir_of g1)/web_data}:/data/web_data:ro \
-v /home/$USER_/$(rdir_of g1)/state:/data/state"
else
# telemetry agents: host root read-only at /host → real disk-usage stats
echo "$base -v /:/host:ro"
echo "$base"
fi
}
@ -61,34 +80,20 @@ verify_tls(){ [ -n "$VERIFY_TLS_OPT" ] && echo "$VERIFY_TLS_OPT" || { [ -n "$SER
push_env(){
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=$surl
DEVICE_TOKEN=$TOKEN
SN=$SN
ROBOT=sanad
MAPS_DIR=/data/maps
DATA_DIR=/data/web_data
STATE_DIR=/data/state
MAP_UPLOAD_MODE=multipart
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
# 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 iface=eth0 btype=humanoid model="$t"
[ "$t" = r1 ] && iface=eth10
[ "$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
# common telemetry env (all agents)
rmt_in "cat > ~/$rdir/.env" <<EOF
SERVER_URL=$surl
DEVICE_TOKEN=$TOKEN
SN=$SN
@ -97,10 +102,22 @@ ROBOT_BRAND=unitree
ROBOT_TYPE=$btype
ROBOT_MODEL=$model
STORAGE_DATA_PATH=$dpath
${POST_ENDPOINT:+TELEMETRY_ENDPOINT=$POST_ENDPOINT}
DDS_INTERFACE=$iface
DDS_DOMAIN=0
VERIFY_TLS=$vtls
POLL_INTERVAL=2
EOF
# g1 additionally syncs the Sanad-dashboard map (web_nav3 stores)
if [ "$t" = g1 ]; then
rmt_in "cat >> ~/$rdir/.env" <<EOF
ROBOT=sanad
MAPS_DIR=/data/maps
DATA_DIR=/data/web_data
STATE_DIR=/data/state
MAP_SELECT=all
MAP_UPLOAD_MODE=multipart
MAP_POLL_INTERVAL=30
EOF
fi
}
@ -111,6 +128,9 @@ 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")"
# SN is the robot's REAL serial — it keys the robot on the fleet server, so it
# must be entered at installation (no silent default).
[ -n "$SN" ] || die "install requires the robot serial: --sn <robot-serial> (e.g. --sn E39N4000Q6D7E70F)"
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
@ -121,6 +141,11 @@ do_install(){
-e "ssh ${SSH_OPTS[*]}" "$AGENTS/$t/" "$USER_@$IP:~/$rdir/" || die "rsync failed"
rmt "mkdir -p ~/$rdir/maps/sanad ~/$rdir/web_data ~/$rdir/state ~/.config/systemd/user"
push_env "$t" "$sip"
if [ "$t" = g1 ]; then
probe_g1_dirs
echo ">> Sanad dashboard maps dir: $G1_MAPS_HOST"
echo ">> places dir: $G1_DATA_HOST"
fi
echo ">> docker build on robot (native arm64; R1/Go2 compile DDS on first build) ..."
rmt "cd ~/$rdir && docker build -t $img:latest ." || die "docker build failed"
echo ">> creating container (systemd will own its lifecycle) ..."
@ -192,10 +217,15 @@ do_test(){
[ "$rc" = 200 ] || die "robot cannot reach workstation server (http $rc) — firewall on $sip:$PORT?"
echo " reachability OK (HTTP $rc)"
if [ "$t" = g1 ]; then
rmt "mkdir -p ~/$(rdir_of g1)/maps/sanad ~/$(rdir_of g1)/web_data/sanad/places
head -c 4096 /dev/urandom > ~/$(rdir_of g1)/maps/sanad/floor-test.db
printf '%s' '{\"dock\":{\"x\":1.2,\"y\":3.4,\"qz\":0,\"qw\":1}}' > ~/$(rdir_of g1)/web_data/sanad/places/floor-test.json
rm -f ~/$(rdir_of g1)/state/uploaded.json"
probe_g1_dirs
if [ "$G1_MAPS_HOST" = "/home/$USER_/$(rdir_of g1)/maps" ]; then
# no real Sanad maps dir on this robot — seed a fixture to prove the path
rmt "mkdir -p ~/$(rdir_of g1)/maps/sanad ~/$(rdir_of g1)/web_data/sanad/places
head -c 4096 /dev/urandom > ~/$(rdir_of g1)/maps/sanad/floor-test.db
touch -d '10 minutes ago' ~/$(rdir_of g1)/maps/sanad/floor-test.db
printf '%s' '{\"dock\":{\"x\":1.2,\"y\":3.4,\"qz\":0,\"qw\":1}}' > ~/$(rdir_of g1)/web_data/sanad/places/floor-test.json
rm -f ~/$(rdir_of g1)/state/uploaded.json"
fi
rmt "docker run --rm $(run_args g1) $img:latest --once --force" || true
else
echo ">> real DDS --once:"; rmt "docker run --rm $(run_args "$t") $img:latest --once" || true
@ -223,7 +253,7 @@ PY
# --------------------------- arg parsing --------------------------- #
CMD=""; ROBOT=""; IP=""; SERVER_IP=""; PORT=8799; TOKEN="test-token"; SN=""; NAME=""; USER_="unitree"; KEEP_SERVER=0
SERVER_URL_OVERRIDE=""; VERIFY_TLS_OPT=""
SERVER_URL_OVERRIDE=""; VERIFY_TLS_OPT=""; POST_ENDPOINT=""
POSA=()
while [ $# -gt 0 ]; do case "$1" in
--server-ip) SERVER_IP="$2"; shift 2;;
@ -233,6 +263,7 @@ while [ $# -gt 0 ]; do case "$1" in
--token) TOKEN="$2"; shift 2;;
--sn) SN="$2"; shift 2;;
--name) NAME="$2"; shift 2;; # friendly display name (default <model>_<last-octet>)
--post) POST_ENDPOINT="$2"; shift 2;; # ingest POST path (telemetry or map endpoint)
--user) USER_="$2"; shift 2;;
--keep-server) KEEP_SERVER=1; shift;;
-h|--help) grep -E '^#( |$)' "$0" | sed 's/^# \{0,1\}//'; exit 0;;
@ -274,12 +305,24 @@ interactive(){
}
prompt_install(){
local def; def="$(sn_default "$ROBOT")"
read -rp "Robot name / SN [$def]: " s; SN="${s:-$def}"
# SN = the robot's REAL serial; it keys the robot on the fleet server → required.
while [ -z "$SN" ]; do
read -rp "Robot serial SN (required, e.g. E39N4000PB89GF88): " SN
done
local defname="${ROBOT}_${IP##*.}"
read -rp "Display name [$defname]: " nm; NAME="${nm:-$defname}"
# Server: full URL (https://… → TLS verified) or a bare IP (local test server).
local autos; autos="$(detect_server_ip)"
read -rp "Fleet server IP [${autos:-required}]: " si; [ -n "$si" ] && SERVER_IP="$si" || SERVER_IP="${SERVER_IP:-$autos}"
read -rp "Server port [$PORT]: " p; [ -n "$p" ] && PORT="$p"
read -rp "SERVER_URL (https://…) or server IP [${autos:-required}]: " si
si="${si:-$autos}"
case "$si" in
http://*|https://*) SERVER_URL_OVERRIDE="$si";;
"") die "server URL or IP required";;
*) SERVER_IP="$si"
read -rp "Server port [$PORT]: " p; [ -n "$p" ] && PORT="$p";;
esac
read -rp "Device token [$TOKEN]: " tk; [ -n "$tk" ] && TOKEN="$tk"
read -rp "POST endpoint [agent default]: " pe; [ -n "$pe" ] && POST_ENDPOINT="$pe"
}
# --------------------------- dispatch --------------------------- #
@ -287,7 +330,9 @@ prompt_install(){
[ -n "$ROBOT" ] && [ -n "$IP" ] || die "usage: $0 <install|uninstall|status|data|logs|test> <g1|r1|go2> <ip> [opts] (or run with no args for interactive)"
echo "$TYPES" | grep -qw "$ROBOT" || die "robot must be one of: $TYPES"
[ -d "$AGENTS/$ROBOT" ] || die "agent dir missing: $AGENTS/$ROBOT"
[ -z "$SN" ] && SN="$(sn_default "$ROBOT")"
# SN: REQUIRED for install (the robot's real serial — checked in do_install);
# for test-only runs a placeholder default is fine.
if [ -z "$SN" ] && [ "$CMD" != install ]; then SN="$(sn_default "$ROBOT")"; fi
case "$CMD" in
install) do_install "$ROBOT";;