typhon/tests/NodeBleControllerClient.test.ts

216 lines
6.8 KiB
TypeScript

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<Promise<void>, []>;
stopNotifications: jest.Mock<Promise<void>, []>;
};
writeCharacteristic: {
writeValue: jest.Mock<Promise<void>, unknown[]>;
};
adapter: {
isDiscovering: jest.Mock<Promise<boolean>, []>;
startDiscovery: jest.Mock<Promise<void>, []>;
stopDiscovery: jest.Mock<Promise<void>, []>;
waitDevice: jest.Mock<Promise<unknown>, unknown[]>;
};
destroy: jest.Mock<void, []>;
}
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<unknown, []>;
};
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);
});
});