Compare commits

...

7 Commits

Author SHA1 Message Date
3ce17f2146 perf: parallelize the TURN OFF button via shared _shutdown_devices
shutdown_all() was the only remaining sequential shutdown path (TV, then
Gabi, then Gaja — 10+s with SSH timeouts). It now uses the same parallel
_shutdown_devices() as the budget tick, which gained a {dev_id: result}
return value so the button response keeps per-device details and
registry ordering. Per-device failures are isolated instead of being
silently swallowed.
2026-09-03 21:47:24 +02:00
cee6162431 fix: gabi_check was parsing the UID column instead of the USER column
loginctl list-users columns are 'UID USER LINGER STATE' — taking the
first field compared 'gabi' against UIDs, so the check always returned
False. Match the second field (the username) instead.
2026-09-03 21:24:50 +02:00
f176cae360 fix: make gaja ssh calls immune to broken system ssh config
Every ssh client call on this box fails with 'Bad owner or permissions'
on /etc/ssh/ssh_config.d (owned by nobody), which silently broke Gaja's
online check on every tick. Pass -F /dev/null so the checks use no
system/user ssh config at all; host key verification via known_hosts is
unaffected.
2026-09-02 20:30:39 +02:00
7071cb7c34 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.
2026-09-02 20:09:32 +02:00
fdfced6901 fix: reset budgets using state file mtime when server was stopped across the daily reset
The old per-device last_check timestamp was only persisted while a
device was consuming budget, so a stop/restart across days left stale
budgets. Check the state file mtime instead: if it predates today's 7 AM
and we're past 7 AM, the server was offline during the daily reset, so
give every device a fresh budget. Also drops the redundant 2-minute
reset window and the persisted last_check field.
2026-09-02 16:21:47 +02:00
06771cd866 config: adjust allowed hours 2026-09-02 16:03:46 +02:00
10245c97c4 chore: ignore __pycache__ and runtime files, stop tracking generated artifacts 2026-09-02 16:01:41 +02:00
11 changed files with 151 additions and 70 deletions

View File

@ -1 +0,0 @@
{"tv": {"budget": 3600, "last_online": "2026-06-15T16:02:02.797875", "last_check": 1781642261.0436604}, "gaja": {"budget": 196.5179741382599, "last_online": "2026-06-15T19:07:34.873875", "last_check": 1781642261.0427294}, "gabi": {"budget": 300.0, "last_online": "2026-06-15T19:07:45.943928", "last_check": 1781642261.042555}}

9
.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
# Python bytecode
__pycache__/
*.pyc
# Runtime state
.device_state.json
# Misc
*_slurp.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -4,10 +4,10 @@
# CONFIGURATION — Edit these values to change behavior # CONFIGURATION — Edit these values to change behavior
# ═══════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════
BUDGET_S = 3600 # default 60 min per device (in seconds) BUDGET_S = 3600 # default 60 min per device (in seconds)
TICK_INTERVAL = 10 # seconds between budget check cycles TICK_INTERVAL = 10 # seconds between budget check cycles
ALLOWED_WEEKDAY = "7:00-20:30" # Mon-Fri allowed hours ALLOWED_WEEKDAY = "17:50-19:10" # Mon-Fri allowed hours
ALLOWED_WEEKEND = "7:00-20:30" # Sat-Sun allowed hours ALLOWED_WEEKEND = "7:30-19:10" # Sat-Sun allowed hours
# ═══════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════

View File

@ -1,6 +1,7 @@
"""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
@ -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,11 @@ 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
# columns: UID USER LINGER STATE — username is the second field
users = {line.split()[1] for line in result.stdout.splitlines() if len(line.split()) > 1}
return "gabi" in users
def gabi_turnoff() -> dict | None: def gabi_turnoff() -> dict | None:
@ -97,6 +110,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.
@ -107,8 +124,10 @@ def gaja_check() -> dict:
return {"online": False, "detail": "Offline"} return {"online": False, "detail": "Offline"}
# Check for a session with seat assigned (desktop session, not SSH) # Check for a session with seat assigned (desktop session, not SSH)
# -F /dev/null: skip system ssh config (broken includes make every ssh call fail)
check = _run( check = _run(
["sshpass", "-p", "Nagaja", "ssh", "-o", "ConnectTimeout=5", ["sshpass", "-p", GAJA_PASSWORD, "ssh", "-F", "/dev/null",
"-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"]
@ -125,8 +144,10 @@ def gaja_turnoff() -> dict | None:
if not state["online"]: if not state["online"]:
return {"detail": "Not online — no action needed"} return {"detail": "Not online — no action needed"}
# -F /dev/null: skip system ssh config (broken includes make every ssh call fail)
_run( _run(
["sshpass", "-p", "Nagaja", "ssh", "-o", "ConnectTimeout=5", ["sshpass", "-p", GAJA_PASSWORD, "ssh", "-F", "/dev/null",
"-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"],
@ -152,12 +173,11 @@ def _get_budget(device: str) -> float:
def _set_budget(device: str, budget_seconds: float): def _set_budget(device: str, budget_seconds: float):
"""Set budget and track last check time for accurate elapsed calculation.""" """Persist the remaining budget for a device."""
state = _load_state() state = _load_state()
if device not in state: if device not in state:
state[device] = {"budget": float(BUDGET_S)} state[device] = {"budget": float(BUDGET_S)}
state[device]["budget"] = budget_seconds state[device]["budget"] = budget_seconds
state[device]["last_check"] = time.time() # track for elapsed calculation
_save_state(state) _save_state(state)
@ -198,6 +218,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.
@ -220,34 +261,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 {
@ -258,29 +278,56 @@ def curfew_status() -> dict:
} }
def _shutdown_devices(device_ids: list[str]): def _shutdown_devices(device_ids: list[str]) -> dict[str, dict | None]:
"""Shut down a list of devices in parallel.""" """Shut down a list of devices in parallel.
Returns {dev_id: turnoff result or None on failure}.
"""
results: dict[str, dict | None] = {}
if not device_ids: if not device_ids:
return return results
with ThreadPoolExecutor(max_workers=len(device_ids)) as executor: with ThreadPoolExecutor(max_workers=len(device_ids)) as executor:
list(executor.submit(DEVICES[dev_id]["turnoff"]) for dev_id in device_ids) futures = {
executor.submit(DEVICES[dev_id]["turnoff"]): dev_id
for dev_id in device_ids
}
for future, dev_id in futures.items():
try:
results[dev_id] = future.result()
except Exception:
results[dev_id] = None
return results
# ── Budget timer (runs every 10s in background) ────────────────────── # ── Budget timer (runs every 10s in background) ──────────────────────
_timer_running = False _timer_running = False
_timer_lock = threading.Lock() _timer_lock = threading.Lock()
_last_check_times: dict[str, float] = {} # in-memory only, not persisted
def _reset_budgets_if_new_day():
"""Reset all budgets if today's 7 AM daily reset was missed.
The state file is only written while the server is running, so its
mtime is the last time the server did anything. If that's before
today's 7 AM (and it's now past 7 AM), the server was stopped across
the daily reset — give every device a fresh budget.
"""
if not STATE_FILE.exists():
return
now = datetime.now()
today_reset = now.replace(hour=7, minute=0, second=0)
mtime = datetime.fromtimestamp(STATE_FILE.stat().st_mtime)
if mtime < today_reset <= now:
for dev_id in DEVICES:
_reset_budget(dev_id)
def _budget_tick(): def _budget_tick():
"""Called every 10 seconds by the background timer.""" """Called every 10 seconds by the background timer."""
now = datetime.now() # New day and we were offline at 7 AM? Reset all budgets to full.
reset_time = now.replace(hour=7, minute=0, second=0) _reset_budgets_if_new_day()
# Reset all budgets at 7:00 AM each day
if now >= reset_time and now < reset_time + timedelta(minutes=2):
for dev_id in DEVICES:
_reset_budget(dev_id)
# Check all devices in parallel # Check all devices in parallel
online_status = {} online_status = {}
@ -299,20 +346,29 @@ def _budget_tick():
# Process results sequentially (budget operations need to be thread-safe) # Process results sequentially (budget operations need to be thread-safe)
with _timer_lock: with _timer_lock:
current_time = time.time() # float timestamp for precise elapsed calculation
# Initialize last check times on first run (server restart)
if not _last_check_times:
for dev_id in DEVICES:
_last_check_times[dev_id] = current_time
if not is_curfew_allowed(): if not is_curfew_allowed():
# Curfew in effect — turn off all online devices in parallel # Curfew in effect — turn off all online devices in parallel
online_devices = [dev_id for dev_id, online in online_status.items() if online] online_devices = [dev_id for dev_id, online in online_status.items() if online]
_shutdown_devices(online_devices) _shutdown_devices(online_devices)
# Update last check times even during curfew
for dev_id in DEVICES:
_last_check_times[dev_id] = current_time
return return
current_time = time.time() # float timestamp for precise elapsed calculation
devices_to_shutdown = [] devices_to_shutdown = []
for dev_id, online in online_status.items(): for dev_id, online in online_status.items():
dev_info = DEVICES[dev_id] dev_info = DEVICES[dev_id]
state = _load_state() state = _load_state()
current_budget = state.get(dev_id, {}).get("budget", float(BUDGET_S)) current_budget = state.get(dev_id, {}).get("budget", float(BUDGET_S))
last_check = state.get(dev_id, {}).get("last_check", current_time) last_check = _last_check_times.get(dev_id, current_time)
# Expired budget + device back online → shut it down # Expired budget + device back online → shut it down
if current_budget <= 0 and online: if current_budget <= 0 and online:
@ -331,8 +387,11 @@ def _budget_tick():
if new_budget <= 0: if new_budget <= 0:
devices_to_shutdown.append(dev_id) devices_to_shutdown.append(dev_id)
else: else:
# Always update last_check even when offline # Always update last_check even when offline (in-memory only)
_set_budget(dev_id, current_budget) pass
# Update last check time for next interval
_last_check_times[dev_id] = current_time
# Shutdown all devices that hit zero budget in parallel # Shutdown all devices that hit zero budget in parallel
if devices_to_shutdown: if devices_to_shutdown:
@ -369,10 +428,14 @@ def stop_timer():
# ── Orchestrator ────────────────────────────────────────────────────── # ── Orchestrator ──────────────────────────────────────────────────────
def shutdown_all() -> list[dict]: def shutdown_all() -> list[dict]:
"""Indiscriminately turn off all devices. Returns ordered list of action dicts.""" """Indiscriminately turn off all devices in parallel.
Returns ordered list of action dicts (registry order).
"""
results = _shutdown_devices(list(DEVICES))
actions = [] actions = []
for dev_id, dev_info in DEVICES.items(): for dev_id, dev_info in DEVICES.items():
result = dev_info["turnoff"]() result = results.get(dev_id)
if result: if result:
budget_minutes = _budget_to_minutes(_get_budget(dev_id)) budget_minutes = _budget_to_minutes(_get_budget(dev_id))
actions.append({ actions.append({
@ -406,6 +469,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",
@ -413,8 +477,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
@ -431,6 +495,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