216 lines
6.8 KiB
TypeScript
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);
|
|
});
|
|
});
|