"""Device management: check status, power off, and budget tracking.""" import json import os import subprocess import threading from datetime import datetime, timedelta from pathlib import Path # ── Config ──────────────────────────────────────────────────────────── BUDGET_SECONDS = int(os.environ.get("BUDGET_MINUTES", "60")) * 60 # default 60 min in seconds STATE_FILE = Path(__file__).parent / ".device_state.json" TICK_INTERVAL = 10 # seconds between budget checks # ── Budget state (persisted to disk) ───────────────────────────────── def _load_state() -> dict: if STATE_FILE.exists(): with open(STATE_FILE) as f: return json.load(f) return {} def _save_state(state: dict): with open(STATE_FILE, "w") as f: json.dump(state, f) # ── Helpers ─────────────────────────────────────────────────────────── def _run(cmd: list[str], *, timeout: int = 10) -> subprocess.CompletedProcess | None: """Run a command, return CompletedProcess or None on error.""" try: return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) except (FileNotFoundError, subprocess.TimeoutExpired): return None # ── TV ──────────────────────────────────────────────────────────────── def tv_check() -> dict: """Check if the TV is reachable and powered on. Returns an action dict describing the current state. """ result = _run(["ping", "-c", "1", "-W", "1", "192.168.1.12"]) if not result or result.returncode != 0: return {"online": False, "detail": "Offline"} _run(["adb", "connect", "192.168.1.12"]) result = _run(["adb", "shell", "dumpsys", "power"]) if result and "mWakefulness=Awake" in result.stdout: return {"online": True, "detail": "Online"} return {"online": False, "detail": "Offline"} def tv_turnoff() -> dict | None: """Send power key to the TV if it's on. Returns action dict or None.""" state = tv_check() if not state["online"]: return None _run(["adb", "shell", "input", "keyevent", "26"]) return {"detail": "Screen turned off (power key sent)"} # ── Gabi ────────────────────────────────────────────────────────────── 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 def gabi_turnoff() -> dict | None: """Log out Gabi if she's logged in. Returns action dict or None.""" if not gabi_check(): return {"detail": "Not logged in — no action needed"} result = _run(["doas", "loginctl", "terminate-user", "gabi"]) if result and result.returncode == 0: return {"detail": "Session terminated"} return {"detail": "Failed to terminate session"} # ── Gaja ────────────────────────────────────────────────────────────── def gaja_check() -> dict: """Check if Gaja's PC is online and she has an active desktop session. Returns an action dict describing the current state. """ result = _run(["ping", "-c", "1", "-W", "1", "192.168.1.122"]) if not result or result.returncode != 0: return {"online": False, "detail": "Offline"} # Check for a session with seat assigned (desktop session, not SSH) check = _run( ["sshpass", "-p", "Nagaja", "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"] ) if check is not None and check.stdout.strip(): return {"online": True, "detail": "Online"} return {"online": False, "detail": "Offline"} def gaja_turnoff() -> dict | None: """Log out Gaja if she's logged in. Returns action dict or None.""" state = gaja_check() if not state["online"]: return {"detail": "Not online — no action needed"} _run( ["sshpass", "-p", "Nagaja", "ssh", "-o", "ConnectTimeout=5", "-o", "StrictHostKeyChecking=no", "-F", "/dev/null", "gaja@192.168.1.122", "loginctl terminate-user gaja"], timeout=5 ) return {"detail": "Logged out"} # ── Device registry ─────────────────────────────────────────────────── DEVICES = { "tv": {"icon": "📺", "name": "TV", "check": tv_check, "turnoff": tv_turnoff}, "gabi": {"icon": "💻", "name": "Gabi's PC", "check": gabi_check, "turnoff": gabi_turnoff}, "gaja": {"icon": "💻", "name": "Gaja's PC", "check": gaja_check, "turnoff": gaja_turnoff}, } def _get_budget(device: str) -> int: """Get remaining budget in seconds.""" state = _load_state() return state.get(device, {}).get("budget", BUDGET_SECONDS) def _set_budget(device: str, budget_seconds: int): state = _load_state() if device not in state: state[device] = {"budget": BUDGET_SECONDS} state[device]["budget"] = budget_seconds state[device]["last_online"] = datetime.now().isoformat() _save_state(state) def _reset_budget(device: str): _set_budget(device, BUDGET_SECONDS) def _budget_to_minutes(budget_seconds: int) -> int: """Convert budget seconds to minutes for display.""" return max(0, budget_seconds // 60) # ── Budget timer (runs every 60s in background) ────────────────────── _timer_running = False _timer_lock = threading.Lock() 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) with _timer_lock: for dev_id, dev_info in DEVICES.items(): state = _load_state() current_budget = state.get(dev_id, {}).get("budget", BUDGET_SECONDS) is_online = dev_info["check"]() if isinstance(is_online, dict): online = is_online.get("online", False) else: online = bool(is_online) # Expired budget + device back online → shut it down if current_budget <= 0 and online: dev_info["turnoff"]() continue if online and current_budget > 0: new_budget = current_budget - TICK_INTERVAL _set_budget(dev_id, new_budget) if new_budget <= 0: dev_info["turnoff"]() def start_timer(): """Start the background budget timer (runs every 10s).""" global _timer_running with _timer_lock: if _timer_running: return _timer_running = True def loop(): while _timer_running: try: _budget_tick() except Exception: pass # Don't crash the timer on device errors threading.Event().wait(TICK_INTERVAL) t = threading.Thread(target=loop, daemon=True) t.start() def stop_timer(): """Stop the background timer.""" global _timer_running with _timer_lock: _timer_running = False # ── Orchestrator ────────────────────────────────────────────────────── def shutdown_all() -> list[dict]: """Indiscriminately turn off all devices. Returns ordered list of action dicts.""" actions = [] for dev_id, dev_info in DEVICES.items(): result = dev_info["turnoff"]() if result: budget_minutes = _budget_to_minutes(_get_budget(dev_id)) actions.append({ "icon": dev_info["icon"], "title": dev_info["name"], "detail": f"{result['detail']} ({budget_minutes} min left)", }) return actions def status_all() -> list[dict]: """Get current status + budget for all devices. Returns ordered list.""" actions = [] for dev_id, dev_info in DEVICES.items(): state = _load_state() budget_seconds = state.get(dev_id, {}).get("budget", BUDGET_SECONDS) is_online = dev_info["check"]() if isinstance(is_online, dict): online = is_online.get("online", False) detail = is_online.get("detail", "") else: online = bool(is_online) detail = "Online" if online else "Offline" budget_minutes = _budget_to_minutes(budget_seconds) # Budget warning if budget_minutes <= 0: icon = "🔴" elif budget_minutes < 5: icon = "🟠" else: icon = dev_info["icon"] actions.append({ "icon": icon, "title": dev_info["name"], "detail": f"{detail} · {budget_minutes} min left", }) return actions