diff --git a/dist/cjs/AlexaErrorResponse.js b/dist/cjs/AlexaErrorResponse.js deleted file mode 100644 index c089de5..0000000 --- a/dist/cjs/AlexaErrorResponse.js +++ /dev/null @@ -1,129 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.AlexaErrorResponse = exports.AlexaErrorType = void 0; -const crypto_1 = require("crypto"); -var AlexaErrorType; -(function (AlexaErrorType) { - AlexaErrorType["ALREADY_IN_OPERATION"] = "ALREADY_IN_OPERATION"; - AlexaErrorType["AUTHORIZATION_REQUIRED"] = "AUTHORIZATION_REQUIRED"; - AlexaErrorType["BRIDGE_UNREACHABLE"] = "BRIDGE_UNREACHABLE"; - AlexaErrorType["BYPASS_NEEDED"] = "BYPASS_NEEDED"; - AlexaErrorType["CLOUD_CONTROL_DISABLED"] = "CLOUD_CONTROL_DISABLED"; - AlexaErrorType["CHILD_LOCK"] = "CHILD_LOCK"; - AlexaErrorType["CONFIGURATION_UPDATE_NOT_ALLOWED"] = "CONFIGURATION_UPDATE_NOT_ALLOWED"; - AlexaErrorType["COOK_DURATION_TOO_LONG"] = "COOK_DURATION_TOO_LONG"; - AlexaErrorType["COOLING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE"] = "COOLING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE"; - AlexaErrorType["COOLING_STAGES_EXCEEDS_LIMIT"] = "COOLING_STAGES_EXCEEDS_LIMIT"; - AlexaErrorType["DATA_DELETION_NOT_SUPPORTED"] = "DATA_DELETION_NOT_SUPPORTED"; - AlexaErrorType["DATA_RETRIEVAL_NOT_SUPPORTED"] = "DATA_RETRIEVAL_NOT_SUPPORTED"; - AlexaErrorType["DEVICE_STUCK"] = "DEVICE_STUCK"; - AlexaErrorType["DISABLED_BY_USER"] = "DISABLED_BY_USER"; - AlexaErrorType["DO_NOT_DISTURB_MODE"] = "DO_NOT_DISTURB_MODE"; - AlexaErrorType["DOOR_CLOSED_TOO_LONG"] = "DOOR_CLOSED_TOO_LONG"; - AlexaErrorType["DOOR_OPEN"] = "DOOR_OPEN"; - AlexaErrorType["DUAL_SETPOINTS_UNSUPPORTED"] = "DUAL_SETPOINTS_UNSUPPORTED"; - AlexaErrorType["ENDPOINT_BUSY"] = "ENDPOINT_BUSY"; - AlexaErrorType["ENDPOINT_CONTROL_UNAVAILABLE"] = "ENDPOINT_CONTROL_UNAVAILABLE"; - AlexaErrorType["ENDPOINT_LOW_POWER"] = "ENDPOINT_LOW_POWER"; - AlexaErrorType["ENDPOINT_UNREACHABLE"] = "ENDPOINT_UNREACHABLE"; - AlexaErrorType["EXCEEDED_PIN_ATTEMPTS"] = "EXCEEDED_PIN_ATTEMPTS"; - AlexaErrorType["EXPIRED_AUTHORIZATION_CREDENTIAL"] = "EXPIRED_AUTHORIZATION_CREDENTIAL"; - AlexaErrorType["FAILED_TO_BOOTSTRAP_COMMISSIONING_PROCESS"] = "FAILED_TO_BOOTSTRAP_COMMISSIONING_PROCESS"; - AlexaErrorType["FIRMWARE_OUT_OF_DATE"] = "FIRMWARE_OUT_OF_DATE"; - AlexaErrorType["HARDWARE_MALFUNCTION"] = "HARDWARE_MALFUNCTION"; - AlexaErrorType["HEATING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE"] = "HEATING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE"; - AlexaErrorType["HEATING_STAGES_EXCEEDS_LIMIT"] = "HEATING_STAGES_EXCEEDS_LIMIT"; - AlexaErrorType["INSUFFICIENT_PERMISSIONS"] = "INSUFFICIENT_PERMISSIONS"; - AlexaErrorType["INSUFFICIENT_RESOURCE"] = "INSUFFICIENT_RESOURCE"; - AlexaErrorType["INSUFFICIENT_SPACE"] = "INSUFFICIENT_SPACE"; - AlexaErrorType["INTERNAL_ERROR"] = "INTERNAL_ERROR"; - AlexaErrorType["INVALID_AUTHORIZATION_CREDENTIAL"] = "INVALID_AUTHORIZATION_CREDENTIAL"; - AlexaErrorType["INVALID_AUXILIARY_HEATING_SYSTEM_TYPE"] = "INVALID_AUXILIARY_HEATING_SYSTEM_TYPE"; - AlexaErrorType["INVALID_DIRECTIVE"] = "INVALID_DIRECTIVE"; - AlexaErrorType["INVALID_SYSTEM_TYPE"] = "INVALID_SYSTEM_TYPE"; - AlexaErrorType["INVALID_TARGET_STATE"] = "INVALID_TARGET_STATE"; - AlexaErrorType["INVALID_TEMPERATURE_SCALE"] = "INVALID_TEMPERATURE_SCALE"; - AlexaErrorType["INVALID_TERMINAL_CONNECTION"] = "INVALID_TERMINAL_CONNECTION"; - AlexaErrorType["INVALID_VALUE"] = "INVALID_VALUE"; - AlexaErrorType["MAINTENANCE_REQUIRED"] = "MAINTENANCE_REQUIRED"; - AlexaErrorType["MAX_COMMISSIONING_LIMIT_REACHED"] = "MAX_COMMISSIONING_LIMIT_REACHED"; - AlexaErrorType["MISSING_SETUP_INFORMATION"] = "MISSING_SETUP_INFORMATION"; - AlexaErrorType["NO_SUCH_ENDPOINT"] = "NO_SUCH_ENDPOINT"; - AlexaErrorType["NOT_CALIBRATED"] = "NOT_CALIBRATED"; - AlexaErrorType["NOT_IN_OPERATION"] = "NOT_IN_OPERATION"; - AlexaErrorType["NOT_READY"] = "NOT_READY"; - AlexaErrorType["NOT_SUPPORTED_IN_CURRENT_MODE"] = "NOT_SUPPORTED_IN_CURRENT_MODE"; - AlexaErrorType["NOT_SUPPORTED_WITH_CURRENT_BATTERY_CHARGE_STATE"] = "NOT_SUPPORTED_WITH_CURRENT_BATTERY_CHARGE_STATE"; - AlexaErrorType["OBSTACLE_DETECTED"] = "OBSTACLE_DETECTED"; - AlexaErrorType["PARTNER_APPLICATION_REDIRECTION"] = "PARTNER_APPLICATION_REDIRECTION"; - AlexaErrorType["PIN_SETUP_REQUIRED"] = "PIN_SETUP_REQUIRED"; - AlexaErrorType["POWER_LEVEL_NOT_SUPPORTED"] = "POWER_LEVEL_NOT_SUPPORTED"; - AlexaErrorType["PREHEAT_REQUIRED"] = "PREHEAT_REQUIRED"; - AlexaErrorType["PROBE_REQUIRED"] = "PROBE_REQUIRED"; - AlexaErrorType["RATE_LIMIT_EXCEEDED"] = "RATE_LIMIT_EXCEEDED"; - AlexaErrorType["REMOTE_START_NOT_SUPPORTED"] = "REMOTE_START_NOT_SUPPORTED"; - AlexaErrorType["REMOVE_PROBE"] = "REMOVE_PROBE"; - AlexaErrorType["REMOTE_START_DISABLED"] = "REMOTE_START_DISABLED"; - AlexaErrorType["REQUESTED_SETPOINTS_TOO_CLOSE"] = "REQUESTED_SETPOINTS_TOO_CLOSE"; - AlexaErrorType["SAFETY_BEAM_BREACHED"] = "SAFETY_BEAM_BREACHED"; - AlexaErrorType["SUBSCRIPTION_REQUIRED"] = "SUBSCRIPTION_REQUIRED"; - AlexaErrorType["TEMPERATURE_VALUE_OUT_OF_RANGE"] = "TEMPERATURE_VALUE_OUT_OF_RANGE"; - AlexaErrorType["THERMOSTAT_IS_OFF"] = "THERMOSTAT_IS_OFF"; - AlexaErrorType["TOO_MANY_FAILED_ATTEMPTS"] = "TOO_MANY_FAILED_ATTEMPTS"; - AlexaErrorType["TRIPLE_SETPOINTS_UNSUPPORTED"] = "TRIPLE_SETPOINTS_UNSUPPORTED"; - AlexaErrorType["UNABLE_TO_CHARGE"] = "UNABLE_TO_CHARGE"; - AlexaErrorType["UNAUTHORIZED"] = "UNAUTHORIZED"; - AlexaErrorType["UNCLEARED_ALARM"] = "UNCLEARED_ALARM"; - AlexaErrorType["UNSUPPORTED_THERMOSTAT_MODE"] = "UNSUPPORTED_THERMOSTAT_MODE"; - AlexaErrorType["UNCLEARED_TROUBLE"] = "UNCLEARED_TROUBLE"; - AlexaErrorType["UNWILLING_TO_SET_SCHEDULE"] = "UNWILLING_TO_SET_SCHEDULE"; - AlexaErrorType["UNWILLING_TO_SET_VALUE"] = "UNWILLING_TO_SET_VALUE"; - AlexaErrorType["VALUE_OUT_OF_RANGE"] = "VALUE_OUT_OF_RANGE"; -})(AlexaErrorType || (exports.AlexaErrorType = AlexaErrorType = {})); -class AlexaErrorResponse { - constructor(correlationToken, rootTopic, endpointId, mqttClient) { - this.rootTopic = rootTopic; - this.endpointId = endpointId; - this.mqttClient = mqttClient; - this.event = { - header: { - namespace: "Alexa", - name: "ErrorResponse", - messageId: (0, crypto_1.randomUUID)(), - correlationToken, - payloadVersion: "3", - }, - endpoint: { - endpointId, - }, - payload: {}, - }; - } - setErrorMessage(type, message, otherParams = {}) { - this.event.payload = { - type, - message, - ...otherParams, - }; - } - toJSON() { - return { event: this.event }; - } - send(sendAsync = false) { - const payload = { - event: this.event, - }; - 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); - return new Promise((resolve) => { - this.mqttClient.publish(topic, payloadStr, (err) => { - if (!err) - return resolve(topic); - 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 - }); - }); - } -} -exports.AlexaErrorResponse = AlexaErrorResponse; diff --git a/dist/cjs/compat/AlexaErrorResponse.js b/dist/cjs/compat/AlexaErrorResponse.js new file mode 100644 index 0000000..25a5d8e --- /dev/null +++ b/dist/cjs/compat/AlexaErrorResponse.js @@ -0,0 +1,54 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.AlexaErrorResponse = void 0; +const crypto_1 = require("crypto"); +const build_js_1 = require("../messages/build.js"); +const errors_js_1 = require("../messages/errors.js"); +/** An ErrorResponse as 1.x builds it: device.getErrorMessage(token), setErrorMessage(), send(). */ +class AlexaErrorResponse { + constructor(correlationToken, rootTopic, endpointId, mqttClient) { + this.correlationToken = correlationToken; + this.rootTopic = rootTopic; + this.endpointId = endpointId; + this.mqttClient = mqttClient; + // Sent when setErrorMessage() was not called: 1.x published an empty payload, which is no ErrorResponse to Alexa + this.error = errors_js_1.AlexaErrors.of("INTERNAL_ERROR", "the device answered with an error and did not say which"); + // One id for the message, however often toJSON() is called + this.messageId = (0, crypto_1.randomUUID)(); + } + /** + * The error to answer with. otherParams are the fields the type adds to the payload (validRange). The header + * carries the namespace the type is documented under, Alexa.ThermostatController for THERMOSTAT_IS_OFF; + * options.namespace names another. + */ + setErrorMessage(type, message, otherParams = {}, options = {}) { + this.error = errors_js_1.AlexaErrors.of(type, message, otherParams); + this.namespace = options.namespace; + } + toJSON() { + const { type, alexaMessage, extra } = this.error; + return (0, build_js_1.errorResponse)({ + endpointId: this.endpointId, + messageId: this.messageId, + correlationToken: this.correlationToken, + type, + message: alexaMessage, + extra, + namespace: this.namespace ?? this.error.namespace, + }); + } + /** Resolves with the topic published to, or "" when the publish failed. Never rejects. */ + send(sendAsync = false) { + const topic = `${this.rootTopic}/${this.endpointId}/${sendAsync ? "deferredResponse" : "alexaResponce"}`; // "alexaResponce" is how Alex2MQTT and Alex2ESP spell the topic + return new Promise((resolve) => { + this.mqttClient.publish(topic, JSON.stringify(this.toJSON()), (err) => { + if (!err) + return resolve(topic); + 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 + }); + }); + } +} +exports.AlexaErrorResponse = AlexaErrorResponse; diff --git a/dist/cjs/AlexaStatusMessage.js b/dist/cjs/compat/AlexaStatusMessage.js similarity index 52% rename from dist/cjs/AlexaStatusMessage.js rename to dist/cjs/compat/AlexaStatusMessage.js index 6fd95c1..e31382a 100644 --- a/dist/cjs/AlexaStatusMessage.js +++ b/dist/cjs/compat/AlexaStatusMessage.js @@ -1,95 +1,63 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -exports.AlexaStatusMessage = exports.TemperatureSensorScale = exports.ThermostatMode = void 0; +exports.AlexaStatusMessage = void 0; const crypto_1 = require("crypto"); -const enums_js_1 = require("./compat/enums.js"); -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 TemperatureSensorScale; -(function (TemperatureSensorScale) { - TemperatureSensorScale["CELSIUS"] = "CELSIUS"; - TemperatureSensorScale["FAHRENHEIT"] = "FAHRENHEIT"; -})(TemperatureSensorScale || (exports.TemperatureSensorScale = TemperatureSensorScale = {})); +const build_js_1 = require("../messages/build.js"); +const StateBuilder_js_1 = require("../messages/StateBuilder.js"); +const enums_js_1 = require("./enums.js"); +// The 1.x helpers report every temperature in Celsius, whatever scale the device works in +function celsius(scale, value) { + return { value: scale === enums_js_1.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. + */ class AlexaStatusMessage { constructor(correlationToken, rootTopic, endpointId, mqttClient, isResponse = false, isDeferred = false, changeCause = null) { - this.context = { properties: [] }; - /** ChangeReport only: the properties that changed (payload.change.properties); the rest go to context. */ - this.changeProps = []; - this.changeCause = null; - this.target = "context"; + this.correlationToken = correlationToken; this.rootTopic = rootTopic; this.endpointId = endpointId; this.mqttClient = mqttClient; + this.isResponse = isResponse; 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: (0, crypto_1.randomUUID)(), - correlationToken, - }, - endpoint: { - endpointId, - }, - payload: {}, - }; - } - getTimestamp() { - return new Date().toISOString(); + // One id for the message, however often toJSON() is called + this.messageId = (0, crypto_1.randomUUID)(); + this.state = new StateBuilder_js_1.StateBuilder({ target: changeCause ? "change" : "context" }); } addProperty(namespace, name, value, uncertaintyInMilliseconds = 0, instance) { - const prop = { - namespace, - name, - value, - timeOfSample: this.getTimestamp(), - uncertaintyInMilliseconds, - }; - if (instance) - prop.instance = instance; - (this.target === "change" ? this.changeProps : this.context.properties).push(prop); + 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.target = "change"; + this.state.changed(); return this; } /** ChangeReport: the add*Prop calls that follow describe the other, unchanged properties (context). */ unchanged() { - this.target = "context"; + 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). */ + /** + * 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 { - event: { ...this.event, payload: { change: { cause: { type: this.changeCause }, properties: this.changeProps } } }, - context: this.context, - }; + return (0, build_js_1.changeReport)({ endpointId, messageId, cause: this.changeCause, changed: this.state.change, context: this.state.context }); } - return { context: this.isDeferred ? null : this.context, event: this.event }; + if (this.isDeferred) { + return (0, build_js_1.deferredResponse)({ endpointId, messageId, correlationToken, estimatedDeferralInSeconds: this.estimatedDeferralInSeconds }); + } + const name = this.isResponse ? "Response" : "StateReport"; + return (0, build_js_1.response)({ name, endpointId, messageId, correlationToken, context: this.state.context }); } addModeControllerProp(instance, value, uncertaintyInMs = 0) { return this.addProperty(enums_js_1.AlexaInterfaceType.MODE_CONTROLLER, "mode", value, uncertaintyInMs, instance); @@ -99,9 +67,7 @@ class AlexaStatusMessage { } addEstimatedDeferralTime(seconds) { if (this.isDeferred) { - this.event.payload = { - estimatedDeferralInSeconds: seconds, - }; + this.estimatedDeferralInSeconds = seconds; } else { console.warn("[AlexaStatusMessage.ts] Attempted to add estimated deferral time, but message is not marked as DeferredResponse."); @@ -109,13 +75,7 @@ class AlexaStatusMessage { return this; } addThermostatControllerProp(name, scale, value, uncertaintyInMs = 0) { - const tempValue = { - value: scale === TemperatureSensorScale.FAHRENHEIT - ? (value - 32) * (5 / 9) - : value, - scale: "CELSIUS", - }; - return this.addProperty(enums_js_1.AlexaInterfaceType.THERMOSTAT_CONTROLLER, name, tempValue, uncertaintyInMs); + return this.addProperty(enums_js_1.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) { @@ -125,13 +85,7 @@ class AlexaStatusMessage { return this.addProperty(enums_js_1.AlexaInterfaceType.POWER_CONTROLLER, "powerState", power, uncertaintyInMs); } addTemperatureSensorProp(scale, value, uncertaintyInMs = 0) { - const tempValue = { - value: scale === TemperatureSensorScale.FAHRENHEIT - ? (value - 32) * (5 / 9) - : value, - scale: "CELSIUS", - }; - return this.addProperty(enums_js_1.AlexaInterfaceType.TEMPERATURE_SENSOR, "temperature", tempValue, uncertaintyInMs); + return this.addProperty(enums_js_1.AlexaInterfaceType.TEMPERATURE_SENSOR, "temperature", celsius(scale, value), uncertaintyInMs); } addBrightnessControllerProp(brightness, uncertaintyInMs = 0) { return this.addProperty(enums_js_1.AlexaInterfaceType.BRIGHTNESS_CONTROLLER, "brightness", brightness, uncertaintyInMs); @@ -142,29 +96,36 @@ class AlexaStatusMessage { addToggleControllerProp(state, instance, uncertaintyInMs = 0) { return this.addProperty(enums_js_1.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.context.properties.push(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 the publish failed - 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). + * 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 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 + : `${this.rootTopic}/${this.endpointId}/${sendAsync ? "deferredResponse" : "alexaResponce"}`; // "alexaResponce" is how Alex2MQTT and Alex2ESP spell the topic return new Promise((resolve) => { - this.mqttClient.publish(topic, payloadStr, (err) => { - if (!err) - return resolve(topic); + 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))); }); } } diff --git a/dist/cjs/compat/enums.js b/dist/cjs/compat/enums.js index cfe7303..31da9aa 100644 --- a/dist/cjs/compat/enums.js +++ b/dist/cjs/compat/enums.js @@ -1,6 +1,6 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -exports.EndpointHealth = exports.PowerController = exports.PowerState = exports.AlexaActions = exports.DisplayCategory = exports.AlexaInterfaceType = void 0; +exports.AlexaErrorType = exports.TemperatureSensorScale = exports.ThermostatMode = exports.EndpointHealth = exports.PowerController = exports.PowerState = exports.AlexaActions = exports.DisplayCategory = exports.AlexaInterfaceType = void 0; // The enums of 1.x, under their 1.x names. const EndpointHealth_js_1 = require("../registry/interfaces/EndpointHealth.js"); const PowerController_js_1 = require("../registry/interfaces/PowerController.js"); @@ -161,3 +161,98 @@ const Connectivity = { OK: "OK", UNREACHABLE: "UNREACHABLE" }; exports.PowerController = Object.assign(PowerController_js_1.PowerController, exports.PowerState); /** The Alexa.EndpointHealth interface, for device.add(). EndpointHealth.OK and EndpointHealth.UNREACHABLE are the 1.x enum of connectivity values. */ exports.EndpointHealth = Object.assign(EndpointHealth_js_1.EndpointHealth, Connectivity); +/** The modes of a thermostat the 1.x helpers know. */ +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 = {})); +/** The scale of a temperature given to addTemperatureSensorProp() and addThermostatControllerProp(). */ +var TemperatureSensorScale; +(function (TemperatureSensorScale) { + TemperatureSensorScale["CELSIUS"] = "CELSIUS"; + TemperatureSensorScale["FAHRENHEIT"] = "FAHRENHEIT"; +})(TemperatureSensorScale || (exports.TemperatureSensorScale = TemperatureSensorScale = {})); +/** The error types of alexa-errorresponse.html, "Error type values". */ +var AlexaErrorType; +(function (AlexaErrorType) { + AlexaErrorType["ALREADY_IN_OPERATION"] = "ALREADY_IN_OPERATION"; + AlexaErrorType["AUTHORIZATION_REQUIRED"] = "AUTHORIZATION_REQUIRED"; + AlexaErrorType["BRIDGE_UNREACHABLE"] = "BRIDGE_UNREACHABLE"; + AlexaErrorType["BYPASS_NEEDED"] = "BYPASS_NEEDED"; + AlexaErrorType["CLOUD_CONTROL_DISABLED"] = "CLOUD_CONTROL_DISABLED"; + AlexaErrorType["CHILD_LOCK"] = "CHILD_LOCK"; + AlexaErrorType["CONFIGURATION_UPDATE_NOT_ALLOWED"] = "CONFIGURATION_UPDATE_NOT_ALLOWED"; + AlexaErrorType["COOK_DURATION_TOO_LONG"] = "COOK_DURATION_TOO_LONG"; + AlexaErrorType["COOLING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE"] = "COOLING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE"; + AlexaErrorType["COOLING_STAGES_EXCEEDS_LIMIT"] = "COOLING_STAGES_EXCEEDS_LIMIT"; + AlexaErrorType["DATA_DELETION_NOT_SUPPORTED"] = "DATA_DELETION_NOT_SUPPORTED"; + AlexaErrorType["DATA_RETRIEVAL_NOT_SUPPORTED"] = "DATA_RETRIEVAL_NOT_SUPPORTED"; + AlexaErrorType["DEVICE_STUCK"] = "DEVICE_STUCK"; + AlexaErrorType["DISABLED_BY_USER"] = "DISABLED_BY_USER"; + AlexaErrorType["DO_NOT_DISTURB_MODE"] = "DO_NOT_DISTURB_MODE"; + AlexaErrorType["DOOR_CLOSED_TOO_LONG"] = "DOOR_CLOSED_TOO_LONG"; + AlexaErrorType["DOOR_OPEN"] = "DOOR_OPEN"; + AlexaErrorType["DUAL_SETPOINTS_UNSUPPORTED"] = "DUAL_SETPOINTS_UNSUPPORTED"; + AlexaErrorType["ENDPOINT_BUSY"] = "ENDPOINT_BUSY"; + AlexaErrorType["ENDPOINT_CONTROL_UNAVAILABLE"] = "ENDPOINT_CONTROL_UNAVAILABLE"; + AlexaErrorType["ENDPOINT_LOW_POWER"] = "ENDPOINT_LOW_POWER"; + AlexaErrorType["ENDPOINT_UNREACHABLE"] = "ENDPOINT_UNREACHABLE"; + AlexaErrorType["EXCEEDED_PIN_ATTEMPTS"] = "EXCEEDED_PIN_ATTEMPTS"; + AlexaErrorType["EXPIRED_AUTHORIZATION_CREDENTIAL"] = "EXPIRED_AUTHORIZATION_CREDENTIAL"; + AlexaErrorType["FAILED_TO_BOOTSTRAP_COMMISSIONING_PROCESS"] = "FAILED_TO_BOOTSTRAP_COMMISSIONING_PROCESS"; + AlexaErrorType["FIRMWARE_OUT_OF_DATE"] = "FIRMWARE_OUT_OF_DATE"; + AlexaErrorType["HARDWARE_MALFUNCTION"] = "HARDWARE_MALFUNCTION"; + AlexaErrorType["HEATING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE"] = "HEATING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE"; + AlexaErrorType["HEATING_STAGES_EXCEEDS_LIMIT"] = "HEATING_STAGES_EXCEEDS_LIMIT"; + AlexaErrorType["INSUFFICIENT_PERMISSIONS"] = "INSUFFICIENT_PERMISSIONS"; + AlexaErrorType["INSUFFICIENT_RESOURCE"] = "INSUFFICIENT_RESOURCE"; + AlexaErrorType["INSUFFICIENT_SPACE"] = "INSUFFICIENT_SPACE"; + AlexaErrorType["INTERNAL_ERROR"] = "INTERNAL_ERROR"; + AlexaErrorType["INVALID_AUTHORIZATION_CREDENTIAL"] = "INVALID_AUTHORIZATION_CREDENTIAL"; + AlexaErrorType["INVALID_AUXILIARY_HEATING_SYSTEM_TYPE"] = "INVALID_AUXILIARY_HEATING_SYSTEM_TYPE"; + AlexaErrorType["INVALID_DIRECTIVE"] = "INVALID_DIRECTIVE"; + AlexaErrorType["INVALID_SYSTEM_TYPE"] = "INVALID_SYSTEM_TYPE"; + AlexaErrorType["INVALID_TARGET_STATE"] = "INVALID_TARGET_STATE"; + AlexaErrorType["INVALID_TEMPERATURE_SCALE"] = "INVALID_TEMPERATURE_SCALE"; + AlexaErrorType["INVALID_TERMINAL_CONNECTION"] = "INVALID_TERMINAL_CONNECTION"; + AlexaErrorType["INVALID_VALUE"] = "INVALID_VALUE"; + AlexaErrorType["MAINTENANCE_REQUIRED"] = "MAINTENANCE_REQUIRED"; + AlexaErrorType["MAX_COMMISSIONING_LIMIT_REACHED"] = "MAX_COMMISSIONING_LIMIT_REACHED"; + AlexaErrorType["MISSING_SETUP_INFORMATION"] = "MISSING_SETUP_INFORMATION"; + AlexaErrorType["NO_SUCH_ENDPOINT"] = "NO_SUCH_ENDPOINT"; + AlexaErrorType["NOT_CALIBRATED"] = "NOT_CALIBRATED"; + AlexaErrorType["NOT_IN_OPERATION"] = "NOT_IN_OPERATION"; + AlexaErrorType["NOT_READY"] = "NOT_READY"; + AlexaErrorType["NOT_SUPPORTED_IN_CURRENT_MODE"] = "NOT_SUPPORTED_IN_CURRENT_MODE"; + AlexaErrorType["NOT_SUPPORTED_WITH_CURRENT_BATTERY_CHARGE_STATE"] = "NOT_SUPPORTED_WITH_CURRENT_BATTERY_CHARGE_STATE"; + AlexaErrorType["OBSTACLE_DETECTED"] = "OBSTACLE_DETECTED"; + AlexaErrorType["PARTNER_APPLICATION_REDIRECTION"] = "PARTNER_APPLICATION_REDIRECTION"; + AlexaErrorType["PIN_SETUP_REQUIRED"] = "PIN_SETUP_REQUIRED"; + AlexaErrorType["POWER_LEVEL_NOT_SUPPORTED"] = "POWER_LEVEL_NOT_SUPPORTED"; + AlexaErrorType["PREHEAT_REQUIRED"] = "PREHEAT_REQUIRED"; + AlexaErrorType["PROBE_REQUIRED"] = "PROBE_REQUIRED"; + AlexaErrorType["RATE_LIMIT_EXCEEDED"] = "RATE_LIMIT_EXCEEDED"; + AlexaErrorType["REMOTE_START_NOT_SUPPORTED"] = "REMOTE_START_NOT_SUPPORTED"; + AlexaErrorType["REMOVE_PROBE"] = "REMOVE_PROBE"; + AlexaErrorType["REMOTE_START_DISABLED"] = "REMOTE_START_DISABLED"; + AlexaErrorType["REQUESTED_SETPOINTS_TOO_CLOSE"] = "REQUESTED_SETPOINTS_TOO_CLOSE"; + AlexaErrorType["SAFETY_BEAM_BREACHED"] = "SAFETY_BEAM_BREACHED"; + AlexaErrorType["SUBSCRIPTION_REQUIRED"] = "SUBSCRIPTION_REQUIRED"; + AlexaErrorType["TEMPERATURE_VALUE_OUT_OF_RANGE"] = "TEMPERATURE_VALUE_OUT_OF_RANGE"; + AlexaErrorType["THERMOSTAT_IS_OFF"] = "THERMOSTAT_IS_OFF"; + AlexaErrorType["TOO_MANY_FAILED_ATTEMPTS"] = "TOO_MANY_FAILED_ATTEMPTS"; + AlexaErrorType["TRIPLE_SETPOINTS_UNSUPPORTED"] = "TRIPLE_SETPOINTS_UNSUPPORTED"; + AlexaErrorType["UNABLE_TO_CHARGE"] = "UNABLE_TO_CHARGE"; + AlexaErrorType["UNAUTHORIZED"] = "UNAUTHORIZED"; + AlexaErrorType["UNCLEARED_ALARM"] = "UNCLEARED_ALARM"; + AlexaErrorType["UNSUPPORTED_THERMOSTAT_MODE"] = "UNSUPPORTED_THERMOSTAT_MODE"; + AlexaErrorType["UNCLEARED_TROUBLE"] = "UNCLEARED_TROUBLE"; + AlexaErrorType["UNWILLING_TO_SET_SCHEDULE"] = "UNWILLING_TO_SET_SCHEDULE"; + AlexaErrorType["UNWILLING_TO_SET_VALUE"] = "UNWILLING_TO_SET_VALUE"; + AlexaErrorType["VALUE_OUT_OF_RANGE"] = "VALUE_OUT_OF_RANGE"; +})(AlexaErrorType || (exports.AlexaErrorType = AlexaErrorType = {})); diff --git a/dist/cjs/device/Device.js b/dist/cjs/device/Device.js index 8e52826..dacfd63 100644 --- a/dist/cjs/device/Device.js +++ b/dist/cjs/device/Device.js @@ -1,11 +1,11 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const events_1 = require("events"); -const crypto_1 = require("crypto"); -const AlexaErrorResponse_js_1 = require("../AlexaErrorResponse.js"); -const AlexaStatusMessage_js_1 = require("../AlexaStatusMessage.js"); +const AlexaErrorResponse_js_1 = require("../compat/AlexaErrorResponse.js"); const AlexaInterface_js_1 = require("../compat/AlexaInterface.js"); +const AlexaStatusMessage_js_1 = require("../compat/AlexaStatusMessage.js"); const enums_js_1 = require("../compat/enums.js"); +const build_js_1 = require("../messages/build.js"); const Alexa_js_1 = require("../registry/interfaces/Alexa.js"); const EndpointHealth_js_1 = require("../registry/interfaces/EndpointHealth.js"); const schema_js_1 = require("../registry/schema.js"); @@ -79,6 +79,7 @@ class Device extends events_1.EventEmitter { * A proactive Alexa.ChangeReport (1.5.1): add the changed properties (the default target), optionally * .unchanged() then the others, and .send() - it goes to /changeReport, which Alex2MQTT forwards to the Alexa * event gateway with the user's token. Alexa only accepts it for capabilities registered with proactivelyReported. + * Without a changed property send() publishes nothing and resolves with "": Alex2MQTT would drop the report. */ getChangeReport(cause = "PHYSICAL_INTERACTION") { const msg = new AlexaStatusMessage_js_1.AlexaStatusMessage("", this.rootTopic, this.endpointId, this.mqttClient, false, false, cause); @@ -90,14 +91,7 @@ class Device extends events_1.EventEmitter { * (1.5.1). Resolves with the topic published to, or "" when the publish failed (never rejects, 1.5.2). */ sendSceneResponse(correlationToken, activated, cause = "VOICE_INTERACTION", sendAsync = false) { - const payload = { - context: {}, - event: { - header: { namespace: "Alexa.SceneController", name: activated ? "ActivationStarted" : "DeactivationStarted", messageId: (0, crypto_1.randomUUID)(), correlationToken, payloadVersion: "3" }, - endpoint: { endpointId: this.endpointId }, - payload: { cause: { type: cause }, timestamp: new Date().toISOString() }, - }, - }; + const payload = (0, build_js_1.sceneEvent)({ endpointId: this.endpointId, correlationToken, activated, cause }); const topic = `${this.rootTopic}/${this.endpointId}/${sendAsync ? "deferredResponse" : "alexaResponce"}`; return new Promise((resolve) => { this.mqttClient.publish(topic, JSON.stringify(payload), (err) => { diff --git a/dist/cjs/index.js b/dist/cjs/index.js index 8e0e781..774c919 100644 --- a/dist/cjs/index.js +++ b/dist/cjs/index.js @@ -1,9 +1,42 @@ "use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); -exports.AlexaErrorResponse = exports.AlexaErrorType = exports.ThermostatMode = exports.TemperatureSensorScale = exports.AlexaStatusMessage = exports.PowerState = exports.DisplayCategory = exports.AlexaInterfaceType = exports.AlexaActions = exports.ActionMapping = exports.AlexaInterface = exports.DisplayCategories = exports.States = exports.Actions = exports.Units = exports.Assets = exports.SemanticsBuilder = exports.semantics = exports.text = exports.asset = exports.EndpointHealth = exports.PowerController = exports.ToggleController = exports.TemperatureSensor = exports.RangeController = exports.ModeController = exports.BrightnessController = exports.Alexa = exports.SchemaError = exports.DeclarationError = exports.registry = exports.Capability = exports.Device = exports.DEFAULT_HOST = exports.Alex2MQTT = void 0; +exports.AlexaErrorResponse = exports.AlexaStatusMessage = exports.ThermostatMode = exports.TemperatureSensorScale = exports.PowerState = exports.DisplayCategory = exports.AlexaInterfaceType = exports.AlexaErrorType = exports.AlexaActions = exports.ActionMapping = exports.AlexaInterface = exports.property = exports.StateBuilder = exports.MessageError = exports.AlexaErrors = exports.AlexaError = exports.messages = exports.DisplayCategories = exports.States = exports.Actions = exports.Units = exports.Assets = exports.SemanticsBuilder = exports.semantics = exports.text = exports.asset = exports.EndpointHealth = exports.PowerController = exports.ToggleController = exports.TemperatureSensor = exports.RangeController = exports.ModeController = exports.BrightnessController = exports.Alexa = exports.SchemaError = exports.DeclarationError = exports.registry = exports.Capability = exports.Device = exports.DEFAULT_HOST = exports.Alex2MQTT = void 0; // Relative specifiers carry ".js": Node's ES module loader resolves no extension, and TypeScript maps it back to the .ts. var Alex2Node_js_1 = require("./Alex2Node.js"); Object.defineProperty(exports, "Alex2MQTT", { enumerable: true, get: function () { return __importDefault(Alex2Node_js_1).default; } }); @@ -39,6 +72,14 @@ Object.defineProperty(exports, "Units", { enumerable: true, get: function () { r Object.defineProperty(exports, "Actions", { enumerable: true, get: function () { return index_js_4.ACTIONS; } }); Object.defineProperty(exports, "States", { enumerable: true, get: function () { return index_js_4.STATES; } }); Object.defineProperty(exports, "DisplayCategories", { enumerable: true, get: function () { return index_js_4.DISPLAY_CATEGORIES; } }); +// The messages: what the bridge publishes, built from plain values +exports.messages = __importStar(require("./messages/index.js")); +var index_js_5 = require("./messages/index.js"); +Object.defineProperty(exports, "AlexaError", { enumerable: true, get: function () { return index_js_5.AlexaError; } }); +Object.defineProperty(exports, "AlexaErrors", { enumerable: true, get: function () { return index_js_5.AlexaErrors; } }); +Object.defineProperty(exports, "MessageError", { enumerable: true, get: function () { return index_js_5.MessageError; } }); +Object.defineProperty(exports, "StateBuilder", { enumerable: true, get: function () { return index_js_5.StateBuilder; } }); +Object.defineProperty(exports, "property", { enumerable: true, get: function () { return index_js_5.property; } }); // 1.x var AlexaInterface_js_1 = require("./compat/AlexaInterface.js"); Object.defineProperty(exports, "AlexaInterface", { enumerable: true, get: function () { return AlexaInterface_js_1.AlexaInterface; } }); @@ -46,13 +87,13 @@ var ActionMapping_js_1 = require("./compat/ActionMapping.js"); Object.defineProperty(exports, "ActionMapping", { enumerable: true, get: function () { return ActionMapping_js_1.ActionMapping; } }); var enums_js_2 = require("./compat/enums.js"); Object.defineProperty(exports, "AlexaActions", { enumerable: true, get: function () { return enums_js_2.AlexaActions; } }); +Object.defineProperty(exports, "AlexaErrorType", { enumerable: true, get: function () { return enums_js_2.AlexaErrorType; } }); Object.defineProperty(exports, "AlexaInterfaceType", { enumerable: true, get: function () { return enums_js_2.AlexaInterfaceType; } }); Object.defineProperty(exports, "DisplayCategory", { enumerable: true, get: function () { return enums_js_2.DisplayCategory; } }); Object.defineProperty(exports, "PowerState", { enumerable: true, get: function () { return enums_js_2.PowerState; } }); -var AlexaStatusMessage_js_1 = require("./AlexaStatusMessage.js"); +Object.defineProperty(exports, "TemperatureSensorScale", { enumerable: true, get: function () { return enums_js_2.TemperatureSensorScale; } }); +Object.defineProperty(exports, "ThermostatMode", { enumerable: true, get: function () { return enums_js_2.ThermostatMode; } }); +var AlexaStatusMessage_js_1 = require("./compat/AlexaStatusMessage.js"); Object.defineProperty(exports, "AlexaStatusMessage", { enumerable: true, get: function () { return AlexaStatusMessage_js_1.AlexaStatusMessage; } }); -Object.defineProperty(exports, "TemperatureSensorScale", { enumerable: true, get: function () { return AlexaStatusMessage_js_1.TemperatureSensorScale; } }); -Object.defineProperty(exports, "ThermostatMode", { enumerable: true, get: function () { return AlexaStatusMessage_js_1.ThermostatMode; } }); -var AlexaErrorResponse_js_1 = require("./AlexaErrorResponse.js"); -Object.defineProperty(exports, "AlexaErrorType", { enumerable: true, get: function () { return AlexaErrorResponse_js_1.AlexaErrorType; } }); +var AlexaErrorResponse_js_1 = require("./compat/AlexaErrorResponse.js"); Object.defineProperty(exports, "AlexaErrorResponse", { enumerable: true, get: function () { return AlexaErrorResponse_js_1.AlexaErrorResponse; } }); diff --git a/dist/cjs/messages/StateBuilder.js b/dist/cjs/messages/StateBuilder.js new file mode 100644 index 0000000..d548f92 --- /dev/null +++ b/dist/cjs/messages/StateBuilder.js @@ -0,0 +1,70 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.StateBuilder = void 0; +const EndpointHealth_js_1 = require("../registry/interfaces/EndpointHealth.js"); +const schema_js_1 = require("../registry/schema.js"); +const property_js_1 = require("./property.js"); +/** + * Collects the properties of one message. For a Response or a StateReport they are its context. For a ChangeReport + * changed() and unchanged() say which of the two lists the properties that follow belong to. + */ +class StateBuilder { + constructor(options = {}) { + this.lists = { context: [], change: [] }; + this.target = options.target ?? "context"; + this.now = options.now ?? (() => new Date()); + } + /** + * A property of an interface the library describes, its value checked: set(PowerController, "powerState", "ON"). + * Given a capability of a device in place of the interface, the property carries the instance of the capability. + * Throws SchemaError for a value Alexa would not take. + */ + set(source, name, value, options = {}) { + const descriptor = "descriptor" in source ? source.descriptor : source; + const instance = options.instance ?? ("descriptor" in source ? source.instance : ""); + const where = `${descriptor.namespace}${instance ? ` "${instance}"` : ""}: ${name}`; + const described = descriptor.properties[name]; + if (!described) { + const names = Object.keys(descriptor.properties); + throw new schema_js_1.SchemaError(where, names.length > 0 + ? `not a property of the interface, which has ${names.join(", ")}` + : "the library knows no property of the interface, report it with setRaw()"); + } + if (descriptor.instanced && !instance) { + throw new schema_js_1.SchemaError(where, "the interface has instances, pass the one that is reported: { instance: \"Blind.Lift\" }"); + } + return this.setRaw(descriptor.namespace, described.name, described.value.parse(value, where), { ...options, instance }); + } + /** A property as given, nothing checked: for an interface or a value the library does not describe. */ + setRaw(namespace, name, value, options = {}) { + return this.add((0, property_js_1.property)(namespace, name, value, { ...options, timeOfSample: options.timeOfSample ?? this.now() })); + } + /** The connectivity of Alexa.EndpointHealth, which belongs in every report of an endpoint that declares it. */ + health(value, reason, options = {}) { + return this.set(EndpointHealth_js_1.EndpointHealth, "connectivity", reason ? { value, reason } : { value }, options); + } + /** A property built elsewhere, to the list that is filled now or to the one named. */ + add(built, target = this.target) { + this.lists[target].push(built); + return this; + } + /** The properties that follow are the ones that changed. */ + changed() { + this.target = "change"; + return this; + } + /** The properties that follow did not change: a ChangeReport lists them in its context. */ + unchanged() { + this.target = "context"; + return this; + } + /** The properties for the context of the message, in the order they were set. */ + get context() { + return [...this.lists.context]; + } + /** The properties for payload.change of a ChangeReport. */ + get change() { + return [...this.lists.change]; + } +} +exports.StateBuilder = StateBuilder; diff --git a/dist/cjs/messages/build.js b/dist/cjs/messages/build.js new file mode 100644 index 0000000..fd6399a --- /dev/null +++ b/dist/cjs/messages/build.js @@ -0,0 +1,136 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.MessageError = void 0; +exports.response = response; +exports.stateReport = stateReport; +exports.deferredResponse = deferredResponse; +exports.errorResponse = errorResponse; +exports.changeReport = changeReport; +exports.sceneEvent = sceneEvent; +exports.doorbellPress = doorbellPress; +exports.simpleEvent = simpleEvent; +// Every message the bridge publishes, built from plain values. Nothing here reads the clock or makes an id unless +// the caller leaves messageId or a time out, so a test that passes both compares whole objects. +const crypto_1 = require("crypto"); +const errors_js_1 = require("./errors.js"); +const property_js_1 = require("./property.js"); +/** A message that would be dropped on its way to Alexa, refused where it is built. */ +class MessageError extends Error { + constructor(endpointId, problem) { + super(`${endpointId}: ${problem}`); + this.endpointId = endpointId; + this.problem = problem; + this.name = "MessageError"; + } +} +exports.MessageError = MessageError; +// The fields in the order of the examples +function header(namespace, name, fields) { + return { + namespace, + name, + ...(fields.instance ? { instance: fields.instance } : {}), + messageId: fields.messageId ?? (0, crypto_1.randomUUID)(), + ...(fields.correlationToken !== undefined ? { correlationToken: fields.correlationToken } : {}), + payloadVersion: fields.payloadVersion ?? "3", + }; +} +/** + * The answer to a directive (alexa-response.html, "Synchronous response"). An endpoint with nothing to report + * answers with an empty list of properties, not without a context (state-reporting-for-smart-home-addons.html, + * "Directive response example for a property that isn't retrievable"). + */ +function response(fields) { + return { + event: { + header: header(fields.namespace ?? "Alexa", fields.name ?? "Response", fields), + endpoint: { endpointId: fields.endpointId }, + payload: fields.payload ?? {}, + }, + context: { properties: [...(fields.context ?? [])] }, + }; +} +/** The answer to ReportState: every retrievable property of the endpoint. */ +function stateReport(fields) { + return response({ ...fields, name: "StateReport", namespace: "Alexa", payload: {} }); +} +/** + * "The directive arrived, the answer follows": no context, the state is in the Response that follows + * (alexa-response.html, "Deferred response example"). + */ +function deferredResponse(fields) { + const { estimatedDeferralInSeconds } = fields; + return { + event: { + header: header("Alexa", "DeferredResponse", fields), + endpoint: { endpointId: fields.endpointId }, + payload: estimatedDeferralInSeconds === undefined ? {} : { estimatedDeferralInSeconds }, + }, + }; +} +/** The answer to a directive the endpoint could not follow (alexa-errorresponse.html). */ +function errorResponse(fields) { + return { + event: { + header: header(fields.namespace ?? (0, errors_js_1.errorNamespace)(fields.type), "ErrorResponse", fields), + endpoint: { endpointId: fields.endpointId }, + payload: { type: fields.type, message: fields.message, ...fields.extra }, + }, + }; +} +const sameProperty = (a, b) => a.namespace === b.namespace && a.name === b.name && (a.instance ?? "") === (b.instance ?? ""); +/** + * A change of state nobody asked for. The header has no correlationToken (message-guide.html, "Header object"). + * A property that changed is left out of the context: it is reported in one of the two (same page, "Context + * object"). Throws MessageError when nothing changed: Alex2MQTT drops such a report without a word. + */ +function changeReport(fields) { + const { endpointId, changed, context = [] } = fields; + if (changed.length === 0) { + throw new MessageError(endpointId, "a ChangeReport needs at least one property that changed, this one has none. " + + "Add the changed property before unchanged(), or send no report when nothing changed"); + } + return { + event: { + header: header("Alexa", "ChangeReport", { messageId: fields.messageId }), + endpoint: { endpointId }, + payload: { change: { cause: { type: fields.cause ?? "PHYSICAL_INTERACTION" }, properties: [...changed] } }, + }, + context: { properties: context.filter((property) => !changed.some((other) => sameProperty(property, other))) }, + }; +} +/** The answer to Activate and Deactivate of a scene (alexa-scenecontroller.html). */ +function sceneEvent(fields) { + return { + event: { + header: header("Alexa.SceneController", fields.activated ? "ActivationStarted" : "DeactivationStarted", fields), + endpoint: { endpointId: fields.endpointId }, + payload: { cause: { type: fields.cause ?? "VOICE_INTERACTION" }, timestamp: (0, property_js_1.isoTime)(fields.timestamp) }, + }, + context: {}, + }; +} +/** Somebody rang (alexa-doorbelleventsource.html). */ +function doorbellPress(fields) { + return { + event: { + header: header("Alexa.DoorbellEventSource", "DoorbellPress", { messageId: fields.messageId }), + endpoint: { endpointId: fields.endpointId }, + payload: { cause: { type: fields.cause ?? "PHYSICAL_INTERACTION" }, timestamp: (0, property_js_1.isoTime)(fields.timestamp) }, + }, + }; +} +/** An event of a button or a sensor that routines start on (alexa-simpleeventsource.html). */ +function simpleEvent(fields) { + return { + event: { + header: header("Alexa.SimpleEventSource", "Event", { + instance: fields.instance, + messageId: fields.messageId, + payloadVersion: "1.0", + }), + endpoint: { endpointId: fields.endpointId }, + payload: { id: fields.id, timestamp: (0, property_js_1.isoTime)(fields.timestamp) }, + }, + }; +} diff --git a/dist/cjs/messages/errors.js b/dist/cjs/messages/errors.js new file mode 100644 index 0000000..b14ff4a --- /dev/null +++ b/dist/cjs/messages/errors.js @@ -0,0 +1,74 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.AlexaErrors = exports.AlexaError = void 0; +exports.errorNamespace = errorNamespace; +// The errors a device answers a directive with (alexa-errorresponse.html). A helper for each type whose payload +// has fields of its own, so that the fields cannot be misspelt. +const catalog_js_1 = require("../registry/catalog.js"); +/** + * The namespace an ErrorResponse of this type goes under: the one the table of error types lists it with + * (THERMOSTAT_IS_OFF: Alexa.ThermostatController, OBSTACLE_DETECTED: Alexa.Safety), Alexa for a type the table + * does not have. + */ +function errorNamespace(type) { + return Object.prototype.hasOwnProperty.call(catalog_js_1.ERROR_TYPES, type) ? catalog_js_1.ERROR_TYPES[type] : "Alexa"; +} +/** Thrown by a directive handler, or built to be sent: the directive is answered with this ErrorResponse. */ +class AlexaError extends Error { + constructor(type, message, extra = {}, namespace = errorNamespace(type)) { + super(`${type}: ${message}`); + this.name = "AlexaError"; + this.type = type; + this.alexaMessage = message; + this.extra = extra; + this.namespace = namespace; + } +} +exports.AlexaError = AlexaError; +// A field that was not given is left out of the payload +function given(fields) { + return Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined)); +} +exports.AlexaErrors = { + /** An error of any type. */ + of(type, message, extra = {}) { + return new AlexaError(type, message, extra); + }, + /** The value is outside what the endpoint takes. For a temperature: temperatureOutOfRange(). */ + valueOutOfRange(message, validRange) { + return new AlexaError("VALUE_OUT_OF_RANGE", message, { validRange }); + }, + temperatureOutOfRange(message, validRange) { + return new AlexaError("TEMPERATURE_VALUE_OUT_OF_RANGE", message, { validRange }); + }, + /** A light showing a color asked for a color temperature: "COLOR". */ + notSupportedInCurrentMode(message, currentDeviceMode) { + return new AlexaError("NOT_SUPPORTED_IN_CURRENT_MODE", message, { currentDeviceMode }); + }, + /** percentageState: what is left of the battery, 0 to 100. */ + endpointLowPower(message, percentageState) { + return new AlexaError("ENDPOINT_LOW_POWER", message, given({ percentageState })); + }, + endpointControlUnavailable(message, reason) { + return new AlexaError("ENDPOINT_CONTROL_UNAVAILABLE", message, { reason }); + }, + notSupportedWithCurrentBatteryChargeState(message, currentChargeState, currentChargeLevelInPercentage) { + return new AlexaError("NOT_SUPPORTED_WITH_CURRENT_BATTERY_CHARGE_STATE", message, given({ currentChargeState, currentChargeLevelInPercentage })); + }, + /** What has to be refilled. */ + insufficientResource(message, resourceType) { + return new AlexaError("INSUFFICIENT_RESOURCE", message, { resourceType }); + }, + /** What the user has to do first. */ + maintenanceRequired(message, maintenanceAction) { + return new AlexaError("MAINTENANCE_REQUIRED", message, given({ maintenanceAction })); + }, + /** Goes under Alexa.ThermostatController. minimumTemperatureDelta: how far apart the setpoints have to be. */ + setpointsTooClose(message, minimumTemperatureDelta) { + return new AlexaError("REQUESTED_SETPOINTS_TOO_CLOSE", message, given({ minimumTemperatureDelta })); + }, + /** Goes under Alexa.SecurityPanelController. With the endpoints listed, the user can bypass them by voice. */ + bypassNeeded(message, endpointsNeedingBypass) { + return new AlexaError("BYPASS_NEEDED", message, given({ endpointsNeedingBypass })); + }, +}; diff --git a/dist/cjs/messages/index.js b/dist/cjs/messages/index.js new file mode 100644 index 0000000..35e4f65 --- /dev/null +++ b/dist/cjs/messages/index.js @@ -0,0 +1,22 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.StateBuilder = exports.property = exports.errorNamespace = exports.AlexaErrors = exports.AlexaError = exports.MessageError = exports.stateReport = exports.simpleEvent = exports.sceneEvent = exports.response = exports.errorResponse = exports.doorbellPress = exports.deferredResponse = exports.changeReport = void 0; +// The messages of the bridge without the bridge: builders, the state collector and the errors. +var build_js_1 = require("./build.js"); +Object.defineProperty(exports, "changeReport", { enumerable: true, get: function () { return build_js_1.changeReport; } }); +Object.defineProperty(exports, "deferredResponse", { enumerable: true, get: function () { return build_js_1.deferredResponse; } }); +Object.defineProperty(exports, "doorbellPress", { enumerable: true, get: function () { return build_js_1.doorbellPress; } }); +Object.defineProperty(exports, "errorResponse", { enumerable: true, get: function () { return build_js_1.errorResponse; } }); +Object.defineProperty(exports, "response", { enumerable: true, get: function () { return build_js_1.response; } }); +Object.defineProperty(exports, "sceneEvent", { enumerable: true, get: function () { return build_js_1.sceneEvent; } }); +Object.defineProperty(exports, "simpleEvent", { enumerable: true, get: function () { return build_js_1.simpleEvent; } }); +Object.defineProperty(exports, "stateReport", { enumerable: true, get: function () { return build_js_1.stateReport; } }); +Object.defineProperty(exports, "MessageError", { enumerable: true, get: function () { return build_js_1.MessageError; } }); +var errors_js_1 = require("./errors.js"); +Object.defineProperty(exports, "AlexaError", { enumerable: true, get: function () { return errors_js_1.AlexaError; } }); +Object.defineProperty(exports, "AlexaErrors", { enumerable: true, get: function () { return errors_js_1.AlexaErrors; } }); +Object.defineProperty(exports, "errorNamespace", { enumerable: true, get: function () { return errors_js_1.errorNamespace; } }); +var property_js_1 = require("./property.js"); +Object.defineProperty(exports, "property", { enumerable: true, get: function () { return property_js_1.property; } }); +var StateBuilder_js_1 = require("./StateBuilder.js"); +Object.defineProperty(exports, "StateBuilder", { enumerable: true, get: function () { return StateBuilder_js_1.StateBuilder; } }); diff --git a/dist/cjs/messages/property.js b/dist/cjs/messages/property.js new file mode 100644 index 0000000..f2820dd --- /dev/null +++ b/dist/cjs/messages/property.js @@ -0,0 +1,19 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.isoTime = isoTime; +exports.property = property; +/** A time as Alexa wants it: ISO 8601 in UTC. Text is taken as given. */ +function isoTime(time = new Date()) { + return typeof time === "string" ? time : time.toISOString(); +} +/** One property of a context or of a change, its fields in the order of the examples. */ +function property(namespace, name, value, options = {}) { + return { + namespace, + ...(options.instance ? { instance: options.instance } : {}), + name, + value, + timeOfSample: isoTime(options.timeOfSample), + uncertaintyInMilliseconds: options.uncertaintyInMilliseconds ?? 0, + }; +} diff --git a/dist/cjs/messages/types.js b/dist/cjs/messages/types.js new file mode 100644 index 0000000..25c112c --- /dev/null +++ b/dist/cjs/messages/types.js @@ -0,0 +1,3 @@ +"use strict"; +// The messages the bridge publishes, as message-guide.html and alexa-response.html draw them. +Object.defineProperty(exports, "__esModule", { value: true }); diff --git a/dist/esm/AlexaErrorResponse.d.ts b/dist/esm/AlexaErrorResponse.d.ts deleted file mode 100644 index 7620270..0000000 --- a/dist/esm/AlexaErrorResponse.d.ts +++ /dev/null @@ -1,92 +0,0 @@ -import type { MqttClient } from "mqtt"; -export declare enum AlexaErrorType { - ALREADY_IN_OPERATION = "ALREADY_IN_OPERATION", - AUTHORIZATION_REQUIRED = "AUTHORIZATION_REQUIRED", - BRIDGE_UNREACHABLE = "BRIDGE_UNREACHABLE", - BYPASS_NEEDED = "BYPASS_NEEDED", - CLOUD_CONTROL_DISABLED = "CLOUD_CONTROL_DISABLED", - CHILD_LOCK = "CHILD_LOCK", - CONFIGURATION_UPDATE_NOT_ALLOWED = "CONFIGURATION_UPDATE_NOT_ALLOWED", - COOK_DURATION_TOO_LONG = "COOK_DURATION_TOO_LONG", - COOLING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE = "COOLING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE", - COOLING_STAGES_EXCEEDS_LIMIT = "COOLING_STAGES_EXCEEDS_LIMIT", - DATA_DELETION_NOT_SUPPORTED = "DATA_DELETION_NOT_SUPPORTED", - DATA_RETRIEVAL_NOT_SUPPORTED = "DATA_RETRIEVAL_NOT_SUPPORTED", - DEVICE_STUCK = "DEVICE_STUCK", - DISABLED_BY_USER = "DISABLED_BY_USER", - DO_NOT_DISTURB_MODE = "DO_NOT_DISTURB_MODE", - DOOR_CLOSED_TOO_LONG = "DOOR_CLOSED_TOO_LONG", - DOOR_OPEN = "DOOR_OPEN", - DUAL_SETPOINTS_UNSUPPORTED = "DUAL_SETPOINTS_UNSUPPORTED", - ENDPOINT_BUSY = "ENDPOINT_BUSY", - ENDPOINT_CONTROL_UNAVAILABLE = "ENDPOINT_CONTROL_UNAVAILABLE", - ENDPOINT_LOW_POWER = "ENDPOINT_LOW_POWER", - ENDPOINT_UNREACHABLE = "ENDPOINT_UNREACHABLE", - EXCEEDED_PIN_ATTEMPTS = "EXCEEDED_PIN_ATTEMPTS", - EXPIRED_AUTHORIZATION_CREDENTIAL = "EXPIRED_AUTHORIZATION_CREDENTIAL", - FAILED_TO_BOOTSTRAP_COMMISSIONING_PROCESS = "FAILED_TO_BOOTSTRAP_COMMISSIONING_PROCESS", - FIRMWARE_OUT_OF_DATE = "FIRMWARE_OUT_OF_DATE", - HARDWARE_MALFUNCTION = "HARDWARE_MALFUNCTION", - HEATING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE = "HEATING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE", - HEATING_STAGES_EXCEEDS_LIMIT = "HEATING_STAGES_EXCEEDS_LIMIT", - INSUFFICIENT_PERMISSIONS = "INSUFFICIENT_PERMISSIONS", - INSUFFICIENT_RESOURCE = "INSUFFICIENT_RESOURCE", - INSUFFICIENT_SPACE = "INSUFFICIENT_SPACE", - INTERNAL_ERROR = "INTERNAL_ERROR", - INVALID_AUTHORIZATION_CREDENTIAL = "INVALID_AUTHORIZATION_CREDENTIAL", - INVALID_AUXILIARY_HEATING_SYSTEM_TYPE = "INVALID_AUXILIARY_HEATING_SYSTEM_TYPE", - INVALID_DIRECTIVE = "INVALID_DIRECTIVE", - INVALID_SYSTEM_TYPE = "INVALID_SYSTEM_TYPE", - INVALID_TARGET_STATE = "INVALID_TARGET_STATE", - INVALID_TEMPERATURE_SCALE = "INVALID_TEMPERATURE_SCALE", - INVALID_TERMINAL_CONNECTION = "INVALID_TERMINAL_CONNECTION", - INVALID_VALUE = "INVALID_VALUE", - MAINTENANCE_REQUIRED = "MAINTENANCE_REQUIRED", - MAX_COMMISSIONING_LIMIT_REACHED = "MAX_COMMISSIONING_LIMIT_REACHED", - MISSING_SETUP_INFORMATION = "MISSING_SETUP_INFORMATION", - NO_SUCH_ENDPOINT = "NO_SUCH_ENDPOINT", - NOT_CALIBRATED = "NOT_CALIBRATED", - NOT_IN_OPERATION = "NOT_IN_OPERATION", - NOT_READY = "NOT_READY", - NOT_SUPPORTED_IN_CURRENT_MODE = "NOT_SUPPORTED_IN_CURRENT_MODE", - NOT_SUPPORTED_WITH_CURRENT_BATTERY_CHARGE_STATE = "NOT_SUPPORTED_WITH_CURRENT_BATTERY_CHARGE_STATE", - OBSTACLE_DETECTED = "OBSTACLE_DETECTED", - PARTNER_APPLICATION_REDIRECTION = "PARTNER_APPLICATION_REDIRECTION", - PIN_SETUP_REQUIRED = "PIN_SETUP_REQUIRED", - POWER_LEVEL_NOT_SUPPORTED = "POWER_LEVEL_NOT_SUPPORTED", - PREHEAT_REQUIRED = "PREHEAT_REQUIRED", - PROBE_REQUIRED = "PROBE_REQUIRED", - RATE_LIMIT_EXCEEDED = "RATE_LIMIT_EXCEEDED", - REMOTE_START_NOT_SUPPORTED = "REMOTE_START_NOT_SUPPORTED", - REMOVE_PROBE = "REMOVE_PROBE", - REMOTE_START_DISABLED = "REMOTE_START_DISABLED", - REQUESTED_SETPOINTS_TOO_CLOSE = "REQUESTED_SETPOINTS_TOO_CLOSE", - SAFETY_BEAM_BREACHED = "SAFETY_BEAM_BREACHED", - SUBSCRIPTION_REQUIRED = "SUBSCRIPTION_REQUIRED", - TEMPERATURE_VALUE_OUT_OF_RANGE = "TEMPERATURE_VALUE_OUT_OF_RANGE", - THERMOSTAT_IS_OFF = "THERMOSTAT_IS_OFF", - TOO_MANY_FAILED_ATTEMPTS = "TOO_MANY_FAILED_ATTEMPTS", - TRIPLE_SETPOINTS_UNSUPPORTED = "TRIPLE_SETPOINTS_UNSUPPORTED", - UNABLE_TO_CHARGE = "UNABLE_TO_CHARGE", - UNAUTHORIZED = "UNAUTHORIZED", - UNCLEARED_ALARM = "UNCLEARED_ALARM", - UNSUPPORTED_THERMOSTAT_MODE = "UNSUPPORTED_THERMOSTAT_MODE", - UNCLEARED_TROUBLE = "UNCLEARED_TROUBLE", - UNWILLING_TO_SET_SCHEDULE = "UNWILLING_TO_SET_SCHEDULE", - UNWILLING_TO_SET_VALUE = "UNWILLING_TO_SET_VALUE", - VALUE_OUT_OF_RANGE = "VALUE_OUT_OF_RANGE" -} -export declare class AlexaErrorResponse { - private event; - private rootTopic; - private endpointId; - private mqttClient; - /** Where a failed publish is reported (set by the Device that built this message, 1.5.2): send() never rejects. */ - onPublishError?: (err: Error) => void; - constructor(correlationToken: string, rootTopic: string, endpointId: string, mqttClient: MqttClient); - setErrorMessage(type: string, message: string, otherParams?: Record): void; - toJSON(): { - event: any; - }; - send(sendAsync?: boolean): Promise; -} diff --git a/dist/esm/AlexaErrorResponse.js b/dist/esm/AlexaErrorResponse.js deleted file mode 100644 index 7521368..0000000 --- a/dist/esm/AlexaErrorResponse.js +++ /dev/null @@ -1,125 +0,0 @@ -import { randomUUID } from "crypto"; -export var AlexaErrorType; -(function (AlexaErrorType) { - AlexaErrorType["ALREADY_IN_OPERATION"] = "ALREADY_IN_OPERATION"; - AlexaErrorType["AUTHORIZATION_REQUIRED"] = "AUTHORIZATION_REQUIRED"; - AlexaErrorType["BRIDGE_UNREACHABLE"] = "BRIDGE_UNREACHABLE"; - AlexaErrorType["BYPASS_NEEDED"] = "BYPASS_NEEDED"; - AlexaErrorType["CLOUD_CONTROL_DISABLED"] = "CLOUD_CONTROL_DISABLED"; - AlexaErrorType["CHILD_LOCK"] = "CHILD_LOCK"; - AlexaErrorType["CONFIGURATION_UPDATE_NOT_ALLOWED"] = "CONFIGURATION_UPDATE_NOT_ALLOWED"; - AlexaErrorType["COOK_DURATION_TOO_LONG"] = "COOK_DURATION_TOO_LONG"; - AlexaErrorType["COOLING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE"] = "COOLING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE"; - AlexaErrorType["COOLING_STAGES_EXCEEDS_LIMIT"] = "COOLING_STAGES_EXCEEDS_LIMIT"; - AlexaErrorType["DATA_DELETION_NOT_SUPPORTED"] = "DATA_DELETION_NOT_SUPPORTED"; - AlexaErrorType["DATA_RETRIEVAL_NOT_SUPPORTED"] = "DATA_RETRIEVAL_NOT_SUPPORTED"; - AlexaErrorType["DEVICE_STUCK"] = "DEVICE_STUCK"; - AlexaErrorType["DISABLED_BY_USER"] = "DISABLED_BY_USER"; - AlexaErrorType["DO_NOT_DISTURB_MODE"] = "DO_NOT_DISTURB_MODE"; - AlexaErrorType["DOOR_CLOSED_TOO_LONG"] = "DOOR_CLOSED_TOO_LONG"; - AlexaErrorType["DOOR_OPEN"] = "DOOR_OPEN"; - AlexaErrorType["DUAL_SETPOINTS_UNSUPPORTED"] = "DUAL_SETPOINTS_UNSUPPORTED"; - AlexaErrorType["ENDPOINT_BUSY"] = "ENDPOINT_BUSY"; - AlexaErrorType["ENDPOINT_CONTROL_UNAVAILABLE"] = "ENDPOINT_CONTROL_UNAVAILABLE"; - AlexaErrorType["ENDPOINT_LOW_POWER"] = "ENDPOINT_LOW_POWER"; - AlexaErrorType["ENDPOINT_UNREACHABLE"] = "ENDPOINT_UNREACHABLE"; - AlexaErrorType["EXCEEDED_PIN_ATTEMPTS"] = "EXCEEDED_PIN_ATTEMPTS"; - AlexaErrorType["EXPIRED_AUTHORIZATION_CREDENTIAL"] = "EXPIRED_AUTHORIZATION_CREDENTIAL"; - AlexaErrorType["FAILED_TO_BOOTSTRAP_COMMISSIONING_PROCESS"] = "FAILED_TO_BOOTSTRAP_COMMISSIONING_PROCESS"; - AlexaErrorType["FIRMWARE_OUT_OF_DATE"] = "FIRMWARE_OUT_OF_DATE"; - AlexaErrorType["HARDWARE_MALFUNCTION"] = "HARDWARE_MALFUNCTION"; - AlexaErrorType["HEATING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE"] = "HEATING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE"; - AlexaErrorType["HEATING_STAGES_EXCEEDS_LIMIT"] = "HEATING_STAGES_EXCEEDS_LIMIT"; - AlexaErrorType["INSUFFICIENT_PERMISSIONS"] = "INSUFFICIENT_PERMISSIONS"; - AlexaErrorType["INSUFFICIENT_RESOURCE"] = "INSUFFICIENT_RESOURCE"; - AlexaErrorType["INSUFFICIENT_SPACE"] = "INSUFFICIENT_SPACE"; - AlexaErrorType["INTERNAL_ERROR"] = "INTERNAL_ERROR"; - AlexaErrorType["INVALID_AUTHORIZATION_CREDENTIAL"] = "INVALID_AUTHORIZATION_CREDENTIAL"; - AlexaErrorType["INVALID_AUXILIARY_HEATING_SYSTEM_TYPE"] = "INVALID_AUXILIARY_HEATING_SYSTEM_TYPE"; - AlexaErrorType["INVALID_DIRECTIVE"] = "INVALID_DIRECTIVE"; - AlexaErrorType["INVALID_SYSTEM_TYPE"] = "INVALID_SYSTEM_TYPE"; - AlexaErrorType["INVALID_TARGET_STATE"] = "INVALID_TARGET_STATE"; - AlexaErrorType["INVALID_TEMPERATURE_SCALE"] = "INVALID_TEMPERATURE_SCALE"; - AlexaErrorType["INVALID_TERMINAL_CONNECTION"] = "INVALID_TERMINAL_CONNECTION"; - AlexaErrorType["INVALID_VALUE"] = "INVALID_VALUE"; - AlexaErrorType["MAINTENANCE_REQUIRED"] = "MAINTENANCE_REQUIRED"; - AlexaErrorType["MAX_COMMISSIONING_LIMIT_REACHED"] = "MAX_COMMISSIONING_LIMIT_REACHED"; - AlexaErrorType["MISSING_SETUP_INFORMATION"] = "MISSING_SETUP_INFORMATION"; - AlexaErrorType["NO_SUCH_ENDPOINT"] = "NO_SUCH_ENDPOINT"; - AlexaErrorType["NOT_CALIBRATED"] = "NOT_CALIBRATED"; - AlexaErrorType["NOT_IN_OPERATION"] = "NOT_IN_OPERATION"; - AlexaErrorType["NOT_READY"] = "NOT_READY"; - AlexaErrorType["NOT_SUPPORTED_IN_CURRENT_MODE"] = "NOT_SUPPORTED_IN_CURRENT_MODE"; - AlexaErrorType["NOT_SUPPORTED_WITH_CURRENT_BATTERY_CHARGE_STATE"] = "NOT_SUPPORTED_WITH_CURRENT_BATTERY_CHARGE_STATE"; - AlexaErrorType["OBSTACLE_DETECTED"] = "OBSTACLE_DETECTED"; - AlexaErrorType["PARTNER_APPLICATION_REDIRECTION"] = "PARTNER_APPLICATION_REDIRECTION"; - AlexaErrorType["PIN_SETUP_REQUIRED"] = "PIN_SETUP_REQUIRED"; - AlexaErrorType["POWER_LEVEL_NOT_SUPPORTED"] = "POWER_LEVEL_NOT_SUPPORTED"; - AlexaErrorType["PREHEAT_REQUIRED"] = "PREHEAT_REQUIRED"; - AlexaErrorType["PROBE_REQUIRED"] = "PROBE_REQUIRED"; - AlexaErrorType["RATE_LIMIT_EXCEEDED"] = "RATE_LIMIT_EXCEEDED"; - AlexaErrorType["REMOTE_START_NOT_SUPPORTED"] = "REMOTE_START_NOT_SUPPORTED"; - AlexaErrorType["REMOVE_PROBE"] = "REMOVE_PROBE"; - AlexaErrorType["REMOTE_START_DISABLED"] = "REMOTE_START_DISABLED"; - AlexaErrorType["REQUESTED_SETPOINTS_TOO_CLOSE"] = "REQUESTED_SETPOINTS_TOO_CLOSE"; - AlexaErrorType["SAFETY_BEAM_BREACHED"] = "SAFETY_BEAM_BREACHED"; - AlexaErrorType["SUBSCRIPTION_REQUIRED"] = "SUBSCRIPTION_REQUIRED"; - AlexaErrorType["TEMPERATURE_VALUE_OUT_OF_RANGE"] = "TEMPERATURE_VALUE_OUT_OF_RANGE"; - AlexaErrorType["THERMOSTAT_IS_OFF"] = "THERMOSTAT_IS_OFF"; - AlexaErrorType["TOO_MANY_FAILED_ATTEMPTS"] = "TOO_MANY_FAILED_ATTEMPTS"; - AlexaErrorType["TRIPLE_SETPOINTS_UNSUPPORTED"] = "TRIPLE_SETPOINTS_UNSUPPORTED"; - AlexaErrorType["UNABLE_TO_CHARGE"] = "UNABLE_TO_CHARGE"; - AlexaErrorType["UNAUTHORIZED"] = "UNAUTHORIZED"; - AlexaErrorType["UNCLEARED_ALARM"] = "UNCLEARED_ALARM"; - AlexaErrorType["UNSUPPORTED_THERMOSTAT_MODE"] = "UNSUPPORTED_THERMOSTAT_MODE"; - AlexaErrorType["UNCLEARED_TROUBLE"] = "UNCLEARED_TROUBLE"; - AlexaErrorType["UNWILLING_TO_SET_SCHEDULE"] = "UNWILLING_TO_SET_SCHEDULE"; - AlexaErrorType["UNWILLING_TO_SET_VALUE"] = "UNWILLING_TO_SET_VALUE"; - AlexaErrorType["VALUE_OUT_OF_RANGE"] = "VALUE_OUT_OF_RANGE"; -})(AlexaErrorType || (AlexaErrorType = {})); -export class AlexaErrorResponse { - constructor(correlationToken, rootTopic, endpointId, mqttClient) { - this.rootTopic = rootTopic; - this.endpointId = endpointId; - this.mqttClient = mqttClient; - this.event = { - header: { - namespace: "Alexa", - name: "ErrorResponse", - messageId: randomUUID(), - correlationToken, - payloadVersion: "3", - }, - endpoint: { - endpointId, - }, - payload: {}, - }; - } - setErrorMessage(type, message, otherParams = {}) { - this.event.payload = { - type, - message, - ...otherParams, - }; - } - toJSON() { - return { event: this.event }; - } - send(sendAsync = false) { - const payload = { - event: this.event, - }; - 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); - return new Promise((resolve) => { - this.mqttClient.publish(topic, payloadStr, (err) => { - if (!err) - return resolve(topic); - 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 - }); - }); - } -} diff --git a/dist/esm/compat/AlexaErrorResponse.d.ts b/dist/esm/compat/AlexaErrorResponse.d.ts new file mode 100644 index 0000000..389b6cc --- /dev/null +++ b/dist/esm/compat/AlexaErrorResponse.d.ts @@ -0,0 +1,26 @@ +import type { MqttClient } from "mqtt"; +import type { ErrorResponseMessage } from "../messages/types.js"; +/** An ErrorResponse as 1.x builds it: device.getErrorMessage(token), setErrorMessage(), send(). */ +export declare class AlexaErrorResponse { + private readonly correlationToken; + private readonly rootTopic; + private readonly endpointId; + private readonly mqttClient; + private error; + private namespace?; + private readonly messageId; + /** Where a failed publish is reported (set by the Device that built this message, 1.5.2): send() never rejects. */ + onPublishError?: (err: Error) => void; + constructor(correlationToken: string, rootTopic: string, endpointId: string, mqttClient: MqttClient); + /** + * The error to answer with. otherParams are the fields the type adds to the payload (validRange). The header + * carries the namespace the type is documented under, Alexa.ThermostatController for THERMOSTAT_IS_OFF; + * options.namespace names another. + */ + setErrorMessage(type: string, message: string, otherParams?: Record, options?: { + namespace?: string; + }): void; + toJSON(): ErrorResponseMessage; + /** Resolves with the topic published to, or "" when the publish failed. Never rejects. */ + send(sendAsync?: boolean): Promise; +} diff --git a/dist/esm/compat/AlexaErrorResponse.js b/dist/esm/compat/AlexaErrorResponse.js new file mode 100644 index 0000000..84cb1eb --- /dev/null +++ b/dist/esm/compat/AlexaErrorResponse.js @@ -0,0 +1,50 @@ +import { randomUUID } from "crypto"; +import { errorResponse } from "../messages/build.js"; +import { AlexaErrors } from "../messages/errors.js"; +/** An ErrorResponse as 1.x builds it: device.getErrorMessage(token), setErrorMessage(), send(). */ +export class AlexaErrorResponse { + constructor(correlationToken, rootTopic, endpointId, mqttClient) { + this.correlationToken = correlationToken; + this.rootTopic = rootTopic; + this.endpointId = endpointId; + this.mqttClient = mqttClient; + // Sent when setErrorMessage() was not called: 1.x published an empty payload, which is no ErrorResponse to Alexa + this.error = AlexaErrors.of("INTERNAL_ERROR", "the device answered with an error and did not say which"); + // One id for the message, however often toJSON() is called + this.messageId = randomUUID(); + } + /** + * The error to answer with. otherParams are the fields the type adds to the payload (validRange). The header + * carries the namespace the type is documented under, Alexa.ThermostatController for THERMOSTAT_IS_OFF; + * options.namespace names another. + */ + setErrorMessage(type, message, otherParams = {}, options = {}) { + this.error = AlexaErrors.of(type, message, otherParams); + this.namespace = options.namespace; + } + toJSON() { + const { type, alexaMessage, extra } = this.error; + return errorResponse({ + endpointId: this.endpointId, + messageId: this.messageId, + correlationToken: this.correlationToken, + type, + message: alexaMessage, + extra, + namespace: this.namespace ?? this.error.namespace, + }); + } + /** Resolves with the topic published to, or "" when the publish failed. Never rejects. */ + send(sendAsync = false) { + const topic = `${this.rootTopic}/${this.endpointId}/${sendAsync ? "deferredResponse" : "alexaResponce"}`; // "alexaResponce" is how Alex2MQTT and Alex2ESP spell the topic + return new Promise((resolve) => { + this.mqttClient.publish(topic, JSON.stringify(this.toJSON()), (err) => { + if (!err) + return resolve(topic); + 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 + }); + }); + } +} diff --git a/dist/esm/AlexaStatusMessage.d.ts b/dist/esm/compat/AlexaStatusMessage.d.ts similarity index 60% rename from dist/esm/AlexaStatusMessage.d.ts rename to dist/esm/compat/AlexaStatusMessage.d.ts index 3dbb621..f4dee17 100644 --- a/dist/esm/AlexaStatusMessage.d.ts +++ b/dist/esm/compat/AlexaStatusMessage.d.ts @@ -1,42 +1,25 @@ -import type { EndpointHealth, PowerController } from "./compat/enums.js"; import type { MqttClient } from "mqtt"; -export declare enum ThermostatMode { - OFF = "OFF", - HEAT = "HEAT", - COOL = "COOL", - AUTO = "AUTO", - ECO = "ECO", - CUSTOM = "CUSTOM" -} -export declare enum TemperatureSensorScale { - CELSIUS = "CELSIUS", - FAHRENHEIT = "FAHRENHEIT" -} -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"; +import type { ChangeCause, Property } from "../messages/types.js"; +import { TemperatureSensorScale } from "./enums.js"; +import type { EndpointHealth, PowerController } from "./enums.js"; +/** + * 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 declare class AlexaStatusMessage { - private context; - /** ChangeReport only: the properties that changed (payload.change.properties); the rest go to context. */ - private changeProps; - private changeCause; - private target; - private event; - private rootTopic; - private endpointId; - private mqttClient; - private isDeferred; + private readonly correlationToken; + private readonly rootTopic; + private readonly endpointId; + private readonly mqttClient; + private readonly isResponse; + private readonly isDeferred; + private readonly changeCause; + private readonly state; + private readonly messageId; + private estimatedDeferralInSeconds?; /** Where a failed publish is reported (set by the Device that built this message, 1.5.2): send() never rejects. */ onPublishError?: (err: Error) => void; constructor(correlationToken: string, rootTopic: string, endpointId: string, mqttClient: MqttClient, isResponse?: boolean, isDeferred?: boolean, changeCause?: ChangeCause | null); - private getTimestamp; private addProperty; /** ChangeReport: the add*Prop calls that follow describe what CHANGED (the default for a change report). */ changed(): this; @@ -44,12 +27,15 @@ export declare class AlexaStatusMessage { unchanged(): this; /** True for a ChangeReport (Device.getChangeReport). */ isChangeReport(): boolean; - /** The message as it will be published (for tests and logging). */ + /** + * 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(): { event: any; - context: { - properties: ContextProperty[]; - } | null; + context?: { + properties: Property[]; + }; }; addModeControllerProp(instance: string, value: string, uncertaintyInMs?: number): this; addThermostatModeProp(mode: string, uncertaintyInMs?: number): this; @@ -62,13 +48,14 @@ export declare class AlexaStatusMessage { addBrightnessControllerProp(brightness: number, uncertaintyInMs?: number): this; addColorTemperatureControllerProp(colorTemp: number, uncertaintyInMs?: number): this; addToggleControllerProp(state: PowerController, instance: string, uncertaintyInMs?: number): this; - addContextProp(prop: ContextProperty): this; + /** A property built by the caller. It goes to the context, also in a ChangeReport after changed(). */ + addContextProp(prop: Property): 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 the publish failed - 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). + * 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?: boolean): Promise; } -export {}; diff --git a/dist/esm/AlexaStatusMessage.js b/dist/esm/compat/AlexaStatusMessage.js similarity index 52% rename from dist/esm/AlexaStatusMessage.js rename to dist/esm/compat/AlexaStatusMessage.js index 9dddc1f..75e1f4c 100644 --- a/dist/esm/AlexaStatusMessage.js +++ b/dist/esm/compat/AlexaStatusMessage.js @@ -1,92 +1,60 @@ import { randomUUID } from "crypto"; -import { AlexaInterfaceType } from "./compat/enums.js"; -export var ThermostatMode; -(function (ThermostatMode) { - ThermostatMode["OFF"] = "OFF"; - ThermostatMode["HEAT"] = "HEAT"; - ThermostatMode["COOL"] = "COOL"; - ThermostatMode["AUTO"] = "AUTO"; - ThermostatMode["ECO"] = "ECO"; - ThermostatMode["CUSTOM"] = "CUSTOM"; -})(ThermostatMode || (ThermostatMode = {})); -export var TemperatureSensorScale; -(function (TemperatureSensorScale) { - TemperatureSensorScale["CELSIUS"] = "CELSIUS"; - TemperatureSensorScale["FAHRENHEIT"] = "FAHRENHEIT"; -})(TemperatureSensorScale || (TemperatureSensorScale = {})); +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.context = { properties: [] }; - /** ChangeReport only: the properties that changed (payload.change.properties); the rest go to context. */ - this.changeProps = []; - this.changeCause = null; - this.target = "context"; + this.correlationToken = correlationToken; this.rootTopic = rootTopic; this.endpointId = endpointId; this.mqttClient = mqttClient; + this.isResponse = isResponse; 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: randomUUID(), - correlationToken, - }, - endpoint: { - endpointId, - }, - payload: {}, - }; - } - getTimestamp() { - return new Date().toISOString(); + // 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) { - const prop = { - namespace, - name, - value, - timeOfSample: this.getTimestamp(), - uncertaintyInMilliseconds, - }; - if (instance) - prop.instance = instance; - (this.target === "change" ? this.changeProps : this.context.properties).push(prop); + 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.target = "change"; + this.state.changed(); return this; } /** ChangeReport: the add*Prop calls that follow describe the other, unchanged properties (context). */ unchanged() { - this.target = "context"; + 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). */ + /** + * 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 { - event: { ...this.event, payload: { change: { cause: { type: this.changeCause }, properties: this.changeProps } } }, - context: this.context, - }; + return changeReport({ endpointId, messageId, cause: this.changeCause, changed: this.state.change, context: this.state.context }); } - return { context: this.isDeferred ? null : this.context, event: this.event }; + 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); @@ -96,9 +64,7 @@ export class AlexaStatusMessage { } addEstimatedDeferralTime(seconds) { if (this.isDeferred) { - this.event.payload = { - estimatedDeferralInSeconds: seconds, - }; + this.estimatedDeferralInSeconds = seconds; } else { console.warn("[AlexaStatusMessage.ts] Attempted to add estimated deferral time, but message is not marked as DeferredResponse."); @@ -106,13 +72,7 @@ export class AlexaStatusMessage { return this; } addThermostatControllerProp(name, scale, value, uncertaintyInMs = 0) { - const tempValue = { - value: scale === TemperatureSensorScale.FAHRENHEIT - ? (value - 32) * (5 / 9) - : value, - scale: "CELSIUS", - }; - return this.addProperty(AlexaInterfaceType.THERMOSTAT_CONTROLLER, name, tempValue, uncertaintyInMs); + 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) { @@ -122,13 +82,7 @@ export class AlexaStatusMessage { return this.addProperty(AlexaInterfaceType.POWER_CONTROLLER, "powerState", power, uncertaintyInMs); } addTemperatureSensorProp(scale, value, uncertaintyInMs = 0) { - const tempValue = { - value: scale === TemperatureSensorScale.FAHRENHEIT - ? (value - 32) * (5 / 9) - : value, - scale: "CELSIUS", - }; - return this.addProperty(AlexaInterfaceType.TEMPERATURE_SENSOR, "temperature", tempValue, uncertaintyInMs); + return this.addProperty(AlexaInterfaceType.TEMPERATURE_SENSOR, "temperature", celsius(scale, value), uncertaintyInMs); } addBrightnessControllerProp(brightness, uncertaintyInMs = 0) { return this.addProperty(AlexaInterfaceType.BRIGHTNESS_CONTROLLER, "brightness", brightness, uncertaintyInMs); @@ -139,29 +93,36 @@ export class AlexaStatusMessage { 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.context.properties.push(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 the publish failed - 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). + * 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 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 + : `${this.rootTopic}/${this.endpointId}/${sendAsync ? "deferredResponse" : "alexaResponce"}`; // "alexaResponce" is how Alex2MQTT and Alex2ESP spell the topic return new Promise((resolve) => { - this.mqttClient.publish(topic, payloadStr, (err) => { - if (!err) - return resolve(topic); + 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))); }); } } diff --git a/dist/esm/compat/enums.d.ts b/dist/esm/compat/enums.d.ts index 71a3f17..e253d48 100644 --- a/dist/esm/compat/enums.d.ts +++ b/dist/esm/compat/enums.d.ts @@ -181,7 +181,7 @@ export declare const EndpointHealth: import("../index.js").InterfaceDescriptor<{ name: string; value: import("../index.js").Schema; - reason: import("../registry/schema.js").OptionalSchema<"WIFI_BAD_PASSWORD" | "WIFI_AP_NOT_FOUND" | "WIFI_ROUTER_UNREACHABLE" | "WIFI_AP_CHANNEL_QUALITY_LOW" | "INTERNET_UNREACHABLE" | "CAPTIVE_PORTAL_CHECK_FAILED" | "UNKNOWN">; + reason: import("../registry/schema.js").OptionalSchema<"UNKNOWN" | "WIFI_BAD_PASSWORD" | "WIFI_AP_NOT_FOUND" | "WIFI_ROUTER_UNREACHABLE" | "WIFI_AP_CHANNEL_QUALITY_LOW" | "INTERNET_UNREACHABLE" | "CAPTIVE_PORTAL_CHECK_FAILED">; }>>; note: string; }; @@ -190,4 +190,96 @@ export declare const EndpointHealth: import("../index.js").InterfaceDescriptor<{ readonly UNREACHABLE: "UNREACHABLE"; }; export type EndpointHealth = (typeof Connectivity)[keyof typeof Connectivity]; +/** The modes of a thermostat the 1.x helpers know. */ +export declare enum ThermostatMode { + OFF = "OFF", + HEAT = "HEAT", + COOL = "COOL", + AUTO = "AUTO", + ECO = "ECO", + CUSTOM = "CUSTOM" +} +/** The scale of a temperature given to addTemperatureSensorProp() and addThermostatControllerProp(). */ +export declare enum TemperatureSensorScale { + CELSIUS = "CELSIUS", + FAHRENHEIT = "FAHRENHEIT" +} +/** The error types of alexa-errorresponse.html, "Error type values". */ +export declare enum AlexaErrorType { + ALREADY_IN_OPERATION = "ALREADY_IN_OPERATION", + AUTHORIZATION_REQUIRED = "AUTHORIZATION_REQUIRED", + BRIDGE_UNREACHABLE = "BRIDGE_UNREACHABLE", + BYPASS_NEEDED = "BYPASS_NEEDED", + CLOUD_CONTROL_DISABLED = "CLOUD_CONTROL_DISABLED", + CHILD_LOCK = "CHILD_LOCK", + CONFIGURATION_UPDATE_NOT_ALLOWED = "CONFIGURATION_UPDATE_NOT_ALLOWED", + COOK_DURATION_TOO_LONG = "COOK_DURATION_TOO_LONG", + COOLING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE = "COOLING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE", + COOLING_STAGES_EXCEEDS_LIMIT = "COOLING_STAGES_EXCEEDS_LIMIT", + DATA_DELETION_NOT_SUPPORTED = "DATA_DELETION_NOT_SUPPORTED", + DATA_RETRIEVAL_NOT_SUPPORTED = "DATA_RETRIEVAL_NOT_SUPPORTED", + DEVICE_STUCK = "DEVICE_STUCK", + DISABLED_BY_USER = "DISABLED_BY_USER", + DO_NOT_DISTURB_MODE = "DO_NOT_DISTURB_MODE", + DOOR_CLOSED_TOO_LONG = "DOOR_CLOSED_TOO_LONG", + DOOR_OPEN = "DOOR_OPEN", + DUAL_SETPOINTS_UNSUPPORTED = "DUAL_SETPOINTS_UNSUPPORTED", + ENDPOINT_BUSY = "ENDPOINT_BUSY", + ENDPOINT_CONTROL_UNAVAILABLE = "ENDPOINT_CONTROL_UNAVAILABLE", + ENDPOINT_LOW_POWER = "ENDPOINT_LOW_POWER", + ENDPOINT_UNREACHABLE = "ENDPOINT_UNREACHABLE", + EXCEEDED_PIN_ATTEMPTS = "EXCEEDED_PIN_ATTEMPTS", + EXPIRED_AUTHORIZATION_CREDENTIAL = "EXPIRED_AUTHORIZATION_CREDENTIAL", + FAILED_TO_BOOTSTRAP_COMMISSIONING_PROCESS = "FAILED_TO_BOOTSTRAP_COMMISSIONING_PROCESS", + FIRMWARE_OUT_OF_DATE = "FIRMWARE_OUT_OF_DATE", + HARDWARE_MALFUNCTION = "HARDWARE_MALFUNCTION", + HEATING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE = "HEATING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE", + HEATING_STAGES_EXCEEDS_LIMIT = "HEATING_STAGES_EXCEEDS_LIMIT", + INSUFFICIENT_PERMISSIONS = "INSUFFICIENT_PERMISSIONS", + INSUFFICIENT_RESOURCE = "INSUFFICIENT_RESOURCE", + INSUFFICIENT_SPACE = "INSUFFICIENT_SPACE", + INTERNAL_ERROR = "INTERNAL_ERROR", + INVALID_AUTHORIZATION_CREDENTIAL = "INVALID_AUTHORIZATION_CREDENTIAL", + INVALID_AUXILIARY_HEATING_SYSTEM_TYPE = "INVALID_AUXILIARY_HEATING_SYSTEM_TYPE", + INVALID_DIRECTIVE = "INVALID_DIRECTIVE", + INVALID_SYSTEM_TYPE = "INVALID_SYSTEM_TYPE", + INVALID_TARGET_STATE = "INVALID_TARGET_STATE", + INVALID_TEMPERATURE_SCALE = "INVALID_TEMPERATURE_SCALE", + INVALID_TERMINAL_CONNECTION = "INVALID_TERMINAL_CONNECTION", + INVALID_VALUE = "INVALID_VALUE", + MAINTENANCE_REQUIRED = "MAINTENANCE_REQUIRED", + MAX_COMMISSIONING_LIMIT_REACHED = "MAX_COMMISSIONING_LIMIT_REACHED", + MISSING_SETUP_INFORMATION = "MISSING_SETUP_INFORMATION", + NO_SUCH_ENDPOINT = "NO_SUCH_ENDPOINT", + NOT_CALIBRATED = "NOT_CALIBRATED", + NOT_IN_OPERATION = "NOT_IN_OPERATION", + NOT_READY = "NOT_READY", + NOT_SUPPORTED_IN_CURRENT_MODE = "NOT_SUPPORTED_IN_CURRENT_MODE", + NOT_SUPPORTED_WITH_CURRENT_BATTERY_CHARGE_STATE = "NOT_SUPPORTED_WITH_CURRENT_BATTERY_CHARGE_STATE", + OBSTACLE_DETECTED = "OBSTACLE_DETECTED", + PARTNER_APPLICATION_REDIRECTION = "PARTNER_APPLICATION_REDIRECTION", + PIN_SETUP_REQUIRED = "PIN_SETUP_REQUIRED", + POWER_LEVEL_NOT_SUPPORTED = "POWER_LEVEL_NOT_SUPPORTED", + PREHEAT_REQUIRED = "PREHEAT_REQUIRED", + PROBE_REQUIRED = "PROBE_REQUIRED", + RATE_LIMIT_EXCEEDED = "RATE_LIMIT_EXCEEDED", + REMOTE_START_NOT_SUPPORTED = "REMOTE_START_NOT_SUPPORTED", + REMOVE_PROBE = "REMOVE_PROBE", + REMOTE_START_DISABLED = "REMOTE_START_DISABLED", + REQUESTED_SETPOINTS_TOO_CLOSE = "REQUESTED_SETPOINTS_TOO_CLOSE", + SAFETY_BEAM_BREACHED = "SAFETY_BEAM_BREACHED", + SUBSCRIPTION_REQUIRED = "SUBSCRIPTION_REQUIRED", + TEMPERATURE_VALUE_OUT_OF_RANGE = "TEMPERATURE_VALUE_OUT_OF_RANGE", + THERMOSTAT_IS_OFF = "THERMOSTAT_IS_OFF", + TOO_MANY_FAILED_ATTEMPTS = "TOO_MANY_FAILED_ATTEMPTS", + TRIPLE_SETPOINTS_UNSUPPORTED = "TRIPLE_SETPOINTS_UNSUPPORTED", + UNABLE_TO_CHARGE = "UNABLE_TO_CHARGE", + UNAUTHORIZED = "UNAUTHORIZED", + UNCLEARED_ALARM = "UNCLEARED_ALARM", + UNSUPPORTED_THERMOSTAT_MODE = "UNSUPPORTED_THERMOSTAT_MODE", + UNCLEARED_TROUBLE = "UNCLEARED_TROUBLE", + UNWILLING_TO_SET_SCHEDULE = "UNWILLING_TO_SET_SCHEDULE", + UNWILLING_TO_SET_VALUE = "UNWILLING_TO_SET_VALUE", + VALUE_OUT_OF_RANGE = "VALUE_OUT_OF_RANGE" +} export {}; diff --git a/dist/esm/compat/enums.js b/dist/esm/compat/enums.js index 68deaf2..d6e5278 100644 --- a/dist/esm/compat/enums.js +++ b/dist/esm/compat/enums.js @@ -158,3 +158,98 @@ const Connectivity = { OK: "OK", UNREACHABLE: "UNREACHABLE" }; export const PowerController = Object.assign(PowerControllerInterface, PowerState); /** The Alexa.EndpointHealth interface, for device.add(). EndpointHealth.OK and EndpointHealth.UNREACHABLE are the 1.x enum of connectivity values. */ export const EndpointHealth = Object.assign(EndpointHealthInterface, Connectivity); +/** The modes of a thermostat the 1.x helpers know. */ +export var ThermostatMode; +(function (ThermostatMode) { + ThermostatMode["OFF"] = "OFF"; + ThermostatMode["HEAT"] = "HEAT"; + ThermostatMode["COOL"] = "COOL"; + ThermostatMode["AUTO"] = "AUTO"; + ThermostatMode["ECO"] = "ECO"; + ThermostatMode["CUSTOM"] = "CUSTOM"; +})(ThermostatMode || (ThermostatMode = {})); +/** The scale of a temperature given to addTemperatureSensorProp() and addThermostatControllerProp(). */ +export var TemperatureSensorScale; +(function (TemperatureSensorScale) { + TemperatureSensorScale["CELSIUS"] = "CELSIUS"; + TemperatureSensorScale["FAHRENHEIT"] = "FAHRENHEIT"; +})(TemperatureSensorScale || (TemperatureSensorScale = {})); +/** The error types of alexa-errorresponse.html, "Error type values". */ +export var AlexaErrorType; +(function (AlexaErrorType) { + AlexaErrorType["ALREADY_IN_OPERATION"] = "ALREADY_IN_OPERATION"; + AlexaErrorType["AUTHORIZATION_REQUIRED"] = "AUTHORIZATION_REQUIRED"; + AlexaErrorType["BRIDGE_UNREACHABLE"] = "BRIDGE_UNREACHABLE"; + AlexaErrorType["BYPASS_NEEDED"] = "BYPASS_NEEDED"; + AlexaErrorType["CLOUD_CONTROL_DISABLED"] = "CLOUD_CONTROL_DISABLED"; + AlexaErrorType["CHILD_LOCK"] = "CHILD_LOCK"; + AlexaErrorType["CONFIGURATION_UPDATE_NOT_ALLOWED"] = "CONFIGURATION_UPDATE_NOT_ALLOWED"; + AlexaErrorType["COOK_DURATION_TOO_LONG"] = "COOK_DURATION_TOO_LONG"; + AlexaErrorType["COOLING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE"] = "COOLING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE"; + AlexaErrorType["COOLING_STAGES_EXCEEDS_LIMIT"] = "COOLING_STAGES_EXCEEDS_LIMIT"; + AlexaErrorType["DATA_DELETION_NOT_SUPPORTED"] = "DATA_DELETION_NOT_SUPPORTED"; + AlexaErrorType["DATA_RETRIEVAL_NOT_SUPPORTED"] = "DATA_RETRIEVAL_NOT_SUPPORTED"; + AlexaErrorType["DEVICE_STUCK"] = "DEVICE_STUCK"; + AlexaErrorType["DISABLED_BY_USER"] = "DISABLED_BY_USER"; + AlexaErrorType["DO_NOT_DISTURB_MODE"] = "DO_NOT_DISTURB_MODE"; + AlexaErrorType["DOOR_CLOSED_TOO_LONG"] = "DOOR_CLOSED_TOO_LONG"; + AlexaErrorType["DOOR_OPEN"] = "DOOR_OPEN"; + AlexaErrorType["DUAL_SETPOINTS_UNSUPPORTED"] = "DUAL_SETPOINTS_UNSUPPORTED"; + AlexaErrorType["ENDPOINT_BUSY"] = "ENDPOINT_BUSY"; + AlexaErrorType["ENDPOINT_CONTROL_UNAVAILABLE"] = "ENDPOINT_CONTROL_UNAVAILABLE"; + AlexaErrorType["ENDPOINT_LOW_POWER"] = "ENDPOINT_LOW_POWER"; + AlexaErrorType["ENDPOINT_UNREACHABLE"] = "ENDPOINT_UNREACHABLE"; + AlexaErrorType["EXCEEDED_PIN_ATTEMPTS"] = "EXCEEDED_PIN_ATTEMPTS"; + AlexaErrorType["EXPIRED_AUTHORIZATION_CREDENTIAL"] = "EXPIRED_AUTHORIZATION_CREDENTIAL"; + AlexaErrorType["FAILED_TO_BOOTSTRAP_COMMISSIONING_PROCESS"] = "FAILED_TO_BOOTSTRAP_COMMISSIONING_PROCESS"; + AlexaErrorType["FIRMWARE_OUT_OF_DATE"] = "FIRMWARE_OUT_OF_DATE"; + AlexaErrorType["HARDWARE_MALFUNCTION"] = "HARDWARE_MALFUNCTION"; + AlexaErrorType["HEATING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE"] = "HEATING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE"; + AlexaErrorType["HEATING_STAGES_EXCEEDS_LIMIT"] = "HEATING_STAGES_EXCEEDS_LIMIT"; + AlexaErrorType["INSUFFICIENT_PERMISSIONS"] = "INSUFFICIENT_PERMISSIONS"; + AlexaErrorType["INSUFFICIENT_RESOURCE"] = "INSUFFICIENT_RESOURCE"; + AlexaErrorType["INSUFFICIENT_SPACE"] = "INSUFFICIENT_SPACE"; + AlexaErrorType["INTERNAL_ERROR"] = "INTERNAL_ERROR"; + AlexaErrorType["INVALID_AUTHORIZATION_CREDENTIAL"] = "INVALID_AUTHORIZATION_CREDENTIAL"; + AlexaErrorType["INVALID_AUXILIARY_HEATING_SYSTEM_TYPE"] = "INVALID_AUXILIARY_HEATING_SYSTEM_TYPE"; + AlexaErrorType["INVALID_DIRECTIVE"] = "INVALID_DIRECTIVE"; + AlexaErrorType["INVALID_SYSTEM_TYPE"] = "INVALID_SYSTEM_TYPE"; + AlexaErrorType["INVALID_TARGET_STATE"] = "INVALID_TARGET_STATE"; + AlexaErrorType["INVALID_TEMPERATURE_SCALE"] = "INVALID_TEMPERATURE_SCALE"; + AlexaErrorType["INVALID_TERMINAL_CONNECTION"] = "INVALID_TERMINAL_CONNECTION"; + AlexaErrorType["INVALID_VALUE"] = "INVALID_VALUE"; + AlexaErrorType["MAINTENANCE_REQUIRED"] = "MAINTENANCE_REQUIRED"; + AlexaErrorType["MAX_COMMISSIONING_LIMIT_REACHED"] = "MAX_COMMISSIONING_LIMIT_REACHED"; + AlexaErrorType["MISSING_SETUP_INFORMATION"] = "MISSING_SETUP_INFORMATION"; + AlexaErrorType["NO_SUCH_ENDPOINT"] = "NO_SUCH_ENDPOINT"; + AlexaErrorType["NOT_CALIBRATED"] = "NOT_CALIBRATED"; + AlexaErrorType["NOT_IN_OPERATION"] = "NOT_IN_OPERATION"; + AlexaErrorType["NOT_READY"] = "NOT_READY"; + AlexaErrorType["NOT_SUPPORTED_IN_CURRENT_MODE"] = "NOT_SUPPORTED_IN_CURRENT_MODE"; + AlexaErrorType["NOT_SUPPORTED_WITH_CURRENT_BATTERY_CHARGE_STATE"] = "NOT_SUPPORTED_WITH_CURRENT_BATTERY_CHARGE_STATE"; + AlexaErrorType["OBSTACLE_DETECTED"] = "OBSTACLE_DETECTED"; + AlexaErrorType["PARTNER_APPLICATION_REDIRECTION"] = "PARTNER_APPLICATION_REDIRECTION"; + AlexaErrorType["PIN_SETUP_REQUIRED"] = "PIN_SETUP_REQUIRED"; + AlexaErrorType["POWER_LEVEL_NOT_SUPPORTED"] = "POWER_LEVEL_NOT_SUPPORTED"; + AlexaErrorType["PREHEAT_REQUIRED"] = "PREHEAT_REQUIRED"; + AlexaErrorType["PROBE_REQUIRED"] = "PROBE_REQUIRED"; + AlexaErrorType["RATE_LIMIT_EXCEEDED"] = "RATE_LIMIT_EXCEEDED"; + AlexaErrorType["REMOTE_START_NOT_SUPPORTED"] = "REMOTE_START_NOT_SUPPORTED"; + AlexaErrorType["REMOVE_PROBE"] = "REMOVE_PROBE"; + AlexaErrorType["REMOTE_START_DISABLED"] = "REMOTE_START_DISABLED"; + AlexaErrorType["REQUESTED_SETPOINTS_TOO_CLOSE"] = "REQUESTED_SETPOINTS_TOO_CLOSE"; + AlexaErrorType["SAFETY_BEAM_BREACHED"] = "SAFETY_BEAM_BREACHED"; + AlexaErrorType["SUBSCRIPTION_REQUIRED"] = "SUBSCRIPTION_REQUIRED"; + AlexaErrorType["TEMPERATURE_VALUE_OUT_OF_RANGE"] = "TEMPERATURE_VALUE_OUT_OF_RANGE"; + AlexaErrorType["THERMOSTAT_IS_OFF"] = "THERMOSTAT_IS_OFF"; + AlexaErrorType["TOO_MANY_FAILED_ATTEMPTS"] = "TOO_MANY_FAILED_ATTEMPTS"; + AlexaErrorType["TRIPLE_SETPOINTS_UNSUPPORTED"] = "TRIPLE_SETPOINTS_UNSUPPORTED"; + AlexaErrorType["UNABLE_TO_CHARGE"] = "UNABLE_TO_CHARGE"; + AlexaErrorType["UNAUTHORIZED"] = "UNAUTHORIZED"; + AlexaErrorType["UNCLEARED_ALARM"] = "UNCLEARED_ALARM"; + AlexaErrorType["UNSUPPORTED_THERMOSTAT_MODE"] = "UNSUPPORTED_THERMOSTAT_MODE"; + AlexaErrorType["UNCLEARED_TROUBLE"] = "UNCLEARED_TROUBLE"; + AlexaErrorType["UNWILLING_TO_SET_SCHEDULE"] = "UNWILLING_TO_SET_SCHEDULE"; + AlexaErrorType["UNWILLING_TO_SET_VALUE"] = "UNWILLING_TO_SET_VALUE"; + AlexaErrorType["VALUE_OUT_OF_RANGE"] = "VALUE_OUT_OF_RANGE"; +})(AlexaErrorType || (AlexaErrorType = {})); diff --git a/dist/esm/device/Device.d.ts b/dist/esm/device/Device.d.ts index e4f1c36..f404968 100644 --- a/dist/esm/device/Device.d.ts +++ b/dist/esm/device/Device.d.ts @@ -1,11 +1,11 @@ import { EventEmitter } from "events"; import type { MqttClient } from "mqtt"; -import { AlexaErrorResponse } from "../AlexaErrorResponse.js"; -import { AlexaStatusMessage } from "../AlexaStatusMessage.js"; -import type { ChangeCause } from "../AlexaStatusMessage.js"; +import { AlexaErrorResponse } from "../compat/AlexaErrorResponse.js"; import { AlexaInterface } from "../compat/AlexaInterface.js"; +import { AlexaStatusMessage } from "../compat/AlexaStatusMessage.js"; import { DisplayCategory } from "../compat/enums.js"; import type { AlexaInterfaceType } from "../compat/enums.js"; +import type { ChangeCause } from "../messages/types.js"; import type { DisplayCategoryName } from "../registry/catalog.js"; import type { Directives, InterfaceDescriptor, Properties } from "../registry/types.js"; import { Capability } from "./Capability.js"; @@ -83,6 +83,7 @@ declare class Device extends EventEmitter { * A proactive Alexa.ChangeReport (1.5.1): add the changed properties (the default target), optionally * .unchanged() then the others, and .send() - it goes to /changeReport, which Alex2MQTT forwards to the Alexa * event gateway with the user's token. Alexa only accepts it for capabilities registered with proactivelyReported. + * Without a changed property send() publishes nothing and resolves with "": Alex2MQTT would drop the report. */ getChangeReport(cause?: ChangeCause): AlexaStatusMessage; /** diff --git a/dist/esm/device/Device.js b/dist/esm/device/Device.js index 5cfb3e6..87f334c 100644 --- a/dist/esm/device/Device.js +++ b/dist/esm/device/Device.js @@ -1,9 +1,9 @@ import { EventEmitter } from "events"; -import { randomUUID } from "crypto"; -import { AlexaErrorResponse } from "../AlexaErrorResponse.js"; -import { AlexaStatusMessage } from "../AlexaStatusMessage.js"; +import { AlexaErrorResponse } from "../compat/AlexaErrorResponse.js"; import { AlexaInterface } from "../compat/AlexaInterface.js"; +import { AlexaStatusMessage } from "../compat/AlexaStatusMessage.js"; import { DisplayCategory } from "../compat/enums.js"; +import { sceneEvent } from "../messages/build.js"; import { Alexa } from "../registry/interfaces/Alexa.js"; import { EndpointHealth } from "../registry/interfaces/EndpointHealth.js"; import { SchemaError } from "../registry/schema.js"; @@ -77,6 +77,7 @@ class Device extends EventEmitter { * A proactive Alexa.ChangeReport (1.5.1): add the changed properties (the default target), optionally * .unchanged() then the others, and .send() - it goes to /changeReport, which Alex2MQTT forwards to the Alexa * event gateway with the user's token. Alexa only accepts it for capabilities registered with proactivelyReported. + * Without a changed property send() publishes nothing and resolves with "": Alex2MQTT would drop the report. */ getChangeReport(cause = "PHYSICAL_INTERACTION") { const msg = new AlexaStatusMessage("", this.rootTopic, this.endpointId, this.mqttClient, false, false, cause); @@ -88,14 +89,7 @@ class Device extends EventEmitter { * (1.5.1). Resolves with the topic published to, or "" when the publish failed (never rejects, 1.5.2). */ sendSceneResponse(correlationToken, activated, cause = "VOICE_INTERACTION", sendAsync = false) { - const payload = { - context: {}, - event: { - header: { namespace: "Alexa.SceneController", name: activated ? "ActivationStarted" : "DeactivationStarted", messageId: randomUUID(), correlationToken, payloadVersion: "3" }, - endpoint: { endpointId: this.endpointId }, - payload: { cause: { type: cause }, timestamp: new Date().toISOString() }, - }, - }; + const payload = sceneEvent({ endpointId: this.endpointId, correlationToken, activated, cause }); const topic = `${this.rootTopic}/${this.endpointId}/${sendAsync ? "deferredResponse" : "alexaResponce"}`; return new Promise((resolve) => { this.mqttClient.publish(topic, JSON.stringify(payload), (err) => { diff --git a/dist/esm/index.d.ts b/dist/esm/index.d.ts index 89506d4..8a97471 100644 --- a/dist/esm/index.d.ts +++ b/dist/esm/index.d.ts @@ -10,10 +10,12 @@ export { PowerController, EndpointHealth } from "./compat/enums.js"; export { asset, text, semantics, SemanticsBuilder } from "./registry/index.js"; export { ASSETS as Assets, UNITS_OF_MEASURE as Units, ACTIONS as Actions, STATES as States, DISPLAY_CATEGORIES as DisplayCategories, } from "./registry/index.js"; export type { ActionId, AssetId, DisplayCategoryName, StateId, UnitOfMeasure, ActionsToDirective, AnyDescriptor, CapabilityExtras, Declared, DirectiveDescriptor, EndpointView, EventDescriptor, InterfaceDescriptor, Label, PropertyDescriptor, Semantics, StatesToRange, StatesToValue, ActionName, StateName, Mode, Infer, Schema, Temperature, TimeInterval, } from "./registry/index.js"; +export * as messages from "./messages/index.js"; +export { AlexaError, AlexaErrors, MessageError, StateBuilder, property } from "./messages/index.js"; +export type { ChangeCause, ChangeReportMessage, DeferredResponseMessage, ErrorResponseMessage, Header, ProactiveEventMessage, Property, PropertyOptions, ResponseMessage, SceneEventMessage, } from "./messages/index.js"; export { AlexaInterface } from "./compat/AlexaInterface.js"; export type { SupportedMode } from "./compat/AlexaInterface.js"; export { ActionMapping } from "./compat/ActionMapping.js"; -export { AlexaActions, AlexaInterfaceType, DisplayCategory, PowerState } from "./compat/enums.js"; -export { AlexaStatusMessage, TemperatureSensorScale, ThermostatMode } from "./AlexaStatusMessage.js"; -export type { ChangeCause } from "./AlexaStatusMessage.js"; -export { AlexaErrorType, AlexaErrorResponse } from "./AlexaErrorResponse.js"; +export { AlexaActions, AlexaErrorType, AlexaInterfaceType, DisplayCategory, PowerState, TemperatureSensorScale, ThermostatMode, } from "./compat/enums.js"; +export { AlexaStatusMessage } from "./compat/AlexaStatusMessage.js"; +export { AlexaErrorResponse } from "./compat/AlexaErrorResponse.js"; diff --git a/dist/esm/index.js b/dist/esm/index.js index d9342f0..e522d0b 100644 --- a/dist/esm/index.js +++ b/dist/esm/index.js @@ -9,9 +9,12 @@ export { PowerController, EndpointHealth } from "./compat/enums.js"; export { asset, text, semantics, SemanticsBuilder } from "./registry/index.js"; // The vocabularies of the Smart Home API export { ASSETS as Assets, UNITS_OF_MEASURE as Units, ACTIONS as Actions, STATES as States, DISPLAY_CATEGORIES as DisplayCategories, } from "./registry/index.js"; +// The messages: what the bridge publishes, built from plain values +export * as messages from "./messages/index.js"; +export { AlexaError, AlexaErrors, MessageError, StateBuilder, property } from "./messages/index.js"; // 1.x export { AlexaInterface } from "./compat/AlexaInterface.js"; export { ActionMapping } from "./compat/ActionMapping.js"; -export { AlexaActions, AlexaInterfaceType, DisplayCategory, PowerState } from "./compat/enums.js"; -export { AlexaStatusMessage, TemperatureSensorScale, ThermostatMode } from "./AlexaStatusMessage.js"; -export { AlexaErrorType, AlexaErrorResponse } from "./AlexaErrorResponse.js"; +export { AlexaActions, AlexaErrorType, AlexaInterfaceType, DisplayCategory, PowerState, TemperatureSensorScale, ThermostatMode, } from "./compat/enums.js"; +export { AlexaStatusMessage } from "./compat/AlexaStatusMessage.js"; +export { AlexaErrorResponse } from "./compat/AlexaErrorResponse.js"; diff --git a/dist/esm/messages/StateBuilder.d.ts b/dist/esm/messages/StateBuilder.d.ts new file mode 100644 index 0000000..8584c30 --- /dev/null +++ b/dist/esm/messages/StateBuilder.d.ts @@ -0,0 +1,49 @@ +import { CONNECTIVITY_REASONS } from "../registry/interfaces/EndpointHealth.js"; +import type { Infer } from "../registry/schema.js"; +import type { InterfaceDescriptor, Properties } from "../registry/types.js"; +import type { PropertyOptions } from "./property.js"; +import type { Property } from "./types.js"; +/** Where a property goes in a ChangeReport: with the ones that changed, or with the rest in the context. */ +export type Target = "context" | "change"; +/** A capability of a device: its properties are reported under its instance. */ +interface DeclaredCapability

{ + readonly descriptor: InterfaceDescriptor; + readonly instance: string; +} +export interface StateBuilderOptions { + /** Where the properties go until changed() or unchanged() says otherwise. Default: "context". */ + target?: Target; + /** The time of a property that is given none. Default: the clock. */ + now?: () => Date; +} +/** + * Collects the properties of one message. For a Response or a StateReport they are its context. For a ChangeReport + * changed() and unchanged() say which of the two lists the properties that follow belong to. + */ +export declare class StateBuilder { + private readonly lists; + private target; + private readonly now; + constructor(options?: StateBuilderOptions); + /** + * A property of an interface the library describes, its value checked: set(PowerController, "powerState", "ON"). + * Given a capability of a device in place of the interface, the property carries the instance of the capability. + * Throws SchemaError for a value Alexa would not take. + */ + set

(source: InterfaceDescriptor | DeclaredCapability

, name: K, value: Infer, options?: PropertyOptions): this; + /** A property as given, nothing checked: for an interface or a value the library does not describe. */ + setRaw(namespace: string, name: string, value: unknown, options?: PropertyOptions): this; + /** The connectivity of Alexa.EndpointHealth, which belongs in every report of an endpoint that declares it. */ + health(value: "OK" | "UNREACHABLE", reason?: (typeof CONNECTIVITY_REASONS)[number], options?: PropertyOptions): this; + /** A property built elsewhere, to the list that is filled now or to the one named. */ + add(built: Property, target?: Target): this; + /** The properties that follow are the ones that changed. */ + changed(): this; + /** The properties that follow did not change: a ChangeReport lists them in its context. */ + unchanged(): this; + /** The properties for the context of the message, in the order they were set. */ + get context(): Property[]; + /** The properties for payload.change of a ChangeReport. */ + get change(): Property[]; +} +export {}; diff --git a/dist/esm/messages/StateBuilder.js b/dist/esm/messages/StateBuilder.js new file mode 100644 index 0000000..bd16636 --- /dev/null +++ b/dist/esm/messages/StateBuilder.js @@ -0,0 +1,66 @@ +import { EndpointHealth } from "../registry/interfaces/EndpointHealth.js"; +import { SchemaError } from "../registry/schema.js"; +import { property } from "./property.js"; +/** + * Collects the properties of one message. For a Response or a StateReport they are its context. For a ChangeReport + * changed() and unchanged() say which of the two lists the properties that follow belong to. + */ +export class StateBuilder { + constructor(options = {}) { + this.lists = { context: [], change: [] }; + this.target = options.target ?? "context"; + this.now = options.now ?? (() => new Date()); + } + /** + * A property of an interface the library describes, its value checked: set(PowerController, "powerState", "ON"). + * Given a capability of a device in place of the interface, the property carries the instance of the capability. + * Throws SchemaError for a value Alexa would not take. + */ + set(source, name, value, options = {}) { + const descriptor = "descriptor" in source ? source.descriptor : source; + const instance = options.instance ?? ("descriptor" in source ? source.instance : ""); + const where = `${descriptor.namespace}${instance ? ` "${instance}"` : ""}: ${name}`; + const described = descriptor.properties[name]; + if (!described) { + const names = Object.keys(descriptor.properties); + throw new SchemaError(where, names.length > 0 + ? `not a property of the interface, which has ${names.join(", ")}` + : "the library knows no property of the interface, report it with setRaw()"); + } + if (descriptor.instanced && !instance) { + throw new SchemaError(where, "the interface has instances, pass the one that is reported: { instance: \"Blind.Lift\" }"); + } + return this.setRaw(descriptor.namespace, described.name, described.value.parse(value, where), { ...options, instance }); + } + /** A property as given, nothing checked: for an interface or a value the library does not describe. */ + setRaw(namespace, name, value, options = {}) { + return this.add(property(namespace, name, value, { ...options, timeOfSample: options.timeOfSample ?? this.now() })); + } + /** The connectivity of Alexa.EndpointHealth, which belongs in every report of an endpoint that declares it. */ + health(value, reason, options = {}) { + return this.set(EndpointHealth, "connectivity", reason ? { value, reason } : { value }, options); + } + /** A property built elsewhere, to the list that is filled now or to the one named. */ + add(built, target = this.target) { + this.lists[target].push(built); + return this; + } + /** The properties that follow are the ones that changed. */ + changed() { + this.target = "change"; + return this; + } + /** The properties that follow did not change: a ChangeReport lists them in its context. */ + unchanged() { + this.target = "context"; + return this; + } + /** The properties for the context of the message, in the order they were set. */ + get context() { + return [...this.lists.context]; + } + /** The properties for payload.change of a ChangeReport. */ + get change() { + return [...this.lists.change]; + } +} diff --git a/dist/esm/messages/build.d.ts b/dist/esm/messages/build.d.ts new file mode 100644 index 0000000..3cc59b2 --- /dev/null +++ b/dist/esm/messages/build.d.ts @@ -0,0 +1,97 @@ +import type { ChangeCause, ChangeReportMessage, DeferredResponseMessage, ErrorResponseMessage, ProactiveEventMessage, Property, ResponseMessage, SceneEventMessage } from "./types.js"; +/** A message that would be dropped on its way to Alexa, refused where it is built. */ +export declare class MessageError extends Error { + readonly endpointId: string; + readonly problem: string; + constructor(endpointId: string, problem: string); +} +interface Envelope { + endpointId: string; + /** Default: a new version 4 UUID, which is what Alexa recommends (message-guide.html, "Header object"). */ + messageId?: string; +} +interface Answer extends Envelope { + /** The correlationToken of the directive that is answered. */ + correlationToken: string; +} +export interface ResponseFields extends Answer { + /** Default: "Response". */ + name?: string; + /** Default: "Alexa". An interface with a response of its own names it: Alexa.SecurityPanelController, Arm.Response. */ + namespace?: string; + payload?: Record; + /** The state of the endpoint after the directive. */ + context?: readonly Property[]; +} +/** + * The answer to a directive (alexa-response.html, "Synchronous response"). An endpoint with nothing to report + * answers with an empty list of properties, not without a context (state-reporting-for-smart-home-addons.html, + * "Directive response example for a property that isn't retrievable"). + */ +export declare function response(fields: ResponseFields): ResponseMessage; +/** The answer to ReportState: every retrievable property of the endpoint. */ +export declare function stateReport(fields: Answer & { + context?: readonly Property[]; +}): ResponseMessage; +/** + * "The directive arrived, the answer follows": no context, the state is in the Response that follows + * (alexa-response.html, "Deferred response example"). + */ +export declare function deferredResponse(fields: Answer & { + estimatedDeferralInSeconds?: number; +}): DeferredResponseMessage; +export interface ErrorResponseFields extends Answer { + /** "ENDPOINT_UNREACHABLE" */ + type: string; + /** For the log of the skill. */ + message: string; + /** The fields a type adds to the payload: validRange, currentDeviceMode. */ + extra?: Record; + /** Default: the namespace the type is documented under, see errorNamespace(). */ + namespace?: string; +} +/** The answer to a directive the endpoint could not follow (alexa-errorresponse.html). */ +export declare function errorResponse(fields: ErrorResponseFields): ErrorResponseMessage; +export interface ChangeReportFields extends Envelope { + /** Default: "PHYSICAL_INTERACTION". */ + cause?: ChangeCause; + /** The properties that changed, at least one. */ + changed: readonly Property[]; + /** The other properties of the endpoint, as they are now. */ + context?: readonly Property[]; +} +/** + * A change of state nobody asked for. The header has no correlationToken (message-guide.html, "Header object"). + * A property that changed is left out of the context: it is reported in one of the two (same page, "Context + * object"). Throws MessageError when nothing changed: Alex2MQTT drops such a report without a word. + */ +export declare function changeReport(fields: ChangeReportFields): ChangeReportMessage; +export interface SceneEventFields extends Answer { + /** true: ActivationStarted. false: DeactivationStarted. */ + activated: boolean; + /** Default: "VOICE_INTERACTION", a scene is started by a directive. */ + cause?: ChangeCause; + /** When the scene started. Default: now. */ + timestamp?: string | Date; +} +/** The answer to Activate and Deactivate of a scene (alexa-scenecontroller.html). */ +export declare function sceneEvent(fields: SceneEventFields): SceneEventMessage; +export interface DoorbellPressFields extends Envelope { + /** Default: "PHYSICAL_INTERACTION", somebody pressed the button. */ + cause?: ChangeCause; + /** When the button was pressed. Default: now. */ + timestamp?: string | Date; +} +/** Somebody rang (alexa-doorbelleventsource.html). */ +export declare function doorbellPress(fields: DoorbellPressFields): ProactiveEventMessage; +export interface SimpleEventFields extends Envelope { + /** The instance of Alexa.SimpleEventSource that raises the event. */ + instance: string; + /** The id of the event as discovery listed it: "Button.SinglePush.1". */ + id: string; + /** When the event happened. Default: now. */ + timestamp?: string | Date; +} +/** An event of a button or a sensor that routines start on (alexa-simpleeventsource.html). */ +export declare function simpleEvent(fields: SimpleEventFields): ProactiveEventMessage; +export {}; diff --git a/dist/esm/messages/build.js b/dist/esm/messages/build.js new file mode 100644 index 0000000..55fc04f --- /dev/null +++ b/dist/esm/messages/build.js @@ -0,0 +1,124 @@ +// Every message the bridge publishes, built from plain values. Nothing here reads the clock or makes an id unless +// the caller leaves messageId or a time out, so a test that passes both compares whole objects. +import { randomUUID } from "crypto"; +import { errorNamespace } from "./errors.js"; +import { isoTime } from "./property.js"; +/** A message that would be dropped on its way to Alexa, refused where it is built. */ +export class MessageError extends Error { + constructor(endpointId, problem) { + super(`${endpointId}: ${problem}`); + this.endpointId = endpointId; + this.problem = problem; + this.name = "MessageError"; + } +} +// The fields in the order of the examples +function header(namespace, name, fields) { + return { + namespace, + name, + ...(fields.instance ? { instance: fields.instance } : {}), + messageId: fields.messageId ?? randomUUID(), + ...(fields.correlationToken !== undefined ? { correlationToken: fields.correlationToken } : {}), + payloadVersion: fields.payloadVersion ?? "3", + }; +} +/** + * The answer to a directive (alexa-response.html, "Synchronous response"). An endpoint with nothing to report + * answers with an empty list of properties, not without a context (state-reporting-for-smart-home-addons.html, + * "Directive response example for a property that isn't retrievable"). + */ +export function response(fields) { + return { + event: { + header: header(fields.namespace ?? "Alexa", fields.name ?? "Response", fields), + endpoint: { endpointId: fields.endpointId }, + payload: fields.payload ?? {}, + }, + context: { properties: [...(fields.context ?? [])] }, + }; +} +/** The answer to ReportState: every retrievable property of the endpoint. */ +export function stateReport(fields) { + return response({ ...fields, name: "StateReport", namespace: "Alexa", payload: {} }); +} +/** + * "The directive arrived, the answer follows": no context, the state is in the Response that follows + * (alexa-response.html, "Deferred response example"). + */ +export function deferredResponse(fields) { + const { estimatedDeferralInSeconds } = fields; + return { + event: { + header: header("Alexa", "DeferredResponse", fields), + endpoint: { endpointId: fields.endpointId }, + payload: estimatedDeferralInSeconds === undefined ? {} : { estimatedDeferralInSeconds }, + }, + }; +} +/** The answer to a directive the endpoint could not follow (alexa-errorresponse.html). */ +export function errorResponse(fields) { + return { + event: { + header: header(fields.namespace ?? errorNamespace(fields.type), "ErrorResponse", fields), + endpoint: { endpointId: fields.endpointId }, + payload: { type: fields.type, message: fields.message, ...fields.extra }, + }, + }; +} +const sameProperty = (a, b) => a.namespace === b.namespace && a.name === b.name && (a.instance ?? "") === (b.instance ?? ""); +/** + * A change of state nobody asked for. The header has no correlationToken (message-guide.html, "Header object"). + * A property that changed is left out of the context: it is reported in one of the two (same page, "Context + * object"). Throws MessageError when nothing changed: Alex2MQTT drops such a report without a word. + */ +export function changeReport(fields) { + const { endpointId, changed, context = [] } = fields; + if (changed.length === 0) { + throw new MessageError(endpointId, "a ChangeReport needs at least one property that changed, this one has none. " + + "Add the changed property before unchanged(), or send no report when nothing changed"); + } + return { + event: { + header: header("Alexa", "ChangeReport", { messageId: fields.messageId }), + endpoint: { endpointId }, + payload: { change: { cause: { type: fields.cause ?? "PHYSICAL_INTERACTION" }, properties: [...changed] } }, + }, + context: { properties: context.filter((property) => !changed.some((other) => sameProperty(property, other))) }, + }; +} +/** The answer to Activate and Deactivate of a scene (alexa-scenecontroller.html). */ +export function sceneEvent(fields) { + return { + event: { + header: header("Alexa.SceneController", fields.activated ? "ActivationStarted" : "DeactivationStarted", fields), + endpoint: { endpointId: fields.endpointId }, + payload: { cause: { type: fields.cause ?? "VOICE_INTERACTION" }, timestamp: isoTime(fields.timestamp) }, + }, + context: {}, + }; +} +/** Somebody rang (alexa-doorbelleventsource.html). */ +export function doorbellPress(fields) { + return { + event: { + header: header("Alexa.DoorbellEventSource", "DoorbellPress", { messageId: fields.messageId }), + endpoint: { endpointId: fields.endpointId }, + payload: { cause: { type: fields.cause ?? "PHYSICAL_INTERACTION" }, timestamp: isoTime(fields.timestamp) }, + }, + }; +} +/** An event of a button or a sensor that routines start on (alexa-simpleeventsource.html). */ +export function simpleEvent(fields) { + return { + event: { + header: header("Alexa.SimpleEventSource", "Event", { + instance: fields.instance, + messageId: fields.messageId, + payloadVersion: "1.0", + }), + endpoint: { endpointId: fields.endpointId }, + payload: { id: fields.id, timestamp: isoTime(fields.timestamp) }, + }, + }; +} diff --git a/dist/esm/messages/errors.d.ts b/dist/esm/messages/errors.d.ts new file mode 100644 index 0000000..1fad696 --- /dev/null +++ b/dist/esm/messages/errors.d.ts @@ -0,0 +1,52 @@ +import type { Temperature } from "../registry/schema.js"; +/** + * The namespace an ErrorResponse of this type goes under: the one the table of error types lists it with + * (THERMOSTAT_IS_OFF: Alexa.ThermostatController, OBSTACLE_DETECTED: Alexa.Safety), Alexa for a type the table + * does not have. + */ +export declare function errorNamespace(type: string): string; +/** Thrown by a directive handler, or built to be sent: the directive is answered with this ErrorResponse. */ +export declare class AlexaError extends Error { + /** "ENDPOINT_UNREACHABLE" */ + readonly type: string; + /** payload.message: for the log of the skill, Alexa does not read it to the user. */ + readonly alexaMessage: string; + /** The fields of the payload next to type and message. */ + readonly extra: Record; + /** The namespace of the header. */ + readonly namespace: string; + constructor(type: string, message: string, extra?: Record, namespace?: string); +} +export type CurrentDeviceMode = "ASLEEP" | "COLOR" | "DEMAND_RESPONSE" | "ECO" | "NOT_PROVISIONED" | "VACATION" | "OTHER"; +export type ControlUnavailableReason = "DEEP_SLEEP_MODE" | "OUT_OF_NETWORK_CONNECTIVITY" | "NO_CONNECTIVITY_PACKAGE_ENABLED" | "UNKNOWN"; +export type ChargeState = "ALREADY_CHARGED_TO_REQUIRED_LEVEL" | "CURRENTLY_CHARGING" | "FULLY_CHARGED" | "NOT_CONNECTED_TO_POWER"; +export declare const AlexaErrors: { + /** An error of any type. */ + of(type: string, message: string, extra?: Record): AlexaError; + /** The value is outside what the endpoint takes. For a temperature: temperatureOutOfRange(). */ + valueOutOfRange(message: string, validRange: { + minimumValue: number; + maximumValue: number; + }): AlexaError; + temperatureOutOfRange(message: string, validRange: { + minimumValue: Temperature; + maximumValue: Temperature; + }): AlexaError; + /** A light showing a color asked for a color temperature: "COLOR". */ + notSupportedInCurrentMode(message: string, currentDeviceMode: CurrentDeviceMode): AlexaError; + /** percentageState: what is left of the battery, 0 to 100. */ + endpointLowPower(message: string, percentageState?: number): AlexaError; + endpointControlUnavailable(message: string, reason: ControlUnavailableReason): AlexaError; + notSupportedWithCurrentBatteryChargeState(message: string, currentChargeState: ChargeState, currentChargeLevelInPercentage?: number): AlexaError; + /** What has to be refilled. */ + insufficientResource(message: string, resourceType: "WATER"): AlexaError; + /** What the user has to do first. */ + maintenanceRequired(message: string, maintenanceAction?: "EMPTY_BIN"): AlexaError; + /** Goes under Alexa.ThermostatController. minimumTemperatureDelta: how far apart the setpoints have to be. */ + setpointsTooClose(message: string, minimumTemperatureDelta?: Temperature): AlexaError; + /** Goes under Alexa.SecurityPanelController. With the endpoints listed, the user can bypass them by voice. */ + bypassNeeded(message: string, endpointsNeedingBypass?: Array<{ + friendlyName: string; + endpointId?: string; + }>): AlexaError; +}; diff --git a/dist/esm/messages/errors.js b/dist/esm/messages/errors.js new file mode 100644 index 0000000..10ee50f --- /dev/null +++ b/dist/esm/messages/errors.js @@ -0,0 +1,69 @@ +// The errors a device answers a directive with (alexa-errorresponse.html). A helper for each type whose payload +// has fields of its own, so that the fields cannot be misspelt. +import { ERROR_TYPES } from "../registry/catalog.js"; +/** + * The namespace an ErrorResponse of this type goes under: the one the table of error types lists it with + * (THERMOSTAT_IS_OFF: Alexa.ThermostatController, OBSTACLE_DETECTED: Alexa.Safety), Alexa for a type the table + * does not have. + */ +export function errorNamespace(type) { + return Object.prototype.hasOwnProperty.call(ERROR_TYPES, type) ? ERROR_TYPES[type] : "Alexa"; +} +/** Thrown by a directive handler, or built to be sent: the directive is answered with this ErrorResponse. */ +export class AlexaError extends Error { + constructor(type, message, extra = {}, namespace = errorNamespace(type)) { + super(`${type}: ${message}`); + this.name = "AlexaError"; + this.type = type; + this.alexaMessage = message; + this.extra = extra; + this.namespace = namespace; + } +} +// A field that was not given is left out of the payload +function given(fields) { + return Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined)); +} +export const AlexaErrors = { + /** An error of any type. */ + of(type, message, extra = {}) { + return new AlexaError(type, message, extra); + }, + /** The value is outside what the endpoint takes. For a temperature: temperatureOutOfRange(). */ + valueOutOfRange(message, validRange) { + return new AlexaError("VALUE_OUT_OF_RANGE", message, { validRange }); + }, + temperatureOutOfRange(message, validRange) { + return new AlexaError("TEMPERATURE_VALUE_OUT_OF_RANGE", message, { validRange }); + }, + /** A light showing a color asked for a color temperature: "COLOR". */ + notSupportedInCurrentMode(message, currentDeviceMode) { + return new AlexaError("NOT_SUPPORTED_IN_CURRENT_MODE", message, { currentDeviceMode }); + }, + /** percentageState: what is left of the battery, 0 to 100. */ + endpointLowPower(message, percentageState) { + return new AlexaError("ENDPOINT_LOW_POWER", message, given({ percentageState })); + }, + endpointControlUnavailable(message, reason) { + return new AlexaError("ENDPOINT_CONTROL_UNAVAILABLE", message, { reason }); + }, + notSupportedWithCurrentBatteryChargeState(message, currentChargeState, currentChargeLevelInPercentage) { + return new AlexaError("NOT_SUPPORTED_WITH_CURRENT_BATTERY_CHARGE_STATE", message, given({ currentChargeState, currentChargeLevelInPercentage })); + }, + /** What has to be refilled. */ + insufficientResource(message, resourceType) { + return new AlexaError("INSUFFICIENT_RESOURCE", message, { resourceType }); + }, + /** What the user has to do first. */ + maintenanceRequired(message, maintenanceAction) { + return new AlexaError("MAINTENANCE_REQUIRED", message, given({ maintenanceAction })); + }, + /** Goes under Alexa.ThermostatController. minimumTemperatureDelta: how far apart the setpoints have to be. */ + setpointsTooClose(message, minimumTemperatureDelta) { + return new AlexaError("REQUESTED_SETPOINTS_TOO_CLOSE", message, given({ minimumTemperatureDelta })); + }, + /** Goes under Alexa.SecurityPanelController. With the endpoints listed, the user can bypass them by voice. */ + bypassNeeded(message, endpointsNeedingBypass) { + return new AlexaError("BYPASS_NEEDED", message, given({ endpointsNeedingBypass })); + }, +}; diff --git a/dist/esm/messages/index.d.ts b/dist/esm/messages/index.d.ts new file mode 100644 index 0000000..95bc693 --- /dev/null +++ b/dist/esm/messages/index.d.ts @@ -0,0 +1,9 @@ +export { changeReport, deferredResponse, doorbellPress, errorResponse, response, sceneEvent, simpleEvent, stateReport, MessageError, } from "./build.js"; +export type { ChangeReportFields, DoorbellPressFields, ErrorResponseFields, ResponseFields, SceneEventFields, SimpleEventFields, } from "./build.js"; +export { AlexaError, AlexaErrors, errorNamespace } from "./errors.js"; +export type { ChargeState, ControlUnavailableReason, CurrentDeviceMode } from "./errors.js"; +export { property } from "./property.js"; +export type { PropertyOptions } from "./property.js"; +export { StateBuilder } from "./StateBuilder.js"; +export type { StateBuilderOptions, Target } from "./StateBuilder.js"; +export type { ChangeCause, ChangeReportMessage, Context, DeferredResponseMessage, Endpoint, ErrorResponseMessage, Event, Header, ProactiveEventMessage, Property, ResponseMessage, SceneEventMessage, } from "./types.js"; diff --git a/dist/esm/messages/index.js b/dist/esm/messages/index.js new file mode 100644 index 0000000..1c6292b --- /dev/null +++ b/dist/esm/messages/index.js @@ -0,0 +1,5 @@ +// The messages of the bridge without the bridge: builders, the state collector and the errors. +export { changeReport, deferredResponse, doorbellPress, errorResponse, response, sceneEvent, simpleEvent, stateReport, MessageError, } from "./build.js"; +export { AlexaError, AlexaErrors, errorNamespace } from "./errors.js"; +export { property } from "./property.js"; +export { StateBuilder } from "./StateBuilder.js"; diff --git a/dist/esm/messages/property.d.ts b/dist/esm/messages/property.d.ts new file mode 100644 index 0000000..6a781e7 --- /dev/null +++ b/dist/esm/messages/property.d.ts @@ -0,0 +1,16 @@ +import type { Property } from "./types.js"; +export interface PropertyOptions { + /** The instance of a generic controller: "Blind.Lift". */ + instance?: string; + /** + * When the value last changed. Default: now. message-guide.html, "Property object": "don't set the same + * timeOfSample value for all properties", so a device that knows when each value changed passes it. + */ + timeOfSample?: string | Date; + /** How old the value may be, for a device that is polled. Default: 0. */ + uncertaintyInMilliseconds?: number; +} +/** A time as Alexa wants it: ISO 8601 in UTC. Text is taken as given. */ +export declare function isoTime(time?: string | Date): string; +/** One property of a context or of a change, its fields in the order of the examples. */ +export declare function property(namespace: string, name: string, value: unknown, options?: PropertyOptions): Property; diff --git a/dist/esm/messages/property.js b/dist/esm/messages/property.js new file mode 100644 index 0000000..e6c023e --- /dev/null +++ b/dist/esm/messages/property.js @@ -0,0 +1,15 @@ +/** A time as Alexa wants it: ISO 8601 in UTC. Text is taken as given. */ +export function isoTime(time = new Date()) { + return typeof time === "string" ? time : time.toISOString(); +} +/** One property of a context or of a change, its fields in the order of the examples. */ +export function property(namespace, name, value, options = {}) { + return { + namespace, + ...(options.instance ? { instance: options.instance } : {}), + name, + value, + timeOfSample: isoTime(options.timeOfSample), + uncertaintyInMilliseconds: options.uncertaintyInMilliseconds ?? 0, + }; +} diff --git a/dist/esm/messages/types.d.ts b/dist/esm/messages/types.d.ts new file mode 100644 index 0000000..8d3ff62 --- /dev/null +++ b/dist/esm/messages/types.d.ts @@ -0,0 +1,78 @@ +/** + * Why a property changed or an event was raised (message-guide.html, "Cause object"). RULE_TRIGGER is not in that + * table; the ChangeReport examples of alexa-securitypanelcontroller.html and alexa-thermostatcontroller.html use it. + */ +export type ChangeCause = "APP_INTERACTION" | "PERIODIC_POLL" | "PHYSICAL_INTERACTION" | "RULE_TRIGGER" | "VOICE_INTERACTION"; +export interface Header { + namespace: string; + name: string; + /** Alexa.SimpleEventSource: the instance that raised the event. */ + instance?: string; + messageId: string; + /** The token of the directive that is answered. An event nobody asked for has none. */ + correlationToken?: string; + payloadVersion: string; +} +export interface Endpoint { + endpointId: string; +} +/** One reported property (message-guide.html, "Property object"). */ +export interface Property { + namespace: string; + /** The instance of a generic controller. */ + instance?: string; + name: string; + value: unknown; + /** When the value was sampled or last changed, ISO 8601 in UTC. */ + timeOfSample: string; + uncertaintyInMilliseconds: number; +} +export interface Event

> { + header: Header; + endpoint: Endpoint; + payload: P; +} +export interface Context { + properties: Property[]; +} +/** A Response, a StateReport or the response of an interface that has its own (Arm.Response). */ +export interface ResponseMessage { + event: Event; + context: Context; +} +export interface DeferredResponseMessage { + event: Event<{ + estimatedDeferralInSeconds?: number; + }>; +} +export interface ErrorResponseMessage { + event: Event<{ + type: string; + message: string; + } & Record>; +} +export interface ChangeReportMessage { + event: Event<{ + change: { + cause: { + type: ChangeCause; + }; + properties: Property[]; + }; + }>; + context: Context; +} +/** ActivationStarted or DeactivationStarted of a scene. */ +export interface SceneEventMessage { + event: Event<{ + cause: { + type: ChangeCause; + }; + timestamp: string; + }>; + context: {}; +} +/** An event a device raises by itself: DoorbellPress, the Event of Alexa.SimpleEventSource. */ +export interface ProactiveEventMessage { + event: Event; +} diff --git a/dist/esm/messages/types.js b/dist/esm/messages/types.js new file mode 100644 index 0000000..df927b8 --- /dev/null +++ b/dist/esm/messages/types.js @@ -0,0 +1,2 @@ +// The messages the bridge publishes, as message-guide.html and alexa-response.html draw them. +export {}; diff --git a/dist/esm/registry/interfaces/EndpointHealth.d.ts b/dist/esm/registry/interfaces/EndpointHealth.d.ts index 1d1621f..a153326 100644 --- a/dist/esm/registry/interfaces/EndpointHealth.d.ts +++ b/dist/esm/registry/interfaces/EndpointHealth.d.ts @@ -8,7 +8,7 @@ export declare const EndpointHealth: import("../types.js").InterfaceDescriptor<{ name: string; value: import("../schema.js").Schema; - reason: import("../schema.js").OptionalSchema<"WIFI_BAD_PASSWORD" | "WIFI_AP_NOT_FOUND" | "WIFI_ROUTER_UNREACHABLE" | "WIFI_AP_CHANNEL_QUALITY_LOW" | "INTERNET_UNREACHABLE" | "CAPTIVE_PORTAL_CHECK_FAILED" | "UNKNOWN">; + reason: import("../schema.js").OptionalSchema<"UNKNOWN" | "WIFI_BAD_PASSWORD" | "WIFI_AP_NOT_FOUND" | "WIFI_ROUTER_UNREACHABLE" | "WIFI_AP_CHANNEL_QUALITY_LOW" | "INTERNET_UNREACHABLE" | "CAPTIVE_PORTAL_CHECK_FAILED">; }>>; note: string; }; diff --git a/dist/types/AlexaErrorResponse.d.ts b/dist/types/AlexaErrorResponse.d.ts deleted file mode 100644 index 7620270..0000000 --- a/dist/types/AlexaErrorResponse.d.ts +++ /dev/null @@ -1,92 +0,0 @@ -import type { MqttClient } from "mqtt"; -export declare enum AlexaErrorType { - ALREADY_IN_OPERATION = "ALREADY_IN_OPERATION", - AUTHORIZATION_REQUIRED = "AUTHORIZATION_REQUIRED", - BRIDGE_UNREACHABLE = "BRIDGE_UNREACHABLE", - BYPASS_NEEDED = "BYPASS_NEEDED", - CLOUD_CONTROL_DISABLED = "CLOUD_CONTROL_DISABLED", - CHILD_LOCK = "CHILD_LOCK", - CONFIGURATION_UPDATE_NOT_ALLOWED = "CONFIGURATION_UPDATE_NOT_ALLOWED", - COOK_DURATION_TOO_LONG = "COOK_DURATION_TOO_LONG", - COOLING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE = "COOLING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE", - COOLING_STAGES_EXCEEDS_LIMIT = "COOLING_STAGES_EXCEEDS_LIMIT", - DATA_DELETION_NOT_SUPPORTED = "DATA_DELETION_NOT_SUPPORTED", - DATA_RETRIEVAL_NOT_SUPPORTED = "DATA_RETRIEVAL_NOT_SUPPORTED", - DEVICE_STUCK = "DEVICE_STUCK", - DISABLED_BY_USER = "DISABLED_BY_USER", - DO_NOT_DISTURB_MODE = "DO_NOT_DISTURB_MODE", - DOOR_CLOSED_TOO_LONG = "DOOR_CLOSED_TOO_LONG", - DOOR_OPEN = "DOOR_OPEN", - DUAL_SETPOINTS_UNSUPPORTED = "DUAL_SETPOINTS_UNSUPPORTED", - ENDPOINT_BUSY = "ENDPOINT_BUSY", - ENDPOINT_CONTROL_UNAVAILABLE = "ENDPOINT_CONTROL_UNAVAILABLE", - ENDPOINT_LOW_POWER = "ENDPOINT_LOW_POWER", - ENDPOINT_UNREACHABLE = "ENDPOINT_UNREACHABLE", - EXCEEDED_PIN_ATTEMPTS = "EXCEEDED_PIN_ATTEMPTS", - EXPIRED_AUTHORIZATION_CREDENTIAL = "EXPIRED_AUTHORIZATION_CREDENTIAL", - FAILED_TO_BOOTSTRAP_COMMISSIONING_PROCESS = "FAILED_TO_BOOTSTRAP_COMMISSIONING_PROCESS", - FIRMWARE_OUT_OF_DATE = "FIRMWARE_OUT_OF_DATE", - HARDWARE_MALFUNCTION = "HARDWARE_MALFUNCTION", - HEATING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE = "HEATING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE", - HEATING_STAGES_EXCEEDS_LIMIT = "HEATING_STAGES_EXCEEDS_LIMIT", - INSUFFICIENT_PERMISSIONS = "INSUFFICIENT_PERMISSIONS", - INSUFFICIENT_RESOURCE = "INSUFFICIENT_RESOURCE", - INSUFFICIENT_SPACE = "INSUFFICIENT_SPACE", - INTERNAL_ERROR = "INTERNAL_ERROR", - INVALID_AUTHORIZATION_CREDENTIAL = "INVALID_AUTHORIZATION_CREDENTIAL", - INVALID_AUXILIARY_HEATING_SYSTEM_TYPE = "INVALID_AUXILIARY_HEATING_SYSTEM_TYPE", - INVALID_DIRECTIVE = "INVALID_DIRECTIVE", - INVALID_SYSTEM_TYPE = "INVALID_SYSTEM_TYPE", - INVALID_TARGET_STATE = "INVALID_TARGET_STATE", - INVALID_TEMPERATURE_SCALE = "INVALID_TEMPERATURE_SCALE", - INVALID_TERMINAL_CONNECTION = "INVALID_TERMINAL_CONNECTION", - INVALID_VALUE = "INVALID_VALUE", - MAINTENANCE_REQUIRED = "MAINTENANCE_REQUIRED", - MAX_COMMISSIONING_LIMIT_REACHED = "MAX_COMMISSIONING_LIMIT_REACHED", - MISSING_SETUP_INFORMATION = "MISSING_SETUP_INFORMATION", - NO_SUCH_ENDPOINT = "NO_SUCH_ENDPOINT", - NOT_CALIBRATED = "NOT_CALIBRATED", - NOT_IN_OPERATION = "NOT_IN_OPERATION", - NOT_READY = "NOT_READY", - NOT_SUPPORTED_IN_CURRENT_MODE = "NOT_SUPPORTED_IN_CURRENT_MODE", - NOT_SUPPORTED_WITH_CURRENT_BATTERY_CHARGE_STATE = "NOT_SUPPORTED_WITH_CURRENT_BATTERY_CHARGE_STATE", - OBSTACLE_DETECTED = "OBSTACLE_DETECTED", - PARTNER_APPLICATION_REDIRECTION = "PARTNER_APPLICATION_REDIRECTION", - PIN_SETUP_REQUIRED = "PIN_SETUP_REQUIRED", - POWER_LEVEL_NOT_SUPPORTED = "POWER_LEVEL_NOT_SUPPORTED", - PREHEAT_REQUIRED = "PREHEAT_REQUIRED", - PROBE_REQUIRED = "PROBE_REQUIRED", - RATE_LIMIT_EXCEEDED = "RATE_LIMIT_EXCEEDED", - REMOTE_START_NOT_SUPPORTED = "REMOTE_START_NOT_SUPPORTED", - REMOVE_PROBE = "REMOVE_PROBE", - REMOTE_START_DISABLED = "REMOTE_START_DISABLED", - REQUESTED_SETPOINTS_TOO_CLOSE = "REQUESTED_SETPOINTS_TOO_CLOSE", - SAFETY_BEAM_BREACHED = "SAFETY_BEAM_BREACHED", - SUBSCRIPTION_REQUIRED = "SUBSCRIPTION_REQUIRED", - TEMPERATURE_VALUE_OUT_OF_RANGE = "TEMPERATURE_VALUE_OUT_OF_RANGE", - THERMOSTAT_IS_OFF = "THERMOSTAT_IS_OFF", - TOO_MANY_FAILED_ATTEMPTS = "TOO_MANY_FAILED_ATTEMPTS", - TRIPLE_SETPOINTS_UNSUPPORTED = "TRIPLE_SETPOINTS_UNSUPPORTED", - UNABLE_TO_CHARGE = "UNABLE_TO_CHARGE", - UNAUTHORIZED = "UNAUTHORIZED", - UNCLEARED_ALARM = "UNCLEARED_ALARM", - UNSUPPORTED_THERMOSTAT_MODE = "UNSUPPORTED_THERMOSTAT_MODE", - UNCLEARED_TROUBLE = "UNCLEARED_TROUBLE", - UNWILLING_TO_SET_SCHEDULE = "UNWILLING_TO_SET_SCHEDULE", - UNWILLING_TO_SET_VALUE = "UNWILLING_TO_SET_VALUE", - VALUE_OUT_OF_RANGE = "VALUE_OUT_OF_RANGE" -} -export declare class AlexaErrorResponse { - private event; - private rootTopic; - private endpointId; - private mqttClient; - /** Where a failed publish is reported (set by the Device that built this message, 1.5.2): send() never rejects. */ - onPublishError?: (err: Error) => void; - constructor(correlationToken: string, rootTopic: string, endpointId: string, mqttClient: MqttClient); - setErrorMessage(type: string, message: string, otherParams?: Record): void; - toJSON(): { - event: any; - }; - send(sendAsync?: boolean): Promise; -} diff --git a/dist/types/compat/AlexaErrorResponse.d.ts b/dist/types/compat/AlexaErrorResponse.d.ts new file mode 100644 index 0000000..389b6cc --- /dev/null +++ b/dist/types/compat/AlexaErrorResponse.d.ts @@ -0,0 +1,26 @@ +import type { MqttClient } from "mqtt"; +import type { ErrorResponseMessage } from "../messages/types.js"; +/** An ErrorResponse as 1.x builds it: device.getErrorMessage(token), setErrorMessage(), send(). */ +export declare class AlexaErrorResponse { + private readonly correlationToken; + private readonly rootTopic; + private readonly endpointId; + private readonly mqttClient; + private error; + private namespace?; + private readonly messageId; + /** Where a failed publish is reported (set by the Device that built this message, 1.5.2): send() never rejects. */ + onPublishError?: (err: Error) => void; + constructor(correlationToken: string, rootTopic: string, endpointId: string, mqttClient: MqttClient); + /** + * The error to answer with. otherParams are the fields the type adds to the payload (validRange). The header + * carries the namespace the type is documented under, Alexa.ThermostatController for THERMOSTAT_IS_OFF; + * options.namespace names another. + */ + setErrorMessage(type: string, message: string, otherParams?: Record, options?: { + namespace?: string; + }): void; + toJSON(): ErrorResponseMessage; + /** Resolves with the topic published to, or "" when the publish failed. Never rejects. */ + send(sendAsync?: boolean): Promise; +} diff --git a/dist/types/AlexaStatusMessage.d.ts b/dist/types/compat/AlexaStatusMessage.d.ts similarity index 60% rename from dist/types/AlexaStatusMessage.d.ts rename to dist/types/compat/AlexaStatusMessage.d.ts index 3dbb621..f4dee17 100644 --- a/dist/types/AlexaStatusMessage.d.ts +++ b/dist/types/compat/AlexaStatusMessage.d.ts @@ -1,42 +1,25 @@ -import type { EndpointHealth, PowerController } from "./compat/enums.js"; import type { MqttClient } from "mqtt"; -export declare enum ThermostatMode { - OFF = "OFF", - HEAT = "HEAT", - COOL = "COOL", - AUTO = "AUTO", - ECO = "ECO", - CUSTOM = "CUSTOM" -} -export declare enum TemperatureSensorScale { - CELSIUS = "CELSIUS", - FAHRENHEIT = "FAHRENHEIT" -} -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"; +import type { ChangeCause, Property } from "../messages/types.js"; +import { TemperatureSensorScale } from "./enums.js"; +import type { EndpointHealth, PowerController } from "./enums.js"; +/** + * 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 declare class AlexaStatusMessage { - private context; - /** ChangeReport only: the properties that changed (payload.change.properties); the rest go to context. */ - private changeProps; - private changeCause; - private target; - private event; - private rootTopic; - private endpointId; - private mqttClient; - private isDeferred; + private readonly correlationToken; + private readonly rootTopic; + private readonly endpointId; + private readonly mqttClient; + private readonly isResponse; + private readonly isDeferred; + private readonly changeCause; + private readonly state; + private readonly messageId; + private estimatedDeferralInSeconds?; /** Where a failed publish is reported (set by the Device that built this message, 1.5.2): send() never rejects. */ onPublishError?: (err: Error) => void; constructor(correlationToken: string, rootTopic: string, endpointId: string, mqttClient: MqttClient, isResponse?: boolean, isDeferred?: boolean, changeCause?: ChangeCause | null); - private getTimestamp; private addProperty; /** ChangeReport: the add*Prop calls that follow describe what CHANGED (the default for a change report). */ changed(): this; @@ -44,12 +27,15 @@ export declare class AlexaStatusMessage { unchanged(): this; /** True for a ChangeReport (Device.getChangeReport). */ isChangeReport(): boolean; - /** The message as it will be published (for tests and logging). */ + /** + * 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(): { event: any; - context: { - properties: ContextProperty[]; - } | null; + context?: { + properties: Property[]; + }; }; addModeControllerProp(instance: string, value: string, uncertaintyInMs?: number): this; addThermostatModeProp(mode: string, uncertaintyInMs?: number): this; @@ -62,13 +48,14 @@ export declare class AlexaStatusMessage { addBrightnessControllerProp(brightness: number, uncertaintyInMs?: number): this; addColorTemperatureControllerProp(colorTemp: number, uncertaintyInMs?: number): this; addToggleControllerProp(state: PowerController, instance: string, uncertaintyInMs?: number): this; - addContextProp(prop: ContextProperty): this; + /** A property built by the caller. It goes to the context, also in a ChangeReport after changed(). */ + addContextProp(prop: Property): 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 the publish failed - 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). + * 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?: boolean): Promise; } -export {}; diff --git a/dist/types/compat/enums.d.ts b/dist/types/compat/enums.d.ts index 71a3f17..e253d48 100644 --- a/dist/types/compat/enums.d.ts +++ b/dist/types/compat/enums.d.ts @@ -181,7 +181,7 @@ export declare const EndpointHealth: import("../index.js").InterfaceDescriptor<{ name: string; value: import("../index.js").Schema; - reason: import("../registry/schema.js").OptionalSchema<"WIFI_BAD_PASSWORD" | "WIFI_AP_NOT_FOUND" | "WIFI_ROUTER_UNREACHABLE" | "WIFI_AP_CHANNEL_QUALITY_LOW" | "INTERNET_UNREACHABLE" | "CAPTIVE_PORTAL_CHECK_FAILED" | "UNKNOWN">; + reason: import("../registry/schema.js").OptionalSchema<"UNKNOWN" | "WIFI_BAD_PASSWORD" | "WIFI_AP_NOT_FOUND" | "WIFI_ROUTER_UNREACHABLE" | "WIFI_AP_CHANNEL_QUALITY_LOW" | "INTERNET_UNREACHABLE" | "CAPTIVE_PORTAL_CHECK_FAILED">; }>>; note: string; }; @@ -190,4 +190,96 @@ export declare const EndpointHealth: import("../index.js").InterfaceDescriptor<{ readonly UNREACHABLE: "UNREACHABLE"; }; export type EndpointHealth = (typeof Connectivity)[keyof typeof Connectivity]; +/** The modes of a thermostat the 1.x helpers know. */ +export declare enum ThermostatMode { + OFF = "OFF", + HEAT = "HEAT", + COOL = "COOL", + AUTO = "AUTO", + ECO = "ECO", + CUSTOM = "CUSTOM" +} +/** The scale of a temperature given to addTemperatureSensorProp() and addThermostatControllerProp(). */ +export declare enum TemperatureSensorScale { + CELSIUS = "CELSIUS", + FAHRENHEIT = "FAHRENHEIT" +} +/** The error types of alexa-errorresponse.html, "Error type values". */ +export declare enum AlexaErrorType { + ALREADY_IN_OPERATION = "ALREADY_IN_OPERATION", + AUTHORIZATION_REQUIRED = "AUTHORIZATION_REQUIRED", + BRIDGE_UNREACHABLE = "BRIDGE_UNREACHABLE", + BYPASS_NEEDED = "BYPASS_NEEDED", + CLOUD_CONTROL_DISABLED = "CLOUD_CONTROL_DISABLED", + CHILD_LOCK = "CHILD_LOCK", + CONFIGURATION_UPDATE_NOT_ALLOWED = "CONFIGURATION_UPDATE_NOT_ALLOWED", + COOK_DURATION_TOO_LONG = "COOK_DURATION_TOO_LONG", + COOLING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE = "COOLING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE", + COOLING_STAGES_EXCEEDS_LIMIT = "COOLING_STAGES_EXCEEDS_LIMIT", + DATA_DELETION_NOT_SUPPORTED = "DATA_DELETION_NOT_SUPPORTED", + DATA_RETRIEVAL_NOT_SUPPORTED = "DATA_RETRIEVAL_NOT_SUPPORTED", + DEVICE_STUCK = "DEVICE_STUCK", + DISABLED_BY_USER = "DISABLED_BY_USER", + DO_NOT_DISTURB_MODE = "DO_NOT_DISTURB_MODE", + DOOR_CLOSED_TOO_LONG = "DOOR_CLOSED_TOO_LONG", + DOOR_OPEN = "DOOR_OPEN", + DUAL_SETPOINTS_UNSUPPORTED = "DUAL_SETPOINTS_UNSUPPORTED", + ENDPOINT_BUSY = "ENDPOINT_BUSY", + ENDPOINT_CONTROL_UNAVAILABLE = "ENDPOINT_CONTROL_UNAVAILABLE", + ENDPOINT_LOW_POWER = "ENDPOINT_LOW_POWER", + ENDPOINT_UNREACHABLE = "ENDPOINT_UNREACHABLE", + EXCEEDED_PIN_ATTEMPTS = "EXCEEDED_PIN_ATTEMPTS", + EXPIRED_AUTHORIZATION_CREDENTIAL = "EXPIRED_AUTHORIZATION_CREDENTIAL", + FAILED_TO_BOOTSTRAP_COMMISSIONING_PROCESS = "FAILED_TO_BOOTSTRAP_COMMISSIONING_PROCESS", + FIRMWARE_OUT_OF_DATE = "FIRMWARE_OUT_OF_DATE", + HARDWARE_MALFUNCTION = "HARDWARE_MALFUNCTION", + HEATING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE = "HEATING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE", + HEATING_STAGES_EXCEEDS_LIMIT = "HEATING_STAGES_EXCEEDS_LIMIT", + INSUFFICIENT_PERMISSIONS = "INSUFFICIENT_PERMISSIONS", + INSUFFICIENT_RESOURCE = "INSUFFICIENT_RESOURCE", + INSUFFICIENT_SPACE = "INSUFFICIENT_SPACE", + INTERNAL_ERROR = "INTERNAL_ERROR", + INVALID_AUTHORIZATION_CREDENTIAL = "INVALID_AUTHORIZATION_CREDENTIAL", + INVALID_AUXILIARY_HEATING_SYSTEM_TYPE = "INVALID_AUXILIARY_HEATING_SYSTEM_TYPE", + INVALID_DIRECTIVE = "INVALID_DIRECTIVE", + INVALID_SYSTEM_TYPE = "INVALID_SYSTEM_TYPE", + INVALID_TARGET_STATE = "INVALID_TARGET_STATE", + INVALID_TEMPERATURE_SCALE = "INVALID_TEMPERATURE_SCALE", + INVALID_TERMINAL_CONNECTION = "INVALID_TERMINAL_CONNECTION", + INVALID_VALUE = "INVALID_VALUE", + MAINTENANCE_REQUIRED = "MAINTENANCE_REQUIRED", + MAX_COMMISSIONING_LIMIT_REACHED = "MAX_COMMISSIONING_LIMIT_REACHED", + MISSING_SETUP_INFORMATION = "MISSING_SETUP_INFORMATION", + NO_SUCH_ENDPOINT = "NO_SUCH_ENDPOINT", + NOT_CALIBRATED = "NOT_CALIBRATED", + NOT_IN_OPERATION = "NOT_IN_OPERATION", + NOT_READY = "NOT_READY", + NOT_SUPPORTED_IN_CURRENT_MODE = "NOT_SUPPORTED_IN_CURRENT_MODE", + NOT_SUPPORTED_WITH_CURRENT_BATTERY_CHARGE_STATE = "NOT_SUPPORTED_WITH_CURRENT_BATTERY_CHARGE_STATE", + OBSTACLE_DETECTED = "OBSTACLE_DETECTED", + PARTNER_APPLICATION_REDIRECTION = "PARTNER_APPLICATION_REDIRECTION", + PIN_SETUP_REQUIRED = "PIN_SETUP_REQUIRED", + POWER_LEVEL_NOT_SUPPORTED = "POWER_LEVEL_NOT_SUPPORTED", + PREHEAT_REQUIRED = "PREHEAT_REQUIRED", + PROBE_REQUIRED = "PROBE_REQUIRED", + RATE_LIMIT_EXCEEDED = "RATE_LIMIT_EXCEEDED", + REMOTE_START_NOT_SUPPORTED = "REMOTE_START_NOT_SUPPORTED", + REMOVE_PROBE = "REMOVE_PROBE", + REMOTE_START_DISABLED = "REMOTE_START_DISABLED", + REQUESTED_SETPOINTS_TOO_CLOSE = "REQUESTED_SETPOINTS_TOO_CLOSE", + SAFETY_BEAM_BREACHED = "SAFETY_BEAM_BREACHED", + SUBSCRIPTION_REQUIRED = "SUBSCRIPTION_REQUIRED", + TEMPERATURE_VALUE_OUT_OF_RANGE = "TEMPERATURE_VALUE_OUT_OF_RANGE", + THERMOSTAT_IS_OFF = "THERMOSTAT_IS_OFF", + TOO_MANY_FAILED_ATTEMPTS = "TOO_MANY_FAILED_ATTEMPTS", + TRIPLE_SETPOINTS_UNSUPPORTED = "TRIPLE_SETPOINTS_UNSUPPORTED", + UNABLE_TO_CHARGE = "UNABLE_TO_CHARGE", + UNAUTHORIZED = "UNAUTHORIZED", + UNCLEARED_ALARM = "UNCLEARED_ALARM", + UNSUPPORTED_THERMOSTAT_MODE = "UNSUPPORTED_THERMOSTAT_MODE", + UNCLEARED_TROUBLE = "UNCLEARED_TROUBLE", + UNWILLING_TO_SET_SCHEDULE = "UNWILLING_TO_SET_SCHEDULE", + UNWILLING_TO_SET_VALUE = "UNWILLING_TO_SET_VALUE", + VALUE_OUT_OF_RANGE = "VALUE_OUT_OF_RANGE" +} export {}; diff --git a/dist/types/device/Device.d.ts b/dist/types/device/Device.d.ts index e4f1c36..f404968 100644 --- a/dist/types/device/Device.d.ts +++ b/dist/types/device/Device.d.ts @@ -1,11 +1,11 @@ import { EventEmitter } from "events"; import type { MqttClient } from "mqtt"; -import { AlexaErrorResponse } from "../AlexaErrorResponse.js"; -import { AlexaStatusMessage } from "../AlexaStatusMessage.js"; -import type { ChangeCause } from "../AlexaStatusMessage.js"; +import { AlexaErrorResponse } from "../compat/AlexaErrorResponse.js"; import { AlexaInterface } from "../compat/AlexaInterface.js"; +import { AlexaStatusMessage } from "../compat/AlexaStatusMessage.js"; import { DisplayCategory } from "../compat/enums.js"; import type { AlexaInterfaceType } from "../compat/enums.js"; +import type { ChangeCause } from "../messages/types.js"; import type { DisplayCategoryName } from "../registry/catalog.js"; import type { Directives, InterfaceDescriptor, Properties } from "../registry/types.js"; import { Capability } from "./Capability.js"; @@ -83,6 +83,7 @@ declare class Device extends EventEmitter { * A proactive Alexa.ChangeReport (1.5.1): add the changed properties (the default target), optionally * .unchanged() then the others, and .send() - it goes to /changeReport, which Alex2MQTT forwards to the Alexa * event gateway with the user's token. Alexa only accepts it for capabilities registered with proactivelyReported. + * Without a changed property send() publishes nothing and resolves with "": Alex2MQTT would drop the report. */ getChangeReport(cause?: ChangeCause): AlexaStatusMessage; /** diff --git a/dist/types/index.d.ts b/dist/types/index.d.ts index 89506d4..8a97471 100644 --- a/dist/types/index.d.ts +++ b/dist/types/index.d.ts @@ -10,10 +10,12 @@ export { PowerController, EndpointHealth } from "./compat/enums.js"; export { asset, text, semantics, SemanticsBuilder } from "./registry/index.js"; export { ASSETS as Assets, UNITS_OF_MEASURE as Units, ACTIONS as Actions, STATES as States, DISPLAY_CATEGORIES as DisplayCategories, } from "./registry/index.js"; export type { ActionId, AssetId, DisplayCategoryName, StateId, UnitOfMeasure, ActionsToDirective, AnyDescriptor, CapabilityExtras, Declared, DirectiveDescriptor, EndpointView, EventDescriptor, InterfaceDescriptor, Label, PropertyDescriptor, Semantics, StatesToRange, StatesToValue, ActionName, StateName, Mode, Infer, Schema, Temperature, TimeInterval, } from "./registry/index.js"; +export * as messages from "./messages/index.js"; +export { AlexaError, AlexaErrors, MessageError, StateBuilder, property } from "./messages/index.js"; +export type { ChangeCause, ChangeReportMessage, DeferredResponseMessage, ErrorResponseMessage, Header, ProactiveEventMessage, Property, PropertyOptions, ResponseMessage, SceneEventMessage, } from "./messages/index.js"; export { AlexaInterface } from "./compat/AlexaInterface.js"; export type { SupportedMode } from "./compat/AlexaInterface.js"; export { ActionMapping } from "./compat/ActionMapping.js"; -export { AlexaActions, AlexaInterfaceType, DisplayCategory, PowerState } from "./compat/enums.js"; -export { AlexaStatusMessage, TemperatureSensorScale, ThermostatMode } from "./AlexaStatusMessage.js"; -export type { ChangeCause } from "./AlexaStatusMessage.js"; -export { AlexaErrorType, AlexaErrorResponse } from "./AlexaErrorResponse.js"; +export { AlexaActions, AlexaErrorType, AlexaInterfaceType, DisplayCategory, PowerState, TemperatureSensorScale, ThermostatMode, } from "./compat/enums.js"; +export { AlexaStatusMessage } from "./compat/AlexaStatusMessage.js"; +export { AlexaErrorResponse } from "./compat/AlexaErrorResponse.js"; diff --git a/dist/types/messages/StateBuilder.d.ts b/dist/types/messages/StateBuilder.d.ts new file mode 100644 index 0000000..8584c30 --- /dev/null +++ b/dist/types/messages/StateBuilder.d.ts @@ -0,0 +1,49 @@ +import { CONNECTIVITY_REASONS } from "../registry/interfaces/EndpointHealth.js"; +import type { Infer } from "../registry/schema.js"; +import type { InterfaceDescriptor, Properties } from "../registry/types.js"; +import type { PropertyOptions } from "./property.js"; +import type { Property } from "./types.js"; +/** Where a property goes in a ChangeReport: with the ones that changed, or with the rest in the context. */ +export type Target = "context" | "change"; +/** A capability of a device: its properties are reported under its instance. */ +interface DeclaredCapability

{ + readonly descriptor: InterfaceDescriptor; + readonly instance: string; +} +export interface StateBuilderOptions { + /** Where the properties go until changed() or unchanged() says otherwise. Default: "context". */ + target?: Target; + /** The time of a property that is given none. Default: the clock. */ + now?: () => Date; +} +/** + * Collects the properties of one message. For a Response or a StateReport they are its context. For a ChangeReport + * changed() and unchanged() say which of the two lists the properties that follow belong to. + */ +export declare class StateBuilder { + private readonly lists; + private target; + private readonly now; + constructor(options?: StateBuilderOptions); + /** + * A property of an interface the library describes, its value checked: set(PowerController, "powerState", "ON"). + * Given a capability of a device in place of the interface, the property carries the instance of the capability. + * Throws SchemaError for a value Alexa would not take. + */ + set

(source: InterfaceDescriptor | DeclaredCapability

, name: K, value: Infer, options?: PropertyOptions): this; + /** A property as given, nothing checked: for an interface or a value the library does not describe. */ + setRaw(namespace: string, name: string, value: unknown, options?: PropertyOptions): this; + /** The connectivity of Alexa.EndpointHealth, which belongs in every report of an endpoint that declares it. */ + health(value: "OK" | "UNREACHABLE", reason?: (typeof CONNECTIVITY_REASONS)[number], options?: PropertyOptions): this; + /** A property built elsewhere, to the list that is filled now or to the one named. */ + add(built: Property, target?: Target): this; + /** The properties that follow are the ones that changed. */ + changed(): this; + /** The properties that follow did not change: a ChangeReport lists them in its context. */ + unchanged(): this; + /** The properties for the context of the message, in the order they were set. */ + get context(): Property[]; + /** The properties for payload.change of a ChangeReport. */ + get change(): Property[]; +} +export {}; diff --git a/dist/types/messages/build.d.ts b/dist/types/messages/build.d.ts new file mode 100644 index 0000000..3cc59b2 --- /dev/null +++ b/dist/types/messages/build.d.ts @@ -0,0 +1,97 @@ +import type { ChangeCause, ChangeReportMessage, DeferredResponseMessage, ErrorResponseMessage, ProactiveEventMessage, Property, ResponseMessage, SceneEventMessage } from "./types.js"; +/** A message that would be dropped on its way to Alexa, refused where it is built. */ +export declare class MessageError extends Error { + readonly endpointId: string; + readonly problem: string; + constructor(endpointId: string, problem: string); +} +interface Envelope { + endpointId: string; + /** Default: a new version 4 UUID, which is what Alexa recommends (message-guide.html, "Header object"). */ + messageId?: string; +} +interface Answer extends Envelope { + /** The correlationToken of the directive that is answered. */ + correlationToken: string; +} +export interface ResponseFields extends Answer { + /** Default: "Response". */ + name?: string; + /** Default: "Alexa". An interface with a response of its own names it: Alexa.SecurityPanelController, Arm.Response. */ + namespace?: string; + payload?: Record; + /** The state of the endpoint after the directive. */ + context?: readonly Property[]; +} +/** + * The answer to a directive (alexa-response.html, "Synchronous response"). An endpoint with nothing to report + * answers with an empty list of properties, not without a context (state-reporting-for-smart-home-addons.html, + * "Directive response example for a property that isn't retrievable"). + */ +export declare function response(fields: ResponseFields): ResponseMessage; +/** The answer to ReportState: every retrievable property of the endpoint. */ +export declare function stateReport(fields: Answer & { + context?: readonly Property[]; +}): ResponseMessage; +/** + * "The directive arrived, the answer follows": no context, the state is in the Response that follows + * (alexa-response.html, "Deferred response example"). + */ +export declare function deferredResponse(fields: Answer & { + estimatedDeferralInSeconds?: number; +}): DeferredResponseMessage; +export interface ErrorResponseFields extends Answer { + /** "ENDPOINT_UNREACHABLE" */ + type: string; + /** For the log of the skill. */ + message: string; + /** The fields a type adds to the payload: validRange, currentDeviceMode. */ + extra?: Record; + /** Default: the namespace the type is documented under, see errorNamespace(). */ + namespace?: string; +} +/** The answer to a directive the endpoint could not follow (alexa-errorresponse.html). */ +export declare function errorResponse(fields: ErrorResponseFields): ErrorResponseMessage; +export interface ChangeReportFields extends Envelope { + /** Default: "PHYSICAL_INTERACTION". */ + cause?: ChangeCause; + /** The properties that changed, at least one. */ + changed: readonly Property[]; + /** The other properties of the endpoint, as they are now. */ + context?: readonly Property[]; +} +/** + * A change of state nobody asked for. The header has no correlationToken (message-guide.html, "Header object"). + * A property that changed is left out of the context: it is reported in one of the two (same page, "Context + * object"). Throws MessageError when nothing changed: Alex2MQTT drops such a report without a word. + */ +export declare function changeReport(fields: ChangeReportFields): ChangeReportMessage; +export interface SceneEventFields extends Answer { + /** true: ActivationStarted. false: DeactivationStarted. */ + activated: boolean; + /** Default: "VOICE_INTERACTION", a scene is started by a directive. */ + cause?: ChangeCause; + /** When the scene started. Default: now. */ + timestamp?: string | Date; +} +/** The answer to Activate and Deactivate of a scene (alexa-scenecontroller.html). */ +export declare function sceneEvent(fields: SceneEventFields): SceneEventMessage; +export interface DoorbellPressFields extends Envelope { + /** Default: "PHYSICAL_INTERACTION", somebody pressed the button. */ + cause?: ChangeCause; + /** When the button was pressed. Default: now. */ + timestamp?: string | Date; +} +/** Somebody rang (alexa-doorbelleventsource.html). */ +export declare function doorbellPress(fields: DoorbellPressFields): ProactiveEventMessage; +export interface SimpleEventFields extends Envelope { + /** The instance of Alexa.SimpleEventSource that raises the event. */ + instance: string; + /** The id of the event as discovery listed it: "Button.SinglePush.1". */ + id: string; + /** When the event happened. Default: now. */ + timestamp?: string | Date; +} +/** An event of a button or a sensor that routines start on (alexa-simpleeventsource.html). */ +export declare function simpleEvent(fields: SimpleEventFields): ProactiveEventMessage; +export {}; diff --git a/dist/types/messages/errors.d.ts b/dist/types/messages/errors.d.ts new file mode 100644 index 0000000..1fad696 --- /dev/null +++ b/dist/types/messages/errors.d.ts @@ -0,0 +1,52 @@ +import type { Temperature } from "../registry/schema.js"; +/** + * The namespace an ErrorResponse of this type goes under: the one the table of error types lists it with + * (THERMOSTAT_IS_OFF: Alexa.ThermostatController, OBSTACLE_DETECTED: Alexa.Safety), Alexa for a type the table + * does not have. + */ +export declare function errorNamespace(type: string): string; +/** Thrown by a directive handler, or built to be sent: the directive is answered with this ErrorResponse. */ +export declare class AlexaError extends Error { + /** "ENDPOINT_UNREACHABLE" */ + readonly type: string; + /** payload.message: for the log of the skill, Alexa does not read it to the user. */ + readonly alexaMessage: string; + /** The fields of the payload next to type and message. */ + readonly extra: Record; + /** The namespace of the header. */ + readonly namespace: string; + constructor(type: string, message: string, extra?: Record, namespace?: string); +} +export type CurrentDeviceMode = "ASLEEP" | "COLOR" | "DEMAND_RESPONSE" | "ECO" | "NOT_PROVISIONED" | "VACATION" | "OTHER"; +export type ControlUnavailableReason = "DEEP_SLEEP_MODE" | "OUT_OF_NETWORK_CONNECTIVITY" | "NO_CONNECTIVITY_PACKAGE_ENABLED" | "UNKNOWN"; +export type ChargeState = "ALREADY_CHARGED_TO_REQUIRED_LEVEL" | "CURRENTLY_CHARGING" | "FULLY_CHARGED" | "NOT_CONNECTED_TO_POWER"; +export declare const AlexaErrors: { + /** An error of any type. */ + of(type: string, message: string, extra?: Record): AlexaError; + /** The value is outside what the endpoint takes. For a temperature: temperatureOutOfRange(). */ + valueOutOfRange(message: string, validRange: { + minimumValue: number; + maximumValue: number; + }): AlexaError; + temperatureOutOfRange(message: string, validRange: { + minimumValue: Temperature; + maximumValue: Temperature; + }): AlexaError; + /** A light showing a color asked for a color temperature: "COLOR". */ + notSupportedInCurrentMode(message: string, currentDeviceMode: CurrentDeviceMode): AlexaError; + /** percentageState: what is left of the battery, 0 to 100. */ + endpointLowPower(message: string, percentageState?: number): AlexaError; + endpointControlUnavailable(message: string, reason: ControlUnavailableReason): AlexaError; + notSupportedWithCurrentBatteryChargeState(message: string, currentChargeState: ChargeState, currentChargeLevelInPercentage?: number): AlexaError; + /** What has to be refilled. */ + insufficientResource(message: string, resourceType: "WATER"): AlexaError; + /** What the user has to do first. */ + maintenanceRequired(message: string, maintenanceAction?: "EMPTY_BIN"): AlexaError; + /** Goes under Alexa.ThermostatController. minimumTemperatureDelta: how far apart the setpoints have to be. */ + setpointsTooClose(message: string, minimumTemperatureDelta?: Temperature): AlexaError; + /** Goes under Alexa.SecurityPanelController. With the endpoints listed, the user can bypass them by voice. */ + bypassNeeded(message: string, endpointsNeedingBypass?: Array<{ + friendlyName: string; + endpointId?: string; + }>): AlexaError; +}; diff --git a/dist/types/messages/index.d.ts b/dist/types/messages/index.d.ts new file mode 100644 index 0000000..95bc693 --- /dev/null +++ b/dist/types/messages/index.d.ts @@ -0,0 +1,9 @@ +export { changeReport, deferredResponse, doorbellPress, errorResponse, response, sceneEvent, simpleEvent, stateReport, MessageError, } from "./build.js"; +export type { ChangeReportFields, DoorbellPressFields, ErrorResponseFields, ResponseFields, SceneEventFields, SimpleEventFields, } from "./build.js"; +export { AlexaError, AlexaErrors, errorNamespace } from "./errors.js"; +export type { ChargeState, ControlUnavailableReason, CurrentDeviceMode } from "./errors.js"; +export { property } from "./property.js"; +export type { PropertyOptions } from "./property.js"; +export { StateBuilder } from "./StateBuilder.js"; +export type { StateBuilderOptions, Target } from "./StateBuilder.js"; +export type { ChangeCause, ChangeReportMessage, Context, DeferredResponseMessage, Endpoint, ErrorResponseMessage, Event, Header, ProactiveEventMessage, Property, ResponseMessage, SceneEventMessage, } from "./types.js"; diff --git a/dist/types/messages/property.d.ts b/dist/types/messages/property.d.ts new file mode 100644 index 0000000..6a781e7 --- /dev/null +++ b/dist/types/messages/property.d.ts @@ -0,0 +1,16 @@ +import type { Property } from "./types.js"; +export interface PropertyOptions { + /** The instance of a generic controller: "Blind.Lift". */ + instance?: string; + /** + * When the value last changed. Default: now. message-guide.html, "Property object": "don't set the same + * timeOfSample value for all properties", so a device that knows when each value changed passes it. + */ + timeOfSample?: string | Date; + /** How old the value may be, for a device that is polled. Default: 0. */ + uncertaintyInMilliseconds?: number; +} +/** A time as Alexa wants it: ISO 8601 in UTC. Text is taken as given. */ +export declare function isoTime(time?: string | Date): string; +/** One property of a context or of a change, its fields in the order of the examples. */ +export declare function property(namespace: string, name: string, value: unknown, options?: PropertyOptions): Property; diff --git a/dist/types/messages/types.d.ts b/dist/types/messages/types.d.ts new file mode 100644 index 0000000..8d3ff62 --- /dev/null +++ b/dist/types/messages/types.d.ts @@ -0,0 +1,78 @@ +/** + * Why a property changed or an event was raised (message-guide.html, "Cause object"). RULE_TRIGGER is not in that + * table; the ChangeReport examples of alexa-securitypanelcontroller.html and alexa-thermostatcontroller.html use it. + */ +export type ChangeCause = "APP_INTERACTION" | "PERIODIC_POLL" | "PHYSICAL_INTERACTION" | "RULE_TRIGGER" | "VOICE_INTERACTION"; +export interface Header { + namespace: string; + name: string; + /** Alexa.SimpleEventSource: the instance that raised the event. */ + instance?: string; + messageId: string; + /** The token of the directive that is answered. An event nobody asked for has none. */ + correlationToken?: string; + payloadVersion: string; +} +export interface Endpoint { + endpointId: string; +} +/** One reported property (message-guide.html, "Property object"). */ +export interface Property { + namespace: string; + /** The instance of a generic controller. */ + instance?: string; + name: string; + value: unknown; + /** When the value was sampled or last changed, ISO 8601 in UTC. */ + timeOfSample: string; + uncertaintyInMilliseconds: number; +} +export interface Event

> { + header: Header; + endpoint: Endpoint; + payload: P; +} +export interface Context { + properties: Property[]; +} +/** A Response, a StateReport or the response of an interface that has its own (Arm.Response). */ +export interface ResponseMessage { + event: Event; + context: Context; +} +export interface DeferredResponseMessage { + event: Event<{ + estimatedDeferralInSeconds?: number; + }>; +} +export interface ErrorResponseMessage { + event: Event<{ + type: string; + message: string; + } & Record>; +} +export interface ChangeReportMessage { + event: Event<{ + change: { + cause: { + type: ChangeCause; + }; + properties: Property[]; + }; + }>; + context: Context; +} +/** ActivationStarted or DeactivationStarted of a scene. */ +export interface SceneEventMessage { + event: Event<{ + cause: { + type: ChangeCause; + }; + timestamp: string; + }>; + context: {}; +} +/** An event a device raises by itself: DoorbellPress, the Event of Alexa.SimpleEventSource. */ +export interface ProactiveEventMessage { + event: Event; +} diff --git a/dist/types/registry/interfaces/EndpointHealth.d.ts b/dist/types/registry/interfaces/EndpointHealth.d.ts index 1d1621f..a153326 100644 --- a/dist/types/registry/interfaces/EndpointHealth.d.ts +++ b/dist/types/registry/interfaces/EndpointHealth.d.ts @@ -8,7 +8,7 @@ export declare const EndpointHealth: import("../types.js").InterfaceDescriptor<{ name: string; value: import("../schema.js").Schema; - reason: import("../schema.js").OptionalSchema<"WIFI_BAD_PASSWORD" | "WIFI_AP_NOT_FOUND" | "WIFI_ROUTER_UNREACHABLE" | "WIFI_AP_CHANNEL_QUALITY_LOW" | "INTERNET_UNREACHABLE" | "CAPTIVE_PORTAL_CHECK_FAILED" | "UNKNOWN">; + reason: import("../schema.js").OptionalSchema<"UNKNOWN" | "WIFI_BAD_PASSWORD" | "WIFI_AP_NOT_FOUND" | "WIFI_ROUTER_UNREACHABLE" | "WIFI_AP_CHANNEL_QUALITY_LOW" | "INTERNET_UNREACHABLE" | "CAPTIVE_PORTAL_CHECK_FAILED">; }>>; note: string; }; diff --git a/src/AlexaErrorResponse.ts b/src/AlexaErrorResponse.ts deleted file mode 100644 index 10a2280..0000000 --- a/src/AlexaErrorResponse.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { randomUUID } from "crypto"; -import type { MqttClient } from "mqtt"; - -export enum AlexaErrorType { - ALREADY_IN_OPERATION = "ALREADY_IN_OPERATION", - AUTHORIZATION_REQUIRED = "AUTHORIZATION_REQUIRED", - BRIDGE_UNREACHABLE = "BRIDGE_UNREACHABLE", - BYPASS_NEEDED = "BYPASS_NEEDED", - CLOUD_CONTROL_DISABLED = "CLOUD_CONTROL_DISABLED", - CHILD_LOCK = "CHILD_LOCK", - CONFIGURATION_UPDATE_NOT_ALLOWED = "CONFIGURATION_UPDATE_NOT_ALLOWED", - COOK_DURATION_TOO_LONG = "COOK_DURATION_TOO_LONG", - COOLING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE = "COOLING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE", - COOLING_STAGES_EXCEEDS_LIMIT = "COOLING_STAGES_EXCEEDS_LIMIT", - DATA_DELETION_NOT_SUPPORTED = "DATA_DELETION_NOT_SUPPORTED", - DATA_RETRIEVAL_NOT_SUPPORTED = "DATA_RETRIEVAL_NOT_SUPPORTED", - DEVICE_STUCK = "DEVICE_STUCK", - DISABLED_BY_USER = "DISABLED_BY_USER", - DO_NOT_DISTURB_MODE = "DO_NOT_DISTURB_MODE", - DOOR_CLOSED_TOO_LONG = "DOOR_CLOSED_TOO_LONG", - DOOR_OPEN = "DOOR_OPEN", - DUAL_SETPOINTS_UNSUPPORTED = "DUAL_SETPOINTS_UNSUPPORTED", - ENDPOINT_BUSY = "ENDPOINT_BUSY", - ENDPOINT_CONTROL_UNAVAILABLE = "ENDPOINT_CONTROL_UNAVAILABLE", - ENDPOINT_LOW_POWER = "ENDPOINT_LOW_POWER", - ENDPOINT_UNREACHABLE = "ENDPOINT_UNREACHABLE", - EXCEEDED_PIN_ATTEMPTS = "EXCEEDED_PIN_ATTEMPTS", - EXPIRED_AUTHORIZATION_CREDENTIAL = "EXPIRED_AUTHORIZATION_CREDENTIAL", - FAILED_TO_BOOTSTRAP_COMMISSIONING_PROCESS = "FAILED_TO_BOOTSTRAP_COMMISSIONING_PROCESS", - FIRMWARE_OUT_OF_DATE = "FIRMWARE_OUT_OF_DATE", - HARDWARE_MALFUNCTION = "HARDWARE_MALFUNCTION", - HEATING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE = "HEATING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE", - HEATING_STAGES_EXCEEDS_LIMIT = "HEATING_STAGES_EXCEEDS_LIMIT", - INSUFFICIENT_PERMISSIONS = "INSUFFICIENT_PERMISSIONS", - INSUFFICIENT_RESOURCE = "INSUFFICIENT_RESOURCE", - INSUFFICIENT_SPACE = "INSUFFICIENT_SPACE", - INTERNAL_ERROR = "INTERNAL_ERROR", - INVALID_AUTHORIZATION_CREDENTIAL = "INVALID_AUTHORIZATION_CREDENTIAL", - INVALID_AUXILIARY_HEATING_SYSTEM_TYPE = "INVALID_AUXILIARY_HEATING_SYSTEM_TYPE", - INVALID_DIRECTIVE = "INVALID_DIRECTIVE", - INVALID_SYSTEM_TYPE = "INVALID_SYSTEM_TYPE", - INVALID_TARGET_STATE = "INVALID_TARGET_STATE", - INVALID_TEMPERATURE_SCALE = "INVALID_TEMPERATURE_SCALE", - INVALID_TERMINAL_CONNECTION = "INVALID_TERMINAL_CONNECTION", - INVALID_VALUE = "INVALID_VALUE", - MAINTENANCE_REQUIRED = "MAINTENANCE_REQUIRED", - MAX_COMMISSIONING_LIMIT_REACHED = "MAX_COMMISSIONING_LIMIT_REACHED", - MISSING_SETUP_INFORMATION = "MISSING_SETUP_INFORMATION", - NO_SUCH_ENDPOINT = "NO_SUCH_ENDPOINT", - NOT_CALIBRATED = "NOT_CALIBRATED", - NOT_IN_OPERATION = "NOT_IN_OPERATION", - NOT_READY = "NOT_READY", - NOT_SUPPORTED_IN_CURRENT_MODE = "NOT_SUPPORTED_IN_CURRENT_MODE", - NOT_SUPPORTED_WITH_CURRENT_BATTERY_CHARGE_STATE = "NOT_SUPPORTED_WITH_CURRENT_BATTERY_CHARGE_STATE", - OBSTACLE_DETECTED = "OBSTACLE_DETECTED", - PARTNER_APPLICATION_REDIRECTION = "PARTNER_APPLICATION_REDIRECTION", - PIN_SETUP_REQUIRED = "PIN_SETUP_REQUIRED", - POWER_LEVEL_NOT_SUPPORTED = "POWER_LEVEL_NOT_SUPPORTED", - PREHEAT_REQUIRED = "PREHEAT_REQUIRED", - PROBE_REQUIRED = "PROBE_REQUIRED", - RATE_LIMIT_EXCEEDED = "RATE_LIMIT_EXCEEDED", - REMOTE_START_NOT_SUPPORTED = "REMOTE_START_NOT_SUPPORTED", - REMOVE_PROBE = "REMOVE_PROBE", - REMOTE_START_DISABLED = "REMOTE_START_DISABLED", - REQUESTED_SETPOINTS_TOO_CLOSE = "REQUESTED_SETPOINTS_TOO_CLOSE", - SAFETY_BEAM_BREACHED = "SAFETY_BEAM_BREACHED", - SUBSCRIPTION_REQUIRED = "SUBSCRIPTION_REQUIRED", - TEMPERATURE_VALUE_OUT_OF_RANGE = "TEMPERATURE_VALUE_OUT_OF_RANGE", - THERMOSTAT_IS_OFF = "THERMOSTAT_IS_OFF", - TOO_MANY_FAILED_ATTEMPTS = "TOO_MANY_FAILED_ATTEMPTS", - TRIPLE_SETPOINTS_UNSUPPORTED = "TRIPLE_SETPOINTS_UNSUPPORTED", - UNABLE_TO_CHARGE = "UNABLE_TO_CHARGE", - UNAUTHORIZED = "UNAUTHORIZED", - UNCLEARED_ALARM = "UNCLEARED_ALARM", - UNSUPPORTED_THERMOSTAT_MODE = "UNSUPPORTED_THERMOSTAT_MODE", - UNCLEARED_TROUBLE = "UNCLEARED_TROUBLE", - UNWILLING_TO_SET_SCHEDULE = "UNWILLING_TO_SET_SCHEDULE", - UNWILLING_TO_SET_VALUE = "UNWILLING_TO_SET_VALUE", - VALUE_OUT_OF_RANGE = "VALUE_OUT_OF_RANGE", -} - -export class AlexaErrorResponse { - private event: any; - private rootTopic: string; - private endpointId: string; - private mqttClient: MqttClient; - /** Where a failed publish is reported (set by the Device that built this message, 1.5.2): send() never rejects. */ - public onPublishError?: (err: Error) => void; - - constructor( - correlationToken: string, - rootTopic: string, - endpointId: string, - mqttClient: MqttClient - ) { - this.rootTopic = rootTopic; - this.endpointId = endpointId; - this.mqttClient = mqttClient; - - this.event = { - header: { - namespace: "Alexa", - name: "ErrorResponse", - messageId: randomUUID(), - correlationToken, - payloadVersion: "3", - }, - endpoint: { - endpointId, - }, - payload: {}, - }; - } - setErrorMessage( - type: string, - message: string, - otherParams: Record = {} - ) { - this.event.payload = { - type, - message, - ...otherParams, - }; - } - - toJSON(): { event: any } { - return { event: this.event }; - } - send(sendAsync = false): Promise { - const payload = { - event: this.event, - }; - 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); - return new Promise((resolve) => { - this.mqttClient.publish(topic, payloadStr, (err) => { - if (!err) return resolve(topic); - 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 - }); - }); - } -} diff --git a/src/AlexaStatusMessage.ts b/src/AlexaStatusMessage.ts deleted file mode 100644 index afc2c39..0000000 --- a/src/AlexaStatusMessage.ts +++ /dev/null @@ -1,298 +0,0 @@ -import { randomUUID } from "crypto"; -import { AlexaInterfaceType } from "./compat/enums.js"; -import type { EndpointHealth, PowerController } from "./compat/enums.js"; -import type { MqttClient } from "mqtt"; - -export enum ThermostatMode { - OFF = "OFF", - HEAT = "HEAT", - COOL = "COOL", - AUTO = "AUTO", - ECO = "ECO", - CUSTOM = "CUSTOM", -} - -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; - /** Where a failed publish is reported (set by the Device that built this message, 1.5.2): send() never rejects. */ - public onPublishError?: (err: Error) => void; - - 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: randomUUID(), - correlationToken, - }, - endpoint: { - endpointId, - }, - payload: {}, - }; - } - - 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 - ); - } - - /** Alexa.EndpointHealth connectivity: EndpointHealth.OK / UNREACHABLE (the plain strings "OK" / "UNREACHABLE" are accepted too). */ - public addHealthProp(health: EndpointHealth | `${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, or "" when the publish failed - 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). - */ - 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) => { - this.mqttClient.publish(topic, payloadStr, (err) => { - if (!err) return resolve(topic); - 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 - }); - }); - } -} diff --git a/src/compat/AlexaErrorResponse.ts b/src/compat/AlexaErrorResponse.ts new file mode 100644 index 0000000..b466662 --- /dev/null +++ b/src/compat/AlexaErrorResponse.ts @@ -0,0 +1,64 @@ +import { randomUUID } from "crypto"; +import type { MqttClient } from "mqtt"; +import { errorResponse } from "../messages/build.js"; +import { AlexaErrors } from "../messages/errors.js"; +import type { AlexaError } from "../messages/errors.js"; +import type { ErrorResponseMessage } from "../messages/types.js"; + +/** An ErrorResponse as 1.x builds it: device.getErrorMessage(token), setErrorMessage(), send(). */ +export class AlexaErrorResponse { + // Sent when setErrorMessage() was not called: 1.x published an empty payload, which is no ErrorResponse to Alexa + private error: AlexaError = AlexaErrors.of("INTERNAL_ERROR", "the device answered with an error and did not say which"); + private namespace?: string; + // One id for the message, however often toJSON() is called + private readonly messageId = randomUUID(); + /** Where a failed publish is reported (set by the Device that built this message, 1.5.2): send() never rejects. */ + public onPublishError?: (err: Error) => void; + + constructor( + private readonly correlationToken: string, + private readonly rootTopic: string, + private readonly endpointId: string, + private readonly mqttClient: MqttClient + ) {} + + /** + * The error to answer with. otherParams are the fields the type adds to the payload (validRange). The header + * carries the namespace the type is documented under, Alexa.ThermostatController for THERMOSTAT_IS_OFF; + * options.namespace names another. + */ + setErrorMessage( + type: string, + message: string, + otherParams: Record = {}, + options: { namespace?: string } = {} + ): void { + this.error = AlexaErrors.of(type, message, otherParams); + this.namespace = options.namespace; + } + + toJSON(): ErrorResponseMessage { + const { type, alexaMessage, extra } = this.error; + return errorResponse({ + endpointId: this.endpointId, + messageId: this.messageId, + correlationToken: this.correlationToken, + type, + message: alexaMessage, + extra, + namespace: this.namespace ?? this.error.namespace, + }); + } + + /** Resolves with the topic published to, or "" when the publish failed. Never rejects. */ + send(sendAsync = false): Promise { + const topic = `${this.rootTopic}/${this.endpointId}/${sendAsync ? "deferredResponse" : "alexaResponce"}`; // "alexaResponce" is how Alex2MQTT and Alex2ESP spell the topic + return new Promise((resolve) => { + this.mqttClient.publish(topic, JSON.stringify(this.toJSON()), (err) => { + if (!err) return resolve(topic); + 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 + }); + }); + } +} diff --git a/src/compat/AlexaStatusMessage.ts b/src/compat/AlexaStatusMessage.ts new file mode 100644 index 0000000..9c23f3b --- /dev/null +++ b/src/compat/AlexaStatusMessage.ts @@ -0,0 +1,159 @@ +import { randomUUID } from "crypto"; +import type { MqttClient } from "mqtt"; +import { changeReport, deferredResponse, response } from "../messages/build.js"; +import { StateBuilder } from "../messages/StateBuilder.js"; +import type { ChangeCause, Property } from "../messages/types.js"; +import { AlexaInterfaceType, TemperatureSensorScale } from "./enums.js"; +import type { EndpointHealth, PowerController } from "./enums.js"; + +// The 1.x helpers report every temperature in Celsius, whatever scale the device works in +function celsius(scale: TemperatureSensorScale, value: number): { value: number; scale: "CELSIUS" } { + 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 { + private readonly state: StateBuilder; + // One id for the message, however often toJSON() is called + private readonly messageId = randomUUID(); + private estimatedDeferralInSeconds?: number; + /** Where a failed publish is reported (set by the Device that built this message, 1.5.2): send() never rejects. */ + public onPublishError?: (err: Error) => void; + + constructor( + private readonly correlationToken: string, + private readonly rootTopic: string, + private readonly endpointId: string, + private readonly mqttClient: MqttClient, + private readonly isResponse: boolean = false, + private readonly isDeferred: boolean = false, + private readonly changeCause: ChangeCause | null = null + ) { + this.state = new StateBuilder({ target: changeCause ? "change" : "context" }); + } + + private addProperty( + namespace: AlexaInterfaceType, + name: string, + value: unknown, + uncertaintyInMilliseconds = 0, + instance?: string + ): this { + 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). */ + public changed(): this { + this.state.changed(); + return this; + } + /** ChangeReport: the add*Prop calls that follow describe the other, unchanged properties (context). */ + public unchanged(): this { + this.state.unchanged(); + 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). A DeferredResponse has no context. Throws + * MessageError for a ChangeReport without a changed property. + */ + public toJSON(): { event: any; context?: { properties: Property[] } } { + 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 }); + } + 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.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 { + 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). */ + public addHealthProp(health: EndpointHealth | `${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 { + return this.addProperty(AlexaInterfaceType.TEMPERATURE_SENSOR, "temperature", celsius(scale, value), 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); + } + + /** A property built by the caller. It goes to the context, also in a ChangeReport after changed(). */ + public addContextProp(prop: Property): this { + 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). + */ + public send(sendAsync: boolean = false): Promise { + 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: Error): void => { + 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: string; + try { + payload = JSON.stringify(this.toJSON()); + } catch (err) { + return failed(err as Error); + } + this.mqttClient.publish(topic, payload, (err) => (err ? failed(err) : resolve(topic))); + }); + } +} diff --git a/src/compat/enums.ts b/src/compat/enums.ts index 63238fc..405ce81 100644 --- a/src/compat/enums.ts +++ b/src/compat/enums.ts @@ -166,3 +166,98 @@ export type PowerController = PowerState; /** The Alexa.EndpointHealth interface, for device.add(). EndpointHealth.OK and EndpointHealth.UNREACHABLE are the 1.x enum of connectivity values. */ export const EndpointHealth = Object.assign(EndpointHealthInterface, Connectivity); export type EndpointHealth = (typeof Connectivity)[keyof typeof Connectivity]; + +/** The modes of a thermostat the 1.x helpers know. */ +export enum ThermostatMode { + OFF = "OFF", + HEAT = "HEAT", + COOL = "COOL", + AUTO = "AUTO", + ECO = "ECO", + CUSTOM = "CUSTOM", +} + +/** The scale of a temperature given to addTemperatureSensorProp() and addThermostatControllerProp(). */ +export enum TemperatureSensorScale { + CELSIUS = "CELSIUS", + FAHRENHEIT = "FAHRENHEIT", +} + +/** The error types of alexa-errorresponse.html, "Error type values". */ +export enum AlexaErrorType { + ALREADY_IN_OPERATION = "ALREADY_IN_OPERATION", + AUTHORIZATION_REQUIRED = "AUTHORIZATION_REQUIRED", + BRIDGE_UNREACHABLE = "BRIDGE_UNREACHABLE", + BYPASS_NEEDED = "BYPASS_NEEDED", + CLOUD_CONTROL_DISABLED = "CLOUD_CONTROL_DISABLED", + CHILD_LOCK = "CHILD_LOCK", + CONFIGURATION_UPDATE_NOT_ALLOWED = "CONFIGURATION_UPDATE_NOT_ALLOWED", + COOK_DURATION_TOO_LONG = "COOK_DURATION_TOO_LONG", + COOLING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE = "COOLING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE", + COOLING_STAGES_EXCEEDS_LIMIT = "COOLING_STAGES_EXCEEDS_LIMIT", + DATA_DELETION_NOT_SUPPORTED = "DATA_DELETION_NOT_SUPPORTED", + DATA_RETRIEVAL_NOT_SUPPORTED = "DATA_RETRIEVAL_NOT_SUPPORTED", + DEVICE_STUCK = "DEVICE_STUCK", + DISABLED_BY_USER = "DISABLED_BY_USER", + DO_NOT_DISTURB_MODE = "DO_NOT_DISTURB_MODE", + DOOR_CLOSED_TOO_LONG = "DOOR_CLOSED_TOO_LONG", + DOOR_OPEN = "DOOR_OPEN", + DUAL_SETPOINTS_UNSUPPORTED = "DUAL_SETPOINTS_UNSUPPORTED", + ENDPOINT_BUSY = "ENDPOINT_BUSY", + ENDPOINT_CONTROL_UNAVAILABLE = "ENDPOINT_CONTROL_UNAVAILABLE", + ENDPOINT_LOW_POWER = "ENDPOINT_LOW_POWER", + ENDPOINT_UNREACHABLE = "ENDPOINT_UNREACHABLE", + EXCEEDED_PIN_ATTEMPTS = "EXCEEDED_PIN_ATTEMPTS", + EXPIRED_AUTHORIZATION_CREDENTIAL = "EXPIRED_AUTHORIZATION_CREDENTIAL", + FAILED_TO_BOOTSTRAP_COMMISSIONING_PROCESS = "FAILED_TO_BOOTSTRAP_COMMISSIONING_PROCESS", + FIRMWARE_OUT_OF_DATE = "FIRMWARE_OUT_OF_DATE", + HARDWARE_MALFUNCTION = "HARDWARE_MALFUNCTION", + HEATING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE = "HEATING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE", + HEATING_STAGES_EXCEEDS_LIMIT = "HEATING_STAGES_EXCEEDS_LIMIT", + INSUFFICIENT_PERMISSIONS = "INSUFFICIENT_PERMISSIONS", + INSUFFICIENT_RESOURCE = "INSUFFICIENT_RESOURCE", + INSUFFICIENT_SPACE = "INSUFFICIENT_SPACE", + INTERNAL_ERROR = "INTERNAL_ERROR", + INVALID_AUTHORIZATION_CREDENTIAL = "INVALID_AUTHORIZATION_CREDENTIAL", + INVALID_AUXILIARY_HEATING_SYSTEM_TYPE = "INVALID_AUXILIARY_HEATING_SYSTEM_TYPE", + INVALID_DIRECTIVE = "INVALID_DIRECTIVE", + INVALID_SYSTEM_TYPE = "INVALID_SYSTEM_TYPE", + INVALID_TARGET_STATE = "INVALID_TARGET_STATE", + INVALID_TEMPERATURE_SCALE = "INVALID_TEMPERATURE_SCALE", + INVALID_TERMINAL_CONNECTION = "INVALID_TERMINAL_CONNECTION", + INVALID_VALUE = "INVALID_VALUE", + MAINTENANCE_REQUIRED = "MAINTENANCE_REQUIRED", + MAX_COMMISSIONING_LIMIT_REACHED = "MAX_COMMISSIONING_LIMIT_REACHED", + MISSING_SETUP_INFORMATION = "MISSING_SETUP_INFORMATION", + NO_SUCH_ENDPOINT = "NO_SUCH_ENDPOINT", + NOT_CALIBRATED = "NOT_CALIBRATED", + NOT_IN_OPERATION = "NOT_IN_OPERATION", + NOT_READY = "NOT_READY", + NOT_SUPPORTED_IN_CURRENT_MODE = "NOT_SUPPORTED_IN_CURRENT_MODE", + NOT_SUPPORTED_WITH_CURRENT_BATTERY_CHARGE_STATE = "NOT_SUPPORTED_WITH_CURRENT_BATTERY_CHARGE_STATE", + OBSTACLE_DETECTED = "OBSTACLE_DETECTED", + PARTNER_APPLICATION_REDIRECTION = "PARTNER_APPLICATION_REDIRECTION", + PIN_SETUP_REQUIRED = "PIN_SETUP_REQUIRED", + POWER_LEVEL_NOT_SUPPORTED = "POWER_LEVEL_NOT_SUPPORTED", + PREHEAT_REQUIRED = "PREHEAT_REQUIRED", + PROBE_REQUIRED = "PROBE_REQUIRED", + RATE_LIMIT_EXCEEDED = "RATE_LIMIT_EXCEEDED", + REMOTE_START_NOT_SUPPORTED = "REMOTE_START_NOT_SUPPORTED", + REMOVE_PROBE = "REMOVE_PROBE", + REMOTE_START_DISABLED = "REMOTE_START_DISABLED", + REQUESTED_SETPOINTS_TOO_CLOSE = "REQUESTED_SETPOINTS_TOO_CLOSE", + SAFETY_BEAM_BREACHED = "SAFETY_BEAM_BREACHED", + SUBSCRIPTION_REQUIRED = "SUBSCRIPTION_REQUIRED", + TEMPERATURE_VALUE_OUT_OF_RANGE = "TEMPERATURE_VALUE_OUT_OF_RANGE", + THERMOSTAT_IS_OFF = "THERMOSTAT_IS_OFF", + TOO_MANY_FAILED_ATTEMPTS = "TOO_MANY_FAILED_ATTEMPTS", + TRIPLE_SETPOINTS_UNSUPPORTED = "TRIPLE_SETPOINTS_UNSUPPORTED", + UNABLE_TO_CHARGE = "UNABLE_TO_CHARGE", + UNAUTHORIZED = "UNAUTHORIZED", + UNCLEARED_ALARM = "UNCLEARED_ALARM", + UNSUPPORTED_THERMOSTAT_MODE = "UNSUPPORTED_THERMOSTAT_MODE", + UNCLEARED_TROUBLE = "UNCLEARED_TROUBLE", + UNWILLING_TO_SET_SCHEDULE = "UNWILLING_TO_SET_SCHEDULE", + UNWILLING_TO_SET_VALUE = "UNWILLING_TO_SET_VALUE", + VALUE_OUT_OF_RANGE = "VALUE_OUT_OF_RANGE", +} diff --git a/src/device/Device.ts b/src/device/Device.ts index 84185ca..3a9cb26 100644 --- a/src/device/Device.ts +++ b/src/device/Device.ts @@ -1,12 +1,12 @@ import { EventEmitter } from "events"; -import { randomUUID } from "crypto"; import type { MqttClient } from "mqtt"; -import { AlexaErrorResponse } from "../AlexaErrorResponse.js"; -import { AlexaStatusMessage } from "../AlexaStatusMessage.js"; -import type { ChangeCause } from "../AlexaStatusMessage.js"; +import { AlexaErrorResponse } from "../compat/AlexaErrorResponse.js"; import { AlexaInterface } from "../compat/AlexaInterface.js"; +import { AlexaStatusMessage } from "../compat/AlexaStatusMessage.js"; import { DisplayCategory } from "../compat/enums.js"; import type { AlexaInterfaceType } from "../compat/enums.js"; +import { sceneEvent } from "../messages/build.js"; +import type { ChangeCause } from "../messages/types.js"; import type { DisplayCategoryName } from "../registry/catalog.js"; import { Alexa } from "../registry/interfaces/Alexa.js"; import { EndpointHealth } from "../registry/interfaces/EndpointHealth.js"; @@ -149,6 +149,7 @@ class Device extends EventEmitter { * A proactive Alexa.ChangeReport (1.5.1): add the changed properties (the default target), optionally * .unchanged() then the others, and .send() - it goes to /changeReport, which Alex2MQTT forwards to the Alexa * event gateway with the user's token. Alexa only accepts it for capabilities registered with proactivelyReported. + * Without a changed property send() publishes nothing and resolves with "": Alex2MQTT would drop the report. */ getChangeReport(cause: ChangeCause = "PHYSICAL_INTERACTION"): AlexaStatusMessage { const msg = new AlexaStatusMessage("", this.rootTopic, this.endpointId, this.mqttClient, false, false, cause); @@ -160,14 +161,7 @@ class Device extends EventEmitter { * (1.5.1). Resolves with the topic published to, or "" when the publish failed (never rejects, 1.5.2). */ sendSceneResponse(correlationToken: string, activated: boolean, cause: ChangeCause = "VOICE_INTERACTION", sendAsync = false): Promise { - const payload = { - context: {}, - event: { - header: { namespace: "Alexa.SceneController", name: activated ? "ActivationStarted" : "DeactivationStarted", messageId: randomUUID(), correlationToken, payloadVersion: "3" }, - endpoint: { endpointId: this.endpointId }, - payload: { cause: { type: cause }, timestamp: new Date().toISOString() }, - }, - }; + const payload = sceneEvent({ endpointId: this.endpointId, correlationToken, activated, cause }); const topic = `${this.rootTopic}/${this.endpointId}/${sendAsync ? "deferredResponse" : "alexaResponce"}`; return new Promise((resolve) => { this.mqttClient.publish(topic, JSON.stringify(payload), (err) => { diff --git a/src/index.ts b/src/index.ts index f090e3e..efe8fd3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,11 +26,20 @@ export type { ActionName, StateName, Mode, Infer, Schema, Temperature, TimeInterval, } from "./registry/index.js"; +// The messages: what the bridge publishes, built from plain values +export * as messages from "./messages/index.js"; +export { AlexaError, AlexaErrors, MessageError, StateBuilder, property } from "./messages/index.js"; +export type { + ChangeCause, ChangeReportMessage, DeferredResponseMessage, ErrorResponseMessage, Header, ProactiveEventMessage, + Property, PropertyOptions, ResponseMessage, SceneEventMessage, +} from "./messages/index.js"; + // 1.x export { AlexaInterface } from "./compat/AlexaInterface.js"; export type { SupportedMode } from "./compat/AlexaInterface.js"; export { ActionMapping } from "./compat/ActionMapping.js"; -export { AlexaActions, AlexaInterfaceType, DisplayCategory, PowerState } from "./compat/enums.js"; -export { AlexaStatusMessage, TemperatureSensorScale, ThermostatMode } from "./AlexaStatusMessage.js"; -export type { ChangeCause } from "./AlexaStatusMessage.js"; -export { AlexaErrorType, AlexaErrorResponse } from "./AlexaErrorResponse.js"; +export { + AlexaActions, AlexaErrorType, AlexaInterfaceType, DisplayCategory, PowerState, TemperatureSensorScale, ThermostatMode, +} from "./compat/enums.js"; +export { AlexaStatusMessage } from "./compat/AlexaStatusMessage.js"; +export { AlexaErrorResponse } from "./compat/AlexaErrorResponse.js"; diff --git a/src/messages/StateBuilder.ts b/src/messages/StateBuilder.ts new file mode 100644 index 0000000..7e8d277 --- /dev/null +++ b/src/messages/StateBuilder.ts @@ -0,0 +1,103 @@ +import { EndpointHealth, CONNECTIVITY_REASONS } from "../registry/interfaces/EndpointHealth.js"; +import { SchemaError } from "../registry/schema.js"; +import type { Infer } from "../registry/schema.js"; +import type { InterfaceDescriptor, Properties } from "../registry/types.js"; +import { property } from "./property.js"; +import type { PropertyOptions } from "./property.js"; +import type { Property } from "./types.js"; + +/** Where a property goes in a ChangeReport: with the ones that changed, or with the rest in the context. */ +export type Target = "context" | "change"; + +/** A capability of a device: its properties are reported under its instance. */ +interface DeclaredCapability

{ + readonly descriptor: InterfaceDescriptor; + readonly instance: string; +} + +export interface StateBuilderOptions { + /** Where the properties go until changed() or unchanged() says otherwise. Default: "context". */ + target?: Target; + /** The time of a property that is given none. Default: the clock. */ + now?: () => Date; +} + +/** + * Collects the properties of one message. For a Response or a StateReport they are its context. For a ChangeReport + * changed() and unchanged() say which of the two lists the properties that follow belong to. + */ +export class StateBuilder { + private readonly lists: Record = { context: [], change: [] }; + private target: Target; + private readonly now: () => Date; + + constructor(options: StateBuilderOptions = {}) { + this.target = options.target ?? "context"; + this.now = options.now ?? (() => new Date()); + } + + /** + * A property of an interface the library describes, its value checked: set(PowerController, "powerState", "ON"). + * Given a capability of a device in place of the interface, the property carries the instance of the capability. + * Throws SchemaError for a value Alexa would not take. + */ + set

( + source: InterfaceDescriptor | DeclaredCapability

, + name: K, + value: Infer, + options: PropertyOptions = {} + ): this { + const descriptor = "descriptor" in source ? source.descriptor : source; + const instance = options.instance ?? ("descriptor" in source ? source.instance : ""); + const where = `${descriptor.namespace}${instance ? ` "${instance}"` : ""}: ${name}`; + const described = descriptor.properties[name]; + if (!described) { + const names = Object.keys(descriptor.properties); + throw new SchemaError(where, names.length > 0 + ? `not a property of the interface, which has ${names.join(", ")}` + : "the library knows no property of the interface, report it with setRaw()"); + } + if (descriptor.instanced && !instance) { + throw new SchemaError(where, "the interface has instances, pass the one that is reported: { instance: \"Blind.Lift\" }"); + } + return this.setRaw(descriptor.namespace, described.name, described.value.parse(value, where), { ...options, instance }); + } + + /** A property as given, nothing checked: for an interface or a value the library does not describe. */ + setRaw(namespace: string, name: string, value: unknown, options: PropertyOptions = {}): this { + return this.add(property(namespace, name, value, { ...options, timeOfSample: options.timeOfSample ?? this.now() })); + } + + /** The connectivity of Alexa.EndpointHealth, which belongs in every report of an endpoint that declares it. */ + health(value: "OK" | "UNREACHABLE", reason?: (typeof CONNECTIVITY_REASONS)[number], options: PropertyOptions = {}): this { + return this.set(EndpointHealth, "connectivity", reason ? { value, reason } : { value }, options); + } + + /** A property built elsewhere, to the list that is filled now or to the one named. */ + add(built: Property, target: Target = this.target): this { + this.lists[target].push(built); + return this; + } + + /** The properties that follow are the ones that changed. */ + changed(): this { + this.target = "change"; + return this; + } + + /** The properties that follow did not change: a ChangeReport lists them in its context. */ + unchanged(): this { + this.target = "context"; + return this; + } + + /** The properties for the context of the message, in the order they were set. */ + get context(): Property[] { + return [...this.lists.context]; + } + + /** The properties for payload.change of a ChangeReport. */ + get change(): Property[] { + return [...this.lists.change]; + } +} diff --git a/src/messages/build.ts b/src/messages/build.ts new file mode 100644 index 0000000..5ce732a --- /dev/null +++ b/src/messages/build.ts @@ -0,0 +1,214 @@ +// Every message the bridge publishes, built from plain values. Nothing here reads the clock or makes an id unless +// the caller leaves messageId or a time out, so a test that passes both compares whole objects. +import { randomUUID } from "crypto"; +import { errorNamespace } from "./errors.js"; +import { isoTime } from "./property.js"; +import type { + ChangeCause, ChangeReportMessage, DeferredResponseMessage, ErrorResponseMessage, Header, ProactiveEventMessage, + Property, ResponseMessage, SceneEventMessage, +} from "./types.js"; + +/** A message that would be dropped on its way to Alexa, refused where it is built. */ +export class MessageError extends Error { + constructor( + readonly endpointId: string, + readonly problem: string + ) { + super(`${endpointId}: ${problem}`); + this.name = "MessageError"; + } +} + +interface Envelope { + endpointId: string; + /** Default: a new version 4 UUID, which is what Alexa recommends (message-guide.html, "Header object"). */ + messageId?: string; +} + +interface Answer extends Envelope { + /** The correlationToken of the directive that is answered. */ + correlationToken: string; +} + +// The fields in the order of the examples +function header( + namespace: string, + name: string, + fields: { messageId?: string; correlationToken?: string; instance?: string; payloadVersion?: string } +): Header { + return { + namespace, + name, + ...(fields.instance ? { instance: fields.instance } : {}), + messageId: fields.messageId ?? randomUUID(), + ...(fields.correlationToken !== undefined ? { correlationToken: fields.correlationToken } : {}), + payloadVersion: fields.payloadVersion ?? "3", + }; +} + +export interface ResponseFields extends Answer { + /** Default: "Response". */ + name?: string; + /** Default: "Alexa". An interface with a response of its own names it: Alexa.SecurityPanelController, Arm.Response. */ + namespace?: string; + payload?: Record; + /** The state of the endpoint after the directive. */ + context?: readonly Property[]; +} + +/** + * The answer to a directive (alexa-response.html, "Synchronous response"). An endpoint with nothing to report + * answers with an empty list of properties, not without a context (state-reporting-for-smart-home-addons.html, + * "Directive response example for a property that isn't retrievable"). + */ +export function response(fields: ResponseFields): ResponseMessage { + return { + event: { + header: header(fields.namespace ?? "Alexa", fields.name ?? "Response", fields), + endpoint: { endpointId: fields.endpointId }, + payload: fields.payload ?? {}, + }, + context: { properties: [...(fields.context ?? [])] }, + }; +} + +/** The answer to ReportState: every retrievable property of the endpoint. */ +export function stateReport(fields: Answer & { context?: readonly Property[] }): ResponseMessage { + return response({ ...fields, name: "StateReport", namespace: "Alexa", payload: {} }); +} + +/** + * "The directive arrived, the answer follows": no context, the state is in the Response that follows + * (alexa-response.html, "Deferred response example"). + */ +export function deferredResponse(fields: Answer & { estimatedDeferralInSeconds?: number }): DeferredResponseMessage { + const { estimatedDeferralInSeconds } = fields; + return { + event: { + header: header("Alexa", "DeferredResponse", fields), + endpoint: { endpointId: fields.endpointId }, + payload: estimatedDeferralInSeconds === undefined ? {} : { estimatedDeferralInSeconds }, + }, + }; +} + +export interface ErrorResponseFields extends Answer { + /** "ENDPOINT_UNREACHABLE" */ + type: string; + /** For the log of the skill. */ + message: string; + /** The fields a type adds to the payload: validRange, currentDeviceMode. */ + extra?: Record; + /** Default: the namespace the type is documented under, see errorNamespace(). */ + namespace?: string; +} + +/** The answer to a directive the endpoint could not follow (alexa-errorresponse.html). */ +export function errorResponse(fields: ErrorResponseFields): ErrorResponseMessage { + return { + event: { + header: header(fields.namespace ?? errorNamespace(fields.type), "ErrorResponse", fields), + endpoint: { endpointId: fields.endpointId }, + payload: { type: fields.type, message: fields.message, ...fields.extra }, + }, + }; +} + +export interface ChangeReportFields extends Envelope { + /** Default: "PHYSICAL_INTERACTION". */ + cause?: ChangeCause; + /** The properties that changed, at least one. */ + changed: readonly Property[]; + /** The other properties of the endpoint, as they are now. */ + context?: readonly Property[]; +} + +const sameProperty = (a: Property, b: Property): boolean => + a.namespace === b.namespace && a.name === b.name && (a.instance ?? "") === (b.instance ?? ""); + +/** + * A change of state nobody asked for. The header has no correlationToken (message-guide.html, "Header object"). + * A property that changed is left out of the context: it is reported in one of the two (same page, "Context + * object"). Throws MessageError when nothing changed: Alex2MQTT drops such a report without a word. + */ +export function changeReport(fields: ChangeReportFields): ChangeReportMessage { + const { endpointId, changed, context = [] } = fields; + if (changed.length === 0) { + throw new MessageError( + endpointId, + "a ChangeReport needs at least one property that changed, this one has none. " + + "Add the changed property before unchanged(), or send no report when nothing changed" + ); + } + return { + event: { + header: header("Alexa", "ChangeReport", { messageId: fields.messageId }), + endpoint: { endpointId }, + payload: { change: { cause: { type: fields.cause ?? "PHYSICAL_INTERACTION" }, properties: [...changed] } }, + }, + context: { properties: context.filter((property) => !changed.some((other) => sameProperty(property, other))) }, + }; +} + +export interface SceneEventFields extends Answer { + /** true: ActivationStarted. false: DeactivationStarted. */ + activated: boolean; + /** Default: "VOICE_INTERACTION", a scene is started by a directive. */ + cause?: ChangeCause; + /** When the scene started. Default: now. */ + timestamp?: string | Date; +} + +/** The answer to Activate and Deactivate of a scene (alexa-scenecontroller.html). */ +export function sceneEvent(fields: SceneEventFields): SceneEventMessage { + return { + event: { + header: header("Alexa.SceneController", fields.activated ? "ActivationStarted" : "DeactivationStarted", fields), + endpoint: { endpointId: fields.endpointId }, + payload: { cause: { type: fields.cause ?? "VOICE_INTERACTION" }, timestamp: isoTime(fields.timestamp) }, + }, + context: {}, + }; +} + +export interface DoorbellPressFields extends Envelope { + /** Default: "PHYSICAL_INTERACTION", somebody pressed the button. */ + cause?: ChangeCause; + /** When the button was pressed. Default: now. */ + timestamp?: string | Date; +} + +/** Somebody rang (alexa-doorbelleventsource.html). */ +export function doorbellPress(fields: DoorbellPressFields): ProactiveEventMessage { + return { + event: { + header: header("Alexa.DoorbellEventSource", "DoorbellPress", { messageId: fields.messageId }), + endpoint: { endpointId: fields.endpointId }, + payload: { cause: { type: fields.cause ?? "PHYSICAL_INTERACTION" }, timestamp: isoTime(fields.timestamp) }, + }, + }; +} + +export interface SimpleEventFields extends Envelope { + /** The instance of Alexa.SimpleEventSource that raises the event. */ + instance: string; + /** The id of the event as discovery listed it: "Button.SinglePush.1". */ + id: string; + /** When the event happened. Default: now. */ + timestamp?: string | Date; +} + +/** An event of a button or a sensor that routines start on (alexa-simpleeventsource.html). */ +export function simpleEvent(fields: SimpleEventFields): ProactiveEventMessage { + return { + event: { + header: header("Alexa.SimpleEventSource", "Event", { + instance: fields.instance, + messageId: fields.messageId, + payloadVersion: "1.0", + }), + endpoint: { endpointId: fields.endpointId }, + payload: { id: fields.id, timestamp: isoTime(fields.timestamp) }, + }, + }; +} diff --git a/src/messages/errors.ts b/src/messages/errors.ts new file mode 100644 index 0000000..bc5d901 --- /dev/null +++ b/src/messages/errors.ts @@ -0,0 +1,105 @@ +// The errors a device answers a directive with (alexa-errorresponse.html). A helper for each type whose payload +// has fields of its own, so that the fields cannot be misspelt. +import { ERROR_TYPES } from "../registry/catalog.js"; +import type { Temperature } from "../registry/schema.js"; + +/** + * The namespace an ErrorResponse of this type goes under: the one the table of error types lists it with + * (THERMOSTAT_IS_OFF: Alexa.ThermostatController, OBSTACLE_DETECTED: Alexa.Safety), Alexa for a type the table + * does not have. + */ +export function errorNamespace(type: string): string { + return Object.prototype.hasOwnProperty.call(ERROR_TYPES, type) ? ERROR_TYPES[type] : "Alexa"; +} + +/** Thrown by a directive handler, or built to be sent: the directive is answered with this ErrorResponse. */ +export class AlexaError extends Error { + /** "ENDPOINT_UNREACHABLE" */ + readonly type: string; + /** payload.message: for the log of the skill, Alexa does not read it to the user. */ + readonly alexaMessage: string; + /** The fields of the payload next to type and message. */ + readonly extra: Record; + /** The namespace of the header. */ + readonly namespace: string; + + constructor(type: string, message: string, extra: Record = {}, namespace: string = errorNamespace(type)) { + super(`${type}: ${message}`); + this.name = "AlexaError"; + this.type = type; + this.alexaMessage = message; + this.extra = extra; + this.namespace = namespace; + } +} + +export type CurrentDeviceMode = "ASLEEP" | "COLOR" | "DEMAND_RESPONSE" | "ECO" | "NOT_PROVISIONED" | "VACATION" | "OTHER"; +export type ControlUnavailableReason = "DEEP_SLEEP_MODE" | "OUT_OF_NETWORK_CONNECTIVITY" | "NO_CONNECTIVITY_PACKAGE_ENABLED" | "UNKNOWN"; +export type ChargeState = "ALREADY_CHARGED_TO_REQUIRED_LEVEL" | "CURRENTLY_CHARGING" | "FULLY_CHARGED" | "NOT_CONNECTED_TO_POWER"; + +// A field that was not given is left out of the payload +function given(fields: Record): Record { + return Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined)); +} + +export const AlexaErrors = { + /** An error of any type. */ + of(type: string, message: string, extra: Record = {}): AlexaError { + return new AlexaError(type, message, extra); + }, + + /** The value is outside what the endpoint takes. For a temperature: temperatureOutOfRange(). */ + valueOutOfRange(message: string, validRange: { minimumValue: number; maximumValue: number }): AlexaError { + return new AlexaError("VALUE_OUT_OF_RANGE", message, { validRange }); + }, + + temperatureOutOfRange(message: string, validRange: { minimumValue: Temperature; maximumValue: Temperature }): AlexaError { + return new AlexaError("TEMPERATURE_VALUE_OUT_OF_RANGE", message, { validRange }); + }, + + /** A light showing a color asked for a color temperature: "COLOR". */ + notSupportedInCurrentMode(message: string, currentDeviceMode: CurrentDeviceMode): AlexaError { + return new AlexaError("NOT_SUPPORTED_IN_CURRENT_MODE", message, { currentDeviceMode }); + }, + + /** percentageState: what is left of the battery, 0 to 100. */ + endpointLowPower(message: string, percentageState?: number): AlexaError { + return new AlexaError("ENDPOINT_LOW_POWER", message, given({ percentageState })); + }, + + endpointControlUnavailable(message: string, reason: ControlUnavailableReason): AlexaError { + return new AlexaError("ENDPOINT_CONTROL_UNAVAILABLE", message, { reason }); + }, + + notSupportedWithCurrentBatteryChargeState( + message: string, + currentChargeState: ChargeState, + currentChargeLevelInPercentage?: number + ): AlexaError { + return new AlexaError( + "NOT_SUPPORTED_WITH_CURRENT_BATTERY_CHARGE_STATE", + message, + given({ currentChargeState, currentChargeLevelInPercentage }) + ); + }, + + /** What has to be refilled. */ + insufficientResource(message: string, resourceType: "WATER"): AlexaError { + return new AlexaError("INSUFFICIENT_RESOURCE", message, { resourceType }); + }, + + /** What the user has to do first. */ + maintenanceRequired(message: string, maintenanceAction?: "EMPTY_BIN"): AlexaError { + return new AlexaError("MAINTENANCE_REQUIRED", message, given({ maintenanceAction })); + }, + + /** Goes under Alexa.ThermostatController. minimumTemperatureDelta: how far apart the setpoints have to be. */ + setpointsTooClose(message: string, minimumTemperatureDelta?: Temperature): AlexaError { + return new AlexaError("REQUESTED_SETPOINTS_TOO_CLOSE", message, given({ minimumTemperatureDelta })); + }, + + /** Goes under Alexa.SecurityPanelController. With the endpoints listed, the user can bypass them by voice. */ + bypassNeeded(message: string, endpointsNeedingBypass?: Array<{ friendlyName: string; endpointId?: string }>): AlexaError { + return new AlexaError("BYPASS_NEEDED", message, given({ endpointsNeedingBypass })); + }, +}; diff --git a/src/messages/index.ts b/src/messages/index.ts new file mode 100644 index 0000000..09e8c82 --- /dev/null +++ b/src/messages/index.ts @@ -0,0 +1,18 @@ +// The messages of the bridge without the bridge: builders, the state collector and the errors. +export { + changeReport, deferredResponse, doorbellPress, errorResponse, response, sceneEvent, simpleEvent, stateReport, + MessageError, +} from "./build.js"; +export type { + ChangeReportFields, DoorbellPressFields, ErrorResponseFields, ResponseFields, SceneEventFields, SimpleEventFields, +} from "./build.js"; +export { AlexaError, AlexaErrors, errorNamespace } from "./errors.js"; +export type { ChargeState, ControlUnavailableReason, CurrentDeviceMode } from "./errors.js"; +export { property } from "./property.js"; +export type { PropertyOptions } from "./property.js"; +export { StateBuilder } from "./StateBuilder.js"; +export type { StateBuilderOptions, Target } from "./StateBuilder.js"; +export type { + ChangeCause, ChangeReportMessage, Context, DeferredResponseMessage, Endpoint, ErrorResponseMessage, Event, Header, + ProactiveEventMessage, Property, ResponseMessage, SceneEventMessage, +} from "./types.js"; diff --git a/src/messages/property.ts b/src/messages/property.ts new file mode 100644 index 0000000..c035cfb --- /dev/null +++ b/src/messages/property.ts @@ -0,0 +1,30 @@ +import type { Property } from "./types.js"; + +export interface PropertyOptions { + /** The instance of a generic controller: "Blind.Lift". */ + instance?: string; + /** + * When the value last changed. Default: now. message-guide.html, "Property object": "don't set the same + * timeOfSample value for all properties", so a device that knows when each value changed passes it. + */ + timeOfSample?: string | Date; + /** How old the value may be, for a device that is polled. Default: 0. */ + uncertaintyInMilliseconds?: number; +} + +/** A time as Alexa wants it: ISO 8601 in UTC. Text is taken as given. */ +export function isoTime(time: string | Date = new Date()): string { + return typeof time === "string" ? time : time.toISOString(); +} + +/** One property of a context or of a change, its fields in the order of the examples. */ +export function property(namespace: string, name: string, value: unknown, options: PropertyOptions = {}): Property { + return { + namespace, + ...(options.instance ? { instance: options.instance } : {}), + name, + value, + timeOfSample: isoTime(options.timeOfSample), + uncertaintyInMilliseconds: options.uncertaintyInMilliseconds ?? 0, + }; +} diff --git a/src/messages/types.ts b/src/messages/types.ts new file mode 100644 index 0000000..a38a705 --- /dev/null +++ b/src/messages/types.ts @@ -0,0 +1,74 @@ +// The messages the bridge publishes, as message-guide.html and alexa-response.html draw them. + +/** + * Why a property changed or an event was raised (message-guide.html, "Cause object"). RULE_TRIGGER is not in that + * table; the ChangeReport examples of alexa-securitypanelcontroller.html and alexa-thermostatcontroller.html use it. + */ +export type ChangeCause = "APP_INTERACTION" | "PERIODIC_POLL" | "PHYSICAL_INTERACTION" | "RULE_TRIGGER" | "VOICE_INTERACTION"; + +export interface Header { + namespace: string; + name: string; + /** Alexa.SimpleEventSource: the instance that raised the event. */ + instance?: string; + messageId: string; + /** The token of the directive that is answered. An event nobody asked for has none. */ + correlationToken?: string; + payloadVersion: string; +} + +export interface Endpoint { + endpointId: string; +} + +/** One reported property (message-guide.html, "Property object"). */ +export interface Property { + namespace: string; + /** The instance of a generic controller. */ + instance?: string; + name: string; + value: unknown; + /** When the value was sampled or last changed, ISO 8601 in UTC. */ + timeOfSample: string; + uncertaintyInMilliseconds: number; +} + +export interface Event

> { + header: Header; + endpoint: Endpoint; + payload: P; +} + +export interface Context { + properties: Property[]; +} + +/** A Response, a StateReport or the response of an interface that has its own (Arm.Response). */ +export interface ResponseMessage { + event: Event; + context: Context; +} + +export interface DeferredResponseMessage { + event: Event<{ estimatedDeferralInSeconds?: number }>; +} + +export interface ErrorResponseMessage { + event: Event<{ type: string; message: string } & Record>; +} + +export interface ChangeReportMessage { + event: Event<{ change: { cause: { type: ChangeCause }; properties: Property[] } }>; + context: Context; +} + +/** ActivationStarted or DeactivationStarted of a scene. */ +export interface SceneEventMessage { + event: Event<{ cause: { type: ChangeCause }; timestamp: string }>; + context: {}; +} + +/** An event a device raises by itself: DoorbellPress, the Event of Alexa.SimpleEventSource. */ +export interface ProactiveEventMessage { + event: Event; +} diff --git a/test/compat/messages.test.js b/test/compat/messages.test.js new file mode 100644 index 0000000..3cc03f0 --- /dev/null +++ b/test/compat/messages.test.js @@ -0,0 +1,191 @@ +"use strict"; +// AlexaStatusMessage and AlexaErrorResponse, the message classes of 1.x: the JSON of 1.5.2, and the four places +// where 2.0 differs from it on the wire (DESIGN.md, 6.1). +const { test } = require("node:test"); +const assert = require("node:assert/strict"); +const { + Alex2MQTT, AlexaErrorResponse, AlexaErrorType, AlexaInterfaceType, AlexaStatusMessage, DisplayCategory, EndpointHealth, + PowerController, TemperatureSensorScale, +} = require("alex2node"); +const { setup, sleep, UUID_V4 } = require("../helpers/harness.js"); + +const ISO_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; + +// The message with its id and its times checked and replaced by their names, so that the rest compares as a whole +function settled(message) { + const json = JSON.parse(JSON.stringify(message)); + assert.match(json.event.header.messageId, UUID_V4); + json.event.header.messageId = ""; + const lists = [json.context?.properties, json.event.payload.change?.properties].filter(Boolean); + for (const property of lists.flat()) { + assert.match(property.timeOfSample, ISO_TIME); + property.timeOfSample = "