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:
parent
46fa06728c
commit
6789a1a077
39 changed files with 839 additions and 246 deletions
5
dist/esm/Alex2Node.d.ts
vendored
5
dist/esm/Alex2Node.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
37
dist/esm/Alex2Node.js
vendored
37
dist/esm/Alex2Node.js
vendored
|
|
@ -5,6 +5,8 @@ import { checkEndpoint } from "./device/validate.js";
|
|||
import { EventEmitter } from "events";
|
||||
import { DisplayCategory } from "./compat/enums.js";
|
||||
import { DeclarationError } from "./registry/types.js";
|
||||
import * as topics from "./topics.js";
|
||||
import { MqttPublisher } from "./transport.js";
|
||||
export const DEFAULT_HOST = "mqtt://Alex2MQTT.stormysdream.club:1883";
|
||||
// What addDevice() copies from the definition to the device
|
||||
const DESCRIBED = [
|
||||
|
|
@ -33,6 +35,8 @@ class Alex2MQTT extends 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 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();
|
||||
|
|
@ -70,8 +74,6 @@ class Alex2MQTT extends EventEmitter {
|
|||
...(this.options.mqtt || {}),
|
||||
};
|
||||
this.client = mqtt.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");
|
||||
|
|
@ -94,12 +96,12 @@ class Alex2MQTT extends 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);
|
||||
});
|
||||
}
|
||||
|
|
@ -173,15 +175,13 @@ 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) {
|
||||
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(this.client, this.rootTopic, name, endpointId, (categories ?? []));
|
||||
const device = new Device(null, this.rootTopic, name, endpointId, (categories ?? []));
|
||||
for (const [field, value] of Object.entries(described)) {
|
||||
if (!DESCRIBED.includes(field))
|
||||
throw new DeclarationError({ endpointId }, `${field} is not a field of an endpoint`);
|
||||
|
|
@ -196,9 +196,6 @@ class Alex2MQTT extends 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`;
|
||||
|
|
@ -210,7 +207,7 @@ class Alex2MQTT extends EventEmitter {
|
|||
}
|
||||
this.log(`Creating new device with endpoint: ${endpointId}`);
|
||||
const normalizedCategory = Array.isArray(displayCategory) ? displayCategory : [displayCategory || DisplayCategory.LIGHT];
|
||||
const device = new Device(this.client, this.rootTopic, name, endpointId, normalizedCategory);
|
||||
const device = new Device(null, this.rootTopic, name, endpointId, normalizedCategory);
|
||||
this.register(device, false);
|
||||
return device;
|
||||
}
|
||||
|
|
@ -218,6 +215,7 @@ class Alex2MQTT extends 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. */
|
||||
|
|
@ -225,16 +223,21 @@ class Alex2MQTT extends 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();
|
||||
}
|
||||
|
|
|
|||
6
dist/esm/compat/AlexaErrorResponse.d.ts
vendored
6
dist/esm/compat/AlexaErrorResponse.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
18
dist/esm/compat/AlexaErrorResponse.js
vendored
18
dist/esm/compat/AlexaErrorResponse.js
vendored
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
6
dist/esm/compat/AlexaStatusMessage.d.ts
vendored
6
dist/esm/compat/AlexaStatusMessage.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
27
dist/esm/compat/AlexaStatusMessage.js
vendored
27
dist/esm/compat/AlexaStatusMessage.js
vendored
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
14
dist/esm/device/Device.d.ts
vendored
14
dist/esm/device/Device.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
36
dist/esm/device/Device.js
vendored
36
dist/esm/device/Device.js
vendored
|
|
@ -8,12 +8,14 @@ import { Alexa } from "../registry/interfaces/Alexa.js";
|
|||
import { EndpointHealth } from "../registry/interfaces/EndpointHealth.js";
|
||||
import { SchemaError } from "../registry/schema.js";
|
||||
import { DeclarationError } from "../registry/types.js";
|
||||
import * as topics from "../topics.js";
|
||||
import { send } from "../transport.js";
|
||||
import { Capability, commonOptions } from "./Capability.js";
|
||||
import { checkCapability, checkCapabilityCount, checkEndpoint } from "./validate.js";
|
||||
class Device extends EventEmitter {
|
||||
constructor(mqttClient, rootTopic, name, endpointId, displayCategory, description = "Alexa to Node.js bridge", manufacturerName = "Alex2Node", manufacturer = "Alex2Node", model = "Alex2Node_v1.0.0") {
|
||||
/** client is ignored: in 1.x it was the broker client, and a device could only be built after connect(). */
|
||||
constructor(client, rootTopic, name, endpointId, displayCategory, description = "Alexa to Node.js bridge", manufacturerName = "Alex2Node", manufacturer = "Alex2Node", model = "Alex2Node_v1.0.0") {
|
||||
super();
|
||||
this.mqttClient = mqttClient;
|
||||
this.rootTopic = rootTopic;
|
||||
this.name = name;
|
||||
this.endpointId = endpointId;
|
||||
|
|
@ -37,10 +39,12 @@ class Device extends EventEmitter {
|
|||
*/
|
||||
this.endpointHealth = false;
|
||||
this.capabilities = [];
|
||||
}
|
||||
/** Internal (1.5.2): Alex2MQTT.connect() re-binds every registered device to its new broker client after a disconnect(). */
|
||||
setMqttClient(client) {
|
||||
this.mqttClient = client;
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
this.publisher = null;
|
||||
}
|
||||
getName() {
|
||||
return this.name;
|
||||
|
|
@ -64,12 +68,12 @@ class Device extends EventEmitter {
|
|||
return this.description;
|
||||
}
|
||||
getErrorMessage(correlationToken) {
|
||||
const msg = new AlexaErrorResponse(correlationToken, this.rootTopic, this.endpointId, this.mqttClient);
|
||||
const msg = new AlexaErrorResponse(correlationToken, this.rootTopic, this.endpointId, this.publisher);
|
||||
msg.onPublishError = this.onPublishError;
|
||||
return msg;
|
||||
}
|
||||
getStatusMessage(correlationToken, isResponse = false, isDeferred = false) {
|
||||
const msg = new AlexaStatusMessage(correlationToken, this.rootTopic, this.endpointId, this.mqttClient, isResponse, isDeferred);
|
||||
const msg = new AlexaStatusMessage(correlationToken, this.rootTopic, this.endpointId, this.publisher, isResponse, isDeferred);
|
||||
msg.onPublishError = this.onPublishError;
|
||||
return msg;
|
||||
}
|
||||
|
|
@ -80,7 +84,7 @@ class Device extends EventEmitter {
|
|||
* Without a changed property send() publishes nothing and resolves with "": Alex2MQTT would drop the report.
|
||||
*/
|
||||
getChangeReport(cause = "PHYSICAL_INTERACTION") {
|
||||
const msg = new AlexaStatusMessage("", this.rootTopic, this.endpointId, this.mqttClient, false, false, cause);
|
||||
const msg = new AlexaStatusMessage("", this.rootTopic, this.endpointId, this.publisher, false, false, cause);
|
||||
msg.onPublishError = this.onPublishError;
|
||||
return msg;
|
||||
}
|
||||
|
|
@ -89,17 +93,9 @@ class Device extends EventEmitter {
|
|||
* (1.5.1). Resolves with the topic published to, or "" when the publish failed (never rejects, 1.5.2).
|
||||
*/
|
||||
sendSceneResponse(correlationToken, activated, cause = "VOICE_INTERACTION", sendAsync = false) {
|
||||
const payload = sceneEvent({ endpointId: this.endpointId, correlationToken, activated, cause });
|
||||
const topic = `${this.rootTopic}/${this.endpointId}/${sendAsync ? "deferredResponse" : "alexaResponce"}`;
|
||||
return new Promise((resolve) => {
|
||||
this.mqttClient.publish(topic, JSON.stringify(payload), (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;
|
||||
const build = () => sceneEvent({ endpointId: this.endpointId, correlationToken, activated, cause });
|
||||
return send(this.publisher, answer(this.rootTopic, this.endpointId), build, this.onPublishError);
|
||||
}
|
||||
/** The capabilities the device declared, in the order it declared them. */
|
||||
getCapabilities() {
|
||||
|
|
|
|||
3
dist/esm/index.d.ts
vendored
3
dist/esm/index.d.ts
vendored
|
|
@ -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";
|
||||
|
|
|
|||
3
dist/esm/index.js
vendored
3
dist/esm/index.js
vendored
|
|
@ -12,6 +12,9 @@ export { ASSETS as Assets, UNITS_OF_MEASURE as Units, ACTIONS as Actions, STATES
|
|||
// The messages: what the bridge publishes, built from plain values
|
||||
export * as messages from "./messages/index.js";
|
||||
export { AlexaError, AlexaErrors, MessageError, StateBuilder, property } from "./messages/index.js";
|
||||
// Publishing: the topics of the Alex2MQTT contract, and a publisher that needs no broker for tests
|
||||
export * as topics from "./topics.js";
|
||||
export { MemoryPublisher } from "./transport.js";
|
||||
// 1.x
|
||||
export { AlexaInterface } from "./compat/AlexaInterface.js";
|
||||
export { ActionMapping } from "./compat/ActionMapping.js";
|
||||
|
|
|
|||
8
dist/esm/topics.d.ts
vendored
Normal file
8
dist/esm/topics.d.ts
vendored
Normal 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;
|
||||
10
dist/esm/topics.js
vendored
Normal file
10
dist/esm/topics.js
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
// The topics of the Alex2MQTT contract, named once. The backend and Alex2ESP use the same names, so none of them
|
||||
// can change here alone.
|
||||
/** Where the bridge answers a discovery request with its endpoints. */
|
||||
export const discoverReply = (root) => `${root}/discover_r`;
|
||||
/** Where the answer to a directive goes. "alexaResponce" is how the backend and Alex2ESP spell it. */
|
||||
export const response = (root, endpointId) => `${root}/${endpointId}/alexaResponce`;
|
||||
/** Where the answer goes once a DeferredResponse was sent for the directive. */
|
||||
export const deferred = (root, endpointId) => `${root}/${endpointId}/deferredResponse`;
|
||||
/** Where every ChangeReport of the root goes: the backend adds the user's token and posts it to Alexa. */
|
||||
export const changeReport = (root) => `${root}/changeReport`;
|
||||
48
dist/esm/transport.d.ts
vendored
Normal file
48
dist/esm/transport.d.ts
vendored
Normal 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>;
|
||||
77
dist/esm/transport.js
vendored
Normal file
77
dist/esm/transport.js
vendored
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
const asError = (err) => (err instanceof Error ? err : new Error(String(err)));
|
||||
/**
|
||||
* 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 class MqttPublisher {
|
||||
constructor(client) {
|
||||
this.client = client;
|
||||
}
|
||||
publish(topic, message) {
|
||||
const client = this.client();
|
||||
return new Promise((resolve) => {
|
||||
const failed = (err) => resolve({ ok: false, topic, error: asError(err) });
|
||||
if (!client) {
|
||||
failed(new Error(`nothing was published to ${topic}: the bridge is not connected, call connect() first`));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
client.publish(topic, JSON.stringify(message), (err) => (err ? failed(err) : resolve({ ok: true, topic })));
|
||||
}
|
||||
catch (err) {
|
||||
failed(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 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 class MemoryPublisher {
|
||||
constructor() {
|
||||
/** Oldest first. The message is what a subscriber gets after JSON.parse. */
|
||||
this.published = [];
|
||||
/** Set it and every publish fails with this error, as a publish to a broker that is gone does. */
|
||||
this.failWith = null;
|
||||
}
|
||||
publish(topic, message) {
|
||||
if (this.failWith)
|
||||
return Promise.resolve({ ok: false, topic, error: this.failWith });
|
||||
try {
|
||||
this.published.push({ topic, message: JSON.parse(JSON.stringify(message)) });
|
||||
}
|
||||
catch (err) {
|
||||
return Promise.resolve({ ok: false, topic, error: asError(err) });
|
||||
}
|
||||
return Promise.resolve({ ok: true, topic });
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 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 async function send(publisher, topic, build, report) {
|
||||
let result;
|
||||
try {
|
||||
const message = build();
|
||||
if (!publisher) {
|
||||
throw new Error(`nothing was published to ${topic}: the device is on no bridge, register it with addDevice() or registerDevice()`);
|
||||
}
|
||||
result = await publisher.publish(topic, message);
|
||||
}
|
||||
catch (err) {
|
||||
result = { ok: false, topic, error: asError(err) };
|
||||
}
|
||||
if (result.ok)
|
||||
return topic;
|
||||
if (report)
|
||||
report(result.error);
|
||||
return "";
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue