src/transport.ts has the Publisher interface, MqttPublisher (the client the bridge has at the time) and MemoryPublisher (tests without a broker); src/topics.ts names the four topics the library publishes to. Device, AlexaStatusMessage, AlexaErrorResponse and sendSceneResponse shared three copies of the publish-and-report code: they now call one send() that resolves the topic or "" and never rejects. registerDevice() and addDevice() work before connect(); a send() without a connection resolves "" and the "error" event says to call connect(). unregisterDevice() and clearDevices() take the publisher from the device. Device.setMqttClient() is gone, the first constructor argument of Device is ignored, and the message classes take a Publisher where they took the client. Tests: 108 -> 113. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
258 lines
12 KiB
JavaScript
258 lines
12 KiB
JavaScript
import { EventEmitter } from "events";
|
|
import { AlexaErrorResponse } from "../compat/AlexaErrorResponse.js";
|
|
import { AlexaInterface } from "../compat/AlexaInterface.js";
|
|
import { AlexaStatusMessage } from "../compat/AlexaStatusMessage.js";
|
|
import { DisplayCategory } from "../compat/enums.js";
|
|
import { sceneEvent } from "../messages/build.js";
|
|
import { Alexa } from "../registry/interfaces/Alexa.js";
|
|
import { EndpointHealth } from "../registry/interfaces/EndpointHealth.js";
|
|
import { SchemaError } from "../registry/schema.js";
|
|
import { DeclarationError } from "../registry/types.js";
|
|
import * as topics from "../topics.js";
|
|
import { send } from "../transport.js";
|
|
import { Capability, commonOptions } from "./Capability.js";
|
|
import { checkCapability, checkCapabilityCount, checkEndpoint } from "./validate.js";
|
|
class Device extends EventEmitter {
|
|
/** client is ignored: in 1.x it was the broker client, and a device could only be built after connect(). */
|
|
constructor(client, rootTopic, name, endpointId, displayCategory, description = "Alexa to Node.js bridge", manufacturerName = "Alex2Node", manufacturer = "Alex2Node", model = "Alex2Node_v1.0.0") {
|
|
super();
|
|
this.rootTopic = rootTopic;
|
|
this.name = name;
|
|
this.endpointId = endpointId;
|
|
this.displayCategory = displayCategory;
|
|
this.description = description;
|
|
this.manufacturerName = manufacturerName;
|
|
this.manufacturer = manufacturer;
|
|
this.model = model;
|
|
this.softwareVersion = "1.0.0";
|
|
this.serialNumber = "Alex2Node";
|
|
this.firmwareVersion = "1.0.0";
|
|
this.customIdentifier = "Alex2Node";
|
|
/**
|
|
* Discovery lists the Alexa interface on the endpoint (alexa-interface.html: "You must explicitly identify your
|
|
* support for the Alexa interface"). The bridge sets it from its alexaInterface option.
|
|
*/
|
|
this.alexaInterface = true;
|
|
/**
|
|
* Discovery lists Alexa.EndpointHealth when the device did not declare it and is not a scene. On for a device of
|
|
* bridge.addDevice(), off for one of registerDevice(), which is announced as 1.x announced it.
|
|
*/
|
|
this.endpointHealth = false;
|
|
this.capabilities = [];
|
|
/**
|
|
* What the device and the messages it builds publish through. The bridge sets it when the device is registered
|
|
* and clears it when the device is unregistered; a MemoryPublisher here tests a device without a broker. While it
|
|
* is null every send() resolves "" and reports why.
|
|
*/
|
|
this.publisher = null;
|
|
}
|
|
getName() {
|
|
return this.name;
|
|
}
|
|
setName(name) {
|
|
this.name = name;
|
|
}
|
|
getEndpointId() {
|
|
return this.endpointId;
|
|
}
|
|
setDisplayCategory(category) {
|
|
this.displayCategory = Array.isArray(category) ? category : [category];
|
|
}
|
|
getDisplayCategory() {
|
|
return this.displayCategory || [DisplayCategory.LIGHT];
|
|
}
|
|
setDescription(description) {
|
|
this.description = description;
|
|
}
|
|
getDescription() {
|
|
return this.description;
|
|
}
|
|
getErrorMessage(correlationToken) {
|
|
const msg = new AlexaErrorResponse(correlationToken, this.rootTopic, this.endpointId, this.publisher);
|
|
msg.onPublishError = this.onPublishError;
|
|
return msg;
|
|
}
|
|
getStatusMessage(correlationToken, isResponse = false, isDeferred = false) {
|
|
const msg = new AlexaStatusMessage(correlationToken, this.rootTopic, this.endpointId, this.publisher, isResponse, isDeferred);
|
|
msg.onPublishError = this.onPublishError;
|
|
return msg;
|
|
}
|
|
/**
|
|
* A proactive Alexa.ChangeReport (1.5.1): add the changed properties (the default target), optionally
|
|
* .unchanged() then the others, and .send() - it goes to <root>/changeReport, which Alex2MQTT forwards to the Alexa
|
|
* event gateway with the user's token. Alexa only accepts it for capabilities registered with proactivelyReported.
|
|
* Without a changed property send() publishes nothing and resolves with "": Alex2MQTT would drop the report.
|
|
*/
|
|
getChangeReport(cause = "PHYSICAL_INTERACTION") {
|
|
const msg = new AlexaStatusMessage("", this.rootTopic, this.endpointId, this.publisher, false, false, cause);
|
|
msg.onPublishError = this.onPublishError;
|
|
return msg;
|
|
}
|
|
/**
|
|
* Alexa.SceneController: answer an Activate / Deactivate directive with ActivationStarted / DeactivationStarted
|
|
* (1.5.1). Resolves with the topic published to, or "" when the publish failed (never rejects, 1.5.2).
|
|
*/
|
|
sendSceneResponse(correlationToken, activated, cause = "VOICE_INTERACTION", sendAsync = false) {
|
|
const answer = sendAsync ? topics.deferred : topics.response;
|
|
const build = () => sceneEvent({ endpointId: this.endpointId, correlationToken, activated, cause });
|
|
return send(this.publisher, answer(this.rootTopic, this.endpointId), build, this.onPublishError);
|
|
}
|
|
/** The capabilities the device declared, in the order it declared them. */
|
|
getCapabilities() {
|
|
return this.capabilities.slice();
|
|
}
|
|
setManufacturerName(name) {
|
|
this.manufacturerName = name;
|
|
}
|
|
getManufacturerName() {
|
|
return this.manufacturerName;
|
|
}
|
|
setManufacturer(manufacturer) {
|
|
this.manufacturer = manufacturer;
|
|
}
|
|
getManufacturer() {
|
|
return this.manufacturer;
|
|
}
|
|
setModel(model) {
|
|
this.model = model;
|
|
}
|
|
getModel() {
|
|
return this.model;
|
|
}
|
|
getSoftwareVersion() {
|
|
return this.softwareVersion;
|
|
}
|
|
/**
|
|
* Declare an interface of the device:
|
|
*
|
|
* lamp.add(PowerController, { proactivelyReported: true });
|
|
* blinds.add(RangeController, { instance: "Blind.Lift", friendlyNames: [asset("Alexa.Setting.Opening")],
|
|
* range: { min: 0, max: 100, precision: 1 } });
|
|
*
|
|
* Throws a DeclarationError that names the endpoint, the interface and the instance when Alexa would reject the
|
|
* capability; the device is then as it was before the call.
|
|
*/
|
|
add(descriptor, ...[options]) {
|
|
const where = { endpointId: this.endpointId, namespace: descriptor.namespace, instance: "" };
|
|
if (options !== undefined && (typeof options !== "object" || options === null)) {
|
|
throw new DeclarationError(where, "the options of a declaration are an object");
|
|
}
|
|
try {
|
|
const { instance, friendlyNames, retrievable, proactivelyReported, nonControllable, ...given } = commonOptions.parse(options ?? {});
|
|
where.instance = instance ?? "";
|
|
const common = { instance, friendlyNames: friendlyNames, retrievable, proactivelyReported, nonControllable };
|
|
const capability = new Capability(descriptor, { ...common, options: interfaceOptions(descriptor, given) });
|
|
capability.endpointId = this.endpointId;
|
|
checkCapability(capability, descriptor, this.view(this.capabilities));
|
|
checkCapabilityCount(this.endpointId, this.announced([...this.capabilities, capability]).length);
|
|
this.capabilities.push(capability);
|
|
return capability;
|
|
}
|
|
catch (err) {
|
|
throw err instanceof SchemaError ? new DeclarationError(where, err.message) : err;
|
|
}
|
|
}
|
|
/**
|
|
* Declare an interface the 1.x way, by its name: options.retrievable / proactivelyReported / instance, then the
|
|
* add and set methods of the capability that is returned. Throws for a name that is not an interface. Anything
|
|
* else Alexa would reject is not refused here: check() lists it.
|
|
*/
|
|
addCapability(type, options = {}) {
|
|
let capability;
|
|
try {
|
|
capability = new AlexaInterface(type, options.retrievable ?? true, options.proactivelyReported ?? false, options.instance ?? "");
|
|
}
|
|
catch (err) {
|
|
throw err instanceof DeclarationError ? new DeclarationError({ endpointId: this.endpointId }, err.problem) : err;
|
|
}
|
|
capability.endpointId = this.endpointId;
|
|
this.capabilities.push(capability);
|
|
return capability;
|
|
}
|
|
/**
|
|
* What Alexa would reject in the device as it is declared now, one line for each: a name with punctuation, an
|
|
* interface declared twice. Also what a capability noted about its declaration. Empty when there is nothing.
|
|
* The bridge logs the lines when it answers a discovery.
|
|
*/
|
|
check() {
|
|
const lines = [];
|
|
const attempt = (rule) => {
|
|
try {
|
|
rule();
|
|
}
|
|
catch (err) {
|
|
if (!(err instanceof DeclarationError))
|
|
throw err;
|
|
lines.push(err.message);
|
|
}
|
|
};
|
|
attempt(() => checkEndpoint(this.fields()));
|
|
this.capabilities.forEach((capability, i) => {
|
|
capability.endpointId = this.endpointId;
|
|
attempt(() => checkCapability(capability, capability.descriptor, this.view(this.capabilities.slice(0, i))));
|
|
const notes = [...capability.notes];
|
|
const { tier, properties } = capability.descriptor;
|
|
if (tier === 3 && Object.keys(properties).length === 0 && capability.toJSON().properties) {
|
|
notes.push("no property list known, discovery lists it with no supported properties");
|
|
}
|
|
for (const note of notes)
|
|
lines.push(new DeclarationError(capability, note).message);
|
|
});
|
|
attempt(() => checkCapabilityCount(this.endpointId, this.announced(this.capabilities).length));
|
|
return lines;
|
|
}
|
|
/** The endpoint object for discovery. */
|
|
getJSON() {
|
|
return { ...this.fields(), capabilities: this.announced(this.capabilities).map((capability) => capability.toJSON()) };
|
|
}
|
|
fields() {
|
|
return {
|
|
endpointId: this.endpointId,
|
|
friendlyName: this.name,
|
|
description: this.description,
|
|
manufacturerName: this.manufacturerName,
|
|
displayCategories: this.getDisplayCategory(),
|
|
additionalAttributes: {
|
|
manufacturer: this.manufacturer,
|
|
model: this.model,
|
|
serialNumber: this.serialNumber,
|
|
firmwareVersion: this.firmwareVersion,
|
|
softwareVersion: this.softwareVersion,
|
|
customIdentifier: this.customIdentifier,
|
|
},
|
|
...(this.cookie ? { cookie: this.cookie } : {}),
|
|
};
|
|
}
|
|
// The endpoint as the rules of a capability see it
|
|
view(declaredBefore) {
|
|
return {
|
|
endpointId: this.endpointId,
|
|
friendlyName: this.name,
|
|
description: this.description,
|
|
displayCategories: this.getDisplayCategory(),
|
|
capabilities: declaredBefore,
|
|
};
|
|
}
|
|
// What discovery lists: the declared capabilities, then the ones the library adds
|
|
announced(declared) {
|
|
const has = (namespace) => declared.some((capability) => capability.namespace === namespace);
|
|
const added = [];
|
|
// alexa-scenecontroller.html, "Discovery": a scene is not a physical device and has no Alexa.EndpointHealth
|
|
if (this.endpointHealth && !has(EndpointHealth.namespace) && !has("Alexa.SceneController")) {
|
|
added.push(new Capability(EndpointHealth, { options: {} }));
|
|
}
|
|
if (this.alexaInterface && !has(Alexa.namespace))
|
|
added.push(new Capability(Alexa, { options: {} }));
|
|
return [...declared, ...added];
|
|
}
|
|
}
|
|
// The options of the interface itself, checked by the schema of its descriptor
|
|
function interfaceOptions(descriptor, given) {
|
|
if (descriptor.options)
|
|
return descriptor.options.parse(given);
|
|
const [unknown] = Object.keys(given);
|
|
if (unknown !== undefined)
|
|
throw new SchemaError(unknown, "not an option of the interface");
|
|
return {};
|
|
}
|
|
export default Device;
|