feat: wi-fi - expand support and fix issues, add GUI components

This commit is contained in:
2026-08-07 10:36:53 +02:00
parent bbe1df62fd
commit 9eb21eb3e3
67 changed files with 4573 additions and 245 deletions
+161
View File
@@ -12,8 +12,12 @@ wifi scan list nearby networks
wifi connect <ssid> <passphrase> join one
dhcp pick up an address
wifi status what you are connected to
wifi saved / wifi forget <ssid> networks remembered for next time
```
There is a graphical path too: a Wi-Fi entry in the desktop panel and a
Wi-Fi tab in the Network app. See "The desktop side" below.
## Layout
```
@@ -147,6 +151,163 @@ removes the MAC and drops the PHY context - the order
`iwl_mvm_mld_vif_cfg_changed_station` and the paths below it use on the way
down. `tests/wifi/ap_mlme.py` pins that order.
### A bad access point must not become a bad computer
Three separate defects turned "the Wi-Fi connection went wrong" into "the whole
machine went wrong". They are worth keeping straight because they have nothing
to do with each other beyond sharing a trigger.
**The command wait is a busy spin on a core the scheduler has been told not to
touch.** `ServiceEvents()` runs from `ApicTimer::ServiceDeferredWork()`, which
sets `cpu->reservedForKernelWork` so a bottom half holding a process-context
mutex cannot be preempted into the process that would wait on it. The
scheduler honours that by refusing to place any process on that core and by
skipping it for the reschedule IPI, and on the BSP `RunBspMaintenance()` - the
thing that wakes sleeping processes - is not reached until the pass returns.
`IwxSendCmd` then waits for the firmware by spinning on `IwxDelayUs`, up to a
second. One pass of `TearDown()` is eight commands. Against an adapter that has
stopped answering, that pinned a core for the better part of ten seconds. The
symptom is not a Wi-Fi symptom at all: the cursor crawls, windows stop
repainting, everything stutters.
So a service pass now carries a budget - `IwxBeginServicePass()` /
`IwxEndServicePass()` - of roughly one command's worth of waiting in total.
Commands that do not fit are not sent at all (an abandoned command still holds
its ring slot, and a late answer would be misread as the *next* command's
completion) and are retried on the next trip round the idle loop. The bring-up
path does not bracket itself: it reserves its CPU deliberately and has nothing
to starve, so it keeps the full per-command timeout.
**Counting consecutive failures never fires on the failure that matters.** The
give-up rule was three unanswered commands in a row, with any success resetting
the count. An adapter that answers some commands and drops others - which is
exactly what a marginal link leaves the firmware doing - therefore never
reached the cutoff, while every drop still cost a full timeout. The stall was
not a one-off; it repeated indefinitely, and the driver never concluded
anything was wrong. It is a leaky bucket now: a failure adds one, a success
drains one, so a firmware failing even a fraction of its commands trips the
cutoff in bounded time.
**`IwxFwState::Error` was a one-way door.** Nothing anywhere cleared it. A
single firmware assert left `StartJoin()` returning `WIFI_ERR_NO_ADAPTER` -
which the user sees as *no adapter is ready* - for the rest of the uptime, and
the only way back was a reboot. Disconnecting and reconnecting could not help:
there was nothing to reconnect with. Since `IwxReadFirmware()` is idempotent
and the parsed image stays resident, the cure is to stop the device and run the
same bring-up again, which `ServiceRecovery()` now does. It is rate-limited to
one attempt per five seconds and capped at three, because the reset has its own
multi-second handshakes and an adapter that will not come back after three
tries is genuinely broken - repeating it forever would be its own kind of
stall.
### The link is up only while the access point says so
`IwxLinkUp()` was nothing but `g_state == Connected`, and nothing moved it off
that state unless the access point was polite enough to send a
deauthentication frame. An access point that simply stops being there - a phone
hotspot that sleeps, wanders off channel, or drops the station without saying
so - left the link reported as up indefinitely. `NetIf::Active()` went on
choosing `wlan0`, every packet went into the void, and the desktop showed a
healthy connection while nothing resolved and nothing connected.
Beacon loss cannot be used to notice this: once associated, `MacConfigCmd`
stops asking for beacons and the firmware tracks them itself. What is
observable is that our own frames stop being acknowledged, since `IwxTxComplete`
gets a status per frame. A long enough run of failures - with any
acknowledgement, or any frame received from the BSS, restarting the count -
means the access point is gone, and the link comes down so the stack can fall
back to a cable and the panel can report the truth.
The threshold is counted in frames rather than seconds on purpose: a link that
is merely idle has nothing to send and must not be torn down for it. And
`IwxTxComplete` runs inside the RX pump, so it only sets a flag; the teardown
happens in `IwxConnectService()`, where sending the commands it needs is
allowed. `tests/wifi/ap_mlme.py` pins both halves of that.
## The desktop side
### Nothing in the GUI may block
`SYS_WIFI_SCAN` sweeps for up to twenty seconds and `SYS_WIFI_CONNECT` waits
out a whole handshake. Either one called from `desktop.elf` would freeze the
compositor - and with it the mouse, the panel and every window - for seconds at
a time. So the same work has a second, non-blocking entry point:
| | |
|---|---|
| `SYS_WIFI_SCAN_START` | starts a sweep and returns immediately |
| `SYS_WIFI_RESULTS` | copies the scan table out without touching the radio |
| `SYS_WIFI_CONNECT_ASYNC` | starts a join and returns immediately |
| `SYS_NETIFS` | lists the registered interfaces (see below) |
`WifiInfo` grew the fields that make polling enough to follow along:
`scanning` and `scanGeneration` for the sweep, `joining` and `connState` for
the handshake, and `lastError` for how the last join ended. The GUI reads
`SYS_WIFI_INFO` every 400 ms while something is in flight and every three
seconds otherwise.
The deadlines belong to the kernel, not the caller: `ServiceAsync()` runs from
`ServiceEvents()` - after the RX pump has returned, so it is allowed to send
commands - and aborts a scan that overruns, tears down a join that stalls, and
records `lastError` when one fails. A failed join is unwound the moment it
fails, so `connState` is back to idle by the time anyone looks; `lastError` is
what survives to be reported.
### Wired and wireless are separate on the panel
The IP configuration is global to the stack, so "which interface does this
address belong to?" is not a question `SYS_GETNETCFG` can answer. `SYS_NETIFS`
reports each registered interface with its name, MAC, link state and an
`active` flag - the one `NetIf::Active()` currently sends through. The Ethernet
popup shows the address only while the wired interface is the active one, and
says "Not in use" when the cable is up but Wi-Fi is carrying the traffic; the
Wi-Fi popup does the mirror image. Each icon appears only when its hardware
does: no wired interface, no Ethernet icon; no adapter, no Wi-Fi icon.
The Wi-Fi icon stays white whatever the radio is doing. State belongs in the
popup, and an icon that changes colour next to the clock is just noise.
Picking a network that needs a passphrase opens a real window - created with
`desktop_create_window()` and the four callbacks, exactly like the reboot and
shutdown dialogs in `dialogs.cpp` - rather than something painted into the
panel overlay. It therefore has a title bar, can be dragged and closed, appears
in the window list, and gets its text field, checkboxes and buttons from the
same `mtk` widgets the settings apps use.
### What runs at startup
`desktop.elf` keeps looking for an adapter until one appears (firmware loads
well after login), starts one scan as soon as the firmware reports ready, and
when the results land joins the strongest saved network that is in range. Once
the link is up, and only if no address is configured, it spawns `dhcp.elf`.
### Saved networks
`0:/config/wifi.toml` holds them:
```toml
[wifi]
autoconnect = true
[network.0]
ssid = "Home"
psk = "passphrase"
```
**No default copy of this file ships in the image.** It is created on the first
save, the way `bluetooth.toml`, `display.toml` and `session.toml` are. A shipped
default looks harmless - it only documents the schema - but it is laid down
again by anything that refreshes the system files, and it takes the user's saved
networks with it when it lands. That is exactly what happened the first time
this was written, and it is why the schema is documented here instead.
`programs/include/montauk/wifi.h` is the one implementation of reading,
writing and searching that file, shared by the panel, the Network app and the
`wifi` command, so all three agree on the schema. The passphrase is stored as
typed because that is what the join needs - the kernel derives the PMK from it,
or takes a 64-character hex string as a raw PSK. There is no key store to hide
it in: anyone who can read `0:/config` can read the passphrases.
## The interface registry
`Net::NetIf` replaced the Ethernet layer's direct calls into the E1000