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;
for(unsigned i=0;i<columns;++i){
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,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);
@@ -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(txt::timer_cancel),pad+cw+gap,top+(large?50:30),cw,bh,41);
}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_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_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",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);
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,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;
@@ -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 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,1,w.value_font,0,horizon+(large?3:1),width/2,LV_TEXT_ALIGN_LEFT,fill(txt::sun_rise,"time",t.extra().sunrise));
part_label(w,2,w.value_font,width/2,horizon+(large?3:1),width/2,LV_TEXT_ALIGN_RIGHT,fill(txt::sun_set,"time",t.extra().sunset));
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",screen_text::clock_text(t.extra().sunset,screen_settings::current.clock_24h!=0,true)));
auto now=now_time?now_time():esphome::ESPTime{};
int rise=minutes_of(t.extra().sunrise),set=minutes_of(t.extra().sunset),minute=now.is_valid()?now.hour*60+now.minute:-1;
bool day=t.state=="above_horizon";float fraction=0.5f;
@@ -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" && std::isfinite(t.target)) value = screen_text::decimal(t.target, 1) + "°";
else if (d == "person") value = t.state=="home"?tr(txt::ha_person_home):t.state=="not_home"?tr(txt::ha_person_not_home):t.state;
else if (d == "sun") value = !t.extra().sunrise.empty() && !t.extra().sunset.empty() ? t.extra().sunrise+" - "+t.extra().sunset : tr(t.state=="above_horizon"?txt::ha_sun_above_horizon:txt::ha_sun_below_horizon);
else if (d == "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 == "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);
+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);
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.
inline int number_style_of(const std::string &name) {
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
`components/smart_display/screen_text_keys.h`, which the build checks against the JSON. The code uses
`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.
- **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.
+6 -2
View File
@@ -16,6 +16,7 @@ from datetime import datetime, time
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
import i18n
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;
@@ -183,6 +184,8 @@ def value(entity, state, entry=None, units=None, tz=None, words=None):
if domain == 'sun':
# 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)
if clock:
clock = i18n.screen_clock(int(clock[:2]), int(clock[3:]))
return (clock or state_text(raw) or raw), None
if domain == 'weather':
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))
except ValueError:
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'):
return f'{short_date(moment)} {moment:%H:%M}', None
return (short_date(moment) if attrs.get('has_date') else f'{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 i18n.screen_clock(moment.hour, moment.minute)), None
if domain == 'sensor' and attrs.get('device_class') == 'timestamp':
return (None, epoch(raw)) if epoch(raw) else (raw, None)
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
# 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):
SCREENS.update(language=language, numbers=numbers)
def set_screens(language, numbers, clock_24h=True):
SCREENS.update(language=language, numbers=numbers, clock_24h=clock_24h)
def screen_t(key, **params):
@@ -184,6 +184,14 @@ def screen_t(key, **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):
"""A number as Home Assistant sends it, written as the screens write numbers ("1.234,5" in Dutch)."""
return format_number(text, SCREENS['numbers'])
@@ -251,9 +259,6 @@ class Region:
'clock': self.clock, 'clock_effective': '24' if self.clock_24h() else '12',
'numbers': self.numbers, 'numbers_effective': self.number_style()}
def screen_text(self, key, **params):
"""A `screen` or `addon` text in the screens' language, for what the app itself sends to them."""
return self.tr.text(key, self.language(), **params)
def format_number(text, style):
+2 -2
View File
@@ -572,7 +572,7 @@ class Manager:
self.ha.language_of = self.region.language
self.ha.on_config = self.language_changed
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._registry_source, self._registry_index = None, {}
self._items_source, self._items = None, []
@@ -1153,7 +1153,7 @@ class Manager:
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())
i18n.set_screens(self.region.language(), self.region.number_style(), self.region.clock_24h())
self.ha.services_changed.set()
self.sent.clear()
self.write_languages()
+16
View File
@@ -81,6 +81,9 @@ class TextTests(unittest.TestCase):
self.assertEqual(header_bar.with_unit('4', None), '4')
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')
registry = {'sensor.t': {'options': {'sensor': {'suggested_display_precision': 1}}},
'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:
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):
# 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³')
+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 &&
number_style_of("space") == 3 && number_style_of("dot") == -1);
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");
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.
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')
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('|', '')
unknown = sorted(set(shown) - drawable - {'\n'})
if unknown: