Device protocol
Device protocol¶
This page describes the protocol between tw and the Tapewyrm firmware: where
its codes are defined, how both ends are generated, what goes over USB and the
limits each verb must respect.
The single source of truth¶
protocol/protocol.toml defines every code both ends share. Neither end
hard-codes them. protocol/generate.py writes the firmware header and the
Python module from it, and CI fails if either generated file is stale.
| Table | Generated C | Generated Python | Holds |
|---|---|---|---|
version |
TW_PROTO_VERSION |
PROTO_VERSION |
The protocol version (currently 1). |
caps |
TW_CAP_<NAME> = 1u << i |
CAPS (a frozenset of names) |
Capability flags INFO advertises, bit i in list order: verbs, capture, markers. |
[[transaction]] |
TW_TXN_<NAME> |
Txn.<NAME> |
Host-to-device command codes, 0x80 and up. |
[[marker]] |
TW_MARK_<NAME> |
Marker.<NAME> |
In-stream capture markers, 0xF0 to 0xF4. |
[[event]] |
TW_EVT_<NAME> |
Event.<NAME> |
EVENT marker sub-codes. |
[[end_reason]] |
TW_END_<NAME> |
EndReason.<NAME> |
END marker reason codes. |
Each array entry has a name, a code and an optional desc, which becomes a
comment on both sides:
[[transaction]]
name = "BUILD_INFO"
code = 0x88
desc = "Firmware build identity: git commit (40 hex chars) + dirty flag"
Generate the files¶
protocol/generate.py is a stdlib-only PEP 723 script (it uses tomllib). It
writes two files:
| File | Contents |
|---|---|
firmware/inc/protocol.h |
#defines for the version and capability bits, and one enum per table. The firmware build reaches it as <protocol.h> through the inc include path. |
packages/tapewyrm-cli/tapewyrm/link/protocol.py |
PROTO_VERSION, CAPS and one IntEnum per table: Txn, Marker, Event, EndReason. |
Both open with GENERATED by protocol/generate.py from protocol.toml — DO NOT
EDIT. Never edit them by hand.
just gen # write both files (CRLF)
just gen-check # exit 1 and name each stale file; writes nothing
--check compares content after normalising line endings, so it does not flag
a file that differs only in CRLF versus LF. When writing, the script writes
CRLF to match the repository.
Four things keep the ends in step:
just gen-checkand thecontract.ymlworkflow, which runspython protocol/generate.py --checkon any change toprotocol/or either generated file.just ci, which runsgenand thengit diff --exit-code.packages/tapewyrm-cli/tests/test_protocol.py, which runs the check, compares the generated enums to the TOML, and asserts thattapewyrm_archive.twrf.WireMarkerequalsMarker.tapewyrm-cli's ruff configuration excludesprotocol.py, so formatting never rewrites generated output.
Generation covers codes, not layouts
The opcodes and marker codes are generated. The byte layout of each verb's
request and response, the marker payloads and the capability-bit names are
not. They are written out by hand in firmware/src/qic/qic.c,
packages/tapewyrm-cli/tapewyrm/link/device.py (_CAP_BITS and the
struct formats) and packages/tapewyrm-archive/tapewyrm_archive/twrf.py.
A layout change must be made in every copy. Moving the layouts into
protocol.toml is an open TODO in device.py.
The USB transaction protocol¶
The board enumerates as a USB CDC-ACM serial port. The Tapewyrm verbs are
ordinary Greaseweazle command packets, dispatched by GW's process_command(),
so the framing is GW's own:
request : cmd:u8 total_len:u8 payload... total_len = 2 + len(payload)
response : cmd:u8 ack:u8 payload... payload only when ack == ACK_OKAY
capture : a continuous byte stream after the CAPTURE response
A response has no length field. The host knows how many payload bytes each
command returns, as GW's own host tools do, and SerialTransport.recv_frame
takes the expected length. On any ack other than ACK_OKAY the device sends
only the two header bytes, so the host must not try to read a payload. All
multi-byte fields are little-endian.
An earlier design specified u16-length frames with JSON payloads. The firmware never implemented it; the host matches the firmware.
Verb payloads¶
| Transaction | Code | Request payload | Response payload (after cmd, ack) |
|---|---|---|---|
INFO |
0x80 |
none | proto_ver:u8, caps:u32, sram_bytes:u32, sample_hz:u32 (13 bytes) |
SET_TIMING |
0x81 |
pulse_us:u16, inter_pulse_us:u16, terminate_gap_us:u16, tack_us:u16, tbit_us:u16, report_on_index:u8 (11 bytes) |
none. Refused unless idle. |
COMMAND_TXN |
0x82 |
cmd_n:u8, report_bits:u8 |
flags:u8 (bit 0 ack, bit 1 final OK, bit 2 timed out), bits:u16, nbits:u8 |
WAIT_READY |
0x83 |
timeout_s:u16 |
status:u8: 0 ready, 1 timed out |
CAPTURE |
0x84 |
motion_n:u8, rate:u16, tpt:u16, direction:u8, pass_id:u16, byte_budget:u32 (12 bytes) |
none; the capture stream follows |
SELECT |
0x85 |
reserved, not implemented | |
ABORT |
0x86 |
reserved, not a command | |
SCOPE |
0x87 |
cmd_n:u8, duration_ms:u16 (duration capped at 10 s) |
initial:u8, n_edges:u8, overflow:u8, counts:4×u16, then n_edges × {t_us:u32, state:u8}; at most 10 edges |
BUILD_INFO |
0x88 |
none | commit:40 bytes ASCII hex (zero-filled if unknown), dirty:u8 |
Notes on each:
- Capability gate.
DeviceLink.open()first sends GW'sGET_INFOfor the board identity, thenINFO. It refuses a device whoseproto_veris belowPROTO_VERSIONor that lacks theverbsandcapturecapabilities. Stock GW answersINFOwithACK_BAD_COMMAND. COMMAND_TXNis the verbatim seam. The host sends a QIC-117 command as a number of STEP pulses and never its meaning. The host raises if the ack or final bit is missing.- Drive select uses GW's own
SET_BUS_TYPE,SELECT,DESELECTandMOTOR. Code0x85exists only in the table. CAPTUREsends the motion command, then arms GW's flux read with no index limit and no deadline. It does not wait for ready first, because a status report can swallow the first segment. Withbyte_budgetabove 0 the device stops itself after that many flux bytes and seals the stream withEND. With 0 it runs until the host stops it.- Stopping a capture. The host stops a running capture out of band with
GW's clear-comms baud change (
BAUD_CLEAR_COMMS, handled infloppy_configure).ABORTnames that action; it is never sent as a packet. The firmware also ends a pass by itself when the tape stops at logical EOT (no flux for 1 s). BUILD_INFOis its own verb rather than moreINFOfields, soINFO's layout stays stable. Older images answerACK_BAD_COMMAND.
The capture stream¶
After CAPTURE, the device streams GW flux bytes with markers on GW's 0xFF
opcode escape:
0xFF marker_code:u8 len:u8 payload...
| Marker | Code | Payload |
|---|---|---|
SESSION_START |
0xF0 |
rate:u16, clock:u32, tpt:u16, direction:u8, pass_id:u16 (11 bytes) |
SEGMENT |
0xF1 |
ticks:u32, index:u32 (8 bytes), one per INDEX edge |
EVENT |
0xF2 |
code:u8 (an Event: MOTION_STARTED, HOLE_EOT, OVERFLOW, GAP) |
END |
0xF3 |
reason:u8, flux_count:u32, byte_count:u32, checksum:u32 (13 bytes) |
HEARTBEAT |
0xF4 |
Defined; optional keepalive |
There is no byte stuffing. 0xFF is an escape only as the lead byte of a
token and appears freely as data, so the host walks the stream token by token
(tapewyrm_archive.twrf.parse_body). END lets the host verify its parse:
the flux byte count and additive checksum must match what it read. The stream
format, as stored in a TWRF file, is specified in TWS-1.
The [[marker]] table in protocol.toml carries a TODO(bench) comment
saying the marker values are placeholders to be reconciled with GW's opcode
space. That reconciliation is done: GW's flux opcodes are 1 to 3, the markers
are 0xF0 to 0xF4, and real captures parse and verify against END. The
comment is stale.
The 64-byte response limit¶
GW sends a command response as one 64-byte USB packet. Anything longer is
cut off. On the bench a 253-byte SCOPE response arrived truncated at 64
bytes and the host desynchronised.
Every verb response, including the 2-byte cmd/ack header, must therefore
fit in 64 bytes. Data that cannot fit must be streamed, as CAPTURE (and GW's
READ_FLUX) do. Current sizes:
| Verb | Response size |
|---|---|
INFO |
2 + 13 = 15 |
COMMAND_TXN |
2 + 4 = 6 |
WAIT_READY |
2 + 1 = 3 |
SCOPE |
2 + 3 + 8 + 10 × 5 = 63, which is why the edge log is capped at 10 |
BUILD_INFO |
2 + 41 = 43 |
Add a verb¶
- Add a
[[transaction]]entry toprotocol/protocol.tomlwith the next free code at0x80or above, and adesc. - Run
just gen. Commitprotocol.toml,firmware/inc/protocol.handtapewyrm/link/protocol.pytogether. - In
firmware/src/qic/qic.c, add#define CMD_QIC_<NAME> TW_TXN_<NAME>and a handler. Document the request and response layout in the comment above it, little-endian, with the total size. Check the response against the 64-byte limit. - Add the
case CMD_QIC_<NAME>:toprocess_command()insrc/floppy.c. - Add the call to
DeviceLinkintapewyrm/link/device.pywith the same layout, and a test that drives it throughFakeTransport. - If the verb is optional, make the host tolerate
ACK_BAD_COMMANDfrom an older image, asBUILD_INFOdoes. Otherwise, bumpversioninprotocol.tomlso the capability gate refuses images without it.
Add a marker or event¶
- Add the entry to
[[marker]],[[event]]or[[end_reason]]and runjust gen. - Update
WireMarkerintapewyrm_archive/twrf.pyto match (the test in tapewyrm-cli fails until you do) and teachparse_bodyits payload. - Emit it from
qic.cwith the same payload layout. - Update TWS-1.