import { EventEmitter } from "node:events"; import NodeBle = require("node-ble"); import { BleFeatureNotSupportedError } from "../src/ble/BleControllerClient"; import { NodeBleControllerClient } from "../src/ble/NodeBleControllerClient"; import { Logger } from "../src/observability/Logger"; jest.mock("node-ble", () => ({ createBluetooth: jest.fn() })); interface BleFixture { readCharacteristic: EventEmitter & { startNotifications: jest.Mock, []>; stopNotifications: jest.Mock, []>; }; writeCharacteristic: { writeValue: jest.Mock, unknown[]>; }; adapter: { isDiscovering: jest.Mock, []>; startDiscovery: jest.Mock, []>; stopDiscovery: jest.Mock, []>; waitDevice: jest.Mock, unknown[]>; }; destroy: jest.Mock; } function setupNodeBleFixture( options: { isDiscovering?: boolean; emitOnWrite?: boolean; missingCharacteristics?: boolean; } = {} ): BleFixture { const readCharacteristic = Object.assign(new EventEmitter(), { startNotifications: jest.fn(async () => undefined), stopNotifications: jest.fn(async () => undefined) }); const writeCharacteristic = { writeValue: jest.fn(async () => { if (options.emitOnWrite !== false) { setImmediate(() => { readCharacteristic.emit("valuechanged", Buffer.from([0x99])); }); } }) }; const service = { getCharacteristic: jest.fn(async (uuid: string) => { if (options.missingCharacteristics) { throw new Error("missing characteristic"); } if (uuid.includes("ff02")) { return readCharacteristic; } if (uuid.includes("ff01")) { return writeCharacteristic; } throw new Error("not found"); }) }; const gatt = { services: jest.fn(async () => ["primary-service"]), getPrimaryService: jest.fn(async () => service) }; const device = { connect: jest.fn(async () => undefined), gatt: jest.fn(async () => gatt), disconnect: jest.fn(async () => undefined) }; const adapter = { isDiscovering: jest.fn(async () => options.isDiscovering === true), startDiscovery: jest.fn(async () => undefined), stopDiscovery: jest.fn(async () => undefined), waitDevice: jest.fn(async () => device) }; const bluetooth = { defaultAdapter: jest.fn(async () => adapter) }; const destroy = jest.fn(() => undefined); const mockedNodeBle = NodeBle as unknown as { createBluetooth: jest.Mock; }; mockedNodeBle.createBluetooth.mockReturnValue({ bluetooth, destroy }); return { readCharacteristic, writeCharacteristic, adapter, destroy }; } function buildClient(logger = new Logger("error", "test")): NodeBleControllerClient { return new NodeBleControllerClient({ requestTimeoutMs: 25, scanTimeoutMs: 100, deviceType: 11, logger }); } describe("NodeBleControllerClient", () => { beforeEach(() => { jest.clearAllMocks(); }); it("reads telemetry over BLE and adds received timestamp", async () => { const fixture = setupNodeBleFixture(); const client = buildClient(); const protocol = { buildGetModelData: jest.fn(() => Buffer.from([0x01])), parseTelemetryNotification: jest.fn(() => ({ temperatureCelsius: 24.5, temperatureFahrenheit: 76.1, humidityPercent: 51.2, vpdKpa: 1.62, choosePort: 3, workType: 2, fanSpeedGuess: 7 })), buildSetLevel: jest.fn(() => Buffer.from([0x02])) }; (client as unknown as { protocol: unknown }).protocol = protocol; const nowSpy = jest.spyOn(Date, "now").mockReturnValue(1_700_000_000_000); const reading = await client.readTelemetry("58:8c:81:c6:fc:f6", 0); nowSpy.mockRestore(); expect(protocol.buildGetModelData).toHaveBeenCalledWith(11, 0); expect(protocol.parseTelemetryNotification).toHaveBeenCalled(); expect(reading.fanSpeedGuess).toBe(7); expect(reading.receivedAtEpochSeconds).toBe(1_700_000_000); expect(fixture.readCharacteristic.startNotifications).toHaveBeenCalled(); expect(fixture.readCharacteristic.stopNotifications).toHaveBeenCalled(); expect(fixture.adapter.startDiscovery).toHaveBeenCalled(); expect(fixture.adapter.stopDiscovery).toHaveBeenCalled(); expect(fixture.destroy).toHaveBeenCalled(); }); it("skips discovery start/stop when adapter is already discovering", async () => { const fixture = setupNodeBleFixture({ isDiscovering: true }); const client = buildClient(); (client as unknown as { protocol: unknown }).protocol = { buildGetModelData: jest.fn(() => Buffer.from([0x01])), parseTelemetryNotification: jest.fn(() => ({ temperatureCelsius: 20, temperatureFahrenheit: 68, humidityPercent: 40, vpdKpa: 1.1, choosePort: 1, workType: 2, fanSpeedGuess: 1 })), buildSetLevel: jest.fn(() => Buffer.from([0x02])) }; await client.readTelemetry("58:8C:81:C6:FC:F6", 1); expect(fixture.adapter.startDiscovery).not.toHaveBeenCalled(); expect(fixture.adapter.stopDiscovery).not.toHaveBeenCalled(); }); it("warns when set speed completes without a BLE notification ack", async () => { setupNodeBleFixture({ emitOnWrite: false }); const logger = new Logger("debug", "test"); const warnSpy = jest.spyOn(logger, "warn").mockImplementation(() => undefined); const client = buildClient(logger); (client as unknown as { protocol: unknown }).protocol = { buildGetModelData: jest.fn(() => Buffer.from([0x01])), parseTelemetryNotification: jest.fn(() => ({ temperatureCelsius: 20, temperatureFahrenheit: 68, humidityPercent: 40, vpdKpa: 1.1, choosePort: 1, workType: 2, fanSpeedGuess: 1 })), buildSetLevel: jest.fn(() => Buffer.from([0x03])) }; await expect(client.setPortSpeed("58:8C:81:C6:FC:F6", 4, 8)).resolves.toBeUndefined(); expect(warnSpy).toHaveBeenCalledWith( "ble set speed completed without notify ack", expect.objectContaining({ error_message: expect.stringContaining("timed out waiting for BLE notification") }) ); }); it("throws when BLE characteristics cannot be resolved", async () => { setupNodeBleFixture({ missingCharacteristics: true }); const client = buildClient(); await expect(client.verifyConnection("58:8C:81:C6:FC:F6")).rejects.toThrow( "failed to resolve AC Infinity BLE characteristics" ); }); it("reports wifi recovery as unsupported", async () => { const client = buildClient(); await expect( client.recoverWifi("58:8C:81:C6:FC:F6", { ssid: "atlas-net", password: "secret-password", hidden: false }) ).rejects.toBeInstanceOf(BleFeatureNotSupportedError); }); });