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.""" """Device management: check status, power off, and budget tracking."""
import json import json
import os
import subprocess import subprocess
import threading import threading
import time import time
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
import config import config
@ -23,14 +24,22 @@ ALLOWED_WEEKEND_END = config.WEEKEND_END
def _load_state() -> dict: def _load_state() -> dict:
if STATE_FILE.exists(): if STATE_FILE.exists():
with open(STATE_FILE) as f: try:
return json.load(f) with open(STATE_FILE) as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
return {} # corrupt state file — start fresh instead of crashing
return {} return {}
def _save_state(state: dict): 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) 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: def gabi_check() -> bool:
"""Return True if user 'gabi' has an active login session.""" """Return True if user 'gabi' has an active login session."""
result = _run(["loginctl", "list-users"]) 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: def gabi_turnoff() -> dict | None:
@ -97,6 +109,10 @@ def gabi_turnoff() -> dict | None:
# ── Gaja ────────────────────────────────────────────────────────────── # ── 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: def gaja_check() -> dict:
"""Check if Gaja's PC is online and she has an active desktop session. """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 for a session with seat assigned (desktop session, not SSH)
check = _run( check = _run(
["sshpass", "-p", "Nagaja", "ssh", "-o", "ConnectTimeout=5", ["sshpass", "-p", GAJA_PASSWORD, "ssh", "-o", "ConnectTimeout=5",
"-o", "StrictHostKeyChecking=no", "-F", "/dev/null", "-o", "StrictHostKeyChecking=no", "-F", "/dev/null",
"gaja@192.168.1.122", "gaja@192.168.1.122",
"loginctl list-sessions --no-pager 2>/dev/null | grep gaja | grep seat"] "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"} return {"detail": "Not online — no action needed"}
_run( _run(
["sshpass", "-p", "Nagaja", "ssh", "-o", "ConnectTimeout=5", ["sshpass", "-p", GAJA_PASSWORD, "ssh", "-o", "ConnectTimeout=5",
"-o", "StrictHostKeyChecking=no", "-F", "/dev/null", "-o", "StrictHostKeyChecking=no", "-F", "/dev/null",
"gaja@192.168.1.122", "gaja@192.168.1.122",
"loginctl terminate-user gaja"], "loginctl terminate-user gaja"],
@ -197,6 +213,27 @@ def _minutes_to_time(minutes: int) -> str:
return f"{h:02d}:{m:02d}" 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: def curfew_status() -> dict:
"""Get curfew status and time until next transition. """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 in_curfew = not (start <= current_minutes < end) # True when devices are BLOCKED
if in_curfew: # Find the next schedule boundary (handles Mon↔weekend switches correctly)
# Devices blocked — show time until allowed hours start minutes_left, boundary = _next_transition(now)
if weekday < 5: # Mon-Fri next_time = _minutes_to_time(boundary)
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)
if in_curfew:
message = f"Blocked until {next_time} ({minutes_left} min)" message = f"Blocked until {next_time} ({minutes_left} min)"
else: 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)" message = f"Allowed until {next_time} ({minutes_left} min)"
return { return {
@ -431,6 +447,7 @@ def _check_device(dev_id: str, dev_info: dict) -> dict:
icon = dev_info["icon"] icon = dev_info["icon"]
return { return {
"id": dev_id,
"icon": icon, "icon": icon,
"title": dev_info["name"], "title": dev_info["name"],
"detail": f"{detail} · {budget_minutes} min left", "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]: def status_all() -> tuple[list[dict], dict]:
"""Get current status + budget for all devices. Returns ordered list.""" """Get current status + budget for all devices. Returns (ordered list, curfew)."""
curfew = curfew_status() curfew = curfew_status()
# Check all devices in parallel # Check all devices in parallel
@ -456,6 +473,7 @@ def status_all() -> list[dict]:
# Return in ordered list (DEVICES preserves insertion order) # Return in ordered list (DEVICES preserves insertion order)
actions = [ actions = [
{ {
"id": dev_id,
"icon": results[dev_id]["icon"], "icon": results[dev_id]["icon"],
"title": results[dev_id]["title"], "title": results[dev_id]["title"],
"detail": results[dev_id]["detail"], "detail": results[dev_id]["detail"],

View File

@ -2,10 +2,17 @@
"""Simple web server: one button to turn off all kids' devices.""" """Simple web server: one button to turn off all kids' devices."""
import json import json
from http.server import HTTPServer, BaseHTTPRequestHandler from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
import config 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): class Handler(BaseHTTPRequestHandler):
@ -46,10 +53,13 @@ class Handler(BaseHTTPRequestHandler):
try: try:
data = json.loads(body) if body else {} data = json.loads(body) if body else {}
dev_id = data.get("device", "") 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)) delta = int(data.get("delta", 0))
current = _get_budget(dev_id) with _timer_lock: # same lock the budget tick uses — no lost updates
new_budget = max(0, min(config.BUDGET_S, current + delta)) current = _get_budget(dev_id)
_set_budget(dev_id, new_budget) new_budget = max(0, min(config.BUDGET_S, current + delta))
_set_budget(dev_id, new_budget)
self.send_response(200) self.send_response(200)
self.send_header("Content-Type", "application/json") self.send_header("Content-Type", "application/json")
self.end_headers() self.end_headers()
@ -201,8 +211,8 @@ HTML = """\
// Device status // Device status
document.getElementById('status').innerHTML = data.devices.map(s => { document.getElementById('status').innerHTML = data.devices.map(s => {
const devId = s.title === 'TV' ? 'tv' : s.title.includes("Gabi") ? 'gabi' : 'gaja'; const devId = s.id;
return `<div class="status-item"> return `<div class="status-item" data-device="${devId}">
<span class="center"> <span class="center">
<span class="icon">${s.icon}</span> <span class="icon">${s.icon}</span>
<span class="name">${s.title}</span> <span class="name">${s.title}</span>
@ -221,11 +231,9 @@ HTML = """\
function adjustBudget(device, delta) { function adjustBudget(device, delta) {
// Update UI immediately (optimistic update) // Update UI immediately (optimistic update)
const items = document.querySelectorAll('.status-item'); const item = document.querySelector(`.status-item[data-device="${device}"]`);
const devMap = {tv: 0, gabi: 1, gaja: 2}; if (item) {
const idx = devMap[device]; const budgetEl = item.querySelector('.budget');
if (idx !== undefined && items[idx]) {
const budgetEl = items[idx].querySelector('.budget');
const current = parseInt(budgetEl.textContent.match(/\\d+/)?.[0] || '0'); const current = parseInt(budgetEl.textContent.match(/\\d+/)?.[0] || '0');
const maxBudget = {max_budget}; const maxBudget = {max_budget};
const newBudget = Math.max(0, Math.min(maxBudget, current + Math.round(delta / 60))); 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")) port = int(__import__("os").environ.get("PORT", "10000"))
start_timer() start_timer()
start_tui(config.TICK_INTERVAL) 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"🚀 Open http://localhost:{port}")
print(f"⏱️ Budget timer running (checks every {config.TICK_INTERVAL}s, reset at 7:00 AM)") print(f"⏱️ Budget timer running (checks every {config.TICK_INTERVAL}s, reset at 7:00 AM)")
try: try:

2
tui.py
View File

@ -5,7 +5,7 @@ import sys
import threading import threading
import time import time
from devices import status_all, is_curfew_allowed from devices import status_all
# ANSI escape codes # ANSI escape codes