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/\ris parsed as text. - Binary frame mode, COBS-framed packets for the desktop app and bulk transfers.
Recognised by the
0x00frame 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.
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.
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.
| 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.
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).
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 ≤ 255andDELAY ms ≤ 65535; the compiler splits longer runs into multiple ops. cmpbyte (WAITIO):0=>,1=<,2=>=,3=<=,4===,5=!=.- Outcome flag: one boolean, init OK.
EXPECT/WAITIOset it;WAITOK(and the reservedIF) read it. This decouples OK/FAIL fromRUN, so a bareWAITFORtimeout also trips it. - Compile-time-only IR (no opcode):
TIMEOUTis folded into eachEXPECT/WAITIOtimeoutfield;$Callis inlined;SETOUT/WAITIOnames are resolved to indices against the connected device.RUN/WAITOK-on-RUN/IF-on-RUNare app-only (tier 3) and are never compiled for a device target.
| 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).
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.
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.
skrit is transport-independent. A device picks one of these; the app discovers
which from INFO.caps (the muxed bit) and how it connected.
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.
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 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.
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.
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_REQUIREDin theINFOflagsbyte. Until the session authenticates, the device answers onlyPING,INFO, andAUTH(everything else → status0x08 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
dutaand advertisesSKRIT_FLAG_DEFAULT_CREDwhile 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.
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.
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_idbyte on that link.
- mux (USB) and byte-stream links (WebSocket, dual-CDC): each DATA record is length-framed
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.
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) andI2C_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 / failedso 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 withCFG_SET 0x14 0x00(uart).
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.
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.)
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.
A device with a WiFi radio (ESP32 family) can run the WebSocket transport
itself — ws://<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. PollCFG_GET 0x12(status):state(1)= 0 off · 1 connecting · 2 connected (detail = the IP to pointws://at) · 3 portal · 4 failed. Setting an empty SSID forgets the network. The password never reads back (0x11GET →"*"). - 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 0x12reports 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).
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.
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.
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
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.
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 id → 0x05 not-found; a device with no INVOKE support →
0x07 unsupported; a payload that doesn't match the signature → 0x03 bad-args.
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 |
|---|---|
0x0000–0x7FFF |
well-known — curated here, ids stable forever |
0x8000–0xFFFF |
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.
Two lists, one authoritative:
- The device's
INVOKE_DESClist 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
argtypesignature. 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).
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.
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.
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.