// What Alexa rejects at discovery, checked where a device is declared. Alexa gives no reason when it drops an // endpoint: the user hears "no new devices found". Each rule names the page it is from. import { DISPLAY_CATEGORIES, LIMITS } from "../registry/catalog.js"; import { labels, shownAs } from "../registry/resources.js"; import { SchemaError } from "../registry/schema.js"; import { DeclarationError } from "../registry/types.js"; // alexa-discovery-objects.html, "Endpoint object details": "letters, numbers, spaces, and the following special // characters: _ - = # ; : ? @ &" const ENDPOINT_ID = /^[A-Za-z0-9 _\-=#;:?@&]+$/; // Same table: "alphanumeric characters and spaces". Letters of any script: Alexa speaks Hindi and Japanese too. const FRIENDLY_NAME = /^[\p{L}\p{M}\p{N} ]+$/u; const isText = (value) => typeof value === "string" && value.length > 0; /** Throws the first rule of the Endpoint object that the fields break. */ export function checkEndpoint(endpoint) { const where = { endpointId: isText(endpoint.endpointId) ? endpoint.endpointId : undefined }; function refuse(problem) { throw new DeclarationError(where, problem); } const { endpointId, friendlyName, description, manufacturerName, displayCategories, additionalAttributes, cookie } = endpoint; if (!isText(endpointId)) refuse("an endpoint needs an endpointId"); if (endpointId.length > LIMITS.endpointIdLength || !ENDPOINT_ID.test(endpointId)) { refuse(`the endpointId takes up to ${LIMITS.endpointIdLength} letters, digits, spaces and _ - = # ; : ? @ &`); } if (!isText(friendlyName)) refuse("an endpoint needs a name"); if (friendlyName.length > LIMITS.friendlyNameLength || !FRIENDLY_NAME.test(friendlyName)) { refuse(`the name ${JSON.stringify(friendlyName)} takes up to ${LIMITS.friendlyNameLength} letters, digits and spaces, no punctuation`); } if (!isText(manufacturerName) || manufacturerName.length > LIMITS.manufacturerNameLength) { refuse(`the manufacturerName takes 1 to ${LIMITS.manufacturerNameLength} characters`); } if (!isText(description) || description.length > LIMITS.descriptionLength) { refuse(`the description takes 1 to ${LIMITS.descriptionLength} characters`); } if (!Array.isArray(displayCategories) || displayCategories.length === 0) refuse("an endpoint needs a display category"); const known = DISPLAY_CATEGORIES; for (const category of displayCategories) { if (!known.includes(category)) refuse(`${JSON.stringify(category)} is not a display category`); } for (const [name, value] of Object.entries(additionalAttributes)) { if (typeof value !== "string" || value.length > LIMITS.additionalAttributeLength) { refuse(`${name} takes up to ${LIMITS.additionalAttributeLength} characters`); } } if (cookie !== undefined) { const bytes = Buffer.byteLength(JSON.stringify(cookie) ?? ""); if (bytes > LIMITS.cookieBytes) refuse(`the cookie is ${bytes} bytes, ${LIMITS.cookieBytes} is the most`); } } /** alexa-discovery.html, "Interface limits". count includes the capabilities the library adds. */ export function checkCapabilityCount(endpointId, count) { if (count > LIMITS.capabilitiesPerEndpoint) { throw new DeclarationError({ endpointId }, `${count} capabilities, an endpoint takes ${LIMITS.capabilitiesPerEndpoint}`); } } // Two names are the same name when the app would show the same: the asset, or the text in one locale. function nameKey(name) { return name["@type"] === "asset" ? `asset ${name.value.assetId}` : `text ${name.value.locale} ${name.value.text.toLowerCase()}`; } function phrases(capability) { const { semantics } = capability.options; return (semantics?.actionMappings ?? []).flatMap((mapping) => mapping.actions); } /** * Throws the first rule that a capability breaks on its endpoint. endpoint.capabilities are the ones declared * before it. A stub is checked for being declared twice and for nothing else: the library does not know its rules. */ export function checkCapability(capability, descriptor, endpoint) { function refuse(problem) { throw new DeclarationError(capability, problem); } const { namespace, instance, friendlyNames } = capability; // generic-controllers.html, "Multiple instances": one capability of an interface, or one per instance name if (endpoint.capabilities.some((other) => other.namespace === namespace && other.instance === instance)) { refuse(instance ? "the instance is declared twice" : "the interface is declared twice"); } if (descriptor.tier === 3) return; if (descriptor.instanced) { // alexa-rangecontroller.html, "Capabilities array": instance and capabilityResources are required if (!isText(instance)) refuse("needs an instance name, like Blind.Lift"); try { labels.parse(friendlyNames, "friendlyNames"); } catch (err) { refuse(err instanceof SchemaError && err.path === "friendlyNames" ? "needs a friendly name, from text() or asset()" : err.message); } // resources-and-assets.html, "CapabilityResources": "The first friendly name in the array must be unique for // the endpoint." const first = nameKey(friendlyNames[0]); const taken = endpoint.capabilities.find((other) => other.friendlyNames.length > 0 && nameKey(other.friendlyNames[0]) === first); if (taken) { refuse(`the first friendly name, ${shownAs(friendlyNames[0])}, is the first of ${taken.namespace} ${JSON.stringify(taken.instance)} too`); } } else { if (instance) refuse("takes no instance name: an endpoint has the interface once"); if (friendlyNames.length > 0) refuse("takes no friendly names"); } // generic-controllers.html, "Semantics for user utterances": "Each semantic phrase must be unique across all // controller instances for each endpoint" const used = new Map(); for (const other of endpoint.capabilities) for (const phrase of phrases(other)) used.set(phrase, other); for (const phrase of phrases(capability)) { const owner = used.get(phrase); if (owner) { const place = owner === capability ? "twice" : `here and on ${owner.namespace} ${JSON.stringify(owner.instance)}`; refuse(`${phrase} is mapped ${place}, an endpoint maps a phrase once`); } used.set(phrase, capability); } if (descriptor.validate) descriptor.validate(capability, endpoint); }