import { execFile } from "node:child_process"; import { readFile } from "node:fs/promises"; import { promisify } from "node:util"; import { z } from "zod"; import { consumeNetworkCommand, networkStatusSchema, writeNetworkStatus, type NetworkAction, type NetworkMode, type NetworkStatus } from "./network.js"; const execFileAsync = promisify(execFile); const sleep = (milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds)); const controllerConfigSchema = z.object({ NETWORK_MANAGER_ENABLED: z.enum(["true", "false"]).default("false").transform((value) => value === "true"), NETWORK_STATUS_PATH: z.string().default("/var/lib/pi-car-companion/network-status.json"), NETWORK_COMMAND_PATH: z.string().default("/var/lib/pi-car-companion/network-command.json"), NETWORK_HOTSPOT_INTERFACE: z.string().regex(/^[a-zA-Z0-9_.-]+$/).default("wlan0"), NETWORK_UPSTREAM_INTERFACE: z.string().regex(/^[a-zA-Z0-9_.-]+$/).default("wlan1"), NETWORK_HOTSPOT_CONNECTION: z.string().min(1).max(128).default("pi-car-hotspot"), NETWORK_PROBE_TIMEOUT_SECONDS: z.coerce.number().int().min(15).max(180).default(60), NETWORK_STABILITY_SECONDS: z.coerce.number().int().min(3).max(60).default(10), NETWORK_POLL_SECONDS: z.coerce.number().int().min(2).max(30).default(5) }); type ControllerConfig = z.infer; type Adapter = NetworkStatus["hotspot"]; async function nmcli(args: string[], timeoutMs = 20_000): Promise { const { stdout } = await execFileAsync("/usr/bin/nmcli", args, { timeout: timeoutMs, maxBuffer: 32 * 1024 }); return stdout.trim(); } async function field(device: string, name: string): Promise { try { return (await nmcli(["--get-values", name, "device", "show", device])).split("\n")[0]?.trim() || null; } catch { return null; } } async function connectionField(connection: string, name: string): Promise { try { return (await nmcli(["--get-values", name, "connection", "show", connection])).split("\n")[0]?.trim() || null; } catch { return null; } } async function inspectAdapter(device: string): Promise { const type = await field(device, "GENERAL.TYPE"); if (type !== "wifi") return { interface: device, available: false, connected: false, connection: null, address: null }; const state = await field(device, "GENERAL.STATE"); return { interface: device, available: true, connected: state?.startsWith("100") ?? false, connection: await field(device, "GENERAL.CONNECTION"), address: await field(device, "IP4.ADDRESS") }; } async function connectivity(): Promise { try { const value = await nmcli(["networking", "connectivity", "check"], 10_000); return ["full", "limited", "portal", "none", "unknown"].includes(value) ? (value as NetworkStatus["connectivity"]) : "unknown"; } catch { return "unknown"; } } class NetworkController { private mode: NetworkMode = "starting"; private message = "Inspecting network adapters."; private lastTransitionAt = new Date().toISOString(); private lastFailure: string | null = null; private probeDeadline: string | null = null; private fallbackAttempted = false; private nextAwusAttemptAt = 0; private stopped = false; constructor(private readonly config: ControllerConfig) {} stop(): void { this.stopped = true; } private transition(mode: NetworkMode, message: string, failure: string | null = null): void { if (this.mode !== mode) this.lastTransitionAt = new Date().toISOString(); this.mode = mode; this.message = message; this.lastFailure = failure; } private async publish(): Promise<{ hotspot: Adapter; upstream: Adapter }> { const [hotspot, upstream, internet, hotspotSsid] = await Promise.all([ inspectAdapter(this.config.NETWORK_HOTSPOT_INTERFACE), inspectAdapter(this.config.NETWORK_UPSTREAM_INTERFACE), connectivity(), connectionField(this.config.NETWORK_HOTSPOT_CONNECTION, "802-11-wireless.ssid") ]); await writeNetworkStatus(this.config.NETWORK_STATUS_PATH, { enabled: true, mode: this.mode, message: this.message, updatedAt: new Date().toISOString(), lastTransitionAt: this.lastTransitionAt, probeDeadline: this.probeDeadline, lastFailure: this.lastFailure, connectivity: internet, hotspotSsid, hotspot, upstream }); return { hotspot, upstream }; } private async connectUpstreamAdapter(): Promise { try { await nmcli(["--wait", "20", "device", "connect", this.config.NETWORK_UPSTREAM_INTERFACE], 25_000); await sleep(this.config.NETWORK_STABILITY_SECONDS * 1000); return (await inspectAdapter(this.config.NETWORK_UPSTREAM_INTERFACE)).connected; } catch (error) { this.lastFailure = error instanceof Error ? error.message.slice(0, 500) : "The upstream adapter could not connect."; return false; } } private async restoreHotspot(message = "Offline hotspot restored; the dashboard remains available without internet."): Promise { this.probeDeadline = null; try { await nmcli( ["--wait", "20", "connection", "up", this.config.NETWORK_HOTSPOT_CONNECTION, "ifname", this.config.NETWORK_HOTSPOT_INTERFACE], 25_000 ); const upstream = await inspectAdapter(this.config.NETWORK_UPSTREAM_INTERFACE); this.transition(upstream.connected ? "dual_radio" : "offline_hotspot", upstream.connected ? "Car hotspot is active with AWUS upstream internet." : message); return true; } catch (error) { const failure = error instanceof Error ? error.message.slice(0, 500) : "The hotspot could not be restored."; this.transition("error", "The recovery hotspot could not be activated. Local intervention is required.", failure); return false; } } private async probeBuiltIn(): Promise { const timeout = this.config.NETWORK_PROBE_TIMEOUT_SECONDS; this.probeDeadline = new Date(Date.now() + timeout * 1000).toISOString(); this.transition("probing_built_in", `Trying saved upstream networks on the built-in adapter for up to ${timeout} seconds.`); await this.publish(); try { await nmcli(["--wait", "10", "connection", "down", this.config.NETWORK_HOTSPOT_CONNECTION], 15_000).catch(() => undefined); await nmcli(["--wait", String(timeout), "device", "connect", this.config.NETWORK_HOTSPOT_INTERFACE], (timeout + 5) * 1000); if ((await inspectAdapter(this.config.NETWORK_HOTSPOT_INTERFACE)).connected) { this.probeDeadline = null; this.transition("built_in_upstream", "The built-in adapter is connected upstream; the car hotspot is temporarily unavailable."); this.fallbackAttempted = false; return; } throw new Error("The built-in adapter did not activate a saved upstream profile."); } catch (error) { this.lastFailure = error instanceof Error ? error.message.slice(0, 500) : "No saved upstream network was available."; this.fallbackAttempted = true; await this.restoreHotspot(); } } private async handleAction(action: NetworkAction): Promise { if (action === "restore-hotspot") { this.fallbackAttempted = true; await this.restoreHotspot("The offline hotspot was restored manually."); return; } this.fallbackAttempted = false; const upstream = await inspectAdapter(this.config.NETWORK_UPSTREAM_INTERFACE); if (upstream.available && (upstream.connected || (await this.connectUpstreamAdapter()))) { await this.restoreHotspot(); return; } await this.probeBuiltIn(); } async run(): Promise { try { const prior = networkStatusSchema.parse(JSON.parse(await readFile(this.config.NETWORK_STATUS_PATH, "utf8"))); this.mode = prior.mode; this.message = prior.message; this.lastTransitionAt = prior.lastTransitionAt; this.lastFailure = prior.lastFailure; this.fallbackAttempted = prior.mode === "offline_hotspot"; } catch { // A first start intentionally discovers the live NetworkManager state. } let previous = await this.publish(); while (!this.stopped) { const command = await consumeNetworkCommand(this.config.NETWORK_COMMAND_PATH); if (command) await this.handleAction(command.action); let { hotspot, upstream } = await this.publish(); const hotspotActive = hotspot.connected && hotspot.connection === this.config.NETWORK_HOTSPOT_CONNECTION; const builtInUpstream = hotspot.connected && !hotspotActive; if (upstream.connected) { if (!hotspotActive) await this.restoreHotspot(); else this.transition("dual_radio", "Car hotspot is active with AWUS upstream internet."); this.fallbackAttempted = false; } else if (builtInUpstream) { this.transition("built_in_upstream", "The built-in adapter is connected upstream; the car hotspot is temporarily unavailable."); if (upstream.available && Date.now() >= this.nextAwusAttemptAt) { this.nextAwusAttemptAt = Date.now() + 60_000; if (await this.connectUpstreamAdapter()) await this.restoreHotspot(); } } else { const awusJustFailed = previous.upstream.connected && !upstream.connected; if (upstream.available && Date.now() >= this.nextAwusAttemptAt) { this.nextAwusAttemptAt = Date.now() + 60_000; if (await this.connectUpstreamAdapter()) { await this.restoreHotspot(); ({ hotspot, upstream } = await this.publish()); } } if (!upstream.connected && !this.fallbackAttempted && (awusJustFailed || hotspotActive || !hotspot.connected)) { await this.probeBuiltIn(); } else if (!upstream.connected && hotspotActive) { this.transition("offline_hotspot", "Offline hotspot is active; no upstream network is currently connected.", this.lastFailure); } } previous = await this.publish(); await sleep(this.config.NETWORK_POLL_SECONDS * 1000); } } } const config = controllerConfigSchema.parse(process.env); if (!config.NETWORK_MANAGER_ENABLED) { process.exit(0); } const controller = new NetworkController(config); process.on("SIGINT", () => controller.stop()); process.on("SIGTERM", () => controller.stop()); await controller.run();