A capability is a descriptor plus what the endpoint declares (device/Capability.ts), and its discovery object is
generated from the two. The new API is bridge.addDevice({ endpointId, name, categories, ... }) and
device.add(PowerController, options): both throw a DeclarationError that names the endpoint, the interface and
the instance (device/validate.ts), and leave the bridge and the device as they were. AlexaInterface is the same
Capability with the 1.x methods on it; it, ActionMapping and the enums moved to src/compat/, Device to src/device/.
What a 1.x caller can observe:
- every endpoint ends with { type: "AlexaInterface", interface: "Alexa", version: "3" } (alexa-interface.html);
new Alex2MQTT(..., { alexaInterface: false }) leaves it out
- the fields of a capability object come in the order of Amazon's examples; their content is unchanged
- addCapability() with a name that is not an interface throws (1.5.2 announced it with the version "UNKNOWN")
- ActionMapping takes the payload as an object; a JSON string is parsed (1.5.2 sent the string), any other throws
- what Alexa would reject in a 1.x declaration is not refused: device.check() lists it and the bridge logs each
line once, as "warning: ..." through the log hook, when it answers a discovery
- a device whose JSON cannot be built is left out of the answer and reported as an error event
- PowerController and EndpointHealth are the descriptors and keep ON/OFF and OK/UNREACHABLE; PowerState is new
Tests: six zoo devices declared the 1.x way give the JSON that Alexa accepted from 1.5.2 on 2026-09-28, plus the
Alexa capability. npm test: 85 pass (was 57) in 10-12 s, also on Node 18.20.8 and 20.20.2.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
108 lines
6.1 KiB
JavaScript
108 lines
6.1 KiB
JavaScript
"use strict";
|
|
// The 1.x way of declaring capabilities, on the classes 2.0 generates discovery from.
|
|
const { test } = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const alex2node = require("alex2node");
|
|
const {
|
|
ActionMapping, AlexaActions, AlexaInterface, AlexaInterfaceType, Capability, DeclarationError, EndpointHealth, PowerController,
|
|
PowerState, registry,
|
|
} = alex2node;
|
|
const { endpoint } = require("../helpers/endpoint.js");
|
|
|
|
test("every export of 1.5.2 is there", () => {
|
|
const exported = [
|
|
"ActionMapping", "Alex2MQTT", "AlexaActions", "AlexaErrorResponse", "AlexaErrorType", "AlexaInterface", "AlexaInterfaceType",
|
|
"AlexaStatusMessage", "DEFAULT_HOST", "Device", "DisplayCategory", "EndpointHealth", "PowerController",
|
|
"TemperatureSensorScale", "ThermostatMode",
|
|
];
|
|
assert.deepEqual(exported.filter((name) => alex2node[name] === undefined), []);
|
|
});
|
|
|
|
test("PowerController and EndpointHealth are the enums of 1.x and the descriptors of 2.0", () => {
|
|
assert.deepEqual([PowerController.ON, PowerController.OFF], ["ON", "OFF"]);
|
|
assert.deepEqual([EndpointHealth.OK, EndpointHealth.UNREACHABLE], ["OK", "UNREACHABLE"]);
|
|
assert.equal(PowerController, registry.get("Alexa.PowerController"));
|
|
assert.equal(EndpointHealth, registry.get(AlexaInterfaceType.ENDPOINT_HEALTH));
|
|
assert.deepEqual(PowerState, { ON: "ON", OFF: "OFF" });
|
|
});
|
|
|
|
test("AlexaInterface: the constructor and the getters of 1.x", () => {
|
|
const capability = new AlexaInterface(AlexaInterfaceType.THERMOSTAT_CONTROLLER, false, true);
|
|
assert.ok(capability instanceof Capability);
|
|
assert.equal(capability.type, "Alexa.ThermostatController");
|
|
assert.equal(capability.getType(), AlexaInterfaceType.THERMOSTAT_CONTROLLER);
|
|
assert.equal(capability.getTypeString(), "Alexa.ThermostatController");
|
|
assert.equal(capability.getVersion(), "3.2");
|
|
assert.deepEqual(capability.getProps(), ["targetSetpoint", "lowerSetpoint", "upperSetpoint", "thermostatMode"]);
|
|
assert.deepEqual([capability.retrievable, capability.proactivelyReported, capability.instance], [false, true, ""]);
|
|
assert.deepEqual(capability.getJSON(), capability.toJSON());
|
|
|
|
const toggle = new AlexaInterface("Alexa.ToggleController", true, false, "Fan.Oscillate");
|
|
toggle.setInstance("Fan.Swing");
|
|
toggle.addFriendlyName("Swing", "en-GB");
|
|
assert.equal(toggle.getJSON().instance, "Fan.Swing");
|
|
assert.deepEqual(toggle.getJSON().capabilityResources, { friendlyNames: [{ "@type": "text", value: { text: "Swing", locale: "en-GB" } }] });
|
|
});
|
|
|
|
test("addCapability takes the enum member or the namespace, and throws for a name that is not an interface", () => {
|
|
const lamp = endpoint();
|
|
const byMember = lamp.addCapability(AlexaInterfaceType.POWER_CONTROLLER, { proactivelyReported: true });
|
|
const byName = lamp.addCapability("Alexa.BrightnessController");
|
|
assert.ok(byMember instanceof AlexaInterface);
|
|
assert.equal(byMember.descriptor, PowerController);
|
|
assert.deepEqual(lamp.getCapabilities(), [byMember, byName]);
|
|
|
|
for (const name of ["Alexa.PowerControler", AlexaInterfaceType.UNKNOWN, undefined]) {
|
|
assert.throws(
|
|
() => lamp.addCapability(name),
|
|
(err) => err instanceof DeclarationError && err.message === `lamp-1: ${JSON.stringify(name)} is not an interface alex2node knows`
|
|
);
|
|
}
|
|
assert.equal(lamp.getCapabilities().length, 2);
|
|
assert.throws(() => new AlexaInterface("Alexa.PowerControler"), DeclarationError);
|
|
});
|
|
|
|
test("getJSON replaced on a capability, as 1.x callers did to correct it, is what discovery lists", () => {
|
|
const lock = endpoint("lock-1", "Front Door", ["SMARTLOCK"]);
|
|
const capability = lock.addCapability(AlexaInterfaceType.LOCK_CONTROLLER);
|
|
const original = capability.getJSON.bind(capability);
|
|
capability.getJSON = () => ({ ...original(), properties: { ...original().properties, supported: [{ name: "lockState" }] } });
|
|
assert.deepEqual(lock.getJSON().capabilities[0].properties.supported, [{ name: "lockState" }]);
|
|
});
|
|
|
|
test("ActionMapping: the payload is an object and reaches discovery as one", () => {
|
|
const open = new ActionMapping([AlexaActions.Open, AlexaActions.Raise], "SetRangeValue", { rangeValue: 100 });
|
|
assert.deepEqual(open.toJSON(), {
|
|
"@type": "ActionsToDirective",
|
|
actions: ["Alexa.Actions.Open", "Alexa.Actions.Raise"],
|
|
directive: { name: "SetRangeValue", payload: { rangeValue: 100 } },
|
|
});
|
|
assert.equal(open.deprecation, undefined);
|
|
// An empty payload is kept: the ToggleController examples of Amazon have "payload": {}
|
|
assert.deepEqual(new ActionMapping([AlexaActions.Close], "TurnOff", {}).toJSON().directive, { name: "TurnOff", payload: {} });
|
|
// None at all, and the "" that 1.x took for none
|
|
assert.deepEqual(new ActionMapping([AlexaActions.Close], "TurnOff").toJSON().directive, { name: "TurnOff" });
|
|
assert.deepEqual(new ActionMapping([AlexaActions.Close], "TurnOff", "").toJSON().directive, { name: "TurnOff" });
|
|
});
|
|
|
|
test("ActionMapping: a JSON string is parsed and noted as deprecated, any other string throws", () => {
|
|
const blinds = endpoint("blinds-1", "Blinds", ["INTERIOR_BLIND"]);
|
|
const lift = blinds.addCapability(AlexaInterfaceType.RANGE_CONTROLLER, { instance: "Blind.Lift" });
|
|
lift.addActionMapping(new ActionMapping([AlexaActions.Close], "SetRangeValue", JSON.stringify({ rangeValue: 0 })));
|
|
lift.addActionMapping(new ActionMapping([AlexaActions.Open], "SetRangeValue", { rangeValue: 100 }));
|
|
|
|
// 1.5.2 put the string itself into discovery: "payload": "{\"rangeValue\":0}"
|
|
assert.deepEqual(lift.getJSON().semantics.actionMappings.map((mapping) => mapping.directive.payload), [{ rangeValue: 0 }, { rangeValue: 100 }]);
|
|
assert.deepEqual(
|
|
blinds.check().filter((line) => line.includes("JSON string")),
|
|
['blinds-1: Alexa.RangeController "Blind.Lift": the payload of the action mapping for SetRangeValue is a JSON string, pass the object']
|
|
);
|
|
|
|
for (const text of ["rangeValue: 0", "0", "[0]", "null", '"closed"']) {
|
|
assert.throws(
|
|
() => new ActionMapping([AlexaActions.Close], "SetRangeValue", text),
|
|
(err) => err instanceof DeclarationError
|
|
&& err.message === `the payload of the action mapping for SetRangeValue is an object, got the string ${JSON.stringify(text)}`
|
|
);
|
|
}
|
|
});
|