The application UART is 115200 baud, 8 data bits, no parity, one stop bit.
Machine consumers can use the generated OpenAPI 3.1, AsyncAPI 3.0, and JSON-RPC schema/catalog. The standalone offline API reference renders the same contracts without a documentation server. Repository validation regenerates the contracts logically and rejects drift from the actual RPC dispatcher or REST route families.
controller.message.send and POST /api/messages use the same bounded host
envelope and publish a normal message event through IPC, WebSocket,
Socket.IO, bridge, TUI, WebUI, and native-host listeners. It does not alter the
firmware protocol or open a serial connection for host-only deliveries.
{
"source": "ipc",
"targets": ["native", "web", "tui"],
"type": "operator.notice",
"text": "Commissioning is ready",
"severity": "warning",
"correlation": "commission-42",
"delivery": "async",
"action": "app.page:events"
}targets is the one canonical ordered request shape. Each entry is a bounded
receiver capability, surface, or exact-instance selector learned from live
endpoint/application advertisement; the host deliberately does not keep a
closed product/surface list. Duplicate selectors are removed in order. lcd
and board explicitly require a connected board that advertises LCD delivery.
all delivers to the board LCD only when that capability is live and otherwise
continues to the currently attached host receivers. Severity is one of debug,
info, success, warning, or error; delivery is sync (completed) or
async (accepted). Correlation and action are descriptive event fields in this
slice—actions are never implicitly executed.
The minimal CLI spelling is:
controller message native,web,tui operator.notice "Commissioning is ready"It permits disconnected operation only for selectors whose delivery does not
require the board; explicit board or lcd delivery connects first and fails
clearly when LCD delivery is unavailable. Native/Web/TUI action adapters, delivery expiry,
deduplication, and generalized board-operation migration continue under #164,
#165, and #166.
Frames are COBS encoded and terminated by 0x00. The decoded frame is:
offset type meaning
0 u8 magic = 0xA5
1 u8 fixed native namespace discriminator (1; Pakar uses 0)
2 u8 opcode
3 u8 sequence
4 u8 payload length, 0..48
5 u8[length] payload
5+len u8 CRC-8/ATM
CRC uses polynomial 0x07, initial value 0x00, over every decoded byte
before the CRC. Multi-byte values are little-endian.
The MCU and host require canonical magic, bounded length, CRC and the fixed
native namespace discriminator1. Pakar's discriminator0 is rejected before
dispatch: its0x11 means audio rather than PWM. There is no dialect guessing or
fallback. This byte is not a firmware/API generation or feature selector.
Known write operations validate a required semantic payload prefix and ignore
trailing extension fields. Structurally distinct record shapes retain their
shape byte; unknown opcodes receive Unsupported. This provides loose
capability-based interoperability without embedding a table of past firmware
versions or development migrations.
The stream decoder bounds incomplete frames, isolates a malformed frame at the next zero delimiter, and continues decoding later frames.
Host requests use sequence values 1..255. A direct response (ACK, ERROR,
HELLO, STATUS, SETTINGS, PWM_VALUES, I2C_RESULT, or RF_ENTRIES) echoes its
request's sequence. Firmware-originated periodic STATUS, asynchronous
EVENT, SEGMENT_CHANGED, and BUZZER_CHANGED frames, plus the unsolicited
boot HELLO, always use sequence 0;
the host never allocates that value.
The host correlates both sequence and expected response opcode. For ACK and
ERROR, it also verifies the request opcode carried in the payload. This
prevents an asynchronous or stale frame from satisfying an unrelated
in-flight request even if a malformed firmware build reuses the same nonzero
sequence.
ACK confirms command acceptance, not necessarily the state later exposed to
clients. The shared mutation path for directly latched general relays R5-R8
therefore follows RELAY_SET ACK with a correlated GET_STATUS request and
reports success only when the returned activeRelays bit matches. That STATUS
response refreshes the shared snapshot before CLI, HTTP, IPC, or Go-library
callers receive success. R1-R4 retain sequencer-aware ACK semantics because a
safe break-before-make transition may intentionally defer their applied state.
Automatic port detection must send HELLO and accept only a valid HELLO
response with board kind 1 and name PCController.
HELLO response:
u8 identitySchema = 3
u8 boardKind (1 = PCController)
u32 capabilities
u32 buildHash little-endian source hash
u32 buildTimestamp little-endian packed date<<16|time
The 14-byte compact identity is mandatory. The board kind supplies the stable
technical identity; firmware identity is hash/time based rather than a
semantic version.
The timestamp packs (year-2000)<<9 | month<<5 | day in the upper word and
hour<<11 | minute<<5 | second>>1 in the lower word. It has two-second
resolution; the host retains the raw u32 and renders YYMMDDHHMMSS.
The current AVR image deliberately does not duplicate identity in a magic PROGMEM record. The named-region HEX patcher must therefore not search for compiler immediates or claim that identity is safely patchable. Changing firmware identity requires a rebuild until a linker-declared metadata region can be added without consuming the ATmega328P safety margin.
This framed opcode protocol exists only while the PCController application is
running. Urboot/Urclock is a separate protocol on the same UART. The host never
mixes the two byte streams: its primary process closes the native session,
delegates Urclock commands to MiniCore's AVRDUDE urclock programmer, and
requires a new valid application HELLO after AVRDUDE exits.
boot backup DIR uses that handoff to read flash and EEPROM and request raw
Urboot/Urclock metadata. It writes SHA-256 and application build identity into
a host-side JSON manifest; backup metadata is not a native opcode and does not
alter firmware EEPROM.
| Request | Value | Payload |
|---|---|---|
| HELLO | 01 |
none |
| GET_STATUS | 02 |
none |
| SET_STREAM | 03 |
u16 period_ms (0 disables, otherwise 100..65535) |
| GET_SETTINGS | 04 |
none |
| SET_SETTINGS | 05 |
exact 15-byte settings shape 3 record below |
| TEMPERATURE_LIST | 06 |
none |
| BUZZER | 10 |
u16 frequency_hz, u16 duration_ms |
| PWM_SET | 11 |
u8 channel, u16 logical_value |
| PWM_ALL_OFF | 12 |
none |
| — | 13 |
reserved; senders must not use this value |
| STATUS_RGB | 14 |
u8 r, u8 g, u8 b, u8 brightness |
| PWM_GET | 15 |
none; returns availability and all sixteen values |
| ADDRESSABLE_LED | 16 |
u8 pixel, u8 r, u8 g, u8 b, u8 brightness; pixel 0..10, or FF to fill |
| RF_TX | 20 |
u32 code, u8 bits (1..32), u8 protocol (1..12), u16 pulse_us |
| RF_LEARN_START | 21 |
u8 timeout_seconds (1..120) |
| RF_LEARN_CANCEL | 22 |
none |
| RF_LEARN_CLEAR | 23 |
none |
| RF_LEARN_LIST | 24 |
u8 cursor |
| RF_LEARN_REMOVE | 25 |
u8 id |
| MENU_ACTION | 30 |
u8 action (Previous/Next/Decrease/Increase = 0..3) |
| RELAY_SET | 31 |
u8 index, u8 active |
| RELAY_SIDE | 32 |
u8 side, u8 motion |
| RELAY_ALL_OFF | 33 |
none |
| RELAY_TEST | 34 |
u16 step_ms (0 stops, otherwise 250..65535) |
| RESET | 35 |
u8 target (0 application, 1 bootloader hint) |
| I2C_TRANSFER | 36 |
bounded generic I2C transaction record |
| MENU_SET_PAGE | 37 |
u8 page |
| DISPLAY_TEXT | 38 |
legacy targets 0..4 below, or scheduled-segment target 5 |
| MACRO_START | 39 |
u8 id, char label[4] (space padded printable ASCII) |
| MACRO_CANCEL | 3A |
none |
| MACRO_STEP | 3B |
u8 kind, u8 target, u16 value |
| FRONT_PANEL_GET | 3C |
none |
| REMOTE_KEY_GESTURE | 3D |
u8 action, u8 keyEvent |
| MENU_LIST | 3E |
u8 cursor |
| RF_LEARN_REPLACE | 3F |
one complete 12-byte learned-RF record |
| MENU_LAYOUT_GET | 40 |
none |
| MENU_LAYOUT_SET | 41 |
exact current layout record |
| PROGRAM_STATE | 45 |
semantic prefix u8 state (0 Idle, 1 Running); future tail tolerated |
Value 26 is reserved: RF mapping changes replace one complete learned record
through RF_LEARN_REPLACE. SET_STREAM remains the immediate stream-cadence
operation, while MENU_ACTION remains the compact stateless navigation path;
REMOTE_KEY_GESTURE is the distinct stateful down/hold/up input path. A
Down performs its primary action in the receive pass, HoldRepeat repeats
it, and Up releases momentary output; Click and HoldStart are lifecycle
classification only. Clients must not wait out a click/double-click timer
before sending Down.
Active physical output state is push-first. A UI, bridge, or integration must not create a repeating read timer merely because a snapshot opcode exists. Firmware emits a changed-only sequence-zero opcode, the Go runtime updates its authoritative cache immediately, and that one event fans out over IPC, WebSocket, Socket.IO, peer bridges, webhooks, TUI, and WebUI. Snapshot reads are reserved for initial connection, an explicit user refresh, recovery after a detected event gap, or a clearly labelled bounded legacy fallback. A fallback must stop as soon as push capability is present and must never shadow the normal low-latency path.
The board pushes changed physical outputs instead of requiring the host to poll
the active front panel. SEGMENT_CHANGED (9C) carries four raw TM1637 segment
bytes followed by a packed state byte: bits 0..2 are brightness, bit 7 means
ACK detection is authoritative, and bit 6 means the display ACKed.
BUZZER_CHANGED (9D) carries
the five-byte state u16 frequency_hz, u16 duration_ms, u8 muted, optionally
followed by u32 device_micros when the producer can timestamp the physical
edge. This is one current variable-length contract, not a migration chain.
These frames use sequence zero and are emitted only when the
corresponding physical output changes. The host may still request
FRONT_PANEL_GET during connection, manual refresh, or recovery.
FRONT_PANEL schema 2 reserves the upper bits of its LCD-flags byte for
TM1637 presence: bit 7 means the detection field is authoritative and bit 6
means the display ACKed. Older schema-2 firmware leaves both bits clear, so a
new host reports detection as unknown rather than inventing an absent device.
The same high bits are copied into FRONT_PANEL once the compact state has
been sampled. Presence changes emit SEGMENT_CHANGED; the host updates the
detection fields without promoting that partial event to an exact full-panel
snapshot.
The host's melody scheduler sends acknowledged BUZZER frames on one monotonic
deadline sequence; command/ACK latency is not added to every note interval.
Each accepted firmware note or explicit pause is mirrored through
BUZZER_CHANGED, then published immediately through IPC, WebSocket, Socket.IO,
bridge peers, optional native motherboard-speaker playback, and optional Web
Audio. Host renderers map a present device_micros onto their local monotonic
clocks; late notes are shortened or discarded instead of shifting later notes. Current
firmware receives one compact STATUS_EFFECT descriptor and renders the
animation locally; the rate-limited STATUS_RGB stream remains only as a
bounded older-firmware compatibility path or an explicitly owned static
preview.
STATUS_EFFECT (17) accepts either the exact one-byte release {0} or this
exact 12-byte board-owned descriptor:
u8 effect 1 breathe, 2 flash, 3 cycle, 4 transition
u8 primary_r, primary_g, primary_b
u8 alternate_r, alternate_g, alternate_b
u8 brightness
u8 minimum_brightness must be <= brightness
u16 period_ms little-endian, 640..60000
u8 repeats 0 loops; 1..255 completes on the MCU
Byte-identical descriptors are idempotent and retain phase. A changed valid descriptor atomically replaces the acknowledged manual owner without first releasing to a native profile. Learning, Warning, Fault, Boot, and the Reset watchdog cue may render above that owner; the exact latest request remains retained and restores deterministically when the higher-priority layer clears. Routine menu, radio, door, Bluetooth, save, and discard cues cannot steal a manual owner. Release clears the retained request while preserving the last physical frame until native lifecycle presentation resumes.
STATUS_RGB (14) is exactly four bytes (r,g,b,brightness) and claims a
manual static preview. It uses the same explicit STATUS_EFFECT {0} release.
STATUS_PROFILE_GET (18) is exactly one condition byte;
STATUS_PROFILE_SET (19) is exactly one condition plus the 12-byte
descriptor. STATUS_LED_CHANGED (9E) is the six-byte actual rendered state
r,g,b,brightness,effect,condition. It is changed-only and coalesces the
latest physical frame to at most one transmission per 17 ms; it does not
fabricate samples during static or flash holds. Link liveness is separate.
This is one living, unversioned board/host contract. It has no numbered route,
generation field, or version-selected descriptor identity. Compatible additions
use explicit capability/feature identifiers and new optional opcodes or fields;
readers ignore unknown optional messages while preserving strict bounds for
safety-critical owner-changing payloads such as the exact STATUS_EFFECT
descriptor above. A new behavior must not fork the protocol into parallel
numbered contract generations.
Every buzzer state from one source supersedes its preceding state. A zero- frequency positive-duration record is a timed pause; zero frequency and zero duration is an authoritative stop. Both cancel an active mirrored tone at the mapped MCU timestamp and never start a host speaker backend.
At the command/API layer a melody repeat count of zero means repeat until an
explicit stop, while 1..20 remains the bounded mode. This is the reusable
continuous WAIT/attention-ringtone path; it does not change the BUZZER
wire payload.
Melody sequencing remains PC-side configuration and stops producing future notes if the host is canceled or disconnected. A buzzer note already accepted by the MCU continues until its duration expires because there is no dedicated buzzer-stop opcode. Status-LED effects are different: their descriptors and condition profiles are rendered by the board as described above.
RELAY_SIDE sides are 0 left and 1 right; motion is 0 stop, 1 up, 2 down.
The firmware owns safe disable-before-direction sequencing. Direct
RELAY_SET is intended for R5..R8; unsafe R1..R4 combinations may be
rejected.
The current firmware implements both RESET targets as the same watchdog
reset. Target 1 does not by itself guarantee that Urboot remains active;
use a DTR/RTS reset pulse or the urclock programming workflow for reliable
bootloader entry.
Legacy DISPLAY_TEXT targets are segments 0, LCD 1, both 2, captured
front panel 3, and release 4. Scheduled segment target 5 uses:
u8 target = 5
u16 speed_ms 0 selects 260 ms; scrolling clamps to at least 80 ms
u8 text_length 0..40
u8 options bits 0..1 repeat: 0 once, 1 loop, 2 interval
bit 7 force marquee; all other input bits reserved
u16 hold_ms static/once visible duration
u8 interval_seconds 1..255 when repeat=interval
char text[text_length] printable ASCII
Text longer than four cells scrolls automatically; bit 7 explicitly scrolls a shorter message. A marquee walks into a completely empty four-cell frame after its last character. Only after that blank frame does it stop, restart, or yield to the local page for its interval. Empty text clears the scheduled host overlay. The Go API defaults to one bounded presentation; the configured automatic presenter uses interval mode at roughly two presentations per minute instead of looping continuously.
Macro step kinds are relay 0 (target 0..7, value 0..1), PWM 1
(target 0..10, value 0..4095), all relays off 2, all user PWM off 3,
and finish 4. Kinds 2..4 require zero target and value. The host streams
the timed steps; the board tracks ownership so cancellation can safely turn
off only outputs claimed by that macro.
| Response/event | Value | Payload |
|---|---|---|
| ACK | 80 |
u8 requestOpcode, u8 result (result=0) |
| HELLO | 81 |
identity above |
| ERROR | 82 |
u8 requestOpcode, u8 errorCode, detail... |
| STATUS | 90 |
fixed status below |
| SETTINGS | 91 |
fixed settings below |
| PWM_VALUES | 92 |
u8 available, u8 selected, 16*u16 values |
| I2C_RESULT | 93 |
u8 count, count*u8 address |
| RF_ENTRIES | 94 |
paginated learned entries below |
| TEMPERATURES | 95 |
named temperature records below |
| MENU_LIST | 97 |
paginated firmware-owned menu entries below |
| SEGMENT_CHANGED | 9C |
u8 raw_segments[4], u8 state (brightness bits 0..2, detected bit 6, known bit 7); unsolicited, changed-only |
| BUZZER_CHANGED | 9D |
u16 frequency_hz, u16 duration_ms, u8 muted[, u32 device_micros]; unsolicited, changed-only |
| EVENT | A0 |
u8 eventType, event-specific data... |
MENU_LIST schema 1 starts with u8 schema, u8 total, u8 nextCursor, u8 count; each entry is u8 id, u8 mode, char label[4]. nextCursor=FF
ends the list. The host treats this live list as authoritative and enriches it
with its human descriptions; a board without the optional directory capability
uses the current host manifest without introducing a second wire schema.
TEMPERATURES uses this schema:
u8 schema = 1
u8 count
count * {
u8 role 0=tLED, 1=tBT
u8 rom[8] DS18B20 ROM, wire order
i16 temperature_centiC
}
The maximum native payload currently permits at most four 11-byte entries; the firmware reports the two assigned roles.
RF_LEARN_LIST begins with cursor 0. Each RF_ENTRIES response is:
u8 schema = 1
u8 total
u8 nextCursor 0xFF when complete
u8 count currently 0..3
count * {
u8 id
u32 code
u8 bits
u8 protocol
u16 pulse_us
u8 actionKind
u8 actionValue
u8 behavior
}
Action kinds are none 0, key 1, menu 2, relay 3, side 4, and PWM
user channel 5. Values use zero-based key/menu/relay/side/channel indices.
Behaviors are press/default 0, toggle 1, momentary 2, up 3, down 4,
and stop 5. A remote button is one learned record; its RF code and timing
identity remain stable while its mapping can be changed independently.
The wire format can represent relay indices 0..7, but the current host API
intentionally refuses new direct learned mappings to indices 0..3 (R1-R4).
Those motion relays must use the firmware's reed-gated side action; direct
learned relay actions are limited to user outputs R5-R8. Existing direct records remain
visible through RF_LEARN_LIST so they can be removed or remapped.
All asynchronous EVENT frames have sequence 0. The current schemas are:
| Type | Name | Exact payload |
|---|---|---|
1 |
key | u8 type=1, u8 key, u8 gesture, u8 source, u8 sourceId |
2 |
door | u8 type=2, u8 open |
3 |
Bluetooth | u8 type=3, u8 state |
4 |
PWM channel | u8 type=4, u8 channel |
5 |
RF learned | u8 type=5, u8 learnedId |
6 |
macro | u8 type=6, u8 state, u8 macroId |
7 |
boot/reset | u8 type=7, u8 resetCause, u32 resetCount |
8 |
RF received | u8 type=8, u32 code, u8 bits, u8 protocol, u16 pulse_us, u8 learnedId |
9 |
RF learning lifecycle | u8 type=9, u8 state, u8 learnedCount, u8 mode, u8 totalSeconds, u8 remainingSeconds |
10 |
relay outputs | u8 type=10, u8 activeMask |
11 |
alert transition | u8 type=11, u8 kind, u8 active |
RF-learning lifecycle states are ended 0, cancelled 1, storage full 2,
started 3, and timer progress 4; modes are indefinite 0 and timer 1.
Alert kinds are firmware fault 1 and HOT temperature 2. Relay and alert
events update host state immediately without waiting for the next telemetry
frame; measurements remain in STATUS.
The key payload is exactly five bytes in current firmware. Keys are zero-based
0..3. Gestures are click 0, double-click 1, hold-start 2,
hold-repeat 3, hold-release 4, down 5, and up 6. Sources are physical
0, RF 1, and host 2; sourceId is FF when there is no learned RF
source.
The RF-received payload is exactly ten bytes. learnedId is FF for an
unlearned signal. Macro states are started 1, step 2, cancelled 3, and
completed 4.
The boot/reset payload is exactly six bytes:
[07, resetCause, resetCount byte0, byte1, byte2, byte3]
resetCount is the MCU-owned, EEPROM-persisted boot count. resetCause is
the AVR MCUSR bit mask captured before ordinary C++ initialization. The same
values are appended to every STATUS, allowing a host that missed the startup
event to recover the latest reset information.
Current firmware and host exchange one exact 15-byte shape. Unsupported records, unknown shapes, and trailing bytes are rejected.
offset type field
0 u8 schema = 3
1 u8 flags bit0 silent; bit1 ProgrammingMode;
bit2 swap tLED/tBT; bits3-4 motion-door
policy; bit5 door audio off; bit6 relay
audio off; bit7 reserved and cleared on apply
2 u8 lightMode
3 u8 onBrightness
4 u8 offBrightness
5 u8 displayBrightness 0..7
6 u8 statusBrightness
7 u8 outputPersistence bit0 restore motion; bit1 restore user
relays; bit2 restore stored user PWM;
bit3 retain direction relay on stop
8 u16 streamPeriod_ms 0, or at least 100
10 u8 defaultPage 0..13
11 u8 extendedFlags bit0 save last page
12 u8 displayOptions bits0-2 closed TM1637 brightness 0..7;
bits3-7 motion exit hold seconds 1..31;
encoded zero selects the 2-second default
13 u8 relayRestoreMask last R1..R8 state; restore policy gates use
14 u8 motionBreakMs exact disable-to-direction delay, 1..255 ms
PWM control is exclusively direct: PWM_SET writes one logical channel value
from 0..4095, PWM_GET reports availability plus all sixteen values, and
PWM_ALL_OFF clears every channel. Scheduled behavior belongs in host macros
or automations rather than firmware state. Channels 0..7 also update the
EEPROM-backed user values used when output-persistence bit 2 is enabled.
relayRestoreMask is continuously updated from the applied relay state, but
motion and user-relay subsets are restored only when their respective policy
bits are enabled. The programming latch always prevents output restoration.
Current STATUS requires the complete 48-byte record:
offset type field
0 u32 uptime_ms
4 i32 supply_mV
8 i32 bus_mV
12 i32 current_mA
16 i32 power_mW
20 i16 temperature1_centiC
22 i16 temperature2_centiC
24 u16 flags
26 u8 rawInputs
27 u8 activeKeys
28 u8 activeRelays
29 u8 menuPage
30 u8 programMode
31 u8 doorOpen
32 u8 bluetoothState
33 u8 pwmAvailable
34 u8 pwmChannel
35 u16 pwmValue
37 u8 lcdAddress
38 u8 pwmErrors
39 u16 framingErrors
41 u16 crcErrors
43 u8 resetCause
44 u32 resetCount
All offsets are byte offsets and the final u32 occupies the half-open byte
range [44:48] (bytes 44..47).
Readers require at least 48 bytes, always decode reset telemetry, and tolerate future appended fields after the canonical record.
The UART record carries only the millisecond counter. Every JSON status shape
(snapshot, history, RPC, REST, WebSocket, and scripting output) retains
uptime_ms as that stable machine value and also includes a derived uptime
string such as 1h13m12.21s. Consumers should calculate with uptime_ms and
may present uptime directly; the derived string is never written to the MCU
or added to the compact UART payload.
Status flag bit 13 reports the host-owned Running state, bit 14 reports that
the firmware considers the host offline, and bit 15 reports its hot-temperature
condition. The Go decoder exposes these as program_running, host_offline,
and hot in addition to preserving the raw flags. HELLO capability bit 24
advertises PROGRAM_STATE; the host sends the current state immediately after
each authenticated connection/reconnection and after every state change. It
also reasserts that semantic byte every two seconds without requesting status,
so firmware's five-second host-presence watchdog stays truthful even when no
telemetry view is subscribed. It does not probe older firmware that omits the
capability.
The UART protocol above is the MCU wire format. Network clients normally talk to the Go host instead of reproducing serial ownership, discovery, retries, and Urclock handoff. The first long-running host is the primary process and is the only process that opens the board. Secondary TUI, CLI, RPC, REST, WebSocket, and Socket.IO clients send requests to that primary.
PC operating-system actions use this ordinary authenticated command surface;
they are not native AVR opcodes. os brightness get|set VALUE reads/writes the
primary display through Win32 DDC/CI, while os sleep, os hibernate,
os restart, and related commands use the guarded power executor. JSON-RPC,
REST, WebSocket, Socket.IO, IPC, TUI, and hosted front-panel callers therefore
share one current file-watched policy and one audit-event path. Brightness and
power writes are disabled by default, and a DDC/CI-unsupported display returns
a capability error rather than falling back to an untracked shell command.
The default network host endpoint is 127.0.0.1:8787. Its TCP listener
multiplexes newline-delimited JSON-RPC and HTTP by inspecting the first request
bytes. HTTP then serves REST, standard WebSocket, and Socket.IO paths. The
public Go rpc package defines the same request, response, structured-error,
message-bound, and caller contract for direct in-process dispatch, Windows
named pipes, Unix-domain sockets, and TCP. Native-local listeners use raw
newline-delimited JSON-RPC only; they never run the HTTP protocol sniffer.
Windows pipe listeners reject remote clients and use a protected current-user
plus LocalSystem DACL. Unix listeners require an owner-only directory, publish
a 0600 socket, reject symlink/non-socket replacements, and recover a stale
socket only when the caller explicitly confirms it holds the ownership lock.
The reusable transport/client foundation is additive. Until the primary-host
record and live endpoint advertisement are advanced by issue #373, the product
primary continues to publish TCP listen as its automatic attachment path;
embedders may explicitly construct and serve a native endpoint through rpc
plus ipcjson.ServeRaw. Native-local and :8787 network listeners are meant
to run concurrently, not replace one another. Closing
the serial port does not stop this service; closing the service does not erase
MCU EEPROM or the PC configuration. JSON-RPC uses protocol 2.0; schema
negotiation reports JSON-RPC 2.0 only because that standards-defined marker
is required by the wire format. Canonical REST URLs live directly under
/api/. JSON-RPC and WebSocket peers remain capability- and semantics-driven,
so different feature sets can still interoperate.
Issue #148 is the active contract: application authentication and
authorization are disabled across raw IPC, HTTP/REST, standard WebSocket,
Socket.IO, browser UI configuration, and peer bridges. Product entry points
report auth_required: false; inbound bearer references are neither resolved
nor injected, and configured principals, Origin rules, session tickets, and
remote_policy bits do not grant or deny an operation. The capability
classifier remains in the protocol so a complete future permission design can
reuse semantic operation names, but it is dormant in the alpha runtime.
Loopback remains the default listener. Selecting a non-loopback listener still
requires the explicit ipc.allow_remote configuration choice and a valid
non-wildcard Origin list so exposure cannot happen accidentally through a bind
typo. These are configuration/exposure checks, not caller authentication. An
already-open remote standard-WebSocket or Socket.IO session is cancelled from
the pushed configuration subscription as soon as ipc.allow_remote becomes
false; a hot edge-disable therefore revokes both new requests and live event
delivery without waiting for a listener restart. An
optional outbound peer secret reference may be resolved only to contact and
upgrade an older host that still enforces the superseded bearer flow. New alpha
peers work without it.
The retained server-proof and session-ticket endpoints are dormant compatibility code, not active evidence of a security boundary. Do not expose the alpha listener to an untrusted network. Motion policies, door checks, relay sequencing, numeric bounds, OS confirmations, and exclusive programming ownership remain functional safety checks and are not application auth/authZ.
The raw transport contains one UTF-8 JSON object per line. HTTP and WebSocket use the same request and response model:
{"jsonrpc":"2.0","id":1,"method":"controller.status","params":{}}{"jsonrpc":"2.0","id":1,"result":{"uptime_ms":1234,"uptime":"1.234s"}}An omitted id is a notification and receives no direct response. Errors use
the standard error.code/error.message object. The primary serializes
mutating RPC calls through the same controller client while snapshot and event
subscriptions use cached/thread-safe paths.
Standard codes are parse error -32700, invalid request -32600, method not
found -32601, and invalid params -32602. Runtime/device failures use
-32000. The dormant future-auth implementation reserves authentication
required -32001 and remote capability denied -32003; alpha product entry
points do not emit those outcomes. The request ID is preserved on every parsed
request error.
| Method | Parameters | Result/behavior |
|---|---|---|
controller.ping |
{} |
service health and JSON-RPC wire-format identity |
controller.connect, controller.open, controller.port.open |
optional port |
resume discovery or open the requested transport, then authenticate HELLO |
controller.close, controller.port.close |
{} |
close UART and pause automatic reconnect |
controller.reset, controller.reset.lines, controller.port.reset |
optional pulse_ms |
one explicit DTR-only pulse on the connected port, pre-authentication connection candidate, or unique configured-filter match; reset lifecycle is broadcast, then fresh application authentication begins |
controller.snapshot |
{} |
cached connection, identity, status, and settings plus backend-derived reset_lines_available, reset_lines_port, and any reset_lines_reason |
controller.command.catalog |
{} |
machine-readable registered command names, aliases, usage, summary, and task group |
controller.melodies.list |
{} |
effective configured host melody catalog with validated note timing |
controller.status |
{} |
fresh board status |
controller.peripherals.get |
{} |
active board profile, resolved peripheral registry, and only the controls/actions valid for that configured wiring; requires read |
controller.peripherals.set |
peripheral_names object |
atomically replace custom host names and return normalized names plus both registries; requires host_configuration |
controller.board_profile.get |
{} |
physical identity source/stability, attachment/configuration truth, wiring mode, and opaque revision; requires read |
controller.board_profile.update |
key, mode, optional expose_raw_relays, optional expected_revision |
bind the attached board identity to ordinary-relays or cinema-seat-motion; cinema profiles may advertise both semantic seat controls and raw R1-R4 controls; requires host_configuration |
controller.peripheral.presentation.update |
key, one or more of name/icon/group/hidden/locked, optional expected_revision |
update one advertised descriptor and return it with the new profile revision; requires host_configuration |
controller.action.invoke |
action_id |
invoke one action actually advertised by the attached profile and return after the board ACK; requires board_commands |
controller.pwm.values |
{} |
authoritative availability, selected channel, raw values, and sixteen described channels with logical percent/output type/icon/curve; requires read |
controller.illumination.get |
{} |
persisted Off/Auto/On policy, on/off brightness, live door-selected target, and exact applied enclosure MOSFET channel 12 (internal index 11); requires read |
controller.illumination.set |
{ "mode": 0..2, "on_brightness": 0..255, "off_brightness": 0..255 } |
preserves every unrelated board setting, applies live, waits for durable EEPROM readback, and returns the authoritative illumination state; requires board_commands |
controller.illumination.override |
{ "value": 0..4095 } |
applies a temporary exact raw value to enclosure MOSFET channel 12 and returns authoritative readback; policy/EEPROM ownership remains unchanged and may reassert its target on the next policy event; requires board_commands |
controller.pwm.set |
channel (0..15) and exactly one of percent (0..100) or raw_value (0..4095) |
map logical intensity once or write exact duty, then return the complete described sixteen-channel snapshot and publish pwm.changed to every state subscriber; requires board_commands |
controller.pwm.channel.configure |
channel, output_type, icon, curve, gamma |
persist one channel's host-owned identity and transfer function and broadcast peripherals.changed; requires host_configuration |
controller.pwm.off |
{} |
clear every PWM channel, read back, return the complete authoritative snapshot, and publish pwm.changed; requires board_commands |
controller.status_led.set |
red, green, blue, brightness (0..255) |
apply an explicit steady status-light override through the shared output scheduler; requires board_commands |
controller.status_led.release |
{} |
release the explicit status-light override back to the board/state policy; requires board_commands |
controller.temperatures |
optional rescan |
named temperatures and ROM identities |
controller.menu.list, controller.menu.current |
{} |
live board catalog when advertised, otherwise the canonical capability-limited manifest |
controller.menu.jump, controller.menu.page |
page ID or name |
select a board menu page |
controller.command.execute |
command |
run any ordinary controller command; quit/exit requests primary shutdown |
controller.firmware.build |
optional firmware_features array or no_firmware_features: true |
compile only the host's canonical configured project; returns an operation ID and normalized final log; requires programming |
controller.program_state.get |
{} |
current host-owned Idle/Running owners, reason, and revision |
controller.program_state.set |
mode, optional owner, reason |
set/clear one host-owned Running claim and mirror it to capable firmware |
controller.rf.list |
{} |
all learned records |
controller.rf.learn.start |
optional mode, optional timeout_ms |
start indefinite multi-code learning (default) or bounded timer multi-code learning; single and one-shot are timer aliases |
controller.rf.learn.status |
{} |
current learn-session state |
controller.rf.learn.cancel |
{} |
stop learning and emit an explicit end event |
controller.rf.map |
id, semantic action, optional target and behavior |
validate and replace one learned mapping, then return the full board readback |
controller.rf.remove |
learned-record id (0..19) |
remove one record, then return the full board readback |
controller.rf.clear |
exact confirm: "CLEAR RF" |
clear all learned records and return an empty inventory |
controller.rf.transmit |
code, bits, protocol, optional pulse_us, repeats |
transmit a validated waveform; omitted repeats means one burst |
controller.event.latest |
{} |
latest monotonically increasing host event ID |
controller.event.next |
after_id, optional kind, opcode, stream, timeout_ms |
long-poll the next event or exact unsolicited opcode without status polling; stream is activity, state, telemetry, or debug |
controller.opcode.send, .exchange, .request |
opcode, optional payload (base64) or payload_hex, optional expect_opcode (ACK by default) |
exchange an opaque 1..255 UART opcode without requiring the host to understand its schema |
controller.app.instances |
{} |
list live UI/automation instances and bounded non-secret state |
controller.app.bridge |
{} |
query the original coordinator bridge instance, including bounded process self-information |
controller.app.instance.get |
id |
query one exact live instance |
controller.app.instance.report |
id, surface, page, optional state, lease_seconds, values, self |
create or refresh an ephemeral instance report; WebUI/TUI followers use bounded navigation mode/group/epoch/revision values |
controller.app.instance.remove |
id |
remove one instance report immediately |
controller.app.navigate |
page, optional target |
navigate *, a surface such as webui/tui, or one exact instance ID |
controller.app.launch |
surface, optional mode, target, page, idempotency_key |
ensure, launch, or focus only the named tui or webui; reports OS acceptance separately from live instance confirmation |
controller.app.navigation.commit |
group, source, page, operation_id |
commit a follower group's canonical page and return its epoch, revision, correlated operation ID, and ordered deliveries |
controller.app.action |
kind, optional value, target, operation_id, timeout_ms |
freeze the selector to exact live clients, push one correlated delivery per target, and return queued/rejected outcomes |
controller.app.action.ack |
operation_id, delivery_id, instance_id, state, optional reason |
acknowledge one exact delivery as applied or rejected; duplicate identical terminal acknowledgements are idempotent |
controller.app.action.outcome |
operation_id |
read the bounded current operation with per-target queued, applied, rejected, or timeout state |
controller.history.status |
optional ISO-8601 since |
retained measurement samples, including samples restored from the bounded host data store after restart |
controller.history.timeline |
optional since, limit |
durable important-event timeline |
controller.os.facts.catalog, controller.host.facts.catalog |
{} |
fixed read-only Windows profile descriptors, columns, and row limits |
controller.os.facts, controller.host.facts |
optional profile and timeout_ms (100–5000) |
bounded read-only result for system, computer, firmware, storage, or serial; empty profile selects system |
controller.lcd.presentation.status |
{} |
prompt/priority presentation state |
controller.lcd.presentation.configure |
enabled, debounce_ms, priority_hold_ms |
configure host LCD presentation |
controller.lcd.prompt |
line1, line2 |
queue a debounced prompt mirror |
controller.lcd.priority |
kind, line1, line2, optional hold_ms |
display a priority overlay, then restore the prompt |
controller.message.send |
typed message envelope below | route/log a message and optionally display it on the board LCD |
controller.bridge.list |
{} |
configured peers and live connection state, without URLs or credentials |
controller.bridge.call |
peer, nested JSON-RPC request |
correlated call through that peer; bridge ingress cannot invoke this method or pivot through command/app-action wrappers |
controller.network.peers.get |
{} |
persistent peer topology including optional secret references but never resolved or plaintext credentials |
controller.network.peers.set |
peers array |
atomically replace and hot-apply peer topology; unknown fields and plaintext auth_token are rejected, and host_configuration classifies remote policy when permissions return |
controller.artifact.manifest, controller.artifact.list |
optional artifact kind |
update capability/default/current discovery and content-addressed catalog |
controller.artifact.fetch, controller.artifact.capture |
typed fetch or explicitly authorized capture request | queue transfer/readback through the primary |
controller.update.firmware, controller.update.eeprom, controller.update.host |
artifact SHA-256 plus authorized: true |
queue the guarded update for that domain |
controller.restore.flash |
captured-flash SHA-256, authorized: true, optional method, port |
restore a flash-backup through its own guarded operation; never reinterpret it as firmware |
controller.update.status |
optional operation id |
asynchronous transfer/programming status and progress |
controller.device.status |
{} |
local-device HTTP/event-stream health and last confirmed power state |
controller.device.action |
action, optional text or count |
bounded Local Device living power, display, alert, or passive-refresh action |
controller.device.inspect |
resource |
sanitized capabilities or snapshot document only |
controller.integrations.local.get |
{} |
credential-free local-device and data-hub enable/URL settings |
controller.integrations.local.set |
local_device, data_hub |
validate and persist LAN-only device and loopback-only data roots |
controller.integrations.status |
{} |
requested and effective buzzer route, mirror backend, and board reconciliation state |
controller.mirror.configure |
MirrorConfig |
persist explicit board IDs, one coordinator, receive allowlist and semantic groups |
controller.mirror.inspect |
{} |
actual board generation, supported volatile state, board-scoped observations and unsupported domains |
controller.mirror.status |
{} |
mirror members, ordering, ACK/readback results, failures and dropped notes |
controller.mirror.apply |
routed Write |
bounded identity/generation/intent/sequence-validated volatile receive operation |
controller.mirror.resync |
group_id, source_board_id |
explicitly synchronize available state; never replay tones or disconnected actuation |
See Board mirroring for commissioning,
capabilities, exclusions, echo limits and physical-acceptance gates.
| controller.ports | {} | current serial devices with stable identity fields |
| controller.quit, controller.exit | {} | close the primary and emit lifecycle shutdown |
The melody catalog is live host configuration. After its effective contents
change, subscribers receive a state-stream controller.state notification
with params.kind set to melodies.changed, plus count and opaque
revision metadata. Consumers should refresh controller.melodies.list on
that event and may also refresh when presenting a picker to guarantee a
current user choice.
Full TUI instances and each browser tab follow the ephemeral default
navigation group unless that client opts out. Browser opt-out is scoped to the
current tab; it is not host configuration and does not affect other clients.
A lease reports only
presence, capabilities, and a process-session epoch/revision; it never mutates
the canonical page. A local page change calls controller.app.navigation.commit
with a client operation ID. The primary commits exactly once, returns
group_epoch, revision, operation_id, and canonical page, then publishes
the same ordered action to the source and every live follower. A late title or
lease callback is therefore unable to roll a source back. When the last lease
leaves or expires the group is discarded, so active pages are never persisted
as host configuration. Retrying the same operation ID is idempotent only while
its epoch, revision, and page remain canonical. Once a newer operation advances
the group, replaying the older operation returns an error instead of a stale
cached page that could roll a client back.
After an event-session reconnect a follower adds
navigation_catch_up=true; the coordinator then re-sends the canonical page
instead of treating the client's potentially stale page as new intent.
Coordinator navigation actions carry navigation_sync=group, group, epoch,
revision, source-instance, and operation-ID metadata. Clients reject duplicate, older, or
foreign-epoch deliveries until an authoritative primary reconnect resets their
acceptance cursor. Generic controller.app.action and /api/app/action
callers cannot supply these coordinator-owned fields. Authenticated remote
control instead uses controller.app.navigate or /api/app/navigate, which is
individually authorized and audited under host_configuration. An explicit
navigation can still target an opted-out instance, but does not enroll it in a
group or make it follow later broadcasts. Prompt input, cursor/editor/modal
state, terminal visibility, and serial ownership always remain local.
controller.app.launch and /api/app/launch are coordinator-owned graphical
entry points, not process execution APIs. They accept only tui or webui and
the modes ensure, launch, or focus; there is no executable, argument,
environment, command, or shell field. ensure reuses a matching live instance
when one is registered. focus never creates a duplicate and truthfully returns
unavailable where the desktop/compositor has no safe focusing primitive.
launch creates a new surface. An optional exact instance ID targets only
ensure or focus, and an idempotency key deduplicates retries. The result
distinguishes an OS-accepted request (accepted) from a subsequently registered
live app instance (confirmed). On Linux the coordinator must already run as
the non-root graphical user and inherit DISPLAY or WAYLAND_DISPLAY; it does
not search other users' sessions or use SSH. The TUI child attaches to the
coordinator IPC endpoint and cannot become a second UART owner.
Launch and registration are serialized per surface through the bounded
registration wait, so a request cannot confirm a window started by another
concurrent request. To prevent accidental window storms, the coordinator permits
at most three new-window start attempts per surface in a rolling ten-second
window; ensure/focus calls that reuse an existing instance do not consume
that allowance. A limited request returns effective=unavailable with a
rate-limit reason rather than invoking an OS launcher.
Typed application actions are resolved once against the pruned live instance
registry. The returned operation records the exact instance IDs and surfaces;
an unknown or offline selector is rejected with an empty target set rather than
inventing an instance. Clients advertise a bounded app_actions list in
their presence values and apply only a push addressed to their exact instance
ID. Each target push carries coordinator-owned operation_delivery_id and
operation_expires_at metadata. Callers cannot supply or override either
field. The client rejects an expired or malformed deadline, deduplicates the
operation-plus-delivery receipt, and returns that delivery nonce as the
required delivery_id in its acknowledgement. The coordinator accepts only a
nonce issued for that exact operation target before its deadline, then records the client-reported
applied or rejected result. An action without a live outcome-capable
advertisement is rejected; it is not delivered through an untracked path.
Operation history is bounded and expires; ordinary delivery and
outcome transitions use the existing event streams and bridge fan-out, never
polling. Successful queued/applied transitions use the state stream so they do
not flood operator activity logs, while rejection and timeout remain visible
one-shot activity events.
Unknown well-formed optional action capabilities remain visible in discovery
without rejecting the whole instance. A namespaced custom action such as
pealayer.play becomes executable only while a matched live instance advertises
that exact capability. Custom namespaces cannot use the reserved app.*,
controller.*, or command names; values are limited to 4096 bytes and cannot
contain NUL, CR, or LF. They always use the correlated exact-target path with a
delivery nonce, deadline, deduplication receipt, and terminal ACK outcome.
These receipts provide correlation and deduplication, not responder
authentication: alpha clients share a trusted event fabric and authorization
is disabled by policy. Transport-session identity binding remains tracked in
#108 and must be implemented with the future auth work, not inferred from a
delivery nonce visible to event subscribers.
Pealayer publishes its local automation routes in the instance self.vars
map. rpc, websocket, and ipc are full URLs whose authority is the same
Pealayer control listener; their canonical paths are /api/rpc, /ws, and
/api/ipc. PCController preserves these advertised URLs for diagnostics and
must not synthesize protocol-specific ports or fall back to historical split
listeners. Application-action delivery still travels over the authenticated
PCController connection and does not make PCController a proxy for those URLs.
RF learning has two mutually exclusive modes. An omitted mode or
{"mode":"indefinite"} keeps accepting codes until cancellation. A bounded
session uses {"mode":"timer","timeout_ms":30000} and continues accepting
multiple codes during that window. The accepted single and one-shot mode
aliases normalize to timer; status and snapshots always return the canonical
mode together with configured_ms, live remaining_ms, captured count, and an
explicit end reason.
controller.rf.map uses operator-readable values rather than exposing compact
firmware enums. Actions are none, key, menu, relay, side, or pwm.
Key targets are 1..4; relay targets are the user relays 5..8; PWM targets are
0..10; menu targets are prev, next, dec, or inc; and side targets are
left/A or right/B. Key, relay, and PWM behaviors are press, toggle,
or momentary; side behavior is up, down, or stop. none accepts neither
target nor behavior. These typed mutations require board_commands, while list
and learn status remain read operations.
The guided Web/TUI workflow starts a fresh 30-second timer for exactly one
labeled A/B/C/D step. It reacts only to a new rf.learn.mapping-required event,
cancels the capture window, reads the stored record back, shows its exact
identity, and waits for explicit confirmation before mapping. Disconnect,
timeout, cancellation, and full-storage events leave the step retryable; they do
not synthesize a capture or optimistic mapping.
The opaque opcode exchange is deliberately versionless. It retains the native 48-byte payload bound, sequence correlation, ACK/error validation, board-command authorization, and one caller-selected response opcode, but does not interpret experimental payload bytes. This lets a client query a newer firmware feature through an older generic-capable bridge without adding API-version branches.
Application-instance coordination uses the same event fan-out. A WebUI, TUI,
CLI, automation, or bridge peer reports an ID, surface, current page, optional
presence state, bounded process/browser self information, and at most 32
non-secret presentation values. Reports may use
a bounded lease (45 seconds by default, 300 maximum); this liveness refresh is
not board-state polling. Credential-like value keys and control-bearing values
are rejected. Native process self-information can include PID, parent PID,
image path, working directory, start time, and explicitly allowlisted runtime
variables. Raw environment, PATH, arguments, and credential-like variable keys
are never published. The original host registers a non-expiring bridge
surface and exposes it directly through controller.app.bridge; every other
instance can therefore find the actual coordinator instead of guessing which
UI owns it. Navigation targets *, a surface (webui or tui), or an exact
instance ID. The resulting app.page event retains source and target metadata
through IPC, REST, WebSocket, Socket.IO, and bridge paths so each recipient can
apply only matching commands and avoid echo loops.
The same targeted action envelope carries app.title, app.progress, and
app.osc. A TUI continuously derives its normal terminal title from the live
product name and selected page, restores the previous title when Bubble Tea
exits, and reports the effective title plus OSC capabilities in its instance
values. app.progress accepts clear, normal, error, indeterminate, or
warning plus an optional/required 0..100 value and emits Windows Terminal's
OSC 9;4 progress state. app.osc accepts only a bounded 1..512-byte payload
beginning with a numeric selector; embedded ESC, BEL, ST, C0, and C1 controls
are rejected so callers cannot smuggle multiple terminal sequences in one
action. app title auto resumes page-derived titles.
Artifact lifecycle events are also presentation state. A fresh update.*
event immediately selects the Updates page in matching WebUI/TUI instances,
updates the visible operation ID/state/detail/progress bar, and updates the TUI
title plus OSC 9;4 tab/taskbar progress. Completion clears terminal progress;
failure leaves an error state visible. The UI reads operation status on initial
entry or explicit refresh, while ongoing changes remain push-driven.
Firmware may emit the compact APP_NAVIGATION event with target all,
webui, or tui and an ASCII page ID. It enters the same host event path and
is processed immediately; it does not wait for a UI poll. Host-to-board
navigation/query can use the generic opcode exchange until a specific semantic
opcode is useful enough to standardize, preserving the living protocol model.
Programming, macros, relays, PWM, RF transmit, settings, display, RGB, buzzer,
and I2C operations remain available through controller.command.execute, so API
clients do not need a second less-safe command implementation. The response is
correlated to its JSON-RPC ID and the inner native command remains correlated
to its UART sequence.
All JSON endpoints share the IPC listener:
| Method and path | Purpose |
|---|---|
GET /healthz |
unauthenticated liveness and service identity; no board data |
GET /api/ui-config |
unauthenticated non-secret browser bootstrap contract |
POST /api/rpc |
one JSON-RPC request |
GET /api/snapshot |
cached controller snapshot |
GET /api/peripherals |
custom names, the canonical 34-entry peripheral registry, and the resolved ordered 21-entry control registry; read capability |
PUT /api/peripherals |
replace custom names from peripheral_names; host_configuration capability |
GET /api/pwm |
authoritative availability, raw values, logical percentages, and per-channel configuration; read capability |
PUT /api/pwm |
write channel plus exactly one of logical percent or exact raw_value, then return the described snapshot; board_commands capability |
DELETE /api/pwm |
clear all sixteen channels and return their authoritative readback; board_commands capability |
GET /api/pwm/channel |
return the current per-channel output type, icon, curve, and gamma configuration; read capability |
PUT /api/pwm/channel |
persist channel, output_type, icon, curve, and gamma, then broadcast the descriptor change; host_configuration capability |
GET /api/commands |
machine-readable shared command catalog |
GET /api/program-state |
current host-owned Idle/Running state |
PUT or POST /api/program-state |
update owner, mode, and optional reason |
GET /api/menu/catalog |
live firmware menu IDs, labels, kinds, flags, and parent relationships |
GET /api/menu/layout |
live visible mask and ordered menu IDs |
PUT or POST /api/menu/layout |
persist a validated visible_mask and order on the board |
GET /api/host-menus |
current watched host-presented menu directory |
PUT or POST /api/host-menus |
validate and persist the complete host-presented menu directory |
GET /api/os/status |
bounded host status and OS-action policy state |
GET /api/os/facts?profile=... |
bounded read-only Windows facts; profile=list or catalog returns the fixed catalog |
POST /api/os/key |
validated virtual-key request under the virtual_keys capability |
POST /api/os/power |
confirmed lock, suspend, hibernate, restart, shutdown, or monitor-brightness request under power_actions |
POST /api/command |
{"command":"..."} through the ordinary command engine |
POST /api/messages |
typed message envelope |
POST /api/display |
arbitrary segment/LCD text with speed_ms, duration_ms, repeat, interval_ms, and optional forced scroll |
POST /api/opcode |
opaque opcode exchange using the same payload/expected-response fields as JSON-RPC |
GET /api/app/bridge |
original coordinator bridge instance and bounded process self-information |
GET /api/app/instances |
list instances, or query exact ?id=... |
POST /api/app/instances |
create/refresh an instance report |
DELETE /api/app/instances?id=... |
remove one instance report |
POST /api/app/navigate |
navigate a page with optional target instance/surface |
POST /api/app/action |
freeze and route a correlated page/title/progress/OSC/command/lifecycle action to exact live targets; response includes accepted plus the operation |
POST /api/app/action/ack |
acknowledge one exact target as applied/rejected |
GET /api/app/action/outcome?operation_id=... |
read one bounded per-target operation outcome |
POST /api/app/launch |
ensure, start, or focus the named TUI/WebUI surface without accepting process or shell input |
GET /api/bridges |
configured peer names/protocols and live state |
POST /api/bridges/call |
peer plus a nested JSON-RPC request |
GET /api/artifacts/manifest |
artifact/default/current discovery and latest operation |
GET /api/artifacts |
content-addressed catalog, optionally filtered by kind |
POST /api/artifacts/upload, /fetch, or /capture |
import, remote-download, or explicitly read device memory |
GET or HEAD /api/artifacts/{kind}/{sha256} |
checksum/ETag/range-capable artifact download |
POST /api/updates/firmware, /eeprom, or /host |
explicitly authorized update job |
POST /api/restores/flash |
explicitly authorized captured-flash restore job |
GET /api/updates/status/{id} |
update progress and result |
POST /api/webhooks/inbound |
typed incoming message when inbound webhooks are enabled |
/api/integrations/datahub/* |
authenticated service-neutral streaming bridge to the configured loopback data service |
/api/integrations/device/* |
always fails closed; device operations require typed RPC |
POST /ipc |
JSON-RPC compatibility on the configured WebSocket path |
Core routes are versionless. /api/v1 and every /api/v1/... path are
explicitly rejected rather than aliased to the living /api/... contract.
All data/API routes except /healthz and the non-secret /api/ui-config
bootstrap apply host authentication. Bodies are limited to
1 MiB. Unsupported methods are rejected, and the inbound webhook path is 404
when disabled. An inbound webhook is data, not an implicit shell command. To
make it actionable, enable a narrow text mapping whose resulting command still
passes the normal safety path.
Before publishing an inbound webhook, the host removes credential-shaped query
and metadata names, caller-reserved provenance, cookies, referrers, signatures,
and all headers outside a bounded trace/content allowlist. Host-owned method and
path provenance is then added, and an empty-body fallback records only the
routed path—not the raw RequestURI or query string.
controller.peripherals.get and GET /api/peripherals return this shape:
{
"peripheral_names": {"relay.5": "Workbench lamp"},
"board_profile": {
"key": "cafe-cinema",
"board_identity": "serial:board-42",
"identity_source": "usb-serial",
"identity_stable": true,
"mode": "cinema-seat-motion",
"configured": true,
"attached": true,
"revision": "7ce113f9a39b1a123d0f1ab2"
},
"peripherals": [
{
"key": "relay.5",
"kind": "relay",
"role": "user-output",
"index": 5,
"default_name": "User Relay 5",
"name": "Workbench lamp",
"control": "relay"
}
],
"controls": [
{
"key": "relay.5",
"kind": "relay",
"order": 5,
"name": "Workbench lamp",
"control": "relay",
"actions": [
{"id": "relay.5.on", "verb": "on", "name": "On"},
{"id": "relay.5.off", "verb": "off", "name": "Off"}
]
}
],
"effects": [
{
"reference": "effect:police",
"id": "police",
"name": "Police red / blue",
"category": "Lighting",
"description": "Alternating red and blue emergency-light sweep",
"kind": "strip-stream",
"engine": "host",
"editable": true,
"duration_ms": 5000,
"default_fps": 20,
"default_pixels": 100,
"program": {
"primitive": "alternating-zones",
"primary": {"red": 255, "green": 0, "blue": 0},
"secondary": {"red": 0, "green": 0, "blue": 255},
"period_ms": 800,
"step_ms": 100,
"swap_after_steps": 4,
"dim_intensity": 36
}
}
]
}Profiles are keyed by the strongest available physical identity: provisioned
USB serial first, USB instance fallback second, and the serial port only as a
last resort. Fallback identities explicitly report identity_stable: false.
Board display names and firmware build hashes are never durable identities.
This first slice is deliberately host-owned and does not rewrite MCU EEPROM.
An unconfigured board advertises R1..R4 as unavailable and omits their control
actions. ordinary-relays advertises R1..R8 as relay.N.on|off.
cinema-seat-motion keeps R1/R2 and R3/R4 as internal direction/enable pairs
and advertises only seat.a/seat.b, with stable up, down, and stop
action IDs. It never publishes parallel motion.a/motion.b aliases. R5..R8
remain ordinary relays in both modes; MOSFET/PWM 0..10 remain directly
controllable, while system-owned PWM 11..15, displays, and sensors remain
descriptive peripherals.
name, icon, group, hidden, and locked are mutable presentation policy
stored in the PC profile; the descriptor key and action IDs remain stable.
Supplying an empty text field to controller.peripheral.presentation.update
clears that text override. hidden is a discoverable consumer presentation
preference: the descriptor remains in the catalog so a UI can reveal it again.
locked is enforced by PCController for semantic actions, relay commands, and
PWM writes as well as advertised to consumers. Callers may send the opaque
expected_revision; a stale revision is rejected with -32000. Successful
profile/presentation changes publish a state-stream
controller.state event whose params.kind is peripherals.changed; clients
then refresh the full catalog. Legacy whole-map peripheral_names remains a
compatibility fallback and also advances the revision. No presentation method
reads or writes MCU EEPROM.
effects is the unified, editable PCController-owned catalog. Lighting entries
are included only while the board is connected and its authenticated HELLO
advertises addressable-strip streaming; timed sequences use the same array.
Each lighting descriptor includes the complete declarative program, display
metadata, default duration, frame rate, and pixel count. Named starter entries
are first-install data, not protocol constants. A consumer must parse the live
catalog before offering an effect and must not infer availability from stale
configuration or a generic command string.
For a one-shot board tone, controller buzzer --frequency 440 --duration 125
is the typed top-level spelling of controller exec buzzer 440 125. Both use
the same primary/local command engine. For a nonzero tone, the engine stops any
host melody, reads board settings, and returns buzzer suppressed: board is silent without a write when firmware-owned silent mode is active. Frequency
zero remains the authoritative stop command: it stops host melody output and
emits the board stop opcode without depending on settings availability.
Settings mutations return confirmed readback from the runtime durability loop.
Formatted settings include the authoritative persisted value; an accepted
write is not reported as durable until readback matches and persistence is set.
All PWM read and mutation methods return the native PWM_VALUES JSON shape:
{
"available": true,
"selected_channel": 3,
"values": [0, 0, 0, 2048, 0, 0, 0, 0, 0, 0, 0, 0, 256, 0, 0, 0]
}The response is an authoritative board readback, not an optimistic echo of the
requested value. Channels 0..10 are user/commissioning outputs and may be
presented as generic PWM controls. Channels 11..15 are role-specific:
enclosure illumination, power indication, and status red/green/blue. They stay
in every sixteen-value snapshot, but user interfaces must represent them with
their dedicated controls and semantics rather than another generic slider.
controller.pwm.off and DELETE /api/pwm deliberately clear all sixteen
channels, including the role-specific outputs.
Host facts are diagnostics, not a general management-query endpoint. The authenticated read surface exposes only these compiled profiles:
| Profile | Fixed class | Maximum rows | Columns |
|---|---|---|---|
system |
Win32_OperatingSystem |
1 | caption, version, build, architecture, last boot, total/free visible memory |
computer |
Win32_ComputerSystem |
1 | manufacturer, model, system type, physical memory, logical processors |
firmware |
Win32_BIOS |
2 | manufacturer, firmware version/release date, SMBIOS level |
storage |
Win32_LogicalDisk |
16 | fixed-volume ID, type, filesystem, size, free space |
serial |
Win32_SerialPort |
32 | device ID, name, description, status, configuration error code |
JSON-RPC rejects unknown fields and accepts only profile plus an optional
timeout_ms from 100 through 5000. REST accepts one profile query parameter
in addition to the normal authentication token; any caller-supplied query,
class, column, or repeated parameter receives 400. The provider caps each
cell at 512 bytes, the whole result at 64 KiB, the combined row count at 32,
and reuses a five-second private cache across CLI, IPC, REST, and WebSocket RPC.
Calls are serialized behind one bounded worker so a stuck native call cannot
create unbounded OS threads. The surface is read-only and exposes no arbitrary
WQL, write, process-control, or method-invocation path. Non-Windows hosts return
an explicit unavailable error.
The production React application is compiled into the Go executable and served
from the same listener at /; no adjacent asset directory is required at
runtime. Hashed assets use immutable caching while the application shell uses
revalidation. Static responses support GET, HEAD, strong ETags,
If-None-Match, If-Range, closed/open/suffix byte ranges, multipart ranges,
exact Content-Length/Content-Range, and 416 responses. API, WebSocket,
Socket.IO, and health paths remain reserved and cannot fall through to the SPA
shell. Canonical path validation rejects traversal and encoded separator/dot
variants before opening an embedded file.
The embedded static set includes the Web App Manifest, mobile presentation metadata, icons, and a deliberately network-only service worker. Supported browsers may install the UI and expose its Overview, Workbench, Activity, and Settings shortcuts, but the worker retains no response cache and provides no offline control surface. If the host listener stops, the browser observes the network failure immediately. The manifest and worker receive the same exact MIME, validator, range, and reserved-path treatment as the other embedded assets.
controller web starts the same primary host services used by the TUI but does
not construct a terminal model or read terminal input. It owns discovery,
serial, automations, global hotkeys, integrations, and the command dispatcher
for its whole lifetime; --no-open serves the application without launching a
browser. The UI sends terminal commands through same-origin typed RPC and keeps
a persistent subscription for status, board/host events, and global application
actions. A TUI is therefore not required in web mode. When the TUI is the
primary instead, the same action broker retains bounded TUI delivery and also
mirrors each valid page, title, progress, and OSC action into cursor-based
browser event history. Browsers apply matching page actions and fresh update
lifecycle navigation; terminal-only actions remain available to matching TUI
instances without being interpreted by the browser.
The loaded application publishes the living, unversioned window.PCController
browser surface for local automation and inspection. Its inspect() result
reports host transport and physical-board connection as separate facts;
command(), refresh(), and navigate() use the same validated host paths as
visible UI actions. State changes also dispatch pccontroller:state with the
same non-secret snapshot. This surface is a convenience adapter, not a second
contract or an authorization bypass.
The local-integration proxy resolves only the configured short names; request
data can never supply an upstream URL. The data hub is restricted to loopback,
while the typed device manager accepts only loopback/private/link-local addresses or explicitly local names. The bridge
streams HTTP bodies and WebSocket upgrades without buffering full exports,
preserves upstream range/ETag semantics, disables environment proxies, and
removes PCController authorization, cookies, forwarding headers, and
access_token before forwarding. Upstream Set-Cookie is also removed so a
companion service cannot set cookies on the controller origin.
Connect to the configured ipc.websocket_path (default /ipc). After the
authenticated HTTP upgrade, send ordinary JSON-RPC request objects. Two
connection-local control methods manage push data:
Browser clients obtain the one-use subprotocol ticket described under
Authentication; native clients may authenticate the upgrade directly with a
Bearer or compatibility header. Neither client type places credentials in the
WebSocket URL. The successful subscribed response includes the authenticated
principal used for subsequent capability decisions.
{"jsonrpc":"2.0","id":1,"method":"controller.subscribe","params":{"topics":["events","state","opcodes","status"],"opcodes":[156,157,225],"interval_ms":200,"after_id":0}}{"jsonrpc":"2.0","id":2,"method":"controller.unsubscribe","params":{}}Topics are events, state, debug, opcodes, and status (opcode and
telemetry are accepted as singular/status aliases). An omitted opcode filter receives every
valid nonzero opcode; a supplied opcodes array selects exact values 1..255.
Status interval is 50..60000 ms and defaults to 200 ms. Event
delivery starts after after_id; zero starts at the current tail and does not
replay the whole timeline. Push messages are JSON-RPC notifications:
The events topic is the human-facing activity stream: actionable entries such
as connection, door, RF, macro, automation, and fault changes. It has its own
retention ring, so animation frames and measurements cannot evict useful
one-shot activity. state carries changed-only display, status-light, buzzer,
and other continuous output frames for immediate UI mirrors without writing
them to activity consoles. debug is an explicit opt-in for raw rx, tx, and
opcode trace events. Subscribe to opcodes for opaque unsolicited frames and
status for the independently paced live measurement stream. Durable timeline
storage and ordinary TUI, WebUI, and secondary-console logs consume activity
only; diagnostic monitors may deliberately request the noisier streams.
Low-rate activity/state edges that change cached controller authority (for
example relay, door, PWM selection, settings, illumination, connection, macro,
and an exact front-panel read) are immediately followed by a
controller.snapshot notification on the same subscribed connection. Clients
apply that pushed snapshot instead of issuing a manual refresh. High-rate
front_panel.segment and status_led.changed events remain compact patches so
animation does not amplify full snapshots. A snapshot distinguishes
have_front_panel (all LCD/menu/key fields are exact) from
have_front_panel_segments (the physical four digits and brightness are exact);
rendering the latter must not imply authority for the former.
{"jsonrpc":"2.0","method":"controller.event","params":{}}
{"jsonrpc":"2.0","method":"controller.state","params":{}}
{"jsonrpc":"2.0","method":"controller.snapshot","params":{}}
{"jsonrpc":"2.0","method":"controller.debug","params":{}}
{"jsonrpc":"2.0","method":"controller.opcode","params":{"opcode":225,"payload":"qrs="}}
{"jsonrpc":"2.0","method":"controller.status","params":{}}
{"jsonrpc":"2.0","method":"controller.error","params":{}}A status subscription participates in demand accounting. UART stays open after
unsubscribe, but periodic status polling stops when no UI, script, automation,
IPC, WebSocket, or bridge consumer requires it. Asynchronous board events keep
flowing because they do not require polling. controller.close deliberately
pauses automatic reconnect; a later controller.open/controller.connect
clears that pause only after a transport has authenticated successfully.
Socket.IO uses a distinct configured path, default /socket.io/. The bounded
adapter is genuine Engine.IO v4 / Socket.IO framing over WebSocket:
ws://HOST:PORT/socket.io/?EIO=4&transport=websocket
It sends an Engine.IO open packet, accepts Socket.IO connect/disconnect, and
implements Engine.IO ping/pong. Socket.IO event packets use the usual
42["name",payload] form. Supported incoming events are:
In the immediate alpha, Engine.IO opens without an application credential; Origin enforcement still precedes the open packet. Header and one-use ticket code is retained only for future design and older-host upgrade compatibility.
| Event | Payload | Response/push events |
|---|---|---|
subscribe |
WebSocket subscription object | subscribed, then controller.event, controller.opcode, controller.status, or controller.error |
unsubscribe |
{} |
unsubscribed |
message |
typed message envelope | message.accepted or error |
command |
{"command":"..."} |
command.response |
rpc |
JSON-RPC request | rpc.response |
This adapter deliberately does not implement Engine.IO long-polling,
namespaces, rooms, binary attachments, acknowledgement callbacks, or a general
Socket.IO cluster. Clients must force WebSocket transport. The standard /ipc
endpoint remains the preferred full JSON-RPC API.
Each enabled host webhook has a name, event-kind filter, URL, method, optional
headers/body template, timeout, retry policy, and optional signing secret.
Methods are GET, POST, PUT, PATCH, and DELETE. A suffix * in the event-kind
filter matches a prefix; empty or * matches all events. webhook.* events are
not sent back through webhooks, preventing a direct delivery loop.
GET and DELETE add id, kind, and text query parameters. Body-bearing
methods send the event JSON by default. A configured template can use
{{id}}, {{kind}}, {{text}}, {{source}}, {{time}}, {{event}}, and
{{metadata}}. In a JSON body, a placeholder inside a quoted string is JSON
escaped; outside a string, event and metadata are inserted as JSON values.
The completed body—not merely the template—is limited to 256 KiB and must be
valid JSON when its content type is JSON.
Delivery is durable. Before a worker sends an event, the queue is atomically
persisted under the host data directory as state/outbound-webhooks.json.
The state contains delivery/event metadata, but never configured URLs,
headers, rendered bodies, or signing secrets. It is bounded to 1,024 pending,
512 dead-letter, and 2,048 recent deduplication records; completed
deduplication records expire after 24 hours. Eight workers prevent one slow
target from blocking unrelated targets. Shutdown drains pending work until the
bounded host shutdown deadline; an interrupted attempt remains recoverable on
the next launch.
Each logical target/event pair receives a deterministic Idempotency-Key and
a stable delivery/correlation ID. Every attempt has a fresh attempt ID and
attempt number. Receivers should deduplicate on Idempotency-Key, because a
network failure can make the sender unable to prove whether the receiver
committed an earlier attempt. The emitted headers are:
Idempotency-KeyX-PCController-Delivery-IDX-PCController-Correlation-IDX-PCController-Attempt-IDX-PCController-Attempt
Only 2xx is success. Transport failures and HTTP 408, 425, 429, and 5xx retry
with exponential backoff and 20% jitter. Defaults are six attempts, 500 ms
initial delay, and 30 seconds maximum delay; per-target settings can override
them. A valid delta-seconds or HTTP-date Retry-After is honored when it is
longer than the computed delay, capped at 24 hours. Other HTTP failures and
exhausted retryable failures move to the durable dead-letter list. Response
draining is capped at 64 KiB. Redirects are never followed: a 3xx response is a
failure, so credentials, configured headers, signatures, and delivery headers
cannot escape to an unvalidated redirect destination. Configure the canonical
final URL explicitly.
When signing_secret is configured, the sender also sets
X-PCController-Timestamp, X-PCController-Nonce, and
X-PCController-Signature. The signature is sha256= followed by the lowercase
hex HMAC-SHA256 of this exact byte sequence:
timestamp + "\n" + nonce + "\n" + uppercase_method + "\n" +
request_uri + "\n" + delivery_id + "\n" + body
The receiver should verify the signature in constant time, reject stale
timestamps, and reject reused nonces. Prefer signing_secret_ref over the
plaintext field; os:NAME uses the current Windows user's Credential Manager
and env:NAME resolves only from the process environment. secret_headers
maps an outbound header name to the same reference types. Resolved values are
excluded from config inspection, queue state, administrative responses,
snapshots, exports, and error text.
Queue administration is structured and never returns endpoint credentials:
delivery listings are limited to 1..100 records per call, and each diagnostic
last_error is capped at 2 KiB.
| Operation | JSON-RPC method | REST endpoint |
|---|---|---|
| Status | controller.webhooks.status |
GET /api/webhooks/outbound/status |
| Pending deliveries | controller.webhooks.pending with {"limit":25} |
GET /api/webhooks/outbound/pending?limit=25 |
| Dead letters | controller.webhooks.dead with {"limit":25} |
GET /api/webhooks/outbound/dead?limit=25 |
| Replay one | controller.webhooks.replay with {"delivery_id":"ID"} |
POST /api/webhooks/outbound/replay with the same JSON body |
| Clear one | controller.webhooks.clear with {"delivery_id":"ID"} |
POST /api/webhooks/outbound/clear with the same JSON body |
Bulk replay/clear requires the explicit body
{"all":true,"confirm_all":true}. Status/list operations require the remote
read capability; replay/clear require the remote integrations capability. The
same JSON-RPC methods work through authenticated IPC, HTTP RPC, WebSocket,
Socket.IO RPC, and a permitted host bridge. CLI/TUI operators can use
webhook status, webhook pending [LIMIT], webhook dead [LIMIT],
webhook replay DELIVERY_ID, and webhook clear dead DELIVERY_ID; bulk CLI
operations require the literal CONFIRM. Queue/retry/dead-letter events are
also emitted to the host timeline.
An enabled integrations.websocket_clients entry makes the primary host a
standard WebSocket or bounded Socket.IO client. During alpha it connects
without credentials unless an optional compatibility bearer is configured for
an older peer. It subscribes to validated events/state/status topics, reconnects with bounded
backoff, and can forward local events as correlated controller.message.send
calls. Transport/control/error events and remote-origin messages are not
re-forwarded, preventing a direct two-host echo loop. Incoming remote
events and state remain structured; status is re-emitted locally as a
source-tagged message.
controller.bridge.call, POST /api/bridges/call, and
bridge call PEER METHOD [PARAMS_JSON] use the existing persistent connection
and an internal wire ID, then restore the caller's nested JSON-RPC ID in the
response. The target host applies its own token, remote capability policy, and
ordinary safety path. Recursive bridge calls are rejected, so this API is not
an unrestricted network pivot. Incoming command requests on an outbound peer
connection additionally require that peer's allow_commands flag.
controller.opcode.exchange may be used as the nested bridge method. Because
the bridge forwards the opaque opcode and bytes rather than a feature-specific
schema, experimental firmware queries continue to work after the generic path
has landed even when that bridge has not been rebuilt for the experiment.
The bridge does not open another host's COM port. Each instance retains one
local serial owner. Programming through a remote primary requires the target's
programming and connection_control permissions and follows the same
application-UART close, guarded toolchain/Urclock run, and fresh HELLO
recovery as local programming.
Subscribed peer events and state remain structured. In particular, an
unsolicited buzzer.note retains its frequency, duration, MCU-clock metadata
when available, and connection_generation. Renderers preserve short cadence
inside one generation, but re-anchor after a reconnect or when the derived
timestamp moves more than 250 ms from local receipt time; a peer's wall clock
can therefore neither delay nor accelerate playback. The receiver stamps
bridge.ingress and never forwards
an ingressed event again; this gives server-to-edge mirroring exactly once
without polling or bridge cycles. Both JSON-RPC and Socket.IO peers must
include state in their configured topics.
snapshot.outputs exposes the live output ownership contract. Native profile
boards remain native-lifecycle until an explicit effect or steady preview is
ACKed. Completed native effects are retained as board-effect; steady previews
are retained as board-preview; failed releases remain
effect_release_pending and can be retried. Programming, planned disconnect,
and shutdown reconcile ownership with one compact status-effect release instead
of streaming a host RGB fallback over board-owned safety/offline profiles.
Firmware distribution and hardware programming are deliberately separate
operations. “Build-only watch” means only the source-to-artifact half; it must
not quietly turn every save or CI build into a physical write. A deployment
watcher may discover and download an update, then send a separate authorized
update request to the already-running primary host. That primary is the sole
component allowed to open hardware. It applies the staged image after an
explicit authorized: true request and performs the existing settings-preservation and
complete flash/EEPROM/metadata backup, releases UART ownership, invokes
Urclock/AVRDUDE, verifies the write, reconnects to a valid application HELLO,
then restores the board settings/buzzer lifecycle. A missing or unresponsive
bootloader produces a diagnostic that recommends the explicit usbasp method;
it does not silently switch to ISP.
Captured-flash restore is deliberately not a firmware-update alias. The RPC
method is controller.restore.flash, the REST route is
POST /api/restores/flash, and the selected artifact must have kind
flash-backup. The primary owner still uses the same proven guarded programmer
transaction: Urclock is the default, usbasp must be requested explicitly,
flash/EEPROM/metadata are backed up before the write, AVRDUDE verifies the
write, and the host requires application HELLO reconnect plus lifecycle
restoration before reporting completion.
The host data directory contains a SHA-256 content store. Equal bytes map to
one immutable blob even if they arrive from a browser upload, another host, an
HTTP repository, or a device readback. Metadata records preserve kind, source,
size, build hash, packed build timestamp, and platform. Firmware, EEPROM, and
flash-readback artifacts must parse as Intel HEX before publication. Downloads
respect HTTP_PROXY, HTTPS_PROXY, and NO_PROXY, limit redirects, reject an
HTTPS-to-HTTP downgrade, remove bearer credentials on cross-authority
redirects (including after a composed client redirect callback), and verify
declared size plus SHA-256 before the final name appears. An injected ordinary
HTTP client is only a settings template: its proxy and dial hooks cannot relax
the public-source invariant. A local test or explicitly trusted peer must select the
separately named trusted constructor explicitly.
Remote artifact and update sources are public-network only: the initial URL,
every redirect, and the effective response URL reject loopback, private,
link-local, multicast, local-DNS, and metadata destinations. Direct connections
resolve again immediately before dialing and use the validated numeric address,
closing DNS-rebinding time-of-check/time-of-use gaps while preserving the
original TLS server name. Proxy trust is bound to the proxy function's actual
choice for that request; a configured proxy bypassed by NO_PROXY cannot exempt
a matching direct destination. The local-name suffix list used by public-source
rejection is also the one used to build the default NO_PROXY bypass. An
explicitly configured forward proxy remains an operator trust boundary because
that proxy performs its own destination lookup; commission proxy behavior before
enabling remote artifact intake.
Artifact metadata and blobs are opened relative to one confined store root. Size and SHA-256 are verified on the already-open regular-file handle, that handle is rewound, and the same handle is served to the caller. The store does not verify a path and reopen it later, so a rename or symlink replacement cannot substitute a different file between integrity verification and download.
The store root is an exclusive trusted-writer boundary owned by the controller process account: it requests owner-only directory modes and portable read-only blob modes, while the canonical user-data directory supplies the platform ACL. Portable modes are advisory on platforms such as Windows. Every later open hashes the content again and rejects an in-place change. A privileged or same-account actor that deliberately changes permissions and overwrites an inode while that already-open handle is actively being streamed is outside this process-local threat model. Do not grant another process write access to the store root; use the import API, which stages and rehashes content, instead.
An embedded default firmware/EEPROM pair makes first-board recovery available;
it never authorizes a write. The manifest reports defaults_enabled only when
both validated artifacts exist. A running-board comparison uses its build hash
and packed two-second timestamp, not a semantic version. The operator must
still choose the artifact and programming method and explicitly confirm the
operation. Host executable updates accept only a structurally valid PE, ELF,
or Mach-O executable. The parsed header supplies OS/architecture; conflicting
caller metadata is rejected. A copied instance of the current host coordinates
replacement after the primary exits. Its durable journal preserves exact argv
(including --config) and working directory, keeps a verified old image,
starts the candidate, and requires a token-bound health acknowledgement within
30 seconds. Early exit or timeout kills the candidate, atomically restores the
old image, and restarts it with the same argv. A later process reconciles a
journal orphaned by coordinator or machine failure from executable hashes.
Device capture, firmware/EEPROM programming, captured-flash restore, and host
self-update share one exclusive transaction lane. Downloads may continue in
parallel, but two operations cannot contend for UART, ISP, reset lines, or the
running executable. These requests accept optional idempotency_key; HTTP may
instead use Idempotency-Key. The same key plus request returns the original
operation ID, including after restart; different parameters are rejected.
Completed operation journals survive restart. A queued/running hardware job
interrupted by restart becomes failed with host_restarted and is not
silently replayed.
Artifact and update JSON-RPC methods are:
| Method | Parameters | Result/behavior |
|---|---|---|
controller.artifact.manifest |
{} |
feature/default/current artifacts, board identity, policy, and latest update status |
controller.artifact.list |
optional kind |
SHA-256-sorted artifact descriptors |
controller.artifact.fetch |
url, kind, optional name, sha256, bytes, build identity, idempotency_key |
queue a verified proxy-aware HTTP download |
controller.artifact.upload.begin, .chunk, .finish, .abort |
bounded transfer descriptor, ordered binary chunks, or transfer_id |
authenticated bridge artifact transport; incomplete transfers expire and never enter the immutable store |
controller.artifact.capture |
components, authorized, optional method, port, idempotency_key |
explicitly read and verify current flash/EEPROM through the primary |
controller.board.firmware.download |
authorized, optional method, port, idempotency_key |
board-facing alias for a fresh verified flash-only readback; supplied components, if any, must be exactly flash |
controller.update.firmware |
artifact_sha256, authorized, optional method, port, deployment, reinitialize_eeprom, idempotency_key |
guarded flash; explicit development workflow skips new raw capture, production defaults to verified backup; reinitialization always retains raw EEPROM, programs/readbacks the Go-owned factory image, and discards incompatible semantic settings |
controller.board.firmware.upload |
same as controller.update.firmware |
board-facing alias that uploads a staged immutable firmware artifact through the guarded primary-owner transaction |
controller.restore.flash |
artifact_sha256, authorized, optional method, port |
guarded restore of a flash-backup; Urclock by default, explicit USBasp fallback |
controller.update.eeprom |
same | full pre-write capture, then confirmed EEPROM restore |
controller.update.host |
artifact_sha256, authorized |
stage a verified deferred self-update |
controller.peer.update.host |
peer, host artifact_sha256, authorized, optional idempotency_key |
transfer through the existing authenticated bridge, revalidate on the peer, then ask that peer coordinator to replace itself gracefully |
controller.update.status |
optional operation id |
latest or selected asynchronous status |
Peer host replacement is an application protocol, not an SSH deployment
recipe. The source streams a verified executable through its already-connected
bridge in bounded chunks; the target rehashes and reparses it before its own
coordinator performs the ordinary journaled self-update and rollback health
check. Either host can be source or target. allow_commands, the target's
programming policy, and explicit authorized: true are all required.
Provider and manifest discovery use a companion, product-neutral contract:
| Method | Parameters | Result/behavior |
|---|---|---|
controller.discovery.github.workflow |
repository, kind, optional branch, workflow, platform, api_base_url, build identity, packed_timestamp, bearer_token |
newest successful matching run and its non-expired artifacts; metadata only |
controller.discovery.github.release |
repository, kind, optional tag, include_prerelease, platform, api_base_url, packed_timestamp, bearer_token |
latest stable, requested tag, or opted-in prerelease assets; reads SHA256SUMS when provided |
controller.discovery.manifest |
url, optional bearer_token |
fetch and validate the living update-manifest document |
controller.discovery.local_manifest |
{} |
publish this primary host's deduplicated inventory in the same portable manifest format |
controller.discovery.check |
current artifact identity, kind, optional platform, candidate list |
same, newer, older, different, or unavailable, using digest before packed/build time |
controller.discovery.stage |
candidate, optional transient bearer_token, idempotency_key |
queue proxy-aware download, digest/size verification, safe ZIP member selection, and content-store import; never programs |
controller.discovery.status |
optional operation id |
latest or selected discovery/staging progress |
bearer_token is request-scoped; the Web UI does not persist it and clears it
after a successful stage. platform uses Go-style os/arch values such as
windows/amd64. ZIP extraction rejects traversal, links/devices, excessive
entry counts, and excessive expanded size. An archive containing more than one
equally suitable member must provide archive_path instead of relying on an
arbitrary first match.
A minimal independently hosted manifest is:
{
"generated_at": "2026-08-02T00:00:00Z",
"artifacts": [
{
"kind": "firmware",
"name": "board.hex",
"url": "images/board.hex",
"bytes": 48192,
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"build_hash": "source-or-build-hash",
"build_timestamp": "2026-08-02T00:00:00Z",
"packed_timestamp": 889298268
}
]
}Artifact URLs may be absolute or relative to the manifest. This contract has no format or schema-version discriminator: unknown additive fields are ignored, while required known fields, URLs, sizes, kinds, and digests are still validated.
The equivalent REST routes are:
| Method and path | Purpose |
|---|---|
GET /api/artifacts/manifest |
feature/default/current manifest and board hash/time comparison |
GET /api/artifacts?kind=... |
list all or one kind |
POST /api/artifacts/upload?kind=...&name=... |
raw or multipart browser upload; optional hash/size query or checksum header |
POST /api/artifacts/fetch |
queue verified remote HTTP download |
POST /api/artifacts/capture |
explicit fresh flash/EEPROM capture |
GET or HEAD /api/artifacts/{kind}/{sha256} |
ranged immutable artifact download |
GET or HEAD /api/artifacts/current/flash |
last explicitly captured and verified current flash |
GET or HEAD /api/artifacts/current/eeprom |
last explicitly captured and verified current EEPROM |
POST /api/board/firmware/upload |
upload a staged immutable firmware artifact to the board through the guarded transaction |
POST /api/board/firmware/download |
request a fresh verified flash-only board readback |
GET or HEAD /api/board/firmware/download |
download the last verified board flash readback |
POST /api/updates/firmware |
explicit firmware programming job |
POST /api/restores/flash |
explicit captured-flash restore job; never routed through firmware update |
POST /api/updates/eeprom |
explicit EEPROM restore job |
POST /api/updates/host |
explicit deferred host replacement job |
GET /api/updates/status/{id} |
update progress/result |
POST /api/discovery/github/workflow |
discover successful workflow artifacts without downloading |
POST /api/discovery/github/release |
discover release assets and checksum metadata |
GET /api/discovery/manifest |
serve this primary host's portable manifest for peer discovery/sync |
POST /api/discovery/manifest |
fetch and validate a remote update manifest |
POST /api/discovery/check |
compare candidate and installed/staged identities |
POST /api/discovery/stage |
queue verified download/extraction/import; no programming |
GET /api/discovery/status/{id} |
discovery/staging bytes, percentage, result, or safe failure |
Every accepted artifact/update response contains a progress descriptor:
{
"transport": "websocket",
"url": "/ipc",
"event_prefix": "update.",
"operation_id": "operation-to-correlate"
}Subscribe to events on that authenticated WebSocket and retain only
controller.event notifications whose metadata operation_id matches the
descriptor. Progress is emitted on the existing event/WebSocket/Socket.IO paths
as update.queued, update.toolchain-resolving,
update.toolchain-provisioning, update.toolchain-ready,
update.downloading, update.downloaded, update.programming,
update.verifying, update.completed, or update.failed, with operation ID,
percent, whether that percent is known, kind, hash, and safe error code in
metadata. Board transfer stages cannot publish a known 0% until
update.toolchain-ready has reported the resolved policy, selected provider,
exact version, and at least two compatible sources. Toolchain failure is
terminal and occurs before the programmer/bootloader is attempted. If live
registry resolution is unavailable, the gate may only verify the exact CLI,
core, and library versions from the embedded checksum-bearing lock; it never
installs from the fallback. Status also carries typed programming_method,
bootloader_outcome, and isp_fallback_suggested. A timed-out Urclock job
therefore reports timed_out and explicitly suggests ISP recovery without a UI
parsing AVRDUDE prose; USBasp/host jobs report not_attempted. A secondary
local process or authenticated bridge peer calls these
same RPC methods; it never opens the port itself. Remote artifact bytes require
a configured bearer token even when read access is enabled, and applying or
capturing memory additionally requires the remote programming capability.
Discovery/staging publishes artifact.discovery.queued,
artifact.discovery.downloading, artifact.discovery.completed, and
artifact.discovery.failed through the same event, WebSocket, Socket.IO, and
bridge paths. Metadata includes operation ID, artifact kind, candidate ID,
completed bytes, total bytes, and percentage. External downloads use Go's
standard proxy-environment behavior (HTTP_PROXY, HTTPS_PROXY, NO_PROXY,
including conventional lowercase forms), bounded redirects, cross-authority
credential stripping, and HTTPS-downgrade rejection.
The board lifecycle uses a separate current-only durable marker. Before UART is
released, the host snapshots exact MCU EEPROM settings, cancels any macro,
sends relay/PWM all-off, and persists a temporary safe image: Silent on,
illumination zero/off, output persistence and relay restore mask cleared,
status light off, and closed-door
TM1637 brightness equal to its open value so Prog stays visible. Every
authenticated application reboot with an unfinished marker reasserts safe
outputs plus Prog / Programming...; ordinary illumination and audio cannot
resume just because the MCU reset. The programmer must durably record its final
host outcome before reconnect may restore and verify the exact original EEPROM
settings (including audio) and clear the marker.
The current protocol and firmware use settings flag bit 1 as the durable
ProgrammingMode safety latch. Boot and normal service keep motion, relays,
PWM, illumination, and macro execution inactive while the latch is set, and
the TM1637 remains on Prog. After a final programmer result, the host first
restores and reads back every original EEPROM field while retaining the latch;
only a second verified settings write clears it and permits normal outputs.
The host recovery journal remains the authoritative multi-step operation
record, while this single reused bit provides the reset-safe MCU hook without
growing EEPROM.
For an unpublished development board whose settings payload cannot be decoded,
the authorized firmware-update request may set reinitialize_eeprom: true.
The primary must first retain a complete verified raw EEPROM image even when
deployment: "development" is selected. The marker records the
query error and partial live-state result, outputs are forced safe, and after
flashing only the new firmware's current settings schema is accepted. Silent
is cleared, illumination/persistence/relay restore are disabled, outputs and
readback are verified, and the marker is then removed. No legacy decoder or
migration behavior is added to the board firmware.
SETTINGS byte 12 is displayOptions: bits 0..2 hold closed-door TM1637
brightness and bits 3..7 hold the motion-menu exit duration in seconds. An
encoded hold value of zero means the configured two-second default; values
1..31 are exact. Byte 13 carries the relay restore mask. The settings prefix is
exactly 15 bytes, with byte 14 holding the persisted 1..255 ms motion
break-before-direction interval; flags bit 7 has no timing role. A SET_SETTINGS
command may append a name-length byte and up to eight printable ASCII name
bytes; the exact 15-byte command deliberately preserves the current name. A
SETTINGS response appends settings-persisted at byte 15, name-persisted at byte
16, name length at byte 17, and the name at byte 18. The current EEPROM record
contains the 31 settings bytes, one name length, eight name bytes, and CRC-8.
The native CRC-backed EEPROM value is the authoritative operator name. Urclock filename/title metadata is upload-time bootloader metadata and is not used as runtime board identity. During alpha, successive version builds do not gain layout migrations or compatibility aliases; those are reserved for distinct profile/feature builds that are intentionally supported concurrently.
Advertisement is enabled by default for mDNS/DNS-SD, SSDP/UPnP,
WS-Discovery, bounded UDP broadcast, and NetBIOS. SSDP advertises
urn:pccontroller-org:service:bridge:1, answers its type and ssdp:all, and
publishes the UPnP device description. GET /upnp/public.json is the canonical
secret-free document containing hostname, host build, board firmware and port
identity, service/connection health, current public telemetry, and API/Web/
WebSocket/Socket.IO/operation/event/opcode endpoints. SOAP GetPublicInfo
locates that document and GetStatus returns the principal health/telemetry
fields directly.
controller.discovery.scan and controller network discover|list query any
combination of the transports and return one merged Instance per host.
protocols identifies every successful path and sources retains each raw
transport observation. controller.discovery.connect opens raw IPC and reads
the remote snapshot; controller network connect --target NAME also probes
HTTP, WebSocket, and Socket.IO. Optional --token-ref REF exists only for an
older auth-on host during upgrade. The TUI HOST
Settings page uses D to scan and Enter/C on a discovered row to open its Web
endpoint. The Web workbench renders the same merged directory and Connect
navigates to the selected host.
Advertisement can be disabled or narrowed per protocol through host config,
controller.discovery.config.get|set, the Web workbench, the TUI network
editor, or controller network advertise. Scan start, each merged device,
completion/failure, connection, and configuration-change events fan out through
the ordinary WebSocket and Socket.IO event subscriptions. Public-document HTTP
enrichment bypasses Internet proxies and is pinned to the discovery responder's
exact host and port. Discovery can still be blocked by Windows Firewall,
VLANs, VPNs, or Wi-Fi isolation. Finding an instance does not enable its
listener or change board/OS safety policy.
HTTP, JSON-RPC, WebSocket, Socket.IO, peer bridges, and the LCD presenter use one schema:
{
"source": "client",
"targets": ["lcd"],
"type": "operator.notice",
"text": "Service required",
"line1": "SERVICE",
"line2": "REQUIRED",
"action": "open-events"
}Allowed sources are client, server, bridge, board, lcd, host,
ipc, rest, webhook, websocket, and socket_io. targets contains
bounded capability, surface, or exact-instance selectors; it is the only
request shape and is not constrained to a hardcoded product list. type
contains 1..32 lowercase letters, digits, dot, dash, or underscore. Text/action
lengths are bounded. Explicit board/lcd delivery requires a live board LCD;
all adds the LCD only when that capability is live. Successful board delivery
uses the ordinary display presenter before the source-tagged host event is
published.
Network ingress does not trust a payload's claimed source. Raw IPC is tagged
ipc, REST is rest, standard WebSocket is websocket, Socket.IO is
socket_io, peer traffic is bridge, and inbound HTTP hooks are webhook; a
different claimed value is retained only as bounded metadata.claimed_source.
Authenticated messages also carry bounded metadata.principal and
metadata.authentication. This prevents a remote message from impersonating a
physical board event in text mappings.
action is descriptive metadata. It is never executed automatically. A
deliberately enabled host text_mappings rule can match source, target, type,
and text content and then submit a fixed configured command. This separation
prevents received text from becoming shell input and retains authentication,
logging, motion policy, and board safety.
The Web presentation retains severity, correlation, and optional action text.
Correlated or actionable notices remain visible until dismissed, but action
text is rendered as context rather than a command control. Exact-target UI
work uses the separately validated controller.app.action coordinator, and
remote commands use the authenticated execute method.
JSON, YAML, and TOML use one semantic schema selected by file extension. Unknown future keys are ignored so an older host can continue with the fields it understands; known-field type errors, multiple YAML documents, invalid ranges, and unsafe remote combinations are rejected. Long-running processes watch atomic replacements and keep the last known-good configuration if a reload is invalid. This file is host-owned; it never replaces the board's CRC-checked EEPROM record.
Authentication references, the named remote principal, origin lists, remote
policy, webhook enablement, outbound hooks, and bridge clients are read from
the current watched host configuration. Supported reference fields are
ipc.auth_token_ref, bridge auth_token_ref, webhook
signing_secret_ref, and webhook secret_headers. A replacement config is
published only after its references resolve. Listener address and URL-path
topology are established when the primary starts and require a host restart to
move the bound socket/path; policy or token changes take effect without
touching MCU EEPROM.
Automated tests cover the multiplexed raw/HTTP/WebSocket listener, Bearer and header authentication, one-use browser tickets, replay/expiry/origin/transport binding, missing-Origin and conflicting-credential rejection, pre-auth frame suppression, remote capability denial, route-derived message provenance, REST, Engine.IO v4/Socket.IO framing, correlated host-to-host calls, forwarded event messages, every outbound webhook method, discovery packet parsing, and secret-free SSDP advertisements. In addition to library-level tests, package-independent raw RFC 6455 clients and raw peer servers verify HTTP upgrades, client masking, JSON-RPC correlation/errors, Engine.IO open/connect/ping/pong, both WebSocket roles, typed messages, and bridge event forwarding. A raw native-protocol virtual board verifies that subscription demand starts/stops only STATUS polling while the authenticated transport and asynchronous events remain live, and that explicit Close/Open pauses/resumes it.
All automated listeners bind dynamic loopback ports and all executable tests run from stable project-owned paths; they never open a physical serial port. Actual mDNS/SSDP visibility across Windows Firewall/VLAN boundaries, TLS reverse-proxy commissioning, physical USB unplug/replug notification timing, and remote flash against physical hardware remain deployment tests rather than claims made by this unit suite.
The Windows host uses native Credential Manager generic credentials scoped to
the current user and local machine for os: references; no subprocess is
involved. Other platforms currently fail explicitly for os: while portable
env: references remain available. Plaintext values are never silently
migrated or deleted. config show and config secrets status omit them, and
the CLI accepts new durable secret values only from a named environment
variable or standard input, never a command-line argument.
The Windows serial mode starts both DTR and RTS inactive. Merely opening the
application therefore does not request reset. connection.reset_on_reconnect
defaults false and, when enabled, permits exactly one DTR-only pulse for a
genuine physical reconnect epoch. Native Windows Plug-and-Play registry change
notifications emit disconnect/reconnecting/connected lifecycle events; a
safety retry is used only if platform notification cannot be established.
See Host Configuration and Integrations for configuration examples, TUI surfaces, and commissioning guidance. See Control-Surface Capability Matrix for the per-domain CLI, library, IPC, REST, WebSocket, event, and authorization reachability contract.