OpenMuscle devices send their readings as JSON-encoded UTF-8 packets over UDP. The computer receives them on UDP port 3141 (sensor and label frames). V4 devices also announce themselves on UDP port 3140 and are subscribed to over a TCP command channel; see Discovery and subscription. Devices that need a computer's address in their settings (older firmware) send to it directly.
{
"v": "1.0",
"type": "flexgrid",
"id": "fg-01",
"ts": 164587,
"data": { ... },
"meta": { ... }
}| Field | Type | Description |
|---|---|---|
v |
string | Protocol version ("1.0") |
type |
string | Device type: "flexgrid", "lask5", or custom |
id |
string | Unique device identifier (user-configurable) |
ts |
int | Device-local timestamp in milliseconds |
data |
object | Device-specific sensor payload (see below) |
| Field | Type | Description |
|---|---|---|
meta |
object | Battery level, calibration state, RSSI, firmware version |
{
"matrix": [[col0_row0, col0_row1, col0_row2, col0_row3], ...],
"rows": 4,
"cols": 15,
"imu": {"gyro": [gx, gy, gz], "accel": [ax, ay, az]}
}matrix is a list of columns, each a list of 4 row values: 15 columns x 4 rows = 60 cells for the V4 band (the first FlexGrid used 16 columns), raw ADC values (0-4095). The computer flattens it row-major into the CSV columns R0C0 to R3C14. imu is present on V4 bands (raw gyro and accel counts; the per-chip scale is advertised in the band's announce and get_info reply). Battery, uptime and similar status values arrive in meta about once a second.
{
"values": [v0, v1, v2, v3],
"joystick": {"x": 2048, "y": 2048}
}4 piston sensor values. Joystick is optional.
Synthesized server-side from WebSocket frames sent by the WebXR client at /vr (browsers can't speak UDP). The payload represents one tracked hand sampled from XRHand each XRFrame:
{
"values": [px,py,pz, rx,ry,rz,rw, ...] // flat, 7 floats per joint
"handedness": "left" | "right",
"joint_names": ["wrist", "thumb-metacarpal", ...],
"hands": {
"handedness": "left" | "right",
"joints": [
{"name": "wrist", "pos": [x,y,z], "rot": [x,y,z,w], "radius": 0.02, "valid": true},
...
]
}
}valuesfollows the same convention as LASK5: flat, in canonical joint × channel order, so the recorder and matcher pairquest_handframes with FlexGrid frames identically to LASK5. The order is[px, py, pz, rx, ry, rz, rw] × N joints.- Fixed-length contract. A well-behaved client MUST send all joints in canonical
joint_namesorder every frame, so thevalueslength and per-slot meaning are stable across frames. When an individual joint pose is momentarily unavailable, send a zero position + identity quaternion with"valid": falserather than omitting the joint, because omitting it would shift every later joint into the wrong CSV column and silently misalign the labels. The server defends against violations by locking the label width at the first label packet and padding/truncating later rows to keep the CSV rectangular (it counts and logs any mismatch), but clients should not rely on that. valid(per joint, inhands.joints) marks whether the pose was actually tracked this frame. It is preserved in the JSONL sidecar so offline analysis can filter zero-filled joints; the flatvalues/ CSV carry only the numbers.joint_nameslists the joints in the same order they're flattened intovalues. The standard set is the W3C WebXR Hand Input spec (25 joints: wrist + 4 thumb + 5 each for index/middle/ring/pinky).handsis the structured per-joint form, kept in the JSONL sidecar for offline analysis. It's redundant withvalues+joint_namesbut easier to diff by eye.- Empty payloads (the headset reports tracking-lost for the whole hand this frame) are dropped silently by the server, because they would otherwise produce zero-rows that mislead training.
A per-capture <name>.labels.schema.json sidecar is written on the first quest_hand packet of a recording. It maps label_0..label_N columns in the CSV back to (joint, channel), so the wide label vector is self-describing.
Future-proofing note: v1 captures one hand per recording (handedness is a single "left" or "right" string). A future handedness: "both" extension (payload carries both hands in data.values with the schema sidecar growing a parallel joint_names_left / joint_names_right, or a per-column hand field) would be backward-compatible. Old consumers see a wider but still-flat values vector; new consumers can split it via the schema. Don't accidentally close that door in any future refactor of _flatten_quest_joint / _write_labels_schema.
The camera page tracks one hand in the browser and sends 25 joints in the same layout as quest_hand to the server's /ws/webcam WebSocket, where they become webcam_hand packets (device ids webcam-left and webcam-right). Positions are in a hand-local frame and rotations are identity. See webcam-labeling.md.
V4 bands and the LASK5 do not need the computer's address. Instead:
- Announce. While no computer is subscribed, the device broadcasts an
announcepacket on UDP port 3140 about once a second. It carries the device id, type (dev), firmware, the ports it offers (services: the data port and the command port) and its capabilities. The computer can also broadcast{"verb": "discover"}to port 3140 and every device answers at once. - Subscribe. The computer connects to the device's TCP command port (8001 for a band, 8002 for a LASK5) and sends a
subscribecommand, then aheartbeatabout once a second. A device drops a subscriber after about 5 seconds of silence. One JSON object per line. - Stream. The device sends its frames by UDP to every subscribed computer, on the computer's data port (3141). Other commands include
get_infoandset_role. - Setup mode. A device with no Wi-Fi saved offers its own network
OM-...and a setup page athttp://192.168.4.1instead (see wifi-setup.md).
The full specification lives in the OpenMuscle-Connect repository (DEVICE-DISCOVERY-SPEC.md and WIRE-FORMAT.md); the code that implements the computer side is pc/src/openmuscle/discovery/manager.py.
Define a new type string and document the data shape. The PC-side parser auto-discovers devices by their type field.
Adding a new type string (e.g. quest_hand) is non-breaking under v1.0: existing parsers ignore unknown types, the schema envelope is unchanged, and devices that don't speak the new type are unaffected. Bump the "v" field to "1.1" (or beyond) only when changing the envelope itself (required-field set, semantics of ts/id, etc.).
The PC parser (openmuscle.protocol.parser) auto-detects three formats:
- New protocol: JSON object with
"v"field - Legacy FlexGrid: bare JSON array (16x4 matrix)
- Legacy LASK5/SensorBand: Python dict with
"id"field (parsed viaast.literal_eval)