From f8a958adcf99e73e265c45f46d80d5dd9e506c2d Mon Sep 17 00:00:00 2001 From: "jampick@microsoft.com" Date: Sun, 22 Mar 2026 21:07:56 -0700 Subject: [PATCH] Add timezone selector to quiet hours in web UI Users can now pick an IANA timezone (e.g. America/New_York) for quiet hours from a dropdown in the web UI rather than always using the Pi's system timezone. Quiet hours check in watch.py respects the configured timezone via zoneinfo; falls back to system local time when unset. - pi/appliance.py: add quiet_tz default ("" = system timezone) - pi/webapp/server.py: SELECTABLE_TIMEZONES list, validate quiet_tz, pass timezones to template - pi/webapp/templates/index.html: replace static timezone label with a + {% for tz in timezones %} + + {% endfor %} + + + +
Printer stays silent between these hours. Articles queue and print when quiet hours end.
diff --git a/printpulse/app.py b/printpulse/app.py index 5f566d4..968e0e4 100644 --- a/printpulse/app.py +++ b/printpulse/app.py @@ -140,6 +140,12 @@ def _build_parser() -> argparse.ArgumentParser: default=None, help="Quiet hours end time (e.g. 08:00). Resume printing at this time.", ) + parser.add_argument( + "--quiet-tz", + metavar="TZ", + default=None, + help="IANA timezone for quiet hours (e.g. America/New_York). Defaults to system timezone.", + ) # ── Letter mode ── parser.add_argument( "--letter", @@ -498,6 +504,7 @@ def _plot_item(text: str, feed_item: dict | None = None): theme=theme, quiet_start=args.quiet_start, quiet_end=args.quiet_end, + quiet_tz=args.quiet_tz, ) return diff --git a/printpulse/pi_launcher.py b/printpulse/pi_launcher.py index 53b37a4..1662e13 100644 --- a/printpulse/pi_launcher.py +++ b/printpulse/pi_launcher.py @@ -57,6 +57,9 @@ def main(): quiet_start = config.get("quiet_start", "22:00") quiet_end = config.get("quiet_end", "08:00") argv.extend(["--quiet-start", quiet_start, "--quiet-end", quiet_end]) + quiet_tz = config.get("quiet_tz", "") + if quiet_tz: + argv.extend(["--quiet-tz", quiet_tz]) print(f"PrintPulse appliance starting: {len(feeds)} feed(s), " f"interval={interval}s, max_prints={max_prints}") diff --git a/printpulse/watch.py b/printpulse/watch.py index f93ef11..4e18f40 100644 --- a/printpulse/watch.py +++ b/printpulse/watch.py @@ -165,12 +165,21 @@ def mark_seen(items: list[dict]): _save_seen(seen) -def _is_in_quiet_hours(quiet_start: str, quiet_end: str) -> bool: +def _is_in_quiet_hours(quiet_start: str, quiet_end: str, tz: str | None = None) -> bool: """Check if current time falls within quiet hours. Handles midnight crossover (e.g. 22:00–08:00). + If *tz* is an IANA timezone name (e.g. "America/New_York") the check is + performed in that timezone; otherwise the system local time is used. """ - now = datetime.now().time() + if tz: + try: + from zoneinfo import ZoneInfo + now = datetime.now(ZoneInfo(tz)).time() + except Exception: + now = datetime.now().time() + else: + now = datetime.now().time() start_h, start_m = int(quiet_start[:2]), int(quiet_start[3:5]) end_h, end_m = int(quiet_end[:2]), int(quiet_end[3:5]) @@ -189,15 +198,19 @@ def _is_in_quiet_hours(quiet_start: str, quiet_end: str) -> bool: def run_watch_loop(feed_urls: list[str], interval: int, max_prints: int, plot_callback, theme: str = "green", quiet_start: str | None = None, - quiet_end: str | None = None): + quiet_end: str | None = None, + quiet_tz: str | None = None): """Main watch loop. Polls feeds and calls plot_callback(text) for each new item.""" from rich.live import Live from rich.text import Text as RText feed_list = "\n".join(f" {url}" for url in feed_urls) if quiet_start and quiet_end: - tz_name = datetime.now().astimezone().strftime("%Z") - quiet_label = f"Quiet hours: {quiet_start}–{quiet_end} ({tz_name})" + if quiet_tz: + tz_label = quiet_tz + else: + tz_label = datetime.now().astimezone().strftime("%Z") + quiet_label = f"Quiet hours: {quiet_start}–{quiet_end} ({tz_label})" else: quiet_label = "Quiet hours: off" ui.retro_panel( @@ -260,7 +273,7 @@ def run_watch_loop(feed_urls: list[str], interval: int, max_prints: int, mark_seen([{"id": r["id"], "title": r["title"]}]) _save_retry_queue(retryable) - if retryable and not (use_quiet and _is_in_quiet_hours(quiet_start, quiet_end)): + if retryable and not (use_quiet and _is_in_quiet_hours(quiet_start, quiet_end, quiet_tz)): live.stop() ui.retro_panel("RETRY", f"Retrying {len(retryable)} failed item(s).", theme) for r_item in retryable: @@ -327,7 +340,7 @@ def run_watch_loop(feed_urls: list[str], interval: int, max_prints: int, # Stop Live temporarily to print story content normally if items: # Check quiet hours — skip printing but don't mark as seen - if use_quiet and _is_in_quiet_hours(quiet_start, quiet_end): + if use_quiet and _is_in_quiet_hours(quiet_start, quiet_end, quiet_tz): live.update(RText( f" [{now}] {len(items)} new item(s) queued — quiet hours ({quiet_start}–{quiet_end})", style=t["primary"], diff --git a/tests/test_watch.py b/tests/test_watch.py index 870197c..68728d5 100644 --- a/tests/test_watch.py +++ b/tests/test_watch.py @@ -79,3 +79,41 @@ def test_midnight_exactly_in_range(self): def test_midnight_exactly_out_of_range(self): with self._mock_time(0, 0): assert _is_in_quiet_hours("01:00", "23:00") is False + + +class TestIsInQuietHoursWithTimezone: + """Test that an explicit IANA timezone is used when provided.""" + + def test_explicit_tz_inside_quiet_hours(self): + """Use America/New_York — mock datetime.now(tz).time() to return 23:00.""" + from unittest.mock import MagicMock + + fake_time = dtime(23, 0) + fake_aware = MagicMock() + fake_aware.time.return_value = fake_time + + with patch("printpulse.watch.datetime") as mock_dt: + mock_dt.now.return_value = fake_aware + result = _is_in_quiet_hours("22:00", "08:00", tz="America/New_York") + assert result is True + + def test_explicit_tz_outside_quiet_hours(self): + """Use America/New_York — mock to return 14:00 (afternoon, outside range).""" + from unittest.mock import MagicMock + + fake_time = dtime(14, 0) + fake_aware = MagicMock() + fake_aware.time.return_value = fake_time + + with patch("printpulse.watch.datetime") as mock_dt: + mock_dt.now.return_value = fake_aware + result = _is_in_quiet_hours("22:00", "08:00", tz="America/New_York") + assert result is False + + def test_invalid_tz_falls_back_to_system_time(self): + """An unrecognised TZ string falls back to system local time without raising.""" + # Should not raise regardless of the bogus TZ value. + try: + _is_in_quiet_hours("22:00", "08:00", tz="Invalid/Timezone") + except Exception as exc: # pragma: no cover + raise AssertionError(f"Should not raise, got: {exc}") from exc