Alex2Node/readme.md
David dd071548bc 2.0.0
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>
2026-09-28 22:55:23 +00:00

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, a DoorbellPress and a button event through device.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 the Adjust... directives and the semantics of a blind, were not tested yet.
  • Not covered by that run. Alexa.Speaker, Alexa.ChannelController and the other interfaces of a television or a speaker were discovered, but the control call has no action for them. Alexa.HumiditySensor was discovered and answered ReportState; 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() 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

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.

License

MIT, see LICENSE.