fleet/PIPELINE.md

12 KiB
Raw Blame History

Sanad Fleet — Data Pipeline

How robot state becomes a record on the YS Lootah fleet dashboard, end to end. This is the reference for what flows, in what shape, when, and how failures are handled. For install/ops, see README.md.


1. Big picture

Sanad Fleet — architecture and data pipeline

        ROBOT (edge)                             │        YS LOOTAH (cloud)
                                                 │
  ┌───────────────┐   read     ┌──────────────┐  │   HTTPS POST     ┌──────────────┐
  │ robot sources │ ─────────▶ │ sanad_api_*  │ ─┼───────────────▶ │ fleet server │
  │ DDS / files   │            │ (Docker)     │  │  Bearer token    │ (ingest API) │
  └───────────────┘            └──────────────┘  │                  └──────┬───────┘
        ▲                            │           │                         │
        │ passive, read-only         │ systemd   │                         ▼
        │ (never commands motion)    │ user svc  │                  ┌──────────────┐
                                     ▼           │                  │  dashboard   │
                              stays "online"     │                  │  storage     │
                              via heartbeat      │                  │  alerts      │
                                                 │                  └──────────────┘
  • Direction: outbound only. The robot opens no inbound ports; every call is an HTTPS POST to the fleet server.
  • Transport: HTTP/1.1 + JSON (telemetry) or multipart/form-data (map).
  • Auth: Authorization: Bearer <device_token> on every request.
  • Identity: each robot is keyed by sn (fleet id, e.g. r1_82); the hardware mac rides along in telemetry.

2. API contract

The full spec (from sanad-tasks-en.html) defines these ingest endpoints. This repo currently implements the bold ones; the rest are documented for later.

endpoint method agent status
/api/v1/fleet/ingest/telemetry POST R1, Go2 implemented
/api/v1/fleet/ingest/{sn}/map POST G1 implemented
/api/v1/fleet/ingest/{sn}/commands GET spec'd, not built
/api/v1/fleet/ingest/commands/{id}/ack POST
/api/v1/fleet/ingest/{sn}/alert POST (critical faults; ordinary ones ride in telemetry faults[])
/api/v1/fleet/ingest/{sn}/logs POST
/api/v1/fleet/ingest/{sn}/remote POST (tunnel/SSH registration)

Auth header (all): Authorization: Bearer <device_token>.


3. Telemetry pipeline (R1, Go2)

Goal: a fresh status record every ~2 s; never go dark.

  DDS topics                    agent (2 s loop)                         server
  ──────────                    ────────────────                         ──────
  rt/lowstate  ─┐   callbacks   ┌─ snapshot() ─┐   build_telemetry()   ┌ POST
  (LowState_)   ├─────────────▶ │ battery      ├──────────────────────▶│ /ingest/
  rt/lf/bmsstate│  (background  │ temps/dq     │  derive status+faults │ telemetry
  (BmsState_)  ─┘   threads)    │ liveness/age │  read mac             └ (JSON)
                                └──────────────┘
                                       │ no data?
                                       └────────▶ heartbeat (battery:null, offline)

3.1 Sources per robot

field R1 (unitree_hg) Go2 (unitree_go)
battery (0100) rt/lf/bmsstateBmsState_.soc rt/lowstateLowState_.bms_state.soc
charging BmsState_.current > +0.05 A bms_state.current > +0.05 A
faults[] rt/lowstate motor temps + staleness same
status derived (see 3.3); optional loco FSM GET 7001 derived
position optional rosbridge /odom optional rosbridge /odom or rt/lf/sportmodestate
mac NIC (/sys/class/net/<iface>/address) NIC

DDS is initialized once (ChannelFactoryInitialize(domain, interface)); each topic has a subscriber whose callback updates a locked in-memory snapshot. The loop reads the snapshot — it never blocks on the network.

3.2 Payload

POST /api/v1/fleet/ingest/telemetry
Authorization: Bearer <device_token>
Content-Type: application/json

{ "sn": "r1_82",
  "mac": "4c:bb:47:51:25:9a",
  "battery": 80,
  "charging": false,
  "status": "idle",
  "position": { "x": 12.4, "y": 3.1 },   // or null when no localization source
  "faults": [],
  "ts": 1731000000 }

3.3 Status derivation

if no rt/lowstate for >3 s AND never saw battery   → "offline"
elif charging (bms current > +0.05 A)              → "charging"
elif max |joint velocity| > 0.15 rad/s             → "moving"
elif R1_READ_FSM and FSM id known                  → FSM label (811 ready / 4 standing / 1 damping / 0 zero_torque)
else                                               → "idle"

3.4 Faults

Ordinary faults ride inside telemetry faults[] (the spec reserves the separate /alert endpoint for critical, immediate events like e-stop):

code trigger severity
LOW_BATTERY soc ≤ LOW_SOC (default 15) warning
MOTOR_OVERTEMP any motor temp ≥ MOTOR_TEMP_MAX (default 85 °C) warning
COMMS_STALE no rt/lowstate for > 3 s critical

3.5 Heartbeat & failure handling

  • No DDS / no state → still POST with battery:null, status:"offline" so the dashboard shows the robot as reachable (the spec's "send a heartbeat" rule).
  • unitree_sdk2py missing → the agent logs a warning and runs in heartbeat mode (no crash).
  • POST fails (transport or non-2xx) → logged, loop continues; next tick retries in POLL_INTERVAL seconds. No back-pressure, no queue (latest state wins).
  • Every tick is wrapped — one bad read can't kill the loop.

4. Map pipeline (G1)

Goal: keep the server's copy of the nav map current, sending only on change.

  files on disk                 agent (30 s scan)                         server
  ─────────────                 ─────────────────                         ──────
  maps/<robot>/*.db  ─┐ discover ┌ fingerprint (size+mtime) ─┐  changed?  ┌ POST
  maps_meta.json      ├────────▶ │ if changed: sha256        ├──yes──────▶│ /ingest/
  web/data/<robot>/   │          │ load places → points[]    │            │ {sn}/map
    places/<map>.json ┘          └ build meta + db bytes ─────┘            └ (multipart)
                                          │ unchanged
                                          └────────▶ skip (state/uploaded.json)

4.1 Sources

  • Map file: RTAB-Map SQLite .dbmaps/<robot>/<name>.db (web_nav3).
  • Metadata: maps/<robot>/maps_meta.json{ "<db>": {description, created_at} }.
  • Places → points: web/data/<robot>/places/<map>.json ({ "<name>": {x, y, z, qx, qy, qz, qw} }), converted to {name, type:"waypoint", x, y, yaw} (yaw computed from the quaternion).

4.2 Change detection

  1. Cheap pre-check: size + mtime vs STATE_DIR/uploaded.json.
  2. If different, compute sha256 of the .db (content-true).
  3. Upload; on success, record the new fingerprint. A restart re-reads state → no redundant re-upload.

MAP_SELECT chooses scope: all (default), active (via web_nav3 GET /api/status), or newest.

4.3 Payload (two wire formats)

multipart/form-data (default) — file part db + form field meta:

POST /api/v1/fleet/ingest/g1_7892/map
Authorization: Bearer <device_token>
Content-Type: multipart/form-data

  db   = <floor-1.db bytes>        (application/octet-stream)
  meta = { "sn":"g1_7892", "name":"floor-1", "file":"floor-1.db",
           "format":"rtabmap_db", "size_bytes":6994944, "sha256":"…",
           "mtime":1731000000, "description":"ground floor",
           "points":[ {"name":"dock","type":"waypoint","x":1.2,"y":3.4,"yaw":0.0} ] }

base64json (set MAP_UPLOAD_MODE=base64json) — same fields as JSON with the .db as db_base64.

Server note: this uploads the raw RTAB-Map .db (not a rendered PNG), so the server must accept a format:"rtabmap_db" artifact. The spec's image-based map body (image_base64/resolution/origin) would instead require rendering the live /map OccupancyGrid over rosbridge — a different path, not used here.


5. Timing

stream cadence trigger
telemetry (R1/Go2) every POLL_INTERVAL (2 s) timer
map (G1) scan every POLL_INTERVAL (30 s) uploads only on content change
DDS reads continuous (subscriber callbacks) firmware publish rate
heartbeat same as telemetry cadence when state is unreadable

6. Where it runs

robot host
└─ systemd --user
   └─ sanad-api-<type>.service   (Restart=always, enabled at boot via linger)
      └─ docker start -a sanad-api-<type>
         └─ container (--network host)
            └─ python -u sanad_api_<type>.py   ← the loop above
  • --network host: DDS multicast visibility (R1/Go2) and localhost access to web_nav3:8765 (G1); also the robot's real NIC MAC.
  • Single owner: the container is docker created without a docker restart policy; systemd owns start/stop/restart.

7. End-to-end sequences

Telemetry tick

loop        DDS(sub)      agent            fleet server
 │  (2 s)      │            │                   │
 │────────────▶│ snapshot   │                   │
 │             │───────────▶│ build payload     │
 │             │            │──── POST JSON ────▶│  (Bearer)
 │             │            │◀──── 200 OK ───────│
 │             │            │ log "telemetry ok" │

Map change

scan(30s)    disk          agent            fleet server
 │────────────▶│ discover   │                   │
 │             │───────────▶│ fingerprint       │
 │             │            │ changed → sha256   │
 │             │            │ load points        │
 │             │            │── POST multipart ─▶│  (db + meta)
 │             │            │◀──── 200 OK ───────│
 │             │            │ save state         │

8. Verified behavior (real hardware)

Confirmed against the live fleet during bring-up:

  • R1 streamed real telemetry every 2 s; battery read live and drained 97 → 80% across the session; mac/sn correct; heartbeat kicked in when the server was down.
  • G1 uploaded a map (multipart, format:rtabmap_db, points with correct yaw) to the server.
  • The fleet server received a G1 map and R1 telemetry in the same window — the multi-robot pipeline works concurrently.
  • Go2: pipeline code + image verified (builds, unitree_go imports, simulate payloads correct); not yet run on a real Go2.