A capability is a descriptor plus what the endpoint declares (device/Capability.ts), and its discovery object is
generated from the two. The new API is bridge.addDevice({ endpointId, name, categories, ... }) and
device.add(PowerController, options): both throw a DeclarationError that names the endpoint, the interface and
the instance (device/validate.ts), and leave the bridge and the device as they were. AlexaInterface is the same
Capability with the 1.x methods on it; it, ActionMapping and the enums moved to src/compat/, Device to src/device/.
What a 1.x caller can observe:
- every endpoint ends with { type: "AlexaInterface", interface: "Alexa", version: "3" } (alexa-interface.html);
new Alex2MQTT(..., { alexaInterface: false }) leaves it out
- the fields of a capability object come in the order of Amazon's examples; their content is unchanged
- addCapability() with a name that is not an interface throws (1.5.2 announced it with the version "UNKNOWN")
- ActionMapping takes the payload as an object; a JSON string is parsed (1.5.2 sent the string), any other throws
- what Alexa would reject in a 1.x declaration is not refused: device.check() lists it and the bridge logs each
line once, as "warning: ..." through the log hook, when it answers a discovery
- a device whose JSON cannot be built is left out of the answer and reported as an error event
- PowerController and EndpointHealth are the descriptors and keep ON/OFF and OK/UNREACHABLE; PowerState is new
Tests: six zoo devices declared the 1.x way give the JSON that Alexa accepted from 1.5.2 on 2026-09-28, plus the
Alexa capability. npm test: 85 pass (was 57) in 10-12 s, also on Node 18.20.8 and 20.20.2.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
193 lines
7.6 KiB
JavaScript
193 lines
7.6 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)),
|
|
};
|
|
}
|
|
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,
|
|
nullable,
|
|
array,
|
|
object,
|
|
temperature,
|
|
dateTime,
|
|
duration,
|
|
timeInterval,
|
|
};
|