Skip to content

zephyr-cp: fall back to wifi MAC for cpu.uid on siwx91x (fixes #7) - #38

Closed
mikeysklar wants to merge 71 commits into
mainfrom
siwx917/fix-cpu-uid-mac-fallback
Closed

zephyr-cp: fall back to wifi MAC for cpu.uid on siwx91x (fixes #7)#38
mikeysklar wants to merge 71 commits into
mainfrom
siwx917/fix-cpu-uid-mac-fallback

Conversation

@mikeysklar

Copy link
Copy Markdown
Owner

Fixes #7.

microcontroller.cpu.uid (and the web workflow's version.json UID field) returned all zeros on the SiWx917: Zephyr has no hwinfo driver for this SoC, so hwinfo_get_device_id() returns -ENOSYS.

Writing that hwinfo driver against the efuse controller was the obvious fix and it is a dead end: the efuse identity region is unprogrammed on this silicon. Verified on two independent dies (ours and Hermes's DK2605A) via read-only commander mfg917 read efuse - each array holds only 4 nonzero calibration bytes at 0x3A..0x3D, and the region where efusecopy mirrors the MACs (0x020..0x02F) is all zero. Commander's own "Unique ID" for this part is the WiFi MAC zero-extended to 8 bytes, sourced from the flash-resident config space.

This PR matches that definition: when hwinfo yields nothing, build the UID from the wifi net_if link address as 00 00 | mac[6], guarded on CONFIG_SOC_FAMILY_SILABS_SIWX91X so no other board changes behavior. The link address is set at driver init, before the radio is enabled.

Hardware-verified on the DK2605A: version.json now reports UID 0000C09B9EC39910, byte-identical to commander device info.

Credit: efuse-is-empty diagnosis by Phil Torrone / Hermes; verified on both benches.

🤖 Generated with Claude Code

mikeysklar and others added 30 commits July 31, 2026 13:51
zephyr_dts_to_cp_board() dereferenced props["label"] unconditionally for
gpio-keys, but label is optional and deprecated there; modern boards
identify keys with zephyr,code instead. Any such board failed board
generation with KeyError: 'label'.

Guard it the same way the gpio-leds handler a few lines above already does.
port_heap_init() passed whichever memory region came first to
tlsf_create_with_pool(). TLSF's control structure lives in that first
pool, so a board whose first region is small ends up with a heap that
cannot hold it, and the first real allocation aborts:

    Init heap at 0x24061c00 - 0x24062000 with size 1024
    Init heap at 0 - 0x400 with size 1024
    Init heap at 0xa000000 - 0xa800000 with size 8388608
    abort()
    >>> ZEPHYR FATAL ERROR 4: Kernel panic on CPU 0

The existing 'size < 1024' guard does not catch it: a region of exactly
1024 bytes passes and still cannot host the control structure.

Visit the largest region first, then the rest in their original order.
On a SiWx917 dev kit this is the difference between a 7 KB heap and the
full 8 MB of PSRAM.
_event_handler() already had a NET_EVENT_WIFI_SCAN_RESULT case, but the
event was never added to the net_mgmt subscription mask, so it never
ran and every scan returned zero networks.

NET_EVENT_WIFI_RAW_SCAN_RESULT, which was subscribed, is not a
substitute: it carries raw beacon frames and only fires when
CONFIG_WIFI_MGMT_RAW_SCAN_RESULTS is enabled, which is not the default.
Wi-Fi 6 + Bluetooth LE 5.4 dev kit built on the SiWG917M111MGTBA:
Cortex-M4F at 180 MHz plus a separate network processor, 8 MB flash and
8 MB memory-mapped PSRAM.

The SoC has no USB device controller, so boards/siwx917_dk2605a.overlay
replaces the port's app.overlay, which unconditionally references
zephyr_udc0. The console falls back to the Zephyr console on ulpuart,
reaching the host over the on-board J-Link VCOM, and file access is via
web workflow. This follows the existing nrf54l15dk precedent.

CIRCUITPY is carved out of code_partition rather than the upper flash.
In common-flash mode the M4 cannot write flash itself; the driver asks
the NWP to do it, and the NWP refuses writes in the OTA-swap region,
which fails as -EIO on the first block. code_partition is shrunk from
2008K to 1M (the image uses ~700K) and 984K given to circuitpy.

The board conf also sets CONFIG_GPIO and a non-zero kernel heap, neither
of which the upstream Zephyr board defconfig enables; without them the
build fails to link.
common_hal_wifi_radio_connect() was a stub: the body was commented-out
ESP-IDF code and it returned WIFI_RADIO_ERROR_NONE without attempting
anything, so connect() silently 'succeeded' while never associating.
get_connected() returned a hardcoded false and the IPv4 getters returned
None. No zephyr-cp board could join a network.

Implement connect() with NET_REQUEST_WIFI_CONNECT:
  - build wifi_connect_req_params from ssid/password/channel/bssid
  - wait on a semaphore signalled from CONNECT_RESULT (or DISCONNECT_RESULT,
    which is how a failed attempt reports), honouring the timeout argument
    and staying interruptible
  - map wifi_conn_status to the CircuitPython error codes so a wrong
    password raises AUTH_FAIL instead of appearing to succeed
  - start DHCPv4 and wait for an address

Also implement get_connected(), get_ipv4_address() and get_ipv4_gateway()
from the Zephyr net_if state.

get_mac_address() returned an uninitialized stack buffer; read the real
address from net_if_get_link_addr() instead.

Security is currently fixed at WIFI_SECURITY_TYPE_PSK. Transition-mode
APs negotiate up from there, but a WPA3-only network needs SAE, which
cannot be inferred without consulting a prior scan.

Verified on a SiWx917-DK2605A: associates, obtains a DHCP lease, and
reports the correct MAC derived from the SoC unique ID.
…ertising

common_hal_bleio_adapter_start_advertising() called set_tx_power()
unconditionally before bt_le_adv_start(). That sends
BT_HCI_OP_VS_WRITE_TX_POWER_LEVEL (0xfc0e), a Zephyr vendor-specific
command rather than a Bluetooth spec one. Controllers that do not
implement Zephyr's VS extensions reject it, and the resulting error
aborted start_advertising() before advertising was ever started, so
those controllers could not advertise at all.

On a SiWx917, which has its own vendor command at 0xfc06 for RF power:

    <wrn> bt_hci_core: opcode 0xfc0e status 0x01   (Unknown HCI Command)
    OSError: [Errno 5] Input/output error

Split the command into a non-raising _try_set_tx_power(). The public
common_hal_bleio_adapter_set_tx_power() still raises, so an explicit
adapter.tx_power assignment fails loudly, but advertising logs the
rejection and continues.

Verified on a SiWx917-DK2605A: the board now advertises and is
discoverable by name from an external BLE scanner.
The guard was 'size < 1024', which let a region of exactly 1024 bytes
through. On the siwx91x that admitted two regions that must never hold
Python objects: memory@0, which the SoC devicetree documents as
'reserved for the NWP (Network Processor)', and memory-dma@24061c00,
a DMA buffer.

Handing objects out of memory written by hardware outside the CPU
produces corruption that surfaces much later and far away - an int that
has become a function, a bytearray with a wrong length, or a jump
through a jumbled pointer.

Raise the bound to 8 KB. Regions smaller than that are not usefully part
of the heap anyway.
The port did not build ulab at all - build_circuitpython.py never
referenced it, so CIRCUITPY_ULAB was always 0 regardless of the usual
CIRCUITPY_FULL_BUILD default.

Add it as a per-board opt-in via CIRCUITPY_ULAB in circuitpython.toml.
Sources and flags mirror py/py.mk (MODULE_ULAB_ENABLED,
ULAB_HAS_USER_MODULE=0, -iquote on extmod/ulab/code). It is off by
default because it costs roughly 95 KB of flash.

Verified on a SiWx917-DK2605A: numpy imports and runs.
Enable ulab for this board (CIRCUITPY_ULAB), which fits comfortably in
the 1 MB code partition at ~76% used.

Disable the 8 MB PSRAM node. It works as memory - sparse writes across a
64 KB buffer verify correctly - but using it as the CircuitPython heap
corrupts objects under sustained allocation, and a loop with no buffer
at all is enough to hang the board. Not a timing problem: identical at
fast-freq 144 MHz and 33 MHz. Disabling the node keeps it out of the
generated ram_bounds[] so the heap falls back to the 319 KB internal
SRAM, which is ample and about 20% faster for array work.

Worth re-enabling once the root cause is found; 8 MB of Python heap
would be exceptional.
Reconnecting on a live association was rejected by the driver with
-EALREADY ("Device already in active state"), and the failure path
took the interface back down, so a redundant reconnect destroyed a
working link:

    NET_EVENT_WIFI_CONNECT_RESULT status 0     <- connected
    <err> siwx91x_wifi: Device already in active state
    NET_REQUEST_WIFI_CONNECT failed: -120
    net_if_down                                <- link dropped

This is not hypothetical: CircuitPython resets the VM between the
supervisor and code.py and re-runs Wi-Fi init each time, so boot-time
auto-connect from settings.toml hit it on every boot.

Disconnect first when already associated, and treat -EALREADY from the
connect request itself as success. Matches what the commented-out ESP
implementation did.
start_web_workflow() created the listening socket, bound it and called
listen() without checking any of them, with an explicit "(Not checking for
failures)" comment on the bind. On a port whose network stack can refuse to
create a socket, the descriptor stays unset and bind() and listen() then run
on -1 and fail with EBADF, but the function still returns true. The caller
takes that as success and installs the background callback, so the supervisor
polls a socket that was never opened and the board can hang before running
code.py. Nothing is ever served, and there is no diagnostic.

Observed on a Zephyr port board, where socket() returns ENOTCONN until the
interface has an address:

    socket() ok=0 num=-1 errno=107   ENOTCONN
    bind port 80 -> 9 errno=9        EBADF
    listen -> 0 errno=9 closed=1
    (returns true)

Return false instead so the caller retries on its next invocation, and close
the socket if bind or listen is the step that failed.

This is not specific to that port. adafruit#10054 reports web workflow unreachable
after a watchdog reset on several ESP32 boards, and the attempted fix in
 adafruit#10948 moved port 80 from closed to filtered, i.e. bound but never serving,
which is the same end state this produces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…network

connect() tore down the existing association and rebuilt it on every call,
because the radio recorded only whether it was connected and never which
network it was connected to, so the already-on-this-network case could not be
detected.

supervisor_start_web_workflow() calls connect() on every invocation, and it
runs once at startup and again on every VM reset. The result is continuous
CONNECT/DISCONNECT churn: the board re-associates forever, never holds a DHCP
lease, and the status bar sits at "Wi-Fi: No IP".

Track the SSID the current association belongs to and return
WIFI_RADIO_ERROR_NONE when connect() names it. Switching networks still
disconnects first, which is what the -EALREADY path below needs.

The comment in web_workflow.c ("it will return early if we're already
connected to the network") states this as a requirement on the port. espressif
and raspberrypi both implement it; this port did not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The board enabled NET_SOCKETS, NET_IPV4 and NET_DHCPV4 but never CONFIG_NET_TCP,
and the siwx91x driver defaults to the native network stack. DHCP is UDP, so
association and the DHCP lease both worked and the board looked healthy while
every SOCK_STREAM socket failed to open. The web workflow listener never came
up and socketpool raised "Out of sockets" from Python.

CONFIG_ZVFS_OPEN_MAX also defaulted to 0, so there were no file descriptors for
Zephyr to hand out to sockets.

With both set, the web workflow serves: PUT returns 201, the bytes land
sha256-exact, and /fs/ returns 401 without credentials.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Wi-Fi path prints on nearly every net event, and those printks shared the
UART with the CircuitPython REPL. A single scan emits dozens of
NET_EVENT_WIFI_SCAN_RESULT lines, which interleave with the raw REPL protocol
and corrupt the handshake:

    Raw REPL did not acknowledge (got b'\x1b]')

so any harness driving the board over serial is flaky.

Route Zephyr's console to RTT over the existing SWD connection. Nothing else
has to change: supervisor/serial.c builds its console from
DEVICE_DT_GET(DT_CHOSEN(zephyr_console)) and drives it with busio directly,
never going through Zephyr's console subsystem, so the REPL keeps ulpuart and
the J-Link VCOM while printk, the boot banner and driver logs move to RTT.

RTT needs no extra pin and works on the debug link already in use. Note the
control block cannot be auto-detected on this SoC: J-Link scans the usual
Cortex-M RAM at 0x20000000 and this part has RAM at 0x400, so the address has
to be passed explicitly. Read it from the ELF and hand it to J-Link:

    arm-none-eabi-nm zephyr.elf | grep _SEGGER_RTT
    JLinkExe ... -autoconnect 1 -RTTTelnetPort 19021   # then: RTTStart <addr>
    JLinkRTTClientExe

Verified: the full Zephyr console including the boot banner appears on RTT, the
serial side is quiet, and a multi-line raw-REPL exec that previously failed
repeatedly now succeeds first try while a scan is running.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The SiWG917 has a 16 KB data cache dedicated to PSRAM (family reference
manual rev 1.2 section 5.4.5) on the QSPI2 path, at 0x44040000. The
bootloader leaves it enabled and half-configured, and nothing in a Zephyr
build can maintain it:

  - WiseConnect's own sli_si91x_psram_device_init() clears the HPROT
    allocate signal immediately after enabling the cache. That step never
    runs here: SL_SI91X_D_CACHE_ENABLE is never defined, and the macros it
    needs (DCACHE_CTRL_AND_STATUS, HPORT_ALLOCATE_SIGNAL) do not exist
    anywhere in the vendored HAL, so the path would not even compile.
  - Zephyr cannot maintain it either. cache_siwx91x.c asserts the SoC has
    no data cache, CPU_HAS_DCACHE is never selected, and sys_cache_data_*
    return -ENOTSUP.

The result is an unmaintained write-allocate cache in front of memory the
NWP also writes. Python objects corrupt under allocation churn: ints that
become function objects, bytearrays reporting wrong lengths, garbled
qstrs, unknown-bytecode fallthrough at py/vm.c, and MPU faults through
pointers read out of PSRAM.

Disable it in port_heap_init(), before any TLSF pool exists.

Two details that cost time and are worth keeping:

  - Only bit 0 of DCACHE_REG_CTRL clears. Bit 1 is sticky, and
    DCACHE_REG_MAINT_STATUS settles at 0x100 rather than 0. The vendor's
    own disable path spins on `(MAINT_STATUS & 0x3) != 0` and would hang
    on this silicon. Clear, barrier, and read back instead.
  - Setting ATTR_MPU_RAM_NOCACHE on the psram node does nothing. That is
    an MPU attribute governing the Cortex-M architectural cache, and
    CPU_HAS_DCACHE is never selected, so there is nothing for it to
    govern. This cache is a separate peripheral an MPU attribute cannot
    reach. A null result there is a null test, not a null theory.

Verified on BRD2605A: with PSRAM as the Python heap, gc.mem_free() reports
8,265,200 bytes, 1000 x bytearray(64) completes, and a 200,000 iteration
bigint accumulation returns the correct sum. All three failed on every
prior build. Independently reproduced on a second board.

This is a workaround, not a fix. The fix is a real cache driver for the
peripheral, or clearing HPROT allocate the way the vendor intended.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Root cause of the corruption is found and fixed in
siwx917/fix-psram-dcache-corruption: an unmaintained 16 KB data cache
dedicated to PSRAM, left enabled and half-configured by the bootloader.

With that in place the heap moves from the 319 KB internal sram0 to the
8 MB PSRAM. gc.mem_free() goes from 72,144 to 8,265,200 bytes.

Depends on siwx917/fix-psram-dcache-corruption. Enabling this node without
it reintroduces the corruption.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four printks were added by the commits on this branch and do not belong in
a merged port:

  - "wifi already connected" fires on the ordinary early-return path, which
    the supervisor hits on every invocation of supervisor_start_web_workflow().
    That is the noisiest of the four and the least informative.
  - "NET_REQUEST_WIFI_CONNECT failed" and "connect timed out" are redundant:
    both are immediately followed by a distinct wifi_radio_error_t return that
    shared-bindings turns into a Python exception, so the caller already learns
    what happened.
  - "NET_REQUEST_WIFI_DISCONNECT failed" sat in a branch whose failure is
    tolerated on purpose. Dropping the check as well as the print avoids an
    unused variable; a genuinely unusable interface still surfaces through the
    connect call below, which does return an error.

Beyond noise, these interleave with the REPL on boards where the Zephyr
console shares a UART with it, corrupting the raw REPL protocol that test
tooling depends on:

    Raw REPL did not acknowledge (got b'\x1b]')

Note the 42 remaining printks in this port's wifi path (40 in
common-hal/wifi/__init__.c, 2 in ScannedNetworks.c, 7 in Radio.c) are
pre-existing upstream code, untouched here. Removing those is a separate
question and does not belong in a feature branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
mikeysklar and others added 22 commits August 3, 2026 17:35
The board .dts leaves spi0 disabled and carries a bare
`/* TODO: Add ICMCM-40627 with SSI */` comment; nothing wires the bus, so
board.SPI and busio.SPI did not exist on this port.

Pin assignment is not guessed. Upstream Zephyr already ships a verified
mapping for this exact board in
zephyr/tests/drivers/spi/spi_loopback/boards/siwx917_dk2605a.overlay
(Silicon Laboratories Inc., 2025), routing GSPI to the breakout pads:

  CLK  -> GSPI_CLK_HP25    (breakout PAD 3)
  MOSI -> GSPI_MOSI_HP27   (breakout PAD 5)
  MISO -> GSPI_MISO_HP26   (breakout PAD 7)
  CS   -> gpiob 12, active low

plus the gpdma channels and the Kconfig from that test's .conf.

cptools/compat2driver.py already maps "silabs_gspi" -> "spi", so enabling
the node is sufficient for zephyr2cp.py to emit the bindings.

Hardware-verified on two SiWx917-DK2605A boards (both benches):

  HAVE_SPI True          HAVE_SPI0 True
  CONSTRUCT ok           CONFIGURE ok freq 1000000
  WRITE ok               XFER ok rx ['0x0','0x0','0x0','0x0']

rx reads all-zero because MISO is a floating unconnected breakout pad;
the claim is that the transfer completes, not that data loops back.

Per UG581 3.4.3 Fig 3.6 the on-board ICM-40627 is on the ULP SPI via ULP
GPIO pins, distinct from both GSPI and SSI; this bus serves the breakout
pads only. See issue #4 for the IMU path.

[adapted from Hermes's c0f695a8 to this tree's conf/overlay tails, and
the overlay comment extended with the UG581 ULP SPI finding]

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018RW6sWt4Kb6WpafgHggmTt
The native_sim web workflow tests cover hostnetwork only; nothing
exercised a real radio, which is how the listener-error bug fixed in
siwx917/fix-web-workflow-listener-errors survived upstream since 2023.
Add an env-gated suite (CP_HW_BOARD_IP / CP_HW_WEB_PASSWORD /
CP_HW_SERIAL_PORT) that runs the issue #10 baseline against a live
board: version.json identity, /fs auth (401/200), PUT/GET/DELETE with
byte-exact sha256, a sequential-latency probe, and a BLE-concurrency
stress that hammers GATT reads while running HTTP - the gap left open
when #13 closed. Skips cleanly when no board is configured.

First run against real hardware caught two defects, now filed and
pinned as xfail so they flip visible when fixed:
- #34: version.json board_name/hostname/ip empty on hardware
- #35: connections refused for ~10s after 3-4 rapid requests

Also raise CONFIG_NET_MAX_CONTEXTS/NET_MAX_CONN (Hermes's values),
which measurably changed the #35 failure mode (every-5th-connect
refused -> refusal window) without eliminating it.

Fixes #10.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018RW6sWt4Kb6WpafgHggmTt
The "~10s" figure was inferred from pytest failure behavior rather than
measured. Direct measurement (burst until refused, poll until
recovered, 3 trials) gives ~1.14s recovery after exactly 6 requests,
reproducible to two decimal places across trials and builds.

Also refutes the NET_SOCKET_MAX_SEND_WAIT hypothesis: its 10000ms
default coincidentally matched the wrong number, and changing it 5x
moved the window by under a millisecond. No config change kept.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018RW6sWt4Kb6WpafgHggmTt
Two failures, both ours, both caught by CI on every open PR.

check-translate: the GATT server work added four new MP_ERROR_TEXT
strings, which ports/zephyr-cp/AGENTS.md explicitly prohibits ("Do not
add new translatable error strings... use raise_zephyr_error() or
CHECK_ZEPHYR_RESULT()"). Converted to raise_zephyr_error() with the
errno that matches each condition: -ENOSPC for the two fixed-table
exhaustion checks, -EALREADY for re-registering a service, -EINVAL for
a negative max_length. Regenerating locale/circuitpython.pot would have
silenced the check while leaving the rule broken.

Deliberately NOT converted: "Value length != required fixed length" and
"Value length > max_length". Those were never part of the failure -
they are already in the .pot because nordic and espressif raise the
same messages for the same conditions - and an errno would lose
information the user needs while diverging from the other ports.

ruff C901: build_circuitpython() reached complexity 41 (limit 40) via
the ulab opt-in commit df644fe. Extracted configure_ulab_module();
ruff passes clean on main, so this was our regression. Suppressing with
noqa or raising the threshold would have hidden a real one-over.

Verified: check-translate ok, ruff all checks passed, firmware builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018RW6sWt4Kb6WpafgHggmTt
Fixes the "tests / zephyr" CI failure, which was ours and which local
hardware testing could never have caught:

  Service.c:104: undefined reference to `bt_gatt_service_unregister'
  Service.c:138: undefined reference to `bt_gatt_service_register'
  (build-native_nrf5340bsim)

The GATT server added in 4f783df lives in the shared
common-hal/_bleio/, so it compiles for every zephyr-cp board, but only
siwx917_dk2605a got CONFIG_BT_GATT_DYNAMIC_DB. Both functions are
inside `#if defined(CONFIG_BT_GATT_DYNAMIC_DB)` in
zephyr/subsys/bluetooth/host/gatt.c (lines 1527-1728), so the other
eight BT-enabled boards failed to link. Added to all of them.

Not verified locally: nrf5340bsim needs babblesim, which is not in this
checkout ("No board named nrf5340bsim_nrf5340_cpuapp found"), so CI is
the check. What is verified is that the symbol is the correct one -
the Kconfig guard around both functions is quoted above - and that
siwx917 still builds.

Also applies ruff format to test_web_workflow_hw.py; my line wrapping
was tighter than ruff's and `ruff format` is a separate pre-commit hook
from `ruff check`, which is why the first fix pass missed it. Full
`pre-commit run --all-files` now passes all nine hooks locally.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018RW6sWt4Kb6WpafgHggmTt
…tring

The extraction dropped "Flags mirror py/py.mk", which is the one line
telling a future reader where to look when these flags need updating.
Restored into the docstring.

Verified the extraction is behavior-preserving on the path that matters:
siwx917_dk2605a is the only board with CIRCUITPY_ULAB = true, and its
build produces 32 ulab objects and 36 ulab symbols in the ELF, so the
enabled branch still compiles and links ulab.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018RW6sWt4Kb6WpafgHggmTt
…ed there)

My sweep in b5eb970 tested `"CONFIG_BT=y" not in s` as a substring,
which matched the COMMENTED line `# CONFIG_BT=y` in this board's conf.
BT is off for stm32wba65i_dk1, so the symbol has unmet dependencies
(BT_GATT_DYNAMIC_DB depends on BT_GATT_SERVICE_CHANGED) and is dead
config on a board that cannot use it.

Correct scope: 9 boards enable BT, siwx917 already had the symbol, so
8 needed it. Re-verified with an anchored match, and confirmed no board
now carries the symbol without BT enabled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018RW6sWt4Kb6WpafgHggmTt
The commit adding CONFIG_BT_GATT_DYNAMIC_DB to the BT boards noted the
fix was unverified locally because "No board named
nrf5340bsim_nrf5340_cpuapp found". That was my error, not a missing
board: the build target is the alias in boards/board_aliases.cmake, not
the .conf filename.

  cp_board_alias(nordic_nrf5340dk nrf5340dk/nrf5340/cpuapp)

Building the alias works, and nrf5340dk is one of the eight boards that
needed the symbol:

  make BOARD=nordic_nrf5340dk   -> exit 0
  [438/438] Linking C executable zephyr/zephyr.elf
  undefined reference to bt_gatt_service*  -> 0 occurrences

So the fix is confirmed by a real link on a non-siwx917 board, not
inferred from the Kconfig guard alone. Credit to Hermes for spotting
that the conf filename and the west build argument are different
strings.

Still not built here: the bsim variant, which dies inside babblesim on
macOS with "ls: --hide=common: No such file or directory" (GNU vs BSD
ls) before reaching the linker. nrf5340dk covers the same link path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018RW6sWt4Kb6WpafgHggmTt
NET_MAX_CONN is the constraining pool for incoming connections. At 8,
the workflow refused every connection after a burst of ~6 zero-spacing
requests and recovered ~1.14s later. Raising it to 12 eliminates it.

Measured A/B/A on hardware, burst-until-refused, 3 trials per arm:

  NET_MAX_CONN=8   refused after 6, recovery 1.11-1.14s
  NET_MAX_CONN=12  200 consecutive requests, zero refusals (x2)
  NET_MAX_CONN=8   refused after 6 again - symptom returns

The revert arm is the part that makes this causal rather than
coincidental. It is a race, not a hard ceiling: at 8 slots a zero-spacing
burst consumes connections faster than teardown recycles them, which is
also why spacing requests >=100ms avoided it.

Ruled out by measurement, each varied in isolation with no effect on the
boundary: NET_SOCKET_MAX_SEND_WAIT (10000->2000), ZVFS_OPEN_MAX (8->16),
NET_MAX_CONTEXTS (10->14). That last null result is what rules out the
TCP connection slab, which NET_MAX_CONTEXTS sizes - and it refutes the
mechanism both benches had converged on.

Two test-harness bugs surfaced while confirming the fix, both mine:

- The shared requests.Session (added earlier to work around this very
  pool exhaustion) became the liability once the pool was fixed: the
  board closes idle keep-alives, so pooled reuse died with ECONNRESET
  (errno 54) - a different error than the ECONNREFUSED (errno 61) this
  issue is about. Dropped the session; a fresh connection per request is
  what the fix makes affordable and what 200 clean requests validated.
- test_http_latency_sane ran right after the PUT/DELETE test, whose
  filesystem writes trigger auto-reload, so it was timing a board restart
  rather than the listener. Added wait_until_stable().

The three xfail pins for this issue are removed; the suite now reports
6 passed, 1 xfailed on hardware, the remaining pin being #34.

Fixes #35.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018RW6sWt4Kb6WpafgHggmTt
Regenerated by building that board to verify the BT_GATT_DYNAMIC_DB fix.
The staleness pre-dates this work: the added entries (audiofilewriter,
audioi2sin, usb_audio) are upstream modules that appeared after the file
was last generated, not a consequence of any change here. The build
warns when this file is out of date and asks for it to be committed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018RW6sWt4Kb6WpafgHggmTt
…slab

The fix commit established NET_MAX_CONN empirically via A/B/A but could
not say why raising the TCP connection slab (NET_MAX_CONTEXTS) did
nothing. Hermes found the missing path in Zephyr's source: a TCP
connection consumes two separate resources.

  tcp.c          tcp_conns_slab      sized by NET_MAX_CONTEXTS
  connection.c:60 conns[]            sized by NET_MAX_CONN
  tcp.c:2566     every TCP connection also registers a packet handler
                 into conns[]

conns[] is the binding constraint, so the slab null result was real and
NET_MAX_CONN was decisive. Their original 8-2=6 arithmetic was correct;
the "correction" that withdrew it was the error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018RW6sWt4Kb6WpafgHggmTt


wifi_radio_get_ipv4_address() - the raw uint32_t getter that
supervisor/shared/web_workflow/web_workflow.c uses internally for the
status bar and /cp/version.json's "ip" field - was a leftover ESP-IDF
stub, unconditionally returning 0. This is a distinct function from
common_hal_wifi_radio_get_ipv4_address() (the Python-facing mp_obj_t
getter, already fixed in feat-wifi-per-ap-security): same underlying
address, two separate C entry points, only one had been fixed.

Verified on hardware: GET /cp/version.json now returns
"ip": "192.168.0.133", matching the board's real DHCP lease.

board_name and hostname remain empty. Root cause is unrelated to the ip
bug: both come from the mDNS responder (CIRCUITPY_MDNS), and this port
has no common-hal/mdns implementation at all - confirmed via
compile_commands.json (no CIRCUITPY_MDNS define reaches
web_workflow.c) and the built ELF (zero mdns symbols). Only espressif
and raspberrypi implement it. That is a new common-hal component, not a
bug fix, so it is split off as #37 rather than folded into this commit
or left silently unfixed under #34's title.

test_version_json_runtime_fields_populated is split into
test_version_json_ip_populated (real assertion now, xfail removed) and
test_version_json_mdns_fields_populated (xfail against #37).

Fixes #34.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018RW6sWt4Kb6WpafgHggmTt
Found by Hermes: "two entry points for one value, only one fixed" is
rarely singular, so they swept their tree for the same shape and it
turned up common_hal_wifi_radio_get_addresses() - a real STA-mode
Python property, distinct from the get_addresses_ap() stub next to it,
which was AP mode and genuinely unimplemented.

common_hal_wifi_radio_get_addresses() returned mp_const_none
unconditionally. Wrong type for the shared-bindings contract
("addresses: Sequence[str] ... Empty sequence when not connected") in
both states: None instead of a tuple when connected, None instead of
() when not.

Reuses wifi_radio_get_ipv4_address() (fixed for #34) and formats it as
a string, matching the Sequence[str] contract other ports follow -
espressif and raspberrypi both return string tuples here, not
IPv4Address objects, which is why get_addresses formats rather than
calling common_hal_ipaddress_new_ipv4address like the ipv4_address
property does.

get_addresses_ap() also returned mp_const_none; corrected to
mp_const_empty_tuple for the same type contract without claiming AP
mode works.

Verified on hardware: wifi.radio.addresses == ('192.168.0.133',),
addresses_ap == (). New test_wifi_radio_addresses covers it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018RW6sWt4Kb6WpafgHggmTt
Two unrelated fixes found while building the mDNS common-hal (#37):

wifi_radio_get_mac_address(self, uint8_t*) was declared in
shared-bindings/wifi/Radio.h but never implemented for this port -- only
the Python-facing common_hal_wifi_radio_get_mac_address() existed. The
mDNS server needs the raw bytes for its default "cpy-XXXXXX" hostname,
same as every other port, so add the raw helper and have the existing
function call it instead of duplicating the netif read.

common_hal_wifi_radio_get_ipv4_gateway/_subnet read net_if_ip.ipv4
unconditionally, but that struct member only exists when CONFIG_NET_IPV4
is set (zephyr/include/zephyr/net/net_if.h). This file is compiled for
every Wi-Fi board in the port's CI matrix, not just siwx917, and
nordic_nrf7002dk doesn't turn IPv4 on -- breaking its cross-board build
(PR #24). Guard both behind #if defined(CONFIG_NET_IPV4).
check_suite["workflowRun"] is null for check suites that aren't attached
to a workflow run (e.g. one that was deleted, or belongs to an app
integration), and the "scheduler" job's Get-last-commit-with-checks step
dereferenced it unconditionally:

  TypeError: 'NoneType' object is not subscriptable

That's what was failing CI on PRs #20 and #23 on this fork. Skip check
suites with no workflow run instead of crashing on them.
/cp/version.json's board_name/hostname fields come from mdns.Server, and
this port never had a common-hal/mdns implementation, so
CIRCUITPY_MDNS was never defined and web_workflow.c's mDNS block
compiled out entirely (split off from #34).

Built on Zephyr's own mDNS responder (CONFIG_MDNS_RESPONDER) and DNS-SD
(CONFIG_DNS_SD, CONFIG_MDNS_RESPONDER_DNS_SD):

- hostname get/set delegate straight to net_hostname_get()/_set(), the
  same underlying store wifi.radio.hostname already uses on this port,
  so both stay in sync. Server construct sets the "cpy-XXXXXX" default
  from the MAC, matching every other port's contract.
- advertise_service() builds a struct dns_sd_rec per service (up to
  MDNS_MAX_SERVICES) and hands them to
  mdns_responder_set_ext_records() -- TXT records length-prefixed per
  RFC 6763 into a per-service buffer.
- find() is an honest empty result, not a fake search: Zephyr's mDNS
  stack (subsys/net/lib/dns) is responder-only, it has no client that
  sends PTR/SRV queries and parses replies. dns_resolve_name(...,
  DNS_QUERY_TYPE_PTR, ...) resolves one already-known name; it isn't a
  browse. Documented in Server.c and boards/siwx917_dk2605a.conf.

Module enablement follows the same pattern as _bleio's CONFIG_BT scan
in cptools/zephyr2cp.py: board_info["mdns"] is set from whether
CONFIG_MDNS_RESPONDER=y is actually in the board's .config, so boards
that don't opt in via Kconfig don't get CIRCUITPY_MDNS=1 and won't hit
this common-hal at all.

Hardware-verified on siwx917_dk2605a: wifi.radio.hostname reads back
"cpy-c39910", /cp/version.json's board_name and hostname are both
populated, hardware suite is 9 passed (up from 8, the mdns-fields test
is no longer xfail). Also added test_mdns_hostname_resolves_over_network
for real on-air resolution, marked xfail: this network's AP appears to
block multicast between wireless clients (a control browse for
_services._dns-sd._udp turned up nothing from any other device either),
so it couldn't be confirmed end-to-end here even though the responder
side is verified correctly configured.
Second-eyes review from Hermes on #16 (verified independently against
this repo's real lib/tlsf/tlsf.c, probe pack attached in Slack):

tlsf_create_with_pool() cannot return NULL for any region that passes
MIN_HEAP_REGION_SIZE on this board. control_construct() sizes the
control block from max_bytes (circuitpy_max_ram_size, a compile-time
8 MiB constant), not from the region being added -- so the control
block is the same fixed size no matter which region hosts it, and both
the 8 KiB guard and TLSF's actual minimum clear it by a wide margin.
The `heap == NULL` branch documented a mechanism that cannot occur;
removed it rather than leave dead code that reads as real handling.

Also corrected the size-guard comment: measured against the real TLSF
sources, a region under the guard doesn't fail to allocate from --
tlsf_create_with_pool() writes its control block past the end of the
region regardless and returns success (a 1 KB region took a 3.7 KB
out-of-bounds write in the measurement). The corruption from that
write, not a clean "cannot allocate" failure, is what the guard
actually prevents.

Hardware-verified on siwx917_dk2605a: boots, gc.mem_free() reports
~8 MB free, allocation works, hw test suite 9 passed/1 xfailed
(unchanged).
The siwx91x SoC family Kconfig never selects BUILD_OUTPUT_HEX, unlike
Silicon Labs' other SoC families (silabs_s0/s1/s2/sim3, which all
select it). The port's Makefile unconditionally copies zephyr.hex to
firmware.hex for release artifacts, so every release build for this
board failed:

  cp: cannot stat '.../zephyr.hex': No such file or directory
  make: *** [Makefile:37: build-.../firmware.hex] Error 1

even though the .elf and .rps (what's actually flashed) built and
linked fine. Verified locally: zephyr.hex now generates.
Zephyr has no hwinfo driver for siwx91x, so hwinfo_get_device_id() returns
-ENOSYS and cpu.uid (and the web workflow's version.json UID field) read as
all zeros.

A hwinfo driver against the efuse controller would not help: the efuse
identity region (0x020..0x02F of the array behind TA_EFUSE_IO_BASE_ADDR) is
unprogrammed on this silicon - verified on two independent dies (this one and
Hermes's DK2605A), each carrying only 4 nonzero calibration bytes at
0x3A..0x3D and zeros where efusecopy holds the MACs. Simplicity Commander's
own 'Unique ID' for this part is the WiFi MAC zero-extended to 8 bytes, read
from the flash-resident config space, not the efuse macro.

Match that definition: when hwinfo yields nothing, build the UID from the
wifi net_if link address as 00 00 | mac[6]. The link address is populated at
driver init before the radio is enabled, so it is available whenever this
can be called. Guarded on CONFIG_SOC_FAMILY_SILABS_SIWX91X so no other
board's uid semantics change.

Hardware-verified: version.json now reports UID 0000C09B9EC39910, matching
commander device info byte for byte. Diagnosis of the empty efuse array by
Phil Torrone / Hermes; on-die verification on both benches.

Also drops the two debug printk lines on the failure path (issue #8).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mikeysklar

Copy link
Copy Markdown
Owner Author

Independent verification, 2026-08-04: ran three separate research passes (Zephyr upstream, the official SiWx917 Family Reference Manual, and the WiseConnect SDK source + manufacturing docs) to check the MAC-fallback approach against documentation rather than just our own hardware probing.

All three corroborate the diagnosis:

  • Zephyr upstream: no hwinfo driver for siwx91x exists on main, and none has ever been proposed or discussed in an issue/PR. This is unmapped territory, not something already solved elsewhere that we missed.
  • Official RM (~1050 pages): zero mentions of "die ID," "unique identifier," or "serial number" anywhere. MCR_CHIP_DEVICE_ID_REG (offset 0x050) was checked and ruled out — it's hardwired to the constant 0x917, the chip family number, identical on every unit, not a per-device ID. The M4 efuse is documented as generic OTP "available for customer applications," consistent with it being blank at the factory (matches our two-die measurement).
  • WiseConnect SDK: no unique_id concept anywhere in the source. The only MAC API is sl_wifi_get_mac_address(), and the SDK's own code treats an all-zero MAC as "use factory default" — Silicon Labs' own firmware treats the MAC as the device's canonical identity. The RM's only per-device secret is a PUF-based key for anti-cloning/secure-boot, deliberately non-readable as plaintext, not a candidate for a UID API.

Conclusion: the MAC fallback isn't just the best option we found, it matches Silicon Labs' own definition of device identity for this part. No changes needed to the fix.

Flagged by Hermes review (#the-forge, 2026-08-04): the MAC-fallback value is
a WiFi MAC that Silicon Labs' own mfg917 tool can reprogram, not a burned-in
constant. Nothing on this silicon offers an immutable alternative -- no die
ID in the RM, no unique_id concept in the vendor SDK, and the one real
per-device secret (a PUF-derived key) is deliberately non-readable as
plaintext -- so the fix stands, but callers relying on cpu.uid as a
tamper-proof hardware serial (license binding, provisioning) need to know
this port doesn't provide one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@mikeysklar mikeysklar added the port:siwx917 SiWx917 CircuitPython port label Aug 4, 2026
mikeysklar added a commit that referenced this pull request Aug 5, 2026
…channel

Fixes tests/zephyr on the rebased zephyr pin. This was blocking the entire
zephyr-cp board matrix in CI: the 'ports' job needs [scheduler, mpy-cross,
tests], and tests/zephyr failing caused ports to be skipped by GitHub's
default needs-gating -- unrelated to what the scheduler computed (verified
separately: ci_set_matrix.py correctly resolves all 25 zephyr-cp boards for
this PR's changed files, reproduced locally).

Symptom, native_native_sim only:

  undefined reference to psa_hash_setup / psa_hash_update / psa_hash_clone
  / psa_hash_finish (shared-module/hashlib/__init__.c, Hash.c)

Confirmed a rebase regression, not pre-existing: PR #38 (old pin) has
tests/zephyr passing; same content otherwise, new pin fails.

Root cause: native_sim's resolved .config has '# CONFIG_PSA_CRYPTO is not
set', despite this file already setting CONFIG_MBEDTLS_PSA_CRYPTO_C=y and
CONFIG_PSA_WANT_ALG_SHA_256=y. Upstream restructured mbedtls into a
PSA_CRYPTO bool + PSA_CRYPTO_PROVIDER choice gate sitting in front of the
provider select (zephyr/modules/mbedtls/Kconfig.psa.logic) -- neither of the
existing lines sets that top-level gate directly. It only worked by side
channel: boards with Bluetooth enabled transitively select PSA_CRYPTO
through BT's own crypto use. native_sim doesn't build BT, so nothing pulled
it in, and hashlib -- which needs PSA hash unconditionally -- lost its
backend.

Set the gate explicitly so hashlib does not depend on BT (or anything else)
happening to select it.

Verified on hardware: rebuilt silabs_siwx917_dk2605a with this change,
ROM checksum is byte-identical to the last known-good build
(0x51291d72) -- confirms PSA_CRYPTO was already effectively on there via
the BT side channel, so this is a true no-op on boards where it already
worked and additive only where it did not.

Could not verify locally on native_native_sim itself: its POSIX
architecture is Linux-only, this dev machine is macOS. Verification is via
this branch's next CI run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@mikeysklar

Copy link
Copy Markdown
Owner Author

Closing as superseded by #40, which is this branch plus 8 commits on the same lineage (7400310 is a direct ancestor of 88c0b27). Pinning the baseline #40 cites from here so it survives this close: green run 30945597491, the before/after reference showing issue #42's web_workflow ReadTimeout is a regression on the new zephyr pin.

@mikeysklar mikeysklar closed this Aug 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

port:siwx917 SiWx917 CircuitPython port

Projects

None yet

Development

Successfully merging this pull request may close these issues.

microcontroller.cpu.uid returns all zeros

1 participant