Skip to content

Latest commit

 

History

History
655 lines (514 loc) · 38.3 KB

File metadata and controls

655 lines (514 loc) · 38.3 KB

skrit: CMD-port wire protocol

The device exposes the two roles below. How they map onto the wire depends on the transport (see Transports): a dual-CDC device gives them two USB interfaces; a single-channel device (single USB-CDC, WebSocket) carries both over one stream via skrit-mux; BLE carries them as two GATT services.

Role Job Framing
DATA transparent USB↔UART bridge to the target console raw bytes, no framing
CMD control / macros / config this protocol

The DATA role is never framed; it carries the bridged bus verbatim. By default that's a UART console (what the terminal widget renders), but a device reports what its DATA channel actually carries via DATA_DESC (uart, can, rs485, spi, ble-sniff, logic) so the app can pick the right viewer. See the roadmap. Everything below is the CMD role only.

The CMD port speaks two interchangeable modes so it stays debuggable by hand while being efficient for the app:

  • ASCII line mode, human-typeable (R1 ON\n, STATUS\n, PING\n). Any line that starts with a printable byte (>= 0x20) and ends in \n/\r is parsed as text.
  • Binary frame mode, COBS-framed packets for the desktop app and bulk transfers. Recognised by the 0x00 frame delimiter.

A receiver disambiguates per message: a 0x00 terminator ⇒ decode the preceding bytes as a COBS frame; a printable-led run ending in newline ⇒ ASCII line.


Binary frame

On the wire, every binary message is delimited by 0x00 on both sides:

0x00 , COBS( payload ) , 0x00

COBS removes all 0x00 bytes from payload, so the 0x00 bytes are unambiguous frame delimiters and the link can always resync after a glitch. The leading 0x00 is what lets the firmware switch cleanly into binary mode (a 0x00 while idle ⇒ a binary frame is starting; a printable byte ⇒ an ASCII line). Back-to-back frames simply share delimiters; empty segments between two 0x00s are ignored.

The decoded payload is a fixed little header + body + CRC:

 0      1      2      3 .. 3+LEN-1      last
+------+------+------+-------------------+------+
| TYPE | SEQ  | LEN  |     BODY[LEN]     | CRC8 |
+------+------+------+-------------------+------+
Field Size Meaning
TYPE 1 message type (table below). Response = request TYPE | 0x80.
SEQ 1 rolling id chosen by the requester; echoed in the response so the app can match replies.
LEN 1 body length, 0..64 (capped to fit the CH552's RAM).
BODY LEN type-specific payload.
CRC8 1 CRC-8/ATM (poly 0x07, init 0x00) over TYPE..BODY inclusive.

LEN is bounded to 64 on purpose: the firmware has ~600 B of RAM, so anything larger is chunked (see Bulk transfer). Max decoded frame = 68 B; after COBS + delimiter, ≤ 70 B on the wire.

Response status

Responses carry a 1-byte STATUS as BODY[0], then type-specific data:

STATUS meaning
0x00 OK
0x01 bad CRC
0x02 unknown TYPE
0x03 bad args / length
0x04 storage error (storage)
0x05 not found (macro id)
0x06 busy
0x07 unsupported (skrit-mc tier/opcode above macro_tier)
0x08 unauthenticated (send AUTH first, network transports)

A malformed or unknown request still gets a response (TYPE|0x80, SEQ echoed, STATUS set) so the app never hangs waiting.


Message types

TYPE name request body response body (after STATUS)
0x01 PING (none) "PONG"
0x02 INFO (none) fw_ver(2), caps(1), n_outputs(1), store_kb(1), proto_ver(1), n_inputs(1), macro_tier(1), flags(1)
0x03 DEVICE_NAME (none) device name string
0x04 REBOOT mode(1) (none) (replies OK, then reboots). mode 0=app reset, 1=bootloader/DFU. Needs CAP_REBOOT.
0x05 AUTH password… (none)
0x06 AUTH_SET new_password… (none)
0x07 DATA_DESC (none) kind(1), name…
0x10 OUTPUT_SET index(1), value(1) (none)
0x11 OUTPUT_GET (none) bitmap(1), bit i = output i is on
0x12 OUTPUT_TOGGLE index(1) bitmap(1)
0x13 OUTPUT_DESC index(1) index(1), type(1), name…; type is the behavior: 0=io (digital on/off), 1=pwm, 2=rgb; what the pin drives (relay, LED, reset…) is the descriptive name.
0x14 INPUT_DESC index(1) index(1), type(1), name… (type 0=digital, 1=analog)
0x15 INPUT_GET index(1) index(1), value(2) (digital 0/1, analog 0-1023)
0x16 OUTPUT_PULSE index(1), ms(2) (none)
0x17 PROTO_GET idx(1) idx(1), flags(1), value(4), opt0(1), opt1(1), opt2(1) — interface idx's link params, interpreted by the DATA kind (see below).
0x18 PROTO_SET idx(1), flags(1), value(4), opt0(1), opt1(1), opt2(1) (none)
0x19 SERIAL_SIGNAL mask(1), value(1) (none)
0x1A OUTPUT_PWM index(1)[, duty(2)] index(1), duty(2)
0x1B OUTPUT_RGB index(1)[, [pixel(1),] r(1), g(1), b(1)] index(1), count(1), r(1), g(1), b(1)
0x1C PWM_CONFIG index(1)[, freq(4), res(1)] index(1), freq(4), res(1)
0x1D PIN_CAPS index(1) index(1), total(1)[, pin(2), caps(1), warn(1), bus(1), name…]
0x1E CONFIG_GET index(1) index(1), n(1)[, type(1), pin(2), flags(1), arg(2), name…]
0x1F CONFIG_SET n(1), n×{type(1), pin(2), flags(1), arg(2), namelen(1), name} status[, bad_index(1)]
0x20 MACRO_LIST start(1) count(1), then repeated {id(1), name_len(1), name…} until frame full; more via next start.
0x21 MACRO_META id(1) id(1), len(2), name_len(1), name…
0x22 MACRO_READ id(1), off(2), n(1) bytes… (n ≤ 64)
0x23 MACRO_WRITE_BEGIN id(1), total(2), name_len(1), name… (none) (allocates / truncates)
0x24 MACRO_WRITE_DATA id(1), off(2), bytes… (none)
0x25 MACRO_WRITE_END id(1), crc16(2) (none) (commits; verifies)
0x26 MACRO_DELETE id(1) (none)
0x27 MACRO_RUN id(1) (none)
0x30 EE_READ addr(2), n(1) bytes…
0x31 EE_WRITE addr(2), bytes… (none)
0x40 CFG_GET key(1) key(1), value…
0x41 CFG_SET key(1), value…
0x60 I2C_SCAN (none) bitmap(16)
0x61 I2C_XFER addr(1), wlen(1), w…, rlen(1) addr(1), r…
0x70 INVOKE_DESC index(1) index(1), total(1)[, id(2), nargs(1), argtype(1)×nargs, flags(1), name…]
0x71 INVOKE id(2), payload… id(2), reply…

Multi-byte integers are little-endian (matches SDCC and x86/arm hosts).

caps bitfield (INFO): bit0=persists-macros, bit1=has-OLED, bit2=has-SPI-flash, bit3=parity, bit4=muxed (one endpoint carries both channels, see skrit-mux), bit5=serial-control (SERIAL_*), bit6=reboot (REBOOT), bit7=pwm (OUTPUT_PWM).

macro_tier (INFO): the highest skrit-mc tier the device's VM executes. 0=none (no on-device macros), 1=open-loop replay (EMIT/DELAY/SETOUT), 2=+closed-loop (EXPECT/WAITIO/WAITOK). A device returning a short INFO body (no macro_tier byte) is treated as 0. The app refuses to save/run a program whose tier exceeds this.


Bulk transfer (macros / fonts)

Because LEN ≤ 64, anything large is sent as a sequence the firmware can stream straight to storage page-by-page without buffering the whole blob:

host → MACRO_WRITE_BEGIN(id, total, name)
host → MACRO_WRITE_DATA(id, off=0,  bytes[0..n])      ┐
host → MACRO_WRITE_DATA(id, off=n,  bytes[n..2n])     │ repeat, n ≤ 64
host → …                                             ┘
host → MACRO_WRITE_END(id, crc16)   ← device verifies & commits

Reads mirror it with MACRO_READ(id, off, n). The stored payload is a skrit-mc program (below); MACRO_RUN(id) runs it through the device VM (so the on-device menu and the app share one code path).


Macro bytecode (skrit-mc v1)

A macro is authored as text (STRING, WAITFOR, IF…) and parsed to a shared IR. The host compiles that IR to a compact byte program; the device runs the program in a tiny VM. This is what makes on-device execution correct: the device never sees STRING/WAITFOR text, only resolved opcodes.

A program is a 1-byte version followed by opcodes, terminated by END:

program := mc_ver(1) , op... , 0x00
mc_ver   = 0x01
op name operands tier effect
0x00 END (none) 1 halt (success)
0x01 EMIT n(1), bytes[n] 1 write bytes to DATA/UART
0x02 DELAY ms(2) 1 pause
0x03 SETOUT index(1), val(1) 1 drive output index (0/1)
0x04 SETPWM index(1), duty(2) 1 set output index PWM duty (0-1023); no-op on a non-PWM output
0x05 SETRGB index(1), r(1), g(1), b(1) 1 fill output index's RGB strip with a color; no-op on a non-RGB output
0x06 INVOKE id(2), n(1), payload[n] 1 call user-defined command id with the packed payload (see INVOKE); no-op if the device doesn't expose that id. Lets a macro drive a module's own commands.
0x10 EXPECT timeout(2), n(1), bytes[n] 2 match bytes on incoming DATA; sets outcome (match=OK, timeout=FAIL)
0x11 WAITIO index(1), cmp(1), val(2), timeout(2) 2 poll input index until value cmp val; sets outcome (met=OK, timeout=FAIL)
0x12 WAITOK (none) 2 if last outcome is FAIL, halt the run with STATUS failed
0x20 IF cond(1), skip(2) 2 reserved v2: if outcome ≠ cond, jump skip bytes
0x21 ELSE skip(2) 2 reserved v2
0x22 ENDIF (none) 2 reserved v2
  • Multi-byte operands are little-endian. EMIT n ≤ 255 and DELAY ms ≤ 65535; the compiler splits longer runs into multiple ops.
  • cmp byte (WAITIO): 0=>, 1=<, 2=>=, 3=<=, 4===, 5=!=.
  • Outcome flag: one boolean, init OK. EXPECT/WAITIO set it; WAITOK (and the reserved IF) read it. This decouples OK/FAIL from RUN, so a bare WAITFOR timeout also trips it.
  • Compile-time-only IR (no opcode): TIMEOUT is folded into each EXPECT/WAITIO timeout field; $Call is inlined; SETOUT/WAITIO names are resolved to indices against the connected device. RUN/WAITOK-on-RUN/IF-on-RUN are app-only (tier 3) and are never compiled for a device target.

Tiers

tier name opcodes model
1 replay EMIT DELAY SETOUT END open-loop: output is a fixed function of time
2 interactive + EXPECT WAITIO WAITOK closed-loop: blocks/branches on a read
3 app-only RUN + the WAITOK/IF that ride it host orchestration (exit codes); never on-device

A program's tier is the max tier of its opcodes. The device advertises the highest tier its VM runs in INFO.macro_tier (0 = no VM). A device executes an op only if its tier ≤ macro_tier; an over-tier op is a backstop STATUS 0x07 (the app pre-checks via INFO).

Running a program

The same bulk path carries bytecode. To persist, MACRO_WRITE_* a program to an id then MACRO_RUN(id) (requires caps bit0, persists-macros). For push-and-run with no storage, write to the reserved scratch id 0xFF (a volatile RAM slot) and MACRO_RUN(0xFF). This works even when the device persists nothing, bounded by RAM. A CAP_STORE device streams stored opcodes page-by-page, so persisted programs aren't RAM-bound.


ASCII mode (debug / on-device menu)

The same actions are reachable as plain lines, for a hand terminal and for the on-device menu to reuse the vocabulary:

PING            -> PONG
ID              -> Duta v<n>
STATUS          -> R1=0 R2=0 LED=0
R1 ON|OFF|TOGGLE
R2 ON|OFF|TOGGLE
LED ON|OFF|TOGGLE
MACRO LIST      -> id: name        (one per line)
MACRO RUN <id>
HELP

Binary mode is preferred from the desktop app (typed, CRC-checked, supports bulk); ASCII stays for humans.


Transports

skrit is transport-independent. A device picks one of these; the app discovers which from INFO.caps (the muxed bit) and how it connected.

Dual-CDC (e.g. CH552)

Two USB-CDC interfaces: DATA (MI_00) is the raw console; CMD (MI_02) speaks the binary/ASCII protocol above. Nothing is multiplexed; each role owns a port. This is the cheapest path on an MCU with composite-USB silicon.

skrit-mux: one channel, both roles

Single-channel transports, a single USB-CDC (ESP32-S3, RP2040, nRF52840 over one CDC ACM) or a WebSocket (network bridge), carry both roles over one byte stream. (BLE is dual-channel instead; see below.) Every packet is a COBS frame with a 1-byte channel tag:

0x00 , COBS( CHANNEL , payload... ) , 0x00

CHANNEL 0x00  DATA  -> payload is raw target-console bytes
CHANNEL 0x01  CMD   -> payload is a CMD frame:  TYPE SEQ LEN BODY[LEN] CRC8

COBS removes every 0x00 from the body, so the delimiters stay unambiguous and the link resyncs after a glitch, exactly like the CMD framing, with one extra tag byte. The CMD payload is byte-identical to the dual-CDC CMD frame: a device that implements the CMD dispatch already has the hard part; mux just prepends a channel tag and routes DATA inline. A muxed device sets the muxed capability bit in INFO so the app knows to wrap/unwrap rather than open a second port.

DATA payloads should stay ≤ 240 B so COBS overhead is a single byte; larger console bursts are split across frames (order is preserved). There is no CRC on the DATA channel; it mirrors the lossless, unframed nature of the raw console port.

BLE: two skrit GATT services (DATA + CMD)

BLE is dual-channel, like dual-CDC: GATT gives each role its own pipe, so there's no need to mux. The two roles map to two GATT services, and caps.muxed is 0:

  • DATA = the skrit DATA service, carrying the raw target console. Its UUID is deliberately NUS-compatible (the de-facto "serial over BLE"), so any generic BLE-UART terminal (nRF Connect, etc.) reads the console directly, no skrit knowledge needed, but the contract is the GATT service below, not Nordic's.
  • CMD = the skrit CMD service carrying the framed CMD protocol (TYPE SEQ LEN BODY CRC8, 0x00-delimited), byte-identical to the dual-CDC CMD port.
Service UUID base RX (host→device, write) TX (device→host, notify)
DATA 6E400001-B5A3-F393-E0A9-E50E24DCCA9E …0002 raw console in …0003 raw console out
CMD 6E410001-B5A3-F393-E0A9-E50E24DCCA9E …0002 CMD frames in …0003 CMD responses/events out

(The CMD service sits on the same vendor base with service word 6E41xxxx, marking it as the DATA service's sibling.) The host subscribes to both TX characteristics (CCC), writes console keystrokes to DATA-RX and CMD frames to CMD-RX. A frame larger than the negotiated ATT MTU (minus 3) is split across notifications and reassembled by the 0x00 delimiters; BLE adds no framing of its own.

Security. The RX (write) and CCC (subscribe) attributes require an encrypted link, so a central must complete LE Secure Connections pairing (ECDH P-256) before it can drive the device or receive notifications — the air link is never plaintext. A device with no display/keyboard uses Just Works pairing (encrypted, passively secure under LESC); one with input could raise this to passkey/numeric-compare. Bonds are persisted, so a paired host reconnects without re-pairing. This is the BLE equivalent of a network transport's AUTH: physical/link-layer access is the credential, so INFO.flags AUTH_REQUIRED stays 0.

Naming. The device advertises a per-unit name Duta-XXXX (from its BLE address) plus the skrit CMD UUID in the scan response, so multiple Dutas are distinguishable and a central can filter by the CMD service. Backpressure: device→host notifications are queued and drained as controller buffers free, so a console/CMD burst is never dropped mid-frame.

Discovery: the app scans for the CMD service UUID (the skrit identifier) and a Duta-prefixed name. nRF52840 is the reference (Zephyr + the in-tree BT stack); see duta/platforms/zephyr.

WebSocket (network)

A networked device (a WiFi bridge, the host reference) carries the skrit-mux byte stream over a WebSocket: ws://host:port/skrit, or wss:// for TLS. The mux frames ride binary WS messages; message boundaries are irrelevant (reassemble by the 0x00 delimiters, as always), so a host just feeds each binary payload to the mux reader. It's muxed (caps.muxed = 1), like a single USB-CDC.

WebSocket is chosen over a raw TCP socket because it is reachable from a browser (which cannot open raw sockets), traverses proxies, shares :443, and gets TLS + standard auth via wss://. Auth is a CMD (below), not the WS handshake, so it works from a browser, where the WebSocket API can't set Authorization headers.

Network auth

A USB or BLE link is gated by physical access / pairing, but a network transport is reachable by anyone who can route to it, and skrit is effectively a remote shell on the target. So a network device requires authentication:

  • It advertises SKRIT_FLAG_AUTH_REQUIRED in the INFO flags byte. Until the session authenticates, the device answers only PING, INFO, and AUTH (everything else → status 0x08 unauthenticated) and does not bridge the DATA console.
  • The host sends AUTH <password>. OK ⇒ the session is authenticated for its lifetime; 0x08 ⇒ wrong password.
  • The device ships with the factory default password duta and advertises SKRIT_FLAG_DEFAULT_CRED while it's unchanged, so the app should prompt the user to set a new one. AUTH_SET <new> changes it (persisted); the current session stays authed.

flags byte: bit0 = auth-required, bit1 = default-credential, bit2 = provision (accepts runtime IO provisioning, see below). USB/BLE devices leave the auth bits 0. Run a network bridge over wss:// so the password and console aren't on the wire in the clear; the default-password gate is a usability backstop, not a substitute for TLS.

Capability model: Controls · Inputs · Invoke · Sources

A device's surface splits four ways, along two questions — is it a standing value or a call?, and (for values) how does it flow?

contract category examples messages
set & read Control (output) relay, PWM, RGB, a mode SELECT OUT_* + SKRIT_CTRL_*
read-only Input analog sensor, button INPUT_* + SKRIT_IN_*
call (args → reply) Invoke get_temp(), reboot() INVOKE_DESC / INVOKE
subscribe Source (stream) the DATA console, a sniffer SOURCE_DESC + the DATA channel

Controls and Inputs are CLOSED, finite primitive sets — the curated "tool" side. Invoke and Sources are OPEN, device-defined sets, self-described and rendered generically — the "platform" side. Invoke returns data (it's RPC), but its result is computed per-call, not a standing value with identity — that, not "returns data", is what separates it from a Control/Input/Source. The same reading can be modeled more than one way (a temperature as an Input, an Invoke, or a Source); the category encodes the interaction contract, not the data's nature. Rule of thumb: if you're tempted to add a Control type that encodes behavior rather than a value-shape, it's an Invoke (or host-side logic) instead — that keeps Controls from rotting into a scripting surface.

Sources — multiplexed DATA streams

The DATA channel is not limited to one stream. A device self-describes its sources with SOURCE_DESC (index → total, source_id, kind, flags, name — iterate like PIN_CAPS); kind is a SKRIT_DATA_*, flags is SKRIT_SRC_* (toggleable / active).

  • total ≤ 1 — every device today, and all dumb low-end MCUs: DATA is one raw, unframed stream, so a plain terminal / COM passthrough still works. Nothing changes. A device may still describe its single source (name/kind) without prefixing anything.
  • total > 1 — sources are tagged on the wire transport-natively:
    • mux (USB) and byte-stream links (WebSocket, dual-CDC): each DATA record is length-framed [len(2 LE)][source_id(1)][payload], so a byte stream self-delimits and the host runs one transport-agnostic demux.
    • BLE GATT: one DATA characteristic per source — the characteristic is the source (native subscribe + per-stream flow control); no source_id byte on that link.

The model (source_id + SOURCE_DESC) is transport-agnostic; only the framing differs. A Source descriptor is read-only — turning a source on/off and any mode (e.g. a radio's PHY) are Controls (a toggle, a SELECT). The host binds widgets to sources by source_id, so e.g. a console viewer and a sniffer viewer render side by side from one device.

I²C — the first typed DATA stream

A device whose bridge supports I²C advertises it via CFG_GET 0x14 and switches media with CFG_SET 0x14 <kind> (persisted; DATA_DESC reflects the active kind). While kind = i2c:

  • The host drives the bus as master over CMD: I2C_SCAN (address bitmap) and I2C_XFER (write-then-read, Bus-Pirate-style).

  • Every transfer is also emitted on the DATA channel as one record per mux frame — the mux framing is the record framing:

    ts_ms(4 LE) · addr(1) · flags(1) · wlen(1) · w-bytes · rlen(1) · r-bytes
    flags: bit0 = had a read phase · bit1 = NAK / failed
    

    so captures (Sutra's transaction log, sutra-extcap → Wireshark) see the bus activity regardless of which link triggered it. The UART console is paused while i2c is active; switch back with CFG_SET 0x14 0x00 (uart).

BLE sniffer (DATA = ble-sniff)

A device with an nRF radio can advertise DATA_DESC kind = 4 and stream captured BLE advertising packets. Each captured PDU is one DATA record (one mux frame):

ts_ms(4 LE) · channel(1) · rssi(1, magnitude in -dBm) · access-address(4 LE) · pdu_len(1) · pdu…

channel is the advertising channel (37/38/39); access-address is the fixed advertising AA (0x8E89BED6); pdu is the on-air advertising PDU (the 2-byte header + payload), de-whitened and CRC-checked by the radio (bad-CRC captures are dropped). v1 is advertising-only — connection following and data channels are future work. Records flow to every link, so Sutra's packet viewer and sutra-extcap → Wireshark both see the same capture.

IEEE 802.15.4 sniffer (DATA = ieee802154)

The nRF52840 is a multiprotocol radio: the same silicon that sniffs BLE also does IEEE 802.15.4 (O-QPSK, 250 kbps), the PHY/MAC under both Zigbee and Thread (one capture kind, two ecosystems — they differ only in upper layers). A device advertises DATA_DESC kind = 7 and streams captured frames, one DATA record per frame:

ts_ms(4 LE) · channel(1, 11..26) · rssi(1, signed dBm) · lqi(1) · flags(1) · psdu_len(1) · psdu…

channel is the 802.15.4 channel (11–26 = 2405–2480 MHz); flags bit0 = FCS ok (the radio computes the 16-bit FCS in hardware; bad-FCS frames are dropped); psdu is the MAC frame including its 2-byte FCS. sutra-extcap reframes each record as LINKTYPE_IEEE802_15_4_TAP, so Wireshark's native 802.15.4 stack decodes Zigbee/Thread/6LoWPAN with no dissector from us. It's a single radio, so 802.15.4 and BLE sniffing are separate sessions (one mode at a time).

Channel is configured through PROTO_GET/PROTO_SET (the generic link-param messages — see below): value holds the 802.15.4 channel (11–26 to pin one, 0 = promiscuous/auto-hop across 11–26, sticking where traffic appears); opt0..2 are unused. So PROTO_SET idx=0, value=15 pins channel 15.

Injection (TX). The DATA channel is bidirectional: a host write to the DATA channel is a MAC frame to transmit on the current channel. The host supplies the on-air MAC frame without the FCS — the radio appends it. The device drops back to RX after sending. (Inject on a network you control; a frame built with another node's source address + a high frame counter can desync that node's anti-replay state until it rejoins.)

Link parameters (PROTO_GET / PROTO_SET)

A device configures the bridged medium's link parameters through one generic pair of messages, interpreted by the DATA kind. idx selects the interface (0 on a single-interface device; reserved for a future device that bridges several at once). flags carries per-interface options — bit0 (SKRIT_PROTO_FWD) = forward this interface's RX to the host; clear it to stop the device piping an interface you don't need (the CMD link still works; macros' EXPECT still sees the data). value (4 LE) is the medium's primary parameter; opt0..2 are medium-specific:

DATA kind value opt0 opt1 opt2
uart baud data_bits parity (SKRIT_PAR_*) stop_bits
i2c clock (Hz)
ieee802154 channel (11–26; 0 = promiscuous/hop)

Unsupported on a device without CAP_SERIAL. SERIAL_SIGNAL (0x19, DTR/RTS/break) stays UART-specific. The capability bit is still named CAP_SERIAL for back-compat.

WiFi provisioning (network-bridge devices)

A device with a WiFi radio (ESP32 family) can run the WebSocket transport itselfws://<device-ip>:9555/, the skrit-mux stream over WS binary frames, auth-gated like any network transport. Two ways to give it a network:

  • Over the CMD link (Sutra): CFG_SET 0x10 <ssid> + CFG_SET 0x11 <password> over USB/BLE; the device persists the credentials and joins. Poll CFG_GET 0x12 (status): state(1) = 0 off · 1 connecting · 2 connected (detail = the IP to point ws:// at) · 3 portal · 4 failed. Setting an empty SSID forgets the network. The password never reads back (0x11 GET → "*").
  • Captive portal: with no stored credentials (or after repeated join failures) the device raises a Duta-… access point with a captive config page — join it from a phone, pick the network, done. While the portal is up, CFG_GET 0x12 reports state 3 + the AP name.

The USB CMD link stays fully usable alongside the WebSocket session — the device serves both, and the DATA console is teed to every authenticated link.

Discovery (mDNS/DNS-SD). Once joined, the device advertises _skrit._tcp on its WS port with TXT records name (the device name) and vendor, under hostname duta-xxxx.local. Hosts browse that service type to auto-discover every Duta on the LAN — no IPs to type. Discovery is finding, not trusting: the session is still auth-gated (AUTH).

Provisioning

A board's IO is a compiled-default table (OUTPUT_DESC self-describes it), but a device may let the IO be re-provisioned at runtime instead of recompiled: wire a relay onto a spare pin, point a PWM at a different one, and push the new map from the app. Capability-gated: a device that supports it sets SKRIT_FLAG_PROVISION in the INFO flags byte. Fixed boards (e.g. CH552) simply don't, and the app hides the Configure device screen for them.

The menu is layered, and each layer only narrows freedom:

  • mcu: the silicon truth, every pin, its intrinsic caps, and an immutable hazard status (free / caution / forbidden). Written once per chip.
  • board: the physical overlay, which pins are broken out, and which are committed to onboard hardware (fixed = hidden; dual-use = offered with a warning).

The device resolves mcu ∩ board and exposes only the offerable pins via PIN_CAPS, so the app's picker constrains each pin to valid roles with zero hardcoded chip knowledge. A pin is offerable iff it's broken out and not fixed; forbidden/fixed pins are never enumerated.

Flow: PIN_CAPS (read the menu) → CONFIG_GET (read the current table) → present a per-pin picker (role + descriptive name, constrained to caps) → CONFIG_SET (write the new table) → REBOOT to apply. The device validates every row against the menu before persisting and rejects an invalid pin/role with 0x03 + the offending bad_index. CONFIG_SET n=0xFF reverts to the compiled default (the soft factory-reset; a held-button boot is the hard one).

caps bitfield (PIN_CAPS): bit0=digital, bit1=adc, bit2=pwm, bit3=dac, bit4=i2c, bit5=spi, bit6=touch. The firmware mirrors these (and the per-mcu pin tables) in duta_pincap.h.

INVOKE — user-defined commands

IO/PWM/RGB/CFG/I2C are the blessed built-ins. INVOKE is the open-ended extension point: a module on the device registers its own command, advertises it, and the host forwards a high-level intent to it without having to understand the implementation. Duta is a framework — stock firmware "just does the simple thing" the way QMK "just sends keycodes", but a user is free to put arbitrary behavior behind a command. Sutra's job is to deliver set_position(x, y) (or zigbee_join(…), or anything) to whatever handler the device named, and trust that the device's own code knows what to do with it.

A device that exposes commands sets FLAG_INVOKE in INFO.

Discovery

Iterate INVOKE_DESC(index) from 0 while index < total — the same shape as PIN_CAPS. Each entry is:

id(2)            the command id (little-endian; see id ranges)
nargs(1)         number of arguments
argtype(1)×nargs the arg signature (types below)
flags(1)         bit0 = the command returns a reply payload
name…            the device's human label for the command

Arg codec

The argtype signature is enough to pack/unpack a call generically — a host can render a typed form (and a macro compiler can emit INVOKE) from the descriptor alone, with no catalog required:

argtype wire
0 u8 1 byte
1 u16 2 bytes LE
2 u32 4 bytes LE
3 i16 2 bytes LE, signed
4 i32 4 bytes LE, signed
5 bytes len(1), then len bytes
6 str len(1), then len UTF-8 bytes

Fixed types are packed back-to-back in argument order; variable types carry their own length. The payload of an INVOKE is exactly this concatenation.

Calling

INVOKE(id(2), payload…)STATUS, id(2)[, reply…]. The device routes id to the handler. reply… is present only if the descriptor's flags bit0 was set. Errors: unknown id0x05 not-found; a device with no INVOKE support → 0x07 unsupported; a payload that doesn't match the signature → 0x03 bad-args.

Id ranges (namespacing)

The 16-bit id space is split so user-defined commands never collide with future blessed ones — the BLE-assigned-numbers / HID-usage-page pattern:

range owner
0x00000x7FFF well-known — curated here, ids stable forever
0x80000xFFFF vendor / custom — the device owns it freely (SKRIT_INVOKE_VENDOR_BASE)

Well-known registry (the canonical list; a host catalog mirrors it):

id name args meaning
0x0001 set_position x:u16, y:u16 move to coordinate x,y (servo/gantry/pointer — the device decides)

A device may also expose a well-known id with a richer handler (e.g. a real 2-axis gantry behind set_position); the contract is only the id + signature.

Host catalog vs device list

Two lists, one authoritative:

  • The device's INVOKE_DESC list is the source of truth for what exists. A host must handle a command it's never heard of.
  • A host-side catalog says what known ids mean — for each well-known id, a nicer label, per-arg names/units, and a UI widget (slider, xy-pad, hex). When the device advertises a catalogued id, the host renders the rich control; when it advertises an unknown (vendor) id, the host falls back to a generic form built from the argtype signature. Unknown ids never hard-fail. The catalog is shipped as a single machine-readable file the app UI and the MCP server both load (the same approach planned for the ZCL dictionary).

In macros

INVOKE is also skrit-mc opcode 0x06 (id(2), n(1), payload[n], tier 1), so a stored macro can drive a module's own commands — the same descriptor + codec compiles a text Invoke set_position 100 200 straight to bytecode.

Async events

A device MAY push unsolicited frames in the 0x50..0x5F range; these have the SKRIT_RESP bit clear and SEQ = 0, so a host routes them to an event sink instead of the reply-matcher (which only ever waits on a TYPE | 0x80 with a matching SEQ). Events are advisory; a host that ignores them loses nothing.

TYPE name body meaning
0x50 EVENT_LOG text… a device log line (e.g. on-device macro progress)
0x51 EVENT_INPUT index(1), value(2) an input crossed an edge / threshold

Events ride the CMD channel (or, on a muxed link, CHANNEL=CMD). They carry no CRC beyond the frame's own; a host treats a malformed event as a no-op.

Versioning

proto_ver starts at 1. Additive changes (new TYPEs like REBOOT, OUTPUT_PULSE, SERIAL_*, INVOKE, the 0x50 events, new caps/flags bits, and the skrit-mux channel tag) keep the same version: an older app simply doesn't send or decode them and a newer device still answers the v1 core. Breaking changes bump it. The app reads INFO on connect and refuses mismatched major versions.