feat(v2): add wifi recovery api contract and ble backend plumbing

This commit is contained in:
Brad Stein 2026-04-14 03:48:09 -03:00
parent cab40d05c6
commit 5384958aca
11 changed files with 292 additions and 7 deletions

View File

@ -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 }`

View File

@ -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<void>;
readTelemetry(macAddress: string, requestPort: number): Promise<BleTelemetryReading>;
setPortSpeed(macAddress: string, portByte: number, speedLevel: number): Promise<void>;
recoverWifi(macAddress: string, credentials: BleWifiNetworkCredentials): Promise<void>;
}

View File

@ -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<void> {
throw new BleFeatureNotSupportedError(
"ble wifi provisioning frames are not implemented yet"
);
}
private async withDevice<T>(
macAddress: string,
action: (characteristics: ResolvedCharacteristics) => Promise<T>

View File

@ -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[];
}

View File

@ -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");

View File

@ -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<WifiRecoveryResult> {
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<ClimateSnapshot> {
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;
}

View File

@ -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<WifiRecoveryResult> {
throw new ControlApiError("wifi recovery is not supported in cloud mode", 409);
}
private mapPorts(
ports: Array<{
port: number;

View File

@ -1,8 +1,14 @@
import { ControlPortState, ControllerControlStatus } from "../domain/ControlTypes";
import {
ControlPortState,
ControllerControlStatus,
WifiRecoveryRequest,
WifiRecoveryResult
} from "../domain/ControlTypes";
export interface ControllerControlBackend {
getStatus(): Promise<ControllerControlStatus>;
pair(macAddress: string): Promise<ControllerControlStatus>;
getPorts(): Promise<ControlPortState[]>;
setPortSpeed(port: number, speedLevel: number): Promise<void>;
recoverWifi(request: WifiRecoveryRequest): Promise<WifiRecoveryResult>;
}

View File

@ -37,6 +37,18 @@ export function renderControlUi(defaultMac: string | null): string {
" </form>",
" <div id=\"pair-result\" class=\"muted\" style=\"margin-top: 0.5rem;\"></div>",
" </div>",
" <div class=\"card\" style=\"margin-top: 0.8rem;\">",
" <div><strong>Wi-Fi Recovery</strong></div>",
" <form id=\"wifi-form\" style=\"margin-top: 0.6rem; display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center;\">",
" <input id=\"wifi-ssid\" name=\"ssid\" placeholder=\"SSID\" maxlength=\"32\" />",
" <input id=\"wifi-password\" name=\"password\" type=\"password\" placeholder=\"Wi-Fi Password\" maxlength=\"63\" />",
" <label class=\"muted\" style=\"display:flex; gap:0.3rem; align-items:center;\">",
" <input id=\"wifi-hidden\" type=\"checkbox\" />Hidden",
" </label>",
" <button type=\"submit\">Recover Wi-Fi</button>",
" </form>",
" <div id=\"wifi-result\" class=\"muted\" style=\"margin-top: 0.5rem;\"></div>",
" </div>",
"",
" <h2>Ports</h2>",
" <div id=\"ports\" class=\"grid\"></div>",
@ -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);",

View File

@ -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<void> {
this.lastSet = { mac: macAddress, portByte, speedLevel };
}
public async recoverWifi(macAddress: string, credentials: BleWifiNetworkCredentials): Promise<void> {
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");
});
});

View File

@ -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<WifiRecoveryResult> {
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();
});