registry: describe an Alexa interface as data

src/registry/ holds what the library knows about an interface: namespace, version, the page it was read from,
properties with their value schemas and directives with their payload schemas. Five interfaces are described
(Alexa, PowerController, BrightnessController, TemperatureSensor, EndpointHealth); the other 65 names of
AlexaInterfaceType are stubs with the version and property names of 1.5.2. schema.ts is the run-time check behind
it (241 lines, no new dependency), catalog.ts the vocabularies of the pages: 103 assets (23 units), 6 actions,
9 states, 56 display categories, 22 reserved words, 73 error types under 11 namespaces.

AlexaInterface.getVersion() and getProps() read the registry; the two switch statements are gone (-167 lines).
On the wire: Alexa.EndpointHealth is announced at 3.1 (was 3.3; the page is titled 3.1 and no page mentions 3.3),
and TimeHoldController and Camera.LiveViewController at 3 and 1.7 (1.5.2 sent the string "UNKNOWN").
DisplayCategory gains VACUUM. New exports: registry, DeclarationError, SchemaError, Assets, Units, Actions,
States, DisplayCategories and the descriptor types.

Tests: 20 JSON examples of the five pages under test/fixtures/alexa-docs; every directive payload and property
value in them parses with its descriptor. npm test: 57 pass (was 30) in 10.8 s, also on Node 18.20.8 and 20.20.2.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
David 2026-09-28 15:18:22 +00:00
parent 128ca35c2a
commit aa0ffd64ea
93 changed files with 4058 additions and 490 deletions

View file

@ -1,4 +1,5 @@
import type { ActionMapping } from "./ActionMapping.js";
import { registry } from "./registry/index.js";
export enum AlexaInterfaceType {
APPLICATION_STATE_REPORTER = "Alexa.ApplicationStateReporter",
@ -119,173 +120,14 @@ export class AlexaInterface {
return this.type;
}
/** The version of the interface, from its descriptor. "UNKNOWN" for a name the registry does not have. */
getVersion(): string {
switch (this.type) {
case AlexaInterfaceType.APPLICATION_STATE_REPORTER:
case AlexaInterfaceType.AUDIO_PLAY_QUEUE:
case AlexaInterfaceType.AUTHORIZATION_CONTROLLER:
case AlexaInterfaceType.AUTOMATION_MANAGEMENT:
case AlexaInterfaceType.AUTOMOTIVE_VEHICLE_DATA:
case AlexaInterfaceType.COMMISSIONABLE:
case AlexaInterfaceType.CONSENT_MANAGEMENT_CONSENT_REQUIRED_REPORTER:
case AlexaInterfaceType.COOKING:
case AlexaInterfaceType.COOKING_FOOD_TEMPERATURE_CONTROLLER:
case AlexaInterfaceType.COOKING_FOOD_TEMPERATURE_SENSOR:
case AlexaInterfaceType.COOKING_PRESET_CONTROLLER:
case AlexaInterfaceType.COOKING_TEMPERATURE_CONTROLLER:
case AlexaInterfaceType.COOKING_TEMPERATURE_SENSOR:
case AlexaInterfaceType.COOKING_TIME_CONTROLLER:
case AlexaInterfaceType.DATA_CONTROLLER:
case AlexaInterfaceType.DEVICE_USAGE_ESTIMATION:
case AlexaInterfaceType.DEVICE_USAGE_METER:
case AlexaInterfaceType.EQUALIZER_CONTROLLER:
case AlexaInterfaceType.INVENTORY_LEVEL_SENSOR:
case AlexaInterfaceType.INVENTORY_LEVEL_USAGE_SENSOR:
case AlexaInterfaceType.INVENTORY_USAGE_SENSOR:
case AlexaInterfaceType.KEYPAD_CONTROLLER:
case AlexaInterfaceType.MEDIA_PLAYBACK:
case AlexaInterfaceType.MEDIA_PLAY_QUEUE:
case AlexaInterfaceType.MEDIA_SEARCH:
case AlexaInterfaceType.PLAYBACK_STATE_REPORTER:
case AlexaInterfaceType.PROACTIVE_NOTIFICATION_SOURCE:
case AlexaInterfaceType.REMOTE_VIDEO_PLAYER:
case AlexaInterfaceType.RTC_SESSION_CONTROLLER:
case AlexaInterfaceType.SECURITY_PANEL_CONTROLLER:
case AlexaInterfaceType.SECURITY_PANEL_CONTROLLER_ALERT:
case AlexaInterfaceType.SIMPLE_EVENT_SOURCE:
case AlexaInterfaceType.SMART_VISION_OBJECT_DETECTION_SENSOR:
case AlexaInterfaceType.SMART_VISION_SNAPSHOT_PROVIDER:
case AlexaInterfaceType.SPEAKER:
case AlexaInterfaceType.STEP_SPEAKER:
case AlexaInterfaceType.THERMOSTAT_CONTROLLER_CONFIGURATION:
case AlexaInterfaceType.THERMOSTAT_CONTROLLER_HVAC_COMPONENTS:
case AlexaInterfaceType.THERMOSTAT_CONTROLLER_SCHEDULE:
case AlexaInterfaceType.UI_CONTROLLER:
case AlexaInterfaceType.USER_PREFERENCE:
case AlexaInterfaceType.VIDEO_RECORDER:
case AlexaInterfaceType.WAKE_ON_LAN_CONTROLLER:
return "1";
case AlexaInterfaceType.BRIGHTNESS_CONTROLLER:
case AlexaInterfaceType.CAMERA_STREAM_CONTROLLER:
case AlexaInterfaceType.CHANNEL_CONTROLLER:
case AlexaInterfaceType.COLOR_CONTROLLER:
case AlexaInterfaceType.COLOR_TEMPERATURE_CONTROLLER:
case AlexaInterfaceType.CONTACT_SENSOR:
case AlexaInterfaceType.DOORBELL_EVENT_SOURCE:
case AlexaInterfaceType.INPUT_CONTROLLER:
case AlexaInterfaceType.LOCK_CONTROLLER:
case AlexaInterfaceType.MODE_CONTROLLER:
case AlexaInterfaceType.MOTION_SENSOR:
case AlexaInterfaceType.PERCENTAGE_CONTROLLER:
case AlexaInterfaceType.PLAYBACK_CONTROLLER:
case AlexaInterfaceType.POWER_CONTROLLER:
case AlexaInterfaceType.POWER_LEVEL_CONTROLLER:
case AlexaInterfaceType.RANGE_CONTROLLER:
case AlexaInterfaceType.RECORD_CONTROLLER:
case AlexaInterfaceType.SCENE_CONTROLLER:
case AlexaInterfaceType.SEEK_CONTROLLER:
case AlexaInterfaceType.TEMPERATURE_SENSOR:
case AlexaInterfaceType.TOGGLE_CONTROLLER:
return "3";
case AlexaInterfaceType.LAUNCHER:
return "1.1";
case AlexaInterfaceType.THERMOSTAT_CONTROLLER:
return "3.2";
case AlexaInterfaceType.ENDPOINT_HEALTH:
return "3.3";
default:
return "UNKNOWN";
}
return registry.has(this.type) ? registry.get(this.type).version : "UNKNOWN";
}
/** The names of the properties the interface reports, from its descriptor. */
getProps(): string[] {
switch (this.type) {
case AlexaInterfaceType.AUTOMATION_MANAGEMENT:
return ["automationStatuses"];
case AlexaInterfaceType.BRIGHTNESS_CONTROLLER:
return ["brightness"];
case AlexaInterfaceType.CHANNEL_CONTROLLER:
return ["channel"];
case AlexaInterfaceType.COLOR_CONTROLLER:
return ["color"];
case AlexaInterfaceType.COLOR_TEMPERATURE_CONTROLLER:
return ["colorTemperatureInKelvin"];
case AlexaInterfaceType.CONTACT_SENSOR:
return ["detectionState"];
case AlexaInterfaceType.POWER_CONTROLLER:
return ["powerState"];
case AlexaInterfaceType.ENDPOINT_HEALTH:
return ["connectivity"];
case AlexaInterfaceType.TOGGLE_CONTROLLER:
return ["toggleState"];
case AlexaInterfaceType.MODE_CONTROLLER:
return ["mode"];
case AlexaInterfaceType.TEMPERATURE_SENSOR:
return ["temperature"];
case AlexaInterfaceType.THERMOSTAT_CONTROLLER:
return [
"targetSetpoint",
"lowerSetpoint",
"upperSetpoint",
"thermostatMode",
];
case AlexaInterfaceType.APPLICATION_STATE_REPORTER:
case AlexaInterfaceType.AUDIO_PLAY_QUEUE:
case AlexaInterfaceType.AUTHORIZATION_CONTROLLER:
case AlexaInterfaceType.AUTOMOTIVE_VEHICLE_DATA:
case AlexaInterfaceType.CAMERA_LIVE_VIEW_CONTROLLER:
case AlexaInterfaceType.CAMERA_STREAM_CONTROLLER:
case AlexaInterfaceType.COMMISSIONABLE:
case AlexaInterfaceType.CONSENT_MANAGEMENT_CONSENT_REQUIRED_REPORTER:
case AlexaInterfaceType.COOKING:
case AlexaInterfaceType.DATA_CONTROLLER:
case AlexaInterfaceType.DEVICE_USAGE_ESTIMATION:
case AlexaInterfaceType.DEVICE_USAGE_METER:
case AlexaInterfaceType.DOORBELL_EVENT_SOURCE:
case AlexaInterfaceType.EQUALIZER_CONTROLLER:
case AlexaInterfaceType.INPUT_CONTROLLER:
case AlexaInterfaceType.INVENTORY_LEVEL_SENSOR:
case AlexaInterfaceType.INVENTORY_LEVEL_USAGE_SENSOR:
case AlexaInterfaceType.INVENTORY_USAGE_SENSOR:
case AlexaInterfaceType.KEYPAD_CONTROLLER:
case AlexaInterfaceType.LAUNCHER:
case AlexaInterfaceType.LOCK_CONTROLLER:
case AlexaInterfaceType.MEDIA_PLAYBACK:
case AlexaInterfaceType.MEDIA_SEARCH:
case AlexaInterfaceType.MOTION_SENSOR:
case AlexaInterfaceType.PERCENTAGE_CONTROLLER:
case AlexaInterfaceType.PLAYBACK_CONTROLLER:
case AlexaInterfaceType.PLAYBACK_STATE_REPORTER:
case AlexaInterfaceType.PROACTIVE_NOTIFICATION_SOURCE:
case AlexaInterfaceType.RANGE_CONTROLLER:
case AlexaInterfaceType.RECORD_CONTROLLER:
case AlexaInterfaceType.REMOTE_VIDEO_PLAYER:
case AlexaInterfaceType.RTC_SESSION_CONTROLLER:
case AlexaInterfaceType.SCENE_CONTROLLER:
return []; // scenes have no reportable properties: Activate/Deactivate answer with ActivationStarted (Device.sendSceneResponse)
case AlexaInterfaceType.SECURITY_PANEL_CONTROLLER:
case AlexaInterfaceType.SEEK_CONTROLLER:
case AlexaInterfaceType.SIMPLE_EVENT_SOURCE:
case AlexaInterfaceType.SMART_VISION_OBJECT_DETECTION_SENSOR:
case AlexaInterfaceType.SMART_VISION_SNAPSHOT_PROVIDER:
case AlexaInterfaceType.SPEAKER:
case AlexaInterfaceType.STEP_SPEAKER:
case AlexaInterfaceType.THERMOSTAT_CONTROLLER_CONFIGURATION:
case AlexaInterfaceType.THERMOSTAT_CONTROLLER_HVAC_COMPONENTS:
case AlexaInterfaceType.THERMOSTAT_CONTROLLER_SCHEDULE:
case AlexaInterfaceType.TIME_HOLD_CONTROLLER:
case AlexaInterfaceType.UI_CONTROLLER:
case AlexaInterfaceType.USER_PREFERENCE:
case AlexaInterfaceType.VIDEO_RECORDER:
case AlexaInterfaceType.WAKE_ON_LAN_CONTROLLER:
// no property list known for these: discovery lists the capability with an empty "supported" (the bridge notes
// it through its log hook; 1.5.1 wrote "UNSUPORTED INTERFACE TYPE" to stderr on every discovery)
return [];
default:
return [];
}
return registry.has(this.type) ? Object.keys(registry.get(this.type).properties) : [];
}
getJSON(): object {

View file

@ -51,6 +51,8 @@ export enum DisplayCategory {
THERMOSTAT = "THERMOSTAT",
TV = "TV",
VACUUM_CLEANER = "VACUUM_CLEANER",
VACUUM = "VACUUM",
/** Not on the list of display categories any more; kept for 1.x callers. */
VEHICLE = "VEHICLE",
WASHER = "WASHER",
WATER_HEATER = "WATER_HEATER",

View file

@ -9,3 +9,15 @@ export { DisplayCategory } from "./DisplayCategory.js";
export { AlexaStatusMessage, PowerController, EndpointHealth, TemperatureSensorScale, ThermostatMode } from "./AlexaStatusMessage.js";
export type { ChangeCause } from "./AlexaStatusMessage.js";
export { AlexaErrorType, AlexaErrorResponse } from "./AlexaErrorResponse.js";
// The interface registry and the vocabularies of the Smart Home API
export { registry, DeclarationError, SchemaError } from "./registry/index.js";
export {
ASSETS as Assets, UNITS_OF_MEASURE as Units, ACTIONS as Actions, STATES as States,
DISPLAY_CATEGORIES as DisplayCategories,
} from "./registry/index.js";
export type {
ActionId, AssetId, DisplayCategoryName, StateId, UnitOfMeasure,
AnyDescriptor, CapabilityExtras, Declared, DirectiveDescriptor, EndpointView, EventDescriptor, InterfaceDescriptor,
Label, PropertyDescriptor, Infer, Schema, Temperature, TimeInterval,
} from "./registry/index.js";

146
src/registry/catalog.ts Normal file
View file

@ -0,0 +1,146 @@
// The fixed vocabularies of the Smart Home API, copied from Amazon's pages as read on 2026-09-28. The pages are under
// https://developer.amazon.com/docs/alexaplus/device-apis/. A value missing here is one Amazon added since.
/**
* The asset ids a friendly name can refer to: 103, the units of measure among them
* (resources-and-assets.html, "Global Alexa catalog").
*/
export const ASSETS = [
"Alexa.Actions.Charge", "Alexa.Actions.Clean", "Alexa.Actions.Dispense", "Alexa.Actions.Dock",
"Alexa.Actions.Empty", "Alexa.Actions.Mop", "Alexa.Actions.Skip", "Alexa.Actions.Sweep", "Alexa.Actions.Vacuum",
"Alexa.Button.OffButton", "Alexa.Button.OnButton", "Alexa.Button.BrightenButton", "Alexa.Button.DimButton",
"Alexa.Button.MainButton", "Alexa.Button.TopButton", "Alexa.Button.BottomButton", "Alexa.Button.CenterButton",
"Alexa.Button.MiddleButton", "Alexa.Button.UpButton", "Alexa.Button.DownButton", "Alexa.Button.LeftButton",
"Alexa.Button.RightButton", "Alexa.Button.FirstButton", "Alexa.Button.SecondButton", "Alexa.Button.ThirdButton",
"Alexa.Button.FourthButton", "Alexa.Button.FifthButton", "Alexa.Button.SixthButton", "Alexa.Button.SeventhButton",
"Alexa.Button.EighthButton", "Alexa.Button.DoublePress", "Alexa.Button.DoublePush", "Alexa.Button.LongPress",
"Alexa.Button.LongPush", "Alexa.Button.SinglePress", "Alexa.Button.SinglePush",
"Alexa.DeviceName.AirPurifier", "Alexa.DeviceName.Camera", "Alexa.DeviceName.Fan", "Alexa.DeviceName.Router",
"Alexa.DeviceName.Shade", "Alexa.DeviceName.Shower", "Alexa.DeviceName.SpaceHeater", "Alexa.DeviceName.Washer",
"Alexa.Gesture.DoubleClick", "Alexa.Gesture.SingleClick", "Alexa.Gesture.SwipeDown", "Alexa.Gesture.SwipeLeft",
"Alexa.Gesture.SwipeRight", "Alexa.Gesture.SwipeUp", "Alexa.Gesture.Tap",
// "Gestures", plural: the page spells this one id differently from the seven above
"Alexa.Gestures.DoubleTap",
"Alexa.Setting.2GGuestWiFi", "Alexa.Setting.5GGuestWiFi", "Alexa.Setting.Auto", "Alexa.Setting.Direction",
"Alexa.Setting.DryCycle", "Alexa.Setting.FanSpeed", "Alexa.Setting.GuestWiFi", "Alexa.Setting.Heat",
"Alexa.Setting.Mode", "Alexa.Setting.Night", "Alexa.Setting.Opening", "Alexa.Setting.Oscillate",
"Alexa.Setting.Preset", "Alexa.Setting.Quiet", "Alexa.Setting.Temperature", "Alexa.Setting.WashCycle",
"Alexa.Setting.WaterTemperature",
"Alexa.Shower.HandHeld", "Alexa.Shower.RainHead",
"Alexa.Unit.Angle.Degrees", "Alexa.Unit.Angle.Radians", "Alexa.Unit.Distance.Feet", "Alexa.Unit.Distance.Inches",
"Alexa.Unit.Distance.Kilometers", "Alexa.Unit.Distance.Meters", "Alexa.Unit.Distance.Miles",
"Alexa.Unit.Distance.Yards", "Alexa.Unit.Mass.Grams", "Alexa.Unit.Mass.Kilograms", "Alexa.Unit.Percent",
"Alexa.Unit.Temperature.Celsius", "Alexa.Unit.Temperature.Degrees", "Alexa.Unit.Temperature.Fahrenheit",
"Alexa.Unit.Temperature.Kelvin", "Alexa.Unit.Volume.CubicFeet", "Alexa.Unit.Volume.CubicMeters",
"Alexa.Unit.Volume.Gallons", "Alexa.Unit.Volume.Liters", "Alexa.Unit.Volume.Pints", "Alexa.Unit.Volume.Quarts",
"Alexa.Unit.Weight.Ounces", "Alexa.Unit.Weight.Pounds",
"Alexa.Value.Close", "Alexa.Value.Delicate", "Alexa.Value.High", "Alexa.Value.Low", "Alexa.Value.Maximum",
"Alexa.Value.Medium", "Alexa.Value.Minimum", "Alexa.Value.Open", "Alexa.Value.QuickWash",
] as const;
export type AssetId = (typeof ASSETS)[number];
/** A unit of measure: the 23 Alexa.Unit.* assets, what a RangeController's unitOfMeasure takes. */
export type UnitOfMeasure = Extract<AssetId, `Alexa.Unit.${string}`>;
export const UNITS_OF_MEASURE = ASSETS.filter((id): id is UnitOfMeasure => id.startsWith("Alexa.Unit."));
/** The phrases an action mapping gives to a directive (alexa-discovery-objects.html, "ActionMappings object"). */
export const ACTIONS = [
"Alexa.Actions.Open", "Alexa.Actions.Close", "Alexa.Actions.Raise", "Alexa.Actions.Lower",
"Alexa.Actions.SetEcoOn", "Alexa.Actions.SetEcoOff",
] as const;
export type ActionId = (typeof ACTIONS)[number];
/** The states a state mapping gives to a property value (alexa-discovery-objects.html, "StateMappings object"). */
export const STATES = [
"Alexa.States.Open", "Alexa.States.Closed", "Alexa.States.EcoOn", "Alexa.States.EcoOff", "Alexa.States.Low",
"Alexa.States.Empty", "Alexa.States.Full", "Alexa.States.Done", "Alexa.States.Stuck",
] as const;
export type StateId = (typeof STATES)[number];
/**
* The 56 display categories (alexa-discovery.html, "Display categories"). The DisplayCategory enum also keeps
* VEHICLE from 1.x, which the page no longer lists.
*/
export const DISPLAY_CATEGORIES = [
"ACTIVITY_TRIGGER", "AIR_CONDITIONER", "AIR_FRESHENER", "AIR_PURIFIER", "AIR_QUALITY_MONITOR",
"ALEXA_VOICE_ENABLED", "AUTO_ACCESSORY", "BLUETOOTH_SPEAKER", "CAMERA", "CHRISTMAS_TREE", "COFFEE_MAKER",
"COMPUTER", "CONTACT_SENSOR", "DISHWASHER", "DOOR", "DOORBELL", "DRYER", "EXTERIOR_BLIND", "FAN", "GAME_CONSOLE",
"GARAGE_DOOR", "HEADPHONES", "HUB", "INTERIOR_BLIND", "LAPTOP", "LIGHT", "MICROWAVE", "MOBILE_PHONE",
"MOTION_SENSOR", "MUSIC_SYSTEM", "NETWORK_HARDWARE", "OTHER", "OVEN", "PHONE", "PRINTER", "REMOTE", "ROUTER",
"SCENE_TRIGGER", "SCREEN", "SECURITY_PANEL", "SECURITY_SYSTEM", "SLOW_COOKER", "SMARTLOCK", "SMARTPLUG", "SPEAKER",
"STREAMING_DEVICE", "SWITCH", "TABLET", "TEMPERATURE_SENSOR", "THERMOSTAT", "TV", "VACUUM_CLEANER", "VACUUM",
"WASHER", "WATER_HEATER", "WEARABLE",
] as const;
export type DisplayCategoryName = (typeof DISPLAY_CATEGORIES)[number];
/** Not to be used as a friendly name (resources-and-assets.html, "Reserved words"). */
export const RESERVED_WORDS = [
"alarm", "alarms", "all alarms", "away mode", "bass", "camera", "date", "date today", "day", "do not disturb",
"drop in", "music", "night light", "notification", "playing", "sleep sounds", "time", "timer", "today in music",
"treble", "volume", "way f. m.",
] as const;
/**
* What a discovery answer may hold (alexa-discovery.html, "Interface limits"; alexa-discovery-objects.html,
* "Endpoint object details" and "AdditionalAttributes object details"; alexa-scenecontroller.html, "Discovery").
*/
export const LIMITS = {
endpointsPerCustomer: 300,
capabilitiesPerEndpoint: 100,
endpointIdLength: 256,
friendlyNameLength: 256,
sceneFriendlyNameLength: 128,
manufacturerNameLength: 128,
descriptionLength: 128,
additionalAttributeLength: 256,
cookieBytes: 5000,
} as const;
// alexa-errorresponse.html, "Error type values". The Interface column names <namespace>.ErrorResponse; the event
// header carries the namespace and the name ErrorResponse, as the examples on the ThermostatController,
// SecurityPanelController and Safety error pages show. The other namespaces are as the table spells them.
// INVALID_VALUE is listed under Alexa and under SmartVision.ObjectDetectionSensor, SUBSCRIPTION_REQUIRED under both
// SmartVision interfaces: the first one listed is kept.
const ERROR_TYPES_BY_NAMESPACE = {
"Alexa": [
"ALREADY_IN_OPERATION", "BRIDGE_UNREACHABLE", "CLOUD_CONTROL_DISABLED", "DEVICE_STUCK", "DO_NOT_DISTURB_MODE",
"ENDPOINT_BUSY", "ENDPOINT_CONTROL_UNAVAILABLE", "ENDPOINT_LOW_POWER", "ENDPOINT_UNREACHABLE",
"EXPIRED_AUTHORIZATION_CREDENTIAL", "FIRMWARE_OUT_OF_DATE", "HARDWARE_MALFUNCTION", "INSUFFICIENT_PERMISSIONS",
"INSUFFICIENT_RESOURCE", "INTERNAL_ERROR", "INVALID_AUTHORIZATION_CREDENTIAL", "INVALID_DIRECTIVE",
"INVALID_VALUE", "MAINTENANCE_REQUIRED", "NO_SUCH_ENDPOINT", "NOT_CALIBRATED", "NOT_IN_OPERATION",
"NOT_SUPPORTED_IN_CURRENT_MODE", "NOT_SUPPORTED_WITH_CURRENT_BATTERY_CHARGE_STATE",
"PARTNER_APPLICATION_REDIRECTION", "POWER_LEVEL_NOT_SUPPORTED", "RATE_LIMIT_EXCEEDED",
"TEMPERATURE_VALUE_OUT_OF_RANGE", "TOO_MANY_FAILED_ATTEMPTS", "UNABLE_TO_CHARGE", "VALUE_OUT_OF_RANGE",
],
"Alexa.Commissionable.ReportCommissioningInformation": [
"FAILED_TO_BOOTSTRAP_COMMISSIONING_PROCESS", "MAX_COMMISSIONING_LIMIT_REACHED",
],
"Alexa.Cooking": [
"CHILD_LOCK", "COOK_DURATION_TOO_LONG", "DOOR_CLOSED_TOO_LONG", "DOOR_OPEN", "PREHEAT_REQUIRED",
"PROBE_REQUIRED", "REMOTE_START_NOT_SUPPORTED", "REMOVE_PROBE", "REMOTE_START_DISABLED",
],
"Alexa.DataController": ["DATA_DELETION_NOT_SUPPORTED", "DATA_RETRIEVAL_NOT_SUPPORTED"],
"Alexa.Safety": ["OBSTACLE_DETECTED", "SAFETY_BEAM_BREACHED"],
"Alexa.SecurityPanelController": [
"AUTHORIZATION_REQUIRED", "BYPASS_NEEDED", "NOT_READY", "UNAUTHORIZED", "UNCLEARED_ALARM", "UNCLEARED_TROUBLE",
],
"Alexa.SmartVision.ObjectDetectionSensor": ["SUBSCRIPTION_REQUIRED"],
"Alexa.SmartVision.SnapshotProvider": ["DISABLED_BY_USER"],
"Alexa.ThermostatController": [
"DUAL_SETPOINTS_UNSUPPORTED", "REQUESTED_SETPOINTS_TOO_CLOSE", "THERMOSTAT_IS_OFF",
"TRIPLE_SETPOINTS_UNSUPPORTED", "UNSUPPORTED_THERMOSTAT_MODE", "UNWILLING_TO_SET_SCHEDULE",
"UNWILLING_TO_SET_VALUE",
],
"Alexa.ThermostatController.Configuration": [
"CONFIGURATION_UPDATE_NOT_ALLOWED", "COOLING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE",
"COOLING_STAGES_EXCEEDS_LIMIT", "HEATING_LOCKOUT_TEMPERATURE_VALUE_OUT_OF_RANGE", "HEATING_STAGES_EXCEEDS_LIMIT",
"INVALID_AUXILIARY_HEATING_SYSTEM_TYPE", "INVALID_SYSTEM_TYPE", "INVALID_TARGET_STATE",
"INVALID_TEMPERATURE_SCALE", "INVALID_TERMINAL_CONNECTION", "MISSING_SETUP_INFORMATION",
],
"Alexa.ThermostatController.Schedule": ["INSUFFICIENT_SPACE"],
} as const;
/** The 73 error types of the table, each with the namespace its ErrorResponse goes under. */
export const ERROR_TYPES: Readonly<Record<string, string>> = Object.fromEntries(
Object.entries(ERROR_TYPES_BY_NAMESPACE).flatMap(([namespace, types]) => types.map((type) => [type, namespace]))
);

53
src/registry/index.ts Normal file
View file

@ -0,0 +1,53 @@
// The interfaces the library knows, by namespace.
import { Alexa } from "./interfaces/Alexa.js";
import { BrightnessController } from "./interfaces/BrightnessController.js";
import { EndpointHealth } from "./interfaces/EndpointHealth.js";
import { PowerController } from "./interfaces/PowerController.js";
import { TemperatureSensor } from "./interfaces/TemperatureSensor.js";
import { STUBS } from "./interfaces/stubs.js";
import { DeclarationError } from "./types.js";
import type { AnyDescriptor } from "./types.js";
const described: readonly AnyDescriptor[] = [
Alexa,
BrightnessController,
EndpointHealth,
PowerController,
TemperatureSensor,
];
const descriptors = new Map<string, AnyDescriptor>();
for (const descriptor of [...described, ...STUBS]) {
// A stub left in the table next to the descriptor that replaces it would win or lose by the order of this list
if (descriptors.has(descriptor.namespace)) throw new Error(`${descriptor.namespace} is described twice`);
descriptors.set(descriptor.namespace, descriptor);
}
export const registry = {
/** Whether an interface of this name is known. */
has(namespace: string): boolean {
return descriptors.has(namespace);
},
/** The descriptor of "Alexa.RangeController" or of AlexaInterfaceType.RANGE_CONTROLLER, which is that string. */
get(namespace: string): AnyDescriptor {
const descriptor = descriptors.get(namespace);
if (!descriptor) throw new DeclarationError({}, `${JSON.stringify(namespace)} is not an interface alex2node knows`);
return descriptor;
},
/** Every descriptor, ordered by namespace. */
list(): AnyDescriptor[] {
return [...descriptors.values()].sort((a, b) => (a.namespace < b.namespace ? -1 : 1));
},
};
export { Alexa, BrightnessController, EndpointHealth, PowerController, TemperatureSensor };
export { DeclarationError, defineInterface } from "./types.js";
export type {
AnyDescriptor, CapabilityExtras, Declared, DirectiveDescriptor, EndpointView, EventDescriptor, InterfaceDescriptor,
Label, PropertyDescriptor,
} from "./types.js";
export { s, SchemaError } from "./schema.js";
export type { Infer, Schema, Temperature, TimeInterval } from "./schema.js";
export * from "./catalog.js";

View file

@ -0,0 +1,21 @@
import { s } from "../schema.js";
import { defineInterface } from "../types.js";
/**
* The base interface: every endpoint lists it (alexa-interface.html, "Support the Alexa interface in all add-ons").
* Its one directive to a device is ReportState, answered with a StateReport.
*/
export const Alexa = defineInterface({
namespace: "Alexa",
version: "3",
doc: "https://developer.amazon.com/docs/alexaplus/device-apis/alexa-interface.html",
kind: "base",
tier: 1,
instanced: false,
properties: {},
directives: {
ReportState: { name: "ReportState", payload: s.object({}) },
},
// { type, interface, version } and nothing else, as in the discovery example of the page
discovery: () => ({ properties: false }),
});

View file

@ -0,0 +1,25 @@
import { s } from "../schema.js";
import { defineInterface } from "../types.js";
/** Both directives turn a light that is off on, at the brightness asked for (alexa-brightnesscontroller.html). */
export const BrightnessController = defineInterface({
namespace: "Alexa.BrightnessController",
version: "3",
doc: "https://developer.amazon.com/docs/alexaplus/device-apis/alexa-brightnesscontroller.html",
kind: "controller",
tier: 1,
instanced: false,
properties: {
brightness: { name: "brightness", value: s.number({ min: 0, max: 100, integer: true }) },
},
directives: {
SetBrightness: {
name: "SetBrightness",
payload: s.object({ brightness: s.number({ min: 0, max: 100, integer: true }) }),
},
AdjustBrightness: {
name: "AdjustBrightness",
payload: s.object({ brightnessDelta: s.number({ min: -100, max: 100, integer: true }) }),
},
},
});

View file

@ -0,0 +1,31 @@
import { s } from "../schema.js";
import { defineInterface } from "../types.js";
export const CONNECTIVITY_REASONS = [
"WIFI_BAD_PASSWORD", "WIFI_AP_NOT_FOUND", "WIFI_ROUTER_UNREACHABLE", "WIFI_AP_CHANNEL_QUALITY_LOW",
"INTERNET_UNREACHABLE", "CAPTIVE_PORTAL_CHECK_FAILED", "UNKNOWN",
] as const;
/**
* Version 3.1, as the page is titled. It defines connectivity only. Examples on other pages declare 3 and 3.2;
* alex2node 1.x declared 3.3, which no page mentions.
*/
export const EndpointHealth = defineInterface({
namespace: "Alexa.EndpointHealth",
version: "3.1",
doc: "https://developer.amazon.com/docs/alexaplus/device-apis/alexa-endpointhealth.html",
kind: "sensor",
tier: 1,
instanced: false,
properties: {
connectivity: {
name: "connectivity",
value: s.object({
value: s.enum("OK", "UNREACHABLE"),
reason: s.optional(s.enum(...CONNECTIVITY_REASONS)),
}),
note: "in every Response, StateReport and ChangeReport; a change of it reported within three seconds",
},
},
directives: {},
});

View file

@ -0,0 +1,28 @@
import { s } from "../schema.js";
import { defineInterface } from "../types.js";
export const PowerController = defineInterface({
namespace: "Alexa.PowerController",
version: "3",
doc: "https://developer.amazon.com/docs/alexaplus/device-apis/alexa-powercontroller.html",
kind: "controller",
tier: 1,
instanced: false,
properties: {
powerState: { name: "powerState", value: s.enum("ON", "OFF") },
},
directives: {
TurnOn: { name: "TurnOn", payload: s.object({}) },
TurnOff: { name: "TurnOff", payload: s.object({}) },
},
options: s.object({
// The directives Alexa asks the user to confirm before it sends them. Amazon supports this for devices in Japan
// only (alexa-discovery-objects.html, "VerificationsRequired object").
verificationsRequired: s.optional(s.array(s.enum("TurnOn", "TurnOff"), { min: 1 })),
}, { unknownKeys: "reject" }),
discovery({ options }) {
if (!options.verificationsRequired) return {};
const confirmed = options.verificationsRequired.map((directive) => ({ directive, methods: [{ "@type": "Confirmation" }] }));
return { topLevel: { verificationsRequired: confirmed } };
},
});

View file

@ -0,0 +1,16 @@
import { s } from "../schema.js";
import { defineInterface } from "../types.js";
/** A sensor: an endpoint that has it declares Alexa.EndpointHealth as well (alexa-temperaturesensor.html). */
export const TemperatureSensor = defineInterface({
namespace: "Alexa.TemperatureSensor",
version: "3",
doc: "https://developer.amazon.com/docs/alexaplus/device-apis/alexa-temperaturesensor.html",
kind: "sensor",
tier: 1,
instanced: false,
properties: {
temperature: { name: "temperature", value: s.temperature() },
},
directives: {},
});

View file

@ -0,0 +1,106 @@
// Interfaces without a descriptor of their own. A stub names the interface with the version and the properties
// alex2node 1.5.2 put in discovery, so an endpoint that declares one is announced as before. Nothing about it is
// checked, and the version is the one 1.5.2 sent, which is not always the one on the page of the interface
// (Alexa.Speaker is "3" there). A row leaves this table when a descriptor is written from that page.
import { s } from "../schema.js";
import type { AnyDescriptor } from "../types.js";
const DEVICE_APIS = "https://developer.amazon.com/docs/alexaplus/device-apis";
const LIST_OF_INTERFACES = `${DEVICE_APIS}/list-of-interfaces.html`;
// "As of August 7, 2026, Automotive skills and the associated Alexa.AuthorizationController and
// Alexa.Automotive.VehicleData APIs are no longer available."
const DEPRECATED_FEATURES = "https://developer.amazon.com/en-US/docs/alexa/ask-overviews/deprecated-features.html";
// namespace, version, properties, page (a name under device-apis/, or a URL)
type Row = readonly [string, string, readonly string[], string];
const TABLE: readonly Row[] = [
["Alexa.ApplicationStateReporter", "1", [], "https://developer.amazon.com/docs/alexaplus/alexa-voice-service/alexa-applicationstatereporter.html"],
["Alexa.Audio.PlayQueue", "1", [], "alexa-audio-playqueue.html"],
["Alexa.AuthorizationController", "1", [], DEPRECATED_FEATURES],
["Alexa.AutomationManagement", "1", ["automationStatuses"], "alexa-automationmanagement.html"],
["Alexa.Automotive.VehicleData", "1", [], DEPRECATED_FEATURES],
// 1.5.2 announced the version "UNKNOWN"; 1.7 is what the list of interfaces gives
["Alexa.Camera.LiveViewController", "1.7", [], LIST_OF_INTERFACES],
["Alexa.CameraStreamController", "3", [], "alexa-camerastreamcontroller.html"],
["Alexa.ChannelController", "3", ["channel"], "alexa-channelcontroller.html"],
["Alexa.ColorController", "3", ["color"], "alexa-colorcontroller.html"],
["Alexa.ColorTemperatureController", "3", ["colorTemperatureInKelvin"], "alexa-colortemperaturecontroller.html"],
["Alexa.Commissionable", "1", [], "alexa-commissionable.html"],
["Alexa.ConsentManagement.ConsentRequiredReporter", "1", [], "alexa-consentrequiredreporter.html"],
["Alexa.ContactSensor", "3", ["detectionState"], "alexa-contactsensor.html"],
["Alexa.Cooking", "1", [], "alexa-cooking.html"],
["Alexa.Cooking.FoodTemperatureController", "1", [], "alexa-cooking-foodtemperaturecontroller.html"],
["Alexa.Cooking.FoodTemperatureSensor", "1", [], "alexa-cooking-foodtemperaturesensor.html"],
["Alexa.Cooking.PresetController", "1", [], "alexa-cooking-presetcontroller.html"],
["Alexa.Cooking.TemperatureController", "1", [], "alexa-cooking-temperaturecontroller.html"],
["Alexa.Cooking.TemperatureSensor", "1", [], "alexa-cooking-temperaturesensor.html"],
["Alexa.Cooking.TimeController", "1", [], "alexa-cooking-timecontroller.html"],
["Alexa.DataController", "1", [], "alexa-datacontroller.html"],
["Alexa.DeviceUsage.Estimation", "1", [], "alexa-deviceusage-estimation.html"],
["Alexa.DeviceUsage.Meter", "1", [], "alexa-deviceusage-meter.html"],
["Alexa.DoorbellEventSource", "3", [], "alexa-doorbelleventsource.html"],
["Alexa.EqualizerController", "1", [], "alexa-equalizercontroller.html"],
["Alexa.InputController", "3", [], "alexa-inputcontroller.html"],
["Alexa.InventoryLevelSensor", "1", [], "alexa-inventorylevelsensor.html"],
["Alexa.InventoryLevelUsageSensor", "1", [], "alexa-inventorylevelusagesensor.html"],
["Alexa.InventoryUsageSensor", "1", [], "alexa-inventoryusagesensor.html"],
["Alexa.KeypadController", "1", [], "alexa-keypadcontroller.html"],
["Alexa.Launcher", "1.1", [], "alexa-launcher.html"],
["Alexa.LockController", "3", [], "alexa-lockcontroller.html"],
["Alexa.Media.PlayQueue", "1", [], "alexa-media-playqueue.html"],
["Alexa.Media.Playback", "1", [], "alexa-media-playback.html"],
["Alexa.Media.Search", "1", [], "alexa-media-search.html"],
["Alexa.ModeController", "3", ["mode"], "alexa-modecontroller.html"],
["Alexa.MotionSensor", "3", [], "alexa-motionsensor.html"],
["Alexa.PercentageController", "3", [], "alexa-percentagecontroller.html"],
["Alexa.PlaybackController", "3", [], "alexa-playbackcontroller.html"],
["Alexa.PlaybackStateReporter", "1", [], "alexa-playbackcontroller.html"],
["Alexa.PowerLevelController", "3", [], "alexa-powerlevelcontroller.html"],
["Alexa.ProactiveNotificationSource", "1", [], "alexa-proactivenotificationsource.html"],
["Alexa.RTCSessionController", "1", [], "alexa-rtcsessioncontroller.html"],
["Alexa.RangeController", "3", [], "alexa-rangecontroller.html"],
["Alexa.RecordController", "3", [], "alexa-recordcontroller.html"],
["Alexa.RemoteVideoPlayer", "1", [], "alexa-remotevideoplayer.html"],
["Alexa.SceneController", "3", [], "alexa-scenecontroller.html"],
["Alexa.SecurityPanelController", "1", [], "alexa-securitypanelcontroller.html"],
["Alexa.SecurityPanelController.Alert", "1", [], "alexa-securitypanelcontroller-alert.html"],
["Alexa.SeekController", "3", [], "alexa-seekcontroller.html"],
["Alexa.SimpleEventSource", "1", [], "alexa-simpleeventsource.html"],
["Alexa.SmartVision.ObjectDetectionSensor", "1", [], "alexa-smartvision-objectdetectionsensor.html"],
["Alexa.SmartVision.SnapshotProvider", "1", [], "alexa-smartvision-snapshotprovider.html"],
["Alexa.Speaker", "1", [], "alexa-speaker.html"],
["Alexa.StepSpeaker", "1", [], "alexa-stepspeaker.html"],
["Alexa.ThermostatController", "3.2", ["targetSetpoint", "lowerSetpoint", "upperSetpoint", "thermostatMode"], "alexa-thermostatcontroller.html"],
["Alexa.ThermostatController.Configuration", "1", [], "alexa-thermostatcontroller-configuration.html"],
["Alexa.ThermostatController.HVAC.Components", "1", [], "alexa-thermostatcontroller-hvac-components.html"],
["Alexa.ThermostatController.Schedule", "1", [], "alexa-thermostatcontroller-schedule.html"],
// "UNKNOWN" in 1.5.2 as well; the page is titled "Interface 3"
["Alexa.TimeHoldController", "3", [], "alexa-timeholdcontroller.html"],
["Alexa.ToggleController", "3", ["toggleState"], "alexa-togglecontroller.html"],
["Alexa.UIController", "1", [], "alexa-uicontroller.html"],
["Alexa.UserPreference", "1", [], "alexa-userpreference.html"],
["Alexa.VideoRecorder", "1", [], "alexa-videorecorder.html"],
["Alexa.WakeOnLANController", "1", [], "alexa-wakeonlancontroller.html"],
];
function kindOf(namespace: string): AnyDescriptor["kind"] {
if (namespace.endsWith("Sensor")) return "sensor";
if (namespace.endsWith("EventSource")) return "eventSource";
return "controller";
}
function stub([namespace, version, properties, page]: Row): AnyDescriptor {
return {
namespace,
version,
doc: page.startsWith("https://") ? page : `${DEVICE_APIS}/${page}`,
kind: kindOf(namespace),
tier: 3,
instanced: false,
properties: Object.fromEntries(properties.map((name) => [name, { name, value: s.unknown() }])),
directives: {},
};
}
export const STUBS: readonly AnyDescriptor[] = TABLE.map(stub);

241
src/registry/schema.ts Normal file
View file

@ -0,0 +1,241 @@
// 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(
readonly path: string,
readonly problem: string
) {
super(path ? `${path}: ${problem}` : problem);
this.name = "SchemaError";
}
}
export interface Schema<T> {
/** What the schema accepts, in the words of an error message: "an integer from 0 to 100", "ON | OFF". */
readonly expects: string;
/** The checked value. path names it in the error message. */
parse(input: unknown, path?: string): T;
}
/** A schema that accepts a missing value; s.object() makes its key optional in the inferred type. */
export interface OptionalSchema<T> extends Schema<T | undefined> {
readonly optional: true;
}
export interface EnumSchema<V> extends Schema<V> {
readonly values: readonly V[];
}
/** The type a schema produces. */
export type Infer<S> = S extends Schema<infer T> ? T : never;
export type Shape = Record<string, Schema<any>>;
type OptionalKeys<S extends Shape> = { [K in keyof S]: S[K] extends OptionalSchema<any> ? K : never }[keyof S];
export type InferShape<S extends Shape> = { [K in Exclude<keyof S, OptionalKeys<S>>]: Infer<S[K]> } & {
[K in OptionalKeys<S>]?: Infer<S[K]>;
};
export interface NumberRules {
min?: number;
max?: number;
/** Greater than, the bound itself excluded. */
gt?: number;
integer?: boolean;
}
export interface Temperature {
value: number;
scale: "CELSIUS" | "FAHRENHEIT" | "KELVIN";
}
export interface TimeInterval {
start?: string;
end?: string;
duration?: string;
}
// The input as an error message shows it: short, and quoted when it is text.
function shown(input: unknown): string {
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;
}
function mismatch(path: string, expects: string, input: unknown): SchemaError {
return new SchemaError(path, `expected ${expects}, got ${shown(input)}`);
}
const at = (path: string, key: string): string => (path ? `${path}.${key}` : key);
function isRecord(input: unknown): input is Record<string, unknown> {
return typeof input === "object" && input !== null && !Array.isArray(input);
}
function schema<T>(expects: string, accepts: (input: unknown) => boolean): Schema<T> {
return {
expects,
parse(input, path = "") {
if (!accepts(input)) throw mismatch(path, expects, input);
return input as T;
},
};
}
function numberText({ min, max, gt, integer }: NumberRules): string {
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: NumberRules = {}): Schema<number> {
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: { min?: number; max?: number; pattern?: RegExp; expects?: string } = {}): Schema<string> {
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(): Schema<boolean> {
return schema("true or false", (input) => typeof input === "boolean");
}
function literal<V extends string | number | boolean | null>(value: V): Schema<V> {
return schema(JSON.stringify(value), (input) => input === value);
}
function enumeration<V extends readonly string[]>(...values: V): EnumSchema<V[number]> {
return { ...schema<V[number]>(values.join(" | "), (input) => values.includes(input as string)), values };
}
function unknown(): Schema<unknown> {
return schema("any value", () => true);
}
function optional<T>(inner: Schema<T>): OptionalSchema<T> {
return {
expects: inner.expects,
optional: true,
parse: (input, path = "") => (input === undefined ? undefined : inner.parse(input, path)),
};
}
function nullable<T>(inner: Schema<T>): Schema<T | null> {
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<T>(item: Schema<T>, rules: { min?: number; max?: number } = {}): Schema<T[]> {
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<S extends Shape>(shape: S, rules: { unknownKeys?: "keep" | "reject" } = {}): Schema<InferShape<S>> {
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: Record<string, unknown> = {};
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 as InferShape<S>;
},
};
}
// alexa-property-schemas.html "Temperature", "Temperature scales"
function temperature(): Schema<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(): Schema<string> {
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(): Schema<string> {
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(): Schema<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,
unknown,
optional,
nullable,
array,
object,
temperature,
dateTime,
duration,
timeInterval,
};

143
src/registry/types.ts Normal file
View file

@ -0,0 +1,143 @@
// What the library knows about one Alexa interface, as data. Discovery JSON, property values and directive payloads
// are produced and checked from a descriptor, so adding an interface is adding one file under interfaces/.
import type { Schema } from "./schema.js";
/**
* A friendly name: text in one locale, or an asset of the global Alexa catalog, which stands for several names in
* every language Alexa speaks (resources-and-assets.html, "Label object").
*/
export type Label =
| { "@type": "text"; value: { text: string; locale: string } }
| { "@type": "asset"; value: { assetId: string } };
export interface PropertyDescriptor<V> {
name: string;
value: Schema<V>;
note?: string;
}
export interface DirectiveDescriptor<T> {
name: string;
payload: Schema<T>;
/** Alexa sends the directive only to a capability declared like this (AdjustMode: an ordered mode). */
when?: (capability: Declared<any>) => boolean;
note?: string;
}
/** An event a device raises that is not a Response: ActivationStarted, DoorbellPress. */
export interface EventDescriptor {
name: string;
/** Default: the namespace of the interface. */
namespace?: string;
/** Default: the version of the interface. */
payloadVersion?: string;
payload: Schema<any>;
/** response: the answer to a directive. proactive: sent without one. */
topic: "response" | "proactive";
}
/** What an interface adds to the capability object in discovery, next to the fields every capability has. */
export interface CapabilityExtras {
configuration?: Record<string, unknown>;
/** Alexa.EqualizerController spells it in the plural. */
configurations?: Record<string, unknown>;
/** Fields of the capability object itself: supportsDeactivation, supportedOperations, inputs. */
topLevel?: Record<string, unknown>;
/** false: the capability has no properties object (the Alexa interface, a scene). */
properties?: false;
}
/** A capability as declared on an endpoint: what discovery() and validate() of its descriptor are given. */
export interface Declared<O = Record<string, unknown>> {
readonly endpointId: string;
readonly namespace: string;
/** "" when the interface is declared without an instance. */
readonly instance: string;
readonly friendlyNames: readonly Label[];
readonly retrievable: boolean;
readonly proactivelyReported: boolean;
readonly nonControllable?: boolean;
/** The options of the interface, as its options schema returned them. */
readonly options: O;
}
/** The endpoint a capability is declared on, as validate() sees it. */
export interface EndpointView {
readonly endpointId: string;
readonly friendlyName: string;
readonly description: string;
readonly displayCategories: readonly string[];
/** The other capabilities of the endpoint. */
readonly capabilities: readonly Declared[];
}
export type Properties = Record<string, PropertyDescriptor<any>>;
export type Directives = Record<string, DirectiveDescriptor<any>>;
export interface InterfaceDescriptor<
P extends Properties = Properties,
D extends Directives = Directives,
O = {},
I extends boolean = boolean
> {
/** "Alexa.RangeController" */
namespace: string;
/** As the title of the interface's page gives it: "3", "3.1", "1.0". */
version: string;
/** The page the descriptor was written from. */
doc: string;
kind: "base" | "controller" | "sensor" | "eventSource";
/** 1 and 2: described in full. 3: a stub, the namespace with its version and property names and nothing checked. */
tier: 1 | 2 | 3;
/** A generic controller: each capability needs an instance name and friendly names. */
instanced: I;
/** The properties the interface reports. */
properties: P;
/** The directives Alexa sends. */
directives: D;
events?: Record<string, EventDescriptor>;
/** What a declaration can set beyond the fields every capability has: a range, the supported modes. */
options?: Schema<O>;
discovery?: (capability: Declared<O>) => CapabilityExtras;
/** The event that answers a directive when it is not Alexa / Response (Arm -> Arm.Response). */
responseFor?: (directive: string) => { namespace: string; name: string; payload?: Schema<any> } | undefined;
/** The namespace an ErrorResponse goes under when its type is one of errorTypes. */
errorNamespace?: string;
errorTypes?: readonly string[];
/** Amazon documents a DeferredResponse for the interface. */
deferrable?: boolean;
/** Rules a schema cannot state. Throws DeclarationError. */
validate?: (capability: Declared<O>, endpoint: EndpointView) => void;
}
export type AnyDescriptor = InterfaceDescriptor<any, any, any, boolean>;
/** Types a descriptor from what it is given: the property names, the payload of each directive, the options. */
export function defineInterface<P extends Properties, D extends Directives, O = {}, I extends boolean = boolean>(
descriptor: InterfaceDescriptor<P, D, O, I>
): InterfaceDescriptor<P, D, O, I> {
return descriptor;
}
/** A device, a capability or an interface name declared in a way Alexa would reject. Thrown where it is declared. */
export class DeclarationError extends Error {
readonly endpointId?: string;
readonly namespace?: string;
readonly instance?: string;
readonly problem: string;
constructor(where: { endpointId?: string; namespace?: string; instance?: string }, problem: string) {
const capability = where.namespace && where.instance ? `${where.namespace} "${where.instance}"` : where.namespace;
super([where.endpointId && shortened(where.endpointId), capability, problem].filter(Boolean).join(": "));
this.name = "DeclarationError";
this.endpointId = where.endpointId;
this.namespace = where.namespace;
this.instance = where.instance || undefined;
this.problem = problem;
}
}
// An endpointId may be the very thing that is wrong, 300 characters of it.
function shortened(text: string): string {
return text.length > 64 ? `${text.slice(0, 61)}...` : text;
}