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.
This commit is contained in:
2026-09-02 20:09:32 +02:00
parent fdfced6901
commit 7071cb7c34
3 changed files with 74 additions and 48 deletions

View File

@ -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"],