1.5.1: broker errors as events that never crash the host (error only when listened to; connect/offline/reconnect/close; connected flag), configurable broker host + mqtt options + log hook, Alexa.ChangeReport via Device.getChangeReport (<root>/changeReport), SceneController discovery + sendSceneResponse, addCapability options (proactivelyReported), unregisterDevice/clearDevices/getDevices/disconnect, discover/directive events, promise-returning quiet send(); tests on an in-process broker (aedes); readme

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
David 2026-09-22 19:29:38 +00:00
parent e96850bdd6
commit 717af632de
16 changed files with 2080 additions and 225 deletions

109
test/alex2node.test.js Normal file
View file

@ -0,0 +1,109 @@
"use strict";
// Alex2Node against an in-process MQTT broker (aedes): the 1.4.0 contract (discovery on <root>/discover -> <root>/discover_r,
// directives on <root>/<endpoint>/alexaDirective -> Response on .../alexaResponce) and the 1.5.1 additions: a configurable
// broker, connection errors as events that never crash the host, ChangeReport on <root>/changeReport, scene responses,
// unregister.
const { test, after } = require("node:test");
const assert = require("node:assert/strict");
const net = require("node:net");
const aedes = require("aedes");
const mqtt = require("mqtt");
const { Alex2MQTT, AlexaInterfaceType, DisplayCategory, PowerController, EndpointHealth, TemperatureSensorScale, DEFAULT_HOST } = require("../dist/index.js");
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const until = async (fn, ms = 3000, what = "condition") => { const t0 = Date.now(); while (Date.now() - t0 < ms) { if (await fn()) return true; await sleep(15); } throw new Error(`timeout waiting for ${what}`); };
const cleanups = [];
after(async () => { for (const c of cleanups.reverse()) { try { await c(); } catch { /* gone */ } } });
async function broker() {
const a = aedes();
const server = net.createServer(a.handle);
await new Promise((r) => server.listen(0, "127.0.0.1", r));
const port = server.address().port;
cleanups.push(() => new Promise((r) => { a.close(() => server.close(() => r())); }));
return { a, port, url: `mqtt://127.0.0.1:${port}` };
}
async function watcher(url, filter) {
const c = mqtt.connect(url); const got = [];
await new Promise((r, j) => { c.once("connect", r); c.once("error", j); });
await new Promise((r) => c.subscribe(filter, r));
c.on("message", (t, m) => { let p; try { p = JSON.parse(m.toString()); } catch { p = m.toString(); } got.push({ topic: t, payload: p }); });
cleanups.push(() => new Promise((r) => c.end(true, {}, r)));
return { c, got, publish: (t, p) => c.publish(t, typeof p === "string" ? p : JSON.stringify(p)) };
}
test("defaults: the 1.4.0 host stays the default; options.host overrides it", () => {
const a = new Alex2MQTT("u", "p", "root");
assert.equal(a.getHost(), DEFAULT_HOST); assert.equal(DEFAULT_HOST, "mqtt://Alex2MQTT.stormysdream.club:1883");
assert.equal(new Alex2MQTT("u", "p", "root", false, { host: "mqtt://127.0.0.1:1" }).getHost(), "mqtt://127.0.0.1:1");
});
test("discovery + directive + ReportState round trip on a local broker", async () => {
const b = await broker();
const alexa = await watcher(b.url, "root/#");
const bridge = new Alex2MQTT("u", "p", "root", false, { host: b.url });
const events = []; bridge.on("discover", (n) => events.push(["discover", n])); bridge.on("directive", (d) => events.push(["directive", d.name]));
bridge.connect();
await until(() => bridge.connected, 3000, "connect");
cleanups.push(() => bridge.disconnect());
const lamp = bridge.registerDevice("Dining Room Light", "5020AA", DisplayCategory.LIGHT);
lamp.addCapability(AlexaInterfaceType.POWER_CONTROLLER, { proactivelyReported: true });
lamp.addCapability(AlexaInterfaceType.BRIGHTNESS_CONTROLLER);
const scene = bridge.registerDevice("Evening", "scene-1", DisplayCategory.SCENE_TRIGGER);
scene.addCapability(AlexaInterfaceType.SCENE_CONTROLLER);
lamp.on("Event", (directive, ns) => { lamp.getStatusMessage(directive.header.correlationToken, true).addHealthProp(EndpointHealth.OK).addPowerControllerProp(directive.header.name === "TurnOn" ? PowerController.ON : PowerController.OFF).addBrightnessControllerProp(100).send(false); });
lamp.on("ReportState", (payload) => { lamp.getStatusMessage(payload.header.correlationToken).addPowerControllerProp(PowerController.OFF).addBrightnessControllerProp(0).send(false); });
scene.on("Event", (directive) => { scene.sendSceneResponse(directive.header.correlationToken, directive.header.name === "Activate"); });
alexa.publish("root/discover", { namespace: "Alexa.Discovery", name: "Discover" });
await until(() => alexa.got.some((m) => m.topic === "root/discover_r"), 3000, "discovery reply");
const disc = alexa.got.find((m) => m.topic === "root/discover_r").payload;
assert.equal(disc.length, 2);
assert.equal(disc[0].friendlyName, "Dining Room Light"); assert.deepEqual(disc[0].displayCategories, ["LIGHT"]);
const power = disc[0].capabilities.find((c) => c.interface === "Alexa.PowerController"); assert.equal(power.properties.proactivelyReported, true); assert.deepEqual(power.properties.supported, [{ name: "powerState" }]);
const sc = disc[1].capabilities.find((c) => c.interface === "Alexa.SceneController"); assert.equal(sc.supportsDeactivation, true); assert.equal(sc.version, "3"); assert.equal(sc.properties, undefined);
assert.deepEqual(events[0], ["discover", 2]); assert.equal(bridge.lastDiscoveryAt !== null, true);
alexa.publish("root/5020AA/alexaDirective", { header: { namespace: "Alexa.PowerController", name: "TurnOn", correlationToken: "ct1", payloadVersion: "3", messageId: "m1" }, endpoint: { endpointId: "5020AA" }, payload: {} });
await until(() => alexa.got.some((m) => m.topic === "root/5020AA/alexaResponce"), 3000, "directive response");
const resp = alexa.got.find((m) => m.topic === "root/5020AA/alexaResponce").payload;
assert.equal(resp.event.header.name, "Response"); assert.equal(resp.event.header.correlationToken, "ct1"); assert.equal(resp.context.properties.find((p) => p.name === "powerState").value, "ON");
assert.deepEqual(events[1], ["directive", "TurnOn"]);
alexa.publish("root/5020AA/alexaDirective", { header: { namespace: "Alexa", name: "ReportState", correlationToken: "ct2", payloadVersion: "3", messageId: "m2" }, endpoint: { endpointId: "5020AA" }, payload: {} });
await until(() => alexa.got.filter((m) => m.topic === "root/5020AA/alexaResponce").length === 2, 3000, "state report");
assert.equal(alexa.got.filter((m) => m.topic === "root/5020AA/alexaResponce")[1].payload.event.header.name, "StateReport");
alexa.publish("root/scene-1/alexaDirective", { header: { namespace: "Alexa.SceneController", name: "Activate", correlationToken: "ct3", payloadVersion: "3", messageId: "m3" }, endpoint: { endpointId: "scene-1" }, payload: {} });
await until(() => alexa.got.some((m) => m.topic === "root/scene-1/alexaResponce"), 3000, "scene response");
const sr = alexa.got.find((m) => m.topic === "root/scene-1/alexaResponce").payload;
assert.equal(sr.event.header.namespace, "Alexa.SceneController"); assert.equal(sr.event.header.name, "ActivationStarted"); assert.equal(sr.event.payload.cause.type, "VOICE_INTERACTION"); assert.ok(sr.event.payload.timestamp);
// change report: changed props in payload.change, the rest in context, on <root>/changeReport
const topic = await lamp.getChangeReport("PHYSICAL_INTERACTION").addPowerControllerProp(PowerController.OFF).unchanged().addBrightnessControllerProp(0).send();
assert.equal(topic, "root/changeReport");
await until(() => alexa.got.some((m) => m.topic === "root/changeReport"), 3000, "change report");
const cr = alexa.got.find((m) => m.topic === "root/changeReport").payload;
assert.equal(cr.event.header.name, "ChangeReport"); assert.equal(cr.event.endpoint.endpointId, "5020AA"); assert.equal(cr.event.payload.change.cause.type, "PHYSICAL_INTERACTION");
assert.deepEqual(cr.event.payload.change.properties.map((p) => [p.name, p.value]), [["powerState", "OFF"]]); assert.deepEqual(cr.context.properties.map((p) => [p.name, p.value]), [["brightness", 0]]);
// thermostat props convert to celsius as 1.4.0 did
const st = lamp.getStatusMessage("x").addTemperatureSensorProp(TemperatureSensorScale.FAHRENHEIT, 68).addThermostatControllerProp("lowerSetpoint", TemperatureSensorScale.FAHRENHEIT, 68).toJSON();
assert.equal(Math.round(st.context.properties[0].value.value * 10) / 10, 20); assert.equal(st.context.properties[0].value.scale, "CELSIUS");
// unregister: the device no longer answers
assert.equal(bridge.unregisterDevice("scene-1"), true); assert.equal(bridge.unregisterDevice("scene-1"), false); assert.equal(bridge.getDevices().length, 1);
alexa.publish("root/discover", {});
await until(() => alexa.got.filter((m) => m.topic === "root/discover_r").length === 2, 3000, "second discovery");
assert.equal(alexa.got.filter((m) => m.topic === "root/discover_r")[1].payload.length, 1);
await bridge.disconnect(); assert.equal(bridge.connected, false);
});
test("a dead broker: 'offline'/'error' events when listened to, and NO crash when nobody listens (1.4.0 killed the host)", async () => {
const dead = new Alex2MQTT("u", "p", "root", false, { host: "mqtt://127.0.0.1:1", mqtt: { reconnectPeriod: 50, connectTimeout: 200 } });
dead.connect(); cleanups.push(() => dead.disconnect());
await sleep(300);
assert.equal(dead.connected, false, "still alive, not connected");
const seen = [];
const listened = new Alex2MQTT("u", "p", "root", false, { host: "mqtt://127.0.0.1:1", mqtt: { reconnectPeriod: 50, connectTimeout: 200 } });
listened.on("error", (e) => seen.push(e.code || e.message)); listened.on("offline", () => seen.push("offline")); listened.on("reconnect", () => seen.push("reconnect"));
listened.connect(); cleanups.push(() => listened.disconnect());
await until(() => seen.includes("ECONNREFUSED") && seen.includes("reconnect"), 3000, "error + reconnect events");
});
test("registerDevice before connect throws (unchanged from 1.4.0)", () => {
assert.throws(() => new Alex2MQTT("u", "p", "root").registerDevice("x", "1", null), /connect before/);
});