Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 34 additions & 3 deletions apps/backend/src/modules/wifi/wifi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
import { getMockHotspotConfig } from "../../mocks/providers/wifi.ts";
import {
type ConnectionUUID,
type MacAddress,
nmConnDelete,
nmConnect,
nmConnGetFields,
Expand Down Expand Up @@ -70,6 +71,7 @@ import {
getMacAddressForWifiInterface,
getWifiIdToMacAddress,
type SSID,
type WifiInterface,
type WifiInterfaceId,
} from "./wifi-interfaces.ts";

Expand Down Expand Up @@ -257,6 +259,32 @@ export function broadcastWifiState() {
broadcastMsg("status", { wifi: wifiBuildMsg() });
}

/*
Record one saved infrastructure connection on the interface(s) it can be used
from. A profile bound to a MAC that a present adapter reports is attributed to
that adapter only. Any other profile — no bound MAC (created outside CeraUI:
nmtui, `nmcli device wifi connect`, a baked image profile) or a bound MAC that
matches no present adapter (MAC randomization / swapped adapter) — is registered
on every adapter, mirroring the scan path where all interfaces see all networks.
Without this fallback an active-but-unbound connection resolves no UUID and the
UI shows "Connect" on the network the device is already connected to.
*/
export function registerSavedWifiConnection(
interfaces: Record<MacAddress, WifiInterface>,
macAddress: MacAddress,
ssid: SSID,
uuid: ConnectionUUID,
): void {
const boundInterface = macAddress ? interfaces[macAddress] : undefined;
if (boundInterface) {
boundInterface.saved[ssid] = uuid;
return;
}
for (const wifiInterface of Object.values(interfaces)) {
wifiInterface.saved[ssid] = uuid;
}
}

export async function wifiUpdateSavedConns() {
// Retry transient nmcli connection-list failures with exponential backoff (T7).
const connections = await pollWithBackoff(() => nmConnsGet("uuid,type"), {
Expand Down Expand Up @@ -305,9 +333,12 @@ export async function wifiUpdateSavedConns() {
if (mode === "ap") {
void handleHotspotConn(macAddress, uuid);
} else if (mode === "infrastructure") {
if (macAddress && wifiInterfacesByMacAddress[macAddress]) {
wifiInterfacesByMacAddress[macAddress].saved[ssid] = uuid;
}
registerSavedWifiConnection(
wifiInterfacesByMacAddress,
macAddress,
ssid,
uuid,
);
}
} catch (err) {
if (err instanceof Error) {
Expand Down
80 changes: 80 additions & 0 deletions apps/backend/src/tests/wifi-saved-connection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/*
* Regression: an ACTIVE, connected saved network showed a "Connect" button
* instead of "Disconnect" (confirmed on real hardware — SSID "SOMOS - 701").
*
* Root cause lived in wifiUpdateSavedConns: a saved infrastructure profile was
* added to an interface's `saved` map ONLY when its `802-11-wireless.mac-address`
* matched a currently-present adapter. A profile with no bound MAC (created
* outside CeraUI — nmtui, `nmcli device wifi connect`, a baked image profile) or
* a bound MAC that no longer matches the live adapter (MAC randomization / swapped
* adapter) was silently dropped. The scan path (wifiUpdateScanResult) has no such
* requirement, so the network still rendered `active: true` in `available` while
* being absent from `saved` — the frontend `getWifiUUID` then resolved `undefined`
* and the UI offered "Connect" on the already-connected network.
*
* `registerSavedWifiConnection` is the extracted keying step. The "unbound" case
* below is the exact reproduction: before the fix the connection was NOT recorded
* in `saved`, so the `toBe(UUID)` assertions would have failed (the value was
* `undefined`).
*/
import { describe, expect, it } from "bun:test";
import { registerSavedWifiConnection } from "../modules/wifi/wifi.ts";
import type { SSID, WifiInterface } from "../modules/wifi/wifi-interfaces.ts";

const SSID_NAME: SSID = "SOMOS - 701";
const UUID = "11112222-3333-4444-5555-666677778888";
const ADAPTER_A = "aa:bb:cc:dd:ee:01";
const ADAPTER_B = "aa:bb:cc:dd:ee:02";

function makeInterface(id: number, ifname: string): WifiInterface {
return {
id,
ifname,
conn: null,
hw: "Test Adapter",
available: new Map(),
saved: {},
};
}

describe("registerSavedWifiConnection", () => {
it("records an unbound profile (empty MAC) so an active network resolves a UUID", () => {
const interfaces = { [ADAPTER_A]: makeInterface(0, "wlan0") };

registerSavedWifiConnection(interfaces, "", SSID_NAME, UUID);

// Pre-fix this profile was dropped (empty MAC matched no adapter) and the
// value here was `undefined` — the "Connect"-on-connected-network bug.
expect(interfaces[ADAPTER_A]?.saved[SSID_NAME]).toBe(UUID);
});

it("records a profile whose bound MAC matches no present adapter on every adapter", () => {
const interfaces = {
[ADAPTER_A]: makeInterface(0, "wlan0"),
[ADAPTER_B]: makeInterface(1, "wlan1"),
};

// A stale/randomized bound MAC that matches neither present adapter.
registerSavedWifiConnection(
interfaces,
"de:ad:be:ef:00:99",
SSID_NAME,
UUID,
);

expect(interfaces[ADAPTER_A]?.saved[SSID_NAME]).toBe(UUID);
expect(interfaces[ADAPTER_B]?.saved[SSID_NAME]).toBe(UUID);
});

it("attributes a MAC-bound profile to only its adapter (multi-adapter disambiguation)", () => {
const interfaces = {
[ADAPTER_A]: makeInterface(0, "wlan0"),
[ADAPTER_B]: makeInterface(1, "wlan1"),
};

registerSavedWifiConnection(interfaces, ADAPTER_B, SSID_NAME, UUID);

expect(interfaces[ADAPTER_B]?.saved[SSID_NAME]).toBe(UUID);
expect(interfaces[ADAPTER_A]?.saved[SSID_NAME]).toBeUndefined();
});
});