From c492ef74d13f3ca8168d04a724013104b9cd7a77 Mon Sep 17 00:00:00 2001 From: David Date: Mon, 28 Sep 2026 15:35:46 +0000 Subject: [PATCH] discovery: generate capability JSON from the registry A capability is a descriptor plus what the endpoint declares (device/Capability.ts), and its discovery object is generated from the two. The new API is bridge.addDevice({ endpointId, name, categories, ... }) and device.add(PowerController, options): both throw a DeclarationError that names the endpoint, the interface and the instance (device/validate.ts), and leave the bridge and the device as they were. AlexaInterface is the same Capability with the 1.x methods on it; it, ActionMapping and the enums moved to src/compat/, Device to src/device/. What a 1.x caller can observe: - every endpoint ends with { type: "AlexaInterface", interface: "Alexa", version: "3" } (alexa-interface.html); new Alex2MQTT(..., { alexaInterface: false }) leaves it out - the fields of a capability object come in the order of Amazon's examples; their content is unchanged - addCapability() with a name that is not an interface throws (1.5.2 announced it with the version "UNKNOWN") - ActionMapping takes the payload as an object; a JSON string is parsed (1.5.2 sent the string), any other throws - what Alexa would reject in a 1.x declaration is not refused: device.check() lists it and the bridge logs each line once, as "warning: ..." through the log hook, when it answers a discovery - a device whose JSON cannot be built is left out of the answer and reported as an error event - PowerController and EndpointHealth are the descriptors and keep ON/OFF and OK/UNREACHABLE; PowerState is new Tests: six zoo devices declared the 1.x way give the JSON that Alexa accepted from 1.5.2 on 2026-09-28, plus the Alexa capability. npm test: 85 pass (was 57) in 10-12 s, also on Node 18.20.8 and 20.20.2. Co-Authored-By: Claude Fable 5.1 --- dist/cjs/ActionMapping.js | 32 - dist/cjs/Alex2Node.js | 83 +- dist/cjs/AlexaStatusMessage.js | 32 +- dist/cjs/Device.js | 145 --- dist/cjs/DisplayCategory.js | 64 - dist/cjs/compat/ActionMapping.js | 49 + dist/cjs/compat/AlexaInterface.js | 61 + .../{AlexaInterface.js => compat/enums.js} | 182 ++- dist/cjs/device/Capability.js | 67 + dist/cjs/device/Device.js | 270 ++++ dist/cjs/device/validate.js | 128 ++ dist/cjs/index.js | 63 +- dist/cjs/registry/interfaces/stubs.js | 15 + dist/cjs/registry/resources.js | 45 + dist/cjs/registry/schema.js | 17 +- dist/esm/ActionMapping.d.ts | 20 - dist/esm/ActionMapping.js | 28 - dist/esm/Alex2Node.d.ts | 26 +- dist/esm/Alex2Node.js | 83 +- dist/esm/AlexaInterface.d.ts | 109 -- dist/esm/AlexaStatusMessage.d.ts | 9 +- dist/esm/AlexaStatusMessage.js | 12 +- dist/esm/Device.d.ts | 62 - dist/esm/Device.js | 143 --- dist/esm/DisplayCategory.d.ts | 60 - dist/esm/DisplayCategory.js | 61 - dist/esm/compat/ActionMapping.d.ts | 23 + dist/esm/compat/ActionMapping.js | 45 + dist/esm/compat/AlexaInterface.d.ts | 49 + dist/esm/compat/AlexaInterface.js | 57 + dist/esm/compat/enums.d.ts | 193 +++ .../{AlexaInterface.js => compat/enums.js} | 179 ++- dist/esm/device/Capability.d.ts | 79 ++ dist/esm/device/Capability.js | 63 + dist/esm/device/Device.d.ts | 135 ++ dist/esm/device/Device.js | 268 ++++ dist/esm/device/validate.d.ts | 20 + dist/esm/device/validate.js | 123 ++ dist/esm/index.d.ts | 24 +- dist/esm/index.js | 20 +- dist/esm/registry/index.d.ts | 2 +- dist/esm/registry/interfaces/stubs.js | 15 + dist/esm/registry/resources.d.ts | 13 + dist/esm/registry/resources.js | 39 + dist/esm/registry/schema.d.ts | 7 + dist/esm/registry/schema.js | 13 +- dist/esm/registry/types.d.ts | 33 +- dist/types/ActionMapping.d.ts | 20 - dist/types/Alex2Node.d.ts | 26 +- dist/types/AlexaInterface.d.ts | 109 -- dist/types/AlexaStatusMessage.d.ts | 9 +- dist/types/Device.d.ts | 62 - dist/types/DisplayCategory.d.ts | 60 - dist/types/compat/ActionMapping.d.ts | 23 + dist/types/compat/AlexaInterface.d.ts | 49 + dist/types/compat/enums.d.ts | 193 +++ dist/types/device/Capability.d.ts | 79 ++ dist/types/device/Device.d.ts | 135 ++ dist/types/device/validate.d.ts | 20 + dist/types/index.d.ts | 24 +- dist/types/registry/index.d.ts | 2 +- dist/types/registry/resources.d.ts | 13 + dist/types/registry/schema.d.ts | 7 + dist/types/registry/types.d.ts | 33 +- src/ActionMapping.ts | 41 - src/Alex2Node.ts | 98 +- src/AlexaStatusMessage.ts | 13 +- src/Device.ts | 182 --- src/DisplayCategory.ts | 60 - src/compat/ActionMapping.ts | 52 + src/compat/AlexaInterface.ts | 82 ++ src/{AlexaInterface.ts => compat/enums.ts} | 189 ++- src/device/Capability.ts | 116 ++ src/device/Device.ts | 348 ++++++ src/device/validate.ts | 135 ++ src/index.ts | 33 +- src/registry/index.ts | 4 +- src/registry/interfaces/stubs.ts | 16 + src/registry/resources.ts | 47 + src/registry/schema.ts | 14 +- src/registry/types.ts | 31 +- test/compat/capabilities.test.js | 108 ++ test/device/discovery.test.js | 253 ++++ test/device/validate.test.js | 148 +++ test/fixtures/interfaces.js | 25 +- test/fixtures/types.ts | 50 +- test/fixtures/zoo/discovery.json | 1098 +++++++++++++++++ test/helpers/endpoint.js | 10 + test/helpers/fixtures.js | 14 +- test/packaging.test.mjs | 2 +- 90 files changed, 5539 insertions(+), 1760 deletions(-) delete mode 100644 dist/cjs/ActionMapping.js delete mode 100644 dist/cjs/Device.js delete mode 100644 dist/cjs/DisplayCategory.js create mode 100644 dist/cjs/compat/ActionMapping.js create mode 100644 dist/cjs/compat/AlexaInterface.js rename dist/cjs/{AlexaInterface.js => compat/enums.js} (53%) create mode 100644 dist/cjs/device/Capability.js create mode 100644 dist/cjs/device/Device.js create mode 100644 dist/cjs/device/validate.js create mode 100644 dist/cjs/registry/resources.js delete mode 100644 dist/esm/ActionMapping.d.ts delete mode 100644 dist/esm/ActionMapping.js delete mode 100644 dist/esm/AlexaInterface.d.ts delete mode 100644 dist/esm/Device.d.ts delete mode 100644 dist/esm/Device.js delete mode 100644 dist/esm/DisplayCategory.d.ts delete mode 100644 dist/esm/DisplayCategory.js create mode 100644 dist/esm/compat/ActionMapping.d.ts create mode 100644 dist/esm/compat/ActionMapping.js create mode 100644 dist/esm/compat/AlexaInterface.d.ts create mode 100644 dist/esm/compat/AlexaInterface.js create mode 100644 dist/esm/compat/enums.d.ts rename dist/esm/{AlexaInterface.js => compat/enums.js} (53%) create mode 100644 dist/esm/device/Capability.d.ts create mode 100644 dist/esm/device/Capability.js create mode 100644 dist/esm/device/Device.d.ts create mode 100644 dist/esm/device/Device.js create mode 100644 dist/esm/device/validate.d.ts create mode 100644 dist/esm/device/validate.js create mode 100644 dist/esm/registry/resources.d.ts create mode 100644 dist/esm/registry/resources.js delete mode 100644 dist/types/ActionMapping.d.ts delete mode 100644 dist/types/AlexaInterface.d.ts delete mode 100644 dist/types/Device.d.ts delete mode 100644 dist/types/DisplayCategory.d.ts create mode 100644 dist/types/compat/ActionMapping.d.ts create mode 100644 dist/types/compat/AlexaInterface.d.ts create mode 100644 dist/types/compat/enums.d.ts create mode 100644 dist/types/device/Capability.d.ts create mode 100644 dist/types/device/Device.d.ts create mode 100644 dist/types/device/validate.d.ts create mode 100644 dist/types/registry/resources.d.ts delete mode 100644 src/ActionMapping.ts delete mode 100644 src/Device.ts delete mode 100644 src/DisplayCategory.ts create mode 100644 src/compat/ActionMapping.ts create mode 100644 src/compat/AlexaInterface.ts rename src/{AlexaInterface.ts => compat/enums.ts} (51%) create mode 100644 src/device/Capability.ts create mode 100644 src/device/Device.ts create mode 100644 src/device/validate.ts create mode 100644 src/registry/resources.ts create mode 100644 test/compat/capabilities.test.js create mode 100644 test/device/discovery.test.js create mode 100644 test/device/validate.test.js create mode 100644 test/fixtures/zoo/discovery.json create mode 100644 test/helpers/endpoint.js diff --git a/dist/cjs/ActionMapping.js b/dist/cjs/ActionMapping.js deleted file mode 100644 index 879fa5b..0000000 --- a/dist/cjs/ActionMapping.js +++ /dev/null @@ -1,32 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.ActionMapping = exports.AlexaActions = void 0; -var AlexaActions; -(function (AlexaActions) { - AlexaActions["Open"] = "Alexa.Actions.Open"; - AlexaActions["Close"] = "Alexa.Actions.Close"; - AlexaActions["Raise"] = "Alexa.Actions.Raise"; - AlexaActions["Lower"] = "Alexa.Actions.Lower"; - AlexaActions["SetEcoOn"] = "Alexa.Actions.SetEcoOn"; - AlexaActions["SetEcoOff"] = "Alexa.Actions.SetEcoOff"; -})(AlexaActions || (exports.AlexaActions = AlexaActions = {})); -class ActionMapping { - constructor(actions, directiveName, directivePayload) { - this.type = "ActionsToDirective"; - this.actions = actions; - this.directive = { - name: directiveName, - }; - if (directivePayload) { - this.directive.payload = directivePayload; - } - } - toJSON() { - return { - "@type": this.type, - actions: this.actions, - directive: this.directive, - }; - } -} -exports.ActionMapping = ActionMapping; diff --git a/dist/cjs/Alex2Node.js b/dist/cjs/Alex2Node.js index 7ab869a..f060321 100644 --- a/dist/cjs/Alex2Node.js +++ b/dist/cjs/Alex2Node.js @@ -6,11 +6,17 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.DEFAULT_HOST = void 0; // mqtt reaches Node as CommonJS: the default import works from both builds, a named one needs Node to detect it. const mqtt_1 = __importDefault(require("mqtt")); -const Device_js_1 = __importDefault(require("./Device.js")); +const Device_js_1 = __importDefault(require("./device/Device.js")); +const validate_js_1 = require("./device/validate.js"); const events_1 = require("events"); -const DisplayCategory_js_1 = require("./DisplayCategory.js"); -const AlexaInterface_js_1 = require("./AlexaInterface.js"); +const enums_js_1 = require("./compat/enums.js"); +const types_js_1 = require("./registry/types.js"); exports.DEFAULT_HOST = "mqtt://Alex2MQTT.stormysdream.club:1883"; +// What addDevice() copies from the definition to the device +const DESCRIBED = [ + "description", "manufacturerName", "manufacturer", "model", "serialNumber", "firmwareVersion", "softwareVersion", + "customIdentifier", "cookie", +]; /** * The Alexa-to-MQTT bridge: one broker connection for a user's root topic, a set of registered devices, discovery and * directive dispatch. @@ -34,6 +40,8 @@ class Alex2MQTT extends events_1.EventEmitter { this.debugLogging = debugLogging; this.client = null; this.devices = []; + // The lines of check() that were logged. Discovery comes every few minutes: a line is logged once. + this.logged = new Set(); /** true while the broker connection is up. */ this.connected = false; /** ISO time of the last discovery request answered, null before the first. */ @@ -90,12 +98,8 @@ class Alex2MQTT extends events_1.EventEmitter { this.log(`MQTT Message Received`, { topic, payload: message.toString() }); if (topic == `${this.rootTopic}/discover`) { this.log("Discovery request received, getting device json..."); - const deviceArray = this.devices.map((device) => device.getJSON()); + const deviceArray = this.describeDevices(); this.lastDiscoveryAt = new Date().toISOString(); - for (const device of this.devices) // a capability the library has no property list for goes out with supported: [] - for (const cap of device.getCapabilities()) - if (cap.getProps().length === 0 && cap.getType() !== AlexaInterface_js_1.AlexaInterfaceType.SCENE_CONTROLLER) - this.log(`${device.endpointId}: no property list known for ${cap.getTypeString()}, discovery lists it with no supported properties`); this.client.publish(topic + "_r", JSON.stringify(deviceArray), (err) => { if (err) { this.fail(err); @@ -136,6 +140,26 @@ class Alex2MQTT extends events_1.EventEmitter { } }); } + // The endpoint objects of a discovery answer. What check() says about a device is logged, each line once. A device + // that cannot be described is left out and reported as an error: the others are still announced. + describeDevices() { + const endpoints = []; + for (const device of this.devices) { + try { + endpoints.push(device.getJSON()); + for (const line of device.check()) { + if (this.logged.has(line)) + continue; + this.logged.add(line); + this.log(`warning: ${line}`); + } + } + catch (err) { + this.fail(new Error(`${device.endpointId} is not in the discovery answer: ${err instanceof Error ? err.message : err}`)); + } + } + return endpoints; + } /** Close the broker connection (resolves once closed). The devices stay registered; connect() again reuses them. */ disconnect() { return new Promise((resolve) => { @@ -147,6 +171,36 @@ class Alex2MQTT extends events_1.EventEmitter { c.end(true, {}, () => resolve()); }); } + /** + * Declare a device: + * + * const blinds = bridge.addDevice({ endpointId: "bedroom-blinds", name: "Bedroom Blinds", + * categories: ["INTERIOR_BLIND"], manufacturerName: "Acme", description: "Roller blind by Acme" }); + * + * Throws a DeclarationError when Alexa would reject the endpoint (an endpointId with a slash, a name with + * punctuation) or when the endpointId is registered already. Discovery lists Alexa.EndpointHealth for the device + * unless endpointHealth is false. + */ + addDevice(definition) { + if (!this.client) { + throw new Error("Must call connect before creating devices"); + } + const { endpointId, name, categories, endpointHealth, ...described } = definition; + // Without categories the device would take LIGHT, the default of registerDevice: here it is a mistake + const device = new Device_js_1.default(this.client, this.rootTopic, name, endpointId, (categories ?? [])); + for (const [field, value] of Object.entries(described)) { + if (!DESCRIBED.includes(field)) + throw new types_js_1.DeclarationError({ endpointId }, `${field} is not a field of an endpoint`); + if (value !== undefined) + Object.assign(device, { [field]: value }); + } + (0, validate_js_1.checkEndpoint)(device.getJSON()); + const existing = this.getDevice(endpointId); + if (existing) + throw new types_js_1.DeclarationError({ endpointId }, `is registered already, as "${existing.name}"`); + this.register(device, endpointHealth !== false); + return device; + } registerDevice(name, endpointId, displayCategory) { if (!this.client) { throw new Error("Must call connect before creating devices"); @@ -161,15 +215,16 @@ class Alex2MQTT extends events_1.EventEmitter { return existing; } this.log(`Creating new device with endpoint: ${endpointId}`); - const normalizedCategory = displayCategory === null - ? [DisplayCategory_js_1.DisplayCategory.LIGHT] - : Array.isArray(displayCategory) - ? displayCategory - : [displayCategory || DisplayCategory_js_1.DisplayCategory.LIGHT]; + const normalizedCategory = Array.isArray(displayCategory) ? displayCategory : [displayCategory || enums_js_1.DisplayCategory.LIGHT]; const device = new Device_js_1.default(this.client, this.rootTopic, name, endpointId, normalizedCategory); + this.register(device, false); + return device; + } + register(device, endpointHealth) { + device.alexaInterface = this.options.alexaInterface !== false; + device.endpointHealth = endpointHealth; device.onPublishError = (err) => this.fail(err); // a failed publish is an "error" event (when listened to), never a rejected send() this.devices.push(device); - return device; } /** Forget a device (its listeners with it). Returns false when there was none. */ unregisterDevice(endpointId) { diff --git a/dist/cjs/AlexaStatusMessage.js b/dist/cjs/AlexaStatusMessage.js index 7171b03..6fd95c1 100644 --- a/dist/cjs/AlexaStatusMessage.js +++ b/dist/cjs/AlexaStatusMessage.js @@ -1,13 +1,8 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -exports.AlexaStatusMessage = exports.TemperatureSensorScale = exports.PowerController = exports.ThermostatMode = exports.EndpointHealth = void 0; +exports.AlexaStatusMessage = exports.TemperatureSensorScale = exports.ThermostatMode = void 0; const crypto_1 = require("crypto"); -const AlexaInterface_js_1 = require("./AlexaInterface.js"); -var EndpointHealth; -(function (EndpointHealth) { - EndpointHealth["OK"] = "OK"; - EndpointHealth["UNREACHABLE"] = "UNREACHABLE"; -})(EndpointHealth || (exports.EndpointHealth = EndpointHealth = {})); +const enums_js_1 = require("./compat/enums.js"); var ThermostatMode; (function (ThermostatMode) { ThermostatMode["OFF"] = "OFF"; @@ -17,11 +12,6 @@ var ThermostatMode; ThermostatMode["ECO"] = "ECO"; ThermostatMode["CUSTOM"] = "CUSTOM"; })(ThermostatMode || (exports.ThermostatMode = ThermostatMode = {})); -var PowerController; -(function (PowerController) { - PowerController["ON"] = "ON"; - PowerController["OFF"] = "OFF"; -})(PowerController || (exports.PowerController = PowerController = {})); var TemperatureSensorScale; (function (TemperatureSensorScale) { TemperatureSensorScale["CELSIUS"] = "CELSIUS"; @@ -102,10 +92,10 @@ class AlexaStatusMessage { return { context: this.isDeferred ? null : this.context, event: this.event }; } addModeControllerProp(instance, value, uncertaintyInMs = 0) { - return this.addProperty(AlexaInterface_js_1.AlexaInterfaceType.MODE_CONTROLLER, "mode", value, uncertaintyInMs, instance); + return this.addProperty(enums_js_1.AlexaInterfaceType.MODE_CONTROLLER, "mode", value, uncertaintyInMs, instance); } addThermostatModeProp(mode, uncertaintyInMs = 0) { - return this.addProperty(AlexaInterface_js_1.AlexaInterfaceType.THERMOSTAT_CONTROLLER, "thermostatMode", mode, uncertaintyInMs); + return this.addProperty(enums_js_1.AlexaInterfaceType.THERMOSTAT_CONTROLLER, "thermostatMode", mode, uncertaintyInMs); } addEstimatedDeferralTime(seconds) { if (this.isDeferred) { @@ -125,14 +115,14 @@ class AlexaStatusMessage { : value, scale: "CELSIUS", }; - return this.addProperty(AlexaInterface_js_1.AlexaInterfaceType.THERMOSTAT_CONTROLLER, name, tempValue, uncertaintyInMs); + return this.addProperty(enums_js_1.AlexaInterfaceType.THERMOSTAT_CONTROLLER, name, tempValue, uncertaintyInMs); } /** Alexa.EndpointHealth connectivity: EndpointHealth.OK / UNREACHABLE (the plain strings "OK" / "UNREACHABLE" are accepted too). */ addHealthProp(health, uncertaintyInMs = 0) { - return this.addProperty(AlexaInterface_js_1.AlexaInterfaceType.ENDPOINT_HEALTH, "connectivity", { value: health }, uncertaintyInMs); + return this.addProperty(enums_js_1.AlexaInterfaceType.ENDPOINT_HEALTH, "connectivity", { value: health }, uncertaintyInMs); } addPowerControllerProp(power, uncertaintyInMs = 0) { - return this.addProperty(AlexaInterface_js_1.AlexaInterfaceType.POWER_CONTROLLER, "powerState", power, uncertaintyInMs); + return this.addProperty(enums_js_1.AlexaInterfaceType.POWER_CONTROLLER, "powerState", power, uncertaintyInMs); } addTemperatureSensorProp(scale, value, uncertaintyInMs = 0) { const tempValue = { @@ -141,16 +131,16 @@ class AlexaStatusMessage { : value, scale: "CELSIUS", }; - return this.addProperty(AlexaInterface_js_1.AlexaInterfaceType.TEMPERATURE_SENSOR, "temperature", tempValue, uncertaintyInMs); + return this.addProperty(enums_js_1.AlexaInterfaceType.TEMPERATURE_SENSOR, "temperature", tempValue, uncertaintyInMs); } addBrightnessControllerProp(brightness, uncertaintyInMs = 0) { - return this.addProperty(AlexaInterface_js_1.AlexaInterfaceType.BRIGHTNESS_CONTROLLER, "brightness", brightness, uncertaintyInMs); + return this.addProperty(enums_js_1.AlexaInterfaceType.BRIGHTNESS_CONTROLLER, "brightness", brightness, uncertaintyInMs); } addColorTemperatureControllerProp(colorTemp, uncertaintyInMs = 0) { - return this.addProperty(AlexaInterface_js_1.AlexaInterfaceType.COLOR_TEMPERATURE_CONTROLLER, "colorTemperatureInKelvin", colorTemp, uncertaintyInMs); + return this.addProperty(enums_js_1.AlexaInterfaceType.COLOR_TEMPERATURE_CONTROLLER, "colorTemperatureInKelvin", colorTemp, uncertaintyInMs); } addToggleControllerProp(state, instance, uncertaintyInMs = 0) { - return this.addProperty(AlexaInterface_js_1.AlexaInterfaceType.TOGGLE_CONTROLLER, "toggleState", state, uncertaintyInMs, instance); + return this.addProperty(enums_js_1.AlexaInterfaceType.TOGGLE_CONTROLLER, "toggleState", state, uncertaintyInMs, instance); } addContextProp(prop) { this.context.properties.push(prop); diff --git a/dist/cjs/Device.js b/dist/cjs/Device.js deleted file mode 100644 index 4789f8a..0000000 --- a/dist/cjs/Device.js +++ /dev/null @@ -1,145 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -const AlexaInterface_js_1 = require("./AlexaInterface.js"); -const DisplayCategory_js_1 = require("./DisplayCategory.js"); -const events_1 = require("events"); -const crypto_1 = require("crypto"); -const AlexaStatusMessage_js_1 = require("./AlexaStatusMessage.js"); -const AlexaErrorResponse_js_1 = require("./AlexaErrorResponse.js"); -class Device extends events_1.EventEmitter { - constructor(mqttClient, rootTopic, name, endpointId, displayCategory, description = "Alexa to Node.js bridge", manufacturerName = "Alex2Node", manufacturer = "Alex2Node", model = "Alex2Node_v1.0.0") { - super(); - this.mqttClient = mqttClient; - this.rootTopic = rootTopic; - this.name = name; - this.endpointId = endpointId; - this.displayCategory = displayCategory; - this.description = description; - this.manufacturerName = manufacturerName; - this.manufacturer = manufacturer; - this.model = model; - this.softwareVersion = "1.0.0"; - this.capabilities = []; - } - /** Internal (1.5.2): Alex2MQTT.connect() re-binds every registered device to its new broker client after a disconnect(). */ - setMqttClient(client) { - this.mqttClient = client; - } - getName() { - return this.name; - } - setName(name) { - this.name = name; - } - getEndpointId() { - return this.endpointId; - } - setDisplayCategory(category) { - this.displayCategory = Array.isArray(category) ? category : [category]; - } - getDisplayCategory() { - return this.displayCategory || [DisplayCategory_js_1.DisplayCategory.LIGHT]; - } - setDescription(description) { - this.description = description; - } - getDescription() { - return this.description; - } - getErrorMessage(correlationToken) { - const msg = new AlexaErrorResponse_js_1.AlexaErrorResponse(correlationToken, this.rootTopic, this.endpointId, this.mqttClient); - msg.onPublishError = this.onPublishError; - return msg; - } - getStatusMessage(correlationToken, isResponse = false, isDeferred = false) { - const msg = new AlexaStatusMessage_js_1.AlexaStatusMessage(correlationToken, this.rootTopic, this.endpointId, this.mqttClient, isResponse, isDeferred); - msg.onPublishError = this.onPublishError; - return msg; - } - /** - * 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. - */ - getChangeReport(cause = "PHYSICAL_INTERACTION") { - const msg = new AlexaStatusMessage_js_1.AlexaStatusMessage("", this.rootTopic, this.endpointId, this.mqttClient, false, false, cause); - msg.onPublishError = this.onPublishError; - return msg; - } - /** - * Alexa.SceneController: answer an Activate / Deactivate directive with ActivationStarted / DeactivationStarted - * (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 topic = `${this.rootTopic}/${this.endpointId}/${sendAsync ? "deferredResponse" : "alexaResponce"}`; - return new Promise((resolve) => { - this.mqttClient.publish(topic, JSON.stringify(payload), (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 - }); - }); - } - getCapabilities() { - return this.capabilities.slice(); - } - setManufacturerName(name) { - this.manufacturerName = name; - } - getManufacturerName() { - return this.manufacturerName; - } - setManufacturer(manufacturer) { - this.manufacturer = manufacturer; - } - getManufacturer() { - return this.manufacturer; - } - setModel(model) { - this.model = model; - } - getModel() { - return this.model; - } - getSoftwareVersion() { - return this.softwareVersion; - } - /** Add a capability. 1.5.1: options.retrievable / proactivelyReported / instance (a change report needs proactivelyReported). */ - addCapability(type, options = {}) { - const newCapability = new AlexaInterface_js_1.AlexaInterface(type, options.retrievable ?? true, options.proactivelyReported ?? false, options.instance ?? ""); - this.capabilities.push(newCapability); - // console.log( - // `Created new interface with type: ${newCapability.getTypeString()}` - // ); - return newCapability; - } - getJSON() { - return { - endpointId: this.endpointId, - friendlyName: this.name, - description: this.description, - manufacturerName: this.manufacturerName, - displayCategories: this.getDisplayCategory(), - additionalAttributes: { - manufacturer: this.manufacturer, - model: this.model, - serialNumber: "Alex2Node", - firmwareVersion: "1.0.0", - softwareVersion: this.softwareVersion, - customIdentifier: "Alex2Node", - }, - capabilities: this.capabilities.map((cap) => cap.getJSON()), - }; - } -} -exports.default = Device; diff --git a/dist/cjs/DisplayCategory.js b/dist/cjs/DisplayCategory.js deleted file mode 100644 index c45cbfa..0000000 --- a/dist/cjs/DisplayCategory.js +++ /dev/null @@ -1,64 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.DisplayCategory = void 0; -var DisplayCategory; -(function (DisplayCategory) { - DisplayCategory["ACTIVITY_TRIGGER"] = "ACTIVITY_TRIGGER"; - DisplayCategory["AIR_CONDITIONER"] = "AIR_CONDITIONER"; - DisplayCategory["AIR_FRESHENER"] = "AIR_FRESHENER"; - DisplayCategory["AIR_PURIFIER"] = "AIR_PURIFIER"; - DisplayCategory["AIR_QUALITY_MONITOR"] = "AIR_QUALITY_MONITOR"; - DisplayCategory["ALEXA_VOICE_ENABLED"] = "ALEXA_VOICE_ENABLED"; - DisplayCategory["AUTO_ACCESSORY"] = "AUTO_ACCESSORY"; - DisplayCategory["BLUETOOTH_SPEAKER"] = "BLUETOOTH_SPEAKER"; - DisplayCategory["CAMERA"] = "CAMERA"; - DisplayCategory["CHRISTMAS_TREE"] = "CHRISTMAS_TREE"; - DisplayCategory["COFFEE_MAKER"] = "COFFEE_MAKER"; - DisplayCategory["COMPUTER"] = "COMPUTER"; - DisplayCategory["CONTACT_SENSOR"] = "CONTACT_SENSOR"; - DisplayCategory["DISHWASHER"] = "DISHWASHER"; - DisplayCategory["DOOR"] = "DOOR"; - DisplayCategory["DOORBELL"] = "DOORBELL"; - DisplayCategory["DRYER"] = "DRYER"; - DisplayCategory["EXTERIOR_BLIND"] = "EXTERIOR_BLIND"; - DisplayCategory["FAN"] = "FAN"; - DisplayCategory["GAME_CONSOLE"] = "GAME_CONSOLE"; - DisplayCategory["GARAGE_DOOR"] = "GARAGE_DOOR"; - DisplayCategory["HEADPHONES"] = "HEADPHONES"; - DisplayCategory["HUB"] = "HUB"; - DisplayCategory["INTERIOR_BLIND"] = "INTERIOR_BLIND"; - DisplayCategory["LAPTOP"] = "LAPTOP"; - DisplayCategory["LIGHT"] = "LIGHT"; - DisplayCategory["MICROWAVE"] = "MICROWAVE"; - DisplayCategory["MOBILE_PHONE"] = "MOBILE_PHONE"; - DisplayCategory["MOTION_SENSOR"] = "MOTION_SENSOR"; - DisplayCategory["MUSIC_SYSTEM"] = "MUSIC_SYSTEM"; - DisplayCategory["NETWORK_HARDWARE"] = "NETWORK_HARDWARE"; - DisplayCategory["OTHER"] = "OTHER"; - DisplayCategory["OVEN"] = "OVEN"; - DisplayCategory["PHONE"] = "PHONE"; - DisplayCategory["PRINTER"] = "PRINTER"; - DisplayCategory["REMOTE"] = "REMOTE"; - DisplayCategory["ROUTER"] = "ROUTER"; - DisplayCategory["SCENE_TRIGGER"] = "SCENE_TRIGGER"; - DisplayCategory["SCREEN"] = "SCREEN"; - DisplayCategory["SECURITY_PANEL"] = "SECURITY_PANEL"; - DisplayCategory["SECURITY_SYSTEM"] = "SECURITY_SYSTEM"; - DisplayCategory["SLOW_COOKER"] = "SLOW_COOKER"; - DisplayCategory["SMARTLOCK"] = "SMARTLOCK"; - DisplayCategory["SMARTPLUG"] = "SMARTPLUG"; - DisplayCategory["SPEAKER"] = "SPEAKER"; - DisplayCategory["STREAMING_DEVICE"] = "STREAMING_DEVICE"; - DisplayCategory["SWITCH"] = "SWITCH"; - DisplayCategory["TABLET"] = "TABLET"; - DisplayCategory["TEMPERATURE_SENSOR"] = "TEMPERATURE_SENSOR"; - DisplayCategory["THERMOSTAT"] = "THERMOSTAT"; - DisplayCategory["TV"] = "TV"; - DisplayCategory["VACUUM_CLEANER"] = "VACUUM_CLEANER"; - DisplayCategory["VACUUM"] = "VACUUM"; - /** Not on the list of display categories any more; kept for 1.x callers. */ - DisplayCategory["VEHICLE"] = "VEHICLE"; - DisplayCategory["WASHER"] = "WASHER"; - DisplayCategory["WATER_HEATER"] = "WATER_HEATER"; - DisplayCategory["WEARABLE"] = "WEARABLE"; -})(DisplayCategory || (exports.DisplayCategory = DisplayCategory = {})); diff --git a/dist/cjs/compat/ActionMapping.js b/dist/cjs/compat/ActionMapping.js new file mode 100644 index 0000000..983c35a --- /dev/null +++ b/dist/cjs/compat/ActionMapping.js @@ -0,0 +1,49 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ActionMapping = void 0; +const types_js_1 = require("../registry/types.js"); +/** + * A semantics action mapping of 1.x: the phrases "open", "close", "raise", "lower" for one directive of the + * capability it is added to. + * + * toggle.addActionMapping(new ActionMapping([AlexaActions.Close], "TurnOff")); + * lift.addActionMapping(new ActionMapping([AlexaActions.Open], "SetRangeValue", { rangeValue: 100 })); + */ +class ActionMapping { + /** + * directivePayload is the payload object of the directive (alexa-discovery-objects.html, "ActionMappings object"). + * 1.x took a string and put it into discovery as one. A string that holds a JSON object is parsed; any other + * string throws a DeclarationError. + */ + constructor(actions, directiveName, directivePayload) { + this.type = "ActionsToDirective"; + this.actions = actions; + this.directive = { name: directiveName }; + if (typeof directivePayload === "string") { + if (directivePayload === "") + return; + this.directive.payload = objectIn(directivePayload, directiveName); + this.deprecation = `the payload of the action mapping for ${directiveName} is a JSON string, pass the object`; + } + else if (directivePayload) { + this.directive.payload = directivePayload; + } + } + toJSON() { + return { "@type": this.type, actions: this.actions, directive: this.directive }; + } +} +exports.ActionMapping = ActionMapping; +function objectIn(text, directiveName) { + let value; + try { + value = JSON.parse(text); + } + catch { + value = undefined; + } + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new types_js_1.DeclarationError({}, `the payload of the action mapping for ${directiveName} is an object, got the string ${JSON.stringify(text)}`); + } + return value; +} diff --git a/dist/cjs/compat/AlexaInterface.js b/dist/cjs/compat/AlexaInterface.js new file mode 100644 index 0000000..d02d6a4 --- /dev/null +++ b/dist/cjs/compat/AlexaInterface.js @@ -0,0 +1,61 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.AlexaInterface = void 0; +const Capability_js_1 = require("../device/Capability.js"); +const index_js_1 = require("../registry/index.js"); +const resources_js_1 = require("../registry/resources.js"); +/** + * A capability as 1.x declares it: device.addCapability(type, options), then the add and set methods below. It is a + * Capability, so what the methods set reaches discovery the same way as the options of device.add(). Nothing is + * refused here except an interface name the registry does not have: what Alexa would reject is logged by the bridge + * when it answers a discovery. + */ +class AlexaInterface extends Capability_js_1.Capability { + constructor(type, retrievable = true, proactivelyReported = false, instance = "") { + super(index_js_1.registry.get(type), { retrievable, proactivelyReported, instance, options: {} }); + } + /** The namespace of the interface, which is the value of its AlexaInterfaceType member. */ + get type() { + return this.descriptor.namespace; + } + addActionMapping(mapping) { + var _a; + const semantics = ((_a = this.options).semantics ?? (_a.semantics = {})); + (semantics.actionMappings ?? (semantics.actionMappings = [])).push(mapping.toJSON()); + if (mapping.deprecation) + this.notes.push(mapping.deprecation); + } + addFriendlyName(name, locale) { + this.friendlyNames.push((0, resources_js_1.text)(name, locale)); + } + /** The modes of a ModeController, as { value, modeResources } objects. */ + addSupportedModes(modes) { + this.options.supportedModes = modes; + } + setInstance(name) { + this.instance = name; + } + getType() { + return this.type; + } + /** @deprecated The same as getType(). */ + getTypeString() { + return this.type; + } + /** @deprecated Read descriptor.version. */ + getVersion() { + return this.descriptor.version; + } + /** @deprecated Read the keys of descriptor.properties. */ + getProps() { + return Object.keys(this.descriptor.properties); + } + getJSON() { + return super.toJSON(); + } + // 1.x callers corrected the discovery JSON by replacing getJSON on the object: discovery goes through it + toJSON() { + return this.getJSON(); + } +} +exports.AlexaInterface = AlexaInterface; diff --git a/dist/cjs/AlexaInterface.js b/dist/cjs/compat/enums.js similarity index 53% rename from dist/cjs/AlexaInterface.js rename to dist/cjs/compat/enums.js index e4ec767..cfe7303 100644 --- a/dist/cjs/AlexaInterface.js +++ b/dist/cjs/compat/enums.js @@ -1,7 +1,10 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -exports.AlexaInterface = exports.AlexaInterfaceType = void 0; -const index_js_1 = require("./registry/index.js"); +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"); +/** Every interface name of 1.x. registry.get() takes a member as it takes the namespace, which is its value. */ var AlexaInterfaceType; (function (AlexaInterfaceType) { AlexaInterfaceType["APPLICATION_STATE_REPORTER"] = "Alexa.ApplicationStateReporter"; @@ -73,97 +76,88 @@ var AlexaInterfaceType; AlexaInterfaceType["USER_PREFERENCE"] = "Alexa.UserPreference"; AlexaInterfaceType["VIDEO_RECORDER"] = "Alexa.VideoRecorder"; AlexaInterfaceType["WAKE_ON_LAN_CONTROLLER"] = "Alexa.WakeOnLANController"; + /** Not an interface: declaring it throws a DeclarationError. Kept because 1.x had it. */ AlexaInterfaceType["UNKNOWN"] = "UNKNOWN"; })(AlexaInterfaceType || (exports.AlexaInterfaceType = AlexaInterfaceType = {})); -class AlexaInterface { - constructor(type, retrievable = true, proactivelyReported = false, instance = "") { - this.type = type; - this.retrievable = retrievable; - this.proactivelyReported = proactivelyReported; - this.instance = instance; - this.friendlyNames = []; - this.actionMappings = []; - this.supportedModes = []; - } - addActionMapping(mapping) { - this.actionMappings.push(mapping); - } - addFriendlyName(name, locale) { - this.friendlyNames.push({ text: name, locale }); - } - /** The modes of a ModeController: { value, modeResources } objects as Alexa wants them (plain strings pass through as given). */ - addSupportedModes(modes) { - this.supportedModes = modes; - } - setInstance(name) { - this.instance = name; - } - getType() { - return this.type; - } - getTypeString() { - return this.type; - } - /** The version of the interface, from its descriptor. "UNKNOWN" for a name the registry does not have. */ - getVersion() { - return index_js_1.registry.has(this.type) ? index_js_1.registry.get(this.type).version : "UNKNOWN"; - } - /** The names of the properties the interface reports, from its descriptor. */ - getProps() { - return index_js_1.registry.has(this.type) ? Object.keys(index_js_1.registry.get(this.type).properties) : []; - } - getJSON() { - const doc = { - interface: this.getTypeString(), - version: this.getVersion(), - type: "AlexaInterface", - properties: { - retrievable: this.retrievable, - proactivelyReported: this.proactivelyReported, - supported: this.getProps().map(name => ({ name })), - }, - }; - if (this.type == AlexaInterfaceType.SCENE_CONTROLLER) { - // Alexa.SceneController v3 carries no properties block: supportsDeactivation + proactivelyReported at the top level - delete doc.properties; - doc.supportsDeactivation = true; - doc.proactivelyReported = this.proactivelyReported; - } - else if (this.type == AlexaInterfaceType.THERMOSTAT_CONTROLLER) { - doc.configuration = { - "supportedModes": ["HEAT", "COOL", "AUTO", "OFF"], - "supportsScheduling": false - }; - } - else if (this.supportedModes.length > 0) { - doc["configuration"] = { - ordered: true, - supportedModes: this.supportedModes, - }; - } - if (this.instance) { - doc.instance = this.instance; - } - if (this.friendlyNames.length > 0) { - const capabilityResources = doc["capabilityResources"] || {}; - const friendlyNamesArray = capabilityResources["friendlyNames"] || []; - this.friendlyNames.forEach((fn) => { - const friendlyNameObj = { "@type": "text" }; - friendlyNameObj["value"] = { - text: fn.text, - locale: fn.locale, - }; - friendlyNamesArray.push(friendlyNameObj); - }); - capabilityResources.friendlyNames = friendlyNamesArray; - doc["capabilityResources"] = capabilityResources; - } - if (this.actionMappings.length > 0) { - doc.semantics = { - actionMappings: this.actionMappings.map((am) => am.toJSON()), - }; - } - return doc; - } -} -exports.AlexaInterface = AlexaInterface; +var DisplayCategory; +(function (DisplayCategory) { + DisplayCategory["ACTIVITY_TRIGGER"] = "ACTIVITY_TRIGGER"; + DisplayCategory["AIR_CONDITIONER"] = "AIR_CONDITIONER"; + DisplayCategory["AIR_FRESHENER"] = "AIR_FRESHENER"; + DisplayCategory["AIR_PURIFIER"] = "AIR_PURIFIER"; + DisplayCategory["AIR_QUALITY_MONITOR"] = "AIR_QUALITY_MONITOR"; + DisplayCategory["ALEXA_VOICE_ENABLED"] = "ALEXA_VOICE_ENABLED"; + DisplayCategory["AUTO_ACCESSORY"] = "AUTO_ACCESSORY"; + DisplayCategory["BLUETOOTH_SPEAKER"] = "BLUETOOTH_SPEAKER"; + DisplayCategory["CAMERA"] = "CAMERA"; + DisplayCategory["CHRISTMAS_TREE"] = "CHRISTMAS_TREE"; + DisplayCategory["COFFEE_MAKER"] = "COFFEE_MAKER"; + DisplayCategory["COMPUTER"] = "COMPUTER"; + DisplayCategory["CONTACT_SENSOR"] = "CONTACT_SENSOR"; + DisplayCategory["DISHWASHER"] = "DISHWASHER"; + DisplayCategory["DOOR"] = "DOOR"; + DisplayCategory["DOORBELL"] = "DOORBELL"; + DisplayCategory["DRYER"] = "DRYER"; + DisplayCategory["EXTERIOR_BLIND"] = "EXTERIOR_BLIND"; + DisplayCategory["FAN"] = "FAN"; + DisplayCategory["GAME_CONSOLE"] = "GAME_CONSOLE"; + DisplayCategory["GARAGE_DOOR"] = "GARAGE_DOOR"; + DisplayCategory["HEADPHONES"] = "HEADPHONES"; + DisplayCategory["HUB"] = "HUB"; + DisplayCategory["INTERIOR_BLIND"] = "INTERIOR_BLIND"; + DisplayCategory["LAPTOP"] = "LAPTOP"; + DisplayCategory["LIGHT"] = "LIGHT"; + DisplayCategory["MICROWAVE"] = "MICROWAVE"; + DisplayCategory["MOBILE_PHONE"] = "MOBILE_PHONE"; + DisplayCategory["MOTION_SENSOR"] = "MOTION_SENSOR"; + DisplayCategory["MUSIC_SYSTEM"] = "MUSIC_SYSTEM"; + DisplayCategory["NETWORK_HARDWARE"] = "NETWORK_HARDWARE"; + DisplayCategory["OTHER"] = "OTHER"; + DisplayCategory["OVEN"] = "OVEN"; + DisplayCategory["PHONE"] = "PHONE"; + DisplayCategory["PRINTER"] = "PRINTER"; + DisplayCategory["REMOTE"] = "REMOTE"; + DisplayCategory["ROUTER"] = "ROUTER"; + DisplayCategory["SCENE_TRIGGER"] = "SCENE_TRIGGER"; + DisplayCategory["SCREEN"] = "SCREEN"; + DisplayCategory["SECURITY_PANEL"] = "SECURITY_PANEL"; + DisplayCategory["SECURITY_SYSTEM"] = "SECURITY_SYSTEM"; + DisplayCategory["SLOW_COOKER"] = "SLOW_COOKER"; + DisplayCategory["SMARTLOCK"] = "SMARTLOCK"; + DisplayCategory["SMARTPLUG"] = "SMARTPLUG"; + DisplayCategory["SPEAKER"] = "SPEAKER"; + DisplayCategory["STREAMING_DEVICE"] = "STREAMING_DEVICE"; + DisplayCategory["SWITCH"] = "SWITCH"; + DisplayCategory["TABLET"] = "TABLET"; + DisplayCategory["TEMPERATURE_SENSOR"] = "TEMPERATURE_SENSOR"; + DisplayCategory["THERMOSTAT"] = "THERMOSTAT"; + DisplayCategory["TV"] = "TV"; + DisplayCategory["VACUUM_CLEANER"] = "VACUUM_CLEANER"; + DisplayCategory["VACUUM"] = "VACUUM"; + /** Not on the list of display categories any more; kept for 1.x callers. */ + DisplayCategory["VEHICLE"] = "VEHICLE"; + DisplayCategory["WASHER"] = "WASHER"; + DisplayCategory["WATER_HEATER"] = "WATER_HEATER"; + DisplayCategory["WEARABLE"] = "WEARABLE"; +})(DisplayCategory || (exports.DisplayCategory = DisplayCategory = {})); +var AlexaActions; +(function (AlexaActions) { + AlexaActions["Open"] = "Alexa.Actions.Open"; + AlexaActions["Close"] = "Alexa.Actions.Close"; + AlexaActions["Raise"] = "Alexa.Actions.Raise"; + AlexaActions["Lower"] = "Alexa.Actions.Lower"; + AlexaActions["SetEcoOn"] = "Alexa.Actions.SetEcoOn"; + AlexaActions["SetEcoOff"] = "Alexa.Actions.SetEcoOff"; +})(AlexaActions || (exports.AlexaActions = AlexaActions = {})); +/** The values of powerState and toggleState. */ +exports.PowerState = { ON: "ON", OFF: "OFF" }; +const Connectivity = { OK: "OK", UNREACHABLE: "UNREACHABLE" }; +// PowerController and EndpointHealth were enums in 1.x and are the names of two interfaces. One export serves both: +// the descriptor, which also has the members of the enum, and a type of the same name for the values. +/** + * The Alexa.PowerController interface, for device.add(). PowerController.ON and PowerController.OFF are the 1.x enum + * of power states and stay until 3.0: PowerState has the same two members. + */ +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); diff --git a/dist/cjs/device/Capability.js b/dist/cjs/device/Capability.js new file mode 100644 index 0000000..82bda5e --- /dev/null +++ b/dist/cjs/device/Capability.js @@ -0,0 +1,67 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.Capability = exports.commonOptions = void 0; +const schema_js_1 = require("../registry/schema.js"); +/** The same as a schema. The friendly names are checked with the instance, by the rules of the interface. */ +exports.commonOptions = schema_js_1.s.object({ + instance: schema_js_1.s.optional(schema_js_1.s.string()), + friendlyNames: schema_js_1.s.optional(schema_js_1.s.array(schema_js_1.s.unknown())), + retrievable: schema_js_1.s.optional(schema_js_1.s.boolean()), + proactivelyReported: schema_js_1.s.optional(schema_js_1.s.boolean()), + nonControllable: schema_js_1.s.optional(schema_js_1.s.boolean()), +}); +/** One interface as an endpoint declares it: the descriptor, and what the declaration says about this endpoint. */ +class Capability { + constructor(descriptor, declared) { + this.descriptor = descriptor; + /** The endpoint the capability is declared on; the device sets it. */ + this.endpointId = ""; + /** What the bridge logs about the declaration, once, when it answers a discovery. */ + this.notes = []; + this.instance = declared.instance ?? ""; + this.friendlyNames = declared.friendlyNames ?? []; + this.retrievable = declared.retrievable ?? true; + this.proactivelyReported = declared.proactivelyReported ?? false; + this.nonControllable = declared.nonControllable; + this.options = declared.options; + } + get namespace() { + return this.descriptor.namespace; + } + /** Mappings of "open", "close", "raise", "lower" and of states, when the declaration has any. */ + get semantics() { + const { semantics } = this.options; + const mapped = (semantics?.actionMappings?.length ?? 0) + (semantics?.stateMappings?.length ?? 0); + return mapped > 0 ? semantics : undefined; + } + /** The capability object for discovery, its fields in the order of the example on alexa-discovery-objects.html. */ + toJSON() { + const { descriptor, semantics } = this; + const extras = descriptor.discovery ? descriptor.discovery(this) : {}; + const json = { + type: "AlexaInterface", + interface: descriptor.namespace, + ...(this.instance ? { instance: this.instance } : {}), + version: descriptor.version, + }; + if (extras.properties !== false) { + json.properties = { + supported: Object.keys(descriptor.properties).map((name) => ({ name })), + proactivelyReported: this.proactivelyReported, + retrievable: this.retrievable, + }; + if (this.nonControllable !== undefined) + json.properties.nonControllable = this.nonControllable; + } + if (this.friendlyNames.length > 0) + json.capabilityResources = { friendlyNames: this.friendlyNames }; + if (extras.configuration) + json.configuration = extras.configuration; + if (extras.configurations) + json.configurations = extras.configurations; + if (semantics) + json.semantics = semantics; + return { ...json, ...extras.topLevel }; + } +} +exports.Capability = Capability; diff --git a/dist/cjs/device/Device.js b/dist/cjs/device/Device.js new file mode 100644 index 0000000..8e52826 --- /dev/null +++ b/dist/cjs/device/Device.js @@ -0,0 +1,270 @@ +"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 AlexaInterface_js_1 = require("../compat/AlexaInterface.js"); +const enums_js_1 = require("../compat/enums.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"); +const types_js_1 = require("../registry/types.js"); +const Capability_js_1 = require("./Capability.js"); +const validate_js_1 = require("./validate.js"); +class Device extends events_1.EventEmitter { + constructor(mqttClient, rootTopic, name, endpointId, displayCategory, description = "Alexa to Node.js bridge", manufacturerName = "Alex2Node", manufacturer = "Alex2Node", model = "Alex2Node_v1.0.0") { + super(); + this.mqttClient = mqttClient; + this.rootTopic = rootTopic; + this.name = name; + this.endpointId = endpointId; + this.displayCategory = displayCategory; + this.description = description; + this.manufacturerName = manufacturerName; + this.manufacturer = manufacturer; + this.model = model; + this.softwareVersion = "1.0.0"; + this.serialNumber = "Alex2Node"; + this.firmwareVersion = "1.0.0"; + this.customIdentifier = "Alex2Node"; + /** + * Discovery lists the Alexa interface on the endpoint (alexa-interface.html: "You must explicitly identify your + * support for the Alexa interface"). The bridge sets it from its alexaInterface option. + */ + this.alexaInterface = true; + /** + * Discovery lists Alexa.EndpointHealth when the device did not declare it and is not a scene. On for a device of + * bridge.addDevice(), off for one of registerDevice(), which is announced as 1.x announced it. + */ + this.endpointHealth = false; + this.capabilities = []; + } + /** Internal (1.5.2): Alex2MQTT.connect() re-binds every registered device to its new broker client after a disconnect(). */ + setMqttClient(client) { + this.mqttClient = client; + } + getName() { + return this.name; + } + setName(name) { + this.name = name; + } + getEndpointId() { + return this.endpointId; + } + setDisplayCategory(category) { + this.displayCategory = Array.isArray(category) ? category : [category]; + } + getDisplayCategory() { + return this.displayCategory || [enums_js_1.DisplayCategory.LIGHT]; + } + setDescription(description) { + this.description = description; + } + getDescription() { + return this.description; + } + getErrorMessage(correlationToken) { + const msg = new AlexaErrorResponse_js_1.AlexaErrorResponse(correlationToken, this.rootTopic, this.endpointId, this.mqttClient); + msg.onPublishError = this.onPublishError; + return msg; + } + getStatusMessage(correlationToken, isResponse = false, isDeferred = false) { + const msg = new AlexaStatusMessage_js_1.AlexaStatusMessage(correlationToken, this.rootTopic, this.endpointId, this.mqttClient, isResponse, isDeferred); + msg.onPublishError = this.onPublishError; + return msg; + } + /** + * 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. + */ + getChangeReport(cause = "PHYSICAL_INTERACTION") { + const msg = new AlexaStatusMessage_js_1.AlexaStatusMessage("", this.rootTopic, this.endpointId, this.mqttClient, false, false, cause); + msg.onPublishError = this.onPublishError; + return msg; + } + /** + * Alexa.SceneController: answer an Activate / Deactivate directive with ActivationStarted / DeactivationStarted + * (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 topic = `${this.rootTopic}/${this.endpointId}/${sendAsync ? "deferredResponse" : "alexaResponce"}`; + return new Promise((resolve) => { + this.mqttClient.publish(topic, JSON.stringify(payload), (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 + }); + }); + } + /** The capabilities the device declared, in the order it declared them. */ + getCapabilities() { + return this.capabilities.slice(); + } + setManufacturerName(name) { + this.manufacturerName = name; + } + getManufacturerName() { + return this.manufacturerName; + } + setManufacturer(manufacturer) { + this.manufacturer = manufacturer; + } + getManufacturer() { + return this.manufacturer; + } + setModel(model) { + this.model = model; + } + getModel() { + return this.model; + } + getSoftwareVersion() { + return this.softwareVersion; + } + /** + * Declare an interface of the device: + * + * lamp.add(PowerController, { proactivelyReported: true }); + * blinds.add(RangeController, { instance: "Blind.Lift", friendlyNames: [asset("Alexa.Setting.Opening")], + * range: { min: 0, max: 100, precision: 1 } }); + * + * Throws a DeclarationError that names the endpoint, the interface and the instance when Alexa would reject the + * capability; the device is then as it was before the call. + */ + add(descriptor, ...[options]) { + const where = { endpointId: this.endpointId, namespace: descriptor.namespace, instance: "" }; + if (options !== undefined && (typeof options !== "object" || options === null)) { + throw new types_js_1.DeclarationError(where, "the options of a declaration are an object"); + } + try { + const { instance, friendlyNames, retrievable, proactivelyReported, nonControllable, ...given } = Capability_js_1.commonOptions.parse(options ?? {}); + where.instance = instance ?? ""; + const common = { instance, friendlyNames: friendlyNames, retrievable, proactivelyReported, nonControllable }; + const capability = new Capability_js_1.Capability(descriptor, { ...common, options: interfaceOptions(descriptor, given) }); + capability.endpointId = this.endpointId; + (0, validate_js_1.checkCapability)(capability, descriptor, this.view(this.capabilities)); + (0, validate_js_1.checkCapabilityCount)(this.endpointId, this.announced([...this.capabilities, capability]).length); + this.capabilities.push(capability); + return capability; + } + catch (err) { + throw err instanceof schema_js_1.SchemaError ? new types_js_1.DeclarationError(where, err.message) : err; + } + } + /** + * Declare an interface the 1.x way, by its name: options.retrievable / proactivelyReported / instance, then the + * add and set methods of the capability that is returned. Throws for a name that is not an interface. Anything + * else Alexa would reject is not refused here: check() lists it. + */ + addCapability(type, options = {}) { + let capability; + try { + capability = new AlexaInterface_js_1.AlexaInterface(type, options.retrievable ?? true, options.proactivelyReported ?? false, options.instance ?? ""); + } + catch (err) { + throw err instanceof types_js_1.DeclarationError ? new types_js_1.DeclarationError({ endpointId: this.endpointId }, err.problem) : err; + } + capability.endpointId = this.endpointId; + this.capabilities.push(capability); + return capability; + } + /** + * What Alexa would reject in the device as it is declared now, one line for each: a name with punctuation, an + * interface declared twice. Also what a capability noted about its declaration. Empty when there is nothing. + * The bridge logs the lines when it answers a discovery. + */ + check() { + const lines = []; + const attempt = (rule) => { + try { + rule(); + } + catch (err) { + if (!(err instanceof types_js_1.DeclarationError)) + throw err; + lines.push(err.message); + } + }; + attempt(() => (0, validate_js_1.checkEndpoint)(this.fields())); + this.capabilities.forEach((capability, i) => { + capability.endpointId = this.endpointId; + attempt(() => (0, validate_js_1.checkCapability)(capability, capability.descriptor, this.view(this.capabilities.slice(0, i)))); + const notes = [...capability.notes]; + const { tier, properties } = capability.descriptor; + if (tier === 3 && Object.keys(properties).length === 0 && capability.toJSON().properties) { + notes.push("no property list known, discovery lists it with no supported properties"); + } + for (const note of notes) + lines.push(new types_js_1.DeclarationError(capability, note).message); + }); + attempt(() => (0, validate_js_1.checkCapabilityCount)(this.endpointId, this.announced(this.capabilities).length)); + return lines; + } + /** The endpoint object for discovery. */ + getJSON() { + return { ...this.fields(), capabilities: this.announced(this.capabilities).map((capability) => capability.toJSON()) }; + } + fields() { + return { + endpointId: this.endpointId, + friendlyName: this.name, + description: this.description, + manufacturerName: this.manufacturerName, + displayCategories: this.getDisplayCategory(), + additionalAttributes: { + manufacturer: this.manufacturer, + model: this.model, + serialNumber: this.serialNumber, + firmwareVersion: this.firmwareVersion, + softwareVersion: this.softwareVersion, + customIdentifier: this.customIdentifier, + }, + ...(this.cookie ? { cookie: this.cookie } : {}), + }; + } + // The endpoint as the rules of a capability see it + view(declaredBefore) { + return { + endpointId: this.endpointId, + friendlyName: this.name, + description: this.description, + displayCategories: this.getDisplayCategory(), + capabilities: declaredBefore, + }; + } + // What discovery lists: the declared capabilities, then the ones the library adds + announced(declared) { + const has = (namespace) => declared.some((capability) => capability.namespace === namespace); + const added = []; + // alexa-scenecontroller.html, "Discovery": a scene is not a physical device and has no Alexa.EndpointHealth + if (this.endpointHealth && !has(EndpointHealth_js_1.EndpointHealth.namespace) && !has("Alexa.SceneController")) { + added.push(new Capability_js_1.Capability(EndpointHealth_js_1.EndpointHealth, { options: {} })); + } + if (this.alexaInterface && !has(Alexa_js_1.Alexa.namespace)) + added.push(new Capability_js_1.Capability(Alexa_js_1.Alexa, { options: {} })); + return [...declared, ...added]; + } +} +// The options of the interface itself, checked by the schema of its descriptor +function interfaceOptions(descriptor, given) { + if (descriptor.options) + return descriptor.options.parse(given); + const [unknown] = Object.keys(given); + if (unknown !== undefined) + throw new schema_js_1.SchemaError(unknown, "not an option of the interface"); + return {}; +} +exports.default = Device; diff --git a/dist/cjs/device/validate.js b/dist/cjs/device/validate.js new file mode 100644 index 0000000..0c0bc65 --- /dev/null +++ b/dist/cjs/device/validate.js @@ -0,0 +1,128 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.checkEndpoint = checkEndpoint; +exports.checkCapabilityCount = checkCapabilityCount; +exports.checkCapability = checkCapability; +// What Alexa rejects at discovery, checked where a device is declared. Alexa gives no reason when it drops an +// endpoint: the user hears "no new devices found". Each rule names the page it is from. +const catalog_js_1 = require("../registry/catalog.js"); +const resources_js_1 = require("../registry/resources.js"); +const schema_js_1 = require("../registry/schema.js"); +const types_js_1 = require("../registry/types.js"); +// alexa-discovery-objects.html, "Endpoint object details": "letters, numbers, spaces, and the following special +// characters: _ - = # ; : ? @ &" +const ENDPOINT_ID = /^[A-Za-z0-9 _\-=#;:?@&]+$/; +// Same table: "alphanumeric characters and spaces". Letters of any script: Alexa speaks Hindi and Japanese too. +const FRIENDLY_NAME = /^[\p{L}\p{M}\p{N} ]+$/u; +const isText = (value) => typeof value === "string" && value.length > 0; +/** Throws the first rule of the Endpoint object that the fields break. */ +function checkEndpoint(endpoint) { + const where = { endpointId: isText(endpoint.endpointId) ? endpoint.endpointId : undefined }; + function refuse(problem) { + throw new types_js_1.DeclarationError(where, problem); + } + const { endpointId, friendlyName, description, manufacturerName, displayCategories, additionalAttributes, cookie } = endpoint; + if (!isText(endpointId)) + refuse("an endpoint needs an endpointId"); + if (endpointId.length > catalog_js_1.LIMITS.endpointIdLength || !ENDPOINT_ID.test(endpointId)) { + refuse(`the endpointId takes up to ${catalog_js_1.LIMITS.endpointIdLength} letters, digits, spaces and _ - = # ; : ? @ &`); + } + if (!isText(friendlyName)) + refuse("an endpoint needs a name"); + if (friendlyName.length > catalog_js_1.LIMITS.friendlyNameLength || !FRIENDLY_NAME.test(friendlyName)) { + refuse(`the name ${JSON.stringify(friendlyName)} takes up to ${catalog_js_1.LIMITS.friendlyNameLength} letters, digits and spaces, no punctuation`); + } + if (!isText(manufacturerName) || manufacturerName.length > catalog_js_1.LIMITS.manufacturerNameLength) { + refuse(`the manufacturerName takes 1 to ${catalog_js_1.LIMITS.manufacturerNameLength} characters`); + } + if (!isText(description) || description.length > catalog_js_1.LIMITS.descriptionLength) { + refuse(`the description takes 1 to ${catalog_js_1.LIMITS.descriptionLength} characters`); + } + if (!Array.isArray(displayCategories) || displayCategories.length === 0) + refuse("an endpoint needs a display category"); + const known = catalog_js_1.DISPLAY_CATEGORIES; + for (const category of displayCategories) { + if (!known.includes(category)) + refuse(`${JSON.stringify(category)} is not a display category`); + } + for (const [name, value] of Object.entries(additionalAttributes)) { + if (typeof value !== "string" || value.length > catalog_js_1.LIMITS.additionalAttributeLength) { + refuse(`${name} takes up to ${catalog_js_1.LIMITS.additionalAttributeLength} characters`); + } + } + if (cookie !== undefined) { + const bytes = Buffer.byteLength(JSON.stringify(cookie) ?? ""); + if (bytes > catalog_js_1.LIMITS.cookieBytes) + refuse(`the cookie is ${bytes} bytes, ${catalog_js_1.LIMITS.cookieBytes} is the most`); + } +} +/** alexa-discovery.html, "Interface limits". count includes the capabilities the library adds. */ +function checkCapabilityCount(endpointId, count) { + if (count > catalog_js_1.LIMITS.capabilitiesPerEndpoint) { + throw new types_js_1.DeclarationError({ endpointId }, `${count} capabilities, an endpoint takes ${catalog_js_1.LIMITS.capabilitiesPerEndpoint}`); + } +} +// Two names are the same name when the app would show the same: the asset, or the text in one locale. +function nameKey(name) { + return name["@type"] === "asset" ? `asset ${name.value.assetId}` : `text ${name.value.locale} ${name.value.text.toLowerCase()}`; +} +function phrases(capability) { + const { semantics } = capability.options; + return (semantics?.actionMappings ?? []).flatMap((mapping) => mapping.actions); +} +/** + * Throws the first rule that a capability breaks on its endpoint. endpoint.capabilities are the ones declared + * before it. A stub is checked for being declared twice and for nothing else: the library does not know its rules. + */ +function checkCapability(capability, descriptor, endpoint) { + function refuse(problem) { + throw new types_js_1.DeclarationError(capability, problem); + } + const { namespace, instance, friendlyNames } = capability; + // generic-controllers.html, "Multiple instances": one capability of an interface, or one per instance name + if (endpoint.capabilities.some((other) => other.namespace === namespace && other.instance === instance)) { + refuse(instance ? "the instance is declared twice" : "the interface is declared twice"); + } + if (descriptor.tier === 3) + return; + if (descriptor.instanced) { + // alexa-rangecontroller.html, "Capabilities array": instance and capabilityResources are required + if (!isText(instance)) + refuse("needs an instance name, like Blind.Lift"); + try { + resources_js_1.labels.parse(friendlyNames, "friendlyNames"); + } + catch (err) { + refuse(err instanceof schema_js_1.SchemaError && err.path === "friendlyNames" ? "needs a friendly name, from text() or asset()" : err.message); + } + // resources-and-assets.html, "CapabilityResources": "The first friendly name in the array must be unique for + // the endpoint." + const first = nameKey(friendlyNames[0]); + const taken = endpoint.capabilities.find((other) => other.friendlyNames.length > 0 && nameKey(other.friendlyNames[0]) === first); + if (taken) { + refuse(`the first friendly name, ${(0, resources_js_1.shownAs)(friendlyNames[0])}, is the first of ${taken.namespace} ${JSON.stringify(taken.instance)} too`); + } + } + else { + if (instance) + refuse("takes no instance name: an endpoint has the interface once"); + if (friendlyNames.length > 0) + refuse("takes no friendly names"); + } + // generic-controllers.html, "Semantics for user utterances": "Each semantic phrase must be unique across all + // controller instances for each endpoint" + const used = new Map(); + for (const other of endpoint.capabilities) + for (const phrase of phrases(other)) + used.set(phrase, other); + for (const phrase of phrases(capability)) { + const owner = used.get(phrase); + if (owner) { + const place = owner === capability ? "twice" : `here and on ${owner.namespace} ${JSON.stringify(owner.instance)}`; + refuse(`${phrase} is mapped ${place}, an endpoint maps a phrase once`); + } + used.set(phrase, capability); + } + if (descriptor.validate) + descriptor.validate(capability, endpoint); +} diff --git a/dist/cjs/index.js b/dist/cjs/index.js index 0336bad..d152ab4 100644 --- a/dist/cjs/index.js +++ b/dist/cjs/index.js @@ -3,38 +3,51 @@ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); -exports.DisplayCategories = exports.States = exports.Actions = exports.Units = exports.Assets = exports.SchemaError = exports.DeclarationError = exports.registry = exports.AlexaErrorResponse = exports.AlexaErrorType = exports.ThermostatMode = exports.TemperatureSensorScale = exports.EndpointHealth = exports.PowerController = exports.AlexaStatusMessage = exports.DisplayCategory = exports.AlexaActions = exports.ActionMapping = exports.AlexaInterfaceType = exports.AlexaInterface = exports.Device = exports.DEFAULT_HOST = exports.Alex2MQTT = void 0; +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.text = exports.asset = exports.EndpointHealth = exports.PowerController = exports.TemperatureSensor = 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; } }); Object.defineProperty(exports, "DEFAULT_HOST", { enumerable: true, get: function () { return Alex2Node_js_1.DEFAULT_HOST; } }); -var Device_js_1 = require("./Device.js"); +var Device_js_1 = require("./device/Device.js"); Object.defineProperty(exports, "Device", { enumerable: true, get: function () { return __importDefault(Device_js_1).default; } }); -var AlexaInterface_js_1 = require("./AlexaInterface.js"); -Object.defineProperty(exports, "AlexaInterface", { enumerable: true, get: function () { return AlexaInterface_js_1.AlexaInterface; } }); -Object.defineProperty(exports, "AlexaInterfaceType", { enumerable: true, get: function () { return AlexaInterface_js_1.AlexaInterfaceType; } }); -var ActionMapping_js_1 = require("./ActionMapping.js"); -Object.defineProperty(exports, "ActionMapping", { enumerable: true, get: function () { return ActionMapping_js_1.ActionMapping; } }); -Object.defineProperty(exports, "AlexaActions", { enumerable: true, get: function () { return ActionMapping_js_1.AlexaActions; } }); -var DisplayCategory_js_1 = require("./DisplayCategory.js"); -Object.defineProperty(exports, "DisplayCategory", { enumerable: true, get: function () { return DisplayCategory_js_1.DisplayCategory; } }); -var AlexaStatusMessage_js_1 = require("./AlexaStatusMessage.js"); -Object.defineProperty(exports, "AlexaStatusMessage", { enumerable: true, get: function () { return AlexaStatusMessage_js_1.AlexaStatusMessage; } }); -Object.defineProperty(exports, "PowerController", { enumerable: true, get: function () { return AlexaStatusMessage_js_1.PowerController; } }); -Object.defineProperty(exports, "EndpointHealth", { enumerable: true, get: function () { return AlexaStatusMessage_js_1.EndpointHealth; } }); -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; } }); -Object.defineProperty(exports, "AlexaErrorResponse", { enumerable: true, get: function () { return AlexaErrorResponse_js_1.AlexaErrorResponse; } }); -// The interface registry and the vocabularies of the Smart Home API +var Capability_js_1 = require("./device/Capability.js"); +Object.defineProperty(exports, "Capability", { enumerable: true, get: function () { return Capability_js_1.Capability; } }); +// The interfaces, for device.add(), and the registry that holds them by name var index_js_1 = require("./registry/index.js"); Object.defineProperty(exports, "registry", { enumerable: true, get: function () { return index_js_1.registry; } }); Object.defineProperty(exports, "DeclarationError", { enumerable: true, get: function () { return index_js_1.DeclarationError; } }); Object.defineProperty(exports, "SchemaError", { enumerable: true, get: function () { return index_js_1.SchemaError; } }); var index_js_2 = require("./registry/index.js"); -Object.defineProperty(exports, "Assets", { enumerable: true, get: function () { return index_js_2.ASSETS; } }); -Object.defineProperty(exports, "Units", { enumerable: true, get: function () { return index_js_2.UNITS_OF_MEASURE; } }); -Object.defineProperty(exports, "Actions", { enumerable: true, get: function () { return index_js_2.ACTIONS; } }); -Object.defineProperty(exports, "States", { enumerable: true, get: function () { return index_js_2.STATES; } }); -Object.defineProperty(exports, "DisplayCategories", { enumerable: true, get: function () { return index_js_2.DISPLAY_CATEGORIES; } }); +Object.defineProperty(exports, "Alexa", { enumerable: true, get: function () { return index_js_2.Alexa; } }); +Object.defineProperty(exports, "BrightnessController", { enumerable: true, get: function () { return index_js_2.BrightnessController; } }); +Object.defineProperty(exports, "TemperatureSensor", { enumerable: true, get: function () { return index_js_2.TemperatureSensor; } }); +var enums_js_1 = require("./compat/enums.js"); +Object.defineProperty(exports, "PowerController", { enumerable: true, get: function () { return enums_js_1.PowerController; } }); +Object.defineProperty(exports, "EndpointHealth", { enumerable: true, get: function () { return enums_js_1.EndpointHealth; } }); +var resources_js_1 = require("./registry/resources.js"); +Object.defineProperty(exports, "asset", { enumerable: true, get: function () { return resources_js_1.asset; } }); +Object.defineProperty(exports, "text", { enumerable: true, get: function () { return resources_js_1.text; } }); +// The vocabularies of the Smart Home API +var index_js_3 = require("./registry/index.js"); +Object.defineProperty(exports, "Assets", { enumerable: true, get: function () { return index_js_3.ASSETS; } }); +Object.defineProperty(exports, "Units", { enumerable: true, get: function () { return index_js_3.UNITS_OF_MEASURE; } }); +Object.defineProperty(exports, "Actions", { enumerable: true, get: function () { return index_js_3.ACTIONS; } }); +Object.defineProperty(exports, "States", { enumerable: true, get: function () { return index_js_3.STATES; } }); +Object.defineProperty(exports, "DisplayCategories", { enumerable: true, get: function () { return index_js_3.DISPLAY_CATEGORIES; } }); +// 1.x +var AlexaInterface_js_1 = require("./compat/AlexaInterface.js"); +Object.defineProperty(exports, "AlexaInterface", { enumerable: true, get: function () { return AlexaInterface_js_1.AlexaInterface; } }); +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, "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, "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; } }); +Object.defineProperty(exports, "AlexaErrorResponse", { enumerable: true, get: function () { return AlexaErrorResponse_js_1.AlexaErrorResponse; } }); diff --git a/dist/cjs/registry/interfaces/stubs.js b/dist/cjs/registry/interfaces/stubs.js index 26d00e0..f33495e 100644 --- a/dist/cjs/registry/interfaces/stubs.js +++ b/dist/cjs/registry/interfaces/stubs.js @@ -80,6 +80,20 @@ const TABLE = [ ["Alexa.VideoRecorder", "1", [], "alexa-videorecorder.html"], ["Alexa.WakeOnLANController", "1", [], "alexa-wakeonlancontroller.html"], ]; +// What 1.5.2 added to the capability object of three of them +const EXTRAS = { + // No properties object; supportsDeactivation and proactivelyReported on the capability itself + "Alexa.SceneController": ({ proactivelyReported }) => ({ + properties: false, + topLevel: { supportsDeactivation: true, proactivelyReported }, + }), + "Alexa.ThermostatController": () => ({ + configuration: { supportedModes: ["HEAT", "COOL", "AUTO", "OFF"], supportsScheduling: false }, + }), + "Alexa.ModeController": ({ options }) => (options.supportedModes?.length > 0 + ? { configuration: { ordered: true, supportedModes: options.supportedModes } } + : {}), +}; function kindOf(namespace) { if (namespace.endsWith("Sensor")) return "sensor"; @@ -97,6 +111,7 @@ function stub([namespace, version, properties, page]) { instanced: false, properties: Object.fromEntries(properties.map((name) => [name, { name, value: schema_js_1.s.unknown() }])), directives: {}, + discovery: EXTRAS[namespace], }; } exports.STUBS = TABLE.map(stub); diff --git a/dist/cjs/registry/resources.js b/dist/cjs/registry/resources.js new file mode 100644 index 0000000..e8ddefb --- /dev/null +++ b/dist/cjs/registry/resources.js @@ -0,0 +1,45 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.labels = exports.label = void 0; +exports.text = text; +exports.asset = asset; +exports.shownAs = shownAs; +// Friendly names: what the user calls an instance, a mode or a preset (resources-and-assets.html). +const catalog_js_1 = require("./catalog.js"); +const schema_js_1 = require("./schema.js"); +/** A friendly name as text, in one locale. */ +function text(name, locale = "en-US") { + return { "@type": "text", value: { text: name, locale } }; +} +/** A friendly name from the global Alexa catalog: one id, several names, every language Alexa speaks. */ +function asset(assetId) { + return { "@type": "asset", value: { assetId } }; +} +const LOCALE = /^[a-z]{2}-[A-Z]{2}$/; +const textValue = schema_js_1.s.object({ + text: schema_js_1.s.string({ min: 1 }), + locale: schema_js_1.s.string({ pattern: LOCALE, expects: "a locale like en-US" }), +}); +const assetValue = schema_js_1.s.object({ assetId: schema_js_1.s.oneOf(catalog_js_1.ASSETS, "an id of the global Alexa catalog, like Alexa.Setting.Opening") }); +const typed = schema_js_1.s.object({ "@type": schema_js_1.s.enum("text", "asset"), value: schema_js_1.s.unknown() }); +const reserved = catalog_js_1.RESERVED_WORDS; +/** One friendly name. An asset has to be in the catalog, a text must not be a word Alexa keeps for itself. */ +exports.label = { + expects: "a friendly name, from text() or asset()", + parse(input, path = "") { + const { "@type": type, value } = typed.parse(input, path); + if (type === "asset") + return { "@type": type, value: assetValue.parse(value, (0, schema_js_1.at)(path, "value")) }; + const named = textValue.parse(value, (0, schema_js_1.at)(path, "value")); + if (reserved.includes(named.text.trim().toLowerCase())) { + throw new schema_js_1.SchemaError((0, schema_js_1.at)(path, "value.text"), `${JSON.stringify(named.text)} is a reserved word, not to be used as a friendly name`); + } + return { "@type": type, value: named }; + }, +}; +/** The friendly names of an instance, a mode or a preset: at least one. */ +exports.labels = schema_js_1.s.array(exports.label, { min: 1 }); +/** What the user is shown for a friendly name: the text, or the id of the asset. */ +function shownAs(name) { + return name["@type"] === "text" ? name.value.text : name.value.assetId; +} diff --git a/dist/cjs/registry/schema.js b/dist/cjs/registry/schema.js index de04ce8..6baf907 100644 --- a/dist/cjs/registry/schema.js +++ b/dist/cjs/registry/schema.js @@ -3,7 +3,8 @@ // and declaration options with these; parse() returns the value or throws a SchemaError that names where it went // wrong. Written here rather than taken from a package: mqtt stays the only runtime dependency. Object.defineProperty(exports, "__esModule", { value: true }); -exports.s = exports.SchemaError = void 0; +exports.s = exports.at = exports.SchemaError = void 0; +exports.mismatch = mismatch; /** A value that does not fit its schema: path is where ("payload.targetSetpoint.scale"), problem is what. */ class SchemaError extends Error { constructor(path, problem) { @@ -23,10 +24,13 @@ function shown(input) { const text = JSON.stringify(input) ?? String(input); return text.length > 60 ? `${text.slice(0, 57)}...` : text; } +/** The error of a value that is not what a schema expects. */ function mismatch(path, expects, input) { return new SchemaError(path, `expected ${expects}, got ${shown(input)}`); } +/** The path of a key of the object at path. */ const at = (path, key) => (path ? `${path}.${key}` : key); +exports.at = at; function isRecord(input) { return typeof input === "object" && input !== null && !Array.isArray(input); } @@ -69,7 +73,11 @@ function literal(value) { return schema(JSON.stringify(value), (input) => input === value); } function enumeration(...values) { - return { ...schema(values.join(" | "), (input) => values.includes(input)), values }; + return oneOf(values, values.join(" | ")); +} +/** One of a list too long to print in an error message: expects says what the list is. */ +function oneOf(values, expects) { + return { ...schema(expects, (input) => values.includes(input)), values }; } function unknown() { return schema("any value", () => true); @@ -127,11 +135,11 @@ function object(shape, rules = {}) { throw mismatch(path, expects, input); const others = Object.keys(input).filter((key) => !known.includes(key)); if (others.length > 0 && rules.unknownKeys === "reject") { - throw new SchemaError(at(path, others[0]), `unknown key, the known ones are ${known.join(", ") || "none"}`); + throw new SchemaError((0, exports.at)(path, others[0]), `unknown key, the known ones are ${known.join(", ") || "none"}`); } const parsed = {}; for (const key of known) { - const value = shape[key].parse(input[key], at(path, key)); + const value = shape[key].parse(input[key], (0, exports.at)(path, key)); if (value !== undefined) parsed[key] = value; } @@ -178,6 +186,7 @@ exports.s = { boolean, literal, enum: enumeration, + oneOf, unknown, optional, nullable, diff --git a/dist/esm/ActionMapping.d.ts b/dist/esm/ActionMapping.d.ts deleted file mode 100644 index 3f1fd42..0000000 --- a/dist/esm/ActionMapping.d.ts +++ /dev/null @@ -1,20 +0,0 @@ -export declare enum AlexaActions { - Open = "Alexa.Actions.Open", - Close = "Alexa.Actions.Close", - Raise = "Alexa.Actions.Raise", - Lower = "Alexa.Actions.Lower", - SetEcoOn = "Alexa.Actions.SetEcoOn", - SetEcoOff = "Alexa.Actions.SetEcoOff" -} -interface Directive { - name: string; - payload?: string; -} -export declare class ActionMapping { - type: string; - actions: AlexaActions[]; - directive: Directive; - constructor(actions: AlexaActions[], directiveName: string, directivePayload?: string); - toJSON(): object; -} -export {}; diff --git a/dist/esm/ActionMapping.js b/dist/esm/ActionMapping.js deleted file mode 100644 index 0b8c83c..0000000 --- a/dist/esm/ActionMapping.js +++ /dev/null @@ -1,28 +0,0 @@ -export var AlexaActions; -(function (AlexaActions) { - AlexaActions["Open"] = "Alexa.Actions.Open"; - AlexaActions["Close"] = "Alexa.Actions.Close"; - AlexaActions["Raise"] = "Alexa.Actions.Raise"; - AlexaActions["Lower"] = "Alexa.Actions.Lower"; - AlexaActions["SetEcoOn"] = "Alexa.Actions.SetEcoOn"; - AlexaActions["SetEcoOff"] = "Alexa.Actions.SetEcoOff"; -})(AlexaActions || (AlexaActions = {})); -export class ActionMapping { - constructor(actions, directiveName, directivePayload) { - this.type = "ActionsToDirective"; - this.actions = actions; - this.directive = { - name: directiveName, - }; - if (directivePayload) { - this.directive.payload = directivePayload; - } - } - toJSON() { - return { - "@type": this.type, - actions: this.actions, - directive: this.directive, - }; - } -} diff --git a/dist/esm/Alex2Node.d.ts b/dist/esm/Alex2Node.d.ts index 48cce6d..983e964 100644 --- a/dist/esm/Alex2Node.d.ts +++ b/dist/esm/Alex2Node.d.ts @@ -1,7 +1,8 @@ import type { IClientOptions } from "mqtt"; -import Device from "./Device.js"; +import Device from "./device/Device.js"; +import type { EndpointDefinition } from "./device/Device.js"; import { EventEmitter } from "events"; -import { DisplayCategory } from "./DisplayCategory.js"; +import { DisplayCategory } from "./compat/enums.js"; /** Optional settings for the bridge (1.5.1). Everything has the 1.4.0 behaviour as its default. */ export interface Alex2MQTTOptions { /** The broker URL. Default: the public Alex2MQTT broker, mqtt://Alex2MQTT.stormysdream.club:1883. */ @@ -10,6 +11,11 @@ export interface Alex2MQTTOptions { mqtt?: IClientOptions; /** Where log lines go. Default: console (only when debugLogging is on). */ log?: (message: string, detail?: unknown) => void; + /** + * false: discovery does not list the Alexa interface on the endpoints. Default: every endpoint ends with + * { type: "AlexaInterface", interface: "Alexa", version: "3" }, which Amazon requires and 1.x left out. + */ + alexaInterface?: boolean; } export declare const DEFAULT_HOST = "mqtt://Alex2MQTT.stormysdream.club:1883"; /** @@ -35,6 +41,7 @@ declare class Alex2MQTT extends EventEmitter { private devices; private MqttHost; private options; + private logged; /** true while the broker connection is up. */ connected: boolean; /** ISO time of the last discovery request answered, null before the first. */ @@ -44,9 +51,22 @@ declare class Alex2MQTT extends EventEmitter { /** Emit "error" only when somebody listens: an unhandled "error" event would crash the host process. */ private fail; connect(): void; + private describeDevices; /** Close the broker connection (resolves once closed). The devices stay registered; connect() again reuses them. */ disconnect(): Promise; - registerDevice(name: string, endpointId: string, displayCategory: DisplayCategory | DisplayCategory[] | null): Device; + /** + * Declare a device: + * + * const blinds = bridge.addDevice({ endpointId: "bedroom-blinds", name: "Bedroom Blinds", + * categories: ["INTERIOR_BLIND"], manufacturerName: "Acme", description: "Roller blind by Acme" }); + * + * Throws a DeclarationError when Alexa would reject the endpoint (an endpointId with a slash, a name with + * punctuation) or when the endpointId is registered already. Discovery lists Alexa.EndpointHealth for the device + * unless endpointHealth is false. + */ + addDevice(definition: EndpointDefinition): Device; + registerDevice(name: string, endpointId: string, displayCategory?: DisplayCategory | DisplayCategory[] | null): Device; + private register; /** Forget a device (its listeners with it). Returns false when there was none. */ unregisterDevice(endpointId: string): boolean; /** Forget every device. */ diff --git a/dist/esm/Alex2Node.js b/dist/esm/Alex2Node.js index 91c41e9..6838e74 100644 --- a/dist/esm/Alex2Node.js +++ b/dist/esm/Alex2Node.js @@ -1,10 +1,16 @@ // mqtt reaches Node as CommonJS: the default import works from both builds, a named one needs Node to detect it. import mqtt from "mqtt"; -import Device from "./Device.js"; +import Device from "./device/Device.js"; +import { checkEndpoint } from "./device/validate.js"; import { EventEmitter } from "events"; -import { DisplayCategory } from "./DisplayCategory.js"; -import { AlexaInterfaceType } from "./AlexaInterface.js"; +import { DisplayCategory } from "./compat/enums.js"; +import { DeclarationError } from "./registry/types.js"; export const DEFAULT_HOST = "mqtt://Alex2MQTT.stormysdream.club:1883"; +// What addDevice() copies from the definition to the device +const DESCRIBED = [ + "description", "manufacturerName", "manufacturer", "model", "serialNumber", "firmwareVersion", "softwareVersion", + "customIdentifier", "cookie", +]; /** * The Alexa-to-MQTT bridge: one broker connection for a user's root topic, a set of registered devices, discovery and * directive dispatch. @@ -28,6 +34,8 @@ class Alex2MQTT extends EventEmitter { this.debugLogging = debugLogging; this.client = null; this.devices = []; + // The lines of check() that were logged. Discovery comes every few minutes: a line is logged once. + this.logged = new Set(); /** true while the broker connection is up. */ this.connected = false; /** ISO time of the last discovery request answered, null before the first. */ @@ -84,12 +92,8 @@ class Alex2MQTT extends EventEmitter { this.log(`MQTT Message Received`, { topic, payload: message.toString() }); if (topic == `${this.rootTopic}/discover`) { this.log("Discovery request received, getting device json..."); - const deviceArray = this.devices.map((device) => device.getJSON()); + const deviceArray = this.describeDevices(); this.lastDiscoveryAt = new Date().toISOString(); - for (const device of this.devices) // a capability the library has no property list for goes out with supported: [] - for (const cap of device.getCapabilities()) - if (cap.getProps().length === 0 && cap.getType() !== AlexaInterfaceType.SCENE_CONTROLLER) - this.log(`${device.endpointId}: no property list known for ${cap.getTypeString()}, discovery lists it with no supported properties`); this.client.publish(topic + "_r", JSON.stringify(deviceArray), (err) => { if (err) { this.fail(err); @@ -130,6 +134,26 @@ class Alex2MQTT extends EventEmitter { } }); } + // The endpoint objects of a discovery answer. What check() says about a device is logged, each line once. A device + // that cannot be described is left out and reported as an error: the others are still announced. + describeDevices() { + const endpoints = []; + for (const device of this.devices) { + try { + endpoints.push(device.getJSON()); + for (const line of device.check()) { + if (this.logged.has(line)) + continue; + this.logged.add(line); + this.log(`warning: ${line}`); + } + } + catch (err) { + this.fail(new Error(`${device.endpointId} is not in the discovery answer: ${err instanceof Error ? err.message : err}`)); + } + } + return endpoints; + } /** Close the broker connection (resolves once closed). The devices stay registered; connect() again reuses them. */ disconnect() { return new Promise((resolve) => { @@ -141,6 +165,36 @@ class Alex2MQTT extends EventEmitter { c.end(true, {}, () => resolve()); }); } + /** + * Declare a device: + * + * const blinds = bridge.addDevice({ endpointId: "bedroom-blinds", name: "Bedroom Blinds", + * categories: ["INTERIOR_BLIND"], manufacturerName: "Acme", description: "Roller blind by Acme" }); + * + * Throws a DeclarationError when Alexa would reject the endpoint (an endpointId with a slash, a name with + * punctuation) or when the endpointId is registered already. Discovery lists Alexa.EndpointHealth for the device + * unless endpointHealth is false. + */ + addDevice(definition) { + if (!this.client) { + throw new Error("Must call connect before creating devices"); + } + const { endpointId, name, categories, endpointHealth, ...described } = definition; + // Without categories the device would take LIGHT, the default of registerDevice: here it is a mistake + const device = new Device(this.client, this.rootTopic, name, endpointId, (categories ?? [])); + for (const [field, value] of Object.entries(described)) { + if (!DESCRIBED.includes(field)) + throw new DeclarationError({ endpointId }, `${field} is not a field of an endpoint`); + if (value !== undefined) + Object.assign(device, { [field]: value }); + } + checkEndpoint(device.getJSON()); + const existing = this.getDevice(endpointId); + if (existing) + throw new DeclarationError({ endpointId }, `is registered already, as "${existing.name}"`); + this.register(device, endpointHealth !== false); + return device; + } registerDevice(name, endpointId, displayCategory) { if (!this.client) { throw new Error("Must call connect before creating devices"); @@ -155,15 +209,16 @@ class Alex2MQTT extends EventEmitter { return existing; } this.log(`Creating new device with endpoint: ${endpointId}`); - const normalizedCategory = displayCategory === null - ? [DisplayCategory.LIGHT] - : Array.isArray(displayCategory) - ? displayCategory - : [displayCategory || DisplayCategory.LIGHT]; + const normalizedCategory = Array.isArray(displayCategory) ? displayCategory : [displayCategory || DisplayCategory.LIGHT]; const device = new Device(this.client, this.rootTopic, name, endpointId, normalizedCategory); + this.register(device, false); + return device; + } + register(device, endpointHealth) { + device.alexaInterface = this.options.alexaInterface !== false; + device.endpointHealth = endpointHealth; device.onPublishError = (err) => this.fail(err); // a failed publish is an "error" event (when listened to), never a rejected send() this.devices.push(device); - return device; } /** Forget a device (its listeners with it). Returns false when there was none. */ unregisterDevice(endpointId) { diff --git a/dist/esm/AlexaInterface.d.ts b/dist/esm/AlexaInterface.d.ts deleted file mode 100644 index c0a4e64..0000000 --- a/dist/esm/AlexaInterface.d.ts +++ /dev/null @@ -1,109 +0,0 @@ -import type { ActionMapping } from "./ActionMapping.js"; -export declare enum AlexaInterfaceType { - APPLICATION_STATE_REPORTER = "Alexa.ApplicationStateReporter", - AUDIO_PLAY_QUEUE = "Alexa.Audio.PlayQueue", - AUTHORIZATION_CONTROLLER = "Alexa.AuthorizationController", - AUTOMATION_MANAGEMENT = "Alexa.AutomationManagement", - AUTOMOTIVE_VEHICLE_DATA = "Alexa.Automotive.VehicleData", - BRIGHTNESS_CONTROLLER = "Alexa.BrightnessController", - CAMERA_LIVE_VIEW_CONTROLLER = "Alexa.Camera.LiveViewController", - CAMERA_STREAM_CONTROLLER = "Alexa.CameraStreamController", - CHANNEL_CONTROLLER = "Alexa.ChannelController", - COLOR_CONTROLLER = "Alexa.ColorController", - COLOR_TEMPERATURE_CONTROLLER = "Alexa.ColorTemperatureController", - COMMISSIONABLE = "Alexa.Commissionable", - CONSENT_MANAGEMENT_CONSENT_REQUIRED_REPORTER = "Alexa.ConsentManagement.ConsentRequiredReporter", - CONTACT_SENSOR = "Alexa.ContactSensor", - COOKING = "Alexa.Cooking", - COOKING_FOOD_TEMPERATURE_CONTROLLER = "Alexa.Cooking.FoodTemperatureController", - COOKING_FOOD_TEMPERATURE_SENSOR = "Alexa.Cooking.FoodTemperatureSensor", - COOKING_PRESET_CONTROLLER = "Alexa.Cooking.PresetController", - COOKING_TEMPERATURE_CONTROLLER = "Alexa.Cooking.TemperatureController", - COOKING_TEMPERATURE_SENSOR = "Alexa.Cooking.TemperatureSensor", - COOKING_TIME_CONTROLLER = "Alexa.Cooking.TimeController", - DATA_CONTROLLER = "Alexa.DataController", - DEVICE_USAGE_ESTIMATION = "Alexa.DeviceUsage.Estimation", - DEVICE_USAGE_METER = "Alexa.DeviceUsage.Meter", - DOORBELL_EVENT_SOURCE = "Alexa.DoorbellEventSource", - ENDPOINT_HEALTH = "Alexa.EndpointHealth", - EQUALIZER_CONTROLLER = "Alexa.EqualizerController", - INPUT_CONTROLLER = "Alexa.InputController", - INVENTORY_LEVEL_SENSOR = "Alexa.InventoryLevelSensor", - INVENTORY_LEVEL_USAGE_SENSOR = "Alexa.InventoryLevelUsageSensor", - INVENTORY_USAGE_SENSOR = "Alexa.InventoryUsageSensor", - KEYPAD_CONTROLLER = "Alexa.KeypadController", - LAUNCHER = "Alexa.Launcher", - LOCK_CONTROLLER = "Alexa.LockController", - MEDIA_PLAYBACK = "Alexa.Media.Playback", - MEDIA_PLAY_QUEUE = "Alexa.Media.PlayQueue", - MEDIA_SEARCH = "Alexa.Media.Search", - MODE_CONTROLLER = "Alexa.ModeController", - MOTION_SENSOR = "Alexa.MotionSensor", - PERCENTAGE_CONTROLLER = "Alexa.PercentageController", - PLAYBACK_CONTROLLER = "Alexa.PlaybackController", - PLAYBACK_STATE_REPORTER = "Alexa.PlaybackStateReporter", - POWER_CONTROLLER = "Alexa.PowerController", - POWER_LEVEL_CONTROLLER = "Alexa.PowerLevelController", - PROACTIVE_NOTIFICATION_SOURCE = "Alexa.ProactiveNotificationSource", - RANGE_CONTROLLER = "Alexa.RangeController", - RECORD_CONTROLLER = "Alexa.RecordController", - REMOTE_VIDEO_PLAYER = "Alexa.RemoteVideoPlayer", - RTC_SESSION_CONTROLLER = "Alexa.RTCSessionController", - SCENE_CONTROLLER = "Alexa.SceneController", - SECURITY_PANEL_CONTROLLER = "Alexa.SecurityPanelController", - SECURITY_PANEL_CONTROLLER_ALERT = "Alexa.SecurityPanelController.Alert", - SEEK_CONTROLLER = "Alexa.SeekController", - SIMPLE_EVENT_SOURCE = "Alexa.SimpleEventSource", - SMART_VISION_OBJECT_DETECTION_SENSOR = "Alexa.SmartVision.ObjectDetectionSensor", - SMART_VISION_SNAPSHOT_PROVIDER = "Alexa.SmartVision.SnapshotProvider", - SPEAKER = "Alexa.Speaker", - STEP_SPEAKER = "Alexa.StepSpeaker", - TEMPERATURE_SENSOR = "Alexa.TemperatureSensor", - THERMOSTAT_CONTROLLER = "Alexa.ThermostatController", - THERMOSTAT_CONTROLLER_CONFIGURATION = "Alexa.ThermostatController.Configuration", - THERMOSTAT_CONTROLLER_HVAC_COMPONENTS = "Alexa.ThermostatController.HVAC.Components", - THERMOSTAT_CONTROLLER_SCHEDULE = "Alexa.ThermostatController.Schedule", - TIME_HOLD_CONTROLLER = "Alexa.TimeHoldController", - TOGGLE_CONTROLLER = "Alexa.ToggleController", - UI_CONTROLLER = "Alexa.UIController", - USER_PREFERENCE = "Alexa.UserPreference", - VIDEO_RECORDER = "Alexa.VideoRecorder", - WAKE_ON_LAN_CONTROLLER = "Alexa.WakeOnLANController", - UNKNOWN = "UNKNOWN" -} -/** One ModeController mode as discovery lists it (configuration.supportedModes): the value plus its friendly names. */ -export interface SupportedMode { - value: string; - modeResources?: { - friendlyNames: Array<{ - "@type": string; - value: { - text?: string; - locale?: string; - assetId?: string; - }; - }>; - }; -} -export declare class AlexaInterface { - type: AlexaInterfaceType; - retrievable: boolean; - proactivelyReported: boolean; - instance: string; - private friendlyNames; - private actionMappings; - private supportedModes; - constructor(type: AlexaInterfaceType, retrievable?: boolean, proactivelyReported?: boolean, instance?: string); - addActionMapping(mapping: ActionMapping): void; - addFriendlyName(name: string, locale: string): void; - /** The modes of a ModeController: { value, modeResources } objects as Alexa wants them (plain strings pass through as given). */ - addSupportedModes(modes: Array): void; - setInstance(name: string): void; - getType(): AlexaInterfaceType; - getTypeString(): string; - /** The version of the interface, from its descriptor. "UNKNOWN" for a name the registry does not have. */ - getVersion(): string; - /** The names of the properties the interface reports, from its descriptor. */ - getProps(): string[]; - getJSON(): object; -} diff --git a/dist/esm/AlexaStatusMessage.d.ts b/dist/esm/AlexaStatusMessage.d.ts index 026845b..3dbb621 100644 --- a/dist/esm/AlexaStatusMessage.d.ts +++ b/dist/esm/AlexaStatusMessage.d.ts @@ -1,8 +1,5 @@ +import type { EndpointHealth, PowerController } from "./compat/enums.js"; import type { MqttClient } from "mqtt"; -export declare enum EndpointHealth { - OK = "OK", - UNREACHABLE = "UNREACHABLE" -} export declare enum ThermostatMode { OFF = "OFF", HEAT = "HEAT", @@ -11,10 +8,6 @@ export declare enum ThermostatMode { ECO = "ECO", CUSTOM = "CUSTOM" } -export declare enum PowerController { - ON = "ON", - OFF = "OFF" -} export declare enum TemperatureSensorScale { CELSIUS = "CELSIUS", FAHRENHEIT = "FAHRENHEIT" diff --git a/dist/esm/AlexaStatusMessage.js b/dist/esm/AlexaStatusMessage.js index 43d1a78..9dddc1f 100644 --- a/dist/esm/AlexaStatusMessage.js +++ b/dist/esm/AlexaStatusMessage.js @@ -1,10 +1,5 @@ import { randomUUID } from "crypto"; -import { AlexaInterfaceType } from "./AlexaInterface.js"; -export var EndpointHealth; -(function (EndpointHealth) { - EndpointHealth["OK"] = "OK"; - EndpointHealth["UNREACHABLE"] = "UNREACHABLE"; -})(EndpointHealth || (EndpointHealth = {})); +import { AlexaInterfaceType } from "./compat/enums.js"; export var ThermostatMode; (function (ThermostatMode) { ThermostatMode["OFF"] = "OFF"; @@ -14,11 +9,6 @@ export var ThermostatMode; ThermostatMode["ECO"] = "ECO"; ThermostatMode["CUSTOM"] = "CUSTOM"; })(ThermostatMode || (ThermostatMode = {})); -export var PowerController; -(function (PowerController) { - PowerController["ON"] = "ON"; - PowerController["OFF"] = "OFF"; -})(PowerController || (PowerController = {})); export var TemperatureSensorScale; (function (TemperatureSensorScale) { TemperatureSensorScale["CELSIUS"] = "CELSIUS"; diff --git a/dist/esm/Device.d.ts b/dist/esm/Device.d.ts deleted file mode 100644 index 102fb74..0000000 --- a/dist/esm/Device.d.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { AlexaInterface } from "./AlexaInterface.js"; -import type { AlexaInterfaceType } from "./AlexaInterface.js"; -import { DisplayCategory } from "./DisplayCategory.js"; -import { EventEmitter } from "events"; -import type { MqttClient } from "mqtt"; -import { AlexaStatusMessage } from "./AlexaStatusMessage.js"; -import type { ChangeCause } from "./AlexaStatusMessage.js"; -import { AlexaErrorResponse } from "./AlexaErrorResponse.js"; -declare class Device extends EventEmitter { - private mqttClient; - private rootTopic; - name: string; - endpointId: string; - displayCategory: Array | null; - description: string; - manufacturerName: string; - manufacturer: string; - model: string; - protected softwareVersion: string; - private capabilities; - /** Where a failed publish from this device or a message it built is reported; Alex2MQTT sets it (1.5.2). */ - onPublishError?: (err: Error) => void; - constructor(mqttClient: MqttClient, rootTopic: string, name: string, endpointId: string, displayCategory: Array | null, description?: string, manufacturerName?: string, manufacturer?: string, model?: string); - /** Internal (1.5.2): Alex2MQTT.connect() re-binds every registered device to its new broker client after a disconnect(). */ - setMqttClient(client: MqttClient): void; - getName(): string; - setName(name: string): void; - getEndpointId(): string; - setDisplayCategory(category: DisplayCategory | DisplayCategory[]): void; - getDisplayCategory(): Array; - setDescription(description: string): void; - getDescription(): string; - getErrorMessage(correlationToken: string): AlexaErrorResponse; - getStatusMessage(correlationToken: string, isResponse?: boolean, isDeferred?: boolean): AlexaStatusMessage; - /** - * 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. - */ - getChangeReport(cause?: ChangeCause): AlexaStatusMessage; - /** - * Alexa.SceneController: answer an Activate / Deactivate directive with ActivationStarted / DeactivationStarted - * (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, sendAsync?: boolean): Promise; - getCapabilities(): AlexaInterface[]; - setManufacturerName(name: string): void; - getManufacturerName(): string; - setManufacturer(manufacturer: string): void; - getManufacturer(): string; - setModel(model: string): void; - getModel(): string; - getSoftwareVersion(): string; - /** Add a capability. 1.5.1: options.retrievable / proactivelyReported / instance (a change report needs proactivelyReported). */ - addCapability(type: AlexaInterfaceType, options?: { - retrievable?: boolean; - proactivelyReported?: boolean; - instance?: string; - }): AlexaInterface; - getJSON(): Record; -} -export default Device; diff --git a/dist/esm/Device.js b/dist/esm/Device.js deleted file mode 100644 index 83a20e4..0000000 --- a/dist/esm/Device.js +++ /dev/null @@ -1,143 +0,0 @@ -import { AlexaInterface } from "./AlexaInterface.js"; -import { DisplayCategory } from "./DisplayCategory.js"; -import { EventEmitter } from "events"; -import { randomUUID } from "crypto"; -import { AlexaStatusMessage } from "./AlexaStatusMessage.js"; -import { AlexaErrorResponse } from "./AlexaErrorResponse.js"; -class Device extends EventEmitter { - constructor(mqttClient, rootTopic, name, endpointId, displayCategory, description = "Alexa to Node.js bridge", manufacturerName = "Alex2Node", manufacturer = "Alex2Node", model = "Alex2Node_v1.0.0") { - super(); - this.mqttClient = mqttClient; - this.rootTopic = rootTopic; - this.name = name; - this.endpointId = endpointId; - this.displayCategory = displayCategory; - this.description = description; - this.manufacturerName = manufacturerName; - this.manufacturer = manufacturer; - this.model = model; - this.softwareVersion = "1.0.0"; - this.capabilities = []; - } - /** Internal (1.5.2): Alex2MQTT.connect() re-binds every registered device to its new broker client after a disconnect(). */ - setMqttClient(client) { - this.mqttClient = client; - } - getName() { - return this.name; - } - setName(name) { - this.name = name; - } - getEndpointId() { - return this.endpointId; - } - setDisplayCategory(category) { - this.displayCategory = Array.isArray(category) ? category : [category]; - } - getDisplayCategory() { - return this.displayCategory || [DisplayCategory.LIGHT]; - } - setDescription(description) { - this.description = description; - } - getDescription() { - return this.description; - } - getErrorMessage(correlationToken) { - const msg = new AlexaErrorResponse(correlationToken, this.rootTopic, this.endpointId, this.mqttClient); - msg.onPublishError = this.onPublishError; - return msg; - } - getStatusMessage(correlationToken, isResponse = false, isDeferred = false) { - const msg = new AlexaStatusMessage(correlationToken, this.rootTopic, this.endpointId, this.mqttClient, isResponse, isDeferred); - msg.onPublishError = this.onPublishError; - return msg; - } - /** - * 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. - */ - getChangeReport(cause = "PHYSICAL_INTERACTION") { - const msg = new AlexaStatusMessage("", this.rootTopic, this.endpointId, this.mqttClient, false, false, cause); - msg.onPublishError = this.onPublishError; - return msg; - } - /** - * Alexa.SceneController: answer an Activate / Deactivate directive with ActivationStarted / DeactivationStarted - * (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 topic = `${this.rootTopic}/${this.endpointId}/${sendAsync ? "deferredResponse" : "alexaResponce"}`; - return new Promise((resolve) => { - this.mqttClient.publish(topic, JSON.stringify(payload), (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 - }); - }); - } - getCapabilities() { - return this.capabilities.slice(); - } - setManufacturerName(name) { - this.manufacturerName = name; - } - getManufacturerName() { - return this.manufacturerName; - } - setManufacturer(manufacturer) { - this.manufacturer = manufacturer; - } - getManufacturer() { - return this.manufacturer; - } - setModel(model) { - this.model = model; - } - getModel() { - return this.model; - } - getSoftwareVersion() { - return this.softwareVersion; - } - /** Add a capability. 1.5.1: options.retrievable / proactivelyReported / instance (a change report needs proactivelyReported). */ - addCapability(type, options = {}) { - const newCapability = new AlexaInterface(type, options.retrievable ?? true, options.proactivelyReported ?? false, options.instance ?? ""); - this.capabilities.push(newCapability); - // console.log( - // `Created new interface with type: ${newCapability.getTypeString()}` - // ); - return newCapability; - } - getJSON() { - return { - endpointId: this.endpointId, - friendlyName: this.name, - description: this.description, - manufacturerName: this.manufacturerName, - displayCategories: this.getDisplayCategory(), - additionalAttributes: { - manufacturer: this.manufacturer, - model: this.model, - serialNumber: "Alex2Node", - firmwareVersion: "1.0.0", - softwareVersion: this.softwareVersion, - customIdentifier: "Alex2Node", - }, - capabilities: this.capabilities.map((cap) => cap.getJSON()), - }; - } -} -export default Device; diff --git a/dist/esm/DisplayCategory.d.ts b/dist/esm/DisplayCategory.d.ts deleted file mode 100644 index ee7b947..0000000 --- a/dist/esm/DisplayCategory.d.ts +++ /dev/null @@ -1,60 +0,0 @@ -export declare enum DisplayCategory { - ACTIVITY_TRIGGER = "ACTIVITY_TRIGGER", - AIR_CONDITIONER = "AIR_CONDITIONER", - AIR_FRESHENER = "AIR_FRESHENER", - AIR_PURIFIER = "AIR_PURIFIER", - AIR_QUALITY_MONITOR = "AIR_QUALITY_MONITOR", - ALEXA_VOICE_ENABLED = "ALEXA_VOICE_ENABLED", - AUTO_ACCESSORY = "AUTO_ACCESSORY", - BLUETOOTH_SPEAKER = "BLUETOOTH_SPEAKER", - CAMERA = "CAMERA", - CHRISTMAS_TREE = "CHRISTMAS_TREE", - COFFEE_MAKER = "COFFEE_MAKER", - COMPUTER = "COMPUTER", - CONTACT_SENSOR = "CONTACT_SENSOR", - DISHWASHER = "DISHWASHER", - DOOR = "DOOR", - DOORBELL = "DOORBELL", - DRYER = "DRYER", - EXTERIOR_BLIND = "EXTERIOR_BLIND", - FAN = "FAN", - GAME_CONSOLE = "GAME_CONSOLE", - GARAGE_DOOR = "GARAGE_DOOR", - HEADPHONES = "HEADPHONES", - HUB = "HUB", - INTERIOR_BLIND = "INTERIOR_BLIND", - LAPTOP = "LAPTOP", - LIGHT = "LIGHT", - MICROWAVE = "MICROWAVE", - MOBILE_PHONE = "MOBILE_PHONE", - MOTION_SENSOR = "MOTION_SENSOR", - MUSIC_SYSTEM = "MUSIC_SYSTEM", - NETWORK_HARDWARE = "NETWORK_HARDWARE", - OTHER = "OTHER", - OVEN = "OVEN", - PHONE = "PHONE", - PRINTER = "PRINTER", - REMOTE = "REMOTE", - ROUTER = "ROUTER", - SCENE_TRIGGER = "SCENE_TRIGGER", - SCREEN = "SCREEN", - SECURITY_PANEL = "SECURITY_PANEL", - SECURITY_SYSTEM = "SECURITY_SYSTEM", - SLOW_COOKER = "SLOW_COOKER", - SMARTLOCK = "SMARTLOCK", - SMARTPLUG = "SMARTPLUG", - SPEAKER = "SPEAKER", - STREAMING_DEVICE = "STREAMING_DEVICE", - SWITCH = "SWITCH", - TABLET = "TABLET", - TEMPERATURE_SENSOR = "TEMPERATURE_SENSOR", - THERMOSTAT = "THERMOSTAT", - TV = "TV", - VACUUM_CLEANER = "VACUUM_CLEANER", - VACUUM = "VACUUM", - /** Not on the list of display categories any more; kept for 1.x callers. */ - VEHICLE = "VEHICLE", - WASHER = "WASHER", - WATER_HEATER = "WATER_HEATER", - WEARABLE = "WEARABLE" -} diff --git a/dist/esm/DisplayCategory.js b/dist/esm/DisplayCategory.js deleted file mode 100644 index 15ca7d9..0000000 --- a/dist/esm/DisplayCategory.js +++ /dev/null @@ -1,61 +0,0 @@ -export var DisplayCategory; -(function (DisplayCategory) { - DisplayCategory["ACTIVITY_TRIGGER"] = "ACTIVITY_TRIGGER"; - DisplayCategory["AIR_CONDITIONER"] = "AIR_CONDITIONER"; - DisplayCategory["AIR_FRESHENER"] = "AIR_FRESHENER"; - DisplayCategory["AIR_PURIFIER"] = "AIR_PURIFIER"; - DisplayCategory["AIR_QUALITY_MONITOR"] = "AIR_QUALITY_MONITOR"; - DisplayCategory["ALEXA_VOICE_ENABLED"] = "ALEXA_VOICE_ENABLED"; - DisplayCategory["AUTO_ACCESSORY"] = "AUTO_ACCESSORY"; - DisplayCategory["BLUETOOTH_SPEAKER"] = "BLUETOOTH_SPEAKER"; - DisplayCategory["CAMERA"] = "CAMERA"; - DisplayCategory["CHRISTMAS_TREE"] = "CHRISTMAS_TREE"; - DisplayCategory["COFFEE_MAKER"] = "COFFEE_MAKER"; - DisplayCategory["COMPUTER"] = "COMPUTER"; - DisplayCategory["CONTACT_SENSOR"] = "CONTACT_SENSOR"; - DisplayCategory["DISHWASHER"] = "DISHWASHER"; - DisplayCategory["DOOR"] = "DOOR"; - DisplayCategory["DOORBELL"] = "DOORBELL"; - DisplayCategory["DRYER"] = "DRYER"; - DisplayCategory["EXTERIOR_BLIND"] = "EXTERIOR_BLIND"; - DisplayCategory["FAN"] = "FAN"; - DisplayCategory["GAME_CONSOLE"] = "GAME_CONSOLE"; - DisplayCategory["GARAGE_DOOR"] = "GARAGE_DOOR"; - DisplayCategory["HEADPHONES"] = "HEADPHONES"; - DisplayCategory["HUB"] = "HUB"; - DisplayCategory["INTERIOR_BLIND"] = "INTERIOR_BLIND"; - DisplayCategory["LAPTOP"] = "LAPTOP"; - DisplayCategory["LIGHT"] = "LIGHT"; - DisplayCategory["MICROWAVE"] = "MICROWAVE"; - DisplayCategory["MOBILE_PHONE"] = "MOBILE_PHONE"; - DisplayCategory["MOTION_SENSOR"] = "MOTION_SENSOR"; - DisplayCategory["MUSIC_SYSTEM"] = "MUSIC_SYSTEM"; - DisplayCategory["NETWORK_HARDWARE"] = "NETWORK_HARDWARE"; - DisplayCategory["OTHER"] = "OTHER"; - DisplayCategory["OVEN"] = "OVEN"; - DisplayCategory["PHONE"] = "PHONE"; - DisplayCategory["PRINTER"] = "PRINTER"; - DisplayCategory["REMOTE"] = "REMOTE"; - DisplayCategory["ROUTER"] = "ROUTER"; - DisplayCategory["SCENE_TRIGGER"] = "SCENE_TRIGGER"; - DisplayCategory["SCREEN"] = "SCREEN"; - DisplayCategory["SECURITY_PANEL"] = "SECURITY_PANEL"; - DisplayCategory["SECURITY_SYSTEM"] = "SECURITY_SYSTEM"; - DisplayCategory["SLOW_COOKER"] = "SLOW_COOKER"; - DisplayCategory["SMARTLOCK"] = "SMARTLOCK"; - DisplayCategory["SMARTPLUG"] = "SMARTPLUG"; - DisplayCategory["SPEAKER"] = "SPEAKER"; - DisplayCategory["STREAMING_DEVICE"] = "STREAMING_DEVICE"; - DisplayCategory["SWITCH"] = "SWITCH"; - DisplayCategory["TABLET"] = "TABLET"; - DisplayCategory["TEMPERATURE_SENSOR"] = "TEMPERATURE_SENSOR"; - DisplayCategory["THERMOSTAT"] = "THERMOSTAT"; - DisplayCategory["TV"] = "TV"; - DisplayCategory["VACUUM_CLEANER"] = "VACUUM_CLEANER"; - DisplayCategory["VACUUM"] = "VACUUM"; - /** Not on the list of display categories any more; kept for 1.x callers. */ - DisplayCategory["VEHICLE"] = "VEHICLE"; - DisplayCategory["WASHER"] = "WASHER"; - DisplayCategory["WATER_HEATER"] = "WATER_HEATER"; - DisplayCategory["WEARABLE"] = "WEARABLE"; -})(DisplayCategory || (DisplayCategory = {})); diff --git a/dist/esm/compat/ActionMapping.d.ts b/dist/esm/compat/ActionMapping.d.ts new file mode 100644 index 0000000..26c685b --- /dev/null +++ b/dist/esm/compat/ActionMapping.d.ts @@ -0,0 +1,23 @@ +import type { ActionsToDirective } from "../registry/types.js"; +import type { AlexaActions } from "./enums.js"; +/** + * A semantics action mapping of 1.x: the phrases "open", "close", "raise", "lower" for one directive of the + * capability it is added to. + * + * toggle.addActionMapping(new ActionMapping([AlexaActions.Close], "TurnOff")); + * lift.addActionMapping(new ActionMapping([AlexaActions.Open], "SetRangeValue", { rangeValue: 100 })); + */ +export declare class ActionMapping { + type: "ActionsToDirective"; + actions: AlexaActions[]; + directive: ActionsToDirective["directive"]; + /** Set when the mapping was built in a way that 3.0 will refuse; the bridge logs it at the next discovery. */ + readonly deprecation?: string; + /** + * directivePayload is the payload object of the directive (alexa-discovery-objects.html, "ActionMappings object"). + * 1.x took a string and put it into discovery as one. A string that holds a JSON object is parsed; any other + * string throws a DeclarationError. + */ + constructor(actions: AlexaActions[], directiveName: string, directivePayload?: Record | string); + toJSON(): ActionsToDirective; +} diff --git a/dist/esm/compat/ActionMapping.js b/dist/esm/compat/ActionMapping.js new file mode 100644 index 0000000..966db34 --- /dev/null +++ b/dist/esm/compat/ActionMapping.js @@ -0,0 +1,45 @@ +import { DeclarationError } from "../registry/types.js"; +/** + * A semantics action mapping of 1.x: the phrases "open", "close", "raise", "lower" for one directive of the + * capability it is added to. + * + * toggle.addActionMapping(new ActionMapping([AlexaActions.Close], "TurnOff")); + * lift.addActionMapping(new ActionMapping([AlexaActions.Open], "SetRangeValue", { rangeValue: 100 })); + */ +export class ActionMapping { + /** + * directivePayload is the payload object of the directive (alexa-discovery-objects.html, "ActionMappings object"). + * 1.x took a string and put it into discovery as one. A string that holds a JSON object is parsed; any other + * string throws a DeclarationError. + */ + constructor(actions, directiveName, directivePayload) { + this.type = "ActionsToDirective"; + this.actions = actions; + this.directive = { name: directiveName }; + if (typeof directivePayload === "string") { + if (directivePayload === "") + return; + this.directive.payload = objectIn(directivePayload, directiveName); + this.deprecation = `the payload of the action mapping for ${directiveName} is a JSON string, pass the object`; + } + else if (directivePayload) { + this.directive.payload = directivePayload; + } + } + toJSON() { + return { "@type": this.type, actions: this.actions, directive: this.directive }; + } +} +function objectIn(text, directiveName) { + let value; + try { + value = JSON.parse(text); + } + catch { + value = undefined; + } + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new DeclarationError({}, `the payload of the action mapping for ${directiveName} is an object, got the string ${JSON.stringify(text)}`); + } + return value; +} diff --git a/dist/esm/compat/AlexaInterface.d.ts b/dist/esm/compat/AlexaInterface.d.ts new file mode 100644 index 0000000..f24a410 --- /dev/null +++ b/dist/esm/compat/AlexaInterface.d.ts @@ -0,0 +1,49 @@ +import { Capability } from "../device/Capability.js"; +import type { CapabilityJson } from "../device/Capability.js"; +import type { Semantics } from "../registry/types.js"; +import type { ActionMapping } from "./ActionMapping.js"; +import type { AlexaInterfaceType } from "./enums.js"; +/** One ModeController mode as discovery lists it (configuration.supportedModes): the value plus its friendly names. */ +export interface SupportedMode { + value: string; + modeResources?: { + friendlyNames: Array<{ + "@type": string; + value: { + text?: string; + locale?: string; + assetId?: string; + }; + }>; + }; +} +interface Options { + semantics?: Semantics; + supportedModes?: Array; +} +/** + * A capability as 1.x declares it: device.addCapability(type, options), then the add and set methods below. It is a + * Capability, so what the methods set reaches discovery the same way as the options of device.add(). Nothing is + * refused here except an interface name the registry does not have: what Alexa would reject is logged by the bridge + * when it answers a discovery. + */ +export declare class AlexaInterface extends Capability { + constructor(type: AlexaInterfaceType | string, retrievable?: boolean, proactivelyReported?: boolean, instance?: string); + /** The namespace of the interface, which is the value of its AlexaInterfaceType member. */ + get type(): AlexaInterfaceType; + addActionMapping(mapping: ActionMapping): void; + addFriendlyName(name: string, locale: string): void; + /** The modes of a ModeController, as { value, modeResources } objects. */ + addSupportedModes(modes: Array): void; + setInstance(name: string): void; + getType(): AlexaInterfaceType; + /** @deprecated The same as getType(). */ + getTypeString(): string; + /** @deprecated Read descriptor.version. */ + getVersion(): string; + /** @deprecated Read the keys of descriptor.properties. */ + getProps(): string[]; + getJSON(): CapabilityJson; + toJSON(): CapabilityJson; +} +export {}; diff --git a/dist/esm/compat/AlexaInterface.js b/dist/esm/compat/AlexaInterface.js new file mode 100644 index 0000000..ef02b39 --- /dev/null +++ b/dist/esm/compat/AlexaInterface.js @@ -0,0 +1,57 @@ +import { Capability } from "../device/Capability.js"; +import { registry } from "../registry/index.js"; +import { text } from "../registry/resources.js"; +/** + * A capability as 1.x declares it: device.addCapability(type, options), then the add and set methods below. It is a + * Capability, so what the methods set reaches discovery the same way as the options of device.add(). Nothing is + * refused here except an interface name the registry does not have: what Alexa would reject is logged by the bridge + * when it answers a discovery. + */ +export class AlexaInterface extends Capability { + constructor(type, retrievable = true, proactivelyReported = false, instance = "") { + super(registry.get(type), { retrievable, proactivelyReported, instance, options: {} }); + } + /** The namespace of the interface, which is the value of its AlexaInterfaceType member. */ + get type() { + return this.descriptor.namespace; + } + addActionMapping(mapping) { + var _a; + const semantics = ((_a = this.options).semantics ?? (_a.semantics = {})); + (semantics.actionMappings ?? (semantics.actionMappings = [])).push(mapping.toJSON()); + if (mapping.deprecation) + this.notes.push(mapping.deprecation); + } + addFriendlyName(name, locale) { + this.friendlyNames.push(text(name, locale)); + } + /** The modes of a ModeController, as { value, modeResources } objects. */ + addSupportedModes(modes) { + this.options.supportedModes = modes; + } + setInstance(name) { + this.instance = name; + } + getType() { + return this.type; + } + /** @deprecated The same as getType(). */ + getTypeString() { + return this.type; + } + /** @deprecated Read descriptor.version. */ + getVersion() { + return this.descriptor.version; + } + /** @deprecated Read the keys of descriptor.properties. */ + getProps() { + return Object.keys(this.descriptor.properties); + } + getJSON() { + return super.toJSON(); + } + // 1.x callers corrected the discovery JSON by replacing getJSON on the object: discovery goes through it + toJSON() { + return this.getJSON(); + } +} diff --git a/dist/esm/compat/enums.d.ts b/dist/esm/compat/enums.d.ts new file mode 100644 index 0000000..71a3f17 --- /dev/null +++ b/dist/esm/compat/enums.d.ts @@ -0,0 +1,193 @@ +/** Every interface name of 1.x. registry.get() takes a member as it takes the namespace, which is its value. */ +export declare enum AlexaInterfaceType { + APPLICATION_STATE_REPORTER = "Alexa.ApplicationStateReporter", + AUDIO_PLAY_QUEUE = "Alexa.Audio.PlayQueue", + AUTHORIZATION_CONTROLLER = "Alexa.AuthorizationController", + AUTOMATION_MANAGEMENT = "Alexa.AutomationManagement", + AUTOMOTIVE_VEHICLE_DATA = "Alexa.Automotive.VehicleData", + BRIGHTNESS_CONTROLLER = "Alexa.BrightnessController", + CAMERA_LIVE_VIEW_CONTROLLER = "Alexa.Camera.LiveViewController", + CAMERA_STREAM_CONTROLLER = "Alexa.CameraStreamController", + CHANNEL_CONTROLLER = "Alexa.ChannelController", + COLOR_CONTROLLER = "Alexa.ColorController", + COLOR_TEMPERATURE_CONTROLLER = "Alexa.ColorTemperatureController", + COMMISSIONABLE = "Alexa.Commissionable", + CONSENT_MANAGEMENT_CONSENT_REQUIRED_REPORTER = "Alexa.ConsentManagement.ConsentRequiredReporter", + CONTACT_SENSOR = "Alexa.ContactSensor", + COOKING = "Alexa.Cooking", + COOKING_FOOD_TEMPERATURE_CONTROLLER = "Alexa.Cooking.FoodTemperatureController", + COOKING_FOOD_TEMPERATURE_SENSOR = "Alexa.Cooking.FoodTemperatureSensor", + COOKING_PRESET_CONTROLLER = "Alexa.Cooking.PresetController", + COOKING_TEMPERATURE_CONTROLLER = "Alexa.Cooking.TemperatureController", + COOKING_TEMPERATURE_SENSOR = "Alexa.Cooking.TemperatureSensor", + COOKING_TIME_CONTROLLER = "Alexa.Cooking.TimeController", + DATA_CONTROLLER = "Alexa.DataController", + DEVICE_USAGE_ESTIMATION = "Alexa.DeviceUsage.Estimation", + DEVICE_USAGE_METER = "Alexa.DeviceUsage.Meter", + DOORBELL_EVENT_SOURCE = "Alexa.DoorbellEventSource", + ENDPOINT_HEALTH = "Alexa.EndpointHealth", + EQUALIZER_CONTROLLER = "Alexa.EqualizerController", + INPUT_CONTROLLER = "Alexa.InputController", + INVENTORY_LEVEL_SENSOR = "Alexa.InventoryLevelSensor", + INVENTORY_LEVEL_USAGE_SENSOR = "Alexa.InventoryLevelUsageSensor", + INVENTORY_USAGE_SENSOR = "Alexa.InventoryUsageSensor", + KEYPAD_CONTROLLER = "Alexa.KeypadController", + LAUNCHER = "Alexa.Launcher", + LOCK_CONTROLLER = "Alexa.LockController", + MEDIA_PLAYBACK = "Alexa.Media.Playback", + MEDIA_PLAY_QUEUE = "Alexa.Media.PlayQueue", + MEDIA_SEARCH = "Alexa.Media.Search", + MODE_CONTROLLER = "Alexa.ModeController", + MOTION_SENSOR = "Alexa.MotionSensor", + PERCENTAGE_CONTROLLER = "Alexa.PercentageController", + PLAYBACK_CONTROLLER = "Alexa.PlaybackController", + PLAYBACK_STATE_REPORTER = "Alexa.PlaybackStateReporter", + POWER_CONTROLLER = "Alexa.PowerController", + POWER_LEVEL_CONTROLLER = "Alexa.PowerLevelController", + PROACTIVE_NOTIFICATION_SOURCE = "Alexa.ProactiveNotificationSource", + RANGE_CONTROLLER = "Alexa.RangeController", + RECORD_CONTROLLER = "Alexa.RecordController", + REMOTE_VIDEO_PLAYER = "Alexa.RemoteVideoPlayer", + RTC_SESSION_CONTROLLER = "Alexa.RTCSessionController", + SCENE_CONTROLLER = "Alexa.SceneController", + SECURITY_PANEL_CONTROLLER = "Alexa.SecurityPanelController", + SECURITY_PANEL_CONTROLLER_ALERT = "Alexa.SecurityPanelController.Alert", + SEEK_CONTROLLER = "Alexa.SeekController", + SIMPLE_EVENT_SOURCE = "Alexa.SimpleEventSource", + SMART_VISION_OBJECT_DETECTION_SENSOR = "Alexa.SmartVision.ObjectDetectionSensor", + SMART_VISION_SNAPSHOT_PROVIDER = "Alexa.SmartVision.SnapshotProvider", + SPEAKER = "Alexa.Speaker", + STEP_SPEAKER = "Alexa.StepSpeaker", + TEMPERATURE_SENSOR = "Alexa.TemperatureSensor", + THERMOSTAT_CONTROLLER = "Alexa.ThermostatController", + THERMOSTAT_CONTROLLER_CONFIGURATION = "Alexa.ThermostatController.Configuration", + THERMOSTAT_CONTROLLER_HVAC_COMPONENTS = "Alexa.ThermostatController.HVAC.Components", + THERMOSTAT_CONTROLLER_SCHEDULE = "Alexa.ThermostatController.Schedule", + TIME_HOLD_CONTROLLER = "Alexa.TimeHoldController", + TOGGLE_CONTROLLER = "Alexa.ToggleController", + UI_CONTROLLER = "Alexa.UIController", + USER_PREFERENCE = "Alexa.UserPreference", + VIDEO_RECORDER = "Alexa.VideoRecorder", + WAKE_ON_LAN_CONTROLLER = "Alexa.WakeOnLANController", + /** Not an interface: declaring it throws a DeclarationError. Kept because 1.x had it. */ + UNKNOWN = "UNKNOWN" +} +export declare enum DisplayCategory { + ACTIVITY_TRIGGER = "ACTIVITY_TRIGGER", + AIR_CONDITIONER = "AIR_CONDITIONER", + AIR_FRESHENER = "AIR_FRESHENER", + AIR_PURIFIER = "AIR_PURIFIER", + AIR_QUALITY_MONITOR = "AIR_QUALITY_MONITOR", + ALEXA_VOICE_ENABLED = "ALEXA_VOICE_ENABLED", + AUTO_ACCESSORY = "AUTO_ACCESSORY", + BLUETOOTH_SPEAKER = "BLUETOOTH_SPEAKER", + CAMERA = "CAMERA", + CHRISTMAS_TREE = "CHRISTMAS_TREE", + COFFEE_MAKER = "COFFEE_MAKER", + COMPUTER = "COMPUTER", + CONTACT_SENSOR = "CONTACT_SENSOR", + DISHWASHER = "DISHWASHER", + DOOR = "DOOR", + DOORBELL = "DOORBELL", + DRYER = "DRYER", + EXTERIOR_BLIND = "EXTERIOR_BLIND", + FAN = "FAN", + GAME_CONSOLE = "GAME_CONSOLE", + GARAGE_DOOR = "GARAGE_DOOR", + HEADPHONES = "HEADPHONES", + HUB = "HUB", + INTERIOR_BLIND = "INTERIOR_BLIND", + LAPTOP = "LAPTOP", + LIGHT = "LIGHT", + MICROWAVE = "MICROWAVE", + MOBILE_PHONE = "MOBILE_PHONE", + MOTION_SENSOR = "MOTION_SENSOR", + MUSIC_SYSTEM = "MUSIC_SYSTEM", + NETWORK_HARDWARE = "NETWORK_HARDWARE", + OTHER = "OTHER", + OVEN = "OVEN", + PHONE = "PHONE", + PRINTER = "PRINTER", + REMOTE = "REMOTE", + ROUTER = "ROUTER", + SCENE_TRIGGER = "SCENE_TRIGGER", + SCREEN = "SCREEN", + SECURITY_PANEL = "SECURITY_PANEL", + SECURITY_SYSTEM = "SECURITY_SYSTEM", + SLOW_COOKER = "SLOW_COOKER", + SMARTLOCK = "SMARTLOCK", + SMARTPLUG = "SMARTPLUG", + SPEAKER = "SPEAKER", + STREAMING_DEVICE = "STREAMING_DEVICE", + SWITCH = "SWITCH", + TABLET = "TABLET", + TEMPERATURE_SENSOR = "TEMPERATURE_SENSOR", + THERMOSTAT = "THERMOSTAT", + TV = "TV", + VACUUM_CLEANER = "VACUUM_CLEANER", + VACUUM = "VACUUM", + /** Not on the list of display categories any more; kept for 1.x callers. */ + VEHICLE = "VEHICLE", + WASHER = "WASHER", + WATER_HEATER = "WATER_HEATER", + WEARABLE = "WEARABLE" +} +export declare enum AlexaActions { + Open = "Alexa.Actions.Open", + Close = "Alexa.Actions.Close", + Raise = "Alexa.Actions.Raise", + Lower = "Alexa.Actions.Lower", + SetEcoOn = "Alexa.Actions.SetEcoOn", + SetEcoOff = "Alexa.Actions.SetEcoOff" +} +/** The values of powerState and toggleState. */ +export declare const PowerState: { + readonly ON: "ON"; + readonly OFF: "OFF"; +}; +export type PowerState = (typeof PowerState)[keyof typeof PowerState]; +declare const Connectivity: { + readonly OK: "OK"; + readonly UNREACHABLE: "UNREACHABLE"; +}; +/** + * The Alexa.PowerController interface, for device.add(). PowerController.ON and PowerController.OFF are the 1.x enum + * of power states and stay until 3.0: PowerState has the same two members. + */ +export declare const PowerController: import("../index.js").InterfaceDescriptor<{ + powerState: { + name: string; + value: import("../registry/schema.js").EnumSchema<"ON" | "OFF">; + }; +}, { + TurnOn: { + name: string; + payload: import("../index.js").Schema>; + }; + TurnOff: { + name: string; + payload: import("../index.js").Schema>; + }; +}, import("../registry/schema.js").InferShape<{ + verificationsRequired: import("../registry/schema.js").OptionalSchema<("TurnOn" | "TurnOff")[]>; +}>, false> & { + readonly ON: "ON"; + readonly OFF: "OFF"; +}; +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 declare const EndpointHealth: import("../index.js").InterfaceDescriptor<{ + connectivity: { + 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">; + }>>; + note: string; + }; +}, {}, {}, false> & { + readonly OK: "OK"; + readonly UNREACHABLE: "UNREACHABLE"; +}; +export type EndpointHealth = (typeof Connectivity)[keyof typeof Connectivity]; +export {}; diff --git a/dist/esm/AlexaInterface.js b/dist/esm/compat/enums.js similarity index 53% rename from dist/esm/AlexaInterface.js rename to dist/esm/compat/enums.js index e5c5940..68deaf2 100644 --- a/dist/esm/AlexaInterface.js +++ b/dist/esm/compat/enums.js @@ -1,4 +1,7 @@ -import { registry } from "./registry/index.js"; +// The enums of 1.x, under their 1.x names. +import { EndpointHealth as EndpointHealthInterface } from "../registry/interfaces/EndpointHealth.js"; +import { PowerController as PowerControllerInterface } from "../registry/interfaces/PowerController.js"; +/** Every interface name of 1.x. registry.get() takes a member as it takes the namespace, which is its value. */ export var AlexaInterfaceType; (function (AlexaInterfaceType) { AlexaInterfaceType["APPLICATION_STATE_REPORTER"] = "Alexa.ApplicationStateReporter"; @@ -70,96 +73,88 @@ export var AlexaInterfaceType; AlexaInterfaceType["USER_PREFERENCE"] = "Alexa.UserPreference"; AlexaInterfaceType["VIDEO_RECORDER"] = "Alexa.VideoRecorder"; AlexaInterfaceType["WAKE_ON_LAN_CONTROLLER"] = "Alexa.WakeOnLANController"; + /** Not an interface: declaring it throws a DeclarationError. Kept because 1.x had it. */ AlexaInterfaceType["UNKNOWN"] = "UNKNOWN"; })(AlexaInterfaceType || (AlexaInterfaceType = {})); -export class AlexaInterface { - constructor(type, retrievable = true, proactivelyReported = false, instance = "") { - this.type = type; - this.retrievable = retrievable; - this.proactivelyReported = proactivelyReported; - this.instance = instance; - this.friendlyNames = []; - this.actionMappings = []; - this.supportedModes = []; - } - addActionMapping(mapping) { - this.actionMappings.push(mapping); - } - addFriendlyName(name, locale) { - this.friendlyNames.push({ text: name, locale }); - } - /** The modes of a ModeController: { value, modeResources } objects as Alexa wants them (plain strings pass through as given). */ - addSupportedModes(modes) { - this.supportedModes = modes; - } - setInstance(name) { - this.instance = name; - } - getType() { - return this.type; - } - getTypeString() { - return this.type; - } - /** The version of the interface, from its descriptor. "UNKNOWN" for a name the registry does not have. */ - getVersion() { - return registry.has(this.type) ? registry.get(this.type).version : "UNKNOWN"; - } - /** The names of the properties the interface reports, from its descriptor. */ - getProps() { - return registry.has(this.type) ? Object.keys(registry.get(this.type).properties) : []; - } - getJSON() { - const doc = { - interface: this.getTypeString(), - version: this.getVersion(), - type: "AlexaInterface", - properties: { - retrievable: this.retrievable, - proactivelyReported: this.proactivelyReported, - supported: this.getProps().map(name => ({ name })), - }, - }; - if (this.type == AlexaInterfaceType.SCENE_CONTROLLER) { - // Alexa.SceneController v3 carries no properties block: supportsDeactivation + proactivelyReported at the top level - delete doc.properties; - doc.supportsDeactivation = true; - doc.proactivelyReported = this.proactivelyReported; - } - else if (this.type == AlexaInterfaceType.THERMOSTAT_CONTROLLER) { - doc.configuration = { - "supportedModes": ["HEAT", "COOL", "AUTO", "OFF"], - "supportsScheduling": false - }; - } - else if (this.supportedModes.length > 0) { - doc["configuration"] = { - ordered: true, - supportedModes: this.supportedModes, - }; - } - if (this.instance) { - doc.instance = this.instance; - } - if (this.friendlyNames.length > 0) { - const capabilityResources = doc["capabilityResources"] || {}; - const friendlyNamesArray = capabilityResources["friendlyNames"] || []; - this.friendlyNames.forEach((fn) => { - const friendlyNameObj = { "@type": "text" }; - friendlyNameObj["value"] = { - text: fn.text, - locale: fn.locale, - }; - friendlyNamesArray.push(friendlyNameObj); - }); - capabilityResources.friendlyNames = friendlyNamesArray; - doc["capabilityResources"] = capabilityResources; - } - if (this.actionMappings.length > 0) { - doc.semantics = { - actionMappings: this.actionMappings.map((am) => am.toJSON()), - }; - } - return doc; - } -} +export var DisplayCategory; +(function (DisplayCategory) { + DisplayCategory["ACTIVITY_TRIGGER"] = "ACTIVITY_TRIGGER"; + DisplayCategory["AIR_CONDITIONER"] = "AIR_CONDITIONER"; + DisplayCategory["AIR_FRESHENER"] = "AIR_FRESHENER"; + DisplayCategory["AIR_PURIFIER"] = "AIR_PURIFIER"; + DisplayCategory["AIR_QUALITY_MONITOR"] = "AIR_QUALITY_MONITOR"; + DisplayCategory["ALEXA_VOICE_ENABLED"] = "ALEXA_VOICE_ENABLED"; + DisplayCategory["AUTO_ACCESSORY"] = "AUTO_ACCESSORY"; + DisplayCategory["BLUETOOTH_SPEAKER"] = "BLUETOOTH_SPEAKER"; + DisplayCategory["CAMERA"] = "CAMERA"; + DisplayCategory["CHRISTMAS_TREE"] = "CHRISTMAS_TREE"; + DisplayCategory["COFFEE_MAKER"] = "COFFEE_MAKER"; + DisplayCategory["COMPUTER"] = "COMPUTER"; + DisplayCategory["CONTACT_SENSOR"] = "CONTACT_SENSOR"; + DisplayCategory["DISHWASHER"] = "DISHWASHER"; + DisplayCategory["DOOR"] = "DOOR"; + DisplayCategory["DOORBELL"] = "DOORBELL"; + DisplayCategory["DRYER"] = "DRYER"; + DisplayCategory["EXTERIOR_BLIND"] = "EXTERIOR_BLIND"; + DisplayCategory["FAN"] = "FAN"; + DisplayCategory["GAME_CONSOLE"] = "GAME_CONSOLE"; + DisplayCategory["GARAGE_DOOR"] = "GARAGE_DOOR"; + DisplayCategory["HEADPHONES"] = "HEADPHONES"; + DisplayCategory["HUB"] = "HUB"; + DisplayCategory["INTERIOR_BLIND"] = "INTERIOR_BLIND"; + DisplayCategory["LAPTOP"] = "LAPTOP"; + DisplayCategory["LIGHT"] = "LIGHT"; + DisplayCategory["MICROWAVE"] = "MICROWAVE"; + DisplayCategory["MOBILE_PHONE"] = "MOBILE_PHONE"; + DisplayCategory["MOTION_SENSOR"] = "MOTION_SENSOR"; + DisplayCategory["MUSIC_SYSTEM"] = "MUSIC_SYSTEM"; + DisplayCategory["NETWORK_HARDWARE"] = "NETWORK_HARDWARE"; + DisplayCategory["OTHER"] = "OTHER"; + DisplayCategory["OVEN"] = "OVEN"; + DisplayCategory["PHONE"] = "PHONE"; + DisplayCategory["PRINTER"] = "PRINTER"; + DisplayCategory["REMOTE"] = "REMOTE"; + DisplayCategory["ROUTER"] = "ROUTER"; + DisplayCategory["SCENE_TRIGGER"] = "SCENE_TRIGGER"; + DisplayCategory["SCREEN"] = "SCREEN"; + DisplayCategory["SECURITY_PANEL"] = "SECURITY_PANEL"; + DisplayCategory["SECURITY_SYSTEM"] = "SECURITY_SYSTEM"; + DisplayCategory["SLOW_COOKER"] = "SLOW_COOKER"; + DisplayCategory["SMARTLOCK"] = "SMARTLOCK"; + DisplayCategory["SMARTPLUG"] = "SMARTPLUG"; + DisplayCategory["SPEAKER"] = "SPEAKER"; + DisplayCategory["STREAMING_DEVICE"] = "STREAMING_DEVICE"; + DisplayCategory["SWITCH"] = "SWITCH"; + DisplayCategory["TABLET"] = "TABLET"; + DisplayCategory["TEMPERATURE_SENSOR"] = "TEMPERATURE_SENSOR"; + DisplayCategory["THERMOSTAT"] = "THERMOSTAT"; + DisplayCategory["TV"] = "TV"; + DisplayCategory["VACUUM_CLEANER"] = "VACUUM_CLEANER"; + DisplayCategory["VACUUM"] = "VACUUM"; + /** Not on the list of display categories any more; kept for 1.x callers. */ + DisplayCategory["VEHICLE"] = "VEHICLE"; + DisplayCategory["WASHER"] = "WASHER"; + DisplayCategory["WATER_HEATER"] = "WATER_HEATER"; + DisplayCategory["WEARABLE"] = "WEARABLE"; +})(DisplayCategory || (DisplayCategory = {})); +export var AlexaActions; +(function (AlexaActions) { + AlexaActions["Open"] = "Alexa.Actions.Open"; + AlexaActions["Close"] = "Alexa.Actions.Close"; + AlexaActions["Raise"] = "Alexa.Actions.Raise"; + AlexaActions["Lower"] = "Alexa.Actions.Lower"; + AlexaActions["SetEcoOn"] = "Alexa.Actions.SetEcoOn"; + AlexaActions["SetEcoOff"] = "Alexa.Actions.SetEcoOff"; +})(AlexaActions || (AlexaActions = {})); +/** The values of powerState and toggleState. */ +export const PowerState = { ON: "ON", OFF: "OFF" }; +const Connectivity = { OK: "OK", UNREACHABLE: "UNREACHABLE" }; +// PowerController and EndpointHealth were enums in 1.x and are the names of two interfaces. One export serves both: +// the descriptor, which also has the members of the enum, and a type of the same name for the values. +/** + * The Alexa.PowerController interface, for device.add(). PowerController.ON and PowerController.OFF are the 1.x enum + * of power states and stay until 3.0: PowerState has the same two members. + */ +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); diff --git a/dist/esm/device/Capability.d.ts b/dist/esm/device/Capability.d.ts new file mode 100644 index 0000000..3b05184 --- /dev/null +++ b/dist/esm/device/Capability.d.ts @@ -0,0 +1,79 @@ +import type { Declared, Directives, InterfaceDescriptor, Label, Properties, Semantics } from "../registry/types.js"; +/** What a declaration can set for any interface. */ +export interface CommonOptions { + /** The name of the instance of a generic controller: "Blind.Lift", "Fan.Speed". */ + instance?: string; + /** What the user calls the instance; the first name is the one the Alexa app shows. */ + friendlyNames?: Label[]; + /** The properties are in the answer to ReportState. Default: true. */ + retrievable?: boolean; + /** A change of the properties is sent to Alexa as a ChangeReport. Default: false. */ + proactivelyReported?: boolean; + /** The user can ask for the properties and cannot set them. Left out of discovery when not given. */ + nonControllable?: boolean; +} +/** The same as a schema. The friendly names are checked with the instance, by the rules of the interface. */ +export declare const commonOptions: import("../registry/schema.js").Schema; + friendlyNames: import("../registry/schema.js").OptionalSchema; + retrievable: import("../registry/schema.js").OptionalSchema; + proactivelyReported: import("../registry/schema.js").OptionalSchema; + nonControllable: import("../registry/schema.js").OptionalSchema; +}>>; +type Naming = I extends true ? { + instance: string; + friendlyNames: Label[]; +} : { + instance?: string; + friendlyNames?: Label[]; +}; +/** The options of device.add() for one interface: its own, and the ones every interface has. */ +export type Declaration = O & Omit & Naming; +/** A capability object of a discovery answer (alexa-discovery-objects.html, "Capability object"). */ +export interface CapabilityJson { + type: "AlexaInterface"; + interface: string; + instance?: string; + version: string; + properties?: { + supported: Array<{ + name: string; + }>; + proactivelyReported: boolean; + retrievable: boolean; + nonControllable?: boolean; + }; + capabilityResources?: { + friendlyNames: Label[]; + }; + configuration?: Record; + configurations?: Record; + semantics?: Semantics; + [field: string]: unknown; +} +/** One interface as an endpoint declares it: the descriptor, and what the declaration says about this endpoint. */ +export declare class Capability

implements Declared { + readonly descriptor: InterfaceDescriptor; + /** The endpoint the capability is declared on; the device sets it. */ + endpointId: string; + /** "" for an interface without instances. */ + instance: string; + friendlyNames: Label[]; + retrievable: boolean; + proactivelyReported: boolean; + nonControllable?: boolean; + /** The options of the interface: a range, the supported modes, the semantics. */ + options: O; + /** What the bridge logs about the declaration, once, when it answers a discovery. */ + readonly notes: string[]; + constructor(descriptor: InterfaceDescriptor, declared: CommonOptions & { + options: O; + }); + get namespace(): string; + /** Mappings of "open", "close", "raise", "lower" and of states, when the declaration has any. */ + get semantics(): Semantics | undefined; + /** The capability object for discovery, its fields in the order of the example on alexa-discovery-objects.html. */ + toJSON(): CapabilityJson; +} +export type AnyCapability = Capability; +export {}; diff --git a/dist/esm/device/Capability.js b/dist/esm/device/Capability.js new file mode 100644 index 0000000..1876be1 --- /dev/null +++ b/dist/esm/device/Capability.js @@ -0,0 +1,63 @@ +import { s } from "../registry/schema.js"; +/** The same as a schema. The friendly names are checked with the instance, by the rules of the interface. */ +export const commonOptions = s.object({ + instance: s.optional(s.string()), + friendlyNames: s.optional(s.array(s.unknown())), + retrievable: s.optional(s.boolean()), + proactivelyReported: s.optional(s.boolean()), + nonControllable: s.optional(s.boolean()), +}); +/** One interface as an endpoint declares it: the descriptor, and what the declaration says about this endpoint. */ +export class Capability { + constructor(descriptor, declared) { + this.descriptor = descriptor; + /** The endpoint the capability is declared on; the device sets it. */ + this.endpointId = ""; + /** What the bridge logs about the declaration, once, when it answers a discovery. */ + this.notes = []; + this.instance = declared.instance ?? ""; + this.friendlyNames = declared.friendlyNames ?? []; + this.retrievable = declared.retrievable ?? true; + this.proactivelyReported = declared.proactivelyReported ?? false; + this.nonControllable = declared.nonControllable; + this.options = declared.options; + } + get namespace() { + return this.descriptor.namespace; + } + /** Mappings of "open", "close", "raise", "lower" and of states, when the declaration has any. */ + get semantics() { + const { semantics } = this.options; + const mapped = (semantics?.actionMappings?.length ?? 0) + (semantics?.stateMappings?.length ?? 0); + return mapped > 0 ? semantics : undefined; + } + /** The capability object for discovery, its fields in the order of the example on alexa-discovery-objects.html. */ + toJSON() { + const { descriptor, semantics } = this; + const extras = descriptor.discovery ? descriptor.discovery(this) : {}; + const json = { + type: "AlexaInterface", + interface: descriptor.namespace, + ...(this.instance ? { instance: this.instance } : {}), + version: descriptor.version, + }; + if (extras.properties !== false) { + json.properties = { + supported: Object.keys(descriptor.properties).map((name) => ({ name })), + proactivelyReported: this.proactivelyReported, + retrievable: this.retrievable, + }; + if (this.nonControllable !== undefined) + json.properties.nonControllable = this.nonControllable; + } + if (this.friendlyNames.length > 0) + json.capabilityResources = { friendlyNames: this.friendlyNames }; + if (extras.configuration) + json.configuration = extras.configuration; + if (extras.configurations) + json.configurations = extras.configurations; + if (semantics) + json.semantics = semantics; + return { ...json, ...extras.topLevel }; + } +} diff --git a/dist/esm/device/Device.d.ts b/dist/esm/device/Device.d.ts new file mode 100644 index 0000000..e4f1c36 --- /dev/null +++ b/dist/esm/device/Device.d.ts @@ -0,0 +1,135 @@ +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 { AlexaInterface } from "../compat/AlexaInterface.js"; +import { DisplayCategory } from "../compat/enums.js"; +import type { AlexaInterfaceType } from "../compat/enums.js"; +import type { DisplayCategoryName } from "../registry/catalog.js"; +import type { Directives, InterfaceDescriptor, Properties } from "../registry/types.js"; +import { Capability } from "./Capability.js"; +import type { AnyCapability, CapabilityJson, Declaration } from "./Capability.js"; +import type { EndpointFields } from "./validate.js"; +/** An endpoint as bridge.addDevice() takes it. */ +export interface EndpointDefinition { + /** Up to 256 letters, digits, spaces and _ - = # ; : ? @ &. The same id at every discovery. */ + endpointId: string; + /** What the user calls the device: letters, digits and spaces. */ + name: string; + /** The first one is the category the Alexa app shows the device under. */ + categories: Array; + /** Shown in the Alexa app, up to 128 characters. Default: "Alexa to Node.js bridge". */ + description?: string; + /** Up to 128 characters. Default: "Alex2Node". */ + manufacturerName?: string; + manufacturer?: string; + model?: string; + serialNumber?: string; + firmwareVersion?: string; + softwareVersion?: string; + customIdentifier?: string; + /** Returned with every directive to the endpoint. Up to 5000 bytes; not a place for state. */ + cookie?: Record; + /** false: the device gets no Alexa.EndpointHealth unless it declares one. */ + endpointHealth?: boolean; +} +/** An endpoint object of a discovery answer (alexa-discovery-objects.html, "Endpoint object"). */ +export interface EndpointJson extends EndpointFields { + capabilities: CapabilityJson[]; +} +type DeclarationArguments = {} extends Declaration ? [options?: Declaration] : [options: Declaration]; +declare class Device extends EventEmitter { + private mqttClient; + private rootTopic; + name: string; + endpointId: string; + displayCategory: Array | null; + description: string; + manufacturerName: string; + manufacturer: string; + model: string; + softwareVersion: string; + serialNumber: string; + firmwareVersion: string; + customIdentifier: string; + cookie?: Record; + /** + * Discovery lists the Alexa interface on the endpoint (alexa-interface.html: "You must explicitly identify your + * support for the Alexa interface"). The bridge sets it from its alexaInterface option. + */ + alexaInterface: boolean; + /** + * Discovery lists Alexa.EndpointHealth when the device did not declare it and is not a scene. On for a device of + * bridge.addDevice(), off for one of registerDevice(), which is announced as 1.x announced it. + */ + endpointHealth: boolean; + private capabilities; + /** Where a failed publish from this device or a message it built is reported; Alex2MQTT sets it (1.5.2). */ + onPublishError?: (err: Error) => void; + constructor(mqttClient: MqttClient, rootTopic: string, name: string, endpointId: string, displayCategory: Array | null, description?: string, manufacturerName?: string, manufacturer?: string, model?: string); + /** Internal (1.5.2): Alex2MQTT.connect() re-binds every registered device to its new broker client after a disconnect(). */ + setMqttClient(client: MqttClient): void; + getName(): string; + setName(name: string): void; + getEndpointId(): string; + setDisplayCategory(category: DisplayCategory | DisplayCategory[]): void; + getDisplayCategory(): Array; + setDescription(description: string): void; + getDescription(): string; + getErrorMessage(correlationToken: string): AlexaErrorResponse; + getStatusMessage(correlationToken: string, isResponse?: boolean, isDeferred?: boolean): AlexaStatusMessage; + /** + * 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. + */ + getChangeReport(cause?: ChangeCause): AlexaStatusMessage; + /** + * Alexa.SceneController: answer an Activate / Deactivate directive with ActivationStarted / DeactivationStarted + * (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, sendAsync?: boolean): Promise; + /** The capabilities the device declared, in the order it declared them. */ + getCapabilities(): AnyCapability[]; + setManufacturerName(name: string): void; + getManufacturerName(): string; + setManufacturer(manufacturer: string): void; + getManufacturer(): string; + setModel(model: string): void; + getModel(): string; + getSoftwareVersion(): string; + /** + * Declare an interface of the device: + * + * lamp.add(PowerController, { proactivelyReported: true }); + * blinds.add(RangeController, { instance: "Blind.Lift", friendlyNames: [asset("Alexa.Setting.Opening")], + * range: { min: 0, max: 100, precision: 1 } }); + * + * Throws a DeclarationError that names the endpoint, the interface and the instance when Alexa would reject the + * capability; the device is then as it was before the call. + */ + add

(descriptor: InterfaceDescriptor, ...[options]: DeclarationArguments): Capability; + /** + * Declare an interface the 1.x way, by its name: options.retrievable / proactivelyReported / instance, then the + * add and set methods of the capability that is returned. Throws for a name that is not an interface. Anything + * else Alexa would reject is not refused here: check() lists it. + */ + addCapability(type: AlexaInterfaceType | string, options?: { + retrievable?: boolean; + proactivelyReported?: boolean; + instance?: string; + }): AlexaInterface; + /** + * What Alexa would reject in the device as it is declared now, one line for each: a name with punctuation, an + * interface declared twice. Also what a capability noted about its declaration. Empty when there is nothing. + * The bridge logs the lines when it answers a discovery. + */ + check(): string[]; + /** The endpoint object for discovery. */ + getJSON(): EndpointJson; + private fields; + private view; + private announced; +} +export default Device; diff --git a/dist/esm/device/Device.js b/dist/esm/device/Device.js new file mode 100644 index 0000000..5cfb3e6 --- /dev/null +++ b/dist/esm/device/Device.js @@ -0,0 +1,268 @@ +import { EventEmitter } from "events"; +import { randomUUID } from "crypto"; +import { AlexaErrorResponse } from "../AlexaErrorResponse.js"; +import { AlexaStatusMessage } from "../AlexaStatusMessage.js"; +import { AlexaInterface } from "../compat/AlexaInterface.js"; +import { DisplayCategory } from "../compat/enums.js"; +import { Alexa } from "../registry/interfaces/Alexa.js"; +import { EndpointHealth } from "../registry/interfaces/EndpointHealth.js"; +import { SchemaError } from "../registry/schema.js"; +import { DeclarationError } from "../registry/types.js"; +import { Capability, commonOptions } from "./Capability.js"; +import { checkCapability, checkCapabilityCount, checkEndpoint } from "./validate.js"; +class Device extends EventEmitter { + constructor(mqttClient, rootTopic, name, endpointId, displayCategory, description = "Alexa to Node.js bridge", manufacturerName = "Alex2Node", manufacturer = "Alex2Node", model = "Alex2Node_v1.0.0") { + super(); + this.mqttClient = mqttClient; + this.rootTopic = rootTopic; + this.name = name; + this.endpointId = endpointId; + this.displayCategory = displayCategory; + this.description = description; + this.manufacturerName = manufacturerName; + this.manufacturer = manufacturer; + this.model = model; + this.softwareVersion = "1.0.0"; + this.serialNumber = "Alex2Node"; + this.firmwareVersion = "1.0.0"; + this.customIdentifier = "Alex2Node"; + /** + * Discovery lists the Alexa interface on the endpoint (alexa-interface.html: "You must explicitly identify your + * support for the Alexa interface"). The bridge sets it from its alexaInterface option. + */ + this.alexaInterface = true; + /** + * Discovery lists Alexa.EndpointHealth when the device did not declare it and is not a scene. On for a device of + * bridge.addDevice(), off for one of registerDevice(), which is announced as 1.x announced it. + */ + this.endpointHealth = false; + this.capabilities = []; + } + /** Internal (1.5.2): Alex2MQTT.connect() re-binds every registered device to its new broker client after a disconnect(). */ + setMqttClient(client) { + this.mqttClient = client; + } + getName() { + return this.name; + } + setName(name) { + this.name = name; + } + getEndpointId() { + return this.endpointId; + } + setDisplayCategory(category) { + this.displayCategory = Array.isArray(category) ? category : [category]; + } + getDisplayCategory() { + return this.displayCategory || [DisplayCategory.LIGHT]; + } + setDescription(description) { + this.description = description; + } + getDescription() { + return this.description; + } + getErrorMessage(correlationToken) { + const msg = new AlexaErrorResponse(correlationToken, this.rootTopic, this.endpointId, this.mqttClient); + msg.onPublishError = this.onPublishError; + return msg; + } + getStatusMessage(correlationToken, isResponse = false, isDeferred = false) { + const msg = new AlexaStatusMessage(correlationToken, this.rootTopic, this.endpointId, this.mqttClient, isResponse, isDeferred); + msg.onPublishError = this.onPublishError; + return msg; + } + /** + * 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. + */ + getChangeReport(cause = "PHYSICAL_INTERACTION") { + const msg = new AlexaStatusMessage("", this.rootTopic, this.endpointId, this.mqttClient, false, false, cause); + msg.onPublishError = this.onPublishError; + return msg; + } + /** + * Alexa.SceneController: answer an Activate / Deactivate directive with ActivationStarted / DeactivationStarted + * (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 topic = `${this.rootTopic}/${this.endpointId}/${sendAsync ? "deferredResponse" : "alexaResponce"}`; + return new Promise((resolve) => { + this.mqttClient.publish(topic, JSON.stringify(payload), (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 + }); + }); + } + /** The capabilities the device declared, in the order it declared them. */ + getCapabilities() { + return this.capabilities.slice(); + } + setManufacturerName(name) { + this.manufacturerName = name; + } + getManufacturerName() { + return this.manufacturerName; + } + setManufacturer(manufacturer) { + this.manufacturer = manufacturer; + } + getManufacturer() { + return this.manufacturer; + } + setModel(model) { + this.model = model; + } + getModel() { + return this.model; + } + getSoftwareVersion() { + return this.softwareVersion; + } + /** + * Declare an interface of the device: + * + * lamp.add(PowerController, { proactivelyReported: true }); + * blinds.add(RangeController, { instance: "Blind.Lift", friendlyNames: [asset("Alexa.Setting.Opening")], + * range: { min: 0, max: 100, precision: 1 } }); + * + * Throws a DeclarationError that names the endpoint, the interface and the instance when Alexa would reject the + * capability; the device is then as it was before the call. + */ + add(descriptor, ...[options]) { + const where = { endpointId: this.endpointId, namespace: descriptor.namespace, instance: "" }; + if (options !== undefined && (typeof options !== "object" || options === null)) { + throw new DeclarationError(where, "the options of a declaration are an object"); + } + try { + const { instance, friendlyNames, retrievable, proactivelyReported, nonControllable, ...given } = commonOptions.parse(options ?? {}); + where.instance = instance ?? ""; + const common = { instance, friendlyNames: friendlyNames, retrievable, proactivelyReported, nonControllable }; + const capability = new Capability(descriptor, { ...common, options: interfaceOptions(descriptor, given) }); + capability.endpointId = this.endpointId; + checkCapability(capability, descriptor, this.view(this.capabilities)); + checkCapabilityCount(this.endpointId, this.announced([...this.capabilities, capability]).length); + this.capabilities.push(capability); + return capability; + } + catch (err) { + throw err instanceof SchemaError ? new DeclarationError(where, err.message) : err; + } + } + /** + * Declare an interface the 1.x way, by its name: options.retrievable / proactivelyReported / instance, then the + * add and set methods of the capability that is returned. Throws for a name that is not an interface. Anything + * else Alexa would reject is not refused here: check() lists it. + */ + addCapability(type, options = {}) { + let capability; + try { + capability = new AlexaInterface(type, options.retrievable ?? true, options.proactivelyReported ?? false, options.instance ?? ""); + } + catch (err) { + throw err instanceof DeclarationError ? new DeclarationError({ endpointId: this.endpointId }, err.problem) : err; + } + capability.endpointId = this.endpointId; + this.capabilities.push(capability); + return capability; + } + /** + * What Alexa would reject in the device as it is declared now, one line for each: a name with punctuation, an + * interface declared twice. Also what a capability noted about its declaration. Empty when there is nothing. + * The bridge logs the lines when it answers a discovery. + */ + check() { + const lines = []; + const attempt = (rule) => { + try { + rule(); + } + catch (err) { + if (!(err instanceof DeclarationError)) + throw err; + lines.push(err.message); + } + }; + attempt(() => checkEndpoint(this.fields())); + this.capabilities.forEach((capability, i) => { + capability.endpointId = this.endpointId; + attempt(() => checkCapability(capability, capability.descriptor, this.view(this.capabilities.slice(0, i)))); + const notes = [...capability.notes]; + const { tier, properties } = capability.descriptor; + if (tier === 3 && Object.keys(properties).length === 0 && capability.toJSON().properties) { + notes.push("no property list known, discovery lists it with no supported properties"); + } + for (const note of notes) + lines.push(new DeclarationError(capability, note).message); + }); + attempt(() => checkCapabilityCount(this.endpointId, this.announced(this.capabilities).length)); + return lines; + } + /** The endpoint object for discovery. */ + getJSON() { + return { ...this.fields(), capabilities: this.announced(this.capabilities).map((capability) => capability.toJSON()) }; + } + fields() { + return { + endpointId: this.endpointId, + friendlyName: this.name, + description: this.description, + manufacturerName: this.manufacturerName, + displayCategories: this.getDisplayCategory(), + additionalAttributes: { + manufacturer: this.manufacturer, + model: this.model, + serialNumber: this.serialNumber, + firmwareVersion: this.firmwareVersion, + softwareVersion: this.softwareVersion, + customIdentifier: this.customIdentifier, + }, + ...(this.cookie ? { cookie: this.cookie } : {}), + }; + } + // The endpoint as the rules of a capability see it + view(declaredBefore) { + return { + endpointId: this.endpointId, + friendlyName: this.name, + description: this.description, + displayCategories: this.getDisplayCategory(), + capabilities: declaredBefore, + }; + } + // What discovery lists: the declared capabilities, then the ones the library adds + announced(declared) { + const has = (namespace) => declared.some((capability) => capability.namespace === namespace); + const added = []; + // alexa-scenecontroller.html, "Discovery": a scene is not a physical device and has no Alexa.EndpointHealth + if (this.endpointHealth && !has(EndpointHealth.namespace) && !has("Alexa.SceneController")) { + added.push(new Capability(EndpointHealth, { options: {} })); + } + if (this.alexaInterface && !has(Alexa.namespace)) + added.push(new Capability(Alexa, { options: {} })); + return [...declared, ...added]; + } +} +// The options of the interface itself, checked by the schema of its descriptor +function interfaceOptions(descriptor, given) { + if (descriptor.options) + return descriptor.options.parse(given); + const [unknown] = Object.keys(given); + if (unknown !== undefined) + throw new SchemaError(unknown, "not an option of the interface"); + return {}; +} +export default Device; diff --git a/dist/esm/device/validate.d.ts b/dist/esm/device/validate.d.ts new file mode 100644 index 0000000..7de49bd --- /dev/null +++ b/dist/esm/device/validate.d.ts @@ -0,0 +1,20 @@ +import type { AnyDescriptor, Declared, EndpointView } from "../registry/types.js"; +/** An endpoint as discovery sends it, without its capabilities. */ +export interface EndpointFields { + endpointId: string; + friendlyName: string; + description: string; + manufacturerName: string; + displayCategories: readonly string[]; + additionalAttributes: Readonly>; + cookie?: Readonly>; +} +/** Throws the first rule of the Endpoint object that the fields break. */ +export declare function checkEndpoint(endpoint: EndpointFields): void; +/** alexa-discovery.html, "Interface limits". count includes the capabilities the library adds. */ +export declare function checkCapabilityCount(endpointId: string, count: number): void; +/** + * Throws the first rule that a capability breaks on its endpoint. endpoint.capabilities are the ones declared + * before it. A stub is checked for being declared twice and for nothing else: the library does not know its rules. + */ +export declare function checkCapability(capability: Declared, descriptor: AnyDescriptor, endpoint: EndpointView): void; diff --git a/dist/esm/device/validate.js b/dist/esm/device/validate.js new file mode 100644 index 0000000..b5d9e01 --- /dev/null +++ b/dist/esm/device/validate.js @@ -0,0 +1,123 @@ +// What Alexa rejects at discovery, checked where a device is declared. Alexa gives no reason when it drops an +// endpoint: the user hears "no new devices found". Each rule names the page it is from. +import { DISPLAY_CATEGORIES, LIMITS } from "../registry/catalog.js"; +import { labels, shownAs } from "../registry/resources.js"; +import { SchemaError } from "../registry/schema.js"; +import { DeclarationError } from "../registry/types.js"; +// alexa-discovery-objects.html, "Endpoint object details": "letters, numbers, spaces, and the following special +// characters: _ - = # ; : ? @ &" +const ENDPOINT_ID = /^[A-Za-z0-9 _\-=#;:?@&]+$/; +// Same table: "alphanumeric characters and spaces". Letters of any script: Alexa speaks Hindi and Japanese too. +const FRIENDLY_NAME = /^[\p{L}\p{M}\p{N} ]+$/u; +const isText = (value) => typeof value === "string" && value.length > 0; +/** Throws the first rule of the Endpoint object that the fields break. */ +export function checkEndpoint(endpoint) { + const where = { endpointId: isText(endpoint.endpointId) ? endpoint.endpointId : undefined }; + function refuse(problem) { + throw new DeclarationError(where, problem); + } + const { endpointId, friendlyName, description, manufacturerName, displayCategories, additionalAttributes, cookie } = endpoint; + if (!isText(endpointId)) + refuse("an endpoint needs an endpointId"); + if (endpointId.length > LIMITS.endpointIdLength || !ENDPOINT_ID.test(endpointId)) { + refuse(`the endpointId takes up to ${LIMITS.endpointIdLength} letters, digits, spaces and _ - = # ; : ? @ &`); + } + if (!isText(friendlyName)) + refuse("an endpoint needs a name"); + if (friendlyName.length > LIMITS.friendlyNameLength || !FRIENDLY_NAME.test(friendlyName)) { + refuse(`the name ${JSON.stringify(friendlyName)} takes up to ${LIMITS.friendlyNameLength} letters, digits and spaces, no punctuation`); + } + if (!isText(manufacturerName) || manufacturerName.length > LIMITS.manufacturerNameLength) { + refuse(`the manufacturerName takes 1 to ${LIMITS.manufacturerNameLength} characters`); + } + if (!isText(description) || description.length > LIMITS.descriptionLength) { + refuse(`the description takes 1 to ${LIMITS.descriptionLength} characters`); + } + if (!Array.isArray(displayCategories) || displayCategories.length === 0) + refuse("an endpoint needs a display category"); + const known = DISPLAY_CATEGORIES; + for (const category of displayCategories) { + if (!known.includes(category)) + refuse(`${JSON.stringify(category)} is not a display category`); + } + for (const [name, value] of Object.entries(additionalAttributes)) { + if (typeof value !== "string" || value.length > LIMITS.additionalAttributeLength) { + refuse(`${name} takes up to ${LIMITS.additionalAttributeLength} characters`); + } + } + if (cookie !== undefined) { + const bytes = Buffer.byteLength(JSON.stringify(cookie) ?? ""); + if (bytes > LIMITS.cookieBytes) + refuse(`the cookie is ${bytes} bytes, ${LIMITS.cookieBytes} is the most`); + } +} +/** alexa-discovery.html, "Interface limits". count includes the capabilities the library adds. */ +export function checkCapabilityCount(endpointId, count) { + if (count > LIMITS.capabilitiesPerEndpoint) { + throw new DeclarationError({ endpointId }, `${count} capabilities, an endpoint takes ${LIMITS.capabilitiesPerEndpoint}`); + } +} +// Two names are the same name when the app would show the same: the asset, or the text in one locale. +function nameKey(name) { + return name["@type"] === "asset" ? `asset ${name.value.assetId}` : `text ${name.value.locale} ${name.value.text.toLowerCase()}`; +} +function phrases(capability) { + const { semantics } = capability.options; + return (semantics?.actionMappings ?? []).flatMap((mapping) => mapping.actions); +} +/** + * Throws the first rule that a capability breaks on its endpoint. endpoint.capabilities are the ones declared + * before it. A stub is checked for being declared twice and for nothing else: the library does not know its rules. + */ +export function checkCapability(capability, descriptor, endpoint) { + function refuse(problem) { + throw new DeclarationError(capability, problem); + } + const { namespace, instance, friendlyNames } = capability; + // generic-controllers.html, "Multiple instances": one capability of an interface, or one per instance name + if (endpoint.capabilities.some((other) => other.namespace === namespace && other.instance === instance)) { + refuse(instance ? "the instance is declared twice" : "the interface is declared twice"); + } + if (descriptor.tier === 3) + return; + if (descriptor.instanced) { + // alexa-rangecontroller.html, "Capabilities array": instance and capabilityResources are required + if (!isText(instance)) + refuse("needs an instance name, like Blind.Lift"); + try { + labels.parse(friendlyNames, "friendlyNames"); + } + catch (err) { + refuse(err instanceof SchemaError && err.path === "friendlyNames" ? "needs a friendly name, from text() or asset()" : err.message); + } + // resources-and-assets.html, "CapabilityResources": "The first friendly name in the array must be unique for + // the endpoint." + const first = nameKey(friendlyNames[0]); + const taken = endpoint.capabilities.find((other) => other.friendlyNames.length > 0 && nameKey(other.friendlyNames[0]) === first); + if (taken) { + refuse(`the first friendly name, ${shownAs(friendlyNames[0])}, is the first of ${taken.namespace} ${JSON.stringify(taken.instance)} too`); + } + } + else { + if (instance) + refuse("takes no instance name: an endpoint has the interface once"); + if (friendlyNames.length > 0) + refuse("takes no friendly names"); + } + // generic-controllers.html, "Semantics for user utterances": "Each semantic phrase must be unique across all + // controller instances for each endpoint" + const used = new Map(); + for (const other of endpoint.capabilities) + for (const phrase of phrases(other)) + used.set(phrase, other); + for (const phrase of phrases(capability)) { + const owner = used.get(phrase); + if (owner) { + const place = owner === capability ? "twice" : `here and on ${owner.namespace} ${JSON.stringify(owner.instance)}`; + refuse(`${phrase} is mapped ${place}, an endpoint maps a phrase once`); + } + used.set(phrase, capability); + } + if (descriptor.validate) + descriptor.validate(capability, endpoint); +} diff --git a/dist/esm/index.d.ts b/dist/esm/index.d.ts index 5e81aa1..303df58 100644 --- a/dist/esm/index.d.ts +++ b/dist/esm/index.d.ts @@ -1,13 +1,19 @@ export { default as Alex2MQTT, DEFAULT_HOST } from "./Alex2Node.js"; export type { Alex2MQTTOptions } from "./Alex2Node.js"; -export { default as Device } from "./Device.js"; -export { AlexaInterface, AlexaInterfaceType } from "./AlexaInterface.js"; -export type { SupportedMode } from "./AlexaInterface.js"; -export { ActionMapping, AlexaActions } from "./ActionMapping.js"; -export { DisplayCategory } from "./DisplayCategory.js"; -export { AlexaStatusMessage, PowerController, EndpointHealth, TemperatureSensorScale, ThermostatMode } from "./AlexaStatusMessage.js"; +export { default as Device } from "./device/Device.js"; +export type { EndpointDefinition, EndpointJson } from "./device/Device.js"; +export { Capability } from "./device/Capability.js"; +export type { CapabilityJson, CommonOptions, Declaration } from "./device/Capability.js"; +export { registry, DeclarationError, SchemaError } from "./registry/index.js"; +export { Alexa, BrightnessController, TemperatureSensor } from "./registry/index.js"; +export { PowerController, EndpointHealth } from "./compat/enums.js"; +export { asset, text } from "./registry/resources.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, Infer, Schema, Temperature, TimeInterval, } from "./registry/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 { registry, DeclarationError, SchemaError } 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, AnyDescriptor, CapabilityExtras, Declared, DirectiveDescriptor, EndpointView, EventDescriptor, InterfaceDescriptor, Label, PropertyDescriptor, Infer, Schema, Temperature, TimeInterval, } from "./registry/index.js"; diff --git a/dist/esm/index.js b/dist/esm/index.js index 8fd8dd3..9e8ab0c 100644 --- a/dist/esm/index.js +++ b/dist/esm/index.js @@ -1,11 +1,17 @@ // Relative specifiers carry ".js": Node's ES module loader resolves no extension, and TypeScript maps it back to the .ts. export { default as Alex2MQTT, DEFAULT_HOST } from "./Alex2Node.js"; -export { default as Device } from "./Device.js"; -export { AlexaInterface, AlexaInterfaceType } from "./AlexaInterface.js"; -export { ActionMapping, AlexaActions } from "./ActionMapping.js"; -export { DisplayCategory } from "./DisplayCategory.js"; -export { AlexaStatusMessage, PowerController, EndpointHealth, TemperatureSensorScale, ThermostatMode } from "./AlexaStatusMessage.js"; -export { AlexaErrorType, AlexaErrorResponse } from "./AlexaErrorResponse.js"; -// The interface registry and the vocabularies of the Smart Home API +export { default as Device } from "./device/Device.js"; +export { Capability } from "./device/Capability.js"; +// The interfaces, for device.add(), and the registry that holds them by name export { registry, DeclarationError, SchemaError } from "./registry/index.js"; +export { Alexa, BrightnessController, TemperatureSensor } from "./registry/index.js"; +export { PowerController, EndpointHealth } from "./compat/enums.js"; +export { asset, text } from "./registry/resources.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"; +// 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"; diff --git a/dist/esm/registry/index.d.ts b/dist/esm/registry/index.d.ts index 895e580..316e8d2 100644 --- a/dist/esm/registry/index.d.ts +++ b/dist/esm/registry/index.d.ts @@ -14,7 +14,7 @@ export declare const registry: { }; export { Alexa, BrightnessController, EndpointHealth, PowerController, TemperatureSensor }; export { DeclarationError, defineInterface } from "./types.js"; -export type { AnyDescriptor, CapabilityExtras, Declared, DirectiveDescriptor, EndpointView, EventDescriptor, InterfaceDescriptor, Label, PropertyDescriptor, } from "./types.js"; +export type { ActionsToDirective, AnyDescriptor, CapabilityExtras, Declared, DirectiveDescriptor, EndpointView, EventDescriptor, InterfaceDescriptor, Label, PropertyDescriptor, Semantics, StatesToRange, StatesToValue, } from "./types.js"; export { s, SchemaError } from "./schema.js"; export type { Infer, Schema, Temperature, TimeInterval } from "./schema.js"; export * from "./catalog.js"; diff --git a/dist/esm/registry/interfaces/stubs.js b/dist/esm/registry/interfaces/stubs.js index 98d3ca1..f5f05df 100644 --- a/dist/esm/registry/interfaces/stubs.js +++ b/dist/esm/registry/interfaces/stubs.js @@ -77,6 +77,20 @@ const TABLE = [ ["Alexa.VideoRecorder", "1", [], "alexa-videorecorder.html"], ["Alexa.WakeOnLANController", "1", [], "alexa-wakeonlancontroller.html"], ]; +// What 1.5.2 added to the capability object of three of them +const EXTRAS = { + // No properties object; supportsDeactivation and proactivelyReported on the capability itself + "Alexa.SceneController": ({ proactivelyReported }) => ({ + properties: false, + topLevel: { supportsDeactivation: true, proactivelyReported }, + }), + "Alexa.ThermostatController": () => ({ + configuration: { supportedModes: ["HEAT", "COOL", "AUTO", "OFF"], supportsScheduling: false }, + }), + "Alexa.ModeController": ({ options }) => (options.supportedModes?.length > 0 + ? { configuration: { ordered: true, supportedModes: options.supportedModes } } + : {}), +}; function kindOf(namespace) { if (namespace.endsWith("Sensor")) return "sensor"; @@ -94,6 +108,7 @@ function stub([namespace, version, properties, page]) { instanced: false, properties: Object.fromEntries(properties.map((name) => [name, { name, value: s.unknown() }])), directives: {}, + discovery: EXTRAS[namespace], }; } export const STUBS = TABLE.map(stub); diff --git a/dist/esm/registry/resources.d.ts b/dist/esm/registry/resources.d.ts new file mode 100644 index 0000000..255165e --- /dev/null +++ b/dist/esm/registry/resources.d.ts @@ -0,0 +1,13 @@ +import type { AssetId } from "./catalog.js"; +import type { Schema } from "./schema.js"; +import type { Label } from "./types.js"; +/** A friendly name as text, in one locale. */ +export declare function text(name: string, locale?: string): Label; +/** A friendly name from the global Alexa catalog: one id, several names, every language Alexa speaks. */ +export declare function asset(assetId: AssetId): Label; +/** One friendly name. An asset has to be in the catalog, a text must not be a word Alexa keeps for itself. */ +export declare const label: Schema

implements Declared { + readonly descriptor: InterfaceDescriptor; + /** The endpoint the capability is declared on; the device sets it. */ + endpointId: string; + /** "" for an interface without instances. */ + instance: string; + friendlyNames: Label[]; + retrievable: boolean; + proactivelyReported: boolean; + nonControllable?: boolean; + /** The options of the interface: a range, the supported modes, the semantics. */ + options: O; + /** What the bridge logs about the declaration, once, when it answers a discovery. */ + readonly notes: string[]; + constructor(descriptor: InterfaceDescriptor, declared: CommonOptions & { + options: O; + }); + get namespace(): string; + /** Mappings of "open", "close", "raise", "lower" and of states, when the declaration has any. */ + get semantics(): Semantics | undefined; + /** The capability object for discovery, its fields in the order of the example on alexa-discovery-objects.html. */ + toJSON(): CapabilityJson; +} +export type AnyCapability = Capability; +export {}; diff --git a/dist/types/device/Device.d.ts b/dist/types/device/Device.d.ts new file mode 100644 index 0000000..e4f1c36 --- /dev/null +++ b/dist/types/device/Device.d.ts @@ -0,0 +1,135 @@ +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 { AlexaInterface } from "../compat/AlexaInterface.js"; +import { DisplayCategory } from "../compat/enums.js"; +import type { AlexaInterfaceType } from "../compat/enums.js"; +import type { DisplayCategoryName } from "../registry/catalog.js"; +import type { Directives, InterfaceDescriptor, Properties } from "../registry/types.js"; +import { Capability } from "./Capability.js"; +import type { AnyCapability, CapabilityJson, Declaration } from "./Capability.js"; +import type { EndpointFields } from "./validate.js"; +/** An endpoint as bridge.addDevice() takes it. */ +export interface EndpointDefinition { + /** Up to 256 letters, digits, spaces and _ - = # ; : ? @ &. The same id at every discovery. */ + endpointId: string; + /** What the user calls the device: letters, digits and spaces. */ + name: string; + /** The first one is the category the Alexa app shows the device under. */ + categories: Array; + /** Shown in the Alexa app, up to 128 characters. Default: "Alexa to Node.js bridge". */ + description?: string; + /** Up to 128 characters. Default: "Alex2Node". */ + manufacturerName?: string; + manufacturer?: string; + model?: string; + serialNumber?: string; + firmwareVersion?: string; + softwareVersion?: string; + customIdentifier?: string; + /** Returned with every directive to the endpoint. Up to 5000 bytes; not a place for state. */ + cookie?: Record; + /** false: the device gets no Alexa.EndpointHealth unless it declares one. */ + endpointHealth?: boolean; +} +/** An endpoint object of a discovery answer (alexa-discovery-objects.html, "Endpoint object"). */ +export interface EndpointJson extends EndpointFields { + capabilities: CapabilityJson[]; +} +type DeclarationArguments = {} extends Declaration ? [options?: Declaration] : [options: Declaration]; +declare class Device extends EventEmitter { + private mqttClient; + private rootTopic; + name: string; + endpointId: string; + displayCategory: Array | null; + description: string; + manufacturerName: string; + manufacturer: string; + model: string; + softwareVersion: string; + serialNumber: string; + firmwareVersion: string; + customIdentifier: string; + cookie?: Record; + /** + * Discovery lists the Alexa interface on the endpoint (alexa-interface.html: "You must explicitly identify your + * support for the Alexa interface"). The bridge sets it from its alexaInterface option. + */ + alexaInterface: boolean; + /** + * Discovery lists Alexa.EndpointHealth when the device did not declare it and is not a scene. On for a device of + * bridge.addDevice(), off for one of registerDevice(), which is announced as 1.x announced it. + */ + endpointHealth: boolean; + private capabilities; + /** Where a failed publish from this device or a message it built is reported; Alex2MQTT sets it (1.5.2). */ + onPublishError?: (err: Error) => void; + constructor(mqttClient: MqttClient, rootTopic: string, name: string, endpointId: string, displayCategory: Array | null, description?: string, manufacturerName?: string, manufacturer?: string, model?: string); + /** Internal (1.5.2): Alex2MQTT.connect() re-binds every registered device to its new broker client after a disconnect(). */ + setMqttClient(client: MqttClient): void; + getName(): string; + setName(name: string): void; + getEndpointId(): string; + setDisplayCategory(category: DisplayCategory | DisplayCategory[]): void; + getDisplayCategory(): Array; + setDescription(description: string): void; + getDescription(): string; + getErrorMessage(correlationToken: string): AlexaErrorResponse; + getStatusMessage(correlationToken: string, isResponse?: boolean, isDeferred?: boolean): AlexaStatusMessage; + /** + * 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. + */ + getChangeReport(cause?: ChangeCause): AlexaStatusMessage; + /** + * Alexa.SceneController: answer an Activate / Deactivate directive with ActivationStarted / DeactivationStarted + * (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, sendAsync?: boolean): Promise; + /** The capabilities the device declared, in the order it declared them. */ + getCapabilities(): AnyCapability[]; + setManufacturerName(name: string): void; + getManufacturerName(): string; + setManufacturer(manufacturer: string): void; + getManufacturer(): string; + setModel(model: string): void; + getModel(): string; + getSoftwareVersion(): string; + /** + * Declare an interface of the device: + * + * lamp.add(PowerController, { proactivelyReported: true }); + * blinds.add(RangeController, { instance: "Blind.Lift", friendlyNames: [asset("Alexa.Setting.Opening")], + * range: { min: 0, max: 100, precision: 1 } }); + * + * Throws a DeclarationError that names the endpoint, the interface and the instance when Alexa would reject the + * capability; the device is then as it was before the call. + */ + add

(descriptor: InterfaceDescriptor, ...[options]: DeclarationArguments): Capability; + /** + * Declare an interface the 1.x way, by its name: options.retrievable / proactivelyReported / instance, then the + * add and set methods of the capability that is returned. Throws for a name that is not an interface. Anything + * else Alexa would reject is not refused here: check() lists it. + */ + addCapability(type: AlexaInterfaceType | string, options?: { + retrievable?: boolean; + proactivelyReported?: boolean; + instance?: string; + }): AlexaInterface; + /** + * What Alexa would reject in the device as it is declared now, one line for each: a name with punctuation, an + * interface declared twice. Also what a capability noted about its declaration. Empty when there is nothing. + * The bridge logs the lines when it answers a discovery. + */ + check(): string[]; + /** The endpoint object for discovery. */ + getJSON(): EndpointJson; + private fields; + private view; + private announced; +} +export default Device; diff --git a/dist/types/device/validate.d.ts b/dist/types/device/validate.d.ts new file mode 100644 index 0000000..7de49bd --- /dev/null +++ b/dist/types/device/validate.d.ts @@ -0,0 +1,20 @@ +import type { AnyDescriptor, Declared, EndpointView } from "../registry/types.js"; +/** An endpoint as discovery sends it, without its capabilities. */ +export interface EndpointFields { + endpointId: string; + friendlyName: string; + description: string; + manufacturerName: string; + displayCategories: readonly string[]; + additionalAttributes: Readonly>; + cookie?: Readonly>; +} +/** Throws the first rule of the Endpoint object that the fields break. */ +export declare function checkEndpoint(endpoint: EndpointFields): void; +/** alexa-discovery.html, "Interface limits". count includes the capabilities the library adds. */ +export declare function checkCapabilityCount(endpointId: string, count: number): void; +/** + * Throws the first rule that a capability breaks on its endpoint. endpoint.capabilities are the ones declared + * before it. A stub is checked for being declared twice and for nothing else: the library does not know its rules. + */ +export declare function checkCapability(capability: Declared, descriptor: AnyDescriptor, endpoint: EndpointView): void; diff --git a/dist/types/index.d.ts b/dist/types/index.d.ts index 5e81aa1..303df58 100644 --- a/dist/types/index.d.ts +++ b/dist/types/index.d.ts @@ -1,13 +1,19 @@ export { default as Alex2MQTT, DEFAULT_HOST } from "./Alex2Node.js"; export type { Alex2MQTTOptions } from "./Alex2Node.js"; -export { default as Device } from "./Device.js"; -export { AlexaInterface, AlexaInterfaceType } from "./AlexaInterface.js"; -export type { SupportedMode } from "./AlexaInterface.js"; -export { ActionMapping, AlexaActions } from "./ActionMapping.js"; -export { DisplayCategory } from "./DisplayCategory.js"; -export { AlexaStatusMessage, PowerController, EndpointHealth, TemperatureSensorScale, ThermostatMode } from "./AlexaStatusMessage.js"; +export { default as Device } from "./device/Device.js"; +export type { EndpointDefinition, EndpointJson } from "./device/Device.js"; +export { Capability } from "./device/Capability.js"; +export type { CapabilityJson, CommonOptions, Declaration } from "./device/Capability.js"; +export { registry, DeclarationError, SchemaError } from "./registry/index.js"; +export { Alexa, BrightnessController, TemperatureSensor } from "./registry/index.js"; +export { PowerController, EndpointHealth } from "./compat/enums.js"; +export { asset, text } from "./registry/resources.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, Infer, Schema, Temperature, TimeInterval, } from "./registry/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 { registry, DeclarationError, SchemaError } 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, AnyDescriptor, CapabilityExtras, Declared, DirectiveDescriptor, EndpointView, EventDescriptor, InterfaceDescriptor, Label, PropertyDescriptor, Infer, Schema, Temperature, TimeInterval, } from "./registry/index.js"; diff --git a/dist/types/registry/index.d.ts b/dist/types/registry/index.d.ts index 895e580..316e8d2 100644 --- a/dist/types/registry/index.d.ts +++ b/dist/types/registry/index.d.ts @@ -14,7 +14,7 @@ export declare const registry: { }; export { Alexa, BrightnessController, EndpointHealth, PowerController, TemperatureSensor }; export { DeclarationError, defineInterface } from "./types.js"; -export type { AnyDescriptor, CapabilityExtras, Declared, DirectiveDescriptor, EndpointView, EventDescriptor, InterfaceDescriptor, Label, PropertyDescriptor, } from "./types.js"; +export type { ActionsToDirective, AnyDescriptor, CapabilityExtras, Declared, DirectiveDescriptor, EndpointView, EventDescriptor, InterfaceDescriptor, Label, PropertyDescriptor, Semantics, StatesToRange, StatesToValue, } from "./types.js"; export { s, SchemaError } from "./schema.js"; export type { Infer, Schema, Temperature, TimeInterval } from "./schema.js"; export * from "./catalog.js"; diff --git a/dist/types/registry/resources.d.ts b/dist/types/registry/resources.d.ts new file mode 100644 index 0000000..255165e --- /dev/null +++ b/dist/types/registry/resources.d.ts @@ -0,0 +1,13 @@ +import type { AssetId } from "./catalog.js"; +import type { Schema } from "./schema.js"; +import type { Label } from "./types.js"; +/** A friendly name as text, in one locale. */ +export declare function text(name: string, locale?: string): Label; +/** A friendly name from the global Alexa catalog: one id, several names, every language Alexa speaks. */ +export declare function asset(assetId: AssetId): Label; +/** One friendly name. An asset has to be in the catalog, a text must not be a word Alexa keeps for itself. */ +export declare const label: Schema

implements Declared { + /** The endpoint the capability is declared on; the device sets it. */ + endpointId = ""; + /** "" for an interface without instances. */ + instance: string; + friendlyNames: Label[]; + retrievable: boolean; + proactivelyReported: boolean; + nonControllable?: boolean; + /** The options of the interface: a range, the supported modes, the semantics. */ + options: O; + /** What the bridge logs about the declaration, once, when it answers a discovery. */ + readonly notes: string[] = []; + + constructor(readonly descriptor: InterfaceDescriptor, declared: CommonOptions & { options: O }) { + this.instance = declared.instance ?? ""; + this.friendlyNames = declared.friendlyNames ?? []; + this.retrievable = declared.retrievable ?? true; + this.proactivelyReported = declared.proactivelyReported ?? false; + this.nonControllable = declared.nonControllable; + this.options = declared.options; + } + + get namespace(): string { + return this.descriptor.namespace; + } + + /** Mappings of "open", "close", "raise", "lower" and of states, when the declaration has any. */ + get semantics(): Semantics | undefined { + const { semantics } = this.options as { semantics?: Semantics }; + const mapped = (semantics?.actionMappings?.length ?? 0) + (semantics?.stateMappings?.length ?? 0); + return mapped > 0 ? semantics : undefined; + } + + /** The capability object for discovery, its fields in the order of the example on alexa-discovery-objects.html. */ + toJSON(): CapabilityJson { + const { descriptor, semantics } = this; + const extras = descriptor.discovery ? descriptor.discovery(this) : {}; + const json: CapabilityJson = { + type: "AlexaInterface", + interface: descriptor.namespace, + ...(this.instance ? { instance: this.instance } : {}), + version: descriptor.version, + }; + if (extras.properties !== false) { + json.properties = { + supported: Object.keys(descriptor.properties).map((name) => ({ name })), + proactivelyReported: this.proactivelyReported, + retrievable: this.retrievable, + }; + if (this.nonControllable !== undefined) json.properties.nonControllable = this.nonControllable; + } + if (this.friendlyNames.length > 0) json.capabilityResources = { friendlyNames: this.friendlyNames }; + if (extras.configuration) json.configuration = extras.configuration; + if (extras.configurations) json.configurations = extras.configurations; + if (semantics) json.semantics = semantics; + return { ...json, ...extras.topLevel }; + } +} + +export type AnyCapability = Capability; diff --git a/src/device/Device.ts b/src/device/Device.ts new file mode 100644 index 0000000..84185ca --- /dev/null +++ b/src/device/Device.ts @@ -0,0 +1,348 @@ +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 { AlexaInterface } from "../compat/AlexaInterface.js"; +import { DisplayCategory } from "../compat/enums.js"; +import type { AlexaInterfaceType } from "../compat/enums.js"; +import type { DisplayCategoryName } from "../registry/catalog.js"; +import { Alexa } from "../registry/interfaces/Alexa.js"; +import { EndpointHealth } from "../registry/interfaces/EndpointHealth.js"; +import { SchemaError } from "../registry/schema.js"; +import { DeclarationError } from "../registry/types.js"; +import type { Directives, EndpointView, InterfaceDescriptor, Label, Properties } from "../registry/types.js"; +import { Capability, commonOptions } from "./Capability.js"; +import type { AnyCapability, CapabilityJson, Declaration } from "./Capability.js"; +import { checkCapability, checkCapabilityCount, checkEndpoint } from "./validate.js"; +import type { EndpointFields } from "./validate.js"; + +/** An endpoint as bridge.addDevice() takes it. */ +export interface EndpointDefinition { + /** Up to 256 letters, digits, spaces and _ - = # ; : ? @ &. The same id at every discovery. */ + endpointId: string; + /** What the user calls the device: letters, digits and spaces. */ + name: string; + /** The first one is the category the Alexa app shows the device under. */ + categories: Array; + /** Shown in the Alexa app, up to 128 characters. Default: "Alexa to Node.js bridge". */ + description?: string; + /** Up to 128 characters. Default: "Alex2Node". */ + manufacturerName?: string; + // additionalAttributes: Alexa tells two devices apart by them when two add-ons report the same one + manufacturer?: string; + model?: string; + serialNumber?: string; + firmwareVersion?: string; + softwareVersion?: string; + customIdentifier?: string; + /** Returned with every directive to the endpoint. Up to 5000 bytes; not a place for state. */ + cookie?: Record; + /** false: the device gets no Alexa.EndpointHealth unless it declares one. */ + endpointHealth?: boolean; +} + +/** An endpoint object of a discovery answer (alexa-discovery-objects.html, "Endpoint object"). */ +export interface EndpointJson extends EndpointFields { + capabilities: CapabilityJson[]; +} + +// device.add(PowerController) needs no options, device.add(RangeController) cannot do without +type DeclarationArguments = {} extends Declaration + ? [options?: Declaration] + : [options: Declaration]; + +class Device extends EventEmitter { + public softwareVersion = "1.0.0"; + public serialNumber = "Alex2Node"; + public firmwareVersion = "1.0.0"; + public customIdentifier = "Alex2Node"; + public cookie?: Record; + /** + * Discovery lists the Alexa interface on the endpoint (alexa-interface.html: "You must explicitly identify your + * support for the Alexa interface"). The bridge sets it from its alexaInterface option. + */ + public alexaInterface = true; + /** + * Discovery lists Alexa.EndpointHealth when the device did not declare it and is not a scene. On for a device of + * bridge.addDevice(), off for one of registerDevice(), which is announced as 1.x announced it. + */ + public endpointHealth = false; + private capabilities: AnyCapability[] = []; + /** Where a failed publish from this device or a message it built is reported; Alex2MQTT sets it (1.5.2). */ + public onPublishError?: (err: Error) => void; + + constructor( + private mqttClient: MqttClient, + private rootTopic: string, + public name: string, + public endpointId: string, + public displayCategory: Array | null, + public description: string = "Alexa to Node.js bridge", + public manufacturerName: string = "Alex2Node", + public manufacturer: string = "Alex2Node", + public model: string = "Alex2Node_v1.0.0" + ) { + super(); + } + + /** Internal (1.5.2): Alex2MQTT.connect() re-binds every registered device to its new broker client after a disconnect(). */ + setMqttClient(client: MqttClient): void { + this.mqttClient = client; + } + + getName(): string { + return this.name; + } + + setName(name: string): void { + this.name = name; + } + + getEndpointId(): string { + return this.endpointId; + } + + setDisplayCategory(category: DisplayCategory | DisplayCategory[]): void { + this.displayCategory = Array.isArray(category) ? category : [category]; + } + + getDisplayCategory(): Array { + return this.displayCategory || [DisplayCategory.LIGHT]; + } + + setDescription(description: string): void { + this.description = description; + } + + getDescription(): string { + return this.description; + } + getErrorMessage(correlationToken: string): AlexaErrorResponse { + const msg = new AlexaErrorResponse( + correlationToken, + this.rootTopic, + this.endpointId, + this.mqttClient + ); + msg.onPublishError = this.onPublishError; + return msg; + } + getStatusMessage( + correlationToken: string, + isResponse = false, + isDeferred = false + ): AlexaStatusMessage { + const msg = new AlexaStatusMessage( + correlationToken, + this.rootTopic, + this.endpointId, + this.mqttClient, + isResponse, + isDeferred + ); + msg.onPublishError = this.onPublishError; + return msg; + } + /** + * 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. + */ + getChangeReport(cause: ChangeCause = "PHYSICAL_INTERACTION"): AlexaStatusMessage { + const msg = new AlexaStatusMessage("", this.rootTopic, this.endpointId, this.mqttClient, false, false, cause); + msg.onPublishError = this.onPublishError; + return msg; + } + /** + * Alexa.SceneController: answer an Activate / Deactivate directive with ActivationStarted / DeactivationStarted + * (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 topic = `${this.rootTopic}/${this.endpointId}/${sendAsync ? "deferredResponse" : "alexaResponce"}`; + return new Promise((resolve) => { + this.mqttClient.publish(topic, JSON.stringify(payload), (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 + }); + }); + } + /** The capabilities the device declared, in the order it declared them. */ + getCapabilities(): AnyCapability[] { + return this.capabilities.slice(); + } + setManufacturerName(name: string): void { + this.manufacturerName = name; + } + + getManufacturerName(): string { + return this.manufacturerName; + } + + setManufacturer(manufacturer: string): void { + this.manufacturer = manufacturer; + } + + getManufacturer(): string { + return this.manufacturer; + } + + setModel(model: string): void { + this.model = model; + } + + getModel(): string { + return this.model; + } + + getSoftwareVersion(): string { + return this.softwareVersion; + } + + /** + * Declare an interface of the device: + * + * lamp.add(PowerController, { proactivelyReported: true }); + * blinds.add(RangeController, { instance: "Blind.Lift", friendlyNames: [asset("Alexa.Setting.Opening")], + * range: { min: 0, max: 100, precision: 1 } }); + * + * Throws a DeclarationError that names the endpoint, the interface and the instance when Alexa would reject the + * capability; the device is then as it was before the call. + */ + add

( + descriptor: InterfaceDescriptor, + ...[options]: DeclarationArguments + ): Capability { + const where = { endpointId: this.endpointId, namespace: descriptor.namespace, instance: "" }; + if (options !== undefined && (typeof options !== "object" || options === null)) { + throw new DeclarationError(where, "the options of a declaration are an object"); + } + try { + const { instance, friendlyNames, retrievable, proactivelyReported, nonControllable, ...given } = commonOptions.parse(options ?? {}); + where.instance = instance ?? ""; + const common = { instance, friendlyNames: friendlyNames as Label[] | undefined, retrievable, proactivelyReported, nonControllable }; + const capability = new Capability(descriptor, { ...common, options: interfaceOptions(descriptor, given) }); + capability.endpointId = this.endpointId; + checkCapability(capability, descriptor, this.view(this.capabilities)); + checkCapabilityCount(this.endpointId, this.announced([...this.capabilities, capability]).length); + this.capabilities.push(capability); + return capability; + } catch (err) { + throw err instanceof SchemaError ? new DeclarationError(where, err.message) : err; + } + } + + /** + * Declare an interface the 1.x way, by its name: options.retrievable / proactivelyReported / instance, then the + * add and set methods of the capability that is returned. Throws for a name that is not an interface. Anything + * else Alexa would reject is not refused here: check() lists it. + */ + addCapability(type: AlexaInterfaceType | string, options: { retrievable?: boolean; proactivelyReported?: boolean; instance?: string } = {}): AlexaInterface { + let capability: AlexaInterface; + try { + capability = new AlexaInterface(type, options.retrievable ?? true, options.proactivelyReported ?? false, options.instance ?? ""); + } catch (err) { + throw err instanceof DeclarationError ? new DeclarationError({ endpointId: this.endpointId }, err.problem) : err; + } + capability.endpointId = this.endpointId; + this.capabilities.push(capability); + return capability; + } + + /** + * What Alexa would reject in the device as it is declared now, one line for each: a name with punctuation, an + * interface declared twice. Also what a capability noted about its declaration. Empty when there is nothing. + * The bridge logs the lines when it answers a discovery. + */ + check(): string[] { + const lines: string[] = []; + const attempt = (rule: () => void): void => { + try { + rule(); + } catch (err) { + if (!(err instanceof DeclarationError)) throw err; + lines.push(err.message); + } + }; + attempt(() => checkEndpoint(this.fields())); + this.capabilities.forEach((capability, i) => { + capability.endpointId = this.endpointId; + attempt(() => checkCapability(capability, capability.descriptor, this.view(this.capabilities.slice(0, i)))); + const notes = [...capability.notes]; + const { tier, properties } = capability.descriptor; + if (tier === 3 && Object.keys(properties).length === 0 && capability.toJSON().properties) { + notes.push("no property list known, discovery lists it with no supported properties"); + } + for (const note of notes) lines.push(new DeclarationError(capability, note).message); + }); + attempt(() => checkCapabilityCount(this.endpointId, this.announced(this.capabilities).length)); + return lines; + } + + /** The endpoint object for discovery. */ + getJSON(): EndpointJson { + return { ...this.fields(), capabilities: this.announced(this.capabilities).map((capability) => capability.toJSON()) }; + } + + private fields(): EndpointFields { + return { + endpointId: this.endpointId, + friendlyName: this.name, + description: this.description, + manufacturerName: this.manufacturerName, + displayCategories: this.getDisplayCategory(), + additionalAttributes: { + manufacturer: this.manufacturer, + model: this.model, + serialNumber: this.serialNumber, + firmwareVersion: this.firmwareVersion, + softwareVersion: this.softwareVersion, + customIdentifier: this.customIdentifier, + }, + ...(this.cookie ? { cookie: this.cookie } : {}), + }; + } + + // The endpoint as the rules of a capability see it + private view(declaredBefore: readonly AnyCapability[]): EndpointView { + return { + endpointId: this.endpointId, + friendlyName: this.name, + description: this.description, + displayCategories: this.getDisplayCategory(), + capabilities: declaredBefore, + }; + } + + // What discovery lists: the declared capabilities, then the ones the library adds + private announced(declared: readonly AnyCapability[]): AnyCapability[] { + const has = (namespace: string): boolean => declared.some((capability) => capability.namespace === namespace); + const added: AnyCapability[] = []; + // alexa-scenecontroller.html, "Discovery": a scene is not a physical device and has no Alexa.EndpointHealth + if (this.endpointHealth && !has(EndpointHealth.namespace) && !has("Alexa.SceneController")) { + added.push(new Capability(EndpointHealth, { options: {} })); + } + if (this.alexaInterface && !has(Alexa.namespace)) added.push(new Capability(Alexa, { options: {} })); + return [...declared, ...added]; + } +} + +// The options of the interface itself, checked by the schema of its descriptor +function interfaceOptions(descriptor: InterfaceDescriptor, given: Record): O { + if (descriptor.options) return descriptor.options.parse(given); + const [unknown] = Object.keys(given); + if (unknown !== undefined) throw new SchemaError(unknown, "not an option of the interface"); + return {} as O; +} + +export default Device; diff --git a/src/device/validate.ts b/src/device/validate.ts new file mode 100644 index 0000000..9771bd7 --- /dev/null +++ b/src/device/validate.ts @@ -0,0 +1,135 @@ +// What Alexa rejects at discovery, checked where a device is declared. Alexa gives no reason when it drops an +// endpoint: the user hears "no new devices found". Each rule names the page it is from. +import { DISPLAY_CATEGORIES, LIMITS } from "../registry/catalog.js"; +import { labels, shownAs } from "../registry/resources.js"; +import { SchemaError } from "../registry/schema.js"; +import { DeclarationError } from "../registry/types.js"; +import type { AnyDescriptor, Declared, EndpointView, Label, Semantics } from "../registry/types.js"; + +/** An endpoint as discovery sends it, without its capabilities. */ +export interface EndpointFields { + endpointId: string; + friendlyName: string; + description: string; + manufacturerName: string; + displayCategories: readonly string[]; + additionalAttributes: Readonly>; + cookie?: Readonly>; +} + +// alexa-discovery-objects.html, "Endpoint object details": "letters, numbers, spaces, and the following special +// characters: _ - = # ; : ? @ &" +const ENDPOINT_ID = /^[A-Za-z0-9 _\-=#;:?@&]+$/; +// Same table: "alphanumeric characters and spaces". Letters of any script: Alexa speaks Hindi and Japanese too. +const FRIENDLY_NAME = /^[\p{L}\p{M}\p{N} ]+$/u; + +const isText = (value: unknown): value is string => typeof value === "string" && value.length > 0; + +/** Throws the first rule of the Endpoint object that the fields break. */ +export function checkEndpoint(endpoint: EndpointFields): void { + const where = { endpointId: isText(endpoint.endpointId) ? endpoint.endpointId : undefined }; + function refuse(problem: string): never { + throw new DeclarationError(where, problem); + } + const { endpointId, friendlyName, description, manufacturerName, displayCategories, additionalAttributes, cookie } = endpoint; + + if (!isText(endpointId)) refuse("an endpoint needs an endpointId"); + if (endpointId.length > LIMITS.endpointIdLength || !ENDPOINT_ID.test(endpointId)) { + refuse(`the endpointId takes up to ${LIMITS.endpointIdLength} letters, digits, spaces and _ - = # ; : ? @ &`); + } + if (!isText(friendlyName)) refuse("an endpoint needs a name"); + if (friendlyName.length > LIMITS.friendlyNameLength || !FRIENDLY_NAME.test(friendlyName)) { + refuse(`the name ${JSON.stringify(friendlyName)} takes up to ${LIMITS.friendlyNameLength} letters, digits and spaces, no punctuation`); + } + if (!isText(manufacturerName) || manufacturerName.length > LIMITS.manufacturerNameLength) { + refuse(`the manufacturerName takes 1 to ${LIMITS.manufacturerNameLength} characters`); + } + if (!isText(description) || description.length > LIMITS.descriptionLength) { + refuse(`the description takes 1 to ${LIMITS.descriptionLength} characters`); + } + + if (!Array.isArray(displayCategories) || displayCategories.length === 0) refuse("an endpoint needs a display category"); + const known: readonly string[] = DISPLAY_CATEGORIES; + for (const category of displayCategories) { + if (!known.includes(category)) refuse(`${JSON.stringify(category)} is not a display category`); + } + + for (const [name, value] of Object.entries(additionalAttributes)) { + if (typeof value !== "string" || value.length > LIMITS.additionalAttributeLength) { + refuse(`${name} takes up to ${LIMITS.additionalAttributeLength} characters`); + } + } + if (cookie !== undefined) { + const bytes = Buffer.byteLength(JSON.stringify(cookie) ?? ""); + if (bytes > LIMITS.cookieBytes) refuse(`the cookie is ${bytes} bytes, ${LIMITS.cookieBytes} is the most`); + } +} + +/** alexa-discovery.html, "Interface limits". count includes the capabilities the library adds. */ +export function checkCapabilityCount(endpointId: string, count: number): void { + if (count > LIMITS.capabilitiesPerEndpoint) { + throw new DeclarationError({ endpointId }, `${count} capabilities, an endpoint takes ${LIMITS.capabilitiesPerEndpoint}`); + } +} + +// Two names are the same name when the app would show the same: the asset, or the text in one locale. +function nameKey(name: Label): string { + return name["@type"] === "asset" ? `asset ${name.value.assetId}` : `text ${name.value.locale} ${name.value.text.toLowerCase()}`; +} + +function phrases(capability: Declared): string[] { + const { semantics } = capability.options as { semantics?: Semantics }; + return (semantics?.actionMappings ?? []).flatMap((mapping) => mapping.actions); +} + +/** + * Throws the first rule that a capability breaks on its endpoint. endpoint.capabilities are the ones declared + * before it. A stub is checked for being declared twice and for nothing else: the library does not know its rules. + */ +export function checkCapability(capability: Declared, descriptor: AnyDescriptor, endpoint: EndpointView): void { + function refuse(problem: string): never { + throw new DeclarationError(capability, problem); + } + const { namespace, instance, friendlyNames } = capability; + + // generic-controllers.html, "Multiple instances": one capability of an interface, or one per instance name + if (endpoint.capabilities.some((other) => other.namespace === namespace && other.instance === instance)) { + refuse(instance ? "the instance is declared twice" : "the interface is declared twice"); + } + if (descriptor.tier === 3) return; + + if (descriptor.instanced) { + // alexa-rangecontroller.html, "Capabilities array": instance and capabilityResources are required + if (!isText(instance)) refuse("needs an instance name, like Blind.Lift"); + try { + labels.parse(friendlyNames, "friendlyNames"); + } catch (err) { + refuse(err instanceof SchemaError && err.path === "friendlyNames" ? "needs a friendly name, from text() or asset()" : (err as Error).message); + } + // resources-and-assets.html, "CapabilityResources": "The first friendly name in the array must be unique for + // the endpoint." + const first = nameKey(friendlyNames[0]); + const taken = endpoint.capabilities.find((other) => other.friendlyNames.length > 0 && nameKey(other.friendlyNames[0]) === first); + if (taken) { + refuse(`the first friendly name, ${shownAs(friendlyNames[0])}, is the first of ${taken.namespace} ${JSON.stringify(taken.instance)} too`); + } + } else { + if (instance) refuse("takes no instance name: an endpoint has the interface once"); + if (friendlyNames.length > 0) refuse("takes no friendly names"); + } + + // generic-controllers.html, "Semantics for user utterances": "Each semantic phrase must be unique across all + // controller instances for each endpoint" + const used = new Map(); + for (const other of endpoint.capabilities) for (const phrase of phrases(other)) used.set(phrase, other); + for (const phrase of phrases(capability)) { + const owner = used.get(phrase); + if (owner) { + const place = owner === capability ? "twice" : `here and on ${owner.namespace} ${JSON.stringify(owner.instance)}`; + refuse(`${phrase} is mapped ${place}, an endpoint maps a phrase once`); + } + used.set(phrase, capability); + } + + if (descriptor.validate) descriptor.validate(capability, endpoint); +} diff --git a/src/index.ts b/src/index.ts index ba6ee74..3e91dfc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,23 +1,34 @@ // Relative specifiers carry ".js": Node's ES module loader resolves no extension, and TypeScript maps it back to the .ts. export { default as Alex2MQTT, DEFAULT_HOST } from "./Alex2Node.js"; export type { Alex2MQTTOptions } from "./Alex2Node.js"; -export { default as Device } from "./Device.js"; -export { AlexaInterface, AlexaInterfaceType } from "./AlexaInterface.js"; -export type { SupportedMode } from "./AlexaInterface.js"; -export { ActionMapping, AlexaActions } from "./ActionMapping.js"; -export { DisplayCategory } from "./DisplayCategory.js"; -export { AlexaStatusMessage, PowerController, EndpointHealth, TemperatureSensorScale, ThermostatMode } from "./AlexaStatusMessage.js"; -export type { ChangeCause } from "./AlexaStatusMessage.js"; -export { AlexaErrorType, AlexaErrorResponse } from "./AlexaErrorResponse.js"; +export { default as Device } from "./device/Device.js"; +export type { EndpointDefinition, EndpointJson } from "./device/Device.js"; +export { Capability } from "./device/Capability.js"; +export type { CapabilityJson, CommonOptions, Declaration } from "./device/Capability.js"; -// The interface registry and the vocabularies of the Smart Home API +// The interfaces, for device.add(), and the registry that holds them by name export { registry, DeclarationError, SchemaError } from "./registry/index.js"; +export { Alexa, BrightnessController, TemperatureSensor } from "./registry/index.js"; +export { PowerController, EndpointHealth } from "./compat/enums.js"; +export { asset, text } from "./registry/resources.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"; export type { ActionId, AssetId, DisplayCategoryName, StateId, UnitOfMeasure, - AnyDescriptor, CapabilityExtras, Declared, DirectiveDescriptor, EndpointView, EventDescriptor, InterfaceDescriptor, - Label, PropertyDescriptor, Infer, Schema, Temperature, TimeInterval, + ActionsToDirective, AnyDescriptor, CapabilityExtras, Declared, DirectiveDescriptor, EndpointView, EventDescriptor, + InterfaceDescriptor, Label, PropertyDescriptor, Semantics, StatesToRange, StatesToValue, + Infer, Schema, Temperature, TimeInterval, } from "./registry/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"; diff --git a/src/registry/index.ts b/src/registry/index.ts index ead3ed9..3c1cac1 100644 --- a/src/registry/index.ts +++ b/src/registry/index.ts @@ -45,8 +45,8 @@ export const registry = { export { Alexa, BrightnessController, EndpointHealth, PowerController, TemperatureSensor }; export { DeclarationError, defineInterface } from "./types.js"; export type { - AnyDescriptor, CapabilityExtras, Declared, DirectiveDescriptor, EndpointView, EventDescriptor, InterfaceDescriptor, - Label, PropertyDescriptor, + ActionsToDirective, AnyDescriptor, CapabilityExtras, Declared, DirectiveDescriptor, EndpointView, EventDescriptor, + InterfaceDescriptor, Label, PropertyDescriptor, Semantics, StatesToRange, StatesToValue, } from "./types.js"; export { s, SchemaError } from "./schema.js"; export type { Infer, Schema, Temperature, TimeInterval } from "./schema.js"; diff --git a/src/registry/interfaces/stubs.ts b/src/registry/interfaces/stubs.ts index 218e5f4..1c45487 100644 --- a/src/registry/interfaces/stubs.ts +++ b/src/registry/interfaces/stubs.ts @@ -84,6 +84,21 @@ const TABLE: readonly Row[] = [ ["Alexa.WakeOnLANController", "1", [], "alexa-wakeonlancontroller.html"], ]; +// What 1.5.2 added to the capability object of three of them +const EXTRAS: Record = { + // No properties object; supportsDeactivation and proactivelyReported on the capability itself + "Alexa.SceneController": ({ proactivelyReported }) => ({ + properties: false, + topLevel: { supportsDeactivation: true, proactivelyReported }, + }), + "Alexa.ThermostatController": () => ({ + configuration: { supportedModes: ["HEAT", "COOL", "AUTO", "OFF"], supportsScheduling: false }, + }), + "Alexa.ModeController": ({ options }) => (options.supportedModes?.length > 0 + ? { configuration: { ordered: true, supportedModes: options.supportedModes } } + : {}), +}; + function kindOf(namespace: string): AnyDescriptor["kind"] { if (namespace.endsWith("Sensor")) return "sensor"; if (namespace.endsWith("EventSource")) return "eventSource"; @@ -100,6 +115,7 @@ function stub([namespace, version, properties, page]: Row): AnyDescriptor { instanced: false, properties: Object.fromEntries(properties.map((name) => [name, { name, value: s.unknown() }])), directives: {}, + discovery: EXTRAS[namespace], }; } diff --git a/src/registry/resources.ts b/src/registry/resources.ts new file mode 100644 index 0000000..2e61875 --- /dev/null +++ b/src/registry/resources.ts @@ -0,0 +1,47 @@ +// Friendly names: what the user calls an instance, a mode or a preset (resources-and-assets.html). +import { ASSETS, RESERVED_WORDS } from "./catalog.js"; +import type { AssetId } from "./catalog.js"; +import { at, s, SchemaError } from "./schema.js"; +import type { Schema } from "./schema.js"; +import type { Label } from "./types.js"; + +/** A friendly name as text, in one locale. */ +export function text(name: string, locale = "en-US"): Label { + return { "@type": "text", value: { text: name, locale } }; +} + +/** A friendly name from the global Alexa catalog: one id, several names, every language Alexa speaks. */ +export function asset(assetId: AssetId): Label { + return { "@type": "asset", value: { assetId } }; +} + +const LOCALE = /^[a-z]{2}-[A-Z]{2}$/; +const textValue = s.object({ + text: s.string({ min: 1 }), + locale: s.string({ pattern: LOCALE, expects: "a locale like en-US" }), +}); +const assetValue = s.object({ assetId: s.oneOf(ASSETS, "an id of the global Alexa catalog, like Alexa.Setting.Opening") }); +const typed = s.object({ "@type": s.enum("text", "asset"), value: s.unknown() }); +const reserved: readonly string[] = RESERVED_WORDS; + +/** One friendly name. An asset has to be in the catalog, a text must not be a word Alexa keeps for itself. */ +export const label: Schema