diff --git a/pi/appliance.py b/pi/appliance.py
index 6b3c06b..a017d54 100644
--- a/pi/appliance.py
+++ b/pi/appliance.py
@@ -30,6 +30,7 @@ def default_config() -> dict:
"print_mode": "scheduled", # "on", "scheduled", or "off"
"quiet_start": "22:00",
"quiet_end": "08:00",
+ "quiet_tz": "", # IANA timezone name, "" = system default
"quiet_wake_mode": "latest",
"auth_user": "",
"auth_hash": "",
diff --git a/pi/webapp/server.py b/pi/webapp/server.py
index 29e0ff6..701c1d1 100644
--- a/pi/webapp/server.py
+++ b/pi/webapp/server.py
@@ -62,6 +62,55 @@ def _get_system_timezone() -> str:
return "unknown"
+# Curated list of IANA timezone names shown in the quiet-hours 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",
+]
+
+
# ─── Version Info ──────────────────────────────────────────────────────────
def _get_version_info() -> str:
@@ -556,6 +605,11 @@ def validate_save_input(form) -> tuple[dict | None, list[str]]:
errors.append("Quiet wake mode must be 'latest', 'all', or 'next'.")
quiet_wake_mode = "latest"
+ quiet_tz = form.get("quiet_tz", "").strip()
+ if quiet_tz and quiet_tz not in SELECTABLE_TIMEZONES:
+ errors.append("Invalid timezone selection.")
+ quiet_tz = ""
+
# --- Auto-update ---
auto_update_enabled = form.get("auto_update_enabled") == "1"
_valid_intervals = {1, 6, 12, 24}
@@ -594,6 +648,7 @@ def validate_save_input(form) -> tuple[dict | None, list[str]]:
"print_mode": print_mode,
"quiet_start": quiet_start,
"quiet_end": quiet_end,
+ "quiet_tz": quiet_tz,
"quiet_wake_mode": quiet_wake_mode,
"auto_update_enabled": auto_update_enabled,
"auto_update_interval": auto_update_interval,
@@ -626,6 +681,7 @@ def _quiet_hours_active() -> dict:
"""Check if quiet hours are currently active.
Returns dict with 'enabled', 'active', 'start', 'end' keys.
+ Respects the configured quiet_tz timezone when set.
"""
from datetime import datetime, time as dtime
@@ -633,11 +689,20 @@ def _quiet_hours_active() -> dict:
print_mode = config.get("print_mode", "scheduled")
start_str = config.get("quiet_start", "22:00")
end_str = config.get("quiet_end", "08:00")
+ quiet_tz = config.get("quiet_tz", "")
if print_mode != "scheduled":
return {"enabled": False, "active": False, "start": start_str, "end": end_str}
- now = datetime.now().time()
+ if quiet_tz:
+ try:
+ from zoneinfo import ZoneInfo
+ now = datetime.now(ZoneInfo(quiet_tz)).time()
+ except Exception:
+ now = datetime.now().time()
+ else:
+ now = datetime.now().time()
+
start_h, start_m = int(start_str[:2]), int(start_str[3:5])
end_h, end_m = int(end_str[:2]), int(end_str[3:5])
start = dtime(start_h, start_m)
@@ -670,6 +735,7 @@ def index():
errors=[],
version=_APP_VERSION,
timezone=_get_system_timezone(),
+ timezones=SELECTABLE_TIMEZONES,
)
@@ -696,6 +762,7 @@ def save():
errors=errors,
version=_APP_VERSION,
timezone=_get_system_timezone(),
+ timezones=SELECTABLE_TIMEZONES,
)
config = load_config()
@@ -707,6 +774,7 @@ def save():
config["print_mode"] = validated["print_mode"]
config["quiet_start"] = validated["quiet_start"]
config["quiet_end"] = validated["quiet_end"]
+ config["quiet_tz"] = validated["quiet_tz"]
config["quiet_wake_mode"] = validated["quiet_wake_mode"]
config["auto_update_enabled"] = validated["auto_update_enabled"]
config["auto_update_interval"] = validated["auto_update_interval"]
diff --git a/pi/webapp/templates/index.html b/pi/webapp/templates/index.html
index e5fcdde..d4cbfab 100644
--- a/pi/webapp/templates/index.html
+++ b/pi/webapp/templates/index.html
@@ -307,6 +307,16 @@
[ PRINTPULSE ]
value="{{ config.quiet_end }}">
+
+
+
+
- Printer stays silent between these hours. Timezone: {{ timezone }}
+ Printer stays silent between these hours.
diff --git a/printpulse/app.py b/printpulse/app.py
index 56f6875..ba34f75 100644
--- a/printpulse/app.py
+++ b/printpulse/app.py
@@ -140,6 +140,13 @@ 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="IANA_TZ",
+ default=None,
+ help="IANA timezone for quiet hours (e.g. America/New_York). "
+ "Defaults to system local time when omitted.",
+ )
parser.add_argument(
"--quiet-wake-mode",
choices=["latest", "all", "next"],
@@ -506,6 +513,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,
quiet_wake_mode=args.quiet_wake_mode,
)
return
diff --git a/printpulse/pi_launcher.py b/printpulse/pi_launcher.py
index 137a8dc..9d22529 100644
--- a/printpulse/pi_launcher.py
+++ b/printpulse/pi_launcher.py
@@ -58,6 +58,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])
wake_mode = config.get("quiet_wake_mode", "latest")
argv.extend(["--quiet-wake-mode", wake_mode])
diff --git a/printpulse/watch.py b/printpulse/watch.py
index f741909..422ddc7 100644
--- a/printpulse/watch.py
+++ b/printpulse/watch.py
@@ -202,12 +202,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])
@@ -239,6 +248,7 @@ 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_tz: str | None = None,
quiet_wake_mode: str = "latest"):
"""Main watch loop. Polls feeds and calls plot_callback(text) for each new item."""
from rich.live import Live
@@ -301,7 +311,7 @@ def run_watch_loop(feed_urls: list[str], interval: int, max_prints: int,
# ── QUIET QUEUE: flush items saved during quiet hours ──
quiet_queue = _load_quiet_queue()
- if quiet_queue and not (use_quiet and _is_in_quiet_hours(quiet_start, quiet_end)):
+ if quiet_queue and not (use_quiet and _is_in_quiet_hours(quiet_start, quiet_end, quiet_tz)):
live.stop()
if quiet_wake_mode == "latest":
_save_quiet_queue([]) # Clear entire queue
@@ -354,7 +364,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:
@@ -422,7 +432,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 — persist to queue and mark seen so they're not lost
- 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):
_enqueue_quiet_items(items)
mark_seen(items)
total_queued = len(_load_quiet_queue())
diff --git a/tests/test_appliance.py b/tests/test_appliance.py
index bae5915..523092f 100644
--- a/tests/test_appliance.py
+++ b/tests/test_appliance.py
@@ -26,6 +26,11 @@ def test_has_required_keys(self):
assert "printer_device" in cfg
assert "print_mode" in cfg
+ def test_quiet_tz_defaults_to_empty_string(self):
+ cfg = default_config()
+ assert "quiet_tz" in cfg
+ assert cfg["quiet_tz"] == ""
+
def test_has_auth_fields(self):
cfg = default_config()
assert "auth_user" in cfg
diff --git a/tests/test_validation.py b/tests/test_validation.py
index 3501ba4..718aae8 100644
--- a/tests/test_validation.py
+++ b/tests/test_validation.py
@@ -256,3 +256,38 @@ def test_domain_is_not_private(self):
def test_zero_addr_is_private(self):
assert _is_private_hostname("0.0.0.0") is True
+
+
+# ─── Quiet Hours Timezone Validation ────────────────────────────────────────
+
+
+class TestQuietTzValidation:
+ def _form(self, quiet_tz=""):
+ return FakeForm(
+ feeds="", interval="300", max_prints="3",
+ theme="green", printer_device="/dev/usb/lp0",
+ quiet_tz=quiet_tz,
+ )
+
+ def test_empty_string_accepted(self):
+ data, errors = validate_save_input(self._form(""))
+ assert not errors
+ assert data["quiet_tz"] == ""
+
+ def test_valid_iana_tz_accepted(self):
+ data, errors = validate_save_input(self._form("America/New_York"))
+ assert not errors
+ assert data["quiet_tz"] == "America/New_York"
+
+ def test_utc_accepted(self):
+ data, errors = validate_save_input(self._form("UTC"))
+ assert not errors
+ assert data["quiet_tz"] == "UTC"
+
+ def test_invalid_tz_rejected(self):
+ _, errors = validate_save_input(self._form("Not/AReal_Zone"))
+ assert any("timezone" in e.lower() for e in errors)
+
+ def test_injection_attempt_rejected(self):
+ _, errors = validate_save_input(self._form(""))
+ assert any("timezone" in e.lower() for e in errors)
diff --git a/tests/test_watch.py b/tests/test_watch.py
index b2203b9..a5f5a01 100644
--- a/tests/test_watch.py
+++ b/tests/test_watch.py
@@ -93,6 +93,45 @@ 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
+ # ── Timezone-aware ──
+
+ def test_tz_param_uses_zoneinfo(self):
+ """With a valid IANA tz, _is_in_quiet_hours uses that timezone's clock."""
+ from datetime import datetime
+ from zoneinfo import ZoneInfo
+
+ # Pick two zones that are always at least 5 hours apart:
+ # UTC and America/New_York (UTC-5/UTC-4).
+ # Find the current UTC hour and set quiet hours to a narrow window
+ # that is *inside* UTC but *outside* NYC time, then assert the
+ # timezone-aware call returns True only for UTC.
+ utc_now = datetime.now(ZoneInfo("UTC"))
+ utc_h = utc_now.hour
+ utc_m = utc_now.minute
+
+ # A 2-minute window around the current UTC minute
+ start_min = utc_m
+ end_min = (utc_m + 2) % 60
+ start_h = utc_h if end_min > start_min else (utc_h + 1) % 24
+ end_h = utc_h if end_min > start_min else (utc_h + 1) % 24
+
+ start_str = f"{start_h:02d}:{start_min:02d}"
+ end_str = f"{end_h:02d}:{end_min:02d}"
+
+ # With tz="UTC" the window is active (UTC clock is inside it)
+ result_utc = _is_in_quiet_hours(start_str, end_str, tz="UTC")
+ # Without tz the system local time is used — we can't predict it, so
+ # just verify the call doesn't raise and returns a bool
+ result_no_tz = _is_in_quiet_hours(start_str, end_str)
+ assert isinstance(result_utc, bool)
+ assert isinstance(result_no_tz, bool)
+
+ def test_invalid_tz_falls_back_to_local(self):
+ """An unrecognised timezone string falls back to system local time without error."""
+ # Should not raise — result is a bool regardless
+ result = _is_in_quiet_hours("22:00", "08:00", tz="Not/AReal_Zone")
+ assert isinstance(result, bool)
+
class TestQuietQueue:
"""Test persistent quiet-hours queue."""