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

@ -2,12 +2,12 @@
// dist/types, resolved through the package's "exports". A "@ts-expect-error" line fails the compile when the
// declaration stops rejecting what follows it.
import {
ActionMapping, AlexaActions, AlexaInterfaceType, Assets, DeclarationError, DisplayCategory, EndpointHealth, PowerController,
registry,
ActionMapping, AlexaActions, AlexaInterfaceType, Assets, BrightnessController, DeclarationError, DisplayCategory, EndpointHealth,
PowerController, PowerState, TemperatureSensor, asset, registry, text,
} from "alex2node";
import type {
Alex2MQTT, Alex2MQTTOptions, AlexaInterface, AlexaStatusMessage, AssetId, ChangeCause, Device, DisplayCategoryName, Infer,
InterfaceDescriptor, Schema, SupportedMode, Temperature, UnitOfMeasure,
Alex2MQTT, Alex2MQTTOptions, AlexaInterface, AlexaStatusMessage, AssetId, Capability, CapabilityJson, ChangeCause, Device,
DisplayCategoryName, EndpointJson, Infer, InterfaceDescriptor, Label, Schema, SupportedMode, Temperature, UnitOfMeasure,
} from "alex2node";
declare const bridge: Alex2MQTT;
@ -18,9 +18,12 @@ const options: Alex2MQTTOptions = { host: "mqtt://127.0.0.1:1883", mqtt: { recon
const device: Device = bridge.registerDevice("Lamp", "lamp-1", [DisplayCategory.LIGHT, DisplayCategory.SWITCH]);
const added: AlexaInterface = device.addCapability(AlexaInterfaceType.POWER_CONTROLLER, { proactivelyReported: true });
// ActionMapping: the payload is optional
// ActionMapping: the payload is optional, and an object
const close = new ActionMapping([AlexaActions.Close], "TurnOn");
capability.addActionMapping(close);
capability.addActionMapping(new ActionMapping([AlexaActions.Open], "SetRangeValue", { rangeValue: 100 }));
// @ts-expect-error a payload is an object or, as in 1.x, a string
capability.addActionMapping(new ActionMapping([AlexaActions.Open], "SetRangeValue", 100));
// addHealthProp: the enum or its string values, chainable
const chained: AlexaStatusMessage = message.addHealthProp(EndpointHealth.OK).addHealthProp("UNREACHABLE", 50);
@ -29,6 +32,10 @@ message.addHealthProp("ASLEEP");
// @ts-expect-error a power state is ON or OFF
message.addPowerControllerProp("ON ");
message.addPowerControllerProp(PowerController.ON);
// PowerController is the descriptor of the interface as well; the enum alone is PowerState
const on: PowerController = PowerState.ON;
const namespace: string = PowerController.namespace;
const reachable: EndpointHealth = EndpointHealth.OK;
// addSupportedModes: Mode objects, plain strings still accepted
const mode: SupportedMode = { value: "Fan.Auto", modeResources: { friendlyNames: [{ "@type": "text", value: { text: "Auto", locale: "en-US" } }] } };
@ -39,6 +46,34 @@ const sent: Promise<string> = device.getChangeReport(cause).addPowerControllerPr
// @ts-expect-error not a cause Alexa knows
device.getChangeReport("BUTTON");
// bridge.addDevice() and device.add(): the options of the interface and the ones every capability has
const blinds: Device = bridge.addDevice({
endpointId: "bedroom-blinds",
name: "Bedroom Blinds",
categories: ["INTERIOR_BLIND", DisplayCategory.OTHER],
cookie: { room: "bedroom" },
endpointHealth: false,
});
// @ts-expect-error an endpoint has categories
bridge.addDevice({ endpointId: "lamp-2", name: "Lamp" });
// @ts-expect-error not a display category
bridge.addDevice({ endpointId: "lamp-2", name: "Lamp", categories: ["LAMP"] });
const power: Capability = blinds.add(PowerController, { proactivelyReported: true, verificationsRequired: ["TurnOff"] });
blinds.add(BrightnessController);
blinds.add(TemperatureSensor, { retrievable: true, nonControllable: true });
// @ts-expect-error not a directive of the interface
blinds.add(PowerController, { verificationsRequired: ["Toggle"] });
// @ts-expect-error not an option of the interface
blinds.add(BrightnessController, { range: { min: 0, max: 100 } });
const announced: CapabilityJson = power.toJSON();
const endpoint: EndpointJson = blinds.getJSON();
const problems: string[] = blinds.check();
// Friendly names
const names: Label[] = [asset("Alexa.Setting.Opening"), text("Position"), text("Posición", "es-MX")];
// @ts-expect-error not in the global Alexa catalog
asset("Alexa.Setting.Openning");
// The registry: a descriptor by its namespace or by the 1.x enum member
const described: InterfaceDescriptor = registry.get("Alexa.PowerController");
const version: string = registry.get(AlexaInterfaceType.ENDPOINT_HEALTH).version;
@ -59,4 +94,7 @@ const measured: Infer<typeof temperature> = { value: 20, scale: "CELSIUS" };
// @ts-expect-error not a temperature scale
const rankine: Infer<typeof temperature> = { value: 20, scale: "RANKINE" };
export { options, added, chained, sent, version, refused, opening, percent, vacuum, misspelt, notAUnit, measured, rankine };
export {
options, added, chained, sent, on, namespace, reachable, announced, endpoint, problems, names, version, refused, opening,
percent, vacuum, misspelt, notAUnit, measured, rankine,
};