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

106
dist/esm/registry/semantics.js vendored Normal file
View file

@ -0,0 +1,106 @@
// Semantics of a generic controller: the phrases "open", "close", "raise", "lower" mapped to directives, and states
// like "open" and "closed" mapped to values of the property (alexa-discovery-objects.html, "Semantics object").
import { ACTIONS, STATES } from "./catalog.js";
import { at, mismatch, s, SchemaError } from "./schema.js";
const listed = (given) => (Array.isArray(given) ? [...given] : [given]);
const withPrefix = (prefix, name) => (name.startsWith(prefix) ? name : `${prefix}${name}`);
/**
* Collects the mappings of one capability:
*
* semantics()
* .action("Close", "SetRangeValue", { rangeValue: 0 })
* .action(["Open", "Raise"], "SetRangeValue", { rangeValue: 100 })
* .state("Closed", 0)
* .stateRange("Open", 1, 100)
*
* What is collected is checked when the capability is declared, against the directives and the property of its
* interface.
*/
export class SemanticsBuilder {
constructor() {
this.actionMappings = [];
this.stateMappings = [];
}
/** The phrases send the directive. payload is its payload; leave it out for a directive that has none. */
action(actions, directive, payload) {
this.actionMappings.push({
"@type": "ActionsToDirective",
actions: listed(actions).map((name) => withPrefix("Alexa.Actions.", name)),
directive: payload === undefined ? { name: directive } : { name: directive, payload },
});
return this;
}
/** The property at this value is in these states: "is the garage door open?" */
state(states, value) {
this.stateMappings.push({ "@type": "StatesToValue", states: listed(states).map((name) => withPrefix("Alexa.States.", name)), value });
return this;
}
/** The property between the two values, both included, is in these states. For an interface with a numeric property. */
stateRange(states, minimumValue, maximumValue) {
this.stateMappings.push({
"@type": "StatesToRange",
states: listed(states).map((name) => withPrefix("Alexa.States.", name)),
range: { minimumValue, maximumValue },
});
return this;
}
}
export function semantics() {
return new SemanticsBuilder();
}
const isHunch = (id) => /\.(SetEcoOn|SetEcoOff|EcoOn|EcoOff)$/.test(id);
/** The schema of the semantics option of one interface. It takes a semantics() builder or the object as discovery has it. */
export function semanticsOf({ directives, stateValue, ranges = false, hunches = false }) {
const allowed = (ids, what) => {
const choice = ids.filter((id) => hunches || !isHunch(id));
return s.array(s.oneOf(choice, `${what}: ${choice.map((id) => id.split(".")[2]).join(", ")}`), { min: 1 });
};
const names = Object.keys(directives);
const lists = s.object({ actionMappings: s.optional(s.array(s.unknown())), stateMappings: s.optional(s.array(s.unknown())) });
const action = s.object({
"@type": s.literal("ActionsToDirective"),
actions: allowed(ACTIONS, "a phrase of Alexa.Actions"),
directive: s.object({ name: s.oneOf(names, `a directive of the interface: ${names.join(", ")}`) }),
});
const state = s.object({
"@type": ranges ? s.enum("StatesToValue", "StatesToRange") : s.enum("StatesToValue"),
states: allowed(STATES, "a state of Alexa.States"),
});
const range = s.object({ minimumValue: s.number(), maximumValue: s.number() }, { unknownKeys: "reject" });
function actionMapping(input, path) {
const mapping = action.parse(input, path);
const { name, payload } = mapping.directive;
// Checked as the directive will arrive; a mapping without a payload stays without one in discovery
const checked = directives[name].payload.parse(payload ?? {}, at(path, "directive.payload"));
return { "@type": mapping["@type"], actions: mapping.actions, directive: payload === undefined ? { name } : { name, payload: checked } };
}
function stateMapping(input, path) {
const mapping = state.parse(input, path);
if (mapping["@type"] === "StatesToValue") {
return { "@type": mapping["@type"], states: mapping.states, value: stateValue.parse(mapping.value, at(path, "value")) };
}
const { minimumValue, maximumValue } = range.parse(mapping.range, at(path, "range"));
if (minimumValue > maximumValue)
throw new SchemaError(at(path, "range"), `the minimumValue ${minimumValue} is above the maximumValue ${maximumValue}`);
return { "@type": mapping["@type"], states: mapping.states, range: { minimumValue, maximumValue } };
}
const expects = "semantics() with at least one action or state";
return {
expects,
parse(input, path = "") {
if (typeof input !== "object" || input === null)
throw mismatch(path, expects, input);
const { actionMappings = [], stateMappings = [] } = lists.parse(input, path);
if (actionMappings.length + stateMappings.length === 0)
throw mismatch(path, expects, {});
const parsed = {};
if (actionMappings.length > 0) {
parsed.actionMappings = actionMappings.map((mapping, i) => actionMapping(mapping, `${at(path, "actionMappings")}[${i}]`));
}
if (stateMappings.length > 0) {
parsed.stateMappings = stateMappings.map((mapping, i) => stateMapping(mapping, `${at(path, "stateMappings")}[${i}]`));
}
return parsed;
},
};
}