diff --git a/pi/appliance.py b/pi/appliance.py index a481488..befa95c 100644 --- a/pi/appliance.py +++ b/pi/appliance.py @@ -27,6 +27,7 @@ def default_config() -> dict: "quiet_enabled": True, "quiet_start": "22:00", "quiet_end": "08:00", + "quiet_tz": "", "enabled": True, "auth_user": "", "auth_hash": "", diff --git a/pi/webapp/server.py b/pi/webapp/server.py index 918128f..1429d6d 100644 --- a/pi/webapp/server.py +++ b/pi/webapp/server.py @@ -42,6 +42,55 @@ # ─── Timezone Info ───────────────────────────────────────────────────────── +# Curated list of IANA timezone names shown in the UI dropdown. +# Empty string ("") means "use system timezone". +SELECTABLE_TIMEZONES: list[str] = [ + "", # System default + "America/New_York", + "America/Chicago", + "America/Denver", + "America/Los_Angeles", + "America/Anchorage", + "America/Honolulu", + "America/Phoenix", + "America/Toronto", + "America/Vancouver", + "America/Sao_Paulo", + "America/Argentina/Buenos_Aires", + "America/Mexico_City", + "Europe/London", + "Europe/Paris", + "Europe/Berlin", + "Europe/Madrid", + "Europe/Rome", + "Europe/Amsterdam", + "Europe/Stockholm", + "Europe/Helsinki", + "Europe/Athens", + "Europe/Istanbul", + "Europe/Moscow", + "Africa/Cairo", + "Africa/Johannesburg", + "Africa/Lagos", + "Asia/Dubai", + "Asia/Kolkata", + "Asia/Dhaka", + "Asia/Bangkok", + "Asia/Singapore", + "Asia/Shanghai", + "Asia/Tokyo", + "Asia/Seoul", + "Asia/Taipei", + "Asia/Jakarta", + "Australia/Sydney", + "Australia/Melbourne", + "Australia/Perth", + "Pacific/Auckland", + "Pacific/Honolulu", + "UTC", +] + + def _get_system_timezone() -> str: """Get a human-readable system timezone string.""" try: @@ -330,6 +379,11 @@ def validate_save_input(form) -> tuple[dict | None, list[str]]: if hh > 23 or mm > 59: errors.append(f"{label} is not a valid time.") + quiet_tz = form.get("quiet_tz", "").strip() + if quiet_tz and quiet_tz not in SELECTABLE_TIMEZONES: + errors.append("Invalid timezone selection.") + quiet_tz = "" + # --- Printer device --- printer_device = form.get("printer_device", "/dev/usb/lp0").strip() if len(printer_device) > 64: @@ -354,6 +408,7 @@ def validate_save_input(form) -> tuple[dict | None, list[str]]: "quiet_enabled": quiet_enabled, "quiet_start": quiet_start, "quiet_end": quiet_end, + "quiet_tz": quiet_tz, }, [] @@ -396,6 +451,7 @@ def index(): errors=[], version=_APP_VERSION, timezone=_get_system_timezone(), + timezones=SELECTABLE_TIMEZONES, ) @@ -422,6 +478,7 @@ def save(): errors=errors, version=_APP_VERSION, timezone=_get_system_timezone(), + timezones=SELECTABLE_TIMEZONES, ) config = load_config() @@ -433,6 +490,7 @@ def save(): config["quiet_enabled"] = validated["quiet_enabled"] config["quiet_start"] = validated["quiet_start"] config["quiet_end"] = validated["quiet_end"] + config["quiet_tz"] = validated["quiet_tz"] save_config(config) # Restart the watcher service to pick up new config diff --git a/pi/webapp/templates/index.html b/pi/webapp/templates/index.html index 48613dc..d4ac732 100644 --- a/pi/webapp/templates/index.html +++ b/pi/webapp/templates/index.html @@ -288,7 +288,19 @@

[ PRINTPULSE ]

value="{{ config.quiet_end }}"> -
Printer stays silent between these hours. Articles queue and print when quiet hours end. Timezone: {{ timezone }}
+
+
+ + +
+
+
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