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:
parent
128ca35c2a
commit
aa0ffd64ea
93 changed files with 4058 additions and 490 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
12
src/index.ts
12
src/index.ts
|
|
@ -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
146
src/registry/catalog.ts
Normal 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
53
src/registry/index.ts
Normal 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";
|
||||
21
src/registry/interfaces/Alexa.ts
Normal file
21
src/registry/interfaces/Alexa.ts
Normal 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 }),
|
||||
});
|
||||
25
src/registry/interfaces/BrightnessController.ts
Normal file
25
src/registry/interfaces/BrightnessController.ts
Normal 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 }) }),
|
||||
},
|
||||
},
|
||||
});
|
||||
31
src/registry/interfaces/EndpointHealth.ts
Normal file
31
src/registry/interfaces/EndpointHealth.ts
Normal 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: {},
|
||||
});
|
||||
28
src/registry/interfaces/PowerController.ts
Normal file
28
src/registry/interfaces/PowerController.ts
Normal 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 } };
|
||||
},
|
||||
});
|
||||
16
src/registry/interfaces/TemperatureSensor.ts
Normal file
16
src/registry/interfaces/TemperatureSensor.ts
Normal 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: {},
|
||||
});
|
||||
106
src/registry/interfaces/stubs.ts
Normal file
106
src/registry/interfaces/stubs.ts
Normal 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
241
src/registry/schema.ts
Normal 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
143
src/registry/types.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue