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 <noreply@anthropic.com>
This commit is contained in:
David 2026-09-28 20:50:57 +00:00
parent f555a5df70
commit c59cf3ff10
39 changed files with 1248 additions and 23 deletions

View file

@ -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",
}]]);
});

View file

@ -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."
}
}
}

View file

@ -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"
}
]
}
}
}

View file

@ -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."
}
}
}

View file

@ -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."
}
}
}

View file

@ -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
}
]
}
}

View file

@ -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"
}
}
}

View file

@ -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
}
]
}
}

View file

@ -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"
}
}
}

View file

@ -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
}
]
}
}

View file

@ -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
}
]
}
}

View file

@ -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
}
]
}
}

View file

@ -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
}
]
}
}

View file

@ -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"
}
}
}
}

View file

@ -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
}
]
}
}

View file

@ -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
}
]
}
}

View file

@ -0,0 +1,6 @@
{
"name": "burglaryAlarm",
"value": {
"value": "OK"
}
}

View file

@ -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"
}
]
}
]
}
}
}

View file

@ -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",

View file

@ -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"],
};

View file

@ -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);
});