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

@ -38,6 +38,7 @@ declare class Alex2MQTT extends EventEmitter {
private rootTopic;
private debugLogging;
private client;
private readonly publisher;
private devices;
private MqttHost;
private options;
@ -62,7 +63,8 @@ declare class Alex2MQTT extends 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: EndpointDefinition): Device;
registerDevice(name: string, endpointId: string, displayCategory?: DisplayCategory | DisplayCategory[] | null): Device;
@ -71,6 +73,7 @@ declare class Alex2MQTT extends EventEmitter {
unregisterDevice(endpointId: string): boolean;
/** Forget every device. */
clearDevices(): void;
private release;
getDevices(): Device[];
getDevice(endpointId: string): Device | undefined;
getRootTopic(): string;

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,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,5 +1,4 @@
import { EventEmitter } from "events";
import type { MqttClient } from "mqtt";
import { AlexaErrorResponse } from "../compat/AlexaErrorResponse.js";
import { AlexaInterface } from "../compat/AlexaInterface.js";
import { AlexaStatusMessage } from "../compat/AlexaStatusMessage.js";
@ -8,6 +7,7 @@ import type { AlexaInterfaceType } from "../compat/enums.js";
import type { ChangeCause } from "../messages/types.js";
import type { DisplayCategoryName } from "../registry/catalog.js";
import type { Directives, InterfaceDescriptor, Properties } from "../registry/types.js";
import type { Publisher } from "../transport.js";
import { Capability } from "./Capability.js";
import type { AnyCapability, CapabilityJson, Declaration } from "./Capability.js";
import type { EndpointFields } from "./validate.js";
@ -40,7 +40,6 @@ export interface EndpointJson extends EndpointFields {
}
type DeclarationArguments<O, I extends boolean> = {} extends Declaration<O, I> ? [options?: Declaration<O, I>] : [options: Declaration<O, I>];
declare class Device extends EventEmitter {
private mqttClient;
private rootTopic;
name: string;
endpointId: string;
@ -67,9 +66,14 @@ declare class Device extends EventEmitter {
private capabilities;
/** Where a failed publish from this device or a message it built is reported; Alex2MQTT sets it (1.5.2). */
onPublishError?: (err: Error) => void;
constructor(mqttClient: MqttClient, rootTopic: string, name: string, endpointId: string, displayCategory: Array<DisplayCategory> | null, description?: string, manufacturerName?: string, manufacturer?: string, model?: string);
/** Internal (1.5.2): Alex2MQTT.connect() re-binds every registered device to its new broker client after a disconnect(). */
setMqttClient(client: MqttClient): void;
/**
* What the device and the messages it builds publish through. The bridge sets it when the device is registered
* and clears it when the device is unregistered; a MemoryPublisher here tests a device without a broker. While it
* is null every send() resolves "" and reports why.
*/
publisher: Publisher | null;
/** client is ignored: in 1.x it was the broker client, and a device could only be built after connect(). */
constructor(client: unknown, rootTopic: string, name: string, endpointId: string, displayCategory: Array<DisplayCategory> | null, description?: string, manufacturerName?: string, manufacturer?: string, model?: string);
getName(): string;
setName(name: string): void;
getEndpointId(): string;

View file

@ -13,6 +13,9 @@ export type { ActionId, AssetId, DisplayCategoryName, StateId, UnitOfMeasure, Ac
export * as messages from "./messages/index.js";
export { AlexaError, AlexaErrors, MessageError, StateBuilder, property } from "./messages/index.js";
export type { ChangeCause, ChangeReportMessage, DeferredResponseMessage, ErrorResponseMessage, Header, ProactiveEventMessage, Property, PropertyOptions, ResponseMessage, SceneEventMessage, } from "./messages/index.js";
export * as topics from "./topics.js";
export { MemoryPublisher } from "./transport.js";
export type { Publisher, PublishResult } from "./transport.js";
export { AlexaInterface } from "./compat/AlexaInterface.js";
export type { SupportedMode } from "./compat/AlexaInterface.js";
export { ActionMapping } from "./compat/ActionMapping.js";

8
dist/types/topics.d.ts vendored Normal file
View file

@ -0,0 +1,8 @@
/** Where the bridge answers a discovery request with its endpoints. */
export declare const discoverReply: (root: string) => string;
/** Where the answer to a directive goes. "alexaResponce" is how the backend and Alex2ESP spell it. */
export declare const response: (root: string, endpointId: string) => string;
/** Where the answer goes once a DeferredResponse was sent for the directive. */
export declare const deferred: (root: string, endpointId: string) => string;
/** Where every ChangeReport of the root goes: the backend adds the user's token and posts it to Alexa. */
export declare const changeReport: (root: string) => string;

48
dist/types/transport.d.ts vendored Normal file
View file

@ -0,0 +1,48 @@
import type { MqttClient } from "mqtt";
/** What became of a publish. A failure is a value: publish() never rejects. */
export type PublishResult = {
ok: true;
topic: string;
} | {
ok: false;
topic: string;
error: Error;
};
/** Where the bridge, its devices and their messages publish. The message is sent as JSON. */
export interface Publisher {
publish(topic: string, message: object): Promise<PublishResult>;
}
/**
* Publishes to the broker through the client the bridge has at the time: a new one after disconnect() and connect(),
* none before connect() and after disconnect(). Without a client the publish is refused. With a client that lost
* its connection mqtt.js keeps the message and sends it after the reconnect, and the publish resolves then.
*/
export declare class MqttPublisher implements Publisher {
private readonly client;
constructor(client: () => MqttClient | null);
publish(topic: string, message: object): Promise<PublishResult>;
}
/**
* Keeps what it is asked to publish, for tests and dry runs without a broker:
*
* const sent = new MemoryPublisher();
* device.publisher = sent;
* await device.getStatusMessage(token, true).addPowerControllerProp(PowerController.ON).send();
* sent.published[0] // { topic: "<root>/<endpointId>/alexaResponce", message: { event, context } }
*/
export declare class MemoryPublisher implements Publisher {
/** Oldest first. The message is what a subscriber gets after JSON.parse. */
readonly published: Array<{
topic: string;
message: any;
}>;
/** Set it and every publish fails with this error, as a publish to a broker that is gone does. */
failWith: Error | null;
publish(topic: string, message: object): Promise<PublishResult>;
}
/**
* send() as 1.x promises it: resolves with the topic, or with "" when nothing was published, and never rejects
* (1.5.1 rejected, and a send() nobody caught killed the host on any broker hiccup). What went wrong goes to report:
* the message cannot be built, the device is on no bridge, or the publish failed.
*/
export declare function send(publisher: Publisher | null, topic: string, build: () => object, report?: (err: Error) => void): Promise<string>;