Skip to content

zephyr-cp/wifi: implement station connect, IPv4 getters and real authmode - #11228

Draft
mikeysklar wants to merge 7 commits into
adafruit:mainfrom
mikeysklar:zephyr-wifi/pr3-connect
Draft

zephyr-cp/wifi: implement station connect, IPv4 getters and real authmode#11228
mikeysklar wants to merge 7 commits into
adafruit:mainfrom
mikeysklar:zephyr-wifi/pr3-connect

Conversation

@mikeysklar

Copy link
Copy Markdown
Collaborator

What

Implements Wi-Fi station connect for zephyr-cp, and fills in the IPv4 getters,
wifi.radio.addresses, real authmode reporting, and the raw MAC helper.

Why

common_hal_wifi_radio_connect() was a stub. Its 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.

The individual fixes:

  • connect() via NET_REQUEST_WIFI_CONNECT, waiting on a semaphore signalled
    from CONNECT_RESULT (or DISCONNECT_RESULT, which is how a failed attempt
    reports), honouring the timeout and staying interruptible, then starting DHCPv4.
    wifi_conn_status is mapped to the CircuitPython error codes so a wrong
    password raises AUTH_FAIL instead of appearing to succeed.
  • Per-AP security selection. The SiWx91x driver maps
    WIFI_SECURITY_TYPE_PSK to WPA2 and WPA_AUTO_PERSONAL to WPA3-transition,
    and neither works everywhere: a WPA2-PSK AP rejects WPA3-transition and a
    WPA3-SAE AP rejects WPA2, both surfacing identically as "Authentication
    failure". So the most recent scan is cached (24 entries) and the SSID looked up
    at connect time, falling back to WPA2-PSK when unseen. Known limit: that
    fallback is silently wrong for a WPA3-only hidden AP.
  • get_authmode() built its mask from a switch that was entirely commented
    out and always returned an empty list, which reads as an open network.
  • get_mac_address() returned an uninitialized stack buffer.
  • The "ip" field in /cp/version.json was always 0: wifi_radio_get_ipv4_address()
    is a separate entry point from the Python-facing getter and was a separate stub.
  • wifi.radio.addresses returned None, the wrong type in both states for
    the documented Sequence[str] contract.
  • net_if_ip.ipv4 is guarded, since that member only exists with
    CONFIG_NET_IPV4 and this file builds for every Wi-Fi board in the port,
    including nrf7002dk which does not enable it.

Hardware tested

Not tested: WPA3-SAE association, hidden networks, AP mode (not implemented on
this port), and any non-SiWx917 zephyr-cp board.

How I tested it

code.py:

import os, wifi, socketpool
n = sum(1 for _ in wifi.radio.start_scanning_networks())
wifi.radio.stop_scanning_networks()
print("scan COUNT:", n)
wifi.radio.connect(os.getenv("CIRCUITPY_WIFI_SSID"), os.getenv("CIRCUITPY_WIFI_PASSWORD"))
print("connected:", wifi.radio.connected, "ip:", wifi.radio.ipv4_address)
print("gw/sn/dns:", wifi.radio.ipv4_gateway, wifi.radio.ipv4_subnet, wifi.radio.ipv4_dns)
print("addresses:", wifi.radio.addresses)
pool = socketpool.SocketPool(wifi.radio)
addr = pool.getaddrinfo("example.com", 80)[0][-1]
s = pool.socket(pool.AF_INET, pool.SOCK_STREAM); s.settimeout(8)
s.connect(addr); s.send(b"HEAD / HTTP/1.0\r\nHost: example.com\r\n\r\n")
b = bytearray(48); k = s.recv_into(b); s.close()
print("http:", bytes(b[:k]).split(b"\r\n")[0])

Serial output:

scan COUNT: 36
connected: True ip: 192.168.0.39
gw/sn/dns: 192.168.0.1 255.255.255.0 192.168.0.1
addresses: ('192.168.0.39',)
http: b'HTTP/1.1 200 OK'

Scan now reports real per-network authmodes rather than an empty list, including
the WPA2/WPA3 distinction the security selection depends on:

('foreverrun',       -51, 5, [wifi.AuthMode.WPA2, wifi.AuthMode.PSK])
('Sids-Dungeon',     -52, 6, [wifi.AuthMode.WPA3, wifi.AuthMode.PSK])
('Roberto_Forever',  -66, 6, [wifi.AuthMode.WPA3, wifi.AuthMode.PSK])

The "ip" field fix, over the web workflow on the second board:

$ curl -s http://192.168.0.133/cp/version.json
{"web_api_version": 4, "version": "10.3.0-alpha.4-70-...", "board_id": "siwx917_dk2605a",
 ..., "ip": "192.168.0.133"}

board_name and hostname stay empty for an unrelated reason noted above: this
port has no common-hal/mdns.

Scope

AP mode stays unimplemented. radio.dns (the setter) is deliberately not
included; only the read-only ipv4_dns getter is here.

Notes

No new translatable strings and no locale/circuitpython.pot churn. The two
MP_ERROR_TEXT uses reuse "Only IPv4 addresses supported", which already exists
and is used by the espressif and raspberrypi ports.

Second of three PRs. Stacked on #11223 and on the logging PR, so this diff also
shows their changes until they merge.

AI assistance

Written with Claude Code. I reviewed the diff myself and verified the behaviour
on the hardware listed above.

mikeysklar and others added 7 commits August 22, 2026 13:32
The event handler has a NET_EVENT_WIFI_SCAN_RESULT case that queues each
AP as it arrives, but that event was never in the subscription mask, so
the case never ran and scans always returned zero networks.

RAW_SCAN_RESULT is in the mask but is not a substitute. It carries raw
beacon frames and only fires when CONFIG_WIFI_MGMT_RAW_SCAN_RESULTS is
enabled, which it is not by default.

Measured on a Raspberry Pi Pico 2 W running raspberrypi_rpi_pico2_w_zephyr,
built from 069144c and flashed over SWD with pyOCD:

  len([1 for n in wifi.radio.start_scanning_networks()])

  before  0
  after   204

Same board, same probe, same script, with only this change reverted for
the before run.
The Wi-Fi common-hal printed on every net event with raw printk. That output
is unconditional, so it corrupts the serial handshake that raw-REPL tooling
relies on, and it cannot be turned down per module.

Register a cp_wifi log module and route the existing calls through it, at
CONFIG_LOG_DEFAULT_LEVEL as supervisor/usb.c already does. Two printks in
start_scanning_networks() only restated the message raised on the following
line, so they are dropped rather than converted.

Also fixes two defects the conversion exposed:

- The unhandled-event print passed a uint64_t mgmt_event to %x, truncating to
  32 bits. Since the layer lives in the high bits, every unhandled Wi-Fi event
  aliased to the same value.
- NET_EVENT_IPV4_ADDR_ADD was already subscribed but had no case, so it fell
  through to the unhandled-event path and the status bar kept reading "No IP"
  after DHCP bound, while wifi.radio.ipv4_address returned the real lease.
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.

Track the associated SSID so a repeat connect() to the same network returns
without tearing down a working link, on both the normal and the -EALREADY
path.

Security is fixed at WIFI_SECURITY_TYPE_PSK here. Transition-mode APs
negotiate up from there; per-network selection follows in the next commit.
Security type has to be chosen per network. The SiWx91x driver maps
WIFI_SECURITY_TYPE_PSK to SL_WIFI_WPA2 and WPA_AUTO_PERSONAL to
SL_WIFI_WPA3_TRANSITION, and neither works everywhere: a WPA2-PSK AP
rejects WPA3 transition and a WPA3-SAE AP rejects WPA2, both surfacing
identically as "Authentication failure". So cache the most recent scan
(24 entries, same-SSID replace) and look the SSID up in connect(), falling
back to WPA2-PSK when it was not seen. Known limit: that fallback is
silently wrong for a WPA3-only hidden AP.

get_authmode() built its mask from a switch that was entirely commented out
(ESP-IDF leftover) and always returned an empty list, which reads as an open
network. Translate Zephyr's wifi_security_type instead. The EAP and OWE arms
are taken from the header and are not exercised on hardware.

Adds the ipv4_subnet and ipv4_dns getters alongside the address and gateway
getters from the previous commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
wifi_radio_get_ipv4_address(), the raw uint32_t getter that
supervisor/shared/web_workflow/web_workflow.c uses for the status bar and
for /cp/version.json's "ip" field, was a leftover ESP-IDF stub that returned
0 unconditionally. It is a separate entry point from
common_hal_wifi_radio_get_ipv4_address(), the Python-facing getter: one
underlying address, two functions, only one of them implemented.

board_name and hostname in version.json stay empty, for an unrelated reason:
both come from the mDNS responder, and zephyr-cp has no common-hal/mdns, so
CIRCUITPY_MDNS never reaches web_workflow.c. That is a new component rather
than a bug fix, so it is left out of this series.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
common_hal_wifi_radio_get_addresses() returned mp_const_none
unconditionally, which is the wrong type in both states for the
shared-bindings contract ("addresses: Sequence[str] ... Empty sequence when
not connected"): None instead of a tuple when connected, None instead of an
empty tuple when not.

Reuse wifi_radio_get_ipv4_address() and format it as a string, which is what
the espressif and raspberrypi ports return here rather than IPv4Address
objects.

get_addresses_ap() had the same problem and is corrected to
mp_const_empty_tuple, without claiming AP mode works.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
wifi_radio_get_mac_address(self, uint8_t *) is declared in
shared-bindings/wifi/Radio.h but was never implemented for this port; only
the Python-facing common_hal_wifi_radio_get_mac_address() existed. Add the
raw helper and have the existing function call it rather than duplicating
the netif read.

common_hal_wifi_radio_get_ipv4_gateway() and _subnet() read net_if_ip.ipv4
unconditionally, but that struct member only exists when CONFIG_NET_IPV4 is
set. This file builds for every Wi-Fi board in the port's CI matrix, and
nrf7002dk does not enable IPv4, so the unguarded access breaks that build.
Guard both.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant