Compare commits
No commits in common. "codex/typhon-platform-gate-hygiene" and "main" have entirely different histories.
codex/typh
...
main
47
Jenkinsfile
vendored
47
Jenkinsfile
vendored
@ -122,7 +122,6 @@ spec:
|
|||||||
|
|
||||||
node <<'NODE'
|
node <<'NODE'
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const junitPath = 'build/junit-typhon.xml';
|
const junitPath = 'build/junit-typhon.xml';
|
||||||
const coveragePath = 'coverage/coverage-summary.json';
|
const coveragePath = 'coverage/coverage-summary.json';
|
||||||
@ -144,44 +143,6 @@ const skipped = Number((junit.match(/skipped="([0-9]+)"/) || [])[1] || 0);
|
|||||||
|
|
||||||
const cov = JSON.parse(fs.readFileSync(coveragePath, 'utf8'));
|
const cov = JSON.parse(fs.readFileSync(coveragePath, 'utf8'));
|
||||||
const total = cov.total || {};
|
const total = cov.total || {};
|
||||||
const sourceRoots = ['src', 'tests', 'scripts'];
|
|
||||||
const sourceExts = new Set(['.ts', '.js', '.cjs', '.mjs', '.sh']);
|
|
||||||
const maxSourceLines = 500;
|
|
||||||
|
|
||||||
function collectOverLimitFiles(rootDir) {
|
|
||||||
const offenders = [];
|
|
||||||
const stack = [rootDir];
|
|
||||||
while (stack.length > 0) {
|
|
||||||
const current = stack.pop();
|
|
||||||
if (!fs.existsSync(current)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
||||||
const fullPath = path.join(current, entry.name);
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
stack.push(fullPath);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!sourceExts.has(path.extname(entry.name))) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const lines = fs.readFileSync(fullPath, 'utf8').split(/\\r?\\n/).length;
|
|
||||||
if (lines > maxSourceLines) {
|
|
||||||
offenders.push({ file: fullPath, lines });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return offenders;
|
|
||||||
}
|
|
||||||
|
|
||||||
const overLimitFiles = sourceRoots.flatMap((root) => collectOverLimitFiles(root));
|
|
||||||
if (overLimitFiles.length > 0) {
|
|
||||||
console.error('source files exceed 500 LOC:');
|
|
||||||
for (const item of overLimitFiles) {
|
|
||||||
console.error(`- ${item.file}: ${item.lines}`);
|
|
||||||
}
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const report = {
|
const report = {
|
||||||
suite: 'typhon',
|
suite: 'typhon',
|
||||||
@ -197,9 +158,6 @@ const report = {
|
|||||||
statements: 85,
|
statements: 85,
|
||||||
functions: 85,
|
functions: 85,
|
||||||
branches: 75
|
branches: 75
|
||||||
},
|
|
||||||
hygiene: {
|
|
||||||
sourceLinesOver500: overLimitFiles.length
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -229,7 +187,6 @@ if (!fs.existsSync(qualityPath)) {
|
|||||||
|
|
||||||
const quality = JSON.parse(fs.readFileSync(qualityPath, 'utf8'));
|
const quality = JSON.parse(fs.readFileSync(qualityPath, 'utf8'));
|
||||||
const status = quality.tests.failures > 0 || quality.tests.errors > 0 ? 'failed' : 'ok';
|
const status = quality.tests.failures > 0 || quality.tests.errors > 0 ? 'failed' : 'ok';
|
||||||
const sourceLinesOver500 = Number(quality.hygiene?.sourceLinesOver500 ?? 0);
|
|
||||||
|
|
||||||
function fetchCounter(targetStatus) {
|
function fetchCounter(targetStatus) {
|
||||||
try {
|
try {
|
||||||
@ -262,10 +219,6 @@ const payload = [
|
|||||||
`typhon_quality_gate_coverage_percent{suite="${suite}",scope="statements"} ${quality.coverage.statements}`,
|
`typhon_quality_gate_coverage_percent{suite="${suite}",scope="statements"} ${quality.coverage.statements}`,
|
||||||
`typhon_quality_gate_coverage_percent{suite="${suite}",scope="functions"} ${quality.coverage.functions}`,
|
`typhon_quality_gate_coverage_percent{suite="${suite}",scope="functions"} ${quality.coverage.functions}`,
|
||||||
`typhon_quality_gate_coverage_percent{suite="${suite}",scope="branches"} ${quality.coverage.branches}`,
|
`typhon_quality_gate_coverage_percent{suite="${suite}",scope="branches"} ${quality.coverage.branches}`,
|
||||||
'# TYPE platform_quality_gate_workspace_line_coverage_percent gauge',
|
|
||||||
`platform_quality_gate_workspace_line_coverage_percent{suite="${suite}"} ${quality.coverage.lines}`,
|
|
||||||
'# TYPE platform_quality_gate_source_lines_over_500_total gauge',
|
|
||||||
`platform_quality_gate_source_lines_over_500_total{suite="${suite}"} ${sourceLinesOver500}`,
|
|
||||||
''
|
''
|
||||||
].join('\\n');
|
].join('\\n');
|
||||||
|
|
||||||
|
|||||||
@ -48,37 +48,4 @@ describe("AcInfinityProtocol", () => {
|
|||||||
expect(parsed.workType).toBe(2);
|
expect(parsed.workType).toBe(2);
|
||||||
expect(parsed.fanSpeedGuess).toBe(6);
|
expect(parsed.fanSpeedGuess).toBe(6);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("omits port selector bytes for unsupported device types", () => {
|
|
||||||
const protocol = new AcInfinityProtocol();
|
|
||||||
const packet = protocol.buildGetModelData(5, 9);
|
|
||||||
|
|
||||||
expect(packet[0]).toBe(0xa5);
|
|
||||||
expect(packet[9]).toBe(1);
|
|
||||||
expect(Array.from(packet.slice(10, 18))).toEqual([
|
|
||||||
16, 17, 18, 19, 20, 21, 22, 23
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws on invalid set-level inputs and malformed telemetry packets", () => {
|
|
||||||
const protocol = new AcInfinityProtocol();
|
|
||||||
|
|
||||||
expect(() => protocol.buildSetLevel(11, 2, 11, 3)).toThrow(
|
|
||||||
"level must be an integer between 0 and 10"
|
|
||||||
);
|
|
||||||
expect(() => protocol.buildSetLevel(11, 1, 4, -1)).toThrow(
|
|
||||||
"port must be an integer between 0 and 255"
|
|
||||||
);
|
|
||||||
expect(() => protocol.parseTelemetryNotification(Buffer.from([0x00, 0x01, 0x02]))).toThrow(
|
|
||||||
"unexpected telemetry notification payload"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("wraps sequence numbers after max uint16", () => {
|
|
||||||
const protocol = new AcInfinityProtocol() as unknown as { sequence: number; nextSequence(): number };
|
|
||||||
protocol.sequence = 65535;
|
|
||||||
|
|
||||||
expect(protocol.nextSequence()).toBe(1);
|
|
||||||
expect(protocol.nextSequence()).toBe(2);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,270 +0,0 @@
|
|||||||
import http from "node:http";
|
|
||||||
|
|
||||||
import {
|
|
||||||
ControlPortState,
|
|
||||||
ControllerControlStatus,
|
|
||||||
WifiRecoveryRequest,
|
|
||||||
WifiRecoveryResult
|
|
||||||
} from "../src/domain/ControlTypes";
|
|
||||||
import { ControlApiServer } from "../src/http/ControlApiServer";
|
|
||||||
import { Logger } from "../src/observability/Logger";
|
|
||||||
import { ControllerControlBackend } from "../src/services/ControllerControlBackend";
|
|
||||||
import { RuleEngineService } from "../src/services/RuleEngineService";
|
|
||||||
|
|
||||||
function nextPort(): number {
|
|
||||||
return 30_000 + Math.floor(Math.random() * 20_000);
|
|
||||||
}
|
|
||||||
|
|
||||||
class EdgeBackend implements ControllerControlBackend {
|
|
||||||
public throwStatus = false;
|
|
||||||
public wifiRecoveryRequests: WifiRecoveryRequest[] = [];
|
|
||||||
|
|
||||||
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> {
|
|
||||||
if (this.throwStatus) {
|
|
||||||
throw new Error("status boom");
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
mode: "ble",
|
|
||||||
backend: "fake",
|
|
||||||
controllerMac: null,
|
|
||||||
connected: false,
|
|
||||||
paired: false,
|
|
||||||
telemetrySource: "ble",
|
|
||||||
lastSnapshotEpochSeconds: null,
|
|
||||||
capabilities: {
|
|
||||||
pairing: true,
|
|
||||||
portControl: true,
|
|
||||||
advancedRules: true
|
|
||||||
},
|
|
||||||
ports: this.ports,
|
|
||||||
notes: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public async pair(): Promise<ControllerControlStatus> {
|
|
||||||
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) => (
|
|
||||||
item.port === port
|
|
||||||
? { ...item, currentSpeedLevel: speedLevel, powerState: speedLevel > 0 }
|
|
||||||
: item
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
public async recoverWifi(request: WifiRecoveryRequest): Promise<WifiRecoveryResult> {
|
|
||||||
this.wifiRecoveryRequests.push(request);
|
|
||||||
return {
|
|
||||||
queued: true,
|
|
||||||
backend: "fake",
|
|
||||||
controllerMac: null,
|
|
||||||
notes: ["queued"]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function requestRaw(
|
|
||||||
port: number,
|
|
||||||
method: string,
|
|
||||||
path: string,
|
|
||||||
body?: string,
|
|
||||||
headers?: Record<string, string>
|
|
||||||
): Promise<{ statusCode: number; text: string; contentType: string }> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const req = http.request(
|
|
||||||
{
|
|
||||||
host: "127.0.0.1",
|
|
||||||
port,
|
|
||||||
method,
|
|
||||||
path,
|
|
||||||
headers
|
|
||||||
},
|
|
||||||
(res) => {
|
|
||||||
const chunks: Buffer[] = [];
|
|
||||||
res.on("data", (chunk) => {
|
|
||||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
||||||
});
|
|
||||||
res.on("end", () => {
|
|
||||||
resolve({
|
|
||||||
statusCode: res.statusCode ?? 0,
|
|
||||||
text: Buffer.concat(chunks).toString("utf8"),
|
|
||||||
contentType: String(res.headers["content-type"] ?? "")
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
req.on("error", reject);
|
|
||||||
if (body !== undefined) {
|
|
||||||
req.write(body);
|
|
||||||
}
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function requestJson(
|
|
||||||
port: number,
|
|
||||||
method: string,
|
|
||||||
path: string,
|
|
||||||
payload?: unknown
|
|
||||||
): Promise<{ statusCode: number; payload: unknown }> {
|
|
||||||
const body = payload === undefined ? undefined : JSON.stringify(payload);
|
|
||||||
const response = await requestRaw(
|
|
||||||
port,
|
|
||||||
method,
|
|
||||||
path,
|
|
||||||
body,
|
|
||||||
body
|
|
||||||
? { "content-type": "application/json" }
|
|
||||||
: undefined
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
statusCode: response.statusCode,
|
|
||||||
payload: JSON.parse(response.text)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("ControlApiServer edge cases", () => {
|
|
||||||
it("serves health and ui routes", async () => {
|
|
||||||
const backend = new EdgeBackend();
|
|
||||||
const port = nextPort();
|
|
||||||
const server = new ControlApiServer(
|
|
||||||
port,
|
|
||||||
backend,
|
|
||||||
new RuleEngineService(),
|
|
||||||
new Logger("error", "test"),
|
|
||||||
null
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
await server.start();
|
|
||||||
|
|
||||||
const health = await requestJson(port, "GET", "/healthz");
|
|
||||||
expect(health.statusCode).toBe(200);
|
|
||||||
expect(health.payload).toEqual({ ok: true });
|
|
||||||
|
|
||||||
const ui = await requestRaw(port, "GET", "/ui");
|
|
||||||
expect(ui.statusCode).toBe(200);
|
|
||||||
expect(ui.contentType).toContain("text/html");
|
|
||||||
expect(ui.text).toContain("Typhon v2 Control (Scaffold)");
|
|
||||||
} finally {
|
|
||||||
await server.stop();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns structured validation errors for malformed requests", async () => {
|
|
||||||
const backend = new EdgeBackend();
|
|
||||||
const port = nextPort();
|
|
||||||
const server = new ControlApiServer(
|
|
||||||
port,
|
|
||||||
backend,
|
|
||||||
new RuleEngineService(),
|
|
||||||
new Logger("error", "test"),
|
|
||||||
null
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
await server.start();
|
|
||||||
|
|
||||||
const badJson = await requestRaw(
|
|
||||||
port,
|
|
||||||
"POST",
|
|
||||||
"/api/v2/pair",
|
|
||||||
"{bad-json",
|
|
||||||
{ "content-type": "application/json" }
|
|
||||||
);
|
|
||||||
expect(badJson.statusCode).toBe(400);
|
|
||||||
expect(JSON.parse(badJson.text)).toEqual({ error: "request body must be valid JSON" });
|
|
||||||
|
|
||||||
const hugeBody = `{\"payload\":\"${"x".repeat(70 * 1024)}\"}`;
|
|
||||||
const tooLarge = await requestRaw(
|
|
||||||
port,
|
|
||||||
"POST",
|
|
||||||
"/api/v2/rules",
|
|
||||||
hugeBody,
|
|
||||||
{ "content-type": "application/json" }
|
|
||||||
);
|
|
||||||
expect(tooLarge.statusCode).toBe(413);
|
|
||||||
expect(JSON.parse(tooLarge.text)).toEqual({ error: "request body too large" });
|
|
||||||
|
|
||||||
const missingSpeed = await requestJson(port, "POST", "/api/v2/ports/1/speed", {});
|
|
||||||
expect(missingSpeed.statusCode).toBe(400);
|
|
||||||
expect(missingSpeed.payload).toEqual({ error: "speed_level is required" });
|
|
||||||
|
|
||||||
const missingSsid = await requestJson(port, "POST", "/api/v2/wifi/recover", { password: "pw" });
|
|
||||||
expect(missingSsid.statusCode).toBe(400);
|
|
||||||
expect(missingSsid.payload).toEqual({ error: "ssid is required" });
|
|
||||||
|
|
||||||
const missingPassword = await requestJson(port, "POST", "/api/v2/wifi/recover", { ssid: "atlas" });
|
|
||||||
expect(missingPassword.statusCode).toBe(400);
|
|
||||||
expect(missingPassword.payload).toEqual({ error: "password is required" });
|
|
||||||
|
|
||||||
const invalidRulesBody = await requestJson(port, "POST", "/api/v2/rules", []);
|
|
||||||
expect(invalidRulesBody.statusCode).toBe(400);
|
|
||||||
expect(invalidRulesBody.payload).toEqual({ error: "request body must be a JSON object" });
|
|
||||||
|
|
||||||
const notFound = await requestJson(port, "GET", "/api/v2/unknown");
|
|
||||||
expect(notFound.statusCode).toBe(404);
|
|
||||||
expect(notFound.payload).toEqual({ error: "not found" });
|
|
||||||
} finally {
|
|
||||||
await server.stop();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles rules read/write and unexpected backend exceptions", async () => {
|
|
||||||
const backend = new EdgeBackend();
|
|
||||||
const logger = new Logger("debug", "test");
|
|
||||||
const loggerSpy = jest.spyOn(logger, "error").mockImplementation(() => undefined);
|
|
||||||
const port = nextPort();
|
|
||||||
const server = new ControlApiServer(port, backend, new RuleEngineService(), logger, null);
|
|
||||||
try {
|
|
||||||
await server.start();
|
|
||||||
|
|
||||||
const rulesBefore = await requestJson(port, "GET", "/api/v2/rules");
|
|
||||||
expect(rulesBefore.statusCode).toBe(200);
|
|
||||||
expect(rulesBefore.payload).toEqual({
|
|
||||||
rules: expect.objectContaining({
|
|
||||||
mode: "manual",
|
|
||||||
minimumHoldSeconds: 60,
|
|
||||||
temperatureTargetC: 24,
|
|
||||||
temperatureBandC: 0.5,
|
|
||||||
humidityTargetPercent: 45,
|
|
||||||
humidityBandPercent: 3
|
|
||||||
})
|
|
||||||
});
|
|
||||||
expect((rulesBefore.payload as { rules: { updatedAtEpochSeconds: unknown } }).rules.updatedAtEpochSeconds).toEqual(expect.any(Number));
|
|
||||||
|
|
||||||
const rulesAfter = await requestJson(port, "POST", "/api/v2/rules", {
|
|
||||||
humidityBandPercent: 5
|
|
||||||
});
|
|
||||||
expect(rulesAfter.statusCode).toBe(200);
|
|
||||||
expect(rulesAfter.payload).toEqual({
|
|
||||||
rules: expect.objectContaining({
|
|
||||||
mode: "manual",
|
|
||||||
humidityBandPercent: 5
|
|
||||||
})
|
|
||||||
});
|
|
||||||
expect((rulesAfter.payload as { rules: { updatedAtEpochSeconds: unknown } }).rules.updatedAtEpochSeconds).toEqual(expect.any(Number));
|
|
||||||
|
|
||||||
backend.throwStatus = true;
|
|
||||||
const failedStatus = await requestJson(port, "GET", "/api/v2/status");
|
|
||||||
expect(failedStatus.statusCode).toBe(500);
|
|
||||||
expect(failedStatus.payload).toEqual({ error: "internal server error" });
|
|
||||||
expect(loggerSpy).toHaveBeenCalledWith(
|
|
||||||
"control api request failed",
|
|
||||||
expect.objectContaining({ error_message: "status boom" })
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
await server.stop();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,53 +0,0 @@
|
|||||||
import { Logger } from "../src/observability/Logger";
|
|
||||||
import { renderControlUi } from "../src/ui/template";
|
|
||||||
|
|
||||||
describe("Logger", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
jest.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("suppresses lower-priority log lines", () => {
|
|
||||||
const logSpy = jest.spyOn(console, "log").mockImplementation(() => undefined);
|
|
||||||
const logger = new Logger("info", "gate-test");
|
|
||||||
|
|
||||||
logger.debug("not emitted", { key: "value" });
|
|
||||||
|
|
||||||
expect(logSpy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("routes warn and error levels to the correct console methods", () => {
|
|
||||||
const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
||||||
const errorSpy = jest.spyOn(console, "error").mockImplementation(() => undefined);
|
|
||||||
const logSpy = jest.spyOn(console, "log").mockImplementation(() => undefined);
|
|
||||||
const logger = new Logger("debug", "gate-test");
|
|
||||||
|
|
||||||
logger.info("info-message", { context: 1 });
|
|
||||||
logger.warn("warn-message", { context: 2 });
|
|
||||||
logger.error("error-message", { context: 3 });
|
|
||||||
|
|
||||||
expect(logSpy).toHaveBeenCalledTimes(1);
|
|
||||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
||||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
const warnPayload = JSON.parse(String(warnSpy.mock.calls[0]?.[0] ?? "{}")) as {
|
|
||||||
level?: string;
|
|
||||||
component?: string;
|
|
||||||
context?: number;
|
|
||||||
};
|
|
||||||
expect(warnPayload.level).toBe("warn");
|
|
||||||
expect(warnPayload.component).toBe("gate-test");
|
|
||||||
expect(warnPayload.context).toBe(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("renderControlUi", () => {
|
|
||||||
it("escapes unsafe MAC input and renders fallback when default MAC is missing", () => {
|
|
||||||
const escaped = renderControlUi(`AA:BB:<script>alert("x")</script>'`);
|
|
||||||
expect(escaped).toContain("<script>alert("x")</script>'");
|
|
||||||
expect(escaped).toContain("AA:BB:");
|
|
||||||
|
|
||||||
const fallback = renderControlUi(null);
|
|
||||||
expect(fallback).toContain("not set");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
@ -1,215 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Loading…
x
Reference in New Issue
Block a user