Docs
One bench, two layers: the chip core runs your firmware, the virtual board answers it. Below: how to run it, how to drive the board, what it supports, how it is built, and how to check a run.
Run firmware
- Load a
.hexor.binbuilt for flash0x0with ARM GCC and no SoftDevice — or pick a preset from the bench. The loader parses Intel HEX (type-02 and type-04 addressing, up to 512 KB) and collects0x1000xxxxrecords as UICR bytes for later; a.binloads raw at zero. Staging parses and holds the image; nothing executes yet. - Press Run. Staging boots through the same
bootImagepath as a dropped file: re-register taps, build the model, create the CPU from the vector-table SP/PC, load firmware at zero, enable IRQ delivery, apply UICR words. The status line names the vectors it booted. - For stock MicroPython, press Boot MicroPython app: the page re-inits pristine (frames between load and click leave dirty timers and NVIC state), re-applies UICR, hand-installs the MBR parameter page (
*(0x20000000)=0x1000,*(0x20000004)=0x1C000) the way a real MBR-to-SoftDevice boot would, sanity-checks the vectors at0x1C000, and resets the CPU there. Banner follows on the console.
If Run reports nothing staged, Load a file or pick a preset first. If MicroPython reports no vector table, the image at 0x1C000 is blank or erased (0xFFFFFFFF). If it reports no valid app, the SP/PC fail the gate (SP inside 0x20000000–0x20020000, PC inside 0x1C000–0x80000) — the bench refuses to jump into the weeds and says so.
Drive the board
Every control on the bench answers through the same peripheral path real firmware uses — there are no shortcuts.
- Matrix: the 5×5 grid renders rows sunk low and columns driven high, gated on direction set to output. A toggling latch on an input pin stays dark, exactly as on silicon. Content appears only once firmware constructs the display and starts the TIMER4 strobe.
- Buttons A and B: pointer down drives the pin low, release returns it high (active-low with pull-ups,
P0.14/P0.23). A held button reads pressed across reset because input latches survive re-init. - Motion: the Tilt X/Y sliders set the accelerometer in milli-g (Z stays at 1000); Shake randomizes all three axes. The part answers
WHO_AM_I, echoes control writes, and pulses data-ready onP0.25— 60 ms low, 140 ms high — because a stuck low trips the shared KL27 threshold and firmware re-polls. - Console: type and press Enter to send. Bytes drip one per consumed event and mirror into the DMA ring, the way silicon paces single-slot RX. Bulk-completing early would stall input, so the bench never does.
- OLED scope: any edge-I2C firmware talking to address
0x3Con TWIM0 draws into the 128×64 framebuffer on the page. - Speedometer: the header shows slice rate plus sustained average since boot. Tight spins burst high while banner progress needs sustained throughput — trust the average.
Board support matrix
Status key: F runs timed with interrupts and a firmware proof, H answers the handshake minimum with no timed behavior or no consumer, – missing or deliberately out of scope. Source: the repo status audit against monox/nrf52833.svd, suite 274 green.
| Board feature | Status | What works | What is left | Remark |
|---|---|---|---|---|
| 5×5 LED matrix (TIMER4 + GPIOTE/PPI) | F | DIR+OUT gated render from P0/P1 OUT with 3-frame strobe persistence; matrix_nrf strobes the "A" glyph (correct masks 0xD1688800); JS Matrix-demo button drives the same patterns without firmware | Content never drawn pre-scroll | Init stalls before first show; a toggling latch on an input pin stays dark, as on silicon |
Buttons A/B (P0.14/P0.23, active-low) | F | Press and release drive inputs; level-poll proof prints BTN:1/0 | Nothing | input_state survives re-init, so a held button reads pressed across reset |
LSM303AGR accel 0x19 + mag 0x1E (TWIM1) | F | WHO_AM_I 0x33/0x40, STATUS data-ready, CTRL echo, live tilt | Nothing | DRDY pulses 60 ms low / 140 ms high on P0.25; a stuck low trips the shared KL27 threshold |
KL27 interface, UIPM 0x70 | F | Valid protocol frames: BOARD_REV 0x9904, I2C v2, DAPLink, power/HSB state, LED state, user-event queue; e8777 NOP-wake; unknown → ERR/UNKNOWN | DAPLink USB itself never emulated (this page is the loader) | parts/kl27.js Kl27Uipm via TWIM1. Mock: protocol frames verified in smoke |
KL27 USB flash 0x39 (wire 0x72<<1) | F | transact() contract: filename/filesize/visibility/geometry + READ/WRITE/ERASE on a 0x1F000 image (4096×31); always-valid answers, no retry burn | No real flash-chip wear | parts/kl27.js Kl27Flash via TWIM1. Mock: config + roundtrip + erase verified in smoke |
Edge I2C + SSD1306 OLED add-on (TWIM0, 0x3C) | F | Init, fill, framebuffer and DMA paths render to the scope | Nothing | 128×64 GDDRAM model |
| Edge SPI (P13–P16, SPIM2/3) | F | DMA bytes route to the SPI tap; ST7789 240×240 scope renders CASET/RASET/RAMWR + MISO ID (P114) | No physical display wired | Live panel canvas; headless check in parts/spidisplay_check.mjs |
| USB serial (UARTE0) | F | Console drain plus paced RX drip with DMA mirror; TX snapshot proven 0/60 guarded (P112) | MPY serial stalls at TX-only init (RX never initialized, baud 0 — P113); not a UARTE model gap | Bulk-completing RX early stalls input, so bytes drip one at a time |
| Speaker P0.00, mic P0.05/RUN_MIC P0.20, logo P1.04 | F | Speaker P0.00 edge + PWM1 SEQSTART observer (host mute); mic fills PDM when RUN_MIC powers it (else silence); logo button drives P1.04; I2S TX streams to WebAudio 16 kHz mono | No acoustic modeling | parts/kl27.js SpeakerPart/MicPart/LogoTouchPart. Mock: edges/mute/silence/tone/press verified in smoke |
| NFC antenna | F | UICR.NFCPINS gate (P114): antenna pins reserve P0.09/P0.10 + gate field sense; GPIO mode routes pins normally | No RF physics | Pin-route truth, not air emulation |
| Power, deep sleep (WFE/WFI) | F | Sleep-aware pump: tick_n plus wake on pending IRQ | Nothing | Without it the core naps forever at first idle |
| QSPI external flash | F | Registered image, program, erase, take/complete API | No board chip wired | Model proven, no consumer |
| USB device | F | EPIN/EPOUT pump, USBRESET inject, SETUP inject | Nothing | air preset pre-signals USBRESET on Run |
| Radio 2.4 GHz | H | Two-instance loopback bridge, RSSI and corrupt injects | No real air | Bare-metal CODAL datagram surface; no WebBluetooth |
| BLE stack (SoftDevice SVC face + Bumble air bridge) | F | All 67 S132 BLE SVC numbers claimed (common 0x60–0x69, GAP 0x70–0x8E, GATTC 0x90–0x99, GATTS 0xA0–0xAC, L2CAP 0xB0–0xB2); GAP connect/disconnect/scan/RSSI, full GATT client (incl. SIGNED/PREP/EXEC writes with op echo) + local GATTS table with CCCD-gated notify/indicate, full SMP reply legs with S132 status codes, bond store (hit/miss/delete + bridge keys leg), TX-flow refill + TX_COMPLETE, dial-in CONNECTED (PERIPH role), param-update event, driver-posted request/report/timeout legs (SEC_REQUEST, CONN_PARAM_UPDATE_REQUEST, SCAN_REQ_REPORT, GAP/GATTC/GATTS TIMEOUTs, USER_MEM pair, RW_AUTHORIZE_REQUEST, SYS_ATTR_MISSING, SC_CONFIRM), TX-power store + adv-state, two-link two-peer air (battery 87 vs 64), serialized ATT queue, live reads on the BLE panel; SoC flash events via SVC 16/82 (P114) | No SMP crypto math (MAC check is firmware-side); adv-state arms no air; no MTU/DLE/PHY; no BLE-enabled stock image; virtual air with link-budget RSSI, not RF — see "BLE: is it fully done?" below | Self-test button runs the full SVC flow on loopback; depth probe covers it headless; E2E proves 42 checks over air; enable BLE air for the live Bumble peers |
Chip peripheral matrix
Every nRF52833 peripheral in the SVD, same status key. Unlisted offsets read as zero and writes are ignored, because real HALs probe reserved registers.
| Peripheral | Status | What works | What is left | Remark |
|---|---|---|---|---|
CLOCK + POWER (0x40000000) | F | HF/LF STARTED, USB detect and ready, RESETREAS, GPREGRET, RAMSTATUS | Nothing | AIRCR SYSRESETREQ reboots and self-clears |
RADIO (0x40001000) | F | TX take, RX completion and inject, CRC error, RSSI, SHORTS, 802.15.4 helpers; link-budget air level (P119: TXPOWER-code table + path-loss injects, RX stamps the packet's own level); real CRC engine + whitening + interference floor (P124) | Real air | Loopback proof green |
| UARTE0 + UARTE1 | F | TX DMA, RXDMA ring, OVERRUN, ERROR, TXSTOPPED; UARTE1 TX+RX DMA firmware proof (uarte1_nrf, P110) | Nothing | STARTTX snapshots bytes synchronously so late takes cannot smear them |
| TWIM/TWI/SPIM/SPIS/TWIS/SPI families (incl. SPIM2/3) | F | SHORTS, NACK without slave (SPI never NACKs), slave engines, MISO routing, shifted-address match, SPIM2/3 DMA firmware proof (spim23_nrf, P110) | Nothing | nrfx shifted addresses normalize to 7-bit. Mock: TWIS write-to-RAM plus read-back, SPIS exchange, edge-display frames |
| TIMER0–4 | F | Prescaler, bitmode, SHORTS-CLEAR, CAPTURE snapshots, interrupts | TIMER4 never starts in MakeCode | Display never constructed — not a timer gap |
| RTC0–2, TEMP, RNG, EGU, PWM | F | Full instances, driver-settable values where applicable, second depth proofs (COMPARE/OVRFLW, STOP/INTEN, DATARDY/INTEN, SHORTS/re-arm, channels/mask — P110) | Nothing | Depth closed P110. Mock: thermometer DATARDY plus quarter-degree read |
| SAADC, PDM, QSPI, NVMC, PPI, I2S, USBD, GPIOTE, NFCT | F | Take/complete pumps, state machines, group dispatch, field detect; crypto+QSPI live in pumpDma (P109) | MWU/NFCT beyond proof-level driving | Every take/complete pair is exported. Mock: limit edges, stream silence plus capture, flash program plus erase, tag tap plus frames, SETUP packet |
| ECB, AAR, CCM | F | Take/complete with FIPS-197 and roundtrip proofs; live pumpDma (shared crypto.js, P109) | AAR has no wasm export | Crypto runs driver-side by design. Mock: FIPS-197 block, resolve, CTR plus MIC |
| WDT, QDEC, COMP, MWU, FICR/UICR, GPIO, NVIC/SCB/SysTick/MPU/FPU | F | Expiry and reboot, quadrature, thresholds, region watch, UICR store, CNF→DIR, lazy FP stacking, nRF engine UNUSED stub (fpu_engine_nrf.rs) | Nothing | Documented, matches the audit. Mock: knob steps, comparator band edges |
| ACL (no SPU on 833) | F | 8 regions ADDR/SIZE/PERM sticky; erase refused into write-protected pages; read-block enforced in the memory layer (MWU-patterned armed flag, bus fault + 0 on blocked reads) | Nothing | SVD-verified: 70 peripherals, none named SPU. Test: acl_regions_sticky_and_block_erase |
BLE: is it fully done?
No — and here is the honest boundary, file by file. The firmware-visible contract is complete: every one of the 67 S132 BLE SVC numbers (common 0x60–0x69, GAP 0x70–0x8E, GATTC 0x90–0x99, GATTS 0xA0–0xAC, L2CAP 0xB0–0xB2) is claimed by nrf52833-periph-wasm/src/sd_ble.rs and answered per the header register contracts — no call falls through to a fault, and cargo test (274 green, 14 sd_ble tests incl. byte-offset asserts) plus the headless mock (real SVC bytes, 18/18) plus the live E2E (42/42 over-air checks on two links) prove the whole flow: enable, table build, connect, discovery ×6, read, write (REQ/CMD/SIGNED/PREP/EXEC), L2CAP, pairing + peer-pairing with passkey, indicate + service-changed, ADV/SCAN roles, scan, RSSI, disconnect. The air side is real too: tools/ble_air_bridge.py resolves every job over a Bumble LocalLink against two advertising peers (battery 87 PeerBatt + heart-rate twin 64 PeerHR, distinct addresses), with per-peer routing, serialized ATT bursts, op-echoed WRITE_RSP, and HCI-RSSI-first sampling. A compiled BLE-enabled image proof also exists: blinky/ble_fw/ble_pairing_fw.c drives the CODAL-BLE-shaped JustWorks flow (ENABLE→GATTS table→CONNECT→GATT→AUTHENTICATE→CONN_SEC_GET→DISCONNECT) end to end natively (cargo test nrf_ble_pairing_fw_markers, 17 BLEP:* markers, 2nd-run clean) plus blinky/ble_fw/ble_roles_fw.c for the ADV/SCAN/whitelist/role legs (21 BLER:* markers, 2nd-run clean, bit-identical rebuild).
What is genuinely finished:
- SVC dispatch: all 67 numbers claimed; unknown numbers still fall through to
raise_syncuntouched (zero-cost when BLE is idle); every return code is S132-verified (NOT_ENABLED / INVALID_CONN_HANDLE / INVALID_STATE / BUSY / NO_TX_PACKETS / CID_IN_USE / DATA_SIZE / NOT_FOUND / INVALID_ADDR / INVALID_PARAM / NO_MEM); RAM-pointer validation on every pointer arg. - GAP: enable with RAM-floor report + SC-enable latch, two-arg evt_get (length query, DATA_SIZE without popping, legacy drain), address get/set, advertising data/params validation (directed peer, filter policy, whitelist shape; IN_USE arbitration + CONN_COUNT leg), scan with observer-slot discipline (BUSY/INVALID_STATE + S132 param/whitelist validation) and live ADV_REPORT (one per peer, unpacked pad byte), connect with staged air job + CONNECTED (CENTRAL role, real conn_params), disconnect with HCI reason echo, RSSI start/stop/get with HCI-first sampling and
srctagging. - GATT client: all six discovery kinds (primary/relationship/characteristic/descriptor/attr-info/UUID-read) plus multi-read, plain read with offset, write (REQ + CMD + SIGNED with 12B signature in tow + PREP queue/EXEC commit onto the table mirror, bytes copied at SVC time; op echoes at wire offset 8), HV confirm; every response carries the gattc envelope head and unpacked pads; 128-bit rows encode null with vendor type.
- GATT server: service/char/descriptor table with SoftDevice-shaped handles, struct-form VALUE_SET/GET (length query, offset checks, conn 0xFFFF allowed), ATTR_GET, CCCD tracking per link with notify-bit/indicate-bit gating (unsubscribed refuses INVALID_STATE), HVX staging with per-link TX budget, SERVICE_CHANGED gated indication (SC-enable bit + 0x2A05 CCCD indicate bit, header retval ladder, tag-16 air job, SC_CONFIRM with conn head).
- Pairing legs: AUTHENTICATE stages the handshake; all six peer-initiated request events post with conn-first wire bodies; every reply SVC validates against the per-link state machine (accept/passkey/OOB/encrypt); S132 SEC_STATUS codes incl. the 0x29→0x85 fix; AUTH_STATUS carries the conn head; complete/fail post AUTH_STATUS (+CONN_SEC_UPDATE) with bonded/encrypted link state feeding CONN_SEC_GET.
- L2CAP: dynamic-CID register/unregister with range + capacity checks, TX staging with bytes copied at SVC time, RX echo completion.
- Multi-link: per-link handles, RSSI, TX budget, security, pairing, CIDs; events carry their conn; pump + bridge + E2E all prove two live links with distinct peers and values.
What is deliberately not done (each is a named, bounded gap — none is a hidden fault):
- No SMP crypto math: passkeys validate shape (6 ASCII digits) but never encrypt; OOB data zeroes 32 bytes. Bond STORE is real (per-peer LTK/IRK/CSRK/master-id, hit/miss/delete + bridge keys leg) — a sniffer comparing air bytes against silicon would see the crypto difference, not a store difference.
- Central dial-out plus dial-in accept (PERIPH-role CONNECTED when a peer answers our advertisement); adv-state (active/directed/filter/whitelist) validates + arbitrates (IN_USE/CONN_COUNT legs) and the bridge completes dial-in over air; TX_POWER_SET stores the S132-legal dBm set. Unallocated numbers inside the S132 ranges answer NOT_SUPPORTED (ble_ranges.h rule), never fault.
- No parameter enforcement: CONN_PARAM_UPDATE / PPCP_SET accept without posting updates; MTU exchange, data-length extension, and PHY update do not exist in S132 SVC form (no SVC numbers for them) and are not synthesized.
- TX flow is real (tokens refill per air packet + TX_COMPLETE posted; NO_TX_PACKETS still gates staging). Driver-posted legs are real too (P119): SEC_REQUEST, CONN_PARAM_UPDATE_REQUEST, SCAN_REQ_REPORT, GAP/GATTC/GATTS TIMEOUTs, USER_MEM_REQUEST/RELEASE, RW_AUTHORIZE_REQUEST, SYS_ATTR_MISSING, SC_CONFIRM all post with S132 wire bodies (mock drains each by strict id).
- GATTC write covers the full op set: REQ(1)/CMD(2) plus SIGNED_WRITE(3, short refuses INVALID_PARAM, signature rides in tow for the firmware MAC check) plus PREP_WRITE(4, offset queue per link) plus EXEC_WRITE(5, commit/cancel onto the table mirror). Every op stages its air job and completes with the op echo.
- SoC/MBR SVC numbers (mutex, rand pool, power/clock/PPI监管, ECB/CCM/AAR-adjacent sd_ calls) are unmodeled — out of scope for the BLE face; the on-chip crypto engines keep their own take/complete models.
- No BLE-enabled stock image: MicroPython ships
MICROBIT_BLE_ENABLED: 0and MakeCode builds here never init the stack, so no shipped firmware exercises this face end-to-end (proven instead by the GCC conformance firmware, the C face, the pairing-fw image, the mock, and the E2E). - Virtual air, not RF: two Bumble peers on a LocalLink, not spectrum; link-budget RSSI (P119: TX dBm minus path loss, clamped [-127, 0], one shared pure function) with adv-sighting fallback; P124 ambient floor heats ED/CCA + RX stamps in log-power; real CRC engine (CRCCNF/POLY/INIT + RXCRC latch) + nRF LFSR whitening. No multipath/fading model.
If you need one sentence: the BLE contract firmware calls is fully answered and proven over virtual air; the radio physics and the crypto math are not.
How the bench is built
The page runs a loop you can read in one sitting. Each animation frame steps the core five times — cpu.step(20000), tick_peripherals(), then the DMA pump — because duties once per frame starve polled firmware: a one-byte transfer staged at slice zero would wait a hundred thousand instructions for completion. When firmware naps in WFE/WFI, the loop advances virtual time and wakes on a pending interrupt; without that the core sleeps forever at its first idle. Faults break the sub-loop and name the program counter on the status line.
- Chip core (Rust to WASM): Cortex-M4F plus every nRF52833 peripheral, timed off a single instruction-count clock. Unlisted register offsets read zero and writes are ignored, because real HALs probe reserved registers. Unknown instructions halt loudly — nothing fails quietly.
- Virtual board (JavaScript): the matrix, the LSM303 motion sensor, the SSD1306 OLED, and the serial line. Each part registers its I2C slaves before init, then answers every frame through take/complete transfers and tap events.
- EASYDMA pump: firmware stages a transfer, the bench moves bytes between guest RAM and the host, then completes it — UARTE, TWIM, SAADC, PDM, I2S, USBD endpoints, NVMC erases (applied as
0xFF), radio loopback, and BLE SVC jobs (GAP/GATTC/L2CAP staged, resolved over the Bumble bridge or loopback, GATTS answered synchronously). I2C buses belong to the parts, not the generic pump. - Reboots: firmware that requests reset via AIRCR is rebooted from the vector table — the app table after a direct-app boot, so MicroPython never falls back into the MBR loop.
Depth probes: every handshake earns a consumer
Handshake status used to mean the registers answer with no timed behavior or no consumer. That second half is now closed: demo/parts/mocks.js holds a live consumer for each one, in the same register-then-poll shape as the board parts. Press Run depth probes on the bench, or run node demo/parts/handshake.mjs headless against the built package — eighteen checks, one per surface:
- Thermometer, comparator band edges, quadrature knob steps.
- FIPS-197 AES block, address resolve, CTR plus MIC roundtrip.
- Audio stream silence and capture, NFC tap with frames, I2C and SPI slave engines, edge-display frames over the SPI tap.
- ADC limit edges, USB setup packet, radio CRC-error path, flash program plus erase.
- BLE stack face: real SVC bytes for enable, table build, connect, all six discovery kinds, read, write, L2CAP, pairing plus peer-pairing with passkey, indicate, scan, RSSI, disconnect — with the self-test button running the same flow on loopback in-page, plus Run JS face / Run TS face buttons proving the contract in both languages on a scratch core.
The KL27 stubs stay handshake by decision: the interface chip is deliberately not emulated, so an empty version frame and a request echo are the whole consumer. The NFC pin-route is modeled (UICR.NFCPINS reserves P0.09/P0.10 + gates field sense); RF physics is not.
Checks before you trust a run
- Suite green: run
cargo testin the crate directory (274 green, both thread modes post-P108; single-threaded stays the gate by convention),node demo/parts/smoke.mjsfor the parts, andnode demo/parts/handshake.mjsfor the handshake mocks (18/18, build the local package first withnpm run build:handshakeinsidedemo/). Live BLE: serve the bench, startpython3 tools/ble_air_bridge.py --port 18771, thennode demo/parts/ble_live_e2e.mjs(42/42 over air) orpython3 tools/browser_verify_16.py(blinky + self-test + 16/16 probes). - Parallel-test flake (CLOSED P108; open pre-existing before that): two mechanisms, both fixed. (a) Deterministic order-dependence — P105: MPU ENABLE + programmed regions lived in the installed model past the test (filtered
mpu mwu unalignfailed 10/10 single-threaded pre-fix; exit disarm in the two MPU-programming cpu tests). (b) Cross-thread SYS-swap — P108: the installedSYSpointer was swapped by everyboot()with no join while sd_ble unit tests never heldBOOT_LOCK(filteredsd_ble mwuparallel failed ~18/20 pre-fix;BOOT_LOCKjoin in all 14 sd_ble tests). Full suite parallel green post-fix (~30/31 runs; residual is rare scheduling noise, backtrace uncaptured); single-threaded-- --test-threads=1stays 239/239 and is the gate by convention, not by necessity. - Rebuild the package after Rust changes with
wasm-pack buildintodemo/pkg, then serve over HTTP —file://cannot load the wasm module. - Firmware proofs rebuild with the GCC flags in
docs/README.md; the C recipe is verified bit-identical.