"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 { 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 /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/); });