"""Device management: check status, power off, and budget tracking.""" import json import subprocess import threading 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.ALLOWED_WEEKDAY_START ALLOWED_WEEKDAY_END = config.ALLOWED_WEEKDAY_END ALLOWED_WEEKEND_START = config.ALLOWED_WEEKEND_START ALLOWED_END_MINUTE = config.ALLOWED_END_MINUTE # ── 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_S) def _set_budget(device: str, budget_seconds: int): state = _load_state() if device not in state: state[device] = {"budget": BUDGET_S} state[device]["budget"] = budget_seconds state[device]["last_online"] = datetime.now().isoformat() _save_state(state) def _reset_budget(device: str): _set_budget(device, BUDGET_S) def _budget_to_minutes(budget_seconds: int) -> int: """Convert budget seconds to minutes for display.""" return max(0, budget_seconds // 60) def is_curfew_allowed() -> bool: """Check if current time is within allowed hours (devices allowed). Allowed hours: Mon-Fri: ALLOWED_WEEKDAY_START:00 to ALLOWED_WEEKDAY_END:ALLOWED_END_MINUTE Sat-Sun: ALLOWED_WEEKEND_START:00 to ALLOWED_WEEKDAY_END:ALLOWED_END_MINUTE """ 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 * 60 end = (ALLOWED_WEEKDAY_END * 60) + ALLOWED_END_MINUTE else: # Sat-Sun start = ALLOWED_WEEKEND_START * 60 end = (ALLOWED_WEEKDAY_END * 60) + ALLOWED_END_MINUTE 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 * 60 end = (ALLOWED_WEEKDAY_END * 60) + ALLOWED_END_MINUTE else: # Sat-Sun start = ALLOWED_WEEKEND_START * 60 end = (ALLOWED_WEEKDAY_END * 60) + ALLOWED_END_MINUTE 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 * 60 next_time = _minutes_to_time(ALLOWED_WEEKDAY_START * 60) 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 {_minutes_to_time(end)} ({minutes_left} min)" return { "in_curfew": in_curfew, "minutes_left": minutes_left, "message": message, "next_time": next_time, } # ── 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) # Only count down budget during allowed curfew hours if not is_curfew_allowed(): return # 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: for dev_id, online in online_status.items(): dev_info = DEVICES[dev_id] state = _load_state() current_budget = state.get(dev_id, {}).get("budget", BUDGET_S) # 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 _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", 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