Add full support and examples for thermostat

This commit is contained in:
David 2025-05-10 12:21:51 +00:00
parent 7f051dfdcb
commit f4d07b589c
12 changed files with 307 additions and 32 deletions

11
dist/Alex2Node.js vendored
View file

@ -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;
}

View file

@ -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;
}

View file

@ -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);
}

8
dist/Device.js vendored
View file

@ -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,

7
dist/index.js vendored
View file

@ -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; } });

View file

@ -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;
}

View file

@ -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",

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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) => {

View file

@ -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<DisplayCategory> | 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<DisplayCategory> {
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,

View file

@ -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';