Update 2026-08-13 16:23

This commit is contained in:
Sidra 2026-08-13 16:23:18 +04:00
commit 164d537e4d
55 changed files with 16209 additions and 0 deletions

30
.gitignore vendored Normal file
View File

@ -0,0 +1,30 @@
# --- secrets ---
.env
.env.*
*.pem
*.key
# --- dependencies ---
node_modules/
vendor/
venv/
.venv/
__pycache__/
# --- build output ---
dist/
build/
out/
bin/
obj/
*.pyc
# --- editors / os ---
.vs/
.idea/
.DS_Store
Thumbs.db
# --- logs ---
*.log
npm-debug.log*

402
README.md Normal file
View File

@ -0,0 +1,402 @@
# AGIBOT X2 Dashboard
A web control room for the AGIBOT X2 humanoid. Open `http://<robot-ip>:8770` from a laptop, phone or tablet on the same network and you get live telemetry, motion control, camera and LiDAR feeds, a posed 3D digital twin, and speech/expression control — with no app to install and no ROS on the client.
The browser never speaks ROS 2. A small agent on the robot bridges ROS 2 DDS to plain TCP, and the dashboard serves an ordinary web page over HTTP.
---
## Contents
- [Architecture](#architecture)
- [Quick start](#quick-start)
- [The interface — 13 tabs](#the-interface--13-tabs)
- [Safety model](#safety-model)
- [Backend modules](#backend-modules)
- [The on-robot agent](#the-on-robot-agent)
- [HTTP API](#http-api)
- [Configuration](#configuration)
- [Extensions](#extensions)
- [Deployment](#deployment)
- [Repository layout](#repository-layout)
- [Design notes](#design-notes)
---
## Architecture
Two processes, one socket between them:
```
Browser ──HTTP + WebSocket──▶ Dashboard server ──JSON lines over TCP──▶ Agent ──▶ ROS 2 DDS
(any device) :8770 (FastAPI, Python) :8781 (rclpy, on robot)
```
**The dashboard server never imports `rclpy`.** That single decision drives most of the design:
- It runs anywhere — including Windows — because it needs no ROS installation.
- The web UI stays up when the robot is **off**, so it can show an offline gate that says "power the robot on" instead of dying with a connection error.
- Reconnection is automatic and continuous. Switch the robot off and the UI shows the gate; switch it on and the dashboard reattaches by itself.
Every bridge implements one interface (`bridge_base.py`), so the server and the entire frontend are identical whether a real robot is attached or not.
| Bridge mode | Behaviour |
|---|---|
| `auto` | Talk to the robot agent if a host is configured, otherwise simulate |
| `agent` | Require the real robot — never silently fall back to simulation |
| `mock` | Always simulate, for UI work away from the robot |
The mock is deliberately more than a random-number generator: battery drains at a rate that depends on what the robot is doing, odometry integrates the velocity you command, joints ease toward their targets, and mode transitions enforce the same preconditions the real robot does. UI logic exercised against the mock behaves the same way once a real bridge is attached.
---
## Quick start
**On the robot** (needs ROS 2 Humble and the AimDK workspace):
```bash
cd ~/x2_dashboard_agent
./run_agent.sh # sources ROS 2 + AimDK, starts the agent on :8781
```
**The dashboard** (on the robot, or on any machine that can reach it):
```bash
cd x2_dashboard
python3 -m backend # binds 0.0.0.0:8770
```
It prints every address it is reachable on, so you can open it from a phone without knowing the host's IP in advance.
To run both together with a crash-restart watchdog, use `run_dashboard.sh` — it is idempotent and safe to call every minute (see [Deployment](#deployment)).
> **DDS profile required.** Camera and LiDAR topics will connect but deliver *nothing* without the vendor Fast DDS profile. `run_agent.sh` exports `FASTRTPS_DEFAULT_PROFILES_FILE` for this reason — see [Design notes](#design-notes).
---
## The interface — 13 tabs
### Overview
The at-a-glance page: one hero figure, supporting stat tiles, and trend sparklines. Battery, mode, uptime, attitude and compass.
### Control
Mode switching, input-source arbitration, driving, and preset motions.
- **18 motion modes** in 6 groups — safety/basic, joint control, standing, locomotion, posture, external. Each carries a description and a danger flag.
- **Driving** with `W`/`A`/`S`/`D` or arrow keys; `Space` is an emergency stop that zeroes velocity immediately.
- **34 preset motions** in 5 groups: greeting (wave, handshake, salute, bow), expressive (heart, thumbs up, hug, cheer, sad), gesture (clap, fist, cross arms), performance (two bass dances, photo poses) and head (point, shake).
- Velocity is only accepted in driveable modes; joint commands only in `JOINT_DEFAULT` / `JOINT_FREEZE`.
### Motion
Joint-level control and end effectors. Four joint groups — **head, waist, arms, legs** — with per-joint sliders, live position/effort bars, and **4 hand presets**.
### Sensors
IMU (chest and torso), the **8-zone head touch sensor**, LiDAR status, and a topic-liveness table so you can see which topics are actually publishing.
### Vision
**Nine selectable feeds** — six live cameras plus three perception debug views — each switched on by hand:
| Feed | Notes |
|---|---|
| Head front (imx900c) | 2688×1944 JPEG, ~10 Hz |
| Head rear | 2064×1552 JPEG, ~10 Hz, mounted upright |
| Orbbec Gemini 335 colour | Module is mounted **upside down** — rotated 180° by default |
| Orbbec depth | 16-bit millimetre map, ~14 Hz, tops out ~4.2 m |
| Stereo pair (left / right) | Both eyes, JPEG ~10 Hz |
| Perception debug ×3 | Input image, segmentation mask, line colour map |
Nothing is subscribed on the robot until you switch a feed on, and switching it off **destroys the subscription on the robot** rather than merely hiding the `<img>`. This is not a nicety: a frame is 170430 KB and the RGB feeds together publish at ~60 Hz, which pushed roughly **15 MB/s through DDS for pictures nobody was looking at** — over the same Wi-Fi the robot uses to walk.
Frames are polled as single images rather than streamed, so a quiet topic degrades into "nothing is publishing" instead of a hung connection.
### LiDAR
Live 3D point cloud from the chest sensor. A scan is ~25 500 points (816 KB) at 2 Hz, decimated on the robot to 4 000 points before crossing the network. Off until switched on, like the cameras.
### Model — the digital twin
The real X2 URDF, posed and shaded by live telemetry. The robot reports all **31 revolute joint positions and efforts at 100 Hz**; this tab draws them across a rigid-body tree of **41 links**. An arm folded in the picture but straight in the room means the encoder, the model, or your idea of which robot you are connected to is wrong.
Shaded by **effort, not temperature** — this robot publishes no joint temperature at all. `aimdk_msgs/JointState` carries name, position, velocity, effort and error code and nothing else, and across all 53 `aimdk_msgs` types the only temperature fields belong to the PMU and the battery. Effort is honest and nearly as useful: motor heating is I²R and current tracks torque, so the joint pulling hardest is the joint getting hottest.
Load is shown as a **percentage of each joint's rated torque**, taken from the URDF's `<limit effort="...">` and baked into `model.json`. That matters — rated torque here runs from 0.6 Nm to 120 Nm, so an absolute Nm ramp would paint every wrist permanently cold and every leg permanently hot regardless of what the robot was doing.
Geometry is baked offline: **112 MB of vendor STL down to 1.4 MB**. Nothing parses a URDF at runtime.
### Interaction
Speech (text-to-speech with priority and interrupt), volume and mute, screen expressions/emoji, and the LED strip. Also hosts the voice-session switch that starts and stops the conversation loop.
### Power
Battery detail — percentage, voltage, current, temperature, cycle count, capacity in mAh, output power — plus **8 power rails** (48 V bus, 48 V output, 12 V output, head power, Orin NX, RK3588, fan rail, 48 V PMOS), fan RPM and thermals, with history charts.
### Navigation
Odometry, heading and travelled path. **No mapping controls are offered on purpose**: this firmware does not expose the documented SLAM interface (`/integrated_command`, `/relocalization_pose`, `GetStoredMapByName` are all absent from the live graph), and a button that silently does nothing is worse than no button.
### Extensions
Renders whatever plugins the backend found. Nothing in this tab knows about any specific plugin — see [Extensions](#extensions).
### Console
Event log, raw ROS access (publish to any topic, call any service) and the discovered ROS graph.
### Settings
Robot discovery, network, transport and safety limits. This is where the "works on any IP" promise lives: the dashboard never stores a fixed robot address — it discovers one on whatever network you are currently attached to, and reports every address it is itself reachable on.
Includes a **QR code** of the numeric dashboard URL. Typing an IP into a phone is exactly the friction that makes people want a hostname; a QR sidesteps the naming question entirely, since it needs no mDNS, no multicast and no DNS — it works even on Wi-Fi that isolates clients from each other.
---
## Safety model
| Guard | Detail |
|---|---|
| **Deadman** | Velocity commands expire after `locomotion_deadman_s` (default 0.5 s). The UI republishes at 20 Hz while a key is held; release it and the robot stops. |
| **Zero-torque confirm** | Entering `PASSIVE_DEFAULT` requires an explicit confirmation dialog (`require_confirm_zero_torque`). These modes collapse a free-standing robot. |
| **Danger flags** | Passive, zero-torque, soft e-stop, run and both stair gaits are flagged in the spec, and the UI renders them as destructive actions. |
| **Mode gating** | Velocity is rejected outside driveable modes; joint commands are rejected outside joint-control modes. |
| **Velocity clamps** | Forward 0.6…0.8 m/s, lateral ±0.7 m/s, angular ±0.8 rad/s — configurable downward in Settings. |
| **Space bar** | Global emergency stop, zeroes velocity immediately. |
---
## Backend modules
| Module | Lines | Purpose |
|---|---:|---|
| `server.py` | 1062 | FastAPI app — all HTTP routes and the WebSocket |
| `x2_spec.py` | 773 | The interface specification (see below) |
| `bridge_agent.py` | 610 | TCP bridge to the on-robot agent, with auto-reconnect |
| `nic.py` | 592 | Picks the LAN address this machine is *actually* reachable on |
| `bridge_mock.py` | 520 | Physics-lite simulated X2 |
| `announce.py` | 348 | mDNS — advertises the dashboard as `<name>.local` |
| `netinfo.py` | 346 | Address enumeration, subnet derivation, robot discovery sweep |
| `voice_session.py` | 306 | On/off switch for the conversation loop |
| `bridge_base.py` | 241 | The contract every bridge implements |
| `plugin_api.py` | 229 | The extension API |
| `settings.py` | 227 | Runtime config, persisted to `config.json` |
| `registry.py` | 203 | Plugin discovery, loading and hot reload |
| `recovery.py` | 118 | Find the robot and start its agent over SSH |
| `hub.py` | 107 | Fan-out from one bridge to N browsers |
| `__main__.py` | 98 | Entry point — `python -m backend` |
### `x2_spec.py` — the ground truth
Every constant in this file was **read back from the robot itself** by introspecting the installed `aimdk_msgs` package and the live ROS 2 graph — not copied from the published documentation. Where the two disagree, the robot wins. Differences found:
- `McAction` has **18 modes, not 5**. `PASSIVE_DEFAULT` (1) and `ZERO_TORQUE_DEFAULT` (4) are distinct; the docs conflated them.
- `McControlArea` is a **bitmask** (`LEFT_HAND=1`, `RIGHT_HAND=2`, `HEAD=4`, `WAIST=8`) — which is why the docs' "area 3" (both arms) and "area 11" (whole body) work out: 3 = 1|2, 11 = 1|2|8.
- Several documented preset motion IDs (1007, 1010, 1011, 3017, 3024, 3025, 3031) **do not exist** in the firmware enum.
- `PmuState` spells its fan field `fan_pecentage` (sic), and `battery_remaining_capacity` is **mAh, not percent** — the percentage lives in `battery_remaining_capacity_percentage`.
### `hub.py` — why each browser gets its own queue
One bridge produces state; N browsers consume it. Each client has a bounded queue, so a slow tab (a backgrounded phone, say) can never stall the telemetry loop for everyone else — it just drops frames.
### `nic.py` — why address selection is not a connectivity test
The dashboard publishes `http://<ip>:8770`, so picking the wrong address hands someone a link that cannot work. This is genuinely hard: a *disconnected* Ethernet adapter still holding an address will **answer a local HTTP fetch successfully**, because Windows routes traffic to any of its own addresses through loopback, skipping the adapter entirely — and it answers *faster* than the real one. No connect, bind or fetch test can distinguish a reachable address from a dead one; the decision has to come from adapter metadata. The rule used here was chosen by running candidates against 23 synthetic adapter tables (VPNs of three shapes, Hyper-V bridges, mobile hotspot, docked Ethernet, duplicate-IP detection, campus public addressing, renamed adapters).
### `recovery.py` — why recovery lives on the dashboard host
The X2 cannot reliably bring its own agent up after a power cycle:
- `systemd --user` units only run while the user has a login session, and the `agi` account cannot enable lingering (`loginctl enable-linger` is denied, and sudo forbids running as root).
- The cron fallback does not fire either — the robot's clock jumps backwards by several hours shortly after boot (RTC vs NTP), and cron stalls on a backward jump.
So the dashboard finds the robot wherever DHCP put it and starts the agent over SSH.
---
## The on-robot agent
`x2_dashboard_agent/x2_agent.py` — ~1 480 lines, runs on PC2 (Jetson Orin). Its only dependency beyond `rclpy` is the standard library.
**Protocol:** newline-delimited JSON.
```
-> {"type":"hello","data":{...}} (agent, on connect)
-> {"type":"state","data":{...}} (agent, ~10 Hz)
<- {"type":"cmd","id":7,"name":"set_mode","args":{}} (client)
-> {"type":"result","id":7,"ok":true,"message":"..."} (agent)
```
**Commands:** `set_mode`, `get_mode`, `set_velocity`, `stop`, `preset`, `register_source`, `set_joints`, `set_hand`, `speak`, `set_volume`, `set_mute`, `emoji`, `led`, `camera_frame`, `graph`, `publish_raw`, `ping`, `stream_set`, `stream_list`, `lidar_points`.
**Flags:** `--host` (default `0.0.0.0`), `--port` (default `8781`), `--hz` (state broadcast rate, default 10).
Cameras and the LiDAR are **on-demand**: no subscription exists until `stream_set` turns one on.
---
## HTTP API
All under `/api`. The WebSocket at `/ws` carries the live telemetry stream.
| Area | Endpoints |
|---|---|
| **State** | `GET /bootstrap` · `/state` · `/series` · `/series/keys` · `/events` · `/topics` · `/health` |
| **Settings** | `GET`/`POST /settings` |
| **Network** | `GET /network` · `POST /network/probe` · `/network/scan` · `/bridge/restart` |
| **Motion** | `GET`/`POST /mode` · `POST /velocity` · `/stop` · `/preset` · `/joints` · `/hand` |
| **Interaction** | `POST /speak` · `/volume` · `/mute` · `/emoji` · `/led` · `GET`/`POST /voice/session` |
| **Arbitration** | `POST /input-source` |
| **Raw ROS** | `POST /raw/publish` · `/raw/service` |
| **Streams** | `GET /streams` · `POST /streams/{key}` · `GET /lidar/points` · `/camera/{key}/frame` |
| **Recovery** | `POST /robot/find` · `/robot/wake` · `GET /robot/status` · `GET /qr` |
| **Plugins** | `GET /plugins` · `POST /plugins/reload` · `POST /plugins/{id}/{control}` |
Interactive API docs are served at `/api/docs`.
---
## Configuration
`config.json` sits beside the project root. Every field is editable from the Settings tab while the server is running.
| Key | Default | Meaning |
|---|---|---|
| `robot_host` | `127.0.0.1` | Agent address |
| `agent_port` | `8781` | Agent TCP port |
| `robot_label` | `AGIBOT X2` | Display name |
| `dashboard_name` | `agibot` | mDNS name → `agibot.local` |
| `advertise_name` | `true` | Publish that name over mDNS |
| `auto_discover` | `true` | Sweep the subnet when the saved address stops answering |
| `auto_start_agent` | `false` | SSH in and start the agent if it is not running |
| `robot_ssh_user` / `_password` / `_port` | `agi` / *(empty)* / `22` | SSH credentials for recovery |
| `agent_start_command` | `ensure_agent.sh` | What to run over SSH |
| `ros_domain_id` | `0` | Shown for reference; used by the agent |
| `rmw_implementation` | `rmw_fastrtps_cpp` | Shown for reference |
| `bridge_mode` | `agent` | `auto` · `agent` · `mock` |
| `host` / `port` | `0.0.0.0` / `8770` | HTTP bind |
| `telemetry_hz` | `10` | State broadcast rate |
| `history_seconds` | `120` | Chart history window |
| `require_confirm_zero_torque` | `true` | Confirm before passive/zero-torque |
| `locomotion_deadman_s` | `0.5` | Velocity command expiry |
| `max_forward_velocity` | `0.8` | m/s |
| `max_lateral_velocity` | `0.7` | m/s |
| `max_angular_velocity` | `0.8` | rad/s |
| `theme` / `accent` | `dark` / `blue` | UI |
`host` is the only key the browser may not change.
> **Note:** `robot_ssh_password` ships empty. Fill it in through the Settings tab at runtime rather than committing a value.
---
## Extensions
Drop a `.py` file into `backend/plugins/` that builds a `Plugin`, and the dashboard grows a new panel for it — no frontend work, no server edits. Each control you declare is rendered by the browser from the manifest, and clicking it calls your handler.
```python
from backend.plugin_api import Plugin
plugin = Plugin(id="hello", name="Hello", icon="👋")
@plugin.action("wave", label="Wave hello")
async def wave(ctx):
await ctx.bridge.play_preset(motion=1002, area=2)
return "Waved"
```
Press **Reload** in the Extensions tab to pick up edits without restarting the server. A plugin that fails to import does not take the dashboard down — the error is recorded and shown in the UI next to the plugin that caused it.
Two worked examples ship in `backend/plugins/`:
- **`example_greeter.py`** — sequencing several subsystems: one button drives mode, motion, speech, screen and lights together.
- **`example_battery_guard.py`** — a background monitor: watches the PMU battery level, warns once per threshold crossing, and can drop the robot into a safe mode before it browns out mid-stride. Demonstrates `on_tick`, `ctx.push` readouts and charted series.
`_template.py` is a blank starting point.
---
## Deployment
Three systemd **user** units, in `systemd_user/`:
| Unit | Role |
|---|---|
| `x2-dashboard.service` | `Type=oneshot` launcher — runs `run_dashboard.sh`, then returns |
| `x2-dashboard.timer` | Watchdog — fires 30 s after boot, then every wall-clock minute |
| `x2-dashboard-agent.service` | The ROS bridge alone |
```bash
cp systemd_user/*.service systemd_user/*.timer ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now x2-dashboard.timer
```
Two non-obvious details are load-bearing:
- **`Type=oneshot` *without* `RemainAfterExit`.** The unit must return to `inactive` after each run, or the timer never re-triggers.
- **`OnCalendar=*:*:00`, not `OnUnitActiveSec`.** The launcher `setsid`s its children into their own sessions so they outlive the one-shot; an activity-relative timer would compute `NextElapse=infinity`. `KillMode=none` stops systemd reaping those children when the unit deactivates.
`run_dashboard.sh` is idempotent by design — it starts only what is not already running, so it is safe from the timer, from `@reboot`, and by hand. It guards on `/proc` rather than trusting `pgrep -f` alone, because `pgrep -f` also matches any shell whose command line merely mentions the pattern; that bug launched a second backend on top of the first during the ~10 s before the port binds. It also trims its own logs at 2 MB.
---
## Repository layout
```
X2_dashboard/
├── x2_dashboard/ the dashboard server + web UI
│ ├── backend/
│ │ ├── server.py FastAPI app, all routes, WebSocket
│ │ ├── x2_spec.py interface spec, read off the real robot
│ │ ├── bridge_base.py the bridge contract
│ │ ├── bridge_agent.py TCP bridge to the on-robot agent
│ │ ├── bridge_mock.py simulated X2
│ │ ├── hub.py fan-out to N browsers
│ │ ├── registry.py plugin discovery + hot reload
│ │ ├── plugin_api.py the extension API
│ │ ├── settings.py config load/save
│ │ ├── announce.py mDNS
│ │ ├── netinfo.py discovery + addressing
│ │ ├── nic.py reachable-address selection
│ │ ├── recovery.py find + wake the robot over SSH
│ │ ├── voice_session.py conversation-loop control
│ │ └── plugins/ drop-in extensions
│ ├── web/
│ │ ├── index.html
│ │ ├── css/app.css design tokens, layout, components
│ │ ├── js/
│ │ │ ├── core.js store, transport, notifications
│ │ │ ├── main.js shell, rail, routing, shortcuts
│ │ │ ├── ui.js DOM builders
│ │ │ ├── charts.js SVG charts
│ │ │ ├── model3d.js WebGL URDF renderer
│ │ │ └── tabs/ the 13 pages
│ │ └── model/ quantised URDF geometry (1.4 MB)
│ └── config.json
├── x2_dashboard_agent/ runs ON the robot
│ ├── x2_agent.py ROS 2 ⇄ TCP bridge
│ ├── run_agent.sh sources ROS 2 + AimDK + DDS profile
│ ├── run_dashboard.sh idempotent launcher for both
│ ├── ensure_agent.sh called over SSH by recovery
│ └── x2-dashboard-agent.service
└── systemd_user/ unit files
```
---
## Design notes
**No framework, no CDN, no build step.** The frontend is plain ES modules and hand-written DOM. The robot has no internet access, and the dashboard is often loaded over the robot's own Wi-Fi — the same link it uses to walk. A framework would cost more than it saves at this size.
That constraint also explains two hand-rolled renderers:
- **`charts.js`** — SVG charts. 2 px lines with round joins, area washes at 10 % opacity, ≥8 px end markers with a 2 px surface ring, hairline gridlines, endpoint-only direct labels, a legend from two series up, and a crosshair tooltip on hover. Text always wears text tokens, never the series colour.
- **`model3d.js`** — one shader, flat shading, a rigid-body tree of 41 links. Geometry arrives as **uint16 positions quantised inside each mesh's bounding box** and the shader expands them back to metres, so the 4× saving over float32 costs two extra instructions per vertex. Normals are computed per-face in the fragment shader from screen-space derivatives, so the vertex buffer carries positions and nothing else.
**Nothing assumes a fixed IP.** Every URL in the frontend is derived from `window.location`, so opening the page from a laptop, a phone, a tablet or a hostname all work with no configuration. The server binds `0.0.0.0` and enumerates its own addresses.
**The Fast DDS profile is not optional.** A camera frame is 170430 KB and a LiDAR scan is 816 KB. Fast DDS defaults to a 512 KB shared-memory segment and small socket buffers, and it **drops samples that large in complete silence** — discovery succeeds, the reader matches the writer, `ros2 topic info` reports a publisher, and not one message is ever delivered. The vendor profile raises the segment to 32 MB and UDP buffers to 10 MB. An earlier revision of `x2_spec.py` concluded this unit had one camera and no chest LiDAR; it has six camera feeds and a LiDAR, and the agent simply could not receive them.
**mDNS is implemented on the stdlib, not `zeroconf`.** The advertised name is additive and reversible — the host keeps its own identity, and the extra name exists only while the dashboard is running, so it needs neither administrator rights nor a reboot.
---
## What is not in this copy
Two things were deliberately excluded when this snapshot was taken from the robot:
- **The Sanad API service** (`sanad_api_x2`) — it carries live credentials in a `.env`, so it is not published here.
- **Teach mode / Manual Recorder** — the record-an-arm-motion-by-hand feature and its `/api/recorder/*` routes were removed from this copy. The dashboard runs without it; the feature still exists on the robot.
No API keys or credentials are present in this repository. The Gemini and LinkSoul credentials the voice session depends on are read at runtime from `~/.sanad_agibot_env` on the robot and are never embedded in this code.

View File

@ -0,0 +1,28 @@
[Unit]
Description=AGIBOT X2 dashboard agent (ROS 2 to TCP bridge)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/home/agi/x2_dashboard_agent
ExecStart=/home/agi/x2_dashboard_agent/run_agent.sh
Restart=always
RestartSec=5
TimeoutStopSec=15
KillMode=mixed
# The robot's own stack takes a while to come up after power-on; the agent
# tolerates an empty graph and fills in as topics appear, so starting early is
# fine and means the dashboard attaches as soon as possible.
StandardOutput=journal
StandardError=journal
# Fast DDS writes a very high volume of discovery chatter (to stdout, alongside
# the agent's own messages, so it cannot simply be dropped). Rate-limiting keeps
# the journal from being swamped while still letting the agent's lines through.
LogRateLimitIntervalSec=30s
LogRateLimitBurst=300
Environment=RCUTILS_LOGGING_SEVERITY=ERROR
Environment=ROS_DOMAIN_ID=0
[Install]
WantedBy=default.target

View File

@ -0,0 +1,27 @@
[Unit]
Description=AGIBOT X2 web dashboard (serves http://<robot-ip>:8770)
After=network-online.target
Wants=network-online.target
[Service]
# A one-shot launcher: run_dashboard.sh starts the ROS bridge and the web
# dashboard if they are not already up, then returns. It is idempotent, so the
# timer below re-runs it every minute as a crash-and-restart watchdog.
#
# Type=oneshot WITHOUT RemainAfterExit: the unit goes back to "inactive" after
# each run, which is what lets the timer's OnUnitActiveSec fire again. With
# RemainAfterExit=yes the unit stays "active" forever and the timer never
# re-triggers (NextElapse=infinity).
Type=oneshot
WorkingDirectory=/home/agi/x2_dashboard
ExecStart=/home/agi/x2_dashboard_agent/run_dashboard.sh
TimeoutStartSec=120
# The launcher setsid's the agent and dashboard into their own sessions so they
# outlive this one-shot. KillMode=none stops systemd from reaping them when the
# unit deactivates, and lets the unit return to "inactive" cleanly - which is
# what re-arms the watchdog timer (OnUnitActiveSec). Without this the cgroup
# stays populated, the unit stays "active" forever, and NextElapse=infinity.
KillMode=none
[Install]
WantedBy=default.target

View File

@ -0,0 +1,19 @@
[Unit]
Description=Keep the AGIBOT X2 dashboard and agent running
[Timer]
# A watchdog: run the idempotent launcher shortly after boot, then on every
# wall-clock minute. This is the crash-and-restart safety net, and it means
# neither the dashboard nor the ROS bridge depends on any external machine.
#
# OnCalendar (not OnUnitActiveSec) on purpose: the launcher is Type=oneshot and
# its background processes keep the unit reading "active", so an activity-relative
# timer never re-arms (NextElapse=infinity). A wall-clock schedule fires
# regardless of the service's state.
OnBootSec=30s
OnCalendar=*:*:00
Persistent=true
AccuracySec=5s
[Install]
WantedBy=timers.target

View File

@ -0,0 +1,3 @@
"""AGIBOT X2 dashboard backend."""
__version__ = "1.0.0"

View File

@ -0,0 +1,98 @@
"""
Entry point: ``python -m backend``
Binds 0.0.0.0 and prints every address the dashboard is reachable on, so you can
open it from a phone or tablet on the same Wi-Fi without knowing this machine's
IP in advance.
"""
from __future__ import annotations
import argparse
import sys
from . import netinfo, settings
def main() -> int:
parser = argparse.ArgumentParser(prog="python -m backend",
description="AGIBOT X2 dashboard server")
parser.add_argument("--port", type=int, help="HTTP port (default from config.json)")
parser.add_argument("--host", default=None, help="Bind address (default 0.0.0.0 - all interfaces)")
parser.add_argument("--robot", default=None, help="Robot host or IP; saved to config.json")
parser.add_argument("--domain", type=int, default=None, help="ROS_DOMAIN_ID")
parser.add_argument("--mode", choices=["auto", "ros2", "mock"], default=None,
help="Bridge mode: auto (default), ros2 (require rclpy), mock (simulate)")
parser.add_argument("--reload", action="store_true", help="Auto-reload on source changes")
args = parser.parse_args()
updates = {}
if args.port is not None:
updates["port"] = args.port
if args.robot is not None:
updates["robot_host"] = args.robot
if args.domain is not None:
updates["ros_domain_id"] = args.domain
if args.mode is not None:
updates["bridge_mode"] = args.mode
if updates:
settings.save(updates)
config = settings.load()
host = args.host or config["host"]
port = config["port"]
try:
import uvicorn
except ImportError:
print("uvicorn is not installed. Run: pip install -r requirements.txt", file=sys.stderr)
return 1
name = config.get("dashboard_name") if config.get("advertise_name") else None
detail = netinfo.address_detail()
print()
print(" AGIBOT X2 Dashboard")
print(" " + "-" * 52)
if detail["ip"]:
print(" Open this from a phone, tablet or laptop:")
print(f" http://{detail['ip']}:{port}")
print(f" (live {detail['kind'] or 'network'} address on '{detail['adapter']}')")
else:
print(" No usable network address right now:")
print(f" {detail['reason_text'] or detail['reason']}")
print(" The dashboard is still running; it will pick the address up")
print(" automatically when the network comes back.")
others = [u for u in netinfo.dashboard_urls(port, name)
if u != f"http://{detail['ip']}:{port}"]
if others:
print()
print(" Also reachable at:")
for url in others:
print(f" {url}")
print(" " + "-" * 52)
print(f" robot host {config['robot_host'] or '(not set - use the Settings tab)'}")
print(f" bridge mode {config['bridge_mode']}")
print(f" ROS_DOMAIN_ID {config['ros_domain_id']}")
print(" Open any address above from any device on this network.")
print(flush=True)
uvicorn.run(
"backend.server:app",
host=host,
port=port,
reload=args.reload,
log_level="info",
access_log=False,
# websockets 16.x crashes uvicorn's default WS impl (starlette/anyio
# "cancel scope" error) -> stuck "Connecting". wsproto is a stable,
# isolated WS engine that avoids it without touching the shared
# `websockets` package the voice pipeline depends on.
ws="wsproto",
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,348 @@
"""
Advertise the dashboard on the LAN under its own name, e.g. `agibot.local`.
Why this exists
---------------
The link people type should be about the robot, not about whatever the PC
happens to be called. Renaming the computer would do it, but that needs
administrator rights and a reboot and changes the machine's identity for
everything else. Publishing an extra name over mDNS is additive, reversible and
needs neither: the computer keeps its own name, and the advertised name exists
only while the dashboard is running.
Why stdlib and not the `zeroconf` package
-----------------------------------------
Two measured constraints on this machine, either of which would have made a
separate helper process silently useless:
* Windows' Public firewall profile is BlockInbound and every inbound rule for
UDP 5353 is *program*-scoped, not port-scoped. The built-in "mDNS (UDP-In)"
rule is scoped to svchost.exe/dnscache and grants a Python process nothing.
The only interpreter with a blanket inbound allow is the base Python that
already serves the dashboard - so the responder has to live *inside* that
process to be reachable from another device.
* A same-host HTTP fetch to the PC's own LAN address is routed via loopback
and skips inbound firewall filtering entirely, so a helper running under the
wrong interpreter passes every local test while remaining invisible to a
phone. Running in-process removes that whole failure mode.
Being stdlib-only also means no extra dependency to install in the interpreter
that matters.
Scope
-----
Only the `<name>.local` form is published. A bare single-label `http://agibot:`
appears to work on Windows because Windows appends `.local` to single-label
names, but it is unreliable (its negative DNS cache can poison the name for
minutes) and no iPhone, Android, Mac or Linux client resolves it. Publishing it
would be advertising something that only works on one machine.
"""
from __future__ import annotations
import logging
import socket
import struct
import threading
import time
log = logging.getLogger("x2.announce")
MDNS_ADDR = "224.0.0.251"
MDNS_PORT = 5353
TTL = 120 # seconds a client may cache the record
QTYPE_A = 1
QTYPE_ANY = 255
CLASS_IN = 1
CACHE_FLUSH = 0x8000 # tells clients this is the authoritative answer
UNICAST_RESPONSE = 0x8000 # the "QU" bit on a question's class
def _encode_name(name: str) -> bytes:
out = bytearray()
for label in name.rstrip(".").split("."):
raw = label.encode("utf-8")
out.append(len(raw))
out += raw
out.append(0)
return bytes(out)
def _decode_name(data: bytes, offset: int) -> tuple[str, int]:
"""Read a DNS name, following compression pointers. Returns (name, next_offset)."""
labels: list[str] = []
jumped = False
end = offset
hops = 0
while True:
if offset >= len(data) or hops > 20:
break
length = data[offset]
if length & 0xC0 == 0xC0: # compression pointer
if offset + 1 >= len(data):
break
pointer = struct.unpack_from("!H", data, offset)[0] & 0x3FFF
if not jumped:
end = offset + 2
offset = pointer
jumped = True
hops += 1
continue
offset += 1
if length == 0:
if not jumped:
end = offset
break
labels.append(data[offset:offset + length].decode("utf-8", "replace"))
offset += length
return ".".join(labels), end
def _build_response(name: str, ip: str) -> bytes:
header = struct.pack(
"!HHHHHH",
0, # ID - always 0 in mDNS
0x8400, # QR=1 (response), AA=1 (authoritative)
0, # no questions echoed
1, # one answer
0, 0,
)
answer = (
_encode_name(name)
+ struct.pack("!HHIH", QTYPE_A, CLASS_IN | CACHE_FLUSH, TTL, 4)
+ socket.inet_aton(ip)
)
return header + answer
def _build_query(name: str) -> bytes:
header = struct.pack("!HHHHHH", 0, 0x0000, 1, 0, 0, 0)
return header + _encode_name(name) + struct.pack("!HH", QTYPE_A, CLASS_IN)
class Announcer:
"""Answers mDNS A queries for one name, for as long as it runs."""
def __init__(self, name: str, port: int, address_provider):
self.name = f"{name.strip().strip('.').lower()}.local"
self.port = port
self._address_of = address_provider
self._sock: socket.socket | None = None
self._thread: threading.Thread | None = None
self._stop = threading.Event()
self.ip: str | None = None
self.answered = 0
self.error = ""
self.conflict = False
# -- lifecycle ----------------------------------------------------------
def start(self) -> bool:
self.ip = self._address_of()
if not self.ip:
self.error = "No LAN address to advertise"
log.warning("mDNS: %s", self.error)
return False
try:
self._sock = self._open_socket()
except OSError as exc:
self.error = f"Could not open UDP {MDNS_PORT}: {exc}"
log.warning("mDNS: %s", self.error)
return False
# RFC 6762 probing: if something else already owns the name, do not
# fight it - two hosts answering for one name gives whichever reply
# arrives first, which is worse than not advertising at all.
if self._name_taken():
self.conflict = True
self.error = f"{self.name} is already claimed by another device on this network"
log.warning("mDNS: %s", self.error)
self._close()
return False
self._stop.clear()
self._thread = threading.Thread(target=self._serve, name="mdns-announce", daemon=True)
self._thread.start()
log.info("mDNS: advertising %s -> %s:%s", self.name, self.ip, self.port)
return True
def stop(self) -> None:
self._stop.set()
self._close()
if self._thread:
self._thread.join(timeout=3.0)
self._thread = None
def _close(self) -> None:
if self._sock is not None:
try:
self._sock.close()
except OSError:
pass
self._sock = None
# -- socket -------------------------------------------------------------
def _open_socket(self) -> socket.socket:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
# Several responders normally share 5353 (Windows' own, Bonjour, and
# anything Chrome is doing); SO_REUSEPORT where available lets us join
# them rather than fail.
if hasattr(socket, "SO_REUSEPORT"):
try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
except OSError:
pass
sock.bind(("", MDNS_PORT))
membership = socket.inet_aton(MDNS_ADDR) + socket.inet_aton(self.ip)
sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, membership)
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 255)
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_IF, socket.inet_aton(self.ip))
sock.settimeout(1.0)
return sock
def _name_taken(self) -> bool:
"""Ask whether anyone else already answers for our name."""
probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
probe.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 255)
probe.settimeout(0.4)
probe.sendto(_build_query(self.name), (MDNS_ADDR, MDNS_PORT))
deadline = time.time() + 0.9
while time.time() < deadline:
try:
data, addr = probe.recvfrom(4096)
except socket.timeout:
continue
except OSError:
break
if addr[0] == self.ip:
continue # our own machine, ignore
if self._is_answer_for_us(data):
log.warning("mDNS: %s already answered by %s", self.name, addr[0])
return True
except OSError:
pass
finally:
probe.close()
return False
def _is_answer_for_us(self, data: bytes) -> bool:
try:
_, flags, qd, an, _, _ = struct.unpack_from("!HHHHHH", data, 0)
except struct.error:
return False
if not (flags & 0x8000) or an == 0:
return False
offset = 12
for _ in range(qd):
_, offset = _decode_name(data, offset)
offset += 4
for _ in range(an):
name, offset = _decode_name(data, offset)
try:
rtype, _, _, rdlen = struct.unpack_from("!HHIH", data, offset)
except struct.error:
return False
offset += 10 + rdlen
if rtype == QTYPE_A and name.lower() == self.name:
return True
return False
# -- serving ------------------------------------------------------------
def _serve(self) -> None:
last_ip_check = time.time()
while not self._stop.is_set():
sock = self._sock
if sock is None:
return
try:
data, addr = sock.recvfrom(4096)
except socket.timeout:
data = None
except OSError:
if not self._stop.is_set():
log.debug("mDNS: socket closed")
return
# DHCP leases change; an advertiser pinned to a stale address will
# confidently point the name at nothing.
if time.time() - last_ip_check > 30:
last_ip_check = time.time()
current = self._address_of()
if current and current != self.ip:
log.info("mDNS: address changed %s -> %s, re-advertising", self.ip, current)
self.ip = current
if not data:
continue
try:
self._handle(data, addr)
except Exception as exc: # never let one packet stop us
log.debug("mDNS: bad packet from %s: %s", addr, exc)
def _handle(self, data: bytes, addr) -> None:
try:
_, flags, qdcount, _, _, _ = struct.unpack_from("!HHHHHH", data, 0)
except struct.error:
return
if flags & 0x8000 or qdcount == 0: # a response, not a question
return
offset = 12
for _ in range(qdcount):
name, offset = _decode_name(data, offset)
try:
qtype, qclass = struct.unpack_from("!HH", data, offset)
except struct.error:
return
offset += 4
if name.lower() != self.name:
continue
if qtype not in (QTYPE_A, QTYPE_ANY):
continue
response = _build_response(self.name, self.ip)
wants_unicast = bool(qclass & UNICAST_RESPONSE)
sock = self._sock
if sock is None:
return
try:
if wants_unicast:
sock.sendto(response, addr)
else:
# Multicast so every listener refreshes its cache, and
# unicast too because some stacks only accept the direct
# reply.
sock.sendto(response, (MDNS_ADDR, MDNS_PORT))
sock.sendto(response, addr)
self.answered += 1
except OSError:
pass
# -- introspection ------------------------------------------------------
def status(self) -> dict:
return {
"name": self.name,
"url": f"http://{self.name}:{self.port}",
"ip": self.ip,
"running": bool(self._thread and self._thread.is_alive()),
"answered": self.answered,
"conflict": self.conflict,
"error": self.error,
}

View File

@ -0,0 +1,610 @@
"""
Bridge to the on-robot agent (agent/x2_agent.py) over plain TCP.
This is the bridge that drives a real X2. The dashboard server itself never
imports rclpy - it talks JSON-lines to the agent running on PC2. That keeps the
dashboard usable on any machine (including Windows) and, more importantly, keeps
the web UI up when the robot is powered off so it can say so instead of dying.
Reconnection is automatic and continuous: switch the robot off and the UI shows
the offline gate; switch it on and the dashboard reattaches on its own.
"""
from __future__ import annotations
import asyncio
import base64
import contextlib
import json
import time
from . import netinfo, recovery, settings, x2_spec
from .bridge_base import Bridge, CommandResult
RECONNECT_MIN = 1.0
RECONNECT_MAX = 6.0
COMMAND_TIMEOUT = 12.0
# asyncio's StreamReader defaults to a 64 KiB limit per line, and readline()
# raises once a line exceeds it. Camera frames arrive base64-encoded at roughly
# 400 KB and a full ROS graph dump is over 100 KB, so the default silently tore
# the link down mid-command. 32 MiB leaves generous headroom.
STREAM_LIMIT = 32 * 1024 * 1024
class AgentBridge(Bridge):
name = "agent"
simulated = False
def __init__(self, hub, config: dict):
super().__init__(hub, config)
self._reader = None
self._writer = None
self._task: asyncio.Task | None = None
self._push_task: asyncio.Task | None = None
self._pending: dict[int, asyncio.Future] = {}
self._next_id = 1
self._connected = asyncio.Event()
self._closing = False
self._agent_info: dict = {}
# key -> (ts, bytes, content_type, flip_used)
self._frames: dict[str, tuple[float, bytes, str, bool | None]] = {}
self._attempts = 0
self._last_error = ""
self._recovering = False
# -- lifecycle ----------------------------------------------------------
@property
def host(self) -> str:
return (self.config.get("robot_host") or "").strip()
@property
def port(self) -> int:
return int(self.config.get("agent_port") or x2_spec.AGENT_PORT)
async def start(self) -> None:
if not self.host and not self.config.get("auto_discover"):
raise RuntimeError(
"No robot host configured. Set it in the Settings tab, run the "
"server with --robot <ip>, or enable auto-discovery."
)
self._closing = False
self.state.connection.transport = "agent"
self.state.connection.host = f"{self.host}:{self.port}"
self.state.connection.simulated = False
self.state.connection.online = False
self._task = asyncio.create_task(self._link_loop(), name="agent-link")
self._push_task = asyncio.create_task(self._push_loop(), name="agent-push")
# Give the first connection a moment so the UI opens attached rather
# than flashing the offline gate, but never block startup on it.
with contextlib.suppress(asyncio.TimeoutError):
await asyncio.wait_for(self._connected.wait(), timeout=4.0)
async def stop(self) -> None:
self._closing = True
for task in (self._task, self._push_task):
if task:
task.cancel()
with contextlib.suppress(asyncio.CancelledError, Exception):
await task
await self._drop_link()
self.state.connection.online = False
self.state.connection.transport = "none"
async def reconfigure(self, config: dict) -> None:
"""
Adopt new settings, and re-dial immediately if the address changed.
Without the re-dial an operator who corrects the robot address sees
nothing happen until they restart the bridge by hand, because the link
loop only reads the address when it next reconnects - and a healthy
connection never reconnects.
"""
before = (self.host, self.port)
self.config = config
after = ((config.get("robot_host") or "").strip(),
int(config.get("agent_port") or x2_spec.AGENT_PORT))
if before != after and self._writer is not None:
await self.hub.emit("info", "robot",
f"Robot address changed to {after[0]}:{after[1]} - reconnecting")
self._attempts = 0
# Dropping the socket makes _connect_once return; the link loop then
# dials the new address on its next pass.
await self._drop_link()
# -- link ---------------------------------------------------------------
async def _link_loop(self) -> None:
while not self._closing:
try:
await self._connect_once()
except asyncio.CancelledError:
raise
except Exception as exc:
self._last_error = str(exc)
finally:
await self._drop_link()
if self._closing:
return
if self.state.connection.online:
# We were up and lost it - that is worth saying out loud.
self.state.connection.online = False
self.state.connection.error = self._last_error or "link lost"
await self.hub.emit("warn", "robot",
f"Lost connection to the robot ({self._last_error or 'link closed'})")
self._attempts += 1
# After a few plain retries, try harder: the robot may have come
# back on a different address, or be up with the agent not running.
if self._attempts % 4 == 0:
await self._attempt_recovery()
delay = min(RECONNECT_MAX, RECONNECT_MIN * (1.35 ** min(self._attempts, 8)))
await asyncio.sleep(delay)
async def _attempt_recovery(self) -> None:
"""
Try to get the robot back without the operator doing anything.
Two failure modes are handled, in order of likelihood after a power
cycle: the agent is not running (robot answers SSH but not the agent
port), and the robot moved to a different DHCP address.
"""
if self._recovering:
return
self._recovering = True
try:
config = settings.load()
port = int(config.get("agent_port") or x2_spec.AGENT_PORT)
host = self.host
# 1. Is the robot there at all, just without the agent running?
if host and config.get("auto_start_agent"):
info = await netinfo.probe_host(
host, ports=[port, int(config.get("robot_ssh_port") or 22)], timeout=1.5)
open_ports = info.get("open_ports") or []
if port in open_ports:
return # agent is up; the next retry will land
if 22 in open_ports:
await self._start_agent_on(host, config, port)
return
# 2. Otherwise look for it somewhere else on this network.
if not config.get("auto_discover"):
return
self._set_recovery("Looking for the robot on this network…")
await self.hub.emit("info", "robot",
"Robot not answering - scanning the network for it")
candidates = await recovery.find_robot(port, int(config.get("robot_ssh_port") or 22))
candidates = [c for c in candidates if c["host"] != host]
running = next((c for c in candidates if c["agent"]), None)
if running:
await self._adopt(running["host"], "found the agent running there")
return
if config.get("auto_start_agent"):
for candidate in candidates:
if not candidate["ssh"]:
continue
if await self._start_agent_on(candidate["host"], config, port, adopt=True):
return
self._set_recovery("")
except asyncio.CancelledError:
raise
except Exception as exc:
self._set_recovery("")
await self.hub.emit("warn", "robot", f"Recovery attempt failed: {exc}")
finally:
self._recovering = False
async def _start_agent_on(self, host: str, config: dict, port: int,
adopt: bool = False) -> bool:
self._set_recovery(f"Robot is on at {host} but the agent is not running — starting it…")
await self.hub.emit("info", "robot",
f"{host} is reachable but the agent is down - starting it over SSH")
ok, message = await recovery.start_agent(host, config)
if not ok:
self._set_recovery(message)
await self.hub.emit("warn", "robot", message)
return False
if await recovery.wait_for_agent(host, port, timeout=45.0):
self._set_recovery("")
if adopt:
await self._adopt(host, "started the agent there")
else:
await self.hub.emit("info", "robot", "Agent started - reconnecting")
return True
self._set_recovery("Started the agent, but it has not come up yet…")
return False
async def _adopt(self, host: str, why: str) -> None:
"""Switch to a newly found address and remember it."""
settings.save({"robot_host": host})
self.config = settings.load()
self.state.connection.host = f"{host}:{self.port}"
self._attempts = 0
self._set_recovery("")
await self.hub.emit("info", "robot", f"Robot found at {host} - {why}")
await self.hub.broadcast("settings", settings.describe())
def _set_recovery(self, message: str) -> None:
"""Progress text for the offline gate, so the operator sees us working."""
self.state.custom["recovery"] = message
if message:
self.state.connection.error = message
async def _connect_once(self) -> None:
host, port = self.host, self.port
if not host:
# Nothing to dial yet - let the recovery pass find one.
self.state.connection.error = "Looking for the robot on this network…"
raise ConnectionError("no robot address yet")
try:
self._reader, self._writer = await asyncio.wait_for(
asyncio.open_connection(host, port, limit=STREAM_LIMIT), timeout=5.0)
except (asyncio.TimeoutError, OSError) as exc:
self._last_error = f"{host}:{port} unreachable"
self.state.connection.error = (
f"Cannot reach the robot agent at {host}:{port}. "
"Is the robot powered on and on this network?"
)
raise ConnectionError(self._last_error) from exc
was_offline = not self.state.connection.online
self._attempts = 0
self._last_error = ""
self.state.connection.online = True
self.state.connection.since = time.time()
self.state.connection.error = ""
self._connected.set()
if was_offline:
await self.hub.emit("info", "robot", f"Connected to robot agent at {host}:{port}")
# Re-assert anything that does not survive a reconnect.
self.state.source_registered = False
asyncio.create_task(self._announce_self())
while not self._closing:
line = await self._reader.readline()
if not line:
raise ConnectionError("agent closed the connection")
try:
message = json.loads(line)
except json.JSONDecodeError:
continue
self._on_message(message)
async def _announce_self(self) -> None:
"""
Tell the robot this dashboard exists as a control source.
The motion controller ignores velocity from a source it has never heard
of, so this has to happen before the joystick will do anything. It is
not a takeover: registering only makes the dashboard *eligible*, and
arbitration still hands control to the highest-priority source that is
actively sending - the handheld RC (80) and mobile app (60) both
outrank us at 30.
Done automatically rather than left to a button, because there is no
situation where an operator wants the dashboard connected but not
allowed to drive, and "register input source" means nothing to someone
who just wants to move the robot.
"""
source = x2_spec.DASHBOARD_INPUT_SOURCE
for attempt in range(3):
await asyncio.sleep(1.0 if attempt == 0 else 3.0)
if not self.state.connection.online:
return
result = await self.register_input_source(
source["name"], source["priority"], source["timeout"])
if result.ok:
return
if attempt == 2:
await self.hub.emit(
"warn", "control",
f"Could not announce the dashboard to the robot: {result.message}. "
"Driving will be refused until this succeeds - use the Control tab to retry.",
)
async def _drop_link(self) -> None:
self._connected.clear()
writer, self._writer, self._reader = self._writer, None, None
if writer is not None:
with contextlib.suppress(Exception):
writer.close()
await writer.wait_closed()
for future in self._pending.values():
if not future.done():
future.set_exception(ConnectionError("link dropped"))
self._pending.clear()
def _on_message(self, message: dict) -> None:
kind = message.get("type")
if kind == "state":
self._apply_state(message.get("data") or {})
elif kind == "hello":
self._agent_info = message.get("data") or {}
self.state.custom["agent"] = self._agent_info
# Report the domain the agent is actually on, rather than whatever
# this machine has configured - only the agent's value is real.
try:
self.state.connection.ros_domain_id = int(
self._agent_info.get("ros_domain_id", 0))
except (TypeError, ValueError):
pass
elif kind == "result":
future = self._pending.pop(message.get("id"), None)
if future and not future.done():
future.set_result(message)
# -- state mapping ------------------------------------------------------
def _apply_state(self, data: dict) -> None:
s = self.state
s.mode = data.get("mode") or "UNKNOWN"
spec = x2_spec.MC_MODE_BY_ID.get(s.mode)
s.mode_desc = spec["label"] if spec else s.mode
status = data.get("mode_status")
s.mode_status = x2_spec.MC_ACTION_STATUS.get(status, str(status or ""))
for field in ("battery_pct", "battery_voltage", "battery_current",
"battery_temp", "battery_cycles", "pmu_temp",
"fan_rpm", "fan_pct"):
setattr(s, field, data.get(field))
s.charging = bool(data.get("charging"))
rails = {}
for spec_rail in x2_spec.PMU_RAILS:
live = (data.get("rails") or {}).get(spec_rail["key"])
if not live:
continue
rails[spec_rail["key"]] = {
"label": spec_rail["label"],
"nominal": spec_rail["nominal"],
"voltage": live.get("voltage"),
"current": live.get("current"),
"ok": live.get("ok", True),
}
s.rails = rails
s.imu = data.get("imu") or {}
s.joints = data.get("joints") or {}
s.hand_type = data.get("hand_type") or "None"
s.hand_state = data.get("hand_state") or {}
s.touch_head = data.get("touch_head") or {}
s.velocity = data.get("velocity") or s.velocity
s.velocity_command = data.get("velocity_command") or s.velocity_command
s.odom = data.get("odom") or s.odom
s.volume = data.get("volume") if data.get("volume") is not None else s.volume
s.muted = bool(data.get("muted"))
s.emoji_id = data.get("emoji_id")
s.led = data.get("led") or s.led
s.input_source = data.get("input_source") or ""
s.topic_stats = data.get("topic_stats") or {}
s.custom["pmu_raw"] = data.get("pmu_raw") or {}
s.custom["pmu_info"] = data.get("pmu_info") or {}
s.custom["cameras"] = data.get("cameras") or {}
# Which feeds are actually subscribed on the robot right now. The
# Vision tab reads this rather than tracking its own idea of on/off,
# so a second browser sees the true state instead of "off".
s.custom["streams"] = data.get("streams") or {}
s.custom["face_status"] = data.get("face_status")
s.custom["battery_capacity_mah"] = data.get("battery_capacity_mah")
s.custom["battery_power"] = data.get("battery_power")
s.custom["agent_version"] = data.get("agent_version")
s.custom["hand_left_type"] = data.get("hand_left_type")
s.custom["hand_right_type"] = data.get("hand_right_type")
s.updated = time.time()
async def _push_loop(self) -> None:
"""Forward state to browsers and record chart series."""
hz = max(1, int(self.config.get("telemetry_hz", 10)))
while True:
try:
await asyncio.sleep(1.0 / hz)
now = time.time()
if self.state.connection.online:
self._record(now)
await self.hub.broadcast("state", self.state.as_dict())
except asyncio.CancelledError:
raise
except Exception:
await asyncio.sleep(1.0)
def _record(self, now: float) -> None:
for key, value in (
("battery_pct", self.state.battery_pct),
("battery_voltage", self.state.battery_voltage),
("battery_current", self.state.battery_current),
("battery_temp", self.state.battery_temp),
("pmu_temp", self.state.pmu_temp),
("fan_pct", self.state.fan_pct),
("vel_forward", self.state.velocity.get("forward")),
("vel_lateral", self.state.velocity.get("lateral")),
("vel_angular", self.state.velocity.get("angular")),
):
if value is not None:
self.hub.record(key, value, now)
chest = self.state.imu.get("chest") or {}
for key, field in (("imu_roll", "roll"), ("imu_pitch", "pitch"), ("imu_yaw", "yaw")):
if field in chest:
self.hub.record(key, chest[field], now)
# -- command plumbing ---------------------------------------------------
async def _send(self, name: str, args: dict | None = None,
timeout: float = COMMAND_TIMEOUT) -> CommandResult:
if self._writer is None or not self.state.connection.online:
return CommandResult.failure(
"The robot is not connected. Power it on and wait for the dashboard to reattach."
)
message_id = self._next_id
self._next_id += 1
future: asyncio.Future = asyncio.get_running_loop().create_future()
self._pending[message_id] = future
payload = json.dumps({"type": "cmd", "id": message_id,
"name": name, "args": args or {}}) + "\n"
try:
self._writer.write(payload.encode())
await self._writer.drain()
except Exception as exc:
self._pending.pop(message_id, None)
return CommandResult.failure(f"Send failed: {exc}")
try:
reply = await asyncio.wait_for(future, timeout=timeout)
except asyncio.TimeoutError:
self._pending.pop(message_id, None)
return CommandResult.failure(f"The robot did not answer '{name}' within {timeout:g} s")
except ConnectionError:
return CommandResult.failure("Connection to the robot dropped mid-command")
return CommandResult(bool(reply.get("ok")),
reply.get("message", ""),
reply.get("detail"))
# -- commands -----------------------------------------------------------
async def set_mode(self, mode_id: str) -> CommandResult:
result = await self._send("set_mode", {"mode": mode_id})
if result.ok:
await self.hub.emit("info", "mode", f"Mode set to {mode_id}")
return result
async def get_mode(self) -> CommandResult:
return await self._send("get_mode")
async def set_velocity(self, forward: float, lateral: float, angular: float) -> CommandResult:
return await self._send("set_velocity",
{"forward": forward, "lateral": lateral, "angular": angular},
timeout=4.0)
async def stop_motion(self) -> CommandResult:
return await self._send("stop", timeout=4.0)
async def play_preset(self, motion: int, area: int, interrupt: bool = True) -> CommandResult:
return await self._send("preset", {"motion": motion, "area": area,
"interrupt": interrupt}, timeout=15.0)
async def set_joints(self, group: str, mode: str, targets: dict,
stiffness=None, damping=None) -> CommandResult:
return await self._send("set_joints", {
"group": group, "mode": mode, "targets": targets,
"stiffness": stiffness, "damping": damping,
})
async def set_hand(self, side: str, positions: list) -> CommandResult:
return await self._send("set_hand", {"side": side, "positions": positions})
async def speak(self, text: str, priority: int = 6, interrupt: bool = False) -> CommandResult:
return await self._send("speak", {"text": text, "priority": priority,
"interrupt": interrupt}, timeout=15.0)
async def set_volume(self, volume: int) -> CommandResult:
return await self._send("set_volume", {"volume": volume})
async def set_mute(self, muted: bool) -> CommandResult:
return await self._send("set_mute", {"muted": muted})
async def play_emoji(self, emotion_id: int, mode: int = 1, priority: int = 6) -> CommandResult:
return await self._send("emoji", {"emotion_id": emotion_id, "mode": mode,
"priority": priority})
async def set_led(self, mode: int, r: int, g: int, b: int, priority: int = 6,
keep: bool = True) -> CommandResult:
return await self._send("led", {"mode": mode, "r": r, "g": g, "b": b,
"priority": priority, "keep": keep})
async def register_input_source(self, name: str, priority: int, timeout: int) -> CommandResult:
result = await self._send("register_source", {"name": name, "priority": priority,
"timeout": timeout})
if result.ok:
self.state.source_registered = True
self.state.input_source = name
return result
async def publish_raw(self, topic: str, message_type: str, payload: dict) -> CommandResult:
return await self._send("publish_raw", {"topic": topic, "type": message_type,
"fields": payload})
async def call_service(self, service: str, service_type: str, payload: dict) -> CommandResult:
return CommandResult.failure(
"Arbitrary service calls are not exposed by the agent. Use the typed commands, "
"or add a handler to agent/x2_agent.py."
)
async def list_topics(self) -> list[dict]:
return list(self.state.topic_stats.values())
async def graph(self) -> dict:
result = await self._send("graph", timeout=10.0)
return result.detail if result.ok and result.detail else {"topics": [], "services": []}
# -- on-demand streams --------------------------------------------------
async def set_stream(self, key: str, active: bool) -> CommandResult:
"""Switch a camera or the LiDAR on or off on the robot."""
result = await self._send("stream_set", {"key": key, "active": bool(active)},
timeout=8.0)
if result.ok:
# A stopped feed must not keep serving its last frame from cache.
if not active:
self._frames.pop(key, None)
await self.hub.emit("info", "vision",
f"{key} switched {'on' if active else 'off'}")
return result
async def list_streams(self) -> CommandResult:
return await self._send("stream_list", timeout=6.0)
async def lidar_points(self) -> CommandResult:
return await self._send("lidar_points", timeout=8.0)
# -- camera -------------------------------------------------------------
async def camera_frame(self, camera_key: str, stream: str = "rgb",
flip: bool | None = None) -> bytes | None:
cached = self._frames.get(camera_key)
# The agent pushes on request; a 120 ms cache stops several browser tabs
# from each pulling the same frame off the robot.
if cached and (time.time() - cached[0]) < 0.12 and cached[3] == flip:
return cached[1]
args: dict = {"key": camera_key}
if flip is not None:
args["flip"] = bool(flip)
result = await self._send("camera_frame", args, timeout=6.0)
if not result.ok or not result.detail:
return None
try:
data = base64.b64decode(result.detail["b64"])
except (KeyError, ValueError):
return None
fmt = result.detail.get("format", "jpeg")
self._frames[camera_key] = (time.time(), data,
"image/png" if fmt == "png" else "image/jpeg",
flip)
return data
def frame_content_type(self, camera_key: str = "", stream: str = "rgb") -> str:
cached = self._frames.get(camera_key)
return cached[2] if cached else "image/jpeg"

View File

@ -0,0 +1,241 @@
"""
The contract every robot bridge implements.
Two bridges exist: bridge_ros2 (real rclpy over the network) and bridge_mock
(a physics-lite simulation). The server and the whole frontend only ever talk to
this interface, so the UI is identical whether or not a robot is present.
"""
from __future__ import annotations
import time
from dataclasses import dataclass, field, asdict
from typing import Any
from . import x2_spec
@dataclass
class CommandResult:
ok: bool
message: str = ""
detail: Any = None
def as_dict(self) -> dict:
return {"ok": self.ok, "message": self.message, "detail": self.detail}
@classmethod
def failure(cls, message: str, detail: Any = None) -> "CommandResult":
return cls(False, message, detail)
@classmethod
def success(cls, message: str = "OK", detail: Any = None) -> "CommandResult":
return cls(True, message, detail)
@dataclass
class Connection:
online: bool = False
transport: str = "none" # "ros2" | "mock" | "none"
host: str = ""
ros_domain_id: int = 0
since: float | None = None
error: str = ""
simulated: bool = True
@property
def uptime_s(self) -> float:
return (time.time() - self.since) if self.since else 0.0
@dataclass
class RobotState:
"""Everything the dashboard knows about the robot right now."""
connection: Connection = field(default_factory=Connection)
mode: str = "UNKNOWN"
mode_desc: str = ""
mode_status: str = "unknown"
# Power (from /aima/hal/pmu/state)
battery_pct: float | None = None
battery_voltage: float | None = None
battery_current: float | None = None
battery_temp: float | None = None
battery_cycles: int | None = None
charging: bool = False
pmu_temp: float | None = None
fan_rpm: float | None = None
fan_pct: float | None = None
rails: dict[str, dict] = field(default_factory=dict)
# IMU (from /aima/hal/imu/*/state)
imu: dict[str, dict] = field(default_factory=dict)
# Joints, keyed by group ("head" | "waist" | "arm" | "leg")
joints: dict[str, list] = field(default_factory=dict)
# End effector
hand_type: str = "unknown"
hand_state: dict[str, list] = field(default_factory=dict)
# Sensors
touch_head: dict = field(default_factory=dict)
# Motion
velocity: dict = field(default_factory=lambda: {"forward": 0.0, "lateral": 0.0, "angular": 0.0})
velocity_command: dict = field(default_factory=lambda: {"forward": 0.0, "lateral": 0.0, "angular": 0.0})
odom: dict = field(default_factory=lambda: {"x": 0.0, "y": 0.0, "yaw": 0.0})
# Interaction
volume: int = 60
muted: bool = False
emoji_id: int | None = None
led: dict = field(default_factory=lambda: {"mode": 0, "r": 0, "g": 0, "b": 0})
# Arbitration
input_source: str = ""
input_sources: list = field(default_factory=list)
source_registered: bool = False
# Topic liveness, keyed by topic name
topic_stats: dict[str, dict] = field(default_factory=dict)
# Anything a plugin wants to publish into the shared state
custom: dict[str, Any] = field(default_factory=dict)
updated: float = field(default_factory=time.time)
def as_dict(self) -> dict:
data = asdict(self)
data["connection"]["uptime_s"] = round(self.connection.uptime_s, 1)
return data
def mark_topic(self, topic: str, expected_hz: float | None = None) -> None:
now = time.time()
stat = self.topic_stats.get(topic)
if stat is None:
stat = {"topic": topic, "count": 0, "last": now, "hz": 0.0,
"expected_hz": expected_hz, "first": now}
self.topic_stats[topic] = stat
gap = now - stat["last"]
if gap > 0:
# Exponential moving average keeps the rate readable rather than jittery.
instant = 1.0 / gap
stat["hz"] = round(instant if stat["count"] == 0 else stat["hz"] * 0.8 + instant * 0.2, 2)
stat["count"] += 1
stat["last"] = now
if expected_hz is not None:
stat["expected_hz"] = expected_hz
class Bridge:
"""Abstract robot bridge. Subclasses override what they can support."""
name = "base"
simulated = True
def __init__(self, hub, config: dict):
self.hub = hub
self.config = config
self.state = RobotState()
# -- lifecycle ----------------------------------------------------------
async def start(self) -> None:
raise NotImplementedError
async def stop(self) -> None:
raise NotImplementedError
async def reconfigure(self, config: dict) -> None:
self.config = config
# -- introspection ------------------------------------------------------
def snapshot(self) -> dict:
return self.state.as_dict()
async def list_topics(self) -> list[dict]:
return list(self.state.topic_stats.values())
# -- commands -----------------------------------------------------------
async def set_mode(self, mode_id: str) -> CommandResult:
return CommandResult.failure("set_mode not supported by this bridge")
async def get_mode(self) -> CommandResult:
return CommandResult.success(detail={"mode": self.state.mode})
async def set_velocity(self, forward: float, lateral: float, angular: float) -> CommandResult:
return CommandResult.failure("set_velocity not supported by this bridge")
async def stop_motion(self) -> CommandResult:
"""
Zero the velocity command unconditionally.
Deliberately not routed through set_velocity: every precondition that
method enforces (right mode, registered input source) is a reason to
refuse *starting* motion, never a reason to refuse stopping it. An
emergency stop that can be declined is not an emergency stop.
"""
self.state.velocity_command = {"forward": 0.0, "lateral": 0.0, "angular": 0.0}
return CommandResult.success("Motion stopped")
async def play_preset(self, motion: int, area: int, interrupt: bool = True) -> CommandResult:
return CommandResult.failure("play_preset not supported by this bridge")
async def set_joints(self, group: str, mode: str, targets: dict[str, float],
stiffness: float | None = None,
damping: float | None = None) -> CommandResult:
return CommandResult.failure("set_joints not supported by this bridge")
async def set_hand(self, side: str, positions: list[float]) -> CommandResult:
return CommandResult.failure("set_hand not supported by this bridge")
async def speak(self, text: str, priority: int = 6, interrupt: bool = False) -> CommandResult:
return CommandResult.failure("speak not supported by this bridge")
async def set_volume(self, volume: int) -> CommandResult:
return CommandResult.failure("set_volume not supported by this bridge")
async def set_mute(self, muted: bool) -> CommandResult:
return CommandResult.failure("set_mute not supported by this bridge")
async def play_emoji(self, emotion_id: int, mode: int = 1, priority: int = 6) -> CommandResult:
return CommandResult.failure("play_emoji not supported by this bridge")
async def set_led(self, mode: int, r: int, g: int, b: int,
priority: int = x2_spec.LED_DEFAULT_PRIORITY,
keep: bool = True) -> CommandResult:
return CommandResult.failure("set_led not supported by this bridge")
async def register_input_source(self, name: str, priority: int, timeout: int) -> CommandResult:
return CommandResult.failure("register_input_source not supported by this bridge")
async def camera_frame(self, camera_key: str, stream: str = "rgb",
flip: bool | None = None) -> bytes | None:
"""Latest frame as JPEG bytes, or None if unavailable."""
return None
# -- on-demand streams --------------------------------------------------
# Cameras and the LiDAR stay unsubscribed on the robot until switched on
# here, so an unopened tab costs no bandwidth.
async def set_stream(self, key: str, active: bool) -> CommandResult:
return CommandResult.failure("set_stream not supported by this bridge")
async def list_streams(self) -> CommandResult:
return CommandResult.failure("list_streams not supported by this bridge")
async def lidar_points(self) -> CommandResult:
return CommandResult.failure("lidar_points not supported by this bridge")
# -- escape hatch for plugins ------------------------------------------
async def publish_raw(self, topic: str, message_type: str, payload: dict) -> CommandResult:
return CommandResult.failure("publish_raw not supported by this bridge")
async def call_service(self, service: str, service_type: str, payload: dict) -> CommandResult:
return CommandResult.failure("call_service not supported by this bridge")

View File

@ -0,0 +1,520 @@
"""
Simulated X2, for building and testing the dashboard without a robot present.
It is deliberately more than a random-number generator: the battery drains at a
rate that depends on what the robot is doing, odometry integrates the velocity
you command, joints ease toward their targets, and mode transitions enforce the
same preconditions the real robot does. That means UI logic exercised here
behaves the same way once a real bridge is attached.
"""
from __future__ import annotations
import asyncio
import math
import struct
import time
import zlib
from . import x2_spec
from .bridge_base import Bridge, CommandResult
def _png(width: int, height: int, rgb_rows: list[bytes]) -> bytes:
"""Minimal PNG encoder - stdlib only, no Pillow dependency."""
raw = b"".join(b"\x00" + row for row in rgb_rows)
def chunk(tag: bytes, data: bytes) -> bytes:
body = tag + data
return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF)
return (
b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0))
+ chunk(b"IDAT", zlib.compress(raw, 6))
+ chunk(b"IEND", b"")
)
class MockBridge(Bridge):
name = "mock"
simulated = True
def __init__(self, hub, config: dict):
super().__init__(hub, config)
self._task: asyncio.Task | None = None
self._started = 0.0
self._joint_targets: dict[str, dict[str, float]] = {}
self._preset_until = 0.0
self._preset_label = ""
self._last_command = 0.0
self._frame_seq = 0
# Every feed starts off, matching the real agent.
self._streams: dict[str, bool] = {
**{camera["key"]: False for camera in x2_spec.CAMERAS},
x2_spec.LIDAR["key"]: False,
}
# -- lifecycle ----------------------------------------------------------
async def start(self) -> None:
self._started = time.time()
self.state.connection.online = True
self.state.connection.transport = "mock"
self.state.connection.host = self.config.get("robot_host") or "simulated"
self.state.connection.ros_domain_id = self.config.get("ros_domain_id", 0)
self.state.connection.since = self._started
self.state.connection.simulated = True
self.state.connection.error = ""
self.state.mode = "DAMPING_DEFAULT"
self.state.mode_desc = "Damping"
self.state.mode_status = "ready"
self.state.battery_pct = 87.0
self.state.battery_voltage = 50.4
self.state.battery_current = -2.1
self.state.battery_temp = 31.5
self.state.battery_cycles = 142
self.state.pmu_temp = 38.0
self.state.fan_rpm = 2400.0
self.state.fan_pct = 42.0
self.state.hand_type = "OmniHand Dynamic Edition 2025"
self.state.hand_type = "None"
for rail in x2_spec.PMU_RAILS:
self.state.rails[rail["key"]] = {
"label": rail["label"],
"voltage": rail["nominal"],
"current": 0.8,
"nominal": rail["nominal"],
"ok": True,
}
for group in x2_spec.JOINT_GROUPS:
self.state.joints[group["key"]] = [
{"name": j["name"], "label": j["label"], "position": 0.0,
"velocity": 0.0, "effort": 0.0, "error": 0}
for j in group["joints"]
]
self._joint_targets[group["key"]] = {j["name"]: 0.0 for j in group["joints"]}
# This unit reports hand type NONE, so the simulator matches it: no hand
# joints are reported until hardware is attached.
self.state.hand_state = {"left": [], "right": [], "left_type": 0, "right_type": 0}
self.state.input_sources = list(x2_spec.BUILTIN_INPUT_SOURCES)
self.state.touch_head = {"touched": False, "zones": [False] * x2_spec.TOUCH_ZONE_COUNT}
self._task = asyncio.create_task(self._loop(), name="mock-bridge")
await self.hub.emit("info", "bridge", "Simulation bridge started - no robot required")
async def stop(self) -> None:
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
self.state.connection.online = False
self.state.connection.transport = "none"
# -- simulation loop ----------------------------------------------------
async def _loop(self) -> None:
hz = max(1, int(self.config.get("telemetry_hz", 10)))
dt = 1.0 / hz
tick = 0
while True:
try:
await asyncio.sleep(dt)
tick += 1
now = time.time()
self._step_motion(dt, now)
self._step_joints(dt)
self._step_imu(now)
self._step_power(dt, tick, hz)
self._step_sensors(now)
self.state.updated = now
for topic in (x2_spec.TOPIC_IMU_CHEST, x2_spec.TOPIC_IMU_TORSO,
x2_spec.TOPIC_LOCOMOTION_VELOCITY):
self.state.mark_topic(topic)
self._record_series(now)
await self.hub.broadcast("state", self.state.as_dict())
except asyncio.CancelledError:
raise
except Exception as exc: # keep the loop alive; surface the fault
await self.hub.emit("error", "bridge", f"Simulation step failed: {exc}")
await asyncio.sleep(1.0)
def _step_motion(self, dt: float, now: float) -> None:
cmd = self.state.velocity_command
moving_mode = self.state.mode in x2_spec.DRIVEABLE_MODES
# Dead-man: a browser that stops sending joystick frames must not leave
# the robot walking. Same rule the real bridge enforces.
deadman = float(self.config.get("locomotion_deadman_s", x2_spec.LOCOMOTION_DEADMAN_S))
if self._last_command and (now - self._last_command) > deadman:
cmd = {"forward": 0.0, "lateral": 0.0, "angular": 0.0}
self.state.velocity_command = dict(cmd)
target = cmd if moving_mode else {"forward": 0.0, "lateral": 0.0, "angular": 0.0}
# Apply the documented activation dead-band.
eff = {}
for axis, key in (("forward", "forward"), ("lateral", "lateral"), ("angular", "angular")):
value = target[axis]
if abs(value) < x2_spec.VELOCITY_THRESHOLDS[key] and abs(self.state.velocity[axis]) < 1e-3:
value = 0.0
eff[axis] = value
# First-order lag toward the commanded velocity.
alpha = min(1.0, dt * 4.0)
for axis in ("forward", "lateral", "angular"):
self.state.velocity[axis] = round(
self.state.velocity[axis] + (eff[axis] - self.state.velocity[axis]) * alpha, 4
)
yaw = self.state.odom["yaw"] + self.state.velocity["angular"] * dt
fwd = self.state.velocity["forward"] * dt
lat = self.state.velocity["lateral"] * dt
self.state.odom = {
"x": round(self.state.odom["x"] + fwd * math.cos(yaw) - lat * math.sin(yaw), 4),
"y": round(self.state.odom["y"] + fwd * math.sin(yaw) + lat * math.cos(yaw), 4),
"yaw": round(math.atan2(math.sin(yaw), math.cos(yaw)), 4),
}
if self._preset_until and now > self._preset_until:
self._preset_until = 0.0
self._preset_label = ""
def _step_joints(self, dt: float) -> None:
speed = min(1.0, dt * 3.0)
walking = abs(self.state.velocity["forward"]) + abs(self.state.velocity["angular"]) > 0.05
phase = time.time() * 4.0
for group_key, joints in self.state.joints.items():
targets = self._joint_targets.get(group_key, {})
for idx, joint in enumerate(joints):
target = targets.get(joint["name"], 0.0)
# Legs get a gait overlay while walking so the joint view is alive.
if group_key == "leg" and walking:
side = 0.0 if idx < 6 else math.pi
target += 0.22 * math.sin(phase + side) * (1 if idx % 6 in (0, 3) else 0.4)
if group_key == "arm" and self._preset_until:
target += 0.5 * math.sin(phase * 0.8) * (1 if idx % 7 < 3 else 0.3)
previous = joint["position"]
joint["position"] = round(previous + (target - previous) * speed, 4)
joint["velocity"] = round((joint["position"] - previous) / dt, 4)
joint["effort"] = round(abs(joint["velocity"]) * 3.2 + abs(joint["position"]) * 1.4, 3)
def _step_imu(self, now: float) -> None:
walking = abs(self.state.velocity["forward"]) > 0.05
wobble = 0.035 if walking else 0.004
base = now * 3.0
for key, offset in (("chest", 0.0), ("torso", 1.1)):
roll = wobble * math.sin(base * 2.1 + offset)
pitch = wobble * math.cos(base * 1.7 + offset) - self.state.velocity["forward"] * 0.08
self.state.imu[key] = {
"roll": round(roll, 4),
"pitch": round(pitch, 4),
"yaw": round(self.state.odom["yaw"], 4),
"accel_x": round(self.state.velocity["forward"] * 1.2 + wobble * 9 * math.sin(base * 5), 3),
"accel_y": round(self.state.velocity["lateral"] * 1.2 + wobble * 7 * math.cos(base * 4), 3),
"accel_z": round(9.81 + wobble * 5 * math.sin(base * 6 + offset), 3),
"gyro_x": round(wobble * 4 * math.cos(base * 3), 4),
"gyro_y": round(wobble * 4 * math.sin(base * 3.3), 4),
"gyro_z": round(self.state.velocity["angular"], 4),
"temp": round(34.0 + math.sin(base * 0.05) * 1.5, 2),
}
def _step_power(self, dt: float, tick: int, hz: int) -> None:
# Draw scales with what the robot is actually doing.
load = 0.5
if self.state.mode in ("STAND_DEFAULT", "LOCOMOTION_DEFAULT"):
load = 1.4
load += abs(self.state.velocity["forward"]) * 2.0
load += abs(self.state.velocity["angular"]) * 1.2
if self._preset_until:
load += 1.0
drain_per_hour = 6.0 * load
self.state.battery_pct = max(0.0, round(self.state.battery_pct - drain_per_hour * dt / 3600.0, 4))
self.state.battery_current = round(-3.0 * load, 3)
self.state.battery_voltage = round(44.0 + (self.state.battery_pct / 100.0) * 8.4, 3)
self.state.battery_temp = round(30.0 + load * 3.5 + math.sin(time.time() * 0.02) * 0.6, 2)
self.state.pmu_temp = round(35.0 + load * 4.0, 2)
self.state.fan_pct = round(min(100.0, 30.0 + load * 28.0), 1)
self.state.fan_rpm = round(1200 + self.state.fan_pct * 42, 0)
self.state.charging = False
for key, rail in self.state.rails.items():
nominal = rail["nominal"]
rail["voltage"] = round(nominal - load * 0.12 + math.sin(time.time() * 0.7) * 0.03, 3)
rail["current"] = round(0.6 + load * (1.8 if key in ("bus_48v", "orin") else 0.5), 3)
rail["ok"] = rail["voltage"] > nominal * 0.85
if tick % max(1, hz * 5) == 0:
self.state.mark_topic(x2_spec.TOPIC_PMU_STATE, x2_spec.PMU_RATE_HZ)
def _step_sensors(self, now: float) -> None:
# A periodic touch event so the sensors tab shows something happening.
touched = (int(now) % 37) < 2
zones = [False] * x2_spec.TOUCH_ZONE_COUNT
if touched:
zones[int(now) % x2_spec.TOUCH_ZONE_COUNT] = True
self.state.touch_head = {"touched": touched, "zones": zones}
self.state.mark_topic(x2_spec.TOPIC_TOUCH_HEAD, 100)
def _record_series(self, now: float) -> None:
self.hub.record("battery_pct", self.state.battery_pct, now)
self.hub.record("battery_voltage", self.state.battery_voltage, now)
self.hub.record("battery_current", self.state.battery_current, now)
self.hub.record("battery_temp", self.state.battery_temp, now)
self.hub.record("pmu_temp", self.state.pmu_temp, now)
self.hub.record("fan_pct", self.state.fan_pct, now)
self.hub.record("vel_forward", self.state.velocity["forward"], now)
self.hub.record("vel_lateral", self.state.velocity["lateral"], now)
self.hub.record("vel_angular", self.state.velocity["angular"], now)
chest = self.state.imu.get("chest", {})
self.hub.record("imu_roll", chest.get("roll"), now)
self.hub.record("imu_pitch", chest.get("pitch"), now)
self.hub.record("imu_yaw", chest.get("yaw"), now)
# -- commands -----------------------------------------------------------
async def set_mode(self, mode_id: str) -> CommandResult:
match = next((m for m in x2_spec.MC_MODES if m["id"] == mode_id), None)
if not match:
return CommandResult.failure(f"Unknown mode '{mode_id}'")
self.state.mode = mode_id
self.state.mode_desc = match["label"]
self.state.mode_status = "Running"
if mode_id not in x2_spec.DRIVEABLE_MODES:
self.state.velocity_command = {"forward": 0.0, "lateral": 0.0, "angular": 0.0}
await self.hub.emit("info", "mode", f"Mode set to {match['label']}")
return CommandResult.success(f"Mode set to {match['label']}", {"mode": mode_id})
async def set_velocity(self, forward: float, lateral: float, angular: float) -> CommandResult:
if self.state.mode not in x2_spec.DRIVEABLE_MODES:
return CommandResult.failure("Switch to Stable stand or Locomotion before commanding velocity")
if not self.state.source_registered:
return CommandResult.failure("Register an MC input source first (Control tab)")
self.state.velocity_command = {
"forward": round(float(forward), 4),
"lateral": round(float(lateral), 4),
"angular": round(float(angular), 4),
}
self._last_command = time.time()
return CommandResult.success("Velocity accepted", self.state.velocity_command)
async def stop_motion(self) -> CommandResult:
self.state.velocity_command = {"forward": 0.0, "lateral": 0.0, "angular": 0.0}
self._last_command = time.time()
return CommandResult.success("Motion stopped")
async def play_preset(self, motion: int, area: int, interrupt: bool = True) -> CommandResult:
if self.state.mode != "STAND_DEFAULT":
return CommandResult.failure("Preset motions require Stable stand mode")
preset = next((p for p in x2_spec.PRESET_MOTIONS
if p["motion"] == motion and p["area"] == area), None)
label = preset["label"] if preset else f"motion {motion}"
self._preset_until = time.time() + 3.0
self._preset_label = label
await self.hub.emit("info", "motion", f"Playing preset: {label}")
return CommandResult.success(f"Playing {label}",
{"task_id": int(time.time() * 1000) % 1_000_000})
async def set_joints(self, group: str, mode: str, targets: dict[str, float],
stiffness=None, damping=None) -> CommandResult:
if group not in self._joint_targets:
return CommandResult.failure(f"Unknown joint group '{group}'")
spec = x2_spec.JOINT_GROUP_BY_KEY[group]
limits = {j["name"]: j for j in spec["joints"]}
applied, rejected = {}, {}
for name, value in targets.items():
joint = limits.get(name)
if joint is None:
rejected[name] = "unknown joint"
continue
lo = math.radians(joint["min_deg"])
hi = math.radians(joint["max_deg"])
clamped = max(lo, min(hi, float(value)))
if abs(clamped - float(value)) > 1e-6:
rejected[name] = f"clamped to [{joint['min_deg']}, {joint['max_deg']}] deg"
self._joint_targets[group][name] = clamped
applied[name] = round(clamped, 4)
return CommandResult.success(f"{len(applied)} joint target(s) applied",
{"applied": applied, "adjusted": rejected})
async def set_hand(self, side: str, positions: list[float]) -> CommandResult:
if side not in ("left", "right"):
return CommandResult.failure(f"Unknown side '{side}'")
# Mirror the real unit: with hand type NONE there is nothing to drive.
self.state.hand_state[side] = [
{"name": name, "position": round(float(positions[i]), 4) if i < len(positions) else 0.0,
"velocity": 0.0, "effort": 0.0, "fault": 0}
for i, name in enumerate(x2_spec.DEXHAND_JOINTS)
]
return CommandResult.success(f"{side} hand updated")
async def speak(self, text: str, priority: int = 6, interrupt: bool = False) -> CommandResult:
if not text.strip():
return CommandResult.failure("Nothing to say")
await self.hub.emit("info", "voice", f'TTS: "{text[:120]}"')
return CommandResult.success("Queued for speech", {"text": text, "priority": priority})
async def set_volume(self, volume: int) -> CommandResult:
self.state.volume = max(0, min(100, int(volume)))
return CommandResult.success(f"Volume {self.state.volume}")
async def set_mute(self, muted: bool) -> CommandResult:
self.state.muted = bool(muted)
return CommandResult.success("Muted" if muted else "Unmuted")
async def play_emoji(self, emotion_id: int, mode: int = 1, priority: int = 6) -> CommandResult:
self.state.emoji_id = int(emotion_id)
entry = next((e for e in x2_spec.EMOJIS if e["id"] == int(emotion_id)), None)
return CommandResult.success(f"Showing {entry['label'] if entry else emotion_id}")
async def set_led(self, mode: int, r: int, g: int, b: int, priority: int = 6,
keep: bool = True) -> CommandResult:
self.state.led = {"mode": int(mode), "r": int(r), "g": int(g),
"b": int(b), "keep": bool(keep)}
return CommandResult.success("LED updated", self.state.led)
async def register_input_source(self, name: str, priority: int, timeout: int) -> CommandResult:
existing = next((s for s in self.state.input_sources if s["name"] == name), None)
if existing:
existing.update({"priority": priority, "timeout": timeout})
else:
self.state.input_sources.append(
{"name": name, "priority": priority, "timeout": timeout, "desc": "This dashboard"}
)
self.state.source_registered = True
# The built-in sources are listed for reference but nothing is actually
# driving them in simulation, so the newly registered source wins
# arbitration. On a real robot the firmware decides this.
self.state.input_source = name
await self.hub.emit("info", "control", f"Input source '{name}' registered at priority {priority}")
return CommandResult.success(f"Registered '{name}'", {"current": self.state.input_source})
async def publish_raw(self, topic: str, message_type: str, payload: dict) -> CommandResult:
self.state.mark_topic(topic)
return CommandResult.success(f"[simulated] published to {topic}", payload)
async def call_service(self, service: str, service_type: str, payload: dict) -> CommandResult:
return CommandResult.success(f"[simulated] called {service}", {"request": payload})
# -- synthetic camera ---------------------------------------------------
async def camera_frame(self, camera_key: str, stream: str = "rgb",
flip: bool | None = None) -> bytes | None:
"""A moving synthetic scene, so the Vision tab is testable without hardware."""
# Off means off in simulation too, or the Vision tab's switches would
# look broken here and only work against real hardware.
if not self._streams.get(camera_key):
return None
width, height = 192, 144
self._frame_seq += 1
t = time.time()
yaw = self.state.odom["yaw"]
depth = stream == "depth" or camera_key.startswith("depth")
# Horizon shifts with pitch, scene pans with yaw - so driving the robot
# visibly changes the view.
pitch = self.state.imu.get("chest", {}).get("pitch", 0.0)
horizon = int(height * 0.55 + pitch * 220)
pan = (yaw * 90.0 + t * 6.0) % width
rows = []
for y in range(height):
row = bytearray()
for x in range(width):
if depth:
# Distance rises toward the horizon; a nearby object sweeps past.
d = abs(y - horizon) / height
obj = math.exp(-(((x - pan) % width - width / 2) ** 2) / 400.0)
v = max(0.0, min(1.0, d * 1.6 - obj * 0.5))
# Single-hue blue ramp, light = near, dark = far.
r = int(205 * (1 - v) + 13 * v)
g = int(226 * (1 - v) + 54 * v)
b = int(251 * (1 - v) + 107 * v)
else:
if y < horizon:
shade = y / max(1, horizon)
r, g, b = int(24 + 26 * shade), int(28 + 32 * shade), int(38 + 46 * shade)
else:
shade = (y - horizon) / max(1, height - horizon)
r, g, b = int(30 + 24 * shade), int(34 + 20 * shade), int(32 + 16 * shade)
# Vertical markers that slide with yaw, giving visible motion.
if (int(x + pan) // 24) % 2 == 0 and y > horizon:
r, g, b = min(255, r + 26), min(255, g + 22), min(255, b + 18)
if abs(y - horizon) <= 1:
r, g, b = 57, 135, 229
row += bytes((r, g, b))
rows.append(bytes(row))
if flip:
# 180 degrees = reverse the row order, and the pixels within each
# row. Pixels are 3 bytes, so reverse in triples, not bytewise.
def reverse_pixels(row: bytes) -> bytes:
return b"".join(row[i - 3:i] for i in range(len(row), 0, -3))
rows = [reverse_pixels(row) for row in reversed(rows)]
return _png(width, height, rows)
def frame_content_type(self, camera_key: str = "", stream: str = "rgb") -> str:
return "image/png"
# -- on-demand streams --------------------------------------------------
async def set_stream(self, key: str, active: bool) -> CommandResult:
self._streams[key] = bool(active)
self.state.custom["streams"] = {
k: {"key": k, "active": v} for k, v in self._streams.items()
}
return CommandResult.success(
f"[simulated] {key} {'on' if active else 'off'}", {"key": key, "active": active})
async def list_streams(self) -> CommandResult:
return CommandResult.success("[simulated] streams", {
"streams": {k: {"key": k, "active": v} for k, v in self._streams.items()},
})
async def lidar_points(self) -> CommandResult:
"""A synthetic room, so the LiDAR view is testable with no robot attached."""
if not self._streams.get(x2_spec.LIDAR["key"]):
return CommandResult.failure("LiDAR is off")
t = time.time()
points = []
# Four walls and a floor patch, swept by a rotating scan line so the
# view visibly updates.
for i in range(900):
angle = (i / 900.0) * math.tau
radius = 3.0 + 0.8 * math.sin(angle * 4 + t * 0.3)
for z in (-0.4, 0.1, 0.6, 1.1):
points.append([round(radius * math.cos(angle), 3),
round(radius * math.sin(angle), 3),
z, round(40 + 60 * abs(math.sin(angle + t)), 1)])
return CommandResult.success("[simulated] points", {
"ts": t, "count": len(points), "points": points,
"frame": x2_spec.LIDAR["frame"],
})

107
x2_dashboard/backend/hub.py Normal file
View File

@ -0,0 +1,107 @@
"""
Fan-out hub between the robot bridge and every connected browser.
One bridge produces state; N browsers consume it. Each client gets its own
bounded queue so a slow tab (a backgrounded phone, say) can never stall the
telemetry loop for everyone else - it just drops frames.
"""
from __future__ import annotations
import asyncio
import json
import time
from collections import deque
from typing import Any
class Hub:
def __init__(self, history_seconds: int = 120, sample_hz: int = 10):
self._clients: set[asyncio.Queue] = set()
self._lock = asyncio.Lock()
self._series: dict[str, deque] = {}
self._history_points = max(60, int(history_seconds * sample_hz))
self._events: deque = deque(maxlen=400)
# -- client lifecycle ---------------------------------------------------
async def register(self) -> asyncio.Queue:
queue: asyncio.Queue = asyncio.Queue(maxsize=32)
async with self._lock:
self._clients.add(queue)
return queue
async def unregister(self, queue: asyncio.Queue) -> None:
async with self._lock:
self._clients.discard(queue)
@property
def client_count(self) -> int:
return len(self._clients)
# -- broadcast ----------------------------------------------------------
async def broadcast(self, kind: str, payload: Any) -> None:
message = json.dumps({"type": kind, "ts": time.time(), "data": payload},
separators=(",", ":"), default=str)
dead = []
for queue in list(self._clients):
try:
queue.put_nowait(message)
except asyncio.QueueFull:
# Drop the oldest frame rather than the newest - stale telemetry
# is worth less than current telemetry.
try:
queue.get_nowait()
queue.put_nowait(message)
except (asyncio.QueueEmpty, asyncio.QueueFull):
dead.append(queue)
for queue in dead:
await self.unregister(queue)
# -- time series --------------------------------------------------------
def record(self, key: str, value: float, ts: float | None = None) -> None:
"""Append one sample to a named series for the dashboard's charts."""
if value is None:
return
try:
value = float(value)
except (TypeError, ValueError):
return
series = self._series.get(key)
if series is None:
series = deque(maxlen=self._history_points)
self._series[key] = series
series.append((ts if ts is not None else time.time(), round(value, 4)))
def series(self, key: str, limit: int | None = None) -> list[list[float]]:
data = list(self._series.get(key, ()))
if limit:
data = data[-limit:]
return [[t, v] for t, v in data]
def all_series(self, limit: int | None = None) -> dict[str, list]:
return {k: self.series(k, limit) for k in self._series}
def series_keys(self) -> list[str]:
return sorted(self._series)
# -- event log ----------------------------------------------------------
def log(self, level: str, source: str, message: str, detail: Any = None) -> dict:
entry = {
"ts": time.time(),
"level": level,
"source": source,
"message": message,
"detail": detail,
}
self._events.append(entry)
return entry
def events(self, limit: int = 200) -> list[dict]:
return list(self._events)[-limit:]
async def emit(self, level: str, source: str, message: str, detail: Any = None) -> None:
await self.broadcast("event", self.log(level, source, message, detail))

View File

@ -0,0 +1,346 @@
"""
Network discovery and host addressing.
Nothing in this dashboard assumes a fixed IP. Three separate problems are solved
here:
1. Which addresses is this dashboard reachable on? -> local_addresses()
The server binds 0.0.0.0, so it answers on every interface. We enumerate them
so the operator can open the dashboard from a phone on the same Wi-Fi.
2. What subnets are we on? -> local_networks()
Derived from the live interface list, so moving between networks just works.
3. Where is the robot right now? -> scan_subnet() / probe_host()
A bounded TCP sweep of the current subnet, matching hosts that answer on the
ports an X2 exposes.
"""
from __future__ import annotations
import asyncio
import ipaddress
import socket
import subprocess
import sys
import time
from typing import Iterable
from . import x2_spec
# --------------------------------------------------------------------------
# Local interface enumeration
# --------------------------------------------------------------------------
def _addresses_from_os() -> set[str]:
"""
Every IPv4 the OS reports, by asking the OS directly.
getaddrinfo() and the UDP-connect trick below both under-report: the first
depends on how the hostname resolves, the second only ever reveals the
default-route interface. On a machine with both Wi-Fi and Ethernet that
means the dashboard advertised only one of its addresses.
"""
found: set[str] = set()
try:
if sys.platform == "win32":
out = subprocess.run(
["ipconfig"], capture_output=True, text=True, timeout=8,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
).stdout
for line in out.splitlines():
line = line.strip()
# Localised Windows uses a translated label, so match on the
# "IPv4" token rather than the full English phrase.
if "IPv4" in line and ":" in line:
found.add(line.split(":")[-1].strip().replace("(Preferred)", "").strip())
else:
out = subprocess.run(
["ip", "-o", "-4", "addr", "show"],
capture_output=True, text=True, timeout=8,
).stdout
for line in out.splitlines():
for token in line.split():
if "/" in token and token[0].isdigit():
found.add(token.split("/")[0])
break
except (OSError, subprocess.SubprocessError, ValueError):
pass
return found
def _addresses_via_socket() -> set[str]:
"""Addresses this host answers on, without third-party dependencies."""
found: set[str] = set(_addresses_from_os())
try:
for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
found.add(info[4][0])
except socket.gaierror:
pass
# Opening a UDP socket toward a public address makes the kernel pick the
# outbound interface and tell us its address - no packet is actually sent.
for probe in ("8.8.8.8", "1.1.1.1"):
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
sock.connect((probe, 80))
found.add(sock.getsockname()[0])
except OSError:
pass
finally:
sock.close()
cleaned = set()
for addr in found:
try:
ipaddress.IPv4Address(addr)
cleaned.add(addr)
except ipaddress.AddressValueError:
continue
return cleaned
def _netmask_for(addr: str) -> str | None:
"""Best-effort netmask lookup. Falls back to a /24 assumption."""
try:
if sys.platform == "win32":
out = subprocess.run(
["ipconfig"], capture_output=True, text=True, timeout=5,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
).stdout
block: list[str] = []
for line in out.splitlines():
block.append(line)
if addr in line:
# The mask is printed on the line after the address.
idx = block.index(line)
for follow in out.splitlines()[idx + 1: idx + 4]:
if "Mask" in follow or "掩码" in follow:
return follow.split(":")[-1].strip()
else:
out = subprocess.run(
["ip", "-o", "-f", "inet", "addr", "show"],
capture_output=True, text=True, timeout=5,
).stdout
for line in out.splitlines():
if f"{addr}/" in line:
for tok in line.split():
if tok.startswith(f"{addr}/"):
return tok.split("/")[1]
except (OSError, subprocess.SubprocessError, ValueError):
pass
return None
def local_addresses() -> list[dict]:
"""Every usable IPv4 this machine holds, loopback last."""
result = []
for addr in sorted(_addresses_via_socket()):
try:
ip = ipaddress.IPv4Address(addr)
except ipaddress.AddressValueError:
continue
result.append({
"address": addr,
"loopback": ip.is_loopback,
"private": ip.is_private,
"link_local": ip.is_link_local,
})
result.sort(key=lambda r: (r["loopback"], r["link_local"], not r["private"], r["address"]))
return result
def local_networks() -> list[str]:
"""CIDR networks this host sits on, excluding loopback and link-local."""
nets: list[str] = []
for entry in local_addresses():
if entry["loopback"] or entry["link_local"]:
continue
addr = entry["address"]
mask = _netmask_for(addr)
try:
iface = ipaddress.IPv4Interface(f"{addr}/{mask}" if mask else f"{addr}/24")
except (ipaddress.AddressValueError, ipaddress.NetmaskValueError, ValueError):
iface = ipaddress.IPv4Interface(f"{addr}/24")
cidr = str(iface.network)
if cidr not in nets:
nets.append(cidr)
return nets
def primary_address() -> str | None:
"""
The address other devices on the LAN would actually reach us on.
Delegates to nic.detect(), which ranks real adapters and refuses to return a
stale one. Enumerating "the first non-loopback address" is not good enough:
a disconnected adapter keeps its address, answers local connections through
loopback, and would be published as a link nobody else can open.
"""
from . import nic
return nic.primary_address()
def address_detail() -> dict:
"""Full detection result - which adapter won, what was rejected and why."""
from . import nic
found = nic.detect()
primary = found["primary"]
return {
"ip": primary["ip"] if primary else None,
"adapter": primary["adapter"] if primary else None,
"kind": ("wifi" if primary and primary.get("iftype") == nic.IF_TYPE_WIFI
else "wired" if primary else None),
"gateway": primary.get("gateway") if primary else None,
"reason": found["reason"],
"reason_text": nic.REASON_TEXT.get(found["reason"] or "", ""),
"alternates": [
{"ip": a["ip"], "adapter": a["adapter"],
"kind": "wifi" if a.get("iftype") == nic.IF_TYPE_WIFI else "wired"}
for a in found["alternates"]
],
"rejected": [
{"ip": r["ip"], "adapter": r["adapter"], "why": r["rejected"]}
for r in found["rejected"]
if not str(r["ip"]).startswith("127.")
],
"checked_at": found["checked_at"],
}
def hostname_urls(port: int, advertised: str | None = None) -> list[str]:
"""
Name-based URLs for the dashboard.
Only the name the dashboard advertises for itself is offered. The PC's own
computer name is deliberately not published: this is a robot dashboard, and
the link should say so. The bare single-label form is left out too - it
resolves on Windows from local configuration without a packet reaching the
network, and no phone resolves it at all.
"""
if not advertised:
return []
return [f"http://{advertised.strip().strip('.').lower()}.local:{port}"]
def dashboard_urls(port: int, advertised: str | None = None) -> list[str]:
"""
URLs for the dashboard, best first.
The live Wi-Fi address leads: it is what a phone can always reach, needing
no mDNS, no multicast and no DNS. Names come after it, because on many
access points `.local` resolution is blocked between wireless clients.
"""
urls: list[str] = []
detail = address_detail()
if detail["ip"]:
urls.append(f"http://{detail['ip']}:{port}")
for alternate in detail["alternates"]:
urls.append(f"http://{alternate['ip']}:{port}")
for url in hostname_urls(port, advertised):
if url not in urls:
urls.append(url)
urls.append(f"http://localhost:{port}")
return urls
# --------------------------------------------------------------------------
# Robot probing
# --------------------------------------------------------------------------
async def _tcp_open(host: str, port: int, timeout: float) -> bool:
try:
fut = asyncio.open_connection(host, port)
reader, writer = await asyncio.wait_for(fut, timeout=timeout)
writer.close()
try:
await writer.wait_closed()
except (ConnectionError, OSError):
pass
return True
except (asyncio.TimeoutError, OSError):
return False
async def probe_host(host: str, ports: Iterable[int] | None = None,
timeout: float = 0.6) -> dict:
"""Check which of the X2's known ports a host answers on."""
ports = list(ports or x2_spec.DISCOVERY_PORTS)
started = time.monotonic()
results = await asyncio.gather(*[_tcp_open(host, p, timeout) for p in ports])
open_ports = [p for p, ok in zip(ports, results) if ok]
hostname = None
if open_ports:
try:
hostname = await asyncio.get_running_loop().run_in_executor(
None, lambda: socket.gethostbyaddr(host)[0]
)
except (OSError, socket.herror):
hostname = None
return {
"host": host,
"reachable": bool(open_ports),
"open_ports": open_ports,
"hostname": hostname,
"latency_ms": round((time.monotonic() - started) * 1000, 1),
"is_pc1": host == x2_spec.PC1_MOTION_CONTROL_IP,
}
async def scan_subnet(cidr: str, ports: Iterable[int] | None = None,
timeout: float = 0.4, concurrency: int = 128,
progress=None) -> list[dict]:
"""
Sweep a subnet for hosts answering on X2 ports.
Refuses networks larger than /22 - a /16 sweep is 65k hosts and would hang
for minutes rather than seconds.
"""
network = ipaddress.IPv4Network(cidr, strict=False)
if network.num_addresses > 1024:
raise ValueError(
f"{cidr} has {network.num_addresses} addresses; "
"narrow the range to /22 or smaller before scanning."
)
hosts = list(network.hosts())
semaphore = asyncio.Semaphore(concurrency)
done = 0
total = len(hosts)
async def one(ip):
nonlocal done
async with semaphore:
res = await probe_host(str(ip), ports, timeout)
done += 1
if progress and done % 16 == 0:
await progress(done, total)
return res
results = await asyncio.gather(*[one(ip) for ip in hosts])
if progress:
await progress(total, total)
return [r for r in results if r["reachable"]]
async def resolve(host: str) -> str | None:
"""Resolve a hostname to an IPv4 address, or return it unchanged if already one."""
try:
ipaddress.IPv4Address(host)
return host
except ipaddress.AddressValueError:
pass
try:
loop = asyncio.get_running_loop()
info = await loop.getaddrinfo(host, None, family=socket.AF_INET)
return info[0][4][0]
except (socket.gaierror, OSError, IndexError):
return None

592
x2_dashboard/backend/nic.py Normal file
View File

@ -0,0 +1,592 @@
"""
Find the LAN address this machine is actually reachable on, live.
The dashboard publishes `http://<ip>:8770`, so picking the wrong address means
handing someone a link that cannot work. That is easy to get wrong: this very
machine has a *disconnected* Ethernet adapter still holding 192.168.123.222, and
a local HTTP fetch to that address SUCCEEDS - Windows routes traffic to any of
its own addresses through loopback, skipping the adapter entirely. It even
answers faster than the real one. There is therefore no connect, bind or fetch
test that can tell a reachable address from a dead one; the decision has to come
from adapter metadata.
The rule below was chosen by running candidate rules against 23 synthetic
adapter tables (VPNs of three different shapes, Hyper-V bridges, mobile hotspot,
docked Ethernet, duplicate-IP detection, campus public addressing, renamed
adapters). Two obvious-looking rules broke 9 times each; this one breaks once,
on a case that is genuinely undecidable (two Wi-Fi radios, nothing in the
adapter table says which one the phone is associated to).
Deliberate non-decisions, each of which was tried and rejected:
* No `is_private` test. It over-rejects campus and CGNAT (100.64/10) addresses
that phones reach perfectly well, and under-rejects Hyper-V's 172.x.
* No adapter *name* matching. "Wi-Fi" is user-renameable and every ipconfig
label is translated on non-English Windows.
* Interface metric is the LAST tiebreak, never a gate. Corporate VPNs
deliberately set a low metric, and on this machine four *disconnected*
adapters sit at metric 25 while the one working NIC is at 30.
"""
from __future__ import annotations
import ipaddress
import json
import logging
import os
import platform
import subprocess
import threading
import time
log = logging.getLogger("x2.nic")
# Re-read this often at most. At ~2 ms a detection the cost is irrelevant; the
# TTL exists so two requests in one page load cannot disagree mid-roam, and so a
# stale address never outlives a single human retry.
TTL_SECONDS = 2.0
IF_TYPE_ETHERNET = 6
IF_TYPE_LOOPBACK = 24
IF_TYPE_WIFI = 71
IF_TYPE_TUNNEL = 131
OPER_STATUS_UP = 1
DAD_STATE_PREFERRED = 4
PREFIX_ORIGIN_WELLKNOWN = 2
PREFIX_ORIGIN_DHCP = 3
# --------------------------------------------------------------------------
# Ranking - platform independent, operating on normalised records
# --------------------------------------------------------------------------
def _class_of(record: dict) -> int:
"""Higher is a better candidate to hand to a phone."""
hardware = record.get("hardware")
iftype = record.get("iftype")
if hardware and iftype == IF_TYPE_WIFI:
return 3 # a real Wi-Fi radio
if hardware and iftype == IF_TYPE_ETHERNET:
return 2 # a real wired NIC
if iftype == IF_TYPE_WIFI:
return 1 # hotspot / Wi-Fi Direct - still a radio
return 0 # bridge or unknown
def _rejected(record: dict) -> str | None:
"""Why this address must not be published, or None if it is usable."""
iftype = record.get("iftype")
if iftype in (IF_TYPE_LOOPBACK, IF_TYPE_TUNNEL):
return "loopback or tunnel"
if record.get("oper_status") != OPER_STATUS_UP:
return "adapter is down"
if record.get("connected") is False:
return "no link"
if record.get("dad_state") not in (None, DAD_STATE_PREFERRED):
# Deprecated = the stale address on a disconnected NIC.
# Tentative = still running duplicate-address detection.
# Duplicate = the stack itself refuses to send from it.
return "address not preferred"
if record.get("prefix_origin") == PREFIX_ORIGIN_WELLKNOWN:
return "self-assigned (APIPA)"
try:
ip = ipaddress.IPv4Address(record["ip"])
except (ipaddress.AddressValueError, KeyError):
return "not an IPv4 address"
if ip.is_loopback or ip.is_link_local:
return "loopback or link-local"
# Virtual adapters (VPN NDIS miniports, Hyper-V, WSL, docker) are not a path
# to a phone. Two exceptions: any Wi-Fi-class radio, which covers the mobile
# hotspot case; and a bridge that has taken over a live physical NIC's
# address, which is what a Hyper-V *external* switch does.
if not record.get("hardware", True):
is_radio = iftype == IF_TYPE_WIFI
is_external_bridge = (
record.get("physical_link_present")
and record.get("has_gateway")
and record.get("prefix_origin") == PREFIX_ORIGIN_DHCP
)
if not (is_radio or is_external_bridge):
return "virtual adapter"
return None
def _sort_key(record: dict):
try:
numeric = int(ipaddress.IPv4Address(record["ip"]))
except Exception:
numeric = 0
return (
1 if record.get("has_gateway") else 0,
_class_of(record),
1 if record.get("prefix_origin") == PREFIX_ORIGIN_DHCP else 0,
-(record.get("metric") or 0),
-numeric,
)
def rank(records: list[dict]) -> tuple[list[dict], list[dict]]:
"""Split candidates into (usable, best first) and (rejected, with reasons)."""
usable, rejected = [], []
for record in records:
why = _rejected(record)
if why:
rejected.append({**record, "rejected": why})
else:
usable.append(record)
usable.sort(key=_sort_key, reverse=True)
return usable, rejected
def _reason_for_nothing(rejected: list[dict]) -> str:
"""Explain an empty result honestly instead of guessing an address."""
if not rejected:
return "no_network_interface"
kinds = {r.get("rejected") for r in rejected}
wifi = [r for r in rejected if r.get("iftype") == IF_TYPE_WIFI and r.get("hardware")]
if wifi and all(r.get("oper_status") != OPER_STATUS_UP or r.get("connected") is False
for r in wifi):
return "wifi_down"
if "address not preferred" in kinds or "self-assigned (APIPA)" in kinds:
return "awaiting_dhcp"
if all(not r.get("has_gateway") for r in rejected):
return "no_router_on_this_network"
return "no_usable_address"
# --------------------------------------------------------------------------
# Windows collector - GetAdaptersAddresses + GetIfTable2 via ctypes
# --------------------------------------------------------------------------
def _collect_windows() -> list[dict]:
import ctypes
from ctypes import wintypes
iphlpapi = ctypes.WinDLL("iphlpapi")
class SOCKET_ADDRESS(ctypes.Structure):
_fields_ = [("lpSockaddr", ctypes.c_void_p), ("iSockaddrLength", ctypes.c_int)]
class IP_ADAPTER_UNICAST_ADDRESS(ctypes.Structure):
pass
IP_ADAPTER_UNICAST_ADDRESS._fields_ = [
("Length", wintypes.ULONG),
("Flags", wintypes.DWORD),
("Next", ctypes.POINTER(IP_ADAPTER_UNICAST_ADDRESS)),
("Address", SOCKET_ADDRESS),
("PrefixOrigin", ctypes.c_int),
("SuffixOrigin", ctypes.c_int),
("DadState", ctypes.c_int),
("ValidLifetime", wintypes.ULONG),
("PreferredLifetime", wintypes.ULONG),
("LeaseLifetime", wintypes.ULONG),
("OnLinkPrefixLength", ctypes.c_ubyte),
]
class IP_ADAPTER_GATEWAY_ADDRESS(ctypes.Structure):
pass
IP_ADAPTER_GATEWAY_ADDRESS._fields_ = [
("Length", wintypes.ULONG),
("Reserved", wintypes.DWORD),
("Next", ctypes.POINTER(IP_ADAPTER_GATEWAY_ADDRESS)),
("Address", SOCKET_ADDRESS),
]
class IP_ADAPTER_ADDRESSES(ctypes.Structure):
pass
IP_ADAPTER_ADDRESSES._fields_ = [
("Length", wintypes.ULONG),
("IfIndex", wintypes.DWORD),
("Next", ctypes.POINTER(IP_ADAPTER_ADDRESSES)),
("AdapterName", ctypes.c_char_p),
("FirstUnicastAddress", ctypes.POINTER(IP_ADAPTER_UNICAST_ADDRESS)),
("FirstAnycastAddress", ctypes.c_void_p),
("FirstMulticastAddress", ctypes.c_void_p),
("FirstDnsServerAddress", ctypes.c_void_p),
("DnsSuffix", ctypes.c_wchar_p),
("Description", ctypes.c_wchar_p),
("FriendlyName", ctypes.c_wchar_p),
("PhysicalAddress", ctypes.c_ubyte * 8),
("PhysicalAddressLength", wintypes.ULONG),
("Flags", wintypes.ULONG),
("Mtu", wintypes.ULONG),
("IfType", wintypes.ULONG),
("OperStatus", ctypes.c_int),
("Ipv6IfIndex", wintypes.DWORD),
("ZoneIndices", wintypes.ULONG * 16),
("FirstPrefix", ctypes.c_void_p),
("TransmitLinkSpeed", ctypes.c_uint64),
("ReceiveLinkSpeed", ctypes.c_uint64),
("FirstWinsServerAddress", ctypes.c_void_p),
("FirstGatewayAddress", ctypes.POINTER(IP_ADAPTER_GATEWAY_ADDRESS)),
("Ipv4Metric", wintypes.ULONG),
("Ipv6Metric", wintypes.ULONG),
("Luid", ctypes.c_uint64),
("Dhcpv4Server", SOCKET_ADDRESS),
("CompartmentId", wintypes.DWORD),
("NetworkGuid", ctypes.c_ubyte * 16),
("ConnectionType", ctypes.c_int),
("TunnelType", ctypes.c_int),
]
AF_INET = 2
GAA_FLAG_INCLUDE_GATEWAYS = 0x0080
GAA_FLAG_SKIP_ANYCAST = 0x0002
GAA_FLAG_SKIP_MULTICAST = 0x0004
GAA_FLAG_SKIP_DNS_SERVER = 0x0008
flags = (GAA_FLAG_INCLUDE_GATEWAYS | GAA_FLAG_SKIP_ANYCAST
| GAA_FLAG_SKIP_MULTICAST | GAA_FLAG_SKIP_DNS_SERVER)
def sockaddr_to_ip(sa: SOCKET_ADDRESS) -> str | None:
if not sa.lpSockaddr:
return None
raw = ctypes.string_at(sa.lpSockaddr, 8)
if int.from_bytes(raw[0:2], "little") != AF_INET:
return None
return ".".join(str(b) for b in raw[4:8])
# The buffer is allocated per call; sharing one across threads corrupts it.
size = wintypes.ULONG(15000)
buffer = ctypes.create_string_buffer(size.value)
result = iphlpapi.GetAdaptersAddresses(
AF_INET, flags, None, ctypes.byref(buffer), ctypes.byref(size))
if result == 111: # ERROR_BUFFER_OVERFLOW
buffer = ctypes.create_string_buffer(size.value)
result = iphlpapi.GetAdaptersAddresses(
AF_INET, flags, None, ctypes.byref(buffer), ctypes.byref(size))
if result != 0:
raise OSError(f"GetAdaptersAddresses failed: {result}")
# First pass: collect IfType per interface so the GetIfTable2 parse below can
# be validated against a second, independent source.
known_types: dict[int, int] = {}
node = ctypes.cast(buffer, ctypes.POINTER(IP_ADAPTER_ADDRESSES))
while node:
known_types[int(node.contents.IfIndex)] = int(node.contents.IfType)
node = node.contents.Next
hardware, connected, physical_live = _windows_interface_flags(known_types)
records: list[dict] = []
node = ctypes.cast(buffer, ctypes.POINTER(IP_ADAPTER_ADDRESSES))
while node:
adapter = node.contents
gateways = []
gw = adapter.FirstGatewayAddress
while gw:
ip = sockaddr_to_ip(gw.contents.Address)
if ip:
gateways.append(ip)
gw = gw.contents.Next
unicast = adapter.FirstUnicastAddress
while unicast:
entry = unicast.contents
ip = sockaddr_to_ip(entry.Address)
if ip:
index = int(adapter.IfIndex)
records.append({
"ip": ip,
"prefix_length": int(entry.OnLinkPrefixLength),
"adapter": adapter.FriendlyName or "",
"description": adapter.Description or "",
"if_index": index,
"iftype": int(adapter.IfType),
"oper_status": int(adapter.OperStatus),
"dad_state": int(entry.DadState),
"prefix_origin": int(entry.PrefixOrigin),
"metric": int(adapter.Ipv4Metric),
"has_gateway": bool(gateways),
"gateway": gateways[0] if gateways else None,
"hardware": hardware.get(index),
"connected": connected.get(index),
"physical_link_present": physical_live,
})
unicast = entry.Next
node = adapter.Next
return records
def _windows_interface_flags(known_types: dict | None = None) -> tuple[dict, dict, bool]:
"""
Per-interface HardwareInterface / MediaConnectState from GetIfTable2.
These two gates cannot be derived from GetAdaptersAddresses, and they are
what separates a real NIC from a VPN miniport or a Hyper-V bridge without
matching English adapter names.
MIB_IF_ROW2 must be modelled COMPLETELY, trailing statistics counters and
all: the rows are read as a contiguous array, so a struct even one field
short gives the wrong stride and every row after the first is garbage -
silently, with no error from the API. `known_types` therefore carries the
IfType values already read from GetAdaptersAddresses so the parse can be
checked against them; on disagreement we discard the whole table and let the
ranking fall back to `hardware: None` (unknown), which is treated as "do not
reject", never as "virtual".
"""
import ctypes
from ctypes import wintypes
try:
iphlpapi = ctypes.WinDLL("iphlpapi")
class MIB_IF_ROW2(ctypes.Structure):
_fields_ = [
("InterfaceLuid", ctypes.c_uint64),
("InterfaceIndex", wintypes.DWORD),
("InterfaceGuid", ctypes.c_ubyte * 16),
("Alias", ctypes.c_wchar * 257),
("Description", ctypes.c_wchar * 257),
("PhysicalAddressLength", wintypes.ULONG),
("PhysicalAddress", ctypes.c_ubyte * 32),
("PermanentPhysicalAddress", ctypes.c_ubyte * 32),
("Mtu", wintypes.ULONG),
("Type", wintypes.ULONG),
("TunnelType", ctypes.c_int),
("MediaType", ctypes.c_int),
("PhysicalMediumType", ctypes.c_int),
("AccessType", ctypes.c_int),
("DirectionType", ctypes.c_int),
("InterfaceAndOperStatusFlags", ctypes.c_ubyte),
("OperStatus", ctypes.c_int),
("AdminStatus", ctypes.c_int),
("MediaConnectState", ctypes.c_int),
("NetworkGuid", ctypes.c_ubyte * 16),
("ConnectionType", ctypes.c_int),
("TransmitLinkSpeed", ctypes.c_uint64),
("ReceiveLinkSpeed", ctypes.c_uint64),
# Everything below is unused, but its size decides the stride.
("InOctets", ctypes.c_uint64),
("InUcastPkts", ctypes.c_uint64),
("InNUcastPkts", ctypes.c_uint64),
("InDiscards", ctypes.c_uint64),
("InErrors", ctypes.c_uint64),
("InUnknownProtos", ctypes.c_uint64),
("InUcastOctets", ctypes.c_uint64),
("InMulticastOctets", ctypes.c_uint64),
("InBroadcastOctets", ctypes.c_uint64),
("OutOctets", ctypes.c_uint64),
("OutUcastPkts", ctypes.c_uint64),
("OutNUcastPkts", ctypes.c_uint64),
("OutDiscards", ctypes.c_uint64),
("OutErrors", ctypes.c_uint64),
("OutUcastOctets", ctypes.c_uint64),
("OutMulticastOctets", ctypes.c_uint64),
("OutBroadcastOctets", ctypes.c_uint64),
("OutQLen", ctypes.c_uint64),
]
class MIB_IF_TABLE2(ctypes.Structure):
# NumEntries then the rows - there is no reserved field here. Adding
# one shifts Table by 8 bytes and every row parses as garbage.
_fields_ = [("NumEntries", wintypes.ULONG),
("Table", MIB_IF_ROW2 * 1)]
iphlpapi.GetIfTable2.argtypes = [ctypes.POINTER(ctypes.POINTER(MIB_IF_TABLE2))]
iphlpapi.GetIfTable2.restype = ctypes.c_ulong
table_ptr = ctypes.POINTER(MIB_IF_TABLE2)()
status = iphlpapi.GetIfTable2(ctypes.byref(table_ptr))
if status != 0 or not table_ptr:
log.debug("GetIfTable2 returned %s", status)
return {}, {}, False
try:
count = int(table_ptr.contents.NumEntries)
if not 0 < count < 4096:
log.debug("GetIfTable2 gave an implausible row count: %s", count)
return {}, {}, False
rows = ctypes.cast(
ctypes.byref(table_ptr.contents.Table),
ctypes.POINTER(MIB_IF_ROW2 * count)).contents
hardware, connected, types = {}, {}, {}
physical_live = False
for row in rows:
index = int(row.InterfaceIndex)
if not 0 < index < 10_000_000:
log.debug("GetIfTable2 row has a bogus index (%s) - bad layout", index)
return {}, {}, False
media = int(row.MediaConnectState)
is_hardware = bool(row.InterfaceAndOperStatusFlags & 0x01)
is_connected = media == 1
hardware[index] = is_hardware
connected[index] = is_connected if media in (1, 2) else None
types[index] = int(row.Type)
if is_hardware and is_connected and int(row.Type) in (
IF_TYPE_ETHERNET, IF_TYPE_WIFI):
physical_live = True
# Cross-check against the types GetAdaptersAddresses already gave
# us. If the two disagree, this parse is not trustworthy.
for index, iftype in (known_types or {}).items():
if index in types and types[index] != iftype:
log.warning("GetIfTable2 disagrees with GetAdaptersAddresses on if%s "
"(%s vs %s) - ignoring hardware flags",
index, types[index], iftype)
return {}, {}, False
return hardware, connected, physical_live
finally:
iphlpapi.FreeMibTable(table_ptr)
except Exception as exc:
log.debug("GetIfTable2 unavailable (%s); ranking without hardware flags", exc)
return {}, {}, False
# --------------------------------------------------------------------------
# Linux collector - `ip -j addr` enriched from sysfs
# --------------------------------------------------------------------------
def _sysfs(interface: str, leaf: str) -> str | None:
try:
with open(f"/sys/class/net/{interface}/{leaf}", "r") as handle:
return handle.read().strip()
except OSError:
return None
def _linux_gateways() -> set[str]:
"""Interfaces owning a default route, from /proc/net/route."""
owners: set[str] = set()
try:
with open("/proc/net/route", "r") as handle:
next(handle, None)
for line in handle:
parts = line.split()
if len(parts) > 2 and parts[1] == "00000000":
owners.add(parts[0])
except OSError:
pass
return owners
def _collect_linux() -> list[dict]:
try:
raw = subprocess.run(["ip", "-j", "-4", "addr", "show"],
capture_output=True, text=True, timeout=5).stdout
interfaces = json.loads(raw or "[]")
except (OSError, subprocess.SubprocessError, ValueError):
return []
gateways = _linux_gateways()
physical_live = False
records: list[dict] = []
for iface in interfaces:
name = iface.get("ifname", "")
# The `device` symlink exists only when a real PCI/USB/platform device
# backs the interface - absent for docker0, veth*, tun*, br-*, lo. It is
# the structural equivalent of Windows' HardwareInterface.
is_hardware = os.path.exists(f"/sys/class/net/{name}/device")
is_wireless = os.path.isdir(f"/sys/class/net/{name}/phy80211") or \
os.path.isdir(f"/sys/class/net/{name}/wireless")
carrier = _sysfs(name, "carrier")
operstate = _sysfs(name, "operstate")
up = (operstate == "up") or ("UP" in (iface.get("flags") or []))
iftype = IF_TYPE_WIFI if is_wireless else (
IF_TYPE_LOOPBACK if name == "lo" else IF_TYPE_ETHERNET)
if is_hardware and carrier == "1" and iftype in (IF_TYPE_ETHERNET, IF_TYPE_WIFI):
physical_live = True
for addr in iface.get("addr_info", []):
if addr.get("family") != "inet":
continue
records.append({
"ip": addr.get("local"),
"prefix_length": addr.get("prefixlen"),
"adapter": name,
"description": name,
"if_index": iface.get("ifindex"),
"iftype": iftype,
"oper_status": OPER_STATUS_UP if up else 2,
# `scope: global` is the practical stand-in for Preferred; it
# also drops link-local for free.
"dad_state": DAD_STATE_PREFERRED if addr.get("scope") == "global" else 1,
"prefix_origin": PREFIX_ORIGIN_DHCP if addr.get("dynamic") else 1,
"metric": 0,
"has_gateway": name in gateways,
"gateway": None,
"hardware": is_hardware,
"connected": None if carrier is None else carrier == "1",
"physical_link_present": physical_live,
})
for record in records:
record["physical_link_present"] = physical_live
return records
# --------------------------------------------------------------------------
# Public interface
# --------------------------------------------------------------------------
_lock = threading.Lock()
_cache: dict | None = None
_cache_at = 0.0
def _collect() -> list[dict]:
if platform.system() == "Windows":
return _collect_windows()
return _collect_linux()
def detect(force: bool = False) -> dict:
"""
The current LAN address, re-read live.
Returns {primary, alternates, rejected, reason, checked_at}. `primary` is
None when there is genuinely nothing usable - the caller must show that
honestly rather than print a stale or invented URL.
"""
global _cache, _cache_at
with _lock:
# monotonic, never time.time(): the wall clock jumps on NTP sync and on
# resume from sleep, which would freeze or expire the cache wrongly.
now = time.monotonic()
if not force and _cache is not None and (now - _cache_at) < TTL_SECONDS:
return _cache
try:
records = _collect()
except Exception as exc:
log.warning("address detection failed: %s", exc)
records = []
usable, rejected = rank(records)
result = {
"primary": usable[0] if usable else None,
"alternates": usable[1:],
"rejected": rejected,
"reason": None if usable else _reason_for_nothing(rejected),
"checked_at": time.time(),
}
_cache, _cache_at = result, now
return result
def primary_address() -> str | None:
"""Just the address, for callers that do not care why."""
found = detect()
return found["primary"]["ip"] if found["primary"] else None
REASON_TEXT = {
"wifi_down": "Wi-Fi is off or not connected",
"awaiting_dhcp": "connected, still waiting for an address",
"no_router_on_this_network": "connected, but this network has no router",
"no_physical_adapter": "no network adapter found",
"no_network_interface": "no network interface found",
"no_usable_address": "no usable network address",
}

View File

@ -0,0 +1,229 @@
"""
The extension API.
Drop a ``.py`` file into ``backend/plugins/`` that builds a ``Plugin`` and the
dashboard grows a new panel for it - no frontend work, no server edits. Each
control you declare is rendered by the browser from the manifest this module
produces, and clicking it calls your handler.
Minimal example::
from backend.plugin_api import Plugin
plugin = Plugin(id="hello", name="Hello", icon="\U0001F44B")
@plugin.action("wave", label="Wave hello")
async def wave(ctx):
await ctx.bridge.play_preset(motion=1002, area=2)
return "Waved"
Handlers may be async or plain functions. Whatever they return is shown as the
result toast; raise an exception and the message is surfaced as an error.
"""
from __future__ import annotations
import inspect
import time
from dataclasses import dataclass, field
from typing import Any, Callable
@dataclass
class Context:
"""Everything a handler is given. Passed as the first argument."""
bridge: Any
hub: Any
config: dict
storage: dict
plugin_id: str
@property
def state(self):
"""Live robot state - the same object the dashboard renders."""
return self.bridge.state
async def log(self, message: str, level: str = "info") -> None:
await self.hub.emit(level, f"plugin:{self.plugin_id}", message)
async def push(self, key: str, value: Any) -> None:
"""Publish a value into the shared state under ``custom``."""
bucket = self.bridge.state.custom.setdefault(self.plugin_id, {})
bucket[key] = value
def record(self, key: str, value: float) -> None:
"""Append a sample to a chartable time series."""
self.hub.record(f"{self.plugin_id}.{key}", value)
@dataclass
class Control:
key: str
kind: str
label: str
handler: Callable | None = None
options: dict = field(default_factory=dict)
def manifest(self) -> dict:
return {"key": self.key, "kind": self.kind, "label": self.label, **self.options}
class Plugin:
"""A named group of controls that appears as its own card in the Extensions tab."""
def __init__(self, id: str, name: str, description: str = "",
icon: str = "", order: int = 100, tab: str = "extensions"):
self.id = id
self.name = name
self.description = description
self.icon = icon
self.order = order
self.tab = tab
self.controls: list[Control] = []
self.readouts: list[dict] = []
self._on_start: Callable | None = None
self._on_tick: Callable | None = None
self._tick_interval = 1.0
self.storage: dict = {}
# -- control declarations ----------------------------------------------
def action(self, key: str, label: str = "", style: str = "default",
confirm: str = "", icon: str = "", help: str = ""):
"""A button. ``style`` is one of default | primary | warn | danger."""
def wrap(fn):
self.controls.append(Control(key, "action", label or key, fn, {
"style": style, "confirm": confirm, "icon": icon, "help": help,
}))
return fn
return wrap
def slider(self, key: str, label: str = "", min: float = 0.0, max: float = 1.0,
step: float = 0.01, default: float = 0.0, unit: str = "",
live: bool = False, help: str = ""):
"""A continuous value. ``live=True`` fires while dragging."""
def wrap(fn):
self.controls.append(Control(key, "slider", label or key, fn, {
"min": min, "max": max, "step": step, "default": default,
"unit": unit, "live": live, "help": help,
}))
return fn
return wrap
def toggle(self, key: str, label: str = "", default: bool = False, help: str = ""):
def wrap(fn):
self.controls.append(Control(key, "toggle", label or key, fn, {
"default": default, "help": help,
}))
return fn
return wrap
def select(self, key: str, label: str = "", options: list | None = None,
default: Any = None, help: str = ""):
"""``options`` is a list of {"value": ..., "label": ...} or bare strings."""
def wrap(fn):
normalised = []
for option in (options or []):
if isinstance(option, dict):
normalised.append(option)
else:
normalised.append({"value": option, "label": str(option)})
self.controls.append(Control(key, "select", label or key, fn, {
"options": normalised, "default": default, "help": help,
}))
return fn
return wrap
def text(self, key: str, label: str = "", default: str = "", placeholder: str = "",
multiline: bool = False, submit_label: str = "Send", help: str = ""):
def wrap(fn):
self.controls.append(Control(key, "text", label or key, fn, {
"default": default, "placeholder": placeholder, "multiline": multiline,
"submit_label": submit_label, "help": help,
}))
return fn
return wrap
def number(self, key: str, label: str = "", min: float | None = None,
max: float | None = None, step: float = 1.0, default: float = 0.0,
unit: str = "", help: str = ""):
def wrap(fn):
self.controls.append(Control(key, "number", label or key, fn, {
"min": min, "max": max, "step": step, "default": default,
"unit": unit, "help": help,
}))
return fn
return wrap
def color(self, key: str, label: str = "", default: str = "#3987e5", help: str = ""):
def wrap(fn):
self.controls.append(Control(key, "color", label or key, fn, {
"default": default, "help": help,
}))
return fn
return wrap
# -- readouts ----------------------------------------------------------
def readout(self, key: str, label: str, unit: str = "", chart: bool = False,
format: str = "number", precision: int = 2) -> None:
"""
Declare a value this plugin publishes via ``ctx.push(key, value)``.
``chart=True`` also draws a sparkline from ``ctx.record(key, value)``.
"""
self.readouts.append({
"key": key, "label": label, "unit": unit, "chart": chart,
"format": format, "precision": precision,
})
# -- lifecycle ---------------------------------------------------------
def on_start(self, fn):
"""Called once when the plugin loads."""
self._on_start = fn
return fn
def on_tick(self, interval: float = 1.0):
"""Called repeatedly on a background timer."""
def wrap(fn):
self._on_tick = fn
self._tick_interval = max(0.1, float(interval))
return fn
return wrap
# -- introspection -----------------------------------------------------
def manifest(self) -> dict:
return {
"id": self.id,
"name": self.name,
"description": self.description,
"icon": self.icon,
"order": self.order,
"tab": self.tab,
"controls": [c.manifest() for c in self.controls],
"readouts": self.readouts,
"has_tick": self._on_tick is not None,
"tick_interval": self._tick_interval,
}
def find(self, key: str) -> Control | None:
return next((c for c in self.controls if c.key == key), None)
async def invoke(handler: Callable, ctx: Context, value: Any = None) -> Any:
"""Call a handler, passing ``value`` only when it takes one."""
signature = inspect.signature(handler)
args = [ctx]
if len(signature.parameters) > 1:
args.append(value)
result = handler(*args)
if inspect.isawaitable(result):
result = await result
return result
def now_ms() -> int:
return int(time.time() * 1000)

View File

@ -0,0 +1,8 @@
"""
Drop-in extension directory.
Any ``.py`` file here that defines a module-level ``Plugin`` becomes a card in
the Extensions tab. See ``_template.py`` for the shape and ``example_patrol.py``
for a working one. Press "Reload extensions" in the UI after editing - no
server restart needed.
"""

View File

@ -0,0 +1,105 @@
"""
Copy this file, rename it, change the id - you have a new dashboard panel.
Every control below shows one widget type. Delete what you do not need.
After editing, press "Reload extensions" in the Extensions tab.
Files starting with an underscore are skipped by the loader, so this template
never appears in the UI until you copy it to a normal name.
"""
from backend.plugin_api import Plugin
plugin = Plugin(
id="template",
name="Template",
description="Every control type, as a starting point.",
icon="\U0001F9E9",
order=900,
)
# A button. style: default | primary | warn | danger
@plugin.action("hello", label="Say hello", style="primary", icon="\U0001F44B")
async def hello(ctx):
await ctx.bridge.speak("Hello from a dashboard extension")
return "Spoke"
# A destructive button asks for confirmation first.
@plugin.action("reset", label="Reset counter", style="danger",
confirm="Reset the stored counter to zero?")
async def reset(ctx):
ctx.storage["count"] = 0
await ctx.push("count", 0)
return "Counter reset"
# A slider. live=True fires continuously while dragging; leave it False to fire
# only on release, which is what you want for anything that moves the robot.
@plugin.slider("speed", label="Cruise speed", min=0.0, max=0.6,
step=0.05, default=0.2, unit="m/s",
help="Applied when the routine below starts.")
async def speed(ctx, value):
ctx.storage["speed"] = value
return f"Speed set to {value:.2f} m/s"
@plugin.toggle("verbose", label="Verbose logging", default=False)
async def verbose(ctx, value):
ctx.storage["verbose"] = value
return "Verbose on" if value else "Verbose off"
@plugin.select("gait", label="Gait", default="normal", options=[
{"value": "slow", "label": "Slow and steady"},
{"value": "normal", "label": "Normal"},
{"value": "brisk", "label": "Brisk"},
])
async def gait(ctx, value):
ctx.storage["gait"] = value
return f"Gait: {value}"
@plugin.text("announce", label="Announcement", placeholder="Type something to say",
submit_label="Speak")
async def announce(ctx, value):
result = await ctx.bridge.speak(value, priority=6)
return result.message
@plugin.number("repeats", label="Repeat count", min=1, max=10, step=1, default=3)
async def repeats(ctx, value):
ctx.storage["repeats"] = int(value)
return f"Will repeat {int(value)}x"
@plugin.color("tint", label="Strip colour", default="#3987e5")
async def tint(ctx, value):
value = value.lstrip("#")
r, g, b = (int(value[i:i + 2], 16) for i in (0, 2, 4))
result = await ctx.bridge.set_led(mode=0, r=r, g=g, b=b)
return result.message
# Declared readouts appear as stat tiles. chart=True adds a sparkline fed by
# ctx.record().
plugin.readout("count", "Ticks", chart=True, precision=0)
plugin.readout("battery", "Battery seen", unit="%", precision=1)
@plugin.on_start
async def start(ctx):
ctx.storage.setdefault("count", 0)
await ctx.log("Template extension loaded")
@plugin.on_tick(interval=2.0)
async def tick(ctx):
ctx.storage["count"] = ctx.storage.get("count", 0) + 1
await ctx.push("count", ctx.storage["count"])
ctx.record("count", ctx.storage["count"])
battery = ctx.state.battery_pct
if battery is not None:
await ctx.push("battery", round(battery, 1))

View File

@ -0,0 +1,112 @@
"""
Battery guard - a worked example of a background monitor.
Watches the PMU battery level, warns once per threshold crossing, and can drop
the robot into a safe mode before it browns out mid-stride. Demonstrates
on_tick, ctx.push readouts and charted series.
"""
from backend.plugin_api import Plugin
plugin = Plugin(
id="battery_guard",
name="Battery guard",
description="Warns on low battery and can auto-safe the robot before it browns out.",
icon="\U0001F50B",
order=20,
)
@plugin.slider("warn_at", label="Warn below", min=5, max=60, step=1, default=25, unit="%")
async def warn_at(ctx, value):
ctx.storage["warn_at"] = float(value)
ctx.storage["warned"] = False
return f"Warning threshold set to {value:.0f}%"
@plugin.slider("safe_at", label="Auto-safe below", min=1, max=30, step=1, default=10, unit="%",
help="Switches to Damping so the robot settles instead of collapsing.")
async def safe_at(ctx, value):
ctx.storage["safe_at"] = float(value)
ctx.storage["safed"] = False
return f"Auto-safe threshold set to {value:.0f}%"
@plugin.toggle("auto_safe", label="Auto-safe enabled", default=False,
help="Off by default - turn on only when you want the robot to act unattended.")
async def auto_safe(ctx, value):
ctx.storage["auto_safe"] = bool(value)
return "Auto-safe armed" if value else "Auto-safe disarmed"
@plugin.action("reset_alarms", label="Reset alarms", style="default")
async def reset_alarms(ctx):
ctx.storage["warned"] = False
ctx.storage["safed"] = False
await ctx.push("status", "armed")
return "Alarms reset"
plugin.readout("level", "Battery", unit="%", chart=True, precision=1)
plugin.readout("draw", "Draw", unit="A", precision=2)
plugin.readout("status", "Guard status", format="text")
plugin.readout("est_runtime", "Estimated runtime", unit="min", precision=0)
@plugin.on_start
async def start(ctx):
ctx.storage.setdefault("warn_at", 25.0)
ctx.storage.setdefault("safe_at", 10.0)
ctx.storage.setdefault("auto_safe", False)
ctx.storage.setdefault("warned", False)
ctx.storage.setdefault("safed", False)
await ctx.push("status", "armed")
@plugin.on_tick(interval=3.0)
async def tick(ctx):
level = ctx.state.battery_pct
if level is None:
await ctx.push("status", "no PMU data")
return
current = ctx.state.battery_current
await ctx.push("level", round(level, 1))
ctx.record("level", level)
if current is not None:
await ctx.push("draw", round(abs(current), 2))
# A crude but useful projection: remaining percent over the observed drain
# rate. Only meaningful while discharging.
history = ctx.hub.series("battery_pct", limit=120)
if len(history) >= 20:
(t0, v0), (t1, v1) = history[0], history[-1]
elapsed = t1 - t0
drop = v0 - v1
if elapsed > 5 and drop > 0.01:
minutes = (level / (drop / elapsed)) / 60.0
await ctx.push("est_runtime", round(min(minutes, 9999), 0))
safe_at = ctx.storage["safe_at"]
warn_at = ctx.storage["warn_at"]
if level <= safe_at:
await ctx.push("status", "critical")
if ctx.storage.get("auto_safe") and not ctx.storage.get("safed"):
ctx.storage["safed"] = True
await ctx.bridge.stop_motion()
outcome = await ctx.bridge.set_mode("DAMPING_DEFAULT")
await ctx.log(
f"Battery {level:.1f}% - auto-safe engaged: {outcome.message}", level="error"
)
await ctx.bridge.speak("Battery critical. Entering safe mode.", priority=10)
elif level <= warn_at:
await ctx.push("status", "low")
if not ctx.storage.get("warned"):
ctx.storage["warned"] = True
await ctx.log(f"Battery low: {level:.1f}%", level="warn")
await ctx.bridge.set_led(mode=2, r=250, g=178, b=25)
else:
await ctx.push("status", "normal")
ctx.storage["warned"] = False
ctx.storage["safed"] = False

View File

@ -0,0 +1,95 @@
"""
Greeter routine - a worked example of sequencing several subsystems.
Shows how one button can drive mode, motion, speech, screen and lights together,
which is the usual reason to write an extension rather than clicking four tabs.
"""
import asyncio
from backend.plugin_api import Plugin
plugin = Plugin(
id="greeter",
name="Greeter routine",
description="A one-button welcome: stand, wave, speak, smile, glow.",
icon="\U0001F44B",
order=10,
)
GREETINGS = {
"en": "Hello, I am X2. Nice to meet you.",
"formal": "Good day. Welcome. How may I assist you?",
"short": "Hi there.",
}
@plugin.select("phrase", label="Greeting", default="en", options=[
{"value": "en", "label": "Friendly"},
{"value": "formal", "label": "Formal"},
{"value": "short", "label": "Short"},
])
async def phrase(ctx, value):
ctx.storage["phrase"] = value
return f"Greeting set to '{value}'"
@plugin.toggle("with_lights", label="Include light cue", default=True)
async def with_lights(ctx, value):
ctx.storage["with_lights"] = value
return "Light cue on" if value else "Light cue off"
@plugin.action("run", label="Run greeting", style="primary", icon="",
help="Requires clear space around the robot.")
async def run(ctx):
if ctx.storage.get("running"):
return {"ok": False, "message": "Greeting already running"}
ctx.storage["running"] = True
try:
# Preset motions need Stable stand, so get there first if we are not.
if ctx.state.mode != "STAND_DEFAULT":
outcome = await ctx.bridge.set_mode("STAND_DEFAULT")
if not outcome.ok:
return {"ok": False, "message": f"Could not enter Stable stand: {outcome.message}"}
await asyncio.sleep(1.5)
if ctx.storage.get("with_lights", True):
await ctx.bridge.set_led(mode=1, r=57, g=135, b=229)
await ctx.bridge.play_emoji(90, mode=1) # Happy
await ctx.bridge.play_preset(motion=1002, area=2) # Right-hand wave
text = GREETINGS.get(ctx.storage.get("phrase", "en"), GREETINGS["en"])
await ctx.bridge.speak(text, priority=6)
ctx.storage["count"] = ctx.storage.get("count", 0) + 1
await ctx.push("count", ctx.storage["count"])
await ctx.log(f"Greeting played ({ctx.storage['count']} total)")
return "Greeting played"
finally:
ctx.storage["running"] = False
@plugin.action("goodbye", label="Say goodbye", style="default", icon="\U0001F44B")
async def goodbye(ctx):
if ctx.state.mode != "STAND_DEFAULT":
return {"ok": False, "message": "Switch to Stable stand first"}
await ctx.bridge.play_emoji(110, mode=1) # Sad
# TURN_WAVE_HAND (2001), whole body. The documented "wave goodbye" id 3031
# does not exist in this firmware's McPresetMotion enum.
await ctx.bridge.play_preset(motion=2001, area=11)
await ctx.bridge.speak("Goodbye. See you soon.", priority=6)
return "Goodbye played"
plugin.readout("count", "Greetings played", precision=0)
@plugin.on_start
async def start(ctx):
ctx.storage.setdefault("count", 0)
ctx.storage.setdefault("phrase", "en")
ctx.storage.setdefault("with_lights", True)
await ctx.push("count", ctx.storage["count"])

View File

@ -0,0 +1,118 @@
"""
Getting the robot back after a power cycle.
The X2 cannot reliably bring the dashboard agent up on its own:
* `systemd --user` units only run while the user has a login session, and the
`agi` account is not allowed to enable lingering (`loginctl enable-linger`
is denied, and sudo forbids running as root).
* The cron fallback does not fire either - the robot's clock jumps backwards
by several hours shortly after boot (RTC vs NTP), and cron stalls on a
backward jump.
The dashboard host is the dependable machine in this setup, so recovery lives
here instead: find the robot wherever DHCP put it, and start the agent over SSH
if it is not already running.
"""
from __future__ import annotations
import asyncio
import time
from . import netinfo, x2_spec
class RecoveryError(Exception):
pass
async def find_robot(agent_port: int, ssh_port: int = 22,
networks: list[str] | None = None) -> list[dict]:
"""
Sweep the current subnets for anything that looks like the robot.
A host already running the agent is the strongest signal; a host answering
on SSH is a candidate we may be able to start the agent on.
"""
cidrs = networks or netinfo.local_networks()
found: list[dict] = []
for cidr in cidrs[:3]:
try:
hosts = await netinfo.scan_subnet(cidr, ports=[agent_port, ssh_port], timeout=0.4)
except (ValueError, Exception):
continue
for host in hosts:
open_ports = host.get("open_ports") or []
found.append({
"host": host["host"],
"hostname": host.get("hostname"),
"agent": agent_port in open_ports,
"ssh": ssh_port in open_ports,
"latency_ms": host.get("latency_ms"),
})
# Anything already running the agent sorts first.
found.sort(key=lambda h: (not h["agent"], not h["ssh"], h["host"]))
return found
def _start_over_ssh_blocking(host: str, port: int, user: str, password: str,
command: str, timeout: float = 25.0) -> tuple[bool, str]:
try:
import paramiko
except ImportError:
return False, ("paramiko is not installed on the dashboard host - "
"run: pip install -r requirements.txt")
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
client.connect(host, port=port, username=user, password=password,
timeout=timeout, allow_agent=False, look_for_keys=False,
banner_timeout=timeout, auth_timeout=timeout)
except Exception as exc:
return False, f"SSH to {user}@{host}:{port} failed: {type(exc).__name__}: {exc}"
try:
# The helper is idempotent: it exits immediately if the agent is already
# listening, so running it when we raced a manual start is harmless.
stdin, stdout, stderr = client.exec_command(command, timeout=timeout)
stdout.channel.recv_exit_status()
err = stderr.read().decode("utf-8", "replace").strip()
if err and "warning" not in err.lower():
return True, f"start command ran with stderr: {err[:200]}"
return True, "start command ran"
except Exception as exc:
return False, f"Running the start command failed: {exc}"
finally:
client.close()
async def start_agent(host: str, config: dict) -> tuple[bool, str]:
"""Log in and run the agent starter. Returns (attempted_ok, message)."""
user = (config.get("robot_ssh_user") or "").strip()
password = config.get("robot_ssh_password") or ""
command = (config.get("agent_start_command") or "").strip()
ssh_port = int(config.get("robot_ssh_port") or 22)
if not user or not command:
return False, "SSH user or start command is not configured"
if not password:
return False, ("No SSH password saved. Add it in Settings so the dashboard can "
"start the agent after the robot reboots.")
return await asyncio.to_thread(
_start_over_ssh_blocking, host, ssh_port, user, password, command)
async def wait_for_agent(host: str, port: int, timeout: float = 45.0) -> bool:
"""Poll until the agent's port opens, or give up."""
deadline = time.time() + timeout
while time.time() < deadline:
info = await netinfo.probe_host(host, ports=[port], timeout=1.0)
if info.get("reachable"):
return True
await asyncio.sleep(2.0)
return False

View File

@ -0,0 +1,203 @@
"""
Plugin discovery, loading and dispatch.
Scans ``backend/plugins/*.py``, imports each one, and collects any module-level
``Plugin`` instance. Reloading is supported at runtime so you can edit a plugin
and press "Reload" in the Extensions tab without restarting the server.
A plugin that fails to import does not take the dashboard down - the error is
recorded and shown in the UI next to the plugin that caused it.
"""
from __future__ import annotations
import asyncio
import importlib
import importlib.util
import sys
import traceback
from pathlib import Path
from .plugin_api import Context, Plugin, invoke
PLUGIN_DIR = Path(__file__).resolve().parent / "plugins"
class Registry:
def __init__(self, hub, config: dict):
self.hub = hub
self.config = config
self.bridge = None
self.plugins: dict[str, Plugin] = {}
self.errors: list[dict] = []
self._tasks: dict[str, asyncio.Task] = {}
self._storage: dict[str, dict] = {}
def attach(self, bridge) -> None:
self.bridge = bridge
# -- loading ------------------------------------------------------------
async def load(self) -> dict:
"""Import every plugin file. Safe to call repeatedly."""
await self._stop_tasks()
self.plugins.clear()
self.errors.clear()
PLUGIN_DIR.mkdir(parents=True, exist_ok=True)
init = PLUGIN_DIR / "__init__.py"
if not init.exists():
init.write_text("", encoding="utf-8")
# A leading underscore marks a file as private: __init__.py and the
# _template.py starting point are both skipped.
for path in sorted(PLUGIN_DIR.glob("*.py")):
if path.name.startswith("_"):
continue
self._load_file(path)
for plugin in self.plugins.values():
plugin.storage = self._storage.setdefault(plugin.id, {})
await self._start_plugin(plugin)
summary = {
"loaded": sorted(self.plugins),
"errors": list(self.errors),
"count": len(self.plugins),
}
if self.errors:
await self.hub.emit("warn", "plugins",
f"{len(self.plugins)} plugin(s) loaded, {len(self.errors)} failed")
else:
await self.hub.emit("info", "plugins", f"{len(self.plugins)} plugin(s) loaded")
return summary
def _load_file(self, path: Path) -> None:
module_name = f"backend.plugins.{path.stem}"
try:
# Always build a fresh module from the file rather than calling
# importlib.reload. reload() requires the parent package to be in
# sys.modules and re-runs stale bytecode paths; executing the file
# afresh is both simpler and a true reload of what is on disk.
spec = importlib.util.spec_from_file_location(module_name, path)
if spec is None or spec.loader is None:
raise ImportError(f"Cannot build import spec for {path.name}")
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
found = [v for v in vars(module).values() if isinstance(v, Plugin)]
if not found:
self.errors.append({
"file": path.name,
"error": "No Plugin instance found at module level",
"trace": "",
})
return
for plugin in found:
if plugin.id in self.plugins:
self.errors.append({
"file": path.name,
"error": f"Duplicate plugin id '{plugin.id}' - ignoring this one",
"trace": "",
})
continue
self.plugins[plugin.id] = plugin
except Exception as exc:
self.errors.append({
"file": path.name,
"error": f"{type(exc).__name__}: {exc}",
"trace": traceback.format_exc(limit=6),
})
sys.modules.pop(module_name, None)
async def _start_plugin(self, plugin: Plugin) -> None:
ctx = self.context(plugin)
if plugin._on_start:
try:
await invoke(plugin._on_start, ctx)
except Exception as exc:
self.errors.append({
"file": plugin.id,
"error": f"on_start failed: {exc}",
"trace": traceback.format_exc(limit=6),
})
if plugin._on_tick:
self._tasks[plugin.id] = asyncio.create_task(
self._tick_loop(plugin), name=f"plugin-{plugin.id}"
)
async def _tick_loop(self, plugin: Plugin) -> None:
ctx = self.context(plugin)
failures = 0
while True:
try:
await asyncio.sleep(plugin._tick_interval)
await invoke(plugin._on_tick, ctx)
failures = 0
except asyncio.CancelledError:
raise
except Exception as exc:
failures += 1
await self.hub.emit("error", f"plugin:{plugin.id}", f"Tick failed: {exc}")
if failures >= 5:
await self.hub.emit("warn", f"plugin:{plugin.id}",
"Tick disabled after 5 consecutive failures")
return
await asyncio.sleep(2.0)
async def _stop_tasks(self) -> None:
for task in self._tasks.values():
task.cancel()
for task in list(self._tasks.values()):
try:
await task
except (asyncio.CancelledError, Exception):
pass
self._tasks.clear()
async def shutdown(self) -> None:
await self._stop_tasks()
# -- dispatch -----------------------------------------------------------
def context(self, plugin: Plugin) -> Context:
return Context(
bridge=self.bridge,
hub=self.hub,
config=self.config,
storage=self._storage.setdefault(plugin.id, {}),
plugin_id=plugin.id,
)
async def dispatch(self, plugin_id: str, control_key: str, value=None) -> dict:
plugin = self.plugins.get(plugin_id)
if plugin is None:
return {"ok": False, "message": f"No plugin '{plugin_id}'"}
control = plugin.find(control_key)
if control is None or control.handler is None:
return {"ok": False, "message": f"No control '{control_key}' in '{plugin_id}'"}
try:
result = await invoke(control.handler, self.context(plugin), value)
except Exception as exc:
await self.hub.emit("error", f"plugin:{plugin_id}", f"{control_key}: {exc}")
return {"ok": False, "message": f"{type(exc).__name__}: {exc}",
"detail": traceback.format_exc(limit=4)}
if isinstance(result, dict) and "ok" in result:
return result
return {"ok": True, "message": str(result) if result is not None else "Done"}
# -- introspection ------------------------------------------------------
def manifest(self) -> dict:
return {
"plugins": sorted((p.manifest() for p in self.plugins.values()),
key=lambda m: (m["order"], m["name"])),
"errors": list(self.errors),
"directory": str(PLUGIN_DIR),
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,227 @@
"""
Runtime configuration, persisted to config.json beside the project root.
The robot address lives here rather than in code, and every field can be changed
from the Settings tab while the server is running.
"""
from __future__ import annotations
import json
import os
import threading
from pathlib import Path
from typing import Any
from . import x2_spec
ROOT = Path(__file__).resolve().parent.parent
CONFIG_PATH = ROOT / "config.json"
_lock = threading.Lock()
DEFAULTS: dict[str, Any] = {
# Where the on-robot agent is reachable. Empty means "not yet configured" -
# the Settings tab prompts for it and the discovery scan can fill it in.
# Deliberately not a compiled-in address.
"robot_host": "",
"agent_port": x2_spec.AGENT_PORT,
"robot_label": "AGIBOT X2",
# The name the dashboard advertises for itself on the network, so the link
# people type is about the robot rather than whatever the PC happens to be
# called. Served as <name>.local via mDNS - see backend/announce.py.
"dashboard_name": "agibot",
"advertise_name": True,
# Finding the robot again after a power cycle.
#
# auto_discover: when the saved address stops answering, sweep the current
# subnets for a host running the agent (or reachable over SSH) and adopt
# it. Handles the robot getting a different DHCP lease.
# auto_start_agent: if the robot answers on SSH but the agent is not
# running, log in and start it. The robot cannot reliably start it by
# itself - the agi account cannot enable systemd lingering, and its clock
# jumps backwards after boot, which stalls cron.
"auto_discover": True,
"auto_start_agent": True,
"robot_ssh_user": "agi",
"robot_ssh_password": "",
"robot_ssh_port": 22,
"agent_start_command": "/home/agi/x2_dashboard_agent/ensure_agent.sh",
# ROS 2 settings are used by the agent on the robot, and shown here for
# reference so the operator can confirm both ends agree.
"ros_domain_id": x2_spec.DEFAULT_ROS_DOMAIN_ID,
"rmw_implementation": "rmw_fastrtps_cpp",
# "auto" - talk to the robot agent if a host is set, else simulate
# "agent" - require the robot agent, never fall back to simulation
# "mock" - always simulate, for UI work away from the robot
"bridge_mode": "auto",
# HTTP server
"host": "0.0.0.0",
"port": 8770,
# Telemetry
"telemetry_hz": 10,
"history_seconds": 120,
# Safety
"require_confirm_zero_torque": True,
"locomotion_deadman_s": x2_spec.LOCOMOTION_DEADMAN_S,
"max_forward_velocity": x2_spec.VELOCITY_LIMITS["forward"]["max"],
"max_lateral_velocity": x2_spec.VELOCITY_LIMITS["lateral"]["max"],
"max_angular_velocity": x2_spec.VELOCITY_LIMITS["angular"]["max"],
# UI
"theme": "dark",
"accent": "blue",
}
# Fields the browser is allowed to change.
WRITABLE = set(DEFAULTS) - {"host"}
_cache: dict[str, Any] | None = None
# Keys an environment variable may force for a single run. These must survive a
# save() that re-reads the file, or the forced value would be lost mid-session.
_ENV_FORCED: set[str] = set()
def _read_disk() -> dict[str, Any]:
if not CONFIG_PATH.exists():
return {}
try:
with CONFIG_PATH.open("r", encoding="utf-8") as fh:
data = json.load(fh)
return data if isinstance(data, dict) else {}
except (OSError, json.JSONDecodeError):
return {}
def load() -> dict[str, Any]:
"""Current configuration: defaults <- config.json <- environment."""
global _cache
with _lock:
if _cache is None:
merged = dict(DEFAULTS)
merged.update({k: v for k, v in _read_disk().items() if k in DEFAULTS})
# Environment wins, so `X2_ROBOT_HOST=... python -m backend` works
# for one-off runs without editing the saved config.
if os.environ.get("X2_ROBOT_HOST"):
merged["robot_host"] = os.environ["X2_ROBOT_HOST"]
_ENV_FORCED.add("robot_host")
if os.environ.get("X2_PORT"):
try:
merged["port"] = int(os.environ["X2_PORT"])
_ENV_FORCED.add("port")
except ValueError:
pass
if os.environ.get("ROS_DOMAIN_ID"):
try:
merged["ros_domain_id"] = int(os.environ["ROS_DOMAIN_ID"])
_ENV_FORCED.add("ros_domain_id")
except ValueError:
pass
if os.environ.get("X2_BRIDGE_MODE") in ("auto", "agent", "mock"):
merged["bridge_mode"] = os.environ["X2_BRIDGE_MODE"]
_ENV_FORCED.add("bridge_mode")
_cache = merged
return dict(_cache)
def get(key: str, fallback: Any = None) -> Any:
return load().get(key, fallback)
def save(updates: dict[str, Any]) -> dict[str, Any]:
"""
Apply and persist a partial update. Unknown keys are ignored.
Merges against what is on disk RIGHT NOW rather than against this process's
cached copy. The cache is per-process and never re-read, so a writer holding
a stale copy would otherwise rewrite the whole file from it and silently
revert anything another process had saved in the meantime - which is exactly
how a saved SSH password disappears. Only the keys in `updates` are the
caller's to change; everything else comes from disk.
"""
global _cache
clean = _coerce(updates)
with _lock:
current = dict(DEFAULTS)
current.update({k: v for k, v in _read_disk().items() if k in DEFAULTS})
# Environment overrides still win, so a value forced for this run is not
# written back over the operator's saved configuration.
if _cache is not None:
for key in _ENV_FORCED:
if key in _cache:
current[key] = _cache[key]
current.update(clean)
_cache = current
try:
CONFIG_PATH.write_text(
json.dumps(current, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
except OSError:
# A read-only filesystem should not take the dashboard down; the
# change still applies for this session.
pass
return dict(current)
def _coerce(updates: dict[str, Any]) -> dict[str, Any]:
"""Validate and type-correct incoming settings from the browser."""
out: dict[str, Any] = {}
for key, value in updates.items():
if key not in WRITABLE:
continue
default = DEFAULTS[key]
try:
if isinstance(default, bool):
out[key] = bool(value)
elif isinstance(default, int):
out[key] = int(value)
elif isinstance(default, float):
out[key] = float(value)
else:
out[key] = str(value).strip()
except (TypeError, ValueError):
continue
if "port" in out:
out["port"] = max(1, min(65535, out["port"]))
if "agent_port" in out:
out["agent_port"] = max(1, min(65535, out["agent_port"]))
if "ros_domain_id" in out:
out["ros_domain_id"] = max(0, min(232, out["ros_domain_id"]))
if "telemetry_hz" in out:
out["telemetry_hz"] = max(1, min(60, out["telemetry_hz"]))
if "bridge_mode" in out and out["bridge_mode"] not in ("auto", "agent", "mock"):
out.pop("bridge_mode")
for key, cap in (
("max_forward_velocity", x2_spec.VELOCITY_LIMITS["forward"]["max"]),
("max_lateral_velocity", x2_spec.VELOCITY_LIMITS["lateral"]["max"]),
("max_angular_velocity", x2_spec.VELOCITY_LIMITS["angular"]["max"]),
):
if key in out:
out[key] = max(0.0, min(cap, out[key]))
return out
def describe() -> dict[str, Any]:
"""Config plus metadata the Settings tab renders."""
return {
"values": load(),
"defaults": DEFAULTS,
"writable": sorted(WRITABLE),
"config_path": str(CONFIG_PATH),
}

View File

@ -0,0 +1,306 @@
"""Voice-session control — the on/off switch behind the Interaction page.
The conversation loop is a systemd *user* unit (sanad_agibot.service) that the
dashboard already has the rights to drive, because both run as `agi` in the same
user session. So "turn speaking on" is: persist the operator's selection, then
start/restart that unit; "off" is stopping it.
The (gender, language, model) -> (persona, voice) mapping deliberately lives in
Sanad's own voice/session_profile.py and is imported from there by path rather
than copied here. A copy would drift, and a drifted copy means the dashboard
shows one character while the robot speaks as another.
Everything in here degrades to a readable status instead of raising: if Sanad
is not deployed, or systemd is unavailable, the card still renders and explains
what is missing.
"""
from __future__ import annotations
import importlib.util
import os
import shutil
import subprocess
import time
from pathlib import Path
from typing import Any
SERVICE = os.environ.get("SANAD_SERVICE_NAME", "sanad_agibot.service")
SANAD_DIR = Path(os.environ.get(
"SANAD_DIR",
Path.home() / "sanad_deploy" / "sanad_package_1" / "vendor" / "Sanad",
))
_PROFILE_MODULE_PATH = SANAD_DIR / "voice" / "session_profile.py"
# Choices the UI renders. Kept server-side so the page and the robot can never
# disagree about what is selectable.
OPTIONS: dict[str, list[dict[str, str]]] = {
"gender": [
{"value": "female", "label": "Female — Muza / موزة"},
{"value": "male", "label": "Male — Lumi / لومي"},
],
"language": [
{"value": "arabic", "label": "Arabic only (Emirati)"},
{"value": "multi", "label": "Multi-language"},
],
# The second value is still keyed "linksoul" so an already-saved selection
# keeps working; the label describes what it actually does now.
"model": [
{"value": "gemini", "label": "Gemini Live"},
{"value": "linksoul", "label": "Pipeline — Fatima / Hamdan"},
],
}
_profile_mod = None
_profile_err: str | None = None
def _profile():
"""Sanad's session_profile module, loaded from disk on first use."""
global _profile_mod, _profile_err
if _profile_mod is not None or _profile_err is not None:
return _profile_mod
try:
spec = importlib.util.spec_from_file_location(
"sanad_session_profile", _PROFILE_MODULE_PATH)
if spec is None or spec.loader is None:
raise ImportError(f"cannot load {_PROFILE_MODULE_PATH}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
_profile_mod = module
except Exception as exc: # noqa: BLE001 - reported, never fatal
_profile_err = f"{type(exc).__name__}: {exc}"
return _profile_mod
def _systemctl(*args: str, timeout: float = 20.0) -> tuple[int, str]:
if not shutil.which("systemctl"):
return 127, "systemctl not available on this host"
try:
proc = subprocess.run(
["systemctl", "--user", *args],
capture_output=True, text=True, timeout=timeout,
)
return proc.returncode, (proc.stdout + proc.stderr).strip()
except subprocess.TimeoutExpired:
return 124, f"systemctl --user {' '.join(args)} timed out"
except OSError as exc:
return 1, str(exc)
_VOICE_PATTERNS = (
r"vendor/Sanad/voice/sanad_voice\.py",
r"vendor/Sanad/pipeline/runner\.py",
)
def _kill_voice_processes(grace: float = 6.0) -> int:
"""Stop the running voice process whoever started it.
Needed because the process is launched by keepalive_daemon.sh rather than
systemd, so stopping the unit is not enough to silence the robot. SIGINT
first so it releases ROS audio focus and closes the Gemini session cleanly.
"""
import signal
import subprocess as sp
pids: list[str] = []
for pat in _VOICE_PATTERNS:
try:
out = sp.run(["pgrep", "-f", pat], capture_output=True, text=True, timeout=5)
pids += [p for p in out.stdout.split() if p.strip()]
except Exception: # noqa: BLE001
pass
if not pids:
return 0
for p in pids:
try:
os.kill(int(p), signal.SIGINT)
except Exception: # noqa: BLE001
pass
time.sleep(grace)
for p in pids:
try:
os.kill(int(p), signal.SIGKILL)
except Exception: # noqa: BLE001
pass
return len(pids)
def service_status() -> dict[str, Any]:
code, out = _systemctl("is-active", SERVICE, timeout=10.0)
active = out.strip() or ("unknown" if code else "inactive")
return {
"unit": SERVICE,
"state": active,
"running": active == "active",
}
ENV_FILE = Path(os.environ.get("SANAD_ENV_FILE", Path.home() / ".sanad_agibot_env"))
_CRED_KEYS = ("LINKSOUL_APP_ID", "LINKSOUL_APP_KEY", "LINKSOUL_APP_SECRET")
def _env_file_keys() -> set[str]:
"""Names assigned in ~/.sanad_agibot_env.
Checked here rather than in os.environ because that file is sourced by the
launcher, not by the dashboard the credentials are never visible in this
process's own environment, so reading os.environ would always report them
missing. Only key names are collected; the values are never read.
"""
found: set[str] = set()
try:
for line in ENV_FILE.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line.startswith("export "):
line = line[len("export "):].lstrip()
name, sep, value = line.partition("=")
if sep and value.strip().strip("\"'"):
found.add(name.strip())
except OSError:
pass
return found
def linksoul_ready() -> dict[str, Any]:
"""Whether the pipeline brain can actually run right now.
It needs edge-tts for the Emirati voice, miniaudio to decode that MP3 (this
robot has no ffmpeg and the `agi` account has no sudo), requests for the STT
call, and a Gemini key. It does NOT need the LinkSoul cloud SDK or its app
credentials - that was the earlier cloud design, which required AgiBot to
bind this robot to an application. The pipeline talks to the mic and speaker
directly, so it works with no platform registration at all.
"""
missing_pkgs = [p for p in ("edge_tts", "miniaudio", "requests")
if importlib.util.find_spec(p) is None]
has_key = ("SANAD_GEMINI_API_KEY" in _env_file_keys()
or bool(os.environ.get("SANAD_GEMINI_API_KEY"))
or bool(os.environ.get("GEMINI_API_KEY")))
reasons = []
if missing_pkgs:
reasons.append("missing Python packages: " + ", ".join(missing_pkgs)
+ " (pip3 install --user " + " ".join(missing_pkgs) + ")")
if not has_key:
reasons.append(f"SANAD_GEMINI_API_KEY is not set in {ENV_FILE}")
return {"ready": not reasons, "reasons": reasons}
def snapshot() -> dict[str, Any]:
"""Everything the Interaction card needs to render itself."""
mod = _profile()
if mod is None:
return {
"available": False,
"error": _profile_err or "session_profile.py not found",
"sanad_dir": str(SANAD_DIR),
"options": OPTIONS,
"service": service_status(),
"linksoul": linksoul_ready(),
}
resolved = mod.resolve()
return {
"available": True,
"error": "",
"options": OPTIONS,
**mod.catalog(),
"profile": {k: resolved[k] for k in ("enabled", "gender", "language", "model")},
"persona": {
"file": resolved["persona_file"],
"exists": resolved["persona_exists"],
"name_en": resolved["name_en"],
"name_ar": resolved["name_ar"],
},
"voice": {
"gemini": resolved["gemini_voice"],
"edge": resolved["edge_voice"],
},
"greeting": resolved["greeting"],
"service": service_status(),
"linksoul": linksoul_ready(),
}
def apply(enabled: bool, gender: str | None, language: str | None,
model: str | None) -> dict[str, Any]:
"""Persist the selection and bring the conversation loop to that state.
Returns the new snapshot plus a `greeting` the caller should speak when the
session has just been switched on. The greeting is spoken by the dashboard
rather than by the persona so it fires exactly once, at the moment Apply is
pressed, on whichever model is selected the LinkSoul path is a passive
callback and cannot start talking on its own.
"""
mod = _profile()
if mod is None:
return {"ok": False, "error": _profile_err or "session_profile.py not found",
"snapshot": snapshot()}
# Validate a CANDIDATE before persisting anything. Saving first and
# rejecting afterwards left an unlaunchable selection on disk: the dashboard
# refused to start it, but start_agibot.sh would happily replay it at the
# next boot, exec a runner that exits immediately, and Restart=on-failure
# would turn that into a crash loop with the robot mute.
current = mod.load()
candidate = mod.normalize({
"enabled": enabled,
"gender": gender or current["gender"],
"language": language or current["language"],
"model": model or current["model"],
})
resolved = mod.resolve(candidate)
if not resolved["persona_exists"]:
return {
"ok": False,
"error": f"Persona file missing: {resolved['persona_file']}",
"snapshot": snapshot(),
}
if enabled and resolved["model"] == "linksoul":
ready = linksoul_ready()
if not ready["ready"]:
return {"ok": False,
"error": "LinkSoul is not usable yet — " + "; ".join(ready["reasons"]),
"snapshot": snapshot()}
saved = mod.save(**{k: candidate[k] for k in ("enabled", "gender", "language", "model")})
resolved = mod.resolve(saved)
# The voice process is owned by keepalive_daemon.sh, not by systemd - the
# user manager dies with the last login session (no linger, needs root), so
# the daemon had to take over. That means `systemctl stop` no longer stops
# anything: pressing "Speaking off" left the robot still listening. So act
# on the processes directly here, and let the daemon keep enforcing it.
_kill_voice_processes()
if enabled:
_systemctl("reset-failed", SERVICE, timeout=10.0)
code, out = _systemctl("restart", SERVICE, timeout=45.0)
action = "restarted"
else:
code, out = _systemctl("stop", SERVICE, timeout=30.0)
action = "stopped"
# The voice process does not exit on SIGINT within TimeoutStopSec, so
# systemd SIGKILLs it and books the unit as failed. That is an artifact
# of an operator-requested stop, not a fault, and showing "failed" on
# the card after pressing Off would be plainly wrong.
if code == 0:
_systemctl("reset-failed", SERVICE, timeout=10.0)
if code != 0:
return {"ok": False, "error": f"systemctl {action} failed: {out}",
"snapshot": snapshot()}
return {
"ok": True,
"action": action,
"greeting": resolved["greeting"] if enabled else "",
"snapshot": snapshot(),
}

View File

@ -0,0 +1,773 @@
"""
AGIBOT X2 interface specification.
IMPORTANT: every constant in this file was read back from the robot itself
(10.255.254.84, PC2 / Jetson Orin) by introspecting the installed `aimdk_msgs`
package and the live ROS 2 graph - not copied from the published documentation.
Where the two disagree, the robot wins. Notable differences found:
* McAction has 18 modes, not 5. PASSIVE_DEFAULT (1) and ZERO_TORQUE_DEFAULT
(4) are distinct; the docs conflated them.
* McControlArea is a BITMASK (LEFT_HAND=1, RIGHT_HAND=2, HEAD=4, WAIST=8),
which is why the docs' "area 3" (both arms) and "area 11" (whole body)
work out - 3 = 1|2 and 11 = 1|2|8.
* Several documented preset motion IDs (1007, 1010, 1011, 3017, 3024, 3025,
3031) do not exist in the firmware enum. The real list is below.
* PmuState spells its fan field `fan_pecentage` (sic) and reports charge in
`battery_remaining_capacity_percentage`; `battery_remaining_capacity` is
mAh, not percent.
* Joint names carry a `_joint` suffix.
* This unit exposes SIX working camera feeds and a chest LiDAR. An earlier
revision of this file claimed one camera, no rear camera and an unreachable
Orbbec; all three were wrong. Corrected by subscribing to each topic and
decoding a real frame off it - see CAMERA_INVENTORY. SLAM command topics
are still genuinely absent.
"""
from __future__ import annotations
# --------------------------------------------------------------------------
# Network
# --------------------------------------------------------------------------
# PC1 runs motion control and must never be used as a build/run host.
PC1_MOTION_CONTROL_IP = "10.0.1.40"
# PC2 (this robot's development unit) as seen on the internal develop0 network.
PC2_DEVELOP_IP = "10.0.1.41"
DEFAULT_ROS_DOMAIN_ID = 0
# Port the on-robot agent listens on.
AGENT_PORT = 8781
# Ports probed when scanning a subnet for the robot.
DISCOVERY_PORTS = [AGENT_PORT, 22, 8080, 9090]
# --------------------------------------------------------------------------
# Sensor topics (verified present on the live graph)
# --------------------------------------------------------------------------
TOPIC_IMU_CHEST = "/aima/hal/imu/chest/state"
TOPIC_IMU_TORSO = "/aima/hal/imu/torso/state"
TOPIC_TOUCH_HEAD = "/aima/hal/sensor/touch_head"
TOPIC_MODULE_INFO = "/aima/hal/sensor/module_info"
# Every feed is on demand - `always_on` is gone. Nothing is subscribed on the
# robot until the operator switches that feed on in the Vision tab, and the
# agent drops the lot when the last browser disconnects. A single frame here is
# 170-430 KB; holding six of them open for a tab nobody is looking at competes
# with the robot's own traffic on the same Wi-Fi.
CAMERAS = [
{
"key": "rgb_head_front_center",
"label": "Head camera (front)",
"kind": "rgb",
"rate_hz": 10,
"topic": "/aima/hal/sensor/rgb_head_front_center/rgb_image/compressed",
"info_topic": "/aima/hal/sensor/rgb_head_front_center/camera_info",
"flip": False,
},
{
"key": "rgb_head_rear",
"label": "Head camera (rear)",
"kind": "rgb",
"rate_hz": 10,
"topic": "/aima/hal/sensor/rgb_head_rear/rgb_image/compressed",
"info_topic": "/aima/hal/sensor/rgb_head_rear/camera_info",
"flip": False,
},
{
"key": "rgbd_head_front",
"label": "RGB-D colour (Orbbec)",
"kind": "rgb",
"rate_hz": 10,
"topic": "/aima/hal/sensor/rgbd_head_front/rgb_image/compressed",
"info_topic": "/aima/hal/sensor/rgbd_head_front/rgb_camera_info",
"flip": True,
"note": "This module is mounted upside down, so the feed is rotated 180 by "
"default. Use the button below if yours is not.",
},
{
"key": "depth_front",
"label": "Depth (colourised)",
"kind": "depth",
"rate_hz": 14,
"topic": "/camera/depth/image_raw/compressedDepth",
"flip": True,
"note": "16-bit millimetre map, rendered to a JET ramp over 0-4.5 m on the robot "
"and rotated 180 like the colour feed it shares a module with. "
"Black is 'no return', not 'very close'.",
},
{
"key": "stereo_head_front_left",
"label": "Stereo left",
"kind": "rgb",
"rate_hz": 10,
"topic": "/aima/hal/sensor/stereo_head_front_left/rgb_image/compressed",
"info_topic": "/aima/hal/sensor/stereo_head_front_left/camera_info",
"flip": False,
},
{
"key": "stereo_head_front_right",
"label": "Stereo right",
"kind": "rgb",
"rate_hz": 10,
"topic": "/aima/hal/sensor/stereo_head_front_right/rgb_image/compressed",
"info_topic": "/aima/hal/sensor/stereo_head_front_right/camera_info",
"flip": False,
},
{
"key": "perception_input",
"label": "Perception input",
"kind": "debug",
"rate_hz": 10,
"topic": "/mono_perception/debug/input_image",
"flip": False,
"note": "Only carries frames while the mono-perception module is running.",
},
{
"key": "perception_seg",
"label": "Segmentation mask",
"kind": "debug",
"rate_hz": 10,
"topic": "/mono_perception/debug/seg_mask",
"flip": False,
"note": "Only carries frames while the mono-perception module is running.",
},
{
"key": "perception_lines",
"label": "Line colour map",
"kind": "debug",
"rate_hz": 10,
"topic": "/mono_perception/debug/line_color_map",
"flip": False,
"note": "Only carries frames while the mono-perception module is running.",
},
]
# What this unit actually has, established the only way that settles it: by
# subscribing to each topic and decoding a frame. A previous revision inferred
# the inventory from the v4l2 device map and the em app list, and got it wrong
# three ways - the rear camera and the Orbbec feeds are on the shared ROS graph
# and publish perfectly well to an ordinary `agi` subscriber. Reading the device
# tree tells you which sensors the SoC enumerates, not which topics carry data.
CAMERA_INVENTORY = [
{
"name": "Head front (imx900c)",
"detail": "MIPI/GMSL sensor 2-0039, 2064x1552 12-bit Bayer, ISP-processed "
"and published as 2688x1944 JPEG at ~10 Hz.",
"status": "live",
"where": "/aima/hal/sensor/rgb_head_front_center/rgb_image/compressed",
},
{
"name": "Head rear",
"detail": "Fitted and publishing 2064x1552 JPEG at ~10 Hz, mounted upright "
"(no rotation needed). Confirmed by decoding a frame.",
"status": "live",
"where": "/aima/hal/sensor/rgb_head_rear/rgb_image/compressed",
},
{
"name": "Orbbec Gemini 335 (RGB-D)",
"detail": "Colour and depth both reachable from the agi account. Depth is a "
"16-bit millimetre map published as compressedDepth at ~14 Hz, "
"topping out around 4.2 m. The module is mounted upside down - both "
"its feeds are rotated 180 by default.",
"status": "live",
"where": "/aima/hal/sensor/rgbd_head_front/* and /camera/depth/image_raw/compressedDepth",
},
{
"name": "Stereo pair (front left / right)",
"detail": "Both eyes publish JPEG at ~10 Hz. Useful side by side for depth "
"the RGB-D sensor misses on reflective surfaces.",
"status": "live",
"where": "/aima/hal/sensor/stereo_head_front_{left,right}/rgb_image/compressed",
},
{
"name": "Perception debug views",
"detail": "Input image, segmentation mask and line colour map. Real topics, "
"but only carry frames while the mono-perception module is running.",
"status": "intermittent",
"where": "/mono_perception/debug/*",
},
]
# --------------------------------------------------------------------------
# Chest LiDAR
# --------------------------------------------------------------------------
# Live on this unit at 2 Hz. Field layout read off the topic rather than
# assumed: x/y/z float32 at offsets 0/4/8, intensity float32 at 16, ring uint16
# at 20, timestamp float64 at 24, point_step 32. A scan is ~25.5k points
# (816 KB), decimated on the robot before it crosses the network.
LIDAR = {
"key": "lidar_chest_front",
"label": "Chest LiDAR",
"topic": "/aima/hal/sensor/lidar_chest_front/lidar_pointcloud_down_sampling",
"status_topic": "/aima/hal/sensor/lidar_chest_front/lidar_status",
"type": "sensor_msgs/PointCloud2",
"rate_hz": 2,
"frame": "lidar_chest_front",
"max_points": 4000,
}
SENSOR_TOPICS = [
{"topic": TOPIC_IMU_CHEST, "type": "sensor_msgs/Imu", "rate_hz": 500, "label": "Chest IMU"},
{"topic": TOPIC_IMU_TORSO, "type": "sensor_msgs/Imu", "rate_hz": 500, "label": "Torso IMU"},
{"topic": TOPIC_TOUCH_HEAD, "type": "aimdk_msgs/TouchState", "rate_hz": 100, "label": "Head touch"},
{"topic": "/aima/hal/pmu/state", "type": "aimdk_msgs/PmuState", "rate_hz": 1, "label": "Power management"},
{"topic": "/aima/hal/joint/head/state", "type": "aimdk_msgs/JointStateArray", "rate_hz": 100, "label": "Head joints"},
{"topic": "/aima/hal/joint/waist/state", "type": "aimdk_msgs/JointStateArray", "rate_hz": 100, "label": "Waist joints"},
{"topic": "/aima/hal/joint/arm/state", "type": "aimdk_msgs/JointStateArray", "rate_hz": 100, "label": "Arm joints"},
{"topic": "/aima/hal/joint/leg/state", "type": "aimdk_msgs/JointStateArray", "rate_hz": 100, "label": "Leg joints"},
{"topic": "/aima/hal/joint/hand/state", "type": "aimdk_msgs/HandStateArray", "rate_hz": 30, "label": "Hand state"},
{"topic": CAMERAS[0]["topic"], "type": "sensor_msgs/CompressedImage", "rate_hz": 10, "label": "Head camera"},
{"topic": "/aima/mc/leg_odometry", "type": "nav_msgs/Odometry", "rate_hz": 50, "label": "Leg odometry"},
{"topic": "/face_ui_proxy/status", "type": "aimdk_msgs/FaceEmojiStatus", "rate_hz": 1, "label": "Face status"},
]
# Head touch reports 8 independent zones.
TOUCH_ZONE_COUNT = 8
# --------------------------------------------------------------------------
# Power management (exact PmuState field names)
# --------------------------------------------------------------------------
TOPIC_PMU_STATE = "/aima/hal/pmu/state"
PMU_RATE_HZ = 1.0
PMU_FIELDS = {
"battery_pct": "battery_remaining_capacity_percentage",
"battery_voltage": "battery_voltage",
"battery_pack_voltage": "battery_pack_voltage",
"battery_current": "battery_current",
"battery_temp": "battery_temperature",
"battery_cycles": "battery_cycle_count",
"battery_capacity_mah": "battery_remaining_capacity",
"battery_power": "battery_output_power",
"pmu_temp": "pmu_temperature",
"fan_rpm": "fan_speed",
"fan_pct": "fan_pecentage", # firmware spells it this way
}
# (display label, voltage field, current field, nominal volts)
PMU_RAILS = [
{"key": "bus_48v", "label": "48 V bus", "voltage": "bus_48v_voltage",
"current": "bus_48v_current", "nominal": 48.0},
{"key": "output_48v", "label": "48 V output", "voltage": "output_48v_voltage",
"current": "output_48v_current", "nominal": 48.0},
{"key": "output_12v", "label": "12 V output", "voltage": "output_12v_voltage",
"current": "output_12v_current", "nominal": 12.0},
{"key": "head_power", "label": "Head power", "voltage": "head_power_voltage",
"current": "head_power_current", "nominal": 24.0},
{"key": "orin", "label": "Orin NX", "voltage": "orin_voltage",
"current": "orin_current", "nominal": 19.0},
{"key": "rk3588", "label": "RK3588", "voltage": "rk3588_voltage",
"current": "rk3588_current", "nominal": 12.0},
{"key": "fan", "label": "Fan rail", "voltage": "fan_voltage",
"current": None, "nominal": 12.0},
{"key": "bus_48v_pmos", "label": "48 V PMOS", "voltage": "bus_48v_pmos_voltage",
"current": None, "nominal": 48.0},
]
PMU_INFO_FIELDS = [
("bms_manufacturer", "BMS manufacturer"),
("bms_serial_number", "BMS serial"),
("bms_hardware_version", "BMS hardware"),
("bms_software_version", "BMS software"),
("pmu_software_version", "PMU software"),
("pmu_hardware_version", "PMU hardware"),
("pmu_protocol_version", "PMU protocol"),
]
# --------------------------------------------------------------------------
# Motion modes (aimdk_msgs/msg/McAction constants, read from the robot)
# --------------------------------------------------------------------------
SRV_GET_MC_ACTION = "/aimdk_5Fmsgs/srv/GetMcAction"
SRV_SET_MC_ACTION = "/aimdk_5Fmsgs/srv/SetMcAction"
MC_MODES = [
# -- safety / low level -------------------------------------------------
{"id": "PASSIVE_DEFAULT", "value": 1, "label": "Passive", "group": "basic",
"desc": "Joints released, no holding force. The robot must be supported.",
"danger": True},
{"id": "ZERO_TORQUE_DEFAULT", "value": 4, "label": "Zero torque", "group": "basic",
"desc": "Explicit zero-torque command. The robot will collapse if free-standing.",
"danger": True},
{"id": "SOFT_EMERGENCY_STOP", "value": 2, "label": "Soft E-stop", "group": "basic",
"desc": "Controlled emergency stop.", "danger": True},
{"id": "DAMPING_DEFAULT", "value": 3, "label": "Damping", "group": "basic",
"desc": "Joints hold damping - the safe resting state.", "danger": False},
# -- position control ---------------------------------------------------
{"id": "JOINT_DEFAULT", "value": 100, "label": "Joint control", "group": "joint",
"desc": "Position-controlled joints. Required for direct joint commands.",
"danger": False},
{"id": "JOINT_FREEZE", "value": 101, "label": "Joint freeze", "group": "joint",
"desc": "Hold the current joint positions.", "danger": False},
# -- standing -----------------------------------------------------------
{"id": "STAND_DEFAULT", "value": 200, "label": "Stable stand", "group": "stand",
"desc": "Active balance. Prerequisite for preset motions and walking.",
"danger": False},
{"id": "STAND_BODY_CONTROL", "value": 201, "label": "Body control", "group": "stand",
"desc": "Standing with body pose control enabled.", "danger": False},
# -- locomotion ---------------------------------------------------------
{"id": "LOCOMOTION_DEFAULT", "value": 300, "label": "Walk", "group": "locomotion",
"desc": "Normal walking gait.", "danger": False},
{"id": "LOCOMOTION_STEP", "value": 302, "label": "Step", "group": "locomotion",
"desc": "Stepping gait.", "danger": False},
{"id": "RUN_DEFAULT", "value": 301, "label": "Run", "group": "locomotion",
"desc": "Running gait. Needs clear space.", "danger": True},
{"id": "ASCEND_STAIRS", "value": 2006, "label": "Ascend stairs", "group": "locomotion",
"desc": "Stair-climbing gait.", "danger": True},
{"id": "DESCEND_STAIRS", "value": 2008, "label": "Descend stairs", "group": "locomotion",
"desc": "Stair-descending gait.", "danger": True},
# -- postures -----------------------------------------------------------
{"id": "STAND_UP_DEFAULT", "value": 2005, "label": "Stand up", "group": "posture",
"desc": "Rise to standing.", "danger": False},
{"id": "SIT_DOWN_DEFAULT", "value": 2000, "label": "Sit down", "group": "posture",
"desc": "Sit down from standing.", "danger": False},
{"id": "CROUCH_DOWN_DEFAULT", "value": 2002, "label": "Crouch", "group": "posture",
"desc": "Crouch down.", "danger": False},
{"id": "LIE_DOWN_DEFAULT", "value": 2004, "label": "Lie down", "group": "posture",
"desc": "Lie down.", "danger": False},
# -- external -----------------------------------------------------------
{"id": "VR_REMOTE_CONTROLLER", "value": 400, "label": "VR teleop", "group": "external",
"desc": "Hand control to the VR teleoperation bridge.", "danger": False},
]
MC_MODE_GROUPS = [
{"key": "basic", "label": "Basic / safety"},
{"key": "joint", "label": "Joint control"},
{"key": "stand", "label": "Standing"},
{"key": "locomotion", "label": "Locomotion"},
{"key": "posture", "label": "Posture"},
{"key": "external", "label": "External control"},
]
MC_MODE_BY_ID = {m["id"]: m for m in MC_MODES}
MC_MODE_BY_VALUE = {m["value"]: m for m in MC_MODES}
MC_MODE_IDS = [m["id"] for m in MC_MODES]
# Modes in which the robot will accept a velocity command.
DRIVEABLE_MODES = {"LOCOMOTION_DEFAULT", "LOCOMOTION_STEP", "RUN_DEFAULT",
"STAND_DEFAULT", "STAND_BODY_CONTROL"}
# Modes in which direct joint commands make sense.
JOINT_CONTROL_MODES_ALLOWED = {"JOINT_DEFAULT", "JOINT_FREEZE"}
MC_ACTION_STATUS = {0: "Idle", 100: "Running", 200: "Transition"}
COMMON_STATE = {
0: "Unknown", 1: "Success", 2: "Failure", 3: "Aborted", 4: "Timeout",
5: "Invalid", 6: "In manual", 100: "Not ready", 200: "Pending",
300: "Created", 400: "Running",
}
# --------------------------------------------------------------------------
# Locomotion
# --------------------------------------------------------------------------
TOPIC_LOCOMOTION_VELOCITY = "/aima/mc/locomotion/velocity"
TOPIC_LEG_ODOMETRY = "/aima/mc/leg_odometry"
TOPIC_BODY_POSE = "/aima/mc/body_pose"
VELOCITY_THRESHOLDS = {"forward": 0.09, "lateral": 0.60, "angular": 0.03}
VELOCITY_LIMITS = {
"forward": {"min": -0.6, "max": 0.8, "step": 0.01, "unit": "m/s"},
"lateral": {"min": -0.7, "max": 0.7, "step": 0.01, "unit": "m/s"},
"angular": {"min": -0.8, "max": 0.8, "step": 0.01, "unit": "rad/s"},
}
LOCOMOTION_PUBLISH_HZ = 20
LOCOMOTION_DEADMAN_S = 0.5
# --------------------------------------------------------------------------
# Input source arbitration
# --------------------------------------------------------------------------
SRV_GET_CURRENT_INPUT_SOURCE = "/aimdk_5Fmsgs/srv/GetCurrentInputSource"
SRV_SET_MC_INPUT_SOURCE = "/aimdk_5Fmsgs/srv/SetMcInputSource"
INPUT_SOURCE_ACTION = {
"ADD": 1001, "MODIFY": 1002, "DELETE": 1003, "ENABLE": 2001, "DISABLE": 2002,
}
BUILTIN_INPUT_SOURCES = [
{"name": "rc", "priority": 80, "timeout": 1000, "desc": "Handheld remote controller"},
{"name": "vr", "priority": 70, "timeout": 1000, "desc": "VR teleoperation"},
{"name": "app_proxy", "priority": 60, "timeout": 1000, "desc": "Mobile app"},
{"name": "interaction", "priority": 50, "timeout": 1000, "desc": "Interaction module"},
{"name": "pnc", "priority": 40, "timeout": 1000, "desc": "Planning and control"},
]
DASHBOARD_INPUT_SOURCE = {"name": "x2_dashboard", "priority": 30, "timeout": 1000}
# --------------------------------------------------------------------------
# Preset motions (aimdk_msgs/msg/McPresetMotion constants, read from robot)
# McControlArea is a bitmask: LEFT_HAND=1 RIGHT_HAND=2 HEAD=4 WAIST=8
# --------------------------------------------------------------------------
SRV_SET_PRESET_MOTION = "/aimdk_5Fmsgs/srv/SetMcPresetMotion"
AREA_LEFT_HAND = 1
AREA_RIGHT_HAND = 2
AREA_BOTH_HANDS = 3
AREA_HEAD = 4
AREA_WAIST = 8
AREA_BODY = 11 # both hands + waist
MC_CONTROL_AREAS = {
0: "None", 1: "Left arm", 2: "Right arm", 3: "Both arms",
4: "Head", 8: "Waist", 11: "Whole body",
}
PRESET_MOTIONS = [
# -- greeting -----------------------------------------------------------
{"key": "wave_right", "label": "Wave", "side": "right", "motion": 1002, "area": AREA_RIGHT_HAND, "group": "greeting", "enum": "WAVE_HAND"},
{"key": "wave_left", "label": "Wave", "side": "left", "motion": 1002, "area": AREA_LEFT_HAND, "group": "greeting", "enum": "WAVE_HAND"},
{"key": "shake_right", "label": "Handshake", "side": "right", "motion": 1003, "area": AREA_RIGHT_HAND, "group": "greeting", "enum": "SHAKE_HAND"},
{"key": "shake_left", "label": "Handshake", "side": "left", "motion": 1003, "area": AREA_LEFT_HAND, "group": "greeting", "enum": "SHAKE_HAND"},
{"key": "raise_right", "label": "Raise hand", "side": "right", "motion": 1001, "area": AREA_RIGHT_HAND, "group": "greeting", "enum": "RAISE_HAND"},
{"key": "raise_left", "label": "Raise hand", "side": "left", "motion": 1001, "area": AREA_LEFT_HAND, "group": "greeting", "enum": "RAISE_HAND"},
{"key": "raise_both", "label": "Raise both hands", "side": "both", "motion": 1001, "area": AREA_BOTH_HANDS, "group": "greeting", "enum": "RAISE_HAND"},
{"key": "salute_right", "label": "Salute", "side": "right", "motion": 1013, "area": AREA_RIGHT_HAND, "group": "greeting", "enum": "SALUTE"},
{"key": "salute_left", "label": "Salute", "side": "left", "motion": 1013, "area": AREA_LEFT_HAND, "group": "greeting", "enum": "SALUTE"},
{"key": "turn_wave", "label": "Turn and wave", "side": "body", "motion": 2001, "area": AREA_BODY, "group": "greeting", "enum": "TURN_WAVE_HAND"},
{"key": "bow", "label": "Bow", "side": "body", "motion": 3001, "area": AREA_BODY, "group": "greeting", "enum": "INTERACTION_BOW"},
# -- expressive ---------------------------------------------------------
{"key": "kiss_right", "label": "Flying kiss", "side": "right", "motion": 1004, "area": AREA_RIGHT_HAND, "group": "expressive", "enum": "FLYING_KISS_HAND"},
{"key": "kiss_left", "label": "Flying kiss", "side": "left", "motion": 1004, "area": AREA_LEFT_HAND, "group": "expressive", "enum": "FLYING_KISS_HAND"},
{"key": "blowkiss", "label": "Blow kiss", "side": "body", "motion": 3012, "area": AREA_BODY, "group": "expressive", "enum": "INTERACTION_BLOWKISS"},
{"key": "sweatheart", "label": "Heart", "side": "body", "motion": 3004, "area": AREA_BODY, "group": "expressive", "enum": "INTERACTION_SWEATHEART"},
{"key": "like", "label": "Thumbs up", "side": "body", "motion": 3002, "area": AREA_BODY, "group": "expressive", "enum": "INTERACTION_LIKE"},
{"key": "ye", "label": "Peace sign", "side": "body", "motion": 3003, "area": AREA_BODY, "group": "expressive", "enum": "INTERACTION_YE"},
{"key": "hug", "label": "Hug", "side": "body", "motion": 3008, "area": AREA_BODY, "group": "expressive", "enum": "INTERACTION_HUG"},
{"key": "cheer", "label": "Cheer", "side": "body", "motion": 3011, "area": AREA_BODY, "group": "expressive", "enum": "INTERACTION_CHEER"},
{"key": "sad", "label": "Sad", "side": "body", "motion": 3006, "area": AREA_BODY, "group": "expressive", "enum": "INTERACTION_SAD"},
{"key": "speak", "label": "Speaking gesture", "side": "body", "motion": 3016, "area": AREA_BODY, "group": "expressive", "enum": "INTERACTION_SPEAK"},
# -- gesture ------------------------------------------------------------
{"key": "clap", "label": "Clap", "side": "both", "motion": 1008, "area": AREA_BOTH_HANDS, "group": "gesture", "enum": "CLAP_HAND"},
{"key": "hitclap", "label": "Hit clap", "side": "body", "motion": 3015, "area": AREA_BODY, "group": "gesture", "enum": "HITCLAP"},
{"key": "fist_right", "label": "Fist", "side": "right", "motion": 1009, "area": AREA_RIGHT_HAND, "group": "gesture", "enum": "CLIPFIST"},
{"key": "fist_left", "label": "Fist", "side": "left", "motion": 1009, "area": AREA_LEFT_HAND, "group": "gesture", "enum": "CLIPFIST"},
{"key": "handx", "label": "Cross arms", "side": "body", "motion": 3009, "area": AREA_BODY, "group": "gesture", "enum": "INTERACTION_HANDX"},
{"key": "chestwave", "label": "Chest wave", "side": "body", "motion": 3010, "area": AREA_BODY, "group": "gesture", "enum": "INTERACTION_CHESTWAVE"},
{"key": "lightwave", "label": "Light wave", "side": "body", "motion": 3007, "area": AREA_BODY, "group": "gesture", "enum": "INTERACTION_LIGHTWAVE"},
# -- performance --------------------------------------------------------
{"key": "dance1", "label": "Bass dance 1", "side": "body", "motion": 3013, "area": AREA_BODY, "group": "performance", "enum": "INTERACTION_BASSDANCE1"},
{"key": "dance2", "label": "Bass dance 2", "side": "body", "motion": 3014, "area": AREA_BODY, "group": "performance", "enum": "INTERACTION_BASSDANCE2"},
{"key": "photo", "label": "Photo pose", "side": "body", "motion": 3018, "area": AREA_BODY, "group": "performance", "enum": "INTERACTION_PHOTOPOSTURE"},
{"key": "photo3", "label": "Triple photo pose", "side": "body", "motion": 3019, "area": AREA_BODY, "group": "performance", "enum": "INTERACTION_PHOTOTRIPPLEPOSTURE"},
# -- head ---------------------------------------------------------------
{"key": "point_head", "label": "Point head", "side": "head", "motion": 4001, "area": AREA_HEAD, "group": "head", "enum": "POINT_HEAD"},
{"key": "shake_head", "label": "Shake head", "side": "head", "motion": 4002, "area": AREA_HEAD, "group": "head", "enum": "SHAKE_HEAD"},
]
PRESET_GROUPS = [
{"key": "greeting", "label": "Greeting"},
{"key": "expressive", "label": "Expressive"},
{"key": "gesture", "label": "Gesture"},
{"key": "performance", "label": "Performance"},
{"key": "head", "label": "Head"},
]
# --------------------------------------------------------------------------
# Joints (names read from live JointStateArray messages)
# Limits: about_agibot_X2/joint_name_and_limit.html, X2 Ultra ranges.
# --------------------------------------------------------------------------
SRV_GET_ALL_JOINT_STATE = "/aimdk_5Fmsgs/srv/GetAllJointState"
def _j(name, label, lo, hi):
return {"name": name, "label": label, "min_deg": lo, "max_deg": hi}
JOINT_GROUPS = [
{
"key": "head", "label": "Head",
"command_topic": "/aima/hal/joint/head/command",
"state_topic": "/aima/hal/joint/head/state",
"joints": [
_j("head_yaw_joint", "Head yaw", -20.0, 20.0),
_j("head_pitch_joint", "Head pitch", 0.0, 0.0),
],
},
{
"key": "waist", "label": "Waist",
"command_topic": "/aima/hal/joint/waist/command",
"state_topic": "/aima/hal/joint/waist/state",
"joints": [
_j("waist_yaw_joint", "Waist yaw", -196.5, 126.5),
_j("waist_pitch_joint", "Waist pitch", -18.0, 18.0),
_j("waist_roll_joint", "Waist roll", -28.0, 28.0),
],
},
{
"key": "arm", "label": "Arms",
"command_topic": "/aima/hal/joint/arm/command",
"state_topic": "/aima/hal/joint/arm/state",
"joints": [
_j("left_shoulder_pitch_joint", "L shoulder pitch", -116.5, 176.5),
_j("left_shoulder_roll_joint", "L shoulder roll", -3.5, 174.5),
_j("left_shoulder_yaw_joint", "L shoulder yaw", -146.5, 146.5),
_j("left_elbow_joint", "L elbow", -135.0, 0.0),
_j("left_wrist_yaw_joint", "L wrist yaw", -146.5, 146.5),
_j("left_wrist_pitch_joint", "L wrist pitch", -33.0, 33.0),
_j("left_wrist_roll_joint", "L wrist roll", -86.5, 41.5),
_j("right_shoulder_pitch_joint", "R shoulder pitch", -116.5, 176.5),
_j("right_shoulder_roll_joint", "R shoulder roll", -3.5, 174.5),
_j("right_shoulder_yaw_joint", "R shoulder yaw", -146.5, 146.5),
_j("right_elbow_joint", "R elbow", -135.0, 0.0),
_j("right_wrist_yaw_joint", "R wrist yaw", -146.5, 146.5),
_j("right_wrist_pitch_joint", "R wrist pitch", -33.0, 33.0),
_j("right_wrist_roll_joint", "R wrist roll", -86.5, 41.5),
],
},
{
"key": "leg", "label": "Legs",
"command_topic": "/aima/hal/joint/leg/command",
"state_topic": "/aima/hal/joint/leg/state",
"joints": [
_j("left_hip_pitch_joint", "L hip pitch", -146.5, 146.5),
_j("left_hip_roll_joint", "L hip roll", -13.5, 166.5),
_j("left_hip_yaw_joint", "L hip yaw", -196.5, 96.5),
_j("left_knee_joint", "L knee", 0.0, 138.0),
_j("left_ankle_pitch_joint", "L ankle pitch", -26.0, 46.0),
_j("left_ankle_roll_joint", "L ankle roll", -15.0, 15.0),
_j("right_hip_pitch_joint", "R hip pitch", -146.5, 146.5),
_j("right_hip_roll_joint", "R hip roll", -13.5, 166.5),
_j("right_hip_yaw_joint", "R hip yaw", -196.5, 96.5),
_j("right_knee_joint", "R knee", 0.0, 138.0),
_j("right_ankle_pitch_joint", "R ankle pitch", -26.0, 46.0),
_j("right_ankle_roll_joint", "R ankle roll", -15.0, 15.0),
],
},
]
JOINT_GROUP_BY_KEY = {g["key"]: g for g in JOINT_GROUPS}
JOINT_CONTROL_MODES = [
{"id": "position", "label": "Position", "unit": "rad", "desc": "Target angle."},
{"id": "velocity", "label": "Velocity", "unit": "rad/s", "desc": "Target angular velocity."},
{"id": "torque", "label": "Torque", "unit": "N·m", "desc": "Direct effort output."},
]
# --------------------------------------------------------------------------
# End effectors
# --------------------------------------------------------------------------
TOPIC_HAND_COMMAND = "/aima/hal/joint/hand/command"
TOPIC_HAND_STATE = "/aima/hal/joint/hand/state"
SRV_GET_HAND_TYPE = "/aimdk_5Fmsgs/srv/GetHandType"
HAND_TYPES = {
0: "None", 1: "Nimble hands", 2: "Claw gripper",
3: "Leisai nimble hands", 255: "Error",
}
DEXHAND_JOINTS = [
"ThumbRoll", "ThumbAbAd", "ThumbMCP",
"IndexAbAd", "IndexPIP", "MiddlePIP",
"RingAbAd", "RingPIP", "PinkyAbAd", "PinkyPIP",
]
HAND_PRESETS = [
{"key": "open", "label": "Open", "positions": [0.0] * 10},
{"key": "close", "label": "Close", "positions": [0.6, 0.6, 1.2, 0.2, 1.4, 1.4, 0.2, 1.4, 0.2, 1.4]},
{"key": "pinch", "label": "Pinch", "positions": [0.4, 0.5, 1.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0]},
{"key": "point", "label": "Point", "positions": [0.6, 0.6, 1.2, 0.0, 0.0, 1.4, 0.2, 1.4, 0.2, 1.4]},
]
# --------------------------------------------------------------------------
# Voice / audio
# --------------------------------------------------------------------------
SRV_PLAY_TTS = "/aimdk_5Fmsgs/srv/PlayTts"
SRV_GET_VOLUME = "/aimdk_5Fmsgs/srv/GetVolume"
SRV_SET_VOLUME = "/aimdk_5Fmsgs/srv/SetVolume"
SRV_GET_MUTE = "/aimdk_5Fmsgs/srv/GetMute"
SRV_SET_MUTE = "/aimdk_5Fmsgs/srv/SetMute"
TOPIC_AUDIO_OUTPUT = "/agent/process_audio_output"
TOPIC_TTS_STATUS = "/interaction/tts_status"
# aimdk_msgs/msg/TtsPriorityLevel constants, read from the robot.
TTS_PRIORITIES = [
{"id": "SAFETY_L10", "value": 10, "label": "Safety", "desc": "Emergency alerts"},
{"id": "WARNING_L8", "value": 8, "label": "Warning", "desc": "Hazard warnings"},
{"id": "SYSTEM_L7", "value": 7, "label": "System", "desc": "System notices"},
{"id": "INTERACTION_L6", "value": 6, "label": "Interaction", "desc": "User responses"},
{"id": "MISSION_L4", "value": 4, "label": "Mission", "desc": "Task execution"},
{"id": "SERVICE_L2", "value": 2, "label": "Service", "desc": "Proactive services"},
{"id": "BACKGROUND_L1", "value": 1, "label": "Background", "desc": "Background logging"},
]
AUDIO_VAD_STATES = {0: "None", 1: "Speech begin", 2: "Processing", 3: "Speech end"}
# --------------------------------------------------------------------------
# Screen / face
# --------------------------------------------------------------------------
SRV_PLAY_EMOJI = "/aimdk_5Fmsgs/srv/PlayEmoji"
TOPIC_FACE_STATUS = "/face_ui_proxy/status"
EMOJI_PLAY_MODES = [{"value": 1, "label": "Once"}, {"value": 2, "label": "Loop"}]
FACE_STATUS = {0: "Idle", 1: "Start", 2: "Running", 3: "Finished", 4: "Stopped"}
EMOJIS = [
{"id": 1, "label": "Blink", "glyph": "\U0001F60C", "group": "neutral"},
{"id": 10, "label": "Calm (eyes 1)", "glyph": "\U0001F642", "group": "neutral"},
{"id": 11, "label": "Calm (eyes 2)", "glyph": "\U0001F642", "group": "neutral"},
{"id": 20, "label": "Calm (game)", "glyph": "\U0001F3AE", "group": "neutral"},
{"id": 30, "label": "Cute 1", "glyph": "\U0001F970", "group": "neutral"},
{"id": 31, "label": "Cute 2", "glyph": "\U0001F970", "group": "neutral"},
{"id": 32, "label": "Cute 3", "glyph": "\U0001F970", "group": "neutral"},
{"id": 33, "label": "Cute 4", "glyph": "\U0001F970", "group": "neutral"},
{"id": 40, "label": "Close eyes", "glyph": "\U0001F636", "group": "neutral"},
{"id": 50, "label": "Open eyes", "glyph": "\U0001F440", "group": "neutral"},
{"id": 60, "label": "Bored", "glyph": "\U0001F644", "group": "neutral"},
{"id": 80, "label": "Sleeping", "glyph": "\U0001F634", "group": "neutral"},
{"id": 90, "label": "Happy", "glyph": "\U0001F604", "group": "positive"},
{"id": 100, "label": "Extra happy", "glyph": "\U0001F606", "group": "positive"},
{"id": 101, "label": "Ecstatic", "glyph": "\U0001F929", "group": "positive"},
{"id": 150, "label": "Acting cute", "glyph": "\U0001F60A", "group": "positive"},
{"id": 200, "label": "Adoration", "glyph": "\U0001F60D", "group": "positive"},
{"id": 210, "label": "Extra adoring", "glyph": "\U0001F929", "group": "positive"},
{"id": 110, "label": "Sad", "glyph": "\U0001F622", "group": "negative"},
{"id": 120, "label": "Sympathy", "glyph": "\U0001F97A", "group": "negative"},
{"id": 130, "label": "Confused", "glyph": "\U0001F615", "group": "negative"},
{"id": 140, "label": "Shocked", "glyph": "\U0001F62E", "group": "negative"},
{"id": 160, "label": "Serious", "glyph": "\U0001F610", "group": "negative"},
{"id": 170, "label": "Thinking", "glyph": "\U0001F914", "group": "negative"},
{"id": 180, "label": "Angry", "glyph": "\U0001F620", "group": "negative"},
{"id": 190, "label": "Extra angry", "glyph": "\U0001F621", "group": "negative"},
{"id": 70, "label": "Abnormal", "glyph": "⚠️", "group": "system"},
{"id": 220, "label": "Charging", "glyph": "\U0001F50B", "group": "system"},
]
EMOJI_GROUPS = [
{"key": "neutral", "label": "Neutral"},
{"key": "positive", "label": "Positive"},
{"key": "negative", "label": "Negative"},
{"key": "system", "label": "System"},
]
# --------------------------------------------------------------------------
# LED strip
# --------------------------------------------------------------------------
SRV_SET_PMU_LED = "/aimdk_5Fmsgs/srv/SetPmuLed"
TOPIC_PMU_LED_STATE = "/aima/hal/pmu/led_state"
LED_MODES = [
{"value": 0, "label": "Steady", "desc": "Continuous solid colour"},
{"value": 1, "label": "Breathing", "desc": "4 s sinusoidal brightness cycle"},
{"value": 2, "label": "Blinking", "desc": "1 s cycle, toggles every 0.5 s"},
{"value": 3, "label": "Flowing", "desc": "2 s cycle, left to right then off"},
]
LED_DEFAULT_PRIORITY = 6
LED_SWATCHES = [
{"label": "Ice", "r": 57, "g": 135, "b": 229},
{"label": "Aqua", "r": 25, "g": 158, "b": 112},
{"label": "Amber", "r": 201, "g": 133, "b": 0},
{"label": "Ember", "r": 217, "g": 89, "b": 38},
{"label": "Rose", "r": 213, "g": 81, "b": 129},
{"label": "Violet", "r": 144, "g": 133, "b": 233},
{"label": "White", "r": 255, "g": 255, "b": 255},
{"label": "Off", "r": 0, "g": 0, "b": 0},
]
# --------------------------------------------------------------------------
# System / diagnostics topics present on this robot
# --------------------------------------------------------------------------
TOPIC_ALERT_CODES = "/aima/hds/alert_code_list"
TOPIC_SM_SYSTEM_STATE = "/aima/sm/system_state"
TOPIC_TASK_MASTER_STATE = "/task_master/state"
PROCESS_INFO_TOPICS = [
"/aima/hds/process/info/soc0",
"/aima/hds/process/info/soc1",
"/aima/hds/process/info/soc2",
]
# --------------------------------------------------------------------------
# Safety notes shown in the UI
# --------------------------------------------------------------------------
SAFETY_NOTES = [
{"scope": "mode", "level": "critical",
"text": "Passive and Zero-torque release all joint holding force. A free-standing robot "
"will collapse. Only select them when the robot is supported or already seated."},
{"scope": "locomotion", "level": "warning",
"text": "Enter Stable stand and register an input source before driving. Keep the handheld "
"remote controller within reach - it outranks this dashboard."},
{"scope": "preset", "level": "warning",
"text": "Preset motions require Stable stand and clear space around the robot."},
{"scope": "network", "level": "critical",
"text": f"Never use PC1 ({PC1_MOTION_CONTROL_IP}) as a build or run host. The dashboard "
f"agent runs on PC2 ({PC2_DEVELOP_IP})."},
]
def client_spec() -> dict:
"""Everything the browser needs, in one payload."""
return {
"modes": MC_MODES,
"mode_groups": MC_MODE_GROUPS,
"driveable_modes": sorted(DRIVEABLE_MODES),
"joint_control_modes_allowed": sorted(JOINT_CONTROL_MODES_ALLOWED),
"action_status": MC_ACTION_STATUS,
"presets": PRESET_MOTIONS,
"preset_groups": PRESET_GROUPS,
"control_areas": MC_CONTROL_AREAS,
"joint_groups": JOINT_GROUPS,
"joint_modes": JOINT_CONTROL_MODES,
"velocity_limits": VELOCITY_LIMITS,
"velocity_thresholds": VELOCITY_THRESHOLDS,
"cameras": CAMERAS,
"camera_inventory": CAMERA_INVENTORY,
"lidar": LIDAR,
"sensor_topics": SENSOR_TOPICS,
"touch_zones": TOUCH_ZONE_COUNT,
"pmu_rails": PMU_RAILS,
"pmu_info_fields": PMU_INFO_FIELDS,
"emojis": EMOJIS,
"emoji_groups": EMOJI_GROUPS,
"emoji_modes": EMOJI_PLAY_MODES,
"face_status": FACE_STATUS,
"led_modes": LED_MODES,
"led_swatches": LED_SWATCHES,
"tts_priorities": TTS_PRIORITIES,
"hand_presets": HAND_PRESETS,
"hand_types": HAND_TYPES,
"dexhand_joints": DEXHAND_JOINTS,
"builtin_input_sources": BUILTIN_INPUT_SOURCES,
"dashboard_input_source": DASHBOARD_INPUT_SOURCE,
"safety_notes": SAFETY_NOTES,
"agent_port": AGENT_PORT,
"topics": {
"locomotion": TOPIC_LOCOMOTION_VELOCITY,
"pmu": TOPIC_PMU_STATE,
"imu_chest": TOPIC_IMU_CHEST,
"imu_torso": TOPIC_IMU_TORSO,
"touch_head": TOPIC_TOUCH_HEAD,
"hand_command": TOPIC_HAND_COMMAND,
"hand_state": TOPIC_HAND_STATE,
"odometry": TOPIC_LEG_ODOMETRY,
"camera": CAMERAS[0]["topic"],
},
}

27
x2_dashboard/config.json Normal file
View File

@ -0,0 +1,27 @@
{
"robot_host": "127.0.0.1",
"agent_port": 8781,
"robot_label": "AGIBOT X2",
"dashboard_name": "agibot",
"advertise_name": true,
"auto_discover": true,
"auto_start_agent": false,
"robot_ssh_user": "agi",
"robot_ssh_password": "",
"robot_ssh_port": 22,
"agent_start_command": "/home/agi/x2_dashboard_agent/ensure_agent.sh",
"ros_domain_id": 0,
"rmw_implementation": "rmw_fastrtps_cpp",
"bridge_mode": "agent",
"host": "0.0.0.0",
"port": 8770,
"telemetry_hz": 10,
"history_seconds": 120,
"require_confirm_zero_torque": true,
"locomotion_deadman_s": 0.5,
"max_forward_velocity": 0.8,
"max_lateral_velocity": 0.7,
"max_angular_velocity": 0.8,
"theme": "dark",
"accent": "blue"
}

View File

@ -0,0 +1,991 @@
/* ==========================================================================
AGIBOT X2 Dashboard
Design tokens first, then layout, then components.
Series colours are the validated data-viz palette; chrome greys are neutral
so nothing competes with the data.
========================================================================== */
:root {
color-scheme: dark;
/* Surfaces */
--bg: #0b0c0e;
--surface: #141619;
--surface-2: #1b1e22;
--surface-3: #23262b;
--surface-hi: #2b2f36;
--border: #262a30;
--border-soft: #1e2126;
/* Ink */
--text: #f2f4f7;
--text-2: #a4adb8;
--text-3: #6d7681;
/* Categorical series - validated for CVD against a dark surface */
--series-1: #3987e5;
--series-2: #d95926;
--series-3: #199e70;
--series-4: #c98500;
--series-5: #d55181;
--series-6: #008300;
--series-7: #9085e9;
--series-8: #e66767;
/* Status - reserved, never used for a series */
--good: #0ca30c;
--warning: #fab219;
--serious: #ec835a;
--critical: #d03b3b;
--accent: var(--series-1);
--accent-soft: rgba(57, 135, 229, 0.14);
--accent-line: rgba(57, 135, 229, 0.4);
/* Chart chrome */
--grid: #22262c;
--axis: #333941;
--radius: 12px;
--radius-sm: 8px;
--radius-lg: 18px;
--shadow: 0 1px 2px rgba(0, 0, 0, .4), 0 8px 24px -12px rgba(0, 0, 0, .6);
--shadow-lg: 0 20px 60px -20px rgba(0, 0, 0, .8);
--rail-w: 216px;
--topbar-h: 60px;
--font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--mono: ui-monospace, "Cascadia Mono", "SF Mono", Menlo, Consolas, monospace;
--ease: cubic-bezier(.2, .7, .3, 1);
}
:root[data-theme="light"] {
color-scheme: light;
--bg: #f4f5f7;
--surface: #ffffff;
--surface-2: #f7f8fa;
--surface-3: #eef0f4;
--surface-hi: #e4e7ec;
--border: #dfe3e9;
--border-soft: #e9ecf1;
--text: #10131a;
--text-2: #4d5663;
--text-3: #7d8794;
--series-1: #2a78d6;
--series-2: #eb6834;
--series-3: #1baf7a;
--series-4: #eda100;
--series-5: #e87ba4;
--series-6: #008300;
--series-7: #4a3aa7;
--series-8: #e34948;
--accent: var(--series-1);
--accent-soft: rgba(42, 120, 214, 0.1);
--accent-line: rgba(42, 120, 214, 0.35);
--grid: #e8eaee;
--axis: #cfd4dc;
--shadow: 0 1px 2px rgba(16, 19, 26, .06), 0 8px 24px -14px rgba(16, 19, 26, .18);
--shadow-lg: 0 24px 60px -24px rgba(16, 19, 26, .28);
}
* { box-sizing: border-box; }
html, body {
margin: 0;
height: 100%;
background: var(--bg);
color: var(--text);
font-family: var(--font);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
body { overflow: hidden; }
button, input, select, textarea { font: inherit; color: inherit; }
h1, h2, h3, h4 { margin: 0; font-weight: 600; letter-spacing: -0.01em; }
a { color: var(--accent); }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--surface-hi); border-radius: 8px; border: 2px solid var(--bg); }
::-webkit-scrollbar-thumb:hover { background: var(--text-3); }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
/* ==========================================================================
App shell
========================================================================== */
.app {
display: grid;
grid-template-columns: var(--rail-w) 1fr;
grid-template-rows: var(--topbar-h) 1fr;
grid-template-areas: "top top" "rail content";
height: 100vh;
height: 100dvh;
}
/* Grid and flex children default to min-width:auto, so their min-content width
props the track open and the whole page scrolls sideways on a phone. Every
shell region opts out and scrolls internally instead. */
.app > * { min-width: 0; }
/* -- Topbar -------------------------------------------------------------- */
.topbar {
grid-area: top;
display: flex;
align-items: center;
gap: 20px;
padding: 0 16px;
background: var(--surface);
border-bottom: 1px solid var(--border);
z-index: 30;
}
.brand { display: flex; align-items: center; gap: 11px; min-width: 0; flex-shrink: 0; }
.brand-mark {
width: 34px; height: 34px;
border-radius: 10px;
background: linear-gradient(150deg, var(--series-1), #1f4f8f);
display: grid; place-items: center;
flex-shrink: 0;
box-shadow: inset 0 1px 0 rgba(255,255,255,.2);
}
.brand-dot {
width: 9px; height: 9px; border-radius: 50%;
background: #fff;
box-shadow: 0 0 0 3px rgba(255,255,255,.25);
}
.brand-text { display: flex; flex-direction: column; line-height: 1.2; min-width: 0; }
.brand-text strong { font-size: 14px; letter-spacing: -0.01em; }
.brand-text span { font-size: 11px; color: var(--text-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.topbar-status {
display: flex; align-items: center; gap: 8px;
flex: 1 1 0; min-width: 0;
overflow-x: auto; overflow-y: hidden;
scrollbar-width: none;
}
.topbar-status::-webkit-scrollbar { display: none; }
.pill {
display: inline-flex; align-items: center; gap: 7px;
height: 30px; padding: 0 11px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 999px;
font-size: 12px;
white-space: nowrap;
transition: background .15s var(--ease);
}
.pill-key { color: var(--text-3); font-size: 11px; text-transform: uppercase; letter-spacing: .06em; }
.pill-label { font-weight: 550; font-variant-numeric: tabular-nums; }
.pill .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--text-3); flex-shrink: 0; }
.pill .dot[data-state="online"] { background: var(--good); box-shadow: 0 0 0 3px rgba(12,163,12,.18); }
.pill .dot[data-state="sim"] { background: var(--warning); box-shadow: 0 0 0 3px rgba(250,178,25,.18); }
.pill .dot[data-state="offline"]{ background: var(--critical); box-shadow: 0 0 0 3px rgba(208,59,59,.18); }
.pill[data-level="warning"] { border-color: rgba(250,178,25,.4); }
.pill[data-level="critical"]{ border-color: rgba(208,59,59,.5); background: rgba(208,59,59,.1); }
.topbar-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.icon-btn {
width: 34px; height: 34px;
display: grid; place-items: center;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
transition: background .15s var(--ease), border-color .15s var(--ease);
}
.icon-btn:hover { background: var(--surface-3); border-color: var(--surface-hi); }
.ico { width: 18px; height: 18px; fill: currentColor; display: block; }
.btn-stop {
display: inline-flex; align-items: center; gap: 8px;
height: 34px; padding: 0 16px;
background: var(--critical);
color: #fff;
border: 1px solid transparent;
border-radius: var(--radius-sm);
font-weight: 700; font-size: 12px; letter-spacing: .08em;
cursor: pointer;
transition: filter .15s var(--ease), transform .08s var(--ease);
}
.btn-stop:hover { filter: brightness(1.12); }
.btn-stop:active { transform: scale(.97); }
.btn-stop .ico { width: 16px; height: 16px; }
/* -- Rail ---------------------------------------------------------------- */
.rail {
grid-area: rail;
background: var(--surface);
border-right: 1px solid var(--border);
padding: 12px 10px;
overflow-y: auto;
display: flex; flex-direction: column; gap: 2px;
}
.rail-group {
font-size: 10px; text-transform: uppercase; letter-spacing: .1em;
color: var(--text-3); font-weight: 600;
padding: 14px 10px 6px;
}
.rail-group:first-child { padding-top: 4px; }
.rail-item {
display: flex; align-items: center; gap: 11px;
padding: 9px 11px;
border-radius: var(--radius-sm);
color: var(--text-2);
background: transparent;
border: 0; width: 100%;
cursor: pointer;
text-align: left;
font-size: 13px; font-weight: 500;
position: relative;
transition: background .13s var(--ease), color .13s var(--ease);
}
.rail-item .ico { width: 17px; height: 17px; flex-shrink: 0; opacity: .85; }
.rail-item:hover { background: var(--surface-2); color: var(--text); }
.rail-item[aria-current="page"] {
background: var(--accent-soft);
color: var(--text);
font-weight: 600;
}
.rail-item[aria-current="page"] .ico { opacity: 1; color: var(--accent); }
.rail-item[aria-current="page"]::before {
content: ""; position: absolute; left: -10px; top: 50%; transform: translateY(-50%);
width: 3px; height: 20px; border-radius: 0 3px 3px 0; background: var(--accent);
}
.rail-badge {
margin-left: auto;
font-size: 10px; font-weight: 700;
min-width: 18px; height: 18px; padding: 0 5px;
border-radius: 9px;
background: var(--surface-3); color: var(--text-2);
display: grid; place-items: center;
font-variant-numeric: tabular-nums;
}
.rail-badge[data-tone="warn"] { background: rgba(250,178,25,.2); color: var(--warning); }
.rail-badge[data-tone="bad"] { background: rgba(208,59,59,.2); color: var(--critical); }
/* -- Content ------------------------------------------------------------- */
.content {
grid-area: content;
overflow-y: auto;
overflow-x: hidden;
padding: 20px 22px 60px;
scroll-behavior: smooth;
}
.content:focus { outline: none; }
.page-head {
display: flex; align-items: flex-end; justify-content: space-between;
gap: 16px; flex-wrap: wrap;
margin-bottom: 18px;
}
.page-head h1 { font-size: 22px; letter-spacing: -0.02em; }
.page-head p { margin: 4px 0 0; color: var(--text-2); font-size: 13px; max-width: 68ch; }
.page-head-actions { display: flex; gap: 8px; flex-wrap: wrap; }
/* ==========================================================================
Layout primitives
========================================================================== */
.grid { display: grid; gap: 14px; }
.grid.cols-2 { grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); }
.grid.cols-3 { grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); }
.grid.cols-4 { grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); }
.grid.cols-auto { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); }
/* A narrow leading column beside a flexible one. Collapses rather than
overflowing, which a fixed two-track template would do on a phone. */
.grid.split { grid-template-columns: minmax(250px, 330px) 1fr; }
.grid.pair { grid-template-columns: 1fr 1fr; }
@media (max-width: 900px) {
.grid.split, .grid.pair { grid-template-columns: 1fr; }
}
.stack { display: flex; flex-direction: column; gap: 14px; }
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.row.tight { gap: 6px; }
.row.between { justify-content: space-between; }
.spacer { flex: 1; }
.span-2 { grid-column: span 2; }
@media (max-width: 820px) { .span-2 { grid-column: span 1; } }
/* ==========================================================================
Card
========================================================================== */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
display: flex; flex-direction: column;
}
.card-head {
display: flex; align-items: center; gap: 10px;
padding: 13px 16px;
border-bottom: 1px solid var(--border-soft);
}
.card-head h3 { font-size: 13px; letter-spacing: .01em; }
.card-head .sub { font-size: 11px; color: var(--text-3); }
.card-head-actions { margin-left: auto; display: flex; gap: 6px; align-items: center; }
.card-body { padding: 16px; flex: 1; }
.card-body.flush { padding: 0; }
.card-foot {
padding: 10px 16px;
border-top: 1px solid var(--border-soft);
background: var(--surface-2);
font-size: 12px; color: var(--text-2);
}
/* ==========================================================================
Buttons and inputs
========================================================================== */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 7px;
height: 34px; padding: 0 14px;
background: var(--surface-2);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 13px; font-weight: 550;
cursor: pointer;
white-space: nowrap;
transition: background .13s var(--ease), border-color .13s var(--ease), transform .08s var(--ease);
}
.btn:hover:not(:disabled) { background: var(--surface-3); border-color: var(--surface-hi); }
.btn:active:not(:disabled) { transform: scale(.98); }
.btn:disabled { opacity: .42; cursor: not-allowed; }
.btn .ico { width: 15px; height: 15px; }
.btn-primary { background: var(--accent); border-color: transparent; color: #fff; }
.btn-primary:hover:not(:disabled) { filter: brightness(1.12); background: var(--accent); }
.btn-danger { background: var(--critical); border-color: transparent; color: #fff; }
.btn-danger:hover:not(:disabled) { filter: brightness(1.12); background: var(--critical); }
.btn-warn { background: rgba(250,178,25,.16); border-color: rgba(250,178,25,.4); color: var(--warning); }
.btn-warn:hover:not(:disabled) { background: rgba(250,178,25,.24); }
.btn-ghost { background: transparent; border-color: transparent; color: var(--text-2); }
.btn-ghost:hover:not(:disabled) { background: var(--surface-2); color: var(--text); }
.btn-sm { height: 28px; padding: 0 10px; font-size: 12px; }
.btn-block { width: 100%; }
.field { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.field > label { font-size: 12px; font-weight: 550; color: var(--text-2); }
/* Not scoped to .field - hints are also used standalone beside toggles. */
.hint { font-size: 11px; color: var(--text-3); line-height: 1.45; }
.input, .select, .textarea {
height: 34px;
padding: 0 11px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
width: 100%;
transition: border-color .13s var(--ease), background .13s var(--ease);
}
.textarea { height: auto; min-height: 78px; padding: 9px 11px; resize: vertical; line-height: 1.5; }
.input:focus, .select:focus, .textarea:focus {
outline: none; border-color: var(--accent); background: var(--surface);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.input::placeholder, .textarea::placeholder { color: var(--text-3); }
.select { appearance: none; cursor: pointer; padding-right: 30px;
background-image: linear-gradient(45deg, transparent 50%, currentColor 50%), linear-gradient(135deg, currentColor 50%, transparent 50%);
background-position: right 13px center, right 8px center;
background-size: 5px 5px, 5px 5px;
background-repeat: no-repeat;
}
.input[type="color"] { padding: 3px; height: 34px; cursor: pointer; }
/* Toggle */
.switch { display: inline-flex; align-items: center; gap: 10px; cursor: pointer; user-select: none; }
.switch input { position: absolute; opacity: 0; width: 0; height: 0; }
.switch-track {
width: 38px; height: 21px; border-radius: 999px;
background: var(--surface-hi); border: 1px solid var(--border);
position: relative; flex-shrink: 0;
transition: background .16s var(--ease);
}
.switch-track::after {
content: ""; position: absolute; top: 2px; left: 2px;
width: 15px; height: 15px; border-radius: 50%;
background: var(--text-2);
transition: transform .16s var(--ease), background .16s var(--ease);
}
.switch input:checked + .switch-track { background: var(--accent); border-color: transparent; }
.switch input:checked + .switch-track::after { transform: translateX(17px); background: #fff; }
.switch input:focus-visible + .switch-track { box-shadow: 0 0 0 3px var(--accent-soft); }
.switch-label { font-size: 13px; }
/* Range */
.range { display: flex; align-items: center; gap: 12px; }
.range input[type="range"] {
flex: 1; appearance: none; height: 4px; border-radius: 2px;
background: var(--surface-hi); cursor: pointer; min-width: 60px;
}
.range input[type="range"]::-webkit-slider-thumb {
appearance: none; width: 16px; height: 16px; border-radius: 50%;
background: var(--accent); border: 2px solid var(--surface);
box-shadow: 0 1px 3px rgba(0,0,0,.4); cursor: grab;
}
.range input[type="range"]::-webkit-slider-thumb:active { cursor: grabbing; }
.range input[type="range"]::-moz-range-thumb {
width: 14px; height: 14px; border-radius: 50%;
background: var(--accent); border: 2px solid var(--surface); cursor: grab;
}
.range-value {
min-width: 62px; text-align: right;
font-variant-numeric: tabular-nums; font-size: 12px;
color: var(--text-2); font-family: var(--mono);
}
/* Segmented control */
.segmented {
display: inline-flex; padding: 3px; gap: 2px;
background: var(--surface-2); border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.segmented button {
border: 0; background: transparent; color: var(--text-2);
padding: 5px 12px; border-radius: 6px; cursor: pointer;
font-size: 12px; font-weight: 550;
transition: background .13s var(--ease), color .13s var(--ease);
}
.segmented button:hover { color: var(--text); }
.segmented button[aria-pressed="true"] { background: var(--surface-hi); color: var(--text); }
/* ==========================================================================
Stat tiles & meters
========================================================================== */
.stat {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px 15px;
display: flex; flex-direction: column; gap: 3px;
min-width: 0;
}
.stat-label {
font-size: 11px; color: var(--text-3);
text-transform: uppercase; letter-spacing: .07em; font-weight: 600;
}
.stat-value {
font-size: 25px; font-weight: 600; letter-spacing: -.02em;
line-height: 1.15; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.stat-value .unit { font-size: 13px; font-weight: 500; color: var(--text-3); margin-left: 3px; }
.stat-sub { font-size: 11.5px; color: var(--text-3); }
.stat-spark { margin-top: 6px; height: 30px; }
.stat[data-tone="good"] .stat-value { color: var(--good); }
.stat[data-tone="warning"] .stat-value { color: var(--warning); }
.stat[data-tone="critical"] .stat-value { color: var(--critical); }
.hero {
font-size: 52px; font-weight: 600; letter-spacing: -.03em; line-height: 1;
}
.hero .unit { font-size: 20px; color: var(--text-3); margin-left: 4px; font-weight: 500; }
.meter { height: 7px; border-radius: 4px; background: var(--surface-hi); overflow: hidden; }
.meter-fill { height: 100%; border-radius: 4px; background: var(--accent); transition: width .35s var(--ease), background .3s var(--ease); }
.meter-fill[data-tone="good"] { background: var(--good); }
.meter-fill[data-tone="warning"] { background: var(--warning); }
.meter-fill[data-tone="critical"] { background: var(--critical); }
/* ==========================================================================
Badges, chips, notes
========================================================================== */
.badge {
display: inline-flex; align-items: center; gap: 5px;
height: 21px; padding: 0 8px;
border-radius: 6px;
font-size: 11px; font-weight: 600;
background: var(--surface-3); color: var(--text-2);
white-space: nowrap;
}
.badge[data-tone="good"] { background: rgba(12,163,12,.15); color: var(--good); }
.badge[data-tone="warning"] { background: rgba(250,178,25,.15); color: var(--warning); }
.badge[data-tone="serious"] { background: rgba(236,131,90,.15); color: var(--serious); }
.badge[data-tone="critical"] { background: rgba(208,59,59,.16); color: var(--critical); }
.badge[data-tone="accent"] { background: var(--accent-soft); color: var(--accent); }
.note {
display: flex; gap: 10px; align-items: flex-start;
padding: 11px 13px;
border-radius: var(--radius-sm);
font-size: 12.5px; line-height: 1.5;
background: var(--surface-2);
border: 1px solid var(--border);
color: var(--text-2);
}
.note strong { color: var(--text); font-weight: 600; }
.note-icon { flex-shrink: 0; font-size: 14px; line-height: 1.3; }
.note[data-level="critical"] { background: rgba(208,59,59,.08); border-color: rgba(208,59,59,.3); }
.note[data-level="warning"] { background: rgba(250,178,25,.07); border-color: rgba(250,178,25,.28); }
.note[data-level="info"] { background: var(--accent-soft); border-color: var(--accent-line); }
.kv { display: grid; grid-template-columns: auto 1fr; gap: 7px 16px; font-size: 12.5px; align-items: baseline; }
.kv dt { color: var(--text-3); white-space: nowrap; }
.kv dd { margin: 0; text-align: right; font-variant-numeric: tabular-nums; font-family: var(--mono); font-size: 12px; word-break: break-all; }
.mono { font-family: var(--mono); font-size: 12px; }
.muted { color: var(--text-3); }
.nowrap { white-space: nowrap; }
/* ==========================================================================
Tables
========================================================================== */
.table-wrap { overflow-x: auto; }
table.data {
width: 100%; border-collapse: collapse; font-size: 12.5px;
}
table.data th {
text-align: left; font-weight: 600; font-size: 11px;
text-transform: uppercase; letter-spacing: .06em;
color: var(--text-3);
padding: 9px 14px;
border-bottom: 1px solid var(--border);
background: var(--surface-2);
position: sticky; top: 0; z-index: 1;
white-space: nowrap;
}
table.data td {
padding: 8px 14px;
border-bottom: 1px solid var(--border-soft);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
table.data tr:last-child td { border-bottom: 0; }
table.data tbody tr:hover { background: var(--surface-2); }
table.data td.num { text-align: right; font-family: var(--mono); font-size: 11.5px; }
table.data td.name { font-weight: 500; white-space: normal; }
/* ==========================================================================
Joystick
========================================================================== */
.joystick-wrap { display: flex; gap: 22px; flex-wrap: wrap; align-items: flex-start; }
.joystick {
width: 200px; height: 200px;
border-radius: 50%;
background: radial-gradient(circle at 50% 42%, var(--surface-3), var(--surface-2) 68%);
border: 1px solid var(--border);
position: relative;
touch-action: none;
cursor: grab;
flex-shrink: 0;
user-select: none;
}
.joystick:active { cursor: grabbing; }
.joystick::before, .joystick::after {
content: ""; position: absolute; background: var(--border); pointer-events: none;
}
.joystick::before { left: 12%; right: 12%; top: 50%; height: 1px; }
.joystick::after { top: 12%; bottom: 12%; left: 50%; width: 1px; }
.joystick-ring {
position: absolute; inset: 22%;
border: 1px dashed var(--border); border-radius: 50%; pointer-events: none;
}
.joystick-knob {
position: absolute; width: 56px; height: 56px;
left: 50%; top: 50%; margin: -28px 0 0 -28px;
border-radius: 50%;
background: linear-gradient(160deg, var(--accent), #1f4f8f);
box-shadow: 0 4px 14px rgba(0,0,0,.45), inset 0 1px 0 rgba(255,255,255,.24);
pointer-events: none;
transition: transform .05s linear;
}
.joystick[data-active="false"] .joystick-knob { transition: transform .22s var(--ease); }
.joystick-label {
position: absolute; font-size: 10px; color: var(--text-3);
text-transform: uppercase; letter-spacing: .08em; pointer-events: none; font-weight: 600;
}
.joystick-label.n { top: 8px; left: 50%; transform: translateX(-50%); }
.joystick-label.s { bottom: 8px; left: 50%; transform: translateX(-50%); }
.joystick-label.w { left: 8px; top: 50%; transform: translateY(-50%); }
.joystick-label.e { right: 8px; top: 50%; transform: translateY(-50%); }
.joystick-readout { display: flex; flex-direction: column; gap: 12px; min-width: 190px; flex: 1; }
/* Key hints */
.keys { display: flex; gap: 5px; flex-wrap: wrap; }
kbd {
display: inline-grid; place-items: center;
min-width: 22px; height: 22px; padding: 0 6px;
background: var(--surface-3); border: 1px solid var(--border);
border-bottom-width: 2px;
border-radius: 5px;
font-family: var(--mono); font-size: 11px; color: var(--text-2);
}
/* ==========================================================================
Preset / emoji / swatch grids
========================================================================== */
.tile-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(126px, 1fr)); gap: 8px; }
.tile {
display: flex; flex-direction: column; gap: 3px;
padding: 11px 12px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer; text-align: left;
transition: background .13s var(--ease), border-color .13s var(--ease), transform .08s var(--ease);
}
.tile:hover:not(:disabled) { background: var(--surface-3); border-color: var(--accent-line); }
.tile:active:not(:disabled) { transform: scale(.97); }
.tile:disabled { opacity: .4; cursor: not-allowed; }
.tile-name { font-size: 12.5px; font-weight: 550; }
.tile-meta { font-size: 10.5px; color: var(--text-3); font-family: var(--mono); }
.tile[aria-pressed="true"] { border-color: var(--accent); background: var(--accent-soft); }
.emoji-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); gap: 7px; }
.emoji-tile {
display: flex; flex-direction: column; align-items: center; gap: 5px;
padding: 11px 6px;
background: var(--surface-2); border: 1px solid var(--border);
border-radius: var(--radius-sm); cursor: pointer;
transition: background .13s var(--ease), border-color .13s var(--ease), transform .08s var(--ease);
}
.emoji-tile:hover { background: var(--surface-3); border-color: var(--accent-line); }
.emoji-tile:active { transform: scale(.95); }
.emoji-glyph { font-size: 24px; line-height: 1; }
.emoji-name { font-size: 10px; color: var(--text-3); text-align: center; line-height: 1.25; }
.emoji-tile[aria-pressed="true"] { border-color: var(--accent); background: var(--accent-soft); }
.swatches { display: flex; gap: 7px; flex-wrap: wrap; }
.swatch {
width: 34px; height: 34px; border-radius: 9px;
border: 1px solid var(--border);
cursor: pointer; position: relative;
transition: transform .1s var(--ease), box-shadow .13s var(--ease);
}
.swatch:hover { transform: scale(1.08); }
.swatch[aria-pressed="true"] { box-shadow: 0 0 0 2px var(--surface), 0 0 0 4px var(--accent); }
/* ==========================================================================
Charts
========================================================================== */
.chart { width: 100%; display: block; overflow: visible; }
.chart .grid-line { stroke: var(--grid); stroke-width: 1; shape-rendering: crispEdges; }
.chart .axis-line { stroke: var(--axis); stroke-width: 1; shape-rendering: crispEdges; }
.chart .tick-text { fill: var(--text-3); font-size: 10px; font-family: var(--font); font-variant-numeric: tabular-nums; }
.chart .series-line { fill: none; stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
.chart .series-area { stroke: none; opacity: .1; }
.chart .end-dot { stroke: var(--surface); stroke-width: 2; }
.chart .crosshair { stroke: var(--text-3); stroke-width: 1; stroke-dasharray: 3 3; pointer-events: none; }
.chart .end-label { fill: var(--text-2); font-size: 10.5px; font-family: var(--font); font-variant-numeric: tabular-nums; }
.legend { display: flex; gap: 14px; flex-wrap: wrap; margin-top: 8px; }
.legend-item { display: inline-flex; align-items: center; gap: 6px; font-size: 11.5px; color: var(--text-2); }
.legend-key { width: 11px; height: 2.5px; border-radius: 2px; flex-shrink: 0; }
.chart-tip {
position: fixed; z-index: 60; pointer-events: none;
background: var(--surface-3);
border: 1px solid var(--surface-hi);
border-radius: var(--radius-sm);
padding: 8px 10px;
font-size: 11.5px;
box-shadow: var(--shadow-lg);
min-width: 118px;
opacity: 0; transition: opacity .1s;
}
.chart-tip[data-show="true"] { opacity: 1; }
.chart-tip .tip-time { color: var(--text-3); font-size: 10.5px; margin-bottom: 5px; }
.chart-tip .tip-row { display: flex; align-items: center; gap: 7px; justify-content: space-between; }
.chart-tip .tip-row b { font-variant-numeric: tabular-nums; font-weight: 600; }
/* Gauge / attitude. The horizon plate is drawn oversized and clipped, so this
svg must not inherit the overflow:visible that line charts need for labels. */
.attitude { display: grid; place-items: center; }
.attitude .chart { overflow: hidden; }
/* ==========================================================================
Camera
========================================================================== */
.cam {
position: relative;
background: #000;
border-radius: var(--radius-sm);
overflow: hidden;
aspect-ratio: 4 / 3;
display: grid; place-items: center;
}
.cam img { width: 100%; height: 100%; object-fit: cover; display: block; image-rendering: auto; }
.cam-placeholder { color: var(--text-3); font-size: 12px; text-align: center; padding: 20px; line-height: 1.6; }
.cam-overlay {
position: absolute; inset: auto 0 0 0;
padding: 8px 10px;
background: linear-gradient(transparent, rgba(0,0,0,.75));
display: flex; justify-content: space-between; align-items: center;
font-size: 11px; color: #e8ecf2;
font-family: var(--mono);
}
.cam-live { display: inline-flex; align-items: center; gap: 5px; }
.cam-live::before {
content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--critical);
animation: pulse 1.6s ease-in-out infinite;
}
@keyframes pulse { 0%,100% { opacity: 1 } 50% { opacity: .3 } }
/* ==========================================================================
Console / log
========================================================================== */
.log {
font-family: var(--mono); font-size: 11.5px;
max-height: 460px; overflow-y: auto;
background: var(--bg);
}
.log-row {
display: grid; grid-template-columns: 74px 62px 100px 1fr;
gap: 10px; padding: 5px 14px;
border-bottom: 1px solid var(--border-soft);
align-items: baseline;
}
.log-row:hover { background: var(--surface-2); }
.log-time { color: var(--text-3); }
.log-level { font-weight: 700; font-size: 10px; text-transform: uppercase; letter-spacing: .05em; }
.log-level[data-level="info"] { color: var(--series-1); }
.log-level[data-level="warn"] { color: var(--warning); }
.log-level[data-level="error"] { color: var(--critical); }
.log-source { color: var(--text-3); overflow: hidden; text-overflow: ellipsis; }
.log-msg { color: var(--text-2); white-space: pre-wrap; word-break: break-word; }
/* ==========================================================================
Toasts & modal
========================================================================== */
.toasts {
position: fixed; right: 18px; bottom: 18px; z-index: 100;
display: flex; flex-direction: column-reverse; gap: 9px;
max-width: min(400px, calc(100vw - 36px));
}
.toast {
display: flex; gap: 10px; align-items: flex-start;
padding: 11px 14px;
background: var(--surface-3);
border: 1px solid var(--surface-hi);
border-left: 3px solid var(--accent);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-lg);
font-size: 12.5px;
animation: toast-in .22s var(--ease);
}
.toast[data-tone="good"] { border-left-color: var(--good); }
.toast[data-tone="warning"] { border-left-color: var(--warning); }
.toast[data-tone="critical"] { border-left-color: var(--critical); }
.toast.leaving { animation: toast-out .18s var(--ease) forwards; }
.toast-body { flex: 1; min-width: 0; }
.toast-title { font-weight: 600; margin-bottom: 2px; }
.toast-msg { color: var(--text-2); word-break: break-word; }
@keyframes toast-in { from { opacity: 0; transform: translateY(10px) scale(.97) } }
@keyframes toast-out { to { opacity: 0; transform: translateX(20px) } }
.modal-backdrop {
position: fixed; inset: 0; z-index: 200;
background: rgba(0,0,0,.6);
display: grid; place-items: center;
padding: 20px;
backdrop-filter: blur(3px);
}
.modal-backdrop[hidden] { display: none; }
.modal {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 22px;
max-width: 440px; width: 100%;
box-shadow: var(--shadow-lg);
animation: toast-in .2s var(--ease);
}
.modal h2 { font-size: 16px; margin-bottom: 8px; }
.modal p { color: var(--text-2); font-size: 13px; margin: 0 0 20px; line-height: 1.55; }
.modal-actions { display: flex; gap: 9px; justify-content: flex-end; }
/* ==========================================================================
Offline gate - covers everything while the robot is unreachable
========================================================================== */
.gate {
position: fixed; inset: 0; z-index: 300;
display: grid; place-items: center;
padding: 24px;
background: var(--bg);
animation: gate-in .25s var(--ease);
}
.gate[hidden] { display: none; }
@keyframes gate-in { from { opacity: 0 } }
.gate-card {
max-width: 520px; width: 100%;
text-align: center;
display: flex; flex-direction: column; align-items: center; gap: 16px;
}
.gate-icon { color: var(--warning); line-height: 0; }
.gate-icon svg { animation: gate-pulse 2.6s ease-in-out infinite; }
@keyframes gate-pulse { 0%, 100% { opacity: .85 } 50% { opacity: .38 } }
.gate-card h1 { font-size: 26px; letter-spacing: -.02em; }
.gate-card > p {
margin: 0; color: var(--text-2); font-size: 14px; line-height: 1.6; max-width: 44ch;
}
.gate-status {
display: inline-flex; align-items: center; gap: 10px;
padding: 9px 16px;
background: var(--surface); border: 1px solid var(--border);
border-radius: 999px;
font-size: 12.5px; color: var(--text-2);
}
.gate-spinner {
width: 13px; height: 13px; flex-shrink: 0;
border: 2px solid var(--surface-hi);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin .9s linear infinite;
}
.gate-facts {
display: grid; grid-template-columns: auto auto; gap: 6px 18px;
margin: 0; font-size: 12px;
padding: 14px 20px;
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius);
text-align: left;
}
.gate-facts dt { color: var(--text-3); }
.gate-facts dd { margin: 0; font-family: var(--mono); font-size: 11.5px; text-align: right; }
.gate-actions { display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
.gate-address {
width: 100%; max-width: 420px;
display: flex; flex-direction: column; gap: 7px;
padding: 14px 16px;
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius);
text-align: left;
}
.gate-address[hidden] { display: none; }
.gate-address > label { font-size: 12px; font-weight: 550; color: var(--text-2); }
.gate-address-row { display: flex; gap: 8px; }
.gate-address-row .input { flex: 1; min-width: 0; }
.gate-results {
width: 100%; max-width: 460px;
display: flex; flex-direction: column; gap: 7px;
text-align: left;
}
.gate-results[hidden] { display: none; }
.gate-result {
display: flex; align-items: center; gap: 10px;
padding: 10px 13px;
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 12.5px;
}
.gate-result .host { font-family: var(--mono); font-size: 12.5px; }
.gate-result .spacer { flex: 1; }
/* Needs to out-specify `.gate-card > p` above, which would otherwise make the
hint as prominent as the main message. */
.gate-card > p.gate-hint {
margin: 0; font-size: 11.5px; color: var(--text-3); max-width: 46ch; line-height: 1.6;
}
/* While the gate is up the shell behind it must not be reachable by keyboard. */
.app[inert] { filter: blur(2px); }
/* ==========================================================================
Empty / loading
========================================================================== */
.empty {
padding: 44px 20px; text-align: center; color: var(--text-3); font-size: 13px;
display: flex; flex-direction: column; align-items: center; gap: 10px;
}
.empty-title { font-size: 14px; font-weight: 600; color: var(--text-2); }
.skeleton {
background: linear-gradient(90deg, var(--surface-2) 25%, var(--surface-3) 50%, var(--surface-2) 75%);
background-size: 200% 100%;
animation: shimmer 1.3s infinite;
border-radius: var(--radius-sm);
}
@keyframes shimmer { to { background-position: -200% 0 } }
.spin { animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg) } }
/* ==========================================================================
Responsive
========================================================================== */
@media (max-width: 980px) {
:root { --rail-w: 60px; }
.rail-item span:not(.rail-badge) { display: none; }
.rail-item { justify-content: center; padding: 11px 0; }
.rail-group { display: none; }
.rail-item .rail-badge { position: absolute; top: 3px; right: 3px; margin: 0; }
.brand-text { display: none; }
}
@media (max-width: 720px) {
.app {
grid-template-columns: 1fr;
grid-template-rows: var(--topbar-h) 1fr auto;
grid-template-areas: "top" "content" "rail";
}
.rail {
flex-direction: row;
border-right: 0; border-top: 1px solid var(--border);
overflow-x: auto; overflow-y: hidden;
padding: 7px 8px;
padding-bottom: max(7px, env(safe-area-inset-bottom));
}
.rail-item { flex-direction: column; gap: 3px; min-width: 56px; font-size: 10px; padding: 7px 4px; }
.rail-item span:not(.rail-badge) { display: block; font-size: 9.5px; }
.rail-item[aria-current="page"]::before { display: none; }
.content { padding: 14px 13px 28px; }
.topbar { padding: 0 11px; gap: 10px; }
.topbar-status { gap: 5px; }
.pill[data-optional] { display: none; }
.page-head h1 { font-size: 19px; }
.toasts { right: 10px; left: 10px; bottom: 78px; max-width: none; }
.joystick { width: 172px; height: 172px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: .01ms !important;
animation-iteration-count: 1 !important;
transition-duration: .01ms !important;
scroll-behavior: auto !important;
}
}

176
x2_dashboard/web/index.html Normal file
View File

@ -0,0 +1,176 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="dark light">
<title>AGIBOT X2 · Control Dashboard</title>
<link rel="stylesheet" href="/css/app.css">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='8' fill='%233987e5'/><path d='M10 12h12v9a3 3 0 0 1-3 3h-6a3 3 0 0 1-3-3z' fill='%23fff'/><circle cx='13.5' cy='16.5' r='1.5' fill='%233987e5'/><circle cx='18.5' cy='16.5' r='1.5' fill='%233987e5'/><path d='M16 6v5' stroke='%23fff' stroke-width='2' stroke-linecap='round'/></svg>">
</head>
<body>
<svg width="0" height="0" style="position:absolute" aria-hidden="true"><defs>
<symbol id="i-overview" viewBox="0 0 24 24"><path d="M4 13h6V4H4v9Zm0 7h6v-5H4v5Zm10 0h6v-9h-6v9Zm0-16v5h6V4h-6Z"/></symbol>
<symbol id="i-control" viewBox="0 0 24 24"><path d="M12 2 9 6h6l-3-4Zm0 20 3-4H9l3 4ZM2 12l4-3v6l-4-3Zm20 0-4 3V9l4 3Zm-10-4a4 4 0 1 0 0 8 4 4 0 0 0 0-8Z"/></symbol>
<symbol id="i-motion" viewBox="0 0 24 24"><path d="M13 2a2 2 0 1 1 0 4 2 2 0 0 1 0-4ZM8.4 7.6 5 9.2a1 1 0 0 0 .8 1.8l3.6-1.6 1.5 2.8-3.2 3.1a1 1 0 0 0-.3.9l.6 4.3a1 1 0 0 0 2-.3l-.5-3.7 3.2-3 2.1 3.9a1 1 0 0 0 1.8-1l-3.1-5.7 1.1-2.9 2.2 1.5a1 1 0 0 0 1.1-1.6l-3.3-2.3a2 2 0 0 0-2.4.1L8.4 7.6Z"/></symbol>
<symbol id="i-sensors" viewBox="0 0 24 24"><path d="M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm0 2a2 2 0 1 1 0 4 2 2 0 0 1 0-4Zm-5.7-4.3a1 1 0 0 1 0 1.4 6 6 0 0 0 0 8.5 1 1 0 1 1-1.4 1.4 8 8 0 0 1 0-11.3 1 1 0 0 1 1.4 0Zm12.8 0a8 8 0 0 1 0 11.3 1 1 0 1 1-1.4-1.4 6 6 0 0 0 0-8.5 1 1 0 0 1 1.4-1.4Z"/></symbol>
<symbol id="i-vision" viewBox="0 0 24 24"><path d="M12 5c-5 0-9 4.5-10 7 1 2.5 5 7 10 7s9-4.5 10-7c-1-2.5-5-7-10-7Zm0 3a4 4 0 1 1 0 8 4 4 0 0 1 0-8Zm0 2a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z"/></symbol>
<symbol id="i-interaction" viewBox="0 0 24 24"><path d="M12 3a3 3 0 0 1 3 3v5a3 3 0 0 1-6 0V6a3 3 0 0 1 3-3Zm-6 8a1 1 0 0 1 1 1 5 5 0 0 0 10 0 1 1 0 1 1 2 0 7 7 0 0 1-6 6.9V21a1 1 0 1 1-2 0v-2.1A7 7 0 0 1 5 12a1 1 0 0 1 1-1Z"/></symbol>
<symbol id="i-power" viewBox="0 0 24 24"><path d="M4 8a2 2 0 0 1 2-2h11a2 2 0 0 1 2 2v1h1a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-1v1a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8Zm2 0v8h11V8H6Zm1.5 1.5h5v5h-5v-5Z"/></symbol>
<symbol id="i-nav" viewBox="0 0 24 24"><path d="M12 2 3 21l9-4 9 4L12 2Zm0 5.6 4.6 9.7L12 15.2l-4.6 2.1L12 7.6Z"/></symbol>
<symbol id="i-extensions" viewBox="0 0 24 24"><path d="M10 3a2 2 0 0 1 2 2v1h3a1 1 0 0 1 1 1v3h1a2 2 0 1 1 0 4h-1v3a1 1 0 0 1-1 1h-3v-1a2 2 0 1 0-4 0v1H5a1 1 0 0 1-1-1v-3H3a2 2 0 1 1 0-4h1V7a1 1 0 0 1 1-1h3V5a2 2 0 0 1 2-2Z"/></symbol>
<symbol id="i-console" viewBox="0 0 24 24"><path d="M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5Zm2 0v14h14V5H5Zm2.7 3.3 3 3a1 1 0 0 1 0 1.4l-3 3a1 1 0 1 1-1.4-1.4L8.6 12 6.3 9.7a1 1 0 0 1 1.4-1.4ZM12 14h5v2h-5v-2Z"/></symbol>
<symbol id="i-settings" viewBox="0 0 24 24"><path d="M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm0 2a2 2 0 1 1 0 4 2 2 0 0 1 0-4Zm-1.4-8h2.8l.4 2.4a8 8 0 0 1 1.7 1l2.3-.9 1.4 2.4-1.9 1.5a8 8 0 0 1 0 2l1.9 1.5-1.4 2.4-2.3-.9a8 8 0 0 1-1.7 1l-.4 2.4h-2.8l-.4-2.4a8 8 0 0 1-1.7-1l-2.3.9-1.4-2.4 1.9-1.5a8 8 0 0 1 0-2L3.2 6.9l1.4-2.4 2.3.9a8 8 0 0 1 1.7-1L10.6 2Z"/></symbol>
<symbol id="i-stop" viewBox="0 0 24 24"><path d="M8 3h8l5 5v8l-5 5H8l-5-5V8l5-5Zm1 6v6h6V9H9Z"/></symbol>
<symbol id="i-refresh" viewBox="0 0 24 24"><path d="M12 5V2L8 6l4 4V7a5 5 0 1 1-5 5H5a7 7 0 1 0 7-7Z"/></symbol>
<symbol id="i-scan" viewBox="0 0 24 24"><path d="M3 7V4a1 1 0 0 1 1-1h3v2H5v2H3Zm14-4h3a1 1 0 0 1 1 1v3h-2V5h-2V3ZM3 17h2v2h2v2H4a1 1 0 0 1-1-1v-3Zm18 0v3a1 1 0 0 1-1 1h-3v-2h2v-2h2ZM3 11h18v2H3v-2Z"/></symbol>
</defs></svg>
<div class="app" id="app">
<header class="topbar">
<div class="brand">
<div class="brand-mark" aria-hidden="true">
<span class="brand-dot"></span>
</div>
<div class="brand-text">
<strong id="brand-name">AGIBOT X2</strong>
<span id="brand-sub">Control dashboard</span>
</div>
</div>
<div class="topbar-status" id="topbar-status">
<div class="pill" id="pill-connection" title="Bridge transport">
<span class="dot" data-state="offline"></span>
<span class="pill-label">Connecting</span>
</div>
<div class="pill" id="pill-mode" title="Current motion mode">
<span class="pill-key">Mode</span>
<span class="pill-label"></span>
</div>
<div class="pill" id="pill-battery" title="Battery level">
<span class="pill-key">Batt</span>
<span class="pill-label"></span>
</div>
<div class="pill" id="pill-host" title="Robot host">
<span class="pill-key">Host</span>
<span class="pill-label"></span>
</div>
</div>
<div class="topbar-actions">
<button class="icon-btn" id="btn-theme" title="Toggle light / dark" aria-label="Toggle theme">
<svg class="ico" aria-hidden="true"><use href="#i-vision"></use></svg>
</button>
<button class="btn-stop" id="btn-stop" title="Zero velocity immediately (Space)">
<svg class="ico" aria-hidden="true"><use href="#i-stop"></use></svg>
<span>STOP</span>
</button>
</div>
</header>
<nav class="rail" id="rail" aria-label="Sections"></nav>
<main class="content" id="content" tabindex="-1"></main>
</div>
<!-- Shown whenever the robot agent is unreachable. Covers the whole app so no
control is clickable while the robot is off. -->
<div class="gate" id="gate" hidden>
<div class="gate-card">
<div class="gate-icon" aria-hidden="true">
<svg viewBox="0 0 64 64" width="72" height="72">
<circle cx="32" cy="32" r="27" fill="none" stroke="currentColor" stroke-width="3" opacity=".22"/>
<path d="M32 14v20" stroke="currentColor" stroke-width="4.5" stroke-linecap="round"/>
<path d="M45.5 20.5a19 19 0 1 1-27 0" fill="none" stroke="currentColor"
stroke-width="4.5" stroke-linecap="round"/>
</svg>
</div>
<h1 id="gate-title">Robot is powered off</h1>
<p id="gate-message">
The dashboard cannot reach the X2. Switch the robot on and this page will
connect by itself — no need to reload.
</p>
<div class="gate-status">
<span class="gate-spinner" aria-hidden="true"></span>
<span id="gate-status-text">Waiting for the robot…</span>
</div>
<dl class="gate-facts" id="gate-facts"></dl>
<div class="gate-actions">
<button class="btn btn-primary" id="gate-find">Find my robot</button>
<button class="btn" id="gate-retry">Retry now</button>
<button class="btn btn-ghost" id="gate-toggle-address">Set address manually</button>
</div>
<!-- Editing the address lives inside the gate. Sending the operator off to
the Settings tab meant leaving the very screen that is blocking the
app, and the gate reasserted itself the moment the next state arrived. -->
<div class="gate-address" id="gate-address" hidden>
<label for="gate-host">Robot address</label>
<div class="gate-address-row">
<input class="input" id="gate-host" type="text"
placeholder="e.g. 10.255.254.84" autocomplete="off" spellcheck="false">
<button class="btn btn-primary" id="gate-save">Connect</button>
</div>
<p class="gate-hint">The dashboard remembers this and reconnects automatically from now on.</p>
</div>
<div class="gate-results" id="gate-results" hidden></div>
<p class="gate-hint" id="gate-hint"></p>
</div>
</div>
<div class="toasts" id="toasts" role="status" aria-live="polite"></div>
<div class="modal-backdrop" id="modal-backdrop" hidden>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title">
<h2 id="modal-title">Confirm</h2>
<p id="modal-body"></p>
<div class="modal-actions">
<button class="btn" id="modal-cancel">Cancel</button>
<button class="btn btn-danger" id="modal-confirm">Confirm</button>
</div>
</div>
</div>
<script>
// Stamped by the server on every page load. index.html is served no-store, so
// this value is always current even when everything else came from cache.
window.__BUILD__ = "{{BUILD}}";
</script>
<script type="module" src="/js/main.js"></script>
<script>
// Safety net. If the browser somehow still ran a cached bundle, main.js will
// not have reported a matching build, and the page would fail in confusing
// ways (a tab throwing "Cannot convert undefined or null to object" because
// it is reading a shape the API no longer sends). Say so plainly instead.
setTimeout(function () {
if (window.__MAIN_BUILD__ === window.__BUILD__) return;
var bar = document.createElement('div');
bar.style.cssText =
'position:fixed;left:0;right:0;bottom:0;z-index:400;padding:14px 18px;' +
'background:#fab219;color:#1a1a19;font:500 13px system-ui,sans-serif;' +
'display:flex;gap:14px;align-items:center;justify-content:center';
bar.innerHTML =
'<span>This page is running an outdated copy of the dashboard.</span>';
var btn = document.createElement('button');
btn.textContent = 'Load the current version';
btn.style.cssText =
'padding:7px 14px;border:0;border-radius:7px;background:#1a1a19;color:#fff;' +
'font:600 13px system-ui,sans-serif;cursor:pointer';
btn.onclick = function () {
location.replace(location.pathname + '?r=' + Date.now());
};
bar.appendChild(btn);
document.body.appendChild(bar);
}, 5000);
</script>
</body>
</html>

View File

@ -0,0 +1,423 @@
/* ==========================================================================
SVG charts.
Mark specs follow the house data-viz rules: 2px lines with round joins, area
washes at 10% opacity, >=8px end markers carrying a 2px surface ring, solid
hairline gridlines, selective direct labels (endpoint only), a legend
whenever there are two or more series, and a crosshair + tooltip on hover.
Text always wears text tokens, never the series colour.
========================================================================== */
import { el, clear } from './ui.js';
import { seriesColor, cssVar } from './core.js';
const NS = 'http://www.w3.org/2000/svg';
function svgEl(tag, attrs = {}) {
const node = document.createElementNS(NS, tag);
for (const [key, value] of Object.entries(attrs)) {
if (value !== null && value !== undefined) node.setAttribute(key, value);
}
return node;
}
function niceTicks(min, max, count = 4) {
if (!Number.isFinite(min) || !Number.isFinite(max)) return [0, 1];
if (min === max) { min -= 0.5; max += 0.5; }
const span = max - min;
const raw = span / count;
const magnitude = Math.pow(10, Math.floor(Math.log10(raw)));
const normalised = raw / magnitude;
const step = (normalised >= 5 ? 10 : normalised >= 2 ? 5 : normalised >= 1 ? 2 : 1) * magnitude;
const ticks = [];
for (let t = Math.ceil(min / step) * step; t <= max + step * 0.001; t += step) {
ticks.push(Number(t.toFixed(10)));
}
return ticks.length >= 2 ? ticks : [min, max];
}
const fmt = (v, precision) => Number(v).toLocaleString(undefined, {
minimumFractionDigits: precision, maximumFractionDigits: precision,
});
/* -- Sparkline ------------------------------------------------------------ */
/**
* A bare trend line for a stat tile. No axes, no legend - the tile's label and
* value carry the meaning; this only shows shape.
*/
export function sparkline(points, { width = 200, height = 30, colorIndex = 0, fill = true } = {}) {
const svg = svgEl('svg', {
class: 'chart', viewBox: `0 0 ${width} ${height}`,
preserveAspectRatio: 'none', height,
});
svg.style.width = '100%';
const values = (points || []).map((p) => (Array.isArray(p) ? p[1] : p)).filter(Number.isFinite);
if (values.length < 2) return svg;
const min = Math.min(...values);
const max = Math.max(...values);
const span = (max - min) || 1;
const pad = 3;
const x = (i) => (i / (values.length - 1)) * width;
const y = (v) => height - pad - ((v - min) / span) * (height - pad * 2);
const line = values.map((v, i) => `${i ? 'L' : 'M'}${x(i).toFixed(2)},${y(v).toFixed(2)}`).join('');
const color = seriesColor(colorIndex);
if (fill) {
svg.appendChild(svgEl('path', {
class: 'series-area', d: `${line}L${width},${height}L0,${height}Z`, fill: color,
}));
}
svg.appendChild(svgEl('path', { class: 'series-line', d: line, stroke: color }));
svg.appendChild(svgEl('circle', {
class: 'end-dot', cx: width, cy: y(values.at(-1)), r: 2.5, fill: color,
}));
return svg;
}
/* -- Time series ---------------------------------------------------------- */
/**
* series: [{ key, label, points: [[ts, value], ...], colorIndex, unit }]
* Returns a container element with an .update(series) method.
*/
export function timeSeries(series, {
height = 200, precision = 2, unit = '', yMin = null, yMax = null,
zeroLine = false, showLegend = null, directLabel = true,
} = {}) {
const host = el('div', { style: { position: 'relative' } });
const svg = svgEl('svg', { class: 'chart', height, preserveAspectRatio: 'none' });
svg.style.width = '100%';
host.appendChild(svg);
const tip = el('div.chart-tip');
document.body.appendChild(tip);
let currentSeries = series;
let hover = null;
const cleanup = new MutationObserver(() => {
if (!host.isConnected) { tip.remove(); cleanup.disconnect(); }
});
cleanup.observe(document.body, { childList: true, subtree: true });
function render() {
const rect = host.getBoundingClientRect();
const width = Math.max(220, rect.width || 480);
const padL = 44, padR = directLabel ? 52 : 12, padT = 10, padB = 22;
const plotW = width - padL - padR;
const plotH = height - padT - padB;
clear(svg);
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
const active = currentSeries.filter((s) => (s.points || []).length >= 2);
if (!active.length) {
svg.appendChild(svgEl('text', {
x: width / 2, y: height / 2, 'text-anchor': 'middle', class: 'tick-text',
})).textContent = 'Waiting for data…';
return;
}
const allValues = active.flatMap((s) => s.points.map((p) => p[1])).filter(Number.isFinite);
const allTimes = active.flatMap((s) => s.points.map((p) => p[0]));
let lo = yMin !== null ? yMin : Math.min(...allValues);
let hi = yMax !== null ? yMax : Math.max(...allValues);
if (lo === hi) { lo -= 0.5; hi += 0.5; }
const headroom = (hi - lo) * 0.12;
if (yMin === null) lo -= headroom;
if (yMax === null) hi += headroom;
if (zeroLine) { lo = Math.min(lo, 0); hi = Math.max(hi, 0); }
const t0 = Math.min(...allTimes), t1 = Math.max(...allTimes);
const tSpan = (t1 - t0) || 1;
const X = (t) => padL + ((t - t0) / tSpan) * plotW;
const Y = (v) => padT + plotH - ((v - lo) / (hi - lo)) * plotH;
// Gridlines - solid hairlines, one step off the surface, recessive.
for (const tick of niceTicks(lo, hi, 4)) {
if (tick < lo || tick > hi) continue;
const y = Y(tick);
svg.appendChild(svgEl('line', { class: 'grid-line', x1: padL, x2: padL + plotW, y1: y, y2: y }));
const label = svgEl('text', { class: 'tick-text', x: padL - 7, y: y + 3.5, 'text-anchor': 'end' });
label.textContent = fmt(tick, precision);
svg.appendChild(label);
}
if (zeroLine && lo < 0 && hi > 0) {
svg.appendChild(svgEl('line', { class: 'axis-line', x1: padL, x2: padL + plotW, y1: Y(0), y2: Y(0) }));
}
svg.appendChild(svgEl('line', {
class: 'axis-line', x1: padL, x2: padL + plotW, y1: padT + plotH, y2: padT + plotH,
}));
const surface = cssVar('--surface') || '#141619';
// When series converge their end-labels overlap. Nudging them apart would
// detach each label from its line and read as noise, so drop direct labels
// for this render and let the legend and tooltip carry identity instead.
const endYs = active.map((s) => Y(s.points.at(-1)[1])).sort((a, b) => a - b);
const labelsCollide = endYs.some((y, i) => i > 0 && Math.abs(y - endYs[i - 1]) < 13);
const showEndLabels = directLabel && !labelsCollide;
active.forEach((s, i) => {
const color = seriesColor(s.colorIndex ?? i);
const path = s.points
.map((p, idx) => `${idx ? 'L' : 'M'}${X(p[0]).toFixed(2)},${Y(p[1]).toFixed(2)}`)
.join('');
if (active.length === 1) {
svg.appendChild(svgEl('path', {
class: 'series-area', fill: color,
d: `${path}L${X(t1)},${padT + plotH}L${X(t0)},${padT + plotH}Z`,
}));
}
svg.appendChild(svgEl('path', { class: 'series-line', d: path, stroke: color }));
// End marker: >=8px with a 2px surface ring so overlaps stay legible.
const last = s.points.at(-1);
svg.appendChild(svgEl('circle', {
class: 'end-dot', cx: X(last[0]), cy: Y(last[1]), r: 4, fill: color, stroke: surface,
}));
// Direct-label the endpoint only - never a number on every point.
if (showEndLabels) {
const label = svgEl('text', {
class: 'end-label', x: X(last[0]) + 9, y: Y(last[1]) + 3.5,
});
label.textContent = `${fmt(last[1], precision)}${s.unit ?? unit}`;
svg.appendChild(label);
}
});
if (hover !== null) {
const x = padL + hover * plotW;
svg.appendChild(svgEl('line', { class: 'crosshair', x1: x, x2: x, y1: padT, y2: padT + plotH }));
}
// Hover layer sits above everything and is the full plot height, so the hit
// target is far bigger than the marks.
const overlay = svgEl('rect', {
x: padL, y: padT, width: plotW, height: plotH, fill: 'transparent', style: 'cursor:crosshair',
});
overlay.addEventListener('pointermove', (event) => {
const bounds = svg.getBoundingClientRect();
const scale = width / bounds.width;
const px = (event.clientX - bounds.left) * scale;
hover = Math.max(0, Math.min(1, (px - padL) / plotW));
const t = t0 + hover * tSpan;
const rows = active.map((s, i) => {
let best = s.points[0], bestGap = Infinity;
for (const p of s.points) {
const gap = Math.abs(p[0] - t);
if (gap < bestGap) { bestGap = gap; best = p; }
}
return { label: s.label, value: best[1], unit: s.unit ?? unit, color: seriesColor(s.colorIndex ?? i) };
});
tip.innerHTML = '';
tip.appendChild(el('div.tip-time', { text: new Date(t * 1000).toLocaleTimeString([], { hour12: false }) }));
for (const row of rows) {
tip.appendChild(el('div.tip-row', {},
el('span', { style: { display: 'inline-flex', alignItems: 'center', gap: '6px' } },
el('span', { style: { width: '10px', height: '2.5px', borderRadius: '2px', background: row.color } }),
el('span', { text: row.label, style: { color: 'var(--text-2)' } }),
),
el('b', { text: `${fmt(row.value, precision)}${row.unit}` }),
));
}
tip.dataset.show = 'true';
const tipBox = tip.getBoundingClientRect();
tip.style.left = `${Math.min(window.innerWidth - tipBox.width - 10, event.clientX + 14)}px`;
tip.style.top = `${Math.max(10, event.clientY - tipBox.height - 12)}px`;
render();
});
overlay.addEventListener('pointerleave', () => {
hover = null; tip.dataset.show = 'false'; render();
});
svg.appendChild(overlay);
}
// A legend is always present for two or more series - identity never rests on
// colour alone. One series needs none; the card title already names it.
const legendVisible = showLegend ?? (series.length >= 2);
if (legendVisible) {
const legend = el('div.legend');
series.forEach((s, i) => {
legend.appendChild(el('div.legend-item', {},
el('span.legend-key', { style: { background: seriesColor(s.colorIndex ?? i) } }),
el('span', { text: s.label }),
));
});
host.appendChild(legend);
}
host.update = (next) => { currentSeries = next; render(); };
host.redraw = render;
requestAnimationFrame(render);
const observer = new ResizeObserver(() => render());
observer.observe(host);
return host;
}
/* -- Horizontal bars ------------------------------------------------------ */
/**
* rows: [{ label, value, min, max, tone }]
* Used for joint positions - a diverging bar around a zero centre reads better
* than a table of numbers when you are looking for the joint that is off.
*/
export function bars(rows, { height = 20, precision = 2, unit = '', diverging = true } = {}) {
const host = el('div', { style: { display: 'flex', flexDirection: 'column', gap: '4px' } });
for (const row of rows) {
const min = row.min ?? -1;
const max = row.max ?? 1;
const span = (max - min) || 1;
const value = Math.max(min, Math.min(max, row.value ?? 0));
const zeroFrac = diverging && min < 0 && max > 0 ? (0 - min) / span : 0;
const valueFrac = (value - min) / span;
const left = Math.min(zeroFrac, valueFrac) * 100;
const width = Math.abs(valueFrac - zeroFrac) * 100;
const track = el('div', {
style: {
position: 'relative', height: `${height - 8}px`, borderRadius: '3px',
background: 'var(--surface-3)', flex: '1', minWidth: '60px', overflow: 'hidden',
},
},
// 4px rounded data-end, square where it meets the baseline.
el('div', {
style: {
position: 'absolute', left: `${left}%`, width: `${Math.max(width, 0.6)}%`,
top: '0', bottom: '0',
background: row.tone === 'critical' ? 'var(--critical)'
: row.tone === 'warning' ? 'var(--warning)' : seriesColor(row.colorIndex ?? 0),
borderRadius: valueFrac >= zeroFrac ? '0 4px 4px 0' : '4px 0 0 4px',
},
}),
zeroFrac > 0 ? el('div', {
style: {
position: 'absolute', left: `${zeroFrac * 100}%`, top: '0', bottom: '0',
width: '1px', background: 'var(--axis)',
},
}) : null,
);
host.appendChild(el('div', { style: { display: 'flex', alignItems: 'center', gap: '10px' } },
el('span', {
text: row.label,
style: { fontSize: '11.5px', color: 'var(--text-2)', minWidth: '118px',
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' },
}),
track,
el('span', {
text: `${Number(row.value ?? 0).toFixed(precision)}${unit}`,
style: { fontSize: '11px', fontFamily: 'var(--mono)', color: 'var(--text-2)',
minWidth: '58px', textAlign: 'right', fontVariantNumeric: 'tabular-nums' },
}),
));
}
return host;
}
/* -- Attitude indicator --------------------------------------------------- */
/** Artificial horizon from IMU roll and pitch - the one place a dial beats a number. */
export function attitude(roll = 0, pitch = 0, { size = 150 } = {}) {
const svg = svgEl('svg', { class: 'chart', viewBox: '0 0 200 200', width: size, height: size });
const clipId = `att-clip-${Math.random().toString(36).slice(2, 8)}`;
const defs = svgEl('defs');
const clip = svgEl('clipPath', { id: clipId });
clip.appendChild(svgEl('circle', { cx: 100, cy: 100, r: 84 }));
defs.appendChild(clip);
svg.appendChild(defs);
const group = svgEl('g', { 'clip-path': `url(#${clipId})` });
const pitchPx = Math.max(-70, Math.min(70, pitch * (180 / Math.PI) * 2));
const inner = svgEl('g', {
transform: `rotate(${-roll * (180 / Math.PI)} 100 100) translate(0 ${pitchPx})`,
});
inner.appendChild(svgEl('rect', { x: -60, y: -80, width: 320, height: 180, fill: 'var(--surface-3)' }));
inner.appendChild(svgEl('rect', { x: -60, y: 100, width: 320, height: 200, fill: 'var(--surface-hi)' }));
inner.appendChild(svgEl('line', { x1: -60, x2: 260, y1: 100, y2: 100,
stroke: seriesColor(0), 'stroke-width': 2 }));
for (const offset of [-40, -20, 20, 40]) {
const wide = Math.abs(offset) === 40;
inner.appendChild(svgEl('line', {
x1: 100 - (wide ? 26 : 16), x2: 100 + (wide ? 26 : 16),
y1: 100 + offset, y2: 100 + offset,
stroke: 'var(--text-3)', 'stroke-width': 1,
}));
}
group.appendChild(inner);
svg.appendChild(group);
svg.appendChild(svgEl('circle', { cx: 100, cy: 100, r: 84, fill: 'none',
stroke: 'var(--border)', 'stroke-width': 1.5 }));
// Fixed aircraft reference.
svg.appendChild(svgEl('path', {
d: 'M62 100 h22 l8 8 l8 -8 h22', fill: 'none',
stroke: 'var(--text)', 'stroke-width': 2.5, 'stroke-linejoin': 'round', 'stroke-linecap': 'round',
}));
svg.appendChild(svgEl('path', {
d: 'M100 16 l-7 12 h14 z', fill: 'var(--text-2)',
}));
return svg;
}
/* -- Compass / heading ---------------------------------------------------- */
export function compass(yaw = 0, { size = 150 } = {}) {
const svg = svgEl('svg', { class: 'chart', viewBox: '0 0 200 200', width: size, height: size });
svg.appendChild(svgEl('circle', { cx: 100, cy: 100, r: 84, fill: 'var(--surface-2)',
stroke: 'var(--border)', 'stroke-width': 1.5 }));
const dial = svgEl('g', { transform: `rotate(${-yaw * (180 / Math.PI)} 100 100)` });
for (let deg = 0; deg < 360; deg += 15) {
const major = deg % 45 === 0;
const rad = (deg - 90) * Math.PI / 180;
const r1 = major ? 66 : 74;
dial.appendChild(svgEl('line', {
x1: 100 + Math.cos(rad) * r1, y1: 100 + Math.sin(rad) * r1,
x2: 100 + Math.cos(rad) * 80, y2: 100 + Math.sin(rad) * 80,
stroke: major ? 'var(--text-3)' : 'var(--border)', 'stroke-width': major ? 1.5 : 1,
}));
}
for (const [label, deg] of [['N', 0], ['E', 90], ['S', 180], ['W', 270]]) {
const rad = (deg - 90) * Math.PI / 180;
const text = svgEl('text', {
x: 100 + Math.cos(rad) * 52, y: 100 + Math.sin(rad) * 52 + 4,
'text-anchor': 'middle', class: 'tick-text',
style: 'font-size:11px;font-weight:600',
});
text.textContent = label;
dial.appendChild(text);
}
svg.appendChild(dial);
svg.appendChild(svgEl('path', {
d: 'M100 34 L110 100 L100 92 L90 100 Z', fill: seriesColor(0),
}));
svg.appendChild(svgEl('circle', { cx: 100, cy: 100, r: 4, fill: 'var(--text-2)' }));
const heading = svgEl('text', {
x: 100, y: 132, 'text-anchor': 'middle', class: 'tick-text',
style: 'font-size:15px;font-weight:600;fill:var(--text)',
});
heading.textContent = `${(((yaw * 180 / Math.PI) % 360 + 360) % 360).toFixed(0)}°`;
svg.appendChild(heading);
return svg;
}

349
x2_dashboard/web/js/core.js Normal file
View File

@ -0,0 +1,349 @@
/* ==========================================================================
Core: store, transport, notifications, formatting.
Every URL here is derived from window.location, so the dashboard works from
whatever address you happened to open it on - laptop, phone, tablet, or a
hostname. Nothing is hardcoded.
========================================================================== */
export const API = `${location.origin}`;
export const WS_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`;
/* -- Store ---------------------------------------------------------------- */
class Store {
constructor() {
this.state = null; // live robot state
this.spec = null; // constants from the backend
this.settings = null;
this.plugins = { plugins: [], errors: [] };
this.bridge = null;
this.network = null;
this.server = null;
this.events = [];
this.connected = false;
this._subs = new Map();
this._nextId = 1;
}
on(channel, fn) {
if (!this._subs.has(channel)) this._subs.set(channel, new Map());
const id = this._nextId++;
this._subs.get(channel).set(id, fn);
return () => this._subs.get(channel)?.delete(id);
}
emit(channel, payload) {
const subs = this._subs.get(channel);
if (!subs) return;
for (const fn of subs.values()) {
try { fn(payload); } catch (err) { console.error(`[${channel}]`, err); }
}
}
}
export const store = new Store();
/* -- HTTP ----------------------------------------------------------------- */
export async function api(path, options = {}) {
const { method = 'GET', body, timeout = 15000 } = options;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
try {
const res = await fetch(`${API}${path}`, {
method,
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal,
});
const text = await res.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch { data = { detail: text }; }
if (!res.ok) {
const message = data?.detail || data?.message || `${res.status} ${res.statusText}`;
const error = new Error(typeof message === 'string' ? message : JSON.stringify(message));
error.status = res.status;
error.data = data;
throw error;
}
return data;
} catch (err) {
if (err.name === 'AbortError') throw new Error(`Request timed out after ${timeout / 1000}s`);
throw err;
} finally {
clearTimeout(timer);
}
}
export const get = (path) => api(path);
export const post = (path, body) => api(path, { method: 'POST', body: body ?? {} });
/**
* POST that reports its own outcome as a toast. Returns the payload on success
* and null on failure, so callers can `if (!await command(...)) return;`.
*/
export async function command(path, body, { silent = false, successTitle } = {}) {
try {
const data = await post(path, body);
if (!silent) toast(successTitle || 'Done', data?.message || '', 'good');
return data;
} catch (err) {
if (!silent) toast('Command failed', err.message, 'critical');
return null;
}
}
/* -- WebSocket ------------------------------------------------------------ */
class Socket {
constructor() {
this.ws = null;
this.attempts = 0;
this.closing = false;
this._pending = null;
this._pendingTimer = null;
}
connect() {
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) return;
this.closing = false;
try {
this.ws = new WebSocket(WS_URL);
} catch (err) {
this._retry();
return;
}
this.ws.onopen = () => {
this.attempts = 0;
store.connected = true;
store.emit('link', { connected: true });
};
this.ws.onmessage = (event) => {
let message;
try { message = JSON.parse(event.data); } catch { return; }
this._route(message);
};
this.ws.onclose = () => {
store.connected = false;
store.emit('link', { connected: false });
if (!this.closing) this._retry();
};
this.ws.onerror = () => { /* onclose handles recovery */ };
}
_route(message) {
const { type, data } = message;
switch (type) {
case 'state':
store.state = data;
store.emit('state', data);
break;
case 'event':
store.events.push(data);
if (store.events.length > 500) store.events.splice(0, store.events.length - 500);
store.emit('event', data);
if (data.level === 'error') toast('Robot', data.message, 'critical');
else if (data.level === 'warn') toast('Robot', data.message, 'warning');
break;
case 'plugins':
store.plugins = data;
store.emit('plugins', data);
break;
case 'settings':
store.settings = data;
store.emit('settings', data);
break;
case 'scan_progress':
store.emit('scan_progress', data);
break;
case 'command_error':
store.emit('command_error', data);
break;
default:
store.emit(type, data);
}
}
_retry() {
// Back off to 8 s so a robot that is off overnight does not spam the network.
const delay = Math.min(8000, 500 * Math.pow(1.6, this.attempts++));
setTimeout(() => this.connect(), delay);
}
send(type, data) {
if (this.ws?.readyState !== WebSocket.OPEN) return false;
this.ws.send(JSON.stringify({ type, data }));
return true;
}
/**
* Rate-limited send for the joystick. Coalesces to ~25 Hz so dragging never
* floods the socket, but always delivers the final position.
*/
sendThrottled(type, data, interval = 40) {
this._pending = { type, data };
if (this._pendingTimer) return;
const flush = () => {
if (!this._pending) { this._pendingTimer = null; return; }
const { type: t, data: d } = this._pending;
this._pending = null;
this.send(t, d);
this._pendingTimer = setTimeout(flush, interval);
};
flush();
}
}
export const socket = new Socket();
/* -- Toasts --------------------------------------------------------------- */
const toastHost = () => document.getElementById('toasts');
export function toast(title, message = '', tone = 'default', ttl = 4200) {
const host = toastHost();
if (!host) return;
const node = document.createElement('div');
node.className = 'toast';
node.dataset.tone = tone;
node.innerHTML = `
<div class="toast-body">
<div class="toast-title"></div>
${message ? '<div class="toast-msg"></div>' : ''}
</div>`;
node.querySelector('.toast-title').textContent = title;
if (message) node.querySelector('.toast-msg').textContent = message;
host.appendChild(node);
while (host.children.length > 5) host.firstElementChild.remove();
setTimeout(() => {
node.classList.add('leaving');
setTimeout(() => node.remove(), 200);
}, ttl);
}
/* -- Confirm modal -------------------------------------------------------- */
export function confirmDialog(title, body, { confirmLabel = 'Confirm', danger = true } = {}) {
return new Promise((resolve) => {
const backdrop = document.getElementById('modal-backdrop');
const confirmBtn = document.getElementById('modal-confirm');
const cancelBtn = document.getElementById('modal-cancel');
document.getElementById('modal-title').textContent = title;
document.getElementById('modal-body').textContent = body;
confirmBtn.textContent = confirmLabel;
confirmBtn.className = danger ? 'btn btn-danger' : 'btn btn-primary';
backdrop.hidden = false;
confirmBtn.focus();
const finish = (value) => {
backdrop.hidden = true;
confirmBtn.removeEventListener('click', onYes);
cancelBtn.removeEventListener('click', onNo);
backdrop.removeEventListener('click', onBackdrop);
document.removeEventListener('keydown', onKey);
resolve(value);
};
const onYes = () => finish(true);
const onNo = () => finish(false);
const onBackdrop = (e) => { if (e.target === backdrop) finish(false); };
const onKey = (e) => { if (e.key === 'Escape') finish(false); };
confirmBtn.addEventListener('click', onYes);
cancelBtn.addEventListener('click', onNo);
backdrop.addEventListener('click', onBackdrop);
document.addEventListener('keydown', onKey);
});
}
/* -- Formatting ----------------------------------------------------------- */
export const RAD2DEG = 180 / Math.PI;
export const DEG2RAD = Math.PI / 180;
export function num(value, precision = 2, fallback = '—') {
if (value === null || value === undefined || Number.isNaN(Number(value))) return fallback;
return Number(value).toFixed(precision);
}
export function compact(value) {
if (value === null || value === undefined) return '—';
const abs = Math.abs(value);
if (abs >= 1e9) return `${(value / 1e9).toFixed(1)}B`;
if (abs >= 1e6) return `${(value / 1e6).toFixed(1)}M`;
if (abs >= 1e4) return `${(value / 1e3).toFixed(1)}K`;
return value.toLocaleString(undefined, { maximumFractionDigits: 0 });
}
export function clockTime(ts) {
const d = new Date(ts * 1000);
return d.toLocaleTimeString([], { hour12: false });
}
export function duration(seconds) {
if (!seconds || seconds < 0) return '—';
const s = Math.floor(seconds % 60);
const m = Math.floor((seconds / 60) % 60);
const h = Math.floor(seconds / 3600);
if (h) return `${h}h ${m}m`;
if (m) return `${m}m ${s}s`;
return `${s}s`;
}
export function batteryTone(pct) {
if (pct === null || pct === undefined) return 'default';
if (pct <= 10) return 'critical';
if (pct <= 25) return 'warning';
return 'good';
}
export function tempTone(celsius, warn = 55, crit = 70) {
if (celsius === null || celsius === undefined) return 'default';
if (celsius >= crit) return 'critical';
if (celsius >= warn) return 'warning';
return 'good';
}
export function throttle(fn, ms) {
let last = 0, timer = null, queued = null;
return (...args) => {
const now = Date.now();
queued = args;
if (now - last >= ms) { last = now; fn(...queued); queued = null; return; }
if (timer) return;
timer = setTimeout(() => {
timer = null; last = Date.now();
if (queued) { fn(...queued); queued = null; }
}, ms - (now - last));
};
}
export function debounce(fn, ms) {
let timer = null;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
};
}
/* Series colours, read from CSS so the theme toggle stays authoritative. */
export function seriesColor(index) {
const styles = getComputedStyle(document.documentElement);
return styles.getPropertyValue(`--series-${(index % 8) + 1}`).trim() || '#3987e5';
}
export function cssVar(name) {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}

538
x2_dashboard/web/js/main.js Normal file
View File

@ -0,0 +1,538 @@
/* ==========================================================================
Application shell: bootstrap, rail, routing, topbar, global shortcuts.
========================================================================== */
import { store, socket, get, post, toast, num, batteryTone, confirmDialog } from './core.js';
import { el, clear, icon, emptyState, button, badge } from './ui.js';
import overview from './tabs/overview.js';
import control from './tabs/control.js';
import motion from './tabs/motion.js';
import sensors from './tabs/sensors.js';
import vision from './tabs/vision.js';
import lidar from './tabs/lidar.js';
import model from './tabs/model.js';
import interaction from './tabs/interaction.js';
import power from './tabs/power.js';
import navigation from './tabs/navigation.js';
import extensions from './tabs/extensions.js';
import consoleTab from './tabs/console.js';
import settings from './tabs/settings.js';
const TABS = [
{ ...overview, group: 'Monitor' },
{ ...power, group: 'Monitor' },
{ ...sensors, group: 'Monitor' },
{ ...vision, group: 'Monitor' },
{ ...lidar, group: 'Monitor' },
{ ...model, group: 'Monitor' },
{ ...control, group: 'Operate' },
{ ...motion, group: 'Operate' },
{ ...interaction, group: 'Operate' },
{ ...navigation, group: 'Operate' },
{ ...extensions, group: 'System' },
{ ...consoleTab, group: 'System' },
{ ...settings, group: 'System' },
];
// Report which build this bundle came from. index.html (always served fresh)
// compares it against the server's build and warns if they differ, which is the
// only reliable way to notice that a browser served a stale cached bundle.
try {
window.__MAIN_BUILD__ = new URL(import.meta.url).searchParams.get('b') || 'dev';
} catch { /* non-critical */ }
const content = document.getElementById('content');
const rail = document.getElementById('rail');
let activeId = null;
let activeDispose = null;
/* -- Routing -------------------------------------------------------------- */
function buildRail() {
clear(rail);
let lastGroup = null;
for (const tab of TABS) {
if (tab.group !== lastGroup) {
rail.appendChild(el('div.rail-group', { text: tab.group }));
lastGroup = tab.group;
}
const item = el('button.rail-item', {
type: 'button',
dataset: { tab: tab.id },
'aria-current': tab.id === activeId ? 'page' : 'false',
onclick: () => navigate(tab.id),
},
icon(tab.icon),
el('span', { text: tab.label }),
);
rail.appendChild(item);
}
}
function markActive() {
for (const item of rail.querySelectorAll('.rail-item')) {
item.setAttribute('aria-current', item.dataset.tab === activeId ? 'page' : 'false');
}
}
export function setRailBadge(tabId, value, tone = 'default') {
const item = rail.querySelector(`.rail-item[data-tab="${tabId}"]`);
if (!item) return;
let badge = item.querySelector('.rail-badge');
if (!value) { badge?.remove(); return; }
if (!badge) {
badge = el('span.rail-badge');
item.appendChild(badge);
}
badge.textContent = String(value);
badge.dataset.tone = tone;
}
async function navigate(id, { replace = false } = {}) {
const tab = TABS.find((t) => t.id === id) || TABS[0];
if (activeDispose) { try { activeDispose(); } catch (err) { console.error(err); } }
activeDispose = null;
activeId = tab.id;
markActive();
if (replace) history.replaceState({ tab: tab.id }, '', `#${tab.id}`);
else if (location.hash.slice(1) !== tab.id) history.pushState({ tab: tab.id }, '', `#${tab.id}`);
clear(content);
content.scrollTop = 0;
try {
const result = await tab.render();
if (result && result.node) {
content.appendChild(result.node);
activeDispose = result.dispose || null;
} else if (result instanceof Node) {
content.appendChild(result);
}
} catch (err) {
console.error(`Tab "${tab.id}" failed:`, err);
const stale = window.__MAIN_BUILD__ !== window.__BUILD__;
content.appendChild(emptyState(
'This section failed to render',
stale
? `${err?.message || err}\n\nThis page is running an outdated copy of the dashboard, `
+ 'which is very likely the cause. Load the current version and try again.'
: String(err?.message || err),
el('div.row', {},
button('Retry', () => navigate(tab.id, { replace: true }), { style: 'primary' }),
stale
? button('Load the current version',
() => location.replace(`${location.pathname}?r=${Date.now()}`))
: null,
),
));
}
}
window.addEventListener('popstate', () => {
const id = location.hash.slice(1);
if (id && id !== activeId) navigate(id, { replace: true });
});
/* -- Topbar --------------------------------------------------------------- */
const pills = {
connection: document.getElementById('pill-connection'),
mode: document.getElementById('pill-mode'),
battery: document.getElementById('pill-battery'),
host: document.getElementById('pill-host'),
};
function setPill(node, label, { state, level } = {}) {
node.querySelector('.pill-label').textContent = label;
if (state) node.querySelector('.dot')?.setAttribute('data-state', state);
if (level) node.dataset.level = level; else delete node.dataset.level;
}
function updateTopbar() {
const state = store.state;
const bridge = store.bridge;
if (!store.connected) {
setPill(pills.connection, 'Reconnecting…', { state: 'offline' });
} else if (bridge?.simulated) {
setPill(pills.connection, 'Simulation', { state: 'sim' });
pills.connection.title = 'No robot attached - the dashboard is running against the built-in simulator.';
} else if (state?.connection?.online) {
setPill(pills.connection, 'Live', { state: 'online' });
pills.connection.title = `Connected to the robot agent at ${state.connection.host}`;
} else {
setPill(pills.connection, 'Robot off', { state: 'offline' });
pills.connection.title = state?.connection?.error || 'The robot agent is not reachable.';
}
const modeSpec = store.spec?.modes?.find((m) => m.id === state?.mode);
setPill(pills.mode, modeSpec?.label || state?.mode || '—', {
level: modeSpec?.danger ? 'critical' : undefined,
});
const pct = state?.battery_pct;
const tone = batteryTone(pct);
setPill(pills.battery, pct === null || pct === undefined ? '—' : `${num(pct, 0)}%`, {
level: tone === 'good' ? undefined : tone,
});
const host = state?.connection?.host || store.settings?.values?.robot_host;
setPill(pills.host, host || 'not set');
pills.host.dataset.optional = '';
}
/* -- Offline gate ---------------------------------------------------------
The dashboard server runs on this machine, not on the robot, so the page
stays up when the X2 is switched off. That is the whole point of the gate:
instead of a dead link, the operator gets "power the robot on", and the
dashboard reattaches by itself the moment the agent answers again.
-------------------------------------------------------------------------- */
const gate = {
root: document.getElementById('gate'),
title: document.getElementById('gate-title'),
message: document.getElementById('gate-message'),
status: document.getElementById('gate-status-text'),
facts: document.getElementById('gate-facts'),
hint: document.getElementById('gate-hint'),
address: document.getElementById('gate-address'),
hostInput: document.getElementById('gate-host'),
results: document.getElementById('gate-results'),
visible: false,
since: 0,
poll: null,
busy: false,
};
function gateReason() {
// Simulation is a deliberate choice, not an outage - never gate on it.
if (store.bridge?.simulated) return null;
if (!store.connected) return 'server';
if (!store.state?.connection?.online) return 'robot';
return null;
}
function showGate(reason) {
const host = store.settings?.values?.robot_host || '—';
const port = store.settings?.values?.agent_port || 8781;
if (reason === 'server') {
gate.title.textContent = 'Dashboard server not responding';
gate.message.textContent =
'The page loaded but lost its connection to the dashboard server. '
+ 'It will reconnect automatically once the server is running again.';
gate.hint.textContent = 'This is the server on this computer, not the robot.';
} else {
gate.title.textContent = 'Robot is powered off';
gate.message.textContent =
'The dashboard cannot reach the X2. Switch the robot on and this page will '
+ 'connect by itself — no need to reload.';
gate.hint.textContent =
'If the robot is already on, check that it is on this Wi-Fi network and that '
+ 'the dashboard agent is running on it.';
}
const error = store.state?.connection?.error || store.bridge?.error || '';
clear(gate.facts);
const facts = [
['Robot address', host === '—' ? 'not set' : `${host}:${port}`],
['Last seen', gate.since ? new Date(gate.since).toLocaleTimeString([], { hour12: false }) : 'not yet this session'],
];
if (error) facts.push(['Detail', error]);
for (const [key, value] of facts) {
gate.facts.appendChild(el('dt', { text: key }));
gate.facts.appendChild(el('dd', { text: value }));
}
if (!gate.visible) {
gate.visible = true;
gate.root.hidden = false;
document.getElementById('app').setAttribute('inert', '');
if (!gate.hostInput.value) {
gate.hostInput.value = store.settings?.values?.robot_host || '';
}
if (!gate.poll) gate.poll = setInterval(pollRobot, 2500);
}
}
function hideGate() {
if (!gate.visible) return;
gate.visible = false;
gate.root.hidden = true;
document.getElementById('app').removeAttribute('inert');
if (gate.poll) { clearInterval(gate.poll); gate.poll = null; }
toast('Robot connected', 'Live telemetry is flowing again.', 'good');
// Re-render the current tab so it rebuilds against real data.
if (activeId) navigate(activeId, { replace: true });
}
async function pollRobot() {
// Anything the operator kicked off owns the status line until it finishes.
if (gate.busy) return;
try {
const status = await get('/api/robot/status');
if (status.online) {
gate.status.textContent = 'Robot answered — reconnecting…';
store.state = store.state || {};
store.state.connection = { ...(store.state.connection || {}), online: true };
updateGate();
return;
}
// The bridge reports what its automatic recovery is doing; showing that is
// far more use than a fixed "waiting" message.
gate.status.textContent = status.recovery
|| `Waiting for the robot at ${status.host || 'no address set'}`;
if (!status.has_ssh_password && status.auto_start_agent) {
gate.hint.textContent =
'Tip: save the robot\'s SSH password in Settings and the dashboard can start the '
+ 'agent for you after a power cycle, instead of waiting for the robot to do it.';
}
} catch {
gate.status.textContent = 'Waiting for the dashboard server…';
}
}
function updateGate() {
const reason = gateReason();
if (reason) showGate(reason);
else {
if (store.state?.connection?.online) gate.since = Date.now();
hideGate();
}
}
document.getElementById('gate-retry').addEventListener('click', async (event) => {
const button = event.currentTarget;
button.disabled = true;
gate.busy = true;
gate.status.textContent = 'Reconnecting…';
try {
await post('/api/bridge/restart');
gate.busy = false;
await pollRobot();
} catch (err) {
gate.status.textContent = err.message;
} finally {
gate.busy = false;
button.disabled = false;
}
});
document.getElementById('gate-toggle-address').addEventListener('click', (event) => {
const shown = !gate.address.hidden;
gate.address.hidden = shown;
event.currentTarget.textContent = shown ? 'Set address manually' : 'Hide address box';
if (!shown) gate.hostInput.focus();
});
async function saveGateHost(host) {
if (!host) return;
gate.busy = true;
gate.status.textContent = `Connecting to ${host}`;
try {
await post('/api/settings', { robot_host: host });
await post('/api/bridge/restart');
// Give the bridge a moment to dial before we report anything.
await new Promise((resolve) => setTimeout(resolve, 2500));
gate.busy = false;
await pollRobot();
} catch (err) {
gate.status.textContent = `Could not connect: ${err.message}`;
} finally {
gate.busy = false;
}
}
document.getElementById('gate-save').addEventListener('click',
() => saveGateHost(gate.hostInput.value.trim()));
gate.hostInput.addEventListener('keydown', (event) => {
if (event.key === 'Enter') saveGateHost(gate.hostInput.value.trim());
});
document.getElementById('gate-find').addEventListener('click', async (event) => {
const button = event.currentTarget;
button.disabled = true;
gate.busy = true;
gate.results.hidden = true;
gate.status.textContent = 'Scanning this network for the robot…';
try {
const data = await post('/api/robot/find');
const found = data.candidates || [];
clear(gate.results);
gate.results.hidden = !found.length;
if (!found.length) {
gate.status.textContent =
'Nothing found. Is the robot powered on and joined to this Wi-Fi?';
return;
}
gate.status.textContent = `Found ${found.length} device${found.length > 1 ? 's' : ''}.`;
for (const item of found) {
const ready = item.agent;
gate.results.appendChild(el('div.gate-result', {},
el('span.host', { text: item.host }),
item.hostname ? el('span', { text: item.hostname, style: { color: 'var(--text-3)' } }) : null,
badge(ready ? 'agent running' : 'reachable, agent off', ready ? 'good' : 'warning'),
el('span.spacer'),
button_(ready ? 'Connect' : 'Start & connect', async (btn) => {
btn.disabled = true;
gate.busy = true;
try {
if (!ready) {
gate.status.textContent = `Starting the agent on ${item.host}`;
const woke = await post('/api/robot/wake', { host: item.host });
if (!woke.ok) {
gate.status.textContent = woke.message;
return;
}
}
await saveGateHost(item.host);
} finally {
gate.busy = false;
btn.disabled = false;
}
}),
));
}
} catch (err) {
gate.status.textContent = `Scan failed: ${err.message}`;
} finally {
gate.busy = false;
button.disabled = false;
}
});
/* Small local button helper - the gate is built before the UI module's styles
are relevant, and it only needs the one variant. */
function button_(label, onClick) {
const node = el('button.btn.btn-sm', { type: 'button', text: label });
node.addEventListener('click', () => onClick(node));
return node;
}
/* -- Theme ---------------------------------------------------------------- */
function initTheme() {
const saved = localStorage.getItem('x2-theme');
if (saved) document.documentElement.dataset.theme = saved;
document.getElementById('btn-theme').addEventListener('click', () => {
const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
document.documentElement.dataset.theme = next;
localStorage.setItem('x2-theme', next);
store.emit('theme', next);
// Charts read their colours from CSS variables, so a re-render picks up the
// new palette rather than keeping stale hexes.
if (activeId) navigate(activeId, { replace: true });
});
}
/* -- Global controls ------------------------------------------------------ */
function initStop() {
const stop = async () => {
socket.send('stop', {});
try {
await post('/api/stop');
toast('Stopped', 'Velocity zeroed', 'warning');
} catch (err) {
toast('Stop failed', err.message, 'critical');
}
};
document.getElementById('btn-stop').addEventListener('click', stop);
document.addEventListener('keydown', (event) => {
const typing = ['INPUT', 'TEXTAREA', 'SELECT'].includes(event.target.tagName);
if (event.code === 'Space' && !typing) {
event.preventDefault();
stop();
return;
}
if (typing || event.metaKey || event.ctrlKey || event.altKey) return;
// Number keys jump between sections.
const index = Number(event.key) - 1;
if (Number.isInteger(index) && index >= 0 && index < TABS.length) {
navigate(TABS[index].id);
}
});
}
/* -- Boot ----------------------------------------------------------------- */
async function boot() {
initTheme();
initStop();
try {
const data = await get('/api/bootstrap');
store.spec = data.spec;
store.settings = data.settings;
store.state = data.state;
store.plugins = data.plugins;
store.bridge = data.bridge;
store.network = data.network;
store.server = data.server;
store.events = data.events || [];
} catch (err) {
clear(content);
content.appendChild(emptyState(
'Cannot reach the dashboard server',
`${err.message}\n\nThe page loaded but /api/bootstrap did not answer. Check that the server process is still running, then reload.`,
button('Reload', () => location.reload(), { style: 'primary' }),
));
return;
}
const name = store.settings?.values?.robot_label || 'AGIBOT X2';
document.getElementById('brand-name').textContent = name;
document.getElementById('brand-sub').textContent =
store.bridge?.simulated ? 'Simulation mode' : 'Live control';
buildRail();
updateTopbar();
updateGate();
store.on('state', () => { updateTopbar(); updateGate(); });
store.on('link', () => { updateTopbar(); updateGate(); });
store.on('settings', (data) => {
store.settings = data;
document.getElementById('brand-name').textContent = data.values.robot_label || 'AGIBOT X2';
updateTopbar();
});
store.on('plugins', (data) => {
store.plugins = data;
setRailBadge('extensions', data.errors?.length || 0, 'bad');
});
store.on('command_error', (data) => toast('Blocked', data.message, 'warning'));
setRailBadge('extensions', store.plugins?.errors?.length || 0, 'bad');
socket.connect();
const initial = location.hash.slice(1);
await navigate(TABS.some((t) => t.id === initial) ? initial : TABS[0].id, { replace: true });
if (store.bridge?.simulated) {
toast(
'Simulation mode',
'No robot address is configured, so the dashboard is driving a simulated X2. '
+ 'Set the address in Settings to attach to the real one.',
'warning', 9000,
);
}
}
boot();
export { navigate, TABS };

View File

@ -0,0 +1,372 @@
/* A small WebGL renderer for the X2's URDF model.
Deliberately hand-rolled rather than pulled from a 3D library. What this
needs is narrow - one shader, flat shading, a rigid-body tree of 41 links -
and the alternative is several hundred kilobytes fetched over the robot's
own Wi-Fi, from a CDN the robot cannot reach.
Geometry arrives as uint16 positions quantised inside each mesh's bounding
box (see build_model.py); the shader expands them back to metres, so the
4x saving over float32 costs two extra instructions per vertex.
Normals are computed per-face in the fragment shader from screen-space
derivatives. That means the vertex buffer carries positions and nothing
else - no normal buffer, no smoothing groups, no index-order assumptions -
and flat shading is the honest look for a machined-metal robot anyway.
*/
const VERTEX_SHADER = `
attribute vec3 a_quantised;
uniform mat4 u_viewProjection;
uniform mat4 u_model;
uniform vec3 u_lo;
uniform vec3 u_span;
varying vec3 v_world;
void main() {
vec3 local = u_lo + (a_quantised / 65535.0) * u_span;
vec4 world = u_model * vec4(local, 1.0);
v_world = world.xyz;
gl_Position = u_viewProjection * world;
}`;
const FRAGMENT_SHADER = `
precision mediump float;
varying vec3 v_world;
uniform vec3 u_colour;
uniform float u_alpha;
void main() {
// Face normal straight from the derivative of world position across the
// triangle. No normal attribute needed, and it cannot disagree with the
// geometry the way a stale baked normal can.
vec3 normal = normalize(cross(dFdx(v_world), dFdy(v_world)));
vec3 keyDir = normalize(vec3(0.45, 0.7, 0.85));
vec3 fillDir = normalize(vec3(-0.6, -0.3, 0.4));
float key = max(dot(normal, keyDir), 0.0);
float fill = max(dot(normal, fillDir), 0.0) * 0.35;
// A little rim light so the silhouette stays readable against a dark panel.
float rim = pow(1.0 - abs(normal.z), 2.0) * 0.18;
vec3 shaded = u_colour * (0.30 + 0.72 * key + fill) + rim;
gl_FragColor = vec4(shaded, u_alpha);
}`;
/* -- Small matrix helpers (column-major, as WebGL expects) ----------------- */
export function identity() {
return new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
}
export function multiply(a, b) {
const out = new Float32Array(16);
for (let col = 0; col < 4; col += 1) {
for (let row = 0; row < 4; row += 1) {
out[col * 4 + row] =
a[row] * b[col * 4] +
a[4 + row] * b[col * 4 + 1] +
a[8 + row] * b[col * 4 + 2] +
a[12 + row] * b[col * 4 + 3];
}
}
return out;
}
/** URDF fixed-axis roll-pitch-yaw: R = Rz(yaw) * Ry(pitch) * Rx(roll). */
export function fromOrigin(xyz, rpy) {
const [x, y, z] = xyz;
const [roll, pitch, yaw] = rpy;
const cr = Math.cos(roll), sr = Math.sin(roll);
const cp = Math.cos(pitch), sp = Math.sin(pitch);
const cy = Math.cos(yaw), sy = Math.sin(yaw);
return new Float32Array([
cy * cp, sy * cp, -sp, 0,
cy * sp * sr - sy * cr, sy * sp * sr + cy * cr, cp * sr, 0,
cy * sp * cr + sy * sr, sy * sp * cr - cy * sr, cp * cr, 0,
x, y, z, 1,
]);
}
/** Rotation of `angle` about an arbitrary unit axis (Rodrigues). */
export function fromAxisAngle(axis, angle) {
let [x, y, z] = axis;
const length = Math.hypot(x, y, z) || 1;
x /= length; y /= length; z /= length;
const c = Math.cos(angle), s = Math.sin(angle), t = 1 - c;
return new Float32Array([
t * x * x + c, t * x * y + s * z, t * x * z - s * y, 0,
t * x * y - s * z, t * y * y + c, t * y * z + s * x, 0,
t * x * z + s * y, t * y * z - s * x, t * z * z + c, 0,
0, 0, 0, 1,
]);
}
export function perspective(fovY, aspect, near, far) {
const f = 1 / Math.tan(fovY / 2);
return new Float32Array([
f / aspect, 0, 0, 0,
0, f, 0, 0,
0, 0, (far + near) / (near - far), -1,
0, 0, (2 * far * near) / (near - far), 0,
]);
}
export function lookAt(eye, target, up) {
const zx = eye[0] - target[0], zy = eye[1] - target[1], zz = eye[2] - target[2];
const zl = Math.hypot(zx, zy, zz) || 1;
const z = [zx / zl, zy / zl, zz / zl];
const xx = up[1] * z[2] - up[2] * z[1];
const xy = up[2] * z[0] - up[0] * z[2];
const xz = up[0] * z[1] - up[1] * z[0];
const xl = Math.hypot(xx, xy, xz) || 1;
const x = [xx / xl, xy / xl, xz / xl];
const y = [
z[1] * x[2] - z[2] * x[1],
z[2] * x[0] - z[0] * x[2],
z[0] * x[1] - z[1] * x[0],
];
return new Float32Array([
x[0], y[0], z[0], 0,
x[1], y[1], z[1], 0,
x[2], y[2], z[2], 0,
-(x[0] * eye[0] + x[1] * eye[1] + x[2] * eye[2]),
-(y[0] * eye[0] + y[1] * eye[1] + y[2] * eye[2]),
-(z[0] * eye[0] + z[1] * eye[1] + z[2] * eye[2]),
1,
]);
}
/* -- Renderer -------------------------------------------------------------- */
export class RobotModel {
constructor(canvas) {
this.canvas = canvas;
this.gl = canvas.getContext('webgl', {
antialias: true, alpha: false, depth: true, preserveDrawingBuffer: false,
});
if (!this.gl) throw new Error('This browser has no WebGL support.');
// Flat shading needs dFdx/dFdy, which is an extension in WebGL 1. Without
// it the shader still links on most drivers but every face comes out
// uniformly lit, so say so rather than render something misleading.
this.derivatives = this.gl.getExtension('OES_standard_derivatives');
this.model = null;
this.parts = [];
this.jointAngles = new Map();
this.jointsByChild = new Map();
this.linkParent = new Map();
this.colour = [0.62, 0.66, 0.72];
this.accent = [0.22, 0.53, 0.90];
this.highlight = new Set();
// link name -> [r, g, b]. Anything not listed falls back to this.colour, so
// a link whose joint reports nothing stays neutral grey rather than
// rendering as "zero load", which would read as a measurement.
this.linkColours = new Map();
this._buildProgram();
}
_buildProgram() {
const gl = this.gl;
const prefix = this.derivatives ? '#extension GL_OES_standard_derivatives : enable\n' : '';
const compile = (type, source) => {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
throw new Error(`Shader failed to compile: ${gl.getShaderInfoLog(shader)}`);
}
return shader;
};
const program = gl.createProgram();
gl.attachShader(program, compile(gl.VERTEX_SHADER, VERTEX_SHADER));
gl.attachShader(program, compile(gl.FRAGMENT_SHADER, prefix + FRAGMENT_SHADER));
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
throw new Error(`Shader failed to link: ${gl.getProgramInfoLog(program)}`);
}
this.program = program;
this.attribs = { quantised: gl.getAttribLocation(program, 'a_quantised') };
this.uniforms = {
viewProjection: gl.getUniformLocation(program, 'u_viewProjection'),
model: gl.getUniformLocation(program, 'u_model'),
lo: gl.getUniformLocation(program, 'u_lo'),
span: gl.getUniformLocation(program, 'u_span'),
colour: gl.getUniformLocation(program, 'u_colour'),
alpha: gl.getUniformLocation(program, 'u_alpha'),
};
}
/** Upload the baked model. `geometry` is the raw model.bin ArrayBuffer. */
load(model, geometry) {
const gl = this.gl;
this.model = model;
this.parts = [];
for (const joint of model.joints) {
this.jointsByChild.set(joint.child, joint);
this.linkParent.set(joint.child, joint.parent);
if (joint.type === 'revolute') this.jointAngles.set(joint.name, 0);
}
for (const [linkName, link] of Object.entries(model.links)) {
for (const mesh of link.meshes) {
const vertexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
gl.bufferData(gl.ARRAY_BUFFER,
new Uint16Array(geometry, mesh.vertex_offset, mesh.vertex_count * 3),
gl.STATIC_DRAW);
const indexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
const indices = mesh.index_bits === 16
? new Uint16Array(geometry, mesh.index_offset, mesh.index_count)
: new Uint32Array(geometry, mesh.index_offset, mesh.index_count);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW);
this.parts.push({
link: linkName,
vertexBuffer,
indexBuffer,
count: mesh.index_count,
type: mesh.index_bits === 16 ? gl.UNSIGNED_SHORT : gl.UNSIGNED_INT,
lo: mesh.lo,
span: mesh.span,
local: fromOrigin(mesh.origin.xyz, mesh.origin.rpy),
});
}
}
// 32-bit indices are an extension in WebGL 1. Every mesh here is under
// 65535 vertices so it should never come up, but a silent wrong-geometry
// render is worse than a clear failure.
if (this.parts.some((part) => part.type === gl.UNSIGNED_INT)
&& !gl.getExtension('OES_element_index_uint')) {
throw new Error('This browser cannot draw 32-bit indices (OES_element_index_uint).');
}
}
setJoint(name, radians) {
if (this.jointAngles.has(name)) this.jointAngles.set(name, radians);
}
setJoints(values) {
for (const [name, radians] of Object.entries(values)) this.setJoint(name, radians);
}
/** World transform for a link, walking up to the root through its joints. */
worldTransform(linkName) {
const chain = [];
let current = linkName;
// Guarded against a malformed tree: a cycle would otherwise hang the frame.
for (let depth = 0; current && depth < 64; depth += 1) {
const joint = this.jointsByChild.get(current);
if (!joint) break;
chain.push(joint);
current = joint.parent;
}
let matrix = identity();
for (const joint of chain) {
let local = fromOrigin(joint.origin.xyz, joint.origin.rpy);
if (joint.type === 'revolute') {
const angle = this.jointAngles.get(joint.name) || 0;
local = multiply(local, fromAxisAngle(joint.axis, angle));
}
matrix = multiply(local, matrix);
}
return matrix;
}
resize() {
const gl = this.gl;
// Cap the backing store at 2x CSS pixels: past that the extra fragments
// cost real milliseconds on a phone and buy nothing visible.
const ratio = Math.min(window.devicePixelRatio || 1, 2);
const width = Math.round(this.canvas.clientWidth * ratio);
const height = Math.round(this.canvas.clientHeight * ratio);
if (width && height && (this.canvas.width !== width || this.canvas.height !== height)) {
this.canvas.width = width;
this.canvas.height = height;
}
gl.viewport(0, 0, this.canvas.width, this.canvas.height);
}
render(camera, background = [0.09, 0.10, 0.12]) {
const gl = this.gl;
this.resize();
gl.clearColor(background[0], background[1], background[2], 1);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
if (!this.model) return;
gl.enable(gl.DEPTH_TEST);
// Backface culling stays off: vertex clustering does not preserve winding
// reliably, so culling by winding punches holes in the model. Depth testing
// alone is correct here; the cost is shading some faces we then overwrite.
gl.disable(gl.CULL_FACE);
const aspect = this.canvas.width / Math.max(1, this.canvas.height);
const eye = [
camera.target[0] + camera.distance * Math.cos(camera.pitch) * Math.cos(camera.yaw),
camera.target[1] + camera.distance * Math.cos(camera.pitch) * Math.sin(camera.yaw),
camera.target[2] + camera.distance * Math.sin(camera.pitch),
];
const viewProjection = multiply(
perspective(camera.fov ?? 0.8, aspect, 0.05, 60),
// Z is up in URDF, so the camera's up vector is Z, not Y.
lookAt(eye, camera.target, [0, 0, 1]),
);
gl.useProgram(this.program);
gl.uniformMatrix4fv(this.uniforms.viewProjection, false, viewProjection);
gl.enableVertexAttribArray(this.attribs.quantised);
const worldCache = new Map();
for (const part of this.parts) {
let world = worldCache.get(part.link);
if (!world) {
world = this.worldTransform(part.link);
worldCache.set(part.link, world);
}
gl.uniformMatrix4fv(this.uniforms.model, false, multiply(world, part.local));
gl.uniform3fv(this.uniforms.lo, part.lo);
gl.uniform3fv(this.uniforms.span, part.span);
const tinted = this.linkColours.get(part.link);
const colour = tinted || (this.highlight.has(part.link) ? this.accent : this.colour);
gl.uniform3fv(this.uniforms.colour, colour);
gl.uniform1f(this.uniforms.alpha, 1.0);
gl.bindBuffer(gl.ARRAY_BUFFER, part.vertexBuffer);
// normalized=false: the shader divides by 65535 itself, because
// normalising here would also apply to the index-style values and makes
// the dequantisation harder to follow.
gl.vertexAttribPointer(this.attribs.quantised, 3, gl.UNSIGNED_SHORT, false, 0, 0);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, part.indexBuffer);
gl.drawElements(gl.TRIANGLES, part.count, part.type, 0);
}
}
dispose() {
const gl = this.gl;
for (const part of this.parts) {
gl.deleteBuffer(part.vertexBuffer);
gl.deleteBuffer(part.indexBuffer);
}
this.parts = [];
gl.deleteProgram(this.program);
// Free the drawing buffer immediately rather than waiting for GC. Browsers
// cap the number of live WebGL contexts (often 16), and navigating between
// tabs repeatedly would otherwise start losing them.
gl.getExtension('WEBGL_lose_context')?.loseContext();
}
}

View File

@ -0,0 +1,238 @@
/* Console - event log, raw ROS access, discovered graph. */
import { store, get, post, toast, clockTime, num } from '../core.js';
import {
el, card, pageHead, button, note, badge, table, input, textarea, field,
kv, segmented, emptyState, setChildren,
} from '../ui.js';
export default {
id: 'console',
label: 'Console',
icon: 'console',
async render() {
const root = el('div.stack');
root.appendChild(pageHead(
'Console',
'Everything the bridge has logged, plus direct access to publish a topic or call a service.',
));
/* ==================================================================
Event log
================================================================== */
let levelFilter = 'all';
let autoScroll = true;
const logHost = el('div.log');
function paintLog() {
const events = store.events.filter(
(e) => levelFilter === 'all' || e.level === levelFilter,
);
if (!events.length) {
logHost.replaceChildren(emptyState('Nothing logged yet'));
return;
}
const wasAtBottom = logHost.scrollHeight - logHost.scrollTop - logHost.clientHeight < 40;
logHost.replaceChildren(...events.slice(-400).map((event) => el('div.log-row', {},
el('span.log-time', { text: clockTime(event.ts) }),
el('span.log-level', { text: event.level, dataset: { level: event.level } }),
el('span.log-source', { text: event.source, title: event.source }),
el('span.log-msg', { text: event.message }),
)));
if (autoScroll && wasAtBottom) logHost.scrollTop = logHost.scrollHeight;
}
const levelTabs = segmented(
[
{ value: 'all', label: 'All' },
{ value: 'info', label: 'Info' },
{ value: 'warn', label: 'Warnings' },
{ value: 'error', label: 'Errors' },
],
levelFilter,
(value) => { levelFilter = value; paintLog(); },
);
root.appendChild(card('Event log', {
sub: 'Bridge, control and extension events',
actions: [
levelTabs,
button('Clear view', () => { store.events.length = 0; paintLog(); }, { size: 'sm', style: 'ghost' }),
],
flush: true,
}, logHost));
/* ==================================================================
Raw publish / service call
================================================================== */
const pubTopic = input({ placeholder: '/aima/hal/joint/head/command' });
const pubType = input({ placeholder: 'aimdk_msgs/msg/JointCommandArray' });
const pubFields = textarea({ placeholder: '{\n "field": 1.0\n}', rows: 5 });
const publishBtn = button('Publish', async (node) => {
let fields;
try {
fields = pubFields.value.trim() ? JSON.parse(pubFields.value) : {};
} catch (err) {
toast('Invalid JSON', err.message, 'critical');
return;
}
node.disabled = true;
try {
const data = await post('/api/raw/publish', {
topic: pubTopic.value.trim(),
type: pubType.value.trim(),
fields,
});
toast('Published', data.message, data.ok ? 'good' : 'critical');
} catch (err) {
toast('Publish failed', err.message, 'critical');
} finally {
node.disabled = false;
}
}, { style: 'primary' });
const agentHost = el('div');
root.appendChild(el('div.grid.cols-2', {},
card('Publish a topic', { sub: 'Raw escape hatch' },
el('div.stack', {},
field('Topic', pubTopic),
field('Message type', pubType, 'Package/kind/Name, e.g. aimdk_msgs/msg/McLocomotionVelocity'),
field('Fields (JSON)', pubFields,
'Only top-level fields the message actually defines are set; the rest are ignored.'),
publishBtn,
note('This bypasses every guard the Control tab applies. Know what the message does '
+ 'before you send it.', 'warning', '⚠'),
),
),
card('Robot agent', { sub: 'The process bridging ROS 2 to this dashboard' }, agentHost),
));
/* ==================================================================
Discovered graph
================================================================== */
const graphHost = el('div');
let graphFilter = '';
const graphSearch = input({ placeholder: 'Filter topics and services…' });
graphSearch.addEventListener('input', () => {
graphFilter = graphSearch.value.toLowerCase();
paintGraph();
});
let graph = { topics: [], services: [] };
async function refreshGraph() {
try {
// Enumerating the graph is a round trip to the robot, so it is opt-in
// rather than part of the routine topic-stats poll.
const data = await get('/api/topics?graph=true');
graph = data.graph || { topics: [], services: [], nodes: [] };
paintGraph();
} catch { /* best-effort */ }
}
function paintAgent() {
const agent = store.bridge?.agent || store.state?.custom?.agent || null;
const connection = store.state?.connection || {};
setChildren(agentHost,
el('div.row', { style: { marginBottom: '12px' } },
badge(connection.online ? 'connected' : 'not connected',
connection.online ? 'good' : 'critical'),
store.bridge?.simulated ? badge('simulation', 'warning') : null,
),
kv([
['Address', connection.host || store.bridge?.host || '—'],
['Agent version', agent?.agent_version || '—'],
['Robot hostname', agent?.hostname || '—'],
['ROS domain', agent?.ros_domain_id ?? '—'],
['Link uptime', connection.uptime_s ? `${Math.round(connection.uptime_s)} s` : '—'],
['Cameras', (agent?.cameras || []).length || '—'],
]),
connection.error
? el('div', { style: { marginTop: '10px' } }, note(connection.error, 'critical', '⚠'))
: null,
);
}
function paintGraph() {
const match = (entry) => !graphFilter || entry.name.toLowerCase().includes(graphFilter);
const topics = (graph.topics || []).filter(match);
const services = (graph.services || []).filter(match);
if (!graph.topics?.length && !graph.services?.length) {
graphHost.replaceChildren(emptyState(
'No ROS graph available',
store.bridge?.simulated
? 'The simulator has no ROS graph. Connect to a real robot to enumerate topics and services.'
: 'The bridge has not enumerated the graph yet — it refreshes every 5 seconds.',
));
return;
}
graphHost.replaceChildren(
el('div.grid.cols-2', {},
el('div', {},
el('div.row', { style: { marginBottom: '8px' } },
el('strong', { text: 'Topics', style: { fontSize: '12px' } }),
badge(String(topics.length)),
),
table([
{ key: 'name', label: 'Name' },
{ key: 'types', label: 'Type', get: (r) => (r.types || []).join(', ') },
], topics.slice(0, 300), { empty: 'No matching topics' }),
),
el('div', {},
el('div.row', { style: { marginBottom: '8px' } },
el('strong', { text: 'Services', style: { fontSize: '12px' } }),
badge(String(services.length)),
),
table([
{ key: 'name', label: 'Name' },
{ key: 'types', label: 'Type', get: (r) => (r.types || []).join(', ') },
], services.slice(0, 300), { empty: 'No matching services' }),
),
),
);
}
root.appendChild(card('ROS graph', {
sub: 'What the robot is advertising right now',
actions: [
graphSearch,
button('Refresh', () => refreshGraph(), { size: 'sm', style: 'ghost', iconName: 'refresh' }),
],
}, graphHost));
graphSearch.style.maxWidth = '240px';
/* -- Wiring ----------------------------------------------------------- */
paintLog();
paintAgent();
refreshGraph();
const unsubscribe = store.on('event', () => paintLog());
let lastAgent = 0;
const unsubState = store.on('state', () => {
const now = Date.now();
if (now - lastAgent < 2000) return;
lastAgent = now;
paintAgent();
});
const timer = setInterval(refreshGraph, 15000);
return {
node: root,
dispose: () => { unsubscribe(); unsubState(); clearInterval(timer); },
};
},
};

View File

@ -0,0 +1,438 @@
/* Control - mode switching, arbitration, driving, preset motions. */
import { store, socket, post, command, toast, num, confirmDialog } from '../core.js';
import {
el, card, pageHead, button, note, badge, table, range, keyHint, kv, setChildren,
} from '../ui.js';
export default {
id: 'control',
label: 'Control',
icon: 'control',
async render() {
const spec = store.spec;
const root = el('div.stack');
root.appendChild(pageHead(
'Control',
'Motion mode, drive input and preset motions. Keep the physical remote controller within reach — it outranks this dashboard.',
));
/* ==================================================================
Motion mode
================================================================== */
const modeTiles = el('div.stack');
const modeButtons = new Map();
for (const group of spec?.mode_groups || []) {
const inGroup = (spec?.modes || []).filter((m) => m.group === group.key);
if (!inGroup.length) continue;
const grid = el('div.tile-grid');
for (const mode of inGroup) {
const tile = el('button.tile', {
type: 'button',
'aria-pressed': 'false',
title: mode.desc,
onclick: () => setMode(mode),
},
el('div.row.between', {},
el('span.tile-name', { text: mode.label }),
mode.danger ? badge('risk', 'critical') : null,
),
el('span.tile-meta', { text: `${mode.id} · ${mode.value}` }),
el('span', {
text: mode.desc,
style: { fontSize: '11px', color: 'var(--text-3)', lineHeight: '1.4' },
}),
);
modeButtons.set(mode.id, tile);
grid.appendChild(tile);
}
modeTiles.appendChild(el('div', {},
el('div', {
text: group.label,
style: { fontSize: '11px', textTransform: 'uppercase', letterSpacing: '.07em',
color: 'var(--text-3)', fontWeight: '600', margin: '2px 0 8px' },
}),
grid,
));
}
async function setMode(mode) {
if (mode.danger) {
const releases = ['PASSIVE_DEFAULT', 'ZERO_TORQUE_DEFAULT'].includes(mode.id);
const ok = await confirmDialog(
`Switch to ${mode.label}?`,
releases
? 'This removes all joint holding force. A free-standing robot will collapse. '
+ 'Only continue if the robot is supported or already seated.'
: `${mode.desc} Make sure the area around the robot is clear before continuing.`,
{ confirmLabel: `Yes, switch to ${mode.label}` },
);
if (!ok) return;
}
await command('/api/mode', { mode: mode.id, confirmed: true },
{ successTitle: `Mode: ${mode.label}` });
}
const modeCard = card('Motion mode', {
sub: `SetMcAction · ${(spec?.modes || []).length} modes`,
},
modeTiles,
el('div', { style: { marginTop: '12px' } },
note('Stable stand is the prerequisite for preset motions and for walking. '
+ 'Joint control mode is required before direct joint commands take effect.', 'info'),
),
);
/* ==================================================================
Input source arbitration
================================================================== */
const dash = spec?.dashboard_input_source || { name: 'x2_dashboard', priority: 30, timeout: 1000 };
const sourceStatus = el('div');
const registerBtn = button('Announce the dashboard again', async () => {
await command('/api/input-source', dash, { successTitle: 'Dashboard announced' });
}, { size: 'sm' });
const arbitrationRows = [
...(spec?.builtin_input_sources || []),
{ name: dash.name, priority: dash.priority, timeout: dash.timeout, desc: 'This dashboard' },
].sort((a, b) => b.priority - a.priority);
const sourceCard = card('Who is allowed to drive', { sub: 'Control arbitration' },
el('div.stack', {},
sourceStatus,
el('p', {
style: { margin: '0', fontSize: '13px', color: 'var(--text-2)', lineHeight: '1.6' },
text: 'Several things can send movement commands to this robot at once — the handheld '
+ 'remote, the mobile app, VR teleoperation, and this dashboard. The robot listens '
+ 'to whichever one is actively sending and has the highest priority.',
}),
el('p', {
style: { margin: '0', fontSize: '13px', color: 'var(--text-2)', lineHeight: '1.6' },
text: 'The robot ignores commands from a sender it has never heard of, so the dashboard '
+ 'introduces itself automatically as soon as it connects. That is all "announcing" '
+ 'means — it does not take control away from anyone. You only need the button below '
+ 'if the robot restarts and forgets.',
}),
table(
[
{ key: 'name', label: 'Sender' },
{ key: 'priority', label: 'Priority', align: 'right' },
{ key: 'desc', label: 'What it is' },
],
arbitrationRows,
),
note(`This dashboard sits at priority ${dash.priority} on purpose — below the handheld `
+ 'remote (80) and the mobile app (60). If someone picks up the remote while you are '
+ 'driving, the remote wins immediately.', 'info'),
el('div.row', {}, registerBtn),
),
);
root.appendChild(el('div.grid.cols-2', {}, modeCard, sourceCard));
/* ==================================================================
Drive
================================================================== */
const limits = spec?.velocity_limits || {
forward: { max: 0.8 }, lateral: { max: 0.7 }, angular: { max: 0.8 },
};
const thresholds = spec?.velocity_thresholds || {};
let scale = 0.5;
let vector = { forward: 0, lateral: 0, angular: 0 };
let strafeMode = false;
const knob = el('div.joystick-knob');
const pad = el('div.joystick', { dataset: { active: 'false' } },
el('div.joystick-ring'),
el('span.joystick-label.n', { text: 'fwd' }),
el('span.joystick-label.s', { text: 'back' }),
el('span.joystick-label.w', { text: 'left' }),
el('span.joystick-label.e', { text: 'right' }),
knob,
);
const readoutHost = el('div');
const strafeToggle = el('div.segmented');
for (const [label, value] of [['Turn', false], ['Strafe', true]]) {
const btn = el('button', { type: 'button', text: label, 'aria-pressed': String(value === strafeMode) });
btn.addEventListener('click', () => {
strafeMode = value;
for (const b of strafeToggle.children) b.setAttribute('aria-pressed', String(b === btn));
setVector(0, 0);
});
strafeToggle.appendChild(btn);
}
const scaleRange = range({
min: 0.1, max: 1, step: 0.05, value: scale, precision: 2,
onInput: (v) => { scale = v; },
});
function setVector(nx, ny) {
// nx / ny are -1..1 in pad space; y is inverted so up is forward.
const forward = -ny * (limits.forward.max ?? 0.8) * scale;
const secondary = nx * ((strafeMode ? limits.lateral.max : limits.angular.max) ?? 0.7) * scale;
vector = {
forward: Number(forward.toFixed(3)),
lateral: strafeMode ? Number(secondary.toFixed(3)) : 0,
// A joystick pushed right should turn right, i.e. clockwise, which is a
// negative yaw rate under the documented counter-clockwise-positive sign.
angular: strafeMode ? 0 : Number((-secondary).toFixed(3)),
};
knob.style.transform = `translate(${nx * 62}px, ${ny * 62}px)`;
socket.sendThrottled('velocity', vector);
paintReadout();
}
function paintReadout() {
const s = store.state;
const measured = s?.velocity || {};
const belowThreshold = (axis, value) => {
const limit = thresholds[axis];
return limit !== undefined && Math.abs(value) > 0 && Math.abs(value) < limit;
};
readoutHost.replaceChildren(
kv([
['Forward cmd', `${num(vector.forward, 2)} m/s`],
['Lateral cmd', `${num(vector.lateral, 2)} m/s`],
['Yaw cmd', `${num(vector.angular, 2)} rad/s`],
['Forward now', `${num(measured.forward, 2)} m/s`],
['Yaw now', `${num(measured.angular, 2)} rad/s`],
]),
);
const warnings = [];
if (belowThreshold('forward', vector.forward)) {
warnings.push(`Forward command is below the ${thresholds.forward} m/s activation threshold — the robot will not start moving.`);
}
if (belowThreshold('angular', vector.angular)) {
warnings.push(`Yaw command is below the ${thresholds.angular} rad/s activation threshold.`);
}
if (belowThreshold('lateral', vector.lateral)) {
warnings.push(`Lateral command is below the ${thresholds.lateral} m/s activation threshold.`);
}
for (const text of warnings) readoutHost.appendChild(note(text, 'warning', '⚠'));
}
/* Pointer drive */
let pointerId = null;
function padPoint(event) {
const rect = pad.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
const radius = rect.width / 2 - 28;
let dx = (event.clientX - cx) / radius;
let dy = (event.clientY - cy) / radius;
const magnitude = Math.hypot(dx, dy);
if (magnitude > 1) { dx /= magnitude; dy /= magnitude; }
return [dx, dy];
}
pad.addEventListener('pointerdown', (event) => {
pointerId = event.pointerId;
pad.setPointerCapture(pointerId);
pad.dataset.active = 'true';
setVector(...padPoint(event));
});
pad.addEventListener('pointermove', (event) => {
if (event.pointerId !== pointerId) return;
setVector(...padPoint(event));
});
const releasePad = (event) => {
if (event.pointerId !== pointerId) return;
pointerId = null;
pad.dataset.active = 'false';
setVector(0, 0);
};
pad.addEventListener('pointerup', releasePad);
pad.addEventListener('pointercancel', releasePad);
/* Keyboard drive */
const held = new Set();
const KEYS = {
KeyW: 'up', ArrowUp: 'up',
KeyS: 'down', ArrowDown: 'down',
KeyA: 'left', ArrowLeft: 'left',
KeyD: 'right', ArrowRight: 'right',
};
function applyKeys() {
const nx = (held.has('right') ? 1 : 0) - (held.has('left') ? 1 : 0);
const ny = (held.has('down') ? 1 : 0) - (held.has('up') ? 1 : 0);
setVector(nx, ny);
}
const onKeyDown = (event) => {
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(event.target.tagName)) return;
const dir = KEYS[event.code];
if (!dir) return;
event.preventDefault();
if (held.has(dir)) return;
held.add(dir);
applyKeys();
};
const onKeyUp = (event) => {
const dir = KEYS[event.code];
if (!dir) return;
held.delete(dir);
applyKeys();
};
// A lost window must not leave the robot walking.
const onBlur = () => { held.clear(); setVector(0, 0); };
document.addEventListener('keydown', onKeyDown);
document.addEventListener('keyup', onKeyUp);
window.addEventListener('blur', onBlur);
const driveCard = card('Drive', { sub: 'McLocomotionVelocity · /aima/mc/locomotion/velocity' },
el('div.joystick-wrap', {},
pad,
el('div.joystick-readout', {},
el('div.row.between', {},
el('span', { text: 'Horizontal axis', style: { fontSize: '12px', color: 'var(--text-2)' } }),
strafeToggle,
),
el('div', {},
el('div', { text: 'Speed scale', style: { fontSize: '12px', color: 'var(--text-2)', marginBottom: '4px' } }),
scaleRange,
),
readoutHost,
),
),
el('div.stack', { style: { marginTop: '14px', gap: '8px' } },
keyHint(['W', 'A', 'S', 'D'], 'or arrow keys to drive'),
keyHint(['Space'], 'emergency stop — zeroes velocity immediately'),
note('Velocity is republished continuously while you hold the stick. If this browser stops '
+ 'sending for half a second the server zeroes the command automatically.', 'info'),
),
);
root.appendChild(driveCard);
/* ==================================================================
Preset motions
================================================================== */
const presetHost = el('div.stack');
const groups = spec?.preset_groups || [];
const presets = spec?.presets || [];
for (const group of groups) {
const inGroup = presets.filter((p) => p.group === group.key);
if (!inGroup.length) continue;
const grid = el('div.tile-grid');
for (const preset of inGroup) {
grid.appendChild(el('button.tile', {
type: 'button',
onclick: async (event) => {
const node = event.currentTarget;
node.disabled = true;
await command('/api/preset', { key: preset.key }, { successTitle: preset.label });
setTimeout(() => { node.disabled = false; }, 900);
},
},
el('span.tile-name', { text: preset.label }),
el('span.tile-meta', { text: `${preset.side} · ${preset.enum}` }),
el('span.tile-meta', {
text: `motion ${preset.motion} · area ${preset.area}`,
style: { opacity: '.7' },
}),
));
}
presetHost.appendChild(el('div', {},
el('div', {
text: group.label,
style: { fontSize: '11px', textTransform: 'uppercase', letterSpacing: '.07em',
color: 'var(--text-3)', fontWeight: '600', margin: '2px 0 8px' },
}),
grid,
));
}
root.appendChild(card('Preset motions', { sub: `SetMcPresetMotion · ${presets.length} actions` },
note('All preset motions require Stable stand mode and clear space around the robot.', 'warning', '⚠'),
el('div', { style: { marginTop: '12px' } }, presetHost),
));
/* ==================================================================
Live binding
================================================================== */
function paintState() {
const s = store.state;
if (!s) return;
for (const [id, tile] of modeButtons) {
tile.setAttribute('aria-pressed', String(id === s.mode));
}
const registered = s.source_registered;
const winner = s.input_source;
setChildren(sourceStatus,
el('div.row', {},
badge(registered ? 'Dashboard can drive' : 'Not announced yet',
registered ? 'good' : 'warning'),
winner && winner !== dash.name
? badge(`${winner} is driving`, 'accent')
: null,
),
registered ? null : el('div', { style: { marginTop: '8px' } },
note('The robot has not acknowledged the dashboard yet. This normally clears within a '
+ 'second or two of connecting; if it does not, use the button below.', 'warning', '⚠'),
),
);
const driveable = (spec?.driveable_modes || []).includes(s.mode);
const canDrive = driveable && registered;
pad.style.opacity = canDrive ? '1' : '.45';
pad.style.pointerEvents = canDrive ? 'auto' : 'none';
pad.title = canDrive ? ''
: !driveable ? `Mode ${s.mode} does not accept velocity — switch to Stable stand or Walk`
: 'Register an input source to drive';
}
paintState();
paintReadout();
let last = 0;
const unsubscribe = store.on('state', () => {
const now = Date.now();
if (now - last < 400) return;
last = now;
paintState();
paintReadout();
});
return {
node: root,
dispose: () => {
unsubscribe();
document.removeEventListener('keydown', onKeyDown);
document.removeEventListener('keyup', onKeyUp);
window.removeEventListener('blur', onBlur);
// Leaving the tab must not leave a velocity command standing.
socket.send('velocity', { forward: 0, lateral: 0, angular: 0 });
},
};
},
};

View File

@ -0,0 +1,363 @@
/* Extensions - renders whatever plugins the backend found.
Nothing here knows about any specific plugin. Each control in the manifest
maps to a widget, and using it POSTs to /api/plugins/<id>/<control>. Add a
Python file, press Reload, and it appears.
*/
import { store, get, post, command, toast, num, confirmDialog } from '../core.js';
import {
el, card, pageHead, button, note, badge, range, select, input, textarea,
emptyState, stat, field, kv,
} from '../ui.js';
import { sparkline } from '../charts.js';
export default {
id: 'extensions',
label: 'Extensions',
icon: 'extensions',
async render() {
const root = el('div.stack');
const body = el('div.stack');
const reloadBtn = button('Reload extensions', async () => {
reloadBtn.disabled = true;
reloadBtn.querySelector('.ico')?.classList.add('spin');
try {
const data = await post('/api/plugins/reload');
store.plugins = data.manifest;
toast('Extensions reloaded',
`${data.summary.count} loaded${data.summary.errors.length ? `, ${data.summary.errors.length} failed` : ''}`,
data.summary.errors.length ? 'warning' : 'good');
paint();
} catch (err) {
toast('Reload failed', err.message, 'critical');
} finally {
reloadBtn.disabled = false;
reloadBtn.querySelector('.ico')?.classList.remove('spin');
}
}, { iconName: 'refresh', style: 'primary' });
root.appendChild(pageHead(
'Extensions',
'Custom controls you have added. Drop a Python file into the plugins folder and reload — '
+ 'no frontend changes needed.',
[reloadBtn],
));
root.appendChild(body);
/* -- Rendering -------------------------------------------------------- */
const readoutNodes = new Map(); // `${pluginId}.${key}` -> { value, spark }
let seriesCache = {};
function paint() {
const manifest = store.plugins || { plugins: [], errors: [] };
body.replaceChildren();
readoutNodes.clear();
/* How-to, always visible so the workflow is discoverable. */
body.appendChild(card('How to add a control', { sub: manifest.directory || 'backend/plugins/' },
el('div.stack', { style: { gap: '10px' } },
el('p', {
text: 'Create a file in the plugins folder, build a Plugin, decorate a handler. '
+ 'It becomes a card on this page the moment you press Reload.',
style: { margin: '0', color: 'var(--text-2)', fontSize: '13px' },
}),
el('pre', {
style: {
margin: '0', padding: '13px 15px', background: 'var(--bg)',
border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)',
fontFamily: 'var(--mono)', fontSize: '11.5px', lineHeight: '1.65',
overflowX: 'auto', color: 'var(--text-2)',
},
text: `from backend.plugin_api import Plugin
plugin = Plugin(id="my_tool", name="My tool", icon="⚙")
@plugin.action("go", label="Do the thing", style="primary")
async def go(ctx):
await ctx.bridge.play_preset(motion=1002, area=2)
return "Done"
@plugin.slider("speed", label="Speed", min=0, max=0.6, default=0.2, unit="m/s")
async def speed(ctx, value):
ctx.storage["speed"] = value
return f"Speed {value:.2f}"
plugin.readout("speed", "Current speed", unit="m/s", chart=True)`,
}),
el('div.row.tight', {},
badge('action → button'), badge('slider → range'), badge('toggle → switch'),
badge('select → dropdown'), badge('text → input'), badge('number → stepper'),
badge('color → picker'), badge('readout → stat tile'),
),
note('ctx gives you .bridge (every robot command), .state (live telemetry), '
+ '.storage (persists across calls), .log(), .push() and .record().', 'info'),
),
));
/* Load errors */
if (manifest.errors?.length) {
body.appendChild(card('Extensions that failed to load', {},
el('div.stack', { style: { gap: '10px' } },
...manifest.errors.map((error) => el('div', {
style: {
padding: '12px 14px', borderRadius: 'var(--radius-sm)',
background: 'rgba(208,59,59,.07)', border: '1px solid rgba(208,59,59,.3)',
},
},
el('div.row.between', { style: { marginBottom: '6px' } },
el('strong', { text: error.file, style: { fontSize: '13px' } }),
badge('failed', 'critical'),
),
el('div', { text: error.error, style: { fontSize: '12px', color: 'var(--text-2)' } }),
error.trace ? el('pre', {
text: error.trace,
style: {
margin: '8px 0 0', fontSize: '10.5px', fontFamily: 'var(--mono)',
color: 'var(--text-3)', whiteSpace: 'pre-wrap', maxHeight: '150px',
overflowY: 'auto',
},
}) : null,
)),
),
));
}
/* Plugin cards */
if (!manifest.plugins?.length) {
body.appendChild(emptyState(
'No extensions loaded yet',
'The plugins folder has no usable files. Copy _template.py to a new name to start.',
));
return;
}
const grid = el('div.grid.cols-2');
for (const plugin of manifest.plugins) grid.appendChild(renderPlugin(plugin));
body.appendChild(grid);
paintReadouts();
}
function renderPlugin(plugin) {
const controls = el('div.stack', { style: { gap: '13px' } });
for (const control of plugin.controls) {
const node = renderControl(plugin, control);
if (node) controls.appendChild(node);
}
const readouts = el('div.grid.cols-2', { style: { gap: '9px' } });
for (const readout of plugin.readouts || []) {
const valueNode = el('div.stat-value', { text: '—' });
const sparkHost = readout.chart ? el('div.stat-spark') : null;
const tile = el('div.stat', {},
el('div.stat-label', { text: readout.label }),
valueNode,
sparkHost,
);
readoutNodes.set(`${plugin.id}.${readout.key}`, { readout, valueNode, sparkHost });
readouts.appendChild(tile);
}
return card(plugin.name, {
sub: plugin.description,
actions: [
el('span', { text: plugin.icon, style: { fontSize: '16px' } }),
plugin.has_tick ? badge(`ticks ${plugin.tick_interval}s`, 'accent') : null,
].filter(Boolean),
},
el('div.stack', {},
plugin.readouts?.length ? readouts : null,
plugin.controls.length ? controls
: note('This extension declares no controls.', 'default'),
),
);
}
function renderControl(plugin, control) {
const path = `/api/plugins/${plugin.id}/${control.key}`;
const run = async (value, node) => {
if (control.confirm) {
const ok = await confirmDialog(control.label, control.confirm,
{ danger: control.style === 'danger' });
if (!ok) return;
}
if (node) node.disabled = true;
try {
const data = await post(path, { value });
toast(control.label, data?.message || 'Done', data?.ok === false ? 'critical' : 'good');
} catch (err) {
toast(control.label, err.message, 'critical');
} finally {
if (node) node.disabled = false;
}
};
switch (control.kind) {
case 'action': {
const btn = button(control.label, (node) => run(null, node), {
style: control.style || 'default',
title: control.help || '',
block: true,
});
return control.help
? el('div', {}, btn, el('div.hint', { text: control.help, style: { marginTop: '4px' } }))
: btn;
}
case 'slider': {
const control_ = range({
min: control.min, max: control.max, step: control.step,
value: control.default, unit: control.unit ? ` ${control.unit}` : '',
precision: decimalsFor(control.step),
onInput: control.live ? throttle((v) => run(v), 200) : undefined,
onChange: control.live ? undefined : (v) => run(v),
});
return field(control.label, control_, control.help);
}
case 'toggle': {
const box = el('input', { type: 'checkbox', checked: control.default });
box.addEventListener('change', () => run(box.checked));
return el('div', {},
el('label.switch', {}, box, el('span.switch-track'),
el('span.switch-label', { text: control.label })),
control.help ? el('div.hint', { text: control.help, style: { marginTop: '4px' } }) : null,
);
}
case 'select': {
const node = select(control.options || [], {
value: control.default,
onChange: (value) => run(value),
});
return field(control.label, node, control.help);
}
case 'text': {
const box = control.multiline
? textarea({ placeholder: control.placeholder || '', value: control.default || '' })
: input({ placeholder: control.placeholder || '', value: control.default || '' });
const submit = button(control.submit_label || 'Send', (node) => {
const value = box.value;
if (!value.trim()) { box.focus(); return; }
run(value, node).then(() => { box.value = ''; });
}, { style: 'primary', size: 'sm' });
if (!control.multiline) {
box.addEventListener('keydown', (e) => { if (e.key === 'Enter') submit.click(); });
}
return field(control.label,
el('div.row', { style: { gap: '8px', flexWrap: 'nowrap' } }, box, submit),
control.help);
}
case 'number': {
const box = input({
type: 'number', value: control.default,
min: control.min ?? undefined, max: control.max ?? undefined, step: control.step,
});
box.addEventListener('change', () => run(Number(box.value)));
return field(
control.unit ? `${control.label} (${control.unit})` : control.label,
box, control.help,
);
}
case 'color': {
const box = el('input.input', { type: 'color', value: control.default || '#3987e5' });
box.addEventListener('change', () => run(box.value));
return field(control.label, box, control.help);
}
default:
return note(`Unsupported control type "${control.kind}"`, 'warning', '⚠');
}
}
/* -- Readouts --------------------------------------------------------- */
function paintReadouts() {
const custom = store.state?.custom || {};
for (const [key, entry] of readoutNodes) {
const [pluginId, readoutKey] = splitOnce(key, '.');
const value = custom[pluginId]?.[readoutKey];
const { readout, valueNode, sparkHost } = entry;
if (value === undefined || value === null) {
valueNode.textContent = '—';
} else if (readout.format === 'text' || typeof value === 'string') {
valueNode.textContent = String(value);
valueNode.style.fontSize = '17px';
} else {
valueNode.textContent = num(value, readout.precision ?? 2);
valueNode.style.fontSize = '';
if (readout.unit) valueNode.appendChild(el('span.unit', { text: readout.unit }));
}
if (sparkHost) {
const points = seriesCache[`${pluginId}.${readoutKey}`] || [];
sparkHost.replaceChildren(
points.length > 2 ? sparkline(points, { height: 30, colorIndex: 2 }) : el('span'),
);
}
}
}
async function refreshSeries() {
const wanted = [...readoutNodes.entries()]
.filter(([, entry]) => entry.readout.chart)
.map(([key]) => key);
if (!wanted.length) return;
try {
seriesCache = await get(`/api/series?keys=${wanted.join(',')}&limit=120`);
} catch { /* best-effort */ }
}
paint();
await refreshSeries();
paintReadouts();
const unsubPlugins = store.on('plugins', () => paint());
let last = 0;
const unsubState = store.on('state', () => {
const now = Date.now();
if (now - last < 900) return;
last = now;
paintReadouts();
});
const timer = setInterval(() => refreshSeries().then(paintReadouts), 4000);
return {
node: root,
dispose: () => { unsubPlugins(); unsubState(); clearInterval(timer); },
};
},
};
function decimalsFor(step) {
const text = String(step);
const dot = text.indexOf('.');
return dot === -1 ? 0 : Math.min(4, text.length - dot - 1);
}
function splitOnce(text, separator) {
const index = text.indexOf(separator);
return index === -1 ? [text, ''] : [text.slice(0, index), text.slice(index + 1)];
}
function throttle(fn, ms) {
let last = 0;
return (...args) => {
const now = Date.now();
if (now - last < ms) return;
last = now;
fn(...args);
};
}

View File

@ -0,0 +1,535 @@
/* Interaction - speech, screen expression, LED strip. */
import { store, command, num, api, get, toast } from '../core.js';
import {
el, card, pageHead, button, note, badge, range, select, textarea, field,
segmented, emptyState,
} from '../ui.js';
export default {
id: 'interaction',
label: 'Interaction',
icon: 'interaction',
async render() {
const spec = store.spec;
const root = el('div.stack');
root.appendChild(pageHead(
'Interaction',
'What the robot says, shows on its face, and signals with its light strip.',
));
/* ==================================================================
Conversation the listening loop (Muza / Lumi)
==================================================================
The robot only starts listening once a character, a language and a model
have been chosen and Apply is pressed. Selections are staged locally in
`draft` and committed in one request, because switching any of them means
relaunching the voice process doing that per-click would restart the
pipeline three times on the way to one setup. */
let session = null;
let applying = false;
const draft = { enabled: false, gender: 'female', language: 'arabic', model: 'gemini' };
const sessionControlsHost = el('div.stack');
const sessionStatusHost = el('div');
const sessionDetailHost = el('div');
const sessionWarnHost = el('div');
const applyBtn = button('Apply', async () => {
applying = true;
applyBtn.disabled = true;
applyBtn.textContent = draft.enabled ? 'Starting…' : 'Stopping…';
paintStatus();
try {
// Deliberately not `command()`: a unit restart can outlast its 15s
// default timeout, and a timed-out apply that actually succeeded is
// the most confusing possible outcome here.
const data = await api('/api/voice/session', {
method: 'POST', body: { ...draft }, timeout: 90000,
});
// Re-seed from what the server ACTUALLY applied, never from `draft`.
// Showing the requested state instead of the achieved one is what made
// the card read "muza_ar.txt / Kore" while Lumi was selected.
syncFromServer(data.snapshot);
if (data.snapshot?.profile?.enabled) {
const who = `${session.persona.name_en} · ${session.persona.name_ar}`;
toast('Starting', `${who} — the greeting plays once the voice `
+ 'connects, usually within about ten seconds.', 'good');
} else {
toast('Speaking off', 'The robot has stopped listening.', 'good');
}
} catch (err) {
toast('Could not apply', err.message, 'critical');
// Pull the real state back: a failed apply may still have changed it.
get('/api/voice/session').then(syncFromServer).catch(() => {});
} finally {
applying = false;
applyBtn.disabled = false;
applyBtn.textContent = 'Apply';
paintStatus();
}
}, { style: 'primary' });
/* Controls are rebuilt only when the SERVER's state changes, not on every
local click rebuilding mid-interaction would tear down the widget the
operator just clicked. Local clicks mutate `draft` and repaint only the
read-only lines below it. */
function buildSessionControls() {
const enableBox = el('input', { type: 'checkbox', checked: draft.enabled });
enableBox.addEventListener('change', () => {
draft.enabled = enableBox.checked;
paintDetail();
});
sessionControlsHost.replaceChildren(
el('label.switch', {}, enableBox, el('span.switch-track'),
el('span.switch-label', { text: 'Speaking on' })),
field('Character',
segmented(session.options.gender, draft.gender, (value) => {
draft.gender = value;
paintDetail();
}),
'Female speaks as Muza (موزة); male speaks as Lumi (لومي).'),
field('Language',
segmented(session.options.language, draft.language, (value) => {
draft.language = value;
paintDetail();
}),
'Arabic only is pure Emirati dialect. Multi-language still opens in '
+ 'Emirati and follows the visitor from there.'),
field('Model',
segmented(session.options.model, draft.model, (value) => {
draft.model = value;
paintDetail();
}),
'Gemini Live is streaming speech-to-speech. LinkSoul routes through '
+ 'the robots own agent, which keeps its native co-speech motion.'),
el('div.row.between', {}, sessionWarnHost, applyBtn),
);
}
function paintStatus() {
const running = session?.service?.running;
sessionStatusHost.replaceChildren(
applying ? badge('Starting…', 'accent')
: badge(running ? 'Listening' : 'Off', running ? 'good' : 'default'),
);
}
function paintDetail() {
if (!session) return;
// Everything below describes the STAGED selection, so it has to be
// derived from `draft` — reading persona/voice off the last snapshot is
// what made the line disagree with the selected character.
const persona = (session.personas || {})[`${draft.gender}_${draft.language}`];
const voice = draft.model === 'linksoul'
? session.voices?.edge?.[draft.gender]
: session.voices?.gemini?.[draft.gender];
const staged = draft.enabled !== !!session.profile?.enabled
|| draft.gender !== session.profile?.gender
|| draft.language !== session.profile?.language
|| draft.model !== session.profile?.model;
sessionDetailHost.replaceChildren(
el('div.hint', {
text: `${staged ? 'Will apply' : 'Running'}: persona ${persona || '—'} · `
+ `voice ${voice || '—'} · unit ${session.service?.unit || '—'} `
+ `(${session.service?.state || '—'})`,
}),
);
// Only warn about LinkSoul while LinkSoul is the staged choice — the
// missing SDK is irrelevant noise when Gemini is selected.
const ls = session.linksoul;
sessionWarnHost.replaceChildren(
draft.model === 'linksoul' && ls && !ls.ready
? note(`LinkSoul cannot start yet — ${ls.reasons.join('; ')}.`, 'warning')
: el('span'),
);
}
function syncFromServer(snapshot) {
if (!snapshot) return;
session = snapshot;
if (!snapshot.available) {
sessionControlsHost.replaceChildren(
note(`Voice session unavailable — ${snapshot.error}. Looked for Sanad at `
+ `${snapshot.sanad_dir || 'the configured path'}.`, 'warning'),
);
return;
}
// Seed the toggle from the SAVED INTENT, not from whether the unit
// happens to be up. Reading service.running here meant that pressing
// Apply during a restart (or any moment the unit was briefly down) sent
// enabled:false and STOPPED the voice instead of starting it — which
// reads exactly like "I pressed Apply and now it never answers".
// paintStatus() still shows the real unit state, so a disagreement
// between intent and reality stays visible without rewriting the intent.
Object.assign(draft, snapshot.profile);
buildSessionControls();
paintStatus();
paintDetail();
}
const conversationCard = card('Conversation', {
sub: 'Muza / Lumi',
actions: [sessionStatusHost],
}, el('div.stack', {}, sessionControlsHost, sessionDetailHost));
root.appendChild(conversationCard);
get('/api/voice/session').then(syncFromServer).catch((err) => {
sessionControlsHost.replaceChildren(
note(`Could not read the voice session: ${err.message}`, 'warning'),
);
});
/* ==================================================================
Voice
================================================================== */
const speechBox = textarea({
placeholder: 'Type what the robot should say…',
rows: 3,
});
const prioritySelect = select(
(spec?.tts_priorities || []).map((p) => ({ value: p.value, label: `${p.label} (${p.value}) — ${p.desc}` })),
{ value: 6 },
);
let interrupt = false;
const speakBtn = button('Speak', async () => {
const text = speechBox.value.trim();
if (!text) { speechBox.focus(); return; }
const ok = await command('/api/speak', {
text,
priority: Number(prioritySelect.value),
interrupt,
}, { successTitle: 'Sent to TTS' });
if (ok) speechBox.value = '';
}, { style: 'primary' });
// Ctrl/Cmd+Enter is the expected shortcut in a text box that has a submit button.
speechBox.addEventListener('keydown', (event) => {
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) speakBtn.click();
});
const quickPhrases = [
'Hello, I am X2.',
'Please stand clear.',
'Starting the routine now.',
'Task complete.',
'Battery is low.',
];
const volumeRange = range({
min: 0, max: 100, step: 1, value: store.state?.volume ?? 60, precision: 0, unit: '%',
onChange: (value) => command('/api/volume', { volume: value }, { silent: true }),
});
const muteHost = el('div');
const ttsStatusHost = el('div');
const voiceCard = card('Speech', { sub: 'PlayTts' },
el('div.stack', {},
field('Text', speechBox, 'Ctrl + Enter to send'),
el('div.row.tight', {},
...quickPhrases.map((phrase) => button(phrase, () => {
speechBox.value = phrase;
speechBox.focus();
}, { size: 'sm', style: 'ghost' })),
),
field('Priority', prioritySelect,
'Higher priorities pre-empt queued speech. Reserve 810 for genuine safety announcements.'),
el('div.row.between', {},
(() => {
const box = el('input', { type: 'checkbox' });
box.addEventListener('change', () => { interrupt = box.checked; });
return el('label.switch', {}, box, el('span.switch-track'),
el('span.switch-label', { text: 'Interrupt current speech' }));
})(),
speakBtn,
),
),
);
const audioCard = card('Audio', { sub: 'GetVolume / SetVolume / SetMute' },
el('div.stack', {},
el('div', {},
el('div', { text: 'Output volume', style: { fontSize: '12px', color: 'var(--text-2)', marginBottom: '5px' } }),
volumeRange,
),
muteHost,
ttsStatusHost,
note('Microphone source switching is not exposed as a service on this firmware, so it is '
+ 'not offered here. Volume and mute are live.', 'info'),
),
);
root.appendChild(el('div.grid.cols-2', {}, voiceCard, audioCard));
/* ==================================================================
Screen / emoji
================================================================== */
let emojiMode = 1;
const emojiHost = el('div.stack');
const emojiTiles = new Map();
const modeTabs = segmented(
(spec?.emoji_modes || []).map((m) => ({ value: m.value, label: m.label })),
emojiMode,
(value) => { emojiMode = value; },
);
for (const group of spec?.emoji_groups || []) {
const inGroup = (spec?.emojis || []).filter((e) => e.group === group.key);
if (!inGroup.length) continue;
const grid = el('div.emoji-grid');
for (const emoji of inGroup) {
const tile = el('button.emoji-tile', {
type: 'button',
'aria-pressed': 'false',
title: `ID ${emoji.id}`,
onclick: () => command('/api/emoji', { emotion_id: emoji.id, mode: emojiMode },
{ successTitle: emoji.label }),
},
el('span.emoji-glyph', { text: emoji.glyph }),
el('span.emoji-name', { text: emoji.label }),
);
emojiTiles.set(emoji.id, tile);
grid.appendChild(tile);
}
emojiHost.appendChild(el('div', {},
el('div', {
text: group.label,
style: { fontSize: '11px', textTransform: 'uppercase', letterSpacing: '.07em',
color: 'var(--text-3)', fontWeight: '600', margin: '2px 0 8px' },
}),
grid,
));
}
root.appendChild(card('Face', {
sub: `PlayEmoji · ${(spec?.emojis || []).length} expressions`,
actions: [modeTabs],
}, emojiHost));
/* ==================================================================
LED strip
================================================================== */
let led = { mode: 0, r: 57, g: 135, b: 229 };
let ledKeep = true;
const preview = el('div', {
style: {
width: '100%', height: '54px', borderRadius: 'var(--radius-sm)',
border: '1px solid var(--border)',
background: `rgb(${led.r}, ${led.g}, ${led.b})`,
transition: 'background .2s',
},
});
const colorInput = el('input.input', { type: 'color', value: rgbToHex(led) });
colorInput.addEventListener('input', () => {
Object.assign(led, hexToRgb(colorInput.value));
preview.style.background = `rgb(${led.r}, ${led.g}, ${led.b})`;
markSwatch();
});
const swatchHost = el('div.swatches');
const swatchNodes = [];
for (const swatch of spec?.led_swatches || []) {
const node = el('button.swatch', {
type: 'button',
title: swatch.label,
'aria-pressed': 'false',
style: {
background: `rgb(${swatch.r}, ${swatch.g}, ${swatch.b})`,
borderColor: swatch.r + swatch.g + swatch.b === 0 ? 'var(--surface-hi)' : 'transparent',
},
onclick: () => {
led = { ...led, r: swatch.r, g: swatch.g, b: swatch.b };
colorInput.value = rgbToHex(led);
preview.style.background = `rgb(${led.r}, ${led.g}, ${led.b})`;
markSwatch();
},
});
node._swatch = swatch;
swatchNodes.push(node);
swatchHost.appendChild(node);
}
function markSwatch() {
for (const node of swatchNodes) {
const s = node._swatch;
node.setAttribute('aria-pressed', String(s.r === led.r && s.g === led.g && s.b === led.b));
}
}
markSwatch();
const ledModeTabs = segmented(
(spec?.led_modes || []).map((m) => ({ value: m.value, label: m.label })),
led.mode,
(value) => { led.mode = value; },
);
const ledCard = card('Light strip', { sub: 'SetPmuLed', actions: [ledModeTabs] },
el('div.stack', {},
preview,
swatchHost,
field('Custom colour', colorInput),
(() => {
const box = el('input', { type: 'checkbox', checked: ledKeep });
box.addEventListener('change', () => { ledKeep = box.checked; });
return el('div', {},
el('label.switch', {}, box, el('span.switch-track'),
el('span.switch-label', { text: 'Keep it on' })),
el('div.hint', {
text: 'The robot\'s own task_manager reclaims the light strip after about a minute. '
+ 'With this on, the dashboard re-applies your setting every 20 seconds so it '
+ 'stays. Turn it off if you want a single one-off flash instead.',
style: { marginTop: '5px' },
}),
);
})(),
el('div.row', {},
button('Apply', () => command('/api/led', { ...led, keep: ledKeep },
{ successTitle: ledKeep ? 'LED set and held' : 'LED set once' }),
{ style: 'primary' }),
button('Turn off', () => {
led = { ...led, r: 0, g: 0, b: 0 };
colorInput.value = '#000000';
preview.style.background = '#000';
markSwatch();
// keep:false also cancels the keepalive, so "off" stays off.
return command('/api/led', { ...led, mode: 0, keep: false },
{ successTitle: 'LED off' });
}),
),
el('div', {},
...(spec?.led_modes || []).map((m) => el('div', {
text: `${m.label}${m.desc}`,
style: { fontSize: '11.5px', color: 'var(--text-3)', lineHeight: '1.6' },
})),
),
),
);
const statusCard = card('Current output', {},
el('div', { id: 'interaction-status' }),
);
root.appendChild(el('div.grid.cols-2', {}, ledCard, statusCard));
/* ==================================================================
Live state
================================================================== */
function paint() {
const s = store.state;
if (!s) return;
muteHost.replaceChildren((() => {
const box = el('input', { type: 'checkbox', checked: s.muted });
box.addEventListener('change', () =>
command('/api/mute', { muted: box.checked }, { silent: true }));
return el('label.switch', {}, box, el('span.switch-track'),
el('span.switch-label', { text: s.muted ? 'Muted' : 'Sound on' }));
})());
const faceStatus = s.custom?.face_status;
const faceLabel = (spec?.face_status || {})[faceStatus];
ttsStatusHost.replaceChildren(
el('div.row', {},
el('span', { text: 'Face playback', style: { fontSize: '12px', color: 'var(--text-2)' } }),
badge(faceLabel || 'unknown',
faceStatus === 2 ? 'good' : faceStatus === 0 ? 'default' : 'accent'),
),
);
for (const [id, tile] of emojiTiles) {
tile.setAttribute('aria-pressed', String(id === s.emoji_id));
}
const activeEmoji = (spec?.emojis || []).find((e) => e.id === s.emoji_id);
const modeLabel = (spec?.led_modes || []).find((m) => m.value === s.led?.mode)?.label || '—';
document.getElementById('interaction-status')?.replaceChildren(
el('div.grid.cols-2', { style: { gap: '10px' } },
el('div.stat', {},
el('div.stat-label', { text: 'Face' }),
el('div.row', { style: { gap: '8px', alignItems: 'center', marginTop: '4px' } },
el('span', { text: activeEmoji?.glyph || '—', style: { fontSize: '26px' } }),
el('span', { text: activeEmoji?.label || 'nothing set', style: { fontSize: '13px' } }),
),
),
el('div.stat', {},
el('div.stat-label', { text: 'Light strip' }),
el('div.row', { style: { gap: '8px', alignItems: 'center', marginTop: '4px' } },
el('span', {
style: {
width: '22px', height: '22px', borderRadius: '6px',
border: '1px solid var(--border)',
background: `rgb(${s.led?.r ?? 0}, ${s.led?.g ?? 0}, ${s.led?.b ?? 0})`,
},
}),
el('span', { text: modeLabel, style: { fontSize: '13px' } }),
),
),
el('div.stat', {},
el('div.stat-label', { text: 'Volume' }),
el('div.stat-value', { text: `${s.volume ?? 0}` }, el('span.unit', { text: '%' })),
),
el('div.stat', {},
el('div.stat-label', { text: 'Audio' }),
el('div', { style: { marginTop: '6px' } },
badge(s.muted ? 'Muted' : 'Active', s.muted ? 'warning' : 'good'),
),
),
),
);
}
paint();
let last = 0;
const unsubscribe = store.on('state', () => {
const now = Date.now();
if (now - last < 800) return;
last = now;
paint();
});
return { node: root, dispose: unsubscribe };
},
};
function rgbToHex({ r, g, b }) {
return `#${[r, g, b].map((v) => Math.max(0, Math.min(255, v | 0)).toString(16).padStart(2, '0')).join('')}`;
}
function hexToRgb(hex) {
const clean = hex.replace('#', '');
return {
r: parseInt(clean.slice(0, 2), 16) || 0,
g: parseInt(clean.slice(2, 4), 16) || 0,
b: parseInt(clean.slice(4, 6), 16) || 0,
};
}

View File

@ -0,0 +1,430 @@
/* LiDAR - live 3D point cloud from the chest sensor.
Off until switched on, like the cameras: a scan is 816 KB at 2 Hz and there
is no reason to carry that when nobody is looking.
Rendered with a hand-rolled projection onto a 2D canvas rather than a 3D
library. Points need no lighting, no materials and no scene graph - just a
matrix multiply and a fillRect - so a library would be a few hundred KB
fetched over the same link we are trying not to saturate. The robot has no
internet either, which rules out a CDN.
*/
import { store, get, post, num, toast } from '../core.js';
import { el, card, pageHead, button, badge, note, stat, segmented, toggle } from '../ui.js';
export default {
id: 'lidar',
label: 'LiDAR',
icon: 'scan',
async render() {
const spec = store.spec?.lidar || {};
const key = spec.key || 'lidar_chest_front';
const root = el('div.stack');
let active = false;
let busy = false;
let timer = null;
/* -- View state -------------------------------------------------------- */
const view = {
yaw: -0.6, // radians, orbit around Z (up)
pitch: 0.45, // radians above the horizon
distance: 12, // metres from the origin
colourBy: 'height',
accumulate: false,
};
let live = []; // newest scan: [x, y, z, intensity]
const trailScans = []; // recent scans, when accumulating
const MAX_TRAIL = 12;
/* -- Controls ---------------------------------------------------------- */
const statusBadge = badge('off');
const powerBox = el('input', { type: 'checkbox' });
powerBox.addEventListener('change', () => setActive(powerBox.checked));
const power = el('label.switch', {}, powerBox, el('span.switch-track'),
el('span.switch-label', { text: 'Off' }));
const powerLabel = power.querySelector('.switch-label');
root.appendChild(pageHead(
'LiDAR',
'Chest LiDAR point cloud, decimated on the robot before it crosses the network. '
+ 'Drag to orbit, scroll to zoom.',
[power, statusBadge],
));
const tiles = el('div.grid.cols-4');
const refs = {};
for (const [tileKey, label] of [
['points', 'Points drawn'],
['scan', 'Points per scan'],
['rate', 'Scan rate'],
['range', 'Furthest return'],
]) {
const node = stat(label, '—', { sub: ' ' });
refs[tileKey] = node;
tiles.appendChild(node);
}
root.appendChild(tiles);
function setTile(tileKey, value, sub, unit = '') {
const node = refs[tileKey];
if (!node) return;
const valueNode = node.querySelector('.stat-value');
valueNode.textContent = String(value);
if (unit) valueNode.appendChild(el('span.unit', { text: unit }));
node.querySelector('.stat-sub').textContent = sub || '';
}
const canvas = el('canvas', {
width: 1200, height: 720,
style: { width: '100%', height: 'auto', display: 'block', cursor: 'grab',
borderRadius: 'var(--radius-sm)', touchAction: 'none' },
});
root.appendChild(card('Point cloud', {
sub: spec.topic || '',
actions: [
segmented(
[{ value: 'height', label: 'Height' },
{ value: 'distance', label: 'Distance' },
{ value: 'intensity', label: 'Intensity' }],
view.colourBy,
(value) => { view.colourBy = value; draw(); },
),
toggle('Trail', false, (on) => {
view.accumulate = on;
trailScans.length = 0;
draw();
}),
button('Reset view', () => {
view.yaw = -0.6; view.pitch = 0.45; view.distance = 12;
draw();
}, { size: 'sm', style: 'ghost' }),
],
flush: true,
}, canvas));
root.appendChild(note(
'Points are in the sensor frame: X forward, Y left, Z up, metres. The robot decimates '
+ `each scan to ${spec.max_points || 4000} points — the full scan is ~25 000, which looks `
+ 'identical on screen and costs ten times the bandwidth.',
'info',
));
/* -- Switching --------------------------------------------------------- */
async function setActive(on) {
if (busy) return;
busy = true;
powerBox.disabled = true;
statusBadge.textContent = on ? 'starting…' : 'stopping…';
statusBadge.dataset.tone = 'default';
try {
await post(`/api/streams/${key}`, { active: on });
active = on;
powerLabel.textContent = on ? 'On' : 'Off';
if (on) {
schedule();
} else {
if (timer) clearTimeout(timer);
timer = null;
live = [];
trailScans.length = 0;
statusBadge.textContent = 'off';
statusBadge.dataset.tone = 'default';
setTile('points', '—', '');
setTile('scan', '—', '');
setTile('rate', '—', '');
setTile('range', '—', '');
draw();
}
} catch (err) {
toast('Could not switch the LiDAR', err.message, 'critical');
powerBox.checked = active;
} finally {
busy = false;
powerBox.disabled = false;
}
}
/* -- Polling ----------------------------------------------------------- */
// The sensor publishes at 2 Hz, so asking faster than that just re-fetches
// the same scan. 400 ms keeps the view current without waste.
const POLL_MS = 400;
let lastTs = 0;
let scanTimes = [];
function schedule() {
if (timer) clearTimeout(timer);
timer = active ? setTimeout(fetchPoints, POLL_MS) : null;
}
async function fetchPoints() {
if (!active) return;
try {
const data = await get('/api/lidar/points');
if (data.off) {
// Something else turned it off - follow rather than fight it.
active = false;
powerBox.checked = false;
powerLabel.textContent = 'Off';
statusBadge.textContent = 'off';
statusBadge.dataset.tone = 'default';
return;
}
if (!data.ok) {
statusBadge.textContent = data.message || 'waiting';
statusBadge.dataset.tone = 'warning';
return;
}
if (data.ts && data.ts !== lastTs) {
scanTimes.push(data.ts);
if (scanTimes.length > 8) scanTimes.shift();
lastTs = data.ts;
if (view.accumulate) {
trailScans.push(live);
while (trailScans.length > MAX_TRAIL) trailScans.shift();
}
live = data.points || [];
}
statusBadge.textContent = 'live';
statusBadge.dataset.tone = 'good';
const hz = scanTimes.length > 1
? (scanTimes.length - 1) / (scanTimes.at(-1) - scanTimes[0])
: 0;
const drawn = live.length + (view.accumulate
? trailScans.reduce((sum, scan) => sum + scan.length, 0) : 0);
let furthest = 0;
for (const p of live) {
const d = Math.hypot(p[0], p[1], p[2]);
if (d > furthest) furthest = d;
}
setTile('points', drawn.toLocaleString(), view.accumulate ? 'including trail' : 'this scan');
setTile('scan', (data.count || 0).toLocaleString(), 'after decimation');
setTile('rate', num(hz, 1), 'measured', ' Hz');
setTile('range', num(furthest, 1), 'from the sensor', ' m');
draw();
} catch {
statusBadge.textContent = 'no answer';
statusBadge.dataset.tone = 'warning';
} finally {
schedule();
}
}
/* -- Interaction ------------------------------------------------------- */
let dragging = null;
canvas.addEventListener('pointerdown', (event) => {
dragging = { x: event.clientX, y: event.clientY };
canvas.setPointerCapture(event.pointerId);
canvas.style.cursor = 'grabbing';
});
canvas.addEventListener('pointermove', (event) => {
if (!dragging) return;
view.yaw += (event.clientX - dragging.x) * 0.007;
// Clamped just short of straight down so the view never flips over.
view.pitch = Math.max(-1.5, Math.min(1.5, view.pitch + (event.clientY - dragging.y) * 0.007));
dragging = { x: event.clientX, y: event.clientY };
draw();
});
for (const type of ['pointerup', 'pointercancel']) {
canvas.addEventListener(type, () => { dragging = null; canvas.style.cursor = 'grab'; });
}
canvas.addEventListener('wheel', (event) => {
event.preventDefault();
view.distance = Math.max(1.5, Math.min(60, view.distance * (1 + Math.sign(event.deltaY) * 0.12)));
draw();
}, { passive: false });
/* -- Rendering --------------------------------------------------------- */
const context = canvas.getContext('2d');
// Robot frame is X forward, Y left, Z up. Screen is X right, Y down. The
// camera orbits the origin looking at it.
function project(x, y, z, cam) {
// World -> camera: yaw about Z, then pitch.
const cy = Math.cos(cam.yaw), sy = Math.sin(cam.yaw);
const rx = x * cy - y * sy;
const ry = x * sy + y * cy;
const cp = Math.cos(cam.pitch), sp = Math.sin(cam.pitch);
const depth = ry * cp + z * sp + cam.distance;
const up = -ry * sp + z * cp;
// Behind the camera - drop it rather than projecting it to a mirrored
// point somewhere silly on screen.
if (depth <= 0.05) return null;
const scale = cam.focal / depth;
return { sx: cam.cx + rx * scale, sy: cam.cy - up * scale, depth };
}
function draw() {
const width = canvas.width;
const height = canvas.height;
const styles = getComputedStyle(document.documentElement);
const surface = styles.getPropertyValue('--surface-2').trim() || '#1b1e22';
const gridColor = styles.getPropertyValue('--grid').trim() || '#22262c';
const axisColor = styles.getPropertyValue('--axis').trim() || '#333941';
const muted = styles.getPropertyValue('--text-3').trim() || '#6d7681';
context.fillStyle = surface;
context.fillRect(0, 0, width, height);
const cam = {
yaw: view.yaw, pitch: view.pitch, distance: view.distance,
focal: height * 0.9, cx: width / 2, cy: height / 2,
};
// Ground grid, 1 m squares, so distances are readable at a glance.
const half = 10;
context.lineWidth = 1;
for (let i = -half; i <= half; i += 1) {
for (const [a, b] of [[[i, -half], [i, half]], [[-half, i], [half, i]]]) {
const p1 = project(a[0], a[1], 0, cam);
const p2 = project(b[0], b[1], 0, cam);
if (!p1 || !p2) continue;
context.strokeStyle = i === 0 ? axisColor : gridColor;
context.beginPath();
context.moveTo(p1.sx, p1.sy);
context.lineTo(p2.sx, p2.sy);
context.stroke();
}
}
if (!active) {
context.fillStyle = muted;
context.font = '16px system-ui, sans-serif';
context.textAlign = 'center';
context.fillText('LiDAR is off — switch it on to see the point cloud',
width / 2, height / 2);
return;
}
if (!live.length) {
context.fillStyle = muted;
context.font = '15px system-ui, sans-serif';
context.textAlign = 'center';
context.fillText('Waiting for the first scan…', width / 2, height / 2);
return;
}
// Older scans first so the newest sits on top.
const batches = view.accumulate
? [...trailScans.map((scan, i) => ({ scan, fade: 0.25 + 0.45 * (i / MAX_TRAIL) })),
{ scan: live, fade: 1 }]
: [{ scan: live, fade: 1 }];
for (const { scan, fade } of batches) {
for (const point of scan) {
const projected = project(point[0], point[1], point[2], cam);
if (!projected) continue;
if (projected.sx < 0 || projected.sx > width
|| projected.sy < 0 || projected.sy > height) continue;
context.fillStyle = colourFor(point, projected.depth, fade);
// Nearer points draw larger - the only depth cue a flat point cloud
// has once perspective alone stops being obvious.
const size = Math.max(1, Math.min(3.5, 9 / projected.depth));
context.fillRect(projected.sx, projected.sy, size, size);
}
}
// Sensor origin, so "where is the robot" is never in doubt.
const origin = project(0, 0, 0, cam);
if (origin) {
context.strokeStyle = styles.getPropertyValue('--series-2').trim() || '#d95926';
context.lineWidth = 2;
context.beginPath();
context.arc(origin.sx, origin.sy, 6, 0, Math.PI * 2);
context.stroke();
}
context.fillStyle = muted;
context.font = '11px system-ui, sans-serif';
context.textAlign = 'left';
context.fillText('1 m grid · X forward, Y left, Z up', 12, height - 12);
}
function colourFor(point, depth, fade) {
let t;
if (view.colourBy === 'height') {
t = (point[2] + 1.5) / 4.0; // -1.5 m .. 2.5 m
} else if (view.colourBy === 'intensity') {
t = (point[3] || 0) / 150;
} else {
t = Math.hypot(point[0], point[1], point[2]) / 15;
}
t = Math.max(0, Math.min(1, t));
// Blue -> cyan -> green -> yellow -> red. Enough steps that a wall at a
// constant height reads as one colour rather than a gradient.
const stops = [[59, 130, 246], [34, 211, 238], [74, 222, 128],
[250, 204, 21], [239, 68, 68]];
const scaled = t * (stops.length - 1);
const index = Math.min(stops.length - 2, Math.floor(scaled));
const frac = scaled - index;
const a = stops[index], b = stops[index + 1];
const r = Math.round(a[0] + (b[0] - a[0]) * frac);
const g = Math.round(a[1] + (b[1] - a[1]) * frac);
const bl = Math.round(a[2] + (b[2] - a[2]) * frac);
return `rgba(${r},${g},${bl},${fade})`;
}
/* -- Follow the robot's own state -------------------------------------- */
function applyStreams(streams) {
if (!streams || busy) return;
const on = !!streams[key]?.active;
if (on === active) return;
active = on;
powerBox.checked = on;
powerLabel.textContent = on ? 'On' : 'Off';
if (on) {
schedule();
} else {
if (timer) clearTimeout(timer);
timer = null;
live = [];
trailScans.length = 0;
statusBadge.textContent = 'off';
statusBadge.dataset.tone = 'default';
draw();
}
}
applyStreams(store.state?.custom?.streams);
const unsubscribe = store.on('state', () => applyStreams(store.state?.custom?.streams));
draw();
return {
node: root,
dispose: () => {
unsubscribe();
if (timer) clearTimeout(timer);
// Never leave the sensor streaming into a page that has gone away.
if (active) post(`/api/streams/${key}`, { active: false }).catch(() => {});
},
};
},
};

View File

@ -0,0 +1,443 @@
/* Digital twin - the real X2 model, posed and shaded by live telemetry.
The robot already reports all 31 revolute joint positions and efforts at
100 Hz, and the dashboard already streams them. This tab draws them, which
turns a table of numbers into something you can check against the machine in
front of you: an arm folded in the picture but straight in the room means the
encoder, the model, or your idea of which robot you are connected to is wrong.
ON LOAD, AND WHY IT IS NOT TEMPERATURE
--------------------------------------
This robot does not publish joint temperature. aimdk_msgs/JointState carries
name, position, velocity, effort and error_code and nothing else; the
GetAllJointState service returns the same type; /diagnostics is the Orbbec
camera's own internals; and across all 53 aimdk_msgs types the only
temperature fields are pmu_temperature and battery_temperature. The
norealtime DCU joint topics may carry more, but their message type is not
installed anywhere on the robot, so nothing here can deserialise them.
So this shades by *effort*, which is honest and nearly as useful: motor
heating is I squared R, and current tracks torque, so the joint pulling
hardest is the joint getting hottest. Every number on screen is measured.
Load is shown as a percentage of each joint's RATED torque, taken from the
URDF's <limit effort="..."> and baked into model.json. That matters: rated
torque here runs from 0.6 Nm to 120 Nm, so an absolute Nm ramp would paint
every wrist permanently cold and every leg permanently hot regardless of what
the robot was doing.
Geometry is baked offline by build_model.py - 112 MB of vendor STL down to
1.4 MB. Nothing here parses a URDF at runtime.
*/
import { store, num } from '../core.js';
import { el, card, pageHead, button, badge, note, toggle, segmented, stat, emptyState } from '../ui.js';
import { RobotModel } from '../model3d.js';
const POSES = {
zero: {},
stand: {
left_hip_pitch_joint: -0.30, right_hip_pitch_joint: -0.30,
left_knee_joint: 0.62, right_knee_joint: 0.62,
left_ankle_pitch_joint: -0.32, right_ankle_pitch_joint: -0.32,
left_shoulder_roll_joint: -0.10, right_shoulder_roll_joint: 0.10,
left_elbow_joint: -0.30, right_elbow_joint: -0.30,
},
crouch: {
left_hip_pitch_joint: -1.20, right_hip_pitch_joint: -1.20,
left_knee_joint: 2.00, right_knee_joint: 2.00,
left_ankle_pitch_joint: -0.85, right_ankle_pitch_joint: -0.85,
waist_pitch_joint: 0.25,
left_elbow_joint: -0.60, right_elbow_joint: -0.60,
},
tpose: { left_shoulder_roll_joint: -1.57, right_shoulder_roll_joint: 1.57 },
wave: {
right_shoulder_pitch_joint: -0.4, right_shoulder_roll_joint: 1.30,
right_elbow_joint: -1.30, right_wrist_pitch_joint: 0.3,
left_shoulder_roll_joint: -0.10,
},
};
// Cool grey through blue, green, amber, red. Grey rather than deep blue at the
// bottom so an idle joint reads as "nothing happening" instead of "measured
// zero", which matters when half the robot is parked.
const LOAD_RAMP = [
[0.42, 0.46, 0.52],
[0.23, 0.51, 0.87],
[0.29, 0.76, 0.55],
[0.95, 0.75, 0.16],
[0.90, 0.28, 0.20],
];
function rampColour(t) {
const clamped = Math.max(0, Math.min(1, t));
const scaled = clamped * (LOAD_RAMP.length - 1);
const index = Math.min(LOAD_RAMP.length - 2, Math.floor(scaled));
const frac = scaled - index;
const a = LOAD_RAMP[index], b = LOAD_RAMP[index + 1];
return [a[0] + (b[0] - a[0]) * frac,
a[1] + (b[1] - a[1]) * frac,
a[2] + (b[2] - a[2]) * frac];
}
function css(colour) {
return `rgb(${colour.map((c) => Math.round(c * 255)).join(',')})`;
}
export default {
id: 'model',
label: 'Twin',
icon: 'motion',
async render() {
const root = el('div.stack');
const statusBadge = badge('loading…');
const canvas = el('canvas', {
style: { width: '100%', height: '520px', display: 'block', cursor: 'grab',
borderRadius: 'var(--radius-sm)', touchAction: 'none' },
});
const camera = { yaw: -0.9, pitch: 0.18, distance: 3.0, target: [0, 0, 0.75], fov: 0.75 };
let robot = null;
let source = 'live';
let shading = 'load'; // 'load' | 'plain'
let spinning = false;
let frame = null;
let disposed = false;
const manual = {};
// joint name -> {link, rated}. Built once the model is loaded: shading
// paints links, but telemetry is per joint, and the link a joint drives is
// its child in the URDF tree.
const jointMeta = new Map();
root.appendChild(pageHead(
'Digital twin',
'The real X2 model, posed by live joint telemetry and shaded by how hard each '
+ 'joint is working. Drag to orbit, scroll to zoom.',
[
segmented(
[{ value: 'load', label: 'Load' }, { value: 'plain', label: 'Plain' }],
shading,
(value) => { shading = value; legend.style.display = value === 'load' ? '' : 'none'; },
),
segmented(
[{ value: 'live', label: 'Follow robot' }, { value: 'pose', label: 'Poses' }],
source,
(value) => {
source = value;
poseRow.style.display = value === 'pose' ? '' : 'none';
statusBadge.textContent = value === 'live' ? 'following robot' : 'manual pose';
statusBadge.dataset.tone = value === 'live' ? 'good' : 'accent';
},
),
statusBadge,
],
));
/* -- Load tiles -------------------------------------------------------- */
const tiles = el('div.grid.cols-4');
const refs = {};
for (const [key, label] of [
['peak', 'Highest load'],
['total', 'Total torque'],
['busy', 'Joints under load'],
['faults', 'Joints reporting error'],
]) {
// sub: ' ' rather than '' so the .stat-sub element always exists and
// setTile can write into it. Same pattern as the Navigation tab.
const node = stat(label, '—', { sub: ' ' });
refs[key] = node;
tiles.appendChild(node);
}
root.appendChild(tiles);
function setTile(key, value, sub, tone = 'default', unit = '') {
const node = refs[key];
if (!node) return;
node.dataset.tone = tone;
const valueNode = node.querySelector('.stat-value');
valueNode.textContent = String(value);
if (unit) valueNode.appendChild(el('span.unit', { text: unit }));
node.querySelector('.stat-sub').textContent = sub || '';
}
/* -- Legend and hardest-working list ----------------------------------- */
const legend = el('div.row', {
style: { gap: '10px', alignItems: 'center', flexWrap: 'wrap' },
},
el('span', { text: 'Load', style: { fontSize: '11.5px', color: 'var(--text-3)' } }),
(() => {
const bar = el('div', {
style: {
height: '10px', width: '190px', borderRadius: '5px',
border: '1px solid var(--border)',
background: `linear-gradient(90deg, ${LOAD_RAMP.map(css).join(',')})`,
},
});
return bar;
})(),
el('span', { text: '0 % → 100 % of rated torque',
style: { fontSize: '11.5px', color: 'var(--text-3)' } }),
);
const poseRow = el('div.row', { style: { gap: '8px', flexWrap: 'wrap', display: 'none' } },
...Object.keys(POSES).map((name) => button(
name === 'tpose' ? 'T-pose' : name[0].toUpperCase() + name.slice(1),
() => applyPose(name),
{ size: 'sm' },
)),
);
const hardest = el('div.stack', { style: { gap: '5px' } });
root.appendChild(card('X2 Ultra', {
sub: '41 links · 31 revolute joints · baked from x2_ultra.urdf',
actions: [
toggle('Spin', false, (on) => { spinning = on; }),
button('Reset view', () => {
camera.yaw = -0.9; camera.pitch = 0.18; camera.distance = 3.0;
camera.target = [0, 0, 0.75];
}, { size: 'sm', style: 'ghost' }),
],
flush: true,
foot: el('div.stack', { style: { gap: '10px' } }, legend, poseRow),
}, canvas));
root.appendChild(card('Working hardest', {
sub: 'Measured torque against each joints rated limit, highest first',
}, hardest));
root.appendChild(note(
'This robot does not report joint temperature — aimdk_msgs/JointState carries only '
+ 'position, velocity, effort and an error code, and no service or topic on the unit '
+ 'exposes motor temperature. Colour here is measured torque as a share of each joints '
+ 'rated limit, which is the closest honest indicator of which joints are heating: motor '
+ 'heating rises with current, and current tracks torque.',
'info',
));
/* -- Boot -------------------------------------------------------------- */
try {
robot = new RobotModel(canvas);
} catch (err) {
root.replaceChildren(pageHead('Digital twin', 'The 3D view could not start.'),
emptyState('WebGL unavailable', err.message));
return { node: root };
}
try {
const [modelRes, geometryRes] = await Promise.all([
fetch('/model/model.json', { cache: 'force-cache' }),
fetch('/model/model.bin', { cache: 'force-cache' }),
]);
if (!modelRes.ok || !geometryRes.ok) {
throw new Error('The baked model is not on the server (web/model/).');
}
const model = await modelRes.json();
const geometry = await geometryRes.arrayBuffer();
robot.load(model, geometry);
for (const joint of model.joints) {
if (joint.type !== 'revolute') continue;
jointMeta.set(joint.name, {
link: joint.child,
// Fall back to a mid-range rating rather than 0 - dividing by zero
// would paint an unrated joint permanently red.
rated: joint.effort && joint.effort > 0 ? joint.effort : 40,
});
}
statusBadge.textContent = 'following robot';
statusBadge.dataset.tone = 'good';
} catch (err) {
robot.dispose();
root.replaceChildren(
pageHead('Digital twin', 'The 3D model could not be loaded.'),
emptyState('Model missing', err.message),
);
return { node: root };
}
/* -- Interaction -------------------------------------------------------- */
let dragging = null;
canvas.addEventListener('pointerdown', (event) => {
dragging = { x: event.clientX, y: event.clientY };
canvas.setPointerCapture(event.pointerId);
canvas.style.cursor = 'grabbing';
});
canvas.addEventListener('pointermove', (event) => {
if (!dragging) return;
camera.yaw -= (event.clientX - dragging.x) * 0.008;
camera.pitch = Math.max(-1.35, Math.min(1.35,
camera.pitch + (event.clientY - dragging.y) * 0.008));
dragging = { x: event.clientX, y: event.clientY };
});
for (const type of ['pointerup', 'pointercancel']) {
canvas.addEventListener(type, () => { dragging = null; canvas.style.cursor = 'grab'; });
}
canvas.addEventListener('wheel', (event) => {
event.preventDefault();
camera.distance = Math.max(0.8, Math.min(12,
camera.distance * (1 + Math.sign(event.deltaY) * 0.1)));
}, { passive: false });
function applyPose(name) {
source = 'pose';
poseRow.style.display = '';
statusBadge.textContent = 'manual pose';
statusBadge.dataset.tone = 'accent';
for (const key of robot.jointAngles.keys()) manual[key] = 0;
Object.assign(manual, POSES[name] || {});
}
/* -- Live telemetry ------------------------------------------------------ */
function readJoints() {
const groups = store.state?.joints || {};
const rows = [];
for (const list of Object.values(groups)) {
if (!Array.isArray(list)) continue;
for (const joint of list) {
if (joint && typeof joint.position === 'number') rows.push(joint);
}
}
return rows;
}
/* -- Frame loop ---------------------------------------------------------- */
const styles = getComputedStyle(document.documentElement);
function background() {
const raw = (styles.getPropertyValue('--surface-2') || '').trim();
const match = /^#?([0-9a-f]{6})$/i.exec(raw);
if (!match) return [0.09, 0.10, 0.12];
const value = parseInt(match[1], 16);
return [((value >> 16) & 255) / 255, ((value >> 8) & 255) / 255, (value & 255) / 255];
}
let lastPanel = 0;
function loop() {
if (disposed) return;
frame = requestAnimationFrame(loop);
if (spinning && !dragging) camera.yaw += 0.004;
const rows = source === 'live' ? readJoints() : [];
const angles = {};
if (source === 'live') {
for (const joint of rows) angles[joint.name] = joint.position;
} else {
Object.assign(angles, manual);
}
robot.setJoints(angles);
// Shading. In pose mode there is no live torque to show, so the model
// stays neutral rather than freezing the last real reading onto a pose
// the robot is not actually holding.
robot.linkColours.clear();
let loads = [];
if (shading === 'load' && source === 'live') {
for (const joint of rows) {
const meta = jointMeta.get(joint.name);
if (!meta) continue;
const effort = Math.abs(joint.effort || 0);
const share = effort / meta.rated;
robot.linkColours.set(meta.link, rampColour(share));
loads.push({ name: joint.name, effort, share, rated: meta.rated,
error: joint.error || 0 });
}
}
robot.render(camera, background());
const now = Date.now();
if (now - lastPanel > 300) {
lastPanel = now;
paintPanels(loads);
}
}
function paintPanels(loads) {
if (!loads.length) {
const why = source === 'live' ? 'Waiting for joint telemetry…'
: 'Manual pose — no live torque to show.';
hardest.replaceChildren(el('div', {
text: why, style: { fontSize: '12.5px', color: 'var(--text-3)' },
}));
for (const key of ['peak', 'total', 'busy', 'faults']) setTile(key, '—', '');
return;
}
loads.sort((a, b) => b.share - a.share);
const peak = loads[0];
const total = loads.reduce((sum, l) => sum + l.effort, 0);
const busy = loads.filter((l) => l.share >= 0.25).length;
const faults = loads.filter((l) => l.error).length;
setTile('peak', num(peak.share * 100, 0),
`${peak.name.replace(/_joint$/, '').replace(/_/g, ' ')} · ${num(peak.effort, 1)} Nm`,
peak.share > 0.8 ? 'critical' : peak.share > 0.5 ? 'warning' : 'good', ' %');
setTile('total', num(total, 0), 'sum across 31 joints', 'default', ' Nm');
setTile('busy', busy, 'above 25 % of rated',
busy > 6 ? 'warning' : 'default');
setTile('faults', faults, faults ? 'check the Motion tab' : 'all clear',
faults ? 'critical' : 'good');
hardest.replaceChildren(...loads.slice(0, 6).map((l) => el('div.row', {
style: { gap: '10px', alignItems: 'center' },
},
el('span', {
text: l.name.replace(/_joint$/, '').replace(/_/g, ' '),
style: { fontSize: '12px', width: '170px', flexShrink: '0' },
}),
el('div', {
style: {
flex: '1', height: '8px', borderRadius: '4px',
background: 'var(--surface-2)', overflow: 'hidden', minWidth: '60px',
},
},
el('div', {
style: {
width: `${Math.max(2, Math.min(100, l.share * 100))}%`, height: '100%',
background: css(rampColour(l.share)), transition: 'width .25s',
},
}),
),
el('span', {
text: `${num(l.effort, 1)} / ${num(l.rated, 0)} Nm`,
style: { fontSize: '11px', fontFamily: 'var(--mono)', color: 'var(--text-3)',
width: '110px', textAlign: 'right', flexShrink: '0' },
}),
el('span', {
text: `${num(l.share * 100, 0)}%`,
style: { fontSize: '11.5px', fontWeight: '600', width: '42px',
textAlign: 'right', flexShrink: '0', color: css(rampColour(l.share)) },
}),
)));
}
loop();
return {
node: root,
dispose: () => {
disposed = true;
if (frame) cancelAnimationFrame(frame);
robot.dispose();
},
};
},
};

View File

@ -0,0 +1,321 @@
/* Motion - joint-level control and end effectors. */
import {
store, command, num, RAD2DEG, DEG2RAD,
} from '../core.js';
import {
el, card, pageHead, button, note, segmented, range, table, badge, emptyState,
} from '../ui.js';
import { bars } from '../charts.js';
export default {
id: 'motion',
label: 'Motion',
icon: 'motion',
async render() {
const spec = store.spec;
const root = el('div.stack');
root.appendChild(pageHead(
'Motion',
'Drive joints and end effectors directly. Limits shown are the published X2 '
+ 'Ultra ranges; the server clamps anything outside them before publishing.',
));
/* ==================================================================
Joint control
================================================================== */
const groups = spec?.joint_groups || [];
let activeGroup = groups[0]?.key || 'arm';
let controlMode = 'position';
const sliders = new Map(); // joint name -> range control
const targets = new Map(); // joint name -> radians
const editorHost = el('div');
const stateHost = el('div');
const stiffness = range({ min: 0, max: 200, step: 1, value: 60, precision: 0, unit: ' N·m/rad' });
const damping = range({ min: 0, max: 20, step: 0.5, value: 3, precision: 1, unit: ' N·m·s/rad' });
function buildEditor() {
const group = groups.find((g) => g.key === activeGroup);
if (!group) { editorHost.replaceChildren(emptyState('Unknown joint group')); return; }
sliders.clear();
const rows = el('div.stack', { style: { gap: '10px' } });
for (const joint of group.joints) {
const fixed = joint.min_deg === joint.max_deg;
const current = targets.get(joint.name) ?? 0;
const control = range({
min: joint.min_deg, max: joint.max_deg,
step: fixed ? 1 : 0.5,
value: current * RAD2DEG,
precision: 1, unit: '°',
onInput: (deg) => targets.set(joint.name, deg * DEG2RAD),
});
if (fixed) {
control.querySelector('input').disabled = true;
control.style.opacity = '.5';
}
sliders.set(joint.name, control);
rows.appendChild(el('div', {},
el('div.row.between', { style: { marginBottom: '3px' } },
el('span', { text: joint.label, style: { fontSize: '12px', fontWeight: '550' } }),
el('span', {
text: fixed ? 'fixed' : `${joint.min_deg}° … ${joint.max_deg}°`,
style: { fontSize: '10.5px', color: 'var(--text-3)', fontFamily: 'var(--mono)' },
}),
),
control,
));
}
editorHost.replaceChildren(rows);
}
async function sendTargets() {
const group = groups.find((g) => g.key === activeGroup);
if (!group) return;
const payload = {};
for (const joint of group.joints) {
if (joint.min_deg === joint.max_deg) continue;
payload[joint.name] = targets.get(joint.name) ?? 0;
}
await command('/api/joints', {
group: activeGroup,
mode: controlMode,
targets: payload,
stiffness: stiffness.getValue(),
damping: damping.getValue(),
}, { successTitle: `${group.label} command sent` });
}
function zeroTargets() {
const group = groups.find((g) => g.key === activeGroup);
for (const joint of group?.joints || []) {
targets.set(joint.name, 0);
sliders.get(joint.name)?.setValue(0);
}
}
function syncFromRobot() {
const live = store.state?.joints?.[activeGroup] || [];
for (const joint of live) {
targets.set(joint.name, joint.position);
sliders.get(joint.name)?.setValue(joint.position * RAD2DEG);
}
}
const groupTabs = segmented(
groups.map((g) => ({ value: g.key, label: g.label })),
activeGroup,
(value) => { activeGroup = value; buildEditor(); paintState(); },
);
const modeTabs = segmented(
(spec?.joint_modes || []).map((m) => ({ value: m.id, label: m.label })),
controlMode,
(value) => { controlMode = value; },
);
buildEditor();
root.appendChild(el('div.grid.pair', {},
card('Joint targets', { sub: 'JointCommandArray', actions: [groupTabs] },
el('div.stack', {},
el('div.row.between', {},
el('span', { text: 'Control mode', style: { fontSize: '12px', color: 'var(--text-2)' } }),
modeTabs,
),
editorHost,
el('div.grid.cols-2', { style: { gap: '10px' } },
el('div', {},
el('div', { text: 'Stiffness', style: { fontSize: '12px', color: 'var(--text-2)', marginBottom: '4px' } }),
stiffness,
),
el('div', {},
el('div', { text: 'Damping', style: { fontSize: '12px', color: 'var(--text-2)', marginBottom: '4px' } }),
damping,
),
),
el('div.row', {},
button('Send targets', sendTargets, { style: 'primary' }),
button('Sync from robot', syncFromRobot),
button('Zero all', zeroTargets, { style: 'ghost' }),
),
note('Head pitch has a published range of 0° on the X2 Ultra — the axis exists in the '
+ 'message but is not articulated, so its slider is disabled.', 'info'),
),
),
card('Measured joint state', { sub: 'JointStateArray' }, stateHost),
));
/* ==================================================================
End effectors
================================================================== */
let handSide = 'right';
const handSliders = [];
const handHost = el('div');
const handStateHost = el('div');
function buildHand() {
const joints = spec?.dexhand_joints || [];
handSliders.length = 0;
const rows = el('div.stack', { style: { gap: '8px' } });
joints.forEach((name, index) => {
const control = range({ min: 0, max: 1.6, step: 0.02, value: 0, precision: 2, unit: ' rad' });
handSliders.push(control);
rows.appendChild(el('div', {},
el('div', { text: name, style: { fontSize: '11.5px', color: 'var(--text-2)', marginBottom: '2px' } }),
control,
));
});
handHost.replaceChildren(rows);
}
async function sendHand() {
await command('/api/hand', {
side: handSide,
positions: handSliders.map((s) => s.getValue()),
}, { successTitle: `${handSide} hand` });
}
async function applyHandPreset(preset) {
preset.positions.forEach((value, index) => handSliders[index]?.setValue(value));
await command('/api/hand', { side: handSide, preset: preset.key },
{ successTitle: `${preset.label} · ${handSide}` });
}
buildHand();
const handSendBtn = button('Send hand pose', sendHand, { style: 'primary' });
const presetRow = el('div.row.tight', {},
...(spec?.hand_presets || []).map((preset) =>
button(preset.label, () => applyHandPreset(preset), { size: 'sm' })),
);
const sideTabs = segmented(
[{ value: 'left', label: 'Left' }, { value: 'right', label: 'Right' }],
handSide,
(value) => { handSide = value; paintState(); },
);
root.appendChild(el('div.grid.pair', {},
card('Dexterous hand', { sub: 'HandCommandArray · OmniHand', actions: [sideTabs] },
el('div.stack', {},
presetRow,
handHost,
handSendBtn,
note('Disable native motor control first — run "aima em stop-app mc" on the robot — or the '
+ 'built-in controller and these commands will fight each other.', 'warning', '⚠'),
),
),
card('Hand state', { sub: 'HandStateArray' }, handStateHost),
));
/* ==================================================================
Live state
================================================================== */
function paintState() {
const s = store.state;
if (!s) return;
const group = groups.find((g) => g.key === activeGroup);
const live = s.joints?.[activeGroup] || [];
const limits = Object.fromEntries((group?.joints || []).map((j) => [j.name, j]));
if (!live.length) {
stateHost.replaceChildren(emptyState(
'No joint state yet',
`Nothing has been received on ${group?.state_topic || 'the joint state topic'}.`,
));
} else {
const rows = live.map((joint) => {
const limit = limits[joint.name] || { min_deg: -180, max_deg: 180 };
const deg = joint.position * RAD2DEG;
const range_ = (limit.max_deg - limit.min_deg) || 1;
const headroom = Math.min(deg - limit.min_deg, limit.max_deg - deg) / range_;
return {
label: joint.label || joint.name,
value: deg,
min: limit.min_deg,
max: limit.max_deg,
colorIndex: 0,
tone: joint.error ? 'critical' : headroom < 0.03 ? 'warning' : 'default',
};
});
stateHost.replaceChildren(
bars(rows, { precision: 1, unit: '°' }),
el('div', { style: { marginTop: '14px' } },
table([
{ key: 'name', label: 'Joint', get: (r) => r.label || r.name },
{ key: 'pos', label: 'Position', align: 'right', get: (r) => `${num(r.position * RAD2DEG, 1)}°` },
{ key: 'vel', label: 'Velocity', align: 'right', get: (r) => num(r.velocity, 3) },
{ key: 'eff', label: 'Effort', align: 'right', get: (r) => num(r.effort, 2) },
{
key: 'err', label: 'Fault',
get: (r) => (r.error ? badge(`code ${r.error}`, 'critical') : badge('ok', 'good')),
},
], live),
),
);
}
const hand = s.hand_state?.[handSide] || [];
const attached = (s.hand_type || 'None') !== 'None';
handStateHost.replaceChildren(
el('div.row', { style: { marginBottom: '10px' } },
badge(s.hand_type || 'unknown', attached ? 'accent' : 'warning'),
badge(`${hand.length} joints reported`),
),
hand.length
? bars(hand.map((joint) => ({
label: joint.name, value: joint.position, min: 0, max: 1.6, colorIndex: 2,
})), { precision: 2, unit: ' rad', diverging: false })
: emptyState(
attached ? 'No hand state yet' : 'No hand hardware attached',
attached
? 'Nothing received on /aima/hal/joint/hand/state.'
: 'GetHandType reports NONE for both sides on this robot, and the state message '
+ 'carries no joints. Attach an OmniHand or OmniPicker to use this panel.',
),
);
// Sending hand commands with no hand attached would silently do nothing.
for (const control of handSliders) {
control.querySelector('input').disabled = !attached;
}
handSendBtn.disabled = !attached;
for (const btn of presetRow.children) btn.disabled = !attached;
}
paintState();
let last = 0;
const unsubscribe = store.on('state', () => {
const now = Date.now();
if (now - last < 600) return;
last = now;
paintState();
});
return {
node: root,
dispose: () => {
unsubscribe();
},
};
},
};

View File

@ -0,0 +1,276 @@
/* Navigation - odometry, heading and travelled path.
This firmware does not expose the documented SLAM command interface
(/integrated_command, /relocalization_pose, GetStoredMapByName are all absent
from the live graph), so no mapping controls are offered - a button that
silently does nothing is worse than no button. What the robot does publish is
leg odometry, and that is what this tab shows.
*/
import { store, num, RAD2DEG } from '../core.js';
import { el, card, pageHead, button, note, badge, stat, kv, emptyState } from '../ui.js';
import { compass, attitude } from '../charts.js';
export default {
id: 'navigation',
label: 'Navigation',
icon: 'nav',
async render() {
const root = el('div.stack');
const trail = [];
let following = true;
root.appendChild(pageHead(
'Navigation',
'Where the robot has been, from leg odometry. Values advance while it walks and hold '
+ 'steady when it is parked.',
[
button('Clear path', () => { trail.length = 0; drawTrail(); }, { size: 'sm', style: 'ghost' }),
],
));
/* -- Tiles ------------------------------------------------------------ */
const tiles = el('div.grid.cols-4');
const refs = {};
for (const [key, label] of [
['x', 'Position X'],
['y', 'Position Y'],
['heading', 'Heading'],
['speed', 'Ground speed'],
['distance', 'Distance travelled'],
['points', 'Path points'],
['mode', 'Motion mode'],
['status', 'Odometry'],
]) {
const node = stat(label, '—', { sub: ' ' });
refs[key] = node;
tiles.appendChild(node);
}
root.appendChild(tiles);
function setTile(key, value, sub, tone = 'default', unit = '') {
const node = refs[key];
if (!node) return;
node.dataset.tone = tone;
const valueNode = node.querySelector('.stat-value');
valueNode.textContent = String(value);
if (unit) valueNode.appendChild(el('span.unit', { text: unit }));
node.querySelector('.stat-sub').textContent = sub || '';
}
/* -- Heading and attitude --------------------------------------------- */
const compassHost = el('div.attitude');
const attitudeHost = el('div.attitude');
const readoutHost = el('div');
const trailCanvas = el('canvas', {
width: 960, height: 620,
style: { width: '100%', height: 'auto', display: 'block', borderRadius: 'var(--radius-sm)' },
});
root.appendChild(el('div.grid.split', {},
card('Heading and attitude', { sub: 'Odometry yaw · chest IMU' },
el('div.stack', { style: { alignItems: 'center' } },
el('div.row', { style: { justifyContent: 'center', gap: '14px', flexWrap: 'wrap' } },
compassHost, attitudeHost,
),
readoutHost,
),
),
card('Travelled path', {
sub: 'Top-down, 1 m grid',
actions: [
(() => {
const box = el('input', { type: 'checkbox', checked: true });
box.addEventListener('change', () => { following = box.checked; drawTrail(); });
return el('label.switch', {}, box, el('span.switch-track'),
el('span.switch-label', { text: 'Follow robot' }));
})(),
],
flush: true,
}, trailCanvas),
));
root.appendChild(note(
'SLAM mapping and relocalization are not offered here: this firmware does not advertise the '
+ '/integrated_command topic or the GetStoredMapByName service, so there is nothing for the '
+ 'dashboard to call. Use the AGIBOT mobile app for map building on this unit.',
'info',
));
/* -- Trail rendering --------------------------------------------------- */
const context = trailCanvas.getContext('2d');
function drawTrail() {
const width = trailCanvas.width;
const height = trailCanvas.height;
const styles = getComputedStyle(document.documentElement);
const surface = styles.getPropertyValue('--surface-2').trim() || '#1b1e22';
const gridColor = styles.getPropertyValue('--grid').trim() || '#22262c';
const axis = styles.getPropertyValue('--axis').trim() || '#333941';
const line = styles.getPropertyValue('--series-1').trim() || '#3987e5';
const marker = styles.getPropertyValue('--series-2').trim() || '#d95926';
const muted = styles.getPropertyValue('--text-3').trim() || '#6d7681';
context.fillStyle = surface;
context.fillRect(0, 0, width, height);
if (!trail.length) {
context.fillStyle = muted;
context.font = '15px system-ui, sans-serif';
context.textAlign = 'center';
context.fillText('Waiting for odometry — walk the robot to draw a path',
width / 2, height / 2);
return;
}
const xs = trail.map((p) => p.x);
const ys = trail.map((p) => p.y);
const last = trail.at(-1);
const spanX = Math.max(2, Math.max(...xs) - Math.min(...xs));
const spanY = Math.max(2, Math.max(...ys) - Math.min(...ys));
const pad = 44;
const scale = Math.min((width - pad * 2) / spanX, (height - pad * 2) / spanY, 110);
const centreX = following ? last.x : (Math.min(...xs) + Math.max(...xs)) / 2;
const centreY = following ? last.y : (Math.min(...ys) + Math.max(...ys)) / 2;
// Robot X is forward and Y is left; canvas y grows downward, so flip it.
const X = (x) => width / 2 + (x - centreX) * scale;
const Y = (y) => height / 2 - (y - centreY) * scale;
const step = scale >= 60 ? 1 : scale >= 25 ? 2 : 5;
context.lineWidth = 1;
const firstX = Math.floor(centreX - width / 2 / scale) - 1;
const lastX = Math.ceil(centreX + width / 2 / scale) + 1;
const firstY = Math.floor(centreY - height / 2 / scale) - 1;
const lastY = Math.ceil(centreY + height / 2 / scale) + 1;
for (let m = Math.ceil(firstX / step) * step; m <= lastX; m += step) {
context.strokeStyle = m === 0 ? axis : gridColor;
context.beginPath(); context.moveTo(X(m), 0); context.lineTo(X(m), height); context.stroke();
}
for (let m = Math.ceil(firstY / step) * step; m <= lastY; m += step) {
context.strokeStyle = m === 0 ? axis : gridColor;
context.beginPath(); context.moveTo(0, Y(m)); context.lineTo(width, Y(m)); context.stroke();
}
// Scale key.
context.fillStyle = muted;
context.font = '11px system-ui, sans-serif';
context.textAlign = 'left';
context.fillText(`${step} m grid`, 12, height - 12);
if (trail.length > 1) {
context.strokeStyle = line;
context.lineWidth = 2;
context.lineJoin = 'round';
context.lineCap = 'round';
context.beginPath();
trail.forEach((point, index) => {
const px = X(point.x), py = Y(point.y);
if (index === 0) context.moveTo(px, py); else context.lineTo(px, py);
});
context.stroke();
// Start marker with a surface ring so it stays legible over the line.
const start = trail[0];
context.beginPath();
context.arc(X(start.x), Y(start.y), 5, 0, Math.PI * 2);
context.fillStyle = surface;
context.fill();
context.strokeStyle = line;
context.lineWidth = 2;
context.stroke();
}
// Robot marker, pointing along its heading.
context.save();
context.translate(X(last.x), Y(last.y));
context.rotate(-last.yaw);
context.fillStyle = marker;
context.strokeStyle = surface;
context.lineWidth = 2.5;
context.beginPath();
context.moveTo(14, 0); context.lineTo(-9, 8); context.lineTo(-4, 0); context.lineTo(-9, -8);
context.closePath();
context.fill();
context.stroke();
context.restore();
}
/* -- Painting ---------------------------------------------------------- */
let distance = 0;
function paint() {
const s = store.state;
if (!s) return;
const odom = s.odom || { x: 0, y: 0, yaw: 0 };
const vel = s.velocity || {};
const chest = s.imu?.chest || {};
const previous = trail.at(-1);
const moved = !previous || Math.hypot(odom.x - previous.x, odom.y - previous.y) > 0.015;
if (moved) {
if (previous) distance += Math.hypot(odom.x - previous.x, odom.y - previous.y);
trail.push({ x: odom.x, y: odom.y, yaw: odom.yaw });
if (trail.length > 4000) trail.shift();
} else if (previous) {
previous.yaw = odom.yaw;
}
const speed = Math.hypot(vel.forward || 0, vel.lateral || 0);
const yawDeg = ((odom.yaw || 0) * RAD2DEG + 360) % 360;
setTile('x', num(odom.x, 2), 'forward positive', 'default', ' m');
setTile('y', num(odom.y, 2), 'left positive', 'default', ' m');
setTile('heading', num(yawDeg, 0), 'counter-clockwise positive', 'default', '°');
setTile('speed', num(speed, 2), `yaw ${num(vel.angular, 2)} rad/s`, 'default', ' m/s');
setTile('distance', num(distance, 1), 'since this tab opened', 'default', ' m');
setTile('points', trail.length, 'samples held');
const modeSpec = store.spec?.modes?.find((m) => m.id === s.mode);
setTile('mode', modeSpec?.label || s.mode || '—', modeSpec?.group || '',
modeSpec?.danger ? 'critical' : 'default');
const stat = s.topic_stats?.['/aima/mc/leg_odometry'];
const age = stat ? (Date.now() / 1000) - stat.last : null;
const live = age !== null && age < 2;
setTile('status', live ? 'Live' : 'Idle',
live ? `${num(stat.hz, 0)} Hz` : 'no recent messages',
live ? 'good' : 'default');
compassHost.replaceChildren(compass(odom.yaw || 0, { size: 150 }));
attitudeHost.replaceChildren(attitude(chest.roll || 0, chest.pitch || 0, { size: 150 }));
readoutHost.replaceChildren(kv([
['Roll', `${num((chest.roll || 0) * RAD2DEG, 2)}°`],
['Pitch', `${num((chest.pitch || 0) * RAD2DEG, 2)}°`],
['Yaw (odometry)', `${num(yawDeg, 1)}°`],
['Forward', `${num(vel.forward, 3)} m/s`],
['Lateral', `${num(vel.lateral, 3)} m/s`],
['Yaw rate', `${num(vel.angular, 3)} rad/s`],
]));
drawTrail();
}
paint();
let last = 0;
const unsubscribe = store.on('state', () => {
const now = Date.now();
if (now - last < 250) return;
last = now;
paint();
});
return { node: root, dispose: unsubscribe };
},
};

View File

@ -0,0 +1,222 @@
/* Overview - the at-a-glance page. One hero figure, supporting tiles, trends. */
import { store, get, num, duration, batteryTone, tempTone } from '../core.js';
import { el, card, stat, meter, note, pageHead, button, kv } from '../ui.js';
import { timeSeries, sparkline, attitude, compass } from '../charts.js';
export default {
id: 'overview',
label: 'Overview',
icon: 'overview',
async render() {
const root = el('div.stack');
/* -- Hero ------------------------------------------------------------- */
const heroNumber = el('span', { text: '—' });
const heroValue = el('div.hero', {}, heroNumber, el('span.unit', { text: '%' }));
const meterHost = el('div');
const heroSub = el('div.stat-sub', { text: 'Battery remaining' });
const heroSpark = el('div', { style: { marginTop: '8px', height: '40px' } });
/* -- Tiles ------------------------------------------------------------ */
const tiles = el('div.grid.cols-3');
const tileRefs = {};
for (const [key, label] of [
['mode', 'Motion mode'],
['transport', 'Link'],
['uptime', 'Bridge uptime'],
['speed', 'Ground speed'],
['heading', 'Heading'],
['temp', 'Battery temp'],
]) {
const node = stat(label, '—', { sub: ' ' });
tileRefs[key] = node;
tiles.appendChild(node);
}
function setTile(key, value, sub, tone = 'default', unit = '') {
const node = tileRefs[key];
if (!node) return;
node.dataset.tone = tone;
const valueNode = node.querySelector('.stat-value');
valueNode.textContent = String(value);
if (unit) valueNode.appendChild(el('span.unit', { text: unit }));
node.querySelector('.stat-sub').textContent = sub || '';
}
/* -- Attitude --------------------------------------------------------- */
const attitudeHost = el('div.attitude');
const compassHost = el('div.attitude');
const attitudeReadout = el('div');
/* -- Charts ----------------------------------------------------------- */
const velChart = timeSeries([
{ key: 'vel_forward', label: 'Forward', points: [], colorIndex: 0, unit: ' m/s' },
{ key: 'vel_lateral', label: 'Lateral', points: [], colorIndex: 1, unit: ' m/s' },
{ key: 'vel_angular', label: 'Yaw rate', points: [], colorIndex: 2, unit: ' rad/s' },
], { height: 176, precision: 2, zeroLine: true });
const batteryChart = timeSeries(
[{ key: 'battery_pct', label: 'Battery', points: [], colorIndex: 0, unit: '%' }],
{ height: 168, precision: 1 },
);
const tempChart = timeSeries([
{ key: 'battery_temp', label: 'Battery', points: [], colorIndex: 1, unit: ' °C' },
{ key: 'pmu_temp', label: 'PMU', points: [], colorIndex: 3, unit: ' °C' },
], { height: 168, precision: 1 });
/* -- Assemble --------------------------------------------------------- */
root.appendChild(pageHead(
'Overview',
'Live condition of the robot. Everything here is read-only — use Control to act.',
[button('Refresh trends', () => refreshCharts(), { iconName: 'refresh', size: 'sm', style: 'ghost' })],
));
root.appendChild(el('div.grid.split', {},
card('Battery', { sub: 'PMU · 0.2 Hz' },
el('div.stack', { style: { gap: '10px' } }, heroValue, meterHost, heroSub, heroSpark),
),
tiles,
));
root.appendChild(el('div.grid.cols-2', {},
card('Attitude', { sub: 'Chest IMU' },
el('div.row', { style: { justifyContent: 'space-around', alignItems: 'center', gap: '16px' } },
attitudeHost, compassHost,
),
el('div', { style: { marginTop: '14px' } }, attitudeReadout),
),
card('Velocity', { sub: 'Measured, last 2 minutes' }, velChart),
));
root.appendChild(el('div.grid.cols-2', {},
card('Battery trend', { sub: 'Last 2 minutes' }, batteryChart),
card('Thermals', { sub: 'Battery cell and PMU' }, tempChart),
));
const safety = (store.spec?.safety_notes || []).filter((n) => n.level !== 'info');
if (safety.length) {
root.appendChild(card('Before you operate', {},
el('div.stack', { style: { gap: '8px' } },
...safety.map((n) => note(n.text, n.level, n.level === 'critical' ? '⚠' : 'ⓘ')),
),
));
}
/* -- Painting --------------------------------------------------------- */
let meterNode = meter(0);
meterHost.appendChild(meterNode);
function paint() {
const s = store.state;
if (!s) return;
const pct = s.battery_pct;
const tone = batteryTone(pct);
heroNumber.textContent = pct === null || pct === undefined ? '—' : num(pct, 1);
heroValue.style.color = tone === 'critical' ? 'var(--critical)'
: tone === 'warning' ? 'var(--warning)' : 'var(--text)';
const nextMeter = meter((pct || 0) / 100, tone);
meterNode.replaceWith(nextMeter);
meterNode = nextMeter;
const { battery_voltage: voltage, battery_current: current } = s;
heroSub.textContent = [
voltage != null ? `${num(voltage, 1)} V` : null,
current != null ? `${num(Math.abs(current), 1)} A ${current > 0 ? 'charging' : 'draw'}` : null,
s.battery_cycles ? `${s.battery_cycles} cycles` : null,
].filter(Boolean).join(' · ') || 'Battery remaining';
const modeSpec = store.spec?.modes?.find((m) => m.id === s.mode);
setTile('mode', modeSpec?.label || s.mode || '—', modeSpec?.desc || '',
s.mode === 'PASSIVE_DEFAULT' ? 'critical' : 'default');
const online = s.connection?.online;
const agent = s.custom?.agent;
setTile('transport',
s.connection?.simulated ? 'Simulated' : online ? 'Live' : 'Robot off',
s.connection?.simulated
? 'No robot attached'
: online
? `${agent?.hostname || 'agent'} · ROS domain ${s.connection?.ros_domain_id ?? 0}`
: 'Agent unreachable',
online ? (s.connection.simulated ? 'warning' : 'good') : 'critical');
setTile('uptime', duration(s.connection?.uptime_s), s.connection?.host || '');
const speed = Math.hypot(s.velocity?.forward || 0, s.velocity?.lateral || 0);
setTile('speed', num(speed, 2), 'linear, from odometry', 'default', ' m/s');
const yawDeg = ((s.odom?.yaw || 0) * 180 / Math.PI + 360) % 360;
setTile('heading', num(yawDeg, 0),
`x ${num(s.odom?.x, 2)} m · y ${num(s.odom?.y, 2)} m`, 'default', '°');
setTile('temp', num(s.battery_temp, 1), 'cell temperature',
tempTone(s.battery_temp, 45, 55), ' °C');
const chest = s.imu?.chest || {};
attitudeHost.replaceChildren(attitude(chest.roll || 0, chest.pitch || 0, { size: 148 }));
compassHost.replaceChildren(compass(s.odom?.yaw || 0, { size: 148 }));
attitudeReadout.replaceChildren(kv([
['Roll', `${num((chest.roll || 0) * 180 / Math.PI, 2)}°`],
['Pitch', `${num((chest.pitch || 0) * 180 / Math.PI, 2)}°`],
['Yaw', `${num((chest.yaw || 0) * 180 / Math.PI, 2)}°`],
['Vertical accel', `${num(chest.accel_z, 2)} m/s²`],
]));
}
async function refreshCharts() {
try {
const keys = 'battery_pct,battery_temp,pmu_temp,vel_forward,vel_lateral,vel_angular';
const data = await get(`/api/series?keys=${keys}&limit=400`);
batteryChart.update([
{ key: 'battery_pct', label: 'Battery', points: data.battery_pct || [], colorIndex: 0, unit: '%' },
]);
tempChart.update([
{ key: 'battery_temp', label: 'Battery', points: data.battery_temp || [], colorIndex: 1, unit: ' °C' },
{ key: 'pmu_temp', label: 'PMU', points: data.pmu_temp || [], colorIndex: 3, unit: ' °C' },
]);
velChart.update([
{ key: 'vel_forward', label: 'Forward', points: data.vel_forward || [], colorIndex: 0, unit: ' m/s' },
{ key: 'vel_lateral', label: 'Lateral', points: data.vel_lateral || [], colorIndex: 1, unit: ' m/s' },
{ key: 'vel_angular', label: 'Yaw rate', points: data.vel_angular || [], colorIndex: 2, unit: ' rad/s' },
]);
const history = data.battery_pct || [];
heroSpark.replaceChildren(
history.length > 2 ? sparkline(history, { height: 40, colorIndex: 0 }) : el('span'),
);
} catch {
// A failed trend refresh is not worth interrupting the operator over -
// the live tiles above are still updating from the WebSocket.
}
}
paint();
refreshCharts();
// Twice a second reads as live without repainting SVG on every frame.
let lastPaint = 0;
const unsubscribe = store.on('state', () => {
const now = Date.now();
if (now - lastPaint < 500) return;
lastPaint = now;
paint();
});
const timer = setInterval(refreshCharts, 3000);
return { node: root, dispose: () => { unsubscribe(); clearInterval(timer); } };
},
};

View File

@ -0,0 +1,246 @@
/* Power - battery detail, rails, thermals. */
import { store, get, num, batteryTone, tempTone, duration } from '../core.js';
import { el, card, pageHead, stat, meter, badge, table, note, emptyState, kv } from '../ui.js';
import { timeSeries, sparkline } from '../charts.js';
export default {
id: 'power',
label: 'Power',
icon: 'power',
async render() {
const spec = store.spec;
const root = el('div.stack');
root.appendChild(pageHead(
'Power',
'Power management unit telemetry. Published at 0.2 Hz, so these values move slowly by design.',
));
/* -- Headline --------------------------------------------------------- */
const tiles = el('div.grid.cols-4');
const refs = {};
for (const [key, label] of [
['level', 'Charge'],
['voltage', 'Pack voltage'],
['current', 'Pack current'],
['cell_temp', 'Cell temp'],
['pmu_temp', 'PMU temp'],
['fan', 'Fan'],
['cycles', 'Cycles'],
['runtime', 'Est. remaining'],
]) {
const node = stat(label, '—', { sub: ' ' });
refs[key] = node;
tiles.appendChild(node);
}
root.appendChild(tiles);
function setTile(key, value, sub, tone = 'default', unit = '') {
const node = refs[key];
if (!node) return;
node.dataset.tone = tone;
const valueNode = node.querySelector('.stat-value');
valueNode.textContent = String(value);
if (unit) valueNode.appendChild(el('span.unit', { text: unit }));
node.querySelector('.stat-sub').textContent = sub || '';
}
/* -- Charts ----------------------------------------------------------- */
const chargeChart = timeSeries(
[{ key: 'battery_pct', label: 'Charge', points: [], colorIndex: 0, unit: '%' }],
{ height: 190, precision: 1, yMin: 0, yMax: 100 },
);
const electricalChart = timeSeries([
{ key: 'battery_voltage', label: 'Voltage', points: [], colorIndex: 0, unit: ' V' },
], { height: 190, precision: 2 });
const currentChart = timeSeries([
{ key: 'battery_current', label: 'Current', points: [], colorIndex: 1, unit: ' A' },
], { height: 190, precision: 2, zeroLine: true });
const thermalChart = timeSeries([
{ key: 'battery_temp', label: 'Battery cell', points: [], colorIndex: 1, unit: ' °C' },
{ key: 'pmu_temp', label: 'PMU', points: [], colorIndex: 3, unit: ' °C' },
{ key: 'fan_pct', label: 'Fan duty', points: [], colorIndex: 2, unit: ' %' },
], { height: 190, precision: 1 });
root.appendChild(el('div.grid.cols-2', {},
card('State of charge', { sub: 'Last 2 minutes' }, chargeChart),
card('Thermals and cooling', { sub: 'Battery, PMU, fan duty' }, thermalChart),
));
root.appendChild(el('div.grid.cols-2', {},
card('Pack voltage', {}, electricalChart),
card('Pack current', { sub: 'Positive is charging' }, currentChart),
));
/* -- Rails ------------------------------------------------------------ */
const railHost = el('div');
root.appendChild(card('Power rails', { sub: 'Per-rail voltage and draw' }, railHost));
/* -- Hardware identity ------------------------------------------------ */
const infoHost = el('div');
root.appendChild(card('Battery and PMU hardware', {
sub: 'Reported by the pack itself',
}, infoHost));
/* -- Raw -------------------------------------------------------------- */
const rawHost = el('div');
root.appendChild(card('Raw PmuState', {
sub: 'Every field the message carried, unmapped',
}, rawHost));
/* -- Painting --------------------------------------------------------- */
function paint() {
const s = store.state;
if (!s) return;
const pct = s.battery_pct;
const tone = batteryTone(pct);
setTile('level', pct == null ? '—' : num(pct, 1), s.charging ? 'charging' : 'discharging', tone, '%');
setTile('voltage', num(s.battery_voltage, 2), 'pack terminal', 'default', ' V');
setTile('current', num(s.battery_current, 2),
s.battery_current > 0 ? 'into pack' : 'out of pack', 'default', ' A');
setTile('cell_temp', num(s.battery_temp, 1), 'cell', tempTone(s.battery_temp, 45, 55), ' °C');
setTile('pmu_temp', num(s.pmu_temp, 1), 'controller', tempTone(s.pmu_temp, 60, 75), ' °C');
setTile('fan', num(s.fan_pct, 0), s.fan_rpm ? `${num(s.fan_rpm, 0)} rpm` : '', 'default', '%');
setTile('cycles', s.battery_cycles ?? '—', 'charge cycles');
// Runtime projection from the observed drain rate.
const runtime = estimateRuntime(pct);
setTile('runtime', runtime.label, runtime.sub, runtime.tone);
/* Rails */
const rails = s.rails || {};
const railRows = (spec?.pmu_rails || []).map((definition) => {
const live = rails[definition.key];
const voltage = live?.voltage;
const nominal = definition.nominal;
const deviation = voltage != null && nominal ? Math.abs(voltage - nominal) / nominal : null;
const ok = live?.ok !== false && (deviation === null || deviation < 0.12);
return {
label: definition.label,
nominal: `${num(nominal, 1)} V`,
voltage: voltage == null ? '—' : `${num(voltage, 2)} V`,
current: live?.current == null ? '—' : `${num(live.current, 2)} A`,
power: voltage != null && live?.current != null
? `${num(voltage * live.current, 1)} W` : '—',
status: live ? badge(ok ? 'nominal' : 'out of range', ok ? 'good' : 'critical')
: badge('no data', 'default'),
};
});
railHost.replaceChildren(
railRows.length
? table([
{ key: 'label', label: 'Rail' },
{ key: 'nominal', label: 'Nominal', align: 'right' },
{ key: 'voltage', label: 'Measured', align: 'right' },
{ key: 'current', label: 'Current', align: 'right' },
{ key: 'power', label: 'Power', align: 'right' },
{ key: 'status', label: 'Status' },
], railRows)
: emptyState('No rail data', 'The PMU message has not been received yet.'),
);
/* Hardware identity */
const info = s.custom?.pmu_info || {};
const infoPairs = (spec?.pmu_info_fields || [])
.map(([field, label]) => [label, info[field]])
.filter(([, value]) => value);
infoHost.replaceChildren(
infoPairs.length
? el('div', { style: { columnWidth: '260px', columnGap: '28px' } }, kv(infoPairs))
: emptyState('No hardware identity reported',
'The PMU message has not arrived yet.'),
);
/* Raw fields */
const raw = s.custom?.pmu_raw;
if (raw && Object.keys(raw).length) {
const pairs = Object.entries(raw)
.filter(([, v]) => typeof v !== 'object')
.map(([k, v]) => [k, typeof v === 'number' ? num(v, 3) : String(v)]);
rawHost.replaceChildren(el('div', { style: { columnWidth: '260px', columnGap: '28px' } }, kv(pairs)));
} else {
rawHost.replaceChildren(emptyState(
'No raw message captured',
store.bridge?.simulated
? 'The simulator publishes mapped values only; connect a real robot to see the full PmuState.'
: 'Waiting for the first /aima/hal/pmu/state message.',
));
}
}
function estimateRuntime(pct) {
const history = seriesCache.battery_pct || [];
if (pct == null || history.length < 20) {
return { label: '—', sub: 'needs more history', tone: 'default' };
}
const [t0, v0] = history[0];
const [t1, v1] = history.at(-1);
const elapsed = t1 - t0;
const drop = v0 - v1;
if (elapsed < 10 || drop <= 0.005) {
return { label: '—', sub: 'not discharging', tone: 'default' };
}
const minutes = (pct / (drop / elapsed)) / 60;
return {
label: duration(minutes * 60),
sub: `at ${num((drop / elapsed) * 3600, 1)} %/h`,
tone: minutes < 15 ? 'critical' : minutes < 40 ? 'warning' : 'good',
};
}
const seriesCache = {};
async function refreshCharts() {
try {
const keys = 'battery_pct,battery_voltage,battery_current,battery_temp,pmu_temp,fan_pct';
const data = await get(`/api/series?keys=${keys}&limit=600`);
Object.assign(seriesCache, data);
chargeChart.update([
{ key: 'battery_pct', label: 'Charge', points: data.battery_pct || [], colorIndex: 0, unit: '%' },
]);
electricalChart.update([
{ key: 'battery_voltage', label: 'Voltage', points: data.battery_voltage || [], colorIndex: 0, unit: ' V' },
]);
currentChart.update([
{ key: 'battery_current', label: 'Current', points: data.battery_current || [], colorIndex: 1, unit: ' A' },
]);
thermalChart.update([
{ key: 'battery_temp', label: 'Battery cell', points: data.battery_temp || [], colorIndex: 1, unit: ' °C' },
{ key: 'pmu_temp', label: 'PMU', points: data.pmu_temp || [], colorIndex: 3, unit: ' °C' },
{ key: 'fan_pct', label: 'Fan duty', points: data.fan_pct || [], colorIndex: 2, unit: ' %' },
]);
} catch { /* best-effort */ }
}
paint();
await refreshCharts();
paint();
let last = 0;
const unsubscribe = store.on('state', () => {
const now = Date.now();
if (now - last < 1000) return;
last = now;
paint();
});
const timer = setInterval(() => { refreshCharts().then(paint); }, 4000);
return { node: root, dispose: () => { unsubscribe(); clearInterval(timer); } };
},
};

View File

@ -0,0 +1,249 @@
/* Sensors - IMU, touch, LiDAR and topic liveness. */
import { store, get, num, clockTime, RAD2DEG } from '../core.js';
import { el, card, pageHead, badge, table, stat, note, emptyState, kv, button } from '../ui.js';
import { timeSeries, attitude } from '../charts.js';
export default {
id: 'sensors',
label: 'Sensors',
icon: 'sensors',
async render() {
const spec = store.spec;
const root = el('div.stack');
root.appendChild(pageHead(
'Sensors',
'Hardware abstraction layer feeds — IMU, touch, LiDAR — and how fresh each topic is.',
));
/* -- IMU -------------------------------------------------------------- */
const imuHosts = {
chest: { attitude: el('div.attitude'), readout: el('div') },
torso: { attitude: el('div.attitude'), readout: el('div') },
};
const imuChart = timeSeries([
{ key: 'imu_roll', label: 'Roll', points: [], colorIndex: 0, unit: '°' },
{ key: 'imu_pitch', label: 'Pitch', points: [], colorIndex: 1, unit: '°' },
], { height: 176, precision: 2, zeroLine: true });
root.appendChild(el('div.grid.cols-2', {},
card('Chest IMU', { sub: '/aima/hal/imu/chest/state · 500 Hz' },
el('div.row', { style: { gap: '18px', alignItems: 'flex-start' } },
imuHosts.chest.attitude,
el('div', { style: { flex: '1', minWidth: '160px' } }, imuHosts.chest.readout),
),
),
card('Torso IMU', { sub: '/aima/hal/imu/torso/state · 500 Hz' },
el('div.row', { style: { gap: '18px', alignItems: 'flex-start' } },
imuHosts.torso.attitude,
el('div', { style: { flex: '1', minWidth: '160px' } }, imuHosts.torso.readout),
),
),
));
root.appendChild(card('Attitude history', { sub: 'Chest IMU, degrees' }, imuChart));
/* -- Touch + LiDAR ---------------------------------------------------- */
const touchHost = el('div');
const odomHost = el('div');
root.appendChild(el('div.grid.cols-2', {},
card('Head touch', { sub: '/aima/hal/sensor/touch_head · 8 zones · 100 Hz' }, touchHost),
card('Odometry', { sub: '/aima/mc/leg_odometry' }, odomHost),
));
/* -- Topic health ----------------------------------------------------- */
const healthHost = el('div');
root.appendChild(card('Topic health', {
sub: 'Measured rate against the documented rate',
actions: [button('Refresh', () => refreshTopics(), { size: 'sm', style: 'ghost', iconName: 'refresh' })],
}, healthHost));
let tracked = [];
async function refreshTopics() {
try {
const data = await get('/api/topics');
tracked = data.tracked || [];
paintHealth();
} catch {
healthHost.replaceChildren(emptyState('Could not read topic statistics'));
}
}
function paintHealth() {
const documented = spec?.sensor_topics || [];
const byTopic = Object.fromEntries(tracked.map((t) => [t.topic, t]));
const now = Date.now() / 1000;
const rows = documented.map((doc) => {
const live = byTopic[doc.topic];
const age = live ? now - live.last : null;
const stale = age === null || age > 3;
const rateRatio = live && doc.rate_hz ? live.hz / doc.rate_hz : null;
let tone = 'critical';
let label = 'silent';
if (live && !stale) {
if (rateRatio === null || rateRatio > 0.6) { tone = 'good'; label = 'healthy'; }
else { tone = 'warning'; label = 'slow'; }
} else if (live) {
tone = 'warning'; label = 'stale';
}
return {
topic: doc.topic,
label: doc.label,
type: doc.type,
expected: doc.rate_hz ? `${doc.rate_hz} Hz` : '—',
actual: live ? `${num(live.hz, 1)} Hz` : '—',
count: live ? live.count.toLocaleString() : '0',
age: age === null ? '—' : `${num(age, 1)} s`,
status: badge(label, tone),
};
});
// Anything the bridge saw that is not in the documented list still matters.
for (const live of tracked) {
if (documented.some((d) => d.topic === live.topic)) continue;
rows.push({
topic: live.topic, label: '—', type: '—',
expected: '—', actual: `${num(live.hz, 1)} Hz`,
count: live.count.toLocaleString(),
age: `${num(now - live.last, 1)} s`,
status: badge('extra', 'accent'),
});
}
healthHost.replaceChildren(table([
{ key: 'label', label: 'Sensor' },
{ key: 'topic', label: 'Topic' },
{ key: 'type', label: 'Type' },
{ key: 'expected', label: 'Expected', align: 'right' },
{ key: 'actual', label: 'Measured', align: 'right' },
{ key: 'count', label: 'Messages', align: 'right' },
{ key: 'age', label: 'Last seen', align: 'right' },
{ key: 'status', label: 'Status' },
], rows, { empty: 'No topics observed yet' }));
}
/* -- Painting --------------------------------------------------------- */
function paintImu(key) {
const data = store.state?.imu?.[key];
const host = imuHosts[key];
if (!host) return;
if (!data) {
host.attitude.replaceChildren();
host.readout.replaceChildren(emptyState('No data', 'Nothing received on this IMU topic.'));
return;
}
host.attitude.replaceChildren(attitude(data.roll || 0, data.pitch || 0, { size: 132 }));
host.readout.replaceChildren(kv([
['Roll', `${num((data.roll || 0) * RAD2DEG, 2)}°`],
['Pitch', `${num((data.pitch || 0) * RAD2DEG, 2)}°`],
['Yaw', `${num((data.yaw || 0) * RAD2DEG, 2)}°`],
['Accel X', `${num(data.accel_x, 2)} m/s²`],
['Accel Y', `${num(data.accel_y, 2)} m/s²`],
['Accel Z', `${num(data.accel_z, 2)} m/s²`],
['Gyro Z', `${num(data.gyro_z, 3)} rad/s`],
]));
}
function paint() {
const s = store.state;
if (!s) return;
paintImu('chest');
paintImu('torso');
const touch = s.touch_head || {};
const zones = Array.isArray(touch.zones) ? touch.zones : [];
const raw = Array.isArray(touch.data) ? touch.data : [];
touchHost.replaceChildren(
el('div.row', { style: { marginBottom: '12px' } },
badge(touch.touched ? 'Contact' : 'No contact', touch.touched ? 'good' : 'default'),
badge(`${zones.filter(Boolean).length} of ${zones.length || 8} active`),
),
zones.length
? el('div', {
style: { display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '6px' },
},
...zones.map((active, index) => el('div', {
style: {
padding: '13px 6px', textAlign: 'center',
borderRadius: 'var(--radius-sm)',
background: active ? 'var(--accent-soft)' : 'var(--surface-2)',
border: `1px solid ${active ? 'var(--accent)' : 'var(--border)'}`,
fontSize: '11px', fontWeight: '600',
color: active ? 'var(--accent)' : 'var(--text-3)',
transition: 'background .15s, border-color .15s',
},
},
el('div', { text: `Z${index + 1}` }),
raw.length > index
? el('div', {
text: String(raw[index]),
style: { fontSize: '9.5px', fontFamily: 'var(--mono)', opacity: '.75' },
})
: null,
)),
)
: note('No TouchState message received yet.', 'default'),
);
const odom = s.odom || {};
const vel = s.velocity || {};
odomHost.replaceChildren(
kv([
['Position X', `${num(odom.x, 3)} m`],
['Position Y', `${num(odom.y, 3)} m`],
['Heading', `${num((odom.yaw || 0) * RAD2DEG, 1)}°`],
['Forward speed', `${num(vel.forward, 3)} m/s`],
['Lateral speed', `${num(vel.lateral, 3)} m/s`],
['Yaw rate', `${num(vel.angular, 3)} rad/s`],
]),
el('div', { style: { marginTop: '12px' } },
note('Leg odometry publishes while the robot is walking. It sits still in Passive or '
+ 'Damping mode, which is why these values stop updating when the robot is parked.',
'info'),
),
);
}
async function refreshChart() {
try {
const data = await get('/api/series?keys=imu_roll,imu_pitch&limit=400');
const toDeg = (points) => (points || []).map(([t, v]) => [t, v * RAD2DEG]);
imuChart.update([
{ key: 'imu_roll', label: 'Roll', points: toDeg(data.imu_roll), colorIndex: 0, unit: '°' },
{ key: 'imu_pitch', label: 'Pitch', points: toDeg(data.imu_pitch), colorIndex: 1, unit: '°' },
]);
} catch { /* chart refresh is best-effort */ }
}
paint();
refreshTopics();
refreshChart();
let last = 0;
const unsubscribe = store.on('state', () => {
const now = Date.now();
if (now - last < 400) return;
last = now;
paint();
});
const timer = setInterval(() => { refreshTopics(); refreshChart(); }, 3000);
return { node: root, dispose: () => { unsubscribe(); clearInterval(timer); } };
},
};

View File

@ -0,0 +1,606 @@
/* Settings - robot discovery, network, transport and safety limits.
This is where the "works on any IP" promise lives: the dashboard never stores
a fixed robot address, it discovers one on whatever network you are currently
attached to, and it reports every address it is itself reachable on.
*/
import { store, get, post, toast, num } from '../core.js';
import {
el, card, pageHead, button, note, badge, table, input, select, field,
emptyState, toggle, range, kv, segmented, setChildren,
} from '../ui.js';
export default {
id: 'settings',
label: 'Settings',
icon: 'settings',
async render() {
const root = el('div.stack');
let config = store.settings?.values || {};
let network = store.network || {};
root.appendChild(pageHead(
'Settings',
'Connection, transport and safety limits. Changes are saved to config.json immediately.',
));
/* ==================================================================
Robot connection + discovery
================================================================== */
const hostInput = input({ value: config.robot_host || '', placeholder: 'IP address or hostname' });
const probeResult = el('div');
const scanResult = el('div');
const scanProgress = el('div', { style: { display: 'none' } });
const saveHost = async (value) => {
const data = await post('/api/settings', { robot_host: value });
config = data.settings.values;
store.settings = data.settings;
toast('Robot host saved', value || 'cleared',
'good');
if (data.needs_restart) restartNote.style.display = '';
};
const probeBtn = button('Test', async (node) => {
const host = hostInput.value.trim();
if (!host) { hostInput.focus(); return; }
node.disabled = true;
probeResult.replaceChildren(el('span.muted', { text: 'Probing…' }));
try {
const data = await post('/api/network/probe', { host });
const info = data.detail || {};
setChildren(probeResult,
el('div.row', {},
badge(data.ok ? 'reachable' : 'no answer', data.ok ? 'good' : 'critical'),
info.hostname ? badge(info.hostname) : null,
info.open_ports?.length ? badge(`ports ${info.open_ports.join(', ')}`, 'accent') : null,
badge(`${num(info.latency_ms, 0)} ms`),
),
info.is_pc1
? note('This is PC1, the motion-control unit. The documentation forbids using it as a '
+ 'build or run host. Point the dashboard at PC2 instead.', 'critical', '⚠')
: null,
);
} catch (err) {
probeResult.replaceChildren(note(err.message, 'critical', '⚠'));
} finally {
node.disabled = false;
}
});
const scanBtn = button('Scan this network', async (node) => {
node.disabled = true;
scanProgress.style.display = '';
scanProgress.replaceChildren(el('span.muted', { text: 'Starting sweep…' }));
scanResult.replaceChildren();
try {
const data = await post('/api/network/scan', {}, );
const hosts = data.hosts || [];
scanResult.replaceChildren(
el('div.row', { style: { marginBottom: '10px' } },
badge(`${hosts.length} responding`, hosts.length ? 'good' : 'warning'),
...(data.networks || []).map((n) => badge(n, 'accent')),
),
hosts.length
? table([
{ key: 'host', label: 'Address' },
{ key: 'hostname', label: 'Name', get: (r) => r.hostname || '—' },
{ key: 'ports', label: 'Open ports', get: (r) => r.open_ports.join(', ') },
{ key: 'latency', label: 'Latency', align: 'right', get: (r) => `${num(r.latency_ms, 0)} ms` },
{
key: 'tags', label: '',
get: (r) => el('div.row.tight', {},
r.is_self ? badge('this machine', 'default') : null,
r.is_pc1 ? badge('PC1 — do not use', 'critical') : null,
),
},
{
key: 'use', label: '',
get: (r) => button('Use', async () => {
hostInput.value = r.host;
await saveHost(r.host);
}, { size: 'sm', style: r.is_pc1 ? 'warn' : 'primary' }),
},
], hosts)
: emptyState('Nothing answered',
'No host on this subnet responded on the ports an X2 exposes. '
+ 'Check that the robot is powered and on the same network.'),
);
} catch (err) {
scanResult.replaceChildren(note(err.message, 'critical', '⚠'));
} finally {
node.disabled = false;
scanProgress.style.display = 'none';
}
}, { iconName: 'scan', style: 'primary' });
const restartNote = el('div', { style: { display: 'none' } },
note('Transport settings changed. Restart the bridge to apply them.', 'warning', '⚠'),
);
const restartBtn = button('Restart bridge', async (node) => {
node.disabled = true;
try {
const data = await post('/api/bridge/restart');
toast(data.ok ? 'Bridge restarted' : 'Restart failed', data.message,
data.ok ? 'good' : 'critical');
if (data.ok) {
restartNote.style.display = 'none';
const boot = await get('/api/bootstrap');
store.bridge = boot.bridge;
store.state = boot.state;
}
} catch (err) {
toast('Restart failed', err.message, 'critical');
} finally {
node.disabled = false;
}
}, { iconName: 'refresh' });
root.appendChild(card('Robot connection', { sub: 'No fixed address is stored' },
el('div.stack', {},
field('Robot host',
el('div.row', { style: { gap: '8px', flexWrap: 'nowrap' } },
hostInput,
probeBtn,
button('Save', () => saveHost(hostInput.value.trim())),
),
'Leave empty to rely on ROS 2 DDS multicast discovery, which finds the robot on the local '
+ 'segment without an address. Set it when you want the dashboard to report and probe a '
+ 'specific host.',
),
probeResult,
el('div.row.between', {},
el('span', { text: 'Find the robot automatically', style: { fontSize: '12px', color: 'var(--text-2)' } }),
scanBtn,
),
scanProgress,
scanResult,
note('The sweep covers whichever subnets this machine is on right now. Move to a different '
+ 'Wi-Fi network and scan again — nothing is cached.', 'info'),
el('div', { style: { borderTop: '1px solid var(--border-soft)', margin: '4px 0 2px' } }),
toggle('Find the robot automatically if its address changes', config.auto_discover,
(value) => saveSetting('auto_discover', value)),
toggle('Start the agent over SSH if the robot is on but not answering',
config.auto_start_agent,
(value) => saveSetting('auto_start_agent', value)),
el('div.grid.cols-2', { style: { gap: '10px' } },
field('SSH user',
input({
value: config.robot_ssh_user,
onchange: (e) => saveSetting('robot_ssh_user', e.target.value),
}),
),
field('SSH password',
input({
type: 'password',
value: config.robot_ssh_password,
placeholder: config.robot_ssh_password ? '' : 'not saved',
onchange: (e) => saveSetting('robot_ssh_password', e.target.value),
}),
),
),
note('The robot cannot start the dashboard agent by itself: the agi account is not '
+ 'allowed to enable systemd lingering, and the robot\'s clock jumps backwards after '
+ 'boot, which stalls cron. With these credentials the dashboard logs in and starts '
+ 'it for you after a power cycle. Stored in config.json on this machine.',
'warning', '⚠'),
restartNote,
),
));
/* ==================================================================
Dashboard reachability
================================================================== */
const urlHost = el('div');
function paintUrls() {
const urls = network.urls || [];
const primary = network.address?.ip
? `http://${network.address.ip}:${network.port}` : null;
const describe = (url) => {
if (url === primary) return { label: 'use this — works everywhere', tone: 'good' };
if (url.includes('localhost') || url.includes('127.0.0.1')) {
return { label: 'this machine only', tone: 'default' };
}
// Names need mDNS, which many access points block between clients.
if (!/\/\/\d+\.\d+\.\d+\.\d+/.test(url)) {
return { label: 'needs mDNS — often blocked on Wi-Fi', tone: 'warning' };
}
return { label: 'other adapter', tone: 'accent' };
};
urlHost.replaceChildren(
el('div.stack', { style: { gap: '8px' } },
...urls.map((url) => {
const meta = describe(url);
return el('div.row.between', {
style: {
padding: '10px 13px', background: 'var(--surface-2)',
border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)',
},
},
el('span', { text: url, style: { fontFamily: 'var(--mono)', fontSize: '12.5px' } }),
el('div.row.tight', {},
badge(meta.label, meta.tone),
button('Copy', async () => {
try {
await navigator.clipboard.writeText(url);
toast('Copied', url, 'good', 1800);
} catch {
toast('Copy blocked', 'Select and copy the address manually.', 'warning');
}
}, { size: 'sm', style: 'ghost' }),
),
);
}),
),
);
}
const nameHost = el('div');
function paintName() {
const adv = network.advertised;
setChildren(nameHost,
field('Dashboard name on the network',
el('div.row', { style: { gap: '8px', flexWrap: 'nowrap' } },
input({
value: config.dashboard_name,
placeholder: 'agibot',
onchange: (e) => saveSetting('dashboard_name', e.target.value.trim().toLowerCase()),
}),
adv?.running ? badge('publishing', 'good')
: adv?.conflict ? badge('name taken', 'critical')
: badge('not publishing', 'warning'),
),
'The dashboard publishes this over mDNS, so the link is about the robot rather than '
+ 'this PC. Takes effect when the server restarts.',
),
adv?.error ? el('div', { style: { marginTop: '8px' } }, note(adv.error, 'warning', '⚠')) : null,
);
}
const qrHost = el('div');
const devicesHost = el('div');
function paintQr() {
const detail = network.address || {};
if (!detail.ip) {
setChildren(qrHost,
note(`No network address right now — ${detail.reason_text || 'not connected'}. `
+ 'The dashboard keeps checking and will show the link the moment the '
+ 'network is back.', 'warning', '⚠'),
);
return;
}
const url = `http://${detail.ip}:${network.port}`;
setChildren(qrHost,
el('div.row', { style: { gap: '18px', alignItems: 'center', flexWrap: 'wrap' } },
el('img', {
src: `/api/qr?url=${encodeURIComponent(url)}&t=${Date.now()}`,
alt: `QR code for ${url}`,
style: {
width: '156px', height: '156px', background: '#fff',
padding: '8px', borderRadius: 'var(--radius-sm)',
},
}),
el('div', { style: { flex: '1', minWidth: '220px' } },
el('div', { text: 'Open this on your phone',
style: { fontWeight: '600', marginBottom: '6px' } }),
el('div', {
text: url,
style: { fontFamily: 'var(--mono)', fontSize: '17px', color: 'var(--text)' },
}),
(() => {
const kindLabel = detail.kind === 'wifi' ? 'Wi-Fi' : 'wired';
const adapter = detail.adapter || '';
return el('div.row.tight', { style: { marginTop: '8px' } },
badge(kindLabel, detail.kind === 'wifi' ? 'good' : 'accent'),
// The adapter is usually just called "Wi-Fi" too - only worth a
// second badge when it actually says something different.
adapter && adapter.toLowerCase() !== kindLabel.toLowerCase()
? badge(adapter) : null,
detail.gateway ? badge(`via ${detail.gateway}`) : null,
badge('live', 'good'),
);
})(),
el('div.hint', {
text: 'Read from the network adapter and re-checked continuously — if this PC '
+ 'gets a new address the link above updates by itself. Scan or type it; '
+ 'it needs no name resolution, so it works even where .local does not.',
style: { marginTop: '8px' },
}),
el('div.row.tight', { style: { marginTop: '8px' } },
button('Copy link', async () => {
try {
await navigator.clipboard.writeText(url);
toast('Copied', url, 'good', 1800);
} catch {
toast('Copy blocked', 'Select and copy it manually.', 'warning');
}
}, { size: 'sm' }),
),
),
),
(detail.rejected || []).length
? el('details', { style: { marginTop: '10px' } },
el('summary', {
text: `${detail.rejected.length} other address(es) ignored`,
style: { cursor: 'pointer', fontSize: '12px', color: 'var(--text-3)' },
}),
el('div', { style: { marginTop: '8px' } },
table([
{ key: 'ip', label: 'Address' },
{ key: 'adapter', label: 'Adapter' },
{ key: 'why', label: 'Why it is not used' },
], detail.rejected),
),
)
: null,
);
}
function paintDevices() {
const devices = network.other_devices || [];
setChildren(devicesHost,
el('div.row', { style: { marginBottom: '8px' } },
el('strong', { text: 'Devices that have opened this dashboard',
style: { fontSize: '12.5px' } }),
badge(String(devices.length), devices.length ? 'good' : 'warning'),
),
devices.length
? table([
{ key: 'address', label: 'Address' },
{ key: 'hits', label: 'Requests', align: 'right' },
{
key: 'agent', label: 'Browser',
get: (r) => {
const a = r.agent || '';
if (/iPhone|iPad/i.test(a)) return 'iPhone / iPad';
if (/Android/i.test(a)) return 'Android';
if (/Macintosh/i.test(a)) return 'Mac';
if (/Windows/i.test(a)) return 'Windows';
return a.slice(0, 28) || '—';
},
},
], devices)
: note('Nothing but this PC has loaded the dashboard yet. If your phone appears here '
+ 'after scanning the QR code, the network is fine and only the .local name is '
+ 'being blocked — which is the access point filtering multicast, not something '
+ 'this dashboard can fix.', 'warning', '⚠'),
);
}
root.appendChild(card('Open on another device', {
sub: 'The server binds every interface',
actions: [button('Refresh', async () => {
network = await get('/api/network');
store.network = network;
paintUrls();
paintName();
paintQr();
paintDevices();
}, { size: 'sm', style: 'ghost', iconName: 'refresh' })],
},
el('div.stack', {},
qrHost,
urlHost,
nameHost,
devicesHost,
note('The .local link goes over mDNS, which iPhone, iPad, Mac, Android 12+ and Windows '
+ 'understand. Many access points block multicast between wireless clients, and then '
+ 'no .local name resolves from a phone no matter what the PC does — scan the QR code '
+ 'instead.', 'info'),
),
));
paintUrls();
paintName();
paintQr();
paintDevices();
// The server pushes a "network" message whenever the detected address
// changes, so a page left open never shows a link that stopped working.
const unsubscribeNetwork = store.on('network', (data) => {
network = { ...network, ...data };
store.network = network;
paintUrls();
paintQr();
toast('Network address changed',
data.address?.ip ? `Now at ${data.address.ip}` : 'No network address',
data.address?.ip ? 'good' : 'warning');
});
/* ==================================================================
Transport
================================================================== */
const transportFields = el('div.stack');
function paintTransport() {
const bridge = store.bridge || {};
const online = store.state?.connection?.online;
setChildren(transportFields,
el('div.row', { style: { marginBottom: '4px' } },
badge(bridge.simulated ? 'Simulation' : online ? 'Live' : 'Robot off',
bridge.simulated ? 'warning' : online ? 'good' : 'critical'),
bridge.agent?.agent_version ? badge(`agent ${bridge.agent.agent_version}`, 'accent') : null,
bridge.agent?.hostname ? badge(bridge.agent.hostname) : null,
),
bridge.simulated
? note('No robot address is set, so the dashboard is driving the built-in simulator. '
+ 'Enter the robot address above and restart the bridge to attach to the real X2.',
'warning', '⚠')
: null,
bridge.error ? note(bridge.error, 'critical', '⚠') : null,
field('Bridge mode',
select([
{ value: 'auto', label: 'Auto — use the robot agent when an address is set' },
{ value: 'agent', label: 'Robot only — never fall back to simulation' },
{ value: 'mock', label: 'Simulation only — never touch a real robot' },
], {
value: config.bridge_mode,
onChange: (value) => saveSetting('bridge_mode', value),
}),
),
field('Agent port',
input({
type: 'number', min: 1, max: 65535, value: config.agent_port,
onchange: (e) => saveSetting('agent_port', Number(e.target.value)),
}),
'The TCP port x2_agent.py listens on aboard the robot.',
),
field('ROS_DOMAIN_ID',
input({
type: 'number', min: 0, max: 232, value: config.ros_domain_id,
onchange: (e) => saveSetting('ros_domain_id', Number(e.target.value)),
}),
'Reference only — the agent inherits the domain from the shell that launched it.',
),
el('div.row', {}, restartBtn),
);
}
/* ==================================================================
Safety and server
================================================================== */
const limits = store.spec?.velocity_limits || {};
const safetyCard = card('Safety limits', { sub: 'Applied server-side to every command' },
el('div.stack', {},
el('div', {},
el('div', { text: 'Max forward velocity', style: labelStyle }),
range({
min: 0.1, max: limits.forward?.max ?? 0.8, step: 0.05,
value: config.max_forward_velocity, precision: 2, unit: ' m/s',
onChange: (v) => saveSetting('max_forward_velocity', v),
}),
),
el('div', {},
el('div', { text: 'Max lateral velocity', style: labelStyle }),
range({
min: 0.1, max: limits.lateral?.max ?? 0.7, step: 0.05,
value: config.max_lateral_velocity, precision: 2, unit: ' m/s',
onChange: (v) => saveSetting('max_lateral_velocity', v),
}),
),
el('div', {},
el('div', { text: 'Max yaw rate', style: labelStyle }),
range({
min: 0.1, max: limits.angular?.max ?? 0.8, step: 0.05,
value: config.max_angular_velocity, precision: 2, unit: ' rad/s',
onChange: (v) => saveSetting('max_angular_velocity', v),
}),
),
el('div', {},
el('div', { text: 'Dead-man timeout', style: labelStyle }),
range({
min: 0.2, max: 3, step: 0.1,
value: config.locomotion_deadman_s, precision: 1, unit: ' s',
onChange: (v) => saveSetting('locomotion_deadman_s', v),
}),
el('div.hint', { text: 'Velocity is zeroed if the browser stops sending for this long.' }),
),
toggle('Confirm before zero-torque', config.require_confirm_zero_torque,
(value) => saveSetting('require_confirm_zero_torque', value)),
),
);
const serverCard = card('Server', { sub: store.settings?.config_path || 'config.json' },
el('div.stack', {},
field('Display name',
input({
value: config.robot_label,
onchange: (e) => saveSetting('robot_label', e.target.value),
}),
),
field('HTTP port',
input({
type: 'number', min: 1, max: 65535, value: config.port,
onchange: (e) => saveSetting('port', Number(e.target.value)),
}),
'Takes effect the next time the server starts.',
),
el('div', {},
el('div', { text: 'Telemetry rate', style: labelStyle }),
range({
min: 1, max: 30, step: 1, value: config.telemetry_hz, precision: 0, unit: ' Hz',
onChange: (v) => saveSetting('telemetry_hz', v),
}),
el('div.hint', { text: 'How often state is pushed to the browser. Lower it on a weak link.' }),
),
el('div', { style: { marginTop: '4px' } },
kv([
['Server version', store.server?.version || '—'],
['Connected clients', String(store.server?.clients ?? '—')],
['Config file', store.settings?.config_path || '—'],
]),
),
),
);
root.appendChild(el('div.grid.cols-2', {},
card('Transport', { sub: 'How the dashboard reaches the robot' }, transportFields),
safetyCard,
));
root.appendChild(serverCard);
paintTransport();
/* ==================================================================
Helpers
================================================================== */
async function saveSetting(key, value) {
try {
const data = await post('/api/settings', { [key]: value });
config = data.settings.values;
store.settings = data.settings;
if (data.needs_restart) restartNote.style.display = '';
toast('Saved', `${key} = ${value}`, 'good', 1800);
} catch (err) {
toast('Could not save', err.message, 'critical');
}
}
const unsubscribe = store.on('scan_progress', (data) => {
scanProgress.replaceChildren(
el('div', {},
el('div.row.between', { style: { fontSize: '12px', color: 'var(--text-2)', marginBottom: '5px' } },
el('span', { text: 'Sweeping subnet' }),
el('span', { text: `${data.done} / ${data.total}` }),
),
el('div.meter', {},
el('div.meter-fill', { style: { width: `${(data.done / data.total) * 100}%` } }),
),
),
);
});
return {
node: root,
dispose: () => { unsubscribe(); unsubscribeNetwork(); },
};
},
};
const labelStyle = { fontSize: '12px', color: 'var(--text-2)', marginBottom: '5px' };

View File

@ -0,0 +1,326 @@
/* Vision - camera feeds, each switched on by hand.
Nothing is subscribed on the robot until a feed is switched on here. That is
not a nicety: a frame off this robot is 170-430 KB, and the six RGB feeds
together publish at ~60 Hz. Subscribing to the lot at startup pushed roughly
15 MB/s through DDS for pictures nobody was looking at, on the same Wi-Fi the
robot uses to walk. Switching a feed off destroys the subscription on the
robot rather than merely hiding the <img>.
Frames are polled as single images rather than streamed, so a quiet topic
degrades into "nothing is publishing" instead of a hung connection.
*/
import { store, API, get, post, toast } from '../core.js';
import { el, card, pageHead, badge, segmented, note, emptyState, button } from '../ui.js';
export default {
id: 'vision',
label: 'Vision',
icon: 'vision',
async render() {
const cameras = store.spec?.cameras || [];
const root = el('div.stack');
const feeds = new Map();
let fps = 2;
const fpsControl = segmented(
[{ value: 1, label: '1 fps' }, { value: 2, label: '2 fps' },
{ value: 5, label: '5 fps' }, { value: 10, label: '10 fps' }],
fps,
(value) => { fps = value; for (const feed of feeds.values()) schedule(feed); },
);
root.appendChild(pageHead(
'Vision',
'Every feed is off until you switch it on — that keeps the robot from streaming '
+ 'pictures nobody is watching. Compressed feeds pass through untouched; depth is '
+ 'colourised on the robot.',
[
fpsControl,
button('Turn all off', () => stopAll(), { size: 'sm', style: 'ghost' }),
],
));
if (!cameras.length) {
root.appendChild(emptyState('No cameras declared', 'The backend reported an empty camera list.'));
return { node: root };
}
const grid = el('div.grid.cols-2');
root.appendChild(grid);
/* -- One card per camera ---------------------------------------------- */
for (const camera of cameras) {
const feed = {
camera,
key: camera.key,
active: false,
flip: !!camera.flip,
busy: false,
failures: 0,
timer: null,
objectUrl: null,
img: el('img', { alt: `${camera.label} live view`, decoding: 'async' }),
status: badge('off'),
meta: el('span', { text: '—' }),
};
feed.img.style.display = 'none';
feed.placeholder = el('div.cam-placeholder', { text: 'Switched off' });
const shell = el('div.cam', {}, feed.placeholder, feed.img,
el('div.cam-overlay', {},
el('span.cam-live', { text: camera.key }),
feed.meta,
),
);
// The power switch. Checked state follows the robot, not this browser -
// see applyStreams() - so two tabs never disagree about what is running.
const box = el('input', { type: 'checkbox' });
box.addEventListener('change', () => setActive(feed, box.checked));
feed.checkbox = box;
const power = el('label.switch', {}, box, el('span.switch-track'),
el('span.switch-label', { text: 'Off' }));
feed.powerLabel = power.querySelector('.switch-label');
const flipBtn = button('Rotate 180°', () => {
feed.flip = !feed.flip;
flipBtn.dataset.on = feed.flip ? '1' : '';
flipBtn.classList.toggle('btn-primary', feed.flip);
if (feed.active) tick(feed);
}, { size: 'sm', style: 'ghost' });
feed.flipBtn = flipBtn;
grid.appendChild(card(camera.label, {
sub: camera.topic,
actions: [power, feed.status],
flush: true,
foot: el('div.row.between', { style: { gap: '10px', flexWrap: 'wrap' } },
el('span', {
text: camera.note || (camera.kind === 'depth'
? 'Depth map, colourised on the robot.'
: `${camera.kind} · ~${camera.rate_hz || '?'} Hz`),
style: { fontSize: '11.5px', color: 'var(--text-3)' },
}),
flipBtn,
),
}, shell));
feeds.set(camera.key, feed);
}
/* -- Hardware inventory ------------------------------------------------ */
const TONE = {
live: 'good', unreachable: 'critical', intermittent: 'warning', absent: 'default',
};
const LABEL = {
live: 'publishing', unreachable: 'not reachable',
intermittent: 'only while running', absent: 'not fitted',
};
const inventory = store.spec?.camera_inventory || [];
if (inventory.length) {
root.appendChild(card('What cameras this robot has', {
sub: 'Confirmed by decoding a real frame off each topic, not read from the datasheet',
},
el('div.stack', { style: { gap: '10px' } },
...inventory.map((item) => el('div', {
style: {
padding: '12px 14px', borderRadius: 'var(--radius-sm)',
background: 'var(--surface-2)', border: '1px solid var(--border)',
},
},
el('div.row.between', { style: { marginBottom: '5px' } },
el('strong', { text: item.name, style: { fontSize: '13px' } }),
badge(LABEL[item.status] || item.status, TONE[item.status] || 'default'),
),
el('div', {
text: item.detail,
style: { fontSize: '12.5px', color: 'var(--text-2)', lineHeight: '1.55' },
}),
el('div', {
text: item.where,
style: { fontSize: '11px', color: 'var(--text-3)', fontFamily: 'var(--mono)',
marginTop: '5px', wordBreak: 'break-all' },
}),
)),
),
));
}
root.appendChild(note(
'Switching a feed off destroys the subscription on the robot, so an unwatched camera '
+ 'costs nothing. Closing this page turns every feed off by itself.',
'info',
));
/* -- Switching --------------------------------------------------------- */
async function setActive(feed, active) {
if (feed.busy) return;
feed.busy = true;
feed.checkbox.disabled = true;
setStatus(feed, active ? 'starting…' : 'stopping…', 'default');
try {
await post(`/api/streams/${feed.key}`, { active });
feed.active = active;
feed.failures = 0;
if (active) {
feed.placeholder.textContent = 'Waiting for the first frame…';
feed.placeholder.style.display = 'block';
schedule(feed);
} else {
stop(feed);
}
paintPower(feed);
} catch (err) {
toast('Could not switch that feed', err.message, 'critical');
feed.checkbox.checked = feed.active;
paintPower(feed);
} finally {
feed.busy = false;
feed.checkbox.disabled = false;
}
}
function paintPower(feed) {
feed.checkbox.checked = feed.active;
feed.powerLabel.textContent = feed.active ? 'On' : 'Off';
if (!feed.active) {
setStatus(feed, 'off', 'default');
feed.meta.textContent = '—';
feed.img.style.display = 'none';
feed.placeholder.style.display = 'block';
feed.placeholder.textContent = 'Switched off';
}
}
function setStatus(feed, text, tone) {
feed.status.textContent = text;
feed.status.dataset.tone = tone;
}
function stopAll() {
for (const feed of feeds.values()) {
if (feed.active) setActive(feed, false);
}
}
/* -- Polling ----------------------------------------------------------- */
// A feed that keeps coming back empty is retried slowly rather than at the
// full rate: the perception topics only publish while that module runs, and
// hammering them fills the console for something working as intended.
const IDLE_RETRY_MS = 10000;
const FAILURES_BEFORE_BACKOFF = 3;
function schedule(feed) {
if (feed.timer) clearTimeout(feed.timer);
feed.timer = null;
if (!feed.active) return;
const period = feed.failures >= FAILURES_BEFORE_BACKOFF ? IDLE_RETRY_MS : 1000 / fps;
feed.timer = setTimeout(() => tick(feed), period);
}
// Fetched rather than assigned straight to img.src: a 204 from a topic
// nobody publishes to is an ordinary answer here, whereas an <img> that
// fails to load logs a console error the page cannot suppress.
async function tick(feed) {
if (!feed.active) return;
const url = `${API}/api/camera/${feed.key}/frame`
+ `?flip=${feed.flip ? 'true' : 'false'}&t=${Date.now()}`;
try {
const res = await fetch(url, { cache: 'no-store' });
if (res.status === 204 || !res.ok) { onMiss(feed); return; }
const blob = await res.blob();
if (!blob.size) { onMiss(feed); return; }
const objectUrl = URL.createObjectURL(blob);
const recovered = feed.failures >= FAILURES_BEFORE_BACKOFF;
await new Promise((resolve) => {
feed.img.onload = resolve;
feed.img.onerror = resolve;
feed.img.src = objectUrl;
});
// Release the previous frame; without this every frame leaks.
if (feed.objectUrl) URL.revokeObjectURL(feed.objectUrl);
feed.objectUrl = objectUrl;
feed.failures = 0;
feed.img.style.display = 'block';
feed.placeholder.style.display = 'none';
setStatus(feed, 'live', 'good');
feed.meta.textContent =
`${feed.img.naturalWidth}×${feed.img.naturalHeight} · ${Math.round(blob.size / 1024)} KB`;
if (recovered) toast('Feed restored', feed.camera.label, 'good', 2500);
} catch {
onMiss(feed);
} finally {
schedule(feed);
}
}
function onMiss(feed) {
feed.failures += 1;
if (feed.failures < 2) return;
feed.img.style.display = 'none';
feed.placeholder.style.display = 'block';
feed.placeholder.textContent = store.bridge?.simulated
? 'Simulated feed unavailable'
: `Nothing is publishing on\n${feed.camera.topic}`;
setStatus(feed, feed.failures >= FAILURES_BEFORE_BACKOFF ? 'not publishing' : 'no signal',
'warning');
feed.meta.textContent = '—';
}
function stop(feed) {
if (feed.timer) clearTimeout(feed.timer);
feed.timer = null;
if (feed.objectUrl) URL.revokeObjectURL(feed.objectUrl);
feed.objectUrl = null;
}
/* -- Follow the robot's own idea of what is running --------------------
Another browser (or the agent dropping everything when the last client
left) can change this behind our back. Reading it from state keeps the
switches honest instead of showing what this tab last asked for. */
function applyStreams(streams) {
if (!streams) return;
for (const feed of feeds.values()) {
if (feed.busy) continue;
const active = !!streams[feed.key]?.active;
if (active === feed.active) continue;
feed.active = active;
paintPower(feed);
if (active) schedule(feed); else stop(feed);
}
}
applyStreams(store.state?.custom?.streams);
const unsubscribe = store.on('state', () => applyStreams(store.state?.custom?.streams));
function dispose() {
unsubscribe();
for (const feed of feeds.values()) {
stop(feed);
// Leaving the tab must not leave the robot streaming. Fire and forget:
// the page is going away and there is nothing useful to report.
if (feed.active) post(`/api/streams/${feed.key}`, { active: false }).catch(() => {});
}
}
return { node: root, dispose };
},
};

286
x2_dashboard/web/js/ui.js Normal file
View File

@ -0,0 +1,286 @@
/* ==========================================================================
UI builders. Plain DOM, no framework - the dashboard is small enough that a
framework would cost more than it saves, and this keeps the payload tiny for
a phone on the robot's Wi-Fi.
========================================================================== */
import { confirmDialog } from './core.js';
/** el('div.card', {id:'x'}, child, child) - tag string supports .class and #id */
export function el(spec, props = {}, ...children) {
const [tagPart, ...classParts] = String(spec).split('.');
const [tag, id] = tagPart.split('#');
const node = document.createElement(tag || 'div');
if (id) node.id = id;
if (classParts.length) node.className = classParts.join(' ');
for (const [key, value] of Object.entries(props || {})) {
if (value === null || value === undefined || value === false) continue;
if (key === 'class') node.className = `${node.className} ${value}`.trim();
else if (key === 'text') node.textContent = value;
else if (key === 'html') node.innerHTML = value;
else if (key === 'style' && typeof value === 'object') Object.assign(node.style, value);
else if (key === 'dataset') Object.assign(node.dataset, value);
else if (key.startsWith('on') && typeof value === 'function') {
node.addEventListener(key.slice(2).toLowerCase(), value);
} else if (key in node && key !== 'list' && typeof value !== 'object') {
try { node[key] = value; } catch { node.setAttribute(key, value); }
} else {
node.setAttribute(key, value === true ? '' : value);
}
}
append(node, children);
return node;
}
export function append(parent, children) {
for (const child of children.flat(4)) {
if (child === null || child === undefined || child === false) continue;
parent.appendChild(child instanceof Node ? child : document.createTextNode(String(child)));
}
return parent;
}
export function clear(node) {
while (node.firstChild) node.removeChild(node.firstChild);
return node;
}
/**
* Replace a node's children, dropping null / undefined / false entries.
*
* The native replaceChildren() stringifies anything that is not a Node, so a
* conditional child written as `cond ? el(...) : null` renders the literal word
* "null" on the page. This keeps the same forgiving semantics as el().
*/
export function setChildren(node, ...children) {
clear(node);
append(node, children);
return node;
}
export function icon(name, cls = 'ico') {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('class', cls);
svg.setAttribute('aria-hidden', 'true');
const use = document.createElementNS('http://www.w3.org/2000/svg', 'use');
use.setAttribute('href', `#i-${name}`);
svg.appendChild(use);
return svg;
}
/* -- Page ----------------------------------------------------------------- */
export function pageHead(title, description, actions = []) {
return el('div.page-head', {},
el('div', {},
el('h1', { text: title }),
description ? el('p', { text: description }) : null,
),
actions.length ? el('div.page-head-actions', {}, ...actions) : null,
);
}
/* -- Card ----------------------------------------------------------------- */
export function card(title, { sub, actions = [], flush = false, foot } = {}, ...body) {
const head = title
? el('div.card-head', {},
el('h3', { text: title }),
sub ? el('span.sub', { text: sub }) : null,
actions.length ? el('div.card-head-actions', {}, ...actions) : null,
)
: null;
return el('section.card', {},
head,
el('div.card-body', { class: flush ? 'flush' : '' }, ...body),
foot ? el('div.card-foot', {}, foot) : null,
);
}
/* -- Buttons -------------------------------------------------------------- */
export function button(label, onClick, { style = '', iconName, size = '', disabled = false,
confirm = null, title = '', block = false } = {}) {
const classes = ['btn'];
if (style === 'primary') classes.push('btn-primary');
else if (style === 'danger') classes.push('btn-danger');
else if (style === 'warn') classes.push('btn-warn');
else if (style === 'ghost') classes.push('btn-ghost');
if (size === 'sm') classes.push('btn-sm');
if (block) classes.push('btn-block');
const node = el('button', { class: classes.join(' '), disabled, title, type: 'button' },
iconName ? icon(iconName) : null,
label ? el('span', { text: label }) : null,
);
node.addEventListener('click', async () => {
if (confirm) {
const ok = await confirmDialog('Confirm', confirm, { danger: style === 'danger' });
if (!ok) return;
}
onClick?.(node);
});
return node;
}
/* -- Fields --------------------------------------------------------------- */
export function field(label, control, hint) {
return el('label.field', {},
label ? el('span', { text: label, style: { fontSize: '12px', fontWeight: '550', color: 'var(--text-2)' } }) : null,
control,
hint ? el('span.hint', { text: hint }) : null,
);
}
export function input(props = {}) {
return el('input.input', { type: 'text', ...props });
}
export function textarea(props = {}) {
return el('textarea.textarea', props);
}
export function select(options, { value, onChange, ...rest } = {}) {
const node = el('select.select', rest);
for (const option of options) {
const opt = typeof option === 'object' ? option : { value: option, label: String(option) };
node.appendChild(el('option', { value: opt.value, text: opt.label ?? String(opt.value) }));
}
if (value !== undefined && value !== null) node.value = String(value);
if (onChange) node.addEventListener('change', () => onChange(node.value, node));
return node;
}
export function toggle(label, checked, onChange) {
const box = el('input', { type: 'checkbox', checked });
box.addEventListener('change', () => onChange?.(box.checked));
return el('label.switch', {}, box, el('span.switch-track'), el('span.switch-label', { text: label }));
}
export function range({ min = 0, max = 1, step = 0.01, value = 0, unit = '',
precision = 2, onInput, onChange } = {}) {
const slider = el('input', { type: 'range', min, max, step, value });
const readout = el('span.range-value', { text: `${Number(value).toFixed(precision)}${unit}` });
slider.addEventListener('input', () => {
const v = Number(slider.value);
readout.textContent = `${v.toFixed(precision)}${unit}`;
onInput?.(v);
});
slider.addEventListener('change', () => onChange?.(Number(slider.value)));
const wrap = el('div.range', {}, slider, readout);
wrap.setValue = (v) => {
slider.value = v;
readout.textContent = `${Number(v).toFixed(precision)}${unit}`;
};
wrap.getValue = () => Number(slider.value);
return wrap;
}
export function segmented(options, value, onChange) {
const node = el('div.segmented', { role: 'group' });
const buttons = [];
for (const option of options) {
const opt = typeof option === 'object' ? option : { value: option, label: String(option) };
const btn = el('button', { type: 'button', text: opt.label,
'aria-pressed': String(opt.value === value) });
btn.addEventListener('click', () => {
buttons.forEach((b) => b.setAttribute('aria-pressed', String(b === btn)));
onChange?.(opt.value);
});
buttons.push(btn);
node.appendChild(btn);
}
node.setValue = (v) => {
options.forEach((option, i) => {
const optValue = typeof option === 'object' ? option.value : option;
buttons[i].setAttribute('aria-pressed', String(optValue === v));
});
};
return node;
}
/* -- Display -------------------------------------------------------------- */
export function stat(label, value, { unit = '', sub = '', tone = 'default', spark = null } = {}) {
return el('div.stat', { dataset: { tone } },
el('div.stat-label', { text: label }),
el('div.stat-value', {}, String(value), unit ? el('span.unit', { text: unit }) : null),
sub ? el('div.stat-sub', { text: sub }) : null,
spark,
);
}
export function badge(text, tone = 'default') {
return el('span.badge', { text, dataset: { tone } });
}
export function meter(fraction, tone = 'default') {
const pct = Math.max(0, Math.min(1, fraction || 0)) * 100;
return el('div.meter', {}, el('div.meter-fill', { dataset: { tone }, style: { width: `${pct}%` } }));
}
export function note(text, level = 'default', iconGlyph = 'ⓘ') {
return el('div.note', { dataset: { level } },
el('span.note-icon', { text: iconGlyph }),
el('div', { text }),
);
}
export function kv(pairs) {
const list = el('dl.kv');
for (const [key, value] of pairs) {
if (value === undefined) continue;
list.appendChild(el('dt', { text: key }));
list.appendChild(value instanceof Node ? el('dd', {}, value) : el('dd', { text: String(value ?? '—') }));
}
return list;
}
export function table(columns, rows, { empty = 'No data' } = {}) {
if (!rows.length) return emptyState(empty);
const head = el('tr');
for (const column of columns) {
head.appendChild(el('th', { text: column.label,
style: column.align === 'right' ? { textAlign: 'right' } : {} }));
}
const body = el('tbody');
for (const row of rows) {
const tr = el('tr');
for (const column of columns) {
const value = column.get ? column.get(row) : row[column.key];
const cls = column.align === 'right' ? 'num' : (column.key === 'name' ? 'name' : '');
tr.appendChild(value instanceof Node
? el('td', { class: cls }, value)
: el('td', { class: cls, text: value === null || value === undefined ? '—' : String(value) }));
}
body.appendChild(tr);
}
return el('div.table-wrap', {}, el('table.data', {}, el('thead', {}, head), body));
}
export function emptyState(title, detail, action) {
return el('div.empty', {},
el('div.empty-title', { text: title }),
detail
? el('div', { text: detail, style: { maxWidth: '52ch', whiteSpace: 'pre-line' } })
: null,
action,
);
}
export function keyHint(keys, description) {
return el('div.row.tight', { style: { fontSize: '11.5px', color: 'var(--text-3)' } },
el('div.keys', {}, ...keys.map((k) => el('kbd', { text: k }))),
el('span', { text: description }),
);
}

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Idempotent starter for the X2 dashboard agent.
#
# Run this every minute from cron. If the agent is already up it does nothing;
# if it is not, it starts it. That covers three cases with one mechanism:
# * boot - the robot came back and nobody logged in
# * crash - the process died
# * manual kill - someone stopped it by hand
#
# Why not systemd? The agi account cannot enable linger (`loginctl
# enable-linger` is denied, and sudo forbids running as root here), so a
# `systemd --user` unit only runs while a login session exists - which is
# exactly not the case after a power cycle. A cron watchdog needs no root and
# does not depend on @reboot firing correctly.
set -e
DIR="$(dirname "$(readlink -f "$0")")"
PORT="${X2_AGENT_PORT:-8781}"
LOG="$DIR/agent.log"
# Already listening? Nothing to do. Checking the port rather than the process
# also catches a process that is alive but wedged before binding.
if ss -ltn 2>/dev/null | grep -q ":${PORT} "; then
exit 0
fi
# Avoid stacking instances if a previous start is still coming up.
if pgrep -f "x2_agent.py" >/dev/null 2>&1; then
exit 0
fi
echo "[$(date -Is)] agent not listening on ${PORT} - starting" >> "$LOG"
# Keep the log from growing without bound (cron runs this every minute).
if [ -f "$LOG" ] && [ "$(stat -c%s "$LOG" 2>/dev/null || echo 0)" -gt 1000000 ]; then
tail -n 200 "$LOG" > "$LOG.tmp" && mv "$LOG.tmp" "$LOG"
fi
setsid nohup "$DIR/run_agent.sh" >> "$LOG" 2>&1 < /dev/null &
exit 0

View File

@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Launcher for the X2 dashboard agent. Sources ROS 2 and the AimDK workspace,
# then runs the agent. Used both interactively and by the systemd --user unit.
#
# Note: no `set -u` here. The ROS 2 setup scripts read variables that are not
# defined on a fresh shell (AMENT_TRACE_SETUP_FILES among them), so nounset
# makes sourcing them fail outright.
set -e
export RCUTILS_LOGGING_SEVERITY="${RCUTILS_LOGGING_SEVERITY:-ERROR}"
export ROS_DOMAIN_ID="${ROS_DOMAIN_ID:-0}"
# shellcheck disable=SC1091
source /opt/ros/humble/setup.bash
if [ -f "$HOME/aimdk/install/setup.bash" ]; then
# shellcheck disable=SC1091
source "$HOME/aimdk/install/setup.bash"
fi
# Fast DDS profile - REQUIRED for the camera and LiDAR topics.
#
# A camera frame off this robot is 170-430 KB and a LiDAR scan is 816 KB. Fast
# DDS defaults to a 512 KB shared-memory segment and small socket buffers, and
# it drops samples that large in complete silence: discovery succeeds, the
# reader matches the writer, `ros2 topic info` reports a publisher, and not one
# message is ever delivered. The vendor profile raises the SHM segment to 32 MB
# and the UDP buffers to 10 MB, which is the entire reason those topics work.
#
# This is why an earlier revision of backend/x2_spec.py concluded that the unit
# had one camera and no chest LiDAR. It has six feeds and a LiDAR; the agent
# just could not receive them without this line.
DDS_PROFILE="${FASTRTPS_DEFAULT_PROFILES_FILE:-/agibot/data/home/agi/.aima/env/ros_dds_configuration.xml}"
if [ -f "$DDS_PROFILE" ]; then
export FASTRTPS_DEFAULT_PROFILES_FILE="$DDS_PROFILE"
else
echo "[run_agent] WARNING: no DDS profile at $DDS_PROFILE - camera and LiDAR feeds will connect but deliver nothing" >&2
fi
# -u keeps the agent's own prints unbuffered so they reach the journal promptly.
# Fast DDS writes a very high volume of discovery chatter to stderr; the systemd
# unit sends stderr to /dev/null so it does not swamp the journal. Run this
# script by hand if you need to see it.
exec python3 -u "$(dirname "$(readlink -f "$0")")/x2_agent.py" "$@"

View File

@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Launcher for the X2 dashboard agent. Sources ROS 2 and the AimDK workspace,
# then runs the agent. Used both interactively and by the systemd --user unit.
#
# Note: no `set -u` here. The ROS 2 setup scripts read variables that are not
# defined on a fresh shell (AMENT_TRACE_SETUP_FILES among them), so nounset
# makes sourcing them fail outright.
set -e
export RCUTILS_LOGGING_SEVERITY="${RCUTILS_LOGGING_SEVERITY:-ERROR}"
export ROS_DOMAIN_ID="${ROS_DOMAIN_ID:-0}"
# shellcheck disable=SC1091
source /opt/ros/humble/setup.bash
if [ -f "$HOME/aimdk/install/setup.bash" ]; then
# shellcheck disable=SC1091
source "$HOME/aimdk/install/setup.bash"
fi
# -u keeps the agent's own prints unbuffered so they reach the journal promptly.
# Fast DDS writes a very high volume of discovery chatter to stderr; the systemd
# unit sends stderr to /dev/null so it does not swamp the journal. Run this
# script by hand if you need to see it.
exec python3 -u "$(dirname "$(readlink -f "$0")")/x2_agent.py" "$@"

View File

@ -0,0 +1,73 @@
#!/usr/bin/env bash
# Bring the X2 dashboard up ON THE ROBOT and keep it up.
#
# The dashboard is served by the robot itself, so the link people open is the
# robot's own address - http://<robot-ip>:8770. Nothing depends on any laptop
# being switched on, and when the robot joins a different Wi-Fi the link simply
# follows its new address.
#
# Idempotent by design: it starts only what is not already running, so it is
# safe to call from cron every minute, from @reboot, and by hand.
#
# No `set -u` - the ROS setup scripts read undefined variables.
set -e
DIR="$(dirname "$(readlink -f "$0")")"
LOG_DIR="/home/agi/x2_dashboard_logs"
AGENT_PORT="${X2_AGENT_PORT:-8781}"
WEB_PORT="${X2_WEB_PORT:-8770}"
mkdir -p "$LOG_DIR"
listening() {
ss -ltn 2>/dev/null | grep -q ":$1 "
}
# The previous guard here was `pgrep -f "x2_dashboard/-m backend|backend.server"`,
# and neither alternative can match the real command line, which is plain
# `python3 -m backend`. That left the port check as the only guard, and the port
# is not bound for the first ~10 s of startup - so the every-minute timer fired
# again mid-boot and launched a second backend on top of the first. That is the
# recurring "[Errno 98] address already in use" in dashboard.log.
#
# Matching on /proc rather than trusting pgrep alone: `pgrep -f` also matches any
# shell whose command line merely mentions the pattern, so require python argv[0].
backend_running() {
local p cmd
for p in $(pgrep -f 'python3 -m backend' 2>/dev/null); do
cmd=$(tr '\0' ' ' < "/proc/$p/cmdline" 2>/dev/null)
case "$cmd" in
python*|*/python*) return 0 ;;
esac
done
return 1
}
trim() { # keep the logs from growing without bound
[ -f "$1" ] || return 0
if [ "$(stat -c%s "$1" 2>/dev/null || echo 0)" -gt 2000000 ]; then
tail -n 300 "$1" > "$1.tmp" && mv "$1.tmp" "$1"
fi
}
# 1. the ROS bridge -------------------------------------------------------
if ! listening "$AGENT_PORT" && ! pgrep -f "x2_agent.py" >/dev/null 2>&1; then
trim "$LOG_DIR/agent.log"
echo "[$(date -Is)] starting agent" >> "$LOG_DIR/agent.log"
setsid nohup "$DIR/run_agent.sh" >> "$LOG_DIR/agent.log" 2>&1 < /dev/null &
sleep 3
fi
# 2. the web dashboard ----------------------------------------------------
if ! listening "$WEB_PORT" && ! backend_running; then
trim "$LOG_DIR/dashboard.log"
echo "[$(date -Is)] starting dashboard" >> "$LOG_DIR/dashboard.log"
cd /home/agi/x2_dashboard
# The dashboard talks to the agent over localhost and needs no ROS itself,
# but sourcing costs nothing and keeps one environment for both.
setsid nohup env PYTHONUNBUFFERED=1 python3 -m backend \
>> "$LOG_DIR/dashboard.log" 2>&1 < /dev/null &
fi
exit 0

View File

@ -0,0 +1,53 @@
#!/usr/bin/env bash
# Bring the X2 dashboard up ON THE ROBOT and keep it up.
#
# The dashboard is served by the robot itself, so the link people open is the
# robot's own address - http://<robot-ip>:8770. Nothing depends on any laptop
# being switched on, and when the robot joins a different Wi-Fi the link simply
# follows its new address.
#
# Idempotent by design: it starts only what is not already running, so it is
# safe to call from cron every minute, from @reboot, and by hand.
#
# No `set -u` - the ROS setup scripts read undefined variables.
set -e
DIR="$(dirname "$(readlink -f "$0")")"
LOG_DIR="/home/agi/x2_dashboard_logs"
AGENT_PORT="${X2_AGENT_PORT:-8781}"
WEB_PORT="${X2_WEB_PORT:-8770}"
mkdir -p "$LOG_DIR"
listening() {
ss -ltn 2>/dev/null | grep -q ":$1 "
}
trim() { # keep the logs from growing without bound
[ -f "$1" ] || return 0
if [ "$(stat -c%s "$1" 2>/dev/null || echo 0)" -gt 2000000 ]; then
tail -n 300 "$1" > "$1.tmp" && mv "$1.tmp" "$1"
fi
}
# 1. the ROS bridge -------------------------------------------------------
if ! listening "$AGENT_PORT" && ! pgrep -f "x2_agent.py" >/dev/null 2>&1; then
trim "$LOG_DIR/agent.log"
echo "[$(date -Is)] starting agent" >> "$LOG_DIR/agent.log"
setsid nohup "$DIR/run_agent.sh" >> "$LOG_DIR/agent.log" 2>&1 < /dev/null &
sleep 3
fi
# 2. the web dashboard ----------------------------------------------------
if ! listening "$WEB_PORT" && ! pgrep -f "x2_dashboard/-m backend|backend.server" >/dev/null 2>&1; then
trim "$LOG_DIR/dashboard.log"
echo "[$(date -Is)] starting dashboard" >> "$LOG_DIR/dashboard.log"
cd /home/agi/x2_dashboard
# The dashboard talks to the agent over localhost and needs no ROS itself,
# but sourcing costs nothing and keeps one environment for both.
setsid nohup env PYTHONUNBUFFERED=1 python3 -m backend \
>> "$LOG_DIR/dashboard.log" 2>&1 < /dev/null &
fi
exit 0

View File

@ -0,0 +1,29 @@
[Unit]
Description=AGIBOT X2 dashboard agent (ROS 2 to TCP bridge)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=agi
WorkingDirectory=/home/agi/x2_dashboard_agent
ExecStart=/home/agi/x2_dashboard_agent/run_agent.sh
Restart=always
RestartSec=5
TimeoutStopSec=15
KillMode=mixed
# The robot's own stack takes a while to come up after power-on; the agent
# tolerates an empty graph and fills in as topics appear, so starting early is
# fine and means the dashboard attaches as soon as possible.
StandardOutput=journal
StandardError=journal
# Fast DDS writes a very high volume of discovery chatter (to stdout, alongside
# the agent's own messages, so it cannot simply be dropped). Rate-limiting keeps
# the journal from being swamped while still letting the agent's lines through.
LogRateLimitIntervalSec=30s
LogRateLimitBurst=300
Environment=RCUTILS_LOGGING_SEVERITY=ERROR
Environment=ROS_DOMAIN_ID=0
[Install]
WantedBy=multi-user.target

File diff suppressed because it is too large Load Diff