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

@ -1,17 +1,17 @@
import type { MqttClient } from "mqtt";
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(). */
export declare class AlexaErrorResponse {
private readonly correlationToken;
private readonly rootTopic;
private readonly endpointId;
private readonly mqttClient;
private readonly publisher;
private error;
private namespace?;
private readonly messageId;
/** Where a failed publish is reported (set by the Device that built this message, 1.5.2): send() never rejects. */
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
* carries the namespace the type is documented under, Alexa.ThermostatController for THERMOSTAT_IS_OFF;

View file

@ -1,13 +1,15 @@
import { randomUUID } from "crypto";
import { errorResponse } from "../messages/build.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(). */
export class AlexaErrorResponse {
constructor(correlationToken, rootTopic, endpointId, mqttClient) {
constructor(correlationToken, rootTopic, endpointId, publisher) {
this.correlationToken = correlationToken;
this.rootTopic = rootTopic;
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
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
@ -36,15 +38,7 @@ export class AlexaErrorResponse {
}
/** Resolves with the topic published to, or "" when the publish failed. Never rejects. */
send(sendAsync = false) {
const topic = `${this.rootTopic}/${this.endpointId}/${sendAsync ? "deferredResponse" : "alexaResponce"}`; // "alexaResponce" is how Alex2MQTT and Alex2ESP spell the topic
return new Promise((resolve) => {
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
});
});
const answer = sendAsync ? topics.deferred : topics.response;
return send(this.publisher, answer(this.rootTopic, this.endpointId), () => this.toJSON(), this.onPublishError);
}
}

View file

@ -1,5 +1,5 @@
import type { MqttClient } from "mqtt";
import type { ChangeCause, Property } from "../messages/types.js";
import type { Publisher } from "../transport.js";
import { TemperatureSensorScale } from "./enums.js";
import type { EndpointHealth, PowerController } from "./enums.js";
/**
@ -10,7 +10,7 @@ export declare class AlexaStatusMessage {
private readonly correlationToken;
private readonly rootTopic;
private readonly endpointId;
private readonly mqttClient;
private readonly publisher;
private readonly isResponse;
private readonly isDeferred;
private readonly changeCause;
@ -19,7 +19,7 @@ export declare class AlexaStatusMessage {
private estimatedDeferralInSeconds?;
/** Where a failed publish is reported (set by the Device that built this message, 1.5.2): send() never rejects. */
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;
/** ChangeReport: the add*Prop calls that follow describe what CHANGED (the default for a change report). */
changed(): this;

View file

@ -1,6 +1,8 @@
import { randomUUID } from "crypto";
import { changeReport, deferredResponse, response } from "../messages/build.js";
import { StateBuilder } from "../messages/StateBuilder.js";
import * as topics from "../topics.js";
import { send } from "../transport.js";
import { AlexaInterfaceType, TemperatureSensorScale } from "./enums.js";
// The 1.x helpers report every temperature in Celsius, whatever scale the device works in
function celsius(scale, value) {
@ -11,11 +13,11 @@ function celsius(scale, value) {
* device.getChangeReport(), the add...Prop() calls, send(). The values are not checked.
*/
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.rootTopic = rootTopic;
this.endpointId = endpointId;
this.mqttClient = mqttClient;
this.publisher = publisher;
this.isResponse = isResponse;
this.isDeferred = isDeferred;
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).
*/
send(sendAsync = false) {
const topic = this.changeCause
? `${this.rootTopic}/changeReport`
: `${this.rootTopic}/${this.endpointId}/${sendAsync ? "deferredResponse" : "alexaResponce"}`; // "alexaResponce" is how Alex2MQTT and Alex2ESP spell the topic
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)));
});
const answer = sendAsync ? topics.deferred : topics.response;
const topic = this.changeCause ? topics.changeReport(this.rootTopic) : answer(this.rootTopic, this.endpointId);
return send(this.publisher, topic, () => this.toJSON(), this.onPublishError);
}
}