proactiveEvent() built every proactive event without a context, so doorbellPress() and
device.raise(DoorbellEventSource, "DoorbellPress") left out the "context": {} that the example of
alexa-doorbelleventsource.html has. The Event of Alexa.SimpleEventSource is printed without one, so
the event descriptor says which it is (emptyContext) and raise() passes it on to the builder.
A raised DoorbellPress is now { event, context: {} }, 13 bytes longer; the Event of a button is
unchanged. The tests compare the keys and the context of the built message with the fixture of the
page, for the builder, the descriptor and raise(). 267 tests pass.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
349 lines
16 KiB
JavaScript
349 lines
16 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 { MessageError, proactiveEvent, sceneEvent } from "../messages/build.js";
|
|
import { Alexa } from "../registry/interfaces/Alexa.js";
|
|
import { EndpointHealth } from "../registry/interfaces/EndpointHealth.js";
|
|
import { SceneController } from "../registry/interfaces/SceneController.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);
|
|
}
|
|
/**
|
|
* Say that something happened on the device, which nobody asked for:
|
|
*
|
|
* door.raise(DoorbellEventSource, "DoorbellPress");
|
|
* remote.raise(SimpleEventSource, "Event", { id: "Button.SinglePush.1" }, { instance: topButton.instance });
|
|
*
|
|
* The event goes to <root>/event, which Alex2MQTT posts to Alexa: up to 30 events a minute for a root. The
|
|
* payload is checked by the descriptor, which also sets the time of the event to now and its cause to the usual
|
|
* one when the payload has none. Resolves with what became of the publish and does not reject.
|
|
*
|
|
* Throws a MessageError for an event that would not arrive: of an interface or an instance the device did not
|
|
* declare, not an event of the interface, with a payload that does not fit, or too long for Alex2MQTT.
|
|
*/
|
|
raise(descriptor, name, payload = {}, options = {}) {
|
|
const { endpointId } = this;
|
|
const { namespace } = descriptor;
|
|
const { instance = "", messageId } = options;
|
|
const refuse = (problem) => {
|
|
throw new MessageError(endpointId, `${namespace}.${name} was not raised: ${problem}`);
|
|
};
|
|
const capability = this.capability(namespace, instance);
|
|
if (!capability) {
|
|
const declared = instance ? `the instance ${JSON.stringify(instance)} of ${namespace}` : namespace;
|
|
return refuse(`the device did not declare ${declared}. Declare it with add() first${descriptor.instanced && !instance ? ", and pass the instance that raises the event" : ""}`);
|
|
}
|
|
const events = capability.descriptor.events ?? {};
|
|
const event = events[name];
|
|
if (!event)
|
|
return refuse(`it is not an event of the interface, which has ${Object.keys(events).join(", ") || "none"}`);
|
|
if (event.topic !== "proactive")
|
|
return refuse("it answers a directive. Send it with respond() in the handler of the directive");
|
|
let checked;
|
|
try {
|
|
checked = event.payload.parse(payload, "payload");
|
|
}
|
|
catch (err) {
|
|
if (err instanceof SchemaError)
|
|
return refuse(err.message);
|
|
throw err;
|
|
}
|
|
const message = proactiveEvent({
|
|
endpointId,
|
|
messageId,
|
|
namespace: event.namespace ?? namespace,
|
|
name,
|
|
instance,
|
|
payloadVersion: event.payloadVersion ?? capability.descriptor.version,
|
|
payload: checked,
|
|
emptyContext: event.emptyContext,
|
|
});
|
|
const bytes = Buffer.byteLength(JSON.stringify(message));
|
|
if (bytes > topics.EVENT_BYTES)
|
|
return refuse(`it is ${bytes} bytes as JSON, Alex2MQTT takes ${topics.EVENT_BYTES}`);
|
|
const topic = topics.event(this.rootTopic);
|
|
const unpublished = new Error(`nothing was published to ${topic}: the device is on no bridge, register it with addDevice() or registerDevice()`);
|
|
const published = this.publisher
|
|
? this.publisher.publish(topic, message)
|
|
: Promise.resolve({ ok: false, topic, error: unpublished });
|
|
return published.then((result) => {
|
|
if (!result.ok)
|
|
this.onPublishError?.(result.error);
|
|
return result;
|
|
});
|
|
}
|
|
/**
|
|
* How the device reports its state, every retrievable property of it:
|
|
*
|
|
* blinds.state((s) => s.set(lift, "rangeValue", motor.position).health("OK"));
|
|
*
|
|
* ReportState is answered with it, and it is the context of every ctx.respond(), where the handler sets only what
|
|
* the directive changed. Alexa wants the whole state in both (alexa-response.html, "Synchronous response").
|
|
*/
|
|
state(fill) {
|
|
this.stateProvider = fill;
|
|
return this;
|
|
}
|
|
/** Answer ReportState in a handler of its own, for a state that has to be read from the device first. */
|
|
onReportState(handler) {
|
|
this.reportStateHandler = handler;
|
|
return this;
|
|
}
|
|
/** The handler for the directives of declared capabilities that have no handler of their own. */
|
|
onDirective(handler) {
|
|
this.directiveHandler = handler;
|
|
return this;
|
|
}
|
|
/** The capability declared for the interface, under the instance for a generic controller. */
|
|
capability(namespace, instance = "") {
|
|
return this.capabilities.find((capability) => capability.namespace === namespace && capability.instance === instance);
|
|
}
|
|
/** 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(SceneController.namespace)) {
|
|
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;
|