From 785944b2da8f7f0d5e3480dcc759c090a65b4c7d Mon Sep 17 00:00:00 2001 From: David Date: Mon, 28 Sep 2026 22:04:03 +0000 Subject: [PATCH] readme: quick start, generated capability table, recipes, bridge contract, migration The readme is rewritten for 2.0: what the library needs, install, the lamp as quick start, concepts, the table of interfaces, recipes, the bridge contract, errors, testing without Alexa, migration from 1.x and limits. scripts/capability-table.js (npm run docs) writes the table from the registry and the text of three example files into their code blocks; "Through Alexa" names only the runs of 2026-09-28, made with 1.5.2. test/readme.test.js fails on a stale table or file, runs examples/testing/lamp.test.js (MemoryPublisher, no broker) and compiles the other code blocks. package.json: description, keywords, CHANGELOG.md in files. Co-Authored-By: Claude Fable 5.1 --- examples/README.md | 9 + examples/testing/lamp.test.js | 83 ++++ package.json | 7 +- readme.md | 718 ++++++++++++++++++++++++++-------- scripts/capability-table.js | 119 ++++++ test/readme.test.js | 86 ++++ 6 files changed, 848 insertions(+), 174 deletions(-) create mode 100644 examples/testing/lamp.test.js create mode 100644 scripts/capability-table.js create mode 100644 test/readme.test.js diff --git a/examples/README.md b/examples/README.md index ff0168e..a4073ef 100644 --- a/examples/README.md +++ b/examples/README.md @@ -31,6 +31,15 @@ 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. +## Test a device without a broker + +[testing/lamp.test.js](testing/lamp.test.js) tests a lamp with a `MemoryPublisher` and `bridge.receive()`. It needs +no credentials and connects nowhere: + +```sh +node examples/testing/lamp.test.js +``` + ## What was tested `test/examples.test.js` starts every file with `node`, against a broker on 127.0.0.1, discovers the device, sends diff --git a/examples/testing/lamp.test.js b/examples/testing/lamp.test.js new file mode 100644 index 0000000..6761a71 --- /dev/null +++ b/examples/testing/lamp.test.js @@ -0,0 +1,83 @@ +// A lamp tested without a broker and without Alexa. The bridge publishes into a MemoryPublisher, and +// bridge.receive() gives it a message as the broker would. connect() is never called. +// +// node examples/testing/lamp.test.js +// +// "alex2node" is the installed package; inside this checkout it resolves to the built dist/. +const { test } = require("node:test"); +const assert = require("node:assert/strict"); +const { Alex2MQTT, MemoryPublisher, PowerController } = require("alex2node"); + +// The device under test. In your program this is the module that declares your devices on a bridge. +function declareLamp(bridge, lamp) { + const device = bridge.addDevice({ endpointId: "desk-lamp", name: "Desk Lamp", categories: ["LIGHT"] }); + const power = device.add(PowerController); + device.state((s) => s.set(power, "powerState", lamp.on ? "ON" : "OFF").health("OK")); + power.on("TurnOn", (ctx) => { + if (lamp.broken) throw new Error("the lamp does not answer"); + lamp.on = true; + return ctx.respond(); + }); + return device; +} + +// A bridge that publishes into sent, and what Alex2MQTT would send it +function setup(lamp) { + const sent = new MemoryPublisher(); + const bridge = new Alex2MQTT("user", "password", "root", false, { publisher: sent }); + declareLamp(bridge, lamp); + let count = 0; + const directive = (namespace, name, payload = {}) => { + count += 1; + return JSON.stringify({ + header: { namespace, name, payloadVersion: "3", messageId: `message-${count}`, correlationToken: `token-${count}` }, + endpoint: { endpointId: "desk-lamp" }, + payload, + }); + }; + return { sent, bridge, directive }; +} + +test("discovery announces the lamp", async () => { + const { sent, bridge } = setup({ on: false }); + await bridge.receive("root/discover", "{}"); + + const [{ topic, message }] = sent.published; + assert.equal(topic, "root/discover_r"); + assert.equal(message[0].friendlyName, "Desk Lamp"); + assert.deepEqual(message[0].capabilities.map((capability) => capability.interface), + ["Alexa.PowerController", "Alexa.EndpointHealth", "Alexa"]); +}); + +test("TurnOn switches the lamp and answers with its state", async () => { + const lamp = { on: false }; + const { sent, bridge, directive } = setup(lamp); + await bridge.receive("root/desk-lamp/alexaDirective", directive("Alexa.PowerController", "TurnOn")); + + assert.equal(lamp.on, true); + const [{ topic, message }] = sent.published; + assert.equal(topic, "root/desk-lamp/alexaResponce"); + assert.equal(message.event.header.name, "Response"); + assert.equal(message.event.header.correlationToken, "token-1"); + const power = message.context.properties.find((property) => property.name === "powerState"); + assert.equal(power.value, "ON"); +}); + +test("a handler that throws is answered with INTERNAL_ERROR", async () => { + const { sent, bridge, directive } = setup({ on: false, broken: true }); + const errors = []; + bridge.on("error", (err) => errors.push(err)); + await bridge.receive("root/desk-lamp/alexaDirective", directive("Alexa.PowerController", "TurnOn")); + + const [{ message }] = sent.published; + assert.equal(message.event.header.name, "ErrorResponse"); + assert.equal(message.event.payload.type, "INTERNAL_ERROR"); + assert.equal(errors.length, 1); +}); + +test("a directive without a handler is answered with INVALID_DIRECTIVE", async () => { + const { sent, bridge, directive } = setup({ on: true }); + await bridge.receive("root/desk-lamp/alexaDirective", directive("Alexa.PowerController", "TurnOff")); + + assert.equal(sent.published[0].message.event.payload.type, "INVALID_DIRECTIVE"); +}); diff --git a/package.json b/package.json index 5d5ceae..3323133 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "alex2node", "version": "1.5.2", - "description": "A Node.js library for creating Alexa-compatible devices using MQTT, based on Alex2MQTT.", + "description": "Alexa smart home devices in Node.js over MQTT, through the Alex2MQTT service: declare a device, answer its directives, report its state.", "type": "commonjs", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", @@ -26,6 +26,7 @@ "scripts": { "build": "node scripts/build.mjs", "check": "tsc --noEmit && tsc -p test/fixtures/tsconfig.json", + "docs": "node scripts/capability-table.js", "prepare": "npm run build", "test": "node --test test/*.test.js test/*.test.mjs test/*/*.test.js" }, @@ -36,6 +37,7 @@ "iot", "home-automation", "amazon-alexa", + "alexa-smart-home", "alex2mqtt" ], "author": "user511", @@ -59,6 +61,7 @@ }, "files": [ "dist", - "readme.md" + "readme.md", + "CHANGELOG.md" ] } diff --git a/readme.md b/readme.md index 3127cd8..bc542c9 100644 --- a/readme.md +++ b/readme.md @@ -1,154 +1,569 @@ # Alex2Node -Alex2Node is a lightweight Node.js library for integrating devices with Amazon Alexa smart home APIs using MQTT. The library simplifies the process of creating Alexa-compatible devices using Alex2MQTT instead of an Alexa Skill directly. +Alex2Node makes a Node.js process a set of Alexa smart home devices. You declare a device and its capabilities, +and answer the directives Alexa sends; the library writes the discovery answer, checks what goes in and out against +the description of each interface, and publishes the responses. It talks MQTT to the +[Alex2MQTT](https://alex2mqtt.stormysdream.club/) service, which is the Alexa skill, so you write no skill and run no +public endpoint. -For more details on how to configure Alex2MQTT, visit [Alex2MQTT Documentation](https://alex2mqtt.stormysdream.club/). +``` +Alexa -> Alex2MQTT (the skill) -> MQTT broker -> your process (alex2node) +``` ---- +Alex2ESP does the same on an ESP8266, on the same topics. -## Features -- Supports "All" Alexa smart home capabilities. -- Simplifies Alexa device integration with MQTT. -- Event-driven architecture for handling Alexa directives. -- Easily configure devices and their capabilities via simple API. +## What you need +- Node.js 18 or later. +- An Alex2MQTT account. Log in at https://alex2mqtt.stormysdream.club/ with your Amazon account; the page shows the + three values the library takes: the MQTT user name, the MQTT password and the root topic. How Alex2MQTT is set + up with Alexa is described there. -## What is new in 1.5.2 +## Install -- `send()` (status, error and scene responses, change reports) never rejects any more: it resolves with the topic, or `""` when the publish failed, and the error goes to the bridge's `error` event when a listener is attached. In 1.5.1 a failing publish rejected the promise, and an un-caught `.send()` (every example, the Quick Start) then killed the host process. -- **Types ship.** `dist/index.d.ts` (and the other declarations) are generated and tracked, so TypeScript users get - types. `addSupportedModes` accepts the `{ value, modeResources }` objects a ModeController needs, `ActionMapping`'s - payload argument is optional, `addHealthProp` takes `EndpointHealth.OK` (or the plain string). -- **`disconnect()` then `connect()` works.** Devices registered before a `disconnect()` publish through the new - connection (1.5.1 left them bound to the closed client: discoverable, but they never answered a directive). -- **Duplicate endpointIds.** `registerDevice()` with an endpointId that is already registered returns the existing - device (with a warning: `console.warn`, or the log hook when one is set) instead of adding a second one that never - received a directive. EndpointIds are unique per root topic. -- **Thermostat discovery** lists `targetSetpoint` (with `lowerSetpoint`, `upperSetpoint`, `thermostatMode`) and no - longer advertises `adaptiveRecoveryStatus`, which nothing reported. A consumer (insteonDashboard) re-announces its - thermostats once on the next discovery because the payload changed - harmless. -- Discovery no longer prints `UNSUPORTED INTERFACE TYPE` to stderr for every interface the library has no property - list for; the bridge notes it through the log hook (debug) instead. -- Examples: `require("alex2node")`, an `error` listener in every one, `EndpointHealth.OK`, neutral endpoint ids; - BlindControl's ReportState reads the correlationToken from the header, the thermostat reports its Fahrenheit data - as Fahrenheit, ExamplePowerController is the plain on/off light again and the new - ExamplePowerControllerWithBrightness declares the BrightnessController it reports. A LICENSE file (MIT). +```sh +npm install alex2node # from npm +npm install git+https://git.stormysdream.club/apps/Alex2Node.git#v2.0.0 # the tag, from the repository +``` -## What is new in 1.5.1 +npm can be behind the repository: `npm view alex2node version` says what it has. The repository is public, so the +`https` form needs no account. `#` or `#` pins a revision, and npm records the resolved commit in +`package-lock.json`. -- **A broker outage no longer crashes your process.** 1.4.0 emitted `error` unconditionally, and Node terminates a - process that has an unhandled `error` event, so every reconnect failure killed the host. 1.5.1 emits `error` only - when you listen for it, keeps reconnecting on its own (mqtt.js, 1 s), and reports `connect`, `offline`, `reconnect` - and `close`. `bridge.connected` says where you stand. -- **Configurable broker.** `new Alex2MQTT(user, pass, rootTopic, debug, { host: "mqtt://broker:1883", mqtt: { ... } })` - (the default is still the public Alex2MQTT broker). `options.mqtt` is merged over the mqtt.js client options; - `options.log` replaces the console output. -- **Proactive state (ChangeReport).** `device.getChangeReport(cause)` builds an `Alexa.ChangeReport`: the - `add*Prop` calls describe what changed, `.unchanged()` switches the following calls to the context, `.send()` - publishes it to `/changeReport`, which Alex2MQTT forwards to the Alexa event gateway with your account's - token. Register the capability with `{ proactivelyReported: true }` so Alexa accepts it. -- **Scenes.** `Alexa.SceneController` discovers correctly (`supportsDeactivation`), and - `device.sendSceneResponse(correlationToken, activated)` answers Activate / Deactivate with ActivationStarted / - DeactivationStarted. -- **Device management.** `unregisterDevice(endpointId)`, `clearDevices()`, `getDevices()`, `getDevice(id)`, - `disconnect()`; `discover` (device count) and `directive` ({ endpointId, namespace, name }) events; - `lastDiscoveryAt`. `addCapability(type, { retrievable, proactivelyReported, instance })`. -- `send()` on every message returns a promise (resolves with the topic) and no longer logs to the console. -- Tests: `npm test` runs the library against an in-process MQTT broker (aedes, a dev dependency). +The package has a CommonJS and an ES module build, and its type declarations: ```js -const { Alex2MQTT, AlexaInterfaceType, DisplayCategory, PowerController } = require("alex2node"); -const bridge = new Alex2MQTT(user, pass, rootTopic, false, { host: process.env.MQTT_URL }); -bridge.on("error", (e) => console.warn("broker:", e.message)); // optional - without it the error is swallowed, never thrown -bridge.on("connect", () => console.log("connected")); +const { Alex2MQTT, PowerController } = require("alex2node"); +``` + +```js +import { Alex2MQTT, PowerController } from "alex2node"; +``` + +## Quick start + +A lamp that switches and dims. This is [examples/lamp.js](examples/lamp.js): + + +```js +// 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(); -const lamp = bridge.registerDevice("Dining Room Light", "dining-room-light-1", DisplayCategory.LIGHT); -lamp.addCapability(AlexaInterfaceType.POWER_CONTROLLER, { proactivelyReported: true }); -lamp.on("Event", (directive) => { /* ... act, then: */ lamp.getStatusMessage(directive.header.correlationToken, true).addPowerControllerProp(PowerController.ON).send(); }); -// later, when the light changes locally: -lamp.getChangeReport("PHYSICAL_INTERACTION").addPowerControllerProp(PowerController.OFF).send(); ``` -## Quick Start - -### Installation - -npm has **1.5.0**. Everything since - 1.5.1 (ChangeReport, configurable broker host, error events) and 1.5.2 (types, -the reconnect fix; see "What is new") - installs straight from the Forgejo repository, so nothing waits on an npm -publish: - -```bash -npm install alex2node # 1.5.0 from npm -npm install git+https://git.stormysdream.club/apps/Alex2Node.git # latest main from Forgejo -npm install git+https://git.stormysdream.club/apps/Alex2Node.git# # pinned to one commit +```sh +ALEX2MQTT_USERNAME=... ALEX2MQTT_PASSWORD=... ALEX2MQTT_ROOT_TOPIC=... node lamp.js ``` -The repository is public, so the `https` form needs no account. `#` (or `#`, when one exists) pins a -revision; either way npm records the resolved commit in `package-lock.json`, so `npm ci` reproduces it. `npm update -alex2node` re-resolves a branch ref such as `#main` to its latest commit; a commit or tag pin stays put. +Then discover the devices in the Alexa app. The lamp answers while the process runs. -Source: https://git.stormysdream.club/apps/Alex2Node +## Concepts -Then, import it into your JavaScript or TypeScript file: +**Bridge.** One `Alex2MQTT` is one broker connection for one root topic. The constructor takes the three +credentials, then `debugLogging` and the options: ```js -const { Alex2MQTT, AlexaInterfaceType, PowerController } = require('alex2node'); +const bridge = new Alex2MQTT(username, password, rootTopic, false, { + host: "mqtt://127.0.0.1:1883", // default: the public Alex2MQTT broker + mqtt: { clientId: "my-house" }, // merged over the mqtt.js client options + log: (line, detail) => console.debug(line, detail ?? ""), + answerWithinMs: 6500, // the default; 0 turns the watchdog off +}); +bridge.on("error", (err) => console.error(err.message)); +bridge.on("unanswered", ({ endpointId, namespace, name }) => console.warn(`${endpointId}: ${namespace}.${name} got no answer`)); +bridge.connect(); ``` -Make sure to include your MQTT connection credentials via a `.env` file or directly in the code. +Its events are `connect`, `offline`, `reconnect`, `close`, `error`, `discover` (the number of devices announced), +`directive`, `unknownEndpoint` and `unanswered`. None has to be listened to. -Credentials can be found at https://alex2mqtt.stormysdream.club/ after logging in with amazon. +**Device.** `bridge.addDevice({ endpointId, name, categories, ... })` declares an endpoint. The `endpointId` is what +Alexa knows the device by: keep it the same at every start. A device can be declared before `connect()`. +`addDevice()` throws a `DeclarationError` for what Alexa would reject, an `endpointId` with a slash or a name with +punctuation. ---- - -## Creating a Basic Device - -### Example: Power Control for a Light - -Here’s how to create a simple device that controls a light: - -First, create an instance of the Alexa-to-MQTT client and initialize it with your MQTT credentials: +**Capability.** `device.add(Interface, options)` declares an interface of the device and returns the capability +that handlers and state are set on. `Alexa.EndpointHealth` and `Alexa` are added to the discovery answer by the +library. A generic controller (Range, Mode, Toggle) takes an instance name and friendly names, and a device can +have several: ```js +const blind = bridge.addDevice({ endpointId: "bedroom-blind", name: "Bedroom Blind", categories: ["INTERIOR_BLIND"] }); +const lift = blind.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 }) + .state("Closed", 0) + .stateRange("Open", 1, 100), +}); +``` + +`add()` throws a `DeclarationError` that names the endpoint, the interface and the instance. `device.check()` +returns what Alexa would reject in the device as lines of text, and the bridge logs each line once when it answers +a discovery. + +**State.** `device.state(fill)` says how to read the whole state of the device. It answers `ReportState`, and it is +the context of every response, which Alexa wants complete. Values are checked against the interface: a +`powerState` of `"on"` throws a `SchemaError`. + +```js +blind.state((s) => s.set(lift, "rangeValue", motor.position).health("OK")); +``` + +**Handlers.** `capability.on("", handler)` answers one directive, `capability.on("*", handler)` the +others of the capability, `device.onDirective(handler)` what is left on the device, and +`device.onReportState(handler)` answers `ReportState` in place of `device.state()`. A handler gets one argument: + +```js +lift.on("SetRangeValue", async (ctx) => { + ctx.payload.rangeValue; // the payload, checked against the interface + ctx.instance; // "Blind.Lift" + if (motor.blocked) return ctx.error("ENDPOINT_BUSY", "The blind is moving"); + await motor.moveTo(ctx.payload.rangeValue); + return ctx.respond(); // a Response with the state of device.state() +}); +``` + +| Call | Sends | +|---|---| +| `ctx.respond(fill?, { payload }?)` | `Response`, or the response the interface has of its own (`Arm.Response`, `ActivationStarted`). `fill` sets properties over those of `device.state()` | +| `ctx.report(fill?)` | `StateReport`, in a handler of `onReportState()` | +| `ctx.defer(seconds?)` | `DeferredResponse`; the `ctx.respond()` or `ctx.error()` after it goes to the deferred topic | +| `ctx.error(type, message, extra?)`, `ctx.error(err)` | `ErrorResponse` | + +Each resolves with `{ ok, topic, error? }` and none rejects. What a handler returns is awaited; what it throws +answers the directive, an `AlexaError` as itself and anything else as `INTERNAL_ERROR`. + +**Change reports.** When the device changes by itself, say so. The capability has to be declared with +`proactivelyReported: true`: + +```js +const contact = door.add(ContactSensor, { proactivelyReported: true }); +const result = await door.changeReport("PHYSICAL_INTERACTION", (s) => s.set(contact, "detectionState", "DETECTED")); +if (!result.ok) console.error(result.error.message); +``` + +`fill` sets what changed; after `s.unchanged()` it sets what did not, and the rest of the context is +`device.state()`. `changeReport()` throws a `MessageError` when `fill` sets no property that changed. + +**Events.** `device.raise(Interface, "", payload?, { instance }?)` sends an event nobody asked for: + +```js +const result = await bell.raise(DoorbellEventSource, "DoorbellPress"); +``` + +It throws a `MessageError` for an event the device did not declare. See [Limits](#limits). + +## Capabilities + +A row is an interface with a descriptor: its version, properties, directives and events as the page of the +interface gives them. Tier 1 and 2 are described in full: the declaration, the property values and the directive +payloads are checked, and the discovery JSON is written from the descriptor. Tier 3 is named only: the interface is +announced with its version, nothing about it is checked, and a handler gets the payload as it arrived. + + +| Interface | Version | Properties | Directives | Events | Tier | Through Alexa | +| --- | --- | --- | --- | --- | --- | --- | +| [Alexa](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-interface.html) | 3 | - | `ReportState` | - | 1 | ReportState | +| [Alexa.BrightnessController](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-brightnesscontroller.html) | 3 | `brightness` | `SetBrightness`, `AdjustBrightness` | - | 1 | directives | +| [Alexa.ChannelController](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-channelcontroller.html) | 3 | `channel` | `ChangeChannel`, `SkipChannels` | - | 2 | - | +| [Alexa.ColorController](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-colorcontroller.html) | 3 | `color` | `SetColor` | - | 1 | directives | +| [Alexa.ColorTemperatureController](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-colortemperaturecontroller.html) | 3 | `colorTemperatureInKelvin` | `SetColorTemperature`, `IncreaseColorTemperature`, `DecreaseColorTemperature` | - | 1 | directives | +| [Alexa.ContactSensor](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-contactsensor.html) | 3 | `detectionState` | - | - | 1 | change report | +| [Alexa.DoorbellEventSource](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-doorbelleventsource.html) | 3 | - | - | `DoorbellPress` | 2 | - | +| [Alexa.EndpointHealth](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-endpointhealth.html) | 3.1 | `connectivity` | - | - | 1 | - | +| [Alexa.EqualizerController](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-equalizercontroller.html) | 3 | `bands`, `mode` | `SetMode`, `SetBands`, `AdjustBands`, `ResetBands` | - | 2 | - | +| [Alexa.HumiditySensor](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-humiditysensor.html) | 3 | `relativeHumidity` | - | - | 1 | - | +| [Alexa.InputController](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-inputcontroller.html) | 3 | `input` | `SelectInput` | - | 2 | - | +| [Alexa.InventoryLevelSensor](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-inventorylevelsensor.html) (instances) | 3 | `level` | - | - | 2 | - | +| [Alexa.LockController](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-lockcontroller.html) | 3 | `lockState` | `Lock`, `Unlock` | - | 1 | directives, change report | +| [Alexa.ModeController](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-modecontroller.html) (instances) | 3 | `mode` | `SetMode`, `AdjustMode` | - | 1 | directives | +| [Alexa.MotionSensor](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-motionsensor.html) | 3 | `detectionState` | - | - | 1 | change report | +| [Alexa.PercentageController](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-percentagecontroller.html) | 3 | `percentage` | `SetPercentage`, `AdjustPercentage` | - | 1 | directives | +| [Alexa.PlaybackController](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-playbackcontroller.html) | 3 | - | `Play`, `Pause`, `Stop`, `Next`, `Previous`, `FastForward`, `Rewind`, `StartOver` | - | 2 | - | +| [Alexa.PlaybackStateReporter](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-playbackcontroller.html) | 3 | `playbackState` | - | - | 2 | - | +| [Alexa.PowerController](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-powercontroller.html) | 3 | `powerState` | `TurnOn`, `TurnOff` | - | 1 | directives, change report | +| [Alexa.PowerLevelController](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-powerlevelcontroller.html) | 3 | `powerLevel` | `SetPowerLevel`, `AdjustPowerLevel` | - | 1 | directives | +| [Alexa.RangeController](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-rangecontroller.html) (instances) | 3 | `rangeValue` | `SetRangeValue`, `AdjustRangeValue` | - | 1 | directives | +| [Alexa.SceneController](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-scenecontroller.html) | 3 | - | `Activate`, `Deactivate` | `ActivationStarted`, `DeactivationStarted` | 1 | directives | +| [Alexa.SecurityPanelController](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-securitypanelcontroller.html) | 3 | `armState`, `burglaryAlarm`, `fireAlarm`, `carbonMonoxideAlarm`, `waterAlarm` | `Arm`, `Disarm` | - | 2 | - | +| [Alexa.SimpleEventSource](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-simpleeventsource.html) (instances) | 1.0 | - | - | `Event` | 2 | - | +| [Alexa.Speaker](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-speaker.html) | 3 | `volume`, `muted` | `SetVolume`, `AdjustVolume`, `SetMute` | - | 2 | - | +| [Alexa.StepSpeaker](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-stepspeaker.html) | 3 | - | `AdjustVolume`, `SetMute` | - | 2 | - | +| [Alexa.TemperatureSensor](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-temperaturesensor.html) | 3 | `temperature` | - | - | 1 | change report | +| [Alexa.ThermostatController](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-thermostatcontroller.html) | 3.2 | `targetSetpoint`, `lowerSetpoint`, `upperSetpoint`, `thermostatMode`, `adaptiveRecoveryStatus` | `SetTargetTemperature`, `AdjustTargetTemperature`, `SetThermostatMode`, `ResumeSchedule` | - | 1 | directives | +| [Alexa.ThermostatController.Schedule](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-thermostatcontroller-schedule.html) | 3.2 | `adaptiveRecoveryEnabled`, `scheduleEnabled` | `SetWeeklySchedule`, `SetScheduleState`, `SetAdaptiveRecovery` | - | 2 | - | +| [Alexa.TimeHoldController](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-timeholdcontroller.html) | 3 | `holdStartTime`, `holdEndTime` | `Hold`, `Resume` | - | 2 | - | +| [Alexa.ToggleController](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-togglecontroller.html) (instances) | 3 | `toggleState` | `TurnOn`, `TurnOff` | - | 1 | directives | +| [Alexa.WakeOnLANController](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-wakeonlancontroller.html) | 3 | - | - | `WakeUp` | 2 | - | + +32 interfaces are described (tier 1 and 2), 39 are named (tier 3). +"Through Alexa" is what a real Alexa account drove through the public Alex2MQTT service on 2026-09-28, with +alex2node 1.5.2; "-" is an interface that no such run has covered. + +Tier 3: [Alexa.ApplicationStateReporter](https://developer.amazon.com/docs/alexaplus/alexa-voice-service/alexa-applicationstatereporter.html) 1, [Alexa.Audio.PlayQueue](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-audio-playqueue.html) 1, [Alexa.AuthorizationController](https://developer.amazon.com/en-US/docs/alexa/ask-overviews/deprecated-features.html) 1, [Alexa.AutomationManagement](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-automationmanagement.html) 1, [Alexa.Automotive.VehicleData](https://developer.amazon.com/en-US/docs/alexa/ask-overviews/deprecated-features.html) 1, [Alexa.Camera.LiveViewController](https://developer.amazon.com/docs/alexaplus/device-apis/list-of-interfaces.html) 1.7, [Alexa.CameraStreamController](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-camerastreamcontroller.html) 3, [Alexa.Commissionable](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-commissionable.html) 1, [Alexa.ConsentManagement.ConsentRequiredReporter](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-consentrequiredreporter.html) 1, [Alexa.Cooking](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-cooking.html) 1, [Alexa.Cooking.FoodTemperatureController](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-cooking-foodtemperaturecontroller.html) 1, [Alexa.Cooking.FoodTemperatureSensor](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-cooking-foodtemperaturesensor.html) 1, [Alexa.Cooking.PresetController](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-cooking-presetcontroller.html) 1, [Alexa.Cooking.TemperatureController](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-cooking-temperaturecontroller.html) 1, [Alexa.Cooking.TemperatureSensor](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-cooking-temperaturesensor.html) 1, [Alexa.Cooking.TimeController](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-cooking-timecontroller.html) 1, [Alexa.DataController](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-datacontroller.html) 1, [Alexa.DeviceUsage.Estimation](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-deviceusage-estimation.html) 1, [Alexa.DeviceUsage.Meter](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-deviceusage-meter.html) 1, [Alexa.InventoryLevelUsageSensor](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-inventorylevelusagesensor.html) 1, [Alexa.InventoryUsageSensor](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-inventoryusagesensor.html) 1, [Alexa.KeypadController](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-keypadcontroller.html) 1, [Alexa.Launcher](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-launcher.html) 1.1, [Alexa.Media.PlayQueue](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-media-playqueue.html) 1, [Alexa.Media.Playback](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-media-playback.html) 1, [Alexa.Media.Search](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-media-search.html) 1, [Alexa.ProactiveNotificationSource](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-proactivenotificationsource.html) 1, [Alexa.RTCSessionController](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-rtcsessioncontroller.html) 1, [Alexa.RecordController](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-recordcontroller.html) 3, [Alexa.RemoteVideoPlayer](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-remotevideoplayer.html) 1, [Alexa.SecurityPanelController.Alert](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-securitypanelcontroller-alert.html) 1, [Alexa.SeekController](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-seekcontroller.html) 3, [Alexa.SmartVision.ObjectDetectionSensor](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-smartvision-objectdetectionsensor.html) 1, [Alexa.SmartVision.SnapshotProvider](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-smartvision-snapshotprovider.html) 1, [Alexa.ThermostatController.Configuration](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-thermostatcontroller-configuration.html) 1, [Alexa.ThermostatController.HVAC.Components](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-thermostatcontroller-hvac-components.html) 1, [Alexa.UIController](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-uicontroller.html) 1, [Alexa.UserPreference](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-userpreference.html) 1, [Alexa.VideoRecorder](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-videorecorder.html) 1. + + +`registry.list()` returns the descriptors and `registry.get("Alexa.RangeController")` one of them. A tier 3 +interface has no export of its own: declare it with `device.add(registry.get("Alexa.KeypadController"))`. + +## Recipes + +Each file is a complete program: it declares one device, answers its directives and connects. The test suite +starts every one of them against a broker on 127.0.0.1, discovers the device, sends it directives and checks the +answers. + +| File | Device | Interfaces | Shows | +|---|---|---|---| +| [lamp.js](examples/lamp.js) | lamp | PowerController, BrightnessController | handlers, `device.state()`, `ctx.respond()` | +| [color-lamp.js](examples/color-lamp.js) | colour lamp | + ColorController, ColorTemperatureController | a state that depends on the mode of the device | +| [thermostat.js](examples/thermostat.js) | thermostat | ThermostatController, TemperatureSensor | one setpoint, three modes, temperature scales | +| [blind.js](examples/blind.js) | roller blind | RangeController | an instance, friendly names, semantics for open and close | +| [lock.js](examples/lock.js) | lock | LockController | `ctx.defer()` for an answer that takes longer than 7 seconds | +| [sensor.js](examples/sensor.js) | contact sensor | ContactSensor | `device.changeReport()` | +| [scene.js](examples/scene.js) | scene | SceneController | ActivationStarted and DeactivationStarted | +| [doorbell.js](examples/doorbell.js) | doorbell | DoorbellEventSource | `device.raise()` | +| [errors.js](examples/errors.js) | fan | PowerController, RangeController | `AlexaErrors`, `ctx.error()` | +| [plug.mjs](examples/plug.mjs) | plug | PowerController | the library as an ES module | + +[examples/testing/lamp.test.js](examples/testing/lamp.test.js) tests a lamp without a broker, see +[below](#testing-your-device-without-alexa). [examples/legacy](examples/legacy) has the nine examples of 1.5.2 on +the 1.x API. + +## The bridge contract + +What the library and Alex2MQTT agree on. You need it to debug with an MQTT client, not to use the library. + +| Topic | Direction | Message | +|---|---|---| +| `/discover` | to the bridge | a discovery request | +| `/discover_r` | from the bridge | the endpoints, as one JSON array | +| `//alexaDirective` | to the bridge | a directive: `{ header, endpoint, payload }` | +| `//alexaResponce` | from the bridge | `Response`, `StateReport`, `ErrorResponse` or `DeferredResponse` | +| `//deferredResponse` | from the bridge | the answer that follows a `DeferredResponse` | +| `/changeReport` | from the bridge | a `ChangeReport` | +| `/event` | from the bridge | an event of `device.raise()` | + +`alexaResponce` is how the service spells it. The bridge subscribes to `/discover` and +`/+/alexaDirective`. + +**The answer window.** Alex2MQTT waits 7 seconds for the answer to a directive. A directive that nothing has +answered after 6.5 seconds is answered by the bridge with `INTERNAL_ERROR`, and the bridge emits `unanswered` and +`error`. `answerWithinMs` sets the time, and `0` turns this off. + +**Deferred answers.** When the device needs longer, call `ctx.defer()` within the window. The answer then goes to +`deferredResponse`, and Alex2MQTT waits 10 seconds for it. The bridge sets no limit of its own after a +`DeferredResponse`. [examples/lock.js](examples/lock.js) does this. + +**Change reports.** A `ChangeReport` goes to `/changeReport`, and Alex2MQTT posts it to Alexa with the token +of your account. A report without a changed property is not published. + +**Proactive events.** An event of `device.raise()` goes to `/event`. The library refuses an event that is +longer than 16000 bytes as JSON. + +**Duplicates.** A directive with the `messageId` of one that came for the endpoint in the last 60 seconds is +dropped, and so is a discovery request with the `messageId` of an earlier one. A second answer for one +`correlationToken` is refused: one `DeferredResponse` and one answer after it pass. + +**The connection.** mqtt.js reconnects by itself, 1 second after the connection is lost, and gives a connection +attempt 10 seconds; `options.mqtt` changes both. The bridge emits `error` only when there is a listener, so a broker +that is away does not end the process. `bridge.connected` says where you stand. + +**Endpoints of other bridges.** A directive for an endpoint the bridge does not have is left alone, because the +endpoint may belong to another process on the same root topic. The bridge emits `unknownEndpoint`; +`answerUnknownEndpoints: true` answers with `NO_SUCH_ENDPOINT`. + +## Errors + +An `ErrorResponse` is an `AlexaError`: thrown in a handler, or sent with `ctx.error()`. `AlexaErrors` has a +function for each of the 73 error types of Amazon's +[table](https://developer.amazon.com/docs/alexaplus/device-apis/alexa-errorresponse.html), named as the type in +camel case. The function of a type whose payload has fields of its own takes them: + +```js +throw AlexaErrors.endpointUnreachable("The lamp does not answer"); +throw AlexaErrors.valueOutOfRange("The fan has the speeds 1 to 3", { minimumValue: 1, maximumValue: 3 }); +throw AlexaErrors.notSupportedInCurrentMode("The lamp shows a colour", "COLOR"); +throw AlexaErrors.of("ENDPOINT_BUSY", "The blind is moving"); +``` + +The header of the `ErrorResponse` carries the namespace the type is documented under: `THERMOSTAT_IS_OFF` goes under +`Alexa.ThermostatController`, `UNAUTHORIZED` under `Alexa.SecurityPanelController`, `OBSTACLE_DETECTED` under +`Alexa.Safety`, and the 31 general types under `Alexa`. + +The library answers by itself in three cases: `INVALID_DIRECTIVE` for a directive without a handler, +`INTERNAL_ERROR` for a handler that throws something else than an `AlexaError`, and `INTERNAL_ERROR` for a directive +that nothing answered in time. [examples/errors.js](examples/errors.js) has a device that refuses. + +The other errors of the library are thrown where the mistake is made: `DeclarationError` by `addDevice()` and +`add()`, `SchemaError` for a value that does not fit the interface, `MessageError` by `changeReport()` and +`raise()`. + +## Testing your device without Alexa + +A bridge with the `publisher` option publishes into it and not to the broker. With a `MemoryPublisher`, and +`bridge.receive(topic, text)` in place of the broker, a test needs no connection: `receive()` resolves when the +handlers are done. This is [examples/testing/lamp.test.js](examples/testing/lamp.test.js): + + +```js +// A lamp tested without a broker and without Alexa. The bridge publishes into a MemoryPublisher, and +// bridge.receive() gives it a message as the broker would. connect() is never called. +// +// node examples/testing/lamp.test.js +// +// "alex2node" is the installed package; inside this checkout it resolves to the built dist/. +const { test } = require("node:test"); +const assert = require("node:assert/strict"); +const { Alex2MQTT, MemoryPublisher, PowerController } = require("alex2node"); + +// The device under test. In your program this is the module that declares your devices on a bridge. +function declareLamp(bridge, lamp) { + const device = bridge.addDevice({ endpointId: "desk-lamp", name: "Desk Lamp", categories: ["LIGHT"] }); + const power = device.add(PowerController); + device.state((s) => s.set(power, "powerState", lamp.on ? "ON" : "OFF").health("OK")); + power.on("TurnOn", (ctx) => { + if (lamp.broken) throw new Error("the lamp does not answer"); + lamp.on = true; + return ctx.respond(); + }); + return device; +} + +// A bridge that publishes into sent, and what Alex2MQTT would send it +function setup(lamp) { + const sent = new MemoryPublisher(); + const bridge = new Alex2MQTT("user", "password", "root", false, { publisher: sent }); + declareLamp(bridge, lamp); + let count = 0; + const directive = (namespace, name, payload = {}) => { + count += 1; + return JSON.stringify({ + header: { namespace, name, payloadVersion: "3", messageId: `message-${count}`, correlationToken: `token-${count}` }, + endpoint: { endpointId: "desk-lamp" }, + payload, + }); + }; + return { sent, bridge, directive }; +} + +test("discovery announces the lamp", async () => { + const { sent, bridge } = setup({ on: false }); + await bridge.receive("root/discover", "{}"); + + const [{ topic, message }] = sent.published; + assert.equal(topic, "root/discover_r"); + assert.equal(message[0].friendlyName, "Desk Lamp"); + assert.deepEqual(message[0].capabilities.map((capability) => capability.interface), + ["Alexa.PowerController", "Alexa.EndpointHealth", "Alexa"]); +}); + +test("TurnOn switches the lamp and answers with its state", async () => { + const lamp = { on: false }; + const { sent, bridge, directive } = setup(lamp); + await bridge.receive("root/desk-lamp/alexaDirective", directive("Alexa.PowerController", "TurnOn")); + + assert.equal(lamp.on, true); + const [{ topic, message }] = sent.published; + assert.equal(topic, "root/desk-lamp/alexaResponce"); + assert.equal(message.event.header.name, "Response"); + assert.equal(message.event.header.correlationToken, "token-1"); + const power = message.context.properties.find((property) => property.name === "powerState"); + assert.equal(power.value, "ON"); +}); + +test("a handler that throws is answered with INTERNAL_ERROR", async () => { + const { sent, bridge, directive } = setup({ on: false, broken: true }); + const errors = []; + bridge.on("error", (err) => errors.push(err)); + await bridge.receive("root/desk-lamp/alexaDirective", directive("Alexa.PowerController", "TurnOn")); + + const [{ message }] = sent.published; + assert.equal(message.event.header.name, "ErrorResponse"); + assert.equal(message.event.payload.type, "INTERNAL_ERROR"); + assert.equal(errors.length, 1); +}); + +test("a directive without a handler is answered with INVALID_DIRECTIVE", async () => { + const { sent, bridge, directive } = setup({ on: true }); + await bridge.receive("root/desk-lamp/alexaDirective", directive("Alexa.PowerController", "TurnOff")); + + assert.equal(sent.published[0].message.event.payload.type, "INVALID_DIRECTIVE"); +}); +``` + +To see the traffic of a running process, subscribe to `/#` with any MQTT client, or pass `log` in the +options. + +## Migrating from 1.x + +Every call of 1.5.2 is kept: `registerDevice()`, `addCapability()`, the `Event` and `ReportState` listeners, +`getStatusMessage()`, `getErrorMessage()`, `getChangeReport()`, `sendSceneResponse()` and the enums. The nine +examples of 1.5.2 run on 2.0 as they were. The topics are unchanged. + +What a 1.x program can observe of 2.0: + +| What | 1.5.2 | 2.0 | What to do | +|---|---|---|---| +| The `Alexa` interface in discovery | not listed | the last capability of every endpoint | a test that compares the capability list gets one more entry. `{ alexaInterface: false }` leaves it out | +| Version of `Alexa.EndpointHealth` | `3.3` | `3.1` | nothing | +| Speaker, StepSpeaker, EqualizerController, PlaybackController, PlaybackStateReporter, InputController, ChannelController, LockController, MotionSensor, PowerLevelController in discovery | version `1` or no property | the version and the properties of the interface | nothing | +| `addSupportedModes(["a", "b"])` | announced as strings | announced as `{ value }` objects, with a warning | pass `{ value, modeResources }` | +| `new ActionMapping(actions, name, "")` | the string was announced | a JSON string is parsed, with a warning; another string throws | pass the object | +| A directive or a discovery request that arrives twice | handled twice | the second is dropped | nothing | +| A second answer for one `correlationToken` | published | refused, `send()` resolves `""` | answer once | +| A directive nothing answers | no answer | `INTERNAL_ERROR` after 6.5 s | answer, or defer; `answerWithinMs: 0` turns it off | +| A listener that throws | the exception left the library | `INTERNAL_ERROR`, and the `error` event | nothing | +| A directive for a device without a listener or handler | no answer | `INVALID_DIRECTIVE` | nothing | +| `getChangeReport()` without a changed property | published | not published, `send()` resolves `""` | send no report when nothing changed | +| Namespace of an `ErrorResponse` | always `Alexa` | the namespace of the error type | `setErrorMessage(type, message, extra, { namespace })` names another | +| `getErrorMessage(token).send()` without `setErrorMessage()` | an empty payload | `INTERNAL_ERROR` | call `setErrorMessage()` | +| `DeferredResponse` | `"context": null` | no `context` | nothing | +| Subscriptions | `/#` | `/discover`, `/+/alexaDirective` | nothing | +| `registerDevice()` before `connect()` | threw | returns the device | nothing | +| `new Device(client, ...)`, `device.setMqttClient(client)` | used the client | the client is ignored, with a warning once | remove it. Both go in 3.0 | + +[docs/wire-changes.md](docs/wire-changes.md) has every row, with the test that pins it. + +The calls of the two APIs, side by side: + +| 1.x | 2.0 | +|---|---| +| `bridge.registerDevice(name, endpointId, category)` | `bridge.addDevice({ endpointId, name, categories })` | +| `device.addCapability(AlexaInterfaceType.POWER_CONTROLLER)` | `const power = device.add(PowerController)` | +| `device.on("Event", ...)` with `getStatusMessage(token, true)...send()` | `power.on("TurnOn", (ctx) => ctx.respond())` | +| `device.on("ReportState", ...)` with `getStatusMessage(token)...send()` | `device.state((s) => s.set(power, "powerState", "ON"))` | +| `getErrorMessage(token).setErrorMessage(type, message).send()` | `ctx.error(type, message)`, or `throw AlexaErrors...` | +| `getChangeReport(cause).add...Prop(value).send()` | `device.changeReport(cause, (s) => s.set(...))` | + +A device of `addDevice()` is announced with `Alexa.EndpointHealth`; one of `registerDevice()` is announced as 1.x +announced it. `send()` of 1.x resolves `""` where the 2.0 calls resolve `{ ok: false, error }` or throw. +`PowerController` is both the 1.x enum (`PowerController.ON`) and the interface for `device.add()`. + +
+The light of the 1.x readme: examples/legacy/ExamplePowerController.js + + +```js +// Import the library ("alex2node" is the installed package; inside this checkout it resolves to the built dist/ via package.json "exports") +const { + Alex2MQTT, + AlexaInterfaceType, + PowerController, + EndpointHealth +} = require("alex2node"); + require("dotenv").config(); // Load environment variables from .env file -const { Alex2MQTT, AlexaInterfaceType, PowerController, EndpointHealth } = require("alex2node"); +// Simulated in-memory device state (used for example/demo purposes) +let outputState = PowerController.OFF; // Initialize the power state to OFF +// Load MQTT connection credentials and root topic from environment variables const username = process.env.MQTT_USERNAME; const password = process.env.MQTT_PASSWORD; const rootTopic = process.env.MQTT_ROOT_TOPIC; -const alex2NodeClient = new Alex2MQTT(username, password, rootTopic, false); // 1.5.1: add { host } as a fifth argument for another broker +// Create and initialize a new Alexa-to-MQTT client +const alex2NodeClient = new Alex2MQTT(username, password, rootTopic, false); alex2NodeClient.connect(); // Connect to the MQTT broker -alex2NodeClient.on("error", (e) => console.error("broker:", e.message)); // Log broker errors - on 1.5.0 this listener is also what keeps an outage from killing the process -``` +alex2NodeClient.on("error", (e) => console.error("broker:", e.message)); // Log broker errors (on 1.5.0 this listener is also what keeps an outage from killing the process) -Once initialized, you can begin to add virtual devices. In this example, we will add a PowerController for a light: - -```js +// Define the device name (bedroom light) const deviceName = "Bedroom Light"; -const bedroomLight = alex2NodeClient.registerDevice(deviceName, "bedroom-light-1"); // the endpointId: unique per root topic -// Add the PowerController capability +// Register the device with a unique endpoint ID (unique per root topic, e.g. "bedroom-light-1") +const bedroomLight = alex2NodeClient.registerDevice(deviceName, "bedroom-light-1"); + +// Add the PowerController capability (for turning on/off the device) bedroomLight.addCapability(AlexaInterfaceType.POWER_CONTROLLER); -// Handle Alexa's ReportState directive to report the current state -let outputState = PowerController.OFF; +// Log the device name to verify registration +console.log(bedroomLight.getName()); + +/** + * Handle Alexa's ReportState directive. + * This occurs when Alexa queries the current state of the device (e.g., during routines or device status checks). + */ bedroomLight.on("ReportState", (payload) => { - const { correlationToken } = payload.header; // the whole directive is passed; Alexa needs the token echoed + console.log("ReportState received!", payload); + + const { correlationToken } = payload.header; + const status = bedroomLight.getStatusMessage(correlationToken); + status - .addHealthProp(EndpointHealth.OK) - .addPowerControllerProp(outputState) - .send(); + .addHealthProp(EndpointHealth.OK) // Device is healthy + .addPowerControllerProp(outputState); // Report the current power state (ON/OFF) + + status.send(); // Send the state report back to Alexa }); -// Handle Alexa's Event directive to process state changes +/** + * Handle incoming control directives (e.g., TurnOn, TurnOff). + * These directives come from Alexa when a user issues a command. + */ bedroomLight.on("Event", (directive, interfaceType) => { + console.log("Event received", { directive, interfaceType }); + + // Ensure the interfaceType is either PowerController or other valid interfaces if (interfaceType === AlexaInterfaceType.POWER_CONTROLLER) { const name = directive.header.name; const token = directive.header.correlationToken; + // Update the internal state based on the command (TurnOn / TurnOff) if (name === "TurnOn") { outputState = PowerController.ON; console.log("Turning ON the Bedroom Light"); @@ -157,6 +572,7 @@ bedroomLight.on("Event", (directive, interfaceType) => { console.log("Turning OFF the Bedroom Light"); } + // Respond to the directive with the updated device status const status = bedroomLight.getStatusMessage(token, true); status .addHealthProp(EndpointHealth.OK) @@ -166,83 +582,41 @@ bedroomLight.on("Event", (directive, interfaceType) => { }); ``` ---- +
-## Interface Types -| Alexa Interface Type | Status | -|-----------------------------------------------------|-------------| -| AlexaInterfaceType.ENDPOINT_HEALTH | Fully Supported | -| AlexaInterfaceType.POWER_CONTROLLER | Fully Supported | -| AlexaInterfaceType.BRIGHTNESS_CONTROLLER | Fully Supported | -| AlexaInterfaceType.TOGGLE_CONTROLLER | Fully Supported | -| AlexaInterfaceType.TEMPERATURE_SENSOR | Fully Supported | -| AlexaInterfaceType.COLOR_TEMPERATURE_CONTROLLER | Fully Supported | -| AlexaInterfaceType.AUTOMATION_MANAGEMENT | Supported* | -| AlexaInterfaceType.CHANNEL_CONTROLLER | Supported* | -| AlexaInterfaceType.COLOR_CONTROLLER | Supported* | -| AlexaInterfaceType.CONTACT_SENSOR | Supported* | -| AlexaInterfaceType.APPLICATION_STATE_REPORTER | Supported* | -| AlexaInterfaceType.AUDIO_PLAY_QUEUE | Supported* | -| AlexaInterfaceType.AUTHORIZATION_CONTROLLER | Supported* | -| AlexaInterfaceType.AUTOMOTIVE_VEHICLE_DATA | Supported* | -| AlexaInterfaceType.CAMERA_LIVE_VIEW_CONTROLLER | Supported* | -| AlexaInterfaceType.CAMERA_STREAM_CONTROLLER | Supported* | -| AlexaInterfaceType.COMMISSIONABLE | Supported* | -| AlexaInterfaceType.CONSENT_MANAGEMENT_CONSENT_REQUIRED_REPORTER | Supported* | -| AlexaInterfaceType.COOKING | Supported* | -| AlexaInterfaceType.DATA_CONTROLLER | Supported* | -| AlexaInterfaceType.DEVICE_USAGE_ESTIMATION | Supported* | -| AlexaInterfaceType.DEVICE_USAGE_METER | Supported* | -| AlexaInterfaceType.DOORBELL_EVENT_SOURCE | Supported* | -| AlexaInterfaceType.EQUALIZER_CONTROLLER | Supported* | -| AlexaInterfaceType.INPUT_CONTROLLER | Supported* | -| AlexaInterfaceType.INVENTORY_LEVEL_SENSOR | Supported* | -| AlexaInterfaceType.INVENTORY_LEVEL_USAGE_SENSOR | Supported* | -| AlexaInterfaceType.INVENTORY_USAGE_SENSOR | Supported* | -| AlexaInterfaceType.KEYPAD_CONTROLLER | Supported* | -| AlexaInterfaceType.LAUNCHER | Supported* | -| AlexaInterfaceType.LOCK_CONTROLLER | Supported* | -| AlexaInterfaceType.MEDIA_PLAYBACK | Supported* | -| AlexaInterfaceType.MEDIA_SEARCH | Supported* | -| AlexaInterfaceType.MODE_CONTROLLER | Supported* | -| AlexaInterfaceType.MOTION_SENSOR | Supported* | -| AlexaInterfaceType.PERCENTAGE_CONTROLLER | Supported* | -| AlexaInterfaceType.PLAYBACK_CONTROLLER | Supported* | -| AlexaInterfaceType.PLAYBACK_STATE_REPORTER | Supported* | -| AlexaInterfaceType.PROACTIVE_NOTIFICATION_SOURCE | Supported* | -| AlexaInterfaceType.RANGE_CONTROLLER | Supported* | -| AlexaInterfaceType.RECORD_CONTROLLER | Supported* | -| AlexaInterfaceType.REMOTE_VIDEO_PLAYER | Supported* | -| AlexaInterfaceType.RTC_SESSION_CONTROLLER | Supported* | -| AlexaInterfaceType.SCENE_CONTROLLER | Supported* | -| AlexaInterfaceType.SECURITY_PANEL_CONTROLLER | Supported* | -| AlexaInterfaceType.SEEK_CONTROLLER | Supported* | -| AlexaInterfaceType.SIMPLE_EVENT_SOURCE | Supported* | -| AlexaInterfaceType.SMART_VISION_OBJECT_DETECTION_SENSOR | Supported* | -| AlexaInterfaceType.SMART_VISION_SNAPSHOT_PROVIDER | Supported* | -| AlexaInterfaceType.SPEAKER | Supported* | -| AlexaInterfaceType.STEP_SPEAKER | Supported* | -| AlexaInterfaceType.THERMOSTAT_CONTROLLER | Supported* | -| AlexaInterfaceType.THERMOSTAT_CONTROLLER_CONFIGURATION | Supported* | -| AlexaInterfaceType.THERMOSTAT_CONTROLLER_HVAC_COMPONENTS | Supported* | -| AlexaInterfaceType.THERMOSTAT_CONTROLLER_SCHEDULE | Supported* | -| AlexaInterfaceType.TIME_HOLD_CONTROLLER | Supported* | -| AlexaInterfaceType.UI_CONTROLLER | Supported* | -| AlexaInterfaceType.USER_PREFERENCE | Supported* | -| AlexaInterfaceType.VIDEO_RECORDER | Supported* | -| AlexaInterfaceType.WAKE_ON_LAN_CONTROLLER | Supported* | +## Limits +- **What was run against Alexa.** On 2026-09-28 alex2node 1.5.2 was driven from a real Alexa account through the + public Alex2MQTT service: Power, Brightness, Color, ColorTemperature, Percentage, PowerLevel, Thermostat, Range, + Mode, Toggle, Lock and Scene, deferred responses, `ErrorResponse`, `ReportState`, and change reports for contact, + motion, lock, temperature and power. Voice commands were not tested yet. The 2.0 API was not run against an + Alexa account yet: the test suite runs it against a broker on 127.0.0.1. +- **Proactive events.** `device.raise()` publishes to `/event`. Whether a `DoorbellPress` reaches Alexa + depends on the Alex2MQTT service relaying that topic, which was not tested. +- **Alexa.WakeOnLANController** needs three messages for one `TurnOn`. The descriptor is there; the exchange was + not run through Alex2MQTT. +- **Tier 3 interfaces** are named, not described: no check of the declaration, the state or the payload. +- **One message for discovery.** The bridge answers a discovery request with all its endpoints in one message. +- **The 1.x helpers `addThermostatControllerProp()` and `addTemperatureSensorProp()`** convert Fahrenheit to + Celsius without rounding: 70 °F is sent as `21.11111111111111`, as in 1.5.2. +- **The constructor** takes its arguments by position. There is no form that takes one object. +- **No skill of your own.** The library does not talk to Alexa: it needs the Alex2MQTT service, or a broker and a + backend that speak its topics. -(*Partial support or limited implementation advanced configuration is required) +## Development ---- +```sh +npm install # builds dist/ +npm run build # after a change in src/; dist/ is committed with it +npm test # against a broker in the test process +npm run check # the type checks +npm run docs # writes the table of interfaces and the example files into this readme +``` -## Contributing +## Changelog -Feel free to submit pull requests or issues for feature requests and bug fixes. - ---- +[CHANGELOG.md](CHANGELOG.md). ## License -This project is licensed under the MIT License. See the LICENSE file for details. +MIT, see [LICENSE](LICENSE). diff --git a/scripts/capability-table.js b/scripts/capability-table.js new file mode 100644 index 0000000..a05bd54 --- /dev/null +++ b/scripts/capability-table.js @@ -0,0 +1,119 @@ +"use strict"; +// Writes what readme.md takes from the code: the table of interfaces, from the registry, between the two marker +// lines, and the text of a file into the code block that follows a line . +// +// npm run build && npm run docs write them +// node scripts/capability-table.js --check exit 1 when the readme has another text +// +// It reads the built registry (dist/cjs), so the build comes first. test/readme.test.js fails on a stale readme. +const fs = require("node:fs"); +const path = require("node:path"); + +const ROOT = path.join(__dirname, ".."); +const README = path.join(ROOT, "readme.md"); +const BEGIN = ""; +const END = ""; + +// What was driven from a real Alexa account through the public Alex2MQTT service, and when. The runs used +// alex2node 1.5.2; a row is added here when a run is recorded, not when a descriptor is written. +const DRIVEN_ON = "2026-09-28"; +const DRIVEN_WITH = "alex2node 1.5.2"; +const DRIVEN = { + "Alexa": "ReportState", + "Alexa.BrightnessController": "directives", + "Alexa.ColorController": "directives", + "Alexa.ColorTemperatureController": "directives", + "Alexa.ContactSensor": "change report", + "Alexa.LockController": "directives, change report", + "Alexa.ModeController": "directives", + "Alexa.MotionSensor": "change report", + "Alexa.PercentageController": "directives", + "Alexa.PowerController": "directives, change report", + "Alexa.PowerLevelController": "directives", + "Alexa.RangeController": "directives", + "Alexa.SceneController": "directives", + "Alexa.TemperatureSensor": "change report", + "Alexa.ThermostatController": "directives", + "Alexa.ToggleController": "directives", +}; + +const code = (names) => (names.length > 0 ? names.map((name) => `\`${name}\``).join(", ") : "-"); + +function row(descriptor) { + const { namespace, version, doc, tier, instanced, properties, directives, events = {} } = descriptor; + return [ + `[${namespace}](${doc})${instanced ? " (instances)" : ""}`, + version, + code(Object.keys(properties)), + code(Object.keys(directives)), + code(Object.keys(events)), + String(tier), + DRIVEN[namespace] ?? "-", + ]; +} + +/** The text between the markers, from the registry of the build. */ +function table(registry = require(path.join(ROOT, "dist", "cjs", "index.js")).registry) { + const all = registry.list(); + const unknown = Object.keys(DRIVEN).filter((namespace) => !registry.has(namespace)); + if (unknown.length > 0) throw new Error(`DRIVEN names ${unknown.join(", ")}, which the registry does not have`); + + const described = all.filter((descriptor) => descriptor.tier !== 3); + const named = all.filter((descriptor) => descriptor.tier === 3); + const header = ["Interface", "Version", "Properties", "Directives", "Events", "Tier", "Through Alexa"]; + const lines = [header, header.map(() => "---"), ...described.map(row)].map((cells) => `| ${cells.join(" | ")} |`); + return [ + ...lines, + "", + `${described.length} interfaces are described (tier 1 and 2), ${named.length} are named (tier 3).`, + `"Through Alexa" is what a real Alexa account drove through the public Alex2MQTT service on ${DRIVEN_ON}, with`, + `${DRIVEN_WITH}; "-" is an interface that no such run has covered.`, + "", + `Tier 3: ${named.map(({ namespace, version, doc }) => `[${namespace}](${doc}) ${version}`).join(", ")}.`, + ].join("\n"); +} + +// A line that names a file, and the code block after it +const FILE = /\n```(\w+)\n[\s\S]*?\n```/g; + +/** The readme with the text the named files have now in their code blocks. */ +function files(readme) { + return readme.replace(FILE, (block, file, language) => { + const text = fs.readFileSync(path.join(ROOT, file), "utf8").trimEnd(); + if (text.includes("```")) throw new Error(`${file} has a code fence, which would end its block in readme.md`); + return `\n\`\`\`${language}\n${text}\n\`\`\``; + }); +} + +/** The files the readme shows, as the marker lines name them. */ +function shown(readme) { + return [...readme.matchAll(FILE)].map(([, file]) => file); +} + +/** The readme with the table of now and the files of now. Throws when the markers are not there, each once and in this order. */ +function update(readme, text = table()) { + const begin = readme.indexOf(BEGIN); + const end = readme.indexOf(END); + if (begin < 0 || end < begin || readme.indexOf(BEGIN, begin + 1) >= 0 || readme.indexOf(END, end + 1) >= 0) { + throw new Error(`readme.md needs the lines "${BEGIN}" and "${END}", once each and in this order`); + } + return files(`${readme.slice(0, begin)}${BEGIN}\n${text}\n${readme.slice(end)}`); +} + +if (require.main === module) { + const before = fs.readFileSync(README, "utf8"); + const after = update(before); + if (process.argv.includes("--check")) { + if (after !== before) { + console.error("readme.md has another table of interfaces or another text of a file than the code gives: npm run build && npm run docs"); + process.exit(1); + } + } else if (after !== before) { + fs.writeFileSync(README, after); + console.log("readme.md was written"); + } else { + console.log("readme.md is up to date"); + } +} + +module.exports = { table, update, files, shown, BEGIN, END, DRIVEN, README }; diff --git a/test/readme.test.js b/test/readme.test.js new file mode 100644 index 0000000..5891f30 --- /dev/null +++ b/test/readme.test.js @@ -0,0 +1,86 @@ +"use strict"; +// readme.md says what the code does: its table of interfaces is the one the registry gives, a program it shows is +// the text of a file that a test runs, and the other code blocks compile. +const { test } = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const { run, ROOT } = require("./helpers/examples.js"); +const { table, update, shown, BEGIN, END, DRIVEN, README } = require("../scripts/capability-table.js"); +const alex2node = require(".."); + +const readme = fs.readFileSync(README, "utf8"); +const AsyncFunction = (async () => {}).constructor; + +// The js code blocks, each with the file its marker line names +const blocks = [...readme.matchAll(/(?:\n)?```js\n([\s\S]*?)\n```/g)] + .map(([, file, code]) => ({ file, code })); +const loads = (code) => /require\("alex2node"\)|from "alex2node"/.test(code); + +test("the table of interfaces and the files in the readme are the ones of the code: npm run docs", () => { + assert.equal(update(readme), readme); +}); + +test("the table has a row for every described interface, and a line for the others", () => { + const text = table(); + for (const { namespace, tier } of alex2node.registry.list()) { + const row = text.split("\n").filter((line) => line.startsWith(`| [${namespace}](`)); + assert.equal(row.length, tier === 3 ? 0 : 1, namespace); + assert.ok(text.includes(`[${namespace}](`), namespace); + } + assert.ok(readme.indexOf(BEGIN) < readme.indexOf(END)); +}); + +test("an interface is said to be driven through Alexa only when a run is recorded for it", () => { + // The runs of 2026-09-28, with alex2node 1.5.2. A name is added here with the record of its run. + assert.deepEqual(Object.keys(DRIVEN).sort(), [ + "Alexa", "Alexa.BrightnessController", "Alexa.ColorController", "Alexa.ColorTemperatureController", + "Alexa.ContactSensor", "Alexa.LockController", "Alexa.ModeController", "Alexa.MotionSensor", + "Alexa.PercentageController", "Alexa.PowerController", "Alexa.PowerLevelController", "Alexa.RangeController", + "Alexa.SceneController", "Alexa.TemperatureSensor", "Alexa.ThermostatController", "Alexa.ToggleController", + ]); + const rows = table().split("\n").filter((line) => line.startsWith("| [Alexa")); + for (const line of rows) { + const cells = line.split(" | "); + const namespace = cells[0].slice(3, cells[0].indexOf("]")); + assert.equal(cells[cells.length - 1].replace(/ \|$/, ""), DRIVEN[namespace] ?? "-", namespace); + } +}); + +test("a program in the readme is a file, and a test runs the file", () => { + assert.deepEqual(shown(readme), ["examples/lamp.js", "examples/testing/lamp.test.js", "examples/legacy/ExamplePowerController.js"]); + // More than the line that loads the library: a program, which has to be one of the files + const programs = blocks.filter(({ code }) => loads(code) && code.split("\n").length > 1); + assert.deepEqual(programs.map(({ file }) => file), shown(readme)); + + const examples = fs.readFileSync(path.join(__dirname, "examples.test.js"), "utf8"); + assert.ok(examples.includes('start("lamp.js"')); + assert.ok("ExamplePowerController.js" in require("./fixtures/legacy-examples/discovery-1.5.2.json")); + // examples/testing/lamp.test.js: the next test +}); + +test("examples/testing/lamp.test.js passes, without a broker", async () => { + // LOOPBACK_PORT 1, where nothing listens: a connection would fail + const example = await run(path.join("testing", "lamp.test.js")); + assert.equal(await example.exited, 0, example.output()); + assert.match(example.output(), /pass 4\n/); + assert.match(example.output(), /fail 0\n/); +}); + +test("the other code blocks of the readme compile, and load what the library exports", () => { + const fragments = blocks.filter(({ file }) => !file); + assert.ok(fragments.length >= 8); + for (const { code } of fragments) { + for (const [, names] of code.matchAll(/(?:const|import) \{([^}]+)\} (?:= require\("alex2node"\)|from "alex2node")/g)) { + for (const name of names.split(",").map((part) => part.trim())) assert.ok(name in alex2node, `${name} is not an export`); + } + const body = code.split("\n").filter((line) => !/^import .* from "alex2node";$/.test(line)).join("\n"); + assert.doesNotThrow(() => new AsyncFunction(body), `this block does not compile:\n${code}`); + } +}); + +test("what the readme calls on AlexaErrors and on the registry is there", () => { + for (const [, helper] of readme.matchAll(/AlexaErrors\.(\w+)\(/g)) assert.equal(typeof alex2node.AlexaErrors[helper], "function", helper); + for (const [, namespace] of readme.matchAll(/registry\.get\("([^"]+)"\)/g)) assert.ok(alex2node.registry.has(namespace), namespace); + assert.equal(Object.keys(require("../dist/cjs/registry/catalog.js").ERROR_TYPES).length, 73); +});