The interface registry, generated and checked discovery, typed dispatch with automatic error answers, message builders, proactive events and typed helpers for every error type; the 1.x API is kept and the examples of 1.5.2 run unchanged. docs/wire-changes.md lists what a 1.x caller can observe. On 2026-09-28 a real Alexa account drove 33 test devices on this API through the public Alex2MQTT service: Alexa accepted the discovery of all of them, 50 of 58 cases passed, 4 had no action in the control call and 4 failed on what the check could see, none on an answer of the library. The readme table names the interfaces that run covered. Voice commands were not tested yet. 307 tests. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
40 KiB
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 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
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:
const { Alex2MQTT, PowerController } = require("alex2node");
import { Alex2MQTT, PowerController } from "alex2node";
Quick start
A lamp that switches and dims. This is examples/lamp.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();
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:
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:
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.
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:
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:
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:
const result = await bell.raise(DoorbellEventSource, "DoorbellPress");
It throws a MessageError for an event the device did not declare. See 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 | 3 | - | ReportState |
- | 1 | ReportState |
| Alexa.BrightnessController | 3 | brightness |
SetBrightness, AdjustBrightness |
- | 1 | directives, change report |
| Alexa.ChannelController | 3 | channel |
ChangeChannel, SkipChannels |
- | 2 | - |
| Alexa.ColorController | 3 | color |
SetColor |
- | 1 | directives |
| Alexa.ColorTemperatureController | 3 | colorTemperatureInKelvin |
SetColorTemperature, IncreaseColorTemperature, DecreaseColorTemperature |
- | 1 | directives |
| Alexa.ContactSensor | 3 | detectionState |
- | - | 1 | change report |
| Alexa.DoorbellEventSource | 3 | - | - | DoorbellPress |
2 | event |
| Alexa.EndpointHealth | 3.1 | connectivity |
- | - | 1 | - |
| Alexa.EqualizerController | 3 | bands, mode |
SetMode, SetBands, AdjustBands, ResetBands |
- | 2 | - |
| Alexa.HumiditySensor | 3 | relativeHumidity |
- | - | 1 | - |
| Alexa.InputController | 3 | input |
SelectInput |
- | 2 | - |
| Alexa.InventoryLevelSensor (instances) | 3 | level |
- | - | 2 | change report |
| Alexa.LockController | 3 | lockState |
Lock, Unlock |
- | 1 | directives with a deferred answer, change report |
| Alexa.ModeController (instances) | 3 | mode |
SetMode, AdjustMode |
- | 1 | directives |
| Alexa.MotionSensor | 3 | detectionState |
- | - | 1 | change report |
| Alexa.PercentageController | 3 | percentage |
SetPercentage, AdjustPercentage |
- | 1 | directives |
| Alexa.PlaybackController | 3 | - | Play, Pause, Stop, Next, Previous, FastForward, Rewind, StartOver |
- | 2 | - |
| Alexa.PlaybackStateReporter | 3 | playbackState |
- | - | 2 | - |
| Alexa.PowerController | 3 | powerState |
TurnOn, TurnOff |
- | 1 | directives, change report |
| Alexa.PowerLevelController | 3 | powerLevel |
SetPowerLevel, AdjustPowerLevel |
- | 1 | directives |
| Alexa.RangeController (instances) | 3 | rangeValue |
SetRangeValue, AdjustRangeValue |
- | 1 | directives, change report |
| Alexa.SceneController | 3 | - | Activate, Deactivate |
ActivationStarted, DeactivationStarted |
1 | directives |
| Alexa.SecurityPanelController | 3 | armState, burglaryAlarm, fireAlarm, carbonMonoxideAlarm, waterAlarm |
Arm, Disarm |
- | 2 | directives, change report |
| Alexa.SimpleEventSource (instances) | 1.0 | - | - | Event |
2 | event |
| Alexa.Speaker | 3 | volume, muted |
SetVolume, AdjustVolume, SetMute |
- | 2 | - |
| Alexa.StepSpeaker | 3 | - | AdjustVolume, SetMute |
- | 2 | - |
| Alexa.TemperatureSensor | 3 | temperature |
- | - | 1 | state report |
| Alexa.ThermostatController | 3.2 | targetSetpoint, lowerSetpoint, upperSetpoint, thermostatMode, adaptiveRecoveryStatus |
SetTargetTemperature, AdjustTargetTemperature, SetThermostatMode, ResumeSchedule |
- | 1 | directives |
| Alexa.ThermostatController.Schedule | 3.2 | adaptiveRecoveryEnabled, scheduleEnabled |
SetWeeklySchedule, SetScheduleState, SetAdaptiveRecovery |
- | 2 | state report |
| Alexa.TimeHoldController | 3 | holdStartTime, holdEndTime |
Hold, Resume |
- | 2 | - |
| Alexa.ToggleController (instances) | 3 | toggleState |
TurnOn, TurnOff |
- | 1 | directives |
| Alexa.WakeOnLANController | 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 2.0.0; "-" is an interface that no such run has covered.
Tier 3: Alexa.ApplicationStateReporter 1, Alexa.Audio.PlayQueue 1, Alexa.AuthorizationController 1, Alexa.AutomationManagement 1, Alexa.Automotive.VehicleData 1, Alexa.Camera.LiveViewController 1.7, Alexa.CameraStreamController 3, Alexa.Commissionable 1, Alexa.ConsentManagement.ConsentRequiredReporter 1, Alexa.Cooking 1, Alexa.Cooking.FoodTemperatureController 1, Alexa.Cooking.FoodTemperatureSensor 1, Alexa.Cooking.PresetController 1, Alexa.Cooking.TemperatureController 1, Alexa.Cooking.TemperatureSensor 1, Alexa.Cooking.TimeController 1, Alexa.DataController 1, Alexa.DeviceUsage.Estimation 1, Alexa.DeviceUsage.Meter 1, Alexa.InventoryLevelUsageSensor 1, Alexa.InventoryUsageSensor 1, Alexa.KeypadController 1, Alexa.Launcher 1.1, Alexa.Media.PlayQueue 1, Alexa.Media.Playback 1, Alexa.Media.Search 1, Alexa.ProactiveNotificationSource 1, Alexa.RTCSessionController 1, Alexa.RecordController 3, Alexa.RemoteVideoPlayer 1, Alexa.SecurityPanelController.Alert 1, Alexa.SeekController 3, Alexa.SmartVision.ObjectDetectionSensor 1, Alexa.SmartVision.SnapshotProvider 1, Alexa.ThermostatController.Configuration 1, Alexa.ThermostatController.HVAC.Components 1, Alexa.UIController 1, Alexa.UserPreference 1, Alexa.VideoRecorder 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 | lamp | PowerController, BrightnessController | handlers, device.state(), ctx.respond() |
| color-lamp.js | colour lamp | + ColorController, ColorTemperatureController | a state that depends on the mode of the device |
| thermostat.js | thermostat | ThermostatController, TemperatureSensor | one setpoint, three modes, temperature scales |
| blind.js | roller blind | RangeController | an instance, friendly names, semantics for open and close |
| lock.js | lock | LockController | ctx.defer() for an answer that takes longer than 7 seconds |
| sensor.js | contact sensor | ContactSensor | device.changeReport() |
| scene.js | scene | SceneController | ActivationStarted and DeactivationStarted |
| doorbell.js | doorbell | DoorbellEventSource | device.raise() |
| errors.js | fan | PowerController, RangeController | AlexaErrors, ctx.error() |
| plug.mjs | plug | PowerController | the library as an ES module |
examples/testing/lamp.test.js tests a lamp without a broker, see below. 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 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, named as the type in
camel case. The function of a type whose payload has fields of its own takes them:
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 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:
// 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 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
// 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();
}
});
Limits
- What was run against Alexa. On 2026-09-28 a real Alexa account drove 33 test devices on the 2.0 API through
the public Alex2MQTT service; the table above has the interfaces. Alexa accepted the discovery of all 33. Covered
beside the directives: deferred answers,
ErrorResponse,ReportState, change reports, aDoorbellPressand a button event throughdevice.raise(). The directives came from the control call of the Alexa app, which has no way to say "raise", "lower" or "warmer": voice commands, and with them theAdjust...directives and the semantics of a blind, were not tested yet. - Not covered by that run.
Alexa.Speaker,Alexa.ChannelControllerand the other interfaces of a television or a speaker were discovered, but the control call has no action for them.Alexa.HumiditySensorwas discovered and answeredReportState; Alexa's own state call does not return a humidity, so nothing shows whether Alexa took the value. - 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()andaddTemperatureSensorProp()convert Fahrenheit to Celsius without rounding: 70 °F is sent as21.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
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
License
MIT, see LICENSE.