From 7071cb7c349e96109d51eb0747ba982a98e7105c Mon Sep 17 00:00:00 2001 From: Mitja Horvat Date: Wed, 2 Sep 2026 20:09:32 +0200 Subject: [PATCH] fix: curfew boundary math, HTTP blocking, state file robustness, budget API hardening - curfew_status: rewrite next-transition calculation to scan up to 7 days ahead; old branching showed wrong 'blocked until' for Friday nights (assumed next day = weekday schedule) and Sunday nights (off by 2 days). Verified against a minute-by-minute reference for a full week: 10080/10080. - server: ThreadingHTTPServer so slow /status (pings/ssh) no longer blocks the TURN OFF button and /budget requests. - devices: atomic state file writes (tmp + os.replace) and tolerant _load_state so a crash mid-write can't silently kill the whole system. - server: /budget takes _timer_lock (no lost updates vs the budget tick) and rejects unknown device ids instead of creating junk state entries. - status: devices carry an explicit id; web UI keys off it instead of parsing titles. - gabi_check: exact username match (no more 'gabriel' false positives). - gaja: password readable from GAJA_PASSWORD env var (hardcoded value remains as fallback). - tui: drop unused import. --- devices.py | 86 +++++++++++++++++++++++++++++++++--------------------- server.py | 34 ++++++++++++--------- tui.py | 2 +- 3 files changed, 74 insertions(+), 48 deletions(-) diff --git a/devices.py b/devices.py index f62ae58..c2be8cf 100644 --- a/devices.py +++ b/devices.py @@ -1,11 +1,12 @@ """Device management: check status, power off, and budget tracking.""" import json +import os import subprocess import threading import time from concurrent.futures import ThreadPoolExecutor, as_completed -from datetime import datetime +from datetime import datetime, timedelta from pathlib import Path import config @@ -23,14 +24,22 @@ ALLOWED_WEEKEND_END = config.WEEKEND_END def _load_state() -> dict: if STATE_FILE.exists(): - with open(STATE_FILE) as f: - return json.load(f) + try: + with open(STATE_FILE) as f: + return json.load(f) + except (json.JSONDecodeError, OSError): + return {} # corrupt state file — start fresh instead of crashing return {} def _save_state(state: dict): - with open(STATE_FILE, "w") as f: + # Atomic write so a crash mid-write can't corrupt the state file + tmp_file = STATE_FILE.with_name(STATE_FILE.name + ".tmp") + with open(tmp_file, "w") as f: json.dump(state, f) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp_file, STATE_FILE) @@ -80,7 +89,10 @@ def tv_turnoff() -> dict | None: def gabi_check() -> bool: """Return True if user 'gabi' has an active login session.""" result = _run(["loginctl", "list-users"]) - return result is not None and "gabi" in result.stdout + if result is None: + return False + users = {line.split()[0] for line in result.stdout.splitlines() if line.split()} + return "gabi" in users def gabi_turnoff() -> dict | None: @@ -97,6 +109,10 @@ def gabi_turnoff() -> dict | None: # ── Gaja ────────────────────────────────────────────────────────────── +# Override with the GAJA_PASSWORD env var if you don't want it in source +GAJA_PASSWORD = os.environ.get("GAJA_PASSWORD", "Nagaja") + + def gaja_check() -> dict: """Check if Gaja's PC is online and she has an active desktop session. @@ -108,7 +124,7 @@ def gaja_check() -> dict: # Check for a session with seat assigned (desktop session, not SSH) check = _run( - ["sshpass", "-p", "Nagaja", "ssh", "-o", "ConnectTimeout=5", + ["sshpass", "-p", GAJA_PASSWORD, "ssh", "-o", "ConnectTimeout=5", "-o", "StrictHostKeyChecking=no", "-F", "/dev/null", "gaja@192.168.1.122", "loginctl list-sessions --no-pager 2>/dev/null | grep gaja | grep seat"] @@ -126,7 +142,7 @@ def gaja_turnoff() -> dict | None: return {"detail": "Not online — no action needed"} _run( - ["sshpass", "-p", "Nagaja", "ssh", "-o", "ConnectTimeout=5", + ["sshpass", "-p", GAJA_PASSWORD, "ssh", "-o", "ConnectTimeout=5", "-o", "StrictHostKeyChecking=no", "-F", "/dev/null", "gaja@192.168.1.122", "loginctl terminate-user gaja"], @@ -197,6 +213,27 @@ def _minutes_to_time(minutes: int) -> str: return f"{h:02d}:{m:02d}" +def _next_transition(now: datetime) -> tuple[int, int]: + """Find the next allowed-hours boundary (start or end) strictly after now. + + Returns (minutes_until, boundary_minutes_since_midnight). Scans up to + 7 days ahead so weekday/weekend schedule switches are handled. + """ + current_minutes = now.hour * 60 + now.minute + best: tuple[int, int] | None = None + for day_offset in range(7): + day = now + timedelta(days=day_offset) + if day.weekday() < 5: + boundaries = (ALLOWED_WEEKDAY_START, ALLOWED_WEEKDAY_END) + else: + boundaries = (ALLOWED_WEEKEND_START, ALLOWED_WEEKEND_END) + for boundary in boundaries: + minutes_until = day_offset * (24 * 60) + boundary - current_minutes + if minutes_until > 0 and (best is None or minutes_until < best[0]): + best = (minutes_until, boundary) + return best + + def curfew_status() -> dict: """Get curfew status and time until next transition. @@ -219,34 +256,13 @@ def curfew_status() -> dict: in_curfew = not (start <= current_minutes < end) # True when devices are BLOCKED - if in_curfew: - # Devices blocked — show time until allowed hours start - if weekday < 5: # Mon-Fri - if current_minutes < start: - # Before allowed hours start today - minutes_left = start - current_minutes - next_time = _minutes_to_time(start) - else: - # After allowed hours end → next day same schedule - minutes_left = (24 * 60) - current_minutes + start - next_time = _minutes_to_time(start) - else: # Sat-Sun - if current_minutes < start: - # Before allowed hours start today - minutes_left = start - current_minutes - next_time = _minutes_to_time(start) - elif weekday == 5: # Saturday after allowed ends → Sunday - minutes_left = (24 * 60) - current_minutes + start - next_time = _minutes_to_time(start) - else: # Sunday after allowed ends → Monday - minutes_left = (3 * 24 * 60) - current_minutes + ALLOWED_WEEKDAY_START - next_time = _minutes_to_time(ALLOWED_WEEKDAY_START) + # Find the next schedule boundary (handles Mon↔weekend switches correctly) + minutes_left, boundary = _next_transition(now) + next_time = _minutes_to_time(boundary) + if in_curfew: message = f"Blocked until {next_time} ({minutes_left} min)" else: - # Devices allowed — show time until allowed hours end - minutes_left = end - current_minutes - next_time = _minutes_to_time(end) message = f"Allowed until {next_time} ({minutes_left} min)" return { @@ -431,6 +447,7 @@ def _check_device(dev_id: str, dev_info: dict) -> dict: icon = dev_info["icon"] return { + "id": dev_id, "icon": icon, "title": dev_info["name"], "detail": f"{detail} · {budget_minutes} min left", @@ -438,8 +455,8 @@ def _check_device(dev_id: str, dev_info: dict) -> dict: } -def status_all() -> list[dict]: - """Get current status + budget for all devices. Returns ordered list.""" +def status_all() -> tuple[list[dict], dict]: + """Get current status + budget for all devices. Returns (ordered list, curfew).""" curfew = curfew_status() # Check all devices in parallel @@ -456,6 +473,7 @@ def status_all() -> list[dict]: # Return in ordered list (DEVICES preserves insertion order) actions = [ { + "id": dev_id, "icon": results[dev_id]["icon"], "title": results[dev_id]["title"], "detail": results[dev_id]["detail"], diff --git a/server.py b/server.py index d7353fb..8cc3e95 100755 --- a/server.py +++ b/server.py @@ -2,10 +2,17 @@ """Simple web server: one button to turn off all kids' devices.""" import json -from http.server import HTTPServer, BaseHTTPRequestHandler +from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler import config -from devices import shutdown_all, status_all, curfew_status, _set_budget, _get_budget +from devices import ( + DEVICES, + _get_budget, + _set_budget, + _timer_lock, + shutdown_all, + status_all, +) class Handler(BaseHTTPRequestHandler): @@ -46,10 +53,13 @@ class Handler(BaseHTTPRequestHandler): try: data = json.loads(body) if body else {} dev_id = data.get("device", "") + if dev_id not in DEVICES: + raise ValueError(f"unknown device: {dev_id!r}") delta = int(data.get("delta", 0)) - current = _get_budget(dev_id) - new_budget = max(0, min(config.BUDGET_S, current + delta)) - _set_budget(dev_id, new_budget) + with _timer_lock: # same lock the budget tick uses — no lost updates + current = _get_budget(dev_id) + new_budget = max(0, min(config.BUDGET_S, current + delta)) + _set_budget(dev_id, new_budget) self.send_response(200) self.send_header("Content-Type", "application/json") self.end_headers() @@ -201,8 +211,8 @@ HTML = """\ // Device status document.getElementById('status').innerHTML = data.devices.map(s => { - const devId = s.title === 'TV' ? 'tv' : s.title.includes("Gabi") ? 'gabi' : 'gaja'; - return `
+ const devId = s.id; + return `
${s.icon} ${s.title} @@ -221,11 +231,9 @@ HTML = """\ function adjustBudget(device, delta) { // Update UI immediately (optimistic update) - const items = document.querySelectorAll('.status-item'); - const devMap = {tv: 0, gabi: 1, gaja: 2}; - const idx = devMap[device]; - if (idx !== undefined && items[idx]) { - const budgetEl = items[idx].querySelector('.budget'); + const item = document.querySelector(`.status-item[data-device="${device}"]`); + if (item) { + const budgetEl = item.querySelector('.budget'); const current = parseInt(budgetEl.textContent.match(/\\d+/)?.[0] || '0'); const maxBudget = {max_budget}; const newBudget = Math.max(0, Math.min(maxBudget, current + Math.round(delta / 60))); @@ -257,7 +265,7 @@ if __name__ == "__main__": port = int(__import__("os").environ.get("PORT", "10000")) start_timer() start_tui(config.TICK_INTERVAL) - server = HTTPServer(("0.0.0.0", port), Handler) + server = ThreadingHTTPServer(("0.0.0.0", port), Handler) print(f"🚀 Open http://localhost:{port}") print(f"⏱️ Budget timer running (checks every {config.TICK_INTERVAL}s, reset at 7:00 AM)") try: diff --git a/tui.py b/tui.py index 4b5d3ec..a14384c 100644 --- a/tui.py +++ b/tui.py @@ -5,7 +5,7 @@ import sys import threading import time -from devices import status_all, is_curfew_allowed +from devices import status_all # ANSI escape codes