examples: one runnable file per recipe on the 2.0 API, the 1.x files in examples/legacy

Ten recipes: lamp, colour lamp, thermostat, blind, lock (deferred), contact
sensor (ChangeReport), scene, doorbell (raise), typed errors, and a plug as an
ES module. Each reads ALEX2MQTT_USERNAME, _PASSWORD and _ROOT_TOPIC and names
the ones that are missing. The nine examples of 1.5.2 move to examples/legacy
unchanged.

test/examples.test.js starts every file with node, its broker connection sent
to a broker on 127.0.0.1 (test/helpers/loopback.js), discovers the device,
sends directives and checks the answers. A legacy file is started and its
discovery compared with the answer the 1.5.2 build gave, recorded in
test/fixtures/legacy-examples.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
David 2026-09-28 21:38:07 +00:00
parent 2cbcde563e
commit 8379a65d78
25 changed files with 1705 additions and 0 deletions

292
test/examples.test.js Normal file
View file

@ -0,0 +1,292 @@
"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 { "<interface>.<property>": 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]);
});
}

View file

@ -0,0 +1,770 @@
{
"BlindControl.js": [
{
"endpointId": "bedroom-blinds-1",
"friendlyName": "Bedroom Blinds",
"description": "Alexa to Node.js bridge",
"manufacturerName": "Alex2Node",
"displayCategories": [
"LIGHT"
],
"additionalAttributes": {
"manufacturer": "Alex2Node",
"model": "Alex2Node_v1.0.0",
"serialNumber": "Alex2Node",
"firmwareVersion": "1.0.0",
"softwareVersion": "1.0.0",
"customIdentifier": "Alex2Node"
},
"capabilities": [
{
"interface": "Alexa.PowerController",
"version": "3",
"type": "AlexaInterface",
"properties": {
"retrievable": true,
"proactivelyReported": false,
"supported": [
{
"name": "powerState"
}
]
}
},
{
"interface": "Alexa.ToggleController",
"version": "3",
"type": "AlexaInterface",
"properties": {
"retrievable": true,
"proactivelyReported": false,
"supported": [
{
"name": "toggleState"
}
]
},
"instance": "NodeJS.Toggle",
"capabilityResources": {
"friendlyNames": [
{
"@type": "text",
"value": {
"text": "Bedroom Blinds",
"locale": "en-US"
}
}
]
},
"semantics": {
"actionMappings": [
{
"@type": "ActionsToDirective",
"actions": [
"Alexa.Actions.Close"
],
"directive": {
"name": "TurnOn"
}
},
{
"@type": "ActionsToDirective",
"actions": [
"Alexa.Actions.Open"
],
"directive": {
"name": "TurnOff"
}
}
]
}
}
]
}
],
"ExampleModeController.js": [
{
"endpointId": "fan-1",
"friendlyName": "Test mode controller",
"description": "Alexa to Node.js bridge",
"manufacturerName": "Alex2Node",
"displayCategories": [
"OTHER"
],
"additionalAttributes": {
"manufacturer": "Alex2Node",
"model": "Alex2Node_v1.0.0",
"serialNumber": "Alex2Node",
"firmwareVersion": "1.0.0",
"softwareVersion": "1.0.0",
"customIdentifier": "Alex2Node"
},
"capabilities": [
{
"interface": "Alexa.ModeController",
"version": "3",
"type": "AlexaInterface",
"properties": {
"retrievable": true,
"proactivelyReported": false,
"supported": [
{
"name": "mode"
}
]
},
"configuration": {
"ordered": true,
"supportedModes": [
{
"value": "mode.fanmode.Normal",
"modeResources": {
"friendlyNames": [
{
"@type": "text",
"value": {
"text": "Normal",
"locale": "en-US"
}
}
]
}
},
{
"value": "mode.fanmode.Low",
"modeResources": {
"friendlyNames": [
{
"@type": "text",
"value": {
"text": "Low",
"locale": "en-US"
}
}
]
}
}
]
},
"instance": "mode.fanmode",
"capabilityResources": {
"friendlyNames": [
{
"@type": "text",
"value": {
"text": "Fan Mode",
"locale": "en-US"
}
}
]
}
}
]
}
],
"ExamplePowerController.js": [
{
"endpointId": "bedroom-light-1",
"friendlyName": "Bedroom Light",
"description": "Alexa to Node.js bridge",
"manufacturerName": "Alex2Node",
"displayCategories": [
"LIGHT"
],
"additionalAttributes": {
"manufacturer": "Alex2Node",
"model": "Alex2Node_v1.0.0",
"serialNumber": "Alex2Node",
"firmwareVersion": "1.0.0",
"softwareVersion": "1.0.0",
"customIdentifier": "Alex2Node"
},
"capabilities": [
{
"interface": "Alexa.PowerController",
"version": "3",
"type": "AlexaInterface",
"properties": {
"retrievable": true,
"proactivelyReported": false,
"supported": [
{
"name": "powerState"
}
]
}
}
]
}
],
"ExamplePowerControllerDeferred.js": [
{
"endpointId": "deferred-light-1",
"friendlyName": "Deferred Light",
"description": "Alexa to Node.js bridge",
"manufacturerName": "Alex2Node",
"displayCategories": [
"LIGHT"
],
"additionalAttributes": {
"manufacturer": "Alex2Node",
"model": "Alex2Node_v1.0.0",
"serialNumber": "Alex2Node",
"firmwareVersion": "1.0.0",
"softwareVersion": "1.0.0",
"customIdentifier": "Alex2Node"
},
"capabilities": [
{
"interface": "Alexa.PowerController",
"version": "3",
"type": "AlexaInterface",
"properties": {
"retrievable": true,
"proactivelyReported": false,
"supported": [
{
"name": "powerState"
}
]
}
}
]
}
],
"ExamplePowerControllerWithBrightness.js": [
{
"endpointId": "living-room-light-1",
"friendlyName": "Living Room Light",
"description": "Alexa to Node.js bridge",
"manufacturerName": "Alex2Node",
"displayCategories": [
"LIGHT"
],
"additionalAttributes": {
"manufacturer": "Alex2Node",
"model": "Alex2Node_v1.0.0",
"serialNumber": "Alex2Node",
"firmwareVersion": "1.0.0",
"softwareVersion": "1.0.0",
"customIdentifier": "Alex2Node"
},
"capabilities": [
{
"interface": "Alexa.PowerController",
"version": "3",
"type": "AlexaInterface",
"properties": {
"retrievable": true,
"proactivelyReported": false,
"supported": [
{
"name": "powerState"
}
]
}
},
{
"interface": "Alexa.BrightnessController",
"version": "3",
"type": "AlexaInterface",
"properties": {
"retrievable": true,
"proactivelyReported": false,
"supported": [
{
"name": "brightness"
}
]
}
}
]
}
],
"ExamplePowerControllerWithErrors.js": [
{
"endpointId": "bad-light-1",
"friendlyName": "bad light",
"description": "Alexa to Node.js bridge",
"manufacturerName": "Alex2Node",
"displayCategories": [
"LIGHT"
],
"additionalAttributes": {
"manufacturer": "Alex2Node",
"model": "Alex2Node_v1.0.0",
"serialNumber": "Alex2Node",
"firmwareVersion": "1.0.0",
"softwareVersion": "1.0.0",
"customIdentifier": "Alex2Node"
},
"capabilities": [
{
"interface": "Alexa.PowerController",
"version": "3",
"type": "AlexaInterface",
"properties": {
"retrievable": true,
"proactivelyReported": false,
"supported": [
{
"name": "powerState"
}
]
}
}
]
}
],
"ExamplePowerControllerWithMomentary.js": [
{
"endpointId": "kitchen-light-1",
"friendlyName": "Kitchen Light",
"description": "Alexa to Node.js bridge",
"manufacturerName": "Alex2Node",
"displayCategories": [
"LIGHT"
],
"additionalAttributes": {
"manufacturer": "Alex2Node",
"model": "Alex2Node_v1.0.0",
"serialNumber": "Alex2Node",
"firmwareVersion": "1.0.0",
"softwareVersion": "1.0.0",
"customIdentifier": "Alex2Node"
},
"capabilities": [
{
"interface": "Alexa.PowerController",
"version": "3",
"type": "AlexaInterface",
"properties": {
"retrievable": true,
"proactivelyReported": false,
"supported": [
{
"name": "powerState"
}
]
}
},
{
"interface": "Alexa.ModeController",
"version": "3",
"type": "AlexaInterface",
"properties": {
"retrievable": true,
"proactivelyReported": false,
"supported": [
{
"name": "mode"
}
]
},
"configuration": {
"ordered": true,
"supportedModes": [
{
"value": "mode.signal.beep.Normal",
"modeResources": {
"friendlyNames": [
{
"@type": "text",
"value": {
"text": "Normal",
"locale": "en-US"
}
}
]
}
},
{
"value": "mode.signal.beep.Beep",
"modeResources": {
"friendlyNames": [
{
"@type": "text",
"value": {
"text": "Beep",
"locale": "en-US"
}
}
]
}
}
]
},
"instance": "mode.signal.beep",
"capabilityResources": {
"friendlyNames": [
{
"@type": "text",
"value": {
"text": "Signal",
"locale": "en-US"
}
}
]
}
}
]
}
],
"ExampleThermostat.js": [
{
"endpointId": "thermostat-1",
"friendlyName": "Thermostat Test",
"description": "Alexa to Node.js bridge",
"manufacturerName": "Alex2Node",
"displayCategories": [
"TEMPERATURE_SENSOR",
"THERMOSTAT",
"OTHER"
],
"additionalAttributes": {
"manufacturer": "Alex2Node",
"model": "Alex2Node_v1.0.0",
"serialNumber": "Alex2Node",
"firmwareVersion": "1.0.0",
"softwareVersion": "1.0.0",
"customIdentifier": "Alex2Node"
},
"capabilities": [
{
"interface": "Alexa.ThermostatController",
"version": "3.2",
"type": "AlexaInterface",
"properties": {
"retrievable": true,
"proactivelyReported": false,
"supported": [
{
"name": "targetSetpoint"
},
{
"name": "lowerSetpoint"
},
{
"name": "upperSetpoint"
},
{
"name": "thermostatMode"
}
]
},
"configuration": {
"supportedModes": [
"HEAT",
"COOL",
"AUTO",
"OFF"
],
"supportsScheduling": false
}
},
{
"interface": "Alexa.TemperatureSensor",
"version": "3",
"type": "AlexaInterface",
"properties": {
"retrievable": true,
"proactivelyReported": false,
"supported": [
{
"name": "temperature"
}
]
}
},
{
"interface": "Alexa.ModeController",
"version": "3",
"type": "AlexaInterface",
"properties": {
"retrievable": true,
"proactivelyReported": false,
"supported": [
{
"name": "mode"
}
]
},
"configuration": {
"ordered": true,
"supportedModes": [
{
"value": "mode.fanmode.Auto",
"modeResources": {
"friendlyNames": [
{
"@type": "text",
"value": {
"text": "Auto",
"locale": "en-US"
}
}
]
}
},
{
"value": "mode.fanmode.On",
"modeResources": {
"friendlyNames": [
{
"@type": "text",
"value": {
"text": "On",
"locale": "en-US"
}
}
]
}
}
]
},
"instance": "mode.fanmode",
"capabilityResources": {
"friendlyNames": [
{
"@type": "text",
"value": {
"text": "Fan",
"locale": "en-US"
}
}
]
}
}
]
}
],
"ExampleToggleController.js": [
{
"endpointId": "custom-device-1",
"friendlyName": "Custom Device",
"description": "Alexa to Node.js bridge",
"manufacturerName": "Alex2Node",
"displayCategories": [
"LIGHT"
],
"additionalAttributes": {
"manufacturer": "Alex2Node",
"model": "Alex2Node_v1.0.0",
"serialNumber": "Alex2Node",
"firmwareVersion": "1.0.0",
"softwareVersion": "1.0.0",
"customIdentifier": "Alex2Node"
},
"capabilities": [
{
"interface": "Alexa.ToggleController",
"version": "3",
"type": "AlexaInterface",
"properties": {
"retrievable": true,
"proactivelyReported": false,
"supported": [
{
"name": "toggleState"
}
]
},
"instance": "nodejs.toggle.switch1",
"capabilityResources": {
"friendlyNames": [
{
"@type": "text",
"value": {
"text": "Switch 1",
"locale": "en-US"
}
}
]
}
},
{
"interface": "Alexa.ToggleController",
"version": "3",
"type": "AlexaInterface",
"properties": {
"retrievable": true,
"proactivelyReported": false,
"supported": [
{
"name": "toggleState"
}
]
},
"instance": "nodejs.toggle.switch2",
"capabilityResources": {
"friendlyNames": [
{
"@type": "text",
"value": {
"text": "Switch 2",
"locale": "en-US"
}
}
]
}
},
{
"interface": "Alexa.ToggleController",
"version": "3",
"type": "AlexaInterface",
"properties": {
"retrievable": true,
"proactivelyReported": false,
"supported": [
{
"name": "toggleState"
}
]
},
"instance": "nodejs.toggle.switch3",
"capabilityResources": {
"friendlyNames": [
{
"@type": "text",
"value": {
"text": "Switch 3",
"locale": "en-US"
}
}
]
}
},
{
"interface": "Alexa.ToggleController",
"version": "3",
"type": "AlexaInterface",
"properties": {
"retrievable": true,
"proactivelyReported": false,
"supported": [
{
"name": "toggleState"
}
]
},
"instance": "nodejs.toggle.switch4",
"capabilityResources": {
"friendlyNames": [
{
"@type": "text",
"value": {
"text": "Switch 4",
"locale": "en-US"
}
}
]
}
},
{
"interface": "Alexa.ToggleController",
"version": "3",
"type": "AlexaInterface",
"properties": {
"retrievable": true,
"proactivelyReported": false,
"supported": [
{
"name": "toggleState"
}
]
},
"instance": "nodejs.toggle.switch5",
"capabilityResources": {
"friendlyNames": [
{
"@type": "text",
"value": {
"text": "Switch 5",
"locale": "en-US"
}
}
]
}
},
{
"interface": "Alexa.ToggleController",
"version": "3",
"type": "AlexaInterface",
"properties": {
"retrievable": true,
"proactivelyReported": false,
"supported": [
{
"name": "toggleState"
}
]
},
"instance": "nodejs.toggle.switch6",
"capabilityResources": {
"friendlyNames": [
{
"@type": "text",
"value": {
"text": "Switch 6",
"locale": "en-US"
}
}
]
}
},
{
"interface": "Alexa.ToggleController",
"version": "3",
"type": "AlexaInterface",
"properties": {
"retrievable": true,
"proactivelyReported": false,
"supported": [
{
"name": "toggleState"
}
]
},
"instance": "nodejs.toggle.switch7",
"capabilityResources": {
"friendlyNames": [
{
"@type": "text",
"value": {
"text": "Switch 7",
"locale": "en-US"
}
}
]
}
},
{
"interface": "Alexa.ToggleController",
"version": "3",
"type": "AlexaInterface",
"properties": {
"retrievable": true,
"proactivelyReported": false,
"supported": [
{
"name": "toggleState"
}
]
},
"instance": "nodejs.toggle.switch8",
"capabilityResources": {
"friendlyNames": [
{
"@type": "text",
"value": {
"text": "Switch 8",
"locale": "en-US"
}
}
]
}
}
]
}
]
}

53
test/helpers/examples.js Normal file
View file

@ -0,0 +1,53 @@
"use strict";
// Runs a file of examples/ as its reader does, with node, in a process of its own. The process gets the environment
// the test names and nothing else, a directory without a .env file, and test/helpers/loopback.js, which sends its
// broker connection to the broker of the test.
const { spawn } = require("node:child_process");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { cleanup, until } = require("./harness.js");
const ROOT = path.join(__dirname, "..", "..");
const LOOPBACK = path.join(__dirname, "loopback.js");
/**
* Start examples/<file>. broker is one of harness.broker(); without it the example can connect nowhere. Resolves
* with { child, output, write, exited, stop } once the example has subscribed to <root>/discover or, without a
* root, at once. output() is what it wrote to stdout and stderr so far.
*/
async function run(file, { broker, root, env = {}, directory = path.join(ROOT, "examples") } = {}) {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "alex2node-example-"));
let subscribed = false;
const onSubscribe = (subscriptions) => {
if (subscriptions.some(({ topic }) => topic === `${root}/discover` || topic === `${root}/#`)) subscribed = true;
};
if (broker && root) broker.aedes.on("subscribe", onSubscribe);
const child = spawn(process.execPath, ["--require", LOOPBACK, path.join(directory, file)], {
cwd,
env: { PATH: process.env.PATH, LOOPBACK_PORT: String(broker ? broker.port : 1), ...env },
stdio: ["pipe", "pipe", "pipe"],
});
let text = "";
child.stdout.on("data", (chunk) => { text += chunk; });
child.stderr.on("data", (chunk) => { text += chunk; });
const exited = new Promise((resolve) => child.once("exit", (code) => resolve(code)));
const stop = async () => {
if (broker && root) broker.aedes.off("subscribe", onSubscribe);
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
await exited;
fs.rmdirSync(cwd);
};
cleanup(stop);
const started = { child, output: () => text, write: (line) => child.stdin.write(line), exited, stop };
if (broker && root) {
await until(() => subscribed || child.exitCode !== null, 10000, `${file} to subscribe`);
broker.aedes.off("subscribe", onSubscribe);
if (!subscribed) throw new Error(`${file} ended before it subscribed:\n${text}`);
}
return started;
}
module.exports = { run, ROOT };

16
test/helpers/loopback.js Normal file
View file

@ -0,0 +1,16 @@
"use strict";
// Preloaded with node --require into an example that a test runs. An example connects to the broker of Alex2MQTT,
// the test has one on 127.0.0.1: every TCP connection of the process goes to LOOPBACK_PORT on 127.0.0.1, whatever
// host and port it names. Nothing of an example under test leaves the machine.
const net = require("node:net");
const port = Number(process.env.LOOPBACK_PORT);
if (!Number.isInteger(port) || port <= 0) throw new Error("LOOPBACK_PORT is not set: the port of the broker of the test");
const connect = net.Socket.prototype.connect;
net.Socket.prototype.connect = function toLoopback(...args) {
// connect(options, listener), connect(port, host, listener), or the two in an array as net.createConnection() passes them
const given = Array.isArray(args[0]) ? args[0] : args;
const listener = given.find((arg) => typeof arg === "function");
return connect.call(this, { host: "127.0.0.1", port }, ...(listener ? [listener] : []));
};