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