diff --git a/README.md b/README.md index bff991e..8df0b75 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,8 @@ Notes: - `TYPHON_MODE=cloud` keeps the existing v1 cloud exporter behavior. - `TYPHON_MODE=ble` enables the v2 control API scaffolding and does not require `ACI_EMAIL`/`ACI_PASSWORD`. - BLE mode uses BlueZ DBus (`node-ble`) and AC Infinity protocol packets for telemetry + per-port speed writes. +- BLE mode includes a Wi-Fi recovery API endpoint (`POST /api/v2/wifi/recover`) for v2 orchestration. +- Current `node-ble` client returns `501` for Wi-Fi recovery until provisioning packet frames are finalized. Kubernetes runtime is expected to source `ACI_EMAIL`/`ACI_PASSWORD` from Vault. @@ -68,3 +70,10 @@ npm run dev Metrics endpoint: - `http://localhost:9108/metrics` - `http://localhost:9108/healthz` + +Control API (when `ENABLE_CONTROL_API=true`): +- `GET /api/v2/status` +- `GET /api/v2/ports` +- `POST /api/v2/pair` `{ "mac_address": "AA:BB:CC:DD:EE:FF" }` +- `POST /api/v2/ports/:port/speed` `{ "speed_level": 0..10 }` +- `POST /api/v2/wifi/recover` `{ "ssid": "...", "password": "...", "hidden": false }` diff --git a/src/ble/BleControllerClient.ts b/src/ble/BleControllerClient.ts index 0945775..44e2b03 100644 --- a/src/ble/BleControllerClient.ts +++ b/src/ble/BleControllerClient.ts @@ -9,8 +9,17 @@ export interface BleTelemetryReading { receivedAtEpochSeconds: number; } +export interface BleWifiNetworkCredentials { + ssid: string; + password: string; + hidden: boolean; +} + +export class BleFeatureNotSupportedError extends Error {} + export interface BleControllerClient { verifyConnection(macAddress: string): Promise; readTelemetry(macAddress: string, requestPort: number): Promise; setPortSpeed(macAddress: string, portByte: number, speedLevel: number): Promise; + recoverWifi(macAddress: string, credentials: BleWifiNetworkCredentials): Promise; } diff --git a/src/ble/NodeBleControllerClient.ts b/src/ble/NodeBleControllerClient.ts index ae751a0..1eb67d8 100644 --- a/src/ble/NodeBleControllerClient.ts +++ b/src/ble/NodeBleControllerClient.ts @@ -2,7 +2,12 @@ import NodeBle = require("node-ble"); import { Logger } from "../observability/Logger"; import { AcInfinityProtocol } from "./AcInfinityProtocol"; -import { BleControllerClient, BleTelemetryReading } from "./BleControllerClient"; +import { + BleControllerClient, + BleFeatureNotSupportedError, + BleTelemetryReading, + BleWifiNetworkCredentials +} from "./BleControllerClient"; const READ_CHARACTERISTIC_UUIDS = [ "70D51002-2C7F-4E75-AE8A-D758951CE4E0", @@ -83,6 +88,15 @@ export class NodeBleControllerClient implements BleControllerClient { }); } + public async recoverWifi( + _macAddress: string, + _credentials: BleWifiNetworkCredentials + ): Promise { + throw new BleFeatureNotSupportedError( + "ble wifi provisioning frames are not implemented yet" + ); + } + private async withDevice( macAddress: string, action: (characteristics: ResolvedCharacteristics) => Promise diff --git a/src/domain/ControlTypes.ts b/src/domain/ControlTypes.ts index 10f6dd3..29f73c1 100644 --- a/src/domain/ControlTypes.ts +++ b/src/domain/ControlTypes.ts @@ -35,3 +35,16 @@ export interface PairRequest { export interface SetPortSpeedRequest { speedLevel: number; } + +export interface WifiRecoveryRequest { + ssid: string; + password: string; + hidden?: boolean; +} + +export interface WifiRecoveryResult { + queued: boolean; + backend: string; + controllerMac: string | null; + notes: string[]; +} diff --git a/src/http/ControlApiServer.ts b/src/http/ControlApiServer.ts index f761d91..14b4de4 100644 --- a/src/http/ControlApiServer.ts +++ b/src/http/ControlApiServer.ts @@ -111,6 +111,28 @@ export class ControlApiServer { return; } + if (method === "POST" && url.pathname === "/api/v2/wifi/recover") { + const body = await this.readJson(req); + const ssid = this.extractString(body, "ssid"); + const password = this.extractString(body, "password"); + const hidden = this.extractBoolean(body, "hidden") ?? false; + + if (!ssid) { + throw new ControlApiError("ssid is required", 400); + } + if (!password) { + throw new ControlApiError("password is required", 400); + } + + const result = await this.backend.recoverWifi({ + ssid, + password, + hidden + }); + this.json(res, 200, result); + return; + } + if (method === "GET" && url.pathname === "/api/v2/rules") { this.json(res, 200, { rules: this.rules.getRules() }); return; @@ -178,6 +200,14 @@ export class ControlApiServer { return value; } + private extractBoolean(body: JsonObject, key: string): boolean | null { + const value = body[key]; + if (typeof value !== "boolean") { + return null; + } + return value; + } + private json(res: http.ServerResponse, statusCode: number, payload: object): void { res.statusCode = statusCode; res.setHeader("Content-Type", "application/json"); diff --git a/src/services/BleControlBackend.ts b/src/services/BleControlBackend.ts index fc12ad2..7eb57a4 100644 --- a/src/services/BleControlBackend.ts +++ b/src/services/BleControlBackend.ts @@ -1,7 +1,16 @@ import { AcInfinityMode, ClimateSnapshot, ControllerClimate, PortClimate } from "../domain/ClimateSnapshot"; -import { ControlPortState, ControllerControlStatus } from "../domain/ControlTypes"; +import { + ControlPortState, + ControllerControlStatus, + WifiRecoveryRequest, + WifiRecoveryResult +} from "../domain/ControlTypes"; import { Logger } from "../observability/Logger"; -import { BleControllerClient, BleTelemetryReading } from "../ble/BleControllerClient"; +import { + BleControllerClient, + BleFeatureNotSupportedError, + BleTelemetryReading +} from "../ble/BleControllerClient"; import { NodeBleControllerClient } from "../ble/NodeBleControllerClient"; import { ControlApiError } from "./ControlApiError"; import { ControllerControlBackend } from "./ControllerControlBackend"; @@ -140,6 +149,50 @@ export class BleControlBackend implements ControllerControlBackend { }); } + public async recoverWifi(request: WifiRecoveryRequest): Promise { + const mac = this.requireMac(); + const ssid = this.validateSsid(request.ssid); + const password = this.validatePassword(request.password); + const hidden = request.hidden === true; + + await this.synchronized(async () => { + this.logger.info("attempting ble wifi recovery", { + mac_address: mac, + ssid, + hidden + }); + try { + await this.client.recoverWifi(mac, { + ssid, + password, + hidden + }); + this.connected = true; + this.lastError = null; + } catch (error) { + if (error instanceof BleFeatureNotSupportedError) { + throw new ControlApiError( + "wifi recovery over BLE is not implemented by this client yet", + 501 + ); + } + this.connected = false; + this.lastError = error instanceof Error ? error.message : String(error); + throw new ControlApiError(`failed to recover wifi over BLE: ${this.lastError}`, 502); + } + }); + + return { + queued: true, + backend: "ble-node-bluez", + controllerMac: mac, + notes: [ + `wifi recovery request sent for ssid=${ssid}`, + "controller may take up to 60s to reconnect" + ] + }; + } + public async refreshTelemetry(): Promise { const mac = this.requireMac(); @@ -240,6 +293,21 @@ export class BleControlBackend implements ControllerControlBackend { return "interior"; } + private validateSsid(value: string): string { + const trimmed = value.trim(); + if (trimmed.length < 1 || trimmed.length > 32) { + throw new ControlApiError("ssid must be between 1 and 32 characters", 400); + } + return trimmed; + } + + private validatePassword(value: string): string { + if (value.length < 8 || value.length > 63) { + throw new ControlApiError("password must be between 8 and 63 characters", 400); + } + return value; + } + private toDevicePort(port: number): number { return port - this.portBase; } diff --git a/src/services/CloudControlBackend.ts b/src/services/CloudControlBackend.ts index 90b3758..0ad5e98 100644 --- a/src/services/CloudControlBackend.ts +++ b/src/services/CloudControlBackend.ts @@ -1,4 +1,9 @@ -import { ControlPortState, ControllerControlStatus } from "../domain/ControlTypes"; +import { + ControlPortState, + ControllerControlStatus, + WifiRecoveryRequest, + WifiRecoveryResult +} from "../domain/ControlTypes"; import { ControlApiError } from "./ControlApiError"; import { ControllerControlBackend } from "./ControllerControlBackend"; import { TelemetryCache } from "./TelemetryCache"; @@ -45,6 +50,10 @@ export class CloudControlBackend implements ControllerControlBackend { throw new ControlApiError("per-port speed writes are not supported in cloud mode", 409); } + public async recoverWifi(_request: WifiRecoveryRequest): Promise { + throw new ControlApiError("wifi recovery is not supported in cloud mode", 409); + } + private mapPorts( ports: Array<{ port: number; diff --git a/src/services/ControllerControlBackend.ts b/src/services/ControllerControlBackend.ts index 84750e0..e7fba51 100644 --- a/src/services/ControllerControlBackend.ts +++ b/src/services/ControllerControlBackend.ts @@ -1,8 +1,14 @@ -import { ControlPortState, ControllerControlStatus } from "../domain/ControlTypes"; +import { + ControlPortState, + ControllerControlStatus, + WifiRecoveryRequest, + WifiRecoveryResult +} from "../domain/ControlTypes"; export interface ControllerControlBackend { getStatus(): Promise; pair(macAddress: string): Promise; getPorts(): Promise; setPortSpeed(port: number, speedLevel: number): Promise; + recoverWifi(request: WifiRecoveryRequest): Promise; } diff --git a/src/ui/template.ts b/src/ui/template.ts index 3687ca7..e1313ef 100644 --- a/src/ui/template.ts +++ b/src/ui/template.ts @@ -37,6 +37,18 @@ export function renderControlUi(defaultMac: string | null): string { " ", "
", " ", + "
", + "
Wi-Fi Recovery
", + "
", + " ", + " ", + " ", + " ", + "
", + "
", + "
", "", "

Ports

", "
", @@ -105,6 +117,25 @@ export function renderControlUi(defaultMac: string | null): string { " await refresh();", " });", "", + " document.getElementById('wifi-form').addEventListener('submit', async (event) => {", + " event.preventDefault();", + " const result = document.getElementById('wifi-result');", + " const ssid = document.getElementById('wifi-ssid').value;", + " const password = document.getElementById('wifi-password').value;", + " const hidden = document.getElementById('wifi-hidden').checked;", + " const response = await fetch('/api/v2/wifi/recover', {", + " method: 'POST',", + " headers: { 'content-type': 'application/json' },", + " body: JSON.stringify({ ssid, password, hidden })", + " });", + " const payload = await response.json();", + " if (!response.ok) {", + " result.textContent = payload.error || 'wifi recovery failed';", + " return;", + " }", + " result.textContent = (payload.notes && payload.notes.length > 0) ? payload.notes.join(' | ') : 'wifi recovery request sent';", + " });", + "", " refresh().catch((err) => {", " const el = document.getElementById('pair-result');", " el.textContent = String(err);", diff --git a/tests/BleControlBackend.test.ts b/tests/BleControlBackend.test.ts index ae453fb..72840d6 100644 --- a/tests/BleControlBackend.test.ts +++ b/tests/BleControlBackend.test.ts @@ -1,9 +1,16 @@ -import { BleControllerClient, BleTelemetryReading } from "../src/ble/BleControllerClient"; +import { + BleControllerClient, + BleFeatureNotSupportedError, + BleTelemetryReading, + BleWifiNetworkCredentials +} from "../src/ble/BleControllerClient"; import { Logger } from "../src/observability/Logger"; import { BleControlBackend } from "../src/services/BleControlBackend"; class FakeBleClient implements BleControllerClient { public lastSet: { mac: string; portByte: number; speedLevel: number } | null = null; + public lastWifiRecovery: { mac: string; credentials: BleWifiNetworkCredentials } | null = null; + public wifiRecoveryMode: "ok" | "unsupported" = "ok"; public telemetry: BleTelemetryReading = { temperatureCelsius: 24.06, temperatureFahrenheit: 75.31, @@ -26,6 +33,13 @@ class FakeBleClient implements BleControllerClient { public async setPortSpeed(macAddress: string, portByte: number, speedLevel: number): Promise { this.lastSet = { mac: macAddress, portByte, speedLevel }; } + + public async recoverWifi(macAddress: string, credentials: BleWifiNetworkCredentials): Promise { + if (this.wifiRecoveryMode === "unsupported") { + throw new BleFeatureNotSupportedError("wifi recovery unsupported"); + } + this.lastWifiRecovery = { mac: macAddress, credentials }; + } } describe("BleControlBackend", () => { @@ -94,4 +108,57 @@ describe("BleControlBackend", () => { expect(controller?.vpdKpa).toBeCloseTo(1.76, 2); expect(controller?.ports.find((p) => p.port === 4)?.currentSpeedLevel).toBe(7); }); + + it("sends wifi recovery credentials through BLE client", async () => { + const client = new FakeBleClient(); + const backend = new BleControlBackend({ + defaultMac: "58:8C:81:C6:FC:F6", + allowedMacs: [], + requestTimeoutMs: 10000, + scanTimeoutMs: 10000, + deviceType: 11, + portBase: 1, + logger: new Logger("error", "test"), + client + }); + + const result = await backend.recoverWifi({ + ssid: "atlas-net", + password: "supersecret", + hidden: true + }); + + expect(result.queued).toBe(true); + expect(client.lastWifiRecovery).toEqual({ + mac: "58:8C:81:C6:FC:F6", + credentials: { + ssid: "atlas-net", + password: "supersecret", + hidden: true + } + }); + }); + + it("returns 501 when wifi recovery is unsupported by BLE client", async () => { + const client = new FakeBleClient(); + client.wifiRecoveryMode = "unsupported"; + const backend = new BleControlBackend({ + defaultMac: "58:8C:81:C6:FC:F6", + allowedMacs: [], + requestTimeoutMs: 10000, + scanTimeoutMs: 10000, + deviceType: 11, + portBase: 1, + logger: new Logger("error", "test"), + client + }); + + await expect( + backend.recoverWifi({ + ssid: "atlas-net", + password: "supersecret", + hidden: false + }) + ).rejects.toThrow("wifi recovery over BLE is not implemented by this client yet"); + }); }); diff --git a/tests/ControlApiServer.test.ts b/tests/ControlApiServer.test.ts index a28ee39..15b01fe 100644 --- a/tests/ControlApiServer.test.ts +++ b/tests/ControlApiServer.test.ts @@ -1,13 +1,19 @@ import http from "node:http"; import { ControlApiServer } from "../src/http/ControlApiServer"; -import { ControlPortState, ControllerControlStatus } from "../src/domain/ControlTypes"; +import { + ControlPortState, + ControllerControlStatus, + WifiRecoveryRequest, + WifiRecoveryResult +} from "../src/domain/ControlTypes"; import { Logger } from "../src/observability/Logger"; import { ControllerControlBackend } from "../src/services/ControllerControlBackend"; import { RuleEngineService } from "../src/services/RuleEngineService"; class FakeBackend implements ControllerControlBackend { private mac: string | null = null; + public wifiRecoveryRequests: WifiRecoveryRequest[] = []; private ports: ControlPortState[] = [ { port: 1, name: "Port 1", fanGroup: "outlet", currentSpeedLevel: 0, online: true, powerState: false }, { port: 2, name: "Port 2", fanGroup: "inside_inlet", currentSpeedLevel: 0, online: true, powerState: false }, @@ -55,6 +61,16 @@ class FakeBackend implements ControllerControlBackend { }; }); } + + public async recoverWifi(request: WifiRecoveryRequest): Promise { + this.wifiRecoveryRequests.push(request); + return { + queued: true, + backend: "fake", + controllerMac: this.mac, + notes: ["queued"] + }; + } } async function requestJson( @@ -120,11 +136,24 @@ describe("ControlApiServer", () => { const setSpeed = await requestJson(port, "POST", "/api/v2/ports/4/speed", { speed_level: 8 }); expect(setSpeed.statusCode).toBe(200); + const wifiRecover = await requestJson(port, "POST", "/api/v2/wifi/recover", { + ssid: "atlas-net", + password: "secret-password", + hidden: false + }); + expect(wifiRecover.statusCode).toBe(200); + const ports = await requestJson(port, "GET", "/api/v2/ports"); expect(ports.statusCode).toBe(200); const parsed = ports.payload as { ports?: Array<{ port: number; currentSpeedLevel: number }> }; const interior = parsed.ports?.find((item) => item.port === 4); expect(interior?.currentSpeedLevel).toBe(8); + expect(backend.wifiRecoveryRequests).toHaveLength(1); + expect(backend.wifiRecoveryRequests[0]).toEqual({ + ssid: "atlas-net", + password: "secret-password", + hidden: false + }); await server.stop(); });