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:
parent
f0ca4c1fa4
commit
2cbcde563e
6 changed files with 165 additions and 6 deletions
27
dist/cjs/device/Device.js
vendored
27
dist/cjs/device/Device.js
vendored
|
|
@ -39,6 +39,7 @@ const AlexaInterface_js_1 = require("../compat/AlexaInterface.js");
|
||||||
const AlexaStatusMessage_js_1 = require("../compat/AlexaStatusMessage.js");
|
const AlexaStatusMessage_js_1 = require("../compat/AlexaStatusMessage.js");
|
||||||
const enums_js_1 = require("../compat/enums.js");
|
const enums_js_1 = require("../compat/enums.js");
|
||||||
const build_js_1 = require("../messages/build.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 Alexa_js_1 = require("../registry/interfaces/Alexa.js");
|
||||||
const EndpointHealth_js_1 = require("../registry/interfaces/EndpointHealth.js");
|
const EndpointHealth_js_1 = require("../registry/interfaces/EndpointHealth.js");
|
||||||
const SceneController_js_1 = require("../registry/interfaces/SceneController.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));
|
const bytes = Buffer.byteLength(JSON.stringify(message));
|
||||||
if (bytes > topics.EVENT_BYTES)
|
if (bytes > topics.EVENT_BYTES)
|
||||||
return refuse(`it is ${bytes} bytes as JSON, Alex2MQTT takes ${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 <root>/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 unpublished = new Error(`nothing was published to ${topic}: the device is on no bridge, register it with addDevice() or registerDevice()`);
|
||||||
const published = this.publisher
|
const published = this.publisher
|
||||||
? this.publisher.publish(topic, message)
|
? this.publisher.publish(topic, message)
|
||||||
|
|
|
||||||
13
dist/esm/device/Device.d.ts
vendored
13
dist/esm/device/Device.d.ts
vendored
|
|
@ -134,6 +134,19 @@ declare class Device extends EventEmitter {
|
||||||
instance?: string;
|
instance?: string;
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
}): Promise<PublishResult>;
|
}): Promise<PublishResult>;
|
||||||
|
/**
|
||||||
|
* 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 <root>/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<PublishResult>;
|
||||||
|
private publish;
|
||||||
/**
|
/**
|
||||||
* How the device reports its state, every retrievable property of it:
|
* How the device reports its state, every retrievable property of it:
|
||||||
*
|
*
|
||||||
|
|
|
||||||
29
dist/esm/device/Device.js
vendored
29
dist/esm/device/Device.js
vendored
|
|
@ -3,7 +3,8 @@ import { AlexaErrorResponse } from "../compat/AlexaErrorResponse.js";
|
||||||
import { AlexaInterface } from "../compat/AlexaInterface.js";
|
import { AlexaInterface } from "../compat/AlexaInterface.js";
|
||||||
import { AlexaStatusMessage } from "../compat/AlexaStatusMessage.js";
|
import { AlexaStatusMessage } from "../compat/AlexaStatusMessage.js";
|
||||||
import { DisplayCategory } from "../compat/enums.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 { Alexa } from "../registry/interfaces/Alexa.js";
|
||||||
import { EndpointHealth } from "../registry/interfaces/EndpointHealth.js";
|
import { EndpointHealth } from "../registry/interfaces/EndpointHealth.js";
|
||||||
import { SceneController } from "../registry/interfaces/SceneController.js";
|
import { SceneController } from "../registry/interfaces/SceneController.js";
|
||||||
|
|
@ -183,7 +184,31 @@ class Device extends EventEmitter {
|
||||||
const bytes = Buffer.byteLength(JSON.stringify(message));
|
const bytes = Buffer.byteLength(JSON.stringify(message));
|
||||||
if (bytes > topics.EVENT_BYTES)
|
if (bytes > topics.EVENT_BYTES)
|
||||||
return refuse(`it is ${bytes} bytes as JSON, Alex2MQTT takes ${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 <root>/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 unpublished = new Error(`nothing was published to ${topic}: the device is on no bridge, register it with addDevice() or registerDevice()`);
|
||||||
const published = this.publisher
|
const published = this.publisher
|
||||||
? this.publisher.publish(topic, message)
|
? this.publisher.publish(topic, message)
|
||||||
|
|
|
||||||
13
dist/types/device/Device.d.ts
vendored
13
dist/types/device/Device.d.ts
vendored
|
|
@ -134,6 +134,19 @@ declare class Device extends EventEmitter {
|
||||||
instance?: string;
|
instance?: string;
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
}): Promise<PublishResult>;
|
}): Promise<PublishResult>;
|
||||||
|
/**
|
||||||
|
* 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 <root>/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<PublishResult>;
|
||||||
|
private publish;
|
||||||
/**
|
/**
|
||||||
* How the device reports its state, every retrievable property of it:
|
* How the device reports its state, every retrievable property of it:
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@ import { AlexaInterface } from "../compat/AlexaInterface.js";
|
||||||
import { AlexaStatusMessage } from "../compat/AlexaStatusMessage.js";
|
import { AlexaStatusMessage } from "../compat/AlexaStatusMessage.js";
|
||||||
import { DisplayCategory } from "../compat/enums.js";
|
import { DisplayCategory } from "../compat/enums.js";
|
||||||
import type { AlexaInterfaceType } 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 { ChangeCause } from "../messages/types.js";
|
||||||
import type { DirectiveHandler, Fill } from "../dispatcher.js";
|
import type { DirectiveHandler, Fill } from "../dispatcher.js";
|
||||||
import type { DisplayCategoryName } from "../registry/catalog.js";
|
import type { DisplayCategoryName } from "../registry/catalog.js";
|
||||||
|
|
@ -273,7 +274,31 @@ class Device extends EventEmitter {
|
||||||
const bytes = Buffer.byteLength(JSON.stringify(message));
|
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}`);
|
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 <root>/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<PublishResult> {
|
||||||
|
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<PublishResult> {
|
||||||
const unpublished = new Error(`nothing was published to ${topic}: the device is on no bridge, register it with addDevice() or registerDevice()`);
|
const unpublished = new Error(`nothing was published to ${topic}: the device is on no bridge, register it with addDevice() or registerDevice()`);
|
||||||
const published: Promise<PublishResult> = this.publisher
|
const published: Promise<PublishResult> = this.publisher
|
||||||
? this.publisher.publish(topic, message)
|
? this.publisher.publish(topic, message)
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
"use strict";
|
"use strict";
|
||||||
// device.raise(): an event nobody asked for goes to <root>/event, and one that would not arrive is refused.
|
// 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 { test } = require("node:test");
|
||||||
const assert = require("node:assert/strict");
|
const assert = require("node:assert/strict");
|
||||||
const {
|
const {
|
||||||
Alex2MQTT, AlexaInterfaceType, DoorbellEventSource, MemoryPublisher, MessageError, PowerController, SceneController,
|
Alex2MQTT, AlexaInterfaceType, ContactSensor, DoorbellEventSource, MemoryPublisher, MessageError, PowerController, SceneController,
|
||||||
SimpleEventSource, asset, topics,
|
SimpleEventSource, asset, topics,
|
||||||
} = require("alex2node");
|
} = require("alex2node");
|
||||||
const { endpoint } = require("../helpers/endpoint.js");
|
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([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"]);
|
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/);
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue