"""Device management: check status, power off, and budget tracking.""" import json import subprocess import threading import time from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime, timedelta from pathlib import Path import config BUDGET_S = config.BUDGET_S STATE_FILE = Path(__file__).parent / ".device_state.json" TICK_INTERVAL = config.TICK_INTERVAL ALLOWED_WEEKDAY_START = config.WEEKDAY_START ALLOWED_WEEKDAY_END = config.WEEKDAY_END ALLOWED_WEEKEND_START = config.WEEKEND_START ALLOWED_WEEKEND_END = config.WEEKEND_END # ── 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) -> float: """Get remaining budget in seconds (float for precision).""" state = _load_state() value = state.get(device, {}).get("budget", float(BUDGET_S)) return float(value) # ensure always float def _set_budget(device: str, budget_seconds: float): """Set budget and track last check time for accurate elapsed calculation.""" 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) def _reset_budget(device: str): _set_budget(device, float(BUDGET_S)) def _budget_to_minutes(budget_seconds: float) -> int: """Convert budget seconds to minutes for display (floor).""" return max(0, int(budget_seconds) // 60) def is_curfew_allowed() -> bool: """Check if current time is within allowed hours (devices allowed). Allowed hours: Mon-Fri: WEEKDAY_START to WEEKDAY_END (minutes since midnight) Sat-Sun: WEEKEND_START to WEEKEND_END (minutes since midnight) """ now = datetime.now() weekday = now.weekday() # 0=Mon, 6=Sun current_minutes = now.hour * 60 + now.minute if weekday < 5: # Mon-Fri start = ALLOWED_WEEKDAY_START end = ALLOWED_WEEKDAY_END else: # Sat-Sun start = ALLOWED_WEEKEND_START end = ALLOWED_WEEKEND_END return start <= current_minutes < end def _minutes_to_time(minutes: int) -> str: """Convert minutes since midnight to HH:MM format.""" h = minutes // 60 m = minutes % 60 return f"{h:02d}:{m:02d}" def curfew_status() -> dict: """Get curfew status and time until next transition. Returns dict with: - in_curfew: bool (True = devices BLOCKED) - minutes_left: int (minutes until curfew ends or starts) - message: str (human-readable description) - next_time: str (clock time of next transition, e.g. '15:00') """ now = datetime.now() weekday = now.weekday() # 0=Mon, 6=Sun current_minutes = now.hour * 60 + now.minute if weekday < 5: # Mon-Fri start = ALLOWED_WEEKDAY_START end = ALLOWED_WEEKDAY_END else: # Sat-Sun start = ALLOWED_WEEKEND_START end = ALLOWED_WEEKEND_END 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) 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 { "in_curfew": in_curfew, "minutes_left": minutes_left, "message": message, "next_time": next_time, } def _shutdown_devices(device_ids: list[str]): """Shut down a list of devices in parallel.""" if not device_ids: return with ThreadPoolExecutor(max_workers=len(device_ids)) as executor: list(executor.submit(DEVICES[dev_id]["turnoff"]) for dev_id in device_ids) # ── Budget timer (runs every 10s 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) # Check all devices in parallel online_status = {} with ThreadPoolExecutor(max_workers=3) as executor: futures = { executor.submit(dev_info["check"]): dev_id for dev_id, dev_info in DEVICES.items() } for future in as_completed(futures): dev_id = futures[future] result = future.result() if isinstance(result, dict): online_status[dev_id] = result.get("online", False) else: online_status[dev_id] = bool(result) # Process results sequentially (budget operations need to be thread-safe) with _timer_lock: 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) 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) # Expired budget + device back online → shut it down if current_budget <= 0 and online: devices_to_shutdown.append(dev_id) _set_budget(dev_id, 0.0) continue if online and current_budget > 0: # Calculate actual elapsed time since last check elapsed = current_time - last_check if elapsed < 1: # ignore very small deltas (< 1s) continue new_budget = current_budget - elapsed _set_budget(dev_id, new_budget) if new_budget <= 0: devices_to_shutdown.append(dev_id) else: # Always update last_check even when offline _set_budget(dev_id, current_budget) # Shutdown all devices that hit zero budget in parallel if devices_to_shutdown: _shutdown_devices(devices_to_shutdown) 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 _check_device(dev_id: str, dev_info: dict) -> dict: """Check a single device and return its status dict.""" 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" state = _load_state() budget_seconds = state.get(dev_id, {}).get("budget", float(BUDGET_S)) budget_minutes = _budget_to_minutes(budget_seconds) # Budget warning if budget_minutes <= 0: icon = "🔴" elif budget_minutes < 5: icon = "🟠" else: icon = dev_info["icon"] return { "icon": icon, "title": dev_info["name"], "detail": f"{detail} · {budget_minutes} min left", "online": online, } def status_all() -> list[dict]: """Get current status + budget for all devices. Returns ordered list.""" curfew = curfew_status() # Check all devices in parallel results = {} with ThreadPoolExecutor(max_workers=3) as executor: futures = { executor.submit(_check_device, dev_id, dev_info): dev_id for dev_id, dev_info in DEVICES.items() } for future in as_completed(futures): dev_id = futures[future] results[dev_id] = future.result() # Return in ordered list (DEVICES preserves insertion order) actions = [ { "icon": results[dev_id]["icon"], "title": results[dev_id]["title"], "detail": results[dev_id]["detail"], } for dev_id in DEVICES ] return actions, curfew