"use strict"; // What Alexa rejects at discovery is refused where it is declared: bridge.addDevice() and device.add() throw a // DeclarationError that names the endpoint, the interface and the instance, and leave everything as it was. const { test } = require("node:test"); const assert = require("node:assert/strict"); const { Alex2MQTT, AlexaInterfaceType, BrightnessController, DeclarationError, EndpointHealth, PowerController, TemperatureSensor, registry, } = require("alex2node"); const { endpoint } = require("../helpers/endpoint.js"); const { setup, sleep } = require("../helpers/harness.js"); // The DeclarationError that declare() throws. function refused(declare) { try { declare(); } catch (err) { assert.ok(err instanceof DeclarationError, `threw ${err && err.stack}`); return err; } return assert.fail("the declaration was accepted"); } test("addDevice refuses an endpoint Alexa would reject, registers nothing and publishes nothing", async () => { const { alexa, bridge } = await setup(Alex2MQTT, "root"); const lamp = { endpointId: "lamp-1", name: "Lamp", categories: ["LIGHT"] }; const cases = [ [{ endpointId: "garage/lamp" }, "garage/lamp: the endpointId takes up to 256 letters, digits, spaces and _ - = # ; : ? @ &"], [{ endpointId: "x".repeat(257) }, `${"x".repeat(61)}...: the endpointId takes up to 256 letters, digits, spaces and _ - = # ; : ? @ &`], [{ endpointId: "" }, "an endpoint needs an endpointId"], [{ name: "Dave's Lamp" }, 'lamp-1: the name "Dave\'s Lamp" takes up to 256 letters, digits and spaces, no punctuation'], [{ name: "L".repeat(257) }, `lamp-1: the name "${"L".repeat(257)}" takes up to 256 letters, digits and spaces, no punctuation`], [{ name: undefined }, "lamp-1: an endpoint needs a name"], [{ description: "d".repeat(129) }, "lamp-1: the description takes 1 to 128 characters"], [{ manufacturerName: "" }, "lamp-1: the manufacturerName takes 1 to 128 characters"], [{ categories: undefined }, "lamp-1: an endpoint needs a display category"], [{ categories: [] }, "lamp-1: an endpoint needs a display category"], [{ categories: ["LAMP"] }, 'lamp-1: "LAMP" is not a display category'], // In the DisplayCategory enum since 1.x, no longer on Amazon's list [{ categories: ["LIGHT", "VEHICLE"] }, 'lamp-1: "VEHICLE" is not a display category'], [{ model: "m".repeat(257) }, "lamp-1: model takes up to 256 characters"], [{ cookie: { note: "c".repeat(5000) } }, "lamp-1: the cookie is 5011 bytes, 5000 is the most"], [{ category: ["LIGHT"] }, "lamp-1: category is not a field of an endpoint"], ]; for (const [fields, message] of cases) { const err = refused(() => bridge.addDevice({ ...lamp, ...fields })); assert.equal(err.message, message); assert.equal(err.name, "DeclarationError"); } assert.deepEqual(bridge.getDevices(), []); // What the rules allow: every character of an endpointId, a name in another script, a cookie of 5000 bytes bridge.addDevice({ ...lamp, endpointId: "A z0_-=#;:?@&", name: "Lampe für Küche 2", cookie: { note: "c".repeat(4989) } }); bridge.addDevice({ ...lamp, endpointId: "x".repeat(256), name: "寝室のライト" }); assert.equal(bridge.getDevices().length, 2); const twice = refused(() => bridge.addDevice({ ...lamp, endpointId: "A z0_-=#;:?@&", name: "Another" })); assert.equal(twice.message, 'A z0_-=#;:?@&: is registered already, as "Lampe für Küche 2"'); assert.equal(twice.endpointId, "A z0_-=#;:?@&"); assert.equal(bridge.getDevices().length, 2); await sleep(50); assert.deepEqual(alexa.got, []); }); test("device.add refuses the interface declared twice, an instance or a friendly name where the interface has none", () => { const lamp = endpoint(); lamp.add(PowerController); const cases = [ [() => lamp.add(PowerController), "lamp-1: Alexa.PowerController: the interface is declared twice"], [() => lamp.add(BrightnessController, { instance: "Lamp.Level" }), 'lamp-1: Alexa.BrightnessController "Lamp.Level": takes no instance name: an endpoint has the interface once'], [ () => lamp.add(BrightnessController, { friendlyNames: [{ "@type": "text", value: { text: "Level", locale: "en-US" } }] }), "lamp-1: Alexa.BrightnessController: takes no friendly names", ], ]; for (const [declare, message] of cases) assert.equal(refused(declare).message, message); assert.deepEqual(lamp.getCapabilities().map((capability) => capability.namespace), ["Alexa.PowerController"]); const err = refused(() => lamp.add(BrightnessController, { instance: "Lamp.Level" })); assert.deepEqual( { endpointId: err.endpointId, namespace: err.namespace, instance: err.instance, problem: err.problem }, { endpointId: "lamp-1", namespace: "Alexa.BrightnessController", instance: "Lamp.Level", problem: "takes no instance name: an endpoint has the interface once" } ); }); test("device.add refuses an option the interface does not have and a value that does not fit", () => { const lamp = endpoint(); const cases = [ [() => lamp.add(PowerController, { verificationRequired: ["TurnOn"] }), "lamp-1: Alexa.PowerController: verificationRequired: unknown key, the known ones are verificationsRequired"], [() => lamp.add(PowerController, { verificationsRequired: ["Toggle"] }), 'lamp-1: Alexa.PowerController: verificationsRequired[0]: expected TurnOn | TurnOff, got "Toggle"'], [() => lamp.add(TemperatureSensor, { scale: "CELSIUS" }), "lamp-1: Alexa.TemperatureSensor: scale: not an option of the interface"], [() => lamp.add(TemperatureSensor, { retrievable: "yes" }), 'lamp-1: Alexa.TemperatureSensor: retrievable: expected true or false, got "yes"'], [() => lamp.add(TemperatureSensor, { instance: 7 }), "lamp-1: Alexa.TemperatureSensor: instance: expected a string, got 7"], [() => lamp.add(TemperatureSensor, "retrievable"), "lamp-1: Alexa.TemperatureSensor: the options of a declaration are an object"], ]; for (const [declare, message] of cases) assert.equal(refused(declare).message, message); assert.deepEqual(lamp.getCapabilities(), []); }); test("an endpoint takes 100 capabilities, the ones the library adds among them", () => { const panel = endpoint("panel-1", "Panel", ["OTHER"]); for (let n = 1; n <= 98; n += 1) panel.addCapability(AlexaInterfaceType.TOGGLE_CONTROLLER, { instance: `Panel.Switch${n}` }); panel.add(PowerController); assert.equal(panel.getJSON().capabilities.length, 100); assert.deepEqual(panel.check(), []); assert.equal(refused(() => panel.add(BrightnessController)).message, "panel-1: 101 capabilities, an endpoint takes 100"); assert.equal(panel.getCapabilities().length, 99); // The 1.x call is not refused: check() says it panel.addCapability(AlexaInterfaceType.BRIGHTNESS_CONTROLLER); assert.deepEqual(panel.check(), ["panel-1: 101 capabilities, an endpoint takes 100"]); // With Alexa.EndpointHealth added for the device there is room for 98 const health = endpoint("panel-2", "Panel", ["OTHER"]); health.endpointHealth = true; for (let n = 1; n <= 98; n += 1) health.addCapability(AlexaInterfaceType.TOGGLE_CONTROLLER, { instance: `Panel.Switch${n}` }); assert.equal(refused(() => health.add(PowerController)).message, "panel-2: 101 capabilities, an endpoint takes 100"); assert.doesNotThrow(() => health.add(EndpointHealth)); }); test("check() lists what is wrong with a device declared the 1.x way, one line for each", () => { const lamp = endpoint("lamp/1", "Lamp #1", ["LIGHT"]); assert.deepEqual(lamp.check(), ["lamp/1: the endpointId takes up to 256 letters, digits, spaces and _ - = # ; : ? @ &"]); const fan = endpoint("fan-1", "Fan", ["FAN", "VEHICLE"]); fan.addCapability(AlexaInterfaceType.POWER_CONTROLLER, { instance: "Fan.Power" }); fan.addCapability(AlexaInterfaceType.TOGGLE_CONTROLLER, { instance: "Fan.Oscillate" }); fan.addCapability(AlexaInterfaceType.TOGGLE_CONTROLLER, { instance: "Fan.Oscillate" }); assert.deepEqual(fan.check(), [ 'fan-1: "VEHICLE" is not a display category', 'fan-1: Alexa.PowerController "Fan.Power": takes no instance name: an endpoint has the interface once', 'fan-1: Alexa.ToggleController "Fan.Oscillate": the instance is declared twice', ]); assert.equal(fan.getJSON().capabilities.length, 4, "announced all the same"); }); test("a stub is declared like any interface and checked for nothing but being declared twice", () => { const lock = endpoint("lock-1", "Front Door", ["SMARTLOCK"]); const capability = lock.add(registry.get("Alexa.LockController"), { proactivelyReported: true }); assert.deepEqual(capability.toJSON(), { type: "AlexaInterface", interface: "Alexa.LockController", version: "3", properties: { supported: [], proactivelyReported: true, retrievable: true }, }); assert.equal(refused(() => lock.add(registry.get("Alexa.LockController"))).message, "lock-1: Alexa.LockController: the interface is declared twice"); });