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

83
dist/esm/Alex2Node.js vendored
View file

@ -1,10 +1,16 @@
// mqtt reaches Node as CommonJS: the default import works from both builds, a named one needs Node to detect it.
import mqtt from "mqtt";
import Device from "./Device.js";
import Device from "./device/Device.js";
import { checkEndpoint } from "./device/validate.js";
import { EventEmitter } from "events";
import { DisplayCategory } from "./DisplayCategory.js";
import { AlexaInterfaceType } from "./AlexaInterface.js";
import { DisplayCategory } from "./compat/enums.js";
import { DeclarationError } from "./registry/types.js";
export const DEFAULT_HOST = "mqtt://Alex2MQTT.stormysdream.club:1883";
// What addDevice() copies from the definition to the device
const DESCRIBED = [
"description", "manufacturerName", "manufacturer", "model", "serialNumber", "firmwareVersion", "softwareVersion",
"customIdentifier", "cookie",
];
/**
* The Alexa-to-MQTT bridge: one broker connection for a user's root topic, a set of registered devices, discovery and
* directive dispatch.
@ -28,6 +34,8 @@ class Alex2MQTT extends EventEmitter {
this.debugLogging = debugLogging;
this.client = null;
this.devices = [];
// The lines of check() that were logged. Discovery comes every few minutes: a line is logged once.
this.logged = new Set();
/** true while the broker connection is up. */
this.connected = false;
/** ISO time of the last discovery request answered, null before the first. */
@ -84,12 +92,8 @@ class Alex2MQTT extends EventEmitter {
this.log(`MQTT Message Received`, { topic, payload: message.toString() });
if (topic == `${this.rootTopic}/discover`) {
this.log("Discovery request received, getting device json...");
const deviceArray = this.devices.map((device) => device.getJSON());
const deviceArray = this.describeDevices();
this.lastDiscoveryAt = new Date().toISOString();
for (const device of this.devices) // a capability the library has no property list for goes out with supported: []
for (const cap of device.getCapabilities())
if (cap.getProps().length === 0 && cap.getType() !== AlexaInterfaceType.SCENE_CONTROLLER)
this.log(`${device.endpointId}: no property list known for ${cap.getTypeString()}, discovery lists it with no supported properties`);
this.client.publish(topic + "_r", JSON.stringify(deviceArray), (err) => {
if (err) {
this.fail(err);
@ -130,6 +134,26 @@ class Alex2MQTT extends EventEmitter {
}
});
}
// The endpoint objects of a discovery answer. What check() says about a device is logged, each line once. A device
// that cannot be described is left out and reported as an error: the others are still announced.
describeDevices() {
const endpoints = [];
for (const device of this.devices) {
try {
endpoints.push(device.getJSON());
for (const line of device.check()) {
if (this.logged.has(line))
continue;
this.logged.add(line);
this.log(`warning: ${line}`);
}
}
catch (err) {
this.fail(new Error(`${device.endpointId} is not in the discovery answer: ${err instanceof Error ? err.message : err}`));
}
}
return endpoints;
}
/** Close the broker connection (resolves once closed). The devices stay registered; connect() again reuses them. */
disconnect() {
return new Promise((resolve) => {
@ -141,6 +165,36 @@ class Alex2MQTT extends EventEmitter {
c.end(true, {}, () => resolve());
});
}
/**
* Declare a device:
*
* const blinds = bridge.addDevice({ endpointId: "bedroom-blinds", name: "Bedroom Blinds",
* categories: ["INTERIOR_BLIND"], manufacturerName: "Acme", description: "Roller blind by Acme" });
*
* Throws a DeclarationError when Alexa would reject the endpoint (an endpointId with a slash, a name with
* punctuation) or when the endpointId is registered already. Discovery lists Alexa.EndpointHealth for the device
* unless endpointHealth is false.
*/
addDevice(definition) {
if (!this.client) {
throw new Error("Must call connect before creating devices");
}
const { endpointId, name, categories, endpointHealth, ...described } = definition;
// Without categories the device would take LIGHT, the default of registerDevice: here it is a mistake
const device = new Device(this.client, this.rootTopic, name, endpointId, (categories ?? []));
for (const [field, value] of Object.entries(described)) {
if (!DESCRIBED.includes(field))
throw new DeclarationError({ endpointId }, `${field} is not a field of an endpoint`);
if (value !== undefined)
Object.assign(device, { [field]: value });
}
checkEndpoint(device.getJSON());
const existing = this.getDevice(endpointId);
if (existing)
throw new DeclarationError({ endpointId }, `is registered already, as "${existing.name}"`);
this.register(device, endpointHealth !== false);
return device;
}
registerDevice(name, endpointId, displayCategory) {
if (!this.client) {
throw new Error("Must call connect before creating devices");
@ -155,15 +209,16 @@ class Alex2MQTT extends EventEmitter {
return existing;
}
this.log(`Creating new device with endpoint: ${endpointId}`);
const normalizedCategory = displayCategory === null
? [DisplayCategory.LIGHT]
: Array.isArray(displayCategory)
? displayCategory
: [displayCategory || DisplayCategory.LIGHT];
const normalizedCategory = Array.isArray(displayCategory) ? displayCategory : [displayCategory || DisplayCategory.LIGHT];
const device = new Device(this.client, this.rootTopic, name, endpointId, normalizedCategory);
this.register(device, false);
return device;
}
register(device, endpointHealth) {
device.alexaInterface = this.options.alexaInterface !== false;
device.endpointHealth = endpointHealth;
device.onPublishError = (err) => this.fail(err); // a failed publish is an "error" event (when listened to), never a rejected send()
this.devices.push(device);
return device;
}
/** Forget a device (its listeners with it). Returns false when there was none. */
unregisterDevice(endpointId) {