1.5.2: send() never rejects (status, error, scene, change report: resolves the topic or "" and reports the failure through the bridge's error event when listened to - 1.5.1 rejected, and an un-caught .send() killed the host on any broker hiccup); types ship (declaration: true, dist/*.d.ts tracked; addSupportedModes takes {value, modeResources}, ActionMapping payload optional, addHealthProp accepts EndpointHealth or the string); disconnect()/connect() re-binds devices to the new client; registerDevice returns the existing device on a duplicate endpointId (warning via the log hook, console.warn without one); ThermostatController discovery lists targetSetpoint and drops adaptiveRecoveryStatus; the UNSUPORTED INTERFACE TYPE stderr spam goes through the log hook. Examples: require("alex2node"), an error listener in each, EndpointHealth.OK, neutral endpoint ids, BlindControl reads correlationToken from the header, the thermostat reports Fahrenheit as Fahrenheit, ExamplePowerController is power-only again + new ExamplePowerControllerWithBrightness. readme (install from Forgejo, 1.5.2 changelog, table syntax), LICENSE (MIT); tests for the non-rejecting send, reconnect, duplicate, thermostat discovery and the shipped declaration signatures (8/8 on an in-process broker).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
David 2026-09-28 04:58:39 +00:00
parent 717af632de
commit 275f00f8a7
35 changed files with 1011 additions and 176 deletions

View file

@ -20,7 +20,7 @@ export class ActionMapping {
constructor(
actions: AlexaActions[],
directiveName: string,
directivePayload: string | undefined
directivePayload?: string
) {
this.actions = actions;
this.directive = {

View file

@ -2,6 +2,7 @@ import mqtt, { MqttClient, IClientOptions } from "mqtt";
import Device from "./Device";
import { EventEmitter } from "events";
import { DisplayCategory } from "./DisplayCategory";
import { AlexaInterfaceType } from "./AlexaInterface";
/** Optional settings for the bridge (1.5.1). Everything has the 1.4.0 behaviour as its default. */
export interface Alex2MQTTOptions {
@ -75,6 +76,7 @@ class Alex2MQTT extends EventEmitter {
};
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;
@ -96,6 +98,10 @@ class Alex2MQTT extends EventEmitter {
this.log("Discovery request received, getting device json...");
const deviceArray = this.devices.map((device) => device.getJSON());
this.lastDiscoveryAt = new Date().toISOString();
for (const device of this.devices) // a capability the library has no property list for goes out with supported: []
for (const cap of device.getCapabilities())
if (cap.getProps().length === 0 && cap.getType() !== AlexaInterfaceType.SCENE_CONTROLLER)
this.log(`${device.endpointId}: no property list known for ${cap.getTypeString()}, discovery lists it with no supported properties`);
this.client!.publish(topic + "_r", JSON.stringify(deviceArray), (err) => {
if (err) { this.fail(err); return; }
this.log(`Discovery payloads published to ${topic + "_r"}`, deviceArray);
@ -149,6 +155,12 @@ class Alex2MQTT extends EventEmitter {
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`;
if (this.options.log) this.options.log(warning); else console.warn(`[Alex2Node.ts] ${warning}`); // visible without a log hook too
return existing;
}
this.log(`Creating new device with endpoint: ${endpointId}`);
const normalizedCategory =
displayCategory === null
@ -164,6 +176,7 @@ class Alex2MQTT extends EventEmitter {
endpointId,
normalizedCategory
);
device.onPublishError = (err) => this.fail(err); // a failed publish is an "error" event (when listened to), never a rejected send()
this.devices.push(device);
return device;
}

View file

@ -85,6 +85,8 @@ export class AlexaErrorResponse {
private rootTopic: string;
private endpointId: string;
private mqttClient: MqttClient;
/** Where a failed publish is reported (set by the Device that built this message, 1.5.2): send() never rejects. */
public onPublishError?: (err: Error) => void;
constructor(
correlationToken: string,
@ -136,8 +138,12 @@ export class AlexaErrorResponse {
sendAsync ? "deferredResponse" : "alexaResponce"
}`; //Yes this should be response but it is incorrect in both Alex2MQTT and Alex2ESP so for consistency is is wrong here too
const payloadStr = JSON.stringify(payload);
return new Promise((resolve, reject) => {
this.mqttClient.publish(topic, payloadStr, (err) => (err ? reject(err) : resolve(topic)));
return new Promise((resolve) => {
this.mqttClient.publish(topic, payloadStr, (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
});
});
}
}

View file

@ -78,10 +78,16 @@ interface FriendlyName {
locale: string;
}
/** One ModeController mode as discovery lists it (configuration.supportedModes): the value plus its friendly names. */
export interface SupportedMode {
value: string;
modeResources?: { friendlyNames: Array<{ "@type": string; value: { text?: string; locale?: string; assetId?: string } }> };
}
export class AlexaInterface {
private friendlyNames: FriendlyName[] = [];
private actionMappings: ActionMapping[] = [];
private supportedModes: string[] = [];
private supportedModes: Array<string | SupportedMode> = [];
constructor(
public type: AlexaInterfaceType,
@ -97,7 +103,8 @@ export class AlexaInterface {
addFriendlyName(name: string, locale: string): void {
this.friendlyNames.push({ text: name, locale });
}
addSupportedModes(modes: string[]): void {
/** The modes of a ModeController: { value, modeResources } objects as Alexa wants them (plain strings pass through as given). */
addSupportedModes(modes: Array<string | SupportedMode>): void {
this.supportedModes = modes;
}
setInstance(name: string): void {
@ -218,10 +225,10 @@ export class AlexaInterface {
return ["temperature"];
case AlexaInterfaceType.THERMOSTAT_CONTROLLER:
return [
"targetSetpoint",
"lowerSetpoint",
"upperSetpoint",
"thermostatMode",
"adaptiveRecoveryStatus",
];
case AlexaInterfaceType.APPLICATION_STATE_REPORTER:
case AlexaInterfaceType.AUDIO_PLAY_QUEUE:
@ -272,7 +279,8 @@ export class AlexaInterface {
case AlexaInterfaceType.USER_PREFERENCE:
case AlexaInterfaceType.VIDEO_RECORDER:
case AlexaInterfaceType.WAKE_ON_LAN_CONTROLLER:
console.error("UNSUPORTED INTERFACE TYPE");
// no property list known for these: discovery lists the capability with an empty "supported" (the bridge notes
// it through its log hook; 1.5.1 wrote "UNSUPORTED INTERFACE TYPE" to stderr on every discovery)
return [];
default:

View file

@ -65,6 +65,8 @@ export class AlexaStatusMessage {
private endpointId: string;
private mqttClient: MqttClient;
private isDeferred: boolean;
/** Where a failed publish is reported (set by the Device that built this message, 1.5.2): send() never rejects. */
public onPublishError?: (err: Error) => void;
constructor(
correlationToken: string,
@ -202,7 +204,8 @@ export class AlexaStatusMessage {
);
}
public addHealthProp(health: EndpointHealth, uncertaintyInMs = 0): this {
/** Alexa.EndpointHealth connectivity: EndpointHealth.OK / UNREACHABLE (the plain strings "OK" / "UNREACHABLE" are accepted too). */
public addHealthProp(health: EndpointHealth | `${EndpointHealth}`, uncertaintyInMs = 0): this {
return this.addProperty(
AlexaInterfaceType.ENDPOINT_HEALTH,
"connectivity",
@ -289,15 +292,20 @@ export class AlexaStatusMessage {
/**
* Publish: a Response/StateReport to <root>/<endpoint>/alexaResponce (sendAsync: deferredResponse), a ChangeReport
* to <root>/changeReport (Alex2MQTT adds the user's token and posts it to the Alexa event gateway). Resolves with
* the topic; rejects on a publish error (1.4.0 only logged).
* the topic, or "" when the publish failed - the error then goes to the bridge's "error" event (when listened to).
* Never rejects (1.5.1 did, so an un-caught send() could kill the host).
*/
public send(sendAsync: boolean = false): Promise<string> {
const payloadStr = JSON.stringify(this.toJSON());
const topic = this.changeCause
? `${this.rootTopic}/changeReport`
: `${this.rootTopic}/${this.endpointId}/${sendAsync ? "deferredResponse" : "alexaResponce"}`; //Yes this should be response but it is incorrect in both Alex2MQTT and Alex2ESP so for consistency is is wrong here too
return new Promise((resolve, reject) => {
this.mqttClient.publish(topic, payloadStr, (err) => (err ? reject(err) : resolve(topic)));
return new Promise((resolve) => {
this.mqttClient.publish(topic, payloadStr, (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
});
});
}
}

View file

@ -9,6 +9,8 @@ import { AlexaErrorResponse } from "./AlexaErrorResponse";
class Device extends EventEmitter {
protected softwareVersion: string = "1.0.0";
private capabilities: AlexaInterface[] = [];
/** Where a failed publish from this device or a message it built is reported; Alex2MQTT sets it (1.5.2). */
public onPublishError?: (err: Error) => void;
constructor(
private mqttClient: MqttClient,
@ -24,6 +26,11 @@ class Device extends EventEmitter {
super();
}
/** Internal (1.5.2): Alex2MQTT.connect() re-binds every registered device to its new broker client after a disconnect(). */
setMqttClient(client: MqttClient): void {
this.mqttClient = client;
}
getName(): string {
return this.name;
}
@ -52,19 +59,21 @@ class Device extends EventEmitter {
return this.description;
}
getErrorMessage(correlationToken: string): AlexaErrorResponse {
return new AlexaErrorResponse(
const msg = new AlexaErrorResponse(
correlationToken,
this.rootTopic,
this.endpointId,
this.mqttClient
);
msg.onPublishError = this.onPublishError;
return msg;
}
getStatusMessage(
correlationToken: string,
isResponse = false,
isDeferred = false
): AlexaStatusMessage {
return new AlexaStatusMessage(
const msg = new AlexaStatusMessage(
correlationToken,
this.rootTopic,
this.endpointId,
@ -72,6 +81,8 @@ class Device extends EventEmitter {
isResponse,
isDeferred
);
msg.onPublishError = this.onPublishError;
return msg;
}
/**
* A proactive Alexa.ChangeReport (1.5.1): add the changed properties (the default target), optionally
@ -79,11 +90,13 @@ class Device extends EventEmitter {
* event gateway with the user's token. Alexa only accepts it for capabilities registered with proactivelyReported.
*/
getChangeReport(cause: ChangeCause = "PHYSICAL_INTERACTION"): AlexaStatusMessage {
return new AlexaStatusMessage("", this.rootTopic, this.endpointId, this.mqttClient, false, false, cause);
const msg = new AlexaStatusMessage("", this.rootTopic, this.endpointId, this.mqttClient, false, false, cause);
msg.onPublishError = this.onPublishError;
return msg;
}
/**
* Alexa.SceneController: answer an Activate / Deactivate directive with ActivationStarted / DeactivationStarted
* (1.5.1). Resolves with the topic published to.
* (1.5.1). Resolves with the topic published to, or "" when the publish failed (never rejects, 1.5.2).
*/
sendSceneResponse(correlationToken: string, activated: boolean, cause: ChangeCause = "VOICE_INTERACTION", sendAsync = false): Promise<string> {
const payload = {
@ -95,8 +108,12 @@ class Device extends EventEmitter {
},
};
const topic = `${this.rootTopic}/${this.endpointId}/${sendAsync ? "deferredResponse" : "alexaResponce"}`;
return new Promise((resolve, reject) => {
this.mqttClient.publish(topic, JSON.stringify(payload), (err) => (err ? reject(err) : resolve(topic)));
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
});
});
}
getCapabilities(): AlexaInterface[] {

View file

@ -2,6 +2,7 @@ export { default as Alex2MQTT, DEFAULT_HOST } from "./Alex2Node";
export type { Alex2MQTTOptions } from "./Alex2Node";
export { default as Device } from "./Device";
export { AlexaInterface, AlexaInterfaceType } from "./AlexaInterface";
export type { SupportedMode } from "./AlexaInterface";
export { ActionMapping, AlexaActions } from "./ActionMapping";
export { DisplayCategory } from "./DisplayCategory";
export { AlexaStatusMessage, PowerController, EndpointHealth, TemperatureSensorScale, ThermostatMode } from "./AlexaStatusMessage";