Compare commits
7 Commits
4401b88726
...
3ce17f2146
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ce17f2146 | |||
| cee6162431 | |||
| f176cae360 | |||
| 7071cb7c34 | |||
| fdfced6901 | |||
| 06771cd866 | |||
| 10245c97c4 |
@ -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
9
.gitignore
vendored
Normal 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.
@ -4,10 +4,10 @@
|
||||
# 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
|
||||
ALLOWED_WEEKDAY = "7:00-20:30" # Mon-Fri allowed hours
|
||||
ALLOWED_WEEKEND = "7:00-20:30" # Sat-Sun allowed hours
|
||||
ALLOWED_WEEKDAY = "17:50-19:10" # Mon-Fri allowed hours
|
||||
ALLOWED_WEEKEND = "7:30-19:10" # Sat-Sun allowed hours
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
169
devices.py
169
devices.py
@ -1,6 +1,7 @@
|
||||
"""Device management: check status, power off, and budget tracking."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
@ -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,11 @@ 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
|
||||
# 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:
|
||||
@ -97,6 +110,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.
|
||||
|
||||
@ -107,8 +124,10 @@ def gaja_check() -> dict:
|
||||
return {"online": False, "detail": "Offline"}
|
||||
|
||||
# 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(
|
||||
["sshpass", "-p", "Nagaja", "ssh", "-o", "ConnectTimeout=5",
|
||||
["sshpass", "-p", GAJA_PASSWORD, "ssh", "-F", "/dev/null",
|
||||
"-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"]
|
||||
@ -125,8 +144,10 @@ def gaja_turnoff() -> dict | None:
|
||||
if not state["online"]:
|
||||
return {"detail": "Not online — no action needed"}
|
||||
|
||||
# -F /dev/null: skip system ssh config (broken includes make every ssh call fail)
|
||||
_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",
|
||||
"gaja@192.168.1.122",
|
||||
"loginctl terminate-user gaja"],
|
||||
@ -152,12 +173,11 @@ def _get_budget(device: str) -> 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()
|
||||
if device not in state:
|
||||
state[device] = {"budget": float(BUDGET_S)}
|
||||
state[device]["budget"] = budget_seconds
|
||||
state[device]["last_check"] = time.time() # track for elapsed calculation
|
||||
_save_state(state)
|
||||
|
||||
|
||||
@ -198,6 +218,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.
|
||||
|
||||
@ -220,34 +261,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 {
|
||||
@ -258,29 +278,56 @@ def curfew_status() -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _shutdown_devices(device_ids: list[str]):
|
||||
"""Shut down a list of devices in parallel."""
|
||||
def _shutdown_devices(device_ids: list[str]) -> dict[str, dict | None]:
|
||||
"""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:
|
||||
return
|
||||
return results
|
||||
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) ──────────────────────
|
||||
|
||||
_timer_running = False
|
||||
_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():
|
||||
"""Called every 10 seconds by the background timer."""
|
||||
now = datetime.now()
|
||||
reset_time = now.replace(hour=7, minute=0, second=0)
|
||||
|
||||
# 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)
|
||||
# New day and we were offline at 7 AM? Reset all budgets to full.
|
||||
_reset_budgets_if_new_day()
|
||||
|
||||
# Check all devices in parallel
|
||||
online_status = {}
|
||||
@ -299,20 +346,29 @@ def _budget_tick():
|
||||
|
||||
# Process results sequentially (budget operations need to be thread-safe)
|
||||
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():
|
||||
# Curfew in effect — turn off all online devices in parallel
|
||||
online_devices = [dev_id for dev_id, online in online_status.items() if online]
|
||||
_shutdown_devices(online_devices)
|
||||
# Update last check times even during curfew
|
||||
for dev_id in DEVICES:
|
||||
_last_check_times[dev_id] = current_time
|
||||
return
|
||||
|
||||
current_time = time.time() # float timestamp for precise elapsed calculation
|
||||
devices_to_shutdown = []
|
||||
|
||||
for dev_id, online in online_status.items():
|
||||
dev_info = DEVICES[dev_id]
|
||||
state = _load_state()
|
||||
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
|
||||
if current_budget <= 0 and online:
|
||||
@ -331,8 +387,11 @@ def _budget_tick():
|
||||
if new_budget <= 0:
|
||||
devices_to_shutdown.append(dev_id)
|
||||
else:
|
||||
# Always update last_check even when offline
|
||||
_set_budget(dev_id, current_budget)
|
||||
# Always update last_check even when offline (in-memory only)
|
||||
pass
|
||||
|
||||
# Update last check time for next interval
|
||||
_last_check_times[dev_id] = current_time
|
||||
|
||||
# Shutdown all devices that hit zero budget in parallel
|
||||
if devices_to_shutdown:
|
||||
@ -369,10 +428,14 @@ def stop_timer():
|
||||
# ── Orchestrator ──────────────────────────────────────────────────────
|
||||
|
||||
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 = []
|
||||
for dev_id, dev_info in DEVICES.items():
|
||||
result = dev_info["turnoff"]()
|
||||
result = results.get(dev_id)
|
||||
if result:
|
||||
budget_minutes = _budget_to_minutes(_get_budget(dev_id))
|
||||
actions.append({
|
||||
@ -406,6 +469,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",
|
||||
@ -413,8 +477,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
|
||||
@ -431,6 +495,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