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=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`.
|
- `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 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.
|
Kubernetes runtime is expected to source `ACI_EMAIL`/`ACI_PASSWORD` from Vault.
|
||||||
|
|
||||||
@ -68,3 +70,10 @@ npm run dev
|
|||||||
Metrics endpoint:
|
Metrics endpoint:
|
||||||
- `http://localhost:9108/metrics`
|
- `http://localhost:9108/metrics`
|
||||||
- `http://localhost:9108/healthz`
|
- `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;
|
receivedAtEpochSeconds: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BleWifiNetworkCredentials {
|
||||||
|
ssid: string;
|
||||||
|
password: string;
|
||||||
|
hidden: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BleFeatureNotSupportedError extends Error {}
|
||||||
|
|
||||||
export interface BleControllerClient {
|
export interface BleControllerClient {
|
||||||
verifyConnection(macAddress: string): Promise<void>;
|
verifyConnection(macAddress: string): Promise<void>;
|
||||||
readTelemetry(macAddress: string, requestPort: number): Promise<BleTelemetryReading>;
|
readTelemetry(macAddress: string, requestPort: number): Promise<BleTelemetryReading>;
|
||||||
setPortSpeed(macAddress: string, portByte: number, speedLevel: number): Promise<void>;
|
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 { Logger } from "../observability/Logger";
|
||||||
import { AcInfinityProtocol } from "./AcInfinityProtocol";
|
import { AcInfinityProtocol } from "./AcInfinityProtocol";
|
||||||
import { BleControllerClient, BleTelemetryReading } from "./BleControllerClient";
|
import {
|
||||||
|
BleControllerClient,
|
||||||
|
BleFeatureNotSupportedError,
|
||||||
|
BleTelemetryReading,
|
||||||
|
BleWifiNetworkCredentials
|
||||||
|
} from "./BleControllerClient";
|
||||||
|
|
||||||
const READ_CHARACTERISTIC_UUIDS = [
|
const READ_CHARACTERISTIC_UUIDS = [
|
||||||
"70D51002-2C7F-4E75-AE8A-D758951CE4E0",
|
"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>(
|
private async withDevice<T>(
|
||||||
macAddress: string,
|
macAddress: string,
|
||||||
action: (characteristics: ResolvedCharacteristics) => Promise<T>
|
action: (characteristics: ResolvedCharacteristics) => Promise<T>
|
||||||
|
|||||||
@ -35,3 +35,16 @@ export interface PairRequest {
|
|||||||
export interface SetPortSpeedRequest {
|
export interface SetPortSpeedRequest {
|
||||||
speedLevel: number;
|
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;
|
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") {
|
if (method === "GET" && url.pathname === "/api/v2/rules") {
|
||||||
this.json(res, 200, { rules: this.rules.getRules() });
|
this.json(res, 200, { rules: this.rules.getRules() });
|
||||||
return;
|
return;
|
||||||
@ -178,6 +200,14 @@ export class ControlApiServer {
|
|||||||
return value;
|
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 {
|
private json(res: http.ServerResponse, statusCode: number, payload: object): void {
|
||||||
res.statusCode = statusCode;
|
res.statusCode = statusCode;
|
||||||
res.setHeader("Content-Type", "application/json");
|
res.setHeader("Content-Type", "application/json");
|
||||||
|
|||||||
@ -1,7 +1,16 @@
|
|||||||
import { AcInfinityMode, ClimateSnapshot, ControllerClimate, PortClimate } from "../domain/ClimateSnapshot";
|
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 { Logger } from "../observability/Logger";
|
||||||
import { BleControllerClient, BleTelemetryReading } from "../ble/BleControllerClient";
|
import {
|
||||||
|
BleControllerClient,
|
||||||
|
BleFeatureNotSupportedError,
|
||||||
|
BleTelemetryReading
|
||||||
|
} from "../ble/BleControllerClient";
|
||||||
import { NodeBleControllerClient } from "../ble/NodeBleControllerClient";
|
import { NodeBleControllerClient } from "../ble/NodeBleControllerClient";
|
||||||
import { ControlApiError } from "./ControlApiError";
|
import { ControlApiError } from "./ControlApiError";
|
||||||
import { ControllerControlBackend } from "./ControllerControlBackend";
|
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> {
|
public async refreshTelemetry(): Promise<ClimateSnapshot> {
|
||||||
const mac = this.requireMac();
|
const mac = this.requireMac();
|
||||||
|
|
||||||
@ -240,6 +293,21 @@ export class BleControlBackend implements ControllerControlBackend {
|
|||||||
return "interior";
|
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 {
|
private toDevicePort(port: number): number {
|
||||||
return port - this.portBase;
|
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 { ControlApiError } from "./ControlApiError";
|
||||||
import { ControllerControlBackend } from "./ControllerControlBackend";
|
import { ControllerControlBackend } from "./ControllerControlBackend";
|
||||||
import { TelemetryCache } from "./TelemetryCache";
|
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);
|
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(
|
private mapPorts(
|
||||||
ports: Array<{
|
ports: Array<{
|
||||||
port: number;
|
port: number;
|
||||||
|
|||||||
@ -1,8 +1,14 @@
|
|||||||
import { ControlPortState, ControllerControlStatus } from "../domain/ControlTypes";
|
import {
|
||||||
|
ControlPortState,
|
||||||
|
ControllerControlStatus,
|
||||||
|
WifiRecoveryRequest,
|
||||||
|
WifiRecoveryResult
|
||||||
|
} from "../domain/ControlTypes";
|
||||||
|
|
||||||
export interface ControllerControlBackend {
|
export interface ControllerControlBackend {
|
||||||
getStatus(): Promise<ControllerControlStatus>;
|
getStatus(): Promise<ControllerControlStatus>;
|
||||||
pair(macAddress: string): Promise<ControllerControlStatus>;
|
pair(macAddress: string): Promise<ControllerControlStatus>;
|
||||||
getPorts(): Promise<ControlPortState[]>;
|
getPorts(): Promise<ControlPortState[]>;
|
||||||
setPortSpeed(port: number, speedLevel: number): Promise<void>;
|
setPortSpeed(port: number, speedLevel: number): Promise<void>;
|
||||||
|
recoverWifi(request: WifiRecoveryRequest): Promise<WifiRecoveryResult>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,6 +37,18 @@ export function renderControlUi(defaultMac: string | null): string {
|
|||||||
" </form>",
|
" </form>",
|
||||||
" <div id=\"pair-result\" class=\"muted\" style=\"margin-top: 0.5rem;\"></div>",
|
" <div id=\"pair-result\" class=\"muted\" style=\"margin-top: 0.5rem;\"></div>",
|
||||||
" </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>",
|
" <h2>Ports</h2>",
|
||||||
" <div id=\"ports\" class=\"grid\"></div>",
|
" <div id=\"ports\" class=\"grid\"></div>",
|
||||||
@ -105,6 +117,25 @@ export function renderControlUi(defaultMac: string | null): string {
|
|||||||
" await refresh();",
|
" 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) => {",
|
" refresh().catch((err) => {",
|
||||||
" const el = document.getElementById('pair-result');",
|
" const el = document.getElementById('pair-result');",
|
||||||
" el.textContent = String(err);",
|
" 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 { Logger } from "../src/observability/Logger";
|
||||||
import { BleControlBackend } from "../src/services/BleControlBackend";
|
import { BleControlBackend } from "../src/services/BleControlBackend";
|
||||||
|
|
||||||
class FakeBleClient implements BleControllerClient {
|
class FakeBleClient implements BleControllerClient {
|
||||||
public lastSet: { mac: string; portByte: number; speedLevel: number } | null = null;
|
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 = {
|
public telemetry: BleTelemetryReading = {
|
||||||
temperatureCelsius: 24.06,
|
temperatureCelsius: 24.06,
|
||||||
temperatureFahrenheit: 75.31,
|
temperatureFahrenheit: 75.31,
|
||||||
@ -26,6 +33,13 @@ class FakeBleClient implements BleControllerClient {
|
|||||||
public async setPortSpeed(macAddress: string, portByte: number, speedLevel: number): Promise<void> {
|
public async setPortSpeed(macAddress: string, portByte: number, speedLevel: number): Promise<void> {
|
||||||
this.lastSet = { mac: macAddress, portByte, speedLevel };
|
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", () => {
|
describe("BleControlBackend", () => {
|
||||||
@ -94,4 +108,57 @@ describe("BleControlBackend", () => {
|
|||||||
expect(controller?.vpdKpa).toBeCloseTo(1.76, 2);
|
expect(controller?.vpdKpa).toBeCloseTo(1.76, 2);
|
||||||
expect(controller?.ports.find((p) => p.port === 4)?.currentSpeedLevel).toBe(7);
|
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 http from "node:http";
|
||||||
|
|
||||||
import { ControlApiServer } from "../src/http/ControlApiServer";
|
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 { Logger } from "../src/observability/Logger";
|
||||||
import { ControllerControlBackend } from "../src/services/ControllerControlBackend";
|
import { ControllerControlBackend } from "../src/services/ControllerControlBackend";
|
||||||
import { RuleEngineService } from "../src/services/RuleEngineService";
|
import { RuleEngineService } from "../src/services/RuleEngineService";
|
||||||
|
|
||||||
class FakeBackend implements ControllerControlBackend {
|
class FakeBackend implements ControllerControlBackend {
|
||||||
private mac: string | null = null;
|
private mac: string | null = null;
|
||||||
|
public wifiRecoveryRequests: WifiRecoveryRequest[] = [];
|
||||||
private ports: ControlPortState[] = [
|
private ports: ControlPortState[] = [
|
||||||
{ port: 1, name: "Port 1", fanGroup: "outlet", currentSpeedLevel: 0, online: true, powerState: false },
|
{ 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 },
|
{ 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(
|
async function requestJson(
|
||||||
@ -120,11 +136,24 @@ describe("ControlApiServer", () => {
|
|||||||
const setSpeed = await requestJson(port, "POST", "/api/v2/ports/4/speed", { speed_level: 8 });
|
const setSpeed = await requestJson(port, "POST", "/api/v2/ports/4/speed", { speed_level: 8 });
|
||||||
expect(setSpeed.statusCode).toBe(200);
|
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");
|
const ports = await requestJson(port, "GET", "/api/v2/ports");
|
||||||
expect(ports.statusCode).toBe(200);
|
expect(ports.statusCode).toBe(200);
|
||||||
const parsed = ports.payload as { ports?: Array<{ port: number; currentSpeedLevel: number }> };
|
const parsed = ports.payload as { ports?: Array<{ port: number; currentSpeedLevel: number }> };
|
||||||
const interior = parsed.ports?.find((item) => item.port === 4);
|
const interior = parsed.ports?.find((item) => item.port === 4);
|
||||||
expect(interior?.currentSpeedLevel).toBe(8);
|
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();
|
await server.stop();
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user