diff --git a/dist/cjs/device/Device.js b/dist/cjs/device/Device.js index 97d6a3d..aec0758 100644 --- a/dist/cjs/device/Device.js +++ b/dist/cjs/device/Device.js @@ -41,6 +41,7 @@ const enums_js_1 = require("../compat/enums.js"); const build_js_1 = require("../messages/build.js"); const Alexa_js_1 = require("../registry/interfaces/Alexa.js"); const EndpointHealth_js_1 = require("../registry/interfaces/EndpointHealth.js"); +const SceneController_js_1 = require("../registry/interfaces/SceneController.js"); const schema_js_1 = require("../registry/schema.js"); const types_js_1 = require("../registry/types.js"); const topics = __importStar(require("../topics.js")); @@ -132,6 +133,69 @@ class Device extends events_1.EventEmitter { const build = () => (0, build_js_1.sceneEvent)({ endpointId: this.endpointId, correlationToken, activated, cause }); return (0, transport_js_1.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 /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 build_js_1.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 schema_js_1.SchemaError) + return refuse(err.message); + throw err; + } + const message = (0, build_js_1.proactiveEvent)({ + endpointId, + messageId, + namespace: event.namespace ?? namespace, + name, + instance, + payloadVersion: event.payloadVersion ?? capability.descriptor.version, + payload: checked, + }); + 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: * @@ -299,7 +363,7 @@ class Device extends events_1.EventEmitter { 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_js_1.EndpointHealth.namespace) && !has("Alexa.SceneController")) { + if (this.endpointHealth && !has(EndpointHealth_js_1.EndpointHealth.namespace) && !has(SceneController_js_1.SceneController.namespace)) { added.push(new Capability_js_1.Capability(EndpointHealth_js_1.EndpointHealth, { options: {} })); } if (this.alexaInterface && !has(Alexa_js_1.Alexa.namespace)) diff --git a/dist/cjs/dispatcher.js b/dist/cjs/dispatcher.js index 9e5f340..bf626c8 100644 --- a/dist/cjs/dispatcher.js +++ b/dist/cjs/dispatcher.js @@ -93,7 +93,7 @@ class Context { } defer(estimatedDeferralInSeconds) { const { endpointId, correlationToken } = this; - if (this.capability && !this.capability.descriptor.deferrable) + if (this.capability && !this.capability.descriptor.deferrable && !this.deferredByAnother()) this.dispatcher.noteDeferral(this.namespace); // Always where the first answer goes: a second DeferredResponse is refused there const topic = topics.response(this.dispatcher.rootTopic, endpointId); @@ -111,6 +111,11 @@ class Context { const { type, alexaMessage, namespace } = error; return this.answer((0, build_js_1.errorResponse)({ endpointId, correlationToken, type, message: alexaMessage, extra: error.extra, namespace })); } + // An interface of the device documents a DeferredResponse for this directive of another one + deferredByAnother() { + const { namespace, name } = this; + return this.device.getCapabilities().some(({ descriptor }) => descriptor.defers?.some((directive) => directive.namespace === namespace && directive.name === name)); + } // The state of the device, and over it what the handler says about this directive state(fill) { const whole = new StateBuilder_js_1.StateBuilder(); diff --git a/dist/cjs/index.js b/dist/cjs/index.js index 9a2944b..a6e404a 100644 --- a/dist/cjs/index.js +++ b/dist/cjs/index.js @@ -36,8 +36,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); -exports.property = exports.StateBuilder = exports.MessageError = exports.AlexaErrors = exports.AlexaError = exports.messages = exports.ThermostatModes = exports.Inputs = exports.DisplayCategories = exports.States = exports.Actions = exports.Units = exports.Assets = exports.SemanticsBuilder = exports.semantics = exports.text = exports.asset = exports.EndpointHealth = exports.PowerController = exports.ToggleController = exports.ThermostatControllerSchedule = exports.ThermostatController = exports.TemperatureSensor = exports.StepSpeaker = exports.Speaker = exports.SecurityPanelController = exports.RangeController = exports.PowerLevelController = exports.PlaybackStateReporter = exports.PlaybackController = exports.PercentageController = exports.MotionSensor = exports.ModeController = exports.LockController = exports.InputController = exports.HumiditySensor = exports.EqualizerController = exports.ContactSensor = exports.ColorTemperatureController = exports.ColorController = exports.ChannelController = exports.BrightnessController = exports.Alexa = exports.SchemaError = exports.DeclarationError = exports.registry = exports.Capability = exports.Device = exports.DEFAULT_HOST = exports.Alex2MQTT = void 0; -exports.AlexaErrorResponse = exports.AlexaStatusMessage = exports.ThermostatMode = exports.TemperatureSensorScale = exports.PowerState = exports.DisplayCategory = exports.AlexaInterfaceType = exports.AlexaErrorType = exports.AlexaActions = exports.ActionMapping = exports.AlexaInterface = exports.MemoryPublisher = exports.topics = void 0; +exports.Inputs = exports.DisplayCategories = exports.Causes = exports.States = exports.Actions = exports.Units = exports.Assets = exports.SemanticsBuilder = exports.semantics = exports.text = exports.asset = exports.EndpointHealth = exports.PowerController = exports.WakeOnLANController = exports.ToggleController = exports.TimeHoldController = exports.ThermostatControllerSchedule = exports.ThermostatController = exports.TemperatureSensor = exports.StepSpeaker = exports.Speaker = exports.SimpleEventSource = exports.SecurityPanelController = exports.SceneController = exports.RangeController = exports.PowerLevelController = exports.PlaybackStateReporter = exports.PlaybackController = exports.PercentageController = exports.MotionSensor = exports.ModeController = exports.LockController = exports.InventoryLevelSensor = exports.InputController = exports.HumiditySensor = exports.EqualizerController = exports.DoorbellEventSource = exports.ContactSensor = exports.ColorTemperatureController = exports.ColorController = exports.ChannelController = exports.BrightnessController = exports.Alexa = exports.SchemaError = exports.DeclarationError = exports.registry = exports.Capability = exports.Device = exports.DEFAULT_HOST = exports.Alex2MQTT = void 0; +exports.AlexaErrorResponse = exports.AlexaStatusMessage = exports.ThermostatMode = exports.TemperatureSensorScale = exports.PowerState = exports.DisplayCategory = exports.AlexaInterfaceType = exports.AlexaErrorType = exports.AlexaActions = exports.ActionMapping = exports.AlexaInterface = exports.MemoryPublisher = exports.topics = exports.property = exports.StateBuilder = exports.MessageError = exports.AlexaErrors = exports.AlexaError = exports.messages = exports.ThermostatModes = void 0; // Relative specifiers carry ".js": Node's ES module loader resolves no extension, and TypeScript maps it back to the .ts. var Alex2Node_js_1 = require("./Alex2Node.js"); Object.defineProperty(exports, "Alex2MQTT", { enumerable: true, get: function () { return __importDefault(Alex2Node_js_1).default; } }); @@ -58,9 +58,11 @@ Object.defineProperty(exports, "ChannelController", { enumerable: true, get: fun Object.defineProperty(exports, "ColorController", { enumerable: true, get: function () { return index_js_2.ColorController; } }); Object.defineProperty(exports, "ColorTemperatureController", { enumerable: true, get: function () { return index_js_2.ColorTemperatureController; } }); Object.defineProperty(exports, "ContactSensor", { enumerable: true, get: function () { return index_js_2.ContactSensor; } }); +Object.defineProperty(exports, "DoorbellEventSource", { enumerable: true, get: function () { return index_js_2.DoorbellEventSource; } }); Object.defineProperty(exports, "EqualizerController", { enumerable: true, get: function () { return index_js_2.EqualizerController; } }); Object.defineProperty(exports, "HumiditySensor", { enumerable: true, get: function () { return index_js_2.HumiditySensor; } }); Object.defineProperty(exports, "InputController", { enumerable: true, get: function () { return index_js_2.InputController; } }); +Object.defineProperty(exports, "InventoryLevelSensor", { enumerable: true, get: function () { return index_js_2.InventoryLevelSensor; } }); Object.defineProperty(exports, "LockController", { enumerable: true, get: function () { return index_js_2.LockController; } }); Object.defineProperty(exports, "ModeController", { enumerable: true, get: function () { return index_js_2.ModeController; } }); Object.defineProperty(exports, "MotionSensor", { enumerable: true, get: function () { return index_js_2.MotionSensor; } }); @@ -69,13 +71,17 @@ Object.defineProperty(exports, "PlaybackController", { enumerable: true, get: fu Object.defineProperty(exports, "PlaybackStateReporter", { enumerable: true, get: function () { return index_js_2.PlaybackStateReporter; } }); Object.defineProperty(exports, "PowerLevelController", { enumerable: true, get: function () { return index_js_2.PowerLevelController; } }); Object.defineProperty(exports, "RangeController", { enumerable: true, get: function () { return index_js_2.RangeController; } }); +Object.defineProperty(exports, "SceneController", { enumerable: true, get: function () { return index_js_2.SceneController; } }); Object.defineProperty(exports, "SecurityPanelController", { enumerable: true, get: function () { return index_js_2.SecurityPanelController; } }); +Object.defineProperty(exports, "SimpleEventSource", { enumerable: true, get: function () { return index_js_2.SimpleEventSource; } }); Object.defineProperty(exports, "Speaker", { enumerable: true, get: function () { return index_js_2.Speaker; } }); Object.defineProperty(exports, "StepSpeaker", { enumerable: true, get: function () { return index_js_2.StepSpeaker; } }); Object.defineProperty(exports, "TemperatureSensor", { enumerable: true, get: function () { return index_js_2.TemperatureSensor; } }); Object.defineProperty(exports, "ThermostatController", { enumerable: true, get: function () { return index_js_2.ThermostatController; } }); Object.defineProperty(exports, "ThermostatControllerSchedule", { enumerable: true, get: function () { return index_js_2.ThermostatControllerSchedule; } }); +Object.defineProperty(exports, "TimeHoldController", { enumerable: true, get: function () { return index_js_2.TimeHoldController; } }); Object.defineProperty(exports, "ToggleController", { enumerable: true, get: function () { return index_js_2.ToggleController; } }); +Object.defineProperty(exports, "WakeOnLANController", { enumerable: true, get: function () { return index_js_2.WakeOnLANController; } }); var enums_js_1 = require("./compat/enums.js"); Object.defineProperty(exports, "PowerController", { enumerable: true, get: function () { return enums_js_1.PowerController; } }); Object.defineProperty(exports, "EndpointHealth", { enumerable: true, get: function () { return enums_js_1.EndpointHealth; } }); @@ -90,6 +96,7 @@ Object.defineProperty(exports, "Assets", { enumerable: true, get: function () { Object.defineProperty(exports, "Units", { enumerable: true, get: function () { return index_js_4.UNITS_OF_MEASURE; } }); Object.defineProperty(exports, "Actions", { enumerable: true, get: function () { return index_js_4.ACTIONS; } }); Object.defineProperty(exports, "States", { enumerable: true, get: function () { return index_js_4.STATES; } }); +Object.defineProperty(exports, "Causes", { enumerable: true, get: function () { return index_js_4.CAUSES; } }); Object.defineProperty(exports, "DisplayCategories", { enumerable: true, get: function () { return index_js_4.DISPLAY_CATEGORIES; } }); Object.defineProperty(exports, "Inputs", { enumerable: true, get: function () { return index_js_4.INPUTS; } }); Object.defineProperty(exports, "ThermostatModes", { enumerable: true, get: function () { return index_js_4.THERMOSTAT_MODES; } }); diff --git a/dist/cjs/messages/build.js b/dist/cjs/messages/build.js index a80af8f..161e227 100644 --- a/dist/cjs/messages/build.js +++ b/dist/cjs/messages/build.js @@ -7,6 +7,7 @@ exports.deferredResponse = deferredResponse; exports.errorResponse = errorResponse; exports.changeReport = changeReport; exports.sceneEvent = sceneEvent; +exports.proactiveEvent = proactiveEvent; exports.doorbellPress = doorbellPress; exports.simpleEvent = simpleEvent; // Every message the bridge publishes, built from plain values. Nothing here reads the clock or makes an id unless @@ -112,27 +113,26 @@ function sceneEvent(fields) { context: {}, }; } -/** Somebody rang (alexa-doorbelleventsource.html). */ -function doorbellPress(fields) { +/** An event a device raises by itself, of any interface. The header has no correlationToken: nobody asked. */ +function proactiveEvent(fields) { + const { namespace, name, instance, messageId, payloadVersion, payload } = fields; return { event: { - header: header("Alexa.DoorbellEventSource", "DoorbellPress", { messageId: fields.messageId }), + header: header(namespace, name, { instance, messageId, payloadVersion }), endpoint: { endpointId: fields.endpointId }, - payload: { cause: { type: fields.cause ?? "PHYSICAL_INTERACTION" }, timestamp: (0, property_js_1.isoTime)(fields.timestamp) }, + payload, }, }; } +/** Somebody rang (alexa-doorbelleventsource.html). */ +function doorbellPress(fields) { + const { endpointId, messageId } = fields; + const payload = { cause: { type: fields.cause ?? "PHYSICAL_INTERACTION" }, timestamp: (0, property_js_1.isoTime)(fields.timestamp) }; + return proactiveEvent({ endpointId, messageId, namespace: "Alexa.DoorbellEventSource", name: "DoorbellPress", payload }); +} /** An event of a button or a sensor that routines start on (alexa-simpleeventsource.html). */ function simpleEvent(fields) { - return { - event: { - header: header("Alexa.SimpleEventSource", "Event", { - instance: fields.instance, - messageId: fields.messageId, - payloadVersion: "1.0", - }), - endpoint: { endpointId: fields.endpointId }, - payload: { id: fields.id, timestamp: (0, property_js_1.isoTime)(fields.timestamp) }, - }, - }; + const { endpointId, messageId, instance } = fields; + const payload = { id: fields.id, timestamp: (0, property_js_1.isoTime)(fields.timestamp) }; + return proactiveEvent({ endpointId, messageId, instance, namespace: "Alexa.SimpleEventSource", name: "Event", payloadVersion: "1.0", payload }); } diff --git a/dist/cjs/messages/index.js b/dist/cjs/messages/index.js index 35e4f65..bb25b40 100644 --- a/dist/cjs/messages/index.js +++ b/dist/cjs/messages/index.js @@ -1,12 +1,13 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -exports.StateBuilder = exports.property = exports.errorNamespace = exports.AlexaErrors = exports.AlexaError = exports.MessageError = exports.stateReport = exports.simpleEvent = exports.sceneEvent = exports.response = exports.errorResponse = exports.doorbellPress = exports.deferredResponse = exports.changeReport = void 0; +exports.StateBuilder = exports.property = exports.errorNamespace = exports.AlexaErrors = exports.AlexaError = exports.MessageError = exports.stateReport = exports.simpleEvent = exports.sceneEvent = exports.response = exports.proactiveEvent = exports.errorResponse = exports.doorbellPress = exports.deferredResponse = exports.changeReport = void 0; // The messages of the bridge without the bridge: builders, the state collector and the errors. var build_js_1 = require("./build.js"); Object.defineProperty(exports, "changeReport", { enumerable: true, get: function () { return build_js_1.changeReport; } }); Object.defineProperty(exports, "deferredResponse", { enumerable: true, get: function () { return build_js_1.deferredResponse; } }); Object.defineProperty(exports, "doorbellPress", { enumerable: true, get: function () { return build_js_1.doorbellPress; } }); Object.defineProperty(exports, "errorResponse", { enumerable: true, get: function () { return build_js_1.errorResponse; } }); +Object.defineProperty(exports, "proactiveEvent", { enumerable: true, get: function () { return build_js_1.proactiveEvent; } }); Object.defineProperty(exports, "response", { enumerable: true, get: function () { return build_js_1.response; } }); Object.defineProperty(exports, "sceneEvent", { enumerable: true, get: function () { return build_js_1.sceneEvent; } }); Object.defineProperty(exports, "simpleEvent", { enumerable: true, get: function () { return build_js_1.simpleEvent; } }); diff --git a/dist/cjs/registry/catalog.js b/dist/cjs/registry/catalog.js index 6a0d3f1..04d3038 100644 --- a/dist/cjs/registry/catalog.js +++ b/dist/cjs/registry/catalog.js @@ -2,7 +2,7 @@ // The fixed vocabularies of the Smart Home API, copied from Amazon's pages as read on 2026-09-28. The pages are under // https://developer.amazon.com/docs/alexaplus/device-apis/. A value missing here is one Amazon added since. Object.defineProperty(exports, "__esModule", { value: true }); -exports.ERROR_TYPES = exports.LIMITS = exports.RESERVED_WORDS = exports.DISPLAY_CATEGORIES = exports.STATES = exports.ACTIONS = exports.UNITS_OF_MEASURE = exports.ASSETS = void 0; +exports.ERROR_TYPES = exports.LIMITS = exports.CAUSES = exports.RESERVED_WORDS = exports.DISPLAY_CATEGORIES = exports.STATES = exports.ACTIONS = exports.UNITS_OF_MEASURE = exports.ASSETS = void 0; /** * The asset ids a friendly name can refer to: 103, the units of measure among them * (resources-and-assets.html, "Global Alexa catalog"). @@ -70,6 +70,11 @@ exports.RESERVED_WORDS = [ "drop in", "music", "night light", "notification", "playing", "sleep sounds", "time", "timer", "today in music", "treble", "volume", "way f. m.", ]; +/** + * Why a property changed or an event was raised (message-guide.html, "Cause object"). RULE_TRIGGER is not in that + * table; the ChangeReport examples of alexa-securitypanelcontroller.html and alexa-thermostatcontroller.html use it. + */ +exports.CAUSES = ["APP_INTERACTION", "PERIODIC_POLL", "PHYSICAL_INTERACTION", "RULE_TRIGGER", "VOICE_INTERACTION"]; /** * What a discovery answer may hold (alexa-discovery.html, "Interface limits"; alexa-discovery-objects.html, * "Endpoint object details" and "AdditionalAttributes object details"; alexa-scenecontroller.html, "Discovery"). diff --git a/dist/cjs/registry/events.js b/dist/cjs/registry/events.js new file mode 100644 index 0000000..7ef2c17 --- /dev/null +++ b/dist/cjs/registry/events.js @@ -0,0 +1,17 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.happened = exports.timestamp = void 0; +// What the events of several interfaces have in common. +const catalog_js_1 = require("./catalog.js"); +const schema_js_1 = require("./schema.js"); +/** The time of an event: now, unless the payload says when. */ +exports.timestamp = schema_js_1.s.defaulted(schema_js_1.s.dateTime(), () => new Date().toISOString()); +/** + * The payload of an event that happened at a time and for a reason: ActivationStarted, DoorbellPress. Both are + * required by Alexa. The cause is the usual one of the event unless the payload names another. + */ +const happened = (usually) => schema_js_1.s.object({ + cause: schema_js_1.s.defaulted(schema_js_1.s.object({ type: schema_js_1.s.enum(...catalog_js_1.CAUSES) }), () => ({ type: usually })), + timestamp: exports.timestamp, +}); +exports.happened = happened; diff --git a/dist/cjs/registry/index.js b/dist/cjs/registry/index.js index 8da9f6c..c807c71 100644 --- a/dist/cjs/registry/index.js +++ b/dist/cjs/registry/index.js @@ -14,7 +14,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) { for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p); }; Object.defineProperty(exports, "__esModule", { value: true }); -exports.SchemaError = exports.s = exports.defineInterface = exports.DeclarationError = exports.SemanticsBuilder = exports.semantics = exports.text = exports.asset = exports.THERMOSTAT_MODES = exports.INPUTS = exports.ToggleController = exports.ThermostatControllerSchedule = exports.ThermostatController = exports.TemperatureSensor = exports.StepSpeaker = exports.Speaker = exports.SecurityPanelController = exports.RangeController = exports.PowerLevelController = exports.PowerController = exports.PlaybackStateReporter = exports.PlaybackController = exports.PercentageController = exports.MotionSensor = exports.ModeController = exports.LockController = exports.InputController = exports.HumiditySensor = exports.EqualizerController = exports.EndpointHealth = exports.ContactSensor = exports.ColorTemperatureController = exports.ColorController = exports.ChannelController = exports.BrightnessController = exports.Alexa = exports.registry = void 0; +exports.SchemaError = exports.s = exports.defineInterface = exports.DeclarationError = exports.SemanticsBuilder = exports.semantics = exports.text = exports.asset = exports.THERMOSTAT_MODES = exports.INPUTS = exports.WakeOnLANController = exports.ToggleController = exports.TimeHoldController = exports.ThermostatControllerSchedule = exports.ThermostatController = exports.TemperatureSensor = exports.StepSpeaker = exports.Speaker = exports.SimpleEventSource = exports.SecurityPanelController = exports.SceneController = exports.RangeController = exports.PowerLevelController = exports.PowerController = exports.PlaybackStateReporter = exports.PlaybackController = exports.PercentageController = exports.MotionSensor = exports.ModeController = exports.LockController = exports.InventoryLevelSensor = exports.InputController = exports.HumiditySensor = exports.EqualizerController = exports.EndpointHealth = exports.DoorbellEventSource = exports.ContactSensor = exports.ColorTemperatureController = exports.ColorController = exports.ChannelController = exports.BrightnessController = exports.Alexa = exports.registry = void 0; // The interfaces the library knows, by namespace. const Alexa_js_1 = require("./interfaces/Alexa.js"); Object.defineProperty(exports, "Alexa", { enumerable: true, get: function () { return Alexa_js_1.Alexa; } }); @@ -28,6 +28,8 @@ const ColorTemperatureController_js_1 = require("./interfaces/ColorTemperatureCo Object.defineProperty(exports, "ColorTemperatureController", { enumerable: true, get: function () { return ColorTemperatureController_js_1.ColorTemperatureController; } }); const ContactSensor_js_1 = require("./interfaces/ContactSensor.js"); Object.defineProperty(exports, "ContactSensor", { enumerable: true, get: function () { return ContactSensor_js_1.ContactSensor; } }); +const DoorbellEventSource_js_1 = require("./interfaces/DoorbellEventSource.js"); +Object.defineProperty(exports, "DoorbellEventSource", { enumerable: true, get: function () { return DoorbellEventSource_js_1.DoorbellEventSource; } }); const EndpointHealth_js_1 = require("./interfaces/EndpointHealth.js"); Object.defineProperty(exports, "EndpointHealth", { enumerable: true, get: function () { return EndpointHealth_js_1.EndpointHealth; } }); const EqualizerController_js_1 = require("./interfaces/EqualizerController.js"); @@ -36,6 +38,8 @@ const HumiditySensor_js_1 = require("./interfaces/HumiditySensor.js"); Object.defineProperty(exports, "HumiditySensor", { enumerable: true, get: function () { return HumiditySensor_js_1.HumiditySensor; } }); const InputController_js_1 = require("./interfaces/InputController.js"); Object.defineProperty(exports, "InputController", { enumerable: true, get: function () { return InputController_js_1.InputController; } }); +const InventoryLevelSensor_js_1 = require("./interfaces/InventoryLevelSensor.js"); +Object.defineProperty(exports, "InventoryLevelSensor", { enumerable: true, get: function () { return InventoryLevelSensor_js_1.InventoryLevelSensor; } }); const LockController_js_1 = require("./interfaces/LockController.js"); Object.defineProperty(exports, "LockController", { enumerable: true, get: function () { return LockController_js_1.LockController; } }); const ModeController_js_1 = require("./interfaces/ModeController.js"); @@ -54,8 +58,12 @@ const PowerLevelController_js_1 = require("./interfaces/PowerLevelController.js" Object.defineProperty(exports, "PowerLevelController", { enumerable: true, get: function () { return PowerLevelController_js_1.PowerLevelController; } }); const RangeController_js_1 = require("./interfaces/RangeController.js"); Object.defineProperty(exports, "RangeController", { enumerable: true, get: function () { return RangeController_js_1.RangeController; } }); +const SceneController_js_1 = require("./interfaces/SceneController.js"); +Object.defineProperty(exports, "SceneController", { enumerable: true, get: function () { return SceneController_js_1.SceneController; } }); const SecurityPanelController_js_1 = require("./interfaces/SecurityPanelController.js"); Object.defineProperty(exports, "SecurityPanelController", { enumerable: true, get: function () { return SecurityPanelController_js_1.SecurityPanelController; } }); +const SimpleEventSource_js_1 = require("./interfaces/SimpleEventSource.js"); +Object.defineProperty(exports, "SimpleEventSource", { enumerable: true, get: function () { return SimpleEventSource_js_1.SimpleEventSource; } }); const Speaker_js_1 = require("./interfaces/Speaker.js"); Object.defineProperty(exports, "Speaker", { enumerable: true, get: function () { return Speaker_js_1.Speaker; } }); const StepSpeaker_js_1 = require("./interfaces/StepSpeaker.js"); @@ -66,8 +74,12 @@ const ThermostatController_js_1 = require("./interfaces/ThermostatController.js" Object.defineProperty(exports, "ThermostatController", { enumerable: true, get: function () { return ThermostatController_js_1.ThermostatController; } }); const ThermostatControllerSchedule_js_1 = require("./interfaces/ThermostatControllerSchedule.js"); Object.defineProperty(exports, "ThermostatControllerSchedule", { enumerable: true, get: function () { return ThermostatControllerSchedule_js_1.ThermostatControllerSchedule; } }); +const TimeHoldController_js_1 = require("./interfaces/TimeHoldController.js"); +Object.defineProperty(exports, "TimeHoldController", { enumerable: true, get: function () { return TimeHoldController_js_1.TimeHoldController; } }); const ToggleController_js_1 = require("./interfaces/ToggleController.js"); Object.defineProperty(exports, "ToggleController", { enumerable: true, get: function () { return ToggleController_js_1.ToggleController; } }); +const WakeOnLANController_js_1 = require("./interfaces/WakeOnLANController.js"); +Object.defineProperty(exports, "WakeOnLANController", { enumerable: true, get: function () { return WakeOnLANController_js_1.WakeOnLANController; } }); const stubs_js_1 = require("./interfaces/stubs.js"); const types_js_1 = require("./types.js"); const described = [ @@ -77,10 +89,12 @@ const described = [ ColorController_js_1.ColorController, ColorTemperatureController_js_1.ColorTemperatureController, ContactSensor_js_1.ContactSensor, + DoorbellEventSource_js_1.DoorbellEventSource, EndpointHealth_js_1.EndpointHealth, EqualizerController_js_1.EqualizerController, HumiditySensor_js_1.HumiditySensor, InputController_js_1.InputController, + InventoryLevelSensor_js_1.InventoryLevelSensor, LockController_js_1.LockController, ModeController_js_1.ModeController, MotionSensor_js_1.MotionSensor, @@ -90,13 +104,17 @@ const described = [ PowerController_js_1.PowerController, PowerLevelController_js_1.PowerLevelController, RangeController_js_1.RangeController, + SceneController_js_1.SceneController, SecurityPanelController_js_1.SecurityPanelController, + SimpleEventSource_js_1.SimpleEventSource, Speaker_js_1.Speaker, StepSpeaker_js_1.StepSpeaker, TemperatureSensor_js_1.TemperatureSensor, ThermostatController_js_1.ThermostatController, ThermostatControllerSchedule_js_1.ThermostatControllerSchedule, + TimeHoldController_js_1.TimeHoldController, ToggleController_js_1.ToggleController, + WakeOnLANController_js_1.WakeOnLANController, ]; const descriptors = new Map(); for (const descriptor of [...described, ...stubs_js_1.STUBS]) { diff --git a/dist/cjs/registry/interfaces/DoorbellEventSource.js b/dist/cjs/registry/interfaces/DoorbellEventSource.js new file mode 100644 index 0000000..661f6e9 --- /dev/null +++ b/dist/cjs/registry/interfaces/DoorbellEventSource.js @@ -0,0 +1,34 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.DoorbellEventSource = void 0; +const events_js_1 = require("../events.js"); +const types_js_1 = require("../types.js"); +/** + * A doorbell. It takes no directive and reports nothing; device.raise(DoorbellEventSource, "DoorbellPress") says + * that somebody rang. Alexa wants 30 seconds between two presses of one doorbell, and announces a press when the + * user switched the announcements on for the doorbell in the Alexa app. + */ +exports.DoorbellEventSource = (0, types_js_1.defineInterface)({ + namespace: "Alexa.DoorbellEventSource", + version: "3", + doc: "https://developer.amazon.com/docs/alexaplus/device-apis/alexa-doorbelleventsource.html", + kind: "eventSource", + tier: 2, + instanced: false, + properties: {}, + directives: {}, + events: { + DoorbellPress: { name: "DoorbellPress", payload: (0, events_js_1.happened)("PHYSICAL_INTERACTION"), topic: "proactive" }, + }, + // The capability is the announcement that the endpoint raises the event: proactivelyReported whatever was declared + discovery: () => ({ properties: false, topLevel: { proactivelyReported: true } }), + // alexa-doorbelleventsource.html, "Discovery" + validate(capability, { displayCategories }) { + const doorbell = displayCategories.indexOf("DOORBELL"); + if (doorbell < 0) + throw new types_js_1.DeclarationError(capability, "a doorbell has the display category DOORBELL"); + if (displayCategories.indexOf("CAMERA") > doorbell) { + throw new types_js_1.DeclarationError(capability, "a video doorbell lists the display category CAMERA before DOORBELL"); + } + }, +}); diff --git a/dist/cjs/registry/interfaces/InventoryLevelSensor.js b/dist/cjs/registry/interfaces/InventoryLevelSensor.js new file mode 100644 index 0000000..2e61e17 --- /dev/null +++ b/dist/cjs/registry/interfaces/InventoryLevelSensor.js @@ -0,0 +1,54 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.InventoryLevelSensor = void 0; +const schema_js_1 = require("../schema.js"); +const types_js_1 = require("../types.js"); +// As the examples of the page spell them; its table has them in lower case +const KINDS = ["Count", "Percentage", "Volume", "Weight"]; +// A volume and a weight have a unit: MILLILITER, GRAM (alexa-property-schemas.html, "Volume unit values" and +// "Weight unit values") +function withUnit(fields) { + return { + expects: fields.expects, + parse(input, path = "") { + const measured = fields.parse(input, path); + const what = measured["@type"].toLowerCase(); + const needed = what === "volume" || what === "weight"; + if (needed && measured.unit === undefined) + throw (0, schema_js_1.mismatch)((0, schema_js_1.at)(path, "unit"), `the unit of the ${what}, like MILLILITER or GRAM`, undefined); + if (!needed && measured.unit !== undefined) + throw (0, schema_js_1.mismatch)((0, schema_js_1.at)(path, "unit"), `no unit for a ${what}`, measured.unit); + return measured; + }, + }; +} +const kind = schema_js_1.s.enum(...KINDS); +const measurement = withUnit(schema_js_1.s.object({ "@type": kind, unit: schema_js_1.s.optional(schema_js_1.s.string({ min: 1 })) }, { unknownKeys: "reject" })); +const level = withUnit(schema_js_1.s.object({ "@type": kind, value: schema_js_1.s.number({ min: 0 }), unit: schema_js_1.s.optional(schema_js_1.s.string({ min: 1 })) })); +/** + * How much is left of something a device uses up: ink, paper, detergent. Each instance is one sensor, and Amazon + * orders a refill by its Dash replenishment id. The level is reported, there is nothing to ask for by voice. + */ +exports.InventoryLevelSensor = (0, types_js_1.defineInterface)({ + namespace: "Alexa.InventoryLevelSensor", + version: "3", + doc: "https://developer.amazon.com/docs/alexaplus/device-apis/alexa-inventorylevelsensor.html", + kind: "sensor", + tier: 2, + instanced: true, + properties: { + level: { name: "level", value: level, note: "measured as the capability declares it" }, + }, + directives: {}, + options: schema_js_1.s.object({ + /** How the level is measured: { "@type": "Volume", unit: "MILLILITER" }, { "@type": "Count" }. */ + measurement, + /** The id the Dash console gave for the product. */ + replenishment: schema_js_1.s.object({ "@type": schema_js_1.s.enum("DashReplenishmentId"), value: schema_js_1.s.string({ min: 1 }) }, { unknownKeys: "reject" }), + }, { unknownKeys: "reject" }), + // A capability declared the 1.x way has neither; check() says so + discovery({ options: { measurement: measured, replenishment } }) { + const configuration = { ...(measured && { measurement: measured }), ...(replenishment && { replenishment }) }; + return Object.keys(configuration).length > 0 ? { configuration } : {}; + }, +}); diff --git a/dist/cjs/registry/interfaces/SceneController.js b/dist/cjs/registry/interfaces/SceneController.js new file mode 100644 index 0000000..75f598d --- /dev/null +++ b/dist/cjs/registry/interfaces/SceneController.js @@ -0,0 +1,67 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.SceneController = void 0; +const catalog_js_1 = require("../catalog.js"); +const events_js_1 = require("../events.js"); +const schema_js_1 = require("../schema.js"); +const types_js_1 = require("../types.js"); +const started = (name) => ({ name, payload: (0, events_js_1.happened)("VOICE_INTERACTION"), topic: "response" }); +const events = { + ActivationStarted: started("ActivationStarted"), + DeactivationStarted: started("DeactivationStarted"), +}; +/** + * A scene: an endpoint that is not a device, but several devices set to a state each. Activate is answered with + * ActivationStarted and Deactivate with DeactivationStarted; ctx.respond() sends them, with the time and + * VOICE_INTERACTION as the cause. A scene reports nothing and has no Alexa.EndpointHealth. + * + * Alexa takes no scene with a lock, a garage door, a camera, a cooking appliance or a security device in it. + */ +exports.SceneController = (0, types_js_1.defineInterface)({ + namespace: "Alexa.SceneController", + version: "3", + doc: "https://developer.amazon.com/docs/alexaplus/device-apis/alexa-scenecontroller.html", + kind: "controller", + tier: 1, + instanced: false, + properties: {}, + directives: { + Activate: { name: "Activate", payload: schema_js_1.s.object({}) }, + Deactivate: { + name: "Deactivate", + payload: schema_js_1.s.object({}), + when: ({ options }) => options.supportsDeactivation !== false, + }, + }, + events, + options: schema_js_1.s.object({ + /** The scene can be switched off again. */ + supportsDeactivation: schema_js_1.s.optional(schema_js_1.s.boolean()), + }, { unknownKeys: "reject" }), + // A scene declared without the option is announced as 1.5.2 announced every scene, which Alexa accepted: it can + // be deactivated, and proactivelyReported is on the capability. The example of the page has supportsDeactivation + // alone. + discovery({ proactivelyReported, options: { supportsDeactivation } }) { + const topLevel = supportsDeactivation === undefined ? { supportsDeactivation: true, proactivelyReported } : { supportsDeactivation }; + return { properties: false, topLevel }; + }, + // alexa-scenecontroller.html, "Discovery" + validate(capability, endpoint) { + const { friendlyName, description, displayCategories } = endpoint; + if (!displayCategories.includes("SCENE_TRIGGER") && !displayCategories.includes("ACTIVITY_TRIGGER")) { + throw new types_js_1.DeclarationError(capability, "a scene has the display category SCENE_TRIGGER, or ACTIVITY_TRIGGER when the order of its steps matters"); + } + if (!/scene/i.test(description)) { + throw new types_js_1.DeclarationError(capability, 'the description of a scene has the word "scene" in it, like "Party scene connected by Alex2Node"'); + } + if (friendlyName.length > catalog_js_1.LIMITS.sceneFriendlyNameLength) { + throw new types_js_1.DeclarationError(capability, `the name of a scene takes up to ${catalog_js_1.LIMITS.sceneFriendlyNameLength} characters, this one has ${friendlyName.length}`); + } + }, + responseFor(directive) { + if (directive !== "Activate" && directive !== "Deactivate") + return undefined; + const { name, payload } = directive === "Activate" ? events.ActivationStarted : events.DeactivationStarted; + return { namespace: "Alexa.SceneController", name, payload }; + }, +}); diff --git a/dist/cjs/registry/interfaces/SimpleEventSource.js b/dist/cjs/registry/interfaces/SimpleEventSource.js new file mode 100644 index 0000000..1764d69 --- /dev/null +++ b/dist/cjs/registry/interfaces/SimpleEventSource.js @@ -0,0 +1,53 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.SimpleEventSource = void 0; +const events_js_1 = require("../events.js"); +const resources_js_1 = require("../resources.js"); +const schema_js_1 = require("../schema.js"); +const types_js_1 = require("../types.js"); +/** + * A button of a remote, or anything else that routines start on. Each instance is one button with the events it + * has, a single and a double push; device.raise(SimpleEventSource, "Event", { id }, { instance }) says that one + * happened. Alexa wants the instance to be unique among all endpoints, "preferably a version 4 UUID". + * + * The page folds its discovery example away. The capability is announced with the fields of its table and without a + * properties object, as the interface has no property. + */ +exports.SimpleEventSource = (0, types_js_1.defineInterface)({ + namespace: "Alexa.SimpleEventSource", + version: "1.0", + doc: "https://developer.amazon.com/docs/alexaplus/device-apis/alexa-simpleeventsource.html", + kind: "eventSource", + tier: 2, + instanced: true, + properties: {}, + directives: {}, + events: { + Event: { + name: "Event", + /** id: the id of one of the supportedEvents of the instance. */ + payload: schema_js_1.s.object({ id: schema_js_1.s.string({ min: 1 }), timestamp: events_js_1.timestamp }), + topic: "proactive", + }, + }, + options: schema_js_1.s.object({ + /** For a REMOTE the names are assets of the catalog: Alexa.Button.SinglePush, Alexa.Gesture.Tap. */ + supportedEvents: schema_js_1.s.array(schema_js_1.s.object({ id: schema_js_1.s.string({ min: 1 }), friendlyNames: resources_js_1.labels }, { unknownKeys: "reject" }), { min: 1 }), + }, { unknownKeys: "reject" }), + // A capability declared the 1.x way has no events; check() says so + discovery: ({ options: { supportedEvents } }) => ({ + properties: false, + ...(supportedEvents && { configuration: { supportedEvents } }), + }), + validate(capability) { + const { supportedEvents } = capability.options; + const ids = supportedEvents.map(({ id }) => id); + // "The first friendly name in the array must be unique per instance of the capability." + const names = supportedEvents.map(({ friendlyNames: [first] }) => (0, resources_js_1.shownAs)(first)); + const twice = (list) => list.find((entry, i) => list.indexOf(entry) !== i); + if (twice(ids)) + throw new types_js_1.DeclarationError(capability, `the event ${twice(ids)} is listed twice`); + if (twice(names)) + throw new types_js_1.DeclarationError(capability, `two events have ${twice(names)} as their first friendly name, the Alexa app shows an event by it`); + }, +}); diff --git a/dist/cjs/registry/interfaces/TimeHoldController.js b/dist/cjs/registry/interfaces/TimeHoldController.js new file mode 100644 index 0000000..8943fbf --- /dev/null +++ b/dist/cjs/registry/interfaces/TimeHoldController.js @@ -0,0 +1,31 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.TimeHoldController = void 0; +const schema_js_1 = require("../schema.js"); +const types_js_1 = require("../types.js"); +/** + * Pausing what a device does, a microwave that heats, and going on with it. Amazon pairs the interface with + * Alexa.Cooking. Alexa sends Resume to a device declared with allowRemoteResume only; for another one it asks the + * user to press start on the device. + */ +exports.TimeHoldController = (0, types_js_1.defineInterface)({ + namespace: "Alexa.TimeHoldController", + version: "3", + doc: "https://developer.amazon.com/docs/alexaplus/device-apis/alexa-timeholdcontroller.html", + kind: "controller", + tier: 2, + instanced: false, + properties: { + holdStartTime: { name: "holdStartTime", value: schema_js_1.s.dateTime() }, + holdEndTime: { name: "holdEndTime", value: schema_js_1.s.dateTime() }, + }, + directives: { + Hold: { name: "Hold", payload: schema_js_1.s.object({}) }, + Resume: { name: "Resume", payload: schema_js_1.s.object({}), when: ({ options }) => options.allowRemoteResume !== false }, + }, + options: schema_js_1.s.object({ + allowRemoteResume: schema_js_1.s.boolean(), + }, { unknownKeys: "reject" }), + // A capability declared the 1.x way has no allowRemoteResume; check() says so + discovery: ({ options: { allowRemoteResume } }) => (allowRemoteResume === undefined ? {} : { configuration: { allowRemoteResume } }), +}); diff --git a/dist/cjs/registry/interfaces/WakeOnLANController.js b/dist/cjs/registry/interfaces/WakeOnLANController.js new file mode 100644 index 0000000..08c66c7 --- /dev/null +++ b/dist/cjs/registry/interfaces/WakeOnLANController.js @@ -0,0 +1,45 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.WakeOnLANController = void 0; +const schema_js_1 = require("../schema.js"); +const types_js_1 = require("../types.js"); +// 00-14-22-01-23-45 in the example of the page; the colon is the other common way to write one +const MAC_ADDRESS = /^[0-9A-Fa-f]{2}([-:][0-9A-Fa-f]{2}){5}$/; +/** + * A device that is switched on by a Wake-on-LAN message, which an Echo of the user sends to the MAC addresses of + * the capability. The interface has no directive and no property: the device gets TurnOn of Alexa.PowerController, + * answers with a DeferredResponse, sends the WakeUp event and, when it is up, the Response. + * + * Alex2MQTT has no topic yet that takes the WakeUp event to Alexa. + */ +exports.WakeOnLANController = (0, types_js_1.defineInterface)({ + namespace: "Alexa.WakeOnLANController", + version: "3", + doc: "https://developer.amazon.com/docs/alexaplus/device-apis/alexa-wakeonlancontroller.html", + kind: "controller", + tier: 2, + instanced: false, + properties: {}, + directives: {}, + events: { + WakeUp: { name: "WakeUp", payload: schema_js_1.s.object({}), topic: "response" }, + }, + options: schema_js_1.s.object({ + macAddresses: schema_js_1.s.array(schema_js_1.s.string({ pattern: MAC_ADDRESS, expects: "a MAC address like 00-14-22-01-23-45" }), { min: 1 }), + }, { unknownKeys: "reject" }), + // The example has a properties object without fields, which topLevel puts where properties: false left none. A + // capability declared the 1.x way has no address; check() says so. + discovery: ({ options: { macAddresses } }) => ({ + properties: false, + topLevel: { properties: {} }, + ...(macAddresses && { configuration: { MACAddresses: macAddresses } }), + }), + validate(capability) { + const addresses = capability.options.macAddresses.map((address) => address.toUpperCase().replace(/:/g, "-")); + const twice = addresses.find((address, i) => addresses.indexOf(address) !== i); + if (twice) + throw new types_js_1.DeclarationError(capability, `${twice} is listed twice`); + }, + deferrable: true, + defers: [{ namespace: "Alexa.PowerController", name: "TurnOn" }], +}); diff --git a/dist/cjs/registry/interfaces/stubs.js b/dist/cjs/registry/interfaces/stubs.js index c9a2a83..cc71582 100644 --- a/dist/cjs/registry/interfaces/stubs.js +++ b/dist/cjs/registry/interfaces/stubs.js @@ -32,8 +32,6 @@ const TABLE = [ ["Alexa.DataController", "1", [], "alexa-datacontroller.html"], ["Alexa.DeviceUsage.Estimation", "1", [], "alexa-deviceusage-estimation.html"], ["Alexa.DeviceUsage.Meter", "1", [], "alexa-deviceusage-meter.html"], - ["Alexa.DoorbellEventSource", "3", [], "alexa-doorbelleventsource.html"], - ["Alexa.InventoryLevelSensor", "1", [], "alexa-inventorylevelsensor.html"], ["Alexa.InventoryLevelUsageSensor", "1", [], "alexa-inventorylevelusagesensor.html"], ["Alexa.InventoryUsageSensor", "1", [], "alexa-inventoryusagesensor.html"], ["Alexa.KeypadController", "1", [], "alexa-keypadcontroller.html"], @@ -45,29 +43,16 @@ const TABLE = [ ["Alexa.RTCSessionController", "1", [], "alexa-rtcsessioncontroller.html"], ["Alexa.RecordController", "3", [], "alexa-recordcontroller.html"], ["Alexa.RemoteVideoPlayer", "1", [], "alexa-remotevideoplayer.html"], - ["Alexa.SceneController", "3", [], "alexa-scenecontroller.html"], ["Alexa.SecurityPanelController.Alert", "1", [], "alexa-securitypanelcontroller-alert.html"], ["Alexa.SeekController", "3", [], "alexa-seekcontroller.html"], - ["Alexa.SimpleEventSource", "1", [], "alexa-simpleeventsource.html"], ["Alexa.SmartVision.ObjectDetectionSensor", "1", [], "alexa-smartvision-objectdetectionsensor.html"], ["Alexa.SmartVision.SnapshotProvider", "1", [], "alexa-smartvision-snapshotprovider.html"], ["Alexa.ThermostatController.Configuration", "1", [], "alexa-thermostatcontroller-configuration.html"], ["Alexa.ThermostatController.HVAC.Components", "1", [], "alexa-thermostatcontroller-hvac-components.html"], - // "UNKNOWN" in 1.5.2 as well; the page is titled "Interface 3" - ["Alexa.TimeHoldController", "3", [], "alexa-timeholdcontroller.html"], ["Alexa.UIController", "1", [], "alexa-uicontroller.html"], ["Alexa.UserPreference", "1", [], "alexa-userpreference.html"], ["Alexa.VideoRecorder", "1", [], "alexa-videorecorder.html"], - ["Alexa.WakeOnLANController", "1", [], "alexa-wakeonlancontroller.html"], ]; -// What 1.5.2 added to the capability object of one of them -const EXTRAS = { - // No properties object; supportsDeactivation and proactivelyReported on the capability itself - "Alexa.SceneController": ({ proactivelyReported }) => ({ - properties: false, - topLevel: { supportsDeactivation: true, proactivelyReported }, - }), -}; function kindOf(namespace) { if (namespace.endsWith("Sensor")) return "sensor"; @@ -85,7 +70,6 @@ function stub([namespace, version, properties, page]) { instanced: false, properties: Object.fromEntries(properties.map((name) => [name, { name, value: schema_js_1.s.unknown() }])), directives: {}, - discovery: EXTRAS[namespace], }; } exports.STUBS = TABLE.map(stub); diff --git a/dist/cjs/registry/schema.js b/dist/cjs/registry/schema.js index 6baf907..6cb283f 100644 --- a/dist/cjs/registry/schema.js +++ b/dist/cjs/registry/schema.js @@ -89,6 +89,13 @@ function optional(inner) { parse: (input, path = "") => (input === undefined ? undefined : inner.parse(input, path)), }; } +/** A value that is made when none is given: the time of an event, which is now unless the caller says when. */ +function defaulted(inner, make) { + return { + expects: inner.expects, + parse: (input, path = "") => (input === undefined ? make() : inner.parse(input, path)), + }; +} function nullable(inner) { const expects = `${inner.expects} or null`; return { @@ -189,6 +196,7 @@ exports.s = { oneOf, unknown, optional, + defaulted, nullable, array, object, diff --git a/dist/cjs/topics.js b/dist/cjs/topics.js index c49665f..63de0e7 100644 --- a/dist/cjs/topics.js +++ b/dist/cjs/topics.js @@ -2,7 +2,7 @@ // The topics of the Alex2MQTT contract, named once. The backend and Alex2ESP use the same names, so none of them // can change here alone. Object.defineProperty(exports, "__esModule", { value: true }); -exports.changeReport = exports.DIRECTIVE_BUDGET_MS = exports.deferred = exports.response = exports.subscriptions = exports.directive = exports.discoverReply = exports.discover = void 0; +exports.EVENT_BYTES = exports.event = exports.changeReport = exports.DIRECTIVE_BUDGET_MS = exports.deferred = exports.response = exports.subscriptions = exports.directive = exports.discoverReply = exports.discover = void 0; exports.directiveEndpoint = directiveEndpoint; /** Where the backend asks for the endpoints of the root. */ const discover = (root) => `${root}/discover`; @@ -35,3 +35,11 @@ exports.DIRECTIVE_BUDGET_MS = 7000; /** Where every ChangeReport of the root goes: the backend adds the user's token and posts it to Alexa. */ const changeReport = (root) => `${root}/changeReport`; exports.changeReport = changeReport; +/** + * Where the events go that a device raises by itself, a DoorbellPress: the backend adds the user's token and posts + * them to Alexa. It takes 30 events a minute from a root. + */ +const event = (root) => `${root}/event`; +exports.event = event; +/** The backend drops an event that is longer, as JSON. */ +exports.EVENT_BYTES = 16000; diff --git a/dist/esm/device/Device.d.ts b/dist/esm/device/Device.d.ts index f912268..9e6befb 100644 --- a/dist/esm/device/Device.d.ts +++ b/dist/esm/device/Device.d.ts @@ -8,7 +8,7 @@ import type { ChangeCause } from "../messages/types.js"; import type { DirectiveHandler, Fill } from "../dispatcher.js"; import type { DisplayCategoryName } from "../registry/catalog.js"; import type { Directives, InterfaceDescriptor, Properties } from "../registry/types.js"; -import type { Publisher } from "../transport.js"; +import type { Publisher, PublishResult } from "../transport.js"; import { Capability } from "./Capability.js"; import type { AnyCapability, CapabilityJson, Declaration } from "./Capability.js"; import type { EndpointFields } from "./validate.js"; @@ -102,6 +102,23 @@ declare class Device extends EventEmitter { * (1.5.1). Resolves with the topic published to, or "" when the publish failed (never rejects, 1.5.2). */ sendSceneResponse(correlationToken: string, activated: boolean, cause?: ChangeCause, sendAsync?: boolean): Promise; + /** + * 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 /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: InterfaceDescriptor, name: string, payload?: Record, options?: { + instance?: string; + messageId?: string; + }): Promise; /** * How the device reports its state, every retrievable property of it: * diff --git a/dist/esm/device/Device.js b/dist/esm/device/Device.js index 8a208f4..14f11e3 100644 --- a/dist/esm/device/Device.js +++ b/dist/esm/device/Device.js @@ -3,9 +3,10 @@ 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 { 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"; @@ -97,6 +98,69 @@ class Device extends EventEmitter { 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 /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, + }); + 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: * @@ -264,7 +328,7 @@ class Device extends EventEmitter { 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")) { + if (this.endpointHealth && !has(EndpointHealth.namespace) && !has(SceneController.namespace)) { added.push(new Capability(EndpointHealth, { options: {} })); } if (this.alexaInterface && !has(Alexa.namespace)) diff --git a/dist/esm/dispatcher.js b/dist/esm/dispatcher.js index 7c6e91f..29d870e 100644 --- a/dist/esm/dispatcher.js +++ b/dist/esm/dispatcher.js @@ -57,7 +57,7 @@ class Context { } defer(estimatedDeferralInSeconds) { const { endpointId, correlationToken } = this; - if (this.capability && !this.capability.descriptor.deferrable) + if (this.capability && !this.capability.descriptor.deferrable && !this.deferredByAnother()) this.dispatcher.noteDeferral(this.namespace); // Always where the first answer goes: a second DeferredResponse is refused there const topic = topics.response(this.dispatcher.rootTopic, endpointId); @@ -75,6 +75,11 @@ class Context { const { type, alexaMessage, namespace } = error; return this.answer(errorResponse({ endpointId, correlationToken, type, message: alexaMessage, extra: error.extra, namespace })); } + // An interface of the device documents a DeferredResponse for this directive of another one + deferredByAnother() { + const { namespace, name } = this; + return this.device.getCapabilities().some(({ descriptor }) => descriptor.defers?.some((directive) => directive.namespace === namespace && directive.name === name)); + } // The state of the device, and over it what the handler says about this directive state(fill) { const whole = new StateBuilder(); diff --git a/dist/esm/index.d.ts b/dist/esm/index.d.ts index 6caa0e7..75ec406 100644 --- a/dist/esm/index.d.ts +++ b/dist/esm/index.d.ts @@ -5,14 +5,14 @@ export type { EndpointDefinition, EndpointJson } from "./device/Device.js"; export { Capability } from "./device/Capability.js"; export type { CapabilityJson, CommonOptions, Declaration } from "./device/Capability.js"; export { registry, DeclarationError, SchemaError } from "./registry/index.js"; -export { Alexa, BrightnessController, ChannelController, ColorController, ColorTemperatureController, ContactSensor, EqualizerController, HumiditySensor, InputController, LockController, ModeController, MotionSensor, PercentageController, PlaybackController, PlaybackStateReporter, PowerLevelController, RangeController, SecurityPanelController, Speaker, StepSpeaker, TemperatureSensor, ThermostatController, ThermostatControllerSchedule, ToggleController, } from "./registry/index.js"; +export { Alexa, BrightnessController, ChannelController, ColorController, ColorTemperatureController, ContactSensor, DoorbellEventSource, EqualizerController, HumiditySensor, InputController, InventoryLevelSensor, LockController, ModeController, MotionSensor, PercentageController, PlaybackController, PlaybackStateReporter, PowerLevelController, RangeController, SceneController, SecurityPanelController, SimpleEventSource, Speaker, StepSpeaker, TemperatureSensor, ThermostatController, ThermostatControllerSchedule, TimeHoldController, ToggleController, WakeOnLANController, } from "./registry/index.js"; export { PowerController, EndpointHealth } from "./compat/enums.js"; export { asset, text, semantics, SemanticsBuilder } from "./registry/index.js"; -export { ASSETS as Assets, UNITS_OF_MEASURE as Units, ACTIONS as Actions, STATES as States, DISPLAY_CATEGORIES as DisplayCategories, INPUTS as Inputs, THERMOSTAT_MODES as ThermostatModes, } from "./registry/index.js"; -export type { ActionId, AssetId, DisplayCategoryName, StateId, UnitOfMeasure, ActionsToDirective, AnyDescriptor, CapabilityExtras, Declared, DirectiveDescriptor, EndpointView, EventDescriptor, InterfaceDescriptor, Label, PropertyDescriptor, Semantics, StatesToRange, StatesToValue, ActionName, StateName, InputName, Mode, ThermostatModeName, Infer, Schema, Temperature, TimeInterval, } from "./registry/index.js"; +export { ASSETS as Assets, UNITS_OF_MEASURE as Units, ACTIONS as Actions, STATES as States, CAUSES as Causes, DISPLAY_CATEGORIES as DisplayCategories, INPUTS as Inputs, THERMOSTAT_MODES as ThermostatModes, } from "./registry/index.js"; +export type { ActionId, AssetId, DisplayCategoryName, StateId, UnitOfMeasure, ActionsToDirective, AnyDescriptor, CapabilityExtras, Declared, DirectiveDescriptor, EndpointView, EventDescriptor, InterfaceDescriptor, Label, PropertyDescriptor, Semantics, StatesToRange, StatesToValue, ActionName, StateName, Cause, InputName, InventoryLevel, Mode, ThermostatModeName, Infer, Schema, Temperature, TimeInterval, } from "./registry/index.js"; export * as messages from "./messages/index.js"; export { AlexaError, AlexaErrors, MessageError, StateBuilder, property } from "./messages/index.js"; -export type { ChangeCause, ChangeReportMessage, DeferredResponseMessage, ErrorResponseMessage, Header, ProactiveEventMessage, Property, PropertyOptions, ResponseMessage, SceneEventMessage, } from "./messages/index.js"; +export type { ChangeCause, ChangeReportMessage, DeferredResponseMessage, ErrorResponseMessage, Header, ProactiveEventFields, ProactiveEventMessage, Property, PropertyOptions, ResponseMessage, SceneEventMessage, } from "./messages/index.js"; export * as topics from "./topics.js"; export { MemoryPublisher } from "./transport.js"; export type { Publisher, PublishResult } from "./transport.js"; diff --git a/dist/esm/index.js b/dist/esm/index.js index 6bbaf62..3216f90 100644 --- a/dist/esm/index.js +++ b/dist/esm/index.js @@ -4,11 +4,11 @@ export { default as Device } from "./device/Device.js"; export { Capability } from "./device/Capability.js"; // The interfaces, for device.add(), and the registry that holds them by name export { registry, DeclarationError, SchemaError } from "./registry/index.js"; -export { Alexa, BrightnessController, ChannelController, ColorController, ColorTemperatureController, ContactSensor, EqualizerController, HumiditySensor, InputController, LockController, ModeController, MotionSensor, PercentageController, PlaybackController, PlaybackStateReporter, PowerLevelController, RangeController, SecurityPanelController, Speaker, StepSpeaker, TemperatureSensor, ThermostatController, ThermostatControllerSchedule, ToggleController, } from "./registry/index.js"; +export { Alexa, BrightnessController, ChannelController, ColorController, ColorTemperatureController, ContactSensor, DoorbellEventSource, EqualizerController, HumiditySensor, InputController, InventoryLevelSensor, LockController, ModeController, MotionSensor, PercentageController, PlaybackController, PlaybackStateReporter, PowerLevelController, RangeController, SceneController, SecurityPanelController, SimpleEventSource, Speaker, StepSpeaker, TemperatureSensor, ThermostatController, ThermostatControllerSchedule, TimeHoldController, ToggleController, WakeOnLANController, } from "./registry/index.js"; export { PowerController, EndpointHealth } from "./compat/enums.js"; export { asset, text, semantics, SemanticsBuilder } from "./registry/index.js"; // The vocabularies of the Smart Home API -export { ASSETS as Assets, UNITS_OF_MEASURE as Units, ACTIONS as Actions, STATES as States, DISPLAY_CATEGORIES as DisplayCategories, INPUTS as Inputs, THERMOSTAT_MODES as ThermostatModes, } from "./registry/index.js"; +export { ASSETS as Assets, UNITS_OF_MEASURE as Units, ACTIONS as Actions, STATES as States, CAUSES as Causes, DISPLAY_CATEGORIES as DisplayCategories, INPUTS as Inputs, THERMOSTAT_MODES as ThermostatModes, } from "./registry/index.js"; // The messages: what the bridge publishes, built from plain values export * as messages from "./messages/index.js"; export { AlexaError, AlexaErrors, MessageError, StateBuilder, property } from "./messages/index.js"; diff --git a/dist/esm/messages/build.d.ts b/dist/esm/messages/build.d.ts index a5ba823..bf44eff 100644 --- a/dist/esm/messages/build.d.ts +++ b/dist/esm/messages/build.d.ts @@ -78,6 +78,19 @@ export interface SceneEventFields extends Answer { } /** The answer to Activate and Deactivate of a scene (alexa-scenecontroller.html). */ export declare function sceneEvent(fields: SceneEventFields): SceneEventMessage; +export interface ProactiveEventFields extends Envelope { + /** "Alexa.DoorbellEventSource" */ + namespace: string; + /** "DoorbellPress" */ + name: string; + /** The instance of a generic interface that raises the event. */ + instance?: string; + /** Default: "3". */ + payloadVersion?: string; + payload: Record; +} +/** An event a device raises by itself, of any interface. The header has no correlationToken: nobody asked. */ +export declare function proactiveEvent(fields: ProactiveEventFields): ProactiveEventMessage; export interface DoorbellPressFields extends Envelope { /** Default: "PHYSICAL_INTERACTION", somebody pressed the button. */ cause?: ChangeCause; diff --git a/dist/esm/messages/build.js b/dist/esm/messages/build.js index 7989381..9159b1d 100644 --- a/dist/esm/messages/build.js +++ b/dist/esm/messages/build.js @@ -99,27 +99,26 @@ export function sceneEvent(fields) { context: {}, }; } -/** Somebody rang (alexa-doorbelleventsource.html). */ -export function doorbellPress(fields) { +/** An event a device raises by itself, of any interface. The header has no correlationToken: nobody asked. */ +export function proactiveEvent(fields) { + const { namespace, name, instance, messageId, payloadVersion, payload } = fields; return { event: { - header: header("Alexa.DoorbellEventSource", "DoorbellPress", { messageId: fields.messageId }), + header: header(namespace, name, { instance, messageId, payloadVersion }), endpoint: { endpointId: fields.endpointId }, - payload: { cause: { type: fields.cause ?? "PHYSICAL_INTERACTION" }, timestamp: isoTime(fields.timestamp) }, + payload, }, }; } +/** Somebody rang (alexa-doorbelleventsource.html). */ +export function doorbellPress(fields) { + const { endpointId, messageId } = fields; + const payload = { cause: { type: fields.cause ?? "PHYSICAL_INTERACTION" }, timestamp: isoTime(fields.timestamp) }; + return proactiveEvent({ endpointId, messageId, namespace: "Alexa.DoorbellEventSource", name: "DoorbellPress", payload }); +} /** An event of a button or a sensor that routines start on (alexa-simpleeventsource.html). */ export function simpleEvent(fields) { - return { - event: { - header: header("Alexa.SimpleEventSource", "Event", { - instance: fields.instance, - messageId: fields.messageId, - payloadVersion: "1.0", - }), - endpoint: { endpointId: fields.endpointId }, - payload: { id: fields.id, timestamp: isoTime(fields.timestamp) }, - }, - }; + const { endpointId, messageId, instance } = fields; + const payload = { id: fields.id, timestamp: isoTime(fields.timestamp) }; + return proactiveEvent({ endpointId, messageId, instance, namespace: "Alexa.SimpleEventSource", name: "Event", payloadVersion: "1.0", payload }); } diff --git a/dist/esm/messages/index.d.ts b/dist/esm/messages/index.d.ts index 95bc693..1fe2b45 100644 --- a/dist/esm/messages/index.d.ts +++ b/dist/esm/messages/index.d.ts @@ -1,5 +1,5 @@ -export { changeReport, deferredResponse, doorbellPress, errorResponse, response, sceneEvent, simpleEvent, stateReport, MessageError, } from "./build.js"; -export type { ChangeReportFields, DoorbellPressFields, ErrorResponseFields, ResponseFields, SceneEventFields, SimpleEventFields, } from "./build.js"; +export { changeReport, deferredResponse, doorbellPress, errorResponse, proactiveEvent, response, sceneEvent, simpleEvent, stateReport, MessageError, } from "./build.js"; +export type { ChangeReportFields, DoorbellPressFields, ErrorResponseFields, ProactiveEventFields, ResponseFields, SceneEventFields, SimpleEventFields, } from "./build.js"; export { AlexaError, AlexaErrors, errorNamespace } from "./errors.js"; export type { ChargeState, ControlUnavailableReason, CurrentDeviceMode } from "./errors.js"; export { property } from "./property.js"; diff --git a/dist/esm/messages/index.js b/dist/esm/messages/index.js index 1c6292b..88c78d4 100644 --- a/dist/esm/messages/index.js +++ b/dist/esm/messages/index.js @@ -1,5 +1,5 @@ // The messages of the bridge without the bridge: builders, the state collector and the errors. -export { changeReport, deferredResponse, doorbellPress, errorResponse, response, sceneEvent, simpleEvent, stateReport, MessageError, } from "./build.js"; +export { changeReport, deferredResponse, doorbellPress, errorResponse, proactiveEvent, response, sceneEvent, simpleEvent, stateReport, MessageError, } from "./build.js"; export { AlexaError, AlexaErrors, errorNamespace } from "./errors.js"; export { property } from "./property.js"; export { StateBuilder } from "./StateBuilder.js"; diff --git a/dist/esm/messages/types.d.ts b/dist/esm/messages/types.d.ts index 8d3ff62..aa644f3 100644 --- a/dist/esm/messages/types.d.ts +++ b/dist/esm/messages/types.d.ts @@ -1,8 +1,6 @@ -/** - * Why a property changed or an event was raised (message-guide.html, "Cause object"). RULE_TRIGGER is not in that - * table; the ChangeReport examples of alexa-securitypanelcontroller.html and alexa-thermostatcontroller.html use it. - */ -export type ChangeCause = "APP_INTERACTION" | "PERIODIC_POLL" | "PHYSICAL_INTERACTION" | "RULE_TRIGGER" | "VOICE_INTERACTION"; +import type { Cause } from "../registry/catalog.js"; +/** Why a property changed or an event was raised: one of CAUSES. */ +export type ChangeCause = Cause; export interface Header { namespace: string; name: string; diff --git a/dist/esm/registry/catalog.d.ts b/dist/esm/registry/catalog.d.ts index 789f869..74d2e41 100644 --- a/dist/esm/registry/catalog.d.ts +++ b/dist/esm/registry/catalog.d.ts @@ -21,6 +21,12 @@ export declare const DISPLAY_CATEGORIES: readonly ["ACTIVITY_TRIGGER", "AIR_COND export type DisplayCategoryName = (typeof DISPLAY_CATEGORIES)[number]; /** Not to be used as a friendly name (resources-and-assets.html, "Reserved words"). */ export declare const RESERVED_WORDS: readonly ["alarm", "alarms", "all alarms", "away mode", "bass", "camera", "date", "date today", "day", "do not disturb", "drop in", "music", "night light", "notification", "playing", "sleep sounds", "time", "timer", "today in music", "treble", "volume", "way f. m."]; +/** + * Why a property changed or an event was raised (message-guide.html, "Cause object"). RULE_TRIGGER is not in that + * table; the ChangeReport examples of alexa-securitypanelcontroller.html and alexa-thermostatcontroller.html use it. + */ +export declare const CAUSES: readonly ["APP_INTERACTION", "PERIODIC_POLL", "PHYSICAL_INTERACTION", "RULE_TRIGGER", "VOICE_INTERACTION"]; +export type Cause = (typeof CAUSES)[number]; /** * What a discovery answer may hold (alexa-discovery.html, "Interface limits"; alexa-discovery-objects.html, * "Endpoint object details" and "AdditionalAttributes object details"; alexa-scenecontroller.html, "Discovery"). diff --git a/dist/esm/registry/catalog.js b/dist/esm/registry/catalog.js index 854b016..654b2cc 100644 --- a/dist/esm/registry/catalog.js +++ b/dist/esm/registry/catalog.js @@ -67,6 +67,11 @@ export const RESERVED_WORDS = [ "drop in", "music", "night light", "notification", "playing", "sleep sounds", "time", "timer", "today in music", "treble", "volume", "way f. m.", ]; +/** + * Why a property changed or an event was raised (message-guide.html, "Cause object"). RULE_TRIGGER is not in that + * table; the ChangeReport examples of alexa-securitypanelcontroller.html and alexa-thermostatcontroller.html use it. + */ +export const CAUSES = ["APP_INTERACTION", "PERIODIC_POLL", "PHYSICAL_INTERACTION", "RULE_TRIGGER", "VOICE_INTERACTION"]; /** * What a discovery answer may hold (alexa-discovery.html, "Interface limits"; alexa-discovery-objects.html, * "Endpoint object details" and "AdditionalAttributes object details"; alexa-scenecontroller.html, "Discovery"). diff --git a/dist/esm/registry/events.d.ts b/dist/esm/registry/events.d.ts new file mode 100644 index 0000000..e91f41b --- /dev/null +++ b/dist/esm/registry/events.d.ts @@ -0,0 +1,13 @@ +import type { Cause } from "./catalog.js"; +/** The time of an event: now, unless the payload says when. */ +export declare const timestamp: import("./schema.js").Schema; +/** + * The payload of an event that happened at a time and for a reason: ActivationStarted, DoorbellPress. Both are + * required by Alexa. The cause is the usual one of the event unless the payload names another. + */ +export declare const happened: (usually: Cause) => import("./schema.js").Schema; + timestamp: import("./schema.js").Schema; +}>>; diff --git a/dist/esm/registry/events.js b/dist/esm/registry/events.js new file mode 100644 index 0000000..781856a --- /dev/null +++ b/dist/esm/registry/events.js @@ -0,0 +1,13 @@ +// What the events of several interfaces have in common. +import { CAUSES } from "./catalog.js"; +import { s } from "./schema.js"; +/** The time of an event: now, unless the payload says when. */ +export const timestamp = s.defaulted(s.dateTime(), () => new Date().toISOString()); +/** + * The payload of an event that happened at a time and for a reason: ActivationStarted, DoorbellPress. Both are + * required by Alexa. The cause is the usual one of the event unless the payload names another. + */ +export const happened = (usually) => s.object({ + cause: s.defaulted(s.object({ type: s.enum(...CAUSES) }), () => ({ type: usually })), + timestamp, +}); diff --git a/dist/esm/registry/index.d.ts b/dist/esm/registry/index.d.ts index e1db8d6..ffa9b81 100644 --- a/dist/esm/registry/index.d.ts +++ b/dist/esm/registry/index.d.ts @@ -4,10 +4,12 @@ import { ChannelController } from "./interfaces/ChannelController.js"; import { ColorController } from "./interfaces/ColorController.js"; import { ColorTemperatureController } from "./interfaces/ColorTemperatureController.js"; import { ContactSensor } from "./interfaces/ContactSensor.js"; +import { DoorbellEventSource } from "./interfaces/DoorbellEventSource.js"; import { EndpointHealth } from "./interfaces/EndpointHealth.js"; import { EqualizerController } from "./interfaces/EqualizerController.js"; import { HumiditySensor } from "./interfaces/HumiditySensor.js"; import { InputController } from "./interfaces/InputController.js"; +import { InventoryLevelSensor } from "./interfaces/InventoryLevelSensor.js"; import { LockController } from "./interfaces/LockController.js"; import { ModeController } from "./interfaces/ModeController.js"; import { MotionSensor } from "./interfaces/MotionSensor.js"; @@ -17,13 +19,17 @@ import { PlaybackStateReporter } from "./interfaces/PlaybackStateReporter.js"; import { PowerController } from "./interfaces/PowerController.js"; import { PowerLevelController } from "./interfaces/PowerLevelController.js"; import { RangeController } from "./interfaces/RangeController.js"; +import { SceneController } from "./interfaces/SceneController.js"; import { SecurityPanelController } from "./interfaces/SecurityPanelController.js"; +import { SimpleEventSource } from "./interfaces/SimpleEventSource.js"; import { Speaker } from "./interfaces/Speaker.js"; import { StepSpeaker } from "./interfaces/StepSpeaker.js"; import { TemperatureSensor } from "./interfaces/TemperatureSensor.js"; import { ThermostatController } from "./interfaces/ThermostatController.js"; import { ThermostatControllerSchedule } from "./interfaces/ThermostatControllerSchedule.js"; +import { TimeHoldController } from "./interfaces/TimeHoldController.js"; import { ToggleController } from "./interfaces/ToggleController.js"; +import { WakeOnLANController } from "./interfaces/WakeOnLANController.js"; import type { AnyDescriptor } from "./types.js"; export declare const registry: { /** Whether an interface of this name is known. */ @@ -33,9 +39,10 @@ export declare const registry: { /** Every descriptor, ordered by namespace. */ list(): AnyDescriptor[]; }; -export { Alexa, BrightnessController, ChannelController, ColorController, ColorTemperatureController, ContactSensor, EndpointHealth, EqualizerController, HumiditySensor, InputController, LockController, ModeController, MotionSensor, PercentageController, PlaybackController, PlaybackStateReporter, PowerController, PowerLevelController, RangeController, SecurityPanelController, Speaker, StepSpeaker, TemperatureSensor, ThermostatController, ThermostatControllerSchedule, ToggleController, }; +export { Alexa, BrightnessController, ChannelController, ColorController, ColorTemperatureController, ContactSensor, DoorbellEventSource, EndpointHealth, EqualizerController, HumiditySensor, InputController, InventoryLevelSensor, LockController, ModeController, MotionSensor, PercentageController, PlaybackController, PlaybackStateReporter, PowerController, PowerLevelController, RangeController, SceneController, SecurityPanelController, SimpleEventSource, Speaker, StepSpeaker, TemperatureSensor, ThermostatController, ThermostatControllerSchedule, TimeHoldController, ToggleController, WakeOnLANController, }; export { INPUTS } from "./interfaces/InputController.js"; export type { InputName } from "./interfaces/InputController.js"; +export type { InventoryLevel } from "./interfaces/InventoryLevelSensor.js"; export type { Mode } from "./interfaces/ModeController.js"; export { THERMOSTAT_MODES } from "./interfaces/ThermostatController.js"; export type { ThermostatModeName } from "./interfaces/ThermostatController.js"; diff --git a/dist/esm/registry/index.js b/dist/esm/registry/index.js index 777213d..cf6f600 100644 --- a/dist/esm/registry/index.js +++ b/dist/esm/registry/index.js @@ -5,10 +5,12 @@ import { ChannelController } from "./interfaces/ChannelController.js"; import { ColorController } from "./interfaces/ColorController.js"; import { ColorTemperatureController } from "./interfaces/ColorTemperatureController.js"; import { ContactSensor } from "./interfaces/ContactSensor.js"; +import { DoorbellEventSource } from "./interfaces/DoorbellEventSource.js"; import { EndpointHealth } from "./interfaces/EndpointHealth.js"; import { EqualizerController } from "./interfaces/EqualizerController.js"; import { HumiditySensor } from "./interfaces/HumiditySensor.js"; import { InputController } from "./interfaces/InputController.js"; +import { InventoryLevelSensor } from "./interfaces/InventoryLevelSensor.js"; import { LockController } from "./interfaces/LockController.js"; import { ModeController } from "./interfaces/ModeController.js"; import { MotionSensor } from "./interfaces/MotionSensor.js"; @@ -18,13 +20,17 @@ import { PlaybackStateReporter } from "./interfaces/PlaybackStateReporter.js"; import { PowerController } from "./interfaces/PowerController.js"; import { PowerLevelController } from "./interfaces/PowerLevelController.js"; import { RangeController } from "./interfaces/RangeController.js"; +import { SceneController } from "./interfaces/SceneController.js"; import { SecurityPanelController } from "./interfaces/SecurityPanelController.js"; +import { SimpleEventSource } from "./interfaces/SimpleEventSource.js"; import { Speaker } from "./interfaces/Speaker.js"; import { StepSpeaker } from "./interfaces/StepSpeaker.js"; import { TemperatureSensor } from "./interfaces/TemperatureSensor.js"; import { ThermostatController } from "./interfaces/ThermostatController.js"; import { ThermostatControllerSchedule } from "./interfaces/ThermostatControllerSchedule.js"; +import { TimeHoldController } from "./interfaces/TimeHoldController.js"; import { ToggleController } from "./interfaces/ToggleController.js"; +import { WakeOnLANController } from "./interfaces/WakeOnLANController.js"; import { STUBS } from "./interfaces/stubs.js"; import { DeclarationError } from "./types.js"; const described = [ @@ -34,10 +40,12 @@ const described = [ ColorController, ColorTemperatureController, ContactSensor, + DoorbellEventSource, EndpointHealth, EqualizerController, HumiditySensor, InputController, + InventoryLevelSensor, LockController, ModeController, MotionSensor, @@ -47,13 +55,17 @@ const described = [ PowerController, PowerLevelController, RangeController, + SceneController, SecurityPanelController, + SimpleEventSource, Speaker, StepSpeaker, TemperatureSensor, ThermostatController, ThermostatControllerSchedule, + TimeHoldController, ToggleController, + WakeOnLANController, ]; const descriptors = new Map(); for (const descriptor of [...described, ...STUBS]) { @@ -79,7 +91,7 @@ export const registry = { return [...descriptors.values()].sort((a, b) => (a.namespace < b.namespace ? -1 : 1)); }, }; -export { Alexa, BrightnessController, ChannelController, ColorController, ColorTemperatureController, ContactSensor, EndpointHealth, EqualizerController, HumiditySensor, InputController, LockController, ModeController, MotionSensor, PercentageController, PlaybackController, PlaybackStateReporter, PowerController, PowerLevelController, RangeController, SecurityPanelController, Speaker, StepSpeaker, TemperatureSensor, ThermostatController, ThermostatControllerSchedule, ToggleController, }; +export { Alexa, BrightnessController, ChannelController, ColorController, ColorTemperatureController, ContactSensor, DoorbellEventSource, EndpointHealth, EqualizerController, HumiditySensor, InputController, InventoryLevelSensor, LockController, ModeController, MotionSensor, PercentageController, PlaybackController, PlaybackStateReporter, PowerController, PowerLevelController, RangeController, SceneController, SecurityPanelController, SimpleEventSource, Speaker, StepSpeaker, TemperatureSensor, ThermostatController, ThermostatControllerSchedule, TimeHoldController, ToggleController, WakeOnLANController, }; export { INPUTS } from "./interfaces/InputController.js"; export { THERMOSTAT_MODES } from "./interfaces/ThermostatController.js"; export { asset, text } from "./resources.js"; diff --git a/dist/esm/registry/interfaces/DoorbellEventSource.d.ts b/dist/esm/registry/interfaces/DoorbellEventSource.d.ts new file mode 100644 index 0000000..3920bf9 --- /dev/null +++ b/dist/esm/registry/interfaces/DoorbellEventSource.d.ts @@ -0,0 +1,6 @@ +/** + * A doorbell. It takes no directive and reports nothing; device.raise(DoorbellEventSource, "DoorbellPress") says + * that somebody rang. Alexa wants 30 seconds between two presses of one doorbell, and announces a press when the + * user switched the announcements on for the doorbell in the Alexa app. + */ +export declare const DoorbellEventSource: import("../types.js").InterfaceDescriptor<{}, {}, {}, false>; diff --git a/dist/esm/registry/interfaces/DoorbellEventSource.js b/dist/esm/registry/interfaces/DoorbellEventSource.js new file mode 100644 index 0000000..20739d8 --- /dev/null +++ b/dist/esm/registry/interfaces/DoorbellEventSource.js @@ -0,0 +1,31 @@ +import { happened } from "../events.js"; +import { DeclarationError, defineInterface } from "../types.js"; +/** + * A doorbell. It takes no directive and reports nothing; device.raise(DoorbellEventSource, "DoorbellPress") says + * that somebody rang. Alexa wants 30 seconds between two presses of one doorbell, and announces a press when the + * user switched the announcements on for the doorbell in the Alexa app. + */ +export const DoorbellEventSource = defineInterface({ + namespace: "Alexa.DoorbellEventSource", + version: "3", + doc: "https://developer.amazon.com/docs/alexaplus/device-apis/alexa-doorbelleventsource.html", + kind: "eventSource", + tier: 2, + instanced: false, + properties: {}, + directives: {}, + events: { + DoorbellPress: { name: "DoorbellPress", payload: happened("PHYSICAL_INTERACTION"), topic: "proactive" }, + }, + // The capability is the announcement that the endpoint raises the event: proactivelyReported whatever was declared + discovery: () => ({ properties: false, topLevel: { proactivelyReported: true } }), + // alexa-doorbelleventsource.html, "Discovery" + validate(capability, { displayCategories }) { + const doorbell = displayCategories.indexOf("DOORBELL"); + if (doorbell < 0) + throw new DeclarationError(capability, "a doorbell has the display category DOORBELL"); + if (displayCategories.indexOf("CAMERA") > doorbell) { + throw new DeclarationError(capability, "a video doorbell lists the display category CAMERA before DOORBELL"); + } + }, +}); diff --git a/dist/esm/registry/interfaces/InventoryLevelSensor.d.ts b/dist/esm/registry/interfaces/InventoryLevelSensor.d.ts new file mode 100644 index 0000000..b317480 --- /dev/null +++ b/dist/esm/registry/interfaces/InventoryLevelSensor.d.ts @@ -0,0 +1,34 @@ +import type { Infer, Schema } from "../schema.js"; +declare const level: Schema; + value: Schema; + unit: import("../schema.js").OptionalSchema; +}>>; +export type InventoryLevel = Infer; +/** + * How much is left of something a device uses up: ink, paper, detergent. Each instance is one sensor, and Amazon + * orders a refill by its Dash replenishment id. The level is reported, there is nothing to ask for by voice. + */ +export declare const InventoryLevelSensor: import("../types.js").InterfaceDescriptor<{ + level: { + name: string; + value: Schema; + value: Schema; + unit: import("../schema.js").OptionalSchema; + }>>; + note: string; + }; +}, {}, import("../schema.js").InferShape<{ + /** How the level is measured: { "@type": "Volume", unit: "MILLILITER" }, { "@type": "Count" }. */ + measurement: Schema; + unit: import("../schema.js").OptionalSchema; + }>>; + /** The id the Dash console gave for the product. */ + replenishment: Schema; + value: Schema; + }>>; +}>, true>; +export {}; diff --git a/dist/esm/registry/interfaces/InventoryLevelSensor.js b/dist/esm/registry/interfaces/InventoryLevelSensor.js new file mode 100644 index 0000000..0eee226 --- /dev/null +++ b/dist/esm/registry/interfaces/InventoryLevelSensor.js @@ -0,0 +1,51 @@ +import { at, mismatch, s } from "../schema.js"; +import { defineInterface } from "../types.js"; +// As the examples of the page spell them; its table has them in lower case +const KINDS = ["Count", "Percentage", "Volume", "Weight"]; +// A volume and a weight have a unit: MILLILITER, GRAM (alexa-property-schemas.html, "Volume unit values" and +// "Weight unit values") +function withUnit(fields) { + return { + expects: fields.expects, + parse(input, path = "") { + const measured = fields.parse(input, path); + const what = measured["@type"].toLowerCase(); + const needed = what === "volume" || what === "weight"; + if (needed && measured.unit === undefined) + throw mismatch(at(path, "unit"), `the unit of the ${what}, like MILLILITER or GRAM`, undefined); + if (!needed && measured.unit !== undefined) + throw mismatch(at(path, "unit"), `no unit for a ${what}`, measured.unit); + return measured; + }, + }; +} +const kind = s.enum(...KINDS); +const measurement = withUnit(s.object({ "@type": kind, unit: s.optional(s.string({ min: 1 })) }, { unknownKeys: "reject" })); +const level = withUnit(s.object({ "@type": kind, value: s.number({ min: 0 }), unit: s.optional(s.string({ min: 1 })) })); +/** + * How much is left of something a device uses up: ink, paper, detergent. Each instance is one sensor, and Amazon + * orders a refill by its Dash replenishment id. The level is reported, there is nothing to ask for by voice. + */ +export const InventoryLevelSensor = defineInterface({ + namespace: "Alexa.InventoryLevelSensor", + version: "3", + doc: "https://developer.amazon.com/docs/alexaplus/device-apis/alexa-inventorylevelsensor.html", + kind: "sensor", + tier: 2, + instanced: true, + properties: { + level: { name: "level", value: level, note: "measured as the capability declares it" }, + }, + directives: {}, + options: s.object({ + /** How the level is measured: { "@type": "Volume", unit: "MILLILITER" }, { "@type": "Count" }. */ + measurement, + /** The id the Dash console gave for the product. */ + replenishment: s.object({ "@type": s.enum("DashReplenishmentId"), value: s.string({ min: 1 }) }, { unknownKeys: "reject" }), + }, { unknownKeys: "reject" }), + // A capability declared the 1.x way has neither; check() says so + discovery({ options: { measurement: measured, replenishment } }) { + const configuration = { ...(measured && { measurement: measured }), ...(replenishment && { replenishment }) }; + return Object.keys(configuration).length > 0 ? { configuration } : {}; + }, +}); diff --git a/dist/esm/registry/interfaces/SceneController.d.ts b/dist/esm/registry/interfaces/SceneController.d.ts new file mode 100644 index 0000000..cf36e95 --- /dev/null +++ b/dist/esm/registry/interfaces/SceneController.d.ts @@ -0,0 +1,21 @@ +/** + * A scene: an endpoint that is not a device, but several devices set to a state each. Activate is answered with + * ActivationStarted and Deactivate with DeactivationStarted; ctx.respond() sends them, with the time and + * VOICE_INTERACTION as the cause. A scene reports nothing and has no Alexa.EndpointHealth. + * + * Alexa takes no scene with a lock, a garage door, a camera, a cooking appliance or a security device in it. + */ +export declare const SceneController: import("../types.js").InterfaceDescriptor<{}, { + Activate: { + name: string; + payload: import("../schema.js").Schema>; + }; + Deactivate: { + name: string; + payload: import("../schema.js").Schema>; + when: ({ options }: import("../types.js").Declared) => boolean; + }; +}, import("../schema.js").InferShape<{ + /** The scene can be switched off again. */ + supportsDeactivation: import("../schema.js").OptionalSchema; +}>, false>; diff --git a/dist/esm/registry/interfaces/SceneController.js b/dist/esm/registry/interfaces/SceneController.js new file mode 100644 index 0000000..b3e23a5 --- /dev/null +++ b/dist/esm/registry/interfaces/SceneController.js @@ -0,0 +1,64 @@ +import { LIMITS } from "../catalog.js"; +import { happened } from "../events.js"; +import { s } from "../schema.js"; +import { DeclarationError, defineInterface } from "../types.js"; +const started = (name) => ({ name, payload: happened("VOICE_INTERACTION"), topic: "response" }); +const events = { + ActivationStarted: started("ActivationStarted"), + DeactivationStarted: started("DeactivationStarted"), +}; +/** + * A scene: an endpoint that is not a device, but several devices set to a state each. Activate is answered with + * ActivationStarted and Deactivate with DeactivationStarted; ctx.respond() sends them, with the time and + * VOICE_INTERACTION as the cause. A scene reports nothing and has no Alexa.EndpointHealth. + * + * Alexa takes no scene with a lock, a garage door, a camera, a cooking appliance or a security device in it. + */ +export const SceneController = defineInterface({ + namespace: "Alexa.SceneController", + version: "3", + doc: "https://developer.amazon.com/docs/alexaplus/device-apis/alexa-scenecontroller.html", + kind: "controller", + tier: 1, + instanced: false, + properties: {}, + directives: { + Activate: { name: "Activate", payload: s.object({}) }, + Deactivate: { + name: "Deactivate", + payload: s.object({}), + when: ({ options }) => options.supportsDeactivation !== false, + }, + }, + events, + options: s.object({ + /** The scene can be switched off again. */ + supportsDeactivation: s.optional(s.boolean()), + }, { unknownKeys: "reject" }), + // A scene declared without the option is announced as 1.5.2 announced every scene, which Alexa accepted: it can + // be deactivated, and proactivelyReported is on the capability. The example of the page has supportsDeactivation + // alone. + discovery({ proactivelyReported, options: { supportsDeactivation } }) { + const topLevel = supportsDeactivation === undefined ? { supportsDeactivation: true, proactivelyReported } : { supportsDeactivation }; + return { properties: false, topLevel }; + }, + // alexa-scenecontroller.html, "Discovery" + validate(capability, endpoint) { + const { friendlyName, description, displayCategories } = endpoint; + if (!displayCategories.includes("SCENE_TRIGGER") && !displayCategories.includes("ACTIVITY_TRIGGER")) { + throw new DeclarationError(capability, "a scene has the display category SCENE_TRIGGER, or ACTIVITY_TRIGGER when the order of its steps matters"); + } + if (!/scene/i.test(description)) { + throw new DeclarationError(capability, 'the description of a scene has the word "scene" in it, like "Party scene connected by Alex2Node"'); + } + if (friendlyName.length > LIMITS.sceneFriendlyNameLength) { + throw new DeclarationError(capability, `the name of a scene takes up to ${LIMITS.sceneFriendlyNameLength} characters, this one has ${friendlyName.length}`); + } + }, + responseFor(directive) { + if (directive !== "Activate" && directive !== "Deactivate") + return undefined; + const { name, payload } = directive === "Activate" ? events.ActivationStarted : events.DeactivationStarted; + return { namespace: "Alexa.SceneController", name, payload }; + }, +}); diff --git a/dist/esm/registry/interfaces/SimpleEventSource.d.ts b/dist/esm/registry/interfaces/SimpleEventSource.d.ts new file mode 100644 index 0000000..99d4959 --- /dev/null +++ b/dist/esm/registry/interfaces/SimpleEventSource.d.ts @@ -0,0 +1,15 @@ +/** + * A button of a remote, or anything else that routines start on. Each instance is one button with the events it + * has, a single and a double push; device.raise(SimpleEventSource, "Event", { id }, { instance }) says that one + * happened. Alexa wants the instance to be unique among all endpoints, "preferably a version 4 UUID". + * + * The page folds its discovery example away. The capability is announced with the fields of its table and without a + * properties object, as the interface has no property. + */ +export declare const SimpleEventSource: import("../types.js").InterfaceDescriptor<{}, {}, import("../schema.js").InferShape<{ + /** For a REMOTE the names are assets of the catalog: Alexa.Button.SinglePush, Alexa.Gesture.Tap. */ + supportedEvents: import("../schema.js").Schema; + friendlyNames: import("../schema.js").Schema; + }>[]>; +}>, true>; diff --git a/dist/esm/registry/interfaces/SimpleEventSource.js b/dist/esm/registry/interfaces/SimpleEventSource.js new file mode 100644 index 0000000..1e15caa --- /dev/null +++ b/dist/esm/registry/interfaces/SimpleEventSource.js @@ -0,0 +1,50 @@ +import { timestamp } from "../events.js"; +import { labels, shownAs } from "../resources.js"; +import { s } from "../schema.js"; +import { DeclarationError, defineInterface } from "../types.js"; +/** + * A button of a remote, or anything else that routines start on. Each instance is one button with the events it + * has, a single and a double push; device.raise(SimpleEventSource, "Event", { id }, { instance }) says that one + * happened. Alexa wants the instance to be unique among all endpoints, "preferably a version 4 UUID". + * + * The page folds its discovery example away. The capability is announced with the fields of its table and without a + * properties object, as the interface has no property. + */ +export const SimpleEventSource = defineInterface({ + namespace: "Alexa.SimpleEventSource", + version: "1.0", + doc: "https://developer.amazon.com/docs/alexaplus/device-apis/alexa-simpleeventsource.html", + kind: "eventSource", + tier: 2, + instanced: true, + properties: {}, + directives: {}, + events: { + Event: { + name: "Event", + /** id: the id of one of the supportedEvents of the instance. */ + payload: s.object({ id: s.string({ min: 1 }), timestamp }), + topic: "proactive", + }, + }, + options: s.object({ + /** For a REMOTE the names are assets of the catalog: Alexa.Button.SinglePush, Alexa.Gesture.Tap. */ + supportedEvents: s.array(s.object({ id: s.string({ min: 1 }), friendlyNames: labels }, { unknownKeys: "reject" }), { min: 1 }), + }, { unknownKeys: "reject" }), + // A capability declared the 1.x way has no events; check() says so + discovery: ({ options: { supportedEvents } }) => ({ + properties: false, + ...(supportedEvents && { configuration: { supportedEvents } }), + }), + validate(capability) { + const { supportedEvents } = capability.options; + const ids = supportedEvents.map(({ id }) => id); + // "The first friendly name in the array must be unique per instance of the capability." + const names = supportedEvents.map(({ friendlyNames: [first] }) => shownAs(first)); + const twice = (list) => list.find((entry, i) => list.indexOf(entry) !== i); + if (twice(ids)) + throw new DeclarationError(capability, `the event ${twice(ids)} is listed twice`); + if (twice(names)) + throw new DeclarationError(capability, `two events have ${twice(names)} as their first friendly name, the Alexa app shows an event by it`); + }, +}); diff --git a/dist/esm/registry/interfaces/TimeHoldController.d.ts b/dist/esm/registry/interfaces/TimeHoldController.d.ts new file mode 100644 index 0000000..b523cae --- /dev/null +++ b/dist/esm/registry/interfaces/TimeHoldController.d.ts @@ -0,0 +1,27 @@ +/** + * Pausing what a device does, a microwave that heats, and going on with it. Amazon pairs the interface with + * Alexa.Cooking. Alexa sends Resume to a device declared with allowRemoteResume only; for another one it asks the + * user to press start on the device. + */ +export declare const TimeHoldController: import("../types.js").InterfaceDescriptor<{ + holdStartTime: { + name: string; + value: import("../schema.js").Schema; + }; + holdEndTime: { + name: string; + value: import("../schema.js").Schema; + }; +}, { + Hold: { + name: string; + payload: import("../schema.js").Schema>; + }; + Resume: { + name: string; + payload: import("../schema.js").Schema>; + when: ({ options }: import("../types.js").Declared) => boolean; + }; +}, import("../schema.js").InferShape<{ + allowRemoteResume: import("../schema.js").Schema; +}>, false>; diff --git a/dist/esm/registry/interfaces/TimeHoldController.js b/dist/esm/registry/interfaces/TimeHoldController.js new file mode 100644 index 0000000..50b21ae --- /dev/null +++ b/dist/esm/registry/interfaces/TimeHoldController.js @@ -0,0 +1,28 @@ +import { s } from "../schema.js"; +import { defineInterface } from "../types.js"; +/** + * Pausing what a device does, a microwave that heats, and going on with it. Amazon pairs the interface with + * Alexa.Cooking. Alexa sends Resume to a device declared with allowRemoteResume only; for another one it asks the + * user to press start on the device. + */ +export const TimeHoldController = defineInterface({ + namespace: "Alexa.TimeHoldController", + version: "3", + doc: "https://developer.amazon.com/docs/alexaplus/device-apis/alexa-timeholdcontroller.html", + kind: "controller", + tier: 2, + instanced: false, + properties: { + holdStartTime: { name: "holdStartTime", value: s.dateTime() }, + holdEndTime: { name: "holdEndTime", value: s.dateTime() }, + }, + directives: { + Hold: { name: "Hold", payload: s.object({}) }, + Resume: { name: "Resume", payload: s.object({}), when: ({ options }) => options.allowRemoteResume !== false }, + }, + options: s.object({ + allowRemoteResume: s.boolean(), + }, { unknownKeys: "reject" }), + // A capability declared the 1.x way has no allowRemoteResume; check() says so + discovery: ({ options: { allowRemoteResume } }) => (allowRemoteResume === undefined ? {} : { configuration: { allowRemoteResume } }), +}); diff --git a/dist/esm/registry/interfaces/WakeOnLANController.d.ts b/dist/esm/registry/interfaces/WakeOnLANController.d.ts new file mode 100644 index 0000000..89d58f2 --- /dev/null +++ b/dist/esm/registry/interfaces/WakeOnLANController.d.ts @@ -0,0 +1,10 @@ +/** + * A device that is switched on by a Wake-on-LAN message, which an Echo of the user sends to the MAC addresses of + * the capability. The interface has no directive and no property: the device gets TurnOn of Alexa.PowerController, + * answers with a DeferredResponse, sends the WakeUp event and, when it is up, the Response. + * + * Alex2MQTT has no topic yet that takes the WakeUp event to Alexa. + */ +export declare const WakeOnLANController: import("../types.js").InterfaceDescriptor<{}, {}, import("../schema.js").InferShape<{ + macAddresses: import("../schema.js").Schema; +}>, false>; diff --git a/dist/esm/registry/interfaces/WakeOnLANController.js b/dist/esm/registry/interfaces/WakeOnLANController.js new file mode 100644 index 0000000..e9dc046 --- /dev/null +++ b/dist/esm/registry/interfaces/WakeOnLANController.js @@ -0,0 +1,42 @@ +import { s } from "../schema.js"; +import { DeclarationError, defineInterface } from "../types.js"; +// 00-14-22-01-23-45 in the example of the page; the colon is the other common way to write one +const MAC_ADDRESS = /^[0-9A-Fa-f]{2}([-:][0-9A-Fa-f]{2}){5}$/; +/** + * A device that is switched on by a Wake-on-LAN message, which an Echo of the user sends to the MAC addresses of + * the capability. The interface has no directive and no property: the device gets TurnOn of Alexa.PowerController, + * answers with a DeferredResponse, sends the WakeUp event and, when it is up, the Response. + * + * Alex2MQTT has no topic yet that takes the WakeUp event to Alexa. + */ +export const WakeOnLANController = defineInterface({ + namespace: "Alexa.WakeOnLANController", + version: "3", + doc: "https://developer.amazon.com/docs/alexaplus/device-apis/alexa-wakeonlancontroller.html", + kind: "controller", + tier: 2, + instanced: false, + properties: {}, + directives: {}, + events: { + WakeUp: { name: "WakeUp", payload: s.object({}), topic: "response" }, + }, + options: s.object({ + macAddresses: s.array(s.string({ pattern: MAC_ADDRESS, expects: "a MAC address like 00-14-22-01-23-45" }), { min: 1 }), + }, { unknownKeys: "reject" }), + // The example has a properties object without fields, which topLevel puts where properties: false left none. A + // capability declared the 1.x way has no address; check() says so. + discovery: ({ options: { macAddresses } }) => ({ + properties: false, + topLevel: { properties: {} }, + ...(macAddresses && { configuration: { MACAddresses: macAddresses } }), + }), + validate(capability) { + const addresses = capability.options.macAddresses.map((address) => address.toUpperCase().replace(/:/g, "-")); + const twice = addresses.find((address, i) => addresses.indexOf(address) !== i); + if (twice) + throw new DeclarationError(capability, `${twice} is listed twice`); + }, + deferrable: true, + defers: [{ namespace: "Alexa.PowerController", name: "TurnOn" }], +}); diff --git a/dist/esm/registry/interfaces/stubs.js b/dist/esm/registry/interfaces/stubs.js index 6024766..1ea2e4b 100644 --- a/dist/esm/registry/interfaces/stubs.js +++ b/dist/esm/registry/interfaces/stubs.js @@ -29,8 +29,6 @@ const TABLE = [ ["Alexa.DataController", "1", [], "alexa-datacontroller.html"], ["Alexa.DeviceUsage.Estimation", "1", [], "alexa-deviceusage-estimation.html"], ["Alexa.DeviceUsage.Meter", "1", [], "alexa-deviceusage-meter.html"], - ["Alexa.DoorbellEventSource", "3", [], "alexa-doorbelleventsource.html"], - ["Alexa.InventoryLevelSensor", "1", [], "alexa-inventorylevelsensor.html"], ["Alexa.InventoryLevelUsageSensor", "1", [], "alexa-inventorylevelusagesensor.html"], ["Alexa.InventoryUsageSensor", "1", [], "alexa-inventoryusagesensor.html"], ["Alexa.KeypadController", "1", [], "alexa-keypadcontroller.html"], @@ -42,29 +40,16 @@ const TABLE = [ ["Alexa.RTCSessionController", "1", [], "alexa-rtcsessioncontroller.html"], ["Alexa.RecordController", "3", [], "alexa-recordcontroller.html"], ["Alexa.RemoteVideoPlayer", "1", [], "alexa-remotevideoplayer.html"], - ["Alexa.SceneController", "3", [], "alexa-scenecontroller.html"], ["Alexa.SecurityPanelController.Alert", "1", [], "alexa-securitypanelcontroller-alert.html"], ["Alexa.SeekController", "3", [], "alexa-seekcontroller.html"], - ["Alexa.SimpleEventSource", "1", [], "alexa-simpleeventsource.html"], ["Alexa.SmartVision.ObjectDetectionSensor", "1", [], "alexa-smartvision-objectdetectionsensor.html"], ["Alexa.SmartVision.SnapshotProvider", "1", [], "alexa-smartvision-snapshotprovider.html"], ["Alexa.ThermostatController.Configuration", "1", [], "alexa-thermostatcontroller-configuration.html"], ["Alexa.ThermostatController.HVAC.Components", "1", [], "alexa-thermostatcontroller-hvac-components.html"], - // "UNKNOWN" in 1.5.2 as well; the page is titled "Interface 3" - ["Alexa.TimeHoldController", "3", [], "alexa-timeholdcontroller.html"], ["Alexa.UIController", "1", [], "alexa-uicontroller.html"], ["Alexa.UserPreference", "1", [], "alexa-userpreference.html"], ["Alexa.VideoRecorder", "1", [], "alexa-videorecorder.html"], - ["Alexa.WakeOnLANController", "1", [], "alexa-wakeonlancontroller.html"], ]; -// What 1.5.2 added to the capability object of one of them -const EXTRAS = { - // No properties object; supportsDeactivation and proactivelyReported on the capability itself - "Alexa.SceneController": ({ proactivelyReported }) => ({ - properties: false, - topLevel: { supportsDeactivation: true, proactivelyReported }, - }), -}; function kindOf(namespace) { if (namespace.endsWith("Sensor")) return "sensor"; @@ -82,7 +67,6 @@ function stub([namespace, version, properties, page]) { instanced: false, properties: Object.fromEntries(properties.map((name) => [name, { name, value: s.unknown() }])), directives: {}, - discovery: EXTRAS[namespace], }; } export const STUBS = TABLE.map(stub); diff --git a/dist/esm/registry/schema.d.ts b/dist/esm/registry/schema.d.ts index d2f4856..77b33cc 100644 --- a/dist/esm/registry/schema.d.ts +++ b/dist/esm/registry/schema.d.ts @@ -62,6 +62,8 @@ declare function enumeration(...values: V): EnumSch declare function oneOf(values: readonly V[], expects: string): EnumSchema; declare function unknown(): Schema; declare function optional(inner: Schema): OptionalSchema; +/** A value that is made when none is given: the time of an event, which is now unless the caller says when. */ +declare function defaulted(inner: Schema, make: () => T): Schema; declare function nullable(inner: Schema): Schema; declare function array(item: Schema, rules?: { min?: number; @@ -87,6 +89,7 @@ export declare const s: { oneOf: typeof oneOf; unknown: typeof unknown; optional: typeof optional; + defaulted: typeof defaulted; nullable: typeof nullable; array: typeof array; object: typeof object; diff --git a/dist/esm/registry/schema.js b/dist/esm/registry/schema.js index 6e71d3d..4dbabd0 100644 --- a/dist/esm/registry/schema.js +++ b/dist/esm/registry/schema.js @@ -83,6 +83,13 @@ function optional(inner) { parse: (input, path = "") => (input === undefined ? undefined : inner.parse(input, path)), }; } +/** A value that is made when none is given: the time of an event, which is now unless the caller says when. */ +function defaulted(inner, make) { + return { + expects: inner.expects, + parse: (input, path = "") => (input === undefined ? make() : inner.parse(input, path)), + }; +} function nullable(inner) { const expects = `${inner.expects} or null`; return { @@ -183,6 +190,7 @@ export const s = { oneOf, unknown, optional, + defaulted, nullable, array, object, diff --git a/dist/esm/registry/types.d.ts b/dist/esm/registry/types.d.ts index cbac58c..2daf117 100644 --- a/dist/esm/registry/types.d.ts +++ b/dist/esm/registry/types.d.ts @@ -138,6 +138,14 @@ export interface InterfaceDescriptor

; /** Rules a schema cannot state, for options that passed the schema. Throws DeclarationError. */ validate?: (capability: Declared, endpoint: EndpointView) => void; } diff --git a/dist/esm/topics.d.ts b/dist/esm/topics.d.ts index 3a6ab99..8af84d5 100644 --- a/dist/esm/topics.d.ts +++ b/dist/esm/topics.d.ts @@ -19,3 +19,10 @@ export declare const deferred: (root: string, endpointId: string) => string; export declare const DIRECTIVE_BUDGET_MS = 7000; /** Where every ChangeReport of the root goes: the backend adds the user's token and posts it to Alexa. */ export declare const changeReport: (root: string) => string; +/** + * Where the events go that a device raises by itself, a DoorbellPress: the backend adds the user's token and posts + * them to Alexa. It takes 30 events a minute from a root. + */ +export declare const event: (root: string) => string; +/** The backend drops an event that is longer, as JSON. */ +export declare const EVENT_BYTES = 16000; diff --git a/dist/esm/topics.js b/dist/esm/topics.js index f33deb3..ae85f17 100644 --- a/dist/esm/topics.js +++ b/dist/esm/topics.js @@ -24,3 +24,10 @@ export const deferred = (root, endpointId) => `${root}/${endpointId}/deferredRes export const DIRECTIVE_BUDGET_MS = 7000; /** Where every ChangeReport of the root goes: the backend adds the user's token and posts it to Alexa. */ export const changeReport = (root) => `${root}/changeReport`; +/** + * Where the events go that a device raises by itself, a DoorbellPress: the backend adds the user's token and posts + * them to Alexa. It takes 30 events a minute from a root. + */ +export const event = (root) => `${root}/event`; +/** The backend drops an event that is longer, as JSON. */ +export const EVENT_BYTES = 16000; diff --git a/dist/types/device/Device.d.ts b/dist/types/device/Device.d.ts index f912268..9e6befb 100644 --- a/dist/types/device/Device.d.ts +++ b/dist/types/device/Device.d.ts @@ -8,7 +8,7 @@ import type { ChangeCause } from "../messages/types.js"; import type { DirectiveHandler, Fill } from "../dispatcher.js"; import type { DisplayCategoryName } from "../registry/catalog.js"; import type { Directives, InterfaceDescriptor, Properties } from "../registry/types.js"; -import type { Publisher } from "../transport.js"; +import type { Publisher, PublishResult } from "../transport.js"; import { Capability } from "./Capability.js"; import type { AnyCapability, CapabilityJson, Declaration } from "./Capability.js"; import type { EndpointFields } from "./validate.js"; @@ -102,6 +102,23 @@ declare class Device extends EventEmitter { * (1.5.1). Resolves with the topic published to, or "" when the publish failed (never rejects, 1.5.2). */ sendSceneResponse(correlationToken: string, activated: boolean, cause?: ChangeCause, sendAsync?: boolean): Promise; + /** + * 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 /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: InterfaceDescriptor, name: string, payload?: Record, options?: { + instance?: string; + messageId?: string; + }): Promise; /** * How the device reports its state, every retrievable property of it: * diff --git a/dist/types/index.d.ts b/dist/types/index.d.ts index 6caa0e7..75ec406 100644 --- a/dist/types/index.d.ts +++ b/dist/types/index.d.ts @@ -5,14 +5,14 @@ export type { EndpointDefinition, EndpointJson } from "./device/Device.js"; export { Capability } from "./device/Capability.js"; export type { CapabilityJson, CommonOptions, Declaration } from "./device/Capability.js"; export { registry, DeclarationError, SchemaError } from "./registry/index.js"; -export { Alexa, BrightnessController, ChannelController, ColorController, ColorTemperatureController, ContactSensor, EqualizerController, HumiditySensor, InputController, LockController, ModeController, MotionSensor, PercentageController, PlaybackController, PlaybackStateReporter, PowerLevelController, RangeController, SecurityPanelController, Speaker, StepSpeaker, TemperatureSensor, ThermostatController, ThermostatControllerSchedule, ToggleController, } from "./registry/index.js"; +export { Alexa, BrightnessController, ChannelController, ColorController, ColorTemperatureController, ContactSensor, DoorbellEventSource, EqualizerController, HumiditySensor, InputController, InventoryLevelSensor, LockController, ModeController, MotionSensor, PercentageController, PlaybackController, PlaybackStateReporter, PowerLevelController, RangeController, SceneController, SecurityPanelController, SimpleEventSource, Speaker, StepSpeaker, TemperatureSensor, ThermostatController, ThermostatControllerSchedule, TimeHoldController, ToggleController, WakeOnLANController, } from "./registry/index.js"; export { PowerController, EndpointHealth } from "./compat/enums.js"; export { asset, text, semantics, SemanticsBuilder } from "./registry/index.js"; -export { ASSETS as Assets, UNITS_OF_MEASURE as Units, ACTIONS as Actions, STATES as States, DISPLAY_CATEGORIES as DisplayCategories, INPUTS as Inputs, THERMOSTAT_MODES as ThermostatModes, } from "./registry/index.js"; -export type { ActionId, AssetId, DisplayCategoryName, StateId, UnitOfMeasure, ActionsToDirective, AnyDescriptor, CapabilityExtras, Declared, DirectiveDescriptor, EndpointView, EventDescriptor, InterfaceDescriptor, Label, PropertyDescriptor, Semantics, StatesToRange, StatesToValue, ActionName, StateName, InputName, Mode, ThermostatModeName, Infer, Schema, Temperature, TimeInterval, } from "./registry/index.js"; +export { ASSETS as Assets, UNITS_OF_MEASURE as Units, ACTIONS as Actions, STATES as States, CAUSES as Causes, DISPLAY_CATEGORIES as DisplayCategories, INPUTS as Inputs, THERMOSTAT_MODES as ThermostatModes, } from "./registry/index.js"; +export type { ActionId, AssetId, DisplayCategoryName, StateId, UnitOfMeasure, ActionsToDirective, AnyDescriptor, CapabilityExtras, Declared, DirectiveDescriptor, EndpointView, EventDescriptor, InterfaceDescriptor, Label, PropertyDescriptor, Semantics, StatesToRange, StatesToValue, ActionName, StateName, Cause, InputName, InventoryLevel, Mode, ThermostatModeName, Infer, Schema, Temperature, TimeInterval, } from "./registry/index.js"; export * as messages from "./messages/index.js"; export { AlexaError, AlexaErrors, MessageError, StateBuilder, property } from "./messages/index.js"; -export type { ChangeCause, ChangeReportMessage, DeferredResponseMessage, ErrorResponseMessage, Header, ProactiveEventMessage, Property, PropertyOptions, ResponseMessage, SceneEventMessage, } from "./messages/index.js"; +export type { ChangeCause, ChangeReportMessage, DeferredResponseMessage, ErrorResponseMessage, Header, ProactiveEventFields, ProactiveEventMessage, Property, PropertyOptions, ResponseMessage, SceneEventMessage, } from "./messages/index.js"; export * as topics from "./topics.js"; export { MemoryPublisher } from "./transport.js"; export type { Publisher, PublishResult } from "./transport.js"; diff --git a/dist/types/messages/build.d.ts b/dist/types/messages/build.d.ts index a5ba823..bf44eff 100644 --- a/dist/types/messages/build.d.ts +++ b/dist/types/messages/build.d.ts @@ -78,6 +78,19 @@ export interface SceneEventFields extends Answer { } /** The answer to Activate and Deactivate of a scene (alexa-scenecontroller.html). */ export declare function sceneEvent(fields: SceneEventFields): SceneEventMessage; +export interface ProactiveEventFields extends Envelope { + /** "Alexa.DoorbellEventSource" */ + namespace: string; + /** "DoorbellPress" */ + name: string; + /** The instance of a generic interface that raises the event. */ + instance?: string; + /** Default: "3". */ + payloadVersion?: string; + payload: Record; +} +/** An event a device raises by itself, of any interface. The header has no correlationToken: nobody asked. */ +export declare function proactiveEvent(fields: ProactiveEventFields): ProactiveEventMessage; export interface DoorbellPressFields extends Envelope { /** Default: "PHYSICAL_INTERACTION", somebody pressed the button. */ cause?: ChangeCause; diff --git a/dist/types/messages/index.d.ts b/dist/types/messages/index.d.ts index 95bc693..1fe2b45 100644 --- a/dist/types/messages/index.d.ts +++ b/dist/types/messages/index.d.ts @@ -1,5 +1,5 @@ -export { changeReport, deferredResponse, doorbellPress, errorResponse, response, sceneEvent, simpleEvent, stateReport, MessageError, } from "./build.js"; -export type { ChangeReportFields, DoorbellPressFields, ErrorResponseFields, ResponseFields, SceneEventFields, SimpleEventFields, } from "./build.js"; +export { changeReport, deferredResponse, doorbellPress, errorResponse, proactiveEvent, response, sceneEvent, simpleEvent, stateReport, MessageError, } from "./build.js"; +export type { ChangeReportFields, DoorbellPressFields, ErrorResponseFields, ProactiveEventFields, ResponseFields, SceneEventFields, SimpleEventFields, } from "./build.js"; export { AlexaError, AlexaErrors, errorNamespace } from "./errors.js"; export type { ChargeState, ControlUnavailableReason, CurrentDeviceMode } from "./errors.js"; export { property } from "./property.js"; diff --git a/dist/types/messages/types.d.ts b/dist/types/messages/types.d.ts index 8d3ff62..aa644f3 100644 --- a/dist/types/messages/types.d.ts +++ b/dist/types/messages/types.d.ts @@ -1,8 +1,6 @@ -/** - * Why a property changed or an event was raised (message-guide.html, "Cause object"). RULE_TRIGGER is not in that - * table; the ChangeReport examples of alexa-securitypanelcontroller.html and alexa-thermostatcontroller.html use it. - */ -export type ChangeCause = "APP_INTERACTION" | "PERIODIC_POLL" | "PHYSICAL_INTERACTION" | "RULE_TRIGGER" | "VOICE_INTERACTION"; +import type { Cause } from "../registry/catalog.js"; +/** Why a property changed or an event was raised: one of CAUSES. */ +export type ChangeCause = Cause; export interface Header { namespace: string; name: string; diff --git a/dist/types/registry/catalog.d.ts b/dist/types/registry/catalog.d.ts index 789f869..74d2e41 100644 --- a/dist/types/registry/catalog.d.ts +++ b/dist/types/registry/catalog.d.ts @@ -21,6 +21,12 @@ export declare const DISPLAY_CATEGORIES: readonly ["ACTIVITY_TRIGGER", "AIR_COND export type DisplayCategoryName = (typeof DISPLAY_CATEGORIES)[number]; /** Not to be used as a friendly name (resources-and-assets.html, "Reserved words"). */ export declare const RESERVED_WORDS: readonly ["alarm", "alarms", "all alarms", "away mode", "bass", "camera", "date", "date today", "day", "do not disturb", "drop in", "music", "night light", "notification", "playing", "sleep sounds", "time", "timer", "today in music", "treble", "volume", "way f. m."]; +/** + * Why a property changed or an event was raised (message-guide.html, "Cause object"). RULE_TRIGGER is not in that + * table; the ChangeReport examples of alexa-securitypanelcontroller.html and alexa-thermostatcontroller.html use it. + */ +export declare const CAUSES: readonly ["APP_INTERACTION", "PERIODIC_POLL", "PHYSICAL_INTERACTION", "RULE_TRIGGER", "VOICE_INTERACTION"]; +export type Cause = (typeof CAUSES)[number]; /** * What a discovery answer may hold (alexa-discovery.html, "Interface limits"; alexa-discovery-objects.html, * "Endpoint object details" and "AdditionalAttributes object details"; alexa-scenecontroller.html, "Discovery"). diff --git a/dist/types/registry/events.d.ts b/dist/types/registry/events.d.ts new file mode 100644 index 0000000..e91f41b --- /dev/null +++ b/dist/types/registry/events.d.ts @@ -0,0 +1,13 @@ +import type { Cause } from "./catalog.js"; +/** The time of an event: now, unless the payload says when. */ +export declare const timestamp: import("./schema.js").Schema; +/** + * The payload of an event that happened at a time and for a reason: ActivationStarted, DoorbellPress. Both are + * required by Alexa. The cause is the usual one of the event unless the payload names another. + */ +export declare const happened: (usually: Cause) => import("./schema.js").Schema; + timestamp: import("./schema.js").Schema; +}>>; diff --git a/dist/types/registry/index.d.ts b/dist/types/registry/index.d.ts index e1db8d6..ffa9b81 100644 --- a/dist/types/registry/index.d.ts +++ b/dist/types/registry/index.d.ts @@ -4,10 +4,12 @@ import { ChannelController } from "./interfaces/ChannelController.js"; import { ColorController } from "./interfaces/ColorController.js"; import { ColorTemperatureController } from "./interfaces/ColorTemperatureController.js"; import { ContactSensor } from "./interfaces/ContactSensor.js"; +import { DoorbellEventSource } from "./interfaces/DoorbellEventSource.js"; import { EndpointHealth } from "./interfaces/EndpointHealth.js"; import { EqualizerController } from "./interfaces/EqualizerController.js"; import { HumiditySensor } from "./interfaces/HumiditySensor.js"; import { InputController } from "./interfaces/InputController.js"; +import { InventoryLevelSensor } from "./interfaces/InventoryLevelSensor.js"; import { LockController } from "./interfaces/LockController.js"; import { ModeController } from "./interfaces/ModeController.js"; import { MotionSensor } from "./interfaces/MotionSensor.js"; @@ -17,13 +19,17 @@ import { PlaybackStateReporter } from "./interfaces/PlaybackStateReporter.js"; import { PowerController } from "./interfaces/PowerController.js"; import { PowerLevelController } from "./interfaces/PowerLevelController.js"; import { RangeController } from "./interfaces/RangeController.js"; +import { SceneController } from "./interfaces/SceneController.js"; import { SecurityPanelController } from "./interfaces/SecurityPanelController.js"; +import { SimpleEventSource } from "./interfaces/SimpleEventSource.js"; import { Speaker } from "./interfaces/Speaker.js"; import { StepSpeaker } from "./interfaces/StepSpeaker.js"; import { TemperatureSensor } from "./interfaces/TemperatureSensor.js"; import { ThermostatController } from "./interfaces/ThermostatController.js"; import { ThermostatControllerSchedule } from "./interfaces/ThermostatControllerSchedule.js"; +import { TimeHoldController } from "./interfaces/TimeHoldController.js"; import { ToggleController } from "./interfaces/ToggleController.js"; +import { WakeOnLANController } from "./interfaces/WakeOnLANController.js"; import type { AnyDescriptor } from "./types.js"; export declare const registry: { /** Whether an interface of this name is known. */ @@ -33,9 +39,10 @@ export declare const registry: { /** Every descriptor, ordered by namespace. */ list(): AnyDescriptor[]; }; -export { Alexa, BrightnessController, ChannelController, ColorController, ColorTemperatureController, ContactSensor, EndpointHealth, EqualizerController, HumiditySensor, InputController, LockController, ModeController, MotionSensor, PercentageController, PlaybackController, PlaybackStateReporter, PowerController, PowerLevelController, RangeController, SecurityPanelController, Speaker, StepSpeaker, TemperatureSensor, ThermostatController, ThermostatControllerSchedule, ToggleController, }; +export { Alexa, BrightnessController, ChannelController, ColorController, ColorTemperatureController, ContactSensor, DoorbellEventSource, EndpointHealth, EqualizerController, HumiditySensor, InputController, InventoryLevelSensor, LockController, ModeController, MotionSensor, PercentageController, PlaybackController, PlaybackStateReporter, PowerController, PowerLevelController, RangeController, SceneController, SecurityPanelController, SimpleEventSource, Speaker, StepSpeaker, TemperatureSensor, ThermostatController, ThermostatControllerSchedule, TimeHoldController, ToggleController, WakeOnLANController, }; export { INPUTS } from "./interfaces/InputController.js"; export type { InputName } from "./interfaces/InputController.js"; +export type { InventoryLevel } from "./interfaces/InventoryLevelSensor.js"; export type { Mode } from "./interfaces/ModeController.js"; export { THERMOSTAT_MODES } from "./interfaces/ThermostatController.js"; export type { ThermostatModeName } from "./interfaces/ThermostatController.js"; diff --git a/dist/types/registry/interfaces/DoorbellEventSource.d.ts b/dist/types/registry/interfaces/DoorbellEventSource.d.ts new file mode 100644 index 0000000..3920bf9 --- /dev/null +++ b/dist/types/registry/interfaces/DoorbellEventSource.d.ts @@ -0,0 +1,6 @@ +/** + * A doorbell. It takes no directive and reports nothing; device.raise(DoorbellEventSource, "DoorbellPress") says + * that somebody rang. Alexa wants 30 seconds between two presses of one doorbell, and announces a press when the + * user switched the announcements on for the doorbell in the Alexa app. + */ +export declare const DoorbellEventSource: import("../types.js").InterfaceDescriptor<{}, {}, {}, false>; diff --git a/dist/types/registry/interfaces/InventoryLevelSensor.d.ts b/dist/types/registry/interfaces/InventoryLevelSensor.d.ts new file mode 100644 index 0000000..b317480 --- /dev/null +++ b/dist/types/registry/interfaces/InventoryLevelSensor.d.ts @@ -0,0 +1,34 @@ +import type { Infer, Schema } from "../schema.js"; +declare const level: Schema; + value: Schema; + unit: import("../schema.js").OptionalSchema; +}>>; +export type InventoryLevel = Infer; +/** + * How much is left of something a device uses up: ink, paper, detergent. Each instance is one sensor, and Amazon + * orders a refill by its Dash replenishment id. The level is reported, there is nothing to ask for by voice. + */ +export declare const InventoryLevelSensor: import("../types.js").InterfaceDescriptor<{ + level: { + name: string; + value: Schema; + value: Schema; + unit: import("../schema.js").OptionalSchema; + }>>; + note: string; + }; +}, {}, import("../schema.js").InferShape<{ + /** How the level is measured: { "@type": "Volume", unit: "MILLILITER" }, { "@type": "Count" }. */ + measurement: Schema; + unit: import("../schema.js").OptionalSchema; + }>>; + /** The id the Dash console gave for the product. */ + replenishment: Schema; + value: Schema; + }>>; +}>, true>; +export {}; diff --git a/dist/types/registry/interfaces/SceneController.d.ts b/dist/types/registry/interfaces/SceneController.d.ts new file mode 100644 index 0000000..cf36e95 --- /dev/null +++ b/dist/types/registry/interfaces/SceneController.d.ts @@ -0,0 +1,21 @@ +/** + * A scene: an endpoint that is not a device, but several devices set to a state each. Activate is answered with + * ActivationStarted and Deactivate with DeactivationStarted; ctx.respond() sends them, with the time and + * VOICE_INTERACTION as the cause. A scene reports nothing and has no Alexa.EndpointHealth. + * + * Alexa takes no scene with a lock, a garage door, a camera, a cooking appliance or a security device in it. + */ +export declare const SceneController: import("../types.js").InterfaceDescriptor<{}, { + Activate: { + name: string; + payload: import("../schema.js").Schema>; + }; + Deactivate: { + name: string; + payload: import("../schema.js").Schema>; + when: ({ options }: import("../types.js").Declared) => boolean; + }; +}, import("../schema.js").InferShape<{ + /** The scene can be switched off again. */ + supportsDeactivation: import("../schema.js").OptionalSchema; +}>, false>; diff --git a/dist/types/registry/interfaces/SimpleEventSource.d.ts b/dist/types/registry/interfaces/SimpleEventSource.d.ts new file mode 100644 index 0000000..99d4959 --- /dev/null +++ b/dist/types/registry/interfaces/SimpleEventSource.d.ts @@ -0,0 +1,15 @@ +/** + * A button of a remote, or anything else that routines start on. Each instance is one button with the events it + * has, a single and a double push; device.raise(SimpleEventSource, "Event", { id }, { instance }) says that one + * happened. Alexa wants the instance to be unique among all endpoints, "preferably a version 4 UUID". + * + * The page folds its discovery example away. The capability is announced with the fields of its table and without a + * properties object, as the interface has no property. + */ +export declare const SimpleEventSource: import("../types.js").InterfaceDescriptor<{}, {}, import("../schema.js").InferShape<{ + /** For a REMOTE the names are assets of the catalog: Alexa.Button.SinglePush, Alexa.Gesture.Tap. */ + supportedEvents: import("../schema.js").Schema; + friendlyNames: import("../schema.js").Schema; + }>[]>; +}>, true>; diff --git a/dist/types/registry/interfaces/TimeHoldController.d.ts b/dist/types/registry/interfaces/TimeHoldController.d.ts new file mode 100644 index 0000000..b523cae --- /dev/null +++ b/dist/types/registry/interfaces/TimeHoldController.d.ts @@ -0,0 +1,27 @@ +/** + * Pausing what a device does, a microwave that heats, and going on with it. Amazon pairs the interface with + * Alexa.Cooking. Alexa sends Resume to a device declared with allowRemoteResume only; for another one it asks the + * user to press start on the device. + */ +export declare const TimeHoldController: import("../types.js").InterfaceDescriptor<{ + holdStartTime: { + name: string; + value: import("../schema.js").Schema; + }; + holdEndTime: { + name: string; + value: import("../schema.js").Schema; + }; +}, { + Hold: { + name: string; + payload: import("../schema.js").Schema>; + }; + Resume: { + name: string; + payload: import("../schema.js").Schema>; + when: ({ options }: import("../types.js").Declared) => boolean; + }; +}, import("../schema.js").InferShape<{ + allowRemoteResume: import("../schema.js").Schema; +}>, false>; diff --git a/dist/types/registry/interfaces/WakeOnLANController.d.ts b/dist/types/registry/interfaces/WakeOnLANController.d.ts new file mode 100644 index 0000000..89d58f2 --- /dev/null +++ b/dist/types/registry/interfaces/WakeOnLANController.d.ts @@ -0,0 +1,10 @@ +/** + * A device that is switched on by a Wake-on-LAN message, which an Echo of the user sends to the MAC addresses of + * the capability. The interface has no directive and no property: the device gets TurnOn of Alexa.PowerController, + * answers with a DeferredResponse, sends the WakeUp event and, when it is up, the Response. + * + * Alex2MQTT has no topic yet that takes the WakeUp event to Alexa. + */ +export declare const WakeOnLANController: import("../types.js").InterfaceDescriptor<{}, {}, import("../schema.js").InferShape<{ + macAddresses: import("../schema.js").Schema; +}>, false>; diff --git a/dist/types/registry/schema.d.ts b/dist/types/registry/schema.d.ts index d2f4856..77b33cc 100644 --- a/dist/types/registry/schema.d.ts +++ b/dist/types/registry/schema.d.ts @@ -62,6 +62,8 @@ declare function enumeration(...values: V): EnumSch declare function oneOf(values: readonly V[], expects: string): EnumSchema; declare function unknown(): Schema; declare function optional(inner: Schema): OptionalSchema; +/** A value that is made when none is given: the time of an event, which is now unless the caller says when. */ +declare function defaulted(inner: Schema, make: () => T): Schema; declare function nullable(inner: Schema): Schema; declare function array(item: Schema, rules?: { min?: number; @@ -87,6 +89,7 @@ export declare const s: { oneOf: typeof oneOf; unknown: typeof unknown; optional: typeof optional; + defaulted: typeof defaulted; nullable: typeof nullable; array: typeof array; object: typeof object; diff --git a/dist/types/registry/types.d.ts b/dist/types/registry/types.d.ts index cbac58c..2daf117 100644 --- a/dist/types/registry/types.d.ts +++ b/dist/types/registry/types.d.ts @@ -138,6 +138,14 @@ export interface InterfaceDescriptor

; /** Rules a schema cannot state, for options that passed the schema. Throws DeclarationError. */ validate?: (capability: Declared, endpoint: EndpointView) => void; } diff --git a/dist/types/topics.d.ts b/dist/types/topics.d.ts index 3a6ab99..8af84d5 100644 --- a/dist/types/topics.d.ts +++ b/dist/types/topics.d.ts @@ -19,3 +19,10 @@ export declare const deferred: (root: string, endpointId: string) => string; export declare const DIRECTIVE_BUDGET_MS = 7000; /** Where every ChangeReport of the root goes: the backend adds the user's token and posts it to Alexa. */ export declare const changeReport: (root: string) => string; +/** + * Where the events go that a device raises by itself, a DoorbellPress: the backend adds the user's token and posts + * them to Alexa. It takes 30 events a minute from a root. + */ +export declare const event: (root: string) => string; +/** The backend drops an event that is longer, as JSON. */ +export declare const EVENT_BYTES = 16000; diff --git a/src/device/Device.ts b/src/device/Device.ts index a5635b9..b3169cf 100644 --- a/src/device/Device.ts +++ b/src/device/Device.ts @@ -4,18 +4,19 @@ import { AlexaInterface } from "../compat/AlexaInterface.js"; import { AlexaStatusMessage } from "../compat/AlexaStatusMessage.js"; import { DisplayCategory } from "../compat/enums.js"; import type { AlexaInterfaceType } from "../compat/enums.js"; -import { sceneEvent } from "../messages/build.js"; +import { MessageError, proactiveEvent, sceneEvent } from "../messages/build.js"; import type { ChangeCause } from "../messages/types.js"; import type { DirectiveHandler, Fill } from "../dispatcher.js"; import type { DisplayCategoryName } from "../registry/catalog.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 type { Directives, EndpointView, InterfaceDescriptor, Label, Properties } from "../registry/types.js"; import * as topics from "../topics.js"; import { send } from "../transport.js"; -import type { Publisher } from "../transport.js"; +import type { Publisher, PublishResult } from "../transport.js"; import { Capability, commonOptions } from "./Capability.js"; import type { AnyCapability, CapabilityJson, Declaration } from "./Capability.js"; import { checkCapability, checkCapabilityCount, checkEndpoint } from "./validate.js"; @@ -176,6 +177,71 @@ class Device extends EventEmitter { 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 /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: InterfaceDescriptor, + name: string, + payload: Record = {}, + options: { instance?: string; messageId?: string } = {} + ): Promise { + const { endpointId } = this; + const { namespace } = descriptor; + const { instance = "", messageId } = options; + const refuse = (problem: string): never => { + 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: Record; + 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, + }); + 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: Promise = 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: * @@ -354,7 +420,7 @@ class Device extends EventEmitter { const has = (namespace: string): boolean => declared.some((capability) => capability.namespace === namespace); const added: AnyCapability[] = []; // 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")) { + 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: {} })); diff --git a/src/dispatcher.ts b/src/dispatcher.ts index 2619406..0d8b22b 100644 --- a/src/dispatcher.ts +++ b/src/dispatcher.ts @@ -164,7 +164,7 @@ class Context implements DirectiveContext { defer(estimatedDeferralInSeconds?: number): Promise { const { endpointId, correlationToken } = this; - if (this.capability && !this.capability.descriptor.deferrable) this.dispatcher.noteDeferral(this.namespace); + if (this.capability && !this.capability.descriptor.deferrable && !this.deferredByAnother()) this.dispatcher.noteDeferral(this.namespace); // Always where the first answer goes: a second DeferredResponse is refused there const topic = topics.response(this.dispatcher.rootTopic, endpointId); return this.dispatcher.send(topic, deferredResponse({ endpointId, correlationToken, estimatedDeferralInSeconds })); @@ -180,6 +180,13 @@ class Context implements DirectiveContext { return this.answer(errorResponse({ endpointId, correlationToken, type, message: alexaMessage, extra: error.extra, namespace })); } + // An interface of the device documents a DeferredResponse for this directive of another one + private deferredByAnother(): boolean { + const { namespace, name } = this; + return this.device.getCapabilities().some(({ descriptor }) => + descriptor.defers?.some((directive) => directive.namespace === namespace && directive.name === name)); + } + // The state of the device, and over it what the handler says about this directive private state(fill?: Fill): Property[] { const whole = new StateBuilder(); diff --git a/src/index.ts b/src/index.ts index 517269b..24185ca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,32 +10,33 @@ export type { CapabilityJson, CommonOptions, Declaration } from "./device/Capabi export { registry, DeclarationError, SchemaError } from "./registry/index.js"; export { Alexa, BrightnessController, ChannelController, ColorController, ColorTemperatureController, ContactSensor, - EqualizerController, HumiditySensor, InputController, LockController, ModeController, MotionSensor, - PercentageController, PlaybackController, PlaybackStateReporter, PowerLevelController, RangeController, - SecurityPanelController, Speaker, StepSpeaker, TemperatureSensor, ThermostatController, - ThermostatControllerSchedule, ToggleController, + DoorbellEventSource, EqualizerController, HumiditySensor, InputController, InventoryLevelSensor, LockController, + ModeController, MotionSensor, PercentageController, PlaybackController, PlaybackStateReporter, PowerLevelController, + RangeController, SceneController, SecurityPanelController, SimpleEventSource, Speaker, StepSpeaker, + TemperatureSensor, ThermostatController, ThermostatControllerSchedule, TimeHoldController, ToggleController, + WakeOnLANController, } from "./registry/index.js"; export { PowerController, EndpointHealth } from "./compat/enums.js"; export { asset, text, semantics, SemanticsBuilder } from "./registry/index.js"; // The vocabularies of the Smart Home API export { - ASSETS as Assets, UNITS_OF_MEASURE as Units, ACTIONS as Actions, STATES as States, + ASSETS as Assets, UNITS_OF_MEASURE as Units, ACTIONS as Actions, STATES as States, CAUSES as Causes, DISPLAY_CATEGORIES as DisplayCategories, INPUTS as Inputs, THERMOSTAT_MODES as ThermostatModes, } from "./registry/index.js"; export type { ActionId, AssetId, DisplayCategoryName, StateId, UnitOfMeasure, ActionsToDirective, AnyDescriptor, CapabilityExtras, Declared, DirectiveDescriptor, EndpointView, EventDescriptor, InterfaceDescriptor, Label, PropertyDescriptor, Semantics, StatesToRange, StatesToValue, - ActionName, StateName, InputName, Mode, ThermostatModeName, Infer, Schema, Temperature, TimeInterval, + ActionName, StateName, Cause, InputName, InventoryLevel, Mode, ThermostatModeName, Infer, Schema, Temperature, TimeInterval, } from "./registry/index.js"; // The messages: what the bridge publishes, built from plain values export * as messages from "./messages/index.js"; export { AlexaError, AlexaErrors, MessageError, StateBuilder, property } from "./messages/index.js"; export type { - ChangeCause, ChangeReportMessage, DeferredResponseMessage, ErrorResponseMessage, Header, ProactiveEventMessage, - Property, PropertyOptions, ResponseMessage, SceneEventMessage, + ChangeCause, ChangeReportMessage, DeferredResponseMessage, ErrorResponseMessage, Header, ProactiveEventFields, + ProactiveEventMessage, Property, PropertyOptions, ResponseMessage, SceneEventMessage, } from "./messages/index.js"; // Publishing: the topics of the Alex2MQTT contract, and a publisher that needs no broker for tests diff --git a/src/messages/build.ts b/src/messages/build.ts index 6b2fad4..1744dc9 100644 --- a/src/messages/build.ts +++ b/src/messages/build.ts @@ -172,6 +172,30 @@ export function sceneEvent(fields: SceneEventFields): SceneEventMessage { }; } +export interface ProactiveEventFields extends Envelope { + /** "Alexa.DoorbellEventSource" */ + namespace: string; + /** "DoorbellPress" */ + name: string; + /** The instance of a generic interface that raises the event. */ + instance?: string; + /** Default: "3". */ + payloadVersion?: string; + payload: Record; +} + +/** An event a device raises by itself, of any interface. The header has no correlationToken: nobody asked. */ +export function proactiveEvent(fields: ProactiveEventFields): ProactiveEventMessage { + const { namespace, name, instance, messageId, payloadVersion, payload } = fields; + return { + event: { + header: header(namespace, name, { instance, messageId, payloadVersion }), + endpoint: { endpointId: fields.endpointId }, + payload, + }, + }; +} + export interface DoorbellPressFields extends Envelope { /** Default: "PHYSICAL_INTERACTION", somebody pressed the button. */ cause?: ChangeCause; @@ -181,13 +205,9 @@ export interface DoorbellPressFields extends Envelope { /** Somebody rang (alexa-doorbelleventsource.html). */ export function doorbellPress(fields: DoorbellPressFields): ProactiveEventMessage { - return { - event: { - header: header("Alexa.DoorbellEventSource", "DoorbellPress", { messageId: fields.messageId }), - endpoint: { endpointId: fields.endpointId }, - payload: { cause: { type: fields.cause ?? "PHYSICAL_INTERACTION" }, timestamp: isoTime(fields.timestamp) }, - }, - }; + const { endpointId, messageId } = fields; + const payload = { cause: { type: fields.cause ?? "PHYSICAL_INTERACTION" }, timestamp: isoTime(fields.timestamp) }; + return proactiveEvent({ endpointId, messageId, namespace: "Alexa.DoorbellEventSource", name: "DoorbellPress", payload }); } export interface SimpleEventFields extends Envelope { @@ -201,15 +221,7 @@ export interface SimpleEventFields extends Envelope { /** An event of a button or a sensor that routines start on (alexa-simpleeventsource.html). */ export function simpleEvent(fields: SimpleEventFields): ProactiveEventMessage { - return { - event: { - header: header("Alexa.SimpleEventSource", "Event", { - instance: fields.instance, - messageId: fields.messageId, - payloadVersion: "1.0", - }), - endpoint: { endpointId: fields.endpointId }, - payload: { id: fields.id, timestamp: isoTime(fields.timestamp) }, - }, - }; + const { endpointId, messageId, instance } = fields; + const payload = { id: fields.id, timestamp: isoTime(fields.timestamp) }; + return proactiveEvent({ endpointId, messageId, instance, namespace: "Alexa.SimpleEventSource", name: "Event", payloadVersion: "1.0", payload }); } diff --git a/src/messages/index.ts b/src/messages/index.ts index 09e8c82..d23fdef 100644 --- a/src/messages/index.ts +++ b/src/messages/index.ts @@ -1,10 +1,11 @@ // The messages of the bridge without the bridge: builders, the state collector and the errors. export { - changeReport, deferredResponse, doorbellPress, errorResponse, response, sceneEvent, simpleEvent, stateReport, - MessageError, + changeReport, deferredResponse, doorbellPress, errorResponse, proactiveEvent, response, sceneEvent, simpleEvent, + stateReport, MessageError, } from "./build.js"; export type { - ChangeReportFields, DoorbellPressFields, ErrorResponseFields, ResponseFields, SceneEventFields, SimpleEventFields, + ChangeReportFields, DoorbellPressFields, ErrorResponseFields, ProactiveEventFields, ResponseFields, SceneEventFields, + SimpleEventFields, } from "./build.js"; export { AlexaError, AlexaErrors, errorNamespace } from "./errors.js"; export type { ChargeState, ControlUnavailableReason, CurrentDeviceMode } from "./errors.js"; diff --git a/src/messages/types.ts b/src/messages/types.ts index a38a705..173064d 100644 --- a/src/messages/types.ts +++ b/src/messages/types.ts @@ -1,10 +1,9 @@ // The messages the bridge publishes, as message-guide.html and alexa-response.html draw them. -/** - * Why a property changed or an event was raised (message-guide.html, "Cause object"). RULE_TRIGGER is not in that - * table; the ChangeReport examples of alexa-securitypanelcontroller.html and alexa-thermostatcontroller.html use it. - */ -export type ChangeCause = "APP_INTERACTION" | "PERIODIC_POLL" | "PHYSICAL_INTERACTION" | "RULE_TRIGGER" | "VOICE_INTERACTION"; +import type { Cause } from "../registry/catalog.js"; + +/** Why a property changed or an event was raised: one of CAUSES. */ +export type ChangeCause = Cause; export interface Header { namespace: string; diff --git a/src/registry/catalog.ts b/src/registry/catalog.ts index 7e7182e..fe1a03d 100644 --- a/src/registry/catalog.ts +++ b/src/registry/catalog.ts @@ -80,6 +80,13 @@ export const RESERVED_WORDS = [ "treble", "volume", "way f. m.", ] as const; +/** + * Why a property changed or an event was raised (message-guide.html, "Cause object"). RULE_TRIGGER is not in that + * table; the ChangeReport examples of alexa-securitypanelcontroller.html and alexa-thermostatcontroller.html use it. + */ +export const CAUSES = ["APP_INTERACTION", "PERIODIC_POLL", "PHYSICAL_INTERACTION", "RULE_TRIGGER", "VOICE_INTERACTION"] as const; +export type Cause = (typeof CAUSES)[number]; + /** * What a discovery answer may hold (alexa-discovery.html, "Interface limits"; alexa-discovery-objects.html, * "Endpoint object details" and "AdditionalAttributes object details"; alexa-scenecontroller.html, "Discovery"). diff --git a/src/registry/events.ts b/src/registry/events.ts new file mode 100644 index 0000000..83787b7 --- /dev/null +++ b/src/registry/events.ts @@ -0,0 +1,16 @@ +// What the events of several interfaces have in common. +import { CAUSES } from "./catalog.js"; +import type { Cause } from "./catalog.js"; +import { s } from "./schema.js"; + +/** The time of an event: now, unless the payload says when. */ +export const timestamp = s.defaulted(s.dateTime(), () => new Date().toISOString()); + +/** + * The payload of an event that happened at a time and for a reason: ActivationStarted, DoorbellPress. Both are + * required by Alexa. The cause is the usual one of the event unless the payload names another. + */ +export const happened = (usually: Cause) => s.object({ + cause: s.defaulted(s.object({ type: s.enum(...CAUSES) }), () => ({ type: usually })), + timestamp, +}); diff --git a/src/registry/index.ts b/src/registry/index.ts index 70cb66c..0279c70 100644 --- a/src/registry/index.ts +++ b/src/registry/index.ts @@ -5,10 +5,12 @@ import { ChannelController } from "./interfaces/ChannelController.js"; import { ColorController } from "./interfaces/ColorController.js"; import { ColorTemperatureController } from "./interfaces/ColorTemperatureController.js"; import { ContactSensor } from "./interfaces/ContactSensor.js"; +import { DoorbellEventSource } from "./interfaces/DoorbellEventSource.js"; import { EndpointHealth } from "./interfaces/EndpointHealth.js"; import { EqualizerController } from "./interfaces/EqualizerController.js"; import { HumiditySensor } from "./interfaces/HumiditySensor.js"; import { InputController } from "./interfaces/InputController.js"; +import { InventoryLevelSensor } from "./interfaces/InventoryLevelSensor.js"; import { LockController } from "./interfaces/LockController.js"; import { ModeController } from "./interfaces/ModeController.js"; import { MotionSensor } from "./interfaces/MotionSensor.js"; @@ -18,13 +20,17 @@ import { PlaybackStateReporter } from "./interfaces/PlaybackStateReporter.js"; import { PowerController } from "./interfaces/PowerController.js"; import { PowerLevelController } from "./interfaces/PowerLevelController.js"; import { RangeController } from "./interfaces/RangeController.js"; +import { SceneController } from "./interfaces/SceneController.js"; import { SecurityPanelController } from "./interfaces/SecurityPanelController.js"; +import { SimpleEventSource } from "./interfaces/SimpleEventSource.js"; import { Speaker } from "./interfaces/Speaker.js"; import { StepSpeaker } from "./interfaces/StepSpeaker.js"; import { TemperatureSensor } from "./interfaces/TemperatureSensor.js"; import { ThermostatController } from "./interfaces/ThermostatController.js"; import { ThermostatControllerSchedule } from "./interfaces/ThermostatControllerSchedule.js"; +import { TimeHoldController } from "./interfaces/TimeHoldController.js"; import { ToggleController } from "./interfaces/ToggleController.js"; +import { WakeOnLANController } from "./interfaces/WakeOnLANController.js"; import { STUBS } from "./interfaces/stubs.js"; import { DeclarationError } from "./types.js"; import type { AnyDescriptor } from "./types.js"; @@ -36,10 +42,12 @@ const described: readonly AnyDescriptor[] = [ ColorController, ColorTemperatureController, ContactSensor, + DoorbellEventSource, EndpointHealth, EqualizerController, HumiditySensor, InputController, + InventoryLevelSensor, LockController, ModeController, MotionSensor, @@ -49,13 +57,17 @@ const described: readonly AnyDescriptor[] = [ PowerController, PowerLevelController, RangeController, + SceneController, SecurityPanelController, + SimpleEventSource, Speaker, StepSpeaker, TemperatureSensor, ThermostatController, ThermostatControllerSchedule, + TimeHoldController, ToggleController, + WakeOnLANController, ]; const descriptors = new Map(); @@ -86,13 +98,15 @@ export const registry = { export { Alexa, BrightnessController, ChannelController, ColorController, ColorTemperatureController, ContactSensor, - EndpointHealth, EqualizerController, HumiditySensor, InputController, LockController, ModeController, MotionSensor, - PercentageController, PlaybackController, PlaybackStateReporter, PowerController, PowerLevelController, - RangeController, SecurityPanelController, Speaker, StepSpeaker, TemperatureSensor, ThermostatController, - ThermostatControllerSchedule, ToggleController, + DoorbellEventSource, EndpointHealth, EqualizerController, HumiditySensor, InputController, InventoryLevelSensor, + LockController, ModeController, MotionSensor, PercentageController, PlaybackController, PlaybackStateReporter, + PowerController, PowerLevelController, RangeController, SceneController, SecurityPanelController, SimpleEventSource, + Speaker, StepSpeaker, TemperatureSensor, ThermostatController, ThermostatControllerSchedule, TimeHoldController, + ToggleController, WakeOnLANController, }; export { INPUTS } from "./interfaces/InputController.js"; export type { InputName } from "./interfaces/InputController.js"; +export type { InventoryLevel } from "./interfaces/InventoryLevelSensor.js"; export type { Mode } from "./interfaces/ModeController.js"; export { THERMOSTAT_MODES } from "./interfaces/ThermostatController.js"; export type { ThermostatModeName } from "./interfaces/ThermostatController.js"; diff --git a/src/registry/interfaces/DoorbellEventSource.ts b/src/registry/interfaces/DoorbellEventSource.ts new file mode 100644 index 0000000..461ad96 --- /dev/null +++ b/src/registry/interfaces/DoorbellEventSource.ts @@ -0,0 +1,31 @@ +import { happened } from "../events.js"; +import { DeclarationError, defineInterface } from "../types.js"; + +/** + * A doorbell. It takes no directive and reports nothing; device.raise(DoorbellEventSource, "DoorbellPress") says + * that somebody rang. Alexa wants 30 seconds between two presses of one doorbell, and announces a press when the + * user switched the announcements on for the doorbell in the Alexa app. + */ +export const DoorbellEventSource = defineInterface({ + namespace: "Alexa.DoorbellEventSource", + version: "3", + doc: "https://developer.amazon.com/docs/alexaplus/device-apis/alexa-doorbelleventsource.html", + kind: "eventSource", + tier: 2, + instanced: false, + properties: {}, + directives: {}, + events: { + DoorbellPress: { name: "DoorbellPress", payload: happened("PHYSICAL_INTERACTION"), topic: "proactive" }, + }, + // The capability is the announcement that the endpoint raises the event: proactivelyReported whatever was declared + discovery: () => ({ properties: false, topLevel: { proactivelyReported: true } }), + // alexa-doorbelleventsource.html, "Discovery" + validate(capability, { displayCategories }) { + const doorbell = displayCategories.indexOf("DOORBELL"); + if (doorbell < 0) throw new DeclarationError(capability, "a doorbell has the display category DOORBELL"); + if (displayCategories.indexOf("CAMERA") > doorbell) { + throw new DeclarationError(capability, "a video doorbell lists the display category CAMERA before DOORBELL"); + } + }, +}); diff --git a/src/registry/interfaces/InventoryLevelSensor.ts b/src/registry/interfaces/InventoryLevelSensor.ts new file mode 100644 index 0000000..17ebd03 --- /dev/null +++ b/src/registry/interfaces/InventoryLevelSensor.ts @@ -0,0 +1,55 @@ +import { at, mismatch, s } from "../schema.js"; +import type { Infer, Schema } from "../schema.js"; +import { defineInterface } from "../types.js"; + +// As the examples of the page spell them; its table has them in lower case +const KINDS = ["Count", "Percentage", "Volume", "Weight"] as const; + +// A volume and a weight have a unit: MILLILITER, GRAM (alexa-property-schemas.html, "Volume unit values" and +// "Weight unit values") +function withUnit(fields: Schema): Schema { + return { + expects: fields.expects, + parse(input, path = "") { + const measured = fields.parse(input, path); + const what = measured["@type"].toLowerCase(); + const needed = what === "volume" || what === "weight"; + if (needed && measured.unit === undefined) throw mismatch(at(path, "unit"), `the unit of the ${what}, like MILLILITER or GRAM`, undefined); + if (!needed && measured.unit !== undefined) throw mismatch(at(path, "unit"), `no unit for a ${what}`, measured.unit); + return measured; + }, + }; +} + +const kind = s.enum(...KINDS); +const measurement = withUnit(s.object({ "@type": kind, unit: s.optional(s.string({ min: 1 })) }, { unknownKeys: "reject" })); +const level = withUnit(s.object({ "@type": kind, value: s.number({ min: 0 }), unit: s.optional(s.string({ min: 1 })) })); +export type InventoryLevel = Infer; + +/** + * How much is left of something a device uses up: ink, paper, detergent. Each instance is one sensor, and Amazon + * orders a refill by its Dash replenishment id. The level is reported, there is nothing to ask for by voice. + */ +export const InventoryLevelSensor = defineInterface({ + namespace: "Alexa.InventoryLevelSensor", + version: "3", + doc: "https://developer.amazon.com/docs/alexaplus/device-apis/alexa-inventorylevelsensor.html", + kind: "sensor", + tier: 2, + instanced: true, + properties: { + level: { name: "level", value: level, note: "measured as the capability declares it" }, + }, + directives: {}, + options: s.object({ + /** How the level is measured: { "@type": "Volume", unit: "MILLILITER" }, { "@type": "Count" }. */ + measurement, + /** The id the Dash console gave for the product. */ + replenishment: s.object({ "@type": s.enum("DashReplenishmentId"), value: s.string({ min: 1 }) }, { unknownKeys: "reject" }), + }, { unknownKeys: "reject" }), + // A capability declared the 1.x way has neither; check() says so + discovery({ options: { measurement: measured, replenishment } }) { + const configuration = { ...(measured && { measurement: measured }), ...(replenishment && { replenishment }) }; + return Object.keys(configuration).length > 0 ? { configuration } : {}; + }, +}); diff --git a/src/registry/interfaces/SceneController.ts b/src/registry/interfaces/SceneController.ts new file mode 100644 index 0000000..5914386 --- /dev/null +++ b/src/registry/interfaces/SceneController.ts @@ -0,0 +1,67 @@ +import { LIMITS } from "../catalog.js"; +import { happened } from "../events.js"; +import { s } from "../schema.js"; +import { DeclarationError, defineInterface } from "../types.js"; +import type { EventDescriptor } from "../types.js"; + +const started = (name: string): EventDescriptor => ({ name, payload: happened("VOICE_INTERACTION"), topic: "response" }); + +const events = { + ActivationStarted: started("ActivationStarted"), + DeactivationStarted: started("DeactivationStarted"), +}; + +/** + * A scene: an endpoint that is not a device, but several devices set to a state each. Activate is answered with + * ActivationStarted and Deactivate with DeactivationStarted; ctx.respond() sends them, with the time and + * VOICE_INTERACTION as the cause. A scene reports nothing and has no Alexa.EndpointHealth. + * + * Alexa takes no scene with a lock, a garage door, a camera, a cooking appliance or a security device in it. + */ +export const SceneController = defineInterface({ + namespace: "Alexa.SceneController", + version: "3", + doc: "https://developer.amazon.com/docs/alexaplus/device-apis/alexa-scenecontroller.html", + kind: "controller", + tier: 1, + instanced: false, + properties: {}, + directives: { + Activate: { name: "Activate", payload: s.object({}) }, + Deactivate: { + name: "Deactivate", + payload: s.object({}), + when: ({ options }) => options.supportsDeactivation !== false, + }, + }, + events, + options: s.object({ + /** The scene can be switched off again. */ + supportsDeactivation: s.optional(s.boolean()), + }, { unknownKeys: "reject" }), + // A scene declared without the option is announced as 1.5.2 announced every scene, which Alexa accepted: it can + // be deactivated, and proactivelyReported is on the capability. The example of the page has supportsDeactivation + // alone. + discovery({ proactivelyReported, options: { supportsDeactivation } }) { + const topLevel = supportsDeactivation === undefined ? { supportsDeactivation: true, proactivelyReported } : { supportsDeactivation }; + return { properties: false, topLevel }; + }, + // alexa-scenecontroller.html, "Discovery" + validate(capability, endpoint) { + const { friendlyName, description, displayCategories } = endpoint; + if (!displayCategories.includes("SCENE_TRIGGER") && !displayCategories.includes("ACTIVITY_TRIGGER")) { + throw new DeclarationError(capability, "a scene has the display category SCENE_TRIGGER, or ACTIVITY_TRIGGER when the order of its steps matters"); + } + if (!/scene/i.test(description)) { + throw new DeclarationError(capability, 'the description of a scene has the word "scene" in it, like "Party scene connected by Alex2Node"'); + } + if (friendlyName.length > LIMITS.sceneFriendlyNameLength) { + throw new DeclarationError(capability, `the name of a scene takes up to ${LIMITS.sceneFriendlyNameLength} characters, this one has ${friendlyName.length}`); + } + }, + responseFor(directive) { + if (directive !== "Activate" && directive !== "Deactivate") return undefined; + const { name, payload } = directive === "Activate" ? events.ActivationStarted : events.DeactivationStarted; + return { namespace: "Alexa.SceneController", name, payload }; + }, +}); diff --git a/src/registry/interfaces/SimpleEventSource.ts b/src/registry/interfaces/SimpleEventSource.ts new file mode 100644 index 0000000..ecf0c15 --- /dev/null +++ b/src/registry/interfaces/SimpleEventSource.ts @@ -0,0 +1,49 @@ +import { timestamp } from "../events.js"; +import { labels, shownAs } from "../resources.js"; +import { s } from "../schema.js"; +import { DeclarationError, defineInterface } from "../types.js"; + +/** + * A button of a remote, or anything else that routines start on. Each instance is one button with the events it + * has, a single and a double push; device.raise(SimpleEventSource, "Event", { id }, { instance }) says that one + * happened. Alexa wants the instance to be unique among all endpoints, "preferably a version 4 UUID". + * + * The page folds its discovery example away. The capability is announced with the fields of its table and without a + * properties object, as the interface has no property. + */ +export const SimpleEventSource = defineInterface({ + namespace: "Alexa.SimpleEventSource", + version: "1.0", + doc: "https://developer.amazon.com/docs/alexaplus/device-apis/alexa-simpleeventsource.html", + kind: "eventSource", + tier: 2, + instanced: true, + properties: {}, + directives: {}, + events: { + Event: { + name: "Event", + /** id: the id of one of the supportedEvents of the instance. */ + payload: s.object({ id: s.string({ min: 1 }), timestamp }), + topic: "proactive", + }, + }, + options: s.object({ + /** For a REMOTE the names are assets of the catalog: Alexa.Button.SinglePush, Alexa.Gesture.Tap. */ + supportedEvents: s.array(s.object({ id: s.string({ min: 1 }), friendlyNames: labels }, { unknownKeys: "reject" }), { min: 1 }), + }, { unknownKeys: "reject" }), + // A capability declared the 1.x way has no events; check() says so + discovery: ({ options: { supportedEvents } }) => ({ + properties: false, + ...(supportedEvents && { configuration: { supportedEvents } }), + }), + validate(capability) { + const { supportedEvents } = capability.options; + const ids = supportedEvents.map(({ id }) => id); + // "The first friendly name in the array must be unique per instance of the capability." + const names = supportedEvents.map(({ friendlyNames: [first] }) => shownAs(first)); + const twice = (list: string[]): string | undefined => list.find((entry, i) => list.indexOf(entry) !== i); + if (twice(ids)) throw new DeclarationError(capability, `the event ${twice(ids)} is listed twice`); + if (twice(names)) throw new DeclarationError(capability, `two events have ${twice(names)} as their first friendly name, the Alexa app shows an event by it`); + }, +}); diff --git a/src/registry/interfaces/TimeHoldController.ts b/src/registry/interfaces/TimeHoldController.ts new file mode 100644 index 0000000..0f4fea5 --- /dev/null +++ b/src/registry/interfaces/TimeHoldController.ts @@ -0,0 +1,29 @@ +import { s } from "../schema.js"; +import { defineInterface } from "../types.js"; + +/** + * Pausing what a device does, a microwave that heats, and going on with it. Amazon pairs the interface with + * Alexa.Cooking. Alexa sends Resume to a device declared with allowRemoteResume only; for another one it asks the + * user to press start on the device. + */ +export const TimeHoldController = defineInterface({ + namespace: "Alexa.TimeHoldController", + version: "3", + doc: "https://developer.amazon.com/docs/alexaplus/device-apis/alexa-timeholdcontroller.html", + kind: "controller", + tier: 2, + instanced: false, + properties: { + holdStartTime: { name: "holdStartTime", value: s.dateTime() }, + holdEndTime: { name: "holdEndTime", value: s.dateTime() }, + }, + directives: { + Hold: { name: "Hold", payload: s.object({}) }, + Resume: { name: "Resume", payload: s.object({}), when: ({ options }) => options.allowRemoteResume !== false }, + }, + options: s.object({ + allowRemoteResume: s.boolean(), + }, { unknownKeys: "reject" }), + // A capability declared the 1.x way has no allowRemoteResume; check() says so + discovery: ({ options: { allowRemoteResume } }) => (allowRemoteResume === undefined ? {} : { configuration: { allowRemoteResume } }), +}); diff --git a/src/registry/interfaces/WakeOnLANController.ts b/src/registry/interfaces/WakeOnLANController.ts new file mode 100644 index 0000000..d61fda6 --- /dev/null +++ b/src/registry/interfaces/WakeOnLANController.ts @@ -0,0 +1,43 @@ +import { s } from "../schema.js"; +import { DeclarationError, defineInterface } from "../types.js"; + +// 00-14-22-01-23-45 in the example of the page; the colon is the other common way to write one +const MAC_ADDRESS = /^[0-9A-Fa-f]{2}([-:][0-9A-Fa-f]{2}){5}$/; + +/** + * A device that is switched on by a Wake-on-LAN message, which an Echo of the user sends to the MAC addresses of + * the capability. The interface has no directive and no property: the device gets TurnOn of Alexa.PowerController, + * answers with a DeferredResponse, sends the WakeUp event and, when it is up, the Response. + * + * Alex2MQTT has no topic yet that takes the WakeUp event to Alexa. + */ +export const WakeOnLANController = defineInterface({ + namespace: "Alexa.WakeOnLANController", + version: "3", + doc: "https://developer.amazon.com/docs/alexaplus/device-apis/alexa-wakeonlancontroller.html", + kind: "controller", + tier: 2, + instanced: false, + properties: {}, + directives: {}, + events: { + WakeUp: { name: "WakeUp", payload: s.object({}), topic: "response" }, + }, + options: s.object({ + macAddresses: s.array(s.string({ pattern: MAC_ADDRESS, expects: "a MAC address like 00-14-22-01-23-45" }), { min: 1 }), + }, { unknownKeys: "reject" }), + // The example has a properties object without fields, which topLevel puts where properties: false left none. A + // capability declared the 1.x way has no address; check() says so. + discovery: ({ options: { macAddresses } }) => ({ + properties: false, + topLevel: { properties: {} }, + ...(macAddresses && { configuration: { MACAddresses: macAddresses } }), + }), + validate(capability) { + const addresses = capability.options.macAddresses.map((address) => address.toUpperCase().replace(/:/g, "-")); + const twice = addresses.find((address, i) => addresses.indexOf(address) !== i); + if (twice) throw new DeclarationError(capability, `${twice} is listed twice`); + }, + deferrable: true, + defers: [{ namespace: "Alexa.PowerController", name: "TurnOn" }], +}); diff --git a/src/registry/interfaces/stubs.ts b/src/registry/interfaces/stubs.ts index 9db1323..93d0279 100644 --- a/src/registry/interfaces/stubs.ts +++ b/src/registry/interfaces/stubs.ts @@ -35,8 +35,6 @@ const TABLE: readonly Row[] = [ ["Alexa.DataController", "1", [], "alexa-datacontroller.html"], ["Alexa.DeviceUsage.Estimation", "1", [], "alexa-deviceusage-estimation.html"], ["Alexa.DeviceUsage.Meter", "1", [], "alexa-deviceusage-meter.html"], - ["Alexa.DoorbellEventSource", "3", [], "alexa-doorbelleventsource.html"], - ["Alexa.InventoryLevelSensor", "1", [], "alexa-inventorylevelsensor.html"], ["Alexa.InventoryLevelUsageSensor", "1", [], "alexa-inventorylevelusagesensor.html"], ["Alexa.InventoryUsageSensor", "1", [], "alexa-inventoryusagesensor.html"], ["Alexa.KeypadController", "1", [], "alexa-keypadcontroller.html"], @@ -48,31 +46,17 @@ const TABLE: readonly Row[] = [ ["Alexa.RTCSessionController", "1", [], "alexa-rtcsessioncontroller.html"], ["Alexa.RecordController", "3", [], "alexa-recordcontroller.html"], ["Alexa.RemoteVideoPlayer", "1", [], "alexa-remotevideoplayer.html"], - ["Alexa.SceneController", "3", [], "alexa-scenecontroller.html"], ["Alexa.SecurityPanelController.Alert", "1", [], "alexa-securitypanelcontroller-alert.html"], ["Alexa.SeekController", "3", [], "alexa-seekcontroller.html"], - ["Alexa.SimpleEventSource", "1", [], "alexa-simpleeventsource.html"], ["Alexa.SmartVision.ObjectDetectionSensor", "1", [], "alexa-smartvision-objectdetectionsensor.html"], ["Alexa.SmartVision.SnapshotProvider", "1", [], "alexa-smartvision-snapshotprovider.html"], ["Alexa.ThermostatController.Configuration", "1", [], "alexa-thermostatcontroller-configuration.html"], ["Alexa.ThermostatController.HVAC.Components", "1", [], "alexa-thermostatcontroller-hvac-components.html"], - // "UNKNOWN" in 1.5.2 as well; the page is titled "Interface 3" - ["Alexa.TimeHoldController", "3", [], "alexa-timeholdcontroller.html"], ["Alexa.UIController", "1", [], "alexa-uicontroller.html"], ["Alexa.UserPreference", "1", [], "alexa-userpreference.html"], ["Alexa.VideoRecorder", "1", [], "alexa-videorecorder.html"], - ["Alexa.WakeOnLANController", "1", [], "alexa-wakeonlancontroller.html"], ]; -// What 1.5.2 added to the capability object of one of them -const EXTRAS: Record = { - // No properties object; supportsDeactivation and proactivelyReported on the capability itself - "Alexa.SceneController": ({ proactivelyReported }) => ({ - properties: false, - topLevel: { supportsDeactivation: true, proactivelyReported }, - }), -}; - function kindOf(namespace: string): AnyDescriptor["kind"] { if (namespace.endsWith("Sensor")) return "sensor"; if (namespace.endsWith("EventSource")) return "eventSource"; @@ -89,7 +73,6 @@ function stub([namespace, version, properties, page]: Row): AnyDescriptor { instanced: false, properties: Object.fromEntries(properties.map((name) => [name, { name, value: s.unknown() }])), directives: {}, - discovery: EXTRAS[namespace], }; } diff --git a/src/registry/schema.ts b/src/registry/schema.ts index 506d0da..2eb0049 100644 --- a/src/registry/schema.ts +++ b/src/registry/schema.ts @@ -139,6 +139,14 @@ function optional(inner: Schema): OptionalSchema { }; } +/** A value that is made when none is given: the time of an event, which is now unless the caller says when. */ +function defaulted(inner: Schema, make: () => T): Schema { + return { + expects: inner.expects, + parse: (input, path = "") => (input === undefined ? make() : inner.parse(input, path)), + }; +} + function nullable(inner: Schema): Schema { const expects = `${inner.expects} or null`; return { @@ -239,6 +247,7 @@ export const s = { oneOf, unknown, optional, + defaulted, nullable, array, object, diff --git a/src/registry/types.ts b/src/registry/types.ts index a839d70..4aa144d 100644 --- a/src/registry/types.ts +++ b/src/registry/types.ts @@ -139,6 +139,11 @@ export interface InterfaceDescriptor< errorTypes?: readonly string[]; /** Amazon documents a DeferredResponse for the interface. */ deferrable?: boolean; + /** + * The directives of other interfaces that Amazon documents a DeferredResponse for on an endpoint with this one: + * TurnOn of Alexa.PowerController, for a device that is woken over the LAN. + */ + defers?: ReadonlyArray<{ namespace: string; name: string }>; /** Rules a schema cannot state, for options that passed the schema. Throws DeclarationError. */ validate?: (capability: Declared, endpoint: EndpointView) => void; } diff --git a/src/topics.ts b/src/topics.ts index 018ff81..bbea79f 100644 --- a/src/topics.ts +++ b/src/topics.ts @@ -33,3 +33,12 @@ export const DIRECTIVE_BUDGET_MS = 7000; /** Where every ChangeReport of the root goes: the backend adds the user's token and posts it to Alexa. */ export const changeReport = (root: string): string => `${root}/changeReport`; + +/** + * Where the events go that a device raises by itself, a DoorbellPress: the backend adds the user's token and posts + * them to Alexa. It takes 30 events a minute from a root. + */ +export const event = (root: string): string => `${root}/event`; + +/** The backend drops an event that is longer, as JSON. */ +export const EVENT_BYTES = 16_000; diff --git a/test/device/discovery.test.js b/test/device/discovery.test.js index bfa41e3..c867fbe 100644 --- a/test/device/discovery.test.js +++ b/test/device/discovery.test.js @@ -34,10 +34,11 @@ function likeTheZoo(endpointId) { return device; } -for (const { namespace, page: own, declared } of interfaces) { +for (const { namespace, page: own, declared, endpoint: { categories, description } = {} } of interfaces) { for (const { options, example, page = own, n = 0 } of declared) { test(`${namespace}: device.add() gives the capability object of ${page}.html, ${example}${n > 0 ? ` (${n})` : ""}`, () => { - const device = endpoint(); + const device = endpoint("lamp-1", "Lamp", categories); + if (description) device.setDescription(description); const capability = device.add(registry.get(namespace), options); assert.deepEqual(capability.toJSON(), capabilities(page, namespace, example)[n]); assert.deepEqual(device.check(), []); diff --git a/test/device/raise.test.js b/test/device/raise.test.js new file mode 100644 index 0000000..239c918 --- /dev/null +++ b/test/device/raise.test.js @@ -0,0 +1,145 @@ +"use strict"; +// device.raise(): an event nobody asked for goes to /event, and one that would not arrive is refused. +const { test } = require("node:test"); +const assert = require("node:assert/strict"); +const { + Alex2MQTT, AlexaInterfaceType, DoorbellEventSource, MemoryPublisher, MessageError, PowerController, SceneController, + SimpleEventSource, asset, topics, +} = require("alex2node"); +const { endpoint } = require("../helpers/endpoint.js"); +const { doc } = require("../helpers/fixtures.js"); +const { UUID_V4 } = require("../helpers/harness.js"); + +function doorbell() { + const sent = new MemoryPublisher(); + const errors = []; + const bridge = new Alex2MQTT("u", "p", "root", false, { publisher: sent, log: () => {} }); + bridge.on("error", (err) => errors.push(err)); + const device = bridge.addDevice({ endpointId: "door-1", name: "Front door", categories: ["DOORBELL"] }); + device.add(DoorbellEventSource); + return { bridge, device, sent, errors }; +} + +const button = (name, events) => ({ + instance: `Remote.${name}`, + friendlyNames: [asset(`Alexa.Button.${name}`)], + supportedEvents: events.map((push) => ({ id: `Button.${push}.1`, friendlyNames: [asset(`Alexa.Button.${push}`)] })), +}); + +const refused = (raise, problem) => assert.throws(raise, (err) => err instanceof MessageError && err.problem === problem); + +test("raise() publishes the event on /event, with the endpoint, a new messageId, the time and the usual cause", async () => { + const { device, sent } = doorbell(); + const printed = doc("alexa-doorbelleventsource", "DoorbellPress.event").event; + const before = Date.now(); + + assert.deepEqual(await device.raise(DoorbellEventSource, "DoorbellPress"), { ok: true, topic: "root/event" }); + await device.raise(DoorbellEventSource, "DoorbellPress", printed.payload); + await device.raise(DoorbellEventSource, "DoorbellPress", { cause: { type: "APP_INTERACTION" } }, { messageId: "press-3" }); + + assert.equal(topics.event("root"), "root/event"); + assert.deepEqual(sent.published.map(({ topic }) => topic), ["root/event", "root/event", "root/event"]); + const [first, second, third] = sent.published.map(({ message }) => message.event); + for (const event of [first, second, third]) { + assert.deepEqual(event.endpoint, { endpointId: "door-1" }); + const { messageId, ...header } = event.header; + assert.deepEqual(header, { namespace: printed.header.namespace, name: printed.header.name, payloadVersion: printed.header.payloadVersion }); + } + assert.match(first.header.messageId, UUID_V4); + assert.match(second.header.messageId, UUID_V4); + assert.notEqual(first.header.messageId, second.header.messageId); + assert.equal(third.header.messageId, "press-3"); + + assert.deepEqual(first.payload.cause, { type: "PHYSICAL_INTERACTION" }); + assert.ok(Date.parse(first.payload.timestamp) >= before && Date.parse(first.payload.timestamp) <= Date.now(), first.payload.timestamp); + assert.deepEqual(second.payload, printed.payload); + assert.deepEqual(third.payload.cause, { type: "APP_INTERACTION" }); +}); + +test("raise() for an instance: the Event of a button has the instance in its header and the version of the interface", async () => { + const sent = new MemoryPublisher(); + const remote = endpoint("remote-1", "Remote", ["REMOTE"]); + remote.publisher = sent; + const top = remote.add(SimpleEventSource, button("TopButton", ["SinglePush", "DoublePush"])); + remote.add(SimpleEventSource, button("BottomButton", ["SinglePush"])); + const printed = doc("alexa-simpleeventsource", "Event.event").event; + + await remote.raise(SimpleEventSource, "Event", printed.payload, { instance: top.instance, messageId: "push-1" }); + assert.deepEqual(sent.published, [{ + topic: "root/event", + message: { + event: { + header: { namespace: "Alexa.SimpleEventSource", name: "Event", instance: "Remote.TopButton", messageId: "push-1", payloadVersion: "1.0" }, + endpoint: { endpointId: "remote-1" }, + payload: printed.payload, + }, + }, + }]); + assert.equal(printed.header.payloadVersion, "1.0"); + + const raised = "remote-1: Alexa.SimpleEventSource.Event was not raised: "; + assert.throws(() => remote.raise(SimpleEventSource, "Event", { id: "Button.SinglePush.1" }), { + name: "MessageError", + message: `${raised}the device did not declare Alexa.SimpleEventSource. Declare it with add() first, and pass the instance that raises the event`, + }); + assert.throws(() => remote.raise(SimpleEventSource, "Event", { id: "Button.SinglePush.1" }, { instance: "Remote.LeftButton" }), { + message: `${raised}the device did not declare the instance "Remote.LeftButton" of Alexa.SimpleEventSource. Declare it with add() first`, + }); + assert.throws(() => remote.raise(SimpleEventSource, "Event", {}, { instance: top.instance }), { + message: `${raised}payload.id: expected a string of 1 or more characters, got nothing`, + }); + assert.equal(sent.published.length, 1); +}); + +test("raise() refuses an event the device has no capability for, and one that would not arrive", () => { + const { device, sent } = doorbell(); + device.add(PowerController); + const scene = endpoint("scene-1", "Evening", ["SCENE_TRIGGER"]); + scene.setDescription("Evening scene"); + scene.publisher = sent; + scene.add(SceneController); + const was = (name) => `${name} was not raised: `; + + refused(() => scene.raise(DoorbellEventSource, "DoorbellPress"), `${was("Alexa.DoorbellEventSource.DoorbellPress")}the device did not declare Alexa.DoorbellEventSource. Declare it with add() first`); + refused(() => device.raise(DoorbellEventSource, "Knock"), `${was("Alexa.DoorbellEventSource.Knock")}it is not an event of the interface, which has DoorbellPress`); + refused(() => device.raise(PowerController, "TurnedOn"), `${was("Alexa.PowerController.TurnedOn")}it is not an event of the interface, which has none`); + refused( + () => scene.raise(SceneController, "ActivationStarted"), + `${was("Alexa.SceneController.ActivationStarted")}it answers a directive. Send it with respond() in the handler of the directive` + ); + refused( + () => device.raise(DoorbellEventSource, "DoorbellPress", { timestamp: "just now" }), + `${was("Alexa.DoorbellEventSource.DoorbellPress")}payload.timestamp: expected a UTC time like 2017-08-30T01:18:21Z, got "just now"` + ); + refused( + () => device.raise(DoorbellEventSource, "DoorbellPress", { cause: { type: "KNOCK" } }), + `${was("Alexa.DoorbellEventSource.DoorbellPress")}payload.cause.type: expected APP_INTERACTION | PERIODIC_POLL | PHYSICAL_INTERACTION | RULE_TRIGGER | VOICE_INTERACTION, got "KNOCK"` + ); + refused( + () => device.raise(DoorbellEventSource, "DoorbellPress", { picture: "x".repeat(topics.EVENT_BYTES) }), + `${was("Alexa.DoorbellEventSource.DoorbellPress")}it is 16296 bytes as JSON, Alex2MQTT takes 16000` + ); + assert.deepEqual(sent.published, []); +}); + +test("raise() of a doorbell declared the 1.x way, of a device on no bridge and with a broker that is gone", async () => { + const sent = new MemoryPublisher(); + const bell = endpoint("door-2", "Back door", ["DOORBELL"]); + bell.addCapability(AlexaInterfaceType.DOORBELL_EVENT_SOURCE); + const reported = []; + bell.onPublishError = (err) => reported.push(err.message); + + const nowhere = await bell.raise(DoorbellEventSource, "DoorbellPress"); + assert.equal(nowhere.ok, false); + assert.deepEqual(reported, ["nothing was published to root/event: the device is on no bridge, register it with addDevice() or registerDevice()"]); + + bell.publisher = sent; + assert.equal((await bell.raise(DoorbellEventSource, "DoorbellPress")).ok, true); + assert.equal(sent.published[0].message.event.endpoint.endpointId, "door-2"); + + const { device, sent: broker, errors } = doorbell(); + broker.failWith = new Error("the broker is gone"); + const result = await device.raise(DoorbellEventSource, "DoorbellPress"); + assert.deepEqual([result.ok, result.topic, result.error.message], [false, "root/event", "the broker is gone"]); + assert.deepEqual(errors.map((err) => err.message), ["the broker is gone"]); +}); diff --git a/test/dispatch/interfaces.test.js b/test/dispatch/interfaces.test.js index 41ada91..8554b7a 100644 --- a/test/dispatch/interfaces.test.js +++ b/test/dispatch/interfaces.test.js @@ -4,8 +4,8 @@ const { test } = require("node:test"); const assert = require("node:assert/strict"); const { - Alex2MQTT, AlexaErrors, LockController, MemoryPublisher, PowerLevelController, SecurityPanelController, - ThermostatController, ThermostatControllerSchedule, registry, + Alex2MQTT, AlexaErrors, LockController, MemoryPublisher, PowerController, PowerLevelController, SceneController, + SecurityPanelController, ThermostatController, TimeHoldController, WakeOnLANController, ThermostatControllerSchedule, registry, } = require("alex2node"); const interfaces = require("../fixtures/interfaces.js"); const { doc, examples } = require("../helpers/fixtures.js"); @@ -181,3 +181,70 @@ test("Alexa.SecurityPanelController: a payload that is no Arm.Response is not se message: "payload.exitDelayInSeconds: expected an integer from 0 to 255, got 300", }]]); }); + +test("Alexa.SceneController: respond() answers Activate with ActivationStarted and Deactivate with DeactivationStarted", async () => { + const sent = new MemoryPublisher(); + const bridge = new Alex2MQTT("u", "p", "root", false, { publisher: sent, log: () => {} }); + const scenes = ["party", "bedtime"].map((endpointId) => bridge.addDevice({ + endpointId, name: endpointId, categories: ["SCENE_TRIGGER"], description: `The ${endpointId} scene`, + })); + scenes[0].add(SceneController).on("*", (ctx) => ctx.respond()); + scenes[1].add(SceneController, { supportsDeactivation: false }).on("*", (ctx) => ctx.respond(undefined, { payload: { timestamp: "2017-02-03T16:20:50Z" } })); + + const before = Date.now(); + let count = 0; + for (const [endpointId, name] of [["party", "Activate"], ["party", "Deactivate"], ["bedtime", "Activate"], ["bedtime", "Deactivate"]]) { + count += 1; + const { header, payload } = doc("alexa-scenecontroller", `${name}.directive`).directive; + const message = { header: { ...header, messageId: `message-${count}`, correlationToken: `ct-${count}` }, endpoint: { endpointId }, payload }; + await bridge.receive(`root/${endpointId}/alexaDirective`, JSON.stringify(message)); + } + const events = sent.published.map(({ message }) => message.event); + assert.deepEqual(sent.published.map(({ topic }) => topic), ["root/party/alexaResponce", "root/party/alexaResponce", "root/bedtime/alexaResponce", "root/bedtime/alexaResponce"]); + assert.deepEqual(events.map(({ header }) => [header.namespace, header.name, header.correlationToken]), [ + ["Alexa.SceneController", "ActivationStarted", "ct-1"], + ["Alexa.SceneController", "DeactivationStarted", "ct-2"], + ["Alexa.SceneController", "ActivationStarted", "ct-3"], + ["Alexa", "ErrorResponse", "ct-4"], + ]); + assert.deepEqual(events[2].payload, doc("alexa-scenecontroller", "ActivationStarted.event").event.payload); + assert.equal(events[3].payload.message, "Alexa.SceneController as bedtime declares it does not take Deactivate"); + for (const { payload } of events.slice(0, 2)) { + assert.deepEqual(payload.cause, { type: "VOICE_INTERACTION" }); + assert.ok(Date.parse(payload.timestamp) >= before && Date.parse(payload.timestamp) <= Date.now(), payload.timestamp); + } +}); + +test("Alexa.TimeHoldController: Resume reaches a device declared with allowRemoteResume only", async () => { + const { device, receive, answers } = bridgeWithout(); + device.add(TimeHoldController, { allowRemoteResume: false }).on("*", (ctx) => ctx.respond()); + await receive(doc("alexa-timeholdcontroller", "Hold.directive").directive); + await receive(doc("alexa-timeholdcontroller", "Resume.directive").directive); + assert.deepEqual(answers(), [ + ["alexaResponce", "Alexa", "Response", {}], + ["alexaResponce", "Alexa", "ErrorResponse", { + type: "INVALID_DIRECTIVE", + message: "Alexa.TimeHoldController as device-1 declares it does not take Resume", + }], + ]); +}); + +test("Alexa.WakeOnLANController: TurnOn is deferred without a warning, TurnOff with the one of the PowerController", async () => { + const { device, logged, receive, answers } = bridgeWithout(); + device.add(WakeOnLANController, { macAddresses: ["00-14-22-01-23-45"] }); + const power = device.add(PowerController); + power.on("*", async (ctx) => { + await ctx.defer(15); + return ctx.respond((s) => s.set(power, "powerState", ctx.name === "TurnOn" ? "ON" : "OFF")); + }); + await receive(doc("alexa-wakeonlancontroller", "TurnOn.directive").directive); + assert.deepEqual(answers(), [ + ["alexaResponce", "Alexa", "DeferredResponse", doc("alexa-wakeonlancontroller", "TurnOn.deferred").event.payload], + ["deferredResponse", "Alexa", "Response", doc("alexa-wakeonlancontroller", "TurnOn.response").event.payload], + ]); + const warnings = () => logged.filter((line) => line.includes("DeferredResponse")); + assert.deepEqual(warnings(), []); + + await receive(doc("alexa-powercontroller", "TurnOff.directive").directive); + assert.deepEqual(warnings(), ["warning: Amazon documents no DeferredResponse for Alexa.PowerController, Alexa may not wait for the answer"]); +}); diff --git a/test/fixtures/alexa-docs/alexa-doorbelleventsource/DoorbellPress.event.json b/test/fixtures/alexa-docs/alexa-doorbelleventsource/DoorbellPress.event.json new file mode 100644 index 0000000..4c9ec75 --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-doorbelleventsource/DoorbellPress.event.json @@ -0,0 +1,24 @@ +{ + "context": {}, + "event": { + "header": { + "messageId": "Unique identifier, preferably a version 4 UUID", + "namespace": "Alexa.DoorbellEventSource", + "name": "DoorbellPress", + "payloadVersion": "3" + }, + "endpoint": { + "scope": { + "type": "BearerToken", + "token": "access-token-from-Amazon" + }, + "endpointId": "appliance-001" + }, + "payload": { + "cause": { + "type": "PHYSICAL_INTERACTION" + }, + "timestamp": "2018-06-09T23:23:23.23Z" + } + } +} diff --git a/test/fixtures/alexa-docs/alexa-doorbelleventsource/discovery.json b/test/fixtures/alexa-docs/alexa-doorbelleventsource/discovery.json new file mode 100644 index 0000000..33ec5eb --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-doorbelleventsource/discovery.json @@ -0,0 +1,60 @@ +{ + "event": { + "header": { + "namespace": "Alexa.Discovery", + "name": "Discover.Response", + "payloadVersion": "3", + "messageId": "Unique identifier, preferably a version 4 UUID" + }, + "payload": { + "endpoints": [ + { + "endpointId": "Unique ID of the endpoint", + "manufacturerName": "Sample Manufacturer", + "description": "Description that appears in the Alexa app", + "friendlyName": "Your device name, displayed in the Alexa app", + "displayCategories": [ + "CAMERA", + "DOORBELL" + ], + "additionalAttributes": { + "manufacturer": "Sample Manufacturer", + "model": "Sample Model", + "serialNumber": "Serial number of the device", + "firmwareVersion": "Firmware version of the device", + "softwareVersion": "Software version of the device", + "customIdentifier": "Optional custom identifier for the device" + }, + "cookie": {}, + "capabilities": [ + { + "type": "AlexaInterface", + "interface": "Alexa.DoorbellEventSource", + "version": "3", + "proactivelyReported": true + }, + { + "type": "AlexaInterface", + "interface": "Alexa.EndpointHealth", + "version": "3.1", + "properties": { + "supported": [ + { + "name": "connectivity" + } + ], + "proactivelyReported": true, + "retrievable": true + } + }, + { + "type": "AlexaInterface", + "interface": "Alexa", + "version": "3" + } + ] + } + ] + } + } +} diff --git a/test/fixtures/alexa-docs/alexa-inventorylevelsensor/ChangeReport.json b/test/fixtures/alexa-docs/alexa-inventorylevelsensor/ChangeReport.json new file mode 100644 index 0000000..131b6c3 --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-inventorylevelsensor/ChangeReport.json @@ -0,0 +1,65 @@ +{ + "event": { + "header": { + "namespace": "Alexa", + "name": "ChangeReport", + "messageId": "Unique identifier, preferably a version 4 UUID", + "payloadVersion": "3" + }, + "endpoint": { + "scope": { + "type": "BearerToken", + "token": "OAuth2.0 bearer token" + }, + "endpointId": "Endpoint ID" + }, + "payload": { + "change": { + "cause": { + "type": "PERIODIC_POLL" + }, + "properties": [ + { + "namespace": "Alexa.InventoryLevelSensor", + "instance": "InkSensor.Cyan", + "name": "level", + "value": { + "@type": "Volume", + "value": 5, + "unit": "MILLILITER" + }, + "timeOfSample": "2019-10-31T17:00:00.00Z", + "uncertaintyInMilliseconds": 0 + }, + { + "namespace": "Alexa.InventoryLevelSensor", + "instance": "PaperSensor.FrontTray", + "name": "level", + "value": { + "@type": "Count", + "value": 200 + }, + "timeOfSample": "2019-10-31T17:00:00.00Z", + "uncertaintyInMilliseconds": 0 + }, + { + "namespace": "Alexa.PowerController", + "name": "powerState", + "value": "ON", + "timeOfSample": "2019-10-31T17:00:00.00Z", + "uncertaintyInMilliseconds": 0 + } + ] + } + } + }, + "context": { + "namespace": "Alexa.EndpointHealth", + "name": "connectivity", + "value": { + "value": "OK" + }, + "timeOfSample": "2019-10-31T16:58:10:00.00Z", + "uncertaintyInMilliseconds": 0 + } +} diff --git a/test/fixtures/alexa-docs/alexa-inventorylevelsensor/discovery-addOrUpdateReport.json b/test/fixtures/alexa-docs/alexa-inventorylevelsensor/discovery-addOrUpdateReport.json new file mode 100644 index 0000000..5b4b8f7 --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-inventorylevelsensor/discovery-addOrUpdateReport.json @@ -0,0 +1,123 @@ +{ + "event": { + "header": { + "namespace": "Alexa.Discovery", + "name": "AddOrUpdateReport", + "payloadVersion": "3", + "messageId": "Unique identifier, preferably a version 4 UUID" + }, + "payload": { + "endpoints": [ + { + "endpointId": "Unique ID of the endpoint", + "manufacturerName": "Printer Plus", + "description": "Smart Printer by Printer Maker Plus", + "friendlyName": "Printer", + "displayCategories": [ + "OTHER" + ], + "cookie": {}, + "capabilities": [ + { + "type": "AlexaInterface", + "interface": "Alexa.InventoryLevelSensor", + "instance": "InkSensor.Cyan", + "version": "3", + "properties": { + "supported": [ + { + "name": "level" + } + ], + "retrievable": true, + "proactivelyReported": true + }, + "configuration": { + "measurement": { + "@type": "Volume", + "unit": "MILLILITER" + }, + "replenishment": { + "@type": "DashReplenishmentId", + "value": "replenishment ID for refill options" + } + }, + "capabilityResources": { + "friendlyNames": [ + { + "@type": "text", + "value": { + "text": "Cyan ink", + "locale": "en-US" + } + }, + { + "@type": "text", + "value": { + "text": "Encre cyan", + "locale": "fr-FR" + } + } + ] + } + }, + { + "type": "AlexaInterface", + "interface": "Alexa.InventoryLevelSensor", + "instance": "PaperSensor.FrontTray", + "version": "3", + "properties": { + "supported": [ + { + "name": "level" + } + ], + "retrievable": true, + "proactivelyReported": true + }, + "configuration": { + "measurement": { + "@type": "Count" + }, + "replenishment": { + "@type": "DashReplenishmentId", + "value": "replenishment ID for refill options" + } + }, + "capabilityResources": { + "friendlyNames": [ + { + "@type": "text", + "value": { + "text": "Front tray", + "locale": "en-US" + } + } + ] + } + }, + { + "type": "AlexaInterface", + "interface": "Alexa.PowerController", + "version": "3", + "properties": { + "supported": [ + { + "name": "powerState" + } + ], + "retrievable": true, + "proactivelyReported": true + } + }, + { + "type": "AlexaInterface", + "interface": "Alexa", + "version": "3" + } + ] + } + ] + } + } +} diff --git a/test/fixtures/alexa-docs/alexa-inventorylevelsensor/discovery.json b/test/fixtures/alexa-docs/alexa-inventorylevelsensor/discovery.json new file mode 100644 index 0000000..2fc3cec --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-inventorylevelsensor/discovery.json @@ -0,0 +1,137 @@ +{ + "event": { + "header": { + "namespace": "Alexa.Discovery", + "name": "Discover.Response", + "payloadVersion": "3", + "messageId": "Unique identifier, preferably a version 4 UUID" + }, + "payload": { + "endpoints": [ + { + "endpointId": "Unique ID of the endpoint", + "manufacturerName": "Printer Plus", + "description": "Smart Printer by Printer Maker Plus", + "friendlyName": "Printer", + "displayCategories": [ + "OTHER" + ], + "cookie": {}, + "capabilities": [ + { + "type": "AlexaInterface", + "interface": "Alexa.InventoryLevelSensor", + "instance": "InkSensor.Cyan", + "version": "3", + "properties": { + "supported": [ + { + "name": "level" + } + ], + "retrievable": false, + "proactivelyReported": true + }, + "configuration": { + "measurement": { + "@type": "Volume", + "unit": "MILLILITER" + }, + "replenishment": { + "@type": "DashReplenishmentId", + "value": "replenishment ID for refill options" + } + }, + "capabilityResources": { + "friendlyNames": [ + { + "@type": "text", + "value": { + "text": "Cyan ink", + "locale": "en-US" + } + }, + { + "@type": "text", + "value": { + "text": "Encre cyan", + "locale": "fr-FR" + } + } + ] + } + }, + { + "type": "AlexaInterface", + "interface": "Alexa.InventoryLevelSensor", + "instance": "PaperSensor.FrontTray", + "version": "3", + "properties": { + "supported": [ + { + "name": "level" + } + ], + "retrievable": false, + "proactivelyReported": true + }, + "configuration": { + "measurement": { + "@type": "Count" + }, + "replenishment": { + "@type": "DashReplenishmentId", + "value": "replenishment ID for refill options" + } + }, + "capabilityResources": { + "friendlyNames": [ + { + "@type": "text", + "value": { + "text": "Front tray", + "locale": "en-US" + } + } + ] + } + }, + { + "type": "AlexaInterface", + "interface": "Alexa.PowerController", + "version": "3", + "properties": { + "supported": [ + { + "name": "powerState" + } + ], + "retrievable": true, + "proactivelyReported": true + } + }, + { + "type": "AlexaInterface", + "interface": "Alexa.EndpointHealth", + "version": "3", + "properties": { + "supported": [ + { + "name": "connectivity" + } + ], + "proactivelyReported": true, + "retrievable": true + } + }, + { + "type": "AlexaInterface", + "interface": "Alexa", + "version": "3" + } + ] + } + ] + } + } +} diff --git a/test/fixtures/alexa-docs/alexa-scenecontroller/Activate.directive.json b/test/fixtures/alexa-docs/alexa-scenecontroller/Activate.directive.json new file mode 100644 index 0000000..4cf2f42 --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-scenecontroller/Activate.directive.json @@ -0,0 +1,19 @@ +{ + "directive": { + "header": { + "namespace": "Alexa.SceneController", + "name": "Activate", + "messageId": "Unique version 4 UUID", + "correlationToken": "Opaque correlation token", + "payloadVersion": "3" + }, + "endpoint": { + "scope": { + "type": "BearerToken", + "token": "OAuth2.0 bearer token" + }, + "endpointId": "endpoint id" + }, + "payload": {} + } +} diff --git a/test/fixtures/alexa-docs/alexa-scenecontroller/ActivationStarted.event.json b/test/fixtures/alexa-docs/alexa-scenecontroller/ActivationStarted.event.json new file mode 100644 index 0000000..ed54b36 --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-scenecontroller/ActivationStarted.event.json @@ -0,0 +1,25 @@ +{ + "event": { + "header": { + "namespace": "Alexa.SceneController", + "name": "ActivationStarted", + "messageId": "Unique identifier, preferably a version 4 UUID", + "correlationToken": "Opaque correlation token that matches the request", + "payloadVersion": "3" + }, + "endpoint": { + "scope": { + "type": "BearerToken", + "token": "OAuth2.0 bearer token" + }, + "endpointId": "endpoint id" + }, + "payload": { + "cause": { + "type": "VOICE_INTERACTION" + }, + "timestamp": "2017-02-03T16:20:50Z" + } + }, + "context": {} +} diff --git a/test/fixtures/alexa-docs/alexa-scenecontroller/Deactivate.directive.json b/test/fixtures/alexa-docs/alexa-scenecontroller/Deactivate.directive.json new file mode 100644 index 0000000..93a9674 --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-scenecontroller/Deactivate.directive.json @@ -0,0 +1,19 @@ +{ + "directive": { + "header": { + "namespace": "Alexa.SceneController", + "name": "Deactivate", + "messageId": "Unique identifier, preferably a version 4 UUID", + "correlationToken": "Opaque correlation token", + "payloadVersion": "3" + }, + "endpoint": { + "scope": { + "type": "BearerToken", + "token": "OAuth2.0 bearer token" + }, + "endpointId": "endpoint id" + }, + "payload": {} + } +} diff --git a/test/fixtures/alexa-docs/alexa-scenecontroller/DeactivationStarted.event.json b/test/fixtures/alexa-docs/alexa-scenecontroller/DeactivationStarted.event.json new file mode 100644 index 0000000..5e28877 --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-scenecontroller/DeactivationStarted.event.json @@ -0,0 +1,25 @@ +{ + "event": { + "header": { + "namespace": "Alexa.SceneController", + "name": "DeactivationStarted", + "messageId": "Unique identifier, preferably a version 4 UUID", + "correlationToken": "Opaque correlation token that matches the request", + "payloadVersion": "3" + }, + "endpoint": { + "scope": { + "type": "BearerToken", + "token": "OAuth2.0 bearer token" + }, + "endpointId": "endpoint id" + }, + "payload": { + "cause": { + "type": "VOICE_INTERACTION" + }, + "timestamp": "2017-02-03T16:20:50Z" + } + }, + "context": {} +} diff --git a/test/fixtures/alexa-docs/alexa-scenecontroller/discovery.json b/test/fixtures/alexa-docs/alexa-scenecontroller/discovery.json new file mode 100644 index 0000000..10e460e --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-scenecontroller/discovery.json @@ -0,0 +1,37 @@ +{ + "event": { + "header": { + "namespace": "Alexa.Discovery", + "name": "Discover.Response", + "payloadVersion": "3", + "messageId": "Unique identifier, preferably a version 4 UUID" + }, + "payload": { + "endpoints": [ + { + "endpointId": "unique ID of the scene", + "manufacturerName": "Sample Vendor", + "description": "Party scene by Sample Vendor", + "friendlyName": "Living Room Party", + "displayCategories": [ + "SCENE_TRIGGER" + ], + "cookie": {}, + "capabilities": [ + { + "type": "AlexaInterface", + "interface": "Alexa.SceneController", + "version": "3", + "supportsDeactivation": false + }, + { + "type": "AlexaInterface", + "interface": "Alexa", + "version": "3" + } + ] + } + ] + } + } +} diff --git a/test/fixtures/alexa-docs/alexa-simpleeventsource/Event.event.json b/test/fixtures/alexa-docs/alexa-simpleeventsource/Event.event.json new file mode 100644 index 0000000..37399b6 --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-simpleeventsource/Event.event.json @@ -0,0 +1,23 @@ +{ + "event": { + "header": { + "namespace": "Alexa.SimpleEventSource", + "name": "Event", + "instance": "Unique identifier, preferably a version 4 UUID", + "messageId": "Unique identifier, preferably a version 4 UUID", + "payloadVersion": "1.0" + }, + "endpoint": { + "scope": { + "type": "BearerToken", + "token": "OAuth2 bearer token" + }, + "endpointId": "Endpoint id", + "cookie": {} + }, + "payload": { + "id": "Button.SinglePush.1", + "timestamp": "2021-12-12T16:20:50.52Z" + } + } +} diff --git a/test/fixtures/alexa-docs/alexa-timeholdcontroller/Hold.directive.json b/test/fixtures/alexa-docs/alexa-timeholdcontroller/Hold.directive.json new file mode 100644 index 0000000..04b9270 --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-timeholdcontroller/Hold.directive.json @@ -0,0 +1,20 @@ +{ + "directive": { + "header": { + "namespace": "Alexa.TimeHoldController", + "name": "Hold", + "messageId": "Unique version 4 UUID", + "correlationToken": "Opaque correlation token", + "payloadVersion": "3" + }, + "endpoint": { + "scope": { + "type": "BearerToken", + "token": "OAuth2.0 bearer token" + }, + "endpointId": "Endpoint ID", + "cookie": {} + }, + "payload": {} + } +} diff --git a/test/fixtures/alexa-docs/alexa-timeholdcontroller/Hold.response.json b/test/fixtures/alexa-docs/alexa-timeholdcontroller/Hold.response.json new file mode 100644 index 0000000..3a31d2c --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-timeholdcontroller/Hold.response.json @@ -0,0 +1,37 @@ +{ + "event": { + "header": { + "namespace": "Alexa", + "name": "Response", + "messageId": "Unique identifier, preferably a version 4 UUID", + "correlationToken": "Opaque correlation token that matches the request", + "payloadVersion": "3" + }, + "endpoint": { + "scope": { + "type": "BearerToken", + "token": "OAuth2.0 bearer token" + }, + "endpointId": "Endpoint ID" + }, + "payload": {} + }, + "context": { + "properties": [ + { + "namespace": "Alexa.TimeHoldController", + "name": "holdStartTime", + "value": "2018-05-31T14:30:00.00Z", + "timeOfSample": "2018-05-31T23:30:02.32Z", + "uncertaintyInMilliseconds": 0 + }, + { + "namespace": "Alexa.TimeHoldController", + "name": "holdEndTime", + "value": "2018-05-31T14:40:00.00Z", + "timeOfSample": "2018-05-31T23:30:02.32Z", + "uncertaintyInMilliseconds": 0 + } + ] + } +} diff --git a/test/fixtures/alexa-docs/alexa-timeholdcontroller/Resume.directive.json b/test/fixtures/alexa-docs/alexa-timeholdcontroller/Resume.directive.json new file mode 100644 index 0000000..b12bbf8 --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-timeholdcontroller/Resume.directive.json @@ -0,0 +1,20 @@ +{ + "directive": { + "header": { + "namespace": "Alexa.TimeHoldController", + "name": "Resume", + "messageId": "Unique version 4 UUID", + "correlationToken": "Opaque correlation token", + "payloadVersion": "3" + }, + "endpoint": { + "scope": { + "type": "BearerToken", + "token": "OAuth2.0 bearer token" + }, + "endpointId": "Endpoint ID", + "cookie": {} + }, + "payload": {} + } +} diff --git a/test/fixtures/alexa-docs/alexa-timeholdcontroller/Resume.response.json b/test/fixtures/alexa-docs/alexa-timeholdcontroller/Resume.response.json new file mode 100644 index 0000000..7dfdfbb --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-timeholdcontroller/Resume.response.json @@ -0,0 +1,20 @@ +{ + "event": { + "header": { + "namespace": "Alexa", + "name": "Response", + "messageId": "Unique identifier, preferably a version 4 UUID", + "correlationToken": "Opaque correlation token that matches the request", + "payloadVersion": "3" + }, + "endpoint": { + "scope": { + "type": "BearerToken", + "token": "OAuth2.0 bearer token" + }, + "endpointId": "Endpoint ID" + }, + "payload": {} + }, + "context": {} +} diff --git a/test/fixtures/alexa-docs/alexa-timeholdcontroller/StateReport.json b/test/fixtures/alexa-docs/alexa-timeholdcontroller/StateReport.json new file mode 100644 index 0000000..1d4dd77 --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-timeholdcontroller/StateReport.json @@ -0,0 +1,37 @@ +{ + "event": { + "header": { + "namespace": "Alexa", + "name": "StateReport", + "messageId": "Unique identifier, preferably a version 4 UUID", + "correlationToken": "Opaque correlation token that matches the request", + "payloadVersion": "3" + }, + "endpoint": { + "scope": { + "type": "BearerToken", + "token": "OAuth2.0 bearer token" + }, + "endpointId": "Endpoint ID" + }, + "payload": {} + }, + "context": { + "properties": [ + { + "namespace": "Alexa.TimeHoldController", + "name": "holdStartTime", + "value": "2018-05-31T14:30:00.00Z", + "timeOfSample": "2018-08-31T23:30:00Z", + "uncertaintyInMilliseconds": 0 + }, + { + "namespace": "Alexa.TimeHoldController", + "name": "holdEndTime", + "value": "2018-05-31T14:40:00.00Z", + "timeOfSample": "2018-08-31T23:30:00Z", + "uncertaintyInMilliseconds": 0 + } + ] + } +} diff --git a/test/fixtures/alexa-docs/alexa-timeholdcontroller/discovery.json b/test/fixtures/alexa-docs/alexa-timeholdcontroller/discovery.json new file mode 100644 index 0000000..5c7e5d0 --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-timeholdcontroller/discovery.json @@ -0,0 +1,94 @@ +{ + "event": { + "header": { + "namespace": "Alexa.Discovery", + "name": "Discover.Response", + "payloadVersion": "3", + "messageId": "Unique identifier, preferably a version 4 UUID" + }, + "payload": { + "endpoints": [ + { + "endpointId": "Unique ID of the endpoint", + "manufacturerName": "Manufacturer of the endpoint", + "description": "Description to be shown in the Alexa app", + "friendlyName": "Microwave", + "displayCategories": [ + "MICROWAVE" + ], + "cookie": {}, + "capabilities": [ + { + "type": "AlexaInterface", + "interface": "Alexa.TimeHoldController", + "version": "3", + "properties": { + "supported": [ + { + "name": "holdStartTime" + }, + { + "name": "holdEndTime" + } + ], + "proactivelyReported": true, + "retrievable": true + }, + "configuration": { + "allowRemoteResume": true + } + }, + { + "type": "AlexaInterface", + "interface": "Alexa.Cooking", + "version": "3", + "properties": { + "supported": [ + { + "name": "cookingMode" + }, + { + "name": "foodItem" + }, + { + "name": "cookingTimeInterval" + } + ], + "proactivelyReported": true, + "retrievable": true + }, + "configuration": { + "supportsRemoteStart": false, + "supportedCookingModes": [ + "REHEAT", + "DEFROST", + "PRESET", + "OFF" + ] + } + }, + { + "type": "AlexaInterface", + "interface": "Alexa.EndpointHealth", + "version": "3", + "properties": { + "supported": [ + { + "name": "connectivity" + } + ], + "proactivelyReported": false, + "retrievable": true + } + }, + { + "type": "AlexaInterface", + "interface": "Alexa", + "version": "3" + } + ] + } + ] + } + } +} diff --git a/test/fixtures/alexa-docs/alexa-wakeonlancontroller/ChangeReport.json b/test/fixtures/alexa-docs/alexa-wakeonlancontroller/ChangeReport.json new file mode 100644 index 0000000..8c2e854 --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-wakeonlancontroller/ChangeReport.json @@ -0,0 +1,42 @@ +{ + "event": { + "header": { + "namespace": "Alexa", + "name": "ChangeReport", + "messageId": "Unique identifier, preferably a version 4 UUID", + "payloadVersion": "3" + }, + "endpoint": { + "scope": { + "type": "BearerToken", + "token": "OAuth2.0 bearer token" + }, + "endpointId": "Endpoint ID" + }, + "payload": { + "change": { + "cause": { + "type": "VOICE_INTERACTION" + }, + "properties": [ + { + "namespace": "Alexa.PowerController", + "name": "powerState", + "value": "ON", + "timeOfSample": "2024-02-03T16:16:00.00Z", + "uncertaintyInMilliseconds": 0 + } + ] + } + } + }, + "context": { + "namespace": "Alexa.EndpointHealth", + "name": "connectivity", + "value": { + "value": "OK" + }, + "timeOfSample": "2024-02-03T16:15:00.00Z", + "uncertaintyInMilliseconds": 0 + } +} diff --git a/test/fixtures/alexa-docs/alexa-wakeonlancontroller/StateReport.json b/test/fixtures/alexa-docs/alexa-wakeonlancontroller/StateReport.json new file mode 100644 index 0000000..8aa0f34 --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-wakeonlancontroller/StateReport.json @@ -0,0 +1,30 @@ +{ + "event": { + "header": { + "namespace": "Alexa", + "name": "StateReport", + "messageId": "Unique identifier, preferably a version 4 UUID", + "correlationToken": "Opaque correlation token that matches the request", + "payloadVersion": "3" + }, + "endpoint": { + "scope": { + "type": "BearerToken", + "token": "OAuth2.0 bearer token" + }, + "endpointId": "Endpoint ID" + }, + "payload": {} + }, + "context": { + "properties": [ + { + "namespace": "Alexa.PowerController", + "name": "powerState", + "value": "OFF", + "timeOfSample": "2017-02-03T16:20:50.52Z", + "uncertaintyInMilliseconds": 0 + } + ] + } +} diff --git a/test/fixtures/alexa-docs/alexa-wakeonlancontroller/TurnOn.deferred.json b/test/fixtures/alexa-docs/alexa-wakeonlancontroller/TurnOn.deferred.json new file mode 100644 index 0000000..1d02610 --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-wakeonlancontroller/TurnOn.deferred.json @@ -0,0 +1,14 @@ +{ + "event": { + "header": { + "namespace": "Alexa", + "name": "DeferredResponse", + "messageId": "Unique identifier, preferably a version 4 UUID", + "correlationToken": "Opaque correlation token that matches the request", + "payloadVersion": "3" + }, + "payload": { + "estimatedDeferralInSeconds": 15 + } + } +} diff --git a/test/fixtures/alexa-docs/alexa-wakeonlancontroller/TurnOn.directive.json b/test/fixtures/alexa-docs/alexa-wakeonlancontroller/TurnOn.directive.json new file mode 100644 index 0000000..99e51a4 --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-wakeonlancontroller/TurnOn.directive.json @@ -0,0 +1,20 @@ +{ + "directive": { + "header": { + "namespace": "Alexa.PowerController", + "name": "TurnOn", + "messageId": "Unique version 4 UUID", + "correlationToken": "Opaque correlation token", + "payloadVersion": "3" + }, + "endpoint": { + "scope": { + "type": "BearerToken", + "token": "OAuth2.0 bearer token" + }, + "endpointId": "Endpoint ID", + "cookie": {} + }, + "payload": {} + } +} diff --git a/test/fixtures/alexa-docs/alexa-wakeonlancontroller/TurnOn.response.json b/test/fixtures/alexa-docs/alexa-wakeonlancontroller/TurnOn.response.json new file mode 100644 index 0000000..7d88098 --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-wakeonlancontroller/TurnOn.response.json @@ -0,0 +1,30 @@ +{ + "event": { + "header": { + "namespace": "Alexa", + "name": "Response", + "messageId": "Unique identifier, preferably a version 4 UUID", + "correlationToken": "Opaque correlation token that matches the request", + "payloadVersion": "3" + }, + "endpoint": { + "scope": { + "type": "BearerToken", + "token": "OAuth2.0 bearer token" + }, + "endpointId": "Endpoint ID" + }, + "payload": {} + }, + "context": { + "properties": [ + { + "namespace": "Alexa.PowerController", + "name": "powerState", + "value": "ON", + "timeOfSample": "2017-02-03T16:20:50.52Z", + "uncertaintyInMilliseconds": 500 + } + ] + } +} diff --git a/test/fixtures/alexa-docs/alexa-wakeonlancontroller/WakeUp.event.json b/test/fixtures/alexa-docs/alexa-wakeonlancontroller/WakeUp.event.json new file mode 100644 index 0000000..e099c71 --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-wakeonlancontroller/WakeUp.event.json @@ -0,0 +1,30 @@ +{ + "event": { + "header": { + "namespace": "Alexa.WakeOnLANController", + "name": "WakeUp", + "messageId": "Unique identifier, preferably a version 4 UUID", + "correlationToken": "Opaque correlation token that matches the request", + "payloadVersion": "3" + }, + "endpoint": { + "scope": { + "type": "BearerToken", + "token": "OAuth2.0 bearer token" + }, + "endpointId": "Endpoint ID" + }, + "payload": {} + }, + "context": { + "properties": [ + { + "namespace": "Alexa.PowerController", + "name": "powerState", + "value": "OFF", + "timeOfSample": "2017-02-03T16:20:50.52Z", + "uncertaintyInMilliseconds": 500 + } + ] + } +} diff --git a/test/fixtures/alexa-docs/alexa-wakeonlancontroller/discovery.json b/test/fixtures/alexa-docs/alexa-wakeonlancontroller/discovery.json new file mode 100644 index 0000000..76bc640 --- /dev/null +++ b/test/fixtures/alexa-docs/alexa-wakeonlancontroller/discovery.json @@ -0,0 +1,70 @@ +{ + "event": { + "header": { + "namespace": "Alexa.Discovery", + "name": "Discover.Response", + "payloadVersion": "3", + "messageId": "Unique identifier, preferably a version 4 UUID" + }, + "payload": { + "endpoints": [ + { + "endpointId": "Unique ID of the endpoint", + "manufacturerName": "Manufacturer of the endpoint", + "description": "Description to be shown in the Alexa app", + "friendlyName": "Device name, displayed in the Alexa app", + "displayCategories": [ + "TV" + ], + "cookie": {}, + "capabilities": [ + { + "type": "AlexaInterface", + "interface": "Alexa.WakeOnLANController", + "version": "3", + "properties": {}, + "configuration": { + "MACAddresses": [ + "00-14-22-01-23-45" + ] + } + }, + { + "type": "AlexaInterface", + "interface": "Alexa.PowerController", + "version": "3", + "properties": { + "supported": [ + { + "name": "powerState" + } + ], + "proactivelyReported": true, + "retrievable": true + } + }, + { + "type": "AlexaInterface", + "interface": "Alexa.EndpointHealth", + "version": "3", + "properties": { + "supported": [ + { + "name": "connectivity" + } + ], + "proactivelyReported": true, + "retrievable": true + } + }, + { + "type": "AlexaInterface", + "interface": "Alexa", + "version": "3" + } + ] + } + ] + } + } +} diff --git a/test/fixtures/interfaces.js b/test/fixtures/interfaces.js index 12a5b49..b02ea98 100644 --- a/test/fixtures/interfaces.js +++ b/test/fixtures/interfaces.js @@ -14,6 +14,7 @@ // unfit [[property, value]]: a value the property does not take // chosen true: a declaration lists the properties the device has, an example some of those of the interface // folded the directives whose example the page folds away, so that it was not saved +// endpoint { categories, description } of the endpoint the interface is declared on, where it has rules for them // // A directive is answered with a Response, or with the event responseFor() of its descriptor names. // @@ -61,6 +62,12 @@ module.exports = [ unfit: [["colorTemperatureInKelvin", 10001], ["colorTemperatureInKelvin", "warm"]], }, { namespace: "Alexa.ContactSensor", page: "alexa-contactsensor", declared, unfit: [["detectionState", "OPEN"], ["detectionState", true]] }, + { + namespace: "Alexa.DoorbellEventSource", + page: "alexa-doorbelleventsource", + endpoint: { categories: ["CAMERA", "DOORBELL"] }, + declared: [{ options: {}, example: "discovery" }], + }, { namespace: "Alexa.EndpointHealth", page: "alexa-endpointhealth", declared: [{ options: reported, example: "discovery" }] }, { namespace: "Alexa.EqualizerController", @@ -100,6 +107,41 @@ module.exports = [ ], unfit: [["input", "hdmi 1"], ["input", 1]], }, + { + namespace: "Alexa.InventoryLevelSensor", + page: "alexa-inventorylevelsensor", + declared: [ + { + example: "discovery", + options: { + retrievable: false, + proactivelyReported: true, + instance: "InkSensor.Cyan", + friendlyNames: [text("Cyan ink", "en-US"), text("Encre cyan", "fr-FR")], + measurement: { "@type": "Volume", unit: "MILLILITER" }, + replenishment: { "@type": "DashReplenishmentId", value: "replenishment ID for refill options" }, + }, + }, + { + example: "discovery", + n: 1, + options: { + retrievable: false, + proactivelyReported: true, + instance: "PaperSensor.FrontTray", + friendlyNames: [text("Front tray", "en-US")], + measurement: { "@type": "Count" }, + replenishment: { "@type": "DashReplenishmentId", value: "replenishment ID for refill options" }, + }, + }, + ], + unfit: [ + ["level", 5], + ["level", { "@type": "Volume", value: 5 }], + ["level", { "@type": "Count", value: 200, unit: "SHEET" }], + ["level", { "@type": "Length", value: 5, unit: "METER" }], + ], + }, { namespace: "Alexa.LockController", page: "alexa-lockcontroller", @@ -175,6 +217,12 @@ module.exports = [ }, }], }, + { + namespace: "Alexa.SceneController", + page: "alexa-scenecontroller", + endpoint: { categories: ["SCENE_TRIGGER"], description: "Party scene by Sample Vendor" }, + declared: [{ options: { supportsDeactivation: false }, example: "discovery" }], + }, { namespace: "Alexa.SecurityPanelController", page: "alexa-securitypanelcontroller", @@ -196,6 +244,8 @@ module.exports = [ ], unfit: [["armState", "ARMED"], ["burglaryAlarm", "ALARM"], ["fireAlarm", { value: "FIRE" }]], }, + // The page folds its discovery example away + { namespace: "Alexa.SimpleEventSource", page: "alexa-simpleeventsource", declared: [] }, { namespace: "Alexa.Speaker", page: "alexa-speaker", @@ -270,6 +320,14 @@ module.exports = [ ], unfit: [["scheduleEnabled", "ON"], ["adaptiveRecoveryEnabled", 1]], }, + { + // The ChangeReport example of the page reports "2018-05-31T14:02.32Z", which is no time: it was not saved + namespace: "Alexa.TimeHoldController", + page: "alexa-timeholdcontroller", + declared: [{ options: { ...reported, allowRemoteResume: true }, example: "discovery" }], + refused: [["Hold", 10, "payload: expected an object, got 10"]], + unfit: [["holdStartTime", "14:30"], ["holdEndTime", "2018-05-31T14:40:00+02:00"]], + }, { namespace: "Alexa.ToggleController", page: "alexa-togglecontroller", @@ -314,4 +372,9 @@ module.exports = [ }, ], }, + { + namespace: "Alexa.WakeOnLANController", + page: "alexa-wakeonlancontroller", + declared: [{ options: { macAddresses: ["00-14-22-01-23-45"] }, example: "discovery" }], + }, ]; diff --git a/test/registry/events.test.js b/test/registry/events.test.js new file mode 100644 index 0000000..deac817 --- /dev/null +++ b/test/registry/events.test.js @@ -0,0 +1,136 @@ +"use strict"; +// Scenes, event sources and the interfaces declared with them: what the examples of their pages do not show. The +// examples themselves are run by descriptors.test.js, device/discovery.test.js and dispatch/interfaces.test.js. +const { test } = require("node:test"); +const assert = require("node:assert/strict"); +const { + AlexaInterfaceType, DeclarationError, DoorbellEventSource, InventoryLevelSensor, SceneController, SimpleEventSource, + TimeHoldController, WakeOnLANController, asset, registry, text, +} = require("alex2node"); +const { endpoint } = require("../helpers/endpoint.js"); +const { doc } = require("../helpers/fixtures.js"); + +function scene(name = "Evening", categories = ["SCENE_TRIGGER"], description = "Evening scene by Alex2Node") { + const device = endpoint("scene-1", name, categories); + device.setDescription(description); + return device; +} + +const refused = (device, descriptor, options, problem) => assert.throws( + () => device.add(descriptor, options), + (err) => err instanceof DeclarationError && err.problem === problem +); + +test("the events of the pages fit the payload of their descriptor, and come back as they are", () => { + const printed = [ + ["Alexa.SceneController", "alexa-scenecontroller", "ActivationStarted", "response"], + ["Alexa.SceneController", "alexa-scenecontroller", "DeactivationStarted", "response"], + ["Alexa.DoorbellEventSource", "alexa-doorbelleventsource", "DoorbellPress", "proactive"], + ["Alexa.SimpleEventSource", "alexa-simpleeventsource", "Event", "proactive"], + ["Alexa.WakeOnLANController", "alexa-wakeonlancontroller", "WakeUp", "response"], + ]; + for (const [namespace, page, name, topic] of printed) { + const { header, payload } = doc(page, `${name}.event`).event; + const descriptor = registry.get(namespace); + const event = descriptor.events[name]; + assert.deepEqual([header.namespace, header.name, header.payloadVersion], [namespace, event.name, descriptor.version]); + assert.equal(event.topic, topic, name); + assert.equal("correlationToken" in header, topic === "response", name); + assert.deepEqual(event.payload.parse(payload, "payload"), payload); + } + const names = registry.list().filter((descriptor) => descriptor.events).map((descriptor) => descriptor.namespace); + assert.deepEqual(names, [...new Set(printed.map(([namespace]) => namespace))].sort()); +}); + +test("SceneController: a scene declared without options is announced as 1.5.2 announced it", () => { + const OF_1X = { type: "AlexaInterface", interface: "Alexa.SceneController", version: "3", supportsDeactivation: true, proactivelyReported: false }; + assert.deepEqual(scene().addCapability(AlexaInterfaceType.SCENE_CONTROLLER).getJSON(), OF_1X); + assert.deepEqual(scene().add(SceneController).toJSON(), OF_1X); + assert.deepEqual(scene().add(SceneController, { supportsDeactivation: true }).toJSON(), { + type: "AlexaInterface", interface: "Alexa.SceneController", version: "3", supportsDeactivation: true, + }); +}); + +test("SceneController: the endpoint of a scene has a category of scenes, the word scene in its description and a name of up to 128", () => { + const device = scene("Evening", ["ACTIVITY_TRIGGER", "OTHER"], "Evening Scene"); + device.add(SceneController); + assert.deepEqual(device.check(), []); + + refused(scene("Evening", ["LIGHT"]), SceneController, {}, "a scene has the display category SCENE_TRIGGER, or ACTIVITY_TRIGGER when the order of its steps matters"); + refused( + scene("Evening", ["SCENE_TRIGGER"], "All lights off"), + SceneController, + {}, + 'the description of a scene has the word "scene" in it, like "Party scene connected by Alex2Node"' + ); + refused(scene("a".repeat(129)), SceneController, {}, "the name of a scene takes up to 128 characters, this one has 129"); + + // Declared the 1.x way the scene is announced, and check() says what Alexa may not take + const old = scene("Evening", ["SCENE_TRIGGER"], "All lights off"); + old.addCapability(AlexaInterfaceType.SCENE_CONTROLLER); + assert.deepEqual(old.check(), ['scene-1: Alexa.SceneController: the description of a scene has the word "scene" in it, like "Party scene connected by Alex2Node"']); +}); + +test("DoorbellEventSource: proactivelyReported on the capability, DOORBELL among the categories and CAMERA before it", () => { + const announced = { type: "AlexaInterface", interface: "Alexa.DoorbellEventSource", version: "3", proactivelyReported: true }; + const bell = endpoint("door-1", "Door", ["DOORBELL"]); + assert.deepEqual(bell.add(DoorbellEventSource, { proactivelyReported: false }).toJSON(), announced); + assert.deepEqual(endpoint("door-1", "Door", ["DOORBELL"]).addCapability(AlexaInterfaceType.DOORBELL_EVENT_SOURCE).getJSON(), announced); + refused(endpoint("door-1", "Door", ["CAMERA"]), DoorbellEventSource, {}, "a doorbell has the display category DOORBELL"); + refused(endpoint("door-1", "Door", ["DOORBELL", "CAMERA"]), DoorbellEventSource, {}, "a video doorbell lists the display category CAMERA before DOORBELL"); +}); + +test("SimpleEventSource: version 1.0, an instance for each button with its events, no properties object", () => { + const remote = () => endpoint("remote-1", "Remote", ["REMOTE"]); + const single = { id: "Button.SinglePush.1", friendlyNames: [asset("Alexa.Button.SinglePush")] }; + const double = { id: "Button.DoublePush.1", friendlyNames: [asset("Alexa.Button.DoublePush"), text("Twice")] }; + const top = { instance: "Remote.TopButton", friendlyNames: [asset("Alexa.Button.TopButton")] }; + + assert.deepEqual(remote().add(SimpleEventSource, { ...top, supportedEvents: [single, double] }).toJSON(), { + type: "AlexaInterface", + interface: "Alexa.SimpleEventSource", + instance: "Remote.TopButton", + version: "1.0", + capabilityResources: { friendlyNames: top.friendlyNames }, + configuration: { supportedEvents: [single, double] }, + }); + refused(remote(), SimpleEventSource, { supportedEvents: [single] }, "needs an instance name, like Blind.Lift"); + refused(remote(), SimpleEventSource, { ...top, supportedEvents: [] }, "supportedEvents: expected a list with 1 or more entries, got []"); + refused(remote(), SimpleEventSource, { ...top, supportedEvents: [single, single] }, "the event Button.SinglePush.1 is listed twice"); + refused( + remote(), + SimpleEventSource, + { ...top, supportedEvents: [single, { ...double, friendlyNames: single.friendlyNames }] }, + "two events have Alexa.Button.SinglePush as their first friendly name, the Alexa app shows an event by it" + ); +}); + +test("TimeHoldController, InventoryLevelSensor and WakeOnLANController: what a declaration has to say", () => { + const device = () => endpoint("device-1", "Device", ["OTHER"]); + refused(device(), TimeHoldController, {}, "allowRemoteResume: expected true or false, got nothing"); + + const ink = { instance: "Ink.Cyan", friendlyNames: [text("Cyan ink")], replenishment: { "@type": "DashReplenishmentId", value: "id-1" } }; + refused(device(), InventoryLevelSensor, { ...ink, measurement: { "@type": "Volume" } }, "measurement.unit: expected the unit of the volume, like MILLILITER or GRAM, got nothing"); + refused(device(), InventoryLevelSensor, { ...ink, measurement: { "@type": "Percentage", unit: "PERCENT" } }, 'measurement.unit: expected no unit for a percentage, got "PERCENT"'); + refused(device(), InventoryLevelSensor, { ...ink, measurement: { "@type": "Count" }, replenishment: undefined }, "replenishment: expected an object with @type, value, got nothing"); + assert.deepEqual(InventoryLevelSensor.properties.level.value.parse({ "@type": "Weight", value: 0.5, unit: "KILOGRAM" }), { "@type": "Weight", value: 0.5, unit: "KILOGRAM" }); + + assert.equal(WakeOnLANController.deferrable, true); + assert.deepEqual(device().add(WakeOnLANController, { macAddresses: ["00:14:22:01:23:45"] }).toJSON().configuration, { MACAddresses: ["00:14:22:01:23:45"] }); + refused(device(), WakeOnLANController, {}, "macAddresses: expected a list with 1 or more entries, got nothing"); + refused(device(), WakeOnLANController, { macAddresses: ["0014.2201.2345"] }, 'macAddresses[0]: expected a MAC address like 00-14-22-01-23-45, got "0014.2201.2345"'); + refused(device(), WakeOnLANController, { macAddresses: ["00-14-22-01-23-ab", "00:14:22:01:23:AB"] }, "00-14-22-01-23-AB is listed twice"); + + // Declared the 1.x way none of the three can say it: they are announced, and check() lists what is missing + const old = device(); + old.addCapability(AlexaInterfaceType.TIME_HOLD_CONTROLLER); + old.addCapability(AlexaInterfaceType.WAKE_ON_LAN_CONTROLLER); + assert.deepEqual(old.getJSON().capabilities.slice(0, 2), [ + { type: "AlexaInterface", interface: "Alexa.TimeHoldController", version: "3", properties: { supported: [{ name: "holdStartTime" }, { name: "holdEndTime" }], proactivelyReported: false, retrievable: true } }, + { type: "AlexaInterface", interface: "Alexa.WakeOnLANController", version: "3", properties: {} }, + ]); + assert.deepEqual(old.check(), [ + "device-1: Alexa.TimeHoldController: allowRemoteResume: expected true or false, got nothing", + "device-1: Alexa.WakeOnLANController: macAddresses: expected a list with 1 or more entries, got nothing", + ]); +});