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