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