The console's telemetry keys are a contract: app.js reads arm_state, owner,
estop, vacuum, freedrive, position and joints by name, so renaming one breaks
the page silently. They are reproduced rather than improved for that reason,
vacuum included, which is the console's name for whatever tool is bolted on.
The two vocabularies do not line up and this is where they are reconciled. The
arm node reports a driver's lifecycle; the console draws an operator's screen.
The ordering in arm_state is the ordering of what an operator most needs to
know: a latched e-stop outranks everything, because the arm may well read ready
and saying so would invite somebody to push it.
A message this node does not recognise is dropped rather than refused. The
console and the node version independently, and a page from a newer build
saying something we have not learned yet must not take the arm down.
Two sources want one arm and the arm cannot take both. Putting the decision in
one place means there is exactly one answer to "why did it not move" rather than
one answer per input path.
The model is ported from the monolithic repo's RobotService lease rather than
replaced with last-writer-wins, because the console is already built on it: its
jog pad is gated on owning the arm and it draws an owner chip. A simpler scheme
would mean rewriting the page to match the node instead of the other way round.
The gamepad takes the arm implicitly, on the first nudge of a stick, so nobody
has to reach for a browser to use the thing in their hands — and gives it back
after sitting untouched, so a pad left on the bench cannot lock the browser out
forever. The web takes and releases explicitly, and may preempt: an operator who
can see the arm is better placed than this code to decide they should have it.
Pure functions, no state and no hardware, because this is the part that has to
be right and the cheapest to check.
Analog and blended: how fast the arm moves depends on how far the stick is
pushed, and several axes move at once. 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.
The deadzone rescales what is left of the travel rather than just clipping it,
so the arm starts from zero just outside the band instead of jumping to a
fraction of full speed the instant the stick leaves it. Expo bends the response
because fine positioning happens near centre, where a linear stick gives almost
no resolution.
Up on the stick sends the arm along +X, matching the console's dial, so the two
agree about which way is which.
The reader owns the pad on a thread of its own, and nothing outside that file
may touch pygame: everything else sees a plain immutable Sample. That is what
lets the rest of the node be tested with no hardware, and what keeps SDL's
quirks out of the arm logic.
Sample.present is the field that matters. A pad that has been unplugged reads as
absent rather than as centred, so "the operator let go" and "the pad fell off
the desk" stay distinguishable — they call for the same stop, but only one of
them is worth logging.
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 this node never uses; the display is needed despite there being no
window, because SDL refreshes joystick state during the event pump.
Settings are parsed once at startup so a deadzone that would silently make the
pad inert stops the node before it joins the dataflow.
Ignore rules, markdown lint configuration and the pointer to AGENTS.md, so the
node itself arrives as a reviewable diff rather than as an initial import.