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
+10 -1
View File
@@ -52,6 +52,9 @@ on the screen itself, and how updates work.
drag them straight into the screen mockup; **Double-width** is an option for every tile, and so is
**Full page** (firmware 0.2.62+): the tile takes the whole page and is one big button that lights up
in its state colour while on, so a screen by the door switches the light when you push anywhere on it.
Only something that can be off lights up: a light, switch, airco, fan, blind, speaker, robot, script,
timer, camera, person or binary sensor. Sensors, numbers, selects, scenes, the weather and the sun stay plain
(firmware 0.2.71+).
Its small slider, direct controls or graph sit at the bottom of the page. A **Go to page** tile
(`screen.page_1` to `screen.page_8`) opens another page: a full-page light switch on page 1, a menu on page 2.
Under **Goes to page** the editor offers the pages the screen has and the next, empty one, where a new sub-page
@@ -83,7 +86,13 @@ on the screen itself, and how updates work.
- **Home Assistant's words and icons** (app 0.2.67 / firmware 0.2.58): a blind says Open or
Closing, a speaker Playing and a door Open or Closed, a sensor shows the decimals Home Assistant
shows, and a tile without an icon of its own gets the one Home Assistant shows, following its
state. A light or binary sensor that is off turns grey (firmware 0.2.53+).
state.
- **Home Assistant's colours** (firmware 0.2.71+): a tile turns grey whenever Home Assistant calls
its entity inactive, by the same rule its own cards use: a light, switch or fan that is off, an
airco that is off (its tile then says Off), a closed blind, a docked robot, a player in standby,
a paused timer. Otherwise it takes Home Assistant's colour for the state: an airco per mode, an
alarm sensor red, a battery green, orange or red by its charge, the weather by its condition.
Scenes, selects, numbers and sensors keep a colour of their own per kind.
- **Weather card** with current weather, the coming hours and days including chance of
rain or millimeters; **climate card** with an on/off button, mode, fan, and swing settings.
The target temperature sits big between − / + keys with one row of mode keys below; the
+32 -10
View File
@@ -290,20 +290,42 @@ struct Tile {
auto d = domain();
return state != "unknown" || d == "scene" || d == "button" || d == "input_button";
}
// Home Assistant's stateActive() (frontend src/common/entity/state_active.ts), the rule its cards colour by: an
// entity it calls inactive is grey, such as an airco that is off, a closed blind, a docked robot, a player in
// standby or a paused timer (firmware 0.2.71+). Only the cases of the domains a tile can show are here. A scene,
// button or image states the moment it last ran (TIMESTAMP_STATE_DOMAINS), so only "unavailable" makes it
// inactive. A built-in card has no state and is always active.
bool active() const {
return state == "on" || state == "cleaning" || state == "active" || (domain() == "person" && state == "home") ||
(domain() == "sun" && state == "above_horizon") || (domain() == "climate" && available() && state != "off");
if (builtin()) return true;
if (!received || state.empty() || state == "unavailable") return false;
auto d = domain();
if (d == "scene" || d == "button" || d == "input_button" || d == "image") return true;
if (state == "unknown" || state == "off") return false;
if (d == "cover") return state != "closed";
if (d == "person") return state != "not_home";
if (d == "media_player") return state != "standby";
if (d == "vacuum") return state != "idle" && state != "docked" && state != "paused";
if (d == "timer") return state == "active";
if (d == "camera") return state == "streaming" || state == "recording";
return true;
}
// A slider shows the card's colour like Home Assistant's tile sliders: grey only while stateActive()
// (frontend src/common/entity/state_active.ts) calls the entity inactive, such as an off light or fan and a
// media player that is off or in standby. A number with a value is active there, and a closed cover's
// position slider keeps the cover's colour so it never looks disabled (hui-cover-position-card-feature.ts).
// A card over the whole page lights up in its state colour while it is on (firmware 0.2.62+): only a tile that can
// be off, closed, docked or away, and only while active() calls it active (firmware 0.2.71+). Sensors, numbers,
// selects, scenes, buttons, images, the weather and the sun are always active in Home Assistant and stay plain.
bool lights_up() const {
const auto d = domain();
for (const char *on_off : {"light", "switch", "input_boolean", "climate", "fan", "cover", "media_player", "vacuum",
"script", "timer", "camera", "person", "binary_sensor"})
if (d == on_off) return active();
return false;
}
// A slider shows the card's colour like Home Assistant's tile sliders: grey only while the entity is inactive
// (active() above), such as an off light or fan and a media player that is off or in standby. A number with a
// value is active there, and a closed cover's position slider keeps the cover's colour so it never looks
// disabled (hui-cover-position-card-feature.ts).
bool slider_active() const {
if (!available()) return false;
auto d = domain();
if (d == "media_player") return state != "off" && state != "standby";
if (d == "cover" || d == "number" || d == "input_number") return true;
return active();
return domain() == "cover" || active();
}
};
// Slot position of a tile within the fixed two-column, three-row pages.
+19 -40
View File
@@ -98,7 +98,6 @@ inline lv_image_dsc_t *cover_ready(const std::string &entity, int size, uint32_t
inline lv_obj_t *media_detail_picture = nullptr;
constexpr unsigned MEDIA_PICTURE = 14;
inline void media_action(Tile &t, int cmd);
inline uint32_t domain_accent(const Tile &t);
inline const char *icon_for(const Tile &tile);
inline void label(lv_obj_t *obj, const std::string &text);
inline int active_index = -1;
@@ -1778,7 +1777,7 @@ inline void render_history_detail(const Tile &t,bool large,int width,int height,
history_request(t.entity,history_hours);
c.ready=history_fits(t);
const bool line=c.ready?h.line:history_line_guess(t);
c.line=line;c.accent=domain_accent(t);
c.line=line;c.accent=tile_controls::accent(t);
const lv_font_t *big=watch_value_font?watch_value_font:(watch_font?watch_font:detail_font);
const lv_font_t *text=control_font?control_font:detail_font;
const lv_font_t *small=small_font?small_font:detail_font;
@@ -2385,34 +2384,6 @@ inline void pad_vertical(lv_obj_t *obj, int value) {
if (lv_obj_get_style_pad_bottom(obj, LV_PART_MAIN) != value) lv_obj_set_style_pad_bottom(obj, value, 0);
}
inline void set_line_width(lv_obj_t *obj, int value) { if (lv_obj_get_style_line_width(obj, LV_PART_MAIN) != value) lv_obj_set_style_line_width(obj, value, 0); }
// HA's default domain/state palette. Pastel circles also identify inactive domains.
// Custom Lovelace card/theme CSS is not an entity attribute and is not imported.
inline uint32_t domain_accent(const Tile &t) {
using namespace theme::ha;
auto d=t.domain();
if(d=="light" || d=="switch" || d=="input_boolean" || d=="binary_sensor")return AMBER;
// A climate card that is off or in an unknown mode keeps HA's orange climate circle.
if(d=="climate"){uint32_t c=tile_controls::mode_color(t.state);return c==GREY?ORANGE:c;}
if(d=="vacuum")return t.state=="error"?RED:TEAL;
if(d=="fan")return CYAN;
if(d=="cover")return PURPLE;
if(d=="media_player")return LIGHT_BLUE;
if(d=="scene" || d=="script")return PURPLE;
if(d=="select" || d=="input_select")return INDIGO;
if(d=="number" || d=="input_number")return TEAL;
if(d=="weather")return t.state=="sunny"?AMBER:t.state=="clear-night"?DEEP_PURPLE:LIGHT_BLUE;
if(d=="sun")return t.state=="above_horizon"?ORANGE:DEEP_PURPLE;
if(d=="timer")return t.state=="active"?TEAL:t.state=="paused"?ORANGE:GREY;
if(d=="person")return t.state=="home"?GREEN:GREY;
if(d=="screen")return BLUE;
if(d=="sensor"){
if(t.unit=="lx")return AMBER;
if(t.unit=="°C" || t.unit=="°F")return DEEP_ORANGE;
if(t.unit=="kWh" || t.unit=="Wh")return PURPLE;
if(t.unit=="%")return TEAL;
}
return BLUE;
}
// Custom cards draw into a transparent `extra` container; parts are rebuilt only
// when a slot changes mode, so paging keeps RAM use flat on the CYD.
// A slot whose next card draws no custom part only hides them: paging back to the clock or
@@ -3123,6 +3094,9 @@ inline void render_slot(size_t slot) {
else if (t.refused_at && esphome::millis() - t.refused_at < 4000) value = "Refused";
else if (d == "light" && t.state == "on" && tile_controls::effect_running(t.extra().effect)) value = t.extra().effect;
else if (d == "light" && t.state == "on" && std::isfinite(t.brightness)) value = std::to_string(static_cast<int>(std::lround(std::clamp(t.brightness, 0.0f, 255.0f) * 100 / 255))) + " %";
// An airco that is off says so, with the room's temperature when it knows it, as Home Assistant's tile does
// (firmware 0.2.71+); while it runs, the tile shows the temperature it is set to.
else if (d == "climate" && t.state == "off") { value = tile_controls::climate_mode_text(t.state); if (std::isfinite(t.current)) { char b[32]; snprintf(b, sizeof(b), " · %.1f°", t.current); value += b; } }
else if (d == "climate" && std::isfinite(t.target)) { char b[32]; snprintf(b, sizeof(b), "%.1f°", t.target); value = b; }
else if (d == "person") value = t.state=="home"?"Home":t.state=="not_home"?"Away":t.state;
else if (d == "sun") value = !t.extra().sunrise.empty() && !t.extra().sunset.empty() ? t.extra().sunrise+" - "+t.extra().sunset : t.state=="above_horizon"?"Above the horizon":"Below the horizon";
@@ -3245,30 +3219,33 @@ inline void render_slot(size_t slot) {
else hide_extra(w);
lap(swipe_profile::CUSTOM);
}
bool on = fresh() && t.active();
// Sliders colour more states than the card itself does, such as a playing media player (Tile::slider_active).
// Home Assistant's rule (Tile::active): what it calls inactive is grey, such as an airco that is off, a closed
// blind, a docked robot or a player in standby (firmware 0.2.71+). A built-in card has no state and keeps its colour.
bool on = t.builtin() || (fresh() && t.active());
// A closed blind's slider keeps the blind's colour while its card is grey, as in Home Assistant (Tile::slider_active).
bool slider_on = fresh() && t.slider_active();
bool available=fresh() && t.available();
int palette_state=(available?2:0)|(on?1:0)|(slider_on?4:0);
if (w.cached_active == palette_state && !w.panel_dirty) { lap(swipe_profile::GEOMETRY); return; }
w.cached_active = palette_state;w.panel_dirty=false;
uint32_t accent = domain_accent(t);
// Off is grey, as in Home Assistant, so a light or a door sensor shows its state at a glance.
uint32_t state_color=(t.is_switch()||d=="light"||d=="binary_sensor"||d=="person"||d=="timer") && !on ? theme::STATE_OFF : accent;
// Home Assistant's colour for the state (tile_controls::accent), and a lamp's own colour while it is on.
uint32_t accent=tile_controls::accent(t);
if(d=="light" && on && t.has_hs_color)
state_color=lv_color_to_u32(lv_color_hsv_to_rgb(t.hue%360,t.saturation,100))&0xFFFFFF;
accent=lv_color_to_u32(lv_color_hsv_to_rgb(t.hue%360,t.saturation,100))&0xFFFFFF;
uint32_t state_color=on?accent:theme::STATE_OFF;
auto color=lv_color_hex(theme::state(state_color));
auto circle_color=lv_color_hex(available?theme::tint(state_color,38):theme::hex(theme::TRACK));
// Very pale bulbs still need a visible icon: a touch darker on a light card, a touch lighter on a dark one.
auto icon_color=lv_color_hex(available?theme::icon(state_color):theme::hex(theme::OFF));
// Off: a grey track without fill or handle, as in Home Assistant.
const uint32_t fill=slider_on?state_color:theme::STATE_OFF;
const uint32_t fill=slider_on?accent:theme::STATE_OFF;
auto fill_color=lv_color_hex(theme::state(fill));
set_color(w.slider,LV_STYLE_BG_COLOR,fill_color,LV_PART_INDICATOR);
set_color(w.slider,LV_STYLE_BG_COLOR,lv_color_hex(theme::tint(fill,51)),LV_PART_MAIN);
slider_bar(w.slider,slider_bar_shown(t,slider_on));
// A full-page card lights up in its state colour while on (firmware 0.2.62+): an amber lamp, a purple scene.
bool lit=w.full && on && available && !t.transparent;
// A full-page card lights up in its state colour while it is on (firmware 0.2.62+): an amber lamp, an open blind, a
// playing speaker, never a sensor or the weather (Tile::lights_up, firmware 0.2.71+).
bool lit=w.full && on && t.lights_up() && available && !t.transparent;
set_color(w.tile,LV_STYLE_BG_COLOR,lv_color_hex(lit?theme::tint(state_color,51):theme::surface(t.background)));
set_number(w.tile,LV_STYLE_BORDER_WIDTH,1);
// "Background: none" hides only the card; geometry and padding stay identical,
@@ -3283,7 +3260,9 @@ inline void render_slot(size_t slot) {
set_color(w.unit,LV_STYLE_TEXT_COLOR,theme::color(t.is_page()?theme::CHEVRON:theme::SLATE));
set_color(w.title,LV_STYLE_TEXT_COLOR,title_color);
set_color(w.value,LV_STYLE_TEXT_COLOR,value_color);
style_panel(w,t,color,title_color);
// The controls take the state's colour even while the card is grey: a closed blind's position slider stays coloured
// (Tile::slider_active), while the slider of something off turns grey and the Off mode key has a grey of its own.
style_panel(w,t,lv_color_hex(theme::state(accent)),title_color);
// Custom parts follow the card palette: text like the title, lines/dots in the accent.
// The sun path sets its own colours on every render, the sunlit area under its arc too: taking the
// accent here made that area orange after a palette change and yellow again after the next minute.
+15 -12
View File
@@ -201,19 +201,22 @@ inline uint32_t outline(uint32_t own) {
// What a state looks like in Home Assistant's frontend (its --state-*-color and --*-color variables), the same in both
// looks: an amber light is amber at night too. They are data, drawn through state(), foreground(), tint() and icon().
namespace ha {
constexpr uint32_t AMBER = 0xFFC107; // a light, switch or binary sensor that is on; lux
constexpr uint32_t ORANGE = 0xFF9800; // the sun above the horizon, a paused timer or robot, drying
constexpr uint32_t AMBER = 0xFFC107; // on: a light, switch, running script or timer, streaming camera; the sun; lux
constexpr uint32_t ORANGE = 0xFF9800; // a paused robot, drying, a battery half full, the sun path by day
constexpr uint32_t DEEP_ORANGE = 0xFF6F22; // temperature, heating
constexpr uint32_t RED = 0xF44336; // a robot in trouble
constexpr uint32_t PURPLE = 0x926BC7; // covers, scenes and scripts, energy
constexpr uint32_t DEEP_PURPLE = 0x6E41AB; // a clear night, the sun below the horizon
constexpr uint32_t INDIGO = 0x3F51B5; // selects
constexpr uint32_t BLUE = 0x2196F3; // cooling, a robot on its way back, the settings card, everything else
constexpr uint32_t LIGHT_BLUE = 0x03A9F4; // media players, the weather
constexpr uint32_t CYAN = 0x00BCD4; // fans
constexpr uint32_t TEAL = 0x009688; // numbers, percentages, a running timer, a robot at work
constexpr uint32_t GREEN = 0x4CAF50; // someone at home, auto mode
constexpr uint32_t GREY = 0x9E9E9E; // anything off or away
constexpr uint32_t RED = 0xF44336; // an alarm going off, a robot in trouble, an empty battery
constexpr uint32_t PURPLE = 0x926BC7; // covers, scenes, energy
constexpr uint32_t DEEP_PURPLE = 0x6E41AB; // a clear night
constexpr uint32_t INDIGO = 0x3F51B5; // selects, the sun below the horizon, pouring rain
constexpr uint32_t BLUE = 0x2196F3; // cooling, rain, a robot on its way back, someone in a zone, everything else
constexpr uint32_t LIGHT_BLUE = 0x03A9F4; // media players, sleet
constexpr uint32_t CYAN = 0x00BCD4; // fans, hail
constexpr uint32_t TEAL = 0x009688; // numbers, percentages, a robot at work
constexpr uint32_t GREEN = 0x4CAF50; // someone at home, auto mode, wind, a full battery
constexpr uint32_t GREY = 0x9E9E9E; // anything off or away, fog
// The rest of the weather, as Home Assistant colours a condition (--state-weather-*-color): clouds, a partly
// cloudy sky, lightning, lightning with rain, and snow, which Home Assistant writes as a value of its own.
constexpr uint32_t LIGHT_GREY = 0xBDBDBD, BLUE_GREY = 0x607D8B, YELLOW = 0xFFEB3B, LIME = 0xCDDC39, ICE = 0xC0E0FF;
constexpr uint32_t SKY = 0x00A6ED; // a robot that is docked or idle
constexpr uint32_t SWITCH_ON = 0xFFB900; // the switch on a history card while on
constexpr uint32_t PURPLE_PRESSED = 0x7552A8; // a chosen cover key under a finger
+63
View File
@@ -59,6 +59,69 @@ inline uint32_t mode_color(const std::string &mode) {
if (mode == "dry") return ORANGE;
return GREY;
}
// Binary sensor classes Home Assistant draws red while they are on (its --state-binary_sensor-<class>-on-color): an
// alarm, a problem, a low battery, an unlocked lock. header_bar.ALARM_CLASSES gives the top bar the same list.
inline bool alarm_class(const std::string &device_class) {
for (const char *alarm : {"battery", "carbon_monoxide", "gas", "heat", "lock", "moisture", "problem", "safety", "smoke",
"sound", "tamper"})
if (device_class == alarm) return true;
return false;
}
// Home Assistant's colour for a weather condition (--state-weather-<condition>-color); a condition it has none for
// takes its --state-active-color.
inline uint32_t weather_color(const std::string &condition) {
using namespace theme::ha;
if (condition == "sunny") return AMBER;
if (condition == "clear-night") return DEEP_PURPLE;
if (condition == "partlycloudy") return BLUE_GREY;
if (condition == "cloudy") return LIGHT_GREY;
if (condition == "fog") return GREY;
if (condition == "rainy") return BLUE;
if (condition == "pouring") return INDIGO;
if (condition == "snowy") return ICE;
if (condition == "snowy-rainy") return LIGHT_BLUE;
if (condition == "hail") return CYAN;
if (condition == "lightning") return YELLOW;
if (condition == "lightning-rainy") return LIME;
if (condition == "windy" || condition == "windy-variant") return GREEN;
if (condition == "exceptional") return RED;
return AMBER;
}
// The colour of a tile while Home Assistant calls it active (Tile::active; anything inactive is grey): its
// stateColorCss() (frontend src/common/entity/state_color.ts) for the domains it colours by state, and a colour of
// our own per kind for those it draws in one neutral blue, such as scenes, selects, numbers and sensors (0.2.3).
// A state Home Assistant has no colour of its own for takes its --state-active-color, amber (firmware 0.2.71+).
// Colours set in a Lovelace card or theme are no entity attributes and never reach the screen.
inline uint32_t accent(const Tile &t) {
using namespace theme::ha;
const auto d = t.domain();
if (d == "binary_sensor") return alarm_class(t.device_class) ? RED : AMBER;
if (d == "light" || d == "switch" || d == "input_boolean" || d == "script" || d == "timer" || d == "camera") return AMBER;
if (d == "climate") { const uint32_t c = mode_color(t.state); return c == GREY ? AMBER : c; }
if (d == "vacuum") return t.state == "error" ? RED : TEAL;
if (d == "fan") return CYAN;
if (d == "cover" || d == "scene") return PURPLE;
if (d == "media_player") return LIGHT_BLUE;
if (d == "select" || d == "input_select") return INDIGO;
if (d == "number" || d == "input_number") return TEAL;
if (d == "weather") return weather_color(t.state);
if (d == "sun") return t.state == "above_horizon" ? AMBER : INDIGO;
// At home green; in another zone blue (--state-person-active-color).
if (d == "person") return t.state == "home" ? GREEN : BLUE;
if (d == "sensor") {
// A battery by its charge, as Home Assistant's battery_color.ts: green from 70 %, orange from 30 %, red below.
if (t.device_class == "battery") {
char *end = nullptr;
const float charge = std::strtof(t.state.c_str(), &end);
if (end != t.state.c_str() && *end == '\0' && std::isfinite(charge)) return charge >= 70 ? GREEN : charge >= 30 ? ORANGE : RED;
}
if (t.unit == "lx") return AMBER;
if (t.unit == "°C" || t.unit == "°F") return DEEP_ORANGE;
if (t.unit == "kWh" || t.unit == "Wh") return PURPLE;
if (t.unit == "%") return TEAL;
}
return BLUE;
}
inline const char *mode_icon(const std::string &mode) {
if (mode == "off") return glyph::POWER;
if (mode == "heat") return glyph::FIRE;
+57
View File
@@ -0,0 +1,57 @@
# Test results app 0.2.85 / firmware 0.2.71 (2026-09-18)
Max noticed that the airco tile always looked on, also with the airco off, and asked whether other tiles did the same.
They did. The firmware had its own short list of what turns grey (switches, lights, binary sensors, persons, timers),
and gave an airco that is off Home Assistant's orange on the belief that Home Assistant draws it so. It does not: its
frontend has no colour for a climate that is off, so it falls back to the inactive grey. Max chose, on two questions:
only tiles that can be off light up over a whole page, and all four groups of Home Assistant's colours come along
(alarm sensors, batteries, running scripts/timers/cameras, weather and sun).
## Sources
Read from Home Assistant's frontend (dev branch on GitHub) and checked against the CSS variables and `const.ts` module
his own Home Assistant 2026.9 serves: `src/common/entity/state_active.ts` (what is active per domain),
`src/common/entity/state_color.ts` (the variable order `--state-<domain>-<device class>-<state>-color`,
`--state-<domain>-<state>-color`, `--state-<domain>-active|inactive-color`, `--state-active|inactive-color`),
`src/common/entity/color/battery_color.ts` (70 / 30), `src/panels/lovelace/cards/hui-tile-card.ts` (inactive grey,
a person's colour on its badge), `src/state-display/state-display.ts` (a climate tile shows its state and the current
temperature) and the `TIMESTAMP_STATE_DOMAINS` set (scene, button, image: active unless unavailable).
## What changed
- Firmware: `Tile::active()` is a port of `stateActive()` for the domains a tile can show; `Tile::lights_up()` limits
the full-page glow to tiles that can be off; `Tile::slider_active()` is `active()` plus the cover exception.
`tile_controls::accent()` replaces `runtime_tiles::domain_accent()` and follows `stateColorCss()` (alarm classes red,
batteries by charge, script/timer/camera amber, weather per condition, sun amber/indigo, a person in a zone blue,
a climate mode without a colour amber), keeping our own colour per kind for scenes, selects, numbers and sensors.
`render_slot` greys every tile that is not active, lights up only `lights_up()` tiles, keeps a closed blind's
slider coloured, and an airco that is off reads "Off" (with " · 21.5°" when it reports a current temperature).
`theme.h` gained Home Assistant's light grey, blue grey, yellow, lime and the snowy blue.
- App: `header_bar.ALARM_CLASSES` is Home Assistant's list of eleven red classes (battery, heat, lock and sound were
missing), so the top bar and the tiles agree.
- Tests: `tests/test_runtime_model.cpp` (58 state cases for `active()` and `lights_up()`, built-in cards, nothing
received, the closed blind), `tests/test_tile_controls.cpp` (every colour rule, all weather conditions, battery
bounds 69.5/70 and 29.9/30), `tests/test_state_colours.py` (the palette values against Home Assistant's, the alarm
list in firmware, top bar and Home Assistant, a colour for every weather condition the add-on knows),
`tests/test_off_look.py` and `tests/test_theme.py` follow the new lines.
## Automated
- `tools/check.sh`: Python 471 tests, C++ 19/19, packages, icons, Vitest, vue-tsc, build and the bundle: all pass.
- `tools/check.sh --firmware --baseline 1676912` (ESPHome 2026.6.2): CYD 1,677,488 B = 91.4 % of the 1,835,008 B
slot, +576 B against 0.2.84 (tight band, well under the 8 KB rule); Guition 2,054,640 B = 25.3 % of its 8,126,464 B slot.
## On the screens
Max asked to skip a host build and check on the two screens that already run on his Home Assistant. After the
release both get this firmware over the air from the YAML his ESPHome Device Builder holds for them (packages from
GitHub main, the same route an Update in ESP Screens takes), with keys and touch calibration checked equal first.
Their tiles cover most of the change: the airco (off), scripts that are not running, the robot in its dock, lights
that are off, a battery sensor at 100 %, a temperature sensor, a playing Apple TV, a plug sensor that is on, scenes,
a button and an input boolean. What they show is reported to Max for his own look.
## Not tested
- Weather, sun, a person in a zone, a camera, a paused timer, a closed or open blind and the alarm classes on real
hardware: none of Max's screens shows them now. The unit tests cover every rule.
- The editor's mockup still draws icons in one colour; it never showed state colours.
+1 -1
View File
@@ -14,7 +14,7 @@
# fonts/materialdesignicons-webfont.ttf (Apache 2.0, github.com/Templarian/MaterialDesign-Webfont).
############################################################
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.70"
SCREEN_FIRMWARE_VERSION: "0.2.71"
DASHBOARD_INBOX_INTERNAL: "false"
CALIBRATION_ON_BOOT: "false" # a bench build: start on the touch test page
ROOM_NAME: "Choose tiles in HA" # shown top left until ESP Screens sends the screen name
+12
View File
@@ -1,3 +1,15 @@
## 0.2.85 (firmware 0.2.71)
Off looks off on every tile, in Home Assistant's colours.
- **What Home Assistant calls off is grey.** Tiles now follow the rule Home Assistant's own cards colour by (its `stateActive()`). An airco or fan that is off, a closed blind, a docked or paused robot, a player that is off or in standby, a script that isn't running, a paused timer and a camera that isn't streaming turn grey, as an off light or door sensor already did. The airco was the one you noticed: its tile stayed orange while the airco was off.
- **An airco that is off says Off.** Its tile showed the temperature it was set to, even while off. It now reads *Off*, with the room's temperature when the device reports one (*Off · 21.5°*), as Home Assistant's tile does. While it runs, the tile still shows the set temperature.
- **Home Assistant's colours for the rest.** An alarm that goes off is red on its tile, as in the top bar: smoke, gas, carbon monoxide, a leak, heat, a problem, safety, sound, tampering, a low battery and an unlocked lock. A battery sensor is green from 70 %, orange from 30 % and red below that. A running script or timer and a streaming camera are amber. The weather takes the colour of its condition: clouds light grey, fog grey, rain blue, pouring rain indigo, snow pale blue, lightning yellow and wind green. The sun is amber by day and indigo at night, and someone in a zone other than home is blue. Scenes, selects, numbers and sensors keep their own colour per kind, where Home Assistant draws them all in one neutral blue.
- **A full-page tile lights up only when it is on.** It now also lights up for an open blind, a speaker that is on, a robot at work or on its way back, a running script, a streaming camera and someone at home or in a zone. Sensors, numbers, selects, scenes, the weather and the sun stay plain, because Home Assistant calls them active all the time; the sun no longer lights up by day.
- **The top bar knows the same alarms.** A heat, sound or lock sensor and a low battery now turn red in the top bar too. That part is the add-on's and needs no firmware.
- A closed blind's small slider keeps its colour while its tile is grey, as in Home Assistant.
- Needs firmware 0.2.71: press **Update** on the screen. Includes everything from 0.2.84. CYD firmware: 1,677,488 bytes, 91.4 % of the update slot (576 bytes more than 0.2.84).
## 0.2.84 (firmware 0.2.70)
One shared screen for every board: the two board profiles became one core plus a small file per board.
+1 -1
View File
@@ -37,7 +37,7 @@ WEEKDAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
REPO = 'https://github.com/MaxGramser/homeassistant_espscreen'
REFS = {'cyd': 'main', 'guition': 'main'}
# Firmware shipped with this app release; screens below it get an update offer.
FIRMWARE_VERSION = '0.2.70'
FIRMWARE_VERSION = '0.2.71'
# The Auto standby switch a screen offers Home Assistant automations.
AUTO_STANDBY_MIN_FIRMWARE = '0.2.41'
# The settings page the screen opens itself, and the screen.settings tile that opens it.
+4 -1
View File
@@ -87,7 +87,10 @@ DOMAIN_ICONS = {
MOMENT_DOMAINS = frozenset(['scene', 'button', 'input_button', 'event'])
INACTIVE = frozenset(['off', 'closed', 'not_home', 'idle', 'docked', 'standby', 'locked', 'disarmed', 'paused', 'below_horizon'])
AMBER, GREEN, RED, BLUE, ORANGE, TEAL = 'FFB300', '43A047', 'E53935', '2196F3', 'FF6F22', '009688'
ALARM_CLASSES = frozenset(['problem', 'safety', 'smoke', 'gas', 'carbon_monoxide', 'moisture', 'tamper'])
# Binary sensor classes Home Assistant draws red while on (--state-binary_sensor-<class>-on-color); the screen's
# tile_controls::alarm_class has the same list (app 0.2.85).
ALARM_CLASSES = frozenset(['battery', 'carbon_monoxide', 'gas', 'heat', 'lock', 'moisture', 'problem', 'safety', 'smoke',
'sound', 'tamper'])
def clean_text(text):
"""Text the screen can draw: known glyphs, accents folded to the base letter, at most TEXT_BYTES."""
+1 -1
View File
@@ -1,5 +1,5 @@
name: ESP Screen Manager
version: "0.2.84"
version: "0.2.85"
slug: esp_screen_manager
description: Choose tiles for your CYD and Guition touchscreen, without reflashing.
url: https://github.com/MaxGramser/homeassistant_espscreen
+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;
}