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:
parent
aa0ffd64ea
commit
c492ef74d1
90 changed files with 5539 additions and 1760 deletions
83
dist/esm/Alex2Node.js
vendored
83
dist/esm/Alex2Node.js
vendored
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue