From 6418b88a4eb9fdd660f94e8fb569c43626ea21b4 Mon Sep 17 00:00:00 2001 From: Elias Bakken Date: Thu, 17 Sep 2026 19:01:13 +0200 Subject: [PATCH 01/12] wifi-scan: count the lit stars, not all four (#154) iwctl prints four stars for every network and draws the unlit ones in grey (***\e[1;90m*\e[0m is three bars). Stripping the colour first made every network read ****. Drop the grey stars before the colour codes. Also drop AccessPoint.Frequency: iwctl's list has no frequency and nothing ever filled it, so it was always "". Co-Authored-By: Claude Opus 5 --- bin/prod/wifi-scan | 10 +++++++--- reflash/server.go | 7 +++---- test/bats/wifi.bats | 26 ++++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/bin/prod/wifi-scan b/bin/prod/wifi-scan index cea7362..7f5380e 100755 --- a/bin/prod/wifi-scan +++ b/bin/prod/wifi-scan @@ -39,10 +39,14 @@ sleep 3 # 4. Structured Output echo "---SCAN_RESULTS_START---" # This pipeline: -# 1. sed: Nukes ANSI escape codes (the \x1B[...m stuff) -# 2. sed: Removes the > marker -# 3. awk: Parses the columns safely +# 1. sed: Drops the grey stars. iwctl always prints four, and draws the unlit +# ones in grey (`***\e[1;90m*\e[0m` is three bars), so stripping the colour +# first turned every network into "****" whatever its signal (#154). +# 2. sed: Nukes the remaining ANSI escape codes (the \x1B[...m stuff) +# 3. sed: Removes the > marker +# 4. awk: Parses the columns safely iwctl station "$INTERFACE" get-networks | \ + sed $'s/\x1b\[1;90m\*\+//g' | \ sed $'s/\x1b\[[0-9;]*m//g' | \ sed 's/>//g' | \ awk ' diff --git a/reflash/server.go b/reflash/server.go index bf37b63..923612b 100644 --- a/reflash/server.go +++ b/reflash/server.go @@ -143,10 +143,9 @@ type GetWifi struct { } type AccessPoint struct { - Frequency string `json:"frequency"` - Signal string `json:"signal"` - Flags string `json:"flags"` - SSID string `json:"SSID"` + Signal string `json:"signal"` + Flags string `json:"flags"` + SSID string `json:"SSID"` } type Options struct { diff --git a/test/bats/wifi.bats b/test/bats/wifi.bats index 80faa57..89c8b78 100644 --- a/test/bats/wifi.bats +++ b/test/bats/wifi.bats @@ -98,6 +98,32 @@ EOF [[ "$output" == *"CoffeeShop|open|**"* ]] } +@test "wifi-scan: grey stars are unlit bars, not signal (#154)" { + with_adapter + # Captured from a real iwctl: it always prints four stars and colours the + # unlit ones grey, so "***" + grey "*" is three bars, not four. + cat > "$SHIMDIR/iwctl" <<'EOF' +#!/usr/bin/env bash +echo "iwctl $*" >> "$CALLS" +if [ "$1 $2 $3" = "device wlan0 show" ]; then echo " Mode station"; fi +if [ "$1 $2 $3" = "station wlan0 get-networks" ]; then + printf ' Available networks\e[1;90m \e[0m\n' + printf '\e[1;90m Network name Security Signal\n\e[0m' + printf '\e[90m--------------------------------------------------------------------------------\n\e[0m' + printf ' \e[1;90m> \e[0m Near psk **** \n' + printf ' Kraakeslottet psk ***\e[1;90m*\e[0m \n' + printf ' Faint open *\e[1;90m***\e[0m \n' +fi +exit 0 +EOF + chmod +x "$SHIMDIR/iwctl" + run "$PROD_BIN/wifi-scan" + [ "$status" -eq 0 ] + [[ "$output" == *"Near|psk|****"* ]] + [[ "$output" == *"Kraakeslottet|psk|***"$'\n'* ]] + [[ "$output" == *"Faint|open|*"$'\n'* ]] +} + # --- full connect happy path ------------------------------------------------ @test "wifi-connect: provisions profile and reports success once DHCP leases" { From 6400dfca0ef9ced00b81a972e51d92fd1142fe87 Mon Sep 17 00:00:00 2001 From: Elias Bakken Date: Thu, 17 Sep 2026 19:02:22 +0200 Subject: [PATCH 02/12] wifi-connect: a failed attempt leaves iwd's profiles as it found them (#150) The profile is written before the attempt, with AutoConnect=true, and was never removed. A wrong passphrase stayed in /var/lib/iwd and iwd kept retrying it by itself (deauth reason 2, over and over), and a mistyped retry overwrote a profile that worked. Keep the old profile aside, and on failure forget the network in iwd and put the old one back, or nothing if there was none. Co-Authored-By: Claude Opus 5 --- bin/prod/wifi-connect | 31 ++++++++++++++++++++++++++++++- test/bats/wifi.bats | 21 +++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/bin/prod/wifi-connect b/bin/prod/wifi-connect index 59a808a..e01d945 100755 --- a/bin/prod/wifi-connect +++ b/bin/prod/wifi-connect @@ -19,6 +19,7 @@ info() { # would otherwise leave the board in station mode with no working # connection and no hotspot - unreachable until physical intervention (#90). restore_hotspot() { + restore_profile info "Connection failed - restoring hotspot ($AP_PROFILE) so the board stays reachable." iwctl ap "$INTERFACE" stop 2>/dev/null iwctl device "$INTERFACE" set-property Mode ap @@ -50,9 +51,36 @@ if [ "$CURRENT_MODE" == "ap" ]; then fi # 2. Create the iwd profile +# +# Kept aside first, because a failed attempt has to leave iwd as it found it. +# The profile carries AutoConnect=true, so a wrong passphrase left behind was +# retried by iwd on its own and on every boot - and a mistyped retry would +# overwrite a profile that worked (#150). +PROFILE="$IWD_DIR/$SSID.psk" +PREV_PROFILE="" +if [ -f "$PROFILE" ]; then + PREV_PROFILE=$(mktemp) + cp -p "$PROFILE" "$PREV_PROFILE" +fi + +# Put back what was there before this attempt, or nothing if there was nothing. +# forget first: iwd holds known networks in memory as well as on disk, and +# deleting the file alone would leave it retrying the passphrase that failed. +restore_profile() { + iwctl known-networks "$SSID" forget 2>/dev/null + rm -f "$PROFILE" + if [ -n "$PREV_PROFILE" ]; then + cp -p "$PREV_PROFILE" "$PROFILE" + rm -f "$PREV_PROFILE" + info "Restored the previous profile for $SSID." + else + info "Removed the new profile for $SSID." + fi +} + info "Provisioning iwd profile for $SSID..." mkdir -p "$IWD_DIR" -cat < "$IWD_DIR/$SSID.psk" +cat < "$PROFILE" [Settings] AutoConnect=true @@ -191,6 +219,7 @@ while [ $COUNT -lt $MAX_RETRIES ]; do if [ -n "$IP" ]; then info "Success! Connected with IP: $IP" install_source_route "${IP%%/*}" + [ -n "$PREV_PROFILE" ] && rm -f "$PREV_PROFILE" exit 0 fi sleep 1 diff --git a/test/bats/wifi.bats b/test/bats/wifi.bats index 89c8b78..ddc0084 100644 --- a/test/bats/wifi.bats +++ b/test/bats/wifi.bats @@ -218,6 +218,27 @@ EOF chmod +x "$SHIMDIR/ip" } +@test "wifi-connect: a failed attempt does not leave its profile behind (#150)" { + with_adapter + no_lease_in_state disconnected + run "$PROD_BIN/wifi-connect" HomeNet wrongpass1 + [ "$status" -eq 1 ] + [ ! -e "$IWD_DIR/HomeNet.psk" ] + # iwd keeps known networks in memory too; the file alone is not enough. + assert_called_with "known-networks HomeNet forget" +} + +@test "wifi-connect: a failed attempt puts the previous profile back (#150)" { + with_adapter + no_lease_in_state disconnected + mkdir -p "$IWD_DIR" + printf '[Security]\nPassphrase=theoneThatWorks\n' > "$IWD_DIR/HomeNet.psk" + run "$PROD_BIN/wifi-connect" HomeNet wrongpass1 + [ "$status" -eq 1 ] + grep -q "Passphrase=theoneThatWorks" "$IWD_DIR/HomeNet.psk" + ! grep -q "wrongpass1" "$IWD_DIR/HomeNet.psk" +} + @test "wifi-connect: associated but no lease is reported as a DHCP problem" { with_adapter no_lease_in_state connected From 64871d1c660d5115f20ae82f4ef80f615cdad8a4 Mon Sep 17 00:00:00 2001 From: Elias Bakken Date: Thu, 17 Sep 2026 19:03:20 +0200 Subject: [PATCH 03/12] Wi-Fi dialog: a wrong passphrase is not a connection (#149) watchForReconnect called it joined as soon as the board reported station mode with the target SSID. network-status reads that SSID from iwctl's Connected network, which is filled in while still connecting - so a wrong passphrase showed "Connected to Kraakeslottet", the watch stopped, and the fall back to the hotspot was never shown. Ask the server, which already knows: wifi_poll_connect's error ends the watch as a failure, and joined now also needs an address and an attempt that is no longer in progress. Co-Authored-By: Claude Opus 5 --- client/src/components/TheWifiSetup.vue | 30 ++++++++++++++++-- client/tests/unit/TheWifiSetup.spec.js | 43 ++++++++++++++++++++++++-- 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/client/src/components/TheWifiSetup.vue b/client/src/components/TheWifiSetup.vue index eb2163d..fc737ca 100644 --- a/client/src/components/TheWifiSetup.vue +++ b/client/src/components/TheWifiSetup.vue @@ -292,17 +292,43 @@ export default { this.boardReachable = false; } + // The server's own verdict on the attempt. The radio state alone cannot + // give it: iwd names the network while it is still *connecting*, so a + // wrong passphrase looked exactly like success for the few seconds before + // it failed, and the watch had stopped by then (#149). null when it + // cannot be read, which is not a verdict either way. + let attempt = null; + if (wifi && wantMode === "station") { + try { + const res = await axios.get('/api/wifi_poll_connect', { timeout: REQUEST_TIMEOUT }); + attempt = res.data || null; + } catch (err) { + attempt = null; + } + } + if (wifi) { if (wifi.mode !== this.reconnectFromMode) { this.sawTransition = true; } + if (wantMode === "station" && attempt && !attempt.isConnecting && attempt.error) { + this.wifi = wifi; + this.stopReconnectWatch(); + this.statusMessage = + `Could not join ${target}. The board is back on its own Recore hotspot.`; + this.$waveui.notify(this.statusMessage, "error", 0); + return; + } // Only "arrived" once the board reports the state we asked for. Right // after the request it is still on the old one, and treating that as - // success would report a connection that has not happened. + // success would report a connection that has not happened. For a + // network that also means an address and an attempt that has finished: + // named-but-still-associating is not joined. const arrived = wantMode === "ap" ? wifi.mode === "ap" - : wifi.mode === "station" && wifi.ssid === target; + : wifi.mode === "station" && wifi.ssid === target && !!wifi.ip && + !(attempt && attempt.isConnecting); if (arrived) { this.wifi = wifi; this.isWifiPresent = !!wifi.present; diff --git a/client/tests/unit/TheWifiSetup.spec.js b/client/tests/unit/TheWifiSetup.spec.js index 160482a..fa5eede 100644 --- a/client/tests/unit/TheWifiSetup.spec.js +++ b/client/tests/unit/TheWifiSetup.spec.js @@ -26,7 +26,7 @@ function mountDialog(open = false) { // Default answers for everything the dialog fetches on open, so individual // tests only have to say what is different. -function stubStatus(wifi = {}, aps = []) { +function stubStatus(wifi = {}, aps = [], attempt = null) { axios.get.mockImplementation((url) => { if (url === '/api/get_status') { return Promise.resolve({ @@ -40,6 +40,9 @@ function stubStatus(wifi = {}, aps = []) { if (url === '/api/wifi_poll_scan') { return Promise.resolve({ status: 200, data: aps }) } + if (url === '/api/wifi_poll_connect' && attempt) { + return Promise.resolve({ status: 200, data: attempt }) + } return Promise.reject(new Error('unexpected GET ' + url)) }) } @@ -290,7 +293,7 @@ describe('reconnect watch after a mode switch', () => { expect(wrapper.vm.reconnecting).toBe(true) // ...and once it really has switched, it still reports success. - stubStatus({ mode: 'station', ssid: 'HomeNet' }) + stubStatus({ mode: 'station', ssid: 'HomeNet', ip: '10.0.0.5' }) await vi.advanceTimersByTimeAsync(2500) expect(wrapper.vm.statusMessage).toBe('Connected to HomeNet.') }) @@ -314,6 +317,42 @@ describe('reconnect watch after a mode switch', () => { expect(wrapper.vm.reconnecting).toBe(false) }) + it('does not call it connected while the board is still associating (#149)', async () => { + // iwd names the network in "Connected network" while it is still + // connecting, so station + the right SSID arrives before any verdict. With + // a wrong passphrase that was announced as a connection and the watch + // stopped, never seeing the fall back to the hotspot. + stubStatus({ mode: 'ap', ssid: 'Recore' }) + const wrapper = mountDialog(true) + await settle(wrapper) + wrapper.vm.selected = { SSID: 'HomeNet' } + + await wrapper.vm.startWifiConnect() + await flushProbe() + stubStatus({ mode: 'station', ssid: 'HomeNet' }, [], { isConnecting: true, error: null }) + await vi.advanceTimersByTimeAsync(2500) + + expect(wrapper.vm.statusMessage).not.toContain('Connected to') + expect(wrapper.vm.reconnecting).toBe(true) + }) + + it('reports a failed attempt from the server verdict (#149)', async () => { + // The live case: the board was caught mid-association looking joined, and + // by the next reading the attempt had failed. The server knows; ask it. + stubStatus({ mode: 'ap', ssid: 'Recore' }) + const wrapper = mountDialog(true) + await settle(wrapper) + wrapper.vm.selected = { SSID: 'HomeNet' } + + await wrapper.vm.startWifiConnect() + await flushProbe() + stubStatus({ mode: 'station', ssid: 'HomeNet' }, [], { isConnecting: false, error: 'exit status 1' }) + await vi.advanceTimersByTimeAsync(2500) + + expect(wrapper.vm.statusMessage).toContain('Could not join HomeNet') + expect(wrapper.vm.reconnecting).toBe(false) + }) + it('settles when the hotspot switch completes', async () => { stubStatus({ mode: 'station', ssid: 'HomeNet' }) const wrapper = mountDialog(true) From 6d0329dc9675390e86964c9b0e151da107c78afe Mon Sep 17 00:00:00 2001 From: Elias Bakken Date: Thu, 17 Sep 2026 19:08:09 +0200 Subject: [PATCH 04/12] Cancel asks the eMMC job to stop; the job reports CANCELLED (#152, #156) Cancelling a backup ended in ERROR: cancelBackup set CANCELLED, the next get_progress poll turned that into IDLE before the killed pipeline had exited, and goBackup then saw no cancel and recorded an error. Install and magic-from-URL had the same shape - goInstall and goMagic never checked for a cancel at all. A cancel now sets a flag and kills xz in the job's own process group; the goroutine, once the job has exited and been cleaned up after, sets CANCELLED. Cancelling with nothing running is a no-op instead of an error toast. The kill was pkill -f xz -9, which matched anything with xz in its command line. runWorker starts each job with Setpgid, and the cancel only reaches xz in that group (#156). A backup that is cancelled or fails now removes