feat(v2): add wifi recovery api contract and ble backend plumbing
This commit is contained in:
parent
cab40d05c6
commit
5384958aca
@ -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 }`
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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[];
|
||||
}
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
@ -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);",
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user