import { randomUUID } from "crypto"; import { changeReport, deferredResponse, response } from "../messages/build.js"; import { StateBuilder } from "../messages/StateBuilder.js"; import { AlexaInterfaceType, TemperatureSensorScale } from "./enums.js"; // The 1.x helpers report every temperature in Celsius, whatever scale the device works in function celsius(scale, value) { return { value: scale === TemperatureSensorScale.FAHRENHEIT ? (value - 32) * (5 / 9) : value, scale: "CELSIUS" }; } /** * A Response, StateReport, DeferredResponse or ChangeReport as 1.x builds it: device.getStatusMessage() or * device.getChangeReport(), the add...Prop() calls, send(). The values are not checked. */ export class AlexaStatusMessage { constructor(correlationToken, rootTopic, endpointId, mqttClient, isResponse = false, isDeferred = false, changeCause = null) { this.correlationToken = correlationToken; this.rootTopic = rootTopic; this.endpointId = endpointId; this.mqttClient = mqttClient; this.isResponse = isResponse; this.isDeferred = isDeferred; this.changeCause = changeCause; // One id for the message, however often toJSON() is called this.messageId = randomUUID(); this.state = new StateBuilder({ target: changeCause ? "change" : "context" }); } addProperty(namespace, name, value, uncertaintyInMilliseconds = 0, instance) { this.state.setRaw(namespace, name, value, { instance, uncertaintyInMilliseconds }); return this; } /** ChangeReport: the add*Prop calls that follow describe what CHANGED (the default for a change report). */ changed() { this.state.changed(); return this; } /** ChangeReport: the add*Prop calls that follow describe the other, unchanged properties (context). */ unchanged() { this.state.unchanged(); return this; } /** True for a ChangeReport (Device.getChangeReport). */ isChangeReport() { return this.changeCause !== null; } /** * The message as it will be published (for tests and logging). A DeferredResponse has no context. Throws * MessageError for a ChangeReport without a changed property. */ toJSON() { const { endpointId, messageId, correlationToken } = this; if (this.changeCause) { return changeReport({ endpointId, messageId, cause: this.changeCause, changed: this.state.change, context: this.state.context }); } if (this.isDeferred) { return deferredResponse({ endpointId, messageId, correlationToken, estimatedDeferralInSeconds: this.estimatedDeferralInSeconds }); } const name = this.isResponse ? "Response" : "StateReport"; return response({ name, endpointId, messageId, correlationToken, context: this.state.context }); } addModeControllerProp(instance, value, uncertaintyInMs = 0) { return this.addProperty(AlexaInterfaceType.MODE_CONTROLLER, "mode", value, uncertaintyInMs, instance); } addThermostatModeProp(mode, uncertaintyInMs = 0) { return this.addProperty(AlexaInterfaceType.THERMOSTAT_CONTROLLER, "thermostatMode", mode, uncertaintyInMs); } addEstimatedDeferralTime(seconds) { if (this.isDeferred) { this.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) { return this.addProperty(AlexaInterfaceType.THERMOSTAT_CONTROLLER, name, celsius(scale, value), uncertaintyInMs); } /** Alexa.EndpointHealth connectivity: EndpointHealth.OK / UNREACHABLE (the plain strings "OK" / "UNREACHABLE" are accepted too). */ addHealthProp(health, uncertaintyInMs = 0) { return this.addProperty(AlexaInterfaceType.ENDPOINT_HEALTH, "connectivity", { value: health }, uncertaintyInMs); } addPowerControllerProp(power, uncertaintyInMs = 0) { return this.addProperty(AlexaInterfaceType.POWER_CONTROLLER, "powerState", power, uncertaintyInMs); } addTemperatureSensorProp(scale, value, uncertaintyInMs = 0) { return this.addProperty(AlexaInterfaceType.TEMPERATURE_SENSOR, "temperature", celsius(scale, value), uncertaintyInMs); } addBrightnessControllerProp(brightness, uncertaintyInMs = 0) { return this.addProperty(AlexaInterfaceType.BRIGHTNESS_CONTROLLER, "brightness", brightness, uncertaintyInMs); } addColorTemperatureControllerProp(colorTemp, uncertaintyInMs = 0) { return this.addProperty(AlexaInterfaceType.COLOR_TEMPERATURE_CONTROLLER, "colorTemperatureInKelvin", colorTemp, uncertaintyInMs); } addToggleControllerProp(state, instance, uncertaintyInMs = 0) { return this.addProperty(AlexaInterfaceType.TOGGLE_CONTROLLER, "toggleState", state, uncertaintyInMs, instance); } /** A property built by the caller. It goes to the context, also in a ChangeReport after changed(). */ addContextProp(prop) { this.state.add(prop, "context"); 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, or "" when nothing was published: the publish failed, or the message is a ChangeReport without a * changed property, which Alex2MQTT would drop. 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 topic = this.changeCause ? `${this.rootTopic}/changeReport` : `${this.rootTopic}/${this.endpointId}/${sendAsync ? "deferredResponse" : "alexaResponce"}`; // "alexaResponce" is how Alex2MQTT and Alex2ESP spell the topic return new Promise((resolve) => { const failed = (err) => { 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 }; let payload; try { payload = JSON.stringify(this.toJSON()); } catch (err) { return failed(err); } this.mqttClient.publish(topic, payload, (err) => (err ? failed(err) : resolve(topic))); }); } }