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/<code>.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 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-19 14:24:19 +02:00
parent 9362a5f1db
commit 378d0b3e61
51 changed files with 2321 additions and 383 deletions
+36 -1
View File
@@ -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 = [] CODEOWNERS = []
AUTO_LOAD = ["json"] 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))
+2 -2
View File
@@ -2,6 +2,7 @@
#include <cstddef> #include <cstddef>
#include <cstdint> #include <cstdint>
#include <string> #include <string>
#include "screen_text.h"
#include "tile_icon.h" #include "tile_icon.h"
#include "tile_icon_names.h" #include "tile_icon_names.h"
#include "tile_palette.h" #include "tile_palette.h"
@@ -11,7 +12,6 @@
// tests/test_alert_overlay.cpp can check them; the profiles do the LVGL work. // tests/test_alert_overlay.cpp can check them; the profiles do the LVGL work.
namespace screen_alert { namespace screen_alert {
constexpr const char *FALLBACK_ICON = "alert-outline"; constexpr const char *FALLBACK_ICON = "alert-outline";
constexpr const char *FALLBACK_TITLE = "Notification";
constexpr const char *FALLBACK_BUTTON = "OK"; constexpr const char *FALLBACK_BUTTON = "OK";
constexpr uint32_t DEFAULT_CARD_COLOR = 0; // the normal card: theme::surface(0) constexpr uint32_t DEFAULT_CARD_COLOR = 0; // the normal card: theme::surface(0)
constexpr int MAX_TIMEOUT_SECONDS = 86400; 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) { size_t title_max, size_t subtitle_max, size_t button_max) {
Alert alert; Alert alert;
alert.title = clipped(trimmed(title), title_max); 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.subtitle = clipped(trimmed(subtitle), subtitle_max);
alert.button = clipped(trimmed(button), button_max); alert.button = clipped(trimmed(button), button_max);
if (alert.button.empty()) alert.button = FALLBACK_BUTTON; if (alert.button.empty()) alert.button = FALLBACK_BUTTON;
+8 -6
View File
@@ -4,6 +4,7 @@
#include "esphome/core/hal.h" #include "esphome/core/hal.h"
#include "lvgl.h" #include "lvgl.h"
#include "theme.h" #include "theme.h"
#include "screen_text.h"
#include <functional> #include <functional>
namespace screen_calibration { namespace screen_calibration {
inline bool active = false, pressed = false; inline bool active = false, pressed = false;
@@ -15,9 +16,10 @@ inline std::array<std::array<Point,3>,5> samples;
inline esphome::ESPPreferenceObject preference; inline esphome::ESPPreferenceObject preference;
inline std::function<void(const Calibration &)> apply; inline std::function<void(const Calibration &)> apply;
inline std::function<void(bool)> isolation; inline std::function<void(bool)> 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_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); lv_label_set_text(status, message);
} }
inline void begin() { 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) { inline void release(int raw_x, int raw_y) {
if (!active || !pressed) return; if (!active || !pressed) return;
pressed = false; 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)}; samples[point][sample++] = {double(raw_x), double(raw_y)};
if (sample < 3) { prompt(); return; } if (sample < 3) { prompt(); return; }
double xs[3], ys[3]; double xs[3], ys[3];
for (int i=0;i<3;++i) { xs[i]=samples[point][i].x; ys[i]=samples[point][i].y; } 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); 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; point++; sample=0;
if (point < 5) { prompt(); return; } if (point < 5) { prompt(); return; }
std::array<Point,5> raw; std::array<Point,5> 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]}; std::sort(xs,xs+3);std::sort(ys,ys+3);raw[n]={xs[1],ys[1]};
} }
Calibration next = calibration; Calibration next = calibration;
if (!fit(raw,next)) { point=0;prompt("Measurement rejected; 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("Save failed; try again");return; } if (!preference.save(&next) || !esphome::global_preferences->sync()) { point=0;prompt(screen_text::tr(screen_text::txt::calibration_save_failed));return; }
calibration=next; calibration=next;
if (apply) apply(calibration); if (apply) apply(calibration);
active=false; active=false;
+5 -4
View File
@@ -7,6 +7,7 @@
#include <string> #include <string>
#include <vector> #include <vector>
#include "runtime_model.h" #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 // 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 // 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); 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; } 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; 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(); show_roller();
} }
inline void picker_back(lv_event_t *) { if (steady()) close_picker(); } 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); 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; 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); 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_set_width(roller_note, w - 2 * m.inset);
lv_obj_center(roller_note); lv_obj_center(roller_note);
// The names of the same entity from a moment ago serve again; anything else is asked for afresh. // 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.entity = row.entity;
options.asked_at = clock(); options.asked_at = clock();
if (ask) ask(row.entity, 0); 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 ---- // ---- the page ----
@@ -331,7 +332,7 @@ inline void draw() {
// The rows: the light's effect first, then the selects of its device. // 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; }; struct Spec { std::string entity, name, current, fallback; uint32_t icon; bool light; };
std::vector<Spec> specs; std::vector<Spec> 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}); 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; int y = m.rows_y;
if (!specs.empty()) { if (!specs.empty()) {
+21 -18
View File
@@ -3,6 +3,7 @@
#include <array> #include <array>
#include <cstdint> #include <cstdint>
#include <string> #include <string>
#include "screen_text.h"
namespace header_bar { namespace header_bar {
// The top bar right of the screen name (firmware 0.2.32+). ESP Screen Manager decides what shows // 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", // 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) { inline std::string ago_text(int64_t then, int64_t now) {
using namespace screen_text;
if (now <= 0 || then <= 0) return "—"; if (now <= 0 || then <= 0) return "—";
int64_t seconds = now - then, span = seconds < 0 ? -seconds : seconds; 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<int>(span / unit); };
if (seconds < 0) { if (seconds < 0) {
if (span < 3600) return "In " + std::to_string(std::max<int64_t>(1, span / 60)) + " min"; if (span < 3600) return fill(txt::time_in_minutes, "n", std::max(1, n(60)));
if (span < 86400) return span / 3600 == 1 ? "In 1 hour" : "In " + n(3600) + " hours"; if (span < 86400) return plural(txt::time_in_hours, n(3600));
if (span < 172800) return "Tomorrow"; if (span < 172800) return tr(txt::time_tomorrow);
return "In " + n(86400) + " days"; return plural(txt::time_in_days, n(86400));
} }
if (span < 60) return "Just now"; if (span < 60) return tr(txt::time_just_now);
if (span < 3600) return n(60) + " min ago"; if (span < 3600) return fill(txt::time_minutes_ago, "n", n(60));
if (span < 86400) return span / 3600 == 1 ? "1 hour ago" : n(3600) + " hours ago"; if (span < 86400) return plural(txt::time_hours_ago, n(3600));
if (span < 172800) return "Yesterday"; if (span < 172800) return tr(txt::time_yesterday);
if (span < 604800) return n(86400) + " days ago"; if (span < 604800) return plural(txt::time_days_ago, n(86400));
if (span < 2592000) return span / 604800 == 1 ? "1 week ago" : n(604800) + " weeks ago"; if (span < 2592000) return plural(txt::time_weeks_ago, n(604800));
if (span < 31536000) return span / 2592000 == 1 ? "1 month ago" : n(2592000) + " months ago"; if (span < 31536000) return plural(txt::time_months_ago, n(2592000));
return span / 31536000 == 1 ? "1 year ago" : n(31536000) + " years ago"; 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) { 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"}; using namespace screen_text;
static const char *months[] = {"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"};
if (day_of_week < 1 || day_of_week > 7 || month < 1 || month > 12) return "—"; 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 // What you see between icon and value, between two items and after the name, from the height of
+26 -21
View File
@@ -9,6 +9,7 @@
#include <string> #include <string>
#include <utility> #include <utility>
#include <vector> #include <vector>
#include "screen_text.h"
namespace history_view { namespace history_view {
constexpr int PARTS = 24; 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 // 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) { 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 local = epoch + offset;
int64_t days = local >= 0 ? local / 86400 : (local - 86399) / 86400; int64_t days = local >= 0 ? local / 86400 : (local - 86399) / 86400;
int seconds = static_cast<int>(local - days * 86400); int seconds = static_cast<int>(local - days * 86400);
int hour = seconds / 3600, minute = seconds % 3600 / 60; int hour = seconds / 3600, minute = seconds % 3600 / 60;
static const char *const names[] = {"Thu", "Fri", "Sat", "Sun", "Mon", "Tue", "Wed"};
char b[24]; char b[24];
if (h24) snprintf(b, sizeof(b), "%02d:%02d", hour, minute); 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; 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<int>((((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. // 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; 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) { inline std::string duration(uint32_t seconds) {
char b[32]; using namespace screen_text;
if (seconds < 60) snprintf(b, sizeof(b), "%u s", static_cast<unsigned>(seconds)); const int s = static_cast<int>(seconds);
else if (seconds < 3600) snprintf(b, sizeof(b), "%u min", static_cast<unsigned>(seconds / 60)); if (s < 60) return fill(txt::duration_seconds, "n", s);
else if (seconds < 86400) { if (s < 3600) return fill(txt::duration_minutes, "n", s / 60);
unsigned minutes = seconds / 60 % 60; if (s < 86400) {
if (minutes) snprintf(b, sizeof(b), "%u h %u min", static_cast<unsigned>(seconds / 3600), minutes); int minutes = s / 60 % 60;
else snprintf(b, sizeof(b), "%u h", static_cast<unsigned>(seconds / 3600)); if (!minutes) return fill(txt::duration_hours, "n", s / 3600);
} else { return fill(fill(txt::duration_hours_minutes, "h", s / 3600), "m", std::to_string(minutes));
unsigned hours = seconds / 3600 % 24;
if (hours) snprintf(b, sizeof(b), "%u d %u h", static_cast<unsigned>(seconds / 86400), hours);
else snprintf(b, sizeof(b), "%u d", static_cast<unsigned>(seconds / 86400));
} }
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 // A value as Home Assistant writes it: fixed decimals, the language's separator between thousands and its decimal
// any other unit after a space. // 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) { inline std::string number(float value, int decimals, const std::string &unit) {
if (!std::isfinite(value)) return "--"; if (!std::isfinite(value)) return "--";
char b[40]; 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); } if (text[0] == '-') { sign = "-"; text.erase(0, 1); }
auto dot = text.find('.'); auto dot = text.find('.');
std::string whole = text.substr(0, dot), rest = dot == std::string::npos ? "" : text.substr(dot); std::string whole = text.substr(0, dot), rest = dot == std::string::npos ? "" : text.substr(dot);
for (int i = static_cast<int>(whole.size()) - 3; i > 0; i -= 3) whole.insert(static_cast<size_t>(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<int>(whole.size()) - 3; i > 0; i -= 3) whole.insert(static_cast<size_t>(i), group);
if (!rest.empty()) rest[0] = screen_text::decimal_mark();
text = (zero ? "" : sign) + whole + rest; text = (zero ? "" : sign) + whole + rest;
if (unit.empty()) return text; if (unit.empty()) return text;
if (unit == "%" || unit == "°") return text + unit; if (unit == "%" || unit == "°") return text + unit;
+4 -3
View File
@@ -4,6 +4,7 @@
#include <cstdio> #include <cstdio>
#include <cstdlib> #include <cstdlib>
#include <string> #include <string>
#include "screen_text.h"
namespace light_controls { namespace light_controls {
inline int clamp(int value, int low, int high) { return std::max(low, std::min(high, value)); } 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_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_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); 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); 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 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 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; 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"}; const char *icons[] = {"\U000F03D8", "\U000F050F", "\U000F0335"};
for (unsigned i = 0; i < 3; ++i) { for (unsigned i = 0; i < 3; ++i) {
auto &row = rows[i]; row.index = 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); 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; row.off = i == 2 && brightness <= 0;
preview(row); row.dirty = false; 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() { inline bool self_test() {
+6 -5
View File
@@ -12,6 +12,7 @@
#include <cstdint> #include <cstdint>
#include <cstdio> #include <cstdio>
#include <string> #include <string>
#include "screen_text.h"
namespace media_card { namespace media_card {
struct Rect { 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. // What the card says instead of a title when nothing plays.
inline const char *idle_text(const std::string &state) { inline const char *idle_text(const std::string &state) {
if (state == "off") return "Off"; if (state == "off") return screen_text::tr(screen_text::txt::ha_off);
if (state == "standby") return "Standby"; if (state == "standby") return screen_text::tr(screen_text::txt::ha_media_standby);
if (state == "buffering") return "Loading"; if (state == "buffering") return screen_text::tr(screen_text::txt::media_loading);
if (state == "unavailable") return "Unavailable"; if (state == "unavailable") return screen_text::tr(screen_text::txt::tile_unavailable);
return "Not playing"; return screen_text::tr(screen_text::txt::media_not_playing);
} }
inline bool playing(const std::string &state) { return state == "playing" || state == "buffering"; } 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"; } inline bool has_track(const std::string &state) { return state == "playing" || state == "paused" || state == "buffering"; }
+3 -2
View File
@@ -5,6 +5,7 @@
#include <string> #include <string>
#include <utility> #include <utility>
#include <vector> #include <vector>
#include "screen_text.h"
#include <cmath> #include <cmath>
#include <cstdint> #include <cstdint>
#include <cstdlib> #include <cstdlib>
@@ -429,7 +430,7 @@ struct Model {
// Pages the manager wants shown even when the last ones are still empty (0.2.26+). // Pages the manager wants shown even when the last ones are still empty (0.2.26+).
uint8_t pages = 1; uint8_t pages = 1;
size_t count = 0; 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; 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 // 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). // message itself was wrong (the status then says "invalid message", as before).
@@ -469,7 +470,7 @@ struct Model {
return false; return false;
} }
// A title-only update must not interrupt an open control card. // 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) { if (changed) {
// Freed before the new list is made, so the heap never holds both. // Freed before the new list is made, so the heap never holds both.
tiles.clear(); tiles.clear();
+123 -101
View File
@@ -25,6 +25,11 @@
#include <algorithm> #include <algorithm>
namespace runtime_tiles { 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; inline bool enabled = false;
// Swiping, rotation and going back to page 1 live in settings_screen, next to the other settings the // 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. // 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 swipe_preference;
inline esphome::ESPPreferenceObject home_preference; inline esphome::ESPPreferenceObject home_preference;
inline esphome::ESPPreferenceObject dark_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 Model model;
inline std::string inbox; inline std::string inbox;
inline const lv_font_t *watch_font = nullptr; inline const lv_font_t *watch_font = nullptr;
@@ -144,6 +151,9 @@ inline void load_settings() {
home_preference = esphome::global_preferences->make_preference<HomeTimeout>(0x484F4D31); home_preference = esphome::global_preferences->make_preference<HomeTimeout>(0x484F4D31);
dark_preference = esphome::global_preferences->make_preference<uint32_t>(0x44524B31); dark_preference = esphome::global_preferences->make_preference<uint32_t>(0x44524B31);
buttons_preference = esphome::global_preferences->make_preference<uint32_t>(0x50474231); buttons_preference = esphome::global_preferences->make_preference<uint32_t>(0x50474231);
numbers_preference = esphome::global_preferences->make_preference<uint32_t>(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; uint32_t swipe_saved=0;
if(swipe_preference.load(&swipe_saved))swipe_pages=swipe_saved==1; if(swipe_preference.load(&swipe_saved))swipe_pages=swipe_saved==1;
uint32_t dark_saved=0; uint32_t dark_saved=0;
@@ -296,6 +306,10 @@ inline std::string receive(const std::string &payload) {
root["auto_home_seconds"].as<unsigned>()<30 || root["auto_home_seconds"].as<unsigned>()>3600))return false; root["auto_home_seconds"].as<unsigned>()<30 || root["auto_home_seconds"].as<unsigned>()>3600))return false;
if(!root["rotation"].isNull() && (!root["rotation"].is<unsigned>() || if(!root["rotation"].isNull() && (!root["rotation"].is<unsigned>() ||
root["rotation"].as<unsigned>()>270 || root["rotation"].as<unsigned>()%90!=0))return false; root["rotation"].as<unsigned>()>270 || root["rotation"].as<unsigned>()%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<bool>())return false;
if(!root["numbers"].isNull() && (!root["numbers"].is<const char*>() ||
screen_text::number_style_of(root["numbers"].as<std::string>())<0))return false;
if(!root["keepalive"].isNull() && (!root["keepalive"].is<unsigned>() || if(!root["keepalive"].isNull() && (!root["keepalive"].is<unsigned>() ||
root["keepalive"].as<unsigned>()<5 || root["keepalive"].as<unsigned>()>3600))return false; root["keepalive"].as<unsigned>()<5 || root["keepalive"].as<unsigned>()>3600))return false;
// Explicit grid positions (0.2.26+), one absolute slot per entity; absent on older managers. // 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); HomeTimeout home{(uint32_t)(auto_home?1:0),(uint32_t)auto_home_seconds};home_preference.save(&home);
} }
bool rotation_changed=false; bool rotation_changed=false;
if(root["clock_24h"].is<bool>())settings.clock_24h=root["clock_24h"].as<bool>()?1:0;
bool format_changed=false;
if(root["numbers"].is<const char*>()){
int style=screen_text::number_style_of(root["numbers"].as<std::string>());
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<unsigned>() && rotation!=root["rotation"].as<unsigned>()){ if(rotation_supported && root["rotation"].is<unsigned>() && rotation!=root["rotation"].as<unsigned>()){
rotation=root["rotation"].as<unsigned>();rotation_preference.save(&rotation);rotation_changed=true; rotation=root["rotation"].as<unsigned>();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 // 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, // 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. // 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(); if (layout_changed) layout_changed();
refresh_all(); 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; 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){ 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()=="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 t.state=="above_horizon"?"Above the horizon":"Below the horizon"; 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()=="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.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=="on")return tr(txt::ha_on);
if(t.state=="off")return "Off"; if(t.state=="off")return tr(txt::ha_off);
if(t.state=="docked")return "Docked"; if(t.state=="docked")return tr(txt::ha_vacuum_docked);
if(t.state=="cleaning")return "Cleaning"; if(t.state=="cleaning")return tr(txt::ha_vacuum_cleaning);
if(t.state=="paused")return "Paused"; if(t.state=="paused")return tr(txt::ha_vacuum_paused);
if(t.state=="returning")return "Returning to dock"; if(t.state=="returning")return tr(txt::ha_vacuum_returning);
if(t.state=="idle")return "Idle"; if(t.state=="idle")return tr(txt::ha_vacuum_idle);
if(t.state=="error")return "Check the robot in HA"; if(t.state=="error")return tr(txt::vacuum_check_robot);
if(!t.available())return "Unavailable"; if(!t.available())return tr(txt::tile_unavailable);
// Home Assistant's word where the screen has none of its own (firmware 0.2.58+). // 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; if(!t.extra().state_word.empty())return t.extra().state_word;
return t.state; 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){ inline std::string rain_text(float chance,float mm,bool with_mm){
char b[32]; char b[32];
if(std::isfinite(chance) && chance>=0){ 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; 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 ""; return "";
} }
inline std::string degrees(float value){ if(!std::isfinite(value))return "--"; char b[16];snprintf(b,sizeof(b),"%.0f°",value);return b; } 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); 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 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); 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; 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(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);} 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); 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); y+=card_a_h+(large?12:6);
// Coming days: a heading and a card with one row per day. // 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(!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,"Coming days",pad+4,y,width-2*pad,detail_font,LV_TEXT_ALIGN_LEFT,muted);y+=text_h+8;} 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); int card_b_h=height-y-(large?10:4);
auto *days=detail_card(pad,y,width-2*pad,card_b_h); 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(); 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 cleaning=t.state=="cleaning",paused=t.state=="paused";
bool battery=std::isfinite(t.battery),on_the_way=cleaning||paused||t.state=="returning"; 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); 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. // 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'); bool small_hero=!large && !mode && !t.choice('w');
int y=large?92:52; 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. // 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; 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); 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; y+=action_h+gap;
if(!mode && !suction && !water){ 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; return;
} }
// How it cleans. The Guition groups the rows on one white card; the small screen has no room for a card. // 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(water)level(*water,60,"\U000F058C");
if(automatic){ if(automatic){
bool smart=tile_controls::shown_value(t,*mode,now).find("smart")!=std::string::npos; 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 ---- // ---- 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_track(){return theme::tint(COVER_ACCENT,37);}
inline uint32_t cover_slats(){return theme::tint(COVER_ACCENT,80);} inline uint32_t cover_slats(){return theme::tint(COVER_ACCENT,80);}
inline lv_obj_t *cover_values[2]{}; 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){ inline void cover_slider_event(lv_event_t *e){
auto *slider=lv_event_get_target_obj(e);auto code=lv_event_get_code(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; 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); detail_card(pad,top,inner,box_h);
struct Part{bool tilt;float value;const char *caption;}; struct Part{bool tilt;float value;const char *caption;};
Part parts[2];int n=0; Part parts[2];int n=0;
if(card.position)parts[n++]={false,t.position,"Position"}; if(card.position)parts[n++]={false,t.position,tr(txt::cover_position)};
if(card.tilt)parts[n++]={true,t.extra().tilt,"Tilt"}; 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); 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("--");}; 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){ 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 // 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. // still be the previous card's while this one waits for its answer.
inline std::string history_words(const Tile &t){ 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; if(history.entity==t.entity)for(const auto &pair:history.words)if(pair.first==t.state)return pair.second;
return detail_state(t); 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 int i=history_view::part_at(fraction);
const uint64_t span=h.end-h.start; const uint64_t span=h.end-h.start;
const uint32_t begin=h.start+static_cast<uint32_t>(span*i/history_view::PARTS),finish=h.start+static_cast<uint32_t>(span*(i+1)/history_view::PARTS); const uint32_t begin=h.start+static_cast<uint32_t>(span*i/history_view::PARTS),finish=h.start+static_cast<uint32_t>(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); 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.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{ }else{
// A run reads the real times of its state, not the slots it covers: a door open for 5 minutes says 5 min. // 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 int r=history_view::run_at(h.runs,h.slots,fraction);if(r<0)return;
const auto &run=h.runs[r]; const auto &run=h.runs[r];
const uint32_t begin=h.start+run.begin,finish=h.start+run.end; const uint32_t begin=h.start+run.begin,finish=h.start+run.end;
label(c.value,run.state<0||run.state>=static_cast<int>(h.states.size())?"No data":h.states[run.state].label); label(c.value,run.state<0||run.state>=static_cast<int>(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.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)); 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){ inline void history_times(int x,int w,int y,const lv_font_t *font,bool large){
const auto &h=history; const auto &h=history;
const lv_font_t *bold=detail_font?detail_font:font; const lv_font_t *bold=detail_font?detail_font:font;
const int now_w=text_width("Now",bold),gap=large?10:6; const int now_w=text_width(tr(txt::history_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); 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; int last=x-1000;
for(uint32_t at:h.times){ for(uint32_t at:h.times){
if(at<=h.start||at>=h.end)continue; 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. // waiting for a command does not lock it.
inline void history_ranges(int x,int y,int w,int h){ inline void history_ranges(int x,int y,int w,int h){
static const uint32_t hours[]={1,24,168}; 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; 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); 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); 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){ if(line){
int decimals=0;const auto dot=t.state.find('.');if(dot!=std::string::npos)decimals=static_cast<int>(std::min<size_t>(4,t.state.size()-dot-1)); int decimals=0;const auto dot=t.state.find('.');if(dot!=std::string::npos)decimals=static_cast<int>(std::min<size_t>(4,t.state.size()-dot-1));
if(c.ready)decimals=h.decimals; 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){ if(c.ready){
// The value now counts too: it can lie beyond the history (the running hour is not in the statistics yet). // 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;} 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)&&current>c.high){c.high=current;c.high_at=h.end;} if(std::isfinite(current)&&std::isfinite(c.high)&&current>c.high){c.high=current;c.high_at=h.end;}
if(std::isfinite(current)&&std::isfinite(c.low)&&current<c.low){c.low=current;c.low_at=h.end;} if(std::isfinite(current)&&std::isfinite(c.low)&&current<c.low){c.low=current;c.low_at=h.end;}
} }
if(std::isfinite(c.high))c.first_text="High "+history_view::number(c.high,h.decimals,h.unit)+" \u00B7 "+history_clock(c.high_at,h.hours==168); if(std::isfinite(c.high))c.first_text=fill(txt::history_high,"value",history_view::number(c.high,h.decimals,h.unit))+" \u00B7 "+history_clock(c.high_at,h.hours==168);
if(std::isfinite(c.low))c.second_text="Low "+history_view::number(c.low,h.decimals,h.unit)+" \u00B7 "+history_clock(c.low_at,h.hours==168); if(std::isfinite(c.low))c.second_text=fill(txt::history_low,"value",history_view::number(c.low,h.decimals,h.unit))+" \u00B7 "+history_clock(c.low_at,h.hours==168);
}else{ }else{
c.value_text=history_words(t); c.value_text=history_words(t);
if(c.ready&&h.active>=0){ if(c.ready&&h.active>=0){
c.first_text=h.states[h.active].label+" \u00B7 "+history_view::duration(h.states[h.active].seconds); 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); 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. // 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); 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<history_view::PARTS;++i)if(h.has[i])widest=std::max(widest,text_width(history_view::number(h.values[i],h.decimals,h.unit),big));} if(c.ready&&line){for(int i=0;i<history_view::PARTS;++i)if(h.has[i])widest=std::max(widest,text_width(history_view::number(h.values[i],h.decimals,h.unit),big));}
else if(c.ready){for(const auto &s:h.states)widest=std::max(widest,text_width(s.label,big));} else if(c.ready){for(const auto &s:h.states)widest=std::max(widest,text_width(s.label,big));}
const int value_x=pad+(large?8:4),value_w=std::min(widest+6,(width-2*pad)*11/20); const int value_x=pad+(large?8:4),value_w=std::min(widest+6,(width-2*pad)*11/20);
@@ -1853,7 +1873,7 @@ inline void render_history_detail(const Tile &t,bool large,int width,int height,
detail_card(pad,top,width-2*pad,card_h); detail_card(pad,top,width-2*pad,card_h);
const bool empty=c.ready&&(line?std::none_of(h.has,h.has+history_view::PARTS,[](bool v){return v;}):h.states.empty()); const bool empty=c.ready&&(line?std::none_of(h.has,h.has+history_view::PARTS,[](bool v){return v;}):h.states.empty());
if(!c.ready||empty){ if(!c.ready||empty){
c.status=detail_text(detail_root,!c.ready?"Loading history...":"No history in this period",pad,top+(card_h-text_h)/2,width-2*pad,text,LV_TEXT_ALIGN_CENTER,theme::SUBTLE); c.status=detail_text(detail_root,tr(!c.ready?txt::history_loading:txt::history_empty),pad,top+(card_h-text_h)/2,width-2*pad,text,LV_TEXT_ALIGN_CENTER,theme::SUBTLE);
}else if(line){ }else if(line){
render_history_line(large,pad,top,width-2*pad,card_h,small,current); render_history_line(large,pad,top,width-2*pad,card_h,small,current);
}else{ }else{
@@ -1889,7 +1909,7 @@ inline void media_action(Tile &t,int cmd){
// An off or standby player shows one key: power, when the player can be turned on from here. // An off or standby player shows one key: power, when the player can be turned on from here.
inline bool media_off(const Tile &t){return t.state=="off" || t.state=="standby";} inline bool media_off(const Tile &t){return t.state=="off" || t.state=="standby";}
inline std::string media_volume_text(const Tile &t){ inline std::string media_volume_text(const Tile &t){
if(t.muted)return "Muted"; if(t.muted)return tr(txt::media_muted);
if(!std::isfinite(t.volume))return ""; if(!std::isfinite(t.volume))return "";
return std::to_string((int)std::lround(std::clamp(t.volume,0.0f,1.0f)*100))+" %"; return std::to_string((int)std::lround(std::clamp(t.volume,0.0f,1.0f)*100))+" %";
} }
@@ -2069,12 +2089,12 @@ inline void show_detail(unsigned index){
}else if(d=="weather"){ }else if(d=="weather"){
render_weather_detail(t,large,width,height,pad); render_weather_detail(t,large,width,height,pad);
}else if(d=="timer"){ }else if(d=="timer"){
detail_label(detail_root,t.state=="active"?"Running":t.state=="paused"?"Paused":"Stopped",pad,top,width-2*pad); detail_label(detail_root,tr(t.state=="active"?txt::timer_running:t.state=="paused"?txt::timer_paused:txt::timer_stopped),pad,top,width-2*pad);
detail_button(t.state=="active"?"Pause":"Start",pad,top+(large?50:30),cw,bh,40); detail_button(tr(t.state=="active"?txt::timer_pause:txt::timer_start),pad,top+(large?50:30),cw,bh,40);
detail_button("Cancel",pad+cw+gap,top+(large?50:30),cw,bh,41); detail_button(tr(txt::timer_cancel),pad+cw+gap,top+(large?50:30),cw,bh,41);
}else if(d=="sun"){ }else if(d=="sun"){
detail_label(detail_root,"Sunrise "+t.extra().sunrise,pad,top,width-2*pad); detail_label(detail_root,fill(txt::sun_sunrise,"time",t.extra().sunrise),pad,top,width-2*pad);
detail_label(detail_root,"Sunset "+t.extra().sunset,pad,top+lv_font_get_line_height(detail_font)+(large?10:4),width-2*pad); detail_label(detail_root,fill(txt::sun_sunset,"time",t.extra().sunset),pad,top+lv_font_get_line_height(detail_font)+(large?10:4),width-2*pad);
} }
} }
} }
@@ -2108,20 +2128,20 @@ inline const char *weather_icon(const std::string &condition) {
return "\U000F0595"; return "\U000F0595";
} }
inline const char *weather_text(const std::string &condition) { inline const char *weather_text(const std::string &condition) {
if (condition == "sunny") return "Sunny"; if (condition == "sunny") return tr(txt::ha_weather_sunny);
if (condition == "clear-night") return "Clear, night"; if (condition == "clear-night") return tr(txt::ha_weather_clear_night);
if (condition == "cloudy") return "Cloudy"; if (condition == "cloudy") return tr(txt::ha_weather_cloudy);
if (condition == "partlycloudy") return "Partly cloudy"; if (condition == "partlycloudy") return tr(txt::ha_weather_partlycloudy);
if (condition == "rainy") return "Rainy"; if (condition == "rainy") return tr(txt::ha_weather_rainy);
if (condition == "pouring") return "Pouring"; if (condition == "pouring") return tr(txt::ha_weather_pouring);
if (condition == "snowy") return "Snowy"; if (condition == "snowy") return tr(txt::ha_weather_snowy);
if (condition == "snowy-rainy") return "Snowy, rainy"; if (condition == "snowy-rainy") return tr(txt::ha_weather_snowy_rainy);
if (condition == "fog") return "Fog"; if (condition == "fog") return tr(txt::ha_weather_fog);
if (condition == "hail") return "Hail"; if (condition == "hail") return tr(txt::ha_weather_hail);
if (condition == "lightning") return "Lightning"; if (condition == "lightning") return tr(txt::ha_weather_lightning);
if (condition == "lightning-rainy") return "Lightning, rainy"; if (condition == "lightning-rainy") return tr(txt::ha_weather_lightning_rainy);
if (condition == "windy" || condition == "windy-variant") return "Windy"; if (condition == "windy" || condition == "windy-variant") return tr(txt::ha_weather_windy);
if (condition == "exceptional") return "Exceptional"; if (condition == "exceptional") return tr(txt::ha_weather_exceptional);
return condition.c_str(); return condition.c_str();
} }
inline const char *icon_for(const Tile &tile) { inline const char *icon_for(const Tile &tile) {
@@ -2150,36 +2170,38 @@ inline std::string countdown(uint32_t seconds) {
else snprintf(b, sizeof(b), "%u:%02u", seconds / 60, seconds % 60); else snprintf(b, sizeof(b), "%u:%02u", seconds / 60, seconds % 60);
return b; return b;
} }
// "Last 14:32" today, "Yesterday 14:32", else "Last 13 Sep"; scripts and scenes have no useful on/off. // "Last 14:32" today, "Yesterday 14:32", else "Last 13 Sep", in the screen's language; scripts and scenes have no
// useful on/off.
inline std::string month_short(const esphome::ESPTime &now); inline std::string month_short(const esphome::ESPTime &now);
inline std::string last_run_text(uint32_t epoch) { inline std::string last_run_text(uint32_t epoch) {
if (!epoch) return "Never run"; if (!epoch) return tr(txt::script_never_run);
auto when = esphome::ESPTime::from_epoch_local(epoch); auto when = esphome::ESPTime::from_epoch_local(epoch);
auto now = now_time ? now_time() : esphome::ESPTime{}; auto now = now_time ? now_time() : esphome::ESPTime{};
if (!when.is_valid()) return "Never run"; if (!when.is_valid()) return tr(txt::script_never_run);
char clock[8]; snprintf(clock, sizeof(clock), "%02d:%02d", when.hour, when.minute); char clock[8]; snprintf(clock, sizeof(clock), "%02d:%02d", when.hour, when.minute);
if (now.is_valid() && now.year == when.year && now.day_of_year == when.day_of_year) return std::string("Last ") + clock; if (now.is_valid() && now.year == when.year && now.day_of_year == when.day_of_year) return fill(txt::script_last_time, "time", clock);
if (now.is_valid() && now.year == when.year && now.day_of_year == when.day_of_year + 1) return std::string("Yesterday ") + clock; if (now.is_valid() && now.year == when.year && now.day_of_year == when.day_of_year + 1) return fill(txt::script_yesterday_time, "time", clock);
return "Last " + std::to_string(when.day_of_month) + " " + month_short(when); std::string date = fill(fill(txt::date_day_month, "day", std::to_string(when.day_of_month)), "month", month_short(when));
return fill(txt::script_last_date, "date", date);
} }
inline std::string timer_text(const Tile &t) { inline std::string timer_text(const Tile &t) {
const Extra &x = t.extra(); const Extra &x = t.extra();
if (t.state == "active") return countdown(timer_left(x.timer_end, now_epoch(), x.duration)); if (t.state == "active") return countdown(timer_left(x.timer_end, now_epoch(), x.duration));
if (t.state == "paused") return "Paused " + countdown(duration_seconds(x.remaining)); if (t.state == "paused") return fill(txt::timer_paused_left, "time", countdown(duration_seconds(x.remaining)));
return x.duration.empty() ? "Off" : countdown(duration_seconds(x.duration)); return x.duration.empty() ? std::string(tr(txt::ha_off)) : countdown(duration_seconds(x.duration));
} }
inline std::string weekday_text(const esphome::ESPTime &now) { inline std::string weekday_text(const esphome::ESPTime &now) {
static const char *days[] = {"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}; return now.is_valid() && now.day_of_week >= 1 && now.day_of_week <= 7 ? tr(txt::date_weekdays + now.day_of_week - 1) : "";
return now.is_valid() && now.day_of_week >= 1 && now.day_of_week <= 7 ? days[now.day_of_week - 1] : "";
} }
inline std::string month_short(const esphome::ESPTime &now) { 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 ? tr(txt::date_months_short + now.month - 1) : "";
return now.is_valid() && now.month >= 1 && now.month <= 12 ? months[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) { 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 ""; 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). // 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) { 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); 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 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; 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,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,"rise "+t.extra().sunrise); 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,"set "+t.extra().sunset); 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{}; 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; 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; 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. // 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; std::string middle;const lv_font_t *mid_font=watch_value_font?watch_value_font:w.value_font;
auto d=t.domain(); 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<int>(std::lround(t.position)))+" %";} else if(d=="cover" && std::isfinite(t.position)){middle=std::to_string(static_cast<int>(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;} 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); 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 unit=watch?t.unit:"";
std::string value = t.state; std::string value = t.state;
// Nothing in Home Assistant stands behind the settings card, so it says the same with the link down. // 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"; if (t.is_settings()) value = tr(txt::tile_tap_to_open);
else if (t.is_page()) value = "Page " + std::to_string(t.page_target()); else if (t.is_page()) value = fill(txt::tile_page, "n", t.page_target());
else if (!fresh() || !t.available()) value = "Unavailable"; else if (!fresh() || !t.available()) value = tr(txt::tile_unavailable);
else if (t.refused_at && esphome::millis() - t.refused_at < 4000) value = "Refused"; 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" && tile_controls::effect_running(t.extra().effect)) value = t.extra().effect;
else if (d == "light" && t.state == "on" && std::isfinite(t.brightness)) value = std::to_string(static_cast<int>(std::lround(std::clamp(t.brightness, 0.0f, 255.0f) * 100 / 255))) + " %"; else if (d == "light" && t.state == "on" && std::isfinite(t.brightness)) value = std::to_string(static_cast<int>(std::lround(std::clamp(t.brightness, 0.0f, 255.0f) * 100 / 255))) + " %";
// An airco that is off says so, with the room's temperature when it knows it, as Home Assistant's tile does // 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. // (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" && 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)) { char b[32]; snprintf(b, sizeof(b), "%.1f°", t.target); value = b; } else if (d == "climate" && std::isfinite(t.target)) value = screen_text::decimal(t.target, 1) + "°";
else if (d == "person") value = t.state=="home"?"Home":t.state=="not_home"?"Away":t.state; 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 : t.state=="above_horizon"?"Above the horizon":"Below the horizon"; 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 == "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 == "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 = t.state == "streaming" ? "Live" : t.state == "recording" ? "Recording" : "Tap to view"; 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 = t.last_run ? "Tap to view" : "No image yet"; 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 (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 == "on") value = tr(txt::ha_on);
else if (value == "off") value = "Off"; else if (value == "off") value = tr(txt::ha_off);
else if (value == "cleaning") value = "Cleaning"; else if (value == "cleaning") value = tr(txt::ha_vacuum_cleaning);
else if (value == "docked") value = "Docked"; 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. // 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; 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+). // 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 (d == "media_player") value = tile_controls::media_state_text(t.state);
else if (!t.unit.empty() && !watch) value += " " + t.unit; else if (!t.unit.empty() && !watch) value += " " + t.unit;
bool pending=t.loading(esphome::millis()); 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)+"%"; 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. // 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(); 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) { inline void render(lv_obj_t *room) {
if (!enabled) return; if (!enabled) return;
room_label=room; swipe_profile::Lap lap; 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; } 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(); render_header();
lap(swipe_profile::HEADER); lap(swipe_profile::HEADER);
bool all=dirty_all || (!dirty_tiles && !dirty_header); bool all=dirty_all || (!dirty_tiles && !dirty_header);
@@ -3856,15 +3878,15 @@ inline void tick() {
const uint32_t now=esphome::millis(); const uint32_t now=esphome::millis();
if(history_fits(t))refresh_detail(detail_index); 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>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<detail_action_count;++i){if(waiting||!fresh()||!t.available())lv_obj_add_state(detail_actions[i],LV_STATE_DISABLED);else lv_obj_remove_state(detail_actions[i],LV_STATE_DISABLED);} for(unsigned i=0;i<detail_action_count;++i){if(waiting||!fresh()||!t.available())lv_obj_add_state(detail_actions[i],LV_STATE_DISABLED);else lv_obj_remove_state(detail_actions[i],LV_STATE_DISABLED);}
std::string status=waiting?(t.confirmed?"Confirmed by Home Assistant":"Command sent..."):t.domain()=="cover"?cover_status_line(t):detail_state(t); std::string status=waiting?std::string(tr(t.confirmed?txt::tile_confirmed:txt::tile_command_sent)):t.domain()=="cover"?cover_status_line(t):detail_state(t);
if(detail_status)label(detail_status,status+(!waiting && !t.unit.empty()?" "+t.unit:"")); if(detail_status)label(detail_status,status+(!waiting && !t.unit.empty()?" "+t.unit:""));
// The vacuum card lays its state out with the room and battery beside it, so a new text draws the // The vacuum card lays its state out with the room and battery beside it, so a new text draws the
// card again; once Home Assistant answered, the new state says enough. // card again; once Home Assistant answered, the new state says enough.
if(detail_badge_status && t.domain()=="vacuum"){ if(detail_badge_status && t.domain()=="vacuum"){
status=waiting && !t.confirmed?"Command sent...":detail_state(t); status=waiting && !t.confirmed?std::string(tr(txt::tile_command_sent)):detail_state(t);
if(status!=lv_label_get_text(detail_badge_status))refresh_detail(detail_index); if(status!=lv_label_get_text(detail_badge_status))refresh_detail(detail_index);
}else if(detail_badge_status)label(detail_badge_status,status); }else if(detail_badge_status)label(detail_badge_status,status);
if(detail_switch && !lv_obj_has_state(detail_switch,LV_STATE_PRESSED)){ if(detail_switch && !lv_obj_has_state(detail_switch,LV_STATE_PRESSED)){
@@ -4261,7 +4283,7 @@ inline void camera_answer(const std::string &view, const std::string &entity, co
if (!camera_root || camera.entity != entity) return; if (!camera_root || camera.entity != entity) return;
camera.link(url); camera.link(url);
if (url.empty()) { if (url.empty()) {
if (!camera.shown) camera_note_text("No image from this camera"); if (!camera.shown) camera_note_text(tr(txt::camera_no_image));
return; return;
} }
// Loaded now rather than on the next tick (firmware 0.2.73+), as an alert's image is. // Loaded now rather than on the next tick (firmware 0.2.73+), as an alert's image is.
@@ -4364,6 +4386,6 @@ inline void camera_failed(bool thumb) {
} }
if (!camera.loading) return; if (!camera.loading) return;
camera.finish(esphome::millis(), false); camera.finish(esphome::millis(), false);
if (!camera.shown) camera_note_text("No image from this camera"); if (!camera.shown) camera_note_text(tr(txt::camera_no_image));
} }
} // namespace runtime_tiles } // namespace runtime_tiles
+85
View File
@@ -0,0 +1,85 @@
#pragma once
// The texts a screen shows, in the one language its firmware was built for (app 0.2.90). Every text has a key in the
// `screen` section of screen_manager/translations/<code>.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 <cstdint>
#include <cstdio>
#include <cstring>
#include <string>
#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
+172
View File
@@ -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/<code>.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 <cstdint>',
'',
'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)
+279
View File
@@ -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 <cstdint>
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
+68 -56
View File
@@ -6,6 +6,7 @@
#include <cstdio> #include <cstdio>
#include <string> #include <string>
#include "screen_settings.h" #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 // 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. // 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 Shown = bool (*)();
using Run = void (*)(); 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 { struct Row {
Kind kind = Kind::info; Kind kind = Kind::info;
const char *label = ""; uint16_t label = NO_TEXT;
const char *icon = ""; // page rows and actions; a Material Design Icons glyph const char *icon = ""; // page rows and actions; a Material Design Icons glyph
Read read = nullptr; // toggle, number, duration, moment, choice Read read = nullptr; // toggle, number, duration, moment, choice
Write write = nullptr; Write write = nullptr;
int32_t low = 0, high = 0, step = 0; // number: fixed step; duration: seconds, step from the ladder 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 *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; uint8_t option_count = 0;
Text text = nullptr; // info Text text = nullptr; // info
Run run = nullptr; // action Run run = nullptr; // action
@@ -51,35 +56,40 @@ struct Row {
uint8_t opens = 0; // page rows: the page they open 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; 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; 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) { const char *unit = "", Shown enabled = nullptr) {
Row r{}; r.kind = Kind::number; r.label = label; r.read = read; r.write = write; 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; 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) { Shown enabled = nullptr) {
Row r{}; r.kind = Kind::duration; r.label = label; r.read = read; r.write = write; 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; 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; 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; 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) { Shown shown = nullptr) {
Row r{}; r.kind = Kind::choice; r.label = label; r.read = read; r.write = write; 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; 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; 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; 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) { inline std::string duration_text(int32_t seconds) {
char buffer[24]; using namespace screen_text;
if (seconds < 60) { snprintf(buffer, sizeof(buffer), "%d sec", (int) seconds); return buffer; } if (seconds < 60) return fill(txt::settings_seconds, "n", (int) seconds);
if (seconds < 3600) { snprintf(buffer, sizeof(buffer), "%d min", (int) (seconds / 60)); return buffer; } if (seconds < 3600) return fill(txt::settings_minutes, "n", (int) (seconds / 60));
int hours = seconds / 3600, minutes = (seconds % 3600) / 60; int hours = seconds / 3600, minutes = (seconds % 3600) / 60;
if (minutes) snprintf(buffer, sizeof(buffer), "%d h %02d", hours, minutes); if (!minutes) return fill(txt::settings_hours, "n", hours);
else snprintf(buffer, sizeof(buffer), "%d h", hours); char two[4];
return buffer; 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. // Minutes since midnight as the clock on this screen shows them.
inline std::string moment_text(int32_t minutes, bool clock_24h) { inline std::string moment_text(int32_t minutes, bool clock_24h) {
char buffer[16]; char buffer[16];
int hour = minutes / 60, minute = minutes % 60; int hour = minutes / 60, minute = minutes % 60;
if (clock_24h) snprintf(buffer, sizeof(buffer), "%02d:%02d", hour, minute); 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; return buffer;
} }
// What the right-hand side of a row says. A toggle draws a switch instead, its text is for the tests. // 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) { inline std::string value_text(const Row &row) {
switch (row.kind) { 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: { case Kind::number: {
char buffer[24]; char buffer[24];
snprintf(buffer, sizeof(buffer), "%d%s", row.read ? (int) row.read() : 0, row.unit); 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::moment: return moment_text(row.read ? row.read() : 0, screen_settings::current.clock_24h != 0);
case Kind::choice: { case Kind::choice: {
int32_t index = row.read ? row.read() : 0; 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() : ""; case Kind::info: return row.text ? row.text() : "";
default: return ""; 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(); } inline bool live_row(const Row &row) { return !row.enabled || row.enabled(); }
struct Page { struct Page {
const char *title; uint16_t title;
const Row *rows; const Row *rows;
uint8_t count; uint8_t count;
}; };
@@ -223,7 +236,6 @@ inline uint8_t fitting_rows(int span, int row_height, int gap, int pager, uint8_
} }
// ------------------------------------------------------------------ the pages // ------------------------------------------------------------------ the pages
inline constexpr const char *clock_options[] = {"12 hour", "24 hour"};
inline constexpr const char *rotation_options[] = {"0°", "90°", "180°", "270°"}; 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. // 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, // plain `int` (a ternary, say) then does not convert to Read at all. The Mac's host build accepts it,
// the board does not. // the board does not.
inline constexpr Row light_rows[] = { 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, "%"), [](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); }), [](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); }), [](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, [](int32_t value) { set("standby_seconds", value); }, 60, 86400,
[] { return screen_settings::current.standby_enabled != 0; }), [] { 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, "%", [](int32_t value) { set("standby_brightness", value); }, 0, 100, 5, "%",
[] { return screen_settings::current.standby_enabled != 0; }), [] { return screen_settings::current.standby_enabled != 0; }),
}; };
// Darker between two times, so a panel in a hallway does not light up the bedroom. // Darker between two times, so a panel in a hallway does not light up the bedroom.
inline constexpr Row night_rows[] = { 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); }), [](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); }, [](int32_t value) { set("night_start", value); },
[] { return screen_settings::current.night_enabled != 0; }), [] { 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); }, [](int32_t value) { set("night_end", value); },
[] { return screen_settings::current.night_enabled != 0; }), [] { 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, "%", [](int32_t value) { set("night_brightness", value); }, 0, 100, 5, "%",
[] { return screen_settings::current.night_enabled != 0; }), [] { 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, // How the screen behaves under your finger: going back to the first page, swiping, the page buttons, turning. The
// turning. // 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[] = { inline constexpr Row screen_rows[] = {
choice("Clock", []() -> int32_t { return screen_settings::current.clock_24h ? 1 : 0; }, toggle(screen_text::txt::settings_back_to_page_1, []() -> int32_t { return auto_home; },
[](int32_t value) { set("clock_24h", value); }, clock_options, 2),
toggle("Back to page 1", []() -> int32_t { return auto_home; },
[](int32_t value) { set("auto_home", value); }), [](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, [](int32_t value) { set("auto_home_seconds", value); }, 30, 3600,
[] { return auto_home != 0; }), [] { 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); }), [](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); }), [](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); }), [](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<int32_t>(value, 0, 3) * 90); }, [](int32_t value) { set("rotation", std::clamp<int32_t>(value, 0, 3) * 90); },
rotation_options, 4, [] { return rotation_supported; }), rotation_options, 4, [] { return rotation_supported; }),
}; };
@@ -288,26 +299,26 @@ inline std::string (*firmware_text)() = nullptr;
inline std::string (*link_text)() = nullptr; inline std::string (*link_text)() = nullptr;
inline void (*restart_device)() = nullptr; inline void (*restart_device)() = nullptr;
inline constexpr Row about_rows[] = { inline constexpr Row about_rows[] = {
info("Screen", [] { return name_text ? name_text() : std::string(); }), info(screen_text::txt::settings_screen, [] { return name_text ? name_text() : std::string(); }),
info("Address", [] { return address_text ? address_text() : std::string(); }), info(screen_text::txt::settings_address, [] { return address_text ? address_text() : std::string(); }),
info("Firmware", [] { return firmware_text ? firmware_text() : std::string(); }), info(screen_text::txt::settings_firmware, [] { return firmware_text ? firmware_text() : std::string(); }),
info("Home Assistant", [] { return link_text ? link_text() : std::string(); }), info(screen_text::txt::settings_home_assistant, [] { return link_text ? link_text() : std::string(); }),
action("Restart", "\U000F0709", [] { if (restart_device) restart_device(); }), action(screen_text::txt::settings_restart, "\U000F0709", [] { if (restart_device) restart_device(); }),
}; };
inline constexpr Row menu_rows[] = { inline constexpr Row menu_rows[] = {
page_row("Brightness", "\U000F0599", 1), page_row(screen_text::txt::settings_brightness, "\U000F0599", 1),
page_row("Night", "\U000F0594", 2), page_row(screen_text::txt::settings_night, "\U000F0594", 2),
page_row("Screen", "\U000F0379", 3), page_row(screen_text::txt::settings_screen, "\U000F0379", 3),
page_row("This screen", "\U000F02FD", 4), page_row(screen_text::txt::settings_this_screen, "\U000F02FD", 4),
}; };
inline constexpr Page pages[] = { inline constexpr Page pages[] = {
{"Settings", menu_rows, (uint8_t) std::size(menu_rows)}, {screen_text::txt::settings_title, menu_rows, (uint8_t) std::size(menu_rows)},
{"Brightness", light_rows, (uint8_t) std::size(light_rows)}, {screen_text::txt::settings_brightness, light_rows, (uint8_t) std::size(light_rows)},
{"Night", night_rows, (uint8_t) std::size(night_rows)}, {screen_text::txt::settings_night, night_rows, (uint8_t) std::size(night_rows)},
{"Screen", screen_rows, (uint8_t) std::size(screen_rows)}, {screen_text::txt::settings_screen, screen_rows, (uint8_t) std::size(screen_rows)},
{"This screen", about_rows, (uint8_t) std::size(about_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); constexpr uint8_t PAGE_COUNT = (uint8_t) std::size(pages);
@@ -587,7 +598,7 @@ inline void draw() {
lv_obj_center(arrow); lv_obj_center(arrow);
lv_obj_add_event_cb(back, back_event, LV_EVENT_SHORT_CLICKED, nullptr); lv_obj_add_event_cb(back, back_event, LV_EVENT_SHORT_CLICKED, nullptr);
const lv_font_t *heading = title_font ? title_font : row_font; 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_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); 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); lv_obj_set_pos(glyph, left, (m.row_h - icon_h) / 2);
left += icon_h + (m.large ? 12 : 8); 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); 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 int right = m.width - 2 * m.pad - m.inset; // free space from the right edge of the card
+72 -52
View File
@@ -4,6 +4,7 @@
// a -/+ step lands on the entity's grid, and the status line beside them. The // 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. // LVGL drawing lives in runtime_tiles.h; tests/test_tile_controls.cpp covers this.
#include "runtime_model.h" #include "runtime_model.h"
#include "screen_text.h"
#include "theme.h" #include "theme.h"
#include <array> #include <array>
#include <cmath> #include <cmath>
@@ -157,69 +158,88 @@ inline float step_value(float current, float step, float minimum, float maximum,
if (has_max) next = std::fmin(next, maximum); if (has_max) next = std::fmin(next, maximum);
return next; 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) { inline std::string format_value(float value, float step, const char *suffix) {
if (!std::isfinite(value)) return "--"; 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) { inline const char *climate_mode_text(const std::string &mode) {
if (mode == "off") return "Off"; if (mode == "off") return screen_text::tr(screen_text::txt::ha_climate_off);
if (mode == "heat") return "Heat"; if (mode == "heat") return screen_text::tr(screen_text::txt::ha_climate_heat);
if (mode == "cool") return "Cool"; if (mode == "cool") return screen_text::tr(screen_text::txt::ha_climate_cool);
if (mode == "heat_cool") return "Heat/Cool"; if (mode == "heat_cool") return screen_text::tr(screen_text::txt::ha_climate_heat_cool);
if (mode == "auto") return "Auto"; if (mode == "auto") return screen_text::tr(screen_text::txt::ha_climate_auto);
if (mode == "dry") return "Dry"; if (mode == "dry") return screen_text::tr(screen_text::txt::ha_climate_dry);
if (mode == "fan_only") return "Fan only"; if (mode == "fan_only") return screen_text::tr(screen_text::txt::ha_climate_fan_only);
return mode.c_str(); return mode.c_str();
} }
inline const char *climate_action_text(const std::string &action) { inline const char *climate_action_text(const std::string &action) {
if (action == "heating") return "Heating"; if (action == "heating") return screen_text::tr(screen_text::txt::ha_hvac_action_heating);
if (action == "cooling") return "Cooling"; if (action == "cooling") return screen_text::tr(screen_text::txt::ha_hvac_action_cooling);
if (action == "idle") return "Idle"; if (action == "idle") return screen_text::tr(screen_text::txt::ha_hvac_action_idle);
if (action == "off") return "Off"; if (action == "off") return screen_text::tr(screen_text::txt::ha_hvac_action_off);
if (action == "drying") return "Drying"; if (action == "drying") return screen_text::tr(screen_text::txt::ha_hvac_action_drying);
if (action == "fan") return "Fan"; if (action == "fan") return screen_text::tr(screen_text::txt::ha_hvac_action_fan);
if (action == "preheating") return "Preheating"; if (action == "preheating") return screen_text::tr(screen_text::txt::ha_hvac_action_preheating);
if (action == "defrosting") return "Defrosting"; if (action == "defrosting") return screen_text::tr(screen_text::txt::ha_hvac_action_defrosting);
return ""; return "";
} }
inline const char *cover_state_text(const std::string &state) { inline const char *cover_state_text(const std::string &state) {
if (state == "open") return "Open"; if (state == "open") return screen_text::tr(screen_text::txt::ha_cover_open);
if (state == "closed") return "Closed"; if (state == "closed") return screen_text::tr(screen_text::txt::ha_cover_closed);
if (state == "opening") return "Opening"; if (state == "opening") return screen_text::tr(screen_text::txt::ha_cover_opening);
if (state == "closing") return "Closing"; if (state == "closing") return screen_text::tr(screen_text::txt::ha_cover_closing);
return state.c_str(); return state.c_str();
} }
inline const char *media_state_text(const std::string &state) { inline const char *media_state_text(const std::string &state) {
if (state == "playing") return "Playing"; if (state == "playing") return screen_text::tr(screen_text::txt::ha_media_playing);
if (state == "paused") return "Paused"; if (state == "paused") return screen_text::tr(screen_text::txt::ha_media_paused);
if (state == "idle") return "Idle"; if (state == "idle") return screen_text::tr(screen_text::txt::ha_media_idle);
if (state == "standby") return "Standby"; if (state == "standby") return screen_text::tr(screen_text::txt::ha_media_standby);
if (state == "buffering") return "Loading"; if (state == "buffering") return screen_text::tr(screen_text::txt::media_loading);
if (state == "on") return "On"; if (state == "on") return screen_text::tr(screen_text::txt::ha_on);
if (state == "off") return "Off"; if (state == "off") return screen_text::tr(screen_text::txt::ha_off);
return state.c_str(); 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 // 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; // Wet or Dry, in the screen's language (screen.ha.binary, taken from Home Assistant's own translations). The add-on's
// tests/test_binary_words.py keeps the two tables equal. Without a class, or with one this table lacks: On and Off. // header_bar.BINARY_STATES reads the same words from the same file for the top bar and the history card. Without a
struct BinaryWords { const char *device_class, *on, *off; }; // class, or with one this table lacks: On and Off.
struct BinaryWords { const char *device_class; uint16_t on, off; };
constexpr BinaryWords BINARY_WORDS[] = { constexpr BinaryWords BINARY_WORDS[] = {
{"battery", "Low", "Normal"}, {"battery_charging", "Charging", "Not charging"}, {"battery", screen_text::txt::ha_binary_battery_on, screen_text::txt::ha_binary_battery_off},
{"carbon_monoxide", "Danger", "Safe"}, {"cold", "Cold", "Normal"}, {"connectivity", "Connected", "Disconnected"}, {"battery_charging", screen_text::txt::ha_binary_battery_charging_on, screen_text::txt::ha_binary_battery_charging_off},
{"door", "Open", "Closed"}, {"garage_door", "Open", "Closed"}, {"gas", "Danger", "Safe"}, {"carbon_monoxide", screen_text::txt::ha_binary_carbon_monoxide_on, screen_text::txt::ha_binary_carbon_monoxide_off},
{"heat", "Hot", "Normal"}, {"light", "Light", "Dark"}, {"lock", "Open", "Locked"}, {"moisture", "Wet", "Dry"}, {"cold", screen_text::txt::ha_binary_cold_on, screen_text::txt::ha_binary_cold_off},
{"motion", "Motion", "No motion"}, {"moving", "Moving", "Still"}, {"occupancy", "Occupied", "Clear"}, {"connectivity", screen_text::txt::ha_binary_connectivity_on, screen_text::txt::ha_binary_connectivity_off},
{"opening", "Open", "Closed"}, {"plug", "Plugged in", "Unplugged"}, {"power", "On", "Off"}, {"door", screen_text::txt::ha_binary_door_on, screen_text::txt::ha_binary_door_off},
{"presence", "Home", "Away"}, {"problem", "Problem", "OK"}, {"running", "Active", "Inactive"}, {"garage_door", screen_text::txt::ha_binary_garage_door_on, screen_text::txt::ha_binary_garage_door_off},
{"safety", "Unsafe", "Safe"}, {"smoke", "Smoke", "No smoke"}, {"sound", "Sound", "Silent"}, {"gas", screen_text::txt::ha_binary_gas_on, screen_text::txt::ha_binary_gas_off},
{"tamper", "Tampering", "OK"}, {"update", "Update", "Up to date"}, {"vibration", "Vibration", "Still"}, {"heat", screen_text::txt::ha_binary_heat_on, screen_text::txt::ha_binary_heat_off},
{"window", "Open", "Closed"}, {"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) { inline const char *binary_state_text(const std::string &device_class, bool on) {
for (const auto &words : BINARY_WORDS) for (const auto &words : BINARY_WORDS)
if (device_class == words.device_class) return on ? words.on : words.off; if (device_class == words.device_class) return screen_text::tr(on ? words.on : words.off);
return on ? "On" : "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. // Status line beside a control panel: what Home Assistant shows under the name.
inline std::string status_text(const Tile &t) { inline std::string status_text(const Tile &t) {
@@ -227,7 +247,7 @@ inline std::string status_text(const Tile &t) {
if (d == "climate") { if (d == "climate") {
std::string text = climate_action_text(t.extra().hvac_action); std::string text = climate_action_text(t.extra().hvac_action);
if (text.empty()) text = climate_mode_text(t.state); 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; return text;
} }
if (d == "cover") { if (d == "cover") {
@@ -261,7 +281,7 @@ inline CoverCard cover_card(const Tile &t) {
inline std::string cover_card_status(const Tile &t) { inline std::string cover_card_status(const Tile &t) {
std::string text = status_text(t); std::string text = status_text(t);
if ((t.supported & feature::COVER_TILT_POSITION) && std::isfinite(t.extra().tilt)) { 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; return text;
} }
@@ -328,9 +348,9 @@ inline std::string neighbour_option(const Tile &t, int direction) {
return options[(current + direction + count) % count]; return options[(current + direction + count) % count];
} }
inline const char *run_label(const std::string &domain) { inline const char *run_label(const std::string &domain) {
if (domain == "scene") return "Activate"; if (domain == "scene") return screen_text::tr(screen_text::txt::button_activate);
if (domain == "script") return "Run"; if (domain == "script") return screen_text::tr(screen_text::txt::button_run);
return "Press"; return screen_text::tr(screen_text::txt::button_press);
} }
// The Home Assistant action behind a key. STEP_* are handled locally (debounced) and return nothing. // 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 = "") { 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. // 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) { inline std::string speed_label(const std::string &speed) {
if (speed == "quiet") return "Quiet"; if (speed == "quiet") return screen_text::tr(screen_text::txt::vacuum_speed_quiet);
if (speed == "balanced") return "Normal"; if (speed == "balanced") return screen_text::tr(screen_text::txt::vacuum_speed_balanced);
if (speed == "turbo") return "Turbo"; if (speed == "turbo") return screen_text::tr(screen_text::txt::vacuum_speed_turbo);
if (speed == "max") return "Max"; if (speed == "max") return screen_text::tr(screen_text::txt::vacuum_speed_max);
return speed; return speed;
} }
// The suction row a vacuum card shows: the manager's (filtered, labelled) speeds, else the first four // The suction row a vacuum card shows: the manager's (filtered, labelled) speeds, else the first four
+3 -3
View File
@@ -275,7 +275,7 @@ substitutions:
# The touch test page # The touch test page
TOUCH_TEST_PAGE_PAINT: "paint_calibration" # the 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_FONT: "sublabel"
TOUCH_TEST_TITLE_Y: "65" TOUCH_TEST_TITLE_Y: "65"
TOUCH_TEST_INSET: "10" # the corner crosses from the edge TOUCH_TEST_INSET: "10" # the corner crosses from the edge
@@ -689,10 +689,10 @@ script:
std::string mode = lower(id(active_climate_mode)); std::string mode = lower(id(active_climate_mode));
bool off = mode == "off" || mode.empty() || mode == "unknown" || mode == "unavailable"; bool off = mode == "off" || mode.empty() || mode == "unknown" || mode == "unavailable";
// What the device does (Heating), else its mode (Heat); then the room. // 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); if (status.empty()) status = tile_controls::climate_mode_text(mode);
char part[32]; 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; } 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()); lv_label_set_text(id(climate_current_label), status.c_str());
+3 -3
View File
@@ -167,7 +167,7 @@ substitutions:
# The touch test page # The touch test page
TOUCH_TEST_PAGE_PAINT: "paint_page_soft" # the 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_FONT: "headline"
TOUCH_TEST_TITLE_Y: "110" TOUCH_TEST_TITLE_Y: "110"
TOUCH_TEST_INSET: "12" # the corner crosses from the edge TOUCH_TEST_INSET: "12" # the corner crosses from the edge
@@ -923,10 +923,10 @@ script:
std::string mode = lower(id(active_climate_mode)); std::string mode = lower(id(active_climate_mode));
bool off = mode == "off" || mode.empty() || mode == "unknown" || mode == "unavailable"; bool off = mode == "off" || mode.empty() || mode == "unknown" || mode == "unavailable";
// What the device does (Heating), else its mode (Heat); then the room. // 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); if (status.empty()) status = tile_controls::climate_mode_text(mode);
char part[32]; 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; } 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()); lv_label_set_text(id(climate_current_label), status.c_str());
+89 -51
View File
@@ -18,6 +18,9 @@ substitutions:
DASHBOARD_INBOX_INTERNAL: "false" DASHBOARD_INBOX_INTERNAL: "false"
CALIBRATION_ON_BOOT: "false" # a bench build: start on the touch test page CALIBRATION_ON_BOOT: "false" # a bench build: start on the touch test page
ROOM_NAME: "" # top left until ESP Screens sends the screen name 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 ## DIM settings
AUTO_DIM: "true" # true = enable auto dimming AUTO_DIM: "true" # true = enable auto dimming
@@ -213,7 +216,7 @@ esphome:
settings_screen::apply = []() { id(apply_screen_settings).execute(); }; 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::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::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::firmware_text = []() { return std::string("${SCREEN_FIRMWARE_VERSION}"); };
settings_screen::link_text = []() { return std::string(esphome::api_is_connected() ? "Connected" : "Not connected"); }; 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(); }; settings_screen::restart_device = []() { ESP_LOGI("settings", "restart from the screen"); App.safe_reboot(); };
@@ -663,21 +666,35 @@ font:
id: headline id: headline
size: ${FONT_HEADLINE_SIZE} size: ${FONT_HEADLINE_SIZE}
bpp: 4 bpp: 4
glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°', glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°','0','1','2','3','4','5',
'0','1','2','3','4','5','6','7','8','9', '6','7','8','9','A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T',
'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', 'U','V','W','X','Y','Z',' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q',
' ','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', 'r','s','t','u','v','w','x','y','z','/','·',
'ä','ö','ü','Ä','Ö','Ü','ß','/','·'] 'À','Á','Â','Ã','Ä','Å','Æ','Ç','È','É','Ê','Ë','Ì','Í','Î','Ï','Ð','Ñ','Ò','Ó','Ô','Õ','Ö','Ø',
'Ù','Ú','Û','Ü','Ý','Þ','ß','à','á','â','ã','ä','å','æ','ç','è','é','ê','ë','ì','í','î','ï','ð',
'ñ','ò','ó','ô','õ','ö','ø','ù','ú','û','ü','ý','þ','ÿ','Ą','ą','Ć','ć','Ę','ę','Ł','ł','Ń','ń',
'Ś','ś','Ź','ź','Ż','ż','Č','č','Ď','ď','Ě','ě','Ň','ň','Ř','ř','Š','š','Ť','ť','Ů','ů','Ž','ž',
'Ĺ','ĺ','Ľ','ľ','Ŕ','ŕ','Ő','ő','Ű','ű','Ă','ă','Ș','ș','Ț','ț','Đ','đ','Ā','ā','Ē','ē','Ģ','ģ',
'Ī','ī','Ķ','ķ','Ļ','ļ','Ņ','ņ','Ū','ū','Ė','ė','Į','į','Ų','ų','Ğ','ğ','İ','ı','Ş','ş','Ċ','ċ',
'Ġ','ġ','Ħ','ħ','Ŵ','ŵ','Ŷ','ŷ','Ŀ','ŀ','Œ','œ','Ÿ','„','“','”','‘','’','«','»','‹','›','¿','¡',
'€','£']
- file: "${FONT_DIR}/Roboto-500.ttf" - file: "${FONT_DIR}/Roboto-500.ttf"
id: watch_value id: watch_value
size: ${FONT_WATCH_VALUE_SIZE} size: ${FONT_WATCH_VALUE_SIZE}
bpp: 4 bpp: 4
glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°', glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°','0','1','2','3','4','5',
'0','1','2','3','4','5','6','7','8','9', '6','7','8','9','A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T',
'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', 'U','V','W','X','Y','Z',' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q',
' ','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', 'r','s','t','u','v','w','x','y','z','/','·',
'ä','ö','ü','Ä','Ö','Ü','ß','/','·'] 'À','Á','Â','Ã','Ä','Å','Æ','Ç','È','É','Ê','Ë','Ì','Í','Î','Ï','Ð','Ñ','Ò','Ó','Ô','Õ','Ö','Ø',
'Ù','Ú','Û','Ü','Ý','Þ','ß','à','á','â','ã','ä','å','æ','ç','è','é','ê','ë','ì','í','î','ï','ð',
'ñ','ò','ó','ô','õ','ö','ø','ù','ú','û','ü','ý','þ','ÿ','Ą','ą','Ć','ć','Ę','ę','Ł','ł','Ń','ń',
'Ś','ś','Ź','ź','Ż','ż','Č','č','Ď','ď','Ě','ě','Ň','ň','Ř','ř','Š','š','Ť','ť','Ů','ů','Ž','ž',
'Ĺ','ĺ','Ľ','ľ','Ŕ','ŕ','Ő','ő','Ű','ű','Ă','ă','Ș','ș','Ț','ț','Đ','đ','Ā','ā','Ē','ē','Ģ','ģ',
'Ī','ī','Ķ','ķ','Ļ','ļ','Ņ','ņ','Ū','ū','Ė','ė','Į','į','Ų','ų','Ğ','ğ','İ','ı','Ş','ş','Ċ','ċ',
'Ġ','ġ','Ħ','ħ','Ŵ','ŵ','Ŷ','ŷ','Ŀ','ŀ','Œ','œ','Ÿ','„','“','”','‘','’','«','»','‹','›','¿','¡',
'€','£']
# Clock card digits only; the date line reuses the tile title font. # Clock card digits only; the date line reuses the tile title font.
- file: "${FONT_DIR}/Roboto-500.ttf" - file: "${FONT_DIR}/Roboto-500.ttf"
@@ -697,31 +714,52 @@ font:
id: label id: label
size: ${FONT_LABEL_SIZE} size: ${FONT_LABEL_SIZE}
bpp: 4 bpp: 4
glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°', glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°','0','1','2','3','4','5',
'0','1','2','3','4','5','6','7','8','9', '6','7','8','9','A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T',
'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', 'U','V','W','X','Y','Z',' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q',
' ','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', 'r','s','t','u','v','w','x','y','z','/','·',
'ä','ö','ü','Ä','Ö','Ü','ß','/','·'] 'À','Á','Â','Ã','Ä','Å','Æ','Ç','È','É','Ê','Ë','Ì','Í','Î','Ï','Ð','Ñ','Ò','Ó','Ô','Õ','Ö','Ø',
'Ù','Ú','Û','Ü','Ý','Þ','ß','à','á','â','ã','ä','å','æ','ç','è','é','ê','ë','ì','í','î','ï','ð',
'ñ','ò','ó','ô','õ','ö','ø','ù','ú','û','ü','ý','þ','ÿ','Ą','ą','Ć','ć','Ę','ę','Ł','ł','Ń','ń',
'Ś','ś','Ź','ź','Ż','ż','Č','č','Ď','ď','Ě','ě','Ň','ň','Ř','ř','Š','š','Ť','ť','Ů','ů','Ž','ž',
'Ĺ','ĺ','Ľ','ľ','Ŕ','ŕ','Ő','ő','Ű','ű','Ă','ă','Ș','ș','Ț','ț','Đ','đ','Ā','ā','Ē','ē','Ģ','ģ',
'Ī','ī','Ķ','ķ','Ļ','ļ','Ņ','ņ','Ū','ū','Ė','ė','Į','į','Ų','ų','Ğ','ğ','İ','ı','Ş','ş','Ċ','ċ',
'Ġ','ġ','Ħ','ħ','Ŵ','ŵ','Ŷ','ŷ','Ŀ','ŀ','Œ','œ','Ÿ','„','“','”','‘','’','«','»','‹','›','¿','¡',
'€','£']
- file: "${FONT_DIR}/Roboto-400.ttf" - file: "${FONT_DIR}/Roboto-400.ttf"
id: sublabel id: sublabel
size: ${FONT_SUBLABEL_SIZE} size: ${FONT_SUBLABEL_SIZE}
bpp: 4 bpp: 4
glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°', glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°','0','1','2','3','4','5',
'0','1','2','3','4','5','6','7','8','9', '6','7','8','9','A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T',
'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', 'U','V','W','X','Y','Z',' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q',
' ','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', 'r','s','t','u','v','w','x','y','z','/','…','·',
'ä','ö','ü','Ä','Ö','Ü','ß','/','…','·'] 'À','Á','Â','Ã','Ä','Å','Æ','Ç','È','É','Ê','Ë','Ì','Í','Î','Ï','Ð','Ñ','Ò','Ó','Ô','Õ','Ö','Ø',
'Ù','Ú','Û','Ü','Ý','Þ','ß','à','á','â','ã','ä','å','æ','ç','è','é','ê','ë','ì','í','î','ï','ð',
'ñ','ò','ó','ô','õ','ö','ø','ù','ú','û','ü','ý','þ','ÿ','Ą','ą','Ć','ć','Ę','ę','Ł','ł','Ń','ń',
'Ś','ś','Ź','ź','Ż','ż','Č','č','Ď','ď','Ě','ě','Ň','ň','Ř','ř','Š','š','Ť','ť','Ů','ů','Ž','ž',
'Ĺ','ĺ','Ľ','ľ','Ŕ','ŕ','Ő','ő','Ű','ű','Ă','ă','Ș','ș','Ț','ț','Đ','đ','Ā','ā','Ē','ē','Ģ','ģ',
'Ī','ī','Ķ','ķ','Ļ','ļ','Ņ','ņ','Ū','ū','Ė','ė','Į','į','Ų','ų','Ğ','ğ','İ','ı','Ş','ş','Ċ','ċ',
'Ġ','ġ','Ħ','ħ','Ŵ','ŵ','Ŷ','ŷ','Ŀ','ŀ','Œ','œ','Ÿ','„','“','”','‘','’','«','»','‹','›','¿','¡',
'€','£']
- file: "${FONT_DIR}/Roboto-400.ttf" - file: "${FONT_DIR}/Roboto-400.ttf"
id: sublabel_big id: sublabel_big
size: ${FONT_SUBLABEL_BIG_SIZE} size: ${FONT_SUBLABEL_BIG_SIZE}
bpp: 4 bpp: 4
glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°', glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°','0','1','2','3','4','5',
'0','1','2','3','4','5','6','7','8','9', '6','7','8','9','A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T',
'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', 'U','V','W','X','Y','Z',' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q',
' ','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', 'r','s','t','u','v','w','x','y','z','/','…','·',"'",'#','*','=',';','²','³','µ','–',
'ä','ö','ü','Ä','Ö','Ü','ß','/','…','·',"'",'#','*','=',';','²','³','µ','é','è','ë','ê','ï','î','ó','ô','à','á','â','ç','ñ','ú','û','–'] 'À','Á','Â','Ã','Ä','Å','Æ','Ç','È','É','Ê','Ë','Ì','Í','Î','Ï','Ð','Ñ','Ò','Ó','Ô','Õ','Ö','Ø',
'Ù','Ú','Û','Ü','Ý','Þ','ß','à','á','â','ã','ä','å','æ','ç','è','é','ê','ë','ì','í','î','ï','ð',
'ñ','ò','ó','ô','õ','ö','ø','ù','ú','û','ü','ý','þ','ÿ','Ą','ą','Ć','ć','Ę','ę','Ł','ł','Ń','ń',
'Ś','ś','Ź','ź','Ż','ż','Č','č','Ď','ď','Ě','ě','Ň','ň','Ř','ř','Š','š','Ť','ť','Ů','ů','Ž','ž',
'Ĺ','ĺ','Ľ','ľ','Ŕ','ŕ','Ő','ő','Ű','ű','Ă','ă','Ș','ș','Ț','ț','Đ','đ','Ā','ā','Ē','ē','Ģ','ģ',
'Ī','ī','Ķ','ķ','Ļ','ļ','Ņ','ņ','Ū','ū','Ė','ė','Į','į','Ų','ų','Ğ','ğ','İ','ı','Ş','ş','Ċ','ċ',
'Ġ','ġ','Ħ','ħ','Ŵ','ŵ','Ŷ','ŷ','Ŀ','ŀ','Œ','œ','Ÿ','„','“','”','‘','’','«','»','‹','›','¿','¡',
'€','£']
# Material Design Icons (MDI) — included at fonts/materialdesignicons-webfont.ttf (Apache 2.0) # Material Design Icons (MDI) — included at fonts/materialdesignicons-webfont.ttf (Apache 2.0)
# Source: https://github.com/Templarian/MaterialDesign-Webfont # Source: https://github.com/Templarian/MaterialDesign-Webfont
@@ -1157,6 +1195,12 @@ text_sensor:
entity_category: diagnostic entity_category: diagnostic
lambda: 'return {"${SCREEN_FIRMWARE_VERSION}"};' lambda: 'return {"${SCREEN_FIRMWARE_VERSION}"};'
update_interval: 60s 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. # ESP Screen Manager matches the screen to its YAML profile and OTA address.
- platform: template - platform: template
name: "Device name" name: "Device name"
@@ -1216,17 +1260,6 @@ switch:
- lambda: 'settings_screen::set("dark_mode", 1);' - lambda: 'settings_screen::set("dark_mode", 1);'
turn_off_action: turn_off_action:
- lambda: 'settings_screen::set("dark_mode", 0);' - 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 - platform: template
id: setting_auto_home id: setting_auto_home
name: "Back to page 1" name: "Back to page 1"
@@ -1461,7 +1494,7 @@ lvgl:
width: ${ALERT_TEXT_W} width: ${ALERT_TEXT_W}
height: ${ALERT_TITLE_H} height: ${ALERT_TITLE_H}
long_mode: DOT long_mode: DOT
text: "Notification" text: !lambda 'return std::string(screen_text::tr(screen_text::txt::alert_notification));'
text_font: headline text_font: headline
clickable: false clickable: false
- label: - label:
@@ -1505,7 +1538,7 @@ lvgl:
- label: - label:
id: alert_ok_label id: alert_ok_label
align: CENTER align: CENTER
text: "OK" text: !lambda 'return std::string(screen_text::tr(screen_text::txt::alert_ok));'
text_font: headline text_font: headline
clickable: false clickable: false
@@ -2717,7 +2750,7 @@ lvgl:
id: climate_set_label id: climate_set_label
align: CENTER align: CENTER
y: ${CLIMATE_CAPTION_Y} y: ${CLIMATE_CAPTION_Y}
text: "Target" text: !lambda 'return std::string(screen_text::tr(screen_text::txt::climate_target));'
text_font: sublabel text_font: sublabel
styles: paint_subtle styles: paint_subtle
clickable: false clickable: false
@@ -2912,7 +2945,7 @@ lvgl:
- label: - label:
align: TOP_MID align: TOP_MID
y: ${PICKER_TITLE_Y} y: ${PICKER_TITLE_Y}
text: "Mode" text: !lambda 'return std::string(screen_text::tr(screen_text::txt::climate_mode));'
text_font: sublabel_big text_font: sublabel_big
clickable: false clickable: false
@@ -2954,7 +2987,7 @@ lvgl:
widgets: widgets:
- label: - label:
align: CENTER align: CENTER
text: "Off" text: !lambda 'return std::string(screen_text::tr(screen_text::txt::ha_climate_off));'
text_font: sublabel_big text_font: sublabel_big
clickable: false clickable: false
- obj: - obj:
@@ -2973,7 +3006,7 @@ lvgl:
widgets: widgets:
- label: - label:
align: CENTER align: CENTER
text: "Heat" text: !lambda 'return std::string(screen_text::tr(screen_text::txt::ha_climate_heat));'
text_font: sublabel_big text_font: sublabel_big
clickable: false clickable: false
- obj: - obj:
@@ -2992,7 +3025,7 @@ lvgl:
widgets: widgets:
- label: - label:
align: CENTER align: CENTER
text: "Cool" text: !lambda 'return std::string(screen_text::tr(screen_text::txt::ha_climate_cool));'
text_font: sublabel_big text_font: sublabel_big
clickable: false clickable: false
- obj: - obj:
@@ -3011,7 +3044,7 @@ lvgl:
widgets: widgets:
- label: - label:
align: CENTER align: CENTER
text: "Heat/Cool" text: !lambda 'return std::string(screen_text::tr(screen_text::txt::ha_climate_heat_cool));'
text_font: sublabel_big text_font: sublabel_big
clickable: false clickable: false
- obj: - obj:
@@ -3030,7 +3063,7 @@ lvgl:
widgets: widgets:
- label: - label:
align: CENTER align: CENTER
text: "Auto" text: !lambda 'return std::string(screen_text::tr(screen_text::txt::ha_climate_auto));'
text_font: sublabel_big text_font: sublabel_big
clickable: false clickable: false
- obj: - obj:
@@ -3049,7 +3082,7 @@ lvgl:
widgets: widgets:
- label: - label:
align: CENTER align: CENTER
text: "Dry" text: !lambda 'return std::string(screen_text::tr(screen_text::txt::ha_climate_dry));'
text_font: sublabel_big text_font: sublabel_big
clickable: false clickable: false
- obj: - obj:
@@ -3068,7 +3101,7 @@ lvgl:
widgets: widgets:
- label: - label:
align: CENTER align: CENTER
text: "Fan only" text: !lambda 'return std::string(screen_text::tr(screen_text::txt::ha_climate_fan_only));'
text_font: sublabel_big text_font: sublabel_big
clickable: false clickable: false
@@ -3076,7 +3109,7 @@ lvgl:
id: cfan_title id: cfan_title
width: 100% width: 100%
height: ${PICKER_TITLE_H} height: ${PICKER_TITLE_H}
text: "Fan" text: !lambda 'return std::string(screen_text::tr(screen_text::txt::climate_fan));'
text_font: sublabel text_font: sublabel
styles: paint_subtle styles: paint_subtle
text_align: CENTER text_align: CENTER
@@ -3165,7 +3198,7 @@ lvgl:
id: cswing_title id: cswing_title
width: 100% width: 100%
height: ${PICKER_TITLE_H} height: ${PICKER_TITLE_H}
text: "Swing" text: !lambda 'return std::string(screen_text::tr(screen_text::txt::climate_swing));'
text_font: sublabel text_font: sublabel
styles: paint_subtle styles: paint_subtle
text_align: CENTER text_align: CENTER
@@ -3310,7 +3343,7 @@ lvgl:
- label: - label:
align: TOP_MID align: TOP_MID
y: ${TOUCH_TEST_TITLE_Y} 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} text_font: ${TOUCH_TEST_TITLE_FONT}
- obj: - obj:
x: ${TOUCH_TEST_INSET} 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. # HA blueprint transport; never publishes the JSON command as an HA state.
text: text:
- platform: smart_display - platform: smart_display
+2
View File
@@ -5,6 +5,8 @@ ARG BUILD_ARCH
LABEL io.hass.version="${BUILD_VERSION}" io.hass.type="app" io.hass.arch="${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 RUN pip install --no-cache-dir 'aiohttp>=3.12,<4' PyYAML
COPY app /app 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). # The Update badge shows what's new from the changelog (app 0.2.73).
COPY CHANGELOG.md /app/CHANGELOG.md COPY CHANGELOG.md /app/CHANGELOG.md
WORKDIR /app WORKDIR /app
+9 -1
View File
@@ -182,6 +182,8 @@ NAME_SCREEN_FIRMWARE = ('Screen firmware', 'Schermfirmware')
NAME_GUITION_TYPE = ('Guition screen type', 'Guition schermtype') NAME_GUITION_TYPE = ('Guition screen type', 'Guition schermtype')
NAME_DEVICE_NAME = ('Device name', 'Apparaatnaam') NAME_DEVICE_NAME = ('Device name', 'Apparaatnaam')
NAME_IP_ADDRESS = ('IP address', 'IP-adres') 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) SCREEN_ENTITY_NAMES = frozenset(NAME_TILE_SETTINGS + NAME_SCREEN_FIRMWARE + NAME_GUITION_TYPE + NAME_DEVICE_NAME + NAME_IP_ADDRESS)
def parse_firmware(text): def parse_firmware(text):
@@ -304,7 +306,6 @@ SETTING_ENTITIES = {
'night_start': ('time', 'Night starts'), 'night_start': ('time', 'Night starts'),
'night_end': ('time', 'Night ends'), 'night_end': ('time', 'Night ends'),
'night_brightness': ('number', 'Night brightness'), 'night_brightness': ('number', 'Night brightness'),
'clock_24h': ('switch', '24-hour clock'),
'auto_home': ('switch', 'Back to page 1'), 'auto_home': ('switch', 'Back to page 1'),
'auto_home_seconds': ('number', 'Back to page 1 after'), 'auto_home_seconds': ('number', 'Back to page 1 after'),
'home_on_standby': ('switch', 'Back to page 1 on standby'), '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 # 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. # 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', 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')) 'Back to page 1 after', 'Back to page 1 on standby', 'Swipe between pages'))
ROTATION_OPTIONS = ('0°', '90°', '180°', '270°') ROTATION_OPTIONS = ('0°', '90°', '180°', '270°')
@@ -1485,6 +1488,7 @@ def discover_screens(registry, states, devices, areas):
return found return found
nodes = diagnostic(NAME_DEVICE_NAME, r'[a-z0-9][a-z0-9-]{0,30}') 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}') 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 = [] screens = []
for item in registry: for item in registry:
eid = item['entity_id'] eid = item['entity_id']
@@ -1502,6 +1506,7 @@ def discover_screens(registry, states, devices, areas):
'firmware_known': known_firmware(firmware, device.get('sw_version')), 'firmware_known': known_firmware(firmware, device.get('sw_version')),
'board': boards.get(item.get('device_id'), 'unknown'), 'board': boards.get(item.get('device_id'), 'unknown'),
'node': nodes.get(item.get('device_id')), 'ip': addresses.get(item.get('device_id')), '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 '', 'device': device.get('name') or '',
'area': area, 'online': state.get('state') not in (None, 'unknown', 'unavailable'), 'area': area, 'online': state.get('state') not in (None, 'unknown', 'unavailable'),
'status': state.get('state', 'Not connected')}) '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: if not isinstance(friendly, str) or not friendly.strip() or len(friendly) > 60:
raise ValueError('Give the screen a recognizable name (60 characters max).') raise ValueError('Give the screen a recognizable name (60 characters max).')
quote = lambda s: json.dumps(s, ensure_ascii=False) 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 # 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). # 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) 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: substitutions:
DEVICE_NAME: {quote(name)} DEVICE_NAME: {quote(name)}
DEVICE_FRIENDLY_NAME: {quote(friendly.strip())} DEVICE_FRIENDLY_NAME: {quote(friendly.strip())}
LANGUAGE: {quote(language)}
esphome: esphome:
name: {quote(name)} name: {quote(name)}
+28 -1
View File
@@ -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) 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', PROTECTED_OVERRIDE_KEYS = frozenset({'esphome', 'api', 'ota', 'wifi', 'packages',
'external_components', 'captive_portal'}) '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 # 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/<node>/ with PlatformIO, # flashes on a board. A build writes it next to firmware.bin: under .pioenvs/<node>/ with PlatformIO,
# under build/ with ESPHome's native ESP-IDF toolchain. # 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. # 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} 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): def _override_path(self, name):
profile = self.profile(name) profile = self.profile(name)
path = self.root / (profile.stem + self.OVERRIDE_SUFFIX) path = self.root / (profile.stem + self.OVERRIDE_SUFFIX)
+3 -1
View File
@@ -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; # 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. # 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 TEXT_BYTES = 40
MONTHS = ('Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec') MONTHS = ('Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec')
UNAVAILABLE = '—' UNAVAILABLE = '—'
+213
View File
@@ -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 '')
+126 -17
View File
@@ -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 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 header_bar
import history_card import history_card
import i18n
from i18n import REQUEST_LANGUAGE, TRANSLATIONS, Region, t
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
@@ -106,6 +108,12 @@ class HomeAssistant:
self.time_zone = None self.time_zone = None
# Home Assistant's unit system; a climate entity's temperature carries no unit of its own. # Home Assistant's unit system; a climate entity's temperature carries no unit of its own.
self.units = {} 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. # (event type, data) of alert events for every screen, for Manager.alert_loop.
self.broadcasts = asyncio.Queue() self.broadcasts = asyncio.Queue()
# (event type, data) of tile events (app 0.2.51+), for Manager.tile_loop. # (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 # 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+). # `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 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 # Home Assistant's own names and descriptions of actions and their fields (frontend/get_translations), for Perform
# like the editor), for Perform action (app 0.2.67). `get_services` has carried no translated names since 2025.10. # action (app 0.2.67); `get_services` has carried no translated names since 2025.10. Per language (app 0.2.90): the
self.service_names = {} # screens' one first, an editor in another language asks for its own (service_names_in).
# Home Assistant's words for states (frontend/get_translations `entity_component` and `entity`, English), which a self.service_names, self.service_names_by_language = {}, {}
# tile shows where it would show the raw state (app 0.2.67). # 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.state_words = {}
self.esphome_services = False self.esphome_services = False
self._platforms_source, self._platforms = None, {} self._platforms_source, self._platforms = None, {}
@@ -182,6 +191,10 @@ class HomeAssistant:
self.changed.set() self.changed.set()
elif event.get('event_type') in REGISTRY_EVENTS: elif event.get('event_type') in REGISTRY_EVENTS:
self.registry_changed.set() 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: 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 # 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. # how the app talks to it.
@@ -216,12 +229,14 @@ class HomeAssistant:
if isinstance(services, dict): if isinstance(services, dict):
self.services, self.services_rev, self.targets = services, self.services_rev + 1, {} self.services, self.services_rev, self.targets = services, self.services_rev + 1, {}
try: 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): if isinstance((names or {}).get('resources'), dict):
self.service_names = names['resources'] self.service_names = names['resources']
self.service_names_by_language = {language: names['resources']}
words = {} words = {}
for category in ('entity_component', 'entity'): 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): if isinstance((found or {}).get('resources'), dict):
words.update(found['resources']) words.update(found['resources'])
if words: if words:
@@ -237,6 +252,34 @@ class HomeAssistant:
# The editor then shows the names services.yaml still carries, and tiles keep their raw states. # 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__) 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): async def refresh_services(self):
"""fetch_services for the connection loop: a list that cannot be read only means no answers are asked for.""" """fetch_services for the connection loop: a list that cannot be read only means no answers are asked for."""
try: try:
@@ -310,13 +353,10 @@ class HomeAssistant:
await self.request('subscribe_events', event_type=event_type) await self.request('subscribe_events', event_type=event_type)
self.states = {s['entity_id']: s for s in await self.request('get_states')} self.states = {s['entity_id']: s for s in await self.request('get_states')}
await self.registries() 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() 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.online = True
self.changed.set() self.changed.set()
connected = time.monotonic() connected = time.monotonic()
@@ -337,6 +377,8 @@ class HomeAssistant:
# A screen that reconnects registers its actions one after the other. # A screen that reconnects registers its actions one after the other.
await asyncio.sleep(1) await asyncio.sleep(1)
self.services_changed.clear() self.services_changed.clear()
if self.config_stale:
await self.read_config()
await self.refresh_services() await self.refresh_services()
if self.esphome_services: if self.esphome_services:
self.esphome_services = False self.esphome_services = False
@@ -524,6 +566,11 @@ class Manager:
self.listeners = set() # asyncio.Event per open /api/events stream self.listeners = set() # asyncio.Event per open /api/events stream
self.firmware = Firmware(os.environ.get("ESPHOME_CONFIG", "/homeassistant/esphome"), self.path.parent) self.firmware = Firmware(os.environ.get("ESPHOME_CONFIG", "/homeassistant/esphome"), self.path.parent)
self.updates = Updater(self, self.path.parent / 'updates.json') 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.skill_dir = claude_skill.skill_dir()
self._registry_source, self._registry_index = None, {} self._registry_source, self._registry_index = None, {}
self._items_source, self._items = None, [] self._items_source, self._items = None, []
@@ -556,6 +603,12 @@ class Manager:
raise ValueError('Unknown storage version; data stays unchanged.') 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. # 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()} 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): def inventory(self):
"""(screens, every tile/top-bar entity): the full walk over the registry, for the editor and saving.""" """(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: for listener in self.listeners:
listener.set() 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): 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. """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) message['auto_home_seconds'] = layout['settings'].get('auto_home_seconds',120)
if screen.get('board')=='guition': if screen.get('board')=='guition':
message['rotation'] = layout['settings'].get('rotation',0) 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). # The revision the screen echoes on every ping (firmware 0.2.33+; older firmware ignores it).
message['rev'] = revision(message) message['rev'] = revision(message)
return message return message
@@ -1600,6 +1691,8 @@ def create_app(manager, development=False):
csrf = secrets.token_urlsafe(32) csrf = secrets.token_urlsafe(32)
@web.middleware @web.middleware
async def guard(request, handler): 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'} allowed = {'127.0.0.1', '::1'} if development else {'172.30.32.2'}
if request.remote not in allowed: if request.remote not in allowed:
raise web.HTTPForbidden(text='Open this page through Home Assistant.') 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)) screen.update(firmware_features(version))
return {'csrf': csrf, 'connected': manager.ha.online, 'screens': screens, return {'csrf': csrf, 'connected': manager.ha.online, 'screens': screens,
'pending': manager.pending_profiles(screens, profiles), '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): async def inventory(request):
if request.query.get('light') == '1': if request.query.get('light') == '1':
# The page polls the light form; entities, backgrounds and icons (~100 KB) only on demand. # 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 actions = await ha.entity_actions(entity) if entity_id(entity) and hasattr(ha, 'entity_actions') else None
if actions is None: if actions is None:
return web.json_response({'actions': 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, 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): async def change_settings(request):
"""Screen settings apply one change at a time, like the settings page on the screen (app 0.2.57).""" """Screen settings apply one change at a time, like the settings page on the screen (app 0.2.57)."""
data = await request.json() data = await request.json()
@@ -1834,8 +1938,12 @@ def create_app(manager, development=False):
raise ValueError('Invalid override data.') raise ValueError('Invalid override data.')
return web.json_response(manager.firmware.save_override(request.match_info['file'], data.get('content'))) return web.json_response(manager.firmware.save_override(request.match_info['file'], data.get('content')))
async def firmware_create(request): async def firmware_create(request):
# Profile, missing wifi secrets and (with a USB port or the download) the build and flash in one request. # Profile, missing wifi secrets and (with a USB port or the download) the build and flash in one request; the
return web.json_response(manager.firmware.install(await request.json())) # 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): async def firmware_download(request):
"""New screen and Firmware & USB → Download: the factory image this app just built, for ESPHome Web on """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.""" 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/screens/{inbox}/update', update_screen)
app.router.add_post('/api/updates/run', update_all) app.router.add_post('/api/updates/run', update_all)
app.router.add_put('/api/updates', update_settings) 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_get('/api/firmware', firmware_status)
app.router.add_post('/api/firmware/jobs', firmware_start) app.router.add_post('/api/firmware/jobs', firmware_start)
app.router.add_get('/api/firmware/profiles/{file}/override', firmware_override) app.router.add_get('/api/firmware/profiles/{file}/override', firmware_override)
+10 -2
View File
@@ -87,8 +87,15 @@ class Updater:
profile = matches[0] if len(matches) == 1 else None profile = matches[0] if len(matches) == 1 else None
return profile, screen.get('ip') or self.hosts.get(screen['id']) 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): def state_for(self, screen, profiles=None):
version = parse_version(screen.get('firmware')) version = parse_version(screen.get('firmware'))
language = self.language_due(screen)
profile, host = self.resolve(screen, profiles) profile, host = self.resolve(screen, profiles)
if screen['id'] == self.current: if screen['id'] == self.current:
state = 'running' state = 'running'
@@ -96,7 +103,8 @@ class Updater:
state = 'queued' state = 'queued'
else: else:
state = 'idle' 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, 'profile': profile, 'host': host, 'state': state, 'phase': self.phase if state == 'running' else None,
'result': self.results.get(screen['id'])} 'result': self.results.get(screen['id'])}
@@ -221,7 +229,7 @@ class Updater:
while time.monotonic() < deadline: while time.monotonic() < deadline:
screen = self.screen(inbox) screen = self.screen(inbox)
version = parse_version(screen.get('firmware')) if screen else None 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 return True
await asyncio.sleep(self.poll_seconds) await asyncio.sleep(self.poll_seconds)
return False return False
+16
View File
@@ -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"
}
}
}
+387 -4
View File
@@ -1,12 +1,395 @@
{ {
"_meta": { "_meta": {
"name": "English", "name": "English (US)",
"english": "English", "english": "English (US)",
"script": "latin", "script": "latin",
"plural": "one_other", "plural": "one_other",
"clock": "12",
"checked": true "checked": true
}, },
"screen": {}, "screen": {
"addon": {}, "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": {} "editor": {}
} }
+313
View File
@@ -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; }
+1
View File
@@ -1,3 +1,4 @@
#include "screen_text_en.h"
#include "components/smart_display/runtime_model.h" #include "components/smart_display/runtime_model.h"
#include "components/smart_display/cyd_ui.h" #include "components/smart_display/cyd_ui.h"
#include <cassert> #include <cassert>
+1
View File
@@ -1,3 +1,4 @@
#include "screen_text_en.h"
#define THEME_TEST #define THEME_TEST
#include "components/smart_display/alert_overlay.h" #include "components/smart_display/alert_overlay.h"
#include <cassert> #include <cassert>
+12 -2
View File
@@ -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 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. 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 re
import sys import sys
import unittest 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') 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(): 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) 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): class BinaryWords(unittest.TestCase):
@@ -41,7 +50,8 @@ class BinaryWords(unittest.TestCase):
for attrs in ({}, {'device_class': 'future_class'}): for attrs in ({}, {'device_class': 'future_class'}):
self.assertEqual(header_bar.value('binary_sensor.x', {'state': 'on', 'attributes': attrs}), ('On', None)) 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.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): def test_the_state_message_already_carries_the_class(self):
tile = {'entity': 'binary_sensor.front_door', 'name': ''} tile = {'entity': 'binary_sensor.front_door', 'name': ''}
+1
View File
@@ -1,3 +1,4 @@
#include "screen_text_en.h"
#include "../components/smart_display/calibration_math.h" #include "../components/smart_display/calibration_math.h"
#include <cassert> #include <cassert>
int main() { int main() {
+1
View File
@@ -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 // 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 "../components/smart_display/camera_view.h"
#include <cassert> #include <cassert>
+1
View File
@@ -1,3 +1,4 @@
#include "screen_text_en.h"
#include "../components/smart_display/chunk_transport.h" #include "../components/smart_display/chunk_transport.h"
#include <cassert> #include <cassert>
int main() { int main() {
+1
View File
@@ -1,3 +1,4 @@
#include "screen_text_en.h"
#include "../components/smart_display/cyd_ui.h" #include "../components/smart_display/cyd_ui.h"
#include <cassert> #include <cassert>
#include <limits> #include <limits>
+1
View File
@@ -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 // 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. // 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 #define EFFECTS_PAGE_TEST
+1
View File
@@ -1,3 +1,4 @@
#include "screen_text_en.h"
#include "components/smart_display/header_bar.h" #include "components/smart_display/header_bar.h"
#include <cassert> #include <cassert>
#include <cstdio> #include <cstdio>
+9 -5
View File
@@ -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 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() 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() TILES = (ROOT / 'components/smart_display/runtime_tiles.h').read_text()
EDITOR = (ROOT / 'web/src/model/topbar.ts').read_text() EDITOR = (ROOT / 'web/src/model/topbar.ts').read_text()
PROFILES = ('guition-4848s040.yaml', 'home-like-2432s028.yaml') 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) 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): 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³') # 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('Ångström ✓'), 'Angström') 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) self.assertLessEqual(len(header_bar.clean_text('é' * 60).encode()), header_bar.TEXT_BYTES)
def test_active_colour_and_icon(self): 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; # 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. # 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] 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() ? ' self.assertIn('if (!model.configured) boot_status(lv_obj_get_parent(room), tr(!ha_connected() ? '
'"Connecting to Home Assistant" : "Waiting for ESP Screens");', render) 'txt::status_connecting : txt::status_waiting));', render)
self.assertIn('else if (boot_panel) { lv_obj_delete(boot_panel);', 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] boot = TILES.split('inline void boot_status(', 1)[1].split('\n}', 1)[0]
self.assertIn('boot_spinner = spinner_create(boot_panel,', boot) self.assertIn('boot_spinner = spinner_create(boot_panel,', boot)
# Under the tiles, the cards and an alert, in the name's place. # Under the tiles, the cards and an alert, in the name's place.
+1
View File
@@ -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 // 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 "../components/smart_display/history_view.h"
#include <cassert> #include <cassert>
+1
View File
@@ -1,3 +1,4 @@
#include "screen_text_en.h"
#define LIGHT_CONTROLS_TEST #define LIGHT_CONTROLS_TEST
#include "../components/smart_display/light_controls.h" #include "../components/smart_display/light_controls.h"
#include <cassert> #include <cassert>
+1
View File
@@ -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 // 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 "../components/smart_display/media_card.h"
#include <cassert> #include <cassert>
+1
View File
@@ -1,3 +1,4 @@
#include "screen_text_en.h"
#include "../components/smart_display/runtime_model.h" #include "../components/smart_display/runtime_model.h"
#include <cassert> #include <cassert>
int main() { int main() {
+12 -9
View File
@@ -47,14 +47,14 @@ ENTITY_IDS = {
'standby_seconds': 'number.office_1_standby_after', 'standby_brightness': 'number.office_1_standby_brightness', '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_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', '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', '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', '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', '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', 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', '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'} '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): class CoreSettings(unittest.TestCase):
def test_every_setting_the_editor_offers_has_an_entity(self): 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.assertEqual(SETTING_ENTITIES_MIN_FIRMWARE, '0.2.49')
self.assertLessEqual(tuple(map(int, SETTING_ENTITIES_MIN_FIRMWARE.split('.'))), tuple(map(int, FIRMWARE_VERSION.split('.')))) 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') self.assertNotIn('settings', m.layouts['text.screen'], 'a screen that owns its settings has none stored')
view = m.settings_view(m.screen('text.screen')) view = m.settings_view(m.screen('text.screen'))
self.assertEqual((view['owner'], view['unavailable']), ('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')}, 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}) {'brightness': 80, 'night_start': 1350, 'rotation': 90, 'home_on_standby': False})
m.ha.states[ENTITY_IDS['night_end']] = {'state': 'unavailable'} 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}) await m.change_settings('text.screen', {'brightness': 10})
self.assertEqual(m.ha.calls, [('number.set_value', {'entity_id': ENTITY_IDS['brightness'], 'value': 10})]) self.assertEqual(m.ha.calls, [('number.set_value', {'entity_id': ENTITY_IDS['brightness'], 'value': 10})])
m.ha.calls.clear() 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'}), 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°'})]) ('select.select_option', {'entity_id': ENTITY_IDS['rotation'], 'option': '270°'})])
m.ha.calls.clear() 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): with self.assertRaises(ValueError):
await m.change_settings('text.screen', bad) await m.change_settings('text.screen', bad)
m.ha.states[ENTITY_IDS['night_end']] = {'state': 'unavailable'} 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): def test_the_rows_are_the_settings_of_the_screen_page(self):
keys = re.findall(r'\{ key: "(\w+)", label: "[^"]+", kind: "(\w+)"', self.script) 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], [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. # 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')] groups = self.script[self.script.index('const SETTING_GROUPS = ['):self.script.index('export type SettingRow')]
script_labels = set(re.findall(r'label: "([^"]+)"', groups)) script_labels = set(re.findall(r'label: "([^"]+)"', groups))
self.assertEqual(page_labels, script_labels) self.assertEqual(page_labels, script_labels)
+1
View File
@@ -1,3 +1,4 @@
#include "screen_text_en.h"
#include "components/smart_display/screen_settings.h" #include "components/smart_display/screen_settings.h"
#include <cassert> #include <cassert>
int main() { int main() {
+14 -12
View File
@@ -1,4 +1,5 @@
#define SETTINGS_SCREEN_TEST #define SETTINGS_SCREEN_TEST
#include "screen_text_en.h"
#include "../components/smart_display/settings_screen.h" #include "../components/smart_display/settings_screen.h"
#include <cassert> #include <cassert>
#include <string> #include <string>
@@ -8,7 +9,7 @@ using namespace settings_screen;
// The row of a page by its label, so a test says what it means. // 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) { static const Row &row_named(const Page &page, const std::string &label) {
for (uint8_t i = 0; i < page.count; ++i) 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); assert(false);
return page.rows[0]; return page.rows[0];
} }
@@ -17,7 +18,7 @@ int main() {
// ---- the duration ladder: seconds low down, quarters of an hour up top, and symmetric ---- // ---- 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(60) == 30 && ladder_step(600) == 60 && ladder_step(1800) == 300);
assert(ladder_step(3600) == 900 && ladder_step(36000) == 1800); 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, 120, 1) == 150 && stepped(timeout, 120, -1) == 90);
assert(stepped(timeout, 300, 1) == 360 && stepped(timeout, 360, -1) == 300); assert(stepped(timeout, 300, 1) == 360 && stepped(timeout, 360, -1) == 300);
assert(stepped(timeout, 300, -1) == 270 && stepped(timeout, 270, 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)); 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 ---- // ---- 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); 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(stepped(start, 1320, 1) == 1335 && stepped(start, 1425, 1) == 0 && stepped(start, 0, -1) == 1425);
assert(!at_end(start, 0, -1)); assert(!at_end(start, 0, -1));
// Holding the key walks whole hours, and first lands on the hour it is in. // 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.standby_brightness == 30); // never above the normal brightness
assert(screen_settings::current.valid()); assert(screen_settings::current.valid());
// Dark mode (firmware 0.2.54+) sits right under Brightness, a switch of its own outside the frozen block. // 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"); const Row &dark = row_named(light, "Dark mode");
assert(value_text(dark) == "Off"); assert(value_text(dark) == "Off");
dark.write(1); dark.write(1);
@@ -84,10 +85,11 @@ int main() {
buttons.write(1); buttons.write(1);
assert(page_buttons == 1); assert(page_buttons == 1);
} }
const Row &clock = row_named(screen, "Clock"); // 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
assert(clock.option_count == 2 && value_text(clock) == "24 hour"); // the value still arrives through set() and the layout message.
clock.write(0); for (uint8_t i = 0; i < screen.count; ++i) assert(std::string(label_text(screen.rows[i])) != "Clock");
assert(screen_settings::current.clock_24h == 0 && value_text(clock) == "12 hour"); set("clock_24h", 0);
assert(screen_settings::current.clock_24h == 0);
const Row &home = row_named(screen, "Back to page 1"); const Row &home = row_named(screen, "Back to page 1");
home.write(0); home.write(0);
assert(auto_home == 0 && value_text(home) == "Off"); assert(auto_home == 0 && value_text(home) == "Off");
@@ -107,17 +109,17 @@ int main() {
assert(PAGE_COUNT == 5); assert(PAGE_COUNT == 5);
for (uint8_t p = 0; p < PAGE_COUNT; ++p) { for (uint8_t p = 0; p < PAGE_COUNT; ++p) {
const Page &page = pages[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) { for (uint8_t i = 0; i < page.count; ++i) {
const Row &row = page.rows[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::page) assert(row.opens > 0 && row.opens < PAGE_COUNT && *row.icon);
if (row.kind == Kind::toggle || row.kind == Kind::choice || row.kind == Kind::number || if (row.kind == Kind::toggle || row.kind == Kind::choice || row.kind == Kind::number ||
row.kind == Kind::duration || row.kind == Kind::moment) row.kind == Kind::duration || row.kind == Kind::moment)
assert(row.read && row.write); assert(row.read && row.write);
if (row.kind == Kind::number) assert(row.step > 0 && row.high > row.low); 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::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::info) assert(row.text);
if (row.kind == Kind::action) assert(row.run && *row.icon); if (row.kind == Kind::action) assert(row.run && *row.icon);
} }
+1
View File
@@ -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. // 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 // clang++ -std=c++17 -Wall -Wextra -Werror -I. tests/test_theme.cpp -o /tmp/test_theme && /tmp/test_theme
#define THEME_TEST #define THEME_TEST
+1 -1
View File
@@ -151,7 +151,7 @@ class Setting(unittest.TestCase):
self.assertEqual(DARK_MODE_MIN_FIRMWARE, '0.2.54') 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('.')))) self.assertLessEqual(tuple(map(int, DARK_MODE_MIN_FIRMWARE.split('.'))), tuple(map(int, FIRMWARE_VERSION.split('.'))))
page = (COMPONENT / 'settings_screen.h').read_text() 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) self.assertIn('else if (key == "dark_mode") reported = dark_mode = flag(value);', page)
editor = (ROOT / 'web/src/store.ts').read_text() editor = (ROOT / 'web/src/store.ts').read_text()
self.assertIn('{ key: "dark_mode", label: "Dark mode", kind: "toggle" },', editor) self.assertIn('{ key: "dark_mode", label: "Dark mode", kind: "toggle" },', editor)
+1
View File
@@ -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 // c++ -std=c++17 -Wall -Wextra -pedantic tests/test_tile_controls.cpp -o /tmp/test_tile_controls && /tmp/test_tile_controls
#define THEME_TEST #define THEME_TEST
#include "../components/smart_display/tile_controls.h" #include "../components/smart_display/tile_controls.h"
+1
View File
@@ -1,3 +1,4 @@
#include "screen_text_en.h"
#include "components/smart_display/tile_icon.h" #include "components/smart_display/tile_icon.h"
#include <cassert> #include <cassert>
int main() { int main() {
+1
View File
@@ -1,3 +1,4 @@
#include "screen_text_en.h"
#define THEME_TEST #define THEME_TEST
#include "components/smart_display/tile_palette.h" #include "components/smart_display/tile_palette.h"
#include <cassert> #include <cassert>
+144
View File
@@ -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())