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:
David 2026-09-28 19:49:41 +00:00
parent e0fe9d2812
commit feff828ec1
40 changed files with 2149 additions and 136 deletions

93
dist/cjs/Alex2Node.js vendored
View file

@ -44,6 +44,7 @@ const validate_js_1 = require("./device/validate.js");
const events_1 = require("events");
const enums_js_1 = require("./compat/enums.js");
const types_js_1 = require("./registry/types.js");
const dispatcher_js_1 = require("./dispatcher.js");
const topics = __importStar(require("./topics.js"));
const transport_js_1 = require("./transport.js");
exports.DEFAULT_HOST = "mqtt://Alex2MQTT.stormysdream.club:1883";
@ -65,6 +66,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 events_1.EventEmitter {
constructor(username, password, rootTopic, debugLogging = false, options = {}) {
@ -74,8 +78,6 @@ class Alex2MQTT extends events_1.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 transport_js_1.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();
@ -85,6 +87,18 @@ class Alex2MQTT extends events_1.EventEmitter {
this.lastDiscoveryAt = null;
this.options = options || {};
this.MqttHost = this.options.host || exports.DEFAULT_HOST;
this.publisher = this.options.publisher ?? new transport_js_1.MqttPublisher(() => this.client);
this.dispatcher = new dispatcher_js_1.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)
@ -129,50 +143,44 @@ class Alex2MQTT extends events_1.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((0, transport_js_1.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.
@ -253,7 +261,8 @@ class Alex2MQTT extends events_1.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. */