Write the sun's and the forecast's times, and the top bar's, for the screens' 12 or 24-hour clock

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-19 15:11:14 +02:00
parent 8d2492daea
commit 42ed793aee
9 changed files with 66 additions and 19 deletions
+7 -7
View File
@@ -1142,7 +1142,7 @@ inline void render_weather_detail(const Tile &t,bool large,int width,int height,
int hy=cy+hero+(large?14:8),col=inner/(int)columns; int hy=cy+hero+(large?14:8),col=inner/(int)columns;
for(unsigned i=0;i<columns;++i){ for(unsigned i=0;i<columns;++i){
const auto &h=weather.hours[i];int x=card_pad+i*col; const auto &h=weather.hours[i];int x=card_pad+i*col;
detail_text(now,h.time,x,hy,col,small,LV_TEXT_ALIGN_CENTER,muted); detail_text(now,screen_text::clock_text(h.time,screen_settings::current.clock_24h!=0,true),x,hy,col,small,LV_TEXT_ALIGN_CENTER,muted);
detail_text(now,weather_icon(h.condition),x,hy+small_h+(large?4:1),col,mini,LV_TEXT_ALIGN_CENTER,weather_accent(h.condition)); detail_text(now,weather_icon(h.condition),x,hy+small_h+(large?4:1),col,mini,LV_TEXT_ALIGN_CENTER,weather_accent(h.condition));
detail_text(now,std::isfinite(h.temp)?degrees(h.temp):"",x,hy+small_h+mini_h+(large?8:2),col,detail_font,LV_TEXT_ALIGN_CENTER,ink); detail_text(now,std::isfinite(h.temp)?degrees(h.temp):"",x,hy+small_h+mini_h+(large?8:2),col,detail_font,LV_TEXT_ALIGN_CENTER,ink);
detail_text(now,rain_text(h.rain,h.mm,false),x,hy+small_h+mini_h+text_h+(large?8:3),col,small,LV_TEXT_ALIGN_CENTER,rain); detail_text(now,rain_text(h.rain,h.mm,false),x,hy+small_h+mini_h+text_h+(large?8:3),col,small,LV_TEXT_ALIGN_CENTER,rain);
@@ -2093,8 +2093,8 @@ inline void show_detail(unsigned index){
detail_button(tr(t.state=="active"?txt::timer_pause:txt::timer_start),pad,top+(large?50:30),cw,bh,40); detail_button(tr(t.state=="active"?txt::timer_pause:txt::timer_start),pad,top+(large?50:30),cw,bh,40);
detail_button(tr(txt::timer_cancel),pad+cw+gap,top+(large?50:30),cw,bh,41); detail_button(tr(txt::timer_cancel),pad+cw+gap,top+(large?50:30),cw,bh,41);
}else if(d=="sun"){ }else if(d=="sun"){
detail_label(detail_root,fill(txt::sun_sunrise,"time",t.extra().sunrise),pad,top,width-2*pad); detail_label(detail_root,fill(txt::sun_sunrise,"time",screen_text::clock_text(t.extra().sunrise,screen_settings::current.clock_24h!=0)),pad,top,width-2*pad);
detail_label(detail_root,fill(txt::sun_sunset,"time",t.extra().sunset),pad,top+lv_font_get_line_height(detail_font)+(large?10:4),width-2*pad); detail_label(detail_root,fill(txt::sun_sunset,"time",screen_text::clock_text(t.extra().sunset,screen_settings::current.clock_24h!=0)),pad,top+lv_font_get_line_height(detail_font)+(large?10:4),width-2*pad);
} }
} }
} }
@@ -2588,7 +2588,7 @@ inline void render_forecast(Widgets &w,const Tile &t,bool large,int width,int he
for(unsigned k=18+3*j;k<21+3*j;++k)if(w.parts[k])lv_obj_remove_flag(w.parts[k],LV_OBJ_FLAG_HIDDEN); for(unsigned k=18+3*j;k<21+3*j;++k)if(w.parts[k])lv_obj_remove_flag(w.parts[k],LV_OBJ_FLAG_HIDDEN);
part_label(w,18+3*j,day_icon,x,y0+day_h,column,LV_TEXT_ALIGN_CENTER,weather_icon(h.condition)); part_label(w,18+3*j,day_icon,x,y0+day_h,column,LV_TEXT_ALIGN_CENTER,weather_icon(h.condition));
part_label(w,19+3*j,w.value_font,x,y0+day_h+icon_col,column,LV_TEXT_ALIGN_CENTER,temp); part_label(w,19+3*j,w.value_font,x,y0+day_h+icon_col,column,LV_TEXT_ALIGN_CENTER,temp);
part_label(w,20+3*j,title_font,x,y0,column,LV_TEXT_ALIGN_CENTER,h.time); part_label(w,20+3*j,title_font,x,y0,column,LV_TEXT_ALIGN_CENTER,screen_text::clock_text(h.time,screen_settings::current.clock_24h!=0,true));
} }
} }
height=top_h; height=top_h;
@@ -2639,8 +2639,8 @@ inline void render_sunpath(Widgets &w,const Tile &t,bool large,int width,int hei
int title_h=lv_font_get_line_height(title_font),text_h=lv_font_get_line_height(w.value_font); int title_h=lv_font_get_line_height(title_font),text_h=lv_font_get_line_height(w.value_font);
int horizon=height-text_h-(large?4:2),top=title_h+(large?4:2),x0=large?14:8,x1=width-x0; int horizon=height-text_h-(large?4:2),top=title_h+(large?4:2),x0=large?14:8,x1=width-x0;
part_label(w,0,title_font,0,0,width,LV_TEXT_ALIGN_LEFT,t.name.empty()?std::string(tr(txt::sun_name)):t.name); part_label(w,0,title_font,0,0,width,LV_TEXT_ALIGN_LEFT,t.name.empty()?std::string(tr(txt::sun_name)):t.name);
part_label(w,1,w.value_font,0,horizon+(large?3:1),width/2,LV_TEXT_ALIGN_LEFT,fill(txt::sun_rise,"time",t.extra().sunrise)); part_label(w,1,w.value_font,0,horizon+(large?3:1),width/2,LV_TEXT_ALIGN_LEFT,fill(txt::sun_rise,"time",screen_text::clock_text(t.extra().sunrise,screen_settings::current.clock_24h!=0,true)));
part_label(w,2,w.value_font,width/2,horizon+(large?3:1),width/2,LV_TEXT_ALIGN_RIGHT,fill(txt::sun_set,"time",t.extra().sunset)); part_label(w,2,w.value_font,width/2,horizon+(large?3:1),width/2,LV_TEXT_ALIGN_RIGHT,fill(txt::sun_set,"time",screen_text::clock_text(t.extra().sunset,screen_settings::current.clock_24h!=0,true)));
auto now=now_time?now_time():esphome::ESPTime{}; auto now=now_time?now_time():esphome::ESPTime{};
int rise=minutes_of(t.extra().sunrise),set=minutes_of(t.extra().sunset),minute=now.is_valid()?now.hour*60+now.minute:-1; int rise=minutes_of(t.extra().sunrise),set=minutes_of(t.extra().sunset),minute=now.is_valid()?now.hour*60+now.minute:-1;
bool day=t.state=="above_horizon";float fraction=0.5f; bool day=t.state=="above_horizon";float fraction=0.5f;
@@ -3126,7 +3126,7 @@ inline void render_slot(size_t slot) {
else if (d == "climate" && t.state == "off") { value = tile_controls::climate_mode_text(t.state); if (std::isfinite(t.current)) value += " · " + screen_text::decimal(t.current, 1) + "°"; } else if (d == "climate" && t.state == "off") { value = tile_controls::climate_mode_text(t.state); if (std::isfinite(t.current)) value += " · " + screen_text::decimal(t.current, 1) + "°"; }
else if (d == "climate" && std::isfinite(t.target)) value = screen_text::decimal(t.target, 1) + "°"; else if (d == "climate" && std::isfinite(t.target)) value = screen_text::decimal(t.target, 1) + "°";
else if (d == "person") value = t.state=="home"?tr(txt::ha_person_home):t.state=="not_home"?tr(txt::ha_person_not_home):t.state; else if (d == "person") value = t.state=="home"?tr(txt::ha_person_home):t.state=="not_home"?tr(txt::ha_person_not_home):t.state;
else if (d == "sun") value = !t.extra().sunrise.empty() && !t.extra().sunset.empty() ? t.extra().sunrise+" - "+t.extra().sunset : tr(t.state=="above_horizon"?txt::ha_sun_above_horizon:txt::ha_sun_below_horizon); else if (d == "sun") value = !t.extra().sunrise.empty() && !t.extra().sunset.empty() ? screen_text::clock_text(t.extra().sunrise,screen_settings::current.clock_24h!=0,true)+" - "+screen_text::clock_text(t.extra().sunset,screen_settings::current.clock_24h!=0,true) : tr(t.state=="above_horizon"?txt::ha_sun_above_horizon:txt::ha_sun_below_horizon);
else if (d == "timer") value = timer_text(t); else if (d == "timer") value = timer_text(t);
else if (d == "script" || d == "scene" || d == "button" || d == "input_button") value = t.state == "on" ? std::string(tr(txt::script_running)) : last_run_text(t.last_run); else if (d == "script" || d == "scene" || d == "button" || d == "input_button") value = t.state == "on" ? std::string(tr(txt::script_running)) : last_run_text(t.last_run);
else if (d == "camera") value = tr(t.state == "streaming" ? txt::camera_live : t.state == "recording" ? txt::camera_recording : txt::camera_tap_to_view); else if (d == "camera") value = tr(t.state == "streaming" ? txt::camera_live : t.state == "recording" ? txt::camera_recording : txt::camera_tap_to_view);
+13
View File
@@ -93,6 +93,19 @@ inline std::string localize(const std::string &state) {
std::string fraction = dot == std::string::npos ? std::string() : state.substr(dot + 1); std::string fraction = dot == std::string::npos ? std::string() : state.substr(dot + 1);
return state.substr(0, start) + write_number(whole, fraction); return state.substr(0, start) + write_number(whole, fraction);
} }
// 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.
inline std::string clock_text(const std::string &hhmm, bool h24, bool compact = false) {
if (h24 || hhmm.size() != 5 || hhmm[2] != ':') return hhmm;
int hour = atoi(hhmm.substr(0, 2).c_str()), minute = atoi(hhmm.substr(3, 2).c_str());
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return hhmm;
char buffer[24];
const char *period = tr(hour < 12 ? txt::time_am : txt::time_pm);
if (compact && minute == 0) snprintf(buffer, sizeof(buffer), "%d %s", hour % 12 ? hour % 12 : 12, period);
else snprintf(buffer, sizeof(buffer), "%d:%02d %s", hour % 12 ? hour % 12 : 12, minute, period);
return buffer;
}
// "point", "comma", "space" or "auto" as its number_style; -1 for anything else. // "point", "comma", "space" or "auto" as its number_style; -1 for anything else.
inline int number_style_of(const std::string &name) { inline int number_style_of(const std::string &name) {
if (name == "auto") return 0; if (name == "auto") return 0;
+4 -1
View File
@@ -131,7 +131,10 @@ firmware update.
- Screen texts: add the key to `en.json` and run `python3 tools/i18n.py header`. That writes - 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 `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::...)`.
- App texts: use `i18n.t(...)` (the editor's language) or `i18n.screen_t(...)` (the screens' language). - 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(...)`.
- Editor texts: go through vue-i18n. - 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, - **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. and leaves them unchecked for a speaker of the language. A missing text shows in English and never blocks a release.
+6 -2
View File
@@ -16,6 +16,7 @@ from datetime import datetime, time
import tile_icons import tile_icons
from core import HEADER_BUILTIN, HEADER_CONTENTS, HEADER_MAX_ITEMS, HEADER_MIN_FIRMWARE, HEADER_SHOWS, epoch, header_items, local_clock, short, state_word from core import HEADER_BUILTIN, HEADER_CONTENTS, HEADER_MAX_ITEMS, HEADER_MIN_FIRMWARE, HEADER_SHOWS, epoch, header_items, local_clock, short, state_word
import i18n
from i18n import TRANSLATIONS, screen_number, screen_t, t from i18n import TRANSLATIONS, screen_number, screen_t, t
# Characters the top bar's text font carries on both boards (`sublabel_big` in the profiles; # Characters the top bar's text font carries on both boards (`sublabel_big` in the profiles;
@@ -183,6 +184,8 @@ def value(entity, state, entry=None, units=None, tz=None, words=None):
if domain == 'sun': if domain == 'sun':
# The next event: sunset while the sun is up, sunrise while it is down. # The next event: sunset while the sun is up, sunrise while it is down.
clock = local_clock(attrs.get('next_setting' if raw == 'above_horizon' else 'next_rising'), tz) clock = local_clock(attrs.get('next_setting' if raw == 'above_horizon' else 'next_rising'), tz)
if clock:
clock = i18n.screen_clock(int(clock[:2]), int(clock[3:]))
return (clock or state_text(raw) or raw), None return (clock or state_text(raw) or raw), None
if domain == 'weather': if domain == 'weather':
temperature = numeric(attrs.get('temperature')) temperature = numeric(attrs.get('temperature'))
@@ -204,9 +207,10 @@ def value(entity, state, entry=None, units=None, tz=None, words=None):
moment = datetime.fromisoformat(raw) if attrs.get('has_date') else datetime.combine(datetime.min, time.fromisoformat(raw)) moment = datetime.fromisoformat(raw) if attrs.get('has_date') else datetime.combine(datetime.min, time.fromisoformat(raw))
except ValueError: except ValueError:
return raw, None return raw, None
# A time of day as the screens' clock writes it (Settings -> Language & region, app 0.2.90).
if attrs.get('has_date') and attrs.get('has_time'): if attrs.get('has_date') and attrs.get('has_time'):
return f'{short_date(moment)} {moment:%H:%M}', None return f'{short_date(moment)} {i18n.screen_clock(moment.hour, moment.minute)}', None
return (short_date(moment) if attrs.get('has_date') else f'{moment:%H:%M}'), None return (short_date(moment) if attrs.get('has_date') else i18n.screen_clock(moment.hour, moment.minute)), None
if domain == 'sensor' and attrs.get('device_class') == 'timestamp': if domain == 'sensor' and attrs.get('device_class') == 'timestamp':
return (None, epoch(raw)) if epoch(raw) else (raw, None) return (None, epoch(raw)) if epoch(raw) else (raw, None)
if domain == 'sensor' and attrs.get('device_class') == 'date': if domain == 'sensor' and attrs.get('device_class') == 'date':
+11 -6
View File
@@ -171,11 +171,11 @@ def shown(value):
# The screens' language and number format (Settings -> Language & region), for the words the app sends to the screens # 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). # itself: tile names, the top bar, the history card. The manager keeps it current (Manager.language_changed).
SCREENS = {'language': 'en', 'numbers': 'point'} SCREENS = {'language': 'en', 'numbers': 'point', 'clock_24h': True}
def set_screens(language, numbers): def set_screens(language, numbers, clock_24h=True):
SCREENS.update(language=language, numbers=numbers) SCREENS.update(language=language, numbers=numbers, clock_24h=clock_24h)
def screen_t(key, **params): def screen_t(key, **params):
@@ -184,6 +184,14 @@ def screen_t(key, **params):
return _render(key, SCREENS['language'], params) return _render(key, SCREENS['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'])
return f'{hour % 12 or 12}:{minute:02d} {period}'
def screen_number(text): def screen_number(text):
"""A number as Home Assistant sends it, written as the screens write numbers ("1.234,5" in Dutch).""" """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, SCREENS['numbers'])
@@ -251,9 +259,6 @@ class Region:
'clock': self.clock, 'clock_effective': '24' if self.clock_24h() else '12', '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()}
def screen_text(self, key, **params):
"""A `screen` or `addon` text in the screens' language, for what the app itself sends to them."""
return self.tr.text(key, self.language(), **params)
def format_number(text, style): def format_number(text, style):
+2 -2
View File
@@ -572,7 +572,7 @@ class Manager:
self.ha.language_of = self.region.language self.ha.language_of = self.region.language
self.ha.on_config = self.language_changed self.ha.on_config = self.language_changed
self.pin_clock = False self.pin_clock = False
i18n.set_screens(self.region.language(), self.region.number_style()) i18n.set_screens(self.region.language(), self.region.number_style(), self.region.clock_24h())
self.skill_dir = claude_skill.skill_dir() self.skill_dir = claude_skill.skill_dir()
self._registry_source, self._registry_index = None, {} self._registry_source, self._registry_index = None, {}
self._items_source, self._items = None, [] self._items_source, self._items = None, []
@@ -1153,7 +1153,7 @@ class Manager:
if known and all(state == 'off' for state in known): if known and all(state == 'off' for state in known):
self.region.clock = '12' self.region.clock = '12'
self.region.save() self.region.save()
i18n.set_screens(self.region.language(), self.region.number_style()) i18n.set_screens(self.region.language(), self.region.number_style(), self.region.clock_24h())
self.ha.services_changed.set() self.ha.services_changed.set()
self.sent.clear() self.sent.clear()
self.write_languages() self.write_languages()
+16
View File
@@ -81,6 +81,9 @@ class TextTests(unittest.TestCase):
self.assertEqual(header_bar.with_unit('4', None), '4') self.assertEqual(header_bar.with_unit('4', None), '4')
def test_values_per_domain(self): def test_values_per_domain(self):
# The screens' clock is Settings -> Language & region's (app 0.2.90); these values are on 24 hours.
import i18n
i18n.set_screens('en', 'point', True)
tz = ZoneInfo('Europe/Amsterdam') tz = ZoneInfo('Europe/Amsterdam')
registry = {'sensor.t': {'options': {'sensor': {'suggested_display_precision': 1}}}, registry = {'sensor.t': {'options': {'sensor': {'suggested_display_precision': 1}}},
'sensor.p': {'options': {'sensor': {'suggested_display_precision': 2, 'display_precision': 0}}}} 'sensor.p': {'options': {'sensor': {'suggested_display_precision': 2, 'display_precision': 0}}}}
@@ -111,6 +114,19 @@ class TextTests(unittest.TestCase):
for eid, value, expected in cases: for eid, value, expected in cases:
self.assertEqual(header_bar.value(eid, value, registry.get(eid), {'temperature': '°C'}, tz), expected, eid) self.assertEqual(header_bar.value(eid, value, registry.get(eid), {'temperature': '°C'}, tz), expected, eid)
def test_times_follow_the_screens_clock(self):
import i18n
try:
i18n.set_screens('en', 'point', False)
self.assertEqual(i18n.screen_clock(19, 57), '7:57 PM')
self.assertEqual(i18n.screen_clock(0, 5), '12:05 AM')
i18n.set_screens('nl', 'comma', False)
self.assertEqual(i18n.screen_clock(9, 30), '9:30 a.m.')
i18n.set_screens('nl', 'comma', True)
self.assertEqual(i18n.screen_clock(9, 30), '09:30')
finally:
i18n.set_screens('en', 'point', True)
def test_text_keeps_to_the_font_and_its_length(self): def test_text_keeps_to_the_font_and_its_length(self):
# Every letter European languages write with stays (app 0.2.90); anything else folds to its base letter or goes. # Every letter European languages write with stays (app 0.2.90); anything else folds to its base letter or goes.
self.assertEqual(header_bar.clean_text('Café “Sun” 25 m³'), 'Café “Sun” 25 m³') self.assertEqual(header_bar.clean_text('Café “Sun” 25 m³'), 'Café “Sun” 25 m³')
+5
View File
@@ -41,6 +41,11 @@ int main() {
assert(number_style_of("auto") == 0 && number_style_of("point") == 1 && number_style_of("comma") == 2 && assert(number_style_of("auto") == 0 && number_style_of("point") == 1 && number_style_of("comma") == 2 &&
number_style_of("space") == 3 && number_style_of("dot") == -1); number_style_of("space") == 3 && number_style_of("dot") == -1);
number_style = 0; number_style = 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");
assert(clock_text("14:30", false, true) == "2:30 PM" && clock_text("7:12", false) == "7:12" && clock_text("", false).empty());
std::puts("test_screen_text: PASS"); std::puts("test_screen_text: PASS");
return 0; return 0;
} }
+2 -1
View File
@@ -114,7 +114,8 @@ def check():
# The editor's vue-i18n reads { } and @ as instructions: only placeholders and its {'x'} literals are allowed. # The editor's vue-i18n reads { } and @ as instructions: only placeholders and its {'x'} literals are allowed.
if key.startswith('editor.') and re.search(r"[{}@]", re.sub(r"\{'[^']*'\}", '', PLACEHOLDER.sub('', text))): if key.startswith('editor.') and re.search(r"[{}@]", re.sub(r"\{'[^']*'\}", '', PLACEHOLDER.sub('', text))):
problems.append(f'{code}: {key} has {{, }} or @ outside a placeholder; the editor would misread it') problems.append(f'{code}: {key} has {{, }} or @ outside a placeholder; the editor would misread it')
if key.startswith('screen.') and drawable: # The app's own words for the screens (addon.screen) are drawn by the same fonts.
if key.startswith(('screen.', 'addon.screen.')) and drawable:
shown = PLACEHOLDER.sub('', text).replace('|', '') shown = PLACEHOLDER.sub('', text).replace('|', '')
unknown = sorted(set(shown) - drawable - {'\n'}) unknown = sorted(set(shown) - drawable - {'\n'})
if unknown: if unknown: