1.5.1: broker errors as events that never crash the host (error only when listened to; connect/offline/reconnect/close; connected flag), configurable broker host + mqtt options + log hook, Alexa.ChangeReport via Device.getChangeReport (<root>/changeReport), SceneController discovery + sendSceneResponse, addCapability options (proactivelyReported), unregisterDevice/clearDevices/getDevices/disconnect, discover/directive events, promise-returning quiet send(); tests on an in-process broker (aedes); readme
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
e96850bdd6
commit
717af632de
16 changed files with 2080 additions and 225 deletions
153
dist/Alex2Node.js
vendored
153
dist/Alex2Node.js
vendored
|
|
@ -3,12 +3,28 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||||
};
|
};
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.DEFAULT_HOST = void 0;
|
||||||
const mqtt_1 = __importDefault(require("mqtt"));
|
const mqtt_1 = __importDefault(require("mqtt"));
|
||||||
const Device_1 = __importDefault(require("./Device"));
|
const Device_1 = __importDefault(require("./Device"));
|
||||||
const events_1 = require("events");
|
const events_1 = require("events");
|
||||||
const DisplayCategory_1 = require("./DisplayCategory");
|
const DisplayCategory_1 = require("./DisplayCategory");
|
||||||
|
exports.DEFAULT_HOST = "mqtt://Alex2MQTT.stormysdream.club:1883";
|
||||||
|
/**
|
||||||
|
* The Alexa-to-MQTT bridge: one broker connection for a user's root topic, a set of registered devices, discovery and
|
||||||
|
* directive dispatch.
|
||||||
|
*
|
||||||
|
* Events (all optional to listen to - since 1.5.1 a broker outage never throws out of the library):
|
||||||
|
* "connect" connected (or reconnected) and subscribed to <root>/#
|
||||||
|
* "offline" the connection dropped; mqtt.js reconnects on its own (reconnectPeriod, default 1 s)
|
||||||
|
* "reconnect" a reconnect attempt starts
|
||||||
|
* "close" the connection closed
|
||||||
|
* "error" (err) a connection or publish error. Emitted ONLY when a listener is attached (1.4.0 emitted it
|
||||||
|
* unconditionally, and Node kills a process that has an unhandled "error" event)
|
||||||
|
* "discover" (n) a discovery request was answered with n devices
|
||||||
|
* "directive" (info) a directive was dispatched to a device: { endpointId, namespace, name }
|
||||||
|
*/
|
||||||
class Alex2MQTT extends events_1.EventEmitter {
|
class Alex2MQTT extends events_1.EventEmitter {
|
||||||
constructor(username, password, rootTopic, debugLogging) {
|
constructor(username, password, rootTopic, debugLogging = false, options = {}) {
|
||||||
super(); // Initialize EventEmitter
|
super(); // Initialize EventEmitter
|
||||||
this.username = username;
|
this.username = username;
|
||||||
this.password = password;
|
this.password = password;
|
||||||
|
|
@ -16,62 +32,73 @@ class Alex2MQTT extends events_1.EventEmitter {
|
||||||
this.debugLogging = debugLogging;
|
this.debugLogging = debugLogging;
|
||||||
this.client = null;
|
this.client = null;
|
||||||
this.devices = [];
|
this.devices = [];
|
||||||
this.MqttHost = "mqtt://Alex2MQTT.stormysdream.club";
|
/** true while the broker connection is up. */
|
||||||
|
this.connected = false;
|
||||||
|
/** ISO time of the last discovery request answered, null before the first. */
|
||||||
|
this.lastDiscoveryAt = null;
|
||||||
|
this.options = options || {};
|
||||||
|
this.MqttHost = this.options.host || exports.DEFAULT_HOST;
|
||||||
|
}
|
||||||
|
log(message, detail) {
|
||||||
|
if (this.options.log)
|
||||||
|
this.options.log(message, detail);
|
||||||
|
else if (this.debugLogging) {
|
||||||
|
if (detail === undefined)
|
||||||
|
console.log(`[Alex2Node.ts] ${message}`);
|
||||||
|
else
|
||||||
|
console.log(`[Alex2Node.ts] ${message}`, detail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** Emit "error" only when somebody listens: an unhandled "error" event would crash the host process. */
|
||||||
|
fail(err) {
|
||||||
|
this.log("error: " + err.message);
|
||||||
|
if (this.listenerCount("error") > 0)
|
||||||
|
this.emit("error", err);
|
||||||
}
|
}
|
||||||
connect() {
|
connect() {
|
||||||
const options = {
|
if (this.client)
|
||||||
username: this.username,
|
return;
|
||||||
password: this.password,
|
const options = Object.assign({ username: this.username, password: this.password, reconnectPeriod: 1000, connectTimeout: 10000 }, (this.options.mqtt || {}));
|
||||||
};
|
|
||||||
this.client = mqtt_1.default.connect(this.MqttHost, options);
|
this.client = mqtt_1.default.connect(this.MqttHost, options);
|
||||||
this.client.on("connect", () => {
|
this.client.on("connect", () => {
|
||||||
if (this.debugLogging) {
|
this.connected = true;
|
||||||
console.log("[Alex2Node.ts] Connected to MQTT broker");
|
this.log("Connected to MQTT broker");
|
||||||
}
|
|
||||||
this.client.subscribe(this.rootTopic + "/#", (err) => {
|
this.client.subscribe(this.rootTopic + "/#", (err) => {
|
||||||
if (this.debugLogging && !err) {
|
|
||||||
console.log("[Alex2Node.ts] Subscribed to topics");
|
|
||||||
}
|
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error("[Alex2Node.ts] MQTT connection error:", err);
|
this.fail(err);
|
||||||
this.emit("error", 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.client.on("message", (topic, message) => {
|
||||||
if (this.debugLogging) {
|
this.log(`MQTT Message Received`, { topic, payload: message.toString() });
|
||||||
console.log(`[Alex2Node.ts] MQTT Message Received`);
|
|
||||||
console.log(` Topic: ${topic}`);
|
|
||||||
console.log(` Payload: ${message.toString()}`);
|
|
||||||
}
|
|
||||||
if (topic == `${this.rootTopic}/discover`) {
|
if (topic == `${this.rootTopic}/discover`) {
|
||||||
if (this.debugLogging) {
|
this.log("Discovery request received, getting device json...");
|
||||||
console.log("[Alex2Node.ts] Discovery request received, getting device json...");
|
|
||||||
}
|
|
||||||
const deviceArray = this.devices.map((device) => device.getJSON());
|
const deviceArray = this.devices.map((device) => device.getJSON());
|
||||||
if (deviceArray.length > 0) {
|
this.lastDiscoveryAt = new Date().toISOString();
|
||||||
this.client.publish(topic + "_r", JSON.stringify(deviceArray), (err) => {
|
this.client.publish(topic + "_r", JSON.stringify(deviceArray), (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error("[Alex2Node.ts] MQTT connection error:", err);
|
this.fail(err);
|
||||||
this.emit("error", err);
|
return;
|
||||||
}
|
|
||||||
else if (this.debugLogging) {
|
|
||||||
console.log(`[Alex2Node.ts] Discovery payloads published to ${topic + "_r"}`);
|
|
||||||
console.log(JSON.stringify(deviceArray, null, 2));
|
|
||||||
}
|
}
|
||||||
|
this.log(`Discovery payloads published to ${topic + "_r"}`, deviceArray);
|
||||||
|
this.emit("discover", deviceArray.length);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
else if (topic.split("/").length == 3) {
|
else if (topic.split("/").length == 3) {
|
||||||
const [root, endpointId, directiveType] = topic.split("/");
|
const [, endpointId, directiveType] = topic.split("/");
|
||||||
if (directiveType != "alexaDirective") {
|
if (directiveType != "alexaDirective") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const device = this.devices.find((d) => d.endpointId === endpointId);
|
const device = this.devices.find((d) => d.endpointId === endpointId);
|
||||||
if (!device) {
|
if (!device) {
|
||||||
if (this.debugLogging) {
|
this.log(`No device found for endpointId: ${endpointId}`);
|
||||||
console.warn(`[Alex2Node.ts] No device found for endpointId: ${endpointId}`);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let payload;
|
let payload;
|
||||||
|
|
@ -79,15 +106,14 @@ class Alex2MQTT extends events_1.EventEmitter {
|
||||||
payload = JSON.parse(message.toString());
|
payload = JSON.parse(message.toString());
|
||||||
}
|
}
|
||||||
catch (e) {
|
catch (e) {
|
||||||
console.error(`[Alex2Node.ts] Failed to parse payload JSON`, e);
|
this.log(`Failed to parse payload JSON`, e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (payload.header &&
|
if (!payload || !payload.header)
|
||||||
payload.header.namespace === "Alexa" &&
|
return;
|
||||||
payload.header.name === "ReportState") {
|
this.emit("directive", { endpointId, namespace: payload.header.namespace, name: payload.header.name });
|
||||||
if (this.debugLogging) {
|
if (payload.header.namespace === "Alexa" && payload.header.name === "ReportState") {
|
||||||
console.log(`[Alex2Node.ts] ReportState directive for device ${endpointId}`);
|
this.log(`ReportState directive for device ${endpointId}`);
|
||||||
}
|
|
||||||
device.emit("ReportState", payload);
|
device.emit("ReportState", payload);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
|
|
@ -95,21 +121,23 @@ class Alex2MQTT extends events_1.EventEmitter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.client.on("error", (err) => {
|
|
||||||
if (this.debugLogging) {
|
|
||||||
console.error("[Alex2Node.ts] Connection failed:", err);
|
|
||||||
}
|
}
|
||||||
console.error("[Alex2Node.ts] MQTT connection error:", err);
|
/** Close the broker connection (resolves once closed). The devices stay registered; connect() again reuses them. */
|
||||||
this.emit("error", err);
|
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) {
|
registerDevice(name, endpointId, displayCategory) {
|
||||||
if (!this.client) {
|
if (!this.client) {
|
||||||
throw new Error("Must call connect before creating devices");
|
throw new Error("Must call connect before creating devices");
|
||||||
}
|
}
|
||||||
if (this.debugLogging) {
|
this.log(`Creating new device with endpoint: ${endpointId}`);
|
||||||
console.log(`[Alex2Node.ts] Creating new device with endpoint: ${endpointId}`);
|
|
||||||
}
|
|
||||||
const normalizedCategory = displayCategory === null
|
const normalizedCategory = displayCategory === null
|
||||||
? [DisplayCategory_1.DisplayCategory.LIGHT]
|
? [DisplayCategory_1.DisplayCategory.LIGHT]
|
||||||
: Array.isArray(displayCategory)
|
: Array.isArray(displayCategory)
|
||||||
|
|
@ -119,5 +147,32 @@ class Alex2MQTT extends events_1.EventEmitter {
|
||||||
this.devices.push(device);
|
this.devices.push(device);
|
||||||
return device;
|
return device;
|
||||||
}
|
}
|
||||||
|
/** Forget a device (its listeners with it). Returns false when there was none. */
|
||||||
|
unregisterDevice(endpointId) {
|
||||||
|
const i = this.devices.findIndex((d) => d.endpointId === endpointId);
|
||||||
|
if (i === -1)
|
||||||
|
return false;
|
||||||
|
this.devices[i].removeAllListeners();
|
||||||
|
this.devices.splice(i, 1);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
/** Forget every device. */
|
||||||
|
clearDevices() {
|
||||||
|
for (const d of this.devices)
|
||||||
|
d.removeAllListeners();
|
||||||
|
this.devices = [];
|
||||||
|
}
|
||||||
|
getDevices() {
|
||||||
|
return this.devices.slice();
|
||||||
|
}
|
||||||
|
getDevice(endpointId) {
|
||||||
|
return this.devices.find((d) => d.endpointId === endpointId);
|
||||||
|
}
|
||||||
|
getRootTopic() {
|
||||||
|
return this.rootTopic;
|
||||||
|
}
|
||||||
|
getHost() {
|
||||||
|
return this.MqttHost;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
exports.default = Alex2MQTT;
|
exports.default = Alex2MQTT;
|
||||||
|
|
|
||||||
12
dist/AlexaErrorResponse.js
vendored
12
dist/AlexaErrorResponse.js
vendored
|
|
@ -106,19 +106,17 @@ class AlexaErrorResponse {
|
||||||
this.event.payload = Object.assign({ type,
|
this.event.payload = Object.assign({ type,
|
||||||
message }, otherParams);
|
message }, otherParams);
|
||||||
}
|
}
|
||||||
|
toJSON() {
|
||||||
|
return { event: this.event };
|
||||||
|
}
|
||||||
send(sendAsync = false) {
|
send(sendAsync = false) {
|
||||||
const payload = {
|
const payload = {
|
||||||
event: this.event,
|
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 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);
|
const payloadStr = JSON.stringify(payload);
|
||||||
this.mqttClient.publish(topic, payloadStr, (err) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (err) {
|
this.mqttClient.publish(topic, payloadStr, (err) => (err ? reject(err) : resolve(topic)));
|
||||||
console.error("[AlexaErrorResponse] Failed to publish status message:", err);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
console.log(`[AlexaErrorResponse] Sent message to ${topic}`);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
9
dist/AlexaInterface.js
vendored
9
dist/AlexaInterface.js
vendored
|
|
@ -244,6 +244,7 @@ class AlexaInterface {
|
||||||
case AlexaInterfaceType.REMOTE_VIDEO_PLAYER:
|
case AlexaInterfaceType.REMOTE_VIDEO_PLAYER:
|
||||||
case AlexaInterfaceType.RTC_SESSION_CONTROLLER:
|
case AlexaInterfaceType.RTC_SESSION_CONTROLLER:
|
||||||
case AlexaInterfaceType.SCENE_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.SECURITY_PANEL_CONTROLLER:
|
||||||
case AlexaInterfaceType.SEEK_CONTROLLER:
|
case AlexaInterfaceType.SEEK_CONTROLLER:
|
||||||
case AlexaInterfaceType.SIMPLE_EVENT_SOURCE:
|
case AlexaInterfaceType.SIMPLE_EVENT_SOURCE:
|
||||||
|
|
@ -276,7 +277,13 @@ class AlexaInterface {
|
||||||
supported: this.getProps().map(name => ({ name })),
|
supported: this.getProps().map(name => ({ name })),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
if (this.type == AlexaInterfaceType.THERMOSTAT_CONTROLLER) {
|
if (this.type == AlexaInterfaceType.SCENE_CONTROLLER) {
|
||||||
|
// Alexa.SceneController v3 carries no properties block: supportsDeactivation + proactivelyReported at the top level
|
||||||
|
delete doc.properties;
|
||||||
|
doc.supportsDeactivation = true;
|
||||||
|
doc.proactivelyReported = this.proactivelyReported;
|
||||||
|
}
|
||||||
|
else if (this.type == AlexaInterfaceType.THERMOSTAT_CONTROLLER) {
|
||||||
doc.configuration = {
|
doc.configuration = {
|
||||||
"supportedModes": ["HEAT", "COOL", "AUTO", "OFF"],
|
"supportedModes": ["HEAT", "COOL", "AUTO", "OFF"],
|
||||||
"supportsScheduling": false
|
"supportsScheduling": false
|
||||||
|
|
|
||||||
65
dist/AlexaStatusMessage.js
vendored
65
dist/AlexaStatusMessage.js
vendored
|
|
@ -28,16 +28,25 @@ var TemperatureSensorScale;
|
||||||
TemperatureSensorScale["FAHRENHEIT"] = "FAHRENHEIT";
|
TemperatureSensorScale["FAHRENHEIT"] = "FAHRENHEIT";
|
||||||
})(TemperatureSensorScale || (exports.TemperatureSensorScale = TemperatureSensorScale = {}));
|
})(TemperatureSensorScale || (exports.TemperatureSensorScale = TemperatureSensorScale = {}));
|
||||||
class AlexaStatusMessage {
|
class AlexaStatusMessage {
|
||||||
constructor(correlationToken, rootTopic, endpointId, mqttClient, isResponse = false, isDeferred = false) {
|
constructor(correlationToken, rootTopic, endpointId, mqttClient, isResponse = false, isDeferred = false, changeCause = null) {
|
||||||
this.context = { properties: [] };
|
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.rootTopic = rootTopic;
|
||||||
this.endpointId = endpointId;
|
this.endpointId = endpointId;
|
||||||
this.mqttClient = mqttClient;
|
this.mqttClient = mqttClient;
|
||||||
this.isDeferred = isDeferred;
|
this.isDeferred = isDeferred;
|
||||||
|
this.changeCause = changeCause;
|
||||||
|
if (changeCause)
|
||||||
|
this.target = "change";
|
||||||
this.event = {
|
this.event = {
|
||||||
header: {
|
header: {
|
||||||
namespace: "Alexa",
|
namespace: "Alexa",
|
||||||
name: isDeferred
|
name: changeCause
|
||||||
|
? "ChangeReport"
|
||||||
|
: isDeferred
|
||||||
? "DeferredResponse"
|
? "DeferredResponse"
|
||||||
: isResponse
|
: isResponse
|
||||||
? "Response"
|
? "Response"
|
||||||
|
|
@ -68,9 +77,33 @@ class AlexaStatusMessage {
|
||||||
};
|
};
|
||||||
if (instance)
|
if (instance)
|
||||||
prop.instance = instance;
|
prop.instance = instance;
|
||||||
this.context.properties.push(prop);
|
(this.target === "change" ? this.changeProps : this.context.properties).push(prop);
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
/** ChangeReport: the add*Prop calls that follow describe what CHANGED (the default for a change report). */
|
||||||
|
changed() {
|
||||||
|
this.target = "change";
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
/** ChangeReport: the add*Prop calls that follow describe the other, unchanged properties (context). */
|
||||||
|
unchanged() {
|
||||||
|
this.target = "context";
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
/** True for a ChangeReport (Device.getChangeReport). */
|
||||||
|
isChangeReport() {
|
||||||
|
return this.changeCause !== null;
|
||||||
|
}
|
||||||
|
/** The message as it will be published (for tests and logging). */
|
||||||
|
toJSON() {
|
||||||
|
if (this.changeCause) {
|
||||||
|
return {
|
||||||
|
event: Object.assign(Object.assign({}, this.event), { payload: { change: { cause: { type: this.changeCause }, properties: this.changeProps } } }),
|
||||||
|
context: this.context,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { context: this.isDeferred ? null : this.context, event: this.event };
|
||||||
|
}
|
||||||
addModeControllerProp(instance, value, uncertaintyInMs = 0) {
|
addModeControllerProp(instance, value, uncertaintyInMs = 0) {
|
||||||
return this.addProperty(AlexaInterface_1.AlexaInterfaceType.MODE_CONTROLLER, "mode", value, uncertaintyInMs, instance);
|
return this.addProperty(AlexaInterface_1.AlexaInterfaceType.MODE_CONTROLLER, "mode", value, uncertaintyInMs, instance);
|
||||||
}
|
}
|
||||||
|
|
@ -125,20 +158,18 @@ class AlexaStatusMessage {
|
||||||
this.context.properties.push(prop);
|
this.context.properties.push(prop);
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
send(sendAsync) {
|
/**
|
||||||
const payload = {
|
* Publish: a Response/StateReport to <root>/<endpoint>/alexaResponce (sendAsync: deferredResponse), a ChangeReport
|
||||||
context: this.isDeferred ? null : this.context,
|
* to <root>/changeReport (Alex2MQTT adds the user's token and posts it to the Alexa event gateway). Resolves with
|
||||||
event: this.event,
|
* the topic; rejects on a publish error (1.4.0 only logged).
|
||||||
};
|
*/
|
||||||
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
|
send(sendAsync = false) {
|
||||||
const payloadStr = JSON.stringify(payload);
|
const payloadStr = JSON.stringify(this.toJSON());
|
||||||
this.mqttClient.publish(topic, payloadStr, (err) => {
|
const topic = this.changeCause
|
||||||
if (err) {
|
? `${this.rootTopic}/changeReport`
|
||||||
console.error("[AlexaStatusMessage] Failed to publish status message:", err);
|
: `${this.rootTopic}/${this.endpointId}/${sendAsync ? "deferredResponse" : "alexaResponce"}`; //Yes this should be response but it is incorrect in both Alex2MQTT and Alex2ESP so for consistency is is wrong here too
|
||||||
}
|
return new Promise((resolve, reject) => {
|
||||||
else {
|
this.mqttClient.publish(topic, payloadStr, (err) => (err ? reject(err) : resolve(topic)));
|
||||||
console.log(`[AlexaStatusMessage] Sent message to ${topic}`);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
42
dist/Device.js
vendored
42
dist/Device.js
vendored
|
|
@ -4,6 +4,7 @@ const AlexaInterface_1 = require("./AlexaInterface");
|
||||||
const DisplayCategory_1 = require("./DisplayCategory");
|
const DisplayCategory_1 = require("./DisplayCategory");
|
||||||
const events_1 = require("events");
|
const events_1 = require("events");
|
||||||
const AlexaStatusMessage_1 = require("./AlexaStatusMessage");
|
const AlexaStatusMessage_1 = require("./AlexaStatusMessage");
|
||||||
|
const uuid_1 = require("uuid");
|
||||||
const AlexaErrorResponse_1 = require("./AlexaErrorResponse");
|
const AlexaErrorResponse_1 = require("./AlexaErrorResponse");
|
||||||
class Device extends events_1.EventEmitter {
|
class Device extends events_1.EventEmitter {
|
||||||
constructor(mqttClient, rootTopic, name, endpointId, displayCategory, description = "Alexa to Node.js bridge", manufacturerName = "Alex2Node", manufacturer = "Alex2Node", model = "Alex2Node_v1.0.0") {
|
constructor(mqttClient, rootTopic, name, endpointId, displayCategory, description = "Alexa to Node.js bridge", manufacturerName = "Alex2Node", manufacturer = "Alex2Node", model = "Alex2Node_v1.0.0") {
|
||||||
|
|
@ -47,6 +48,35 @@ class Device extends events_1.EventEmitter {
|
||||||
getStatusMessage(correlationToken, isResponse = false, isDeferred = false) {
|
getStatusMessage(correlationToken, isResponse = false, isDeferred = false) {
|
||||||
return new AlexaStatusMessage_1.AlexaStatusMessage(correlationToken, this.rootTopic, this.endpointId, this.mqttClient, isResponse, isDeferred);
|
return new AlexaStatusMessage_1.AlexaStatusMessage(correlationToken, this.rootTopic, this.endpointId, this.mqttClient, isResponse, isDeferred);
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* A proactive Alexa.ChangeReport (1.5.1): add the changed properties (the default target), optionally
|
||||||
|
* .unchanged() then the others, and .send() - it goes to <root>/changeReport, which Alex2MQTT forwards to the Alexa
|
||||||
|
* event gateway with the user's token. Alexa only accepts it for capabilities registered with proactivelyReported.
|
||||||
|
*/
|
||||||
|
getChangeReport(cause = "PHYSICAL_INTERACTION") {
|
||||||
|
return new AlexaStatusMessage_1.AlexaStatusMessage("", this.rootTopic, this.endpointId, this.mqttClient, false, false, cause);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Alexa.SceneController: answer an Activate / Deactivate directive with ActivationStarted / DeactivationStarted
|
||||||
|
* (1.5.1). Resolves with the topic published to.
|
||||||
|
*/
|
||||||
|
sendSceneResponse(correlationToken, activated, cause = "VOICE_INTERACTION", sendAsync = false) {
|
||||||
|
const payload = {
|
||||||
|
context: {},
|
||||||
|
event: {
|
||||||
|
header: { namespace: "Alexa.SceneController", name: activated ? "ActivationStarted" : "DeactivationStarted", messageId: (0, uuid_1.v4)(), correlationToken, payloadVersion: "3" },
|
||||||
|
endpoint: { endpointId: this.endpointId },
|
||||||
|
payload: { cause: { type: cause }, timestamp: new Date().toISOString() },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const topic = `${this.rootTopic}/${this.endpointId}/${sendAsync ? "deferredResponse" : "alexaResponce"}`;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.mqttClient.publish(topic, JSON.stringify(payload), (err) => (err ? reject(err) : resolve(topic)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
getCapabilities() {
|
||||||
|
return this.capabilities.slice();
|
||||||
|
}
|
||||||
setManufacturerName(name) {
|
setManufacturerName(name) {
|
||||||
this.manufacturerName = name;
|
this.manufacturerName = name;
|
||||||
}
|
}
|
||||||
|
|
@ -68,14 +98,10 @@ class Device extends events_1.EventEmitter {
|
||||||
getSoftwareVersion() {
|
getSoftwareVersion() {
|
||||||
return this.softwareVersion;
|
return this.softwareVersion;
|
||||||
}
|
}
|
||||||
addCapability(type) {
|
/** Add a capability. 1.5.1: options.retrievable / proactivelyReported / instance (a change report needs proactivelyReported). */
|
||||||
// const existing = this.capabilities.find(
|
addCapability(type, options = {}) {
|
||||||
// (iface) => iface.getType() === type
|
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 : "");
|
||||||
// if (existing) {
|
|
||||||
// return existing;
|
|
||||||
// }
|
|
||||||
const newCapability = new AlexaInterface_1.AlexaInterface(type);
|
|
||||||
this.capabilities.push(newCapability);
|
this.capabilities.push(newCapability);
|
||||||
// console.log(
|
// console.log(
|
||||||
// `Created new interface with type: ${newCapability.getTypeString()}`
|
// `Created new interface with type: ${newCapability.getTypeString()}`
|
||||||
|
|
|
||||||
7
dist/index.js
vendored
7
dist/index.js
vendored
|
|
@ -3,10 +3,14 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||||
};
|
};
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
exports.AlexaErrorResponse = exports.AlexaErrorType = exports.ThermostatMode = exports.TemperatureSensorScale = exports.EndpointHealth = exports.PowerController = exports.DisplayCategory = exports.AlexaActions = exports.ActionMapping = exports.AlexaInterfaceType = exports.Alex2MQTT = void 0;
|
exports.AlexaErrorResponse = exports.AlexaErrorType = exports.ThermostatMode = exports.TemperatureSensorScale = exports.EndpointHealth = exports.PowerController = exports.AlexaStatusMessage = exports.DisplayCategory = exports.AlexaActions = exports.ActionMapping = exports.AlexaInterfaceType = exports.AlexaInterface = exports.Device = exports.DEFAULT_HOST = exports.Alex2MQTT = void 0;
|
||||||
var Alex2Node_1 = require("./Alex2Node");
|
var Alex2Node_1 = require("./Alex2Node");
|
||||||
Object.defineProperty(exports, "Alex2MQTT", { enumerable: true, get: function () { return __importDefault(Alex2Node_1).default; } });
|
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");
|
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; } });
|
Object.defineProperty(exports, "AlexaInterfaceType", { enumerable: true, get: function () { return AlexaInterface_1.AlexaInterfaceType; } });
|
||||||
var ActionMapping_1 = require("./ActionMapping");
|
var ActionMapping_1 = require("./ActionMapping");
|
||||||
Object.defineProperty(exports, "ActionMapping", { enumerable: true, get: function () { return ActionMapping_1.ActionMapping; } });
|
Object.defineProperty(exports, "ActionMapping", { enumerable: true, get: function () { return ActionMapping_1.ActionMapping; } });
|
||||||
|
|
@ -14,6 +18,7 @@ Object.defineProperty(exports, "AlexaActions", { enumerable: true, get: function
|
||||||
var DisplayCategory_1 = require("./DisplayCategory");
|
var DisplayCategory_1 = require("./DisplayCategory");
|
||||||
Object.defineProperty(exports, "DisplayCategory", { enumerable: true, get: function () { return DisplayCategory_1.DisplayCategory; } });
|
Object.defineProperty(exports, "DisplayCategory", { enumerable: true, get: function () { return DisplayCategory_1.DisplayCategory; } });
|
||||||
var AlexaStatusMessage_1 = require("./AlexaStatusMessage");
|
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, "PowerController", { enumerable: true, get: function () { return AlexaStatusMessage_1.PowerController; } });
|
||||||
Object.defineProperty(exports, "EndpointHealth", { enumerable: true, get: function () { return AlexaStatusMessage_1.EndpointHealth; } });
|
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, "TemperatureSensorScale", { enumerable: true, get: function () { return AlexaStatusMessage_1.TemperatureSensorScale; } });
|
||||||
|
|
|
||||||
1491
package-lock.json
generated
1491
package-lock.json
generated
File diff suppressed because it is too large
Load diff
16
package.json
16
package.json
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "alex2node",
|
"name": "alex2node",
|
||||||
"version": "1.5.0",
|
"version": "1.5.1",
|
||||||
"description": "A Node.js library for creating Alexa-compatible devices using MQTT, based on Alex2MQTT.",
|
"description": "A Node.js library for creating Alexa-compatible devices using MQTT, based on Alex2MQTT.",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
|
|
@ -12,7 +12,8 @@
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"prepare": "npm run build"
|
"prepare": "npm run build",
|
||||||
|
"test": "node --test test/*.test.js"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"alexa",
|
"alexa",
|
||||||
|
|
@ -34,11 +35,16 @@
|
||||||
},
|
},
|
||||||
"homepage": "https://git.stormysdream.club/apps/Alex2Node#readme",
|
"homepage": "https://git.stormysdream.club/apps/Alex2Node#readme",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.8.3",
|
"aedes": "^0.51.3",
|
||||||
"dotenv": "^16.5.0"
|
"dotenv": "^16.5.0",
|
||||||
|
"typescript": "^5.8.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mqtt": "^5.10.3",
|
"mqtt": "^5.10.3",
|
||||||
"uuid": "^11.0.3"
|
"uuid": "^11.0.3"
|
||||||
}
|
},
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"readme.md"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
37
readme.md
37
readme.md
|
|
@ -13,6 +13,41 @@ For more details on how to configure Alex2MQTT, visit [Alex2MQTT Documentation](
|
||||||
- Easily configure devices and their capabilities via simple API.
|
- Easily configure devices and their capabilities via simple API.
|
||||||
|
|
||||||
|
|
||||||
|
## What is new in 1.5.1
|
||||||
|
|
||||||
|
- **A broker outage no longer crashes your process.** 1.4.0 emitted `error` unconditionally, and Node terminates a
|
||||||
|
process that has an unhandled `error` event, so every reconnect failure killed the host. 1.5.1 emits `error` only
|
||||||
|
when you listen for it, keeps reconnecting on its own (mqtt.js, 1 s), and reports `connect`, `offline`, `reconnect`
|
||||||
|
and `close`. `bridge.connected` says where you stand.
|
||||||
|
- **Configurable broker.** `new Alex2MQTT(user, pass, rootTopic, debug, { host: "mqtt://broker:1883", mqtt: { ... } })`
|
||||||
|
(the default is still the public Alex2MQTT broker). `options.mqtt` is merged over the mqtt.js client options;
|
||||||
|
`options.log` replaces the console output.
|
||||||
|
- **Proactive state (ChangeReport).** `device.getChangeReport(cause)` builds an `Alexa.ChangeReport`: the
|
||||||
|
`add*Prop` calls describe what changed, `.unchanged()` switches the following calls to the context, `.send()`
|
||||||
|
publishes it to `<rootTopic>/changeReport`, which Alex2MQTT forwards to the Alexa event gateway with your account's
|
||||||
|
token. Register the capability with `{ proactivelyReported: true }` so Alexa accepts it.
|
||||||
|
- **Scenes.** `Alexa.SceneController` discovers correctly (`supportsDeactivation`), and
|
||||||
|
`device.sendSceneResponse(correlationToken, activated)` answers Activate / Deactivate with ActivationStarted /
|
||||||
|
DeactivationStarted.
|
||||||
|
- **Device management.** `unregisterDevice(endpointId)`, `clearDevices()`, `getDevices()`, `getDevice(id)`,
|
||||||
|
`disconnect()`; `discover` (device count) and `directive` ({ endpointId, namespace, name }) events;
|
||||||
|
`lastDiscoveryAt`. `addCapability(type, { retrievable, proactivelyReported, instance })`.
|
||||||
|
- `send()` on every message returns a promise (resolves with the topic) and no longer logs to the console.
|
||||||
|
- Tests: `npm test` runs the library against an in-process MQTT broker (aedes, a dev dependency).
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { Alex2MQTT, AlexaInterfaceType, DisplayCategory, PowerController } = require("alex2node");
|
||||||
|
const bridge = new Alex2MQTT(user, pass, rootTopic, false, { host: process.env.MQTT_URL });
|
||||||
|
bridge.on("error", (e) => console.warn("broker:", e.message)); // optional - without it the error is swallowed, never thrown
|
||||||
|
bridge.on("connect", () => console.log("connected"));
|
||||||
|
bridge.connect();
|
||||||
|
const lamp = bridge.registerDevice("Dining Room Light", "5020AA", DisplayCategory.LIGHT);
|
||||||
|
lamp.addCapability(AlexaInterfaceType.POWER_CONTROLLER, { proactivelyReported: true });
|
||||||
|
lamp.on("Event", (directive) => { /* ... act, then: */ lamp.getStatusMessage(directive.header.correlationToken, true).addPowerControllerProp(PowerController.ON).send(); });
|
||||||
|
// later, when the light changes locally:
|
||||||
|
lamp.getChangeReport("PHYSICAL_INTERACTION").addPowerControllerProp(PowerController.OFF).send();
|
||||||
|
```
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
|
|
@ -54,7 +89,7 @@ const username = process.env.MQTT_USERNAME;
|
||||||
const password = process.env.MQTT_PASSWORD;
|
const password = process.env.MQTT_PASSWORD;
|
||||||
const rootTopic = process.env.MQTT_ROOT_TOPIC;
|
const rootTopic = process.env.MQTT_ROOT_TOPIC;
|
||||||
|
|
||||||
const alex2NodeClient = new Alex2MQTT(username, password, rootTopic, false);
|
const alex2NodeClient = new Alex2MQTT(username, password, rootTopic, false); // 1.5.1: add { host } as a fifth argument for another broker
|
||||||
alex2NodeClient.connect(); // Connect to the MQTT broker
|
alex2NodeClient.connect(); // Connect to the MQTT broker
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
187
src/Alex2Node.ts
187
src/Alex2Node.ts
|
|
@ -1,122 +1,143 @@
|
||||||
import mqtt, { MqttClient } from "mqtt";
|
import mqtt, { MqttClient, IClientOptions } from "mqtt";
|
||||||
import Device from "./Device";
|
import Device from "./Device";
|
||||||
import { EventEmitter } from "events";
|
import { EventEmitter } from "events";
|
||||||
import { DisplayCategory } from "./DisplayCategory";
|
import { DisplayCategory } from "./DisplayCategory";
|
||||||
|
|
||||||
|
/** 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. */
|
||||||
|
host?: string;
|
||||||
|
/** Extra mqtt.js client options (reconnectPeriod, connectTimeout, clientId, ...). Merged over the defaults. */
|
||||||
|
mqtt?: IClientOptions;
|
||||||
|
/** Where log lines go. Default: console (only when debugLogging is on). */
|
||||||
|
log?: (message: string, detail?: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_HOST = "mqtt://Alex2MQTT.stormysdream.club:1883";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Alexa-to-MQTT bridge: one broker connection for a user's root topic, a set of registered devices, discovery and
|
||||||
|
* directive dispatch.
|
||||||
|
*
|
||||||
|
* Events (all optional to listen to - since 1.5.1 a broker outage never throws out of the library):
|
||||||
|
* "connect" connected (or reconnected) and subscribed to <root>/#
|
||||||
|
* "offline" the connection dropped; mqtt.js reconnects on its own (reconnectPeriod, default 1 s)
|
||||||
|
* "reconnect" a reconnect attempt starts
|
||||||
|
* "close" the connection closed
|
||||||
|
* "error" (err) a connection or publish error. Emitted ONLY when a listener is attached (1.4.0 emitted it
|
||||||
|
* unconditionally, and Node kills a process that has an unhandled "error" event)
|
||||||
|
* "discover" (n) a discovery request was answered with n devices
|
||||||
|
* "directive" (info) a directive was dispatched to a device: { endpointId, namespace, name }
|
||||||
|
*/
|
||||||
class Alex2MQTT extends EventEmitter {
|
class Alex2MQTT extends EventEmitter {
|
||||||
private client: MqttClient | null = null;
|
private client: MqttClient | null = null;
|
||||||
private devices: Device[] = [];
|
private devices: Device[] = [];
|
||||||
private MqttHost = "mqtt://Alex2MQTT.stormysdream.club";
|
private MqttHost: string;
|
||||||
|
private options: Alex2MQTTOptions;
|
||||||
|
/** true while the broker connection is up. */
|
||||||
|
public connected = false;
|
||||||
|
/** ISO time of the last discovery request answered, null before the first. */
|
||||||
|
public lastDiscoveryAt: string | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private username: string,
|
private username: string,
|
||||||
private password: string,
|
private password: string,
|
||||||
private rootTopic: string,
|
private rootTopic: string,
|
||||||
private debugLogging: boolean
|
private debugLogging: boolean = false,
|
||||||
|
options: Alex2MQTTOptions = {}
|
||||||
) {
|
) {
|
||||||
super(); // Initialize EventEmitter
|
super(); // Initialize EventEmitter
|
||||||
|
this.options = options || {};
|
||||||
|
this.MqttHost = this.options.host || DEFAULT_HOST;
|
||||||
|
}
|
||||||
|
|
||||||
|
private log(message: string, detail?: unknown): void {
|
||||||
|
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. */
|
||||||
|
private fail(err: Error): void {
|
||||||
|
this.log("error: " + err.message);
|
||||||
|
if (this.listenerCount("error") > 0) this.emit("error", err);
|
||||||
}
|
}
|
||||||
|
|
||||||
connect(): void {
|
connect(): void {
|
||||||
const options = {
|
if (this.client) return;
|
||||||
|
const options: IClientOptions = {
|
||||||
username: this.username,
|
username: this.username,
|
||||||
password: this.password,
|
password: this.password,
|
||||||
|
reconnectPeriod: 1000,
|
||||||
|
connectTimeout: 10000,
|
||||||
|
...(this.options.mqtt || {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
this.client = mqtt.connect(this.MqttHost, options);
|
this.client = mqtt.connect(this.MqttHost, options);
|
||||||
|
|
||||||
this.client.on("connect", () => {
|
this.client.on("connect", () => {
|
||||||
if (this.debugLogging) {
|
this.connected = true;
|
||||||
console.log("[Alex2Node.ts] Connected to MQTT broker");
|
this.log("Connected to MQTT broker");
|
||||||
}
|
|
||||||
|
|
||||||
this.client!.subscribe(this.rootTopic + "/#", (err) => {
|
this.client!.subscribe(this.rootTopic + "/#", (err) => {
|
||||||
if (this.debugLogging && !err) {
|
if (err) { this.fail(err); return; }
|
||||||
console.log("[Alex2Node.ts] Subscribed to topics");
|
this.log("Subscribed to topics");
|
||||||
}
|
this.emit("connect");
|
||||||
if (err) {
|
|
||||||
console.error("[Alex2Node.ts] MQTT connection error:", err);
|
|
||||||
this.emit("error", err);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
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: Error) => { this.connected = false; this.fail(err); });
|
||||||
|
|
||||||
this.client.on("message", (topic: string, message: Buffer) => {
|
this.client.on("message", (topic: string, message: Buffer) => {
|
||||||
if (this.debugLogging) {
|
this.log(`MQTT Message Received`, { topic, payload: message.toString() });
|
||||||
console.log(`[Alex2Node.ts] MQTT Message Received`);
|
|
||||||
console.log(` Topic: ${topic}`);
|
|
||||||
console.log(` Payload: ${message.toString()}`);
|
|
||||||
}
|
|
||||||
if (topic == `${this.rootTopic}/discover`) {
|
if (topic == `${this.rootTopic}/discover`) {
|
||||||
if (this.debugLogging) {
|
this.log("Discovery request received, getting device json...");
|
||||||
console.log(
|
|
||||||
"[Alex2Node.ts] Discovery request received, getting device json..."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const deviceArray = this.devices.map((device) => device.getJSON());
|
const deviceArray = this.devices.map((device) => device.getJSON());
|
||||||
|
this.lastDiscoveryAt = new Date().toISOString();
|
||||||
if (deviceArray.length > 0) {
|
this.client!.publish(topic + "_r", JSON.stringify(deviceArray), (err) => {
|
||||||
this.client!.publish(
|
if (err) { this.fail(err); return; }
|
||||||
topic + "_r",
|
this.log(`Discovery payloads published to ${topic + "_r"}`, deviceArray);
|
||||||
JSON.stringify(deviceArray),
|
this.emit("discover", deviceArray.length);
|
||||||
(err) => {
|
});
|
||||||
if (err) {
|
|
||||||
console.error("[Alex2Node.ts] MQTT connection error:", err);
|
|
||||||
this.emit("error", err);
|
|
||||||
} else if (this.debugLogging) {
|
|
||||||
console.log(
|
|
||||||
`[Alex2Node.ts] Discovery payloads published to ${
|
|
||||||
topic + "_r"
|
|
||||||
}`
|
|
||||||
);
|
|
||||||
console.log(JSON.stringify(deviceArray, null, 2));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if (topic.split("/").length == 3) {
|
} else if (topic.split("/").length == 3) {
|
||||||
const [root, endpointId, directiveType] = topic.split("/");
|
const [, endpointId, directiveType] = topic.split("/");
|
||||||
if (directiveType != "alexaDirective") {
|
if (directiveType != "alexaDirective") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const device = this.devices.find((d) => d.endpointId === endpointId);
|
const device = this.devices.find((d) => d.endpointId === endpointId);
|
||||||
if (!device) {
|
if (!device) {
|
||||||
if (this.debugLogging) {
|
this.log(`No device found for endpointId: ${endpointId}`);
|
||||||
console.warn(
|
|
||||||
`[Alex2Node.ts] No device found for endpointId: ${endpointId}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let payload;
|
let payload;
|
||||||
try {
|
try {
|
||||||
payload = JSON.parse(message.toString());
|
payload = JSON.parse(message.toString());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`[Alex2Node.ts] Failed to parse payload JSON`, e);
|
this.log(`Failed to parse payload JSON`, e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (
|
if (!payload || !payload.header) return;
|
||||||
payload.header &&
|
this.emit("directive", { endpointId, namespace: payload.header.namespace, name: payload.header.name });
|
||||||
payload.header.namespace === "Alexa" &&
|
if (payload.header.namespace === "Alexa" && payload.header.name === "ReportState") {
|
||||||
payload.header.name === "ReportState"
|
this.log(`ReportState directive for device ${endpointId}`);
|
||||||
) {
|
|
||||||
if (this.debugLogging) {
|
|
||||||
console.log(
|
|
||||||
`[Alex2Node.ts] ReportState directive for device ${endpointId}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
device.emit("ReportState", payload);
|
device.emit("ReportState", payload);
|
||||||
} else {
|
} else {
|
||||||
device.emit("Event", payload, payload.header.namespace);
|
device.emit("Event", payload, payload.header.namespace);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.client.on("error", (err: Error) => {
|
|
||||||
if (this.debugLogging) {
|
|
||||||
console.error("[Alex2Node.ts] Connection failed:", err);
|
|
||||||
}
|
}
|
||||||
console.error("[Alex2Node.ts] MQTT connection error:", err);
|
|
||||||
this.emit("error", err);
|
/** Close the broker connection (resolves once closed). The devices stay registered; connect() again reuses them. */
|
||||||
|
disconnect(): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const c = this.client;
|
||||||
|
if (!c) return resolve();
|
||||||
|
this.client = null;
|
||||||
|
this.connected = false;
|
||||||
|
c.end(true, {}, () => resolve());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -128,11 +149,7 @@ class Alex2MQTT extends EventEmitter {
|
||||||
if (!this.client) {
|
if (!this.client) {
|
||||||
throw new Error("Must call connect before creating devices");
|
throw new Error("Must call connect before creating devices");
|
||||||
}
|
}
|
||||||
if (this.debugLogging) {
|
this.log(`Creating new device with endpoint: ${endpointId}`);
|
||||||
console.log(
|
|
||||||
`[Alex2Node.ts] Creating new device with endpoint: ${endpointId}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const normalizedCategory =
|
const normalizedCategory =
|
||||||
displayCategory === null
|
displayCategory === null
|
||||||
? [DisplayCategory.LIGHT]
|
? [DisplayCategory.LIGHT]
|
||||||
|
|
@ -150,6 +167,32 @@ class Alex2MQTT extends EventEmitter {
|
||||||
this.devices.push(device);
|
this.devices.push(device);
|
||||||
return device;
|
return device;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Forget a device (its listeners with it). Returns false when there was none. */
|
||||||
|
unregisterDevice(endpointId: string): boolean {
|
||||||
|
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(): void {
|
||||||
|
for (const d of this.devices) d.removeAllListeners();
|
||||||
|
this.devices = [];
|
||||||
|
}
|
||||||
|
getDevices(): Device[] {
|
||||||
|
return this.devices.slice();
|
||||||
|
}
|
||||||
|
getDevice(endpointId: string): Device | undefined {
|
||||||
|
return this.devices.find((d) => d.endpointId === endpointId);
|
||||||
|
}
|
||||||
|
getRootTopic(): string {
|
||||||
|
return this.rootTopic;
|
||||||
|
}
|
||||||
|
getHost(): string {
|
||||||
|
return this.MqttHost;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Alex2MQTT;
|
export default Alex2MQTT;
|
||||||
|
|
|
||||||
|
|
@ -125,7 +125,10 @@ export class AlexaErrorResponse {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
send(sendAsync = false) {
|
toJSON(): { event: any } {
|
||||||
|
return { event: this.event };
|
||||||
|
}
|
||||||
|
send(sendAsync = false): Promise<string> {
|
||||||
const payload = {
|
const payload = {
|
||||||
event: this.event,
|
event: this.event,
|
||||||
};
|
};
|
||||||
|
|
@ -133,16 +136,8 @@ export class AlexaErrorResponse {
|
||||||
sendAsync ? "deferredResponse" : "alexaResponce"
|
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
|
}`; //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);
|
const payloadStr = JSON.stringify(payload);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
this.mqttClient.publish(topic, payloadStr, (err) => {
|
this.mqttClient.publish(topic, payloadStr, (err) => (err ? reject(err) : resolve(topic)));
|
||||||
if (err) {
|
|
||||||
console.error(
|
|
||||||
"[AlexaErrorResponse] Failed to publish status message:",
|
|
||||||
err
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.log(`[AlexaErrorResponse] Sent message to ${topic}`);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -256,6 +256,7 @@ export class AlexaInterface {
|
||||||
case AlexaInterfaceType.REMOTE_VIDEO_PLAYER:
|
case AlexaInterfaceType.REMOTE_VIDEO_PLAYER:
|
||||||
case AlexaInterfaceType.RTC_SESSION_CONTROLLER:
|
case AlexaInterfaceType.RTC_SESSION_CONTROLLER:
|
||||||
case AlexaInterfaceType.SCENE_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.SECURITY_PANEL_CONTROLLER:
|
||||||
case AlexaInterfaceType.SEEK_CONTROLLER:
|
case AlexaInterfaceType.SEEK_CONTROLLER:
|
||||||
case AlexaInterfaceType.SIMPLE_EVENT_SOURCE:
|
case AlexaInterfaceType.SIMPLE_EVENT_SOURCE:
|
||||||
|
|
@ -290,7 +291,12 @@ export class AlexaInterface {
|
||||||
supported: this.getProps().map(name => ({ name })),
|
supported: this.getProps().map(name => ({ name })),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
if(this.type==AlexaInterfaceType.THERMOSTAT_CONTROLLER){
|
if (this.type == AlexaInterfaceType.SCENE_CONTROLLER) {
|
||||||
|
// Alexa.SceneController v3 carries no properties block: supportsDeactivation + proactivelyReported at the top level
|
||||||
|
delete doc.properties;
|
||||||
|
doc.supportsDeactivation = true;
|
||||||
|
doc.proactivelyReported = this.proactivelyReported;
|
||||||
|
} else if(this.type==AlexaInterfaceType.THERMOSTAT_CONTROLLER){
|
||||||
doc.configuration={
|
doc.configuration={
|
||||||
"supportedModes": ["HEAT", "COOL", "AUTO", "OFF"],
|
"supportedModes": ["HEAT", "COOL", "AUTO", "OFF"],
|
||||||
"supportsScheduling": false
|
"supportsScheduling": false
|
||||||
|
|
|
||||||
|
|
@ -47,8 +47,15 @@ interface ContextProperty {
|
||||||
instance?: string;
|
instance?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Why a ChangeReport is sent (Alexa.ChangeReport payload.change.cause.type). */
|
||||||
|
export type ChangeCause = "APP_INTERACTION" | "PHYSICAL_INTERACTION" | "PERIODIC_POLL" | "RULE_TRIGGER" | "VOICE_INTERACTION";
|
||||||
|
|
||||||
export class AlexaStatusMessage {
|
export class AlexaStatusMessage {
|
||||||
private context: { properties: ContextProperty[] } = { properties: [] };
|
private context: { properties: ContextProperty[] } = { properties: [] };
|
||||||
|
/** ChangeReport only: the properties that changed (payload.change.properties); the rest go to context. */
|
||||||
|
private changeProps: ContextProperty[] = [];
|
||||||
|
private changeCause: ChangeCause | null = null;
|
||||||
|
private target: "context" | "change" = "context";
|
||||||
private event: {
|
private event: {
|
||||||
header: DirectiveHeader;
|
header: DirectiveHeader;
|
||||||
endpoint: DirectiveEndpoint;
|
endpoint: DirectiveEndpoint;
|
||||||
|
|
@ -65,16 +72,21 @@ export class AlexaStatusMessage {
|
||||||
endpointId: string,
|
endpointId: string,
|
||||||
mqttClient: MqttClient,
|
mqttClient: MqttClient,
|
||||||
isResponse: boolean = false,
|
isResponse: boolean = false,
|
||||||
isDeferred: boolean = false
|
isDeferred: boolean = false,
|
||||||
|
changeCause: ChangeCause | null = null
|
||||||
) {
|
) {
|
||||||
this.rootTopic = rootTopic;
|
this.rootTopic = rootTopic;
|
||||||
this.endpointId = endpointId;
|
this.endpointId = endpointId;
|
||||||
this.mqttClient = mqttClient;
|
this.mqttClient = mqttClient;
|
||||||
this.isDeferred = isDeferred;
|
this.isDeferred = isDeferred;
|
||||||
|
this.changeCause = changeCause;
|
||||||
|
if (changeCause) this.target = "change";
|
||||||
this.event = {
|
this.event = {
|
||||||
header: {
|
header: {
|
||||||
namespace: "Alexa",
|
namespace: "Alexa",
|
||||||
name: isDeferred
|
name: changeCause
|
||||||
|
? "ChangeReport"
|
||||||
|
: isDeferred
|
||||||
? "DeferredResponse"
|
? "DeferredResponse"
|
||||||
: isResponse
|
: isResponse
|
||||||
? "Response"
|
? "Response"
|
||||||
|
|
@ -112,9 +124,33 @@ export class AlexaStatusMessage {
|
||||||
uncertaintyInMilliseconds,
|
uncertaintyInMilliseconds,
|
||||||
};
|
};
|
||||||
if (instance) prop.instance = instance;
|
if (instance) prop.instance = instance;
|
||||||
this.context.properties.push(prop);
|
(this.target === "change" ? this.changeProps : this.context.properties).push(prop);
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
/** ChangeReport: the add*Prop calls that follow describe what CHANGED (the default for a change report). */
|
||||||
|
public changed(): this {
|
||||||
|
this.target = "change";
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
/** ChangeReport: the add*Prop calls that follow describe the other, unchanged properties (context). */
|
||||||
|
public unchanged(): this {
|
||||||
|
this.target = "context";
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
/** True for a ChangeReport (Device.getChangeReport). */
|
||||||
|
public isChangeReport(): boolean {
|
||||||
|
return this.changeCause !== null;
|
||||||
|
}
|
||||||
|
/** The message as it will be published (for tests and logging). */
|
||||||
|
public toJSON(): { event: any; context: { properties: ContextProperty[] } | null } {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
public addModeControllerProp(instance: string, value: string, uncertaintyInMs = 0): this {
|
public addModeControllerProp(instance: string, value: string, uncertaintyInMs = 0): this {
|
||||||
return this.addProperty(
|
return this.addProperty(
|
||||||
AlexaInterfaceType.MODE_CONTROLLER,
|
AlexaInterfaceType.MODE_CONTROLLER,
|
||||||
|
|
@ -250,23 +286,18 @@ export class AlexaStatusMessage {
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public send(sendAsync: boolean): void {
|
/**
|
||||||
const payload = {
|
* Publish: a Response/StateReport to <root>/<endpoint>/alexaResponce (sendAsync: deferredResponse), a ChangeReport
|
||||||
context: this.isDeferred ? null : this.context,
|
* to <root>/changeReport (Alex2MQTT adds the user's token and posts it to the Alexa event gateway). Resolves with
|
||||||
event: this.event,
|
* the topic; rejects on a publish error (1.4.0 only logged).
|
||||||
};
|
*/
|
||||||
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
|
public send(sendAsync: boolean = false): Promise<string> {
|
||||||
const payloadStr = JSON.stringify(payload);
|
const payloadStr = JSON.stringify(this.toJSON());
|
||||||
|
const topic = this.changeCause
|
||||||
this.mqttClient.publish(topic, payloadStr, (err) => {
|
? `${this.rootTopic}/changeReport`
|
||||||
if (err) {
|
: `${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
|
||||||
console.error(
|
return new Promise((resolve, reject) => {
|
||||||
"[AlexaStatusMessage] Failed to publish status message:",
|
this.mqttClient.publish(topic, payloadStr, (err) => (err ? reject(err) : resolve(topic)));
|
||||||
err
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.log(`[AlexaStatusMessage] Sent message to ${topic}`);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@ import { AlexaInterface, AlexaInterfaceType } from "./AlexaInterface";
|
||||||
import { DisplayCategory } from "./DisplayCategory";
|
import { DisplayCategory } from "./DisplayCategory";
|
||||||
import { EventEmitter } from "events";
|
import { EventEmitter } from "events";
|
||||||
import { MqttClient } from "mqtt";
|
import { MqttClient } from "mqtt";
|
||||||
import { AlexaStatusMessage } from "./AlexaStatusMessage";
|
import { AlexaStatusMessage, ChangeCause } from "./AlexaStatusMessage";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import { AlexaErrorResponse } from "./AlexaErrorResponse";
|
import { AlexaErrorResponse } from "./AlexaErrorResponse";
|
||||||
|
|
||||||
class Device extends EventEmitter {
|
class Device extends EventEmitter {
|
||||||
|
|
@ -72,6 +73,35 @@ class Device extends EventEmitter {
|
||||||
isDeferred
|
isDeferred
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* A proactive Alexa.ChangeReport (1.5.1): add the changed properties (the default target), optionally
|
||||||
|
* .unchanged() then the others, and .send() - it goes to <root>/changeReport, which Alex2MQTT forwards to the Alexa
|
||||||
|
* event gateway with the user's token. Alexa only accepts it for capabilities registered with proactivelyReported.
|
||||||
|
*/
|
||||||
|
getChangeReport(cause: ChangeCause = "PHYSICAL_INTERACTION"): AlexaStatusMessage {
|
||||||
|
return new AlexaStatusMessage("", this.rootTopic, this.endpointId, this.mqttClient, false, false, cause);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Alexa.SceneController: answer an Activate / Deactivate directive with ActivationStarted / DeactivationStarted
|
||||||
|
* (1.5.1). Resolves with the topic published to.
|
||||||
|
*/
|
||||||
|
sendSceneResponse(correlationToken: string, activated: boolean, cause: ChangeCause = "VOICE_INTERACTION", sendAsync = false): Promise<string> {
|
||||||
|
const payload = {
|
||||||
|
context: {},
|
||||||
|
event: {
|
||||||
|
header: { namespace: "Alexa.SceneController", name: activated ? "ActivationStarted" : "DeactivationStarted", messageId: uuidv4(), correlationToken, payloadVersion: "3" },
|
||||||
|
endpoint: { endpointId: this.endpointId },
|
||||||
|
payload: { cause: { type: cause }, timestamp: new Date().toISOString() },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const topic = `${this.rootTopic}/${this.endpointId}/${sendAsync ? "deferredResponse" : "alexaResponce"}`;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.mqttClient.publish(topic, JSON.stringify(payload), (err) => (err ? reject(err) : resolve(topic)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
getCapabilities(): AlexaInterface[] {
|
||||||
|
return this.capabilities.slice();
|
||||||
|
}
|
||||||
setManufacturerName(name: string): void {
|
setManufacturerName(name: string): void {
|
||||||
this.manufacturerName = name;
|
this.manufacturerName = name;
|
||||||
}
|
}
|
||||||
|
|
@ -100,15 +130,9 @@ class Device extends EventEmitter {
|
||||||
return this.softwareVersion;
|
return this.softwareVersion;
|
||||||
}
|
}
|
||||||
|
|
||||||
addCapability(type: AlexaInterfaceType): AlexaInterface {
|
/** Add a capability. 1.5.1: options.retrievable / proactivelyReported / instance (a change report needs proactivelyReported). */
|
||||||
// const existing = this.capabilities.find(
|
addCapability(type: AlexaInterfaceType, options: { retrievable?: boolean; proactivelyReported?: boolean; instance?: string } = {}): AlexaInterface {
|
||||||
// (iface) => iface.getType() === type
|
const newCapability = new AlexaInterface(type, options.retrievable ?? true, options.proactivelyReported ?? false, options.instance ?? "");
|
||||||
// );
|
|
||||||
// if (existing) {
|
|
||||||
// return existing;
|
|
||||||
// }
|
|
||||||
|
|
||||||
const newCapability = new AlexaInterface(type);
|
|
||||||
this.capabilities.push(newCapability);
|
this.capabilities.push(newCapability);
|
||||||
|
|
||||||
// console.log(
|
// console.log(
|
||||||
|
|
|
||||||
13
src/index.ts
13
src/index.ts
|
|
@ -1,6 +1,9 @@
|
||||||
export { default as Alex2MQTT } from './Alex2Node';
|
export { default as Alex2MQTT, DEFAULT_HOST } from "./Alex2Node";
|
||||||
export { AlexaInterfaceType } from './AlexaInterface';
|
export type { Alex2MQTTOptions } from "./Alex2Node";
|
||||||
export { ActionMapping, AlexaActions } from './ActionMapping';
|
export { default as Device } from "./Device";
|
||||||
|
export { AlexaInterface, AlexaInterfaceType } from "./AlexaInterface";
|
||||||
|
export { ActionMapping, AlexaActions } from "./ActionMapping";
|
||||||
export { DisplayCategory } from "./DisplayCategory";
|
export { DisplayCategory } from "./DisplayCategory";
|
||||||
export { PowerController,EndpointHealth,TemperatureSensorScale,ThermostatMode } from './AlexaStatusMessage';
|
export { AlexaStatusMessage, PowerController, EndpointHealth, TemperatureSensorScale, ThermostatMode } from "./AlexaStatusMessage";
|
||||||
export { AlexaErrorType,AlexaErrorResponse} from './AlexaErrorResponse';
|
export type { ChangeCause } from "./AlexaStatusMessage";
|
||||||
|
export { AlexaErrorType, AlexaErrorResponse } from "./AlexaErrorResponse";
|
||||||
|
|
|
||||||
109
test/alex2node.test.js
Normal file
109
test/alex2node.test.js
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
"use strict";
|
||||||
|
// Alex2Node against an in-process MQTT broker (aedes): the 1.4.0 contract (discovery on <root>/discover -> <root>/discover_r,
|
||||||
|
// directives on <root>/<endpoint>/alexaDirective -> Response on .../alexaResponce) and the 1.5.1 additions: a configurable
|
||||||
|
// broker, connection errors as events that never crash the host, ChangeReport on <root>/changeReport, scene responses,
|
||||||
|
// unregister.
|
||||||
|
const { test, after } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const net = require("node:net");
|
||||||
|
const aedes = require("aedes");
|
||||||
|
const mqtt = require("mqtt");
|
||||||
|
const { Alex2MQTT, AlexaInterfaceType, DisplayCategory, PowerController, EndpointHealth, TemperatureSensorScale, DEFAULT_HOST } = require("../dist/index.js");
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
const until = async (fn, ms = 3000, what = "condition") => { const t0 = Date.now(); while (Date.now() - t0 < ms) { if (await fn()) return true; await sleep(15); } throw new Error(`timeout waiting for ${what}`); };
|
||||||
|
const cleanups = [];
|
||||||
|
after(async () => { for (const c of cleanups.reverse()) { try { await c(); } catch { /* gone */ } } });
|
||||||
|
|
||||||
|
async function broker() {
|
||||||
|
const a = aedes();
|
||||||
|
const server = net.createServer(a.handle);
|
||||||
|
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
||||||
|
const port = server.address().port;
|
||||||
|
cleanups.push(() => new Promise((r) => { a.close(() => server.close(() => r())); }));
|
||||||
|
return { a, port, url: `mqtt://127.0.0.1:${port}` };
|
||||||
|
}
|
||||||
|
async function watcher(url, filter) {
|
||||||
|
const c = mqtt.connect(url); const got = [];
|
||||||
|
await new Promise((r, j) => { c.once("connect", r); c.once("error", j); });
|
||||||
|
await new Promise((r) => c.subscribe(filter, r));
|
||||||
|
c.on("message", (t, m) => { let p; try { p = JSON.parse(m.toString()); } catch { p = m.toString(); } got.push({ topic: t, payload: p }); });
|
||||||
|
cleanups.push(() => new Promise((r) => c.end(true, {}, r)));
|
||||||
|
return { c, got, publish: (t, p) => c.publish(t, typeof p === "string" ? p : JSON.stringify(p)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("defaults: the 1.4.0 host stays the default; options.host overrides it", () => {
|
||||||
|
const a = new Alex2MQTT("u", "p", "root");
|
||||||
|
assert.equal(a.getHost(), DEFAULT_HOST); assert.equal(DEFAULT_HOST, "mqtt://Alex2MQTT.stormysdream.club:1883");
|
||||||
|
assert.equal(new Alex2MQTT("u", "p", "root", false, { host: "mqtt://127.0.0.1:1" }).getHost(), "mqtt://127.0.0.1:1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("discovery + directive + ReportState round trip on a local broker", async () => {
|
||||||
|
const b = await broker();
|
||||||
|
const alexa = await watcher(b.url, "root/#");
|
||||||
|
const bridge = new Alex2MQTT("u", "p", "root", false, { host: b.url });
|
||||||
|
const events = []; bridge.on("discover", (n) => events.push(["discover", n])); bridge.on("directive", (d) => events.push(["directive", d.name]));
|
||||||
|
bridge.connect();
|
||||||
|
await until(() => bridge.connected, 3000, "connect");
|
||||||
|
cleanups.push(() => bridge.disconnect());
|
||||||
|
const lamp = bridge.registerDevice("Dining Room Light", "5020AA", DisplayCategory.LIGHT);
|
||||||
|
lamp.addCapability(AlexaInterfaceType.POWER_CONTROLLER, { proactivelyReported: true });
|
||||||
|
lamp.addCapability(AlexaInterfaceType.BRIGHTNESS_CONTROLLER);
|
||||||
|
const scene = bridge.registerDevice("Evening", "scene-1", DisplayCategory.SCENE_TRIGGER);
|
||||||
|
scene.addCapability(AlexaInterfaceType.SCENE_CONTROLLER);
|
||||||
|
lamp.on("Event", (directive, ns) => { lamp.getStatusMessage(directive.header.correlationToken, true).addHealthProp(EndpointHealth.OK).addPowerControllerProp(directive.header.name === "TurnOn" ? PowerController.ON : PowerController.OFF).addBrightnessControllerProp(100).send(false); });
|
||||||
|
lamp.on("ReportState", (payload) => { lamp.getStatusMessage(payload.header.correlationToken).addPowerControllerProp(PowerController.OFF).addBrightnessControllerProp(0).send(false); });
|
||||||
|
scene.on("Event", (directive) => { scene.sendSceneResponse(directive.header.correlationToken, directive.header.name === "Activate"); });
|
||||||
|
alexa.publish("root/discover", { namespace: "Alexa.Discovery", name: "Discover" });
|
||||||
|
await until(() => alexa.got.some((m) => m.topic === "root/discover_r"), 3000, "discovery reply");
|
||||||
|
const disc = alexa.got.find((m) => m.topic === "root/discover_r").payload;
|
||||||
|
assert.equal(disc.length, 2);
|
||||||
|
assert.equal(disc[0].friendlyName, "Dining Room Light"); assert.deepEqual(disc[0].displayCategories, ["LIGHT"]);
|
||||||
|
const power = disc[0].capabilities.find((c) => c.interface === "Alexa.PowerController"); assert.equal(power.properties.proactivelyReported, true); assert.deepEqual(power.properties.supported, [{ name: "powerState" }]);
|
||||||
|
const sc = disc[1].capabilities.find((c) => c.interface === "Alexa.SceneController"); assert.equal(sc.supportsDeactivation, true); assert.equal(sc.version, "3"); assert.equal(sc.properties, undefined);
|
||||||
|
assert.deepEqual(events[0], ["discover", 2]); assert.equal(bridge.lastDiscoveryAt !== null, true);
|
||||||
|
alexa.publish("root/5020AA/alexaDirective", { header: { namespace: "Alexa.PowerController", name: "TurnOn", correlationToken: "ct1", payloadVersion: "3", messageId: "m1" }, endpoint: { endpointId: "5020AA" }, payload: {} });
|
||||||
|
await until(() => alexa.got.some((m) => m.topic === "root/5020AA/alexaResponce"), 3000, "directive response");
|
||||||
|
const resp = alexa.got.find((m) => m.topic === "root/5020AA/alexaResponce").payload;
|
||||||
|
assert.equal(resp.event.header.name, "Response"); assert.equal(resp.event.header.correlationToken, "ct1"); assert.equal(resp.context.properties.find((p) => p.name === "powerState").value, "ON");
|
||||||
|
assert.deepEqual(events[1], ["directive", "TurnOn"]);
|
||||||
|
alexa.publish("root/5020AA/alexaDirective", { header: { namespace: "Alexa", name: "ReportState", correlationToken: "ct2", payloadVersion: "3", messageId: "m2" }, endpoint: { endpointId: "5020AA" }, payload: {} });
|
||||||
|
await until(() => alexa.got.filter((m) => m.topic === "root/5020AA/alexaResponce").length === 2, 3000, "state report");
|
||||||
|
assert.equal(alexa.got.filter((m) => m.topic === "root/5020AA/alexaResponce")[1].payload.event.header.name, "StateReport");
|
||||||
|
alexa.publish("root/scene-1/alexaDirective", { header: { namespace: "Alexa.SceneController", name: "Activate", correlationToken: "ct3", payloadVersion: "3", messageId: "m3" }, endpoint: { endpointId: "scene-1" }, payload: {} });
|
||||||
|
await until(() => alexa.got.some((m) => m.topic === "root/scene-1/alexaResponce"), 3000, "scene response");
|
||||||
|
const sr = alexa.got.find((m) => m.topic === "root/scene-1/alexaResponce").payload;
|
||||||
|
assert.equal(sr.event.header.namespace, "Alexa.SceneController"); assert.equal(sr.event.header.name, "ActivationStarted"); assert.equal(sr.event.payload.cause.type, "VOICE_INTERACTION"); assert.ok(sr.event.payload.timestamp);
|
||||||
|
// change report: changed props in payload.change, the rest in context, on <root>/changeReport
|
||||||
|
const topic = await lamp.getChangeReport("PHYSICAL_INTERACTION").addPowerControllerProp(PowerController.OFF).unchanged().addBrightnessControllerProp(0).send();
|
||||||
|
assert.equal(topic, "root/changeReport");
|
||||||
|
await until(() => alexa.got.some((m) => m.topic === "root/changeReport"), 3000, "change report");
|
||||||
|
const cr = alexa.got.find((m) => m.topic === "root/changeReport").payload;
|
||||||
|
assert.equal(cr.event.header.name, "ChangeReport"); assert.equal(cr.event.endpoint.endpointId, "5020AA"); assert.equal(cr.event.payload.change.cause.type, "PHYSICAL_INTERACTION");
|
||||||
|
assert.deepEqual(cr.event.payload.change.properties.map((p) => [p.name, p.value]), [["powerState", "OFF"]]); assert.deepEqual(cr.context.properties.map((p) => [p.name, p.value]), [["brightness", 0]]);
|
||||||
|
// thermostat props convert to celsius as 1.4.0 did
|
||||||
|
const st = lamp.getStatusMessage("x").addTemperatureSensorProp(TemperatureSensorScale.FAHRENHEIT, 68).addThermostatControllerProp("lowerSetpoint", TemperatureSensorScale.FAHRENHEIT, 68).toJSON();
|
||||||
|
assert.equal(Math.round(st.context.properties[0].value.value * 10) / 10, 20); assert.equal(st.context.properties[0].value.scale, "CELSIUS");
|
||||||
|
// unregister: the device no longer answers
|
||||||
|
assert.equal(bridge.unregisterDevice("scene-1"), true); assert.equal(bridge.unregisterDevice("scene-1"), false); assert.equal(bridge.getDevices().length, 1);
|
||||||
|
alexa.publish("root/discover", {});
|
||||||
|
await until(() => alexa.got.filter((m) => m.topic === "root/discover_r").length === 2, 3000, "second discovery");
|
||||||
|
assert.equal(alexa.got.filter((m) => m.topic === "root/discover_r")[1].payload.length, 1);
|
||||||
|
await bridge.disconnect(); assert.equal(bridge.connected, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a dead broker: 'offline'/'error' events when listened to, and NO crash when nobody listens (1.4.0 killed the host)", async () => {
|
||||||
|
const dead = new Alex2MQTT("u", "p", "root", false, { host: "mqtt://127.0.0.1:1", mqtt: { reconnectPeriod: 50, connectTimeout: 200 } });
|
||||||
|
dead.connect(); cleanups.push(() => dead.disconnect());
|
||||||
|
await sleep(300);
|
||||||
|
assert.equal(dead.connected, false, "still alive, not connected");
|
||||||
|
const seen = [];
|
||||||
|
const listened = new Alex2MQTT("u", "p", "root", false, { host: "mqtt://127.0.0.1:1", mqtt: { reconnectPeriod: 50, connectTimeout: 200 } });
|
||||||
|
listened.on("error", (e) => seen.push(e.code || e.message)); listened.on("offline", () => seen.push("offline")); listened.on("reconnect", () => seen.push("reconnect"));
|
||||||
|
listened.connect(); cleanups.push(() => listened.disconnect());
|
||||||
|
await until(() => seen.includes("ECONNREFUSED") && seen.includes("reconnect"), 3000, "error + reconnect events");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("registerDevice before connect throws (unchanged from 1.4.0)", () => {
|
||||||
|
assert.throws(() => new Alex2MQTT("u", "p", "root").registerDevice("x", "1", null), /connect before/);
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue