"use strict"; // The examples run: every file of examples/ is started with node as its reader starts it, is discovered, gets a // directive and answers it. The files of examples/legacy are the examples of 1.5.2 on the 1.x API: each is started // and its discovery answer compared with the one 1.5.2 gave. const { test } = require("node:test"); const assert = require("node:assert/strict"); const fs = require("node:fs"); const path = require("node:path"); const { broker, watcher, directive, until } = require("./helpers/harness.js"); const { run, ROOT } = require("./helpers/examples.js"); const EXAMPLES = path.join(ROOT, "examples"); const LEGACY = path.join(EXAMPLES, "legacy"); const CELSIUS = (value) => ({ value, scale: "CELSIUS" }); let shared; const local = () => (shared ??= broker()); // Start an example on a root topic of its own, and play Alexa for it async function start(file, env = {}) { const at = await local(); const root = `examples-${file.replace(/\W/g, "-")}`; const alexa = await watcher(at.url, `${root}/#`); const example = await run(file, { broker: at, root, env: { ALEX2MQTT_USERNAME: "u", ALEX2MQTT_PASSWORD: "p", ALEX2MQTT_ROOT_TOPIC: root, ...env }, }); let sent = 0; // Send a directive and wait for the n answers to it on the topic const ask = async (endpointId, namespace, name, payload = {}, { instance, topic = "alexaResponce", answers = 1 } = {}) => { const token = `token-${++sent}`; const message = directive(namespace, name, endpointId, token, payload); if (instance) message.header.instance = instance; alexa.send(root, message); const mine = () => alexa.on(`${root}/${endpointId}/${topic}`).filter(({ event }) => event.header.correlationToken === token); await until(() => mine().length >= answers, 5000, `${file} to answer ${namespace}.${name}`); return mine()[answers - 1]; }; const discover = async () => { alexa.publish(`${root}/discover`, "{}"); await until(() => alexa.on(`${root}/discover_r`).length > 0, 5000, `${file} to answer the discovery`); return alexa.on(`${root}/discover_r`)[0]; }; return { root, alexa, example, ask, discover }; } const interfaces = (endpoint) => endpoint.capabilities.map((capability) => capability.interface); const name = (answer) => `${answer.event.header.namespace}.${answer.event.header.name}`; // The context of an answer as { ".": value }, the interface without "Alexa." const state = (answer) => Object.fromEntries(answer.context.properties .map((property) => [`${property.namespace.replace("Alexa.", "")}.${property.name}`, property.value])); test("every file of examples/ is run by a test here", () => { const files = fs.readdirSync(EXAMPLES).filter((file) => /\.m?js$/.test(file)).sort(); const source = fs.readFileSync(__filename, "utf8"); const untested = files.filter((file) => !source.includes(`start("${file}"`)); assert.deepEqual(untested, []); assert.equal(files.length, 10); }); test("an example without its environment says which variables to set, and ends", async () => { const example = await run("lamp.js", { env: { ALEX2MQTT_USERNAME: "u" } }); assert.equal(await example.exited, 1); assert.match(example.output(), /^ALEX2MQTT_PASSWORD, ALEX2MQTT_ROOT_TOPIC not set\./); }); test("lamp.js: a lamp with power and brightness", async () => { const { discover, ask, example } = await start("lamp.js"); const [endpoint] = await discover(); assert.equal(endpoint.endpointId, "desk-lamp"); assert.deepEqual(endpoint.displayCategories, ["LIGHT"]); assert.deepEqual(interfaces(endpoint), ["Alexa.PowerController", "Alexa.BrightnessController", "Alexa.EndpointHealth", "Alexa"]); assert.match(example.output(), /^connected/); const on = await ask("desk-lamp", "Alexa.PowerController", "TurnOn"); assert.equal(name(on), "Alexa.Response"); assert.deepEqual(state(on), { "PowerController.powerState": "ON", "BrightnessController.brightness": 100, "EndpointHealth.connectivity": { value: "OK" } }); const dimmed = await ask("desk-lamp", "Alexa.BrightnessController", "SetBrightness", { brightness: 40 }); assert.equal(state(dimmed)["BrightnessController.brightness"], 40); const darker = await ask("desk-lamp", "Alexa.BrightnessController", "AdjustBrightness", { brightnessDelta: -50 }); assert.equal(state(darker)["BrightnessController.brightness"], 0); const off = await ask("desk-lamp", "Alexa.PowerController", "TurnOff"); assert.equal(state(off)["PowerController.powerState"], "OFF"); const report = await ask("desk-lamp", "Alexa", "ReportState"); assert.equal(name(report), "Alexa.StateReport"); assert.deepEqual(state(report), { "PowerController.powerState": "OFF", "BrightnessController.brightness": 0, "EndpointHealth.connectivity": { value: "OK" } }); }); test("color-lamp.js: a lamp that reports the colour or the white it shows", async () => { const { discover, ask } = await start("color-lamp.js"); const [endpoint] = await discover(); assert.deepEqual(interfaces(endpoint), [ "Alexa.PowerController", "Alexa.BrightnessController", "Alexa.ColorController", "Alexa.ColorTemperatureController", "Alexa.EndpointHealth", "Alexa", ]); await ask("floor-lamp", "Alexa.BrightnessController", "SetBrightness", { brightness: 50 }); const red = await ask("floor-lamp", "Alexa.ColorController", "SetColor", { color: { hue: 0, saturation: 1, brightness: 1 } }); assert.deepEqual(state(red)["ColorController.color"], { hue: 0, saturation: 1, brightness: 0.5 }); assert.equal("ColorTemperatureController.colorTemperatureInKelvin" in state(red), false); const refused = await ask("floor-lamp", "Alexa.ColorTemperatureController", "IncreaseColorTemperature"); assert.equal(name(refused), "Alexa.ErrorResponse"); assert.deepEqual(refused.event.payload, { type: "NOT_SUPPORTED_IN_CURRENT_MODE", message: "The lamp shows a colour", currentDeviceMode: "COLOR" }); const white = await ask("floor-lamp", "Alexa.ColorTemperatureController", "SetColorTemperature", { colorTemperatureInKelvin: 4000 }); assert.equal(state(white)["ColorTemperatureController.colorTemperatureInKelvin"], 4000); assert.equal("ColorController.color" in state(white), false); const cooler = await ask("floor-lamp", "Alexa.ColorTemperatureController", "IncreaseColorTemperature"); assert.equal(state(cooler)["ColorTemperatureController.colorTemperatureInKelvin"], 5500); const warmer = await ask("floor-lamp", "Alexa.ColorTemperatureController", "DecreaseColorTemperature"); assert.equal(state(warmer)["ColorTemperatureController.colorTemperatureInKelvin"], 4000); }); test("thermostat.js: one setpoint, three modes, and the errors of a thermostat", async () => { const { discover, ask } = await start("thermostat.js"); const [endpoint] = await discover(); const [control] = endpoint.capabilities; assert.deepEqual(control.properties.supported, [{ name: "targetSetpoint" }, { name: "thermostatMode" }]); assert.deepEqual(control.configuration, { supportedModes: ["HEAT", "COOL", "OFF"], supportsScheduling: false }); const set = await ask("hall-thermostat", "Alexa.ThermostatController", "SetTargetTemperature", { targetSetpoint: { value: 68, scale: "FAHRENHEIT" } }); assert.deepEqual(state(set), { "ThermostatController.targetSetpoint": CELSIUS(20), "ThermostatController.thermostatMode": "HEAT", "TemperatureSensor.temperature": CELSIUS(19.5), "EndpointHealth.connectivity": { value: "OK" }, }); const warmer = await ask("hall-thermostat", "Alexa.ThermostatController", "AdjustTargetTemperature", { targetSetpointDelta: CELSIUS(2) }); assert.deepEqual(state(warmer)["ThermostatController.targetSetpoint"], CELSIUS(22)); const hot = await ask("hall-thermostat", "Alexa.ThermostatController", "SetTargetTemperature", { targetSetpoint: CELSIUS(40) }); assert.equal(name(hot), "Alexa.ErrorResponse"); assert.equal(hot.event.payload.type, "TEMPERATURE_VALUE_OUT_OF_RANGE"); assert.deepEqual(hot.event.payload.validRange, { minimumValue: CELSIUS(5), maximumValue: CELSIUS(30) }); const two = await ask("hall-thermostat", "Alexa.ThermostatController", "SetTargetTemperature", { lowerSetpoint: CELSIUS(18), upperSetpoint: CELSIUS(24) }); assert.equal(name(two), "Alexa.ThermostatController.ErrorResponse"); assert.equal(two.event.payload.type, "DUAL_SETPOINTS_UNSUPPORTED"); const eco = await ask("hall-thermostat", "Alexa.ThermostatController", "SetThermostatMode", { thermostatMode: { value: "ECO" } }); assert.equal(eco.event.payload.type, "UNSUPPORTED_THERMOSTAT_MODE"); const off = await ask("hall-thermostat", "Alexa.ThermostatController", "SetThermostatMode", { thermostatMode: { value: "OFF" } }); assert.equal(state(off)["ThermostatController.thermostatMode"], "OFF"); const late = await ask("hall-thermostat", "Alexa.ThermostatController", "SetTargetTemperature", { targetSetpoint: CELSIUS(20) }); assert.equal(late.event.payload.type, "THERMOSTAT_IS_OFF"); }); test("blind.js: a position in percent, with open and close as semantics", async () => { const { discover, ask } = await start("blind.js"); const [endpoint] = await discover(); const [lift] = endpoint.capabilities; assert.equal(lift.instance, "Blind.Lift"); assert.deepEqual(lift.semantics.actionMappings.map(({ actions, directive: { name: to, payload } }) => [actions, to, payload]), [ [["Alexa.Actions.Close"], "SetRangeValue", { rangeValue: 0 }], [["Alexa.Actions.Open"], "SetRangeValue", { rangeValue: 100 }], [["Alexa.Actions.Lower"], "AdjustRangeValue", { rangeValueDelta: -10, rangeValueDeltaDefault: false }], [["Alexa.Actions.Raise"], "AdjustRangeValue", { rangeValueDelta: 10, rangeValueDeltaDefault: false }], ]); assert.equal(lift.semantics.stateMappings.length, 2); const open = await ask("bedroom-blind", "Alexa.RangeController", "SetRangeValue", { rangeValue: 100 }, { instance: "Blind.Lift" }); assert.equal(name(open), "Alexa.Response"); const [position] = open.context.properties; assert.deepEqual([position.namespace, position.instance, position.name, position.value], ["Alexa.RangeController", "Blind.Lift", "rangeValue", 100]); const lower = await ask("bedroom-blind", "Alexa.RangeController", "AdjustRangeValue", { rangeValueDelta: -10, rangeValueDeltaDefault: false }, { instance: "Blind.Lift" }); assert.equal(state(lower)["RangeController.rangeValue"], 90); }); test("lock.js: a DeferredResponse at once, the Response when the bolt has moved", async () => { const { discover, ask, alexa, root } = await start("lock.js", { BOLT_SECONDS: "0.3" }); const [endpoint] = await discover(); assert.deepEqual(interfaces(endpoint), ["Alexa.LockController", "Alexa.EndpointHealth", "Alexa"]); const unlocked = await ask("front-door-lock", "Alexa.LockController", "Unlock", {}, { topic: "deferredResponse" }); assert.equal(name(unlocked), "Alexa.Response"); assert.equal(state(unlocked)["LockController.lockState"], "UNLOCKED"); const [deferred, ...more] = alexa.on(`${root}/front-door-lock/alexaResponce`); assert.equal(name(deferred), "Alexa.DeferredResponse"); assert.deepEqual(deferred.event.payload, { estimatedDeferralInSeconds: 1 }); assert.equal(deferred.event.header.correlationToken, unlocked.event.header.correlationToken); assert.deepEqual(more, []); }); test("sensor.js: a ChangeReport when the door opens, and the state for ReportState", async () => { const { discover, ask, alexa, root, example } = await start("sensor.js"); const [endpoint] = await discover(); assert.deepEqual(endpoint.capabilities[0].properties, { supported: [{ name: "detectionState" }], proactivelyReported: true, retrievable: true }); example.write("\n"); await until(() => alexa.on(`${root}/changeReport`).length === 1, 5000, "the ChangeReport"); const [report] = alexa.on(`${root}/changeReport`); assert.equal(name(report), "Alexa.ChangeReport"); assert.equal(report.event.endpoint.endpointId, "back-door"); assert.equal(report.event.payload.change.cause.type, "PHYSICAL_INTERACTION"); assert.deepEqual(report.event.payload.change.properties.map((property) => [property.namespace, property.name, property.value]), [["Alexa.ContactSensor", "detectionState", "DETECTED"]]); assert.deepEqual(state(report), { "EndpointHealth.connectivity": { value: "OK" } }); await until(() => /the door reported DETECTED/.test(example.output()), 5000, "the line of the example"); const asked = await ask("back-door", "Alexa", "ReportState"); assert.equal(state(asked)["ContactSensor.detectionState"], "DETECTED"); assert.equal(asked.context.properties[0].timeOfSample, report.event.payload.change.properties[0].timeOfSample); }); test("scene.js: Activate is answered with ActivationStarted, Deactivate with DeactivationStarted", async () => { const { discover, ask } = await start("scene.js"); const [endpoint] = await discover(); assert.deepEqual(endpoint.displayCategories, ["SCENE_TRIGGER"]); assert.deepEqual(interfaces(endpoint), ["Alexa.SceneController", "Alexa"]); assert.equal(endpoint.capabilities[0].supportsDeactivation, true); const started = await ask("movie-night", "Alexa.SceneController", "Activate"); assert.equal(name(started), "Alexa.SceneController.ActivationStarted"); assert.equal(started.event.payload.cause.type, "VOICE_INTERACTION"); const stopped = await ask("movie-night", "Alexa.SceneController", "Deactivate"); assert.equal(name(stopped), "Alexa.SceneController.DeactivationStarted"); }); test("doorbell.js: a press is raised as DoorbellPress, a second one within 30 seconds is not", async () => { const { discover, alexa, root, example } = await start("doorbell.js"); const [endpoint] = await discover(); assert.deepEqual(interfaces(endpoint), ["Alexa.DoorbellEventSource", "Alexa.EndpointHealth", "Alexa"]); example.write("\n"); await until(() => alexa.on(`${root}/event`).length === 1, 5000, "the DoorbellPress"); const [press] = alexa.on(`${root}/event`); assert.equal(name(press), "Alexa.DoorbellEventSource.DoorbellPress"); assert.equal(press.event.endpoint.endpointId, "front-doorbell"); assert.equal(press.event.payload.cause.type, "PHYSICAL_INTERACTION"); example.write("\n"); await until(() => /not sent: the last press/.test(example.output()), 5000, "the second press to be refused"); assert.equal(alexa.on(`${root}/event`).length, 1); }); test("errors.js: the answers of a device that says no", async () => { const { discover, ask } = await start("errors.js"); await discover(); const speed = (rangeValue) => ask("attic-fan", "Alexa.RangeController", "SetRangeValue", { rangeValue }, { instance: "Fan.Speed" }); const stopped = await speed(2); assert.equal(name(stopped), "Alexa.ErrorResponse"); assert.deepEqual(stopped.event.payload, { type: "NOT_IN_OPERATION", message: "The fan is off" }); await ask("attic-fan", "Alexa.PowerController", "TurnOn"); const fast = await speed(7); assert.deepEqual(fast.event.payload, { type: "VALUE_OUT_OF_RANGE", message: "The fan has the speeds 1 to 3", validRange: { minimumValue: 1, maximumValue: 3 }, }); assert.equal(state(await speed(3))["RangeController.rangeValue"], 3); const adjusted = await ask("attic-fan", "Alexa.RangeController", "AdjustRangeValue", { rangeValueDelta: 1, rangeValueDeltaDefault: false }, { instance: "Fan.Speed" }); assert.equal(adjusted.event.payload.type, "INVALID_DIRECTIVE"); }); test("plug.mjs: the library as an ES module", async () => { const { discover, ask } = await start("plug.mjs"); const [endpoint] = await discover(); assert.equal(endpoint.endpointId, "coffee-plug"); assert.deepEqual(interfaces(endpoint), ["Alexa.PowerController", "Alexa.EndpointHealth", "Alexa"]); const on = await ask("coffee-plug", "Alexa.PowerController", "TurnOn"); assert.deepEqual(state(on), { "PowerController.powerState": "ON", "EndpointHealth.connectivity": { value: "OK" } }); }); // examples/legacy const before = require("./fixtures/legacy-examples/discovery-1.5.2.json"); // What 2.0 announces differently for a device of the 1.x API (docs/wire-changes.md): the Alexa interface at the end // of the capabilities. Without it the answer is the one of 1.5.2. function as152(endpoints) { return endpoints.map((endpoint) => { const last = endpoint.capabilities[endpoint.capabilities.length - 1]; assert.deepEqual(last, { type: "AlexaInterface", interface: "Alexa", version: "3" }); return { ...endpoint, capabilities: endpoint.capabilities.slice(0, -1) }; }); } test("examples/legacy has the nine examples of 1.5.2", () => { assert.deepEqual(fs.readdirSync(LEGACY).filter((file) => file.endsWith(".js")).sort(), Object.keys(before).sort()); assert.equal(Object.keys(before).length, 9); }); for (const file of Object.keys(before)) { test(`legacy/${file} runs on 2.0 and is discovered as on 1.5.2`, async () => { const at = await local(); const root = `legacy-${file.replace(/\.js$/, "")}`; const alexa = await watcher(at.url, `${root}/#`); await run(file, { broker: at, root, directory: LEGACY, env: { MQTT_USERNAME: "u", MQTT_PASSWORD: "p", MQTT_ROOT_TOPIC: root } }); alexa.publish(`${root}/discover`, "{}"); await until(() => alexa.on(`${root}/discover_r`).length > 0, 5000, `${file} to answer the discovery`); assert.deepEqual(as152(alexa.on(`${root}/discover_r`)[0]), before[file]); }); }