Alex2Node/test/registry/thermostat.test.js
David f555a5df70 registry: ThermostatController 3.2 and ThermostatController.Schedule
The thermostat is described from its page: one, two or three setpoints with holdUntil, AdjustTargetTemperature,
SetThermostatMode with the six modes (EM_HEAT is new), ResumeSchedule, adaptiveRecoveryStatus, and its seven error
types under Alexa.ThermostatController. A declaration gives supportedModes, supportsScheduling and the properties
the device has; without them discovery is what 1.5.2 sent: HEAT, COOL, AUTO, OFF and the three setpoints with
thermostatMode. supportedModes without HEAT or COOL is refused where it is declared.

ThermostatController.Schedule replaces its stub. Wire change for a 1.x declaration of it: version 3.2 (was 1) and
scheduleEnabled listed (was nothing). SetAdaptiveRecovery is taken only with supportsAdaptiveRecovery.

24 examples of three pages are saved as fixtures; the page folds the SetWeeklySchedule example away, its payload
is tested from the tables. 207 tests pass, 191 before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-28 20:34:49 +00:00

153 lines
9.5 KiB
JavaScript

"use strict";
// Alexa.ThermostatController 3.2 and Alexa.ThermostatController.Schedule: what the examples of their pages do not
// show. The examples themselves are run by descriptors.test.js, device/discovery.test.js and dispatch/interfaces.test.js.
const { test } = require("node:test");
const assert = require("node:assert/strict");
const {
AlexaInterfaceType, AlexaStatusMessage, DeclarationError, SchemaError, TemperatureSensorScale, ThermostatController,
ThermostatControllerSchedule, ThermostatMode, ThermostatModes,
} = require("alex2node");
const { endpoint } = require("../helpers/endpoint.js");
const { capabilities } = require("../helpers/fixtures.js");
const celsius = (value) => ({ value, scale: "CELSIUS" });
const thermostat = () => endpoint("thermo-1", "Thermostat", ["THERMOSTAT"]);
const OF_1X = {
type: "AlexaInterface",
interface: "Alexa.ThermostatController",
version: "3.2",
properties: {
supported: [{ name: "targetSetpoint" }, { name: "lowerSetpoint" }, { name: "upperSetpoint" }, { name: "thermostatMode" }],
proactivelyReported: false,
retrievable: true,
},
configuration: { supportedModes: ["HEAT", "COOL", "AUTO", "OFF"], supportsScheduling: false },
};
test("SetTargetTemperature: one, two and three setpoints, with and without holdUntil; none is refused", () => {
const { payload } = ThermostatController.directives.SetTargetTemperature;
const single = { targetSetpoint: celsius(20) };
const dual = { lowerSetpoint: celsius(18), upperSetpoint: celsius(24) };
const hold = { holdUntil: { start: "2017-10-04T14:00Z", end: "2017-10-04T14:15Z" } };
for (const setpoints of [single, dual, { ...single, ...dual }, { ...single, ...hold }, { ...single, ...dual, ...hold }]) {
assert.deepEqual(payload.parse(setpoints, "payload"), setpoints);
}
assert.throws(() => payload.parse({}, "payload"), /^SchemaError: payload: expected one or more of targetSetpoint, lowerSetpoint, upperSetpoint, got \{\}$/);
assert.throws(() => payload.parse(hold, "payload"), SchemaError);
const all = { start: "2017-10-04T14:00Z", end: "2017-10-04T14:15Z", duration: "PT15M" };
assert.throws(() => payload.parse({ ...single, holdUntil: all }, "payload"), /^SchemaError: payload\.holdUntil: expected a time interval with one or two of/);
});
test("the modes are the six of the page, EM_HEAT among them, in a directive and in a report", () => {
assert.deepEqual(ThermostatModes, ["AUTO", "COOL", "ECO", "EM_HEAT", "HEAT", "OFF"]);
const { directives, properties } = ThermostatController;
for (const value of ThermostatModes) {
assert.deepEqual(directives.SetThermostatMode.payload.parse({ thermostatMode: { value } }), { thermostatMode: { value } });
assert.equal(properties.thermostatMode.value.parse(value), value);
}
assert.equal(ThermostatMode.EM_HEAT, "EM_HEAT");
// The mode 1.x had of its own is none Alexa knows
assert.throws(() => properties.thermostatMode.value.parse(ThermostatMode.CUSTOM, "thermostatMode"), SchemaError);
});
test("a thermostat declared without options, the 1.x way or with add(), is announced as 1.5.2 announced it", () => {
const capability = thermostat().addCapability(AlexaInterfaceType.THERMOSTAT_CONTROLLER);
assert.deepEqual(capability.getJSON(), OF_1X);
assert.deepEqual(capability.getProps(), ["targetSetpoint", "lowerSetpoint", "upperSetpoint", "thermostatMode"]);
const device = thermostat();
assert.deepEqual(device.add(ThermostatController).toJSON(), OF_1X);
assert.deepEqual(device.check(), []);
});
test("a thermostat declares its modes, its properties and whether it holds a temperature for a time", () => {
const heater = thermostat().add(ThermostatController, {
supportedModes: ["HEAT", "EM_HEAT", "OFF"], properties: ["targetSetpoint", "thermostatMode"], proactivelyReported: true,
});
assert.deepEqual(heater.toJSON(), {
...OF_1X,
properties: { supported: [{ name: "targetSetpoint" }, { name: "thermostatMode" }], proactivelyReported: true, retrievable: true },
configuration: { supportedModes: ["HEAT", "EM_HEAT", "OFF"], supportsScheduling: false },
});
// The air conditioner of the page leaves supportsScheduling out, which is false: "The default is false"
const [printed] = capabilities("alexa-thermostatcontroller", "Alexa.ThermostatController", "discovery-air-conditioner");
const conditioner = thermostat().add(ThermostatController, {
supportedModes: ["OFF", "COOL"], properties: ["targetSetpoint", "thermostatMode"], proactivelyReported: true,
});
assert.equal("supportsScheduling" in printed.configuration, false);
assert.deepEqual(conditioner.toJSON(), { ...printed, configuration: { ...printed.configuration, supportsScheduling: false } });
});
test("a declaration Alexa would reject is refused: no HEAT or COOL, an entry twice, a name that is none", () => {
const refused = (options) => {
try {
thermostat().add(ThermostatController, options);
} catch (err) {
assert.ok(err instanceof DeclarationError, String(err));
return err.message;
}
return "declared";
};
assert.equal(refused({ supportedModes: ["AUTO", "OFF"] }), "thermo-1: Alexa.ThermostatController: supportedModes AUTO, OFF has neither HEAT nor COOL, Alexa wants one");
assert.equal(refused({ supportedModes: ["HEAT", "OFF", "HEAT"] }), "thermo-1: Alexa.ThermostatController: HEAT is listed twice");
assert.equal(refused({ properties: ["targetSetpoint", "targetSetpoint"] }), "thermo-1: Alexa.ThermostatController: targetSetpoint is listed twice");
assert.match(refused({ supportedModes: ["HEAT", "WARM"] }), /supportedModes\[1\]: expected AUTO \| COOL \| ECO \| EM_HEAT \| HEAT \| OFF, got "WARM"$/);
assert.match(refused({ properties: ["setpoint"] }), /properties\[0\]: expected targetSetpoint \| .* \| adaptiveRecoveryStatus, got "setpoint"$/);
assert.match(refused({ modes: ["HEAT"] }), /modes: unknown key/);
});
test("what the 1.x helpers report for a thermostat is the JSON of 1.5.2, and a value the interface takes", () => {
const message = new AlexaStatusMessage("ct", "root", "thermo-1", null, true)
.addThermostatModeProp(ThermostatMode.HEAT)
.addThermostatControllerProp("targetSetpoint", TemperatureSensorScale.CELSIUS, 21.5)
.addThermostatControllerProp("lowerSetpoint", TemperatureSensorScale.FAHRENHEIT, 68)
.addThermostatControllerProp("upperSetpoint", TemperatureSensorScale.FAHRENHEIT, 77);
const reported = message.toJSON().context.properties;
assert.deepEqual(reported.map(({ namespace, name, value }) => [namespace, name, value]), [
["Alexa.ThermostatController", "thermostatMode", "HEAT"],
["Alexa.ThermostatController", "targetSetpoint", celsius(21.5)],
["Alexa.ThermostatController", "lowerSetpoint", celsius(20)],
["Alexa.ThermostatController", "upperSetpoint", celsius(25)],
]);
for (const { name, value } of reported) assert.deepEqual(ThermostatController.properties[name].value.parse(value, name), value);
});
test("Schedule: a 1.x declaration lists scheduleEnabled and no configuration, and check() names what is missing", () => {
const device = thermostat();
const capability = device.addCapability(AlexaInterfaceType.THERMOSTAT_CONTROLLER_SCHEDULE);
assert.deepEqual(capability.getJSON(), {
type: "AlexaInterface",
interface: "Alexa.ThermostatController.Schedule",
version: "3.2",
properties: { supported: [{ name: "scheduleEnabled" }], proactivelyReported: false, retrievable: true },
});
assert.deepEqual(device.check(), ["thermo-1: Alexa.ThermostatController.Schedule: supportedFanModes: expected a list, got nothing"]);
const plain = thermostat().add(ThermostatControllerSchedule, { supportedFanModes: ["ON", "AUTO", "CIRCULATE"], supportsAdaptiveRecovery: false });
assert.deepEqual(plain.toJSON().properties.supported, [{ name: "scheduleEnabled" }]);
assert.deepEqual(plain.toJSON().configuration, { supportedFanModes: ["ON", "AUTO", "CIRCULATE"], supportsAdaptiveRecovery: false });
});
test("SetWeeklySchedule: a week of entries as the tables of the page describe them", () => {
const { payload } = ThermostatControllerSchedule.directives.SetWeeklySchedule;
const workday = [
{ startTimeInMinutes: 360, setpoints: { lowerSetpoint: celsius(20), upperSetpoint: celsius(24) }, activityType: { type: "HOME" } },
{ startTimeInMinutes: 480, setpoints: { lowerSetpoint: celsius(16) }, fanSetting: { mode: "AUTO" }, activityType: { type: "AWAY" } },
{ startTimeInMinutes: 1320, setpoints: { upperSetpoint: celsius(26) }, fanSetting: { mode: "CIRCULATE" }, activityType: { type: "SLEEP" } },
];
const week = (monday) => ({
temperatureScale: "CELSIUS", Monday: monday, Tuesday: workday, Wednesday: workday, Thursday: workday, Friday: workday, Saturday: [], Sunday: [],
});
assert.deepEqual(payload.parse({ weeklySchedule: week(workday) }, "payload"), { weeklySchedule: week(workday) });
// The table marks weeklySchedule as not required
assert.deepEqual(payload.parse({}, "payload"), {});
const refused = (entry, message) => assert.throws(
() => payload.parse({ weeklySchedule: week([{ ...workday[0], ...entry }]) }, "payload"),
(err) => err instanceof SchemaError && err.message === `payload.weeklySchedule.Monday[0].${message}`
);
refused({ startTimeInMinutes: 1440 }, "startTimeInMinutes: expected an integer from 0 to 1439, got 1440");
refused({ setpoints: undefined }, "setpoints: expected an object with upperSetpoint, lowerSetpoint, got nothing");
refused({ fanSetting: { mode: "OFF" } }, 'fanSetting.mode: expected ON | AUTO | CIRCULATE, got "OFF"');
refused({ activityType: { type: "PARTY" } }, 'activityType.type: expected AWAY | HOME | SLEEP, got "PARTY"');
});