From c59cf3ff1010f8fc26432b2bb866a980013f2e99 Mon Sep 17 00:00:00 2001 From: David Date: Mon, 28 Sep 2026 20:50:57 +0000 Subject: [PATCH] registry: SecurityPanelController with Arm.Response and its errors Alexa.SecurityPanelController is described from its page: version 3 (the stub of 1.5.2 said 1), armState and the four alarms, Arm and Disarm. ctx.respond() answers Arm with Arm.Response under the namespace of the interface, its payload checked (exitDelayInSeconds 0 to 255, bypassedEndpoints), and Disarm with a Response. A Disarm carries the PIN the user said when the panel is declared with supportedAuthorizationTypes; the handler checks it and answers UNAUTHORIZED, which goes under Alexa.SecurityPanelController like the five other error types. A panel declared without options lists armState, where 1.5.2 listed no property; alarms are declared. 17 examples of the two pages are saved as fixtures. 215 tests pass, 207 before. Co-Authored-By: Claude Fable 5.1 --- dist/cjs/index.js | 5 +- dist/cjs/registry/index.js | 5 +- .../interfaces/SecurityPanelController.js | 91 +++++++++++++++++++ dist/cjs/registry/interfaces/stubs.js | 1 - dist/esm/index.d.ts | 2 +- dist/esm/index.js | 2 +- dist/esm/registry/index.d.ts | 3 +- dist/esm/registry/index.js | 4 +- .../interfaces/SecurityPanelController.d.ts | 67 ++++++++++++++ .../interfaces/SecurityPanelController.js | 88 ++++++++++++++++++ dist/esm/registry/interfaces/stubs.js | 1 - dist/types/index.d.ts | 2 +- dist/types/registry/index.d.ts | 3 +- .../interfaces/SecurityPanelController.d.ts | 67 ++++++++++++++ src/index.ts | 4 +- src/registry/index.ts | 4 +- .../interfaces/SecurityPanelController.ts | 91 +++++++++++++++++++ src/registry/interfaces/stubs.ts | 1 - test/dispatch/interfaces.test.js | 74 ++++++++++++++- .../AUTHORIZATION_REQUIRED.error.json | 22 +++++ .../BYPASS_NEEDED.error.json | 35 +++++++ .../UNAUTHORIZED.error.json | 18 ++++ .../UNCLEARED_ALARM.error.json | 22 +++++ .../Arm-alarm.response.json | 50 ++++++++++ .../Arm-bypass.directive.json | 23 +++++ .../Arm-bypass.response.json | 54 +++++++++++ .../Arm.directive.json | 22 +++++ .../Arm.response.json | 41 +++++++++ .../ChangeReport-alarm.json | 64 +++++++++++++ .../ChangeReport.json | 64 +++++++++++++ .../Disarm-alarm.response.json | 48 ++++++++++ .../Disarm.directive.json | 25 +++++ .../Disarm.response.json | 39 ++++++++ .../StateReport.json | 57 ++++++++++++ .../burglaryAlarm.property.json | 6 ++ .../discovery.json | 83 +++++++++++++++++ test/fixtures/interfaces.js | 23 +++++ test/registry/descriptors.test.js | 9 +- test/registry/security-panel.test.js | 51 +++++++++++ 39 files changed, 1248 insertions(+), 23 deletions(-) create mode 100644 dist/cjs/registry/interfaces/SecurityPanelController.js create mode 100644 dist/esm/registry/interfaces/SecurityPanelController.d.ts create mode 100644 dist/esm/registry/interfaces/SecurityPanelController.js create mode 100644 dist/types/registry/interfaces/SecurityPanelController.d.ts create mode 100644 src/registry/interfaces/SecurityPanelController.ts create mode 100644 test/fixtures/alexa-docs/alexa-securitypanelcontroller-errorresponse/AUTHORIZATION_REQUIRED.error.json create mode 100644 test/fixtures/alexa-docs/alexa-securitypanelcontroller-errorresponse/BYPASS_NEEDED.error.json create mode 100644 test/fixtures/alexa-docs/alexa-securitypanelcontroller-errorresponse/UNAUTHORIZED.error.json create mode 100644 test/fixtures/alexa-docs/alexa-securitypanelcontroller-errorresponse/UNCLEARED_ALARM.error.json create mode 100644 test/fixtures/alexa-docs/alexa-securitypanelcontroller/Arm-alarm.response.json create mode 100644 test/fixtures/alexa-docs/alexa-securitypanelcontroller/Arm-bypass.directive.json create mode 100644 test/fixtures/alexa-docs/alexa-securitypanelcontroller/Arm-bypass.response.json create mode 100644 test/fixtures/alexa-docs/alexa-securitypanelcontroller/Arm.directive.json create mode 100644 test/fixtures/alexa-docs/alexa-securitypanelcontroller/Arm.response.json create mode 100644 test/fixtures/alexa-docs/alexa-securitypanelcontroller/ChangeReport-alarm.json create mode 100644 test/fixtures/alexa-docs/alexa-securitypanelcontroller/ChangeReport.json create mode 100644 test/fixtures/alexa-docs/alexa-securitypanelcontroller/Disarm-alarm.response.json create mode 100644 test/fixtures/alexa-docs/alexa-securitypanelcontroller/Disarm.directive.json create mode 100644 test/fixtures/alexa-docs/alexa-securitypanelcontroller/Disarm.response.json create mode 100644 test/fixtures/alexa-docs/alexa-securitypanelcontroller/StateReport.json create mode 100644 test/fixtures/alexa-docs/alexa-securitypanelcontroller/burglaryAlarm.property.json create mode 100644 test/fixtures/alexa-docs/alexa-securitypanelcontroller/discovery.json create mode 100644 test/registry/security-panel.test.js diff --git a/dist/cjs/index.js b/dist/cjs/index.js index 512bd81..b52523f 100644 --- a/dist/cjs/index.js +++ b/dist/cjs/index.js @@ -36,8 +36,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); -exports.PowerState = exports.DisplayCategory = exports.AlexaInterfaceType = exports.AlexaErrorType = exports.AlexaActions = exports.ActionMapping = exports.AlexaInterface = exports.MemoryPublisher = exports.topics = exports.property = exports.StateBuilder = exports.MessageError = exports.AlexaErrors = exports.AlexaError = exports.messages = exports.ThermostatModes = exports.DisplayCategories = exports.States = exports.Actions = exports.Units = exports.Assets = exports.SemanticsBuilder = exports.semantics = exports.text = exports.asset = exports.EndpointHealth = exports.PowerController = exports.ToggleController = exports.ThermostatControllerSchedule = exports.ThermostatController = exports.TemperatureSensor = exports.RangeController = exports.PowerLevelController = exports.PercentageController = exports.MotionSensor = exports.ModeController = exports.LockController = exports.HumiditySensor = exports.ContactSensor = exports.ColorTemperatureController = exports.ColorController = exports.BrightnessController = exports.Alexa = exports.SchemaError = exports.DeclarationError = exports.registry = exports.Capability = exports.Device = exports.DEFAULT_HOST = exports.Alex2MQTT = void 0; -exports.AlexaErrorResponse = exports.AlexaStatusMessage = exports.ThermostatMode = exports.TemperatureSensorScale = void 0; +exports.DisplayCategory = exports.AlexaInterfaceType = exports.AlexaErrorType = exports.AlexaActions = exports.ActionMapping = exports.AlexaInterface = exports.MemoryPublisher = exports.topics = exports.property = exports.StateBuilder = exports.MessageError = exports.AlexaErrors = exports.AlexaError = exports.messages = exports.ThermostatModes = exports.DisplayCategories = exports.States = exports.Actions = exports.Units = exports.Assets = exports.SemanticsBuilder = exports.semantics = exports.text = exports.asset = exports.EndpointHealth = exports.PowerController = exports.ToggleController = exports.ThermostatControllerSchedule = exports.ThermostatController = exports.TemperatureSensor = exports.SecurityPanelController = exports.RangeController = exports.PowerLevelController = exports.PercentageController = exports.MotionSensor = exports.ModeController = exports.LockController = exports.HumiditySensor = exports.ContactSensor = exports.ColorTemperatureController = exports.ColorController = exports.BrightnessController = exports.Alexa = exports.SchemaError = exports.DeclarationError = exports.registry = exports.Capability = exports.Device = exports.DEFAULT_HOST = exports.Alex2MQTT = void 0; +exports.AlexaErrorResponse = exports.AlexaStatusMessage = exports.ThermostatMode = exports.TemperatureSensorScale = exports.PowerState = 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; } }); @@ -64,6 +64,7 @@ Object.defineProperty(exports, "MotionSensor", { enumerable: true, get: function Object.defineProperty(exports, "PercentageController", { enumerable: true, get: function () { return index_js_2.PercentageController; } }); Object.defineProperty(exports, "PowerLevelController", { enumerable: true, get: function () { return index_js_2.PowerLevelController; } }); Object.defineProperty(exports, "RangeController", { enumerable: true, get: function () { return index_js_2.RangeController; } }); +Object.defineProperty(exports, "SecurityPanelController", { enumerable: true, get: function () { return index_js_2.SecurityPanelController; } }); Object.defineProperty(exports, "TemperatureSensor", { enumerable: true, get: function () { return index_js_2.TemperatureSensor; } }); Object.defineProperty(exports, "ThermostatController", { enumerable: true, get: function () { return index_js_2.ThermostatController; } }); Object.defineProperty(exports, "ThermostatControllerSchedule", { enumerable: true, get: function () { return index_js_2.ThermostatControllerSchedule; } }); diff --git a/dist/cjs/registry/index.js b/dist/cjs/registry/index.js index 0ea83c5..f1505c5 100644 --- a/dist/cjs/registry/index.js +++ b/dist/cjs/registry/index.js @@ -14,7 +14,7 @@ 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.SemanticsBuilder = exports.semantics = exports.text = exports.asset = exports.THERMOSTAT_MODES = exports.ToggleController = exports.ThermostatControllerSchedule = exports.ThermostatController = exports.TemperatureSensor = exports.RangeController = exports.PowerLevelController = exports.PowerController = exports.PercentageController = exports.MotionSensor = exports.ModeController = exports.LockController = exports.HumiditySensor = exports.EndpointHealth = exports.ContactSensor = exports.ColorTemperatureController = exports.ColorController = exports.BrightnessController = exports.Alexa = exports.registry = void 0; +exports.SchemaError = exports.s = exports.defineInterface = exports.DeclarationError = exports.SemanticsBuilder = exports.semantics = exports.text = exports.asset = exports.THERMOSTAT_MODES = exports.ToggleController = exports.ThermostatControllerSchedule = exports.ThermostatController = exports.TemperatureSensor = exports.SecurityPanelController = exports.RangeController = exports.PowerLevelController = exports.PowerController = exports.PercentageController = exports.MotionSensor = exports.ModeController = exports.LockController = exports.HumiditySensor = exports.EndpointHealth = exports.ContactSensor = exports.ColorTemperatureController = exports.ColorController = 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; } }); @@ -44,6 +44,8 @@ const PowerLevelController_js_1 = require("./interfaces/PowerLevelController.js" Object.defineProperty(exports, "PowerLevelController", { enumerable: true, get: function () { return PowerLevelController_js_1.PowerLevelController; } }); const RangeController_js_1 = require("./interfaces/RangeController.js"); Object.defineProperty(exports, "RangeController", { enumerable: true, get: function () { return RangeController_js_1.RangeController; } }); +const SecurityPanelController_js_1 = require("./interfaces/SecurityPanelController.js"); +Object.defineProperty(exports, "SecurityPanelController", { enumerable: true, get: function () { return SecurityPanelController_js_1.SecurityPanelController; } }); const TemperatureSensor_js_1 = require("./interfaces/TemperatureSensor.js"); Object.defineProperty(exports, "TemperatureSensor", { enumerable: true, get: function () { return TemperatureSensor_js_1.TemperatureSensor; } }); const ThermostatController_js_1 = require("./interfaces/ThermostatController.js"); @@ -69,6 +71,7 @@ const described = [ PowerController_js_1.PowerController, PowerLevelController_js_1.PowerLevelController, RangeController_js_1.RangeController, + SecurityPanelController_js_1.SecurityPanelController, TemperatureSensor_js_1.TemperatureSensor, ThermostatController_js_1.ThermostatController, ThermostatControllerSchedule_js_1.ThermostatControllerSchedule, diff --git a/dist/cjs/registry/interfaces/SecurityPanelController.js b/dist/cjs/registry/interfaces/SecurityPanelController.js new file mode 100644 index 0000000..9dfa8af --- /dev/null +++ b/dist/cjs/registry/interfaces/SecurityPanelController.js @@ -0,0 +1,91 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.SecurityPanelController = void 0; +const schema_js_1 = require("../schema.js"); +const types_js_1 = require("../types.js"); +const ARM_STATES = ["ARMED_AWAY", "ARMED_STAY", "ARMED_NIGHT", "DISARMED"]; +const ALARMS = ["burglaryAlarm", "fireAlarm", "carbonMonoxideAlarm", "waterAlarm"]; +const alarm = schema_js_1.s.object({ value: schema_js_1.s.enum("OK", "ALARM") }); +// A sensor that is left out when the panel arms. The endpointId is for a sensor that is an endpoint of its own. +const bypassed = schema_js_1.s.object({ friendlyName: schema_js_1.s.optional(schema_js_1.s.string({ min: 1 })), endpointId: schema_js_1.s.optional(schema_js_1.s.string({ min: 1 })) }); +const armResponse = schema_js_1.s.object({ + /** How long the occupants have to leave before the panel arms. 0: it arms at once. */ + exitDelayInSeconds: schema_js_1.s.optional(schema_js_1.s.number({ min: 0, max: 255, integer: true })), + /** For an Arm that came with bypassType only. */ + bypassedEndpoints: schema_js_1.s.optional(schema_js_1.s.array(bypassed)), +}); +/** + * A security panel. Arm is answered with Arm.Response, Disarm with a Response, an error of errorTypes under the + * namespace of the interface (alexa-securitypanelcontroller-errorresponse.html). + * + * Alexa sends Disarm only when the user switched on disarming by voice in the Alexa app. A panel declared with + * supportedAuthorizationTypes gets the PIN the user said in payload.authorization and has to check it: a wrong PIN + * is answered with UNAUTHORIZED. Without authorization in the payload Alexa checked a voice code of its own. + * + * A panel that is ARMED_AWAY is disarmed before it is armed another way: Arm to ARMED_STAY or ARMED_NIGHT is + * then answered with AUTHORIZATION_REQUIRED. Arm to the state the panel is in is answered with Arm.Response. + */ +exports.SecurityPanelController = (0, types_js_1.defineInterface)({ + namespace: "Alexa.SecurityPanelController", + version: "3", + doc: "https://developer.amazon.com/docs/alexaplus/device-apis/alexa-securitypanelcontroller.html", + kind: "controller", + tier: 2, + instanced: false, + properties: { + armState: { name: "armState", value: schema_js_1.s.enum(...ARM_STATES) }, + burglaryAlarm: { name: "burglaryAlarm", value: alarm }, + fireAlarm: { name: "fireAlarm", value: alarm }, + carbonMonoxideAlarm: { name: "carbonMonoxideAlarm", value: alarm }, + waterAlarm: { name: "waterAlarm", value: alarm }, + }, + directives: { + Arm: { + name: "Arm", + payload: schema_js_1.s.object({ armState: schema_js_1.s.enum(...ARM_STATES), bypassType: schema_js_1.s.optional(schema_js_1.s.enum("BYPASS_ALL")) }), + note: "bypassType: the user said to arm with the open sensors left out, which BYPASS_NEEDED listed", + }, + Disarm: { + name: "Disarm", + payload: schema_js_1.s.object({ + authorization: schema_js_1.s.optional(schema_js_1.s.object({ + type: schema_js_1.s.enum("FOUR_DIGIT_PIN"), + value: schema_js_1.s.string({ pattern: /^\d{4}$/, expects: "a PIN of four digits" }), + })), + }), + note: "authorization: the PIN the user said, for the panel to check", + }, + }, + options: schema_js_1.s.object({ + /** Default: Alexa takes the panel to have all four. */ + supportedArmStates: schema_js_1.s.optional(schema_js_1.s.array(schema_js_1.s.enum(...ARM_STATES), { min: 1 })), + /** For a panel that has four digit PINs and checks the PIN of a Disarm itself. */ + supportedAuthorizationTypes: schema_js_1.s.optional(schema_js_1.s.array(schema_js_1.s.enum("FOUR_DIGIT_PIN"), { min: 1 })), + /** The alarms the panel reports. Default: none. */ + alarms: schema_js_1.s.optional(schema_js_1.s.array(schema_js_1.s.enum(...ALARMS))), + }, { unknownKeys: "reject" }), + discovery({ options: { supportedArmStates, supportedAuthorizationTypes, alarms = [] } }) { + const configuration = { + ...(supportedArmStates && { supportedArmStates: supportedArmStates.map((value) => ({ value })) }), + ...(supportedAuthorizationTypes && { supportedAuthorizationTypes: supportedAuthorizationTypes.map((type) => ({ type })) }), + }; + return { + supported: ["armState", ...alarms], + ...(Object.keys(configuration).length > 0 && { configuration }), + }; + }, + validate(capability) { + const { supportedArmStates = [], supportedAuthorizationTypes = [], alarms = [] } = capability.options; + const lists = [supportedArmStates, supportedAuthorizationTypes, alarms]; + for (const list of lists) { + const twice = list.find((entry, i) => list.indexOf(entry) !== i); + if (twice) + throw new types_js_1.DeclarationError(capability, `${twice} is listed twice`); + } + }, + responseFor: (directive) => (directive === "Arm" + ? { namespace: "Alexa.SecurityPanelController", name: "Arm.Response", payload: armResponse } + : undefined), + errorNamespace: "Alexa.SecurityPanelController", + errorTypes: ["AUTHORIZATION_REQUIRED", "BYPASS_NEEDED", "NOT_READY", "UNAUTHORIZED", "UNCLEARED_ALARM", "UNCLEARED_TROUBLE"], +}); diff --git a/dist/cjs/registry/interfaces/stubs.js b/dist/cjs/registry/interfaces/stubs.js index 53f3b6d..0ab04f3 100644 --- a/dist/cjs/registry/interfaces/stubs.js +++ b/dist/cjs/registry/interfaces/stubs.js @@ -51,7 +51,6 @@ const TABLE = [ ["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"], diff --git a/dist/esm/index.d.ts b/dist/esm/index.d.ts index 1d9a078..719331e 100644 --- a/dist/esm/index.d.ts +++ b/dist/esm/index.d.ts @@ -5,7 +5,7 @@ export type { EndpointDefinition, EndpointJson } from "./device/Device.js"; export { Capability } from "./device/Capability.js"; export type { CapabilityJson, CommonOptions, Declaration } from "./device/Capability.js"; export { registry, DeclarationError, SchemaError } from "./registry/index.js"; -export { Alexa, BrightnessController, ColorController, ColorTemperatureController, ContactSensor, HumiditySensor, LockController, ModeController, MotionSensor, PercentageController, PowerLevelController, RangeController, TemperatureSensor, ThermostatController, ThermostatControllerSchedule, ToggleController, } from "./registry/index.js"; +export { Alexa, BrightnessController, ColorController, ColorTemperatureController, ContactSensor, HumiditySensor, LockController, ModeController, MotionSensor, PercentageController, PowerLevelController, RangeController, SecurityPanelController, TemperatureSensor, ThermostatController, ThermostatControllerSchedule, ToggleController, } from "./registry/index.js"; export { PowerController, EndpointHealth } from "./compat/enums.js"; export { asset, text, semantics, SemanticsBuilder } from "./registry/index.js"; export { ASSETS as Assets, UNITS_OF_MEASURE as Units, ACTIONS as Actions, STATES as States, DISPLAY_CATEGORIES as DisplayCategories, THERMOSTAT_MODES as ThermostatModes, } from "./registry/index.js"; diff --git a/dist/esm/index.js b/dist/esm/index.js index 33a0a99..56e13a2 100644 --- a/dist/esm/index.js +++ b/dist/esm/index.js @@ -4,7 +4,7 @@ export { default as Device } from "./device/Device.js"; export { Capability } from "./device/Capability.js"; // The interfaces, for device.add(), and the registry that holds them by name export { registry, DeclarationError, SchemaError } from "./registry/index.js"; -export { Alexa, BrightnessController, ColorController, ColorTemperatureController, ContactSensor, HumiditySensor, LockController, ModeController, MotionSensor, PercentageController, PowerLevelController, RangeController, TemperatureSensor, ThermostatController, ThermostatControllerSchedule, ToggleController, } from "./registry/index.js"; +export { Alexa, BrightnessController, ColorController, ColorTemperatureController, ContactSensor, HumiditySensor, LockController, ModeController, MotionSensor, PercentageController, PowerLevelController, RangeController, SecurityPanelController, TemperatureSensor, ThermostatController, ThermostatControllerSchedule, ToggleController, } from "./registry/index.js"; export { PowerController, EndpointHealth } from "./compat/enums.js"; export { asset, text, semantics, SemanticsBuilder } from "./registry/index.js"; // The vocabularies of the Smart Home API diff --git a/dist/esm/registry/index.d.ts b/dist/esm/registry/index.d.ts index 1b75de5..aff5619 100644 --- a/dist/esm/registry/index.d.ts +++ b/dist/esm/registry/index.d.ts @@ -12,6 +12,7 @@ import { PercentageController } from "./interfaces/PercentageController.js"; import { PowerController } from "./interfaces/PowerController.js"; import { PowerLevelController } from "./interfaces/PowerLevelController.js"; import { RangeController } from "./interfaces/RangeController.js"; +import { SecurityPanelController } from "./interfaces/SecurityPanelController.js"; import { TemperatureSensor } from "./interfaces/TemperatureSensor.js"; import { ThermostatController } from "./interfaces/ThermostatController.js"; import { ThermostatControllerSchedule } from "./interfaces/ThermostatControllerSchedule.js"; @@ -25,7 +26,7 @@ export declare const registry: { /** Every descriptor, ordered by namespace. */ list(): AnyDescriptor[]; }; -export { Alexa, BrightnessController, ColorController, ColorTemperatureController, ContactSensor, EndpointHealth, HumiditySensor, LockController, ModeController, MotionSensor, PercentageController, PowerController, PowerLevelController, RangeController, TemperatureSensor, ThermostatController, ThermostatControllerSchedule, ToggleController, }; +export { Alexa, BrightnessController, ColorController, ColorTemperatureController, ContactSensor, EndpointHealth, HumiditySensor, LockController, ModeController, MotionSensor, PercentageController, PowerController, PowerLevelController, RangeController, SecurityPanelController, TemperatureSensor, ThermostatController, ThermostatControllerSchedule, ToggleController, }; export type { Mode } from "./interfaces/ModeController.js"; export { THERMOSTAT_MODES } from "./interfaces/ThermostatController.js"; export type { ThermostatModeName } from "./interfaces/ThermostatController.js"; diff --git a/dist/esm/registry/index.js b/dist/esm/registry/index.js index 6e9fcc3..6bc9b24 100644 --- a/dist/esm/registry/index.js +++ b/dist/esm/registry/index.js @@ -13,6 +13,7 @@ import { PercentageController } from "./interfaces/PercentageController.js"; import { PowerController } from "./interfaces/PowerController.js"; import { PowerLevelController } from "./interfaces/PowerLevelController.js"; import { RangeController } from "./interfaces/RangeController.js"; +import { SecurityPanelController } from "./interfaces/SecurityPanelController.js"; import { TemperatureSensor } from "./interfaces/TemperatureSensor.js"; import { ThermostatController } from "./interfaces/ThermostatController.js"; import { ThermostatControllerSchedule } from "./interfaces/ThermostatControllerSchedule.js"; @@ -34,6 +35,7 @@ const described = [ PowerController, PowerLevelController, RangeController, + SecurityPanelController, TemperatureSensor, ThermostatController, ThermostatControllerSchedule, @@ -63,7 +65,7 @@ export const registry = { return [...descriptors.values()].sort((a, b) => (a.namespace < b.namespace ? -1 : 1)); }, }; -export { Alexa, BrightnessController, ColorController, ColorTemperatureController, ContactSensor, EndpointHealth, HumiditySensor, LockController, ModeController, MotionSensor, PercentageController, PowerController, PowerLevelController, RangeController, TemperatureSensor, ThermostatController, ThermostatControllerSchedule, ToggleController, }; +export { Alexa, BrightnessController, ColorController, ColorTemperatureController, ContactSensor, EndpointHealth, HumiditySensor, LockController, ModeController, MotionSensor, PercentageController, PowerController, PowerLevelController, RangeController, SecurityPanelController, TemperatureSensor, ThermostatController, ThermostatControllerSchedule, ToggleController, }; export { THERMOSTAT_MODES } from "./interfaces/ThermostatController.js"; export { asset, text } from "./resources.js"; export { semantics, SemanticsBuilder } from "./semantics.js"; diff --git a/dist/esm/registry/interfaces/SecurityPanelController.d.ts b/dist/esm/registry/interfaces/SecurityPanelController.d.ts new file mode 100644 index 0000000..a54a898 --- /dev/null +++ b/dist/esm/registry/interfaces/SecurityPanelController.d.ts @@ -0,0 +1,67 @@ +/** + * A security panel. Arm is answered with Arm.Response, Disarm with a Response, an error of errorTypes under the + * namespace of the interface (alexa-securitypanelcontroller-errorresponse.html). + * + * Alexa sends Disarm only when the user switched on disarming by voice in the Alexa app. A panel declared with + * supportedAuthorizationTypes gets the PIN the user said in payload.authorization and has to check it: a wrong PIN + * is answered with UNAUTHORIZED. Without authorization in the payload Alexa checked a voice code of its own. + * + * A panel that is ARMED_AWAY is disarmed before it is armed another way: Arm to ARMED_STAY or ARMED_NIGHT is + * then answered with AUTHORIZATION_REQUIRED. Arm to the state the panel is in is answered with Arm.Response. + */ +export declare const SecurityPanelController: import("../types.js").InterfaceDescriptor<{ + armState: { + name: string; + value: import("../schema.js").EnumSchema<"ARMED_AWAY" | "ARMED_STAY" | "ARMED_NIGHT" | "DISARMED">; + }; + burglaryAlarm: { + name: string; + value: import("../schema.js").Schema; + }>>; + }; + fireAlarm: { + name: string; + value: import("../schema.js").Schema; + }>>; + }; + carbonMonoxideAlarm: { + name: string; + value: import("../schema.js").Schema; + }>>; + }; + waterAlarm: { + name: string; + value: import("../schema.js").Schema; + }>>; + }; +}, { + Arm: { + name: string; + payload: import("../schema.js").Schema; + bypassType: import("../schema.js").OptionalSchema<"BYPASS_ALL">; + }>>; + note: string; + }; + Disarm: { + name: string; + payload: import("../schema.js").Schema; + value: import("../schema.js").Schema; + }>>; + }>>; + note: string; + }; +}, import("../schema.js").InferShape<{ + /** Default: Alexa takes the panel to have all four. */ + supportedArmStates: import("../schema.js").OptionalSchema<("ARMED_AWAY" | "ARMED_STAY" | "ARMED_NIGHT" | "DISARMED")[]>; + /** For a panel that has four digit PINs and checks the PIN of a Disarm itself. */ + supportedAuthorizationTypes: import("../schema.js").OptionalSchema<"FOUR_DIGIT_PIN"[]>; + /** The alarms the panel reports. Default: none. */ + alarms: import("../schema.js").OptionalSchema<("burglaryAlarm" | "fireAlarm" | "carbonMonoxideAlarm" | "waterAlarm")[]>; +}>, false>; diff --git a/dist/esm/registry/interfaces/SecurityPanelController.js b/dist/esm/registry/interfaces/SecurityPanelController.js new file mode 100644 index 0000000..1465412 --- /dev/null +++ b/dist/esm/registry/interfaces/SecurityPanelController.js @@ -0,0 +1,88 @@ +import { s } from "../schema.js"; +import { DeclarationError, defineInterface } from "../types.js"; +const ARM_STATES = ["ARMED_AWAY", "ARMED_STAY", "ARMED_NIGHT", "DISARMED"]; +const ALARMS = ["burglaryAlarm", "fireAlarm", "carbonMonoxideAlarm", "waterAlarm"]; +const alarm = s.object({ value: s.enum("OK", "ALARM") }); +// A sensor that is left out when the panel arms. The endpointId is for a sensor that is an endpoint of its own. +const bypassed = s.object({ friendlyName: s.optional(s.string({ min: 1 })), endpointId: s.optional(s.string({ min: 1 })) }); +const armResponse = s.object({ + /** How long the occupants have to leave before the panel arms. 0: it arms at once. */ + exitDelayInSeconds: s.optional(s.number({ min: 0, max: 255, integer: true })), + /** For an Arm that came with bypassType only. */ + bypassedEndpoints: s.optional(s.array(bypassed)), +}); +/** + * A security panel. Arm is answered with Arm.Response, Disarm with a Response, an error of errorTypes under the + * namespace of the interface (alexa-securitypanelcontroller-errorresponse.html). + * + * Alexa sends Disarm only when the user switched on disarming by voice in the Alexa app. A panel declared with + * supportedAuthorizationTypes gets the PIN the user said in payload.authorization and has to check it: a wrong PIN + * is answered with UNAUTHORIZED. Without authorization in the payload Alexa checked a voice code of its own. + * + * A panel that is ARMED_AWAY is disarmed before it is armed another way: Arm to ARMED_STAY or ARMED_NIGHT is + * then answered with AUTHORIZATION_REQUIRED. Arm to the state the panel is in is answered with Arm.Response. + */ +export const SecurityPanelController = defineInterface({ + namespace: "Alexa.SecurityPanelController", + version: "3", + doc: "https://developer.amazon.com/docs/alexaplus/device-apis/alexa-securitypanelcontroller.html", + kind: "controller", + tier: 2, + instanced: false, + properties: { + armState: { name: "armState", value: s.enum(...ARM_STATES) }, + burglaryAlarm: { name: "burglaryAlarm", value: alarm }, + fireAlarm: { name: "fireAlarm", value: alarm }, + carbonMonoxideAlarm: { name: "carbonMonoxideAlarm", value: alarm }, + waterAlarm: { name: "waterAlarm", value: alarm }, + }, + directives: { + Arm: { + name: "Arm", + payload: s.object({ armState: s.enum(...ARM_STATES), bypassType: s.optional(s.enum("BYPASS_ALL")) }), + note: "bypassType: the user said to arm with the open sensors left out, which BYPASS_NEEDED listed", + }, + Disarm: { + name: "Disarm", + payload: s.object({ + authorization: s.optional(s.object({ + type: s.enum("FOUR_DIGIT_PIN"), + value: s.string({ pattern: /^\d{4}$/, expects: "a PIN of four digits" }), + })), + }), + note: "authorization: the PIN the user said, for the panel to check", + }, + }, + options: s.object({ + /** Default: Alexa takes the panel to have all four. */ + supportedArmStates: s.optional(s.array(s.enum(...ARM_STATES), { min: 1 })), + /** For a panel that has four digit PINs and checks the PIN of a Disarm itself. */ + supportedAuthorizationTypes: s.optional(s.array(s.enum("FOUR_DIGIT_PIN"), { min: 1 })), + /** The alarms the panel reports. Default: none. */ + alarms: s.optional(s.array(s.enum(...ALARMS))), + }, { unknownKeys: "reject" }), + discovery({ options: { supportedArmStates, supportedAuthorizationTypes, alarms = [] } }) { + const configuration = { + ...(supportedArmStates && { supportedArmStates: supportedArmStates.map((value) => ({ value })) }), + ...(supportedAuthorizationTypes && { supportedAuthorizationTypes: supportedAuthorizationTypes.map((type) => ({ type })) }), + }; + return { + supported: ["armState", ...alarms], + ...(Object.keys(configuration).length > 0 && { configuration }), + }; + }, + validate(capability) { + const { supportedArmStates = [], supportedAuthorizationTypes = [], alarms = [] } = capability.options; + const lists = [supportedArmStates, supportedAuthorizationTypes, alarms]; + for (const list of lists) { + const twice = list.find((entry, i) => list.indexOf(entry) !== i); + if (twice) + throw new DeclarationError(capability, `${twice} is listed twice`); + } + }, + responseFor: (directive) => (directive === "Arm" + ? { namespace: "Alexa.SecurityPanelController", name: "Arm.Response", payload: armResponse } + : undefined), + errorNamespace: "Alexa.SecurityPanelController", + errorTypes: ["AUTHORIZATION_REQUIRED", "BYPASS_NEEDED", "NOT_READY", "UNAUTHORIZED", "UNCLEARED_ALARM", "UNCLEARED_TROUBLE"], +}); diff --git a/dist/esm/registry/interfaces/stubs.js b/dist/esm/registry/interfaces/stubs.js index eb022ba..07029a9 100644 --- a/dist/esm/registry/interfaces/stubs.js +++ b/dist/esm/registry/interfaces/stubs.js @@ -48,7 +48,6 @@ const TABLE = [ ["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"], diff --git a/dist/types/index.d.ts b/dist/types/index.d.ts index 1d9a078..719331e 100644 --- a/dist/types/index.d.ts +++ b/dist/types/index.d.ts @@ -5,7 +5,7 @@ export type { EndpointDefinition, EndpointJson } from "./device/Device.js"; export { Capability } from "./device/Capability.js"; export type { CapabilityJson, CommonOptions, Declaration } from "./device/Capability.js"; export { registry, DeclarationError, SchemaError } from "./registry/index.js"; -export { Alexa, BrightnessController, ColorController, ColorTemperatureController, ContactSensor, HumiditySensor, LockController, ModeController, MotionSensor, PercentageController, PowerLevelController, RangeController, TemperatureSensor, ThermostatController, ThermostatControllerSchedule, ToggleController, } from "./registry/index.js"; +export { Alexa, BrightnessController, ColorController, ColorTemperatureController, ContactSensor, HumiditySensor, LockController, ModeController, MotionSensor, PercentageController, PowerLevelController, RangeController, SecurityPanelController, TemperatureSensor, ThermostatController, ThermostatControllerSchedule, ToggleController, } from "./registry/index.js"; export { PowerController, EndpointHealth } from "./compat/enums.js"; export { asset, text, semantics, SemanticsBuilder } from "./registry/index.js"; export { ASSETS as Assets, UNITS_OF_MEASURE as Units, ACTIONS as Actions, STATES as States, DISPLAY_CATEGORIES as DisplayCategories, THERMOSTAT_MODES as ThermostatModes, } from "./registry/index.js"; diff --git a/dist/types/registry/index.d.ts b/dist/types/registry/index.d.ts index 1b75de5..aff5619 100644 --- a/dist/types/registry/index.d.ts +++ b/dist/types/registry/index.d.ts @@ -12,6 +12,7 @@ import { PercentageController } from "./interfaces/PercentageController.js"; import { PowerController } from "./interfaces/PowerController.js"; import { PowerLevelController } from "./interfaces/PowerLevelController.js"; import { RangeController } from "./interfaces/RangeController.js"; +import { SecurityPanelController } from "./interfaces/SecurityPanelController.js"; import { TemperatureSensor } from "./interfaces/TemperatureSensor.js"; import { ThermostatController } from "./interfaces/ThermostatController.js"; import { ThermostatControllerSchedule } from "./interfaces/ThermostatControllerSchedule.js"; @@ -25,7 +26,7 @@ export declare const registry: { /** Every descriptor, ordered by namespace. */ list(): AnyDescriptor[]; }; -export { Alexa, BrightnessController, ColorController, ColorTemperatureController, ContactSensor, EndpointHealth, HumiditySensor, LockController, ModeController, MotionSensor, PercentageController, PowerController, PowerLevelController, RangeController, TemperatureSensor, ThermostatController, ThermostatControllerSchedule, ToggleController, }; +export { Alexa, BrightnessController, ColorController, ColorTemperatureController, ContactSensor, EndpointHealth, HumiditySensor, LockController, ModeController, MotionSensor, PercentageController, PowerController, PowerLevelController, RangeController, SecurityPanelController, TemperatureSensor, ThermostatController, ThermostatControllerSchedule, ToggleController, }; export type { Mode } from "./interfaces/ModeController.js"; export { THERMOSTAT_MODES } from "./interfaces/ThermostatController.js"; export type { ThermostatModeName } from "./interfaces/ThermostatController.js"; diff --git a/dist/types/registry/interfaces/SecurityPanelController.d.ts b/dist/types/registry/interfaces/SecurityPanelController.d.ts new file mode 100644 index 0000000..a54a898 --- /dev/null +++ b/dist/types/registry/interfaces/SecurityPanelController.d.ts @@ -0,0 +1,67 @@ +/** + * A security panel. Arm is answered with Arm.Response, Disarm with a Response, an error of errorTypes under the + * namespace of the interface (alexa-securitypanelcontroller-errorresponse.html). + * + * Alexa sends Disarm only when the user switched on disarming by voice in the Alexa app. A panel declared with + * supportedAuthorizationTypes gets the PIN the user said in payload.authorization and has to check it: a wrong PIN + * is answered with UNAUTHORIZED. Without authorization in the payload Alexa checked a voice code of its own. + * + * A panel that is ARMED_AWAY is disarmed before it is armed another way: Arm to ARMED_STAY or ARMED_NIGHT is + * then answered with AUTHORIZATION_REQUIRED. Arm to the state the panel is in is answered with Arm.Response. + */ +export declare const SecurityPanelController: import("../types.js").InterfaceDescriptor<{ + armState: { + name: string; + value: import("../schema.js").EnumSchema<"ARMED_AWAY" | "ARMED_STAY" | "ARMED_NIGHT" | "DISARMED">; + }; + burglaryAlarm: { + name: string; + value: import("../schema.js").Schema; + }>>; + }; + fireAlarm: { + name: string; + value: import("../schema.js").Schema; + }>>; + }; + carbonMonoxideAlarm: { + name: string; + value: import("../schema.js").Schema; + }>>; + }; + waterAlarm: { + name: string; + value: import("../schema.js").Schema; + }>>; + }; +}, { + Arm: { + name: string; + payload: import("../schema.js").Schema; + bypassType: import("../schema.js").OptionalSchema<"BYPASS_ALL">; + }>>; + note: string; + }; + Disarm: { + name: string; + payload: import("../schema.js").Schema; + value: import("../schema.js").Schema; + }>>; + }>>; + note: string; + }; +}, import("../schema.js").InferShape<{ + /** Default: Alexa takes the panel to have all four. */ + supportedArmStates: import("../schema.js").OptionalSchema<("ARMED_AWAY" | "ARMED_STAY" | "ARMED_NIGHT" | "DISARMED")[]>; + /** For a panel that has four digit PINs and checks the PIN of a Disarm itself. */ + supportedAuthorizationTypes: import("../schema.js").OptionalSchema<"FOUR_DIGIT_PIN"[]>; + /** The alarms the panel reports. Default: none. */ + alarms: import("../schema.js").OptionalSchema<("burglaryAlarm" | "fireAlarm" | "carbonMonoxideAlarm" | "waterAlarm")[]>; +}>, false>; diff --git a/src/index.ts b/src/index.ts index 6501cd2..acec3d7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,8 +10,8 @@ export type { CapabilityJson, CommonOptions, Declaration } from "./device/Capabi export { registry, DeclarationError, SchemaError } from "./registry/index.js"; export { Alexa, BrightnessController, ColorController, ColorTemperatureController, ContactSensor, HumiditySensor, LockController, - ModeController, MotionSensor, PercentageController, PowerLevelController, RangeController, TemperatureSensor, - ThermostatController, ThermostatControllerSchedule, ToggleController, + ModeController, MotionSensor, PercentageController, PowerLevelController, RangeController, SecurityPanelController, + TemperatureSensor, ThermostatController, ThermostatControllerSchedule, ToggleController, } from "./registry/index.js"; export { PowerController, EndpointHealth } from "./compat/enums.js"; export { asset, text, semantics, SemanticsBuilder } from "./registry/index.js"; diff --git a/src/registry/index.ts b/src/registry/index.ts index 3ce9d98..f172525 100644 --- a/src/registry/index.ts +++ b/src/registry/index.ts @@ -13,6 +13,7 @@ import { PercentageController } from "./interfaces/PercentageController.js"; import { PowerController } from "./interfaces/PowerController.js"; import { PowerLevelController } from "./interfaces/PowerLevelController.js"; import { RangeController } from "./interfaces/RangeController.js"; +import { SecurityPanelController } from "./interfaces/SecurityPanelController.js"; import { TemperatureSensor } from "./interfaces/TemperatureSensor.js"; import { ThermostatController } from "./interfaces/ThermostatController.js"; import { ThermostatControllerSchedule } from "./interfaces/ThermostatControllerSchedule.js"; @@ -36,6 +37,7 @@ const described: readonly AnyDescriptor[] = [ PowerController, PowerLevelController, RangeController, + SecurityPanelController, TemperatureSensor, ThermostatController, ThermostatControllerSchedule, @@ -71,7 +73,7 @@ export const registry = { export { Alexa, BrightnessController, ColorController, ColorTemperatureController, ContactSensor, EndpointHealth, HumiditySensor, LockController, ModeController, MotionSensor, PercentageController, PowerController, PowerLevelController, RangeController, - TemperatureSensor, ThermostatController, ThermostatControllerSchedule, ToggleController, + SecurityPanelController, TemperatureSensor, ThermostatController, ThermostatControllerSchedule, ToggleController, }; export type { Mode } from "./interfaces/ModeController.js"; export { THERMOSTAT_MODES } from "./interfaces/ThermostatController.js"; diff --git a/src/registry/interfaces/SecurityPanelController.ts b/src/registry/interfaces/SecurityPanelController.ts new file mode 100644 index 0000000..ba1319d --- /dev/null +++ b/src/registry/interfaces/SecurityPanelController.ts @@ -0,0 +1,91 @@ +import { s } from "../schema.js"; +import { DeclarationError, defineInterface } from "../types.js"; + +const ARM_STATES = ["ARMED_AWAY", "ARMED_STAY", "ARMED_NIGHT", "DISARMED"] as const; +const ALARMS = ["burglaryAlarm", "fireAlarm", "carbonMonoxideAlarm", "waterAlarm"] as const; + +const alarm = s.object({ value: s.enum("OK", "ALARM") }); +// A sensor that is left out when the panel arms. The endpointId is for a sensor that is an endpoint of its own. +const bypassed = s.object({ friendlyName: s.optional(s.string({ min: 1 })), endpointId: s.optional(s.string({ min: 1 })) }); + +const armResponse = s.object({ + /** How long the occupants have to leave before the panel arms. 0: it arms at once. */ + exitDelayInSeconds: s.optional(s.number({ min: 0, max: 255, integer: true })), + /** For an Arm that came with bypassType only. */ + bypassedEndpoints: s.optional(s.array(bypassed)), +}); + +/** + * A security panel. Arm is answered with Arm.Response, Disarm with a Response, an error of errorTypes under the + * namespace of the interface (alexa-securitypanelcontroller-errorresponse.html). + * + * Alexa sends Disarm only when the user switched on disarming by voice in the Alexa app. A panel declared with + * supportedAuthorizationTypes gets the PIN the user said in payload.authorization and has to check it: a wrong PIN + * is answered with UNAUTHORIZED. Without authorization in the payload Alexa checked a voice code of its own. + * + * A panel that is ARMED_AWAY is disarmed before it is armed another way: Arm to ARMED_STAY or ARMED_NIGHT is + * then answered with AUTHORIZATION_REQUIRED. Arm to the state the panel is in is answered with Arm.Response. + */ +export const SecurityPanelController = defineInterface({ + namespace: "Alexa.SecurityPanelController", + version: "3", + doc: "https://developer.amazon.com/docs/alexaplus/device-apis/alexa-securitypanelcontroller.html", + kind: "controller", + tier: 2, + instanced: false, + properties: { + armState: { name: "armState", value: s.enum(...ARM_STATES) }, + burglaryAlarm: { name: "burglaryAlarm", value: alarm }, + fireAlarm: { name: "fireAlarm", value: alarm }, + carbonMonoxideAlarm: { name: "carbonMonoxideAlarm", value: alarm }, + waterAlarm: { name: "waterAlarm", value: alarm }, + }, + directives: { + Arm: { + name: "Arm", + payload: s.object({ armState: s.enum(...ARM_STATES), bypassType: s.optional(s.enum("BYPASS_ALL")) }), + note: "bypassType: the user said to arm with the open sensors left out, which BYPASS_NEEDED listed", + }, + Disarm: { + name: "Disarm", + payload: s.object({ + authorization: s.optional(s.object({ + type: s.enum("FOUR_DIGIT_PIN"), + value: s.string({ pattern: /^\d{4}$/, expects: "a PIN of four digits" }), + })), + }), + note: "authorization: the PIN the user said, for the panel to check", + }, + }, + options: s.object({ + /** Default: Alexa takes the panel to have all four. */ + supportedArmStates: s.optional(s.array(s.enum(...ARM_STATES), { min: 1 })), + /** For a panel that has four digit PINs and checks the PIN of a Disarm itself. */ + supportedAuthorizationTypes: s.optional(s.array(s.enum("FOUR_DIGIT_PIN"), { min: 1 })), + /** The alarms the panel reports. Default: none. */ + alarms: s.optional(s.array(s.enum(...ALARMS))), + }, { unknownKeys: "reject" }), + discovery({ options: { supportedArmStates, supportedAuthorizationTypes, alarms = [] } }) { + const configuration = { + ...(supportedArmStates && { supportedArmStates: supportedArmStates.map((value) => ({ value })) }), + ...(supportedAuthorizationTypes && { supportedAuthorizationTypes: supportedAuthorizationTypes.map((type) => ({ type })) }), + }; + return { + supported: ["armState", ...alarms], + ...(Object.keys(configuration).length > 0 && { configuration }), + }; + }, + validate(capability) { + const { supportedArmStates = [], supportedAuthorizationTypes = [], alarms = [] } = capability.options; + const lists: ReadonlyArray = [supportedArmStates, supportedAuthorizationTypes, alarms]; + for (const list of lists) { + const twice = list.find((entry, i) => list.indexOf(entry) !== i); + if (twice) throw new DeclarationError(capability, `${twice} is listed twice`); + } + }, + responseFor: (directive) => (directive === "Arm" + ? { namespace: "Alexa.SecurityPanelController", name: "Arm.Response", payload: armResponse } + : undefined), + errorNamespace: "Alexa.SecurityPanelController", + errorTypes: ["AUTHORIZATION_REQUIRED", "BYPASS_NEEDED", "NOT_READY", "UNAUTHORIZED", "UNCLEARED_ALARM", "UNCLEARED_TROUBLE"], +}); diff --git a/src/registry/interfaces/stubs.ts b/src/registry/interfaces/stubs.ts index bd71f04..be9fb66 100644 --- a/src/registry/interfaces/stubs.ts +++ b/src/registry/interfaces/stubs.ts @@ -54,7 +54,6 @@ const TABLE: readonly Row[] = [ ["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"], diff --git a/test/dispatch/interfaces.test.js b/test/dispatch/interfaces.test.js index b830231..41ada91 100644 --- a/test/dispatch/interfaces.test.js +++ b/test/dispatch/interfaces.test.js @@ -4,8 +4,8 @@ const { test } = require("node:test"); const assert = require("node:assert/strict"); const { - Alex2MQTT, AlexaErrors, LockController, MemoryPublisher, PowerLevelController, ThermostatController, - ThermostatControllerSchedule, registry, + Alex2MQTT, AlexaErrors, LockController, MemoryPublisher, PowerLevelController, SecurityPanelController, + ThermostatController, ThermostatControllerSchedule, registry, } = require("alex2node"); const interfaces = require("../fixtures/interfaces.js"); const { doc, examples } = require("../helpers/fixtures.js"); @@ -33,15 +33,20 @@ for (const { namespace, page, declared, refused } of interfaces.filter((entry) = test(`${namespace}: the directives of ${page}.html reach the handler, a payload that does not fit is answered with INVALID_VALUE`, async () => { const { device, receive, answers } = bridgeWithout(); const seen = []; - device.add(registry.get(namespace), declared[0].options).on("*", (ctx) => { + const descriptor = registry.get(namespace); + device.add(descriptor, declared[0].options).on("*", (ctx) => { seen.push([ctx.name, ctx.payload]); return ctx.respond(); }); - const printed = examples(page, ".directive").map((name) => doc(page, name).directive); + const printed = examples(page, ".directive").map((name) => doc(page, name).directive) + .filter((example) => example.header.namespace === namespace); for (const example of printed) await receive(example); assert.deepEqual(seen, printed.map(({ header, payload }) => [header.name, payload])); - assert.deepEqual(answers(), printed.map(() => ["alexaResponce", "Alexa", "Response", {}])); + assert.deepEqual(answers(), printed.map(({ header }) => { + const own = descriptor.responseFor && descriptor.responseFor(header.name); + return ["alexaResponce", own ? own.namespace : "Alexa", own ? own.name : "Response", {}]; + })); for (const [name, payload] of refused) await receive(directive(namespace, name, "device-1", "ct-1", payload)); assert.deepEqual( @@ -117,3 +122,62 @@ test("Alexa.ThermostatController: an error of the thermostat goes under its name message: "Alexa.ThermostatController.Schedule as device-1 declares it does not take SetAdaptiveRecovery", }]); }); + +test("Alexa.SecurityPanelController: Arm is answered with Arm.Response, Disarm with a Response when the PIN is the one of the panel", async () => { + const { device, sent, receive, answers } = bridgeWithout(); + const page = "alexa-securitypanelcontroller"; + const errors = "alexa-securitypanelcontroller-errorresponse"; + const open = doc(errors, "BYPASS_NEEDED.error").event.payload; + const bypassed = doc(page, "Arm-bypass.response").event.payload; + let armState = "DISARMED"; + const panel = device.add(SecurityPanelController, { supportedAuthorizationTypes: ["FOUR_DIGIT_PIN"], alarms: ["burglaryAlarm"] }); + device.state((s) => s.set(panel, "armState", armState).set(panel, "burglaryAlarm", { value: "OK" })); + panel.on("Arm", (ctx) => { + if (armState === "ARMED_AWAY" && ctx.payload.armState !== "ARMED_AWAY") { + throw AlexaErrors.of("AUTHORIZATION_REQUIRED", "You must disarm the system before you can set the requested arm state."); + } + if (!ctx.payload.bypassType) throw AlexaErrors.bypassNeeded(open.message, open.endpointsNeedingBypass); + armState = ctx.payload.armState; + return ctx.respond(undefined, { payload: bypassed }); + }); + panel.on("Disarm", (ctx) => { + const { authorization } = ctx.payload; + if (!authorization || authorization.value !== "1234") throw AlexaErrors.of("UNAUTHORIZED", "The PIN code is not correct."); + armState = "DISARMED"; + return ctx.respond(); + }); + + await receive(doc(page, "Arm.directive").directive); + await receive(doc(page, "Arm-bypass.directive").directive); + await receive(directive(panel.namespace, "Arm", "device-1", "ct", { armState: "ARMED_NIGHT" })); + await receive(directive(panel.namespace, "Disarm", "device-1", "ct", { authorization: { type: "FOUR_DIGIT_PIN", value: "4321" } })); + await receive(directive(panel.namespace, "Disarm", "device-1", "ct", {})); + await receive(doc(page, "Disarm.directive").directive); + + const refused = (type) => { + const { header, payload } = doc(errors, `${type}.error`).event; + return ["alexaResponce", header.namespace, header.name, payload]; + }; + assert.deepEqual(answers(), [ + refused("BYPASS_NEEDED"), + ["alexaResponce", "Alexa.SecurityPanelController", "Arm.Response", bypassed], + refused("AUTHORIZATION_REQUIRED"), + refused("UNAUTHORIZED"), + refused("UNAUTHORIZED"), + ["alexaResponce", "Alexa", "Response", {}], + ]); + assert.equal(refused("UNAUTHORIZED")[1], "Alexa.SecurityPanelController"); + const states = sent.published.filter(({ message }) => message.context) + .map(({ message }) => message.context.properties.find((property) => property.name === "armState").value); + assert.deepEqual(states, ["ARMED_AWAY", "DISARMED"]); +}); + +test("Alexa.SecurityPanelController: a payload that is no Arm.Response is not sent, the directive is answered with INTERNAL_ERROR", async () => { + const { device, receive, answers } = bridgeWithout(); + device.add(SecurityPanelController).on("Arm", (ctx) => ctx.respond(undefined, { payload: { exitDelayInSeconds: 300 } })); + await receive(doc("alexa-securitypanelcontroller", "Arm.directive").directive); + assert.deepEqual(answers(), [["alexaResponce", "Alexa", "ErrorResponse", { + type: "INTERNAL_ERROR", + message: "payload.exitDelayInSeconds: expected an integer from 0 to 255, got 300", + }]]); +}); diff --git a/test/fixtures/alexa-docs/alexa-securitypanelcontroller-errorresponse/AUTHORIZATION_REQUIRED.error.json b/test/fixtures/alexa-docs/alexa-securitypanelcontroller-errorresponse/AUTHORIZATION_REQUIRED.error.json new file mode 100644 index 0000000..4aa9658 --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-securitypanelcontroller-errorresponse/AUTHORIZATION_REQUIRED.error.json @@ -0,0 +1,22 @@ +{ + "event": { + "header": { + "namespace": "Alexa.SecurityPanelController", + "name": "ErrorResponse", + "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": { + "type": "AUTHORIZATION_REQUIRED", + "message": "You must disarm the system before you can set the requested arm state." + } + } +} diff --git a/test/fixtures/alexa-docs/alexa-securitypanelcontroller-errorresponse/BYPASS_NEEDED.error.json b/test/fixtures/alexa-docs/alexa-securitypanelcontroller-errorresponse/BYPASS_NEEDED.error.json new file mode 100644 index 0000000..de54f60 --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-securitypanelcontroller-errorresponse/BYPASS_NEEDED.error.json @@ -0,0 +1,35 @@ +{ + "event": { + "header": { + "namespace": "Alexa.SecurityPanelController", + "name": "ErrorResponse", + "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": { + "type": "BYPASS_NEEDED", + "message": "Unable to arm the security panel because there are open sensors.", + "endpointsNeedingBypass": [ + { + "friendlyName": "side window sensor", + "endpointId": "Endpoint ID" + }, + { + "friendlyName": "front door sensor", + "endpointId": "Endpoint ID" + }, + { + "friendlyName": "water sensor" + } + ] + } + } +} diff --git a/test/fixtures/alexa-docs/alexa-securitypanelcontroller-errorresponse/UNAUTHORIZED.error.json b/test/fixtures/alexa-docs/alexa-securitypanelcontroller-errorresponse/UNAUTHORIZED.error.json new file mode 100644 index 0000000..eb69a0e --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-securitypanelcontroller-errorresponse/UNAUTHORIZED.error.json @@ -0,0 +1,18 @@ +{ + "event": { + "header": { + "namespace": "Alexa.SecurityPanelController", + "name": "ErrorResponse", + "messageId": "Unique identifier, preferably a version 4 UUID", + "correlationToken": "Opaque correlation token that matches the request", + "payloadVersion": "3" + }, + "endpoint": { + "endpointId": "Endpoint ID" + }, + "payload": { + "type": "UNAUTHORIZED", + "message": "The PIN code is not correct." + } + } +} diff --git a/test/fixtures/alexa-docs/alexa-securitypanelcontroller-errorresponse/UNCLEARED_ALARM.error.json b/test/fixtures/alexa-docs/alexa-securitypanelcontroller-errorresponse/UNCLEARED_ALARM.error.json new file mode 100644 index 0000000..9f6578c --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-securitypanelcontroller-errorresponse/UNCLEARED_ALARM.error.json @@ -0,0 +1,22 @@ +{ + "event": { + "header": { + "namespace": "Alexa.SecurityPanelController", + "name": "ErrorResponse", + "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": { + "type": "UNCLEARED_ALARM", + "message": "Unable to arm or disarm the security panel because it is in alarm status." + } + } +} diff --git a/test/fixtures/alexa-docs/alexa-securitypanelcontroller/Arm-alarm.response.json b/test/fixtures/alexa-docs/alexa-securitypanelcontroller/Arm-alarm.response.json new file mode 100644 index 0000000..954c2db --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-securitypanelcontroller/Arm-alarm.response.json @@ -0,0 +1,50 @@ +{ + "event": { + "header": { + "namespace": "Alexa.SecurityPanelController", + "name": "Arm.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": { + "exitDelayInSeconds": 60 + } + }, + "context": { + "properties": [ + { + "namespace": "Alexa.SecurityPanelController", + "name": "burglaryAlarm", + "value": { + "value": "ALARM" + }, + "timeOfSample": "2024-07-03T10:20:50.52Z", + "uncertaintyInMilliseconds": 0 + }, + { + "namespace": "Alexa.SecurityPanelController", + "name": "armState", + "value": "ARMED_AWAY", + "timeOfSample": "2024-07-03T10:20:50.52Z", + "uncertaintyInMilliseconds": 0 + }, + { + "namespace": "Alexa.EndpointHealth", + "name": "connectivity", + "value": { + "value": "OK" + }, + "timeOfSample": "2024-07-03T09:55:05.52Z", + "uncertaintyInMilliseconds": 0 + } + ] + } +} diff --git a/test/fixtures/alexa-docs/alexa-securitypanelcontroller/Arm-bypass.directive.json b/test/fixtures/alexa-docs/alexa-securitypanelcontroller/Arm-bypass.directive.json new file mode 100644 index 0000000..24a29f0 --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-securitypanelcontroller/Arm-bypass.directive.json @@ -0,0 +1,23 @@ +{ + "directive": { + "header": { + "namespace": "Alexa.SecurityPanelController", + "name": "Arm", + "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": { + "armState": "ARMED_AWAY", + "bypassType": "BYPASS_ALL" + } + } +} diff --git a/test/fixtures/alexa-docs/alexa-securitypanelcontroller/Arm-bypass.response.json b/test/fixtures/alexa-docs/alexa-securitypanelcontroller/Arm-bypass.response.json new file mode 100644 index 0000000..03b0fae --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-securitypanelcontroller/Arm-bypass.response.json @@ -0,0 +1,54 @@ +{ + "event": { + "header": { + "namespace": "Alexa.SecurityPanelController", + "name": "Arm.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": { + "exitDelayInSeconds": 60, + "bypassedEndpoints": [ + { + "friendlyName": "side window sensor", + "endpointId": "Endpoint ID" + }, + { + "friendlyName": "front door sensor", + "endpointId": "Endpoint ID" + }, + { + "friendlyName": "water sensor" + } + ] + } + }, + "context": { + "properties": [ + { + "namespace": "Alexa.SecurityPanelController", + "name": "armState", + "value": "ARMED_AWAY", + "timeOfSample": "2024-07-03T10:20:50.52Z", + "uncertaintyInMilliseconds": 0 + }, + { + "namespace": "Alexa.EndpointHealth", + "name": "connectivity", + "value": { + "value": "OK" + }, + "timeOfSample": "2024-07-03T09:55:05.52Z", + "uncertaintyInMilliseconds": 0 + } + ] + } +} diff --git a/test/fixtures/alexa-docs/alexa-securitypanelcontroller/Arm.directive.json b/test/fixtures/alexa-docs/alexa-securitypanelcontroller/Arm.directive.json new file mode 100644 index 0000000..b77f9b7 --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-securitypanelcontroller/Arm.directive.json @@ -0,0 +1,22 @@ +{ + "directive": { + "header": { + "namespace": "Alexa.SecurityPanelController", + "name": "Arm", + "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": { + "armState": "ARMED_AWAY" + } + } +} diff --git a/test/fixtures/alexa-docs/alexa-securitypanelcontroller/Arm.response.json b/test/fixtures/alexa-docs/alexa-securitypanelcontroller/Arm.response.json new file mode 100644 index 0000000..033235e --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-securitypanelcontroller/Arm.response.json @@ -0,0 +1,41 @@ +{ + "event": { + "header": { + "namespace": "Alexa.SecurityPanelController", + "name": "Arm.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": { + "exitDelayInSeconds": 60 + } + }, + "context": { + "properties": [ + { + "namespace": "Alexa.SecurityPanelController", + "name": "armState", + "value": "ARMED_AWAY", + "timeOfSample": "2024-07-03T10:20:50.52Z", + "uncertaintyInMilliseconds": 0 + }, + { + "namespace": "Alexa.EndpointHealth", + "name": "connectivity", + "value": { + "value": "OK" + }, + "timeOfSample": "2024-07-03T09:55:05.52Z", + "uncertaintyInMilliseconds": 0 + } + ] + } +} diff --git a/test/fixtures/alexa-docs/alexa-securitypanelcontroller/ChangeReport-alarm.json b/test/fixtures/alexa-docs/alexa-securitypanelcontroller/ChangeReport-alarm.json new file mode 100644 index 0000000..1e59cd3 --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-securitypanelcontroller/ChangeReport-alarm.json @@ -0,0 +1,64 @@ +{ + "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": "RULE_TRIGGER" + }, + "properties": [ + { + "namespace": "Alexa.SecurityPanelController", + "name": "burglaryAlarm", + "value": { + "value": "ALARM" + }, + "timeOfSample": "2024-07-03T10:20:50.52Z", + "uncertaintyInMilliseconds": 0 + } + ] + } + } + }, + "context": { + "properties": [ + { + "namespace": "Alexa.SecurityPanelController", + "name": "armState", + "value": "ARMED_AWAY", + "timeOfSample": "2024-07-03T08:20:50.52Z", + "uncertaintyInMilliseconds": 0 + }, + { + "namespace": "Alexa.SecurityPanelController", + "name": "fireAlarm", + "value": { + "value": "OK" + }, + "timeOfSample": "2024-07-03T08:20:50.52Z", + "uncertaintyInMilliseconds": 0 + }, + { + "namespace": "Alexa.EndpointHealth", + "name": "connectivity", + "value": { + "value": "OK" + }, + "timeOfSample": "2024-07-03T09:55:05.52Z", + "uncertaintyInMilliseconds": 0 + } + ] + } +} diff --git a/test/fixtures/alexa-docs/alexa-securitypanelcontroller/ChangeReport.json b/test/fixtures/alexa-docs/alexa-securitypanelcontroller/ChangeReport.json new file mode 100644 index 0000000..fd3393c --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-securitypanelcontroller/ChangeReport.json @@ -0,0 +1,64 @@ +{ + "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.SecurityPanelController", + "name": "armState", + "value": "DISARMED", + "timeOfSample": "2024-07-03T10:20:50.52Z", + "uncertaintyInMilliseconds": 0 + } + ] + } + } + }, + "context": { + "properties": [ + { + "namespace": "Alexa.SecurityPanelController", + "name": "burglaryAlarm", + "value": { + "value": "OK" + }, + "timeOfSample": "2024-07-03T09:20:50.52Z", + "uncertaintyInMilliseconds": 0 + }, + { + "namespace": "Alexa.SecurityPanelController", + "name": "fireAlarm", + "value": { + "value": "OK" + }, + "timeOfSample": "2024-07-03T09:20:50.51Z", + "uncertaintyInMilliseconds": 0 + }, + { + "namespace": "Alexa.EndpointHealth", + "name": "connectivity", + "value": { + "value": "OK" + }, + "timeOfSample": "2024-07-03T10:19:00.00Z", + "uncertaintyInMilliseconds": 600000 + } + ] + } +} diff --git a/test/fixtures/alexa-docs/alexa-securitypanelcontroller/Disarm-alarm.response.json b/test/fixtures/alexa-docs/alexa-securitypanelcontroller/Disarm-alarm.response.json new file mode 100644 index 0000000..95e08d4 --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-securitypanelcontroller/Disarm-alarm.response.json @@ -0,0 +1,48 @@ +{ + "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.SecurityPanelController", + "name": "burglaryAlarm", + "value": { + "value": "ALARM" + }, + "timeOfSample": "2024-07-03T10:20:50.52Z", + "uncertaintyInMilliseconds": 0 + }, + { + "namespace": "Alexa.SecurityPanelController", + "name": "armState", + "value": "ARMED_AWAY", + "timeOfSample": "2024-07-03T10:20:50.52Z", + "uncertaintyInMilliseconds": 0 + }, + { + "namespace": "Alexa.EndpointHealth", + "name": "connectivity", + "value": { + "value": "OK" + }, + "timeOfSample": "2024-07-03T09:55:05.52Z", + "uncertaintyInMilliseconds": 0 + } + ] + } +} diff --git a/test/fixtures/alexa-docs/alexa-securitypanelcontroller/Disarm.directive.json b/test/fixtures/alexa-docs/alexa-securitypanelcontroller/Disarm.directive.json new file mode 100644 index 0000000..2f38546 --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-securitypanelcontroller/Disarm.directive.json @@ -0,0 +1,25 @@ +{ + "directive": { + "header": { + "namespace": "Alexa.SecurityPanelController", + "name": "Disarm", + "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": { + "authorization": { + "type": "FOUR_DIGIT_PIN", + "value": "1234" + } + } + } +} diff --git a/test/fixtures/alexa-docs/alexa-securitypanelcontroller/Disarm.response.json b/test/fixtures/alexa-docs/alexa-securitypanelcontroller/Disarm.response.json new file mode 100644 index 0000000..9028951 --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-securitypanelcontroller/Disarm.response.json @@ -0,0 +1,39 @@ +{ + "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.SecurityPanelController", + "name": "armState", + "value": "DISARMED", + "timeOfSample": "2024-07-03T11:20:50.52Z", + "uncertaintyInMilliseconds": 0 + }, + { + "namespace": "Alexa.EndpointHealth", + "name": "connectivity", + "value": { + "value": "OK" + }, + "timeOfSample": "2024-07-03T09:55:05.52Z", + "uncertaintyInMilliseconds": 0 + } + ] + } +} diff --git a/test/fixtures/alexa-docs/alexa-securitypanelcontroller/StateReport.json b/test/fixtures/alexa-docs/alexa-securitypanelcontroller/StateReport.json new file mode 100644 index 0000000..5324dd3 --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-securitypanelcontroller/StateReport.json @@ -0,0 +1,57 @@ +{ + "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.SecurityPanelController", + "name": "armState", + "value": "ARMED_AWAY", + "timeOfSample": "2024-07-03T10:20:50.52Z", + "uncertaintyInMilliseconds": 0 + }, + { + "namespace": "Alexa.SecurityPanelController", + "name": "burglaryAlarm", + "value": { + "value": "OK" + }, + "timeOfSample": "2024-07-03T10:20:50.52Z", + "uncertaintyInMilliseconds": 0 + }, + { + "namespace": "Alexa.SecurityPanelController", + "name": "fireAlarm", + "value": { + "value": "OK" + }, + "timeOfSample": "2024-07-03T10:20:50.52Z", + "uncertaintyInMilliseconds": 0 + }, + { + "namespace": "Alexa.EndpointHealth", + "name": "connectivity", + "value": { + "value": "OK" + }, + "timeOfSample": "2024-07-03T09:55:05.52Z", + "uncertaintyInMilliseconds": 0 + } + ] + } +} diff --git a/test/fixtures/alexa-docs/alexa-securitypanelcontroller/burglaryAlarm.property.json b/test/fixtures/alexa-docs/alexa-securitypanelcontroller/burglaryAlarm.property.json new file mode 100644 index 0000000..d6daa43 --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-securitypanelcontroller/burglaryAlarm.property.json @@ -0,0 +1,6 @@ +{ + "name": "burglaryAlarm", + "value": { + "value": "OK" + } +} diff --git a/test/fixtures/alexa-docs/alexa-securitypanelcontroller/discovery.json b/test/fixtures/alexa-docs/alexa-securitypanelcontroller/discovery.json new file mode 100644 index 0000000..bd66876 --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-securitypanelcontroller/discovery.json @@ -0,0 +1,83 @@ +{ + "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": "Your security panel name, displayed in the Alexa app", + "displayCategories": [ + "SECURITY_PANEL" + ], + "cookie": {}, + "capabilities": [ + { + "type": "AlexaInterface", + "interface": "Alexa.SecurityPanelController", + "version": "3", + "properties": { + "supported": [ + { + "name": "armState" + }, + { + "name": "burglaryAlarm" + }, + { + "name": "fireAlarm" + } + ], + "proactivelyReported": true, + "retrievable": true + }, + "configuration": { + "supportedArmStates": [ + { + "value": "ARMED_AWAY" + }, + { + "value": "ARMED_STAY" + }, + { + "value": "DISARMED" + } + ], + "supportedAuthorizationTypes": [ + { + "type": "FOUR_DIGIT_PIN" + } + ] + } + }, + { + "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 index 692e33e..ede2f58 100644 --- a/test/fixtures/interfaces.js +++ b/test/fixtures/interfaces.js @@ -15,6 +15,8 @@ // chosen true: a declaration lists the properties the device has, an example some of those of the interface // folded the directives whose example the page folds away, so that it was not saved // +// A directive is answered with a Response, or with the event responseFor() of its descriptor names. +// // test/registry/descriptors.test.js, test/device/discovery.test.js and test/dispatch/interfaces.test.js run the // entries. The first one fails for a descriptor of tier 1 or 2 that has no entry. const { asset, semantics, text } = require("alex2node"); @@ -109,6 +111,27 @@ module.exports = [ }, }], }, + { + namespace: "Alexa.SecurityPanelController", + page: "alexa-securitypanelcontroller", + chosen: true, + declared: [{ + example: "discovery", + options: { + ...reported, + supportedArmStates: ["ARMED_AWAY", "ARMED_STAY", "DISARMED"], + supportedAuthorizationTypes: ["FOUR_DIGIT_PIN"], + alarms: ["burglaryAlarm", "fireAlarm"], + }, + }], + refused: [ + ["Arm", { armState: "ARMED" }, 'payload.armState: expected ARMED_AWAY | ARMED_STAY | ARMED_NIGHT | DISARMED, got "ARMED"'], + ["Arm", { armState: "ARMED_STAY", bypassType: "BYPASS_SOME" }, 'payload.bypassType: expected BYPASS_ALL, got "BYPASS_SOME"'], + ["Disarm", { authorization: { type: "FOUR_DIGIT_PIN", value: "12345" } }, 'payload.authorization.value: expected a PIN of four digits, got "12345"'], + ["Disarm", { authorization: { type: "VOICE", value: "1234" } }, 'payload.authorization.type: expected FOUR_DIGIT_PIN, got "VOICE"'], + ], + unfit: [["armState", "ARMED"], ["burglaryAlarm", "ALARM"], ["fireAlarm", { value: "FIRE" }]], + }, { namespace: "Alexa.TemperatureSensor", page: "alexa-temperaturesensor", declared: [{ options: reported, example: "discovery" }] }, { namespace: "Alexa.ThermostatController", diff --git a/test/registry/descriptors.test.js b/test/registry/descriptors.test.js index 75e5f33..4b59bed 100644 --- a/test/registry/descriptors.test.js +++ b/test/registry/descriptors.test.js @@ -48,14 +48,15 @@ for (const { namespace, page, declared, unfit = [], chosen = false, folded = [] } } - const directives = examples(page, ".directive").map((name) => doc(page, name).directive); + // A page also shows the directives of the interface it is paired with: TurnOn on the page of WakeOnLANController + const directives = examples(page, ".directive").map((name) => doc(page, name).directive) + .filter((directive) => directive.header.namespace === namespace); assert.deepEqual( [...new Set(directives.map((directive) => directive.header.name))].sort(), Object.keys(descriptor.directives).filter((name) => name !== "ReportState" && !folded.includes(name)).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); } @@ -135,8 +136,10 @@ test("every AlexaInterfaceType is in the registry: described, or a stub with the }); test("AlexaInterface.getVersion() and getProps() read the registry", () => { - // What a thermostat lists is for its declaration to say: a 1.x declaration lists what 1.5.2 listed + // What a thermostat or a security panel lists is for its declaration to say: a 1.x declaration of the thermostat + // lists what 1.5.2 listed, one of the panel the arm state and no alarm const chosen = { + "Alexa.SecurityPanelController": ["armState"], "Alexa.ThermostatController": ["targetSetpoint", "lowerSetpoint", "upperSetpoint", "thermostatMode"], "Alexa.ThermostatController.Schedule": ["scheduleEnabled"], }; diff --git a/test/registry/security-panel.test.js b/test/registry/security-panel.test.js new file mode 100644 index 0000000..b98b660 --- /dev/null +++ b/test/registry/security-panel.test.js @@ -0,0 +1,51 @@ +"use strict"; +// Alexa.SecurityPanelController: what the examples of its page do not show. The examples themselves are run by +// descriptors.test.js, device/discovery.test.js and dispatch/interfaces.test.js. +const { test } = require("node:test"); +const assert = require("node:assert/strict"); +const { AlexaInterfaceType, DeclarationError, SecurityPanelController } = require("alex2node"); +const { endpoint } = require("../helpers/endpoint.js"); + +const panel = () => endpoint("panel-1", "Panel", ["SECURITY_PANEL"]); + +test("a panel declared without options lists the arm state and no configuration, the 1.x way or with add()", () => { + const announced = { + type: "AlexaInterface", + interface: "Alexa.SecurityPanelController", + version: "3", + properties: { supported: [{ name: "armState" }], proactivelyReported: false, retrievable: true }, + }; + assert.deepEqual(panel().addCapability(AlexaInterfaceType.SECURITY_PANEL_CONTROLLER).getJSON(), announced); + const device = panel(); + assert.deepEqual(device.add(SecurityPanelController).toJSON(), announced); + assert.deepEqual(device.check(), []); +}); + +test("the alarms and the arm states of a declaration are the ones of the page, each listed once", () => { + const { properties, configuration } = panel().add(SecurityPanelController, { + supportedArmStates: ["ARMED_NIGHT", "DISARMED"], + alarms: ["waterAlarm", "carbonMonoxideAlarm"], + }).toJSON(); + assert.deepEqual(properties.supported, [{ name: "armState" }, { name: "waterAlarm" }, { name: "carbonMonoxideAlarm" }]); + assert.deepEqual(configuration, { supportedArmStates: [{ value: "ARMED_NIGHT" }, { value: "DISARMED" }] }); + + const refused = (options, message) => assert.throws( + () => panel().add(SecurityPanelController, options), + (err) => err instanceof DeclarationError && err.message === `panel-1: Alexa.SecurityPanelController: ${message}` + ); + refused({ alarms: ["smokeAlarm"] }, 'alarms[0]: expected burglaryAlarm | fireAlarm | carbonMonoxideAlarm | waterAlarm, got "smokeAlarm"'); + refused({ alarms: ["fireAlarm", "fireAlarm"] }, "fireAlarm is listed twice"); + refused({ supportedArmStates: [] }, "supportedArmStates: expected a list with 1 or more entries, got []"); + refused({ supportedAuthorizationTypes: ["SIX_DIGIT_PIN"] }, 'supportedAuthorizationTypes[0]: expected FOUR_DIGIT_PIN, got "SIX_DIGIT_PIN"'); +}); + +test("Arm.Response: an exit delay of 0 to 255 seconds, and the sensors that were bypassed", () => { + const { namespace, name, payload } = SecurityPanelController.responseFor("Arm"); + assert.deepEqual([namespace, name], ["Alexa.SecurityPanelController", "Arm.Response"]); + for (const fields of [{}, { exitDelayInSeconds: 0 }, { exitDelayInSeconds: 255, bypassedEndpoints: [{ friendlyName: "water sensor" }] }]) { + assert.deepEqual(payload.parse(fields, "payload"), fields); + } + assert.throws(() => payload.parse({ exitDelayInSeconds: 1.5 }, "payload"), /^SchemaError: payload\.exitDelayInSeconds: expected an integer from 0 to 255, got 1\.5$/); + assert.throws(() => payload.parse({ bypassedEndpoints: ["side window"] }, "payload"), /^SchemaError: payload\.bypassedEndpoints\[0\]: expected an object/); + assert.equal(SecurityPanelController.responseFor("Disarm"), undefined); +});