import { v4 as uuidv4 } from "uuid"; import { AlexaInterface, AlexaInterfaceType } from "./AlexaInterface"; import { MqttClient } from "mqtt"; export enum EndpointHealth { OK = "OK", UNREACHABLE = "UNREACHABLE", } export enum ThermostatMode { OFF = "OFF", HEAT = "HEAT", COOL = "COOL", AUTO = "AUTO", ECO = "ECO", CUSTOM = "CUSTOM", } export enum PowerController { ON = "ON", OFF = "OFF", } export enum TemperatureSensorScale { CELSIUS = "CELSIUS", FAHRENHEIT = "FAHRENHEIT", } interface DirectiveHeader { namespace: string; name: string; payloadVersion: string; messageId: string; correlationToken: string; } interface DirectiveEndpoint { endpointId: string; } 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 class AlexaStatusMessage { private context: { properties: ContextProperty[] } = { properties: [] }; /** ChangeReport only: the properties that changed (payload.change.properties); the rest go to context. */ private changeProps: ContextProperty[] = []; private changeCause: ChangeCause | null = null; private target: "context" | "change" = "context"; private event: { header: DirectiveHeader; endpoint: DirectiveEndpoint; payload: {}; }; private rootTopic: string; private endpointId: string; private mqttClient: MqttClient; private isDeferred: boolean; constructor( correlationToken: string, rootTopic: string, endpointId: string, mqttClient: MqttClient, isResponse: boolean = false, isDeferred: boolean = false, changeCause: ChangeCause | null = null ) { this.rootTopic = rootTopic; this.endpointId = endpointId; this.mqttClient = mqttClient; this.isDeferred = isDeferred; this.changeCause = changeCause; if (changeCause) this.target = "change"; this.event = { header: { namespace: "Alexa", name: changeCause ? "ChangeReport" : isDeferred ? "DeferredResponse" : isResponse ? "Response" : "StateReport", payloadVersion: "3", messageId: this.generateMessageId(), correlationToken, }, endpoint: { endpointId, }, payload: {}, }; } private generateMessageId(): string { return uuidv4(); } private getTimestamp(): string { return new Date().toISOString(); } private addProperty( namespace: AlexaInterfaceType, name: string, value: any, uncertaintyInMilliseconds = 0, instance?: string ): this { const prop: ContextProperty = { namespace, name, value, timeOfSample: this.getTimestamp(), uncertaintyInMilliseconds, }; if (instance) prop.instance = instance; (this.target === "change" ? this.changeProps : this.context.properties).push(prop); return this; } /** ChangeReport: the add*Prop calls that follow describe what CHANGED (the default for a change report). */ public changed(): this { this.target = "change"; return this; } /** ChangeReport: the add*Prop calls that follow describe the other, unchanged properties (context). */ public unchanged(): this { this.target = "context"; return this; } /** True for a ChangeReport (Device.getChangeReport). */ public isChangeReport(): boolean { return this.changeCause !== null; } /** The message as it will be published (for tests and logging). */ public toJSON(): { event: any; context: { properties: ContextProperty[] } | null } { if (this.changeCause) { return { event: { ...this.event, payload: { change: { cause: { type: this.changeCause }, properties: this.changeProps } } }, context: this.context, }; } return { context: this.isDeferred ? null : this.context, event: this.event }; } public addModeControllerProp(instance: string, value: string, uncertaintyInMs = 0): this { return this.addProperty( AlexaInterfaceType.MODE_CONTROLLER, "mode", value, uncertaintyInMs, instance ); } public addThermostatModeProp(mode: string, uncertaintyInMs = 0): this { return this.addProperty( AlexaInterfaceType.THERMOSTAT_CONTROLLER, "thermostatMode", mode, 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", 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( AlexaInterfaceType.ENDPOINT_HEALTH, "connectivity", { value: health }, uncertaintyInMs ); } public addPowerControllerProp( power: PowerController, uncertaintyInMs = 0 ): this { return this.addProperty( AlexaInterfaceType.POWER_CONTROLLER, "powerState", power, uncertaintyInMs ); } public addTemperatureSensorProp( scale: TemperatureSensorScale, value: number, uncertaintyInMs = 0 ): this { const tempValue = { value: scale === TemperatureSensorScale.FAHRENHEIT ? (value - 32) * (5 / 9) : value, scale: "CELSIUS", }; return this.addProperty( AlexaInterfaceType.TEMPERATURE_SENSOR, "temperature", tempValue, uncertaintyInMs ); } public addBrightnessControllerProp( brightness: number, uncertaintyInMs = 0 ): this { return this.addProperty( AlexaInterfaceType.BRIGHTNESS_CONTROLLER, "brightness", brightness, uncertaintyInMs ); } public addColorTemperatureControllerProp( colorTemp: number, uncertaintyInMs = 0 ): this { return this.addProperty( AlexaInterfaceType.COLOR_TEMPERATURE_CONTROLLER, "colorTemperatureInKelvin", colorTemp, uncertaintyInMs ); } public addToggleControllerProp( state: PowerController, instance: string, uncertaintyInMs = 0 ): this { return this.addProperty( AlexaInterfaceType.TOGGLE_CONTROLLER, "toggleState", state, uncertaintyInMs, instance ); } public addContextProp(prop: ContextProperty): this { this.context.properties.push(prop); return 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; rejects on a publish error (1.4.0 only logged). */ 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))); }); } }