Grey out what Home Assistant calls off and colour the rest like Home Assistant, so an airco that is off no longer looks on (0.2.85)

The airco tile stayed orange while the airco was off. The firmware kept its own
short list of what turns grey and gave an off climate Home Assistant's orange,
but Home Assistant has no colour for a climate that is off: its cards fall back
to the inactive grey. Other tiles had the same fault: an off fan, a closed
blind, a docked robot, a player in standby, an idle script, a paused timer and
a camera that isn't streaming all kept their colour.

Tile::active() is now a port of the frontend's stateActive() for the domains a
tile can show, and every tile that is not active is grey. An airco that is off
reads Off, with the room's temperature when the device reports one, as Home
Assistant's tile does. tile_controls::accent() follows stateColorCss(): alarm
binary sensors red, a battery green, orange or red by its charge, a running
script or timer and a streaming camera amber, the weather per condition, the
sun amber and indigo, someone in a zone blue. Scenes, selects, numbers and
sensors keep their own colour per kind. A full-page tile lights up only for
tiles that can be off (Tile::lights_up), and a closed blind's slider keeps its
colour. The top bar's alarm classes are Home Assistant's eleven.

Checked with tools/check.sh (471 Python tests, 19/19 C++ with 58 state cases
and every colour rule) and compiled for both boards: CYD 1,677,488 B (91.4 %,
+576 B), Guition 2,054,640 B (25.3 %).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-18 23:31:10 +02:00
parent d76461d774
commit 9391d1b0ac
16 changed files with 369 additions and 69 deletions
+3 -1
View File
@@ -26,7 +26,9 @@ class OffLook(unittest.TestCase):
def test_the_screen_crosses_out_its_own_bulb_and_greys_what_is_off(self):
self.assertIn('if (d == "light" && tile.state == "off") return "\\U000F0E4F";', TILES)
self.assertIn('(t.is_switch()||d=="light"||d=="binary_sensor"||d=="person"||d=="timer") && !on ? theme::STATE_OFF : accent', TILES)
# Since firmware 0.2.71 every tile follows Home Assistant's stateActive() (Tile::active), not a list of domains.
self.assertIn('bool on = t.builtin() || (fresh() && t.active());', TILES)
self.assertIn('uint32_t state_color=on?accent:theme::STATE_OFF;', TILES)
self.assertIn('constexpr uint32_t STATE_OFF = ha::GREY;', THEME)
self.assertIn('constexpr uint32_t GREY = 0x9E9E9E;', THEME)
+43
View File
@@ -50,6 +50,7 @@ static void test_domains_and_packing() {
}
{ runtime_tiles::Tile lamp; lamp.entity = "light.lamp"; lamp.received = true; lamp.state = "unknown"; assert(!lamp.available()); }
{ runtime_tiles::Tile waiting; waiting.entity = "scene.evening"; waiting.state = "unknown"; assert(!waiting.available()); }
m.tiles[4].received = m.tiles[5].received = true;
m.tiles[4].state = "home"; assert(m.tiles[4].active());
m.tiles[5].state = "active"; assert(m.tiles[5].active());
m.tiles[5].state = "idle"; assert(!m.tiles[5].active());
@@ -186,6 +187,48 @@ static void test_slider_colours() {
setpoint.state = "55.0"; assert(setpoint.slider_active());
}
struct RunSliderColours { RunSliderColours() { test_slider_colours(); } } run_slider_colours;
// Home Assistant's stateActive() (firmware 0.2.71+): what it calls inactive is grey on the screen, and only a tile
// that can be off lights up over a whole page, while it is on.
static void test_state_active() {
using namespace runtime_tiles;
struct Case { const char *entity, *state; bool active, lights; };
for (const Case &c : std::initializer_list<Case>{
{"climate.airco", "off", false, false}, {"climate.airco", "cool", true, true}, {"climate.airco", "heat", true, true},
{"climate.airco", "heat_cool", true, true}, {"climate.airco", "fan_only", true, true}, {"climate.airco", "dry", true, true},
{"light.a", "on", true, true}, {"light.a", "off", false, false}, {"switch.a", "on", true, true},
{"input_boolean.a", "off", false, false}, {"fan.a", "on", true, true}, {"fan.a", "off", false, false},
{"cover.a", "open", true, true}, {"cover.a", "opening", true, true}, {"cover.a", "closing", true, true},
{"cover.a", "closed", false, false}, {"media_player.a", "playing", true, true}, {"media_player.a", "paused", true, true},
{"media_player.a", "idle", true, true}, {"media_player.a", "on", true, true}, {"media_player.a", "standby", false, false},
{"media_player.a", "off", false, false}, {"vacuum.a", "cleaning", true, true}, {"vacuum.a", "returning", true, true},
{"vacuum.a", "error", true, true}, {"vacuum.a", "docked", false, false}, {"vacuum.a", "idle", false, false},
{"vacuum.a", "paused", false, false}, {"script.a", "on", true, true}, {"script.a", "off", false, false},
{"timer.a", "active", true, true}, {"timer.a", "paused", false, false}, {"timer.a", "idle", false, false},
{"camera.a", "streaming", true, true}, {"camera.a", "recording", true, true}, {"camera.a", "idle", false, false},
{"person.a", "home", true, true}, {"person.a", "Work", true, true}, {"person.a", "not_home", false, false},
{"binary_sensor.a", "on", true, true}, {"binary_sensor.a", "off", false, false},
// Always active in Home Assistant: coloured, but never lit.
{"sensor.a", "21.5", true, false}, {"number.a", "3", true, false}, {"input_number.a", "3", true, false},
{"select.a", "eco", true, false}, {"input_select.a", "eco", true, false}, {"weather.a", "rainy", true, false},
{"sun.sun", "above_horizon", true, false}, {"sun.sun", "below_horizon", true, false},
{"scene.a", "2026-09-18T20:00:00+00:00", true, false}, {"scene.a", "unknown", true, false},
{"button.a", "unknown", true, false}, {"input_button.a", "unknown", true, false}, {"image.a", "unknown", true, false},
// "off" is inactive in every domain, a select's option too; unknown too, except where the state is a moment.
{"select.a", "off", false, false}, {"sensor.a", "unknown", false, false}, {"light.a", "unavailable", false, false},
{"scene.a", "unavailable", false, false}}) {
Tile t; t.entity = c.entity; t.state = c.state; t.received = true;
assert(t.active() == c.active);
assert(t.lights_up() == c.lights);
}
// Nothing received yet is inactive; a built-in card has no state, is always active and never lit.
{ Tile t; t.entity = "light.a"; t.state = "on"; assert(!t.active() && !t.lights_up()); }
for (const char *entity : {"screen.clock", "screen.settings", "screen.page_2"}) {
Tile t; t.entity = entity; assert(t.active() && !t.lights_up());
}
// A closed blind: grey card, coloured slider.
{ Tile t; t.entity = "cover.a"; t.state = "closed"; t.received = true; assert(!t.active() && t.slider_active()); }
}
struct RunStateActive { RunStateActive() { test_state_active(); } } run_state_active;
// A navigation tile may sit on several pages of one screen (firmware 0.2.65+); a Home Assistant entity still appears once.
static void test_repeated_page_tiles() {
using namespace runtime_tiles;
+74
View File
@@ -0,0 +1,74 @@
"""Tiles take Home Assistant's state colours and its rule for what is off (app 0.2.85 / firmware 0.2.71).
Home Assistant's frontend decides with stateActive() (src/common/entity/state_active.ts) whether a card shows an
entity in its colour or grey, and stateColorCss() (src/common/entity/state_color.ts) picks the colour from the
--state-*-color variables of its theme. The screen ports both: Tile::active() and tile_controls::accent(). These
checks keep the tables in step with each other and with Home Assistant's palette; tests/test_runtime_model.cpp and
tests/test_tile_controls.cpp check the rules state by state.
"""
import re
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
import header_bar # noqa: E402
import tile_icons # noqa: E402
COMPONENT = ROOT / 'components/smart_display'
CONTROLS = (COMPONENT / 'tile_controls.h').read_text()
TILES = (COMPONENT / 'runtime_tiles.h').read_text()
MODEL = (COMPONENT / 'runtime_model.h').read_text()
THEME = (COMPONENT / 'theme.h').read_text()
# Home Assistant's palette (frontend src/resources/theme/color/color.globals.ts), as its 2026.9 frontend serves it.
HA_PALETTE = {'RED': 'F44336', 'PURPLE': '926BC7', 'DEEP_PURPLE': '6E41AB', 'INDIGO': '3F51B5', 'BLUE': '2196F3',
'LIGHT_BLUE': '03A9F4', 'CYAN': '00BCD4', 'TEAL': '009688', 'GREEN': '4CAF50', 'LIME': 'CDDC39',
'YELLOW': 'FFEB3B', 'AMBER': 'FFC107', 'ORANGE': 'FF9800', 'DEEP_ORANGE': 'FF6F22', 'LIGHT_GREY': 'BDBDBD',
'GREY': '9E9E9E', 'BLUE_GREY': '607D8B', 'ICE': 'C0E0FF'}
# The binary sensor classes with a red --state-binary_sensor-<class>-on-color in that theme.
HA_ALARM_CLASSES = {'battery', 'carbon_monoxide', 'gas', 'heat', 'lock', 'moisture', 'problem', 'safety', 'smoke', 'sound',
'tamper'}
def function(source, signature):
return source.split(signature, 1)[1].split('\n}\n', 1)[0]
class StateColours(unittest.TestCase):
def test_the_theme_holds_home_assistants_palette(self):
ha = THEME.split('namespace ha {', 1)[1].split('} // namespace ha', 1)[0]
values = dict(re.findall(r'\b([A-Z_]+) = 0x([0-9A-F]{6})', ha))
for name, value in HA_PALETTE.items():
self.assertEqual(values.get(name), value, name)
def test_alarms_are_red_on_the_tile_and_in_the_top_bar(self):
firmware = set(re.findall(r'"(\w+)"', function(CONTROLS, 'inline bool alarm_class(const std::string &device_class) {')))
self.assertEqual(firmware, HA_ALARM_CLASSES)
self.assertEqual(set(header_bar.ALARM_CLASSES), HA_ALARM_CLASSES)
for device_class in sorted(HA_ALARM_CLASSES):
state = {'state': 'on', 'attributes': {'device_class': device_class}}
self.assertEqual(header_bar.accent('binary_sensor.a', state), header_bar.RED, device_class)
self.assertEqual(header_bar.accent('binary_sensor.a', {'state': 'on', 'attributes': {'device_class': 'door'}}),
header_bar.AMBER)
def test_every_weather_condition_has_its_colour(self):
body = function(CONTROLS, 'inline uint32_t weather_color(const std::string &condition) {')
self.assertEqual(set(re.findall(r'condition == "([\w-]+)"', body)), set(tile_icons.WEATHER))
def test_the_tile_greys_and_lights_up_by_home_assistants_rule(self):
self.assertIn('uint32_t accent=tile_controls::accent(t);', TILES)
self.assertIn('bool lit=w.full && on && t.lights_up() && available && !t.transparent;', TILES)
self.assertIn('const uint32_t fill=slider_on?accent:theme::STATE_OFF;', TILES)
self.assertNotIn('domain_accent', TILES)
self.assertIn('frontend src/common/entity/state_active.ts', MODEL)
def test_an_airco_that_is_off_says_so(self):
self.assertIn('else if (d == "climate" && t.state == "off") { value = tile_controls::climate_mode_text(t.state);', TILES)
off = TILES.index('d == "climate" && t.state == "off"')
self.assertLess(off, TILES.index('d == "climate" && std::isfinite(t.target)'), 'before the setpoint line')
if __name__ == '__main__':
unittest.main()
+1 -1
View File
@@ -27,7 +27,7 @@ ALLOWED_IN_FIRMWARE = {
'light_controls.h': ('constexpr uint32_t WARM = 0xFF9C32, COOL = 0xC6E6FF;',
'inline constexpr uint32_t RAINBOW[] = {0xFF0000, 0xFFFF00, 0x00FF00, 0x00FFFF, 0x0000FF, 0xFF00FF, 0xFF0000};'),
'tile_icon.h': ('if (cp < 0x10000 || cp > 0x10FFFF) return {};',),
'runtime_tiles.h': ('state_color=lv_color_to_u32(lv_color_hsv_to_rgb(t.hue%360,t.saturation,100))&0xFFFFFF;',),
'runtime_tiles.h': ('accent=lv_color_to_u32(lv_color_hsv_to_rgb(t.hue%360,t.saturation,100))&0xFFFFFF;',),
}
COLOUR_WHEEL = {'0xFF3B30', '0xFF9500', '0xFFD60A', '0x30D158', '0x32D6FF', '0x0A84FF', '0x5856D6', '0xBF5AF2'}
+33
View File
@@ -241,5 +241,38 @@ int main() {
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);
}
return 0;
}