"use strict"; // Alex2Node against an in-process MQTT broker (aedes): the 1.4.0 contract (discovery on /discover -> /discover_r, // directives on //alexaDirective -> Response on .../alexaResponce) and the 1.5.1 additions: a configurable // broker, connection errors as events that never crash the host, ChangeReport on /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 fs = require("node:fs"); const path = require("node:path"); const { Alex2MQTT, AlexaInterfaceType, DisplayCategory, PowerController, EndpointHealth, TemperatureSensorScale, DEFAULT_HOST, ActionMapping, AlexaActions, AlexaStatusMessage } = 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 /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/); }); // 1.5.2 const connected = (bridge) => { const p = new Promise((r) => bridge.once("connect", r)); bridge.connect(); return p; }; // resolves once subscribed to /# const turnOn = (alexa, id, ct) => alexa.publish(`root/${id}/alexaDirective`, { header: { namespace: "Alexa.PowerController", name: "TurnOn", correlationToken: ct, payloadVersion: "3", messageId: ct }, endpoint: { endpointId: id }, payload: {} }); const answered = (alexa, id, ct) => alexa.got.some((m) => m.topic === `root/${id}/alexaResponce` && m.payload.event.header.correlationToken === ct); test("disconnect() then connect(): devices registered before still answer (1.5.1 left them on the closed client)", async () => { const b = await broker(); const alexa = await watcher(b.url, "root/#"); const bridge = new Alex2MQTT("u", "p", "root", false, { host: b.url }); await connected(bridge); cleanups.push(() => bridge.disconnect()); const lamp = bridge.registerDevice("Lamp", "lamp-1", DisplayCategory.LIGHT); lamp.addCapability(AlexaInterfaceType.POWER_CONTROLLER); lamp.on("Event", (d) => { lamp.getStatusMessage(d.header.correlationToken, true).addPowerControllerProp(PowerController.ON).send(); }); turnOn(alexa, "lamp-1", "before"); await until(() => answered(alexa, "lamp-1", "before"), 3000, "response before disconnect"); await bridge.disconnect(); assert.equal(bridge.connected, false); await connected(bridge); assert.equal(bridge.getDevices().length, 1, "the devices stay registered"); turnOn(alexa, "lamp-1", "after"); await until(() => answered(alexa, "lamp-1", "after"), 3000, "response after disconnect() + connect()"); }); test("registerDevice with an endpointId already registered returns the existing device and warns; thermostat discovery lists targetSetpoint", async () => { const b = await broker(); const alexa = await watcher(b.url, "root/#"); const logs = []; const bridge = new Alex2MQTT("u", "p", "root", false, { host: b.url, log: (m) => logs.push(m) }); await connected(bridge); cleanups.push(() => bridge.disconnect()); const a = bridge.registerDevice("Lamp A", "same", DisplayCategory.LIGHT); a.addCapability(AlexaInterfaceType.POWER_CONTROLLER); const again = bridge.registerDevice("Lamp B", "same", DisplayCategory.SWITCH); assert.equal(again, a); assert.equal(again.name, "Lamp A"); assert.equal(bridge.getDevices().length, 1); assert.ok(logs.some((m) => /warning: registerDevice\("same"\)/.test(m)), "warned through the log hook"); // without a log hook (the examples and the Quick Start set none) the warning falls back to console.warn const quiet = new Alex2MQTT("u", "p", "root-quiet", false, { host: b.url }); quiet.connect(); cleanups.push(() => quiet.disconnect()); const warned = []; const realWarn = console.warn; console.warn = (...args) => warned.push(args.join(" ")); try { quiet.registerDevice("Q", "same", null); assert.equal(quiet.registerDevice("Q again", "same", null).name, "Q"); } finally { console.warn = realWarn; } assert.ok(warned.some((m) => /warning: registerDevice\("same"\)/.test(m)), "console.warn without a log hook"); bridge.registerDevice("Thermostat", "thermo-1", DisplayCategory.THERMOSTAT).addCapability(AlexaInterfaceType.THERMOSTAT_CONTROLLER); alexa.publish("root/discover", {}); await until(() => alexa.got.some((m) => m.topic === "root/discover_r"), 3000, "discovery"); const disc = alexa.got.find((m) => m.topic === "root/discover_r").payload; assert.deepEqual(disc.map((d) => d.endpointId), ["same", "thermo-1"]); const thermo = disc[1].capabilities.find((c) => c.interface === "Alexa.ThermostatController"); assert.deepEqual(thermo.properties.supported.map((p) => p.name), ["targetSetpoint", "lowerSetpoint", "upperSetpoint", "thermostatMode"]); assert.equal(thermo.version, "3.2"); }); test("1.5.2 typings: dist ships declarations, ActionMapping's payload is optional, addHealthProp takes the enum or the string", () => { for (const f of ["index", "Alex2Node", "Device", "AlexaInterface", "AlexaStatusMessage", "AlexaErrorResponse", "ActionMapping", "DisplayCategory"]) assert.ok(fs.existsSync(path.join(__dirname, "..", "dist", `${f}.d.ts`)), `dist/${f}.d.ts`); // the declarations themselves (N-15 / NX-04 / N-04): a revert in src/ would rebuild narrower types and the runtime checks below would not notice const dts = (f) => fs.readFileSync(path.join(__dirname, "..", "dist", `${f}.d.ts`), "utf8"); assert.match(dts("ActionMapping"), /constructor\(actions: AlexaActions\[\], directiveName: string, directivePayload\?: string\);/); assert.match(dts("AlexaStatusMessage"), /addHealthProp\(health: EndpointHealth \| `\$\{EndpointHealth\}`, uncertaintyInMs\?: number\): this;/); assert.match(dts("AlexaInterface"), /addSupportedModes\(modes: Array\): void;/); assert.match(dts("index"), /export type \{ SupportedMode \} from "\.\/AlexaInterface";/); assert.deepEqual(new ActionMapping([AlexaActions.Close], "TurnOn").toJSON(), { "@type": "ActionsToDirective", actions: ["Alexa.Actions.Close"], directive: { name: "TurnOn" } }); const health = (h) => new AlexaStatusMessage("ct", "root", "e", null).addHealthProp(h).toJSON().context.properties[0].value; assert.deepEqual(health(EndpointHealth.OK), { value: "OK" }); assert.deepEqual(health("UNREACHABLE"), { value: "UNREACHABLE" }); }); test("1.5.2: a failed publish never rejects send() - it resolves \"\" and reaches the bridge's error listener", async () => { const b = await broker(); const bridge = new Alex2MQTT("u", "p", "root-send", false, { host: b.url }); const errors = []; bridge.on("error", (e) => errors.push(e.message)); bridge.connect(); cleanups.push(() => bridge.disconnect()); await new Promise((r) => bridge.once("connect", r)); const lamp = bridge.registerDevice("Lamp", "lamp-send", null); lamp.addCapability(AlexaInterfaceType.POWER_CONTROLLER); await bridge.disconnect(); // the device still holds the ended client until the next connect(): every publish now fails const status = await lamp.getStatusMessage("ct", true).addPowerControllerProp(PowerController.ON).send(); const error = await lamp.getErrorMessage("ct").send(); const scene = await lamp.sendSceneResponse("ct", true); const change = await lamp.getChangeReport("PHYSICAL_INTERACTION").addPowerControllerProp(PowerController.OFF).send(); assert.deepEqual([status, error, scene, change], ["", "", "", ""], "each resolves \"\" instead of rejecting"); assert.ok(errors.length >= 4, `every failure reached the error listener: ${errors.join(" | ")}`); // and with NO listener the same failures are swallowed (an "error" event without a listener would throw) const quiet = new Alex2MQTT("u", "p", "root-send-quiet", false, { host: b.url }); quiet.connect(); cleanups.push(() => quiet.disconnect()); await new Promise((r) => quiet.once("connect", r)); const q = quiet.registerDevice("Q", "q-1", null); await quiet.disconnect(); assert.equal(await q.getStatusMessage("ct", true).addPowerControllerProp(PowerController.ON).send(), ""); });