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:
@@ -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);
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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³')
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user