From 61b032c8896dfeb4abb9153ed29d36b2fa893259 Mon Sep 17 00:00:00 2001 From: David <43281665+chaos511@users.noreply.github.com> Date: Sun, 11 May 2025 23:12:47 +0000 Subject: [PATCH] add support and examples for deferred state report responses --- dist/AlexaStatusMessage.js | 28 +++++-- dist/Device.js | 4 +- examples/ExamplePowerController.js | 2 +- examples/ExamplePowerControllerDeferred.js | 90 ++++++++++++++++++++++ examples/ExampleThermostat.js | 2 +- package.json | 2 +- src/AlexaStatusMessage.ts | 38 ++++++--- src/Device.ts | 6 +- 8 files changed, 147 insertions(+), 25 deletions(-) create mode 100644 examples/ExamplePowerControllerDeferred.js diff --git a/dist/AlexaStatusMessage.js b/dist/AlexaStatusMessage.js index a076673..0d8eeb3 100644 --- a/dist/AlexaStatusMessage.js +++ b/dist/AlexaStatusMessage.js @@ -28,15 +28,20 @@ var TemperatureSensorScale; TemperatureSensorScale["FAHRENHEIT"] = "FAHRENHEIT"; })(TemperatureSensorScale || (exports.TemperatureSensorScale = TemperatureSensorScale = {})); class AlexaStatusMessage { - constructor(correlationToken, rootTopic, endpointId, mqttClient, isResponse = false) { + constructor(correlationToken, rootTopic, endpointId, mqttClient, isResponse = false, isDeferred = false) { this.context = { properties: [] }; this.rootTopic = rootTopic; this.endpointId = endpointId; this.mqttClient = mqttClient; + this.isDeferred = isDeferred; this.event = { header: { namespace: "Alexa", - name: isResponse ? "Response" : "StateReport", + name: isDeferred + ? "DeferredResponse" + : isResponse + ? "Response" + : "StateReport", payloadVersion: "3", messageId: this.generateMessageId(), correlationToken, @@ -69,6 +74,17 @@ class AlexaStatusMessage { addThermostatModeProp(mode, uncertaintyInMs = 0) { return this.addProperty(AlexaInterface_1.AlexaInterfaceType.THERMOSTAT_CONTROLLER, "thermostatMode", mode, uncertaintyInMs); } + addEstimatedDeferralTime(seconds) { + if (this.isDeferred) { + this.event.payload = { + estimatedDeferralInSeconds: seconds, + }; + } + else { + console.warn("[AlexaStatusMessage.ts] Attempted to add estimated deferral time, but message is not marked as DeferredResponse."); + } + return this; + } addThermostatControllerProp(name, scale, value, uncertaintyInMs = 0) { const tempValue = { value: scale === TemperatureSensorScale.FAHRENHEIT @@ -106,19 +122,19 @@ class AlexaStatusMessage { this.context.properties.push(prop); return this; } - send() { + send(sendAsync) { const payload = { - context: this.context, + context: this.isDeferred ? null : 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}/${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); this.mqttClient.publish(topic, payloadStr, (err) => { if (err) { console.error("[AlexaStatusMessage] Failed to publish status message:", err); } else { - // console.log(`[AlexaStatusMessage] Sent message to ${topic}`); + console.log(`[AlexaStatusMessage] Sent message to ${topic}`); } }); } diff --git a/dist/Device.js b/dist/Device.js index 0a4ae97..94084cf 100644 --- a/dist/Device.js +++ b/dist/Device.js @@ -40,8 +40,8 @@ class Device extends events_1.EventEmitter { getDescription() { return this.description; } - getStatusMessage(correlationToken, isResponse = false) { - return new AlexaStatusMessage_1.AlexaStatusMessage(correlationToken, this.rootTopic, this.endpointId, this.mqttClient, isResponse); + getStatusMessage(correlationToken, isResponse = false, isDeferred = false) { + return new AlexaStatusMessage_1.AlexaStatusMessage(correlationToken, this.rootTopic, this.endpointId, this.mqttClient, isResponse, isDeferred); } setManufacturerName(name) { this.manufacturerName = name; diff --git a/examples/ExamplePowerController.js b/examples/ExamplePowerController.js index 5658a55..ae0a35b 100644 --- a/examples/ExamplePowerController.js +++ b/examples/ExamplePowerController.js @@ -38,7 +38,7 @@ console.log(bedroomLight.getName()); bedroomLight.on("ReportState", (payload) => { console.log("ReportState received!", payload); - const { correlationToken } = payload; + const { correlationToken } = payload.header; const status = bedroomLight.getStatusMessage(correlationToken); diff --git a/examples/ExamplePowerControllerDeferred.js b/examples/ExamplePowerControllerDeferred.js new file mode 100644 index 0000000..1661318 --- /dev/null +++ b/examples/ExamplePowerControllerDeferred.js @@ -0,0 +1,90 @@ +// Import required modules and types from the compiled TypeScript distribution (via dist/index.js) +const { + Alex2MQTT, + AlexaInterfaceType, + PowerController +} = 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 + +// 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 (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"); + +// Add the PowerController capability (for turning on/off the device) +deferredLight.addCapability(AlexaInterfaceType.POWER_CONTROLLER); + +// Log the device name to verify registration +console.log(deferredLight.getName()); + +/** + * Handle Alexa's ReportState directive. + * This occurs when Alexa queries the current state of the device (e.g., during routines or device status checks). + */ +deferredLight.on("ReportState", (payload) => { + console.log("ReportState received!", payload); + + const { correlationToken } = payload.header; + + // Step 1: Send a DeferredResponse after ~1 second + setTimeout(() => { + const deferred = deferredLight.getStatusMessage(correlationToken, false, true); // isDeferred = true + deferred.addEstimatedDeferralTime(20).send(); + console.log("Sent DeferredResponse"); + }, 2000); // simulate slight delay before deferred response + + // Step 2: Send actual StateReport after 10 seconds + setTimeout(() => { + const status = deferredLight.getStatusMessage(correlationToken,false,false); // normal StateReport + status + .addHealthProp("OK") + .addPowerControllerProp(outputState) + .send(true); + console.log("Sent actual StateReport with device status"); + }, 8000); +}); + + +/** + * Handle incoming control directives (e.g., TurnOn, TurnOff). + * These directives come from Alexa when a user issues a command. + */ +deferredLight.on("Event", (directive, interfaceType) => { + console.log("Event received", { directive, interfaceType }); + + // Ensure the interfaceType is either PowerController or other valid interfaces + if (interfaceType === AlexaInterfaceType.POWER_CONTROLLER) { + const name = directive.header.name; + const token = directive.header.correlationToken; + + // Update the internal state based on the command (TurnOn / TurnOff) + if (name === "TurnOn") { + outputState = PowerController.ON; + console.log("Turning ON the Deferred Light"); + } else if (name === "TurnOff") { + outputState = PowerController.OFF; + console.log("Turning OFF the Deferred Light"); + } + + // Respond to the directive with the updated device status + const status = deferredLight.getStatusMessage(token, true); + status + .addHealthProp("OK") + .addPowerControllerProp(outputState) + .send(); + } +}); diff --git a/examples/ExampleThermostat.js b/examples/ExampleThermostat.js index b4839e6..006858d 100644 --- a/examples/ExampleThermostat.js +++ b/examples/ExampleThermostat.js @@ -54,7 +54,7 @@ const deviceData = { thermostat.on("ReportState", (payload) => { // console.log("ReportState received!", payload); - const { correlationToken } = payload; + const { correlationToken } = payload.header; let status = thermostat.getStatusMessage(correlationToken); diff --git a/package.json b/package.json index db43f84..1274a8a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "alex2node", - "version": "1.1.0", + "version": "1.2.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/AlexaStatusMessage.ts b/src/AlexaStatusMessage.ts index fefa365..0cdb05a 100644 --- a/src/AlexaStatusMessage.ts +++ b/src/AlexaStatusMessage.ts @@ -57,22 +57,28 @@ export class AlexaStatusMessage { private rootTopic: string; private endpointId: string; private mqttClient: MqttClient; + private isDeferred: boolean; constructor( correlationToken: string, rootTopic: string, endpointId: string, mqttClient: MqttClient, - isResponse: boolean = false + isResponse: boolean = false, + isDeferred: boolean = false ) { this.rootTopic = rootTopic; this.endpointId = endpointId; this.mqttClient = mqttClient; - + this.isDeferred = isDeferred; this.event = { header: { namespace: "Alexa", - name: isResponse ? "Response" : "StateReport", + name: isDeferred + ? "DeferredResponse" + : isResponse + ? "Response" + : "StateReport", payloadVersion: "3", messageId: this.generateMessageId(), correlationToken, @@ -91,7 +97,6 @@ export class AlexaStatusMessage { private getTimestamp(): string { return new Date().toISOString(); } - private addProperty( namespace: AlexaInterfaceType, name: string, @@ -110,10 +115,7 @@ export class AlexaStatusMessage { this.context.properties.push(prop); return this; } - public addThermostatModeProp( - mode: string, - uncertaintyInMs = 0 - ): this { + public addThermostatModeProp(mode: string, uncertaintyInMs = 0): this { return this.addProperty( AlexaInterfaceType.THERMOSTAT_CONTROLLER, "thermostatMode", @@ -121,6 +123,18 @@ export class AlexaStatusMessage { uncertaintyInMs ); } + public addEstimatedDeferralTime(seconds: number): this { + if (this.isDeferred) { + this.event.payload = { + estimatedDeferralInSeconds: seconds, + }; + } else { + console.warn( + "[AlexaStatusMessage.ts] Attempted to add estimated deferral time, but message is not marked as DeferredResponse." + ); + } + return this; + } public addThermostatControllerProp( name: "lowerSetpoint" | "upperSetpoint" | "targetSetpoint", @@ -227,12 +241,12 @@ export class AlexaStatusMessage { return this; } - public send(): void { + public send(sendAsync: boolean): void { const payload = { - context: this.context, + context: this.isDeferred ? null : 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}/${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); this.mqttClient.publish(topic, payloadStr, (err) => { @@ -242,7 +256,7 @@ export class AlexaStatusMessage { err ); } else { - // console.log(`[AlexaStatusMessage] Sent message to ${topic}`); + console.log(`[AlexaStatusMessage] Sent message to ${topic}`); } }); } diff --git a/src/Device.ts b/src/Device.ts index 82d294a..cdcc22e 100644 --- a/src/Device.ts +++ b/src/Device.ts @@ -51,14 +51,16 @@ class Device extends EventEmitter { } getStatusMessage( correlationToken: string, - isResponse = false + isResponse = false, + isDeferred = false, ): AlexaStatusMessage { return new AlexaStatusMessage( correlationToken, this.rootTopic, this.endpointId, this.mqttClient, - isResponse + isResponse, + isDeferred ); } setManufacturerName(name: string): void {