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:
@@ -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
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"){
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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
@@ -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 '')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"number": {
|
||||
"decimal": ",",
|
||||
"group": ".",
|
||||
"group_min": "1"
|
||||
"group_min": "1",
|
||||
"percent": " %"
|
||||
},
|
||||
"time": {
|
||||
"just_now": "Gerade eben",
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"number": {
|
||||
"decimal": ".",
|
||||
"group": ",",
|
||||
"group_min": "1"
|
||||
"group_min": "1",
|
||||
"percent": "%"
|
||||
},
|
||||
"time": {
|
||||
"just_now": "Just now",
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"number": {
|
||||
"decimal": ",",
|
||||
"group": ".",
|
||||
"group_min": "2"
|
||||
"group_min": "2",
|
||||
"percent": "%"
|
||||
},
|
||||
"time": {
|
||||
"just_now": "Justo ahora",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"number": {
|
||||
"decimal": ",",
|
||||
"group": ".",
|
||||
"group_min": "2"
|
||||
"group_min": "2",
|
||||
"percent": "%"
|
||||
},
|
||||
"time": {
|
||||
"just_now": "Proprio ora",
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"number": {
|
||||
"decimal": ",",
|
||||
"group": ".",
|
||||
"group_min": "1"
|
||||
"group_min": "1",
|
||||
"percent": "%"
|
||||
},
|
||||
"time": {
|
||||
"just_now": "Zojuist",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.'):
|
||||
|
||||
@@ -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"};
|
||||
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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' })),
|
||||
|
||||
Reference in New Issue
Block a user