registry: RangeController, ModeController, ToggleController with resources, presets and semantics

The three generic controllers are described in full and leave the stub table (62 stubs remain). A declaration
takes an instance name, friendly names from text() and asset(), and the options of the interface: range, unit and
presets for a range; supportedModes and ordered for a mode; semantics() for all three, with actions mapped to
directives and states mapped to a value or a range of values.

Refused at device.add(), each with the endpoint, interface and instance in the message: no instance name or no
friendly name; an instance declared twice; a first friendly name or a phrase used by another capability of the
endpoint; a reserved word, an asset id that is not in the catalog; range.max not above range.min; a preset off the
precision grid or outside the range; fewer than two modes or a mode listed twice; a mapping to a directive the
interface does not have, to a payload that does not fit, to a mode that is not listed, to AdjustMode on modes
that are not ordered; SetEcoOn, SetEcoOff, EcoOn and EcoOff on anything but a toggle.

For 1.x callers: Alexa.RangeController lists rangeValue (1.5.2 sent supported: []); addSupportedModes() announces
a mode given as a string as { value } and notes it, and takes { ordered: false } as a second argument, the default
stays true as 1.5.2 sent it (device.add() defaults to false). Declared the 1.x way, the zoo fan and curtain give
the same JSON as before.

Tests: the zoo blind, whose range, unit and semantics were written by hand into the JSON of 1.5.2, is declared
with device.add() and equals that JSON plus the Alexa capability; five capability objects of Amazon's examples
are reproduced the same way. 27 examples of four pages added. npm test: 108 pass (was 85) in 10.5-11.3 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:
David 2026-09-28 15:50:25 +00:00
parent c492ef74d1
commit 2558e21787
80 changed files with 3131 additions and 107 deletions

View file

@ -5,7 +5,7 @@ const { test } = require("node:test");
const assert = require("node:assert/strict");
const {
Alex2MQTT, ActionMapping, Alexa, AlexaActions, AlexaInterfaceType, BrightnessController, DisplayCategory, EndpointHealth,
PowerController, TemperatureSensor, registry,
ModeController, PowerController, RangeController, TemperatureSensor, ToggleController, asset, registry, semantics, text,
} = require("alex2node");
const interfaces = require("../fixtures/interfaces.js");
const { endpoint } = require("../helpers/endpoint.js");
@ -23,13 +23,26 @@ async function discover(alexa, root) {
return alexa.on(`${root}/discover_r`)[before];
}
for (const { namespace, page, declared } of interfaces) {
test(`${namespace}: device.add() gives the capability object of ${page}.html`, () => {
for (const { options, as: [example, n = 0] } of declared) {
const capability = endpoint().add(registry.get(namespace), options);
assert.deepEqual(capability.toJSON(), capabilities(page, namespace, example)[n], `${example}[${n}]`);
}
});
// A device with the fields of a zoo endpoint and no capabilities.
function likeTheZoo(endpointId) {
const accepted = zoo(endpointId);
const device = endpoint(endpointId, accepted.friendlyName, accepted.displayCategories);
device.setDescription(accepted.description);
device.setManufacturerName(accepted.manufacturerName);
device.setManufacturer(accepted.additionalAttributes.manufacturer);
device.setModel(accepted.additionalAttributes.model);
return device;
}
for (const { namespace, page: own, declared } of interfaces) {
for (const { options, example, page = own, n = 0 } of declared) {
test(`${namespace}: device.add() gives the capability object of ${page}.html, ${example}${n > 0 ? ` (${n})` : ""}`, () => {
const device = endpoint();
const capability = device.add(registry.get(namespace), options);
assert.deepEqual(capability.toJSON(), capabilities(page, namespace, example)[n]);
assert.deepEqual(device.check(), []);
});
}
}
test("the fields of a capability object are in the order of the examples: type, interface, version, properties", () => {
@ -111,17 +124,115 @@ test("1.x declarations are announced as 1.5.2 announced them, with the Alexa int
};
for (const [endpointId, declare] of Object.entries(declarations)) {
const accepted = zoo(endpointId);
const device = endpoint(endpointId, accepted.friendlyName, accepted.displayCategories);
device.setDescription(accepted.description);
device.setManufacturerName(accepted.manufacturerName);
device.setManufacturer(accepted.additionalAttributes.manufacturer);
device.setModel(accepted.additionalAttributes.model);
const device = likeTheZoo(endpointId);
declare(device);
assert.deepEqual(device.getJSON(), { ...accepted, capabilities: [...accepted.capabilities, ALEXA] }, endpointId);
assert.deepEqual(device.check(), [], endpointId);
}
});
test("a RangeController blind declared with device.add() is the zoo's blind, whose JSON was written by hand", () => {
const blinds = likeTheZoo("zoo-blinds");
blinds.add(RangeController, {
instance: "Blind.Lift",
friendlyNames: [asset("Alexa.Setting.Opening")],
range: { min: 0, max: 100, precision: 1 },
unit: "Alexa.Unit.Percent",
semantics: semantics()
.action("Close", "SetRangeValue", { rangeValue: 0 })
.action("Open", "SetRangeValue", { rangeValue: 100 })
.action("Lower", "AdjustRangeValue", { rangeValueDelta: -10, rangeValueDeltaDefault: false })
.action("Raise", "AdjustRangeValue", { rangeValueDelta: 10, rangeValueDeltaDefault: false })
.state("Closed", 0)
.stateRange("Open", 1, 100),
retrievable: true,
proactivelyReported: true,
});
const accepted = zoo("zoo-blinds");
assert.deepEqual(blinds.getJSON(), { ...accepted, capabilities: [...accepted.capabilities, ALEXA] });
assert.deepEqual(blinds.check(), []);
});
test("the zoo's fan and curtain declared with device.add(): a ModeController and a ToggleController with semantics", () => {
const fan = likeTheZoo("zoo-fan");
fan.add(PowerController, { proactivelyReported: true });
fan.add(ModeController, {
instance: "Fan.Speed",
friendlyNames: [text("Speed")],
supportedModes: ["Low", "Medium", "High"].map((name) => ({ value: `Speed.${name}`, friendlyNames: [text(name)] })),
ordered: true,
proactivelyReported: true,
});
assert.deepEqual(fan.getJSON().capabilities, [...zoo("zoo-fan").capabilities, ALEXA]);
const curtain = likeTheZoo("zoo-curtain");
curtain.add(PowerController, { proactivelyReported: true });
curtain.add(ToggleController, {
instance: "Zoo.Shade",
friendlyNames: [text("Shade")],
semantics: semantics().action("Close", "TurnOn").action("Open", "TurnOff"),
proactivelyReported: true,
});
assert.deepEqual(curtain.getJSON().capabilities, [...zoo("zoo-curtain").capabilities, ALEXA]);
});
test("ModeController: the modes are not ordered unless the declaration says so; a mode is declared in either form", () => {
const modes = endpoint("washer-1", "Washer", ["WASHER"]).add(ModeController, {
instance: "Washer.WashCycle",
friendlyNames: [asset("Alexa.Setting.WashCycle")],
supportedModes: [
{ value: "WashCycle.Normal", friendlyNames: [text("Normal"), text("Cottons")] },
{ value: "WashCycle.Delicates", modeResources: { friendlyNames: [asset("Alexa.Value.Delicate"), text("Knits")] } },
],
});
assert.deepEqual(modes.toJSON().configuration, {
ordered: false,
supportedModes: [
{ value: "WashCycle.Normal", modeResources: { friendlyNames: [text("Normal"), text("Cottons")] } },
{ value: "WashCycle.Delicates", modeResources: { friendlyNames: [asset("Alexa.Value.Delicate"), text("Knits")] } },
],
});
});
test("1.x addSupportedModes: ordered as 1.x announced it, or as the second argument says; a string becomes { value }", () => {
const fan = endpoint("fan-1", "Fan", ["FAN"]);
const named = (value) => ({ value, modeResources: { friendlyNames: [{ "@type": "text", value: { text: value, locale: "en-US" } }] } });
const speed = fan.addCapability(AlexaInterfaceType.MODE_CONTROLLER, { instance: "Fan.Speed" });
speed.addFriendlyName("Speed", "en-US");
speed.addSupportedModes([named("Low"), named("High")]);
assert.equal(speed.getJSON().configuration.ordered, true);
const source = fan.addCapability(AlexaInterfaceType.MODE_CONTROLLER, { instance: "Fan.Mode" });
source.addFriendlyName("Mode", "en-US");
source.addSupportedModes([named("Auto"), named("On")], { ordered: false });
assert.deepEqual(source.getJSON().configuration, { ordered: false, supportedModes: [named("Auto"), named("On")] });
assert.deepEqual(fan.check(), []);
const plain = fan.addCapability(AlexaInterfaceType.MODE_CONTROLLER, { instance: "Fan.Timer" });
plain.addFriendlyName("Countdown", "en-US");
plain.addSupportedModes(["Short", named("Long")]);
assert.deepEqual(plain.getJSON().configuration.supportedModes, [{ value: "Short" }, named("Long")]);
assert.deepEqual(fan.check(), [
'fan-1: Alexa.ModeController "Fan.Timer": supportedModes[0].friendlyNames: expected a list with 1 or more entries, got []',
'fan-1: Alexa.ModeController "Fan.Timer": the mode Short was given as a string, pass { value, modeResources }',
]);
});
test("a 1.x RangeController has no way to give its range: announced as 1.5.2 did, with its property now, and check() says it", () => {
const blinds = endpoint("blinds-1", "Blinds", ["INTERIOR_BLIND"]);
const lift = blinds.addCapability(AlexaInterfaceType.RANGE_CONTROLLER, { instance: "Blind.Lift", proactivelyReported: true });
lift.addFriendlyName("Lift", "en-US");
assert.deepEqual(lift.getJSON(), {
type: "AlexaInterface",
interface: "Alexa.RangeController",
instance: "Blind.Lift",
version: "3",
properties: { supported: [{ name: "rangeValue" }], proactivelyReported: true, retrievable: true },
capabilityResources: { friendlyNames: [text("Lift")] },
});
assert.deepEqual(blinds.check(), ['blinds-1: Alexa.RangeController "Blind.Lift": range: expected an object with min, max, precision, got nothing']);
});
test("registerDevice: every endpoint ends with the Alexa interface; alexaInterface: false leaves it out", async () => {
const { alexa, bridge } = await setup(Alex2MQTT, "root");
const lamp = bridge.registerDevice("Lamp", "lamp-1", DisplayCategory.LIGHT);