Remaining work to take the Thread transport (hap-thread) and its reference
accessory (hap-thread-dut) from "validated over UDP" to "validated over real
Thread against a real accessory, published." Each item below is a self-contained
session spec — hand one to a fresh session and it has the context to execute.
Do them in order (each builds on the previous). Everything lives on branch
thread-support (off main).
Crates
crates/hap-thread— the HAP-over-Thread controller (client). Speaks HAP PDUs over CoAP/UDP/IPv6. Reviewed twice; seecrates/hap-thread/BRINGUP.md.crates/hap-thread-dut— the reference accessory (server / device-under- test). Lets the controller be driven end-to-end without Apple hardware. To be published as a DUT.crates/hap-crypto— all crypto. Controller-side Pair Setup/Verify clients, plus (added for the DUT)SrpServer,PairVerifyClient::event_key. The SRP module (src/srp.rs) ispub(crate).
Reference (the correctness oracle — cross-verify against it, per CLAUDE.md):
aiohomekit, vendored at
xtask/scripts/capture-pair-setup/.venv/lib/python3.14/site-packages/aiohomekit/
— CoAP transport in controller/coap/{connection,pairing,structs,pdu}.py; BLE in
controller/ble/. Run its pure functions to capture byte-exact vectors.
Design docs: docs/superpowers/specs/2026-08-07-hap-thread-design.md (the
hap-thread design + open decisions D1–D6), crates/hap-thread/BRINGUP.md
(hardware findings F1/F2/F7), test-vectors/thread-coap/ (session/key vectors +
capture script).
What already works (over UDP, no hardware): hap-thread ⇄ hap-thread-dut
complete identify, Pair Verify (M1–M4), the encrypted session, and Lightbulb
On read/write — see crates/hap-thread-dut/tests/identify.rs. Run
cargo test -p hap-thread-dut to confirm the baseline.
The Pi test rig: admin@192.168.1.29 (passwordless sudo). OTBR is already
running (leader of Thread network OpenThread-89d7, channel 24; off-mesh prefix
fdc8:45f:7f98:1::/64). Thread radio (Nordic RCP) on /dev/ttyACM0; ESP32-C6 on
/dev/ttyACM1. Rust toolchain installed; repo cloned at ~/hap-rust. No
espflash/esptool yet.
House rules (CLAUDE.md): no unwrap/expect in library code (tests exempt
with an #[allow]); #![forbid(unsafe_code)]; rustdoc on public items; clippy
pedantic -D warnings; test-vectors captured from aiohomekit before code; strict
semver. No Co-Authored-By trailers.
Goal: hap-thread's real pair_setup completes a full M1–M6 Pair Setup
against hap-thread-dut over CoAP, establishing a pairing (the DUT learns the
controller's LTPK; the controller learns the accessory's LTPK) that a subsequent
Pair Verify then uses — all over UDP, no hardware.
Already done: the SRP-6a primitive — hap_crypto::srp::SrpServer
(verifier, B, premaster, session key, verify-M1→M2), cross-validated against
SrpClient (srp::tests::srp_client_and_server_agree_end_to_end).
Task:
- Expose the SRP server for the DUT. Recommended: a small public HAP-specific
wrapper in
hap-crypto(e.g.pub struct HapPairSetupSrpServer) hiding the generics/group — constructornew(setup_code) -> (server, salt), methodsb_pub_bytes(),session_key(a_pub_bytes) -> Result<Vec<u8>>,verify_m1_prove_m2(a_pub_bytes, m1) -> Result<Vec<u8>>. Keepsrpmodulepub(crate); only the wrapper is public. (Additive → hap-crypto minor bump.) - Implement the accessory M1–M6 state machine in
hap-thread-dut(src/pairing.rs— addpair_setupalongside the existing Pair Verify):- M1→M2: parse
{State=1, Method=0}; reply{State=2, Salt=s, PublicKey=B}. - M3→M4: parse
{State=3, PublicKey=A, Proof=M1}; compute the session key fromA, verifyM1, reply{State=4, Proof=M2}(or{State=4, Error}). - M5→M6 (the LTPK exchange): decrypt M5's
EncryptedDataunderHKDF(K, "Pair-Setup-Encrypt-Salt", "Pair-Setup-Encrypt-Info")with noncePS-Msg05; it holds{Identifier=controllerID, PublicKey=controllerLTPK, Signature}. Verify the controller signature overiOSDeviceX ‖ controllerID ‖ controllerLTPK, whereiOSDeviceX = HKDF(K, "Pair-Setup-Controller-Sign-Salt", "Pair-Setup-Controller-Sign-Info"). Store(controllerID, controllerLTPK)as the pairing (call the existingprovision_controller). Then build M6: signAccessoryX ‖ accessoryID ‖ accessoryLTPK(AccessoryX = HKDF(K, "Pair-Setup-Accessory-Sign-Salt", "Pair-Setup-Accessory-Sign-Info")), encrypt{Identifier, PublicKey, Signature}with noncePS-Msg06, reply{State=6, EncryptedData}. - Wire
PATH_PAIR_SETUP("1") inhandle()to this state machine (drop the current4.04stub). Hold M1/M3 progress in aMutex, like Pair Verify.
- M1→M2: parse
- Cross-check (the whole point): in
tests/identify.rs, add a test that driveshap_thread'sThreadController::pair(Pair Setup) against the DUT with a fixed setup code, asserts it returns anAccessoryPairing, and thenconnect()s (Pair Verify) with that pairing and reads the Lightbulb — a full pair → verify → read with nothing pre-provisioned.
References: mirror hap_crypto::pair_setup::PairSetupClient exactly (M5/M6
salts, info, nonces PS-Msg05/06, the iOSDeviceX/AccessoryX derivations, the
signed-message concatenation order) — it is the client this must interoperate
with. aiohomekit/controller/coap/pairing.py::do_pair_setup* shows the CoAP
framing.
Acceptance: the new pair→verify→read test passes; cargo test -p hap-thread-dut and cargo test -p hap-crypto green; clippy -D warnings +
cargo fmt --check clean.
Goal: the DUT runs on the Thread mesh (via the Pi's OTBR) and hap-thread
drives it (identify → pair → verify → read/write On) over actual Thread/IPv6
— proving the real radio/transport path, not just loopback UDP.
Prereq: Item 1 (so full pairing works) — or run with provision_controller
if Item 1 isn't done yet. Item 3's transport fixes are only needed if this
run surfaces them (the DUT answers immediately with small payloads, so the
basic transport may already suffice for read/write; the 0x09 database — which
would need Block2 — is not implemented in the DUT, so it won't be exercised).
Task:
- Cross-compile the DUT for the Pi (
aarch64-unknown-linux-gnu) or build it natively on the Pi (ssh admin@192.168.1.29, the toolchain +~/hap-rustare there):git fetch && checkout thread-support,cargo build --release -p hap-thread-dut. - Bind the DUT to a Thread-reachable address. With OTBR, the Pi has a Thread
interface; bind the accessory to the off-mesh-routable address (in
fdc8:45f:7f98:1::/64) or[::]:5683. Run:./target/release/hap-thread-dut '[<thread-addr>]:5683' AA:BB:CC:DD:EE:FF. (Optionally add SRP registration so it appears under_hap._udp; otherwise address the controller at the known IPv6 directly and skip discovery.) - Drive it from
hap-thread. Either write a tiny example binary (crates/hap-thread/examples/thread_connect.rs: discover or take an addr, pair/connect, read/writeOn) run on the Pi, or from the Mac if it routes to the mesh through the Pi border router. ConfirmOnread/write works over the radio. - Log results in
docs/tested-devices.md.
Acceptance: identify + pair/verify + On read/write succeed against the DUT
over the Thread mesh (not loopback); a captured log shows the CoAP round-trips.
Note anything that broke (feeds Item 3).
Goal: UdpCoapTransport survives what a real accessory (the Onvis) does that
the DUT doesn't. From the hap-thread final review (F1/F2/F7 in
crates/hap-thread/BRINGUP.md).
Task:
- F1 — CoAP separate responses / empty ACKs + token correlation. A slow
accessory sends an empty ACK (type=ACK, code
0.00, same message-id) then a separate CON carrying the payload (new message-id, same token).coap.rs::UdpCoapTransport::postcurrently matches by message-id and returns the empty ACK → fails. Fix: set + match on the token; on an empty ACK keep waiting (don't retransmit) for the token's real response, ACK that separate CON, use its payload. (This also fixes F7's retransmit correlation.) - F2 — Block2 reassembly (RFC 7959). A large response (the
0x09database) arrives block-wise;coap-litedoes message-level only. Reassemble Block2 blocks before handing the payload up (which then gets decrypted as one AEAD message). Raise/relaxRECV_BUFas needed. - Test both against the DUT. Extend
hap-thread-dutto optionally exhibit these behaviours behind a flag: a "slow" mode that empty-ACKs then sends a separate CON, and a "blockwise" mode that chunks a large response. Addhap-threadtransport tests driving those.
References: aiohomekit gets both for free via aiocoap; RFC 7252 §5.2.2
(separate responses), RFC 7959 (Block2). crates/hap-thread/BRINGUP.md.
Acceptance: new transport tests for empty-ACK/separate-response and Block2 reassembly pass against the DUT's slow/blockwise modes; existing tests still green; clippy/fmt clean.
Goal: writing On through the DUT visibly toggles the C6's onboard LED — a
physical demo of the whole chain.
Already done (Rust side): hap_thread_dut::SerialLedActuator writes b'1'/
b'0' to a serial device; the bin takes a 3rd arg for the serial-LED path
(hap-thread-dut '[::]:5683' <id> /dev/ttyACM1).
Task:
- Flash a tiny firmware on the C6 (on
/dev/ttyACM1) that reads one serial byte and sets its onboard addressable LED (WS2812 on GPIO8 for ESP32-C6-DevKitC):'1'→ on,'0'→ off. Options, easiest first:- MicroPython: flash the prebuilt ESP32-C6 MicroPython
.binwithesptool(install in a venv on the Pi — PEP 668 blocks a barepip install), then a ~10-linemain.py(sys.stdinread →neopixel). - Arduino / ESP-IDF / esp-hal: a ~10-line sketch; heavier toolchain.
Set up
esptoolon the Pi:python3 -m venv ~/esptool-venv && ~/esptool-venv/bin/pip install esptool. Chip is on/dev/ttyACM1(USB-serial-JTAG).
- MicroPython: flash the prebuilt ESP32-C6 MicroPython
- Demo: run the DUT with
/dev/ttyACM1as the actuator, driveOnfromhap-thread, watch the LED. (Note: only one process can hold the serial port — don't leave a REPL attached.) - Commit the C6 firmware/sketch under
crates/hap-thread-dut/firmware/esp32c6/with a README (wiring, flash command).
Acceptance: hap-thread writing On=true/false toggles the physical LED.
Goal: confirm hap-thread works against a real HAP-over-Thread accessory,
then release.
Prereq: Items 1–3 (full pairing + hardened transport). The Onvis must be
commissioned onto our Thread network first — this is the real gap
(HomeKit Thread accessories receive their Thread credentials over BLE during
setup; if the Onvis is on Apple Home's Thread network we must re-provision it to
our OTBR). Scope the commissioning approach at the start of this session; it may
need a BLE Thread-credential write added to hap-ble (deferred design work).
Task:
- Commission the SMS2 onto
OpenThread-89d7(BLE credential provisioning or the OTBR commissioner). - Drive
hap-threadagainst it: discover_hap._udp, pair, verify, read the0x09database (needs Item 3's Block2), read sensor characteristics. - Backfill the real
0x09database body as a committed vector undertest-vectors/thread-coap/and implement/verify the0x09→hap-modeltree decode inhap-thread(deferred in MT-1; see design §11 / BRINGUP). - Log in
docs/tested-devices.md. - Publish (user-gated, per repo policy): version-bump + CHANGELOG, verify
full workspace + both clippy matrices + fmt, merge
thread-support→main, publishhap-crypto(if bumped) →hap-thread→hap-thread-dut, tag.
Acceptance: a real read from the SMS2 over Thread; crates published; roadmap items checked off.
- Item 1 — Pair Setup server (SRP primitive ✅; M1–M6 orchestration + M5/M6 ✅)
- Item 2 — Run over real Thread (DUT ⇄ controller over the OMR mesh address on the Pi; radio-hop deferred to a separate node in Item 4/5)
- Item 3 — Transport hardening (F1 separate-responses ✅, F2 Block2 ✅; token correlation covers F7)
- Item 4 — ESP32-C6 LED firmware (MicroPython flashed; byte→LED path verified; DUT drives On→serial end-to-end — physical photons pending a visual confirm)
- Item 5 — Onvis validation + publish. PUBLISHED (hap-crypto 1.4.0, hap-ble 0.7.0, hap-controller 3.0.3, hap-thread 0.1.0, hap-thread-dut 0.1.0; tags pushed). Full chain hardware-validated on the real SMS2: commission over BLE (
hap-ble0.7.0thread_provision) → Pair Verify over Thread →0x09database read (2737 bytes; decodes to the full SMS2 database, MotionDetected iid 3074) → subscribe → live MotionDetected events over the radio (value=[1]detect +value=[0]clear). Transport bugs found+fixed along the way: Block2 continuation framing (empty payload + fresh token) and the decisiveRECV_BUF1500→16 KiB (the Onvis returns0x09as one ~2.7 KB IPv6-fragmented datagram — truncation was the AEAD failure). Events built (recv_eventserver +open_event+decode_events+subscribe/next_event). BLE runs on the Pi (macOS-26objc2blocker on the Mac).
-
0x09→ typedhap-modeltree decode —hap_thread::decode_database+ThreadAccessory::read_database(), cross-verified against aiohomekit on the committed real SMS2 body. Published hap-thread 0.2.0. - Events
StreamAPI —ThreadAccessory::watch_events() -> Stream<(iid, value)>over a background task. Published hap-thread 0.3.0. - Single-socket connection-actor demux — one reader task owns the socket and demuxes request responses from accessory event PUTs, so reads/writes and
watch_events()run concurrently on one accessory. Published hap-thread 0.4.0. - End-to-end events coverage in CI —
hap-thread-dutnow implements event subscribe/unsubscribe + push (push_event), andcrates/hap-thread-dut/tests/events.rsdrives the real controller through subscribe → push → decrypt and proves a concurrent read + event-watch on one accessory (regression guard for the demux) — all over UDP, no hardware.