bridge: publish through a Publisher; devices no longer hold the broker client
src/transport.ts has the Publisher interface, MqttPublisher (the client the bridge has at the time) and MemoryPublisher (tests without a broker); src/topics.ts names the four topics the library publishes to. Device, AlexaStatusMessage, AlexaErrorResponse and sendSceneResponse shared three copies of the publish-and-report code: they now call one send() that resolves the topic or "" and never rejects. registerDevice() and addDevice() work before connect(); a send() without a connection resolves "" and the "error" event says to call connect(). unregisterDevice() and clearDevices() take the publisher from the device. Device.setMqttClient() is gone, the first constructor argument of Device is ignored, and the message classes take a Publisher where they took the client. Tests: 108 -> 113. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
46fa06728c
commit
6789a1a077
39 changed files with 839 additions and 246 deletions
70
dist/cjs/Alex2Node.js
vendored
70
dist/cjs/Alex2Node.js
vendored
|
|
@ -1,4 +1,37 @@
|
||||||
"use strict";
|
"use strict";
|
||||||
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||||
|
if (k2 === undefined) k2 = k;
|
||||||
|
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||||
|
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||||
|
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||||
|
}
|
||||||
|
Object.defineProperty(o, k2, desc);
|
||||||
|
}) : (function(o, m, k, k2) {
|
||||||
|
if (k2 === undefined) k2 = k;
|
||||||
|
o[k2] = m[k];
|
||||||
|
}));
|
||||||
|
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||||
|
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||||
|
}) : function(o, v) {
|
||||||
|
o["default"] = v;
|
||||||
|
});
|
||||||
|
var __importStar = (this && this.__importStar) || (function () {
|
||||||
|
var ownKeys = function(o) {
|
||||||
|
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||||
|
var ar = [];
|
||||||
|
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||||
|
return ar;
|
||||||
|
};
|
||||||
|
return ownKeys(o);
|
||||||
|
};
|
||||||
|
return function (mod) {
|
||||||
|
if (mod && mod.__esModule) return mod;
|
||||||
|
var result = {};
|
||||||
|
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||||
|
__setModuleDefault(result, mod);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
})();
|
||||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||||
};
|
};
|
||||||
|
|
@ -11,6 +44,8 @@ const validate_js_1 = require("./device/validate.js");
|
||||||
const events_1 = require("events");
|
const events_1 = require("events");
|
||||||
const enums_js_1 = require("./compat/enums.js");
|
const enums_js_1 = require("./compat/enums.js");
|
||||||
const types_js_1 = require("./registry/types.js");
|
const types_js_1 = require("./registry/types.js");
|
||||||
|
const topics = __importStar(require("./topics.js"));
|
||||||
|
const transport_js_1 = require("./transport.js");
|
||||||
exports.DEFAULT_HOST = "mqtt://Alex2MQTT.stormysdream.club:1883";
|
exports.DEFAULT_HOST = "mqtt://Alex2MQTT.stormysdream.club:1883";
|
||||||
// What addDevice() copies from the definition to the device
|
// What addDevice() copies from the definition to the device
|
||||||
const DESCRIBED = [
|
const DESCRIBED = [
|
||||||
|
|
@ -39,6 +74,8 @@ class Alex2MQTT extends events_1.EventEmitter {
|
||||||
this.rootTopic = rootTopic;
|
this.rootTopic = rootTopic;
|
||||||
this.debugLogging = debugLogging;
|
this.debugLogging = debugLogging;
|
||||||
this.client = null;
|
this.client = null;
|
||||||
|
// One for the life of the bridge: the devices keep it over disconnect() and connect()
|
||||||
|
this.publisher = new transport_js_1.MqttPublisher(() => this.client);
|
||||||
this.devices = [];
|
this.devices = [];
|
||||||
// The lines of check() that were logged. Discovery comes every few minutes: a line is logged once.
|
// The lines of check() that were logged. Discovery comes every few minutes: a line is logged once.
|
||||||
this.logged = new Set();
|
this.logged = new Set();
|
||||||
|
|
@ -76,8 +113,6 @@ class Alex2MQTT extends events_1.EventEmitter {
|
||||||
...(this.options.mqtt || {}),
|
...(this.options.mqtt || {}),
|
||||||
};
|
};
|
||||||
this.client = mqtt_1.default.connect(this.MqttHost, options);
|
this.client = mqtt_1.default.connect(this.MqttHost, options);
|
||||||
for (const d of this.devices)
|
|
||||||
d.setMqttClient(this.client); // after a disconnect(): the devices publish through the new client
|
|
||||||
this.client.on("connect", () => {
|
this.client.on("connect", () => {
|
||||||
this.connected = true;
|
this.connected = true;
|
||||||
this.log("Connected to MQTT broker");
|
this.log("Connected to MQTT broker");
|
||||||
|
|
@ -100,12 +135,12 @@ class Alex2MQTT extends events_1.EventEmitter {
|
||||||
this.log("Discovery request received, getting device json...");
|
this.log("Discovery request received, getting device json...");
|
||||||
const deviceArray = this.describeDevices();
|
const deviceArray = this.describeDevices();
|
||||||
this.lastDiscoveryAt = new Date().toISOString();
|
this.lastDiscoveryAt = new Date().toISOString();
|
||||||
this.client.publish(topic + "_r", JSON.stringify(deviceArray), (err) => {
|
void this.publisher.publish(topics.discoverReply(this.rootTopic), deviceArray).then((result) => {
|
||||||
if (err) {
|
if (!result.ok) {
|
||||||
this.fail(err);
|
this.fail(result.error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.log(`Discovery payloads published to ${topic + "_r"}`, deviceArray);
|
this.log(`Discovery payloads published to ${result.topic}`, deviceArray);
|
||||||
this.emit("discover", deviceArray.length);
|
this.emit("discover", deviceArray.length);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -179,15 +214,13 @@ class Alex2MQTT extends events_1.EventEmitter {
|
||||||
*
|
*
|
||||||
* Throws a DeclarationError when Alexa would reject the endpoint (an endpointId with a slash, a name with
|
* Throws a DeclarationError when Alexa would reject the endpoint (an endpointId with a slash, a name with
|
||||||
* punctuation) or when the endpointId is registered already. Discovery lists Alexa.EndpointHealth for the device
|
* punctuation) or when the endpointId is registered already. Discovery lists Alexa.EndpointHealth for the device
|
||||||
* unless endpointHealth is false.
|
* unless endpointHealth is false. A device can be declared before connect(): what it sends before the bridge has
|
||||||
|
* a connection is not published, and the "error" event says so.
|
||||||
*/
|
*/
|
||||||
addDevice(definition) {
|
addDevice(definition) {
|
||||||
if (!this.client) {
|
|
||||||
throw new Error("Must call connect before creating devices");
|
|
||||||
}
|
|
||||||
const { endpointId, name, categories, endpointHealth, ...described } = definition;
|
const { endpointId, name, categories, endpointHealth, ...described } = definition;
|
||||||
// Without categories the device would take LIGHT, the default of registerDevice: here it is a mistake
|
// Without categories the device would take LIGHT, the default of registerDevice: here it is a mistake
|
||||||
const device = new Device_js_1.default(this.client, this.rootTopic, name, endpointId, (categories ?? []));
|
const device = new Device_js_1.default(null, this.rootTopic, name, endpointId, (categories ?? []));
|
||||||
for (const [field, value] of Object.entries(described)) {
|
for (const [field, value] of Object.entries(described)) {
|
||||||
if (!DESCRIBED.includes(field))
|
if (!DESCRIBED.includes(field))
|
||||||
throw new types_js_1.DeclarationError({ endpointId }, `${field} is not a field of an endpoint`);
|
throw new types_js_1.DeclarationError({ endpointId }, `${field} is not a field of an endpoint`);
|
||||||
|
|
@ -202,9 +235,6 @@ class Alex2MQTT extends events_1.EventEmitter {
|
||||||
return device;
|
return device;
|
||||||
}
|
}
|
||||||
registerDevice(name, endpointId, displayCategory) {
|
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);
|
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
|
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`;
|
const warning = `warning: registerDevice("${endpointId}") is already registered as "${existing.name}", returning that device`;
|
||||||
|
|
@ -216,7 +246,7 @@ class Alex2MQTT extends events_1.EventEmitter {
|
||||||
}
|
}
|
||||||
this.log(`Creating new device with endpoint: ${endpointId}`);
|
this.log(`Creating new device with endpoint: ${endpointId}`);
|
||||||
const normalizedCategory = Array.isArray(displayCategory) ? displayCategory : [displayCategory || enums_js_1.DisplayCategory.LIGHT];
|
const normalizedCategory = Array.isArray(displayCategory) ? displayCategory : [displayCategory || enums_js_1.DisplayCategory.LIGHT];
|
||||||
const device = new Device_js_1.default(this.client, this.rootTopic, name, endpointId, normalizedCategory);
|
const device = new Device_js_1.default(null, this.rootTopic, name, endpointId, normalizedCategory);
|
||||||
this.register(device, false);
|
this.register(device, false);
|
||||||
return device;
|
return device;
|
||||||
}
|
}
|
||||||
|
|
@ -224,6 +254,7 @@ class Alex2MQTT extends events_1.EventEmitter {
|
||||||
device.alexaInterface = this.options.alexaInterface !== false;
|
device.alexaInterface = this.options.alexaInterface !== false;
|
||||||
device.endpointHealth = endpointHealth;
|
device.endpointHealth = endpointHealth;
|
||||||
device.onPublishError = (err) => this.fail(err); // a failed publish is an "error" event (when listened to), never a rejected send()
|
device.onPublishError = (err) => this.fail(err); // a failed publish is an "error" event (when listened to), never a rejected send()
|
||||||
|
device.publisher = this.publisher;
|
||||||
this.devices.push(device);
|
this.devices.push(device);
|
||||||
}
|
}
|
||||||
/** Forget a device (its listeners with it). Returns false when there was none. */
|
/** Forget a device (its listeners with it). Returns false when there was none. */
|
||||||
|
|
@ -231,16 +262,21 @@ class Alex2MQTT extends events_1.EventEmitter {
|
||||||
const i = this.devices.findIndex((d) => d.endpointId === endpointId);
|
const i = this.devices.findIndex((d) => d.endpointId === endpointId);
|
||||||
if (i === -1)
|
if (i === -1)
|
||||||
return false;
|
return false;
|
||||||
this.devices[i].removeAllListeners();
|
this.release(this.devices[i]);
|
||||||
this.devices.splice(i, 1);
|
this.devices.splice(i, 1);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
/** Forget every device. */
|
/** Forget every device. */
|
||||||
clearDevices() {
|
clearDevices() {
|
||||||
for (const d of this.devices)
|
for (const d of this.devices)
|
||||||
d.removeAllListeners();
|
this.release(d);
|
||||||
this.devices = [];
|
this.devices = [];
|
||||||
}
|
}
|
||||||
|
// A device the bridge forgot hears no directive and publishes nothing
|
||||||
|
release(device) {
|
||||||
|
device.removeAllListeners();
|
||||||
|
device.publisher = null;
|
||||||
|
}
|
||||||
getDevices() {
|
getDevices() {
|
||||||
return this.devices.slice();
|
return this.devices.slice();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
51
dist/cjs/compat/AlexaErrorResponse.js
vendored
51
dist/cjs/compat/AlexaErrorResponse.js
vendored
|
|
@ -1,16 +1,51 @@
|
||||||
"use strict";
|
"use strict";
|
||||||
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||||
|
if (k2 === undefined) k2 = k;
|
||||||
|
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||||
|
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||||
|
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||||
|
}
|
||||||
|
Object.defineProperty(o, k2, desc);
|
||||||
|
}) : (function(o, m, k, k2) {
|
||||||
|
if (k2 === undefined) k2 = k;
|
||||||
|
o[k2] = m[k];
|
||||||
|
}));
|
||||||
|
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||||
|
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||||
|
}) : function(o, v) {
|
||||||
|
o["default"] = v;
|
||||||
|
});
|
||||||
|
var __importStar = (this && this.__importStar) || (function () {
|
||||||
|
var ownKeys = function(o) {
|
||||||
|
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||||
|
var ar = [];
|
||||||
|
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||||
|
return ar;
|
||||||
|
};
|
||||||
|
return ownKeys(o);
|
||||||
|
};
|
||||||
|
return function (mod) {
|
||||||
|
if (mod && mod.__esModule) return mod;
|
||||||
|
var result = {};
|
||||||
|
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||||
|
__setModuleDefault(result, mod);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
})();
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
exports.AlexaErrorResponse = void 0;
|
exports.AlexaErrorResponse = void 0;
|
||||||
const crypto_1 = require("crypto");
|
const crypto_1 = require("crypto");
|
||||||
const build_js_1 = require("../messages/build.js");
|
const build_js_1 = require("../messages/build.js");
|
||||||
const errors_js_1 = require("../messages/errors.js");
|
const errors_js_1 = require("../messages/errors.js");
|
||||||
|
const topics = __importStar(require("../topics.js"));
|
||||||
|
const transport_js_1 = require("../transport.js");
|
||||||
/** An ErrorResponse as 1.x builds it: device.getErrorMessage(token), setErrorMessage(), send(). */
|
/** An ErrorResponse as 1.x builds it: device.getErrorMessage(token), setErrorMessage(), send(). */
|
||||||
class AlexaErrorResponse {
|
class AlexaErrorResponse {
|
||||||
constructor(correlationToken, rootTopic, endpointId, mqttClient) {
|
constructor(correlationToken, rootTopic, endpointId, publisher) {
|
||||||
this.correlationToken = correlationToken;
|
this.correlationToken = correlationToken;
|
||||||
this.rootTopic = rootTopic;
|
this.rootTopic = rootTopic;
|
||||||
this.endpointId = endpointId;
|
this.endpointId = endpointId;
|
||||||
this.mqttClient = mqttClient;
|
this.publisher = publisher;
|
||||||
// Sent when setErrorMessage() was not called: 1.x published an empty payload, which is no ErrorResponse to Alexa
|
// Sent when setErrorMessage() was not called: 1.x published an empty payload, which is no ErrorResponse to Alexa
|
||||||
this.error = errors_js_1.AlexaErrors.of("INTERNAL_ERROR", "the device answered with an error and did not say which");
|
this.error = errors_js_1.AlexaErrors.of("INTERNAL_ERROR", "the device answered with an error and did not say which");
|
||||||
// One id for the message, however often toJSON() is called
|
// One id for the message, however often toJSON() is called
|
||||||
|
|
@ -39,16 +74,8 @@ class AlexaErrorResponse {
|
||||||
}
|
}
|
||||||
/** Resolves with the topic published to, or "" when the publish failed. Never rejects. */
|
/** Resolves with the topic published to, or "" when the publish failed. Never rejects. */
|
||||||
send(sendAsync = false) {
|
send(sendAsync = false) {
|
||||||
const topic = `${this.rootTopic}/${this.endpointId}/${sendAsync ? "deferredResponse" : "alexaResponce"}`; // "alexaResponce" is how Alex2MQTT and Alex2ESP spell the topic
|
const answer = sendAsync ? topics.deferred : topics.response;
|
||||||
return new Promise((resolve) => {
|
return (0, transport_js_1.send)(this.publisher, answer(this.rootTopic, this.endpointId), () => this.toJSON(), this.onPublishError);
|
||||||
this.mqttClient.publish(topic, JSON.stringify(this.toJSON()), (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
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
exports.AlexaErrorResponse = AlexaErrorResponse;
|
exports.AlexaErrorResponse = AlexaErrorResponse;
|
||||||
|
|
|
||||||
60
dist/cjs/compat/AlexaStatusMessage.js
vendored
60
dist/cjs/compat/AlexaStatusMessage.js
vendored
|
|
@ -1,9 +1,44 @@
|
||||||
"use strict";
|
"use strict";
|
||||||
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||||
|
if (k2 === undefined) k2 = k;
|
||||||
|
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||||
|
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||||
|
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||||
|
}
|
||||||
|
Object.defineProperty(o, k2, desc);
|
||||||
|
}) : (function(o, m, k, k2) {
|
||||||
|
if (k2 === undefined) k2 = k;
|
||||||
|
o[k2] = m[k];
|
||||||
|
}));
|
||||||
|
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||||
|
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||||
|
}) : function(o, v) {
|
||||||
|
o["default"] = v;
|
||||||
|
});
|
||||||
|
var __importStar = (this && this.__importStar) || (function () {
|
||||||
|
var ownKeys = function(o) {
|
||||||
|
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||||
|
var ar = [];
|
||||||
|
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||||
|
return ar;
|
||||||
|
};
|
||||||
|
return ownKeys(o);
|
||||||
|
};
|
||||||
|
return function (mod) {
|
||||||
|
if (mod && mod.__esModule) return mod;
|
||||||
|
var result = {};
|
||||||
|
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||||
|
__setModuleDefault(result, mod);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
})();
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
exports.AlexaStatusMessage = void 0;
|
exports.AlexaStatusMessage = void 0;
|
||||||
const crypto_1 = require("crypto");
|
const crypto_1 = require("crypto");
|
||||||
const build_js_1 = require("../messages/build.js");
|
const build_js_1 = require("../messages/build.js");
|
||||||
const StateBuilder_js_1 = require("../messages/StateBuilder.js");
|
const StateBuilder_js_1 = require("../messages/StateBuilder.js");
|
||||||
|
const topics = __importStar(require("../topics.js"));
|
||||||
|
const transport_js_1 = require("../transport.js");
|
||||||
const enums_js_1 = require("./enums.js");
|
const enums_js_1 = require("./enums.js");
|
||||||
// The 1.x helpers report every temperature in Celsius, whatever scale the device works in
|
// The 1.x helpers report every temperature in Celsius, whatever scale the device works in
|
||||||
function celsius(scale, value) {
|
function celsius(scale, value) {
|
||||||
|
|
@ -14,11 +49,11 @@ function celsius(scale, value) {
|
||||||
* device.getChangeReport(), the add...Prop() calls, send(). The values are not checked.
|
* device.getChangeReport(), the add...Prop() calls, send(). The values are not checked.
|
||||||
*/
|
*/
|
||||||
class AlexaStatusMessage {
|
class AlexaStatusMessage {
|
||||||
constructor(correlationToken, rootTopic, endpointId, mqttClient, isResponse = false, isDeferred = false, changeCause = null) {
|
constructor(correlationToken, rootTopic, endpointId, publisher, isResponse = false, isDeferred = false, changeCause = null) {
|
||||||
this.correlationToken = correlationToken;
|
this.correlationToken = correlationToken;
|
||||||
this.rootTopic = rootTopic;
|
this.rootTopic = rootTopic;
|
||||||
this.endpointId = endpointId;
|
this.endpointId = endpointId;
|
||||||
this.mqttClient = mqttClient;
|
this.publisher = publisher;
|
||||||
this.isResponse = isResponse;
|
this.isResponse = isResponse;
|
||||||
this.isDeferred = isDeferred;
|
this.isDeferred = isDeferred;
|
||||||
this.changeCause = changeCause;
|
this.changeCause = changeCause;
|
||||||
|
|
@ -109,24 +144,9 @@ class AlexaStatusMessage {
|
||||||
* to). Never rejects (1.5.1 did, so an un-caught send() could kill the host).
|
* to). Never rejects (1.5.1 did, so an un-caught send() could kill the host).
|
||||||
*/
|
*/
|
||||||
send(sendAsync = false) {
|
send(sendAsync = false) {
|
||||||
const topic = this.changeCause
|
const answer = sendAsync ? topics.deferred : topics.response;
|
||||||
? `${this.rootTopic}/changeReport`
|
const topic = this.changeCause ? topics.changeReport(this.rootTopic) : answer(this.rootTopic, this.endpointId);
|
||||||
: `${this.rootTopic}/${this.endpointId}/${sendAsync ? "deferredResponse" : "alexaResponce"}`; // "alexaResponce" is how Alex2MQTT and Alex2ESP spell the topic
|
return (0, transport_js_1.send)(this.publisher, topic, () => this.toJSON(), this.onPublishError);
|
||||||
return new Promise((resolve) => {
|
|
||||||
const failed = (err) => {
|
|
||||||
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
|
|
||||||
};
|
|
||||||
let payload;
|
|
||||||
try {
|
|
||||||
payload = JSON.stringify(this.toJSON());
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
return failed(err);
|
|
||||||
}
|
|
||||||
this.mqttClient.publish(topic, payload, (err) => (err ? failed(err) : resolve(topic)));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
exports.AlexaStatusMessage = AlexaStatusMessage;
|
exports.AlexaStatusMessage = AlexaStatusMessage;
|
||||||
|
|
|
||||||
69
dist/cjs/device/Device.js
vendored
69
dist/cjs/device/Device.js
vendored
|
|
@ -1,4 +1,37 @@
|
||||||
"use strict";
|
"use strict";
|
||||||
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||||
|
if (k2 === undefined) k2 = k;
|
||||||
|
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||||
|
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||||
|
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||||
|
}
|
||||||
|
Object.defineProperty(o, k2, desc);
|
||||||
|
}) : (function(o, m, k, k2) {
|
||||||
|
if (k2 === undefined) k2 = k;
|
||||||
|
o[k2] = m[k];
|
||||||
|
}));
|
||||||
|
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||||
|
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||||
|
}) : function(o, v) {
|
||||||
|
o["default"] = v;
|
||||||
|
});
|
||||||
|
var __importStar = (this && this.__importStar) || (function () {
|
||||||
|
var ownKeys = function(o) {
|
||||||
|
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||||
|
var ar = [];
|
||||||
|
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||||
|
return ar;
|
||||||
|
};
|
||||||
|
return ownKeys(o);
|
||||||
|
};
|
||||||
|
return function (mod) {
|
||||||
|
if (mod && mod.__esModule) return mod;
|
||||||
|
var result = {};
|
||||||
|
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||||
|
__setModuleDefault(result, mod);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
})();
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
const events_1 = require("events");
|
const events_1 = require("events");
|
||||||
const AlexaErrorResponse_js_1 = require("../compat/AlexaErrorResponse.js");
|
const AlexaErrorResponse_js_1 = require("../compat/AlexaErrorResponse.js");
|
||||||
|
|
@ -10,12 +43,14 @@ const Alexa_js_1 = require("../registry/interfaces/Alexa.js");
|
||||||
const EndpointHealth_js_1 = require("../registry/interfaces/EndpointHealth.js");
|
const EndpointHealth_js_1 = require("../registry/interfaces/EndpointHealth.js");
|
||||||
const schema_js_1 = require("../registry/schema.js");
|
const schema_js_1 = require("../registry/schema.js");
|
||||||
const types_js_1 = require("../registry/types.js");
|
const types_js_1 = require("../registry/types.js");
|
||||||
|
const topics = __importStar(require("../topics.js"));
|
||||||
|
const transport_js_1 = require("../transport.js");
|
||||||
const Capability_js_1 = require("./Capability.js");
|
const Capability_js_1 = require("./Capability.js");
|
||||||
const validate_js_1 = require("./validate.js");
|
const validate_js_1 = require("./validate.js");
|
||||||
class Device extends events_1.EventEmitter {
|
class Device extends events_1.EventEmitter {
|
||||||
constructor(mqttClient, rootTopic, name, endpointId, displayCategory, description = "Alexa to Node.js bridge", manufacturerName = "Alex2Node", manufacturer = "Alex2Node", model = "Alex2Node_v1.0.0") {
|
/** client is ignored: in 1.x it was the broker client, and a device could only be built after connect(). */
|
||||||
|
constructor(client, rootTopic, name, endpointId, displayCategory, description = "Alexa to Node.js bridge", manufacturerName = "Alex2Node", manufacturer = "Alex2Node", model = "Alex2Node_v1.0.0") {
|
||||||
super();
|
super();
|
||||||
this.mqttClient = mqttClient;
|
|
||||||
this.rootTopic = rootTopic;
|
this.rootTopic = rootTopic;
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.endpointId = endpointId;
|
this.endpointId = endpointId;
|
||||||
|
|
@ -39,10 +74,12 @@ class Device extends events_1.EventEmitter {
|
||||||
*/
|
*/
|
||||||
this.endpointHealth = false;
|
this.endpointHealth = false;
|
||||||
this.capabilities = [];
|
this.capabilities = [];
|
||||||
}
|
/**
|
||||||
/** Internal (1.5.2): Alex2MQTT.connect() re-binds every registered device to its new broker client after a disconnect(). */
|
* What the device and the messages it builds publish through. The bridge sets it when the device is registered
|
||||||
setMqttClient(client) {
|
* and clears it when the device is unregistered; a MemoryPublisher here tests a device without a broker. While it
|
||||||
this.mqttClient = client;
|
* is null every send() resolves "" and reports why.
|
||||||
|
*/
|
||||||
|
this.publisher = null;
|
||||||
}
|
}
|
||||||
getName() {
|
getName() {
|
||||||
return this.name;
|
return this.name;
|
||||||
|
|
@ -66,12 +103,12 @@ class Device extends events_1.EventEmitter {
|
||||||
return this.description;
|
return this.description;
|
||||||
}
|
}
|
||||||
getErrorMessage(correlationToken) {
|
getErrorMessage(correlationToken) {
|
||||||
const msg = new AlexaErrorResponse_js_1.AlexaErrorResponse(correlationToken, this.rootTopic, this.endpointId, this.mqttClient);
|
const msg = new AlexaErrorResponse_js_1.AlexaErrorResponse(correlationToken, this.rootTopic, this.endpointId, this.publisher);
|
||||||
msg.onPublishError = this.onPublishError;
|
msg.onPublishError = this.onPublishError;
|
||||||
return msg;
|
return msg;
|
||||||
}
|
}
|
||||||
getStatusMessage(correlationToken, isResponse = false, isDeferred = false) {
|
getStatusMessage(correlationToken, isResponse = false, isDeferred = false) {
|
||||||
const msg = new AlexaStatusMessage_js_1.AlexaStatusMessage(correlationToken, this.rootTopic, this.endpointId, this.mqttClient, isResponse, isDeferred);
|
const msg = new AlexaStatusMessage_js_1.AlexaStatusMessage(correlationToken, this.rootTopic, this.endpointId, this.publisher, isResponse, isDeferred);
|
||||||
msg.onPublishError = this.onPublishError;
|
msg.onPublishError = this.onPublishError;
|
||||||
return msg;
|
return msg;
|
||||||
}
|
}
|
||||||
|
|
@ -82,7 +119,7 @@ class Device extends events_1.EventEmitter {
|
||||||
* Without a changed property send() publishes nothing and resolves with "": Alex2MQTT would drop the report.
|
* Without a changed property send() publishes nothing and resolves with "": Alex2MQTT would drop the report.
|
||||||
*/
|
*/
|
||||||
getChangeReport(cause = "PHYSICAL_INTERACTION") {
|
getChangeReport(cause = "PHYSICAL_INTERACTION") {
|
||||||
const msg = new AlexaStatusMessage_js_1.AlexaStatusMessage("", this.rootTopic, this.endpointId, this.mqttClient, false, false, cause);
|
const msg = new AlexaStatusMessage_js_1.AlexaStatusMessage("", this.rootTopic, this.endpointId, this.publisher, false, false, cause);
|
||||||
msg.onPublishError = this.onPublishError;
|
msg.onPublishError = this.onPublishError;
|
||||||
return msg;
|
return msg;
|
||||||
}
|
}
|
||||||
|
|
@ -91,17 +128,9 @@ class Device extends events_1.EventEmitter {
|
||||||
* (1.5.1). Resolves with the topic published to, or "" when the publish failed (never rejects, 1.5.2).
|
* (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) {
|
sendSceneResponse(correlationToken, activated, cause = "VOICE_INTERACTION", sendAsync = false) {
|
||||||
const payload = (0, build_js_1.sceneEvent)({ endpointId: this.endpointId, correlationToken, activated, cause });
|
const answer = sendAsync ? topics.deferred : topics.response;
|
||||||
const topic = `${this.rootTopic}/${this.endpointId}/${sendAsync ? "deferredResponse" : "alexaResponce"}`;
|
const build = () => (0, build_js_1.sceneEvent)({ endpointId: this.endpointId, correlationToken, activated, cause });
|
||||||
return new Promise((resolve) => {
|
return (0, transport_js_1.send)(this.publisher, answer(this.rootTopic, this.endpointId), build, this.onPublishError);
|
||||||
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
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
/** The capabilities the device declared, in the order it declared them. */
|
/** The capabilities the device declared, in the order it declared them. */
|
||||||
getCapabilities() {
|
getCapabilities() {
|
||||||
|
|
|
||||||
6
dist/cjs/index.js
vendored
6
dist/cjs/index.js
vendored
|
|
@ -36,7 +36,7 @@ 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.AlexaStatusMessage = exports.ThermostatMode = exports.TemperatureSensorScale = exports.PowerState = exports.DisplayCategory = exports.AlexaInterfaceType = exports.AlexaErrorType = exports.AlexaActions = exports.ActionMapping = exports.AlexaInterface = exports.property = exports.StateBuilder = exports.MessageError = exports.AlexaErrors = exports.AlexaError = exports.messages = exports.DisplayCategories = exports.States = exports.Actions = exports.Units = exports.Assets = exports.SemanticsBuilder = exports.semantics = exports.text = exports.asset = exports.EndpointHealth = exports.PowerController = exports.ToggleController = exports.TemperatureSensor = exports.RangeController = exports.ModeController = exports.BrightnessController = exports.Alexa = exports.SchemaError = exports.DeclarationError = exports.registry = exports.Capability = exports.Device = exports.DEFAULT_HOST = exports.Alex2MQTT = void 0;
|
exports.AlexaErrorResponse = exports.AlexaStatusMessage = exports.ThermostatMode = exports.TemperatureSensorScale = exports.PowerState = exports.DisplayCategory = exports.AlexaInterfaceType = exports.AlexaErrorType = exports.AlexaActions = exports.ActionMapping = exports.AlexaInterface = exports.MemoryPublisher = exports.topics = exports.property = exports.StateBuilder = exports.MessageError = exports.AlexaErrors = exports.AlexaError = exports.messages = exports.DisplayCategories = exports.States = exports.Actions = exports.Units = exports.Assets = exports.SemanticsBuilder = exports.semantics = exports.text = exports.asset = exports.EndpointHealth = exports.PowerController = exports.ToggleController = exports.TemperatureSensor = exports.RangeController = exports.ModeController = exports.BrightnessController = exports.Alexa = exports.SchemaError = exports.DeclarationError = exports.registry = exports.Capability = 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.
|
// 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");
|
var Alex2Node_js_1 = require("./Alex2Node.js");
|
||||||
Object.defineProperty(exports, "Alex2MQTT", { enumerable: true, get: function () { return __importDefault(Alex2Node_js_1).default; } });
|
Object.defineProperty(exports, "Alex2MQTT", { enumerable: true, get: function () { return __importDefault(Alex2Node_js_1).default; } });
|
||||||
|
|
@ -80,6 +80,10 @@ Object.defineProperty(exports, "AlexaErrors", { enumerable: true, get: function
|
||||||
Object.defineProperty(exports, "MessageError", { enumerable: true, get: function () { return index_js_5.MessageError; } });
|
Object.defineProperty(exports, "MessageError", { enumerable: true, get: function () { return index_js_5.MessageError; } });
|
||||||
Object.defineProperty(exports, "StateBuilder", { enumerable: true, get: function () { return index_js_5.StateBuilder; } });
|
Object.defineProperty(exports, "StateBuilder", { enumerable: true, get: function () { return index_js_5.StateBuilder; } });
|
||||||
Object.defineProperty(exports, "property", { enumerable: true, get: function () { return index_js_5.property; } });
|
Object.defineProperty(exports, "property", { enumerable: true, get: function () { return index_js_5.property; } });
|
||||||
|
// Publishing: the topics of the Alex2MQTT contract, and a publisher that needs no broker for tests
|
||||||
|
exports.topics = __importStar(require("./topics.js"));
|
||||||
|
var transport_js_1 = require("./transport.js");
|
||||||
|
Object.defineProperty(exports, "MemoryPublisher", { enumerable: true, get: function () { return transport_js_1.MemoryPublisher; } });
|
||||||
// 1.x
|
// 1.x
|
||||||
var AlexaInterface_js_1 = require("./compat/AlexaInterface.js");
|
var AlexaInterface_js_1 = require("./compat/AlexaInterface.js");
|
||||||
Object.defineProperty(exports, "AlexaInterface", { enumerable: true, get: function () { return AlexaInterface_js_1.AlexaInterface; } });
|
Object.defineProperty(exports, "AlexaInterface", { enumerable: true, get: function () { return AlexaInterface_js_1.AlexaInterface; } });
|
||||||
|
|
|
||||||
17
dist/cjs/topics.js
vendored
Normal file
17
dist/cjs/topics.js
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
"use strict";
|
||||||
|
// The topics of the Alex2MQTT contract, named once. The backend and Alex2ESP use the same names, so none of them
|
||||||
|
// can change here alone.
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.changeReport = exports.deferred = exports.response = exports.discoverReply = void 0;
|
||||||
|
/** Where the bridge answers a discovery request with its endpoints. */
|
||||||
|
const discoverReply = (root) => `${root}/discover_r`;
|
||||||
|
exports.discoverReply = discoverReply;
|
||||||
|
/** Where the answer to a directive goes. "alexaResponce" is how the backend and Alex2ESP spell it. */
|
||||||
|
const response = (root, endpointId) => `${root}/${endpointId}/alexaResponce`;
|
||||||
|
exports.response = response;
|
||||||
|
/** Where the answer goes once a DeferredResponse was sent for the directive. */
|
||||||
|
const deferred = (root, endpointId) => `${root}/${endpointId}/deferredResponse`;
|
||||||
|
exports.deferred = deferred;
|
||||||
|
/** Where every ChangeReport of the root goes: the backend adds the user's token and posts it to Alexa. */
|
||||||
|
const changeReport = (root) => `${root}/changeReport`;
|
||||||
|
exports.changeReport = changeReport;
|
||||||
83
dist/cjs/transport.js
vendored
Normal file
83
dist/cjs/transport.js
vendored
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.MemoryPublisher = exports.MqttPublisher = void 0;
|
||||||
|
exports.send = send;
|
||||||
|
const asError = (err) => (err instanceof Error ? err : new Error(String(err)));
|
||||||
|
/**
|
||||||
|
* Publishes to the broker through the client the bridge has at the time: a new one after disconnect() and connect(),
|
||||||
|
* none before connect() and after disconnect(). Without a client the publish is refused. With a client that lost
|
||||||
|
* its connection mqtt.js keeps the message and sends it after the reconnect, and the publish resolves then.
|
||||||
|
*/
|
||||||
|
class MqttPublisher {
|
||||||
|
constructor(client) {
|
||||||
|
this.client = client;
|
||||||
|
}
|
||||||
|
publish(topic, message) {
|
||||||
|
const client = this.client();
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const failed = (err) => resolve({ ok: false, topic, error: asError(err) });
|
||||||
|
if (!client) {
|
||||||
|
failed(new Error(`nothing was published to ${topic}: the bridge is not connected, call connect() first`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
client.publish(topic, JSON.stringify(message), (err) => (err ? failed(err) : resolve({ ok: true, topic })));
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
failed(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.MqttPublisher = MqttPublisher;
|
||||||
|
/**
|
||||||
|
* Keeps what it is asked to publish, for tests and dry runs without a broker:
|
||||||
|
*
|
||||||
|
* const sent = new MemoryPublisher();
|
||||||
|
* device.publisher = sent;
|
||||||
|
* await device.getStatusMessage(token, true).addPowerControllerProp(PowerController.ON).send();
|
||||||
|
* sent.published[0] // { topic: "<root>/<endpointId>/alexaResponce", message: { event, context } }
|
||||||
|
*/
|
||||||
|
class MemoryPublisher {
|
||||||
|
constructor() {
|
||||||
|
/** Oldest first. The message is what a subscriber gets after JSON.parse. */
|
||||||
|
this.published = [];
|
||||||
|
/** Set it and every publish fails with this error, as a publish to a broker that is gone does. */
|
||||||
|
this.failWith = null;
|
||||||
|
}
|
||||||
|
publish(topic, message) {
|
||||||
|
if (this.failWith)
|
||||||
|
return Promise.resolve({ ok: false, topic, error: this.failWith });
|
||||||
|
try {
|
||||||
|
this.published.push({ topic, message: JSON.parse(JSON.stringify(message)) });
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
return Promise.resolve({ ok: false, topic, error: asError(err) });
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ok: true, topic });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.MemoryPublisher = MemoryPublisher;
|
||||||
|
/**
|
||||||
|
* send() as 1.x promises it: resolves with the topic, or with "" when nothing was published, and never rejects
|
||||||
|
* (1.5.1 rejected, and a send() nobody caught killed the host on any broker hiccup). What went wrong goes to report:
|
||||||
|
* the message cannot be built, the device is on no bridge, or the publish failed.
|
||||||
|
*/
|
||||||
|
async function send(publisher, topic, build, report) {
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
const message = build();
|
||||||
|
if (!publisher) {
|
||||||
|
throw new Error(`nothing was published to ${topic}: the device is on no bridge, register it with addDevice() or registerDevice()`);
|
||||||
|
}
|
||||||
|
result = await publisher.publish(topic, message);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
result = { ok: false, topic, error: asError(err) };
|
||||||
|
}
|
||||||
|
if (result.ok)
|
||||||
|
return topic;
|
||||||
|
if (report)
|
||||||
|
report(result.error);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
5
dist/esm/Alex2Node.d.ts
vendored
5
dist/esm/Alex2Node.d.ts
vendored
|
|
@ -38,6 +38,7 @@ declare class Alex2MQTT extends EventEmitter {
|
||||||
private rootTopic;
|
private rootTopic;
|
||||||
private debugLogging;
|
private debugLogging;
|
||||||
private client;
|
private client;
|
||||||
|
private readonly publisher;
|
||||||
private devices;
|
private devices;
|
||||||
private MqttHost;
|
private MqttHost;
|
||||||
private options;
|
private options;
|
||||||
|
|
@ -62,7 +63,8 @@ declare class Alex2MQTT extends EventEmitter {
|
||||||
*
|
*
|
||||||
* Throws a DeclarationError when Alexa would reject the endpoint (an endpointId with a slash, a name with
|
* Throws a DeclarationError when Alexa would reject the endpoint (an endpointId with a slash, a name with
|
||||||
* punctuation) or when the endpointId is registered already. Discovery lists Alexa.EndpointHealth for the device
|
* punctuation) or when the endpointId is registered already. Discovery lists Alexa.EndpointHealth for the device
|
||||||
* unless endpointHealth is false.
|
* unless endpointHealth is false. A device can be declared before connect(): what it sends before the bridge has
|
||||||
|
* a connection is not published, and the "error" event says so.
|
||||||
*/
|
*/
|
||||||
addDevice(definition: EndpointDefinition): Device;
|
addDevice(definition: EndpointDefinition): Device;
|
||||||
registerDevice(name: string, endpointId: string, displayCategory?: DisplayCategory | DisplayCategory[] | null): Device;
|
registerDevice(name: string, endpointId: string, displayCategory?: DisplayCategory | DisplayCategory[] | null): Device;
|
||||||
|
|
@ -71,6 +73,7 @@ declare class Alex2MQTT extends EventEmitter {
|
||||||
unregisterDevice(endpointId: string): boolean;
|
unregisterDevice(endpointId: string): boolean;
|
||||||
/** Forget every device. */
|
/** Forget every device. */
|
||||||
clearDevices(): void;
|
clearDevices(): void;
|
||||||
|
private release;
|
||||||
getDevices(): Device[];
|
getDevices(): Device[];
|
||||||
getDevice(endpointId: string): Device | undefined;
|
getDevice(endpointId: string): Device | undefined;
|
||||||
getRootTopic(): string;
|
getRootTopic(): string;
|
||||||
|
|
|
||||||
37
dist/esm/Alex2Node.js
vendored
37
dist/esm/Alex2Node.js
vendored
|
|
@ -5,6 +5,8 @@ import { checkEndpoint } from "./device/validate.js";
|
||||||
import { EventEmitter } from "events";
|
import { EventEmitter } from "events";
|
||||||
import { DisplayCategory } from "./compat/enums.js";
|
import { DisplayCategory } from "./compat/enums.js";
|
||||||
import { DeclarationError } from "./registry/types.js";
|
import { DeclarationError } from "./registry/types.js";
|
||||||
|
import * as topics from "./topics.js";
|
||||||
|
import { MqttPublisher } from "./transport.js";
|
||||||
export const DEFAULT_HOST = "mqtt://Alex2MQTT.stormysdream.club:1883";
|
export const DEFAULT_HOST = "mqtt://Alex2MQTT.stormysdream.club:1883";
|
||||||
// What addDevice() copies from the definition to the device
|
// What addDevice() copies from the definition to the device
|
||||||
const DESCRIBED = [
|
const DESCRIBED = [
|
||||||
|
|
@ -33,6 +35,8 @@ class Alex2MQTT extends EventEmitter {
|
||||||
this.rootTopic = rootTopic;
|
this.rootTopic = rootTopic;
|
||||||
this.debugLogging = debugLogging;
|
this.debugLogging = debugLogging;
|
||||||
this.client = null;
|
this.client = null;
|
||||||
|
// One for the life of the bridge: the devices keep it over disconnect() and connect()
|
||||||
|
this.publisher = new MqttPublisher(() => this.client);
|
||||||
this.devices = [];
|
this.devices = [];
|
||||||
// The lines of check() that were logged. Discovery comes every few minutes: a line is logged once.
|
// The lines of check() that were logged. Discovery comes every few minutes: a line is logged once.
|
||||||
this.logged = new Set();
|
this.logged = new Set();
|
||||||
|
|
@ -70,8 +74,6 @@ class Alex2MQTT extends EventEmitter {
|
||||||
...(this.options.mqtt || {}),
|
...(this.options.mqtt || {}),
|
||||||
};
|
};
|
||||||
this.client = mqtt.connect(this.MqttHost, options);
|
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.client.on("connect", () => {
|
||||||
this.connected = true;
|
this.connected = true;
|
||||||
this.log("Connected to MQTT broker");
|
this.log("Connected to MQTT broker");
|
||||||
|
|
@ -94,12 +96,12 @@ class Alex2MQTT extends EventEmitter {
|
||||||
this.log("Discovery request received, getting device json...");
|
this.log("Discovery request received, getting device json...");
|
||||||
const deviceArray = this.describeDevices();
|
const deviceArray = this.describeDevices();
|
||||||
this.lastDiscoveryAt = new Date().toISOString();
|
this.lastDiscoveryAt = new Date().toISOString();
|
||||||
this.client.publish(topic + "_r", JSON.stringify(deviceArray), (err) => {
|
void this.publisher.publish(topics.discoverReply(this.rootTopic), deviceArray).then((result) => {
|
||||||
if (err) {
|
if (!result.ok) {
|
||||||
this.fail(err);
|
this.fail(result.error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.log(`Discovery payloads published to ${topic + "_r"}`, deviceArray);
|
this.log(`Discovery payloads published to ${result.topic}`, deviceArray);
|
||||||
this.emit("discover", deviceArray.length);
|
this.emit("discover", deviceArray.length);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -173,15 +175,13 @@ class Alex2MQTT extends EventEmitter {
|
||||||
*
|
*
|
||||||
* Throws a DeclarationError when Alexa would reject the endpoint (an endpointId with a slash, a name with
|
* Throws a DeclarationError when Alexa would reject the endpoint (an endpointId with a slash, a name with
|
||||||
* punctuation) or when the endpointId is registered already. Discovery lists Alexa.EndpointHealth for the device
|
* punctuation) or when the endpointId is registered already. Discovery lists Alexa.EndpointHealth for the device
|
||||||
* unless endpointHealth is false.
|
* unless endpointHealth is false. A device can be declared before connect(): what it sends before the bridge has
|
||||||
|
* a connection is not published, and the "error" event says so.
|
||||||
*/
|
*/
|
||||||
addDevice(definition) {
|
addDevice(definition) {
|
||||||
if (!this.client) {
|
|
||||||
throw new Error("Must call connect before creating devices");
|
|
||||||
}
|
|
||||||
const { endpointId, name, categories, endpointHealth, ...described } = definition;
|
const { endpointId, name, categories, endpointHealth, ...described } = definition;
|
||||||
// Without categories the device would take LIGHT, the default of registerDevice: here it is a mistake
|
// Without categories the device would take LIGHT, the default of registerDevice: here it is a mistake
|
||||||
const device = new Device(this.client, this.rootTopic, name, endpointId, (categories ?? []));
|
const device = new Device(null, this.rootTopic, name, endpointId, (categories ?? []));
|
||||||
for (const [field, value] of Object.entries(described)) {
|
for (const [field, value] of Object.entries(described)) {
|
||||||
if (!DESCRIBED.includes(field))
|
if (!DESCRIBED.includes(field))
|
||||||
throw new DeclarationError({ endpointId }, `${field} is not a field of an endpoint`);
|
throw new DeclarationError({ endpointId }, `${field} is not a field of an endpoint`);
|
||||||
|
|
@ -196,9 +196,6 @@ class Alex2MQTT extends EventEmitter {
|
||||||
return device;
|
return device;
|
||||||
}
|
}
|
||||||
registerDevice(name, endpointId, displayCategory) {
|
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);
|
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
|
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`;
|
const warning = `warning: registerDevice("${endpointId}") is already registered as "${existing.name}", returning that device`;
|
||||||
|
|
@ -210,7 +207,7 @@ class Alex2MQTT extends EventEmitter {
|
||||||
}
|
}
|
||||||
this.log(`Creating new device with endpoint: ${endpointId}`);
|
this.log(`Creating new device with endpoint: ${endpointId}`);
|
||||||
const normalizedCategory = Array.isArray(displayCategory) ? displayCategory : [displayCategory || DisplayCategory.LIGHT];
|
const normalizedCategory = Array.isArray(displayCategory) ? displayCategory : [displayCategory || DisplayCategory.LIGHT];
|
||||||
const device = new Device(this.client, this.rootTopic, name, endpointId, normalizedCategory);
|
const device = new Device(null, this.rootTopic, name, endpointId, normalizedCategory);
|
||||||
this.register(device, false);
|
this.register(device, false);
|
||||||
return device;
|
return device;
|
||||||
}
|
}
|
||||||
|
|
@ -218,6 +215,7 @@ class Alex2MQTT extends EventEmitter {
|
||||||
device.alexaInterface = this.options.alexaInterface !== false;
|
device.alexaInterface = this.options.alexaInterface !== false;
|
||||||
device.endpointHealth = endpointHealth;
|
device.endpointHealth = endpointHealth;
|
||||||
device.onPublishError = (err) => this.fail(err); // a failed publish is an "error" event (when listened to), never a rejected send()
|
device.onPublishError = (err) => this.fail(err); // a failed publish is an "error" event (when listened to), never a rejected send()
|
||||||
|
device.publisher = this.publisher;
|
||||||
this.devices.push(device);
|
this.devices.push(device);
|
||||||
}
|
}
|
||||||
/** Forget a device (its listeners with it). Returns false when there was none. */
|
/** Forget a device (its listeners with it). Returns false when there was none. */
|
||||||
|
|
@ -225,16 +223,21 @@ class Alex2MQTT extends EventEmitter {
|
||||||
const i = this.devices.findIndex((d) => d.endpointId === endpointId);
|
const i = this.devices.findIndex((d) => d.endpointId === endpointId);
|
||||||
if (i === -1)
|
if (i === -1)
|
||||||
return false;
|
return false;
|
||||||
this.devices[i].removeAllListeners();
|
this.release(this.devices[i]);
|
||||||
this.devices.splice(i, 1);
|
this.devices.splice(i, 1);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
/** Forget every device. */
|
/** Forget every device. */
|
||||||
clearDevices() {
|
clearDevices() {
|
||||||
for (const d of this.devices)
|
for (const d of this.devices)
|
||||||
d.removeAllListeners();
|
this.release(d);
|
||||||
this.devices = [];
|
this.devices = [];
|
||||||
}
|
}
|
||||||
|
// A device the bridge forgot hears no directive and publishes nothing
|
||||||
|
release(device) {
|
||||||
|
device.removeAllListeners();
|
||||||
|
device.publisher = null;
|
||||||
|
}
|
||||||
getDevices() {
|
getDevices() {
|
||||||
return this.devices.slice();
|
return this.devices.slice();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
6
dist/esm/compat/AlexaErrorResponse.d.ts
vendored
6
dist/esm/compat/AlexaErrorResponse.d.ts
vendored
|
|
@ -1,17 +1,17 @@
|
||||||
import type { MqttClient } from "mqtt";
|
|
||||||
import type { ErrorResponseMessage } from "../messages/types.js";
|
import type { ErrorResponseMessage } from "../messages/types.js";
|
||||||
|
import type { Publisher } from "../transport.js";
|
||||||
/** An ErrorResponse as 1.x builds it: device.getErrorMessage(token), setErrorMessage(), send(). */
|
/** An ErrorResponse as 1.x builds it: device.getErrorMessage(token), setErrorMessage(), send(). */
|
||||||
export declare class AlexaErrorResponse {
|
export declare class AlexaErrorResponse {
|
||||||
private readonly correlationToken;
|
private readonly correlationToken;
|
||||||
private readonly rootTopic;
|
private readonly rootTopic;
|
||||||
private readonly endpointId;
|
private readonly endpointId;
|
||||||
private readonly mqttClient;
|
private readonly publisher;
|
||||||
private error;
|
private error;
|
||||||
private namespace?;
|
private namespace?;
|
||||||
private readonly messageId;
|
private readonly messageId;
|
||||||
/** Where a failed publish is reported (set by the Device that built this message, 1.5.2): send() never rejects. */
|
/** Where a failed publish is reported (set by the Device that built this message, 1.5.2): send() never rejects. */
|
||||||
onPublishError?: (err: Error) => void;
|
onPublishError?: (err: Error) => void;
|
||||||
constructor(correlationToken: string, rootTopic: string, endpointId: string, mqttClient: MqttClient);
|
constructor(correlationToken: string, rootTopic: string, endpointId: string, publisher: Publisher | null);
|
||||||
/**
|
/**
|
||||||
* The error to answer with. otherParams are the fields the type adds to the payload (validRange). The header
|
* The error to answer with. otherParams are the fields the type adds to the payload (validRange). The header
|
||||||
* carries the namespace the type is documented under, Alexa.ThermostatController for THERMOSTAT_IS_OFF;
|
* carries the namespace the type is documented under, Alexa.ThermostatController for THERMOSTAT_IS_OFF;
|
||||||
|
|
|
||||||
18
dist/esm/compat/AlexaErrorResponse.js
vendored
18
dist/esm/compat/AlexaErrorResponse.js
vendored
|
|
@ -1,13 +1,15 @@
|
||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
import { errorResponse } from "../messages/build.js";
|
import { errorResponse } from "../messages/build.js";
|
||||||
import { AlexaErrors } from "../messages/errors.js";
|
import { AlexaErrors } from "../messages/errors.js";
|
||||||
|
import * as topics from "../topics.js";
|
||||||
|
import { send } from "../transport.js";
|
||||||
/** An ErrorResponse as 1.x builds it: device.getErrorMessage(token), setErrorMessage(), send(). */
|
/** An ErrorResponse as 1.x builds it: device.getErrorMessage(token), setErrorMessage(), send(). */
|
||||||
export class AlexaErrorResponse {
|
export class AlexaErrorResponse {
|
||||||
constructor(correlationToken, rootTopic, endpointId, mqttClient) {
|
constructor(correlationToken, rootTopic, endpointId, publisher) {
|
||||||
this.correlationToken = correlationToken;
|
this.correlationToken = correlationToken;
|
||||||
this.rootTopic = rootTopic;
|
this.rootTopic = rootTopic;
|
||||||
this.endpointId = endpointId;
|
this.endpointId = endpointId;
|
||||||
this.mqttClient = mqttClient;
|
this.publisher = publisher;
|
||||||
// Sent when setErrorMessage() was not called: 1.x published an empty payload, which is no ErrorResponse to Alexa
|
// Sent when setErrorMessage() was not called: 1.x published an empty payload, which is no ErrorResponse to Alexa
|
||||||
this.error = AlexaErrors.of("INTERNAL_ERROR", "the device answered with an error and did not say which");
|
this.error = AlexaErrors.of("INTERNAL_ERROR", "the device answered with an error and did not say which");
|
||||||
// One id for the message, however often toJSON() is called
|
// One id for the message, however often toJSON() is called
|
||||||
|
|
@ -36,15 +38,7 @@ export class AlexaErrorResponse {
|
||||||
}
|
}
|
||||||
/** Resolves with the topic published to, or "" when the publish failed. Never rejects. */
|
/** Resolves with the topic published to, or "" when the publish failed. Never rejects. */
|
||||||
send(sendAsync = false) {
|
send(sendAsync = false) {
|
||||||
const topic = `${this.rootTopic}/${this.endpointId}/${sendAsync ? "deferredResponse" : "alexaResponce"}`; // "alexaResponce" is how Alex2MQTT and Alex2ESP spell the topic
|
const answer = sendAsync ? topics.deferred : topics.response;
|
||||||
return new Promise((resolve) => {
|
return send(this.publisher, answer(this.rootTopic, this.endpointId), () => this.toJSON(), this.onPublishError);
|
||||||
this.mqttClient.publish(topic, JSON.stringify(this.toJSON()), (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
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
6
dist/esm/compat/AlexaStatusMessage.d.ts
vendored
6
dist/esm/compat/AlexaStatusMessage.d.ts
vendored
|
|
@ -1,5 +1,5 @@
|
||||||
import type { MqttClient } from "mqtt";
|
|
||||||
import type { ChangeCause, Property } from "../messages/types.js";
|
import type { ChangeCause, Property } from "../messages/types.js";
|
||||||
|
import type { Publisher } from "../transport.js";
|
||||||
import { TemperatureSensorScale } from "./enums.js";
|
import { TemperatureSensorScale } from "./enums.js";
|
||||||
import type { EndpointHealth, PowerController } from "./enums.js";
|
import type { EndpointHealth, PowerController } from "./enums.js";
|
||||||
/**
|
/**
|
||||||
|
|
@ -10,7 +10,7 @@ export declare class AlexaStatusMessage {
|
||||||
private readonly correlationToken;
|
private readonly correlationToken;
|
||||||
private readonly rootTopic;
|
private readonly rootTopic;
|
||||||
private readonly endpointId;
|
private readonly endpointId;
|
||||||
private readonly mqttClient;
|
private readonly publisher;
|
||||||
private readonly isResponse;
|
private readonly isResponse;
|
||||||
private readonly isDeferred;
|
private readonly isDeferred;
|
||||||
private readonly changeCause;
|
private readonly changeCause;
|
||||||
|
|
@ -19,7 +19,7 @@ export declare class AlexaStatusMessage {
|
||||||
private estimatedDeferralInSeconds?;
|
private estimatedDeferralInSeconds?;
|
||||||
/** Where a failed publish is reported (set by the Device that built this message, 1.5.2): send() never rejects. */
|
/** Where a failed publish is reported (set by the Device that built this message, 1.5.2): send() never rejects. */
|
||||||
onPublishError?: (err: Error) => void;
|
onPublishError?: (err: Error) => void;
|
||||||
constructor(correlationToken: string, rootTopic: string, endpointId: string, mqttClient: MqttClient, isResponse?: boolean, isDeferred?: boolean, changeCause?: ChangeCause | null);
|
constructor(correlationToken: string, rootTopic: string, endpointId: string, publisher: Publisher | null, isResponse?: boolean, isDeferred?: boolean, changeCause?: ChangeCause | null);
|
||||||
private addProperty;
|
private addProperty;
|
||||||
/** ChangeReport: the add*Prop calls that follow describe what CHANGED (the default for a change report). */
|
/** ChangeReport: the add*Prop calls that follow describe what CHANGED (the default for a change report). */
|
||||||
changed(): this;
|
changed(): this;
|
||||||
|
|
|
||||||
27
dist/esm/compat/AlexaStatusMessage.js
vendored
27
dist/esm/compat/AlexaStatusMessage.js
vendored
|
|
@ -1,6 +1,8 @@
|
||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
import { changeReport, deferredResponse, response } from "../messages/build.js";
|
import { changeReport, deferredResponse, response } from "../messages/build.js";
|
||||||
import { StateBuilder } from "../messages/StateBuilder.js";
|
import { StateBuilder } from "../messages/StateBuilder.js";
|
||||||
|
import * as topics from "../topics.js";
|
||||||
|
import { send } from "../transport.js";
|
||||||
import { AlexaInterfaceType, TemperatureSensorScale } from "./enums.js";
|
import { AlexaInterfaceType, TemperatureSensorScale } from "./enums.js";
|
||||||
// The 1.x helpers report every temperature in Celsius, whatever scale the device works in
|
// The 1.x helpers report every temperature in Celsius, whatever scale the device works in
|
||||||
function celsius(scale, value) {
|
function celsius(scale, value) {
|
||||||
|
|
@ -11,11 +13,11 @@ function celsius(scale, value) {
|
||||||
* device.getChangeReport(), the add...Prop() calls, send(). The values are not checked.
|
* device.getChangeReport(), the add...Prop() calls, send(). The values are not checked.
|
||||||
*/
|
*/
|
||||||
export class AlexaStatusMessage {
|
export class AlexaStatusMessage {
|
||||||
constructor(correlationToken, rootTopic, endpointId, mqttClient, isResponse = false, isDeferred = false, changeCause = null) {
|
constructor(correlationToken, rootTopic, endpointId, publisher, isResponse = false, isDeferred = false, changeCause = null) {
|
||||||
this.correlationToken = correlationToken;
|
this.correlationToken = correlationToken;
|
||||||
this.rootTopic = rootTopic;
|
this.rootTopic = rootTopic;
|
||||||
this.endpointId = endpointId;
|
this.endpointId = endpointId;
|
||||||
this.mqttClient = mqttClient;
|
this.publisher = publisher;
|
||||||
this.isResponse = isResponse;
|
this.isResponse = isResponse;
|
||||||
this.isDeferred = isDeferred;
|
this.isDeferred = isDeferred;
|
||||||
this.changeCause = changeCause;
|
this.changeCause = changeCause;
|
||||||
|
|
@ -106,23 +108,8 @@ export class AlexaStatusMessage {
|
||||||
* to). Never rejects (1.5.1 did, so an un-caught send() could kill the host).
|
* to). Never rejects (1.5.1 did, so an un-caught send() could kill the host).
|
||||||
*/
|
*/
|
||||||
send(sendAsync = false) {
|
send(sendAsync = false) {
|
||||||
const topic = this.changeCause
|
const answer = sendAsync ? topics.deferred : topics.response;
|
||||||
? `${this.rootTopic}/changeReport`
|
const topic = this.changeCause ? topics.changeReport(this.rootTopic) : answer(this.rootTopic, this.endpointId);
|
||||||
: `${this.rootTopic}/${this.endpointId}/${sendAsync ? "deferredResponse" : "alexaResponce"}`; // "alexaResponce" is how Alex2MQTT and Alex2ESP spell the topic
|
return send(this.publisher, topic, () => this.toJSON(), this.onPublishError);
|
||||||
return new Promise((resolve) => {
|
|
||||||
const failed = (err) => {
|
|
||||||
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
|
|
||||||
};
|
|
||||||
let payload;
|
|
||||||
try {
|
|
||||||
payload = JSON.stringify(this.toJSON());
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
return failed(err);
|
|
||||||
}
|
|
||||||
this.mqttClient.publish(topic, payload, (err) => (err ? failed(err) : resolve(topic)));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
14
dist/esm/device/Device.d.ts
vendored
14
dist/esm/device/Device.d.ts
vendored
|
|
@ -1,5 +1,4 @@
|
||||||
import { EventEmitter } from "events";
|
import { EventEmitter } from "events";
|
||||||
import type { MqttClient } from "mqtt";
|
|
||||||
import { AlexaErrorResponse } from "../compat/AlexaErrorResponse.js";
|
import { AlexaErrorResponse } from "../compat/AlexaErrorResponse.js";
|
||||||
import { AlexaInterface } from "../compat/AlexaInterface.js";
|
import { AlexaInterface } from "../compat/AlexaInterface.js";
|
||||||
import { AlexaStatusMessage } from "../compat/AlexaStatusMessage.js";
|
import { AlexaStatusMessage } from "../compat/AlexaStatusMessage.js";
|
||||||
|
|
@ -8,6 +7,7 @@ import type { AlexaInterfaceType } from "../compat/enums.js";
|
||||||
import type { ChangeCause } from "../messages/types.js";
|
import type { ChangeCause } from "../messages/types.js";
|
||||||
import type { DisplayCategoryName } from "../registry/catalog.js";
|
import type { DisplayCategoryName } from "../registry/catalog.js";
|
||||||
import type { Directives, InterfaceDescriptor, Properties } from "../registry/types.js";
|
import type { Directives, InterfaceDescriptor, Properties } from "../registry/types.js";
|
||||||
|
import type { Publisher } from "../transport.js";
|
||||||
import { Capability } from "./Capability.js";
|
import { Capability } from "./Capability.js";
|
||||||
import type { AnyCapability, CapabilityJson, Declaration } from "./Capability.js";
|
import type { AnyCapability, CapabilityJson, Declaration } from "./Capability.js";
|
||||||
import type { EndpointFields } from "./validate.js";
|
import type { EndpointFields } from "./validate.js";
|
||||||
|
|
@ -40,7 +40,6 @@ export interface EndpointJson extends EndpointFields {
|
||||||
}
|
}
|
||||||
type DeclarationArguments<O, I extends boolean> = {} extends Declaration<O, I> ? [options?: Declaration<O, I>] : [options: Declaration<O, I>];
|
type DeclarationArguments<O, I extends boolean> = {} extends Declaration<O, I> ? [options?: Declaration<O, I>] : [options: Declaration<O, I>];
|
||||||
declare class Device extends EventEmitter {
|
declare class Device extends EventEmitter {
|
||||||
private mqttClient;
|
|
||||||
private rootTopic;
|
private rootTopic;
|
||||||
name: string;
|
name: string;
|
||||||
endpointId: string;
|
endpointId: string;
|
||||||
|
|
@ -67,9 +66,14 @@ declare class Device extends EventEmitter {
|
||||||
private capabilities;
|
private capabilities;
|
||||||
/** Where a failed publish from this device or a message it built is reported; Alex2MQTT sets it (1.5.2). */
|
/** Where a failed publish from this device or a message it built is reported; Alex2MQTT sets it (1.5.2). */
|
||||||
onPublishError?: (err: Error) => void;
|
onPublishError?: (err: Error) => void;
|
||||||
constructor(mqttClient: MqttClient, rootTopic: string, name: string, endpointId: string, displayCategory: Array<DisplayCategory> | null, description?: string, manufacturerName?: string, manufacturer?: string, model?: string);
|
/**
|
||||||
/** Internal (1.5.2): Alex2MQTT.connect() re-binds every registered device to its new broker client after a disconnect(). */
|
* What the device and the messages it builds publish through. The bridge sets it when the device is registered
|
||||||
setMqttClient(client: MqttClient): void;
|
* and clears it when the device is unregistered; a MemoryPublisher here tests a device without a broker. While it
|
||||||
|
* is null every send() resolves "" and reports why.
|
||||||
|
*/
|
||||||
|
publisher: Publisher | null;
|
||||||
|
/** client is ignored: in 1.x it was the broker client, and a device could only be built after connect(). */
|
||||||
|
constructor(client: unknown, rootTopic: string, name: string, endpointId: string, displayCategory: Array<DisplayCategory> | null, description?: string, manufacturerName?: string, manufacturer?: string, model?: string);
|
||||||
getName(): string;
|
getName(): string;
|
||||||
setName(name: string): void;
|
setName(name: string): void;
|
||||||
getEndpointId(): string;
|
getEndpointId(): string;
|
||||||
|
|
|
||||||
36
dist/esm/device/Device.js
vendored
36
dist/esm/device/Device.js
vendored
|
|
@ -8,12 +8,14 @@ import { Alexa } from "../registry/interfaces/Alexa.js";
|
||||||
import { EndpointHealth } from "../registry/interfaces/EndpointHealth.js";
|
import { EndpointHealth } from "../registry/interfaces/EndpointHealth.js";
|
||||||
import { SchemaError } from "../registry/schema.js";
|
import { SchemaError } from "../registry/schema.js";
|
||||||
import { DeclarationError } from "../registry/types.js";
|
import { DeclarationError } from "../registry/types.js";
|
||||||
|
import * as topics from "../topics.js";
|
||||||
|
import { send } from "../transport.js";
|
||||||
import { Capability, commonOptions } from "./Capability.js";
|
import { Capability, commonOptions } from "./Capability.js";
|
||||||
import { checkCapability, checkCapabilityCount, checkEndpoint } from "./validate.js";
|
import { checkCapability, checkCapabilityCount, checkEndpoint } from "./validate.js";
|
||||||
class Device extends EventEmitter {
|
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") {
|
/** client is ignored: in 1.x it was the broker client, and a device could only be built after connect(). */
|
||||||
|
constructor(client, rootTopic, name, endpointId, displayCategory, description = "Alexa to Node.js bridge", manufacturerName = "Alex2Node", manufacturer = "Alex2Node", model = "Alex2Node_v1.0.0") {
|
||||||
super();
|
super();
|
||||||
this.mqttClient = mqttClient;
|
|
||||||
this.rootTopic = rootTopic;
|
this.rootTopic = rootTopic;
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.endpointId = endpointId;
|
this.endpointId = endpointId;
|
||||||
|
|
@ -37,10 +39,12 @@ class Device extends EventEmitter {
|
||||||
*/
|
*/
|
||||||
this.endpointHealth = false;
|
this.endpointHealth = false;
|
||||||
this.capabilities = [];
|
this.capabilities = [];
|
||||||
}
|
/**
|
||||||
/** Internal (1.5.2): Alex2MQTT.connect() re-binds every registered device to its new broker client after a disconnect(). */
|
* What the device and the messages it builds publish through. The bridge sets it when the device is registered
|
||||||
setMqttClient(client) {
|
* and clears it when the device is unregistered; a MemoryPublisher here tests a device without a broker. While it
|
||||||
this.mqttClient = client;
|
* is null every send() resolves "" and reports why.
|
||||||
|
*/
|
||||||
|
this.publisher = null;
|
||||||
}
|
}
|
||||||
getName() {
|
getName() {
|
||||||
return this.name;
|
return this.name;
|
||||||
|
|
@ -64,12 +68,12 @@ class Device extends EventEmitter {
|
||||||
return this.description;
|
return this.description;
|
||||||
}
|
}
|
||||||
getErrorMessage(correlationToken) {
|
getErrorMessage(correlationToken) {
|
||||||
const msg = new AlexaErrorResponse(correlationToken, this.rootTopic, this.endpointId, this.mqttClient);
|
const msg = new AlexaErrorResponse(correlationToken, this.rootTopic, this.endpointId, this.publisher);
|
||||||
msg.onPublishError = this.onPublishError;
|
msg.onPublishError = this.onPublishError;
|
||||||
return msg;
|
return msg;
|
||||||
}
|
}
|
||||||
getStatusMessage(correlationToken, isResponse = false, isDeferred = false) {
|
getStatusMessage(correlationToken, isResponse = false, isDeferred = false) {
|
||||||
const msg = new AlexaStatusMessage(correlationToken, this.rootTopic, this.endpointId, this.mqttClient, isResponse, isDeferred);
|
const msg = new AlexaStatusMessage(correlationToken, this.rootTopic, this.endpointId, this.publisher, isResponse, isDeferred);
|
||||||
msg.onPublishError = this.onPublishError;
|
msg.onPublishError = this.onPublishError;
|
||||||
return msg;
|
return msg;
|
||||||
}
|
}
|
||||||
|
|
@ -80,7 +84,7 @@ class Device extends EventEmitter {
|
||||||
* Without a changed property send() publishes nothing and resolves with "": Alex2MQTT would drop the report.
|
* Without a changed property send() publishes nothing and resolves with "": Alex2MQTT would drop the report.
|
||||||
*/
|
*/
|
||||||
getChangeReport(cause = "PHYSICAL_INTERACTION") {
|
getChangeReport(cause = "PHYSICAL_INTERACTION") {
|
||||||
const msg = new AlexaStatusMessage("", this.rootTopic, this.endpointId, this.mqttClient, false, false, cause);
|
const msg = new AlexaStatusMessage("", this.rootTopic, this.endpointId, this.publisher, false, false, cause);
|
||||||
msg.onPublishError = this.onPublishError;
|
msg.onPublishError = this.onPublishError;
|
||||||
return msg;
|
return msg;
|
||||||
}
|
}
|
||||||
|
|
@ -89,17 +93,9 @@ class Device extends EventEmitter {
|
||||||
* (1.5.1). Resolves with the topic published to, or "" when the publish failed (never rejects, 1.5.2).
|
* (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) {
|
sendSceneResponse(correlationToken, activated, cause = "VOICE_INTERACTION", sendAsync = false) {
|
||||||
const payload = sceneEvent({ endpointId: this.endpointId, correlationToken, activated, cause });
|
const answer = sendAsync ? topics.deferred : topics.response;
|
||||||
const topic = `${this.rootTopic}/${this.endpointId}/${sendAsync ? "deferredResponse" : "alexaResponce"}`;
|
const build = () => sceneEvent({ endpointId: this.endpointId, correlationToken, activated, cause });
|
||||||
return new Promise((resolve) => {
|
return send(this.publisher, answer(this.rootTopic, this.endpointId), build, this.onPublishError);
|
||||||
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
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
/** The capabilities the device declared, in the order it declared them. */
|
/** The capabilities the device declared, in the order it declared them. */
|
||||||
getCapabilities() {
|
getCapabilities() {
|
||||||
|
|
|
||||||
3
dist/esm/index.d.ts
vendored
3
dist/esm/index.d.ts
vendored
|
|
@ -13,6 +13,9 @@ export type { ActionId, AssetId, DisplayCategoryName, StateId, UnitOfMeasure, Ac
|
||||||
export * as messages from "./messages/index.js";
|
export * as messages from "./messages/index.js";
|
||||||
export { AlexaError, AlexaErrors, MessageError, StateBuilder, property } from "./messages/index.js";
|
export { AlexaError, AlexaErrors, MessageError, StateBuilder, property } from "./messages/index.js";
|
||||||
export type { ChangeCause, ChangeReportMessage, DeferredResponseMessage, ErrorResponseMessage, Header, ProactiveEventMessage, Property, PropertyOptions, ResponseMessage, SceneEventMessage, } from "./messages/index.js";
|
export type { ChangeCause, ChangeReportMessage, DeferredResponseMessage, ErrorResponseMessage, Header, ProactiveEventMessage, Property, PropertyOptions, ResponseMessage, SceneEventMessage, } from "./messages/index.js";
|
||||||
|
export * as topics from "./topics.js";
|
||||||
|
export { MemoryPublisher } from "./transport.js";
|
||||||
|
export type { Publisher, PublishResult } from "./transport.js";
|
||||||
export { AlexaInterface } from "./compat/AlexaInterface.js";
|
export { AlexaInterface } from "./compat/AlexaInterface.js";
|
||||||
export type { SupportedMode } from "./compat/AlexaInterface.js";
|
export type { SupportedMode } from "./compat/AlexaInterface.js";
|
||||||
export { ActionMapping } from "./compat/ActionMapping.js";
|
export { ActionMapping } from "./compat/ActionMapping.js";
|
||||||
|
|
|
||||||
3
dist/esm/index.js
vendored
3
dist/esm/index.js
vendored
|
|
@ -12,6 +12,9 @@ export { ASSETS as Assets, UNITS_OF_MEASURE as Units, ACTIONS as Actions, STATES
|
||||||
// The messages: what the bridge publishes, built from plain values
|
// The messages: what the bridge publishes, built from plain values
|
||||||
export * as messages from "./messages/index.js";
|
export * as messages from "./messages/index.js";
|
||||||
export { AlexaError, AlexaErrors, MessageError, StateBuilder, property } from "./messages/index.js";
|
export { AlexaError, AlexaErrors, MessageError, StateBuilder, property } from "./messages/index.js";
|
||||||
|
// Publishing: the topics of the Alex2MQTT contract, and a publisher that needs no broker for tests
|
||||||
|
export * as topics from "./topics.js";
|
||||||
|
export { MemoryPublisher } from "./transport.js";
|
||||||
// 1.x
|
// 1.x
|
||||||
export { AlexaInterface } from "./compat/AlexaInterface.js";
|
export { AlexaInterface } from "./compat/AlexaInterface.js";
|
||||||
export { ActionMapping } from "./compat/ActionMapping.js";
|
export { ActionMapping } from "./compat/ActionMapping.js";
|
||||||
|
|
|
||||||
8
dist/esm/topics.d.ts
vendored
Normal file
8
dist/esm/topics.d.ts
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
/** Where the bridge answers a discovery request with its endpoints. */
|
||||||
|
export declare const discoverReply: (root: string) => string;
|
||||||
|
/** Where the answer to a directive goes. "alexaResponce" is how the backend and Alex2ESP spell it. */
|
||||||
|
export declare const response: (root: string, endpointId: string) => string;
|
||||||
|
/** Where the answer goes once a DeferredResponse was sent for the directive. */
|
||||||
|
export declare const deferred: (root: string, endpointId: string) => string;
|
||||||
|
/** Where every ChangeReport of the root goes: the backend adds the user's token and posts it to Alexa. */
|
||||||
|
export declare const changeReport: (root: string) => string;
|
||||||
10
dist/esm/topics.js
vendored
Normal file
10
dist/esm/topics.js
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
// The topics of the Alex2MQTT contract, named once. The backend and Alex2ESP use the same names, so none of them
|
||||||
|
// can change here alone.
|
||||||
|
/** Where the bridge answers a discovery request with its endpoints. */
|
||||||
|
export const discoverReply = (root) => `${root}/discover_r`;
|
||||||
|
/** Where the answer to a directive goes. "alexaResponce" is how the backend and Alex2ESP spell it. */
|
||||||
|
export const response = (root, endpointId) => `${root}/${endpointId}/alexaResponce`;
|
||||||
|
/** Where the answer goes once a DeferredResponse was sent for the directive. */
|
||||||
|
export const deferred = (root, endpointId) => `${root}/${endpointId}/deferredResponse`;
|
||||||
|
/** Where every ChangeReport of the root goes: the backend adds the user's token and posts it to Alexa. */
|
||||||
|
export const changeReport = (root) => `${root}/changeReport`;
|
||||||
48
dist/esm/transport.d.ts
vendored
Normal file
48
dist/esm/transport.d.ts
vendored
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import type { MqttClient } from "mqtt";
|
||||||
|
/** What became of a publish. A failure is a value: publish() never rejects. */
|
||||||
|
export type PublishResult = {
|
||||||
|
ok: true;
|
||||||
|
topic: string;
|
||||||
|
} | {
|
||||||
|
ok: false;
|
||||||
|
topic: string;
|
||||||
|
error: Error;
|
||||||
|
};
|
||||||
|
/** Where the bridge, its devices and their messages publish. The message is sent as JSON. */
|
||||||
|
export interface Publisher {
|
||||||
|
publish(topic: string, message: object): Promise<PublishResult>;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Publishes to the broker through the client the bridge has at the time: a new one after disconnect() and connect(),
|
||||||
|
* none before connect() and after disconnect(). Without a client the publish is refused. With a client that lost
|
||||||
|
* its connection mqtt.js keeps the message and sends it after the reconnect, and the publish resolves then.
|
||||||
|
*/
|
||||||
|
export declare class MqttPublisher implements Publisher {
|
||||||
|
private readonly client;
|
||||||
|
constructor(client: () => MqttClient | null);
|
||||||
|
publish(topic: string, message: object): Promise<PublishResult>;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Keeps what it is asked to publish, for tests and dry runs without a broker:
|
||||||
|
*
|
||||||
|
* const sent = new MemoryPublisher();
|
||||||
|
* device.publisher = sent;
|
||||||
|
* await device.getStatusMessage(token, true).addPowerControllerProp(PowerController.ON).send();
|
||||||
|
* sent.published[0] // { topic: "<root>/<endpointId>/alexaResponce", message: { event, context } }
|
||||||
|
*/
|
||||||
|
export declare class MemoryPublisher implements Publisher {
|
||||||
|
/** Oldest first. The message is what a subscriber gets after JSON.parse. */
|
||||||
|
readonly published: Array<{
|
||||||
|
topic: string;
|
||||||
|
message: any;
|
||||||
|
}>;
|
||||||
|
/** Set it and every publish fails with this error, as a publish to a broker that is gone does. */
|
||||||
|
failWith: Error | null;
|
||||||
|
publish(topic: string, message: object): Promise<PublishResult>;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* send() as 1.x promises it: resolves with the topic, or with "" when nothing was published, and never rejects
|
||||||
|
* (1.5.1 rejected, and a send() nobody caught killed the host on any broker hiccup). What went wrong goes to report:
|
||||||
|
* the message cannot be built, the device is on no bridge, or the publish failed.
|
||||||
|
*/
|
||||||
|
export declare function send(publisher: Publisher | null, topic: string, build: () => object, report?: (err: Error) => void): Promise<string>;
|
||||||
77
dist/esm/transport.js
vendored
Normal file
77
dist/esm/transport.js
vendored
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
const asError = (err) => (err instanceof Error ? err : new Error(String(err)));
|
||||||
|
/**
|
||||||
|
* Publishes to the broker through the client the bridge has at the time: a new one after disconnect() and connect(),
|
||||||
|
* none before connect() and after disconnect(). Without a client the publish is refused. With a client that lost
|
||||||
|
* its connection mqtt.js keeps the message and sends it after the reconnect, and the publish resolves then.
|
||||||
|
*/
|
||||||
|
export class MqttPublisher {
|
||||||
|
constructor(client) {
|
||||||
|
this.client = client;
|
||||||
|
}
|
||||||
|
publish(topic, message) {
|
||||||
|
const client = this.client();
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const failed = (err) => resolve({ ok: false, topic, error: asError(err) });
|
||||||
|
if (!client) {
|
||||||
|
failed(new Error(`nothing was published to ${topic}: the bridge is not connected, call connect() first`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
client.publish(topic, JSON.stringify(message), (err) => (err ? failed(err) : resolve({ ok: true, topic })));
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
failed(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Keeps what it is asked to publish, for tests and dry runs without a broker:
|
||||||
|
*
|
||||||
|
* const sent = new MemoryPublisher();
|
||||||
|
* device.publisher = sent;
|
||||||
|
* await device.getStatusMessage(token, true).addPowerControllerProp(PowerController.ON).send();
|
||||||
|
* sent.published[0] // { topic: "<root>/<endpointId>/alexaResponce", message: { event, context } }
|
||||||
|
*/
|
||||||
|
export class MemoryPublisher {
|
||||||
|
constructor() {
|
||||||
|
/** Oldest first. The message is what a subscriber gets after JSON.parse. */
|
||||||
|
this.published = [];
|
||||||
|
/** Set it and every publish fails with this error, as a publish to a broker that is gone does. */
|
||||||
|
this.failWith = null;
|
||||||
|
}
|
||||||
|
publish(topic, message) {
|
||||||
|
if (this.failWith)
|
||||||
|
return Promise.resolve({ ok: false, topic, error: this.failWith });
|
||||||
|
try {
|
||||||
|
this.published.push({ topic, message: JSON.parse(JSON.stringify(message)) });
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
return Promise.resolve({ ok: false, topic, error: asError(err) });
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ok: true, topic });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* send() as 1.x promises it: resolves with the topic, or with "" when nothing was published, and never rejects
|
||||||
|
* (1.5.1 rejected, and a send() nobody caught killed the host on any broker hiccup). What went wrong goes to report:
|
||||||
|
* the message cannot be built, the device is on no bridge, or the publish failed.
|
||||||
|
*/
|
||||||
|
export async function send(publisher, topic, build, report) {
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
const message = build();
|
||||||
|
if (!publisher) {
|
||||||
|
throw new Error(`nothing was published to ${topic}: the device is on no bridge, register it with addDevice() or registerDevice()`);
|
||||||
|
}
|
||||||
|
result = await publisher.publish(topic, message);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
result = { ok: false, topic, error: asError(err) };
|
||||||
|
}
|
||||||
|
if (result.ok)
|
||||||
|
return topic;
|
||||||
|
if (report)
|
||||||
|
report(result.error);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
5
dist/types/Alex2Node.d.ts
vendored
5
dist/types/Alex2Node.d.ts
vendored
|
|
@ -38,6 +38,7 @@ declare class Alex2MQTT extends EventEmitter {
|
||||||
private rootTopic;
|
private rootTopic;
|
||||||
private debugLogging;
|
private debugLogging;
|
||||||
private client;
|
private client;
|
||||||
|
private readonly publisher;
|
||||||
private devices;
|
private devices;
|
||||||
private MqttHost;
|
private MqttHost;
|
||||||
private options;
|
private options;
|
||||||
|
|
@ -62,7 +63,8 @@ declare class Alex2MQTT extends EventEmitter {
|
||||||
*
|
*
|
||||||
* Throws a DeclarationError when Alexa would reject the endpoint (an endpointId with a slash, a name with
|
* Throws a DeclarationError when Alexa would reject the endpoint (an endpointId with a slash, a name with
|
||||||
* punctuation) or when the endpointId is registered already. Discovery lists Alexa.EndpointHealth for the device
|
* punctuation) or when the endpointId is registered already. Discovery lists Alexa.EndpointHealth for the device
|
||||||
* unless endpointHealth is false.
|
* unless endpointHealth is false. A device can be declared before connect(): what it sends before the bridge has
|
||||||
|
* a connection is not published, and the "error" event says so.
|
||||||
*/
|
*/
|
||||||
addDevice(definition: EndpointDefinition): Device;
|
addDevice(definition: EndpointDefinition): Device;
|
||||||
registerDevice(name: string, endpointId: string, displayCategory?: DisplayCategory | DisplayCategory[] | null): Device;
|
registerDevice(name: string, endpointId: string, displayCategory?: DisplayCategory | DisplayCategory[] | null): Device;
|
||||||
|
|
@ -71,6 +73,7 @@ declare class Alex2MQTT extends EventEmitter {
|
||||||
unregisterDevice(endpointId: string): boolean;
|
unregisterDevice(endpointId: string): boolean;
|
||||||
/** Forget every device. */
|
/** Forget every device. */
|
||||||
clearDevices(): void;
|
clearDevices(): void;
|
||||||
|
private release;
|
||||||
getDevices(): Device[];
|
getDevices(): Device[];
|
||||||
getDevice(endpointId: string): Device | undefined;
|
getDevice(endpointId: string): Device | undefined;
|
||||||
getRootTopic(): string;
|
getRootTopic(): string;
|
||||||
|
|
|
||||||
6
dist/types/compat/AlexaErrorResponse.d.ts
vendored
6
dist/types/compat/AlexaErrorResponse.d.ts
vendored
|
|
@ -1,17 +1,17 @@
|
||||||
import type { MqttClient } from "mqtt";
|
|
||||||
import type { ErrorResponseMessage } from "../messages/types.js";
|
import type { ErrorResponseMessage } from "../messages/types.js";
|
||||||
|
import type { Publisher } from "../transport.js";
|
||||||
/** An ErrorResponse as 1.x builds it: device.getErrorMessage(token), setErrorMessage(), send(). */
|
/** An ErrorResponse as 1.x builds it: device.getErrorMessage(token), setErrorMessage(), send(). */
|
||||||
export declare class AlexaErrorResponse {
|
export declare class AlexaErrorResponse {
|
||||||
private readonly correlationToken;
|
private readonly correlationToken;
|
||||||
private readonly rootTopic;
|
private readonly rootTopic;
|
||||||
private readonly endpointId;
|
private readonly endpointId;
|
||||||
private readonly mqttClient;
|
private readonly publisher;
|
||||||
private error;
|
private error;
|
||||||
private namespace?;
|
private namespace?;
|
||||||
private readonly messageId;
|
private readonly messageId;
|
||||||
/** Where a failed publish is reported (set by the Device that built this message, 1.5.2): send() never rejects. */
|
/** Where a failed publish is reported (set by the Device that built this message, 1.5.2): send() never rejects. */
|
||||||
onPublishError?: (err: Error) => void;
|
onPublishError?: (err: Error) => void;
|
||||||
constructor(correlationToken: string, rootTopic: string, endpointId: string, mqttClient: MqttClient);
|
constructor(correlationToken: string, rootTopic: string, endpointId: string, publisher: Publisher | null);
|
||||||
/**
|
/**
|
||||||
* The error to answer with. otherParams are the fields the type adds to the payload (validRange). The header
|
* The error to answer with. otherParams are the fields the type adds to the payload (validRange). The header
|
||||||
* carries the namespace the type is documented under, Alexa.ThermostatController for THERMOSTAT_IS_OFF;
|
* carries the namespace the type is documented under, Alexa.ThermostatController for THERMOSTAT_IS_OFF;
|
||||||
|
|
|
||||||
6
dist/types/compat/AlexaStatusMessage.d.ts
vendored
6
dist/types/compat/AlexaStatusMessage.d.ts
vendored
|
|
@ -1,5 +1,5 @@
|
||||||
import type { MqttClient } from "mqtt";
|
|
||||||
import type { ChangeCause, Property } from "../messages/types.js";
|
import type { ChangeCause, Property } from "../messages/types.js";
|
||||||
|
import type { Publisher } from "../transport.js";
|
||||||
import { TemperatureSensorScale } from "./enums.js";
|
import { TemperatureSensorScale } from "./enums.js";
|
||||||
import type { EndpointHealth, PowerController } from "./enums.js";
|
import type { EndpointHealth, PowerController } from "./enums.js";
|
||||||
/**
|
/**
|
||||||
|
|
@ -10,7 +10,7 @@ export declare class AlexaStatusMessage {
|
||||||
private readonly correlationToken;
|
private readonly correlationToken;
|
||||||
private readonly rootTopic;
|
private readonly rootTopic;
|
||||||
private readonly endpointId;
|
private readonly endpointId;
|
||||||
private readonly mqttClient;
|
private readonly publisher;
|
||||||
private readonly isResponse;
|
private readonly isResponse;
|
||||||
private readonly isDeferred;
|
private readonly isDeferred;
|
||||||
private readonly changeCause;
|
private readonly changeCause;
|
||||||
|
|
@ -19,7 +19,7 @@ export declare class AlexaStatusMessage {
|
||||||
private estimatedDeferralInSeconds?;
|
private estimatedDeferralInSeconds?;
|
||||||
/** Where a failed publish is reported (set by the Device that built this message, 1.5.2): send() never rejects. */
|
/** Where a failed publish is reported (set by the Device that built this message, 1.5.2): send() never rejects. */
|
||||||
onPublishError?: (err: Error) => void;
|
onPublishError?: (err: Error) => void;
|
||||||
constructor(correlationToken: string, rootTopic: string, endpointId: string, mqttClient: MqttClient, isResponse?: boolean, isDeferred?: boolean, changeCause?: ChangeCause | null);
|
constructor(correlationToken: string, rootTopic: string, endpointId: string, publisher: Publisher | null, isResponse?: boolean, isDeferred?: boolean, changeCause?: ChangeCause | null);
|
||||||
private addProperty;
|
private addProperty;
|
||||||
/** ChangeReport: the add*Prop calls that follow describe what CHANGED (the default for a change report). */
|
/** ChangeReport: the add*Prop calls that follow describe what CHANGED (the default for a change report). */
|
||||||
changed(): this;
|
changed(): this;
|
||||||
|
|
|
||||||
14
dist/types/device/Device.d.ts
vendored
14
dist/types/device/Device.d.ts
vendored
|
|
@ -1,5 +1,4 @@
|
||||||
import { EventEmitter } from "events";
|
import { EventEmitter } from "events";
|
||||||
import type { MqttClient } from "mqtt";
|
|
||||||
import { AlexaErrorResponse } from "../compat/AlexaErrorResponse.js";
|
import { AlexaErrorResponse } from "../compat/AlexaErrorResponse.js";
|
||||||
import { AlexaInterface } from "../compat/AlexaInterface.js";
|
import { AlexaInterface } from "../compat/AlexaInterface.js";
|
||||||
import { AlexaStatusMessage } from "../compat/AlexaStatusMessage.js";
|
import { AlexaStatusMessage } from "../compat/AlexaStatusMessage.js";
|
||||||
|
|
@ -8,6 +7,7 @@ import type { AlexaInterfaceType } from "../compat/enums.js";
|
||||||
import type { ChangeCause } from "../messages/types.js";
|
import type { ChangeCause } from "../messages/types.js";
|
||||||
import type { DisplayCategoryName } from "../registry/catalog.js";
|
import type { DisplayCategoryName } from "../registry/catalog.js";
|
||||||
import type { Directives, InterfaceDescriptor, Properties } from "../registry/types.js";
|
import type { Directives, InterfaceDescriptor, Properties } from "../registry/types.js";
|
||||||
|
import type { Publisher } from "../transport.js";
|
||||||
import { Capability } from "./Capability.js";
|
import { Capability } from "./Capability.js";
|
||||||
import type { AnyCapability, CapabilityJson, Declaration } from "./Capability.js";
|
import type { AnyCapability, CapabilityJson, Declaration } from "./Capability.js";
|
||||||
import type { EndpointFields } from "./validate.js";
|
import type { EndpointFields } from "./validate.js";
|
||||||
|
|
@ -40,7 +40,6 @@ export interface EndpointJson extends EndpointFields {
|
||||||
}
|
}
|
||||||
type DeclarationArguments<O, I extends boolean> = {} extends Declaration<O, I> ? [options?: Declaration<O, I>] : [options: Declaration<O, I>];
|
type DeclarationArguments<O, I extends boolean> = {} extends Declaration<O, I> ? [options?: Declaration<O, I>] : [options: Declaration<O, I>];
|
||||||
declare class Device extends EventEmitter {
|
declare class Device extends EventEmitter {
|
||||||
private mqttClient;
|
|
||||||
private rootTopic;
|
private rootTopic;
|
||||||
name: string;
|
name: string;
|
||||||
endpointId: string;
|
endpointId: string;
|
||||||
|
|
@ -67,9 +66,14 @@ declare class Device extends EventEmitter {
|
||||||
private capabilities;
|
private capabilities;
|
||||||
/** Where a failed publish from this device or a message it built is reported; Alex2MQTT sets it (1.5.2). */
|
/** Where a failed publish from this device or a message it built is reported; Alex2MQTT sets it (1.5.2). */
|
||||||
onPublishError?: (err: Error) => void;
|
onPublishError?: (err: Error) => void;
|
||||||
constructor(mqttClient: MqttClient, rootTopic: string, name: string, endpointId: string, displayCategory: Array<DisplayCategory> | null, description?: string, manufacturerName?: string, manufacturer?: string, model?: string);
|
/**
|
||||||
/** Internal (1.5.2): Alex2MQTT.connect() re-binds every registered device to its new broker client after a disconnect(). */
|
* What the device and the messages it builds publish through. The bridge sets it when the device is registered
|
||||||
setMqttClient(client: MqttClient): void;
|
* and clears it when the device is unregistered; a MemoryPublisher here tests a device without a broker. While it
|
||||||
|
* is null every send() resolves "" and reports why.
|
||||||
|
*/
|
||||||
|
publisher: Publisher | null;
|
||||||
|
/** client is ignored: in 1.x it was the broker client, and a device could only be built after connect(). */
|
||||||
|
constructor(client: unknown, rootTopic: string, name: string, endpointId: string, displayCategory: Array<DisplayCategory> | null, description?: string, manufacturerName?: string, manufacturer?: string, model?: string);
|
||||||
getName(): string;
|
getName(): string;
|
||||||
setName(name: string): void;
|
setName(name: string): void;
|
||||||
getEndpointId(): string;
|
getEndpointId(): string;
|
||||||
|
|
|
||||||
3
dist/types/index.d.ts
vendored
3
dist/types/index.d.ts
vendored
|
|
@ -13,6 +13,9 @@ export type { ActionId, AssetId, DisplayCategoryName, StateId, UnitOfMeasure, Ac
|
||||||
export * as messages from "./messages/index.js";
|
export * as messages from "./messages/index.js";
|
||||||
export { AlexaError, AlexaErrors, MessageError, StateBuilder, property } from "./messages/index.js";
|
export { AlexaError, AlexaErrors, MessageError, StateBuilder, property } from "./messages/index.js";
|
||||||
export type { ChangeCause, ChangeReportMessage, DeferredResponseMessage, ErrorResponseMessage, Header, ProactiveEventMessage, Property, PropertyOptions, ResponseMessage, SceneEventMessage, } from "./messages/index.js";
|
export type { ChangeCause, ChangeReportMessage, DeferredResponseMessage, ErrorResponseMessage, Header, ProactiveEventMessage, Property, PropertyOptions, ResponseMessage, SceneEventMessage, } from "./messages/index.js";
|
||||||
|
export * as topics from "./topics.js";
|
||||||
|
export { MemoryPublisher } from "./transport.js";
|
||||||
|
export type { Publisher, PublishResult } from "./transport.js";
|
||||||
export { AlexaInterface } from "./compat/AlexaInterface.js";
|
export { AlexaInterface } from "./compat/AlexaInterface.js";
|
||||||
export type { SupportedMode } from "./compat/AlexaInterface.js";
|
export type { SupportedMode } from "./compat/AlexaInterface.js";
|
||||||
export { ActionMapping } from "./compat/ActionMapping.js";
|
export { ActionMapping } from "./compat/ActionMapping.js";
|
||||||
|
|
|
||||||
8
dist/types/topics.d.ts
vendored
Normal file
8
dist/types/topics.d.ts
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
/** Where the bridge answers a discovery request with its endpoints. */
|
||||||
|
export declare const discoverReply: (root: string) => string;
|
||||||
|
/** Where the answer to a directive goes. "alexaResponce" is how the backend and Alex2ESP spell it. */
|
||||||
|
export declare const response: (root: string, endpointId: string) => string;
|
||||||
|
/** Where the answer goes once a DeferredResponse was sent for the directive. */
|
||||||
|
export declare const deferred: (root: string, endpointId: string) => string;
|
||||||
|
/** Where every ChangeReport of the root goes: the backend adds the user's token and posts it to Alexa. */
|
||||||
|
export declare const changeReport: (root: string) => string;
|
||||||
48
dist/types/transport.d.ts
vendored
Normal file
48
dist/types/transport.d.ts
vendored
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import type { MqttClient } from "mqtt";
|
||||||
|
/** What became of a publish. A failure is a value: publish() never rejects. */
|
||||||
|
export type PublishResult = {
|
||||||
|
ok: true;
|
||||||
|
topic: string;
|
||||||
|
} | {
|
||||||
|
ok: false;
|
||||||
|
topic: string;
|
||||||
|
error: Error;
|
||||||
|
};
|
||||||
|
/** Where the bridge, its devices and their messages publish. The message is sent as JSON. */
|
||||||
|
export interface Publisher {
|
||||||
|
publish(topic: string, message: object): Promise<PublishResult>;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Publishes to the broker through the client the bridge has at the time: a new one after disconnect() and connect(),
|
||||||
|
* none before connect() and after disconnect(). Without a client the publish is refused. With a client that lost
|
||||||
|
* its connection mqtt.js keeps the message and sends it after the reconnect, and the publish resolves then.
|
||||||
|
*/
|
||||||
|
export declare class MqttPublisher implements Publisher {
|
||||||
|
private readonly client;
|
||||||
|
constructor(client: () => MqttClient | null);
|
||||||
|
publish(topic: string, message: object): Promise<PublishResult>;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Keeps what it is asked to publish, for tests and dry runs without a broker:
|
||||||
|
*
|
||||||
|
* const sent = new MemoryPublisher();
|
||||||
|
* device.publisher = sent;
|
||||||
|
* await device.getStatusMessage(token, true).addPowerControllerProp(PowerController.ON).send();
|
||||||
|
* sent.published[0] // { topic: "<root>/<endpointId>/alexaResponce", message: { event, context } }
|
||||||
|
*/
|
||||||
|
export declare class MemoryPublisher implements Publisher {
|
||||||
|
/** Oldest first. The message is what a subscriber gets after JSON.parse. */
|
||||||
|
readonly published: Array<{
|
||||||
|
topic: string;
|
||||||
|
message: any;
|
||||||
|
}>;
|
||||||
|
/** Set it and every publish fails with this error, as a publish to a broker that is gone does. */
|
||||||
|
failWith: Error | null;
|
||||||
|
publish(topic: string, message: object): Promise<PublishResult>;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* send() as 1.x promises it: resolves with the topic, or with "" when nothing was published, and never rejects
|
||||||
|
* (1.5.1 rejected, and a send() nobody caught killed the host on any broker hiccup). What went wrong goes to report:
|
||||||
|
* the message cannot be built, the device is on no bridge, or the publish failed.
|
||||||
|
*/
|
||||||
|
export declare function send(publisher: Publisher | null, topic: string, build: () => object, report?: (err: Error) => void): Promise<string>;
|
||||||
|
|
@ -7,6 +7,8 @@ import { checkEndpoint } from "./device/validate.js";
|
||||||
import { EventEmitter } from "events";
|
import { EventEmitter } from "events";
|
||||||
import { DisplayCategory } from "./compat/enums.js";
|
import { DisplayCategory } from "./compat/enums.js";
|
||||||
import { DeclarationError } from "./registry/types.js";
|
import { DeclarationError } from "./registry/types.js";
|
||||||
|
import * as topics from "./topics.js";
|
||||||
|
import { MqttPublisher } from "./transport.js";
|
||||||
|
|
||||||
/** Optional settings for the bridge (1.5.1). Everything has the 1.4.0 behaviour as its default. */
|
/** Optional settings for the bridge (1.5.1). Everything has the 1.4.0 behaviour as its default. */
|
||||||
export interface Alex2MQTTOptions {
|
export interface Alex2MQTTOptions {
|
||||||
|
|
@ -47,6 +49,8 @@ const DESCRIBED = [
|
||||||
*/
|
*/
|
||||||
class Alex2MQTT extends EventEmitter {
|
class Alex2MQTT extends EventEmitter {
|
||||||
private client: MqttClient | null = null;
|
private client: MqttClient | null = null;
|
||||||
|
// One for the life of the bridge: the devices keep it over disconnect() and connect()
|
||||||
|
private readonly publisher = new MqttPublisher(() => this.client);
|
||||||
private devices: Device[] = [];
|
private devices: Device[] = [];
|
||||||
private MqttHost: string;
|
private MqttHost: string;
|
||||||
private options: Alex2MQTTOptions;
|
private options: Alex2MQTTOptions;
|
||||||
|
|
@ -93,7 +97,6 @@ class Alex2MQTT extends EventEmitter {
|
||||||
};
|
};
|
||||||
|
|
||||||
this.client = mqtt.connect(this.MqttHost, options);
|
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.client.on("connect", () => {
|
||||||
this.connected = true;
|
this.connected = true;
|
||||||
|
|
@ -115,9 +118,9 @@ class Alex2MQTT extends EventEmitter {
|
||||||
this.log("Discovery request received, getting device json...");
|
this.log("Discovery request received, getting device json...");
|
||||||
const deviceArray = this.describeDevices();
|
const deviceArray = this.describeDevices();
|
||||||
this.lastDiscoveryAt = new Date().toISOString();
|
this.lastDiscoveryAt = new Date().toISOString();
|
||||||
this.client!.publish(topic + "_r", JSON.stringify(deviceArray), (err) => {
|
void this.publisher.publish(topics.discoverReply(this.rootTopic), deviceArray).then((result) => {
|
||||||
if (err) { this.fail(err); return; }
|
if (!result.ok) { this.fail(result.error); return; }
|
||||||
this.log(`Discovery payloads published to ${topic + "_r"}`, deviceArray);
|
this.log(`Discovery payloads published to ${result.topic}`, deviceArray);
|
||||||
this.emit("discover", deviceArray.length);
|
this.emit("discover", deviceArray.length);
|
||||||
});
|
});
|
||||||
} else if (topic.split("/").length == 3) {
|
} else if (topic.split("/").length == 3) {
|
||||||
|
|
@ -187,15 +190,13 @@ class Alex2MQTT extends EventEmitter {
|
||||||
*
|
*
|
||||||
* Throws a DeclarationError when Alexa would reject the endpoint (an endpointId with a slash, a name with
|
* Throws a DeclarationError when Alexa would reject the endpoint (an endpointId with a slash, a name with
|
||||||
* punctuation) or when the endpointId is registered already. Discovery lists Alexa.EndpointHealth for the device
|
* punctuation) or when the endpointId is registered already. Discovery lists Alexa.EndpointHealth for the device
|
||||||
* unless endpointHealth is false.
|
* unless endpointHealth is false. A device can be declared before connect(): what it sends before the bridge has
|
||||||
|
* a connection is not published, and the "error" event says so.
|
||||||
*/
|
*/
|
||||||
addDevice(definition: EndpointDefinition): Device {
|
addDevice(definition: EndpointDefinition): Device {
|
||||||
if (!this.client) {
|
|
||||||
throw new Error("Must call connect before creating devices");
|
|
||||||
}
|
|
||||||
const { endpointId, name, categories, endpointHealth, ...described } = definition;
|
const { endpointId, name, categories, endpointHealth, ...described } = definition;
|
||||||
// Without categories the device would take LIGHT, the default of registerDevice: here it is a mistake
|
// Without categories the device would take LIGHT, the default of registerDevice: here it is a mistake
|
||||||
const device = new Device(this.client, this.rootTopic, name, endpointId, (categories ?? []) as DisplayCategory[]);
|
const device = new Device(null, this.rootTopic, name, endpointId, (categories ?? []) as DisplayCategory[]);
|
||||||
for (const [field, value] of Object.entries(described)) {
|
for (const [field, value] of Object.entries(described)) {
|
||||||
if (!DESCRIBED.includes(field)) throw new DeclarationError({ endpointId }, `${field} is not a field of an endpoint`);
|
if (!DESCRIBED.includes(field)) throw new DeclarationError({ endpointId }, `${field} is not a field of an endpoint`);
|
||||||
if (value !== undefined) Object.assign(device, { [field]: value });
|
if (value !== undefined) Object.assign(device, { [field]: value });
|
||||||
|
|
@ -212,9 +213,6 @@ class Alex2MQTT extends EventEmitter {
|
||||||
endpointId: string,
|
endpointId: string,
|
||||||
displayCategory?: DisplayCategory | DisplayCategory[] | null
|
displayCategory?: DisplayCategory | DisplayCategory[] | null
|
||||||
): Device {
|
): Device {
|
||||||
if (!this.client) {
|
|
||||||
throw new Error("Must call connect before creating devices");
|
|
||||||
}
|
|
||||||
const existing = this.devices.find((d) => d.endpointId === endpointId);
|
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
|
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`;
|
const warning = `warning: registerDevice("${endpointId}") is already registered as "${existing.name}", returning that device`;
|
||||||
|
|
@ -223,7 +221,7 @@ class Alex2MQTT extends EventEmitter {
|
||||||
}
|
}
|
||||||
this.log(`Creating new device with endpoint: ${endpointId}`);
|
this.log(`Creating new device with endpoint: ${endpointId}`);
|
||||||
const normalizedCategory = Array.isArray(displayCategory) ? displayCategory : [displayCategory || DisplayCategory.LIGHT];
|
const normalizedCategory = Array.isArray(displayCategory) ? displayCategory : [displayCategory || DisplayCategory.LIGHT];
|
||||||
const device = new Device(this.client, this.rootTopic, name, endpointId, normalizedCategory);
|
const device = new Device(null, this.rootTopic, name, endpointId, normalizedCategory);
|
||||||
this.register(device, false);
|
this.register(device, false);
|
||||||
return device;
|
return device;
|
||||||
}
|
}
|
||||||
|
|
@ -232,6 +230,7 @@ class Alex2MQTT extends EventEmitter {
|
||||||
device.alexaInterface = this.options.alexaInterface !== false;
|
device.alexaInterface = this.options.alexaInterface !== false;
|
||||||
device.endpointHealth = endpointHealth;
|
device.endpointHealth = endpointHealth;
|
||||||
device.onPublishError = (err) => this.fail(err); // a failed publish is an "error" event (when listened to), never a rejected send()
|
device.onPublishError = (err) => this.fail(err); // a failed publish is an "error" event (when listened to), never a rejected send()
|
||||||
|
device.publisher = this.publisher;
|
||||||
this.devices.push(device);
|
this.devices.push(device);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -239,15 +238,20 @@ class Alex2MQTT extends EventEmitter {
|
||||||
unregisterDevice(endpointId: string): boolean {
|
unregisterDevice(endpointId: string): boolean {
|
||||||
const i = this.devices.findIndex((d) => d.endpointId === endpointId);
|
const i = this.devices.findIndex((d) => d.endpointId === endpointId);
|
||||||
if (i === -1) return false;
|
if (i === -1) return false;
|
||||||
this.devices[i].removeAllListeners();
|
this.release(this.devices[i]);
|
||||||
this.devices.splice(i, 1);
|
this.devices.splice(i, 1);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
/** Forget every device. */
|
/** Forget every device. */
|
||||||
clearDevices(): void {
|
clearDevices(): void {
|
||||||
for (const d of this.devices) d.removeAllListeners();
|
for (const d of this.devices) this.release(d);
|
||||||
this.devices = [];
|
this.devices = [];
|
||||||
}
|
}
|
||||||
|
// A device the bridge forgot hears no directive and publishes nothing
|
||||||
|
private release(device: Device): void {
|
||||||
|
device.removeAllListeners();
|
||||||
|
device.publisher = null;
|
||||||
|
}
|
||||||
getDevices(): Device[] {
|
getDevices(): Device[] {
|
||||||
return this.devices.slice();
|
return this.devices.slice();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
import type { MqttClient } from "mqtt";
|
|
||||||
import { errorResponse } from "../messages/build.js";
|
import { errorResponse } from "../messages/build.js";
|
||||||
import { AlexaErrors } from "../messages/errors.js";
|
import { AlexaErrors } from "../messages/errors.js";
|
||||||
import type { AlexaError } from "../messages/errors.js";
|
import type { AlexaError } from "../messages/errors.js";
|
||||||
import type { ErrorResponseMessage } from "../messages/types.js";
|
import type { ErrorResponseMessage } from "../messages/types.js";
|
||||||
|
import * as topics from "../topics.js";
|
||||||
|
import { send } from "../transport.js";
|
||||||
|
import type { Publisher } from "../transport.js";
|
||||||
|
|
||||||
/** An ErrorResponse as 1.x builds it: device.getErrorMessage(token), setErrorMessage(), send(). */
|
/** An ErrorResponse as 1.x builds it: device.getErrorMessage(token), setErrorMessage(), send(). */
|
||||||
export class AlexaErrorResponse {
|
export class AlexaErrorResponse {
|
||||||
|
|
@ -19,7 +21,7 @@ export class AlexaErrorResponse {
|
||||||
private readonly correlationToken: string,
|
private readonly correlationToken: string,
|
||||||
private readonly rootTopic: string,
|
private readonly rootTopic: string,
|
||||||
private readonly endpointId: string,
|
private readonly endpointId: string,
|
||||||
private readonly mqttClient: MqttClient
|
private readonly publisher: Publisher | null
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -52,13 +54,7 @@ export class AlexaErrorResponse {
|
||||||
|
|
||||||
/** Resolves with the topic published to, or "" when the publish failed. Never rejects. */
|
/** Resolves with the topic published to, or "" when the publish failed. Never rejects. */
|
||||||
send(sendAsync = false): Promise<string> {
|
send(sendAsync = false): Promise<string> {
|
||||||
const topic = `${this.rootTopic}/${this.endpointId}/${sendAsync ? "deferredResponse" : "alexaResponce"}`; // "alexaResponce" is how Alex2MQTT and Alex2ESP spell the topic
|
const answer = sendAsync ? topics.deferred : topics.response;
|
||||||
return new Promise((resolve) => {
|
return send(this.publisher, answer(this.rootTopic, this.endpointId), () => this.toJSON(), this.onPublishError);
|
||||||
this.mqttClient.publish(topic, JSON.stringify(this.toJSON()), (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
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
import type { MqttClient } from "mqtt";
|
|
||||||
import { changeReport, deferredResponse, response } from "../messages/build.js";
|
import { changeReport, deferredResponse, response } from "../messages/build.js";
|
||||||
import { StateBuilder } from "../messages/StateBuilder.js";
|
import { StateBuilder } from "../messages/StateBuilder.js";
|
||||||
import type { ChangeCause, Property } from "../messages/types.js";
|
import type { ChangeCause, Property } from "../messages/types.js";
|
||||||
|
import * as topics from "../topics.js";
|
||||||
|
import { send } from "../transport.js";
|
||||||
|
import type { Publisher } from "../transport.js";
|
||||||
import { AlexaInterfaceType, TemperatureSensorScale } from "./enums.js";
|
import { AlexaInterfaceType, TemperatureSensorScale } from "./enums.js";
|
||||||
import type { EndpointHealth, PowerController } from "./enums.js";
|
import type { EndpointHealth, PowerController } from "./enums.js";
|
||||||
|
|
||||||
|
|
@ -27,7 +29,7 @@ export class AlexaStatusMessage {
|
||||||
private readonly correlationToken: string,
|
private readonly correlationToken: string,
|
||||||
private readonly rootTopic: string,
|
private readonly rootTopic: string,
|
||||||
private readonly endpointId: string,
|
private readonly endpointId: string,
|
||||||
private readonly mqttClient: MqttClient,
|
private readonly publisher: Publisher | null,
|
||||||
private readonly isResponse: boolean = false,
|
private readonly isResponse: boolean = false,
|
||||||
private readonly isDeferred: boolean = false,
|
private readonly isDeferred: boolean = false,
|
||||||
private readonly changeCause: ChangeCause | null = null
|
private readonly changeCause: ChangeCause | null = null
|
||||||
|
|
@ -139,21 +141,8 @@ export class AlexaStatusMessage {
|
||||||
* to). Never rejects (1.5.1 did, so an un-caught send() could kill the host).
|
* to). Never rejects (1.5.1 did, so an un-caught send() could kill the host).
|
||||||
*/
|
*/
|
||||||
public send(sendAsync: boolean = false): Promise<string> {
|
public send(sendAsync: boolean = false): Promise<string> {
|
||||||
const topic = this.changeCause
|
const answer = sendAsync ? topics.deferred : topics.response;
|
||||||
? `${this.rootTopic}/changeReport`
|
const topic = this.changeCause ? topics.changeReport(this.rootTopic) : answer(this.rootTopic, this.endpointId);
|
||||||
: `${this.rootTopic}/${this.endpointId}/${sendAsync ? "deferredResponse" : "alexaResponce"}`; // "alexaResponce" is how Alex2MQTT and Alex2ESP spell the topic
|
return send(this.publisher, topic, () => this.toJSON(), this.onPublishError);
|
||||||
return new Promise((resolve) => {
|
|
||||||
const failed = (err: Error): void => {
|
|
||||||
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
|
|
||||||
};
|
|
||||||
let payload: string;
|
|
||||||
try {
|
|
||||||
payload = JSON.stringify(this.toJSON());
|
|
||||||
} catch (err) {
|
|
||||||
return failed(err as Error);
|
|
||||||
}
|
|
||||||
this.mqttClient.publish(topic, payload, (err) => (err ? failed(err) : resolve(topic)));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { EventEmitter } from "events";
|
import { EventEmitter } from "events";
|
||||||
import type { MqttClient } from "mqtt";
|
|
||||||
import { AlexaErrorResponse } from "../compat/AlexaErrorResponse.js";
|
import { AlexaErrorResponse } from "../compat/AlexaErrorResponse.js";
|
||||||
import { AlexaInterface } from "../compat/AlexaInterface.js";
|
import { AlexaInterface } from "../compat/AlexaInterface.js";
|
||||||
import { AlexaStatusMessage } from "../compat/AlexaStatusMessage.js";
|
import { AlexaStatusMessage } from "../compat/AlexaStatusMessage.js";
|
||||||
|
|
@ -13,6 +12,9 @@ import { EndpointHealth } from "../registry/interfaces/EndpointHealth.js";
|
||||||
import { SchemaError } from "../registry/schema.js";
|
import { SchemaError } from "../registry/schema.js";
|
||||||
import { DeclarationError } from "../registry/types.js";
|
import { DeclarationError } from "../registry/types.js";
|
||||||
import type { Directives, EndpointView, InterfaceDescriptor, Label, Properties } from "../registry/types.js";
|
import type { Directives, EndpointView, InterfaceDescriptor, Label, Properties } from "../registry/types.js";
|
||||||
|
import * as topics from "../topics.js";
|
||||||
|
import { send } from "../transport.js";
|
||||||
|
import type { Publisher } from "../transport.js";
|
||||||
import { Capability, commonOptions } from "./Capability.js";
|
import { Capability, commonOptions } from "./Capability.js";
|
||||||
import type { AnyCapability, CapabilityJson, Declaration } from "./Capability.js";
|
import type { AnyCapability, CapabilityJson, Declaration } from "./Capability.js";
|
||||||
import { checkCapability, checkCapabilityCount, checkEndpoint } from "./validate.js";
|
import { checkCapability, checkCapabilityCount, checkEndpoint } from "./validate.js";
|
||||||
|
|
@ -72,9 +74,16 @@ class Device extends EventEmitter {
|
||||||
private capabilities: AnyCapability[] = [];
|
private capabilities: AnyCapability[] = [];
|
||||||
/** Where a failed publish from this device or a message it built is reported; Alex2MQTT sets it (1.5.2). */
|
/** Where a failed publish from this device or a message it built is reported; Alex2MQTT sets it (1.5.2). */
|
||||||
public onPublishError?: (err: Error) => void;
|
public onPublishError?: (err: Error) => void;
|
||||||
|
/**
|
||||||
|
* What the device and the messages it builds publish through. The bridge sets it when the device is registered
|
||||||
|
* and clears it when the device is unregistered; a MemoryPublisher here tests a device without a broker. While it
|
||||||
|
* is null every send() resolves "" and reports why.
|
||||||
|
*/
|
||||||
|
public publisher: Publisher | null = null;
|
||||||
|
|
||||||
|
/** client is ignored: in 1.x it was the broker client, and a device could only be built after connect(). */
|
||||||
constructor(
|
constructor(
|
||||||
private mqttClient: MqttClient,
|
client: unknown,
|
||||||
private rootTopic: string,
|
private rootTopic: string,
|
||||||
public name: string,
|
public name: string,
|
||||||
public endpointId: string,
|
public endpointId: string,
|
||||||
|
|
@ -87,11 +96,6 @@ class Device extends EventEmitter {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Internal (1.5.2): Alex2MQTT.connect() re-binds every registered device to its new broker client after a disconnect(). */
|
|
||||||
setMqttClient(client: MqttClient): void {
|
|
||||||
this.mqttClient = client;
|
|
||||||
}
|
|
||||||
|
|
||||||
getName(): string {
|
getName(): string {
|
||||||
return this.name;
|
return this.name;
|
||||||
}
|
}
|
||||||
|
|
@ -124,7 +128,7 @@ class Device extends EventEmitter {
|
||||||
correlationToken,
|
correlationToken,
|
||||||
this.rootTopic,
|
this.rootTopic,
|
||||||
this.endpointId,
|
this.endpointId,
|
||||||
this.mqttClient
|
this.publisher
|
||||||
);
|
);
|
||||||
msg.onPublishError = this.onPublishError;
|
msg.onPublishError = this.onPublishError;
|
||||||
return msg;
|
return msg;
|
||||||
|
|
@ -138,7 +142,7 @@ class Device extends EventEmitter {
|
||||||
correlationToken,
|
correlationToken,
|
||||||
this.rootTopic,
|
this.rootTopic,
|
||||||
this.endpointId,
|
this.endpointId,
|
||||||
this.mqttClient,
|
this.publisher,
|
||||||
isResponse,
|
isResponse,
|
||||||
isDeferred
|
isDeferred
|
||||||
);
|
);
|
||||||
|
|
@ -152,7 +156,7 @@ class Device extends EventEmitter {
|
||||||
* Without a changed property send() publishes nothing and resolves with "": Alex2MQTT would drop the report.
|
* Without a changed property send() publishes nothing and resolves with "": Alex2MQTT would drop the report.
|
||||||
*/
|
*/
|
||||||
getChangeReport(cause: ChangeCause = "PHYSICAL_INTERACTION"): AlexaStatusMessage {
|
getChangeReport(cause: ChangeCause = "PHYSICAL_INTERACTION"): AlexaStatusMessage {
|
||||||
const msg = new AlexaStatusMessage("", this.rootTopic, this.endpointId, this.mqttClient, false, false, cause);
|
const msg = new AlexaStatusMessage("", this.rootTopic, this.endpointId, this.publisher, false, false, cause);
|
||||||
msg.onPublishError = this.onPublishError;
|
msg.onPublishError = this.onPublishError;
|
||||||
return msg;
|
return msg;
|
||||||
}
|
}
|
||||||
|
|
@ -161,15 +165,9 @@ class Device extends EventEmitter {
|
||||||
* (1.5.1). Resolves with the topic published to, or "" when the publish failed (never rejects, 1.5.2).
|
* (1.5.1). Resolves with the topic published to, or "" when the publish failed (never rejects, 1.5.2).
|
||||||
*/
|
*/
|
||||||
sendSceneResponse(correlationToken: string, activated: boolean, cause: ChangeCause = "VOICE_INTERACTION", sendAsync = false): Promise<string> {
|
sendSceneResponse(correlationToken: string, activated: boolean, cause: ChangeCause = "VOICE_INTERACTION", sendAsync = false): Promise<string> {
|
||||||
const payload = sceneEvent({ endpointId: this.endpointId, correlationToken, activated, cause });
|
const answer = sendAsync ? topics.deferred : topics.response;
|
||||||
const topic = `${this.rootTopic}/${this.endpointId}/${sendAsync ? "deferredResponse" : "alexaResponce"}`;
|
const build = () => sceneEvent({ endpointId: this.endpointId, correlationToken, activated, cause });
|
||||||
return new Promise((resolve) => {
|
return send(this.publisher, answer(this.rootTopic, this.endpointId), build, this.onPublishError);
|
||||||
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
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
/** The capabilities the device declared, in the order it declared them. */
|
/** The capabilities the device declared, in the order it declared them. */
|
||||||
getCapabilities(): AnyCapability[] {
|
getCapabilities(): AnyCapability[] {
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,11 @@ export type {
|
||||||
Property, PropertyOptions, ResponseMessage, SceneEventMessage,
|
Property, PropertyOptions, ResponseMessage, SceneEventMessage,
|
||||||
} from "./messages/index.js";
|
} from "./messages/index.js";
|
||||||
|
|
||||||
|
// Publishing: the topics of the Alex2MQTT contract, and a publisher that needs no broker for tests
|
||||||
|
export * as topics from "./topics.js";
|
||||||
|
export { MemoryPublisher } from "./transport.js";
|
||||||
|
export type { Publisher, PublishResult } from "./transport.js";
|
||||||
|
|
||||||
// 1.x
|
// 1.x
|
||||||
export { AlexaInterface } from "./compat/AlexaInterface.js";
|
export { AlexaInterface } from "./compat/AlexaInterface.js";
|
||||||
export type { SupportedMode } from "./compat/AlexaInterface.js";
|
export type { SupportedMode } from "./compat/AlexaInterface.js";
|
||||||
|
|
|
||||||
14
src/topics.ts
Normal file
14
src/topics.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
// The topics of the Alex2MQTT contract, named once. The backend and Alex2ESP use the same names, so none of them
|
||||||
|
// can change here alone.
|
||||||
|
|
||||||
|
/** Where the bridge answers a discovery request with its endpoints. */
|
||||||
|
export const discoverReply = (root: string): string => `${root}/discover_r`;
|
||||||
|
|
||||||
|
/** Where the answer to a directive goes. "alexaResponce" is how the backend and Alex2ESP spell it. */
|
||||||
|
export const response = (root: string, endpointId: string): string => `${root}/${endpointId}/alexaResponce`;
|
||||||
|
|
||||||
|
/** Where the answer goes once a DeferredResponse was sent for the directive. */
|
||||||
|
export const deferred = (root: string, endpointId: string): string => `${root}/${endpointId}/deferredResponse`;
|
||||||
|
|
||||||
|
/** Where every ChangeReport of the root goes: the backend adds the user's token and posts it to Alexa. */
|
||||||
|
export const changeReport = (root: string): string => `${root}/changeReport`;
|
||||||
89
src/transport.ts
Normal file
89
src/transport.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import type { MqttClient } from "mqtt";
|
||||||
|
|
||||||
|
/** What became of a publish. A failure is a value: publish() never rejects. */
|
||||||
|
export type PublishResult =
|
||||||
|
| { ok: true; topic: string }
|
||||||
|
| { ok: false; topic: string; error: Error };
|
||||||
|
|
||||||
|
/** Where the bridge, its devices and their messages publish. The message is sent as JSON. */
|
||||||
|
export interface Publisher {
|
||||||
|
publish(topic: string, message: object): Promise<PublishResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const asError = (err: unknown): Error => (err instanceof Error ? err : new Error(String(err)));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publishes to the broker through the client the bridge has at the time: a new one after disconnect() and connect(),
|
||||||
|
* none before connect() and after disconnect(). Without a client the publish is refused. With a client that lost
|
||||||
|
* its connection mqtt.js keeps the message and sends it after the reconnect, and the publish resolves then.
|
||||||
|
*/
|
||||||
|
export class MqttPublisher implements Publisher {
|
||||||
|
constructor(private readonly client: () => MqttClient | null) {}
|
||||||
|
|
||||||
|
publish(topic: string, message: object): Promise<PublishResult> {
|
||||||
|
const client = this.client();
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const failed = (err: unknown): void => resolve({ ok: false, topic, error: asError(err) });
|
||||||
|
if (!client) {
|
||||||
|
failed(new Error(`nothing was published to ${topic}: the bridge is not connected, call connect() first`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
client.publish(topic, JSON.stringify(message), (err) => (err ? failed(err) : resolve({ ok: true, topic })));
|
||||||
|
} catch (err) {
|
||||||
|
failed(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps what it is asked to publish, for tests and dry runs without a broker:
|
||||||
|
*
|
||||||
|
* const sent = new MemoryPublisher();
|
||||||
|
* device.publisher = sent;
|
||||||
|
* await device.getStatusMessage(token, true).addPowerControllerProp(PowerController.ON).send();
|
||||||
|
* sent.published[0] // { topic: "<root>/<endpointId>/alexaResponce", message: { event, context } }
|
||||||
|
*/
|
||||||
|
export class MemoryPublisher implements Publisher {
|
||||||
|
/** Oldest first. The message is what a subscriber gets after JSON.parse. */
|
||||||
|
public readonly published: Array<{ topic: string; message: any }> = [];
|
||||||
|
/** Set it and every publish fails with this error, as a publish to a broker that is gone does. */
|
||||||
|
public failWith: Error | null = null;
|
||||||
|
|
||||||
|
publish(topic: string, message: object): Promise<PublishResult> {
|
||||||
|
if (this.failWith) return Promise.resolve({ ok: false, topic, error: this.failWith });
|
||||||
|
try {
|
||||||
|
this.published.push({ topic, message: JSON.parse(JSON.stringify(message)) });
|
||||||
|
} catch (err) {
|
||||||
|
return Promise.resolve({ ok: false, topic, error: asError(err) });
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ok: true, topic });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* send() as 1.x promises it: resolves with the topic, or with "" when nothing was published, and never rejects
|
||||||
|
* (1.5.1 rejected, and a send() nobody caught killed the host on any broker hiccup). What went wrong goes to report:
|
||||||
|
* the message cannot be built, the device is on no bridge, or the publish failed.
|
||||||
|
*/
|
||||||
|
export async function send(
|
||||||
|
publisher: Publisher | null,
|
||||||
|
topic: string,
|
||||||
|
build: () => object,
|
||||||
|
report?: (err: Error) => void
|
||||||
|
): Promise<string> {
|
||||||
|
let result: PublishResult;
|
||||||
|
try {
|
||||||
|
const message = build();
|
||||||
|
if (!publisher) {
|
||||||
|
throw new Error(`nothing was published to ${topic}: the device is on no bridge, register it with addDevice() or registerDevice()`);
|
||||||
|
}
|
||||||
|
result = await publisher.publish(topic, message);
|
||||||
|
} catch (err) {
|
||||||
|
result = { ok: false, topic, error: asError(err) };
|
||||||
|
}
|
||||||
|
if (result.ok) return topic;
|
||||||
|
if (report) report(result.error);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,7 @@ const { test } = require("node:test");
|
||||||
const assert = require("node:assert/strict");
|
const assert = require("node:assert/strict");
|
||||||
const {
|
const {
|
||||||
Alex2MQTT, AlexaErrorResponse, AlexaErrorType, AlexaInterfaceType, AlexaStatusMessage, DisplayCategory, EndpointHealth,
|
Alex2MQTT, AlexaErrorResponse, AlexaErrorType, AlexaInterfaceType, AlexaStatusMessage, DisplayCategory, EndpointHealth,
|
||||||
PowerController, TemperatureSensorScale,
|
MemoryPublisher, PowerController, TemperatureSensorScale,
|
||||||
} = require("alex2node");
|
} = require("alex2node");
|
||||||
const { setup, sleep, UUID_V4 } = require("../helpers/harness.js");
|
const { setup, sleep, UUID_V4 } = require("../helpers/harness.js");
|
||||||
|
|
||||||
|
|
@ -28,12 +28,6 @@ const property = (namespace, name, value, more = {}) => (
|
||||||
{ namespace, name, value, timeOfSample: "<time>", uncertaintyInMilliseconds: 0, ...more }
|
{ namespace, name, value, timeOfSample: "<time>", uncertaintyInMilliseconds: 0, ...more }
|
||||||
);
|
);
|
||||||
|
|
||||||
// A client that keeps what it is asked to publish
|
|
||||||
function recorder(error) {
|
|
||||||
const published = [];
|
|
||||||
return { published, publish: (topic, payload, done) => { published.push({ topic, payload: JSON.parse(payload) }); done(error); } };
|
|
||||||
}
|
|
||||||
|
|
||||||
test("a Response with every add...Prop helper: the JSON of 1.5.2", () => {
|
test("a Response with every add...Prop helper: the JSON of 1.5.2", () => {
|
||||||
const message = new AlexaStatusMessage("ct", "root", "5020AA", null, true)
|
const message = new AlexaStatusMessage("ct", "root", "5020AA", null, true)
|
||||||
.addPowerControllerProp(PowerController.ON)
|
.addPowerControllerProp(PowerController.ON)
|
||||||
|
|
@ -131,8 +125,8 @@ test("wire change: the namespace of an ErrorResponse follows the type (1.5.2: al
|
||||||
});
|
});
|
||||||
|
|
||||||
test("an ErrorResponse with the fields of its type: the JSON of 1.5.2, sent to the topic asked for", async () => {
|
test("an ErrorResponse with the fields of its type: the JSON of 1.5.2, sent to the topic asked for", async () => {
|
||||||
const client = recorder();
|
const sent = new MemoryPublisher();
|
||||||
const error = new AlexaErrorResponse("ct", "root", "5020AA", client);
|
const error = new AlexaErrorResponse("ct", "root", "5020AA", sent);
|
||||||
error.setErrorMessage(AlexaErrorType.VALUE_OUT_OF_RANGE, "too high", { validRange: { minimumValue: 1, maximumValue: 10 } });
|
error.setErrorMessage(AlexaErrorType.VALUE_OUT_OF_RANGE, "too high", { validRange: { minimumValue: 1, maximumValue: 10 } });
|
||||||
const expected = {
|
const expected = {
|
||||||
event: {
|
event: {
|
||||||
|
|
@ -144,7 +138,7 @@ test("an ErrorResponse with the fields of its type: the JSON of 1.5.2, sent to t
|
||||||
assert.deepEqual(settled(error), expected);
|
assert.deepEqual(settled(error), expected);
|
||||||
assert.equal(await error.send(), "root/5020AA/alexaResponce");
|
assert.equal(await error.send(), "root/5020AA/alexaResponce");
|
||||||
assert.equal(await error.send(true), "root/5020AA/deferredResponse");
|
assert.equal(await error.send(true), "root/5020AA/deferredResponse");
|
||||||
assert.deepEqual(client.published.map(({ topic, payload }) => [topic, settled(payload)]), [
|
assert.deepEqual(sent.published.map(({ topic, message }) => [topic, settled(message)]), [
|
||||||
["root/5020AA/alexaResponce", expected],
|
["root/5020AA/alexaResponce", expected],
|
||||||
["root/5020AA/deferredResponse", expected],
|
["root/5020AA/deferredResponse", expected],
|
||||||
]);
|
]);
|
||||||
|
|
@ -157,14 +151,16 @@ test("an ErrorResponse nobody gave a type is an INTERNAL_ERROR", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("send: the topic of the message; a publish that fails resolves \"\" and is reported, never rejected", async () => {
|
test("send: the topic of the message; a publish that fails resolves \"\" and is reported, never rejected", async () => {
|
||||||
const client = recorder();
|
const sent = new MemoryPublisher();
|
||||||
const status = (...flags) => new AlexaStatusMessage("ct", "root", "5020AA", client, ...flags).addPowerControllerProp(PowerController.ON);
|
const status = (...flags) => new AlexaStatusMessage("ct", "root", "5020AA", sent, ...flags).addPowerControllerProp(PowerController.ON);
|
||||||
assert.equal(await status(true).send(), "root/5020AA/alexaResponce");
|
assert.equal(await status(true).send(), "root/5020AA/alexaResponce");
|
||||||
assert.equal(await status(true).send(true), "root/5020AA/deferredResponse");
|
assert.equal(await status(true).send(true), "root/5020AA/deferredResponse");
|
||||||
assert.equal(await status(false, false, "PHYSICAL_INTERACTION").send(), "root/changeReport");
|
assert.equal(await status(false, false, "PHYSICAL_INTERACTION").send(), "root/changeReport");
|
||||||
assert.deepEqual(client.published.map(({ payload }) => payload.event.header.name), ["Response", "Response", "ChangeReport"]);
|
assert.deepEqual(sent.published.map(({ message }) => message.event.header.name), ["Response", "Response", "ChangeReport"]);
|
||||||
|
|
||||||
const failing = new AlexaStatusMessage("ct", "root", "5020AA", recorder(new Error("broker gone")), true);
|
const gone = new MemoryPublisher();
|
||||||
|
gone.failWith = new Error("broker gone");
|
||||||
|
const failing = new AlexaStatusMessage("ct", "root", "5020AA", gone, true);
|
||||||
const reported = [];
|
const reported = [];
|
||||||
failing.onPublishError = (err) => reported.push(err.message);
|
failing.onPublishError = (err) => reported.push(err.message);
|
||||||
assert.equal(await failing.send(), "");
|
assert.equal(await failing.send(), "");
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
"use strict";
|
"use strict";
|
||||||
// The broker connection: the default host, an outage reported as events that never crash the host, disconnect() then
|
// The broker connection: the default host, an outage reported as events that never crash the host, devices declared
|
||||||
// connect(), and a publish that fails without rejecting.
|
// before connect(), disconnect() then connect(), and a publish that fails without rejecting.
|
||||||
const { test } = require("node:test");
|
const { test } = require("node:test");
|
||||||
const assert = require("node:assert/strict");
|
const assert = require("node:assert/strict");
|
||||||
const { Alex2MQTT, AlexaInterfaceType, DisplayCategory, PowerController, DEFAULT_HOST } = require("alex2node");
|
const { Alex2MQTT, AlexaInterfaceType, DisplayCategory, PowerController, DEFAULT_HOST } = require("alex2node");
|
||||||
const { broker, setup, connected, directive, until, sleep, cleanup } = require("./helpers/harness.js");
|
const { broker, watcher, setup, connected, directive, until, sleep, cleanup } = require("./helpers/harness.js");
|
||||||
|
|
||||||
// Nothing listens on port 1, so every attempt is refused at once.
|
// Nothing listens on port 1, so every attempt is refused at once.
|
||||||
const DEAD_BROKER = { host: "mqtt://127.0.0.1:1", mqtt: { reconnectPeriod: 50, connectTimeout: 200 } };
|
const DEAD_BROKER = { host: "mqtt://127.0.0.1:1", mqtt: { reconnectPeriod: 50, connectTimeout: 200 } };
|
||||||
|
|
@ -32,8 +32,27 @@ test("a dead broker: 'offline'/'error' events when listened to, and NO crash whe
|
||||||
await until(() => seen.includes("ECONNREFUSED") && seen.includes("reconnect"), 3000, "error + reconnect events");
|
await until(() => seen.includes("ECONNREFUSED") && seen.includes("reconnect"), 3000, "error + reconnect events");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("registerDevice before connect throws (unchanged from 1.4.0)", () => {
|
test("devices declared before connect() (1.x threw): a send() is refused until the bridge connects, then they answer", async () => {
|
||||||
assert.throws(() => new Alex2MQTT("u", "p", "root").registerDevice("x", "1", null), /connect before/);
|
const { url } = await broker();
|
||||||
|
const alexa = await watcher(url, "root-early/#");
|
||||||
|
const bridge = new Alex2MQTT("u", "p", "root-early", false, { host: url });
|
||||||
|
const errors = [];
|
||||||
|
bridge.on("error", (e) => errors.push(e.message));
|
||||||
|
const lamp = bridge.registerDevice("Lamp", "lamp-1", DisplayCategory.LIGHT);
|
||||||
|
const plug = bridge.addDevice({ endpointId: "plug-1", name: "Plug", categories: ["SMARTPLUG"] });
|
||||||
|
for (const device of [lamp, plug]) {
|
||||||
|
device.on("Event", (d) => device.getStatusMessage(d.header.correlationToken, true).addPowerControllerProp(PowerController.ON).send());
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(await lamp.getStatusMessage("early", true).addPowerControllerProp(PowerController.ON).send(), "");
|
||||||
|
assert.deepEqual(errors, ["nothing was published to root-early/lamp-1/alexaResponce: the bridge is not connected, call connect() first"]);
|
||||||
|
|
||||||
|
await connected(bridge);
|
||||||
|
for (const id of ["lamp-1", "plug-1"]) {
|
||||||
|
alexa.send("root-early", directive("Alexa.PowerController", "TurnOn", id, `ct-${id}`));
|
||||||
|
await until(() => alexa.on(`root-early/${id}/alexaResponce`).length === 1, 3000, `the response of ${id}`);
|
||||||
|
assert.equal(alexa.on(`root-early/${id}/alexaResponce`)[0].event.header.correlationToken, `ct-${id}`);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("disconnect() then connect(): devices registered before still answer (1.5.1 left them on the closed client)", async () => {
|
test("disconnect() then connect(): devices registered before still answer (1.5.1 left them on the closed client)", async () => {
|
||||||
|
|
@ -66,7 +85,7 @@ test("1.5.2: a failed publish never rejects send() - it resolves \"\" and reache
|
||||||
await connected(bridge);
|
await connected(bridge);
|
||||||
const lamp = bridge.registerDevice("Lamp", "lamp-send", null);
|
const lamp = bridge.registerDevice("Lamp", "lamp-send", null);
|
||||||
lamp.addCapability(AlexaInterfaceType.POWER_CONTROLLER);
|
lamp.addCapability(AlexaInterfaceType.POWER_CONTROLLER);
|
||||||
await bridge.disconnect(); // the device still holds the ended client until the next connect(): every publish now fails
|
await bridge.disconnect(); // the bridge has no client until the next connect(): every publish is refused
|
||||||
|
|
||||||
const status = await lamp.getStatusMessage("ct", true).addPowerControllerProp(PowerController.ON).send();
|
const status = await lamp.getStatusMessage("ct", true).addPowerControllerProp(PowerController.ON).send();
|
||||||
const error = await lamp.getErrorMessage("ct").send();
|
const error = await lamp.getErrorMessage("ct").send();
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
// A device that is on no bridge. What a device declares and the discovery JSON it answers with need no broker.
|
// A device that is on no bridge. What a device declares and the discovery JSON it answers with need no broker.
|
||||||
const { Device } = require("alex2node");
|
const { Device } = require("alex2node");
|
||||||
|
|
||||||
/** The arguments Device takes before the name are the broker client and the root topic of its bridge. */
|
/** The arguments Device takes before the name are the broker client of 1.x, ignored, and the root topic of its bridge. */
|
||||||
function endpoint(endpointId = "lamp-1", name = "Lamp", categories = ["LIGHT"]) {
|
function endpoint(endpointId = "lamp-1", name = "Lamp", categories = ["LIGHT"]) {
|
||||||
return new Device(null, "root", name, endpointId, categories);
|
return new Device(null, "root", name, endpointId, categories);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
65
test/transport.test.js
Normal file
65
test/transport.test.js
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
"use strict";
|
||||||
|
// Publishing without a broker: MemoryPublisher under a device, a device on no bridge, and the topics by name.
|
||||||
|
const { test } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { Alex2MQTT, MemoryPublisher, PowerController, topics } = require("alex2node");
|
||||||
|
const { endpoint } = require("./helpers/endpoint.js");
|
||||||
|
|
||||||
|
test("the topics of the contract, \"alexaResponce\" spelt as the backend spells it", () => {
|
||||||
|
assert.equal(topics.discoverReply("root"), "root/discover_r");
|
||||||
|
assert.equal(topics.response("root", "lamp-1"), "root/lamp-1/alexaResponce");
|
||||||
|
assert.equal(topics.deferred("root", "lamp-1"), "root/lamp-1/deferredResponse");
|
||||||
|
assert.equal(topics.changeReport("root"), "root/changeReport");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("MemoryPublisher: what a device sends, by topic, as a subscriber parses it", async () => {
|
||||||
|
const lamp = endpoint("lamp-1");
|
||||||
|
const sent = lamp.publisher = new MemoryPublisher();
|
||||||
|
|
||||||
|
assert.equal(await lamp.getStatusMessage("ct", true).addPowerControllerProp(PowerController.ON).send(), "root/lamp-1/alexaResponce");
|
||||||
|
assert.equal(await lamp.getErrorMessage("ct").send(true), "root/lamp-1/deferredResponse");
|
||||||
|
assert.equal(await lamp.sendSceneResponse("ct", true), "root/lamp-1/alexaResponce");
|
||||||
|
assert.equal(await lamp.getChangeReport().addPowerControllerProp(PowerController.OFF).send(), "root/changeReport");
|
||||||
|
assert.deepEqual(sent.published.map(({ topic, message }) => [topic, message.event.header.name]), [
|
||||||
|
["root/lamp-1/alexaResponce", "Response"],
|
||||||
|
["root/lamp-1/deferredResponse", "ErrorResponse"],
|
||||||
|
["root/lamp-1/alexaResponce", "ActivationStarted"],
|
||||||
|
["root/changeReport", "ChangeReport"],
|
||||||
|
]);
|
||||||
|
assert.deepEqual(await sent.publish("root/x", { a: 1, b: undefined }), { ok: true, topic: "root/x" });
|
||||||
|
assert.deepEqual(sent.published[4].message, { a: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("MemoryPublisher.failWith: send() resolves \"\" and the error is reported, nothing is kept", async () => {
|
||||||
|
const lamp = endpoint("lamp-1");
|
||||||
|
const sent = lamp.publisher = new MemoryPublisher();
|
||||||
|
sent.failWith = new Error("broker gone");
|
||||||
|
const reported = [];
|
||||||
|
lamp.onPublishError = (err) => reported.push(err.message);
|
||||||
|
|
||||||
|
assert.equal(await lamp.sendSceneResponse("ct", false), "");
|
||||||
|
assert.equal(await lamp.getErrorMessage("ct").send(), "");
|
||||||
|
assert.deepEqual(reported, ["broker gone", "broker gone"]);
|
||||||
|
assert.deepEqual(sent.published, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a device on no bridge: send() resolves \"\" and the error says to register it", async () => {
|
||||||
|
const lamp = endpoint("lamp-1");
|
||||||
|
const reported = [];
|
||||||
|
lamp.onPublishError = (err) => reported.push(err.message);
|
||||||
|
assert.equal(await lamp.getStatusMessage("ct", true).send(), "");
|
||||||
|
assert.deepEqual(reported, [
|
||||||
|
"nothing was published to root/lamp-1/alexaResponce: the device is on no bridge, register it with addDevice() or registerDevice()",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unregisterDevice and clearDevices take the publisher from the device", () => {
|
||||||
|
const bridge = new Alex2MQTT("u", "p", "root");
|
||||||
|
const lamp = bridge.registerDevice("Lamp", "lamp-1", null);
|
||||||
|
const plug = bridge.registerDevice("Plug", "plug-1", null);
|
||||||
|
assert.ok(lamp.publisher && lamp.publisher === plug.publisher, "one publisher for the devices of a bridge");
|
||||||
|
bridge.unregisterDevice("lamp-1");
|
||||||
|
assert.equal(lamp.publisher, null);
|
||||||
|
bridge.clearDevices();
|
||||||
|
assert.equal(plug.publisher, null);
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue