"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 { ActionMapping, Alex2MQTT, AlexaActions, AlexaInterfaceType, BrightnessController, DeclarationError, EndpointHealth, ModeController, PowerController, RangeController, TemperatureSensor, ToggleController, asset, registry, semantics, text, } = 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 switches = (device) => { for (let n = 1; n <= 98; n += 1) device.add(ToggleController, { instance: `Panel.Switch${n}`, friendlyNames: [text(`Switch ${n}`)] }); }; const panel = endpoint("panel-1", "Panel", ["OTHER"]); switches(panel); 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; switches(health); 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" }); fan.addCapability(AlexaInterfaceType.TOGGLE_CONTROLLER).addFriendlyName("Light", "en-US"); fan.addCapability(AlexaInterfaceType.RANGE_CONTROLLER, { instance: "Fan.Speed" }).addFriendlyName("Speed", "en-US"); const mode = fan.addCapability(AlexaInterfaceType.MODE_CONTROLLER, { instance: "Fan.Mode" }); mode.addFriendlyName("Volume", "en-US"); mode.addSupportedModes(["Auto"]); const direction = fan.addCapability(AlexaInterfaceType.TOGGLE_CONTROLLER, { instance: "Fan.Direction" }); direction.addFriendlyName("Speed", "en-US"); direction.addActionMapping(new ActionMapping([AlexaActions.Open], "Reverse")); 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": needs a friendly name, from text() or asset()', 'fan-1: Alexa.ToggleController "Fan.Oscillate": the instance is declared twice', "fan-1: Alexa.ToggleController: needs an instance name, like Blind.Lift", 'fan-1: Alexa.RangeController "Fan.Speed": range: expected an object with min, max, precision, got nothing', 'fan-1: Alexa.ModeController "Fan.Mode": friendlyNames[0].value.text: "Volume" is a reserved word, not to be used as a friendly name', 'fan-1: Alexa.ModeController "Fan.Mode": the mode Auto was given as a string, pass { value, modeResources }', 'fan-1: Alexa.ToggleController "Fan.Direction": the first friendly name, Speed, is the first of Alexa.RangeController "Fan.Speed" too', ]); assert.equal(fan.getJSON().capabilities.length, 8, "announced all the same"); }); // A blind with a lift from 0 to 100: what each case below declares on top of it const LIFT = { instance: "Blind.Lift", friendlyNames: [asset("Alexa.Setting.Opening")], range: { min: 0, max: 100, precision: 5 } }; const POSITION = { instance: "Blind.Position", friendlyNames: [text("Position")], supportedModes: [ { value: "Position.Up", friendlyNames: [asset("Alexa.Value.Open")] }, { value: "Position.Down", friendlyNames: [asset("Alexa.Value.Close")] }, ], }; const TILT = { instance: "Blind.Tilt", friendlyNames: [text("Tilt")] }; const without = (options, ...keys) => Object.fromEntries(Object.entries(options).filter(([key]) => !keys.includes(key))); test("a generic controller needs an instance name and a friendly name, and each instance is declared once", () => { const cases = [ [RangeController, without(LIFT, "instance"), "Alexa.RangeController: needs an instance name, like Blind.Lift"], [ModeController, without(POSITION, "instance"), "Alexa.ModeController: needs an instance name, like Blind.Lift"], [ToggleController, { ...TILT, instance: "" }, "Alexa.ToggleController: needs an instance name, like Blind.Lift"], [RangeController, without(LIFT, "friendlyNames"), 'Alexa.RangeController "Blind.Lift": needs a friendly name, from text() or asset()'], [ModeController, { ...POSITION, friendlyNames: [] }, 'Alexa.ModeController "Blind.Position": needs a friendly name, from text() or asset()'], [ToggleController, without(TILT, "friendlyNames"), 'Alexa.ToggleController "Blind.Tilt": needs a friendly name, from text() or asset()'], ]; for (const [descriptor, options, message] of cases) { assert.equal(refused(() => endpoint("blinds-1").add(descriptor, options)).message, `blinds-1: ${message}`); } const blinds = endpoint("blinds-1"); blinds.add(ToggleController, TILT); assert.equal( refused(() => blinds.add(ToggleController, { ...TILT, friendlyNames: [text("Slats")] })).message, 'blinds-1: Alexa.ToggleController "Blind.Tilt": the instance is declared twice' ); // The same instance name under another interface is another capability blinds.add(RangeController, { ...LIFT, instance: "Blind.Tilt" }); assert.equal(blinds.getCapabilities().length, 2); }); test("friendly names: an asset of the catalog or a text with a locale, no reserved word, the first one once on the endpoint", () => { const named = (...friendlyNames) => () => endpoint("blinds-1").add(ToggleController, { ...TILT, friendlyNames }); const cases = [ [named(text("volume")), 'friendlyNames[0].value.text: "volume" is a reserved word, not to be used as a friendly name'], [named(text("Tilt"), text("Night Light")), 'friendlyNames[1].value.text: "Night Light" is a reserved word, not to be used as a friendly name'], [named(asset("Alexa.Setting.Tilt")), 'friendlyNames[0].value.assetId: expected an id of the global Alexa catalog, like Alexa.Setting.Opening, got "Alexa.Setting.Tilt"'], [named(text("Tilt", "english")), 'friendlyNames[0].value.locale: expected a locale like en-US, got "english"'], [named(text("")), 'friendlyNames[0].value.text: expected a string of 1 or more characters, got ""'], [named("Tilt"), 'friendlyNames[0]: expected a friendly name, from text() or asset(), got "Tilt"'], [named({ "@type": "name", value: "Tilt" }), 'friendlyNames[0].@type: expected text | asset, got "name"'], ]; for (const [declare, message] of cases) assert.equal(refused(declare).message, `blinds-1: Alexa.ToggleController "Blind.Tilt": ${message}`); const blinds = endpoint("blinds-1"); blinds.add(RangeController, LIFT); blinds.add(ToggleController, TILT); assert.equal( refused(() => blinds.add(ModeController, { ...POSITION, friendlyNames: [text("tilt"), text("Position")] })).message, 'blinds-1: Alexa.ModeController "Blind.Position": the first friendly name, tilt, is the first of Alexa.ToggleController "Blind.Tilt" too' ); assert.equal( refused(() => blinds.add(ModeController, { ...POSITION, friendlyNames: [asset("Alexa.Setting.Opening")] })).message, 'blinds-1: Alexa.ModeController "Blind.Position": the first friendly name, Alexa.Setting.Opening, is the first of Alexa.RangeController "Blind.Lift" too' ); // Second names may repeat, and the same text in another language is another name blinds.add(ModeController, { ...POSITION, friendlyNames: [text("Tilt", "en-GB"), text("Tilt")] }); assert.equal(blinds.getCapabilities().length, 3); }); test("RangeController: a range, presets on its grid, a unit of measure", () => { const lift = (options) => () => endpoint("blinds-1").add(RangeController, { ...LIFT, ...options }); const preset = (value) => ({ value, friendlyNames: [text("Half")] }); const cases = [ [() => endpoint("blinds-1").add(RangeController, without(LIFT, "range")), "range: expected an object with min, max, precision, got nothing"], [lift({ range: { min: 0, max: 100 } }), "range.precision: expected a number greater than 0, got nothing"], [lift({ range: { min: 0, max: 100, precision: 0 } }), "range.precision: expected a number greater than 0, got 0"], [lift({ range: { min: 0, max: 100, step: 5 } }), "range.step: unknown key, the known ones are min, max, precision"], [lift({ range: { min: 100, max: 0, precision: 5 } }), "range.max 0 is not above range.min 100"], [lift({ presets: [preset(50), preset(52)] }), "the preset 52 is not 0 + n x 5, up to 100"], [lift({ presets: [preset(105)] }), "the preset 105 is not 0 + n x 5, up to 100"], [lift({ presets: [{ value: 50 }] }), "presets[0].friendlyNames: expected a list with 1 or more entries, got nothing"], [lift({ presets: [{ value: 50, friendlyNames: [text("Timer")] }] }), 'presets[0].friendlyNames[0].value.text: "Timer" is a reserved word, not to be used as a friendly name'], [lift({ unit: "Alexa.Unit.Percentage" }), 'unit: expected a unit of measure like Alexa.Unit.Percent, got "Alexa.Unit.Percentage"'], [lift({ preset: [] }), "preset: unknown key, the known ones are range, unit, presets, semantics"], [lift({ semantics: semantics().action("Open", "SetRangeValue", { rangeValue: 1000 }) }), "an action is mapped to SetRangeValue 1000, the range is 0 to 100"], ]; for (const [declare, message] of cases) assert.equal(refused(declare).message, `blinds-1: Alexa.RangeController "Blind.Lift": ${message}`); // A grid that does not start at 0, and one of tenths const fan = endpoint("fan-1"); fan.add(RangeController, { ...LIFT, range: { min: 1, max: 10, precision: 3 }, presets: [preset(1), preset(4), preset(10)] }); fan.add(RangeController, { instance: "Fan.Angle", friendlyNames: [text("Angle")], range: { min: 0, max: 1, precision: 0.1 }, presets: [{ value: 0.3, friendlyNames: [text("Narrow")] }, { value: 0.7, friendlyNames: [text("Wide")] }], }); assert.equal(fan.getCapabilities().length, 2); }); test("ModeController: two modes or more, each once, and mappings to modes it has", () => { const position = (options) => () => endpoint("blinds-1").add(ModeController, { ...POSITION, ...options }); const [up, down] = POSITION.supportedModes; const cases = [ [() => endpoint("blinds-1").add(ModeController, without(POSITION, "supportedModes")), "supportedModes: expected a list with 2 or more entries, got nothing"], [position({ supportedModes: [up] }), /^supportedModes: expected a list with 2 or more entries, got \[\{"value":"Position\.Up"/], [position({ supportedModes: [up, down, up] }), "the mode Position.Up is listed twice"], [position({ supportedModes: [up, { value: "Position.Down" }] }), "supportedModes[1].friendlyNames: expected a list with 1 or more entries, got nothing"], [position({ supportedModes: [up, "Position.Down"] }), 'supportedModes[1]: expected a mode { value, friendlyNames }, got "Position.Down"'], [position({ ordered: "yes" }), 'ordered: expected true or false, got "yes"'], [position({ semantics: semantics().action("Open", "SetMode", { mode: "Position.Open" }) }), "an action is mapped to SetMode Position.Open, the modes are Position.Up, Position.Down"], [position({ semantics: semantics().state("Closed", "Position.Closed") }), "a state is mapped to Position.Closed, the modes are Position.Up, Position.Down"], [position({ semantics: semantics().action("Raise", "AdjustMode", { modeDelta: 1 }) }), "an action is mapped to AdjustMode, which needs ordered: true"], ]; for (const [declare, message] of cases) { const prefix = 'blinds-1: Alexa.ModeController "Blind.Position": '; if (typeof message === "string") assert.equal(refused(declare).message, prefix + message); else assert.match(refused(declare).message.slice(prefix.length), message); } const ordered = position({ ordered: true, semantics: semantics().action("Raise", "AdjustMode", { modeDelta: -1 }) })(); const unordered = position({})(); const { AdjustMode } = ModeController.directives; assert.deepEqual([AdjustMode.when(ordered), AdjustMode.when(unordered)], [true, false], "Alexa sends AdjustMode to an ordered mode only"); }); test("a phrase is mapped once on an endpoint, across its Range, Mode and Toggle capabilities", () => { const blinds = endpoint("blinds-1"); blinds.add(RangeController, { ...LIFT, semantics: semantics().action(["Open", "Raise"], "SetRangeValue", { rangeValue: 100 }) }); assert.equal( refused(() => blinds.add(ToggleController, { ...TILT, semantics: semantics().action("Close", "TurnOff").action("Open", "TurnOn") })).message, 'blinds-1: Alexa.ToggleController "Blind.Tilt": Alexa.Actions.Open is mapped here and on Alexa.RangeController "Blind.Lift", an endpoint maps a phrase once' ); assert.equal( refused(() => blinds.add(ModeController, { ...POSITION, semantics: semantics().action("Lower", "SetMode", { mode: "Position.Down" }).action(["Close", "Lower"], "SetMode", { mode: "Position.Down" }) })).message, 'blinds-1: Alexa.ModeController "Blind.Position": Alexa.Actions.Lower is mapped twice, an endpoint maps a phrase once' ); blinds.add(ToggleController, { ...TILT, semantics: semantics().action("Close", "TurnOff").action("Lower", "TurnOff") }); // Another endpoint maps the same phrases endpoint("blinds-2").add(ToggleController, { ...TILT, semantics: semantics().action("Open", "TurnOn") }); }); 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.KeypadController"), { proactivelyReported: true }); assert.deepEqual(capability.toJSON(), { type: "AlexaInterface", interface: "Alexa.KeypadController", version: "1", properties: { supported: [], proactivelyReported: true, retrievable: true }, }); assert.equal(refused(() => lock.add(registry.get("Alexa.KeypadController"))).message, "lock-1: Alexa.KeypadController: the interface is declared twice"); });