Notation like Home Assistant, each screen in its own language, and the language in every build

From the end-to-end test on the bench boards and an independent review:
- Units spaced as Home Assistant does (54% / 54 % in German and French, 18°, 21.5 °C), in the firmware and the app.
- Grouping from five digits where the language does (1234 but 12.345 in Spanish, Polish, Portuguese): the layout
  message carries group_min and percent_space; an unknown number style no longer refuses a layout.
- Automatic follows Home Assistant's own language for the clock and numbers, also without a translation (Swedish:
  24 hours, 1 234,5; pt-BR: 1.234,5): regions.json from the CLDR for all 63 languages Home Assistant offers.
- 12-hour clock: the top bar says 3:48 PM, the clock card 3:48 (not 03:48), Last runs follow the clock.
- The climate setpoint font has the decimal comma; the value fonts have ², ³ and µ (m³ was a box).
- Weather days: at most three letters without the dot (mié, sáb, czw); the screen took three bytes.
- Texts fall back per key through the base language (pt-BR -> pt -> en) in the firmware and the app.
- Each screen gets the app's words in the language its firmware speaks; firmware from before the languages gets
  English and its own letters, not boxes.
- set_language re-reads what it writes (indentation, comments, quoted keys, CRLF, !include) and every build writes
  the language first; the 24-hour pin no longer hits a fresh install and waits for the old clock switches.
- Tests: region and notation, plural parity with the C++ rules, set_language edge cases, the pin, PUT /api/language.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-19 16:52:34 +02:00
parent ad8a8ccf56
commit 125346d94e
37 changed files with 1419 additions and 448 deletions
+1 -1
View File
@@ -128,7 +128,7 @@ https://github.com/user-attachments/assets/f9f6a933-d388-4c8b-9c9a-011dfa37617b
</p>
<p align="center"><sub>A top bar built from your own home, firmware updates over Wi-Fi (every night if you like), and a skill so Claude can rearrange your screens.</sub></p>
<p align="center">
<img src="docs/images/editor-screen-settings.png" width="98%" alt="The Screen settings tab in ESP Screens: Brightness with Dark mode and standby, Night with its hours and brightness, and Screen with the clock, back to page 1, swiping, the page buttons and the rotation">
<img src="docs/images/editor-screen-settings.png" width="98%" alt="The Screen settings tab in ESP Screens: Brightness with Dark mode and standby, Night with its hours and brightness, and Screen with back to page 1, swiping, the page buttons and the rotation">
</p>
<p align="center"><sub>Screen settings apply at once, and a change made on the screen or by an automation shows up here too.</sub></p>
<p align="center">
+2 -2
View File
@@ -440,12 +440,12 @@ takes effect at once, and appears in ESP Screens within a second, so both sides
the same value. The **Screen settings** cards in ESP Screens have the same rows.
<p align="center">
<img src="docs/images/editor-screen-settings.png" width="98%" alt="Screen settings in ESP Screens: Brightness with Dark mode and standby, Night with its hours and brightness, and Screen with the clock, back to page 1, swiping, the page buttons and the rotation">
<img src="docs/images/editor-screen-settings.png" width="98%" alt="Screen settings in ESP Screens: Brightness with Dark mode and standby, Night with its hours and brightness, and Screen with back to page 1, swiping, the page buttons and the rotation">
</p>
**In Home Assistant** (firmware 0.2.49+), every setting is an entity on the screen's device, under
*Configuration*: `number.<screen>_normal_brightness`, `switch.<screen>_night_mode`,
`time.<screen>_night_starts`, `switch.<screen>_24_hour_clock`, `select.<screen>_rotation` on a
`time.<screen>_night_starts`, `switch.<screen>_dark_mode`, `select.<screen>_rotation` on a
Guition, and the rest. An automation, the settings page and ESP Screens all change the same value,
and setting a value the screen already has costs nothing. The full list is in
[docs/SETTINGS.md](docs/SETTINGS.md#who-owns-a-setting).
+1 -1
View File
@@ -56,7 +56,7 @@ inline std::string number_text(float value) {
if (text.back() == '.') text.pop_back();
return text;
}
inline std::string percent_text(int percent) { return std::to_string(percent) + " %"; }
inline std::string percent_text(int percent) { return screen_text::percent(percent); }
// The roller takes its names as one string, a line per name.
inline std::string joined(const std::vector<std::string> &names) {
std::string out;
+2 -5
View File
@@ -65,8 +65,7 @@ inline std::string duration(uint32_t seconds) {
}
// 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.
// mark (screen.number: "1,234.5" in English, "1.234,5" in Dutch), the unit spaced as Home Assistant spaces it.
inline std::string number(float value, int decimals, const std::string &unit) {
if (!std::isfinite(value)) return "--";
char b[40];
@@ -77,9 +76,7 @@ inline std::string number(float value, int decimals, const std::string &unit) {
std::string whole = text.substr(0, dot), rest = dot == std::string::npos ? "" : text.substr(dot);
bool zero = whole.find_first_not_of('0') == std::string::npos && rest.find_first_not_of(".0") == std::string::npos;
text = (zero ? "" : sign) + screen_text::write_number(whole, rest.empty() ? std::string() : rest.substr(1));
if (unit.empty()) return text;
if (unit == "%" || unit == "°") return text + unit;
return text + " " + unit;
return screen_text::with_unit(text, unit);
}
// Where part `i` of 24 is drawn and read: the middle of its stretch of the range, as a fraction of the width.
+1 -1
View File
@@ -95,7 +95,7 @@ inline void preview(Row &row) {
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, screen_text::tr(screen_text::txt::ha_off));
else lv_label_set_text_fmt(row.value, "%d %%", value);
else lv_label_set_text(row.value, screen_text::percent(value).c_str());
}
}
inline void event(lv_event_t *e) {
+50 -30
View File
@@ -44,8 +44,17 @@ 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.
// The number format of Settings -> Language & region (app 0.2.90), kept for the next start: screen_text::number_style
// (bits 0-3), number_group_min (4-5) and number_percent (6-7) in one word.
inline esphome::ESPPreferenceObject numbers_preference;
inline uint32_t screen_text_numbers() {
return screen_text::number_style | (uint32_t) screen_text::number_group_min << 4 | (uint32_t) screen_text::number_percent << 6;
}
inline void screen_text_numbers(uint32_t word) {
screen_text::number_style = (word & 0xF) <= 3 ? word & 0xF : 0;
screen_text::number_group_min = (word >> 4 & 3) <= 2 ? word >> 4 & 3 : 0;
screen_text::number_percent = (word >> 6 & 3) <= 2 ? word >> 6 & 3 : 0;
}
inline Model model;
inline std::string inbox;
inline const lv_font_t *watch_font = nullptr;
@@ -91,6 +100,11 @@ inline const char *weather_icon(const std::string &condition);
inline const char *weather_text(const std::string &condition);
inline std::string timer_text(const Tile &t);
inline std::string last_run_text(uint32_t epoch);
// "07:12": a time of day as Home Assistant and ESP Screens send it, for screen_text::clock_text.
inline std::string hhmm(const esphome::ESPTime &time) {
char b[8]; snprintf(b, sizeof(b), "%02d:%02d", time.hour, time.minute);
return b;
}
inline void history_received();
// Camera images full screen and on an alert (firmware 0.2.57+, the Guition binds them; see the end of this file).
inline void camera_open(const std::string &entity, const std::string &name);
@@ -153,7 +167,7 @@ inline void load_settings() {
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;
if(numbers_preference.load(&numbers_saved))screen_text_numbers(numbers_saved);
uint32_t swipe_saved=0;
if(swipe_preference.load(&swipe_saved))swipe_pages=swipe_saved==1;
uint32_t dark_saved=0;
@@ -308,8 +322,6 @@ inline std::string receive(const std::string &payload) {
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.
@@ -345,11 +357,16 @@ inline std::string receive(const std::string &payload) {
}
bool rotation_changed=false;
if(root["clock_24h"].is<bool>())settings.clock_24h=root["clock_24h"].as<bool>()?1:0;
// How numbers are written; a value this firmware doesn't know is left out, never the layout (a newer app may
// know more styles).
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;}
}
int style=root["numbers"].is<const char*>()?screen_text::number_style_of(root["numbers"].as<std::string>()):-1;
uint32_t numbers=screen_text_numbers();
if(style>=0)numbers=(numbers&~0xFu)|(uint32_t)style;
if(root["group_min"].is<unsigned>() && root["group_min"].as<unsigned>()>=1 && root["group_min"].as<unsigned>()<=2)
numbers=(numbers&~0x30u)|(root["group_min"].as<unsigned>()<<4);
if(root["percent_space"].is<bool>())numbers=(numbers&~0xC0u)|((root["percent_space"].as<bool>()?2u:1u)<<6);
if(numbers!=screen_text_numbers()){screen_text_numbers(numbers);numbers_preference.save(&numbers);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;
}
@@ -591,7 +608,7 @@ inline std::string receive(const std::string &payload) {
if (extra["days"].is<JsonArray>()) for (JsonVariant day : extra["days"].as<JsonArray>()) {
if (next.forecast.size() == 5) break;
next.forecast.emplace_back(); auto &f = next.forecast.back();
f.day = string(day["d"], 3); f.condition = string(day["c"], 20); f.high = number(day["h"]); f.low = number(day["l"]);
f.day = string(day["d"], 8); f.condition = string(day["c"], 20); f.high = number(day["h"]); f.low = number(day["l"]);
f.rain = number(day["p"]); f.mm = number(day["r"]);
}
if (extra["hours"].is<JsonArray>()) for (JsonVariant hour : extra["hours"].as<JsonArray>()) {
@@ -1086,10 +1103,9 @@ inline lv_obj_t *detail_text(lv_obj_t *parent,const std::string &text,int x,int
}
// "30%", "30% · 1.7 mm" or "1.7 mm": whatever the provider reports; empty when dry.
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%% · ",(int)std::lround(chance));return b+screen_text::decimal(mm,1)+" mm";}
snprintf(b,sizeof(b),"%d%%",(int)std::lround(chance));return b;
if(with_mm && std::isfinite(mm) && mm>=0.05f)return screen_text::percent((int)std::lround(chance))+" · "+screen_text::with_unit(screen_text::decimal(mm,1),"mm");
return screen_text::percent((int)std::lround(chance));
}
if(std::isfinite(mm) && mm>=0.05f)return screen_text::decimal(mm,mm<10?1:0)+" mm";
return "";
@@ -1134,7 +1150,7 @@ inline void render_weather_detail(const Tile &t,bool large,int width,int height,
detail_text(now,t.available()?weather_text(t.state):tr(txt::ha_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))details=fill(txt::weather_feels_like,"n",(int)std::lround(weather.feels));
if(std::isfinite(t.humidity)){snprintf(b,sizeof(b),"%d%%",(int)std::lround(t.humidity));details+=(details.empty()?"":" · ")+std::string(b);}
if(std::isfinite(t.humidity))details+=(details.empty()?"":" · ")+screen_text::percent((int)std::lround(t.humidity));
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);
// Next hours inside the same card: time, icon, temperature, rain per column.
@@ -1221,7 +1237,7 @@ inline void vacuum_battery(lv_obj_t *parent,int x,int y,int w,int h,float level)
// Without a parent it only measures.
inline int vacuum_power(lv_obj_t *parent,const Tile &t,int x,int y,const lv_font_t *font,bool large){
const lv_font_t *bolt_font=large && watch_icon_font?watch_icon_font:mini_icon_font;
std::string percent=std::to_string((int)std::lround(t.battery))+"%";
std::string percent=screen_text::percent((int)std::lround(t.battery));
int h=lv_font_get_line_height(font),meter_w=large?30:22,meter_h=large?15:11,gap=large?10:6,words=text_width(percent,font);
int bolt_w=t.extra().charging && bolt_font?text_width("\U000F0241",bolt_font):0;
int width=meter_w+3+gap+words+(bolt_w?gap/2+bolt_w:0);
@@ -1382,7 +1398,7 @@ inline void cover_slider_event(lv_event_t *e){
int raw=lv_slider_get_value(slider);
if(raw<0||raw>1000){raw=std::clamp(raw,0,1000);lv_slider_set_value(slider,raw,LV_ANIM_OFF);}
int percent=(int)std::lround(raw/10.0f);
if(auto *value=cover_values[tilt?1:0])label(value,std::to_string(tilt?percent:100-percent)+"%");
if(auto *value=cover_values[tilt?1:0])label(value,screen_text::percent(tilt?percent:100-percent));
return;
}
if(code!=LV_EVENT_RELEASED||detail_index>=model.count)return;
@@ -1462,7 +1478,7 @@ inline void cover_battery(const Tile &t,bool large,int width){
int level=(int)std::lround(std::clamp(t.battery,0.0f,100.0f));
int line=lv_font_get_line_height(font),block=meter_h+gap+line,cx=width-bar_x-bar/2,y=bar_y+(bar-block)/2;
vacuum_battery(detail_root,cx-meter_w/2-1,y,meter_w,meter_h,t.battery);
detail_text(detail_root,std::to_string(level)+"%",cx-bar/2-8,y+meter_h+gap,bar+16,font,LV_TEXT_ALIGN_CENTER,theme::MUTED);
detail_text(detail_root,screen_text::percent(level),cx-bar/2-8,y+meter_h+gap,bar+16,font,LV_TEXT_ALIGN_CENTER,theme::MUTED);
}
inline void render_cover_detail(Tile &t,bool large,int width,int height,int pad){
auto card=tile_controls::cover_card(t);
@@ -1485,7 +1501,7 @@ inline void render_cover_detail(Tile &t,bool large,int width,int height,int pad)
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("--");};
auto percent=[](float value){return std::isfinite(value)?screen_text::percent((int)std::lround(std::clamp(value,0.0f,100.0f))):std::string("--");};
if(large){
// Columns: the slider with its value and name below.
int edge=16,slider_h=box_h-2*edge-value_h-caption_h+4,slider_w=n==2?120:140,column_gap=48;
@@ -1911,7 +1927,7 @@ 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 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))+" %";
return screen_text::percent((int)std::lround(std::clamp(t.volume,0.0f,1.0f)*100));
}
// A rounded box without a style of its own: the art's placeholder, the bar's track and its fill.
inline lv_obj_t *media_box(lv_obj_t *parent,lv_obj_t *existing,const media_card::Rect &r,uint32_t color,int radius){
@@ -2072,7 +2088,7 @@ inline void show_detail(unsigned index){
std::string state=d=="cover"?cover_status_line(t):detail_state(t);
// The vacuum and history cards draw their own state.
const bool with_history=history_card(t);
if(d!="vacuum"&&d!="media_player"&&!with_history){detail_status=detail_label(detail_root,state+(t.unit.empty()?"":" "+t.unit),pad,large?80:50,width-2*pad);lv_obj_set_style_text_align(detail_status,LV_TEXT_ALIGN_CENTER,0);lv_obj_set_style_text_color(detail_status,theme::color(theme::MUTED),0);}
if(d!="vacuum"&&d!="media_player"&&!with_history){detail_status=detail_label(detail_root,screen_text::with_unit(state,t.unit),pad,large?80:50,width-2*pad);lv_obj_set_style_text_align(detail_status,LV_TEXT_ALIGN_CENTER,0);lv_obj_set_style_text_color(detail_status,theme::color(theme::MUTED),0);}
if(with_history){
render_history_detail(t,large,width,height,pad);
}else if(d=="vacuum"){
@@ -2178,7 +2194,7 @@ inline std::string last_run_text(uint32_t epoch) {
auto when = esphome::ESPTime::from_epoch_local(epoch);
auto now = now_time ? now_time() : esphome::ESPTime{};
if (!when.is_valid()) return tr(txt::script_never_run);
char clock[8]; snprintf(clock, sizeof(clock), "%02d:%02d", when.hour, when.minute);
std::string clock = screen_text::clock_text(hhmm(when), screen_settings::current.clock_24h != 0);
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));
@@ -2470,8 +2486,12 @@ inline lv_obj_t *part_line(Widgets &w,unsigned i,lv_point_precise_t *points,unsi
if(!p){p=lv_line_create(w.extra);lv_obj_remove_flag(p,LV_OBJ_FLAG_CLICKABLE);lv_obj_set_style_line_rounded(p,true,0);}
set_line_width(p,width);lv_line_set_points(p,points,count);lv_obj_set_pos(p,x,y);return p;
}
// The big digits of the clock card: "07:12" on 24 hours, "7:12" on 12 (the clock font has no letters for AM and PM).
inline std::string time_text(esphome::ESPTime now) {
return now.is_valid() ? now.strftime(screen_settings::current.clock_24h ? "%H:%M" : "%I:%M") : "--:--";
if (!now.is_valid()) return "--:--";
if (screen_settings::current.clock_24h) return hhmm(now);
char b[8]; snprintf(b, sizeof(b), "%d:%02d", now.hour % 12 ? now.hour % 12 : 12, now.minute);
return b;
}
// Digital: big time over the date. Analog: index strokes (numerals at 12/3/6/9 on
// large cards) with hour and minute hands. A single card adds a calendar block
@@ -2794,7 +2814,7 @@ inline int layout_panel(Widgets &w,const Tile &t,bool large,int content_w,int co
panel_w=count?count*m.key_w+(count-1)*m.gap:0;
}else if(mode=="setpoint"||mode=="stepper"){
float shown=std::isfinite(t.edit_value)?t.edit_value:tile_controls::edit_target(t);
std::string suffix=d=="climate"?"°":t.unit.empty()?"":" "+t.unit;
std::string suffix=d=="climate"?"°":screen_text::unit_suffix(t.unit);
label(w.pill_value,tile_controls::format_value(shown,tile_controls::edit_step(t),suffix.c_str()));
panel_w=m.pill_w;panel_h=m.key_h+2;
}else if(tile_controls::is_slider(mode)){
@@ -2881,7 +2901,7 @@ inline void control_event(lv_event_t *e) {
float current=std::isfinite(t.edit_value)?t.edit_value:tile_controls::edit_target(t);
t.edit_value=tile_controls::step_value(current,tile_controls::edit_step(t),t.minimum,t.maximum,command==tile_controls::STEP_UP?1:-1);
t.edit_since=now;t.edit_sent=false;
if(w.pill_value){std::string suffix=t.domain()=="climate"?"°":t.unit.empty()?"":" "+t.unit;label(w.pill_value,tile_controls::format_value(t.edit_value,tile_controls::edit_step(t),suffix.c_str()));}
if(w.pill_value){std::string suffix=t.domain()=="climate"?"°":screen_text::unit_suffix(t.unit);label(w.pill_value,tile_controls::format_value(t.edit_value,tile_controls::edit_step(t),suffix.c_str()));}
return;
}
if(t.waiting(now))return;
@@ -3047,7 +3067,7 @@ inline void render_full(Widgets &w,const Tile &t,bool custom,bool clock,bool sun
lv_obj_set_height(w.title,name_h);
lv_obj_set_pos(w.title,0,top+circle+gap);lv_obj_set_width(w.title,content_w);
lv_obj_set_pos(w.value,0,top+circle+gap+name_h+2);lv_obj_set_width(w.value,content_w);
if(!unit.empty())label(w.value,value+" "+unit);
if(!unit.empty())label(w.value,screen_text::with_unit(value,unit));
return;
}
// The head as on the double-width card: the circle at the left, name and state beside it.
@@ -3092,7 +3112,7 @@ inline void render_full(Widgets &w,const Tile &t,bool custom,bool clock,bool sun
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))middle=screen_text::decimal(t.current,1)+"°";
else if(d=="cover" && std::isfinite(t.position)){middle=std::to_string(static_cast<int>(std::lround(t.position)))+" %";}
else if(d=="cover" && std::isfinite(t.position)){middle=screen_text::percent(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);
if(middle.empty() || mid_h<mid_font_h)return;
@@ -3120,7 +3140,7 @@ inline void render_slot(size_t slot) {
else if (!fresh() || !t.available()) value = tr(txt::ha_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))) + " %";
else if (d == "light" && t.state == "on" && std::isfinite(t.brightness)) value = screen_text::percent(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)) value += " · " + screen_text::decimal(t.current, 1) + "°"; }
@@ -3145,8 +3165,8 @@ inline void render_slot(size_t slot) {
else if (!t.unit.empty() && !watch) value = screen_text::localize(value) + " " + t.unit;
else if (!t.unit.empty() || d == "number" || d == "input_number" || d == "counter") value = screen_text::localize(value);
bool pending=t.loading(esphome::millis());
if(d=="weather" && std::isfinite(t.current)) {value=screen_text::decimal(t.current,1);if(!watch)value+=" "+t.unit;}
if(d=="vacuum" && std::isfinite(t.battery))value += " / "+std::to_string((int)t.battery)+"%";
if(d=="weather" && std::isfinite(t.current)) {value=screen_text::decimal(t.current,1);if(!watch)value=screen_text::with_unit(value,t.unit);}
if(d=="vacuum" && std::isfinite(t.battery))value += " / "+screen_text::percent((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();
if(with_panel){std::string status=tile_controls::status_text(t);if(!status.empty())value=status;}
@@ -3488,7 +3508,7 @@ inline void draw_header(bool live) {
p.item = i;
if (item.kind == Kind::analog) { p.dial = true; p.width = dial; }
else {
p.text = item.kind == Kind::clock ? (now.is_valid() ? now.strftime(screen_settings::current.clock_24h ? "%H:%M" : "%I:%M") : std::string("--:--"))
p.text = item.kind == Kind::clock ? (now.is_valid() ? screen_text::clock_text(hhmm(now), screen_settings::current.clock_24h != 0) : std::string("--:--"))
: item.kind == Kind::date ? (now.is_valid() ? header_bar::date_text(now.day_of_week, now.day_of_month, now.month) : std::string("—"))
: item.kind == Kind::ago ? header_bar::ago_text(item.epoch, now_epoch()) : item.text;
lv_font_glyph_dsc_t g;
@@ -3886,7 +3906,7 @@ inline void tick() {
}
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?std::string(tr(t.confirmed?txt::tile_confirmed:txt::tile_command_sent)):t.domain()=="cover"?cover_status_line(t):detail_state(t);
if(detail_status)label(detail_status,status+(!waiting && !t.unit.empty()?" "+t.unit:""));
if(detail_status)label(detail_status,waiting?status:screen_text::with_unit(status,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"){
+24 -5
View File
@@ -47,9 +47,12 @@ inline std::string plural(uint16_t id, int n) {
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;
// How numbers are written (app 0.2.90): ESP Screens sends the choice of Settings -> Language & region with every layout,
// the one place for every screen, worked out for the language Home Assistant speaks. Until a screen has it (and with an
// app that doesn't send it), the screen writes numbers the way its own language does (screen.number); 0 means that.
inline uint8_t number_style = 0; // 1 "1,234.5", 2 "1.234,5", 3 "1 234,5"
inline uint8_t number_group_min = 0; // CLDR's minimum grouping digits: 2 writes 1234 but 12.345 (Spanish, Polish)
inline uint8_t number_percent = 0; // 1 "54%", 2 "54 %" (German, French: Home Assistant's own rule)
inline char decimal_mark() {
if (number_style == 1) return '.';
if (number_style == 2 || number_style == 3) return ',';
@@ -66,8 +69,7 @@ inline const char *group_mark() {
// How many digits a whole number needs before it gets separators: 4 ("1,234") in most languages, 5 in Italian, Spanish
// and Polish, which write 1234 but 12.345 (CLDR's minimum grouping digits, as Home Assistant's own numbers do).
inline size_t group_from() {
if (number_style) return 4;
int minimum = atoi(tr(txt::number_group_min));
int minimum = number_group_min ? number_group_min : atoi(tr(txt::number_group_min));
return minimum >= 2 ? 5 : 4;
}
// A number the way this screen writes numbers: `whole` digits (no sign) and the digits after the point.
@@ -93,6 +95,23 @@ inline std::string localize(const std::string &state) {
std::string fraction = dot == std::string::npos ? std::string() : state.substr(dot + 1);
return state.substr(0, start) + write_number(whole, fraction);
}
// What follows a number for a percentage: "%" or " %".
inline const char *percent_sign() {
if (number_percent == 1) return "%";
if (number_percent == 2) return " %";
return tr(txt::number_percent);
}
// What follows a number for its unit, spaced as Home Assistant spaces them: " °C", "%" (" %" in German and French),
// "°" (Home Assistant's blankBeforeUnit).
inline std::string unit_suffix(const std::string &unit) {
if (unit.empty() || unit == "\u00B0") return unit;
if (unit == "%") return percent_sign();
return " " + unit;
}
// A value with its unit: "21.5 °C", "54%", "18°".
inline std::string with_unit(const std::string &value, const std::string &unit) { return value + unit_suffix(unit); }
// A whole percentage: "54%", "54 %" in German and French.
inline std::string percent(int value) { return std::to_string(value) + percent_sign(); }
// A time "HH:MM" as Home Assistant or ESP Screens sends it, written for the screen's clock: "07:12" on 24 hours,
// "7:12 AM" on 12 (in the language's day periods); `compact` leaves out ":00" ("2 PM"), as under a graph. Anything
// else stays as it is.
+16 -6
View File
@@ -94,19 +94,29 @@ def cpp_string(text):
return '"' + ''.join(out) + '"'
def chain(language, folder=TRANSLATIONS):
"""The files a language's texts come from, most specific first: pt-BR, then pt, then English."""
found = [language]
base = language.split('-')[0]
if base != language and load(base, folder) is not None:
found.append(base)
return found + ([] if 'en' in found else ['en'])
def table(code, folder=TRANSLATIONS):
"""(language, keys, texts, plural rule) of a build: every English key, in its language where it has one."""
"""(language, keys, texts, plural rule) of a build: every English key, in its language where it has one, else in its
base language (a small pt-BR file takes the rest from pt), else in English."""
english = load('en', folder)
language = resolve(code, folder)
own = dict(screen_pairs(load(language, folder))) if language != 'en' else {}
files = [load(name, folder) or {} for name in chain(language, folder)]
texts_of = [dict(screen_pairs(data)) for data in files]
keys, texts = [], []
for key, text in screen_pairs(english):
keys.append(key)
translated = own.get(key)
# Only an empty text is missing: a separator of one space (French thousands) is a text.
texts.append(translated if isinstance(translated, str) and translated != '' else text)
meta = (load(language, folder) or {}).get('_meta', {})
rule = meta.get('plural') if meta.get('plural') in PLURAL_RULES else 'one_other'
texts.append(next((own[key] for own in texts_of if isinstance(own.get(key), str) and own[key] != ''), text))
plural = next((data.get('_meta', {}).get('plural') for data in files if data.get('_meta', {}).get('plural')), None)
rule = plural if plural in PLURAL_RULES else 'one_other'
return language, keys, texts, rule
+265 -264
View File
@@ -9,274 +9,275 @@ namespace txt {
constexpr uint16_t number_decimal = 0;
constexpr uint16_t number_group = 1;
constexpr uint16_t number_group_min = 2;
constexpr uint16_t time_just_now = 3;
constexpr uint16_t time_minutes_ago = 4;
constexpr uint16_t time_hours_ago = 5;
constexpr uint16_t time_yesterday = 6;
constexpr uint16_t time_days_ago = 7;
constexpr uint16_t time_weeks_ago = 8;
constexpr uint16_t time_months_ago = 9;
constexpr uint16_t time_years_ago = 10;
constexpr uint16_t time_in_minutes = 11;
constexpr uint16_t time_in_hours = 12;
constexpr uint16_t time_tomorrow = 13;
constexpr uint16_t time_in_days = 14;
constexpr uint16_t time_am = 15;
constexpr uint16_t time_pm = 16;
constexpr uint16_t date_top_bar = 62;
constexpr uint16_t date_full = 63;
constexpr uint16_t date_day_month = 64;
constexpr uint16_t date_weekday_time = 65;
constexpr uint16_t duration_seconds = 66;
constexpr uint16_t duration_minutes = 67;
constexpr uint16_t duration_hours = 68;
constexpr uint16_t duration_hours_minutes = 69;
constexpr uint16_t duration_days = 70;
constexpr uint16_t duration_days_hours = 71;
constexpr uint16_t status_connecting = 72;
constexpr uint16_t status_waiting = 73;
constexpr uint16_t status_loading_tiles = 74;
constexpr uint16_t status_ha_not_connected = 75;
constexpr uint16_t status_manager_not_active = 76;
constexpr uint16_t status_choose_tiles = 77;
constexpr uint16_t status_home = 78;
constexpr uint16_t status_no_network = 79;
constexpr uint16_t settings_title = 80;
constexpr uint16_t settings_brightness = 81;
constexpr uint16_t settings_night = 82;
constexpr uint16_t settings_screen = 83;
constexpr uint16_t settings_this_screen = 84;
constexpr uint16_t settings_dark_mode = 85;
constexpr uint16_t settings_auto_standby = 86;
constexpr uint16_t settings_standby_after = 87;
constexpr uint16_t settings_standby_brightness = 88;
constexpr uint16_t settings_night_mode = 89;
constexpr uint16_t settings_starts = 90;
constexpr uint16_t settings_ends = 91;
constexpr uint16_t settings_night_brightness = 92;
constexpr uint16_t settings_back_to_page_1 = 93;
constexpr uint16_t settings_after = 94;
constexpr uint16_t settings_also_on_standby = 95;
constexpr uint16_t settings_swipe_between_pages = 96;
constexpr uint16_t settings_page_buttons = 97;
constexpr uint16_t settings_rotation = 98;
constexpr uint16_t settings_address = 99;
constexpr uint16_t settings_firmware = 100;
constexpr uint16_t settings_home_assistant = 101;
constexpr uint16_t settings_restart = 102;
constexpr uint16_t settings_connected = 103;
constexpr uint16_t settings_not_connected = 104;
constexpr uint16_t settings_tap_again_to_restart = 105;
constexpr uint16_t settings_seconds = 106;
constexpr uint16_t settings_minutes = 107;
constexpr uint16_t settings_hours = 108;
constexpr uint16_t settings_hours_minutes = 109;
constexpr uint16_t tile_tap_to_open = 110;
constexpr uint16_t tile_page = 111;
constexpr uint16_t tile_refused = 112;
constexpr uint16_t tile_command_sent = 113;
constexpr uint16_t tile_confirmed = 114;
constexpr uint16_t alert_notification = 115;
constexpr uint16_t alert_ok = 116;
constexpr uint16_t light_color = 117;
constexpr uint16_t light_color_temperature = 118;
constexpr uint16_t light_brightness = 119;
constexpr uint16_t light_waiting = 120;
constexpr uint16_t effects_effect = 121;
constexpr uint16_t effects_nothing = 122;
constexpr uint16_t effects_loading = 123;
constexpr uint16_t effects_not_connected = 124;
constexpr uint16_t climate_target = 125;
constexpr uint16_t climate_mode = 126;
constexpr uint16_t climate_fan = 127;
constexpr uint16_t climate_swing = 128;
constexpr uint16_t climate_now = 129;
constexpr uint16_t vacuum_check_robot = 130;
constexpr uint16_t vacuum_auto_suction = 131;
constexpr uint16_t vacuum_robot_chooses = 132;
constexpr uint16_t vacuum_per_room = 133;
constexpr uint16_t vacuum_speed_quiet = 134;
constexpr uint16_t vacuum_speed_balanced = 135;
constexpr uint16_t vacuum_speed_turbo = 136;
constexpr uint16_t vacuum_speed_max = 137;
constexpr uint16_t vacuum_pause_cleaning = 138;
constexpr uint16_t vacuum_pause = 139;
constexpr uint16_t vacuum_resume = 140;
constexpr uint16_t vacuum_start_cleaning = 141;
constexpr uint16_t vacuum_clean = 142;
constexpr uint16_t vacuum_dock = 143;
constexpr uint16_t cover_position = 144;
constexpr uint16_t cover_tilt = 145;
constexpr uint16_t cover_tilt_value = 146;
constexpr uint16_t weather_feels_like = 147;
constexpr uint16_t weather_no_forecast = 148;
constexpr uint16_t weather_coming_days = 149;
constexpr uint16_t history_no_data = 150;
constexpr uint16_t history_average = 151;
constexpr uint16_t history_average_of = 152;
constexpr uint16_t history_now = 153;
constexpr uint16_t history_range_hour = 154;
constexpr uint16_t history_range_day = 155;
constexpr uint16_t history_range_week = 156;
constexpr uint16_t history_high = 157;
constexpr uint16_t history_low = 158;
constexpr uint16_t history_times = 159;
constexpr uint16_t history_loading = 160;
constexpr uint16_t history_empty = 161;
constexpr uint16_t history_unavailable = 162;
constexpr uint16_t media_muted = 163;
constexpr uint16_t media_not_playing = 164;
constexpr uint16_t media_loading = 165;
constexpr uint16_t timer_running = 166;
constexpr uint16_t timer_paused = 167;
constexpr uint16_t timer_stopped = 168;
constexpr uint16_t timer_pause = 169;
constexpr uint16_t timer_start = 170;
constexpr uint16_t timer_cancel = 171;
constexpr uint16_t timer_paused_left = 172;
constexpr uint16_t sun_name = 173;
constexpr uint16_t sun_sunrise = 174;
constexpr uint16_t sun_sunset = 175;
constexpr uint16_t sun_rise = 176;
constexpr uint16_t sun_set = 177;
constexpr uint16_t script_running = 178;
constexpr uint16_t script_never_run = 179;
constexpr uint16_t script_last_time = 180;
constexpr uint16_t script_yesterday_time = 181;
constexpr uint16_t script_last_date = 182;
constexpr uint16_t camera_no_image = 183;
constexpr uint16_t camera_loading = 184;
constexpr uint16_t camera_live = 185;
constexpr uint16_t camera_recording = 186;
constexpr uint16_t camera_tap_to_view = 187;
constexpr uint16_t camera_no_image_yet = 188;
constexpr uint16_t calibration_title = 189;
constexpr uint16_t calibration_touch_check = 190;
constexpr uint16_t calibration_hold = 191;
constexpr uint16_t calibration_crosshair = 192;
constexpr uint16_t calibration_gently = 193;
constexpr uint16_t calibration_same = 194;
constexpr uint16_t calibration_rejected = 195;
constexpr uint16_t calibration_save_failed = 196;
constexpr uint16_t ha_on = 197;
constexpr uint16_t ha_off = 198;
constexpr uint16_t ha_unavailable = 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 ha_button_activate = 302;
constexpr uint16_t ha_button_run = 303;
constexpr uint16_t ha_button_press = 304;
constexpr uint16_t date_weekdays = 17;
constexpr uint16_t number_percent = 3;
constexpr uint16_t time_just_now = 4;
constexpr uint16_t time_minutes_ago = 5;
constexpr uint16_t time_hours_ago = 6;
constexpr uint16_t time_yesterday = 7;
constexpr uint16_t time_days_ago = 8;
constexpr uint16_t time_weeks_ago = 9;
constexpr uint16_t time_months_ago = 10;
constexpr uint16_t time_years_ago = 11;
constexpr uint16_t time_in_minutes = 12;
constexpr uint16_t time_in_hours = 13;
constexpr uint16_t time_tomorrow = 14;
constexpr uint16_t time_in_days = 15;
constexpr uint16_t time_am = 16;
constexpr uint16_t time_pm = 17;
constexpr uint16_t date_top_bar = 63;
constexpr uint16_t date_full = 64;
constexpr uint16_t date_day_month = 65;
constexpr uint16_t date_weekday_time = 66;
constexpr uint16_t duration_seconds = 67;
constexpr uint16_t duration_minutes = 68;
constexpr uint16_t duration_hours = 69;
constexpr uint16_t duration_hours_minutes = 70;
constexpr uint16_t duration_days = 71;
constexpr uint16_t duration_days_hours = 72;
constexpr uint16_t status_connecting = 73;
constexpr uint16_t status_waiting = 74;
constexpr uint16_t status_loading_tiles = 75;
constexpr uint16_t status_ha_not_connected = 76;
constexpr uint16_t status_manager_not_active = 77;
constexpr uint16_t status_choose_tiles = 78;
constexpr uint16_t status_home = 79;
constexpr uint16_t status_no_network = 80;
constexpr uint16_t settings_title = 81;
constexpr uint16_t settings_brightness = 82;
constexpr uint16_t settings_night = 83;
constexpr uint16_t settings_screen = 84;
constexpr uint16_t settings_this_screen = 85;
constexpr uint16_t settings_dark_mode = 86;
constexpr uint16_t settings_auto_standby = 87;
constexpr uint16_t settings_standby_after = 88;
constexpr uint16_t settings_standby_brightness = 89;
constexpr uint16_t settings_night_mode = 90;
constexpr uint16_t settings_starts = 91;
constexpr uint16_t settings_ends = 92;
constexpr uint16_t settings_night_brightness = 93;
constexpr uint16_t settings_back_to_page_1 = 94;
constexpr uint16_t settings_after = 95;
constexpr uint16_t settings_also_on_standby = 96;
constexpr uint16_t settings_swipe_between_pages = 97;
constexpr uint16_t settings_page_buttons = 98;
constexpr uint16_t settings_rotation = 99;
constexpr uint16_t settings_address = 100;
constexpr uint16_t settings_firmware = 101;
constexpr uint16_t settings_home_assistant = 102;
constexpr uint16_t settings_restart = 103;
constexpr uint16_t settings_connected = 104;
constexpr uint16_t settings_not_connected = 105;
constexpr uint16_t settings_tap_again_to_restart = 106;
constexpr uint16_t settings_seconds = 107;
constexpr uint16_t settings_minutes = 108;
constexpr uint16_t settings_hours = 109;
constexpr uint16_t settings_hours_minutes = 110;
constexpr uint16_t tile_tap_to_open = 111;
constexpr uint16_t tile_page = 112;
constexpr uint16_t tile_refused = 113;
constexpr uint16_t tile_command_sent = 114;
constexpr uint16_t tile_confirmed = 115;
constexpr uint16_t alert_notification = 116;
constexpr uint16_t alert_ok = 117;
constexpr uint16_t light_color = 118;
constexpr uint16_t light_color_temperature = 119;
constexpr uint16_t light_brightness = 120;
constexpr uint16_t light_waiting = 121;
constexpr uint16_t effects_effect = 122;
constexpr uint16_t effects_nothing = 123;
constexpr uint16_t effects_loading = 124;
constexpr uint16_t effects_not_connected = 125;
constexpr uint16_t climate_target = 126;
constexpr uint16_t climate_mode = 127;
constexpr uint16_t climate_fan = 128;
constexpr uint16_t climate_swing = 129;
constexpr uint16_t climate_now = 130;
constexpr uint16_t vacuum_check_robot = 131;
constexpr uint16_t vacuum_auto_suction = 132;
constexpr uint16_t vacuum_robot_chooses = 133;
constexpr uint16_t vacuum_per_room = 134;
constexpr uint16_t vacuum_speed_quiet = 135;
constexpr uint16_t vacuum_speed_balanced = 136;
constexpr uint16_t vacuum_speed_turbo = 137;
constexpr uint16_t vacuum_speed_max = 138;
constexpr uint16_t vacuum_pause_cleaning = 139;
constexpr uint16_t vacuum_pause = 140;
constexpr uint16_t vacuum_resume = 141;
constexpr uint16_t vacuum_start_cleaning = 142;
constexpr uint16_t vacuum_clean = 143;
constexpr uint16_t vacuum_dock = 144;
constexpr uint16_t cover_position = 145;
constexpr uint16_t cover_tilt = 146;
constexpr uint16_t cover_tilt_value = 147;
constexpr uint16_t weather_feels_like = 148;
constexpr uint16_t weather_no_forecast = 149;
constexpr uint16_t weather_coming_days = 150;
constexpr uint16_t history_no_data = 151;
constexpr uint16_t history_average = 152;
constexpr uint16_t history_average_of = 153;
constexpr uint16_t history_now = 154;
constexpr uint16_t history_range_hour = 155;
constexpr uint16_t history_range_day = 156;
constexpr uint16_t history_range_week = 157;
constexpr uint16_t history_high = 158;
constexpr uint16_t history_low = 159;
constexpr uint16_t history_times = 160;
constexpr uint16_t history_loading = 161;
constexpr uint16_t history_empty = 162;
constexpr uint16_t history_unavailable = 163;
constexpr uint16_t media_muted = 164;
constexpr uint16_t media_not_playing = 165;
constexpr uint16_t media_loading = 166;
constexpr uint16_t timer_running = 167;
constexpr uint16_t timer_paused = 168;
constexpr uint16_t timer_stopped = 169;
constexpr uint16_t timer_pause = 170;
constexpr uint16_t timer_start = 171;
constexpr uint16_t timer_cancel = 172;
constexpr uint16_t timer_paused_left = 173;
constexpr uint16_t sun_name = 174;
constexpr uint16_t sun_sunrise = 175;
constexpr uint16_t sun_sunset = 176;
constexpr uint16_t sun_rise = 177;
constexpr uint16_t sun_set = 178;
constexpr uint16_t script_running = 179;
constexpr uint16_t script_never_run = 180;
constexpr uint16_t script_last_time = 181;
constexpr uint16_t script_yesterday_time = 182;
constexpr uint16_t script_last_date = 183;
constexpr uint16_t camera_no_image = 184;
constexpr uint16_t camera_loading = 185;
constexpr uint16_t camera_live = 186;
constexpr uint16_t camera_recording = 187;
constexpr uint16_t camera_tap_to_view = 188;
constexpr uint16_t camera_no_image_yet = 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_unavailable = 200;
constexpr uint16_t ha_climate_off = 201;
constexpr uint16_t ha_climate_heat = 202;
constexpr uint16_t ha_climate_cool = 203;
constexpr uint16_t ha_climate_heat_cool = 204;
constexpr uint16_t ha_climate_auto = 205;
constexpr uint16_t ha_climate_dry = 206;
constexpr uint16_t ha_climate_fan_only = 207;
constexpr uint16_t ha_hvac_action_heating = 208;
constexpr uint16_t ha_hvac_action_cooling = 209;
constexpr uint16_t ha_hvac_action_idle = 210;
constexpr uint16_t ha_hvac_action_off = 211;
constexpr uint16_t ha_hvac_action_drying = 212;
constexpr uint16_t ha_hvac_action_fan = 213;
constexpr uint16_t ha_hvac_action_preheating = 214;
constexpr uint16_t ha_hvac_action_defrosting = 215;
constexpr uint16_t ha_cover_open = 216;
constexpr uint16_t ha_cover_closed = 217;
constexpr uint16_t ha_cover_opening = 218;
constexpr uint16_t ha_cover_closing = 219;
constexpr uint16_t ha_media_playing = 220;
constexpr uint16_t ha_media_paused = 221;
constexpr uint16_t ha_media_idle = 222;
constexpr uint16_t ha_media_standby = 223;
constexpr uint16_t ha_person_home = 224;
constexpr uint16_t ha_person_not_home = 225;
constexpr uint16_t ha_sun_above_horizon = 226;
constexpr uint16_t ha_sun_below_horizon = 227;
constexpr uint16_t ha_vacuum_docked = 228;
constexpr uint16_t ha_vacuum_cleaning = 229;
constexpr uint16_t ha_vacuum_paused = 230;
constexpr uint16_t ha_vacuum_returning = 231;
constexpr uint16_t ha_vacuum_idle = 232;
constexpr uint16_t ha_weather_sunny = 233;
constexpr uint16_t ha_weather_clear_night = 234;
constexpr uint16_t ha_weather_cloudy = 235;
constexpr uint16_t ha_weather_partlycloudy = 236;
constexpr uint16_t ha_weather_rainy = 237;
constexpr uint16_t ha_weather_pouring = 238;
constexpr uint16_t ha_weather_snowy = 239;
constexpr uint16_t ha_weather_snowy_rainy = 240;
constexpr uint16_t ha_weather_fog = 241;
constexpr uint16_t ha_weather_hail = 242;
constexpr uint16_t ha_weather_lightning = 243;
constexpr uint16_t ha_weather_lightning_rainy = 244;
constexpr uint16_t ha_weather_windy = 245;
constexpr uint16_t ha_weather_exceptional = 246;
constexpr uint16_t ha_binary_battery_on = 247;
constexpr uint16_t ha_binary_battery_off = 248;
constexpr uint16_t ha_binary_battery_charging_on = 249;
constexpr uint16_t ha_binary_battery_charging_off = 250;
constexpr uint16_t ha_binary_carbon_monoxide_on = 251;
constexpr uint16_t ha_binary_carbon_monoxide_off = 252;
constexpr uint16_t ha_binary_cold_on = 253;
constexpr uint16_t ha_binary_cold_off = 254;
constexpr uint16_t ha_binary_connectivity_on = 255;
constexpr uint16_t ha_binary_connectivity_off = 256;
constexpr uint16_t ha_binary_door_on = 257;
constexpr uint16_t ha_binary_door_off = 258;
constexpr uint16_t ha_binary_garage_door_on = 259;
constexpr uint16_t ha_binary_garage_door_off = 260;
constexpr uint16_t ha_binary_gas_on = 261;
constexpr uint16_t ha_binary_gas_off = 262;
constexpr uint16_t ha_binary_heat_on = 263;
constexpr uint16_t ha_binary_heat_off = 264;
constexpr uint16_t ha_binary_light_on = 265;
constexpr uint16_t ha_binary_light_off = 266;
constexpr uint16_t ha_binary_lock_on = 267;
constexpr uint16_t ha_binary_lock_off = 268;
constexpr uint16_t ha_binary_moisture_on = 269;
constexpr uint16_t ha_binary_moisture_off = 270;
constexpr uint16_t ha_binary_motion_on = 271;
constexpr uint16_t ha_binary_motion_off = 272;
constexpr uint16_t ha_binary_moving_on = 273;
constexpr uint16_t ha_binary_moving_off = 274;
constexpr uint16_t ha_binary_occupancy_on = 275;
constexpr uint16_t ha_binary_occupancy_off = 276;
constexpr uint16_t ha_binary_opening_on = 277;
constexpr uint16_t ha_binary_opening_off = 278;
constexpr uint16_t ha_binary_plug_on = 279;
constexpr uint16_t ha_binary_plug_off = 280;
constexpr uint16_t ha_binary_power_on = 281;
constexpr uint16_t ha_binary_power_off = 282;
constexpr uint16_t ha_binary_presence_on = 283;
constexpr uint16_t ha_binary_presence_off = 284;
constexpr uint16_t ha_binary_problem_on = 285;
constexpr uint16_t ha_binary_problem_off = 286;
constexpr uint16_t ha_binary_running_on = 287;
constexpr uint16_t ha_binary_running_off = 288;
constexpr uint16_t ha_binary_safety_on = 289;
constexpr uint16_t ha_binary_safety_off = 290;
constexpr uint16_t ha_binary_smoke_on = 291;
constexpr uint16_t ha_binary_smoke_off = 292;
constexpr uint16_t ha_binary_sound_on = 293;
constexpr uint16_t ha_binary_sound_off = 294;
constexpr uint16_t ha_binary_tamper_on = 295;
constexpr uint16_t ha_binary_tamper_off = 296;
constexpr uint16_t ha_binary_update_on = 297;
constexpr uint16_t ha_binary_update_off = 298;
constexpr uint16_t ha_binary_vibration_on = 299;
constexpr uint16_t ha_binary_vibration_off = 300;
constexpr uint16_t ha_binary_window_on = 301;
constexpr uint16_t ha_binary_window_off = 302;
constexpr uint16_t ha_button_activate = 303;
constexpr uint16_t ha_button_run = 304;
constexpr uint16_t ha_button_press = 305;
constexpr uint16_t date_weekdays = 18;
constexpr uint16_t date_weekdays_count = 7;
constexpr uint16_t date_weekdays_short = 24;
constexpr uint16_t date_weekdays_short = 25;
constexpr uint16_t date_weekdays_short_count = 7;
constexpr uint16_t date_weekdays_min = 31;
constexpr uint16_t date_weekdays_min = 32;
constexpr uint16_t date_weekdays_min_count = 7;
constexpr uint16_t date_months = 38;
constexpr uint16_t date_months = 39;
constexpr uint16_t date_months_count = 12;
constexpr uint16_t date_months_short = 50;
constexpr uint16_t date_months_short = 51;
constexpr uint16_t date_months_short_count = 12;
} // namespace txt
constexpr uint16_t KEY_COUNT = 305;
constexpr uint32_t KEYS_HASH = 0xC6B1A3EDu;
constexpr uint16_t KEY_COUNT = 306;
constexpr uint32_t KEYS_HASH = 0x5DBB4605u;
} // namespace screen_text
+5 -9
View File
@@ -190,21 +190,17 @@ inline std::string duration_text(int32_t seconds) {
}
// 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,
screen_text::tr(hour < 12 ? screen_text::txt::time_am : screen_text::txt::time_pm));
return buffer;
char buffer[8];
snprintf(buffer, sizeof(buffer), "%02d:%02d", (int) (minutes / 60 % 24), (int) (minutes % 60));
return screen_text::clock_text(buffer, clock_24h);
}
// 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 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);
return buffer;
int value = row.read ? (int) row.read() : 0;
return strcmp(row.unit, "%") == 0 ? screen_text::percent(value) : std::to_string(value) + row.unit;
}
case Kind::duration: return duration_text(row.read ? row.read() : 0);
case Kind::moment: return moment_text(row.read ? row.read() : 0, screen_settings::current.clock_24h != 0);
+3 -3
View File
@@ -243,7 +243,7 @@ inline const char *binary_state_text(const std::string &device_class, bool on) {
}
// Status line beside a control panel: what Home Assistant shows under the name.
inline std::string status_text(const Tile &t) {
auto d = t.domain(); char b[48];
auto d = t.domain();
if (d == "climate") {
std::string text = climate_action_text(t.extra().hvac_action);
if (text.empty()) text = climate_mode_text(t.state);
@@ -252,13 +252,13 @@ inline std::string status_text(const Tile &t) {
}
if (d == "cover") {
std::string text = cover_state_text(t.state);
if (std::isfinite(t.position)) { snprintf(b, sizeof(b), " · %d%%", (int) std::lround(t.position)); text += b; }
if (std::isfinite(t.position)) text += " · " + screen_text::percent((int) std::lround(t.position));
return text;
}
if (d == "media_player") {
const std::string &title = t.extra().media_title;
std::string text = (t.state == "playing" || t.state == "paused") && !title.empty() ? title : media_state_text(t.state);
if (std::isfinite(t.volume) && (t.supported & feature::MEDIA_VOLUME_SET)) { snprintf(b, sizeof(b), " · %d%%", (int) std::lround(t.volume * 100)); text += b; }
if (std::isfinite(t.volume) && (t.supported & feature::MEDIA_VOLUME_SET)) text += " · " + screen_text::percent((int) std::lround(t.volume * 100));
return text;
}
return {};
+14 -4
View File
@@ -27,9 +27,14 @@ Three parts fill themselves. Don't change them by hand:
[Home Assistant's translations](https://developers.home-assistant.io/docs/translations/); we pick it up from there.
- `screen.date` holds day and month names and the order of a date. `screen.number` says how numbers are written.
`screen.time.am` and `.pm` are the day periods. All of this comes from the Unicode CLDR, the same data every browser
uses, through `tools/i18n.py cldr`.
uses, through `tools/i18n.py cldr`. `screen.number.percent` follows Home Assistant's own rule: a space before `%` in
Czech, German, Finnish, French, Slovak and Swedish, none in the other languages.
- `_meta.clock`, 12 or 24 hours, also comes from the CLDR.
How the clock and numbers are written doesn't wait for a translation: `screen_manager/app/regions.json` holds them for
every language Home Assistant offers, also from the CLDR. A Swedish Home Assistant gets English texts on the screens until
there is a `sv.json`, but already the Swedish 24-hour clock and `1 234,5`.
## Check a language
1. Open the file of your language, for example
@@ -130,11 +135,15 @@ firmware update.
- **A new English text:**
- Screen texts: add the key to `en.json` and run `python3 tools/i18n.py header`. That writes
`components/smart_display/screen_text_keys.h`, which the build checks against the JSON. The code uses
`screen_text::tr(txt::...)`.
`screen_text::tr(txt::...)`. A number with a unit goes through `screen_text::with_unit`, a percentage through
`screen_text::percent`, so the spacing is Home Assistant's.
- App texts: use `i18n.t(...)` for the editor's language, and `i18n.screen_t(...)` for the screens' language.
- A text that is stored or made outside a request uses `i18n.english(...)`. That covers a delivery status and an
update result. `i18n.shown(...)` puts it in the language of the editor that asks. Numbers and times for the
screens go through `i18n.screen_number(...)` and `i18n.screen_clock(...)`.
screens go through `i18n.screen_number(...)` and `i18n.screen_clock(...)`, units through `i18n.unit_suffix(...)`.
- A screen gets the app's words in the language its firmware speaks (its "Screen language" sensor), so a screen that
isn't updated yet stays in one language; firmware from before the languages gets English and its own letters.
`Manager.sync_one` sets that screen for the texts it writes (`i18n.SCREEN`).
- Editor texts: go through vue-i18n.
- **Every release:** Claude drafts the new texts for every language, with Home Assistant's own words as the glossary,
and leaves them unchecked for a speaker of the language. A missing text shows in English and never blocks a release.
@@ -150,7 +159,8 @@ firmware update.
python3 tools/i18n.py cldr --write
```
`ha-words` needs a Home Assistant: `HA_URL` and `HA_TOKEN` in the environment.
`ha-words` needs a Home Assistant: `HA_URL` and `HA_TOKEN` in the environment. When Home Assistant adds a language,
add its code to `HA_LANGUAGES` in `tools/i18n.py` and run `cldr --write` for its clock and numbers.
- **What stays English everywhere:**
- the names of the screens' own entities in Home Assistant (renaming one gives it a new entity id);
- log lines;
+5 -5
View File
@@ -679,7 +679,7 @@ font:
'Ĺ','ĺ','Ľ','ľ','Ŕ','ŕ','Ő','ő','Ű','ű','Ă','ă','Ș','ș','Ț','ț','Đ','đ','Ā','ā','Ē','ē','Ģ','ģ',
'Ī','ī','Ķ','ķ','Ļ','ļ','Ņ','ņ','Ū','ū','Ė','ė','Į','į','Ų','ų','Ğ','ğ','İ','ı','Ş','ş','Ċ','ċ',
'Ġ','ġ','Ħ','ħ','Ŵ','ŵ','Ŷ','ŷ','Ŀ','ŀ','Œ','œ','Ÿ','„','“','”','‘','’','«','»','‹','›','¿','¡',
'€','£']
'€','£','²','³','µ']
- file: "${FONT_DIR}/Roboto-500.ttf"
id: watch_value
@@ -696,7 +696,7 @@ font:
'Ĺ','ĺ','Ľ','ľ','Ŕ','ŕ','Ő','ő','Ű','ű','Ă','ă','Ș','ș','Ț','ț','Đ','đ','Ā','ā','Ē','ē','Ģ','ģ',
'Ī','ī','Ķ','ķ','Ļ','ļ','Ņ','ņ','Ū','ū','Ė','ė','Į','į','Ų','ų','Ğ','ğ','İ','ı','Ş','ş','Ċ','ċ',
'Ġ','ġ','Ħ','ħ','Ŵ','ŵ','Ŷ','ŷ','Ŀ','ŀ','Œ','œ','Ÿ','„','“','”','‘','’','«','»','‹','›','¿','¡',
'€','£']
'€','£','²','³','µ']
# Clock card digits only; the date line reuses the tile title font.
- file: "${FONT_DIR}/Roboto-500.ttf"
@@ -710,7 +710,7 @@ font:
id: setpoint_digits
size: ${FONT_SETPOINT_SIZE}
bpp: 4
glyphs: ['-','.','°','0','1','2','3','4','5','6','7','8','9']
glyphs: ['-','.',',','°','0','1','2','3','4','5','6','7','8','9']
- file: "${FONT_DIR}/Roboto-700.ttf"
id: label
@@ -727,7 +727,7 @@ font:
'Ĺ','ĺ','Ľ','ľ','Ŕ','ŕ','Ő','ő','Ű','ű','Ă','ă','Ș','ș','Ț','ț','Đ','đ','Ā','ā','Ē','ē','Ģ','ģ',
'Ī','ī','Ķ','ķ','Ļ','ļ','Ņ','ņ','Ū','ū','Ė','ė','Į','į','Ų','ų','Ğ','ğ','İ','ı','Ş','ş','Ċ','ċ',
'Ġ','ġ','Ħ','ħ','Ŵ','ŵ','Ŷ','ŷ','Ŀ','ŀ','Œ','œ','Ÿ','„','“','”','‘','’','«','»','‹','›','¿','¡',
'€','£']
'€','£','²','³','µ']
- file: "${FONT_DIR}/Roboto-400.ttf"
id: sublabel
@@ -744,7 +744,7 @@ font:
'Ĺ','ĺ','Ľ','ľ','Ŕ','ŕ','Ő','ő','Ű','ű','Ă','ă','Ș','ș','Ț','ț','Đ','đ','Ā','ā','Ē','ē','Ģ','ģ',
'Ī','ī','Ķ','ķ','Ļ','ļ','Ņ','ņ','Ū','ū','Ė','ė','Į','į','Ų','ų','Ğ','ğ','İ','ı','Ş','ş','Ċ','ċ',
'Ġ','ġ','Ħ','ħ','Ŵ','ŵ','Ŷ','ŷ','Ŀ','ŀ','Œ','œ','Ÿ','„','“','”','‘','’','«','»','‹','›','¿','¡',
'€','£']
'€','£','²','³','µ']
- file: "${FONT_DIR}/Roboto-400.ttf"
id: sublabel_big
+2 -1
View File
@@ -283,13 +283,14 @@ Every screen has these entities in Home Assistant, on its ESPHome device. `<scre
| `switch.<screen>_dark_mode` | On: the dark look, a black page with graphite cards and soft white text, for a screen beside a bed or in a dark room. Off: the light look. Firmware {DARK_MODE_MIN_FIRMWARE} or newer. |
| `switch.<screen>_night_mode` | Night mode: the night brightness between the two times below. Firmware {SETTING_ENTITIES_MIN_FIRMWARE} or newer, like every row below it. |
| `time.<screen>_night_starts`, `time.<screen>_night_ends` | The night hours; set them with `time.set_value`. |
| `switch.<screen>_24_hour_clock` | On: 24-hour clock. Off: 12-hour clock. |
| `switch.<screen>_back_to_page_1` | On: after `number.<screen>_back_to_page_1_after` seconds without a touch (30 to 3600) the screen closes a card and goes back to page 1. Pressing Wake, an alert or Auto standby don't count as a touch. |
| `switch.<screen>_back_to_page_1_on_standby` | On: going into standby also goes back to page 1. |
| `switch.<screen>_swipe_between_pages` | On: swipe between pages. |
| `switch.<screen>_page_buttons` | On: the Previous and Next bar under the tiles on a screen with more than one page. Off: no bar, the tiles take its room, and only swiping or Go to page tiles change the page. Firmware {PAGE_BUTTONS_MIN_FIRMWARE} or newer. |
| `select.<screen>_rotation` | Guition only: `0°`, `90°`, `180°` or `270°`. |
The 12 or 24-hour clock, the language and how numbers are written are one choice for every screen, in ESP Screens under Settings → Language & region; no entity changes them (firmware 0.2.76 or newer; older firmware still has `switch.<screen>_24_hour_clock`).
Wake and Sleep are buttons: press them with the `button.press` action. They save nothing, so an automation may press them as often as it likes, on every motion too. To reach several screens at once, list their buttons under `entity_id`. Don't target an area or a device with `button.press`: that presses every other button there as well, the Wake and Sleep of the same screen included.
```yaml
+4
View File
@@ -1533,6 +1533,9 @@ def discover_screens(registry, states, devices, areas):
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})?')
# Firmware from before the languages (0.2.75 and older) has no such sensor: it speaks English, with fewer letters.
speaks = {item.get('device_id') for item in registry
if item.get('platform') == 'esphome' and item.get('original_name') in NAME_SCREEN_LANGUAGE}
screens = []
for item in registry:
eid = item['entity_id']
@@ -1551,6 +1554,7 @@ def discover_screens(registry, states, devices, areas):
'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')),
'language_sensor': item.get('device_id') in speaks,
'device': device.get('name') or '',
'area': area, 'online': state.get('state') not in (None, 'unknown', 'unavailable'),
# What the screen reports (a word of the firmware's protocol), or ours in English, which the editor
+57 -15
View File
@@ -3,6 +3,7 @@ import asyncio
from collections import deque
import glob
import json
import logging
import os
from pathlib import Path
import re
@@ -13,6 +14,8 @@ import yaml
from core import REPO, installation_yaml
from i18n import t
LOG = logging.getLogger('screen_manager')
# libyaml parses a 300 KB profile roughly ten times faster than the pure-Python loader.
class LenientLoader(yaml.CSafeLoader if getattr(yaml, '__with_libyaml__', False) else yaml.SafeLoader):
"""Reads profile metadata without resolving !secret or !include."""
@@ -65,6 +68,7 @@ class Firmware:
self.images = {}
self.downloaded = set()
self._names = {} # file -> (stat signature, meta or None); parsed only when the file changes
self.language = None # the language builds speak: the manager's Settings -> Language & region (app 0.2.90)
def profiles(self):
if not self.root.exists(): return []
@@ -192,26 +196,57 @@ class Firmware:
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."""
left alone, as is an English one without the line (English is the packages' own default), and one whose
substitutions this can't edit safely (an !include, a {...} mapping). The result is read back, and written only
when LANGUAGE is the one thing that changed. 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:
raw = profile.read_bytes().decode('utf-8')
if 'homeassistant_espscreen' not in raw and 'packages/core.yaml' not in raw:
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':
newline = '\r\n' if '\r\n' in raw else '\n'
text = raw.replace('\r\n', '\n')
if not text.endswith('\n'):
text += '\n'
before = yaml.load(text, Loader=LenientLoader)
if not isinstance(before, dict):
return False
substitutions = before.get('substitutions')
if 'substitutions' in before and not isinstance(substitutions, dict):
LOG.warning('%s: its substitutions are not a plain block, so its language stays as it is', profile.name)
return False
substitutions = substitutions or {}
if substitutions.get('LANGUAGE') == language or (language == 'en' and 'LANGUAGE' not in substitutions):
return False
line = f'LANGUAGE: {json.dumps(language)}'
block = re.search(r'(?m)^substitutions:[ \t]*(?:#.*)?\n', text)
if block:
# The block: indented lines, blank ones and comments, up to the next key at the start of a line.
body = re.compile(r'(?:(?:[ \t]+.*|[ \t]*|#.*)\n)*').match(text, block.end())
indent = re.search(r'(?m)^([ \t]+)\S', body.group(0))
indent = indent.group(1) if indent else ' '
current = re.search(r'(?m)^[ \t]+["\']?LANGUAGE["\']?[ \t]*:.*$', body.group(0))
if current:
lines = body.group(0)[:current.start()] + indent + line + body.group(0)[current.end():]
else:
lines = indent + line + '\n' + body.group(0)
updated = text[:body.start()] + lines + text[body.end():]
elif 'substitutions' in before:
LOG.warning('%s: its substitutions are not a plain block, so its language stays as it is', profile.name)
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:
# After the comments (and a document start) at the top of the file.
head = re.match(r'(?:(?:#.*|[ \t]*|---[ \t]*)\n)*', text)
updated = text[:head.end()] + f'substitutions:\n {line}\n\n' + text[head.end():]
after = yaml.load(updated, Loader=LenientLoader)
def rest(data):
data = dict(data)
rest_substitutions = {k: v for k, v in (data.get('substitutions') or {}).items() if k != 'LANGUAGE'}
data.pop('substitutions', None)
return data, rest_substitutions
if not isinstance(after, dict) or (after.get('substitutions') or {}).get('LANGUAGE') != language or rest(after) != rest(before):
LOG.warning('%s: its language could not be written without changing more, so it stays as it is', profile.name)
return False
self._atomic_write(profile, updated)
self._atomic_write(profile, updated.replace('\n', newline))
self._names.pop(profile.name, None)
return True
@@ -356,6 +391,13 @@ class Firmware:
profile = self.profile(data.get('file'))
action = data.get('action')
if action not in ('validate','build','install','download'): raise ValueError(t('addon.errors.firmware.unknown_action'))
# Every build speaks the language of Settings -> Language & region, also for a profile made or changed since
# that language was set (app 0.2.90).
if action != 'validate' and callable(self.language):
try:
self.set_language(profile.name, self.language())
except (OSError, ValueError, yaml.YAMLError) as error:
LOG.warning('Could not write the language into %s (%s)', profile.name, error)
if not shutil.which('esphome'): raise ValueError(t('addon.errors.firmware.no_esphome'))
target = data.get('target','')
if action == 'install':
+7 -6
View File
@@ -24,6 +24,8 @@ from i18n import TRANSLATIONS, screen_number, screen_t, t
# 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/…·\'#*=;²³µ–ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿĄąĆćĘꣳŃńŚśŹźŻżČčĎďĚěŇňŘřŠšŤťŮůŽžĹ弾ŔŕŐőŰűĂăȘșȚțĐđĀāĒēĢģĪīĶķĻļŅņŪūĖėĮįŲųĞğİıŞşĊċĠġĦħŴŵŶŷĿŀŒœŸ„“”‘’«»‹›¿¡€£')
# The letters of firmware from before the languages (0.2.75 and older), which gets what it can draw.
LEGACY_GLYPHS = frozenset("<>—&@!,.?\"%()+-_:°0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyzäöüÄÖÜß/…·'#*=;²³µéèëêïîóôàáâçñúû–")
TEXT_BYTES = 40
# The months in English, the language of reference (tests/test_header_bar.py); a screen gets them in its own language
# (short_date, app 0.2.90).
@@ -115,13 +117,14 @@ ALARM_CLASSES = frozenset(['battery', 'carbon_monoxide', 'gas', 'heat', 'lock',
def clean_text(text):
"""Text the screen can draw: known glyphs, accents folded to the base letter, at most TEXT_BYTES."""
glyphs = LEGACY_GLYPHS if i18n.legacy_screen() else GLYPHS
out = []
for char in unicodedata.normalize('NFC', str(text)):
if char in GLYPHS:
if char in glyphs:
out.append(char)
continue
base = unicodedata.normalize('NFKD', char)[0]
if base in GLYPHS:
if base in glyphs:
out.append(base)
return short(''.join(out).strip(), TEXT_BYTES)
@@ -142,10 +145,8 @@ def number_text(value, precision=None):
return screen_number(text)
def with_unit(text, unit):
"""Home Assistant's spacing: "21.3 °C", "65%", "18°"."""
if not unit:
return text
return text + unit if unit in ('%', '°') else f'{text} {unit}'
"""Home Assistant's spacing: "21.3 °C", "65%" ("65 %" in German and French), "18°"."""
return text + i18n.unit_suffix(unit)
def precision_of(entry):
options = ((entry or {}).get('options') or {}).get('sensor') or {}
+110 -22
View File
@@ -11,6 +11,7 @@ changed their profile: the clock and the numbers follow the language.
"""
import contextvars
import json
import math
import logging
import os
import re
@@ -28,7 +29,8 @@ 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
# C++'s remainder (the sign of n), as the firmware and the editor count.
t, h = int(math.fmod(n, 10)), int(math.fmod(n, 100))
if one:
return 0
return 1 if 2 <= t <= 4 and not 12 <= h <= 14 else 2
@@ -37,7 +39,7 @@ 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),
'east_slavic': lambda n: _slavic(n, math.fmod(n, 10) == 1 and math.fmod(n, 100) != 11),
'none': lambda n: 0,
}
@@ -88,7 +90,12 @@ class Translations:
return 'en'
def meta(self, code):
return self.data.get(self.resolve(code), {}).get('_meta', {})
"""A language's `_meta`, its base language's for what a small variant file (en-GB) leaves out."""
language = self.resolve(code)
merged = {}
for name in reversed(list(dict.fromkeys([language, self.resolve(language.split('-')[0])]))):
merged.update(self.data.get(name, {}).get('_meta', {}))
return merged
def languages(self):
"""Every language for the settings: English first, then by English name."""
@@ -97,14 +104,20 @@ class Translations:
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."""
def chain(self, language):
"""The files a language's texts come from, most specific first: pt-BR, then pt, then English."""
language = self.resolve(language)
base = self.resolve(language.split('-')[0])
return list(dict.fromkeys([language, base, 'en']))
def text(self, key, language='en', **params):
"""`key` in `language`, else in its base language (pt-BR -> pt), else in English, with {placeholders} filled;
`n` picks a plural form."""
language = self.resolve(language)
text = self.flat.get(language, {}).get(key)
# Only an empty text is missing: a separator of one space (French thousands) is a text.
if not isinstance(text, str) or text == '':
text = self.flat['en'].get(key)
if not isinstance(text, str) or text == '':
text = next((found for found in (self.flat.get(code, {}).get(key) for code in self.chain(language))
if isinstance(found, str) and found != ''), None)
if text is None:
return key
if '|' in text and 'n' in params:
forms = [form.strip() for form in text.split('|')]
@@ -169,32 +182,76 @@ def shown(value):
return value.into(REQUEST_LANGUAGE.get()) if isinstance(value, Text) else value
# How every Home Assistant language writes the clock and numbers (tools/i18n.py cldr writes it from the Unicode CLDR):
# Automatic in Settings -> Language & region follows it, also for a language ESP Screens has no texts for yet.
try:
REGIONS = {code: value for code, value in json.loads((HERE / 'regions.json').read_text(encoding='utf-8')).items()
if isinstance(value, dict)}
except (OSError, ValueError):
REGIONS = {}
# The screens' language and number format (Settings -> Language & region), for the words the app sends to the screens
# itself: tile names, the top bar, the history card. The manager keeps it current (Manager.language_changed).
SCREENS = {'language': 'en', 'numbers': 'point', 'clock_24h': True}
SCREENS = {'language': 'en', 'numbers': 'point', 'clock_24h': True, 'group_min': 1, 'percent_space': False}
# The screen whose messages the app is writing now (Manager.sync_one), for the words that go to that screen alone: the
# language its firmware speaks, which can still be the one before an update, and whether that firmware predates the
# languages (English, fewer letters, numbers of its own).
SCREEN = contextvars.ContextVar('screen', default=None)
LEGACY = {'language': 'en', 'legacy': True, 'numbers': 'point', 'group_min': 1, 'percent_space': False}
def set_screens(language, numbers, clock_24h=True):
SCREENS.update(language=language, numbers=numbers, clock_24h=clock_24h)
def set_screens(language, numbers, clock_24h=True, group_min=1, percent_space=False):
SCREENS.update(language=language, numbers=numbers, clock_24h=clock_24h, group_min=group_min, percent_space=percent_space)
def screen_context(screen):
"""What SCREEN holds for a screen: firmware without the "Screen language" sensor predates the languages; one whose
sensor has no state yet (restarting) counts as speaking the screens' language."""
if not screen:
return None
if not screen.get('language_sensor'):
return LEGACY
return {'language': TRANSLATIONS.resolve(screen.get('language') or SCREENS['language']), 'legacy': False}
def _screen(name):
"""A property of the screen being written for, else of all screens."""
screen = SCREEN.get()
return screen[name] if screen and name in screen else SCREENS[name]
def legacy_screen():
screen = SCREEN.get()
return bool(screen and screen.get('legacy'))
def screen_t(key, **params):
"""A text in the screens' language, for what the app sends to them (and the notification it leaves in Home
Assistant); a param that is a Text goes into that language too."""
return _render(key, SCREENS['language'], params)
return _render(key, _screen('language'), params)
def screen_clock(hour, minute):
"""A time of day as the screens write it: "07:30" on 24 hours, "7:30 AM" on 12, in the language's day periods."""
if SCREENS.get('clock_24h', True):
return f'{hour:02d}:{minute:02d}'
period = TRANSLATIONS.text('screen.time.am' if hour < 12 else 'screen.time.pm', SCREENS['language'])
period = TRANSLATIONS.text('screen.time.am' if hour < 12 else 'screen.time.pm', _screen('language'))
return f'{hour % 12 or 12}:{minute:02d} {period}'
def screen_number(text):
"""A number as Home Assistant sends it, written as the screens write numbers ("1.234,5" in Dutch)."""
return format_number(text, SCREENS['numbers'])
return format_number(text, _screen('numbers'), _screen('group_min'))
def unit_suffix(unit):
"""What follows a number for its unit, spaced as Home Assistant spaces them (blankBeforeUnit): nothing before "°",
" %" or "%" by the language, one space before any other unit."""
if not unit or unit == '°':
return unit or ''
if unit == '%':
return ' %' if _screen('percent_space') else '%'
return ' ' + unit
class Region:
@@ -204,6 +261,8 @@ class Region:
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'
# The 24 hours an app from before Language & region pinned, until Home Assistant tells what the screens showed.
self.pinned = False
# Home Assistant's own language (get_config), once connected.
self.ha_language = ha_language or (lambda: None)
self.stored = self.path.exists()
@@ -216,13 +275,15 @@ class Region:
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'
self.pinned = raw.get('pinned') is True
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)
json.dump({'version': 1, 'language': self.setting, 'clock': self.clock, 'numbers': self.numbers,
**({'pinned': True} if self.pinned else {})}, handle)
handle.flush()
os.fsync(handle.fileno())
temporary.replace(self.path)
@@ -239,36 +300,63 @@ class Region:
raise ValueError(t('addon.errors.language_choice'))
if clock not in CLOCKS or numbers not in NUMBERS:
raise ValueError(t('addon.errors.region_choice'))
# The owner choosing a clock ends the pin.
self.setting, self.clock, self.numbers = setting, clock, numbers
self.pinned = self.pinned and 'clock' not in data
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 written(self):
"""How the language writes the clock and numbers: Home Assistant's own language (sv, pt-BR) even when ESP Screens
shows it English or Portuguese texts, the chosen language when there is one. From regions.json, else from the
language's file."""
code = self.setting if self.setting != 'auto' else (self.ha_language() or 'en')
for candidate in (code, str(code).split('-')[0]):
found = REGIONS.get(candidate)
if found:
return found
language = self.tr.resolve(code)
return {'clock': '24' if self.tr.clock_24h(language) else '12', 'numbers': self.tr.number_style(language),
'group_min': 2 if self.tr.text('screen.number.group_min', language) == '2' else 1,
'percent_space': self.tr.text('screen.number.percent', language).startswith(' ')}
def clock_24h(self):
return self.tr.clock_24h(self.language()) if self.clock == 'auto' else self.clock == '24'
return self.written().get('clock') != '12' 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
return self.written().get('numbers', 'point') if self.numbers == 'auto' else self.numbers
def group_min(self):
"""CLDR's minimum grouping digits of the numbers the screens write: 2 is 1234 but 12.345. A chosen format is
Home Assistant's for it (en, de or fr), which groups from 1,234."""
return int(self.written().get('group_min', 1)) if self.numbers == 'auto' else 1
def percent_space(self):
"""Whether the language puts a space before "%" (Home Assistant's rule, whatever the number format)."""
return bool(self.written().get('percent_space'))
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()}
'numbers': self.numbers, 'numbers_effective': self.number_style(),
'group_min': self.group_min(), 'percent_space': self.percent_space()}
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."""
def format_number(text, style, group_min=1):
"""A number as Home Assistant sends it ("1234.5") written in `style` ("1.234,5" for comma); anything else unchanged.
`group_min` 2 leaves four digits whole (1234, but 12.345), as the language itself does."""
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:
if len(whole) > (4 if group_min == 2 else 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 '')
+381
View File
@@ -0,0 +1,381 @@
{
"_about": "GENERATED by tools/i18n.py cldr: how every Home Assistant language writes the clock and numbers (Unicode CLDR, Home Assistant's own rule for the space before %). The app takes it for Settings -> Language & region set to Automatic.",
"af": {
"clock": "24",
"numbers": "space",
"group_min": 1,
"percent_space": false
},
"ar": {
"clock": "12",
"numbers": "point",
"group_min": 1,
"percent_space": false
},
"bg": {
"clock": "24",
"numbers": "space",
"group_min": 2,
"percent_space": false
},
"bn": {
"clock": "12",
"numbers": "point",
"group_min": 1,
"percent_space": false
},
"bs": {
"clock": "24",
"numbers": "comma",
"group_min": 1,
"percent_space": false
},
"ca": {
"clock": "24",
"numbers": "comma",
"group_min": 1,
"percent_space": false
},
"cs": {
"clock": "24",
"numbers": "space",
"group_min": 1,
"percent_space": true
},
"cy": {
"clock": "24",
"numbers": "point",
"group_min": 1,
"percent_space": false
},
"da": {
"clock": "24",
"numbers": "comma",
"group_min": 1,
"percent_space": false
},
"de": {
"clock": "24",
"numbers": "comma",
"group_min": 1,
"percent_space": true
},
"el": {
"clock": "12",
"numbers": "comma",
"group_min": 1,
"percent_space": false
},
"en": {
"clock": "12",
"numbers": "point",
"group_min": 1,
"percent_space": false
},
"en-GB": {
"clock": "24",
"numbers": "point",
"group_min": 1,
"percent_space": false
},
"eo": {
"clock": "24",
"numbers": "space",
"group_min": 1,
"percent_space": false
},
"es": {
"clock": "24",
"numbers": "comma",
"group_min": 2,
"percent_space": false
},
"et": {
"clock": "24",
"numbers": "space",
"group_min": 2,
"percent_space": false
},
"eu": {
"clock": "24",
"numbers": "comma",
"group_min": 1,
"percent_space": false
},
"fa": {
"clock": "24",
"numbers": "point",
"group_min": 1,
"percent_space": false
},
"fi": {
"clock": "24",
"numbers": "space",
"group_min": 1,
"percent_space": true
},
"fy": {
"clock": "24",
"numbers": "comma",
"group_min": 1,
"percent_space": false
},
"fr": {
"clock": "24",
"numbers": "space",
"group_min": 1,
"percent_space": true
},
"ga": {
"clock": "24",
"numbers": "point",
"group_min": 1,
"percent_space": false
},
"gl": {
"clock": "24",
"numbers": "comma",
"group_min": 1,
"percent_space": false
},
"gsw": {
"clock": "24",
"numbers": "point",
"group_min": 1,
"percent_space": false
},
"he": {
"clock": "24",
"numbers": "point",
"group_min": 1,
"percent_space": false
},
"hi": {
"clock": "12",
"numbers": "point",
"group_min": 1,
"percent_space": false
},
"hr": {
"clock": "24",
"numbers": "comma",
"group_min": 1,
"percent_space": false
},
"hu": {
"clock": "24",
"numbers": "space",
"group_min": 2,
"percent_space": false
},
"hy": {
"clock": "24",
"numbers": "space",
"group_min": 1,
"percent_space": false
},
"id": {
"clock": "24",
"numbers": "comma",
"group_min": 1,
"percent_space": false
},
"it": {
"clock": "24",
"numbers": "comma",
"group_min": 2,
"percent_space": false
},
"is": {
"clock": "24",
"numbers": "comma",
"group_min": 1,
"percent_space": false
},
"ja": {
"clock": "24",
"numbers": "point",
"group_min": 1,
"percent_space": false
},
"ka": {
"clock": "24",
"numbers": "space",
"group_min": 2,
"percent_space": false
},
"ko": {
"clock": "12",
"numbers": "point",
"group_min": 1,
"percent_space": false
},
"lb": {
"clock": "24",
"numbers": "comma",
"group_min": 1,
"percent_space": false
},
"lt": {
"clock": "24",
"numbers": "space",
"group_min": 1,
"percent_space": false
},
"lv": {
"clock": "24",
"numbers": "space",
"group_min": 2,
"percent_space": false
},
"mk": {
"clock": "24",
"numbers": "comma",
"group_min": 1,
"percent_space": false
},
"ml": {
"clock": "12",
"numbers": "point",
"group_min": 1,
"percent_space": false
},
"nl": {
"clock": "24",
"numbers": "comma",
"group_min": 1,
"percent_space": false
},
"nb": {
"clock": "24",
"numbers": "space",
"group_min": 1,
"percent_space": false
},
"nn": {
"clock": "24",
"numbers": "space",
"group_min": 1,
"percent_space": false
},
"pl": {
"clock": "24",
"numbers": "space",
"group_min": 2,
"percent_space": false
},
"pt": {
"clock": "24",
"numbers": "space",
"group_min": 2,
"percent_space": false
},
"pt-BR": {
"clock": "24",
"numbers": "comma",
"group_min": 1,
"percent_space": false
},
"ro": {
"clock": "24",
"numbers": "comma",
"group_min": 1,
"percent_space": false
},
"ru": {
"clock": "24",
"numbers": "space",
"group_min": 1,
"percent_space": false
},
"sk": {
"clock": "24",
"numbers": "space",
"group_min": 1,
"percent_space": true
},
"sl": {
"clock": "24",
"numbers": "comma",
"group_min": 2,
"percent_space": false
},
"sr": {
"clock": "24",
"numbers": "comma",
"group_min": 1,
"percent_space": false
},
"sr-Latn": {
"clock": "24",
"numbers": "comma",
"group_min": 1,
"percent_space": false
},
"sv": {
"clock": "24",
"numbers": "space",
"group_min": 1,
"percent_space": true
},
"sq": {
"clock": "12",
"numbers": "space",
"group_min": 2,
"percent_space": false
},
"ta": {
"clock": "12",
"numbers": "point",
"group_min": 1,
"percent_space": false
},
"te": {
"clock": "12",
"numbers": "point",
"group_min": 1,
"percent_space": false
},
"th": {
"clock": "24",
"numbers": "point",
"group_min": 1,
"percent_space": false
},
"tr": {
"clock": "24",
"numbers": "comma",
"group_min": 1,
"percent_space": false
},
"uk": {
"clock": "24",
"numbers": "space",
"group_min": 1,
"percent_space": false
},
"ur": {
"clock": "12",
"numbers": "point",
"group_min": 1,
"percent_space": false
},
"vi": {
"clock": "24",
"numbers": "comma",
"group_min": 1,
"percent_space": false
},
"zh-Hans": {
"clock": "24",
"numbers": "point",
"group_min": 1,
"percent_space": false
},
"zh-Hant": {
"clock": "12",
"numbers": "point",
"group_min": 1,
"percent_space": false
}
}
+68 -25
View File
@@ -570,9 +570,9 @@ class Manager:
# 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.firmware.language = self.region.language
self.ha.on_config = self.language_changed
self.pin_clock = False
i18n.set_screens(self.region.language(), self.region.number_style(), self.region.clock_24h())
self.set_screens()
self.skill_dir = claude_skill.skill_dir()
self._registry_source, self._registry_index = None, {}
self._items_source, self._items = None, []
@@ -605,12 +605,36 @@ 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'
# An app from before Language & region (app 0.2.90): its screens keep the 24 hours they show, until Home Assistant
# says every screen's own clock setting was 12 hours (resolve_clock_pin). A new install starts on Automatic, and
# is stored right away so a restart never takes it for an old one.
if not self.region.stored:
if self.layouts:
self.region.clock, self.region.pinned = '24', True
self.region.save()
self.pin_clock = True
def resolve_clock_pin(self):
"""The pinned 24 hours of an app from before Language & region become 12 when every screen's own "24-hour clock"
switch (firmware before 0.2.76) was off; they stay 24 when one was on or none is left. Waits while a switch has no
state yet (a screen restarts). True when the clock changed."""
if not self.region.pinned or not self.ha.online or not getattr(self.ha, 'registry', None):
return False
switches = [item['entity_id'] for item in self.ha.registry if item.get('platform') == 'esphome'
and item.get('original_name') == '24-hour clock' and not item.get('disabled_by')]
states = [self.ha.states.get(entity, {}).get('state') for entity in switches]
if any(state not in ('on', 'off') for state in states):
return False
self.region.pinned = False
twelve = bool(states) and all(state == 'off' for state in states)
if twelve:
self.region.clock = '12'
self.region.save()
return twelve
def set_screens(self):
"""What the app writes for the screens follows Settings -> Language & region (i18n.SCREENS)."""
region = self.region
i18n.set_screens(region.language(), region.number_style(), region.clock_24h(), region.group_min(), region.percent_space())
def inventory(self):
"""(screens, every tile/top-bar entity): the full walk over the registry, for the editor and saving."""
@@ -953,11 +977,17 @@ class Manager:
action = self.transport(inbox, screen)
if what is None or not action:
return
await self.ha.send(inbox, await self.card_history(entity, hours, what), action)
# Its words in the language of this screen's firmware (app 0.2.90).
token = i18n.SCREEN.set(i18n.screen_context(screen))
try:
await self.ha.send(inbox, await self.card_history(entity, hours, what), action)
finally:
i18n.SCREEN.reset(token)
async def card_history(self, entity, hours, what):
"""The history message, from a short cache; screens asking at the same time share one fetch."""
key = (entity, hours)
"""The history message, from a short cache; screens asking at the same time share one fetch, per language."""
context = i18n.SCREEN.get() or {}
key = (entity, hours, context.get('language'), bool(context.get('legacy')))
cached = self.card_histories.get(key)
if cached and time.monotonic() - cached[0] < history_card.CACHE_SECONDS[hours]:
return cached[1]
@@ -1145,15 +1175,8 @@ class Manager:
"""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()
i18n.set_screens(self.region.language(), self.region.number_style(), self.region.clock_24h())
self.resolve_clock_pin()
self.set_screens()
self.ha.services_changed.set()
self.sent.clear()
self.write_languages()
@@ -1274,6 +1297,9 @@ class Manager:
# 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()
# From 1234 or 12.345 on, and the space before "%" of the language (firmware 0.2.76+, app 0.2.90).
message['group_min'] = self.region.group_min()
message['percent_space'] = self.region.percent_space()
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).
@@ -1288,6 +1314,15 @@ class Manager:
sent. None rebuilds every message and sends the differences; `force` sends everything."""
if screen is None:
screen = self.screen(inbox) or {}
# The words go in the language this screen's firmware speaks, which is the old one until its update (app 0.2.90).
context = i18n.screen_context(screen)
token = i18n.SCREEN.set(context)
try:
return await self._sync_one(inbox, layout, force, screen, dirty, context)
finally:
i18n.SCREEN.reset(token)
async def _sync_one(self, inbox, layout, force, screen, dirty, context):
needed = self.needs_firmware(inbox, layout, screen)
if needed:
self.status[inbox]=english('addon.status.firmware_needed', version=needed)
@@ -1301,7 +1336,9 @@ class Manager:
self.status[inbox] = english('addon.status.too_large')
return False
previous = self.sent.get(inbox)
full = force or not previous or previous['layout'] != layout_msg or dirty is None
# A screen that now speaks another language (an update, or its sensor reporting after a restart) gets every
# message written anew.
full = force or not previous or previous['layout'] != layout_msg or dirty is None or previous.get('context') != context
# The top bar right after the layout (firmware 0.2.32+; older firmware draws the clock of show_clock).
header_msg = None
if self.supports_header(inbox, screen):
@@ -1335,7 +1372,7 @@ class Manager:
# sent (settings the screen reported itself, see store_settings) keeps the one it had.
sent_layout = any(message is layout_msg for message in outgoing)
rev = layout_msg['rev'] if sent_layout or not previous else previous.get('rev', layout_msg['rev'])
self.sent[inbox] = {'layout': layout_msg, 'header': header_msg, 'states': states, 'rev': rev}
self.sent[inbox] = {'layout': layout_msg, 'header': header_msg, 'states': states, 'rev': rev, 'context': context}
# The hourly timer follows a real full transmission; any message refreshes the screen's
# feed window, so the ping timer follows whatever went out (a rebuild without differences,
# after a registry refresh, must not postpone the ping).
@@ -1414,6 +1451,8 @@ class Manager:
event.get('inbox') if isinstance(event, dict) else '?', error)
if hasattr(self.ha,'setting_events'): self.ha.setting_events.clear()
dirty = self.take_dirty()
if self.region.pinned and self.resolve_clock_pin():
self.language_changed()
screens_key = self._screens_key
screens = self.screens()
changed = self._screens_key != screens_key
@@ -1915,11 +1954,15 @@ def create_app(manager, development=False):
return ready[0]
async def identify(request):
"""Identify (app 0.2.73): the screen shows a short card and blinks its backlight, so you know which one it is. The
card speaks the screens' language (app 0.2.90)."""
card speaks the language of the screen's firmware (app 0.2.90)."""
screen = one_alert_target(request.match_info['inbox'])
data, _ = alert_data({'title': screen_t('addon.screen.identify.title', name=screen['name']),
'subtitle': screen_t('addon.screen.identify.subtitle'), 'icon': 'bell-ring',
'color': 'blue', 'button_text': screen_t('screen.alert.ok'), 'timeout': 8, 'flash': True})
token = i18n.SCREEN.set(i18n.screen_context(screen))
try:
data, _ = alert_data({'title': screen_t('addon.screen.identify.title', name=screen['name']),
'subtitle': screen_t('addon.screen.identify.subtitle'), 'icon': 'bell-ring',
'color': 'blue', 'button_text': screen_t('screen.alert.ok'), 'timeout': 8, 'flash': True})
finally:
i18n.SCREEN.reset(token)
await manager.ha.call(alert_service(screen['node']), data)
return web.json_response({'ok': True})
async def test_alert(request):
+13 -3
View File
@@ -103,9 +103,19 @@ class Updater:
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."""
"Screen language" sensor, or English for firmware from before that sensor. A sensor without a state yet (the
screen restarts) says nothing."""
region = getattr(self.manager, 'region', None)
return bool(region) and (screen.get('language') or 'en') != region.language()
if not region or (screen.get('language_sensor') and not screen.get('language')):
return False
return (screen.get('language') or 'en') != region.language()
def speaks_region(self, screen):
"""Whether the screen says it speaks the language of Settings -> Language & region: what an update waits for."""
region = getattr(self.manager, 'region', None)
if not region:
return True
return (screen.get('language') if screen.get('language_sensor') else 'en') == region.language()
def state_for(self, screen, profiles=None):
version = parse_version(screen.get('firmware'))
@@ -251,7 +261,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 and not self.language_due(screen):
if screen and screen['online'] and version and version >= TARGET and self.speaks_region(screen):
return True
await asyncio.sleep(self.poll_seconds)
return False
+2 -1
View File
@@ -11,7 +11,8 @@
"number": {
"decimal": ",",
"group": ".",
"group_min": "1"
"group_min": "1",
"percent": " %"
},
"time": {
"just_now": "Gerade eben",
+2 -1
View File
@@ -11,7 +11,8 @@
"number": {
"decimal": ".",
"group": ",",
"group_min": "1"
"group_min": "1",
"percent": "%"
},
"time": {
"just_now": "Just now",
+2 -1
View File
@@ -11,7 +11,8 @@
"number": {
"decimal": ",",
"group": ".",
"group_min": "2"
"group_min": "2",
"percent": "%"
},
"time": {
"just_now": "Justo ahora",
+9 -8
View File
@@ -11,7 +11,8 @@
"number": {
"decimal": ",",
"group": " ",
"group_min": "1"
"group_min": "1",
"percent": " %"
},
"time": {
"just_now": "À l'instant",
@@ -49,13 +50,13 @@
"sam."
],
"weekdays_min": [
"dim.",
"lun.",
"mar.",
"mer.",
"jeu.",
"ven.",
"sam."
"dim",
"lun",
"mar",
"mer",
"jeu",
"ven",
"sam"
],
"months": [
"janvier",
+2 -1
View File
@@ -11,7 +11,8 @@
"number": {
"decimal": ",",
"group": ".",
"group_min": "2"
"group_min": "2",
"percent": "%"
},
"time": {
"just_now": "Proprio ora",
+2 -1
View File
@@ -11,7 +11,8 @@
"number": {
"decimal": ",",
"group": ".",
"group_min": "1"
"group_min": "1",
"percent": "%"
},
"time": {
"just_now": "Zojuist",
+9 -8
View File
@@ -11,7 +11,8 @@
"number": {
"decimal": ",",
"group": " ",
"group_min": "2"
"group_min": "2",
"percent": "%"
},
"time": {
"just_now": "Przed chwilą",
@@ -49,13 +50,13 @@
"sob."
],
"weekdays_min": [
"niedz.",
"pon.",
"wt.",
"śr.",
"czw.",
"pt.",
"sob."
"nie",
"pon",
"wt",
"śr",
"czw",
"pt",
"sob"
],
"months": [
"stycznia",
+9 -8
View File
@@ -11,7 +11,8 @@
"number": {
"decimal": ",",
"group": " ",
"group_min": "2"
"group_min": "2",
"percent": "%"
},
"time": {
"just_now": "Agora mesmo",
@@ -49,13 +50,13 @@
"sábado"
],
"weekdays_min": [
"domingo",
"segunda",
"terça",
"quarta",
"quinta",
"sexta",
"sábado"
"dom",
"seg",
"ter",
"qua",
"qui",
"sex",
"sáb"
],
"months": [
"janeiro",
+2 -1
View File
@@ -3,12 +3,13 @@
#pragma once
#include "components/smart_display/screen_text.h"
static_assert(screen_text::KEYS_HASH == 0xC6B1A3EDu && screen_text::KEY_COUNT == 305,
static_assert(screen_text::KEYS_HASH == 0x5DBB4605u && screen_text::KEY_COUNT == 306,
"screen_text_keys.h does not match screen_manager/translations/en.json: run tools/i18n.py header");
const char *const screen_text::TABLE[] = {
".",
",",
"1",
"%",
"Just now",
"{n} min ago",
"1 hour ago | {n} hours ago",
+11 -3
View File
@@ -304,11 +304,19 @@ class Editor(unittest.IsolatedAsyncioTestCase):
i18n.set_screens('xx', 'point')
async with TestClient(TestServer(create_app(manager, True))) as client:
csrf = (await (await client.get('/api/inventory?light=1')).json())['csrf']
# Firmware from before the languages speaks English, whatever the screens' language is.
answer = await client.post('/api/screens/text.screen/identify', headers={'X-Screen-CSRF': csrf})
self.assertEqual(answer.status, 200)
action, data = ha.calls[0]
self.assertEqual((action, data['title'], data['subtitle'], data['button_text']),
('esphome.office_1_show_alert', '[xx] This is Office 1', '[xx] Identify, from ESP Screens', '[xx] OK'))
# Firmware that says it speaks the screens' language gets the card in it.
ha.registry = ha.registry + [{'entity_id': 'sensor.language', 'platform': 'esphome', 'original_name': 'Screen language',
'device_id': 'd1'}]
ha.states['sensor.language'] = {'state': 'xx'}
answer = await client.post('/api/screens/text.screen/identify', headers={'X-Screen-CSRF': csrf})
self.assertEqual(answer.status, 200)
texts = [(action, data['title'], data['subtitle'], data['button_text']) for action, data in ha.calls]
self.assertEqual(texts, [('esphome.office_1_show_alert', 'This is Office 1', 'Identify, from ESP Screens', 'OK'),
('esphome.office_1_show_alert', '[xx] This is Office 1', '[xx] Identify, from ESP Screens', '[xx] OK')])
i18n.set_screens('en', 'point')
async def test_a_tile_event_answers_home_assistant_in_english(self):
with tempfile.TemporaryDirectory() as tmp, Language('addon.'):
+1 -1
View File
@@ -38,7 +38,7 @@ int main() {
assert(number_text(128) == "128");
assert(number_text(12.5f) == "12.5");
assert(number_text(0) == "0");
assert(percent_text(50) == "50 %");
assert(percent_text(50) == "50%"); // "50 %" where the language writes it so (screen_text::percent_sign)
// The roller's text and the row that is chosen in it.
std::vector<std::string> names = {"Solid", "Akemi", "TV Simulator"};
+263
View File
@@ -0,0 +1,263 @@
"""Settings -> Language & region (app 0.2.90): how the screens write the clock and numbers, the language each screen's
messages go in, the language in the screens' YAML and the 24 hours an app from before it kept."""
import asyncio
import importlib.util
import json
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
sys.path.insert(0, str(ROOT / 'tests'))
import i18n # noqa: E402
import yaml # noqa: E402
from firmware import Firmware, LenientLoader # noqa: E402
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
PROFILE_TAIL = 'packages:\n display: !include /r/packages/core.yaml\n'
def region(tmp, ha='en', **stored):
path = Path(tmp) / 'language.json'
if stored:
path.write_text(json.dumps({'version': 1, **stored}))
return i18n.Region(path, ha_language=lambda: ha)
class NotationTests(unittest.TestCase):
def test_every_home_assistant_language_has_a_notation(self):
tool = importlib.util.spec_from_file_location('i18n_tool', ROOT / 'tools' / 'i18n.py')
module = importlib.util.module_from_spec(tool)
tool.loader.exec_module(module)
self.assertEqual(set(module.HA_LANGUAGES), set(i18n.REGIONS) - {'_about'})
for code, written in i18n.REGIONS.items():
if code == '_about':
continue
self.assertIn(written['clock'], ('12', '24'), code)
self.assertIn(written['numbers'], ('point', 'comma', 'space'), code)
self.assertIn(written['group_min'], (1, 2), code)
def test_automatic_follows_home_assistants_own_language(self):
with tempfile.TemporaryDirectory() as tmp:
# Swedish: English texts (no file yet), Swedish clock and numbers.
sv = region(tmp, 'sv')
self.assertEqual((sv.language(), sv.clock_24h(), sv.number_style(), sv.group_min(), sv.percent_space()),
('en', True, 'space', 1, True))
# Brazilian Portuguese: the Portuguese texts, Brazil's numbers.
br = region(tmp, 'pt-BR')
self.assertEqual((br.language(), br.number_style(), br.group_min()), ('pt', 'comma', 1))
pt = region(tmp, 'pt')
self.assertEqual((pt.number_style(), pt.group_min(), pt.percent_space()), ('space', 2, False))
us, uk = region(tmp, 'en'), region(tmp, 'en-GB')
self.assertEqual((us.clock_24h(), uk.clock_24h(), uk.language()), (False, True, 'en-GB'))
de = region(tmp, 'de')
self.assertEqual((de.number_style(), de.percent_space()), ('comma', True))
def test_a_chosen_language_and_format_win(self):
with tempfile.TemporaryDirectory() as tmp:
chosen = region(tmp, 'nl', language='es', clock='12', numbers='point')
# A chosen format is Home Assistant's for it, which groups from four digits; the space before % stays the
# language's.
self.assertEqual((chosen.language(), chosen.clock_24h(), chosen.number_style(), chosen.group_min()),
('es', False, 'point', 1))
self.assertEqual(region(tmp, 'nl', language='es', clock='auto', numbers='auto').group_min(), 2)
with tempfile.TemporaryDirectory() as tmp:
view = region(tmp, 'de').view()
self.assertEqual({key: view[key] for key in ('group_min', 'percent_space', 'numbers_effective')},
{'group_min': 1, 'percent_space': True, 'numbers_effective': 'comma'})
def test_numbers_and_units_as_home_assistant_writes_them(self):
self.assertEqual(i18n.format_number('1234.5', 'comma', 2), '1234,5')
self.assertEqual(i18n.format_number('12345.5', 'comma', 2), '12.345,5')
self.assertEqual(i18n.format_number('1234', 'space'), '1 234')
self.assertEqual(i18n.format_number('-1234567', 'point'), '-1,234,567')
self.assertEqual(i18n.format_number('on', 'comma'), 'on')
try:
i18n.set_screens('de', 'comma', True, 1, True)
self.assertEqual([i18n.unit_suffix(unit) for unit in ('%', '°', '°C', '', None)], [' %', '°', ' °C', '', ''])
i18n.set_screens('nl', 'comma', True, 1, False)
self.assertEqual(i18n.unit_suffix('%'), '%')
finally:
i18n.set_screens('en', 'point')
def test_plural_rules_match_the_firmware(self):
"""Python's rules against the C++ ones a screen's build writes (screen_text_gen.PLURAL_RULES), n from -30."""
compiler = shutil.which(os.environ.get('CXX', 'clang++')) or shutil.which('g++')
if not compiler:
self.skipTest('no C++ compiler')
spec = importlib.util.spec_from_file_location('gen', ROOT / 'components/smart_display/screen_text_gen.py')
gen = importlib.util.module_from_spec(spec)
spec.loader.exec_module(gen)
numbers = list(range(-30, 131)) + [1001, 1011, 1021, 1112]
with tempfile.TemporaryDirectory() as tmp:
source = Path(tmp) / 'rules.cpp'
functions = '\n'.join(f'int rule_{name}(int n) {{ {body} }}' for name, body in gen.PLURAL_RULES.items())
calls = '\n'.join(f' for (int n : ns) std::printf("{name} %d %d\\n", n, rule_{name}(n));' for name in gen.PLURAL_RULES)
source.write_text('#include <cstdio>\n' + functions + '\nint main() {\n int ns[] = {' + ','.join(map(str, numbers)) +
'};\n' + calls + '\n}\n')
subprocess.run([compiler, '-std=c++17', str(source), '-o', str(Path(tmp) / 'rules')], check=True)
out = subprocess.run([str(Path(tmp) / 'rules')], check=True, capture_output=True, text=True).stdout.split('\n')
for line in filter(None, out):
name, n, index = line.split()
self.assertEqual(i18n.PLURAL[name](int(n)), int(index), f'{name}({n})')
class ScreenContextTests(unittest.TestCase):
def tearDown(self):
i18n.set_screens('en', 'point')
def test_each_screen_gets_the_words_its_firmware_speaks(self):
import header_bar
i18n.set_screens('nl', 'comma', True, 1, False)
legacy = i18n.screen_context({'language': None, 'language_sensor': False})
dutch = i18n.screen_context({'language': 'nl', 'language_sensor': True})
restarting = i18n.screen_context({'language': None, 'language_sensor': True})
self.assertEqual(i18n.screen_t('screen.ha.on'), 'Aan')
for context, word, number, letters in ((legacy, 'On', '1,234.5', 'Waz'), (dutch, 'Aan', '1.234,5', 'Wąż'),
(restarting, 'Aan', '1.234,5', 'Wąż')):
token = i18n.SCREEN.set(context)
try:
self.assertEqual((i18n.screen_t('screen.ha.on'), i18n.screen_number('1234.5')), (word, number))
# Firmware from before the languages lacks most accented letters: they fold to the base letter.
self.assertEqual(header_bar.clean_text('Wąż'), letters)
finally:
i18n.SCREEN.reset(token)
self.assertIsNone(i18n.screen_context(None))
class ProfileLanguageTests(unittest.TestCase):
CASES = {
'plain': ('substitutions:\n DEVICE_NAME: "a"\n\n' + PROFILE_TAIL, True),
'four spaces': ('substitutions:\n DEVICE_NAME: "a"\n FONT: "x"\n' + PROFILE_TAIL, True),
'a comment inside': ('substitutions:\n DEVICE_NAME: "a"\n# a note\n LANGUAGE: "en"\n' + PROFILE_TAIL, True),
'quoted key': ('substitutions:\n "LANGUAGE": "en"\n DEVICE_NAME: a\n' + PROFILE_TAIL, True),
'no final newline': (PROFILE_TAIL + 'substitutions:\n DEVICE_NAME: "a"\n LANGUAGE: "de"', True),
'document start': ('# mine\n---\n' + PROFILE_TAIL, True),
'comments after the key': ('substitutions: # mine\n DEVICE_NAME: "a" # name\n\n\n# esphome\nesphome:\n name: a\n' + PROFILE_TAIL, True),
'windows line ends': (('substitutions:\n DEVICE_NAME: "a"\n' + PROFILE_TAIL).replace('\n', '\r\n'), True),
'include': ('substitutions: !include subs.yaml\n' + PROFILE_TAIL, False),
'flow mapping': ('substitutions: {DEVICE_NAME: a}\n' + PROFILE_TAIL, False),
'not ours': ('substitutions:\n A: b\nesphome:\n name: z\n', False),
}
def test_the_language_line_changes_nothing_else(self):
with tempfile.TemporaryDirectory() as tmp:
firmware = Firmware(tmp, tmp)
for index, (name, (text, writes)) in enumerate(self.CASES.items()):
with self.subTest(name):
path = Path(tmp) / f'p{index}.yaml'
path.write_bytes(text.encode())
before = yaml.load(text.replace('\r\n', '\n'), Loader=LenientLoader)
with self.assertLogs('screen_manager', 'WARNING') if name in ('include', 'flow mapping') else _quiet():
self.assertEqual(firmware.set_language(path.name, 'nl'), writes)
out = path.read_bytes().decode()
self.assertEqual('\r\n' in out, '\r\n' in text, 'line ends stay as they were')
after = yaml.load(out.replace('\r\n', '\n'), Loader=LenientLoader)
if writes:
self.assertEqual(after['substitutions']['LANGUAGE'], 'nl')
strip = lambda data: ({k: v for k, v in data.items() if k != 'substitutions'},
{k: v for k, v in (data.get('substitutions') or {}).items() if k != 'LANGUAGE'})
self.assertEqual(strip(after), strip(before))
self.assertFalse(firmware.set_language(path.name, 'nl'), 'nothing to do the second time')
else:
self.assertEqual(out, text)
english = Path(tmp) / 'english.yaml'
english.write_text(PROFILE_TAIL)
self.assertFalse(firmware.set_language(english.name, 'en'), 'English is the default: no line needed')
def test_every_build_writes_the_language_first(self):
with tempfile.TemporaryDirectory() as tmp:
firmware = Firmware(tmp, tmp)
path = Path(tmp) / 'screen.yaml'
path.write_text('substitutions:\n DEVICE_NAME: "screen"\n' + PROFILE_TAIL)
firmware.language = lambda: 'de'
firmware.run = lambda *args: asyncio.sleep(0)
with unittest.mock.patch('shutil.which', return_value='/usr/bin/esphome'):
async def start():
firmware.start({'file': 'screen.yaml', 'action': 'validate'})
self.assertNotIn('LANGUAGE', path.read_text(), 'validating changes nothing')
await firmware.task
firmware.start({'file': 'screen.yaml', 'action': 'build'})
await firmware.task
asyncio.run(start())
self.assertIn('LANGUAGE: "de"', path.read_text())
class _quiet:
def __enter__(self):
return self
def __exit__(self, *args):
return False
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
class ClockPinTests(unittest.IsolatedAsyncioTestCase):
def manager(self, path, switches=None):
import test_scaling
from server import Manager
ha = test_scaling.fake_ha(firmware='0.2.75')
for index, state in enumerate(switches or []):
ha.registry.append({'entity_id': f'switch.clock_{index}', 'platform': 'esphome', 'original_name': '24-hour clock',
'device_id': 'd1'})
if state is not None:
ha.states[f'switch.clock_{index}'] = {'state': state}
ha.ha_language = 'en'
ha.services_changed = asyncio.Event()
return Manager(ha, path)
async def test_a_new_install_follows_the_language_from_the_start(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / 'screens.json'
first = self.manager(path)
self.assertTrue((Path(tmp) / 'language.json').exists(), 'stored at once')
first.save('text.screen', {'title': 'Home', 'tiles': [{'entity': 'light.a'}]})
again = self.manager(path, ['on'])
self.assertEqual((again.region.clock, again.region.pinned, again.region.clock_24h()), ('auto', False, False))
async def test_an_older_app_keeps_its_24_hours_until_the_switches_tell(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / 'screens.json'
path.write_text(json.dumps({'version': 1, 'screens': {'text.screen': {'title': 'Home', 'tiles': [{'entity': 'light.a'}]}}}))
waiting = self.manager(path, ['off', None])
self.assertEqual((waiting.region.clock, waiting.region.pinned), ('24', True))
self.assertFalse(waiting.resolve_clock_pin(), 'a switch without a state: ask again later')
self.assertTrue(json.loads((Path(tmp) / 'language.json').read_text())['pinned'], 'the question survives a restart')
twelve = self.manager(path, ['off', 'off'])
self.assertTrue(twelve.resolve_clock_pin())
self.assertEqual((twelve.region.clock, twelve.region.pinned), ('12', False))
(Path(tmp) / 'language.json').unlink()
mixed = self.manager(path, ['off', 'on'])
self.assertFalse(mixed.resolve_clock_pin())
self.assertEqual((mixed.region.clock, mixed.region.pinned), ('24', False))
(Path(tmp) / 'language.json').unlink()
chosen = self.manager(path, ['off', None])
chosen.region.change({'clock': 'auto'})
self.assertFalse(chosen.region.pinned, 'the owner chose a clock')
async def test_language_and_region_over_http(self):
from server import create_app
from aiohttp.test_utils import TestClient, TestServer
with tempfile.TemporaryDirectory() as tmp:
m = self.manager(Path(tmp) / 'screens.json')
async with TestClient(TestServer(create_app(m, True))) as client:
csrf = (await (await client.get('/api/inventory?light=1')).json())['csrf']
answer = await client.put('/api/language', json={'setting': 'de', 'numbers': 'point'}, headers={'X-Screen-CSRF': csrf})
view = (await answer.json())['language']
self.assertEqual((view['effective'], view['numbers_effective'], view['group_min'], view['percent_space']),
('de', 'point', 1, True))
refused = await client.put('/api/language', json={'clock': '13'}, headers={'X-Screen-CSRF': csrf, 'X-ESP-Screens-Language': 'nl'})
self.assertEqual(refused.status, 400)
self.assertEqual((await refused.json())['error'], i18n.TRANSLATIONS.text('addon.errors.region_choice', 'nl'))
self.assertEqual(json.loads((Path(tmp) / 'language.json').read_text())['language'], 'de')
import unittest.mock # noqa: E402
if __name__ == '__main__':
unittest.main()
+5 -2
View File
@@ -129,11 +129,14 @@ class ManagerTests(unittest.IsolatedAsyncioTestCase):
with self.assertRaises(ValueError): fresh.save('text.screen',{**saved,'settings':{'brightness':0}})
self.assertEqual(path.read_bytes(),before)
await fresh.sync_one('text.screen',saved)
self.assertEqual(fresh.ha.messages[0][1]['settings'],{k:v for k,v in settings.items() if k not in SETTINGS_BESIDE_BLOCK})
# The clock is the one of Settings -> Language & region (app 0.2.90), the rest what the screen kept.
self.assertEqual(fresh.ha.messages[0][1]['settings'],{**{k:v for k,v in settings.items() if k not in SETTINGS_BESIDE_BLOCK},
'clock_24h':fresh.region.clock_24h()})
await fresh.sync_one('text.screen',saved)
self.assertEqual(len(fresh.ha.messages),2)
await fresh.sync_one('text.screen',saved,True)
self.assertEqual(fresh.ha.messages[2][1]['settings'],{k:v for k,v in settings.items() if k not in SETTINGS_BESIDE_BLOCK})
self.assertEqual(fresh.ha.messages[2][1]['settings'],{**{k:v for k,v in settings.items() if k not in SETTINGS_BESIDE_BLOCK},
'clock_24h':fresh.region.clock_24h()})
async def test_reorder_aborts_old_batch(self):
with tempfile.TemporaryDirectory() as temp:
+17
View File
@@ -42,6 +42,23 @@ int main() {
number_style_of("space") == 3 && number_style_of("dot") == -1);
number_style = 0;
// Grouping from five digits where the language does (CLDR: Spanish, Polish write 1234 but 12.345), as the app says.
number_group_min = 2;
assert(localize("1234") == "1234" && localize("12345") == "12,345" && localize("-1234.5") == "-1234.5");
number_group_min = 1;
assert(localize("1234") == "1,234");
number_group_min = 0;
// Units spaced as Home Assistant spaces them: none before "%" or "°" in English, one before any other unit; German
// and French put one before "%" too (the app sends it).
assert(with_unit("54", "%") == "54%" && with_unit("18", "\u00B0") == "18\u00B0" && with_unit("21.5", "\u00B0C") == "21.5 \u00B0C");
assert(with_unit("7", "") == "7" && unit_suffix("kWh") == " kWh" && percent(71) == "71%");
number_percent = 2;
assert(with_unit("54", "%") == "54 %" && percent(71) == "71 %");
number_percent = 1;
assert(percent(71) == "71%");
number_percent = 0;
// Times the screen gets as "HH:MM" (the sun, the forecast), written for its clock.
assert(clock_text("07:12", true) == "07:12" && clock_text("19:05", false) == "7:05 PM");
assert(clock_text("00:00", false) == "12:00 AM" && clock_text("14:00", false, true) == "2 PM");
+49 -3
View File
@@ -7,7 +7,8 @@
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
(Node's Intl), into every language's screen.date and screen.number, and the
clock and numbers of every Home Assistant language (screen_manager/app/regions.json)
python3 tools/i18n.py ha-words [--write] Home Assistant's own words for states (screen.ha) in every language, from a
Home Assistant (HA_URL and HA_TOKEN); needs aiohttp
python3 tools/i18n.py new CODE NAME ENGLISH PLURAL
@@ -38,7 +39,8 @@ def generator():
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'))}
found = {path.stem: json.loads(path.read_text(encoding='utf-8')) for path in sorted(FOLDER.glob('*.json'))
if re.fullmatch(r'[a-z]{2,3}(-[A-Za-z0-9]{2,8})?', path.stem)}
return dict(sorted(found.items(), key=lambda item: (item[0] != 'en', item[0])))
@@ -330,6 +332,40 @@ def main():
# The CLDR locale of a file where it differs: Portuguese is Home Assistant's pt, the one of Portugal.
CLDR_LOCALE = {'pt': 'pt-PT'}
# Every language Home Assistant's frontend offers (2026.9: the languages of its translationMetadata). A language ESP
# Screens has no file for still writes the clock and numbers as Home Assistant does (regions.json); its texts fall back
# to English.
HA_LANGUAGES = ('af ar bg bn bs ca cs cy da de el en en-GB eo es et eu fa fi fy fr ga gl gsw he hi hr hu hy id it is ja '
'ka ko lb lt lv mk ml nl nb nn pl pt pt-BR ro ru sk sl sr sr-Latn sv sq ta te th tr uk ur vi zh-Hans '
'zh-Hant').split()
# The languages that put a space between a number and "%" ("54 %"), as Home Assistant's frontend does
# (src/common/translations/blank_before_unit.ts, blankBeforePercent); every other one writes "54%".
HA_PERCENT_BLANK = ('cs', 'de', 'fi', 'fr', 'sk', 'sv')
REGIONS = ROOT / 'screen_manager' / 'app' / 'regions.json'
def percent_space(code):
return code.split('-')[0] in HA_PERCENT_BLANK
def number_style(number):
"""point (1,234.5), comma (1.234,5) or space (1 234,5): the choices of Settings -> Language & region."""
if number['decimal'] != ',':
return 'point'
return 'space' if number['group'].strip() == '' else 'comma'
def regions():
"""{code: {clock, numbers, group_min, percent_space}} for every Home Assistant language, from Node's Intl."""
locales = [CLDR_LOCALE.get(code, code) for code in HA_LANGUAGES]
found = json.loads(subprocess.run(['node', str(ROOT / 'tools' / 'i18n_cldr.mjs'), *locales], check=True,
capture_output=True, text=True).stdout)
table = {}
for code, locale in zip(HA_LANGUAGES, locales):
cldr = found[locale]
table[code] = {'clock': cldr['clock'], 'numbers': number_style(cldr['number']),
'group_min': int(cldr['number']['group_min']), 'percent_space': percent_space(code)}
return table
def write_language(code, data):
@@ -360,11 +396,21 @@ def cldr_dates(write, codes=None):
cldr = found[locale]
before = json.dumps(data, ensure_ascii=False)
data.setdefault('_meta', {})['clock'] = cldr['clock']
merge(data.setdefault('screen', {}), {'number': cldr['number'], 'time': cldr['time'], 'date': cldr['date']})
number = {**cldr['number'], 'percent': ' %' if percent_space(code) else '%'}
merge(data.setdefault('screen', {}), {'number': number, 'time': cldr['time'], 'date': cldr['date']})
if json.dumps(data, ensure_ascii=False) != before:
print(f'{code}: {"wrote" if write else "would change"} the CLDR texts')
if write:
write_language(code, data)
table = regions()
text = json.dumps({'_about': 'GENERATED by tools/i18n.py cldr: how every Home Assistant language writes the clock and '
'numbers (Unicode CLDR, Home Assistant\'s own rule for the space before %). The app takes '
'it for Settings -> Language & region set to Automatic.', **table},
ensure_ascii=False, indent=1) + '\n'
if not REGIONS.exists() or REGIONS.read_text(encoding='utf-8') != text:
print(f'regions.json: {"wrote" if write else "would change"} the clock and numbers of {len(table)} languages')
if write:
REGIONS.write_text(text, encoding='utf-8')
return 0
+3 -1
View File
@@ -39,7 +39,9 @@ for (const code of codes) {
date: {
weekdays: [0, 1, 2, 3, 4, 5, 6].map((i) => long.format(day(i))),
weekdays_short: [0, 1, 2, 3, 4, 5, 6].map((i) => short.format(day(i))),
weekdays_min: [0, 1, 2, 3, 4, 5, 6].map((i) => short.format(day(i))),
// The narrow columns of the weather card: the short name without its dot, at most three letters ("mié", "sáb",
// "czw", "dom"); a screen shows no more.
weekdays_min: [0, 1, 2, 3, 4, 5, 6].map((i) => [...short.format(day(i)).replace(/\.$/, '')].slice(0, 3).join('')),
months: [...Array(12).keys()].map(monthLong),
months_short: [...Array(12).keys()].map(numericMonth ? standalone : monthShort),
top_bar: withNames(pattern(code, { weekday: 'short', day: 'numeric', month: 'short' }, { weekday: 'weekday', day: 'day', month: 'month' })),