From aa0ffd64ead42f49e21b543b486ce7acf16cbc5b Mon Sep 17 00:00:00 2001 From: David Date: Mon, 28 Sep 2026 15:18:22 +0000 Subject: [PATCH] registry: describe an Alexa interface as data src/registry/ holds what the library knows about an interface: namespace, version, the page it was read from, properties with their value schemas and directives with their payload schemas. Five interfaces are described (Alexa, PowerController, BrightnessController, TemperatureSensor, EndpointHealth); the other 65 names of AlexaInterfaceType are stubs with the version and property names of 1.5.2. schema.ts is the run-time check behind it (241 lines, no new dependency), catalog.ts the vocabularies of the pages: 103 assets (23 units), 6 actions, 9 states, 56 display categories, 22 reserved words, 73 error types under 11 namespaces. AlexaInterface.getVersion() and getProps() read the registry; the two switch statements are gone (-167 lines). On the wire: Alexa.EndpointHealth is announced at 3.1 (was 3.3; the page is titled 3.1 and no page mentions 3.3), and TimeHoldController and Camera.LiveViewController at 3 and 1.7 (1.5.2 sent the string "UNKNOWN"). DisplayCategory gains VACUUM. New exports: registry, DeclarationError, SchemaError, Assets, Units, Actions, States, DisplayCategories and the descriptor types. Tests: 20 JSON examples of the five pages under test/fixtures/alexa-docs; every directive payload and property value in them parses with its descriptor. npm test: 57 pass (was 30) in 10.8 s, also on Node 18.20.8 and 20.20.2. Co-Authored-By: Claude Fable 5.1 --- dist/cjs/AlexaInterface.js | 166 +----------- dist/cjs/DisplayCategory.js | 2 + dist/cjs/index.js | 13 +- dist/cjs/registry/catalog.js | 132 ++++++++++ dist/cjs/registry/index.js | 68 +++++ dist/cjs/registry/interfaces/Alexa.js | 23 ++ .../interfaces/BrightnessController.js | 27 ++ .../cjs/registry/interfaces/EndpointHealth.js | 32 +++ .../registry/interfaces/PowerController.js | 31 +++ .../registry/interfaces/TemperatureSensor.js | 18 ++ dist/cjs/registry/interfaces/stubs.js | 102 ++++++++ dist/cjs/registry/schema.js | 190 ++++++++++++++ dist/cjs/registry/types.js | 25 ++ dist/esm/AlexaInterface.d.ts | 2 + dist/esm/AlexaInterface.js | 166 +----------- dist/esm/DisplayCategory.d.ts | 2 + dist/esm/DisplayCategory.js | 2 + dist/esm/index.d.ts | 3 + dist/esm/index.js | 3 + dist/esm/registry/catalog.d.ts | 40 +++ dist/esm/registry/catalog.js | 129 ++++++++++ dist/esm/registry/index.d.ts | 20 ++ dist/esm/registry/index.js | 43 ++++ dist/esm/registry/interfaces/Alexa.d.ts | 10 + dist/esm/registry/interfaces/Alexa.js | 20 ++ .../interfaces/BrightnessController.d.ts | 20 ++ .../interfaces/BrightnessController.js | 24 ++ .../registry/interfaces/EndpointHealth.d.ts | 15 ++ .../esm/registry/interfaces/EndpointHealth.js | 29 +++ .../registry/interfaces/PowerController.d.ts | 17 ++ .../registry/interfaces/PowerController.js | 28 ++ .../interfaces/TemperatureSensor.d.ts | 7 + .../registry/interfaces/TemperatureSensor.js | 15 ++ dist/esm/registry/interfaces/stubs.d.ts | 2 + dist/esm/registry/interfaces/stubs.js | 99 +++++++ dist/esm/registry/schema.d.ts | 91 +++++++ dist/esm/registry/schema.js | 186 ++++++++++++++ dist/esm/registry/types.d.ts | 123 +++++++++ dist/esm/registry/types.js | 20 ++ dist/types/AlexaInterface.d.ts | 2 + dist/types/DisplayCategory.d.ts | 2 + dist/types/index.d.ts | 3 + dist/types/registry/catalog.d.ts | 40 +++ dist/types/registry/index.d.ts | 20 ++ dist/types/registry/interfaces/Alexa.d.ts | 10 + .../interfaces/BrightnessController.d.ts | 20 ++ .../registry/interfaces/EndpointHealth.d.ts | 15 ++ .../registry/interfaces/PowerController.d.ts | 17 ++ .../interfaces/TemperatureSensor.d.ts | 7 + dist/types/registry/interfaces/stubs.d.ts | 2 + dist/types/registry/schema.d.ts | 91 +++++++ dist/types/registry/types.d.ts | 123 +++++++++ package.json | 2 +- src/AlexaInterface.ts | 168 +----------- src/DisplayCategory.ts | 2 + src/index.ts | 12 + src/registry/catalog.ts | 146 +++++++++++ src/registry/index.ts | 53 ++++ src/registry/interfaces/Alexa.ts | 21 ++ .../interfaces/BrightnessController.ts | 25 ++ src/registry/interfaces/EndpointHealth.ts | 31 +++ src/registry/interfaces/PowerController.ts | 28 ++ src/registry/interfaces/TemperatureSensor.ts | 16 ++ src/registry/interfaces/stubs.ts | 106 ++++++++ src/registry/schema.ts | 241 ++++++++++++++++++ src/registry/types.ts | 143 +++++++++++ test/discovery.test.js | 12 + .../AdjustBrightness.directive.json | 22 ++ .../AdjustBrightness.response.json | 30 +++ .../ChangeReport.json | 42 +++ .../SetBrightness.directive.json | 22 ++ .../SetBrightness.response.json | 30 +++ .../StateReport.json | 30 +++ .../alexa-brightnesscontroller/discovery.json | 81 ++++++ .../connectivity.property.json | 7 + .../alexa-endpointhealth/discovery.json | 67 +++++ .../alexa-docs/alexa-interface/discovery.json | 45 ++++ .../alexa-powercontroller/ChangeReport.json | 42 +++ .../alexa-powercontroller/StateReport.json | 30 +++ .../TurnOff.directive.json | 20 ++ .../TurnOff.response.json | 30 +++ .../TurnOn.directive.json | 20 ++ .../TurnOn.response.json | 30 +++ .../alexa-powercontroller/discovery.json | 81 ++++++ .../alexa-temperaturesensor/ChangeReport.json | 66 +++++ .../alexa-temperaturesensor/StateReport.json | 59 +++++ .../alexa-temperaturesensor/discovery.json | 91 +++++++ test/fixtures/interfaces.js | 11 + test/fixtures/types.ts | 32 ++- test/helpers/fixtures.js | 47 ++++ test/registry/catalog.test.js | 72 ++++++ test/registry/descriptors.test.js | 127 +++++++++ test/registry/schema.test.js | 111 ++++++++ 93 files changed, 4058 insertions(+), 490 deletions(-) create mode 100644 dist/cjs/registry/catalog.js create mode 100644 dist/cjs/registry/index.js create mode 100644 dist/cjs/registry/interfaces/Alexa.js create mode 100644 dist/cjs/registry/interfaces/BrightnessController.js create mode 100644 dist/cjs/registry/interfaces/EndpointHealth.js create mode 100644 dist/cjs/registry/interfaces/PowerController.js create mode 100644 dist/cjs/registry/interfaces/TemperatureSensor.js create mode 100644 dist/cjs/registry/interfaces/stubs.js create mode 100644 dist/cjs/registry/schema.js create mode 100644 dist/cjs/registry/types.js create mode 100644 dist/esm/registry/catalog.d.ts create mode 100644 dist/esm/registry/catalog.js create mode 100644 dist/esm/registry/index.d.ts create mode 100644 dist/esm/registry/index.js create mode 100644 dist/esm/registry/interfaces/Alexa.d.ts create mode 100644 dist/esm/registry/interfaces/Alexa.js create mode 100644 dist/esm/registry/interfaces/BrightnessController.d.ts create mode 100644 dist/esm/registry/interfaces/BrightnessController.js create mode 100644 dist/esm/registry/interfaces/EndpointHealth.d.ts create mode 100644 dist/esm/registry/interfaces/EndpointHealth.js create mode 100644 dist/esm/registry/interfaces/PowerController.d.ts create mode 100644 dist/esm/registry/interfaces/PowerController.js create mode 100644 dist/esm/registry/interfaces/TemperatureSensor.d.ts create mode 100644 dist/esm/registry/interfaces/TemperatureSensor.js create mode 100644 dist/esm/registry/interfaces/stubs.d.ts create mode 100644 dist/esm/registry/interfaces/stubs.js create mode 100644 dist/esm/registry/schema.d.ts create mode 100644 dist/esm/registry/schema.js create mode 100644 dist/esm/registry/types.d.ts create mode 100644 dist/esm/registry/types.js create mode 100644 dist/types/registry/catalog.d.ts create mode 100644 dist/types/registry/index.d.ts create mode 100644 dist/types/registry/interfaces/Alexa.d.ts create mode 100644 dist/types/registry/interfaces/BrightnessController.d.ts create mode 100644 dist/types/registry/interfaces/EndpointHealth.d.ts create mode 100644 dist/types/registry/interfaces/PowerController.d.ts create mode 100644 dist/types/registry/interfaces/TemperatureSensor.d.ts create mode 100644 dist/types/registry/interfaces/stubs.d.ts create mode 100644 dist/types/registry/schema.d.ts create mode 100644 dist/types/registry/types.d.ts create mode 100644 src/registry/catalog.ts create mode 100644 src/registry/index.ts create mode 100644 src/registry/interfaces/Alexa.ts create mode 100644 src/registry/interfaces/BrightnessController.ts create mode 100644 src/registry/interfaces/EndpointHealth.ts create mode 100644 src/registry/interfaces/PowerController.ts create mode 100644 src/registry/interfaces/TemperatureSensor.ts create mode 100644 src/registry/interfaces/stubs.ts create mode 100644 src/registry/schema.ts create mode 100644 src/registry/types.ts create mode 100644 test/fixtures/alexa-docs/alexa-brightnesscontroller/AdjustBrightness.directive.json create mode 100644 test/fixtures/alexa-docs/alexa-brightnesscontroller/AdjustBrightness.response.json create mode 100644 test/fixtures/alexa-docs/alexa-brightnesscontroller/ChangeReport.json create mode 100644 test/fixtures/alexa-docs/alexa-brightnesscontroller/SetBrightness.directive.json create mode 100644 test/fixtures/alexa-docs/alexa-brightnesscontroller/SetBrightness.response.json create mode 100644 test/fixtures/alexa-docs/alexa-brightnesscontroller/StateReport.json create mode 100644 test/fixtures/alexa-docs/alexa-brightnesscontroller/discovery.json create mode 100644 test/fixtures/alexa-docs/alexa-endpointhealth/connectivity.property.json create mode 100644 test/fixtures/alexa-docs/alexa-endpointhealth/discovery.json create mode 100644 test/fixtures/alexa-docs/alexa-interface/discovery.json create mode 100644 test/fixtures/alexa-docs/alexa-powercontroller/ChangeReport.json create mode 100644 test/fixtures/alexa-docs/alexa-powercontroller/StateReport.json create mode 100644 test/fixtures/alexa-docs/alexa-powercontroller/TurnOff.directive.json create mode 100644 test/fixtures/alexa-docs/alexa-powercontroller/TurnOff.response.json create mode 100644 test/fixtures/alexa-docs/alexa-powercontroller/TurnOn.directive.json create mode 100644 test/fixtures/alexa-docs/alexa-powercontroller/TurnOn.response.json create mode 100644 test/fixtures/alexa-docs/alexa-powercontroller/discovery.json create mode 100644 test/fixtures/alexa-docs/alexa-temperaturesensor/ChangeReport.json create mode 100644 test/fixtures/alexa-docs/alexa-temperaturesensor/StateReport.json create mode 100644 test/fixtures/alexa-docs/alexa-temperaturesensor/discovery.json create mode 100644 test/fixtures/interfaces.js create mode 100644 test/helpers/fixtures.js create mode 100644 test/registry/catalog.test.js create mode 100644 test/registry/descriptors.test.js create mode 100644 test/registry/schema.test.js diff --git a/dist/cjs/AlexaInterface.js b/dist/cjs/AlexaInterface.js index 8038b18..e4ec767 100644 --- a/dist/cjs/AlexaInterface.js +++ b/dist/cjs/AlexaInterface.js @@ -1,6 +1,7 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.AlexaInterface = exports.AlexaInterfaceType = void 0; +const index_js_1 = require("./registry/index.js"); var AlexaInterfaceType; (function (AlexaInterfaceType) { AlexaInterfaceType["APPLICATION_STATE_REPORTER"] = "Alexa.ApplicationStateReporter"; @@ -103,170 +104,13 @@ class AlexaInterface { getTypeString() { return this.type; } + /** The version of the interface, from its descriptor. "UNKNOWN" for a name the registry does not have. */ getVersion() { - switch (this.type) { - case AlexaInterfaceType.APPLICATION_STATE_REPORTER: - case AlexaInterfaceType.AUDIO_PLAY_QUEUE: - case AlexaInterfaceType.AUTHORIZATION_CONTROLLER: - case AlexaInterfaceType.AUTOMATION_MANAGEMENT: - case AlexaInterfaceType.AUTOMOTIVE_VEHICLE_DATA: - case AlexaInterfaceType.COMMISSIONABLE: - case AlexaInterfaceType.CONSENT_MANAGEMENT_CONSENT_REQUIRED_REPORTER: - case AlexaInterfaceType.COOKING: - case AlexaInterfaceType.COOKING_FOOD_TEMPERATURE_CONTROLLER: - case AlexaInterfaceType.COOKING_FOOD_TEMPERATURE_SENSOR: - case AlexaInterfaceType.COOKING_PRESET_CONTROLLER: - case AlexaInterfaceType.COOKING_TEMPERATURE_CONTROLLER: - case AlexaInterfaceType.COOKING_TEMPERATURE_SENSOR: - case AlexaInterfaceType.COOKING_TIME_CONTROLLER: - case AlexaInterfaceType.DATA_CONTROLLER: - case AlexaInterfaceType.DEVICE_USAGE_ESTIMATION: - case AlexaInterfaceType.DEVICE_USAGE_METER: - case AlexaInterfaceType.EQUALIZER_CONTROLLER: - case AlexaInterfaceType.INVENTORY_LEVEL_SENSOR: - case AlexaInterfaceType.INVENTORY_LEVEL_USAGE_SENSOR: - case AlexaInterfaceType.INVENTORY_USAGE_SENSOR: - case AlexaInterfaceType.KEYPAD_CONTROLLER: - case AlexaInterfaceType.MEDIA_PLAYBACK: - case AlexaInterfaceType.MEDIA_PLAY_QUEUE: - case AlexaInterfaceType.MEDIA_SEARCH: - case AlexaInterfaceType.PLAYBACK_STATE_REPORTER: - case AlexaInterfaceType.PROACTIVE_NOTIFICATION_SOURCE: - case AlexaInterfaceType.REMOTE_VIDEO_PLAYER: - case AlexaInterfaceType.RTC_SESSION_CONTROLLER: - case AlexaInterfaceType.SECURITY_PANEL_CONTROLLER: - case AlexaInterfaceType.SECURITY_PANEL_CONTROLLER_ALERT: - case AlexaInterfaceType.SIMPLE_EVENT_SOURCE: - case AlexaInterfaceType.SMART_VISION_OBJECT_DETECTION_SENSOR: - case AlexaInterfaceType.SMART_VISION_SNAPSHOT_PROVIDER: - case AlexaInterfaceType.SPEAKER: - case AlexaInterfaceType.STEP_SPEAKER: - case AlexaInterfaceType.THERMOSTAT_CONTROLLER_CONFIGURATION: - case AlexaInterfaceType.THERMOSTAT_CONTROLLER_HVAC_COMPONENTS: - case AlexaInterfaceType.THERMOSTAT_CONTROLLER_SCHEDULE: - case AlexaInterfaceType.UI_CONTROLLER: - case AlexaInterfaceType.USER_PREFERENCE: - case AlexaInterfaceType.VIDEO_RECORDER: - case AlexaInterfaceType.WAKE_ON_LAN_CONTROLLER: - return "1"; - case AlexaInterfaceType.BRIGHTNESS_CONTROLLER: - case AlexaInterfaceType.CAMERA_STREAM_CONTROLLER: - case AlexaInterfaceType.CHANNEL_CONTROLLER: - case AlexaInterfaceType.COLOR_CONTROLLER: - case AlexaInterfaceType.COLOR_TEMPERATURE_CONTROLLER: - case AlexaInterfaceType.CONTACT_SENSOR: - case AlexaInterfaceType.DOORBELL_EVENT_SOURCE: - case AlexaInterfaceType.INPUT_CONTROLLER: - case AlexaInterfaceType.LOCK_CONTROLLER: - case AlexaInterfaceType.MODE_CONTROLLER: - case AlexaInterfaceType.MOTION_SENSOR: - case AlexaInterfaceType.PERCENTAGE_CONTROLLER: - case AlexaInterfaceType.PLAYBACK_CONTROLLER: - case AlexaInterfaceType.POWER_CONTROLLER: - case AlexaInterfaceType.POWER_LEVEL_CONTROLLER: - case AlexaInterfaceType.RANGE_CONTROLLER: - case AlexaInterfaceType.RECORD_CONTROLLER: - case AlexaInterfaceType.SCENE_CONTROLLER: - case AlexaInterfaceType.SEEK_CONTROLLER: - case AlexaInterfaceType.TEMPERATURE_SENSOR: - case AlexaInterfaceType.TOGGLE_CONTROLLER: - return "3"; - case AlexaInterfaceType.LAUNCHER: - return "1.1"; - case AlexaInterfaceType.THERMOSTAT_CONTROLLER: - return "3.2"; - case AlexaInterfaceType.ENDPOINT_HEALTH: - return "3.3"; - default: - return "UNKNOWN"; - } + 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() { - switch (this.type) { - case AlexaInterfaceType.AUTOMATION_MANAGEMENT: - return ["automationStatuses"]; - case AlexaInterfaceType.BRIGHTNESS_CONTROLLER: - return ["brightness"]; - case AlexaInterfaceType.CHANNEL_CONTROLLER: - return ["channel"]; - case AlexaInterfaceType.COLOR_CONTROLLER: - return ["color"]; - case AlexaInterfaceType.COLOR_TEMPERATURE_CONTROLLER: - return ["colorTemperatureInKelvin"]; - case AlexaInterfaceType.CONTACT_SENSOR: - return ["detectionState"]; - case AlexaInterfaceType.POWER_CONTROLLER: - return ["powerState"]; - case AlexaInterfaceType.ENDPOINT_HEALTH: - return ["connectivity"]; - case AlexaInterfaceType.TOGGLE_CONTROLLER: - return ["toggleState"]; - case AlexaInterfaceType.MODE_CONTROLLER: - return ["mode"]; - case AlexaInterfaceType.TEMPERATURE_SENSOR: - return ["temperature"]; - case AlexaInterfaceType.THERMOSTAT_CONTROLLER: - return [ - "targetSetpoint", - "lowerSetpoint", - "upperSetpoint", - "thermostatMode", - ]; - case AlexaInterfaceType.APPLICATION_STATE_REPORTER: - case AlexaInterfaceType.AUDIO_PLAY_QUEUE: - case AlexaInterfaceType.AUTHORIZATION_CONTROLLER: - case AlexaInterfaceType.AUTOMOTIVE_VEHICLE_DATA: - case AlexaInterfaceType.CAMERA_LIVE_VIEW_CONTROLLER: - case AlexaInterfaceType.CAMERA_STREAM_CONTROLLER: - case AlexaInterfaceType.COMMISSIONABLE: - case AlexaInterfaceType.CONSENT_MANAGEMENT_CONSENT_REQUIRED_REPORTER: - case AlexaInterfaceType.COOKING: - case AlexaInterfaceType.DATA_CONTROLLER: - case AlexaInterfaceType.DEVICE_USAGE_ESTIMATION: - case AlexaInterfaceType.DEVICE_USAGE_METER: - case AlexaInterfaceType.DOORBELL_EVENT_SOURCE: - case AlexaInterfaceType.EQUALIZER_CONTROLLER: - case AlexaInterfaceType.INPUT_CONTROLLER: - case AlexaInterfaceType.INVENTORY_LEVEL_SENSOR: - case AlexaInterfaceType.INVENTORY_LEVEL_USAGE_SENSOR: - case AlexaInterfaceType.INVENTORY_USAGE_SENSOR: - case AlexaInterfaceType.KEYPAD_CONTROLLER: - case AlexaInterfaceType.LAUNCHER: - case AlexaInterfaceType.LOCK_CONTROLLER: - case AlexaInterfaceType.MEDIA_PLAYBACK: - case AlexaInterfaceType.MEDIA_SEARCH: - case AlexaInterfaceType.MOTION_SENSOR: - case AlexaInterfaceType.PERCENTAGE_CONTROLLER: - case AlexaInterfaceType.PLAYBACK_CONTROLLER: - case AlexaInterfaceType.PLAYBACK_STATE_REPORTER: - case AlexaInterfaceType.PROACTIVE_NOTIFICATION_SOURCE: - case AlexaInterfaceType.RANGE_CONTROLLER: - case AlexaInterfaceType.RECORD_CONTROLLER: - case AlexaInterfaceType.REMOTE_VIDEO_PLAYER: - case AlexaInterfaceType.RTC_SESSION_CONTROLLER: - case AlexaInterfaceType.SCENE_CONTROLLER: - return []; // scenes have no reportable properties: Activate/Deactivate answer with ActivationStarted (Device.sendSceneResponse) - case AlexaInterfaceType.SECURITY_PANEL_CONTROLLER: - case AlexaInterfaceType.SEEK_CONTROLLER: - case AlexaInterfaceType.SIMPLE_EVENT_SOURCE: - case AlexaInterfaceType.SMART_VISION_OBJECT_DETECTION_SENSOR: - case AlexaInterfaceType.SMART_VISION_SNAPSHOT_PROVIDER: - case AlexaInterfaceType.SPEAKER: - case AlexaInterfaceType.STEP_SPEAKER: - case AlexaInterfaceType.THERMOSTAT_CONTROLLER_CONFIGURATION: - case AlexaInterfaceType.THERMOSTAT_CONTROLLER_HVAC_COMPONENTS: - case AlexaInterfaceType.THERMOSTAT_CONTROLLER_SCHEDULE: - case AlexaInterfaceType.TIME_HOLD_CONTROLLER: - case AlexaInterfaceType.UI_CONTROLLER: - case AlexaInterfaceType.USER_PREFERENCE: - case AlexaInterfaceType.VIDEO_RECORDER: - case AlexaInterfaceType.WAKE_ON_LAN_CONTROLLER: - // no property list known for these: discovery lists the capability with an empty "supported" (the bridge notes - // it through its log hook; 1.5.1 wrote "UNSUPORTED INTERFACE TYPE" to stderr on every discovery) - return []; - default: - return []; - } + return index_js_1.registry.has(this.type) ? Object.keys(index_js_1.registry.get(this.type).properties) : []; } getJSON() { const doc = { diff --git a/dist/cjs/DisplayCategory.js b/dist/cjs/DisplayCategory.js index 809458c..c45cbfa 100644 --- a/dist/cjs/DisplayCategory.js +++ b/dist/cjs/DisplayCategory.js @@ -55,6 +55,8 @@ var DisplayCategory; 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"; diff --git a/dist/cjs/index.js b/dist/cjs/index.js index 57320aa..0336bad 100644 --- a/dist/cjs/index.js +++ b/dist/cjs/index.js @@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); -exports.AlexaErrorResponse = exports.AlexaErrorType = exports.ThermostatMode = exports.TemperatureSensorScale = exports.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.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; // 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; } }); @@ -27,3 +27,14 @@ Object.defineProperty(exports, "ThermostatMode", { enumerable: true, get: functi 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 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; } }); diff --git a/dist/cjs/registry/catalog.js b/dist/cjs/registry/catalog.js new file mode 100644 index 0000000..6a0d3f1 --- /dev/null +++ b/dist/cjs/registry/catalog.js @@ -0,0 +1,132 @@ +"use strict"; +// The fixed vocabularies of the Smart Home API, copied from Amazon's pages as read on 2026-09-28. The pages are under +// https://developer.amazon.com/docs/alexaplus/device-apis/. A value missing here is one Amazon added since. +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ERROR_TYPES = exports.LIMITS = exports.RESERVED_WORDS = exports.DISPLAY_CATEGORIES = exports.STATES = exports.ACTIONS = exports.UNITS_OF_MEASURE = exports.ASSETS = void 0; +/** + * The asset ids a friendly name can refer to: 103, the units of measure among them + * (resources-and-assets.html, "Global Alexa catalog"). + */ +exports.ASSETS = [ + "Alexa.Actions.Charge", "Alexa.Actions.Clean", "Alexa.Actions.Dispense", "Alexa.Actions.Dock", + "Alexa.Actions.Empty", "Alexa.Actions.Mop", "Alexa.Actions.Skip", "Alexa.Actions.Sweep", "Alexa.Actions.Vacuum", + "Alexa.Button.OffButton", "Alexa.Button.OnButton", "Alexa.Button.BrightenButton", "Alexa.Button.DimButton", + "Alexa.Button.MainButton", "Alexa.Button.TopButton", "Alexa.Button.BottomButton", "Alexa.Button.CenterButton", + "Alexa.Button.MiddleButton", "Alexa.Button.UpButton", "Alexa.Button.DownButton", "Alexa.Button.LeftButton", + "Alexa.Button.RightButton", "Alexa.Button.FirstButton", "Alexa.Button.SecondButton", "Alexa.Button.ThirdButton", + "Alexa.Button.FourthButton", "Alexa.Button.FifthButton", "Alexa.Button.SixthButton", "Alexa.Button.SeventhButton", + "Alexa.Button.EighthButton", "Alexa.Button.DoublePress", "Alexa.Button.DoublePush", "Alexa.Button.LongPress", + "Alexa.Button.LongPush", "Alexa.Button.SinglePress", "Alexa.Button.SinglePush", + "Alexa.DeviceName.AirPurifier", "Alexa.DeviceName.Camera", "Alexa.DeviceName.Fan", "Alexa.DeviceName.Router", + "Alexa.DeviceName.Shade", "Alexa.DeviceName.Shower", "Alexa.DeviceName.SpaceHeater", "Alexa.DeviceName.Washer", + "Alexa.Gesture.DoubleClick", "Alexa.Gesture.SingleClick", "Alexa.Gesture.SwipeDown", "Alexa.Gesture.SwipeLeft", + "Alexa.Gesture.SwipeRight", "Alexa.Gesture.SwipeUp", "Alexa.Gesture.Tap", + // "Gestures", plural: the page spells this one id differently from the seven above + "Alexa.Gestures.DoubleTap", + "Alexa.Setting.2GGuestWiFi", "Alexa.Setting.5GGuestWiFi", "Alexa.Setting.Auto", "Alexa.Setting.Direction", + "Alexa.Setting.DryCycle", "Alexa.Setting.FanSpeed", "Alexa.Setting.GuestWiFi", "Alexa.Setting.Heat", + "Alexa.Setting.Mode", "Alexa.Setting.Night", "Alexa.Setting.Opening", "Alexa.Setting.Oscillate", + "Alexa.Setting.Preset", "Alexa.Setting.Quiet", "Alexa.Setting.Temperature", "Alexa.Setting.WashCycle", + "Alexa.Setting.WaterTemperature", + "Alexa.Shower.HandHeld", "Alexa.Shower.RainHead", + "Alexa.Unit.Angle.Degrees", "Alexa.Unit.Angle.Radians", "Alexa.Unit.Distance.Feet", "Alexa.Unit.Distance.Inches", + "Alexa.Unit.Distance.Kilometers", "Alexa.Unit.Distance.Meters", "Alexa.Unit.Distance.Miles", + "Alexa.Unit.Distance.Yards", "Alexa.Unit.Mass.Grams", "Alexa.Unit.Mass.Kilograms", "Alexa.Unit.Percent", + "Alexa.Unit.Temperature.Celsius", "Alexa.Unit.Temperature.Degrees", "Alexa.Unit.Temperature.Fahrenheit", + "Alexa.Unit.Temperature.Kelvin", "Alexa.Unit.Volume.CubicFeet", "Alexa.Unit.Volume.CubicMeters", + "Alexa.Unit.Volume.Gallons", "Alexa.Unit.Volume.Liters", "Alexa.Unit.Volume.Pints", "Alexa.Unit.Volume.Quarts", + "Alexa.Unit.Weight.Ounces", "Alexa.Unit.Weight.Pounds", + "Alexa.Value.Close", "Alexa.Value.Delicate", "Alexa.Value.High", "Alexa.Value.Low", "Alexa.Value.Maximum", + "Alexa.Value.Medium", "Alexa.Value.Minimum", "Alexa.Value.Open", "Alexa.Value.QuickWash", +]; +exports.UNITS_OF_MEASURE = exports.ASSETS.filter((id) => id.startsWith("Alexa.Unit.")); +/** The phrases an action mapping gives to a directive (alexa-discovery-objects.html, "ActionMappings object"). */ +exports.ACTIONS = [ + "Alexa.Actions.Open", "Alexa.Actions.Close", "Alexa.Actions.Raise", "Alexa.Actions.Lower", + "Alexa.Actions.SetEcoOn", "Alexa.Actions.SetEcoOff", +]; +/** The states a state mapping gives to a property value (alexa-discovery-objects.html, "StateMappings object"). */ +exports.STATES = [ + "Alexa.States.Open", "Alexa.States.Closed", "Alexa.States.EcoOn", "Alexa.States.EcoOff", "Alexa.States.Low", + "Alexa.States.Empty", "Alexa.States.Full", "Alexa.States.Done", "Alexa.States.Stuck", +]; +/** + * The 56 display categories (alexa-discovery.html, "Display categories"). The DisplayCategory enum also keeps + * VEHICLE from 1.x, which the page no longer lists. + */ +exports.DISPLAY_CATEGORIES = [ + "ACTIVITY_TRIGGER", "AIR_CONDITIONER", "AIR_FRESHENER", "AIR_PURIFIER", "AIR_QUALITY_MONITOR", + "ALEXA_VOICE_ENABLED", "AUTO_ACCESSORY", "BLUETOOTH_SPEAKER", "CAMERA", "CHRISTMAS_TREE", "COFFEE_MAKER", + "COMPUTER", "CONTACT_SENSOR", "DISHWASHER", "DOOR", "DOORBELL", "DRYER", "EXTERIOR_BLIND", "FAN", "GAME_CONSOLE", + "GARAGE_DOOR", "HEADPHONES", "HUB", "INTERIOR_BLIND", "LAPTOP", "LIGHT", "MICROWAVE", "MOBILE_PHONE", + "MOTION_SENSOR", "MUSIC_SYSTEM", "NETWORK_HARDWARE", "OTHER", "OVEN", "PHONE", "PRINTER", "REMOTE", "ROUTER", + "SCENE_TRIGGER", "SCREEN", "SECURITY_PANEL", "SECURITY_SYSTEM", "SLOW_COOKER", "SMARTLOCK", "SMARTPLUG", "SPEAKER", + "STREAMING_DEVICE", "SWITCH", "TABLET", "TEMPERATURE_SENSOR", "THERMOSTAT", "TV", "VACUUM_CLEANER", "VACUUM", + "WASHER", "WATER_HEATER", "WEARABLE", +]; +/** Not to be used as a friendly name (resources-and-assets.html, "Reserved words"). */ +exports.RESERVED_WORDS = [ + "alarm", "alarms", "all alarms", "away mode", "bass", "camera", "date", "date today", "day", "do not disturb", + "drop in", "music", "night light", "notification", "playing", "sleep sounds", "time", "timer", "today in music", + "treble", "volume", "way f. m.", +]; +/** + * What a discovery answer may hold (alexa-discovery.html, "Interface limits"; alexa-discovery-objects.html, + * "Endpoint object details" and "AdditionalAttributes object details"; alexa-scenecontroller.html, "Discovery"). + */ +exports.LIMITS = { + endpointsPerCustomer: 300, + capabilitiesPerEndpoint: 100, + endpointIdLength: 256, + friendlyNameLength: 256, + sceneFriendlyNameLength: 128, + manufacturerNameLength: 128, + descriptionLength: 128, + additionalAttributeLength: 256, + cookieBytes: 5000, +}; +// alexa-errorresponse.html, "Error type values". The Interface column names .ErrorResponse; the event +// header carries the namespace and the name ErrorResponse, as the examples on the ThermostatController, +// SecurityPanelController and Safety error pages show. The other namespaces are as the table spells them. +// INVALID_VALUE is listed under Alexa and under SmartVision.ObjectDetectionSensor, SUBSCRIPTION_REQUIRED under both +// SmartVision interfaces: the first one listed is kept. +const ERROR_TYPES_BY_NAMESPACE = { + "Alexa": [ + "ALREADY_IN_OPERATION", "BRIDGE_UNREACHABLE", "CLOUD_CONTROL_DISABLED", "DEVICE_STUCK", "DO_NOT_DISTURB_MODE", + "ENDPOINT_BUSY", "ENDPOINT_CONTROL_UNAVAILABLE", "ENDPOINT_LOW_POWER", "ENDPOINT_UNREACHABLE", + "EXPIRED_AUTHORIZATION_CREDENTIAL", "FIRMWARE_OUT_OF_DATE", "HARDWARE_MALFUNCTION", "INSUFFICIENT_PERMISSIONS", + "INSUFFICIENT_RESOURCE", "INTERNAL_ERROR", "INVALID_AUTHORIZATION_CREDENTIAL", "INVALID_DIRECTIVE", + "INVALID_VALUE", "MAINTENANCE_REQUIRED", "NO_SUCH_ENDPOINT", "NOT_CALIBRATED", "NOT_IN_OPERATION", + "NOT_SUPPORTED_IN_CURRENT_MODE", "NOT_SUPPORTED_WITH_CURRENT_BATTERY_CHARGE_STATE", + "PARTNER_APPLICATION_REDIRECTION", "POWER_LEVEL_NOT_SUPPORTED", "RATE_LIMIT_EXCEEDED", + "TEMPERATURE_VALUE_OUT_OF_RANGE", "TOO_MANY_FAILED_ATTEMPTS", "UNABLE_TO_CHARGE", "VALUE_OUT_OF_RANGE", + ], + "Alexa.Commissionable.ReportCommissioningInformation": [ + "FAILED_TO_BOOTSTRAP_COMMISSIONING_PROCESS", "MAX_COMMISSIONING_LIMIT_REACHED", + ], + "Alexa.Cooking": [ + "CHILD_LOCK", "COOK_DURATION_TOO_LONG", "DOOR_CLOSED_TOO_LONG", "DOOR_OPEN", "PREHEAT_REQUIRED", + "PROBE_REQUIRED", "REMOTE_START_NOT_SUPPORTED", "REMOVE_PROBE", "REMOTE_START_DISABLED", + ], + "Alexa.DataController": ["DATA_DELETION_NOT_SUPPORTED", "DATA_RETRIEVAL_NOT_SUPPORTED"], + "Alexa.Safety": ["OBSTACLE_DETECTED", "SAFETY_BEAM_BREACHED"], + "Alexa.SecurityPanelController": [ + "AUTHORIZATION_REQUIRED", "BYPASS_NEEDED", "NOT_READY", "UNAUTHORIZED", "UNCLEARED_ALARM", "UNCLEARED_TROUBLE", + ], + "Alexa.SmartVision.ObjectDetectionSensor": ["SUBSCRIPTION_REQUIRED"], + "Alexa.SmartVision.SnapshotProvider": ["DISABLED_BY_USER"], + "Alexa.ThermostatController": [ + "DUAL_SETPOINTS_UNSUPPORTED", "REQUESTED_SETPOINTS_TOO_CLOSE", "THERMOSTAT_IS_OFF", + "TRIPLE_SETPOINTS_UNSUPPORTED", "UNSUPPORTED_THERMOSTAT_MODE", "UNWILLING_TO_SET_SCHEDULE", + "UNWILLING_TO_SET_VALUE", + ], + "Alexa.ThermostatController.Configuration": [ + "CONFIGURATION_UPDATE_NOT_ALLOWED", "COOLING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE", + "COOLING_STAGES_EXCEEDS_LIMIT", "HEATING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE", "HEATING_STAGES_EXCEEDS_LIMIT", + "INVALID_AUXILIARY_HEATING_SYSTEM_TYPE", "INVALID_SYSTEM_TYPE", "INVALID_TARGET_STATE", + "INVALID_TEMPERATURE_SCALE", "INVALID_TERMINAL_CONNECTION", "MISSING_SETUP_INFORMATION", + ], + "Alexa.ThermostatController.Schedule": ["INSUFFICIENT_SPACE"], +}; +/** The 73 error types of the table, each with the namespace its ErrorResponse goes under. */ +exports.ERROR_TYPES = Object.fromEntries(Object.entries(ERROR_TYPES_BY_NAMESPACE).flatMap(([namespace, types]) => types.map((type) => [type, namespace]))); diff --git a/dist/cjs/registry/index.js b/dist/cjs/registry/index.js new file mode 100644 index 0000000..1222c88 --- /dev/null +++ b/dist/cjs/registry/index.js @@ -0,0 +1,68 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __exportStar = (this && this.__exportStar) || function(m, exports) { + for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.SchemaError = exports.s = exports.defineInterface = exports.DeclarationError = exports.TemperatureSensor = exports.PowerController = exports.EndpointHealth = exports.BrightnessController = exports.Alexa = exports.registry = void 0; +// The interfaces the library knows, by namespace. +const Alexa_js_1 = require("./interfaces/Alexa.js"); +Object.defineProperty(exports, "Alexa", { enumerable: true, get: function () { return Alexa_js_1.Alexa; } }); +const BrightnessController_js_1 = require("./interfaces/BrightnessController.js"); +Object.defineProperty(exports, "BrightnessController", { enumerable: true, get: function () { return BrightnessController_js_1.BrightnessController; } }); +const EndpointHealth_js_1 = require("./interfaces/EndpointHealth.js"); +Object.defineProperty(exports, "EndpointHealth", { enumerable: true, get: function () { return EndpointHealth_js_1.EndpointHealth; } }); +const PowerController_js_1 = require("./interfaces/PowerController.js"); +Object.defineProperty(exports, "PowerController", { enumerable: true, get: function () { return PowerController_js_1.PowerController; } }); +const TemperatureSensor_js_1 = require("./interfaces/TemperatureSensor.js"); +Object.defineProperty(exports, "TemperatureSensor", { enumerable: true, get: function () { return TemperatureSensor_js_1.TemperatureSensor; } }); +const stubs_js_1 = require("./interfaces/stubs.js"); +const types_js_1 = require("./types.js"); +const described = [ + Alexa_js_1.Alexa, + BrightnessController_js_1.BrightnessController, + EndpointHealth_js_1.EndpointHealth, + PowerController_js_1.PowerController, + TemperatureSensor_js_1.TemperatureSensor, +]; +const descriptors = new Map(); +for (const descriptor of [...described, ...stubs_js_1.STUBS]) { + // A stub left in the table next to the descriptor that replaces it would win or lose by the order of this list + if (descriptors.has(descriptor.namespace)) + throw new Error(`${descriptor.namespace} is described twice`); + descriptors.set(descriptor.namespace, descriptor); +} +exports.registry = { + /** Whether an interface of this name is known. */ + has(namespace) { + return descriptors.has(namespace); + }, + /** The descriptor of "Alexa.RangeController" or of AlexaInterfaceType.RANGE_CONTROLLER, which is that string. */ + get(namespace) { + const descriptor = descriptors.get(namespace); + if (!descriptor) + throw new types_js_1.DeclarationError({}, `${JSON.stringify(namespace)} is not an interface alex2node knows`); + return descriptor; + }, + /** Every descriptor, ordered by namespace. */ + list() { + return [...descriptors.values()].sort((a, b) => (a.namespace < b.namespace ? -1 : 1)); + }, +}; +var types_js_2 = require("./types.js"); +Object.defineProperty(exports, "DeclarationError", { enumerable: true, get: function () { return types_js_2.DeclarationError; } }); +Object.defineProperty(exports, "defineInterface", { enumerable: true, get: function () { return types_js_2.defineInterface; } }); +var schema_js_1 = require("./schema.js"); +Object.defineProperty(exports, "s", { enumerable: true, get: function () { return schema_js_1.s; } }); +Object.defineProperty(exports, "SchemaError", { enumerable: true, get: function () { return schema_js_1.SchemaError; } }); +__exportStar(require("./catalog.js"), exports); diff --git a/dist/cjs/registry/interfaces/Alexa.js b/dist/cjs/registry/interfaces/Alexa.js new file mode 100644 index 0000000..1e23f89 --- /dev/null +++ b/dist/cjs/registry/interfaces/Alexa.js @@ -0,0 +1,23 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.Alexa = void 0; +const schema_js_1 = require("../schema.js"); +const types_js_1 = require("../types.js"); +/** + * The base interface: every endpoint lists it (alexa-interface.html, "Support the Alexa interface in all add-ons"). + * Its one directive to a device is ReportState, answered with a StateReport. + */ +exports.Alexa = (0, types_js_1.defineInterface)({ + namespace: "Alexa", + version: "3", + doc: "https://developer.amazon.com/docs/alexaplus/device-apis/alexa-interface.html", + kind: "base", + tier: 1, + instanced: false, + properties: {}, + directives: { + ReportState: { name: "ReportState", payload: schema_js_1.s.object({}) }, + }, + // { type, interface, version } and nothing else, as in the discovery example of the page + discovery: () => ({ properties: false }), +}); diff --git a/dist/cjs/registry/interfaces/BrightnessController.js b/dist/cjs/registry/interfaces/BrightnessController.js new file mode 100644 index 0000000..1c9d9fb --- /dev/null +++ b/dist/cjs/registry/interfaces/BrightnessController.js @@ -0,0 +1,27 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.BrightnessController = void 0; +const schema_js_1 = require("../schema.js"); +const types_js_1 = require("../types.js"); +/** Both directives turn a light that is off on, at the brightness asked for (alexa-brightnesscontroller.html). */ +exports.BrightnessController = (0, types_js_1.defineInterface)({ + namespace: "Alexa.BrightnessController", + version: "3", + doc: "https://developer.amazon.com/docs/alexaplus/device-apis/alexa-brightnesscontroller.html", + kind: "controller", + tier: 1, + instanced: false, + properties: { + brightness: { name: "brightness", value: schema_js_1.s.number({ min: 0, max: 100, integer: true }) }, + }, + directives: { + SetBrightness: { + name: "SetBrightness", + payload: schema_js_1.s.object({ brightness: schema_js_1.s.number({ min: 0, max: 100, integer: true }) }), + }, + AdjustBrightness: { + name: "AdjustBrightness", + payload: schema_js_1.s.object({ brightnessDelta: schema_js_1.s.number({ min: -100, max: 100, integer: true }) }), + }, + }, +}); diff --git a/dist/cjs/registry/interfaces/EndpointHealth.js b/dist/cjs/registry/interfaces/EndpointHealth.js new file mode 100644 index 0000000..0e1571f --- /dev/null +++ b/dist/cjs/registry/interfaces/EndpointHealth.js @@ -0,0 +1,32 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.EndpointHealth = exports.CONNECTIVITY_REASONS = void 0; +const schema_js_1 = require("../schema.js"); +const types_js_1 = require("../types.js"); +exports.CONNECTIVITY_REASONS = [ + "WIFI_BAD_PASSWORD", "WIFI_AP_NOT_FOUND", "WIFI_ROUTER_UNREACHABLE", "WIFI_AP_CHANNEL_QUALITY_LOW", + "INTERNET_UNREACHABLE", "CAPTIVE_PORTAL_CHECK_FAILED", "UNKNOWN", +]; +/** + * Version 3.1, as the page is titled. It defines connectivity only. Examples on other pages declare 3 and 3.2; + * alex2node 1.x declared 3.3, which no page mentions. + */ +exports.EndpointHealth = (0, types_js_1.defineInterface)({ + namespace: "Alexa.EndpointHealth", + version: "3.1", + doc: "https://developer.amazon.com/docs/alexaplus/device-apis/alexa-endpointhealth.html", + kind: "sensor", + tier: 1, + instanced: false, + properties: { + connectivity: { + name: "connectivity", + value: schema_js_1.s.object({ + value: schema_js_1.s.enum("OK", "UNREACHABLE"), + reason: schema_js_1.s.optional(schema_js_1.s.enum(...exports.CONNECTIVITY_REASONS)), + }), + note: "in every Response, StateReport and ChangeReport; a change of it reported within three seconds", + }, + }, + directives: {}, +}); diff --git a/dist/cjs/registry/interfaces/PowerController.js b/dist/cjs/registry/interfaces/PowerController.js new file mode 100644 index 0000000..2a9cbe2 --- /dev/null +++ b/dist/cjs/registry/interfaces/PowerController.js @@ -0,0 +1,31 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.PowerController = void 0; +const schema_js_1 = require("../schema.js"); +const types_js_1 = require("../types.js"); +exports.PowerController = (0, types_js_1.defineInterface)({ + namespace: "Alexa.PowerController", + version: "3", + doc: "https://developer.amazon.com/docs/alexaplus/device-apis/alexa-powercontroller.html", + kind: "controller", + tier: 1, + instanced: false, + properties: { + powerState: { name: "powerState", value: schema_js_1.s.enum("ON", "OFF") }, + }, + directives: { + TurnOn: { name: "TurnOn", payload: schema_js_1.s.object({}) }, + TurnOff: { name: "TurnOff", payload: schema_js_1.s.object({}) }, + }, + options: schema_js_1.s.object({ + // The directives Alexa asks the user to confirm before it sends them. Amazon supports this for devices in Japan + // only (alexa-discovery-objects.html, "VerificationsRequired object"). + verificationsRequired: schema_js_1.s.optional(schema_js_1.s.array(schema_js_1.s.enum("TurnOn", "TurnOff"), { min: 1 })), + }, { unknownKeys: "reject" }), + discovery({ options }) { + if (!options.verificationsRequired) + return {}; + const confirmed = options.verificationsRequired.map((directive) => ({ directive, methods: [{ "@type": "Confirmation" }] })); + return { topLevel: { verificationsRequired: confirmed } }; + }, +}); diff --git a/dist/cjs/registry/interfaces/TemperatureSensor.js b/dist/cjs/registry/interfaces/TemperatureSensor.js new file mode 100644 index 0000000..d4e7bba --- /dev/null +++ b/dist/cjs/registry/interfaces/TemperatureSensor.js @@ -0,0 +1,18 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.TemperatureSensor = void 0; +const schema_js_1 = require("../schema.js"); +const types_js_1 = require("../types.js"); +/** A sensor: an endpoint that has it declares Alexa.EndpointHealth as well (alexa-temperaturesensor.html). */ +exports.TemperatureSensor = (0, types_js_1.defineInterface)({ + namespace: "Alexa.TemperatureSensor", + version: "3", + doc: "https://developer.amazon.com/docs/alexaplus/device-apis/alexa-temperaturesensor.html", + kind: "sensor", + tier: 1, + instanced: false, + properties: { + temperature: { name: "temperature", value: schema_js_1.s.temperature() }, + }, + directives: {}, +}); diff --git a/dist/cjs/registry/interfaces/stubs.js b/dist/cjs/registry/interfaces/stubs.js new file mode 100644 index 0000000..26d00e0 --- /dev/null +++ b/dist/cjs/registry/interfaces/stubs.js @@ -0,0 +1,102 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.STUBS = void 0; +// Interfaces without a descriptor of their own. A stub names the interface with the version and the properties +// alex2node 1.5.2 put in discovery, so an endpoint that declares one is announced as before. Nothing about it is +// checked, and the version is the one 1.5.2 sent, which is not always the one on the page of the interface +// (Alexa.Speaker is "3" there). A row leaves this table when a descriptor is written from that page. +const schema_js_1 = require("../schema.js"); +const DEVICE_APIS = "https://developer.amazon.com/docs/alexaplus/device-apis"; +const LIST_OF_INTERFACES = `${DEVICE_APIS}/list-of-interfaces.html`; +// "As of August 7, 2026, Automotive skills and the associated Alexa.AuthorizationController and +// Alexa.Automotive.VehicleData APIs are no longer available." +const DEPRECATED_FEATURES = "https://developer.amazon.com/en-US/docs/alexa/ask-overviews/deprecated-features.html"; +const TABLE = [ + ["Alexa.ApplicationStateReporter", "1", [], "https://developer.amazon.com/docs/alexaplus/alexa-voice-service/alexa-applicationstatereporter.html"], + ["Alexa.Audio.PlayQueue", "1", [], "alexa-audio-playqueue.html"], + ["Alexa.AuthorizationController", "1", [], DEPRECATED_FEATURES], + ["Alexa.AutomationManagement", "1", ["automationStatuses"], "alexa-automationmanagement.html"], + ["Alexa.Automotive.VehicleData", "1", [], DEPRECATED_FEATURES], + // 1.5.2 announced the version "UNKNOWN"; 1.7 is what the list of interfaces gives + ["Alexa.Camera.LiveViewController", "1.7", [], LIST_OF_INTERFACES], + ["Alexa.CameraStreamController", "3", [], "alexa-camerastreamcontroller.html"], + ["Alexa.ChannelController", "3", ["channel"], "alexa-channelcontroller.html"], + ["Alexa.ColorController", "3", ["color"], "alexa-colorcontroller.html"], + ["Alexa.ColorTemperatureController", "3", ["colorTemperatureInKelvin"], "alexa-colortemperaturecontroller.html"], + ["Alexa.Commissionable", "1", [], "alexa-commissionable.html"], + ["Alexa.ConsentManagement.ConsentRequiredReporter", "1", [], "alexa-consentrequiredreporter.html"], + ["Alexa.ContactSensor", "3", ["detectionState"], "alexa-contactsensor.html"], + ["Alexa.Cooking", "1", [], "alexa-cooking.html"], + ["Alexa.Cooking.FoodTemperatureController", "1", [], "alexa-cooking-foodtemperaturecontroller.html"], + ["Alexa.Cooking.FoodTemperatureSensor", "1", [], "alexa-cooking-foodtemperaturesensor.html"], + ["Alexa.Cooking.PresetController", "1", [], "alexa-cooking-presetcontroller.html"], + ["Alexa.Cooking.TemperatureController", "1", [], "alexa-cooking-temperaturecontroller.html"], + ["Alexa.Cooking.TemperatureSensor", "1", [], "alexa-cooking-temperaturesensor.html"], + ["Alexa.Cooking.TimeController", "1", [], "alexa-cooking-timecontroller.html"], + ["Alexa.DataController", "1", [], "alexa-datacontroller.html"], + ["Alexa.DeviceUsage.Estimation", "1", [], "alexa-deviceusage-estimation.html"], + ["Alexa.DeviceUsage.Meter", "1", [], "alexa-deviceusage-meter.html"], + ["Alexa.DoorbellEventSource", "3", [], "alexa-doorbelleventsource.html"], + ["Alexa.EqualizerController", "1", [], "alexa-equalizercontroller.html"], + ["Alexa.InputController", "3", [], "alexa-inputcontroller.html"], + ["Alexa.InventoryLevelSensor", "1", [], "alexa-inventorylevelsensor.html"], + ["Alexa.InventoryLevelUsageSensor", "1", [], "alexa-inventorylevelusagesensor.html"], + ["Alexa.InventoryUsageSensor", "1", [], "alexa-inventoryusagesensor.html"], + ["Alexa.KeypadController", "1", [], "alexa-keypadcontroller.html"], + ["Alexa.Launcher", "1.1", [], "alexa-launcher.html"], + ["Alexa.LockController", "3", [], "alexa-lockcontroller.html"], + ["Alexa.Media.PlayQueue", "1", [], "alexa-media-playqueue.html"], + ["Alexa.Media.Playback", "1", [], "alexa-media-playback.html"], + ["Alexa.Media.Search", "1", [], "alexa-media-search.html"], + ["Alexa.ModeController", "3", ["mode"], "alexa-modecontroller.html"], + ["Alexa.MotionSensor", "3", [], "alexa-motionsensor.html"], + ["Alexa.PercentageController", "3", [], "alexa-percentagecontroller.html"], + ["Alexa.PlaybackController", "3", [], "alexa-playbackcontroller.html"], + ["Alexa.PlaybackStateReporter", "1", [], "alexa-playbackcontroller.html"], + ["Alexa.PowerLevelController", "3", [], "alexa-powerlevelcontroller.html"], + ["Alexa.ProactiveNotificationSource", "1", [], "alexa-proactivenotificationsource.html"], + ["Alexa.RTCSessionController", "1", [], "alexa-rtcsessioncontroller.html"], + ["Alexa.RangeController", "3", [], "alexa-rangecontroller.html"], + ["Alexa.RecordController", "3", [], "alexa-recordcontroller.html"], + ["Alexa.RemoteVideoPlayer", "1", [], "alexa-remotevideoplayer.html"], + ["Alexa.SceneController", "3", [], "alexa-scenecontroller.html"], + ["Alexa.SecurityPanelController", "1", [], "alexa-securitypanelcontroller.html"], + ["Alexa.SecurityPanelController.Alert", "1", [], "alexa-securitypanelcontroller-alert.html"], + ["Alexa.SeekController", "3", [], "alexa-seekcontroller.html"], + ["Alexa.SimpleEventSource", "1", [], "alexa-simpleeventsource.html"], + ["Alexa.SmartVision.ObjectDetectionSensor", "1", [], "alexa-smartvision-objectdetectionsensor.html"], + ["Alexa.SmartVision.SnapshotProvider", "1", [], "alexa-smartvision-snapshotprovider.html"], + ["Alexa.Speaker", "1", [], "alexa-speaker.html"], + ["Alexa.StepSpeaker", "1", [], "alexa-stepspeaker.html"], + ["Alexa.ThermostatController", "3.2", ["targetSetpoint", "lowerSetpoint", "upperSetpoint", "thermostatMode"], "alexa-thermostatcontroller.html"], + ["Alexa.ThermostatController.Configuration", "1", [], "alexa-thermostatcontroller-configuration.html"], + ["Alexa.ThermostatController.HVAC.Components", "1", [], "alexa-thermostatcontroller-hvac-components.html"], + ["Alexa.ThermostatController.Schedule", "1", [], "alexa-thermostatcontroller-schedule.html"], + // "UNKNOWN" in 1.5.2 as well; the page is titled "Interface 3" + ["Alexa.TimeHoldController", "3", [], "alexa-timeholdcontroller.html"], + ["Alexa.ToggleController", "3", ["toggleState"], "alexa-togglecontroller.html"], + ["Alexa.UIController", "1", [], "alexa-uicontroller.html"], + ["Alexa.UserPreference", "1", [], "alexa-userpreference.html"], + ["Alexa.VideoRecorder", "1", [], "alexa-videorecorder.html"], + ["Alexa.WakeOnLANController", "1", [], "alexa-wakeonlancontroller.html"], +]; +function kindOf(namespace) { + if (namespace.endsWith("Sensor")) + return "sensor"; + if (namespace.endsWith("EventSource")) + return "eventSource"; + return "controller"; +} +function stub([namespace, version, properties, page]) { + return { + namespace, + version, + doc: page.startsWith("https://") ? page : `${DEVICE_APIS}/${page}`, + kind: kindOf(namespace), + tier: 3, + instanced: false, + properties: Object.fromEntries(properties.map((name) => [name, { name, value: schema_js_1.s.unknown() }])), + directives: {}, + }; +} +exports.STUBS = TABLE.map(stub); diff --git a/dist/cjs/registry/schema.js b/dist/cjs/registry/schema.js new file mode 100644 index 0000000..de04ce8 --- /dev/null +++ b/dist/cjs/registry/schema.js @@ -0,0 +1,190 @@ +"use strict"; +// Values checked at run time and typed at compile time. A descriptor states its property values, directive payloads +// 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; +/** A value that does not fit its schema: path is where ("payload.targetSetpoint.scale"), problem is what. */ +class SchemaError extends Error { + constructor(path, problem) { + super(path ? `${path}: ${problem}` : problem); + this.path = path; + this.problem = problem; + this.name = "SchemaError"; + } +} +exports.SchemaError = SchemaError; +// The input as an error message shows it: short, and quoted when it is text. +function shown(input) { + if (input === undefined) + return "nothing"; + if (typeof input === "function") + return "a function"; + const text = JSON.stringify(input) ?? String(input); + return text.length > 60 ? `${text.slice(0, 57)}...` : text; +} +function mismatch(path, expects, input) { + return new SchemaError(path, `expected ${expects}, got ${shown(input)}`); +} +const at = (path, key) => (path ? `${path}.${key}` : key); +function isRecord(input) { + return typeof input === "object" && input !== null && !Array.isArray(input); +} +function schema(expects, accepts) { + return { + expects, + parse(input, path = "") { + if (!accepts(input)) + throw mismatch(path, expects, input); + return input; + }, + }; +} +function numberText({ min, max, gt, integer }) { + const kind = integer ? "an integer" : "a number"; + if (min !== undefined && max !== undefined) + return `${kind} from ${min} to ${max}`; + if (min !== undefined) + return `${kind} of ${min} or more`; + if (max !== undefined) + return `${kind} of ${max} or less`; + if (gt !== undefined) + return `${kind} greater than ${gt}`; + return kind; +} +function number(rules = {}) { + const { min = -Infinity, max = Infinity, gt = -Infinity, integer = false } = rules; + return schema(numberText(rules), (input) => typeof input === "number" && Number.isFinite(input) && input >= min && input <= max && input > gt + && (!integer || Number.isInteger(input))); +} +function string(rules = {}) { + const { min = 0, max = Infinity, pattern } = rules; + const length = max === Infinity ? (min > 0 ? ` of ${min} or more characters` : "") : ` of ${min} to ${max} characters`; + return schema(rules.expects ?? `a string${length}`, (input) => typeof input === "string" && input.length >= min && input.length <= max && (!pattern || pattern.test(input))); +} +function boolean() { + return schema("true or false", (input) => typeof input === "boolean"); +} +function literal(value) { + return schema(JSON.stringify(value), (input) => input === value); +} +function enumeration(...values) { + return { ...schema(values.join(" | "), (input) => values.includes(input)), values }; +} +function unknown() { + return schema("any value", () => true); +} +function optional(inner) { + return { + expects: inner.expects, + optional: true, + parse: (input, path = "") => (input === undefined ? undefined : inner.parse(input, path)), + }; +} +function nullable(inner) { + const expects = `${inner.expects} or null`; + return { + expects, + parse(input, path = "") { + if (input === null) + return null; + try { + return inner.parse(input, path); + } + catch (err) { + // The value itself is of the wrong kind: null was a choice too. An error further in keeps its own path. + if (err instanceof SchemaError && err.path === path) + throw mismatch(path, expects, input); + throw err; + } + }, + }; +} +function array(item, rules = {}) { + const { min = 0, max = Infinity } = rules; + const count = max === Infinity ? (min > 0 ? ` with ${min} or more entries` : "") : ` with ${min} to ${max} entries`; + const expects = `a list${count}`; + return { + expects, + parse(input, path = "") { + if (!Array.isArray(input) || input.length < min || input.length > max) + throw mismatch(path, expects, input); + return input.map((entry, i) => item.parse(entry, `${path}[${i}]`)); + }, + }; +} +/** + * An object with the keys of shape. Keys the shape does not name are kept as they are: a field Alexa adds to a + * directive reaches the handler. unknownKeys "reject" is for what a developer writes, where such a key is a typo. + */ +function object(shape, rules = {}) { + const known = Object.keys(shape); + const expects = known.length > 0 ? `an object with ${known.join(", ")}` : "an object"; + return { + expects, + parse(input, path = "") { + if (!isRecord(input)) + 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"}`); + } + const parsed = {}; + for (const key of known) { + const value = shape[key].parse(input[key], at(path, key)); + if (value !== undefined) + parsed[key] = value; + } + for (const key of others) + parsed[key] = input[key]; + return parsed; + }, + }; +} +// alexa-property-schemas.html "Temperature", "Temperature scales" +function temperature() { + return object({ value: number(), scale: enumeration("CELSIUS", "FAHRENHEIT", "KELVIN") }); +} +// alexa-property-schemas.html "DateTime": UTC, no offsets. The seconds are optional here because the TimeInterval +// examples on the same page leave them out ("2017-10-04T14:00Z"). +const DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?Z$/; +function dateTime() { + return schema("a UTC time like 2017-08-30T01:18:21Z", (input) => typeof input === "string" && DATE_TIME.test(input) && !Number.isNaN(Date.parse(input))); +} +// alexa-property-schemas.html "Duration": the time portion of ISO 8601, negative for a delta ("PT-30S") +const DURATION = /^PT(?=.)(-?\d+H)?(-?\d+M)?(-?\d+S)?$/; +function duration() { + return string({ pattern: DURATION, expects: "a duration like PT3M15S" }); +} +// alexa-property-schemas.html "TimeInterval": "Specify one or two of the time interval fields. If you specify all +// three fields, an error occurs." +function timeInterval() { + const fields = object({ start: optional(dateTime()), end: optional(dateTime()), duration: optional(duration()) }); + const expects = "a time interval with one or two of start, end, duration"; + return { + expects, + parse(input, path = "") { + const interval = fields.parse(input, path); + const given = [interval.start, interval.end, interval.duration].filter((field) => field !== undefined).length; + if (given < 1 || given > 2) + throw mismatch(path, expects, input); + return interval; + }, + }; +} +exports.s = { + string, + number, + boolean, + literal, + enum: enumeration, + unknown, + optional, + nullable, + array, + object, + temperature, + dateTime, + duration, + timeInterval, +}; diff --git a/dist/cjs/registry/types.js b/dist/cjs/registry/types.js new file mode 100644 index 0000000..c0a1a07 --- /dev/null +++ b/dist/cjs/registry/types.js @@ -0,0 +1,25 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.DeclarationError = void 0; +exports.defineInterface = defineInterface; +/** Types a descriptor from what it is given: the property names, the payload of each directive, the options. */ +function defineInterface(descriptor) { + return descriptor; +} +/** A device, a capability or an interface name declared in a way Alexa would reject. Thrown where it is declared. */ +class DeclarationError extends Error { + constructor(where, problem) { + const capability = where.namespace && where.instance ? `${where.namespace} "${where.instance}"` : where.namespace; + super([where.endpointId && shortened(where.endpointId), capability, problem].filter(Boolean).join(": ")); + this.name = "DeclarationError"; + this.endpointId = where.endpointId; + this.namespace = where.namespace; + this.instance = where.instance || undefined; + this.problem = problem; + } +} +exports.DeclarationError = DeclarationError; +// An endpointId may be the very thing that is wrong, 300 characters of it. +function shortened(text) { + return text.length > 64 ? `${text.slice(0, 61)}...` : text; +} diff --git a/dist/esm/AlexaInterface.d.ts b/dist/esm/AlexaInterface.d.ts index 9efe0f7..c0a4e64 100644 --- a/dist/esm/AlexaInterface.d.ts +++ b/dist/esm/AlexaInterface.d.ts @@ -101,7 +101,9 @@ export declare class AlexaInterface { 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/AlexaInterface.js b/dist/esm/AlexaInterface.js index 38a5820..e5c5940 100644 --- a/dist/esm/AlexaInterface.js +++ b/dist/esm/AlexaInterface.js @@ -1,3 +1,4 @@ +import { registry } from "./registry/index.js"; export var AlexaInterfaceType; (function (AlexaInterfaceType) { AlexaInterfaceType["APPLICATION_STATE_REPORTER"] = "Alexa.ApplicationStateReporter"; @@ -100,170 +101,13 @@ export class AlexaInterface { getTypeString() { return this.type; } + /** The version of the interface, from its descriptor. "UNKNOWN" for a name the registry does not have. */ getVersion() { - switch (this.type) { - case AlexaInterfaceType.APPLICATION_STATE_REPORTER: - case AlexaInterfaceType.AUDIO_PLAY_QUEUE: - case AlexaInterfaceType.AUTHORIZATION_CONTROLLER: - case AlexaInterfaceType.AUTOMATION_MANAGEMENT: - case AlexaInterfaceType.AUTOMOTIVE_VEHICLE_DATA: - case AlexaInterfaceType.COMMISSIONABLE: - case AlexaInterfaceType.CONSENT_MANAGEMENT_CONSENT_REQUIRED_REPORTER: - case AlexaInterfaceType.COOKING: - case AlexaInterfaceType.COOKING_FOOD_TEMPERATURE_CONTROLLER: - case AlexaInterfaceType.COOKING_FOOD_TEMPERATURE_SENSOR: - case AlexaInterfaceType.COOKING_PRESET_CONTROLLER: - case AlexaInterfaceType.COOKING_TEMPERATURE_CONTROLLER: - case AlexaInterfaceType.COOKING_TEMPERATURE_SENSOR: - case AlexaInterfaceType.COOKING_TIME_CONTROLLER: - case AlexaInterfaceType.DATA_CONTROLLER: - case AlexaInterfaceType.DEVICE_USAGE_ESTIMATION: - case AlexaInterfaceType.DEVICE_USAGE_METER: - case AlexaInterfaceType.EQUALIZER_CONTROLLER: - case AlexaInterfaceType.INVENTORY_LEVEL_SENSOR: - case AlexaInterfaceType.INVENTORY_LEVEL_USAGE_SENSOR: - case AlexaInterfaceType.INVENTORY_USAGE_SENSOR: - case AlexaInterfaceType.KEYPAD_CONTROLLER: - case AlexaInterfaceType.MEDIA_PLAYBACK: - case AlexaInterfaceType.MEDIA_PLAY_QUEUE: - case AlexaInterfaceType.MEDIA_SEARCH: - case AlexaInterfaceType.PLAYBACK_STATE_REPORTER: - case AlexaInterfaceType.PROACTIVE_NOTIFICATION_SOURCE: - case AlexaInterfaceType.REMOTE_VIDEO_PLAYER: - case AlexaInterfaceType.RTC_SESSION_CONTROLLER: - case AlexaInterfaceType.SECURITY_PANEL_CONTROLLER: - case AlexaInterfaceType.SECURITY_PANEL_CONTROLLER_ALERT: - case AlexaInterfaceType.SIMPLE_EVENT_SOURCE: - case AlexaInterfaceType.SMART_VISION_OBJECT_DETECTION_SENSOR: - case AlexaInterfaceType.SMART_VISION_SNAPSHOT_PROVIDER: - case AlexaInterfaceType.SPEAKER: - case AlexaInterfaceType.STEP_SPEAKER: - case AlexaInterfaceType.THERMOSTAT_CONTROLLER_CONFIGURATION: - case AlexaInterfaceType.THERMOSTAT_CONTROLLER_HVAC_COMPONENTS: - case AlexaInterfaceType.THERMOSTAT_CONTROLLER_SCHEDULE: - case AlexaInterfaceType.UI_CONTROLLER: - case AlexaInterfaceType.USER_PREFERENCE: - case AlexaInterfaceType.VIDEO_RECORDER: - case AlexaInterfaceType.WAKE_ON_LAN_CONTROLLER: - return "1"; - case AlexaInterfaceType.BRIGHTNESS_CONTROLLER: - case AlexaInterfaceType.CAMERA_STREAM_CONTROLLER: - case AlexaInterfaceType.CHANNEL_CONTROLLER: - case AlexaInterfaceType.COLOR_CONTROLLER: - case AlexaInterfaceType.COLOR_TEMPERATURE_CONTROLLER: - case AlexaInterfaceType.CONTACT_SENSOR: - case AlexaInterfaceType.DOORBELL_EVENT_SOURCE: - case AlexaInterfaceType.INPUT_CONTROLLER: - case AlexaInterfaceType.LOCK_CONTROLLER: - case AlexaInterfaceType.MODE_CONTROLLER: - case AlexaInterfaceType.MOTION_SENSOR: - case AlexaInterfaceType.PERCENTAGE_CONTROLLER: - case AlexaInterfaceType.PLAYBACK_CONTROLLER: - case AlexaInterfaceType.POWER_CONTROLLER: - case AlexaInterfaceType.POWER_LEVEL_CONTROLLER: - case AlexaInterfaceType.RANGE_CONTROLLER: - case AlexaInterfaceType.RECORD_CONTROLLER: - case AlexaInterfaceType.SCENE_CONTROLLER: - case AlexaInterfaceType.SEEK_CONTROLLER: - case AlexaInterfaceType.TEMPERATURE_SENSOR: - case AlexaInterfaceType.TOGGLE_CONTROLLER: - return "3"; - case AlexaInterfaceType.LAUNCHER: - return "1.1"; - case AlexaInterfaceType.THERMOSTAT_CONTROLLER: - return "3.2"; - case AlexaInterfaceType.ENDPOINT_HEALTH: - return "3.3"; - default: - return "UNKNOWN"; - } + return registry.has(this.type) ? registry.get(this.type).version : "UNKNOWN"; } + /** The names of the properties the interface reports, from its descriptor. */ getProps() { - switch (this.type) { - case AlexaInterfaceType.AUTOMATION_MANAGEMENT: - return ["automationStatuses"]; - case AlexaInterfaceType.BRIGHTNESS_CONTROLLER: - return ["brightness"]; - case AlexaInterfaceType.CHANNEL_CONTROLLER: - return ["channel"]; - case AlexaInterfaceType.COLOR_CONTROLLER: - return ["color"]; - case AlexaInterfaceType.COLOR_TEMPERATURE_CONTROLLER: - return ["colorTemperatureInKelvin"]; - case AlexaInterfaceType.CONTACT_SENSOR: - return ["detectionState"]; - case AlexaInterfaceType.POWER_CONTROLLER: - return ["powerState"]; - case AlexaInterfaceType.ENDPOINT_HEALTH: - return ["connectivity"]; - case AlexaInterfaceType.TOGGLE_CONTROLLER: - return ["toggleState"]; - case AlexaInterfaceType.MODE_CONTROLLER: - return ["mode"]; - case AlexaInterfaceType.TEMPERATURE_SENSOR: - return ["temperature"]; - case AlexaInterfaceType.THERMOSTAT_CONTROLLER: - return [ - "targetSetpoint", - "lowerSetpoint", - "upperSetpoint", - "thermostatMode", - ]; - case AlexaInterfaceType.APPLICATION_STATE_REPORTER: - case AlexaInterfaceType.AUDIO_PLAY_QUEUE: - case AlexaInterfaceType.AUTHORIZATION_CONTROLLER: - case AlexaInterfaceType.AUTOMOTIVE_VEHICLE_DATA: - case AlexaInterfaceType.CAMERA_LIVE_VIEW_CONTROLLER: - case AlexaInterfaceType.CAMERA_STREAM_CONTROLLER: - case AlexaInterfaceType.COMMISSIONABLE: - case AlexaInterfaceType.CONSENT_MANAGEMENT_CONSENT_REQUIRED_REPORTER: - case AlexaInterfaceType.COOKING: - case AlexaInterfaceType.DATA_CONTROLLER: - case AlexaInterfaceType.DEVICE_USAGE_ESTIMATION: - case AlexaInterfaceType.DEVICE_USAGE_METER: - case AlexaInterfaceType.DOORBELL_EVENT_SOURCE: - case AlexaInterfaceType.EQUALIZER_CONTROLLER: - case AlexaInterfaceType.INPUT_CONTROLLER: - case AlexaInterfaceType.INVENTORY_LEVEL_SENSOR: - case AlexaInterfaceType.INVENTORY_LEVEL_USAGE_SENSOR: - case AlexaInterfaceType.INVENTORY_USAGE_SENSOR: - case AlexaInterfaceType.KEYPAD_CONTROLLER: - case AlexaInterfaceType.LAUNCHER: - case AlexaInterfaceType.LOCK_CONTROLLER: - case AlexaInterfaceType.MEDIA_PLAYBACK: - case AlexaInterfaceType.MEDIA_SEARCH: - case AlexaInterfaceType.MOTION_SENSOR: - case AlexaInterfaceType.PERCENTAGE_CONTROLLER: - case AlexaInterfaceType.PLAYBACK_CONTROLLER: - case AlexaInterfaceType.PLAYBACK_STATE_REPORTER: - case AlexaInterfaceType.PROACTIVE_NOTIFICATION_SOURCE: - case AlexaInterfaceType.RANGE_CONTROLLER: - case AlexaInterfaceType.RECORD_CONTROLLER: - case AlexaInterfaceType.REMOTE_VIDEO_PLAYER: - case AlexaInterfaceType.RTC_SESSION_CONTROLLER: - case AlexaInterfaceType.SCENE_CONTROLLER: - return []; // scenes have no reportable properties: Activate/Deactivate answer with ActivationStarted (Device.sendSceneResponse) - case AlexaInterfaceType.SECURITY_PANEL_CONTROLLER: - case AlexaInterfaceType.SEEK_CONTROLLER: - case AlexaInterfaceType.SIMPLE_EVENT_SOURCE: - case AlexaInterfaceType.SMART_VISION_OBJECT_DETECTION_SENSOR: - case AlexaInterfaceType.SMART_VISION_SNAPSHOT_PROVIDER: - case AlexaInterfaceType.SPEAKER: - case AlexaInterfaceType.STEP_SPEAKER: - case AlexaInterfaceType.THERMOSTAT_CONTROLLER_CONFIGURATION: - case AlexaInterfaceType.THERMOSTAT_CONTROLLER_HVAC_COMPONENTS: - case AlexaInterfaceType.THERMOSTAT_CONTROLLER_SCHEDULE: - case AlexaInterfaceType.TIME_HOLD_CONTROLLER: - case AlexaInterfaceType.UI_CONTROLLER: - case AlexaInterfaceType.USER_PREFERENCE: - case AlexaInterfaceType.VIDEO_RECORDER: - case AlexaInterfaceType.WAKE_ON_LAN_CONTROLLER: - // no property list known for these: discovery lists the capability with an empty "supported" (the bridge notes - // it through its log hook; 1.5.1 wrote "UNSUPORTED INTERFACE TYPE" to stderr on every discovery) - return []; - default: - return []; - } + return registry.has(this.type) ? Object.keys(registry.get(this.type).properties) : []; } getJSON() { const doc = { diff --git a/dist/esm/DisplayCategory.d.ts b/dist/esm/DisplayCategory.d.ts index c70eeea..ee7b947 100644 --- a/dist/esm/DisplayCategory.d.ts +++ b/dist/esm/DisplayCategory.d.ts @@ -51,6 +51,8 @@ export declare enum DisplayCategory { 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", diff --git a/dist/esm/DisplayCategory.js b/dist/esm/DisplayCategory.js index 9b5c3cd..15ca7d9 100644 --- a/dist/esm/DisplayCategory.js +++ b/dist/esm/DisplayCategory.js @@ -52,6 +52,8 @@ export var DisplayCategory; 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"; diff --git a/dist/esm/index.d.ts b/dist/esm/index.d.ts index 7b539f6..5e81aa1 100644 --- a/dist/esm/index.d.ts +++ b/dist/esm/index.d.ts @@ -8,3 +8,6 @@ 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 { 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 5cacdbd..8fd8dd3 100644 --- a/dist/esm/index.js +++ b/dist/esm/index.js @@ -6,3 +6,6 @@ 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 { 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"; diff --git a/dist/esm/registry/catalog.d.ts b/dist/esm/registry/catalog.d.ts new file mode 100644 index 0000000..789f869 --- /dev/null +++ b/dist/esm/registry/catalog.d.ts @@ -0,0 +1,40 @@ +/** + * The asset ids a friendly name can refer to: 103, the units of measure among them + * (resources-and-assets.html, "Global Alexa catalog"). + */ +export declare const ASSETS: readonly ["Alexa.Actions.Charge", "Alexa.Actions.Clean", "Alexa.Actions.Dispense", "Alexa.Actions.Dock", "Alexa.Actions.Empty", "Alexa.Actions.Mop", "Alexa.Actions.Skip", "Alexa.Actions.Sweep", "Alexa.Actions.Vacuum", "Alexa.Button.OffButton", "Alexa.Button.OnButton", "Alexa.Button.BrightenButton", "Alexa.Button.DimButton", "Alexa.Button.MainButton", "Alexa.Button.TopButton", "Alexa.Button.BottomButton", "Alexa.Button.CenterButton", "Alexa.Button.MiddleButton", "Alexa.Button.UpButton", "Alexa.Button.DownButton", "Alexa.Button.LeftButton", "Alexa.Button.RightButton", "Alexa.Button.FirstButton", "Alexa.Button.SecondButton", "Alexa.Button.ThirdButton", "Alexa.Button.FourthButton", "Alexa.Button.FifthButton", "Alexa.Button.SixthButton", "Alexa.Button.SeventhButton", "Alexa.Button.EighthButton", "Alexa.Button.DoublePress", "Alexa.Button.DoublePush", "Alexa.Button.LongPress", "Alexa.Button.LongPush", "Alexa.Button.SinglePress", "Alexa.Button.SinglePush", "Alexa.DeviceName.AirPurifier", "Alexa.DeviceName.Camera", "Alexa.DeviceName.Fan", "Alexa.DeviceName.Router", "Alexa.DeviceName.Shade", "Alexa.DeviceName.Shower", "Alexa.DeviceName.SpaceHeater", "Alexa.DeviceName.Washer", "Alexa.Gesture.DoubleClick", "Alexa.Gesture.SingleClick", "Alexa.Gesture.SwipeDown", "Alexa.Gesture.SwipeLeft", "Alexa.Gesture.SwipeRight", "Alexa.Gesture.SwipeUp", "Alexa.Gesture.Tap", "Alexa.Gestures.DoubleTap", "Alexa.Setting.2GGuestWiFi", "Alexa.Setting.5GGuestWiFi", "Alexa.Setting.Auto", "Alexa.Setting.Direction", "Alexa.Setting.DryCycle", "Alexa.Setting.FanSpeed", "Alexa.Setting.GuestWiFi", "Alexa.Setting.Heat", "Alexa.Setting.Mode", "Alexa.Setting.Night", "Alexa.Setting.Opening", "Alexa.Setting.Oscillate", "Alexa.Setting.Preset", "Alexa.Setting.Quiet", "Alexa.Setting.Temperature", "Alexa.Setting.WashCycle", "Alexa.Setting.WaterTemperature", "Alexa.Shower.HandHeld", "Alexa.Shower.RainHead", "Alexa.Unit.Angle.Degrees", "Alexa.Unit.Angle.Radians", "Alexa.Unit.Distance.Feet", "Alexa.Unit.Distance.Inches", "Alexa.Unit.Distance.Kilometers", "Alexa.Unit.Distance.Meters", "Alexa.Unit.Distance.Miles", "Alexa.Unit.Distance.Yards", "Alexa.Unit.Mass.Grams", "Alexa.Unit.Mass.Kilograms", "Alexa.Unit.Percent", "Alexa.Unit.Temperature.Celsius", "Alexa.Unit.Temperature.Degrees", "Alexa.Unit.Temperature.Fahrenheit", "Alexa.Unit.Temperature.Kelvin", "Alexa.Unit.Volume.CubicFeet", "Alexa.Unit.Volume.CubicMeters", "Alexa.Unit.Volume.Gallons", "Alexa.Unit.Volume.Liters", "Alexa.Unit.Volume.Pints", "Alexa.Unit.Volume.Quarts", "Alexa.Unit.Weight.Ounces", "Alexa.Unit.Weight.Pounds", "Alexa.Value.Close", "Alexa.Value.Delicate", "Alexa.Value.High", "Alexa.Value.Low", "Alexa.Value.Maximum", "Alexa.Value.Medium", "Alexa.Value.Minimum", "Alexa.Value.Open", "Alexa.Value.QuickWash"]; +export type AssetId = (typeof ASSETS)[number]; +/** A unit of measure: the 23 Alexa.Unit.* assets, what a RangeController's unitOfMeasure takes. */ +export type UnitOfMeasure = Extract; +export declare const UNITS_OF_MEASURE: UnitOfMeasure[]; +/** The phrases an action mapping gives to a directive (alexa-discovery-objects.html, "ActionMappings object"). */ +export declare const ACTIONS: readonly ["Alexa.Actions.Open", "Alexa.Actions.Close", "Alexa.Actions.Raise", "Alexa.Actions.Lower", "Alexa.Actions.SetEcoOn", "Alexa.Actions.SetEcoOff"]; +export type ActionId = (typeof ACTIONS)[number]; +/** The states a state mapping gives to a property value (alexa-discovery-objects.html, "StateMappings object"). */ +export declare const STATES: readonly ["Alexa.States.Open", "Alexa.States.Closed", "Alexa.States.EcoOn", "Alexa.States.EcoOff", "Alexa.States.Low", "Alexa.States.Empty", "Alexa.States.Full", "Alexa.States.Done", "Alexa.States.Stuck"]; +export type StateId = (typeof STATES)[number]; +/** + * The 56 display categories (alexa-discovery.html, "Display categories"). The DisplayCategory enum also keeps + * VEHICLE from 1.x, which the page no longer lists. + */ +export declare const DISPLAY_CATEGORIES: readonly ["ACTIVITY_TRIGGER", "AIR_CONDITIONER", "AIR_FRESHENER", "AIR_PURIFIER", "AIR_QUALITY_MONITOR", "ALEXA_VOICE_ENABLED", "AUTO_ACCESSORY", "BLUETOOTH_SPEAKER", "CAMERA", "CHRISTMAS_TREE", "COFFEE_MAKER", "COMPUTER", "CONTACT_SENSOR", "DISHWASHER", "DOOR", "DOORBELL", "DRYER", "EXTERIOR_BLIND", "FAN", "GAME_CONSOLE", "GARAGE_DOOR", "HEADPHONES", "HUB", "INTERIOR_BLIND", "LAPTOP", "LIGHT", "MICROWAVE", "MOBILE_PHONE", "MOTION_SENSOR", "MUSIC_SYSTEM", "NETWORK_HARDWARE", "OTHER", "OVEN", "PHONE", "PRINTER", "REMOTE", "ROUTER", "SCENE_TRIGGER", "SCREEN", "SECURITY_PANEL", "SECURITY_SYSTEM", "SLOW_COOKER", "SMARTLOCK", "SMARTPLUG", "SPEAKER", "STREAMING_DEVICE", "SWITCH", "TABLET", "TEMPERATURE_SENSOR", "THERMOSTAT", "TV", "VACUUM_CLEANER", "VACUUM", "WASHER", "WATER_HEATER", "WEARABLE"]; +export type DisplayCategoryName = (typeof DISPLAY_CATEGORIES)[number]; +/** Not to be used as a friendly name (resources-and-assets.html, "Reserved words"). */ +export declare const RESERVED_WORDS: readonly ["alarm", "alarms", "all alarms", "away mode", "bass", "camera", "date", "date today", "day", "do not disturb", "drop in", "music", "night light", "notification", "playing", "sleep sounds", "time", "timer", "today in music", "treble", "volume", "way f. m."]; +/** + * What a discovery answer may hold (alexa-discovery.html, "Interface limits"; alexa-discovery-objects.html, + * "Endpoint object details" and "AdditionalAttributes object details"; alexa-scenecontroller.html, "Discovery"). + */ +export declare const LIMITS: { + readonly endpointsPerCustomer: 300; + readonly capabilitiesPerEndpoint: 100; + readonly endpointIdLength: 256; + readonly friendlyNameLength: 256; + readonly sceneFriendlyNameLength: 128; + readonly manufacturerNameLength: 128; + readonly descriptionLength: 128; + readonly additionalAttributeLength: 256; + readonly cookieBytes: 5000; +}; +/** The 73 error types of the table, each with the namespace its ErrorResponse goes under. */ +export declare const ERROR_TYPES: Readonly>; diff --git a/dist/esm/registry/catalog.js b/dist/esm/registry/catalog.js new file mode 100644 index 0000000..854b016 --- /dev/null +++ b/dist/esm/registry/catalog.js @@ -0,0 +1,129 @@ +// The fixed vocabularies of the Smart Home API, copied from Amazon's pages as read on 2026-09-28. The pages are under +// https://developer.amazon.com/docs/alexaplus/device-apis/. A value missing here is one Amazon added since. +/** + * The asset ids a friendly name can refer to: 103, the units of measure among them + * (resources-and-assets.html, "Global Alexa catalog"). + */ +export const ASSETS = [ + "Alexa.Actions.Charge", "Alexa.Actions.Clean", "Alexa.Actions.Dispense", "Alexa.Actions.Dock", + "Alexa.Actions.Empty", "Alexa.Actions.Mop", "Alexa.Actions.Skip", "Alexa.Actions.Sweep", "Alexa.Actions.Vacuum", + "Alexa.Button.OffButton", "Alexa.Button.OnButton", "Alexa.Button.BrightenButton", "Alexa.Button.DimButton", + "Alexa.Button.MainButton", "Alexa.Button.TopButton", "Alexa.Button.BottomButton", "Alexa.Button.CenterButton", + "Alexa.Button.MiddleButton", "Alexa.Button.UpButton", "Alexa.Button.DownButton", "Alexa.Button.LeftButton", + "Alexa.Button.RightButton", "Alexa.Button.FirstButton", "Alexa.Button.SecondButton", "Alexa.Button.ThirdButton", + "Alexa.Button.FourthButton", "Alexa.Button.FifthButton", "Alexa.Button.SixthButton", "Alexa.Button.SeventhButton", + "Alexa.Button.EighthButton", "Alexa.Button.DoublePress", "Alexa.Button.DoublePush", "Alexa.Button.LongPress", + "Alexa.Button.LongPush", "Alexa.Button.SinglePress", "Alexa.Button.SinglePush", + "Alexa.DeviceName.AirPurifier", "Alexa.DeviceName.Camera", "Alexa.DeviceName.Fan", "Alexa.DeviceName.Router", + "Alexa.DeviceName.Shade", "Alexa.DeviceName.Shower", "Alexa.DeviceName.SpaceHeater", "Alexa.DeviceName.Washer", + "Alexa.Gesture.DoubleClick", "Alexa.Gesture.SingleClick", "Alexa.Gesture.SwipeDown", "Alexa.Gesture.SwipeLeft", + "Alexa.Gesture.SwipeRight", "Alexa.Gesture.SwipeUp", "Alexa.Gesture.Tap", + // "Gestures", plural: the page spells this one id differently from the seven above + "Alexa.Gestures.DoubleTap", + "Alexa.Setting.2GGuestWiFi", "Alexa.Setting.5GGuestWiFi", "Alexa.Setting.Auto", "Alexa.Setting.Direction", + "Alexa.Setting.DryCycle", "Alexa.Setting.FanSpeed", "Alexa.Setting.GuestWiFi", "Alexa.Setting.Heat", + "Alexa.Setting.Mode", "Alexa.Setting.Night", "Alexa.Setting.Opening", "Alexa.Setting.Oscillate", + "Alexa.Setting.Preset", "Alexa.Setting.Quiet", "Alexa.Setting.Temperature", "Alexa.Setting.WashCycle", + "Alexa.Setting.WaterTemperature", + "Alexa.Shower.HandHeld", "Alexa.Shower.RainHead", + "Alexa.Unit.Angle.Degrees", "Alexa.Unit.Angle.Radians", "Alexa.Unit.Distance.Feet", "Alexa.Unit.Distance.Inches", + "Alexa.Unit.Distance.Kilometers", "Alexa.Unit.Distance.Meters", "Alexa.Unit.Distance.Miles", + "Alexa.Unit.Distance.Yards", "Alexa.Unit.Mass.Grams", "Alexa.Unit.Mass.Kilograms", "Alexa.Unit.Percent", + "Alexa.Unit.Temperature.Celsius", "Alexa.Unit.Temperature.Degrees", "Alexa.Unit.Temperature.Fahrenheit", + "Alexa.Unit.Temperature.Kelvin", "Alexa.Unit.Volume.CubicFeet", "Alexa.Unit.Volume.CubicMeters", + "Alexa.Unit.Volume.Gallons", "Alexa.Unit.Volume.Liters", "Alexa.Unit.Volume.Pints", "Alexa.Unit.Volume.Quarts", + "Alexa.Unit.Weight.Ounces", "Alexa.Unit.Weight.Pounds", + "Alexa.Value.Close", "Alexa.Value.Delicate", "Alexa.Value.High", "Alexa.Value.Low", "Alexa.Value.Maximum", + "Alexa.Value.Medium", "Alexa.Value.Minimum", "Alexa.Value.Open", "Alexa.Value.QuickWash", +]; +export const UNITS_OF_MEASURE = ASSETS.filter((id) => id.startsWith("Alexa.Unit.")); +/** The phrases an action mapping gives to a directive (alexa-discovery-objects.html, "ActionMappings object"). */ +export const ACTIONS = [ + "Alexa.Actions.Open", "Alexa.Actions.Close", "Alexa.Actions.Raise", "Alexa.Actions.Lower", + "Alexa.Actions.SetEcoOn", "Alexa.Actions.SetEcoOff", +]; +/** The states a state mapping gives to a property value (alexa-discovery-objects.html, "StateMappings object"). */ +export const STATES = [ + "Alexa.States.Open", "Alexa.States.Closed", "Alexa.States.EcoOn", "Alexa.States.EcoOff", "Alexa.States.Low", + "Alexa.States.Empty", "Alexa.States.Full", "Alexa.States.Done", "Alexa.States.Stuck", +]; +/** + * The 56 display categories (alexa-discovery.html, "Display categories"). The DisplayCategory enum also keeps + * VEHICLE from 1.x, which the page no longer lists. + */ +export const DISPLAY_CATEGORIES = [ + "ACTIVITY_TRIGGER", "AIR_CONDITIONER", "AIR_FRESHENER", "AIR_PURIFIER", "AIR_QUALITY_MONITOR", + "ALEXA_VOICE_ENABLED", "AUTO_ACCESSORY", "BLUETOOTH_SPEAKER", "CAMERA", "CHRISTMAS_TREE", "COFFEE_MAKER", + "COMPUTER", "CONTACT_SENSOR", "DISHWASHER", "DOOR", "DOORBELL", "DRYER", "EXTERIOR_BLIND", "FAN", "GAME_CONSOLE", + "GARAGE_DOOR", "HEADPHONES", "HUB", "INTERIOR_BLIND", "LAPTOP", "LIGHT", "MICROWAVE", "MOBILE_PHONE", + "MOTION_SENSOR", "MUSIC_SYSTEM", "NETWORK_HARDWARE", "OTHER", "OVEN", "PHONE", "PRINTER", "REMOTE", "ROUTER", + "SCENE_TRIGGER", "SCREEN", "SECURITY_PANEL", "SECURITY_SYSTEM", "SLOW_COOKER", "SMARTLOCK", "SMARTPLUG", "SPEAKER", + "STREAMING_DEVICE", "SWITCH", "TABLET", "TEMPERATURE_SENSOR", "THERMOSTAT", "TV", "VACUUM_CLEANER", "VACUUM", + "WASHER", "WATER_HEATER", "WEARABLE", +]; +/** Not to be used as a friendly name (resources-and-assets.html, "Reserved words"). */ +export const RESERVED_WORDS = [ + "alarm", "alarms", "all alarms", "away mode", "bass", "camera", "date", "date today", "day", "do not disturb", + "drop in", "music", "night light", "notification", "playing", "sleep sounds", "time", "timer", "today in music", + "treble", "volume", "way f. m.", +]; +/** + * What a discovery answer may hold (alexa-discovery.html, "Interface limits"; alexa-discovery-objects.html, + * "Endpoint object details" and "AdditionalAttributes object details"; alexa-scenecontroller.html, "Discovery"). + */ +export const LIMITS = { + endpointsPerCustomer: 300, + capabilitiesPerEndpoint: 100, + endpointIdLength: 256, + friendlyNameLength: 256, + sceneFriendlyNameLength: 128, + manufacturerNameLength: 128, + descriptionLength: 128, + additionalAttributeLength: 256, + cookieBytes: 5000, +}; +// alexa-errorresponse.html, "Error type values". The Interface column names .ErrorResponse; the event +// header carries the namespace and the name ErrorResponse, as the examples on the ThermostatController, +// SecurityPanelController and Safety error pages show. The other namespaces are as the table spells them. +// INVALID_VALUE is listed under Alexa and under SmartVision.ObjectDetectionSensor, SUBSCRIPTION_REQUIRED under both +// SmartVision interfaces: the first one listed is kept. +const ERROR_TYPES_BY_NAMESPACE = { + "Alexa": [ + "ALREADY_IN_OPERATION", "BRIDGE_UNREACHABLE", "CLOUD_CONTROL_DISABLED", "DEVICE_STUCK", "DO_NOT_DISTURB_MODE", + "ENDPOINT_BUSY", "ENDPOINT_CONTROL_UNAVAILABLE", "ENDPOINT_LOW_POWER", "ENDPOINT_UNREACHABLE", + "EXPIRED_AUTHORIZATION_CREDENTIAL", "FIRMWARE_OUT_OF_DATE", "HARDWARE_MALFUNCTION", "INSUFFICIENT_PERMISSIONS", + "INSUFFICIENT_RESOURCE", "INTERNAL_ERROR", "INVALID_AUTHORIZATION_CREDENTIAL", "INVALID_DIRECTIVE", + "INVALID_VALUE", "MAINTENANCE_REQUIRED", "NO_SUCH_ENDPOINT", "NOT_CALIBRATED", "NOT_IN_OPERATION", + "NOT_SUPPORTED_IN_CURRENT_MODE", "NOT_SUPPORTED_WITH_CURRENT_BATTERY_CHARGE_STATE", + "PARTNER_APPLICATION_REDIRECTION", "POWER_LEVEL_NOT_SUPPORTED", "RATE_LIMIT_EXCEEDED", + "TEMPERATURE_VALUE_OUT_OF_RANGE", "TOO_MANY_FAILED_ATTEMPTS", "UNABLE_TO_CHARGE", "VALUE_OUT_OF_RANGE", + ], + "Alexa.Commissionable.ReportCommissioningInformation": [ + "FAILED_TO_BOOTSTRAP_COMMISSIONING_PROCESS", "MAX_COMMISSIONING_LIMIT_REACHED", + ], + "Alexa.Cooking": [ + "CHILD_LOCK", "COOK_DURATION_TOO_LONG", "DOOR_CLOSED_TOO_LONG", "DOOR_OPEN", "PREHEAT_REQUIRED", + "PROBE_REQUIRED", "REMOTE_START_NOT_SUPPORTED", "REMOVE_PROBE", "REMOTE_START_DISABLED", + ], + "Alexa.DataController": ["DATA_DELETION_NOT_SUPPORTED", "DATA_RETRIEVAL_NOT_SUPPORTED"], + "Alexa.Safety": ["OBSTACLE_DETECTED", "SAFETY_BEAM_BREACHED"], + "Alexa.SecurityPanelController": [ + "AUTHORIZATION_REQUIRED", "BYPASS_NEEDED", "NOT_READY", "UNAUTHORIZED", "UNCLEARED_ALARM", "UNCLEARED_TROUBLE", + ], + "Alexa.SmartVision.ObjectDetectionSensor": ["SUBSCRIPTION_REQUIRED"], + "Alexa.SmartVision.SnapshotProvider": ["DISABLED_BY_USER"], + "Alexa.ThermostatController": [ + "DUAL_SETPOINTS_UNSUPPORTED", "REQUESTED_SETPOINTS_TOO_CLOSE", "THERMOSTAT_IS_OFF", + "TRIPLE_SETPOINTS_UNSUPPORTED", "UNSUPPORTED_THERMOSTAT_MODE", "UNWILLING_TO_SET_SCHEDULE", + "UNWILLING_TO_SET_VALUE", + ], + "Alexa.ThermostatController.Configuration": [ + "CONFIGURATION_UPDATE_NOT_ALLOWED", "COOLING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE", + "COOLING_STAGES_EXCEEDS_LIMIT", "HEATING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE", "HEATING_STAGES_EXCEEDS_LIMIT", + "INVALID_AUXILIARY_HEATING_SYSTEM_TYPE", "INVALID_SYSTEM_TYPE", "INVALID_TARGET_STATE", + "INVALID_TEMPERATURE_SCALE", "INVALID_TERMINAL_CONNECTION", "MISSING_SETUP_INFORMATION", + ], + "Alexa.ThermostatController.Schedule": ["INSUFFICIENT_SPACE"], +}; +/** The 73 error types of the table, each with the namespace its ErrorResponse goes under. */ +export const ERROR_TYPES = Object.fromEntries(Object.entries(ERROR_TYPES_BY_NAMESPACE).flatMap(([namespace, types]) => types.map((type) => [type, namespace]))); diff --git a/dist/esm/registry/index.d.ts b/dist/esm/registry/index.d.ts new file mode 100644 index 0000000..895e580 --- /dev/null +++ b/dist/esm/registry/index.d.ts @@ -0,0 +1,20 @@ +import { Alexa } from "./interfaces/Alexa.js"; +import { BrightnessController } from "./interfaces/BrightnessController.js"; +import { EndpointHealth } from "./interfaces/EndpointHealth.js"; +import { PowerController } from "./interfaces/PowerController.js"; +import { TemperatureSensor } from "./interfaces/TemperatureSensor.js"; +import type { AnyDescriptor } from "./types.js"; +export declare const registry: { + /** Whether an interface of this name is known. */ + has(namespace: string): boolean; + /** The descriptor of "Alexa.RangeController" or of AlexaInterfaceType.RANGE_CONTROLLER, which is that string. */ + get(namespace: string): AnyDescriptor; + /** Every descriptor, ordered by namespace. */ + list(): AnyDescriptor[]; +}; +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 { s, SchemaError } from "./schema.js"; +export type { Infer, Schema, Temperature, TimeInterval } from "./schema.js"; +export * from "./catalog.js"; diff --git a/dist/esm/registry/index.js b/dist/esm/registry/index.js new file mode 100644 index 0000000..91dc8ba --- /dev/null +++ b/dist/esm/registry/index.js @@ -0,0 +1,43 @@ +// The interfaces the library knows, by namespace. +import { Alexa } from "./interfaces/Alexa.js"; +import { BrightnessController } from "./interfaces/BrightnessController.js"; +import { EndpointHealth } from "./interfaces/EndpointHealth.js"; +import { PowerController } from "./interfaces/PowerController.js"; +import { TemperatureSensor } from "./interfaces/TemperatureSensor.js"; +import { STUBS } from "./interfaces/stubs.js"; +import { DeclarationError } from "./types.js"; +const described = [ + Alexa, + BrightnessController, + EndpointHealth, + PowerController, + TemperatureSensor, +]; +const descriptors = new Map(); +for (const descriptor of [...described, ...STUBS]) { + // A stub left in the table next to the descriptor that replaces it would win or lose by the order of this list + if (descriptors.has(descriptor.namespace)) + throw new Error(`${descriptor.namespace} is described twice`); + descriptors.set(descriptor.namespace, descriptor); +} +export const registry = { + /** Whether an interface of this name is known. */ + has(namespace) { + return descriptors.has(namespace); + }, + /** The descriptor of "Alexa.RangeController" or of AlexaInterfaceType.RANGE_CONTROLLER, which is that string. */ + get(namespace) { + const descriptor = descriptors.get(namespace); + if (!descriptor) + throw new DeclarationError({}, `${JSON.stringify(namespace)} is not an interface alex2node knows`); + return descriptor; + }, + /** Every descriptor, ordered by namespace. */ + list() { + return [...descriptors.values()].sort((a, b) => (a.namespace < b.namespace ? -1 : 1)); + }, +}; +export { Alexa, BrightnessController, EndpointHealth, PowerController, TemperatureSensor }; +export { DeclarationError, defineInterface } from "./types.js"; +export { s, SchemaError } from "./schema.js"; +export * from "./catalog.js"; diff --git a/dist/esm/registry/interfaces/Alexa.d.ts b/dist/esm/registry/interfaces/Alexa.d.ts new file mode 100644 index 0000000..fe082f2 --- /dev/null +++ b/dist/esm/registry/interfaces/Alexa.d.ts @@ -0,0 +1,10 @@ +/** + * The base interface: every endpoint lists it (alexa-interface.html, "Support the Alexa interface in all add-ons"). + * Its one directive to a device is ReportState, answered with a StateReport. + */ +export declare const Alexa: import("../types.js").InterfaceDescriptor<{}, { + ReportState: { + name: string; + payload: import("../schema.js").Schema>; + }; +}, {}, false>; diff --git a/dist/esm/registry/interfaces/Alexa.js b/dist/esm/registry/interfaces/Alexa.js new file mode 100644 index 0000000..a059187 --- /dev/null +++ b/dist/esm/registry/interfaces/Alexa.js @@ -0,0 +1,20 @@ +import { s } from "../schema.js"; +import { defineInterface } from "../types.js"; +/** + * The base interface: every endpoint lists it (alexa-interface.html, "Support the Alexa interface in all add-ons"). + * Its one directive to a device is ReportState, answered with a StateReport. + */ +export const Alexa = defineInterface({ + namespace: "Alexa", + version: "3", + doc: "https://developer.amazon.com/docs/alexaplus/device-apis/alexa-interface.html", + kind: "base", + tier: 1, + instanced: false, + properties: {}, + directives: { + ReportState: { name: "ReportState", payload: s.object({}) }, + }, + // { type, interface, version } and nothing else, as in the discovery example of the page + discovery: () => ({ properties: false }), +}); diff --git a/dist/esm/registry/interfaces/BrightnessController.d.ts b/dist/esm/registry/interfaces/BrightnessController.d.ts new file mode 100644 index 0000000..9f45c39 --- /dev/null +++ b/dist/esm/registry/interfaces/BrightnessController.d.ts @@ -0,0 +1,20 @@ +/** Both directives turn a light that is off on, at the brightness asked for (alexa-brightnesscontroller.html). */ +export declare const BrightnessController: import("../types.js").InterfaceDescriptor<{ + brightness: { + name: string; + value: import("../schema.js").Schema; + }; +}, { + SetBrightness: { + name: string; + payload: import("../schema.js").Schema; + }>>; + }; + AdjustBrightness: { + name: string; + payload: import("../schema.js").Schema; + }>>; + }; +}, {}, false>; diff --git a/dist/esm/registry/interfaces/BrightnessController.js b/dist/esm/registry/interfaces/BrightnessController.js new file mode 100644 index 0000000..4e7e34a --- /dev/null +++ b/dist/esm/registry/interfaces/BrightnessController.js @@ -0,0 +1,24 @@ +import { s } from "../schema.js"; +import { defineInterface } from "../types.js"; +/** Both directives turn a light that is off on, at the brightness asked for (alexa-brightnesscontroller.html). */ +export const BrightnessController = defineInterface({ + namespace: "Alexa.BrightnessController", + version: "3", + doc: "https://developer.amazon.com/docs/alexaplus/device-apis/alexa-brightnesscontroller.html", + kind: "controller", + tier: 1, + instanced: false, + properties: { + brightness: { name: "brightness", value: s.number({ min: 0, max: 100, integer: true }) }, + }, + directives: { + SetBrightness: { + name: "SetBrightness", + payload: s.object({ brightness: s.number({ min: 0, max: 100, integer: true }) }), + }, + AdjustBrightness: { + name: "AdjustBrightness", + payload: s.object({ brightnessDelta: s.number({ min: -100, max: 100, integer: true }) }), + }, + }, +}); diff --git a/dist/esm/registry/interfaces/EndpointHealth.d.ts b/dist/esm/registry/interfaces/EndpointHealth.d.ts new file mode 100644 index 0000000..1d1621f --- /dev/null +++ b/dist/esm/registry/interfaces/EndpointHealth.d.ts @@ -0,0 +1,15 @@ +export declare const CONNECTIVITY_REASONS: readonly ["WIFI_BAD_PASSWORD", "WIFI_AP_NOT_FOUND", "WIFI_ROUTER_UNREACHABLE", "WIFI_AP_CHANNEL_QUALITY_LOW", "INTERNET_UNREACHABLE", "CAPTIVE_PORTAL_CHECK_FAILED", "UNKNOWN"]; +/** + * Version 3.1, as the page is titled. It defines connectivity only. Examples on other pages declare 3 and 3.2; + * alex2node 1.x declared 3.3, which no page mentions. + */ +export declare const EndpointHealth: import("../types.js").InterfaceDescriptor<{ + connectivity: { + name: string; + value: import("../schema.js").Schema; + reason: import("../schema.js").OptionalSchema<"WIFI_BAD_PASSWORD" | "WIFI_AP_NOT_FOUND" | "WIFI_ROUTER_UNREACHABLE" | "WIFI_AP_CHANNEL_QUALITY_LOW" | "INTERNET_UNREACHABLE" | "CAPTIVE_PORTAL_CHECK_FAILED" | "UNKNOWN">; + }>>; + note: string; + }; +}, {}, {}, false>; diff --git a/dist/esm/registry/interfaces/EndpointHealth.js b/dist/esm/registry/interfaces/EndpointHealth.js new file mode 100644 index 0000000..ed5938a --- /dev/null +++ b/dist/esm/registry/interfaces/EndpointHealth.js @@ -0,0 +1,29 @@ +import { s } from "../schema.js"; +import { defineInterface } from "../types.js"; +export const CONNECTIVITY_REASONS = [ + "WIFI_BAD_PASSWORD", "WIFI_AP_NOT_FOUND", "WIFI_ROUTER_UNREACHABLE", "WIFI_AP_CHANNEL_QUALITY_LOW", + "INTERNET_UNREACHABLE", "CAPTIVE_PORTAL_CHECK_FAILED", "UNKNOWN", +]; +/** + * Version 3.1, as the page is titled. It defines connectivity only. Examples on other pages declare 3 and 3.2; + * alex2node 1.x declared 3.3, which no page mentions. + */ +export const EndpointHealth = defineInterface({ + namespace: "Alexa.EndpointHealth", + version: "3.1", + doc: "https://developer.amazon.com/docs/alexaplus/device-apis/alexa-endpointhealth.html", + kind: "sensor", + tier: 1, + instanced: false, + properties: { + connectivity: { + name: "connectivity", + value: s.object({ + value: s.enum("OK", "UNREACHABLE"), + reason: s.optional(s.enum(...CONNECTIVITY_REASONS)), + }), + note: "in every Response, StateReport and ChangeReport; a change of it reported within three seconds", + }, + }, + directives: {}, +}); diff --git a/dist/esm/registry/interfaces/PowerController.d.ts b/dist/esm/registry/interfaces/PowerController.d.ts new file mode 100644 index 0000000..8d9efe1 --- /dev/null +++ b/dist/esm/registry/interfaces/PowerController.d.ts @@ -0,0 +1,17 @@ +export declare const PowerController: import("../types.js").InterfaceDescriptor<{ + powerState: { + name: string; + value: import("../schema.js").EnumSchema<"ON" | "OFF">; + }; +}, { + TurnOn: { + name: string; + payload: import("../schema.js").Schema>; + }; + TurnOff: { + name: string; + payload: import("../schema.js").Schema>; + }; +}, import("../schema.js").InferShape<{ + verificationsRequired: import("../schema.js").OptionalSchema<("TurnOn" | "TurnOff")[]>; +}>, false>; diff --git a/dist/esm/registry/interfaces/PowerController.js b/dist/esm/registry/interfaces/PowerController.js new file mode 100644 index 0000000..eecdc7e --- /dev/null +++ b/dist/esm/registry/interfaces/PowerController.js @@ -0,0 +1,28 @@ +import { s } from "../schema.js"; +import { defineInterface } from "../types.js"; +export const PowerController = defineInterface({ + namespace: "Alexa.PowerController", + version: "3", + doc: "https://developer.amazon.com/docs/alexaplus/device-apis/alexa-powercontroller.html", + kind: "controller", + tier: 1, + instanced: false, + properties: { + powerState: { name: "powerState", value: s.enum("ON", "OFF") }, + }, + directives: { + TurnOn: { name: "TurnOn", payload: s.object({}) }, + TurnOff: { name: "TurnOff", payload: s.object({}) }, + }, + options: s.object({ + // The directives Alexa asks the user to confirm before it sends them. Amazon supports this for devices in Japan + // only (alexa-discovery-objects.html, "VerificationsRequired object"). + verificationsRequired: s.optional(s.array(s.enum("TurnOn", "TurnOff"), { min: 1 })), + }, { unknownKeys: "reject" }), + discovery({ options }) { + if (!options.verificationsRequired) + return {}; + const confirmed = options.verificationsRequired.map((directive) => ({ directive, methods: [{ "@type": "Confirmation" }] })); + return { topLevel: { verificationsRequired: confirmed } }; + }, +}); diff --git a/dist/esm/registry/interfaces/TemperatureSensor.d.ts b/dist/esm/registry/interfaces/TemperatureSensor.d.ts new file mode 100644 index 0000000..ed3be04 --- /dev/null +++ b/dist/esm/registry/interfaces/TemperatureSensor.d.ts @@ -0,0 +1,7 @@ +/** A sensor: an endpoint that has it declares Alexa.EndpointHealth as well (alexa-temperaturesensor.html). */ +export declare const TemperatureSensor: import("../types.js").InterfaceDescriptor<{ + temperature: { + name: string; + value: import("../schema.js").Schema; + }; +}, {}, {}, false>; diff --git a/dist/esm/registry/interfaces/TemperatureSensor.js b/dist/esm/registry/interfaces/TemperatureSensor.js new file mode 100644 index 0000000..cc4a797 --- /dev/null +++ b/dist/esm/registry/interfaces/TemperatureSensor.js @@ -0,0 +1,15 @@ +import { s } from "../schema.js"; +import { defineInterface } from "../types.js"; +/** A sensor: an endpoint that has it declares Alexa.EndpointHealth as well (alexa-temperaturesensor.html). */ +export const TemperatureSensor = defineInterface({ + namespace: "Alexa.TemperatureSensor", + version: "3", + doc: "https://developer.amazon.com/docs/alexaplus/device-apis/alexa-temperaturesensor.html", + kind: "sensor", + tier: 1, + instanced: false, + properties: { + temperature: { name: "temperature", value: s.temperature() }, + }, + directives: {}, +}); diff --git a/dist/esm/registry/interfaces/stubs.d.ts b/dist/esm/registry/interfaces/stubs.d.ts new file mode 100644 index 0000000..0317d38 --- /dev/null +++ b/dist/esm/registry/interfaces/stubs.d.ts @@ -0,0 +1,2 @@ +import type { AnyDescriptor } from "../types.js"; +export declare const STUBS: readonly AnyDescriptor[]; diff --git a/dist/esm/registry/interfaces/stubs.js b/dist/esm/registry/interfaces/stubs.js new file mode 100644 index 0000000..98d3ca1 --- /dev/null +++ b/dist/esm/registry/interfaces/stubs.js @@ -0,0 +1,99 @@ +// Interfaces without a descriptor of their own. A stub names the interface with the version and the properties +// alex2node 1.5.2 put in discovery, so an endpoint that declares one is announced as before. Nothing about it is +// checked, and the version is the one 1.5.2 sent, which is not always the one on the page of the interface +// (Alexa.Speaker is "3" there). A row leaves this table when a descriptor is written from that page. +import { s } from "../schema.js"; +const DEVICE_APIS = "https://developer.amazon.com/docs/alexaplus/device-apis"; +const LIST_OF_INTERFACES = `${DEVICE_APIS}/list-of-interfaces.html`; +// "As of August 7, 2026, Automotive skills and the associated Alexa.AuthorizationController and +// Alexa.Automotive.VehicleData APIs are no longer available." +const DEPRECATED_FEATURES = "https://developer.amazon.com/en-US/docs/alexa/ask-overviews/deprecated-features.html"; +const TABLE = [ + ["Alexa.ApplicationStateReporter", "1", [], "https://developer.amazon.com/docs/alexaplus/alexa-voice-service/alexa-applicationstatereporter.html"], + ["Alexa.Audio.PlayQueue", "1", [], "alexa-audio-playqueue.html"], + ["Alexa.AuthorizationController", "1", [], DEPRECATED_FEATURES], + ["Alexa.AutomationManagement", "1", ["automationStatuses"], "alexa-automationmanagement.html"], + ["Alexa.Automotive.VehicleData", "1", [], DEPRECATED_FEATURES], + // 1.5.2 announced the version "UNKNOWN"; 1.7 is what the list of interfaces gives + ["Alexa.Camera.LiveViewController", "1.7", [], LIST_OF_INTERFACES], + ["Alexa.CameraStreamController", "3", [], "alexa-camerastreamcontroller.html"], + ["Alexa.ChannelController", "3", ["channel"], "alexa-channelcontroller.html"], + ["Alexa.ColorController", "3", ["color"], "alexa-colorcontroller.html"], + ["Alexa.ColorTemperatureController", "3", ["colorTemperatureInKelvin"], "alexa-colortemperaturecontroller.html"], + ["Alexa.Commissionable", "1", [], "alexa-commissionable.html"], + ["Alexa.ConsentManagement.ConsentRequiredReporter", "1", [], "alexa-consentrequiredreporter.html"], + ["Alexa.ContactSensor", "3", ["detectionState"], "alexa-contactsensor.html"], + ["Alexa.Cooking", "1", [], "alexa-cooking.html"], + ["Alexa.Cooking.FoodTemperatureController", "1", [], "alexa-cooking-foodtemperaturecontroller.html"], + ["Alexa.Cooking.FoodTemperatureSensor", "1", [], "alexa-cooking-foodtemperaturesensor.html"], + ["Alexa.Cooking.PresetController", "1", [], "alexa-cooking-presetcontroller.html"], + ["Alexa.Cooking.TemperatureController", "1", [], "alexa-cooking-temperaturecontroller.html"], + ["Alexa.Cooking.TemperatureSensor", "1", [], "alexa-cooking-temperaturesensor.html"], + ["Alexa.Cooking.TimeController", "1", [], "alexa-cooking-timecontroller.html"], + ["Alexa.DataController", "1", [], "alexa-datacontroller.html"], + ["Alexa.DeviceUsage.Estimation", "1", [], "alexa-deviceusage-estimation.html"], + ["Alexa.DeviceUsage.Meter", "1", [], "alexa-deviceusage-meter.html"], + ["Alexa.DoorbellEventSource", "3", [], "alexa-doorbelleventsource.html"], + ["Alexa.EqualizerController", "1", [], "alexa-equalizercontroller.html"], + ["Alexa.InputController", "3", [], "alexa-inputcontroller.html"], + ["Alexa.InventoryLevelSensor", "1", [], "alexa-inventorylevelsensor.html"], + ["Alexa.InventoryLevelUsageSensor", "1", [], "alexa-inventorylevelusagesensor.html"], + ["Alexa.InventoryUsageSensor", "1", [], "alexa-inventoryusagesensor.html"], + ["Alexa.KeypadController", "1", [], "alexa-keypadcontroller.html"], + ["Alexa.Launcher", "1.1", [], "alexa-launcher.html"], + ["Alexa.LockController", "3", [], "alexa-lockcontroller.html"], + ["Alexa.Media.PlayQueue", "1", [], "alexa-media-playqueue.html"], + ["Alexa.Media.Playback", "1", [], "alexa-media-playback.html"], + ["Alexa.Media.Search", "1", [], "alexa-media-search.html"], + ["Alexa.ModeController", "3", ["mode"], "alexa-modecontroller.html"], + ["Alexa.MotionSensor", "3", [], "alexa-motionsensor.html"], + ["Alexa.PercentageController", "3", [], "alexa-percentagecontroller.html"], + ["Alexa.PlaybackController", "3", [], "alexa-playbackcontroller.html"], + ["Alexa.PlaybackStateReporter", "1", [], "alexa-playbackcontroller.html"], + ["Alexa.PowerLevelController", "3", [], "alexa-powerlevelcontroller.html"], + ["Alexa.ProactiveNotificationSource", "1", [], "alexa-proactivenotificationsource.html"], + ["Alexa.RTCSessionController", "1", [], "alexa-rtcsessioncontroller.html"], + ["Alexa.RangeController", "3", [], "alexa-rangecontroller.html"], + ["Alexa.RecordController", "3", [], "alexa-recordcontroller.html"], + ["Alexa.RemoteVideoPlayer", "1", [], "alexa-remotevideoplayer.html"], + ["Alexa.SceneController", "3", [], "alexa-scenecontroller.html"], + ["Alexa.SecurityPanelController", "1", [], "alexa-securitypanelcontroller.html"], + ["Alexa.SecurityPanelController.Alert", "1", [], "alexa-securitypanelcontroller-alert.html"], + ["Alexa.SeekController", "3", [], "alexa-seekcontroller.html"], + ["Alexa.SimpleEventSource", "1", [], "alexa-simpleeventsource.html"], + ["Alexa.SmartVision.ObjectDetectionSensor", "1", [], "alexa-smartvision-objectdetectionsensor.html"], + ["Alexa.SmartVision.SnapshotProvider", "1", [], "alexa-smartvision-snapshotprovider.html"], + ["Alexa.Speaker", "1", [], "alexa-speaker.html"], + ["Alexa.StepSpeaker", "1", [], "alexa-stepspeaker.html"], + ["Alexa.ThermostatController", "3.2", ["targetSetpoint", "lowerSetpoint", "upperSetpoint", "thermostatMode"], "alexa-thermostatcontroller.html"], + ["Alexa.ThermostatController.Configuration", "1", [], "alexa-thermostatcontroller-configuration.html"], + ["Alexa.ThermostatController.HVAC.Components", "1", [], "alexa-thermostatcontroller-hvac-components.html"], + ["Alexa.ThermostatController.Schedule", "1", [], "alexa-thermostatcontroller-schedule.html"], + // "UNKNOWN" in 1.5.2 as well; the page is titled "Interface 3" + ["Alexa.TimeHoldController", "3", [], "alexa-timeholdcontroller.html"], + ["Alexa.ToggleController", "3", ["toggleState"], "alexa-togglecontroller.html"], + ["Alexa.UIController", "1", [], "alexa-uicontroller.html"], + ["Alexa.UserPreference", "1", [], "alexa-userpreference.html"], + ["Alexa.VideoRecorder", "1", [], "alexa-videorecorder.html"], + ["Alexa.WakeOnLANController", "1", [], "alexa-wakeonlancontroller.html"], +]; +function kindOf(namespace) { + if (namespace.endsWith("Sensor")) + return "sensor"; + if (namespace.endsWith("EventSource")) + return "eventSource"; + return "controller"; +} +function stub([namespace, version, properties, page]) { + return { + namespace, + version, + doc: page.startsWith("https://") ? page : `${DEVICE_APIS}/${page}`, + kind: kindOf(namespace), + tier: 3, + instanced: false, + properties: Object.fromEntries(properties.map((name) => [name, { name, value: s.unknown() }])), + directives: {}, + }; +} +export const STUBS = TABLE.map(stub); diff --git a/dist/esm/registry/schema.d.ts b/dist/esm/registry/schema.d.ts new file mode 100644 index 0000000..12754e7 --- /dev/null +++ b/dist/esm/registry/schema.d.ts @@ -0,0 +1,91 @@ +/** A value that does not fit its schema: path is where ("payload.targetSetpoint.scale"), problem is what. */ +export declare class SchemaError extends Error { + readonly path: string; + readonly problem: string; + constructor(path: string, problem: string); +} +export interface Schema { + /** What the schema accepts, in the words of an error message: "an integer from 0 to 100", "ON | OFF". */ + readonly expects: string; + /** The checked value. path names it in the error message. */ + parse(input: unknown, path?: string): T; +} +/** A schema that accepts a missing value; s.object() makes its key optional in the inferred type. */ +export interface OptionalSchema extends Schema { + readonly optional: true; +} +export interface EnumSchema extends Schema { + readonly values: readonly V[]; +} +/** The type a schema produces. */ +export type Infer = S extends Schema ? T : never; +export type Shape = Record>; +type OptionalKeys = { + [K in keyof S]: S[K] extends OptionalSchema ? K : never; +}[keyof S]; +export type InferShape = { + [K in Exclude>]: Infer; +} & { + [K in OptionalKeys]?: Infer; +}; +export interface NumberRules { + min?: number; + max?: number; + /** Greater than, the bound itself excluded. */ + gt?: number; + integer?: boolean; +} +export interface Temperature { + value: number; + scale: "CELSIUS" | "FAHRENHEIT" | "KELVIN"; +} +export interface TimeInterval { + start?: string; + end?: string; + duration?: string; +} +declare function number(rules?: NumberRules): Schema; +declare function string(rules?: { + min?: number; + max?: number; + pattern?: RegExp; + expects?: string; +}): Schema; +declare function boolean(): Schema; +declare function literal(value: V): Schema; +declare function enumeration(...values: V): EnumSchema; +declare function unknown(): Schema; +declare function optional(inner: Schema): OptionalSchema; +declare function nullable(inner: Schema): Schema; +declare function array(item: Schema, rules?: { + min?: number; + max?: number; +}): Schema; +/** + * An object with the keys of shape. Keys the shape does not name are kept as they are: a field Alexa adds to a + * directive reaches the handler. unknownKeys "reject" is for what a developer writes, where such a key is a typo. + */ +declare function object(shape: S, rules?: { + unknownKeys?: "keep" | "reject"; +}): Schema>; +declare function temperature(): Schema; +declare function dateTime(): Schema; +declare function duration(): Schema; +declare function timeInterval(): Schema; +export declare const s: { + string: typeof string; + number: typeof number; + boolean: typeof boolean; + literal: typeof literal; + enum: typeof enumeration; + unknown: typeof unknown; + optional: typeof optional; + nullable: typeof nullable; + array: typeof array; + object: typeof object; + temperature: typeof temperature; + dateTime: typeof dateTime; + duration: typeof duration; + timeInterval: typeof timeInterval; +}; +export {}; diff --git a/dist/esm/registry/schema.js b/dist/esm/registry/schema.js new file mode 100644 index 0000000..92a9633 --- /dev/null +++ b/dist/esm/registry/schema.js @@ -0,0 +1,186 @@ +// Values checked at run time and typed at compile time. A descriptor states its property values, directive payloads +// 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. +/** A value that does not fit its schema: path is where ("payload.targetSetpoint.scale"), problem is what. */ +export class SchemaError extends Error { + constructor(path, problem) { + super(path ? `${path}: ${problem}` : problem); + this.path = path; + this.problem = problem; + this.name = "SchemaError"; + } +} +// The input as an error message shows it: short, and quoted when it is text. +function shown(input) { + if (input === undefined) + return "nothing"; + if (typeof input === "function") + return "a function"; + const text = JSON.stringify(input) ?? String(input); + return text.length > 60 ? `${text.slice(0, 57)}...` : text; +} +function mismatch(path, expects, input) { + return new SchemaError(path, `expected ${expects}, got ${shown(input)}`); +} +const at = (path, key) => (path ? `${path}.${key}` : key); +function isRecord(input) { + return typeof input === "object" && input !== null && !Array.isArray(input); +} +function schema(expects, accepts) { + return { + expects, + parse(input, path = "") { + if (!accepts(input)) + throw mismatch(path, expects, input); + return input; + }, + }; +} +function numberText({ min, max, gt, integer }) { + const kind = integer ? "an integer" : "a number"; + if (min !== undefined && max !== undefined) + return `${kind} from ${min} to ${max}`; + if (min !== undefined) + return `${kind} of ${min} or more`; + if (max !== undefined) + return `${kind} of ${max} or less`; + if (gt !== undefined) + return `${kind} greater than ${gt}`; + return kind; +} +function number(rules = {}) { + const { min = -Infinity, max = Infinity, gt = -Infinity, integer = false } = rules; + return schema(numberText(rules), (input) => typeof input === "number" && Number.isFinite(input) && input >= min && input <= max && input > gt + && (!integer || Number.isInteger(input))); +} +function string(rules = {}) { + const { min = 0, max = Infinity, pattern } = rules; + const length = max === Infinity ? (min > 0 ? ` of ${min} or more characters` : "") : ` of ${min} to ${max} characters`; + return schema(rules.expects ?? `a string${length}`, (input) => typeof input === "string" && input.length >= min && input.length <= max && (!pattern || pattern.test(input))); +} +function boolean() { + return schema("true or false", (input) => typeof input === "boolean"); +} +function literal(value) { + return schema(JSON.stringify(value), (input) => input === value); +} +function enumeration(...values) { + return { ...schema(values.join(" | "), (input) => values.includes(input)), values }; +} +function unknown() { + return schema("any value", () => true); +} +function optional(inner) { + return { + expects: inner.expects, + optional: true, + parse: (input, path = "") => (input === undefined ? undefined : inner.parse(input, path)), + }; +} +function nullable(inner) { + const expects = `${inner.expects} or null`; + return { + expects, + parse(input, path = "") { + if (input === null) + return null; + try { + return inner.parse(input, path); + } + catch (err) { + // The value itself is of the wrong kind: null was a choice too. An error further in keeps its own path. + if (err instanceof SchemaError && err.path === path) + throw mismatch(path, expects, input); + throw err; + } + }, + }; +} +function array(item, rules = {}) { + const { min = 0, max = Infinity } = rules; + const count = max === Infinity ? (min > 0 ? ` with ${min} or more entries` : "") : ` with ${min} to ${max} entries`; + const expects = `a list${count}`; + return { + expects, + parse(input, path = "") { + if (!Array.isArray(input) || input.length < min || input.length > max) + throw mismatch(path, expects, input); + return input.map((entry, i) => item.parse(entry, `${path}[${i}]`)); + }, + }; +} +/** + * An object with the keys of shape. Keys the shape does not name are kept as they are: a field Alexa adds to a + * directive reaches the handler. unknownKeys "reject" is for what a developer writes, where such a key is a typo. + */ +function object(shape, rules = {}) { + const known = Object.keys(shape); + const expects = known.length > 0 ? `an object with ${known.join(", ")}` : "an object"; + return { + expects, + parse(input, path = "") { + if (!isRecord(input)) + 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"}`); + } + const parsed = {}; + for (const key of known) { + const value = shape[key].parse(input[key], at(path, key)); + if (value !== undefined) + parsed[key] = value; + } + for (const key of others) + parsed[key] = input[key]; + return parsed; + }, + }; +} +// alexa-property-schemas.html "Temperature", "Temperature scales" +function temperature() { + return object({ value: number(), scale: enumeration("CELSIUS", "FAHRENHEIT", "KELVIN") }); +} +// alexa-property-schemas.html "DateTime": UTC, no offsets. The seconds are optional here because the TimeInterval +// examples on the same page leave them out ("2017-10-04T14:00Z"). +const DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?Z$/; +function dateTime() { + return schema("a UTC time like 2017-08-30T01:18:21Z", (input) => typeof input === "string" && DATE_TIME.test(input) && !Number.isNaN(Date.parse(input))); +} +// alexa-property-schemas.html "Duration": the time portion of ISO 8601, negative for a delta ("PT-30S") +const DURATION = /^PT(?=.)(-?\d+H)?(-?\d+M)?(-?\d+S)?$/; +function duration() { + return string({ pattern: DURATION, expects: "a duration like PT3M15S" }); +} +// alexa-property-schemas.html "TimeInterval": "Specify one or two of the time interval fields. If you specify all +// three fields, an error occurs." +function timeInterval() { + const fields = object({ start: optional(dateTime()), end: optional(dateTime()), duration: optional(duration()) }); + const expects = "a time interval with one or two of start, end, duration"; + return { + expects, + parse(input, path = "") { + const interval = fields.parse(input, path); + const given = [interval.start, interval.end, interval.duration].filter((field) => field !== undefined).length; + if (given < 1 || given > 2) + throw mismatch(path, expects, input); + return interval; + }, + }; +} +export const s = { + string, + number, + boolean, + literal, + enum: enumeration, + unknown, + optional, + nullable, + array, + object, + temperature, + dateTime, + duration, + timeInterval, +}; diff --git a/dist/esm/registry/types.d.ts b/dist/esm/registry/types.d.ts new file mode 100644 index 0000000..acc6d22 --- /dev/null +++ b/dist/esm/registry/types.d.ts @@ -0,0 +1,123 @@ +import type { Schema } from "./schema.js"; +/** + * A friendly name: text in one locale, or an asset of the global Alexa catalog, which stands for several names in + * every language Alexa speaks (resources-and-assets.html, "Label object"). + */ +export type Label = { + "@type": "text"; + value: { + text: string; + locale: string; + }; +} | { + "@type": "asset"; + value: { + assetId: string; + }; +}; +export interface PropertyDescriptor { + name: string; + value: Schema; + note?: string; +} +export interface DirectiveDescriptor { + name: string; + payload: Schema; + /** Alexa sends the directive only to a capability declared like this (AdjustMode: an ordered mode). */ + when?: (capability: Declared) => boolean; + note?: string; +} +/** An event a device raises that is not a Response: ActivationStarted, DoorbellPress. */ +export interface EventDescriptor { + name: string; + /** Default: the namespace of the interface. */ + namespace?: string; + /** Default: the version of the interface. */ + payloadVersion?: string; + payload: Schema; + /** response: the answer to a directive. proactive: sent without one. */ + topic: "response" | "proactive"; +} +/** What an interface adds to the capability object in discovery, next to the fields every capability has. */ +export interface CapabilityExtras { + configuration?: Record; + /** Alexa.EqualizerController spells it in the plural. */ + configurations?: Record; + /** Fields of the capability object itself: supportsDeactivation, supportedOperations, inputs. */ + topLevel?: Record; + /** false: the capability has no properties object (the Alexa interface, a scene). */ + properties?: false; +} +/** A capability as declared on an endpoint: what discovery() and validate() of its descriptor are given. */ +export interface Declared> { + readonly endpointId: string; + readonly namespace: string; + /** "" when the interface is declared without an instance. */ + readonly instance: string; + readonly friendlyNames: readonly Label[]; + readonly retrievable: boolean; + readonly proactivelyReported: boolean; + readonly nonControllable?: boolean; + /** The options of the interface, as its options schema returned them. */ + readonly options: O; +} +/** The endpoint a capability is declared on, as validate() sees it. */ +export interface EndpointView { + readonly endpointId: string; + readonly friendlyName: string; + readonly description: string; + readonly displayCategories: readonly string[]; + /** The other capabilities of the endpoint. */ + readonly capabilities: readonly Declared[]; +} +export type Properties = Record>; +export type Directives = Record>; +export interface InterfaceDescriptor

{ + /** "Alexa.RangeController" */ + namespace: string; + /** As the title of the interface's page gives it: "3", "3.1", "1.0". */ + version: string; + /** The page the descriptor was written from. */ + doc: string; + kind: "base" | "controller" | "sensor" | "eventSource"; + /** 1 and 2: described in full. 3: a stub, the namespace with its version and property names and nothing checked. */ + tier: 1 | 2 | 3; + /** A generic controller: each capability needs an instance name and friendly names. */ + instanced: I; + /** The properties the interface reports. */ + properties: P; + /** The directives Alexa sends. */ + directives: D; + events?: Record; + /** What a declaration can set beyond the fields every capability has: a range, the supported modes. */ + options?: Schema; + discovery?: (capability: Declared) => CapabilityExtras; + /** The event that answers a directive when it is not Alexa / Response (Arm -> Arm.Response). */ + responseFor?: (directive: string) => { + namespace: string; + name: string; + payload?: Schema; + } | undefined; + /** The namespace an ErrorResponse goes under when its type is one of errorTypes. */ + errorNamespace?: string; + errorTypes?: readonly string[]; + /** Amazon documents a DeferredResponse for the interface. */ + deferrable?: boolean; + /** Rules a schema cannot state. Throws DeclarationError. */ + validate?: (capability: Declared, endpoint: EndpointView) => void; +} +export type AnyDescriptor = InterfaceDescriptor; +/** Types a descriptor from what it is given: the property names, the payload of each directive, the options. */ +export declare function defineInterface

(descriptor: InterfaceDescriptor): InterfaceDescriptor; +/** A device, a capability or an interface name declared in a way Alexa would reject. Thrown where it is declared. */ +export declare class DeclarationError extends Error { + readonly endpointId?: string; + readonly namespace?: string; + readonly instance?: string; + readonly problem: string; + constructor(where: { + endpointId?: string; + namespace?: string; + instance?: string; + }, problem: string); +} diff --git a/dist/esm/registry/types.js b/dist/esm/registry/types.js new file mode 100644 index 0000000..0a9599c --- /dev/null +++ b/dist/esm/registry/types.js @@ -0,0 +1,20 @@ +/** Types a descriptor from what it is given: the property names, the payload of each directive, the options. */ +export function defineInterface(descriptor) { + return descriptor; +} +/** A device, a capability or an interface name declared in a way Alexa would reject. Thrown where it is declared. */ +export class DeclarationError extends Error { + constructor(where, problem) { + const capability = where.namespace && where.instance ? `${where.namespace} "${where.instance}"` : where.namespace; + super([where.endpointId && shortened(where.endpointId), capability, problem].filter(Boolean).join(": ")); + this.name = "DeclarationError"; + this.endpointId = where.endpointId; + this.namespace = where.namespace; + this.instance = where.instance || undefined; + this.problem = problem; + } +} +// An endpointId may be the very thing that is wrong, 300 characters of it. +function shortened(text) { + return text.length > 64 ? `${text.slice(0, 61)}...` : text; +} diff --git a/dist/types/AlexaInterface.d.ts b/dist/types/AlexaInterface.d.ts index 9efe0f7..c0a4e64 100644 --- a/dist/types/AlexaInterface.d.ts +++ b/dist/types/AlexaInterface.d.ts @@ -101,7 +101,9 @@ export declare class AlexaInterface { 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/types/DisplayCategory.d.ts b/dist/types/DisplayCategory.d.ts index c70eeea..ee7b947 100644 --- a/dist/types/DisplayCategory.d.ts +++ b/dist/types/DisplayCategory.d.ts @@ -51,6 +51,8 @@ export declare enum DisplayCategory { 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", diff --git a/dist/types/index.d.ts b/dist/types/index.d.ts index 7b539f6..5e81aa1 100644 --- a/dist/types/index.d.ts +++ b/dist/types/index.d.ts @@ -8,3 +8,6 @@ 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 { 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/catalog.d.ts b/dist/types/registry/catalog.d.ts new file mode 100644 index 0000000..789f869 --- /dev/null +++ b/dist/types/registry/catalog.d.ts @@ -0,0 +1,40 @@ +/** + * The asset ids a friendly name can refer to: 103, the units of measure among them + * (resources-and-assets.html, "Global Alexa catalog"). + */ +export declare const ASSETS: readonly ["Alexa.Actions.Charge", "Alexa.Actions.Clean", "Alexa.Actions.Dispense", "Alexa.Actions.Dock", "Alexa.Actions.Empty", "Alexa.Actions.Mop", "Alexa.Actions.Skip", "Alexa.Actions.Sweep", "Alexa.Actions.Vacuum", "Alexa.Button.OffButton", "Alexa.Button.OnButton", "Alexa.Button.BrightenButton", "Alexa.Button.DimButton", "Alexa.Button.MainButton", "Alexa.Button.TopButton", "Alexa.Button.BottomButton", "Alexa.Button.CenterButton", "Alexa.Button.MiddleButton", "Alexa.Button.UpButton", "Alexa.Button.DownButton", "Alexa.Button.LeftButton", "Alexa.Button.RightButton", "Alexa.Button.FirstButton", "Alexa.Button.SecondButton", "Alexa.Button.ThirdButton", "Alexa.Button.FourthButton", "Alexa.Button.FifthButton", "Alexa.Button.SixthButton", "Alexa.Button.SeventhButton", "Alexa.Button.EighthButton", "Alexa.Button.DoublePress", "Alexa.Button.DoublePush", "Alexa.Button.LongPress", "Alexa.Button.LongPush", "Alexa.Button.SinglePress", "Alexa.Button.SinglePush", "Alexa.DeviceName.AirPurifier", "Alexa.DeviceName.Camera", "Alexa.DeviceName.Fan", "Alexa.DeviceName.Router", "Alexa.DeviceName.Shade", "Alexa.DeviceName.Shower", "Alexa.DeviceName.SpaceHeater", "Alexa.DeviceName.Washer", "Alexa.Gesture.DoubleClick", "Alexa.Gesture.SingleClick", "Alexa.Gesture.SwipeDown", "Alexa.Gesture.SwipeLeft", "Alexa.Gesture.SwipeRight", "Alexa.Gesture.SwipeUp", "Alexa.Gesture.Tap", "Alexa.Gestures.DoubleTap", "Alexa.Setting.2GGuestWiFi", "Alexa.Setting.5GGuestWiFi", "Alexa.Setting.Auto", "Alexa.Setting.Direction", "Alexa.Setting.DryCycle", "Alexa.Setting.FanSpeed", "Alexa.Setting.GuestWiFi", "Alexa.Setting.Heat", "Alexa.Setting.Mode", "Alexa.Setting.Night", "Alexa.Setting.Opening", "Alexa.Setting.Oscillate", "Alexa.Setting.Preset", "Alexa.Setting.Quiet", "Alexa.Setting.Temperature", "Alexa.Setting.WashCycle", "Alexa.Setting.WaterTemperature", "Alexa.Shower.HandHeld", "Alexa.Shower.RainHead", "Alexa.Unit.Angle.Degrees", "Alexa.Unit.Angle.Radians", "Alexa.Unit.Distance.Feet", "Alexa.Unit.Distance.Inches", "Alexa.Unit.Distance.Kilometers", "Alexa.Unit.Distance.Meters", "Alexa.Unit.Distance.Miles", "Alexa.Unit.Distance.Yards", "Alexa.Unit.Mass.Grams", "Alexa.Unit.Mass.Kilograms", "Alexa.Unit.Percent", "Alexa.Unit.Temperature.Celsius", "Alexa.Unit.Temperature.Degrees", "Alexa.Unit.Temperature.Fahrenheit", "Alexa.Unit.Temperature.Kelvin", "Alexa.Unit.Volume.CubicFeet", "Alexa.Unit.Volume.CubicMeters", "Alexa.Unit.Volume.Gallons", "Alexa.Unit.Volume.Liters", "Alexa.Unit.Volume.Pints", "Alexa.Unit.Volume.Quarts", "Alexa.Unit.Weight.Ounces", "Alexa.Unit.Weight.Pounds", "Alexa.Value.Close", "Alexa.Value.Delicate", "Alexa.Value.High", "Alexa.Value.Low", "Alexa.Value.Maximum", "Alexa.Value.Medium", "Alexa.Value.Minimum", "Alexa.Value.Open", "Alexa.Value.QuickWash"]; +export type AssetId = (typeof ASSETS)[number]; +/** A unit of measure: the 23 Alexa.Unit.* assets, what a RangeController's unitOfMeasure takes. */ +export type UnitOfMeasure = Extract; +export declare const UNITS_OF_MEASURE: UnitOfMeasure[]; +/** The phrases an action mapping gives to a directive (alexa-discovery-objects.html, "ActionMappings object"). */ +export declare const ACTIONS: readonly ["Alexa.Actions.Open", "Alexa.Actions.Close", "Alexa.Actions.Raise", "Alexa.Actions.Lower", "Alexa.Actions.SetEcoOn", "Alexa.Actions.SetEcoOff"]; +export type ActionId = (typeof ACTIONS)[number]; +/** The states a state mapping gives to a property value (alexa-discovery-objects.html, "StateMappings object"). */ +export declare const STATES: readonly ["Alexa.States.Open", "Alexa.States.Closed", "Alexa.States.EcoOn", "Alexa.States.EcoOff", "Alexa.States.Low", "Alexa.States.Empty", "Alexa.States.Full", "Alexa.States.Done", "Alexa.States.Stuck"]; +export type StateId = (typeof STATES)[number]; +/** + * The 56 display categories (alexa-discovery.html, "Display categories"). The DisplayCategory enum also keeps + * VEHICLE from 1.x, which the page no longer lists. + */ +export declare const DISPLAY_CATEGORIES: readonly ["ACTIVITY_TRIGGER", "AIR_CONDITIONER", "AIR_FRESHENER", "AIR_PURIFIER", "AIR_QUALITY_MONITOR", "ALEXA_VOICE_ENABLED", "AUTO_ACCESSORY", "BLUETOOTH_SPEAKER", "CAMERA", "CHRISTMAS_TREE", "COFFEE_MAKER", "COMPUTER", "CONTACT_SENSOR", "DISHWASHER", "DOOR", "DOORBELL", "DRYER", "EXTERIOR_BLIND", "FAN", "GAME_CONSOLE", "GARAGE_DOOR", "HEADPHONES", "HUB", "INTERIOR_BLIND", "LAPTOP", "LIGHT", "MICROWAVE", "MOBILE_PHONE", "MOTION_SENSOR", "MUSIC_SYSTEM", "NETWORK_HARDWARE", "OTHER", "OVEN", "PHONE", "PRINTER", "REMOTE", "ROUTER", "SCENE_TRIGGER", "SCREEN", "SECURITY_PANEL", "SECURITY_SYSTEM", "SLOW_COOKER", "SMARTLOCK", "SMARTPLUG", "SPEAKER", "STREAMING_DEVICE", "SWITCH", "TABLET", "TEMPERATURE_SENSOR", "THERMOSTAT", "TV", "VACUUM_CLEANER", "VACUUM", "WASHER", "WATER_HEATER", "WEARABLE"]; +export type DisplayCategoryName = (typeof DISPLAY_CATEGORIES)[number]; +/** Not to be used as a friendly name (resources-and-assets.html, "Reserved words"). */ +export declare const RESERVED_WORDS: readonly ["alarm", "alarms", "all alarms", "away mode", "bass", "camera", "date", "date today", "day", "do not disturb", "drop in", "music", "night light", "notification", "playing", "sleep sounds", "time", "timer", "today in music", "treble", "volume", "way f. m."]; +/** + * What a discovery answer may hold (alexa-discovery.html, "Interface limits"; alexa-discovery-objects.html, + * "Endpoint object details" and "AdditionalAttributes object details"; alexa-scenecontroller.html, "Discovery"). + */ +export declare const LIMITS: { + readonly endpointsPerCustomer: 300; + readonly capabilitiesPerEndpoint: 100; + readonly endpointIdLength: 256; + readonly friendlyNameLength: 256; + readonly sceneFriendlyNameLength: 128; + readonly manufacturerNameLength: 128; + readonly descriptionLength: 128; + readonly additionalAttributeLength: 256; + readonly cookieBytes: 5000; +}; +/** The 73 error types of the table, each with the namespace its ErrorResponse goes under. */ +export declare const ERROR_TYPES: Readonly>; diff --git a/dist/types/registry/index.d.ts b/dist/types/registry/index.d.ts new file mode 100644 index 0000000..895e580 --- /dev/null +++ b/dist/types/registry/index.d.ts @@ -0,0 +1,20 @@ +import { Alexa } from "./interfaces/Alexa.js"; +import { BrightnessController } from "./interfaces/BrightnessController.js"; +import { EndpointHealth } from "./interfaces/EndpointHealth.js"; +import { PowerController } from "./interfaces/PowerController.js"; +import { TemperatureSensor } from "./interfaces/TemperatureSensor.js"; +import type { AnyDescriptor } from "./types.js"; +export declare const registry: { + /** Whether an interface of this name is known. */ + has(namespace: string): boolean; + /** The descriptor of "Alexa.RangeController" or of AlexaInterfaceType.RANGE_CONTROLLER, which is that string. */ + get(namespace: string): AnyDescriptor; + /** Every descriptor, ordered by namespace. */ + list(): AnyDescriptor[]; +}; +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 { s, SchemaError } from "./schema.js"; +export type { Infer, Schema, Temperature, TimeInterval } from "./schema.js"; +export * from "./catalog.js"; diff --git a/dist/types/registry/interfaces/Alexa.d.ts b/dist/types/registry/interfaces/Alexa.d.ts new file mode 100644 index 0000000..fe082f2 --- /dev/null +++ b/dist/types/registry/interfaces/Alexa.d.ts @@ -0,0 +1,10 @@ +/** + * The base interface: every endpoint lists it (alexa-interface.html, "Support the Alexa interface in all add-ons"). + * Its one directive to a device is ReportState, answered with a StateReport. + */ +export declare const Alexa: import("../types.js").InterfaceDescriptor<{}, { + ReportState: { + name: string; + payload: import("../schema.js").Schema>; + }; +}, {}, false>; diff --git a/dist/types/registry/interfaces/BrightnessController.d.ts b/dist/types/registry/interfaces/BrightnessController.d.ts new file mode 100644 index 0000000..9f45c39 --- /dev/null +++ b/dist/types/registry/interfaces/BrightnessController.d.ts @@ -0,0 +1,20 @@ +/** Both directives turn a light that is off on, at the brightness asked for (alexa-brightnesscontroller.html). */ +export declare const BrightnessController: import("../types.js").InterfaceDescriptor<{ + brightness: { + name: string; + value: import("../schema.js").Schema; + }; +}, { + SetBrightness: { + name: string; + payload: import("../schema.js").Schema; + }>>; + }; + AdjustBrightness: { + name: string; + payload: import("../schema.js").Schema; + }>>; + }; +}, {}, false>; diff --git a/dist/types/registry/interfaces/EndpointHealth.d.ts b/dist/types/registry/interfaces/EndpointHealth.d.ts new file mode 100644 index 0000000..1d1621f --- /dev/null +++ b/dist/types/registry/interfaces/EndpointHealth.d.ts @@ -0,0 +1,15 @@ +export declare const CONNECTIVITY_REASONS: readonly ["WIFI_BAD_PASSWORD", "WIFI_AP_NOT_FOUND", "WIFI_ROUTER_UNREACHABLE", "WIFI_AP_CHANNEL_QUALITY_LOW", "INTERNET_UNREACHABLE", "CAPTIVE_PORTAL_CHECK_FAILED", "UNKNOWN"]; +/** + * Version 3.1, as the page is titled. It defines connectivity only. Examples on other pages declare 3 and 3.2; + * alex2node 1.x declared 3.3, which no page mentions. + */ +export declare const EndpointHealth: import("../types.js").InterfaceDescriptor<{ + connectivity: { + name: string; + value: import("../schema.js").Schema; + reason: import("../schema.js").OptionalSchema<"WIFI_BAD_PASSWORD" | "WIFI_AP_NOT_FOUND" | "WIFI_ROUTER_UNREACHABLE" | "WIFI_AP_CHANNEL_QUALITY_LOW" | "INTERNET_UNREACHABLE" | "CAPTIVE_PORTAL_CHECK_FAILED" | "UNKNOWN">; + }>>; + note: string; + }; +}, {}, {}, false>; diff --git a/dist/types/registry/interfaces/PowerController.d.ts b/dist/types/registry/interfaces/PowerController.d.ts new file mode 100644 index 0000000..8d9efe1 --- /dev/null +++ b/dist/types/registry/interfaces/PowerController.d.ts @@ -0,0 +1,17 @@ +export declare const PowerController: import("../types.js").InterfaceDescriptor<{ + powerState: { + name: string; + value: import("../schema.js").EnumSchema<"ON" | "OFF">; + }; +}, { + TurnOn: { + name: string; + payload: import("../schema.js").Schema>; + }; + TurnOff: { + name: string; + payload: import("../schema.js").Schema>; + }; +}, import("../schema.js").InferShape<{ + verificationsRequired: import("../schema.js").OptionalSchema<("TurnOn" | "TurnOff")[]>; +}>, false>; diff --git a/dist/types/registry/interfaces/TemperatureSensor.d.ts b/dist/types/registry/interfaces/TemperatureSensor.d.ts new file mode 100644 index 0000000..ed3be04 --- /dev/null +++ b/dist/types/registry/interfaces/TemperatureSensor.d.ts @@ -0,0 +1,7 @@ +/** A sensor: an endpoint that has it declares Alexa.EndpointHealth as well (alexa-temperaturesensor.html). */ +export declare const TemperatureSensor: import("../types.js").InterfaceDescriptor<{ + temperature: { + name: string; + value: import("../schema.js").Schema; + }; +}, {}, {}, false>; diff --git a/dist/types/registry/interfaces/stubs.d.ts b/dist/types/registry/interfaces/stubs.d.ts new file mode 100644 index 0000000..0317d38 --- /dev/null +++ b/dist/types/registry/interfaces/stubs.d.ts @@ -0,0 +1,2 @@ +import type { AnyDescriptor } from "../types.js"; +export declare const STUBS: readonly AnyDescriptor[]; diff --git a/dist/types/registry/schema.d.ts b/dist/types/registry/schema.d.ts new file mode 100644 index 0000000..12754e7 --- /dev/null +++ b/dist/types/registry/schema.d.ts @@ -0,0 +1,91 @@ +/** A value that does not fit its schema: path is where ("payload.targetSetpoint.scale"), problem is what. */ +export declare class SchemaError extends Error { + readonly path: string; + readonly problem: string; + constructor(path: string, problem: string); +} +export interface Schema { + /** What the schema accepts, in the words of an error message: "an integer from 0 to 100", "ON | OFF". */ + readonly expects: string; + /** The checked value. path names it in the error message. */ + parse(input: unknown, path?: string): T; +} +/** A schema that accepts a missing value; s.object() makes its key optional in the inferred type. */ +export interface OptionalSchema extends Schema { + readonly optional: true; +} +export interface EnumSchema extends Schema { + readonly values: readonly V[]; +} +/** The type a schema produces. */ +export type Infer = S extends Schema ? T : never; +export type Shape = Record>; +type OptionalKeys = { + [K in keyof S]: S[K] extends OptionalSchema ? K : never; +}[keyof S]; +export type InferShape = { + [K in Exclude>]: Infer; +} & { + [K in OptionalKeys]?: Infer; +}; +export interface NumberRules { + min?: number; + max?: number; + /** Greater than, the bound itself excluded. */ + gt?: number; + integer?: boolean; +} +export interface Temperature { + value: number; + scale: "CELSIUS" | "FAHRENHEIT" | "KELVIN"; +} +export interface TimeInterval { + start?: string; + end?: string; + duration?: string; +} +declare function number(rules?: NumberRules): Schema; +declare function string(rules?: { + min?: number; + max?: number; + pattern?: RegExp; + expects?: string; +}): Schema; +declare function boolean(): Schema; +declare function literal(value: V): Schema; +declare function enumeration(...values: V): EnumSchema; +declare function unknown(): Schema; +declare function optional(inner: Schema): OptionalSchema; +declare function nullable(inner: Schema): Schema; +declare function array(item: Schema, rules?: { + min?: number; + max?: number; +}): Schema; +/** + * An object with the keys of shape. Keys the shape does not name are kept as they are: a field Alexa adds to a + * directive reaches the handler. unknownKeys "reject" is for what a developer writes, where such a key is a typo. + */ +declare function object(shape: S, rules?: { + unknownKeys?: "keep" | "reject"; +}): Schema>; +declare function temperature(): Schema; +declare function dateTime(): Schema; +declare function duration(): Schema; +declare function timeInterval(): Schema; +export declare const s: { + string: typeof string; + number: typeof number; + boolean: typeof boolean; + literal: typeof literal; + enum: typeof enumeration; + unknown: typeof unknown; + optional: typeof optional; + nullable: typeof nullable; + array: typeof array; + object: typeof object; + temperature: typeof temperature; + dateTime: typeof dateTime; + duration: typeof duration; + timeInterval: typeof timeInterval; +}; +export {}; diff --git a/dist/types/registry/types.d.ts b/dist/types/registry/types.d.ts new file mode 100644 index 0000000..acc6d22 --- /dev/null +++ b/dist/types/registry/types.d.ts @@ -0,0 +1,123 @@ +import type { Schema } from "./schema.js"; +/** + * A friendly name: text in one locale, or an asset of the global Alexa catalog, which stands for several names in + * every language Alexa speaks (resources-and-assets.html, "Label object"). + */ +export type Label = { + "@type": "text"; + value: { + text: string; + locale: string; + }; +} | { + "@type": "asset"; + value: { + assetId: string; + }; +}; +export interface PropertyDescriptor { + name: string; + value: Schema; + note?: string; +} +export interface DirectiveDescriptor { + name: string; + payload: Schema; + /** Alexa sends the directive only to a capability declared like this (AdjustMode: an ordered mode). */ + when?: (capability: Declared) => boolean; + note?: string; +} +/** An event a device raises that is not a Response: ActivationStarted, DoorbellPress. */ +export interface EventDescriptor { + name: string; + /** Default: the namespace of the interface. */ + namespace?: string; + /** Default: the version of the interface. */ + payloadVersion?: string; + payload: Schema; + /** response: the answer to a directive. proactive: sent without one. */ + topic: "response" | "proactive"; +} +/** What an interface adds to the capability object in discovery, next to the fields every capability has. */ +export interface CapabilityExtras { + configuration?: Record; + /** Alexa.EqualizerController spells it in the plural. */ + configurations?: Record; + /** Fields of the capability object itself: supportsDeactivation, supportedOperations, inputs. */ + topLevel?: Record; + /** false: the capability has no properties object (the Alexa interface, a scene). */ + properties?: false; +} +/** A capability as declared on an endpoint: what discovery() and validate() of its descriptor are given. */ +export interface Declared> { + readonly endpointId: string; + readonly namespace: string; + /** "" when the interface is declared without an instance. */ + readonly instance: string; + readonly friendlyNames: readonly Label[]; + readonly retrievable: boolean; + readonly proactivelyReported: boolean; + readonly nonControllable?: boolean; + /** The options of the interface, as its options schema returned them. */ + readonly options: O; +} +/** The endpoint a capability is declared on, as validate() sees it. */ +export interface EndpointView { + readonly endpointId: string; + readonly friendlyName: string; + readonly description: string; + readonly displayCategories: readonly string[]; + /** The other capabilities of the endpoint. */ + readonly capabilities: readonly Declared[]; +} +export type Properties = Record>; +export type Directives = Record>; +export interface InterfaceDescriptor

{ + /** "Alexa.RangeController" */ + namespace: string; + /** As the title of the interface's page gives it: "3", "3.1", "1.0". */ + version: string; + /** The page the descriptor was written from. */ + doc: string; + kind: "base" | "controller" | "sensor" | "eventSource"; + /** 1 and 2: described in full. 3: a stub, the namespace with its version and property names and nothing checked. */ + tier: 1 | 2 | 3; + /** A generic controller: each capability needs an instance name and friendly names. */ + instanced: I; + /** The properties the interface reports. */ + properties: P; + /** The directives Alexa sends. */ + directives: D; + events?: Record; + /** What a declaration can set beyond the fields every capability has: a range, the supported modes. */ + options?: Schema; + discovery?: (capability: Declared) => CapabilityExtras; + /** The event that answers a directive when it is not Alexa / Response (Arm -> Arm.Response). */ + responseFor?: (directive: string) => { + namespace: string; + name: string; + payload?: Schema; + } | undefined; + /** The namespace an ErrorResponse goes under when its type is one of errorTypes. */ + errorNamespace?: string; + errorTypes?: readonly string[]; + /** Amazon documents a DeferredResponse for the interface. */ + deferrable?: boolean; + /** Rules a schema cannot state. Throws DeclarationError. */ + validate?: (capability: Declared, endpoint: EndpointView) => void; +} +export type AnyDescriptor = InterfaceDescriptor; +/** Types a descriptor from what it is given: the property names, the payload of each directive, the options. */ +export declare function defineInterface

(descriptor: InterfaceDescriptor): InterfaceDescriptor; +/** A device, a capability or an interface name declared in a way Alexa would reject. Thrown where it is declared. */ +export declare class DeclarationError extends Error { + readonly endpointId?: string; + readonly namespace?: string; + readonly instance?: string; + readonly problem: string; + constructor(where: { + endpointId?: string; + namespace?: string; + instance?: string; + }, problem: string); +} diff --git a/package.json b/package.json index 76d9fee..5d5ceae 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "build": "node scripts/build.mjs", "check": "tsc --noEmit && tsc -p test/fixtures/tsconfig.json", "prepare": "npm run build", - "test": "node --test test/*.test.js test/*.test.mjs" + "test": "node --test test/*.test.js test/*.test.mjs test/*/*.test.js" }, "keywords": [ "alexa", diff --git a/src/AlexaInterface.ts b/src/AlexaInterface.ts index 8b848e6..322b48f 100644 --- a/src/AlexaInterface.ts +++ b/src/AlexaInterface.ts @@ -1,4 +1,5 @@ import type { ActionMapping } from "./ActionMapping.js"; +import { registry } from "./registry/index.js"; export enum AlexaInterfaceType { APPLICATION_STATE_REPORTER = "Alexa.ApplicationStateReporter", @@ -119,173 +120,14 @@ export class AlexaInterface { return this.type; } + /** The version of the interface, from its descriptor. "UNKNOWN" for a name the registry does not have. */ getVersion(): string { - switch (this.type) { - case AlexaInterfaceType.APPLICATION_STATE_REPORTER: - case AlexaInterfaceType.AUDIO_PLAY_QUEUE: - case AlexaInterfaceType.AUTHORIZATION_CONTROLLER: - case AlexaInterfaceType.AUTOMATION_MANAGEMENT: - case AlexaInterfaceType.AUTOMOTIVE_VEHICLE_DATA: - case AlexaInterfaceType.COMMISSIONABLE: - case AlexaInterfaceType.CONSENT_MANAGEMENT_CONSENT_REQUIRED_REPORTER: - case AlexaInterfaceType.COOKING: - case AlexaInterfaceType.COOKING_FOOD_TEMPERATURE_CONTROLLER: - case AlexaInterfaceType.COOKING_FOOD_TEMPERATURE_SENSOR: - case AlexaInterfaceType.COOKING_PRESET_CONTROLLER: - case AlexaInterfaceType.COOKING_TEMPERATURE_CONTROLLER: - case AlexaInterfaceType.COOKING_TEMPERATURE_SENSOR: - case AlexaInterfaceType.COOKING_TIME_CONTROLLER: - case AlexaInterfaceType.DATA_CONTROLLER: - case AlexaInterfaceType.DEVICE_USAGE_ESTIMATION: - case AlexaInterfaceType.DEVICE_USAGE_METER: - case AlexaInterfaceType.EQUALIZER_CONTROLLER: - case AlexaInterfaceType.INVENTORY_LEVEL_SENSOR: - case AlexaInterfaceType.INVENTORY_LEVEL_USAGE_SENSOR: - case AlexaInterfaceType.INVENTORY_USAGE_SENSOR: - case AlexaInterfaceType.KEYPAD_CONTROLLER: - case AlexaInterfaceType.MEDIA_PLAYBACK: - case AlexaInterfaceType.MEDIA_PLAY_QUEUE: - case AlexaInterfaceType.MEDIA_SEARCH: - case AlexaInterfaceType.PLAYBACK_STATE_REPORTER: - case AlexaInterfaceType.PROACTIVE_NOTIFICATION_SOURCE: - case AlexaInterfaceType.REMOTE_VIDEO_PLAYER: - case AlexaInterfaceType.RTC_SESSION_CONTROLLER: - case AlexaInterfaceType.SECURITY_PANEL_CONTROLLER: - case AlexaInterfaceType.SECURITY_PANEL_CONTROLLER_ALERT: - case AlexaInterfaceType.SIMPLE_EVENT_SOURCE: - case AlexaInterfaceType.SMART_VISION_OBJECT_DETECTION_SENSOR: - case AlexaInterfaceType.SMART_VISION_SNAPSHOT_PROVIDER: - case AlexaInterfaceType.SPEAKER: - case AlexaInterfaceType.STEP_SPEAKER: - case AlexaInterfaceType.THERMOSTAT_CONTROLLER_CONFIGURATION: - case AlexaInterfaceType.THERMOSTAT_CONTROLLER_HVAC_COMPONENTS: - case AlexaInterfaceType.THERMOSTAT_CONTROLLER_SCHEDULE: - case AlexaInterfaceType.UI_CONTROLLER: - case AlexaInterfaceType.USER_PREFERENCE: - case AlexaInterfaceType.VIDEO_RECORDER: - case AlexaInterfaceType.WAKE_ON_LAN_CONTROLLER: - return "1"; - - case AlexaInterfaceType.BRIGHTNESS_CONTROLLER: - case AlexaInterfaceType.CAMERA_STREAM_CONTROLLER: - case AlexaInterfaceType.CHANNEL_CONTROLLER: - case AlexaInterfaceType.COLOR_CONTROLLER: - case AlexaInterfaceType.COLOR_TEMPERATURE_CONTROLLER: - case AlexaInterfaceType.CONTACT_SENSOR: - case AlexaInterfaceType.DOORBELL_EVENT_SOURCE: - case AlexaInterfaceType.INPUT_CONTROLLER: - case AlexaInterfaceType.LOCK_CONTROLLER: - case AlexaInterfaceType.MODE_CONTROLLER: - case AlexaInterfaceType.MOTION_SENSOR: - case AlexaInterfaceType.PERCENTAGE_CONTROLLER: - case AlexaInterfaceType.PLAYBACK_CONTROLLER: - case AlexaInterfaceType.POWER_CONTROLLER: - case AlexaInterfaceType.POWER_LEVEL_CONTROLLER: - case AlexaInterfaceType.RANGE_CONTROLLER: - case AlexaInterfaceType.RECORD_CONTROLLER: - case AlexaInterfaceType.SCENE_CONTROLLER: - case AlexaInterfaceType.SEEK_CONTROLLER: - case AlexaInterfaceType.TEMPERATURE_SENSOR: - case AlexaInterfaceType.TOGGLE_CONTROLLER: - return "3"; - case AlexaInterfaceType.LAUNCHER: - return "1.1"; - case AlexaInterfaceType.THERMOSTAT_CONTROLLER: - return "3.2"; - case AlexaInterfaceType.ENDPOINT_HEALTH: - return "3.3"; - default: - return "UNKNOWN"; - } + return registry.has(this.type) ? registry.get(this.type).version : "UNKNOWN"; } + /** The names of the properties the interface reports, from its descriptor. */ getProps(): string[] { - switch (this.type) { - case AlexaInterfaceType.AUTOMATION_MANAGEMENT: - return ["automationStatuses"]; - case AlexaInterfaceType.BRIGHTNESS_CONTROLLER: - return ["brightness"]; - case AlexaInterfaceType.CHANNEL_CONTROLLER: - return ["channel"]; - case AlexaInterfaceType.COLOR_CONTROLLER: - return ["color"]; - case AlexaInterfaceType.COLOR_TEMPERATURE_CONTROLLER: - return ["colorTemperatureInKelvin"]; - case AlexaInterfaceType.CONTACT_SENSOR: - return ["detectionState"]; - case AlexaInterfaceType.POWER_CONTROLLER: - return ["powerState"]; - case AlexaInterfaceType.ENDPOINT_HEALTH: - return ["connectivity"]; - case AlexaInterfaceType.TOGGLE_CONTROLLER: - return ["toggleState"]; - case AlexaInterfaceType.MODE_CONTROLLER: - return ["mode"]; - case AlexaInterfaceType.TEMPERATURE_SENSOR: - return ["temperature"]; - case AlexaInterfaceType.THERMOSTAT_CONTROLLER: - return [ - "targetSetpoint", - "lowerSetpoint", - "upperSetpoint", - "thermostatMode", - ]; - case AlexaInterfaceType.APPLICATION_STATE_REPORTER: - case AlexaInterfaceType.AUDIO_PLAY_QUEUE: - case AlexaInterfaceType.AUTHORIZATION_CONTROLLER: - case AlexaInterfaceType.AUTOMOTIVE_VEHICLE_DATA: - case AlexaInterfaceType.CAMERA_LIVE_VIEW_CONTROLLER: - case AlexaInterfaceType.CAMERA_STREAM_CONTROLLER: - case AlexaInterfaceType.COMMISSIONABLE: - case AlexaInterfaceType.CONSENT_MANAGEMENT_CONSENT_REQUIRED_REPORTER: - case AlexaInterfaceType.COOKING: - case AlexaInterfaceType.DATA_CONTROLLER: - case AlexaInterfaceType.DEVICE_USAGE_ESTIMATION: - case AlexaInterfaceType.DEVICE_USAGE_METER: - case AlexaInterfaceType.DOORBELL_EVENT_SOURCE: - case AlexaInterfaceType.EQUALIZER_CONTROLLER: - case AlexaInterfaceType.INPUT_CONTROLLER: - case AlexaInterfaceType.INVENTORY_LEVEL_SENSOR: - case AlexaInterfaceType.INVENTORY_LEVEL_USAGE_SENSOR: - case AlexaInterfaceType.INVENTORY_USAGE_SENSOR: - case AlexaInterfaceType.KEYPAD_CONTROLLER: - case AlexaInterfaceType.LAUNCHER: - case AlexaInterfaceType.LOCK_CONTROLLER: - case AlexaInterfaceType.MEDIA_PLAYBACK: - case AlexaInterfaceType.MEDIA_SEARCH: - case AlexaInterfaceType.MOTION_SENSOR: - case AlexaInterfaceType.PERCENTAGE_CONTROLLER: - case AlexaInterfaceType.PLAYBACK_CONTROLLER: - case AlexaInterfaceType.PLAYBACK_STATE_REPORTER: - case AlexaInterfaceType.PROACTIVE_NOTIFICATION_SOURCE: - case AlexaInterfaceType.RANGE_CONTROLLER: - case AlexaInterfaceType.RECORD_CONTROLLER: - case AlexaInterfaceType.REMOTE_VIDEO_PLAYER: - case AlexaInterfaceType.RTC_SESSION_CONTROLLER: - case AlexaInterfaceType.SCENE_CONTROLLER: - return []; // scenes have no reportable properties: Activate/Deactivate answer with ActivationStarted (Device.sendSceneResponse) - case AlexaInterfaceType.SECURITY_PANEL_CONTROLLER: - case AlexaInterfaceType.SEEK_CONTROLLER: - case AlexaInterfaceType.SIMPLE_EVENT_SOURCE: - case AlexaInterfaceType.SMART_VISION_OBJECT_DETECTION_SENSOR: - case AlexaInterfaceType.SMART_VISION_SNAPSHOT_PROVIDER: - case AlexaInterfaceType.SPEAKER: - case AlexaInterfaceType.STEP_SPEAKER: - case AlexaInterfaceType.THERMOSTAT_CONTROLLER_CONFIGURATION: - case AlexaInterfaceType.THERMOSTAT_CONTROLLER_HVAC_COMPONENTS: - case AlexaInterfaceType.THERMOSTAT_CONTROLLER_SCHEDULE: - case AlexaInterfaceType.TIME_HOLD_CONTROLLER: - case AlexaInterfaceType.UI_CONTROLLER: - case AlexaInterfaceType.USER_PREFERENCE: - case AlexaInterfaceType.VIDEO_RECORDER: - case AlexaInterfaceType.WAKE_ON_LAN_CONTROLLER: - // no property list known for these: discovery lists the capability with an empty "supported" (the bridge notes - // it through its log hook; 1.5.1 wrote "UNSUPORTED INTERFACE TYPE" to stderr on every discovery) - return []; - - default: - return []; - } + return registry.has(this.type) ? Object.keys(registry.get(this.type).properties) : []; } getJSON(): object { diff --git a/src/DisplayCategory.ts b/src/DisplayCategory.ts index 7398650..1ab061b 100644 --- a/src/DisplayCategory.ts +++ b/src/DisplayCategory.ts @@ -51,6 +51,8 @@ export enum DisplayCategory { 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", diff --git a/src/index.ts b/src/index.ts index 24450b8..ba6ee74 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,3 +9,15 @@ 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"; + +// The interface registry and the vocabularies of the Smart Home API +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/src/registry/catalog.ts b/src/registry/catalog.ts new file mode 100644 index 0000000..7e7182e --- /dev/null +++ b/src/registry/catalog.ts @@ -0,0 +1,146 @@ +// The fixed vocabularies of the Smart Home API, copied from Amazon's pages as read on 2026-09-28. The pages are under +// https://developer.amazon.com/docs/alexaplus/device-apis/. A value missing here is one Amazon added since. + +/** + * The asset ids a friendly name can refer to: 103, the units of measure among them + * (resources-and-assets.html, "Global Alexa catalog"). + */ +export const ASSETS = [ + "Alexa.Actions.Charge", "Alexa.Actions.Clean", "Alexa.Actions.Dispense", "Alexa.Actions.Dock", + "Alexa.Actions.Empty", "Alexa.Actions.Mop", "Alexa.Actions.Skip", "Alexa.Actions.Sweep", "Alexa.Actions.Vacuum", + "Alexa.Button.OffButton", "Alexa.Button.OnButton", "Alexa.Button.BrightenButton", "Alexa.Button.DimButton", + "Alexa.Button.MainButton", "Alexa.Button.TopButton", "Alexa.Button.BottomButton", "Alexa.Button.CenterButton", + "Alexa.Button.MiddleButton", "Alexa.Button.UpButton", "Alexa.Button.DownButton", "Alexa.Button.LeftButton", + "Alexa.Button.RightButton", "Alexa.Button.FirstButton", "Alexa.Button.SecondButton", "Alexa.Button.ThirdButton", + "Alexa.Button.FourthButton", "Alexa.Button.FifthButton", "Alexa.Button.SixthButton", "Alexa.Button.SeventhButton", + "Alexa.Button.EighthButton", "Alexa.Button.DoublePress", "Alexa.Button.DoublePush", "Alexa.Button.LongPress", + "Alexa.Button.LongPush", "Alexa.Button.SinglePress", "Alexa.Button.SinglePush", + "Alexa.DeviceName.AirPurifier", "Alexa.DeviceName.Camera", "Alexa.DeviceName.Fan", "Alexa.DeviceName.Router", + "Alexa.DeviceName.Shade", "Alexa.DeviceName.Shower", "Alexa.DeviceName.SpaceHeater", "Alexa.DeviceName.Washer", + "Alexa.Gesture.DoubleClick", "Alexa.Gesture.SingleClick", "Alexa.Gesture.SwipeDown", "Alexa.Gesture.SwipeLeft", + "Alexa.Gesture.SwipeRight", "Alexa.Gesture.SwipeUp", "Alexa.Gesture.Tap", + // "Gestures", plural: the page spells this one id differently from the seven above + "Alexa.Gestures.DoubleTap", + "Alexa.Setting.2GGuestWiFi", "Alexa.Setting.5GGuestWiFi", "Alexa.Setting.Auto", "Alexa.Setting.Direction", + "Alexa.Setting.DryCycle", "Alexa.Setting.FanSpeed", "Alexa.Setting.GuestWiFi", "Alexa.Setting.Heat", + "Alexa.Setting.Mode", "Alexa.Setting.Night", "Alexa.Setting.Opening", "Alexa.Setting.Oscillate", + "Alexa.Setting.Preset", "Alexa.Setting.Quiet", "Alexa.Setting.Temperature", "Alexa.Setting.WashCycle", + "Alexa.Setting.WaterTemperature", + "Alexa.Shower.HandHeld", "Alexa.Shower.RainHead", + "Alexa.Unit.Angle.Degrees", "Alexa.Unit.Angle.Radians", "Alexa.Unit.Distance.Feet", "Alexa.Unit.Distance.Inches", + "Alexa.Unit.Distance.Kilometers", "Alexa.Unit.Distance.Meters", "Alexa.Unit.Distance.Miles", + "Alexa.Unit.Distance.Yards", "Alexa.Unit.Mass.Grams", "Alexa.Unit.Mass.Kilograms", "Alexa.Unit.Percent", + "Alexa.Unit.Temperature.Celsius", "Alexa.Unit.Temperature.Degrees", "Alexa.Unit.Temperature.Fahrenheit", + "Alexa.Unit.Temperature.Kelvin", "Alexa.Unit.Volume.CubicFeet", "Alexa.Unit.Volume.CubicMeters", + "Alexa.Unit.Volume.Gallons", "Alexa.Unit.Volume.Liters", "Alexa.Unit.Volume.Pints", "Alexa.Unit.Volume.Quarts", + "Alexa.Unit.Weight.Ounces", "Alexa.Unit.Weight.Pounds", + "Alexa.Value.Close", "Alexa.Value.Delicate", "Alexa.Value.High", "Alexa.Value.Low", "Alexa.Value.Maximum", + "Alexa.Value.Medium", "Alexa.Value.Minimum", "Alexa.Value.Open", "Alexa.Value.QuickWash", +] as const; +export type AssetId = (typeof ASSETS)[number]; + +/** A unit of measure: the 23 Alexa.Unit.* assets, what a RangeController's unitOfMeasure takes. */ +export type UnitOfMeasure = Extract; +export const UNITS_OF_MEASURE = ASSETS.filter((id): id is UnitOfMeasure => id.startsWith("Alexa.Unit.")); + +/** The phrases an action mapping gives to a directive (alexa-discovery-objects.html, "ActionMappings object"). */ +export const ACTIONS = [ + "Alexa.Actions.Open", "Alexa.Actions.Close", "Alexa.Actions.Raise", "Alexa.Actions.Lower", + "Alexa.Actions.SetEcoOn", "Alexa.Actions.SetEcoOff", +] as const; +export type ActionId = (typeof ACTIONS)[number]; + +/** The states a state mapping gives to a property value (alexa-discovery-objects.html, "StateMappings object"). */ +export const STATES = [ + "Alexa.States.Open", "Alexa.States.Closed", "Alexa.States.EcoOn", "Alexa.States.EcoOff", "Alexa.States.Low", + "Alexa.States.Empty", "Alexa.States.Full", "Alexa.States.Done", "Alexa.States.Stuck", +] as const; +export type StateId = (typeof STATES)[number]; + +/** + * The 56 display categories (alexa-discovery.html, "Display categories"). The DisplayCategory enum also keeps + * VEHICLE from 1.x, which the page no longer lists. + */ +export const DISPLAY_CATEGORIES = [ + "ACTIVITY_TRIGGER", "AIR_CONDITIONER", "AIR_FRESHENER", "AIR_PURIFIER", "AIR_QUALITY_MONITOR", + "ALEXA_VOICE_ENABLED", "AUTO_ACCESSORY", "BLUETOOTH_SPEAKER", "CAMERA", "CHRISTMAS_TREE", "COFFEE_MAKER", + "COMPUTER", "CONTACT_SENSOR", "DISHWASHER", "DOOR", "DOORBELL", "DRYER", "EXTERIOR_BLIND", "FAN", "GAME_CONSOLE", + "GARAGE_DOOR", "HEADPHONES", "HUB", "INTERIOR_BLIND", "LAPTOP", "LIGHT", "MICROWAVE", "MOBILE_PHONE", + "MOTION_SENSOR", "MUSIC_SYSTEM", "NETWORK_HARDWARE", "OTHER", "OVEN", "PHONE", "PRINTER", "REMOTE", "ROUTER", + "SCENE_TRIGGER", "SCREEN", "SECURITY_PANEL", "SECURITY_SYSTEM", "SLOW_COOKER", "SMARTLOCK", "SMARTPLUG", "SPEAKER", + "STREAMING_DEVICE", "SWITCH", "TABLET", "TEMPERATURE_SENSOR", "THERMOSTAT", "TV", "VACUUM_CLEANER", "VACUUM", + "WASHER", "WATER_HEATER", "WEARABLE", +] as const; +export type DisplayCategoryName = (typeof DISPLAY_CATEGORIES)[number]; + +/** Not to be used as a friendly name (resources-and-assets.html, "Reserved words"). */ +export const RESERVED_WORDS = [ + "alarm", "alarms", "all alarms", "away mode", "bass", "camera", "date", "date today", "day", "do not disturb", + "drop in", "music", "night light", "notification", "playing", "sleep sounds", "time", "timer", "today in music", + "treble", "volume", "way f. m.", +] as const; + +/** + * What a discovery answer may hold (alexa-discovery.html, "Interface limits"; alexa-discovery-objects.html, + * "Endpoint object details" and "AdditionalAttributes object details"; alexa-scenecontroller.html, "Discovery"). + */ +export const LIMITS = { + endpointsPerCustomer: 300, + capabilitiesPerEndpoint: 100, + endpointIdLength: 256, + friendlyNameLength: 256, + sceneFriendlyNameLength: 128, + manufacturerNameLength: 128, + descriptionLength: 128, + additionalAttributeLength: 256, + cookieBytes: 5000, +} as const; + +// alexa-errorresponse.html, "Error type values". The Interface column names .ErrorResponse; the event +// header carries the namespace and the name ErrorResponse, as the examples on the ThermostatController, +// SecurityPanelController and Safety error pages show. The other namespaces are as the table spells them. +// INVALID_VALUE is listed under Alexa and under SmartVision.ObjectDetectionSensor, SUBSCRIPTION_REQUIRED under both +// SmartVision interfaces: the first one listed is kept. +const ERROR_TYPES_BY_NAMESPACE = { + "Alexa": [ + "ALREADY_IN_OPERATION", "BRIDGE_UNREACHABLE", "CLOUD_CONTROL_DISABLED", "DEVICE_STUCK", "DO_NOT_DISTURB_MODE", + "ENDPOINT_BUSY", "ENDPOINT_CONTROL_UNAVAILABLE", "ENDPOINT_LOW_POWER", "ENDPOINT_UNREACHABLE", + "EXPIRED_AUTHORIZATION_CREDENTIAL", "FIRMWARE_OUT_OF_DATE", "HARDWARE_MALFUNCTION", "INSUFFICIENT_PERMISSIONS", + "INSUFFICIENT_RESOURCE", "INTERNAL_ERROR", "INVALID_AUTHORIZATION_CREDENTIAL", "INVALID_DIRECTIVE", + "INVALID_VALUE", "MAINTENANCE_REQUIRED", "NO_SUCH_ENDPOINT", "NOT_CALIBRATED", "NOT_IN_OPERATION", + "NOT_SUPPORTED_IN_CURRENT_MODE", "NOT_SUPPORTED_WITH_CURRENT_BATTERY_CHARGE_STATE", + "PARTNER_APPLICATION_REDIRECTION", "POWER_LEVEL_NOT_SUPPORTED", "RATE_LIMIT_EXCEEDED", + "TEMPERATURE_VALUE_OUT_OF_RANGE", "TOO_MANY_FAILED_ATTEMPTS", "UNABLE_TO_CHARGE", "VALUE_OUT_OF_RANGE", + ], + "Alexa.Commissionable.ReportCommissioningInformation": [ + "FAILED_TO_BOOTSTRAP_COMMISSIONING_PROCESS", "MAX_COMMISSIONING_LIMIT_REACHED", + ], + "Alexa.Cooking": [ + "CHILD_LOCK", "COOK_DURATION_TOO_LONG", "DOOR_CLOSED_TOO_LONG", "DOOR_OPEN", "PREHEAT_REQUIRED", + "PROBE_REQUIRED", "REMOTE_START_NOT_SUPPORTED", "REMOVE_PROBE", "REMOTE_START_DISABLED", + ], + "Alexa.DataController": ["DATA_DELETION_NOT_SUPPORTED", "DATA_RETRIEVAL_NOT_SUPPORTED"], + "Alexa.Safety": ["OBSTACLE_DETECTED", "SAFETY_BEAM_BREACHED"], + "Alexa.SecurityPanelController": [ + "AUTHORIZATION_REQUIRED", "BYPASS_NEEDED", "NOT_READY", "UNAUTHORIZED", "UNCLEARED_ALARM", "UNCLEARED_TROUBLE", + ], + "Alexa.SmartVision.ObjectDetectionSensor": ["SUBSCRIPTION_REQUIRED"], + "Alexa.SmartVision.SnapshotProvider": ["DISABLED_BY_USER"], + "Alexa.ThermostatController": [ + "DUAL_SETPOINTS_UNSUPPORTED", "REQUESTED_SETPOINTS_TOO_CLOSE", "THERMOSTAT_IS_OFF", + "TRIPLE_SETPOINTS_UNSUPPORTED", "UNSUPPORTED_THERMOSTAT_MODE", "UNWILLING_TO_SET_SCHEDULE", + "UNWILLING_TO_SET_VALUE", + ], + "Alexa.ThermostatController.Configuration": [ + "CONFIGURATION_UPDATE_NOT_ALLOWED", "COOLING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE", + "COOLING_STAGES_EXCEEDS_LIMIT", "HEATING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE", "HEATING_STAGES_EXCEEDS_LIMIT", + "INVALID_AUXILIARY_HEATING_SYSTEM_TYPE", "INVALID_SYSTEM_TYPE", "INVALID_TARGET_STATE", + "INVALID_TEMPERATURE_SCALE", "INVALID_TERMINAL_CONNECTION", "MISSING_SETUP_INFORMATION", + ], + "Alexa.ThermostatController.Schedule": ["INSUFFICIENT_SPACE"], +} as const; + +/** The 73 error types of the table, each with the namespace its ErrorResponse goes under. */ +export const ERROR_TYPES: Readonly> = Object.fromEntries( + Object.entries(ERROR_TYPES_BY_NAMESPACE).flatMap(([namespace, types]) => types.map((type) => [type, namespace])) +); diff --git a/src/registry/index.ts b/src/registry/index.ts new file mode 100644 index 0000000..ead3ed9 --- /dev/null +++ b/src/registry/index.ts @@ -0,0 +1,53 @@ +// The interfaces the library knows, by namespace. +import { Alexa } from "./interfaces/Alexa.js"; +import { BrightnessController } from "./interfaces/BrightnessController.js"; +import { EndpointHealth } from "./interfaces/EndpointHealth.js"; +import { PowerController } from "./interfaces/PowerController.js"; +import { TemperatureSensor } from "./interfaces/TemperatureSensor.js"; +import { STUBS } from "./interfaces/stubs.js"; +import { DeclarationError } from "./types.js"; +import type { AnyDescriptor } from "./types.js"; + +const described: readonly AnyDescriptor[] = [ + Alexa, + BrightnessController, + EndpointHealth, + PowerController, + TemperatureSensor, +]; + +const descriptors = new Map(); +for (const descriptor of [...described, ...STUBS]) { + // A stub left in the table next to the descriptor that replaces it would win or lose by the order of this list + if (descriptors.has(descriptor.namespace)) throw new Error(`${descriptor.namespace} is described twice`); + descriptors.set(descriptor.namespace, descriptor); +} + +export const registry = { + /** Whether an interface of this name is known. */ + has(namespace: string): boolean { + return descriptors.has(namespace); + }, + + /** The descriptor of "Alexa.RangeController" or of AlexaInterfaceType.RANGE_CONTROLLER, which is that string. */ + get(namespace: string): AnyDescriptor { + const descriptor = descriptors.get(namespace); + if (!descriptor) throw new DeclarationError({}, `${JSON.stringify(namespace)} is not an interface alex2node knows`); + return descriptor; + }, + + /** Every descriptor, ordered by namespace. */ + list(): AnyDescriptor[] { + return [...descriptors.values()].sort((a, b) => (a.namespace < b.namespace ? -1 : 1)); + }, +}; + +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 { s, SchemaError } from "./schema.js"; +export type { Infer, Schema, Temperature, TimeInterval } from "./schema.js"; +export * from "./catalog.js"; diff --git a/src/registry/interfaces/Alexa.ts b/src/registry/interfaces/Alexa.ts new file mode 100644 index 0000000..6efec4f --- /dev/null +++ b/src/registry/interfaces/Alexa.ts @@ -0,0 +1,21 @@ +import { s } from "../schema.js"; +import { defineInterface } from "../types.js"; + +/** + * The base interface: every endpoint lists it (alexa-interface.html, "Support the Alexa interface in all add-ons"). + * Its one directive to a device is ReportState, answered with a StateReport. + */ +export const Alexa = defineInterface({ + namespace: "Alexa", + version: "3", + doc: "https://developer.amazon.com/docs/alexaplus/device-apis/alexa-interface.html", + kind: "base", + tier: 1, + instanced: false, + properties: {}, + directives: { + ReportState: { name: "ReportState", payload: s.object({}) }, + }, + // { type, interface, version } and nothing else, as in the discovery example of the page + discovery: () => ({ properties: false }), +}); diff --git a/src/registry/interfaces/BrightnessController.ts b/src/registry/interfaces/BrightnessController.ts new file mode 100644 index 0000000..5969da1 --- /dev/null +++ b/src/registry/interfaces/BrightnessController.ts @@ -0,0 +1,25 @@ +import { s } from "../schema.js"; +import { defineInterface } from "../types.js"; + +/** Both directives turn a light that is off on, at the brightness asked for (alexa-brightnesscontroller.html). */ +export const BrightnessController = defineInterface({ + namespace: "Alexa.BrightnessController", + version: "3", + doc: "https://developer.amazon.com/docs/alexaplus/device-apis/alexa-brightnesscontroller.html", + kind: "controller", + tier: 1, + instanced: false, + properties: { + brightness: { name: "brightness", value: s.number({ min: 0, max: 100, integer: true }) }, + }, + directives: { + SetBrightness: { + name: "SetBrightness", + payload: s.object({ brightness: s.number({ min: 0, max: 100, integer: true }) }), + }, + AdjustBrightness: { + name: "AdjustBrightness", + payload: s.object({ brightnessDelta: s.number({ min: -100, max: 100, integer: true }) }), + }, + }, +}); diff --git a/src/registry/interfaces/EndpointHealth.ts b/src/registry/interfaces/EndpointHealth.ts new file mode 100644 index 0000000..8e3121d --- /dev/null +++ b/src/registry/interfaces/EndpointHealth.ts @@ -0,0 +1,31 @@ +import { s } from "../schema.js"; +import { defineInterface } from "../types.js"; + +export const CONNECTIVITY_REASONS = [ + "WIFI_BAD_PASSWORD", "WIFI_AP_NOT_FOUND", "WIFI_ROUTER_UNREACHABLE", "WIFI_AP_CHANNEL_QUALITY_LOW", + "INTERNET_UNREACHABLE", "CAPTIVE_PORTAL_CHECK_FAILED", "UNKNOWN", +] as const; + +/** + * Version 3.1, as the page is titled. It defines connectivity only. Examples on other pages declare 3 and 3.2; + * alex2node 1.x declared 3.3, which no page mentions. + */ +export const EndpointHealth = defineInterface({ + namespace: "Alexa.EndpointHealth", + version: "3.1", + doc: "https://developer.amazon.com/docs/alexaplus/device-apis/alexa-endpointhealth.html", + kind: "sensor", + tier: 1, + instanced: false, + properties: { + connectivity: { + name: "connectivity", + value: s.object({ + value: s.enum("OK", "UNREACHABLE"), + reason: s.optional(s.enum(...CONNECTIVITY_REASONS)), + }), + note: "in every Response, StateReport and ChangeReport; a change of it reported within three seconds", + }, + }, + directives: {}, +}); diff --git a/src/registry/interfaces/PowerController.ts b/src/registry/interfaces/PowerController.ts new file mode 100644 index 0000000..67a9e66 --- /dev/null +++ b/src/registry/interfaces/PowerController.ts @@ -0,0 +1,28 @@ +import { s } from "../schema.js"; +import { defineInterface } from "../types.js"; + +export const PowerController = defineInterface({ + namespace: "Alexa.PowerController", + version: "3", + doc: "https://developer.amazon.com/docs/alexaplus/device-apis/alexa-powercontroller.html", + kind: "controller", + tier: 1, + instanced: false, + properties: { + powerState: { name: "powerState", value: s.enum("ON", "OFF") }, + }, + directives: { + TurnOn: { name: "TurnOn", payload: s.object({}) }, + TurnOff: { name: "TurnOff", payload: s.object({}) }, + }, + options: s.object({ + // The directives Alexa asks the user to confirm before it sends them. Amazon supports this for devices in Japan + // only (alexa-discovery-objects.html, "VerificationsRequired object"). + verificationsRequired: s.optional(s.array(s.enum("TurnOn", "TurnOff"), { min: 1 })), + }, { unknownKeys: "reject" }), + discovery({ options }) { + if (!options.verificationsRequired) return {}; + const confirmed = options.verificationsRequired.map((directive) => ({ directive, methods: [{ "@type": "Confirmation" }] })); + return { topLevel: { verificationsRequired: confirmed } }; + }, +}); diff --git a/src/registry/interfaces/TemperatureSensor.ts b/src/registry/interfaces/TemperatureSensor.ts new file mode 100644 index 0000000..327daca --- /dev/null +++ b/src/registry/interfaces/TemperatureSensor.ts @@ -0,0 +1,16 @@ +import { s } from "../schema.js"; +import { defineInterface } from "../types.js"; + +/** A sensor: an endpoint that has it declares Alexa.EndpointHealth as well (alexa-temperaturesensor.html). */ +export const TemperatureSensor = defineInterface({ + namespace: "Alexa.TemperatureSensor", + version: "3", + doc: "https://developer.amazon.com/docs/alexaplus/device-apis/alexa-temperaturesensor.html", + kind: "sensor", + tier: 1, + instanced: false, + properties: { + temperature: { name: "temperature", value: s.temperature() }, + }, + directives: {}, +}); diff --git a/src/registry/interfaces/stubs.ts b/src/registry/interfaces/stubs.ts new file mode 100644 index 0000000..218e5f4 --- /dev/null +++ b/src/registry/interfaces/stubs.ts @@ -0,0 +1,106 @@ +// Interfaces without a descriptor of their own. A stub names the interface with the version and the properties +// alex2node 1.5.2 put in discovery, so an endpoint that declares one is announced as before. Nothing about it is +// checked, and the version is the one 1.5.2 sent, which is not always the one on the page of the interface +// (Alexa.Speaker is "3" there). A row leaves this table when a descriptor is written from that page. +import { s } from "../schema.js"; +import type { AnyDescriptor } from "../types.js"; + +const DEVICE_APIS = "https://developer.amazon.com/docs/alexaplus/device-apis"; +const LIST_OF_INTERFACES = `${DEVICE_APIS}/list-of-interfaces.html`; +// "As of August 7, 2026, Automotive skills and the associated Alexa.AuthorizationController and +// Alexa.Automotive.VehicleData APIs are no longer available." +const DEPRECATED_FEATURES = "https://developer.amazon.com/en-US/docs/alexa/ask-overviews/deprecated-features.html"; + +// namespace, version, properties, page (a name under device-apis/, or a URL) +type Row = readonly [string, string, readonly string[], string]; + +const TABLE: readonly Row[] = [ + ["Alexa.ApplicationStateReporter", "1", [], "https://developer.amazon.com/docs/alexaplus/alexa-voice-service/alexa-applicationstatereporter.html"], + ["Alexa.Audio.PlayQueue", "1", [], "alexa-audio-playqueue.html"], + ["Alexa.AuthorizationController", "1", [], DEPRECATED_FEATURES], + ["Alexa.AutomationManagement", "1", ["automationStatuses"], "alexa-automationmanagement.html"], + ["Alexa.Automotive.VehicleData", "1", [], DEPRECATED_FEATURES], + // 1.5.2 announced the version "UNKNOWN"; 1.7 is what the list of interfaces gives + ["Alexa.Camera.LiveViewController", "1.7", [], LIST_OF_INTERFACES], + ["Alexa.CameraStreamController", "3", [], "alexa-camerastreamcontroller.html"], + ["Alexa.ChannelController", "3", ["channel"], "alexa-channelcontroller.html"], + ["Alexa.ColorController", "3", ["color"], "alexa-colorcontroller.html"], + ["Alexa.ColorTemperatureController", "3", ["colorTemperatureInKelvin"], "alexa-colortemperaturecontroller.html"], + ["Alexa.Commissionable", "1", [], "alexa-commissionable.html"], + ["Alexa.ConsentManagement.ConsentRequiredReporter", "1", [], "alexa-consentrequiredreporter.html"], + ["Alexa.ContactSensor", "3", ["detectionState"], "alexa-contactsensor.html"], + ["Alexa.Cooking", "1", [], "alexa-cooking.html"], + ["Alexa.Cooking.FoodTemperatureController", "1", [], "alexa-cooking-foodtemperaturecontroller.html"], + ["Alexa.Cooking.FoodTemperatureSensor", "1", [], "alexa-cooking-foodtemperaturesensor.html"], + ["Alexa.Cooking.PresetController", "1", [], "alexa-cooking-presetcontroller.html"], + ["Alexa.Cooking.TemperatureController", "1", [], "alexa-cooking-temperaturecontroller.html"], + ["Alexa.Cooking.TemperatureSensor", "1", [], "alexa-cooking-temperaturesensor.html"], + ["Alexa.Cooking.TimeController", "1", [], "alexa-cooking-timecontroller.html"], + ["Alexa.DataController", "1", [], "alexa-datacontroller.html"], + ["Alexa.DeviceUsage.Estimation", "1", [], "alexa-deviceusage-estimation.html"], + ["Alexa.DeviceUsage.Meter", "1", [], "alexa-deviceusage-meter.html"], + ["Alexa.DoorbellEventSource", "3", [], "alexa-doorbelleventsource.html"], + ["Alexa.EqualizerController", "1", [], "alexa-equalizercontroller.html"], + ["Alexa.InputController", "3", [], "alexa-inputcontroller.html"], + ["Alexa.InventoryLevelSensor", "1", [], "alexa-inventorylevelsensor.html"], + ["Alexa.InventoryLevelUsageSensor", "1", [], "alexa-inventorylevelusagesensor.html"], + ["Alexa.InventoryUsageSensor", "1", [], "alexa-inventoryusagesensor.html"], + ["Alexa.KeypadController", "1", [], "alexa-keypadcontroller.html"], + ["Alexa.Launcher", "1.1", [], "alexa-launcher.html"], + ["Alexa.LockController", "3", [], "alexa-lockcontroller.html"], + ["Alexa.Media.PlayQueue", "1", [], "alexa-media-playqueue.html"], + ["Alexa.Media.Playback", "1", [], "alexa-media-playback.html"], + ["Alexa.Media.Search", "1", [], "alexa-media-search.html"], + ["Alexa.ModeController", "3", ["mode"], "alexa-modecontroller.html"], + ["Alexa.MotionSensor", "3", [], "alexa-motionsensor.html"], + ["Alexa.PercentageController", "3", [], "alexa-percentagecontroller.html"], + ["Alexa.PlaybackController", "3", [], "alexa-playbackcontroller.html"], + ["Alexa.PlaybackStateReporter", "1", [], "alexa-playbackcontroller.html"], + ["Alexa.PowerLevelController", "3", [], "alexa-powerlevelcontroller.html"], + ["Alexa.ProactiveNotificationSource", "1", [], "alexa-proactivenotificationsource.html"], + ["Alexa.RTCSessionController", "1", [], "alexa-rtcsessioncontroller.html"], + ["Alexa.RangeController", "3", [], "alexa-rangecontroller.html"], + ["Alexa.RecordController", "3", [], "alexa-recordcontroller.html"], + ["Alexa.RemoteVideoPlayer", "1", [], "alexa-remotevideoplayer.html"], + ["Alexa.SceneController", "3", [], "alexa-scenecontroller.html"], + ["Alexa.SecurityPanelController", "1", [], "alexa-securitypanelcontroller.html"], + ["Alexa.SecurityPanelController.Alert", "1", [], "alexa-securitypanelcontroller-alert.html"], + ["Alexa.SeekController", "3", [], "alexa-seekcontroller.html"], + ["Alexa.SimpleEventSource", "1", [], "alexa-simpleeventsource.html"], + ["Alexa.SmartVision.ObjectDetectionSensor", "1", [], "alexa-smartvision-objectdetectionsensor.html"], + ["Alexa.SmartVision.SnapshotProvider", "1", [], "alexa-smartvision-snapshotprovider.html"], + ["Alexa.Speaker", "1", [], "alexa-speaker.html"], + ["Alexa.StepSpeaker", "1", [], "alexa-stepspeaker.html"], + ["Alexa.ThermostatController", "3.2", ["targetSetpoint", "lowerSetpoint", "upperSetpoint", "thermostatMode"], "alexa-thermostatcontroller.html"], + ["Alexa.ThermostatController.Configuration", "1", [], "alexa-thermostatcontroller-configuration.html"], + ["Alexa.ThermostatController.HVAC.Components", "1", [], "alexa-thermostatcontroller-hvac-components.html"], + ["Alexa.ThermostatController.Schedule", "1", [], "alexa-thermostatcontroller-schedule.html"], + // "UNKNOWN" in 1.5.2 as well; the page is titled "Interface 3" + ["Alexa.TimeHoldController", "3", [], "alexa-timeholdcontroller.html"], + ["Alexa.ToggleController", "3", ["toggleState"], "alexa-togglecontroller.html"], + ["Alexa.UIController", "1", [], "alexa-uicontroller.html"], + ["Alexa.UserPreference", "1", [], "alexa-userpreference.html"], + ["Alexa.VideoRecorder", "1", [], "alexa-videorecorder.html"], + ["Alexa.WakeOnLANController", "1", [], "alexa-wakeonlancontroller.html"], +]; + +function kindOf(namespace: string): AnyDescriptor["kind"] { + if (namespace.endsWith("Sensor")) return "sensor"; + if (namespace.endsWith("EventSource")) return "eventSource"; + return "controller"; +} + +function stub([namespace, version, properties, page]: Row): AnyDescriptor { + return { + namespace, + version, + doc: page.startsWith("https://") ? page : `${DEVICE_APIS}/${page}`, + kind: kindOf(namespace), + tier: 3, + instanced: false, + properties: Object.fromEntries(properties.map((name) => [name, { name, value: s.unknown() }])), + directives: {}, + }; +} + +export const STUBS: readonly AnyDescriptor[] = TABLE.map(stub); diff --git a/src/registry/schema.ts b/src/registry/schema.ts new file mode 100644 index 0000000..0d99459 --- /dev/null +++ b/src/registry/schema.ts @@ -0,0 +1,241 @@ +// Values checked at run time and typed at compile time. A descriptor states its property values, directive payloads +// 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. + +/** A value that does not fit its schema: path is where ("payload.targetSetpoint.scale"), problem is what. */ +export class SchemaError extends Error { + constructor( + readonly path: string, + readonly problem: string + ) { + super(path ? `${path}: ${problem}` : problem); + this.name = "SchemaError"; + } +} + +export interface Schema { + /** What the schema accepts, in the words of an error message: "an integer from 0 to 100", "ON | OFF". */ + readonly expects: string; + /** The checked value. path names it in the error message. */ + parse(input: unknown, path?: string): T; +} + +/** A schema that accepts a missing value; s.object() makes its key optional in the inferred type. */ +export interface OptionalSchema extends Schema { + readonly optional: true; +} + +export interface EnumSchema extends Schema { + readonly values: readonly V[]; +} + +/** The type a schema produces. */ +export type Infer = S extends Schema ? T : never; + +export type Shape = Record>; +type OptionalKeys = { [K in keyof S]: S[K] extends OptionalSchema ? K : never }[keyof S]; +export type InferShape = { [K in Exclude>]: Infer } & { + [K in OptionalKeys]?: Infer; +}; + +export interface NumberRules { + min?: number; + max?: number; + /** Greater than, the bound itself excluded. */ + gt?: number; + integer?: boolean; +} + +export interface Temperature { + value: number; + scale: "CELSIUS" | "FAHRENHEIT" | "KELVIN"; +} + +export interface TimeInterval { + start?: string; + end?: string; + duration?: string; +} + +// The input as an error message shows it: short, and quoted when it is text. +function shown(input: unknown): string { + if (input === undefined) return "nothing"; + if (typeof input === "function") return "a function"; + const text = JSON.stringify(input) ?? String(input); + return text.length > 60 ? `${text.slice(0, 57)}...` : text; +} + +function mismatch(path: string, expects: string, input: unknown): SchemaError { + return new SchemaError(path, `expected ${expects}, got ${shown(input)}`); +} + +const at = (path: string, key: string): string => (path ? `${path}.${key}` : key); + +function isRecord(input: unknown): input is Record { + return typeof input === "object" && input !== null && !Array.isArray(input); +} + +function schema(expects: string, accepts: (input: unknown) => boolean): Schema { + return { + expects, + parse(input, path = "") { + if (!accepts(input)) throw mismatch(path, expects, input); + return input as T; + }, + }; +} + +function numberText({ min, max, gt, integer }: NumberRules): string { + const kind = integer ? "an integer" : "a number"; + if (min !== undefined && max !== undefined) return `${kind} from ${min} to ${max}`; + if (min !== undefined) return `${kind} of ${min} or more`; + if (max !== undefined) return `${kind} of ${max} or less`; + if (gt !== undefined) return `${kind} greater than ${gt}`; + return kind; +} + +function number(rules: NumberRules = {}): Schema { + const { min = -Infinity, max = Infinity, gt = -Infinity, integer = false } = rules; + return schema(numberText(rules), (input) => + typeof input === "number" && Number.isFinite(input) && input >= min && input <= max && input > gt + && (!integer || Number.isInteger(input))); +} + +function string(rules: { min?: number; max?: number; pattern?: RegExp; expects?: string } = {}): Schema { + const { min = 0, max = Infinity, pattern } = rules; + const length = max === Infinity ? (min > 0 ? ` of ${min} or more characters` : "") : ` of ${min} to ${max} characters`; + return schema(rules.expects ?? `a string${length}`, (input) => + typeof input === "string" && input.length >= min && input.length <= max && (!pattern || pattern.test(input))); +} + +function boolean(): Schema { + return schema("true or false", (input) => typeof input === "boolean"); +} + +function literal(value: V): Schema { + return schema(JSON.stringify(value), (input) => input === value); +} + +function enumeration(...values: V): EnumSchema { + return { ...schema(values.join(" | "), (input) => values.includes(input as string)), values }; +} + +function unknown(): Schema { + return schema("any value", () => true); +} + +function optional(inner: Schema): OptionalSchema { + return { + expects: inner.expects, + optional: true, + parse: (input, path = "") => (input === undefined ? undefined : inner.parse(input, path)), + }; +} + +function nullable(inner: Schema): Schema { + const expects = `${inner.expects} or null`; + return { + expects, + parse(input, path = "") { + if (input === null) return null; + try { + return inner.parse(input, path); + } catch (err) { + // The value itself is of the wrong kind: null was a choice too. An error further in keeps its own path. + if (err instanceof SchemaError && err.path === path) throw mismatch(path, expects, input); + throw err; + } + }, + }; +} + +function array(item: Schema, rules: { min?: number; max?: number } = {}): Schema { + const { min = 0, max = Infinity } = rules; + const count = max === Infinity ? (min > 0 ? ` with ${min} or more entries` : "") : ` with ${min} to ${max} entries`; + const expects = `a list${count}`; + return { + expects, + parse(input, path = "") { + if (!Array.isArray(input) || input.length < min || input.length > max) throw mismatch(path, expects, input); + return input.map((entry, i) => item.parse(entry, `${path}[${i}]`)); + }, + }; +} + +/** + * An object with the keys of shape. Keys the shape does not name are kept as they are: a field Alexa adds to a + * directive reaches the handler. unknownKeys "reject" is for what a developer writes, where such a key is a typo. + */ +function object(shape: S, rules: { unknownKeys?: "keep" | "reject" } = {}): Schema> { + const known = Object.keys(shape); + const expects = known.length > 0 ? `an object with ${known.join(", ")}` : "an object"; + return { + expects, + parse(input, path = "") { + if (!isRecord(input)) 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"}`); + } + const parsed: Record = {}; + for (const key of known) { + const value = shape[key].parse(input[key], at(path, key)); + if (value !== undefined) parsed[key] = value; + } + for (const key of others) parsed[key] = input[key]; + return parsed as InferShape; + }, + }; +} + +// alexa-property-schemas.html "Temperature", "Temperature scales" +function temperature(): Schema { + return object({ value: number(), scale: enumeration("CELSIUS", "FAHRENHEIT", "KELVIN") }); +} + +// alexa-property-schemas.html "DateTime": UTC, no offsets. The seconds are optional here because the TimeInterval +// examples on the same page leave them out ("2017-10-04T14:00Z"). +const DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?Z$/; +function dateTime(): Schema { + return schema("a UTC time like 2017-08-30T01:18:21Z", (input) => + typeof input === "string" && DATE_TIME.test(input) && !Number.isNaN(Date.parse(input))); +} + +// alexa-property-schemas.html "Duration": the time portion of ISO 8601, negative for a delta ("PT-30S") +const DURATION = /^PT(?=.)(-?\d+H)?(-?\d+M)?(-?\d+S)?$/; +function duration(): Schema { + return string({ pattern: DURATION, expects: "a duration like PT3M15S" }); +} + +// alexa-property-schemas.html "TimeInterval": "Specify one or two of the time interval fields. If you specify all +// three fields, an error occurs." +function timeInterval(): Schema { + const fields = object({ start: optional(dateTime()), end: optional(dateTime()), duration: optional(duration()) }); + const expects = "a time interval with one or two of start, end, duration"; + return { + expects, + parse(input, path = "") { + const interval = fields.parse(input, path); + const given = [interval.start, interval.end, interval.duration].filter((field) => field !== undefined).length; + if (given < 1 || given > 2) throw mismatch(path, expects, input); + return interval; + }, + }; +} + +export const s = { + string, + number, + boolean, + literal, + enum: enumeration, + unknown, + optional, + nullable, + array, + object, + temperature, + dateTime, + duration, + timeInterval, +}; diff --git a/src/registry/types.ts b/src/registry/types.ts new file mode 100644 index 0000000..691e356 --- /dev/null +++ b/src/registry/types.ts @@ -0,0 +1,143 @@ +// What the library knows about one Alexa interface, as data. Discovery JSON, property values and directive payloads +// are produced and checked from a descriptor, so adding an interface is adding one file under interfaces/. +import type { Schema } from "./schema.js"; + +/** + * A friendly name: text in one locale, or an asset of the global Alexa catalog, which stands for several names in + * every language Alexa speaks (resources-and-assets.html, "Label object"). + */ +export type Label = + | { "@type": "text"; value: { text: string; locale: string } } + | { "@type": "asset"; value: { assetId: string } }; + +export interface PropertyDescriptor { + name: string; + value: Schema; + note?: string; +} + +export interface DirectiveDescriptor { + name: string; + payload: Schema; + /** Alexa sends the directive only to a capability declared like this (AdjustMode: an ordered mode). */ + when?: (capability: Declared) => boolean; + note?: string; +} + +/** An event a device raises that is not a Response: ActivationStarted, DoorbellPress. */ +export interface EventDescriptor { + name: string; + /** Default: the namespace of the interface. */ + namespace?: string; + /** Default: the version of the interface. */ + payloadVersion?: string; + payload: Schema; + /** response: the answer to a directive. proactive: sent without one. */ + topic: "response" | "proactive"; +} + +/** What an interface adds to the capability object in discovery, next to the fields every capability has. */ +export interface CapabilityExtras { + configuration?: Record; + /** Alexa.EqualizerController spells it in the plural. */ + configurations?: Record; + /** Fields of the capability object itself: supportsDeactivation, supportedOperations, inputs. */ + topLevel?: Record; + /** false: the capability has no properties object (the Alexa interface, a scene). */ + properties?: false; +} + +/** A capability as declared on an endpoint: what discovery() and validate() of its descriptor are given. */ +export interface Declared> { + readonly endpointId: string; + readonly namespace: string; + /** "" when the interface is declared without an instance. */ + readonly instance: string; + readonly friendlyNames: readonly Label[]; + readonly retrievable: boolean; + readonly proactivelyReported: boolean; + readonly nonControllable?: boolean; + /** The options of the interface, as its options schema returned them. */ + readonly options: O; +} + +/** The endpoint a capability is declared on, as validate() sees it. */ +export interface EndpointView { + readonly endpointId: string; + readonly friendlyName: string; + readonly description: string; + readonly displayCategories: readonly string[]; + /** The other capabilities of the endpoint. */ + readonly capabilities: readonly Declared[]; +} + +export type Properties = Record>; +export type Directives = Record>; + +export interface InterfaceDescriptor< + P extends Properties = Properties, + D extends Directives = Directives, + O = {}, + I extends boolean = boolean +> { + /** "Alexa.RangeController" */ + namespace: string; + /** As the title of the interface's page gives it: "3", "3.1", "1.0". */ + version: string; + /** The page the descriptor was written from. */ + doc: string; + kind: "base" | "controller" | "sensor" | "eventSource"; + /** 1 and 2: described in full. 3: a stub, the namespace with its version and property names and nothing checked. */ + tier: 1 | 2 | 3; + /** A generic controller: each capability needs an instance name and friendly names. */ + instanced: I; + /** The properties the interface reports. */ + properties: P; + /** The directives Alexa sends. */ + directives: D; + events?: Record; + /** What a declaration can set beyond the fields every capability has: a range, the supported modes. */ + options?: Schema; + discovery?: (capability: Declared) => CapabilityExtras; + /** The event that answers a directive when it is not Alexa / Response (Arm -> Arm.Response). */ + responseFor?: (directive: string) => { namespace: string; name: string; payload?: Schema } | undefined; + /** The namespace an ErrorResponse goes under when its type is one of errorTypes. */ + errorNamespace?: string; + errorTypes?: readonly string[]; + /** Amazon documents a DeferredResponse for the interface. */ + deferrable?: boolean; + /** Rules a schema cannot state. Throws DeclarationError. */ + validate?: (capability: Declared, endpoint: EndpointView) => void; +} + +export type AnyDescriptor = InterfaceDescriptor; + +/** Types a descriptor from what it is given: the property names, the payload of each directive, the options. */ +export function defineInterface

( + descriptor: InterfaceDescriptor +): InterfaceDescriptor { + return descriptor; +} + +/** A device, a capability or an interface name declared in a way Alexa would reject. Thrown where it is declared. */ +export class DeclarationError extends Error { + readonly endpointId?: string; + readonly namespace?: string; + readonly instance?: string; + readonly problem: string; + + constructor(where: { endpointId?: string; namespace?: string; instance?: string }, problem: string) { + const capability = where.namespace && where.instance ? `${where.namespace} "${where.instance}"` : where.namespace; + super([where.endpointId && shortened(where.endpointId), capability, problem].filter(Boolean).join(": ")); + this.name = "DeclarationError"; + this.endpointId = where.endpointId; + this.namespace = where.namespace; + this.instance = where.instance || undefined; + this.problem = problem; + } +} + +// An endpointId may be the very thing that is wrong, 300 characters of it. +function shortened(text: string): string { + return text.length > 64 ? `${text.slice(0, 61)}...` : text; +} diff --git a/test/discovery.test.js b/test/discovery.test.js index 78052fa..b8a1492 100644 --- a/test/discovery.test.js +++ b/test/discovery.test.js @@ -102,3 +102,15 @@ test("ThermostatController discovery: version 3.2, targetSetpoint listed with bo ["targetSetpoint", "lowerSetpoint", "upperSetpoint", "thermostatMode"] ); }); + +test("EndpointHealth discovery: version 3.1 with connectivity (1.x announced 3.3, which no Alexa page mentions)", async () => { + const { alexa, bridge } = await setup(Alex2MQTT, "root"); + const lamp = bridge.registerDevice("Lamp", "lamp-1", DisplayCategory.LIGHT); + lamp.addCapability(AlexaInterfaceType.POWER_CONTROLLER); + lamp.addCapability(AlexaInterfaceType.ENDPOINT_HEALTH, { proactivelyReported: true }); + + const [endpoint] = await discover(alexa, "root"); + const health = endpoint.capabilities.find((c) => c.interface === "Alexa.EndpointHealth"); + assert.equal(health.version, "3.1"); + assert.deepEqual(health.properties, { retrievable: true, proactivelyReported: true, supported: [{ name: "connectivity" }] }); +}); diff --git a/test/fixtures/alexa-docs/alexa-brightnesscontroller/AdjustBrightness.directive.json b/test/fixtures/alexa-docs/alexa-brightnesscontroller/AdjustBrightness.directive.json new file mode 100644 index 0000000..ae47cbb --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-brightnesscontroller/AdjustBrightness.directive.json @@ -0,0 +1,22 @@ +{ + "directive": { + "header": { + "namespace": "Alexa.BrightnessController", + "name": "AdjustBrightness", + "messageId": "Unique version 4 UUID", + "correlationToken": "Opaque correlation token", + "payloadVersion": "3" + }, + "endpoint": { + "scope": { + "type": "BearerToken", + "token": "OAuth2.0 bearer token" + }, + "endpointId": "Endpoint ID", + "cookie": {} + }, + "payload": { + "brightnessDelta": -25 + } + } +} diff --git a/test/fixtures/alexa-docs/alexa-brightnesscontroller/AdjustBrightness.response.json b/test/fixtures/alexa-docs/alexa-brightnesscontroller/AdjustBrightness.response.json new file mode 100644 index 0000000..c6a87a6 --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-brightnesscontroller/AdjustBrightness.response.json @@ -0,0 +1,30 @@ +{ + "event": { + "header": { + "namespace": "Alexa", + "name": "Response", + "messageId": "Unique identifier, preferably a version 4 UUID", + "correlationToken": "Opaque correlation token that matches the request", + "payloadVersion": "3" + }, + "endpoint": { + "scope": { + "type": "BearerToken", + "token": "OAuth2.0 bearer token" + }, + "endpointId": "Endpoint ID" + }, + "payload": {} + }, + "context": { + "properties": [ + { + "namespace": "Alexa.BrightnessController", + "name": "brightness", + "value": 75, + "timeOfSample": "2017-02-03T16:20:50.52Z", + "uncertaintyInMilliseconds": 1000 + } + ] + } +} diff --git a/test/fixtures/alexa-docs/alexa-brightnesscontroller/ChangeReport.json b/test/fixtures/alexa-docs/alexa-brightnesscontroller/ChangeReport.json new file mode 100644 index 0000000..035f46a --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-brightnesscontroller/ChangeReport.json @@ -0,0 +1,42 @@ +{ + "event": { + "header": { + "namespace": "Alexa", + "name": "ChangeReport", + "messageId": "Unique identifier, preferably a version 4 UUID", + "payloadVersion": "3" + }, + "endpoint": { + "scope": { + "type": "BearerToken", + "token": "OAuth2.0 bearer token" + }, + "endpointId": "Endpoint ID" + }, + "payload": { + "change": { + "cause": { + "type": "PHYSICAL_INTERACTION" + }, + "properties": [ + { + "namespace": "Alexa.BrightnessController", + "name": "brightness", + "value": 75, + "timeOfSample": "2024-02-03T16:17:00.00Z", + "uncertaintyInMilliseconds": 0 + } + ] + } + } + }, + "context": { + "namespace": "Alexa.EndpointHealth", + "name": "connectivity", + "value": { + "value": "OK" + }, + "timeOfSample": "2024-02-03T16:15:00.00Z", + "uncertaintyInMilliseconds": 0 + } +} diff --git a/test/fixtures/alexa-docs/alexa-brightnesscontroller/SetBrightness.directive.json b/test/fixtures/alexa-docs/alexa-brightnesscontroller/SetBrightness.directive.json new file mode 100644 index 0000000..1c42ca4 --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-brightnesscontroller/SetBrightness.directive.json @@ -0,0 +1,22 @@ +{ + "directive": { + "header": { + "namespace": "Alexa.BrightnessController", + "name": "SetBrightness", + "messageId": "Unique version 4 UUID", + "correlationToken": "Opaque correlation token", + "payloadVersion": "3" + }, + "endpoint": { + "scope": { + "type": "BearerToken", + "token": "OAuth2.0 bearer token" + }, + "endpointId": "Endpoint ID", + "cookie": {} + }, + "payload": { + "brightness": 50 + } + } +} diff --git a/test/fixtures/alexa-docs/alexa-brightnesscontroller/SetBrightness.response.json b/test/fixtures/alexa-docs/alexa-brightnesscontroller/SetBrightness.response.json new file mode 100644 index 0000000..9f111de --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-brightnesscontroller/SetBrightness.response.json @@ -0,0 +1,30 @@ +{ + "event": { + "header": { + "namespace": "Alexa", + "name": "Response", + "messageId": "Unique identifier, preferably a version 4 UUID", + "correlationToken": "Opaque correlation token that matches the request", + "payloadVersion": "3" + }, + "endpoint": { + "scope": { + "type": "BearerToken", + "token": "OAuth2.0 bearer token" + }, + "endpointId": "Endpoint ID" + }, + "payload": {} + }, + "context": { + "properties": [ + { + "namespace": "Alexa.BrightnessController", + "name": "brightness", + "value": 50, + "timeOfSample": "2017-02-03T16:20:50.52Z", + "uncertaintyInMilliseconds": 500 + } + ] + } +} diff --git a/test/fixtures/alexa-docs/alexa-brightnesscontroller/StateReport.json b/test/fixtures/alexa-docs/alexa-brightnesscontroller/StateReport.json new file mode 100644 index 0000000..458747d --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-brightnesscontroller/StateReport.json @@ -0,0 +1,30 @@ +{ + "event": { + "header": { + "namespace": "Alexa", + "name": "StateReport", + "messageId": "Unique identifier, preferably a version 4 UUID", + "correlationToken": "Opaque correlation token that matches the request", + "payloadVersion": "3" + }, + "endpoint": { + "scope": { + "type": "BearerToken", + "token": "OAuth2.0 bearer token" + }, + "endpointId": "Endpoint ID" + }, + "payload": {} + }, + "context": { + "properties": [ + { + "namespace": "Alexa.BrightnessController", + "name": "brightness", + "value": 75, + "timeOfSample": "2017-02-03T16:20:50.52Z", + "uncertaintyInMilliseconds": 1000 + } + ] + } +} diff --git a/test/fixtures/alexa-docs/alexa-brightnesscontroller/discovery.json b/test/fixtures/alexa-docs/alexa-brightnesscontroller/discovery.json new file mode 100644 index 0000000..ededae8 --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-brightnesscontroller/discovery.json @@ -0,0 +1,81 @@ +{ + "event": { + "header": { + "namespace": "Alexa.Discovery", + "name": "Discover.Response", + "payloadVersion": "3", + "messageId": "Unique identifier, preferably a version 4 UUID" + }, + "payload": { + "endpoints": [ + { + "endpointId": "Unique ID of the endpoint", + "manufacturerName": "Manufacturer of the endpoint", + "description": "Description to be shown in the Alexa app", + "friendlyName": "Living Room Light", + "displayCategories": [ + "LIGHT" + ], + "additionalAttributes": { + "manufacturer": "Manufacturer of the endpoint", + "model": "Model of the device", + "serialNumber": "Serial number of the device", + "firmwareVersion": "Firmware version of the device", + "softwareVersion": "Software version of the device", + "customIdentifier": "Optional custom identifier for the device" + }, + "cookie": {}, + "capabilities": [ + { + "type": "AlexaInterface", + "interface": "Alexa.BrightnessController", + "version": "3", + "properties": { + "supported": [ + { + "name": "brightness" + } + ], + "proactivelyReported": true, + "retrievable": true + } + }, + { + "type": "AlexaInterface", + "interface": "Alexa.ColorController", + "version": "3", + "properties": { + "supported": [ + { + "name": "color" + } + ], + "proactivelyReported": true, + "retrievable": true + } + }, + { + "type": "AlexaInterface", + "interface": "Alexa.EndpointHealth", + "version": "3", + "properties": { + "supported": [ + { + "name": "connectivity" + } + ], + "proactivelyReported": true, + "retrievable": true + } + }, + { + "type": "AlexaInterface", + "interface": "Alexa", + "version": "3" + } + ] + } + ] + } + } +} diff --git a/test/fixtures/alexa-docs/alexa-endpointhealth/connectivity.property.json b/test/fixtures/alexa-docs/alexa-endpointhealth/connectivity.property.json new file mode 100644 index 0000000..c2aeff2 --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-endpointhealth/connectivity.property.json @@ -0,0 +1,7 @@ +{ + "name": "connectivity", + "value": { + "value": "UNREACHABLE", + "reason": "WIFI_BAD_PASSWORD" + } +} diff --git a/test/fixtures/alexa-docs/alexa-endpointhealth/discovery.json b/test/fixtures/alexa-docs/alexa-endpointhealth/discovery.json new file mode 100644 index 0000000..5cb9188 --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-endpointhealth/discovery.json @@ -0,0 +1,67 @@ +{ + "event": { + "header": { + "namespace": "Alexa.Discovery", + "name": "Discover.Response", + "payloadVersion": "3", + "messageId": "Unique identifier, preferably a version 4 UUID" + }, + "payload": { + "endpoints": [ + { + "endpointId": "Unique ID of the endpoint", + "manufacturerName": "Sample Manufacturer", + "description": "Description to be shown in the Alexa app", + "friendlyName": "Your device name, displayed in the Alexa app, for example Front Door>", + "displayCategories": [ + "SMARTLOCK" + ], + "additionalAttributes": { + "manufacturer": "Sample Manufacturer", + "model": "Sample Model", + "serialNumber": "Serial number of the device", + "firmwareVersion": "Firmware version of the device", + "softwareVersion": "Software version of the device", + "customIdentifier": "Optional custom identifier for the device" + }, + "cookie": {}, + "capabilities": [ + { + "type": "AlexaInterface", + "interface": "Alexa.LockController", + "version": "3", + "properties": { + "supported": [ + { + "name": "lockState" + } + ], + "proactivelyReported": true, + "retrievable": true + } + }, + { + "type": "AlexaInterface", + "interface": "Alexa.EndpointHealth", + "version": "3.1", + "properties": { + "supported": [ + { + "name": "connectivity" + } + ], + "proactivelyReported": true, + "retrievable": true + } + }, + { + "type": "AlexaInterface", + "interface": "Alexa", + "version": "3" + } + ] + } + ] + } + } +} diff --git a/test/fixtures/alexa-docs/alexa-interface/discovery.json b/test/fixtures/alexa-docs/alexa-interface/discovery.json new file mode 100644 index 0000000..675a338 --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-interface/discovery.json @@ -0,0 +1,45 @@ +{ + "event": { + "header": { + "namespace": "Alexa.Discovery", + "name": "Discover.Response", + "payloadVersion": "3", + "messageId": "Unique identifier, preferably a version 4 UUID" + }, + "payload": { + "endpoints": [ + { + "endpointId": "Unique ID of the endpoint", + "manufacturerName": "Manufacturer of the endpoint", + "description": "Description to be shown in the Alexa app", + "friendlyName": "Device name, displayed in the Alexa app", + "displayCategories": [ + "LIGHT" + ], + "cookie": {}, + "capabilities": [ + { + "type": "AlexaInterface", + "interface": "Alexa.PowerController", + "version": "3", + "properties": { + "supported": [ + { + "name": "powerState" + } + ], + "proactivelyReported": true, + "retrievable": true + } + }, + { + "type": "AlexaInterface", + "interface": "Alexa", + "version": "3" + } + ] + } + ] + } + } +} diff --git a/test/fixtures/alexa-docs/alexa-powercontroller/ChangeReport.json b/test/fixtures/alexa-docs/alexa-powercontroller/ChangeReport.json new file mode 100644 index 0000000..1c5302b --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-powercontroller/ChangeReport.json @@ -0,0 +1,42 @@ +{ + "event": { + "header": { + "namespace": "Alexa", + "name": "ChangeReport", + "messageId": "Unique identifier, preferably a version 4 UUID", + "payloadVersion": "3" + }, + "endpoint": { + "scope": { + "type": "BearerToken", + "token": "OAuth2.0 bearer token" + }, + "endpointId": "Endpoint ID" + }, + "payload": { + "change": { + "cause": { + "type": "PHYSICAL_INTERACTION" + }, + "properties": [ + { + "namespace": "Alexa.PowerController", + "name": "powerState", + "value": "ON", + "timeOfSample": "2024-05-01T09:32:05.05Z", + "uncertaintyInMilliseconds": 0 + } + ] + } + } + }, + "context": { + "namespace": "Alexa.EndpointHealth", + "name": "connectivity", + "value": { + "value": "OK" + }, + "timeOfSample": "2024-05-01T09:31:00.00Z", + "uncertaintyInMilliseconds": 0 + } +} diff --git a/test/fixtures/alexa-docs/alexa-powercontroller/StateReport.json b/test/fixtures/alexa-docs/alexa-powercontroller/StateReport.json new file mode 100644 index 0000000..8aa0f34 --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-powercontroller/StateReport.json @@ -0,0 +1,30 @@ +{ + "event": { + "header": { + "namespace": "Alexa", + "name": "StateReport", + "messageId": "Unique identifier, preferably a version 4 UUID", + "correlationToken": "Opaque correlation token that matches the request", + "payloadVersion": "3" + }, + "endpoint": { + "scope": { + "type": "BearerToken", + "token": "OAuth2.0 bearer token" + }, + "endpointId": "Endpoint ID" + }, + "payload": {} + }, + "context": { + "properties": [ + { + "namespace": "Alexa.PowerController", + "name": "powerState", + "value": "OFF", + "timeOfSample": "2017-02-03T16:20:50.52Z", + "uncertaintyInMilliseconds": 0 + } + ] + } +} diff --git a/test/fixtures/alexa-docs/alexa-powercontroller/TurnOff.directive.json b/test/fixtures/alexa-docs/alexa-powercontroller/TurnOff.directive.json new file mode 100644 index 0000000..dd5e986 --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-powercontroller/TurnOff.directive.json @@ -0,0 +1,20 @@ +{ + "directive": { + "header": { + "namespace": "Alexa.PowerController", + "name": "TurnOff", + "messageId": "Unique version 4 UUID", + "correlationToken": "Opaque correlation token", + "payloadVersion": "3" + }, + "endpoint": { + "scope": { + "type": "BearerToken", + "token": "OAuth2.0 bearer token" + }, + "endpointId": "Endpoint ID", + "cookie": {} + }, + "payload": {} + } +} diff --git a/test/fixtures/alexa-docs/alexa-powercontroller/TurnOff.response.json b/test/fixtures/alexa-docs/alexa-powercontroller/TurnOff.response.json new file mode 100644 index 0000000..4161c46 --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-powercontroller/TurnOff.response.json @@ -0,0 +1,30 @@ +{ + "event": { + "header": { + "namespace": "Alexa", + "name": "Response", + "messageId": "Unique identifier, preferably a version 4 UUID", + "correlationToken": "Opaque correlation token that matches the request", + "payloadVersion": "3" + }, + "endpoint": { + "scope": { + "type": "BearerToken", + "token": "OAuth2.0 bearer token" + }, + "endpointId": "Endpoint ID" + }, + "payload": {} + }, + "context": { + "properties": [ + { + "namespace": "Alexa.PowerController", + "name": "powerState", + "value": "OFF", + "timeOfSample": "2017-02-03T16:20:50.52Z", + "uncertaintyInMilliseconds": 500 + } + ] + } +} diff --git a/test/fixtures/alexa-docs/alexa-powercontroller/TurnOn.directive.json b/test/fixtures/alexa-docs/alexa-powercontroller/TurnOn.directive.json new file mode 100644 index 0000000..99e51a4 --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-powercontroller/TurnOn.directive.json @@ -0,0 +1,20 @@ +{ + "directive": { + "header": { + "namespace": "Alexa.PowerController", + "name": "TurnOn", + "messageId": "Unique version 4 UUID", + "correlationToken": "Opaque correlation token", + "payloadVersion": "3" + }, + "endpoint": { + "scope": { + "type": "BearerToken", + "token": "OAuth2.0 bearer token" + }, + "endpointId": "Endpoint ID", + "cookie": {} + }, + "payload": {} + } +} diff --git a/test/fixtures/alexa-docs/alexa-powercontroller/TurnOn.response.json b/test/fixtures/alexa-docs/alexa-powercontroller/TurnOn.response.json new file mode 100644 index 0000000..7d88098 --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-powercontroller/TurnOn.response.json @@ -0,0 +1,30 @@ +{ + "event": { + "header": { + "namespace": "Alexa", + "name": "Response", + "messageId": "Unique identifier, preferably a version 4 UUID", + "correlationToken": "Opaque correlation token that matches the request", + "payloadVersion": "3" + }, + "endpoint": { + "scope": { + "type": "BearerToken", + "token": "OAuth2.0 bearer token" + }, + "endpointId": "Endpoint ID" + }, + "payload": {} + }, + "context": { + "properties": [ + { + "namespace": "Alexa.PowerController", + "name": "powerState", + "value": "ON", + "timeOfSample": "2017-02-03T16:20:50.52Z", + "uncertaintyInMilliseconds": 500 + } + ] + } +} diff --git a/test/fixtures/alexa-docs/alexa-powercontroller/discovery.json b/test/fixtures/alexa-docs/alexa-powercontroller/discovery.json new file mode 100644 index 0000000..bc251e2 --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-powercontroller/discovery.json @@ -0,0 +1,81 @@ +{ + "event": { + "header": { + "namespace": "Alexa.Discovery", + "name": "Discover.Response", + "payloadVersion": "3", + "messageId": "Unique identifier, preferably a version 4 UUID" + }, + "payload": { + "endpoints": [ + { + "endpointId": "Unique ID of the endpoint", + "manufacturerName": "Manufacturer of the endpoint", + "description": "Description to be shown in the Alexa app", + "friendlyName": "Living Room Light", + "displayCategories": [ + "LIGHT" + ], + "additionalAttributes": { + "manufacturer": "Manufacturer of the endpoint", + "model": "Model of the device", + "serialNumber": "Serial number of the device", + "firmwareVersion": "Firmware version of the device", + "softwareVersion": "Software version of the device", + "customIdentifier": "Optional custom identifier for the device" + }, + "cookie": {}, + "capabilities": [ + { + "type": "AlexaInterface", + "interface": "Alexa.PowerController", + "version": "3", + "properties": { + "supported": [ + { + "name": "powerState" + } + ], + "proactivelyReported": true, + "retrievable": true + } + }, + { + "type": "AlexaInterface", + "interface": "Alexa.BrightnessController", + "version": "3", + "properties": { + "supported": [ + { + "name": "brightness" + } + ], + "proactivelyReported": true, + "retrievable": true + } + }, + { + "type": "AlexaInterface", + "interface": "Alexa.EndpointHealth", + "version": "3", + "properties": { + "supported": [ + { + "name": "connectivity" + } + ], + "proactivelyReported": true, + "retrievable": true + } + }, + { + "type": "AlexaInterface", + "interface": "Alexa", + "version": "3" + } + ] + } + ] + } + } +} diff --git a/test/fixtures/alexa-docs/alexa-temperaturesensor/ChangeReport.json b/test/fixtures/alexa-docs/alexa-temperaturesensor/ChangeReport.json new file mode 100644 index 0000000..dab1ac1 --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-temperaturesensor/ChangeReport.json @@ -0,0 +1,66 @@ +{ + "event": { + "header": { + "namespace": "Alexa", + "name": "ChangeReport", + "messageId": "Unique identifier, preferably a version 4 UUID", + "payloadVersion": "3" + }, + "endpoint": { + "scope": { + "type": "BearerToken", + "token": "OAuth2.0 bearer token" + }, + "endpointId": "Endpoint ID" + }, + "payload": { + "change": { + "cause": { + "type": "PHYSICAL_INTERACTION" + }, + "properties": [ + { + "namespace": "Alexa.ThermostatController", + "name": "targetSetpoint", + "value": { + "value": 18, + "scale": "CELSIUS" + }, + "timeOfSample": "2017-02-03T16:20:50.52Z", + "uncertaintyInMilliseconds": 500 + } + ] + } + } + }, + "context": { + "properties": [ + { + "namespace": "Alexa.TemperatureSensor", + "name": "temperature", + "value": { + "value": 19.1, + "scale": "CELSIUS" + }, + "timeOfSample": "2017-02-03T16:20:50.52Z", + "uncertaintyInMilliseconds": 1000 + }, + { + "namespace": "Alexa.ThermostatController", + "name": "thermostatMode", + "value": "COOL", + "timeOfSample": "2017-02-03T16:20:50.52Z", + "uncertaintyInMilliseconds": 500 + }, + { + "namespace": "Alexa.EndpointHealth", + "name": "connectivity", + "value": { + "value": "OK" + }, + "timeOfSample": "2017-02-03T16:20:50.52Z", + "uncertaintyInMilliseconds": 0 + } + ] + } +} diff --git a/test/fixtures/alexa-docs/alexa-temperaturesensor/StateReport.json b/test/fixtures/alexa-docs/alexa-temperaturesensor/StateReport.json new file mode 100644 index 0000000..658a1d2 --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-temperaturesensor/StateReport.json @@ -0,0 +1,59 @@ +{ + "event": { + "header": { + "namespace": "Alexa", + "name": "StateReport", + "messageId": "Unique identifier, preferably a version 4 UUID", + "correlationToken": "Opaque correlation token that matches the request", + "payloadVersion": "3" + }, + "endpoint": { + "scope": { + "type": "BearerToken", + "token": "OAuth2.0 bearer token" + }, + "endpointId": "Endpoint ID" + }, + "payload": {} + }, + "context": { + "properties": [ + { + "namespace": "Alexa.TemperatureSensor", + "name": "temperature", + "value": { + "value": 19.9, + "scale": "CELSIUS" + }, + "timeOfSample": "2024-01-06T09:00:00.05Z", + "uncertaintyInMilliseconds": 1000 + }, + { + "namespace": "Alexa.ThermostatController", + "name": "thermostatMode", + "value": "HEAT", + "timeOfSample": "2024-01-01T08:00:00.05Z", + "uncertaintyInMilliseconds": 500 + }, + { + "namespace": "Alexa.ThermostatController", + "name": "targetSetpoint", + "value": { + "value": 20, + "scale": "CELSIUS" + }, + "timeOfSample": "2024-01-01T08:00:00.05Z", + "uncertaintyInMilliseconds": 500 + }, + { + "namespace": "Alexa.EndpointHealth", + "name": "connectivity", + "value": { + "value": "OK" + }, + "timeOfSample": "2024-01-06T09:00:00.05Z", + "uncertaintyInMilliseconds": 0 + } + ] + } +} diff --git a/test/fixtures/alexa-docs/alexa-temperaturesensor/discovery.json b/test/fixtures/alexa-docs/alexa-temperaturesensor/discovery.json new file mode 100644 index 0000000..d3e0030 --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-temperaturesensor/discovery.json @@ -0,0 +1,91 @@ +{ + "event": { + "header": { + "namespace": "Alexa.Discovery", + "name": "Discover.Response", + "payloadVersion": "3", + "messageId": "Unique identifier, preferably a version 4 UUID" + }, + "payload": { + "endpoints": [ + { + "endpointId": "Unique ID of the endpoint", + "manufacturerName": "Manufacturer of the endpoint", + "description": "Smart Thermostat by Thermostat Maker", + "friendlyName": "Hallway Thermostat", + "displayCategories": [ + "THERMOSTAT", + "TEMPERATURE_SENSOR" + ], + "cookie": {}, + "capabilities": [ + { + "type": "AlexaInterface", + "interface": "Alexa.TemperatureSensor", + "version": "3", + "properties": { + "supported": [ + { + "name": "temperature" + } + ], + "proactivelyReported": true, + "retrievable": true + } + }, + { + "type": "AlexaInterface", + "interface": "Alexa.ThermostatController", + "version": "3.1", + "properties": { + "supported": [ + { + "name": "targetSetpoint" + }, + { + "name": "lowerSetpoint" + }, + { + "name": "upperSetpoint" + }, + { + "name": "thermostatMode" + } + ], + "proactivelyReported": true, + "retrievable": true + }, + "configuration": { + "supportedModes": [ + "HEAT", + "COOL", + "AUTO" + ], + "supportsScheduling": false + } + }, + { + "type": "AlexaInterface", + "interface": "Alexa.EndpointHealth", + "version": "3", + "properties": { + "supported": [ + { + "name": "connectivity" + } + ], + "proactivelyReported": true, + "retrievable": true + } + }, + { + "type": "AlexaInterface", + "interface": "Alexa", + "version": "3" + } + ] + } + ] + } + } +} diff --git a/test/fixtures/interfaces.js b/test/fixtures/interfaces.js new file mode 100644 index 0000000..9ceff60 --- /dev/null +++ b/test/fixtures/interfaces.js @@ -0,0 +1,11 @@ +"use strict"; +// One entry per interface that is described in full: the page its examples were saved from (test/helpers/fixtures.js). +// test/registry/descriptors.test.js holds every entry against those examples and fails for a descriptor of tier 1 or +// 2 that has no entry here. +module.exports = [ + { namespace: "Alexa", page: "alexa-interface" }, + { namespace: "Alexa.BrightnessController", page: "alexa-brightnesscontroller" }, + { namespace: "Alexa.EndpointHealth", page: "alexa-endpointhealth" }, + { namespace: "Alexa.PowerController", page: "alexa-powercontroller" }, + { namespace: "Alexa.TemperatureSensor", page: "alexa-temperaturesensor" }, +]; diff --git a/test/fixtures/types.ts b/test/fixtures/types.ts index 597a330..526a7e9 100644 --- a/test/fixtures/types.ts +++ b/test/fixtures/types.ts @@ -1,8 +1,14 @@ // Compiled, never run (test/typings.test.js, npm run check): a CommonJS TypeScript consumer of the declarations in // dist/types, resolved through the package's "exports". A "@ts-expect-error" line fails the compile when the // declaration stops rejecting what follows it. -import { ActionMapping, AlexaActions, AlexaInterfaceType, DisplayCategory, EndpointHealth, PowerController } from "alex2node"; -import type { Alex2MQTT, Alex2MQTTOptions, AlexaInterface, AlexaStatusMessage, ChangeCause, Device, SupportedMode } from "alex2node"; +import { + ActionMapping, AlexaActions, AlexaInterfaceType, Assets, DeclarationError, DisplayCategory, EndpointHealth, PowerController, + registry, +} from "alex2node"; +import type { + Alex2MQTT, Alex2MQTTOptions, AlexaInterface, AlexaStatusMessage, AssetId, ChangeCause, Device, DisplayCategoryName, Infer, + InterfaceDescriptor, Schema, SupportedMode, Temperature, UnitOfMeasure, +} from "alex2node"; declare const bridge: Alex2MQTT; declare const message: AlexaStatusMessage; @@ -33,4 +39,24 @@ const sent: Promise = device.getChangeReport(cause).addPowerControllerPr // @ts-expect-error not a cause Alexa knows device.getChangeReport("BUTTON"); -export { options, added, chained, sent }; +// The registry: a descriptor by its namespace or by the 1.x enum member +const described: InterfaceDescriptor = registry.get("Alexa.PowerController"); +const version: string = registry.get(AlexaInterfaceType.ENDPOINT_HEALTH).version; +const refused: Error = new DeclarationError({ endpointId: "lamp-1", namespace: described.namespace }, "declared twice"); + +// The vocabularies are unions of what the pages list +const opening: AssetId = Assets[0]; +const percent: UnitOfMeasure = "Alexa.Unit.Percent"; +const vacuum: DisplayCategoryName = "VACUUM"; +// @ts-expect-error not in the global Alexa catalog +const misspelt: AssetId = "Alexa.Setting.Openning"; +// @ts-expect-error an asset, but not a unit of measure +const notAUnit: UnitOfMeasure = "Alexa.Setting.Opening"; + +// A schema types what it parses +declare const temperature: Schema; +const measured: Infer = { value: 20, scale: "CELSIUS" }; +// @ts-expect-error not a temperature scale +const rankine: Infer = { value: 20, scale: "RANKINE" }; + +export { options, added, chained, sent, version, refused, opening, percent, vacuum, misspelt, notAUnit, measured, rankine }; diff --git a/test/helpers/fixtures.js b/test/helpers/fixtures.js new file mode 100644 index 0000000..1bb41f9 --- /dev/null +++ b/test/helpers/fixtures.js @@ -0,0 +1,47 @@ +"use strict"; +// The JSON examples of Amazon's interface pages. test/fixtures/alexa-docs// holds the examples of +// https://developer.amazon.com/docs/alexaplus/device-apis/.html as the page printed them on 2026-09-28, one +// file per example, named after the heading it stands under. +const fs = require("node:fs"); +const path = require("node:path"); + +const DOCS = path.join(__dirname, "..", "fixtures", "alexa-docs"); + +/** One example: doc("alexa-powercontroller", "TurnOn.directive"). */ +function doc(page, name) { + return JSON.parse(fs.readFileSync(path.join(DOCS, page, `${name}.json`), "utf8")); +} + +/** The names of the examples of a page that end in suffix: examples("alexa-powercontroller", ".directive"). */ +function examples(page, suffix = "") { + return fs.readdirSync(path.join(DOCS, page)) + .filter((file) => file.endsWith(`${suffix}.json`)) + .map((file) => file.slice(0, -".json".length)) + .sort(); +} + +/** The capability objects of one interface in a discovery example, in the order of the example. */ +function capabilities(page, namespace, name = "discovery") { + const [endpoint] = doc(page, name).event.payload.endpoints; + return endpoint.capabilities.filter((capability) => capability.interface === namespace); +} + +/** + * Every value of a property of one interface that the examples of a page show, as { example, property }: what the + * Response, StateReport and ChangeReport examples report, and the property examples of the page itself. + */ +function reported(page, namespace) { + const found = []; + for (const example of examples(page)) { + const { event, context, ...property } = doc(page, example); + if (example.endsWith(".property")) found.push({ example, property: { namespace, ...property } }); + if (!event) continue; + const change = event.payload && event.payload.change; + // Several ChangeReport examples print the context as one property instead of { properties: [...] } + const contextProperties = context && (context.properties || (context.namespace ? [context] : [])); + found.push(...[...(change ? change.properties : []), ...(contextProperties || [])].map((each) => ({ example, property: each }))); + } + return found.filter(({ property }) => property.namespace === namespace); +} + +module.exports = { doc, examples, capabilities, reported }; diff --git a/test/registry/catalog.test.js b/test/registry/catalog.test.js new file mode 100644 index 0000000..38198d5 --- /dev/null +++ b/test/registry/catalog.test.js @@ -0,0 +1,72 @@ +"use strict"; +// The vocabularies copied from Amazon's pages: their sizes as counted on the pages on 2026-09-28, and how they +// relate to the 1.x enums. +const { test } = require("node:test"); +const assert = require("node:assert/strict"); +const { + Actions, AlexaActions, AlexaErrorType, Assets, DisplayCategories, DisplayCategory, States, Units, +} = require("alex2node"); +const { ERROR_TYPES, RESERVED_WORDS, LIMITS } = require("../../dist/cjs/registry/catalog.js"); + +const unique = (list) => new Set(list).size === list.length; + +test("assets: the 103 ids of the global Alexa catalog, 23 of them units of measure", () => { + assert.equal(Assets.length, 103); + assert.ok(unique(Assets)); + for (const id of Assets) assert.match(id, /^Alexa\.(Actions|Button|DeviceName|Gestures?|Setting|Shower|Unit|Value)\.[A-Za-z0-9.]+$/); + assert.equal(Units.length, 23); + assert.deepEqual(Units, Assets.filter((id) => id.startsWith("Alexa.Unit."))); + assert.ok(Units.includes("Alexa.Unit.Percent")); + // The page has "Gesture" seven times and "Gestures" once + assert.deepEqual(Assets.filter((id) => id.startsWith("Alexa.Gestures.")), ["Alexa.Gestures.DoubleTap"]); +}); + +test("semantics: six actions, the ones the AlexaActions enum of 1.x has, and nine states", () => { + assert.deepEqual([...Actions].sort(), Object.values(AlexaActions).sort()); + assert.deepEqual(States.map((id) => id.replace("Alexa.States.", "")), ["Open", "Closed", "EcoOn", "EcoOff", "Low", "Empty", "Full", "Done", "Stuck"]); +}); + +test("display categories: the 56 of the page; the enum has them all and keeps VEHICLE", () => { + assert.equal(DisplayCategories.length, 56); + assert.ok(unique(DisplayCategories)); + const inEnum = Object.values(DisplayCategory); + assert.deepEqual(DisplayCategories.filter((category) => !inEnum.includes(category)), []); + assert.deepEqual(inEnum.filter((category) => !DisplayCategories.includes(category)), ["VEHICLE"]); + assert.equal(DisplayCategory.VACUUM, "VACUUM"); + for (const [key, value] of Object.entries(DisplayCategory)) assert.equal(key, value); +}); + +test("error types: the 73 of the error type table, each under the namespace of its interface", () => { + assert.equal(Object.keys(ERROR_TYPES).length, 73); + assert.equal(ERROR_TYPES.ENDPOINT_UNREACHABLE, "Alexa"); + assert.equal(ERROR_TYPES.INVALID_VALUE, "Alexa"); + assert.equal(ERROR_TYPES.THERMOSTAT_IS_OFF, "Alexa.ThermostatController"); + assert.equal(ERROR_TYPES.UNAUTHORIZED, "Alexa.SecurityPanelController"); + assert.equal(ERROR_TYPES.OBSTACLE_DETECTED, "Alexa.Safety"); + assert.equal(ERROR_TYPES.TEMPERATURE_VALUE_OUT_OF_RANGE, "Alexa", "the generic one, not a thermostat error"); + const namespaces = [...new Set(Object.values(ERROR_TYPES))].sort(); + assert.equal(namespaces.length, 11); + for (const namespace of namespaces) assert.match(namespace, /^Alexa(\.[A-Za-z]+)*$/); + + // AlexaErrorType of 1.x has every type of the table and two that the table does not list + const inEnum = Object.values(AlexaErrorType); + assert.deepEqual(Object.keys(ERROR_TYPES).filter((type) => !inEnum.includes(type)), []); + assert.deepEqual(inEnum.filter((type) => !(type in ERROR_TYPES)).sort(), ["EXCEEDED_PIN_ATTEMPTS", "PIN_SETUP_REQUIRED"]); +}); + +test("reserved words and limits", () => { + assert.equal(RESERVED_WORDS.length, 22); + assert.ok(unique(RESERVED_WORDS)); + for (const word of RESERVED_WORDS) assert.equal(word, word.toLowerCase()); + assert.deepEqual(LIMITS, { + endpointsPerCustomer: 300, + capabilitiesPerEndpoint: 100, + endpointIdLength: 256, + friendlyNameLength: 256, + sceneFriendlyNameLength: 128, + manufacturerNameLength: 128, + descriptionLength: 128, + additionalAttributeLength: 256, + cookieBytes: 5000, + }); +}); diff --git a/test/registry/descriptors.test.js b/test/registry/descriptors.test.js new file mode 100644 index 0000000..5250feb --- /dev/null +++ b/test/registry/descriptors.test.js @@ -0,0 +1,127 @@ +"use strict"; +// The registry: every descriptor is well formed, the ones described in full agree with the examples of their page, +// and every interface name of 1.x resolves. +const { test } = require("node:test"); +const assert = require("node:assert/strict"); +const { registry, AlexaInterface, AlexaInterfaceType, DeclarationError } = require("alex2node"); +const interfaces = require("../fixtures/interfaces.js"); +const { doc, examples, capabilities, reported } = require("../helpers/fixtures.js"); + +const described = registry.list().filter((descriptor) => descriptor.tier !== 3); +const stubs = registry.list().filter((descriptor) => descriptor.tier === 3); + +test("every descriptor has a namespace, a version, a kind and the page it was written from", () => { + const namespaces = registry.list().map((descriptor) => descriptor.namespace); + assert.deepEqual(namespaces, [...namespaces].sort(), "list() is ordered by namespace"); + for (const descriptor of registry.list()) { + const { namespace } = descriptor; + assert.match(namespace, /^Alexa(\.[A-Za-z]+)*$/); + assert.match(descriptor.version, /^\d+(\.\d+)?$/, namespace); + assert.match(descriptor.doc, /^https:\/\/developer\.amazon\.com\/[\w\-./]+\.html$/, namespace); + assert.ok(["base", "controller", "sensor", "eventSource"].includes(descriptor.kind), namespace); + assert.ok([1, 2, 3].includes(descriptor.tier), namespace); + assert.equal(typeof descriptor.instanced, "boolean", namespace); + for (const [key, property] of Object.entries(descriptor.properties)) assert.equal(property.name, key, namespace); + for (const [key, directive] of Object.entries(descriptor.directives)) assert.equal(directive.name, key, namespace); + } +}); + +test("a descriptor of tier 1 or 2 has an entry in test/fixtures/interfaces.js, and every entry a descriptor", () => { + assert.deepEqual(described.map((descriptor) => descriptor.namespace), interfaces.map((entry) => entry.namespace).sort()); +}); + +for (const { namespace, page } of interfaces) { + test(`${namespace}: version, properties, directive payloads and property values of ${page}.html`, () => { + const descriptor = registry.get(namespace); + assert.ok(descriptor.doc.endsWith(`/${page}.html`), descriptor.doc); + + for (const name of examples(page).filter((example) => example.startsWith("discovery"))) { + for (const capability of capabilities(page, namespace, name)) { + assert.equal(capability.version, descriptor.version, `${name}: version`); + const supported = capability.properties ? capability.properties.supported.map((property) => property.name) : []; + for (const property of supported) assert.ok(property in descriptor.properties, `${name}: ${property}`); + } + } + + const directives = examples(page, ".directive").map((name) => doc(page, name).directive); + assert.deepEqual( + directives.map((directive) => directive.header.name).sort(), + Object.keys(descriptor.directives).filter((name) => name !== "ReportState").sort(), + "the page has an example of every directive" + ); + for (const { header, payload } of directives) { + assert.equal(header.namespace, namespace); + assert.deepEqual(descriptor.directives[header.name].payload.parse(payload, "payload"), payload); + } + + const values = reported(page, namespace); + for (const { example, property } of values) { + const known = descriptor.properties[property.name]; + assert.ok(known, `${example}: ${property.name} is not a property of the descriptor`); + assert.deepEqual(known.value.parse(property.value, property.name), property.value, example); + } + if (Object.keys(descriptor.properties).length > 0) assert.ok(values.length > 0, "the page reports no property of the interface"); + }); +} + +test("Alexa.EndpointHealth is version 3.1 and reports connectivity, with a reason when it has one", () => { + const { version, properties } = registry.get("Alexa.EndpointHealth"); + assert.equal(version, "3.1"); + assert.deepEqual(Object.keys(properties), ["connectivity"]); + const unreachable = doc("alexa-endpointhealth", "connectivity.property").value; + assert.deepEqual(properties.connectivity.value.parse(unreachable), { value: "UNREACHABLE", reason: "WIFI_BAD_PASSWORD" }); + assert.deepEqual(properties.connectivity.value.parse({ value: "OK" }), { value: "OK" }); + assert.throws( + () => properties.connectivity.value.parse({ value: "UNREACHABLE", reason: "UNPLUGGED" }, "connectivity"), + /^SchemaError: connectivity\.reason: expected WIFI_BAD_PASSWORD \| .* \| UNKNOWN, got "UNPLUGGED"$/ + ); +}); + +test("a payload or a value that does not fit is refused with its path", () => { + const { directives, properties } = registry.get("Alexa.BrightnessController"); + assert.throws( + () => directives.SetBrightness.payload.parse({ brightness: "lots" }, "payload"), + /^SchemaError: payload\.brightness: expected an integer from 0 to 100, got "lots"$/ + ); + assert.throws(() => directives.AdjustBrightness.payload.parse({ brightnessDelta: 101 }, "payload"), /from -100 to 100/); + assert.throws(() => properties.brightness.value.parse(-1, "brightness"), /^SchemaError: brightness: expected an integer/); + assert.throws(() => registry.get("Alexa.PowerController").properties.powerState.value.parse("on", "powerState"), /expected ON \| OFF/); +}); + +test("registry.get: the namespace and the AlexaInterfaceType member give the same descriptor; an unknown name throws", () => { + assert.equal(registry.get(AlexaInterfaceType.POWER_CONTROLLER), registry.get("Alexa.PowerController")); + assert.equal(registry.has("Alexa.PowerController"), true); + assert.equal(registry.has("Alexa.TeleportController"), false); + assert.throws( + () => registry.get("Alexa.TeleportController"), + (err) => err instanceof DeclarationError && err.message === '"Alexa.TeleportController" is not an interface alex2node knows' + ); + assert.throws(() => registry.get(AlexaInterfaceType.UNKNOWN), DeclarationError); +}); + +test("every AlexaInterfaceType of 1.x is in the registry: described, or a stub with the version and properties of 1.5.2", () => { + const names = Object.values(AlexaInterfaceType).filter((name) => name !== AlexaInterfaceType.UNKNOWN); + assert.equal(names.length, 69); + assert.deepEqual(names.filter((name) => !registry.has(name)), []); + // "Alexa" is the one interface that was never in the enum + assert.deepEqual(registry.list().map((descriptor) => descriptor.namespace).filter((name) => !names.includes(name)), ["Alexa"]); + + for (const stub of stubs) { + assert.deepEqual(stub.directives, {}, stub.namespace); + assert.equal(stub.instanced, false, stub.namespace); + } + const thermostat = registry.get("Alexa.ThermostatController"); + assert.equal(thermostat.version, "3.2"); + assert.deepEqual(Object.keys(thermostat.properties), ["targetSetpoint", "lowerSetpoint", "upperSetpoint", "thermostatMode"]); +}); + +test("AlexaInterface.getVersion() and getProps() read the registry", () => { + for (const descriptor of registry.list().filter(({ namespace }) => namespace !== "Alexa")) { + const capability = new AlexaInterface(descriptor.namespace); + assert.equal(capability.getVersion(), descriptor.version, descriptor.namespace); + assert.deepEqual(capability.getProps(), Object.keys(descriptor.properties), descriptor.namespace); + } + // 1.5.2 had no version for these two and put "UNKNOWN" in discovery + assert.equal(new AlexaInterface(AlexaInterfaceType.TIME_HOLD_CONTROLLER).getVersion(), "3"); + assert.equal(new AlexaInterface(AlexaInterfaceType.CAMERA_LIVE_VIEW_CONTROLLER).getVersion(), "1.7"); +}); diff --git a/test/registry/schema.test.js b/test/registry/schema.test.js new file mode 100644 index 0000000..c1ee876 --- /dev/null +++ b/test/registry/schema.test.js @@ -0,0 +1,111 @@ +"use strict"; +// The schema helper of the registry: what parse() returns, and what the error says when the value does not fit. +const { test } = require("node:test"); +const assert = require("node:assert/strict"); +const { SchemaError } = require("alex2node"); +// The helper is not part of the public surface; the descriptors are built with it. +const { s } = require("../../dist/cjs/registry/schema.js"); + +// The message of the SchemaError that parse() throws. +function refusal(schema, input, path) { + try { + schema.parse(input, path); + } catch (err) { + assert.ok(err instanceof SchemaError, `threw ${err}`); + return err.message; + } + return assert.fail(`${JSON.stringify(input)} was accepted`); +} + +test("number: bounds, integers, and no NaN or text", () => { + const percent = s.number({ min: 0, max: 100, integer: true }); + assert.equal(percent.parse(0), 0); + assert.equal(percent.parse(100), 100); + assert.equal(refusal(percent, 101, "payload.brightness"), "payload.brightness: expected an integer from 0 to 100, got 101"); + assert.equal(refusal(percent, 50.5, "payload.brightness"), "payload.brightness: expected an integer from 0 to 100, got 50.5"); + assert.equal(refusal(percent, "lots", "payload.brightness"), 'payload.brightness: expected an integer from 0 to 100, got "lots"'); + assert.equal(refusal(s.number(), NaN, "value"), "value: expected a number, got null"); + assert.equal(refusal(s.number({ gt: 0 }), 0, "range.precision"), "range.precision: expected a number greater than 0, got 0"); + assert.equal(s.number({ gt: 0 }).parse(0.5), 0.5); +}); + +test("enum and literal: the value itself, the choices in the message", () => { + const power = s.enum("ON", "OFF"); + assert.equal(power.parse("ON"), "ON"); + assert.deepEqual(power.values, ["ON", "OFF"]); + assert.equal(refusal(power, "on", "powerState"), 'powerState: expected ON | OFF, got "on"'); + assert.equal(s.literal("FOUR_DIGIT_PIN").parse("FOUR_DIGIT_PIN"), "FOUR_DIGIT_PIN"); + assert.equal(refusal(s.literal(3), "3", "version"), 'version: expected 3, got "3"'); +}); + +test("object: the path of the key that is wrong; a key that is not in the shape is kept", () => { + const payload = s.object({ rangeValueDelta: s.number(), rangeValueDeltaDefault: s.boolean() }); + assert.deepEqual(payload.parse({ rangeValueDelta: -5, rangeValueDeltaDefault: false }), { rangeValueDelta: -5, rangeValueDeltaDefault: false }); + assert.equal( + refusal(payload, { rangeValueDelta: -5 }, "payload"), + "payload.rangeValueDeltaDefault: expected true or false, got nothing" + ); + assert.equal(refusal(payload, [1, 2], "payload"), "payload: expected an object with rangeValueDelta, rangeValueDeltaDefault, got [1,2]"); + assert.equal(refusal(payload, null, "payload"), "payload: expected an object with rangeValueDelta, rangeValueDeltaDefault, got null"); + // A field Alexa adds to a directive later reaches the handler + assert.deepEqual(s.object({}).parse({ added: 1 }), { added: 1 }); +}); + +test("object with unknownKeys \"reject\": a misspelt option is named, with the ones that exist", () => { + const options = s.object({ range: s.unknown(), presets: s.optional(s.unknown()) }, { unknownKeys: "reject" }); + assert.equal(refusal(options, { range: {}, preset: [] }), "preset: unknown key, the known ones are range, presets"); + assert.deepEqual(options.parse({ range: {} }), { range: {} }); +}); + +test("optional, nullable, array", () => { + assert.equal(s.optional(s.number()).parse(undefined), undefined); + assert.equal(refusal(s.optional(s.number()), null, "delta"), "delta: expected a number, got null"); + assert.equal(s.nullable(s.string()).parse(null), null); + assert.equal(refusal(s.nullable(s.string()), 5, "mode"), "mode: expected a string or null, got 5"); + const modes = s.array(s.object({ value: s.string() }), { min: 2 }); + assert.deepEqual(modes.parse([{ value: "a" }, { value: "b" }]), [{ value: "a" }, { value: "b" }]); + assert.equal(refusal(modes, [{ value: "a" }], "supportedModes"), 'supportedModes: expected a list with 2 or more entries, got [{"value":"a"}]'); + assert.equal(refusal(modes, [{ value: "a" }, { value: 2 }], "supportedModes"), "supportedModes[1].value: expected a string, got 2"); +}); + +test("temperature: the three scales of alexa-property-schemas.html, the scale named when it is wrong", () => { + const temperature = s.temperature(); + assert.deepEqual(temperature.parse({ value: 68.0, scale: "FAHRENHEIT" }), { value: 68, scale: "FAHRENHEIT" }); + assert.deepEqual(temperature.parse({ value: 293.15, scale: "KELVIN" }), { value: 293.15, scale: "KELVIN" }); + assert.equal( + refusal(temperature, { value: 20, scale: "Celsius" }, "payload.targetSetpoint"), + 'payload.targetSetpoint.scale: expected CELSIUS | FAHRENHEIT | KELVIN, got "Celsius"' + ); + assert.equal(refusal(temperature, 20, "temperature"), "temperature: expected an object with value, scale, got 20"); +}); + +test("dateTime and duration: the examples of alexa-property-schemas.html", () => { + for (const time of ["2017-08-30T01:18:21Z", "2017-08-30T01:18:21.123Z", "2017-10-04T14:00Z"]) assert.equal(s.dateTime().parse(time), time); + for (const time of ["2017-08-30T01:18:21+02:00", "2017-08-30 01:18:21Z", "2017-13-40T01:18:21Z", 1504055901]) { + assert.match(refusal(s.dateTime(), time, "start"), /^start: expected a UTC time like 2017-08-30T01:18:21Z, got /); + } + for (const length of ["PT3M15S", "PT-30S", "PT30M", "PT1H"]) assert.equal(s.duration().parse(length), length); + for (const length of ["PT", "3M", "P1D", "PT3M15"]) { + assert.match(refusal(s.duration(), length, "duration"), /^duration: expected a duration like PT3M15S, got /); + } +}); + +test("timeInterval: one or two of start, end and duration", () => { + const interval = s.timeInterval(); + for (const example of [ + { start: "2017-10-04T14:00Z", end: "2017-10-04T14:15Z" }, + { start: "2017-10-04T14:00Z", duration: "PT30M" }, + { duration: "PT30M" }, + ]) assert.deepEqual(interval.parse(example), example); + const all = { start: "2017-10-04T14:00Z", end: "2017-10-04T14:15Z", duration: "PT15M" }; + assert.match(refusal(interval, all, "holdUntil"), /^holdUntil: expected a time interval with one or two of start, end, duration, got /); + assert.match(refusal(interval, {}, "holdUntil"), /^holdUntil: expected a time interval with one or two of start, end, duration, got \{\}$/); + assert.equal(refusal(interval, { end: "tomorrow" }, "holdUntil"), 'holdUntil.end: expected a UTC time like 2017-08-30T01:18:21Z, got "tomorrow"'); +}); + +test("an error without a path is the problem alone, and a long value is cut short", () => { + assert.equal(refusal(s.boolean(), "yes"), 'expected true or false, got "yes"'); + const message = refusal(s.number(), "x".repeat(200), "value"); + assert.equal(message.length, "value: expected a number, got ".length + 60); + assert.ok(message.endsWith("...")); +});