From 8379a65d7892a2b50769f31c4390e1e7cff1a6b6 Mon Sep 17 00:00:00 2001 From: David Date: Mon, 28 Sep 2026 21:38:07 +0000 Subject: [PATCH] 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 --- examples/README.md | 42 + examples/blind.js | 54 ++ examples/color-lamp.js | 76 ++ examples/doorbell.js | 40 + examples/errors.js | 61 ++ examples/lamp.js | 52 ++ examples/{ => legacy}/BlindControl.js | 0 .../{ => legacy}/ExampleModeController.js | 0 .../{ => legacy}/ExamplePowerController.js | 0 .../ExamplePowerControllerDeferred.js | 0 .../ExamplePowerControllerWithBrightness.js | 0 .../ExamplePowerControllerWithErrors.js | 0 .../ExamplePowerControllerWithMomentary.js | 0 examples/{ => legacy}/ExampleThermostat.js | 0 .../{ => legacy}/ExampleToggleController.js | 0 examples/legacy/README.md | 12 + examples/lock.js | 49 ++ examples/plug.mjs | 35 + examples/scene.js | 41 + examples/sensor.js | 40 + examples/thermostat.js | 72 ++ test/examples.test.js | 292 +++++++ .../legacy-examples/discovery-1.5.2.json | 770 ++++++++++++++++++ test/helpers/examples.js | 53 ++ test/helpers/loopback.js | 16 + 25 files changed, 1705 insertions(+) create mode 100644 examples/README.md create mode 100644 examples/blind.js create mode 100644 examples/color-lamp.js create mode 100644 examples/doorbell.js create mode 100644 examples/errors.js create mode 100644 examples/lamp.js rename examples/{ => legacy}/BlindControl.js (100%) rename examples/{ => legacy}/ExampleModeController.js (100%) rename examples/{ => legacy}/ExamplePowerController.js (100%) rename examples/{ => legacy}/ExamplePowerControllerDeferred.js (100%) rename examples/{ => legacy}/ExamplePowerControllerWithBrightness.js (100%) rename examples/{ => legacy}/ExamplePowerControllerWithErrors.js (100%) rename examples/{ => legacy}/ExamplePowerControllerWithMomentary.js (100%) rename examples/{ => legacy}/ExampleThermostat.js (100%) rename examples/{ => legacy}/ExampleToggleController.js (100%) create mode 100644 examples/legacy/README.md create mode 100644 examples/lock.js create mode 100644 examples/plug.mjs create mode 100644 examples/scene.js create mode 100644 examples/sensor.js create mode 100644 examples/thermostat.js create mode 100644 test/examples.test.js create mode 100644 test/fixtures/legacy-examples/discovery-1.5.2.json create mode 100644 test/helpers/examples.js create mode 100644 test/helpers/loopback.js diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..ff0168e --- /dev/null +++ b/examples/README.md @@ -0,0 +1,42 @@ +# Examples + +One file per recipe, on the 2.0 API. Each file is complete: it declares one device, answers its directives and +connects. The state of the device is an object in the file, which you replace with what drives your device. + +| File | Device | Interfaces | Shows | +|---|---|---|---| +| [lamp.js](lamp.js) | lamp | PowerController, BrightnessController | handlers, `device.state()`, `ctx.respond()` | +| [color-lamp.js](color-lamp.js) | colour lamp | + ColorController, ColorTemperatureController | a state that depends on the mode of the device | +| [thermostat.js](thermostat.js) | thermostat | ThermostatController, TemperatureSensor | one setpoint, three modes, temperature scales | +| [blind.js](blind.js) | roller blind | RangeController | an instance, friendly names, semantics for open and close | +| [lock.js](lock.js) | lock | LockController | `ctx.defer()` for an answer that takes longer than 7 seconds | +| [sensor.js](sensor.js) | contact sensor | ContactSensor | `device.changeReport()` | +| [scene.js](scene.js) | scene | SceneController | ActivationStarted and DeactivationStarted | +| [doorbell.js](doorbell.js) | doorbell | DoorbellEventSource | `device.raise()` | +| [errors.js](errors.js) | fan | PowerController, RangeController | `AlexaErrors`, `ctx.error()` | +| [plug.mjs](plug.mjs) | plug | PowerController | the library as an ES module | + +## Run one + +The examples read the MQTT user name, the password and the root topic of your Alex2MQTT account from the +environment, and say which variable is missing: + +```sh +ALEX2MQTT_USERNAME=... ALEX2MQTT_PASSWORD=... ALEX2MQTT_ROOT_TOPIC=... node examples/lamp.js +``` + +In a checkout of this repository `npm install` comes first: it builds `dist/`, which `require("alex2node")` resolves +to. Then discover the devices in the Alexa app. + +`sensor.js` and `doorbell.js` wait for the Enter key: it opens and closes the door, and rings the bell. +`lock.js` takes `BOLT_SECONDS`, the time its bolt needs, 8 by default. + +## What was tested + +`test/examples.test.js` starts every file with `node`, against a broker on 127.0.0.1, discovers the device, sends +it directives and checks the answers. The examples were not run against an Alexa account. `doorbell.js` publishes +to `/event`, and whether a press reaches Alexa depends on the Alex2MQTT service relaying that topic. + +## 1.x + +[legacy/](legacy) has the nine examples of 1.5.2, on the 1.x API. diff --git a/examples/blind.js b/examples/blind.js new file mode 100644 index 0000000..018355d --- /dev/null +++ b/examples/blind.js @@ -0,0 +1,54 @@ +// A roller blind: Alexa.RangeController with the position in percent, 0 closed and 100 open. The semantics say +// what "open", "close", "raise" and "lower" do, and which positions count as open and as closed. +// +// ALEX2MQTT_USERNAME=... ALEX2MQTT_PASSWORD=... ALEX2MQTT_ROOT_TOPIC=... node examples/blind.js +// +// "alex2node" is the installed package; inside this checkout it resolves to the built dist/. +const { Alex2MQTT, RangeController, asset, text, semantics } = require("alex2node"); + +const missing = ["ALEX2MQTT_USERNAME", "ALEX2MQTT_PASSWORD", "ALEX2MQTT_ROOT_TOPIC"].filter((name) => !process.env[name]); +if (missing.length > 0) { + console.error(`${missing.join(", ")} not set. Set the MQTT user name, the password and the root topic of your Alex2MQTT account.`); + process.exit(1); +} +const { ALEX2MQTT_USERNAME, ALEX2MQTT_PASSWORD, ALEX2MQTT_ROOT_TOPIC } = process.env; + +const bridge = new Alex2MQTT(ALEX2MQTT_USERNAME, ALEX2MQTT_PASSWORD, ALEX2MQTT_ROOT_TOPIC); +bridge.on("connect", () => console.log("connected, discover the devices in the Alexa app")); +bridge.on("error", (err) => console.error("bridge:", err.message)); + +const blind = { position: 0 }; + +const device = bridge.addDevice({ endpointId: "bedroom-blind", name: "Bedroom Blind", categories: ["INTERIOR_BLIND"] }); +const lift = device.add(RangeController, { + instance: "Blind.Lift", + friendlyNames: [asset("Alexa.Setting.Opening"), text("Position", "en-US")], + range: { min: 0, max: 100, precision: 1 }, + unit: "Alexa.Unit.Percent", + semantics: semantics() + .action("Close", "SetRangeValue", { rangeValue: 0 }) + .action("Open", "SetRangeValue", { rangeValue: 100 }) + .action("Lower", "AdjustRangeValue", { rangeValueDelta: -10, rangeValueDeltaDefault: false }) + .action("Raise", "AdjustRangeValue", { rangeValueDelta: 10, rangeValueDeltaDefault: false }) + .state("Closed", 0) + .stateRange("Open", 1, 100), +}); + +// lift carries the instance: the property is reported under "Blind.Lift" +device.state((s) => s.set(lift, "rangeValue", blind.position).health("OK")); + +const within = (position) => Math.min(100, Math.max(0, Math.round(position))); + +lift.on("SetRangeValue", (ctx) => { + blind.position = within(ctx.payload.rangeValue); + return ctx.respond(); +}); +// rangeValueDeltaDefault: the user named no amount, and the blind picks its own step +lift.on("AdjustRangeValue", (ctx) => { + const { rangeValueDelta, rangeValueDeltaDefault } = ctx.payload; + const delta = rangeValueDeltaDefault ? Math.sign(rangeValueDelta) * 10 : rangeValueDelta; + blind.position = within(blind.position + delta); + return ctx.respond(); +}); + +bridge.connect(); diff --git a/examples/color-lamp.js b/examples/color-lamp.js new file mode 100644 index 0000000..af0f488 --- /dev/null +++ b/examples/color-lamp.js @@ -0,0 +1,76 @@ +// A lamp that shows a colour or a white: Alexa.ColorController and Alexa.ColorTemperatureController, with power +// and brightness. It reports the colour or the colour temperature, whichever it shows. +// +// ALEX2MQTT_USERNAME=... ALEX2MQTT_PASSWORD=... ALEX2MQTT_ROOT_TOPIC=... node examples/color-lamp.js +// +// "alex2node" is the installed package; inside this checkout it resolves to the built dist/. +const { Alex2MQTT, AlexaErrors, PowerController, BrightnessController, ColorController, ColorTemperatureController } = require("alex2node"); + +const missing = ["ALEX2MQTT_USERNAME", "ALEX2MQTT_PASSWORD", "ALEX2MQTT_ROOT_TOPIC"].filter((name) => !process.env[name]); +if (missing.length > 0) { + console.error(`${missing.join(", ")} not set. Set the MQTT user name, the password and the root topic of your Alex2MQTT account.`); + process.exit(1); +} +const { ALEX2MQTT_USERNAME, ALEX2MQTT_PASSWORD, ALEX2MQTT_ROOT_TOPIC } = process.env; + +const bridge = new Alex2MQTT(ALEX2MQTT_USERNAME, ALEX2MQTT_PASSWORD, ALEX2MQTT_ROOT_TOPIC); +bridge.on("connect", () => console.log("connected, discover the devices in the Alexa app")); +bridge.on("error", (err) => console.error("bridge:", err.message)); + +// The whites Alexa asks for by name: warm, soft, white, daylight, cool +const WHITES = [2200, 2700, 4000, 5500, 7000]; +// color is null while the lamp shows a white +const lamp = { on: false, brightness: 100, color: null, kelvin: 2700 }; + +const device = bridge.addDevice({ endpointId: "floor-lamp", name: "Floor Lamp", categories: ["LIGHT"] }); +const power = device.add(PowerController); +const brightness = device.add(BrightnessController); +const color = device.add(ColorController); +const white = device.add(ColorTemperatureController); + +device.state((s) => { + s.set(power, "powerState", lamp.on ? "ON" : "OFF").set(brightness, "brightness", lamp.brightness); + if (lamp.color) s.set(color, "color", { ...lamp.color, brightness: lamp.brightness / 100 }); + else s.set(white, "colorTemperatureInKelvin", lamp.kelvin); + s.health("OK"); +}); + +power.on("TurnOn", (ctx) => { + lamp.on = true; + return ctx.respond(); +}); +power.on("TurnOff", (ctx) => { + lamp.on = false; + return ctx.respond(); +}); +brightness.on("SetBrightness", (ctx) => { + lamp.brightness = ctx.payload.brightness; + lamp.on = true; + return ctx.respond(); +}); +// SetColor comes with brightness 1 whatever the lamp shows: the lamp takes hue and saturation and keeps its brightness +color.on("SetColor", (ctx) => { + const { hue, saturation } = ctx.payload.color; + lamp.color = { hue, saturation }; + lamp.on = true; + return ctx.respond(); +}); +white.on("SetColorTemperature", (ctx) => { + lamp.kelvin = Math.min(7000, Math.max(2200, ctx.payload.colorTemperatureInKelvin)); + lamp.color = null; + lamp.on = true; + return ctx.respond(); +}); +// "Warmer" and "cooler" have no meaning while the lamp shows a colour. A handler that throws answers with the error. +function step(by) { + return (ctx) => { + if (lamp.color) throw AlexaErrors.notSupportedInCurrentMode("The lamp shows a colour", "COLOR"); + const next = WHITES.indexOf(WHITES.find((kelvin) => kelvin >= lamp.kelvin) ?? 7000) + by; + lamp.kelvin = WHITES[Math.min(WHITES.length - 1, Math.max(0, next))]; + return ctx.respond(); + }; +} +white.on("IncreaseColorTemperature", step(1)); +white.on("DecreaseColorTemperature", step(-1)); + +bridge.connect(); diff --git a/examples/doorbell.js b/examples/doorbell.js new file mode 100644 index 0000000..ea37370 --- /dev/null +++ b/examples/doorbell.js @@ -0,0 +1,40 @@ +// A doorbell: Alexa.DoorbellEventSource. It takes no directive and reports no state of the button; +// device.raise() says that somebody rang. Press Enter to ring. +// +// The event is published to /event. Not tested with an Alexa account yet: whether the press reaches Alexa +// depends on the Alex2MQTT service relaying that topic. Alexa wants 30 seconds between two presses of one doorbell. +// +// ALEX2MQTT_USERNAME=... ALEX2MQTT_PASSWORD=... ALEX2MQTT_ROOT_TOPIC=... node examples/doorbell.js +// +// "alex2node" is the installed package; inside this checkout it resolves to the built dist/. +const { Alex2MQTT, DoorbellEventSource } = require("alex2node"); + +const missing = ["ALEX2MQTT_USERNAME", "ALEX2MQTT_PASSWORD", "ALEX2MQTT_ROOT_TOPIC"].filter((name) => !process.env[name]); +if (missing.length > 0) { + console.error(`${missing.join(", ")} not set. Set the MQTT user name, the password and the root topic of your Alex2MQTT account.`); + process.exit(1); +} +const { ALEX2MQTT_USERNAME, ALEX2MQTT_PASSWORD, ALEX2MQTT_ROOT_TOPIC } = process.env; + +const bridge = new Alex2MQTT(ALEX2MQTT_USERNAME, ALEX2MQTT_PASSWORD, ALEX2MQTT_ROOT_TOPIC); +bridge.on("connect", () => console.log("connected, discover the devices in the Alexa app")); +bridge.on("error", (err) => console.error("bridge:", err.message)); + +const device = bridge.addDevice({ endpointId: "front-doorbell", name: "Front Doorbell", categories: ["DOORBELL"] }); +device.add(DoorbellEventSource); + +device.state((s) => s.health("OK")); + +let rangAt = 0; +process.stdin.on("data", async () => { + if (Date.now() - rangAt < 30000) { + console.log("not sent: the last press is less than 30 seconds ago"); + return; + } + rangAt = Date.now(); + // raise() throws for an event the device did not declare, and resolves with what became of the publish + const result = await device.raise(DoorbellEventSource, "DoorbellPress"); + console.log(result.ok ? "the press was sent" : `not sent: ${result.error.message}`); +}); + +bridge.connect(); diff --git a/examples/errors.js b/examples/errors.js new file mode 100644 index 0000000..c55f6a4 --- /dev/null +++ b/examples/errors.js @@ -0,0 +1,61 @@ +// A fan that says no: the error types of Alexa as answers. A handler throws an AlexaError of AlexaErrors, or +// answers with ctx.error(). Anything else a handler throws is answered with INTERNAL_ERROR, and a directive without +// a handler with INVALID_DIRECTIVE. +// +// ALEX2MQTT_USERNAME=... ALEX2MQTT_PASSWORD=... ALEX2MQTT_ROOT_TOPIC=... node examples/errors.js +// +// "alex2node" is the installed package; inside this checkout it resolves to the built dist/. +const { Alex2MQTT, AlexaErrors, PowerController, RangeController, text } = require("alex2node"); + +const missing = ["ALEX2MQTT_USERNAME", "ALEX2MQTT_PASSWORD", "ALEX2MQTT_ROOT_TOPIC"].filter((name) => !process.env[name]); +if (missing.length > 0) { + console.error(`${missing.join(", ")} not set. Set the MQTT user name, the password and the root topic of your Alex2MQTT account.`); + process.exit(1); +} +const { ALEX2MQTT_USERNAME, ALEX2MQTT_PASSWORD, ALEX2MQTT_ROOT_TOPIC } = process.env; + +const bridge = new Alex2MQTT(ALEX2MQTT_USERNAME, ALEX2MQTT_PASSWORD, ALEX2MQTT_ROOT_TOPIC); +bridge.on("connect", () => console.log("connected, discover the devices in the Alexa app")); +bridge.on("error", (err) => console.error("bridge:", err.message)); + +const SPEEDS = { min: 1, max: 3 }; +const fan = { on: false, speed: 1 }; + +const device = bridge.addDevice({ endpointId: "attic-fan", name: "Attic Fan", categories: ["FAN"] }); +const power = device.add(PowerController); +const speed = device.add(RangeController, { + instance: "Fan.Speed", + friendlyNames: [text("Speed", "en-US")], + range: { min: SPEEDS.min, max: SPEEDS.max, precision: 1 }, +}); + +device.state((s) => s + .set(power, "powerState", fan.on ? "ON" : "OFF") + .set(speed, "rangeValue", fan.speed) + .health("OK")); + +power.on("TurnOn", (ctx) => { + fan.on = true; + return ctx.respond(); +}); +power.on("TurnOff", (ctx) => { + fan.on = false; + return ctx.respond(); +}); +speed.on("SetRangeValue", (ctx) => { + const { rangeValue } = ctx.payload; + // Without a throw: the type and the message + if (!fan.on) return ctx.error("NOT_IN_OPERATION", "The fan is off"); + // With a throw: every type has a helper, and the helper of a type with fields of its own takes them + if (!Number.isInteger(rangeValue) || rangeValue < SPEEDS.min || rangeValue > SPEEDS.max) { + throw AlexaErrors.valueOutOfRange( + `The fan has the speeds ${SPEEDS.min} to ${SPEEDS.max}`, + { minimumValue: SPEEDS.min, maximumValue: SPEEDS.max } + ); + } + fan.speed = rangeValue; + return ctx.respond(); +}); +// AdjustRangeValue has no handler: the bridge answers it with INVALID_DIRECTIVE + +bridge.connect(); diff --git a/examples/lamp.js b/examples/lamp.js new file mode 100644 index 0000000..8eb6c23 --- /dev/null +++ b/examples/lamp.js @@ -0,0 +1,52 @@ +// A lamp that switches and dims: Alexa.PowerController and Alexa.BrightnessController. +// +// ALEX2MQTT_USERNAME=... ALEX2MQTT_PASSWORD=... ALEX2MQTT_ROOT_TOPIC=... node examples/lamp.js +// +// "alex2node" is the installed package; inside this checkout it resolves to the built dist/. +const { Alex2MQTT, PowerController, BrightnessController } = require("alex2node"); + +const missing = ["ALEX2MQTT_USERNAME", "ALEX2MQTT_PASSWORD", "ALEX2MQTT_ROOT_TOPIC"].filter((name) => !process.env[name]); +if (missing.length > 0) { + console.error(`${missing.join(", ")} not set. Set the MQTT user name, the password and the root topic of your Alex2MQTT account.`); + process.exit(1); +} +const { ALEX2MQTT_USERNAME, ALEX2MQTT_PASSWORD, ALEX2MQTT_ROOT_TOPIC } = process.env; + +const bridge = new Alex2MQTT(ALEX2MQTT_USERNAME, ALEX2MQTT_PASSWORD, ALEX2MQTT_ROOT_TOPIC); +bridge.on("connect", () => console.log("connected, discover the devices in the Alexa app")); +bridge.on("error", (err) => console.error("bridge:", err.message)); + +// The lamp itself. Replace it with what drives yours. +const lamp = { on: false, brightness: 100 }; + +const device = bridge.addDevice({ endpointId: "desk-lamp", name: "Desk Lamp", categories: ["LIGHT"] }); +const power = device.add(PowerController); +const brightness = device.add(BrightnessController); + +// The whole state: the answer to ReportState, and the context of every ctx.respond() +device.state((s) => s + .set(power, "powerState", lamp.on ? "ON" : "OFF") + .set(brightness, "brightness", lamp.brightness) + .health("OK")); + +power.on("TurnOn", (ctx) => { + lamp.on = true; + return ctx.respond(); +}); +power.on("TurnOff", (ctx) => { + lamp.on = false; + return ctx.respond(); +}); +// Both brightness directives turn a lamp on that is off +brightness.on("SetBrightness", (ctx) => { + lamp.brightness = ctx.payload.brightness; + lamp.on = true; + return ctx.respond(); +}); +brightness.on("AdjustBrightness", (ctx) => { + lamp.brightness = Math.min(100, Math.max(0, lamp.brightness + ctx.payload.brightnessDelta)); + lamp.on = true; + return ctx.respond(); +}); + +bridge.connect(); diff --git a/examples/BlindControl.js b/examples/legacy/BlindControl.js similarity index 100% rename from examples/BlindControl.js rename to examples/legacy/BlindControl.js diff --git a/examples/ExampleModeController.js b/examples/legacy/ExampleModeController.js similarity index 100% rename from examples/ExampleModeController.js rename to examples/legacy/ExampleModeController.js diff --git a/examples/ExamplePowerController.js b/examples/legacy/ExamplePowerController.js similarity index 100% rename from examples/ExamplePowerController.js rename to examples/legacy/ExamplePowerController.js diff --git a/examples/ExamplePowerControllerDeferred.js b/examples/legacy/ExamplePowerControllerDeferred.js similarity index 100% rename from examples/ExamplePowerControllerDeferred.js rename to examples/legacy/ExamplePowerControllerDeferred.js diff --git a/examples/ExamplePowerControllerWithBrightness.js b/examples/legacy/ExamplePowerControllerWithBrightness.js similarity index 100% rename from examples/ExamplePowerControllerWithBrightness.js rename to examples/legacy/ExamplePowerControllerWithBrightness.js diff --git a/examples/ExamplePowerControllerWithErrors.js b/examples/legacy/ExamplePowerControllerWithErrors.js similarity index 100% rename from examples/ExamplePowerControllerWithErrors.js rename to examples/legacy/ExamplePowerControllerWithErrors.js diff --git a/examples/ExamplePowerControllerWithMomentary.js b/examples/legacy/ExamplePowerControllerWithMomentary.js similarity index 100% rename from examples/ExamplePowerControllerWithMomentary.js rename to examples/legacy/ExamplePowerControllerWithMomentary.js diff --git a/examples/ExampleThermostat.js b/examples/legacy/ExampleThermostat.js similarity index 100% rename from examples/ExampleThermostat.js rename to examples/legacy/ExampleThermostat.js diff --git a/examples/ExampleToggleController.js b/examples/legacy/ExampleToggleController.js similarity index 100% rename from examples/ExampleToggleController.js rename to examples/legacy/ExampleToggleController.js diff --git a/examples/legacy/README.md b/examples/legacy/README.md new file mode 100644 index 0000000..87a73b0 --- /dev/null +++ b/examples/legacy/README.md @@ -0,0 +1,12 @@ +# Examples of 1.5.2 + +The nine examples of alex2node 1.5.2, as they were. They run on 2.0 through its compat layer, which keeps the 1.x +API: `registerDevice()`, `addCapability()`, the `Event` and `ReportState` listeners, `getStatusMessage()` and +`getErrorMessage()`. `test/examples.test.js` starts each of them and compares its discovery answer with the one +1.5.2 gave; 2.0 adds the `Alexa` interface at the end of the capabilities and changes nothing else in them. + +They read `MQTT_USERNAME`, `MQTT_PASSWORD` and `MQTT_ROOT_TOPIC` from the environment or from a `.env` file, with +`dotenv`, which is a development dependency of this repository. + +For a new program use the examples one directory up. [docs/wire-changes.md](../../docs/wire-changes.md) lists what +a 1.x program can observe of 2.0. diff --git a/examples/lock.js b/examples/lock.js new file mode 100644 index 0000000..9505f85 --- /dev/null +++ b/examples/lock.js @@ -0,0 +1,49 @@ +// A lock whose bolt takes longer than the 7 seconds Alex2MQTT waits for an answer: Alexa.LockController. The +// handler sends a DeferredResponse at once and the Response when the bolt has moved. Alex2MQTT waits 10 seconds for +// the Response after a DeferredResponse. +// +// ALEX2MQTT_USERNAME=... ALEX2MQTT_PASSWORD=... ALEX2MQTT_ROOT_TOPIC=... node examples/lock.js +// +// "alex2node" is the installed package; inside this checkout it resolves to the built dist/. +const { Alex2MQTT, LockController } = require("alex2node"); + +const missing = ["ALEX2MQTT_USERNAME", "ALEX2MQTT_PASSWORD", "ALEX2MQTT_ROOT_TOPIC"].filter((name) => !process.env[name]); +if (missing.length > 0) { + console.error(`${missing.join(", ")} not set. Set the MQTT user name, the password and the root topic of your Alex2MQTT account.`); + process.exit(1); +} +const { ALEX2MQTT_USERNAME, ALEX2MQTT_PASSWORD, ALEX2MQTT_ROOT_TOPIC } = process.env; + +const bridge = new Alex2MQTT(ALEX2MQTT_USERNAME, ALEX2MQTT_PASSWORD, ALEX2MQTT_ROOT_TOPIC); +bridge.on("connect", () => console.log("connected, discover the devices in the Alexa app")); +bridge.on("error", (err) => console.error("bridge:", err.message)); + +// How long the bolt takes, in seconds +const BOLT_SECONDS = Number(process.env.BOLT_SECONDS ?? 8); +const lock = { state: "LOCKED" }; + +const moveBolt = (state) => new Promise((resolve) => { + setTimeout(() => { + lock.state = state; + resolve(); + }, BOLT_SECONDS * 1000); +}); + +const device = bridge.addDevice({ endpointId: "front-door-lock", name: "Front Door", categories: ["SMARTLOCK"] }); +const bolt = device.add(LockController); + +device.state((s) => s.set(bolt, "lockState", lock.state).health("OK")); + +function move(state) { + return async (ctx) => { + // The argument is optional: how many seconds the answer will take + await ctx.defer(Math.ceil(BOLT_SECONDS)); + await moveBolt(state); + // After defer() the answer goes to //deferredResponse + return ctx.respond(); + }; +} +bolt.on("Lock", move("LOCKED")); +bolt.on("Unlock", move("UNLOCKED")); + +bridge.connect(); diff --git a/examples/plug.mjs b/examples/plug.mjs new file mode 100644 index 0000000..f523567 --- /dev/null +++ b/examples/plug.mjs @@ -0,0 +1,35 @@ +// A plug, as an ES module: the same library with import. Alexa.PowerController. +// +// ALEX2MQTT_USERNAME=... ALEX2MQTT_PASSWORD=... ALEX2MQTT_ROOT_TOPIC=... node examples/plug.mjs +// +// "alex2node" is the installed package; inside this checkout it resolves to the built dist/. +import { Alex2MQTT, PowerController } from "alex2node"; + +const missing = ["ALEX2MQTT_USERNAME", "ALEX2MQTT_PASSWORD", "ALEX2MQTT_ROOT_TOPIC"].filter((name) => !process.env[name]); +if (missing.length > 0) { + console.error(`${missing.join(", ")} not set. Set the MQTT user name, the password and the root topic of your Alex2MQTT account.`); + process.exit(1); +} +const { ALEX2MQTT_USERNAME, ALEX2MQTT_PASSWORD, ALEX2MQTT_ROOT_TOPIC } = process.env; + +const bridge = new Alex2MQTT(ALEX2MQTT_USERNAME, ALEX2MQTT_PASSWORD, ALEX2MQTT_ROOT_TOPIC); +bridge.on("connect", () => console.log("connected, discover the devices in the Alexa app")); +bridge.on("error", (err) => console.error("bridge:", err.message)); + +const plug = { on: false }; + +const device = bridge.addDevice({ endpointId: "coffee-plug", name: "Coffee Machine", categories: ["SMARTPLUG"] }); +const power = device.add(PowerController); + +device.state((s) => s.set(power, "powerState", plug.on ? "ON" : "OFF").health("OK")); + +power.on("TurnOn", (ctx) => { + plug.on = true; + return ctx.respond(); +}); +power.on("TurnOff", (ctx) => { + plug.on = false; + return ctx.respond(); +}); + +bridge.connect(); diff --git a/examples/scene.js b/examples/scene.js new file mode 100644 index 0000000..3f30e31 --- /dev/null +++ b/examples/scene.js @@ -0,0 +1,41 @@ +// A scene: Alexa.SceneController. A scene is an endpoint that is not a device. It reports no state, and Activate +// and Deactivate are answered with ActivationStarted and DeactivationStarted, which ctx.respond() sends. +// +// ALEX2MQTT_USERNAME=... ALEX2MQTT_PASSWORD=... ALEX2MQTT_ROOT_TOPIC=... node examples/scene.js +// +// "alex2node" is the installed package; inside this checkout it resolves to the built dist/. +const { Alex2MQTT, SceneController } = require("alex2node"); + +const missing = ["ALEX2MQTT_USERNAME", "ALEX2MQTT_PASSWORD", "ALEX2MQTT_ROOT_TOPIC"].filter((name) => !process.env[name]); +if (missing.length > 0) { + console.error(`${missing.join(", ")} not set. Set the MQTT user name, the password and the root topic of your Alex2MQTT account.`); + process.exit(1); +} +const { ALEX2MQTT_USERNAME, ALEX2MQTT_PASSWORD, ALEX2MQTT_ROOT_TOPIC } = process.env; + +const bridge = new Alex2MQTT(ALEX2MQTT_USERNAME, ALEX2MQTT_PASSWORD, ALEX2MQTT_ROOT_TOPIC); +bridge.on("connect", () => console.log("connected, discover the devices in the Alexa app")); +bridge.on("error", (err) => console.error("bridge:", err.message)); + +// What the scene sets. Replace it with your devices. +const room = { lights: "bright", blind: "open" }; + +// Alexa wants the word "scene" in the description, and the category SCENE_TRIGGER or ACTIVITY_TRIGGER +const device = bridge.addDevice({ + endpointId: "movie-night", name: "Movie Night", categories: ["SCENE_TRIGGER"], + description: "Movie night scene by Alex2Node", +}); +const scene = device.add(SceneController, { supportsDeactivation: true }); + +scene.on("Activate", (ctx) => { + Object.assign(room, { lights: "dim", blind: "closed" }); + console.log("movie night on", room); + return ctx.respond(); +}); +scene.on("Deactivate", (ctx) => { + Object.assign(room, { lights: "bright", blind: "open" }); + console.log("movie night off", room); + return ctx.respond(); +}); + +bridge.connect(); diff --git a/examples/sensor.js b/examples/sensor.js new file mode 100644 index 0000000..55fa70f --- /dev/null +++ b/examples/sensor.js @@ -0,0 +1,40 @@ +// A contact sensor on a door: Alexa.ContactSensor. It takes no directive. It answers ReportState and sends a +// ChangeReport when the door opens or closes. Press Enter to open and close the door. +// +// ALEX2MQTT_USERNAME=... ALEX2MQTT_PASSWORD=... ALEX2MQTT_ROOT_TOPIC=... node examples/sensor.js +// +// "alex2node" is the installed package; inside this checkout it resolves to the built dist/. +const { Alex2MQTT, ContactSensor } = require("alex2node"); + +const missing = ["ALEX2MQTT_USERNAME", "ALEX2MQTT_PASSWORD", "ALEX2MQTT_ROOT_TOPIC"].filter((name) => !process.env[name]); +if (missing.length > 0) { + console.error(`${missing.join(", ")} not set. Set the MQTT user name, the password and the root topic of your Alex2MQTT account.`); + process.exit(1); +} +const { ALEX2MQTT_USERNAME, ALEX2MQTT_PASSWORD, ALEX2MQTT_ROOT_TOPIC } = process.env; + +const bridge = new Alex2MQTT(ALEX2MQTT_USERNAME, ALEX2MQTT_PASSWORD, ALEX2MQTT_ROOT_TOPIC); +bridge.on("connect", () => console.log("connected, discover the devices in the Alexa app")); +bridge.on("error", (err) => console.error("bridge:", err.message)); + +// DETECTED is open: the two pieces of the sensor are apart +const door = { contact: "NOT_DETECTED", changedAt: new Date() }; + +const device = bridge.addDevice({ endpointId: "back-door", name: "Back Door", categories: ["CONTACT_SENSOR"] }); +// proactivelyReported: Alexa takes ChangeReports for the capability +const contact = device.add(ContactSensor, { proactivelyReported: true }); + +device.state((s) => s.set(contact, "detectionState", door.contact, { timeOfSample: door.changedAt }).health("OK")); + +async function changed(state) { + door.contact = state; + door.changedAt = new Date(); + // What changed is set here. The rest of device.state(), the health, is the context of the report. + const result = await device.changeReport("PHYSICAL_INTERACTION", (s) => s + .set(contact, "detectionState", door.contact, { timeOfSample: door.changedAt })); + console.log(result.ok ? `the door reported ${state}` : `not reported: ${result.error.message}`); +} + +process.stdin.on("data", () => changed(door.contact === "DETECTED" ? "NOT_DETECTED" : "DETECTED")); + +bridge.connect(); diff --git a/examples/thermostat.js b/examples/thermostat.js new file mode 100644 index 0000000..63d0257 --- /dev/null +++ b/examples/thermostat.js @@ -0,0 +1,72 @@ +// A thermostat with one setpoint and the modes HEAT, COOL and OFF: Alexa.ThermostatController, and +// Alexa.TemperatureSensor for the temperature of the room. It works in Celsius and takes Fahrenheit and Kelvin. +// +// ALEX2MQTT_USERNAME=... ALEX2MQTT_PASSWORD=... ALEX2MQTT_ROOT_TOPIC=... node examples/thermostat.js +// +// "alex2node" is the installed package; inside this checkout it resolves to the built dist/. +const { Alex2MQTT, AlexaErrors, ThermostatController, TemperatureSensor } = require("alex2node"); + +const missing = ["ALEX2MQTT_USERNAME", "ALEX2MQTT_PASSWORD", "ALEX2MQTT_ROOT_TOPIC"].filter((name) => !process.env[name]); +if (missing.length > 0) { + console.error(`${missing.join(", ")} not set. Set the MQTT user name, the password and the root topic of your Alex2MQTT account.`); + process.exit(1); +} +const { ALEX2MQTT_USERNAME, ALEX2MQTT_PASSWORD, ALEX2MQTT_ROOT_TOPIC } = process.env; + +const bridge = new Alex2MQTT(ALEX2MQTT_USERNAME, ALEX2MQTT_PASSWORD, ALEX2MQTT_ROOT_TOPIC); +bridge.on("connect", () => console.log("connected, discover the devices in the Alexa app")); +bridge.on("error", (err) => console.error("bridge:", err.message)); + +const MODES = ["HEAT", "COOL", "OFF"]; +const RANGE = { min: 5, max: 30 }; +const thermostat = { mode: "HEAT", target: 21, room: 19.5 }; + +const celsius = (value) => ({ value, scale: "CELSIUS" }); +// A difference has no offset: 9 degrees Fahrenheit more are 5 degrees Celsius more +const deltaInCelsius = ({ value, scale }) => (scale === "FAHRENHEIT" ? (value * 5) / 9 : value); +function inCelsius({ value, scale }) { + if (scale === "FAHRENHEIT") return ((value - 32) * 5) / 9; + return scale === "KELVIN" ? value - 273.15 : value; +} + +const device = bridge.addDevice({ + endpointId: "hall-thermostat", name: "Hall Thermostat", categories: ["THERMOSTAT", "TEMPERATURE_SENSOR"], +}); +const control = device.add(ThermostatController, { supportedModes: MODES, properties: ["targetSetpoint", "thermostatMode"] }); +const sensor = device.add(TemperatureSensor); + +device.state((s) => s + .set(control, "targetSetpoint", celsius(thermostat.target)) + .set(control, "thermostatMode", thermostat.mode) + .set(sensor, "temperature", celsius(thermostat.room)) + .health("OK")); + +function setTarget(ctx, target) { + if (thermostat.mode === "OFF") throw AlexaErrors.thermostatIsOff("The thermostat is off"); + const rounded = Math.round(target * 2) / 2; + if (rounded < RANGE.min || rounded > RANGE.max) { + throw AlexaErrors.temperatureOutOfRange( + `The thermostat takes ${RANGE.min} to ${RANGE.max} degrees Celsius`, + { minimumValue: celsius(RANGE.min), maximumValue: celsius(RANGE.max) } + ); + } + thermostat.target = rounded; + return ctx.respond(); +} + +control.on("SetTargetTemperature", (ctx) => { + const { targetSetpoint, lowerSetpoint, upperSetpoint } = ctx.payload; + if (!targetSetpoint || lowerSetpoint || upperSetpoint) { + throw AlexaErrors.dualSetpointsUnsupported("The thermostat has one setpoint"); + } + return setTarget(ctx, inCelsius(targetSetpoint)); +}); +control.on("AdjustTargetTemperature", (ctx) => setTarget(ctx, thermostat.target + deltaInCelsius(ctx.payload.targetSetpointDelta))); +control.on("SetThermostatMode", (ctx) => { + const mode = ctx.payload.thermostatMode.value; + if (!MODES.includes(mode)) throw AlexaErrors.unsupportedThermostatMode(`The thermostat has no mode ${mode}`); + thermostat.mode = mode; + return ctx.respond(); +}); + +bridge.connect(); diff --git a/test/examples.test.js b/test/examples.test.js new file mode 100644 index 0000000..654d4d3 --- /dev/null +++ b/test/examples.test.js @@ -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 { ".": 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]); + }); +} diff --git a/test/fixtures/legacy-examples/discovery-1.5.2.json b/test/fixtures/legacy-examples/discovery-1.5.2.json new file mode 100644 index 0000000..a00480d --- /dev/null +++ b/test/fixtures/legacy-examples/discovery-1.5.2.json @@ -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" + } + } + ] + } + } + ] + } + ] +} diff --git a/test/helpers/examples.js b/test/helpers/examples.js new file mode 100644 index 0000000..db4e524 --- /dev/null +++ b/test/helpers/examples.js @@ -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/. 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 /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 }; diff --git a/test/helpers/loopback.js b/test/helpers/loopback.js new file mode 100644 index 0000000..63be41b --- /dev/null +++ b/test/helpers/loopback.js @@ -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] : [])); +};