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

View file

@ -5,7 +5,7 @@ const { test } = require("node:test");
const assert = require("node:assert/strict");
const {
Alex2MQTT, AlexaErrorResponse, AlexaErrorType, AlexaInterfaceType, AlexaStatusMessage, DisplayCategory, EndpointHealth,
PowerController, TemperatureSensorScale,
MemoryPublisher, PowerController, TemperatureSensorScale,
} = require("alex2node");
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 }
);
// 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", () => {
const message = new AlexaStatusMessage("ct", "root", "5020AA", null, true)
.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 () => {
const client = recorder();
const error = new AlexaErrorResponse("ct", "root", "5020AA", client);
const sent = new MemoryPublisher();
const error = new AlexaErrorResponse("ct", "root", "5020AA", sent);
error.setErrorMessage(AlexaErrorType.VALUE_OUT_OF_RANGE, "too high", { validRange: { minimumValue: 1, maximumValue: 10 } });
const expected = {
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.equal(await error.send(), "root/5020AA/alexaResponce");
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/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 () => {
const client = recorder();
const status = (...flags) => new AlexaStatusMessage("ct", "root", "5020AA", client, ...flags).addPowerControllerProp(PowerController.ON);
const sent = new MemoryPublisher();
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(true), "root/5020AA/deferredResponse");
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 = [];
failing.onPublishError = (err) => reported.push(err.message);
assert.equal(await failing.send(), "");

View file

@ -1,10 +1,10 @@
"use strict";
// The broker connection: the default host, an outage reported as events that never crash the host, disconnect() then
// connect(), and a publish that fails without rejecting.
// The broker connection: the default host, an outage reported as events that never crash the host, devices declared
// before connect(), disconnect() then connect(), and a publish that fails without rejecting.
const { test } = require("node:test");
const assert = require("node:assert/strict");
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.
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");
});
test("registerDevice before connect throws (unchanged from 1.4.0)", () => {
assert.throws(() => new Alex2MQTT("u", "p", "root").registerDevice("x", "1", null), /connect before/);
test("devices declared before connect() (1.x threw): a send() is refused until the bridge connects, then they answer", async () => {
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 () => {
@ -66,7 +85,7 @@ test("1.5.2: a failed publish never rejects send() - it resolves \"\" and reache
await connected(bridge);
const lamp = bridge.registerDevice("Lamp", "lamp-send", null);
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 error = await lamp.getErrorMessage("ct").send();

View file

@ -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.
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"]) {
return new Device(null, "root", name, endpointId, categories);
}

65
test/transport.test.js Normal file
View 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);
});