1.5.1: broker errors as events that never crash the host (error only when listened to; connect/offline/reconnect/close; connected flag), configurable broker host + mqtt options + log hook, Alexa.ChangeReport via Device.getChangeReport (<root>/changeReport), SceneController discovery + sendSceneResponse, addCapability options (proactivelyReported), unregisterDevice/clearDevices/getDevices/disconnect, discover/directive events, promise-returning quiet send(); tests on an in-process broker (aedes); readme

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
David 2026-09-22 19:29:38 +00:00
parent e96850bdd6
commit 717af632de
16 changed files with 2080 additions and 225 deletions

163
dist/Alex2Node.js vendored
View file

@ -3,12 +3,28 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.DEFAULT_HOST = void 0;
const mqtt_1 = __importDefault(require("mqtt"));
const Device_1 = __importDefault(require("./Device"));
const events_1 = require("events");
const DisplayCategory_1 = require("./DisplayCategory");
exports.DEFAULT_HOST = "mqtt://Alex2MQTT.stormysdream.club:1883";
/**
* The Alexa-to-MQTT bridge: one broker connection for a user's root topic, a set of registered devices, discovery and
* directive dispatch.
*
* Events (all optional to listen to - since 1.5.1 a broker outage never throws out of the library):
* "connect" connected (or reconnected) and subscribed to <root>/#
* "offline" the connection dropped; mqtt.js reconnects on its own (reconnectPeriod, default 1 s)
* "reconnect" a reconnect attempt starts
* "close" the connection closed
* "error" (err) a connection or publish error. Emitted ONLY when a listener is attached (1.4.0 emitted it
* unconditionally, and Node kills a process that has an unhandled "error" event)
* "discover" (n) a discovery request was answered with n devices
* "directive" (info) a directive was dispatched to a device: { endpointId, namespace, name }
*/
class Alex2MQTT extends events_1.EventEmitter {
constructor(username, password, rootTopic, debugLogging) {
constructor(username, password, rootTopic, debugLogging = false, options = {}) {
super(); // Initialize EventEmitter
this.username = username;
this.password = password;
@ -16,62 +32,73 @@ class Alex2MQTT extends events_1.EventEmitter {
this.debugLogging = debugLogging;
this.client = null;
this.devices = [];
this.MqttHost = "mqtt://Alex2MQTT.stormysdream.club";
/** true while the broker connection is up. */
this.connected = false;
/** ISO time of the last discovery request answered, null before the first. */
this.lastDiscoveryAt = null;
this.options = options || {};
this.MqttHost = this.options.host || exports.DEFAULT_HOST;
}
log(message, detail) {
if (this.options.log)
this.options.log(message, detail);
else if (this.debugLogging) {
if (detail === undefined)
console.log(`[Alex2Node.ts] ${message}`);
else
console.log(`[Alex2Node.ts] ${message}`, detail);
}
}
/** Emit "error" only when somebody listens: an unhandled "error" event would crash the host process. */
fail(err) {
this.log("error: " + err.message);
if (this.listenerCount("error") > 0)
this.emit("error", err);
}
connect() {
const options = {
username: this.username,
password: this.password,
};
if (this.client)
return;
const options = Object.assign({ username: this.username, password: this.password, reconnectPeriod: 1000, connectTimeout: 10000 }, (this.options.mqtt || {}));
this.client = mqtt_1.default.connect(this.MqttHost, options);
this.client.on("connect", () => {
if (this.debugLogging) {
console.log("[Alex2Node.ts] Connected to MQTT broker");
}
this.connected = true;
this.log("Connected to MQTT broker");
this.client.subscribe(this.rootTopic + "/#", (err) => {
if (this.debugLogging && !err) {
console.log("[Alex2Node.ts] Subscribed to topics");
}
if (err) {
console.error("[Alex2Node.ts] MQTT connection error:", err);
this.emit("error", err);
this.fail(err);
return;
}
this.log("Subscribed to topics");
this.emit("connect");
});
});
this.client.on("offline", () => { this.connected = false; this.log("offline"); this.emit("offline"); });
this.client.on("reconnect", () => { this.log("reconnecting"); this.emit("reconnect"); });
this.client.on("close", () => { this.connected = false; this.emit("close"); });
this.client.on("error", (err) => { this.connected = false; this.fail(err); });
this.client.on("message", (topic, message) => {
if (this.debugLogging) {
console.log(`[Alex2Node.ts] MQTT Message Received`);
console.log(` Topic: ${topic}`);
console.log(` Payload: ${message.toString()}`);
}
this.log(`MQTT Message Received`, { topic, payload: message.toString() });
if (topic == `${this.rootTopic}/discover`) {
if (this.debugLogging) {
console.log("[Alex2Node.ts] Discovery request received, getting device json...");
}
this.log("Discovery request received, getting device json...");
const deviceArray = this.devices.map((device) => device.getJSON());
if (deviceArray.length > 0) {
this.client.publish(topic + "_r", JSON.stringify(deviceArray), (err) => {
if (err) {
console.error("[Alex2Node.ts] MQTT connection error:", err);
this.emit("error", err);
}
else if (this.debugLogging) {
console.log(`[Alex2Node.ts] Discovery payloads published to ${topic + "_r"}`);
console.log(JSON.stringify(deviceArray, null, 2));
}
});
}
this.lastDiscoveryAt = new Date().toISOString();
this.client.publish(topic + "_r", JSON.stringify(deviceArray), (err) => {
if (err) {
this.fail(err);
return;
}
this.log(`Discovery payloads published to ${topic + "_r"}`, deviceArray);
this.emit("discover", deviceArray.length);
});
}
else if (topic.split("/").length == 3) {
const [root, endpointId, directiveType] = topic.split("/");
const [, endpointId, directiveType] = topic.split("/");
if (directiveType != "alexaDirective") {
return;
}
const device = this.devices.find((d) => d.endpointId === endpointId);
if (!device) {
if (this.debugLogging) {
console.warn(`[Alex2Node.ts] No device found for endpointId: ${endpointId}`);
}
this.log(`No device found for endpointId: ${endpointId}`);
return;
}
let payload;
@ -79,15 +106,14 @@ class Alex2MQTT extends events_1.EventEmitter {
payload = JSON.parse(message.toString());
}
catch (e) {
console.error(`[Alex2Node.ts] Failed to parse payload JSON`, e);
this.log(`Failed to parse payload JSON`, e);
return;
}
if (payload.header &&
payload.header.namespace === "Alexa" &&
payload.header.name === "ReportState") {
if (this.debugLogging) {
console.log(`[Alex2Node.ts] ReportState directive for device ${endpointId}`);
}
if (!payload || !payload.header)
return;
this.emit("directive", { endpointId, namespace: payload.header.namespace, name: payload.header.name });
if (payload.header.namespace === "Alexa" && payload.header.name === "ReportState") {
this.log(`ReportState directive for device ${endpointId}`);
device.emit("ReportState", payload);
}
else {
@ -95,21 +121,23 @@ class Alex2MQTT extends events_1.EventEmitter {
}
}
});
this.client.on("error", (err) => {
if (this.debugLogging) {
console.error("[Alex2Node.ts] Connection failed:", err);
}
console.error("[Alex2Node.ts] MQTT connection error:", err);
this.emit("error", err);
}
/** Close the broker connection (resolves once closed). The devices stay registered; connect() again reuses them. */
disconnect() {
return new Promise((resolve) => {
const c = this.client;
if (!c)
return resolve();
this.client = null;
this.connected = false;
c.end(true, {}, () => resolve());
});
}
registerDevice(name, endpointId, displayCategory) {
if (!this.client) {
throw new Error("Must call connect before creating devices");
}
if (this.debugLogging) {
console.log(`[Alex2Node.ts] Creating new device with endpoint: ${endpointId}`);
}
this.log(`Creating new device with endpoint: ${endpointId}`);
const normalizedCategory = displayCategory === null
? [DisplayCategory_1.DisplayCategory.LIGHT]
: Array.isArray(displayCategory)
@ -119,5 +147,32 @@ class Alex2MQTT extends events_1.EventEmitter {
this.devices.push(device);
return device;
}
/** Forget a device (its listeners with it). Returns false when there was none. */
unregisterDevice(endpointId) {
const i = this.devices.findIndex((d) => d.endpointId === endpointId);
if (i === -1)
return false;
this.devices[i].removeAllListeners();
this.devices.splice(i, 1);
return true;
}
/** Forget every device. */
clearDevices() {
for (const d of this.devices)
d.removeAllListeners();
this.devices = [];
}
getDevices() {
return this.devices.slice();
}
getDevice(endpointId) {
return this.devices.find((d) => d.endpointId === endpointId);
}
getRootTopic() {
return this.rootTopic;
}
getHost() {
return this.MqttHost;
}
}
exports.default = Alex2MQTT;

View file

@ -106,19 +106,17 @@ class AlexaErrorResponse {
this.event.payload = Object.assign({ type,
message }, otherParams);
}
toJSON() {
return { event: this.event };
}
send(sendAsync = false) {
const payload = {
event: this.event,
};
const topic = `${this.rootTopic}/${this.endpointId}/${sendAsync ? "deferredResponse" : "alexaResponce"}`; //Yes this should be response but it is incorrect in both Alex2MQTT and Alex2ESP so for consistency is is wrong here too
const payloadStr = JSON.stringify(payload);
this.mqttClient.publish(topic, payloadStr, (err) => {
if (err) {
console.error("[AlexaErrorResponse] Failed to publish status message:", err);
}
else {
console.log(`[AlexaErrorResponse] Sent message to ${topic}`);
}
return new Promise((resolve, reject) => {
this.mqttClient.publish(topic, payloadStr, (err) => (err ? reject(err) : resolve(topic)));
});
}
}

View file

@ -244,6 +244,7 @@ class AlexaInterface {
case AlexaInterfaceType.REMOTE_VIDEO_PLAYER:
case AlexaInterfaceType.RTC_SESSION_CONTROLLER:
case AlexaInterfaceType.SCENE_CONTROLLER:
return []; // scenes have no reportable properties: Activate/Deactivate answer with ActivationStarted (Device.sendSceneResponse)
case AlexaInterfaceType.SECURITY_PANEL_CONTROLLER:
case AlexaInterfaceType.SEEK_CONTROLLER:
case AlexaInterfaceType.SIMPLE_EVENT_SOURCE:
@ -276,7 +277,13 @@ class AlexaInterface {
supported: this.getProps().map(name => ({ name })),
},
};
if (this.type == AlexaInterfaceType.THERMOSTAT_CONTROLLER) {
if (this.type == AlexaInterfaceType.SCENE_CONTROLLER) {
// Alexa.SceneController v3 carries no properties block: supportsDeactivation + proactivelyReported at the top level
delete doc.properties;
doc.supportsDeactivation = true;
doc.proactivelyReported = this.proactivelyReported;
}
else if (this.type == AlexaInterfaceType.THERMOSTAT_CONTROLLER) {
doc.configuration = {
"supportedModes": ["HEAT", "COOL", "AUTO", "OFF"],
"supportsScheduling": false

View file

@ -28,20 +28,29 @@ var TemperatureSensorScale;
TemperatureSensorScale["FAHRENHEIT"] = "FAHRENHEIT";
})(TemperatureSensorScale || (exports.TemperatureSensorScale = TemperatureSensorScale = {}));
class AlexaStatusMessage {
constructor(correlationToken, rootTopic, endpointId, mqttClient, isResponse = false, isDeferred = false) {
constructor(correlationToken, rootTopic, endpointId, mqttClient, isResponse = false, isDeferred = false, changeCause = null) {
this.context = { properties: [] };
/** ChangeReport only: the properties that changed (payload.change.properties); the rest go to context. */
this.changeProps = [];
this.changeCause = null;
this.target = "context";
this.rootTopic = rootTopic;
this.endpointId = endpointId;
this.mqttClient = mqttClient;
this.isDeferred = isDeferred;
this.changeCause = changeCause;
if (changeCause)
this.target = "change";
this.event = {
header: {
namespace: "Alexa",
name: isDeferred
? "DeferredResponse"
: isResponse
? "Response"
: "StateReport",
name: changeCause
? "ChangeReport"
: isDeferred
? "DeferredResponse"
: isResponse
? "Response"
: "StateReport",
payloadVersion: "3",
messageId: this.generateMessageId(),
correlationToken,
@ -68,9 +77,33 @@ class AlexaStatusMessage {
};
if (instance)
prop.instance = instance;
this.context.properties.push(prop);
(this.target === "change" ? this.changeProps : this.context.properties).push(prop);
return this;
}
/** ChangeReport: the add*Prop calls that follow describe what CHANGED (the default for a change report). */
changed() {
this.target = "change";
return this;
}
/** ChangeReport: the add*Prop calls that follow describe the other, unchanged properties (context). */
unchanged() {
this.target = "context";
return this;
}
/** True for a ChangeReport (Device.getChangeReport). */
isChangeReport() {
return this.changeCause !== null;
}
/** The message as it will be published (for tests and logging). */
toJSON() {
if (this.changeCause) {
return {
event: Object.assign(Object.assign({}, this.event), { payload: { change: { cause: { type: this.changeCause }, properties: this.changeProps } } }),
context: this.context,
};
}
return { context: this.isDeferred ? null : this.context, event: this.event };
}
addModeControllerProp(instance, value, uncertaintyInMs = 0) {
return this.addProperty(AlexaInterface_1.AlexaInterfaceType.MODE_CONTROLLER, "mode", value, uncertaintyInMs, instance);
}
@ -125,20 +158,18 @@ class AlexaStatusMessage {
this.context.properties.push(prop);
return this;
}
send(sendAsync) {
const payload = {
context: this.isDeferred ? null : this.context,
event: this.event,
};
const topic = `${this.rootTopic}/${this.endpointId}/${sendAsync ? "deferredResponse" : "alexaResponce"}`; //Yes this should be response but it is incorrect in both Alex2MQTT and Alex2ESP so for consistency is is wrong here too
const payloadStr = JSON.stringify(payload);
this.mqttClient.publish(topic, payloadStr, (err) => {
if (err) {
console.error("[AlexaStatusMessage] Failed to publish status message:", err);
}
else {
console.log(`[AlexaStatusMessage] Sent message to ${topic}`);
}
/**
* Publish: a Response/StateReport to <root>/<endpoint>/alexaResponce (sendAsync: deferredResponse), a ChangeReport
* to <root>/changeReport (Alex2MQTT adds the user's token and posts it to the Alexa event gateway). Resolves with
* the topic; rejects on a publish error (1.4.0 only logged).
*/
send(sendAsync = false) {
const payloadStr = JSON.stringify(this.toJSON());
const topic = this.changeCause
? `${this.rootTopic}/changeReport`
: `${this.rootTopic}/${this.endpointId}/${sendAsync ? "deferredResponse" : "alexaResponce"}`; //Yes this should be response but it is incorrect in both Alex2MQTT and Alex2ESP so for consistency is is wrong here too
return new Promise((resolve, reject) => {
this.mqttClient.publish(topic, payloadStr, (err) => (err ? reject(err) : resolve(topic)));
});
}
}

42
dist/Device.js vendored
View file

@ -4,6 +4,7 @@ const AlexaInterface_1 = require("./AlexaInterface");
const DisplayCategory_1 = require("./DisplayCategory");
const events_1 = require("events");
const AlexaStatusMessage_1 = require("./AlexaStatusMessage");
const uuid_1 = require("uuid");
const AlexaErrorResponse_1 = require("./AlexaErrorResponse");
class Device extends events_1.EventEmitter {
constructor(mqttClient, rootTopic, name, endpointId, displayCategory, description = "Alexa to Node.js bridge", manufacturerName = "Alex2Node", manufacturer = "Alex2Node", model = "Alex2Node_v1.0.0") {
@ -47,6 +48,35 @@ class Device extends events_1.EventEmitter {
getStatusMessage(correlationToken, isResponse = false, isDeferred = false) {
return new AlexaStatusMessage_1.AlexaStatusMessage(correlationToken, this.rootTopic, this.endpointId, this.mqttClient, isResponse, isDeferred);
}
/**
* A proactive Alexa.ChangeReport (1.5.1): add the changed properties (the default target), optionally
* .unchanged() then the others, and .send() - it goes to <root>/changeReport, which Alex2MQTT forwards to the Alexa
* event gateway with the user's token. Alexa only accepts it for capabilities registered with proactivelyReported.
*/
getChangeReport(cause = "PHYSICAL_INTERACTION") {
return new AlexaStatusMessage_1.AlexaStatusMessage("", this.rootTopic, this.endpointId, this.mqttClient, false, false, cause);
}
/**
* Alexa.SceneController: answer an Activate / Deactivate directive with ActivationStarted / DeactivationStarted
* (1.5.1). Resolves with the topic published to.
*/
sendSceneResponse(correlationToken, activated, cause = "VOICE_INTERACTION", sendAsync = false) {
const payload = {
context: {},
event: {
header: { namespace: "Alexa.SceneController", name: activated ? "ActivationStarted" : "DeactivationStarted", messageId: (0, uuid_1.v4)(), correlationToken, payloadVersion: "3" },
endpoint: { endpointId: this.endpointId },
payload: { cause: { type: cause }, timestamp: new Date().toISOString() },
},
};
const topic = `${this.rootTopic}/${this.endpointId}/${sendAsync ? "deferredResponse" : "alexaResponce"}`;
return new Promise((resolve, reject) => {
this.mqttClient.publish(topic, JSON.stringify(payload), (err) => (err ? reject(err) : resolve(topic)));
});
}
getCapabilities() {
return this.capabilities.slice();
}
setManufacturerName(name) {
this.manufacturerName = name;
}
@ -68,14 +98,10 @@ class Device extends events_1.EventEmitter {
getSoftwareVersion() {
return this.softwareVersion;
}
addCapability(type) {
// const existing = this.capabilities.find(
// (iface) => iface.getType() === type
// );
// if (existing) {
// return existing;
// }
const newCapability = new AlexaInterface_1.AlexaInterface(type);
/** Add a capability. 1.5.1: options.retrievable / proactivelyReported / instance (a change report needs proactivelyReported). */
addCapability(type, options = {}) {
var _a, _b, _c;
const newCapability = new AlexaInterface_1.AlexaInterface(type, (_a = options.retrievable) !== null && _a !== void 0 ? _a : true, (_b = options.proactivelyReported) !== null && _b !== void 0 ? _b : false, (_c = options.instance) !== null && _c !== void 0 ? _c : "");
this.capabilities.push(newCapability);
// console.log(
// `Created new interface with type: ${newCapability.getTypeString()}`

7
dist/index.js vendored
View file

@ -3,10 +3,14 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.AlexaErrorResponse = exports.AlexaErrorType = exports.ThermostatMode = exports.TemperatureSensorScale = exports.EndpointHealth = exports.PowerController = exports.DisplayCategory = exports.AlexaActions = exports.ActionMapping = exports.AlexaInterfaceType = exports.Alex2MQTT = void 0;
exports.AlexaErrorResponse = exports.AlexaErrorType = exports.ThermostatMode = exports.TemperatureSensorScale = exports.EndpointHealth = exports.PowerController = exports.AlexaStatusMessage = exports.DisplayCategory = exports.AlexaActions = exports.ActionMapping = exports.AlexaInterfaceType = exports.AlexaInterface = exports.Device = exports.DEFAULT_HOST = exports.Alex2MQTT = void 0;
var Alex2Node_1 = require("./Alex2Node");
Object.defineProperty(exports, "Alex2MQTT", { enumerable: true, get: function () { return __importDefault(Alex2Node_1).default; } });
Object.defineProperty(exports, "DEFAULT_HOST", { enumerable: true, get: function () { return Alex2Node_1.DEFAULT_HOST; } });
var Device_1 = require("./Device");
Object.defineProperty(exports, "Device", { enumerable: true, get: function () { return __importDefault(Device_1).default; } });
var AlexaInterface_1 = require("./AlexaInterface");
Object.defineProperty(exports, "AlexaInterface", { enumerable: true, get: function () { return AlexaInterface_1.AlexaInterface; } });
Object.defineProperty(exports, "AlexaInterfaceType", { enumerable: true, get: function () { return AlexaInterface_1.AlexaInterfaceType; } });
var ActionMapping_1 = require("./ActionMapping");
Object.defineProperty(exports, "ActionMapping", { enumerable: true, get: function () { return ActionMapping_1.ActionMapping; } });
@ -14,6 +18,7 @@ Object.defineProperty(exports, "AlexaActions", { enumerable: true, get: function
var DisplayCategory_1 = require("./DisplayCategory");
Object.defineProperty(exports, "DisplayCategory", { enumerable: true, get: function () { return DisplayCategory_1.DisplayCategory; } });
var AlexaStatusMessage_1 = require("./AlexaStatusMessage");
Object.defineProperty(exports, "AlexaStatusMessage", { enumerable: true, get: function () { return AlexaStatusMessage_1.AlexaStatusMessage; } });
Object.defineProperty(exports, "PowerController", { enumerable: true, get: function () { return AlexaStatusMessage_1.PowerController; } });
Object.defineProperty(exports, "EndpointHealth", { enumerable: true, get: function () { return AlexaStatusMessage_1.EndpointHealth; } });
Object.defineProperty(exports, "TemperatureSensorScale", { enumerable: true, get: function () { return AlexaStatusMessage_1.TemperatureSensorScale; } });