diff --git a/components/smart_display/runtime_tiles.h b/components/smart_display/runtime_tiles.h index 79b5109..200b509 100644 --- a/components/smart_display/runtime_tiles.h +++ b/components/smart_display/runtime_tiles.h @@ -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 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; diff --git a/docs/TRANSLATING.md b/docs/TRANSLATING.md index 8eae450..f1d39cd 100644 --- a/docs/TRANSLATING.md +++ b/docs/TRANSLATING.md @@ -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. diff --git a/screen_manager/app/header_bar.py b/screen_manager/app/header_bar.py index e2ccd27..1863570 100644 --- a/screen_manager/app/header_bar.py +++ b/screen_manager/app/header_bar.py @@ -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': diff --git a/screen_manager/app/i18n.py b/screen_manager/app/i18n.py index bcdbb3b..140bfad 100644 --- a/screen_manager/app/i18n.py +++ b/screen_manager/app/i18n.py @@ -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): diff --git a/screen_manager/app/server.py b/screen_manager/app/server.py index a3fa2ca..85891de 100644 --- a/screen_manager/app/server.py +++ b/screen_manager/app/server.py @@ -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() diff --git a/tests/test_header_bar.py b/tests/test_header_bar.py index 79418bf..06be5bb 100644 --- a/tests/test_header_bar.py +++ b/tests/test_header_bar.py @@ -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³') diff --git a/tests/test_screen_text.cpp b/tests/test_screen_text.cpp index 46c74a9..7294c32 100644 --- a/tests/test_screen_text.cpp +++ b/tests/test_screen_text.cpp @@ -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; } diff --git a/tools/i18n.py b/tools/i18n.py index fe75518..1ad050c 100644 --- a/tools/i18n.py +++ b/tools/i18n.py @@ -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: