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
<root>/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 <noreply@anthropic.com>
This commit is contained in:
David 2026-09-28 21:37:37 +00:00
parent f0ca4c1fa4
commit 2cbcde563e
6 changed files with 165 additions and 6 deletions

View file

@ -1,9 +1,10 @@
"use strict";
// device.raise(): an event nobody asked for goes to <root>/event, and one that would not arrive is refused.
// device.changeReport(): a change of state nobody asked for goes to <root>/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 <root>/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/);
});