build: dual CJS/ESM output, ES2020 target, Node 18 floor

"exports" gains "import" and "types" next to "require": import "alex2node" failed with
ERR_PACKAGE_PATH_NOT_EXPORTED. scripts/build.mjs compiles src/ to dist/cjs, dist/esm and dist/types in a staging
directory and swaps them in only when both compiler runs passed. "main" and "types" follow for resolvers that do
not read "exports". Target ES6 -> ES2020, "engines": node >= 18, relative imports carry ".js" for Node's ESM loader.
uuid gives way to crypto.randomUUID(); mqtt is the only runtime dependency left.

Tests load the package by name. New: both entry points, the modules the builds import, dist/ equal to a fresh
build, a round trip on the ES module build, and two compiled type fixtures in place of the .d.ts regexes.
22 tests pass in 5.7 s. dist/: 16 files, 79,711 B -> 25 files, 132,291 B; the npm tarball is 25,125 B.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
David 2026-09-28 13:58:17 +00:00
parent 58869d44c1
commit 1d88c7811a
50 changed files with 1472 additions and 195 deletions

2
.gitignore vendored
View file

@ -1,2 +1,4 @@
node_modules/ node_modules/
.env .env
# the staging directory of a build that was killed (scripts/build.mjs removes it otherwise)
dist/.build-*/

View file

@ -4,11 +4,12 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
}; };
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.DEFAULT_HOST = void 0; exports.DEFAULT_HOST = void 0;
// mqtt reaches Node as CommonJS: the default import works from both builds, a named one needs Node to detect it.
const mqtt_1 = __importDefault(require("mqtt")); const mqtt_1 = __importDefault(require("mqtt"));
const Device_1 = __importDefault(require("./Device")); const Device_js_1 = __importDefault(require("./Device.js"));
const events_1 = require("events"); const events_1 = require("events");
const DisplayCategory_1 = require("./DisplayCategory"); const DisplayCategory_js_1 = require("./DisplayCategory.js");
const AlexaInterface_1 = require("./AlexaInterface"); const AlexaInterface_js_1 = require("./AlexaInterface.js");
exports.DEFAULT_HOST = "mqtt://Alex2MQTT.stormysdream.club:1883"; 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 * The Alexa-to-MQTT bridge: one broker connection for a user's root topic, a set of registered devices, discovery and
@ -59,7 +60,13 @@ class Alex2MQTT extends events_1.EventEmitter {
connect() { connect() {
if (this.client) if (this.client)
return; return;
const options = Object.assign({ username: this.username, password: this.password, reconnectPeriod: 1000, connectTimeout: 10000 }, (this.options.mqtt || {})); const options = {
username: this.username,
password: this.password,
reconnectPeriod: 1000,
connectTimeout: 10000,
...(this.options.mqtt || {}),
};
this.client = mqtt_1.default.connect(this.MqttHost, options); this.client = mqtt_1.default.connect(this.MqttHost, options);
for (const d of this.devices) for (const d of this.devices)
d.setMqttClient(this.client); // after a disconnect(): the devices publish through the new client d.setMqttClient(this.client); // after a disconnect(): the devices publish through the new client
@ -87,7 +94,7 @@ class Alex2MQTT extends events_1.EventEmitter {
this.lastDiscoveryAt = new Date().toISOString(); this.lastDiscoveryAt = new Date().toISOString();
for (const device of this.devices) // a capability the library has no property list for goes out with supported: [] for (const device of this.devices) // a capability the library has no property list for goes out with supported: []
for (const cap of device.getCapabilities()) for (const cap of device.getCapabilities())
if (cap.getProps().length === 0 && cap.getType() !== AlexaInterface_1.AlexaInterfaceType.SCENE_CONTROLLER) if (cap.getProps().length === 0 && cap.getType() !== AlexaInterface_js_1.AlexaInterfaceType.SCENE_CONTROLLER)
this.log(`${device.endpointId}: no property list known for ${cap.getTypeString()}, discovery lists it with no supported properties`); this.log(`${device.endpointId}: no property list known for ${cap.getTypeString()}, discovery lists it with no supported properties`);
this.client.publish(topic + "_r", JSON.stringify(deviceArray), (err) => { this.client.publish(topic + "_r", JSON.stringify(deviceArray), (err) => {
if (err) { if (err) {
@ -155,11 +162,11 @@ class Alex2MQTT extends events_1.EventEmitter {
} }
this.log(`Creating new device with endpoint: ${endpointId}`); this.log(`Creating new device with endpoint: ${endpointId}`);
const normalizedCategory = displayCategory === null const normalizedCategory = displayCategory === null
? [DisplayCategory_1.DisplayCategory.LIGHT] ? [DisplayCategory_js_1.DisplayCategory.LIGHT]
: Array.isArray(displayCategory) : Array.isArray(displayCategory)
? displayCategory ? displayCategory
: [displayCategory || DisplayCategory_1.DisplayCategory.LIGHT]; : [displayCategory || DisplayCategory_js_1.DisplayCategory.LIGHT];
const device = new Device_1.default(this.client, this.rootTopic, name, endpointId, normalizedCategory); const device = new Device_js_1.default(this.client, this.rootTopic, name, endpointId, normalizedCategory);
device.onPublishError = (err) => this.fail(err); // a failed publish is an "error" event (when listened to), never a rejected send() device.onPublishError = (err) => this.fail(err); // a failed publish is an "error" event (when listened to), never a rejected send()
this.devices.push(device); this.devices.push(device);
return device; return device;

View file

@ -1,7 +1,7 @@
"use strict"; "use strict";
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.AlexaErrorResponse = exports.AlexaErrorType = void 0; exports.AlexaErrorResponse = exports.AlexaErrorType = void 0;
const uuid_1 = require("uuid"); const crypto_1 = require("crypto");
var AlexaErrorType; var AlexaErrorType;
(function (AlexaErrorType) { (function (AlexaErrorType) {
AlexaErrorType["ALREADY_IN_OPERATION"] = "ALREADY_IN_OPERATION"; AlexaErrorType["ALREADY_IN_OPERATION"] = "ALREADY_IN_OPERATION";
@ -89,7 +89,7 @@ class AlexaErrorResponse {
header: { header: {
namespace: "Alexa", namespace: "Alexa",
name: "ErrorResponse", name: "ErrorResponse",
messageId: this.generateMessageId(), messageId: (0, crypto_1.randomUUID)(),
correlationToken, correlationToken,
payloadVersion: "3", payloadVersion: "3",
}, },
@ -99,12 +99,12 @@ class AlexaErrorResponse {
payload: {}, payload: {},
}; };
} }
generateMessageId() {
return (0, uuid_1.v4)();
}
setErrorMessage(type, message, otherParams = {}) { setErrorMessage(type, message, otherParams = {}) {
this.event.payload = Object.assign({ type, this.event.payload = {
message }, otherParams); type,
message,
...otherParams,
};
} }
toJSON() { toJSON() {
return { event: this.event }; return { event: this.event };

View file

@ -1,8 +1,8 @@
"use strict"; "use strict";
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.AlexaStatusMessage = exports.TemperatureSensorScale = exports.PowerController = exports.ThermostatMode = exports.EndpointHealth = void 0; exports.AlexaStatusMessage = exports.TemperatureSensorScale = exports.PowerController = exports.ThermostatMode = exports.EndpointHealth = void 0;
const uuid_1 = require("uuid"); const crypto_1 = require("crypto");
const AlexaInterface_1 = require("./AlexaInterface"); const AlexaInterface_js_1 = require("./AlexaInterface.js");
var EndpointHealth; var EndpointHealth;
(function (EndpointHealth) { (function (EndpointHealth) {
EndpointHealth["OK"] = "OK"; EndpointHealth["OK"] = "OK";
@ -52,7 +52,7 @@ class AlexaStatusMessage {
? "Response" ? "Response"
: "StateReport", : "StateReport",
payloadVersion: "3", payloadVersion: "3",
messageId: this.generateMessageId(), messageId: (0, crypto_1.randomUUID)(),
correlationToken, correlationToken,
}, },
endpoint: { endpoint: {
@ -61,9 +61,6 @@ class AlexaStatusMessage {
payload: {}, payload: {},
}; };
} }
generateMessageId() {
return (0, uuid_1.v4)();
}
getTimestamp() { getTimestamp() {
return new Date().toISOString(); return new Date().toISOString();
} }
@ -98,17 +95,17 @@ class AlexaStatusMessage {
toJSON() { toJSON() {
if (this.changeCause) { if (this.changeCause) {
return { return {
event: Object.assign(Object.assign({}, this.event), { payload: { change: { cause: { type: this.changeCause }, properties: this.changeProps } } }), event: { ...this.event, payload: { change: { cause: { type: this.changeCause }, properties: this.changeProps } } },
context: this.context, context: this.context,
}; };
} }
return { context: this.isDeferred ? null : this.context, event: this.event }; return { context: this.isDeferred ? null : this.context, event: this.event };
} }
addModeControllerProp(instance, value, uncertaintyInMs = 0) { addModeControllerProp(instance, value, uncertaintyInMs = 0) {
return this.addProperty(AlexaInterface_1.AlexaInterfaceType.MODE_CONTROLLER, "mode", value, uncertaintyInMs, instance); return this.addProperty(AlexaInterface_js_1.AlexaInterfaceType.MODE_CONTROLLER, "mode", value, uncertaintyInMs, instance);
} }
addThermostatModeProp(mode, uncertaintyInMs = 0) { addThermostatModeProp(mode, uncertaintyInMs = 0) {
return this.addProperty(AlexaInterface_1.AlexaInterfaceType.THERMOSTAT_CONTROLLER, "thermostatMode", mode, uncertaintyInMs); return this.addProperty(AlexaInterface_js_1.AlexaInterfaceType.THERMOSTAT_CONTROLLER, "thermostatMode", mode, uncertaintyInMs);
} }
addEstimatedDeferralTime(seconds) { addEstimatedDeferralTime(seconds) {
if (this.isDeferred) { if (this.isDeferred) {
@ -128,14 +125,14 @@ class AlexaStatusMessage {
: value, : value,
scale: "CELSIUS", scale: "CELSIUS",
}; };
return this.addProperty(AlexaInterface_1.AlexaInterfaceType.THERMOSTAT_CONTROLLER, name, tempValue, uncertaintyInMs); return this.addProperty(AlexaInterface_js_1.AlexaInterfaceType.THERMOSTAT_CONTROLLER, name, tempValue, uncertaintyInMs);
} }
/** Alexa.EndpointHealth connectivity: EndpointHealth.OK / UNREACHABLE (the plain strings "OK" / "UNREACHABLE" are accepted too). */ /** Alexa.EndpointHealth connectivity: EndpointHealth.OK / UNREACHABLE (the plain strings "OK" / "UNREACHABLE" are accepted too). */
addHealthProp(health, uncertaintyInMs = 0) { addHealthProp(health, uncertaintyInMs = 0) {
return this.addProperty(AlexaInterface_1.AlexaInterfaceType.ENDPOINT_HEALTH, "connectivity", { value: health }, uncertaintyInMs); return this.addProperty(AlexaInterface_js_1.AlexaInterfaceType.ENDPOINT_HEALTH, "connectivity", { value: health }, uncertaintyInMs);
} }
addPowerControllerProp(power, uncertaintyInMs = 0) { addPowerControllerProp(power, uncertaintyInMs = 0) {
return this.addProperty(AlexaInterface_1.AlexaInterfaceType.POWER_CONTROLLER, "powerState", power, uncertaintyInMs); return this.addProperty(AlexaInterface_js_1.AlexaInterfaceType.POWER_CONTROLLER, "powerState", power, uncertaintyInMs);
} }
addTemperatureSensorProp(scale, value, uncertaintyInMs = 0) { addTemperatureSensorProp(scale, value, uncertaintyInMs = 0) {
const tempValue = { const tempValue = {
@ -144,16 +141,16 @@ class AlexaStatusMessage {
: value, : value,
scale: "CELSIUS", scale: "CELSIUS",
}; };
return this.addProperty(AlexaInterface_1.AlexaInterfaceType.TEMPERATURE_SENSOR, "temperature", tempValue, uncertaintyInMs); return this.addProperty(AlexaInterface_js_1.AlexaInterfaceType.TEMPERATURE_SENSOR, "temperature", tempValue, uncertaintyInMs);
} }
addBrightnessControllerProp(brightness, uncertaintyInMs = 0) { addBrightnessControllerProp(brightness, uncertaintyInMs = 0) {
return this.addProperty(AlexaInterface_1.AlexaInterfaceType.BRIGHTNESS_CONTROLLER, "brightness", brightness, uncertaintyInMs); return this.addProperty(AlexaInterface_js_1.AlexaInterfaceType.BRIGHTNESS_CONTROLLER, "brightness", brightness, uncertaintyInMs);
} }
addColorTemperatureControllerProp(colorTemp, uncertaintyInMs = 0) { addColorTemperatureControllerProp(colorTemp, uncertaintyInMs = 0) {
return this.addProperty(AlexaInterface_1.AlexaInterfaceType.COLOR_TEMPERATURE_CONTROLLER, "colorTemperatureInKelvin", colorTemp, uncertaintyInMs); return this.addProperty(AlexaInterface_js_1.AlexaInterfaceType.COLOR_TEMPERATURE_CONTROLLER, "colorTemperatureInKelvin", colorTemp, uncertaintyInMs);
} }
addToggleControllerProp(state, instance, uncertaintyInMs = 0) { addToggleControllerProp(state, instance, uncertaintyInMs = 0) {
return this.addProperty(AlexaInterface_1.AlexaInterfaceType.TOGGLE_CONTROLLER, "toggleState", state, uncertaintyInMs, instance); return this.addProperty(AlexaInterface_js_1.AlexaInterfaceType.TOGGLE_CONTROLLER, "toggleState", state, uncertaintyInMs, instance);
} }
addContextProp(prop) { addContextProp(prop) {
this.context.properties.push(prop); this.context.properties.push(prop);

View file

@ -1,11 +1,11 @@
"use strict"; "use strict";
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
const AlexaInterface_1 = require("./AlexaInterface"); const AlexaInterface_js_1 = require("./AlexaInterface.js");
const DisplayCategory_1 = require("./DisplayCategory"); const DisplayCategory_js_1 = require("./DisplayCategory.js");
const events_1 = require("events"); const events_1 = require("events");
const AlexaStatusMessage_1 = require("./AlexaStatusMessage"); const crypto_1 = require("crypto");
const uuid_1 = require("uuid"); const AlexaStatusMessage_js_1 = require("./AlexaStatusMessage.js");
const AlexaErrorResponse_1 = require("./AlexaErrorResponse"); const AlexaErrorResponse_js_1 = require("./AlexaErrorResponse.js");
class Device extends events_1.EventEmitter { 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") { constructor(mqttClient, rootTopic, name, endpointId, displayCategory, description = "Alexa to Node.js bridge", manufacturerName = "Alex2Node", manufacturer = "Alex2Node", model = "Alex2Node_v1.0.0") {
super(); super();
@ -38,7 +38,7 @@ class Device extends events_1.EventEmitter {
this.displayCategory = Array.isArray(category) ? category : [category]; this.displayCategory = Array.isArray(category) ? category : [category];
} }
getDisplayCategory() { getDisplayCategory() {
return this.displayCategory || [DisplayCategory_1.DisplayCategory.LIGHT]; return this.displayCategory || [DisplayCategory_js_1.DisplayCategory.LIGHT];
} }
setDescription(description) { setDescription(description) {
this.description = description; this.description = description;
@ -47,12 +47,12 @@ class Device extends events_1.EventEmitter {
return this.description; return this.description;
} }
getErrorMessage(correlationToken) { getErrorMessage(correlationToken) {
const msg = new AlexaErrorResponse_1.AlexaErrorResponse(correlationToken, this.rootTopic, this.endpointId, this.mqttClient); const msg = new AlexaErrorResponse_js_1.AlexaErrorResponse(correlationToken, this.rootTopic, this.endpointId, this.mqttClient);
msg.onPublishError = this.onPublishError; msg.onPublishError = this.onPublishError;
return msg; return msg;
} }
getStatusMessage(correlationToken, isResponse = false, isDeferred = false) { getStatusMessage(correlationToken, isResponse = false, isDeferred = false) {
const msg = new AlexaStatusMessage_1.AlexaStatusMessage(correlationToken, this.rootTopic, this.endpointId, this.mqttClient, isResponse, isDeferred); const msg = new AlexaStatusMessage_js_1.AlexaStatusMessage(correlationToken, this.rootTopic, this.endpointId, this.mqttClient, isResponse, isDeferred);
msg.onPublishError = this.onPublishError; msg.onPublishError = this.onPublishError;
return msg; return msg;
} }
@ -62,7 +62,7 @@ class Device extends events_1.EventEmitter {
* event gateway with the user's token. Alexa only accepts it for capabilities registered with proactivelyReported. * event gateway with the user's token. Alexa only accepts it for capabilities registered with proactivelyReported.
*/ */
getChangeReport(cause = "PHYSICAL_INTERACTION") { getChangeReport(cause = "PHYSICAL_INTERACTION") {
const msg = new AlexaStatusMessage_1.AlexaStatusMessage("", this.rootTopic, this.endpointId, this.mqttClient, false, false, cause); const msg = new AlexaStatusMessage_js_1.AlexaStatusMessage("", this.rootTopic, this.endpointId, this.mqttClient, false, false, cause);
msg.onPublishError = this.onPublishError; msg.onPublishError = this.onPublishError;
return msg; return msg;
} }
@ -74,7 +74,7 @@ class Device extends events_1.EventEmitter {
const payload = { const payload = {
context: {}, context: {},
event: { event: {
header: { namespace: "Alexa.SceneController", name: activated ? "ActivationStarted" : "DeactivationStarted", messageId: (0, uuid_1.v4)(), correlationToken, payloadVersion: "3" }, header: { namespace: "Alexa.SceneController", name: activated ? "ActivationStarted" : "DeactivationStarted", messageId: (0, crypto_1.randomUUID)(), correlationToken, payloadVersion: "3" },
endpoint: { endpointId: this.endpointId }, endpoint: { endpointId: this.endpointId },
payload: { cause: { type: cause }, timestamp: new Date().toISOString() }, payload: { cause: { type: cause }, timestamp: new Date().toISOString() },
}, },
@ -116,8 +116,7 @@ class Device extends events_1.EventEmitter {
} }
/** Add a capability. 1.5.1: options.retrievable / proactivelyReported / instance (a change report needs proactivelyReported). */ /** Add a capability. 1.5.1: options.retrievable / proactivelyReported / instance (a change report needs proactivelyReported). */
addCapability(type, options = {}) { addCapability(type, options = {}) {
var _a, _b, _c; const newCapability = new AlexaInterface_js_1.AlexaInterface(type, options.retrievable ?? true, options.proactivelyReported ?? false, options.instance ?? "");
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); this.capabilities.push(newCapability);
// console.log( // console.log(
// `Created new interface with type: ${newCapability.getTypeString()}` // `Created new interface with type: ${newCapability.getTypeString()}`

29
dist/cjs/index.js vendored Normal file
View file

@ -0,0 +1,29 @@
"use strict";
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.AlexaStatusMessage = exports.DisplayCategory = exports.AlexaActions = exports.ActionMapping = exports.AlexaInterfaceType = exports.AlexaInterface = exports.Device = exports.DEFAULT_HOST = exports.Alex2MQTT = void 0;
// Relative specifiers carry ".js": Node's ES module loader resolves no extension, and TypeScript maps it back to the .ts.
var Alex2Node_js_1 = require("./Alex2Node.js");
Object.defineProperty(exports, "Alex2MQTT", { enumerable: true, get: function () { return __importDefault(Alex2Node_js_1).default; } });
Object.defineProperty(exports, "DEFAULT_HOST", { enumerable: true, get: function () { return Alex2Node_js_1.DEFAULT_HOST; } });
var Device_js_1 = require("./Device.js");
Object.defineProperty(exports, "Device", { enumerable: true, get: function () { return __importDefault(Device_js_1).default; } });
var AlexaInterface_js_1 = require("./AlexaInterface.js");
Object.defineProperty(exports, "AlexaInterface", { enumerable: true, get: function () { return AlexaInterface_js_1.AlexaInterface; } });
Object.defineProperty(exports, "AlexaInterfaceType", { enumerable: true, get: function () { return AlexaInterface_js_1.AlexaInterfaceType; } });
var ActionMapping_js_1 = require("./ActionMapping.js");
Object.defineProperty(exports, "ActionMapping", { enumerable: true, get: function () { return ActionMapping_js_1.ActionMapping; } });
Object.defineProperty(exports, "AlexaActions", { enumerable: true, get: function () { return ActionMapping_js_1.AlexaActions; } });
var DisplayCategory_js_1 = require("./DisplayCategory.js");
Object.defineProperty(exports, "DisplayCategory", { enumerable: true, get: function () { return DisplayCategory_js_1.DisplayCategory; } });
var AlexaStatusMessage_js_1 = require("./AlexaStatusMessage.js");
Object.defineProperty(exports, "AlexaStatusMessage", { enumerable: true, get: function () { return AlexaStatusMessage_js_1.AlexaStatusMessage; } });
Object.defineProperty(exports, "PowerController", { enumerable: true, get: function () { return AlexaStatusMessage_js_1.PowerController; } });
Object.defineProperty(exports, "EndpointHealth", { enumerable: true, get: function () { return AlexaStatusMessage_js_1.EndpointHealth; } });
Object.defineProperty(exports, "TemperatureSensorScale", { enumerable: true, get: function () { return AlexaStatusMessage_js_1.TemperatureSensorScale; } });
Object.defineProperty(exports, "ThermostatMode", { enumerable: true, get: function () { return AlexaStatusMessage_js_1.ThermostatMode; } });
var AlexaErrorResponse_js_1 = require("./AlexaErrorResponse.js");
Object.defineProperty(exports, "AlexaErrorType", { enumerable: true, get: function () { return AlexaErrorResponse_js_1.AlexaErrorType; } });
Object.defineProperty(exports, "AlexaErrorResponse", { enumerable: true, get: function () { return AlexaErrorResponse_js_1.AlexaErrorResponse; } });

28
dist/esm/ActionMapping.js vendored Normal file
View file

@ -0,0 +1,28 @@
export var AlexaActions;
(function (AlexaActions) {
AlexaActions["Open"] = "Alexa.Actions.Open";
AlexaActions["Close"] = "Alexa.Actions.Close";
AlexaActions["Raise"] = "Alexa.Actions.Raise";
AlexaActions["Lower"] = "Alexa.Actions.Lower";
AlexaActions["SetEcoOn"] = "Alexa.Actions.SetEcoOn";
AlexaActions["SetEcoOff"] = "Alexa.Actions.SetEcoOff";
})(AlexaActions || (AlexaActions = {}));
export class ActionMapping {
constructor(actions, directiveName, directivePayload) {
this.type = "ActionsToDirective";
this.actions = actions;
this.directive = {
name: directiveName,
};
if (directivePayload) {
this.directive.payload = directivePayload;
}
}
toJSON() {
return {
"@type": this.type,
actions: this.actions,
directive: this.directive,
};
}
}

196
dist/esm/Alex2Node.js vendored Normal file
View file

@ -0,0 +1,196 @@
// mqtt reaches Node as CommonJS: the default import works from both builds, a named one needs Node to detect it.
import mqtt from "mqtt";
import Device from "./Device.js";
import { EventEmitter } from "events";
import { DisplayCategory } from "./DisplayCategory.js";
import { AlexaInterfaceType } from "./AlexaInterface.js";
export const 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 EventEmitter {
constructor(username, password, rootTopic, debugLogging = false, options = {}) {
super(); // Initialize EventEmitter
this.username = username;
this.password = password;
this.rootTopic = rootTopic;
this.debugLogging = debugLogging;
this.client = null;
this.devices = [];
/** 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 || 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() {
if (this.client)
return;
const options = {
username: this.username,
password: this.password,
reconnectPeriod: 1000,
connectTimeout: 10000,
...(this.options.mqtt || {}),
};
this.client = mqtt.connect(this.MqttHost, options);
for (const d of this.devices)
d.setMqttClient(this.client); // after a disconnect(): the devices publish through the new client
this.client.on("connect", () => {
this.connected = true;
this.log("Connected to MQTT broker");
this.client.subscribe(this.rootTopic + "/#", (err) => {
if (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) => {
this.log(`MQTT Message Received`, { topic, payload: message.toString() });
if (topic == `${this.rootTopic}/discover`) {
this.log("Discovery request received, getting device json...");
const deviceArray = this.devices.map((device) => device.getJSON());
this.lastDiscoveryAt = new Date().toISOString();
for (const device of this.devices) // a capability the library has no property list for goes out with supported: []
for (const cap of device.getCapabilities())
if (cap.getProps().length === 0 && cap.getType() !== AlexaInterfaceType.SCENE_CONTROLLER)
this.log(`${device.endpointId}: no property list known for ${cap.getTypeString()}, discovery lists it with no supported properties`);
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 [, endpointId, directiveType] = topic.split("/");
if (directiveType != "alexaDirective") {
return;
}
const device = this.devices.find((d) => d.endpointId === endpointId);
if (!device) {
this.log(`No device found for endpointId: ${endpointId}`);
return;
}
let payload;
try {
payload = JSON.parse(message.toString());
}
catch (e) {
this.log(`Failed to parse payload JSON`, e);
return;
}
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 {
device.emit("Event", payload, payload.header.namespace);
}
}
});
}
/** 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");
}
const existing = this.devices.find((d) => d.endpointId === endpointId);
if (existing) { // 1.5.2: endpointIds are unique per root topic; 1.5.1 added a second device that never got a directive
const warning = `warning: registerDevice("${endpointId}") is already registered as "${existing.name}", returning that device`;
if (this.options.log)
this.options.log(warning);
else
console.warn(`[Alex2Node.ts] ${warning}`); // visible without a log hook too
return existing;
}
this.log(`Creating new device with endpoint: ${endpointId}`);
const normalizedCategory = displayCategory === null
? [DisplayCategory.LIGHT]
: Array.isArray(displayCategory)
? displayCategory
: [displayCategory || DisplayCategory.LIGHT];
const device = new Device(this.client, this.rootTopic, name, endpointId, normalizedCategory);
device.onPublishError = (err) => this.fail(err); // a failed publish is an "error" event (when listened to), never a rejected send()
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;
}
}
export default Alex2MQTT;

125
dist/esm/AlexaErrorResponse.js vendored Normal file
View file

@ -0,0 +1,125 @@
import { randomUUID } from "crypto";
export var AlexaErrorType;
(function (AlexaErrorType) {
AlexaErrorType["ALREADY_IN_OPERATION"] = "ALREADY_IN_OPERATION";
AlexaErrorType["AUTHORIZATION_REQUIRED"] = "AUTHORIZATION_REQUIRED";
AlexaErrorType["BRIDGE_UNREACHABLE"] = "BRIDGE_UNREACHABLE";
AlexaErrorType["BYPASS_NEEDED"] = "BYPASS_NEEDED";
AlexaErrorType["CLOUD_CONTROL_DISABLED"] = "CLOUD_CONTROL_DISABLED";
AlexaErrorType["CHILD_LOCK"] = "CHILD_LOCK";
AlexaErrorType["CONFIGURATION_UPDATE_NOT_ALLOWED"] = "CONFIGURATION_UPDATE_NOT_ALLOWED";
AlexaErrorType["COOK_DURATION_TOO_LONG"] = "COOK_DURATION_TOO_LONG";
AlexaErrorType["COOLING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE"] = "COOLING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE";
AlexaErrorType["COOLING_STAGES_EXCEEDS_LIMIT"] = "COOLING_STAGES_EXCEEDS_LIMIT";
AlexaErrorType["DATA_DELETION_NOT_SUPPORTED"] = "DATA_DELETION_NOT_SUPPORTED";
AlexaErrorType["DATA_RETRIEVAL_NOT_SUPPORTED"] = "DATA_RETRIEVAL_NOT_SUPPORTED";
AlexaErrorType["DEVICE_STUCK"] = "DEVICE_STUCK";
AlexaErrorType["DISABLED_BY_USER"] = "DISABLED_BY_USER";
AlexaErrorType["DO_NOT_DISTURB_MODE"] = "DO_NOT_DISTURB_MODE";
AlexaErrorType["DOOR_CLOSED_TOO_LONG"] = "DOOR_CLOSED_TOO_LONG";
AlexaErrorType["DOOR_OPEN"] = "DOOR_OPEN";
AlexaErrorType["DUAL_SETPOINTS_UNSUPPORTED"] = "DUAL_SETPOINTS_UNSUPPORTED";
AlexaErrorType["ENDPOINT_BUSY"] = "ENDPOINT_BUSY";
AlexaErrorType["ENDPOINT_CONTROL_UNAVAILABLE"] = "ENDPOINT_CONTROL_UNAVAILABLE";
AlexaErrorType["ENDPOINT_LOW_POWER"] = "ENDPOINT_LOW_POWER";
AlexaErrorType["ENDPOINT_UNREACHABLE"] = "ENDPOINT_UNREACHABLE";
AlexaErrorType["EXCEEDED_PIN_ATTEMPTS"] = "EXCEEDED_PIN_ATTEMPTS";
AlexaErrorType["EXPIRED_AUTHORIZATION_CREDENTIAL"] = "EXPIRED_AUTHORIZATION_CREDENTIAL";
AlexaErrorType["FAILED_TO_BOOTSTRAP_COMMISSIONING_PROCESS"] = "FAILED_TO_BOOTSTRAP_COMMISSIONING_PROCESS";
AlexaErrorType["FIRMWARE_OUT_OF_DATE"] = "FIRMWARE_OUT_OF_DATE";
AlexaErrorType["HARDWARE_MALFUNCTION"] = "HARDWARE_MALFUNCTION";
AlexaErrorType["HEATING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE"] = "HEATING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE";
AlexaErrorType["HEATING_STAGES_EXCEEDS_LIMIT"] = "HEATING_STAGES_EXCEEDS_LIMIT";
AlexaErrorType["INSUFFICIENT_PERMISSIONS"] = "INSUFFICIENT_PERMISSIONS";
AlexaErrorType["INSUFFICIENT_RESOURCE"] = "INSUFFICIENT_RESOURCE";
AlexaErrorType["INSUFFICIENT_SPACE"] = "INSUFFICIENT_SPACE";
AlexaErrorType["INTERNAL_ERROR"] = "INTERNAL_ERROR";
AlexaErrorType["INVALID_AUTHORIZATION_CREDENTIAL"] = "INVALID_AUTHORIZATION_CREDENTIAL";
AlexaErrorType["INVALID_AUXILIARY_HEATING_SYSTEM_TYPE"] = "INVALID_AUXILIARY_HEATING_SYSTEM_TYPE";
AlexaErrorType["INVALID_DIRECTIVE"] = "INVALID_DIRECTIVE";
AlexaErrorType["INVALID_SYSTEM_TYPE"] = "INVALID_SYSTEM_TYPE";
AlexaErrorType["INVALID_TARGET_STATE"] = "INVALID_TARGET_STATE";
AlexaErrorType["INVALID_TEMPERATURE_SCALE"] = "INVALID_TEMPERATURE_SCALE";
AlexaErrorType["INVALID_TERMINAL_CONNECTION"] = "INVALID_TERMINAL_CONNECTION";
AlexaErrorType["INVALID_VALUE"] = "INVALID_VALUE";
AlexaErrorType["MAINTENANCE_REQUIRED"] = "MAINTENANCE_REQUIRED";
AlexaErrorType["MAX_COMMISSIONING_LIMIT_REACHED"] = "MAX_COMMISSIONING_LIMIT_REACHED";
AlexaErrorType["MISSING_SETUP_INFORMATION"] = "MISSING_SETUP_INFORMATION";
AlexaErrorType["NO_SUCH_ENDPOINT"] = "NO_SUCH_ENDPOINT";
AlexaErrorType["NOT_CALIBRATED"] = "NOT_CALIBRATED";
AlexaErrorType["NOT_IN_OPERATION"] = "NOT_IN_OPERATION";
AlexaErrorType["NOT_READY"] = "NOT_READY";
AlexaErrorType["NOT_SUPPORTED_IN_CURRENT_MODE"] = "NOT_SUPPORTED_IN_CURRENT_MODE";
AlexaErrorType["NOT_SUPPORTED_WITH_CURRENT_BATTERY_CHARGE_STATE"] = "NOT_SUPPORTED_WITH_CURRENT_BATTERY_CHARGE_STATE";
AlexaErrorType["OBSTACLE_DETECTED"] = "OBSTACLE_DETECTED";
AlexaErrorType["PARTNER_APPLICATION_REDIRECTION"] = "PARTNER_APPLICATION_REDIRECTION";
AlexaErrorType["PIN_SETUP_REQUIRED"] = "PIN_SETUP_REQUIRED";
AlexaErrorType["POWER_LEVEL_NOT_SUPPORTED"] = "POWER_LEVEL_NOT_SUPPORTED";
AlexaErrorType["PREHEAT_REQUIRED"] = "PREHEAT_REQUIRED";
AlexaErrorType["PROBE_REQUIRED"] = "PROBE_REQUIRED";
AlexaErrorType["RATE_LIMIT_EXCEEDED"] = "RATE_LIMIT_EXCEEDED";
AlexaErrorType["REMOTE_START_NOT_SUPPORTED"] = "REMOTE_START_NOT_SUPPORTED";
AlexaErrorType["REMOVE_PROBE"] = "REMOVE_PROBE";
AlexaErrorType["REMOTE_START_DISABLED"] = "REMOTE_START_DISABLED";
AlexaErrorType["REQUESTED_SETPOINTS_TOO_CLOSE"] = "REQUESTED_SETPOINTS_TOO_CLOSE";
AlexaErrorType["SAFETY_BEAM_BREACHED"] = "SAFETY_BEAM_BREACHED";
AlexaErrorType["SUBSCRIPTION_REQUIRED"] = "SUBSCRIPTION_REQUIRED";
AlexaErrorType["TEMPERATURE_VALUE_OUT_OF_RANGE"] = "TEMPERATURE_VALUE_OUT_OF_RANGE";
AlexaErrorType["THERMOSTAT_IS_OFF"] = "THERMOSTAT_IS_OFF";
AlexaErrorType["TOO_MANY_FAILED_ATTEMPTS"] = "TOO_MANY_FAILED_ATTEMPTS";
AlexaErrorType["TRIPLE_SETPOINTS_UNSUPPORTED"] = "TRIPLE_SETPOINTS_UNSUPPORTED";
AlexaErrorType["UNABLE_TO_CHARGE"] = "UNABLE_TO_CHARGE";
AlexaErrorType["UNAUTHORIZED"] = "UNAUTHORIZED";
AlexaErrorType["UNCLEARED_ALARM"] = "UNCLEARED_ALARM";
AlexaErrorType["UNSUPPORTED_THERMOSTAT_MODE"] = "UNSUPPORTED_THERMOSTAT_MODE";
AlexaErrorType["UNCLEARED_TROUBLE"] = "UNCLEARED_TROUBLE";
AlexaErrorType["UNWILLING_TO_SET_SCHEDULE"] = "UNWILLING_TO_SET_SCHEDULE";
AlexaErrorType["UNWILLING_TO_SET_VALUE"] = "UNWILLING_TO_SET_VALUE";
AlexaErrorType["VALUE_OUT_OF_RANGE"] = "VALUE_OUT_OF_RANGE";
})(AlexaErrorType || (AlexaErrorType = {}));
export class AlexaErrorResponse {
constructor(correlationToken, rootTopic, endpointId, mqttClient) {
this.rootTopic = rootTopic;
this.endpointId = endpointId;
this.mqttClient = mqttClient;
this.event = {
header: {
namespace: "Alexa",
name: "ErrorResponse",
messageId: randomUUID(),
correlationToken,
payloadVersion: "3",
},
endpoint: {
endpointId,
},
payload: {},
};
}
setErrorMessage(type, message, otherParams = {}) {
this.event.payload = {
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);
return new Promise((resolve) => {
this.mqttClient.publish(topic, payloadStr, (err) => {
if (!err)
return resolve(topic);
if (this.onPublishError)
this.onPublishError(err); // -> the bridge's "error" event (when somebody listens)
resolve(""); // 1.5.1 rejected here, and an un-caught send() then killed the host on any broker hiccup
});
});
}
}

321
dist/esm/AlexaInterface.js vendored Normal file
View file

@ -0,0 +1,321 @@
export var AlexaInterfaceType;
(function (AlexaInterfaceType) {
AlexaInterfaceType["APPLICATION_STATE_REPORTER"] = "Alexa.ApplicationStateReporter";
AlexaInterfaceType["AUDIO_PLAY_QUEUE"] = "Alexa.Audio.PlayQueue";
AlexaInterfaceType["AUTHORIZATION_CONTROLLER"] = "Alexa.AuthorizationController";
AlexaInterfaceType["AUTOMATION_MANAGEMENT"] = "Alexa.AutomationManagement";
AlexaInterfaceType["AUTOMOTIVE_VEHICLE_DATA"] = "Alexa.Automotive.VehicleData";
AlexaInterfaceType["BRIGHTNESS_CONTROLLER"] = "Alexa.BrightnessController";
AlexaInterfaceType["CAMERA_LIVE_VIEW_CONTROLLER"] = "Alexa.Camera.LiveViewController";
AlexaInterfaceType["CAMERA_STREAM_CONTROLLER"] = "Alexa.CameraStreamController";
AlexaInterfaceType["CHANNEL_CONTROLLER"] = "Alexa.ChannelController";
AlexaInterfaceType["COLOR_CONTROLLER"] = "Alexa.ColorController";
AlexaInterfaceType["COLOR_TEMPERATURE_CONTROLLER"] = "Alexa.ColorTemperatureController";
AlexaInterfaceType["COMMISSIONABLE"] = "Alexa.Commissionable";
AlexaInterfaceType["CONSENT_MANAGEMENT_CONSENT_REQUIRED_REPORTER"] = "Alexa.ConsentManagement.ConsentRequiredReporter";
AlexaInterfaceType["CONTACT_SENSOR"] = "Alexa.ContactSensor";
AlexaInterfaceType["COOKING"] = "Alexa.Cooking";
AlexaInterfaceType["COOKING_FOOD_TEMPERATURE_CONTROLLER"] = "Alexa.Cooking.FoodTemperatureController";
AlexaInterfaceType["COOKING_FOOD_TEMPERATURE_SENSOR"] = "Alexa.Cooking.FoodTemperatureSensor";
AlexaInterfaceType["COOKING_PRESET_CONTROLLER"] = "Alexa.Cooking.PresetController";
AlexaInterfaceType["COOKING_TEMPERATURE_CONTROLLER"] = "Alexa.Cooking.TemperatureController";
AlexaInterfaceType["COOKING_TEMPERATURE_SENSOR"] = "Alexa.Cooking.TemperatureSensor";
AlexaInterfaceType["COOKING_TIME_CONTROLLER"] = "Alexa.Cooking.TimeController";
AlexaInterfaceType["DATA_CONTROLLER"] = "Alexa.DataController";
AlexaInterfaceType["DEVICE_USAGE_ESTIMATION"] = "Alexa.DeviceUsage.Estimation";
AlexaInterfaceType["DEVICE_USAGE_METER"] = "Alexa.DeviceUsage.Meter";
AlexaInterfaceType["DOORBELL_EVENT_SOURCE"] = "Alexa.DoorbellEventSource";
AlexaInterfaceType["ENDPOINT_HEALTH"] = "Alexa.EndpointHealth";
AlexaInterfaceType["EQUALIZER_CONTROLLER"] = "Alexa.EqualizerController";
AlexaInterfaceType["INPUT_CONTROLLER"] = "Alexa.InputController";
AlexaInterfaceType["INVENTORY_LEVEL_SENSOR"] = "Alexa.InventoryLevelSensor";
AlexaInterfaceType["INVENTORY_LEVEL_USAGE_SENSOR"] = "Alexa.InventoryLevelUsageSensor";
AlexaInterfaceType["INVENTORY_USAGE_SENSOR"] = "Alexa.InventoryUsageSensor";
AlexaInterfaceType["KEYPAD_CONTROLLER"] = "Alexa.KeypadController";
AlexaInterfaceType["LAUNCHER"] = "Alexa.Launcher";
AlexaInterfaceType["LOCK_CONTROLLER"] = "Alexa.LockController";
AlexaInterfaceType["MEDIA_PLAYBACK"] = "Alexa.Media.Playback";
AlexaInterfaceType["MEDIA_PLAY_QUEUE"] = "Alexa.Media.PlayQueue";
AlexaInterfaceType["MEDIA_SEARCH"] = "Alexa.Media.Search";
AlexaInterfaceType["MODE_CONTROLLER"] = "Alexa.ModeController";
AlexaInterfaceType["MOTION_SENSOR"] = "Alexa.MotionSensor";
AlexaInterfaceType["PERCENTAGE_CONTROLLER"] = "Alexa.PercentageController";
AlexaInterfaceType["PLAYBACK_CONTROLLER"] = "Alexa.PlaybackController";
AlexaInterfaceType["PLAYBACK_STATE_REPORTER"] = "Alexa.PlaybackStateReporter";
AlexaInterfaceType["POWER_CONTROLLER"] = "Alexa.PowerController";
AlexaInterfaceType["POWER_LEVEL_CONTROLLER"] = "Alexa.PowerLevelController";
AlexaInterfaceType["PROACTIVE_NOTIFICATION_SOURCE"] = "Alexa.ProactiveNotificationSource";
AlexaInterfaceType["RANGE_CONTROLLER"] = "Alexa.RangeController";
AlexaInterfaceType["RECORD_CONTROLLER"] = "Alexa.RecordController";
AlexaInterfaceType["REMOTE_VIDEO_PLAYER"] = "Alexa.RemoteVideoPlayer";
AlexaInterfaceType["RTC_SESSION_CONTROLLER"] = "Alexa.RTCSessionController";
AlexaInterfaceType["SCENE_CONTROLLER"] = "Alexa.SceneController";
AlexaInterfaceType["SECURITY_PANEL_CONTROLLER"] = "Alexa.SecurityPanelController";
AlexaInterfaceType["SECURITY_PANEL_CONTROLLER_ALERT"] = "Alexa.SecurityPanelController.Alert";
AlexaInterfaceType["SEEK_CONTROLLER"] = "Alexa.SeekController";
AlexaInterfaceType["SIMPLE_EVENT_SOURCE"] = "Alexa.SimpleEventSource";
AlexaInterfaceType["SMART_VISION_OBJECT_DETECTION_SENSOR"] = "Alexa.SmartVision.ObjectDetectionSensor";
AlexaInterfaceType["SMART_VISION_SNAPSHOT_PROVIDER"] = "Alexa.SmartVision.SnapshotProvider";
AlexaInterfaceType["SPEAKER"] = "Alexa.Speaker";
AlexaInterfaceType["STEP_SPEAKER"] = "Alexa.StepSpeaker";
AlexaInterfaceType["TEMPERATURE_SENSOR"] = "Alexa.TemperatureSensor";
AlexaInterfaceType["THERMOSTAT_CONTROLLER"] = "Alexa.ThermostatController";
AlexaInterfaceType["THERMOSTAT_CONTROLLER_CONFIGURATION"] = "Alexa.ThermostatController.Configuration";
AlexaInterfaceType["THERMOSTAT_CONTROLLER_HVAC_COMPONENTS"] = "Alexa.ThermostatController.HVAC.Components";
AlexaInterfaceType["THERMOSTAT_CONTROLLER_SCHEDULE"] = "Alexa.ThermostatController.Schedule";
AlexaInterfaceType["TIME_HOLD_CONTROLLER"] = "Alexa.TimeHoldController";
AlexaInterfaceType["TOGGLE_CONTROLLER"] = "Alexa.ToggleController";
AlexaInterfaceType["UI_CONTROLLER"] = "Alexa.UIController";
AlexaInterfaceType["USER_PREFERENCE"] = "Alexa.UserPreference";
AlexaInterfaceType["VIDEO_RECORDER"] = "Alexa.VideoRecorder";
AlexaInterfaceType["WAKE_ON_LAN_CONTROLLER"] = "Alexa.WakeOnLANController";
AlexaInterfaceType["UNKNOWN"] = "UNKNOWN";
})(AlexaInterfaceType || (AlexaInterfaceType = {}));
export class AlexaInterface {
constructor(type, retrievable = true, proactivelyReported = false, instance = "") {
this.type = type;
this.retrievable = retrievable;
this.proactivelyReported = proactivelyReported;
this.instance = instance;
this.friendlyNames = [];
this.actionMappings = [];
this.supportedModes = [];
}
addActionMapping(mapping) {
this.actionMappings.push(mapping);
}
addFriendlyName(name, locale) {
this.friendlyNames.push({ text: name, locale });
}
/** The modes of a ModeController: { value, modeResources } objects as Alexa wants them (plain strings pass through as given). */
addSupportedModes(modes) {
this.supportedModes = modes;
}
setInstance(name) {
this.instance = name;
}
getType() {
return this.type;
}
getTypeString() {
return this.type;
}
getVersion() {
switch (this.type) {
case AlexaInterfaceType.APPLICATION_STATE_REPORTER:
case AlexaInterfaceType.AUDIO_PLAY_QUEUE:
case AlexaInterfaceType.AUTHORIZATION_CONTROLLER:
case AlexaInterfaceType.AUTOMATION_MANAGEMENT:
case AlexaInterfaceType.AUTOMOTIVE_VEHICLE_DATA:
case AlexaInterfaceType.COMMISSIONABLE:
case AlexaInterfaceType.CONSENT_MANAGEMENT_CONSENT_REQUIRED_REPORTER:
case AlexaInterfaceType.COOKING:
case AlexaInterfaceType.COOKING_FOOD_TEMPERATURE_CONTROLLER:
case AlexaInterfaceType.COOKING_FOOD_TEMPERATURE_SENSOR:
case AlexaInterfaceType.COOKING_PRESET_CONTROLLER:
case AlexaInterfaceType.COOKING_TEMPERATURE_CONTROLLER:
case AlexaInterfaceType.COOKING_TEMPERATURE_SENSOR:
case AlexaInterfaceType.COOKING_TIME_CONTROLLER:
case AlexaInterfaceType.DATA_CONTROLLER:
case AlexaInterfaceType.DEVICE_USAGE_ESTIMATION:
case AlexaInterfaceType.DEVICE_USAGE_METER:
case AlexaInterfaceType.EQUALIZER_CONTROLLER:
case AlexaInterfaceType.INVENTORY_LEVEL_SENSOR:
case AlexaInterfaceType.INVENTORY_LEVEL_USAGE_SENSOR:
case AlexaInterfaceType.INVENTORY_USAGE_SENSOR:
case AlexaInterfaceType.KEYPAD_CONTROLLER:
case AlexaInterfaceType.MEDIA_PLAYBACK:
case AlexaInterfaceType.MEDIA_PLAY_QUEUE:
case AlexaInterfaceType.MEDIA_SEARCH:
case AlexaInterfaceType.PLAYBACK_STATE_REPORTER:
case AlexaInterfaceType.PROACTIVE_NOTIFICATION_SOURCE:
case AlexaInterfaceType.REMOTE_VIDEO_PLAYER:
case AlexaInterfaceType.RTC_SESSION_CONTROLLER:
case AlexaInterfaceType.SECURITY_PANEL_CONTROLLER:
case AlexaInterfaceType.SECURITY_PANEL_CONTROLLER_ALERT:
case AlexaInterfaceType.SIMPLE_EVENT_SOURCE:
case AlexaInterfaceType.SMART_VISION_OBJECT_DETECTION_SENSOR:
case AlexaInterfaceType.SMART_VISION_SNAPSHOT_PROVIDER:
case AlexaInterfaceType.SPEAKER:
case AlexaInterfaceType.STEP_SPEAKER:
case AlexaInterfaceType.THERMOSTAT_CONTROLLER_CONFIGURATION:
case AlexaInterfaceType.THERMOSTAT_CONTROLLER_HVAC_COMPONENTS:
case AlexaInterfaceType.THERMOSTAT_CONTROLLER_SCHEDULE:
case AlexaInterfaceType.UI_CONTROLLER:
case AlexaInterfaceType.USER_PREFERENCE:
case AlexaInterfaceType.VIDEO_RECORDER:
case AlexaInterfaceType.WAKE_ON_LAN_CONTROLLER:
return "1";
case AlexaInterfaceType.BRIGHTNESS_CONTROLLER:
case AlexaInterfaceType.CAMERA_STREAM_CONTROLLER:
case AlexaInterfaceType.CHANNEL_CONTROLLER:
case AlexaInterfaceType.COLOR_CONTROLLER:
case AlexaInterfaceType.COLOR_TEMPERATURE_CONTROLLER:
case AlexaInterfaceType.CONTACT_SENSOR:
case AlexaInterfaceType.DOORBELL_EVENT_SOURCE:
case AlexaInterfaceType.INPUT_CONTROLLER:
case AlexaInterfaceType.LOCK_CONTROLLER:
case AlexaInterfaceType.MODE_CONTROLLER:
case AlexaInterfaceType.MOTION_SENSOR:
case AlexaInterfaceType.PERCENTAGE_CONTROLLER:
case AlexaInterfaceType.PLAYBACK_CONTROLLER:
case AlexaInterfaceType.POWER_CONTROLLER:
case AlexaInterfaceType.POWER_LEVEL_CONTROLLER:
case AlexaInterfaceType.RANGE_CONTROLLER:
case AlexaInterfaceType.RECORD_CONTROLLER:
case AlexaInterfaceType.SCENE_CONTROLLER:
case AlexaInterfaceType.SEEK_CONTROLLER:
case AlexaInterfaceType.TEMPERATURE_SENSOR:
case AlexaInterfaceType.TOGGLE_CONTROLLER:
return "3";
case AlexaInterfaceType.LAUNCHER:
return "1.1";
case AlexaInterfaceType.THERMOSTAT_CONTROLLER:
return "3.2";
case AlexaInterfaceType.ENDPOINT_HEALTH:
return "3.3";
default:
return "UNKNOWN";
}
}
getProps() {
switch (this.type) {
case AlexaInterfaceType.AUTOMATION_MANAGEMENT:
return ["automationStatuses"];
case AlexaInterfaceType.BRIGHTNESS_CONTROLLER:
return ["brightness"];
case AlexaInterfaceType.CHANNEL_CONTROLLER:
return ["channel"];
case AlexaInterfaceType.COLOR_CONTROLLER:
return ["color"];
case AlexaInterfaceType.COLOR_TEMPERATURE_CONTROLLER:
return ["colorTemperatureInKelvin"];
case AlexaInterfaceType.CONTACT_SENSOR:
return ["detectionState"];
case AlexaInterfaceType.POWER_CONTROLLER:
return ["powerState"];
case AlexaInterfaceType.ENDPOINT_HEALTH:
return ["connectivity"];
case AlexaInterfaceType.TOGGLE_CONTROLLER:
return ["toggleState"];
case AlexaInterfaceType.MODE_CONTROLLER:
return ["mode"];
case AlexaInterfaceType.TEMPERATURE_SENSOR:
return ["temperature"];
case AlexaInterfaceType.THERMOSTAT_CONTROLLER:
return [
"targetSetpoint",
"lowerSetpoint",
"upperSetpoint",
"thermostatMode",
];
case AlexaInterfaceType.APPLICATION_STATE_REPORTER:
case AlexaInterfaceType.AUDIO_PLAY_QUEUE:
case AlexaInterfaceType.AUTHORIZATION_CONTROLLER:
case AlexaInterfaceType.AUTOMOTIVE_VEHICLE_DATA:
case AlexaInterfaceType.CAMERA_LIVE_VIEW_CONTROLLER:
case AlexaInterfaceType.CAMERA_STREAM_CONTROLLER:
case AlexaInterfaceType.COMMISSIONABLE:
case AlexaInterfaceType.CONSENT_MANAGEMENT_CONSENT_REQUIRED_REPORTER:
case AlexaInterfaceType.COOKING:
case AlexaInterfaceType.DATA_CONTROLLER:
case AlexaInterfaceType.DEVICE_USAGE_ESTIMATION:
case AlexaInterfaceType.DEVICE_USAGE_METER:
case AlexaInterfaceType.DOORBELL_EVENT_SOURCE:
case AlexaInterfaceType.EQUALIZER_CONTROLLER:
case AlexaInterfaceType.INPUT_CONTROLLER:
case AlexaInterfaceType.INVENTORY_LEVEL_SENSOR:
case AlexaInterfaceType.INVENTORY_LEVEL_USAGE_SENSOR:
case AlexaInterfaceType.INVENTORY_USAGE_SENSOR:
case AlexaInterfaceType.KEYPAD_CONTROLLER:
case AlexaInterfaceType.LAUNCHER:
case AlexaInterfaceType.LOCK_CONTROLLER:
case AlexaInterfaceType.MEDIA_PLAYBACK:
case AlexaInterfaceType.MEDIA_SEARCH:
case AlexaInterfaceType.MOTION_SENSOR:
case AlexaInterfaceType.PERCENTAGE_CONTROLLER:
case AlexaInterfaceType.PLAYBACK_CONTROLLER:
case AlexaInterfaceType.PLAYBACK_STATE_REPORTER:
case AlexaInterfaceType.PROACTIVE_NOTIFICATION_SOURCE:
case AlexaInterfaceType.RANGE_CONTROLLER:
case AlexaInterfaceType.RECORD_CONTROLLER:
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:
case AlexaInterfaceType.SMART_VISION_OBJECT_DETECTION_SENSOR:
case AlexaInterfaceType.SMART_VISION_SNAPSHOT_PROVIDER:
case AlexaInterfaceType.SPEAKER:
case AlexaInterfaceType.STEP_SPEAKER:
case AlexaInterfaceType.THERMOSTAT_CONTROLLER_CONFIGURATION:
case AlexaInterfaceType.THERMOSTAT_CONTROLLER_HVAC_COMPONENTS:
case AlexaInterfaceType.THERMOSTAT_CONTROLLER_SCHEDULE:
case AlexaInterfaceType.TIME_HOLD_CONTROLLER:
case AlexaInterfaceType.UI_CONTROLLER:
case AlexaInterfaceType.USER_PREFERENCE:
case AlexaInterfaceType.VIDEO_RECORDER:
case AlexaInterfaceType.WAKE_ON_LAN_CONTROLLER:
// no property list known for these: discovery lists the capability with an empty "supported" (the bridge notes
// it through its log hook; 1.5.1 wrote "UNSUPORTED INTERFACE TYPE" to stderr on every discovery)
return [];
default:
return [];
}
}
getJSON() {
const doc = {
interface: this.getTypeString(),
version: this.getVersion(),
type: "AlexaInterface",
properties: {
retrievable: this.retrievable,
proactivelyReported: this.proactivelyReported,
supported: this.getProps().map(name => ({ name })),
},
};
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
};
}
else if (this.supportedModes.length > 0) {
doc["configuration"] = {
ordered: true,
supportedModes: this.supportedModes,
};
}
if (this.instance) {
doc.instance = this.instance;
}
if (this.friendlyNames.length > 0) {
const capabilityResources = doc["capabilityResources"] || {};
const friendlyNamesArray = capabilityResources["friendlyNames"] || [];
this.friendlyNames.forEach((fn) => {
const friendlyNameObj = { "@type": "text" };
friendlyNameObj["value"] = {
text: fn.text,
locale: fn.locale,
};
friendlyNamesArray.push(friendlyNameObj);
});
capabilityResources.friendlyNames = friendlyNamesArray;
doc["capabilityResources"] = capabilityResources;
}
if (this.actionMappings.length > 0) {
doc.semantics = {
actionMappings: this.actionMappings.map((am) => am.toJSON()),
};
}
return doc;
}
}

177
dist/esm/AlexaStatusMessage.js vendored Normal file
View file

@ -0,0 +1,177 @@
import { randomUUID } from "crypto";
import { AlexaInterfaceType } from "./AlexaInterface.js";
export var EndpointHealth;
(function (EndpointHealth) {
EndpointHealth["OK"] = "OK";
EndpointHealth["UNREACHABLE"] = "UNREACHABLE";
})(EndpointHealth || (EndpointHealth = {}));
export var ThermostatMode;
(function (ThermostatMode) {
ThermostatMode["OFF"] = "OFF";
ThermostatMode["HEAT"] = "HEAT";
ThermostatMode["COOL"] = "COOL";
ThermostatMode["AUTO"] = "AUTO";
ThermostatMode["ECO"] = "ECO";
ThermostatMode["CUSTOM"] = "CUSTOM";
})(ThermostatMode || (ThermostatMode = {}));
export var PowerController;
(function (PowerController) {
PowerController["ON"] = "ON";
PowerController["OFF"] = "OFF";
})(PowerController || (PowerController = {}));
export var TemperatureSensorScale;
(function (TemperatureSensorScale) {
TemperatureSensorScale["CELSIUS"] = "CELSIUS";
TemperatureSensorScale["FAHRENHEIT"] = "FAHRENHEIT";
})(TemperatureSensorScale || (TemperatureSensorScale = {}));
export class AlexaStatusMessage {
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: changeCause
? "ChangeReport"
: isDeferred
? "DeferredResponse"
: isResponse
? "Response"
: "StateReport",
payloadVersion: "3",
messageId: randomUUID(),
correlationToken,
},
endpoint: {
endpointId,
},
payload: {},
};
}
getTimestamp() {
return new Date().toISOString();
}
addProperty(namespace, name, value, uncertaintyInMilliseconds = 0, instance) {
const prop = {
namespace,
name,
value,
timeOfSample: this.getTimestamp(),
uncertaintyInMilliseconds,
};
if (instance)
prop.instance = instance;
(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: { ...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(AlexaInterfaceType.MODE_CONTROLLER, "mode", value, uncertaintyInMs, instance);
}
addThermostatModeProp(mode, uncertaintyInMs = 0) {
return this.addProperty(AlexaInterfaceType.THERMOSTAT_CONTROLLER, "thermostatMode", mode, uncertaintyInMs);
}
addEstimatedDeferralTime(seconds) {
if (this.isDeferred) {
this.event.payload = {
estimatedDeferralInSeconds: seconds,
};
}
else {
console.warn("[AlexaStatusMessage.ts] Attempted to add estimated deferral time, but message is not marked as DeferredResponse.");
}
return this;
}
addThermostatControllerProp(name, scale, value, uncertaintyInMs = 0) {
const tempValue = {
value: scale === TemperatureSensorScale.FAHRENHEIT
? (value - 32) * (5 / 9)
: value,
scale: "CELSIUS",
};
return this.addProperty(AlexaInterfaceType.THERMOSTAT_CONTROLLER, name, tempValue, uncertaintyInMs);
}
/** Alexa.EndpointHealth connectivity: EndpointHealth.OK / UNREACHABLE (the plain strings "OK" / "UNREACHABLE" are accepted too). */
addHealthProp(health, uncertaintyInMs = 0) {
return this.addProperty(AlexaInterfaceType.ENDPOINT_HEALTH, "connectivity", { value: health }, uncertaintyInMs);
}
addPowerControllerProp(power, uncertaintyInMs = 0) {
return this.addProperty(AlexaInterfaceType.POWER_CONTROLLER, "powerState", power, uncertaintyInMs);
}
addTemperatureSensorProp(scale, value, uncertaintyInMs = 0) {
const tempValue = {
value: scale === TemperatureSensorScale.FAHRENHEIT
? (value - 32) * (5 / 9)
: value,
scale: "CELSIUS",
};
return this.addProperty(AlexaInterfaceType.TEMPERATURE_SENSOR, "temperature", tempValue, uncertaintyInMs);
}
addBrightnessControllerProp(brightness, uncertaintyInMs = 0) {
return this.addProperty(AlexaInterfaceType.BRIGHTNESS_CONTROLLER, "brightness", brightness, uncertaintyInMs);
}
addColorTemperatureControllerProp(colorTemp, uncertaintyInMs = 0) {
return this.addProperty(AlexaInterfaceType.COLOR_TEMPERATURE_CONTROLLER, "colorTemperatureInKelvin", colorTemp, uncertaintyInMs);
}
addToggleControllerProp(state, instance, uncertaintyInMs = 0) {
return this.addProperty(AlexaInterfaceType.TOGGLE_CONTROLLER, "toggleState", state, uncertaintyInMs, instance);
}
addContextProp(prop) {
this.context.properties.push(prop);
return this;
}
/**
* 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, or "" when the publish failed - the error then goes to the bridge's "error" event (when listened to).
* Never rejects (1.5.1 did, so an un-caught send() could kill the host).
*/
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) => {
this.mqttClient.publish(topic, payloadStr, (err) => {
if (!err)
return resolve(topic);
if (this.onPublishError)
this.onPublishError(err); // -> the bridge's "error" event (when somebody listens)
resolve(""); // 1.5.1 rejected here, and an un-caught send() then killed the host on any broker hiccup
});
});
}
}

143
dist/esm/Device.js vendored Normal file
View file

@ -0,0 +1,143 @@
import { AlexaInterface } from "./AlexaInterface.js";
import { DisplayCategory } from "./DisplayCategory.js";
import { EventEmitter } from "events";
import { randomUUID } from "crypto";
import { AlexaStatusMessage } from "./AlexaStatusMessage.js";
import { AlexaErrorResponse } from "./AlexaErrorResponse.js";
class Device extends EventEmitter {
constructor(mqttClient, rootTopic, name, endpointId, displayCategory, description = "Alexa to Node.js bridge", manufacturerName = "Alex2Node", manufacturer = "Alex2Node", model = "Alex2Node_v1.0.0") {
super();
this.mqttClient = mqttClient;
this.rootTopic = rootTopic;
this.name = name;
this.endpointId = endpointId;
this.displayCategory = displayCategory;
this.description = description;
this.manufacturerName = manufacturerName;
this.manufacturer = manufacturer;
this.model = model;
this.softwareVersion = "1.0.0";
this.capabilities = [];
}
/** Internal (1.5.2): Alex2MQTT.connect() re-binds every registered device to its new broker client after a disconnect(). */
setMqttClient(client) {
this.mqttClient = client;
}
getName() {
return this.name;
}
setName(name) {
this.name = name;
}
getEndpointId() {
return this.endpointId;
}
setDisplayCategory(category) {
this.displayCategory = Array.isArray(category) ? category : [category];
}
getDisplayCategory() {
return this.displayCategory || [DisplayCategory.LIGHT];
}
setDescription(description) {
this.description = description;
}
getDescription() {
return this.description;
}
getErrorMessage(correlationToken) {
const msg = new AlexaErrorResponse(correlationToken, this.rootTopic, this.endpointId, this.mqttClient);
msg.onPublishError = this.onPublishError;
return msg;
}
getStatusMessage(correlationToken, isResponse = false, isDeferred = false) {
const msg = new AlexaStatusMessage(correlationToken, this.rootTopic, this.endpointId, this.mqttClient, isResponse, isDeferred);
msg.onPublishError = this.onPublishError;
return msg;
}
/**
* 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") {
const msg = new AlexaStatusMessage("", this.rootTopic, this.endpointId, this.mqttClient, false, false, cause);
msg.onPublishError = this.onPublishError;
return msg;
}
/**
* Alexa.SceneController: answer an Activate / Deactivate directive with ActivationStarted / DeactivationStarted
* (1.5.1). Resolves with the topic published to, or "" when the publish failed (never rejects, 1.5.2).
*/
sendSceneResponse(correlationToken, activated, cause = "VOICE_INTERACTION", sendAsync = false) {
const payload = {
context: {},
event: {
header: { namespace: "Alexa.SceneController", name: activated ? "ActivationStarted" : "DeactivationStarted", messageId: randomUUID(), 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) => {
this.mqttClient.publish(topic, JSON.stringify(payload), (err) => {
if (!err)
return resolve(topic);
if (this.onPublishError)
this.onPublishError(err); // -> the bridge's "error" event (when somebody listens)
resolve(""); // 1.5.1 rejected here, and an un-caught send() then killed the host on any broker hiccup
});
});
}
getCapabilities() {
return this.capabilities.slice();
}
setManufacturerName(name) {
this.manufacturerName = name;
}
getManufacturerName() {
return this.manufacturerName;
}
setManufacturer(manufacturer) {
this.manufacturer = manufacturer;
}
getManufacturer() {
return this.manufacturer;
}
setModel(model) {
this.model = model;
}
getModel() {
return this.model;
}
getSoftwareVersion() {
return this.softwareVersion;
}
/** Add a capability. 1.5.1: options.retrievable / proactivelyReported / instance (a change report needs proactivelyReported). */
addCapability(type, options = {}) {
const newCapability = new AlexaInterface(type, options.retrievable ?? true, options.proactivelyReported ?? false, options.instance ?? "");
this.capabilities.push(newCapability);
// console.log(
// `Created new interface with type: ${newCapability.getTypeString()}`
// );
return newCapability;
}
getJSON() {
return {
endpointId: this.endpointId,
friendlyName: this.name,
description: this.description,
manufacturerName: this.manufacturerName,
displayCategories: this.getDisplayCategory(),
additionalAttributes: {
manufacturer: this.manufacturer,
model: this.model,
serialNumber: "Alex2Node",
firmwareVersion: "1.0.0",
softwareVersion: this.softwareVersion,
customIdentifier: "Alex2Node",
},
capabilities: this.capabilities.map((cap) => cap.getJSON()),
};
}
}
export default Device;

59
dist/esm/DisplayCategory.js vendored Normal file
View file

@ -0,0 +1,59 @@
export var DisplayCategory;
(function (DisplayCategory) {
DisplayCategory["ACTIVITY_TRIGGER"] = "ACTIVITY_TRIGGER";
DisplayCategory["AIR_CONDITIONER"] = "AIR_CONDITIONER";
DisplayCategory["AIR_FRESHENER"] = "AIR_FRESHENER";
DisplayCategory["AIR_PURIFIER"] = "AIR_PURIFIER";
DisplayCategory["AIR_QUALITY_MONITOR"] = "AIR_QUALITY_MONITOR";
DisplayCategory["ALEXA_VOICE_ENABLED"] = "ALEXA_VOICE_ENABLED";
DisplayCategory["AUTO_ACCESSORY"] = "AUTO_ACCESSORY";
DisplayCategory["BLUETOOTH_SPEAKER"] = "BLUETOOTH_SPEAKER";
DisplayCategory["CAMERA"] = "CAMERA";
DisplayCategory["CHRISTMAS_TREE"] = "CHRISTMAS_TREE";
DisplayCategory["COFFEE_MAKER"] = "COFFEE_MAKER";
DisplayCategory["COMPUTER"] = "COMPUTER";
DisplayCategory["CONTACT_SENSOR"] = "CONTACT_SENSOR";
DisplayCategory["DISHWASHER"] = "DISHWASHER";
DisplayCategory["DOOR"] = "DOOR";
DisplayCategory["DOORBELL"] = "DOORBELL";
DisplayCategory["DRYER"] = "DRYER";
DisplayCategory["EXTERIOR_BLIND"] = "EXTERIOR_BLIND";
DisplayCategory["FAN"] = "FAN";
DisplayCategory["GAME_CONSOLE"] = "GAME_CONSOLE";
DisplayCategory["GARAGE_DOOR"] = "GARAGE_DOOR";
DisplayCategory["HEADPHONES"] = "HEADPHONES";
DisplayCategory["HUB"] = "HUB";
DisplayCategory["INTERIOR_BLIND"] = "INTERIOR_BLIND";
DisplayCategory["LAPTOP"] = "LAPTOP";
DisplayCategory["LIGHT"] = "LIGHT";
DisplayCategory["MICROWAVE"] = "MICROWAVE";
DisplayCategory["MOBILE_PHONE"] = "MOBILE_PHONE";
DisplayCategory["MOTION_SENSOR"] = "MOTION_SENSOR";
DisplayCategory["MUSIC_SYSTEM"] = "MUSIC_SYSTEM";
DisplayCategory["NETWORK_HARDWARE"] = "NETWORK_HARDWARE";
DisplayCategory["OTHER"] = "OTHER";
DisplayCategory["OVEN"] = "OVEN";
DisplayCategory["PHONE"] = "PHONE";
DisplayCategory["PRINTER"] = "PRINTER";
DisplayCategory["REMOTE"] = "REMOTE";
DisplayCategory["ROUTER"] = "ROUTER";
DisplayCategory["SCENE_TRIGGER"] = "SCENE_TRIGGER";
DisplayCategory["SCREEN"] = "SCREEN";
DisplayCategory["SECURITY_PANEL"] = "SECURITY_PANEL";
DisplayCategory["SECURITY_SYSTEM"] = "SECURITY_SYSTEM";
DisplayCategory["SLOW_COOKER"] = "SLOW_COOKER";
DisplayCategory["SMARTLOCK"] = "SMARTLOCK";
DisplayCategory["SMARTPLUG"] = "SMARTPLUG";
DisplayCategory["SPEAKER"] = "SPEAKER";
DisplayCategory["STREAMING_DEVICE"] = "STREAMING_DEVICE";
DisplayCategory["SWITCH"] = "SWITCH";
DisplayCategory["TABLET"] = "TABLET";
DisplayCategory["TEMPERATURE_SENSOR"] = "TEMPERATURE_SENSOR";
DisplayCategory["THERMOSTAT"] = "THERMOSTAT";
DisplayCategory["TV"] = "TV";
DisplayCategory["VACUUM_CLEANER"] = "VACUUM_CLEANER";
DisplayCategory["VEHICLE"] = "VEHICLE";
DisplayCategory["WASHER"] = "WASHER";
DisplayCategory["WATER_HEATER"] = "WATER_HEATER";
DisplayCategory["WEARABLE"] = "WEARABLE";
})(DisplayCategory || (DisplayCategory = {}));

8
dist/esm/index.js vendored Normal file
View file

@ -0,0 +1,8 @@
// Relative specifiers carry ".js": Node's ES module loader resolves no extension, and TypeScript maps it back to the .ts.
export { default as Alex2MQTT, DEFAULT_HOST } from "./Alex2Node.js";
export { default as Device } from "./Device.js";
export { AlexaInterface, AlexaInterfaceType } from "./AlexaInterface.js";
export { ActionMapping, AlexaActions } from "./ActionMapping.js";
export { DisplayCategory } from "./DisplayCategory.js";
export { AlexaStatusMessage, PowerController, EndpointHealth, TemperatureSensorScale, ThermostatMode } from "./AlexaStatusMessage.js";
export { AlexaErrorType, AlexaErrorResponse } from "./AlexaErrorResponse.js";

3
dist/esm/package.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"type": "module"
}

10
dist/index.d.ts vendored
View file

@ -1,10 +0,0 @@
export { default as Alex2MQTT, DEFAULT_HOST } from "./Alex2Node";
export type { Alex2MQTTOptions } from "./Alex2Node";
export { default as Device } from "./Device";
export { AlexaInterface, AlexaInterfaceType } from "./AlexaInterface";
export type { SupportedMode } from "./AlexaInterface";
export { ActionMapping, AlexaActions } from "./ActionMapping";
export { DisplayCategory } from "./DisplayCategory";
export { AlexaStatusMessage, PowerController, EndpointHealth, TemperatureSensorScale, ThermostatMode } from "./AlexaStatusMessage";
export type { ChangeCause } from "./AlexaStatusMessage";
export { AlexaErrorType, AlexaErrorResponse } from "./AlexaErrorResponse";

28
dist/index.js vendored
View file

@ -1,28 +0,0 @@
"use strict";
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.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; } });
Object.defineProperty(exports, "AlexaActions", { enumerable: true, get: function () { return ActionMapping_1.AlexaActions; } });
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; } });
Object.defineProperty(exports, "ThermostatMode", { enumerable: true, get: function () { return AlexaStatusMessage_1.ThermostatMode; } });
var AlexaErrorResponse_1 = require("./AlexaErrorResponse");
Object.defineProperty(exports, "AlexaErrorType", { enumerable: true, get: function () { return AlexaErrorResponse_1.AlexaErrorType; } });
Object.defineProperty(exports, "AlexaErrorResponse", { enumerable: true, get: function () { return AlexaErrorResponse_1.AlexaErrorResponse; } });

View file

@ -1,7 +1,7 @@
import { IClientOptions } from "mqtt"; import type { IClientOptions } from "mqtt";
import Device from "./Device"; import Device from "./Device.js";
import { EventEmitter } from "events"; import { EventEmitter } from "events";
import { DisplayCategory } from "./DisplayCategory"; import { DisplayCategory } from "./DisplayCategory.js";
/** Optional settings for the bridge (1.5.1). Everything has the 1.4.0 behaviour as its default. */ /** Optional settings for the bridge (1.5.1). Everything has the 1.4.0 behaviour as its default. */
export interface Alex2MQTTOptions { export interface Alex2MQTTOptions {
/** The broker URL. Default: the public Alex2MQTT broker, mqtt://Alex2MQTT.stormysdream.club:1883. */ /** The broker URL. Default: the public Alex2MQTT broker, mqtt://Alex2MQTT.stormysdream.club:1883. */

View file

@ -1,4 +1,4 @@
import { MqttClient } from "mqtt"; import type { MqttClient } from "mqtt";
export declare enum AlexaErrorType { export declare enum AlexaErrorType {
ALREADY_IN_OPERATION = "ALREADY_IN_OPERATION", ALREADY_IN_OPERATION = "ALREADY_IN_OPERATION",
AUTHORIZATION_REQUIRED = "AUTHORIZATION_REQUIRED", AUTHORIZATION_REQUIRED = "AUTHORIZATION_REQUIRED",
@ -84,7 +84,6 @@ export declare class AlexaErrorResponse {
/** Where a failed publish is reported (set by the Device that built this message, 1.5.2): send() never rejects. */ /** Where a failed publish is reported (set by the Device that built this message, 1.5.2): send() never rejects. */
onPublishError?: (err: Error) => void; onPublishError?: (err: Error) => void;
constructor(correlationToken: string, rootTopic: string, endpointId: string, mqttClient: MqttClient); constructor(correlationToken: string, rootTopic: string, endpointId: string, mqttClient: MqttClient);
private generateMessageId;
setErrorMessage(type: string, message: string, otherParams?: Record<string, any>): void; setErrorMessage(type: string, message: string, otherParams?: Record<string, any>): void;
toJSON(): { toJSON(): {
event: any; event: any;

View file

@ -1,4 +1,4 @@
import { ActionMapping } from "./ActionMapping"; import type { ActionMapping } from "./ActionMapping.js";
export declare enum AlexaInterfaceType { export declare enum AlexaInterfaceType {
APPLICATION_STATE_REPORTER = "Alexa.ApplicationStateReporter", APPLICATION_STATE_REPORTER = "Alexa.ApplicationStateReporter",
AUDIO_PLAY_QUEUE = "Alexa.Audio.PlayQueue", AUDIO_PLAY_QUEUE = "Alexa.Audio.PlayQueue",

View file

@ -1,4 +1,4 @@
import { MqttClient } from "mqtt"; import type { MqttClient } from "mqtt";
export declare enum EndpointHealth { export declare enum EndpointHealth {
OK = "OK", OK = "OK",
UNREACHABLE = "UNREACHABLE" UNREACHABLE = "UNREACHABLE"
@ -43,7 +43,6 @@ export declare class AlexaStatusMessage {
/** Where a failed publish is reported (set by the Device that built this message, 1.5.2): send() never rejects. */ /** Where a failed publish is reported (set by the Device that built this message, 1.5.2): send() never rejects. */
onPublishError?: (err: Error) => void; onPublishError?: (err: Error) => void;
constructor(correlationToken: string, rootTopic: string, endpointId: string, mqttClient: MqttClient, isResponse?: boolean, isDeferred?: boolean, changeCause?: ChangeCause | null); constructor(correlationToken: string, rootTopic: string, endpointId: string, mqttClient: MqttClient, isResponse?: boolean, isDeferred?: boolean, changeCause?: ChangeCause | null);
private generateMessageId;
private getTimestamp; private getTimestamp;
private addProperty; private addProperty;
/** ChangeReport: the add*Prop calls that follow describe what CHANGED (the default for a change report). */ /** ChangeReport: the add*Prop calls that follow describe what CHANGED (the default for a change report). */

View file

@ -1,9 +1,11 @@
import { AlexaInterface, AlexaInterfaceType } from "./AlexaInterface"; import { AlexaInterface } from "./AlexaInterface.js";
import { DisplayCategory } from "./DisplayCategory"; import type { AlexaInterfaceType } from "./AlexaInterface.js";
import { DisplayCategory } from "./DisplayCategory.js";
import { EventEmitter } from "events"; import { EventEmitter } from "events";
import { MqttClient } from "mqtt"; import type { MqttClient } from "mqtt";
import { AlexaStatusMessage, ChangeCause } from "./AlexaStatusMessage"; import { AlexaStatusMessage } from "./AlexaStatusMessage.js";
import { AlexaErrorResponse } from "./AlexaErrorResponse"; import type { ChangeCause } from "./AlexaStatusMessage.js";
import { AlexaErrorResponse } from "./AlexaErrorResponse.js";
declare class Device extends EventEmitter { declare class Device extends EventEmitter {
private mqttClient; private mqttClient;
private rootTopic; private rootTopic;

10
dist/types/index.d.ts vendored Normal file
View file

@ -0,0 +1,10 @@
export { default as Alex2MQTT, DEFAULT_HOST } from "./Alex2Node.js";
export type { Alex2MQTTOptions } from "./Alex2Node.js";
export { default as Device } from "./Device.js";
export { AlexaInterface, AlexaInterfaceType } from "./AlexaInterface.js";
export type { SupportedMode } from "./AlexaInterface.js";
export { ActionMapping, AlexaActions } from "./ActionMapping.js";
export { DisplayCategory } from "./DisplayCategory.js";
export { AlexaStatusMessage, PowerController, EndpointHealth, TemperatureSensorScale, ThermostatMode } from "./AlexaStatusMessage.js";
export type { ChangeCause } from "./AlexaStatusMessage.js";
export { AlexaErrorType, AlexaErrorResponse } from "./AlexaErrorResponse.js";

24
package-lock.json generated
View file

@ -9,13 +9,15 @@
"version": "1.5.2", "version": "1.5.2",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"mqtt": "^5.10.3", "mqtt": "^5.10.3"
"uuid": "^11.0.3"
}, },
"devDependencies": { "devDependencies": {
"aedes": "^0.51.3", "aedes": "^0.51.3",
"dotenv": "^16.5.0", "dotenv": "^16.5.0",
"typescript": "^5.8.3" "typescript": "^5.8.3"
},
"engines": {
"node": ">=18"
} }
}, },
"node_modules/@babel/runtime": { "node_modules/@babel/runtime": {
@ -814,19 +816,6 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/uuid": {
"version": "11.1.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz",
"integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/esm/bin/uuid"
}
},
"node_modules/uuid-parse": { "node_modules/uuid-parse": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/uuid-parse/-/uuid-parse-1.1.0.tgz", "resolved": "https://registry.npmjs.org/uuid-parse/-/uuid-parse-1.1.0.tgz",
@ -1461,11 +1450,6 @@
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
}, },
"uuid": {
"version": "11.1.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz",
"integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ=="
},
"uuid-parse": { "uuid-parse": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/uuid-parse/-/uuid-parse-1.1.0.tgz", "resolved": "https://registry.npmjs.org/uuid-parse/-/uuid-parse-1.1.0.tgz",

View file

@ -2,18 +2,27 @@
"name": "alex2node", "name": "alex2node",
"version": "1.5.2", "version": "1.5.2",
"description": "A Node.js library for creating Alexa-compatible devices using MQTT, based on Alex2MQTT.", "description": "A Node.js library for creating Alexa-compatible devices using MQTT, based on Alex2MQTT.",
"main": "dist/index.js", "type": "commonjs",
"types": "dist/index.d.ts", "main": "./dist/cjs/index.js",
"module": "./dist/esm/index.js",
"types": "./dist/types/index.d.ts",
"exports": { "exports": {
".": { ".": {
"require": "./dist/index.js" "types": "./dist/types/index.d.ts",
} "import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js"
},
"./package.json": "./package.json"
},
"sideEffects": false,
"engines": {
"node": ">=18"
}, },
"type": "commonjs",
"scripts": { "scripts": {
"build": "tsc", "build": "node scripts/build.mjs",
"check": "tsc --noEmit && tsc -p test/fixtures/tsconfig.json",
"prepare": "npm run build", "prepare": "npm run build",
"test": "node --test test/*.test.js" "test": "node --test test/*.test.js test/*.test.mjs"
}, },
"keywords": [ "keywords": [
"alexa", "alexa",
@ -40,8 +49,7 @@
"typescript": "^5.8.3" "typescript": "^5.8.3"
}, },
"dependencies": { "dependencies": {
"mqtt": "^5.10.3", "mqtt": "^5.10.3"
"uuid": "^11.0.3"
}, },
"files": [ "files": [
"dist", "dist",

49
scripts/build.mjs Normal file
View file

@ -0,0 +1,49 @@
// npm run build - the only writer of dist/.
//
// node scripts/build.mjs [--out <dir>] <dir> defaults to dist/ in the package
//
// Two compiler runs over src/: CommonJS with the declarations (cjs/, types/), then ES modules (esm/). Both write to a
// staging directory that replaces the three directories only after both runs passed, so a failed build leaves the
// tracked dist/ as it was. Files of sources that no longer exist go with the old directories.
import { spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const tsc = createRequire(import.meta.url).resolve("typescript/bin/tsc");
function outputDirectory(args) {
const at = args.indexOf("--out");
if (at === -1) return join(root, "dist");
if (!args[at + 1]) throw new Error("--out needs a directory");
return resolve(args[at + 1]);
}
function compile(project, options) {
const run = spawnSync(process.execPath, [tsc, "-p", join(root, project), ...options], { cwd: root, stdio: "inherit" });
if (run.error) throw run.error;
if (run.status !== 0) throw new Error(`tsc -p ${project} failed, ${out} is unchanged`);
}
let out;
let staging;
try {
out = outputDirectory(process.argv.slice(2));
mkdirSync(out, { recursive: true });
staging = mkdtempSync(join(out, ".build-"));
compile("tsconfig.cjs.json", ["--outDir", join(staging, "cjs"), "--declarationDir", join(staging, "types")]);
compile("tsconfig.esm.json", ["--outDir", join(staging, "esm")]);
// The package is "type": "commonjs"; this marks the .js files under esm/ as ES modules.
writeFileSync(join(staging, "esm", "package.json"), JSON.stringify({ type: "module" }, null, 2) + "\n");
for (const part of ["cjs", "esm", "types"]) {
rmSync(join(out, part), { recursive: true, force: true });
renameSync(join(staging, part), join(out, part));
}
} catch (err) {
console.error(`build: ${err.message}`);
process.exitCode = 1;
} finally {
if (staging) rmSync(staging, { recursive: true, force: true });
}

View file

@ -1,8 +1,10 @@
import mqtt, { MqttClient, IClientOptions } from "mqtt"; // mqtt reaches Node as CommonJS: the default import works from both builds, a named one needs Node to detect it.
import Device from "./Device"; import mqtt from "mqtt";
import type { MqttClient, IClientOptions } from "mqtt";
import Device from "./Device.js";
import { EventEmitter } from "events"; import { EventEmitter } from "events";
import { DisplayCategory } from "./DisplayCategory"; import { DisplayCategory } from "./DisplayCategory.js";
import { AlexaInterfaceType } from "./AlexaInterface"; import { AlexaInterfaceType } from "./AlexaInterface.js";
/** Optional settings for the bridge (1.5.1). Everything has the 1.4.0 behaviour as its default. */ /** Optional settings for the bridge (1.5.1). Everything has the 1.4.0 behaviour as its default. */
export interface Alex2MQTTOptions { export interface Alex2MQTTOptions {

View file

@ -1,6 +1,5 @@
import { v4 as uuidv4 } from "uuid"; import { randomUUID } from "crypto";
import { AlexaInterface, AlexaInterfaceType } from "./AlexaInterface"; import type { MqttClient } from "mqtt";
import { MqttClient } from "mqtt";
export enum AlexaErrorType { export enum AlexaErrorType {
ALREADY_IN_OPERATION = "ALREADY_IN_OPERATION", ALREADY_IN_OPERATION = "ALREADY_IN_OPERATION",
@ -102,7 +101,7 @@ export class AlexaErrorResponse {
header: { header: {
namespace: "Alexa", namespace: "Alexa",
name: "ErrorResponse", name: "ErrorResponse",
messageId: this.generateMessageId(), messageId: randomUUID(),
correlationToken, correlationToken,
payloadVersion: "3", payloadVersion: "3",
}, },
@ -112,9 +111,6 @@ export class AlexaErrorResponse {
payload: {}, payload: {},
}; };
} }
private generateMessageId(): string {
return uuidv4();
}
setErrorMessage( setErrorMessage(
type: string, type: string,
message: string, message: string,

View file

@ -1,4 +1,4 @@
import { ActionMapping, AlexaActions } from "./ActionMapping"; import type { ActionMapping } from "./ActionMapping.js";
export enum AlexaInterfaceType { export enum AlexaInterfaceType {
APPLICATION_STATE_REPORTER = "Alexa.ApplicationStateReporter", APPLICATION_STATE_REPORTER = "Alexa.ApplicationStateReporter",

View file

@ -1,6 +1,6 @@
import { v4 as uuidv4 } from "uuid"; import { randomUUID } from "crypto";
import { AlexaInterface, AlexaInterfaceType } from "./AlexaInterface"; import { AlexaInterfaceType } from "./AlexaInterface.js";
import { MqttClient } from "mqtt"; import type { MqttClient } from "mqtt";
export enum EndpointHealth { export enum EndpointHealth {
OK = "OK", OK = "OK",
@ -94,7 +94,7 @@ export class AlexaStatusMessage {
? "Response" ? "Response"
: "StateReport", : "StateReport",
payloadVersion: "3", payloadVersion: "3",
messageId: this.generateMessageId(), messageId: randomUUID(),
correlationToken, correlationToken,
}, },
endpoint: { endpoint: {
@ -104,10 +104,6 @@ export class AlexaStatusMessage {
}; };
} }
private generateMessageId(): string {
return uuidv4();
}
private getTimestamp(): string { private getTimestamp(): string {
return new Date().toISOString(); return new Date().toISOString();
} }

View file

@ -1,10 +1,12 @@
import { AlexaInterface, AlexaInterfaceType } from "./AlexaInterface"; import { AlexaInterface } from "./AlexaInterface.js";
import { DisplayCategory } from "./DisplayCategory"; import type { AlexaInterfaceType } from "./AlexaInterface.js";
import { DisplayCategory } from "./DisplayCategory.js";
import { EventEmitter } from "events"; import { EventEmitter } from "events";
import { MqttClient } from "mqtt"; import { randomUUID } from "crypto";
import { AlexaStatusMessage, ChangeCause } from "./AlexaStatusMessage"; import type { MqttClient } from "mqtt";
import { v4 as uuidv4 } from "uuid"; import { AlexaStatusMessage } from "./AlexaStatusMessage.js";
import { AlexaErrorResponse } from "./AlexaErrorResponse"; import type { ChangeCause } from "./AlexaStatusMessage.js";
import { AlexaErrorResponse } from "./AlexaErrorResponse.js";
class Device extends EventEmitter { class Device extends EventEmitter {
protected softwareVersion: string = "1.0.0"; protected softwareVersion: string = "1.0.0";
@ -102,7 +104,7 @@ class Device extends EventEmitter {
const payload = { const payload = {
context: {}, context: {},
event: { event: {
header: { namespace: "Alexa.SceneController", name: activated ? "ActivationStarted" : "DeactivationStarted", messageId: uuidv4(), correlationToken, payloadVersion: "3" }, header: { namespace: "Alexa.SceneController", name: activated ? "ActivationStarted" : "DeactivationStarted", messageId: randomUUID(), correlationToken, payloadVersion: "3" },
endpoint: { endpointId: this.endpointId }, endpoint: { endpointId: this.endpointId },
payload: { cause: { type: cause }, timestamp: new Date().toISOString() }, payload: { cause: { type: cause }, timestamp: new Date().toISOString() },
}, },

View file

@ -1,10 +1,11 @@
export { default as Alex2MQTT, DEFAULT_HOST } from "./Alex2Node"; // Relative specifiers carry ".js": Node's ES module loader resolves no extension, and TypeScript maps it back to the .ts.
export type { Alex2MQTTOptions } from "./Alex2Node"; export { default as Alex2MQTT, DEFAULT_HOST } from "./Alex2Node.js";
export { default as Device } from "./Device"; export type { Alex2MQTTOptions } from "./Alex2Node.js";
export { AlexaInterface, AlexaInterfaceType } from "./AlexaInterface"; export { default as Device } from "./Device.js";
export type { SupportedMode } from "./AlexaInterface"; export { AlexaInterface, AlexaInterfaceType } from "./AlexaInterface.js";
export { ActionMapping, AlexaActions } from "./ActionMapping"; export type { SupportedMode } from "./AlexaInterface.js";
export { DisplayCategory } from "./DisplayCategory"; export { ActionMapping, AlexaActions } from "./ActionMapping.js";
export { AlexaStatusMessage, PowerController, EndpointHealth, TemperatureSensorScale, ThermostatMode } from "./AlexaStatusMessage"; export { DisplayCategory } from "./DisplayCategory.js";
export type { ChangeCause } from "./AlexaStatusMessage"; export { AlexaStatusMessage, PowerController, EndpointHealth, TemperatureSensorScale, ThermostatMode } from "./AlexaStatusMessage.js";
export { AlexaErrorType, AlexaErrorResponse } from "./AlexaErrorResponse"; export type { ChangeCause } from "./AlexaStatusMessage.js";
export { AlexaErrorType, AlexaErrorResponse } from "./AlexaErrorResponse.js";

View file

@ -3,7 +3,7 @@
// Alexa event gateway. // Alexa event gateway.
const { test } = require("node:test"); const { test } = require("node:test");
const assert = require("node:assert/strict"); const assert = require("node:assert/strict");
const { Alex2MQTT, AlexaInterfaceType, DisplayCategory, PowerController } = require("../dist/index.js"); const { Alex2MQTT, AlexaInterfaceType, DisplayCategory, PowerController } = require("alex2node");
const { setup, until } = require("./helpers/harness.js"); const { setup, until } = require("./helpers/harness.js");
test("a change report carries the changed properties in payload.change and the rest in context", async () => { test("a change report carries the changed properties in payload.change and the rest in context", async () => {

View file

@ -3,7 +3,7 @@
// connect(), and a publish that fails without rejecting. // connect(), and a publish that fails without rejecting.
const { test } = require("node:test"); const { test } = require("node:test");
const assert = require("node:assert/strict"); const assert = require("node:assert/strict");
const { Alex2MQTT, AlexaInterfaceType, DisplayCategory, PowerController, DEFAULT_HOST } = require("../dist/index.js"); const { Alex2MQTT, AlexaInterfaceType, DisplayCategory, PowerController, DEFAULT_HOST } = require("alex2node");
const { broker, setup, connected, directive, until, sleep, cleanup } = require("./helpers/harness.js"); const { broker, setup, connected, directive, until, sleep, cleanup } = require("./helpers/harness.js");
// Nothing listens on port 1, so every attempt is refused at once. // Nothing listens on port 1, so every attempt is refused at once.

View file

@ -5,7 +5,7 @@ const { test } = require("node:test");
const assert = require("node:assert/strict"); const assert = require("node:assert/strict");
const { const {
Alex2MQTT, AlexaInterfaceType, DisplayCategory, PowerController, EndpointHealth, TemperatureSensorScale, Alex2MQTT, AlexaInterfaceType, DisplayCategory, PowerController, EndpointHealth, TemperatureSensorScale,
} = require("../dist/index.js"); } = require("alex2node");
const { setup, directive, until } = require("./helpers/harness.js"); const { setup, directive, until } = require("./helpers/harness.js");
// A light that answers every directive with its new power state and ReportState with "off at 0 %". // A light that answers every directive with its new power state and ReportState with "off at 0 %".

View file

@ -3,7 +3,7 @@
// per registered device. // per registered device.
const { test } = require("node:test"); const { test } = require("node:test");
const assert = require("node:assert/strict"); const assert = require("node:assert/strict");
const { Alex2MQTT, AlexaInterfaceType, DisplayCategory } = require("../dist/index.js"); const { Alex2MQTT, AlexaInterfaceType, DisplayCategory } = require("alex2node");
const { setup, connected, until } = require("./helpers/harness.js"); const { setup, connected, until } = require("./helpers/harness.js");
// Ask as Alex2MQTT does and return the endpoints of the one answer. // Ask as Alex2MQTT does and return the endpoints of the one answer.

11
test/fixtures/tsconfig.json vendored Normal file
View file

@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "Node16",
"moduleResolution": "Node16",
"strict": true,
"skipLibCheck": false,
"noEmit": true
},
"files": ["types.ts", "types.mts"]
}

10
test/fixtures/types.mts vendored Normal file
View file

@ -0,0 +1,10 @@
// Compiled, never run: the same declarations as seen from an ES module. Without a "types" condition in "exports" the
// import resolves to dist/esm/index.js, which has no declarations next to it, and this file fails with TS7016.
import { Alex2MQTT, DEFAULT_HOST, EndpointHealth } from "alex2node";
import type { Device } from "alex2node";
const bridge = new Alex2MQTT("user", "password", "root", false, { host: DEFAULT_HOST });
const devices: Device[] = bridge.getDevices();
const health: EndpointHealth = EndpointHealth.OK;
export { devices, health };

36
test/fixtures/types.ts vendored Normal file
View file

@ -0,0 +1,36 @@
// Compiled, never run (test/typings.test.js, npm run check): a CommonJS TypeScript consumer of the declarations in
// dist/types, resolved through the package's "exports". A "@ts-expect-error" line fails the compile when the
// declaration stops rejecting what follows it.
import { ActionMapping, AlexaActions, AlexaInterfaceType, DisplayCategory, EndpointHealth, PowerController } from "alex2node";
import type { Alex2MQTT, Alex2MQTTOptions, AlexaInterface, AlexaStatusMessage, ChangeCause, Device, SupportedMode } from "alex2node";
declare const bridge: Alex2MQTT;
declare const message: AlexaStatusMessage;
declare const capability: AlexaInterface;
const options: Alex2MQTTOptions = { host: "mqtt://127.0.0.1:1883", mqtt: { reconnectPeriod: 1000 }, log: (line) => line.length };
const device: Device = bridge.registerDevice("Lamp", "lamp-1", [DisplayCategory.LIGHT, DisplayCategory.SWITCH]);
const added: AlexaInterface = device.addCapability(AlexaInterfaceType.POWER_CONTROLLER, { proactivelyReported: true });
// ActionMapping: the payload is optional
const close = new ActionMapping([AlexaActions.Close], "TurnOn");
capability.addActionMapping(close);
// addHealthProp: the enum or its string values, chainable
const chained: AlexaStatusMessage = message.addHealthProp(EndpointHealth.OK).addHealthProp("UNREACHABLE", 50);
// @ts-expect-error not a connectivity value
message.addHealthProp("ASLEEP");
// @ts-expect-error a power state is ON or OFF
message.addPowerControllerProp("ON ");
message.addPowerControllerProp(PowerController.ON);
// addSupportedModes: Mode objects, plain strings still accepted
const mode: SupportedMode = { value: "Fan.Auto", modeResources: { friendlyNames: [{ "@type": "text", value: { text: "Auto", locale: "en-US" } }] } };
capability.addSupportedModes([mode, "Fan.On"]);
const cause: ChangeCause = "PHYSICAL_INTERACTION";
const sent: Promise<string> = device.getChangeReport(cause).addPowerControllerProp(PowerController.OFF).send();
// @ts-expect-error not a cause Alexa knows
device.getChangeReport("BUTTON");
export { options, added, chained, sent };

69
test/packaging.test.js Normal file
View file

@ -0,0 +1,69 @@
"use strict";
// The package as a consumer resolves it. A package can load itself by name through its own "exports", so
// require("alex2node") and import("alex2node") below take the path an installed copy takes.
const { test } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { builtinModules } = require("node:module");
const { spawnSync } = require("node:child_process");
const pkg = require("alex2node/package.json");
const ROOT = path.join(__dirname, "..");
const DIST = path.join(ROOT, "dist");
// Every file under dir, relative to it, sorted. A staging directory left by a killed build is not part of dist/.
function files(dir, prefix = "") {
return fs.readdirSync(path.join(dir, prefix), { withFileTypes: true })
.filter((entry) => !entry.name.startsWith(".build-"))
.flatMap((entry) => (entry.isDirectory() ? files(dir, path.join(prefix, entry.name)) : [path.join(prefix, entry.name)]))
.sort();
}
test("require(\"alex2node\") loads the CommonJS build", () => {
assert.equal(require.resolve("alex2node"), path.join(DIST, "cjs", "index.js"));
assert.equal(typeof require("alex2node").Alex2MQTT, "function");
});
test("import(\"alex2node\") loads the ES module build, with the export names of the CommonJS build", async () => {
const cjs = require("alex2node");
const esm = await import("alex2node");
// Had the import reached the CommonJS file, the namespace would carry "default" and the classes would be identical.
assert.deepEqual(Object.keys(esm).sort(), Object.keys(cjs).sort());
assert.notEqual(esm.Alex2MQTT, cjs.Alex2MQTT);
assert.equal(esm.DEFAULT_HOST, cjs.DEFAULT_HOST);
});
test("\"main\", \"module\" and \"types\" name the same files as \"exports\", for resolvers that do not read it", () => {
const entry = pkg.exports["."];
assert.deepEqual([pkg.main, pkg.module, pkg.types], [entry.require, entry.import, entry.types]);
for (const file of [pkg.main, pkg.module, pkg.types]) assert.ok(fs.existsSync(path.join(ROOT, file)), file);
assert.equal(require(path.join(ROOT, pkg.main)), require("alex2node"));
});
test("the builds import Node built-ins and the declared dependencies, nothing else", () => {
// "mqtt/lib/x" and "@scope/name/x" belong to the packages "mqtt" and "@scope/name"
const packageOf = (specifier) => specifier.split("/").slice(0, specifier.startsWith("@") ? 2 : 1).join("/");
const imported = new Set();
for (const file of files(DIST).filter((f) => f.endsWith(".js"))) {
const source = fs.readFileSync(path.join(DIST, file), "utf8");
for (const [, specifier] of source.matchAll(/(?:\brequire\(|\bfrom\s+|\bimport\s+)"([^".][^"]*)"/g)) {
if (!builtinModules.includes(specifier.replace(/^node:/, ""))) imported.add(packageOf(specifier));
}
}
assert.deepEqual([...imported].sort(), Object.keys(pkg.dependencies).sort());
});
test("dist/ is what npm run build produces from src/", () => {
const fresh = fs.mkdtempSync(path.join(os.tmpdir(), "alex2node-build-"));
try {
const build = spawnSync(process.execPath, [path.join(ROOT, "scripts", "build.mjs"), "--out", fresh], { encoding: "utf8" });
assert.equal(build.status, 0, `the build failed:\n${build.stdout}${build.stderr}`);
assert.deepEqual(files(DIST), files(fresh), "dist/ has other files than a build writes: npm run build");
const stale = files(fresh).filter((f) => !fs.readFileSync(path.join(DIST, f)).equals(fs.readFileSync(path.join(fresh, f))));
assert.deepEqual(stale, [], "dist/ is older than src/: npm run build");
} finally {
fs.rmSync(fresh, { recursive: true, force: true });
}
});

39
test/packaging.test.mjs Normal file
View file

@ -0,0 +1,39 @@
// The ES module build, imported the way an ES module consumer writes it, on the broker: a build that loads but
// cannot reach mqtt or one of its own files fails here.
import { test } from "node:test";
import assert from "node:assert/strict";
import { Alex2MQTT, AlexaInterfaceType, DisplayCategory, EndpointHealth, PowerController } from "alex2node";
import harness from "./helpers/harness.js";
const { setup, directive, until } = harness;
test("import { Alex2MQTT } from \"alex2node\": discovery, a directive and a change report on the ES module build", async () => {
const { alexa, bridge } = await setup(Alex2MQTT, "root");
const lamp = bridge.registerDevice("Lamp", "lamp-1", DisplayCategory.LIGHT);
lamp.addCapability(AlexaInterfaceType.POWER_CONTROLLER, { proactivelyReported: true });
lamp.addCapability(AlexaInterfaceType.ENDPOINT_HEALTH);
lamp.on("Event", (request) => {
lamp.getStatusMessage(request.header.correlationToken, true)
.addHealthProp(EndpointHealth.OK)
.addPowerControllerProp(PowerController.ON)
.send();
});
alexa.publish("root/discover", { namespace: "Alexa.Discovery", name: "Discover" });
await until(() => alexa.on("root/discover_r").length === 1, 3000, "discovery reply");
const [endpoints] = alexa.on("root/discover_r");
assert.deepEqual(endpoints.map((e) => e.endpointId), ["lamp-1"]);
assert.deepEqual(endpoints[0].capabilities.map((c) => c.interface), ["Alexa.PowerController", "Alexa.EndpointHealth"]);
alexa.send("root", directive("Alexa.PowerController", "TurnOn", "lamp-1", "ct1"));
await until(() => alexa.on("root/lamp-1/alexaResponce").length === 1, 3000, "directive response");
const [response] = alexa.on("root/lamp-1/alexaResponce");
assert.equal(response.event.header.name, "Response");
assert.equal(response.event.header.correlationToken, "ct1");
// crypto.randomUUID() writes the messageId: a version 4 UUID, as uuid's v4() did
assert.match(response.event.header.messageId, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
assert.equal(await lamp.getChangeReport().addPowerControllerProp(PowerController.OFF).send(), "root/changeReport");
await until(() => alexa.on("root/changeReport").length === 1, 3000, "change report");
assert.equal(alexa.on("root/changeReport")[0].event.payload.change.properties[0].value, "OFF");
});

View file

@ -1,28 +1,20 @@
"use strict"; "use strict";
// The declarations tracked in dist/: what a TypeScript consumer compiles against. // The declarations in dist/types: what a TypeScript consumer compiles against.
const { test } = require("node:test"); const { test } = require("node:test");
const assert = require("node:assert/strict"); const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path"); const path = require("node:path");
const { ActionMapping, AlexaActions, AlexaStatusMessage, EndpointHealth } = require("../dist/index.js"); const { spawnSync } = require("node:child_process");
const { ActionMapping, AlexaActions, AlexaStatusMessage, EndpointHealth } = require("alex2node");
const DIST = path.join(__dirname, "..", "dist"); const FIXTURES = path.join(__dirname, "fixtures");
const MODULES = [
"index", "Alex2Node", "Device", "AlexaInterface", "AlexaStatusMessage", "AlexaErrorResponse", "ActionMapping",
"DisplayCategory",
];
const declaration = (name) => fs.readFileSync(path.join(DIST, `${name}.d.ts`), "utf8");
test("1.5.2 typings: dist ships declarations, ActionMapping's payload is optional, addHealthProp takes the enum or the string", () => { test("the declarations compile for a CommonJS and an ES module consumer (test/fixtures/types.ts, types.mts)", () => {
for (const name of MODULES) assert.ok(fs.existsSync(path.join(DIST, `${name}.d.ts`)), `dist/${name}.d.ts`); const tsc = require.resolve("typescript/bin/tsc");
const run = spawnSync(process.execPath, [tsc, "-p", path.join(FIXTURES, "tsconfig.json")], { encoding: "utf8" });
// The declaration text itself: a revert in src/ would rebuild narrower types, and the runtime checks below would assert.equal(run.status, 0, `tsc reported:\n${run.stdout}${run.stderr}`);
// not notice. });
assert.match(declaration("ActionMapping"), /constructor\(actions: AlexaActions\[\], directiveName: string, directivePayload\?: string\);/);
assert.match(declaration("AlexaStatusMessage"), /addHealthProp\(health: EndpointHealth \| `\$\{EndpointHealth\}`, uncertaintyInMs\?: number\): this;/);
assert.match(declaration("AlexaInterface"), /addSupportedModes\(modes: Array<string \| SupportedMode>\): void;/);
assert.match(declaration("index"), /export type \{ SupportedMode \} from "\.\/AlexaInterface";/);
test("what the declarations promise holds at run time: ActionMapping without a payload, addHealthProp with the enum or the string", () => {
assert.deepEqual(new ActionMapping([AlexaActions.Close], "TurnOn").toJSON(), { assert.deepEqual(new ActionMapping([AlexaActions.Close], "TurnOn").toJSON(), {
"@type": "ActionsToDirective", "@type": "ActionsToDirective",
actions: ["Alexa.Actions.Close"], actions: ["Alexa.Actions.Close"],

10
tsconfig.cjs.json Normal file
View file

@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "CommonJS",
"noEmit": false,
"outDir": "./dist/cjs",
"declaration": true,
"declarationDir": "./dist/types"
}
}

8
tsconfig.esm.json Normal file
View file

@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "ES2020",
"noEmit": false,
"outDir": "./dist/esm"
}
}

View file

@ -1,14 +1,12 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES6", "target": "ES2020",
"module": "CommonJS", "module": "CommonJS",
"moduleResolution": "node", "moduleResolution": "node",
"outDir": "./dist",
"declaration": true,
"esModuleInterop": true, "esModuleInterop": true,
"strict": true, "strict": true,
"skipLibCheck": true "skipLibCheck": true,
"noEmit": true
}, },
"include": ["src/**/*.ts"] "include": ["src/**/*.ts"]
} }