Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pi/appliance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": "",
Expand Down
58 changes: 58 additions & 0 deletions pi/webapp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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,
}, []


Expand Down Expand Up @@ -396,6 +451,7 @@ def index():
errors=[],
version=_APP_VERSION,
timezone=_get_system_timezone(),
timezones=SELECTABLE_TIMEZONES,
)


Expand All @@ -422,6 +478,7 @@ def save():
errors=errors,
version=_APP_VERSION,
timezone=_get_system_timezone(),
timezones=SELECTABLE_TIMEZONES,
)

config = load_config()
Expand All @@ -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
Expand Down
14 changes: 13 additions & 1 deletion pi/webapp/templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,19 @@ <h1>[ PRINTPULSE ]</h1>
value="{{ config.quiet_end }}">
</div>
</div>
<div class="help">Printer stays silent between these hours. Articles queue and print when quiet hours end. Timezone: {{ timezone }}</div>
<div class="row">
<div>
<label for="quiet_tz">Timezone:</label>
<select name="quiet_tz" id="quiet_tz">
{% for tz in timezones %}
<option value="{{ tz }}"{% if config.quiet_tz == tz %} selected{% endif %}>
{%- if tz == "" -%}System default ({{ timezone }}){%- else -%}{{ tz }}{%- endif -%}
</option>
{% endfor %}
</select>
</div>
</div>
<div class="help">Printer stays silent between these hours. Articles queue and print when quiet hours end.</div>
</div>
</div>

Expand Down
7 changes: 7 additions & 0 deletions printpulse/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions printpulse/pi_launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down
27 changes: 20 additions & 7 deletions printpulse/watch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])

Expand All @@ -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(
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"],
Expand Down
38 changes: 38 additions & 0 deletions tests/test_watch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading