discovery: generate capability JSON from the registry

A capability is a descriptor plus what the endpoint declares (device/Capability.ts), and its discovery object is
generated from the two. The new API is bridge.addDevice({ endpointId, name, categories, ... }) and
device.add(PowerController, options): both throw a DeclarationError that names the endpoint, the interface and
the instance (device/validate.ts), and leave the bridge and the device as they were. AlexaInterface is the same
Capability with the 1.x methods on it; it, ActionMapping and the enums moved to src/compat/, Device to src/device/.

What a 1.x caller can observe:
- every endpoint ends with { type: "AlexaInterface", interface: "Alexa", version: "3" } (alexa-interface.html);
  new Alex2MQTT(..., { alexaInterface: false }) leaves it out
- the fields of a capability object come in the order of Amazon's examples; their content is unchanged
- addCapability() with a name that is not an interface throws (1.5.2 announced it with the version "UNKNOWN")
- ActionMapping takes the payload as an object; a JSON string is parsed (1.5.2 sent the string), any other throws
- what Alexa would reject in a 1.x declaration is not refused: device.check() lists it and the bridge logs each
  line once, as "warning: ..." through the log hook, when it answers a discovery
- a device whose JSON cannot be built is left out of the answer and reported as an error event
- PowerController and EndpointHealth are the descriptors and keep ON/OFF and OK/UNREACHABLE; PowerState is new

Tests: six zoo devices declared the 1.x way give the JSON that Alexa accepted from 1.5.2 on 2026-09-28, plus the
Alexa capability. npm test: 85 pass (was 57) in 10-12 s, also on Node 18.20.8 and 20.20.2.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
David 2026-09-28 15:35:46 +00:00
parent aa0ffd64ea
commit c492ef74d1
90 changed files with 5539 additions and 1760 deletions

View file

@ -1,165 +0,0 @@
import { registry } from "./registry/index.js";
export var AlexaInterfaceType;
(function (AlexaInterfaceType) {
AlexaInterfaceType["APPLICATION_STATE_REPORTER"] = "Alexa.ApplicationStateReporter";
AlexaInterfaceType["AUDIO_PLAY_QUEUE"] = "Alexa.Audio.PlayQueue";
AlexaInterfaceType["AUTHORIZATION_CONTROLLER"] = "Alexa.AuthorizationController";
AlexaInterfaceType["AUTOMATION_MANAGEMENT"] = "Alexa.AutomationManagement";
AlexaInterfaceType["AUTOMOTIVE_VEHICLE_DATA"] = "Alexa.Automotive.VehicleData";
AlexaInterfaceType["BRIGHTNESS_CONTROLLER"] = "Alexa.BrightnessController";
AlexaInterfaceType["CAMERA_LIVE_VIEW_CONTROLLER"] = "Alexa.Camera.LiveViewController";
AlexaInterfaceType["CAMERA_STREAM_CONTROLLER"] = "Alexa.CameraStreamController";
AlexaInterfaceType["CHANNEL_CONTROLLER"] = "Alexa.ChannelController";
AlexaInterfaceType["COLOR_CONTROLLER"] = "Alexa.ColorController";
AlexaInterfaceType["COLOR_TEMPERATURE_CONTROLLER"] = "Alexa.ColorTemperatureController";
AlexaInterfaceType["COMMISSIONABLE"] = "Alexa.Commissionable";
AlexaInterfaceType["CONSENT_MANAGEMENT_CONSENT_REQUIRED_REPORTER"] = "Alexa.ConsentManagement.ConsentRequiredReporter";
AlexaInterfaceType["CONTACT_SENSOR"] = "Alexa.ContactSensor";
AlexaInterfaceType["COOKING"] = "Alexa.Cooking";
AlexaInterfaceType["COOKING_FOOD_TEMPERATURE_CONTROLLER"] = "Alexa.Cooking.FoodTemperatureController";
AlexaInterfaceType["COOKING_FOOD_TEMPERATURE_SENSOR"] = "Alexa.Cooking.FoodTemperatureSensor";
AlexaInterfaceType["COOKING_PRESET_CONTROLLER"] = "Alexa.Cooking.PresetController";
AlexaInterfaceType["COOKING_TEMPERATURE_CONTROLLER"] = "Alexa.Cooking.TemperatureController";
AlexaInterfaceType["COOKING_TEMPERATURE_SENSOR"] = "Alexa.Cooking.TemperatureSensor";
AlexaInterfaceType["COOKING_TIME_CONTROLLER"] = "Alexa.Cooking.TimeController";
AlexaInterfaceType["DATA_CONTROLLER"] = "Alexa.DataController";
AlexaInterfaceType["DEVICE_USAGE_ESTIMATION"] = "Alexa.DeviceUsage.Estimation";
AlexaInterfaceType["DEVICE_USAGE_METER"] = "Alexa.DeviceUsage.Meter";
AlexaInterfaceType["DOORBELL_EVENT_SOURCE"] = "Alexa.DoorbellEventSource";
AlexaInterfaceType["ENDPOINT_HEALTH"] = "Alexa.EndpointHealth";
AlexaInterfaceType["EQUALIZER_CONTROLLER"] = "Alexa.EqualizerController";
AlexaInterfaceType["INPUT_CONTROLLER"] = "Alexa.InputController";
AlexaInterfaceType["INVENTORY_LEVEL_SENSOR"] = "Alexa.InventoryLevelSensor";
AlexaInterfaceType["INVENTORY_LEVEL_USAGE_SENSOR"] = "Alexa.InventoryLevelUsageSensor";
AlexaInterfaceType["INVENTORY_USAGE_SENSOR"] = "Alexa.InventoryUsageSensor";
AlexaInterfaceType["KEYPAD_CONTROLLER"] = "Alexa.KeypadController";
AlexaInterfaceType["LAUNCHER"] = "Alexa.Launcher";
AlexaInterfaceType["LOCK_CONTROLLER"] = "Alexa.LockController";
AlexaInterfaceType["MEDIA_PLAYBACK"] = "Alexa.Media.Playback";
AlexaInterfaceType["MEDIA_PLAY_QUEUE"] = "Alexa.Media.PlayQueue";
AlexaInterfaceType["MEDIA_SEARCH"] = "Alexa.Media.Search";
AlexaInterfaceType["MODE_CONTROLLER"] = "Alexa.ModeController";
AlexaInterfaceType["MOTION_SENSOR"] = "Alexa.MotionSensor";
AlexaInterfaceType["PERCENTAGE_CONTROLLER"] = "Alexa.PercentageController";
AlexaInterfaceType["PLAYBACK_CONTROLLER"] = "Alexa.PlaybackController";
AlexaInterfaceType["PLAYBACK_STATE_REPORTER"] = "Alexa.PlaybackStateReporter";
AlexaInterfaceType["POWER_CONTROLLER"] = "Alexa.PowerController";
AlexaInterfaceType["POWER_LEVEL_CONTROLLER"] = "Alexa.PowerLevelController";
AlexaInterfaceType["PROACTIVE_NOTIFICATION_SOURCE"] = "Alexa.ProactiveNotificationSource";
AlexaInterfaceType["RANGE_CONTROLLER"] = "Alexa.RangeController";
AlexaInterfaceType["RECORD_CONTROLLER"] = "Alexa.RecordController";
AlexaInterfaceType["REMOTE_VIDEO_PLAYER"] = "Alexa.RemoteVideoPlayer";
AlexaInterfaceType["RTC_SESSION_CONTROLLER"] = "Alexa.RTCSessionController";
AlexaInterfaceType["SCENE_CONTROLLER"] = "Alexa.SceneController";
AlexaInterfaceType["SECURITY_PANEL_CONTROLLER"] = "Alexa.SecurityPanelController";
AlexaInterfaceType["SECURITY_PANEL_CONTROLLER_ALERT"] = "Alexa.SecurityPanelController.Alert";
AlexaInterfaceType["SEEK_CONTROLLER"] = "Alexa.SeekController";
AlexaInterfaceType["SIMPLE_EVENT_SOURCE"] = "Alexa.SimpleEventSource";
AlexaInterfaceType["SMART_VISION_OBJECT_DETECTION_SENSOR"] = "Alexa.SmartVision.ObjectDetectionSensor";
AlexaInterfaceType["SMART_VISION_SNAPSHOT_PROVIDER"] = "Alexa.SmartVision.SnapshotProvider";
AlexaInterfaceType["SPEAKER"] = "Alexa.Speaker";
AlexaInterfaceType["STEP_SPEAKER"] = "Alexa.StepSpeaker";
AlexaInterfaceType["TEMPERATURE_SENSOR"] = "Alexa.TemperatureSensor";
AlexaInterfaceType["THERMOSTAT_CONTROLLER"] = "Alexa.ThermostatController";
AlexaInterfaceType["THERMOSTAT_CONTROLLER_CONFIGURATION"] = "Alexa.ThermostatController.Configuration";
AlexaInterfaceType["THERMOSTAT_CONTROLLER_HVAC_COMPONENTS"] = "Alexa.ThermostatController.HVAC.Components";
AlexaInterfaceType["THERMOSTAT_CONTROLLER_SCHEDULE"] = "Alexa.ThermostatController.Schedule";
AlexaInterfaceType["TIME_HOLD_CONTROLLER"] = "Alexa.TimeHoldController";
AlexaInterfaceType["TOGGLE_CONTROLLER"] = "Alexa.ToggleController";
AlexaInterfaceType["UI_CONTROLLER"] = "Alexa.UIController";
AlexaInterfaceType["USER_PREFERENCE"] = "Alexa.UserPreference";
AlexaInterfaceType["VIDEO_RECORDER"] = "Alexa.VideoRecorder";
AlexaInterfaceType["WAKE_ON_LAN_CONTROLLER"] = "Alexa.WakeOnLANController";
AlexaInterfaceType["UNKNOWN"] = "UNKNOWN";
})(AlexaInterfaceType || (AlexaInterfaceType = {}));
export class AlexaInterface {
constructor(type, retrievable = true, proactivelyReported = false, instance = "") {
this.type = type;
this.retrievable = retrievable;
this.proactivelyReported = proactivelyReported;
this.instance = instance;
this.friendlyNames = [];
this.actionMappings = [];
this.supportedModes = [];
}
addActionMapping(mapping) {
this.actionMappings.push(mapping);
}
addFriendlyName(name, locale) {
this.friendlyNames.push({ text: name, locale });
}
/** The modes of a ModeController: { value, modeResources } objects as Alexa wants them (plain strings pass through as given). */
addSupportedModes(modes) {
this.supportedModes = modes;
}
setInstance(name) {
this.instance = name;
}
getType() {
return this.type;
}
getTypeString() {
return this.type;
}
/** The version of the interface, from its descriptor. "UNKNOWN" for a name the registry does not have. */
getVersion() {
return registry.has(this.type) ? registry.get(this.type).version : "UNKNOWN";
}
/** The names of the properties the interface reports, from its descriptor. */
getProps() {
return registry.has(this.type) ? Object.keys(registry.get(this.type).properties) : [];
}
getJSON() {
const doc = {
interface: this.getTypeString(),
version: this.getVersion(),
type: "AlexaInterface",
properties: {
retrievable: this.retrievable,
proactivelyReported: this.proactivelyReported,
supported: this.getProps().map(name => ({ name })),
},
};
if (this.type == AlexaInterfaceType.SCENE_CONTROLLER) {
// Alexa.SceneController v3 carries no properties block: supportsDeactivation + proactivelyReported at the top level
delete doc.properties;
doc.supportsDeactivation = true;
doc.proactivelyReported = this.proactivelyReported;
}
else if (this.type == AlexaInterfaceType.THERMOSTAT_CONTROLLER) {
doc.configuration = {
"supportedModes": ["HEAT", "COOL", "AUTO", "OFF"],
"supportsScheduling": false
};
}
else if (this.supportedModes.length > 0) {
doc["configuration"] = {
ordered: true,
supportedModes: this.supportedModes,
};
}
if (this.instance) {
doc.instance = this.instance;
}
if (this.friendlyNames.length > 0) {
const capabilityResources = doc["capabilityResources"] || {};
const friendlyNamesArray = capabilityResources["friendlyNames"] || [];
this.friendlyNames.forEach((fn) => {
const friendlyNameObj = { "@type": "text" };
friendlyNameObj["value"] = {
text: fn.text,
locale: fn.locale,
};
friendlyNamesArray.push(friendlyNameObj);
});
capabilityResources.friendlyNames = friendlyNamesArray;
doc["capabilityResources"] = capabilityResources;
}
if (this.actionMappings.length > 0) {
doc.semantics = {
actionMappings: this.actionMappings.map((am) => am.toJSON()),
};
}
return doc;
}
}