Skip to content

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

Merged
tannewt merged 7 commits into
adafruit:mainfrom
mikeysklar:zephyr-wifi/pr3-connect
Aug 27, 2026
Merged

zephyr-cp/wifi: implement station connect, IPv4 getters and real authmode#11228
tannewt merged 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
mikeysklar force-pushed the zephyr-wifi/pr3-connect branch from 70db1a3 to d16ed05 Compare August 22, 2026 21:38
@mikeysklar
mikeysklar force-pushed the zephyr-wifi/pr3-connect branch from d16ed05 to 714600d Compare August 24, 2026 21:53
@mikeysklar
mikeysklar marked this pull request as ready for review August 24, 2026 21:54

@tannewt tannewt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for working on this!

Comment thread ports/zephyr-cp/common-hal/wifi/Radio.c Outdated
Comment on lines 126 to 140
void wifi_radio_get_mac_address(wifi_radio_obj_t *self, uint8_t *mac) {
memset(mac, 0, MAC_ADDRESS_LENGTH);
if (self->sta_netif != NULL) {
struct net_linkaddr *addr = net_if_get_link_addr(self->sta_netif);
if (addr != NULL && addr->len >= MAC_ADDRESS_LENGTH) {
memcpy(mac, addr->addr, MAC_ADDRESS_LENGTH);
}
}
}

mp_obj_t common_hal_wifi_radio_get_mac_address(wifi_radio_obj_t *self) {
uint8_t mac[MAC_ADDRESS_LENGTH];
// esp_wifi_get_mac(ESP_IF_WIFI_STA, mac);
wifi_radio_get_mac_address(self, mac);
return mp_obj_new_bytes(mac, MAC_ADDRESS_LENGTH);
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Combine these. You don't need a separate function.

for (int i = 0; i < 40 && self->connected; i++) {
k_msleep(50);
}
self->connected = false;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Background task here?

Comment thread ports/zephyr-cp/common-hal/wifi/Radio.c
Comment thread ports/zephyr-cp/common-hal/wifi/Radio.c
Comment thread ports/zephyr-cp/common-hal/wifi/Radio.c Outdated
Comment thread ports/zephyr-cp/common-hal/wifi/Radio.c
Comment thread ports/zephyr-cp/common-hal/wifi/Radio.c
Comment thread ports/zephyr-cp/common-hal/wifi/__init__.c Outdated
mikeysklar added a commit to mikeysklar/circuitpython that referenced this pull request Aug 25, 2026
Review feedback from adafruit#11228.

The per-AP security cache is gone. Pico W is the closest precedent, since the
CYW43 driver needs an explicit auth mode the way Zephyr does, and it just uses
password_len ? CYW43_AUTH_WPA2_AES_PSK : CYW43_AUTH_OPEN with no cache at all.
Espressif sets no security type and lets the IDF infer it.

So a password now means WIFI_SECURITY_TYPE_WPA_AUTO_PERSONAL and no password
means WIFI_SECURITY_TYPE_NONE. The siwx91x driver maps WPA_AUTO_PERSONAL to
SL_WIFI_WPA3_TRANSITION, which an AP in either WPA2 or WPA3 mode accepts, so
this also covers the WPA3-only case that Pico W's hardcoded WPA2 does not.
WIFI_SECURITY_TYPE_UNKNOWN is not usable here, the driver returns -ENOTSUP.

That removes the 24 entry scan cache, its lookup and its insert path, and with
it the question of reading a channel back out of it.

Also folds wifi_radio_get_mac_address() into its only caller, and runs
background tasks in the three waits (disconnect, association, DHCP) the way
espressif does.

Verified on two SiWx917-DK2605A boards: both associate to a WPA2 AP and take a
DHCP lease, 192.168.0.39 and 192.168.0.133.
@mikeysklar

mikeysklar commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator Author

Security. Dropped the scan cache. Pico W is the closest case, since CYW43 needs an explicit auth mode the way Zephyr does, and it just uses password_len ? CYW43_AUTH_WPA2_AES_PSK : CYW43_AUTH_OPEN with no cache. Espressif sets nothing and lets the IDF infer it. So this does the same: password means WPA_AUTO_PERSONAL, no password means NONE. The driver maps WPA_AUTO_PERSONAL to WPA3-transition, so unlike Pico W's hardcoded WPA2 it also works on a WPA3-only AP. That removes the cached-channel question, there is no cache left. (WIFI_SECURITY_TYPE_UNKNOWN is not usable, the driver returns -ENOTSUP.)

Combined the mac helper into its only caller. Background tasks added to all three waits, matching espressif.

addresses_ap: the docstring already says "Empty sequence when disabled", which is what it returns. Making it Optional would be a shared-bindings change across all ports, so I left it.

connect() taking a ScanResult: new public API, better agreed in an issue than added here. I can file one if you want it.

Associates on two boards, 192.168.0.39 and 192.168.0.133.

@tannewt tannewt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One more small thing.

Comment on lines +64 to +72
if (result != NULL && self->current_scan != NULL) {
wifi_scannednetworks_scan_result(self->current_scan, result);
if (result != NULL) {
// Remember the authmode so connect() can request the matching
// security type later.
if (self->current_scan != NULL) {
wifi_scannednetworks_scan_result(self->current_scan, result);
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Revert this back

mikeysklar added a commit to mikeysklar/circuitpython that referenced this pull request Aug 26, 2026
Review feedback from adafruit#11228.

The nested if and the comment about remembering the authmode were only there
to make room for the scan cache insert. The cache is gone, so this is back to
exactly what main has. Drops the <string.h> include and the blank line that
came in with the cache too.

Scan and connect still work on two SiWx917-DK2605A boards: 45 and 49 networks
with correct RSSI and authmode, and both associate.
mikeysklar and others added 7 commits August 26, 2026 15:46
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.
Review feedback from adafruit#11228.

The per-AP security cache is gone. Pico W is the closest precedent, since the
CYW43 driver needs an explicit auth mode the way Zephyr does, and it just uses
password_len ? CYW43_AUTH_WPA2_AES_PSK : CYW43_AUTH_OPEN with no cache at all.
Espressif sets no security type and lets the IDF infer it.

So a password now means WIFI_SECURITY_TYPE_WPA_AUTO_PERSONAL and no password
means WIFI_SECURITY_TYPE_NONE. The siwx91x driver maps WPA_AUTO_PERSONAL to
SL_WIFI_WPA3_TRANSITION, which an AP in either WPA2 or WPA3 mode accepts, so
this also covers the WPA3-only case that Pico W's hardcoded WPA2 does not.
WIFI_SECURITY_TYPE_UNKNOWN is not usable here, the driver returns -ENOTSUP.

That removes the 24 entry scan cache, its lookup and its insert path, and with
it the question of reading a channel back out of it.

Also folds wifi_radio_get_mac_address() into its only caller, and runs
background tasks in the three waits (disconnect, association, DHCP) the way
espressif does.

Verified on two SiWx917-DK2605A boards: both associate to a WPA2 AP and take a
DHCP lease, 192.168.0.39 and 192.168.0.133.
Review feedback from adafruit#11228.

The nested if and the comment about remembering the authmode were only there
to make room for the scan cache insert. The cache is gone, so this is back to
exactly what main has. Drops the <string.h> include and the blank line that
came in with the cache too.

Scan and connect still work on two SiWx917-DK2605A boards: 45 and 49 networks
with correct RSSI and authmode, and both associate.
@mikeysklar
mikeysklar force-pushed the zephyr-wifi/pr3-connect branch from 2aa394f to 6429231 Compare August 26, 2026 22:56

@tannewt tannewt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you!

@tannewt
tannewt merged commit 03d88be into adafruit:main Aug 27, 2026
43 checks passed
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.

2 participants