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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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'}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user