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"]]);
});

View file

@ -4,6 +4,7 @@
import {
ActionMapping, AlexaActions, AlexaInterfaceType, Assets, BrightnessController, DeclarationError, DisplayCategory, EndpointHealth,
ModeController, PowerController, PowerState, RangeController, TemperatureSensor, ToggleController, asset, registry, semantics, text,
AlexaErrors, StateBuilder, messages,
} from "alex2node";
import type {
Alex2MQTT, Alex2MQTTOptions, AlexaInterface, AlexaStatusMessage, AssetId, Capability, CapabilityJson, ChangeCause, Device,
@ -136,3 +137,23 @@ export {
options, added, chained, sent, on, namespace, reachable, announced, endpoint, problems, names, lower, position, current, version,
refused, opening, percent, vacuum, misspelt, notAUnit, measured, rankine,
};
// StateBuilder.set(): the properties of the interface and the type of each value
const state = new StateBuilder()
.set(PowerController, "powerState", "ON")
.set(TemperatureSensor, "temperature", { value: 70, scale: "FAHRENHEIT" })
.set(RangeController, "rangeValue", 3, { instance: "Blind.Lift", timeOfSample: new Date() })
.health("UNREACHABLE", "WIFI_AP_NOT_FOUND");
// @ts-expect-error a power state is ON or OFF
state.set(PowerController, "powerState", "on");
// @ts-expect-error not a property of the interface
state.set(PowerController, "power", "ON");
// The builders take what the builder of the state collected; a message is typed down to its payload
const report = messages.changeReport({ endpointId: "lamp-1", cause: "APP_INTERACTION", changed: state.change, context: state.context });
const changedNames: string[] = report.event.payload.change.properties.map((property) => property.name);
// @ts-expect-error a DeferredResponse has no context
messages.deferredResponse({ endpointId: "lamp-1", correlationToken: "ct" }).context;
// @ts-expect-error the range is two numbers
AlexaErrors.valueOutOfRange("too high", { minimumValue: 1 });
const errorNamespace: string = AlexaErrors.setpointsTooClose("too close", { value: 2, scale: "CELSIUS" }).namespace;

165
test/messages/build.test.js Normal file
View file

@ -0,0 +1,165 @@
"use strict";
// The builders of src/messages/build.ts. Given a messageId and the times, a builder returns the same object every
// time, so each test compares the whole message.
const { test } = require("node:test");
const assert = require("node:assert/strict");
const { messages, MessageError } = require("alex2node");
const { UUID_V4 } = require("../helpers/harness.js");
const AT = "2026-09-28T10:00:00.000Z";
const messageId = "0c2bd0a4-6c1c-4b7a-9d3e-5f0a3a1b2c3d";
const powerState = messages.property("Alexa.PowerController", "powerState", "ON", { timeOfSample: AT });
const brightness = messages.property("Alexa.BrightnessController", "brightness", 40, { timeOfSample: AT, uncertaintyInMilliseconds: 500 });
test("response: the event and the state of the endpoint as its context", () => {
assert.deepEqual(messages.response({ endpointId: "lamp-1", correlationToken: "ct", messageId, context: [powerState, brightness] }), {
event: {
header: { namespace: "Alexa", name: "Response", messageId, correlationToken: "ct", payloadVersion: "3" },
endpoint: { endpointId: "lamp-1" },
payload: {},
},
context: {
properties: [
{ namespace: "Alexa.PowerController", name: "powerState", value: "ON", timeOfSample: AT, uncertaintyInMilliseconds: 0 },
{ namespace: "Alexa.BrightnessController", name: "brightness", value: 40, timeOfSample: AT, uncertaintyInMilliseconds: 500 },
],
},
});
});
test("response: nothing to report is an empty list of properties; an interface names its own response", () => {
const armed = messages.response({
namespace: "Alexa.SecurityPanelController", name: "Arm.Response", endpointId: "panel", correlationToken: "ct", messageId,
payload: { exitDelayInSeconds: 30 },
});
assert.deepEqual(armed, {
event: {
header: { namespace: "Alexa.SecurityPanelController", name: "Arm.Response", messageId, correlationToken: "ct", payloadVersion: "3" },
endpoint: { endpointId: "panel" },
payload: { exitDelayInSeconds: 30 },
},
context: { properties: [] },
});
});
test("stateReport: a Response by the name of StateReport", () => {
assert.deepEqual(messages.stateReport({ endpointId: "lamp-1", correlationToken: "ct", messageId, context: [powerState] }), {
event: {
header: { namespace: "Alexa", name: "StateReport", messageId, correlationToken: "ct", payloadVersion: "3" },
endpoint: { endpointId: "lamp-1" },
payload: {},
},
context: { properties: [powerState] },
});
});
test("deferredResponse: no context key, the estimate only when given", () => {
const event = {
header: { namespace: "Alexa", name: "DeferredResponse", messageId, correlationToken: "ct", payloadVersion: "3" },
endpoint: { endpointId: "lamp-1" },
};
assert.deepEqual(messages.deferredResponse({ endpointId: "lamp-1", correlationToken: "ct", messageId }), {
event: { ...event, payload: {} },
});
assert.deepEqual(
messages.deferredResponse({ endpointId: "lamp-1", correlationToken: "ct", messageId, estimatedDeferralInSeconds: 20 }),
{ event: { ...event, payload: { estimatedDeferralInSeconds: 20 } } }
);
});
test("errorResponse: the event alone, the fields of the type next to type and message", () => {
const answer = messages.errorResponse({
endpointId: "lamp-1", correlationToken: "ct", messageId,
type: "VALUE_OUT_OF_RANGE", message: "too high", extra: { validRange: { minimumValue: 1, maximumValue: 10 } },
});
assert.deepEqual(answer, {
event: {
header: { namespace: "Alexa", name: "ErrorResponse", messageId, correlationToken: "ct", payloadVersion: "3" },
endpoint: { endpointId: "lamp-1" },
payload: { type: "VALUE_OUT_OF_RANGE", message: "too high", validRange: { minimumValue: 1, maximumValue: 10 } },
},
});
});
test("changeReport: what changed in payload.change, the rest in the context, no correlationToken", () => {
const report = messages.changeReport({ endpointId: "lamp-1", messageId, cause: "APP_INTERACTION", changed: [powerState], context: [brightness] });
assert.deepEqual(report, {
event: {
header: { namespace: "Alexa", name: "ChangeReport", messageId, payloadVersion: "3" },
endpoint: { endpointId: "lamp-1" },
payload: { change: { cause: { type: "APP_INTERACTION" }, properties: [powerState] } },
},
context: { properties: [brightness] },
});
});
test("changeReport: a property that changed is not repeated in the context; instances are told apart", () => {
const lift = (instance, value) => messages.property("Alexa.RangeController", "rangeValue", value, { instance, timeOfSample: AT });
const report = messages.changeReport({
endpointId: "blind", messageId,
changed: [lift("Blind.Lift", 40)],
context: [lift("Blind.Lift", 30), lift("Blind.Tilt", 5)],
});
assert.equal(report.event.payload.change.cause.type, "PHYSICAL_INTERACTION");
assert.deepEqual(report.context.properties, [lift("Blind.Tilt", 5)]);
});
test("changeReport: without a changed property it is refused, the error names the endpoint and what to do", () => {
assert.throws(
() => messages.changeReport({ endpointId: "lamp-1", changed: [], context: [brightness] }),
(err) => {
assert.ok(err instanceof MessageError);
assert.equal(err.endpointId, "lamp-1");
assert.match(err.message, /^lamp-1: a ChangeReport needs at least one property that changed/);
assert.match(err.message, /send no report when nothing changed/);
return true;
}
);
});
test("sceneEvent: ActivationStarted and DeactivationStarted with the cause and the time", () => {
const started = messages.sceneEvent({ endpointId: "scene-1", correlationToken: "ct", messageId, activated: true, timestamp: AT });
assert.deepEqual(started, {
event: {
header: { namespace: "Alexa.SceneController", name: "ActivationStarted", messageId, correlationToken: "ct", payloadVersion: "3" },
endpoint: { endpointId: "scene-1" },
payload: { cause: { type: "VOICE_INTERACTION" }, timestamp: AT },
},
context: {},
});
const stopped = messages.sceneEvent({
endpointId: "scene-1", correlationToken: "ct", messageId, activated: false, cause: "APP_INTERACTION", timestamp: new Date(AT),
});
assert.equal(stopped.event.header.name, "DeactivationStarted");
assert.deepEqual(stopped.event.payload, { cause: { type: "APP_INTERACTION" }, timestamp: AT });
});
test("doorbellPress: an event without a correlationToken and without a context", () => {
assert.deepEqual(messages.doorbellPress({ endpointId: "door", messageId, timestamp: AT }), {
event: {
header: { namespace: "Alexa.DoorbellEventSource", name: "DoorbellPress", messageId, payloadVersion: "3" },
endpoint: { endpointId: "door" },
payload: { cause: { type: "PHYSICAL_INTERACTION" }, timestamp: AT },
},
});
});
test("simpleEvent: the instance in the header, payloadVersion 1.0", () => {
assert.deepEqual(messages.simpleEvent({ endpointId: "button", instance: "Button.Front", id: "Button.SinglePush.1", messageId, timestamp: AT }), {
event: {
header: { namespace: "Alexa.SimpleEventSource", name: "Event", instance: "Button.Front", messageId, payloadVersion: "1.0" },
endpoint: { endpointId: "button" },
payload: { id: "Button.SinglePush.1", timestamp: AT },
},
});
});
test("left out, the messageId is a new version 4 UUID and the time is now", () => {
const before = Date.now();
const first = messages.doorbellPress({ endpointId: "door" });
const second = messages.doorbellPress({ endpointId: "door" });
assert.match(first.event.header.messageId, UUID_V4);
assert.notEqual(first.event.header.messageId, second.event.header.messageId);
const at = Date.parse(first.event.payload.timestamp);
assert.ok(at >= before && at <= Date.now(), first.event.payload.timestamp);
});

View file

@ -0,0 +1,79 @@
"use strict";
// AlexaError, the helpers of AlexaErrors and the namespace an ErrorResponse goes under.
const { test } = require("node:test");
const assert = require("node:assert/strict");
const { AlexaError, AlexaErrors, AlexaErrorType, messages } = require("alex2node");
test("the namespace follows the type: the interface that documents it, Alexa for the rest", () => {
const namespaces = Object.fromEntries(
["ENDPOINT_UNREACHABLE", "THERMOSTAT_IS_OFF", "UNAUTHORIZED", "OBSTACLE_DETECTED", "SAFETY_BEAM_BREACHED", "NOT_A_TYPE", "constructor"]
.map((type) => [type, messages.errorNamespace(type)])
);
assert.deepEqual(namespaces, {
ENDPOINT_UNREACHABLE: "Alexa",
THERMOSTAT_IS_OFF: "Alexa.ThermostatController",
UNAUTHORIZED: "Alexa.SecurityPanelController",
OBSTACLE_DETECTED: "Alexa.Safety",
SAFETY_BEAM_BREACHED: "Alexa.Safety",
NOT_A_TYPE: "Alexa",
constructor: "Alexa",
});
});
test("every type of the 1.x enum has a namespace that starts with Alexa", () => {
for (const type of Object.values(AlexaErrorType)) assert.match(messages.errorNamespace(type), /^Alexa(\.[A-Za-z]+)*$/, type);
});
test("AlexaError: an Error with the type, the message for Alexa, the fields of the payload and the namespace", () => {
const error = AlexaErrors.of("THERMOSTAT_IS_OFF", "the thermostat is off");
assert.ok(error instanceof AlexaError && error instanceof Error);
assert.equal(error.name, "AlexaError");
assert.equal(error.message, "THERMOSTAT_IS_OFF: the thermostat is off");
assert.deepEqual(
{ type: error.type, alexaMessage: error.alexaMessage, extra: error.extra, namespace: error.namespace },
{ type: "THERMOSTAT_IS_OFF", alexaMessage: "the thermostat is off", extra: {}, namespace: "Alexa.ThermostatController" }
);
assert.equal(new AlexaError("THERMOSTAT_IS_OFF", "off", {}, "Alexa").namespace, "Alexa");
});
test("the helpers: the type, the namespace and the payload fields as the pages spell them", () => {
const celsius = (value) => ({ value, scale: "CELSIUS" });
const cases = [
[AlexaErrors.valueOutOfRange("m", { minimumValue: 1, maximumValue: 10 }),
"Alexa", "VALUE_OUT_OF_RANGE", { validRange: { minimumValue: 1, maximumValue: 10 } }],
[AlexaErrors.temperatureOutOfRange("m", { minimumValue: celsius(15), maximumValue: celsius(30) }),
"Alexa", "TEMPERATURE_VALUE_OUT_OF_RANGE", { validRange: { minimumValue: celsius(15), maximumValue: celsius(30) } }],
[AlexaErrors.notSupportedInCurrentMode("m", "COLOR"), "Alexa", "NOT_SUPPORTED_IN_CURRENT_MODE", { currentDeviceMode: "COLOR" }],
[AlexaErrors.endpointLowPower("m", 5), "Alexa", "ENDPOINT_LOW_POWER", { percentageState: 5 }],
[AlexaErrors.endpointLowPower("m"), "Alexa", "ENDPOINT_LOW_POWER", {}],
[AlexaErrors.endpointControlUnavailable("m", "DEEP_SLEEP_MODE"), "Alexa", "ENDPOINT_CONTROL_UNAVAILABLE", { reason: "DEEP_SLEEP_MODE" }],
[AlexaErrors.notSupportedWithCurrentBatteryChargeState("m", "NOT_CONNECTED_TO_POWER", 75),
"Alexa", "NOT_SUPPORTED_WITH_CURRENT_BATTERY_CHARGE_STATE",
{ currentChargeState: "NOT_CONNECTED_TO_POWER", currentChargeLevelInPercentage: 75 }],
[AlexaErrors.notSupportedWithCurrentBatteryChargeState("m", "FULLY_CHARGED"),
"Alexa", "NOT_SUPPORTED_WITH_CURRENT_BATTERY_CHARGE_STATE", { currentChargeState: "FULLY_CHARGED" }],
[AlexaErrors.insufficientResource("m", "WATER"), "Alexa", "INSUFFICIENT_RESOURCE", { resourceType: "WATER" }],
[AlexaErrors.maintenanceRequired("m", "EMPTY_BIN"), "Alexa", "MAINTENANCE_REQUIRED", { maintenanceAction: "EMPTY_BIN" }],
[AlexaErrors.maintenanceRequired("m"), "Alexa", "MAINTENANCE_REQUIRED", {}],
[AlexaErrors.setpointsTooClose("m", celsius(2)),
"Alexa.ThermostatController", "REQUESTED_SETPOINTS_TOO_CLOSE", { minimumTemperatureDelta: celsius(2) }],
[AlexaErrors.bypassNeeded("m", [{ friendlyName: "Front door" }]),
"Alexa.SecurityPanelController", "BYPASS_NEEDED", { endpointsNeedingBypass: [{ friendlyName: "Front door" }] }],
[AlexaErrors.of("OBSTACLE_DETECTED", "m"), "Alexa.Safety", "OBSTACLE_DETECTED", {}],
];
for (const [error, namespace, type, extra] of cases) {
assert.deepEqual(
{ namespace: error.namespace, type: error.type, alexaMessage: error.alexaMessage, extra: error.extra },
{ namespace, type, alexaMessage: "m", extra },
type
);
}
});
test("errorResponse: the namespace of the type, or the one the caller names", () => {
const fields = { endpointId: "thermostat", correlationToken: "ct", messageId: "m", type: "THERMOSTAT_IS_OFF", message: "off" };
assert.deepEqual(messages.errorResponse(fields).event.header, {
namespace: "Alexa.ThermostatController", name: "ErrorResponse", messageId: "m", correlationToken: "ct", payloadVersion: "3",
});
assert.equal(messages.errorResponse({ ...fields, namespace: "Alexa" }).event.header.namespace, "Alexa");
});

View file

@ -0,0 +1,91 @@
"use strict";
// StateBuilder: the properties of one message, checked by the descriptors of the registry.
const { test } = require("node:test");
const assert = require("node:assert/strict");
const {
BrightnessController, Capability, EndpointHealth, PowerController, RangeController, SchemaError, StateBuilder,
TemperatureSensor, messages,
} = require("alex2node");
const AT = "2026-09-28T10:00:00.000Z";
const now = () => new Date(AT);
test("set: the property of a described interface, its time from the clock of the builder", () => {
const state = new StateBuilder({ now }).set(PowerController, "powerState", "ON").set(BrightnessController, "brightness", 40);
assert.deepEqual(state.context, [
{ namespace: "Alexa.PowerController", name: "powerState", value: "ON", timeOfSample: AT, uncertaintyInMilliseconds: 0 },
{ namespace: "Alexa.BrightnessController", name: "brightness", value: 40, timeOfSample: AT, uncertaintyInMilliseconds: 0 },
]);
assert.deepEqual(state.change, []);
});
test("set: a property has the time and the uncertainty it is given", () => {
const changedAt = "2026-09-28T09:12:00.000Z";
const [property] = new StateBuilder({ now })
.set(PowerController, "powerState", "OFF", { timeOfSample: changedAt, uncertaintyInMilliseconds: 6000 }).context;
assert.equal(property.timeOfSample, changedAt);
assert.equal(property.uncertaintyInMilliseconds, 6000);
});
test("set: a temperature is reported in the scale it is given in", () => {
const [property] = new StateBuilder({ now }).set(TemperatureSensor, "temperature", { value: 70, scale: "FAHRENHEIT" }).context;
assert.deepEqual(property.value, { value: 70, scale: "FAHRENHEIT" });
});
test("set: a value Alexa would not take is refused, the error names the interface and the property", () => {
const state = new StateBuilder({ now });
assert.throws(() => state.set(PowerController, "powerState", "on"), (err) => {
assert.ok(err instanceof SchemaError);
assert.match(err.message, /^Alexa\.PowerController: powerState: expected .*ON.*got "on"$/);
return true;
});
assert.throws(() => state.set(PowerController, "power", "ON"), /Alexa\.PowerController: power: not a property of the interface, which has powerState/);
assert.deepEqual(state.context, []);
});
test("set: a generic controller needs its instance, given with the property or by the capability", () => {
const state = new StateBuilder({ now });
assert.throws(() => state.set(RangeController, "rangeValue", 3), /Alexa\.RangeController: rangeValue: the interface has instances/);
const lift = new Capability(RangeController, { instance: "Blind.Lift", options: {} });
state.set(RangeController, "rangeValue", 3, { instance: "Blind.Tilt" }).set(lift, "rangeValue", 40);
assert.deepEqual(state.context, [
{ namespace: "Alexa.RangeController", instance: "Blind.Tilt", name: "rangeValue", value: 3, timeOfSample: AT, uncertaintyInMilliseconds: 0 },
{ namespace: "Alexa.RangeController", instance: "Blind.Lift", name: "rangeValue", value: 40, timeOfSample: AT, uncertaintyInMilliseconds: 0 },
]);
});
test("setRaw: a property of an interface the library does not describe, nothing checked", () => {
const state = new StateBuilder({ now }).setRaw("Alexa.Vendor.Thing", "level", { anything: true }, { instance: "Thing.One" });
assert.deepEqual(state.context, [
{ namespace: "Alexa.Vendor.Thing", instance: "Thing.One", name: "level", value: { anything: true }, timeOfSample: AT, uncertaintyInMilliseconds: 0 },
]);
});
test("health: the connectivity of Alexa.EndpointHealth, with the reason when there is one", () => {
const state = new StateBuilder({ now }).health("OK").health("UNREACHABLE", "WIFI_AP_NOT_FOUND");
assert.deepEqual(state.context.map((property) => [property.namespace, property.name, property.value]), [
[EndpointHealth.namespace, "connectivity", { value: "OK" }],
[EndpointHealth.namespace, "connectivity", { value: "UNREACHABLE", reason: "WIFI_AP_NOT_FOUND" }],
]);
assert.throws(() => new StateBuilder().health("ASLEEP"), SchemaError);
});
test("changed and unchanged: the list the properties that follow go to; add() names one", () => {
const state = new StateBuilder({ now, target: "change" })
.set(PowerController, "powerState", "ON")
.unchanged()
.set(BrightnessController, "brightness", 40)
.changed()
.health("OK")
.add(messages.property("Alexa.Vendor.Thing", "level", 1, { timeOfSample: AT }), "context");
assert.deepEqual(state.change.map((property) => property.name), ["powerState", "connectivity"]);
assert.deepEqual(state.context.map((property) => property.name), ["brightness", "level"]);
});
test("context and change are copies: the message built from them does not change with the builder", () => {
const state = new StateBuilder({ now }).set(PowerController, "powerState", "ON");
const report = messages.stateReport({ endpointId: "lamp-1", correlationToken: "ct", messageId: "m", context: state.context });
state.set(BrightnessController, "brightness", 40);
assert.equal(report.context.properties.length, 1);
});