diff --git a/dist/Alex2Node.js b/dist/Alex2Node.js index 7ef4af6..d44c7ce 100644 --- a/dist/Alex2Node.js +++ b/dist/Alex2Node.js @@ -56,7 +56,7 @@ class Alex2MQTT extends events_1.EventEmitter { } else if (this.debugLogging) { console.log(`[Alex2Node.ts] Discovery payloads published to ${topic + "_r"}`); - console.log(deviceArray); + console.log(JSON.stringify(deviceArray, null, 2)); } }); } @@ -102,14 +102,19 @@ class Alex2MQTT extends events_1.EventEmitter { this.emit("error", err); }); } - registerDevice(name, endpointId) { + registerDevice(name, endpointId, displayCategory) { if (!this.client) { throw new Error("Must call connect before creating devices"); } if (this.debugLogging) { console.log(`[Alex2Node.ts] Creating new device with endpoint: ${endpointId}`); } - const device = new Device_1.default(this.client, this.rootTopic, name, endpointId); + const normalizedCategory = displayCategory === null + ? null + : Array.isArray(displayCategory) + ? displayCategory + : [displayCategory]; + const device = new Device_1.default(this.client, this.rootTopic, name, endpointId, normalizedCategory); this.devices.push(device); return device; } diff --git a/dist/AlexaInterface.js b/dist/AlexaInterface.js index 452ed28..826263c 100644 --- a/dist/AlexaInterface.js +++ b/dist/AlexaInterface.js @@ -164,11 +164,12 @@ class AlexaInterface { case AlexaInterfaceType.SCENE_CONTROLLER: case AlexaInterfaceType.SEEK_CONTROLLER: case AlexaInterfaceType.TEMPERATURE_SENSOR: - case AlexaInterfaceType.THERMOSTAT_CONTROLLER: case AlexaInterfaceType.TOGGLE_CONTROLLER: return "3"; case AlexaInterfaceType.LAUNCHER: return "1.1"; + case AlexaInterfaceType.THERMOSTAT_CONTROLLER: + return "3.2"; case AlexaInterfaceType.ENDPOINT_HEALTH: return "3.3"; default: @@ -197,6 +198,13 @@ class AlexaInterface { return ["toggleState"]; case AlexaInterfaceType.TEMPERATURE_SENSOR: return ["temperature"]; + case AlexaInterfaceType.THERMOSTAT_CONTROLLER: + return [ + "lowerSetpoint", + "upperSetpoint", + "thermostatMode", + "adaptiveRecoveryStatus", + ]; case AlexaInterfaceType.APPLICATION_STATE_REPORTER: case AlexaInterfaceType.AUDIO_PLAY_QUEUE: case AlexaInterfaceType.AUTHORIZATION_CONTROLLER: @@ -238,7 +246,6 @@ class AlexaInterface { case AlexaInterfaceType.SMART_VISION_SNAPSHOT_PROVIDER: case AlexaInterfaceType.SPEAKER: case AlexaInterfaceType.STEP_SPEAKER: - case AlexaInterfaceType.THERMOSTAT_CONTROLLER: case AlexaInterfaceType.THERMOSTAT_CONTROLLER_CONFIGURATION: case AlexaInterfaceType.THERMOSTAT_CONTROLLER_HVAC_COMPONENTS: case AlexaInterfaceType.THERMOSTAT_CONTROLLER_SCHEDULE: @@ -261,9 +268,15 @@ class AlexaInterface { properties: { retrievable: this.retrievable, proactivelyReported: this.proactivelyReported, - supported: [{ name: this.getProps()[0] }], + supported: this.getProps().map(name => ({ name })), }, }; + if (this.type == AlexaInterfaceType.THERMOSTAT_CONTROLLER) { + doc.configuration = { + "supportedModes": ["HEAT", "COOL", "AUTO", "OFF"], + "supportsScheduling": false + }; + } if (this.instance) { doc.instance = this.instance; } diff --git a/dist/AlexaStatusMessage.js b/dist/AlexaStatusMessage.js index 6bfd1fb..a076673 100644 --- a/dist/AlexaStatusMessage.js +++ b/dist/AlexaStatusMessage.js @@ -1,6 +1,6 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -exports.AlexaStatusMessage = exports.TemperatureSensorScale = exports.PowerController = exports.EndpointHealth = void 0; +exports.AlexaStatusMessage = exports.TemperatureSensorScale = exports.PowerController = exports.ThermostatMode = exports.EndpointHealth = void 0; const uuid_1 = require("uuid"); const AlexaInterface_1 = require("./AlexaInterface"); var EndpointHealth; @@ -8,6 +8,15 @@ var EndpointHealth; EndpointHealth["OK"] = "OK"; EndpointHealth["UNREACHABLE"] = "UNREACHABLE"; })(EndpointHealth || (exports.EndpointHealth = EndpointHealth = {})); +var ThermostatMode; +(function (ThermostatMode) { + ThermostatMode["OFF"] = "OFF"; + ThermostatMode["HEAT"] = "HEAT"; + ThermostatMode["COOL"] = "COOL"; + ThermostatMode["AUTO"] = "AUTO"; + ThermostatMode["ECO"] = "ECO"; + ThermostatMode["CUSTOM"] = "CUSTOM"; +})(ThermostatMode || (exports.ThermostatMode = ThermostatMode = {})); var PowerController; (function (PowerController) { PowerController["ON"] = "ON"; @@ -57,6 +66,18 @@ class AlexaStatusMessage { this.context.properties.push(prop); return this; } + addThermostatModeProp(mode, uncertaintyInMs = 0) { + return this.addProperty(AlexaInterface_1.AlexaInterfaceType.THERMOSTAT_CONTROLLER, "thermostatMode", mode, uncertaintyInMs); + } + addThermostatControllerProp(name, scale, value, uncertaintyInMs = 0) { + const tempValue = { + value: scale === TemperatureSensorScale.FAHRENHEIT + ? (value - 32) * (5 / 9) + : value, + scale: "CELSIUS", + }; + return this.addProperty(AlexaInterface_1.AlexaInterfaceType.THERMOSTAT_CONTROLLER, name, tempValue, uncertaintyInMs); + } addHealthProp(health, uncertaintyInMs = 0) { return this.addProperty(AlexaInterface_1.AlexaInterfaceType.ENDPOINT_HEALTH, "connectivity", { value: health }, uncertaintyInMs); } diff --git a/dist/Device.js b/dist/Device.js index 7decf86..0a4ae97 100644 --- a/dist/Device.js +++ b/dist/Device.js @@ -5,7 +5,7 @@ const DisplayCategory_1 = require("./DisplayCategory"); const events_1 = require("events"); const AlexaStatusMessage_1 = require("./AlexaStatusMessage"); class Device extends events_1.EventEmitter { - constructor(mqttClient, rootTopic, name, endpointId, displayCategory = DisplayCategory_1.DisplayCategory.LIGHT, description = "Alexa to Node.js bridge", manufacturerName = "Alex2Node", manufacturer = "Alex2Node", model = "Alex2Node_v1.0.0") { + constructor(mqttClient, rootTopic, name, endpointId, displayCategory, description = "Alexa to Node.js bridge", manufacturerName = "Alex2Node", manufacturer = "Alex2Node", model = "Alex2Node_v1.0.0") { super(); this.mqttClient = mqttClient; this.rootTopic = rootTopic; @@ -29,10 +29,10 @@ class Device extends events_1.EventEmitter { return this.endpointId; } setDisplayCategory(category) { - this.displayCategory = category; + this.displayCategory = Array.isArray(category) ? category : [category]; } getDisplayCategory() { - return this.displayCategory; + return this.displayCategory || [DisplayCategory_1.DisplayCategory.LIGHT]; } setDescription(description) { this.description = description; @@ -82,7 +82,7 @@ class Device extends events_1.EventEmitter { friendlyName: this.name, description: this.description, manufacturerName: this.manufacturerName, - displayCategories: [this.displayCategory], + displayCategories: this.getDisplayCategory(), additionalAttributes: { manufacturer: this.manufacturer, model: this.model, diff --git a/dist/index.js b/dist/index.js index 77b248c..6a32109 100644 --- a/dist/index.js +++ b/dist/index.js @@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); -exports.PowerController = exports.AlexaActions = exports.ActionMapping = exports.AlexaInterfaceType = exports.Alex2MQTT = void 0; +exports.ThermostatMode = exports.TemperatureSensorScale = exports.EndpointHealth = exports.PowerController = exports.DisplayCategory = exports.AlexaActions = exports.ActionMapping = exports.AlexaInterfaceType = exports.Alex2MQTT = void 0; var Alex2Node_1 = require("./Alex2Node"); Object.defineProperty(exports, "Alex2MQTT", { enumerable: true, get: function () { return __importDefault(Alex2Node_1).default; } }); var AlexaInterface_1 = require("./AlexaInterface"); @@ -11,5 +11,10 @@ Object.defineProperty(exports, "AlexaInterfaceType", { enumerable: true, get: fu var ActionMapping_1 = require("./ActionMapping"); Object.defineProperty(exports, "ActionMapping", { enumerable: true, get: function () { return ActionMapping_1.ActionMapping; } }); Object.defineProperty(exports, "AlexaActions", { enumerable: true, get: function () { return ActionMapping_1.AlexaActions; } }); +var DisplayCategory_1 = require("./DisplayCategory"); +Object.defineProperty(exports, "DisplayCategory", { enumerable: true, get: function () { return DisplayCategory_1.DisplayCategory; } }); var AlexaStatusMessage_1 = require("./AlexaStatusMessage"); Object.defineProperty(exports, "PowerController", { enumerable: true, get: function () { return AlexaStatusMessage_1.PowerController; } }); +Object.defineProperty(exports, "EndpointHealth", { enumerable: true, get: function () { return AlexaStatusMessage_1.EndpointHealth; } }); +Object.defineProperty(exports, "TemperatureSensorScale", { enumerable: true, get: function () { return AlexaStatusMessage_1.TemperatureSensorScale; } }); +Object.defineProperty(exports, "ThermostatMode", { enumerable: true, get: function () { return AlexaStatusMessage_1.ThermostatMode; } }); diff --git a/examples/ExampleThermostat.js b/examples/ExampleThermostat.js new file mode 100644 index 0000000..b4839e6 --- /dev/null +++ b/examples/ExampleThermostat.js @@ -0,0 +1,160 @@ +// Import required modules and types from the compiled TypeScript distribution (via dist/index.js) +const { + Alex2MQTT, + AlexaInterfaceType, + PowerController, + TemperatureSensorScale, + DisplayCategory, + ThermostatMode, +} = require("../dist/index.js"); + +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; +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 + +// Define the device name (Thermostat Test) +const deviceName = "Thermostat Test"; + +// Register the thermostat device with a unique endpoint ID +const thermostat = alex2NodeClient.registerDevice( + deviceName, + "thermostatEndpoint1", + [DisplayCategory.TEMPERATURE_SENSOR, DisplayCategory.THERMOSTAT] +); + +// Add thermostat-related capabilities +thermostat.addCapability(AlexaInterfaceType.THERMOSTAT_CONTROLLER); // Allows Alexa to control thermostat modes and setpoints +thermostat.addCapability(AlexaInterfaceType.TEMPERATURE_SENSOR); // Allows Alexa to read current temperature from the sensor + +// Log the device name to verify registration +console.log(thermostat.getName()); + +/** + * Handle Alexa's ReportState directive. + * This occurs when Alexa queries the current state of the thermostat (e.g., during routines or device status checks). + */ + +const deviceData = { + lowerSetpoint: 20, + upperSetpoint: 20, + targetSetpoint: 20, + current: 20, + thermostatMode: ThermostatMode.AUTO, +}; +thermostat.on("ReportState", (payload) => { + // console.log("ReportState received!", payload); + + const { correlationToken } = payload; + + let status = thermostat.getStatusMessage(correlationToken); + + status + .addHealthProp("OK") // Device is healthy and reachable + .addTemperatureSensorProp( + TemperatureSensorScale.CELSIUS, + deviceData.current + ) // Report current temperature + .addThermostatModeProp(deviceData.thermostatMode); // Report current thermostat mode + + if (deviceData.thermostatMode == "AUTO") { + status = status + .addThermostatControllerProp( + "lowerSetpoint", + TemperatureSensorScale.CELSIUS, + deviceData.lowerSetpoint + ) // Lower bound of temperature range + .addThermostatControllerProp( + "upperSetpoint", + TemperatureSensorScale.CELSIUS, + deviceData.upperSetpoint + ); // Upper bound of temperature range + } else { + status = status.addThermostatControllerProp( + "targetSetpoint", + TemperatureSensorScale.CELSIUS, + deviceData.targetSetpoint + ); // Lower bound of temperature range + } + status.send(); // Send the full state report back to Alexa +}); + +/** + * Handle incoming control directives for the thermostat. + * These directives come from Alexa when a user issues a command (e.g., to change temperature or mode). + */ +thermostat.on("Event", (directive, interfaceType) => { + console.log("Event received", { directive, interfaceType }); + if (interfaceType === AlexaInterfaceType.THERMOSTAT_CONTROLLER) { + const name = directive.header.name; + const token = directive.header.correlationToken; + + if (name == "SetTargetTemperature") { + console.log({ name: directive.payload }); + if (directive.payload.upperSetpoint) { + deviceData.upperSetpoint = praseTempValue( + directive.payload.upperSetpoint + ); + } + if (directive.payload.lowerSetpoint) { + deviceData.lowerSetpoint = praseTempValue( + directive.payload.lowerSetpoint + ); + } + if (directive.payload.targetSetpoint) { + deviceData.targetSetpoint = praseTempValue( + directive.payload.targetSetpoint + ); + } + } + if (name == "SetThermostatMode") { + console.log(directive.payload.thermostatMode); + if (directive.payload.thermostatMode) { + deviceData.thermostatMode = directive.payload.thermostatMode.value; + } + } + + let status = thermostat.getStatusMessage(token, true); + + status + .addHealthProp("OK") // Device is healthy and reachable + .addTemperatureSensorProp( + TemperatureSensorScale.CELSIUS, + deviceData.current + ) // Report current temperature + .addThermostatModeProp(deviceData.thermostatMode); // Report current thermostat mode + if (deviceData.thermostatMode == "AUTO") { + status = status + .addThermostatControllerProp( + "lowerSetpoint", + TemperatureSensorScale.CELSIUS, + deviceData.lowerSetpoint + ) // Lower bound of temperature range + .addThermostatControllerProp( + "upperSetpoint", + TemperatureSensorScale.CELSIUS, + deviceData.upperSetpoint + ); // Upper bound of temperature range + } else { + status = status.addThermostatControllerProp( + "targetSetpoint", + TemperatureSensorScale.CELSIUS, + deviceData.targetSetpoint + ); // Lower bound of temperature range + } + status.send(); // Send the full state report back to Alexa + } +}); + +function praseTempValue(data) { + return data.value; +} diff --git a/package.json b/package.json index 7a7ea67..db43f84 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "alex2node", - "version": "1.0.1", + "version": "1.1.0", "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/src/Alex2Node.ts b/src/Alex2Node.ts index d0682ca..a9f3c1d 100644 --- a/src/Alex2Node.ts +++ b/src/Alex2Node.ts @@ -1,6 +1,7 @@ import mqtt, { MqttClient } from "mqtt"; import Device from "./Device"; import { EventEmitter } from "events"; +import { DisplayCategory } from "./DisplayCategory"; class Alex2MQTT extends EventEmitter { private client: MqttClient | null = null; @@ -15,7 +16,6 @@ class Alex2MQTT extends EventEmitter { ) { super(); // Initialize EventEmitter } - connect(): void { const options = { @@ -69,7 +69,7 @@ class Alex2MQTT extends EventEmitter { topic + "_r" }` ); - console.log(deviceArray); + console.log(JSON.stringify(deviceArray, null, 2)); } } ); @@ -106,8 +106,8 @@ class Alex2MQTT extends EventEmitter { ); } device.emit("ReportState", payload); - }else{ - device.emit("Event", payload,payload.header.namespace); + } else { + device.emit("Event", payload, payload.header.namespace); } } }); @@ -120,7 +120,11 @@ class Alex2MQTT extends EventEmitter { }); } - registerDevice(name: string, endpointId: string): Device { + registerDevice( + name: string, + endpointId: string, + displayCategory: DisplayCategory | DisplayCategory[] | null + ): Device { if (!this.client) { throw new Error("Must call connect before creating devices"); } @@ -129,7 +133,20 @@ class Alex2MQTT extends EventEmitter { `[Alex2Node.ts] Creating new device with endpoint: ${endpointId}` ); } - const device = new Device(this.client!, this.rootTopic, name, endpointId); + const normalizedCategory = + displayCategory === null + ? null + : Array.isArray(displayCategory) + ? displayCategory + : [displayCategory]; + + const device = new Device( + this.client!, + this.rootTopic, + name, + endpointId, + normalizedCategory + ); this.devices.push(device); return device; } diff --git a/src/AlexaInterface.ts b/src/AlexaInterface.ts index 9cdc58d..1f9bcf3 100644 --- a/src/AlexaInterface.ts +++ b/src/AlexaInterface.ts @@ -176,11 +176,12 @@ export class AlexaInterface { case AlexaInterfaceType.SCENE_CONTROLLER: case AlexaInterfaceType.SEEK_CONTROLLER: case AlexaInterfaceType.TEMPERATURE_SENSOR: - case AlexaInterfaceType.THERMOSTAT_CONTROLLER: case AlexaInterfaceType.TOGGLE_CONTROLLER: return "3"; case AlexaInterfaceType.LAUNCHER: return "1.1"; + case AlexaInterfaceType.THERMOSTAT_CONTROLLER: + return "3.2"; case AlexaInterfaceType.ENDPOINT_HEALTH: return "3.3"; default: @@ -210,7 +211,13 @@ export class AlexaInterface { return ["toggleState"]; case AlexaInterfaceType.TEMPERATURE_SENSOR: return ["temperature"]; - + case AlexaInterfaceType.THERMOSTAT_CONTROLLER: + return [ + "lowerSetpoint", + "upperSetpoint", + "thermostatMode", + "adaptiveRecoveryStatus", + ]; case AlexaInterfaceType.APPLICATION_STATE_REPORTER: case AlexaInterfaceType.AUDIO_PLAY_QUEUE: case AlexaInterfaceType.AUTHORIZATION_CONTROLLER: @@ -252,7 +259,6 @@ export class AlexaInterface { case AlexaInterfaceType.SMART_VISION_SNAPSHOT_PROVIDER: case AlexaInterfaceType.SPEAKER: case AlexaInterfaceType.STEP_SPEAKER: - case AlexaInterfaceType.THERMOSTAT_CONTROLLER: case AlexaInterfaceType.THERMOSTAT_CONTROLLER_CONFIGURATION: case AlexaInterfaceType.THERMOSTAT_CONTROLLER_HVAC_COMPONENTS: case AlexaInterfaceType.THERMOSTAT_CONTROLLER_SCHEDULE: @@ -277,9 +283,15 @@ export class AlexaInterface { properties: { retrievable: this.retrievable, proactivelyReported: this.proactivelyReported, - supported: [{ name: this.getProps()[0] }], + supported: this.getProps().map(name => ({ name })), }, }; + if(this.type==AlexaInterfaceType.THERMOSTAT_CONTROLLER){ + doc.configuration={ + "supportedModes": ["HEAT", "COOL", "AUTO", "OFF"], + "supportsScheduling": false + } + } if (this.instance) { doc.instance = this.instance; } diff --git a/src/AlexaStatusMessage.ts b/src/AlexaStatusMessage.ts index e8f4f38..fefa365 100644 --- a/src/AlexaStatusMessage.ts +++ b/src/AlexaStatusMessage.ts @@ -7,6 +7,15 @@ export enum EndpointHealth { UNREACHABLE = "UNREACHABLE", } +export enum ThermostatMode { + OFF = "OFF", + HEAT = "HEAT", + COOL = "COOL", + AUTO = "AUTO", + ECO = "ECO", + CUSTOM = "CUSTOM", +} + export enum PowerController { ON = "ON", OFF = "OFF", @@ -101,6 +110,38 @@ export class AlexaStatusMessage { this.context.properties.push(prop); return this; } + public addThermostatModeProp( + mode: string, + uncertaintyInMs = 0 + ): this { + return this.addProperty( + AlexaInterfaceType.THERMOSTAT_CONTROLLER, + "thermostatMode", + mode, + uncertaintyInMs + ); + } + + public addThermostatControllerProp( + name: "lowerSetpoint" | "upperSetpoint" | "targetSetpoint", + scale: TemperatureSensorScale, + value: number, + uncertaintyInMs = 0 + ): this { + const tempValue = { + value: + scale === TemperatureSensorScale.FAHRENHEIT + ? (value - 32) * (5 / 9) + : value, + scale: "CELSIUS", + }; + return this.addProperty( + AlexaInterfaceType.THERMOSTAT_CONTROLLER, + name, + tempValue, + uncertaintyInMs + ); + } public addHealthProp(health: EndpointHealth, uncertaintyInMs = 0): this { return this.addProperty( @@ -191,7 +232,7 @@ export class AlexaStatusMessage { context: this.context, event: this.event, }; - const topic = `${this.rootTopic}/${this.endpointId}/alexaResponce`;//Yes this should be response but it is incorrect in both Alex2MQTT and Alex2ESP so for consistency is is wrong here too + const topic = `${this.rootTopic}/${this.endpointId}/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); this.mqttClient.publish(topic, payloadStr, (err) => { diff --git a/src/Device.ts b/src/Device.ts index 80fa436..82d294a 100644 --- a/src/Device.ts +++ b/src/Device.ts @@ -13,7 +13,7 @@ class Device extends EventEmitter { private rootTopic: string, public name: string, public endpointId: string, - public displayCategory: DisplayCategory = DisplayCategory.LIGHT, + public displayCategory: Array | null, public description: string = "Alexa to Node.js bridge", public manufacturerName: string = "Alex2Node", public manufacturer: string = "Alex2Node", @@ -34,12 +34,12 @@ class Device extends EventEmitter { return this.endpointId; } - setDisplayCategory(category: DisplayCategory): void { - this.displayCategory = category; + setDisplayCategory(category: DisplayCategory | DisplayCategory[]): void { + this.displayCategory = Array.isArray(category) ? category : [category]; } - - getDisplayCategory(): DisplayCategory { - return this.displayCategory; + + getDisplayCategory(): Array { + return this.displayCategory ||[DisplayCategory.LIGHT]; } setDescription(description: string): void { @@ -111,7 +111,7 @@ class Device extends EventEmitter { friendlyName: this.name, description: this.description, manufacturerName: this.manufacturerName, - displayCategories: [this.displayCategory], + displayCategories: this.getDisplayCategory(), additionalAttributes: { manufacturer: this.manufacturer, model: this.model, diff --git a/src/index.ts b/src/index.ts index 77b9e07..4dde791 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ export { default as Alex2MQTT } from './Alex2Node'; export { AlexaInterfaceType } from './AlexaInterface'; export { ActionMapping, AlexaActions } from './ActionMapping'; -export { PowerController } from './AlexaStatusMessage'; +export {DisplayCategory} from "./DisplayCategory"; +export { PowerController,EndpointHealth,TemperatureSensorScale,ThermostatMode } from './AlexaStatusMessage';