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 @@ -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": "",
Expand Down
70 changes: 69 additions & 1 deletion pi/webapp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -626,18 +681,28 @@ 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

config = load_config()
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)
Expand Down Expand Up @@ -670,6 +735,7 @@ def index():
errors=[],
version=_APP_VERSION,
timezone=_get_system_timezone(),
timezones=SELECTABLE_TIMEZONES,
)


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

config = load_config()
Expand All @@ -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"]
Expand Down
12 changes: 11 additions & 1 deletion pi/webapp/templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,16 @@ <h1 style="white-space:nowrap;">[ PRINTPULSE ]</h1>
value="{{ config.quiet_end }}">
</div>
</div>
<div style="margin-top: 0.5rem;">
<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 style="margin-top: 0.5rem;">
<label for="quiet_wake_mode">When quiet hours end:</label>
<select name="quiet_wake_mode" id="quiet_wake_mode">
Expand All @@ -315,7 +325,7 @@ <h1 style="white-space:nowrap;">[ PRINTPULSE ]</h1>
<option value="all" {% if config.quiet_wake_mode == 'all' %}selected{% endif %}>Print all queued items</option>
</select>
</div>
<div class="help">Printer stays silent between these hours. Timezone: {{ timezone }}</div>
<div class="help">Printer stays silent between these hours.</div>
</div>
</div>

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

Expand Down
20 changes: 15 additions & 5 deletions printpulse/watch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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())
Expand Down
5 changes: 5 additions & 0 deletions tests/test_appliance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions tests/test_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("<script>alert(1)</script>"))
assert any("timezone" in e.lower() for e in errors)
39 changes: 39 additions & 0 deletions tests/test_watch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
Loading