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>
210 lines
11 KiB
JavaScript
210 lines
11 KiB
JavaScript
"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, ContactSensor, 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 <root>/event, with the endpoint, a new messageId, the time and the usual cause", async () => {
|
|
const { device, sent } = doorbell();
|
|
const example = doc("alexa-doorbelleventsource", "DoorbellPress.event");
|
|
const printed = example.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"]);
|
|
for (const { message } of sent.published) {
|
|
assert.deepEqual(Object.keys(message).sort(), Object.keys(example).sort());
|
|
assert.deepEqual(message.context, example.context);
|
|
}
|
|
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 example = doc("alexa-simpleeventsource", "Event.event");
|
|
const printed = example.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");
|
|
assert.deepEqual(Object.keys(sent.published[0].message), Object.keys(example));
|
|
|
|
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 16309 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"]);
|
|
});
|
|
|
|
// 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/);
|
|
});
|