Status: DRAFT · Owner: project maintainer · Last updated: 2026-07-30
Frozen at G0 (2026-07-01,
[docs]): the source-tree layout (§10.5), which realizes the frozen NFR-MAINT-2 six-module design and the specs.md §5.1/§5.6/§6/§8 freeze. Design narrative elsewhere in this doc remains DRAFT and is pinned per-story by its[red]tests (§4).Frozen pre-v1 resource-qualification design (2026-07-30,
[docs]): §8.5 and §14.3 freeze the two-profile measurement, policy, and HIL V2 design before the dependent[red]tests. Numeric thresholds remain pending measured baseline evidence.
In this document, TDD = Technical Design Document — the engineering design of the PyBLE agent firmware. The project's development methodology is also abbreviated "TDD", meaning Test-Driven Development (red → green → refactor), defined in PRD §1B. Where this document says "TDD" it means this design document; where it discusses the methodology it spells out "Test-Driven Development". §14 is where the two meet: the Technical Design is exercised through Test-Driven Development.
This document specifies how the initial ESP32-family v1 PyBLE agent port is
engineered to satisfy the requirements in specs.md (the firmware
requirements specification). specs.md owns what/why (requirement IDs FR-*,
NFR-*, CON-*, IF-*, BLD-*, SEC-*); this document owns how (modules,
data structures, tasks, state machines, build pipeline) and traces every design
element back to those IDs (§15). It is not a mandate that
future MicroPython ports use ESP-IDF, NimBLE, FreeRTOS, NVS, or the same binary
layout.
The design covers Layers 1–3 of the four-layer model
(firmware.md §1,
specs.md §1.2) for the initial ESP32 port: the consumed upstream
MicroPython submodule (Layer 1), the per-chip board overlay (Layer 2), and the
PyBLE agent modules pyble_ble / pyble_proto / pyble_runner / pyble_fs /
pyble_console / pyble_info plus the pyble_agent dispatch surface (Layer
3). Layer 4 (the user workspace) is served, not designed here. The Flutter app
(app.md) is out of scope except where the firmware shares an
in-memory fake transport with the Dart pble client for conformance tests
(§14).
- specs.md — the requirements this design satisfies; the authority on what.
- PRD §10 — apex firmware requirement set; specs.md and this TDD both flow from it.
- firmware.md — the firmware overview (four-layer rule, module table, native-vs-frozen decision, build, footprint); this TDD is the detailed design beneath it.
- protocol.md (PBLE/1) — owns the wire format. This document maps the wire format to in-firmware structures and never redefines frame bytes, opcodes, UUIDs, or status codes; it cites the relevant
§. - hardware.md — owns chip facts; cited, never restated.
firmware/versions.lock,firmware/upstream/README.md— the single version pin and the clean-submodule rule that the build design (§10) implements.
The decisions below are the load-bearing choices the rest of the design rests on. Each cites the requirement(s) it serves.
Decision (D1, revised 2026-07-01 — see ADR-0006): implement the agent as a native USER_C_MODULE (C) from v1.0 — the pble_*.c sources under firmware/user_c_modules/pyble/, compiled into the firmware image via USER_C_MODULES. It uses the ESP-IDF NimBLE, VFS, and FreeRTOS APIs behind the unchanged PBLE/1 contract. Any interim frozen .py module is a scaffold, retired as each native module reaches parity; pble_proto.c is byte-identical to pyble_proto.py and shares its conformance corpus. — (firmware.md §2; serves NFR-MAINT-3, NFR-FP-C3; closes OI-3 — the transition point is day one.)
Rationale: native C gives throughput + RAM/flash headroom from the start (most acutely on ESP32-C3, the footprint constraint, §8) and deterministic per-packet timing, matching the project's decision to own the agent base in C. TDD leans on the shared PBLE/1 conformance corpus plus a C host harness and on-device/HIL verification rather than CPython host runs. The module boundaries (§4) still isolate each module behind the wire so it can be built and verified independently.
Decision (D2): within the initial ESP32 port, the shared agent code
contains zero esp32/esp32-s3/esp32-c3 conditionals. Everything
chip-specific (pins, flash size, USB, PSRAM, NimBLE config) lives in the
Layer-2 board overlay; the agent reads any chip-varying value at runtime from
MicroPython (sys.platform, os.uname(), gc.mem_free(), machine) or from
a single overlay-provided constants module. Cross-vendor BLE, scheduler,
storage, identity, clock, build, and provisioning differences belong behind a
future target-adapter implementation, not in this ESP32 chip overlay. —
(satisfies NFR-MAINT-1, CON-4; supports BLD-4.)
Decision (D3): all large, long-lived buffers (reassembly buffer, file I/O buffer, TX notification staging) are allocated once at boot and reused, never per-message. Per-message Python object churn on the hot path is minimized to keep the GC quiet and keep a predictable heap floor on the ESP32-C3 (§8). — (serves NFR-FP-HEAP, NFR-FP-C3, NFR-REL-1.)
Decision (D4): the agent runs on a BLE/agent task (the asyncio event loop servicing NimBLE + PBLE/1 dispatch). User code runs on a separate runner task spawned via _thread. The link, STOP, and all control-plane commands remain serviceable no matter what user code does — including while True: pass. — (satisfies FR-RUN-3, FR-BLE-11, NFR-SAFE-2; see §5.)
Decision (D5): Layer 1 is a pinned submodule that is never modified. The board overlay is copied into the upstream ports/esp32/boards/ tree at build prep, the agent ships as USER_C_MODULES / frozen manifest, and the submodule tree stays pristine; the default patch count is zero. — (satisfies CON-1, CON-2, CON-4, CON-12, BLD-15; implemented in §10.)
Decision (D6): pyble_proto and pyble_ble treat protocol.md §3/§4/§8 as the single source of truth; opcode/status/UUID numbers live in one generated constants table mirrored from protocol.md, so a protocol freeze updates exactly one file. — (satisfies IF-PROTO, FR-PROTO-2; manages OI-4.)
Decision (D7): the device label and the single optional identify-LED (one GPIO number + active level) are persisted in a tiny NVS-backed device-config store (§4.8) that the agent owns purely for its own screenless-identity UX. This store is not a routing/pin profile, not a board-capability map, and never maps hardware for user code; the identify blink is cosmetic only. device_id/label/identify config are for recognition and display, and the agent never branches trust or gates access on MAC/device_id/label. — (satisfies CON-13, FR-IDENT-6, SEC-11; upholds — and does not weaken — the PRD §1A.3 rejection of routing/pin profiles and MAC gating.)
Decision (D8): PBLE/1, the protected control plane, workspace jail,
capability negotiation, and conformance corpus are portable. This TDD's
ESP-IDF NimBLE, FreeRTOS scheduling/interrupt delivery, MicroPython VFS,
esp32.NVS, BLE-MAC identity, timer, build, binary-layout, and ESP Web Tools
details are the initial ESP32 target adapter. A future MicroPython + BLE port
MUST supply equivalent BLE peripheral, runner/STOP, filesystem, persistent
config, stable non-personal identity, timer, build, artifact, provisioning, and
HIL behavior behind Layer 2; it need not reproduce these concrete APIs. —
(implements ADR-0021; serves NFR-MAINT-1 and the PRD §1A.4 portability
boundary.)
+--------------------------------------------------------------+
| Layer 4 USER WORKSPACE (served, not part of the agent) |
| /main.py /lib/*.py /data/* /project.json |
+--------------------------------------------------------------+
| Layer 3 PYBLE AGENT (control plane — this design) |
| pyble_agent (boot + dispatch surface) |
| pyble_ble pyble_proto pyble_runner |
| pyble_fs pyble_console pyble_info |
+--------------------------------------------------------------+
| Layer 2 TARGET ADAPTER / BOARD OVERLAY (v1: esp32/-s3/-c3) |
| pins, flash, USB, PSRAM, NimBLE config, manifest |
+--------------------------------------------------------------+
| Layer 1 UPSTREAM MICROPYTHON (pinned submodule, pristine) |
| VM, bluetooth(NimBLE), vfs/LittleFS, _thread, asyncio|
+--------------------------------------------------------------+
BLE (GATT: RX write / TX notify / INFO read)
|
+---------------------v-----------------------+
| pyble_ble | NimBLE peripheral:
| advertise(PyBLE-XXXX) · GATT svc · MTU | adv, MTU, fragment/
| fragment/reassemble (protocol §3.2) | reassemble
+-----------+--------------------+------------+
RX msgs | ^ TX msgs (notify)
v |
+-----------+--------------------+------------+
| pyble_proto | frame codec, CRC32,
| decode/encode frame · CRC32 · ID correlate | ID match, dispatch
| dispatch table (OPCODE -> handler) | table
+--+-------+---------+----------+---------+---+
| | | | |
v v v v v
pyble_info pyble_fs pyble_runner pyble_console pyble_agent
(HELLO/ (list/ (RUN/STOP/ (stdout/ (boot, mode
DEVICE_ stat/get/ SOFT_REBOOT/ stderr tee, FSM, wiring,
INFO, put/del/ RUN_STATE, stdin feed, single-writer
caps) mkdir/rn) runner task) CONSOLE_*) serialization)
| | |
v v v
vfs/LittleFS _thread/asyncio TX queue (back to pyble_ble)
(fs_root jail) user code task
- App writes PBLE/1 fragments to RX;
pyble_blereassembles per protocol.md §3.2 into a complete §3.1 message in the static reassembly buffer. pyble_protovalidates CRC32, decodes the frame, and dispatches byOPCODEto a handler (pyble_fs/pyble_runner/pyble_console/pyble_info), or returns an error status if CRC/structure/version is bad.- The handler executes (file op, run, etc.) under the single-writer serialization owned by
pyble_agent. - Responses (
RSP) and asynchronous events (EVT:RUN_STATE,CONSOLE_DATA,FILE_PUT_ACK,FILE_GET_*) are encoded bypyble_protoand handed topyble_ble, which fragments and Notifies them on TX. - INFO characteristic reads are answered directly by
pyble_blefrom aDEVICE_INFO-equivalent payload prepared bypyble_info, with no subscription required.
All six functional modules plus pyble_agent are Layer 3. They depend downward only on Layer-1 MicroPython APIs and a single Layer-2 constants module (D2). No Layer-3 module imports another chip-specific symbol directly. — (NFR-MAINT-1, NFR-MAINT-2.)
Each module is single-responsibility (NFR-MAINT-2). API sketches are illustrative Python signatures, not the frozen interface; the frozen interface is whatever the [red] tests pin down per §14.
Responsibility: own the NimBLE peripheral: advertising, the single GATT service (RX/TX/INFO), MTU negotiation, and PBLE/1 fragmentation/reassembly. It is the only module that touches bluetooth.
Public interface (sketch):
class BleLink:
def start(self, info_payload: bytes) -> None # init NimBLE, register svc, advertise
def set_info_payload(self, payload: bytes) -> None # update INFO characteristic (FR-BLE-4)
def set_adv_name(self, name: str) -> None # update advertised name pre-connect (FR-BLE-12)
def send_message(self, msg: bytes) -> None # fragment + notify on TX (FR-BLE-10)
def on_message(self, cb) -> None # register reassembled-message callback
def mtu(self) -> int # negotiated MTU (FR-BLE-7/8)
def on_connect(self, cb) -> None
def on_disconnect(self, cb) -> NoneKey data structures / state: the GATT table (Service/RX/TX/INFO UUIDs from the protocol.md §2 constants mirror); a single reassembly buffer (static, sized max_message, see §7.3); a fragment-index tracker (FIRST/LAST/index mod 64); negotiated MTU; connection handle; the advertised name — the device label when set, else PyBLE- + device_id (the last two BLE-MAC bytes in uppercase hex), used for the name only, never for access control (§4.8, CON-7/SEC-7/SEC-11).
Advertised-name assembly: at boot the name is PyBLE- + device_id; when SET_LABEL sets a non-empty label, pyble_ble.set_adv_name(label) updates the advertisement so the label shows in the scan list pre-connect; clearing the label restores PyBLE- + device_id (FR-BLE-12). Length bounding/validation happens in the device-config store before the value reaches the air (§4.8, SEC-10).
Dependencies: Layer-1 bluetooth; the protocol constants mirror (UUIDs, frag-header bits); the device-config store (§4.8) for the label/device_id feeding the advertised name. Calls up into pyble_proto via the on_message callback.
Frozen-vs-native plan: frozen first. Reassembly, the per-fragment copy loop, and TX fragmentation are prime native candidates (D1) since they run per packet.
Satisfies: FR-BLE-1…12, IF-BLE, CON-5 (NimBLE only), SEC-5 (advertisement contents).
Responsibility: encode/decode the protocol.md §3.1 message frame, compute/verify IEEE CRC-32 over VER…PAYLOAD, correlate request/response by ID, and dispatch decoded CMDs to handlers by OPCODE.
Public interface (sketch):
def decode(msg: bytes) -> Frame | None # returns None / raises on structural error
def encode(type_, opcode, id_, payload) -> bytes
def crc32(buf) -> int # IEEE CRC-32 (zlib-compatible)
class Dispatcher:
def register(self, opcode, handler) -> None
def handle(self, frame: Frame) -> None # routes CMD -> handler; emits RSPKey data structures: Frame (namedtuple-like: ver, type, opcode, id, payload); the dispatch table — a dict mapping OPCODE → handler (§7.2); a small CRC-32 lookup table (or native CRC).
Internal state: stateless per message except the registered dispatch table. Events use ID = 0 (FR-PROTO-4).
Error behaviour: CRC fail → drop + EVT ERROR(ECRC) referencing opcode if known (FR-PROTO-3); structurally invalid → EBADREQ (FR-PROTO-8); unknown/unsupported opcode → EUNSUPPORTED (FR-PROTO-9); VER != 0x01 → refuse per versioning (FR-PROTO-7).
Frozen-vs-native plan: frozen first; CRC32 + frame pack/unpack are the second native candidate after pyble_ble.
Satisfies: FR-PROTO-1…10, FR-PROTO-6 (status mapping), IF-PROTO, D6.
Responsibility: run a file or inline source on a separate task; capture stdout/stderr (via pyble_console); implement STOP (KeyboardInterrupt) and SOFT_REBOOT; own and emit RUN_STATE.
Public interface (sketch):
class Runner:
def run_file(self, path: str) -> int # status; spawns runner task (FR-RUN-1)
def run_source(self, src: str) -> int # (FR-RUN-2)
def stop(self) -> int # KeyboardInterrupt into runner (FR-RUN-5)
def soft_reboot(self) -> int # soft-reset VM (FR-RUN-8)
def state(self) -> int # idle/running/done/errorKey data structures / state: RUN_STATE enum (idle / running / done / error); a single runner-thread handle; a busy flag guarded by the single-writer lock (D4/SEC-3); the compiled code object for mode: source.
Execution model: a RUN while state == running returns EBUSY (FR-RUN-4). On launch: reply RSP{status} then emit RUN_STATE(running) (FR-RUN-1). STOP raises KeyboardInterrupt in the runner thread (using MicroPython's scheduled-exception / pending-interrupt mechanism) so it lands even against a tight loop (FR-RUN-5, NFR-SAFE-1). On normal return → RUN_STATE(done); on uncaught exception → traceback to CONSOLE_DATA(stderr) then RUN_STATE(error) (FR-RUN-9). After STOP/exception, teardown is clean and the board returns to idle (FR-RUN-6/10). SOFT_REBOOT clears interpreter state and keeps the BLE link where possible (FR-RUN-8).
Dependencies: Layer-1 _thread, asyncio, the VM's exec primitives; pyble_console for stream capture; pyble_agent for the single-writer lock.
Frozen-vs-native plan: stays frozen — it is control logic, not a per-byte hot path.
Satisfies: FR-RUN-1…10, FR-MODE-2/3, NFR-SAFE-1/2, NFR-REL-1.
Responsibility: implement the file opcodes (list/stat/get/put/delete/mkdir/rename), the windowed-upload state machine with CRC + resume, the fs_root path jail, and temp-write-then-rename atomicity.
Public interface (sketch):
class FsBridge:
def list(self, path) -> list
def stat(self, path) -> tuple # (size, crc32) or ENOENT (FR-FS-2)
def get_begin(self, path, offset) -> tuple # (status, total_size, crc32)
def get_stream(self) -> None # streams FILE_GET_DATA events
def put_begin(self, path, size, crc) -> tuple # (status, resume_offset) (FR-FS-7)
def put_data(self, offset, data) -> None # window write -> FILE_PUT_ACK
def put_end(self, crc) -> int # verify whole-file CRC (FR-FS-6/14)
def delete(self, path) -> int
def mkdir(self, path) -> int
def rename(self, src, dst) -> int
def _resolve(self, path) -> str # jail enforcement (FR-FS-10/11)Key data structures / state: active-upload context (path, tmp_path, total_size, expected_crc, ack_offset, running CRC accumulator, window bookkeeping); the static file I/O buffer; fs_root constant. Only one transfer is active at a time (serialized by the single-writer lock).
Jail design (§9.3): every path is normalized and verified to resolve inside fs_root; .. traversal or absolute escape → EACCES (FR-FS-10). Layer-2/Layer-3 paths are a forbidden set → EACCES (FR-FS-11, SEC-4, CON-10). Only .py/data artifacts accepted; .mpy/.pyc rejected (FR-FS-12, CON-3).
Upload integrity: chunks land in a .tmp sibling; FILE_PUT_END verifies whole-file CRC before an atomic rename over the target; mismatch → ECRC, target untouched (FR-FS-6/9/14, NFR-REL-2/3). FS errors map to ENOENT/ENOSPC/EACCES/EIO/ERANGE (FR-FS-15).
Frozen-vs-native plan: frozen for orchestration; the chunk write + incremental CRC inner loop is a native candidate on C3 (D1).
Satisfies: FR-FS-1…16, IF-FS, NFR-PERF-2, NFR-REL-2/3, SEC-3/4, CON-3/10.
Responsibility: tee the running program's stdout/stderr to BLE as CONSOLE_DATA events (stream-tagged), feed CONSOLE_INPUT bytes to a program blocked on input()/sys.stdin, and optionally mirror to USB-serial for local debug.
Public interface (sketch):
class Console:
def attach(self) -> None # redirect sys.stdout/stderr to the tee
def detach(self) -> None
def feed_input(self, data: bytes) -> None # -> runner stdin (FR-CON-3)
def write_out(self, data: bytes) -> None # -> CONSOLE_DATA(stdout)
def write_err(self, data: bytes) -> None # -> CONSOLE_DATA(stderr)Key data structures / state: an output staging buffer with backpressure (§5.4); a stdin queue feeding the runner; stream-tag constants (stdout/stderr). Output is observe-anywhere — emitted regardless of which client triggered the run (FR-CON-4).
Dependencies: pyble_proto/pyble_ble (TX), pyble_runner (stdin target), optional sys/UART for USB mirror (FR-CON-5, IF-USB — debug only, never a runtime transport).
Frozen-vs-native plan: frozen.
Satisfies: FR-CON-1…5, NFR-PERF-3.
Responsibility: assemble the DEVICE_INFO and HELLO reply payloads (incl. device_id/label/has_identify/identify_led); perform protocol-version + capability negotiation; supply the INFO-characteristic payload to pyble_ble; own the device-config store (§4.8) and handle SET_LABEL/SET_IDENTIFY_LED/IDENTIFY.
Public interface (sketch):
class Info:
def device_info(self) -> bytes # chip, mpy ver, free_mem, fs_root, MTU,
# device_id, label (FR-INFO-1)
def hello_reply(self, offered_versions) -> tuple # (proto_version, caps) | refuse (FR-INFO-5)
def caps(self) -> dict # chip, mpy_version, fs_root, max_file_size,
# put_window W, chunk_size, has_sd, free_mem,
# device_id, label, has_identify, identify_led
def info_payload(self) -> bytes # DEVICE_INFO-equivalent for INFO read (FR-INFO-4)
def set_label(self, payload) -> bytes # 0x50 -> DeviceConfig.set_label (FR-IDENT-1)
def set_identify_led(self, payload) -> bytes # 0x51 -> DeviceConfig.set_identify_led (FR-IDENT-2)
def identify(self, payload) -> bytes # 0x52 -> DeviceConfig.identify (FR-IDENT-3/4)Key data structures / state: the caps dict (FR-INFO-3); supported proto_versions; SD-presence detection result (FR-INFO-6); the auto-run capability flag (opt-in, FR-BOOT-3 — flag name owned by protocol.md §7, OI-5); a handle to the device-config store (§4.8) supplying device_id/label/has_identify/identify_led. All chip-varying values read at runtime (D2) from os.uname(), gc.mem_free(), sys.platform.
Negotiation: HELLO is the first exchange after connect (FR-INFO-2); a client whose offered versions cannot be satisfied is refused rather than silently mis-spoken (FR-INFO-5). The agent advertises only what it implements (FR-PROTO-10).
Frozen-vs-native plan: frozen.
Satisfies: FR-INFO-1…6, FR-FS-13 (fs_root reporting), FR-BOOT-3 (auto-run flag surface), FR-IDENT-1/2/4 (label & identify config exposed via caps/DEVICE_INFO, see §4.8), BLD-13.
Responsibility: the top-level wiring and lifecycle. It boots the agent, constructs and connects the modules, owns the boot/runtime state machine (§6), registers handlers into pyble_proto's dispatch table, and owns the single-writer serialization lock so file/run operations cannot interleave (SEC-3).
Public interface (sketch):
def boot() -> None # entry point from frozen manifest; init -> advertise -> serve
class Agent:
def on_connect(self) -> None
def on_disconnect(self) -> None
def dispatch(self, frame) -> None # delegates to pyble_proto.Dispatcher under the lockKey data structures / state: the lifecycle FSM state; the single-writer lock (_thread.allocate_lock or asyncio lock); references to all module instances; a fail-safe handler that returns the board to advertising on a control-plane fault (FR-BOOT-6, NFR-REL-1).
Boot policy: initialize, start advertising, wait for connection (FR-BOOT-1); never auto-run main.py unless the opt-in flag is set (FR-BOOT-2/3/4/5, NFR-SAFE-3); reach advertising independent of workspace validity (FR-BOOT-4); the agent does not depend on an editable boot.py/main.py (FR-BOOT-5).
Frozen-vs-native plan: frozen (orchestration).
Satisfies: FR-BOOT-1…6, FR-MODE-1/4, SEC-3/6, NFR-REL-1, NFR-MAINT-2.
Responsibility: a tiny persisted device-config store holding exactly two pieces of per-device UX state — the device label (bounded UTF-8) and the single optional identify-LED (one GPIO number + active level) — plus the non-blocking identify blink actuator. It is owned by pyble_info (which reads it for caps/DEVICE_INFO) and read by pyble_ble (advertised name). It is not a routing/pin profile, capability map, or access-control surface (D7, CON-13, FR-IDENT-6, SEC-11).
Public interface (sketch):
class DeviceConfig:
def device_id(self) -> str # stable MAC-derived suffix (XXXX), read-only
def label(self) -> str # persisted label or "" (FR-IDENT-1)
def set_label(self, text: str) -> int # persist; ERANGE if over bound; "" clears (FR-IDENT-1, SEC-10)
def identify_led(self): # (gpio, active_level) or None (FR-IDENT-4)
def set_identify_led(self, gpio, active_level) -> int # persist one LED config (FR-IDENT-2)
def has_identify(self) -> bool # True iff an identify LED is configured (FR-IDENT-4)
def identify(self, duration_ms) -> int # non-blocking blink; EUNSUPPORTED if unset (FR-IDENT-3/4)Persistence (NVS): the label and identify-LED config live in a small NVS namespace (esp32.NVS, available identically on esp32/-s3/-c3 — D2), separate from the LittleFS workspace (§9.5). They survive reboot, so the advertised name, has_identify, and identify_led are stable across power cycles (FR-IDENT-5). The store is outside the fs_root path jail and unreachable by PBLE/1 file opcodes (§9.3); it is mutated only through SET_LABEL/SET_IDENTIFY_LED under the single-writer lock (SEC-3).
device_id derivation (initial ESP32 port): the last two bytes of the BLE
MAC in uppercase hex (XXXX), computed once at boot — stable and ~unique
(protocol.md §2). It feeds the default
name and the device_id caps/DEVICE_INFO field, and is never used for
authorization (SEC-11, CON-7/SEC-7). Another platform port supplies an
equivalent stable, non-personal local suffix without changing the wire field.
Advertised-name assembly (in pyble_ble): the advertised name is label when a non-empty label is set, else PyBLE- + device_id. Setting a label (including clearing to "") calls pyble_ble.set_adv_name(...) so the change is visible in the scan list pre-connect; an over-length label is rejected (ERANGE) before it can reach the air (FR-BLE-12, FR-IDENT-1, SEC-10). The concrete label max-length is owned by protocol.md (OI-6).
Identify blink (non-blocking): IDENTIFY schedules a bounded LED toggle on the BLE/agent context's timer/event-loop path (a machine.Timer or short-lived asyncio task), replies RSP{OK} immediately, and blinks for the protocol-bounded duration without blocking the dispatch loop or the runner task (§5.5). With no identify LED configured it returns EUNSUPPORTED (0x0A) and changes no GPIO (FR-IDENT-3/4). The blink is cosmetic only and maps no hardware for user code (FR-IDENT-6, CON-13).
Dispatch wiring: pyble_agent registers 0x50 SET_LABEL → set_label, 0x51 SET_IDENTIFY_LED → set_identify_led, and 0x52 IDENTIFY → identify into the §7.2 table; each returns a protocol.md §8 status. The wire payload shapes (label encoding, SET_IDENTIFY_LED GPIO+active-level encoding, blink-duration bound) are owned by protocol.md and mirrored, never redefined here (D6, OI-6).
Frozen-vs-native plan: frozen (control/config logic, not a per-byte hot path).
Satisfies: FR-IDENT-1…6, FR-BLE-12, FR-INFO-1/4 (label/device_id fields), SEC-10/11, CON-13, OI-6.
Two cooperating execution contexts (D4):
- BLE/agent context — the asyncio event loop on the main task. It services NimBLE callbacks, runs reassembly/dispatch, executes all control-plane handlers (info/fs/console-input/run-control), and drains the TX queue. It must never block on user code.
- Runner task — spawned via
_threadfor eachRUN. It executes user code withsys.stdout/stderrredirected to the console tee.
Because the link is serviced by the BLE/agent context and user code lives on the runner task, a while True: pass cannot wedge BLE or block STOP (FR-BLE-11, FR-RUN-3, NFR-SAFE-2).
STOP is handled on the BLE/agent context (so it is always reachable) and raises KeyboardInterrupt into the runner thread via MicroPython's pending-exception/schedule mechanism, which lands even inside a tight bytecode loop (FR-RUN-5, NFR-SAFE-1). The runner's top frame wraps user code in a try/finally to guarantee teardown and a final RUN_STATE (FR-RUN-6/10).
pyble_agent holds one lock that serializes mutating operations (file writes, RUN, SOFT_REBOOT) so concurrent commands cannot corrupt workspace or runner state (SEC-3). Read-only commands (FILE_LIST, FILE_STAT, DEVICE_INFO) and STOP are not blocked by a long upload's data phase beyond what correctness requires; RUN during running short-circuits to EBUSY without taking the lock's slow path (FR-RUN-4).
The console tee writes into a bounded staging buffer drained by the TX notification path. When BLE is slower than the program's output, the tee applies backpressure: the runner thread blocks briefly on a full buffer rather than dropping data or growing the heap without bound (protects NFR-FP-HEAP and keeps console latency bounded, NFR-PERF-3). CONSOLE_INPUT bytes are queued and delivered to the runner's stdin (FR-CON-3).
The IDENTIFY actuator (§4.8) must never wedge BLE or user code. The blink runs as a bounded, self-terminating job on the BLE/agent context (a machine.Timer or short-lived asyncio task), not on the runner thread: the handler replies RSP{OK} and returns to the dispatch loop immediately while the LED toggles for the protocol-bounded duration (FR-IDENT-3). It holds no long-lived lock, allocates no per-toggle heap, and stops on its own; a concurrent RUN/STOP/file op is unaffected. With no identify LED configured the handler returns EUNSUPPORTED and does nothing (FR-IDENT-4). The blink is cosmetic and never repurposed for GPIO routing, capability mapping, or access gating (FR-IDENT-6, CON-13).
INIT → ADVERTISING → CONNECTED → AGENT_MODE (idle) ⇄ RUN_MODE (running) → (done|error) → back to AGENT_MODE. STOP/disconnect/fault paths return toward ADVERTISING/AGENT_MODE. RUN_STATE events are emitted on every transition (FR-MODE-3, FR-RUN-7).
power-on
|
v
+--------+ init ok +-------------+
| INIT |----------------->| ADVERTISING |<--------------------+
+--------+ +-------------+ |
| control-plane fault | central connects |
| (fail-safe, FR-BOOT-6) v |
+---------------------> +-----------+ disconnect |
| CONNECTED |---------------------+ |
+-----------+ | |
| HELLO (first exchange) | |
v | |
+------------------+ | |
+---->| AGENT_MODE/idle |----------------+ |
| +------------------+ disconnect |
| | ^ |
RUN | | | RUN_STATE(done|error) |
(RUN_STATE| v | or STOP -> idle |
running) | +------------------+ |
+---| RUN_MODE/running | |
+------------------+ |
| SOFT_REBOOT: clear VM state, |
| keep link if possible ------------+
v
(KeyboardInterrupt on STOP -> teardown -> idle)
On cold boot the board is unowned: it advertises and waits (SEC-6). It does not auto-run main.py unless the opt-in auto-run capability is enabled (FR-BOOT-2/3, NFR-SAFE-3); a broken or infinite-loop main.py cannot prevent advertising/connection (FR-BOOT-4). A control-plane fault fails safe back to ADVERTISING (FR-BOOT-6).
SOFT_REBOOT clears interpreter state (re-init the VM heap/imports) and re-enters AGENT_MODE, keeping the BLE link where the platform allows (FR-RUN-8). It is distinct from a hardware reset and does not re-advertise unless the link drops.
This section maps the protocol.md-owned wire format to in-firmware structures. It references, never redefines, the wire bytes.
The reassembled protocol.md §3.1 message (VER/TYPE/OPCODE/ID/LEN/PAYLOAD/CRC32) decodes into the Frame structure (§4.2). Header fields are read with struct/slicing; LEN is little-endian uint16; CRC32 is IEEE CRC-32 over VER…PAYLOAD, little-endian, validated before dispatch (FR-PROTO-2/3).
A dict maps each protocol.md §4 opcode to its handler:
0x01 HELLO -> pyble_info.hello_reply
0x02 DEVICE_INFO -> pyble_info.device_info
0x10 FILE_LIST -> pyble_fs.list
0x11 FILE_STAT -> pyble_fs.stat
0x12 FILE_GET_BEGIN -> pyble_fs.get_begin (then get_stream emits 0x13/0x14)
0x15 FILE_PUT_BEGIN -> pyble_fs.put_begin
0x16 FILE_PUT_DATA -> pyble_fs.put_data (emits 0x41 FILE_PUT_ACK)
0x17 FILE_PUT_END -> pyble_fs.put_end
0x18 FILE_DELETE / 0x19 MKDIR / 0x1A FILE_RENAME -> pyble_fs.*
0x20 RUN / 0x21 STOP / 0x22 SOFT_REBOOT -> pyble_runner.*
0x31 CONSOLE_INPUT -> pyble_console.feed_input
0x50 SET_LABEL / 0x51 SET_IDENTIFY_LED / 0x52 IDENTIFY -> pyble_info (device config, §4.8)
Outbound EVTs (0x13/0x14 FILE_GET_*, 0x30 CONSOLE_DATA, 0x40 RUN_STATE, 0x41 FILE_PUT_ACK) carry ID = 0 (FR-PROTO-4). Every CMD handler returns a protocol.md §8 status (FR-PROTO-1/5/6).
- Reassembly buffer: sized to
max_message= the largest legal §3.1 message the agent accepts, derived frommax_file_size/chunk_sizeincaps. Single static allocation (D3). - Per-fragment payload:
MTU − 3(ATT) − 1 (frag header), tracked from the negotiated MTU (FR-BLE-8). At MTU 247 this is 243 bytes. - File I/O buffer: one MTU-sized chunk (NFR-PERF-2, chunk = one MTU).
- TX staging: bounded console + event queue (§5.4).
Implements protocol.md §5:
IDLE
--FILE_PUT_BEGIN{path,size,crc}--> RESUMING
(stat tmp prefix; reply resume_offset >=0) (FR-FS-7)
--> RECEIVING
on FILE_PUT_DATA{offset,bytes}:
if offset == ack_offset: write to .tmp, update running CRC,
advance ack_offset; else hold/ignore gap
emit FILE_PUT_ACK{ack_offset} (FR-FS-5, NFR-PERF-2)
window: up to W unacked chunks (ref agent W=8, queue depth W+2) (FR-FS-4)
--FILE_PUT_END{crc}--> VERIFY
whole-file CRC == expected ?
yes -> atomic rename(.tmp -> target) -> RSP OK (FR-FS-6/9)
no -> ECRC, leave target untouched, keep/discard .tmp (FR-FS-14)
--> IDLE
Download (FILE_GET_*) is the symmetric streamer: FILE_GET_BEGIN{path,offset} → RSP{status,total_size,crc32}, then a streamer emits FILE_GET_DATA{offset,bytes} events and a final FILE_GET_END{crc32} (FR-FS-3). Resume on reconnect uses FILE_STAT to learn the verified partial size (NFR-REL-2).
Static, boot-time allocation of all large buffers (D3) makes resource
headroom repeatable enough to measure after HELLO and after transfer workloads.
The current v0.4.2 public-beta qualification work measures the owned exact
profiles esp32-4mb and esp32-s3-n16r8. Its supplemental production-browser
rows passed, while its formal resource and remaining HIL rows stay open. The
S3's PSRAM is useful Python headroom but
MUST NOT conceal internal-RAM pressure, so the gate records Python GC memory
and internal ESP-IDF heap separately. The design still targets the
ESP32-C3 floor for v1.0 (single-core RISC-V, ~400 KB SRAM —
hardware.md §1); C3 measurement
is deferred until matching hardware exists and remains the binding v1.0 gate.
Hot-path buffers (reassembly, file I/O, TX staging) are static and reused; per-message Python allocations are minimized to keep GC pauses out of the console/transfer latency path (NFR-PERF-3) and to avoid heap fragmentation under sustained transfer (NFR-REL-3).
NimBLE is the only stack built (CON-5, FR-BLE-9; Bluedroid excluded saves flash and RAM). The board overlay tunes NimBLE buffer counts/sizes to the minimum that sustains MTU 247 windowed transfer, validated on C3 (NFR-FP-TPUT).
The agent ships as frozen .py in the manifest, so module bytecode lives in flash, not heap. This is the primary flash/heap lever before native (D1).
The normative metric definitions, sample counts, payload generator, timer boundaries, reliability workload, and outward-rounding formulas are frozen in specs.md §5.3. This design deliberately separates static build facts from runtime HIL facts:
- the release builder obtains
application_image_bytesfrom the exactapplication.bincopied frommicropython.bin, parsesfactory_partition_bytes, derives headroom, and verifies the immutable candidate against the committed ceiling/floor; - a bounded PBLE RUN probe executes
gc.collect(),gc.mem_free(),gc.mem_alloc(), andesp32.idf_heap_info(2052)on the MicroPython VM thread; the host never calls MicroPython GC internals from the NimBLE task; - the existing HELLO/INFO
free_memvalue is recorded only asheap_default_free_bytes, becauseMALLOC_CAP_DEFAULTmay include S3 PSRAM; - the host HIL runner asserts EN/reset through the selected serial adapter, starts a service-UUID-filtered scanner before release, rejects stale advertising during the one-second quiet interval, and times release to the first fresh matching on-air advertisement with a monotonic clock; and
- the transfer runner requires HELLO
mtu=247,window=8, andchunk=229, uses no diagnostic overrides, validates GET offsets as contiguous and unique, and keeps PUT/GET timing separate from the multi-file integrity/reliability workload.
No production firmware metric capability is needed for this design. In particular, GC/internal-heap values remain a bounded qualification probe rather than volatile HELLO/INFO fields, and the external scanner measures the on-air event rather than a firmware-local “advertise requested” timestamp.
tests/firmware_tests/hil/oi1_profile_bench.py is the single profile-level
orchestrator. It has two modes:
- baseline emits canonical raw observations but cannot approve a release;
- verify loads the committed policy, executes the same frozen workload, evaluates every threshold, and emits the completed observation object.
Shared PBLE transfer, RUN-probe, metric, and canonical-JSON helpers live in
tests/firmware_tests/hil/_pble_bench.py. The existing
f11_reliability_bench.py and file_roundtrip_bench.py reuse those helpers
and retain their standalone CLIs. The central wrapper must distinguish an
observed MTU from a requested or fallback value: HELLO's device-side
negotiated mtu is authoritative, a backend-reported value must agree, and a
fallback must never certify MTU 247.
The bench accepts an explicit profile ID, expected PBLE chip value, BLE
address, and reset serial device. It refuses any profile outside the current
policy order, including esp32-c3-4mb. It records the central OS, BLE backend
and adapter, Python/bench identity, board/module description, candidate
identity and hashes, every integer sample, retransmit/rewind counts,
disconnects, integrity results, and a SHA-256 of the retained redacted raw
log. It writes atomically and never silently drops a successful sample.
The exact machine policy is
firmware/qualification/oi1-gates.json, schema 1. Its shape and nine threshold
keys are frozen in specs.md §5.3.3. Initially the measurement tooling and
negative tests land while release qualification remains pending. The
maintainer then:
- runs the engineering baseline on the two exact owned profiles;
- runs
assemble-oi1-baselineagainst the immutable staged inputs and the two bench fragments so the tool creates the canonical, redacted evidence underdocs/validation/firmware/oi1/, derives every threshold with the frozen formulas, and atomically updates the policy with its evidence SHA-256; - reviews and commits those mechanically assembled files; and
- builds the final tagged candidate and reruns verify-mode HIL on both exact profiles.
A policy has exactly two threshold-bearing profile entries and one deferred C3 profile ID. C3 continues through source build, two-root reproducibility, structural application-partition fit, and the six-role license audit; those checks do not manufacture a C3 resource threshold or public support claim.
Candidate generation embeds the parsed policy, its exact-byte SHA-256, the
matching baseline-evidence digest, and immutable build measurements in
PYBLE_HIL_RECORDS_V2. Its runtime observation is pending. Finalization may
fill observations, operator fields, and derived checks only; it must prove the
policy and build portions remain byte/semantically equal to the candidate.
The assemble-hil-report helper accepts only bounded per-profile mutable
evidence, copies candidate-frozen fields from the pending report, derives the
footprint/reliability pass from validated observations, and emits the completed
V2 report atomically before finalization.
The validator recomputes image/headroom arithmetic, sample counts, heap minima, latency maximum, goodput from recorded durations, threshold comparisons, and reliability totals. It rejects missing/extra fields, booleans as integers, wrong units/order/profile/hash, an unknown-MTU fallback, a C3 row or C3 numeric policy entry, or a value crossing its bound. Any firmware, manifest, policy, or candidate-identity change invalidates the evidence.
If the frozen-Python agent does not fit C3's flash/heap with usable user-code headroom, the design changes (move the §4.1/§4.2/§4.4 hot paths into a USER_C_MODULE), not the constraint (NFR-FP-C3, D1). The PBLE/1 wire contract is invariant across the move (NFR-MAINT-3). Trigger point is owned by OI-3.
The workspace is a MicroPython VFS (LittleFS) rooted at fs_root (IF-FS). The agent does not reformat or repartition at runtime; the partition table is part of the per-chip build artifact (BLD-5).
fs_root/
main.py # user entry (run on demand; auto-run opt-in only)
lib/*.py # user modules
data/* # user data
project.json # optional project metadata
This is Layer 4 — served, not part of the agent. .mpy/.pyc are neither required nor accepted (FR-FS-12, CON-3).
pyble_fs._resolve normalizes each requested path (collapse ./.., reject absolute escapes) and confirms the result is within fs_root; otherwise EACCES (FR-FS-10). A forbidden-path set covers Layer-2/Layer-3 locations so the control plane can never be overwritten via PBLE/1 (FR-FS-11, SEC-4, CON-10). User code at runtime is not jailed — it uses standard os/vfs freely (FR-FS-16).
Uploads write to a .tmp sibling and os.rename over the target only after whole-file CRC verification, so a target file is never corrupted mid-transfer or by a dropped link (FR-FS-9, NFR-REL-2/3). FS errors map to PBLE/1 status codes (FR-FS-15).
The device label and identify-LED config persist in a small NVS namespace (esp32.NVS) — not in the LittleFS workspace and not under fs_root. This keeps the per-device UX state stable across reboot (FR-IDENT-5) while staying outside the path jail (§9.3) and outside anything user code or PBLE/1 file opcodes can reach. It is read at boot to derive the advertised name and the identity caps, and written only by SET_LABEL/SET_IDENTIFY_LED. It is not a routing/pin profile or capability map (D7, CON-13, FR-IDENT-6).
Implements the initial ESP32 build described by
firmware.md §6,
versions.lock, and
firmware/upstream/README.md. A future
target port defines its own pinned, reproducible artifact and provisioning
pipeline while retaining the shared PBLE/1 conformance gates.
1. read versions.lock (single source of truth: MicroPython + ESP-IDF pins) BLD-1
2. SHA-drift gate: verify checked-out submodule SHA == versions.lock;
refuse to proceed on mismatch BLD-2
3. install ESP-IDF from the pin into a GITIGNORED dir (not an outer submodule) BLD-11
4. create one retained .sources/<target>/micropython checkout per target;
verify its locked commit, canonical origin, and clean tracked tree;
COPY board_overlays/<target>/ into only that target's checkout
ports/esp32/boards/ (canonical submodule stays pristine) CON-4, D5,
BLD-14
apply firmware/patches/micropython-<tag>/* if any (default: zero) CON-12, BLD-15
5. rebuild mpy-cross from the pinned MicroPython BLD-10
6. invoke the esp32 port build with USER_C_MODULES (native) + frozen
manifest (agent .py), mapping PyBLE target -> IDF target BLD-3/4
7. emit merged firmware.bin + application + bootloader + partition table
+ flasher_args.json BLD-5
8. validate the merged image, flasher args, component roles/placements, and
partitions against the frozen current release profiles; normalize only
component names BLD-17
9. generate the exact current-release-profile ESP Web Tools manifests plus
release.json/schema, SHA256SUMS, provenance, release/recovery/HIL documents
BLD-6/18/19/20
10. retain all three target checkouts; bind each application project description
to its exact checkout, then reconcile all six application/bootloader linked
inventories, exact frozen Python inputs, prebuilt blobs, and compiler
runtimes against the pinned SBOM toolchain and fail-closed license policy;
generate THIRD_PARTY_LICENSES.txt mechanically BLD-8/14
11. run no-leak, SPDX, manifest/integrity/license/reproducibility gates CON-6,
BLD-14/18
12. publish identical immutable bytes to the versioned same-origin path and
matching GitHub Release only after every included profile passes HIL;
alternatively, the exact digest-bound v0.4.2 exception may publish the two
profiles as a hardware-tested beta and GitHub pre-release after both pass
the scoped production-browser install/recovery run; C3 stays unavailable
BLD-7/21/22
firmware/scripts/build.sh <target> builds exactly one of esp32 | esp32-s3 | esp32-c3; build_all.sh builds all three (BLD-3). Target→IDF mapping (esp32→esp32, esp32-s3→esp32s3, esp32-c3→esp32c3) comes from versions.lock [targets] (BLD-4). Upstream upgrades go only through upgrade_micropython.sh (BLD-9).
Builds are reproducible from a clean checkout given the pins; a release requires two clean builds to yield byte-identical released parts after documented deterministic normalization (NFR-REL-4, BLD-14). Each of those two build roots uses this identical relative generated-source layout:
<build-root>/
.sources/
esp32/micropython/
esp32-s3/micropython/
esp32-c3/micropython/
Each directory is an independent checkout of the MicroPython repository URL
and full commit pinned in versions.lock. Before it is admitted, its origin
URL MUST equal that canonical locked URL, HEAD MUST equal the locked commit,
and its tracked tree MUST be clean. The target's application
project_description.json project_path MUST resolve to
.sources/<target>/micropython/ports/esp32 in that same build root; a
description that names the canonical submodule, another target's checkout, or
an escaped/symlinked location is fatal.
All target-scoped mutable preparation, including board-copy and ESP-IDF
submodule/managed-component materialization, runs inside that target's checkout
and target build directory. No target may share, replace, delete, or mutate
another target's checkout or ports/esp32/managed_components. The three
checkouts remain retained until linked-inventory/license audit, bundle
validation, and the two-root comparison have all completed.
Isolation does not establish a second proof root. Candidate-controlled inputs
in the canonical PyBLE checkout — including versions.lock, board overlays,
firmware/pyble, literal manifests, release tooling and policy/evidence, and
the pinned generator/compiler inputs under firmware/upstream/micropython —
remain independently hash-bound in the semantic audit receipt and are compared
with the selected build inputs. A target-local checkout cannot substitute
unreviewed bytes for those canonical proof inputs.
The release-packaging design,
exact public tree, manifest, separate integrity/provenance metadata, recovery,
HIL report, activation, and rollback are frozen in
browser-flashing.md. Identical immutable bytes publish
both at the versioned pyble.dev path and through the matching GitHub Release,
with exact release-profile parity: two hardware-tested beta profiles in v0.4.2
and all three qualified profiles at v1.0 (BLD-7/17…22). DEVICE_INFO/HELLO,
manifest.json/release.json, tag, and release notes make agent/protocol/
upstream/source/artifact versions recoverable (BLD-13); the agent follows
SemVer (BLD-12).
firmware/patches/micropython-<tag>/ holds any unavoidable patch with a written reason, applied only at build prep, re-reviewed for retirement each upgrade; the default is zero patches (CON-12, BLD-15).
FROZEN for v1.0 (G0 · 2026-07-01; browser-release paths amended 2026-07-29 ·
[docs]). This is the authoritative firmware source tree. It realizes the six-module agent (NFR-MAINT-2), the chip-agnostic Layer-3 / Layer-2-overlay split (NFR-MAINT-1, CON-4), and the clean-submodule build (CON-1/2/12, BLD-*). Adding a path is a[docs]amendment; the layout itself does not change per-story. Freezing the layout does not populate any module file — module bodies are authored per-story under TDD by their owning engineer.
firmware/
versions.lock # single source of truth for the pins (BLD-1) [build-smith]
release-tools.lock # hash-pinned SBOM/release Python closure (BLD-8) [build-smith]
licenses/
license-policy.json # reviewed source/archive -> SPDX/text/NOTICE policy [build-smith]
excluded-cves.yaml # hash-pinned offline SBOM input; empty by policy [build-smith]
texts/ # exact reviewed third-party license/NOTICE texts [build-smith]
upstream/
README.md # clean-submodule rationale (F-15) [build-smith]
micropython/ # Layer 1 — pinned submodule, pristine (CON-1/2) [build-smith / .gitmodules]
pyble/ # Layer 3 — frozen-Python agent package (NFR-MAINT-2)
__init__.py # empty placeholder package at S1 (do NOT populate) [build-smith scaffold]
pyble_agent.py # boot + dispatch surface + single-writer lock [runtime-engineer]
pyble_ble.py # NimBLE peripheral, GATT, fragmentation [ble-transport-engineer]
pyble_proto.py # frame codec, CRC32, dispatch [protocol-engineer]
pyble_runner.py # RUN/STOP/SOFT_REBOOT, RUN_STATE [runtime-engineer]
pyble_fs.py # filesystem bridge + workspace jail [storage-engineer]
pyble_console.py # stdout/stderr tee + stdin feed [runtime-engineer]
pyble_info.py # DEVICE_INFO/HELLO/caps + device-config store [identity-engineer]
user_c_modules/
pyble/ # Layer 3 native hot paths — LATER, OI-3 (pble_*.c) [ble-/protocol-engineer]
board_overlays/
esp32/ esp32-s3/ esp32-c3/ # Layer 2 — per-chip overlay, copied at build prep [build-smith]
scripts/
build.sh build_all.sh # per-target / all-three build (BLD-3/4) [build-smith]
release_bundle.py # deterministic manifest/integrity/license bundle [build-smith]
upgrade_micropython.sh # controlled pin bump (BLD-9) [build-smith]
build/releases/ # generated candidate/public bundles; gitignored [build output]
patches/
micropython-<tag>/ # unavoidable patches, default ZERO (CON-12, BLD-15) [build-smith]
tests/
firmware_tests/
host/test_release_bundle.py # manifest/merge/schema/hash/license fixtures [firmware-test-author]
test_release_bundle.sh # shell entrypoint for CI/release gates [firmware-test-author]
... # remaining host, conformance, and HIL tests
tools/
ci/ # repo-wide gate scripts (no-leak, SPDX, SHA-drift) [build-smith]
.github/
workflows/ # CI: no-leak · SPDX · SHA-drift · 3-chip build matrix [build-smith]
ADR-0018 adds one standard user-runtime package without adding an agent module
or upstream patch. All three lean target manifests explicitly call
require("neopixel"), freeze the same board-owned _boot.py, and avoid the
broad upstream networking bundle. A host resolver test follows manifest
includes and requires, proving each target selects exactly one upstream
micropython/drivers/led/neopixel/neopixel.py plus the firmware-embedded PyBLE
boot lifecycle, while structure tests reject a copied PyBLE driver,
GPIO/default table, upstream edit, or inert target image (FR-BOOT-1…6,
FR-LIB-1..3, BLD-16).
Every target leaves MICROPY_BOARD_STARTUP at the pinned ESP32 port default,
boardctrl_startup(). PyBLE MUST NOT replace that hook merely to initialize
NVS: the upstream default already initializes NVS, records the physical flash
size, and provides the fallback VFS-partition behavior. The frozen PyBLE
_boot.py starts the agent only after the VM and filesystem are ready.
The C3 overlay additionally includes the pinned upstream
boards/sdkconfig.riscv, resolves CONFIG_COMPILER_OPTIMIZATION_SIZE=y, keeps
CONFIG_ESP_SYSTEM_HW_STACK_GUARD disabled because the pinned ESP-IDF release
documents false RISC-V NLR panics for that guard, and enables the UART REPL for
debug parity. Its release configuration is DIO/80 MHz/4 MiB with the custom
partition table and an explicit ESP-image silicon window of
min_chip_rev_full=3 through max_chip_rev_full=199 (ESP32-C3 revision v0.3
or newer). Host configuration tests inspect the fully resolved SDK and
MicroPython board configuration; comments or a partial fragment do not satisfy
this contract.
Cross-build verification starts from fresh generated freeze state and inspects
the generated frozen content—not orphaned frozen_mpy/*.mpy intermediates—for
the NeoPixel module. It records artifact deltas, keeps the C3 footprint gate,
and runs from neopixel import NeoPixel before and after soft reboot on all
three targets. An optional visual HIL script accepts the GPIO explicitly,
drives one dim pixel for a bounded interval, and clears it in finally
(FR-LIB-4).
The exact audit algorithm and esp-idf-sbom pin are frozen in
browser-flashing §6.
Application and bootloader inventories for every profile are separate inputs;
their union is not inferred from an ESP-IDF component list alone. The audit
reconciles map-file archive members, project_description.json,
compile_commands.json, generated frozen content, prebuilt blobs, and
compiler/newlib runtime archives against license-policy.json. It runs
offline with isolated caches and rejects any unknown or stale input before a
candidate can be promoted.
Pinned nested manifests are resolved by their literal package/module
selections, and the result must equal generated frozen content. Archive member
names are a multiset: duplicate basenames are legal, while absent map members
or an unbound archive remain fatal. Build-generated component archives are
selected in policy only by stable component topology. Ordinary archive
membership is derived from the exact component object-output directory and
archive-member multiset, not from unique source paths. Every linked compile
output is consumed exactly once by an archive or by an exact map LOAD
direct-object path. A compile output absent from both is classified as
build-only/unlinked rather than forced into redistributed evidence. Its source
path stays in the hash-bound compile-command document and still passes exact
root validation, but its output/source bytes are not redistributed inputs. The
JSON output path and compiler -o path must resolve identically under their
distinct specified bases, as must the JSON source and compiler -c source. One
source may have both a main archive output and a direct ELF output. The
direct-object set must also equal the safely parsed .o/.obj arguments in the
role's hash-bound link.txt.
The pinned generated-header, repository PyBLE C-source, retained Berkeley DB,
and zero-byte IDF ELF-anchor shapes are the only described/compiled-source
exceptions. The generated main binding carries the linker-command hash,
metadata-input records, and direct output/source records in addition to its
archive-only sources. Exact
project-description/compile/map hashes, source hashes, member lists, and
generated archive digests are derived after the build and live in the receipt,
never in committed policy. Tests run equivalent clean builds below different
absolute roots, require one shared policy to accept both, require distinct
receipt bindings, and reject any policy attempt to predeclare a generated
value. The complete generated context is observed again after all SBOM runs;
mutating a source, metadata input, direct object, or link document during that
window is fatal. A nested selector covers the exact CMake
target/archive/object-directory topology below an empty CONFIG_ONLY owner.
Tests use the real Mbed TLS shape,
classify separately compiled Everest/p256m object-library outputs as unlinked,
and reject a path-only selector, synthetic component, object-directory escape,
unmatched linked compile output, or archive/member mismatch. Opaque,
frozen-source, prebuilt, and admitted versioned-toolchain inputs retain reviewed
predeclared digests.
External GCC distributions remain in the installed ESP-IDF tools download
cache; they are not copied into the repository. Schema-v2 policy pins the
logical URL-basename filename, size, digest, format, and top-level archive root,
separately from the installed version directory. Each toolchain also has one
exact, nonempty compiler_frontends catalog of unique
installed-root-relative path/SHA-256 records; the initial Xtensa and RISC-V
records contain exactly the prefixed gcc and g++ pair, not one
representative compiler. The observer derives the tools home and
dist/<filename> only from
the complete executable set parsed from compile commands, the versioned
ESP-IDF installation layout, and the hash-bound pinned tools/tools.json
record. Every compile-command entry is attributed to exactly one catalog
frontend, every frontend is observed, and all frontends derive the same
installed root, trusted tools home, metadata identity, and distribution.
Tests reject missing, extra, or duplicate frontends, a mixed/sibling frontend root, frontend digest drift, frontend or ancestor symlinks, installed bytes that differ from the cached distribution, policy/caller cache paths, metadata or filename disagreement, a sibling version, cache/install symlink escape, distribution digest/size drift, unsafe members, and archive-root drift. Private observations retain exact absolute executable paths for verification; validated results and receipts retain only the canonical relative frontend catalog and contain no host-absolute tools-home, cache, frontend, installed-root, or runtime path.
All six exact raw SBOM outputs and all six normalized reviewed documents stay
with build-review evidence. Raw package fields and relationship graphs are
validated as exact evidence, including literal NOASSERTION states; reviewed
immutable metadata is added only in the normalized layer with explicit policy
attribution. Supplemental packages cover redistributed frozen or linked inputs
that the raw ESP-IDF graph omits: NeoPixel plus the contributing
libmbedcrypto.a, libmbedtls.a, and libmbedx509.a archives in the initial
profiles. Tests preserve omitted PackageVersion as an absent versionInfo
property for the real-shaped LAN867x/TinyUSB records and reject an invented
empty or reviewed value. Supplemental choice tests retain Mbed TLS's original
(Apache-2.0 OR GPL-2.0-or-later) evidence while selecting only
Apache-2.0 for redistribution. Resolved runtime cases include
libgcc/libstdc++ with the GCC exception, newlib libc/libc_nano/libm_nano with
the complete reviewed newlib terms, and Xtensa libxt_hal.a with its pinned
MIT attribution. A reviewed input-expression allowlist permits those exact
runtime terms without rewriting the concrete broad raw toolchain expression;
identifier-only subsets and unreviewed alternate expressions fail. Exact
no-gap/no-duplicate coverage is release blocking: each observed item is
consumed by one resolution record, while that record carries an explicit
many-to-many package/input attribution.
The literal frozen-manifest observer derives canonical per-target evidence: the complete unique, lexically ordered traversed-manifest inventory of repository-logical path/SHA-256 records and the complete unique destination to selected-source repository-logical path/SHA-256, literal optimization, and metadata-version mapping. A non-null metadata version is a bounded ASCII version token, never an arbitrary string or path. Tests require the semantic receipt to bind those records, require the public verifier to repeat the traversal and recompute them, reject any host-absolute path, and reject a selected source or traversed manifest changed after the build.
The same per-target gate reconstructs frozen bytecode from clean temporary
state with the pinned mpy-cross and pinned manifest/MPY generators, compares
the exact retained .mpy set and bytes, then compares the complete regenerated
frozen_content.c; the final pinned mpy-tool.py pass uses the byte-proven
retained MPY paths so its original-source comments also match. A once-per-audit
clean temporary mpy-cross build from the pinned MicroPython source must be
byte-identical to the admitted executable. Generator,
compiler, and replayed C-compilation subprocesses receive only the
release-defined controlled environment. The gate replays the unique exact
frozen_content.c compile argument vector, replacing only -o, and requires
the rebuilt object bytes to equal the unique owning linked-archive member. The
semantic receipt binds the architecture, qstr digest, logical
generator/compiler path hashes, destination/MPY hashes, owning archive/member,
and object hash. Tests
mutate source bytes while restoring timestamps, manifest metadata/optimization
or order, the qstr header, retained .mpy, compiler/generator bytes,
architecture, copied board files, and frozen C; they also cover missing,
extra, duplicate, and symlinked retained MPY paths, an internally-consistent
replacement C/MPY/qstr set with an unchanged linked object, inherited loader
or Python environment knobs, and assert that no temporary or host-absolute
path reaches public evidence. Every (profile, role, frozen destination) is
consumed by exactly one resolved input; cloning a frozen tree/input under a
second identifier is fatal.
Shipped raw aggregate/source packages without an exclusive input use
allow-aggregate and one exact same-profile/role raw-relationship path to an
input-owning allow resolution. Tests cover the 24 NimBLE, ESP-IDF, FreeRTOS,
lwIP, project, and heap-TLSF occurrences, keep them in the notice, and reject
input reuse, invented/cyclic/cross-role paths, aggregate-to-aggregate targets,
or not-shipped masquerading. The 18 genuinely unshipped occurrences retain
not-shipped. Its committed zero-input proof contains only profile/role and
an empty match set; the validator derives the exact project-description,
compile-command, and map hashes into the receipt. Tests require the derived
proof—not only raw policy JSON—to participate in the receipt semantic hash.
Top-level shipment_review: {path, sha256} independently binds a strict
canonical JSON ledger with the exact JSON integer schema_version: 1 and the
complete sorted
occurrences array of exact {profile_id, role, spdx_id, disposition}
records. Tests require unique no-gap/no-extra raw occurrence coverage, the
three-value disposition vocabulary, strict integer schema types (never a
boolean or numerically-equal float), canonical lexical order, exact agreement
with resolutions, and receipt coverage of both the policy record and
normalized classifications. They reject a fully formed
aggregate-to-not-shipped substitution even when its mutable resolution and
zero-input proof were changed consistently. The ledger has no build hash and
is not duplicated inside resolution records.
The same strict integer rule applies at every other release schema boundary:
build provenance, the TOML tool lock, policy v2, audit receipt, embedded HIL
record, and release.json. Tests inject booleans and numerically-equal floats
at each parser boundary and require rejection before semantic use.
License texts are a hash-bound identifier catalog, not an untyped list. Every
SPDX identifier, exception, and approved LicenseRef appearing in any raw,
reviewed, resolved-input, or supplemental expression has one exact complete
text record. Tests distinguish the ESP-IDF component newlib terms from the
larger pinned-toolchain runtime newlib terms, byte-compare the GPLv3 and GCC
exception texts with both locked toolchain distributions, and preserve the
MicroPython BSD-1-Clause/Berkeley rescission, Mbed TLS option selection,
NimBLE/opaque-library notices, and managed-component source proof. Raw
nonselected controller and BLE Mesh records are covered once by explicit
zero-input not-shipped resolutions and do not leak into the public notice.
Verbatim supporting NOTICE/COPYRIGHT/license/SBOM/source/attribution artifacts
live in the separate required review_files catalog, where exact
repository-relative bytes are bound to a purpose and immutable commit, tree,
or managed-component identity. Tests reject duplicate or missing records,
path escape/symlinks, byte drift, and evidence with no source identity.
The public tree contains the deterministic complete
THIRD_PARTY_LICENSES.txt. The npm/web closure has a separate website notice
and never contaminates the firmware-embedded dependency inventory.
- Native names: native modules are
pble_*.c/pble_*.hunderfirmware/user_c_modules/pyble/(naming per CLAUDE.md; codec filespble_*). They are OI-3 and out of scope until HIL forces them (D1, §8.6). - Ownership in brackets is the routing target for each path; the firmware-architect freezes the layout, the named engineer authors the body, and firmware-test-author is the sole author of
[red]tests undertests/firmware_tests/. - Root stays clean (CLAUDE.md GOLDEN RULE): no new top-level entries; everything above lives under
firmware/,tests/,tools/,.github/.
Chip facts are owned by hardware.md §1; the agent (Layer 3) is identical across all three (D2, NFR-MAINT-1). Differences live in the board overlay (Layer 2).
- esp32 (baseline): two-core Xtensa, ~520 KB SRAM. The conservative reference; runner task and BLE/agent task can sit on separate cores. Tightest heap among the dual-core parts but not the binding constraint.
- esp32-s3: two-core Xtensa, ~512 KB SRAM, PSRAM common, native USB. Most
headroom; the USB-serial debug mirror (FR-CON-5, IF-USB) is most useful here.
The chip-agnostic Layer-3 design must not require PSRAM because C3 has none,
but the initial S3 browser image profile is deliberately narrower:
esp32-s3-n16r8requires 16 MiB flash plus 8 MiB Octal PSRAM. A different S3 memory topology requires another provisioning profile and HIL row; it is not silently accepted by family detection (BLD-17). - esp32-c3 (hardest — validate early): single-core RISC-V, ~400 KB SRAM,
least RAM. The binding footprint constraint (NFR-FP-C3). With one core,
the BLE/agent and runner tasks time-share; STOP responsiveness
(§5.2) and console backpressure
(§5.4) must be validated here first. If
frozen-Python does not fit, hot paths go native (D1,
§8.6). The known
esp32-c3-4mbprovisioning profile is defined only for C3 silicon revision v0.3 or newer but remains unavailable in the current v0.4.2 public beta pending exact-profile HIL. Its exact image revision window appears in release metadata only after a later candidate qualifies it.
Every CMD returns a protocol.md §8 1-byte status (FR-PROTO-6). Firmware error paths map as:
| Firmware condition | PBLE/1 status | Requirement |
|---|---|---|
| Success | OK (0x00) |
FR-PROTO-6 |
| Malformed/structurally invalid frame | EBADREQ (0x01) |
FR-PROTO-8 |
| Missing file/dir | ENOENT (0x02) |
FR-FS-2/15 |
| Path escapes jail / forbidden / control-plane write | EACCES (0x03) |
FR-FS-10/11, SEC-4 |
| Filesystem full | ENOSPC (0x04) |
FR-FS-15 |
| Underlying I/O failure | EIO (0x05) |
FR-FS-15 |
| Allocation failure | ENOMEM (0x06) |
FR-PROTO-6 |
| RUN while running | EBUSY (0x07) |
FR-RUN-4, SEC-3 |
| CRC mismatch (frame or whole-file) | ECRC (0x08) |
FR-PROTO-3, FR-FS-14 |
| Bad offset/length, or over-length device label | ERANGE (0x09) |
FR-FS-15, FR-IDENT-1, SEC-10 |
Unsupported opcode/feature; IDENTIFY with no identify LED configured |
EUNSUPPORTED (0x0A) |
FR-PROTO-9, FR-IDENT-4 |
| Unexpected internal fault | EINTERNAL (0xFF) |
FR-PROTO-6, FR-BOOT-6 |
A CRC-failed frame is dropped and answered with EVT ERROR(ECRC) referencing the opcode if known (FR-PROTO-3). A control-plane fault is caught and the board fails safe to advertising (FR-BOOT-6, NFR-REL-1).
- Pairing baseline (SEC-1/2): v1.0 relies on BLE link-layer pairing/encryption (protocol.md §10); no application-layer auth — a connected client is trusted. No partial app-layer auth ships in v1.0.
- Single active writer (SEC-3):
pyble_agent's lock serializes file/run mutations; concurrentRUN→EBUSY(§5.3). - Non-writable control plane (SEC-4): the path jail + forbidden-path set make Layer 2/3 unreachable by PBLE/1 file commands (FR-FS-11, CON-10).
- Advertisement contents (SEC-5): only the PyBLE Service UUID + short device name; no personal/user-identifying data by default. A user-set device label (
SET_LABEL) may replace the name and is broadcast — bounded and non-PII (SEC-10 below). The defaultPyBLE-XXXXsuffix is cosmetic, never an access key. - Broadcast label is bounded & non-PII (SEC-10): a user-set
SET_LABELvalue replaces the advertised name and is therefore broadcast pre-connect; the board bounds its length (over-length →ERANGE) and the label is never required — the defaultPyBLE-XXXXcarries no personal data. The app warns the user against putting PII in a broadcast label (app.md). - Identity is display-only, never authorization (SEC-11):
device_id,label, and the identify-LED config are for recognition/display and the board's own UX. The agent MUST NOT gate access or branch trust on MAC,device_id, orlabel(consistent with SEC-7, CON-7); cold-boot trust remains simply "the connected client" (SEC-6). - Unowned cold boot (SEC-6): advertise + wait; trust is simply the connected client; no stored owner/board identity, no MAC gating (SEC-7, CON-7).
- On-device only (SEC-8): no telemetry, no off-device transmission by default; consistent with offline-first (NFR-OFF-1/2).
- No physical-output safety guard (NFR-SAFE-4): the agent contains no hardware-output, calibration, or routing module (CON-8/9); physical safety is the user program's concern.
- Future app-layer token (SEC-9): deferred; if added, negotiated additively via HELLO caps with no breaking wire change.
This is where the Technical Design meets Test-Driven Development (§0). The five verification categories of PRD §1B.3 / specs.md §2.2 — unit, conformance, build, size, HIL — map onto the modules below. Each module's behaviour is pinned by a [red] test before code.
| Module | unit (host) | conformance | build | size | HIL |
|---|---|---|---|---|---|
pyble_proto |
frame codec, CRC32, ID correlation, error-status mapping | full opcode round-trip vs fake transport | SPDX/no-leak lint | — | — |
pyble_ble |
fragment/reassemble logic (pure), adv-name = label-else-PyBLE-XXXX |
fragmentation vs MTU matrix | NimBLE-only config | NimBLE buffer sizing | adv/scan-filter, MTU 247, INFO read, label shows in scan |
pyble_fs |
jail resolution, CRC accumulate, temp-rename | put/get window + resume + CRC vs fake | — | file-buffer footprint | multi-file upload, dropped-link resume |
pyble_runner |
state-machine transitions | RUN/STOP/RUN_STATE sequence | — | — | STOP vs while True: pass, traceback→stderr |
pyble_console |
tee + backpressure logic | CONSOLE_DATA stream tagging | — | staging-buffer footprint | live stdout/stdin latency |
pyble_info |
caps assembly (incl. device_id/label/has_identify/identify_led), label bound→ERANGE, identify EUNSUPPORTED-when-unset, version negotiation | HELLO/DEVICE_INFO identity fields, SET_LABEL/SET_IDENTIFY_LED/IDENTIFY round-trip | — | — | real chip/mpy/free_mem/has_sd, label↔NVS persist across reboot, non-blocking blink |
pyble_agent |
dispatch wiring, lock serialization | EBUSY, single-writer | frozen-manifest build | flash/heap gates | cold-boot safety, fail-safe |
PBLE/1 conformance tests run against an in-memory fake transport shared between the firmware agent and the Dart pble client (app.md), so both ends are tested against the same byte sequences — the contract is validated once, both sides honor it (FR-PROTO-1, NFR-MAINT-3, IF-PROTO). This is the cross-language guard that protocol changes do not silently diverge.
- build: SHA-drift gate (BLD-2), no-leak gate (CON-6), SPDX lint
(NFR-MAINT-4), per-target build sanity (BLD-3/4/5), exact manifest/profile
validation (BLD-6/17), provenance/integrity/license/reproducibility gates
(BLD-8/14/18/19), and public-byte parity (BLD-7/22). Reproducibility
fixtures verify the exact three-checkout layout in both build roots,
application-project-path binding, locked commit and canonical origin,
clean tracked trees, retained source availability during audit, and
target-isolated managed components; they reject cross-target checkout
reuse, overwrite, early deletion, path escape/symlinks, or canonical-proof
drift. License fixtures cover
the pinned literal asyncio/NeoPixel manifests, duplicate archive members,
real tag/value
NOASSERTIONplus concrete-license records, verified external toolchain inputs, generated component receipts, exact raw/reviewed evidence sets, supplemental source-tree packages, identifier-to-text catalog coverage, byte-distinct framework/toolchain newlib terms, exact toolchain-distribution license bytes from the trusted ESP-IDF download cache, exact metadata/cache/install binding with distinct archive and version roots, absence of host-absolute paths in receipts, and profile-specific zero-input not-shipped proof. Supplemental source-tree digests exclude Python bytecode cache artifacts while the audit rejects any such artifacts in the retained checkout; release builds force bytecode generation off so checkout-local absolute paths cannot contaminate otherwise identical source evidence. - size: enforce the total application-image ceiling and derived factory-partition headroom floor during build/candidate validation. Continue structural application-fit checks on all three source targets, including deferred C3. Heap, boot, goodput, and reliability are not mislabeled as static size gates.
- HIL: the release-blocking bench runs on every exact profile included in
the release. For the v0.4.2 formal candidate matrix that is exactly
esp32-4mbandesp32-s3-n16r8; it covers the frozen §8.5 resource workload, multi-file integrity (NFR-REL-5), STOP authority (NFR-SAFE-1), cold-boot safety (NFR-SAFE-3), candidate-browser install, and interrupted-flash recovery from an access-controlled, production-equivalent HTTPS deployment (BLD-20/21). C3 HIL and footprint/goodput gates remain open, block C3 enablement, and block v1.0. The later supplemental production-browser run completed only the browser install and interrupted-recovery rows for both profiles. The other formal rows remain open; the exact public-beta activation follows the bounded exception in browser-flashing §10 rather than claiming BLD-21 completion.
The first implementation commit after this freeze is [red] and covers:
tests/firmware_tests/host/test_oi1_profile_bench.py: fake BLE central and reset transport; exact workload/sample counts; stale-advertisement rejection; HELLO MTU/window/chunk enforcement; deterministic payload bytes; strict GET offset validation; timer boundaries; retransmit accounting; heap-marker parsing; canonical evidence; baseline versus verify mode; and explicit C3 refusal;tests/firmware_tests/host/test_footprint_gates.py: exact two-profile policy order, exact C3 deferral, all nine threshold keys/types, evidence hash, application bytes/headroom arithmetic, derivation algorithms, threshold boundary pass, one-unit crossing failure, and continued structural C3 build validation without C3 qualification numbers;tests/firmware_tests/host/test_release_bundle.pyandtest_release_finalization.py: the exact HIL V2 marker and keys, V1 rejection, candidate null observations, policy/build immutability, public observation recomputation, candidate-release binding, missing/extra/wrong- type/wrong-unit/wrong-count/wrong-profile/wrong-order/wrong-hash fixtures, manufactured-MTU rejection, and candidate-to-public mutation envelope;tests/firmware_tests/host/test_audited_candidate_validation.py: policy and baseline evidence remain source/audit bound while C3 retains build/license audit participation but cannot acquire a public row; and- shell entry points
test_oi1_profile_bench.shandtest_footprint_gates.sh, wired into the applicable host/release gate.
Tests use fake transports and fixtures; real hardware execution occurs only
after the [green] bench/tooling commit. Production firmware caps, app code,
and PBLE/1 bytes are intentionally outside this minimal change.
Design element → satisfied requirement IDs. Each FR-* block has at least one design home.
| Design element (section) | Module(s) | Requirement IDs |
|---|---|---|
| BLE peripheral, GATT, adv, MTU, fragmentation, advertised-name assembly (§4.1, §4.8) | pyble_ble | FR-BLE-1…12, IF-BLE, CON-5, SEC-5 |
| Frame codec, CRC32, dispatch, status map (§4.2, §7, §12) | pyble_proto | FR-PROTO-1…10, IF-PROTO |
| Runner, STOP, SOFT_REBOOT, RUN_STATE (§4.3, §5) | pyble_runner | FR-RUN-1…10, FR-MODE-2/3, NFR-SAFE-1/2 |
| FS bridge, jail, windowed upload, resume, atomicity (§4.4, §7.4, §9) | pyble_fs | FR-FS-1…16, IF-FS, NFR-PERF-2, NFR-REL-2/3, CON-3/10 |
| Console tee, stdin feed, backpressure, USB mirror (§4.5, §5.4) | pyble_console | FR-CON-1…5, NFR-PERF-3, IF-USB |
| Device info, HELLO/caps, version negotiation (§4.6) | pyble_info | FR-INFO-1…6, FR-FS-13, FR-BOOT-3, BLD-13 |
| Device config store (label + identify-LED), identity caps, non-blocking identify, dispatch wiring (§4.8, §5.5, §9.5) | pyble_info + pyble_ble + pyble_agent | FR-IDENT-1…6, FR-BLE-12, FR-INFO-1/4, SEC-10/11, CON-13, OI-6 |
| Boot, lifecycle FSM, dispatch surface, single-writer (§4.7, §6) | pyble_agent | FR-BOOT-1…6, FR-MODE-1/4, SEC-3/6, NFR-REL-1 |
| Two-task model, STOP delivery, serialization (§5) | runner+agent | FR-RUN-3, FR-BLE-11, NFR-SAFE-1/2, SEC-3 |
| Memory/footprint levers, native plan (§8) | all | NFR-FP-FLASH/HEAP/BOOT/TPUT/C3/GATE/CLOSE, NFR-PERF-1/4 |
| Chip-agnostic Layer 3 (§2.2, §11) | all | NFR-MAINT-1, CON-4 |
| Build pipeline, SHA gate, overlay copy-in, release manifest/integrity/provenance/recovery bundle (§10) | scripts | BLD-1…22, CON-1/2/11/12, NFR-REL-4, NFR-MAINT-4 |
| Native-without-contract-change (§2.1, §8.6) | proto/ble/fs | NFR-MAINT-3 |
| Security design (§13) | all | SEC-1…11, NFR-OFF-1/2, NFR-SAFE-4, CON-6/7/8/9/13 |
| Test design, fake transport, gates (§14) | all | NFR-FP-GATE, NFR-REL-5, NFR-MAINT-2/3, all verify traces |
| Offline-first (no network) (§13) | all | NFR-OFF-1/2, SEC-8 |
IF-MACHINE (hardware via standard machine) (§9.3, §11) |
(user code) | IF-MACHINE, CON-9 |
Requirements with no dedicated design element: none. SEC-9 is intentionally deferred (no v1.0 design surface beyond the additive-caps note in §13). The NFR-FP-* measurement and enforcement design is frozen in §8.5; only evidence-derived per-profile numbers remain OI-1. Likewise the concrete label max-length, SET_IDENTIFY_LED encoding, and IDENTIFY blink-duration bound are owned by protocol.md and tracked by OI-6, not invented here.
- R1 — Footprint on ESP32-C3 (binding). C3 real-hardware resource numbers
remain deferred, so C3 cannot appear in the current pre-v1 bundle and v1.0
cannot close. Mitigation once matching hardware is available: run the same
frozen method, and use native
USER_C_MODULEhot paths if needed (§8.6). The two-profile pre-v1 qualification does not waive or predict the C3 result (OI-1, NFR-FP-CLOSE). - R2 — Frozen→native trigger point. Which paths move to C, and on which chip the budget forces it, is undecided until HIL measurement (OI-3). The module boundaries (§4) are drawn to make the move contract-neutral (NFR-MAINT-3).
- R3 — iOS/Android BLE MTU quirks. Central platforms negotiate MTU differently and may not grant 247; the firmware must operate correctly across the negotiated MTU down to the default (FR-BLE-8). Fragmentation/reassembly is tested across an MTU matrix (§14.1).
- R4 — Single-core C3 STOP latency. With one core the runner and BLE/agent task time-share; STOP must still land promptly against a tight loop (§5.2). Validate on C3 HIL first (§11).
- R5 — v0.4.2 candidate pins are selected but not fully HIL-approved. The
exact
versions.lockvalues (MicroPython v1.28.0 / ESP-IDF v5.5.1) are candidate-frozen for v0.4.2 (OI-2). Candidate-freezing makes the input immutable; it does not approve the remaining formal matrix, C3 compatibility, or a qualified release. A pin change creates a new candidate and reruns all build, audit, deployment, and exact-profile HIL gates throughupgrade_micropython.sh(BLD-9/19/21). - R6 — PBLE/1 still DRAFT. Opcode/UUID/status numbers are provisional until protocol.md §2/§4 freeze (OI-4); the single constants mirror (D6) localizes the churn.
- R7 — Auto-run caps flag naming. The opt-in
main.pyauto-run flag name/encoding is owned by protocol.md §7 and must be fixed before F-12 (OI-5). - R8 — Identity/identify wire constants not frozen. The label max-length, the
SET_IDENTIFY_LEDGPIO+active-level encoding, and theIDENTIFYblink-duration bound are owned by protocol.md and must be frozen before FR-IDENT/FR-BLE-12 implementation (OI-6); the single constants mirror (D6) localizes the churn.