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:
86
devices.py
86
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"],
|
||||
|
||||
34
server.py
34
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 `<div class="status-item">
|
||||
const devId = s.id;
|
||||
return `<div class="status-item" data-device="${devId}">
|
||||
<span class="center">
|
||||
<span class="icon">${s.icon}</span>
|
||||
<span class="name">${s.title}</span>
|
||||
@ -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:
|
||||
|
||||
Reference in New Issue
Block a user