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:
David 2026-09-28 19:34:39 +00:00
parent 46fa06728c
commit 6789a1a077
39 changed files with 839 additions and 246 deletions

70
dist/cjs/Alex2Node.js vendored
View file

@ -1,4 +1,37 @@
"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) {
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 enums_js_1 = require("./compat/enums.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";
// What addDevice() copies from the definition to the device
const DESCRIBED = [
@ -39,6 +74,8 @@ 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();
@ -76,8 +113,6 @@ class Alex2MQTT extends events_1.EventEmitter {
...(this.options.mqtt || {}),
};
this.client = mqtt_1.default.connect(this.MqttHost, options);
for (const d of this.devices)
d.setMqttClient(this.client); // after a disconnect(): the devices publish through the new client
this.client.on("connect", () => {
this.connected = true;
this.log("Connected to MQTT broker");
@ -100,12 +135,12 @@ class Alex2MQTT extends events_1.EventEmitter {
this.log("Discovery request received, getting device json...");
const deviceArray = this.describeDevices();
this.lastDiscoveryAt = new Date().toISOString();
this.client.publish(topic + "_r", JSON.stringify(deviceArray), (err) => {
if (err) {
this.fail(err);
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 ${topic + "_r"}`, deviceArray);
this.log(`Discovery payloads published to ${result.topic}`, deviceArray);
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
* 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) {
if (!this.client) {
throw new Error("Must call connect before creating devices");
}
const { endpointId, name, categories, endpointHealth, ...described } = definition;
// 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)) {
if (!DESCRIBED.includes(field))
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;
}
registerDevice(name, endpointId, displayCategory) {
if (!this.client) {
throw new Error("Must call connect before creating devices");
}
const existing = this.devices.find((d) => d.endpointId === endpointId);
if (existing) { // 1.5.2: endpointIds are unique per root topic; 1.5.1 added a second device that never got a directive
const warning = `warning: registerDevice("${endpointId}") is already registered as "${existing.name}", returning that device`;
@ -216,7 +246,7 @@ class Alex2MQTT extends events_1.EventEmitter {
}
this.log(`Creating new device with endpoint: ${endpointId}`);
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);
return device;
}
@ -224,6 +254,7 @@ 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;
this.devices.push(device);
}
/** 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);
if (i === -1)
return false;
this.devices[i].removeAllListeners();
this.release(this.devices[i]);
this.devices.splice(i, 1);
return true;
}
/** Forget every device. */
clearDevices() {
for (const d of this.devices)
d.removeAllListeners();
this.release(d);
this.devices = [];
}
// A device the bridge forgot hears no directive and publishes nothing
release(device) {
device.removeAllListeners();
device.publisher = null;
}
getDevices() {
return this.devices.slice();
}