feat: complete device management system with float budget tracking, curfew enforcement, TUI, and parallel checks
- Float-based budget tracking with precise elapsed time calculation - Curfew enforcement (devices turn off when blocked) - Terminal TUI with live status refresh - Parallel device checks using ThreadPoolExecutor - Simplified config with time range format (e.g. '15:00-20:30') - Optimistic UI updates for budget buttons - Bug fixes: curfew shutdown, last_check reset on offline
This commit is contained in:
1
.device_state.json
Normal file
1
.device_state.json
Normal file
@ -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}}
|
||||||
BIN
1780955064_slurp.png
Normal file
BIN
1780955064_slurp.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
BIN
__pycache__/config.cpython-314.pyc
Normal file
BIN
__pycache__/config.cpython-314.pyc
Normal file
Binary file not shown.
BIN
__pycache__/devices.cpython-314.pyc
Normal file
BIN
__pycache__/devices.cpython-314.pyc
Normal file
Binary file not shown.
BIN
__pycache__/server.cpython-314.pyc
Normal file
BIN
__pycache__/server.cpython-314.pyc
Normal file
Binary file not shown.
BIN
__pycache__/tui.cpython-314.pyc
Normal file
BIN
__pycache__/tui.cpython-314.pyc
Normal file
Binary file not shown.
34
config.py
34
config.py
@ -1,13 +1,29 @@
|
|||||||
"""Application configuration."""
|
"""Application configuration."""
|
||||||
|
|
||||||
# ── Budget ────────────────────────────────────────────────────────────
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
BUDGET_S = 3600 # default 60 min in seconds
|
# CONFIGURATION — Edit these values to change behavior
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
# ── Timer ─────────────────────────────────────────────────────────────
|
BUDGET_S = 3600 # default 60 min per device (in seconds)
|
||||||
TICK_INTERVAL = 10 # seconds between budget check cycles
|
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
|
def _parse_time_range(time_str: str) -> tuple[int, int]:
|
||||||
ALLOWED_END_MINUTE = 30 # End minute (all days)
|
"""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)
|
||||||
|
|||||||
84
devices.py
84
devices.py
@ -3,6 +3,7 @@
|
|||||||
import json
|
import json
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -12,10 +13,10 @@ import config
|
|||||||
BUDGET_S = config.BUDGET_S
|
BUDGET_S = config.BUDGET_S
|
||||||
STATE_FILE = Path(__file__).parent / ".device_state.json"
|
STATE_FILE = Path(__file__).parent / ".device_state.json"
|
||||||
TICK_INTERVAL = config.TICK_INTERVAL
|
TICK_INTERVAL = config.TICK_INTERVAL
|
||||||
ALLOWED_WEEKDAY_START = config.ALLOWED_WEEKDAY_START
|
ALLOWED_WEEKDAY_START = config.WEEKDAY_START
|
||||||
ALLOWED_WEEKDAY_END = config.ALLOWED_WEEKDAY_END
|
ALLOWED_WEEKDAY_END = config.WEEKDAY_END
|
||||||
ALLOWED_WEEKEND_START = config.ALLOWED_WEEKEND_START
|
ALLOWED_WEEKEND_START = config.WEEKEND_START
|
||||||
ALLOWED_END_MINUTE = config.ALLOWED_END_MINUTE
|
ALLOWED_WEEKEND_END = config.WEEKEND_END
|
||||||
|
|
||||||
|
|
||||||
# ── Budget state (persisted to disk) ─────────────────────────────────
|
# ── Budget state (persisted to disk) ─────────────────────────────────
|
||||||
@ -143,47 +144,49 @@ DEVICES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _get_budget(device: str) -> int:
|
def _get_budget(device: str) -> float:
|
||||||
"""Get remaining budget in seconds."""
|
"""Get remaining budget in seconds (float for precision)."""
|
||||||
state = _load_state()
|
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()
|
state = _load_state()
|
||||||
if device not in state:
|
if device not in state:
|
||||||
state[device] = {"budget": BUDGET_S}
|
state[device] = {"budget": float(BUDGET_S)}
|
||||||
state[device]["budget"] = budget_seconds
|
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)
|
_save_state(state)
|
||||||
|
|
||||||
|
|
||||||
def _reset_budget(device: str):
|
def _reset_budget(device: str):
|
||||||
_set_budget(device, BUDGET_S)
|
_set_budget(device, float(BUDGET_S))
|
||||||
|
|
||||||
|
|
||||||
def _budget_to_minutes(budget_seconds: int) -> int:
|
def _budget_to_minutes(budget_seconds: float) -> int:
|
||||||
"""Convert budget seconds to minutes for display."""
|
"""Convert budget seconds to minutes for display (floor)."""
|
||||||
return max(0, budget_seconds // 60)
|
return max(0, int(budget_seconds) // 60)
|
||||||
|
|
||||||
|
|
||||||
def is_curfew_allowed() -> bool:
|
def is_curfew_allowed() -> bool:
|
||||||
"""Check if current time is within allowed hours (devices allowed).
|
"""Check if current time is within allowed hours (devices allowed).
|
||||||
|
|
||||||
Allowed hours:
|
Allowed hours:
|
||||||
Mon-Fri: ALLOWED_WEEKDAY_START:00 to ALLOWED_WEEKDAY_END:ALLOWED_END_MINUTE
|
Mon-Fri: WEEKDAY_START to WEEKDAY_END (minutes since midnight)
|
||||||
Sat-Sun: ALLOWED_WEEKEND_START:00 to ALLOWED_WEEKDAY_END:ALLOWED_END_MINUTE
|
Sat-Sun: WEEKEND_START to WEEKEND_END (minutes since midnight)
|
||||||
"""
|
"""
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
weekday = now.weekday() # 0=Mon, 6=Sun
|
weekday = now.weekday() # 0=Mon, 6=Sun
|
||||||
current_minutes = now.hour * 60 + now.minute
|
current_minutes = now.hour * 60 + now.minute
|
||||||
|
|
||||||
if weekday < 5: # Mon-Fri
|
if weekday < 5: # Mon-Fri
|
||||||
start = ALLOWED_WEEKDAY_START * 60
|
start = ALLOWED_WEEKDAY_START
|
||||||
end = (ALLOWED_WEEKDAY_END * 60) + ALLOWED_END_MINUTE
|
end = ALLOWED_WEEKDAY_END
|
||||||
else: # Sat-Sun
|
else: # Sat-Sun
|
||||||
start = ALLOWED_WEEKEND_START * 60
|
start = ALLOWED_WEEKEND_START
|
||||||
end = (ALLOWED_WEEKDAY_END * 60) + ALLOWED_END_MINUTE
|
end = ALLOWED_WEEKEND_END
|
||||||
|
|
||||||
return start <= current_minutes < end
|
return start <= current_minutes < end
|
||||||
|
|
||||||
@ -209,11 +212,11 @@ def curfew_status() -> dict:
|
|||||||
current_minutes = now.hour * 60 + now.minute
|
current_minutes = now.hour * 60 + now.minute
|
||||||
|
|
||||||
if weekday < 5: # Mon-Fri
|
if weekday < 5: # Mon-Fri
|
||||||
start = ALLOWED_WEEKDAY_START * 60
|
start = ALLOWED_WEEKDAY_START
|
||||||
end = (ALLOWED_WEEKDAY_END * 60) + ALLOWED_END_MINUTE
|
end = ALLOWED_WEEKDAY_END
|
||||||
else: # Sat-Sun
|
else: # Sat-Sun
|
||||||
start = ALLOWED_WEEKEND_START * 60
|
start = ALLOWED_WEEKEND_START
|
||||||
end = (ALLOWED_WEEKDAY_END * 60) + ALLOWED_END_MINUTE
|
end = ALLOWED_WEEKEND_END
|
||||||
|
|
||||||
in_curfew = not (start <= current_minutes < end) # True when devices are BLOCKED
|
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
|
minutes_left = (24 * 60) - current_minutes + start
|
||||||
next_time = _minutes_to_time(start)
|
next_time = _minutes_to_time(start)
|
||||||
else: # Sunday after allowed ends → Monday
|
else: # Sunday after allowed ends → Monday
|
||||||
minutes_left = (3 * 24 * 60) - current_minutes + ALLOWED_WEEKDAY_START * 60
|
minutes_left = (3 * 24 * 60) - current_minutes + ALLOWED_WEEKDAY_START
|
||||||
next_time = _minutes_to_time(ALLOWED_WEEKDAY_START * 60)
|
next_time = _minutes_to_time(ALLOWED_WEEKDAY_START)
|
||||||
|
|
||||||
message = f"Blocked until {next_time} ({minutes_left} min)"
|
message = f"Blocked until {next_time} ({minutes_left} min)"
|
||||||
else:
|
else:
|
||||||
# Devices allowed — show time until allowed hours end
|
# Devices allowed — show time until allowed hours end
|
||||||
minutes_left = end - current_minutes
|
minutes_left = end - current_minutes
|
||||||
next_time = _minutes_to_time(end)
|
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 {
|
return {
|
||||||
"in_curfew": in_curfew,
|
"in_curfew": in_curfew,
|
||||||
@ -271,10 +274,6 @@ def _budget_tick():
|
|||||||
for dev_id in DEVICES:
|
for dev_id in DEVICES:
|
||||||
_reset_budget(dev_id)
|
_reset_budget(dev_id)
|
||||||
|
|
||||||
# Only count down budget during allowed curfew hours
|
|
||||||
if not is_curfew_allowed():
|
|
||||||
return
|
|
||||||
|
|
||||||
# Check all devices in parallel
|
# Check all devices in parallel
|
||||||
online_status = {}
|
online_status = {}
|
||||||
with ThreadPoolExecutor(max_workers=3) as executor:
|
with ThreadPoolExecutor(max_workers=3) as executor:
|
||||||
@ -292,22 +291,39 @@ def _budget_tick():
|
|||||||
|
|
||||||
# Process results sequentially (budget operations need to be thread-safe)
|
# Process results sequentially (budget operations need to be thread-safe)
|
||||||
with _timer_lock:
|
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():
|
for dev_id, online in online_status.items():
|
||||||
dev_info = DEVICES[dev_id]
|
dev_info = DEVICES[dev_id]
|
||||||
state = _load_state()
|
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
|
# Expired budget + device back online → shut it down
|
||||||
if current_budget <= 0 and online:
|
if current_budget <= 0 and online:
|
||||||
dev_info["turnoff"]()
|
dev_info["turnoff"]()
|
||||||
|
_set_budget(dev_id, 0.0)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if online and current_budget > 0:
|
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)
|
_set_budget(dev_id, new_budget)
|
||||||
|
|
||||||
if new_budget <= 0:
|
if new_budget <= 0:
|
||||||
dev_info["turnoff"]()
|
dev_info["turnoff"]()
|
||||||
|
else:
|
||||||
|
# Always update last_check even when offline
|
||||||
|
_set_budget(dev_id, current_budget)
|
||||||
|
|
||||||
|
|
||||||
def start_timer():
|
def start_timer():
|
||||||
@ -365,7 +381,7 @@ def _check_device(dev_id: str, dev_info: dict) -> dict:
|
|||||||
detail = "Online" if online else "Offline"
|
detail = "Online" if online else "Offline"
|
||||||
|
|
||||||
state = _load_state()
|
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_minutes = _budget_to_minutes(budget_seconds)
|
||||||
|
|
||||||
# Budget warning
|
# Budget warning
|
||||||
|
|||||||
Reference in New Issue
Block a user