// 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; }, }; }