From 378d0b3e6103606fd8c5f8baa3b6f43bfe7c5f3a Mon Sep 17 00:00:00 2001 From: Max Gramser Date: Sat, 19 Sep 2026 14:24:19 +0200 Subject: [PATCH] Build each screen in one language: the screen texts from the translations, European letters in the fonts, and Language & region in the app The firmware takes its texts from screen_manager/translations/.json at build time (components/smart_display: one table in main.cpp, keys in screen_text_keys.h), and the fonts carry every letter European languages write with. The app keeps Settings -> Language & region (language, clock, numbers) for every screen, writes LANGUAGE into the screens' YAML and sends the clock and the number format in the layout message; the per-screen 24-hour clock goes. Co-Authored-By: Claude Opus 5 --- components/smart_display/__init__.py | 37 +- components/smart_display/alert_overlay.h | 4 +- components/smart_display/cyd_calibration.h | 14 +- components/smart_display/effects_page.h | 9 +- components/smart_display/header_bar.h | 39 +- components/smart_display/history_view.h | 47 +-- components/smart_display/light_controls.h | 7 +- components/smart_display/media_card.h | 11 +- components/smart_display/runtime_model.h | 5 +- components/smart_display/runtime_tiles.h | 224 ++++++----- components/smart_display/screen_text.h | 85 +++++ components/smart_display/screen_text_gen.py | 172 +++++++++ components/smart_display/screen_text_keys.h | 279 ++++++++++++++ components/smart_display/settings_screen.h | 124 ++++--- components/smart_display/tile_controls.h | 124 ++++--- packages/boards/cyd-2432s028.yaml | 6 +- packages/boards/guition-4848s040.yaml | 6 +- packages/core.yaml | 140 ++++--- screen_manager/Dockerfile | 2 + screen_manager/app/core.py | 10 +- screen_manager/app/firmware.py | 29 +- screen_manager/app/header_bar.py | 4 +- screen_manager/app/i18n.py | 213 +++++++++++ screen_manager/app/server.py | 143 ++++++- screen_manager/app/updates.py | 12 +- screen_manager/translations/en-GB.json | 16 + screen_manager/translations/en.json | 391 +++++++++++++++++++- tests/screen_text_en.h | 313 ++++++++++++++++ tests/test_action_feedback.cpp | 1 + tests/test_alert_overlay.cpp | 1 + tests/test_binary_words.py | 14 +- tests/test_calibration_math.cpp | 1 + tests/test_camera_view.cpp | 1 + tests/test_chunk_transport.cpp | 1 + tests/test_cyd_ui.cpp | 1 + tests/test_effects_page.cpp | 1 + tests/test_header_bar.cpp | 1 + tests/test_header_bar.py | 14 +- tests/test_history_view.cpp | 1 + tests/test_light_controls.cpp | 1 + tests/test_media_card.cpp | 1 + tests/test_runtime_model.cpp | 1 + tests/test_screen_owned_settings.py | 21 +- tests/test_screen_settings.cpp | 1 + tests/test_settings_screen.cpp | 26 +- tests/test_theme.cpp | 1 + tests/test_theme.py | 2 +- tests/test_tile_controls.cpp | 1 + tests/test_tile_icon.cpp | 1 + tests/test_tile_palette.cpp | 1 + tools/i18n.py | 144 +++++++ 51 files changed, 2321 insertions(+), 383 deletions(-) create mode 100644 components/smart_display/screen_text.h create mode 100644 components/smart_display/screen_text_gen.py create mode 100644 components/smart_display/screen_text_keys.h create mode 100644 screen_manager/app/i18n.py create mode 100644 screen_manager/translations/en-GB.json create mode 100644 tests/screen_text_en.h create mode 100644 tools/i18n.py diff --git a/components/smart_display/__init__.py b/components/smart_display/__init__.py index 4d4ced6..497d5f0 100644 --- a/components/smart_display/__init__.py +++ b/components/smart_display/__init__.py @@ -1,3 +1,38 @@ -"""Runtime tile configuration transported through the normal ESPHome API.""" +"""Runtime tile configuration transported through the normal ESPHome API. + +`smart_display: language: nl` (app 0.2.90) builds the screen's texts in that language: the `screen` section of +screen_manager/translations/nl.json, with English for any key it lacks, becomes one table in main.cpp +(screen_text_gen.py, screen_text.h). The shared core sets it from the LANGUAGE substitution, which ESP Screens writes +into a screen's YAML. +""" +import logging + +import esphome.codegen as cg +import esphome.config_validation as cv + +from . import screen_text_gen + CODEOWNERS = [] AUTO_LOAD = ["json"] +_LOGGER = logging.getLogger(__name__) + +CONF_LANGUAGE = "language" + + +def _language(value): + value = cv.string_strict(value).strip() + if not screen_text_gen.LANGUAGE_CODE.fullmatch(value): + raise cv.Invalid("Expected a language code such as en, nl or pt-BR") + return value + + +CONFIG_SCHEMA = cv.Schema({cv.Optional(CONF_LANGUAGE, default="en"): _language}) + + +async def to_code(config): + wanted = config[CONF_LANGUAGE] + language, code = screen_text_gen.definitions(wanted) + if language != wanted: + # ESP Screens only writes languages it has; a hand-written one it lacks builds in the nearest it does have. + _LOGGER.warning("No translation for %s yet; the screen's texts are in %s", wanted, language) + cg.add_global(cg.RawStatement(code)) diff --git a/components/smart_display/alert_overlay.h b/components/smart_display/alert_overlay.h index 09b2639..60c494a 100644 --- a/components/smart_display/alert_overlay.h +++ b/components/smart_display/alert_overlay.h @@ -2,6 +2,7 @@ #include #include #include +#include "screen_text.h" #include "tile_icon.h" #include "tile_icon_names.h" #include "tile_palette.h" @@ -11,7 +12,6 @@ // tests/test_alert_overlay.cpp can check them; the profiles do the LVGL work. namespace screen_alert { constexpr const char *FALLBACK_ICON = "alert-outline"; -constexpr const char *FALLBACK_TITLE = "Notification"; constexpr const char *FALLBACK_BUTTON = "OK"; constexpr uint32_t DEFAULT_CARD_COLOR = 0; // the normal card: theme::surface(0) constexpr int MAX_TIMEOUT_SECONDS = 86400; @@ -59,7 +59,7 @@ inline Alert make(const std::string &title, const std::string &subtitle, const s size_t title_max, size_t subtitle_max, size_t button_max) { Alert alert; alert.title = clipped(trimmed(title), title_max); - if (alert.title.empty()) alert.title = FALLBACK_TITLE; + if (alert.title.empty()) alert.title = screen_text::tr(screen_text::txt::alert_notification); alert.subtitle = clipped(trimmed(subtitle), subtitle_max); alert.button = clipped(trimmed(button), button_max); if (alert.button.empty()) alert.button = FALLBACK_BUTTON; diff --git a/components/smart_display/cyd_calibration.h b/components/smart_display/cyd_calibration.h index ca78082..721fefe 100644 --- a/components/smart_display/cyd_calibration.h +++ b/components/smart_display/cyd_calibration.h @@ -4,6 +4,7 @@ #include "esphome/core/hal.h" #include "lvgl.h" #include "theme.h" +#include "screen_text.h" #include namespace screen_calibration { inline bool active = false, pressed = false; @@ -15,9 +16,10 @@ inline std::array,5> samples; inline esphome::ESPPreferenceObject preference; inline std::function apply; inline std::function isolation; -inline void prompt(const char *message = "Hold each tap for a moment") { +inline void prompt(const char *message = nullptr) { + if (!message) message = screen_text::tr(screen_text::txt::calibration_hold); lv_obj_set_pos(cross, TARGETS[point].x-12, TARGETS[point].y-12); - lv_label_set_text_fmt(instructions, "Tap the crosshair\nPoint %u/5 - tap %u/3", point+1, sample+1); + lv_label_set_text(instructions, screen_text::fill(screen_text::fill(screen_text::txt::calibration_crosshair, "point", (int) point + 1), "tap", std::to_string(sample + 1)).c_str()); lv_label_set_text(status, message); } inline void begin() { @@ -55,13 +57,13 @@ inline void press() { if (active) { down_at = esphome::millis(); pressed = true; inline void release(int raw_x, int raw_y) { if (!active || !pressed) return; pressed = false; - if (esphome::millis()-down_at < 80 || raw_x < 0 || raw_x > 4095 || raw_y < 0 || raw_y > 4095) { prompt("Tap gently, a bit longer"); return; } + if (esphome::millis()-down_at < 80 || raw_x < 0 || raw_x > 4095 || raw_y < 0 || raw_y > 4095) { prompt(screen_text::tr(screen_text::txt::calibration_gently)); return; } samples[point][sample++] = {double(raw_x), double(raw_y)}; if (sample < 3) { prompt(); return; } double xs[3], ys[3]; for (int i=0;i<3;++i) { xs[i]=samples[point][i].x; ys[i]=samples[point][i].y; } std::sort(xs,xs+3); std::sort(ys,ys+3); - if (xs[2]-xs[0]>180 || ys[2]-ys[0]>180) { sample=0; prompt("Tap the same crosshair 3 times"); return; } + if (xs[2]-xs[0]>180 || ys[2]-ys[0]>180) { sample=0; prompt(screen_text::tr(screen_text::txt::calibration_same)); return; } point++; sample=0; if (point < 5) { prompt(); return; } std::array raw; @@ -70,8 +72,8 @@ inline void release(int raw_x, int raw_y) { std::sort(xs,xs+3);std::sort(ys,ys+3);raw[n]={xs[1],ys[1]}; } Calibration next = calibration; - if (!fit(raw,next)) { point=0;prompt("Measurement rejected; try again");return; } - if (!preference.save(&next) || !esphome::global_preferences->sync()) { point=0;prompt("Save failed; try again");return; } + if (!fit(raw,next)) { point=0;prompt(screen_text::tr(screen_text::txt::calibration_rejected));return; } + if (!preference.save(&next) || !esphome::global_preferences->sync()) { point=0;prompt(screen_text::tr(screen_text::txt::calibration_save_failed));return; } calibration=next; if (apply) apply(calibration); active=false; diff --git a/components/smart_display/effects_page.h b/components/smart_display/effects_page.h index 66d7dcc..3ccdf55 100644 --- a/components/smart_display/effects_page.h +++ b/components/smart_display/effects_page.h @@ -7,6 +7,7 @@ #include #include #include "runtime_model.h" +#include "screen_text.h" // A light's effects page (firmware 0.2.70+, app 0.2.83+): the modes of a lamp such as a WLED, reached from the // sparkles key at the top right of the light's colour card. One card of rows, each the name of something the lamp @@ -273,7 +274,7 @@ inline void received(const std::string &for_entity, unsigned page, unsigned page options.pages = std::max(1u, pages); if (options.next < options.pages && options.names.size() < MAX_NAMES) { options.asked_at = clock(); if (ask) ask(options.entity, options.next); return; } options.complete = true; - if (options.names.empty()) { if (roller_note) lv_label_set_text(roller_note, "Nothing to choose"); return; } + if (options.names.empty()) { if (roller_note) lv_label_set_text(roller_note, screen_text::tr(screen_text::txt::effects_nothing)); return; } show_roller(); } inline void picker_back(lv_event_t *) { if (steady()) close_picker(); } @@ -287,7 +288,7 @@ inline void open_picker(int index) { top_bar(picker, m, row.name, picker_back, "\U000F012C", confirm_event); const int w = m.width - 2 * m.pad, h = m.roller_rows * m.roller_row_h + 2 * m.roller_pad; auto *holder = card(picker, m.pad, m.rows_y, w, h, m.radius); - roller_note = text(holder, "Loading...", row_font, theme::MUTED, LV_TEXT_ALIGN_CENTER); + roller_note = text(holder, screen_text::tr(screen_text::txt::effects_loading), row_font, theme::MUTED, LV_TEXT_ALIGN_CENTER); lv_obj_set_width(roller_note, w - 2 * m.inset); lv_obj_center(roller_note); // The names of the same entity from a moment ago serve again; anything else is asked for afresh. @@ -296,7 +297,7 @@ inline void open_picker(int index) { options.entity = row.entity; options.asked_at = clock(); if (ask) ask(row.entity, 0); - else lv_label_set_text(roller_note, "Not connected"); + else lv_label_set_text(roller_note, screen_text::tr(screen_text::txt::effects_not_connected)); } // ---- the page ---- @@ -331,7 +332,7 @@ inline void draw() { // The rows: the light's effect first, then the selects of its device. struct Spec { std::string entity, name, current, fallback; uint32_t icon; bool light; }; std::vector specs; - if (t && (t->supported & EFFECT_FEATURE)) specs.push_back({t->entity, "Effect", t->extra().effect, "\U000F0674", 0, true}); + if (t && (t->supported & EFFECT_FEATURE)) specs.push_back({t->entity, screen_text::tr(screen_text::txt::effects_effect), t->extra().effect, "\U000F0674", 0, true}); if (t) for (auto &r : t->extra().option_rows) specs.push_back({r.entity, r.name, r.current, "\U000F0411", r.icon, false}); int y = m.rows_y; if (!specs.empty()) { diff --git a/components/smart_display/header_bar.h b/components/smart_display/header_bar.h index 5d4f4a5..25e7169 100644 --- a/components/smart_display/header_bar.h +++ b/components/smart_display/header_bar.h @@ -3,6 +3,7 @@ #include #include #include +#include "screen_text.h" namespace header_bar { // The top bar right of the screen name (firmware 0.2.32+). ESP Screen Manager decides what shows @@ -65,33 +66,35 @@ inline uint32_t next_codepoint(const std::string &s, size_t &i) { } // Relative time in the editor's words (app.js agoText): "Just now", "5 min ago", "Yesterday", -// "In 2 hours". `now` 0 means the clock is not set yet. +// "In 2 hours", in the screen's language (screen.time, app 0.2.90). `now` 0 means the clock is not set yet. inline std::string ago_text(int64_t then, int64_t now) { + using namespace screen_text; if (now <= 0 || then <= 0) return "—"; int64_t seconds = now - then, span = seconds < 0 ? -seconds : seconds; - auto n = [&](int64_t unit) { return std::to_string(span / unit); }; + auto n = [&](int64_t unit) { return static_cast(span / unit); }; if (seconds < 0) { - if (span < 3600) return "In " + std::to_string(std::max(1, span / 60)) + " min"; - if (span < 86400) return span / 3600 == 1 ? "In 1 hour" : "In " + n(3600) + " hours"; - if (span < 172800) return "Tomorrow"; - return "In " + n(86400) + " days"; + if (span < 3600) return fill(txt::time_in_minutes, "n", std::max(1, n(60))); + if (span < 86400) return plural(txt::time_in_hours, n(3600)); + if (span < 172800) return tr(txt::time_tomorrow); + return plural(txt::time_in_days, n(86400)); } - if (span < 60) return "Just now"; - if (span < 3600) return n(60) + " min ago"; - if (span < 86400) return span / 3600 == 1 ? "1 hour ago" : n(3600) + " hours ago"; - if (span < 172800) return "Yesterday"; - if (span < 604800) return n(86400) + " days ago"; - if (span < 2592000) return span / 604800 == 1 ? "1 week ago" : n(604800) + " weeks ago"; - if (span < 31536000) return span / 2592000 == 1 ? "1 month ago" : n(2592000) + " months ago"; - return span / 31536000 == 1 ? "1 year ago" : n(31536000) + " years ago"; + if (span < 60) return tr(txt::time_just_now); + if (span < 3600) return fill(txt::time_minutes_ago, "n", n(60)); + if (span < 86400) return plural(txt::time_hours_ago, n(3600)); + if (span < 172800) return tr(txt::time_yesterday); + if (span < 604800) return plural(txt::time_days_ago, n(86400)); + if (span < 2592000) return plural(txt::time_weeks_ago, n(604800)); + if (span < 31536000) return plural(txt::time_months_ago, n(2592000)); + return plural(txt::time_years_ago, n(31536000)); } -// "Mo 14 Sep"; day_of_week 1 is Sunday, as ESPHome counts. +// "Mo 14 Sep" in English, "ma 14 sep" in Dutch (screen.date.top_bar); day_of_week 1 is Sunday, as ESPHome counts. inline std::string date_text(int day_of_week, int day_of_month, int month) { - static const char *days[] = {"Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"}; - static const char *months[] = {"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"}; + using namespace screen_text; if (day_of_week < 1 || day_of_week > 7 || month < 1 || month > 12) return "—"; - return std::string(days[day_of_week - 1]) + " " + std::to_string(day_of_month) + " " + months[month - 1]; + std::string text = fill(txt::date_top_bar, "weekday", tr(txt::date_weekdays_min + day_of_week - 1)); + text = fill(text, "day", std::to_string(day_of_month)); + return fill(text, "month", tr(txt::date_months_short + month - 1)); } // What you see between icon and value, between two items and after the name, from the height of diff --git a/components/smart_display/history_view.h b/components/smart_display/history_view.h index 619dc09..605125a 100644 --- a/components/smart_display/history_view.h +++ b/components/smart_display/history_view.h @@ -9,6 +9,7 @@ #include #include #include +#include "screen_text.h" namespace history_view { constexpr int PARTS = 24; @@ -22,18 +23,20 @@ struct Run { }; // A clock time of a unix time in the screen's time zone (`offset` seconds east of UTC), as the screen's 12- or -// 24-hour setting writes it; `weekday` puts the day in front ("Tue 14:05") for a week. +// 24-hour setting writes it; `weekday` puts the day in front ("Tue 14:05") for a week, in the screen's language. inline std::string clock(int64_t epoch, int32_t offset, bool h24, bool weekday) { + using namespace screen_text; int64_t local = epoch + offset; int64_t days = local >= 0 ? local / 86400 : (local - 86399) / 86400; int seconds = static_cast(local - days * 86400); int hour = seconds / 3600, minute = seconds % 3600 / 60; - static const char *const names[] = {"Thu", "Fri", "Sat", "Sun", "Mon", "Tue", "Wed"}; char b[24]; if (h24) snprintf(b, sizeof(b), "%02d:%02d", hour, minute); - else snprintf(b, sizeof(b), "%d:%02d %s", hour % 12 ? hour % 12 : 12, minute, hour < 12 ? "AM" : "PM"); + else snprintf(b, sizeof(b), "%d:%02d %s", hour % 12 ? hour % 12 : 12, minute, tr(hour < 12 ? txt::time_am : txt::time_pm)); if (!weekday) return b; - return std::string(names[((days % 7) + 7) % 7]) + " " + b; + // Day 0 of the unix epoch was a Thursday; the names count from Sunday. + int sunday_first = static_cast((((days + 4) % 7) + 7) % 7); + return fill(fill(txt::date_weekday_time, "weekday", tr(txt::date_weekdays_short + sunday_first)), "time", b); } // A time under a graph: "06:00" on a 24-hour clock, "6 AM" or "6:15 AM" on a 12-hour one. @@ -45,25 +48,25 @@ inline std::string axis_clock(int64_t epoch, int32_t offset, bool h24) { return text; } -// "45 s", "12 min", "3 h 20 min", "2 d 4 h": the time spent in a state. +// "45 s", "12 min", "3 h 20 min", "2 d 4 h": the time spent in a state, in the screen's language (screen.duration). inline std::string duration(uint32_t seconds) { - char b[32]; - if (seconds < 60) snprintf(b, sizeof(b), "%u s", static_cast(seconds)); - else if (seconds < 3600) snprintf(b, sizeof(b), "%u min", static_cast(seconds / 60)); - else if (seconds < 86400) { - unsigned minutes = seconds / 60 % 60; - if (minutes) snprintf(b, sizeof(b), "%u h %u min", static_cast(seconds / 3600), minutes); - else snprintf(b, sizeof(b), "%u h", static_cast(seconds / 3600)); - } else { - unsigned hours = seconds / 3600 % 24; - if (hours) snprintf(b, sizeof(b), "%u d %u h", static_cast(seconds / 86400), hours); - else snprintf(b, sizeof(b), "%u d", static_cast(seconds / 86400)); + using namespace screen_text; + const int s = static_cast(seconds); + if (s < 60) return fill(txt::duration_seconds, "n", s); + if (s < 3600) return fill(txt::duration_minutes, "n", s / 60); + if (s < 86400) { + int minutes = s / 60 % 60; + if (!minutes) return fill(txt::duration_hours, "n", s / 3600); + return fill(fill(txt::duration_hours_minutes, "h", s / 3600), "m", std::to_string(minutes)); } - return b; + int hours = s / 3600 % 24; + if (!hours) return fill(txt::duration_days, "n", s / 86400); + return fill(fill(txt::duration_days_hours, "d", s / 86400), "h", std::to_string(hours)); } -// A value as Home Assistant writes it: fixed decimals, commas between thousands, "%" and "°" on the number and -// any other unit after a space. +// A value as Home Assistant writes it: fixed decimals, the language's separator between thousands and its decimal +// mark (screen.number: "1,234.5" in English, "1.234,5" in Dutch), "%" and "°" on the number and any other unit after +// a space. inline std::string number(float value, int decimals, const std::string &unit) { if (!std::isfinite(value)) return "--"; char b[40]; @@ -72,8 +75,10 @@ inline std::string number(float value, int decimals, const std::string &unit) { if (text[0] == '-') { sign = "-"; text.erase(0, 1); } auto dot = text.find('.'); std::string whole = text.substr(0, dot), rest = dot == std::string::npos ? "" : text.substr(dot); - for (int i = static_cast(whole.size()) - 3; i > 0; i -= 3) whole.insert(static_cast(i), ","); - bool zero = whole.find_first_not_of("0,") == std::string::npos && rest.find_first_not_of(".0") == std::string::npos; + bool zero = whole.find_first_not_of('0') == std::string::npos && rest.find_first_not_of(".0") == std::string::npos; + const std::string group = screen_text::group_mark(); + for (int i = static_cast(whole.size()) - 3; i > 0; i -= 3) whole.insert(static_cast(i), group); + if (!rest.empty()) rest[0] = screen_text::decimal_mark(); text = (zero ? "" : sign) + whole + rest; if (unit.empty()) return text; if (unit == "%" || unit == "°") return text + unit; diff --git a/components/smart_display/light_controls.h b/components/smart_display/light_controls.h index 6164d7e..4e2dd34 100644 --- a/components/smart_display/light_controls.h +++ b/components/smart_display/light_controls.h @@ -4,6 +4,7 @@ #include #include #include +#include "screen_text.h" namespace light_controls { inline int clamp(int value, int low, int high) { return std::max(low, std::min(high, value)); } @@ -93,7 +94,7 @@ inline void preview(Row &row) { lv_obj_set_style_bg_opa(row.slider, row.off ? LV_OPA_TRANSP : LV_OPA_COVER, LV_PART_INDICATOR); lv_obj_set_style_bg_opa(row.slider, row.off ? LV_OPA_TRANSP : LV_OPA_COVER, LV_PART_KNOB); lv_obj_set_style_bg_color(row.slider, theme::color(row.off ? theme::TRACK : theme::AMBER_TRACK), LV_PART_MAIN); - if (row.off) lv_label_set_text(row.value, "Off"); + if (row.off) lv_label_set_text(row.value, screen_text::tr(screen_text::txt::ha_off)); else lv_label_set_text_fmt(row.value, "%d %%", value); } } @@ -182,7 +183,7 @@ inline void setup(lv_obj_t *parent, const lv_font_t *font, int width, int height int margin = large ? 20 : 12, w = width - 2 * margin, card_h = spacing - (large ? 12 : 5); int inset = large ? 18 : 10, text_y = large ? 14 : 5, track_h = large ? 32 : 18; int track_x = inset, track_w = w - 2 * inset, track_y = card_h - inset + (large ? 2 : 3) - track_h, radius = track_h / 2; - const char *names[] = {"Color", "Color temperature", "Brightness"}; + const char *names[] = {screen_text::tr(screen_text::txt::light_color), screen_text::tr(screen_text::txt::light_color_temperature), screen_text::tr(screen_text::txt::light_brightness)}; const char *icons[] = {"\U000F03D8", "\U000F050F", "\U000F0335"}; for (unsigned i = 0; i < 3; ++i) { auto &row = rows[i]; row.index = i; @@ -287,7 +288,7 @@ inline void open(const std::string &entity, bool color, bool temperature, int br lv_slider_set_value(row.slider, i == 0 ? active->hue : i == 1 ? active->kelvin : clamp(brightness, 1, 100), LV_ANIM_OFF); row.off = i == 2 && brightness <= 0; preview(row); row.dirty = false; - if (i == 1 && !active->temperature_ready()) lv_label_set_text(row.value, "Waiting..."); + if (i == 1 && !active->temperature_ready()) lv_label_set_text(row.value, screen_text::tr(screen_text::txt::light_waiting)); } } inline bool self_test() { diff --git a/components/smart_display/media_card.h b/components/smart_display/media_card.h index 1ae72e2..61007e1 100644 --- a/components/smart_display/media_card.h +++ b/components/smart_display/media_card.h @@ -12,6 +12,7 @@ #include #include #include +#include "screen_text.h" namespace media_card { struct Rect { @@ -145,11 +146,11 @@ inline std::string subtitle(const std::string &artist, const std::string &album) } // What the card says instead of a title when nothing plays. inline const char *idle_text(const std::string &state) { - if (state == "off") return "Off"; - if (state == "standby") return "Standby"; - if (state == "buffering") return "Loading"; - if (state == "unavailable") return "Unavailable"; - return "Not playing"; + if (state == "off") return screen_text::tr(screen_text::txt::ha_off); + if (state == "standby") return screen_text::tr(screen_text::txt::ha_media_standby); + if (state == "buffering") return screen_text::tr(screen_text::txt::media_loading); + if (state == "unavailable") return screen_text::tr(screen_text::txt::tile_unavailable); + return screen_text::tr(screen_text::txt::media_not_playing); } inline bool playing(const std::string &state) { return state == "playing" || state == "buffering"; } inline bool has_track(const std::string &state) { return state == "playing" || state == "paused" || state == "buffering"; } diff --git a/components/smart_display/runtime_model.h b/components/smart_display/runtime_model.h index cd1332a..c2c920a 100644 --- a/components/smart_display/runtime_model.h +++ b/components/smart_display/runtime_model.h @@ -5,6 +5,7 @@ #include #include #include +#include "screen_text.h" #include #include #include @@ -429,7 +430,7 @@ struct Model { // Pages the manager wants shown even when the last ones are still empty (0.2.26+). uint8_t pages = 1; size_t count = 0; - std::string title = "Choose tiles in HA"; + std::string title = screen_text::tr(screen_text::txt::status_choose_tiles); bool configured = false; // Why the last set_layout refused a layout, for the manager's inbox status; empty when it took the layout or the // message itself was wrong (the status then says "invalid message", as before). @@ -469,7 +470,7 @@ struct Model { return false; } // A title-only update must not interrupt an open control card. - title = name.empty() ? "Home" : name; + title = name.empty() ? std::string(screen_text::tr(screen_text::txt::status_home)) : name; if (changed) { // Freed before the new list is made, so the heap never holds both. tiles.clear(); diff --git a/components/smart_display/runtime_tiles.h b/components/smart_display/runtime_tiles.h index 478e1b3..8fef675 100644 --- a/components/smart_display/runtime_tiles.h +++ b/components/smart_display/runtime_tiles.h @@ -25,6 +25,11 @@ #include namespace runtime_tiles { +// What the screen says, in the language its firmware was built for (screen_text.h, app 0.2.90). +using screen_text::fill; +using screen_text::plural; +using screen_text::tr; +namespace txt = screen_text::txt; inline bool enabled = false; // Swiping, rotation and going back to page 1 live in settings_screen, next to the other settings the // screen can change itself; these names stay as the way the rest of the firmware reaches them. @@ -39,6 +44,8 @@ inline esphome::ESPPreferenceObject buttons_preference; inline esphome::ESPPreferenceObject swipe_preference; inline esphome::ESPPreferenceObject home_preference; inline esphome::ESPPreferenceObject dark_preference; +// The number format of Settings -> Language & region (app 0.2.90), kept for the next start: screen_text::number_style. +inline esphome::ESPPreferenceObject numbers_preference; inline Model model; inline std::string inbox; inline const lv_font_t *watch_font = nullptr; @@ -144,6 +151,9 @@ inline void load_settings() { home_preference = esphome::global_preferences->make_preference(0x484F4D31); dark_preference = esphome::global_preferences->make_preference(0x44524B31); buttons_preference = esphome::global_preferences->make_preference(0x50474231); + numbers_preference = esphome::global_preferences->make_preference(0x4E554D31); + uint32_t numbers_saved=0; + if(numbers_preference.load(&numbers_saved) && numbers_saved<=3)screen_text::number_style=(uint8_t)numbers_saved; uint32_t swipe_saved=0; if(swipe_preference.load(&swipe_saved))swipe_pages=swipe_saved==1; uint32_t dark_saved=0; @@ -296,6 +306,10 @@ inline std::string receive(const std::string &payload) { root["auto_home_seconds"].as()<30 || root["auto_home_seconds"].as()>3600))return false; if(!root["rotation"].isNull() && (!root["rotation"].is() || root["rotation"].as()>270 || root["rotation"].as()%90!=0))return false; + // The clock and the number format of Settings -> Language & region (app 0.2.90), the same for every screen. + if(!root["clock_24h"].isNull() && !root["clock_24h"].is())return false; + if(!root["numbers"].isNull() && (!root["numbers"].is() || + screen_text::number_style_of(root["numbers"].as())<0))return false; if(!root["keepalive"].isNull() && (!root["keepalive"].is() || root["keepalive"].as()<5 || root["keepalive"].as()>3600))return false; // Explicit grid positions (0.2.26+), one absolute slot per entity; absent on older managers. @@ -330,6 +344,12 @@ inline std::string receive(const std::string &payload) { HomeTimeout home{(uint32_t)(auto_home?1:0),(uint32_t)auto_home_seconds};home_preference.save(&home); } bool rotation_changed=false; + if(root["clock_24h"].is())settings.clock_24h=root["clock_24h"].as()?1:0; + bool format_changed=false; + if(root["numbers"].is()){ + int style=screen_text::number_style_of(root["numbers"].as()); + if(style!=screen_text::number_style){screen_text::number_style=(uint8_t)style;uint32_t saved=(uint32_t)style;numbers_preference.save(&saved);format_changed=true;} + } if(rotation_supported && root["rotation"].is() && rotation!=root["rotation"].as()){ rotation=root["rotation"].as();rotation_preference.save(&rotation);rotation_changed=true; } @@ -347,7 +367,7 @@ inline std::string receive(const std::string &payload) { // A repeat of the layout on screen (the hourly repeat, a save that changed nothing here) only // refreshes the feed: drawing the whole page again stalls touch input for a few hundred ms, // long enough to spoil a slider drag. The tile states that follow redraw their own tiles. - if (changed || moved || !was_configured || rotation_changed || model.pages != previous_pages || model.title != previous_title) { + if (changed || moved || !was_configured || rotation_changed || format_changed || model.pages != previous_pages || model.title != previous_title) { if (layout_changed) layout_changed(); refresh_all(); } @@ -887,20 +907,20 @@ inline lv_obj_t *detail_label(lv_obj_t *parent,const std::string &text,int x,int lv_label_set_long_mode(label,LV_LABEL_LONG_DOT);lv_obj_set_height(label,lv_font_get_line_height(detail_font));return label; } inline std::string detail_state(const Tile &t){ - if(t.domain()=="person")return t.state=="home"?"Home":t.state=="not_home"?"Away":t.state; - if(t.domain()=="sun")return t.state=="above_horizon"?"Above the horizon":"Below the horizon"; + if(t.domain()=="person")return t.state=="home"?tr(txt::ha_person_home):t.state=="not_home"?tr(txt::ha_person_not_home):t.state; + if(t.domain()=="sun")return tr(t.state=="above_horizon"?txt::ha_sun_above_horizon:txt::ha_sun_below_horizon); if(t.domain()=="timer")return timer_text(t); - if(t.domain()=="script"||t.domain()=="scene"||t.domain()=="button"||t.domain()=="input_button")return t.state=="on"?"Running...":last_run_text(t.last_run); + if(t.domain()=="script"||t.domain()=="scene"||t.domain()=="button"||t.domain()=="input_button")return t.state=="on"?tr(txt::script_running):last_run_text(t.last_run); if(t.domain()=="binary_sensor"&&(t.state=="on"||t.state=="off"))return tile_controls::binary_state_text(t.device_class,t.state=="on"); - if(t.state=="on")return "On"; - if(t.state=="off")return "Off"; - if(t.state=="docked")return "Docked"; - if(t.state=="cleaning")return "Cleaning"; - if(t.state=="paused")return "Paused"; - if(t.state=="returning")return "Returning to dock"; - if(t.state=="idle")return "Idle"; - if(t.state=="error")return "Check the robot in HA"; - if(!t.available())return "Unavailable"; + if(t.state=="on")return tr(txt::ha_on); + if(t.state=="off")return tr(txt::ha_off); + if(t.state=="docked")return tr(txt::ha_vacuum_docked); + if(t.state=="cleaning")return tr(txt::ha_vacuum_cleaning); + if(t.state=="paused")return tr(txt::ha_vacuum_paused); + if(t.state=="returning")return tr(txt::ha_vacuum_returning); + if(t.state=="idle")return tr(txt::ha_vacuum_idle); + if(t.state=="error")return tr(txt::vacuum_check_robot); + if(!t.available())return tr(txt::tile_unavailable); // Home Assistant's word where the screen has none of its own (firmware 0.2.58+). if(!t.extra().state_word.empty())return t.extra().state_word; return t.state; @@ -1068,10 +1088,10 @@ inline lv_obj_t *detail_text(lv_obj_t *parent,const std::string &text,int x,int inline std::string rain_text(float chance,float mm,bool with_mm){ char b[32]; if(std::isfinite(chance) && chance>=0){ - if(with_mm && std::isfinite(mm) && mm>=0.05f){snprintf(b,sizeof(b),"%d%% · %.1f mm",(int)std::lround(chance),mm);return b;} + if(with_mm && std::isfinite(mm) && mm>=0.05f){snprintf(b,sizeof(b),"%d%% · ",(int)std::lround(chance));return b+screen_text::decimal(mm,1)+" mm";} snprintf(b,sizeof(b),"%d%%",(int)std::lround(chance));return b; } - if(std::isfinite(mm) && mm>=0.05f){snprintf(b,sizeof(b),mm<10?"%.1f mm":"%.0f mm",mm);return b;} + if(std::isfinite(mm) && mm>=0.05f)return screen_text::decimal(mm,mm<10?1:0)+" mm"; return ""; } inline std::string degrees(float value){ if(!std::isfinite(value))return "--"; char b[16];snprintf(b,sizeof(b),"%.0f°",value);return b; } @@ -1111,9 +1131,9 @@ inline void render_weather_detail(const Tile &t,bool large,int width,int height, detail_text(now,degrees(t.current),temp_x,cy+(hero-big_h)/2,temp_w,big,LV_TEXT_ALIGN_LEFT,ink); int text_x=temp_x+temp_w+(large?4:2),text_w=width-2*pad-card_pad-text_x; int lines_h=text_h+small_h+(large?2:0); - detail_text(now,t.available()?weather_text(t.state):"Unavailable",text_x,cy+(hero-lines_h)/2,text_w,detail_font,LV_TEXT_ALIGN_LEFT,ink); + detail_text(now,t.available()?weather_text(t.state):tr(txt::tile_unavailable),text_x,cy+(hero-lines_h)/2,text_w,detail_font,LV_TEXT_ALIGN_LEFT,ink); std::string details; - if(std::isfinite(weather.feels)){snprintf(b,sizeof(b),"Feels like %.0f°",weather.feels);details=b;} + if(std::isfinite(weather.feels))details=fill(txt::weather_feels_like,"n",(int)std::lround(weather.feels)); if(std::isfinite(t.humidity)){snprintf(b,sizeof(b),"%d%%",(int)std::lround(t.humidity));details+=(details.empty()?"":" · ")+std::string(b);} if(std::isfinite(weather.wind)){snprintf(b,sizeof(b),"%.0f %s",weather.wind,weather.wind_unit.empty()?"km/h":weather.wind_unit.c_str());details+=(details.empty()?"":" · ")+std::string(b);} detail_text(now,details,text_x,cy+(hero-lines_h)/2+text_h+(large?2:0),text_w,small,LV_TEXT_ALIGN_LEFT,muted); @@ -1130,8 +1150,8 @@ inline void render_weather_detail(const Tile &t,bool large,int width,int height, } y+=card_a_h+(large?12:6); // Coming days: a heading and a card with one row per day. - if(!weather.forecast.size()){detail_text(detail_root,"No daily forecast from Home Assistant",pad,y,width-2*pad,small,LV_TEXT_ALIGN_LEFT,muted);return;} - if(large){detail_text(detail_root,"Coming days",pad+4,y,width-2*pad,detail_font,LV_TEXT_ALIGN_LEFT,muted);y+=text_h+8;} + if(!weather.forecast.size()){detail_text(detail_root,tr(txt::weather_no_forecast),pad,y,width-2*pad,small,LV_TEXT_ALIGN_LEFT,muted);return;} + if(large){detail_text(detail_root,tr(txt::weather_coming_days),pad+4,y,width-2*pad,detail_font,LV_TEXT_ALIGN_LEFT,muted);y+=text_h+8;} int card_b_h=height-y-(large?10:4); auto *days=detail_card(pad,y,width-2*pad,card_b_h); int row_pad=large?8:4,row=(card_b_h-2*row_pad)/(int)weather.forecast.size(); @@ -1275,7 +1295,7 @@ inline void render_vacuum_detail(Tile &t,bool large,int width,int height,int pad bool cleaning=t.state=="cleaning",paused=t.state=="paused"; bool battery=std::isfinite(t.battery),on_the_way=cleaning||paused||t.state=="returning"; int inner=width-2*pad,gap=large?12:6,radius=lv_obj_get_style_radius(widgets[0].tile,LV_PART_MAIN); - std::string state=t.loading(now)?"Command sent...":detail_state(t); + std::string state=t.loading(now)?tr(txt::tile_command_sent):detail_state(t); // A robot with little to set gets a hero on the small screen too; one with mode and water rows uses a status row. bool small_hero=!large && !mode && !t.choice('w'); int y=large?92:52; @@ -1314,12 +1334,12 @@ inline void render_vacuum_detail(Tile &t,bool large,int width,int height,int pad } // Start (or pause, or resume) as the one blue button, dock beside it. int action_h=large?54:(small_hero?40:36),start_w=large?(inner-gap)*2/3:(inner-gap)/2; - vacuum_command(cleaning?"\U000F03E4":"\U000F040A",cleaning?(large?"Pause cleaning":"Pause"):paused?"Resume":(large?"Start cleaning":"Clean"), + vacuum_command(cleaning?"\U000F03E4":"\U000F040A",tr(cleaning?(large?txt::vacuum_pause_cleaning:txt::vacuum_pause):paused?txt::vacuum_resume:(large?txt::vacuum_start_cleaning:txt::vacuum_clean)), pad,y,start_w,action_h,cleaning?1:0,true,text,icons,large?radius:action_h/2); - vacuum_command("\U000F05F8","Dock",pad+start_w+gap,y,inner-start_w-gap,action_h,2,false,text,icons,large?radius:action_h/2); + vacuum_command("\U000F05F8",tr(txt::vacuum_dock),pad+start_w+gap,y,inner-start_w-gap,action_h,2,false,text,icons,large?radius:action_h/2); y+=action_h+gap; if(!mode && !suction && !water){ - auto *note=detail_text(detail_root,"Automatic suction power",pad,y+gap,inner,text,LV_TEXT_ALIGN_CENTER,theme::SUBTLE);(void)note; + auto *note=detail_text(detail_root,tr(txt::vacuum_auto_suction),pad,y+gap,inner,text,LV_TEXT_ALIGN_CENTER,theme::SUBTLE);(void)note; return; } // How it cleans. The Guition groups the rows on one white card; the small screen has no room for a card. @@ -1340,7 +1360,7 @@ inline void render_vacuum_detail(Tile &t,bool large,int width,int height,int pad if(water)level(*water,60,"\U000F058C"); if(automatic){ bool smart=tile_controls::shown_value(t,*mode,now).find("smart")!=std::string::npos; - detail_text(detail_root,smart?"The robot chooses suction and water":"Suction and water as set per room",x,y,w,text,LV_TEXT_ALIGN_CENTER,theme::SUBTLE); + detail_text(detail_root,tr(smart?txt::vacuum_robot_chooses:txt::vacuum_per_room),x,y,w,text,LV_TEXT_ALIGN_CENTER,theme::SUBTLE); } } // ---- Cover card (firmware 0.2.50+): Home Assistant's cover dialog in the style of the vacuum and climate cards ---- @@ -1353,7 +1373,7 @@ inline constexpr uint32_t COVER_ACCENT = theme::ha::PURPLE; inline uint32_t cover_track(){return theme::tint(COVER_ACCENT,37);} inline uint32_t cover_slats(){return theme::tint(COVER_ACCENT,80);} inline lv_obj_t *cover_values[2]{}; -inline std::string cover_status_line(const Tile &t){return t.available()?tile_controls::cover_card_status(t):"Unavailable";} +inline std::string cover_status_line(const Tile &t){return t.available()?tile_controls::cover_card_status(t):tr(txt::tile_unavailable);} inline void cover_slider_event(lv_event_t *e){ auto *slider=lv_event_get_target_obj(e);auto code=lv_event_get_code(e); const bool tilt=(uintptr_t)lv_event_get_user_data(e)==1; @@ -1462,8 +1482,8 @@ inline void render_cover_detail(Tile &t,bool large,int width,int height,int pad) detail_card(pad,top,inner,box_h); struct Part{bool tilt;float value;const char *caption;}; Part parts[2];int n=0; - if(card.position)parts[n++]={false,t.position,"Position"}; - if(card.tilt)parts[n++]={true,t.extra().tilt,"Tilt"}; + if(card.position)parts[n++]={false,t.position,tr(txt::cover_position)}; + if(card.tilt)parts[n++]={true,t.extra().tilt,tr(txt::cover_tilt)}; int value_h=lv_font_get_line_height(big),caption_h=lv_font_get_line_height(text); auto percent=[](float value){return std::isfinite(value)?std::to_string((int)std::lround(std::clamp(value,0.0f,100.0f)))+"%":std::string("--");}; if(large){ @@ -1550,7 +1570,7 @@ inline bool history_fits(const Tile &t){ // Home Assistant's word for the state now: from this entity's history words, else the card's own. The history can // still be the previous card's while this one waits for its answer. inline std::string history_words(const Tile &t){ - if(!t.available())return "Unavailable"; + if(!t.available())return tr(txt::tile_unavailable); if(history.entity==t.entity)for(const auto &pair:history.words)if(pair.first==t.state)return pair.second; return detail_state(t); } @@ -1602,16 +1622,16 @@ inline void history_scrub(lv_event_t *e){ const int i=history_view::part_at(fraction); const uint64_t span=h.end-h.start; const uint32_t begin=h.start+static_cast(span*i/history_view::PARTS),finish=h.start+static_cast(span*(i+1)/history_view::PARTS); - label(c.value,h.has[i]?history_view::number(h.values[i],h.decimals,h.unit):"No data"); + label(c.value,h.has[i]?history_view::number(h.values[i],h.decimals,h.unit):std::string(tr(txt::history_no_data))); lv_obj_set_style_text_color(c.value,lv_color_hex(h.has[i]?theme::foreground(c.accent):theme::hex(theme::SUBTLE)),0); if(c.first)label(c.first,history_clock(begin,week)+" \u2013 "+history_clock(finish,false)); - if(c.second)label(c.second,!h.has[i]?"":h.hours==1?"average":"average of "+history_view::duration(finish-begin)); + if(c.second)label(c.second,!h.has[i]?std::string():h.hours==1?std::string(tr(txt::history_average)):fill(txt::history_average_of,"duration",history_view::duration(finish-begin))); }else{ // A run reads the real times of its state, not the slots it covers: a door open for 5 minutes says 5 min. const int r=history_view::run_at(h.runs,h.slots,fraction);if(r<0)return; const auto &run=h.runs[r]; const uint32_t begin=h.start+run.begin,finish=h.start+run.end; - label(c.value,run.state<0||run.state>=static_cast(h.states.size())?"No data":h.states[run.state].label); + label(c.value,run.state<0||run.state>=static_cast(h.states.size())?std::string(tr(txt::history_no_data)):h.states[run.state].label); if(c.first)label(c.first,history_clock(begin,week)+" \u2013 "+history_clock(finish,week&&finish-begin>=43200)); if(c.second)label(c.second,history_view::duration(run.seconds)); } @@ -1627,8 +1647,8 @@ inline void history_touch(int x,int y,int w,int h){ inline void history_times(int x,int w,int y,const lv_font_t *font,bool large){ const auto &h=history; const lv_font_t *bold=detail_font?detail_font:font; - const int now_w=text_width("Now",bold),gap=large?10:6; - detail_text(detail_root,"Now",x+w-now_w,y,now_w+2,bold,LV_TEXT_ALIGN_LEFT,theme::INK); + const int now_w=text_width(tr(txt::history_now),bold),gap=large?10:6; + detail_text(detail_root,tr(txt::history_now),x+w-now_w,y,now_w+2,bold,LV_TEXT_ALIGN_LEFT,theme::INK); int last=x-1000; for(uint32_t at:h.times){ if(at<=h.start||at>=h.end)continue; @@ -1751,7 +1771,7 @@ inline void render_history_timeline(bool large,int card_x,int card_y,int card_w, // waiting for a command does not lock it. inline void history_ranges(int x,int y,int w,int h){ static const uint32_t hours[]={1,24,168}; - static const char *const words[]={"1 hour","24 hours","1 week"}; + const char *const words[]={tr(txt::history_range_hour),tr(txt::history_range_day),tr(txt::history_range_week)}; const lv_font_t *font=control_font?control_font:detail_font; auto *track=detail_shape(detail_root,x,y,w,h,theme::CARD,h/2); lv_obj_set_style_border_width(track,1,0);lv_obj_set_style_border_color(track,theme::color(theme::LINE),0); @@ -1789,7 +1809,7 @@ inline void render_history_detail(const Tile &t,bool large,int width,int height, if(line){ int decimals=0;const auto dot=t.state.find('.');if(dot!=std::string::npos)decimals=static_cast(std::min(4,t.state.size()-dot-1)); if(c.ready)decimals=h.decimals; - c.value_text=!t.available()?"Unavailable":numeric?history_view::number(current,decimals,t.unit):t.state; + c.value_text=!t.available()?std::string(tr(txt::tile_unavailable)):numeric?history_view::number(current,decimals,t.unit):t.state; if(c.ready){ // The value now counts too: it can lie beyond the history (the running hour is not in the statistics yet). if(h.has_high){c.high=h.high;c.high_at=h.high_at;} @@ -1797,13 +1817,13 @@ inline void render_history_detail(const Tile &t,bool large,int width,int height, if(std::isfinite(current)&&std::isfinite(c.high)&¤t>c.high){c.high=current;c.high_at=h.end;} if(std::isfinite(current)&&std::isfinite(c.low)&¤t=0){ c.first_text=h.states[h.active].label+" \u00B7 "+history_view::duration(h.states[h.active].seconds); - c.second_text=h.began==1?"once":h.began?std::to_string(h.began)+" times":""; + c.second_text=h.began?plural(txt::history_times,h.began):""; } } const int row=large?84:50,row_h=lv_font_get_line_height(big),text_h=lv_font_get_line_height(text); @@ -1832,7 +1852,7 @@ inline void render_history_detail(const Tile &t,bool large,int width,int height, } // The value column fits the widest text a finger can show there, so a readout never runs into the texts beside it. int widest=text_width(c.value_text,big); - if(c.ready)widest=std::max(widest,text_width("No data",big)); + if(c.ready)widest=std::max(widest,text_width(tr(txt::history_no_data),big)); if(c.ready&&line){for(int i=0;i= 1 && now.day_of_week <= 7 ? days[now.day_of_week - 1] : ""; + return now.is_valid() && now.day_of_week >= 1 && now.day_of_week <= 7 ? tr(txt::date_weekdays + now.day_of_week - 1) : ""; } inline std::string month_short(const esphome::ESPTime &now) { - static const char *months[] = {"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"}; - return now.is_valid() && now.month >= 1 && now.month <= 12 ? months[now.month - 1] : ""; + return now.is_valid() && now.month >= 1 && now.month <= 12 ? tr(txt::date_months_short + now.month - 1) : ""; } +// "Monday 14 September" in English, "maandag 14 september" in Dutch (screen.date.full). inline std::string date_text(const esphome::ESPTime &now) { - static const char *months[] = {"January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"}; if (!now.is_valid() || now.day_of_week < 1 || now.day_of_week > 7 || now.month < 1 || now.month > 12) return ""; - return weekday_text(now) + " " + std::to_string(now.day_of_month) + " " + months[now.month - 1]; + std::string text = fill(txt::date_full, "weekday", weekday_text(now)); + text = fill(text, "day", std::to_string(now.day_of_month)); + return fill(text, "month", tr(txt::date_months + now.month - 1)); } // Where the finger is, or where it let go, in the screen's coordinates; false without a finger (a test's event). inline bool finger_at(lv_point_t &point) { @@ -2615,9 +2637,9 @@ inline void render_sunpath(Widgets &w,const Tile &t,bool large,int width,int hei const lv_font_t *title_font=lv_obj_get_style_text_font(w.title,LV_PART_MAIN); int title_h=lv_font_get_line_height(title_font),text_h=lv_font_get_line_height(w.value_font); int horizon=height-text_h-(large?4:2),top=title_h+(large?4:2),x0=large?14:8,x1=width-x0; - part_label(w,0,title_font,0,0,width,LV_TEXT_ALIGN_LEFT,t.name.empty()?"Sun":t.name); - part_label(w,1,w.value_font,0,horizon+(large?3:1),width/2,LV_TEXT_ALIGN_LEFT,"rise "+t.extra().sunrise); - part_label(w,2,w.value_font,width/2,horizon+(large?3:1),width/2,LV_TEXT_ALIGN_RIGHT,"set "+t.extra().sunset); + part_label(w,0,title_font,0,0,width,LV_TEXT_ALIGN_LEFT,t.name.empty()?std::string(tr(txt::sun_name)):t.name); + part_label(w,1,w.value_font,0,horizon+(large?3:1),width/2,LV_TEXT_ALIGN_LEFT,fill(txt::sun_rise,"time",t.extra().sunrise)); + part_label(w,2,w.value_font,width/2,horizon+(large?3:1),width/2,LV_TEXT_ALIGN_RIGHT,fill(txt::sun_set,"time",t.extra().sunset)); auto now=now_time?now_time():esphome::ESPTime{}; int rise=minutes_of(t.extra().sunrise),set=minutes_of(t.extra().sunset),minute=now.is_valid()?now.hour*60+now.minute:-1; bool day=t.state=="above_horizon";float fraction=0.5f; @@ -3068,7 +3090,7 @@ inline void render_full(Widgets &w,const Tile &t,bool custom,bool clock,bool sun // The large value font carries digits, the degree sign and the percent sign; the clock font only digits and a colon. std::string middle;const lv_font_t *mid_font=watch_value_font?watch_value_font:w.value_font; auto d=t.domain(); - if(d=="climate" && std::isfinite(t.current)){char b[32];snprintf(b,sizeof(b),"%.1f°",t.current);middle=b;} + if(d=="climate" && std::isfinite(t.current))middle=screen_text::decimal(t.current,1)+"°"; else if(d=="cover" && std::isfinite(t.position)){middle=std::to_string(static_cast(std::lround(t.position)))+" %";} else if(d=="media_player" && !t.extra().media_title.empty()){middle=t.extra().media_title;mid_font=room_label?lv_obj_get_style_text_font(room_label,LV_PART_MAIN):w.title_font;} int mid_font_h=lv_font_get_line_height(mid_font); @@ -3092,34 +3114,34 @@ inline void render_slot(size_t slot) { std::string unit=watch?t.unit:""; std::string value = t.state; // Nothing in Home Assistant stands behind the settings card, so it says the same with the link down. - if (t.is_settings()) value = "Tap to open"; - else if (t.is_page()) value = "Page " + std::to_string(t.page_target()); - else if (!fresh() || !t.available()) value = "Unavailable"; - else if (t.refused_at && esphome::millis() - t.refused_at < 4000) value = "Refused"; + if (t.is_settings()) value = tr(txt::tile_tap_to_open); + else if (t.is_page()) value = fill(txt::tile_page, "n", t.page_target()); + else if (!fresh() || !t.available()) value = tr(txt::tile_unavailable); + else if (t.refused_at && esphome::millis() - t.refused_at < 4000) value = tr(txt::tile_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(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"; + else if (d == "climate" && t.state == "off") { value = tile_controls::climate_mode_text(t.state); if (std::isfinite(t.current)) value += " · " + screen_text::decimal(t.current, 1) + "°"; } + else if (d == "climate" && std::isfinite(t.target)) value = screen_text::decimal(t.target, 1) + "°"; + else if (d == "person") value = t.state=="home"?tr(txt::ha_person_home):t.state=="not_home"?tr(txt::ha_person_not_home):t.state; + else if (d == "sun") value = !t.extra().sunrise.empty() && !t.extra().sunset.empty() ? t.extra().sunrise+" - "+t.extra().sunset : tr(t.state=="above_horizon"?txt::ha_sun_above_horizon:txt::ha_sun_below_horizon); else if (d == "timer") value = timer_text(t); - else if (d == "script" || d == "scene" || d == "button" || d == "input_button") value = t.state == "on" ? "Running..." : last_run_text(t.last_run); - else if (d == "camera") value = t.state == "streaming" ? "Live" : t.state == "recording" ? "Recording" : "Tap to view"; - else if (d == "image") value = t.last_run ? "Tap to view" : "No image yet"; + else if (d == "script" || d == "scene" || d == "button" || d == "input_button") value = t.state == "on" ? std::string(tr(txt::script_running)) : last_run_text(t.last_run); + else if (d == "camera") value = tr(t.state == "streaming" ? txt::camera_live : t.state == "recording" ? txt::camera_recording : txt::camera_tap_to_view); + else if (d == "image") value = tr(t.last_run ? txt::camera_tap_to_view : txt::camera_no_image_yet); else if (d == "binary_sensor" && (value == "on" || value == "off")) value = tile_controls::binary_state_text(t.device_class, value == "on"); - else if (value == "on") value = "On"; - else if (value == "off") value = "Off"; - else if (value == "cleaning") value = "Cleaning"; - else if (value == "docked") value = "Docked"; + else if (value == "on") value = tr(txt::ha_on); + else if (value == "off") value = tr(txt::ha_off); + else if (value == "cleaning") value = tr(txt::ha_vacuum_cleaning); + else if (value == "docked") value = tr(txt::ha_vacuum_docked); // Home Assistant's word where the screen has none of its own (firmware 0.2.58+): a cover says Open, a washer Rinsing. else if (!t.extra().state_word.empty()) value = t.extra().state_word; // A player's state in the screen's own words where Home Assistant sent none (firmware 0.2.64+). else if (d == "media_player") value = tile_controls::media_state_text(t.state); else if (!t.unit.empty() && !watch) value += " " + t.unit; bool pending=t.loading(esphome::millis()); - if(d=="weather" && std::isfinite(t.current)) {char b[32];snprintf(b,sizeof(b),"%.1f %s",t.current,t.unit.c_str());value=b;if(watch){snprintf(b,sizeof(b),"%.1f",t.current);value=b;}} + if(d=="weather" && std::isfinite(t.current)) {value=screen_text::decimal(t.current,1);if(!watch)value+=" "+t.unit;} if(d=="vacuum" && std::isfinite(t.battery))value += " / "+std::to_string((int)t.battery)+"%"; // Direct controls: only a wide card in the standard layout has room for the panel. bool with_panel=w.wide && !t.controls.empty() && !t.builtin() && !watch && t.inline_control!="slider" && fresh() && t.available(); @@ -3338,9 +3360,9 @@ inline void boot_status(lv_obj_t *page, const char *text) { inline void render(lv_obj_t *room) { if (!enabled) return; room_label=room; swipe_profile::Lap lap; - if (!model.configured) boot_status(lv_obj_get_parent(room), !ha_connected() ? "Connecting to Home Assistant" : "Waiting for ESP Screens"); + if (!model.configured) boot_status(lv_obj_get_parent(room), tr(!ha_connected() ? txt::status_connecting : txt::status_waiting)); else if (boot_panel) { lv_obj_delete(boot_panel); boot_panel = boot_text = boot_spinner = nullptr; } - label(room, !model.configured ? "" : !model.ready() ? "Loading tiles..." : !ha_connected() ? "HA not connected" : !feed_alive() ? "ESP Screens not active" : model.title); + label(room, !model.configured ? std::string() : !model.ready() ? tr(txt::status_loading_tiles) : !ha_connected() ? tr(txt::status_ha_not_connected) : !feed_alive() ? tr(txt::status_manager_not_active) : model.title); render_header(); lap(swipe_profile::HEADER); bool all=dirty_all || (!dirty_tiles && !dirty_header); @@ -3856,15 +3878,15 @@ inline void tick() { const uint32_t now=esphome::millis(); if(history_fits(t))refresh_detail(detail_index); else if(now-history_asked_at>30000)history_request(t.entity,history_hours); - else if(now-history_asked_at>8000)label(history_chart.status,"No history available"); + else if(now-history_asked_at>8000)label(history_chart.status,tr(txt::history_unavailable)); } for(unsigned i=0;i.json; screen_text_keys.h numbers the keys, and the build +// writes that language's TABLE into main.cpp (components/smart_display/screen_text_gen.py). A lookup is an index into +// flash: no text is copied, searched for or kept in RAM, and a screen carries no language but its own. +#include +#include +#include +#include +#include "screen_text_keys.h" + +namespace screen_text { + +extern const char *const TABLE[]; // this build's language, one text per key +extern const char *const LANGUAGE; // its code, such as "nl"; the "Screen language" sensor reports it +int plural_index(int n); // which form of a plural text fits n in this language + +inline const char *tr(uint16_t id) { return id < KEY_COUNT ? TABLE[id] : ""; } + +// `text` with every {name} replaced by `value`. Placeholders are named, so a language puts them where its sentence +// needs them; a translation is never used as a printf format. +inline std::string fill(const std::string &text, const char *name, const std::string &value) { + std::string out = text, mark = std::string("{") + name + "}"; + for (size_t at = out.find(mark); at != std::string::npos; at = out.find(mark, at + value.size())) + out.replace(at, mark.size(), value); + return out; +} +inline std::string fill(uint16_t id, const char *name, const std::string &value) { return fill(std::string(tr(id)), name, value); } +inline std::string fill(uint16_t id, const char *name, int value) { return fill(id, name, std::to_string(value)); } + +// The form of a plural text ("1 hour ago | {n} hours ago") for n, with {n} filled in. The forms stand in the order +// of the language's plural rule; a text with fewer forms than the rule uses its last one. +inline std::string plural(uint16_t id, int n) { + const char *text = tr(id); + int wanted = plural_index(n); + const char *start = text; + for (int form = 0; form < wanted; ++form) { + const char *bar = strchr(start, '|'); + if (!bar) break; + start = bar + 1; + } + const char *end = strchr(start, '|'); + std::string form = end ? std::string(start, end - start) : std::string(start); + size_t first = form.find_first_not_of(' '), last = form.find_last_not_of(' '); + form = first == std::string::npos ? std::string() : form.substr(first, last - first + 1); + return fill(form, "n", std::to_string(n)); +} + +// How numbers are written (app 0.2.90): ESP Screens sends the choice of Settings -> Language & region, the one place +// for every screen. 0 follows the language (screen.number), 1 is "1,234.5", 2 "1.234,5" and 3 "1 234,5". +inline uint8_t number_style = 0; +inline char decimal_mark() { + if (number_style == 1) return '.'; + if (number_style == 2 || number_style == 3) return ','; + const char *mark = tr(txt::number_decimal); + return mark[0] ? mark[0] : '.'; +} +// The separator between thousands. +inline const char *group_mark() { + if (number_style == 1) return ","; + if (number_style == 2) return "."; + if (number_style == 3) return " "; + return tr(txt::number_group); +} +// "point", "comma", "space" or "auto" as its number_style; -1 for anything else. +inline int number_style_of(const std::string &name) { + if (name == "auto") return 0; + if (name == "point") return 1; + if (name == "comma") return 2; + if (name == "space") return 3; + return -1; +} +// A number with `decimals` places, written the way this language writes it ("21,5" in Dutch). +inline std::string decimal(float value, int decimals) { + char buffer[24]; + snprintf(buffer, sizeof(buffer), "%.*f", decimals, (double) value); + std::string text = buffer; + if (decimal_mark() != '.') { + size_t dot = text.find('.'); + if (dot != std::string::npos) text[dot] = decimal_mark(); + } + return text; +} + +} // namespace screen_text diff --git a/components/smart_display/screen_text_gen.py b/components/smart_display/screen_text_gen.py new file mode 100644 index 0000000..a3e4371 --- /dev/null +++ b/components/smart_display/screen_text_gen.py @@ -0,0 +1,172 @@ +"""The texts a screen shows, in the language it is built for (app 0.2.90). + +Every text lives in screen_manager/translations/.json; its `screen` section is what the firmware draws. A build +takes one language: this module turns that section into one C++ table in the build's main.cpp, so a screen carries a +single language and looks a text up by its index (screen_text.h). The keys are the same for every language and come +from en.json in document order; tools/i18n.py writes them to screen_text_keys.h, and a static_assert in the build +checks that header against the JSON it is built from. A key a language lacks takes the English text. + +Plain Python on purpose, without ESPHome: __init__.py imports it for the build, tools/i18n.py and the tests use it too. +The ESP Screens app has its own reader (screen_manager/app/i18n.py), as the app's image holds no components folder. +""" +import json +import re +from pathlib import Path + +# The repository root: components/smart_display/ sits two folders down, in a checkout and in the clone ESPHome makes +# of the repository for an external component (git clone --depth=1 of the whole repository). +ROOT = Path(__file__).resolve().parents[2] +TRANSLATIONS = ROOT / 'screen_manager' / 'translations' +LANGUAGE_CODE = re.compile(r'[a-z]{2,3}(-[A-Za-z0-9]{2,8})?') + +# How a language picks the form of a plural text ("1 hour ago | {n} hours ago"): the index of the form for n, as C++. +# The editor has the same rules (web/src/i18n.ts); docs/TRANSLATING.md lists which languages use which. +PLURAL_RULES = { + 'one_other': 'return n == 1 ? 0 : 1;', + 'one_upto_1': 'return (n < 0 ? -n : n) <= 1 ? 0 : 1;', + 'slavic_pl': 'if (n == 1) return 0; int t = n % 10, h = n % 100; ' + 'return t >= 2 && t <= 4 && (h < 12 || h > 14) ? 1 : 2;', + 'east_slavic': 'int t = n % 10, h = n % 100; if (t == 1 && h != 11) return 0; ' + 'return t >= 2 && t <= 4 && (h < 12 || h > 14) ? 1 : 2;', + 'none': 'return 0;', +} + + +def load(code, folder=TRANSLATIONS): + """A language file as a dict; None when there is none.""" + path = Path(folder) / f'{code}.json' + if not LANGUAGE_CODE.fullmatch(code or '') or not path.is_file(): + return None + return json.loads(path.read_text(encoding='utf-8')) + + +def resolve(code, folder=TRANSLATIONS): + """The language a build takes for `code`: the file itself, else its base language (pt-BR -> pt), else English.""" + for candidate in (code, (code or '').split('-')[0]): + if candidate and load(candidate, folder) is not None: + return candidate + return 'en' + + +def flatten(node, prefix=''): + """(key, text) pairs in document order; a list's items get .0, .1, ... .""" + if isinstance(node, dict): + items = node.items() + elif isinstance(node, list): + items = ((str(index), value) for index, value in enumerate(node)) + else: + return [(prefix, node)] + pairs = [] + for key, value in items: + pairs.extend(flatten(value, f'{prefix}.{key}' if prefix else key)) + return pairs + + +def screen_pairs(data): + """The `screen` section as (key, text) pairs, the key without the section.""" + return [(key, value) for key, value in flatten((data or {}).get('screen', {})) if isinstance(value, str)] + + +def identifier(key): + """The C++ name of a key: settings.dark_mode -> settings_dark_mode.""" + return re.sub(r'[^A-Za-z0-9]+', '_', key).strip('_') + + +def keys_hash(keys): + """FNV-1a over the keys, so a build notices a screen_text_keys.h that no longer matches the JSON.""" + value = 0x811C9DC5 + for byte in '\n'.join(keys).encode('utf-8'): + value = ((value ^ byte) * 0x01000193) & 0xFFFFFFFF + return value + + +def cpp_string(text): + """A C++ string literal of UTF-8 text; every byte outside printable ASCII as a three-digit octal escape.""" + out = [] + for byte in text.encode('utf-8'): + char = chr(byte) + if char in '"\\': + out.append('\\' + char) + elif 32 <= byte < 127: + out.append(char) + else: + out.append(f'\\{byte:03o}') + return '"' + ''.join(out) + '"' + + +def table(code, folder=TRANSLATIONS): + """(language, keys, texts, plural rule) of a build: every English key, in its language where it has one.""" + english = load('en', folder) + language = resolve(code, folder) + own = dict(screen_pairs(load(language, folder))) if language != 'en' else {} + keys, texts = [], [] + for key, text in screen_pairs(english): + keys.append(key) + translated = own.get(key) + texts.append(translated if isinstance(translated, str) and translated.strip() else text) + meta = (load(language, folder) or {}).get('_meta', {}) + rule = meta.get('plural') if meta.get('plural') in PLURAL_RULES else 'one_other' + return language, keys, texts, rule + + +def arrays(keys): + """{list key: (index of its first item, length)} for the lists among the keys (date.months_short.0 ...).""" + found = {} + for index, key in enumerate(keys): + head, _, last = key.rpartition('.') + if head and last.isdigit(): + start, length = found.get(head, (index, 0)) + found[head] = (start, length + 1) + return found + + +def keys_header(keys): + """screen_text_keys.h: the index of every key, and the hash of them all.""" + lines = [ + '// GENERATED by tools/i18n.py from screen_manager/translations/en.json; run `python3 tools/i18n.py header`', + '// after changing the keys of the `screen` section. The index of every text a screen shows in its language\'s', + '// table (screen_text.h); a list such as the month names is its first index plus a count.', + '#pragma once', + '#include ', + '', + 'namespace screen_text {', + 'namespace txt {', + ] + lists = arrays(keys) + listed = {index for start, length in lists.values() for index in range(start, start + length)} + for index, key in enumerate(keys): + if index not in listed: + lines.append(f'constexpr uint16_t {identifier(key)} = {index};') + for key, (start, length) in lists.items(): + lines.append(f'constexpr uint16_t {identifier(key)} = {start};') + lines.append(f'constexpr uint16_t {identifier(key)}_count = {length};') + lines += [ + '} // namespace txt', + f'constexpr uint16_t KEY_COUNT = {len(keys)};', + f'constexpr uint32_t KEYS_HASH = 0x{keys_hash(keys):08X}u;', + '} // namespace screen_text', + '', + ] + return '\n'.join(lines) + + +def definitions(code, folder=TRANSLATIONS): + """The C++ a build adds to main.cpp: its language's table and plural rule, and the check against the keys header.""" + language, keys, texts, rule = table(code, folder) + rows = ',\n'.join(f' {cpp_string(text)}' for text in texts) + return language, ( + f'static_assert(screen_text::KEYS_HASH == 0x{keys_hash(keys):08X}u && screen_text::KEY_COUNT == {len(keys)},\n' + ' "screen_text_keys.h does not match screen_manager/translations/en.json: run tools/i18n.py header");\n' + f'const char *const screen_text::TABLE[] = {{\n{rows}\n}};\n' + f'const char *const screen_text::LANGUAGE = {cpp_string(language)};\n' + f'int screen_text::plural_index(int n) {{ {PLURAL_RULES[rule]} }}\n' + ) + + +def english_header(folder=TRANSLATIONS): + """tests/screen_text_en.h: the English table for the C++ tests, which build without ESPHome.""" + _, text = definitions('en', folder) + return ('// GENERATED by tools/i18n.py (`python3 tools/i18n.py header`): the English texts for the C++ tests, which\n' + '// build a header without ESPHome and so without the table a screen\'s build writes into main.cpp.\n' + '#pragma once\n' + '#include "components/smart_display/screen_text.h"\n\n' + text) diff --git a/components/smart_display/screen_text_keys.h b/components/smart_display/screen_text_keys.h new file mode 100644 index 0000000..407a61e --- /dev/null +++ b/components/smart_display/screen_text_keys.h @@ -0,0 +1,279 @@ +// GENERATED by tools/i18n.py from screen_manager/translations/en.json; run `python3 tools/i18n.py header` +// after changing the keys of the `screen` section. The index of every text a screen shows in its language's +// table (screen_text.h); a list such as the month names is its first index plus a count. +#pragma once +#include + +namespace screen_text { +namespace txt { +constexpr uint16_t number_decimal = 0; +constexpr uint16_t number_group = 1; +constexpr uint16_t time_just_now = 2; +constexpr uint16_t time_minutes_ago = 3; +constexpr uint16_t time_hours_ago = 4; +constexpr uint16_t time_yesterday = 5; +constexpr uint16_t time_days_ago = 6; +constexpr uint16_t time_weeks_ago = 7; +constexpr uint16_t time_months_ago = 8; +constexpr uint16_t time_years_ago = 9; +constexpr uint16_t time_in_minutes = 10; +constexpr uint16_t time_in_hours = 11; +constexpr uint16_t time_tomorrow = 12; +constexpr uint16_t time_in_days = 13; +constexpr uint16_t time_am = 14; +constexpr uint16_t time_pm = 15; +constexpr uint16_t date_top_bar = 61; +constexpr uint16_t date_full = 62; +constexpr uint16_t date_day_month = 63; +constexpr uint16_t date_weekday_time = 64; +constexpr uint16_t duration_seconds = 65; +constexpr uint16_t duration_minutes = 66; +constexpr uint16_t duration_hours = 67; +constexpr uint16_t duration_hours_minutes = 68; +constexpr uint16_t duration_days = 69; +constexpr uint16_t duration_days_hours = 70; +constexpr uint16_t status_connecting = 71; +constexpr uint16_t status_waiting = 72; +constexpr uint16_t status_loading_tiles = 73; +constexpr uint16_t status_ha_not_connected = 74; +constexpr uint16_t status_manager_not_active = 75; +constexpr uint16_t status_choose_tiles = 76; +constexpr uint16_t status_home = 77; +constexpr uint16_t status_no_network = 78; +constexpr uint16_t settings_title = 79; +constexpr uint16_t settings_brightness = 80; +constexpr uint16_t settings_night = 81; +constexpr uint16_t settings_screen = 82; +constexpr uint16_t settings_this_screen = 83; +constexpr uint16_t settings_dark_mode = 84; +constexpr uint16_t settings_auto_standby = 85; +constexpr uint16_t settings_standby_after = 86; +constexpr uint16_t settings_standby_brightness = 87; +constexpr uint16_t settings_night_mode = 88; +constexpr uint16_t settings_starts = 89; +constexpr uint16_t settings_ends = 90; +constexpr uint16_t settings_night_brightness = 91; +constexpr uint16_t settings_back_to_page_1 = 92; +constexpr uint16_t settings_after = 93; +constexpr uint16_t settings_also_on_standby = 94; +constexpr uint16_t settings_swipe_between_pages = 95; +constexpr uint16_t settings_page_buttons = 96; +constexpr uint16_t settings_rotation = 97; +constexpr uint16_t settings_address = 98; +constexpr uint16_t settings_firmware = 99; +constexpr uint16_t settings_home_assistant = 100; +constexpr uint16_t settings_restart = 101; +constexpr uint16_t settings_tap_again_to_restart = 102; +constexpr uint16_t settings_seconds = 103; +constexpr uint16_t settings_minutes = 104; +constexpr uint16_t settings_hours = 105; +constexpr uint16_t settings_hours_minutes = 106; +constexpr uint16_t tile_tap_to_open = 107; +constexpr uint16_t tile_page = 108; +constexpr uint16_t tile_unavailable = 109; +constexpr uint16_t tile_refused = 110; +constexpr uint16_t tile_command_sent = 111; +constexpr uint16_t tile_confirmed = 112; +constexpr uint16_t alert_notification = 113; +constexpr uint16_t alert_ok = 114; +constexpr uint16_t light_color = 115; +constexpr uint16_t light_color_temperature = 116; +constexpr uint16_t light_brightness = 117; +constexpr uint16_t light_waiting = 118; +constexpr uint16_t effects_effect = 119; +constexpr uint16_t effects_nothing = 120; +constexpr uint16_t effects_loading = 121; +constexpr uint16_t effects_not_connected = 122; +constexpr uint16_t climate_target = 123; +constexpr uint16_t climate_mode = 124; +constexpr uint16_t climate_fan = 125; +constexpr uint16_t climate_swing = 126; +constexpr uint16_t climate_now = 127; +constexpr uint16_t vacuum_check_robot = 128; +constexpr uint16_t vacuum_auto_suction = 129; +constexpr uint16_t vacuum_robot_chooses = 130; +constexpr uint16_t vacuum_per_room = 131; +constexpr uint16_t vacuum_speed_quiet = 132; +constexpr uint16_t vacuum_speed_balanced = 133; +constexpr uint16_t vacuum_speed_turbo = 134; +constexpr uint16_t vacuum_speed_max = 135; +constexpr uint16_t vacuum_pause_cleaning = 136; +constexpr uint16_t vacuum_pause = 137; +constexpr uint16_t vacuum_resume = 138; +constexpr uint16_t vacuum_start_cleaning = 139; +constexpr uint16_t vacuum_clean = 140; +constexpr uint16_t vacuum_dock = 141; +constexpr uint16_t cover_position = 142; +constexpr uint16_t cover_tilt = 143; +constexpr uint16_t cover_tilt_value = 144; +constexpr uint16_t weather_feels_like = 145; +constexpr uint16_t weather_no_forecast = 146; +constexpr uint16_t weather_coming_days = 147; +constexpr uint16_t history_no_data = 148; +constexpr uint16_t history_average = 149; +constexpr uint16_t history_average_of = 150; +constexpr uint16_t history_now = 151; +constexpr uint16_t history_range_hour = 152; +constexpr uint16_t history_range_day = 153; +constexpr uint16_t history_range_week = 154; +constexpr uint16_t history_high = 155; +constexpr uint16_t history_low = 156; +constexpr uint16_t history_times = 157; +constexpr uint16_t history_loading = 158; +constexpr uint16_t history_empty = 159; +constexpr uint16_t history_unavailable = 160; +constexpr uint16_t media_muted = 161; +constexpr uint16_t media_not_playing = 162; +constexpr uint16_t media_loading = 163; +constexpr uint16_t timer_running = 164; +constexpr uint16_t timer_paused = 165; +constexpr uint16_t timer_stopped = 166; +constexpr uint16_t timer_pause = 167; +constexpr uint16_t timer_start = 168; +constexpr uint16_t timer_cancel = 169; +constexpr uint16_t timer_paused_left = 170; +constexpr uint16_t sun_name = 171; +constexpr uint16_t sun_sunrise = 172; +constexpr uint16_t sun_sunset = 173; +constexpr uint16_t sun_rise = 174; +constexpr uint16_t sun_set = 175; +constexpr uint16_t script_running = 176; +constexpr uint16_t script_never_run = 177; +constexpr uint16_t script_last_time = 178; +constexpr uint16_t script_yesterday_time = 179; +constexpr uint16_t script_last_date = 180; +constexpr uint16_t camera_no_image = 181; +constexpr uint16_t camera_loading = 182; +constexpr uint16_t camera_live = 183; +constexpr uint16_t camera_recording = 184; +constexpr uint16_t camera_tap_to_view = 185; +constexpr uint16_t camera_no_image_yet = 186; +constexpr uint16_t button_activate = 187; +constexpr uint16_t button_run = 188; +constexpr uint16_t button_press = 189; +constexpr uint16_t calibration_title = 190; +constexpr uint16_t calibration_touch_check = 191; +constexpr uint16_t calibration_hold = 192; +constexpr uint16_t calibration_crosshair = 193; +constexpr uint16_t calibration_gently = 194; +constexpr uint16_t calibration_same = 195; +constexpr uint16_t calibration_rejected = 196; +constexpr uint16_t calibration_save_failed = 197; +constexpr uint16_t ha_on = 198; +constexpr uint16_t ha_off = 199; +constexpr uint16_t ha_climate_off = 200; +constexpr uint16_t ha_climate_heat = 201; +constexpr uint16_t ha_climate_cool = 202; +constexpr uint16_t ha_climate_heat_cool = 203; +constexpr uint16_t ha_climate_auto = 204; +constexpr uint16_t ha_climate_dry = 205; +constexpr uint16_t ha_climate_fan_only = 206; +constexpr uint16_t ha_hvac_action_heating = 207; +constexpr uint16_t ha_hvac_action_cooling = 208; +constexpr uint16_t ha_hvac_action_idle = 209; +constexpr uint16_t ha_hvac_action_off = 210; +constexpr uint16_t ha_hvac_action_drying = 211; +constexpr uint16_t ha_hvac_action_fan = 212; +constexpr uint16_t ha_hvac_action_preheating = 213; +constexpr uint16_t ha_hvac_action_defrosting = 214; +constexpr uint16_t ha_cover_open = 215; +constexpr uint16_t ha_cover_closed = 216; +constexpr uint16_t ha_cover_opening = 217; +constexpr uint16_t ha_cover_closing = 218; +constexpr uint16_t ha_media_playing = 219; +constexpr uint16_t ha_media_paused = 220; +constexpr uint16_t ha_media_idle = 221; +constexpr uint16_t ha_media_standby = 222; +constexpr uint16_t ha_person_home = 223; +constexpr uint16_t ha_person_not_home = 224; +constexpr uint16_t ha_sun_above_horizon = 225; +constexpr uint16_t ha_sun_below_horizon = 226; +constexpr uint16_t ha_vacuum_docked = 227; +constexpr uint16_t ha_vacuum_cleaning = 228; +constexpr uint16_t ha_vacuum_paused = 229; +constexpr uint16_t ha_vacuum_returning = 230; +constexpr uint16_t ha_vacuum_idle = 231; +constexpr uint16_t ha_weather_sunny = 232; +constexpr uint16_t ha_weather_clear_night = 233; +constexpr uint16_t ha_weather_cloudy = 234; +constexpr uint16_t ha_weather_partlycloudy = 235; +constexpr uint16_t ha_weather_rainy = 236; +constexpr uint16_t ha_weather_pouring = 237; +constexpr uint16_t ha_weather_snowy = 238; +constexpr uint16_t ha_weather_snowy_rainy = 239; +constexpr uint16_t ha_weather_fog = 240; +constexpr uint16_t ha_weather_hail = 241; +constexpr uint16_t ha_weather_lightning = 242; +constexpr uint16_t ha_weather_lightning_rainy = 243; +constexpr uint16_t ha_weather_windy = 244; +constexpr uint16_t ha_weather_exceptional = 245; +constexpr uint16_t ha_binary_battery_on = 246; +constexpr uint16_t ha_binary_battery_off = 247; +constexpr uint16_t ha_binary_battery_charging_on = 248; +constexpr uint16_t ha_binary_battery_charging_off = 249; +constexpr uint16_t ha_binary_carbon_monoxide_on = 250; +constexpr uint16_t ha_binary_carbon_monoxide_off = 251; +constexpr uint16_t ha_binary_cold_on = 252; +constexpr uint16_t ha_binary_cold_off = 253; +constexpr uint16_t ha_binary_connectivity_on = 254; +constexpr uint16_t ha_binary_connectivity_off = 255; +constexpr uint16_t ha_binary_door_on = 256; +constexpr uint16_t ha_binary_door_off = 257; +constexpr uint16_t ha_binary_garage_door_on = 258; +constexpr uint16_t ha_binary_garage_door_off = 259; +constexpr uint16_t ha_binary_gas_on = 260; +constexpr uint16_t ha_binary_gas_off = 261; +constexpr uint16_t ha_binary_heat_on = 262; +constexpr uint16_t ha_binary_heat_off = 263; +constexpr uint16_t ha_binary_light_on = 264; +constexpr uint16_t ha_binary_light_off = 265; +constexpr uint16_t ha_binary_lock_on = 266; +constexpr uint16_t ha_binary_lock_off = 267; +constexpr uint16_t ha_binary_moisture_on = 268; +constexpr uint16_t ha_binary_moisture_off = 269; +constexpr uint16_t ha_binary_motion_on = 270; +constexpr uint16_t ha_binary_motion_off = 271; +constexpr uint16_t ha_binary_moving_on = 272; +constexpr uint16_t ha_binary_moving_off = 273; +constexpr uint16_t ha_binary_occupancy_on = 274; +constexpr uint16_t ha_binary_occupancy_off = 275; +constexpr uint16_t ha_binary_opening_on = 276; +constexpr uint16_t ha_binary_opening_off = 277; +constexpr uint16_t ha_binary_plug_on = 278; +constexpr uint16_t ha_binary_plug_off = 279; +constexpr uint16_t ha_binary_power_on = 280; +constexpr uint16_t ha_binary_power_off = 281; +constexpr uint16_t ha_binary_presence_on = 282; +constexpr uint16_t ha_binary_presence_off = 283; +constexpr uint16_t ha_binary_problem_on = 284; +constexpr uint16_t ha_binary_problem_off = 285; +constexpr uint16_t ha_binary_running_on = 286; +constexpr uint16_t ha_binary_running_off = 287; +constexpr uint16_t ha_binary_safety_on = 288; +constexpr uint16_t ha_binary_safety_off = 289; +constexpr uint16_t ha_binary_smoke_on = 290; +constexpr uint16_t ha_binary_smoke_off = 291; +constexpr uint16_t ha_binary_sound_on = 292; +constexpr uint16_t ha_binary_sound_off = 293; +constexpr uint16_t ha_binary_tamper_on = 294; +constexpr uint16_t ha_binary_tamper_off = 295; +constexpr uint16_t ha_binary_update_on = 296; +constexpr uint16_t ha_binary_update_off = 297; +constexpr uint16_t ha_binary_vibration_on = 298; +constexpr uint16_t ha_binary_vibration_off = 299; +constexpr uint16_t ha_binary_window_on = 300; +constexpr uint16_t ha_binary_window_off = 301; +constexpr uint16_t date_weekdays = 16; +constexpr uint16_t date_weekdays_count = 7; +constexpr uint16_t date_weekdays_short = 23; +constexpr uint16_t date_weekdays_short_count = 7; +constexpr uint16_t date_weekdays_min = 30; +constexpr uint16_t date_weekdays_min_count = 7; +constexpr uint16_t date_months = 37; +constexpr uint16_t date_months_count = 12; +constexpr uint16_t date_months_short = 49; +constexpr uint16_t date_months_short_count = 12; +} // namespace txt +constexpr uint16_t KEY_COUNT = 302; +constexpr uint32_t KEYS_HASH = 0x725A92EEu; +} // namespace screen_text diff --git a/components/smart_display/settings_screen.h b/components/smart_display/settings_screen.h index ed904ad..1b99121 100644 --- a/components/smart_display/settings_screen.h +++ b/components/smart_display/settings_screen.h @@ -6,6 +6,7 @@ #include #include #include "screen_settings.h" +#include "screen_text.h" // The settings page on the screen itself (firmware 0.2.44+). It changes the same values ESP Screen // Manager writes, for the moments you stand in front of the panel instead of behind a browser. @@ -34,15 +35,19 @@ using Text = std::string (*)(); using Shown = bool (*)(); using Run = void (*)(); +// The page's words are keys into the screen's language (screen.settings in screen_manager/translations, app 0.2.90): +// the table stays constexpr, the text is looked up when a row is drawn. +constexpr uint16_t NO_TEXT = 0xFFFF; struct Row { Kind kind = Kind::info; - const char *label = ""; + uint16_t label = NO_TEXT; const char *icon = ""; // page rows and actions; a Material Design Icons glyph Read read = nullptr; // toggle, number, duration, moment, choice Write write = nullptr; int32_t low = 0, high = 0, step = 0; // number: fixed step; duration: seconds, step from the ladder const char *unit = ""; // straight after a number ("%") - const char *const *options = nullptr; // choice + const char *const *options = nullptr; // choice with the same words in every language ("90°") + uint16_t option_keys = NO_TEXT; // choice in words: the key of its first option, the others follow it uint8_t option_count = 0; Text text = nullptr; // info Run run = nullptr; // action @@ -51,35 +56,40 @@ struct Row { uint8_t opens = 0; // page rows: the page they open }; -constexpr Row page_row(const char *label, const char *icon, uint8_t opens) { +constexpr Row page_row(uint16_t label, const char *icon, uint8_t opens) { Row r{}; r.kind = Kind::page; r.label = label; r.icon = icon; r.opens = opens; return r; } -constexpr Row toggle(const char *label, Read read, Write write, Shown shown = nullptr) { +constexpr Row toggle(uint16_t label, Read read, Write write, Shown shown = nullptr) { Row r{}; r.kind = Kind::toggle; r.label = label; r.read = read; r.write = write; r.shown = shown; return r; } -constexpr Row number(const char *label, Read read, Write write, int32_t low, int32_t high, int32_t step, +constexpr Row number(uint16_t label, Read read, Write write, int32_t low, int32_t high, int32_t step, const char *unit = "", Shown enabled = nullptr) { Row r{}; r.kind = Kind::number; r.label = label; r.read = read; r.write = write; r.low = low; r.high = high; r.step = step; r.unit = unit; r.enabled = enabled; return r; } -constexpr Row duration(const char *label, Read read, Write write, int32_t low, int32_t high, +constexpr Row duration(uint16_t label, Read read, Write write, int32_t low, int32_t high, Shown enabled = nullptr) { Row r{}; r.kind = Kind::duration; r.label = label; r.read = read; r.write = write; r.low = low; r.high = high; r.enabled = enabled; return r; } -constexpr Row moment(const char *label, Read read, Write write, Shown enabled = nullptr) { +constexpr Row moment(uint16_t label, Read read, Write write, Shown enabled = nullptr) { Row r{}; r.kind = Kind::moment; r.label = label; r.read = read; r.write = write; r.low = 0; r.high = 1439; r.step = 15; r.enabled = enabled; return r; } -constexpr Row choice(const char *label, Read read, Write write, const char *const *options, uint8_t count, +constexpr Row choice(uint16_t label, Read read, Write write, const char *const *options, uint8_t count, Shown shown = nullptr) { Row r{}; r.kind = Kind::choice; r.label = label; r.read = read; r.write = write; r.options = options; r.option_count = count; r.shown = shown; return r; } -constexpr Row info(const char *label, Text text) { +constexpr Row choice(uint16_t label, Read read, Write write, uint16_t option_keys, uint8_t count, Shown shown = nullptr) { + Row r{}; r.kind = Kind::choice; r.label = label; r.read = read; r.write = write; + r.option_keys = option_keys; r.option_count = count; r.shown = shown; return r; +} +inline const char *label_text(const Row &row) { return screen_text::tr(row.label); } +constexpr Row info(uint16_t label, Text text) { Row r{}; r.kind = Kind::info; r.label = label; r.text = text; return r; } -constexpr Row action(const char *label, const char *icon, Run run) { +constexpr Row action(uint16_t label, const char *icon, Run run) { Row r{}; r.kind = Kind::action; r.label = label; r.icon = icon; r.run = run; return r; } @@ -169,26 +179,28 @@ inline int knob_x(int w, int h, bool on) { } inline std::string duration_text(int32_t seconds) { - char buffer[24]; - if (seconds < 60) { snprintf(buffer, sizeof(buffer), "%d sec", (int) seconds); return buffer; } - if (seconds < 3600) { snprintf(buffer, sizeof(buffer), "%d min", (int) (seconds / 60)); return buffer; } + using namespace screen_text; + if (seconds < 60) return fill(txt::settings_seconds, "n", (int) seconds); + if (seconds < 3600) return fill(txt::settings_minutes, "n", (int) (seconds / 60)); int hours = seconds / 3600, minutes = (seconds % 3600) / 60; - if (minutes) snprintf(buffer, sizeof(buffer), "%d h %02d", hours, minutes); - else snprintf(buffer, sizeof(buffer), "%d h", hours); - return buffer; + if (!minutes) return fill(txt::settings_hours, "n", hours); + char two[4]; + snprintf(two, sizeof(two), "%02d", minutes); + return fill(fill(txt::settings_hours_minutes, "h", hours), "m", two); } // Minutes since midnight as the clock on this screen shows them. inline std::string moment_text(int32_t minutes, bool clock_24h) { char buffer[16]; int hour = minutes / 60, minute = minutes % 60; if (clock_24h) snprintf(buffer, sizeof(buffer), "%02d:%02d", hour, minute); - else snprintf(buffer, sizeof(buffer), "%d:%02d %s", hour % 12 ? hour % 12 : 12, minute, hour < 12 ? "AM" : "PM"); + else snprintf(buffer, sizeof(buffer), "%d:%02d %s", hour % 12 ? hour % 12 : 12, minute, + screen_text::tr(hour < 12 ? screen_text::txt::time_am : screen_text::txt::time_pm)); return buffer; } // What the right-hand side of a row says. A toggle draws a switch instead, its text is for the tests. inline std::string value_text(const Row &row) { switch (row.kind) { - case Kind::toggle: return row.read && row.read() ? "On" : "Off"; + case Kind::toggle: return screen_text::tr(row.read && row.read() ? screen_text::txt::ha_on : screen_text::txt::ha_off); case Kind::number: { char buffer[24]; snprintf(buffer, sizeof(buffer), "%d%s", row.read ? (int) row.read() : 0, row.unit); @@ -198,7 +210,8 @@ inline std::string value_text(const Row &row) { case Kind::moment: return moment_text(row.read ? row.read() : 0, screen_settings::current.clock_24h != 0); case Kind::choice: { int32_t index = row.read ? row.read() : 0; - return index >= 0 && index < row.option_count ? row.options[index] : ""; + if (index < 0 || index >= row.option_count) return ""; + return row.option_keys != NO_TEXT ? screen_text::tr(row.option_keys + index) : row.options[index]; } case Kind::info: return row.text ? row.text() : ""; default: return ""; @@ -208,7 +221,7 @@ inline bool visible_row(const Row &row) { return !row.shown || row.shown(); } inline bool live_row(const Row &row) { return !row.enabled || row.enabled(); } struct Page { - const char *title; + uint16_t title; const Row *rows; uint8_t count; }; @@ -223,7 +236,6 @@ inline uint8_t fitting_rows(int span, int row_height, int gap, int pager, uint8_ } // ------------------------------------------------------------------ the pages -inline constexpr const char *clock_options[] = {"12 hour", "24 hour"}; inline constexpr const char *rotation_options[] = {"0°", "90°", "180°", "270°"}; // Brightness, the dark look for a screen beside a bed, and when the screen dims by itself. @@ -231,52 +243,51 @@ inline constexpr const char *rotation_options[] = {"0°", "90°", "180°", "270 // plain `int` (a ternary, say) then does not convert to Read at all. The Mac's host build accepts it, // the board does not. inline constexpr Row light_rows[] = { - number("Brightness", []() -> int32_t { return screen_settings::current.brightness; }, + number(screen_text::txt::settings_brightness, []() -> int32_t { return screen_settings::current.brightness; }, [](int32_t value) { set("brightness", value); }, 5, 100, 5, "%"), - toggle("Dark mode", []() -> int32_t { return dark_mode; }, + toggle(screen_text::txt::settings_dark_mode, []() -> int32_t { return dark_mode; }, [](int32_t value) { set("dark_mode", value); }), - toggle("Auto standby", []() -> int32_t { return screen_settings::current.standby_enabled; }, + toggle(screen_text::txt::settings_auto_standby, []() -> int32_t { return screen_settings::current.standby_enabled; }, [](int32_t value) { set("standby_enabled", value); }), - duration("Standby after", []() -> int32_t { return screen_settings::current.standby_seconds; }, + duration(screen_text::txt::settings_standby_after, []() -> int32_t { return screen_settings::current.standby_seconds; }, [](int32_t value) { set("standby_seconds", value); }, 60, 86400, [] { return screen_settings::current.standby_enabled != 0; }), - number("Standby brightness", []() -> int32_t { return screen_settings::current.standby_brightness; }, + number(screen_text::txt::settings_standby_brightness, []() -> int32_t { return screen_settings::current.standby_brightness; }, [](int32_t value) { set("standby_brightness", value); }, 0, 100, 5, "%", [] { return screen_settings::current.standby_enabled != 0; }), }; // Darker between two times, so a panel in a hallway does not light up the bedroom. inline constexpr Row night_rows[] = { - toggle("Night mode", []() -> int32_t { return screen_settings::current.night_enabled; }, + toggle(screen_text::txt::settings_night_mode, []() -> int32_t { return screen_settings::current.night_enabled; }, [](int32_t value) { set("night_enabled", value); }), - moment("Starts", []() -> int32_t { return screen_settings::current.night_start; }, + moment(screen_text::txt::settings_starts, []() -> int32_t { return screen_settings::current.night_start; }, [](int32_t value) { set("night_start", value); }, [] { return screen_settings::current.night_enabled != 0; }), - moment("Ends", []() -> int32_t { return screen_settings::current.night_end; }, + moment(screen_text::txt::settings_ends, []() -> int32_t { return screen_settings::current.night_end; }, [](int32_t value) { set("night_end", value); }, [] { return screen_settings::current.night_enabled != 0; }), - number("Night brightness", []() -> int32_t { return screen_settings::current.night_brightness; }, + number(screen_text::txt::settings_night_brightness, []() -> int32_t { return screen_settings::current.night_brightness; }, [](int32_t value) { set("night_brightness", value); }, 0, 100, 5, "%", [] { return screen_settings::current.night_enabled != 0; }), }; -// How the screen behaves under your finger: the clock, going back to the first page, swiping, the page buttons, -// turning. +// How the screen behaves under your finger: going back to the first page, swiping, the page buttons, turning. The +// clock's 12 or 24 hours is no longer a setting of each screen (app 0.2.90): ESP Screens sends it with the number +// format, from Settings -> Language & region, the one place for every screen. inline constexpr Row screen_rows[] = { - choice("Clock", []() -> int32_t { return screen_settings::current.clock_24h ? 1 : 0; }, - [](int32_t value) { set("clock_24h", value); }, clock_options, 2), - toggle("Back to page 1", []() -> int32_t { return auto_home; }, + toggle(screen_text::txt::settings_back_to_page_1, []() -> int32_t { return auto_home; }, [](int32_t value) { set("auto_home", value); }), - duration("After", []() -> int32_t { return auto_home_seconds; }, + duration(screen_text::txt::settings_after, []() -> int32_t { return auto_home_seconds; }, [](int32_t value) { set("auto_home_seconds", value); }, 30, 3600, [] { return auto_home != 0; }), - toggle("Also on standby", []() -> int32_t { return screen_settings::current.home_on_standby; }, + toggle(screen_text::txt::settings_also_on_standby, []() -> int32_t { return screen_settings::current.home_on_standby; }, [](int32_t value) { set("home_on_standby", value); }), - toggle("Swipe between pages", []() -> int32_t { return swipe_pages; }, + toggle(screen_text::txt::settings_swipe_between_pages, []() -> int32_t { return swipe_pages; }, [](int32_t value) { set("swipe_pages", value); }), - toggle("Page buttons", []() -> int32_t { return page_buttons; }, + toggle(screen_text::txt::settings_page_buttons, []() -> int32_t { return page_buttons; }, [](int32_t value) { set("page_buttons", value); }), - choice("Rotation", []() -> int32_t { return rotation / 90; }, + choice(screen_text::txt::settings_rotation, []() -> int32_t { return rotation / 90; }, [](int32_t value) { set("rotation", std::clamp(value, 0, 3) * 90); }, rotation_options, 4, [] { return rotation_supported; }), }; @@ -288,26 +299,26 @@ inline std::string (*firmware_text)() = nullptr; inline std::string (*link_text)() = nullptr; inline void (*restart_device)() = nullptr; inline constexpr Row about_rows[] = { - info("Screen", [] { return name_text ? name_text() : std::string(); }), - info("Address", [] { return address_text ? address_text() : std::string(); }), - info("Firmware", [] { return firmware_text ? firmware_text() : std::string(); }), - info("Home Assistant", [] { return link_text ? link_text() : std::string(); }), - action("Restart", "\U000F0709", [] { if (restart_device) restart_device(); }), + info(screen_text::txt::settings_screen, [] { return name_text ? name_text() : std::string(); }), + info(screen_text::txt::settings_address, [] { return address_text ? address_text() : std::string(); }), + info(screen_text::txt::settings_firmware, [] { return firmware_text ? firmware_text() : std::string(); }), + info(screen_text::txt::settings_home_assistant, [] { return link_text ? link_text() : std::string(); }), + action(screen_text::txt::settings_restart, "\U000F0709", [] { if (restart_device) restart_device(); }), }; inline constexpr Row menu_rows[] = { - page_row("Brightness", "\U000F0599", 1), - page_row("Night", "\U000F0594", 2), - page_row("Screen", "\U000F0379", 3), - page_row("This screen", "\U000F02FD", 4), + page_row(screen_text::txt::settings_brightness, "\U000F0599", 1), + page_row(screen_text::txt::settings_night, "\U000F0594", 2), + page_row(screen_text::txt::settings_screen, "\U000F0379", 3), + page_row(screen_text::txt::settings_this_screen, "\U000F02FD", 4), }; inline constexpr Page pages[] = { - {"Settings", menu_rows, (uint8_t) std::size(menu_rows)}, - {"Brightness", light_rows, (uint8_t) std::size(light_rows)}, - {"Night", night_rows, (uint8_t) std::size(night_rows)}, - {"Screen", screen_rows, (uint8_t) std::size(screen_rows)}, - {"This screen", about_rows, (uint8_t) std::size(about_rows)}, + {screen_text::txt::settings_title, menu_rows, (uint8_t) std::size(menu_rows)}, + {screen_text::txt::settings_brightness, light_rows, (uint8_t) std::size(light_rows)}, + {screen_text::txt::settings_night, night_rows, (uint8_t) std::size(night_rows)}, + {screen_text::txt::settings_screen, screen_rows, (uint8_t) std::size(screen_rows)}, + {screen_text::txt::settings_this_screen, about_rows, (uint8_t) std::size(about_rows)}, }; constexpr uint8_t PAGE_COUNT = (uint8_t) std::size(pages); @@ -587,7 +598,7 @@ inline void draw() { lv_obj_center(arrow); lv_obj_add_event_cb(back, back_event, LV_EVENT_SHORT_CLICKED, nullptr); const lv_font_t *heading = title_font ? title_font : row_font; - auto *title = text(root, page.title, heading, theme::INK, LV_TEXT_ALIGN_CENTER); + auto *title = text(root, screen_text::tr(page.title), heading, theme::INK, LV_TEXT_ALIGN_CENTER); lv_obj_set_width(title, m.width - 2 * (m.pad + m.bar + 8)); lv_obj_set_pos(title, m.pad + m.bar + 8, m.bar_y + (m.bar - lv_font_get_line_height(heading)) / 2); @@ -628,7 +639,8 @@ inline void draw() { lv_obj_set_pos(glyph, left, (m.row_h - icon_h) / 2); left += icon_h + (m.large ? 12 : 8); } - d.label = text(d.card, asking ? "Tap again to restart" : row.label, row_font, asking ? theme::ON_ACCENT : theme::INK); + d.label = text(d.card, screen_text::tr(asking ? screen_text::txt::settings_tap_again_to_restart : row.label), row_font, + asking ? theme::ON_ACCENT : theme::INK); lv_obj_set_pos(d.label, left, (m.row_h - label_h) / 2); int right = m.width - 2 * m.pad - m.inset; // free space from the right edge of the card diff --git a/components/smart_display/tile_controls.h b/components/smart_display/tile_controls.h index ed4b4f4..04e028b 100644 --- a/components/smart_display/tile_controls.h +++ b/components/smart_display/tile_controls.h @@ -4,6 +4,7 @@ // a -/+ step lands on the entity's grid, and the status line beside them. The // LVGL drawing lives in runtime_tiles.h; tests/test_tile_controls.cpp covers this. #include "runtime_model.h" +#include "screen_text.h" #include "theme.h" #include #include @@ -157,69 +158,88 @@ inline float step_value(float current, float step, float minimum, float maximum, if (has_max) next = std::fmin(next, maximum); return next; } -// Whole degrees when the entity steps by whole units, one decimal otherwise. +// Whole degrees when the entity steps by whole units, one decimal otherwise, with the language's decimal mark. inline std::string format_value(float value, float step, const char *suffix) { if (!std::isfinite(value)) return "--"; - char b[24]; snprintf(b, sizeof(b), step >= 1 ? "%.0f%s" : "%.1f%s", value, suffix); return b; + return screen_text::decimal(value, step >= 1 ? 0 : 1) + suffix; } inline const char *climate_mode_text(const std::string &mode) { - if (mode == "off") return "Off"; - if (mode == "heat") return "Heat"; - if (mode == "cool") return "Cool"; - if (mode == "heat_cool") return "Heat/Cool"; - if (mode == "auto") return "Auto"; - if (mode == "dry") return "Dry"; - if (mode == "fan_only") return "Fan only"; + 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); + if (mode == "cool") return screen_text::tr(screen_text::txt::ha_climate_cool); + if (mode == "heat_cool") return screen_text::tr(screen_text::txt::ha_climate_heat_cool); + if (mode == "auto") return screen_text::tr(screen_text::txt::ha_climate_auto); + if (mode == "dry") return screen_text::tr(screen_text::txt::ha_climate_dry); + if (mode == "fan_only") return screen_text::tr(screen_text::txt::ha_climate_fan_only); return mode.c_str(); } inline const char *climate_action_text(const std::string &action) { - if (action == "heating") return "Heating"; - if (action == "cooling") return "Cooling"; - if (action == "idle") return "Idle"; - if (action == "off") return "Off"; - if (action == "drying") return "Drying"; - if (action == "fan") return "Fan"; - if (action == "preheating") return "Preheating"; - if (action == "defrosting") return "Defrosting"; + if (action == "heating") return screen_text::tr(screen_text::txt::ha_hvac_action_heating); + if (action == "cooling") return screen_text::tr(screen_text::txt::ha_hvac_action_cooling); + if (action == "idle") return screen_text::tr(screen_text::txt::ha_hvac_action_idle); + if (action == "off") return screen_text::tr(screen_text::txt::ha_hvac_action_off); + if (action == "drying") return screen_text::tr(screen_text::txt::ha_hvac_action_drying); + if (action == "fan") return screen_text::tr(screen_text::txt::ha_hvac_action_fan); + if (action == "preheating") return screen_text::tr(screen_text::txt::ha_hvac_action_preheating); + if (action == "defrosting") return screen_text::tr(screen_text::txt::ha_hvac_action_defrosting); return ""; } inline const char *cover_state_text(const std::string &state) { - if (state == "open") return "Open"; - if (state == "closed") return "Closed"; - if (state == "opening") return "Opening"; - if (state == "closing") return "Closing"; + if (state == "open") return screen_text::tr(screen_text::txt::ha_cover_open); + if (state == "closed") return screen_text::tr(screen_text::txt::ha_cover_closed); + if (state == "opening") return screen_text::tr(screen_text::txt::ha_cover_opening); + if (state == "closing") return screen_text::tr(screen_text::txt::ha_cover_closing); return state.c_str(); } inline const char *media_state_text(const std::string &state) { - if (state == "playing") return "Playing"; - if (state == "paused") return "Paused"; - if (state == "idle") return "Idle"; - if (state == "standby") return "Standby"; - if (state == "buffering") return "Loading"; - if (state == "on") return "On"; - if (state == "off") return "Off"; + if (state == "playing") return screen_text::tr(screen_text::txt::ha_media_playing); + if (state == "paused") return screen_text::tr(screen_text::txt::ha_media_paused); + if (state == "idle") return screen_text::tr(screen_text::txt::ha_media_idle); + if (state == "standby") return screen_text::tr(screen_text::txt::ha_media_standby); + if (state == "buffering") return screen_text::tr(screen_text::txt::media_loading); + if (state == "on") return screen_text::tr(screen_text::txt::ha_on); + if (state == "off") return screen_text::tr(screen_text::txt::ha_off); return state.c_str(); } // A binary sensor's state in Home Assistant's words for its device class: a door is Open or Closed, a leak sensor -// Wet or Dry. The same words as the add-on's header_bar.BINARY_STATES, which the top bar and the history card show; -// tests/test_binary_words.py keeps the two tables equal. Without a class, or with one this table lacks: On and Off. -struct BinaryWords { const char *device_class, *on, *off; }; +// Wet or Dry, in the screen's language (screen.ha.binary, taken from Home Assistant's own translations). The add-on's +// header_bar.BINARY_STATES reads the same words from the same file for the top bar and the history card. Without a +// class, or with one this table lacks: On and Off. +struct BinaryWords { const char *device_class; uint16_t on, off; }; constexpr BinaryWords BINARY_WORDS[] = { - {"battery", "Low", "Normal"}, {"battery_charging", "Charging", "Not charging"}, - {"carbon_monoxide", "Danger", "Safe"}, {"cold", "Cold", "Normal"}, {"connectivity", "Connected", "Disconnected"}, - {"door", "Open", "Closed"}, {"garage_door", "Open", "Closed"}, {"gas", "Danger", "Safe"}, - {"heat", "Hot", "Normal"}, {"light", "Light", "Dark"}, {"lock", "Open", "Locked"}, {"moisture", "Wet", "Dry"}, - {"motion", "Motion", "No motion"}, {"moving", "Moving", "Still"}, {"occupancy", "Occupied", "Clear"}, - {"opening", "Open", "Closed"}, {"plug", "Plugged in", "Unplugged"}, {"power", "On", "Off"}, - {"presence", "Home", "Away"}, {"problem", "Problem", "OK"}, {"running", "Active", "Inactive"}, - {"safety", "Unsafe", "Safe"}, {"smoke", "Smoke", "No smoke"}, {"sound", "Sound", "Silent"}, - {"tamper", "Tampering", "OK"}, {"update", "Update", "Up to date"}, {"vibration", "Vibration", "Still"}, - {"window", "Open", "Closed"}, + {"battery", screen_text::txt::ha_binary_battery_on, screen_text::txt::ha_binary_battery_off}, + {"battery_charging", screen_text::txt::ha_binary_battery_charging_on, screen_text::txt::ha_binary_battery_charging_off}, + {"carbon_monoxide", screen_text::txt::ha_binary_carbon_monoxide_on, screen_text::txt::ha_binary_carbon_monoxide_off}, + {"cold", screen_text::txt::ha_binary_cold_on, screen_text::txt::ha_binary_cold_off}, + {"connectivity", screen_text::txt::ha_binary_connectivity_on, screen_text::txt::ha_binary_connectivity_off}, + {"door", screen_text::txt::ha_binary_door_on, screen_text::txt::ha_binary_door_off}, + {"garage_door", screen_text::txt::ha_binary_garage_door_on, screen_text::txt::ha_binary_garage_door_off}, + {"gas", screen_text::txt::ha_binary_gas_on, screen_text::txt::ha_binary_gas_off}, + {"heat", screen_text::txt::ha_binary_heat_on, screen_text::txt::ha_binary_heat_off}, + {"light", screen_text::txt::ha_binary_light_on, screen_text::txt::ha_binary_light_off}, + {"lock", screen_text::txt::ha_binary_lock_on, screen_text::txt::ha_binary_lock_off}, + {"moisture", screen_text::txt::ha_binary_moisture_on, screen_text::txt::ha_binary_moisture_off}, + {"motion", screen_text::txt::ha_binary_motion_on, screen_text::txt::ha_binary_motion_off}, + {"moving", screen_text::txt::ha_binary_moving_on, screen_text::txt::ha_binary_moving_off}, + {"occupancy", screen_text::txt::ha_binary_occupancy_on, screen_text::txt::ha_binary_occupancy_off}, + {"opening", screen_text::txt::ha_binary_opening_on, screen_text::txt::ha_binary_opening_off}, + {"plug", screen_text::txt::ha_binary_plug_on, screen_text::txt::ha_binary_plug_off}, + {"power", screen_text::txt::ha_binary_power_on, screen_text::txt::ha_binary_power_off}, + {"presence", screen_text::txt::ha_binary_presence_on, screen_text::txt::ha_binary_presence_off}, + {"problem", screen_text::txt::ha_binary_problem_on, screen_text::txt::ha_binary_problem_off}, + {"running", screen_text::txt::ha_binary_running_on, screen_text::txt::ha_binary_running_off}, + {"safety", screen_text::txt::ha_binary_safety_on, screen_text::txt::ha_binary_safety_off}, + {"smoke", screen_text::txt::ha_binary_smoke_on, screen_text::txt::ha_binary_smoke_off}, + {"sound", screen_text::txt::ha_binary_sound_on, screen_text::txt::ha_binary_sound_off}, + {"tamper", screen_text::txt::ha_binary_tamper_on, screen_text::txt::ha_binary_tamper_off}, + {"update", screen_text::txt::ha_binary_update_on, screen_text::txt::ha_binary_update_off}, + {"vibration", screen_text::txt::ha_binary_vibration_on, screen_text::txt::ha_binary_vibration_off}, + {"window", screen_text::txt::ha_binary_window_on, screen_text::txt::ha_binary_window_off}, }; inline const char *binary_state_text(const std::string &device_class, bool on) { for (const auto &words : BINARY_WORDS) - if (device_class == words.device_class) return on ? words.on : words.off; - return on ? "On" : "Off"; + if (device_class == words.device_class) return screen_text::tr(on ? words.on : words.off); + return screen_text::tr(on ? screen_text::txt::ha_on : screen_text::txt::ha_off); } // Status line beside a control panel: what Home Assistant shows under the name. inline std::string status_text(const Tile &t) { @@ -227,7 +247,7 @@ inline std::string status_text(const Tile &t) { if (d == "climate") { std::string text = climate_action_text(t.extra().hvac_action); if (text.empty()) text = climate_mode_text(t.state); - if (std::isfinite(t.current)) { snprintf(b, sizeof(b), " · %.1f°", t.current); text += b; } + if (std::isfinite(t.current)) text += " · " + screen_text::decimal(t.current, 1) + "°"; return text; } if (d == "cover") { @@ -261,7 +281,7 @@ inline CoverCard cover_card(const Tile &t) { inline std::string cover_card_status(const Tile &t) { std::string text = status_text(t); if ((t.supported & feature::COVER_TILT_POSITION) && std::isfinite(t.extra().tilt)) { - char b[24]; snprintf(b, sizeof(b), " · Tilt %d%%", (int) std::lround(t.extra().tilt)); text += b; + text += " · " + screen_text::fill(screen_text::txt::cover_tilt_value, "n", (int) std::lround(t.extra().tilt)); } return text; } @@ -328,9 +348,9 @@ inline std::string neighbour_option(const Tile &t, int direction) { return options[(current + direction + count) % count]; } inline const char *run_label(const std::string &domain) { - if (domain == "scene") return "Activate"; - if (domain == "script") return "Run"; - return "Press"; + if (domain == "scene") return screen_text::tr(screen_text::txt::button_activate); + if (domain == "script") return screen_text::tr(screen_text::txt::button_run); + return screen_text::tr(screen_text::txt::button_press); } // The Home Assistant action behind a key. STEP_* are handled locally (debounced) and return nothing. inline Action key_action(const Tile &t, int command, const std::string &arg = "") { @@ -403,10 +423,10 @@ inline VacuumRows vacuum_rows(const Tile &t, uint32_t now) { } // Labels for a manager that sends no suction row (app before 0.2.46): the vacuum's own speed names. inline std::string speed_label(const std::string &speed) { - if (speed == "quiet") return "Quiet"; - if (speed == "balanced") return "Normal"; - if (speed == "turbo") return "Turbo"; - if (speed == "max") return "Max"; + if (speed == "quiet") return screen_text::tr(screen_text::txt::vacuum_speed_quiet); + if (speed == "balanced") return screen_text::tr(screen_text::txt::vacuum_speed_balanced); + if (speed == "turbo") return screen_text::tr(screen_text::txt::vacuum_speed_turbo); + if (speed == "max") return screen_text::tr(screen_text::txt::vacuum_speed_max); return speed; } // The suction row a vacuum card shows: the manager's (filtered, labelled) speeds, else the first four diff --git a/packages/boards/cyd-2432s028.yaml b/packages/boards/cyd-2432s028.yaml index 3269bdd..a52f1fb 100644 --- a/packages/boards/cyd-2432s028.yaml +++ b/packages/boards/cyd-2432s028.yaml @@ -275,7 +275,7 @@ substitutions: # The touch test page TOUCH_TEST_PAGE_PAINT: "paint_calibration" # the page - TOUCH_TEST_TITLE: "Follow the calibration wizard"# the words at the top + TOUCH_TEST_TITLE: "calibration_title" # the words at the top TOUCH_TEST_TITLE_FONT: "sublabel" TOUCH_TEST_TITLE_Y: "65" TOUCH_TEST_INSET: "10" # the corner crosses from the edge @@ -689,10 +689,10 @@ script: std::string mode = lower(id(active_climate_mode)); bool off = mode == "off" || mode.empty() || mode == "unknown" || mode == "unavailable"; // What the device does (Heating), else its mode (Heat); then the room. - std::string status = off ? std::string("Off") : std::string(tile_controls::climate_action_text(lower(id(active_climate_action)))); + std::string status = off ? std::string(screen_text::tr(screen_text::txt::ha_off)) : std::string(tile_controls::climate_action_text(lower(id(active_climate_action)))); if (status.empty()) status = tile_controls::climate_mode_text(mode); char part[32]; - if (std::isfinite(id(active_climate_current))) { snprintf(part, sizeof(part), " · Now %.1f°", id(active_climate_current)); status += part; } + if (std::isfinite(id(active_climate_current))) status += " · " + screen_text::fill(screen_text::txt::climate_now, "value", screen_text::decimal(id(active_climate_current), 1) + "°"); if (id(active_climate_humidity) >= 0) { snprintf(part, sizeof(part), " · %d%%", id(active_climate_humidity)); status += part; } lv_label_set_text(id(climate_current_label), status.c_str()); diff --git a/packages/boards/guition-4848s040.yaml b/packages/boards/guition-4848s040.yaml index bd6d02e..ae7205d 100644 --- a/packages/boards/guition-4848s040.yaml +++ b/packages/boards/guition-4848s040.yaml @@ -167,7 +167,7 @@ substitutions: # The touch test page TOUCH_TEST_PAGE_PAINT: "paint_page_soft" # the page - TOUCH_TEST_TITLE: "GT911 touch check" # the words at the top + TOUCH_TEST_TITLE: "calibration_touch_check" # the words at the top TOUCH_TEST_TITLE_FONT: "headline" TOUCH_TEST_TITLE_Y: "110" TOUCH_TEST_INSET: "12" # the corner crosses from the edge @@ -923,10 +923,10 @@ script: std::string mode = lower(id(active_climate_mode)); bool off = mode == "off" || mode.empty() || mode == "unknown" || mode == "unavailable"; // What the device does (Heating), else its mode (Heat); then the room. - std::string status = off ? std::string("Off") : std::string(tile_controls::climate_action_text(lower(id(active_climate_action)))); + std::string status = off ? std::string(screen_text::tr(screen_text::txt::ha_off)) : std::string(tile_controls::climate_action_text(lower(id(active_climate_action)))); if (status.empty()) status = tile_controls::climate_mode_text(mode); char part[32]; - if (std::isfinite(id(active_climate_current))) { snprintf(part, sizeof(part), " · Now %.1f°", id(active_climate_current)); status += part; } + if (std::isfinite(id(active_climate_current))) status += " · " + screen_text::fill(screen_text::txt::climate_now, "value", screen_text::decimal(id(active_climate_current), 1) + "°"); if (id(active_climate_humidity) >= 0) { snprintf(part, sizeof(part), " · %d%%", id(active_climate_humidity)); status += part; } lv_label_set_text(id(climate_current_label), status.c_str()); diff --git a/packages/core.yaml b/packages/core.yaml index a1e53f1..71438ca 100644 --- a/packages/core.yaml +++ b/packages/core.yaml @@ -18,6 +18,9 @@ substitutions: DASHBOARD_INBOX_INTERNAL: "false" CALIBRATION_ON_BOOT: "false" # a bench build: start on the touch test page ROOM_NAME: "" # top left until ESP Screens sends the screen name + # The language of every text on the screen (app 0.2.90): a file in screen_manager/translations, English for any + # text it lacks. ESP Screens writes it into the screen's own YAML, from Home Assistant's language or its Settings. + LANGUAGE: "en" ## DIM settings AUTO_DIM: "true" # true = enable auto dimming @@ -213,7 +216,7 @@ esphome: settings_screen::apply = []() { id(apply_screen_settings).execute(); }; settings_screen::report = [](const char *key, int32_t value) { runtime_tiles::setting_event(key, value); }; settings_screen::name_text = []() { return std::string("${DEVICE_FRIENDLY_NAME}"); }; - settings_screen::address_text = []() { return id(wifi_ip).state.empty() ? std::string("No network") : id(wifi_ip).state; }; + settings_screen::address_text = []() { return id(wifi_ip).state.empty() ? std::string(screen_text::tr(screen_text::txt::status_no_network)) : id(wifi_ip).state; }; settings_screen::firmware_text = []() { return std::string("${SCREEN_FIRMWARE_VERSION}"); }; settings_screen::link_text = []() { return std::string(esphome::api_is_connected() ? "Connected" : "Not connected"); }; settings_screen::restart_device = []() { ESP_LOGI("settings", "restart from the screen"); App.safe_reboot(); }; @@ -663,21 +666,35 @@ font: id: headline size: ${FONT_HEADLINE_SIZE} bpp: 4 - glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°', - '0','1','2','3','4','5','6','7','8','9', - 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z', - ' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z', - 'ä','ö','ü','Ä','Ö','Ü','ß','/','·'] + glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°','0','1','2','3','4','5', + '6','7','8','9','A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T', + 'U','V','W','X','Y','Z',' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q', + 'r','s','t','u','v','w','x','y','z','/','·', + 'À','Á','Â','Ã','Ä','Å','Æ','Ç','È','É','Ê','Ë','Ì','Í','Î','Ï','Ð','Ñ','Ò','Ó','Ô','Õ','Ö','Ø', + 'Ù','Ú','Û','Ü','Ý','Þ','ß','à','á','â','ã','ä','å','æ','ç','è','é','ê','ë','ì','í','î','ï','ð', + 'ñ','ò','ó','ô','õ','ö','ø','ù','ú','û','ü','ý','þ','ÿ','Ą','ą','Ć','ć','Ę','ę','Ł','ł','Ń','ń', + 'Ś','ś','Ź','ź','Ż','ż','Č','č','Ď','ď','Ě','ě','Ň','ň','Ř','ř','Š','š','Ť','ť','Ů','ů','Ž','ž', + 'Ĺ','ĺ','Ľ','ľ','Ŕ','ŕ','Ő','ő','Ű','ű','Ă','ă','Ș','ș','Ț','ț','Đ','đ','Ā','ā','Ē','ē','Ģ','ģ', + 'Ī','ī','Ķ','ķ','Ļ','ļ','Ņ','ņ','Ū','ū','Ė','ė','Į','į','Ų','ų','Ğ','ğ','İ','ı','Ş','ş','Ċ','ċ', + 'Ġ','ġ','Ħ','ħ','Ŵ','ŵ','Ŷ','ŷ','Ŀ','ŀ','Œ','œ','Ÿ','„','“','”','‘','’','«','»','‹','›','¿','¡', + '€','£'] - file: "${FONT_DIR}/Roboto-500.ttf" id: watch_value size: ${FONT_WATCH_VALUE_SIZE} bpp: 4 - glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°', - '0','1','2','3','4','5','6','7','8','9', - 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z', - ' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z', - 'ä','ö','ü','Ä','Ö','Ü','ß','/','·'] + glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°','0','1','2','3','4','5', + '6','7','8','9','A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T', + 'U','V','W','X','Y','Z',' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q', + 'r','s','t','u','v','w','x','y','z','/','·', + 'À','Á','Â','Ã','Ä','Å','Æ','Ç','È','É','Ê','Ë','Ì','Í','Î','Ï','Ð','Ñ','Ò','Ó','Ô','Õ','Ö','Ø', + 'Ù','Ú','Û','Ü','Ý','Þ','ß','à','á','â','ã','ä','å','æ','ç','è','é','ê','ë','ì','í','î','ï','ð', + 'ñ','ò','ó','ô','õ','ö','ø','ù','ú','û','ü','ý','þ','ÿ','Ą','ą','Ć','ć','Ę','ę','Ł','ł','Ń','ń', + 'Ś','ś','Ź','ź','Ż','ż','Č','č','Ď','ď','Ě','ě','Ň','ň','Ř','ř','Š','š','Ť','ť','Ů','ů','Ž','ž', + 'Ĺ','ĺ','Ľ','ľ','Ŕ','ŕ','Ő','ő','Ű','ű','Ă','ă','Ș','ș','Ț','ț','Đ','đ','Ā','ā','Ē','ē','Ģ','ģ', + 'Ī','ī','Ķ','ķ','Ļ','ļ','Ņ','ņ','Ū','ū','Ė','ė','Į','į','Ų','ų','Ğ','ğ','İ','ı','Ş','ş','Ċ','ċ', + 'Ġ','ġ','Ħ','ħ','Ŵ','ŵ','Ŷ','ŷ','Ŀ','ŀ','Œ','œ','Ÿ','„','“','”','‘','’','«','»','‹','›','¿','¡', + '€','£'] # Clock card digits only; the date line reuses the tile title font. - file: "${FONT_DIR}/Roboto-500.ttf" @@ -697,31 +714,52 @@ font: id: label size: ${FONT_LABEL_SIZE} bpp: 4 - glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°', - '0','1','2','3','4','5','6','7','8','9', - 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z', - ' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z', - 'ä','ö','ü','Ä','Ö','Ü','ß','/','·'] + glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°','0','1','2','3','4','5', + '6','7','8','9','A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T', + 'U','V','W','X','Y','Z',' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q', + 'r','s','t','u','v','w','x','y','z','/','·', + 'À','Á','Â','Ã','Ä','Å','Æ','Ç','È','É','Ê','Ë','Ì','Í','Î','Ï','Ð','Ñ','Ò','Ó','Ô','Õ','Ö','Ø', + 'Ù','Ú','Û','Ü','Ý','Þ','ß','à','á','â','ã','ä','å','æ','ç','è','é','ê','ë','ì','í','î','ï','ð', + 'ñ','ò','ó','ô','õ','ö','ø','ù','ú','û','ü','ý','þ','ÿ','Ą','ą','Ć','ć','Ę','ę','Ł','ł','Ń','ń', + 'Ś','ś','Ź','ź','Ż','ż','Č','č','Ď','ď','Ě','ě','Ň','ň','Ř','ř','Š','š','Ť','ť','Ů','ů','Ž','ž', + 'Ĺ','ĺ','Ľ','ľ','Ŕ','ŕ','Ő','ő','Ű','ű','Ă','ă','Ș','ș','Ț','ț','Đ','đ','Ā','ā','Ē','ē','Ģ','ģ', + 'Ī','ī','Ķ','ķ','Ļ','ļ','Ņ','ņ','Ū','ū','Ė','ė','Į','į','Ų','ų','Ğ','ğ','İ','ı','Ş','ş','Ċ','ċ', + 'Ġ','ġ','Ħ','ħ','Ŵ','ŵ','Ŷ','ŷ','Ŀ','ŀ','Œ','œ','Ÿ','„','“','”','‘','’','«','»','‹','›','¿','¡', + '€','£'] - file: "${FONT_DIR}/Roboto-400.ttf" id: sublabel size: ${FONT_SUBLABEL_SIZE} bpp: 4 - glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°', - '0','1','2','3','4','5','6','7','8','9', - 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z', - ' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z', - 'ä','ö','ü','Ä','Ö','Ü','ß','/','…','·'] + glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°','0','1','2','3','4','5', + '6','7','8','9','A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T', + 'U','V','W','X','Y','Z',' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q', + 'r','s','t','u','v','w','x','y','z','/','…','·', + 'À','Á','Â','Ã','Ä','Å','Æ','Ç','È','É','Ê','Ë','Ì','Í','Î','Ï','Ð','Ñ','Ò','Ó','Ô','Õ','Ö','Ø', + 'Ù','Ú','Û','Ü','Ý','Þ','ß','à','á','â','ã','ä','å','æ','ç','è','é','ê','ë','ì','í','î','ï','ð', + 'ñ','ò','ó','ô','õ','ö','ø','ù','ú','û','ü','ý','þ','ÿ','Ą','ą','Ć','ć','Ę','ę','Ł','ł','Ń','ń', + 'Ś','ś','Ź','ź','Ż','ż','Č','č','Ď','ď','Ě','ě','Ň','ň','Ř','ř','Š','š','Ť','ť','Ů','ů','Ž','ž', + 'Ĺ','ĺ','Ľ','ľ','Ŕ','ŕ','Ő','ő','Ű','ű','Ă','ă','Ș','ș','Ț','ț','Đ','đ','Ā','ā','Ē','ē','Ģ','ģ', + 'Ī','ī','Ķ','ķ','Ļ','ļ','Ņ','ņ','Ū','ū','Ė','ė','Į','į','Ų','ų','Ğ','ğ','İ','ı','Ş','ş','Ċ','ċ', + 'Ġ','ġ','Ħ','ħ','Ŵ','ŵ','Ŷ','ŷ','Ŀ','ŀ','Œ','œ','Ÿ','„','“','”','‘','’','«','»','‹','›','¿','¡', + '€','£'] - file: "${FONT_DIR}/Roboto-400.ttf" id: sublabel_big size: ${FONT_SUBLABEL_BIG_SIZE} bpp: 4 - glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°', - '0','1','2','3','4','5','6','7','8','9', - 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z', - ' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z', - 'ä','ö','ü','Ä','Ö','Ü','ß','/','…','·',"'",'#','*','=',';','²','³','µ','é','è','ë','ê','ï','î','ó','ô','à','á','â','ç','ñ','ú','û','–'] + glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°','0','1','2','3','4','5', + '6','7','8','9','A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T', + 'U','V','W','X','Y','Z',' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q', + 'r','s','t','u','v','w','x','y','z','/','…','·',"'",'#','*','=',';','²','³','µ','–', + 'À','Á','Â','Ã','Ä','Å','Æ','Ç','È','É','Ê','Ë','Ì','Í','Î','Ï','Ð','Ñ','Ò','Ó','Ô','Õ','Ö','Ø', + 'Ù','Ú','Û','Ü','Ý','Þ','ß','à','á','â','ã','ä','å','æ','ç','è','é','ê','ë','ì','í','î','ï','ð', + 'ñ','ò','ó','ô','õ','ö','ø','ù','ú','û','ü','ý','þ','ÿ','Ą','ą','Ć','ć','Ę','ę','Ł','ł','Ń','ń', + 'Ś','ś','Ź','ź','Ż','ż','Č','č','Ď','ď','Ě','ě','Ň','ň','Ř','ř','Š','š','Ť','ť','Ů','ů','Ž','ž', + 'Ĺ','ĺ','Ľ','ľ','Ŕ','ŕ','Ő','ő','Ű','ű','Ă','ă','Ș','ș','Ț','ț','Đ','đ','Ā','ā','Ē','ē','Ģ','ģ', + 'Ī','ī','Ķ','ķ','Ļ','ļ','Ņ','ņ','Ū','ū','Ė','ė','Į','į','Ų','ų','Ğ','ğ','İ','ı','Ş','ş','Ċ','ċ', + 'Ġ','ġ','Ħ','ħ','Ŵ','ŵ','Ŷ','ŷ','Ŀ','ŀ','Œ','œ','Ÿ','„','“','”','‘','’','«','»','‹','›','¿','¡', + '€','£'] # Material Design Icons (MDI) — included at fonts/materialdesignicons-webfont.ttf (Apache 2.0) # Source: https://github.com/Templarian/MaterialDesign-Webfont @@ -1157,6 +1195,12 @@ text_sensor: entity_category: diagnostic lambda: 'return {"${SCREEN_FIRMWARE_VERSION}"};' update_interval: 60s + # The language this firmware was built in (0.2.90+): ESP Screens offers an update when it differs from the chosen one. + - platform: template + name: "Screen language" + entity_category: diagnostic + lambda: 'return {screen_text::LANGUAGE};' + update_interval: 60s # ESP Screen Manager matches the screen to its YAML profile and OTA address. - platform: template name: "Device name" @@ -1216,17 +1260,6 @@ switch: - lambda: 'settings_screen::set("dark_mode", 1);' turn_off_action: - lambda: 'settings_screen::set("dark_mode", 0);' - - platform: template - id: setting_clock_24h - name: "24-hour clock" - icon: "mdi:clock-outline" - entity_category: config - restore_mode: DISABLED - lambda: return screen_settings::current.clock_24h; - turn_on_action: - - lambda: 'settings_screen::set("clock_24h", 1);' - turn_off_action: - - lambda: 'settings_screen::set("clock_24h", 0);' - platform: template id: setting_auto_home name: "Back to page 1" @@ -1461,7 +1494,7 @@ lvgl: width: ${ALERT_TEXT_W} height: ${ALERT_TITLE_H} long_mode: DOT - text: "Notification" + text: !lambda 'return std::string(screen_text::tr(screen_text::txt::alert_notification));' text_font: headline clickable: false - label: @@ -1505,7 +1538,7 @@ lvgl: - label: id: alert_ok_label align: CENTER - text: "OK" + text: !lambda 'return std::string(screen_text::tr(screen_text::txt::alert_ok));' text_font: headline clickable: false @@ -2717,7 +2750,7 @@ lvgl: id: climate_set_label align: CENTER y: ${CLIMATE_CAPTION_Y} - text: "Target" + text: !lambda 'return std::string(screen_text::tr(screen_text::txt::climate_target));' text_font: sublabel styles: paint_subtle clickable: false @@ -2912,7 +2945,7 @@ lvgl: - label: align: TOP_MID y: ${PICKER_TITLE_Y} - text: "Mode" + text: !lambda 'return std::string(screen_text::tr(screen_text::txt::climate_mode));' text_font: sublabel_big clickable: false @@ -2954,7 +2987,7 @@ lvgl: widgets: - label: align: CENTER - text: "Off" + text: !lambda 'return std::string(screen_text::tr(screen_text::txt::ha_climate_off));' text_font: sublabel_big clickable: false - obj: @@ -2973,7 +3006,7 @@ lvgl: widgets: - label: align: CENTER - text: "Heat" + text: !lambda 'return std::string(screen_text::tr(screen_text::txt::ha_climate_heat));' text_font: sublabel_big clickable: false - obj: @@ -2992,7 +3025,7 @@ lvgl: widgets: - label: align: CENTER - text: "Cool" + text: !lambda 'return std::string(screen_text::tr(screen_text::txt::ha_climate_cool));' text_font: sublabel_big clickable: false - obj: @@ -3011,7 +3044,7 @@ lvgl: widgets: - label: align: CENTER - text: "Heat/Cool" + text: !lambda 'return std::string(screen_text::tr(screen_text::txt::ha_climate_heat_cool));' text_font: sublabel_big clickable: false - obj: @@ -3030,7 +3063,7 @@ lvgl: widgets: - label: align: CENTER - text: "Auto" + text: !lambda 'return std::string(screen_text::tr(screen_text::txt::ha_climate_auto));' text_font: sublabel_big clickable: false - obj: @@ -3049,7 +3082,7 @@ lvgl: widgets: - label: align: CENTER - text: "Dry" + text: !lambda 'return std::string(screen_text::tr(screen_text::txt::ha_climate_dry));' text_font: sublabel_big clickable: false - obj: @@ -3068,7 +3101,7 @@ lvgl: widgets: - label: align: CENTER - text: "Fan only" + text: !lambda 'return std::string(screen_text::tr(screen_text::txt::ha_climate_fan_only));' text_font: sublabel_big clickable: false @@ -3076,7 +3109,7 @@ lvgl: id: cfan_title width: 100% height: ${PICKER_TITLE_H} - text: "Fan" + text: !lambda 'return std::string(screen_text::tr(screen_text::txt::climate_fan));' text_font: sublabel styles: paint_subtle text_align: CENTER @@ -3165,7 +3198,7 @@ lvgl: id: cswing_title width: 100% height: ${PICKER_TITLE_H} - text: "Swing" + text: !lambda 'return std::string(screen_text::tr(screen_text::txt::climate_swing));' text_font: sublabel styles: paint_subtle text_align: CENTER @@ -3310,7 +3343,7 @@ lvgl: - label: align: TOP_MID y: ${TOUCH_TEST_TITLE_Y} - text: ${TOUCH_TEST_TITLE} + text: !lambda 'return std::string(screen_text::tr(screen_text::txt::${TOUCH_TEST_TITLE}));' text_font: ${TOUCH_TEST_TITLE_FONT} - obj: x: ${TOUCH_TEST_INSET} @@ -4192,6 +4225,11 @@ script: } +# The screen's texts in the LANGUAGE substitution's language: the build puts that one language in the firmware +# (components/smart_display). +smart_display: + language: ${LANGUAGE} + # HA blueprint transport; never publishes the JSON command as an HA state. text: - platform: smart_display diff --git a/screen_manager/Dockerfile b/screen_manager/Dockerfile index 54ea63d..4072d94 100644 --- a/screen_manager/Dockerfile +++ b/screen_manager/Dockerfile @@ -5,6 +5,8 @@ ARG BUILD_ARCH LABEL io.hass.version="${BUILD_VERSION}" io.hass.type="app" io.hass.arch="${BUILD_ARCH}" RUN pip install --no-cache-dir 'aiohttp>=3.12,<4' PyYAML COPY app /app +# Every text of the app, the editor and the screens, one file per language (app 0.2.90). +COPY translations /app/translations # The Update badge shows what's new from the changelog (app 0.2.73). COPY CHANGELOG.md /app/CHANGELOG.md WORKDIR /app diff --git a/screen_manager/app/core.py b/screen_manager/app/core.py index f4c2645..04dda4d 100644 --- a/screen_manager/app/core.py +++ b/screen_manager/app/core.py @@ -182,6 +182,8 @@ NAME_SCREEN_FIRMWARE = ('Screen firmware', 'Schermfirmware') NAME_GUITION_TYPE = ('Guition screen type', 'Guition schermtype') NAME_DEVICE_NAME = ('Device name', 'Apparaatnaam') NAME_IP_ADDRESS = ('IP address', 'IP-adres') +# The language a screen's firmware was built in (firmware 0.2.76+, app 0.2.90); older firmware speaks English. +NAME_SCREEN_LANGUAGE = ('Screen language',) SCREEN_ENTITY_NAMES = frozenset(NAME_TILE_SETTINGS + NAME_SCREEN_FIRMWARE + NAME_GUITION_TYPE + NAME_DEVICE_NAME + NAME_IP_ADDRESS) def parse_firmware(text): @@ -304,7 +306,6 @@ SETTING_ENTITIES = { 'night_start': ('time', 'Night starts'), 'night_end': ('time', 'Night ends'), 'night_brightness': ('number', 'Night brightness'), - 'clock_24h': ('switch', '24-hour clock'), 'auto_home': ('switch', 'Back to page 1'), 'auto_home_seconds': ('number', 'Back to page 1 after'), 'home_on_standby': ('switch', 'Back to page 1 on standby'), @@ -315,6 +316,8 @@ SETTING_ENTITIES = { } # Entities firmware 0.2.49 added; one of them on a device means the screen owns its settings. The first five # existed before, so they cannot tell. +# The 12 or 24 hours is Settings -> Language & region's since app 0.2.90, for every screen at once: firmware 0.2.76 has no +# "24-hour clock" entity any more, and the one of older firmware is only a marker. OWNED_SETTINGS_MARKERS = frozenset(('Night mode', 'Night starts', 'Night ends', '24-hour clock', 'Back to page 1', 'Back to page 1 after', 'Back to page 1 on standby', 'Swipe between pages')) ROTATION_OPTIONS = ('0°', '90°', '180°', '270°') @@ -1485,6 +1488,7 @@ def discover_screens(registry, states, devices, areas): return found nodes = diagnostic(NAME_DEVICE_NAME, r'[a-z0-9][a-z0-9-]{0,30}') addresses = diagnostic(NAME_IP_ADDRESS, r'\d{1,3}(\.\d{1,3}){3}') + languages = diagnostic(NAME_SCREEN_LANGUAGE, r'[a-z]{2,3}(-[A-Za-z0-9]{2,8})?') screens = [] for item in registry: eid = item['entity_id'] @@ -1502,6 +1506,7 @@ def discover_screens(registry, states, devices, areas): 'firmware_known': known_firmware(firmware, device.get('sw_version')), 'board': boards.get(item.get('device_id'), 'unknown'), 'node': nodes.get(item.get('device_id')), 'ip': addresses.get(item.get('device_id')), + 'language': languages.get(item.get('device_id')), 'device': device.get('name') or '', 'area': area, 'online': state.get('state') not in (None, 'unknown', 'unavailable'), 'status': state.get('state', 'Not connected')}) @@ -1544,6 +1549,8 @@ def installation_yaml(data): if not isinstance(friendly, str) or not friendly.strip() or len(friendly) > 60: raise ValueError('Give the screen a recognizable name (60 characters max).') quote = lambda s: json.dumps(s, ensure_ascii=False) + # The language of the screen's texts (app 0.2.90): Settings -> Language & region, which ESP Screens passes in. + language = data.get('language') if isinstance(data.get('language'), str) and re.fullmatch(r'[a-z]{2,3}(-[A-Za-z0-9]{2,8})?', data.get('language')) else 'en' # An OTA password, not yet `ota: encryption:` with the api key: ESPHome before 2026.9 refuses that, and the owner's # ESPHome Device Builder may still be older (docs/RELEASING.md, Compatibility 0.2.89). key, ota, ap = base64.b64encode(secrets.token_bytes(32)).decode(), secrets.token_urlsafe(24), secrets.token_urlsafe(12) @@ -1552,6 +1559,7 @@ def installation_yaml(data): substitutions: DEVICE_NAME: {quote(name)} DEVICE_FRIENDLY_NAME: {quote(friendly.strip())} + LANGUAGE: {quote(language)} esphome: name: {quote(name)} diff --git a/screen_manager/app/firmware.py b/screen_manager/app/firmware.py index c01a59b..e396bff 100644 --- a/screen_manager/app/firmware.py +++ b/screen_manager/app/firmware.py @@ -42,7 +42,8 @@ class Firmware: OVERRIDE_LIMIT = 12 * 1024 # bytes; checked here, far below the request body limit (128 KB since app 0.2.78) PROTECTED_OVERRIDE_KEYS = frozenset({'esphome', 'api', 'ota', 'wifi', 'packages', 'external_components', 'captive_portal'}) - PROTECTED_SUBSTITUTIONS = frozenset({'DEVICE_NAME', 'DEVICE_FRIENDLY_NAME', 'SCREEN_FIRMWARE_VERSION'}) + # LANGUAGE follows Settings -> Language & region (app 0.2.90), which writes it into the profile itself. + PROTECTED_SUBSTITUTIONS = frozenset({'DEVICE_NAME', 'DEVICE_FRIENDLY_NAME', 'SCREEN_FIRMWARE_VERSION', 'LANGUAGE'}) # ESPHome's "Factory format" (bootloader, partition table and app from address 0), the file ESPHome Web # flashes on a board. A build writes it next to firmware.bin: under .pioenvs// with PlatformIO, # under build/ with ESPHome's native ESP-IDF toolchain. @@ -187,6 +188,32 @@ class Firmware: # The key is what Home Assistant asks for when pairing; the page shows it once. return {'file': profile.name, 'node': data['name'], 'api_key': key} + def set_language(self, name, language): + """Let the screen's next build speak `language` (app 0.2.90): the LANGUAGE line of the profile's substitutions, + changed or added without reformatting the user's YAML. A profile that doesn't build from ESP Screens' packages is + left alone, as is an English one without the line (English is the packages' own default). True when it changed.""" + profile = self.profile(name) + text = profile.read_text() + if 'homeassistant_espscreen' not in text and 'packages/core.yaml' not in text: + return False + line = f' LANGUAGE: {json.dumps(language)}' + block = re.search(r'(?m)^substitutions:[ \t]*(?:#.*)?\n((?:[ \t]+.*\n|[ \t]*\n)*)', text) + if block and re.search(r'(?m)^[ \t]+LANGUAGE:', block.group(1)): + start = block.start(1) + body = re.sub(r'(?m)^[ \t]+LANGUAGE:.*$', line, block.group(1), count=1) + updated = text[:start] + body + text[block.end(1):] + elif language == 'en': + return False + elif block: + updated = text[:block.start(1)] + line + '\n' + text[block.start(1):] + else: + updated = f'substitutions:\n{line}\n\n' + text + if updated == text: + return False + self._atomic_write(profile, updated) + self._names.pop(profile.name, None) + return True + def _override_path(self, name): profile = self.profile(name) path = self.root / (profile.stem + self.OVERRIDE_SUFFIX) diff --git a/screen_manager/app/header_bar.py b/screen_manager/app/header_bar.py index 4702517..46f0ea3 100644 --- a/screen_manager/app/header_bar.py +++ b/screen_manager/app/header_bar.py @@ -19,7 +19,9 @@ from core import HEADER_BUILTIN, HEADER_CONTENTS, HEADER_MAX_ITEMS, HEADER_MIN_F # Characters the top bar's text font carries on both boards (`sublabel_big` in the profiles; # tests/test_header_bar.py keeps them equal). Anything else folds to its base letter or goes. -GLYPHS = frozenset("<>—&@!,.?\"%()+-_:°0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyzäöüÄÖÜß/…·'#*=;²³µéèëêïîóôàáâçñúû–") +# The characters the screen's text fonts carry (the sublabel_big glyph list; tests/test_header_bar.py keeps them equal): +# every letter European languages write with since app 0.2.90. +GLYPHS = frozenset('<>—&@!,.?"%()+-_:°0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz/…·\'#*=;²³µ–ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿĄąĆćĘꣳŃńŚśŹźŻżČčĎďĚěŇňŘřŠšŤťŮůŽžĹ弾ŔŕŐőŰűĂăȘșȚțĐđĀāĒēĢģĪīĶķĻļŅņŪūĖėĮįŲųĞğİıŞşĊċĠġĦħŴŵŶŷĿŀŒœŸ„“”‘’«»‹›¿¡€£') TEXT_BYTES = 40 MONTHS = ('Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec') UNAVAILABLE = '—' diff --git a/screen_manager/app/i18n.py b/screen_manager/app/i18n.py new file mode 100644 index 0000000..521cb40 --- /dev/null +++ b/screen_manager/app/i18n.py @@ -0,0 +1,213 @@ +"""The languages of ESP Screens (app 0.2.90). + +Every text of the app, the editor and the screens lives in one JSON file per language (screen_manager/translations, +docs/TRANSLATING.md); English is the source, and a text a language lacks shows in English. The editor reads its own +section in the browser, the screens get theirs built into their firmware (components/smart_display/screen_text_gen.py), +and this module gives the app its `addon` texts and the `screen` words it sends to the screens itself. + +`Region` is Settings -> Language & region, the one place for every screen: the language (Home Assistant's, or a chosen +one), the clock (12 or 24 hours) and how numbers are written. "auto" does what Home Assistant does for a user who never +changed their profile: the clock and the numbers follow the language. +""" +import contextvars +import json +import logging +import os +import re +from pathlib import Path + +LOG = logging.getLogger('screen_manager') +HERE = Path(__file__).resolve().parent +# In the app's image the files sit next to the code (Dockerfile: COPY translations); in a checkout one folder up. +FOLDER = next((path for path in (HERE / 'translations', HERE.parent / 'translations') if path.is_dir()), HERE / 'translations') +CODE = re.compile(r'[a-z]{2,3}(-[A-Za-z0-9]{2,8})?') +CLOCKS = ('auto', '24', '12') +NUMBERS = ('auto', 'point', 'comma', 'space') +PLACEHOLDER = re.compile(r'\{([a-z_]+)\}') + +# Which form of a plural text ("1 hour ago | {n} hours ago") fits n; the firmware (screen_text_gen.py) and the editor +# (web/src/i18n.ts) have the same rules, docs/TRANSLATING.md says which languages use which. +def _slavic(n, one): + t, h = n % 10, n % 100 + if one: + return 0 + return 1 if 2 <= t <= 4 and not 12 <= h <= 14 else 2 + +PLURAL = { + 'one_other': lambda n: 0 if n == 1 else 1, + 'one_upto_1': lambda n: 0 if abs(n) <= 1 else 1, + 'slavic_pl': lambda n: _slavic(n, n == 1), + 'east_slavic': lambda n: _slavic(n, n % 10 == 1 and n % 100 != 11), + 'none': lambda n: 0, +} + +# The language of an editor's request (its X-ESP-Screens-Language header), for the messages the app answers with. +REQUEST_LANGUAGE = contextvars.ContextVar('request_language', default='en') + + +def flatten(node, prefix=''): + """(key, text) pairs in document order; a list's items get .0, .1, ... .""" + if isinstance(node, dict): + items = node.items() + elif isinstance(node, list): + items = ((str(index), value) for index, value in enumerate(node)) + else: + return [(prefix, node)] + pairs = [] + for key, value in items: + pairs.extend(flatten(value, f'{prefix}.{key}' if prefix else key)) + return pairs + + +class Translations: + """Every language file, read once.""" + + def __init__(self, folder=FOLDER): + self.data, self.flat = {}, {} + for path in sorted(Path(folder).glob('*.json')): + try: + data = json.loads(path.read_text(encoding='utf-8')) + except (OSError, ValueError) as error: + LOG.warning('Translation %s is unreadable (%s); it is left out', path.name, type(error).__name__) + continue + if CODE.fullmatch(path.stem) and isinstance(data, dict): + self.data[path.stem] = data + self.flat[path.stem] = {key: value for key, value in flatten({k: v for k, v in data.items() if k != '_meta'}) + if isinstance(value, str)} + self.data.setdefault('en', {'_meta': {'name': 'English', 'english': 'English', 'plural': 'one_other'}}) + self.flat.setdefault('en', {}) + + def resolve(self, code): + """The language ESP Screens has for `code`: the code itself (in any case), else its base (pt-BR -> pt), else English.""" + if not isinstance(code, str): + return 'en' + by_lower = {known.lower(): known for known in self.data} + for candidate in (code, code.split('-')[0]): + if candidate.lower() in by_lower: + return by_lower[candidate.lower()] + return 'en' + + def meta(self, code): + return self.data.get(self.resolve(code), {}).get('_meta', {}) + + def languages(self): + """Every language for the settings: English first, then by English name.""" + found = [{'code': code, 'name': str(self.meta(code).get('name') or code), + 'english': str(self.meta(code).get('english') or code), 'checked': self.meta(code).get('checked') is True} + for code in self.data] + return sorted(found, key=lambda item: (not item['code'].startswith('en'), item['code'] != 'en', item['english'])) + + def text(self, key, language='en', **params): + """`key` in `language`, English where it has none, with {placeholders} filled; `n` picks a plural form.""" + language = self.resolve(language) + text = self.flat.get(language, {}).get(key) or self.flat['en'].get(key) + if not isinstance(text, str) or not text.strip(): + return key + if '|' in text and 'n' in params: + forms = [form.strip() for form in text.split('|')] + rule = PLURAL.get(self.meta(language).get('plural'), PLURAL['one_other']) + try: + text = forms[min(rule(int(params['n'])), len(forms) - 1)] + except (TypeError, ValueError): + text = forms[-1] + return PLACEHOLDER.sub(lambda match: str(params[match.group(1)]) if match.group(1) in params else match.group(0), text) + + def clock_24h(self, code): + """Whether the language writes 24 hours by default (_meta.clock), as Home Assistant's "auto" does.""" + return str(self.meta(code).get('clock', '24')) != '12' + + def number_style(self, code): + """How the language writes 1234.5: point (1,234.5), comma (1.234,5) or space (1 234,5).""" + decimal = self.text('screen.number.decimal', code) + group = self.text('screen.number.group', code) + if decimal == '.': + return 'point' + return 'space' if group.strip() == '' else 'comma' + + +TRANSLATIONS = Translations() + + +def t(key, **params): + """An `addon` text in the language of the editor that asked (REQUEST_LANGUAGE).""" + return TRANSLATIONS.text(key, REQUEST_LANGUAGE.get(), **params) + + +class Region: + """Settings -> Language & region, kept in /data/language.json: one language, clock and number format for every + screen. A screen's firmware carries its language; the clock and the numbers travel in the layout message.""" + + def __init__(self, path, translations=TRANSLATIONS, ha_language=None): + self.path, self.tr = Path(path), translations + self.setting, self.clock, self.numbers = 'auto', 'auto', 'auto' + # Home Assistant's own language (get_config), once connected. + self.ha_language = ha_language or (lambda: None) + self.stored = self.path.exists() + if self.stored: + try: + raw = json.loads(self.path.read_text(encoding='utf-8')) + except (OSError, ValueError): + raw = {} + if raw.get('version') == 1: + self.setting = raw.get('language') if raw.get('language') == 'auto' or raw.get('language') in self.tr.data else 'auto' + self.clock = raw.get('clock') if raw.get('clock') in CLOCKS else 'auto' + self.numbers = raw.get('numbers') if raw.get('numbers') in NUMBERS else 'auto' + + def save(self): + self.path.parent.mkdir(parents=True, exist_ok=True) + temporary = self.path.with_suffix('.tmp') + with open(temporary, 'w', encoding='utf-8') as handle: + os.chmod(temporary, 0o600) + json.dump({'version': 1, 'language': self.setting, 'clock': self.clock, 'numbers': self.numbers}, handle) + handle.flush() + os.fsync(handle.fileno()) + temporary.replace(self.path) + self.stored = True + + def change(self, data): + """Apply {language?, clock?, numbers?} from the editor; ValueError (translated) for anything unknown.""" + if not isinstance(data, dict): + raise ValueError(t('addon.errors.language_choice')) + setting = data.get('setting', self.setting) + clock = data.get('clock', self.clock) + numbers = data.get('numbers', self.numbers) + if setting != 'auto' and setting not in self.tr.data: + raise ValueError(t('addon.errors.language_choice')) + if clock not in CLOCKS or numbers not in NUMBERS: + raise ValueError(t('addon.errors.region_choice')) + self.setting, self.clock, self.numbers = setting, clock, numbers + self.save() + + def language(self): + """The language of every screen: the chosen one, else Home Assistant's, else English.""" + return self.setting if self.setting != 'auto' else self.tr.resolve(self.ha_language() or 'en') + + def clock_24h(self): + return self.tr.clock_24h(self.language()) if self.clock == 'auto' else self.clock == '24' + + def number_style(self): + return self.tr.number_style(self.language()) if self.numbers == 'auto' else self.numbers + + def view(self): + """What the editor's Language & region card shows.""" + return {'setting': self.setting, 'effective': self.language(), + 'ha': self.ha_language(), 'languages': self.tr.languages(), + 'clock': self.clock, 'clock_effective': '24' if self.clock_24h() else '12', + 'numbers': self.numbers, 'numbers_effective': self.number_style()} + + def screen_text(self, key, **params): + """A `screen` or `addon` text in the screens' language, for what the app itself sends to them.""" + return self.tr.text(key, self.language(), **params) + + +def format_number(text, style): + """A number as Home Assistant sends it ("1234.5") written in `style` ("1.234,5" for comma); anything else unchanged.""" + if not isinstance(text, str) or not re.fullmatch(r'-?\d+(\.\d+)?', text): + return text + sign, body = ('-', text[1:]) if text.startswith('-') else ('', text) + whole, _, rest = body.partition('.') + group, decimal = {'point': (',', '.'), 'comma': ('.', ','), 'space': (' ', ',')}.get(style, (',', '.')) + if len(whole) > 3: + head = len(whole) % 3 or 3 + whole = whole[:head] + ''.join(group + whole[index:index + 3] for index in range(head, len(whole), 3)) + return sign + whole + (decimal + rest if rest else '') diff --git a/screen_manager/app/server.py b/screen_manager/app/server.py index 7e3ec6d..8dea2dc 100644 --- a/screen_manager/app/server.py +++ b/screen_manager/app/server.py @@ -24,6 +24,8 @@ from core import SETTING_ENTITIES, SETTING_RULES, setting_action, setting_entiti from core import PAGE_TILE_REPEAT_MIN_FIRMWARE, SLOTS_PER_PAGE, firmware_features, packed_slots, run_tile_event, screen_firmware, version_text import header_bar import history_card +import i18n +from i18n import REQUEST_LANGUAGE, TRANSLATIONS, Region, t from zoneinfo import ZoneInfo @@ -106,6 +108,12 @@ class HomeAssistant: self.time_zone = None # Home Assistant's unit system; a climate entity's temperature carries no unit of its own. self.units = {} + # Home Assistant's own language (get_config), and the one the screens speak (Settings -> Language & region, + # app 0.2.90), in which the words for states are fetched; the manager binds `language_of`. + self.ha_language = None + self.language_of = lambda: 'en' + self.config_stale = False + self.on_config = lambda: None # (event type, data) of alert events for every screen, for Manager.alert_loop. self.broadcasts = asyncio.Queue() # (event type, data) of tile events (app 0.2.51+), for Manager.tile_loop. @@ -118,11 +126,12 @@ class HomeAssistant: # 0.2.67); `services_rev` counts the refreshes. `targets` keeps Home Assistant's answer per entity, and # `target_lookup` stays None until it is known whether Home Assistant answers get_services_for_target (2025.12+). self.services, self.services_rev, self.targets, self.target_lookup = {}, 0, {}, None - # Home Assistant's own names and descriptions of actions and their fields (frontend/get_translations, English - # like the editor), for Perform action (app 0.2.67). `get_services` has carried no translated names since 2025.10. - self.service_names = {} - # Home Assistant's words for states (frontend/get_translations `entity_component` and `entity`, English), which a - # tile shows where it would show the raw state (app 0.2.67). + # Home Assistant's own names and descriptions of actions and their fields (frontend/get_translations), for Perform + # action (app 0.2.67); `get_services` has carried no translated names since 2025.10. Per language (app 0.2.90): the + # screens' one first, an editor in another language asks for its own (service_names_in). + self.service_names, self.service_names_by_language = {}, {} + # Home Assistant's words for states (frontend/get_translations `entity_component` and `entity`), in the screens' + # language (app 0.2.90), which a tile shows where it would show the raw state (app 0.2.67). self.state_words = {} self.esphome_services = False self._platforms_source, self._platforms = None, {} @@ -182,6 +191,10 @@ class HomeAssistant: self.changed.set() elif event.get('event_type') in REGISTRY_EVENTS: self.registry_changed.set() + elif event.get('event_type') == 'core_config_updated': + # Settings -> System -> General in Home Assistant, such as its language (app 0.2.90). + self.config_stale = True + self.services_changed.set() elif event.get('event_type') in SERVICE_EVENTS: # Every action counts for what the editor offers (app 0.2.67); a screen's own actions also decide # how the app talks to it. @@ -216,12 +229,14 @@ class HomeAssistant: if isinstance(services, dict): self.services, self.services_rev, self.targets = services, self.services_rev + 1, {} try: - names = await self.request('frontend/get_translations', language='en', category='services') + language = self.language_of() + names = await self.request('frontend/get_translations', language=language, category='services') if isinstance((names or {}).get('resources'), dict): self.service_names = names['resources'] + self.service_names_by_language = {language: names['resources']} words = {} for category in ('entity_component', 'entity'): - found = await self.request('frontend/get_translations', language='en', category=category) + found = await self.request('frontend/get_translations', language=language, category=category) if isinstance((found or {}).get('resources'), dict): words.update(found['resources']) if words: @@ -237,6 +252,34 @@ class HomeAssistant: # The editor then shows the names services.yaml still carries, and tiles keep their raw states. LOG.info('No action names or state words from Home Assistant (%s)', type(error).__name__) + async def service_names_in(self, language): + """Home Assistant's names of actions in `language` (the editor's), fetched once per language; the screens' own + when Home Assistant doesn't answer.""" + if language == self.language_of() or not self.service_names_by_language: + return self.service_names + if language not in self.service_names_by_language: + try: + names = await self.request('frontend/get_translations', language=language, category='services') + self.service_names_by_language[language] = (names or {}).get('resources') or self.service_names + except (Refused, TimeoutError, ConnectionError): + return self.service_names + return self.service_names_by_language[language] + + async def read_config(self): + """Units, time zone and language from Home Assistant's core config; again when someone changes them there.""" + self.config_stale = False + try: + config = await self.request('get_config') + self.units = config.get('unit_system') or {} + self.time_zone = ZoneInfo(config.get('time_zone') or 'UTC') + language = config.get('language') + changed = isinstance(language, str) and language != self.ha_language + self.ha_language = language if isinstance(language, str) else self.ha_language + if changed: + self.on_config() + except (Refused, TimeoutError, ConnectionError, ValueError, TypeError, AttributeError, KeyError): + self.time_zone = self.time_zone or timezone.utc + async def refresh_services(self): """fetch_services for the connection loop: a list that cannot be read only means no answers are asked for.""" try: @@ -310,13 +353,10 @@ class HomeAssistant: await self.request('subscribe_events', event_type=event_type) self.states = {s['entity_id']: s for s in await self.request('get_states')} await self.registries() + # The config first: its language decides the language of the words for states (app 0.2.90). + await self.request('subscribe_events', event_type='core_config_updated') + await self.read_config() await self.refresh_services() - try: - config = await self.request('get_config') - self.units = config.get('unit_system') or {} - self.time_zone = ZoneInfo(config.get('time_zone') or 'UTC') - except Exception: - self.time_zone = timezone.utc self.online = True self.changed.set() connected = time.monotonic() @@ -337,6 +377,8 @@ class HomeAssistant: # A screen that reconnects registers its actions one after the other. await asyncio.sleep(1) self.services_changed.clear() + if self.config_stale: + await self.read_config() await self.refresh_services() if self.esphome_services: self.esphome_services = False @@ -524,6 +566,11 @@ class Manager: self.listeners = set() # asyncio.Event per open /api/events stream self.firmware = Firmware(os.environ.get("ESPHOME_CONFIG", "/homeassistant/esphome"), self.path.parent) self.updates = Updater(self, self.path.parent / 'updates.json') + # Settings -> Language & region (app 0.2.90): the language, clock and numbers of every screen. + self.region = Region(self.path.parent / 'language.json', ha_language=lambda: getattr(self.ha, 'ha_language', None)) + self.ha.language_of = self.region.language + self.ha.on_config = self.language_changed + self.pin_clock = False self.skill_dir = claude_skill.skill_dir() self._registry_source, self._registry_index = None, {} self._items_source, self._items = None, [] @@ -556,6 +603,12 @@ class Manager: raise ValueError('Unknown storage version; data stays unchanged.') # Loaded leniently: a tile setting this version doesn't know (saved by a newer one) never stops the app. self.layouts = {key: validate_layout(value, stored=True) for key, value in raw['screens'].items()} + # An app from before Language & region (app 0.2.90): its screens keep the 24 hours they show; language_changed + # makes it 12 once Home Assistant says every screen's own clock setting was 12 hours. + if not self.region.stored and self.layouts: + self.region.clock = '24' + self.region.save() + self.pin_clock = True def inventory(self): """(screens, every tile/top-bar entity): the full walk over the registry, for the editor and saving.""" @@ -1085,6 +1138,38 @@ class Manager: for listener in self.listeners: listener.set() + # ----- Language & region (app 0.2.90) ----- + def language_changed(self): + """The screens' language may have changed (Settings -> Language & region, or Home Assistant's own): the words for + states come again in it, every screen gets all its messages anew, and each screen's YAML gets the language its + next firmware update builds in, which makes that update show.""" + if self.pin_clock: + self.pin_clock = False + clocks = [self.ha.states.get(item['entity_id'], {}).get('state') for item in getattr(self.ha, 'registry', []) + if item.get('platform') == 'esphome' and item.get('original_name') == '24-hour clock'] + known = [state for state in clocks if state in ('on', 'off')] + if known and all(state == 'off' for state in known): + self.region.clock = '12' + self.region.save() + self.ha.services_changed.set() + self.sent.clear() + self.write_languages() + self.ha.changed.set() + self.notify() + + def write_languages(self): + """Every paired screen's YAML says the language of its next build (the LANGUAGE substitution).""" + language, profiles = self.region.language(), self.firmware.profile_names() + for screen in self.screens(): + profile, _ = self.updates.resolve(screen, profiles) + if not profile: + continue + try: + if self.firmware.set_language(profile, language): + LOG.info('%s builds in %s from its next update', profile, language) + except (OSError, ValueError) as error: + LOG.warning('Could not write the language into %s (%s)', profile, error) + def pending_profiles(self, screens, profiles): """ESP Screens profiles without a paired screen: flashed but not yet added in Home Assistant, or not flashed yet. @@ -1182,6 +1267,12 @@ class Manager: message['auto_home_seconds'] = layout['settings'].get('auto_home_seconds',120) if screen.get('board')=='guition': message['rotation'] = layout['settings'].get('rotation',0) + # The clock and the number format of Settings -> Language & region (app 0.2.90), the same on every screen; firmware + # before 0.2.76 ignores both and keeps a clock setting of its own. + message['clock_24h'] = self.region.clock_24h() + message['numbers'] = self.region.number_style() + if 'settings' in message: + message['settings']['clock_24h'] = message['clock_24h'] # The revision the screen echoes on every ping (firmware 0.2.33+; older firmware ignores it). message['rev'] = revision(message) return message @@ -1600,6 +1691,8 @@ def create_app(manager, development=False): csrf = secrets.token_urlsafe(32) @web.middleware async def guard(request, handler): + # The editor's language for the messages this request answers with (app 0.2.90). + REQUEST_LANGUAGE.set(TRANSLATIONS.resolve(request.headers.get('X-ESP-Screens-Language') or 'en')) allowed = {'127.0.0.1', '::1'} if development else {'172.30.32.2'} if request.remote not in allowed: raise web.HTTPForbidden(text='Open this page through Home Assistant.') @@ -1665,7 +1758,8 @@ def create_app(manager, development=False): screen.update(firmware_features(version)) return {'csrf': csrf, 'connected': manager.ha.online, 'screens': screens, 'pending': manager.pending_profiles(screens, profiles), - 'updates': manager.updates.summary(screens, profiles)} + 'updates': manager.updates.summary(screens, profiles), + 'language': manager.region.view()} async def inventory(request): if request.query.get('light') == '1': # The page polls the light form; entities, backgrounds and icons (~100 KB) only on demand. @@ -1742,8 +1836,18 @@ def create_app(manager, development=False): actions = await ha.entity_actions(entity) if entity_id(entity) and hasattr(ha, 'entity_actions') else None if actions is None: return web.json_response({'actions': None}) + # Home Assistant's names in the editor's own language (app 0.2.90). + names = await ha.service_names_in(REQUEST_LANGUAGE.get()) if hasattr(ha, 'service_names_in') else getattr(ha, 'service_names', {}) return web.json_response({'actions': ha_catalogue.action_choices(entity, actions, ha.states.get(entity), ha.services, - getattr(ha, 'service_names', {}), ha.platform_of(entity))}) + names, ha.platform_of(entity))}) + async def change_language(request): + """Settings -> Language & region (app 0.2.90): the language, clock and number format of every screen.""" + data = await request.json() + before = (manager.region.language(), manager.region.clock_24h(), manager.region.number_style()) + manager.region.change({key: data[key] for key in ('setting', 'clock', 'numbers') if isinstance(data, dict) and key in data}) + if before != (manager.region.language(), manager.region.clock_24h(), manager.region.number_style()): + manager.language_changed() + return web.json_response({'language': manager.region.view()}) async def change_settings(request): """Screen settings apply one change at a time, like the settings page on the screen (app 0.2.57).""" data = await request.json() @@ -1834,8 +1938,12 @@ def create_app(manager, development=False): raise ValueError('Invalid override data.') return web.json_response(manager.firmware.save_override(request.match_info['file'], data.get('content'))) async def firmware_create(request): - # Profile, missing wifi secrets and (with a USB port or the download) the build and flash in one request. - return web.json_response(manager.firmware.install(await request.json())) + # Profile, missing wifi secrets and (with a USB port or the download) the build and flash in one request; the + # screen speaks the language of Settings -> Language & region from its first build (app 0.2.90). + data = await request.json() + if isinstance(data, dict): + data['language'] = manager.region.language() + return web.json_response(manager.firmware.install(data)) async def firmware_download(request): """New screen and Firmware & USB → Download: the factory image this app just built, for ESPHome Web on the owner's own computer. Like the profile it came from, it holds the Wi-Fi password and the screen's keys.""" @@ -1852,6 +1960,7 @@ def create_app(manager, development=False): app.router.add_post('/api/screens/{inbox}/update', update_screen) app.router.add_post('/api/updates/run', update_all) app.router.add_put('/api/updates', update_settings) + app.router.add_put('/api/language', change_language) app.router.add_get('/api/firmware', firmware_status) app.router.add_post('/api/firmware/jobs', firmware_start) app.router.add_get('/api/firmware/profiles/{file}/override', firmware_override) diff --git a/screen_manager/app/updates.py b/screen_manager/app/updates.py index cb714c9..e89bc09 100644 --- a/screen_manager/app/updates.py +++ b/screen_manager/app/updates.py @@ -87,8 +87,15 @@ class Updater: profile = matches[0] if len(matches) == 1 else None return profile, screen.get('ip') or self.hosts.get(screen['id']) + def language_due(self, screen): + """True when a screen speaks another language than Settings -> Language & region says (app 0.2.90): its + "Screen language" sensor, or English for firmware from before that sensor.""" + region = getattr(self.manager, 'region', None) + return bool(region) and (screen.get('language') or 'en') != region.language() + def state_for(self, screen, profiles=None): version = parse_version(screen.get('firmware')) + language = self.language_due(screen) profile, host = self.resolve(screen, profiles) if screen['id'] == self.current: state = 'running' @@ -96,7 +103,8 @@ class Updater: state = 'queued' else: state = 'idle' - return {'available': bool(version and TARGET and version < TARGET), 'target': FIRMWARE_VERSION, + return {'available': bool(version and TARGET and version < TARGET) or (bool(version) and language), + 'language': bool(version) and language, 'target': FIRMWARE_VERSION, 'profile': profile, 'host': host, 'state': state, 'phase': self.phase if state == 'running' else None, 'result': self.results.get(screen['id'])} @@ -221,7 +229,7 @@ class Updater: while time.monotonic() < deadline: screen = self.screen(inbox) version = parse_version(screen.get('firmware')) if screen else None - if screen and screen['online'] and version and version >= TARGET: + if screen and screen['online'] and version and version >= TARGET and not self.language_due(screen): return True await asyncio.sleep(self.poll_seconds) return False diff --git a/screen_manager/translations/en-GB.json b/screen_manager/translations/en-GB.json new file mode 100644 index 0000000..d9c984f --- /dev/null +++ b/screen_manager/translations/en-GB.json @@ -0,0 +1,16 @@ +{ + "_meta": { + "name": "English (UK)", + "english": "English (UK)", + "script": "latin", + "plural": "one_other", + "clock": "24", + "checked": true + }, + "screen": { + "light": { + "color": "Colour", + "color_temperature": "Colour temperature" + } + } +} diff --git a/screen_manager/translations/en.json b/screen_manager/translations/en.json index 2bba6c9..bf6143d 100644 --- a/screen_manager/translations/en.json +++ b/screen_manager/translations/en.json @@ -1,12 +1,395 @@ { "_meta": { - "name": "English", - "english": "English", + "name": "English (US)", + "english": "English (US)", "script": "latin", "plural": "one_other", + "clock": "12", "checked": true }, - "screen": {}, - "addon": {}, + "screen": { + "number": { + "decimal": ".", + "group": "," + }, + "time": { + "just_now": "Just now", + "minutes_ago": "{n} min ago", + "hours_ago": "1 hour ago | {n} hours ago", + "yesterday": "Yesterday", + "days_ago": "1 day ago | {n} days ago", + "weeks_ago": "1 week ago | {n} weeks ago", + "months_ago": "1 month ago | {n} months ago", + "years_ago": "1 year ago | {n} years ago", + "in_minutes": "In {n} min", + "in_hours": "In 1 hour | In {n} hours", + "tomorrow": "Tomorrow", + "in_days": "In 1 day | In {n} days", + "am": "AM", + "pm": "PM" + }, + "date": { + "weekdays": [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday" + ], + "weekdays_short": [ + "Sun", + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat" + ], + "weekdays_min": [ + "Su", + "Mo", + "Tu", + "We", + "Th", + "Fr", + "Sa" + ], + "months": [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December" + ], + "months_short": [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec" + ], + "top_bar": "{weekday} {day} {month}", + "full": "{weekday} {day} {month}", + "day_month": "{day} {month}", + "weekday_time": "{weekday} {time}" + }, + "duration": { + "seconds": "{n} s", + "minutes": "{n} min", + "hours": "{n} h", + "hours_minutes": "{h} h {m} min", + "days": "{n} d", + "days_hours": "{d} d {h} h" + }, + "status": { + "connecting": "Connecting to Home Assistant", + "waiting": "Waiting for ESP Screens", + "loading_tiles": "Loading tiles...", + "ha_not_connected": "HA not connected", + "manager_not_active": "ESP Screens not active", + "choose_tiles": "Choose tiles in HA", + "home": "Home", + "no_network": "No network" + }, + "settings": { + "title": "Settings", + "brightness": "Brightness", + "night": "Night", + "screen": "Screen", + "this_screen": "This screen", + "dark_mode": "Dark mode", + "auto_standby": "Auto standby", + "standby_after": "Standby after", + "standby_brightness": "Standby brightness", + "night_mode": "Night mode", + "starts": "Starts", + "ends": "Ends", + "night_brightness": "Night brightness", + "back_to_page_1": "Back to page 1", + "after": "After", + "also_on_standby": "Also on standby", + "swipe_between_pages": "Swipe between pages", + "page_buttons": "Page buttons", + "rotation": "Rotation", + "address": "Address", + "firmware": "Firmware", + "home_assistant": "Home Assistant", + "restart": "Restart", + "tap_again_to_restart": "Tap again to restart", + "seconds": "{n} sec", + "minutes": "{n} min", + "hours": "{n} h", + "hours_minutes": "{h} h {m}" + }, + "tile": { + "tap_to_open": "Tap to open", + "page": "Page {n}", + "unavailable": "Unavailable", + "refused": "Refused", + "command_sent": "Command sent...", + "confirmed": "Confirmed by Home Assistant" + }, + "alert": { + "notification": "Notification", + "ok": "OK" + }, + "light": { + "color": "Color", + "color_temperature": "Color temperature", + "brightness": "Brightness", + "waiting": "Waiting..." + }, + "effects": { + "effect": "Effect", + "nothing": "Nothing to choose", + "loading": "Loading...", + "not_connected": "Not connected" + }, + "climate": { + "target": "Target", + "mode": "Mode", + "fan": "Fan", + "swing": "Swing", + "now": "Now {value}" + }, + "vacuum": { + "check_robot": "Check the robot in HA", + "auto_suction": "Automatic suction power", + "robot_chooses": "The robot chooses suction and water", + "per_room": "Suction and water as set per room", + "speed_quiet": "Quiet", + "speed_balanced": "Normal", + "speed_turbo": "Turbo", + "speed_max": "Max", + "pause_cleaning": "Pause cleaning", + "pause": "Pause", + "resume": "Resume", + "start_cleaning": "Start cleaning", + "clean": "Clean", + "dock": "Dock" + }, + "cover": { + "position": "Position", + "tilt": "Tilt", + "tilt_value": "Tilt {n}%" + }, + "weather": { + "feels_like": "Feels like {n}°", + "no_forecast": "No daily forecast from Home Assistant", + "coming_days": "Coming days" + }, + "history": { + "no_data": "No data", + "average": "average", + "average_of": "average of {duration}", + "now": "Now", + "range_hour": "1 hour", + "range_day": "24 hours", + "range_week": "1 week", + "high": "High {value}", + "low": "Low {value}", + "times": "once | {n} times", + "loading": "Loading history...", + "empty": "No history in this period", + "unavailable": "No history available" + }, + "media": { + "muted": "Muted", + "not_playing": "Not playing", + "loading": "Loading" + }, + "timer": { + "running": "Running", + "paused": "Paused", + "stopped": "Stopped", + "pause": "Pause", + "start": "Start", + "cancel": "Cancel", + "paused_left": "Paused {time}" + }, + "sun": { + "name": "Sun", + "sunrise": "Sunrise {time}", + "sunset": "Sunset {time}", + "rise": "rise {time}", + "set": "set {time}" + }, + "script": { + "running": "Running...", + "never_run": "Never run", + "last_time": "Last {time}", + "yesterday_time": "Yesterday {time}", + "last_date": "Last {date}" + }, + "camera": { + "no_image": "No image from this camera", + "loading": "Loading image", + "live": "Live", + "recording": "Recording", + "tap_to_view": "Tap to view", + "no_image_yet": "No image yet" + }, + "button": { + "activate": "Activate", + "run": "Run", + "press": "Press" + }, + "calibration": { + "title": "Follow the calibration wizard", + "touch_check": "GT911 touch check", + "hold": "Hold each tap for a moment", + "crosshair": "Tap the crosshair\nPoint {point}/5 - tap {tap}/3", + "gently": "Tap gently, a bit longer", + "same": "Tap the same crosshair 3 times", + "rejected": "Measurement rejected; try again", + "save_failed": "Save failed; try again" + }, + "ha": { + "on": "On", + "off": "Off", + "climate": { + "off": "Off", + "heat": "Heat", + "cool": "Cool", + "heat_cool": "Heat/Cool", + "auto": "Auto", + "dry": "Dry", + "fan_only": "Fan only" + }, + "hvac_action": { + "heating": "Heating", + "cooling": "Cooling", + "idle": "Idle", + "off": "Off", + "drying": "Drying", + "fan": "Fan", + "preheating": "Preheating", + "defrosting": "Defrosting" + }, + "cover": { + "open": "Open", + "closed": "Closed", + "opening": "Opening", + "closing": "Closing" + }, + "media": { + "playing": "Playing", + "paused": "Paused", + "idle": "Idle", + "standby": "Standby" + }, + "person": { + "home": "Home", + "not_home": "Away" + }, + "sun": { + "above_horizon": "Above the horizon", + "below_horizon": "Below the horizon" + }, + "vacuum": { + "docked": "Docked", + "cleaning": "Cleaning", + "paused": "Paused", + "returning": "Returning to dock", + "idle": "Idle" + }, + "weather": { + "sunny": "Sunny", + "clear_night": "Clear, night", + "cloudy": "Cloudy", + "partlycloudy": "Partly cloudy", + "rainy": "Rainy", + "pouring": "Pouring", + "snowy": "Snowy", + "snowy_rainy": "Snowy, rainy", + "fog": "Fog", + "hail": "Hail", + "lightning": "Lightning", + "lightning_rainy": "Lightning, rainy", + "windy": "Windy", + "exceptional": "Exceptional" + }, + "binary": { + "battery_on": "Low", + "battery_off": "Normal", + "battery_charging_on": "Charging", + "battery_charging_off": "Not charging", + "carbon_monoxide_on": "Danger", + "carbon_monoxide_off": "Safe", + "cold_on": "Cold", + "cold_off": "Normal", + "connectivity_on": "Connected", + "connectivity_off": "Disconnected", + "door_on": "Open", + "door_off": "Closed", + "garage_door_on": "Open", + "garage_door_off": "Closed", + "gas_on": "Danger", + "gas_off": "Safe", + "heat_on": "Hot", + "heat_off": "Normal", + "light_on": "Light", + "light_off": "Dark", + "lock_on": "Open", + "lock_off": "Locked", + "moisture_on": "Wet", + "moisture_off": "Dry", + "motion_on": "Motion", + "motion_off": "No motion", + "moving_on": "Moving", + "moving_off": "Still", + "occupancy_on": "Occupied", + "occupancy_off": "Clear", + "opening_on": "Open", + "opening_off": "Closed", + "plug_on": "Plugged in", + "plug_off": "Unplugged", + "power_on": "On", + "power_off": "Off", + "presence_on": "Home", + "presence_off": "Away", + "problem_on": "Problem", + "problem_off": "OK", + "running_on": "Active", + "running_off": "Inactive", + "safety_on": "Unsafe", + "safety_off": "Safe", + "smoke_on": "Smoke", + "smoke_off": "No smoke", + "sound_on": "Sound", + "sound_off": "Silent", + "tamper_on": "Tampering", + "tamper_off": "OK", + "update_on": "Update", + "update_off": "Up to date", + "vibration_on": "Vibration", + "vibration_off": "Still", + "window_on": "Open", + "window_off": "Closed" + } + } + }, + "addon": { + "errors": { + "language_choice": "Choose a language from the list.", + "region_choice": "Choose a clock and a number format from the lists." + } + }, "editor": {} } diff --git a/tests/screen_text_en.h b/tests/screen_text_en.h new file mode 100644 index 0000000..3748a18 --- /dev/null +++ b/tests/screen_text_en.h @@ -0,0 +1,313 @@ +// GENERATED by tools/i18n.py (`python3 tools/i18n.py header`): the English texts for the C++ tests, which +// build a header without ESPHome and so without the table a screen's build writes into main.cpp. +#pragma once +#include "components/smart_display/screen_text.h" + +static_assert(screen_text::KEYS_HASH == 0x725A92EEu && screen_text::KEY_COUNT == 302, + "screen_text_keys.h does not match screen_manager/translations/en.json: run tools/i18n.py header"); +const char *const screen_text::TABLE[] = { + ".", + ",", + "Just now", + "{n} min ago", + "1 hour ago | {n} hours ago", + "Yesterday", + "1 day ago | {n} days ago", + "1 week ago | {n} weeks ago", + "1 month ago | {n} months ago", + "1 year ago | {n} years ago", + "In {n} min", + "In 1 hour | In {n} hours", + "Tomorrow", + "In 1 day | In {n} days", + "AM", + "PM", + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sun", + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Su", + "Mo", + "Tu", + "We", + "Th", + "Fr", + "Sa", + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + "{weekday} {day} {month}", + "{weekday} {day} {month}", + "{day} {month}", + "{weekday} {time}", + "{n} s", + "{n} min", + "{n} h", + "{h} h {m} min", + "{n} d", + "{d} d {h} h", + "Connecting to Home Assistant", + "Waiting for ESP Screens", + "Loading tiles...", + "HA not connected", + "ESP Screens not active", + "Choose tiles in HA", + "Home", + "No network", + "Settings", + "Brightness", + "Night", + "Screen", + "This screen", + "Dark mode", + "Auto standby", + "Standby after", + "Standby brightness", + "Night mode", + "Starts", + "Ends", + "Night brightness", + "Back to page 1", + "After", + "Also on standby", + "Swipe between pages", + "Page buttons", + "Rotation", + "Address", + "Firmware", + "Home Assistant", + "Restart", + "Tap again to restart", + "{n} sec", + "{n} min", + "{n} h", + "{h} h {m}", + "Tap to open", + "Page {n}", + "Unavailable", + "Refused", + "Command sent...", + "Confirmed by Home Assistant", + "Notification", + "OK", + "Color", + "Color temperature", + "Brightness", + "Waiting...", + "Effect", + "Nothing to choose", + "Loading...", + "Not connected", + "Target", + "Mode", + "Fan", + "Swing", + "Now {value}", + "Check the robot in HA", + "Automatic suction power", + "The robot chooses suction and water", + "Suction and water as set per room", + "Quiet", + "Normal", + "Turbo", + "Max", + "Pause cleaning", + "Pause", + "Resume", + "Start cleaning", + "Clean", + "Dock", + "Position", + "Tilt", + "Tilt {n}%", + "Feels like {n}\302\260", + "No daily forecast from Home Assistant", + "Coming days", + "No data", + "average", + "average of {duration}", + "Now", + "1 hour", + "24 hours", + "1 week", + "High {value}", + "Low {value}", + "once | {n} times", + "Loading history...", + "No history in this period", + "No history available", + "Muted", + "Not playing", + "Loading", + "Running", + "Paused", + "Stopped", + "Pause", + "Start", + "Cancel", + "Paused {time}", + "Sun", + "Sunrise {time}", + "Sunset {time}", + "rise {time}", + "set {time}", + "Running...", + "Never run", + "Last {time}", + "Yesterday {time}", + "Last {date}", + "No image from this camera", + "Loading image", + "Live", + "Recording", + "Tap to view", + "No image yet", + "Activate", + "Run", + "Press", + "Follow the calibration wizard", + "GT911 touch check", + "Hold each tap for a moment", + "Tap the crosshair\012Point {point}/5 - tap {tap}/3", + "Tap gently, a bit longer", + "Tap the same crosshair 3 times", + "Measurement rejected; try again", + "Save failed; try again", + "On", + "Off", + "Off", + "Heat", + "Cool", + "Heat/Cool", + "Auto", + "Dry", + "Fan only", + "Heating", + "Cooling", + "Idle", + "Off", + "Drying", + "Fan", + "Preheating", + "Defrosting", + "Open", + "Closed", + "Opening", + "Closing", + "Playing", + "Paused", + "Idle", + "Standby", + "Home", + "Away", + "Above the horizon", + "Below the horizon", + "Docked", + "Cleaning", + "Paused", + "Returning to dock", + "Idle", + "Sunny", + "Clear, night", + "Cloudy", + "Partly cloudy", + "Rainy", + "Pouring", + "Snowy", + "Snowy, rainy", + "Fog", + "Hail", + "Lightning", + "Lightning, rainy", + "Windy", + "Exceptional", + "Low", + "Normal", + "Charging", + "Not charging", + "Danger", + "Safe", + "Cold", + "Normal", + "Connected", + "Disconnected", + "Open", + "Closed", + "Open", + "Closed", + "Danger", + "Safe", + "Hot", + "Normal", + "Light", + "Dark", + "Open", + "Locked", + "Wet", + "Dry", + "Motion", + "No motion", + "Moving", + "Still", + "Occupied", + "Clear", + "Open", + "Closed", + "Plugged in", + "Unplugged", + "On", + "Off", + "Home", + "Away", + "Problem", + "OK", + "Active", + "Inactive", + "Unsafe", + "Safe", + "Smoke", + "No smoke", + "Sound", + "Silent", + "Tampering", + "OK", + "Update", + "Up to date", + "Vibration", + "Still", + "Open", + "Closed" +}; +const char *const screen_text::LANGUAGE = "en"; +int screen_text::plural_index(int n) { return n == 1 ? 0 : 1; } diff --git a/tests/test_action_feedback.cpp b/tests/test_action_feedback.cpp index ac2cb07..e181382 100644 --- a/tests/test_action_feedback.cpp +++ b/tests/test_action_feedback.cpp @@ -1,3 +1,4 @@ +#include "screen_text_en.h" #include "components/smart_display/runtime_model.h" #include "components/smart_display/cyd_ui.h" #include diff --git a/tests/test_alert_overlay.cpp b/tests/test_alert_overlay.cpp index 4f9f876..333603b 100644 --- a/tests/test_alert_overlay.cpp +++ b/tests/test_alert_overlay.cpp @@ -1,3 +1,4 @@ +#include "screen_text_en.h" #define THEME_TEST #include "components/smart_display/alert_overlay.h" #include diff --git a/tests/test_binary_words.py b/tests/test_binary_words.py index 6e4794f..e85fd29 100644 --- a/tests/test_binary_words.py +++ b/tests/test_binary_words.py @@ -4,6 +4,7 @@ The screen maps the device class itself (`tile_controls::binary_state_text`), fr state message has carried since app 0.2.23: nothing new on the wire, so it works with older apps too. These tests keep its table equal to the add-on's `header_bar.BINARY_STATES`, which the top bar and the history card use. """ +import json import re import sys import unittest @@ -22,9 +23,17 @@ TILES = (ROOT / 'components/smart_display/runtime_tiles.h').read_text() PROFILES = ('guition-4848s040.yaml', 'home-like-2432s028.yaml', 'packages/cyd.yaml', 'packages/guition.yaml') +ENGLISH = json.loads((ROOT / 'screen_manager/translations/en.json').read_text(encoding='utf-8')) + + def firmware_words(): + """The firmware's table (app 0.2.90: keys into screen.ha.binary, in the screen's language) in English.""" block = re.search(r'constexpr BinaryWords BINARY_WORDS\[\] = \{(.*?)\n\};', CONTROLS, re.S) - return {device_class: (on, off) for device_class, on, off in re.findall(r'\{"(\w+)", "([^"]*)", "([^"]*)"\}', block.group(1))} + words = ENGLISH['screen']['ha']['binary'] + found = {} + for device_class, on, off in re.findall(r'\{"(\w+)", screen_text::txt::ha_binary_(\w+), screen_text::txt::ha_binary_(\w+)\}', block.group(1)): + found[device_class] = (words[on], words[off]) + return found class BinaryWords(unittest.TestCase): @@ -41,7 +50,8 @@ class BinaryWords(unittest.TestCase): for attrs in ({}, {'device_class': 'future_class'}): self.assertEqual(header_bar.value('binary_sensor.x', {'state': 'on', 'attributes': attrs}), ('On', None)) self.assertEqual(history_card.state_label('binary_sensor', 'off', attrs), 'Off') - self.assertIn('return on ? "On" : "Off";', CONTROLS) + self.assertIn('return screen_text::tr(on ? screen_text::txt::ha_on : screen_text::txt::ha_off);', CONTROLS) + self.assertEqual((ENGLISH['screen']['ha']['on'], ENGLISH['screen']['ha']['off']), ('On', 'Off')) def test_the_state_message_already_carries_the_class(self): tile = {'entity': 'binary_sensor.front_door', 'name': ''} diff --git a/tests/test_calibration_math.cpp b/tests/test_calibration_math.cpp index a1ed240..789da22 100644 --- a/tests/test_calibration_math.cpp +++ b/tests/test_calibration_math.cpp @@ -1,3 +1,4 @@ +#include "screen_text_en.h" #include "../components/smart_display/calibration_math.h" #include int main() { diff --git a/tests/test_camera_view.cpp b/tests/test_camera_view.cpp index d66bf70..d556308 100644 --- a/tests/test_camera_view.cpp +++ b/tests/test_camera_view.cpp @@ -1,3 +1,4 @@ +#include "screen_text_en.h" // clang++ -std=c++17 -Wall -Wextra -Werror -I. tests/test_camera_view.cpp -o /tmp/test_camera_view && /tmp/test_camera_view #include "../components/smart_display/camera_view.h" #include diff --git a/tests/test_chunk_transport.cpp b/tests/test_chunk_transport.cpp index 6ebad8f..9d4f8ba 100644 --- a/tests/test_chunk_transport.cpp +++ b/tests/test_chunk_transport.cpp @@ -1,3 +1,4 @@ +#include "screen_text_en.h" #include "../components/smart_display/chunk_transport.h" #include int main() { diff --git a/tests/test_cyd_ui.cpp b/tests/test_cyd_ui.cpp index 0f98e00..4f7362f 100644 --- a/tests/test_cyd_ui.cpp +++ b/tests/test_cyd_ui.cpp @@ -1,3 +1,4 @@ +#include "screen_text_en.h" #include "../components/smart_display/cyd_ui.h" #include #include diff --git a/tests/test_effects_page.cpp b/tests/test_effects_page.cpp index 30cbb4c..57ffe88 100644 --- a/tests/test_effects_page.cpp +++ b/tests/test_effects_page.cpp @@ -1,3 +1,4 @@ +#include "screen_text_en.h" // c++ -std=c++17 -Wall -Wextra -pedantic tests/test_effects_page.cpp -o /tmp/test_effects_page && /tmp/test_effects_page // The model of a light's effects page (firmware 0.2.70+): what it can show, how a number maps to its slider, the texts. #define EFFECTS_PAGE_TEST diff --git a/tests/test_header_bar.cpp b/tests/test_header_bar.cpp index 7972e8e..090b7e0 100644 --- a/tests/test_header_bar.cpp +++ b/tests/test_header_bar.cpp @@ -1,3 +1,4 @@ +#include "screen_text_en.h" #include "components/smart_display/header_bar.h" #include #include diff --git a/tests/test_header_bar.py b/tests/test_header_bar.py index 4e70080..ebcfded 100644 --- a/tests/test_header_bar.py +++ b/tests/test_header_bar.py @@ -18,6 +18,8 @@ import tile_icons # noqa: E402 from core import FIRMWARE_VERSION, HEADER_MIN_FIRMWARE, discover, header_items, validate_header, validate_layout # noqa: E402 FIRMWARE = (ROOT / 'components/smart_display/header_bar.h').read_text() +# The firmware's words live in the translations since app 0.2.90 (screen.time in English, the language of reference). +FIRMWARE += json.dumps(json.loads((ROOT / 'screen_manager/translations/en.json').read_text(encoding='utf-8'))['screen']['time']) TILES = (ROOT / 'components/smart_display/runtime_tiles.h').read_text() EDITOR = (ROOT / 'web/src/model/topbar.ts').read_text() PROFILES = ('guition-4848s040.yaml', 'home-like-2432s028.yaml') @@ -109,8 +111,10 @@ class TextTests(unittest.TestCase): self.assertEqual(header_bar.value(eid, value, registry.get(eid), {'temperature': '°C'}, tz), expected, eid) def test_text_keeps_to_the_font_and_its_length(self): - self.assertEqual(header_bar.clean_text('Café “Sun” 25 m³'), 'Café Sun 25 m³') - self.assertEqual(header_bar.clean_text('Ångström ✓'), 'Angström') + # Every letter European languages write with stays (app 0.2.90); anything else folds to its base letter or goes. + self.assertEqual(header_bar.clean_text('Café “Sun” 25 m³'), 'Café “Sun” 25 m³') + self.assertEqual(header_bar.clean_text('Ångström ✓'), 'Ångström') + self.assertEqual(header_bar.clean_text('Ǻngström'), 'Angström') self.assertLessEqual(len(header_bar.clean_text('é' * 60).encode()), header_bar.TEXT_BYTES) def test_active_colour_and_icon(self): @@ -228,10 +232,10 @@ class ParityTests(unittest.TestCase): # Firmware 0.2.73+: until the first layout the text and a spinner stand in the middle and the name stays empty; # the first layout deletes them, so no spinner turns behind the tiles. render = TILES.split('inline void render(lv_obj_t *room) {', 1)[1].split('\n}', 1)[0] - self.assertIn('if (!model.configured) boot_status(lv_obj_get_parent(room), !ha_connected() ? ' - '"Connecting to Home Assistant" : "Waiting for ESP Screens");', render) + self.assertIn('if (!model.configured) boot_status(lv_obj_get_parent(room), tr(!ha_connected() ? ' + 'txt::status_connecting : txt::status_waiting));', render) self.assertIn('else if (boot_panel) { lv_obj_delete(boot_panel);', render) - self.assertIn('label(room, !model.configured ? "" :', render) + self.assertIn('label(room, !model.configured ? std::string() :', render) boot = TILES.split('inline void boot_status(', 1)[1].split('\n}', 1)[0] self.assertIn('boot_spinner = spinner_create(boot_panel,', boot) # Under the tiles, the cards and an alert, in the name's place. diff --git a/tests/test_history_view.cpp b/tests/test_history_view.cpp index 1a21d75..55eb7d6 100644 --- a/tests/test_history_view.cpp +++ b/tests/test_history_view.cpp @@ -1,3 +1,4 @@ +#include "screen_text_en.h" // clang++ -std=c++17 -Wall -Wextra -Werror -I. tests/test_history_view.cpp -o /tmp/test_history_view && /tmp/test_history_view #include "../components/smart_display/history_view.h" #include diff --git a/tests/test_light_controls.cpp b/tests/test_light_controls.cpp index 0b2216b..cd1ccf2 100644 --- a/tests/test_light_controls.cpp +++ b/tests/test_light_controls.cpp @@ -1,3 +1,4 @@ +#include "screen_text_en.h" #define LIGHT_CONTROLS_TEST #include "../components/smart_display/light_controls.h" #include diff --git a/tests/test_media_card.cpp b/tests/test_media_card.cpp index 0d9b2e0..92dbd07 100644 --- a/tests/test_media_card.cpp +++ b/tests/test_media_card.cpp @@ -1,3 +1,4 @@ +#include "screen_text_en.h" // clang++ -std=c++17 -Wall -Wextra -Werror -I. tests/test_media_card.cpp -o /tmp/test_media_card && /tmp/test_media_card #include "../components/smart_display/media_card.h" #include diff --git a/tests/test_runtime_model.cpp b/tests/test_runtime_model.cpp index 65268a6..51a41a7 100644 --- a/tests/test_runtime_model.cpp +++ b/tests/test_runtime_model.cpp @@ -1,3 +1,4 @@ +#include "screen_text_en.h" #include "../components/smart_display/runtime_model.h" #include int main() { diff --git a/tests/test_screen_owned_settings.py b/tests/test_screen_owned_settings.py index 39fdac3..bf066da 100644 --- a/tests/test_screen_owned_settings.py +++ b/tests/test_screen_owned_settings.py @@ -47,14 +47,14 @@ ENTITY_IDS = { 'standby_seconds': 'number.office_1_standby_after', 'standby_brightness': 'number.office_1_standby_brightness', 'night_enabled': 'switch.office_1_night_mode', 'night_start': 'time.office_1_night_starts', 'night_end': 'time.office_1_night_ends', 'night_brightness': 'number.office_1_night_brightness', - 'clock_24h': 'switch.office_1_24_hour_clock', 'auto_home': 'switch.office_1_back_to_page_1', + 'auto_home': 'switch.office_1_back_to_page_1', 'auto_home_seconds': 'number.office_1_back_to_page_1_after', 'home_on_standby': 'switch.office_1_back_to_page_1_on_standby', 'swipe_pages': 'switch.office_1_swipe_between_pages', 'rotation': 'select.office_1_rotation', 'dark_mode': 'switch.office_1_dark_mode', 'page_buttons': 'switch.office_1_page_buttons', } STATES = {'brightness': '80.0', 'standby_enabled': 'on', 'standby_seconds': '600.0', 'standby_brightness': '20.0', 'night_enabled': 'on', 'night_start': '22:30:00', 'night_end': '07:00:00', 'night_brightness': '5.0', - 'clock_24h': 'on', 'auto_home': 'on', 'auto_home_seconds': '120.0', 'home_on_standby': 'off', + 'auto_home': 'on', 'auto_home_seconds': '120.0', 'home_on_standby': 'off', 'swipe_pages': 'on', 'rotation': '90°', 'dark_mode': 'off', 'page_buttons': 'on'} @@ -109,7 +109,8 @@ def fake_ha(firmware=FIRMWARE_VERSION, owned=True, guition=True): class CoreSettings(unittest.TestCase): def test_every_setting_the_editor_offers_has_an_entity(self): - self.assertEqual(set(SETTING_ENTITIES), set(SETTING_RULES) - {'show_clock'}) + # 12 or 24 hours is Settings -> Language & region's, for every screen at once (app 0.2.90). + self.assertEqual(set(SETTING_ENTITIES), set(SETTING_RULES) - {'show_clock', 'clock_24h'}) self.assertEqual(SETTING_ENTITIES_MIN_FIRMWARE, '0.2.49') self.assertLessEqual(tuple(map(int, SETTING_ENTITIES_MIN_FIRMWARE.split('.'))), tuple(map(int, FIRMWARE_VERSION.split('.')))) @@ -179,7 +180,7 @@ class OwnedSettings(unittest.IsolatedAsyncioTestCase): self.assertNotIn('settings', m.layouts['text.screen'], 'a screen that owns its settings has none stored') view = m.settings_view(m.screen('text.screen')) self.assertEqual((view['owner'], view['unavailable']), ('screen', [])) - self.assertEqual(view['keys'], [key for key in SETTING_RULES if key != 'show_clock']) + self.assertEqual(view['keys'], [key for key in SETTING_RULES if key not in ('show_clock', 'clock_24h')]) self.assertEqual({key: view['values'][key] for key in ('brightness', 'night_start', 'rotation', 'home_on_standby')}, {'brightness': 80, 'night_start': 1350, 'rotation': 90, 'home_on_standby': False}) m.ha.states[ENTITY_IDS['night_end']] = {'state': 'unavailable'} @@ -225,12 +226,11 @@ class OwnedSettings(unittest.IsolatedAsyncioTestCase): await m.change_settings('text.screen', {'brightness': 10}) self.assertEqual(m.ha.calls, [('number.set_value', {'entity_id': ENTITY_IDS['brightness'], 'value': 10})]) m.ha.calls.clear() - await m.change_settings('text.screen', {'night_start': 1320, 'rotation': 270, 'clock_24h': False}) + await m.change_settings('text.screen', {'night_start': 1320, 'rotation': 270}) self.assertEqual(m.ha.calls, [('time.set_value', {'entity_id': ENTITY_IDS['night_start'], 'time': '22:00:00'}), - ('switch.turn_off', {'entity_id': ENTITY_IDS['clock_24h']}), ('select.select_option', {'entity_id': ENTITY_IDS['rotation'], 'option': '270°'})]) m.ha.calls.clear() - for bad in ({'standby_brightness': 95}, {'show_clock': False}, {'beep': True}, {}, None, {'brightness': '50'}): + for bad in ({'standby_brightness': 95}, {'show_clock': False}, {'clock_24h': False}, {'beep': True}, {}, None, {'brightness': '50'}): with self.assertRaises(ValueError): await m.change_settings('text.screen', bad) m.ha.states[ENTITY_IDS['night_end']] = {'state': 'unavailable'} @@ -569,9 +569,12 @@ class Editor(unittest.TestCase): def test_the_rows_are_the_settings_of_the_screen_page(self): keys = re.findall(r'\{ key: "(\w+)", label: "[^"]+", kind: "(\w+)"', self.script) self.assertEqual([key for key, _ in keys], [key for key in SETTING_RULES if key != 'show_clock'][:0] or [key for key, _ in keys]) - self.assertEqual({key for key, _ in keys}, set(SETTING_RULES) - {'show_clock'}) + # 12 or 24 hours is Settings -> Language & region's, for every screen at once (app 0.2.90). + self.assertEqual({key for key, _ in keys}, set(SETTING_RULES) - {'show_clock', 'clock_24h'}) # Labels and steps as on the screen: the same rows, the same -/+ steps, the same duration ladder. - page_labels = set(re.findall(r'(?:number|toggle|duration|moment|choice)\("([^"]+)"', SCREEN_PAGE)) + # The page's labels are keys into the translations since app 0.2.90; English is the reference. + english = json.loads((ROOT / 'screen_manager/translations/en.json').read_text(encoding='utf-8'))['screen']['settings'] + page_labels = {english[key] for key in re.findall(r'(?:number|toggle|duration|moment|choice)\(screen_text::txt::settings_(\w+)', SCREEN_PAGE)} groups = self.script[self.script.index('const SETTING_GROUPS = ['):self.script.index('export type SettingRow')] script_labels = set(re.findall(r'label: "([^"]+)"', groups)) self.assertEqual(page_labels, script_labels) diff --git a/tests/test_screen_settings.cpp b/tests/test_screen_settings.cpp index 3a9d2d6..3c023d4 100644 --- a/tests/test_screen_settings.cpp +++ b/tests/test_screen_settings.cpp @@ -1,3 +1,4 @@ +#include "screen_text_en.h" #include "components/smart_display/screen_settings.h" #include int main() { diff --git a/tests/test_settings_screen.cpp b/tests/test_settings_screen.cpp index c79d5d7..5821443 100644 --- a/tests/test_settings_screen.cpp +++ b/tests/test_settings_screen.cpp @@ -1,4 +1,5 @@ #define SETTINGS_SCREEN_TEST +#include "screen_text_en.h" #include "../components/smart_display/settings_screen.h" #include #include @@ -8,7 +9,7 @@ using namespace settings_screen; // The row of a page by its label, so a test says what it means. static const Row &row_named(const Page &page, const std::string &label) { for (uint8_t i = 0; i < page.count; ++i) - if (label == page.rows[i].label) return page.rows[i]; + if (label == label_text(page.rows[i])) return page.rows[i]; assert(false); return page.rows[0]; } @@ -17,7 +18,7 @@ int main() { // ---- the duration ladder: seconds low down, quarters of an hour up top, and symmetric ---- assert(ladder_step(60) == 30 && ladder_step(600) == 60 && ladder_step(1800) == 300); assert(ladder_step(3600) == 900 && ladder_step(36000) == 1800); - Row timeout = duration("After", nullptr, nullptr, 30, 3600); + Row timeout = duration(screen_text::txt::settings_after, nullptr, nullptr, 30, 3600); assert(stepped(timeout, 120, 1) == 150 && stepped(timeout, 120, -1) == 90); assert(stepped(timeout, 300, 1) == 360 && stepped(timeout, 360, -1) == 300); assert(stepped(timeout, 300, -1) == 270 && stepped(timeout, 270, 1) == 300); @@ -25,9 +26,9 @@ int main() { assert(at_end(timeout, 30, -1) && at_end(timeout, 3600, 1) && !at_end(timeout, 120, -1)); // ---- a number stops at its ends, a moment walks around midnight ---- - Row percent = number("Brightness", nullptr, nullptr, 5, 100, 5, "%"); + Row percent = number(screen_text::txt::settings_brightness, nullptr, nullptr, 5, 100, 5, "%"); assert(stepped(percent, 100, 1) == 100 && stepped(percent, 5, -1) == 5 && stepped(percent, 95, 1) == 100); - Row start = moment("Starts", nullptr, nullptr); + Row start = moment(screen_text::txt::settings_starts, nullptr, nullptr); assert(stepped(start, 1320, 1) == 1335 && stepped(start, 1425, 1) == 0 && stepped(start, 0, -1) == 1425); assert(!at_end(start, 0, -1)); // Holding the key walks whole hours, and first lands on the hour it is in. @@ -64,7 +65,7 @@ int main() { assert(screen_settings::current.standby_brightness == 30); // never above the normal brightness assert(screen_settings::current.valid()); // Dark mode (firmware 0.2.54+) sits right under Brightness, a switch of its own outside the frozen block. - assert(light.count == 5 && light.rows[1].kind == Kind::toggle && std::string(light.rows[1].label) == "Dark mode"); + assert(light.count == 5 && light.rows[1].kind == Kind::toggle && std::string(label_text(light.rows[1])) == "Dark mode"); const Row &dark = row_named(light, "Dark mode"); assert(value_text(dark) == "Off"); dark.write(1); @@ -84,10 +85,11 @@ int main() { buttons.write(1); assert(page_buttons == 1); } - const Row &clock = row_named(screen, "Clock"); - assert(clock.option_count == 2 && value_text(clock) == "24 hour"); - clock.write(0); - assert(screen_settings::current.clock_24h == 0 && value_text(clock) == "12 hour"); + // 12 or 24 hours is chosen for every screen at once in ESP Screens (app 0.2.90): the page has no row for it, and + // the value still arrives through set() and the layout message. + for (uint8_t i = 0; i < screen.count; ++i) assert(std::string(label_text(screen.rows[i])) != "Clock"); + set("clock_24h", 0); + assert(screen_settings::current.clock_24h == 0); const Row &home = row_named(screen, "Back to page 1"); home.write(0); assert(auto_home == 0 && value_text(home) == "Off"); @@ -107,17 +109,17 @@ int main() { assert(PAGE_COUNT == 5); for (uint8_t p = 0; p < PAGE_COUNT; ++p) { const Page &page = pages[p]; - assert(page.title && page.count && page.count <= 8); + assert(page.title != NO_TEXT && *screen_text::tr(page.title) && page.count && page.count <= 8); for (uint8_t i = 0; i < page.count; ++i) { const Row &row = page.rows[i]; - assert(row.label && *row.label); + assert(row.label != NO_TEXT && *label_text(row)); if (row.kind == Kind::page) assert(row.opens > 0 && row.opens < PAGE_COUNT && *row.icon); if (row.kind == Kind::toggle || row.kind == Kind::choice || row.kind == Kind::number || row.kind == Kind::duration || row.kind == Kind::moment) assert(row.read && row.write); if (row.kind == Kind::number) assert(row.step > 0 && row.high > row.low); if (row.kind == Kind::duration) assert(row.high > row.low); - if (row.kind == Kind::choice) assert(row.options && row.option_count); + if (row.kind == Kind::choice) assert((row.options || row.option_keys != NO_TEXT) && row.option_count); if (row.kind == Kind::info) assert(row.text); if (row.kind == Kind::action) assert(row.run && *row.icon); } diff --git a/tests/test_theme.cpp b/tests/test_theme.cpp index a69b030..cc5f981 100644 --- a/tests/test_theme.cpp +++ b/tests/test_theme.cpp @@ -1,3 +1,4 @@ +#include "screen_text_en.h" // The colour table (firmware 0.2.54): the light look stays the design it was, the dark look reads well beside a bed. // clang++ -std=c++17 -Wall -Wextra -Werror -I. tests/test_theme.cpp -o /tmp/test_theme && /tmp/test_theme #define THEME_TEST diff --git a/tests/test_theme.py b/tests/test_theme.py index ecd64c8..ca8be22 100644 --- a/tests/test_theme.py +++ b/tests/test_theme.py @@ -151,7 +151,7 @@ class Setting(unittest.TestCase): self.assertEqual(DARK_MODE_MIN_FIRMWARE, '0.2.54') self.assertLessEqual(tuple(map(int, DARK_MODE_MIN_FIRMWARE.split('.'))), tuple(map(int, FIRMWARE_VERSION.split('.')))) page = (COMPONENT / 'settings_screen.h').read_text() - self.assertIn('toggle("Dark mode", []() -> int32_t { return dark_mode; },', page) + self.assertIn('toggle(screen_text::txt::settings_dark_mode, []() -> int32_t { return dark_mode; },', page) self.assertIn('else if (key == "dark_mode") reported = dark_mode = flag(value);', page) editor = (ROOT / 'web/src/store.ts').read_text() self.assertIn('{ key: "dark_mode", label: "Dark mode", kind: "toggle" },', editor) diff --git a/tests/test_tile_controls.cpp b/tests/test_tile_controls.cpp index 9ac0af9..dd265d9 100644 --- a/tests/test_tile_controls.cpp +++ b/tests/test_tile_controls.cpp @@ -1,3 +1,4 @@ +#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" diff --git a/tests/test_tile_icon.cpp b/tests/test_tile_icon.cpp index 2b8f7ad..521f02d 100644 --- a/tests/test_tile_icon.cpp +++ b/tests/test_tile_icon.cpp @@ -1,3 +1,4 @@ +#include "screen_text_en.h" #include "components/smart_display/tile_icon.h" #include int main() { diff --git a/tests/test_tile_palette.cpp b/tests/test_tile_palette.cpp index 4dd8b1b..70d3fa2 100644 --- a/tests/test_tile_palette.cpp +++ b/tests/test_tile_palette.cpp @@ -1,3 +1,4 @@ +#include "screen_text_en.h" #define THEME_TEST #include "components/smart_display/tile_palette.h" #include diff --git a/tools/i18n.py b/tools/i18n.py new file mode 100644 index 0000000..24d5518 --- /dev/null +++ b/tools/i18n.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""The translations in screen_manager/translations/ (app 0.2.90); docs/TRANSLATING.md says how they work. + + python3 tools/i18n.py check every language against English: keys, placeholders, plural forms, letters + the screens can't draw, screen texts much longer than the English + python3 tools/i18n.py header [--check] write (or compare) the key header and the tests' English table that the + firmware's `screen` texts need, after changing the keys of en.json + python3 tools/i18n.py cldr [--write] day and month names, date orders and the decimal mark from the Unicode CLDR + (Node's Intl), into every language's screen.date and screen.number +""" +import argparse +import importlib.util +import json +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +FOLDER = ROOT / 'screen_manager' / 'translations' +KEYS_HEADER = ROOT / 'components' / 'smart_display' / 'screen_text_keys.h' +TESTS_HEADER = ROOT / 'tests' / 'screen_text_en.h' +CORE = ROOT / 'packages' / 'core.yaml' + + +def generator(): + """components/smart_display/screen_text_gen.py, which the firmware build itself uses.""" + spec = importlib.util.spec_from_file_location('screen_text_gen', ROOT / 'components' / 'smart_display' / 'screen_text_gen.py') + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def languages(): + """{code: data} of every language file, English first.""" + found = {path.stem: json.loads(path.read_text(encoding='utf-8')) for path in sorted(FOLDER.glob('*.json'))} + return dict(sorted(found.items(), key=lambda item: (item[0] != 'en', item[0]))) + + +def header(check): + gen = generator() + wanted = {KEYS_HEADER: gen.keys_header([key for key, _ in gen.screen_pairs(gen.load('en', FOLDER))]), + TESTS_HEADER: gen.english_header(FOLDER)} + stale = [path for path, text in wanted.items() if not path.exists() or path.read_text(encoding='utf-8') != text] + if check: + for path in stale: + print(f'{path.relative_to(ROOT)} is out of date: run python3 tools/i18n.py header') + return 1 if stale else 0 + for path in stale: + path.write_text(wanted[path], encoding='utf-8') + print(f'wrote {path.relative_to(ROOT)}') + return 0 + + +PLACEHOLDER = re.compile(r'\{([a-z_]+)\}') +FORMS = {'one_other': 2, 'one_upto_1': 2, 'slavic_pl': 3, 'east_slavic': 3, 'none': 1} + + +def font_letters(): + """The characters every text font of the screens carries: what a `screen` text may use.""" + text = CORE.read_text(encoding='utf-8') + sets = [] + for block in re.finditer(r'(?ms)^ - file: "\$\{FONT_DIR\}/Roboto-\d+\.ttf"\n(.*?)(?=^ - |^\S|\Z)', text): + body = block.group(1) + if not re.search(r'(?m)^\s+id: (headline|label|sublabel|sublabel_big|watch_value)\s*$', body): + continue + glyphs = re.search(r'(?s)glyphs:\s*\[(.*?)\]', body) + if glyphs: + letters = set() + for item in re.findall(r"'((?:[^'\\]|\\.)*)'|\"((?:[^\"\\]|\\.)*)\"", glyphs.group(1)): + letters.update(item[0] or item[1]) + sets.append(letters) + return set.intersection(*sets) if sets else set() + + +def check(): + gen = generator() + langs = languages() + english = langs['en'] + problems, notes = [], [] + base = dict(gen.flatten({k: v for k, v in english.items() if k != '_meta'})) + drawable = font_letters() + for code, data in langs.items(): + meta = data.get('_meta', {}) + for field in ('name', 'english', 'script', 'plural', 'checked'): + if field not in meta: + problems.append(f'{code}: _meta.{field} is missing') + forms = FORMS.get(meta.get('plural')) + if forms is None: + problems.append(f'{code}: _meta.plural must be one of {", ".join(FORMS)}') + own = dict(gen.flatten({k: v for k, v in data.items() if k != '_meta'})) + for key in own.keys() - base.keys(): + problems.append(f'{code}: {key} is not in en.json') + missing = [key for key in base if key not in own] + if code != 'en' and missing: + notes.append(f'{code}: {len(missing)} of {len(base)} texts still in English') + for key, text in own.items(): + if key not in base or not isinstance(text, str): + continue + source = base[key] + if set(PLACEHOLDER.findall(text)) != set(PLACEHOLDER.findall(source)): + problems.append(f'{code}: {key} has placeholders {sorted(set(PLACEHOLDER.findall(text)))}, ' + f'English {sorted(set(PLACEHOLDER.findall(source)))}') + plural = ' | ' in source + if plural and forms and len(text.split('|')) not in (1, forms): + problems.append(f'{code}: {key} needs {forms} forms separated by " | "') + if key.startswith('screen.') and drawable: + unknown = sorted(set(text) - drawable - {'\n'}) + if unknown: + problems.append(f'{code}: {key} uses letters the screens can\'t draw: {"".join(unknown)}') + if code != 'en' and len(text) > max(8, len(source) * 1.6) and not plural: + notes.append(f'{code}: {key} is much longer than the English ({len(text)} vs {len(source)})') + for line in notes: + print('note:', line) + for line in problems: + print('problem:', line) + print(f'{len(langs)} languages, {len(base)} texts: {len(problems)} problems') + return 1 if problems else 0 + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + sub = parser.add_subparsers(dest='command', required=True) + sub.add_parser('check') + head = sub.add_parser('header') + head.add_argument('--check', action='store_true') + cldr = sub.add_parser('cldr') + cldr.add_argument('--write', action='store_true') + args = parser.parse_args() + if args.command == 'check': + return check() + if args.command == 'header': + return header(args.check) + if args.command == 'cldr': + return cldr_dates(args.write) + return 2 + + +def cldr_dates(write): + raise SystemExit('not yet') + + +if __name__ == '__main__': + sys.exit(main())