diff --git a/bin/prod/expand-usb b/bin/prod/expand-usb index ce1c9ee..caa9650 100755 --- a/bin/prod/expand-usb +++ b/bin/prod/expand-usb @@ -15,7 +15,23 @@ if [ -b /dev/sda2 ]; then fi info "Creating partition on unused space" -printf "n\n\n\n\nw\n" | fdisk /dev/sda +# fdisk's own in-place re-read of the table can fail with "Device or +# resource busy" even though the write itself succeeded (seen live on a +# fresh USB drive) - the kernel picks up the new partition via udev +# shortly after regardless, so don't treat that as fatal; wait for the +# device node to actually appear instead of trusting fdisk's exit code. +printf "n\n\n\n\nw\n" | fdisk /dev/sda || true + +info "Waiting for /dev/sda2 to appear" +for i in $(seq 1 10); do + [ -b /dev/sda2 ] && break + sleep 1 +done + +if [ ! -b /dev/sda2 ]; then + info "Partition 2 did not appear after creation" + exit 1 +fi info "Creating ext4 filesystem" mkfs.ext4 -F -E nodiscard /dev/sda2 diff --git a/bin/prod/wifi-connect b/bin/prod/wifi-connect index 64a34a5..98800d6 100755 --- a/bin/prod/wifi-connect +++ b/bin/prod/wifi-connect @@ -5,6 +5,7 @@ INTERFACE="${WIFI_INTERFACE:-wlan0}" SYS_NET="${SYS_NET:-/sys/class/net}" IWD_DIR="${IWD_DIR:-/var/lib/iwd}" LOG_FILE="${LOG_FILE:-/var/log/reflash.log}" +AP_PROFILE="Recore" SSID="$1" PASS="$2" @@ -14,6 +15,16 @@ info() { echo "$1" } +# A failed connection here (bad password, AP out of range, DHCP timeout) +# would otherwise leave the board in station mode with no working +# connection and no hotspot - unreachable until physical intervention (#90). +restore_hotspot() { + 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 + iwctl ap "$INTERFACE" start-profile "$AP_PROFILE" 2>/dev/null +} + if [ -z "$SSID" ] || [ -z "$PASS" ]; then echo "Usage: $0 " exit 1 @@ -66,6 +77,7 @@ for i in {1..3}; do sleep 2 if [ $i -eq 3 ]; then info "Connection command failed after 3 attempts." + restore_hotspot exit 1 fi done @@ -85,5 +97,6 @@ while [ $COUNT -lt $MAX_RETRIES ]; do done info "Timed out waiting for IP address." +restore_hotspot exit 1 diff --git a/client/src/components/TheWifiSetup.vue b/client/src/components/TheWifiSetup.vue index f3057cc..618e8de 100644 --- a/client/src/components/TheWifiSetup.vue +++ b/client/src/components/TheWifiSetup.vue @@ -45,6 +45,11 @@

⚠️ No WiFi dongle detected. Please plug in a USB WiFi adapter.

+ +
+

{{ statusMessage }}

+ Continue at recore.local +
@@ -72,6 +77,9 @@ export default { availableAPs: [], selected: null, progressVisible: false, + connectPollDeadline: 0, + networkSwitching: false, + statusMessage: "", }), computed: mapGetters(["options"]), methods: { @@ -100,11 +108,13 @@ export default { }, async startWifiScan() { this.progressVisible = true; + this.statusMessage = "Scanning for networks..."; try { await axios.post('/api/wifi_start_scan'); setTimeout(this.pollScanResults, 1000); } catch (err) { this.progressVisible = false; + this.statusMessage = "Could not start scan."; } }, async pollScanResults() { @@ -118,7 +128,7 @@ export default { for (const ap in this.availableAPs) { this.availableAPs[ap].label = this.availableAPs[ap].SSID + " " + this.availableAPs[ap].signal; } - this.progressVisible = false; + this.statusMessage = `Found ${this.availableAPs.length} network(s).`; } } catch (err) { setTimeout(this.pollScanResults, 1000); @@ -130,6 +140,9 @@ export default { return; } this.progressVisible = true; + this.networkSwitching = false; + this.connectPollDeadline = Date.now() + 15000; + this.statusMessage = `Connecting to ${this.selected.SSID}...`; try { await axios.post('/api/wifi_start_connect',{ SSID: this.selected.SSID, @@ -139,33 +152,67 @@ export default { } catch (err) { const msg = err.response?.data || "Could not start connection"; this.$waveui.notify(msg, "error", 4000); + this.statusMessage = msg; this.progressVisible = false; } }, async pollConnectResults() { + // Once the board switches to station mode it tears down its own + // hotspot AP - if that's the network this page loaded from, its + // origin is gone for good from here on, regardless of whether the + // station-mode connection ultimately succeeds (#90). Polling that + // dead origin can never observe an outcome, so only do it for a + // short window to catch fast, local failures (e.g. validation + // errors) that happen before the switch - after that, stop and + // point the user at reconnecting manually instead of spinning + // forever against an address that will never respond again. + // + // A deadline (not an attempt counter) bounds this, because a + // request against a dead origin doesn't necessarily fail quickly - + // confirmed live (#95) that a request can hang completely silently + // (no error, ever) once the interface disappears. The explicit + // timeout below turns that into a normal rejection so the loop + // keeps moving instead of getting stuck forever on one request. + if (Date.now() > this.connectPollDeadline) { + this.progressVisible = false; + this.networkSwitching = true; + this.statusMessage = `The board is switching to ${this.selected.SSID}. Reconnect this device to that network, then continue below.`; + return; + } try { - const res = await axios.get('/api/wifi_poll_connect'); + const res = await axios.get('/api/wifi_poll_connect', { timeout: 3000 }); if (res.status === 204) { setTimeout(this.pollConnectResults, 1000); } else { console.log(res.data) if(res.data.isConnecting == true){ + this.statusMessage = `Still connecting to ${this.selected.SSID}...`; setTimeout(this.pollConnectResults, 1000); } else { if (res.data.error) { this.$waveui.notify(res.data.error, "error", 0); + this.statusMessage = res.data.error; } else{ - this.$waveui.notify("Connected to "+this.selected.SSID, "info", 0); + this.$waveui.notify("Connected to "+this.selected.SSID, "info", 0); + this.statusMessage = `Connected to ${this.selected.SSID}.`; } this.progressVisible = false; } } } catch (err) { + // A hung/timed-out request here looks the same as a normal + // network blip - keep showing the same "still connecting" + // status rather than alarming the user prematurely. The + // deadline check above is what actually decides when to give up. + this.statusMessage = `Still connecting to ${this.selected.SSID}...`; setTimeout(this.pollConnectResults, 1000); } }, + continueToBoard() { + window.location.href = "http://recore.local/"; + }, async getWifiStatus() { try { const response = await axios.get('/api/get_wifi_status'); @@ -186,6 +233,8 @@ export default { immediate: true, handler(is_open) { if (is_open) { + this.networkSwitching = false; + this.statusMessage = ""; this.getWifiStatus() this.dialog.show = true; } diff --git a/client/src/main.js b/client/src/main.js index 9fbc91c..41d5b0b 100644 --- a/client/src/main.js +++ b/client/src/main.js @@ -1,10 +1,22 @@ import { createApp } from 'vue' import WaveUI from 'wave-ui' +import axios from 'axios' import App from './App.vue' import store from './store' import 'wave-ui/dist/wave-ui.css' +// Several components poll the board on a timer with no per-call timeout +// (WiFi status, transfer progress, scan results, ...). A request with no +// timeout that never resolves - confirmed live: the board's WiFi +// interface disappearing mid-request during an AP/station switch (#90, +// #95) causes exactly this - sits holding one of the browser's ~6 +// same-origin connection slots forever. Enough of those piling up at +// once starves every other request, including ones with their own +// explicit timeout, since they can't get a socket to run on at all. A +// global default bounds every request that doesn't set its own. +axios.defaults.timeout = 10000 + const app = createApp(App) app.use(store) diff --git a/client/vue.config.js b/client/vue.config.js index 1a7ac06..20ca43a 100644 --- a/client/vue.config.js +++ b/client/vue.config.js @@ -1,5 +1,10 @@ module.exports = { transpileDependencies: true, + // The board's rootfs is a small, tightly-sized initrd ramdisk (368M + // total) - sourcemaps add 3MB+ per build for a debugging aid that's + // not needed on-device, and repeated deploys without them were + // enough to fill the disk outright during testing. + productionSourceMap: false, devServer: { proxy: { '^/api': { diff --git a/test/bats/wifi.bats b/test/bats/wifi.bats index 9cc6228..90efe12 100644 --- a/test/bats/wifi.bats +++ b/test/bats/wifi.bats @@ -123,3 +123,45 @@ EOF assert_called_with "station wlan0 connect HomeNet" [[ "$output" == *"Connected with IP: 192.168.1.50/24"* ]] } + +# --- connect failure -> hotspot fallback (#90) ------------------------------ + +@test "wifi-connect: restores hotspot if the connect command keeps failing" { + with_adapter + 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 connect" ]; then exit 1; fi +exit 0 +EOF + chmod +x "$SHIMDIR/iwctl" + run "$PROD_BIN/wifi-connect" HomeNet hunter2 + [ "$status" -eq 1 ] + [[ "$output" == *"Connection command failed after 3 attempts."* ]] + assert_called_with "device wlan0 set-property Mode ap" + assert_called_with "ap wlan0 start-profile Recore" +} + +@test "wifi-connect: restores hotspot if DHCP never leases" { + with_adapter + cat > "$SHIMDIR/iwctl" <<'EOF' +#!/usr/bin/env bash +echo "iwctl $*" >> "$CALLS" +if [ "$1 $2 $3" = "device wlan0 show" ]; then echo "Mode station"; fi +exit 0 +EOF + chmod +x "$SHIMDIR/iwctl" + cat > "$SHIMDIR/ip" <<'EOF' +#!/usr/bin/env bash +echo "ip $*" >> "$CALLS" +# No "inet " line in the output - no lease ever arrives. +exit 0 +EOF + chmod +x "$SHIMDIR/ip" + run "$PROD_BIN/wifi-connect" HomeNet hunter2 + [ "$status" -eq 1 ] + [[ "$output" == *"Timed out waiting for IP address."* ]] + assert_called_with "device wlan0 set-property Mode ap" + assert_called_with "ap wlan0 start-profile Recore" +}