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 `