Six descriptors written from their pages replace the last stubs of tiers 1 and 2. A scene answers Activate and Deactivate through ctx.respond() with ActivationStarted and DeactivationStarted, the time and the cause filled in. device.raise(descriptor, name, payload) publishes DoorbellPress and the Event of a button on <root>/event with the endpoint and a new messageId; it throws a MessageError for an interface or instance the device did not declare, an event that answers a directive, a payload that does not fit and a message over 16000 bytes. TurnOn of a device with WakeOnLANController is deferred without the warning. On the wire: a doorbell has no properties object and proactivelyReported on the capability; SimpleEventSource is version 1.0, InventoryLevelSensor and WakeOnLANController version 3 (1.5.2: 1). A scene declared without options is announced as before. Alex2MQTT has no topic yet for the WakeUp event. 24 examples of the six pages are saved as fixtures. 267 tests pass, 241 before. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
201 lines
7.9 KiB
JavaScript
201 lines
7.9 KiB
JavaScript
// Values checked at run time and typed at compile time. A descriptor states its property values, directive payloads
|
|
// and declaration options with these; parse() returns the value or throws a SchemaError that names where it went
|
|
// wrong. Written here rather than taken from a package: mqtt stays the only runtime dependency.
|
|
/** A value that does not fit its schema: path is where ("payload.targetSetpoint.scale"), problem is what. */
|
|
export class SchemaError extends Error {
|
|
constructor(path, problem) {
|
|
super(path ? `${path}: ${problem}` : problem);
|
|
this.path = path;
|
|
this.problem = problem;
|
|
this.name = "SchemaError";
|
|
}
|
|
}
|
|
// The input as an error message shows it: short, and quoted when it is text.
|
|
function shown(input) {
|
|
if (input === undefined)
|
|
return "nothing";
|
|
if (typeof input === "function")
|
|
return "a function";
|
|
const text = JSON.stringify(input) ?? String(input);
|
|
return text.length > 60 ? `${text.slice(0, 57)}...` : text;
|
|
}
|
|
/** The error of a value that is not what a schema expects. */
|
|
export function mismatch(path, expects, input) {
|
|
return new SchemaError(path, `expected ${expects}, got ${shown(input)}`);
|
|
}
|
|
/** The path of a key of the object at path. */
|
|
export const at = (path, key) => (path ? `${path}.${key}` : key);
|
|
function isRecord(input) {
|
|
return typeof input === "object" && input !== null && !Array.isArray(input);
|
|
}
|
|
function schema(expects, accepts) {
|
|
return {
|
|
expects,
|
|
parse(input, path = "") {
|
|
if (!accepts(input))
|
|
throw mismatch(path, expects, input);
|
|
return input;
|
|
},
|
|
};
|
|
}
|
|
function numberText({ min, max, gt, integer }) {
|
|
const kind = integer ? "an integer" : "a number";
|
|
if (min !== undefined && max !== undefined)
|
|
return `${kind} from ${min} to ${max}`;
|
|
if (min !== undefined)
|
|
return `${kind} of ${min} or more`;
|
|
if (max !== undefined)
|
|
return `${kind} of ${max} or less`;
|
|
if (gt !== undefined)
|
|
return `${kind} greater than ${gt}`;
|
|
return kind;
|
|
}
|
|
function number(rules = {}) {
|
|
const { min = -Infinity, max = Infinity, gt = -Infinity, integer = false } = rules;
|
|
return schema(numberText(rules), (input) => typeof input === "number" && Number.isFinite(input) && input >= min && input <= max && input > gt
|
|
&& (!integer || Number.isInteger(input)));
|
|
}
|
|
function string(rules = {}) {
|
|
const { min = 0, max = Infinity, pattern } = rules;
|
|
const length = max === Infinity ? (min > 0 ? ` of ${min} or more characters` : "") : ` of ${min} to ${max} characters`;
|
|
return schema(rules.expects ?? `a string${length}`, (input) => typeof input === "string" && input.length >= min && input.length <= max && (!pattern || pattern.test(input)));
|
|
}
|
|
function boolean() {
|
|
return schema("true or false", (input) => typeof input === "boolean");
|
|
}
|
|
function literal(value) {
|
|
return schema(JSON.stringify(value), (input) => input === value);
|
|
}
|
|
function enumeration(...values) {
|
|
return oneOf(values, values.join(" | "));
|
|
}
|
|
/** One of a list too long to print in an error message: expects says what the list is. */
|
|
function oneOf(values, expects) {
|
|
return { ...schema(expects, (input) => values.includes(input)), values };
|
|
}
|
|
function unknown() {
|
|
return schema("any value", () => true);
|
|
}
|
|
function optional(inner) {
|
|
return {
|
|
expects: inner.expects,
|
|
optional: true,
|
|
parse: (input, path = "") => (input === undefined ? undefined : inner.parse(input, path)),
|
|
};
|
|
}
|
|
/** A value that is made when none is given: the time of an event, which is now unless the caller says when. */
|
|
function defaulted(inner, make) {
|
|
return {
|
|
expects: inner.expects,
|
|
parse: (input, path = "") => (input === undefined ? make() : inner.parse(input, path)),
|
|
};
|
|
}
|
|
function nullable(inner) {
|
|
const expects = `${inner.expects} or null`;
|
|
return {
|
|
expects,
|
|
parse(input, path = "") {
|
|
if (input === null)
|
|
return null;
|
|
try {
|
|
return inner.parse(input, path);
|
|
}
|
|
catch (err) {
|
|
// The value itself is of the wrong kind: null was a choice too. An error further in keeps its own path.
|
|
if (err instanceof SchemaError && err.path === path)
|
|
throw mismatch(path, expects, input);
|
|
throw err;
|
|
}
|
|
},
|
|
};
|
|
}
|
|
function array(item, rules = {}) {
|
|
const { min = 0, max = Infinity } = rules;
|
|
const count = max === Infinity ? (min > 0 ? ` with ${min} or more entries` : "") : ` with ${min} to ${max} entries`;
|
|
const expects = `a list${count}`;
|
|
return {
|
|
expects,
|
|
parse(input, path = "") {
|
|
if (!Array.isArray(input) || input.length < min || input.length > max)
|
|
throw mismatch(path, expects, input);
|
|
return input.map((entry, i) => item.parse(entry, `${path}[${i}]`));
|
|
},
|
|
};
|
|
}
|
|
/**
|
|
* An object with the keys of shape. Keys the shape does not name are kept as they are: a field Alexa adds to a
|
|
* directive reaches the handler. unknownKeys "reject" is for what a developer writes, where such a key is a typo.
|
|
*/
|
|
function object(shape, rules = {}) {
|
|
const known = Object.keys(shape);
|
|
const expects = known.length > 0 ? `an object with ${known.join(", ")}` : "an object";
|
|
return {
|
|
expects,
|
|
parse(input, path = "") {
|
|
if (!isRecord(input))
|
|
throw mismatch(path, expects, input);
|
|
const others = Object.keys(input).filter((key) => !known.includes(key));
|
|
if (others.length > 0 && rules.unknownKeys === "reject") {
|
|
throw new SchemaError(at(path, others[0]), `unknown key, the known ones are ${known.join(", ") || "none"}`);
|
|
}
|
|
const parsed = {};
|
|
for (const key of known) {
|
|
const value = shape[key].parse(input[key], at(path, key));
|
|
if (value !== undefined)
|
|
parsed[key] = value;
|
|
}
|
|
for (const key of others)
|
|
parsed[key] = input[key];
|
|
return parsed;
|
|
},
|
|
};
|
|
}
|
|
// alexa-property-schemas.html "Temperature", "Temperature scales"
|
|
function temperature() {
|
|
return object({ value: number(), scale: enumeration("CELSIUS", "FAHRENHEIT", "KELVIN") });
|
|
}
|
|
// alexa-property-schemas.html "DateTime": UTC, no offsets. The seconds are optional here because the TimeInterval
|
|
// examples on the same page leave them out ("2017-10-04T14:00Z").
|
|
const DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?Z$/;
|
|
function dateTime() {
|
|
return schema("a UTC time like 2017-08-30T01:18:21Z", (input) => typeof input === "string" && DATE_TIME.test(input) && !Number.isNaN(Date.parse(input)));
|
|
}
|
|
// alexa-property-schemas.html "Duration": the time portion of ISO 8601, negative for a delta ("PT-30S")
|
|
const DURATION = /^PT(?=.)(-?\d+H)?(-?\d+M)?(-?\d+S)?$/;
|
|
function duration() {
|
|
return string({ pattern: DURATION, expects: "a duration like PT3M15S" });
|
|
}
|
|
// alexa-property-schemas.html "TimeInterval": "Specify one or two of the time interval fields. If you specify all
|
|
// three fields, an error occurs."
|
|
function timeInterval() {
|
|
const fields = object({ start: optional(dateTime()), end: optional(dateTime()), duration: optional(duration()) });
|
|
const expects = "a time interval with one or two of start, end, duration";
|
|
return {
|
|
expects,
|
|
parse(input, path = "") {
|
|
const interval = fields.parse(input, path);
|
|
const given = [interval.start, interval.end, interval.duration].filter((field) => field !== undefined).length;
|
|
if (given < 1 || given > 2)
|
|
throw mismatch(path, expects, input);
|
|
return interval;
|
|
},
|
|
};
|
|
}
|
|
export const s = {
|
|
string,
|
|
number,
|
|
boolean,
|
|
literal,
|
|
enum: enumeration,
|
|
oneOf,
|
|
unknown,
|
|
optional,
|
|
defaulted,
|
|
nullable,
|
|
array,
|
|
object,
|
|
temperature,
|
|
dateTime,
|
|
duration,
|
|
timeInterval,
|
|
};
|