dispatch: typed handlers, respond/defer/error, automatic ErrorResponse, watchdog
src/dispatcher.ts routes a directive to capability.on(name | "*"), device.onDirective() or device.onReportState(), with the payload checked by the descriptor of the interface. The DirectiveContext answers with respond/report/defer/error; respond() and report() start from device.state(). A handler or 1.x listener that throws or rejects is answered with INTERNAL_ERROR (an AlexaError with itself) and reported through "error", never as an unhandled rejection. Undeclared interface, unknown directive, AdjustMode on an unordered mode and a missing handler get INVALID_DIRECTIVE, a bad payload INVALID_VALUE; a device with an "Event" or "ReportState" listener keeps the answer to itself. Every answer passes the dispatcher: the first one per correlationToken is published, a second is refused. No answer within answerWithinMs (6500, 0 = off, unref'd timer) sends INTERNAL_ERROR and emits "unanswered". A messageId that arrived in the last 60 s is dropped. New: "unknownEndpoint", options answerUnknownEndpoints, publisher, timers, and bridge.receive() to run a bridge without a broker. 168 tests pass (18 new). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
e0fe9d2812
commit
feff828ec1
40 changed files with 2149 additions and 136 deletions
95
dist/esm/Alex2Node.js
vendored
95
dist/esm/Alex2Node.js
vendored
|
|
@ -5,8 +5,9 @@ import { checkEndpoint } from "./device/validate.js";
|
|||
import { EventEmitter } from "events";
|
||||
import { DisplayCategory } from "./compat/enums.js";
|
||||
import { DeclarationError } from "./registry/types.js";
|
||||
import { Dispatcher } from "./dispatcher.js";
|
||||
import * as topics from "./topics.js";
|
||||
import { MqttPublisher } from "./transport.js";
|
||||
import { asError, MqttPublisher } from "./transport.js";
|
||||
export const DEFAULT_HOST = "mqtt://Alex2MQTT.stormysdream.club:1883";
|
||||
// What addDevice() copies from the definition to the device
|
||||
const DESCRIBED = [
|
||||
|
|
@ -26,6 +27,9 @@ const DESCRIBED = [
|
|||
* unconditionally, and Node kills a process that has an unhandled "error" event)
|
||||
* "discover" (n) a discovery request was answered with n devices
|
||||
* "directive" (info) a directive was dispatched to a device: { endpointId, namespace, name }
|
||||
* "unknownEndpoint" (info) a directive came for an endpoint that is not registered: { endpointId, namespace, name }
|
||||
* "unanswered" (info) nothing answered a directive in time and the bridge answered INTERNAL_ERROR:
|
||||
* { endpointId, namespace, name, correlationToken }
|
||||
*/
|
||||
class Alex2MQTT extends EventEmitter {
|
||||
constructor(username, password, rootTopic, debugLogging = false, options = {}) {
|
||||
|
|
@ -35,8 +39,6 @@ class Alex2MQTT extends EventEmitter {
|
|||
this.rootTopic = rootTopic;
|
||||
this.debugLogging = debugLogging;
|
||||
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 = [];
|
||||
// The lines of check() that were logged. Discovery comes every few minutes: a line is logged once.
|
||||
this.logged = new Set();
|
||||
|
|
@ -46,6 +48,18 @@ class Alex2MQTT extends EventEmitter {
|
|||
this.lastDiscoveryAt = null;
|
||||
this.options = options || {};
|
||||
this.MqttHost = this.options.host || DEFAULT_HOST;
|
||||
this.publisher = this.options.publisher ?? new MqttPublisher(() => this.client);
|
||||
this.dispatcher = new Dispatcher({
|
||||
rootTopic,
|
||||
publisher: this.publisher,
|
||||
getDevice: (endpointId) => this.getDevice(endpointId),
|
||||
log: (message, detail) => this.log(message, detail),
|
||||
fail: (err) => this.fail(err),
|
||||
emit: (event, ...args) => { this.emit(event, ...args); },
|
||||
answerWithinMs: this.options.answerWithinMs,
|
||||
answerUnknownEndpoints: this.options.answerUnknownEndpoints,
|
||||
timers: this.options.timers,
|
||||
});
|
||||
}
|
||||
log(message, detail) {
|
||||
if (this.options.log)
|
||||
|
|
@ -90,50 +104,44 @@ class Alex2MQTT extends EventEmitter {
|
|||
this.client.on("reconnect", () => { this.log("reconnecting"); this.emit("reconnect"); });
|
||||
this.client.on("close", () => { this.connected = false; this.emit("close"); });
|
||||
this.client.on("error", (err) => { this.connected = false; this.fail(err); });
|
||||
this.client.on("message", (topic, message) => {
|
||||
this.log(`MQTT Message Received`, { topic, payload: message.toString() });
|
||||
this.client.on("message", (topic, message) => { void this.receive(topic, message); });
|
||||
}
|
||||
/**
|
||||
* What the bridge does with a message of the broker: it answers a discovery request, and it hands a directive to
|
||||
* the handlers of its device. Resolves when they are done and never rejects. With the publisher option a test
|
||||
* calls it in place of the broker:
|
||||
*
|
||||
* await bridge.receive("root/lamp-1/alexaDirective", JSON.stringify(directive));
|
||||
*/
|
||||
async receive(topic, payload) {
|
||||
const text = payload.toString();
|
||||
this.log(`MQTT Message Received`, { topic, payload: text });
|
||||
try {
|
||||
if (topic === topics.discover(this.rootTopic)) {
|
||||
this.log("Discovery request received, getting device json...");
|
||||
const deviceArray = this.describeDevices();
|
||||
this.lastDiscoveryAt = new Date().toISOString();
|
||||
void this.publisher.publish(topics.discoverReply(this.rootTopic), deviceArray).then((result) => {
|
||||
if (!result.ok) {
|
||||
this.fail(result.error);
|
||||
return;
|
||||
}
|
||||
this.log(`Discovery payloads published to ${result.topic}`, deviceArray);
|
||||
this.emit("discover", deviceArray.length);
|
||||
});
|
||||
await this.answerDiscovery();
|
||||
return;
|
||||
}
|
||||
// Another topic can still arrive: a broker may deliver what an earlier session of this client id subscribed to
|
||||
const endpointId = topics.directiveEndpoint(this.rootTopic, topic);
|
||||
if (endpointId === null)
|
||||
return;
|
||||
const device = this.devices.find((d) => d.endpointId === endpointId);
|
||||
if (!device) {
|
||||
this.log(`No device found for endpointId: ${endpointId}`);
|
||||
return;
|
||||
}
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(message.toString());
|
||||
}
|
||||
catch (e) {
|
||||
this.log(`Failed to parse payload JSON`, e);
|
||||
return;
|
||||
}
|
||||
if (!payload || !payload.header)
|
||||
return;
|
||||
this.emit("directive", { endpointId, namespace: payload.header.namespace, name: payload.header.name });
|
||||
if (payload.header.namespace === "Alexa" && payload.header.name === "ReportState") {
|
||||
this.log(`ReportState directive for device ${endpointId}`);
|
||||
device.emit("ReportState", payload);
|
||||
}
|
||||
else {
|
||||
device.emit("Event", payload, payload.header.namespace);
|
||||
}
|
||||
});
|
||||
if (endpointId !== null)
|
||||
await this.dispatcher.dispatch(endpointId, text);
|
||||
}
|
||||
catch (err) {
|
||||
// A listener of "discover", "directive" or "unknownEndpoint" threw
|
||||
this.fail(asError(err));
|
||||
}
|
||||
}
|
||||
async answerDiscovery() {
|
||||
this.log("Discovery request received, getting device json...");
|
||||
const deviceArray = this.describeDevices();
|
||||
this.lastDiscoveryAt = new Date().toISOString();
|
||||
const result = await this.publisher.publish(topics.discoverReply(this.rootTopic), deviceArray);
|
||||
if (!result.ok) {
|
||||
this.fail(result.error);
|
||||
return;
|
||||
}
|
||||
this.log(`Discovery payloads published to ${result.topic}`, deviceArray);
|
||||
this.emit("discover", deviceArray.length);
|
||||
}
|
||||
// The endpoint objects of a discovery answer. What check() says about a device is logged, each line once. A device
|
||||
// that cannot be described is left out and reported as an error: the others are still announced.
|
||||
|
|
@ -214,7 +222,8 @@ class Alex2MQTT extends EventEmitter {
|
|||
device.alexaInterface = this.options.alexaInterface !== false;
|
||||
device.endpointHealth = endpointHealth;
|
||||
device.onPublishError = (err) => this.fail(err); // a failed publish is an "error" event (when listened to), never a rejected send()
|
||||
device.publisher = this.publisher;
|
||||
// Through the dispatcher, which lets one answer to a directive pass
|
||||
device.publisher = this.dispatcher.publisher;
|
||||
this.devices.push(device);
|
||||
}
|
||||
/** Forget a device (its listeners with it). Returns false when there was none. */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue