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 = []
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 <cstdint>
#include <string>
#include "screen_text.h"
#include "tile_icon.h"
#include "tile_icon_names.h"
#include "tile_palette.h"
@@ -11,7 +12,6 @@
// tests/test_alert_overlay.cpp can check them; the profiles do the LVGL work.
namespace screen_alert {
constexpr const char *FALLBACK_ICON = "alert-outline";
constexpr const char *FALLBACK_TITLE = "Notification";
constexpr const char *FALLBACK_BUTTON = "OK";
constexpr uint32_t DEFAULT_CARD_COLOR = 0; // the normal card: theme::surface(0)
constexpr int MAX_TIMEOUT_SECONDS = 86400;
@@ -59,7 +59,7 @@ inline Alert make(const std::string &title, const std::string &subtitle, const s
size_t title_max, size_t subtitle_max, size_t button_max) {
Alert alert;
alert.title = clipped(trimmed(title), title_max);
if (alert.title.empty()) alert.title = FALLBACK_TITLE;
if (alert.title.empty()) alert.title = screen_text::tr(screen_text::txt::alert_notification);
alert.subtitle = clipped(trimmed(subtitle), subtitle_max);
alert.button = clipped(trimmed(button), button_max);
if (alert.button.empty()) alert.button = FALLBACK_BUTTON;
+8 -6
View File
@@ -4,6 +4,7 @@
#include "esphome/core/hal.h"
#include "lvgl.h"
#include "theme.h"
#include "screen_text.h"
#include <functional>
namespace screen_calibration {
inline bool active = false, pressed = false;
@@ -15,9 +16,10 @@ inline std::array<std::array<Point,3>,5> samples;
inline esphome::ESPPreferenceObject preference;
inline std::function<void(const Calibration &)> apply;
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_label_set_text_fmt(instructions, "Tap the crosshair\nPoint %u/5 - tap %u/3", point+1, sample+1);
lv_label_set_text(instructions, screen_text::fill(screen_text::fill(screen_text::txt::calibration_crosshair, "point", (int) point + 1), "tap", std::to_string(sample + 1)).c_str());
lv_label_set_text(status, message);
}
inline void begin() {
@@ -55,13 +57,13 @@ inline void press() { if (active) { down_at = esphome::millis(); pressed = true;
inline void release(int raw_x, int raw_y) {
if (!active || !pressed) return;
pressed = false;
if (esphome::millis()-down_at < 80 || raw_x < 0 || raw_x > 4095 || raw_y < 0 || raw_y > 4095) { prompt("Tap gently, a bit longer"); return; }
if (esphome::millis()-down_at < 80 || raw_x < 0 || raw_x > 4095 || raw_y < 0 || raw_y > 4095) { prompt(screen_text::tr(screen_text::txt::calibration_gently)); return; }
samples[point][sample++] = {double(raw_x), double(raw_y)};
if (sample < 3) { prompt(); return; }
double xs[3], ys[3];
for (int i=0;i<3;++i) { xs[i]=samples[point][i].x; ys[i]=samples[point][i].y; }
std::sort(xs,xs+3); std::sort(ys,ys+3);
if (xs[2]-xs[0]>180 || ys[2]-ys[0]>180) { sample=0; prompt("Tap the same crosshair 3 times"); return; }
if (xs[2]-xs[0]>180 || ys[2]-ys[0]>180) { sample=0; prompt(screen_text::tr(screen_text::txt::calibration_same)); return; }
point++; sample=0;
if (point < 5) { prompt(); return; }
std::array<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]};
}
Calibration next = calibration;
if (!fit(raw,next)) { point=0;prompt("Measurement rejected; try again");return; }
if (!preference.save(&next) || !esphome::global_preferences->sync()) { point=0;prompt("Save failed; try again");return; }
if (!fit(raw,next)) { point=0;prompt(screen_text::tr(screen_text::txt::calibration_rejected));return; }
if (!preference.save(&next) || !esphome::global_preferences->sync()) { point=0;prompt(screen_text::tr(screen_text::txt::calibration_save_failed));return; }
calibration=next;
if (apply) apply(calibration);
active=false;
+5 -4
View File
@@ -7,6 +7,7 @@
#include <string>
#include <vector>
#include "runtime_model.h"
#include "screen_text.h"
// A light's effects page (firmware 0.2.70+, app 0.2.83+): the modes of a lamp such as a WLED, reached from the
// sparkles key at the top right of the light's colour card. One card of rows, each the name of something the lamp
@@ -273,7 +274,7 @@ inline void received(const std::string &for_entity, unsigned page, unsigned page
options.pages = std::max(1u, pages);
if (options.next < options.pages && options.names.size() < MAX_NAMES) { options.asked_at = clock(); if (ask) ask(options.entity, options.next); return; }
options.complete = true;
if (options.names.empty()) { if (roller_note) lv_label_set_text(roller_note, "Nothing to choose"); return; }
if (options.names.empty()) { if (roller_note) lv_label_set_text(roller_note, screen_text::tr(screen_text::txt::effects_nothing)); return; }
show_roller();
}
inline void picker_back(lv_event_t *) { if (steady()) close_picker(); }
@@ -287,7 +288,7 @@ inline void open_picker(int index) {
top_bar(picker, m, row.name, picker_back, "\U000F012C", confirm_event);
const int w = m.width - 2 * m.pad, h = m.roller_rows * m.roller_row_h + 2 * m.roller_pad;
auto *holder = card(picker, m.pad, m.rows_y, w, h, m.radius);
roller_note = text(holder, "Loading...", row_font, theme::MUTED, LV_TEXT_ALIGN_CENTER);
roller_note = text(holder, screen_text::tr(screen_text::txt::effects_loading), row_font, theme::MUTED, LV_TEXT_ALIGN_CENTER);
lv_obj_set_width(roller_note, w - 2 * m.inset);
lv_obj_center(roller_note);
// The names of the same entity from a moment ago serve again; anything else is asked for afresh.
@@ -296,7 +297,7 @@ inline void open_picker(int index) {
options.entity = row.entity;
options.asked_at = clock();
if (ask) ask(row.entity, 0);
else lv_label_set_text(roller_note, "Not connected");
else lv_label_set_text(roller_note, screen_text::tr(screen_text::txt::effects_not_connected));
}
// ---- the page ----
@@ -331,7 +332,7 @@ inline void draw() {
// The rows: the light's effect first, then the selects of its device.
struct Spec { std::string entity, name, current, fallback; uint32_t icon; bool light; };
std::vector<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});
int y = m.rows_y;
if (!specs.empty()) {
+21 -18
View File
@@ -3,6 +3,7 @@
#include <array>
#include <cstdint>
#include <string>
#include "screen_text.h"
namespace header_bar {
// The top bar right of the screen name (firmware 0.2.32+). ESP Screen Manager decides what shows
@@ -65,33 +66,35 @@ inline uint32_t next_codepoint(const std::string &s, size_t &i) {
}
// Relative time in the editor's words (app.js agoText): "Just now", "5 min ago", "Yesterday",
// "In 2 hours". `now` 0 means the clock is not set yet.
// "In 2 hours", in the screen's language (screen.time, app 0.2.90). `now` 0 means the clock is not set yet.
inline std::string ago_text(int64_t then, int64_t now) {
using namespace screen_text;
if (now <= 0 || then <= 0) return "—";
int64_t seconds = now - then, span = seconds < 0 ? -seconds : seconds;
auto n = [&](int64_t unit) { return std::to_string(span / unit); };
auto n = [&](int64_t unit) { return static_cast<int>(span / unit); };
if (seconds < 0) {
if (span < 3600) return "In " + std::to_string(std::max<int64_t>(1, span / 60)) + " min";
if (span < 86400) return span / 3600 == 1 ? "In 1 hour" : "In " + n(3600) + " hours";
if (span < 172800) return "Tomorrow";
return "In " + n(86400) + " days";
if (span < 3600) return fill(txt::time_in_minutes, "n", std::max(1, n(60)));
if (span < 86400) return plural(txt::time_in_hours, n(3600));
if (span < 172800) return tr(txt::time_tomorrow);
return plural(txt::time_in_days, n(86400));
}
if (span < 60) return "Just now";
if (span < 3600) return n(60) + " min ago";
if (span < 86400) return span / 3600 == 1 ? "1 hour ago" : n(3600) + " hours ago";
if (span < 172800) return "Yesterday";
if (span < 604800) return n(86400) + " days ago";
if (span < 2592000) return span / 604800 == 1 ? "1 week ago" : n(604800) + " weeks ago";
if (span < 31536000) return span / 2592000 == 1 ? "1 month ago" : n(2592000) + " months ago";
return span / 31536000 == 1 ? "1 year ago" : n(31536000) + " years ago";
if (span < 60) return tr(txt::time_just_now);
if (span < 3600) return fill(txt::time_minutes_ago, "n", n(60));
if (span < 86400) return plural(txt::time_hours_ago, n(3600));
if (span < 172800) return tr(txt::time_yesterday);
if (span < 604800) return plural(txt::time_days_ago, n(86400));
if (span < 2592000) return plural(txt::time_weeks_ago, n(604800));
if (span < 31536000) return plural(txt::time_months_ago, n(2592000));
return plural(txt::time_years_ago, n(31536000));
}
// "Mo 14 Sep"; day_of_week 1 is Sunday, as ESPHome counts.
// "Mo 14 Sep" in English, "ma 14 sep" in Dutch (screen.date.top_bar); day_of_week 1 is Sunday, as ESPHome counts.
inline std::string date_text(int day_of_week, int day_of_month, int month) {
static const char *days[] = {"Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"};
static const char *months[] = {"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"};
using namespace screen_text;
if (day_of_week < 1 || day_of_week > 7 || month < 1 || month > 12) return "—";
return std::string(days[day_of_week - 1]) + " " + std::to_string(day_of_month) + " " + months[month - 1];
std::string text = fill(txt::date_top_bar, "weekday", tr(txt::date_weekdays_min + day_of_week - 1));
text = fill(text, "day", std::to_string(day_of_month));
return fill(text, "month", tr(txt::date_months_short + month - 1));
}
// What you see between icon and value, between two items and after the name, from the height of
+26 -21
View File
@@ -9,6 +9,7 @@
#include <string>
#include <utility>
#include <vector>
#include "screen_text.h"
namespace history_view {
constexpr int PARTS = 24;
@@ -22,18 +23,20 @@ struct Run {
};
// A clock time of a unix time in the screen's time zone (`offset` seconds east of UTC), as the screen's 12- or
// 24-hour setting writes it; `weekday` puts the day in front ("Tue 14:05") for a week.
// 24-hour setting writes it; `weekday` puts the day in front ("Tue 14:05") for a week, in the screen's language.
inline std::string clock(int64_t epoch, int32_t offset, bool h24, bool weekday) {
using namespace screen_text;
int64_t local = epoch + offset;
int64_t days = local >= 0 ? local / 86400 : (local - 86399) / 86400;
int seconds = static_cast<int>(local - days * 86400);
int hour = seconds / 3600, minute = seconds % 3600 / 60;
static const char *const names[] = {"Thu", "Fri", "Sat", "Sun", "Mon", "Tue", "Wed"};
char b[24];
if (h24) snprintf(b, sizeof(b), "%02d:%02d", hour, minute);
else snprintf(b, sizeof(b), "%d:%02d %s", hour % 12 ? hour % 12 : 12, minute, hour < 12 ? "AM" : "PM");
else snprintf(b, sizeof(b), "%d:%02d %s", hour % 12 ? hour % 12 : 12, minute, tr(hour < 12 ? txt::time_am : txt::time_pm));
if (!weekday) return b;
return std::string(names[((days % 7) + 7) % 7]) + " " + b;
// Day 0 of the unix epoch was a Thursday; the names count from Sunday.
int sunday_first = static_cast<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.
@@ -45,25 +48,25 @@ inline std::string axis_clock(int64_t epoch, int32_t offset, bool h24) {
return text;
}
// "45 s", "12 min", "3 h 20 min", "2 d 4 h": the time spent in a state.
// "45 s", "12 min", "3 h 20 min", "2 d 4 h": the time spent in a state, in the screen's language (screen.duration).
inline std::string duration(uint32_t seconds) {
char b[32];
if (seconds < 60) snprintf(b, sizeof(b), "%u s", static_cast<unsigned>(seconds));
else if (seconds < 3600) snprintf(b, sizeof(b), "%u min", static_cast<unsigned>(seconds / 60));
else if (seconds < 86400) {
unsigned minutes = seconds / 60 % 60;
if (minutes) snprintf(b, sizeof(b), "%u h %u min", static_cast<unsigned>(seconds / 3600), minutes);
else snprintf(b, sizeof(b), "%u h", static_cast<unsigned>(seconds / 3600));
} else {
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));
using namespace screen_text;
const int s = static_cast<int>(seconds);
if (s < 60) return fill(txt::duration_seconds, "n", s);
if (s < 3600) return fill(txt::duration_minutes, "n", s / 60);
if (s < 86400) {
int minutes = s / 60 % 60;
if (!minutes) return fill(txt::duration_hours, "n", s / 3600);
return fill(fill(txt::duration_hours_minutes, "h", s / 3600), "m", std::to_string(minutes));
}
return b;
int hours = s / 3600 % 24;
if (!hours) return fill(txt::duration_days, "n", s / 86400);
return fill(fill(txt::duration_days_hours, "d", s / 86400), "h", std::to_string(hours));
}
// A value as Home Assistant writes it: fixed decimals, commas between thousands, "%" and "°" on the number and
// any other unit after a space.
// A value as Home Assistant writes it: fixed decimals, the language's separator between thousands and its decimal
// mark (screen.number: "1,234.5" in English, "1.234,5" in Dutch), "%" and "°" on the number and any other unit after
// a space.
inline std::string number(float value, int decimals, const std::string &unit) {
if (!std::isfinite(value)) return "--";
char b[40];
@@ -72,8 +75,10 @@ inline std::string number(float value, int decimals, const std::string &unit) {
if (text[0] == '-') { sign = "-"; text.erase(0, 1); }
auto dot = text.find('.');
std::string whole = text.substr(0, dot), rest = dot == std::string::npos ? "" : text.substr(dot);
for (int i = static_cast<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;
if (unit.empty()) return text;
if (unit == "%" || unit == "°") return text + unit;
+4 -3
View File
@@ -4,6 +4,7 @@
#include <cstdio>
#include <cstdlib>
#include <string>
#include "screen_text.h"
namespace light_controls {
inline int clamp(int value, int low, int high) { return std::max(low, std::min(high, value)); }
@@ -93,7 +94,7 @@ inline void preview(Row &row) {
lv_obj_set_style_bg_opa(row.slider, row.off ? LV_OPA_TRANSP : LV_OPA_COVER, LV_PART_INDICATOR);
lv_obj_set_style_bg_opa(row.slider, row.off ? LV_OPA_TRANSP : LV_OPA_COVER, LV_PART_KNOB);
lv_obj_set_style_bg_color(row.slider, theme::color(row.off ? theme::TRACK : theme::AMBER_TRACK), LV_PART_MAIN);
if (row.off) lv_label_set_text(row.value, "Off");
if (row.off) lv_label_set_text(row.value, screen_text::tr(screen_text::txt::ha_off));
else lv_label_set_text_fmt(row.value, "%d %%", value);
}
}
@@ -182,7 +183,7 @@ inline void setup(lv_obj_t *parent, const lv_font_t *font, int width, int height
int margin = large ? 20 : 12, w = width - 2 * margin, card_h = spacing - (large ? 12 : 5);
int inset = large ? 18 : 10, text_y = large ? 14 : 5, track_h = large ? 32 : 18;
int track_x = inset, track_w = w - 2 * inset, track_y = card_h - inset + (large ? 2 : 3) - track_h, radius = track_h / 2;
const char *names[] = {"Color", "Color temperature", "Brightness"};
const char *names[] = {screen_text::tr(screen_text::txt::light_color), screen_text::tr(screen_text::txt::light_color_temperature), screen_text::tr(screen_text::txt::light_brightness)};
const char *icons[] = {"\U000F03D8", "\U000F050F", "\U000F0335"};
for (unsigned i = 0; i < 3; ++i) {
auto &row = rows[i]; row.index = i;
@@ -287,7 +288,7 @@ inline void open(const std::string &entity, bool color, bool temperature, int br
lv_slider_set_value(row.slider, i == 0 ? active->hue : i == 1 ? active->kelvin : clamp(brightness, 1, 100), LV_ANIM_OFF);
row.off = i == 2 && brightness <= 0;
preview(row); row.dirty = false;
if (i == 1 && !active->temperature_ready()) lv_label_set_text(row.value, "Waiting...");
if (i == 1 && !active->temperature_ready()) lv_label_set_text(row.value, screen_text::tr(screen_text::txt::light_waiting));
}
}
inline bool self_test() {
+6 -5
View File
@@ -12,6 +12,7 @@
#include <cstdint>
#include <cstdio>
#include <string>
#include "screen_text.h"
namespace media_card {
struct Rect {
@@ -145,11 +146,11 @@ inline std::string subtitle(const std::string &artist, const std::string &album)
}
// What the card says instead of a title when nothing plays.
inline const char *idle_text(const std::string &state) {
if (state == "off") return "Off";
if (state == "standby") return "Standby";
if (state == "buffering") return "Loading";
if (state == "unavailable") return "Unavailable";
return "Not playing";
if (state == "off") return screen_text::tr(screen_text::txt::ha_off);
if (state == "standby") return screen_text::tr(screen_text::txt::ha_media_standby);
if (state == "buffering") return screen_text::tr(screen_text::txt::media_loading);
if (state == "unavailable") return screen_text::tr(screen_text::txt::tile_unavailable);
return screen_text::tr(screen_text::txt::media_not_playing);
}
inline bool playing(const std::string &state) { return state == "playing" || state == "buffering"; }
inline bool has_track(const std::string &state) { return state == "playing" || state == "paused" || state == "buffering"; }
+3 -2
View File
@@ -5,6 +5,7 @@
#include <string>
#include <utility>
#include <vector>
#include "screen_text.h"
#include <cmath>
#include <cstdint>
#include <cstdlib>
@@ -429,7 +430,7 @@ struct Model {
// Pages the manager wants shown even when the last ones are still empty (0.2.26+).
uint8_t pages = 1;
size_t count = 0;
std::string title = "Choose tiles in HA";
std::string title = screen_text::tr(screen_text::txt::status_choose_tiles);
bool configured = false;
// Why the last set_layout refused a layout, for the manager's inbox status; empty when it took the layout or the
// message itself was wrong (the status then says "invalid message", as before).
@@ -469,7 +470,7 @@ struct Model {
return false;
}
// A title-only update must not interrupt an open control card.
title = name.empty() ? "Home" : name;
title = name.empty() ? std::string(screen_text::tr(screen_text::txt::status_home)) : name;
if (changed) {
// Freed before the new list is made, so the heap never holds both.
tiles.clear();
+123 -101
View File
@@ -25,6 +25,11 @@
#include <algorithm>
namespace runtime_tiles {
// What the screen says, in the language its firmware was built for (screen_text.h, app 0.2.90).
using screen_text::fill;
using screen_text::plural;
using screen_text::tr;
namespace txt = screen_text::txt;
inline bool enabled = false;
// Swiping, rotation and going back to page 1 live in settings_screen, next to the other settings the
// screen can change itself; these names stay as the way the rest of the firmware reaches them.
@@ -39,6 +44,8 @@ inline esphome::ESPPreferenceObject buttons_preference;
inline esphome::ESPPreferenceObject swipe_preference;
inline esphome::ESPPreferenceObject home_preference;
inline esphome::ESPPreferenceObject dark_preference;
// The number format of Settings -> Language & region (app 0.2.90), kept for the next start: screen_text::number_style.
inline esphome::ESPPreferenceObject numbers_preference;
inline Model model;
inline std::string inbox;
inline const lv_font_t *watch_font = nullptr;
@@ -144,6 +151,9 @@ inline void load_settings() {
home_preference = esphome::global_preferences->make_preference<HomeTimeout>(0x484F4D31);
dark_preference = esphome::global_preferences->make_preference<uint32_t>(0x44524B31);
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;
if(swipe_preference.load(&swipe_saved))swipe_pages=swipe_saved==1;
uint32_t dark_saved=0;
@@ -296,6 +306,10 @@ inline std::string receive(const std::string &payload) {
root["auto_home_seconds"].as<unsigned>()<30 || root["auto_home_seconds"].as<unsigned>()>3600))return false;
if(!root["rotation"].isNull() && (!root["rotation"].is<unsigned>() ||
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>() ||
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.
@@ -330,6 +344,12 @@ inline std::string receive(const std::string &payload) {
HomeTimeout home{(uint32_t)(auto_home?1:0),(uint32_t)auto_home_seconds};home_preference.save(&home);
}
bool rotation_changed=false;
if(root["clock_24h"].is<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>()){
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
// refreshes the feed: drawing the whole page again stalls touch input for a few hundred ms,
// long enough to spoil a slider drag. The tile states that follow redraw their own tiles.
if (changed || moved || !was_configured || rotation_changed || model.pages != previous_pages || model.title != previous_title) {
if (changed || moved || !was_configured || rotation_changed || format_changed || model.pages != previous_pages || model.title != previous_title) {
if (layout_changed) layout_changed();
refresh_all();
}
@@ -887,20 +907,20 @@ inline lv_obj_t *detail_label(lv_obj_t *parent,const std::string &text,int x,int
lv_label_set_long_mode(label,LV_LABEL_LONG_DOT);lv_obj_set_height(label,lv_font_get_line_height(detail_font));return label;
}
inline std::string detail_state(const Tile &t){
if(t.domain()=="person")return t.state=="home"?"Home":t.state=="not_home"?"Away":t.state;
if(t.domain()=="sun")return t.state=="above_horizon"?"Above the horizon":"Below the horizon";
if(t.domain()=="person")return t.state=="home"?tr(txt::ha_person_home):t.state=="not_home"?tr(txt::ha_person_not_home):t.state;
if(t.domain()=="sun")return tr(t.state=="above_horizon"?txt::ha_sun_above_horizon:txt::ha_sun_below_horizon);
if(t.domain()=="timer")return timer_text(t);
if(t.domain()=="script"||t.domain()=="scene"||t.domain()=="button"||t.domain()=="input_button")return t.state=="on"?"Running...":last_run_text(t.last_run);
if(t.domain()=="script"||t.domain()=="scene"||t.domain()=="button"||t.domain()=="input_button")return t.state=="on"?tr(txt::script_running):last_run_text(t.last_run);
if(t.domain()=="binary_sensor"&&(t.state=="on"||t.state=="off"))return tile_controls::binary_state_text(t.device_class,t.state=="on");
if(t.state=="on")return "On";
if(t.state=="off")return "Off";
if(t.state=="docked")return "Docked";
if(t.state=="cleaning")return "Cleaning";
if(t.state=="paused")return "Paused";
if(t.state=="returning")return "Returning to dock";
if(t.state=="idle")return "Idle";
if(t.state=="error")return "Check the robot in HA";
if(!t.available())return "Unavailable";
if(t.state=="on")return tr(txt::ha_on);
if(t.state=="off")return tr(txt::ha_off);
if(t.state=="docked")return tr(txt::ha_vacuum_docked);
if(t.state=="cleaning")return tr(txt::ha_vacuum_cleaning);
if(t.state=="paused")return tr(txt::ha_vacuum_paused);
if(t.state=="returning")return tr(txt::ha_vacuum_returning);
if(t.state=="idle")return tr(txt::ha_vacuum_idle);
if(t.state=="error")return tr(txt::vacuum_check_robot);
if(!t.available())return tr(txt::tile_unavailable);
// Home Assistant's word where the screen has none of its own (firmware 0.2.58+).
if(!t.extra().state_word.empty())return t.extra().state_word;
return t.state;
@@ -1068,10 +1088,10 @@ inline lv_obj_t *detail_text(lv_obj_t *parent,const std::string &text,int x,int
inline std::string rain_text(float chance,float mm,bool with_mm){
char b[32];
if(std::isfinite(chance) && chance>=0){
if(with_mm && std::isfinite(mm) && mm>=0.05f){snprintf(b,sizeof(b),"%d%% · %.1f mm",(int)std::lround(chance),mm);return b;}
if(with_mm && std::isfinite(mm) && mm>=0.05f){snprintf(b,sizeof(b),"%d%% · ",(int)std::lround(chance));return b+screen_text::decimal(mm,1)+" mm";}
snprintf(b,sizeof(b),"%d%%",(int)std::lround(chance));return b;
}
if(std::isfinite(mm) && mm>=0.05f){snprintf(b,sizeof(b),mm<10?"%.1f mm":"%.0f mm",mm);return b;}
if(std::isfinite(mm) && mm>=0.05f)return screen_text::decimal(mm,mm<10?1:0)+" mm";
return "";
}
inline std::string degrees(float value){ if(!std::isfinite(value))return "--"; char b[16];snprintf(b,sizeof(b),"%.0f°",value);return b; }
@@ -1111,9 +1131,9 @@ inline void render_weather_detail(const Tile &t,bool large,int width,int height,
detail_text(now,degrees(t.current),temp_x,cy+(hero-big_h)/2,temp_w,big,LV_TEXT_ALIGN_LEFT,ink);
int text_x=temp_x+temp_w+(large?4:2),text_w=width-2*pad-card_pad-text_x;
int lines_h=text_h+small_h+(large?2:0);
detail_text(now,t.available()?weather_text(t.state):"Unavailable",text_x,cy+(hero-lines_h)/2,text_w,detail_font,LV_TEXT_ALIGN_LEFT,ink);
detail_text(now,t.available()?weather_text(t.state):tr(txt::tile_unavailable),text_x,cy+(hero-lines_h)/2,text_w,detail_font,LV_TEXT_ALIGN_LEFT,ink);
std::string details;
if(std::isfinite(weather.feels)){snprintf(b,sizeof(b),"Feels like %.0f°",weather.feels);details=b;}
if(std::isfinite(weather.feels))details=fill(txt::weather_feels_like,"n",(int)std::lround(weather.feels));
if(std::isfinite(t.humidity)){snprintf(b,sizeof(b),"%d%%",(int)std::lround(t.humidity));details+=(details.empty()?"":" · ")+std::string(b);}
if(std::isfinite(weather.wind)){snprintf(b,sizeof(b),"%.0f %s",weather.wind,weather.wind_unit.empty()?"km/h":weather.wind_unit.c_str());details+=(details.empty()?"":" · ")+std::string(b);}
detail_text(now,details,text_x,cy+(hero-lines_h)/2+text_h+(large?2:0),text_w,small,LV_TEXT_ALIGN_LEFT,muted);
@@ -1130,8 +1150,8 @@ inline void render_weather_detail(const Tile &t,bool large,int width,int height,
}
y+=card_a_h+(large?12:6);
// Coming days: a heading and a card with one row per day.
if(!weather.forecast.size()){detail_text(detail_root,"No daily forecast from Home Assistant",pad,y,width-2*pad,small,LV_TEXT_ALIGN_LEFT,muted);return;}
if(large){detail_text(detail_root,"Coming days",pad+4,y,width-2*pad,detail_font,LV_TEXT_ALIGN_LEFT,muted);y+=text_h+8;}
if(!weather.forecast.size()){detail_text(detail_root,tr(txt::weather_no_forecast),pad,y,width-2*pad,small,LV_TEXT_ALIGN_LEFT,muted);return;}
if(large){detail_text(detail_root,tr(txt::weather_coming_days),pad+4,y,width-2*pad,detail_font,LV_TEXT_ALIGN_LEFT,muted);y+=text_h+8;}
int card_b_h=height-y-(large?10:4);
auto *days=detail_card(pad,y,width-2*pad,card_b_h);
int row_pad=large?8:4,row=(card_b_h-2*row_pad)/(int)weather.forecast.size();
@@ -1275,7 +1295,7 @@ inline void render_vacuum_detail(Tile &t,bool large,int width,int height,int pad
bool cleaning=t.state=="cleaning",paused=t.state=="paused";
bool battery=std::isfinite(t.battery),on_the_way=cleaning||paused||t.state=="returning";
int inner=width-2*pad,gap=large?12:6,radius=lv_obj_get_style_radius(widgets[0].tile,LV_PART_MAIN);
std::string state=t.loading(now)?"Command sent...":detail_state(t);
std::string state=t.loading(now)?tr(txt::tile_command_sent):detail_state(t);
// A robot with little to set gets a hero on the small screen too; one with mode and water rows uses a status row.
bool small_hero=!large && !mode && !t.choice('w');
int y=large?92:52;
@@ -1314,12 +1334,12 @@ inline void render_vacuum_detail(Tile &t,bool large,int width,int height,int pad
}
// Start (or pause, or resume) as the one blue button, dock beside it.
int action_h=large?54:(small_hero?40:36),start_w=large?(inner-gap)*2/3:(inner-gap)/2;
vacuum_command(cleaning?"\U000F03E4":"\U000F040A",cleaning?(large?"Pause cleaning":"Pause"):paused?"Resume":(large?"Start cleaning":"Clean"),
vacuum_command(cleaning?"\U000F03E4":"\U000F040A",tr(cleaning?(large?txt::vacuum_pause_cleaning:txt::vacuum_pause):paused?txt::vacuum_resume:(large?txt::vacuum_start_cleaning:txt::vacuum_clean)),
pad,y,start_w,action_h,cleaning?1:0,true,text,icons,large?radius:action_h/2);
vacuum_command("\U000F05F8","Dock",pad+start_w+gap,y,inner-start_w-gap,action_h,2,false,text,icons,large?radius:action_h/2);
vacuum_command("\U000F05F8",tr(txt::vacuum_dock),pad+start_w+gap,y,inner-start_w-gap,action_h,2,false,text,icons,large?radius:action_h/2);
y+=action_h+gap;
if(!mode && !suction && !water){
auto *note=detail_text(detail_root,"Automatic suction power",pad,y+gap,inner,text,LV_TEXT_ALIGN_CENTER,theme::SUBTLE);(void)note;
auto *note=detail_text(detail_root,tr(txt::vacuum_auto_suction),pad,y+gap,inner,text,LV_TEXT_ALIGN_CENTER,theme::SUBTLE);(void)note;
return;
}
// How it cleans. The Guition groups the rows on one white card; the small screen has no room for a card.
@@ -1340,7 +1360,7 @@ inline void render_vacuum_detail(Tile &t,bool large,int width,int height,int pad
if(water)level(*water,60,"\U000F058C");
if(automatic){
bool smart=tile_controls::shown_value(t,*mode,now).find("smart")!=std::string::npos;
detail_text(detail_root,smart?"The robot chooses suction and water":"Suction and water as set per room",x,y,w,text,LV_TEXT_ALIGN_CENTER,theme::SUBTLE);
detail_text(detail_root,tr(smart?txt::vacuum_robot_chooses:txt::vacuum_per_room),x,y,w,text,LV_TEXT_ALIGN_CENTER,theme::SUBTLE);
}
}
// ---- Cover card (firmware 0.2.50+): Home Assistant's cover dialog in the style of the vacuum and climate cards ----
@@ -1353,7 +1373,7 @@ inline constexpr uint32_t COVER_ACCENT = theme::ha::PURPLE;
inline uint32_t cover_track(){return theme::tint(COVER_ACCENT,37);}
inline uint32_t cover_slats(){return theme::tint(COVER_ACCENT,80);}
inline lv_obj_t *cover_values[2]{};
inline std::string cover_status_line(const Tile &t){return t.available()?tile_controls::cover_card_status(t):"Unavailable";}
inline std::string cover_status_line(const Tile &t){return t.available()?tile_controls::cover_card_status(t):tr(txt::tile_unavailable);}
inline void cover_slider_event(lv_event_t *e){
auto *slider=lv_event_get_target_obj(e);auto code=lv_event_get_code(e);
const bool tilt=(uintptr_t)lv_event_get_user_data(e)==1;
@@ -1462,8 +1482,8 @@ inline void render_cover_detail(Tile &t,bool large,int width,int height,int pad)
detail_card(pad,top,inner,box_h);
struct Part{bool tilt;float value;const char *caption;};
Part parts[2];int n=0;
if(card.position)parts[n++]={false,t.position,"Position"};
if(card.tilt)parts[n++]={true,t.extra().tilt,"Tilt"};
if(card.position)parts[n++]={false,t.position,tr(txt::cover_position)};
if(card.tilt)parts[n++]={true,t.extra().tilt,tr(txt::cover_tilt)};
int value_h=lv_font_get_line_height(big),caption_h=lv_font_get_line_height(text);
auto percent=[](float value){return std::isfinite(value)?std::to_string((int)std::lround(std::clamp(value,0.0f,100.0f)))+"%":std::string("--");};
if(large){
@@ -1550,7 +1570,7 @@ inline bool history_fits(const Tile &t){
// Home Assistant's word for the state now: from this entity's history words, else the card's own. The history can
// still be the previous card's while this one waits for its answer.
inline std::string history_words(const Tile &t){
if(!t.available())return "Unavailable";
if(!t.available())return tr(txt::tile_unavailable);
if(history.entity==t.entity)for(const auto &pair:history.words)if(pair.first==t.state)return pair.second;
return detail_state(t);
}
@@ -1602,16 +1622,16 @@ inline void history_scrub(lv_event_t *e){
const int i=history_view::part_at(fraction);
const uint64_t span=h.end-h.start;
const uint32_t begin=h.start+static_cast<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);
if(c.first)label(c.first,history_clock(begin,week)+" \u2013 "+history_clock(finish,false));
if(c.second)label(c.second,!h.has[i]?"":h.hours==1?"average":"average of "+history_view::duration(finish-begin));
if(c.second)label(c.second,!h.has[i]?std::string():h.hours==1?std::string(tr(txt::history_average)):fill(txt::history_average_of,"duration",history_view::duration(finish-begin)));
}else{
// A run reads the real times of its state, not the slots it covers: a door open for 5 minutes says 5 min.
const int r=history_view::run_at(h.runs,h.slots,fraction);if(r<0)return;
const auto &run=h.runs[r];
const uint32_t begin=h.start+run.begin,finish=h.start+run.end;
label(c.value,run.state<0||run.state>=static_cast<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.second)label(c.second,history_view::duration(run.seconds));
}
@@ -1627,8 +1647,8 @@ inline void history_touch(int x,int y,int w,int h){
inline void history_times(int x,int w,int y,const lv_font_t *font,bool large){
const auto &h=history;
const lv_font_t *bold=detail_font?detail_font:font;
const int now_w=text_width("Now",bold),gap=large?10:6;
detail_text(detail_root,"Now",x+w-now_w,y,now_w+2,bold,LV_TEXT_ALIGN_LEFT,theme::INK);
const int now_w=text_width(tr(txt::history_now),bold),gap=large?10:6;
detail_text(detail_root,tr(txt::history_now),x+w-now_w,y,now_w+2,bold,LV_TEXT_ALIGN_LEFT,theme::INK);
int last=x-1000;
for(uint32_t at:h.times){
if(at<=h.start||at>=h.end)continue;
@@ -1751,7 +1771,7 @@ inline void render_history_timeline(bool large,int card_x,int card_y,int card_w,
// waiting for a command does not lock it.
inline void history_ranges(int x,int y,int w,int h){
static const uint32_t hours[]={1,24,168};
static const char *const words[]={"1 hour","24 hours","1 week"};
const char *const words[]={tr(txt::history_range_hour),tr(txt::history_range_day),tr(txt::history_range_week)};
const lv_font_t *font=control_font?control_font:detail_font;
auto *track=detail_shape(detail_root,x,y,w,h,theme::CARD,h/2);
lv_obj_set_style_border_width(track,1,0);lv_obj_set_style_border_color(track,theme::color(theme::LINE),0);
@@ -1789,7 +1809,7 @@ inline void render_history_detail(const Tile &t,bool large,int width,int height,
if(line){
int decimals=0;const auto dot=t.state.find('.');if(dot!=std::string::npos)decimals=static_cast<int>(std::min<size_t>(4,t.state.size()-dot-1));
if(c.ready)decimals=h.decimals;
c.value_text=!t.available()?"Unavailable":numeric?history_view::number(current,decimals,t.unit):t.state;
c.value_text=!t.available()?std::string(tr(txt::tile_unavailable)):numeric?history_view::number(current,decimals,t.unit):t.state;
if(c.ready){
// The value now counts too: it can lie beyond the history (the running hour is not in the statistics yet).
if(h.has_high){c.high=h.high;c.high_at=h.high_at;}
@@ -1797,13 +1817,13 @@ inline void render_history_detail(const Tile &t,bool large,int width,int height,
if(std::isfinite(current)&&std::isfinite(c.high)&&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(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.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.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=fill(txt::history_low,"value",history_view::number(c.low,h.decimals,h.unit))+" \u00B7 "+history_clock(c.low_at,h.hours==168);
}else{
c.value_text=history_words(t);
if(c.ready&&h.active>=0){
c.first_text=h.states[h.active].label+" \u00B7 "+history_view::duration(h.states[h.active].seconds);
c.second_text=h.began==1?"once":h.began?std::to_string(h.began)+" times":"";
c.second_text=h.began?plural(txt::history_times,h.began):"";
}
}
const int row=large?84:50,row_h=lv_font_get_line_height(big),text_h=lv_font_get_line_height(text);
@@ -1832,7 +1852,7 @@ inline void render_history_detail(const Tile &t,bool large,int width,int height,
}
// The value column fits the widest text a finger can show there, so a readout never runs into the texts beside it.
int widest=text_width(c.value_text,big);
if(c.ready)widest=std::max(widest,text_width("No data",big));
if(c.ready)widest=std::max(widest,text_width(tr(txt::history_no_data),big));
if(c.ready&&line){for(int i=0;i<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));}
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);
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){
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){
render_history_line(large,pad,top,width-2*pad,card_h,small,current);
}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.
inline bool media_off(const Tile &t){return t.state=="off" || t.state=="standby";}
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 "";
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"){
render_weather_detail(t,large,width,height,pad);
}else if(d=="timer"){
detail_label(detail_root,t.state=="active"?"Running":t.state=="paused"?"Paused":"Stopped",pad,top,width-2*pad);
detail_button(t.state=="active"?"Pause":"Start",pad,top+(large?50:30),cw,bh,40);
detail_button("Cancel",pad+cw+gap,top+(large?50:30),cw,bh,41);
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(tr(t.state=="active"?txt::timer_pause:txt::timer_start),pad,top+(large?50:30),cw,bh,40);
detail_button(tr(txt::timer_cancel),pad+cw+gap,top+(large?50:30),cw,bh,41);
}else if(d=="sun"){
detail_label(detail_root,"Sunrise "+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_sunrise,"time",t.extra().sunrise),pad,top,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";
}
inline const char *weather_text(const std::string &condition) {
if (condition == "sunny") return "Sunny";
if (condition == "clear-night") return "Clear, night";
if (condition == "cloudy") return "Cloudy";
if (condition == "partlycloudy") return "Partly cloudy";
if (condition == "rainy") return "Rainy";
if (condition == "pouring") return "Pouring";
if (condition == "snowy") return "Snowy";
if (condition == "snowy-rainy") return "Snowy, rainy";
if (condition == "fog") return "Fog";
if (condition == "hail") return "Hail";
if (condition == "lightning") return "Lightning";
if (condition == "lightning-rainy") return "Lightning, rainy";
if (condition == "windy" || condition == "windy-variant") return "Windy";
if (condition == "exceptional") return "Exceptional";
if (condition == "sunny") return tr(txt::ha_weather_sunny);
if (condition == "clear-night") return tr(txt::ha_weather_clear_night);
if (condition == "cloudy") return tr(txt::ha_weather_cloudy);
if (condition == "partlycloudy") return tr(txt::ha_weather_partlycloudy);
if (condition == "rainy") return tr(txt::ha_weather_rainy);
if (condition == "pouring") return tr(txt::ha_weather_pouring);
if (condition == "snowy") return tr(txt::ha_weather_snowy);
if (condition == "snowy-rainy") return tr(txt::ha_weather_snowy_rainy);
if (condition == "fog") return tr(txt::ha_weather_fog);
if (condition == "hail") return tr(txt::ha_weather_hail);
if (condition == "lightning") return tr(txt::ha_weather_lightning);
if (condition == "lightning-rainy") return tr(txt::ha_weather_lightning_rainy);
if (condition == "windy" || condition == "windy-variant") return tr(txt::ha_weather_windy);
if (condition == "exceptional") return tr(txt::ha_weather_exceptional);
return condition.c_str();
}
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);
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 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 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);
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 + 1) return std::string("Yesterday ") + clock;
return "Last " + std::to_string(when.day_of_month) + " " + month_short(when);
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 fill(txt::script_yesterday_time, "time", clock);
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) {
const Extra &x = t.extra();
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));
return x.duration.empty() ? "Off" : countdown(duration_seconds(x.duration));
if (t.state == "paused") return fill(txt::timer_paused_left, "time", countdown(duration_seconds(x.remaining)));
return x.duration.empty() ? std::string(tr(txt::ha_off)) : countdown(duration_seconds(x.duration));
}
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 ? days[now.day_of_week - 1] : "";
return now.is_valid() && now.day_of_week >= 1 && now.day_of_week <= 7 ? tr(txt::date_weekdays + now.day_of_week - 1) : "";
}
inline std::string month_short(const esphome::ESPTime &now) {
static const char *months[] = {"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"};
return now.is_valid() && now.month >= 1 && now.month <= 12 ? months[now.month - 1] : "";
return now.is_valid() && now.month >= 1 && now.month <= 12 ? tr(txt::date_months_short + now.month - 1) : "";
}
// "Monday 14 September" in English, "maandag 14 september" in Dutch (screen.date.full).
inline std::string date_text(const esphome::ESPTime &now) {
static const char *months[] = {"January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"};
if (!now.is_valid() || now.day_of_week < 1 || now.day_of_week > 7 || now.month < 1 || now.month > 12) return "";
return weekday_text(now) + " " + std::to_string(now.day_of_month) + " " + months[now.month - 1];
std::string text = fill(txt::date_full, "weekday", weekday_text(now));
text = fill(text, "day", std::to_string(now.day_of_month));
return fill(text, "month", tr(txt::date_months + now.month - 1));
}
// Where the finger is, or where it let go, in the screen's coordinates; false without a finger (a test's event).
inline bool finger_at(lv_point_t &point) {
@@ -2615,9 +2637,9 @@ inline void render_sunpath(Widgets &w,const Tile &t,bool large,int width,int hei
const lv_font_t *title_font=lv_obj_get_style_text_font(w.title,LV_PART_MAIN);
int title_h=lv_font_get_line_height(title_font),text_h=lv_font_get_line_height(w.value_font);
int horizon=height-text_h-(large?4:2),top=title_h+(large?4:2),x0=large?14:8,x1=width-x0;
part_label(w,0,title_font,0,0,width,LV_TEXT_ALIGN_LEFT,t.name.empty()?"Sun":t.name);
part_label(w,1,w.value_font,0,horizon+(large?3:1),width/2,LV_TEXT_ALIGN_LEFT,"rise "+t.extra().sunrise);
part_label(w,2,w.value_font,width/2,horizon+(large?3:1),width/2,LV_TEXT_ALIGN_RIGHT,"set "+t.extra().sunset);
part_label(w,0,title_font,0,0,width,LV_TEXT_ALIGN_LEFT,t.name.empty()?std::string(tr(txt::sun_name)):t.name);
part_label(w,1,w.value_font,0,horizon+(large?3:1),width/2,LV_TEXT_ALIGN_LEFT,fill(txt::sun_rise,"time",t.extra().sunrise));
part_label(w,2,w.value_font,width/2,horizon+(large?3:1),width/2,LV_TEXT_ALIGN_RIGHT,fill(txt::sun_set,"time",t.extra().sunset));
auto now=now_time?now_time():esphome::ESPTime{};
int rise=minutes_of(t.extra().sunrise),set=minutes_of(t.extra().sunset),minute=now.is_valid()?now.hour*60+now.minute:-1;
bool day=t.state=="above_horizon";float fraction=0.5f;
@@ -3068,7 +3090,7 @@ inline void render_full(Widgets &w,const Tile &t,bool custom,bool clock,bool sun
// The large value font carries digits, the degree sign and the percent sign; the clock font only digits and a colon.
std::string middle;const lv_font_t *mid_font=watch_value_font?watch_value_font:w.value_font;
auto d=t.domain();
if(d=="climate" && std::isfinite(t.current)){char b[32];snprintf(b,sizeof(b),"%.1f°",t.current);middle=b;}
if(d=="climate" && std::isfinite(t.current))middle=screen_text::decimal(t.current,1)+"°";
else if(d=="cover" && std::isfinite(t.position)){middle=std::to_string(static_cast<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;}
int mid_font_h=lv_font_get_line_height(mid_font);
@@ -3092,34 +3114,34 @@ inline void render_slot(size_t slot) {
std::string unit=watch?t.unit:"";
std::string value = t.state;
// Nothing in Home Assistant stands behind the settings card, so it says the same with the link down.
if (t.is_settings()) value = "Tap to open";
else if (t.is_page()) value = "Page " + std::to_string(t.page_target());
else if (!fresh() || !t.available()) value = "Unavailable";
else if (t.refused_at && esphome::millis() - t.refused_at < 4000) value = "Refused";
if (t.is_settings()) value = tr(txt::tile_tap_to_open);
else if (t.is_page()) value = fill(txt::tile_page, "n", t.page_target());
else if (!fresh() || !t.available()) value = tr(txt::tile_unavailable);
else if (t.refused_at && esphome::millis() - t.refused_at < 4000) value = tr(txt::tile_refused);
else if (d == "light" && t.state == "on" && tile_controls::effect_running(t.extra().effect)) value = t.extra().effect;
else if (d == "light" && t.state == "on" && std::isfinite(t.brightness)) value = std::to_string(static_cast<int>(std::lround(std::clamp(t.brightness, 0.0f, 255.0f) * 100 / 255))) + " %";
// An airco that is off says so, with the room's temperature when it knows it, as Home Assistant's tile does
// (firmware 0.2.71+); while it runs, the tile shows the temperature it is set to.
else if (d == "climate" && t.state == "off") { value = tile_controls::climate_mode_text(t.state); if (std::isfinite(t.current)) { char b[32]; snprintf(b, sizeof(b), " · %.1f°", t.current); value += b; } }
else if (d == "climate" && std::isfinite(t.target)) { char b[32]; snprintf(b, sizeof(b), "%.1f°", t.target); value = b; }
else if (d == "person") value = t.state=="home"?"Home":t.state=="not_home"?"Away":t.state;
else if (d == "sun") value = !t.extra().sunrise.empty() && !t.extra().sunset.empty() ? t.extra().sunrise+" - "+t.extra().sunset : t.state=="above_horizon"?"Above the horizon":"Below the horizon";
else if (d == "climate" && t.state == "off") { value = tile_controls::climate_mode_text(t.state); if (std::isfinite(t.current)) value += " · " + screen_text::decimal(t.current, 1) + "°"; }
else if (d == "climate" && std::isfinite(t.target)) value = screen_text::decimal(t.target, 1) + "°";
else if (d == "person") value = t.state=="home"?tr(txt::ha_person_home):t.state=="not_home"?tr(txt::ha_person_not_home):t.state;
else if (d == "sun") value = !t.extra().sunrise.empty() && !t.extra().sunset.empty() ? t.extra().sunrise+" - "+t.extra().sunset : tr(t.state=="above_horizon"?txt::ha_sun_above_horizon:txt::ha_sun_below_horizon);
else if (d == "timer") value = timer_text(t);
else if (d == "script" || d == "scene" || d == "button" || d == "input_button") value = t.state == "on" ? "Running..." : last_run_text(t.last_run);
else if (d == "camera") value = t.state == "streaming" ? "Live" : t.state == "recording" ? "Recording" : "Tap to view";
else if (d == "image") value = t.last_run ? "Tap to view" : "No image yet";
else if (d == "script" || d == "scene" || d == "button" || d == "input_button") value = t.state == "on" ? std::string(tr(txt::script_running)) : last_run_text(t.last_run);
else if (d == "camera") value = tr(t.state == "streaming" ? txt::camera_live : t.state == "recording" ? txt::camera_recording : txt::camera_tap_to_view);
else if (d == "image") value = tr(t.last_run ? txt::camera_tap_to_view : txt::camera_no_image_yet);
else if (d == "binary_sensor" && (value == "on" || value == "off")) value = tile_controls::binary_state_text(t.device_class, value == "on");
else if (value == "on") value = "On";
else if (value == "off") value = "Off";
else if (value == "cleaning") value = "Cleaning";
else if (value == "docked") value = "Docked";
else if (value == "on") value = tr(txt::ha_on);
else if (value == "off") value = tr(txt::ha_off);
else if (value == "cleaning") value = tr(txt::ha_vacuum_cleaning);
else if (value == "docked") value = tr(txt::ha_vacuum_docked);
// Home Assistant's word where the screen has none of its own (firmware 0.2.58+): a cover says Open, a washer Rinsing.
else if (!t.extra().state_word.empty()) value = t.extra().state_word;
// A player's state in the screen's own words where Home Assistant sent none (firmware 0.2.64+).
else if (d == "media_player") value = tile_controls::media_state_text(t.state);
else if (!t.unit.empty() && !watch) value += " " + t.unit;
bool pending=t.loading(esphome::millis());
if(d=="weather" && std::isfinite(t.current)) {char b[32];snprintf(b,sizeof(b),"%.1f %s",t.current,t.unit.c_str());value=b;if(watch){snprintf(b,sizeof(b),"%.1f",t.current);value=b;}}
if(d=="weather" && std::isfinite(t.current)) {value=screen_text::decimal(t.current,1);if(!watch)value+=" "+t.unit;}
if(d=="vacuum" && std::isfinite(t.battery))value += " / "+std::to_string((int)t.battery)+"%";
// Direct controls: only a wide card in the standard layout has room for the panel.
bool with_panel=w.wide && !t.controls.empty() && !t.builtin() && !watch && t.inline_control!="slider" && fresh() && t.available();
@@ -3338,9 +3360,9 @@ inline void boot_status(lv_obj_t *page, const char *text) {
inline void render(lv_obj_t *room) {
if (!enabled) return;
room_label=room; swipe_profile::Lap lap;
if (!model.configured) boot_status(lv_obj_get_parent(room), !ha_connected() ? "Connecting to Home Assistant" : "Waiting for ESP Screens");
if (!model.configured) boot_status(lv_obj_get_parent(room), tr(!ha_connected() ? txt::status_connecting : txt::status_waiting));
else if (boot_panel) { lv_obj_delete(boot_panel); boot_panel = boot_text = boot_spinner = nullptr; }
label(room, !model.configured ? "" : !model.ready() ? "Loading tiles..." : !ha_connected() ? "HA not connected" : !feed_alive() ? "ESP Screens not active" : model.title);
label(room, !model.configured ? std::string() : !model.ready() ? tr(txt::status_loading_tiles) : !ha_connected() ? tr(txt::status_ha_not_connected) : !feed_alive() ? tr(txt::status_manager_not_active) : model.title);
render_header();
lap(swipe_profile::HEADER);
bool all=dirty_all || (!dirty_tiles && !dirty_header);
@@ -3856,15 +3878,15 @@ inline void tick() {
const uint32_t now=esphome::millis();
if(history_fits(t))refresh_detail(detail_index);
else if(now-history_asked_at>30000)history_request(t.entity,history_hours);
else if(now-history_asked_at>8000)label(history_chart.status,"No history available");
else if(now-history_asked_at>8000)label(history_chart.status,tr(txt::history_unavailable));
}
for(unsigned i=0;i<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:""));
// 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.
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);
}else if(detail_badge_status)label(detail_badge_status,status);
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;
camera.link(url);
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;
}
// 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;
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
+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 <string>
#include "screen_settings.h"
#include "screen_text.h"
// The settings page on the screen itself (firmware 0.2.44+). It changes the same values ESP Screen
// Manager writes, for the moments you stand in front of the panel instead of behind a browser.
@@ -34,15 +35,19 @@ using Text = std::string (*)();
using Shown = bool (*)();
using Run = void (*)();
// The page's words are keys into the screen's language (screen.settings in screen_manager/translations, app 0.2.90):
// the table stays constexpr, the text is looked up when a row is drawn.
constexpr uint16_t NO_TEXT = 0xFFFF;
struct Row {
Kind kind = Kind::info;
const char *label = "";
uint16_t label = NO_TEXT;
const char *icon = ""; // page rows and actions; a Material Design Icons glyph
Read read = nullptr; // toggle, number, duration, moment, choice
Write write = nullptr;
int32_t low = 0, high = 0, step = 0; // number: fixed step; duration: seconds, step from the ladder
const char *unit = ""; // straight after a number ("%")
const char *const *options = nullptr; // choice
const char *const *options = nullptr; // choice with the same words in every language ("90°")
uint16_t option_keys = NO_TEXT; // choice in words: the key of its first option, the others follow it
uint8_t option_count = 0;
Text text = nullptr; // info
Run run = nullptr; // action
@@ -51,35 +56,40 @@ struct Row {
uint8_t opens = 0; // page rows: the page they open
};
constexpr Row page_row(const char *label, const char *icon, uint8_t opens) {
constexpr Row page_row(uint16_t label, const char *icon, uint8_t opens) {
Row r{}; r.kind = Kind::page; r.label = label; r.icon = icon; r.opens = opens; return r;
}
constexpr Row toggle(const char *label, Read read, Write write, Shown shown = nullptr) {
constexpr Row toggle(uint16_t label, Read read, Write write, Shown shown = nullptr) {
Row r{}; r.kind = Kind::toggle; r.label = label; r.read = read; r.write = write; r.shown = shown; return r;
}
constexpr Row number(const char *label, Read read, Write write, int32_t low, int32_t high, int32_t step,
constexpr Row number(uint16_t label, Read read, Write write, int32_t low, int32_t high, int32_t step,
const char *unit = "", Shown enabled = nullptr) {
Row r{}; r.kind = Kind::number; r.label = label; r.read = read; r.write = write;
r.low = low; r.high = high; r.step = step; r.unit = unit; r.enabled = enabled; return r;
}
constexpr Row duration(const char *label, Read read, Write write, int32_t low, int32_t high,
constexpr Row duration(uint16_t label, Read read, Write write, int32_t low, int32_t high,
Shown enabled = nullptr) {
Row r{}; r.kind = Kind::duration; r.label = label; r.read = read; r.write = write;
r.low = low; r.high = high; r.enabled = enabled; return r;
}
constexpr Row moment(const char *label, Read read, Write write, Shown enabled = nullptr) {
constexpr Row moment(uint16_t label, Read read, Write write, Shown enabled = nullptr) {
Row r{}; r.kind = Kind::moment; r.label = label; r.read = read; r.write = write;
r.low = 0; r.high = 1439; r.step = 15; r.enabled = enabled; return r;
}
constexpr Row choice(const char *label, Read read, Write write, const char *const *options, uint8_t count,
constexpr Row choice(uint16_t label, Read read, Write write, const char *const *options, uint8_t count,
Shown shown = nullptr) {
Row r{}; r.kind = Kind::choice; r.label = label; r.read = read; r.write = write;
r.options = options; r.option_count = count; r.shown = shown; return r;
}
constexpr Row info(const char *label, Text text) {
constexpr Row choice(uint16_t label, Read read, Write write, uint16_t option_keys, uint8_t count, Shown shown = nullptr) {
Row r{}; r.kind = Kind::choice; r.label = label; r.read = read; r.write = write;
r.option_keys = option_keys; r.option_count = count; r.shown = shown; return r;
}
inline const char *label_text(const Row &row) { return screen_text::tr(row.label); }
constexpr Row info(uint16_t label, Text text) {
Row r{}; r.kind = Kind::info; r.label = label; r.text = text; return r;
}
constexpr Row action(const char *label, const char *icon, Run run) {
constexpr Row action(uint16_t label, const char *icon, Run run) {
Row r{}; r.kind = Kind::action; r.label = label; r.icon = icon; r.run = run; return r;
}
@@ -169,26 +179,28 @@ inline int knob_x(int w, int h, bool on) {
}
inline std::string duration_text(int32_t seconds) {
char buffer[24];
if (seconds < 60) { snprintf(buffer, sizeof(buffer), "%d sec", (int) seconds); return buffer; }
if (seconds < 3600) { snprintf(buffer, sizeof(buffer), "%d min", (int) (seconds / 60)); return buffer; }
using namespace screen_text;
if (seconds < 60) return fill(txt::settings_seconds, "n", (int) seconds);
if (seconds < 3600) return fill(txt::settings_minutes, "n", (int) (seconds / 60));
int hours = seconds / 3600, minutes = (seconds % 3600) / 60;
if (minutes) snprintf(buffer, sizeof(buffer), "%d h %02d", hours, minutes);
else snprintf(buffer, sizeof(buffer), "%d h", hours);
return buffer;
if (!minutes) return fill(txt::settings_hours, "n", hours);
char two[4];
snprintf(two, sizeof(two), "%02d", minutes);
return fill(fill(txt::settings_hours_minutes, "h", hours), "m", two);
}
// Minutes since midnight as the clock on this screen shows them.
inline std::string moment_text(int32_t minutes, bool clock_24h) {
char buffer[16];
int hour = minutes / 60, minute = minutes % 60;
if (clock_24h) snprintf(buffer, sizeof(buffer), "%02d:%02d", hour, minute);
else snprintf(buffer, sizeof(buffer), "%d:%02d %s", hour % 12 ? hour % 12 : 12, minute, hour < 12 ? "AM" : "PM");
else snprintf(buffer, sizeof(buffer), "%d:%02d %s", hour % 12 ? hour % 12 : 12, minute,
screen_text::tr(hour < 12 ? screen_text::txt::time_am : screen_text::txt::time_pm));
return buffer;
}
// What the right-hand side of a row says. A toggle draws a switch instead, its text is for the tests.
inline std::string value_text(const Row &row) {
switch (row.kind) {
case Kind::toggle: return row.read && row.read() ? "On" : "Off";
case Kind::toggle: return screen_text::tr(row.read && row.read() ? screen_text::txt::ha_on : screen_text::txt::ha_off);
case Kind::number: {
char buffer[24];
snprintf(buffer, sizeof(buffer), "%d%s", row.read ? (int) row.read() : 0, row.unit);
@@ -198,7 +210,8 @@ inline std::string value_text(const Row &row) {
case Kind::moment: return moment_text(row.read ? row.read() : 0, screen_settings::current.clock_24h != 0);
case Kind::choice: {
int32_t index = row.read ? row.read() : 0;
return index >= 0 && index < row.option_count ? row.options[index] : "";
if (index < 0 || index >= row.option_count) return "";
return row.option_keys != NO_TEXT ? screen_text::tr(row.option_keys + index) : row.options[index];
}
case Kind::info: return row.text ? row.text() : "";
default: return "";
@@ -208,7 +221,7 @@ inline bool visible_row(const Row &row) { return !row.shown || row.shown(); }
inline bool live_row(const Row &row) { return !row.enabled || row.enabled(); }
struct Page {
const char *title;
uint16_t title;
const Row *rows;
uint8_t count;
};
@@ -223,7 +236,6 @@ inline uint8_t fitting_rows(int span, int row_height, int gap, int pager, uint8_
}
// ------------------------------------------------------------------ the pages
inline constexpr const char *clock_options[] = {"12 hour", "24 hour"};
inline constexpr const char *rotation_options[] = {"0°", "90°", "180°", "270°"};
// Brightness, the dark look for a screen beside a bed, and when the screen dims by itself.
@@ -231,52 +243,51 @@ inline constexpr const char *rotation_options[] = {"0°", "90°", "180°", "270
// plain `int` (a ternary, say) then does not convert to Read at all. The Mac's host build accepts it,
// the board does not.
inline constexpr Row light_rows[] = {
number("Brightness", []() -> int32_t { return screen_settings::current.brightness; },
number(screen_text::txt::settings_brightness, []() -> int32_t { return screen_settings::current.brightness; },
[](int32_t value) { set("brightness", value); }, 5, 100, 5, "%"),
toggle("Dark mode", []() -> int32_t { return dark_mode; },
toggle(screen_text::txt::settings_dark_mode, []() -> int32_t { return dark_mode; },
[](int32_t value) { set("dark_mode", value); }),
toggle("Auto standby", []() -> int32_t { return screen_settings::current.standby_enabled; },
toggle(screen_text::txt::settings_auto_standby, []() -> int32_t { return screen_settings::current.standby_enabled; },
[](int32_t value) { set("standby_enabled", value); }),
duration("Standby after", []() -> int32_t { return screen_settings::current.standby_seconds; },
duration(screen_text::txt::settings_standby_after, []() -> int32_t { return screen_settings::current.standby_seconds; },
[](int32_t value) { set("standby_seconds", value); }, 60, 86400,
[] { return screen_settings::current.standby_enabled != 0; }),
number("Standby brightness", []() -> int32_t { return screen_settings::current.standby_brightness; },
number(screen_text::txt::settings_standby_brightness, []() -> int32_t { return screen_settings::current.standby_brightness; },
[](int32_t value) { set("standby_brightness", value); }, 0, 100, 5, "%",
[] { return screen_settings::current.standby_enabled != 0; }),
};
// Darker between two times, so a panel in a hallway does not light up the bedroom.
inline constexpr Row night_rows[] = {
toggle("Night mode", []() -> int32_t { return screen_settings::current.night_enabled; },
toggle(screen_text::txt::settings_night_mode, []() -> int32_t { return screen_settings::current.night_enabled; },
[](int32_t value) { set("night_enabled", value); }),
moment("Starts", []() -> int32_t { return screen_settings::current.night_start; },
moment(screen_text::txt::settings_starts, []() -> int32_t { return screen_settings::current.night_start; },
[](int32_t value) { set("night_start", value); },
[] { return screen_settings::current.night_enabled != 0; }),
moment("Ends", []() -> int32_t { return screen_settings::current.night_end; },
moment(screen_text::txt::settings_ends, []() -> int32_t { return screen_settings::current.night_end; },
[](int32_t value) { set("night_end", value); },
[] { return screen_settings::current.night_enabled != 0; }),
number("Night brightness", []() -> int32_t { return screen_settings::current.night_brightness; },
number(screen_text::txt::settings_night_brightness, []() -> int32_t { return screen_settings::current.night_brightness; },
[](int32_t value) { set("night_brightness", value); }, 0, 100, 5, "%",
[] { return screen_settings::current.night_enabled != 0; }),
};
// How the screen behaves under your finger: the clock, going back to the first page, swiping, the page buttons,
// turning.
// How the screen behaves under your finger: going back to the first page, swiping, the page buttons, turning. The
// clock's 12 or 24 hours is no longer a setting of each screen (app 0.2.90): ESP Screens sends it with the number
// format, from Settings -> Language & region, the one place for every screen.
inline constexpr Row screen_rows[] = {
choice("Clock", []() -> int32_t { return screen_settings::current.clock_24h ? 1 : 0; },
[](int32_t value) { set("clock_24h", value); }, clock_options, 2),
toggle("Back to page 1", []() -> int32_t { return auto_home; },
toggle(screen_text::txt::settings_back_to_page_1, []() -> int32_t { return auto_home; },
[](int32_t value) { set("auto_home", value); }),
duration("After", []() -> int32_t { return auto_home_seconds; },
duration(screen_text::txt::settings_after, []() -> int32_t { return auto_home_seconds; },
[](int32_t value) { set("auto_home_seconds", value); }, 30, 3600,
[] { return auto_home != 0; }),
toggle("Also on standby", []() -> int32_t { return screen_settings::current.home_on_standby; },
toggle(screen_text::txt::settings_also_on_standby, []() -> int32_t { return screen_settings::current.home_on_standby; },
[](int32_t value) { set("home_on_standby", value); }),
toggle("Swipe between pages", []() -> int32_t { return swipe_pages; },
toggle(screen_text::txt::settings_swipe_between_pages, []() -> int32_t { return swipe_pages; },
[](int32_t value) { set("swipe_pages", value); }),
toggle("Page buttons", []() -> int32_t { return page_buttons; },
toggle(screen_text::txt::settings_page_buttons, []() -> int32_t { return page_buttons; },
[](int32_t value) { set("page_buttons", value); }),
choice("Rotation", []() -> int32_t { return rotation / 90; },
choice(screen_text::txt::settings_rotation, []() -> int32_t { return rotation / 90; },
[](int32_t value) { set("rotation", std::clamp<int32_t>(value, 0, 3) * 90); },
rotation_options, 4, [] { return rotation_supported; }),
};
@@ -288,26 +299,26 @@ inline std::string (*firmware_text)() = nullptr;
inline std::string (*link_text)() = nullptr;
inline void (*restart_device)() = nullptr;
inline constexpr Row about_rows[] = {
info("Screen", [] { return name_text ? name_text() : std::string(); }),
info("Address", [] { return address_text ? address_text() : std::string(); }),
info("Firmware", [] { return firmware_text ? firmware_text() : std::string(); }),
info("Home Assistant", [] { return link_text ? link_text() : std::string(); }),
action("Restart", "\U000F0709", [] { if (restart_device) restart_device(); }),
info(screen_text::txt::settings_screen, [] { return name_text ? name_text() : std::string(); }),
info(screen_text::txt::settings_address, [] { return address_text ? address_text() : std::string(); }),
info(screen_text::txt::settings_firmware, [] { return firmware_text ? firmware_text() : std::string(); }),
info(screen_text::txt::settings_home_assistant, [] { return link_text ? link_text() : std::string(); }),
action(screen_text::txt::settings_restart, "\U000F0709", [] { if (restart_device) restart_device(); }),
};
inline constexpr Row menu_rows[] = {
page_row("Brightness", "\U000F0599", 1),
page_row("Night", "\U000F0594", 2),
page_row("Screen", "\U000F0379", 3),
page_row("This screen", "\U000F02FD", 4),
page_row(screen_text::txt::settings_brightness, "\U000F0599", 1),
page_row(screen_text::txt::settings_night, "\U000F0594", 2),
page_row(screen_text::txt::settings_screen, "\U000F0379", 3),
page_row(screen_text::txt::settings_this_screen, "\U000F02FD", 4),
};
inline constexpr Page pages[] = {
{"Settings", menu_rows, (uint8_t) std::size(menu_rows)},
{"Brightness", light_rows, (uint8_t) std::size(light_rows)},
{"Night", night_rows, (uint8_t) std::size(night_rows)},
{"Screen", screen_rows, (uint8_t) std::size(screen_rows)},
{"This screen", about_rows, (uint8_t) std::size(about_rows)},
{screen_text::txt::settings_title, menu_rows, (uint8_t) std::size(menu_rows)},
{screen_text::txt::settings_brightness, light_rows, (uint8_t) std::size(light_rows)},
{screen_text::txt::settings_night, night_rows, (uint8_t) std::size(night_rows)},
{screen_text::txt::settings_screen, screen_rows, (uint8_t) std::size(screen_rows)},
{screen_text::txt::settings_this_screen, about_rows, (uint8_t) std::size(about_rows)},
};
constexpr uint8_t PAGE_COUNT = (uint8_t) std::size(pages);
@@ -587,7 +598,7 @@ inline void draw() {
lv_obj_center(arrow);
lv_obj_add_event_cb(back, back_event, LV_EVENT_SHORT_CLICKED, nullptr);
const lv_font_t *heading = title_font ? title_font : row_font;
auto *title = text(root, page.title, heading, theme::INK, LV_TEXT_ALIGN_CENTER);
auto *title = text(root, screen_text::tr(page.title), heading, theme::INK, LV_TEXT_ALIGN_CENTER);
lv_obj_set_width(title, m.width - 2 * (m.pad + m.bar + 8));
lv_obj_set_pos(title, m.pad + m.bar + 8, m.bar_y + (m.bar - lv_font_get_line_height(heading)) / 2);
@@ -628,7 +639,8 @@ inline void draw() {
lv_obj_set_pos(glyph, left, (m.row_h - icon_h) / 2);
left += icon_h + (m.large ? 12 : 8);
}
d.label = text(d.card, asking ? "Tap again to restart" : row.label, row_font, asking ? theme::ON_ACCENT : theme::INK);
d.label = text(d.card, screen_text::tr(asking ? screen_text::txt::settings_tap_again_to_restart : row.label), row_font,
asking ? theme::ON_ACCENT : theme::INK);
lv_obj_set_pos(d.label, left, (m.row_h - label_h) / 2);
int right = m.width - 2 * m.pad - m.inset; // free space from the right edge of the card
+72 -52
View File
@@ -4,6 +4,7 @@
// a -/+ step lands on the entity's grid, and the status line beside them. The
// LVGL drawing lives in runtime_tiles.h; tests/test_tile_controls.cpp covers this.
#include "runtime_model.h"
#include "screen_text.h"
#include "theme.h"
#include <array>
#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);
return next;
}
// Whole degrees when the entity steps by whole units, one decimal otherwise.
// Whole degrees when the entity steps by whole units, one decimal otherwise, with the language's decimal mark.
inline std::string format_value(float value, float step, const char *suffix) {
if (!std::isfinite(value)) return "--";
char b[24]; snprintf(b, sizeof(b), step >= 1 ? "%.0f%s" : "%.1f%s", value, suffix); return b;
return screen_text::decimal(value, step >= 1 ? 0 : 1) + suffix;
}
inline const char *climate_mode_text(const std::string &mode) {
if (mode == "off") return "Off";
if (mode == "heat") return "Heat";
if (mode == "cool") return "Cool";
if (mode == "heat_cool") return "Heat/Cool";
if (mode == "auto") return "Auto";
if (mode == "dry") return "Dry";
if (mode == "fan_only") return "Fan only";
if (mode == "off") return screen_text::tr(screen_text::txt::ha_climate_off);
if (mode == "heat") return screen_text::tr(screen_text::txt::ha_climate_heat);
if (mode == "cool") return screen_text::tr(screen_text::txt::ha_climate_cool);
if (mode == "heat_cool") return screen_text::tr(screen_text::txt::ha_climate_heat_cool);
if (mode == "auto") return screen_text::tr(screen_text::txt::ha_climate_auto);
if (mode == "dry") return screen_text::tr(screen_text::txt::ha_climate_dry);
if (mode == "fan_only") return screen_text::tr(screen_text::txt::ha_climate_fan_only);
return mode.c_str();
}
inline const char *climate_action_text(const std::string &action) {
if (action == "heating") return "Heating";
if (action == "cooling") return "Cooling";
if (action == "idle") return "Idle";
if (action == "off") return "Off";
if (action == "drying") return "Drying";
if (action == "fan") return "Fan";
if (action == "preheating") return "Preheating";
if (action == "defrosting") return "Defrosting";
if (action == "heating") return screen_text::tr(screen_text::txt::ha_hvac_action_heating);
if (action == "cooling") return screen_text::tr(screen_text::txt::ha_hvac_action_cooling);
if (action == "idle") return screen_text::tr(screen_text::txt::ha_hvac_action_idle);
if (action == "off") return screen_text::tr(screen_text::txt::ha_hvac_action_off);
if (action == "drying") return screen_text::tr(screen_text::txt::ha_hvac_action_drying);
if (action == "fan") return screen_text::tr(screen_text::txt::ha_hvac_action_fan);
if (action == "preheating") return screen_text::tr(screen_text::txt::ha_hvac_action_preheating);
if (action == "defrosting") return screen_text::tr(screen_text::txt::ha_hvac_action_defrosting);
return "";
}
inline const char *cover_state_text(const std::string &state) {
if (state == "open") return "Open";
if (state == "closed") return "Closed";
if (state == "opening") return "Opening";
if (state == "closing") return "Closing";
if (state == "open") return screen_text::tr(screen_text::txt::ha_cover_open);
if (state == "closed") return screen_text::tr(screen_text::txt::ha_cover_closed);
if (state == "opening") return screen_text::tr(screen_text::txt::ha_cover_opening);
if (state == "closing") return screen_text::tr(screen_text::txt::ha_cover_closing);
return state.c_str();
}
inline const char *media_state_text(const std::string &state) {
if (state == "playing") return "Playing";
if (state == "paused") return "Paused";
if (state == "idle") return "Idle";
if (state == "standby") return "Standby";
if (state == "buffering") return "Loading";
if (state == "on") return "On";
if (state == "off") return "Off";
if (state == "playing") return screen_text::tr(screen_text::txt::ha_media_playing);
if (state == "paused") return screen_text::tr(screen_text::txt::ha_media_paused);
if (state == "idle") return screen_text::tr(screen_text::txt::ha_media_idle);
if (state == "standby") return screen_text::tr(screen_text::txt::ha_media_standby);
if (state == "buffering") return screen_text::tr(screen_text::txt::media_loading);
if (state == "on") return screen_text::tr(screen_text::txt::ha_on);
if (state == "off") return screen_text::tr(screen_text::txt::ha_off);
return state.c_str();
}
// A binary sensor's state in Home Assistant's words for its device class: a door is Open or Closed, a leak sensor
// Wet or Dry. The same words as the add-on's header_bar.BINARY_STATES, which the top bar and the history card show;
// tests/test_binary_words.py keeps the two tables equal. Without a class, or with one this table lacks: On and Off.
struct BinaryWords { const char *device_class, *on, *off; };
// Wet or Dry, in the screen's language (screen.ha.binary, taken from Home Assistant's own translations). The add-on's
// header_bar.BINARY_STATES reads the same words from the same file for the top bar and the history card. Without a
// class, or with one this table lacks: On and Off.
struct BinaryWords { const char *device_class; uint16_t on, off; };
constexpr BinaryWords BINARY_WORDS[] = {
{"battery", "Low", "Normal"}, {"battery_charging", "Charging", "Not charging"},
{"carbon_monoxide", "Danger", "Safe"}, {"cold", "Cold", "Normal"}, {"connectivity", "Connected", "Disconnected"},
{"door", "Open", "Closed"}, {"garage_door", "Open", "Closed"}, {"gas", "Danger", "Safe"},
{"heat", "Hot", "Normal"}, {"light", "Light", "Dark"}, {"lock", "Open", "Locked"}, {"moisture", "Wet", "Dry"},
{"motion", "Motion", "No motion"}, {"moving", "Moving", "Still"}, {"occupancy", "Occupied", "Clear"},
{"opening", "Open", "Closed"}, {"plug", "Plugged in", "Unplugged"}, {"power", "On", "Off"},
{"presence", "Home", "Away"}, {"problem", "Problem", "OK"}, {"running", "Active", "Inactive"},
{"safety", "Unsafe", "Safe"}, {"smoke", "Smoke", "No smoke"}, {"sound", "Sound", "Silent"},
{"tamper", "Tampering", "OK"}, {"update", "Update", "Up to date"}, {"vibration", "Vibration", "Still"},
{"window", "Open", "Closed"},
{"battery", screen_text::txt::ha_binary_battery_on, screen_text::txt::ha_binary_battery_off},
{"battery_charging", screen_text::txt::ha_binary_battery_charging_on, screen_text::txt::ha_binary_battery_charging_off},
{"carbon_monoxide", screen_text::txt::ha_binary_carbon_monoxide_on, screen_text::txt::ha_binary_carbon_monoxide_off},
{"cold", screen_text::txt::ha_binary_cold_on, screen_text::txt::ha_binary_cold_off},
{"connectivity", screen_text::txt::ha_binary_connectivity_on, screen_text::txt::ha_binary_connectivity_off},
{"door", screen_text::txt::ha_binary_door_on, screen_text::txt::ha_binary_door_off},
{"garage_door", screen_text::txt::ha_binary_garage_door_on, screen_text::txt::ha_binary_garage_door_off},
{"gas", screen_text::txt::ha_binary_gas_on, screen_text::txt::ha_binary_gas_off},
{"heat", screen_text::txt::ha_binary_heat_on, screen_text::txt::ha_binary_heat_off},
{"light", screen_text::txt::ha_binary_light_on, screen_text::txt::ha_binary_light_off},
{"lock", screen_text::txt::ha_binary_lock_on, screen_text::txt::ha_binary_lock_off},
{"moisture", screen_text::txt::ha_binary_moisture_on, screen_text::txt::ha_binary_moisture_off},
{"motion", screen_text::txt::ha_binary_motion_on, screen_text::txt::ha_binary_motion_off},
{"moving", screen_text::txt::ha_binary_moving_on, screen_text::txt::ha_binary_moving_off},
{"occupancy", screen_text::txt::ha_binary_occupancy_on, screen_text::txt::ha_binary_occupancy_off},
{"opening", screen_text::txt::ha_binary_opening_on, screen_text::txt::ha_binary_opening_off},
{"plug", screen_text::txt::ha_binary_plug_on, screen_text::txt::ha_binary_plug_off},
{"power", screen_text::txt::ha_binary_power_on, screen_text::txt::ha_binary_power_off},
{"presence", screen_text::txt::ha_binary_presence_on, screen_text::txt::ha_binary_presence_off},
{"problem", screen_text::txt::ha_binary_problem_on, screen_text::txt::ha_binary_problem_off},
{"running", screen_text::txt::ha_binary_running_on, screen_text::txt::ha_binary_running_off},
{"safety", screen_text::txt::ha_binary_safety_on, screen_text::txt::ha_binary_safety_off},
{"smoke", screen_text::txt::ha_binary_smoke_on, screen_text::txt::ha_binary_smoke_off},
{"sound", screen_text::txt::ha_binary_sound_on, screen_text::txt::ha_binary_sound_off},
{"tamper", screen_text::txt::ha_binary_tamper_on, screen_text::txt::ha_binary_tamper_off},
{"update", screen_text::txt::ha_binary_update_on, screen_text::txt::ha_binary_update_off},
{"vibration", screen_text::txt::ha_binary_vibration_on, screen_text::txt::ha_binary_vibration_off},
{"window", screen_text::txt::ha_binary_window_on, screen_text::txt::ha_binary_window_off},
};
inline const char *binary_state_text(const std::string &device_class, bool on) {
for (const auto &words : BINARY_WORDS)
if (device_class == words.device_class) return on ? words.on : words.off;
return on ? "On" : "Off";
if (device_class == words.device_class) return screen_text::tr(on ? words.on : words.off);
return screen_text::tr(on ? screen_text::txt::ha_on : screen_text::txt::ha_off);
}
// Status line beside a control panel: what Home Assistant shows under the name.
inline std::string status_text(const Tile &t) {
@@ -227,7 +247,7 @@ inline std::string status_text(const Tile &t) {
if (d == "climate") {
std::string text = climate_action_text(t.extra().hvac_action);
if (text.empty()) text = climate_mode_text(t.state);
if (std::isfinite(t.current)) { snprintf(b, sizeof(b), " · %.1f°", t.current); text += b; }
if (std::isfinite(t.current)) text += " · " + screen_text::decimal(t.current, 1) + "°";
return text;
}
if (d == "cover") {
@@ -261,7 +281,7 @@ inline CoverCard cover_card(const Tile &t) {
inline std::string cover_card_status(const Tile &t) {
std::string text = status_text(t);
if ((t.supported & feature::COVER_TILT_POSITION) && std::isfinite(t.extra().tilt)) {
char b[24]; snprintf(b, sizeof(b), " · Tilt %d%%", (int) std::lround(t.extra().tilt)); text += b;
text += " · " + screen_text::fill(screen_text::txt::cover_tilt_value, "n", (int) std::lround(t.extra().tilt));
}
return text;
}
@@ -328,9 +348,9 @@ inline std::string neighbour_option(const Tile &t, int direction) {
return options[(current + direction + count) % count];
}
inline const char *run_label(const std::string &domain) {
if (domain == "scene") return "Activate";
if (domain == "script") return "Run";
return "Press";
if (domain == "scene") return screen_text::tr(screen_text::txt::button_activate);
if (domain == "script") return screen_text::tr(screen_text::txt::button_run);
return screen_text::tr(screen_text::txt::button_press);
}
// The Home Assistant action behind a key. STEP_* are handled locally (debounced) and return nothing.
inline Action key_action(const Tile &t, int command, const std::string &arg = "") {
@@ -403,10 +423,10 @@ inline VacuumRows vacuum_rows(const Tile &t, uint32_t now) {
}
// Labels for a manager that sends no suction row (app before 0.2.46): the vacuum's own speed names.
inline std::string speed_label(const std::string &speed) {
if (speed == "quiet") return "Quiet";
if (speed == "balanced") return "Normal";
if (speed == "turbo") return "Turbo";
if (speed == "max") return "Max";
if (speed == "quiet") return screen_text::tr(screen_text::txt::vacuum_speed_quiet);
if (speed == "balanced") return screen_text::tr(screen_text::txt::vacuum_speed_balanced);
if (speed == "turbo") return screen_text::tr(screen_text::txt::vacuum_speed_turbo);
if (speed == "max") return screen_text::tr(screen_text::txt::vacuum_speed_max);
return speed;
}
// The suction row a vacuum card shows: the manager's (filtered, labelled) speeds, else the first four
+3 -3
View File
@@ -275,7 +275,7 @@ substitutions:
# The touch test page
TOUCH_TEST_PAGE_PAINT: "paint_calibration" # the page
TOUCH_TEST_TITLE: "Follow the calibration wizard"# the words at the top
TOUCH_TEST_TITLE: "calibration_title" # the words at the top
TOUCH_TEST_TITLE_FONT: "sublabel"
TOUCH_TEST_TITLE_Y: "65"
TOUCH_TEST_INSET: "10" # the corner crosses from the edge
@@ -689,10 +689,10 @@ script:
std::string mode = lower(id(active_climate_mode));
bool off = mode == "off" || mode.empty() || mode == "unknown" || mode == "unavailable";
// What the device does (Heating), else its mode (Heat); then the room.
std::string status = off ? std::string("Off") : std::string(tile_controls::climate_action_text(lower(id(active_climate_action))));
std::string status = off ? std::string(screen_text::tr(screen_text::txt::ha_off)) : std::string(tile_controls::climate_action_text(lower(id(active_climate_action))));
if (status.empty()) status = tile_controls::climate_mode_text(mode);
char part[32];
if (std::isfinite(id(active_climate_current))) { snprintf(part, sizeof(part), " · Now %.1f°", id(active_climate_current)); status += part; }
if (std::isfinite(id(active_climate_current))) status += " · " + screen_text::fill(screen_text::txt::climate_now, "value", screen_text::decimal(id(active_climate_current), 1) + "°");
if (id(active_climate_humidity) >= 0) { snprintf(part, sizeof(part), " · %d%%", id(active_climate_humidity)); status += part; }
lv_label_set_text(id(climate_current_label), status.c_str());
+3 -3
View File
@@ -167,7 +167,7 @@ substitutions:
# The touch test page
TOUCH_TEST_PAGE_PAINT: "paint_page_soft" # the page
TOUCH_TEST_TITLE: "GT911 touch check" # the words at the top
TOUCH_TEST_TITLE: "calibration_touch_check" # the words at the top
TOUCH_TEST_TITLE_FONT: "headline"
TOUCH_TEST_TITLE_Y: "110"
TOUCH_TEST_INSET: "12" # the corner crosses from the edge
@@ -923,10 +923,10 @@ script:
std::string mode = lower(id(active_climate_mode));
bool off = mode == "off" || mode.empty() || mode == "unknown" || mode == "unavailable";
// What the device does (Heating), else its mode (Heat); then the room.
std::string status = off ? std::string("Off") : std::string(tile_controls::climate_action_text(lower(id(active_climate_action))));
std::string status = off ? std::string(screen_text::tr(screen_text::txt::ha_off)) : std::string(tile_controls::climate_action_text(lower(id(active_climate_action))));
if (status.empty()) status = tile_controls::climate_mode_text(mode);
char part[32];
if (std::isfinite(id(active_climate_current))) { snprintf(part, sizeof(part), " · Now %.1f°", id(active_climate_current)); status += part; }
if (std::isfinite(id(active_climate_current))) status += " · " + screen_text::fill(screen_text::txt::climate_now, "value", screen_text::decimal(id(active_climate_current), 1) + "°");
if (id(active_climate_humidity) >= 0) { snprintf(part, sizeof(part), " · %d%%", id(active_climate_humidity)); status += part; }
lv_label_set_text(id(climate_current_label), status.c_str());
+89 -51
View File
@@ -18,6 +18,9 @@ substitutions:
DASHBOARD_INBOX_INTERNAL: "false"
CALIBRATION_ON_BOOT: "false" # a bench build: start on the touch test page
ROOM_NAME: "" # top left until ESP Screens sends the screen name
# The language of every text on the screen (app 0.2.90): a file in screen_manager/translations, English for any
# text it lacks. ESP Screens writes it into the screen's own YAML, from Home Assistant's language or its Settings.
LANGUAGE: "en"
## DIM settings
AUTO_DIM: "true" # true = enable auto dimming
@@ -213,7 +216,7 @@ esphome:
settings_screen::apply = []() { id(apply_screen_settings).execute(); };
settings_screen::report = [](const char *key, int32_t value) { runtime_tiles::setting_event(key, value); };
settings_screen::name_text = []() { return std::string("${DEVICE_FRIENDLY_NAME}"); };
settings_screen::address_text = []() { return id(wifi_ip).state.empty() ? std::string("No network") : id(wifi_ip).state; };
settings_screen::address_text = []() { return id(wifi_ip).state.empty() ? std::string(screen_text::tr(screen_text::txt::status_no_network)) : id(wifi_ip).state; };
settings_screen::firmware_text = []() { return std::string("${SCREEN_FIRMWARE_VERSION}"); };
settings_screen::link_text = []() { return std::string(esphome::api_is_connected() ? "Connected" : "Not connected"); };
settings_screen::restart_device = []() { ESP_LOGI("settings", "restart from the screen"); App.safe_reboot(); };
@@ -663,21 +666,35 @@ font:
id: headline
size: ${FONT_HEADLINE_SIZE}
bpp: 4
glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°',
'0','1','2','3','4','5','6','7','8','9',
'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z',
' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z',
'ä','ö','ü','Ä','Ö','Ü','ß','/','·']
glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°','0','1','2','3','4','5',
'6','7','8','9','A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T',
'U','V','W','X','Y','Z',' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q',
'r','s','t','u','v','w','x','y','z','/','·',
'À','Á','Â','Ã','Ä','Å','Æ','Ç','È','É','Ê','Ë','Ì','Í','Î','Ï','Ð','Ñ','Ò','Ó','Ô','Õ','Ö','Ø',
'Ù','Ú','Û','Ü','Ý','Þ','ß','à','á','â','ã','ä','å','æ','ç','è','é','ê','ë','ì','í','î','ï','ð',
'ñ','ò','ó','ô','õ','ö','ø','ù','ú','û','ü','ý','þ','ÿ','Ą','ą','Ć','ć','Ę','ę','Ł','ł','Ń','ń',
'Ś','ś','Ź','ź','Ż','ż','Č','č','Ď','ď','Ě','ě','Ň','ň','Ř','ř','Š','š','Ť','ť','Ů','ů','Ž','ž',
'Ĺ','ĺ','Ľ','ľ','Ŕ','ŕ','Ő','ő','Ű','ű','Ă','ă','Ș','ș','Ț','ț','Đ','đ','Ā','ā','Ē','ē','Ģ','ģ',
'Ī','ī','Ķ','ķ','Ļ','ļ','Ņ','ņ','Ū','ū','Ė','ė','Į','į','Ų','ų','Ğ','ğ','İ','ı','Ş','ş','Ċ','ċ',
'Ġ','ġ','Ħ','ħ','Ŵ','ŵ','Ŷ','ŷ','Ŀ','ŀ','Œ','œ','Ÿ','„','“','”','‘','’','«','»','‹','›','¿','¡',
'€','£']
- file: "${FONT_DIR}/Roboto-500.ttf"
id: watch_value
size: ${FONT_WATCH_VALUE_SIZE}
bpp: 4
glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°',
'0','1','2','3','4','5','6','7','8','9',
'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z',
' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z',
'ä','ö','ü','Ä','Ö','Ü','ß','/','·']
glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°','0','1','2','3','4','5',
'6','7','8','9','A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T',
'U','V','W','X','Y','Z',' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q',
'r','s','t','u','v','w','x','y','z','/','·',
'À','Á','Â','Ã','Ä','Å','Æ','Ç','È','É','Ê','Ë','Ì','Í','Î','Ï','Ð','Ñ','Ò','Ó','Ô','Õ','Ö','Ø',
'Ù','Ú','Û','Ü','Ý','Þ','ß','à','á','â','ã','ä','å','æ','ç','è','é','ê','ë','ì','í','î','ï','ð',
'ñ','ò','ó','ô','õ','ö','ø','ù','ú','û','ü','ý','þ','ÿ','Ą','ą','Ć','ć','Ę','ę','Ł','ł','Ń','ń',
'Ś','ś','Ź','ź','Ż','ż','Č','č','Ď','ď','Ě','ě','Ň','ň','Ř','ř','Š','š','Ť','ť','Ů','ů','Ž','ž',
'Ĺ','ĺ','Ľ','ľ','Ŕ','ŕ','Ő','ő','Ű','ű','Ă','ă','Ș','ș','Ț','ț','Đ','đ','Ā','ā','Ē','ē','Ģ','ģ',
'Ī','ī','Ķ','ķ','Ļ','ļ','Ņ','ņ','Ū','ū','Ė','ė','Į','į','Ų','ų','Ğ','ğ','İ','ı','Ş','ş','Ċ','ċ',
'Ġ','ġ','Ħ','ħ','Ŵ','ŵ','Ŷ','ŷ','Ŀ','ŀ','Œ','œ','Ÿ','„','“','”','‘','’','«','»','‹','›','¿','¡',
'€','£']
# Clock card digits only; the date line reuses the tile title font.
- file: "${FONT_DIR}/Roboto-500.ttf"
@@ -697,31 +714,52 @@ font:
id: label
size: ${FONT_LABEL_SIZE}
bpp: 4
glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°',
'0','1','2','3','4','5','6','7','8','9',
'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z',
' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z',
'ä','ö','ü','Ä','Ö','Ü','ß','/','·']
glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°','0','1','2','3','4','5',
'6','7','8','9','A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T',
'U','V','W','X','Y','Z',' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q',
'r','s','t','u','v','w','x','y','z','/','·',
'À','Á','Â','Ã','Ä','Å','Æ','Ç','È','É','Ê','Ë','Ì','Í','Î','Ï','Ð','Ñ','Ò','Ó','Ô','Õ','Ö','Ø',
'Ù','Ú','Û','Ü','Ý','Þ','ß','à','á','â','ã','ä','å','æ','ç','è','é','ê','ë','ì','í','î','ï','ð',
'ñ','ò','ó','ô','õ','ö','ø','ù','ú','û','ü','ý','þ','ÿ','Ą','ą','Ć','ć','Ę','ę','Ł','ł','Ń','ń',
'Ś','ś','Ź','ź','Ż','ż','Č','č','Ď','ď','Ě','ě','Ň','ň','Ř','ř','Š','š','Ť','ť','Ů','ů','Ž','ž',
'Ĺ','ĺ','Ľ','ľ','Ŕ','ŕ','Ő','ő','Ű','ű','Ă','ă','Ș','ș','Ț','ț','Đ','đ','Ā','ā','Ē','ē','Ģ','ģ',
'Ī','ī','Ķ','ķ','Ļ','ļ','Ņ','ņ','Ū','ū','Ė','ė','Į','į','Ų','ų','Ğ','ğ','İ','ı','Ş','ş','Ċ','ċ',
'Ġ','ġ','Ħ','ħ','Ŵ','ŵ','Ŷ','ŷ','Ŀ','ŀ','Œ','œ','Ÿ','„','“','”','‘','’','«','»','‹','›','¿','¡',
'€','£']
- file: "${FONT_DIR}/Roboto-400.ttf"
id: sublabel
size: ${FONT_SUBLABEL_SIZE}
bpp: 4
glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°',
'0','1','2','3','4','5','6','7','8','9',
'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z',
' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z',
'ä','ö','ü','Ä','Ö','Ü','ß','/','…','·']
glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°','0','1','2','3','4','5',
'6','7','8','9','A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T',
'U','V','W','X','Y','Z',' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q',
'r','s','t','u','v','w','x','y','z','/','…','·',
'À','Á','Â','Ã','Ä','Å','Æ','Ç','È','É','Ê','Ë','Ì','Í','Î','Ï','Ð','Ñ','Ò','Ó','Ô','Õ','Ö','Ø',
'Ù','Ú','Û','Ü','Ý','Þ','ß','à','á','â','ã','ä','å','æ','ç','è','é','ê','ë','ì','í','î','ï','ð',
'ñ','ò','ó','ô','õ','ö','ø','ù','ú','û','ü','ý','þ','ÿ','Ą','ą','Ć','ć','Ę','ę','Ł','ł','Ń','ń',
'Ś','ś','Ź','ź','Ż','ż','Č','č','Ď','ď','Ě','ě','Ň','ň','Ř','ř','Š','š','Ť','ť','Ů','ů','Ž','ž',
'Ĺ','ĺ','Ľ','ľ','Ŕ','ŕ','Ő','ő','Ű','ű','Ă','ă','Ș','ș','Ț','ț','Đ','đ','Ā','ā','Ē','ē','Ģ','ģ',
'Ī','ī','Ķ','ķ','Ļ','ļ','Ņ','ņ','Ū','ū','Ė','ė','Į','į','Ų','ų','Ğ','ğ','İ','ı','Ş','ş','Ċ','ċ',
'Ġ','ġ','Ħ','ħ','Ŵ','ŵ','Ŷ','ŷ','Ŀ','ŀ','Œ','œ','Ÿ','„','“','”','‘','’','«','»','‹','›','¿','¡',
'€','£']
- file: "${FONT_DIR}/Roboto-400.ttf"
id: sublabel_big
size: ${FONT_SUBLABEL_BIG_SIZE}
bpp: 4
glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°',
'0','1','2','3','4','5','6','7','8','9',
'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z',
' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z',
'ä','ö','ü','Ä','Ö','Ü','ß','/','…','·',"'",'#','*','=',';','²','³','µ','é','è','ë','ê','ï','î','ó','ô','à','á','â','ç','ñ','ú','û','–']
glyphs: ['<','>','—','&','@','!',',','.','?','"','%','(',')','+','-','_',':','°','0','1','2','3','4','5',
'6','7','8','9','A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T',
'U','V','W','X','Y','Z',' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q',
'r','s','t','u','v','w','x','y','z','/','…','·',"'",'#','*','=',';','²','³','µ','–',
'À','Á','Â','Ã','Ä','Å','Æ','Ç','È','É','Ê','Ë','Ì','Í','Î','Ï','Ð','Ñ','Ò','Ó','Ô','Õ','Ö','Ø',
'Ù','Ú','Û','Ü','Ý','Þ','ß','à','á','â','ã','ä','å','æ','ç','è','é','ê','ë','ì','í','î','ï','ð',
'ñ','ò','ó','ô','õ','ö','ø','ù','ú','û','ü','ý','þ','ÿ','Ą','ą','Ć','ć','Ę','ę','Ł','ł','Ń','ń',
'Ś','ś','Ź','ź','Ż','ż','Č','č','Ď','ď','Ě','ě','Ň','ň','Ř','ř','Š','š','Ť','ť','Ů','ů','Ž','ž',
'Ĺ','ĺ','Ľ','ľ','Ŕ','ŕ','Ő','ő','Ű','ű','Ă','ă','Ș','ș','Ț','ț','Đ','đ','Ā','ā','Ē','ē','Ģ','ģ',
'Ī','ī','Ķ','ķ','Ļ','ļ','Ņ','ņ','Ū','ū','Ė','ė','Į','į','Ų','ų','Ğ','ğ','İ','ı','Ş','ş','Ċ','ċ',
'Ġ','ġ','Ħ','ħ','Ŵ','ŵ','Ŷ','ŷ','Ŀ','ŀ','Œ','œ','Ÿ','„','“','”','‘','’','«','»','‹','›','¿','¡',
'€','£']
# Material Design Icons (MDI) — included at fonts/materialdesignicons-webfont.ttf (Apache 2.0)
# Source: https://github.com/Templarian/MaterialDesign-Webfont
@@ -1157,6 +1195,12 @@ text_sensor:
entity_category: diagnostic
lambda: 'return {"${SCREEN_FIRMWARE_VERSION}"};'
update_interval: 60s
# The language this firmware was built in (0.2.90+): ESP Screens offers an update when it differs from the chosen one.
- platform: template
name: "Screen language"
entity_category: diagnostic
lambda: 'return {screen_text::LANGUAGE};'
update_interval: 60s
# ESP Screen Manager matches the screen to its YAML profile and OTA address.
- platform: template
name: "Device name"
@@ -1216,17 +1260,6 @@ switch:
- lambda: 'settings_screen::set("dark_mode", 1);'
turn_off_action:
- lambda: 'settings_screen::set("dark_mode", 0);'
- platform: template
id: setting_clock_24h
name: "24-hour clock"
icon: "mdi:clock-outline"
entity_category: config
restore_mode: DISABLED
lambda: return screen_settings::current.clock_24h;
turn_on_action:
- lambda: 'settings_screen::set("clock_24h", 1);'
turn_off_action:
- lambda: 'settings_screen::set("clock_24h", 0);'
- platform: template
id: setting_auto_home
name: "Back to page 1"
@@ -1461,7 +1494,7 @@ lvgl:
width: ${ALERT_TEXT_W}
height: ${ALERT_TITLE_H}
long_mode: DOT
text: "Notification"
text: !lambda 'return std::string(screen_text::tr(screen_text::txt::alert_notification));'
text_font: headline
clickable: false
- label:
@@ -1505,7 +1538,7 @@ lvgl:
- label:
id: alert_ok_label
align: CENTER
text: "OK"
text: !lambda 'return std::string(screen_text::tr(screen_text::txt::alert_ok));'
text_font: headline
clickable: false
@@ -2717,7 +2750,7 @@ lvgl:
id: climate_set_label
align: CENTER
y: ${CLIMATE_CAPTION_Y}
text: "Target"
text: !lambda 'return std::string(screen_text::tr(screen_text::txt::climate_target));'
text_font: sublabel
styles: paint_subtle
clickable: false
@@ -2912,7 +2945,7 @@ lvgl:
- label:
align: TOP_MID
y: ${PICKER_TITLE_Y}
text: "Mode"
text: !lambda 'return std::string(screen_text::tr(screen_text::txt::climate_mode));'
text_font: sublabel_big
clickable: false
@@ -2954,7 +2987,7 @@ lvgl:
widgets:
- label:
align: CENTER
text: "Off"
text: !lambda 'return std::string(screen_text::tr(screen_text::txt::ha_climate_off));'
text_font: sublabel_big
clickable: false
- obj:
@@ -2973,7 +3006,7 @@ lvgl:
widgets:
- label:
align: CENTER
text: "Heat"
text: !lambda 'return std::string(screen_text::tr(screen_text::txt::ha_climate_heat));'
text_font: sublabel_big
clickable: false
- obj:
@@ -2992,7 +3025,7 @@ lvgl:
widgets:
- label:
align: CENTER
text: "Cool"
text: !lambda 'return std::string(screen_text::tr(screen_text::txt::ha_climate_cool));'
text_font: sublabel_big
clickable: false
- obj:
@@ -3011,7 +3044,7 @@ lvgl:
widgets:
- label:
align: CENTER
text: "Heat/Cool"
text: !lambda 'return std::string(screen_text::tr(screen_text::txt::ha_climate_heat_cool));'
text_font: sublabel_big
clickable: false
- obj:
@@ -3030,7 +3063,7 @@ lvgl:
widgets:
- label:
align: CENTER
text: "Auto"
text: !lambda 'return std::string(screen_text::tr(screen_text::txt::ha_climate_auto));'
text_font: sublabel_big
clickable: false
- obj:
@@ -3049,7 +3082,7 @@ lvgl:
widgets:
- label:
align: CENTER
text: "Dry"
text: !lambda 'return std::string(screen_text::tr(screen_text::txt::ha_climate_dry));'
text_font: sublabel_big
clickable: false
- obj:
@@ -3068,7 +3101,7 @@ lvgl:
widgets:
- label:
align: CENTER
text: "Fan only"
text: !lambda 'return std::string(screen_text::tr(screen_text::txt::ha_climate_fan_only));'
text_font: sublabel_big
clickable: false
@@ -3076,7 +3109,7 @@ lvgl:
id: cfan_title
width: 100%
height: ${PICKER_TITLE_H}
text: "Fan"
text: !lambda 'return std::string(screen_text::tr(screen_text::txt::climate_fan));'
text_font: sublabel
styles: paint_subtle
text_align: CENTER
@@ -3165,7 +3198,7 @@ lvgl:
id: cswing_title
width: 100%
height: ${PICKER_TITLE_H}
text: "Swing"
text: !lambda 'return std::string(screen_text::tr(screen_text::txt::climate_swing));'
text_font: sublabel
styles: paint_subtle
text_align: CENTER
@@ -3310,7 +3343,7 @@ lvgl:
- label:
align: TOP_MID
y: ${TOUCH_TEST_TITLE_Y}
text: ${TOUCH_TEST_TITLE}
text: !lambda 'return std::string(screen_text::tr(screen_text::txt::${TOUCH_TEST_TITLE}));'
text_font: ${TOUCH_TEST_TITLE_FONT}
- obj:
x: ${TOUCH_TEST_INSET}
@@ -4192,6 +4225,11 @@ script:
}
# The screen's texts in the LANGUAGE substitution's language: the build puts that one language in the firmware
# (components/smart_display).
smart_display:
language: ${LANGUAGE}
# HA blueprint transport; never publishes the JSON command as an HA state.
text:
- platform: smart_display
+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}"
RUN pip install --no-cache-dir 'aiohttp>=3.12,<4' PyYAML
COPY app /app
# Every text of the app, the editor and the screens, one file per language (app 0.2.90).
COPY translations /app/translations
# The Update badge shows what's new from the changelog (app 0.2.73).
COPY CHANGELOG.md /app/CHANGELOG.md
WORKDIR /app
+9 -1
View File
@@ -182,6 +182,8 @@ NAME_SCREEN_FIRMWARE = ('Screen firmware', 'Schermfirmware')
NAME_GUITION_TYPE = ('Guition screen type', 'Guition schermtype')
NAME_DEVICE_NAME = ('Device name', 'Apparaatnaam')
NAME_IP_ADDRESS = ('IP address', 'IP-adres')
# The language a screen's firmware was built in (firmware 0.2.76+, app 0.2.90); older firmware speaks English.
NAME_SCREEN_LANGUAGE = ('Screen language',)
SCREEN_ENTITY_NAMES = frozenset(NAME_TILE_SETTINGS + NAME_SCREEN_FIRMWARE + NAME_GUITION_TYPE + NAME_DEVICE_NAME + NAME_IP_ADDRESS)
def parse_firmware(text):
@@ -304,7 +306,6 @@ SETTING_ENTITIES = {
'night_start': ('time', 'Night starts'),
'night_end': ('time', 'Night ends'),
'night_brightness': ('number', 'Night brightness'),
'clock_24h': ('switch', '24-hour clock'),
'auto_home': ('switch', 'Back to page 1'),
'auto_home_seconds': ('number', 'Back to page 1 after'),
'home_on_standby': ('switch', 'Back to page 1 on standby'),
@@ -315,6 +316,8 @@ SETTING_ENTITIES = {
}
# Entities firmware 0.2.49 added; one of them on a device means the screen owns its settings. The first five
# existed before, so they cannot tell.
# The 12 or 24 hours is Settings -> Language & region's since app 0.2.90, for every screen at once: firmware 0.2.76 has no
# "24-hour clock" entity any more, and the one of older firmware is only a marker.
OWNED_SETTINGS_MARKERS = frozenset(('Night mode', 'Night starts', 'Night ends', '24-hour clock', 'Back to page 1',
'Back to page 1 after', 'Back to page 1 on standby', 'Swipe between pages'))
ROTATION_OPTIONS = ('0°', '90°', '180°', '270°')
@@ -1485,6 +1488,7 @@ def discover_screens(registry, states, devices, areas):
return found
nodes = diagnostic(NAME_DEVICE_NAME, r'[a-z0-9][a-z0-9-]{0,30}')
addresses = diagnostic(NAME_IP_ADDRESS, r'\d{1,3}(\.\d{1,3}){3}')
languages = diagnostic(NAME_SCREEN_LANGUAGE, r'[a-z]{2,3}(-[A-Za-z0-9]{2,8})?')
screens = []
for item in registry:
eid = item['entity_id']
@@ -1502,6 +1506,7 @@ def discover_screens(registry, states, devices, areas):
'firmware_known': known_firmware(firmware, device.get('sw_version')),
'board': boards.get(item.get('device_id'), 'unknown'),
'node': nodes.get(item.get('device_id')), 'ip': addresses.get(item.get('device_id')),
'language': languages.get(item.get('device_id')),
'device': device.get('name') or '',
'area': area, 'online': state.get('state') not in (None, 'unknown', 'unavailable'),
'status': state.get('state', 'Not connected')})
@@ -1544,6 +1549,8 @@ def installation_yaml(data):
if not isinstance(friendly, str) or not friendly.strip() or len(friendly) > 60:
raise ValueError('Give the screen a recognizable name (60 characters max).')
quote = lambda s: json.dumps(s, ensure_ascii=False)
# The language of the screen's texts (app 0.2.90): Settings -> Language & region, which ESP Screens passes in.
language = data.get('language') if isinstance(data.get('language'), str) and re.fullmatch(r'[a-z]{2,3}(-[A-Za-z0-9]{2,8})?', data.get('language')) else 'en'
# An OTA password, not yet `ota: encryption:` with the api key: ESPHome before 2026.9 refuses that, and the owner's
# ESPHome Device Builder may still be older (docs/RELEASING.md, Compatibility 0.2.89).
key, ota, ap = base64.b64encode(secrets.token_bytes(32)).decode(), secrets.token_urlsafe(24), secrets.token_urlsafe(12)
@@ -1552,6 +1559,7 @@ def installation_yaml(data):
substitutions:
DEVICE_NAME: {quote(name)}
DEVICE_FRIENDLY_NAME: {quote(friendly.strip())}
LANGUAGE: {quote(language)}
esphome:
name: {quote(name)}
+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)
PROTECTED_OVERRIDE_KEYS = frozenset({'esphome', 'api', 'ota', 'wifi', 'packages',
'external_components', 'captive_portal'})
PROTECTED_SUBSTITUTIONS = frozenset({'DEVICE_NAME', 'DEVICE_FRIENDLY_NAME', 'SCREEN_FIRMWARE_VERSION'})
# LANGUAGE follows Settings -> Language & region (app 0.2.90), which writes it into the profile itself.
PROTECTED_SUBSTITUTIONS = frozenset({'DEVICE_NAME', 'DEVICE_FRIENDLY_NAME', 'SCREEN_FIRMWARE_VERSION', 'LANGUAGE'})
# ESPHome's "Factory format" (bootloader, partition table and app from address 0), the file ESPHome Web
# flashes on a board. A build writes it next to firmware.bin: under .pioenvs/<node>/ with PlatformIO,
# under build/ with ESPHome's native ESP-IDF toolchain.
@@ -187,6 +188,32 @@ class Firmware:
# The key is what Home Assistant asks for when pairing; the page shows it once.
return {'file': profile.name, 'node': data['name'], 'api_key': key}
def set_language(self, name, language):
"""Let the screen's next build speak `language` (app 0.2.90): the LANGUAGE line of the profile's substitutions,
changed or added without reformatting the user's YAML. A profile that doesn't build from ESP Screens' packages is
left alone, as is an English one without the line (English is the packages' own default). True when it changed."""
profile = self.profile(name)
text = profile.read_text()
if 'homeassistant_espscreen' not in text and 'packages/core.yaml' not in text:
return False
line = f' LANGUAGE: {json.dumps(language)}'
block = re.search(r'(?m)^substitutions:[ \t]*(?:#.*)?\n((?:[ \t]+.*\n|[ \t]*\n)*)', text)
if block and re.search(r'(?m)^[ \t]+LANGUAGE:', block.group(1)):
start = block.start(1)
body = re.sub(r'(?m)^[ \t]+LANGUAGE:.*$', line, block.group(1), count=1)
updated = text[:start] + body + text[block.end(1):]
elif language == 'en':
return False
elif block:
updated = text[:block.start(1)] + line + '\n' + text[block.start(1):]
else:
updated = f'substitutions:\n{line}\n\n' + text
if updated == text:
return False
self._atomic_write(profile, updated)
self._names.pop(profile.name, None)
return True
def _override_path(self, name):
profile = self.profile(name)
path = self.root / (profile.stem + self.OVERRIDE_SUFFIX)
+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;
# tests/test_header_bar.py keeps them equal). Anything else folds to its base letter or goes.
GLYPHS = frozenset("<>—&@!,.?\"%()+-_:°0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyzäöüÄÖÜß/…·'#*=;²³µéèëêïîóôàáâçñúû–")
# The characters the screen's text fonts carry (the sublabel_big glyph list; tests/test_header_bar.py keeps them equal):
# every letter European languages write with since app 0.2.90.
GLYPHS = frozenset('<>—&@!,.?"%()+-_:°0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz/…·\'#*=;²³µ–ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿĄąĆćĘꣳŃńŚśŹźŻżČčĎďĚěŇňŘřŠšŤťŮůŽžĹ弾ŔŕŐőŰűĂăȘșȚțĐđĀāĒēĢģĪīĶķĻļŅņŪūĖėĮįŲųĞğİıŞşĊċĠġĦħŴŵŶŷĿŀŒœŸ„“”‘’«»‹›¿¡€£')
TEXT_BYTES = 40
MONTHS = ('Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec')
UNAVAILABLE = '—'
+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
import header_bar
import history_card
import i18n
from i18n import REQUEST_LANGUAGE, TRANSLATIONS, Region, t
from zoneinfo import ZoneInfo
@@ -106,6 +108,12 @@ class HomeAssistant:
self.time_zone = None
# Home Assistant's unit system; a climate entity's temperature carries no unit of its own.
self.units = {}
# Home Assistant's own language (get_config), and the one the screens speak (Settings -> Language & region,
# app 0.2.90), in which the words for states are fetched; the manager binds `language_of`.
self.ha_language = None
self.language_of = lambda: 'en'
self.config_stale = False
self.on_config = lambda: None
# (event type, data) of alert events for every screen, for Manager.alert_loop.
self.broadcasts = asyncio.Queue()
# (event type, data) of tile events (app 0.2.51+), for Manager.tile_loop.
@@ -118,11 +126,12 @@ class HomeAssistant:
# 0.2.67); `services_rev` counts the refreshes. `targets` keeps Home Assistant's answer per entity, and
# `target_lookup` stays None until it is known whether Home Assistant answers get_services_for_target (2025.12+).
self.services, self.services_rev, self.targets, self.target_lookup = {}, 0, {}, None
# Home Assistant's own names and descriptions of actions and their fields (frontend/get_translations, English
# like the editor), for Perform action (app 0.2.67). `get_services` has carried no translated names since 2025.10.
self.service_names = {}
# Home Assistant's words for states (frontend/get_translations `entity_component` and `entity`, English), which a
# tile shows where it would show the raw state (app 0.2.67).
# Home Assistant's own names and descriptions of actions and their fields (frontend/get_translations), for Perform
# action (app 0.2.67); `get_services` has carried no translated names since 2025.10. Per language (app 0.2.90): the
# screens' one first, an editor in another language asks for its own (service_names_in).
self.service_names, self.service_names_by_language = {}, {}
# Home Assistant's words for states (frontend/get_translations `entity_component` and `entity`), in the screens'
# language (app 0.2.90), which a tile shows where it would show the raw state (app 0.2.67).
self.state_words = {}
self.esphome_services = False
self._platforms_source, self._platforms = None, {}
@@ -182,6 +191,10 @@ class HomeAssistant:
self.changed.set()
elif event.get('event_type') in REGISTRY_EVENTS:
self.registry_changed.set()
elif event.get('event_type') == 'core_config_updated':
# Settings -> System -> General in Home Assistant, such as its language (app 0.2.90).
self.config_stale = True
self.services_changed.set()
elif event.get('event_type') in SERVICE_EVENTS:
# Every action counts for what the editor offers (app 0.2.67); a screen's own actions also decide
# how the app talks to it.
@@ -216,12 +229,14 @@ class HomeAssistant:
if isinstance(services, dict):
self.services, self.services_rev, self.targets = services, self.services_rev + 1, {}
try:
names = await self.request('frontend/get_translations', language='en', category='services')
language = self.language_of()
names = await self.request('frontend/get_translations', language=language, category='services')
if isinstance((names or {}).get('resources'), dict):
self.service_names = names['resources']
self.service_names_by_language = {language: names['resources']}
words = {}
for category in ('entity_component', 'entity'):
found = await self.request('frontend/get_translations', language='en', category=category)
found = await self.request('frontend/get_translations', language=language, category=category)
if isinstance((found or {}).get('resources'), dict):
words.update(found['resources'])
if words:
@@ -237,6 +252,34 @@ class HomeAssistant:
# The editor then shows the names services.yaml still carries, and tiles keep their raw states.
LOG.info('No action names or state words from Home Assistant (%s)', type(error).__name__)
async def service_names_in(self, language):
"""Home Assistant's names of actions in `language` (the editor's), fetched once per language; the screens' own
when Home Assistant doesn't answer."""
if language == self.language_of() or not self.service_names_by_language:
return self.service_names
if language not in self.service_names_by_language:
try:
names = await self.request('frontend/get_translations', language=language, category='services')
self.service_names_by_language[language] = (names or {}).get('resources') or self.service_names
except (Refused, TimeoutError, ConnectionError):
return self.service_names
return self.service_names_by_language[language]
async def read_config(self):
"""Units, time zone and language from Home Assistant's core config; again when someone changes them there."""
self.config_stale = False
try:
config = await self.request('get_config')
self.units = config.get('unit_system') or {}
self.time_zone = ZoneInfo(config.get('time_zone') or 'UTC')
language = config.get('language')
changed = isinstance(language, str) and language != self.ha_language
self.ha_language = language if isinstance(language, str) else self.ha_language
if changed:
self.on_config()
except (Refused, TimeoutError, ConnectionError, ValueError, TypeError, AttributeError, KeyError):
self.time_zone = self.time_zone or timezone.utc
async def refresh_services(self):
"""fetch_services for the connection loop: a list that cannot be read only means no answers are asked for."""
try:
@@ -310,13 +353,10 @@ class HomeAssistant:
await self.request('subscribe_events', event_type=event_type)
self.states = {s['entity_id']: s for s in await self.request('get_states')}
await self.registries()
# The config first: its language decides the language of the words for states (app 0.2.90).
await self.request('subscribe_events', event_type='core_config_updated')
await self.read_config()
await self.refresh_services()
try:
config = await self.request('get_config')
self.units = config.get('unit_system') or {}
self.time_zone = ZoneInfo(config.get('time_zone') or 'UTC')
except Exception:
self.time_zone = timezone.utc
self.online = True
self.changed.set()
connected = time.monotonic()
@@ -337,6 +377,8 @@ class HomeAssistant:
# A screen that reconnects registers its actions one after the other.
await asyncio.sleep(1)
self.services_changed.clear()
if self.config_stale:
await self.read_config()
await self.refresh_services()
if self.esphome_services:
self.esphome_services = False
@@ -524,6 +566,11 @@ class Manager:
self.listeners = set() # asyncio.Event per open /api/events stream
self.firmware = Firmware(os.environ.get("ESPHOME_CONFIG", "/homeassistant/esphome"), self.path.parent)
self.updates = Updater(self, self.path.parent / 'updates.json')
# Settings -> Language & region (app 0.2.90): the language, clock and numbers of every screen.
self.region = Region(self.path.parent / 'language.json', ha_language=lambda: getattr(self.ha, 'ha_language', None))
self.ha.language_of = self.region.language
self.ha.on_config = self.language_changed
self.pin_clock = False
self.skill_dir = claude_skill.skill_dir()
self._registry_source, self._registry_index = None, {}
self._items_source, self._items = None, []
@@ -556,6 +603,12 @@ class Manager:
raise ValueError('Unknown storage version; data stays unchanged.')
# Loaded leniently: a tile setting this version doesn't know (saved by a newer one) never stops the app.
self.layouts = {key: validate_layout(value, stored=True) for key, value in raw['screens'].items()}
# An app from before Language & region (app 0.2.90): its screens keep the 24 hours they show; language_changed
# makes it 12 once Home Assistant says every screen's own clock setting was 12 hours.
if not self.region.stored and self.layouts:
self.region.clock = '24'
self.region.save()
self.pin_clock = True
def inventory(self):
"""(screens, every tile/top-bar entity): the full walk over the registry, for the editor and saving."""
@@ -1085,6 +1138,38 @@ class Manager:
for listener in self.listeners:
listener.set()
# ----- Language & region (app 0.2.90) -----
def language_changed(self):
"""The screens' language may have changed (Settings -> Language & region, or Home Assistant's own): the words for
states come again in it, every screen gets all its messages anew, and each screen's YAML gets the language its
next firmware update builds in, which makes that update show."""
if self.pin_clock:
self.pin_clock = False
clocks = [self.ha.states.get(item['entity_id'], {}).get('state') for item in getattr(self.ha, 'registry', [])
if item.get('platform') == 'esphome' and item.get('original_name') == '24-hour clock']
known = [state for state in clocks if state in ('on', 'off')]
if known and all(state == 'off' for state in known):
self.region.clock = '12'
self.region.save()
self.ha.services_changed.set()
self.sent.clear()
self.write_languages()
self.ha.changed.set()
self.notify()
def write_languages(self):
"""Every paired screen's YAML says the language of its next build (the LANGUAGE substitution)."""
language, profiles = self.region.language(), self.firmware.profile_names()
for screen in self.screens():
profile, _ = self.updates.resolve(screen, profiles)
if not profile:
continue
try:
if self.firmware.set_language(profile, language):
LOG.info('%s builds in %s from its next update', profile, language)
except (OSError, ValueError) as error:
LOG.warning('Could not write the language into %s (%s)', profile, error)
def pending_profiles(self, screens, profiles):
"""ESP Screens profiles without a paired screen: flashed but not yet added in Home Assistant, or not flashed yet.
@@ -1182,6 +1267,12 @@ class Manager:
message['auto_home_seconds'] = layout['settings'].get('auto_home_seconds',120)
if screen.get('board')=='guition':
message['rotation'] = layout['settings'].get('rotation',0)
# The clock and the number format of Settings -> Language & region (app 0.2.90), the same on every screen; firmware
# before 0.2.76 ignores both and keeps a clock setting of its own.
message['clock_24h'] = self.region.clock_24h()
message['numbers'] = self.region.number_style()
if 'settings' in message:
message['settings']['clock_24h'] = message['clock_24h']
# The revision the screen echoes on every ping (firmware 0.2.33+; older firmware ignores it).
message['rev'] = revision(message)
return message
@@ -1600,6 +1691,8 @@ def create_app(manager, development=False):
csrf = secrets.token_urlsafe(32)
@web.middleware
async def guard(request, handler):
# The editor's language for the messages this request answers with (app 0.2.90).
REQUEST_LANGUAGE.set(TRANSLATIONS.resolve(request.headers.get('X-ESP-Screens-Language') or 'en'))
allowed = {'127.0.0.1', '::1'} if development else {'172.30.32.2'}
if request.remote not in allowed:
raise web.HTTPForbidden(text='Open this page through Home Assistant.')
@@ -1665,7 +1758,8 @@ def create_app(manager, development=False):
screen.update(firmware_features(version))
return {'csrf': csrf, 'connected': manager.ha.online, 'screens': screens,
'pending': manager.pending_profiles(screens, profiles),
'updates': manager.updates.summary(screens, profiles)}
'updates': manager.updates.summary(screens, profiles),
'language': manager.region.view()}
async def inventory(request):
if request.query.get('light') == '1':
# The page polls the light form; entities, backgrounds and icons (~100 KB) only on demand.
@@ -1742,8 +1836,18 @@ def create_app(manager, development=False):
actions = await ha.entity_actions(entity) if entity_id(entity) and hasattr(ha, 'entity_actions') else None
if actions is None:
return web.json_response({'actions': None})
# Home Assistant's names in the editor's own language (app 0.2.90).
names = await ha.service_names_in(REQUEST_LANGUAGE.get()) if hasattr(ha, 'service_names_in') else getattr(ha, 'service_names', {})
return web.json_response({'actions': ha_catalogue.action_choices(entity, actions, ha.states.get(entity), ha.services,
getattr(ha, 'service_names', {}), ha.platform_of(entity))})
names, ha.platform_of(entity))})
async def change_language(request):
"""Settings -> Language & region (app 0.2.90): the language, clock and number format of every screen."""
data = await request.json()
before = (manager.region.language(), manager.region.clock_24h(), manager.region.number_style())
manager.region.change({key: data[key] for key in ('setting', 'clock', 'numbers') if isinstance(data, dict) and key in data})
if before != (manager.region.language(), manager.region.clock_24h(), manager.region.number_style()):
manager.language_changed()
return web.json_response({'language': manager.region.view()})
async def change_settings(request):
"""Screen settings apply one change at a time, like the settings page on the screen (app 0.2.57)."""
data = await request.json()
@@ -1834,8 +1938,12 @@ def create_app(manager, development=False):
raise ValueError('Invalid override data.')
return web.json_response(manager.firmware.save_override(request.match_info['file'], data.get('content')))
async def firmware_create(request):
# Profile, missing wifi secrets and (with a USB port or the download) the build and flash in one request.
return web.json_response(manager.firmware.install(await request.json()))
# Profile, missing wifi secrets and (with a USB port or the download) the build and flash in one request; the
# screen speaks the language of Settings -> Language & region from its first build (app 0.2.90).
data = await request.json()
if isinstance(data, dict):
data['language'] = manager.region.language()
return web.json_response(manager.firmware.install(data))
async def firmware_download(request):
"""New screen and Firmware & USB → Download: the factory image this app just built, for ESPHome Web on
the owner's own computer. Like the profile it came from, it holds the Wi-Fi password and the screen's keys."""
@@ -1852,6 +1960,7 @@ def create_app(manager, development=False):
app.router.add_post('/api/screens/{inbox}/update', update_screen)
app.router.add_post('/api/updates/run', update_all)
app.router.add_put('/api/updates', update_settings)
app.router.add_put('/api/language', change_language)
app.router.add_get('/api/firmware', firmware_status)
app.router.add_post('/api/firmware/jobs', firmware_start)
app.router.add_get('/api/firmware/profiles/{file}/override', firmware_override)
+10 -2
View File
@@ -87,8 +87,15 @@ class Updater:
profile = matches[0] if len(matches) == 1 else None
return profile, screen.get('ip') or self.hosts.get(screen['id'])
def language_due(self, screen):
"""True when a screen speaks another language than Settings -> Language & region says (app 0.2.90): its
"Screen language" sensor, or English for firmware from before that sensor."""
region = getattr(self.manager, 'region', None)
return bool(region) and (screen.get('language') or 'en') != region.language()
def state_for(self, screen, profiles=None):
version = parse_version(screen.get('firmware'))
language = self.language_due(screen)
profile, host = self.resolve(screen, profiles)
if screen['id'] == self.current:
state = 'running'
@@ -96,7 +103,8 @@ class Updater:
state = 'queued'
else:
state = 'idle'
return {'available': bool(version and TARGET and version < TARGET), 'target': FIRMWARE_VERSION,
return {'available': bool(version and TARGET and version < TARGET) or (bool(version) and language),
'language': bool(version) and language, 'target': FIRMWARE_VERSION,
'profile': profile, 'host': host, 'state': state, 'phase': self.phase if state == 'running' else None,
'result': self.results.get(screen['id'])}
@@ -221,7 +229,7 @@ class Updater:
while time.monotonic() < deadline:
screen = self.screen(inbox)
version = parse_version(screen.get('firmware')) if screen else None
if screen and screen['online'] and version and version >= TARGET:
if screen and screen['online'] and version and version >= TARGET and not self.language_due(screen):
return True
await asyncio.sleep(self.poll_seconds)
return False
+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": {
"name": "English",
"english": "English",
"name": "English (US)",
"english": "English (US)",
"script": "latin",
"plural": "one_other",
"clock": "12",
"checked": true
},
"screen": {},
"addon": {},
"screen": {
"number": {
"decimal": ".",
"group": ","
},
"time": {
"just_now": "Just now",
"minutes_ago": "{n} min ago",
"hours_ago": "1 hour ago | {n} hours ago",
"yesterday": "Yesterday",
"days_ago": "1 day ago | {n} days ago",
"weeks_ago": "1 week ago | {n} weeks ago",
"months_ago": "1 month ago | {n} months ago",
"years_ago": "1 year ago | {n} years ago",
"in_minutes": "In {n} min",
"in_hours": "In 1 hour | In {n} hours",
"tomorrow": "Tomorrow",
"in_days": "In 1 day | In {n} days",
"am": "AM",
"pm": "PM"
},
"date": {
"weekdays": [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday"
],
"weekdays_short": [
"Sun",
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat"
],
"weekdays_min": [
"Su",
"Mo",
"Tu",
"We",
"Th",
"Fr",
"Sa"
],
"months": [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December"
],
"months_short": [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec"
],
"top_bar": "{weekday} {day} {month}",
"full": "{weekday} {day} {month}",
"day_month": "{day} {month}",
"weekday_time": "{weekday} {time}"
},
"duration": {
"seconds": "{n} s",
"minutes": "{n} min",
"hours": "{n} h",
"hours_minutes": "{h} h {m} min",
"days": "{n} d",
"days_hours": "{d} d {h} h"
},
"status": {
"connecting": "Connecting to Home Assistant",
"waiting": "Waiting for ESP Screens",
"loading_tiles": "Loading tiles...",
"ha_not_connected": "HA not connected",
"manager_not_active": "ESP Screens not active",
"choose_tiles": "Choose tiles in HA",
"home": "Home",
"no_network": "No network"
},
"settings": {
"title": "Settings",
"brightness": "Brightness",
"night": "Night",
"screen": "Screen",
"this_screen": "This screen",
"dark_mode": "Dark mode",
"auto_standby": "Auto standby",
"standby_after": "Standby after",
"standby_brightness": "Standby brightness",
"night_mode": "Night mode",
"starts": "Starts",
"ends": "Ends",
"night_brightness": "Night brightness",
"back_to_page_1": "Back to page 1",
"after": "After",
"also_on_standby": "Also on standby",
"swipe_between_pages": "Swipe between pages",
"page_buttons": "Page buttons",
"rotation": "Rotation",
"address": "Address",
"firmware": "Firmware",
"home_assistant": "Home Assistant",
"restart": "Restart",
"tap_again_to_restart": "Tap again to restart",
"seconds": "{n} sec",
"minutes": "{n} min",
"hours": "{n} h",
"hours_minutes": "{h} h {m}"
},
"tile": {
"tap_to_open": "Tap to open",
"page": "Page {n}",
"unavailable": "Unavailable",
"refused": "Refused",
"command_sent": "Command sent...",
"confirmed": "Confirmed by Home Assistant"
},
"alert": {
"notification": "Notification",
"ok": "OK"
},
"light": {
"color": "Color",
"color_temperature": "Color temperature",
"brightness": "Brightness",
"waiting": "Waiting..."
},
"effects": {
"effect": "Effect",
"nothing": "Nothing to choose",
"loading": "Loading...",
"not_connected": "Not connected"
},
"climate": {
"target": "Target",
"mode": "Mode",
"fan": "Fan",
"swing": "Swing",
"now": "Now {value}"
},
"vacuum": {
"check_robot": "Check the robot in HA",
"auto_suction": "Automatic suction power",
"robot_chooses": "The robot chooses suction and water",
"per_room": "Suction and water as set per room",
"speed_quiet": "Quiet",
"speed_balanced": "Normal",
"speed_turbo": "Turbo",
"speed_max": "Max",
"pause_cleaning": "Pause cleaning",
"pause": "Pause",
"resume": "Resume",
"start_cleaning": "Start cleaning",
"clean": "Clean",
"dock": "Dock"
},
"cover": {
"position": "Position",
"tilt": "Tilt",
"tilt_value": "Tilt {n}%"
},
"weather": {
"feels_like": "Feels like {n}°",
"no_forecast": "No daily forecast from Home Assistant",
"coming_days": "Coming days"
},
"history": {
"no_data": "No data",
"average": "average",
"average_of": "average of {duration}",
"now": "Now",
"range_hour": "1 hour",
"range_day": "24 hours",
"range_week": "1 week",
"high": "High {value}",
"low": "Low {value}",
"times": "once | {n} times",
"loading": "Loading history...",
"empty": "No history in this period",
"unavailable": "No history available"
},
"media": {
"muted": "Muted",
"not_playing": "Not playing",
"loading": "Loading"
},
"timer": {
"running": "Running",
"paused": "Paused",
"stopped": "Stopped",
"pause": "Pause",
"start": "Start",
"cancel": "Cancel",
"paused_left": "Paused {time}"
},
"sun": {
"name": "Sun",
"sunrise": "Sunrise {time}",
"sunset": "Sunset {time}",
"rise": "rise {time}",
"set": "set {time}"
},
"script": {
"running": "Running...",
"never_run": "Never run",
"last_time": "Last {time}",
"yesterday_time": "Yesterday {time}",
"last_date": "Last {date}"
},
"camera": {
"no_image": "No image from this camera",
"loading": "Loading image",
"live": "Live",
"recording": "Recording",
"tap_to_view": "Tap to view",
"no_image_yet": "No image yet"
},
"button": {
"activate": "Activate",
"run": "Run",
"press": "Press"
},
"calibration": {
"title": "Follow the calibration wizard",
"touch_check": "GT911 touch check",
"hold": "Hold each tap for a moment",
"crosshair": "Tap the crosshair\nPoint {point}/5 - tap {tap}/3",
"gently": "Tap gently, a bit longer",
"same": "Tap the same crosshair 3 times",
"rejected": "Measurement rejected; try again",
"save_failed": "Save failed; try again"
},
"ha": {
"on": "On",
"off": "Off",
"climate": {
"off": "Off",
"heat": "Heat",
"cool": "Cool",
"heat_cool": "Heat/Cool",
"auto": "Auto",
"dry": "Dry",
"fan_only": "Fan only"
},
"hvac_action": {
"heating": "Heating",
"cooling": "Cooling",
"idle": "Idle",
"off": "Off",
"drying": "Drying",
"fan": "Fan",
"preheating": "Preheating",
"defrosting": "Defrosting"
},
"cover": {
"open": "Open",
"closed": "Closed",
"opening": "Opening",
"closing": "Closing"
},
"media": {
"playing": "Playing",
"paused": "Paused",
"idle": "Idle",
"standby": "Standby"
},
"person": {
"home": "Home",
"not_home": "Away"
},
"sun": {
"above_horizon": "Above the horizon",
"below_horizon": "Below the horizon"
},
"vacuum": {
"docked": "Docked",
"cleaning": "Cleaning",
"paused": "Paused",
"returning": "Returning to dock",
"idle": "Idle"
},
"weather": {
"sunny": "Sunny",
"clear_night": "Clear, night",
"cloudy": "Cloudy",
"partlycloudy": "Partly cloudy",
"rainy": "Rainy",
"pouring": "Pouring",
"snowy": "Snowy",
"snowy_rainy": "Snowy, rainy",
"fog": "Fog",
"hail": "Hail",
"lightning": "Lightning",
"lightning_rainy": "Lightning, rainy",
"windy": "Windy",
"exceptional": "Exceptional"
},
"binary": {
"battery_on": "Low",
"battery_off": "Normal",
"battery_charging_on": "Charging",
"battery_charging_off": "Not charging",
"carbon_monoxide_on": "Danger",
"carbon_monoxide_off": "Safe",
"cold_on": "Cold",
"cold_off": "Normal",
"connectivity_on": "Connected",
"connectivity_off": "Disconnected",
"door_on": "Open",
"door_off": "Closed",
"garage_door_on": "Open",
"garage_door_off": "Closed",
"gas_on": "Danger",
"gas_off": "Safe",
"heat_on": "Hot",
"heat_off": "Normal",
"light_on": "Light",
"light_off": "Dark",
"lock_on": "Open",
"lock_off": "Locked",
"moisture_on": "Wet",
"moisture_off": "Dry",
"motion_on": "Motion",
"motion_off": "No motion",
"moving_on": "Moving",
"moving_off": "Still",
"occupancy_on": "Occupied",
"occupancy_off": "Clear",
"opening_on": "Open",
"opening_off": "Closed",
"plug_on": "Plugged in",
"plug_off": "Unplugged",
"power_on": "On",
"power_off": "Off",
"presence_on": "Home",
"presence_off": "Away",
"problem_on": "Problem",
"problem_off": "OK",
"running_on": "Active",
"running_off": "Inactive",
"safety_on": "Unsafe",
"safety_off": "Safe",
"smoke_on": "Smoke",
"smoke_off": "No smoke",
"sound_on": "Sound",
"sound_off": "Silent",
"tamper_on": "Tampering",
"tamper_off": "OK",
"update_on": "Update",
"update_off": "Up to date",
"vibration_on": "Vibration",
"vibration_off": "Still",
"window_on": "Open",
"window_off": "Closed"
}
}
},
"addon": {
"errors": {
"language_choice": "Choose a language from the list.",
"region_choice": "Choose a clock and a number format from the lists."
}
},
"editor": {}
}
+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/cyd_ui.h"
#include <cassert>
+1
View File
@@ -1,3 +1,4 @@
#include "screen_text_en.h"
#define THEME_TEST
#include "components/smart_display/alert_overlay.h"
#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
its table equal to the add-on's `header_bar.BINARY_STATES`, which the top bar and the history card use.
"""
import json
import re
import sys
import unittest
@@ -22,9 +23,17 @@ TILES = (ROOT / 'components/smart_display/runtime_tiles.h').read_text()
PROFILES = ('guition-4848s040.yaml', 'home-like-2432s028.yaml', 'packages/cyd.yaml', 'packages/guition.yaml')
ENGLISH = json.loads((ROOT / 'screen_manager/translations/en.json').read_text(encoding='utf-8'))
def firmware_words():
"""The firmware's table (app 0.2.90: keys into screen.ha.binary, in the screen's language) in English."""
block = re.search(r'constexpr BinaryWords BINARY_WORDS\[\] = \{(.*?)\n\};', CONTROLS, re.S)
return {device_class: (on, off) for device_class, on, off in re.findall(r'\{"(\w+)", "([^"]*)", "([^"]*)"\}', block.group(1))}
words = ENGLISH['screen']['ha']['binary']
found = {}
for device_class, on, off in re.findall(r'\{"(\w+)", screen_text::txt::ha_binary_(\w+), screen_text::txt::ha_binary_(\w+)\}', block.group(1)):
found[device_class] = (words[on], words[off])
return found
class BinaryWords(unittest.TestCase):
@@ -41,7 +50,8 @@ class BinaryWords(unittest.TestCase):
for attrs in ({}, {'device_class': 'future_class'}):
self.assertEqual(header_bar.value('binary_sensor.x', {'state': 'on', 'attributes': attrs}), ('On', None))
self.assertEqual(history_card.state_label('binary_sensor', 'off', attrs), 'Off')
self.assertIn('return on ? "On" : "Off";', CONTROLS)
self.assertIn('return screen_text::tr(on ? screen_text::txt::ha_on : screen_text::txt::ha_off);', CONTROLS)
self.assertEqual((ENGLISH['screen']['ha']['on'], ENGLISH['screen']['ha']['off']), ('On', 'Off'))
def test_the_state_message_already_carries_the_class(self):
tile = {'entity': 'binary_sensor.front_door', 'name': ''}
+1
View File
@@ -1,3 +1,4 @@
#include "screen_text_en.h"
#include "../components/smart_display/calibration_math.h"
#include <cassert>
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
#include "../components/smart_display/camera_view.h"
#include <cassert>
+1
View File
@@ -1,3 +1,4 @@
#include "screen_text_en.h"
#include "../components/smart_display/chunk_transport.h"
#include <cassert>
int main() {
+1
View File
@@ -1,3 +1,4 @@
#include "screen_text_en.h"
#include "../components/smart_display/cyd_ui.h"
#include <cassert>
#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
// 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
+1
View File
@@ -1,3 +1,4 @@
#include "screen_text_en.h"
#include "components/smart_display/header_bar.h"
#include <cassert>
#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
FIRMWARE = (ROOT / 'components/smart_display/header_bar.h').read_text()
# The firmware's words live in the translations since app 0.2.90 (screen.time in English, the language of reference).
FIRMWARE += json.dumps(json.loads((ROOT / 'screen_manager/translations/en.json').read_text(encoding='utf-8'))['screen']['time'])
TILES = (ROOT / 'components/smart_display/runtime_tiles.h').read_text()
EDITOR = (ROOT / 'web/src/model/topbar.ts').read_text()
PROFILES = ('guition-4848s040.yaml', 'home-like-2432s028.yaml')
@@ -109,8 +111,10 @@ class TextTests(unittest.TestCase):
self.assertEqual(header_bar.value(eid, value, registry.get(eid), {'temperature': '°C'}, tz), expected, eid)
def test_text_keeps_to_the_font_and_its_length(self):
self.assertEqual(header_bar.clean_text('Café “Sun” 25 m³'), 'Café Sun 25 m³')
self.assertEqual(header_bar.clean_text('Ångström ✓'), 'Angström')
# Every letter European languages write with stays (app 0.2.90); anything else folds to its base letter or goes.
self.assertEqual(header_bar.clean_text('Café “Sun” 25 m³'), 'Café “Sun” 25 m³')
self.assertEqual(header_bar.clean_text('Ångström ✓'), 'Ångström')
self.assertEqual(header_bar.clean_text('Ǻngström'), 'Angström')
self.assertLessEqual(len(header_bar.clean_text('é' * 60).encode()), header_bar.TEXT_BYTES)
def test_active_colour_and_icon(self):
@@ -228,10 +232,10 @@ class ParityTests(unittest.TestCase):
# Firmware 0.2.73+: until the first layout the text and a spinner stand in the middle and the name stays empty;
# the first layout deletes them, so no spinner turns behind the tiles.
render = TILES.split('inline void render(lv_obj_t *room) {', 1)[1].split('\n}', 1)[0]
self.assertIn('if (!model.configured) boot_status(lv_obj_get_parent(room), !ha_connected() ? '
'"Connecting to Home Assistant" : "Waiting for ESP Screens");', render)
self.assertIn('if (!model.configured) boot_status(lv_obj_get_parent(room), tr(!ha_connected() ? '
'txt::status_connecting : txt::status_waiting));', render)
self.assertIn('else if (boot_panel) { lv_obj_delete(boot_panel);', render)
self.assertIn('label(room, !model.configured ? "" :', render)
self.assertIn('label(room, !model.configured ? std::string() :', render)
boot = TILES.split('inline void boot_status(', 1)[1].split('\n}', 1)[0]
self.assertIn('boot_spinner = spinner_create(boot_panel,', boot)
# Under the tiles, the cards and an alert, in the name's place.
+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
#include "../components/smart_display/history_view.h"
#include <cassert>
+1
View File
@@ -1,3 +1,4 @@
#include "screen_text_en.h"
#define LIGHT_CONTROLS_TEST
#include "../components/smart_display/light_controls.h"
#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
#include "../components/smart_display/media_card.h"
#include <cassert>
+1
View File
@@ -1,3 +1,4 @@
#include "screen_text_en.h"
#include "../components/smart_display/runtime_model.h"
#include <cassert>
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',
'night_enabled': 'switch.office_1_night_mode', 'night_start': 'time.office_1_night_starts',
'night_end': 'time.office_1_night_ends', 'night_brightness': 'number.office_1_night_brightness',
'clock_24h': 'switch.office_1_24_hour_clock', 'auto_home': 'switch.office_1_back_to_page_1',
'auto_home': 'switch.office_1_back_to_page_1',
'auto_home_seconds': 'number.office_1_back_to_page_1_after', 'home_on_standby': 'switch.office_1_back_to_page_1_on_standby',
'swipe_pages': 'switch.office_1_swipe_between_pages', 'rotation': 'select.office_1_rotation',
'dark_mode': 'switch.office_1_dark_mode', 'page_buttons': 'switch.office_1_page_buttons',
}
STATES = {'brightness': '80.0', 'standby_enabled': 'on', 'standby_seconds': '600.0', 'standby_brightness': '20.0',
'night_enabled': 'on', 'night_start': '22:30:00', 'night_end': '07:00:00', 'night_brightness': '5.0',
'clock_24h': 'on', 'auto_home': 'on', 'auto_home_seconds': '120.0', 'home_on_standby': 'off',
'auto_home': 'on', 'auto_home_seconds': '120.0', 'home_on_standby': 'off',
'swipe_pages': 'on', 'rotation': '90°', 'dark_mode': 'off', 'page_buttons': 'on'}
@@ -109,7 +109,8 @@ def fake_ha(firmware=FIRMWARE_VERSION, owned=True, guition=True):
class CoreSettings(unittest.TestCase):
def test_every_setting_the_editor_offers_has_an_entity(self):
self.assertEqual(set(SETTING_ENTITIES), set(SETTING_RULES) - {'show_clock'})
# 12 or 24 hours is Settings -> Language & region's, for every screen at once (app 0.2.90).
self.assertEqual(set(SETTING_ENTITIES), set(SETTING_RULES) - {'show_clock', 'clock_24h'})
self.assertEqual(SETTING_ENTITIES_MIN_FIRMWARE, '0.2.49')
self.assertLessEqual(tuple(map(int, SETTING_ENTITIES_MIN_FIRMWARE.split('.'))), tuple(map(int, FIRMWARE_VERSION.split('.'))))
@@ -179,7 +180,7 @@ class OwnedSettings(unittest.IsolatedAsyncioTestCase):
self.assertNotIn('settings', m.layouts['text.screen'], 'a screen that owns its settings has none stored')
view = m.settings_view(m.screen('text.screen'))
self.assertEqual((view['owner'], view['unavailable']), ('screen', []))
self.assertEqual(view['keys'], [key for key in SETTING_RULES if key != 'show_clock'])
self.assertEqual(view['keys'], [key for key in SETTING_RULES if key not in ('show_clock', 'clock_24h')])
self.assertEqual({key: view['values'][key] for key in ('brightness', 'night_start', 'rotation', 'home_on_standby')},
{'brightness': 80, 'night_start': 1350, 'rotation': 90, 'home_on_standby': False})
m.ha.states[ENTITY_IDS['night_end']] = {'state': 'unavailable'}
@@ -225,12 +226,11 @@ class OwnedSettings(unittest.IsolatedAsyncioTestCase):
await m.change_settings('text.screen', {'brightness': 10})
self.assertEqual(m.ha.calls, [('number.set_value', {'entity_id': ENTITY_IDS['brightness'], 'value': 10})])
m.ha.calls.clear()
await m.change_settings('text.screen', {'night_start': 1320, 'rotation': 270, 'clock_24h': False})
await m.change_settings('text.screen', {'night_start': 1320, 'rotation': 270})
self.assertEqual(m.ha.calls, [('time.set_value', {'entity_id': ENTITY_IDS['night_start'], 'time': '22:00:00'}),
('switch.turn_off', {'entity_id': ENTITY_IDS['clock_24h']}),
('select.select_option', {'entity_id': ENTITY_IDS['rotation'], 'option': '270°'})])
m.ha.calls.clear()
for bad in ({'standby_brightness': 95}, {'show_clock': False}, {'beep': True}, {}, None, {'brightness': '50'}):
for bad in ({'standby_brightness': 95}, {'show_clock': False}, {'clock_24h': False}, {'beep': True}, {}, None, {'brightness': '50'}):
with self.assertRaises(ValueError):
await m.change_settings('text.screen', bad)
m.ha.states[ENTITY_IDS['night_end']] = {'state': 'unavailable'}
@@ -569,9 +569,12 @@ class Editor(unittest.TestCase):
def test_the_rows_are_the_settings_of_the_screen_page(self):
keys = re.findall(r'\{ key: "(\w+)", label: "[^"]+", kind: "(\w+)"', self.script)
self.assertEqual([key for key, _ in keys], [key for key in SETTING_RULES if key != 'show_clock'][:0] or [key for key, _ in keys])
self.assertEqual({key for key, _ in keys}, set(SETTING_RULES) - {'show_clock'})
# 12 or 24 hours is Settings -> Language & region's, for every screen at once (app 0.2.90).
self.assertEqual({key for key, _ in keys}, set(SETTING_RULES) - {'show_clock', 'clock_24h'})
# Labels and steps as on the screen: the same rows, the same -/+ steps, the same duration ladder.
page_labels = set(re.findall(r'(?:number|toggle|duration|moment|choice)\("([^"]+)"', SCREEN_PAGE))
# The page's labels are keys into the translations since app 0.2.90; English is the reference.
english = json.loads((ROOT / 'screen_manager/translations/en.json').read_text(encoding='utf-8'))['screen']['settings']
page_labels = {english[key] for key in re.findall(r'(?:number|toggle|duration|moment|choice)\(screen_text::txt::settings_(\w+)', SCREEN_PAGE)}
groups = self.script[self.script.index('const SETTING_GROUPS = ['):self.script.index('export type SettingRow')]
script_labels = set(re.findall(r'label: "([^"]+)"', groups))
self.assertEqual(page_labels, script_labels)
+1
View File
@@ -1,3 +1,4 @@
#include "screen_text_en.h"
#include "components/smart_display/screen_settings.h"
#include <cassert>
int main() {
+14 -12
View File
@@ -1,4 +1,5 @@
#define SETTINGS_SCREEN_TEST
#include "screen_text_en.h"
#include "../components/smart_display/settings_screen.h"
#include <cassert>
#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.
static const Row &row_named(const Page &page, const std::string &label) {
for (uint8_t i = 0; i < page.count; ++i)
if (label == page.rows[i].label) return page.rows[i];
if (label == label_text(page.rows[i])) return page.rows[i];
assert(false);
return page.rows[0];
}
@@ -17,7 +18,7 @@ int main() {
// ---- the duration ladder: seconds low down, quarters of an hour up top, and symmetric ----
assert(ladder_step(60) == 30 && ladder_step(600) == 60 && ladder_step(1800) == 300);
assert(ladder_step(3600) == 900 && ladder_step(36000) == 1800);
Row timeout = duration("After", nullptr, nullptr, 30, 3600);
Row timeout = duration(screen_text::txt::settings_after, nullptr, nullptr, 30, 3600);
assert(stepped(timeout, 120, 1) == 150 && stepped(timeout, 120, -1) == 90);
assert(stepped(timeout, 300, 1) == 360 && stepped(timeout, 360, -1) == 300);
assert(stepped(timeout, 300, -1) == 270 && stepped(timeout, 270, 1) == 300);
@@ -25,9 +26,9 @@ int main() {
assert(at_end(timeout, 30, -1) && at_end(timeout, 3600, 1) && !at_end(timeout, 120, -1));
// ---- a number stops at its ends, a moment walks around midnight ----
Row percent = number("Brightness", nullptr, nullptr, 5, 100, 5, "%");
Row percent = number(screen_text::txt::settings_brightness, nullptr, nullptr, 5, 100, 5, "%");
assert(stepped(percent, 100, 1) == 100 && stepped(percent, 5, -1) == 5 && stepped(percent, 95, 1) == 100);
Row start = moment("Starts", nullptr, nullptr);
Row start = moment(screen_text::txt::settings_starts, nullptr, nullptr);
assert(stepped(start, 1320, 1) == 1335 && stepped(start, 1425, 1) == 0 && stepped(start, 0, -1) == 1425);
assert(!at_end(start, 0, -1));
// Holding the key walks whole hours, and first lands on the hour it is in.
@@ -64,7 +65,7 @@ int main() {
assert(screen_settings::current.standby_brightness == 30); // never above the normal brightness
assert(screen_settings::current.valid());
// Dark mode (firmware 0.2.54+) sits right under Brightness, a switch of its own outside the frozen block.
assert(light.count == 5 && light.rows[1].kind == Kind::toggle && std::string(light.rows[1].label) == "Dark mode");
assert(light.count == 5 && light.rows[1].kind == Kind::toggle && std::string(label_text(light.rows[1])) == "Dark mode");
const Row &dark = row_named(light, "Dark mode");
assert(value_text(dark) == "Off");
dark.write(1);
@@ -84,10 +85,11 @@ int main() {
buttons.write(1);
assert(page_buttons == 1);
}
const Row &clock = row_named(screen, "Clock");
assert(clock.option_count == 2 && value_text(clock) == "24 hour");
clock.write(0);
assert(screen_settings::current.clock_24h == 0 && value_text(clock) == "12 hour");
// 12 or 24 hours is chosen for every screen at once in ESP Screens (app 0.2.90): the page has no row for it, and
// the value still arrives through set() and the layout message.
for (uint8_t i = 0; i < screen.count; ++i) assert(std::string(label_text(screen.rows[i])) != "Clock");
set("clock_24h", 0);
assert(screen_settings::current.clock_24h == 0);
const Row &home = row_named(screen, "Back to page 1");
home.write(0);
assert(auto_home == 0 && value_text(home) == "Off");
@@ -107,17 +109,17 @@ int main() {
assert(PAGE_COUNT == 5);
for (uint8_t p = 0; p < PAGE_COUNT; ++p) {
const Page &page = pages[p];
assert(page.title && page.count && page.count <= 8);
assert(page.title != NO_TEXT && *screen_text::tr(page.title) && page.count && page.count <= 8);
for (uint8_t i = 0; i < page.count; ++i) {
const Row &row = page.rows[i];
assert(row.label && *row.label);
assert(row.label != NO_TEXT && *label_text(row));
if (row.kind == Kind::page) assert(row.opens > 0 && row.opens < PAGE_COUNT && *row.icon);
if (row.kind == Kind::toggle || row.kind == Kind::choice || row.kind == Kind::number ||
row.kind == Kind::duration || row.kind == Kind::moment)
assert(row.read && row.write);
if (row.kind == Kind::number) assert(row.step > 0 && row.high > row.low);
if (row.kind == Kind::duration) assert(row.high > row.low);
if (row.kind == Kind::choice) assert(row.options && row.option_count);
if (row.kind == Kind::choice) assert((row.options || row.option_keys != NO_TEXT) && row.option_count);
if (row.kind == Kind::info) assert(row.text);
if (row.kind == Kind::action) assert(row.run && *row.icon);
}
+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.
// clang++ -std=c++17 -Wall -Wextra -Werror -I. tests/test_theme.cpp -o /tmp/test_theme && /tmp/test_theme
#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.assertLessEqual(tuple(map(int, DARK_MODE_MIN_FIRMWARE.split('.'))), tuple(map(int, FIRMWARE_VERSION.split('.'))))
page = (COMPONENT / 'settings_screen.h').read_text()
self.assertIn('toggle("Dark mode", []() -> int32_t { return dark_mode; },', page)
self.assertIn('toggle(screen_text::txt::settings_dark_mode, []() -> int32_t { return dark_mode; },', page)
self.assertIn('else if (key == "dark_mode") reported = dark_mode = flag(value);', page)
editor = (ROOT / 'web/src/store.ts').read_text()
self.assertIn('{ key: "dark_mode", label: "Dark mode", kind: "toggle" },', editor)
+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
#define THEME_TEST
#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 <cassert>
int main() {
+1
View File
@@ -1,3 +1,4 @@
#include "screen_text_en.h"
#define THEME_TEST
#include "components/smart_display/tile_palette.h"
#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())