From 2cbcde563ed4c34d886c6192ed6429a96bd0f99a Mon Sep 17 00:00:00 2001 From: David Date: Mon, 28 Sep 2026 21:37:37 +0000 Subject: [PATCH] device: changeReport(cause, fill) sends a ChangeReport on the 2.0 API The design has it (section 5) and 2.0 lacked it: a ChangeReport could only be sent with getChangeReport() of 1.x, which has no helper for a contact or a motion sensor. fill sets what changed, and after unchanged() what did not; the rest of the context is the state of device.state(). The report goes to /changeReport. It throws MessageError without a changed property and resolves with the failure when the device is on no bridge, as raise() does. Four tests in test/device/raise.test.js. Co-Authored-By: Claude Fable 5.1 --- dist/cjs/device/Device.js | 27 +++++++++++++++- dist/esm/device/Device.d.ts | 13 ++++++++ dist/esm/device/Device.js | 29 +++++++++++++++-- dist/types/device/Device.d.ts | 13 ++++++++ src/device/Device.ts | 29 +++++++++++++++-- test/device/raise.test.js | 60 ++++++++++++++++++++++++++++++++++- 6 files changed, 165 insertions(+), 6 deletions(-) diff --git a/dist/cjs/device/Device.js b/dist/cjs/device/Device.js index d125d3f..7e4eee5 100644 --- a/dist/cjs/device/Device.js +++ b/dist/cjs/device/Device.js @@ -39,6 +39,7 @@ const AlexaInterface_js_1 = require("../compat/AlexaInterface.js"); const AlexaStatusMessage_js_1 = require("../compat/AlexaStatusMessage.js"); const enums_js_1 = require("../compat/enums.js"); const build_js_1 = require("../messages/build.js"); +const StateBuilder_js_1 = require("../messages/StateBuilder.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"); @@ -218,7 +219,31 @@ class Device extends events_1.EventEmitter { 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); + return this.publish(topics.event(this.rootTopic), message); + } + /** + * Say that the state of the device changed, without a directive that asked for it: + * + * door.changeReport("PHYSICAL_INTERACTION", (s) => s.set(contact, "detectionState", "DETECTED")); + * + * fill sets what changed; after unchanged() it sets what did not. The rest of the context is the state of + * device.state(). The report goes to /changeReport, which Alex2MQTT posts to Alexa; Alexa takes it for a + * capability declared with proactivelyReported. Resolves with what became of the publish and does not reject. + * + * Throws a MessageError when fill sets no property that changed, and a SchemaError for a value Alexa would not take. + */ + changeReport(cause, fill) { + const { endpointId } = this; + const own = new StateBuilder_js_1.StateBuilder({ target: "change" }); + fill(own); + const whole = new StateBuilder_js_1.StateBuilder(); + this.stateProvider?.(whole); + const context = [...whole.context.filter((property) => !own.context.some((other) => (0, build_js_1.sameProperty)(property, other))), ...own.context]; + const message = (0, build_js_1.changeReport)({ endpointId, cause, changed: own.change, context }); + return this.publish(topics.changeReport(this.rootTopic), message); + } + // A message nobody asked for: a failed publish is reported to the bridge and is the result + publish(topic, message) { 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) diff --git a/dist/esm/device/Device.d.ts b/dist/esm/device/Device.d.ts index bf12866..f3697d1 100644 --- a/dist/esm/device/Device.d.ts +++ b/dist/esm/device/Device.d.ts @@ -134,6 +134,19 @@ declare class Device extends EventEmitter { instance?: string; messageId?: string; }): Promise; + /** + * Say that the state of the device changed, without a directive that asked for it: + * + * door.changeReport("PHYSICAL_INTERACTION", (s) => s.set(contact, "detectionState", "DETECTED")); + * + * fill sets what changed; after unchanged() it sets what did not. The rest of the context is the state of + * device.state(). The report goes to /changeReport, which Alex2MQTT posts to Alexa; Alexa takes it for a + * capability declared with proactivelyReported. Resolves with what became of the publish and does not reject. + * + * Throws a MessageError when fill sets no property that changed, and a SchemaError for a value Alexa would not take. + */ + changeReport(cause: ChangeCause, fill: Fill): Promise; + private publish; /** * 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 cd6882a..638e8a5 100644 --- a/dist/esm/device/Device.js +++ b/dist/esm/device/Device.js @@ -3,7 +3,8 @@ import { AlexaErrorResponse } from "../compat/AlexaErrorResponse.js"; import { AlexaInterface } from "../compat/AlexaInterface.js"; import { AlexaStatusMessage } from "../compat/AlexaStatusMessage.js"; import { DisplayCategory } from "../compat/enums.js"; -import { MessageError, proactiveEvent, sceneEvent } from "../messages/build.js"; +import { changeReport, MessageError, proactiveEvent, sameProperty, sceneEvent } from "../messages/build.js"; +import { StateBuilder } from "../messages/StateBuilder.js"; import { Alexa } from "../registry/interfaces/Alexa.js"; import { EndpointHealth } from "../registry/interfaces/EndpointHealth.js"; import { SceneController } from "../registry/interfaces/SceneController.js"; @@ -183,7 +184,31 @@ class Device extends EventEmitter { 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); + return this.publish(topics.event(this.rootTopic), message); + } + /** + * Say that the state of the device changed, without a directive that asked for it: + * + * door.changeReport("PHYSICAL_INTERACTION", (s) => s.set(contact, "detectionState", "DETECTED")); + * + * fill sets what changed; after unchanged() it sets what did not. The rest of the context is the state of + * device.state(). The report goes to /changeReport, which Alex2MQTT posts to Alexa; Alexa takes it for a + * capability declared with proactivelyReported. Resolves with what became of the publish and does not reject. + * + * Throws a MessageError when fill sets no property that changed, and a SchemaError for a value Alexa would not take. + */ + changeReport(cause, fill) { + const { endpointId } = this; + const own = new StateBuilder({ target: "change" }); + fill(own); + const whole = new StateBuilder(); + this.stateProvider?.(whole); + const context = [...whole.context.filter((property) => !own.context.some((other) => sameProperty(property, other))), ...own.context]; + const message = changeReport({ endpointId, cause, changed: own.change, context }); + return this.publish(topics.changeReport(this.rootTopic), message); + } + // A message nobody asked for: a failed publish is reported to the bridge and is the result + publish(topic, message) { 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) diff --git a/dist/types/device/Device.d.ts b/dist/types/device/Device.d.ts index bf12866..f3697d1 100644 --- a/dist/types/device/Device.d.ts +++ b/dist/types/device/Device.d.ts @@ -134,6 +134,19 @@ declare class Device extends EventEmitter { instance?: string; messageId?: string; }): Promise; + /** + * Say that the state of the device changed, without a directive that asked for it: + * + * door.changeReport("PHYSICAL_INTERACTION", (s) => s.set(contact, "detectionState", "DETECTED")); + * + * fill sets what changed; after unchanged() it sets what did not. The rest of the context is the state of + * device.state(). The report goes to /changeReport, which Alex2MQTT posts to Alexa; Alexa takes it for a + * capability declared with proactivelyReported. Resolves with what became of the publish and does not reject. + * + * Throws a MessageError when fill sets no property that changed, and a SchemaError for a value Alexa would not take. + */ + changeReport(cause: ChangeCause, fill: Fill): Promise; + private publish; /** * How the device reports its state, every retrievable property of it: * diff --git a/src/device/Device.ts b/src/device/Device.ts index c768999..dd76982 100644 --- a/src/device/Device.ts +++ b/src/device/Device.ts @@ -4,7 +4,8 @@ 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 { MessageError, proactiveEvent, sceneEvent } from "../messages/build.js"; +import { changeReport, MessageError, proactiveEvent, sameProperty, sceneEvent } from "../messages/build.js"; +import { StateBuilder } from "../messages/StateBuilder.js"; import type { ChangeCause } from "../messages/types.js"; import type { DirectiveHandler, Fill } from "../dispatcher.js"; import type { DisplayCategoryName } from "../registry/catalog.js"; @@ -273,7 +274,31 @@ class Device extends EventEmitter { 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); + return this.publish(topics.event(this.rootTopic), message); + } + /** + * Say that the state of the device changed, without a directive that asked for it: + * + * door.changeReport("PHYSICAL_INTERACTION", (s) => s.set(contact, "detectionState", "DETECTED")); + * + * fill sets what changed; after unchanged() it sets what did not. The rest of the context is the state of + * device.state(). The report goes to /changeReport, which Alex2MQTT posts to Alexa; Alexa takes it for a + * capability declared with proactivelyReported. Resolves with what became of the publish and does not reject. + * + * Throws a MessageError when fill sets no property that changed, and a SchemaError for a value Alexa would not take. + */ + changeReport(cause: ChangeCause, fill: Fill): Promise { + const { endpointId } = this; + const own = new StateBuilder({ target: "change" }); + fill(own); + const whole = new StateBuilder(); + this.stateProvider?.(whole); + const context = [...whole.context.filter((property) => !own.context.some((other) => sameProperty(property, other))), ...own.context]; + const message = changeReport({ endpointId, cause, changed: own.change, context }); + return this.publish(topics.changeReport(this.rootTopic), message); + } + // A message nobody asked for: a failed publish is reported to the bridge and is the result + private publish(topic: string, message: object): Promise { 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) diff --git a/test/device/raise.test.js b/test/device/raise.test.js index 5f17130..49e01c5 100644 --- a/test/device/raise.test.js +++ b/test/device/raise.test.js @@ -1,9 +1,10 @@ "use strict"; // device.raise(): an event nobody asked for goes to /event, and one that would not arrive is refused. +// device.changeReport(): a change of state nobody asked for goes to /changeReport. const { test } = require("node:test"); const assert = require("node:assert/strict"); const { - Alex2MQTT, AlexaInterfaceType, DoorbellEventSource, MemoryPublisher, MessageError, PowerController, SceneController, + Alex2MQTT, AlexaInterfaceType, ContactSensor, DoorbellEventSource, MemoryPublisher, MessageError, PowerController, SceneController, SimpleEventSource, asset, topics, } = require("alex2node"); const { endpoint } = require("../helpers/endpoint.js"); @@ -150,3 +151,60 @@ test("raise() of a doorbell declared the 1.x way, of a device on no bridge and w 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"]); }); + +// device.changeReport(): the 2.0 way to send a ChangeReport +function sensor() { + const sent = new MemoryPublisher(); + const bridge = new Alex2MQTT("u", "p", "root", false, { publisher: sent, log: () => {} }); + const device = bridge.addDevice({ endpointId: "door-2", name: "Back door", categories: ["CONTACT_SENSOR"] }); + const contact = device.add(ContactSensor, { proactivelyReported: true }); + const state = { contact: "NOT_DETECTED" }; + device.state((s) => s.set(contact, "detectionState", state.contact).health("OK")); + return { device, contact, sent, state }; +} + +test("changeReport() publishes what changed on /changeReport, with the rest of the state as the context", async () => { + const { device, contact, sent } = sensor(); + + const result = await device.changeReport("PHYSICAL_INTERACTION", (s) => s.set(contact, "detectionState", "DETECTED")); + + assert.deepEqual(result, { ok: true, topic: "root/changeReport" }); + const [{ topic, message }] = sent.published; + assert.equal(topic, "root/changeReport"); + assert.equal(message.event.header.name, "ChangeReport"); + assert.equal("correlationToken" in message.event.header, false); + assert.equal(message.event.endpoint.endpointId, "door-2"); + assert.equal(message.event.payload.change.cause.type, "PHYSICAL_INTERACTION"); + assert.deepEqual(message.event.payload.change.properties.map(({ namespace, name, value }) => [namespace, name, value]), + [["Alexa.ContactSensor", "detectionState", "DETECTED"]]); + // The property that changed is not in the context a second time, with the value device.state() still has + assert.deepEqual(message.context.properties.map(({ namespace, name, value }) => [namespace, name, value]), + [["Alexa.EndpointHealth", "connectivity", { value: "OK" }]]); +}); + +test("changeReport() takes what did not change after unchanged(), in place of the same property of device.state()", async () => { + const { device, contact, sent } = sensor(); + + await device.changeReport("PERIODIC_POLL", (s) => s.set(contact, "detectionState", "DETECTED").unchanged().health("UNREACHABLE")); + + assert.deepEqual(sent.published[0].message.context.properties.map(({ name, value }) => [name, value]), + [["connectivity", { value: "UNREACHABLE" }]]); +}); + +test("changeReport() without a property that changed throws and publishes nothing", () => { + const { device, sent } = sensor(); + + assert.throws(() => device.changeReport("PHYSICAL_INTERACTION", (s) => s.unchanged().health("OK")), + (err) => err instanceof MessageError && /at least one property that changed/.test(err.message)); + assert.equal(sent.published.length, 0); +}); + +test("changeReport() of a device on no bridge resolves with the failure", async () => { + const { device, contact } = sensor(); + device.publisher = null; + + const result = await device.changeReport("PHYSICAL_INTERACTION", (s) => s.set(contact, "detectionState", "DETECTED")); + + assert.equal(result.ok, false); + assert.match(result.error.message, /nothing was published to root\/changeReport/); +});