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>
153 lines
9.5 KiB
JavaScript
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"');
|
|
});
|