diff --git a/.device_state.json b/.device_state.json new file mode 100644 index 0000000..2944648 --- /dev/null +++ b/.device_state.json @@ -0,0 +1 @@ +{"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}} \ No newline at end of file diff --git a/1780955064_slurp.png b/1780955064_slurp.png new file mode 100644 index 0000000..8e1525f Binary files /dev/null and b/1780955064_slurp.png differ diff --git a/__pycache__/config.cpython-314.pyc b/__pycache__/config.cpython-314.pyc new file mode 100644 index 0000000..281a3a1 Binary files /dev/null and b/__pycache__/config.cpython-314.pyc differ diff --git a/__pycache__/devices.cpython-314.pyc b/__pycache__/devices.cpython-314.pyc new file mode 100644 index 0000000..674f0d2 Binary files /dev/null and b/__pycache__/devices.cpython-314.pyc differ diff --git a/__pycache__/server.cpython-314.pyc b/__pycache__/server.cpython-314.pyc new file mode 100644 index 0000000..7d5ffe0 Binary files /dev/null and b/__pycache__/server.cpython-314.pyc differ diff --git a/__pycache__/tui.cpython-314.pyc b/__pycache__/tui.cpython-314.pyc new file mode 100644 index 0000000..9b2c921 Binary files /dev/null and b/__pycache__/tui.cpython-314.pyc differ diff --git a/config.py b/config.py index 93b26a9..84a25e0 100644 --- a/config.py +++ b/config.py @@ -1,13 +1,29 @@ """Application configuration.""" -# ── Budget ──────────────────────────────────────────────────────────── -BUDGET_S = 3600 # default 60 min in seconds +# ═══════════════════════════════════════════════════════════════════════ +# CONFIGURATION — Edit these values to change behavior +# ═══════════════════════════════════════════════════════════════════════ -# ── Timer ───────────────────────────────────────────────────────────── -TICK_INTERVAL = 10 # seconds between budget check cycles +BUDGET_S = 3600 # default 60 min per device (in seconds) +TICK_INTERVAL = 10 # seconds between budget check cycles +ALLOWED_WEEKDAY = "7:00-23:30" # Mon-Fri allowed hours +ALLOWED_WEEKEND = "7:00-20:30" # Sat-Sun allowed hours -# ── Allowed device hours (curfew) ───────────────────────────────────── -ALLOWED_WEEKDAY_START = 15 # Mon-Fri start hour -ALLOWED_WEEKDAY_END = 20 # Mon-Fri end hour -ALLOWED_WEEKEND_START = 8 # Sat-Sun start hour -ALLOWED_END_MINUTE = 30 # End minute (all days) +# ═══════════════════════════════════════════════════════════════════════ + + +def _parse_time_range(time_str: str) -> tuple[int, int]: + """Parse a time range string like '15:00-20:30' into (start_minutes, end_minutes). + + Returns minutes since midnight for start and end times. + """ + start_str, end_str = time_str.split("-") + sh, sm = map(int, start_str.split(":")) + eh, em = map(int, end_str.split(":")) + return sh * 60 + sm, eh * 60 + em + + +# ── Parsed values (do not edit) ────────────────────────────────────── + +WEEKDAY_START, WEEKDAY_END = _parse_time_range(ALLOWED_WEEKDAY) +WEEKEND_START, WEEKEND_END = _parse_time_range(ALLOWED_WEEKEND) diff --git a/devices.py b/devices.py index eb5ada7..e73eed4 100644 --- a/devices.py +++ b/devices.py @@ -3,6 +3,7 @@ import json import subprocess import threading +import time from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime, timedelta from pathlib import Path @@ -12,10 +13,10 @@ 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 +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) ───────────────────────────────── @@ -143,47 +144,49 @@ DEVICES = { } -def _get_budget(device: str) -> int: - """Get remaining budget in seconds.""" +def _get_budget(device: str) -> float: + """Get remaining budget in seconds (float for precision).""" state = _load_state() - return state.get(device, {}).get("budget", BUDGET_S) + value = state.get(device, {}).get("budget", float(BUDGET_S)) + return float(value) # ensure always float -def _set_budget(device: str, budget_seconds: int): +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": BUDGET_S} + state[device] = {"budget": float(BUDGET_S)} state[device]["budget"] = budget_seconds - state[device]["last_online"] = datetime.now().isoformat() + state[device]["last_check"] = time.time() # track for elapsed calculation _save_state(state) def _reset_budget(device: str): - _set_budget(device, BUDGET_S) + _set_budget(device, float(BUDGET_S)) -def _budget_to_minutes(budget_seconds: int) -> int: - """Convert budget seconds to minutes for display.""" - return max(0, budget_seconds // 60) +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: ALLOWED_WEEKDAY_START:00 to ALLOWED_WEEKDAY_END:ALLOWED_END_MINUTE - Sat-Sun: ALLOWED_WEEKEND_START:00 to ALLOWED_WEEKDAY_END:ALLOWED_END_MINUTE + 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 * 60 - end = (ALLOWED_WEEKDAY_END * 60) + ALLOWED_END_MINUTE + start = ALLOWED_WEEKDAY_START + end = ALLOWED_WEEKDAY_END else: # Sat-Sun - start = ALLOWED_WEEKEND_START * 60 - end = (ALLOWED_WEEKDAY_END * 60) + ALLOWED_END_MINUTE + start = ALLOWED_WEEKEND_START + end = ALLOWED_WEEKEND_END return start <= current_minutes < end @@ -209,11 +212,11 @@ def curfew_status() -> dict: 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 + start = ALLOWED_WEEKDAY_START + end = ALLOWED_WEEKDAY_END else: # Sat-Sun - start = ALLOWED_WEEKEND_START * 60 - end = (ALLOWED_WEEKDAY_END * 60) + ALLOWED_END_MINUTE + start = ALLOWED_WEEKEND_START + end = ALLOWED_WEEKEND_END in_curfew = not (start <= current_minutes < end) # True when devices are BLOCKED @@ -237,15 +240,15 @@ def curfew_status() -> dict: 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) + 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 {_minutes_to_time(end)} ({minutes_left} min)" + message = f"Allowed until {next_time} ({minutes_left} min)" return { "in_curfew": in_curfew, @@ -271,10 +274,6 @@ def _budget_tick(): 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: @@ -292,22 +291,39 @@ def _budget_tick(): # 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 + for dev_id, online in online_status.items(): + if online: + DEVICES[dev_id]["turnoff"]() + return + + current_time = time.time() # float timestamp for precise elapsed calculation 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) + 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: dev_info["turnoff"]() + _set_budget(dev_id, 0.0) continue if online and current_budget > 0: - new_budget = current_budget - TICK_INTERVAL + # 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: dev_info["turnoff"]() + else: + # Always update last_check even when offline + _set_budget(dev_id, current_budget) def start_timer(): @@ -365,7 +381,7 @@ def _check_device(dev_id: str, dev_info: dict) -> dict: detail = "Online" if online else "Offline" state = _load_state() - budget_seconds = state.get(dev_id, {}).get("budget", BUDGET_S) + budget_seconds = state.get(dev_id, {}).get("budget", float(BUDGET_S)) budget_minutes = _budget_to_minutes(budget_seconds) # Budget warning