Run on the Raspberry Pi Pico 2 W (hub) and Pico W (node): capability gates, RTC-less clock, BLE advertisement transport - #11
Run on the Raspberry Pi Pico 2 W (hub) and Pico W (node): capability gates, RTC-less clock, BLE advertisement transport#11tyeth wants to merge 6 commits into
Conversation
Hardware results and corrections to this PR's premisesEverything below was measured on a Pico 2 W ( 1.
|
| this PR's doc | measured |
|---|---|
| 303,336 B free (520 KB − static) | too high — ignores supervisor + GC bookkeeping |
| — | gc.mem_free() at a bare REPL: ~70,700 B (the initial heap; the split heap grows on demand, rising to 103,808 B mid-allocation) |
| — | 212,992 B cumulative bytearray(8192) to MemoryError — the real capacity |
| — | largest single contiguous allocation: between 50,000 and 100,000 B |
port_heap_init() skips its TLSF pool when picolibc's CONFIG_COMMON_LIBC_MALLOC_ARENA_SIZE=-1 arena covers the same region, routing allocations through Zephyr malloc. Capacity is therefore chunked: object count is fine, single large buffers are not. Budget against ~208 KB with nothing over ~50 KB — that rules out whole-page HTML buffers and large TLS records, not hub_main's object graph.
Also: frozen modules landed (tyeth/circuitpython#23) — adafruit_ble + 2 submodules cost 10,384 B frozen vs 21,792 B from .mpy, for +28,336 B flash and zero static RAM. That reopens this PR's "no BLE UART portal on the Pico W, ever" conclusion, though Pico W firmware RAM has since risen to 88.41 % because TCP costs ~9.8 KB, so the budget moved both ways.
What held up
rtc = false:time.time()→RuntimeError('RTC is not supported on this board'). Thecaps.now()epoch-offset approach is right.start_ap()returning without error whileap_activestayedFalse— exactly as described (cause was deeper, see 3).busio.I2C(scl, sda)→NotImplementedError('Use device tree to define I2C devices');board.I2C0/I2C1/SPI/UARTexist as callables.espnowabsent,alarm/watchdogfalse.- BLE advertisement reception works: 925 reports from 20 distinct devices in one scan, names resolving (
TUYA_,Sinilink-APP,LE-Ninja), so scan RX and active-scan TX are both fine.
Also relevant to testing this PR
Release builds used to lose USB entirely under BLE load — UDC_RPI_PICO_STACK_SIZE was raised to 2048 in debug.conf only, so release images kept Zephyr's 512-byte default (tyeth/circuitpython#18, fixed in #19). And BLE bring-up after a warm SWD reset with patchram re-download has been seen to fail and halt on BT_ASSERT; that is being worked separately and would make a bench run of this PR unrunnable until fixed.
A full bench-test task brief will be attached separately.
🤖 Generated with Claude Code
…ll.py Pure renames, no content change: collector/code.py -> collector/hub_main.py, node/code.py -> node/node_full.py. The next commit puts capability gates in their place. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CircuitPython loads a module's entire bytecode before executing its first
statement, so a 47 KB code.py that "checks for espnow first" has already
spent the RAM by the time the check runs -- on a Raspberry Pi Pico W
(~40 KB of heap) it dies in the import, not at the check.
Move the two bodies aside unchanged -- collector/code.py -> hub_main.py,
node/code.py -> node_full.py -- and make code.py a 2 KB dispatcher that
decides from capabilities, not board ids:
collector/code.py refuses to start below 128 KB free or without
wifi/socketpool, prints why, and idles with the REPL
reachable instead of half-starting into a MemoryError
node/code.py node_full where espnow + alarm exist,
node_lite (three commits on) otherwise
The 128 KB figure is what hub_main + the adafruit_ble chain + the HTTP
portal + stores measure to on a 32-bit build (~85 + 30 + 20 KB of code
before any data); ESP32 boards have 150-230 KB free at boot, a Pico 2 W
~300 KB, a Pico W ~42 KB.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Raspberry Pi Pico W / Pico 2 W on CircuitPython's Zephyr port (sys.platform == "Zephyr", tyeth/circuitpython ci/pico2w-ble-assets @ 94bfd47b5f) are missing things this code assumed, and two of the gaps fail silently rather than raising: * rtc is absent, and with it the time source behind time.time() and zero-argument time.localtime(): both raise "RTC is not supported on this board" (the weak rtc_get_time_source_time() is what the ELF links). time.localtime(secs) and time.mktime() still work, so caps keeps an epoch offset against time.monotonic_ns() and the collector asks caps.now()/caps.localtime()/caps.set_epoch() instead of the time module (34 call sites across hub_main, net_wifi, datastore, alerts, certstore). On a board with an RTC caps just uses it. * wifi.radio.start_ap() is an empty stub: it returns without starting an AP and ap_active stays False. hub_main and net_captive now check the radio after the call and raise if nothing came up, and skip the attempt entirely where caps.AP_SUPPORTED is False, with a message that says to use CIRCUITPY_WIFI_SSID instead. The "AP" that "worked" was a captive portal on an address nobody could reach. * busio.SPI(clk, mosi, miso) / busio.I2C(scl, sda) raise NotImplementedError; only devicetree buses exist, and board.SPI is the CYW43439 radio's own PIO bus. caps.spi() returns None there, so the eInk and the SD card are skipped before their modules are imported; caps.i2c() returns board.I2C0 (SDA GP4, SCL GP5). * espnow does not exist: net_espnow is imported only when it does, and a _NoHub stand-in keeps the counters/API the status page and main loop expect. * CONFIG_NET_MAX_CONTEXTS=6: six sockets in total including the listener and any UDP socket. net_wifi caps client connections at caps.MAX_SOCKETS - 2 (config.json "max_sockets" overrides for firmware with a raised ceiling). Every probe is an import, a call that raises, or the platform string -- an ESP32 build missing a module gets the same treatment as the Pico. caps.py is identical in collector/ and node/. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nodes on boards without ESP-NOW need another way to reach the hub, and
the Pico W cannot afford the existing one: the adafruit_ble import chain
behind the BLE UART portal measures ~51 KB on the 64-bit unix build,
~30 KB on a 32-bit target, against ~42 KB of heap in total. So the
reading goes out as a legacy BLE *advertisement* instead:
envadv.py 18-byte reading (seq, tc, rh, co2, pm25, voc, nox,
battery, sensor type) in one Manufacturer Specific
Data structure, 25 bytes on air with the flags;
identical in collector/ and node/
node/net_bleadv.py raw _bleio, non-connectable, no scan response
collector/net_blescan.py
1 s passive scan every 5 s with the AD prefix as
the filter; de-duplicates by (address, seq); feeds
take_node_packet() with mac=None so the existing
store/alert/ring path runs and nothing is sent back
Why broadcast: the CYW43439 controller is built with CONFIG_BT_MAX_CONN=1,
so a hub that connected to its nodes could not also serve a phone. A
broadcast costs no connection at either end and no adafruit_ble on the
node. The price is stated in both files: no delivery confirmation and no
cfg push over this path. Nodes appear on the hub as ble-XXXX (last two
address bytes); config.json "zones" maps them to names. Scanning while
the hub advertises its own UART service is the untested combination; the
receiver reports a failing start_scan once and keeps trying.
node/node_lite.py is the node for these boards: read -> advertise for
ble_adv_s -> optional WiFi POST (only above wifi_min_free, so off on a
Pico W) -> time.sleep() the rest of interval_s. No deep sleep exists
here, so it is an awake loop and says so: tens of mA continuously
against tens of uA for the ESP32 nodes. node/code.py loads it when
espnow or alarm is missing and node_full (the unchanged ESP32 node)
otherwise; below 24 KB free it refuses and explains.
Budget (tools/board_budget.py, .mpy x1.2): node_lite + caps + envadv +
node_sensors + net_bleadv = 12.4 KB of .mpy -> ~15 KB of heap, leaving
~27 KB for a driver (SCD4x ~9 KB, SEN6x ~15 KB) and the working set.
Deploy .mpy: compiling from source is the peak that does not fit.
Executed on the unix CircuitPython build with stubbed radios: node_lite
runs cycles with the sim sensor and a flat heap; hub_main brings up with
the scanner on and enters its main loop.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…vadv.py board_budget.py walks each entry's import graph with CPython's ast and classifies every import of a CircuitPython built-in the board lacks as HARD (unconditional at module level: ImportError at load) or soft (try/except, under an if, or inside a function). The default table is the one both Pico W zephyr-cp boards generate at 94bfd47b5f; --board-toml takes any board's autogenerated circuitpython.toml. With mpy-cross it also sums the reachable .mpy sizes and compares x1.2 against the two boards' free RAM (42,292 / 303,336 B). Against the tree before this series it reports exactly the three imports that would have stopped node/code.py on a Pico (alarm, espnow, rtc) and the rtc/espnow ones in the collector. test_envadv.py round-trips the advertisement format, checks the prefix filter the way _bleio matches it (from the AD type byte), exercises the RTC-less clock, and verifies the shared copies are identical. It passes under CPython and under the unix CircuitPython build (26 checks), as does the existing test_envproto.py (21) -- the latter is the first time envproto ran under MicroPython semantics off-board. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…h plan pico_w_zephyr.md: the firmware's module table and the two silent gaps (start_ap stub, no time source), the RAM budget from the .elf, measured import costs and why adafruit_ble cannot fit a Pico W, what changed and why, the power cost of an awake loop stated plainly, configuration for each board, and a step-by-step bench plan with the REPL lines to expect and the failure signatures that would falsify the design. Nothing in it was run on hardware; it says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
b469873 to
8fd8bee
Compare
Summary
Makes the two trees run on the Raspberry Pi Pico 2 W (hub) and Pico W (node) under CircuitPython's Zephyr port — firmware
tyeth/circuitpythonci/pico2w-ble-assets@94bfd47b5f, binaries on prereleasezephyr-cp-ble-ci-20260909. Umbrella: tyeth/circuitpython#15; why the reviewable firmware PRs cannot link in CI: tyeth/circuitpython#16.Nothing in this PR was run on hardware. It was verified by compiling every source with the matching
mpy-cross, walking both import graphs against the boards' generated module table, measuring import costs on the unix CircuitPython build, and executing the pure-logic modules and both entry points there with stubbed radios. The bench plan inpico_w_zephyr.mdis what turns "should" into "does", with the REPL lines to expect and the failure signatures that would falsify the design.What runs where
.elf)node_litehub_main), or a nodePOST /api/ingestover WiFiThe repo's name promises deep sleep and ESP-NOW; on these boards it delivers neither, and the code now says so at boot instead of dying at
import.What the firmware turned out to lack — beyond
espnow/alarmVerified in the port sources and the linked ELF, not assumed:
rtc = falsemeanstime.time()and zero-argtime.localtime()raiseRuntimeError: RTC is not supported on this board(the weakrtc_get_time_source_time()is linked;nmshowsW). The old code calledtime.time()in 34 places.caps.pykeeps an epoch offset againsttime.monotonic_ns();time.localtime(secs)/time.mktime()are pure conversions and keep working.wifi.radio.start_ap()is an empty stub — it returns without starting anything andap_activestaysFalse(common-hal/wifi/Radio.c, body commented out). The old code would have believed it was serving a captive portal on 192.168.4.1. Both call sites now check the radio and raise if nothing came up; wherecaps.AP_SUPPORTEDis False they skip the attempt and say to useCIRCUITPY_WIFI_SSID.busio:busio.SPI(...)/busio.I2C(...)raiseNotImplementedError; only devicetree buses exist andboard.SPIis the CYW43439 radio's own PIO bus. So no eInk, no SD card, decided before their modules import. Sensor I2C isboard.I2C0(SDA GP4, SCL GP5).CONFIG_NET_MAX_CONTEXTS=6: six sockets total including the listener;net_wificaps clients at 4 here (caps.MAX_SOCKETS - 2)._bleio: legacy advertising only,CONFIG_BT_MAX_CONN=1— which is why the node→hub BLE path is a broadcast, not a connection.Every import in both trees against the module table (
tools/board_budget.py):Changes
collector/code.py,node/code.py, 2 KB each). CircuitPython loads a module's whole bytecode before running its first line, so the check has to live in a file small enough to load anywhere. The bodies moved verbatim:collector/code.py → hub_main.py,node/code.py → node_full.py(pure renames in the first commit). The collector refuses below 128 KB free or withoutwifi/socketpool, prints why, and idles with the REPL reachable. The node loadsnode_fullwhereespnow+alarmexist andnode_liteotherwise. Capability and free memory, not board ids: an ESP32 build missing a module gets the same treatment.caps.py(identical in both trees): the probes and the RTC-less clock. Everytime.time()/time.localtime()/rtc.RTC().datetime =in the collector goes through it; unchanged behaviour on boards with an RTC.envadv.py(18-byte reading in one Manufacturer Specific Data structure, 25 bytes on air),node/net_bleadv.py(raw_bleio, non-connectable),collector/net_blescan.py(1 s passive scan every 5 s with the AD prefix as filter, de-dup by address+seq, feeds the existingtake_node_packet()withmac=None). No confirmation and no cfg push over this path — stated in both files. Nodes appear asble-XXXX;zonesmaps them.node/node_lite.py: read → advertiseble_adv_s→ optional WiFi POST (gated onwifi_min_free, so off on a Pico W) →time.sleep()the rest ofinterval_s. An awake loop, because that is all the port offers; the power cost is written down (tens of mA vs tens of µA).net_espnowis imported only whenespnowexists (a_NoHubkeeps the counters/API); no-SPI boards skip display and SD before importing them;net_wifisyncs NTP throughcaps.set_epoch(time.mktime(ntp.datetime)).tools/board_budget.py(import graph + heap floor per board),tools/test_envadv.py;pico_w_zephyr.md; README pointers.bugs_issues_and_todos.mddeliberately untouched.RAM: why the Pico W gets
node_liteand not the existing node or the BLE UART portalMeasured on the 64-bit unix build (
gc.mem_free()delta on import; ~2.0× a module's.mpysize there, so ~1.2× on 32-bit — the factorboard_budget.pyuses):.mpyadafruit_blechain thatnet_bleneedsnode/code.pyalonenet_blenode_lite+caps+envadv+node_sensors+net_bleadvadafruit_scd4x+bus_deviceadafruit_sen6xadafruit_requests+connection_managerhub_maintree (17 modules)So: an SCD4x
node_liteshould fit a Pico W with ~15 KB to spare; a SEN6x node is marginal; anything withadafruit_bleor the WiFi POST path on top does not fit, andnode/code.pysays so below 24 KB free. The 42 KB is the ceiling — the supervisor's own allocations come out of the same pool, so step 0 of the bench plan is readinggc.mem_free()on a flashed Pico W. Deploy.mpy(PR #10'stools/build_bundle.sh): compiling a 10 KB source on a 40 KB heap is the peak that fails.Firmware
One change, on its own branch, built locally, not run:
zephyr-cp-pico2w-net-contexts(one commit onci/pico2w-ble-assets) raises the Pico 2 W toCONFIG_NET_MAX_CONTEXTS=12/CONFIG_NET_MAX_CONN=16— FLASH unchanged 1,146,096 B, RAM 229,144 → 231,112 B (+1,968 B). Pair it with"max_sockets": 12inconfig.json. The prerelease UF2s do not include it. The existing WiFi+BLE stack sizing (SYSTEM_WORKQUEUE_STACK_SIZE=4096,BT_TX_PROCESSOR_STACK_SIZE=4096,HW_STACK_PROTECTION=y) was left alone: nothing off-hardware can show it insufficient, and guessing larger numbers would hide the fault that would prove it.west updatewas never run; the module clones are on their feature branches, 0 dirty.Verification (no hardware)
mpy-cross(10.3.0-50-g94bfd47b5f, same tree as the firmware): all 32 device sources compile; PR Build node/ and collector/ with mpy-cross + circup; record results #10'stools/build_bundle.shrun against this branch stages every library and compiles the source-only SEN5x driver, same result as Build node/ and collector/ with mpy-cross + circup; record results #10 recorded.tools/test_envproto.py: 21/21 under CPython and under the unix CircuitPython build (first timeenvprotohas run under MicroPython semantics off-board).tools/test_envadv.py: 26/26 on both._bleio/wifi/board(thewifistub'sstart_ap()is a no-op exactly like zephyr-cp's):node/code.py → node_literuns cycles with the sim sensor and a flat heap;collector/code.py → hub_mainbrings up —_NoHub, the AP check firing, scanner on — and enters its main loop.native_simwas not usable here (no i386 toolchain on the host, no container runtime, and the port's container init runswest update); the unix build stood in for the pure-logic paths. It proves logic, not radios.Untested, explicitly
Scanning while the hub advertises its own UART service (the receiver reports a failing
start_scanonce and keeps trying); WiFi station + HTTP + BLE on the shared gSPI bus at the same time; whether a Pico W's supervisor leaves the ~25 KBnode_liteneeds; the CYW43 controller's behaviour on a long advertising duty cycle. Each has a REPL signature in the bench plan.Also corrects the record on #10 (comment there): both board builds are green with UF2 artifacts and
zephyr-tests / zephyris green on a branch off currentmain.🤖 Generated with Claude Code