messages: pure builders for every event the bridge sends

src/messages builds Response, StateReport, DeferredResponse, ErrorResponse, ChangeReport and the scene, doorbell
and simple events from plain values; messageId and times are parameters, so a test compares whole objects.
StateBuilder collects properties checked by the descriptors, AlexaError and AlexaErrors carry the payload fields
of an error type. AlexaStatusMessage and AlexaErrorResponse move to src/compat and are written on the builders.
On the wire, against 1.5.2: a DeferredResponse has no context key, a ChangeReport has no correlationToken, the
namespace of an ErrorResponse follows its type, and a ChangeReport without a changed property is not published:
send() resolves "" and the bridge reports an error that names the endpoint.
143 tests pass (108 before).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
David 2026-09-28 19:28:05 +00:00
parent 2558e21787
commit 46fa06728c
68 changed files with 3410 additions and 1208 deletions

View file

@ -0,0 +1,191 @@
"use strict";
// AlexaStatusMessage and AlexaErrorResponse, the message classes of 1.x: the JSON of 1.5.2, and the four places
// where 2.0 differs from it on the wire (DESIGN.md, 6.1).
const { test } = require("node:test");
const assert = require("node:assert/strict");
const {
Alex2MQTT, AlexaErrorResponse, AlexaErrorType, AlexaInterfaceType, AlexaStatusMessage, DisplayCategory, EndpointHealth,
PowerController, TemperatureSensorScale,
} = require("alex2node");
const { setup, sleep, UUID_V4 } = require("../helpers/harness.js");
const ISO_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
// The message with its id and its times checked and replaced by their names, so that the rest compares as a whole
function settled(message) {
const json = JSON.parse(JSON.stringify(message));
assert.match(json.event.header.messageId, UUID_V4);
json.event.header.messageId = "<id>";
const lists = [json.context?.properties, json.event.payload.change?.properties].filter(Boolean);
for (const property of lists.flat()) {
assert.match(property.timeOfSample, ISO_TIME);
property.timeOfSample = "<time>";
}
return json;
}
const property = (namespace, name, value, more = {}) => (
{ namespace, name, value, timeOfSample: "<time>", uncertaintyInMilliseconds: 0, ...more }
);
// A client that keeps what it is asked to publish
function recorder(error) {
const published = [];
return { published, publish: (topic, payload, done) => { published.push({ topic, payload: JSON.parse(payload) }); done(error); } };
}
test("a Response with every add...Prop helper: the JSON of 1.5.2", () => {
const message = new AlexaStatusMessage("ct", "root", "5020AA", null, true)
.addPowerControllerProp(PowerController.ON)
.addBrightnessControllerProp(40, 500)
.addColorTemperatureControllerProp(2700)
.addToggleControllerProp(PowerController.OFF, "Oven.Light")
.addModeControllerProp("Washer.Cycle", "Cycle.Delicates")
.addThermostatModeProp("HEAT")
.addThermostatControllerProp("targetSetpoint", TemperatureSensorScale.CELSIUS, 21.5)
.addTemperatureSensorProp(TemperatureSensorScale.FAHRENHEIT, 212)
.addHealthProp(EndpointHealth.OK)
.addContextProp({ namespace: "Alexa.Vendor", name: "level", value: 1, timeOfSample: "2026-09-28T10:00:00.000Z", uncertaintyInMilliseconds: 0 });
assert.equal(message.isChangeReport(), false);
assert.deepEqual(settled(message), {
event: {
header: { namespace: "Alexa", name: "Response", payloadVersion: "3", messageId: "<id>", correlationToken: "ct" },
endpoint: { endpointId: "5020AA" },
payload: {},
},
context: {
properties: [
property("Alexa.PowerController", "powerState", "ON"),
property("Alexa.BrightnessController", "brightness", 40, { uncertaintyInMilliseconds: 500 }),
property("Alexa.ColorTemperatureController", "colorTemperatureInKelvin", 2700),
property("Alexa.ToggleController", "toggleState", "OFF", { instance: "Oven.Light" }),
property("Alexa.ModeController", "mode", "Cycle.Delicates", { instance: "Washer.Cycle" }),
property("Alexa.ThermostatController", "thermostatMode", "HEAT"),
property("Alexa.ThermostatController", "targetSetpoint", { value: 21.5, scale: "CELSIUS" }),
property("Alexa.TemperatureSensor", "temperature", { value: 100, scale: "CELSIUS" }),
property("Alexa.EndpointHealth", "connectivity", { value: "OK" }),
property("Alexa.Vendor", "level", 1),
],
},
});
});
test("a StateReport is the default; the messageId stays the same from one toJSON() to the next", () => {
const message = new AlexaStatusMessage("ct", "root", "5020AA", null).addPowerControllerProp(PowerController.OFF);
assert.equal(message.toJSON().event.header.name, "StateReport");
assert.equal(message.toJSON().event.header.messageId, message.toJSON().event.header.messageId);
});
test("wire change: a DeferredResponse has no context key (1.5.2: \"context\": null)", () => {
const message = new AlexaStatusMessage("ct", "root", "5020AA", null, false, true).addEstimatedDeferralTime(20);
assert.deepEqual(settled(message), {
event: {
header: { namespace: "Alexa", name: "DeferredResponse", payloadVersion: "3", messageId: "<id>", correlationToken: "ct" },
endpoint: { endpointId: "5020AA" },
payload: { estimatedDeferralInSeconds: 20 },
},
});
assert.equal("context" in message.toJSON(), false);
});
test("wire change: a ChangeReport has no correlationToken (1.5.2: an empty one)", () => {
const message = new AlexaStatusMessage("", "root", "5020AA", null, false, false, "APP_INTERACTION")
.addPowerControllerProp(PowerController.ON)
.unchanged()
.addBrightnessControllerProp(40)
.changed()
.addHealthProp("OK")
.addContextProp(property("Alexa.Vendor", "level", 1, { timeOfSample: "2026-09-28T10:00:00.000Z" }));
assert.equal(message.isChangeReport(), true);
assert.deepEqual(settled(message), {
event: {
header: { namespace: "Alexa", name: "ChangeReport", payloadVersion: "3", messageId: "<id>" },
endpoint: { endpointId: "5020AA" },
payload: {
change: {
cause: { type: "APP_INTERACTION" },
properties: [
property("Alexa.PowerController", "powerState", "ON"),
property("Alexa.EndpointHealth", "connectivity", { value: "OK" }),
],
},
},
},
context: { properties: [property("Alexa.BrightnessController", "brightness", 40), property("Alexa.Vendor", "level", 1)] },
});
});
test("wire change: the namespace of an ErrorResponse follows the type (1.5.2: always Alexa)", () => {
const header = (type, options) => {
const error = new AlexaErrorResponse("ct", "root", "5020AA", null);
error.setErrorMessage(type, "m", {}, options);
return error.toJSON().event.header.namespace;
};
assert.equal(header(AlexaErrorType.ENDPOINT_UNREACHABLE), "Alexa");
assert.equal(header(AlexaErrorType.THERMOSTAT_IS_OFF), "Alexa.ThermostatController");
assert.equal(header(AlexaErrorType.UNAUTHORIZED), "Alexa.SecurityPanelController");
assert.equal(header(AlexaErrorType.OBSTACLE_DETECTED), "Alexa.Safety");
assert.equal(header(AlexaErrorType.THERMOSTAT_IS_OFF, { namespace: "Alexa" }), "Alexa");
// The idiom of the consumer: a type the enum does not have is sent as it is
assert.equal(header(AlexaErrorType.SOMETHING_NEW || "SOMETHING_NEW"), "Alexa");
});
test("an ErrorResponse with the fields of its type: the JSON of 1.5.2, sent to the topic asked for", async () => {
const client = recorder();
const error = new AlexaErrorResponse("ct", "root", "5020AA", client);
error.setErrorMessage(AlexaErrorType.VALUE_OUT_OF_RANGE, "too high", { validRange: { minimumValue: 1, maximumValue: 10 } });
const expected = {
event: {
header: { namespace: "Alexa", name: "ErrorResponse", messageId: "<id>", correlationToken: "ct", payloadVersion: "3" },
endpoint: { endpointId: "5020AA" },
payload: { type: "VALUE_OUT_OF_RANGE", message: "too high", validRange: { minimumValue: 1, maximumValue: 10 } },
},
};
assert.deepEqual(settled(error), expected);
assert.equal(await error.send(), "root/5020AA/alexaResponce");
assert.equal(await error.send(true), "root/5020AA/deferredResponse");
assert.deepEqual(client.published.map(({ topic, payload }) => [topic, settled(payload)]), [
["root/5020AA/alexaResponce", expected],
["root/5020AA/deferredResponse", expected],
]);
});
test("an ErrorResponse nobody gave a type is an INTERNAL_ERROR", () => {
const { payload } = new AlexaErrorResponse("ct", "root", "5020AA", null).toJSON().event;
assert.equal(payload.type, "INTERNAL_ERROR");
assert.match(payload.message, /did not say which/);
});
test("send: the topic of the message; a publish that fails resolves \"\" and is reported, never rejected", async () => {
const client = recorder();
const status = (...flags) => new AlexaStatusMessage("ct", "root", "5020AA", client, ...flags).addPowerControllerProp(PowerController.ON);
assert.equal(await status(true).send(), "root/5020AA/alexaResponce");
assert.equal(await status(true).send(true), "root/5020AA/deferredResponse");
assert.equal(await status(false, false, "PHYSICAL_INTERACTION").send(), "root/changeReport");
assert.deepEqual(client.published.map(({ payload }) => payload.event.header.name), ["Response", "Response", "ChangeReport"]);
const failing = new AlexaStatusMessage("ct", "root", "5020AA", recorder(new Error("broker gone")), true);
const reported = [];
failing.onPublishError = (err) => reported.push(err.message);
assert.equal(await failing.send(), "");
assert.deepEqual(reported, ["broker gone"]);
});
test("wire change: a ChangeReport without a changed property is not published, the bridge reports which endpoint", async () => {
const { alexa, bridge } = await setup(Alex2MQTT, "root");
const errors = [];
bridge.on("error", (err) => errors.push(err));
const lamp = bridge.registerDevice("Dining Room Light", "5020AA", DisplayCategory.LIGHT);
lamp.addCapability(AlexaInterfaceType.POWER_CONTROLLER, { proactivelyReported: true });
const report = lamp.getChangeReport().unchanged().addBrightnessControllerProp(40);
assert.throws(() => report.toJSON(), /^MessageError: 5020AA: a ChangeReport needs at least one property that changed/);
assert.equal(await report.send(), "");
assert.equal(errors.length, 1);
assert.match(errors[0].message, /^5020AA: a ChangeReport needs at least one property that changed/);
// The report that follows arrives alone: the empty one was not published before it
assert.equal(await lamp.getChangeReport().addPowerControllerProp(PowerController.ON).send(), "root/changeReport");
await sleep(100);
assert.deepEqual(alexa.on("root/changeReport").map((m) => m.event.payload.change.properties.map((p) => p.name)), [["powerState"]]);
});