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

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024-2026 user511 (David)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

20
dist/ActionMapping.d.ts vendored Normal file
View file

@ -0,0 +1,20 @@
export declare enum AlexaActions {
Open = "Alexa.Actions.Open",
Close = "Alexa.Actions.Close",
Raise = "Alexa.Actions.Raise",
Lower = "Alexa.Actions.Lower",
SetEcoOn = "Alexa.Actions.SetEcoOn",
SetEcoOff = "Alexa.Actions.SetEcoOff"
}
interface Directive {
name: string;
payload?: string;
}
export declare class ActionMapping {
type: string;
actions: AlexaActions[];
directive: Directive;
constructor(actions: AlexaActions[], directiveName: string, directivePayload?: string);
toJSON(): object;
}
export {};

59
dist/Alex2Node.d.ts vendored Normal file
View file

@ -0,0 +1,59 @@
import { IClientOptions } from "mqtt";
import Device from "./Device";
import { EventEmitter } from "events";
import { DisplayCategory } from "./DisplayCategory";
/** Optional settings for the bridge (1.5.1). Everything has the 1.4.0 behaviour as its default. */
export interface Alex2MQTTOptions {
/** The broker URL. Default: the public Alex2MQTT broker, mqtt://Alex2MQTT.stormysdream.club:1883. */
host?: string;
/** Extra mqtt.js client options (reconnectPeriod, connectTimeout, clientId, ...). Merged over the defaults. */
mqtt?: IClientOptions;
/** Where log lines go. Default: console (only when debugLogging is on). */
log?: (message: string, detail?: unknown) => void;
}
export declare const DEFAULT_HOST = "mqtt://Alex2MQTT.stormysdream.club:1883";
/**
* The Alexa-to-MQTT bridge: one broker connection for a user's root topic, a set of registered devices, discovery and
* directive dispatch.
*
* Events (all optional to listen to - since 1.5.1 a broker outage never throws out of the library):
* "connect" connected (or reconnected) and subscribed to <root>/#
* "offline" the connection dropped; mqtt.js reconnects on its own (reconnectPeriod, default 1 s)
* "reconnect" a reconnect attempt starts
* "close" the connection closed
* "error" (err) a connection or publish error. Emitted ONLY when a listener is attached (1.4.0 emitted it
* unconditionally, and Node kills a process that has an unhandled "error" event)
* "discover" (n) a discovery request was answered with n devices
* "directive" (info) a directive was dispatched to a device: { endpointId, namespace, name }
*/
declare class Alex2MQTT extends EventEmitter {
private username;
private password;
private rootTopic;
private debugLogging;
private client;
private devices;
private MqttHost;
private options;
/** true while the broker connection is up. */
connected: boolean;
/** ISO time of the last discovery request answered, null before the first. */
lastDiscoveryAt: string | null;
constructor(username: string, password: string, rootTopic: string, debugLogging?: boolean, options?: Alex2MQTTOptions);
private log;
/** Emit "error" only when somebody listens: an unhandled "error" event would crash the host process. */
private fail;
connect(): void;
/** Close the broker connection (resolves once closed). The devices stay registered; connect() again reuses them. */
disconnect(): Promise<void>;
registerDevice(name: string, endpointId: string, displayCategory: DisplayCategory | DisplayCategory[] | null): Device;
/** Forget a device (its listeners with it). Returns false when there was none. */
unregisterDevice(endpointId: string): boolean;
/** Forget every device. */
clearDevices(): void;
getDevices(): Device[];
getDevice(endpointId: string): Device | undefined;
getRootTopic(): string;
getHost(): string;
}
export default Alex2MQTT;

17
dist/Alex2Node.js vendored
View file

@ -8,6 +8,7 @@ const mqtt_1 = __importDefault(require("mqtt"));
const Device_1 = __importDefault(require("./Device")); const Device_1 = __importDefault(require("./Device"));
const events_1 = require("events"); const events_1 = require("events");
const DisplayCategory_1 = require("./DisplayCategory"); const DisplayCategory_1 = require("./DisplayCategory");
const AlexaInterface_1 = require("./AlexaInterface");
exports.DEFAULT_HOST = "mqtt://Alex2MQTT.stormysdream.club:1883"; exports.DEFAULT_HOST = "mqtt://Alex2MQTT.stormysdream.club:1883";
/** /**
* The Alexa-to-MQTT bridge: one broker connection for a user's root topic, a set of registered devices, discovery and * The Alexa-to-MQTT bridge: one broker connection for a user's root topic, a set of registered devices, discovery and
@ -60,6 +61,8 @@ class Alex2MQTT extends events_1.EventEmitter {
return; return;
const options = Object.assign({ username: this.username, password: this.password, reconnectPeriod: 1000, connectTimeout: 10000 }, (this.options.mqtt || {})); const options = Object.assign({ username: this.username, password: this.password, reconnectPeriod: 1000, connectTimeout: 10000 }, (this.options.mqtt || {}));
this.client = mqtt_1.default.connect(this.MqttHost, options); this.client = mqtt_1.default.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.client.on("connect", () => {
this.connected = true; this.connected = true;
this.log("Connected to MQTT broker"); this.log("Connected to MQTT broker");
@ -82,6 +85,10 @@ class Alex2MQTT extends events_1.EventEmitter {
this.log("Discovery request received, getting device json..."); this.log("Discovery request received, getting device json...");
const deviceArray = this.devices.map((device) => device.getJSON()); const deviceArray = this.devices.map((device) => device.getJSON());
this.lastDiscoveryAt = new Date().toISOString(); 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() !== AlexaInterface_1.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) => { this.client.publish(topic + "_r", JSON.stringify(deviceArray), (err) => {
if (err) { if (err) {
this.fail(err); this.fail(err);
@ -137,6 +144,15 @@ class Alex2MQTT extends events_1.EventEmitter {
if (!this.client) { if (!this.client) {
throw new Error("Must call connect before creating devices"); 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}`); this.log(`Creating new device with endpoint: ${endpointId}`);
const normalizedCategory = displayCategory === null const normalizedCategory = displayCategory === null
? [DisplayCategory_1.DisplayCategory.LIGHT] ? [DisplayCategory_1.DisplayCategory.LIGHT]
@ -144,6 +160,7 @@ class Alex2MQTT extends events_1.EventEmitter {
? displayCategory ? displayCategory
: [displayCategory || DisplayCategory_1.DisplayCategory.LIGHT]; : [displayCategory || DisplayCategory_1.DisplayCategory.LIGHT];
const device = new Device_1.default(this.client, this.rootTopic, name, endpointId, normalizedCategory); const device = new Device_1.default(this.client, this.rootTopic, name, 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); this.devices.push(device);
return device; return device;
} }

93
dist/AlexaErrorResponse.d.ts vendored Normal file
View file

@ -0,0 +1,93 @@
import { MqttClient } from "mqtt";
export declare enum AlexaErrorType {
ALREADY_IN_OPERATION = "ALREADY_IN_OPERATION",
AUTHORIZATION_REQUIRED = "AUTHORIZATION_REQUIRED",
BRIDGE_UNREACHABLE = "BRIDGE_UNREACHABLE",
BYPASS_NEEDED = "BYPASS_NEEDED",
CLOUD_CONTROL_DISABLED = "CLOUD_CONTROL_DISABLED",
CHILD_LOCK = "CHILD_LOCK",
CONFIGURATION_UPDATE_NOT_ALLOWED = "CONFIGURATION_UPDATE_NOT_ALLOWED",
COOK_DURATION_TOO_LONG = "COOK_DURATION_TOO_LONG",
COOLING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE = "COOLING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE",
COOLING_STAGES_EXCEEDS_LIMIT = "COOLING_STAGES_EXCEEDS_LIMIT",
DATA_DELETION_NOT_SUPPORTED = "DATA_DELETION_NOT_SUPPORTED",
DATA_RETRIEVAL_NOT_SUPPORTED = "DATA_RETRIEVAL_NOT_SUPPORTED",
DEVICE_STUCK = "DEVICE_STUCK",
DISABLED_BY_USER = "DISABLED_BY_USER",
DO_NOT_DISTURB_MODE = "DO_NOT_DISTURB_MODE",
DOOR_CLOSED_TOO_LONG = "DOOR_CLOSED_TOO_LONG",
DOOR_OPEN = "DOOR_OPEN",
DUAL_SETPOINTS_UNSUPPORTED = "DUAL_SETPOINTS_UNSUPPORTED",
ENDPOINT_BUSY = "ENDPOINT_BUSY",
ENDPOINT_CONTROL_UNAVAILABLE = "ENDPOINT_CONTROL_UNAVAILABLE",
ENDPOINT_LOW_POWER = "ENDPOINT_LOW_POWER",
ENDPOINT_UNREACHABLE = "ENDPOINT_UNREACHABLE",
EXCEEDED_PIN_ATTEMPTS = "EXCEEDED_PIN_ATTEMPTS",
EXPIRED_AUTHORIZATION_CREDENTIAL = "EXPIRED_AUTHORIZATION_CREDENTIAL",
FAILED_TO_BOOTSTRAP_COMMISSIONING_PROCESS = "FAILED_TO_BOOTSTRAP_COMMISSIONING_PROCESS",
FIRMWARE_OUT_OF_DATE = "FIRMWARE_OUT_OF_DATE",
HARDWARE_MALFUNCTION = "HARDWARE_MALFUNCTION",
HEATING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE = "HEATING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE",
HEATING_STAGES_EXCEEDS_LIMIT = "HEATING_STAGES_EXCEEDS_LIMIT",
INSUFFICIENT_PERMISSIONS = "INSUFFICIENT_PERMISSIONS",
INSUFFICIENT_RESOURCE = "INSUFFICIENT_RESOURCE",
INSUFFICIENT_SPACE = "INSUFFICIENT_SPACE",
INTERNAL_ERROR = "INTERNAL_ERROR",
INVALID_AUTHORIZATION_CREDENTIAL = "INVALID_AUTHORIZATION_CREDENTIAL",
INVALID_AUXILIARY_HEATING_SYSTEM_TYPE = "INVALID_AUXILIARY_HEATING_SYSTEM_TYPE",
INVALID_DIRECTIVE = "INVALID_DIRECTIVE",
INVALID_SYSTEM_TYPE = "INVALID_SYSTEM_TYPE",
INVALID_TARGET_STATE = "INVALID_TARGET_STATE",
INVALID_TEMPERATURE_SCALE = "INVALID_TEMPERATURE_SCALE",
INVALID_TERMINAL_CONNECTION = "INVALID_TERMINAL_CONNECTION",
INVALID_VALUE = "INVALID_VALUE",
MAINTENANCE_REQUIRED = "MAINTENANCE_REQUIRED",
MAX_COMMISSIONING_LIMIT_REACHED = "MAX_COMMISSIONING_LIMIT_REACHED",
MISSING_SETUP_INFORMATION = "MISSING_SETUP_INFORMATION",
NO_SUCH_ENDPOINT = "NO_SUCH_ENDPOINT",
NOT_CALIBRATED = "NOT_CALIBRATED",
NOT_IN_OPERATION = "NOT_IN_OPERATION",
NOT_READY = "NOT_READY",
NOT_SUPPORTED_IN_CURRENT_MODE = "NOT_SUPPORTED_IN_CURRENT_MODE",
NOT_SUPPORTED_WITH_CURRENT_BATTERY_CHARGE_STATE = "NOT_SUPPORTED_WITH_CURRENT_BATTERY_CHARGE_STATE",
OBSTACLE_DETECTED = "OBSTACLE_DETECTED",
PARTNER_APPLICATION_REDIRECTION = "PARTNER_APPLICATION_REDIRECTION",
PIN_SETUP_REQUIRED = "PIN_SETUP_REQUIRED",
POWER_LEVEL_NOT_SUPPORTED = "POWER_LEVEL_NOT_SUPPORTED",
PREHEAT_REQUIRED = "PREHEAT_REQUIRED",
PROBE_REQUIRED = "PROBE_REQUIRED",
RATE_LIMIT_EXCEEDED = "RATE_LIMIT_EXCEEDED",
REMOTE_START_NOT_SUPPORTED = "REMOTE_START_NOT_SUPPORTED",
REMOVE_PROBE = "REMOVE_PROBE",
REMOTE_START_DISABLED = "REMOTE_START_DISABLED",
REQUESTED_SETPOINTS_TOO_CLOSE = "REQUESTED_SETPOINTS_TOO_CLOSE",
SAFETY_BEAM_BREACHED = "SAFETY_BEAM_BREACHED",
SUBSCRIPTION_REQUIRED = "SUBSCRIPTION_REQUIRED",
TEMPERATURE_VALUE_OUT_OF_RANGE = "TEMPERATURE_VALUE_OUT_OF_RANGE",
THERMOSTAT_IS_OFF = "THERMOSTAT_IS_OFF",
TOO_MANY_FAILED_ATTEMPTS = "TOO_MANY_FAILED_ATTEMPTS",
TRIPLE_SETPOINTS_UNSUPPORTED = "TRIPLE_SETPOINTS_UNSUPPORTED",
UNABLE_TO_CHARGE = "UNABLE_TO_CHARGE",
UNAUTHORIZED = "UNAUTHORIZED",
UNCLEARED_ALARM = "UNCLEARED_ALARM",
UNSUPPORTED_THERMOSTAT_MODE = "UNSUPPORTED_THERMOSTAT_MODE",
UNCLEARED_TROUBLE = "UNCLEARED_TROUBLE",
UNWILLING_TO_SET_SCHEDULE = "UNWILLING_TO_SET_SCHEDULE",
UNWILLING_TO_SET_VALUE = "UNWILLING_TO_SET_VALUE",
VALUE_OUT_OF_RANGE = "VALUE_OUT_OF_RANGE"
}
export declare class AlexaErrorResponse {
private event;
private rootTopic;
private endpointId;
private mqttClient;
/** 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);
private generateMessageId;
setErrorMessage(type: string, message: string, otherParams?: Record<string, any>): void;
toJSON(): {
event: any;
};
send(sendAsync?: boolean): Promise<string>;
}

View file

@ -115,8 +115,14 @@ class AlexaErrorResponse {
}; };
const topic = `${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 const topic = `${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
const payloadStr = JSON.stringify(payload); const payloadStr = JSON.stringify(payload);
return new Promise((resolve, reject) => { return new Promise((resolve) => {
this.mqttClient.publish(topic, payloadStr, (err) => (err ? reject(err) : resolve(topic))); 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
});
}); });
} }
} }

107
dist/AlexaInterface.d.ts vendored Normal file
View file

@ -0,0 +1,107 @@
import { ActionMapping } from "./ActionMapping";
export declare enum AlexaInterfaceType {
APPLICATION_STATE_REPORTER = "Alexa.ApplicationStateReporter",
AUDIO_PLAY_QUEUE = "Alexa.Audio.PlayQueue",
AUTHORIZATION_CONTROLLER = "Alexa.AuthorizationController",
AUTOMATION_MANAGEMENT = "Alexa.AutomationManagement",
AUTOMOTIVE_VEHICLE_DATA = "Alexa.Automotive.VehicleData",
BRIGHTNESS_CONTROLLER = "Alexa.BrightnessController",
CAMERA_LIVE_VIEW_CONTROLLER = "Alexa.Camera.LiveViewController",
CAMERA_STREAM_CONTROLLER = "Alexa.CameraStreamController",
CHANNEL_CONTROLLER = "Alexa.ChannelController",
COLOR_CONTROLLER = "Alexa.ColorController",
COLOR_TEMPERATURE_CONTROLLER = "Alexa.ColorTemperatureController",
COMMISSIONABLE = "Alexa.Commissionable",
CONSENT_MANAGEMENT_CONSENT_REQUIRED_REPORTER = "Alexa.ConsentManagement.ConsentRequiredReporter",
CONTACT_SENSOR = "Alexa.ContactSensor",
COOKING = "Alexa.Cooking",
COOKING_FOOD_TEMPERATURE_CONTROLLER = "Alexa.Cooking.FoodTemperatureController",
COOKING_FOOD_TEMPERATURE_SENSOR = "Alexa.Cooking.FoodTemperatureSensor",
COOKING_PRESET_CONTROLLER = "Alexa.Cooking.PresetController",
COOKING_TEMPERATURE_CONTROLLER = "Alexa.Cooking.TemperatureController",
COOKING_TEMPERATURE_SENSOR = "Alexa.Cooking.TemperatureSensor",
COOKING_TIME_CONTROLLER = "Alexa.Cooking.TimeController",
DATA_CONTROLLER = "Alexa.DataController",
DEVICE_USAGE_ESTIMATION = "Alexa.DeviceUsage.Estimation",
DEVICE_USAGE_METER = "Alexa.DeviceUsage.Meter",
DOORBELL_EVENT_SOURCE = "Alexa.DoorbellEventSource",
ENDPOINT_HEALTH = "Alexa.EndpointHealth",
EQUALIZER_CONTROLLER = "Alexa.EqualizerController",
INPUT_CONTROLLER = "Alexa.InputController",
INVENTORY_LEVEL_SENSOR = "Alexa.InventoryLevelSensor",
INVENTORY_LEVEL_USAGE_SENSOR = "Alexa.InventoryLevelUsageSensor",
INVENTORY_USAGE_SENSOR = "Alexa.InventoryUsageSensor",
KEYPAD_CONTROLLER = "Alexa.KeypadController",
LAUNCHER = "Alexa.Launcher",
LOCK_CONTROLLER = "Alexa.LockController",
MEDIA_PLAYBACK = "Alexa.Media.Playback",
MEDIA_PLAY_QUEUE = "Alexa.Media.PlayQueue",
MEDIA_SEARCH = "Alexa.Media.Search",
MODE_CONTROLLER = "Alexa.ModeController",
MOTION_SENSOR = "Alexa.MotionSensor",
PERCENTAGE_CONTROLLER = "Alexa.PercentageController",
PLAYBACK_CONTROLLER = "Alexa.PlaybackController",
PLAYBACK_STATE_REPORTER = "Alexa.PlaybackStateReporter",
POWER_CONTROLLER = "Alexa.PowerController",
POWER_LEVEL_CONTROLLER = "Alexa.PowerLevelController",
PROACTIVE_NOTIFICATION_SOURCE = "Alexa.ProactiveNotificationSource",
RANGE_CONTROLLER = "Alexa.RangeController",
RECORD_CONTROLLER = "Alexa.RecordController",
REMOTE_VIDEO_PLAYER = "Alexa.RemoteVideoPlayer",
RTC_SESSION_CONTROLLER = "Alexa.RTCSessionController",
SCENE_CONTROLLER = "Alexa.SceneController",
SECURITY_PANEL_CONTROLLER = "Alexa.SecurityPanelController",
SECURITY_PANEL_CONTROLLER_ALERT = "Alexa.SecurityPanelController.Alert",
SEEK_CONTROLLER = "Alexa.SeekController",
SIMPLE_EVENT_SOURCE = "Alexa.SimpleEventSource",
SMART_VISION_OBJECT_DETECTION_SENSOR = "Alexa.SmartVision.ObjectDetectionSensor",
SMART_VISION_SNAPSHOT_PROVIDER = "Alexa.SmartVision.SnapshotProvider",
SPEAKER = "Alexa.Speaker",
STEP_SPEAKER = "Alexa.StepSpeaker",
TEMPERATURE_SENSOR = "Alexa.TemperatureSensor",
THERMOSTAT_CONTROLLER = "Alexa.ThermostatController",
THERMOSTAT_CONTROLLER_CONFIGURATION = "Alexa.ThermostatController.Configuration",
THERMOSTAT_CONTROLLER_HVAC_COMPONENTS = "Alexa.ThermostatController.HVAC.Components",
THERMOSTAT_CONTROLLER_SCHEDULE = "Alexa.ThermostatController.Schedule",
TIME_HOLD_CONTROLLER = "Alexa.TimeHoldController",
TOGGLE_CONTROLLER = "Alexa.ToggleController",
UI_CONTROLLER = "Alexa.UIController",
USER_PREFERENCE = "Alexa.UserPreference",
VIDEO_RECORDER = "Alexa.VideoRecorder",
WAKE_ON_LAN_CONTROLLER = "Alexa.WakeOnLANController",
UNKNOWN = "UNKNOWN"
}
/** 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 declare class AlexaInterface {
type: AlexaInterfaceType;
retrievable: boolean;
proactivelyReported: boolean;
instance: string;
private friendlyNames;
private actionMappings;
private supportedModes;
constructor(type: AlexaInterfaceType, retrievable?: boolean, proactivelyReported?: boolean, instance?: string);
addActionMapping(mapping: ActionMapping): void;
addFriendlyName(name: string, locale: 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;
setInstance(name: string): void;
getType(): AlexaInterfaceType;
getTypeString(): string;
getVersion(): string;
getProps(): string[];
getJSON(): object;
}

View file

@ -90,6 +90,7 @@ class AlexaInterface {
addFriendlyName(name, locale) { addFriendlyName(name, locale) {
this.friendlyNames.push({ text: name, locale }); this.friendlyNames.push({ text: name, locale });
} }
/** The modes of a ModeController: { value, modeResources } objects as Alexa wants them (plain strings pass through as given). */
addSupportedModes(modes) { addSupportedModes(modes) {
this.supportedModes = modes; this.supportedModes = modes;
} }
@ -206,10 +207,10 @@ class AlexaInterface {
return ["temperature"]; return ["temperature"];
case AlexaInterfaceType.THERMOSTAT_CONTROLLER: case AlexaInterfaceType.THERMOSTAT_CONTROLLER:
return [ return [
"targetSetpoint",
"lowerSetpoint", "lowerSetpoint",
"upperSetpoint", "upperSetpoint",
"thermostatMode", "thermostatMode",
"adaptiveRecoveryStatus",
]; ];
case AlexaInterfaceType.APPLICATION_STATE_REPORTER: case AlexaInterfaceType.APPLICATION_STATE_REPORTER:
case AlexaInterfaceType.AUDIO_PLAY_QUEUE: case AlexaInterfaceType.AUDIO_PLAY_QUEUE:
@ -260,7 +261,8 @@ class AlexaInterface {
case AlexaInterfaceType.USER_PREFERENCE: case AlexaInterfaceType.USER_PREFERENCE:
case AlexaInterfaceType.VIDEO_RECORDER: case AlexaInterfaceType.VIDEO_RECORDER:
case AlexaInterfaceType.WAKE_ON_LAN_CONTROLLER: 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 []; return [];
default: default:
return []; return [];

82
dist/AlexaStatusMessage.d.ts vendored Normal file
View file

@ -0,0 +1,82 @@
import { MqttClient } from "mqtt";
export declare enum EndpointHealth {
OK = "OK",
UNREACHABLE = "UNREACHABLE"
}
export declare enum ThermostatMode {
OFF = "OFF",
HEAT = "HEAT",
COOL = "COOL",
AUTO = "AUTO",
ECO = "ECO",
CUSTOM = "CUSTOM"
}
export declare enum PowerController {
ON = "ON",
OFF = "OFF"
}
export declare enum TemperatureSensorScale {
CELSIUS = "CELSIUS",
FAHRENHEIT = "FAHRENHEIT"
}
interface ContextProperty {
namespace: string;
name: string;
value: any;
timeOfSample: string;
uncertaintyInMilliseconds: number;
instance?: string;
}
/** Why a ChangeReport is sent (Alexa.ChangeReport payload.change.cause.type). */
export type ChangeCause = "APP_INTERACTION" | "PHYSICAL_INTERACTION" | "PERIODIC_POLL" | "RULE_TRIGGER" | "VOICE_INTERACTION";
export declare class AlexaStatusMessage {
private context;
/** ChangeReport only: the properties that changed (payload.change.properties); the rest go to context. */
private changeProps;
private changeCause;
private target;
private event;
private rootTopic;
private endpointId;
private mqttClient;
private isDeferred;
/** 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);
private generateMessageId;
private getTimestamp;
private addProperty;
/** ChangeReport: the add*Prop calls that follow describe what CHANGED (the default for a change report). */
changed(): this;
/** ChangeReport: the add*Prop calls that follow describe the other, unchanged properties (context). */
unchanged(): this;
/** True for a ChangeReport (Device.getChangeReport). */
isChangeReport(): boolean;
/** The message as it will be published (for tests and logging). */
toJSON(): {
event: any;
context: {
properties: ContextProperty[];
} | null;
};
addModeControllerProp(instance: string, value: string, uncertaintyInMs?: number): this;
addThermostatModeProp(mode: string, uncertaintyInMs?: number): this;
addEstimatedDeferralTime(seconds: number): this;
addThermostatControllerProp(name: "lowerSetpoint" | "upperSetpoint" | "targetSetpoint", scale: TemperatureSensorScale, value: number, uncertaintyInMs?: number): this;
/** Alexa.EndpointHealth connectivity: EndpointHealth.OK / UNREACHABLE (the plain strings "OK" / "UNREACHABLE" are accepted too). */
addHealthProp(health: EndpointHealth | `${EndpointHealth}`, uncertaintyInMs?: number): this;
addPowerControllerProp(power: PowerController, uncertaintyInMs?: number): this;
addTemperatureSensorProp(scale: TemperatureSensorScale, value: number, uncertaintyInMs?: number): this;
addBrightnessControllerProp(brightness: number, uncertaintyInMs?: number): this;
addColorTemperatureControllerProp(colorTemp: number, uncertaintyInMs?: number): this;
addToggleControllerProp(state: PowerController, instance: string, uncertaintyInMs?: number): this;
addContextProp(prop: ContextProperty): this;
/**
* 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, 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).
*/
send(sendAsync?: boolean): Promise<string>;
}
export {};

View file

@ -130,6 +130,7 @@ class AlexaStatusMessage {
}; };
return this.addProperty(AlexaInterface_1.AlexaInterfaceType.THERMOSTAT_CONTROLLER, name, tempValue, uncertaintyInMs); return this.addProperty(AlexaInterface_1.AlexaInterfaceType.THERMOSTAT_CONTROLLER, name, tempValue, uncertaintyInMs);
} }
/** Alexa.EndpointHealth connectivity: EndpointHealth.OK / UNREACHABLE (the plain strings "OK" / "UNREACHABLE" are accepted too). */
addHealthProp(health, uncertaintyInMs = 0) { addHealthProp(health, uncertaintyInMs = 0) {
return this.addProperty(AlexaInterface_1.AlexaInterfaceType.ENDPOINT_HEALTH, "connectivity", { value: health }, uncertaintyInMs); return this.addProperty(AlexaInterface_1.AlexaInterfaceType.ENDPOINT_HEALTH, "connectivity", { value: health }, uncertaintyInMs);
} }
@ -161,15 +162,22 @@ class AlexaStatusMessage {
/** /**
* Publish: a Response/StateReport to <root>/<endpoint>/alexaResponce (sendAsync: deferredResponse), a ChangeReport * 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 * 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).
*/ */
send(sendAsync = false) { send(sendAsync = false) {
const payloadStr = JSON.stringify(this.toJSON()); const payloadStr = JSON.stringify(this.toJSON());
const topic = this.changeCause const topic = this.changeCause
? `${this.rootTopic}/changeReport` ? `${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 : `${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) => { return new Promise((resolve) => {
this.mqttClient.publish(topic, payloadStr, (err) => (err ? reject(err) : resolve(topic))); 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
});
}); });
} }
} }

60
dist/Device.d.ts vendored Normal file
View file

@ -0,0 +1,60 @@
import { AlexaInterface, AlexaInterfaceType } from "./AlexaInterface";
import { DisplayCategory } from "./DisplayCategory";
import { EventEmitter } from "events";
import { MqttClient } from "mqtt";
import { AlexaStatusMessage, ChangeCause } from "./AlexaStatusMessage";
import { AlexaErrorResponse } from "./AlexaErrorResponse";
declare class Device extends EventEmitter {
private mqttClient;
private rootTopic;
name: string;
endpointId: string;
displayCategory: Array<DisplayCategory> | null;
description: string;
manufacturerName: string;
manufacturer: string;
model: string;
protected softwareVersion: string;
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;
getName(): string;
setName(name: string): void;
getEndpointId(): string;
setDisplayCategory(category: DisplayCategory | DisplayCategory[]): void;
getDisplayCategory(): Array<DisplayCategory>;
setDescription(description: string): void;
getDescription(): string;
getErrorMessage(correlationToken: string): AlexaErrorResponse;
getStatusMessage(correlationToken: string, isResponse?: boolean, isDeferred?: boolean): AlexaStatusMessage;
/**
* A proactive Alexa.ChangeReport (1.5.1): add the changed properties (the default target), optionally
* .unchanged() then the others, and .send() - it goes to <root>/changeReport, which Alex2MQTT forwards to the Alexa
* event gateway with the user's token. Alexa only accepts it for capabilities registered with proactivelyReported.
*/
getChangeReport(cause?: ChangeCause): AlexaStatusMessage;
/**
* Alexa.SceneController: answer an Activate / Deactivate directive with ActivationStarted / DeactivationStarted
* (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, sendAsync?: boolean): Promise<string>;
getCapabilities(): AlexaInterface[];
setManufacturerName(name: string): void;
getManufacturerName(): string;
setManufacturer(manufacturer: string): void;
getManufacturer(): string;
setModel(model: string): void;
getModel(): string;
getSoftwareVersion(): string;
/** Add a capability. 1.5.1: options.retrievable / proactivelyReported / instance (a change report needs proactivelyReported). */
addCapability(type: AlexaInterfaceType, options?: {
retrievable?: boolean;
proactivelyReported?: boolean;
instance?: string;
}): AlexaInterface;
getJSON(): Record<string, any>;
}
export default Device;

28
dist/Device.js vendored
View file

@ -21,6 +21,10 @@ class Device extends events_1.EventEmitter {
this.softwareVersion = "1.0.0"; this.softwareVersion = "1.0.0";
this.capabilities = []; 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;
}
getName() { getName() {
return this.name; return this.name;
} }
@ -43,10 +47,14 @@ class Device extends events_1.EventEmitter {
return this.description; return this.description;
} }
getErrorMessage(correlationToken) { getErrorMessage(correlationToken) {
return new AlexaErrorResponse_1.AlexaErrorResponse(correlationToken, this.rootTopic, this.endpointId, this.mqttClient); const msg = new AlexaErrorResponse_1.AlexaErrorResponse(correlationToken, this.rootTopic, this.endpointId, this.mqttClient);
msg.onPublishError = this.onPublishError;
return msg;
} }
getStatusMessage(correlationToken, isResponse = false, isDeferred = false) { getStatusMessage(correlationToken, isResponse = false, isDeferred = false) {
return new AlexaStatusMessage_1.AlexaStatusMessage(correlationToken, this.rootTopic, this.endpointId, this.mqttClient, isResponse, isDeferred); const msg = new AlexaStatusMessage_1.AlexaStatusMessage(correlationToken, this.rootTopic, this.endpointId, this.mqttClient, isResponse, isDeferred);
msg.onPublishError = this.onPublishError;
return msg;
} }
/** /**
* A proactive Alexa.ChangeReport (1.5.1): add the changed properties (the default target), optionally * A proactive Alexa.ChangeReport (1.5.1): add the changed properties (the default target), optionally
@ -54,11 +62,13 @@ class Device extends events_1.EventEmitter {
* event gateway with the user's token. Alexa only accepts it for capabilities registered with proactivelyReported. * event gateway with the user's token. Alexa only accepts it for capabilities registered with proactivelyReported.
*/ */
getChangeReport(cause = "PHYSICAL_INTERACTION") { getChangeReport(cause = "PHYSICAL_INTERACTION") {
return new AlexaStatusMessage_1.AlexaStatusMessage("", this.rootTopic, this.endpointId, this.mqttClient, false, false, cause); const msg = new AlexaStatusMessage_1.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 * 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, activated, cause = "VOICE_INTERACTION", sendAsync = false) { sendSceneResponse(correlationToken, activated, cause = "VOICE_INTERACTION", sendAsync = false) {
const payload = { const payload = {
@ -70,8 +80,14 @@ class Device extends events_1.EventEmitter {
}, },
}; };
const topic = `${this.rootTopic}/${this.endpointId}/${sendAsync ? "deferredResponse" : "alexaResponce"}`; const topic = `${this.rootTopic}/${this.endpointId}/${sendAsync ? "deferredResponse" : "alexaResponce"}`;
return new Promise((resolve, reject) => { return new Promise((resolve) => {
this.mqttClient.publish(topic, JSON.stringify(payload), (err) => (err ? reject(err) : resolve(topic))); 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() { getCapabilities() {

58
dist/DisplayCategory.d.ts vendored Normal file
View file

@ -0,0 +1,58 @@
export declare enum DisplayCategory {
ACTIVITY_TRIGGER = "ACTIVITY_TRIGGER",
AIR_CONDITIONER = "AIR_CONDITIONER",
AIR_FRESHENER = "AIR_FRESHENER",
AIR_PURIFIER = "AIR_PURIFIER",
AIR_QUALITY_MONITOR = "AIR_QUALITY_MONITOR",
ALEXA_VOICE_ENABLED = "ALEXA_VOICE_ENABLED",
AUTO_ACCESSORY = "AUTO_ACCESSORY",
BLUETOOTH_SPEAKER = "BLUETOOTH_SPEAKER",
CAMERA = "CAMERA",
CHRISTMAS_TREE = "CHRISTMAS_TREE",
COFFEE_MAKER = "COFFEE_MAKER",
COMPUTER = "COMPUTER",
CONTACT_SENSOR = "CONTACT_SENSOR",
DISHWASHER = "DISHWASHER",
DOOR = "DOOR",
DOORBELL = "DOORBELL",
DRYER = "DRYER",
EXTERIOR_BLIND = "EXTERIOR_BLIND",
FAN = "FAN",
GAME_CONSOLE = "GAME_CONSOLE",
GARAGE_DOOR = "GARAGE_DOOR",
HEADPHONES = "HEADPHONES",
HUB = "HUB",
INTERIOR_BLIND = "INTERIOR_BLIND",
LAPTOP = "LAPTOP",
LIGHT = "LIGHT",
MICROWAVE = "MICROWAVE",
MOBILE_PHONE = "MOBILE_PHONE",
MOTION_SENSOR = "MOTION_SENSOR",
MUSIC_SYSTEM = "MUSIC_SYSTEM",
NETWORK_HARDWARE = "NETWORK_HARDWARE",
OTHER = "OTHER",
OVEN = "OVEN",
PHONE = "PHONE",
PRINTER = "PRINTER",
REMOTE = "REMOTE",
ROUTER = "ROUTER",
SCENE_TRIGGER = "SCENE_TRIGGER",
SCREEN = "SCREEN",
SECURITY_PANEL = "SECURITY_PANEL",
SECURITY_SYSTEM = "SECURITY_SYSTEM",
SLOW_COOKER = "SLOW_COOKER",
SMARTLOCK = "SMARTLOCK",
SMARTPLUG = "SMARTPLUG",
SPEAKER = "SPEAKER",
STREAMING_DEVICE = "STREAMING_DEVICE",
SWITCH = "SWITCH",
TABLET = "TABLET",
TEMPERATURE_SENSOR = "TEMPERATURE_SENSOR",
THERMOSTAT = "THERMOSTAT",
TV = "TV",
VACUUM_CLEANER = "VACUUM_CLEANER",
VEHICLE = "VEHICLE",
WASHER = "WASHER",
WATER_HEATER = "WATER_HEATER",
WEARABLE = "WEARABLE"
}

10
dist/index.d.ts vendored Normal file
View file

@ -0,0 +1,10 @@
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";
export type { ChangeCause } from "./AlexaStatusMessage";
export { AlexaErrorType, AlexaErrorResponse } from "./AlexaErrorResponse";

View file

@ -1,11 +1,12 @@
// Import required modules and types from the compiled TypeScript distribution (via dist/index.js) // Import the library ("alex2node" is the installed package; inside this checkout it resolves to the built dist/ via package.json "exports")
const { const {
Alex2MQTT, Alex2MQTT,
AlexaInterfaceType, AlexaInterfaceType,
ActionMapping, ActionMapping,
AlexaActions, AlexaActions,
PowerController PowerController,
} = require("../dist/index.js"); EndpointHealth
} = require("alex2node");
require("dotenv").config(); // Load environment variables from .env file require("dotenv").config(); // Load environment variables from .env file
@ -21,12 +22,13 @@ const rootTopic = process.env.MQTT_ROOT_TOPIC;
// Create and initialize a new Alexa-to-MQTT client // Create and initialize a new Alexa-to-MQTT client
const alex2NodeClient = new Alex2MQTT(username, password, rootTopic, false); const alex2NodeClient = new Alex2MQTT(username, password, rootTopic, false);
alex2NodeClient.connect(); // Connect to the MQTT broker alex2NodeClient.connect(); // Connect to the MQTT broker
alex2NodeClient.on("error", (e) => console.error("broker:", e.message)); // Log broker errors (on 1.5.0 this listener is also what keeps an outage from killing the process)
// Define the device name // Define the device name
const deviceName = "Bedroom Blinds"; const deviceName = "Bedroom Blinds";
// Register the device with a unique endpoint ID // Register the device with a unique endpoint ID
const blinds = alex2NodeClient.registerDevice(deviceName, "endpoint1"); const blinds = alex2NodeClient.registerDevice(deviceName, "bedroom-blinds-1");
// Add basic Alexa capabilities to the device // Add basic Alexa capabilities to the device
blinds.addCapability(AlexaInterfaceType.POWER_CONTROLLER); blinds.addCapability(AlexaInterfaceType.POWER_CONTROLLER);
@ -63,12 +65,12 @@ console.log(blinds.getName());
blinds.on("ReportState", (payload) => { blinds.on("ReportState", (payload) => {
console.log("ReportState received!", payload); console.log("ReportState received!", payload);
const { correlationToken } = payload; const { correlationToken } = payload.header;
const status = blinds.getStatusMessage(correlationToken); const status = blinds.getStatusMessage(correlationToken);
status status
.addHealthProp("OK") // Device is healthy .addHealthProp(EndpointHealth.OK) // Device is healthy
.addPowerControllerProp(outputState) // Report power state .addPowerControllerProp(outputState) // Report power state
.addToggleControllerProp(outputState, "NodeJS.Toggle"); // Report toggle controller state .addToggleControllerProp(outputState, "NodeJS.Toggle"); // Report toggle controller state
@ -101,7 +103,7 @@ blinds.on("Event", (directive, interfaceType) => {
// Respond to the directive with the updated device status // Respond to the directive with the updated device status
const status = blinds.getStatusMessage(token, true); const status = blinds.getStatusMessage(token, true);
status status
.addHealthProp("OK") .addHealthProp(EndpointHealth.OK)
.addToggleControllerProp(outputState, "NodeJS.Toggle") .addToggleControllerProp(outputState, "NodeJS.Toggle")
.addPowerControllerProp(outputState) .addPowerControllerProp(outputState)
.send(); .send();

View file

@ -1,10 +1,11 @@
// Import required modules and types from the compiled TypeScript distribution (via dist/index.js) // Import the library ("alex2node" is the installed package; inside this checkout it resolves to the built dist/ via package.json "exports")
const { const {
Alex2MQTT, Alex2MQTT,
AlexaInterfaceType, AlexaInterfaceType,
PowerController, PowerController,
DisplayCategory, DisplayCategory,
} = require("../dist/index.js"); EndpointHealth,
} = require("alex2node");
require("dotenv").config(); // Load environment variables from .env file require("dotenv").config(); // Load environment variables from .env file
@ -19,14 +20,15 @@ const rootTopic = process.env.MQTT_ROOT_TOPIC;
// Create and initialize a new Alexa-to-MQTT client // Create and initialize a new Alexa-to-MQTT client
const alex2NodeClient = new Alex2MQTT(username, password, rootTopic, false); const alex2NodeClient = new Alex2MQTT(username, password, rootTopic, false);
alex2NodeClient.connect(); // Connect to the MQTT broker alex2NodeClient.connect(); // Connect to the MQTT broker
alex2NodeClient.on("error", (e) => console.error("broker:", e.message)); // Log broker errors (on 1.5.0 this listener is also what keeps an outage from killing the process)
// Define the device name (Test mode controller) // Define the device name (Test mode controller)
const deviceName = "Test mode controller"; const deviceName = "Test mode controller";
// Register the device with a unique endpoint ID (you can use something like "endpoint1") // Register the device with a unique endpoint ID (unique per root topic, e.g. "fan-1")
const modeControllerDevice = alex2NodeClient.registerDevice( const modeControllerDevice = alex2NodeClient.registerDevice(
deviceName, deviceName,
"endpoint6", "fan-1",
[DisplayCategory.OTHER] [DisplayCategory.OTHER]
); );
@ -83,7 +85,7 @@ modeControllerDevice.on("ReportState", (payload) => {
const status = modeControllerDevice.getStatusMessage(correlationToken); const status = modeControllerDevice.getStatusMessage(correlationToken);
status status
.addHealthProp("OK") // Device is healthy .addHealthProp(EndpointHealth.OK) // Device is healthy
.addModeControllerProp("mode.fanmode", currentMode); .addModeControllerProp("mode.fanmode", currentMode);
status.send(); // Send the state report back to Alexa status.send(); // Send the state report back to Alexa
@ -110,7 +112,7 @@ modeControllerDevice.on("Event", (directive, interfaceType) => {
const status = modeControllerDevice.getStatusMessage(token, true); const status = modeControllerDevice.getStatusMessage(token, true);
status status
.addHealthProp("OK") // Device is healthy .addHealthProp(EndpointHealth.OK) // Device is healthy
.addModeControllerProp("mode.fanmode", currentMode); .addModeControllerProp("mode.fanmode", currentMode);
status.send(); // Send the state report back to Alexa status.send(); // Send the state report back to Alexa

View file

@ -1,9 +1,10 @@
// Import required modules and types from the compiled TypeScript distribution (via dist/index.js) // Import the library ("alex2node" is the installed package; inside this checkout it resolves to the built dist/ via package.json "exports")
const { const {
Alex2MQTT, Alex2MQTT,
AlexaInterfaceType, AlexaInterfaceType,
PowerController PowerController,
} = require("../dist/index.js"); EndpointHealth
} = require("alex2node");
require("dotenv").config(); // Load environment variables from .env file require("dotenv").config(); // Load environment variables from .env file
@ -18,12 +19,13 @@ const rootTopic = process.env.MQTT_ROOT_TOPIC;
// Create and initialize a new Alexa-to-MQTT client // Create and initialize a new Alexa-to-MQTT client
const alex2NodeClient = new Alex2MQTT(username, password, rootTopic, false); const alex2NodeClient = new Alex2MQTT(username, password, rootTopic, false);
alex2NodeClient.connect(); // Connect to the MQTT broker alex2NodeClient.connect(); // Connect to the MQTT broker
alex2NodeClient.on("error", (e) => console.error("broker:", e.message)); // Log broker errors (on 1.5.0 this listener is also what keeps an outage from killing the process)
// Define the device name (bedroom light) // Define the device name (bedroom light)
const deviceName = "Bedroom Light"; const deviceName = "Bedroom Light";
// Register the device with a unique endpoint ID (you can use something like "endpoint1") // Register the device with a unique endpoint ID (unique per root topic, e.g. "bedroom-light-1")
const bedroomLight = alex2NodeClient.registerDevice(deviceName, "2E3AE9"); const bedroomLight = alex2NodeClient.registerDevice(deviceName, "bedroom-light-1");
// Add the PowerController capability (for turning on/off the device) // Add the PowerController capability (for turning on/off the device)
bedroomLight.addCapability(AlexaInterfaceType.POWER_CONTROLLER); bedroomLight.addCapability(AlexaInterfaceType.POWER_CONTROLLER);
@ -31,9 +33,6 @@ bedroomLight.addCapability(AlexaInterfaceType.POWER_CONTROLLER);
// Log the device name to verify registration // Log the device name to verify registration
console.log(bedroomLight.getName()); console.log(bedroomLight.getName());
const momentaryModeInstance = "mode.signal.beep";
const defaultMomentaryMode = "mode.signal.beep.Normal";
/** /**
* Handle Alexa's ReportState directive. * Handle Alexa's ReportState directive.
* This occurs when Alexa queries the current state of the device (e.g., during routines or device status checks). * This occurs when Alexa queries the current state of the device (e.g., during routines or device status checks).
@ -46,10 +45,8 @@ bedroomLight.on("ReportState", (payload) => {
const status = bedroomLight.getStatusMessage(correlationToken); const status = bedroomLight.getStatusMessage(correlationToken);
status status
.addHealthProp("OK") // Device is healthy .addHealthProp(EndpointHealth.OK) // Device is healthy
.addPowerControllerProp(outputState) // Report the current power state (ON/OFF) .addPowerControllerProp(outputState); // Report the current power state (ON/OFF)
.addBrightnessControllerProp(100)
.addModeControllerProp(momentaryModeInstance, defaultMomentaryMode);
status.send(); // Send the state report back to Alexa status.send(); // Send the state report back to Alexa
}); });
@ -78,9 +75,8 @@ bedroomLight.on("Event", (directive, interfaceType) => {
// Respond to the directive with the updated device status // Respond to the directive with the updated device status
const status = bedroomLight.getStatusMessage(token, true); const status = bedroomLight.getStatusMessage(token, true);
status status
.addHealthProp("OK") .addHealthProp(EndpointHealth.OK)
.addPowerControllerProp(outputState) .addPowerControllerProp(outputState)
.addBrightnessControllerProp(100)
.send(); .send();
} }
}); });

View file

@ -1,9 +1,10 @@
// Import required modules and types from the compiled TypeScript distribution (via dist/index.js) // Import the library ("alex2node" is the installed package; inside this checkout it resolves to the built dist/ via package.json "exports")
const { const {
Alex2MQTT, Alex2MQTT,
AlexaInterfaceType, AlexaInterfaceType,
PowerController PowerController,
} = require("../dist/index.js"); EndpointHealth
} = require("alex2node");
require("dotenv").config(); // Load environment variables from .env file require("dotenv").config(); // Load environment variables from .env file
@ -18,12 +19,13 @@ const rootTopic = process.env.MQTT_ROOT_TOPIC;
// Create and initialize a new Alexa-to-MQTT client // Create and initialize a new Alexa-to-MQTT client
const alex2NodeClient = new Alex2MQTT(username, password, rootTopic, false); const alex2NodeClient = new Alex2MQTT(username, password, rootTopic, false);
alex2NodeClient.connect(); // Connect to the MQTT broker alex2NodeClient.connect(); // Connect to the MQTT broker
alex2NodeClient.on("error", (e) => console.error("broker:", e.message)); // Log broker errors (on 1.5.0 this listener is also what keeps an outage from killing the process)
// Define the device name (deferred light) // Define the device name (deferred light)
const deviceName = "Deferred Light"; const deviceName = "Deferred Light";
// Register the device with a unique endpoint ID (you can use something like "endpoint1") // Register the device with a unique endpoint ID (unique per root topic, e.g. "deferred-light-1")
const deferredLight = alex2NodeClient.registerDevice(deviceName, "endpoint3"); const deferredLight = alex2NodeClient.registerDevice(deviceName, "deferred-light-1");
// Add the PowerController capability (for turning on/off the device) // Add the PowerController capability (for turning on/off the device)
deferredLight.addCapability(AlexaInterfaceType.POWER_CONTROLLER); deferredLight.addCapability(AlexaInterfaceType.POWER_CONTROLLER);
@ -40,7 +42,7 @@ deferredLight.on("ReportState", (payload) => {
const { correlationToken } = payload.header; const { correlationToken } = payload.header;
// Step 1: Send a DeferredResponse after ~1 second // Step 1: Send a DeferredResponse after ~2 seconds
setTimeout(() => { setTimeout(() => {
const deferred = deferredLight.getStatusMessage(correlationToken, false, true); // isDeferred = true const deferred = deferredLight.getStatusMessage(correlationToken, false, true); // isDeferred = true
deferred.addEstimatedDeferralTime(20).send(); deferred.addEstimatedDeferralTime(20).send();
@ -51,7 +53,7 @@ deferredLight.on("ReportState", (payload) => {
setTimeout(() => { setTimeout(() => {
const status = deferredLight.getStatusMessage(correlationToken,false,false); // normal StateReport const status = deferredLight.getStatusMessage(correlationToken,false,false); // normal StateReport
status status
.addHealthProp("OK") .addHealthProp(EndpointHealth.OK)
.addPowerControllerProp(outputState) .addPowerControllerProp(outputState)
.send(true); .send(true);
console.log("Sent actual StateReport with device status"); console.log("Sent actual StateReport with device status");
@ -83,7 +85,7 @@ deferredLight.on("Event", (directive, interfaceType) => {
// Respond to the directive with the updated device status // Respond to the directive with the updated device status
const status = deferredLight.getStatusMessage(token, true); const status = deferredLight.getStatusMessage(token, true);
status status
.addHealthProp("OK") .addHealthProp(EndpointHealth.OK)
.addPowerControllerProp(outputState) .addPowerControllerProp(outputState)
.send(); .send();
} }

View file

@ -0,0 +1,99 @@
// Import the library ("alex2node" is the installed package; inside this checkout it resolves to the built dist/ via package.json "exports")
const {
Alex2MQTT,
AlexaInterfaceType,
PowerController,
EndpointHealth
} = require("alex2node");
require("dotenv").config(); // Load environment variables from .env file
// Simulated in-memory device state (used for example/demo purposes)
let outputState = PowerController.OFF; // Initialize the power state to OFF
let brightness = 100; // 0-100, what Alexa.BrightnessController reports
// Load MQTT connection credentials and root topic from environment variables
const username = process.env.MQTT_USERNAME;
const password = process.env.MQTT_PASSWORD;
const rootTopic = process.env.MQTT_ROOT_TOPIC;
// Create and initialize a new Alexa-to-MQTT client
const alex2NodeClient = new Alex2MQTT(username, password, rootTopic, false);
alex2NodeClient.connect(); // Connect to the MQTT broker
alex2NodeClient.on("error", (e) => console.error("broker:", e.message)); // Log broker errors (on 1.5.0 this listener is also what keeps an outage from killing the process)
// Define the device name (dimmable living room light)
const deviceName = "Living Room Light";
// Register the device with a unique endpoint ID (unique per root topic, e.g. "living-room-light-1")
const livingRoomLight = alex2NodeClient.registerDevice(deviceName, "living-room-light-1");
// Declare every capability the device reports: on/off AND brightness (a property Alexa has not been told about is ignored)
livingRoomLight.addCapability(AlexaInterfaceType.POWER_CONTROLLER);
livingRoomLight.addCapability(AlexaInterfaceType.BRIGHTNESS_CONTROLLER);
// Log the device name to verify registration
console.log(livingRoomLight.getName());
// Clamp a brightness to Alexa's 0-100 range
const clamp = (value) => Math.max(0, Math.min(100, Math.round(value)));
/**
* Handle Alexa's ReportState directive.
* This occurs when Alexa queries the current state of the device (e.g., during routines or device status checks).
*/
livingRoomLight.on("ReportState", (payload) => {
console.log("ReportState received!", payload);
const { correlationToken } = payload.header;
const status = livingRoomLight.getStatusMessage(correlationToken);
status
.addHealthProp(EndpointHealth.OK) // Device is healthy
.addPowerControllerProp(outputState) // Report the current power state (ON/OFF)
.addBrightnessControllerProp(brightness); // Report the current brightness (0-100)
status.send(); // Send the state report back to Alexa
});
/**
* Handle incoming control directives (TurnOn / TurnOff, SetBrightness / AdjustBrightness).
* These directives come from Alexa when a user issues a command.
*/
livingRoomLight.on("Event", (directive, interfaceType) => {
console.log("Event received", { directive, interfaceType });
const name = directive.header.name;
const token = directive.header.correlationToken;
if (interfaceType === AlexaInterfaceType.POWER_CONTROLLER) {
// Update the internal state based on the command (TurnOn / TurnOff)
if (name === "TurnOn") {
outputState = PowerController.ON;
console.log("Turning ON the Living Room Light");
} else if (name === "TurnOff") {
outputState = PowerController.OFF;
console.log("Turning OFF the Living Room Light");
}
} else if (interfaceType === AlexaInterfaceType.BRIGHTNESS_CONTROLLER) {
// "Set the light to 40 percent" -> SetBrightness { brightness }; "dim the light" -> AdjustBrightness { brightnessDelta }
if (name === "SetBrightness") {
brightness = clamp(directive.payload.brightness);
} else if (name === "AdjustBrightness") {
brightness = clamp(brightness + directive.payload.brightnessDelta);
}
outputState = brightness > 0 ? PowerController.ON : PowerController.OFF; // a dimmer at 0 is off
console.log(`Living Room Light brightness is now ${brightness}%`);
} else {
return; // not one of ours
}
// Respond to the directive with the full device status (both properties, whichever one changed)
const status = livingRoomLight.getStatusMessage(token, true);
status
.addHealthProp(EndpointHealth.OK)
.addPowerControllerProp(outputState)
.addBrightnessControllerProp(brightness)
.send();
});

View file

@ -1,9 +1,11 @@
// Import the library ("alex2node" is the installed package; inside this checkout it resolves to the built dist/ via package.json "exports")
const { const {
Alex2MQTT, Alex2MQTT,
AlexaInterfaceType, AlexaInterfaceType,
PowerController, PowerController,
AlexaErrorType, AlexaErrorType,
} = require("../dist/index.js"); EndpointHealth,
} = require("alex2node");
require("dotenv").config(); require("dotenv").config();
@ -15,8 +17,9 @@ const rootTopic = process.env.MQTT_ROOT_TOPIC;
const alex2NodeClient = new Alex2MQTT(username, password, rootTopic, false); const alex2NodeClient = new Alex2MQTT(username, password, rootTopic, false);
alex2NodeClient.connect(); alex2NodeClient.connect();
alex2NodeClient.on("error", (e) => console.error("broker:", e.message)); // Log broker errors (on 1.5.0 this listener is also what keeps an outage from killing the process)
const errorLight = alex2NodeClient.registerDevice("bad light", "endpoint4"); const errorLight = alex2NodeClient.registerDevice("bad light", "bad-light-1");
errorLight.addCapability(AlexaInterfaceType.POWER_CONTROLLER); errorLight.addCapability(AlexaInterfaceType.POWER_CONTROLLER);
console.log(errorLight.getName()); console.log(errorLight.getName());
@ -85,7 +88,7 @@ errorLight.on("Event", (directive, interfaceType) => {
const status = errorLight.getStatusMessage(token, true); const status = errorLight.getStatusMessage(token, true);
status status
.addHealthProp("OK") .addHealthProp(EndpointHealth.OK)
.addPowerControllerProp(outputState) .addPowerControllerProp(outputState)
.send(); .send();
} }

View file

@ -1,10 +1,11 @@
// Import required modules and types from the compiled TypeScript distribution (via dist/index.js) // Import the library ("alex2node" is the installed package; inside this checkout it resolves to the built dist/ via package.json "exports")
const { const {
Alex2MQTT, Alex2MQTT,
AlexaInterfaceType, AlexaInterfaceType,
PowerController, PowerController,
DisplayCategory DisplayCategory,
} = require("../dist/index.js"); EndpointHealth
} = require("alex2node");
require("dotenv").config(); // Load environment variables from .env file require("dotenv").config(); // Load environment variables from .env file
@ -21,12 +22,13 @@ const rootTopic = process.env.MQTT_ROOT_TOPIC;
// Create and initialize a new Alexa-to-MQTT client // Create and initialize a new Alexa-to-MQTT client
const alex2NodeClient = new Alex2MQTT(username, password, rootTopic, false); const alex2NodeClient = new Alex2MQTT(username, password, rootTopic, false);
alex2NodeClient.connect(); // Connect to the MQTT broker alex2NodeClient.connect(); // Connect to the MQTT broker
alex2NodeClient.on("error", (e) => console.error("broker:", e.message)); // Log broker errors (on 1.5.0 this listener is also what keeps an outage from killing the process)
// Define the device name and register it // Define the device name and register it
const deviceName = "Kitchen Light"; const deviceName = "Kitchen Light";
const kitchenLight = alex2NodeClient.registerDevice( const kitchenLight = alex2NodeClient.registerDevice(
deviceName, deviceName,
"endpoint7", "kitchen-light-1",
[DisplayCategory.LIGHT] [DisplayCategory.LIGHT]
); );
@ -69,7 +71,7 @@ kitchenLight.on("ReportState", (payload) => {
const { correlationToken } = payload.header; const { correlationToken } = payload.header;
const status = kitchenLight.getStatusMessage(correlationToken); const status = kitchenLight.getStatusMessage(correlationToken);
status status
.addHealthProp("OK") .addHealthProp(EndpointHealth.OK)
.addPowerControllerProp(outputState) .addPowerControllerProp(outputState)
.addModeControllerProp(momentaryModeInstance, defaultMomentaryMode); .addModeControllerProp(momentaryModeInstance, defaultMomentaryMode);
status.send(); status.send();
@ -82,7 +84,7 @@ kitchenLight.on("Event", (directive, interfaceType) => {
console.log("Event received", { directive, interfaceType }); console.log("Event received", { directive, interfaceType });
const { name, correlationToken, instance } = directive.header; const { name, correlationToken, instance } = directive.header;
const status = kitchenLight.getStatusMessage(correlationToken, true).addHealthProp("OK"); const status = kitchenLight.getStatusMessage(correlationToken, true).addHealthProp(EndpointHealth.OK);
// PowerController logic // PowerController logic
if (interfaceType === AlexaInterfaceType.POWER_CONTROLLER) { if (interfaceType === AlexaInterfaceType.POWER_CONTROLLER) {

View file

@ -1,18 +1,15 @@
// Import required modules and types from the compiled TypeScript distribution (via dist/index.js) // Import the library ("alex2node" is the installed package; inside this checkout it resolves to the built dist/ via package.json "exports")
const { const {
Alex2MQTT, Alex2MQTT,
AlexaInterfaceType, AlexaInterfaceType,
PowerController,
TemperatureSensorScale, TemperatureSensorScale,
DisplayCategory, DisplayCategory,
ThermostatMode, ThermostatMode,
} = require("../dist/index.js"); EndpointHealth,
} = require("alex2node");
require("dotenv").config(); // Load environment variables from .env file require("dotenv").config(); // Load environment variables from .env file
// Simulated in-memory device state (used for example/demo purposes)
let outputState = PowerController.OFF; // Initialize the power state to OFF (if thermostat supports ON/OFF modes)
// Load MQTT connection credentials and root topic from environment variables // Load MQTT connection credentials and root topic from environment variables
const username = process.env.MQTT_USERNAME; const username = process.env.MQTT_USERNAME;
const password = process.env.MQTT_PASSWORD; const password = process.env.MQTT_PASSWORD;
@ -21,6 +18,7 @@ const rootTopic = process.env.MQTT_ROOT_TOPIC;
// Create and initialize a new Alexa-to-MQTT client // Create and initialize a new Alexa-to-MQTT client
const alex2NodeClient = new Alex2MQTT(username, password, rootTopic, false); const alex2NodeClient = new Alex2MQTT(username, password, rootTopic, false);
alex2NodeClient.connect(); // Connect to the MQTT broker alex2NodeClient.connect(); // Connect to the MQTT broker
alex2NodeClient.on("error", (e) => console.error("broker:", e.message)); // Log broker errors (on 1.5.0 this listener is also what keeps an outage from killing the process)
// Define the device name (Thermostat Test) // Define the device name (Thermostat Test)
const deviceName = "Thermostat Test"; const deviceName = "Thermostat Test";
@ -28,7 +26,7 @@ const deviceName = "Thermostat Test";
// Register the thermostat device with a unique endpoint ID // Register the thermostat device with a unique endpoint ID
const thermostat = alex2NodeClient.registerDevice( const thermostat = alex2NodeClient.registerDevice(
deviceName, deviceName,
"thermostatEndpoint1", "thermostat-1",
[DisplayCategory.TEMPERATURE_SENSOR, DisplayCategory.THERMOSTAT,DisplayCategory.OTHER] [DisplayCategory.TEMPERATURE_SENSOR, DisplayCategory.THERMOSTAT,DisplayCategory.OTHER]
); );
@ -49,7 +47,7 @@ modeController.addSupportedModes([
{ {
"@type": "text", "@type": "text",
value: { value: {
text: "Bannana", text: "Auto",
locale: "en-US", locale: "en-US",
}, },
}, },
@ -63,7 +61,7 @@ modeController.addSupportedModes([
{ {
"@type": "text", "@type": "text",
value: { value: {
text: "Apple", text: "On",
locale: "en-US", locale: "en-US",
}, },
}, },
@ -80,7 +78,7 @@ console.log(thermostat.getName());
* This occurs when Alexa queries the current state of the thermostat (e.g., during routines or device status checks). * This occurs when Alexa queries the current state of the thermostat (e.g., during routines or device status checks).
*/ */
const deviceData = { const deviceData = { // all temperatures in Fahrenheit; the library converts to Celsius for Alexa
lowerSetpoint: 50, lowerSetpoint: 50,
upperSetpoint: 70, upperSetpoint: 70,
targetSetpoint: 60, targetSetpoint: 60,
@ -96,10 +94,10 @@ thermostat.on("ReportState", (payload) => {
let status = thermostat.getStatusMessage(correlationToken); let status = thermostat.getStatusMessage(correlationToken);
status status
.addHealthProp("OK") // Device is healthy and reachable .addHealthProp(EndpointHealth.OK) // Device is healthy and reachable
.addModeControllerProp("mode.fanmode", deviceData.fanMode) .addModeControllerProp("mode.fanmode", deviceData.fanMode)
.addTemperatureSensorProp( .addTemperatureSensorProp(
TemperatureSensorScale.CELSIUS, TemperatureSensorScale.FAHRENHEIT,
deviceData.current deviceData.current
) // Report current temperature ) // Report current temperature
.addThermostatModeProp(deviceData.thermostatMode); // Report current thermostat mode .addThermostatModeProp(deviceData.thermostatMode); // Report current thermostat mode
@ -108,20 +106,20 @@ thermostat.on("ReportState", (payload) => {
status = status status = status
.addThermostatControllerProp( .addThermostatControllerProp(
"lowerSetpoint", "lowerSetpoint",
TemperatureSensorScale.CELSIUS, TemperatureSensorScale.FAHRENHEIT,
deviceData.lowerSetpoint deviceData.lowerSetpoint
) // Lower bound of temperature range ) // Lower bound of temperature range
.addThermostatControllerProp( .addThermostatControllerProp(
"upperSetpoint", "upperSetpoint",
TemperatureSensorScale.CELSIUS, TemperatureSensorScale.FAHRENHEIT,
deviceData.upperSetpoint deviceData.upperSetpoint
); // Upper bound of temperature range ); // Upper bound of temperature range
} else { } else {
status = status.addThermostatControllerProp( status = status.addThermostatControllerProp(
"targetSetpoint", "targetSetpoint",
TemperatureSensorScale.CELSIUS, TemperatureSensorScale.FAHRENHEIT,
deviceData.targetSetpoint deviceData.targetSetpoint
); // Lower bound of temperature range ); // Single setpoint (HEAT / COOL)
} }
status.send(); // Send the full state report back to Alexa status.send(); // Send the full state report back to Alexa
}); });
@ -164,7 +162,7 @@ thermostat.on("Event", (directive, interfaceType) => {
let status = thermostat.getStatusMessage(token, true); let status = thermostat.getStatusMessage(token, true);
status status
.addHealthProp("OK") // Device is healthy and reachable .addHealthProp(EndpointHealth.OK) // Device is healthy and reachable
.addModeControllerProp("mode.fanmode", deviceData.fanMode) .addModeControllerProp("mode.fanmode", deviceData.fanMode)
.addTemperatureSensorProp( .addTemperatureSensorProp(
TemperatureSensorScale.FAHRENHEIT, TemperatureSensorScale.FAHRENHEIT,
@ -188,7 +186,7 @@ thermostat.on("Event", (directive, interfaceType) => {
"targetSetpoint", "targetSetpoint",
TemperatureSensorScale.FAHRENHEIT, TemperatureSensorScale.FAHRENHEIT,
deviceData.targetSetpoint deviceData.targetSetpoint
); // Lower bound of temperature range ); // Single setpoint (HEAT / COOL)
} }
status.send(); // Send the full state report back to Alexa status.send(); // Send the full state report back to Alexa
} }

View file

@ -1,8 +1,10 @@
// Import the library ("alex2node" is the installed package; inside this checkout it resolves to the built dist/ via package.json "exports")
const { const {
Alex2MQTT, Alex2MQTT,
AlexaInterfaceType, AlexaInterfaceType,
PowerController, PowerController,
} = require("../dist/index.js"); EndpointHealth,
} = require("alex2node");
require("dotenv").config(); require("dotenv").config();
@ -10,11 +12,12 @@ const {
const password = process.env.MQTT_PASSWORD; const password = process.env.MQTT_PASSWORD;
const rootTopic = process.env.MQTT_ROOT_TOPIC; const rootTopic = process.env.MQTT_ROOT_TOPIC;
const alex2NodeClient = new Alex2MQTT(username, password, rootTopic, true); const alex2NodeClient = new Alex2MQTT(username, password, rootTopic, false); // set the 4th argument to true to print every MQTT payload
alex2NodeClient.connect(); alex2NodeClient.connect();
alex2NodeClient.on("error", (e) => console.error("broker:", e.message)); // Log broker errors (on 1.5.0 this listener is also what keeps an outage from killing the process)
const deviceName = "Custom Device"; const deviceName = "Custom Device";
const customToggle = alex2NodeClient.registerDevice(deviceName, "endpoint8"); const customToggle = alex2NodeClient.registerDevice(deviceName, "custom-device-1");
// Define 8 toggle instances and friendly names // Define 8 toggle instances and friendly names
const switchCount = 8; const switchCount = 8;
@ -40,7 +43,7 @@ const {
const { correlationToken } = payload.header; const { correlationToken } = payload.header;
const status = customToggle.getStatusMessage(correlationToken); const status = customToggle.getStatusMessage(correlationToken);
status.addHealthProp("OK"); status.addHealthProp(EndpointHealth.OK);
// Report all toggle states // Report all toggle states
for (let i = 0; i < switchCount; i++) { for (let i = 0; i < switchCount; i++) {
@ -68,7 +71,7 @@ const {
const status = customToggle.getStatusMessage(correlationToken, true); const status = customToggle.getStatusMessage(correlationToken, true);
status status
.addHealthProp("OK") .addHealthProp(EndpointHealth.OK)
.addToggleControllerProp(toggleStates[index], instance) .addToggleControllerProp(toggleStates[index], instance)
.send(); .send();
} }

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "alex2node", "name": "alex2node",
"version": "1.5.1", "version": "1.5.2",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "alex2node", "name": "alex2node",
"version": "1.5.1", "version": "1.5.2",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"mqtt": "^5.10.3", "mqtt": "^5.10.3",

View file

@ -1,6 +1,6 @@
{ {
"name": "alex2node", "name": "alex2node",
"version": "1.5.1", "version": "1.5.2",
"description": "A Node.js library for creating Alexa-compatible devices using MQTT, based on Alex2MQTT.", "description": "A Node.js library for creating Alexa-compatible devices using MQTT, based on Alex2MQTT.",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",

166
readme.md
View file

@ -13,6 +13,27 @@ For more details on how to configure Alex2MQTT, visit [Alex2MQTT Documentation](
- Easily configure devices and their capabilities via simple API. - Easily configure devices and their capabilities via simple API.
## What is new in 1.5.2
- `send()` (status, error and scene responses, change reports) never rejects any more: it resolves with the topic, or `""` when the publish failed, and the error goes to the bridge's `error` event when a listener is attached. In 1.5.1 a failing publish rejected the promise, and an un-caught `.send()` (every example, the Quick Start) then killed the host process.
- **Types ship.** `dist/index.d.ts` (and the other declarations) are generated and tracked, so TypeScript users get
types. `addSupportedModes` accepts the `{ value, modeResources }` objects a ModeController needs, `ActionMapping`'s
payload argument is optional, `addHealthProp` takes `EndpointHealth.OK` (or the plain string).
- **`disconnect()` then `connect()` works.** Devices registered before a `disconnect()` publish through the new
connection (1.5.1 left them bound to the closed client: discoverable, but they never answered a directive).
- **Duplicate endpointIds.** `registerDevice()` with an endpointId that is already registered returns the existing
device (with a warning: `console.warn`, or the log hook when one is set) instead of adding a second one that never
received a directive. EndpointIds are unique per root topic.
- **Thermostat discovery** lists `targetSetpoint` (with `lowerSetpoint`, `upperSetpoint`, `thermostatMode`) and no
longer advertises `adaptiveRecoveryStatus`, which nothing reported. A consumer (insteonDashboard) re-announces its
thermostats once on the next discovery because the payload changed - harmless.
- Discovery no longer prints `UNSUPORTED INTERFACE TYPE` to stderr for every interface the library has no property
list for; the bridge notes it through the log hook (debug) instead.
- Examples: `require("alex2node")`, an `error` listener in every one, `EndpointHealth.OK`, neutral endpoint ids;
BlindControl's ReportState reads the correlationToken from the header, the thermostat reports its Fahrenheit data
as Fahrenheit, ExamplePowerController is the plain on/off light again and the new
ExamplePowerControllerWithBrightness declares the BrightnessController it reports. A LICENSE file (MIT).
## What is new in 1.5.1 ## What is new in 1.5.1
- **A broker outage no longer crashes your process.** 1.4.0 emitted `error` unconditionally, and Node terminates a - **A broker outage no longer crashes your process.** 1.4.0 emitted `error` unconditionally, and Node terminates a
@ -41,7 +62,7 @@ const bridge = new Alex2MQTT(user, pass, rootTopic, false, { host: process.env.M
bridge.on("error", (e) => console.warn("broker:", e.message)); // optional - without it the error is swallowed, never thrown bridge.on("error", (e) => console.warn("broker:", e.message)); // optional - without it the error is swallowed, never thrown
bridge.on("connect", () => console.log("connected")); bridge.on("connect", () => console.log("connected"));
bridge.connect(); bridge.connect();
const lamp = bridge.registerDevice("Dining Room Light", "5020AA", DisplayCategory.LIGHT); const lamp = bridge.registerDevice("Dining Room Light", "dining-room-light-1", DisplayCategory.LIGHT);
lamp.addCapability(AlexaInterfaceType.POWER_CONTROLLER, { proactivelyReported: true }); lamp.addCapability(AlexaInterfaceType.POWER_CONTROLLER, { proactivelyReported: true });
lamp.on("Event", (directive) => { /* ... act, then: */ lamp.getStatusMessage(directive.header.correlationToken, true).addPowerControllerProp(PowerController.ON).send(); }); lamp.on("Event", (directive) => { /* ... act, then: */ lamp.getStatusMessage(directive.header.correlationToken, true).addPowerControllerProp(PowerController.ON).send(); });
// later, when the light changes locally: // later, when the light changes locally:
@ -52,12 +73,20 @@ lamp.getChangeReport("PHYSICAL_INTERACTION").addPowerControllerProp(PowerControl
### Installation ### Installation
You can install **Alex2Node** via npm: npm has **1.5.0**. Everything since - 1.5.1 (ChangeReport, configurable broker host, error events) and 1.5.2 (types,
the reconnect fix; see "What is new") - installs straight from the Forgejo repository, so nothing waits on an npm
publish:
```bash ```bash
npm install alex2node npm install alex2node # 1.5.0 from npm
npm install git+https://git.stormysdream.club/apps/Alex2Node.git # latest main from Forgejo
npm install git+https://git.stormysdream.club/apps/Alex2Node.git#<commit> # pinned to one commit
``` ```
The repository is public, so the `https` form needs no account. `#<commit>` (or `#<tag>`, when one exists) pins a
revision; either way npm records the resolved commit in `package-lock.json`, so `npm ci` reproduces it. `npm update
alex2node` re-resolves a branch ref such as `#main` to its latest commit; a commit or tag pin stays put.
Source: https://git.stormysdream.club/apps/Alex2Node Source: https://git.stormysdream.club/apps/Alex2Node
Then, import it into your JavaScript or TypeScript file: Then, import it into your JavaScript or TypeScript file:
@ -83,7 +112,7 @@ First, create an instance of the Alexa-to-MQTT client and initialize it with you
```js ```js
require("dotenv").config(); // Load environment variables from .env file require("dotenv").config(); // Load environment variables from .env file
const { Alex2MQTT, AlexaInterfaceType, PowerController } = require("alex2node"); const { Alex2MQTT, AlexaInterfaceType, PowerController, EndpointHealth } = require("alex2node");
const username = process.env.MQTT_USERNAME; const username = process.env.MQTT_USERNAME;
const password = process.env.MQTT_PASSWORD; const password = process.env.MQTT_PASSWORD;
@ -91,13 +120,14 @@ const rootTopic = process.env.MQTT_ROOT_TOPIC;
const alex2NodeClient = new Alex2MQTT(username, password, rootTopic, false); // 1.5.1: add { host } as a fifth argument for another broker const alex2NodeClient = new Alex2MQTT(username, password, rootTopic, false); // 1.5.1: add { host } as a fifth argument for another broker
alex2NodeClient.connect(); // Connect to the MQTT broker alex2NodeClient.connect(); // Connect to the MQTT broker
alex2NodeClient.on("error", (e) => console.error("broker:", e.message)); // Log broker errors - on 1.5.0 this listener is also what keeps an outage from killing the process
``` ```
Once initialized, you can begin to add virtual devices. In this example, we will add a PowerController for a light: Once initialized, you can begin to add virtual devices. In this example, we will add a PowerController for a light:
```js ```js
const deviceName = "Bedroom Light"; const deviceName = "Bedroom Light";
const bedroomLight = alex2NodeClient.registerDevice(deviceName, "endpoint1"); const bedroomLight = alex2NodeClient.registerDevice(deviceName, "bedroom-light-1"); // the endpointId: unique per root topic
// Add the PowerController capability // Add the PowerController capability
bedroomLight.addCapability(AlexaInterfaceType.POWER_CONTROLLER); bedroomLight.addCapability(AlexaInterfaceType.POWER_CONTROLLER);
@ -105,10 +135,10 @@ bedroomLight.addCapability(AlexaInterfaceType.POWER_CONTROLLER);
// Handle Alexa's ReportState directive to report the current state // Handle Alexa's ReportState directive to report the current state
let outputState = PowerController.OFF; let outputState = PowerController.OFF;
bedroomLight.on("ReportState", (payload) => { bedroomLight.on("ReportState", (payload) => {
const { correlationToken } = payload; const { correlationToken } = payload.header; // the whole directive is passed; Alexa needs the token echoed
const status = bedroomLight.getStatusMessage(correlationToken); const status = bedroomLight.getStatusMessage(correlationToken);
status status
.addHealthProp("OK") .addHealthProp(EndpointHealth.OK)
.addPowerControllerProp(outputState) .addPowerControllerProp(outputState)
.send(); .send();
}); });
@ -129,7 +159,7 @@ bedroomLight.on("Event", (directive, interfaceType) => {
const status = bedroomLight.getStatusMessage(token, true); const status = bedroomLight.getStatusMessage(token, true);
status status
.addHealthProp("OK") .addHealthProp(EndpointHealth.OK)
.addPowerControllerProp(outputState) .addPowerControllerProp(outputState)
.send(); .send();
} }
@ -141,66 +171,66 @@ bedroomLight.on("Event", (directive, interfaceType) => {
## Interface Types ## Interface Types
| Alexa Interface Type | Status | | Alexa Interface Type | Status |
|-----------------------------------------------------|-------------| |-----------------------------------------------------|-------------|
| AlexaInterfaceType::ENDPOINT_HEALTH | Fully Supported | | AlexaInterfaceType.ENDPOINT_HEALTH | Fully Supported |
| AlexaInterfaceType::POWER_CONTROLLER | Fully Supported | | AlexaInterfaceType.POWER_CONTROLLER | Fully Supported |
| AlexaInterfaceType::BRIGHTNESS_CONTROLLER | Fully Supported | | AlexaInterfaceType.BRIGHTNESS_CONTROLLER | Fully Supported |
| AlexaInterfaceType::TOGGLE_CONTROLLER | Fully Supported | | AlexaInterfaceType.TOGGLE_CONTROLLER | Fully Supported |
| AlexaInterfaceType::TEMPERATURE_SENSOR | Fully Supported | | AlexaInterfaceType.TEMPERATURE_SENSOR | Fully Supported |
| AlexaInterfaceType::COLOR_TEMPERATURE_CONTROLLER | Fully Supported | | AlexaInterfaceType.COLOR_TEMPERATURE_CONTROLLER | Fully Supported |
| AlexaInterfaceType::AUTOMATION_MANAGEMENT | Supported* | | AlexaInterfaceType.AUTOMATION_MANAGEMENT | Supported* |
| AlexaInterfaceType::CHANNEL_CONTROLLER | Supported* | | AlexaInterfaceType.CHANNEL_CONTROLLER | Supported* |
| AlexaInterfaceType::COLOR_CONTROLLER | Supported* | | AlexaInterfaceType.COLOR_CONTROLLER | Supported* |
| AlexaInterfaceType::CONTACT_SENSOR | Supported* | | AlexaInterfaceType.CONTACT_SENSOR | Supported* |
| AlexaInterfaceType::APPLICATION_STATE_REPORTER | Supported* | | AlexaInterfaceType.APPLICATION_STATE_REPORTER | Supported* |
| AlexaInterfaceType::AUDIO_PLAY_QUEUE | Supported* | | AlexaInterfaceType.AUDIO_PLAY_QUEUE | Supported* |
| AlexaInterfaceType::AUTHORIZATION_CONTROLLER | Supported* | | AlexaInterfaceType.AUTHORIZATION_CONTROLLER | Supported* |
| AlexaInterfaceType::AUTOMOTIVE_VEHICLE_DATA | Supported* | | AlexaInterfaceType.AUTOMOTIVE_VEHICLE_DATA | Supported* |
| AlexaInterfaceType::CAMERA_LIVE_VIEW_CONTROLLER | Supported* | | AlexaInterfaceType.CAMERA_LIVE_VIEW_CONTROLLER | Supported* |
| AlexaInterfaceType::CAMERA_STREAM_CONTROLLER | Supported* | | AlexaInterfaceType.CAMERA_STREAM_CONTROLLER | Supported* |
| AlexaInterfaceType::COMMISSIONABLE | Supported* | | AlexaInterfaceType.COMMISSIONABLE | Supported* |
| AlexaInterfaceType::CONSENT_MANAGEMENT_CONSENT_REQUIRED_REPORTER | Supported* | | AlexaInterfaceType.CONSENT_MANAGEMENT_CONSENT_REQUIRED_REPORTER | Supported* |
| AlexaInterfaceType::COOKING | Supported* | | AlexaInterfaceType.COOKING | Supported* |
| AlexaInterfaceType::DATA_CONTROLLER | Supported* | | AlexaInterfaceType.DATA_CONTROLLER | Supported* |
| AlexaInterfaceType::DEVICE_USAGE_ESTIMATION | Supported* | | AlexaInterfaceType.DEVICE_USAGE_ESTIMATION | Supported* |
| AlexaInterfaceType::DEVICE_USAGE_METER | Supported* | | AlexaInterfaceType.DEVICE_USAGE_METER | Supported* |
| AlexaInterfaceType::DOORBELL_EVENT_SOURCE | Supported* | | AlexaInterfaceType.DOORBELL_EVENT_SOURCE | Supported* |
| AlexaInterfaceType::EQUALIZER_CONTROLLER | Supported* | | AlexaInterfaceType.EQUALIZER_CONTROLLER | Supported* |
| AlexaInterfaceType::INPUT_CONTROLLER | Supported* | | AlexaInterfaceType.INPUT_CONTROLLER | Supported* |
| AlexaInterfaceType::INVENTORY_LEVEL_SENSOR | Supported* | | AlexaInterfaceType.INVENTORY_LEVEL_SENSOR | Supported* |
| AlexaInterfaceType::INVENTORY_LEVEL_USAGE_SENSOR | Supported* | | AlexaInterfaceType.INVENTORY_LEVEL_USAGE_SENSOR | Supported* |
| AlexaInterfaceType::INVENTORY_USAGE_SENSOR | Supported* | | AlexaInterfaceType.INVENTORY_USAGE_SENSOR | Supported* |
| AlexaInterfaceType::KEYPAD_CONTROLLER | Supported* | | AlexaInterfaceType.KEYPAD_CONTROLLER | Supported* |
| AlexaInterfaceType::LAUNCHER | Supported* | | AlexaInterfaceType.LAUNCHER | Supported* |
| AlexaInterfaceType::LOCK_CONTROLLER | Supported* | | AlexaInterfaceType.LOCK_CONTROLLER | Supported* |
| AlexaInterfaceType::MEDIA_PLAYBACK | Supported* | | AlexaInterfaceType.MEDIA_PLAYBACK | Supported* |
| AlexaInterfaceType::MEDIA_SEARCH | Supported* | | AlexaInterfaceType.MEDIA_SEARCH | Supported* |
| AlexaInterfaceType::MODE_CONTROLLER | Supported* | | AlexaInterfaceType.MODE_CONTROLLER | Supported* |
| AlexaInterfaceType::MOTION_SENSOR | Supported* | | AlexaInterfaceType.MOTION_SENSOR | Supported* |
| AlexaInterfaceType::PERCENTAGE_CONTROLLER | Supported* | | AlexaInterfaceType.PERCENTAGE_CONTROLLER | Supported* |
| AlexaInterfaceType::PLAYBACK_CONTROLLER | Supported* | | AlexaInterfaceType.PLAYBACK_CONTROLLER | Supported* |
| AlexaInterfaceType::PLAYBACK_STATE_REPORTER | Supported* | | AlexaInterfaceType.PLAYBACK_STATE_REPORTER | Supported* |
| AlexaInterfaceType::PROACTIVE_NOTIFICATION_SOURCE | Supported* | | AlexaInterfaceType.PROACTIVE_NOTIFICATION_SOURCE | Supported* |
| AlexaInterfaceType::RANGE_CONTROLLER | Supported* | | AlexaInterfaceType.RANGE_CONTROLLER | Supported* |
| AlexaInterfaceType::RECORD_CONTROLLER | Supported* | | AlexaInterfaceType.RECORD_CONTROLLER | Supported* |
| AlexaInterfaceType::REMOTE_VIDEO_PLAYER | Supported* | | AlexaInterfaceType.REMOTE_VIDEO_PLAYER | Supported* |
| AlexaInterfaceType::RTC_SESSION_CONTROLLER | Supported* | | AlexaInterfaceType.RTC_SESSION_CONTROLLER | Supported* |
| AlexaInterfaceType::SCENE_CONTROLLER | Supported* | | AlexaInterfaceType.SCENE_CONTROLLER | Supported* |
| AlexaInterfaceType::SECURITY_PANEL_CONTROLLER | Supported* | | AlexaInterfaceType.SECURITY_PANEL_CONTROLLER | Supported* |
| AlexaInterfaceType::SEEK_CONTROLLER | Supported* | | AlexaInterfaceType.SEEK_CONTROLLER | Supported* |
| AlexaInterfaceType::SIMPLE_EVENT_SOURCE | Supported* | | AlexaInterfaceType.SIMPLE_EVENT_SOURCE | Supported* |
| AlexaInterfaceType::SMART_VISION_OBJECT_DETECTION_SENSOR | Supported* | | AlexaInterfaceType.SMART_VISION_OBJECT_DETECTION_SENSOR | Supported* |
| AlexaInterfaceType::SMART_VISION_SNAPSHOT_PROVIDER | Supported* | | AlexaInterfaceType.SMART_VISION_SNAPSHOT_PROVIDER | Supported* |
| AlexaInterfaceType::SPEAKER | Supported* | | AlexaInterfaceType.SPEAKER | Supported* |
| AlexaInterfaceType::STEP_SPEAKER | Supported* | | AlexaInterfaceType.STEP_SPEAKER | Supported* |
| AlexaInterfaceType::THERMOSTAT_CONTROLLER | Supported* | | AlexaInterfaceType.THERMOSTAT_CONTROLLER | Supported* |
| AlexaInterfaceType::THERMOSTAT_CONTROLLER_CONFIGURATION | Supported* | | AlexaInterfaceType.THERMOSTAT_CONTROLLER_CONFIGURATION | Supported* |
| AlexaInterfaceType::THERMOSTAT_CONTROLLER_HVAC_COMPONENTS | Supported* | | AlexaInterfaceType.THERMOSTAT_CONTROLLER_HVAC_COMPONENTS | Supported* |
| AlexaInterfaceType::THERMOSTAT_CONTROLLER_SCHEDULE | Supported* | | AlexaInterfaceType.THERMOSTAT_CONTROLLER_SCHEDULE | Supported* |
| AlexaInterfaceType::TIME_HOLD_CONTROLLER | Supported* | | AlexaInterfaceType.TIME_HOLD_CONTROLLER | Supported* |
| AlexaInterfaceType::UI_CONTROLLER | Supported* | | AlexaInterfaceType.UI_CONTROLLER | Supported* |
| AlexaInterfaceType::USER_PREFERENCE | Supported* | | AlexaInterfaceType.USER_PREFERENCE | Supported* |
| AlexaInterfaceType::VIDEO_RECORDER | Supported* | | AlexaInterfaceType.VIDEO_RECORDER | Supported* |
| AlexaInterfaceType::WAKE_ON_LAN_CONTROLLER | Supported* | | AlexaInterfaceType.WAKE_ON_LAN_CONTROLLER | Supported* |
(*Partial support or limited implementation advanced configuration is required) (*Partial support or limited implementation advanced configuration is required)

View file

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

View file

@ -2,6 +2,7 @@ import mqtt, { MqttClient, IClientOptions } from "mqtt";
import Device from "./Device"; import Device from "./Device";
import { EventEmitter } from "events"; import { EventEmitter } from "events";
import { DisplayCategory } from "./DisplayCategory"; 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. */ /** Optional settings for the bridge (1.5.1). Everything has the 1.4.0 behaviour as its default. */
export interface Alex2MQTTOptions { export interface Alex2MQTTOptions {
@ -75,6 +76,7 @@ class Alex2MQTT extends EventEmitter {
}; };
this.client = mqtt.connect(this.MqttHost, options); 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.client.on("connect", () => {
this.connected = true; this.connected = true;
@ -96,6 +98,10 @@ class Alex2MQTT extends EventEmitter {
this.log("Discovery request received, getting device json..."); this.log("Discovery request received, getting device json...");
const deviceArray = this.devices.map((device) => device.getJSON()); const deviceArray = this.devices.map((device) => device.getJSON());
this.lastDiscoveryAt = new Date().toISOString(); 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) => { this.client!.publish(topic + "_r", JSON.stringify(deviceArray), (err) => {
if (err) { this.fail(err); return; } if (err) { this.fail(err); return; }
this.log(`Discovery payloads published to ${topic + "_r"}`, deviceArray); this.log(`Discovery payloads published to ${topic + "_r"}`, deviceArray);
@ -149,6 +155,12 @@ class Alex2MQTT extends EventEmitter {
if (!this.client) { if (!this.client) {
throw new Error("Must call connect before creating devices"); 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}`); this.log(`Creating new device with endpoint: ${endpointId}`);
const normalizedCategory = const normalizedCategory =
displayCategory === null displayCategory === null
@ -164,6 +176,7 @@ class Alex2MQTT extends EventEmitter {
endpointId, endpointId,
normalizedCategory 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); this.devices.push(device);
return device; return device;
} }

View file

@ -85,6 +85,8 @@ export class AlexaErrorResponse {
private rootTopic: string; private rootTopic: string;
private endpointId: string; private endpointId: string;
private mqttClient: MqttClient; 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( constructor(
correlationToken: string, correlationToken: string,
@ -136,8 +138,12 @@ export class AlexaErrorResponse {
sendAsync ? "deferredResponse" : "alexaResponce" 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 }`; //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); const payloadStr = JSON.stringify(payload);
return new Promise((resolve, reject) => { return new Promise((resolve) => {
this.mqttClient.publish(topic, payloadStr, (err) => (err ? reject(err) : resolve(topic))); 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; 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 { export class AlexaInterface {
private friendlyNames: FriendlyName[] = []; private friendlyNames: FriendlyName[] = [];
private actionMappings: ActionMapping[] = []; private actionMappings: ActionMapping[] = [];
private supportedModes: string[] = []; private supportedModes: Array<string | SupportedMode> = [];
constructor( constructor(
public type: AlexaInterfaceType, public type: AlexaInterfaceType,
@ -97,7 +103,8 @@ export class AlexaInterface {
addFriendlyName(name: string, locale: string): void { addFriendlyName(name: string, locale: string): void {
this.friendlyNames.push({ text: name, locale }); 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; this.supportedModes = modes;
} }
setInstance(name: string): void { setInstance(name: string): void {
@ -218,10 +225,10 @@ export class AlexaInterface {
return ["temperature"]; return ["temperature"];
case AlexaInterfaceType.THERMOSTAT_CONTROLLER: case AlexaInterfaceType.THERMOSTAT_CONTROLLER:
return [ return [
"targetSetpoint",
"lowerSetpoint", "lowerSetpoint",
"upperSetpoint", "upperSetpoint",
"thermostatMode", "thermostatMode",
"adaptiveRecoveryStatus",
]; ];
case AlexaInterfaceType.APPLICATION_STATE_REPORTER: case AlexaInterfaceType.APPLICATION_STATE_REPORTER:
case AlexaInterfaceType.AUDIO_PLAY_QUEUE: case AlexaInterfaceType.AUDIO_PLAY_QUEUE:
@ -272,7 +279,8 @@ export class AlexaInterface {
case AlexaInterfaceType.USER_PREFERENCE: case AlexaInterfaceType.USER_PREFERENCE:
case AlexaInterfaceType.VIDEO_RECORDER: case AlexaInterfaceType.VIDEO_RECORDER:
case AlexaInterfaceType.WAKE_ON_LAN_CONTROLLER: 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 []; return [];
default: default:

View file

@ -65,6 +65,8 @@ export class AlexaStatusMessage {
private endpointId: string; private endpointId: string;
private mqttClient: MqttClient; private mqttClient: MqttClient;
private isDeferred: boolean; 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( constructor(
correlationToken: string, 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( return this.addProperty(
AlexaInterfaceType.ENDPOINT_HEALTH, AlexaInterfaceType.ENDPOINT_HEALTH,
"connectivity", "connectivity",
@ -289,15 +292,20 @@ export class AlexaStatusMessage {
/** /**
* Publish: a Response/StateReport to <root>/<endpoint>/alexaResponce (sendAsync: deferredResponse), a ChangeReport * 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 * 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> { public send(sendAsync: boolean = false): Promise<string> {
const payloadStr = JSON.stringify(this.toJSON()); const payloadStr = JSON.stringify(this.toJSON());
const topic = this.changeCause const topic = this.changeCause
? `${this.rootTopic}/changeReport` ? `${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 : `${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) => { return new Promise((resolve) => {
this.mqttClient.publish(topic, payloadStr, (err) => (err ? reject(err) : resolve(topic))); 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 { class Device extends EventEmitter {
protected softwareVersion: string = "1.0.0"; protected softwareVersion: string = "1.0.0";
private capabilities: AlexaInterface[] = []; 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( constructor(
private mqttClient: MqttClient, private mqttClient: MqttClient,
@ -24,6 +26,11 @@ class Device extends EventEmitter {
super(); 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 { getName(): string {
return this.name; return this.name;
} }
@ -52,19 +59,21 @@ class Device extends EventEmitter {
return this.description; return this.description;
} }
getErrorMessage(correlationToken: string): AlexaErrorResponse { getErrorMessage(correlationToken: string): AlexaErrorResponse {
return new AlexaErrorResponse( const msg = new AlexaErrorResponse(
correlationToken, correlationToken,
this.rootTopic, this.rootTopic,
this.endpointId, this.endpointId,
this.mqttClient this.mqttClient
); );
msg.onPublishError = this.onPublishError;
return msg;
} }
getStatusMessage( getStatusMessage(
correlationToken: string, correlationToken: string,
isResponse = false, isResponse = false,
isDeferred = false isDeferred = false
): AlexaStatusMessage { ): AlexaStatusMessage {
return new AlexaStatusMessage( const msg = new AlexaStatusMessage(
correlationToken, correlationToken,
this.rootTopic, this.rootTopic,
this.endpointId, this.endpointId,
@ -72,6 +81,8 @@ class Device extends EventEmitter {
isResponse, isResponse,
isDeferred isDeferred
); );
msg.onPublishError = this.onPublishError;
return msg;
} }
/** /**
* A proactive Alexa.ChangeReport (1.5.1): add the changed properties (the default target), optionally * 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. * event gateway with the user's token. Alexa only accepts it for capabilities registered with proactivelyReported.
*/ */
getChangeReport(cause: ChangeCause = "PHYSICAL_INTERACTION"): AlexaStatusMessage { 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 * 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> { sendSceneResponse(correlationToken: string, activated: boolean, cause: ChangeCause = "VOICE_INTERACTION", sendAsync = false): Promise<string> {
const payload = { const payload = {
@ -95,8 +108,12 @@ class Device extends EventEmitter {
}, },
}; };
const topic = `${this.rootTopic}/${this.endpointId}/${sendAsync ? "deferredResponse" : "alexaResponce"}`; const topic = `${this.rootTopic}/${this.endpointId}/${sendAsync ? "deferredResponse" : "alexaResponce"}`;
return new Promise((resolve, reject) => { return new Promise((resolve) => {
this.mqttClient.publish(topic, JSON.stringify(payload), (err) => (err ? reject(err) : resolve(topic))); 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[] { getCapabilities(): AlexaInterface[] {

View file

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

View file

@ -8,7 +8,9 @@ const assert = require("node:assert/strict");
const net = require("node:net"); const net = require("node:net");
const aedes = require("aedes"); const aedes = require("aedes");
const mqtt = require("mqtt"); const mqtt = require("mqtt");
const { Alex2MQTT, AlexaInterfaceType, DisplayCategory, PowerController, EndpointHealth, TemperatureSensorScale, DEFAULT_HOST } = require("../dist/index.js"); const fs = require("node:fs");
const path = require("node:path");
const { Alex2MQTT, AlexaInterfaceType, DisplayCategory, PowerController, EndpointHealth, TemperatureSensorScale, DEFAULT_HOST, ActionMapping, AlexaActions, AlexaStatusMessage } = require("../dist/index.js");
const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const until = async (fn, ms = 3000, what = "condition") => { const t0 = Date.now(); while (Date.now() - t0 < ms) { if (await fn()) return true; await sleep(15); } throw new Error(`timeout waiting for ${what}`); }; const until = async (fn, ms = 3000, what = "condition") => { const t0 = Date.now(); while (Date.now() - t0 < ms) { if (await fn()) return true; await sleep(15); } throw new Error(`timeout waiting for ${what}`); };
@ -107,3 +109,86 @@ test("a dead broker: 'offline'/'error' events when listened to, and NO crash whe
test("registerDevice before connect throws (unchanged from 1.4.0)", () => { test("registerDevice before connect throws (unchanged from 1.4.0)", () => {
assert.throws(() => new Alex2MQTT("u", "p", "root").registerDevice("x", "1", null), /connect before/); assert.throws(() => new Alex2MQTT("u", "p", "root").registerDevice("x", "1", null), /connect before/);
}); });
// 1.5.2
const connected = (bridge) => { const p = new Promise((r) => bridge.once("connect", r)); bridge.connect(); return p; }; // resolves once subscribed to <root>/#
const turnOn = (alexa, id, ct) => alexa.publish(`root/${id}/alexaDirective`, { header: { namespace: "Alexa.PowerController", name: "TurnOn", correlationToken: ct, payloadVersion: "3", messageId: ct }, endpoint: { endpointId: id }, payload: {} });
const answered = (alexa, id, ct) => alexa.got.some((m) => m.topic === `root/${id}/alexaResponce` && m.payload.event.header.correlationToken === ct);
test("disconnect() then connect(): devices registered before still answer (1.5.1 left them on the closed client)", async () => {
const b = await broker();
const alexa = await watcher(b.url, "root/#");
const bridge = new Alex2MQTT("u", "p", "root", false, { host: b.url });
await connected(bridge);
cleanups.push(() => bridge.disconnect());
const lamp = bridge.registerDevice("Lamp", "lamp-1", DisplayCategory.LIGHT);
lamp.addCapability(AlexaInterfaceType.POWER_CONTROLLER);
lamp.on("Event", (d) => { lamp.getStatusMessage(d.header.correlationToken, true).addPowerControllerProp(PowerController.ON).send(); });
turnOn(alexa, "lamp-1", "before");
await until(() => answered(alexa, "lamp-1", "before"), 3000, "response before disconnect");
await bridge.disconnect(); assert.equal(bridge.connected, false);
await connected(bridge);
assert.equal(bridge.getDevices().length, 1, "the devices stay registered");
turnOn(alexa, "lamp-1", "after");
await until(() => answered(alexa, "lamp-1", "after"), 3000, "response after disconnect() + connect()");
});
test("registerDevice with an endpointId already registered returns the existing device and warns; thermostat discovery lists targetSetpoint", async () => {
const b = await broker();
const alexa = await watcher(b.url, "root/#");
const logs = [];
const bridge = new Alex2MQTT("u", "p", "root", false, { host: b.url, log: (m) => logs.push(m) });
await connected(bridge);
cleanups.push(() => bridge.disconnect());
const a = bridge.registerDevice("Lamp A", "same", DisplayCategory.LIGHT); a.addCapability(AlexaInterfaceType.POWER_CONTROLLER);
const again = bridge.registerDevice("Lamp B", "same", DisplayCategory.SWITCH);
assert.equal(again, a); assert.equal(again.name, "Lamp A"); assert.equal(bridge.getDevices().length, 1);
assert.ok(logs.some((m) => /warning: registerDevice\("same"\)/.test(m)), "warned through the log hook");
// without a log hook (the examples and the Quick Start set none) the warning falls back to console.warn
const quiet = new Alex2MQTT("u", "p", "root-quiet", false, { host: b.url }); quiet.connect(); cleanups.push(() => quiet.disconnect());
const warned = []; const realWarn = console.warn; console.warn = (...args) => warned.push(args.join(" "));
try { quiet.registerDevice("Q", "same", null); assert.equal(quiet.registerDevice("Q again", "same", null).name, "Q"); } finally { console.warn = realWarn; }
assert.ok(warned.some((m) => /warning: registerDevice\("same"\)/.test(m)), "console.warn without a log hook");
bridge.registerDevice("Thermostat", "thermo-1", DisplayCategory.THERMOSTAT).addCapability(AlexaInterfaceType.THERMOSTAT_CONTROLLER);
alexa.publish("root/discover", {});
await until(() => alexa.got.some((m) => m.topic === "root/discover_r"), 3000, "discovery");
const disc = alexa.got.find((m) => m.topic === "root/discover_r").payload;
assert.deepEqual(disc.map((d) => d.endpointId), ["same", "thermo-1"]);
const thermo = disc[1].capabilities.find((c) => c.interface === "Alexa.ThermostatController");
assert.deepEqual(thermo.properties.supported.map((p) => p.name), ["targetSetpoint", "lowerSetpoint", "upperSetpoint", "thermostatMode"]);
assert.equal(thermo.version, "3.2");
});
test("1.5.2 typings: dist ships declarations, ActionMapping's payload is optional, addHealthProp takes the enum or the string", () => {
for (const f of ["index", "Alex2Node", "Device", "AlexaInterface", "AlexaStatusMessage", "AlexaErrorResponse", "ActionMapping", "DisplayCategory"]) assert.ok(fs.existsSync(path.join(__dirname, "..", "dist", `${f}.d.ts`)), `dist/${f}.d.ts`);
// the declarations themselves (N-15 / NX-04 / N-04): a revert in src/ would rebuild narrower types and the runtime checks below would not notice
const dts = (f) => fs.readFileSync(path.join(__dirname, "..", "dist", `${f}.d.ts`), "utf8");
assert.match(dts("ActionMapping"), /constructor\(actions: AlexaActions\[\], directiveName: string, directivePayload\?: string\);/);
assert.match(dts("AlexaStatusMessage"), /addHealthProp\(health: EndpointHealth \| `\$\{EndpointHealth\}`, uncertaintyInMs\?: number\): this;/);
assert.match(dts("AlexaInterface"), /addSupportedModes\(modes: Array<string \| SupportedMode>\): void;/);
assert.match(dts("index"), /export type \{ SupportedMode \} from "\.\/AlexaInterface";/);
assert.deepEqual(new ActionMapping([AlexaActions.Close], "TurnOn").toJSON(), { "@type": "ActionsToDirective", actions: ["Alexa.Actions.Close"], directive: { name: "TurnOn" } });
const health = (h) => new AlexaStatusMessage("ct", "root", "e", null).addHealthProp(h).toJSON().context.properties[0].value;
assert.deepEqual(health(EndpointHealth.OK), { value: "OK" }); assert.deepEqual(health("UNREACHABLE"), { value: "UNREACHABLE" });
});
test("1.5.2: a failed publish never rejects send() - it resolves \"\" and reaches the bridge's error listener", async () => {
const b = await broker();
const bridge = new Alex2MQTT("u", "p", "root-send", false, { host: b.url });
const errors = []; bridge.on("error", (e) => errors.push(e.message));
bridge.connect(); cleanups.push(() => bridge.disconnect());
await new Promise((r) => bridge.once("connect", r));
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
const status = await lamp.getStatusMessage("ct", true).addPowerControllerProp(PowerController.ON).send();
const error = await lamp.getErrorMessage("ct").send();
const scene = await lamp.sendSceneResponse("ct", true);
const change = await lamp.getChangeReport("PHYSICAL_INTERACTION").addPowerControllerProp(PowerController.OFF).send();
assert.deepEqual([status, error, scene, change], ["", "", "", ""], "each resolves \"\" instead of rejecting");
assert.ok(errors.length >= 4, `every failure reached the error listener: ${errors.join(" | ")}`);
// and with NO listener the same failures are swallowed (an "error" event without a listener would throw)
const quiet = new Alex2MQTT("u", "p", "root-send-quiet", false, { host: b.url }); quiet.connect(); cleanups.push(() => quiet.disconnect());
await new Promise((r) => quiet.once("connect", r));
const q = quiet.registerDevice("Q", "q-1", null); await quiet.disconnect();
assert.equal(await q.getStatusMessage("ct", true).addPowerControllerProp(PowerController.ON).send(), "");
});

View file

@ -4,6 +4,7 @@
"module": "CommonJS", "module": "CommonJS",
"moduleResolution": "node", "moduleResolution": "node",
"outDir": "./dist", "outDir": "./dist",
"declaration": true,
"esModuleInterop": true, "esModuleInterop": true,
"strict": true, "strict": true,
"skipLibCheck": true "skipLibCheck": true