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>
106 lines
5.5 KiB
JavaScript
106 lines
5.5 KiB
JavaScript
// 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;
|
|
},
|
|
};
|
|
}
|