diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..515ac52 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/dist/ActionMapping.d.ts b/dist/ActionMapping.d.ts new file mode 100644 index 0000000..3f1fd42 --- /dev/null +++ b/dist/ActionMapping.d.ts @@ -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 {}; diff --git a/dist/Alex2Node.d.ts b/dist/Alex2Node.d.ts new file mode 100644 index 0000000..022a72c --- /dev/null +++ b/dist/Alex2Node.d.ts @@ -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 /# + * "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; + 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; diff --git a/dist/Alex2Node.js b/dist/Alex2Node.js index 883b3d9..03fdff8 100644 --- a/dist/Alex2Node.js +++ b/dist/Alex2Node.js @@ -8,6 +8,7 @@ const mqtt_1 = __importDefault(require("mqtt")); const Device_1 = __importDefault(require("./Device")); const events_1 = require("events"); const DisplayCategory_1 = require("./DisplayCategory"); +const AlexaInterface_1 = require("./AlexaInterface"); 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 @@ -60,6 +61,8 @@ class Alex2MQTT extends events_1.EventEmitter { return; 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); + for (const d of this.devices) + d.setMqttClient(this.client); // after a disconnect(): the devices publish through the new client this.client.on("connect", () => { this.connected = true; this.log("Connected to MQTT broker"); @@ -82,6 +85,10 @@ class Alex2MQTT extends events_1.EventEmitter { this.log("Discovery request received, getting device json..."); const deviceArray = this.devices.map((device) => device.getJSON()); this.lastDiscoveryAt = new Date().toISOString(); + for (const device of this.devices) // a capability the library has no property list for goes out with supported: [] + for (const cap of device.getCapabilities()) + if (cap.getProps().length === 0 && cap.getType() !== 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) => { if (err) { this.fail(err); @@ -137,6 +144,15 @@ class Alex2MQTT extends events_1.EventEmitter { if (!this.client) { throw new Error("Must call connect before creating devices"); } + const existing = this.devices.find((d) => d.endpointId === endpointId); + if (existing) { // 1.5.2: endpointIds are unique per root topic; 1.5.1 added a second device that never got a directive + const warning = `warning: registerDevice("${endpointId}") is already registered as "${existing.name}", returning that device`; + if (this.options.log) + this.options.log(warning); + else + console.warn(`[Alex2Node.ts] ${warning}`); // visible without a log hook too + return existing; + } this.log(`Creating new device with endpoint: ${endpointId}`); const normalizedCategory = displayCategory === null ? [DisplayCategory_1.DisplayCategory.LIGHT] @@ -144,6 +160,7 @@ class Alex2MQTT extends events_1.EventEmitter { ? displayCategory : [displayCategory || DisplayCategory_1.DisplayCategory.LIGHT]; 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); return device; } diff --git a/dist/AlexaErrorResponse.d.ts b/dist/AlexaErrorResponse.d.ts new file mode 100644 index 0000000..c96b92b --- /dev/null +++ b/dist/AlexaErrorResponse.d.ts @@ -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): void; + toJSON(): { + event: any; + }; + send(sendAsync?: boolean): Promise; +} diff --git a/dist/AlexaErrorResponse.js b/dist/AlexaErrorResponse.js index 5c31483..6cc3b0f 100644 --- a/dist/AlexaErrorResponse.js +++ b/dist/AlexaErrorResponse.js @@ -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 payloadStr = JSON.stringify(payload); - return new Promise((resolve, reject) => { - this.mqttClient.publish(topic, payloadStr, (err) => (err ? reject(err) : resolve(topic))); + return new Promise((resolve) => { + this.mqttClient.publish(topic, payloadStr, (err) => { + if (!err) + return resolve(topic); + if (this.onPublishError) + this.onPublishError(err); // -> the bridge's "error" event (when somebody listens) + resolve(""); // 1.5.1 rejected here, and an un-caught send() then killed the host on any broker hiccup + }); }); } } diff --git a/dist/AlexaInterface.d.ts b/dist/AlexaInterface.d.ts new file mode 100644 index 0000000..2c2f642 --- /dev/null +++ b/dist/AlexaInterface.d.ts @@ -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): void; + setInstance(name: string): void; + getType(): AlexaInterfaceType; + getTypeString(): string; + getVersion(): string; + getProps(): string[]; + getJSON(): object; +} diff --git a/dist/AlexaInterface.js b/dist/AlexaInterface.js index 1b97c71..8038b18 100644 --- a/dist/AlexaInterface.js +++ b/dist/AlexaInterface.js @@ -90,6 +90,7 @@ class AlexaInterface { addFriendlyName(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) { this.supportedModes = modes; } @@ -206,10 +207,10 @@ class AlexaInterface { return ["temperature"]; case AlexaInterfaceType.THERMOSTAT_CONTROLLER: return [ + "targetSetpoint", "lowerSetpoint", "upperSetpoint", "thermostatMode", - "adaptiveRecoveryStatus", ]; case AlexaInterfaceType.APPLICATION_STATE_REPORTER: case AlexaInterfaceType.AUDIO_PLAY_QUEUE: @@ -260,7 +261,8 @@ class AlexaInterface { case AlexaInterfaceType.USER_PREFERENCE: case AlexaInterfaceType.VIDEO_RECORDER: case AlexaInterfaceType.WAKE_ON_LAN_CONTROLLER: - console.error("UNSUPORTED INTERFACE TYPE"); + // no property list known for these: discovery lists the capability with an empty "supported" (the bridge notes + // it through its log hook; 1.5.1 wrote "UNSUPORTED INTERFACE TYPE" to stderr on every discovery) return []; default: return []; diff --git a/dist/AlexaStatusMessage.d.ts b/dist/AlexaStatusMessage.d.ts new file mode 100644 index 0000000..53b0e2d --- /dev/null +++ b/dist/AlexaStatusMessage.d.ts @@ -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 //alexaResponce (sendAsync: deferredResponse), a ChangeReport + * to /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; +} +export {}; diff --git a/dist/AlexaStatusMessage.js b/dist/AlexaStatusMessage.js index 38f4bc6..b6aec00 100644 --- a/dist/AlexaStatusMessage.js +++ b/dist/AlexaStatusMessage.js @@ -130,6 +130,7 @@ class AlexaStatusMessage { }; 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) { return this.addProperty(AlexaInterface_1.AlexaInterfaceType.ENDPOINT_HEALTH, "connectivity", { value: health }, uncertaintyInMs); } @@ -161,15 +162,22 @@ class AlexaStatusMessage { /** * Publish: a Response/StateReport to //alexaResponce (sendAsync: deferredResponse), a ChangeReport * to /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) { const payloadStr = JSON.stringify(this.toJSON()); const topic = this.changeCause ? `${this.rootTopic}/changeReport` : `${this.rootTopic}/${this.endpointId}/${sendAsync ? "deferredResponse" : "alexaResponce"}`; //Yes this should be response but it is incorrect in both Alex2MQTT and Alex2ESP so for consistency is is wrong here too - return new Promise((resolve, reject) => { - this.mqttClient.publish(topic, payloadStr, (err) => (err ? reject(err) : resolve(topic))); + return new Promise((resolve) => { + this.mqttClient.publish(topic, payloadStr, (err) => { + if (!err) + return resolve(topic); + if (this.onPublishError) + this.onPublishError(err); // -> the bridge's "error" event (when somebody listens) + resolve(""); // 1.5.1 rejected here, and an un-caught send() then killed the host on any broker hiccup + }); }); } } diff --git a/dist/Device.d.ts b/dist/Device.d.ts new file mode 100644 index 0000000..cb731e0 --- /dev/null +++ b/dist/Device.d.ts @@ -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 | 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 | 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; + 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 /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; + 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; +} +export default Device; diff --git a/dist/Device.js b/dist/Device.js index b151148..d9d0ec8 100644 --- a/dist/Device.js +++ b/dist/Device.js @@ -21,6 +21,10 @@ class Device extends events_1.EventEmitter { this.softwareVersion = "1.0.0"; 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() { return this.name; } @@ -43,10 +47,14 @@ class Device extends events_1.EventEmitter { return this.description; } 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) { - 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 @@ -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. */ 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 - * (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) { const payload = { @@ -70,8 +80,14 @@ class Device extends events_1.EventEmitter { }, }; const topic = `${this.rootTopic}/${this.endpointId}/${sendAsync ? "deferredResponse" : "alexaResponce"}`; - return new Promise((resolve, reject) => { - this.mqttClient.publish(topic, JSON.stringify(payload), (err) => (err ? reject(err) : resolve(topic))); + return new Promise((resolve) => { + this.mqttClient.publish(topic, JSON.stringify(payload), (err) => { + if (!err) + return resolve(topic); + if (this.onPublishError) + this.onPublishError(err); // -> the bridge's "error" event (when somebody listens) + resolve(""); // 1.5.1 rejected here, and an un-caught send() then killed the host on any broker hiccup + }); }); } getCapabilities() { diff --git a/dist/DisplayCategory.d.ts b/dist/DisplayCategory.d.ts new file mode 100644 index 0000000..c70eeea --- /dev/null +++ b/dist/DisplayCategory.d.ts @@ -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" +} diff --git a/dist/index.d.ts b/dist/index.d.ts new file mode 100644 index 0000000..5a2dc61 --- /dev/null +++ b/dist/index.d.ts @@ -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"; diff --git a/examples/BlindControl.js b/examples/BlindControl.js index fefd7bd..02f9e16 100644 --- a/examples/BlindControl.js +++ b/examples/BlindControl.js @@ -1,11 +1,12 @@ -// Import required modules and types from the compiled TypeScript distribution (via dist/index.js) -const { +// Import the library ("alex2node" is the installed package; inside this checkout it resolves to the built dist/ via package.json "exports") +const { Alex2MQTT, AlexaInterfaceType, ActionMapping, AlexaActions, - PowerController - } = require("../dist/index.js"); + PowerController, + EndpointHealth + } = require("alex2node"); 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 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 const deviceName = "Bedroom Blinds"; // 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 blinds.addCapability(AlexaInterfaceType.POWER_CONTROLLER); @@ -63,12 +65,12 @@ console.log(blinds.getName()); blinds.on("ReportState", (payload) => { console.log("ReportState received!", payload); - const { correlationToken } = payload; + const { correlationToken } = payload.header; const status = blinds.getStatusMessage(correlationToken); status - .addHealthProp("OK") // Device is healthy + .addHealthProp(EndpointHealth.OK) // Device is healthy .addPowerControllerProp(outputState) // Report power 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 const status = blinds.getStatusMessage(token, true); status - .addHealthProp("OK") + .addHealthProp(EndpointHealth.OK) .addToggleControllerProp(outputState, "NodeJS.Toggle") .addPowerControllerProp(outputState) .send(); diff --git a/examples/ExampleModeController.js b/examples/ExampleModeController.js index 0313d5e..b79a643 100644 --- a/examples/ExampleModeController.js +++ b/examples/ExampleModeController.js @@ -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 { Alex2MQTT, AlexaInterfaceType, PowerController, DisplayCategory, -} = require("../dist/index.js"); + EndpointHealth, +} = require("alex2node"); 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 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 (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( deviceName, - "endpoint6", + "fan-1", [DisplayCategory.OTHER] ); @@ -83,7 +85,7 @@ modeControllerDevice.on("ReportState", (payload) => { const status = modeControllerDevice.getStatusMessage(correlationToken); status - .addHealthProp("OK") // Device is healthy + .addHealthProp(EndpointHealth.OK) // Device is healthy .addModeControllerProp("mode.fanmode", currentMode); status.send(); // Send the state report back to Alexa @@ -110,7 +112,7 @@ modeControllerDevice.on("Event", (directive, interfaceType) => { const status = modeControllerDevice.getStatusMessage(token, true); status - .addHealthProp("OK") // Device is healthy + .addHealthProp(EndpointHealth.OK) // Device is healthy .addModeControllerProp("mode.fanmode", currentMode); status.send(); // Send the state report back to Alexa diff --git a/examples/ExamplePowerController.js b/examples/ExamplePowerController.js index 8e6bfb2..7461b0a 100644 --- a/examples/ExamplePowerController.js +++ b/examples/ExamplePowerController.js @@ -1,9 +1,10 @@ -// Import required modules and types from the compiled TypeScript distribution (via dist/index.js) -const { +// 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 -} = require("../dist/index.js"); + PowerController, + EndpointHealth +} = require("alex2node"); 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 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 (bedroom light) const deviceName = "Bedroom Light"; -// Register the device with a unique endpoint ID (you can use something like "endpoint1") -const bedroomLight = alex2NodeClient.registerDevice(deviceName, "2E3AE9"); +// Register the device with a unique endpoint ID (unique per root topic, e.g. "bedroom-light-1") +const bedroomLight = alex2NodeClient.registerDevice(deviceName, "bedroom-light-1"); // Add the PowerController capability (for turning on/off the device) bedroomLight.addCapability(AlexaInterfaceType.POWER_CONTROLLER); @@ -31,9 +33,6 @@ bedroomLight.addCapability(AlexaInterfaceType.POWER_CONTROLLER); // Log the device name to verify registration console.log(bedroomLight.getName()); -const momentaryModeInstance = "mode.signal.beep"; -const defaultMomentaryMode = "mode.signal.beep.Normal"; - /** * Handle Alexa's ReportState directive. * 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); status - .addHealthProp("OK") // Device is healthy - .addPowerControllerProp(outputState) // Report the current power state (ON/OFF) - .addBrightnessControllerProp(100) - .addModeControllerProp(momentaryModeInstance, defaultMomentaryMode); + .addHealthProp(EndpointHealth.OK) // Device is healthy + .addPowerControllerProp(outputState); // Report the current power state (ON/OFF) 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 const status = bedroomLight.getStatusMessage(token, true); status - .addHealthProp("OK") + .addHealthProp(EndpointHealth.OK) .addPowerControllerProp(outputState) - .addBrightnessControllerProp(100) .send(); } }); diff --git a/examples/ExamplePowerControllerDeferred.js b/examples/ExamplePowerControllerDeferred.js index 1661318..26ff72a 100644 --- a/examples/ExamplePowerControllerDeferred.js +++ b/examples/ExamplePowerControllerDeferred.js @@ -1,9 +1,10 @@ -// Import required modules and types from the compiled TypeScript distribution (via dist/index.js) -const { +// 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 -} = require("../dist/index.js"); + PowerController, + EndpointHealth +} = require("alex2node"); 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 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 (deferred light) const deviceName = "Deferred Light"; -// Register the device with a unique endpoint ID (you can use something like "endpoint1") -const deferredLight = alex2NodeClient.registerDevice(deviceName, "endpoint3"); +// Register the device with a unique endpoint ID (unique per root topic, e.g. "deferred-light-1") +const deferredLight = alex2NodeClient.registerDevice(deviceName, "deferred-light-1"); // Add the PowerController capability (for turning on/off the device) deferredLight.addCapability(AlexaInterfaceType.POWER_CONTROLLER); @@ -40,7 +42,7 @@ deferredLight.on("ReportState", (payload) => { const { correlationToken } = payload.header; - // Step 1: Send a DeferredResponse after ~1 second + // Step 1: Send a DeferredResponse after ~2 seconds setTimeout(() => { const deferred = deferredLight.getStatusMessage(correlationToken, false, true); // isDeferred = true deferred.addEstimatedDeferralTime(20).send(); @@ -51,7 +53,7 @@ deferredLight.on("ReportState", (payload) => { setTimeout(() => { const status = deferredLight.getStatusMessage(correlationToken,false,false); // normal StateReport status - .addHealthProp("OK") + .addHealthProp(EndpointHealth.OK) .addPowerControllerProp(outputState) .send(true); 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 const status = deferredLight.getStatusMessage(token, true); status - .addHealthProp("OK") + .addHealthProp(EndpointHealth.OK) .addPowerControllerProp(outputState) .send(); } diff --git a/examples/ExamplePowerControllerWithBrightness.js b/examples/ExamplePowerControllerWithBrightness.js new file mode 100644 index 0000000..dda1f37 --- /dev/null +++ b/examples/ExamplePowerControllerWithBrightness.js @@ -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(); +}); diff --git a/examples/ExamplePowerControllerWithErrors.js b/examples/ExamplePowerControllerWithErrors.js index de20d68..8d504d7 100644 --- a/examples/ExamplePowerControllerWithErrors.js +++ b/examples/ExamplePowerControllerWithErrors.js @@ -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 { Alex2MQTT, AlexaInterfaceType, PowerController, AlexaErrorType, -} = require("../dist/index.js"); + EndpointHealth, +} = require("alex2node"); require("dotenv").config(); @@ -15,8 +17,9 @@ const rootTopic = process.env.MQTT_ROOT_TOPIC; const alex2NodeClient = new Alex2MQTT(username, password, rootTopic, false); 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); console.log(errorLight.getName()); @@ -85,7 +88,7 @@ errorLight.on("Event", (directive, interfaceType) => { const status = errorLight.getStatusMessage(token, true); status - .addHealthProp("OK") + .addHealthProp(EndpointHealth.OK) .addPowerControllerProp(outputState) .send(); } diff --git a/examples/ExamplePowerControllerWithMomentary.js b/examples/ExamplePowerControllerWithMomentary.js index eb0558b..8f09e97 100644 --- a/examples/ExamplePowerControllerWithMomentary.js +++ b/examples/ExamplePowerControllerWithMomentary.js @@ -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 { Alex2MQTT, AlexaInterfaceType, PowerController, - DisplayCategory -} = require("../dist/index.js"); + DisplayCategory, + EndpointHealth +} = require("alex2node"); 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 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 and register it const deviceName = "Kitchen Light"; const kitchenLight = alex2NodeClient.registerDevice( deviceName, - "endpoint7", + "kitchen-light-1", [DisplayCategory.LIGHT] ); @@ -69,7 +71,7 @@ kitchenLight.on("ReportState", (payload) => { const { correlationToken } = payload.header; const status = kitchenLight.getStatusMessage(correlationToken); status - .addHealthProp("OK") + .addHealthProp(EndpointHealth.OK) .addPowerControllerProp(outputState) .addModeControllerProp(momentaryModeInstance, defaultMomentaryMode); status.send(); @@ -82,7 +84,7 @@ kitchenLight.on("Event", (directive, interfaceType) => { console.log("Event received", { directive, interfaceType }); 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 if (interfaceType === AlexaInterfaceType.POWER_CONTROLLER) { diff --git a/examples/ExampleThermostat.js b/examples/ExampleThermostat.js index a8737b9..93daead 100644 --- a/examples/ExampleThermostat.js +++ b/examples/ExampleThermostat.js @@ -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 { Alex2MQTT, AlexaInterfaceType, - PowerController, TemperatureSensorScale, DisplayCategory, ThermostatMode, -} = require("../dist/index.js"); + 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 (if thermostat supports ON/OFF modes) - // Load MQTT connection credentials and root topic from environment variables const username = process.env.MQTT_USERNAME; 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 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 (Thermostat Test) const deviceName = "Thermostat Test"; @@ -28,7 +26,7 @@ const deviceName = "Thermostat Test"; // Register the thermostat device with a unique endpoint ID const thermostat = alex2NodeClient.registerDevice( deviceName, - "thermostatEndpoint1", + "thermostat-1", [DisplayCategory.TEMPERATURE_SENSOR, DisplayCategory.THERMOSTAT,DisplayCategory.OTHER] ); @@ -49,7 +47,7 @@ modeController.addSupportedModes([ { "@type": "text", value: { - text: "Bannana", + text: "Auto", locale: "en-US", }, }, @@ -63,7 +61,7 @@ modeController.addSupportedModes([ { "@type": "text", value: { - text: "Apple", + text: "On", 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). */ -const deviceData = { +const deviceData = { // all temperatures in Fahrenheit; the library converts to Celsius for Alexa lowerSetpoint: 50, upperSetpoint: 70, targetSetpoint: 60, @@ -96,10 +94,10 @@ thermostat.on("ReportState", (payload) => { let status = thermostat.getStatusMessage(correlationToken); status - .addHealthProp("OK") // Device is healthy and reachable + .addHealthProp(EndpointHealth.OK) // Device is healthy and reachable .addModeControllerProp("mode.fanmode", deviceData.fanMode) .addTemperatureSensorProp( - TemperatureSensorScale.CELSIUS, + TemperatureSensorScale.FAHRENHEIT, deviceData.current ) // Report current temperature .addThermostatModeProp(deviceData.thermostatMode); // Report current thermostat mode @@ -108,20 +106,20 @@ thermostat.on("ReportState", (payload) => { status = status .addThermostatControllerProp( "lowerSetpoint", - TemperatureSensorScale.CELSIUS, + TemperatureSensorScale.FAHRENHEIT, deviceData.lowerSetpoint ) // Lower bound of temperature range .addThermostatControllerProp( "upperSetpoint", - TemperatureSensorScale.CELSIUS, + TemperatureSensorScale.FAHRENHEIT, deviceData.upperSetpoint ); // Upper bound of temperature range } else { status = status.addThermostatControllerProp( "targetSetpoint", - TemperatureSensorScale.CELSIUS, + TemperatureSensorScale.FAHRENHEIT, deviceData.targetSetpoint - ); // Lower bound of temperature range + ); // Single setpoint (HEAT / COOL) } 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); status - .addHealthProp("OK") // Device is healthy and reachable + .addHealthProp(EndpointHealth.OK) // Device is healthy and reachable .addModeControllerProp("mode.fanmode", deviceData.fanMode) .addTemperatureSensorProp( TemperatureSensorScale.FAHRENHEIT, @@ -188,7 +186,7 @@ thermostat.on("Event", (directive, interfaceType) => { "targetSetpoint", TemperatureSensorScale.FAHRENHEIT, deviceData.targetSetpoint - ); // Lower bound of temperature range + ); // Single setpoint (HEAT / COOL) } status.send(); // Send the full state report back to Alexa } diff --git a/examples/ExampleToggleController.js b/examples/ExampleToggleController.js index 0d6deca..226280f 100644 --- a/examples/ExampleToggleController.js +++ b/examples/ExampleToggleController.js @@ -1,20 +1,23 @@ +// 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, - } = require("../dist/index.js"); - + EndpointHealth, + } = require("alex2node"); + require("dotenv").config(); - + const username = process.env.MQTT_USERNAME; const password = process.env.MQTT_PASSWORD; 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.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 customToggle = alex2NodeClient.registerDevice(deviceName, "endpoint8"); + const customToggle = alex2NodeClient.registerDevice(deviceName, "custom-device-1"); // Define 8 toggle instances and friendly names const switchCount = 8; @@ -40,7 +43,7 @@ const { const { correlationToken } = payload.header; const status = customToggle.getStatusMessage(correlationToken); - status.addHealthProp("OK"); + status.addHealthProp(EndpointHealth.OK); // Report all toggle states for (let i = 0; i < switchCount; i++) { @@ -68,7 +71,7 @@ const { const status = customToggle.getStatusMessage(correlationToken, true); status - .addHealthProp("OK") + .addHealthProp(EndpointHealth.OK) .addToggleControllerProp(toggleStates[index], instance) .send(); } diff --git a/package-lock.json b/package-lock.json index 9250360..66e35bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "alex2node", - "version": "1.5.1", + "version": "1.5.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "alex2node", - "version": "1.5.1", + "version": "1.5.2", "license": "MIT", "dependencies": { "mqtt": "^5.10.3", diff --git a/package.json b/package.json index 3003ac9..2a61fd0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "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.", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/readme.md b/readme.md index 2303e8f..3127cd8 100644 --- a/readme.md +++ b/readme.md @@ -13,6 +13,27 @@ For more details on how to configure Alex2MQTT, visit [Alex2MQTT Documentation]( - 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 - **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("connect", () => console.log("connected")); 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.on("Event", (directive) => { /* ... act, then: */ lamp.getStatusMessage(directive.header.correlationToken, true).addPowerControllerProp(PowerController.ON).send(); }); // later, when the light changes locally: @@ -52,12 +73,20 @@ lamp.getChangeReport("PHYSICAL_INTERACTION").addPowerControllerProp(PowerControl ### 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 -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# # pinned to one commit ``` +The repository is public, so the `https` form needs no account. `#` (or `#`, 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 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 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 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 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: ```js 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 bedroomLight.addCapability(AlexaInterfaceType.POWER_CONTROLLER); @@ -105,10 +135,10 @@ bedroomLight.addCapability(AlexaInterfaceType.POWER_CONTROLLER); // Handle Alexa's ReportState directive to report the current state let outputState = PowerController.OFF; 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); status - .addHealthProp("OK") + .addHealthProp(EndpointHealth.OK) .addPowerControllerProp(outputState) .send(); }); @@ -129,7 +159,7 @@ bedroomLight.on("Event", (directive, interfaceType) => { const status = bedroomLight.getStatusMessage(token, true); status - .addHealthProp("OK") + .addHealthProp(EndpointHealth.OK) .addPowerControllerProp(outputState) .send(); } @@ -141,66 +171,66 @@ bedroomLight.on("Event", (directive, interfaceType) => { ## Interface Types | Alexa Interface Type | Status | |-----------------------------------------------------|-------------| -| AlexaInterfaceType::ENDPOINT_HEALTH | Fully Supported | -| AlexaInterfaceType::POWER_CONTROLLER | Fully Supported | -| AlexaInterfaceType::BRIGHTNESS_CONTROLLER | Fully Supported | -| AlexaInterfaceType::TOGGLE_CONTROLLER | Fully Supported | -| AlexaInterfaceType::TEMPERATURE_SENSOR | Fully Supported | -| AlexaInterfaceType::COLOR_TEMPERATURE_CONTROLLER | Fully Supported | -| AlexaInterfaceType::AUTOMATION_MANAGEMENT | Supported* | -| AlexaInterfaceType::CHANNEL_CONTROLLER | Supported* | -| AlexaInterfaceType::COLOR_CONTROLLER | Supported* | -| AlexaInterfaceType::CONTACT_SENSOR | Supported* | -| AlexaInterfaceType::APPLICATION_STATE_REPORTER | Supported* | -| AlexaInterfaceType::AUDIO_PLAY_QUEUE | Supported* | -| AlexaInterfaceType::AUTHORIZATION_CONTROLLER | Supported* | -| AlexaInterfaceType::AUTOMOTIVE_VEHICLE_DATA | Supported* | -| AlexaInterfaceType::CAMERA_LIVE_VIEW_CONTROLLER | Supported* | -| AlexaInterfaceType::CAMERA_STREAM_CONTROLLER | Supported* | -| AlexaInterfaceType::COMMISSIONABLE | Supported* | -| AlexaInterfaceType::CONSENT_MANAGEMENT_CONSENT_REQUIRED_REPORTER | Supported* | -| AlexaInterfaceType::COOKING | Supported* | -| AlexaInterfaceType::DATA_CONTROLLER | Supported* | -| AlexaInterfaceType::DEVICE_USAGE_ESTIMATION | Supported* | -| AlexaInterfaceType::DEVICE_USAGE_METER | Supported* | -| AlexaInterfaceType::DOORBELL_EVENT_SOURCE | Supported* | -| AlexaInterfaceType::EQUALIZER_CONTROLLER | Supported* | -| AlexaInterfaceType::INPUT_CONTROLLER | Supported* | -| AlexaInterfaceType::INVENTORY_LEVEL_SENSOR | Supported* | -| AlexaInterfaceType::INVENTORY_LEVEL_USAGE_SENSOR | Supported* | -| AlexaInterfaceType::INVENTORY_USAGE_SENSOR | Supported* | -| AlexaInterfaceType::KEYPAD_CONTROLLER | Supported* | -| AlexaInterfaceType::LAUNCHER | Supported* | -| AlexaInterfaceType::LOCK_CONTROLLER | Supported* | -| AlexaInterfaceType::MEDIA_PLAYBACK | Supported* | -| AlexaInterfaceType::MEDIA_SEARCH | Supported* | -| AlexaInterfaceType::MODE_CONTROLLER | Supported* | -| AlexaInterfaceType::MOTION_SENSOR | Supported* | -| AlexaInterfaceType::PERCENTAGE_CONTROLLER | Supported* | -| AlexaInterfaceType::PLAYBACK_CONTROLLER | Supported* | -| AlexaInterfaceType::PLAYBACK_STATE_REPORTER | Supported* | -| AlexaInterfaceType::PROACTIVE_NOTIFICATION_SOURCE | Supported* | -| AlexaInterfaceType::RANGE_CONTROLLER | Supported* | -| AlexaInterfaceType::RECORD_CONTROLLER | Supported* | -| AlexaInterfaceType::REMOTE_VIDEO_PLAYER | Supported* | -| AlexaInterfaceType::RTC_SESSION_CONTROLLER | Supported* | -| AlexaInterfaceType::SCENE_CONTROLLER | Supported* | -| AlexaInterfaceType::SECURITY_PANEL_CONTROLLER | Supported* | -| AlexaInterfaceType::SEEK_CONTROLLER | Supported* | -| AlexaInterfaceType::SIMPLE_EVENT_SOURCE | Supported* | -| AlexaInterfaceType::SMART_VISION_OBJECT_DETECTION_SENSOR | Supported* | -| AlexaInterfaceType::SMART_VISION_SNAPSHOT_PROVIDER | Supported* | -| AlexaInterfaceType::SPEAKER | Supported* | -| AlexaInterfaceType::STEP_SPEAKER | Supported* | -| AlexaInterfaceType::THERMOSTAT_CONTROLLER | Supported* | -| AlexaInterfaceType::THERMOSTAT_CONTROLLER_CONFIGURATION | Supported* | -| AlexaInterfaceType::THERMOSTAT_CONTROLLER_HVAC_COMPONENTS | Supported* | -| AlexaInterfaceType::THERMOSTAT_CONTROLLER_SCHEDULE | Supported* | -| AlexaInterfaceType::TIME_HOLD_CONTROLLER | Supported* | -| AlexaInterfaceType::UI_CONTROLLER | Supported* | -| AlexaInterfaceType::USER_PREFERENCE | Supported* | -| AlexaInterfaceType::VIDEO_RECORDER | Supported* | -| AlexaInterfaceType::WAKE_ON_LAN_CONTROLLER | Supported* | +| AlexaInterfaceType.ENDPOINT_HEALTH | Fully Supported | +| AlexaInterfaceType.POWER_CONTROLLER | Fully Supported | +| AlexaInterfaceType.BRIGHTNESS_CONTROLLER | Fully Supported | +| AlexaInterfaceType.TOGGLE_CONTROLLER | Fully Supported | +| AlexaInterfaceType.TEMPERATURE_SENSOR | Fully Supported | +| AlexaInterfaceType.COLOR_TEMPERATURE_CONTROLLER | Fully Supported | +| AlexaInterfaceType.AUTOMATION_MANAGEMENT | Supported* | +| AlexaInterfaceType.CHANNEL_CONTROLLER | Supported* | +| AlexaInterfaceType.COLOR_CONTROLLER | Supported* | +| AlexaInterfaceType.CONTACT_SENSOR | Supported* | +| AlexaInterfaceType.APPLICATION_STATE_REPORTER | Supported* | +| AlexaInterfaceType.AUDIO_PLAY_QUEUE | Supported* | +| AlexaInterfaceType.AUTHORIZATION_CONTROLLER | Supported* | +| AlexaInterfaceType.AUTOMOTIVE_VEHICLE_DATA | Supported* | +| AlexaInterfaceType.CAMERA_LIVE_VIEW_CONTROLLER | Supported* | +| AlexaInterfaceType.CAMERA_STREAM_CONTROLLER | Supported* | +| AlexaInterfaceType.COMMISSIONABLE | Supported* | +| AlexaInterfaceType.CONSENT_MANAGEMENT_CONSENT_REQUIRED_REPORTER | Supported* | +| AlexaInterfaceType.COOKING | Supported* | +| AlexaInterfaceType.DATA_CONTROLLER | Supported* | +| AlexaInterfaceType.DEVICE_USAGE_ESTIMATION | Supported* | +| AlexaInterfaceType.DEVICE_USAGE_METER | Supported* | +| AlexaInterfaceType.DOORBELL_EVENT_SOURCE | Supported* | +| AlexaInterfaceType.EQUALIZER_CONTROLLER | Supported* | +| AlexaInterfaceType.INPUT_CONTROLLER | Supported* | +| AlexaInterfaceType.INVENTORY_LEVEL_SENSOR | Supported* | +| AlexaInterfaceType.INVENTORY_LEVEL_USAGE_SENSOR | Supported* | +| AlexaInterfaceType.INVENTORY_USAGE_SENSOR | Supported* | +| AlexaInterfaceType.KEYPAD_CONTROLLER | Supported* | +| AlexaInterfaceType.LAUNCHER | Supported* | +| AlexaInterfaceType.LOCK_CONTROLLER | Supported* | +| AlexaInterfaceType.MEDIA_PLAYBACK | Supported* | +| AlexaInterfaceType.MEDIA_SEARCH | Supported* | +| AlexaInterfaceType.MODE_CONTROLLER | Supported* | +| AlexaInterfaceType.MOTION_SENSOR | Supported* | +| AlexaInterfaceType.PERCENTAGE_CONTROLLER | Supported* | +| AlexaInterfaceType.PLAYBACK_CONTROLLER | Supported* | +| AlexaInterfaceType.PLAYBACK_STATE_REPORTER | Supported* | +| AlexaInterfaceType.PROACTIVE_NOTIFICATION_SOURCE | Supported* | +| AlexaInterfaceType.RANGE_CONTROLLER | Supported* | +| AlexaInterfaceType.RECORD_CONTROLLER | Supported* | +| AlexaInterfaceType.REMOTE_VIDEO_PLAYER | Supported* | +| AlexaInterfaceType.RTC_SESSION_CONTROLLER | Supported* | +| AlexaInterfaceType.SCENE_CONTROLLER | Supported* | +| AlexaInterfaceType.SECURITY_PANEL_CONTROLLER | Supported* | +| AlexaInterfaceType.SEEK_CONTROLLER | Supported* | +| AlexaInterfaceType.SIMPLE_EVENT_SOURCE | Supported* | +| AlexaInterfaceType.SMART_VISION_OBJECT_DETECTION_SENSOR | Supported* | +| AlexaInterfaceType.SMART_VISION_SNAPSHOT_PROVIDER | Supported* | +| AlexaInterfaceType.SPEAKER | Supported* | +| AlexaInterfaceType.STEP_SPEAKER | Supported* | +| AlexaInterfaceType.THERMOSTAT_CONTROLLER | Supported* | +| AlexaInterfaceType.THERMOSTAT_CONTROLLER_CONFIGURATION | Supported* | +| AlexaInterfaceType.THERMOSTAT_CONTROLLER_HVAC_COMPONENTS | Supported* | +| AlexaInterfaceType.THERMOSTAT_CONTROLLER_SCHEDULE | Supported* | +| AlexaInterfaceType.TIME_HOLD_CONTROLLER | Supported* | +| AlexaInterfaceType.UI_CONTROLLER | Supported* | +| AlexaInterfaceType.USER_PREFERENCE | Supported* | +| AlexaInterfaceType.VIDEO_RECORDER | Supported* | +| AlexaInterfaceType.WAKE_ON_LAN_CONTROLLER | Supported* | (*Partial support or limited implementation advanced configuration is required) diff --git a/src/ActionMapping.ts b/src/ActionMapping.ts index 2c28bc1..e89be14 100644 --- a/src/ActionMapping.ts +++ b/src/ActionMapping.ts @@ -20,7 +20,7 @@ export class ActionMapping { constructor( actions: AlexaActions[], directiveName: string, - directivePayload: string | undefined + directivePayload?: string ) { this.actions = actions; this.directive = { diff --git a/src/Alex2Node.ts b/src/Alex2Node.ts index 60c5125..8e3672a 100644 --- a/src/Alex2Node.ts +++ b/src/Alex2Node.ts @@ -2,6 +2,7 @@ import mqtt, { MqttClient, IClientOptions } from "mqtt"; import Device from "./Device"; import { EventEmitter } from "events"; import { DisplayCategory } from "./DisplayCategory"; +import { AlexaInterfaceType } from "./AlexaInterface"; /** Optional settings for the bridge (1.5.1). Everything has the 1.4.0 behaviour as its default. */ export interface Alex2MQTTOptions { @@ -75,6 +76,7 @@ class Alex2MQTT extends EventEmitter { }; this.client = mqtt.connect(this.MqttHost, options); + for (const d of this.devices) d.setMqttClient(this.client); // after a disconnect(): the devices publish through the new client this.client.on("connect", () => { this.connected = true; @@ -96,6 +98,10 @@ class Alex2MQTT extends EventEmitter { this.log("Discovery request received, getting device json..."); const deviceArray = this.devices.map((device) => device.getJSON()); this.lastDiscoveryAt = new Date().toISOString(); + for (const device of this.devices) // a capability the library has no property list for goes out with supported: [] + for (const cap of device.getCapabilities()) + if (cap.getProps().length === 0 && cap.getType() !== AlexaInterfaceType.SCENE_CONTROLLER) + this.log(`${device.endpointId}: no property list known for ${cap.getTypeString()}, discovery lists it with no supported properties`); this.client!.publish(topic + "_r", JSON.stringify(deviceArray), (err) => { if (err) { this.fail(err); return; } this.log(`Discovery payloads published to ${topic + "_r"}`, deviceArray); @@ -149,6 +155,12 @@ class Alex2MQTT extends EventEmitter { if (!this.client) { throw new Error("Must call connect before creating devices"); } + const existing = this.devices.find((d) => d.endpointId === endpointId); + if (existing) { // 1.5.2: endpointIds are unique per root topic; 1.5.1 added a second device that never got a directive + const warning = `warning: registerDevice("${endpointId}") is already registered as "${existing.name}", returning that device`; + if (this.options.log) this.options.log(warning); else console.warn(`[Alex2Node.ts] ${warning}`); // visible without a log hook too + return existing; + } this.log(`Creating new device with endpoint: ${endpointId}`); const normalizedCategory = displayCategory === null @@ -164,6 +176,7 @@ class Alex2MQTT extends EventEmitter { endpointId, normalizedCategory ); + device.onPublishError = (err) => this.fail(err); // a failed publish is an "error" event (when listened to), never a rejected send() this.devices.push(device); return device; } diff --git a/src/AlexaErrorResponse.ts b/src/AlexaErrorResponse.ts index 1f065c5..77910c4 100644 --- a/src/AlexaErrorResponse.ts +++ b/src/AlexaErrorResponse.ts @@ -85,6 +85,8 @@ export class AlexaErrorResponse { private rootTopic: string; private endpointId: string; private mqttClient: MqttClient; + /** Where a failed publish is reported (set by the Device that built this message, 1.5.2): send() never rejects. */ + public onPublishError?: (err: Error) => void; constructor( correlationToken: string, @@ -136,8 +138,12 @@ export class AlexaErrorResponse { sendAsync ? "deferredResponse" : "alexaResponce" }`; //Yes this should be response but it is incorrect in both Alex2MQTT and Alex2ESP so for consistency is is wrong here too const payloadStr = JSON.stringify(payload); - return new Promise((resolve, reject) => { - this.mqttClient.publish(topic, payloadStr, (err) => (err ? reject(err) : resolve(topic))); + return new Promise((resolve) => { + this.mqttClient.publish(topic, payloadStr, (err) => { + if (!err) return resolve(topic); + if (this.onPublishError) this.onPublishError(err); // -> the bridge's "error" event (when somebody listens) + resolve(""); // 1.5.1 rejected here, and an un-caught send() then killed the host on any broker hiccup + }); }); } } diff --git a/src/AlexaInterface.ts b/src/AlexaInterface.ts index 8140450..39b10c9 100644 --- a/src/AlexaInterface.ts +++ b/src/AlexaInterface.ts @@ -78,10 +78,16 @@ interface FriendlyName { locale: string; } +/** One ModeController mode as discovery lists it (configuration.supportedModes): the value plus its friendly names. */ +export interface SupportedMode { + value: string; + modeResources?: { friendlyNames: Array<{ "@type": string; value: { text?: string; locale?: string; assetId?: string } }> }; +} + export class AlexaInterface { private friendlyNames: FriendlyName[] = []; private actionMappings: ActionMapping[] = []; - private supportedModes: string[] = []; + private supportedModes: Array = []; constructor( public type: AlexaInterfaceType, @@ -97,7 +103,8 @@ export class AlexaInterface { addFriendlyName(name: string, locale: string): void { this.friendlyNames.push({ text: name, locale }); } - addSupportedModes(modes: string[]): void { + /** The modes of a ModeController: { value, modeResources } objects as Alexa wants them (plain strings pass through as given). */ + addSupportedModes(modes: Array): void { this.supportedModes = modes; } setInstance(name: string): void { @@ -218,10 +225,10 @@ export class AlexaInterface { return ["temperature"]; case AlexaInterfaceType.THERMOSTAT_CONTROLLER: return [ + "targetSetpoint", "lowerSetpoint", "upperSetpoint", "thermostatMode", - "adaptiveRecoveryStatus", ]; case AlexaInterfaceType.APPLICATION_STATE_REPORTER: case AlexaInterfaceType.AUDIO_PLAY_QUEUE: @@ -272,7 +279,8 @@ export class AlexaInterface { case AlexaInterfaceType.USER_PREFERENCE: case AlexaInterfaceType.VIDEO_RECORDER: case AlexaInterfaceType.WAKE_ON_LAN_CONTROLLER: - console.error("UNSUPORTED INTERFACE TYPE"); + // no property list known for these: discovery lists the capability with an empty "supported" (the bridge notes + // it through its log hook; 1.5.1 wrote "UNSUPORTED INTERFACE TYPE" to stderr on every discovery) return []; default: diff --git a/src/AlexaStatusMessage.ts b/src/AlexaStatusMessage.ts index 301887f..414aaa8 100644 --- a/src/AlexaStatusMessage.ts +++ b/src/AlexaStatusMessage.ts @@ -65,6 +65,8 @@ export class AlexaStatusMessage { private endpointId: string; private mqttClient: MqttClient; private isDeferred: boolean; + /** Where a failed publish is reported (set by the Device that built this message, 1.5.2): send() never rejects. */ + public onPublishError?: (err: Error) => void; constructor( correlationToken: string, @@ -202,7 +204,8 @@ export class AlexaStatusMessage { ); } - public addHealthProp(health: EndpointHealth, uncertaintyInMs = 0): this { + /** Alexa.EndpointHealth connectivity: EndpointHealth.OK / UNREACHABLE (the plain strings "OK" / "UNREACHABLE" are accepted too). */ + public addHealthProp(health: EndpointHealth | `${EndpointHealth}`, uncertaintyInMs = 0): this { return this.addProperty( AlexaInterfaceType.ENDPOINT_HEALTH, "connectivity", @@ -289,15 +292,20 @@ export class AlexaStatusMessage { /** * Publish: a Response/StateReport to //alexaResponce (sendAsync: deferredResponse), a ChangeReport * to /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 { const payloadStr = JSON.stringify(this.toJSON()); const topic = this.changeCause ? `${this.rootTopic}/changeReport` : `${this.rootTopic}/${this.endpointId}/${sendAsync ? "deferredResponse" : "alexaResponce"}`; //Yes this should be response but it is incorrect in both Alex2MQTT and Alex2ESP so for consistency is is wrong here too - return new Promise((resolve, reject) => { - this.mqttClient.publish(topic, payloadStr, (err) => (err ? reject(err) : resolve(topic))); + return new Promise((resolve) => { + this.mqttClient.publish(topic, payloadStr, (err) => { + if (!err) return resolve(topic); + if (this.onPublishError) this.onPublishError(err); // -> the bridge's "error" event (when somebody listens) + resolve(""); // 1.5.1 rejected here, and an un-caught send() then killed the host on any broker hiccup + }); }); } } diff --git a/src/Device.ts b/src/Device.ts index 2532fa5..0fdc8c4 100644 --- a/src/Device.ts +++ b/src/Device.ts @@ -9,6 +9,8 @@ import { AlexaErrorResponse } from "./AlexaErrorResponse"; class Device extends EventEmitter { protected softwareVersion: string = "1.0.0"; private capabilities: AlexaInterface[] = []; + /** Where a failed publish from this device or a message it built is reported; Alex2MQTT sets it (1.5.2). */ + public onPublishError?: (err: Error) => void; constructor( private mqttClient: MqttClient, @@ -24,6 +26,11 @@ class Device extends EventEmitter { super(); } + /** Internal (1.5.2): Alex2MQTT.connect() re-binds every registered device to its new broker client after a disconnect(). */ + setMqttClient(client: MqttClient): void { + this.mqttClient = client; + } + getName(): string { return this.name; } @@ -52,19 +59,21 @@ class Device extends EventEmitter { return this.description; } getErrorMessage(correlationToken: string): AlexaErrorResponse { - return new AlexaErrorResponse( + const msg = new AlexaErrorResponse( correlationToken, this.rootTopic, this.endpointId, this.mqttClient ); + msg.onPublishError = this.onPublishError; + return msg; } getStatusMessage( correlationToken: string, isResponse = false, isDeferred = false ): AlexaStatusMessage { - return new AlexaStatusMessage( + const msg = new AlexaStatusMessage( correlationToken, this.rootTopic, this.endpointId, @@ -72,6 +81,8 @@ class Device extends EventEmitter { isResponse, isDeferred ); + msg.onPublishError = this.onPublishError; + return msg; } /** * A proactive Alexa.ChangeReport (1.5.1): add the changed properties (the default target), optionally @@ -79,11 +90,13 @@ class Device extends EventEmitter { * event gateway with the user's token. Alexa only accepts it for capabilities registered with proactivelyReported. */ getChangeReport(cause: ChangeCause = "PHYSICAL_INTERACTION"): AlexaStatusMessage { - return new AlexaStatusMessage("", this.rootTopic, this.endpointId, this.mqttClient, false, false, cause); + const msg = new AlexaStatusMessage("", this.rootTopic, this.endpointId, this.mqttClient, false, false, cause); + msg.onPublishError = this.onPublishError; + return msg; } /** * Alexa.SceneController: answer an Activate / Deactivate directive with ActivationStarted / DeactivationStarted - * (1.5.1). Resolves with the topic published to. + * (1.5.1). Resolves with the topic published to, or "" when the publish failed (never rejects, 1.5.2). */ sendSceneResponse(correlationToken: string, activated: boolean, cause: ChangeCause = "VOICE_INTERACTION", sendAsync = false): Promise { const payload = { @@ -95,8 +108,12 @@ class Device extends EventEmitter { }, }; const topic = `${this.rootTopic}/${this.endpointId}/${sendAsync ? "deferredResponse" : "alexaResponce"}`; - return new Promise((resolve, reject) => { - this.mqttClient.publish(topic, JSON.stringify(payload), (err) => (err ? reject(err) : resolve(topic))); + return new Promise((resolve) => { + this.mqttClient.publish(topic, JSON.stringify(payload), (err) => { + if (!err) return resolve(topic); + if (this.onPublishError) this.onPublishError(err); // -> the bridge's "error" event (when somebody listens) + resolve(""); // 1.5.1 rejected here, and an un-caught send() then killed the host on any broker hiccup + }); }); } getCapabilities(): AlexaInterface[] { diff --git a/src/index.ts b/src/index.ts index 7c93aca..5a2dc61 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ export { default as Alex2MQTT, DEFAULT_HOST } from "./Alex2Node"; export type { Alex2MQTTOptions } from "./Alex2Node"; export { default as Device } from "./Device"; export { AlexaInterface, AlexaInterfaceType } from "./AlexaInterface"; +export type { SupportedMode } from "./AlexaInterface"; export { ActionMapping, AlexaActions } from "./ActionMapping"; export { DisplayCategory } from "./DisplayCategory"; export { AlexaStatusMessage, PowerController, EndpointHealth, TemperatureSensorScale, ThermostatMode } from "./AlexaStatusMessage"; diff --git a/test/alex2node.test.js b/test/alex2node.test.js index ecc1bb2..f05d3e4 100644 --- a/test/alex2node.test.js +++ b/test/alex2node.test.js @@ -8,7 +8,9 @@ const assert = require("node:assert/strict"); const net = require("node:net"); const aedes = require("aedes"); 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 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)", () => { 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 /# +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\): 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(), ""); +}); diff --git a/tsconfig.json b/tsconfig.json index a8fefa8..14a9474 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,7 @@ "module": "CommonJS", "moduleResolution": "node", "outDir": "./dist", + "declaration": true, "esModuleInterop": true, "strict": true, "skipLibCheck": true