feat(v2): add BLE control backend, protocol, and control api scaffold
This commit is contained in:
parent
f573477cd0
commit
cab40d05c6
13
README.md
13
README.md
@ -25,6 +25,7 @@ This implementation follows the same API conventions used by `keithah/homebridge
|
||||
## Configuration
|
||||
|
||||
Environment variables:
|
||||
- `TYPHON_MODE` (optional, default `cloud`, values: `cloud|ble`)
|
||||
- `ACI_EMAIL` (required)
|
||||
- `ACI_PASSWORD` (required, use <= 25 chars for reliability)
|
||||
- `ACI_HOST` (optional, default `http://www.acinfinityserver.com`)
|
||||
@ -32,6 +33,18 @@ Environment variables:
|
||||
- `REQUEST_TIMEOUT_MS` (optional, default `10000`)
|
||||
- `LISTEN_PORT` (optional, default `9108`)
|
||||
- `LOG_LEVEL` (optional, default `info`)
|
||||
- `ENABLE_CONTROL_API` (optional, default `false`)
|
||||
- `CONTROL_LISTEN_PORT` (optional, default `9110`)
|
||||
- `TY_BLE_DEFAULT_MAC` (optional, default empty)
|
||||
- `TY_BLE_ALLOWED_MACS` (optional, comma-delimited)
|
||||
- `TY_BLE_DEVICE_TYPE` (optional, default `11` for Controller 69 Pro)
|
||||
- `TY_BLE_SCAN_TIMEOUT_MS` (optional, default `20000`)
|
||||
- `TY_BLE_PORT_BASE` (optional, `1` by default; set `0` if your controller expects zero-based port bytes)
|
||||
|
||||
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.
|
||||
|
||||
Kubernetes runtime is expected to source `ACI_EMAIL`/`ACI_PASSWORD` from Vault.
|
||||
|
||||
|
||||
1201
package-lock.json
generated
1201
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -15,6 +15,7 @@
|
||||
"test:ci": "mkdir -p build && jest --ci --runInBand --coverage --coverageReporters=text-summary --coverageReporters=cobertura --coverageReporters=json-summary"
|
||||
},
|
||||
"dependencies": {
|
||||
"node-ble": "^1.13.0",
|
||||
"prom-client": "^15.1.3",
|
||||
"undici": "^7.16.0"
|
||||
},
|
||||
|
||||
@ -1,45 +1,107 @@
|
||||
import { AppConfig } from "../config/AppConfig";
|
||||
import { AcInfinityApiClient } from "../http/AcInfinityApiClient";
|
||||
import { ControlApiServer } from "../http/ControlApiServer";
|
||||
import { TyphonMetrics } from "../metrics/TyphonMetrics";
|
||||
import { Logger } from "../observability/Logger";
|
||||
import { BleControlBackend } from "../services/BleControlBackend";
|
||||
import { BlePollingService } from "../services/BlePollingService";
|
||||
import { ClimatePollingService } from "../services/ClimatePollingService";
|
||||
import { CloudControlBackend } from "../services/CloudControlBackend";
|
||||
import { ControllerControlBackend } from "../services/ControllerControlBackend";
|
||||
import { RuleEngineService } from "../services/RuleEngineService";
|
||||
import { TelemetryCache } from "../services/TelemetryCache";
|
||||
import { MetricsServer } from "../transport/MetricsServer";
|
||||
|
||||
interface PollingService {
|
||||
start(): void;
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
export class TyphonApplication {
|
||||
private readonly logger: Logger;
|
||||
private readonly apiClient: AcInfinityApiClient;
|
||||
private readonly metrics: TyphonMetrics;
|
||||
private readonly pollingService: ClimatePollingService;
|
||||
private readonly telemetryCache: TelemetryCache;
|
||||
private readonly apiClient: AcInfinityApiClient | null;
|
||||
private readonly pollingService: PollingService | null;
|
||||
private readonly metricsServer: MetricsServer;
|
||||
private readonly controlBackend: ControllerControlBackend;
|
||||
private readonly ruleEngine: RuleEngineService;
|
||||
private readonly controlApiServer: ControlApiServer | null;
|
||||
private shuttingDown = false;
|
||||
|
||||
public constructor(private readonly config: AppConfig, version: string) {
|
||||
this.logger = new Logger(config.logLevel, "typhon");
|
||||
this.metrics = new TyphonMetrics(version);
|
||||
this.metrics.setRuntimeMode(config.mode);
|
||||
this.telemetryCache = new TelemetryCache();
|
||||
this.ruleEngine = new RuleEngineService();
|
||||
|
||||
if (config.mode === "cloud") {
|
||||
if (!config.aciEmail || !config.aciPassword) {
|
||||
throw new Error("ACI_EMAIL and ACI_PASSWORD are required in cloud mode");
|
||||
}
|
||||
this.apiClient = new AcInfinityApiClient(
|
||||
config.aciHost,
|
||||
config.aciEmail,
|
||||
config.aciPassword,
|
||||
config.requestTimeoutMs
|
||||
);
|
||||
|
||||
this.metrics = new TyphonMetrics(version);
|
||||
this.pollingService = new ClimatePollingService(
|
||||
this.apiClient,
|
||||
this.metrics,
|
||||
this.logger,
|
||||
config.pollIntervalSeconds,
|
||||
(snapshot) => {
|
||||
this.telemetryCache.setLatest(snapshot);
|
||||
}
|
||||
);
|
||||
this.controlBackend = new CloudControlBackend(this.telemetryCache);
|
||||
} else {
|
||||
this.apiClient = null;
|
||||
this.pollingService = null;
|
||||
const bleBackend = new BleControlBackend({
|
||||
defaultMac: config.bleDefaultMac,
|
||||
allowedMacs: config.bleAllowedMacs,
|
||||
requestTimeoutMs: config.requestTimeoutMs,
|
||||
scanTimeoutMs: config.bleScanTimeoutMs,
|
||||
deviceType: config.bleDeviceType,
|
||||
portBase: config.blePortBase,
|
||||
logger: this.logger
|
||||
});
|
||||
this.pollingService = new BlePollingService(
|
||||
bleBackend,
|
||||
this.metrics,
|
||||
this.logger,
|
||||
config.pollIntervalSeconds
|
||||
);
|
||||
this.controlBackend = bleBackend;
|
||||
}
|
||||
|
||||
this.controlApiServer = config.enableControlApi
|
||||
? new ControlApiServer(
|
||||
config.controlListenPort,
|
||||
this.controlBackend,
|
||||
this.ruleEngine,
|
||||
this.logger,
|
||||
config.bleDefaultMac
|
||||
)
|
||||
: null;
|
||||
this.metricsServer = new MetricsServer(config.listenPort, this.metrics, this.logger);
|
||||
}
|
||||
|
||||
public async start(): Promise<void> {
|
||||
this.installSignalHandlers();
|
||||
await this.metricsServer.start();
|
||||
this.pollingService.start();
|
||||
this.pollingService?.start();
|
||||
if (this.controlApiServer) {
|
||||
await this.controlApiServer.start();
|
||||
}
|
||||
this.logger.info("typhon started", {
|
||||
mode: this.config.mode,
|
||||
poll_interval_seconds: this.config.pollIntervalSeconds,
|
||||
listen_port: this.config.listenPort
|
||||
listen_port: this.config.listenPort,
|
||||
control_api_enabled: this.config.enableControlApi,
|
||||
control_listen_port: this.config.controlListenPort
|
||||
});
|
||||
}
|
||||
|
||||
@ -51,9 +113,14 @@ export class TyphonApplication {
|
||||
this.shuttingDown = true;
|
||||
this.logger.info("typhon shutting down");
|
||||
|
||||
this.pollingService.stop();
|
||||
this.pollingService?.stop();
|
||||
if (this.controlApiServer) {
|
||||
await this.controlApiServer.stop();
|
||||
}
|
||||
await this.metricsServer.stop();
|
||||
if (this.apiClient) {
|
||||
await this.apiClient.close();
|
||||
}
|
||||
|
||||
this.logger.info("typhon shutdown complete");
|
||||
}
|
||||
|
||||
132
src/ble/AcInfinityProtocol.ts
Normal file
132
src/ble/AcInfinityProtocol.ts
Normal file
@ -0,0 +1,132 @@
|
||||
export interface ParsedBleTelemetry {
|
||||
temperatureCelsius: number;
|
||||
temperatureFahrenheit: number;
|
||||
humidityPercent: number;
|
||||
vpdKpa: number;
|
||||
choosePort: number;
|
||||
workType: number;
|
||||
fanSpeedGuess: number;
|
||||
}
|
||||
|
||||
function getBits(value: number, start: number, width: number): number {
|
||||
return (value >> ((8 - start) - width)) & (255 >> (8 - width));
|
||||
}
|
||||
|
||||
function getSignedShort(data: Buffer, offset: number): number {
|
||||
const high = data[offset] ?? 0;
|
||||
const low = data[offset + 1] ?? 0;
|
||||
const combined = (low & 255) | ((high << 8) & 65280);
|
||||
return combined > 32767 ? combined - 65536 : combined;
|
||||
}
|
||||
|
||||
function crc16CcittFalse(bytes: number[], start = 0, length = bytes.length): [number, number] {
|
||||
let crc = 0xffff;
|
||||
for (let index = start; index < start + length; index += 1) {
|
||||
const value = bytes[index] ?? 0;
|
||||
const b2 = (((crc << 8) | (crc >> 8)) & 0xffff) ^ (value & 0xff);
|
||||
const b3 = b2 ^ ((b2 & 0xff) >> 4);
|
||||
const b4 = b3 ^ ((b3 << 12) & 0xffff);
|
||||
crc = b4 ^ (((b4 & 0xff) << 5) & 0xffff);
|
||||
}
|
||||
const finalValue = crc & 0xffff;
|
||||
return [((finalValue >> 8) & 0xff), (finalValue & 0xff)];
|
||||
}
|
||||
|
||||
export class AcInfinityProtocol {
|
||||
private sequence = 1;
|
||||
|
||||
public nextSequence(): number {
|
||||
if (this.sequence >= 65535) {
|
||||
this.sequence = 1;
|
||||
return this.sequence;
|
||||
}
|
||||
this.sequence += 1;
|
||||
return this.sequence;
|
||||
}
|
||||
|
||||
public buildGetModelData(deviceType: number, port: number): Buffer {
|
||||
const command = [16, 17, 18, 19, 20, 21, 22, 23];
|
||||
if (this.supportsPortSelection(deviceType)) {
|
||||
command.push(255, this.normalizePortByte(port));
|
||||
}
|
||||
return this.addHead(command, 1, this.nextSequence());
|
||||
}
|
||||
|
||||
public buildSetLevel(
|
||||
deviceType: number,
|
||||
workType: 1 | 2,
|
||||
level: number,
|
||||
port: number
|
||||
): Buffer {
|
||||
if (level < 0 || level > 10 || !Number.isInteger(level)) {
|
||||
throw new Error("level must be an integer between 0 and 10");
|
||||
}
|
||||
|
||||
const command = [16, 1, workType, workType + 16, 1, level];
|
||||
if (this.supportsPortSelection(deviceType)) {
|
||||
command.push(255, this.normalizePortByte(port));
|
||||
}
|
||||
return this.addHead(command, 3, this.nextSequence());
|
||||
}
|
||||
|
||||
public parseTelemetryNotification(data: Buffer): ParsedBleTelemetry {
|
||||
if (data.length < 18 || data[0] !== 0x1e || data[1] !== 0xff) {
|
||||
throw new Error("unexpected telemetry notification payload");
|
||||
}
|
||||
|
||||
const temperatureCelsius = getSignedShort(data, 8) / 100;
|
||||
const humidityPercent = getSignedShort(data, 10) / 100;
|
||||
const vpdKpa = getSignedShort(data, 12) / 100;
|
||||
const choosePort = getBits(data[7] ?? 0, 4, 4);
|
||||
const workType = getBits(data[17] ?? 0, 4, 4);
|
||||
const fanSpeedGuess = getBits(data[17] ?? 0, 0, 4);
|
||||
|
||||
return {
|
||||
temperatureCelsius,
|
||||
temperatureFahrenheit: (temperatureCelsius * 9) / 5 + 32,
|
||||
humidityPercent,
|
||||
vpdKpa,
|
||||
choosePort,
|
||||
workType,
|
||||
fanSpeedGuess
|
||||
};
|
||||
}
|
||||
|
||||
private supportsPortSelection(deviceType: number): boolean {
|
||||
return deviceType === 7 || deviceType === 9 || deviceType === 11 || deviceType === 12;
|
||||
}
|
||||
|
||||
private normalizePortByte(port: number): number {
|
||||
if (!Number.isInteger(port) || port < 0 || port > 255) {
|
||||
throw new Error("port must be an integer between 0 and 255");
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
private addHead(command: number[], commandType: number, sequence: number): Buffer {
|
||||
const result = new Array<number>(command.length + 12).fill(0);
|
||||
result[0] = 165;
|
||||
result[1] = 0;
|
||||
result[2] = (command.length >> 8) & 255;
|
||||
result[3] = command.length & 255;
|
||||
result[4] = (sequence >> 8) & 255;
|
||||
result[5] = sequence & 255;
|
||||
|
||||
const [crcA0, crcA1] = crc16CcittFalse(result, 0, 6);
|
||||
result[6] = crcA0;
|
||||
result[7] = crcA1;
|
||||
|
||||
result[8] = 0;
|
||||
result[9] = commandType;
|
||||
|
||||
for (let i = 0; i < command.length; i += 1) {
|
||||
result[10 + i] = command[i] ?? 0;
|
||||
}
|
||||
|
||||
const [crcB0, crcB1] = crc16CcittFalse(result, 8, command.length + 2);
|
||||
result[10 + command.length] = crcB0;
|
||||
result[11 + command.length] = crcB1;
|
||||
|
||||
return Buffer.from(result);
|
||||
}
|
||||
}
|
||||
16
src/ble/BleControllerClient.ts
Normal file
16
src/ble/BleControllerClient.ts
Normal file
@ -0,0 +1,16 @@
|
||||
export interface BleTelemetryReading {
|
||||
temperatureCelsius: number;
|
||||
temperatureFahrenheit: number;
|
||||
humidityPercent: number;
|
||||
vpdKpa: number;
|
||||
choosePort: number;
|
||||
workType: number;
|
||||
fanSpeedGuess: number;
|
||||
receivedAtEpochSeconds: number;
|
||||
}
|
||||
|
||||
export interface BleControllerClient {
|
||||
verifyConnection(macAddress: string): Promise<void>;
|
||||
readTelemetry(macAddress: string, requestPort: number): Promise<BleTelemetryReading>;
|
||||
setPortSpeed(macAddress: string, portByte: number, speedLevel: number): Promise<void>;
|
||||
}
|
||||
212
src/ble/NodeBleControllerClient.ts
Normal file
212
src/ble/NodeBleControllerClient.ts
Normal file
@ -0,0 +1,212 @@
|
||||
import NodeBle = require("node-ble");
|
||||
|
||||
import { Logger } from "../observability/Logger";
|
||||
import { AcInfinityProtocol } from "./AcInfinityProtocol";
|
||||
import { BleControllerClient, BleTelemetryReading } from "./BleControllerClient";
|
||||
|
||||
const READ_CHARACTERISTIC_UUIDS = [
|
||||
"70D51002-2C7F-4E75-AE8A-D758951CE4E0",
|
||||
"0000ff02-0000-1000-8000-00805f9b34fb"
|
||||
];
|
||||
|
||||
const WRITE_CHARACTERISTIC_UUIDS = [
|
||||
"70D51001-2C7F-4E75-AE8A-D758951CE4E0",
|
||||
"0000ff01-0000-1000-8000-00805f9b34fb"
|
||||
];
|
||||
|
||||
interface NodeBleClientOptions {
|
||||
requestTimeoutMs: number;
|
||||
scanTimeoutMs: number;
|
||||
deviceType: number;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
interface ResolvedCharacteristics {
|
||||
read: NodeBle.GattCharacteristic;
|
||||
write: NodeBle.GattCharacteristic;
|
||||
}
|
||||
|
||||
export class NodeBleControllerClient implements BleControllerClient {
|
||||
private readonly protocol = new AcInfinityProtocol();
|
||||
|
||||
public constructor(private readonly options: NodeBleClientOptions) {}
|
||||
|
||||
public async verifyConnection(macAddress: string): Promise<void> {
|
||||
await this.withDevice(macAddress, async () => {
|
||||
// no-op connection verification
|
||||
});
|
||||
}
|
||||
|
||||
public async readTelemetry(macAddress: string, requestPort: number): Promise<BleTelemetryReading> {
|
||||
return this.withDevice(macAddress, async (characteristics) => {
|
||||
const command = this.protocol.buildGetModelData(this.options.deviceType, requestPort);
|
||||
const wait = this.waitForNotification(characteristics.read, this.options.requestTimeoutMs);
|
||||
await characteristics.write.writeValue(command, { type: "request" });
|
||||
const response = await wait;
|
||||
const parsed = this.protocol.parseTelemetryNotification(response);
|
||||
|
||||
return {
|
||||
...parsed,
|
||||
receivedAtEpochSeconds: Math.floor(Date.now() / 1000)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public async setPortSpeed(
|
||||
macAddress: string,
|
||||
portByte: number,
|
||||
speedLevel: number
|
||||
): Promise<void> {
|
||||
await this.withDevice(macAddress, async (characteristics) => {
|
||||
const command = this.protocol.buildSetLevel(
|
||||
this.options.deviceType,
|
||||
speedLevel > 0 ? 2 : 1,
|
||||
speedLevel,
|
||||
portByte
|
||||
);
|
||||
|
||||
// Some controllers respond with a notify ack, some do not.
|
||||
// We wait briefly but do not hard-fail if no notification arrives.
|
||||
const wait = this.waitForNotification(
|
||||
characteristics.read,
|
||||
Math.min(3000, this.options.requestTimeoutMs)
|
||||
);
|
||||
|
||||
await characteristics.write.writeValue(command, { type: "request" });
|
||||
try {
|
||||
await wait;
|
||||
} catch (error) {
|
||||
this.options.logger.warn("ble set speed completed without notify ack", {
|
||||
error_message: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async withDevice<T>(
|
||||
macAddress: string,
|
||||
action: (characteristics: ResolvedCharacteristics) => Promise<T>
|
||||
): Promise<T> {
|
||||
const normalizedMac = macAddress.trim().toUpperCase();
|
||||
const { bluetooth, destroy } = NodeBle.createBluetooth();
|
||||
|
||||
let adapter: NodeBle.Adapter | null = null;
|
||||
let discoveryStarted = false;
|
||||
let device: NodeBle.Device | null = null;
|
||||
let read: NodeBle.GattCharacteristic | null = null;
|
||||
|
||||
try {
|
||||
adapter = await bluetooth.defaultAdapter();
|
||||
const isDiscovering = await adapter.isDiscovering();
|
||||
if (!isDiscovering) {
|
||||
await adapter.startDiscovery();
|
||||
discoveryStarted = true;
|
||||
}
|
||||
|
||||
this.options.logger.debug("waiting for ble device", {
|
||||
mac_address: normalizedMac,
|
||||
scan_timeout_ms: this.options.scanTimeoutMs
|
||||
});
|
||||
|
||||
device = await adapter.waitDevice(normalizedMac, this.options.scanTimeoutMs);
|
||||
await device.connect();
|
||||
|
||||
const gatt = await device.gatt();
|
||||
const resolved = await this.resolveCharacteristics(gatt);
|
||||
read = resolved.read;
|
||||
await read.startNotifications();
|
||||
|
||||
return await action(resolved);
|
||||
} finally {
|
||||
if (read) {
|
||||
try {
|
||||
await read.stopNotifications();
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
if (device) {
|
||||
try {
|
||||
await device.disconnect();
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
if (adapter && discoveryStarted) {
|
||||
try {
|
||||
await adapter.stopDiscovery();
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
destroy();
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveCharacteristics(gatt: NodeBle.GattServer): Promise<ResolvedCharacteristics> {
|
||||
const serviceUuids = await gatt.services();
|
||||
|
||||
let read: NodeBle.GattCharacteristic | null = null;
|
||||
let write: NodeBle.GattCharacteristic | null = null;
|
||||
|
||||
for (const serviceUuid of serviceUuids) {
|
||||
let service: NodeBle.GattService;
|
||||
try {
|
||||
service = await gatt.getPrimaryService(serviceUuid);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!read) {
|
||||
read = await this.tryResolveCharacteristic(service, READ_CHARACTERISTIC_UUIDS);
|
||||
}
|
||||
if (!write) {
|
||||
write = await this.tryResolveCharacteristic(service, WRITE_CHARACTERISTIC_UUIDS);
|
||||
}
|
||||
|
||||
if (read && write) {
|
||||
return { read, write };
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("failed to resolve AC Infinity BLE characteristics");
|
||||
}
|
||||
|
||||
private async tryResolveCharacteristic(
|
||||
service: NodeBle.GattService,
|
||||
uuids: string[]
|
||||
): Promise<NodeBle.GattCharacteristic | null> {
|
||||
for (const uuid of uuids) {
|
||||
try {
|
||||
return await service.getCharacteristic(uuid);
|
||||
} catch {
|
||||
// try next uuid
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async waitForNotification(
|
||||
characteristic: NodeBle.GattCharacteristic,
|
||||
timeoutMs: number
|
||||
): Promise<Buffer> {
|
||||
return new Promise<Buffer>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error(`timed out waiting for BLE notification after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
const onValue = (buffer: Buffer): void => {
|
||||
cleanup();
|
||||
resolve(buffer);
|
||||
};
|
||||
|
||||
const cleanup = (): void => {
|
||||
clearTimeout(timer);
|
||||
characteristic.removeListener("valuechanged", onValue);
|
||||
};
|
||||
|
||||
characteristic.on("valuechanged", onValue);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1,35 +1,62 @@
|
||||
import { DEFAULT_AC_INFINITY_HOST } from "../http/ApiConstants";
|
||||
|
||||
export type LogLevel = "debug" | "info" | "warn" | "error";
|
||||
export type TyphonMode = "cloud" | "ble";
|
||||
|
||||
const MAC_ADDRESS_RE = /^([0-9A-F]{2}:){5}[0-9A-F]{2}$/;
|
||||
|
||||
export class AppConfig {
|
||||
public constructor(
|
||||
public readonly aciEmail: string,
|
||||
public readonly aciPassword: string,
|
||||
public readonly mode: TyphonMode,
|
||||
public readonly aciEmail: string | null,
|
||||
public readonly aciPassword: string | null,
|
||||
public readonly aciHost: string,
|
||||
public readonly pollIntervalSeconds: number,
|
||||
public readonly listenPort: number,
|
||||
public readonly requestTimeoutMs: number,
|
||||
public readonly logLevel: LogLevel
|
||||
public readonly logLevel: LogLevel,
|
||||
public readonly enableControlApi: boolean,
|
||||
public readonly controlListenPort: number,
|
||||
public readonly bleDefaultMac: string | null,
|
||||
public readonly bleAllowedMacs: string[],
|
||||
public readonly bleDeviceType: number,
|
||||
public readonly bleScanTimeoutMs: number,
|
||||
public readonly blePortBase: 0 | 1
|
||||
) {}
|
||||
|
||||
public static fromEnv(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||
const aciEmail = this.getRequired(env, "ACI_EMAIL");
|
||||
const aciPassword = this.getRequired(env, "ACI_PASSWORD");
|
||||
const mode = this.parseMode(this.getOptional(env, "TYPHON_MODE", "cloud"));
|
||||
const aciEmail = mode === "cloud" ? this.getRequired(env, "ACI_EMAIL") : null;
|
||||
const aciPassword = mode === "cloud" ? this.getRequired(env, "ACI_PASSWORD") : null;
|
||||
const aciHost = this.getOptional(env, "ACI_HOST", DEFAULT_AC_INFINITY_HOST);
|
||||
const pollIntervalSeconds = this.parseNumber(env, "POLL_INTERVAL_SECONDS", 30, 5, 600);
|
||||
const listenPort = this.parseNumber(env, "LISTEN_PORT", 9108, 1, 65535);
|
||||
const requestTimeoutMs = this.parseNumber(env, "REQUEST_TIMEOUT_MS", 10000, 1000, 120000);
|
||||
const logLevel = this.parseLogLevel(this.getOptional(env, "LOG_LEVEL", "info"));
|
||||
const enableControlApi = this.parseBoolean(env, "ENABLE_CONTROL_API", false);
|
||||
const controlListenPort = this.parseNumber(env, "CONTROL_LISTEN_PORT", 9110, 1, 65535);
|
||||
const bleDefaultMac = this.parseOptionalMac(this.getOptional(env, "TY_BLE_DEFAULT_MAC", ""));
|
||||
const bleAllowedMacs = this.parseMacList(this.getOptional(env, "TY_BLE_ALLOWED_MACS", ""), bleDefaultMac);
|
||||
const bleDeviceType = this.parseNumber(env, "TY_BLE_DEVICE_TYPE", 11, 1, 255);
|
||||
const bleScanTimeoutMs = this.parseNumber(env, "TY_BLE_SCAN_TIMEOUT_MS", 20000, 1000, 120000);
|
||||
const blePortBase = this.parsePortBase(this.getOptional(env, "TY_BLE_PORT_BASE", "1"));
|
||||
|
||||
return new AppConfig(
|
||||
mode,
|
||||
aciEmail,
|
||||
aciPassword,
|
||||
aciHost,
|
||||
pollIntervalSeconds,
|
||||
listenPort,
|
||||
requestTimeoutMs,
|
||||
logLevel
|
||||
logLevel,
|
||||
enableControlApi,
|
||||
controlListenPort,
|
||||
bleDefaultMac,
|
||||
bleAllowedMacs,
|
||||
bleDeviceType,
|
||||
bleScanTimeoutMs,
|
||||
blePortBase
|
||||
);
|
||||
}
|
||||
|
||||
@ -74,4 +101,71 @@ export class AppConfig {
|
||||
}
|
||||
throw new Error(`LOG_LEVEL must be one of: debug, info, warn, error`);
|
||||
}
|
||||
|
||||
private static parseMode(value: string): TyphonMode {
|
||||
if (value === "cloud" || value === "ble") {
|
||||
return value;
|
||||
}
|
||||
throw new Error("TYPHON_MODE must be one of: cloud, ble");
|
||||
}
|
||||
|
||||
private static parseBoolean(
|
||||
env: NodeJS.ProcessEnv,
|
||||
key: string,
|
||||
fallback: boolean
|
||||
): boolean {
|
||||
const raw = env[key];
|
||||
if (!raw || raw.trim().length === 0) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const normalized = raw.trim().toLowerCase();
|
||||
if (normalized === "true" || normalized === "1" || normalized === "yes") {
|
||||
return true;
|
||||
}
|
||||
if (normalized === "false" || normalized === "0" || normalized === "no") {
|
||||
return false;
|
||||
}
|
||||
throw new Error(`${key} must be a boolean`);
|
||||
}
|
||||
|
||||
private static parseOptionalMac(value: string): string | null {
|
||||
const normalized = value.trim().toUpperCase();
|
||||
if (normalized.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (!MAC_ADDRESS_RE.test(normalized)) {
|
||||
throw new Error("TY_BLE_DEFAULT_MAC must be a MAC address like AA:BB:CC:DD:EE:FF");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static parseMacList(value: string, bleDefaultMac: string | null): string[] {
|
||||
const parsed = value
|
||||
.split(",")
|
||||
.map((part) => part.trim().toUpperCase())
|
||||
.filter((part) => part.length > 0);
|
||||
|
||||
for (const mac of parsed) {
|
||||
if (!MAC_ADDRESS_RE.test(mac)) {
|
||||
throw new Error("TY_BLE_ALLOWED_MACS must contain MAC addresses like AA:BB:CC:DD:EE:FF");
|
||||
}
|
||||
}
|
||||
|
||||
if (bleDefaultMac && !parsed.includes(bleDefaultMac)) {
|
||||
parsed.push(bleDefaultMac);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
private static parsePortBase(value: string): 0 | 1 {
|
||||
if (value === "0") {
|
||||
return 0;
|
||||
}
|
||||
if (value === "1") {
|
||||
return 1;
|
||||
}
|
||||
throw new Error("TY_BLE_PORT_BASE must be 0 or 1");
|
||||
}
|
||||
}
|
||||
|
||||
37
src/domain/ControlTypes.ts
Normal file
37
src/domain/ControlTypes.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import type { TyphonMode } from "../config/AppConfig";
|
||||
|
||||
export interface ControlCapabilities {
|
||||
pairing: boolean;
|
||||
portControl: boolean;
|
||||
advancedRules: boolean;
|
||||
}
|
||||
|
||||
export interface ControlPortState {
|
||||
port: number;
|
||||
name: string;
|
||||
fanGroup: string;
|
||||
currentSpeedLevel: number | null;
|
||||
online: boolean | null;
|
||||
powerState: boolean | null;
|
||||
}
|
||||
|
||||
export interface ControllerControlStatus {
|
||||
mode: TyphonMode;
|
||||
backend: string;
|
||||
controllerMac: string | null;
|
||||
connected: boolean;
|
||||
paired: boolean;
|
||||
telemetrySource: "cloud" | "ble";
|
||||
lastSnapshotEpochSeconds: number | null;
|
||||
capabilities: ControlCapabilities;
|
||||
ports: ControlPortState[];
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
export interface PairRequest {
|
||||
macAddress: string;
|
||||
}
|
||||
|
||||
export interface SetPortSpeedRequest {
|
||||
speedLevel: number;
|
||||
}
|
||||
25
src/domain/RuleTypes.ts
Normal file
25
src/domain/RuleTypes.ts
Normal file
@ -0,0 +1,25 @@
|
||||
export type RuleMode = "manual" | "climate_auto";
|
||||
|
||||
export interface RuleSet {
|
||||
mode: RuleMode;
|
||||
minimumHoldSeconds: number;
|
||||
temperatureTargetC: number;
|
||||
temperatureBandC: number;
|
||||
humidityTargetPercent: number;
|
||||
humidityBandPercent: number;
|
||||
updatedAtEpochSeconds: number;
|
||||
}
|
||||
|
||||
export type RuleSetUpdate = Partial<Omit<RuleSet, "updatedAtEpochSeconds">>;
|
||||
|
||||
export function defaultRuleSet(nowEpochSeconds = Math.floor(Date.now() / 1000)): RuleSet {
|
||||
return {
|
||||
mode: "manual",
|
||||
minimumHoldSeconds: 60,
|
||||
temperatureTargetC: 24,
|
||||
temperatureBandC: 0.5,
|
||||
humidityTargetPercent: 45,
|
||||
humidityBandPercent: 3,
|
||||
updatedAtEpochSeconds: nowEpochSeconds
|
||||
};
|
||||
}
|
||||
198
src/http/ControlApiServer.ts
Normal file
198
src/http/ControlApiServer.ts
Normal file
@ -0,0 +1,198 @@
|
||||
import http, { IncomingMessage } from "node:http";
|
||||
|
||||
import { Logger } from "../observability/Logger";
|
||||
import { ControlApiError } from "../services/ControlApiError";
|
||||
import { ControllerControlBackend } from "../services/ControllerControlBackend";
|
||||
import { RuleEngineService } from "../services/RuleEngineService";
|
||||
import { renderControlUi } from "../ui/template";
|
||||
|
||||
interface JsonObject {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export class ControlApiServer {
|
||||
private server: http.Server | null = null;
|
||||
|
||||
public constructor(
|
||||
private readonly port: number,
|
||||
private readonly backend: ControllerControlBackend,
|
||||
private readonly rules: RuleEngineService,
|
||||
private readonly logger: Logger,
|
||||
private readonly defaultMac: string | null
|
||||
) {}
|
||||
|
||||
public async start(): Promise<void> {
|
||||
this.server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
await this.handle(req, res);
|
||||
} catch (error) {
|
||||
this.handleError(error, res);
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
this.server?.listen(this.port, () => {
|
||||
this.logger.info("control api server started", { port: this.port });
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
if (!this.server) {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.server?.close((err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
this.server = null;
|
||||
}
|
||||
|
||||
private async handle(req: IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||
const method = req.method ?? "GET";
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
|
||||
if (url.pathname === "/healthz") {
|
||||
this.json(res, 200, { ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/ui" || url.pathname === "/") {
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.end(renderControlUi(this.defaultMac));
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "GET" && url.pathname === "/api/v2/status") {
|
||||
const status = await this.backend.getStatus();
|
||||
this.json(res, 200, {
|
||||
...status,
|
||||
defaultMac: this.defaultMac
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "GET" && url.pathname === "/api/v2/ports") {
|
||||
const ports = await this.backend.getPorts();
|
||||
this.json(res, 200, { ports });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "POST" && url.pathname === "/api/v2/pair") {
|
||||
const body = await this.readJson(req);
|
||||
const macFromBody = this.extractString(body, "macAddress") ?? this.extractString(body, "mac_address");
|
||||
const mac = macFromBody ?? this.defaultMac;
|
||||
if (!mac) {
|
||||
throw new ControlApiError("mac address is required", 400);
|
||||
}
|
||||
const status = await this.backend.pair(mac);
|
||||
this.json(res, 200, status);
|
||||
return;
|
||||
}
|
||||
|
||||
const speedMatch = url.pathname.match(/^\/api\/v2\/ports\/(\d+)\/speed$/);
|
||||
if (method === "POST" && speedMatch?.[1]) {
|
||||
const port = Number(speedMatch[1]);
|
||||
const body = await this.readJson(req);
|
||||
const speedValue = this.extractNumber(body, "speedLevel") ?? this.extractNumber(body, "speed_level");
|
||||
if (speedValue === null) {
|
||||
throw new ControlApiError("speed_level is required", 400);
|
||||
}
|
||||
await this.backend.setPortSpeed(port, speedValue);
|
||||
this.json(res, 200, { ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "GET" && url.pathname === "/api/v2/rules") {
|
||||
this.json(res, 200, { rules: this.rules.getRules() });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "POST" && url.pathname === "/api/v2/rules") {
|
||||
const body = await this.readJson(req);
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
||||
throw new ControlApiError("rules body must be an object", 400);
|
||||
}
|
||||
const updated = this.rules.updateRules(body);
|
||||
this.json(res, 200, { rules: updated });
|
||||
return;
|
||||
}
|
||||
|
||||
this.json(res, 404, { error: "not found" });
|
||||
}
|
||||
|
||||
private async readJson(req: IncomingMessage): Promise<JsonObject> {
|
||||
const chunks: Buffer[] = [];
|
||||
let totalBytes = 0;
|
||||
const maxBytes = 64 * 1024;
|
||||
|
||||
for await (const chunk of req) {
|
||||
const asBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
totalBytes += asBuffer.length;
|
||||
if (totalBytes > maxBytes) {
|
||||
throw new ControlApiError("request body too large", 413);
|
||||
}
|
||||
chunks.push(asBuffer);
|
||||
}
|
||||
|
||||
if (chunks.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||
} catch {
|
||||
throw new ControlApiError("request body must be valid JSON", 400);
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new ControlApiError("request body must be a JSON object", 400);
|
||||
}
|
||||
|
||||
return parsed as JsonObject;
|
||||
}
|
||||
|
||||
private extractString(body: JsonObject, key: string): string | null {
|
||||
const value = body[key];
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
private extractNumber(body: JsonObject, key: string): number | null {
|
||||
const value = body[key];
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private json(res: http.ServerResponse, statusCode: number, payload: object): void {
|
||||
res.statusCode = statusCode;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
private handleError(error: unknown, res: http.ServerResponse): void {
|
||||
if (error instanceof ControlApiError) {
|
||||
this.json(res, error.statusCode, { error: error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.error("control api request failed", {
|
||||
error_message: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
this.json(res, 500, { error: "internal server error" });
|
||||
}
|
||||
}
|
||||
@ -9,6 +9,7 @@ import {
|
||||
AcInfinityMode,
|
||||
ClimateSnapshot
|
||||
} from "../domain/ClimateSnapshot";
|
||||
import type { TyphonMode } from "../config/AppConfig";
|
||||
|
||||
const MODE_LABELS: readonly AcInfinityMode[] = [
|
||||
AcInfinityMode.Off,
|
||||
@ -49,6 +50,7 @@ export class TyphonMetrics {
|
||||
private readonly modeGauge: Gauge<"controller_id" | "controller_name" | "port" | "port_name" | "fan_group" | "mode">;
|
||||
|
||||
private readonly buildInfo: Gauge<"version">;
|
||||
private readonly runtimeMode: Gauge<"mode">;
|
||||
|
||||
private readonly resettable: Array<
|
||||
Gauge<"controller_id" | "controller_name"> |
|
||||
@ -180,6 +182,12 @@ export class TyphonMetrics {
|
||||
labelNames: ["version"],
|
||||
registers: [this.registry]
|
||||
});
|
||||
this.runtimeMode = new Gauge({
|
||||
name: "typhon_runtime_mode",
|
||||
help: "Typhon runtime mode (one-hot by mode label)",
|
||||
labelNames: ["mode"],
|
||||
registers: [this.registry]
|
||||
});
|
||||
|
||||
this.resettable = [
|
||||
this.controllerOnline,
|
||||
@ -201,6 +209,8 @@ export class TyphonMetrics {
|
||||
this.exporterUp.set(0);
|
||||
this.dataAgeSeconds.set(0);
|
||||
this.buildInfo.labels(version).set(1);
|
||||
this.runtimeMode.labels("cloud").set(1);
|
||||
this.runtimeMode.labels("ble").set(0);
|
||||
}
|
||||
|
||||
public getRegistry(): Registry {
|
||||
@ -279,4 +289,9 @@ export class TyphonMetrics {
|
||||
}
|
||||
this.dataAgeSeconds.set(Math.max(0, nowEpochSeconds - this.lastSuccessEpoch));
|
||||
}
|
||||
|
||||
public setRuntimeMode(mode: TyphonMode): void {
|
||||
this.runtimeMode.labels("cloud").set(mode === "cloud" ? 1 : 0);
|
||||
this.runtimeMode.labels("ble").set(mode === "ble" ? 1 : 0);
|
||||
}
|
||||
}
|
||||
|
||||
266
src/services/BleControlBackend.ts
Normal file
266
src/services/BleControlBackend.ts
Normal file
@ -0,0 +1,266 @@
|
||||
import { AcInfinityMode, ClimateSnapshot, ControllerClimate, PortClimate } from "../domain/ClimateSnapshot";
|
||||
import { ControlPortState, ControllerControlStatus } from "../domain/ControlTypes";
|
||||
import { Logger } from "../observability/Logger";
|
||||
import { BleControllerClient, BleTelemetryReading } from "../ble/BleControllerClient";
|
||||
import { NodeBleControllerClient } from "../ble/NodeBleControllerClient";
|
||||
import { ControlApiError } from "./ControlApiError";
|
||||
import { ControllerControlBackend } from "./ControllerControlBackend";
|
||||
|
||||
const MAC_ADDRESS_RE = /^([0-9A-F]{2}:){5}[0-9A-F]{2}$/;
|
||||
|
||||
interface BleControlBackendOptions {
|
||||
defaultMac: string | null;
|
||||
allowedMacs: string[];
|
||||
requestTimeoutMs: number;
|
||||
scanTimeoutMs: number;
|
||||
deviceType: number;
|
||||
portBase: 0 | 1;
|
||||
logger: Logger;
|
||||
client?: BleControllerClient;
|
||||
}
|
||||
|
||||
export class BleControlBackend implements ControllerControlBackend {
|
||||
private pairedMac: string | null;
|
||||
private connected = false;
|
||||
private readonly portSpeeds = new Map<number, number>();
|
||||
private readonly allowedMacs: Set<string>;
|
||||
private readonly client: BleControllerClient;
|
||||
private readonly logger: Logger;
|
||||
private readonly portBase: 0 | 1;
|
||||
|
||||
private lastTelemetry: BleTelemetryReading | null = null;
|
||||
private lastError: string | null = null;
|
||||
|
||||
private queue: Promise<void> = Promise.resolve();
|
||||
|
||||
public constructor(options: BleControlBackendOptions) {
|
||||
this.pairedMac = options.defaultMac;
|
||||
this.allowedMacs = new Set(options.allowedMacs);
|
||||
this.logger = options.logger;
|
||||
this.portBase = options.portBase;
|
||||
this.client = options.client ?? new NodeBleControllerClient({
|
||||
requestTimeoutMs: options.requestTimeoutMs,
|
||||
scanTimeoutMs: options.scanTimeoutMs,
|
||||
deviceType: options.deviceType,
|
||||
logger: options.logger
|
||||
});
|
||||
|
||||
for (let port = 1; port <= 4; port += 1) {
|
||||
this.portSpeeds.set(port, 0);
|
||||
}
|
||||
}
|
||||
|
||||
public async getStatus(): Promise<ControllerControlStatus> {
|
||||
const notes: string[] = [
|
||||
"BLE backend active via BlueZ DBus.",
|
||||
"Per-port writes use AC Infinity protocol with port selector bytes."
|
||||
];
|
||||
|
||||
if (this.lastError) {
|
||||
notes.push(`last_error=${this.lastError}`);
|
||||
}
|
||||
|
||||
return {
|
||||
mode: "ble",
|
||||
backend: "ble-node-bluez",
|
||||
controllerMac: this.pairedMac,
|
||||
connected: this.connected,
|
||||
paired: this.pairedMac !== null,
|
||||
telemetrySource: "ble",
|
||||
lastSnapshotEpochSeconds: this.lastTelemetry?.receivedAtEpochSeconds ?? null,
|
||||
capabilities: {
|
||||
pairing: true,
|
||||
portControl: true,
|
||||
advancedRules: true
|
||||
},
|
||||
ports: await this.getPorts(),
|
||||
notes
|
||||
};
|
||||
}
|
||||
|
||||
public async pair(macAddress: string): Promise<ControllerControlStatus> {
|
||||
const normalized = this.normalizeMac(macAddress);
|
||||
this.assertAllowedMac(normalized);
|
||||
|
||||
await this.synchronized(async () => {
|
||||
this.logger.info("attempting ble pair/connect", { mac_address: normalized });
|
||||
try {
|
||||
await this.client.verifyConnection(normalized);
|
||||
this.pairedMac = normalized;
|
||||
this.connected = true;
|
||||
this.lastError = null;
|
||||
} catch (error) {
|
||||
this.connected = false;
|
||||
this.lastError = error instanceof Error ? error.message : String(error);
|
||||
throw new ControlApiError(`failed to pair/connect over BLE: ${this.lastError}`, 502);
|
||||
}
|
||||
});
|
||||
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
public async getPorts(): Promise<ControlPortState[]> {
|
||||
const ports: ControlPortState[] = [];
|
||||
for (let port = 1; port <= 4; port += 1) {
|
||||
const speed = this.portSpeeds.get(port) ?? 0;
|
||||
ports.push({
|
||||
port,
|
||||
name: `Port ${port}`,
|
||||
fanGroup: this.defaultFanGroup(port),
|
||||
currentSpeedLevel: speed,
|
||||
online: this.connected,
|
||||
powerState: speed > 0
|
||||
});
|
||||
}
|
||||
return ports;
|
||||
}
|
||||
|
||||
public async setPortSpeed(port: number, speedLevel: number): Promise<void> {
|
||||
if (!Number.isInteger(port) || port < 1 || port > 4) {
|
||||
throw new ControlApiError("port must be an integer between 1 and 4", 400);
|
||||
}
|
||||
if (!Number.isInteger(speedLevel) || speedLevel < 0 || speedLevel > 10) {
|
||||
throw new ControlApiError("speed_level must be an integer between 0 and 10", 400);
|
||||
}
|
||||
|
||||
const mac = this.requireMac();
|
||||
|
||||
await this.synchronized(async () => {
|
||||
this.logger.debug("setting ble fan speed", { port, speed_level: speedLevel });
|
||||
try {
|
||||
await this.client.setPortSpeed(mac, this.toDevicePort(port), speedLevel);
|
||||
this.portSpeeds.set(port, speedLevel);
|
||||
this.connected = true;
|
||||
this.lastError = null;
|
||||
} catch (error) {
|
||||
this.connected = false;
|
||||
this.lastError = error instanceof Error ? error.message : String(error);
|
||||
throw new ControlApiError(`failed to set BLE fan speed: ${this.lastError}`, 502);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async refreshTelemetry(): Promise<ClimateSnapshot> {
|
||||
const mac = this.requireMac();
|
||||
|
||||
await this.synchronized(async () => {
|
||||
try {
|
||||
// 0 requests controller-level telemetry regardless of active port selection.
|
||||
const telemetry = await this.client.readTelemetry(mac, 0);
|
||||
this.lastTelemetry = telemetry;
|
||||
this.connected = true;
|
||||
this.lastError = null;
|
||||
|
||||
const derivedPort = this.fromDevicePort(telemetry.choosePort);
|
||||
if (derivedPort >= 1 && derivedPort <= 4 && telemetry.fanSpeedGuess >= 0 && telemetry.fanSpeedGuess <= 10) {
|
||||
this.portSpeeds.set(derivedPort, telemetry.fanSpeedGuess);
|
||||
}
|
||||
} catch (error) {
|
||||
this.connected = false;
|
||||
this.lastError = error instanceof Error ? error.message : String(error);
|
||||
throw new ControlApiError(`failed to read BLE telemetry: ${this.lastError}`, 502);
|
||||
}
|
||||
});
|
||||
|
||||
return this.buildSnapshot();
|
||||
}
|
||||
|
||||
public buildSnapshot(nowEpochSeconds = Math.floor(Date.now() / 1000)): ClimateSnapshot {
|
||||
const reading = this.lastTelemetry;
|
||||
const controller = new ControllerClimate(
|
||||
this.pairedMac ?? "ble-controller",
|
||||
"AC Infinity 69 Pro",
|
||||
this.connected,
|
||||
reading?.temperatureCelsius ?? 0,
|
||||
(reading?.humidityPercent ?? 0) / 100,
|
||||
reading?.vpdKpa ?? 0,
|
||||
11,
|
||||
true,
|
||||
this.buildPortSnapshots()
|
||||
);
|
||||
|
||||
return new ClimateSnapshot(nowEpochSeconds, [controller]);
|
||||
}
|
||||
|
||||
private buildPortSnapshots(): PortClimate[] {
|
||||
const ports: PortClimate[] = [];
|
||||
for (let port = 1; port <= 4; port += 1) {
|
||||
const speed = this.portSpeeds.get(port) ?? 0;
|
||||
const mode = speed > 0 ? AcInfinityMode.On : AcInfinityMode.Off;
|
||||
ports.push(
|
||||
new PortClimate(
|
||||
port,
|
||||
`Port ${port}`,
|
||||
this.defaultFanGroup(port),
|
||||
this.connected,
|
||||
speed > 0,
|
||||
speed,
|
||||
speed,
|
||||
0,
|
||||
true,
|
||||
null,
|
||||
mode
|
||||
)
|
||||
);
|
||||
}
|
||||
return ports;
|
||||
}
|
||||
|
||||
private requireMac(): string {
|
||||
if (!this.pairedMac) {
|
||||
throw new ControlApiError("controller mac address is not paired; run /api/v2/pair first", 412);
|
||||
}
|
||||
return this.pairedMac;
|
||||
}
|
||||
|
||||
private normalizeMac(value: string): string {
|
||||
const normalized = value.trim().toUpperCase();
|
||||
if (!MAC_ADDRESS_RE.test(normalized)) {
|
||||
throw new ControlApiError("mac address must look like AA:BB:CC:DD:EE:FF", 400);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private assertAllowedMac(macAddress: string): void {
|
||||
if (this.allowedMacs.size > 0 && !this.allowedMacs.has(macAddress)) {
|
||||
throw new ControlApiError("mac address is not in TY_BLE_ALLOWED_MACS", 403);
|
||||
}
|
||||
}
|
||||
|
||||
private defaultFanGroup(port: number): string {
|
||||
if (port === 1) {
|
||||
return "outlet";
|
||||
}
|
||||
if (port === 2) {
|
||||
return "inside_inlet";
|
||||
}
|
||||
if (port === 3) {
|
||||
return "outside_inlet";
|
||||
}
|
||||
return "interior";
|
||||
}
|
||||
|
||||
private toDevicePort(port: number): number {
|
||||
return port - this.portBase;
|
||||
}
|
||||
|
||||
private fromDevicePort(portByte: number): number {
|
||||
return portByte + this.portBase;
|
||||
}
|
||||
|
||||
private async synchronized<T>(task: () => Promise<T>): Promise<T> {
|
||||
const previous = this.queue;
|
||||
let release: (() => void) | undefined;
|
||||
this.queue = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
|
||||
await previous;
|
||||
|
||||
try {
|
||||
return await task();
|
||||
} finally {
|
||||
release?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
53
src/services/BlePollingService.ts
Normal file
53
src/services/BlePollingService.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { TyphonMetrics } from "../metrics/TyphonMetrics";
|
||||
import { Logger } from "../observability/Logger";
|
||||
import { BleControlBackend } from "./BleControlBackend";
|
||||
|
||||
export class BlePollingService {
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
private inFlight = false;
|
||||
|
||||
public constructor(
|
||||
private readonly backend: BleControlBackend,
|
||||
private readonly metrics: TyphonMetrics,
|
||||
private readonly logger: Logger,
|
||||
private readonly pollIntervalSeconds: number
|
||||
) {}
|
||||
|
||||
public start(): void {
|
||||
void this.pollOnce();
|
||||
this.timer = setInterval(() => {
|
||||
void this.pollOnce();
|
||||
}, this.pollIntervalSeconds * 1000);
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async pollOnce(): Promise<void> {
|
||||
if (this.inFlight) {
|
||||
this.logger.warn("skipping ble poll because previous poll is still running");
|
||||
return;
|
||||
}
|
||||
|
||||
this.inFlight = true;
|
||||
try {
|
||||
const snapshot = await this.backend.refreshTelemetry();
|
||||
this.metrics.updateFromSnapshot(snapshot);
|
||||
this.logger.info("ble poll succeeded", {
|
||||
controller_count: snapshot.controllers.length
|
||||
});
|
||||
} catch (error) {
|
||||
this.metrics.markPollFailure("ble", "ble_error");
|
||||
this.logger.error("ble poll failed", {
|
||||
error_message: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
} finally {
|
||||
this.metrics.refreshDataAgeGauge();
|
||||
this.inFlight = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import { AcInfinityApiClient, AcInfinityApiError } from "../http/AcInfinityApiClient";
|
||||
import { TyphonMetrics } from "../metrics/TyphonMetrics";
|
||||
import { Logger } from "../observability/Logger";
|
||||
import { ClimateSnapshot } from "../domain/ClimateSnapshot";
|
||||
|
||||
export class ClimatePollingService {
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
@ -10,7 +11,8 @@ export class ClimatePollingService {
|
||||
private readonly client: AcInfinityApiClient,
|
||||
private readonly metrics: TyphonMetrics,
|
||||
private readonly logger: Logger,
|
||||
private readonly pollIntervalSeconds: number
|
||||
private readonly pollIntervalSeconds: number,
|
||||
private readonly onSnapshot?: (snapshot: ClimateSnapshot) => void
|
||||
) {}
|
||||
|
||||
public start(): void {
|
||||
@ -37,6 +39,7 @@ export class ClimatePollingService {
|
||||
try {
|
||||
const snapshot = await this.client.fetchSnapshot();
|
||||
this.metrics.updateFromSnapshot(snapshot);
|
||||
this.onSnapshot?.(snapshot);
|
||||
this.logger.info("poll succeeded", {
|
||||
controller_count: snapshot.controllers.length
|
||||
});
|
||||
|
||||
69
src/services/CloudControlBackend.ts
Normal file
69
src/services/CloudControlBackend.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import { ControlPortState, ControllerControlStatus } from "../domain/ControlTypes";
|
||||
import { ControlApiError } from "./ControlApiError";
|
||||
import { ControllerControlBackend } from "./ControllerControlBackend";
|
||||
import { TelemetryCache } from "./TelemetryCache";
|
||||
|
||||
export class CloudControlBackend implements ControllerControlBackend {
|
||||
public constructor(private readonly cache: TelemetryCache) {}
|
||||
|
||||
public async getStatus(): Promise<ControllerControlStatus> {
|
||||
const latest = this.cache.getLatest();
|
||||
const firstController = latest?.controllers[0] ?? null;
|
||||
const connected = latest?.controllers.some((controller) => controller.online) ?? false;
|
||||
|
||||
return {
|
||||
mode: "cloud",
|
||||
backend: "cloud-api",
|
||||
controllerMac: null,
|
||||
connected,
|
||||
paired: false,
|
||||
telemetrySource: "cloud",
|
||||
lastSnapshotEpochSeconds: latest?.collectedAtEpochSeconds ?? null,
|
||||
capabilities: {
|
||||
pairing: false,
|
||||
portControl: false,
|
||||
advancedRules: true
|
||||
},
|
||||
ports: this.mapPorts(firstController?.ports ?? []),
|
||||
notes: [
|
||||
"Control write APIs are disabled in cloud mode during v2 buildout."
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
public async pair(): Promise<ControllerControlStatus> {
|
||||
throw new ControlApiError("pairing is not supported in cloud mode", 409);
|
||||
}
|
||||
|
||||
public async getPorts(): Promise<ControlPortState[]> {
|
||||
const latest = this.cache.getLatest();
|
||||
const firstController = latest?.controllers[0] ?? null;
|
||||
return this.mapPorts(firstController?.ports ?? []);
|
||||
}
|
||||
|
||||
public async setPortSpeed(): Promise<void> {
|
||||
throw new ControlApiError("per-port speed writes are not supported in cloud mode", 409);
|
||||
}
|
||||
|
||||
private mapPorts(
|
||||
ports: Array<{
|
||||
port: number;
|
||||
name: string;
|
||||
fanGroup: string;
|
||||
currentSpeedLevel: number;
|
||||
online: boolean;
|
||||
powerState: boolean;
|
||||
}>
|
||||
): ControlPortState[] {
|
||||
return ports
|
||||
.map((port) => ({
|
||||
port: port.port,
|
||||
name: port.name,
|
||||
fanGroup: port.fanGroup,
|
||||
currentSpeedLevel: port.currentSpeedLevel,
|
||||
online: port.online,
|
||||
powerState: port.powerState
|
||||
}))
|
||||
.sort((a, b) => a.port - b.port);
|
||||
}
|
||||
}
|
||||
9
src/services/ControlApiError.ts
Normal file
9
src/services/ControlApiError.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export class ControlApiError extends Error {
|
||||
public constructor(
|
||||
message: string,
|
||||
public readonly statusCode: number
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ControlApiError";
|
||||
}
|
||||
}
|
||||
8
src/services/ControllerControlBackend.ts
Normal file
8
src/services/ControllerControlBackend.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { ControlPortState, ControllerControlStatus } from "../domain/ControlTypes";
|
||||
|
||||
export interface ControllerControlBackend {
|
||||
getStatus(): Promise<ControllerControlStatus>;
|
||||
pair(macAddress: string): Promise<ControllerControlStatus>;
|
||||
getPorts(): Promise<ControlPortState[]>;
|
||||
setPortSpeed(port: number, speedLevel: number): Promise<void>;
|
||||
}
|
||||
47
src/services/RuleEngineService.ts
Normal file
47
src/services/RuleEngineService.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { RuleSet, RuleSetUpdate, defaultRuleSet } from "../domain/RuleTypes";
|
||||
import { ControlApiError } from "./ControlApiError";
|
||||
|
||||
export class RuleEngineService {
|
||||
private ruleSet: RuleSet;
|
||||
|
||||
public constructor(initialRuleSet = defaultRuleSet()) {
|
||||
this.ruleSet = initialRuleSet;
|
||||
}
|
||||
|
||||
public getRules(): RuleSet {
|
||||
return { ...this.ruleSet };
|
||||
}
|
||||
|
||||
public updateRules(update: RuleSetUpdate, nowEpochSeconds = Math.floor(Date.now() / 1000)): RuleSet {
|
||||
const merged: RuleSet = {
|
||||
...this.ruleSet,
|
||||
...update,
|
||||
updatedAtEpochSeconds: nowEpochSeconds
|
||||
};
|
||||
|
||||
this.assertValid(merged);
|
||||
this.ruleSet = merged;
|
||||
return { ...this.ruleSet };
|
||||
}
|
||||
|
||||
private assertValid(ruleSet: RuleSet): void {
|
||||
if (ruleSet.mode !== "manual" && ruleSet.mode !== "climate_auto") {
|
||||
throw new ControlApiError("rules.mode must be one of: manual, climate_auto", 400);
|
||||
}
|
||||
|
||||
if (!Number.isInteger(ruleSet.minimumHoldSeconds) || ruleSet.minimumHoldSeconds < 5 || ruleSet.minimumHoldSeconds > 7200) {
|
||||
throw new ControlApiError("rules.minimumHoldSeconds must be an integer between 5 and 7200", 400);
|
||||
}
|
||||
|
||||
this.assertRange(ruleSet.temperatureTargetC, "rules.temperatureTargetC", 5, 45);
|
||||
this.assertRange(ruleSet.temperatureBandC, "rules.temperatureBandC", 0.1, 10);
|
||||
this.assertRange(ruleSet.humidityTargetPercent, "rules.humidityTargetPercent", 10, 90);
|
||||
this.assertRange(ruleSet.humidityBandPercent, "rules.humidityBandPercent", 1, 30);
|
||||
}
|
||||
|
||||
private assertRange(value: number, field: string, min: number, max: number): void {
|
||||
if (!Number.isFinite(value) || value < min || value > max) {
|
||||
throw new ControlApiError(`${field} must be between ${min} and ${max}`, 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
13
src/services/TelemetryCache.ts
Normal file
13
src/services/TelemetryCache.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { ClimateSnapshot } from "../domain/ClimateSnapshot";
|
||||
|
||||
export class TelemetryCache {
|
||||
private latestSnapshot: ClimateSnapshot | null = null;
|
||||
|
||||
public setLatest(snapshot: ClimateSnapshot): void {
|
||||
this.latestSnapshot = snapshot;
|
||||
}
|
||||
|
||||
public getLatest(): ClimateSnapshot | null {
|
||||
return this.latestSnapshot;
|
||||
}
|
||||
}
|
||||
116
src/ui/template.ts
Normal file
116
src/ui/template.ts
Normal file
@ -0,0 +1,116 @@
|
||||
function esc(value: string): string {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
export function renderControlUi(defaultMac: string | null): string {
|
||||
const safeMac = esc(defaultMac ?? "");
|
||||
return [
|
||||
"<!doctype html>",
|
||||
"<html lang=\"en\">",
|
||||
"<head>",
|
||||
" <meta charset=\"utf-8\" />",
|
||||
" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />",
|
||||
" <title>Typhon v2 Control</title>",
|
||||
" <style>",
|
||||
" body { font-family: sans-serif; margin: 1rem; color: #e8edf2; background: #111822; }",
|
||||
" h1 { margin-top: 0; }",
|
||||
" .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 0.75rem; }",
|
||||
" .card { background: #1a2431; border: 1px solid #2a3647; border-radius: 8px; padding: 0.8rem; }",
|
||||
" button { cursor: pointer; }",
|
||||
" input, button { padding: 0.4rem 0.6rem; border-radius: 6px; border: 1px solid #3c4b60; background: #111822; color: #e8edf2; }",
|
||||
" .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }",
|
||||
" .muted { opacity: 0.8; font-size: 0.9rem; }",
|
||||
" </style>",
|
||||
"</head>",
|
||||
"<body>",
|
||||
" <h1>Typhon v2 Control (Scaffold)</h1>",
|
||||
" <div class=\"card\">",
|
||||
` <div>Default MAC (Vault): <span class=\"mono\">${safeMac || "not set"}</span></div>`,
|
||||
" <form id=\"pair-form\" style=\"margin-top: 0.6rem; display: flex; gap: 0.5rem; flex-wrap: wrap;\">",
|
||||
` <input id=\"mac-input\" name=\"mac\" placeholder=\"AA:BB:CC:DD:EE:FF\" value=\"${safeMac}\" />`,
|
||||
" <button type=\"submit\">Pair</button>",
|
||||
" </form>",
|
||||
" <div id=\"pair-result\" class=\"muted\" style=\"margin-top: 0.5rem;\"></div>",
|
||||
" </div>",
|
||||
"",
|
||||
" <h2>Ports</h2>",
|
||||
" <div id=\"ports\" class=\"grid\"></div>",
|
||||
"",
|
||||
" <script>",
|
||||
" async function refresh() {",
|
||||
" const statusRes = await fetch('/api/v2/status');",
|
||||
" const status = await statusRes.json();",
|
||||
" const portsRes = await fetch('/api/v2/ports');",
|
||||
" const portsJson = await portsRes.json();",
|
||||
" const ports = Array.isArray(portsJson.ports) ? portsJson.ports : [];",
|
||||
" const container = document.getElementById('ports');",
|
||||
" container.innerHTML = '';",
|
||||
"",
|
||||
" for (const port of ports) {",
|
||||
" const card = document.createElement('div');",
|
||||
" card.className = 'card';",
|
||||
" const speed = Number.isFinite(port.currentSpeedLevel) ? port.currentSpeedLevel : 0;",
|
||||
" const title = port.name || ('Port ' + port.port);",
|
||||
" const group = port.fanGroup || 'unknown';",
|
||||
" card.innerHTML = '<div><strong>' + title + '</strong></div>' +",
|
||||
" '<div class=\"muted\">Group: ' + group + '</div>' +",
|
||||
" '<div style=\"margin-top:0.4rem;\">Current speed: <span class=\"mono\">' + speed + '</span></div>' +",
|
||||
" '<form data-port=\"' + port.port + '\" style=\"margin-top:0.5rem; display:flex; gap:0.4rem; align-items:center;\">' +",
|
||||
" '<input type=\"number\" min=\"0\" max=\"10\" step=\"1\" value=\"' + speed + '\" style=\"width:70px\" />' +",
|
||||
" '<button type=\"submit\">Set</button>' +",
|
||||
" '</form>';",
|
||||
" container.appendChild(card);",
|
||||
" }",
|
||||
"",
|
||||
" container.querySelectorAll('form[data-port]').forEach((form) => {",
|
||||
" form.addEventListener('submit', async (event) => {",
|
||||
" event.preventDefault();",
|
||||
" const port = Number(form.getAttribute('data-port'));",
|
||||
" const input = form.querySelector('input[type=\"number\"]');",
|
||||
" const speed = Number(input.value);",
|
||||
" await fetch('/api/v2/ports/' + port + '/speed', {",
|
||||
" method: 'POST',",
|
||||
" headers: { 'content-type': 'application/json' },",
|
||||
" body: JSON.stringify({ speed_level: speed })",
|
||||
" });",
|
||||
" await refresh();",
|
||||
" });",
|
||||
" });",
|
||||
"",
|
||||
" const pairResult = document.getElementById('pair-result');",
|
||||
" pairResult.textContent = 'Backend: ' + (status.backend || 'unknown') + ' | Connected: ' + String(status.connected) + ' | Mode: ' + status.mode;",
|
||||
" }",
|
||||
"",
|
||||
" document.getElementById('pair-form').addEventListener('submit', async (event) => {",
|
||||
" event.preventDefault();",
|
||||
" const input = document.getElementById('mac-input');",
|
||||
" const result = document.getElementById('pair-result');",
|
||||
" const mac = input.value;",
|
||||
" const response = await fetch('/api/v2/pair', {",
|
||||
" method: 'POST',",
|
||||
" headers: { 'content-type': 'application/json' },",
|
||||
" body: JSON.stringify({ mac_address: mac })",
|
||||
" });",
|
||||
" const payload = await response.json();",
|
||||
" if (!response.ok) {",
|
||||
" result.textContent = payload.error || 'pair failed';",
|
||||
" return;",
|
||||
" }",
|
||||
" result.textContent = 'paired: ' + (payload.controllerMac || mac);",
|
||||
" await refresh();",
|
||||
" });",
|
||||
"",
|
||||
" refresh().catch((err) => {",
|
||||
" const el = document.getElementById('pair-result');",
|
||||
" el.textContent = String(err);",
|
||||
" });",
|
||||
" </script>",
|
||||
"</body>",
|
||||
"</html>"
|
||||
].join("\n");
|
||||
}
|
||||
51
tests/AcInfinityProtocol.test.ts
Normal file
51
tests/AcInfinityProtocol.test.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { AcInfinityProtocol } from "../src/ble/AcInfinityProtocol";
|
||||
|
||||
describe("AcInfinityProtocol", () => {
|
||||
it("builds get-model-data command with port selector for supported device types", () => {
|
||||
const protocol = new AcInfinityProtocol();
|
||||
const packet = protocol.buildGetModelData(11, 2);
|
||||
|
||||
expect(packet[0]).toBe(0xa5);
|
||||
expect(packet[9]).toBe(1);
|
||||
expect(Array.from(packet.slice(10, 20))).toEqual([
|
||||
16, 17, 18, 19, 20, 21, 22, 23, 255, 2
|
||||
]);
|
||||
});
|
||||
|
||||
it("builds set-level command with explicit port byte", () => {
|
||||
const protocol = new AcInfinityProtocol();
|
||||
const packet = protocol.buildSetLevel(11, 2, 5, 3);
|
||||
|
||||
expect(packet[0]).toBe(0xa5);
|
||||
expect(packet[9]).toBe(3);
|
||||
expect(Array.from(packet.slice(10, 18))).toEqual([
|
||||
16, 1, 2, 18, 1, 5, 255, 3
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses telemetry notifications", () => {
|
||||
const protocol = new AcInfinityProtocol();
|
||||
|
||||
const data = Buffer.alloc(20, 0);
|
||||
data[0] = 0x1e;
|
||||
data[1] = 0xff;
|
||||
data[7] = 0x03; // choose_port raw byte
|
||||
data[8] = 0x09;
|
||||
data[9] = 0x6c; // 24.12C
|
||||
data[10] = 0x11;
|
||||
data[11] = 0x9e; // 45.10%
|
||||
data[12] = 0x00;
|
||||
data[13] = 0xb0; // 1.76kPa
|
||||
data[17] = 0x62; // fanSpeedGuess=6, workType=2
|
||||
|
||||
const parsed = protocol.parseTelemetryNotification(data);
|
||||
|
||||
expect(parsed.temperatureCelsius).toBeCloseTo(24.12, 2);
|
||||
expect(parsed.temperatureFahrenheit).toBeCloseTo(75.416, 3);
|
||||
expect(parsed.humidityPercent).toBeCloseTo(45.1, 2);
|
||||
expect(parsed.vpdKpa).toBeCloseTo(1.76, 2);
|
||||
expect(parsed.choosePort).toBe(3);
|
||||
expect(parsed.workType).toBe(2);
|
||||
expect(parsed.fanSpeedGuess).toBe(6);
|
||||
});
|
||||
});
|
||||
@ -7,11 +7,19 @@ describe("AppConfig", () => {
|
||||
ACI_PASSWORD: "super-secret"
|
||||
});
|
||||
|
||||
expect(config.mode).toBe("cloud");
|
||||
expect(config.aciHost).toBe("http://www.acinfinityserver.com");
|
||||
expect(config.pollIntervalSeconds).toBe(30);
|
||||
expect(config.listenPort).toBe(9108);
|
||||
expect(config.requestTimeoutMs).toBe(10000);
|
||||
expect(config.logLevel).toBe("info");
|
||||
expect(config.enableControlApi).toBe(false);
|
||||
expect(config.controlListenPort).toBe(9110);
|
||||
expect(config.bleDefaultMac).toBeNull();
|
||||
expect(config.bleAllowedMacs).toEqual([]);
|
||||
expect(config.bleDeviceType).toBe(11);
|
||||
expect(config.bleScanTimeoutMs).toBe(20000);
|
||||
expect(config.blePortBase).toBe(1);
|
||||
});
|
||||
|
||||
it("throws when required values are missing", () => {
|
||||
@ -34,4 +42,47 @@ describe("AppConfig", () => {
|
||||
})
|
||||
).toThrow("POLL_INTERVAL_SECONDS must be between 5 and 600");
|
||||
});
|
||||
|
||||
it("supports ble mode without cloud credentials", () => {
|
||||
const config = AppConfig.fromEnv({
|
||||
TYPHON_MODE: "ble",
|
||||
ENABLE_CONTROL_API: "true",
|
||||
TY_BLE_DEFAULT_MAC: "58:8c:81:c6:fc:f6",
|
||||
TY_BLE_ALLOWED_MACS: "11:22:33:44:55:66",
|
||||
TY_BLE_DEVICE_TYPE: "11",
|
||||
TY_BLE_SCAN_TIMEOUT_MS: "25000",
|
||||
TY_BLE_PORT_BASE: "1"
|
||||
});
|
||||
|
||||
expect(config.mode).toBe("ble");
|
||||
expect(config.aciEmail).toBeNull();
|
||||
expect(config.aciPassword).toBeNull();
|
||||
expect(config.enableControlApi).toBe(true);
|
||||
expect(config.bleDefaultMac).toBe("58:8C:81:C6:FC:F6");
|
||||
expect(config.bleAllowedMacs).toEqual([
|
||||
"11:22:33:44:55:66",
|
||||
"58:8C:81:C6:FC:F6"
|
||||
]);
|
||||
expect(config.bleDeviceType).toBe(11);
|
||||
expect(config.bleScanTimeoutMs).toBe(25000);
|
||||
expect(config.blePortBase).toBe(1);
|
||||
});
|
||||
|
||||
it("validates mode and MAC inputs", () => {
|
||||
expect(() => AppConfig.fromEnv({
|
||||
TYPHON_MODE: "unknown",
|
||||
ACI_EMAIL: "x",
|
||||
ACI_PASSWORD: "y"
|
||||
})).toThrow("TYPHON_MODE must be one of: cloud, ble");
|
||||
|
||||
expect(() => AppConfig.fromEnv({
|
||||
TYPHON_MODE: "ble",
|
||||
TY_BLE_DEFAULT_MAC: "not-a-mac"
|
||||
})).toThrow("TY_BLE_DEFAULT_MAC must be a MAC address like AA:BB:CC:DD:EE:FF");
|
||||
|
||||
expect(() => AppConfig.fromEnv({
|
||||
TYPHON_MODE: "ble",
|
||||
TY_BLE_PORT_BASE: "2"
|
||||
})).toThrow("TY_BLE_PORT_BASE must be 0 or 1");
|
||||
});
|
||||
});
|
||||
|
||||
97
tests/BleControlBackend.test.ts
Normal file
97
tests/BleControlBackend.test.ts
Normal file
@ -0,0 +1,97 @@
|
||||
import { BleControllerClient, BleTelemetryReading } 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 telemetry: BleTelemetryReading = {
|
||||
temperatureCelsius: 24.06,
|
||||
temperatureFahrenheit: 75.31,
|
||||
humidityPercent: 41.06,
|
||||
vpdKpa: 1.76,
|
||||
choosePort: 3,
|
||||
workType: 2,
|
||||
fanSpeedGuess: 7,
|
||||
receivedAtEpochSeconds: 1_700_000_123
|
||||
};
|
||||
|
||||
public async verifyConnection(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
public async readTelemetry(): Promise<BleTelemetryReading> {
|
||||
return this.telemetry;
|
||||
}
|
||||
|
||||
public async setPortSpeed(macAddress: string, portByte: number, speedLevel: number): Promise<void> {
|
||||
this.lastSet = { mac: macAddress, portByte, speedLevel };
|
||||
}
|
||||
}
|
||||
|
||||
describe("BleControlBackend", () => {
|
||||
it("pairs and enforces allowed MAC list", async () => {
|
||||
const client = new FakeBleClient();
|
||||
const backend = new BleControlBackend({
|
||||
defaultMac: null,
|
||||
allowedMacs: ["58:8C:81:C6:FC:F6"],
|
||||
requestTimeoutMs: 10000,
|
||||
scanTimeoutMs: 10000,
|
||||
deviceType: 11,
|
||||
portBase: 1,
|
||||
logger: new Logger("error", "test"),
|
||||
client
|
||||
});
|
||||
|
||||
await expect(backend.pair("11:22:33:44:55:66")).rejects.toThrow("mac address is not in TY_BLE_ALLOWED_MACS");
|
||||
|
||||
const status = await backend.pair("58:8c:81:c6:fc:f6");
|
||||
expect(status.controllerMac).toBe("58:8C:81:C6:FC:F6");
|
||||
expect(status.connected).toBe(true);
|
||||
});
|
||||
|
||||
it("maps port numbers to device bytes and updates speed cache", 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
|
||||
});
|
||||
|
||||
await backend.setPortSpeed(4, 9);
|
||||
expect(client.lastSet).toEqual({
|
||||
mac: "58:8C:81:C6:FC:F6",
|
||||
portByte: 3,
|
||||
speedLevel: 9
|
||||
});
|
||||
|
||||
const ports = await backend.getPorts();
|
||||
expect(ports.find((p) => p.port === 4)?.currentSpeedLevel).toBe(9);
|
||||
});
|
||||
|
||||
it("builds a climate snapshot from BLE telemetry", 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 snapshot = await backend.refreshTelemetry();
|
||||
const controller = snapshot.controllers[0];
|
||||
|
||||
expect(controller?.temperatureCelsius).toBeCloseTo(24.06, 2);
|
||||
expect(controller?.relativeHumidityRatio).toBeCloseTo(0.4106, 4);
|
||||
expect(controller?.vpdKpa).toBeCloseTo(1.76, 2);
|
||||
expect(controller?.ports.find((p) => p.port === 4)?.currentSpeedLevel).toBe(7);
|
||||
});
|
||||
});
|
||||
131
tests/ControlApiServer.test.ts
Normal file
131
tests/ControlApiServer.test.ts
Normal file
@ -0,0 +1,131 @@
|
||||
import http from "node:http";
|
||||
|
||||
import { ControlApiServer } from "../src/http/ControlApiServer";
|
||||
import { ControlPortState, ControllerControlStatus } 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;
|
||||
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 },
|
||||
{ port: 3, name: "Port 3", fanGroup: "outside_inlet", currentSpeedLevel: 0, online: true, powerState: false },
|
||||
{ port: 4, name: "Port 4", fanGroup: "interior", currentSpeedLevel: 0, online: true, powerState: false }
|
||||
];
|
||||
|
||||
public async getStatus(): Promise<ControllerControlStatus> {
|
||||
return {
|
||||
mode: "ble",
|
||||
backend: "fake",
|
||||
controllerMac: this.mac,
|
||||
connected: this.mac !== null,
|
||||
paired: this.mac !== null,
|
||||
telemetrySource: "ble",
|
||||
lastSnapshotEpochSeconds: null,
|
||||
capabilities: {
|
||||
pairing: true,
|
||||
portControl: true,
|
||||
advancedRules: true
|
||||
},
|
||||
ports: this.ports,
|
||||
notes: []
|
||||
};
|
||||
}
|
||||
|
||||
public async pair(macAddress: string): Promise<ControllerControlStatus> {
|
||||
this.mac = macAddress;
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
public async getPorts(): Promise<ControlPortState[]> {
|
||||
return this.ports;
|
||||
}
|
||||
|
||||
public async setPortSpeed(port: number, speedLevel: number): Promise<void> {
|
||||
this.ports = this.ports.map((item) => {
|
||||
if (item.port !== port) {
|
||||
return item;
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
currentSpeedLevel: speedLevel,
|
||||
powerState: speedLevel > 0
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function requestJson(
|
||||
port: number,
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown
|
||||
): Promise<{ statusCode: number; payload: unknown }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{
|
||||
host: "127.0.0.1",
|
||||
port,
|
||||
method,
|
||||
path,
|
||||
headers: body ? { "content-type": "application/json" } : undefined
|
||||
},
|
||||
(res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on("data", (chunk) => {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
});
|
||||
res.on("end", () => {
|
||||
const text = Buffer.concat(chunks).toString("utf8");
|
||||
let parsed: unknown = text;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
// non-json response in tests should still be visible
|
||||
}
|
||||
resolve({
|
||||
statusCode: res.statusCode ?? 0,
|
||||
payload: parsed
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
req.on("error", reject);
|
||||
if (body !== undefined) {
|
||||
req.write(JSON.stringify(body));
|
||||
}
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
describe("ControlApiServer", () => {
|
||||
it("supports status, pair, and per-port speed writes", async () => {
|
||||
const backend = new FakeBackend();
|
||||
const rules = new RuleEngineService();
|
||||
const logger = new Logger("error", "test");
|
||||
const port = 19110;
|
||||
|
||||
const server = new ControlApiServer(port, backend, rules, logger, "58:8C:81:C6:FC:F6");
|
||||
await server.start();
|
||||
|
||||
const statusBefore = await requestJson(port, "GET", "/api/v2/status");
|
||||
expect(statusBefore.statusCode).toBe(200);
|
||||
|
||||
const pair = await requestJson(port, "POST", "/api/v2/pair", {});
|
||||
expect(pair.statusCode).toBe(200);
|
||||
|
||||
const setSpeed = await requestJson(port, "POST", "/api/v2/ports/4/speed", { speed_level: 8 });
|
||||
expect(setSpeed.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);
|
||||
|
||||
await server.stop();
|
||||
});
|
||||
});
|
||||
21
tests/RuleEngineService.test.ts
Normal file
21
tests/RuleEngineService.test.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { RuleEngineService } from "../src/services/RuleEngineService";
|
||||
|
||||
describe("RuleEngineService", () => {
|
||||
it("returns defaults and accepts valid updates", () => {
|
||||
const svc = new RuleEngineService();
|
||||
const defaults = svc.getRules();
|
||||
expect(defaults.mode).toBe("manual");
|
||||
|
||||
const updated = svc.updateRules({ mode: "climate_auto", temperatureTargetC: 23.5 }, 1_700_000_123);
|
||||
expect(updated.mode).toBe("climate_auto");
|
||||
expect(updated.temperatureTargetC).toBe(23.5);
|
||||
expect(updated.updatedAtEpochSeconds).toBe(1_700_000_123);
|
||||
});
|
||||
|
||||
it("rejects invalid rules", () => {
|
||||
const svc = new RuleEngineService();
|
||||
expect(() => svc.updateRules({ minimumHoldSeconds: 2 })).toThrow(
|
||||
"rules.minimumHoldSeconds must be an integer between 5 and 7200"
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user