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>
This commit is contained in:
Max Gramser
2026-09-19 20:17:32 +02:00
parent a3a5d11c54
commit 85feb9a740
26 changed files with 257 additions and 34 deletions
+2 -7
View File
@@ -8,6 +8,7 @@
#include "esphome/core/log.h"
#include "lvgl.h"
#include "theme.h"
#include "tile_controls.h"
#include <algorithm>
#include <functional>
#include <string>
@@ -26,12 +27,6 @@ inline Row rows[2];
inline int row_count = 0;
inline bool queued = false;
// Home Assistant's raw mode names read as words: "fan_only" -> "Fan only", "high" -> "High".
inline std::string pretty(std::string raw) {
for (char &c : raw) if (c == '_') c = ' ';
if (!raw.empty() && raw[0] >= 'a' && raw[0] <= 'z') raw[0] = static_cast<char>(raw[0] - 'a' + 'A');
return raw;
}
inline unsigned choices(const std::string &modes) {
unsigned n = 0;
while (n < MAX_CHOICES && !cyd::list_item(modes, n).empty()) ++n;
@@ -95,7 +90,7 @@ inline void build() {
std::string labels[MAX_CHOICES];
int widths[MAX_CHOICES], words = 0;
for (unsigned i = 0; i < n; ++i) {
labels[i] = pretty(cyd::list_item(row.modes, i));
labels[i] = tile_controls::climate_setting_text(row.kind, cyd::list_item(row.modes, i));
widths[i] = text_width(labels[i], look.font);
words += widths[i];
}
+17 -2
View File
@@ -267,6 +267,21 @@ constexpr uint16_t ha_binary_window_off = 302;
constexpr uint16_t ha_button_activate = 303;
constexpr uint16_t ha_button_run = 304;
constexpr uint16_t ha_button_press = 305;
constexpr uint16_t ha_climate_fan_auto = 306;
constexpr uint16_t ha_climate_fan_low = 307;
constexpr uint16_t ha_climate_fan_medium = 308;
constexpr uint16_t ha_climate_fan_high = 309;
constexpr uint16_t ha_climate_fan_middle = 310;
constexpr uint16_t ha_climate_fan_focus = 311;
constexpr uint16_t ha_climate_fan_diffuse = 312;
constexpr uint16_t ha_climate_fan_top = 313;
constexpr uint16_t ha_climate_fan_on = 314;
constexpr uint16_t ha_climate_fan_off = 315;
constexpr uint16_t ha_climate_swing_on = 316;
constexpr uint16_t ha_climate_swing_off = 317;
constexpr uint16_t ha_climate_swing_both = 318;
constexpr uint16_t ha_climate_swing_vertical = 319;
constexpr uint16_t ha_climate_swing_horizontal = 320;
constexpr uint16_t date_weekdays = 18;
constexpr uint16_t date_weekdays_count = 7;
constexpr uint16_t date_weekdays_short = 25;
@@ -278,6 +293,6 @@ constexpr uint16_t date_months_count = 12;
constexpr uint16_t date_months_short = 51;
constexpr uint16_t date_months_short_count = 12;
} // namespace txt
constexpr uint16_t KEY_COUNT = 306;
constexpr uint32_t KEYS_HASH = 0x5DBB4605u;
constexpr uint16_t KEY_COUNT = 321;
constexpr uint32_t KEYS_HASH = 0x8FB53847u;
} // namespace screen_text
+19
View File
@@ -163,6 +163,25 @@ inline std::string format_value(float value, float step, const char *suffix) {
if (!std::isfinite(value)) return "--";
return screen_text::decimal(value, step >= 1 ? 0 : 1) + suffix;
}
// Home Assistant's word for a climate fan ('f') or swing ('s') setting that Home Assistant names itself ("low" is
// "Laag" in Dutch); an integration's own mode reads as its name ("fan_only" -> "Fan only").
inline std::string climate_setting_text(char kind, std::string raw) {
using namespace screen_text;
struct Word { const char *value; uint16_t id; };
static const Word fan[] = {{"auto", txt::ha_climate_fan_auto}, {"low", txt::ha_climate_fan_low},
{"medium", txt::ha_climate_fan_medium}, {"high", txt::ha_climate_fan_high},
{"middle", txt::ha_climate_fan_middle}, {"focus", txt::ha_climate_fan_focus},
{"diffuse", txt::ha_climate_fan_diffuse}, {"top", txt::ha_climate_fan_top},
{"on", txt::ha_climate_fan_on}, {"off", txt::ha_climate_fan_off}};
static const Word swing[] = {{"on", txt::ha_climate_swing_on}, {"off", txt::ha_climate_swing_off},
{"both", txt::ha_climate_swing_both}, {"vertical", txt::ha_climate_swing_vertical},
{"horizontal", txt::ha_climate_swing_horizontal}};
if (kind == 'f') { for (const auto &w : fan) if (raw == w.value) return tr(w.id); }
if (kind == 's') { for (const auto &w : swing) if (raw == w.value) return tr(w.id); }
for (char &c : raw) if (c == '_') c = ' ';
if (!raw.empty() && raw[0] >= 'a' && raw[0] <= 'z') raw[0] = static_cast<char>(raw[0] - 'a' + 'A');
return raw;
}
inline const char *climate_mode_text(const std::string &mode) {
if (mode == "off") return screen_text::tr(screen_text::txt::ha_climate_off);
if (mode == "heat") return screen_text::tr(screen_text::txt::ha_climate_heat);
+5 -6
View File
@@ -3697,15 +3697,14 @@ script:
apply(id(cmode_auto), "auto");
apply(id(cmode_dry), "dry");
apply(id(cmode_fan_only), "fan_only");
// Fan and swing rows: the entity's own mode names, at most four each, as Home Assistant reports them.
auto pretty = [](std::string raw) { if (!raw.empty() && raw[0] >= 'a' && raw[0] <= 'z') raw[0] = (char)(raw[0] - 'a' + 'A'); return raw; };
auto fill = [&](lv_obj_t *title, lv_obj_t *chips[4], const std::string &modes, const std::string &current_mode) {
// Fan and swing rows: the entity's own modes, at most four each, in Home Assistant's words where it names them.
auto fill = [&](char kind, lv_obj_t *title, lv_obj_t *chips[4], const std::string &modes, const std::string &current_mode) {
unsigned shown = 0;
for (unsigned i = 0; i < 4; ++i) {
std::string raw = cyd::list_item(modes, i);
if (raw.empty()) { lv_obj_add_flag(chips[i], LV_OBJ_FLAG_HIDDEN); continue; }
lv_obj_clear_flag(chips[i], LV_OBJ_FLAG_HIDDEN); ++shown;
lv_label_set_text(lv_obj_get_child(chips[i], 0), pretty(raw).c_str());
lv_label_set_text(lv_obj_get_child(chips[i], 0), tile_controls::climate_setting_text(kind, raw).c_str());
lv_obj_set_style_bg_color(chips[i], theme::color(raw == current_mode ? theme::ACCENT_BRIGHT : theme::CARD), LV_PART_MAIN);
lv_obj_set_style_text_color(lv_obj_get_child(chips[i], 0), theme::color(raw == current_mode ? theme::ON_ACCENT : theme::INK), LV_PART_MAIN);
}
@@ -3713,8 +3712,8 @@ script:
};
lv_obj_t *fan_chips[4] = {id(cfan_0), id(cfan_1), id(cfan_2), id(cfan_3)};
lv_obj_t *swing_chips[4] = {id(cswing_0), id(cswing_1), id(cswing_2), id(cswing_3)};
fill(id(cfan_title), fan_chips, id(active_climate_fan_modes), id(active_climate_fan_mode));
fill(id(cswing_title), swing_chips, id(active_climate_swing_modes), id(active_climate_swing_mode));
fill('f', id(cfan_title), fan_chips, id(active_climate_fan_modes), id(active_climate_fan_mode));
fill('s', id(cswing_title), swing_chips, id(active_climate_swing_modes), id(active_climate_swing_mode));
- lvgl.widget.show: climate_mode_overlay
# Sets exactly one mode. No intermediate steps, so no unwanted
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<title>ESP Screens</title>
<script type="module" crossorigin src="./assets/index-Cw-mwwtp.js"></script>
<script type="module" crossorigin src="./assets/index-CLkdYPyY.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-B7AxjXTR.css">
</head>
<body>
+19
View File
@@ -386,6 +386,25 @@
"activate": "Aktivieren",
"run": "Ausführen",
"press": "Drücken"
},
"climate_fan": {
"auto": "Auto",
"low": "Niedrig",
"medium": "Mittel",
"high": "Hoch",
"middle": "Mittig",
"focus": "Fokus",
"diffuse": "Diffus",
"top": "Oben",
"on": "Ein",
"off": "Aus"
},
"climate_swing": {
"on": "Ein",
"off": "Aus",
"both": "Beides",
"vertical": "Vertikal",
"horizontal": "Horizontal"
}
}
},
+19
View File
@@ -140,6 +140,25 @@
"activate": "Activate",
"run": "Run",
"press": "Press"
},
"climate_fan": {
"auto": "Auto",
"low": "Low",
"medium": "Medium",
"high": "High",
"middle": "Middle",
"focus": "Focus",
"diffuse": "Diffuse",
"top": "Top",
"on": "On",
"off": "Off"
},
"climate_swing": {
"on": "On",
"off": "Off",
"both": "Both",
"vertical": "Vertical",
"horizontal": "Horizontal"
}
}
}
+19
View File
@@ -386,6 +386,25 @@
"activate": "Activate",
"run": "Run",
"press": "Press"
},
"climate_fan": {
"auto": "Auto",
"low": "Low",
"medium": "Medium",
"high": "High",
"middle": "Middle",
"focus": "Focus",
"diffuse": "Diffuse",
"top": "Top",
"on": "On",
"off": "Off"
},
"climate_swing": {
"on": "On",
"off": "Off",
"both": "Both",
"vertical": "Vertical",
"horizontal": "Horizontal"
}
}
},
+19
View File
@@ -386,6 +386,25 @@
"activate": "Activar",
"run": "Ejecutar",
"press": "Pulsa"
},
"climate_fan": {
"auto": "Auto",
"low": "Bajo",
"medium": "Medio",
"high": "Alto",
"middle": "Moderado",
"focus": "Enfoque",
"diffuse": "Difuso",
"top": "Parte superior",
"on": "Encendido",
"off": "Apagado"
},
"climate_swing": {
"on": "Encendido",
"off": "Apagado",
"both": "Ambos",
"vertical": "Vertical",
"horizontal": "Horizontal"
}
}
},
+19
View File
@@ -386,6 +386,25 @@
"activate": "Activer",
"run": "Exécuter",
"press": "Appuyer"
},
"climate_fan": {
"auto": "Auto",
"low": "Faible",
"medium": "Moyenne",
"high": "Élevée",
"middle": "Milieu",
"focus": "Focalisée",
"diffuse": "Diffuse",
"top": "Haute",
"on": "Activé",
"off": "Désactivé"
},
"climate_swing": {
"on": "Activé",
"off": "Désactivé",
"both": "Les deux",
"vertical": "Verticale",
"horizontal": "Horizontale"
}
}
},
+19
View File
@@ -386,6 +386,25 @@
"activate": "Attiva",
"run": "Esegui",
"press": "Premi"
},
"climate_fan": {
"auto": "Automatico",
"low": "Basso/a",
"medium": "Medio/a",
"high": "Alto/a",
"middle": "Mezzo",
"focus": "Focalizzato",
"diffuse": "Diffuso",
"top": "Superiore",
"on": "Acceso",
"off": "Spento"
},
"climate_swing": {
"on": "Acceso",
"off": "Spento",
"both": "Entrambi",
"vertical": "Verticale",
"horizontal": "Orizzontale"
}
}
},
+19
View File
@@ -386,6 +386,25 @@
"activate": "Inschakelen",
"run": "Uitvoeren",
"press": "Klik"
},
"climate_fan": {
"auto": "Automatisch",
"low": "Laag",
"medium": "Gemiddeld",
"high": "Hoog",
"middle": "Midden",
"focus": "Concentratie",
"diffuse": "Diffuus",
"top": "Boven",
"on": "Aan",
"off": "Uit"
},
"climate_swing": {
"on": "Aan",
"off": "Uit",
"both": "Beide",
"vertical": "Verticaal",
"horizontal": "Horizontaal"
}
}
},
+19
View File
@@ -386,6 +386,25 @@
"activate": "Aktywuj",
"run": "Uruchom",
"press": "Naciśnij"
},
"climate_fan": {
"auto": "Auto",
"low": "Low",
"medium": "Medium",
"high": "High",
"middle": "pośredni",
"focus": "skupiony nawiew",
"diffuse": "rozproszony nawiew",
"top": "nawiew górny",
"on": "włączono",
"off": "wyłączono"
},
"climate_swing": {
"on": "włączono",
"off": "wyłączono",
"both": "ruch w obu płaszczyznach",
"vertical": "ruch pionowy",
"horizontal": "ruch poziomy"
}
}
},
+19
View File
@@ -386,6 +386,25 @@
"activate": "Ativar",
"run": "Executar",
"press": "Pressione"
},
"climate_fan": {
"auto": "Auto",
"low": "Baixo",
"medium": "Médio",
"high": "Alto",
"middle": "Meio",
"focus": "Concentrado",
"diffuse": "Difuso",
"top": "Total",
"on": "Ligado",
"off": "Desligado"
},
"climate_swing": {
"on": "Ligado",
"off": "Desligado",
"both": "Ambos",
"vertical": "Vertical",
"horizontal": "Horizontal"
}
}
},
+17 -2
View File
@@ -3,7 +3,7 @@
#pragma once
#include "components/smart_display/screen_text.h"
static_assert(screen_text::KEYS_HASH == 0x5DBB4605u && screen_text::KEY_COUNT == 306,
static_assert(screen_text::KEYS_HASH == 0x8FB53847u && screen_text::KEY_COUNT == 321,
"screen_text_keys.h does not match screen_manager/translations/en.json: run tools/i18n.py header");
const char *const screen_text::TABLE[] = {
".",
@@ -311,7 +311,22 @@ const char *const screen_text::TABLE[] = {
"Closed",
"Activate",
"Run",
"Press"
"Press",
"Auto",
"Low",
"Medium",
"High",
"Middle",
"Focus",
"Diffuse",
"Top",
"On",
"Off",
"On",
"Off",
"Both",
"Vertical",
"Horizontal"
};
const char *const screen_text::LANGUAGE = "en";
int screen_text::plural_index(int n) { return n == 1 ? 0 : 1; }
+3
View File
@@ -287,5 +287,8 @@ int main() {
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;
}
+7 -1
View File
@@ -231,6 +231,11 @@ def ha_sources():
sources[f'climate.{mode}'] = f'component.climate.entity_component._.state.{mode}'
for action in ('heating', 'cooling', 'idle', 'off', 'drying', 'fan', 'preheating', 'defrosting'):
sources[f'hvac_action.{action}'] = f'component.climate.entity_component._.state_attributes.hvac_action.state.{action}'
# The fan and swing settings Home Assistant names itself; an integration's own modes stay as it reports them.
for mode in ('auto', 'low', 'medium', 'high', 'middle', 'focus', 'diffuse', 'top', 'on', 'off'):
sources[f'climate_fan.{mode}'] = f'component.climate.entity_component._.state_attributes.fan_mode.state.{mode}'
for mode in ('on', 'off', 'both', 'vertical', 'horizontal'):
sources[f'climate_swing.{mode}'] = f'component.climate.entity_component._.state_attributes.swing_mode.state.{mode}'
for state in ('open', 'closed', 'opening', 'closing'):
sources[f'cover.{state}'] = f'component.cover.entity_component._.state.{state}'
for state in ('playing', 'paused', 'idle', 'standby'):
@@ -334,7 +339,8 @@ def ha_words(write):
before = json.dumps(data, ensure_ascii=False)
merge(data.setdefault('screen', {}).setdefault('ha', {}), tree)
if json.dumps(data, ensure_ascii=False) != before:
print(f'{code}: {"wrote" if write else "would change"} {len(own)} words from Home Assistant')
changed = sum(1 for key, word in own.items() if reference.get(key) != word)
print(f'{code}: {"wrote" if write else "would change"} {changed} of {len(own)} words from Home Assistant')
if write:
write_language(code, data)
return status