feat(v2): add BLE control backend, protocol, and control api scaffold

This commit is contained in:
Brad Stein 2026-04-13 21:59:42 -03:00
parent f573477cd0
commit cab40d05c6
26 changed files with 2951 additions and 49 deletions

View File

@ -25,6 +25,7 @@ This implementation follows the same API conventions used by `keithah/homebridge
## Configuration ## Configuration
Environment variables: Environment variables:
- `TYPHON_MODE` (optional, default `cloud`, values: `cloud|ble`)
- `ACI_EMAIL` (required) - `ACI_EMAIL` (required)
- `ACI_PASSWORD` (required, use <= 25 chars for reliability) - `ACI_PASSWORD` (required, use <= 25 chars for reliability)
- `ACI_HOST` (optional, default `http://www.acinfinityserver.com`) - `ACI_HOST` (optional, default `http://www.acinfinityserver.com`)
@ -32,6 +33,18 @@ Environment variables:
- `REQUEST_TIMEOUT_MS` (optional, default `10000`) - `REQUEST_TIMEOUT_MS` (optional, default `10000`)
- `LISTEN_PORT` (optional, default `9108`) - `LISTEN_PORT` (optional, default `9108`)
- `LOG_LEVEL` (optional, default `info`) - `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. Kubernetes runtime is expected to source `ACI_EMAIL`/`ACI_PASSWORD` from Vault.

1201
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -15,6 +15,7 @@
"test:ci": "mkdir -p build && jest --ci --runInBand --coverage --coverageReporters=text-summary --coverageReporters=cobertura --coverageReporters=json-summary" "test:ci": "mkdir -p build && jest --ci --runInBand --coverage --coverageReporters=text-summary --coverageReporters=cobertura --coverageReporters=json-summary"
}, },
"dependencies": { "dependencies": {
"node-ble": "^1.13.0",
"prom-client": "^15.1.3", "prom-client": "^15.1.3",
"undici": "^7.16.0" "undici": "^7.16.0"
}, },

View File

@ -1,45 +1,107 @@
import { AppConfig } from "../config/AppConfig"; import { AppConfig } from "../config/AppConfig";
import { AcInfinityApiClient } from "../http/AcInfinityApiClient"; import { AcInfinityApiClient } from "../http/AcInfinityApiClient";
import { ControlApiServer } from "../http/ControlApiServer";
import { TyphonMetrics } from "../metrics/TyphonMetrics"; import { TyphonMetrics } from "../metrics/TyphonMetrics";
import { Logger } from "../observability/Logger"; import { Logger } from "../observability/Logger";
import { BleControlBackend } from "../services/BleControlBackend";
import { BlePollingService } from "../services/BlePollingService";
import { ClimatePollingService } from "../services/ClimatePollingService"; 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"; import { MetricsServer } from "../transport/MetricsServer";
interface PollingService {
start(): void;
stop(): void;
}
export class TyphonApplication { export class TyphonApplication {
private readonly logger: Logger; private readonly logger: Logger;
private readonly apiClient: AcInfinityApiClient;
private readonly metrics: TyphonMetrics; 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 metricsServer: MetricsServer;
private readonly controlBackend: ControllerControlBackend;
private readonly ruleEngine: RuleEngineService;
private readonly controlApiServer: ControlApiServer | null;
private shuttingDown = false; private shuttingDown = false;
public constructor(private readonly config: AppConfig, version: string) { public constructor(private readonly config: AppConfig, version: string) {
this.logger = new Logger(config.logLevel, "typhon"); 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( this.apiClient = new AcInfinityApiClient(
config.aciHost, config.aciHost,
config.aciEmail, config.aciEmail,
config.aciPassword, config.aciPassword,
config.requestTimeoutMs config.requestTimeoutMs
); );
this.metrics = new TyphonMetrics(version);
this.pollingService = new ClimatePollingService( this.pollingService = new ClimatePollingService(
this.apiClient, this.apiClient,
this.metrics, this.metrics,
this.logger, 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 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); this.metricsServer = new MetricsServer(config.listenPort, this.metrics, this.logger);
} }
public async start(): Promise<void> { public async start(): Promise<void> {
this.installSignalHandlers(); this.installSignalHandlers();
await this.metricsServer.start(); await this.metricsServer.start();
this.pollingService.start(); this.pollingService?.start();
if (this.controlApiServer) {
await this.controlApiServer.start();
}
this.logger.info("typhon started", { this.logger.info("typhon started", {
mode: this.config.mode,
poll_interval_seconds: this.config.pollIntervalSeconds, 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.shuttingDown = true;
this.logger.info("typhon shutting down"); this.logger.info("typhon shutting down");
this.pollingService.stop(); this.pollingService?.stop();
if (this.controlApiServer) {
await this.controlApiServer.stop();
}
await this.metricsServer.stop(); await this.metricsServer.stop();
if (this.apiClient) {
await this.apiClient.close(); await this.apiClient.close();
}
this.logger.info("typhon shutdown complete"); this.logger.info("typhon shutdown complete");
} }

View 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);
}
}

View 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>;
}

View 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);
});
}
}

View File

@ -1,35 +1,62 @@
import { DEFAULT_AC_INFINITY_HOST } from "../http/ApiConstants"; import { DEFAULT_AC_INFINITY_HOST } from "../http/ApiConstants";
export type LogLevel = "debug" | "info" | "warn" | "error"; 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 { export class AppConfig {
public constructor( public constructor(
public readonly aciEmail: string, public readonly mode: TyphonMode,
public readonly aciPassword: string, public readonly aciEmail: string | null,
public readonly aciPassword: string | null,
public readonly aciHost: string, public readonly aciHost: string,
public readonly pollIntervalSeconds: number, public readonly pollIntervalSeconds: number,
public readonly listenPort: number, public readonly listenPort: number,
public readonly requestTimeoutMs: 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 { public static fromEnv(env: NodeJS.ProcessEnv = process.env): AppConfig {
const aciEmail = this.getRequired(env, "ACI_EMAIL"); const mode = this.parseMode(this.getOptional(env, "TYPHON_MODE", "cloud"));
const aciPassword = this.getRequired(env, "ACI_PASSWORD"); 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 aciHost = this.getOptional(env, "ACI_HOST", DEFAULT_AC_INFINITY_HOST);
const pollIntervalSeconds = this.parseNumber(env, "POLL_INTERVAL_SECONDS", 30, 5, 600); const pollIntervalSeconds = this.parseNumber(env, "POLL_INTERVAL_SECONDS", 30, 5, 600);
const listenPort = this.parseNumber(env, "LISTEN_PORT", 9108, 1, 65535); const listenPort = this.parseNumber(env, "LISTEN_PORT", 9108, 1, 65535);
const requestTimeoutMs = this.parseNumber(env, "REQUEST_TIMEOUT_MS", 10000, 1000, 120000); const requestTimeoutMs = this.parseNumber(env, "REQUEST_TIMEOUT_MS", 10000, 1000, 120000);
const logLevel = this.parseLogLevel(this.getOptional(env, "LOG_LEVEL", "info")); 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( return new AppConfig(
mode,
aciEmail, aciEmail,
aciPassword, aciPassword,
aciHost, aciHost,
pollIntervalSeconds, pollIntervalSeconds,
listenPort, listenPort,
requestTimeoutMs, 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`); 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");
}
} }

View 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
View 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
};
}

View 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" });
}
}

View File

@ -9,6 +9,7 @@ import {
AcInfinityMode, AcInfinityMode,
ClimateSnapshot ClimateSnapshot
} from "../domain/ClimateSnapshot"; } from "../domain/ClimateSnapshot";
import type { TyphonMode } from "../config/AppConfig";
const MODE_LABELS: readonly AcInfinityMode[] = [ const MODE_LABELS: readonly AcInfinityMode[] = [
AcInfinityMode.Off, 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 modeGauge: Gauge<"controller_id" | "controller_name" | "port" | "port_name" | "fan_group" | "mode">;
private readonly buildInfo: Gauge<"version">; private readonly buildInfo: Gauge<"version">;
private readonly runtimeMode: Gauge<"mode">;
private readonly resettable: Array< private readonly resettable: Array<
Gauge<"controller_id" | "controller_name"> | Gauge<"controller_id" | "controller_name"> |
@ -180,6 +182,12 @@ export class TyphonMetrics {
labelNames: ["version"], labelNames: ["version"],
registers: [this.registry] 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.resettable = [
this.controllerOnline, this.controllerOnline,
@ -201,6 +209,8 @@ export class TyphonMetrics {
this.exporterUp.set(0); this.exporterUp.set(0);
this.dataAgeSeconds.set(0); this.dataAgeSeconds.set(0);
this.buildInfo.labels(version).set(1); this.buildInfo.labels(version).set(1);
this.runtimeMode.labels("cloud").set(1);
this.runtimeMode.labels("ble").set(0);
} }
public getRegistry(): Registry { public getRegistry(): Registry {
@ -279,4 +289,9 @@ export class TyphonMetrics {
} }
this.dataAgeSeconds.set(Math.max(0, nowEpochSeconds - this.lastSuccessEpoch)); 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);
}
} }

View 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?.();
}
}
}

View 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;
}
}
}

View File

@ -1,6 +1,7 @@
import { AcInfinityApiClient, AcInfinityApiError } from "../http/AcInfinityApiClient"; import { AcInfinityApiClient, AcInfinityApiError } from "../http/AcInfinityApiClient";
import { TyphonMetrics } from "../metrics/TyphonMetrics"; import { TyphonMetrics } from "../metrics/TyphonMetrics";
import { Logger } from "../observability/Logger"; import { Logger } from "../observability/Logger";
import { ClimateSnapshot } from "../domain/ClimateSnapshot";
export class ClimatePollingService { export class ClimatePollingService {
private timer: NodeJS.Timeout | null = null; private timer: NodeJS.Timeout | null = null;
@ -10,7 +11,8 @@ export class ClimatePollingService {
private readonly client: AcInfinityApiClient, private readonly client: AcInfinityApiClient,
private readonly metrics: TyphonMetrics, private readonly metrics: TyphonMetrics,
private readonly logger: Logger, private readonly logger: Logger,
private readonly pollIntervalSeconds: number private readonly pollIntervalSeconds: number,
private readonly onSnapshot?: (snapshot: ClimateSnapshot) => void
) {} ) {}
public start(): void { public start(): void {
@ -37,6 +39,7 @@ export class ClimatePollingService {
try { try {
const snapshot = await this.client.fetchSnapshot(); const snapshot = await this.client.fetchSnapshot();
this.metrics.updateFromSnapshot(snapshot); this.metrics.updateFromSnapshot(snapshot);
this.onSnapshot?.(snapshot);
this.logger.info("poll succeeded", { this.logger.info("poll succeeded", {
controller_count: snapshot.controllers.length controller_count: snapshot.controllers.length
}); });

View 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);
}
}

View File

@ -0,0 +1,9 @@
export class ControlApiError extends Error {
public constructor(
message: string,
public readonly statusCode: number
) {
super(message);
this.name = "ControlApiError";
}
}

View 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>;
}

View 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);
}
}
}

View 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
View File

@ -0,0 +1,116 @@
function esc(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
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");
}

View 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);
});
});

View File

@ -7,11 +7,19 @@ describe("AppConfig", () => {
ACI_PASSWORD: "super-secret" ACI_PASSWORD: "super-secret"
}); });
expect(config.mode).toBe("cloud");
expect(config.aciHost).toBe("http://www.acinfinityserver.com"); expect(config.aciHost).toBe("http://www.acinfinityserver.com");
expect(config.pollIntervalSeconds).toBe(30); expect(config.pollIntervalSeconds).toBe(30);
expect(config.listenPort).toBe(9108); expect(config.listenPort).toBe(9108);
expect(config.requestTimeoutMs).toBe(10000); expect(config.requestTimeoutMs).toBe(10000);
expect(config.logLevel).toBe("info"); 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", () => { it("throws when required values are missing", () => {
@ -34,4 +42,47 @@ describe("AppConfig", () => {
}) })
).toThrow("POLL_INTERVAL_SECONDS must be between 5 and 600"); ).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");
});
}); });

View 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);
});
});

View 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();
});
});

View 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"
);
});
});