diff --git a/.gitignore b/.gitignore index 713d500..026a99d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ node_modules/ .env +# the staging directory of a build that was killed (scripts/build.mjs removes it otherwise) +dist/.build-*/ diff --git a/dist/ActionMapping.js b/dist/cjs/ActionMapping.js similarity index 100% rename from dist/ActionMapping.js rename to dist/cjs/ActionMapping.js diff --git a/dist/Alex2Node.js b/dist/cjs/Alex2Node.js similarity index 91% rename from dist/Alex2Node.js rename to dist/cjs/Alex2Node.js index 03fdff8..7ab869a 100644 --- a/dist/Alex2Node.js +++ b/dist/cjs/Alex2Node.js @@ -4,11 +4,12 @@ var __importDefault = (this && this.__importDefault) || function (mod) { }; Object.defineProperty(exports, "__esModule", { value: true }); 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 Device_1 = __importDefault(require("./Device")); +const Device_js_1 = __importDefault(require("./Device.js")); const events_1 = require("events"); -const DisplayCategory_1 = require("./DisplayCategory"); -const AlexaInterface_1 = require("./AlexaInterface"); +const DisplayCategory_js_1 = require("./DisplayCategory.js"); +const AlexaInterface_js_1 = require("./AlexaInterface.js"); 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 @@ -59,7 +60,13 @@ class Alex2MQTT extends events_1.EventEmitter { connect() { if (this.client) 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); for (const d of this.devices) 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(); 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() !== 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.client.publish(topic + "_r", JSON.stringify(deviceArray), (err) => { if (err) { @@ -155,11 +162,11 @@ class Alex2MQTT extends events_1.EventEmitter { } this.log(`Creating new device with endpoint: ${endpointId}`); const normalizedCategory = displayCategory === null - ? [DisplayCategory_1.DisplayCategory.LIGHT] + ? [DisplayCategory_js_1.DisplayCategory.LIGHT] : Array.isArray(displayCategory) ? displayCategory - : [displayCategory || DisplayCategory_1.DisplayCategory.LIGHT]; - const device = new Device_1.default(this.client, this.rootTopic, name, endpointId, normalizedCategory); + : [displayCategory || DisplayCategory_js_1.DisplayCategory.LIGHT]; + 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() this.devices.push(device); return device; diff --git a/dist/AlexaErrorResponse.js b/dist/cjs/AlexaErrorResponse.js similarity index 96% rename from dist/AlexaErrorResponse.js rename to dist/cjs/AlexaErrorResponse.js index 6cc3b0f..c089de5 100644 --- a/dist/AlexaErrorResponse.js +++ b/dist/cjs/AlexaErrorResponse.js @@ -1,7 +1,7 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.AlexaErrorResponse = exports.AlexaErrorType = void 0; -const uuid_1 = require("uuid"); +const crypto_1 = require("crypto"); var AlexaErrorType; (function (AlexaErrorType) { AlexaErrorType["ALREADY_IN_OPERATION"] = "ALREADY_IN_OPERATION"; @@ -89,7 +89,7 @@ class AlexaErrorResponse { header: { namespace: "Alexa", name: "ErrorResponse", - messageId: this.generateMessageId(), + messageId: (0, crypto_1.randomUUID)(), correlationToken, payloadVersion: "3", }, @@ -99,12 +99,12 @@ class AlexaErrorResponse { payload: {}, }; } - generateMessageId() { - return (0, uuid_1.v4)(); - } setErrorMessage(type, message, otherParams = {}) { - this.event.payload = Object.assign({ type, - message }, otherParams); + this.event.payload = { + type, + message, + ...otherParams, + }; } toJSON() { return { event: this.event }; diff --git a/dist/AlexaInterface.js b/dist/cjs/AlexaInterface.js similarity index 100% rename from dist/AlexaInterface.js rename to dist/cjs/AlexaInterface.js diff --git a/dist/AlexaStatusMessage.js b/dist/cjs/AlexaStatusMessage.js similarity index 81% rename from dist/AlexaStatusMessage.js rename to dist/cjs/AlexaStatusMessage.js index b6aec00..7171b03 100644 --- a/dist/AlexaStatusMessage.js +++ b/dist/cjs/AlexaStatusMessage.js @@ -1,8 +1,8 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.AlexaStatusMessage = exports.TemperatureSensorScale = exports.PowerController = exports.ThermostatMode = exports.EndpointHealth = void 0; -const uuid_1 = require("uuid"); -const AlexaInterface_1 = require("./AlexaInterface"); +const crypto_1 = require("crypto"); +const AlexaInterface_js_1 = require("./AlexaInterface.js"); var EndpointHealth; (function (EndpointHealth) { EndpointHealth["OK"] = "OK"; @@ -52,7 +52,7 @@ class AlexaStatusMessage { ? "Response" : "StateReport", payloadVersion: "3", - messageId: this.generateMessageId(), + messageId: (0, crypto_1.randomUUID)(), correlationToken, }, endpoint: { @@ -61,9 +61,6 @@ class AlexaStatusMessage { payload: {}, }; } - generateMessageId() { - return (0, uuid_1.v4)(); - } getTimestamp() { return new Date().toISOString(); } @@ -98,17 +95,17 @@ class AlexaStatusMessage { toJSON() { if (this.changeCause) { 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, }; } return { context: this.isDeferred ? null : this.context, event: this.event }; } addModeControllerProp(instance, value, uncertaintyInMs = 0) { - return this.addProperty(AlexaInterface_1.AlexaInterfaceType.MODE_CONTROLLER, "mode", value, uncertaintyInMs, instance); + return this.addProperty(AlexaInterface_js_1.AlexaInterfaceType.MODE_CONTROLLER, "mode", value, uncertaintyInMs, instance); } 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) { if (this.isDeferred) { @@ -128,14 +125,14 @@ class AlexaStatusMessage { : value, 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). */ 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) { - 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) { const tempValue = { @@ -144,16 +141,16 @@ class AlexaStatusMessage { : value, 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) { - 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) { - 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) { - 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) { this.context.properties.push(prop); diff --git a/dist/Device.js b/dist/cjs/Device.js similarity index 81% rename from dist/Device.js rename to dist/cjs/Device.js index d9d0ec8..4789f8a 100644 --- a/dist/Device.js +++ b/dist/cjs/Device.js @@ -1,11 +1,11 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const AlexaInterface_1 = require("./AlexaInterface"); -const DisplayCategory_1 = require("./DisplayCategory"); +const AlexaInterface_js_1 = require("./AlexaInterface.js"); +const DisplayCategory_js_1 = require("./DisplayCategory.js"); const events_1 = require("events"); -const AlexaStatusMessage_1 = require("./AlexaStatusMessage"); -const uuid_1 = require("uuid"); -const AlexaErrorResponse_1 = require("./AlexaErrorResponse"); +const crypto_1 = require("crypto"); +const AlexaStatusMessage_js_1 = require("./AlexaStatusMessage.js"); +const AlexaErrorResponse_js_1 = require("./AlexaErrorResponse.js"); 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") { super(); @@ -38,7 +38,7 @@ class Device extends events_1.EventEmitter { this.displayCategory = Array.isArray(category) ? category : [category]; } getDisplayCategory() { - return this.displayCategory || [DisplayCategory_1.DisplayCategory.LIGHT]; + return this.displayCategory || [DisplayCategory_js_1.DisplayCategory.LIGHT]; } setDescription(description) { this.description = description; @@ -47,12 +47,12 @@ class Device extends events_1.EventEmitter { return this.description; } 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; return msg; } 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; 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. */ 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; return msg; } @@ -74,7 +74,7 @@ class Device extends events_1.EventEmitter { const payload = { context: {}, 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 }, 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). */ addCapability(type, options = {}) { - var _a, _b, _c; - const newCapability = new AlexaInterface_1.AlexaInterface(type, (_a = options.retrievable) !== null && _a !== void 0 ? _a : true, (_b = options.proactivelyReported) !== null && _b !== void 0 ? _b : false, (_c = options.instance) !== null && _c !== void 0 ? _c : ""); + const newCapability = new AlexaInterface_js_1.AlexaInterface(type, options.retrievable ?? true, options.proactivelyReported ?? false, options.instance ?? ""); this.capabilities.push(newCapability); // console.log( // `Created new interface with type: ${newCapability.getTypeString()}` diff --git a/dist/DisplayCategory.js b/dist/cjs/DisplayCategory.js similarity index 100% rename from dist/DisplayCategory.js rename to dist/cjs/DisplayCategory.js diff --git a/dist/cjs/index.js b/dist/cjs/index.js new file mode 100644 index 0000000..57320aa --- /dev/null +++ b/dist/cjs/index.js @@ -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; } }); diff --git a/dist/esm/ActionMapping.js b/dist/esm/ActionMapping.js new file mode 100644 index 0000000..0b8c83c --- /dev/null +++ b/dist/esm/ActionMapping.js @@ -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, + }; + } +} diff --git a/dist/esm/Alex2Node.js b/dist/esm/Alex2Node.js new file mode 100644 index 0000000..91c41e9 --- /dev/null +++ b/dist/esm/Alex2Node.js @@ -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 /# + * "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; diff --git a/dist/esm/AlexaErrorResponse.js b/dist/esm/AlexaErrorResponse.js new file mode 100644 index 0000000..7521368 --- /dev/null +++ b/dist/esm/AlexaErrorResponse.js @@ -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 + }); + }); + } +} diff --git a/dist/esm/AlexaInterface.js b/dist/esm/AlexaInterface.js new file mode 100644 index 0000000..38a5820 --- /dev/null +++ b/dist/esm/AlexaInterface.js @@ -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; + } +} diff --git a/dist/esm/AlexaStatusMessage.js b/dist/esm/AlexaStatusMessage.js new file mode 100644 index 0000000..43d1a78 --- /dev/null +++ b/dist/esm/AlexaStatusMessage.js @@ -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 //alexaResponce (sendAsync: deferredResponse), a ChangeReport + * to /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 + }); + }); + } +} diff --git a/dist/esm/Device.js b/dist/esm/Device.js new file mode 100644 index 0000000..83a20e4 --- /dev/null +++ b/dist/esm/Device.js @@ -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 /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; diff --git a/dist/esm/DisplayCategory.js b/dist/esm/DisplayCategory.js new file mode 100644 index 0000000..9b5c3cd --- /dev/null +++ b/dist/esm/DisplayCategory.js @@ -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 = {})); diff --git a/dist/esm/index.js b/dist/esm/index.js new file mode 100644 index 0000000..5cacdbd --- /dev/null +++ b/dist/esm/index.js @@ -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"; diff --git a/dist/esm/package.json b/dist/esm/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/dist/esm/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/dist/index.d.ts b/dist/index.d.ts deleted file mode 100644 index 5a2dc61..0000000 --- a/dist/index.d.ts +++ /dev/null @@ -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"; diff --git a/dist/index.js b/dist/index.js deleted file mode 100644 index a0dcefa..0000000 --- a/dist/index.js +++ /dev/null @@ -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; } }); diff --git a/dist/ActionMapping.d.ts b/dist/types/ActionMapping.d.ts similarity index 100% rename from dist/ActionMapping.d.ts rename to dist/types/ActionMapping.d.ts diff --git a/dist/Alex2Node.d.ts b/dist/types/Alex2Node.d.ts similarity index 95% rename from dist/Alex2Node.d.ts rename to dist/types/Alex2Node.d.ts index 022a72c..48cce6d 100644 --- a/dist/Alex2Node.d.ts +++ b/dist/types/Alex2Node.d.ts @@ -1,7 +1,7 @@ -import { IClientOptions } from "mqtt"; -import Device from "./Device"; +import type { IClientOptions } from "mqtt"; +import Device from "./Device.js"; 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. */ export interface Alex2MQTTOptions { /** The broker URL. Default: the public Alex2MQTT broker, mqtt://Alex2MQTT.stormysdream.club:1883. */ diff --git a/dist/AlexaErrorResponse.d.ts b/dist/types/AlexaErrorResponse.d.ts similarity index 98% rename from dist/AlexaErrorResponse.d.ts rename to dist/types/AlexaErrorResponse.d.ts index c96b92b..7620270 100644 --- a/dist/AlexaErrorResponse.d.ts +++ b/dist/types/AlexaErrorResponse.d.ts @@ -1,4 +1,4 @@ -import { MqttClient } from "mqtt"; +import type { MqttClient } from "mqtt"; export declare enum AlexaErrorType { ALREADY_IN_OPERATION = "ALREADY_IN_OPERATION", 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. */ onPublishError?: (err: Error) => void; constructor(correlationToken: string, rootTopic: string, endpointId: string, mqttClient: MqttClient); - private generateMessageId; setErrorMessage(type: string, message: string, otherParams?: Record): void; toJSON(): { event: any; diff --git a/dist/AlexaInterface.d.ts b/dist/types/AlexaInterface.d.ts similarity index 98% rename from dist/AlexaInterface.d.ts rename to dist/types/AlexaInterface.d.ts index 2c2f642..9efe0f7 100644 --- a/dist/AlexaInterface.d.ts +++ b/dist/types/AlexaInterface.d.ts @@ -1,4 +1,4 @@ -import { ActionMapping } from "./ActionMapping"; +import type { ActionMapping } from "./ActionMapping.js"; export declare enum AlexaInterfaceType { APPLICATION_STATE_REPORTER = "Alexa.ApplicationStateReporter", AUDIO_PLAY_QUEUE = "Alexa.Audio.PlayQueue", diff --git a/dist/AlexaStatusMessage.d.ts b/dist/types/AlexaStatusMessage.d.ts similarity index 98% rename from dist/AlexaStatusMessage.d.ts rename to dist/types/AlexaStatusMessage.d.ts index 53b0e2d..026845b 100644 --- a/dist/AlexaStatusMessage.d.ts +++ b/dist/types/AlexaStatusMessage.d.ts @@ -1,4 +1,4 @@ -import { MqttClient } from "mqtt"; +import type { MqttClient } from "mqtt"; export declare enum EndpointHealth { OK = "OK", 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. */ onPublishError?: (err: Error) => void; constructor(correlationToken: string, rootTopic: string, endpointId: string, mqttClient: MqttClient, isResponse?: boolean, isDeferred?: boolean, changeCause?: ChangeCause | null); - private generateMessageId; private getTimestamp; private addProperty; /** ChangeReport: the add*Prop calls that follow describe what CHANGED (the default for a change report). */ diff --git a/dist/Device.d.ts b/dist/types/Device.d.ts similarity index 87% rename from dist/Device.d.ts rename to dist/types/Device.d.ts index cb731e0..102fb74 100644 --- a/dist/Device.d.ts +++ b/dist/types/Device.d.ts @@ -1,9 +1,11 @@ -import { AlexaInterface, AlexaInterfaceType } from "./AlexaInterface"; -import { DisplayCategory } from "./DisplayCategory"; +import { AlexaInterface } from "./AlexaInterface.js"; +import type { AlexaInterfaceType } from "./AlexaInterface.js"; +import { DisplayCategory } from "./DisplayCategory.js"; import { EventEmitter } from "events"; -import { MqttClient } from "mqtt"; -import { AlexaStatusMessage, ChangeCause } from "./AlexaStatusMessage"; -import { AlexaErrorResponse } from "./AlexaErrorResponse"; +import type { MqttClient } from "mqtt"; +import { AlexaStatusMessage } from "./AlexaStatusMessage.js"; +import type { ChangeCause } from "./AlexaStatusMessage.js"; +import { AlexaErrorResponse } from "./AlexaErrorResponse.js"; declare class Device extends EventEmitter { private mqttClient; private rootTopic; diff --git a/dist/DisplayCategory.d.ts b/dist/types/DisplayCategory.d.ts similarity index 100% rename from dist/DisplayCategory.d.ts rename to dist/types/DisplayCategory.d.ts diff --git a/dist/types/index.d.ts b/dist/types/index.d.ts new file mode 100644 index 0000000..7b539f6 --- /dev/null +++ b/dist/types/index.d.ts @@ -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"; diff --git a/package-lock.json b/package-lock.json index 66e35bd..020da80 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,13 +9,15 @@ "version": "1.5.2", "license": "MIT", "dependencies": { - "mqtt": "^5.10.3", - "uuid": "^11.0.3" + "mqtt": "^5.10.3" }, "devDependencies": { "aedes": "^0.51.3", "dotenv": "^16.5.0", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=18" } }, "node_modules/@babel/runtime": { @@ -814,19 +816,6 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "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": { "version": "1.1.0", "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", "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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/uuid-parse/-/uuid-parse-1.1.0.tgz", diff --git a/package.json b/package.json index 2a61fd0..4a5cce9 100644 --- a/package.json +++ b/package.json @@ -2,18 +2,27 @@ "name": "alex2node", "version": "1.5.2", "description": "A Node.js library for creating Alexa-compatible devices using MQTT, based on Alex2MQTT.", - "main": "dist/index.js", - "types": "dist/index.d.ts", + "type": "commonjs", + "main": "./dist/cjs/index.js", + "module": "./dist/esm/index.js", + "types": "./dist/types/index.d.ts", "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": { - "build": "tsc", + "build": "node scripts/build.mjs", + "check": "tsc --noEmit && tsc -p test/fixtures/tsconfig.json", "prepare": "npm run build", - "test": "node --test test/*.test.js" + "test": "node --test test/*.test.js test/*.test.mjs" }, "keywords": [ "alexa", @@ -40,8 +49,7 @@ "typescript": "^5.8.3" }, "dependencies": { - "mqtt": "^5.10.3", - "uuid": "^11.0.3" + "mqtt": "^5.10.3" }, "files": [ "dist", diff --git a/scripts/build.mjs b/scripts/build.mjs new file mode 100644 index 0000000..9eaef3b --- /dev/null +++ b/scripts/build.mjs @@ -0,0 +1,49 @@ +// npm run build - the only writer of dist/. +// +// node scripts/build.mjs [--out ] 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 }); +} diff --git a/src/Alex2Node.ts b/src/Alex2Node.ts index 8e3672a..c92ae4f 100644 --- a/src/Alex2Node.ts +++ b/src/Alex2Node.ts @@ -1,8 +1,10 @@ -import mqtt, { MqttClient, IClientOptions } from "mqtt"; -import Device from "./Device"; +// 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 type { MqttClient, IClientOptions } from "mqtt"; +import Device from "./Device.js"; import { EventEmitter } from "events"; -import { DisplayCategory } from "./DisplayCategory"; -import { AlexaInterfaceType } from "./AlexaInterface"; +import { DisplayCategory } from "./DisplayCategory.js"; +import { AlexaInterfaceType } from "./AlexaInterface.js"; /** Optional settings for the bridge (1.5.1). Everything has the 1.4.0 behaviour as its default. */ export interface Alex2MQTTOptions { diff --git a/src/AlexaErrorResponse.ts b/src/AlexaErrorResponse.ts index 77910c4..10a2280 100644 --- a/src/AlexaErrorResponse.ts +++ b/src/AlexaErrorResponse.ts @@ -1,6 +1,5 @@ -import { v4 as uuidv4 } from "uuid"; -import { AlexaInterface, AlexaInterfaceType } from "./AlexaInterface"; -import { MqttClient } from "mqtt"; +import { randomUUID } from "crypto"; +import type { MqttClient } from "mqtt"; export enum AlexaErrorType { ALREADY_IN_OPERATION = "ALREADY_IN_OPERATION", @@ -102,7 +101,7 @@ export class AlexaErrorResponse { header: { namespace: "Alexa", name: "ErrorResponse", - messageId: this.generateMessageId(), + messageId: randomUUID(), correlationToken, payloadVersion: "3", }, @@ -112,9 +111,6 @@ export class AlexaErrorResponse { payload: {}, }; } - private generateMessageId(): string { - return uuidv4(); - } setErrorMessage( type: string, message: string, diff --git a/src/AlexaInterface.ts b/src/AlexaInterface.ts index 39b10c9..8b848e6 100644 --- a/src/AlexaInterface.ts +++ b/src/AlexaInterface.ts @@ -1,4 +1,4 @@ -import { ActionMapping, AlexaActions } from "./ActionMapping"; +import type { ActionMapping } from "./ActionMapping.js"; export enum AlexaInterfaceType { APPLICATION_STATE_REPORTER = "Alexa.ApplicationStateReporter", diff --git a/src/AlexaStatusMessage.ts b/src/AlexaStatusMessage.ts index 414aaa8..f6639e4 100644 --- a/src/AlexaStatusMessage.ts +++ b/src/AlexaStatusMessage.ts @@ -1,6 +1,6 @@ -import { v4 as uuidv4 } from "uuid"; -import { AlexaInterface, AlexaInterfaceType } from "./AlexaInterface"; -import { MqttClient } from "mqtt"; +import { randomUUID } from "crypto"; +import { AlexaInterfaceType } from "./AlexaInterface.js"; +import type { MqttClient } from "mqtt"; export enum EndpointHealth { OK = "OK", @@ -94,7 +94,7 @@ export class AlexaStatusMessage { ? "Response" : "StateReport", payloadVersion: "3", - messageId: this.generateMessageId(), + messageId: randomUUID(), correlationToken, }, endpoint: { @@ -104,10 +104,6 @@ export class AlexaStatusMessage { }; } - private generateMessageId(): string { - return uuidv4(); - } - private getTimestamp(): string { return new Date().toISOString(); } diff --git a/src/Device.ts b/src/Device.ts index 0fdc8c4..32637ab 100644 --- a/src/Device.ts +++ b/src/Device.ts @@ -1,10 +1,12 @@ -import { AlexaInterface, AlexaInterfaceType } from "./AlexaInterface"; -import { DisplayCategory } from "./DisplayCategory"; +import { AlexaInterface } from "./AlexaInterface.js"; +import type { AlexaInterfaceType } from "./AlexaInterface.js"; +import { DisplayCategory } from "./DisplayCategory.js"; import { EventEmitter } from "events"; -import { MqttClient } from "mqtt"; -import { AlexaStatusMessage, ChangeCause } from "./AlexaStatusMessage"; -import { v4 as uuidv4 } from "uuid"; -import { AlexaErrorResponse } from "./AlexaErrorResponse"; +import { randomUUID } from "crypto"; +import type { MqttClient } from "mqtt"; +import { AlexaStatusMessage } from "./AlexaStatusMessage.js"; +import type { ChangeCause } from "./AlexaStatusMessage.js"; +import { AlexaErrorResponse } from "./AlexaErrorResponse.js"; class Device extends EventEmitter { protected softwareVersion: string = "1.0.0"; @@ -102,7 +104,7 @@ class Device extends EventEmitter { const payload = { context: {}, 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 }, payload: { cause: { type: cause }, timestamp: new Date().toISOString() }, }, diff --git a/src/index.ts b/src/index.ts index 5a2dc61..24450b8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,11 @@ -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"; +// 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 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"; diff --git a/test/change-report.test.js b/test/change-report.test.js index e0d4579..af57381 100644 --- a/test/change-report.test.js +++ b/test/change-report.test.js @@ -3,7 +3,7 @@ // Alexa event gateway. const { test } = require("node:test"); 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"); test("a change report carries the changed properties in payload.change and the rest in context", async () => { diff --git a/test/connection.test.js b/test/connection.test.js index 99c2777..fea2f14 100644 --- a/test/connection.test.js +++ b/test/connection.test.js @@ -3,7 +3,7 @@ // connect(), and a publish that fails without rejecting. const { test } = require("node:test"); 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"); // Nothing listens on port 1, so every attempt is refused at once. diff --git a/test/directives.test.js b/test/directives.test.js index f644b49..a7b917f 100644 --- a/test/directives.test.js +++ b/test/directives.test.js @@ -5,7 +5,7 @@ const { test } = require("node:test"); const assert = require("node:assert/strict"); const { Alex2MQTT, AlexaInterfaceType, DisplayCategory, PowerController, EndpointHealth, TemperatureSensorScale, -} = require("../dist/index.js"); +} = require("alex2node"); 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 %". diff --git a/test/discovery.test.js b/test/discovery.test.js index 2e96212..78052fa 100644 --- a/test/discovery.test.js +++ b/test/discovery.test.js @@ -3,7 +3,7 @@ // per registered device. const { test } = require("node:test"); 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"); // Ask as Alex2MQTT does and return the endpoints of the one answer. diff --git a/test/fixtures/tsconfig.json b/test/fixtures/tsconfig.json new file mode 100644 index 0000000..b59b5d5 --- /dev/null +++ b/test/fixtures/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "Node16", + "moduleResolution": "Node16", + "strict": true, + "skipLibCheck": false, + "noEmit": true + }, + "files": ["types.ts", "types.mts"] +} diff --git a/test/fixtures/types.mts b/test/fixtures/types.mts new file mode 100644 index 0000000..0b7720c --- /dev/null +++ b/test/fixtures/types.mts @@ -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 }; diff --git a/test/fixtures/types.ts b/test/fixtures/types.ts new file mode 100644 index 0000000..597a330 --- /dev/null +++ b/test/fixtures/types.ts @@ -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 = device.getChangeReport(cause).addPowerControllerProp(PowerController.OFF).send(); +// @ts-expect-error not a cause Alexa knows +device.getChangeReport("BUTTON"); + +export { options, added, chained, sent }; diff --git a/test/packaging.test.js b/test/packaging.test.js new file mode 100644 index 0000000..30880c3 --- /dev/null +++ b/test/packaging.test.js @@ -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 }); + } +}); diff --git a/test/packaging.test.mjs b/test/packaging.test.mjs new file mode 100644 index 0000000..f3799af --- /dev/null +++ b/test/packaging.test.mjs @@ -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"); +}); diff --git a/test/typings.test.js b/test/typings.test.js index fe6c2f3..ab99d24 100644 --- a/test/typings.test.js +++ b/test/typings.test.js @@ -1,28 +1,20 @@ "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 assert = require("node:assert/strict"); -const fs = require("node:fs"); 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 MODULES = [ - "index", "Alex2Node", "Device", "AlexaInterface", "AlexaStatusMessage", "AlexaErrorResponse", "ActionMapping", - "DisplayCategory", -]; -const declaration = (name) => fs.readFileSync(path.join(DIST, `${name}.d.ts`), "utf8"); +const FIXTURES = path.join(__dirname, "fixtures"); -test("1.5.2 typings: dist ships declarations, ActionMapping's payload is optional, addHealthProp takes the enum or the string", () => { - for (const name of MODULES) assert.ok(fs.existsSync(path.join(DIST, `${name}.d.ts`)), `dist/${name}.d.ts`); - - // The declaration text itself: a revert in src/ would rebuild narrower types, and the runtime checks below would - // 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\): void;/); - assert.match(declaration("index"), /export type \{ SupportedMode \} from "\.\/AlexaInterface";/); +test("the declarations compile for a CommonJS and an ES module consumer (test/fixtures/types.ts, types.mts)", () => { + const tsc = require.resolve("typescript/bin/tsc"); + const run = spawnSync(process.execPath, [tsc, "-p", path.join(FIXTURES, "tsconfig.json")], { encoding: "utf8" }); + assert.equal(run.status, 0, `tsc reported:\n${run.stdout}${run.stderr}`); +}); +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(), { "@type": "ActionsToDirective", actions: ["Alexa.Actions.Close"], diff --git a/tsconfig.cjs.json b/tsconfig.cjs.json new file mode 100644 index 0000000..9f9b944 --- /dev/null +++ b/tsconfig.cjs.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "CommonJS", + "noEmit": false, + "outDir": "./dist/cjs", + "declaration": true, + "declarationDir": "./dist/types" + } +} diff --git a/tsconfig.esm.json b/tsconfig.esm.json new file mode 100644 index 0000000..30a9249 --- /dev/null +++ b/tsconfig.esm.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "ES2020", + "noEmit": false, + "outDir": "./dist/esm" + } +} diff --git a/tsconfig.json b/tsconfig.json index 14a9474..155d244 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,14 +1,12 @@ { - "compilerOptions": { - "target": "ES6", - "module": "CommonJS", - "moduleResolution": "node", - "outDir": "./dist", - "declaration": true, - "esModuleInterop": true, - "strict": true, - "skipLibCheck": true - }, - "include": ["src/**/*.ts"] - } - \ No newline at end of file + "compilerOptions": { + "target": "ES2020", + "module": "CommonJS", + "moduleResolution": "node", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src/**/*.ts"] +}