The shell block under the quick start named the file without its directory. Run from the repository root, as the rest of the readme assumes, it failed with "Cannot find module". The command now matches the header comment of examples/lamp.js and the recipe table. The readme test compiles only the js blocks, so it did not see this. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
622 lines
39 KiB
Markdown
622 lines
39 KiB
Markdown
# Alex2Node
|
|
|
|
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.
|
|
|
|
```
|
|
Alexa -> Alex2MQTT (the skill) -> MQTT broker -> your process (alex2node)
|
|
```
|
|
|
|
Alex2ESP does the same on an ESP8266, on the same topics.
|
|
|
|
## 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.
|
|
|
|
## Install
|
|
|
|
```sh
|
|
npm install alex2node # from npm
|
|
npm install git+https://git.stormysdream.club/apps/Alex2Node.git#v2.0.0 # the tag, from the repository
|
|
```
|
|
|
|
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. `#<tag>` or `#<commit>` pins a revision, and npm records the resolved commit in
|
|
`package-lock.json`.
|
|
|
|
The package has a CommonJS and an ES module build, and its type declarations:
|
|
|
|
```js
|
|
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):
|
|
|
|
<!-- file: 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();
|
|
```
|
|
|
|
```sh
|
|
ALEX2MQTT_USERNAME=... ALEX2MQTT_PASSWORD=... ALEX2MQTT_ROOT_TOPIC=... node examples/lamp.js
|
|
```
|
|
|
|
Then discover the devices in the Alexa app. The lamp answers while the process runs.
|
|
|
|
## Concepts
|
|
|
|
**Bridge.** One `Alex2MQTT` is one broker connection for one root topic. The constructor takes the three
|
|
credentials, then `debugLogging` and the options:
|
|
|
|
```js
|
|
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();
|
|
```
|
|
|
|
Its events are `connect`, `offline`, `reconnect`, `close`, `error`, `discover` (the number of devices announced),
|
|
`directive`, `unknownEndpoint` and `unanswered`. None has to be listened to.
|
|
|
|
**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.
|
|
|
|
**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("<Directive>", 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, "<Event>", 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.
|
|
|
|
<!-- capabilities: written by scripts/capability-table.js, npm run docs -->
|
|
| 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.
|
|
<!-- /capabilities -->
|
|
|
|
`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 |
|
|
|---|---|---|
|
|
| `<root>/discover` | to the bridge | a discovery request |
|
|
| `<root>/discover_r` | from the bridge | the endpoints, as one JSON array |
|
|
| `<root>/<endpointId>/alexaDirective` | to the bridge | a directive: `{ header, endpoint, payload }` |
|
|
| `<root>/<endpointId>/alexaResponce` | from the bridge | `Response`, `StateReport`, `ErrorResponse` or `DeferredResponse` |
|
|
| `<root>/<endpointId>/deferredResponse` | from the bridge | the answer that follows a `DeferredResponse` |
|
|
| `<root>/changeReport` | from the bridge | a `ChangeReport` |
|
|
| `<root>/event` | from the bridge | an event of `device.raise()` |
|
|
|
|
`alexaResponce` is how the service spells it. The bridge subscribes to `<root>/discover` and
|
|
`<root>/+/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 `<root>/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 `<root>/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):
|
|
|
|
<!-- file: 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 `<root>/#` 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, "<string>")` | 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 | `<root>/#` | `<root>/discover`, `<root>/+/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()`.
|
|
|
|
<details>
|
|
<summary>The light of the 1.x readme: examples/legacy/ExamplePowerController.js</summary>
|
|
|
|
<!-- file: 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
|
|
|
|
// 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;
|
|
|
|
// 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)
|
|
|
|
// Define the device name (bedroom light)
|
|
const deviceName = "Bedroom Light";
|
|
|
|
// 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);
|
|
|
|
// 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) => {
|
|
console.log("ReportState received!", payload);
|
|
|
|
const { correlationToken } = payload.header;
|
|
|
|
const status = bedroomLight.getStatusMessage(correlationToken);
|
|
|
|
status
|
|
.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 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");
|
|
} else if (name === "TurnOff") {
|
|
outputState = PowerController.OFF;
|
|
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)
|
|
.addPowerControllerProp(outputState)
|
|
.send();
|
|
}
|
|
});
|
|
```
|
|
|
|
</details>
|
|
|
|
## 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 `<root>/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.
|
|
|
|
## 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
|
|
```
|
|
|
|
## Changelog
|
|
|
|
[CHANGELOG.md](CHANGELOG.md).
|
|
|
|
## License
|
|
|
|
MIT, see [LICENSE](LICENSE).
|