Files
Max Gramser 85feb9a740 Climate fan and swing settings in Home Assistant's words
The climate card's fan and swing rows showed the raw mode names (Auto, Low, Medium, High) in every language.
Home Assistant names the common ones itself (Laag, Mittel, Élevée): ha-words now copies those into screen.ha, and
the card looks a mode up there; an integration's own mode still reads as its name. ha-words also says how many
words it really changed. Found in the second test pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 20:17:32 +02:00

295 lines
22 KiB
C++

#include "screen_text_en.h"
// c++ -std=c++17 -Wall -Wextra -pedantic tests/test_tile_controls.cpp -o /tmp/test_tile_controls && /tmp/test_tile_controls
#define THEME_TEST
#include "../components/smart_display/tile_controls.h"
#include <cassert>
#include <cmath>
#include <cstring>
using namespace tile_controls;
using runtime_tiles::Tile;
static Tile make(const char *entity, const char *state, uint32_t supported = 0) {
Tile t; t.entity = entity; t.state = state; t.supported = supported; t.received = true; return t;
}
int main() {
// -/+ steps snap to the entity's grid and stay inside its range.
assert(step_value(20.0f, 0.5f, 16, 32, 1) == 20.5f);
assert(step_value(20.3f, 0.5f, 16, 32, -1) == 20.0f);
assert(step_value(31.8f, 1.0f, 16, 32, 1) == 32.0f);
assert(step_value(16.0f, 1.0f, 16, 32, -1) == 16.0f);
assert(step_value(NAN, 1.0f, 16, 32, 1) == 17.0f);
assert(step_value(5.0f, 0.0f, NAN, NAN, 1) == 6.0f);
assert(format_value(20.0f, 1.0f, "°") == "20°");
assert(format_value(20.5f, 0.5f, "°") == "20.5°");
assert(format_value(NAN, 0.5f, "°") == "--");
// Curtain: horizontal arrows, open disabled when fully open, stop only when supported.
Tile curtain = make("cover.curtains", "open", 15); curtain.device_class = "curtain"; curtain.position = 100; curtain.controls = "buttons";
std::array<Key, 3> keys;
assert(keys_for(curtain, keys) == 3);
assert(!strcmp(keys[0].icon, glyph::EXPAND) && keys[0].command == COVER_OPEN && keys[0].disabled);
assert(keys[1].command == COVER_STOP && !keys[1].disabled);
assert(!strcmp(keys[2].icon, glyph::COLLAPSE) && keys[2].command == COVER_CLOSE && !keys[2].disabled);
assert(key_action(curtain, COVER_CLOSE).service == "cover.close_cover");
Tile shutter = make("cover.shutter", "closed", 3); shutter.controls = "buttons";
assert(keys_for(shutter, keys) == 2 && !strcmp(keys[0].icon, glyph::UP) && keys[1].disabled);
assert(status_text(curtain) == "Open · 100%");
assert(status_text(shutter) == "Closed");
// The cover card (firmware 0.2.50+). Motionblinds venetian blinds report every feature (255).
Tile blind = make("cover.venetianblind_0001", "open", 255); blind.device_class = "blind"; blind.position = 60; blind.edit_extra().tilt = 40;
CoverCard card = cover_card(blind);
assert(card.position && card.tilt && card.keys && !card.tilt_keys);
assert(cover_card_status(blind) == "Open · 60% · Tilt 40%");
assert(cover_keys(blind, keys) == 3 && !strcmp(keys[0].icon, glyph::UP) && !keys[0].disabled && !keys[2].disabled && !keys[2].checked);
blind.state = "closing";
assert(cover_keys(blind, keys) == 3 && keys[2].checked && !keys[0].checked);
assert(cover_tilt_keys(blind, keys) == 3 && keys[0].command == COVER_OPEN_TILT && keys[2].command == COVER_CLOSE_TILT);
assert(key_action(blind, COVER_OPEN_TILT).service == "cover.open_cover_tilt");
assert(key_action(blind, COVER_STOP_TILT).service == "cover.stop_cover_tilt" && key_action(blind, COVER_CLOSE_TILT).service == "cover.close_cover_tilt");
CoverCard curtain_card = cover_card(curtain);
assert(curtain_card.position && !curtain_card.tilt && curtain_card.keys && !curtain_card.tilt_keys);
assert(cover_card_status(curtain) == "Open · 100%");
// A garage door: open, stop and close only; close is disabled while it is closed.
Tile garage = make("cover.garage", "closed", 11); garage.device_class = "garage";
CoverCard garage_card = cover_card(garage);
assert(!garage_card.position && !garage_card.tilt && garage_card.keys && !garage_card.tilt_keys);
assert(cover_keys(garage, keys) == 3 && keys[2].disabled && !keys[0].disabled);
// Slats that tilt without a position get tilt keys; a key that cannot tilt further is disabled.
Tile slats = make("cover.slats", "open", 3 | 16 | 32); slats.edit_extra().tilt = 100;
CoverCard slats_card = cover_card(slats);
assert(slats_card.tilt_keys && !slats_card.tilt && !slats_card.position);
assert(cover_tilt_keys(slats, keys) == 2 && keys[0].disabled && !keys[1].disabled);
assert(cover_card_status(slats) == "Open");
// Features not reported yet: the card offers the three keys, a tile's key row stays empty as before.
Tile unknown = make("cover.new", "unknown", 0);
CoverCard unknown_card = cover_card(unknown);
assert(unknown_card.keys && !unknown_card.position && cover_keys(unknown, keys) == 3);
unknown.controls = "buttons";
assert(keys_for(unknown, keys) == 0);
// Vacuum: play while docked, pause while cleaning, dock disabled in the dock.
Tile robot = make("vacuum.s8", "docked", 30524); robot.controls = "buttons";
assert(keys_for(robot, keys) == 3);
assert(!strcmp(keys[0].icon, glyph::PLAY) && keys[0].command == VACUUM_START && !keys[0].disabled);
assert(keys[1].command == VACUUM_STOP && keys[1].disabled);
assert(keys[2].command == VACUUM_DOCK && keys[2].disabled);
assert(key_action(robot, VACUUM_START).service == "vacuum.start");
robot.state = "cleaning";
assert(keys_for(robot, keys) == 3 && keys[0].command == VACUUM_PAUSE && !keys[1].disabled && !keys[2].disabled);
Tile old_robot = make("vacuum.old", "docked", feature::VACUUM_TURN_ON | feature::VACUUM_RETURN); old_robot.controls = "buttons";
assert(keys_for(old_robot, keys) == 2 && key_action(old_robot, VACUUM_START).service == "vacuum.turn_on");
// Media: playback keys follow supported_features; mute flips is_volume_muted.
Tile sonos = make("media_player.sonos", "playing", 8321599); sonos.volume = 0.17f; sonos.edit_extra().media_title = "TV"; sonos.controls = "playback";
assert(keys_for(sonos, keys) == 3 && !strcmp(keys[1].icon, glyph::PAUSE) && keys[1].command == MEDIA_PLAY_PAUSE);
assert(status_text(sonos) == "TV · 17%");
Action mute = key_action(sonos, MEDIA_MUTE);
assert(mute.service == "media_player.volume_mute" && mute.key == "is_volume_muted" && mute.value == "true");
sonos.muted = true; assert(key_action(sonos, MEDIA_MUTE).value == "false");
Tile radio = make("media_player.radio", "idle", feature::MEDIA_PLAY | feature::MEDIA_PAUSE); radio.controls = "playback";
assert(keys_for(radio, keys) == 1 && !strcmp(keys[0].icon, glyph::PLAY));
assert(status_text(radio) == "Idle");
// Climate: mode keys in priority order, at most three, the active one checked.
Tile ac = make("climate.ac", "cool"); ac.edit_extra().hvac_modes = "[\"off\",\"heat_cool\",\"cool\",\"heat\",\"fan_only\",\"dry\"]"; ac.controls = "mode"; ac.current = 21.5f; ac.target = 20; ac.step = 1;
assert(keys_for(ac, keys) == 3);
assert(keys[0].arg == "off" && keys[1].arg == "heat" && keys[2].arg == "cool" && keys[2].checked && !keys[1].checked);
Action mode = key_action(ac, HVAC_MODE, keys[1].arg);
assert(mode.service == "climate.set_hvac_mode" && mode.key == "hvac_mode" && mode.value == "heat");
assert(status_text(ac) == "Cool · 21.5°");
ac.edit_extra().hvac_action = "cooling"; assert(status_text(ac) == "Cooling · 21.5°");
assert(edit_target(ac) == 20 && edit_step(ac) == 1);
Action set = edit_action(ac, step_value(edit_target(ac), edit_step(ac), ac.minimum, ac.maximum, 1));
assert(set.service == "climate.set_temperature" && set.key == "temperature" && set.value == "21");
assert(edit_action(ac, 20.5f).value == "20.5");
// Numbers edit their own state; selects step through their options with wrap-around.
Tile number = make("number.target", "55"); number.minimum = 0; number.maximum = 100; number.step = 5;
assert(edit_target(number) == 55 && edit_action(number, step_value(55, 5, 0, 100, -1)).value == "50");
assert(edit_action(number, 50).service == "number.set_value");
Tile select = make("select.stand", "Comfort"); select.edit_extra().options = {"Eco", "Comfort", "Boost"}; select.controls = "stepper";
assert(panel_kind(select) == "chevrons");
assert(keys_for(select, keys) == 2 && !keys[0].disabled);
assert(key_action(select, SELECT_NEXT).value == "Boost" && key_action(select, SELECT_PREVIOUS).value == "Eco");
select.state = "Boost"; assert(key_action(select, SELECT_NEXT).value == "Eco");
select.edit_extra().options.resize(1); assert(keys_for(select, keys) == 2 && keys[0].disabled);
// Timer, run buttons and toggles.
Tile timer = make("timer.eggs", "active"); timer.controls = "buttons";
assert(keys_for(timer, keys) == 2 && keys[0].command == TIMER_PAUSE && !keys[1].disabled);
timer.state = "idle"; assert(keys_for(timer, keys) == 2 && keys[0].command == TIMER_START && keys[1].disabled);
assert(key_action(make("scene.evening", "unknown"), RUN).service == "scene.turn_on");
assert(key_action(make("script.all", "off"), RUN).service == "script.turn_on");
assert(key_action(make("input_button.bell", "unknown"), RUN).service == "input_button.press");
assert(!strcmp(run_label("scene"), "Activate") && !strcmp(run_label("button"), "Press"));
assert(key_action(make("switch.desk", "on"), TOGGLE).service == "switch.turn_off");
assert(key_action(make("light.lamp", "off"), TOGGLE).service == "light.turn_on");
assert(!key_action(make("sensor.x", "1"), TOGGLE).valid());
assert(!key_action(make("sensor.x", "1"), RUN).valid());
// A toggle key sends what its tap asks for and then shows the new stand; firmware 0.2.59 to 0.2.71 showed it first
// and so sent an off light light.turn_off.
Tile off_lamp = make("light.lamp", "off");
assert(press_key(off_lamp, TOGGLE).service == "light.turn_on" && off_lamp.state == "on" && off_lamp.optimistic_tap);
off_lamp.undo_optimistic(); assert(off_lamp.state == "off");
Tile on_fan = make("fan.ceiling", "on");
assert(press_key(on_fan, TOGGLE).service == "fan.turn_off" && on_fan.state == "off");
Tile odd = make("sensor.x", "1");
assert(!press_key(odd, TOGGLE).valid() && odd.state == "1" && !odd.optimistic_tap);
Tile shut = make("cover.shutter", "closed", 3);
assert(press_key(shut, COVER_OPEN).service == "cover.open_cover" && shut.state == "closed" && !shut.optimistic_tap);
// Panel kinds.
Tile lamp = make("light.lamp", "on"); lamp.controls = "brightness";
assert(is_slider(panel_kind(lamp)) && !is_key_row(panel_kind(lamp)));
assert(is_key_row("playback") && is_key_row("mode") && !is_key_row("setpoint"));
// Climate mode colours follow Home Assistant; anything else is grey.
assert(mode_color("heat") == 0xFF6F22 && mode_color("cool") == 0x2196F3 && mode_color("off") == 0x9E9E9E);
// Vacuum rows: the cleaning mode decides which of suction and water the card shows.
Tile pippa = make("vacuum.s8", "docked", 30524);
auto row = [](char kind, const char *entity, const char *current, std::vector<std::string> values, const char *roles = "") {
runtime_tiles::Choice c; c.kind = kind; c.entity = entity; c.current = current; c.values = values; c.labels = values; c.roles = roles; return c;
};
pippa.edit_extra().choices = {row('m', "select.woonkamer_s8_schoonmaakmodus", "vac_and_mop", {"vacuum", "vac_and_mop", "mop", "custom"}, "vbma"),
row('w', "select.s8_intensiteit_van_dweilen", "intense", {"mild", "standard", "intense"}),
row('s', "", "", {"quiet", "balanced", "turbo", "max", "max_plus"})};
pippa.edit_extra().fan_speed = "max";
settle_suction(pippa.edit_extra());
assert(pippa.choice('s')->current == "max" && pippa.extra().choices.size() == 3);
auto rows = vacuum_rows(pippa, 0);
assert(rows.suction && rows.water && vacuum_role(pippa, 0) == 'b');
pippa.choice('m')->current = "mop"; rows = vacuum_rows(pippa, 0);
assert(!rows.suction && rows.water);
pippa.choice('m')->current = "vacuum"; rows = vacuum_rows(pippa, 0);
assert(rows.suction && !rows.water);
pippa.choice('m')->current = "custom"; rows = vacuum_rows(pippa, 0);
assert(!rows.suction && !rows.water && vacuum_role(pippa, 0) == 'a');
pippa.choice('m')->current = "something_new"; assert(vacuum_role(pippa, 0) == 'b');
// A tapped chip shows while Home Assistant is busy, and the old value once it gave up.
pippa.choice('m')->current = "vac_and_mop"; pippa.choice('m')->sent = "mop";
pippa.begin(1000);
assert(shown_value(pippa, *pippa.choice('m'), 1500) == "mop" && vacuum_rows(pippa, 1500).water && !vacuum_rows(pippa, 1500).suction);
assert(shown_value(pippa, *pippa.choice('m'), 9000) == "vac_and_mop");
assert(choice_action(pippa, 'm', "mop").service == "select.select_option" && choice_action(pippa, 'm', "mop").key == "option");
assert(choice_action(pippa, 's', "turbo").service == "vacuum.set_fan_speed" && choice_action(pippa, 's', "turbo").value == "turbo");
// An older manager sends no suction row: the vacuum's own four speeds, with the card's names.
Tile old_robot2 = make("vacuum.old", "docked", 30524);
old_robot2.edit_extra().fan_speeds = {"quiet", "balanced"}; old_robot2.edit_extra().fan_speed = "balanced";
settle_suction(old_robot2.edit_extra());
assert(old_robot2.choice('s') && old_robot2.choice('s')->labels[1] == "Normal" && old_robot2.choice('s')->current == "balanced");
rows = vacuum_rows(old_robot2, 0);
assert(rows.suction && !rows.water && !choice_action(old_robot2, 'm', "mop").valid());
// Binary sensors (firmware 0.2.53+) in the words of their device class, Home Assistant's own since app 0.2.90 (the
// English table of the tests); no class or an unknown one says On and Off.
assert(!strcmp(binary_state_text("door", true), "Open") && !strcmp(binary_state_text("door", false), "Closed"));
assert(!strcmp(binary_state_text("motion", true), "Detected") && !strcmp(binary_state_text("motion", false), "Clear"));
assert(!strcmp(binary_state_text("moisture", true), "Wet") && !strcmp(binary_state_text("moisture", false), "Dry"));
assert(!strcmp(binary_state_text("battery_charging", false), "Not charging") && !strcmp(binary_state_text("battery", true), "Low"));
assert(!strcmp(binary_state_text("", true), "On") && !strcmp(binary_state_text("", false), "Off"));
assert(!strcmp(binary_state_text("future_class", true), "On") && !strcmp(binary_state_text("Door", true), "On"));
// Tap routing (firmware 0.2.58). `legacy` is runtime_tiles::event() of firmware 0.2.56, line by line: every tap
// choice an existing layout can hold must route exactly as before, so no screen changes behaviour on the update.
struct Legacy { TapRoute route; std::string service; bool busy; };
auto legacy = [](const Tile &tile, bool hold) -> Legacy {
const std::string d = tile.domain();
if (tile.tap == "none") return {TapRoute::NONE, "", false};
bool open = hold || d == "climate" || d == "vacuum" || d == "cover";
if (!hold && tile.tap == "detail") open = true;
if (!hold && tile.tap == "toggle") open = false;
if (d == "media_player" && tile.tap == "toggle" && !hold) return {TapRoute::ACTION, "media_player.toggle", false};
if (d == "climate" && tile.tap == "toggle" && !hold) return {TapRoute::ACTION, "climate.toggle", false};
if (d == "timer" && !open) return {TapRoute::ACTION, tile.state == "active" ? "timer.pause" : "timer.start", false};
if (d == "sensor" || d == "binary_sensor" || d == "weather" || d == "number" || d == "input_number" || d == "select" ||
d == "input_select" || d == "media_player" || d == "vacuum" || d == "cover" || d == "sun" || d == "person" || d == "timer")
return {TapRoute::CARD, "", true};
if (open) return d == "light" || d == "climate" || d == "vacuum" || d == "fan" ? Legacy{TapRoute::OVERLAY, "", true} : Legacy{TapRoute::CARD, "", false};
if (d == "light" || d == "switch" || d == "input_boolean" || d == "fan") return {TapRoute::ACTION, d + ".toggle", false};
if (d == "scene" || d == "script") return {TapRoute::ACTION, d + ".turn_on", false};
if (d == "button" || d == "input_button") return {TapRoute::ACTION, d + ".press", false};
return {TapRoute::NONE, "", false};
};
const char *domains[] = {"light", "switch", "input_boolean", "scene", "script", "climate", "vacuum", "fan", "cover", "sensor",
"binary_sensor", "input_select", "select", "number", "input_number", "weather", "media_player",
"button", "input_button", "sun", "timer", "person"};
// What validate_layout accepted before 0.2.67: `toggle` only on these six domains.
auto toggled_before = [](const std::string &d) {
return d == "light" || d == "switch" || d == "input_boolean" || d == "fan" || d == "media_player" || d == "climate";
};
int compared = 0;
// "action" without an action (an app before 0.2.67) taps like firmware 0.2.56 did with a value it didn't know: automatically.
for (const char *domain : domains) for (const char *choice : {"auto", "detail", "toggle", "none", "action"})
for (const char *state : {"on", "off", "active", "idle", "open"}) for (bool hold : {false, true}) {
Tile tile = make((std::string(domain) + ".x").c_str(), state);
tile.tap = choice;
Tap now = tap_route(tile, hold);
if (std::string(choice) == "toggle" && !toggled_before(domain)) {
// New in 0.2.58: a short tap toggles any entity the app allowed it for; holding still opens the card as before.
if (!hold) { assert(now.route == TapRoute::ACTION && now.service == std::string(domain) + ".toggle"); continue; }
}
Legacy before = legacy(tile, hold);
assert(now.route == before.route && now.service == before.service);
if (now.route != TapRoute::ACTION) assert(now.busy == before.busy);
++compared;
}
assert(compared == 1020); // 22 domains x 5 choices x 5 states x 2 gestures, less the 80 new short toggles
// The example of issue #7: a short tap on a cover set to On / off sends cover.toggle; holding opens its card.
Tile issue7 = make("cover.curtain", "open", feature::COVER_OPEN | feature::COVER_CLOSE | feature::COVER_STOP);
issue7.tap = "toggle";
assert(tap_route(issue7, false).route == TapRoute::ACTION && tap_route(issue7, false).service == "cover.toggle");
assert(tap_route(issue7, true).route == TapRoute::CARD);
issue7.tap = "auto";
assert(tap_route(issue7, false).route == TapRoute::CARD && tap_route(issue7, false).busy);
// An action of the tile's own choosing (firmware 0.2.58): a short tap performs it, holding still opens the card.
issue7.tap = "action";
issue7.edit_extra().action = "cover.set_cover_position";
issue7.edit_extra().action_data = {{"position", "50"}};
assert(tap_route(issue7, false).route == TapRoute::CUSTOM && tap_route(issue7, false).service == "cover.set_cover_position");
assert(tap_route(issue7, true).route == TapRoute::CARD);
assert(runtime_tiles::valid_action("cover.toggle") && runtime_tiles::valid_action("sonos.snapshot") && runtime_tiles::valid_action("homeassistant.turn_on"));
assert(!runtime_tiles::valid_action("cover") && !runtime_tiles::valid_action("Cover.toggle") && !runtime_tiles::valid_action("a.b.c") &&
!runtime_tiles::valid_action(".toggle") && !runtime_tiles::valid_action("cover.") && !runtime_tiles::valid_action("cover.to ggle"));
// Home Assistant's state colours (firmware 0.2.71+): what a tile shows while it is active; inactive is grey.
{
using namespace theme::ha;
auto colour = [](const char *entity, const char *state, const char *device_class = "", const char *unit = "") {
Tile t = make(entity, state); t.device_class = device_class; t.unit = unit; return accent(t);
};
assert(colour("climate.a", "cool") == BLUE && colour("climate.a", "heat") == DEEP_ORANGE && colour("climate.a", "dry") == ORANGE);
assert(colour("climate.a", "fan_only") == CYAN && colour("climate.a", "auto") == GREEN && colour("climate.a", "heat_cool") == AMBER);
assert(colour("climate.a", "eco") == AMBER); // a mode Home Assistant has no colour for: its active colour
for (const char *alarm : {"battery", "carbon_monoxide", "gas", "heat", "lock", "moisture", "problem", "safety", "smoke", "sound", "tamper"})
assert(alarm_class(alarm) && colour("binary_sensor.a", "on", alarm) == RED);
for (const char *calm : {"door", "window", "motion", "occupancy", "opening", "plug", "battery_charging", ""})
assert(!alarm_class(calm) && colour("binary_sensor.a", "on", calm) == AMBER);
assert(colour("sensor.a", "70", "battery", "%") == GREEN && colour("sensor.a", "69.5", "battery", "%") == ORANGE);
assert(colour("sensor.a", "30", "battery", "%") == ORANGE && colour("sensor.a", "29.9", "battery", "%") == RED);
assert(colour("sensor.a", "low", "battery") == BLUE && colour("sensor.a", "5%", "battery", "%") == TEAL); // no number
assert(colour("sensor.a", "50", "humidity", "%") == TEAL && colour("sensor.a", "21", "temperature", "°C") == DEEP_ORANGE);
assert(colour("script.a", "on") == AMBER && colour("timer.a", "active") == AMBER && colour("camera.a", "streaming") == AMBER);
assert(colour("light.a", "on") == AMBER && colour("switch.a", "on") == AMBER && colour("input_boolean.a", "on") == AMBER);
assert(colour("scene.a", "2026-09-18T20:00:00+00:00") == PURPLE && colour("cover.a", "open") == PURPLE);
assert(colour("select.a", "eco") == INDIGO && colour("number.a", "3") == TEAL && colour("fan.a", "on") == CYAN);
assert(colour("media_player.a", "playing") == LIGHT_BLUE && colour("vacuum.a", "cleaning") == TEAL && colour("vacuum.a", "error") == RED);
assert(colour("person.a", "home") == GREEN && colour("person.a", "Work") == BLUE);
assert(colour("sun.sun", "above_horizon") == AMBER && colour("sun.sun", "below_horizon") == INDIGO);
assert(colour("screen.clock", "") == BLUE && colour("image.a", "unknown") == BLUE && colour("button.a", "unknown") == BLUE);
// Every condition Home Assistant's weather knows, in its colour.
assert(weather_color("sunny") == AMBER && weather_color("clear-night") == DEEP_PURPLE && weather_color("partlycloudy") == BLUE_GREY);
assert(weather_color("cloudy") == LIGHT_GREY && weather_color("fog") == GREY && weather_color("rainy") == BLUE);
assert(weather_color("pouring") == INDIGO && weather_color("snowy") == ICE && weather_color("snowy-rainy") == LIGHT_BLUE);
assert(weather_color("hail") == CYAN && weather_color("lightning") == YELLOW && weather_color("lightning-rainy") == LIME);
assert(weather_color("windy") == GREEN && weather_color("windy-variant") == GREEN && weather_color("exceptional") == RED);
assert(weather_color("something-new") == AMBER && colour("weather.a", "pouring") == INDIGO);
}
// Fan and swing settings in Home Assistant's words where it names them, an integration's own as its name.
assert(climate_setting_text('f', "low") == "Low" && climate_setting_text('s', "both") == "Both");
assert(climate_setting_text('f', "quiet_night") == "Quiet night" && climate_setting_text('s', "low") == "Low");
return 0;
}