add the node manifest, dataflow, CI and docs
validate / validate (pull_request) Failing after 58m48s
validate / test (pull_request) Successful in 58m48s
validate / markdownlint (pull_request) Successful in 58m43s

dora-node.yml is the typed contract a consumer wires against, and the only one
they get: no source package is shared between node repositories. The jog,
request and ui ports stay untyped, because dora treats a port carrying
request_id as pattern-correlated and several Arrow schemas legitimately flow
over each — one per service, one per console message.

dataflow.yml runs the whole operator stack from checkouts: this node, the
WebSocket bridge and the arm.

web/ holds a placeholder page, not the real console. It exists so the dataflow
has something to serve and so the node can be checked end to end before the
Control section is ported across from the monolithic repo. It reads telemetry
and can take, release and stop; nothing else.
This commit is contained in:
2026-09-15 15:43:12 -05:00
parent 56b86699bd
commit 7250396a1f
6 changed files with 669 additions and 0 deletions
+58
View File
@@ -0,0 +1,58 @@
# A pull request would otherwise run this twice on the same commit: once for the
# branch push and once for the PR.
name: validate
on:
push:
branches: [main]
pull_request:
env:
DORA_VERSION: v1.0.1
DORA_TARGET: x86_64-unknown-linux-gnu
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install the dora CLI
run: |
curl -fsSL -o dora-cli.tar.gz \
"https://github.com/dora-rs/dora/releases/download/${DORA_VERSION}/dora-cli-${DORA_TARGET}.tar.gz"
tar xzf dora-cli.tar.gz
mkdir -p "$HOME/.local/bin"
install -m 755 "dora-cli-${DORA_TARGET}/dora" "$HOME/.local/bin/dora"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
rm -rf dora-cli.tar.gz "dora-cli-${DORA_TARGET}"
- run: dora --version
# strict_types: true in the dataflow turns a type warning into a non-zero
# exit, so a mismatched annotation fails here rather than at runtime.
- name: Validate the dataflow
run: dora validate dataflow.yml
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
with:
enable-cache: true
# --locked fails if uv.lock is out of date with pyproject.toml, so a
# dependency change that was never locked cannot reach main.
- run: uv sync --locked --group dev
- run: uv run ruff check .
- run: uv run ruff format --check .
- run: uv run pytest -q
markdownlint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: DavidAnson/markdownlint-cli2-action@v16
with:
globs: "*.md"
+138
View File
@@ -0,0 +1,138 @@
# AGENTS.md
This file provides guidance to AI coding assistants when working with code in this repository.
## What this is
A dora-rs Python node that gives a human control of a robot arm, from a gamepad or from a
web browser, and decides which of the two is driving.
It is **the piece in the middle and nothing else**. It contains no robot driver — the arm
is [`dora-ulite6`](https://gitea.liria.cl/liria/dora-ulite6), reached through that node's
ordinary `jog` and `request` ports. It contains no web server — the browser is carried by
[`dora-websocket-transceiver`](https://gitea.liria.cl/liria/dora-websocket-transceiver).
Do not add either here. A teleop node that grew its own HTTP server is exactly the
monolith `dora-ulite6` was rewritten to get out of.
The web console it serves is ported from `Littlehand/monolitic-littlehand-python`
(`littlehand/console/static/`), and **only its Control section**. Perception, calibration,
tasks, chat, ROI, settings and health belong to other nodes; porting them here would
rebuild the monolith inside a node.
## Build
```bash
uv sync # runtime deps
uv sync --group dev # plus pytest and ruff
```
## Run
```bash
dora run dataflow.yml --uv
```
`dataflow.yml` runs the whole stack — this node, the bridge and the arm — from checkouts
expected next to this one.
## Test
```bash
uv run pytest # 66 tests, no gamepad, arm, browser or daemon needed
uv run ruff check .
uv run ruff format --check .
```
## Architecture
Two things carry the design: **what may touch the hardware**, and **what must happen when
a source goes away**.
- **`dora_teleop/main.py`** — The dora event loop, and the one decision this node exists
to make: whether a velocity reaches the arm this tick, and from whom. **It never
blocks.** `_drive` and `_wanted_velocity` are the safety-critical pair — every path that
is not an owner actively asking for motion returns a stop, so a case nobody thought of
fails by halting rather than by carrying on. Keep it that way: the default has to be
"still".
- **`dora_teleop/gamepad.py`** — The pad, on a thread of its own. **Nothing outside this
file may touch pygame.** Everything else sees a plain immutable `Sample`, which is what
lets the rest be tested with no hardware and keeps SDL's quirks out of the arm logic.
`Sample.present` is the load-bearing field: an unplugged pad reads as *absent*, not as
centred, so "the operator let go" and "the pad fell off the desk" stay distinguishable.
- **`dora_teleop/mapping.py`** — Stick deflection to base-frame velocity. Pure functions,
no state, no hardware, because this is the part that has to be right and the cheapest to
check. Output is always the arm node's units: mm/s and deg/s, base frame.
- **`dora_teleop/arbiter.py`** — The lease. One place that answers "why did it not move",
rather than one answer per input path. Ported from the monolithic repo's `RobotService`
lease because the console's `app.js` is already built on it — it gates its jog pad on
`t.owner === "web"` and draws an owner chip. A simpler last-writer-wins scheme would mean
rewriting the page to match the node rather than the other way round.
- **`dora_teleop/ui.py`** — Translation both ways. The console's telemetry keys are a
contract: `app.js` reads `arm_state`, `owner`, `estop`, `vacuum`, `freedrive`,
`position`, `joints` by name, so renaming one breaks the page silently. `arm_state()` is
where the arm's vocabulary (`connecting|ready|moving|recovering|unavailable`) is
reconciled with the console's (`idle|moving|jogging|freedrive|estop|stopped|disconnected`).
- **`dora_teleop/config.py`** — Every environment variable, parsed once at startup, so an
unusable setting stops the node before it joins the dataflow instead of surfacing the
first time somebody picks up the pad. Errors name the variable.
- **`dora_teleop/log.py`** — Timestamped stderr logging. stderr because dora reads a
dynamic node's stdout.
## Rules that are not negotiable
- **The stop is sent once, not every tick.** A stream of zero velocities keeps
`dora-ulite6` believing a jog is live and holds `robot_state` at `moving` forever.
- **E-STOP is never gated on the lease.** From either source, at any time.
- **`JOG_TTL_S` must stay below the arm node's `VELOCITY_COMMAND_TTL_S`** (0.5 s). The
controller-side expiry is the backstop; a backstop that fires first backs up nothing.
- **An unplugged pad or a departed browser stops the arm immediately**, without waiting out
any timer.
- **Do not reintroduce a web server or a robot driver here.** See above.
## What this node cannot do
The lease binds only what goes through this node. `dora-ulite6` has no concept of an owner,
so another node publishing `request` will preempt an operator mid-motion and nothing here
can prevent it. Say so rather than implying otherwise.
`jog` has no feedback channel at all — a malformed one is logged and dropped by the arm
node, with no response and no error output. What this node sends, it learns nothing back
about except through `robot_state` and telemetry.
## Two manifest rules worth knowing
`dora-node.yml` is the typed contract a consumer wires against, and the only one they get:
no source package is shared between node repositories. `dora validate` checks a dataflow
against it without running the node, so CI needs neither a gamepad nor a robot.
`jog`, `request`, `response`, `motion_state` and the `ui_` ports are deliberately
**untyped**. dora treats a port carrying `request_id` as pattern-correlated and skips type
checks on it, and several Arrow schemas legitimately flow over each. Annotating them would
claim a contract neither side keeps.
## Key APIs
- **dora Python API** (`dora`): `Node()`, `node.send_output(id, array, metadata)`, event
iteration via `for event in node`.
- **pygame-ce** (`pygame.joystick`): chosen over evdev because Littlehand targets Linux
*and* macOS and evdev is Linux-only, and because it ships prebuilt wheels for
linux-x86_64, linux-aarch64 and macos-arm64 — nothing here compiles from source. Only
the display and joystick subsystems are initialised: `pygame.init()` also opens audio,
which on a headless robot fills stderr with ALSA errors for a device never used. The
display is needed despite there being no window, because SDL refreshes joystick state
during the event pump and the pump needs it.
## Tools
- **Issue tracker:** Gitea. Use the Gitea MCP tools (`mcp__gitea__*`) to read and manage
issues, pull requests, and other repository operations.
## Code style
- Write docstrings in NumPy style (summary line, then `Parameters`, `Returns`, `Raises`
sections as needed).
- Run `ruff check .` and `ruff format .` before committing. CI enforces both.
## Rules
- Do not mention AI tools in commit messages or documentation.
+151
View File
@@ -1 +1,152 @@
# dora-teleop
A dora-rs node that hands a robot arm to a human. Pick up a gamepad and move the arm, or
open a browser and move it from there — one of the two at a time, decided here.
It drives [`dora-ulite6`](https://gitea.liria.cl/liria/dora-ulite6) through that node's
ordinary `jog` and `request` ports, and talks to the browser through
[`dora-websocket-transceiver`](https://gitea.liria.cl/liria/dora-websocket-transceiver).
It contains no web server and no robot driver: it is the piece in the middle that decides
what the arm should be doing, and that decision is the only thing it owns.
## The gamepad
Analog and blended — several axes at once, and how fast depends on how far you push. That
is the whole reason a pad beats the web console's four-sector dial pad, which can only ask
for one axis at a time at one fixed speed.
| Control | Does | Units |
|-------------|-------------------------------------------|-------|
| Left stick | Move along X (up/down) and Y (left/right) | mm/s |
| Triggers | Lift on the right, lower on the left | mm/s |
| Right stick | Roll (left/right) and pitch (up/down) | deg/s |
| Bumpers | Yaw, left and right | deg/s |
| A | Close the tool | — |
| B | Open the tool | — |
| X | Clear a controller error | — |
| Y | Toggle freedrive | — |
| Back | **E-STOP** | — |
Up on the stick sends the arm away from you, along **+X** in the robot's base frame —
the same convention the web console's dial uses, so the two agree.
Indices are SDL's, which is what most Xbox- and PlayStation-style pads report. A pad that
disagrees is a change to `Layout` in `dora_teleop/mapping.py` rather than a new
environment variable: an index nobody can verify from outside is worse than a line of code
somebody can read.
`home` is deliberately **not** on the pad. It is a large unattended motion and belongs
behind a deliberate click, not a thumb resting on a face button.
## The lease
Two sources, one arm. Whoever holds the lease drives; the other is ignored.
- The **gamepad takes it implicitly**, the first time a stick moves, if nobody holds it.
Nobody should have to reach for a browser to use the thing in their hands.
- It **gives it back** after `GAMEPAD_IDLE_RELEASE_S` untouched, so a pad left on the
bench does not lock the browser out forever.
- The **browser takes it explicitly**, with the console's TAKE CONTROL button, and gives
it back with RELEASE. It can preempt a held arm, because an operator who can see the arm
is better placed than this code to decide they should have it.
- **E-STOP is never gated.** From either source, held lease or not. Somebody reaching for
the stop is the one moment when asking who owns the arm is the wrong question.
**The lease binds only what goes through this node.** `dora-ulite6` has no idea it exists.
Another node publishing `request` will preempt an operator mid-motion and nothing here can
stop it — if that matters for your dataflow, do not wire one.
## The deadman
An arm under a velocity command keeps moving until told otherwise, so the question is
never whether to start but whether anything has happened that means it must stop. Three
layers, outermost last:
1. **Re-send every tick** (50 ms) while a source is driving.
2. **This node's watchdog** (`JOG_TTL_S`, 0.4 s) stops the arm when the owner goes quiet —
and stops it *immediately*, without waiting out the timer, when the gamepad is unplugged
or the last browser disconnects. An unplugged pad is not a centred pad.
3. **The arm's own expiry** (`VELOCITY_COMMAND_TTL_S` in `dora-ulite6`, 0.5 s), enforced by
the controller whether or not this process is alive.
Keep them in that order. If this node's timer is longer than the controller's, the backstop
fires before the thing it is backing up.
On shutdown the node sends one all-zero velocity before exiting. A motion lives in the
controller, not in this process.
The stop is sent **once**, not every tick. A stream of zeroes would keep `dora-ulite6`
believing a jog is live and hold `robot_state` at `moving` forever.
## Ports
| Direction | Name | Description |
|-----------|----------------|------------------------------------------------------|
| in | `tick` | 50 ms — re-send, deadman, telemetry |
| in | `ui_command` | One console message, from the bridge's `data_out` |
| in | `clients` | Connect/disconnect from the bridge — the web deadman |
| in | `response` | An answer from the arm |
| in | `motion_state` | How a motion ended |
| in | `robot_state` | The arm's own state |
| in | `tcp_pose` | Tool-tip pose, six float32, mm and degrees |
| in | `joint_pose` | Joint angles, six float32, degrees |
| out | `jog` | `{velocity: [vx, vy, vz, wx, wy, wz]}`, mm/s + deg/s |
| out | `request` | A service call, `request_id` in the metadata |
| out | `ui_state` | The console's telemetry snapshot, as JSON |
## Environment Variables
| Variable | Default | Description |
|--------------------------|---------|--------------------------------------------------------|
| `SPEED_MM_S` | `60.0` | Translation speed at full deflection |
| `SPEED_DEG_S` | `30.0` | Rotation speed at full deflection |
| `DEADZONE` | `0.12` | Stick travel near centre to ignore |
| `EXPO` | `0.4` | How much of the curve is cubic; 0 is linear |
| `JOYSTICK_INDEX` | `0` | Which pad to open |
| `JOG_TTL_S` | `0.4` | Owner silence before the arm is stopped |
| `GAMEPAD_IDLE_RELEASE_S` | `2.0` | Untouched pad before it hands the arm back |
| `STATE_POLL_S` | `0.5` | How often to ask the arm what telemetry does not carry |
A value that cannot be read, a `DEADZONE` outside the stick or an `EXPO` off the curve
stops the node at startup rather than the first time somebody picks up the pad.
## Usage
See `dataflow.yml` for the whole stack wired together. The short version:
```yaml
- id: teleop
git: ssh://git@gitea.liria.cl:5001/liria/dora-teleop.git
tag: v0.1.0
build: pip install .
path: dora-teleop
inputs:
tick: dora/timer/millis/50
ui_command: ws-transceiver/data_out
clients: ws-transceiver/clients
response: ulite6/response
motion_state: ulite6/motion_state
robot_state: ulite6/robot_state
tcp_pose: ulite6/tcp_pose
joint_pose: ulite6/joint_pose
outputs: [jog, request, ui_state]
```
```bash
dora run dataflow.yml --uv
```
**Requirements:** dora 1.0+ (verified with `dora-cli 1.0.1`), Python 3.11+, `uv`, and a
gamepad if you want one — the node starts without and says so.
## Development
```bash
uv sync --group dev
uv run pytest # 66 tests, no gamepad, arm, browser or daemon needed
uv run ruff check .
uv run ruff format --check .
```
`dora_teleop/gamepad.py` is the only file that touches pygame, and the only one that
cannot be tested without hardware. Everything else sees a plain `Sample`.
+65
View File
@@ -0,0 +1,65 @@
# The whole operator stack, and what CI validates.
#
# Built from checkouts rather than tags, so `dora validate` resolves each node's
# manifest from its `path:` and checks the wiring against the versions in front
# of you. The sibling repos are expected next to this one, which is how they are
# cloned in practice.
#
# Three nodes, one loop: the bridge carries the browser, this node decides who
# drives, and the arm node moves the arm and reports back.
strict_types: true
nodes:
- id: teleop
path: dora-teleop
build: pip install .
inputs:
tick: dora/timer/millis/50
ui_command: ws-transceiver/data_out
clients: ws-transceiver/clients
response: ulite6/response
motion_state: ulite6/motion_state
robot_state: ulite6/robot_state
tcp_pose: ulite6/tcp_pose
joint_pose: ulite6/joint_pose
outputs:
- jog
- request
- ui_state
env:
SPEED_MM_S: "60"
SPEED_DEG_S: "30"
- id: ws-transceiver
path: ../dora-websocket-transceiver
build: pip install .
inputs:
data_in: teleop/ui_state
outputs:
- data_out
- clients
env:
WS_PORT: "9000"
STATIC_DIR: "../dora-teleop/web"
HTTP_PORT: "9010"
- id: ulite6
path: ../dora-ulite6
build: pip install .
inputs:
tick: dora/timer/millis/100
request: teleop/request
jog: teleop/jog
outputs:
- response
- motion_state
- joint_pose
- tcp_pose
- robot_state
env:
ROBOT_IP: "192.168.1.192"
TOOL_TYPE: "vacuum"
# Nothing moves because the node started. An operator asks for the first
# motion, which is the whole point of a teleop stack.
INIT_MODE: "none"
+172
View File
@@ -0,0 +1,172 @@
# Node manifest: the typed contract a consumer wires against.
#
# No source package is shared between node repositories, so this file and its
# type URNs are the only contract a consumer has. `dora validate` checks a
# dataflow against it without running the node, so CI needs neither a gamepad
# nor a robot.
#
# `jog`, `request`, `response`, `motion_state` and the two `ui_` ports are
# deliberately **untyped**. dora treats a port carrying `request_id` as
# pattern-correlated and skips type checks on it, and several Arrow schemas
# legitimately flow over each — one per service, one per console message.
# Annotating them would claim a contract neither side keeps.
apiVersion: 1
name: dora-teleop
namespace: liria
description: Hands a robot arm to a human, from a gamepad or a browser
categories: [robot, teleop, input]
keywords: [teleop, gamepad, joystick, jog, arm, manipulator, web-console, operator]
runtime: python
entrypoint: dora-teleop
# `dora build --uv` / `dora run --uv` rewrites this to `uv pip install .` and
# gives the node its own managed venv under `.dora/python-envs/`, so the
# dependencies it sees at runtime are the ones installed here.
build: pip install .
dora: ">=1.0"
inputs:
tick:
description: >-
Drives everything time-based: re-sending the held velocity, expiring a
source that has gone quiet, and publishing the console's telemetry. Use 50
ms. Slower than about 100 ms and the arm node's own velocity expiry starts
firing between ticks, which an operator feels as the arm stuttering.
type: std/core/v1/UInt64
ui_command:
required: false
description: >-
One message from the web console, as the JSON string the WebSocket bridge
publishes on `data_out`. Wire it from the bridge. A message this node does
not recognise is dropped rather than refused, so a page from a newer build
cannot take the arm down.
clients:
required: false
description: >-
Connect and disconnect events from the WebSocket bridge, as
`{connected, event}`. **This is the browser's deadman.** Without it a tab
closing mid-jog leaves the arm travelling until the arm node's own expiry
catches up, because a socket that went away sends nothing to say so.
response:
required: false
description: >-
An answer from the arm node, correlated by `request_id` in the metadata. A
failure is passed to the console as a toast; a `get_state` answer fills in
what telemetry does not carry.
motion_state:
required: false
description: >-
How a motion ended. Needed because this node sends motions with
`wait: false`, and a non-blocking motion reports its outcome here and
nowhere else.
robot_state:
required: false
description: >-
The arm's own state, one of `connecting`, `ready`, `moving`, `recovering`
or `unavailable`. Translated into the vocabulary the console draws.
type: std/core/v1/String
tcp_pose:
required: false
description: >-
Tool-tip pose, six float32 as `[x, y, z, roll, pitch, yaw]` in mm and
degrees, base frame. Shown in the console's pose readout.
type: std/core/v1/Float32
joint_pose:
required: false
description: >-
Joint angles, six float32 in degrees. Shown in the console's joint readout.
type: std/core/v1/Float32
outputs:
jog:
description: >-
A base-frame velocity for the arm, as `{velocity: [vx, vy, vz, wx, wy, wz]}`
in mm/s and deg/s. Re-sent every tick while a source is driving, and
followed by exactly one all-zero vector when it stops — once, not
repeatedly, because a stream of zeroes would keep the arm node believing a
jog is live.
request:
description: >-
A service call on the arm — `stop`, `grasp`, `release`, `clear`,
`freedrive`, `home`, `move_to`, `get_state` — with `request_id` in the
metadata.
ui_state:
description: >-
The console's telemetry snapshot, as a JSON string for the WebSocket
bridge to broadcast. Also carries error toasts. Every key in it is read by
name in the console's `app.js`.
env:
SPEED_MM_S:
type: float
default: 60.0
description: >-
Translation speed at full stick deflection, in mm/s. A stick is analog, so
this is a maximum and anything less moves proportionally slower. Defaults
to 60.0.
SPEED_DEG_S:
type: float
default: 30.0
description: Rotation speed at full deflection, in deg/s. Defaults to 30.0.
DEADZONE:
type: float
default: 0.12
description: >-
Stick travel near centre to ignore, as a fraction. A worn stick that does
not centre would otherwise drive the arm on its own. What is left is
rescaled, so motion still starts from zero just outside the band.
Defaults to 0.12.
EXPO:
type: float
default: 0.4
description: >-
How much of the response curve is cubic, from 0 (linear) to 1. Fine
positioning happens near centre and a linear stick gives it almost no
resolution there. Defaults to 0.4.
JOYSTICK_INDEX:
type: int
default: 0
description: Which pad to open, when more than one is plugged in. Defaults to 0.
JOG_TTL_S:
type: float
default: 0.4
description: >-
How long the owning source may go quiet before the arm is stopped. Keep it
**below** the arm node's VELOCITY_COMMAND_TTL_S (0.5 s by default), which
is the controller-side backstop behind this one. Defaults to 0.4.
GAMEPAD_IDLE_RELEASE_S:
type: float
default: 2.0
description: >-
How long a pad may sit untouched before it hands the arm back, so a
browser can take over without anyone pressing anything. Defaults to 2.0.
STATE_POLL_S:
type: float
default: 0.5
description: >-
How often to call `get_state` for what telemetry does not carry: whether
the tool is engaged and whether freedrive is on. Defaults to 0.5.
example: |
- id: teleop
git: ssh://git@gitea.liria.cl:5001/liria/dora-teleop.git
tag: v0.1.0
build: pip install .
path: dora-teleop
inputs:
tick: dora/timer/millis/50
ui_command: ws-transceiver/data_out
clients: ws-transceiver/clients
response: ulite6/response
motion_state: ulite6/motion_state
robot_state: ulite6/robot_state
tcp_pose: ulite6/tcp_pose
joint_pose: ulite6/joint_pose
outputs:
- jog
- request
- ui_state
env:
SPEED_MM_S: "60"
SPEED_DEG_S: "30"
+85
View File
@@ -0,0 +1,85 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>teleop — placeholder console</title>
<style>
:root { color-scheme: dark; }
body { margin: 0; padding: 24px; background: #15151c; color: #e6e6ef;
font: 14px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }
h1 { font-size: 16px; margin: 0 0 4px; }
p.note { color: #8a8a9e; margin: 0 0 20px; max-width: 60ch; }
.card { background: #1d1d26; border: 1px solid #2c2c3a; border-radius: 8px;
padding: 16px; margin-bottom: 16px; max-width: 60ch; }
.row { display: flex; justify-content: space-between; gap: 16px; padding: 3px 0; }
.row span:last-child { color: #7fd1e8; }
button { background: #2c2c3a; color: #e6e6ef; border: 1px solid #3c3c4e;
border-radius: 6px; padding: 8px 14px; font: inherit; cursor: pointer; }
button:hover { background: #363648; }
button.stop { background: #5a1f27; border-color: #7d2b35; }
#link { color: #8a8a9e; }
</style>
</head>
<body>
<h1>teleop — placeholder console</h1>
<p class="note">
Not the real console. This page exists so the dataflow has something to serve and so the
node can be checked end to end before the Control section is ported across from the
monolithic repo. It reads telemetry and can take, release and stop — nothing else.
</p>
<div class="card" id="state"></div>
<div class="card">
<button onclick="send({type:'control', action:'take', preempt:true})">Take control</button>
<button onclick="send({type:'control', action:'release'})">Release</button>
<button class="stop" onclick="send({type:'estop'})">E-STOP</button>
<button onclick="send({type:'reset'})">Reset</button>
</div>
<div id="link"></div>
<script>
"use strict";
const KEYS = ["arm_state", "owner", "connected", "ready", "estop", "vacuum", "freedrive"];
let socket = null;
function send(payload) {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: "data", payload }));
}
}
function render(telemetry) {
const rows = KEYS.map((key) =>
`<div class="row"><span>${key}</span><span>${telemetry[key]}</span></div>`);
const pose = telemetry.position;
if (pose) {
const shown = ["x", "y", "z"].map((axis) => pose[axis].toFixed(1)).join(", ");
rows.push(`<div class="row"><span>position</span><span>${shown} mm</span></div>`);
}
document.getElementById("state").innerHTML = rows.join("");
}
function connect() {
// The socket is the bridge's port, not this page's — the page is served over
// HTTP on a different one.
const url = `ws://${location.hostname}:9000`;
document.getElementById("link").textContent = `socket: ${url}`;
socket = new WebSocket(url);
socket.onmessage = (event) => {
const message = JSON.parse(event.data);
if (message.type !== "data" || !message.payload) return;
if (message.payload.type === "telemetry") render(message.payload);
};
socket.onclose = () => {
document.getElementById("link").textContent = `socket: ${url} — closed, retrying`;
setTimeout(connect, 2000);
};
}
connect();
</script>
</body>
</html>