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>
191 lines
9.7 KiB
JavaScript
191 lines
9.7 KiB
JavaScript
"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"]]);
|
|
});
|