Update 2026-07-13 10:28:18
This commit is contained in:
parent
f638ca1e9e
commit
e45a03159b
20
README.md
20
README.md
@ -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)
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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
@ -1,4 +0,0 @@
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
README.md
|
||||
@ -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=
|
||||
3
agents/g1t/.gitignore
vendored
3
agents/g1t/.gitignore
vendored
@ -1,3 +0,0 @@
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@ -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"]
|
||||
@ -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` 0–100; 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).
|
||||
@ -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
|
||||
@ -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.
|
||||
@ -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())
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
147
fleet_install.sh
147
fleet_install.sh
@ -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";;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user