diff --git a/devices.py b/devices.py index 9212d35..862dc5c 100644 --- a/devices.py +++ b/devices.py @@ -13,6 +13,12 @@ BUDGET_SECONDS = int(os.environ.get("BUDGET_MINUTES", "60")) * 60 # default 60 STATE_FILE = Path(__file__).parent / ".device_state.json" TICK_INTERVAL = 10 # seconds between budget checks +# Allowed device hours (24h format) +ALLOWED_WEEKDAY_START = int(os.environ.get("ALLOWED_WEEKDAY_START", "15")) # Mon-Fri start hour +ALLOWED_WEEKDAY_END = int(os.environ.get("ALLOWED_WEEKDAY_END", "20")) # Mon-Fri end hour +ALLOWED_WEEKEND_START = int(os.environ.get("ALLOWED_WEEKEND_START", "8")) # Sat-Sun start hour +ALLOWED_END_MINUTE = int(os.environ.get("ALLOWED_END_MINUTE", "30")) # End minute (same for all days) + # ── Budget state (persisted to disk) ───────────────────────────────── @@ -163,6 +169,94 @@ def _budget_to_minutes(budget_seconds: int) -> int: 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 @@ -179,6 +273,10 @@ 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 + with _timer_lock: for dev_id, dev_info in DEVICES.items(): state = _load_state() @@ -249,6 +347,7 @@ def shutdown_all() -> list[dict]: def status_all() -> list[dict]: """Get current status + budget for all devices. Returns ordered list.""" + curfew = curfew_status() actions = [] for dev_id, dev_info in DEVICES.items(): state = _load_state() @@ -277,4 +376,4 @@ def status_all() -> list[dict]: "title": dev_info["name"], "detail": f"{detail} · {budget_minutes} min left", }) - return actions + return actions, curfew diff --git a/server.py b/server.py index 1bc83e9..6fd1b8a 100755 --- a/server.py +++ b/server.py @@ -4,7 +4,7 @@ import json from http.server import HTTPServer, BaseHTTPRequestHandler -from devices import shutdown_all, status_all, _set_budget, _get_budget, BUDGET_SECONDS, TICK_INTERVAL +from devices import shutdown_all, status_all, curfew_status, _set_budget, _get_budget, BUDGET_SECONDS, TICK_INTERVAL class Handler(BaseHTTPRequestHandler): @@ -15,10 +15,12 @@ class Handler(BaseHTTPRequestHandler): self.end_headers() self.wfile.write(HTML.encode()) elif self.path == "/status": + devices_status, curfew = status_all() + response = {"devices": devices_status, "curfew": curfew} self.send_response(200) self.send_header("Content-Type", "application/json") self.end_headers() - self.wfile.write(json.dumps(status_all()).encode()) + self.wfile.write(json.dumps(response).encode()) else: self.send_error(404) @@ -119,6 +121,16 @@ HTML = """\ .status-item .icon { font-size: 1.1rem; flex-shrink: 0; } .status-item .name { font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .status-item .budget { color: #aaa; font-size: .75rem; margin-left: auto; flex-shrink: 0; } + #curfew { + text-align: center; + padding: .5rem; + margin-bottom: 1rem; + border-radius: 6px; + font-size: .85rem; + font-weight: 500; + } + #curfew.blocked { background: #3a1a1a; color: #f87171; } + #curfew.allowed { background: #1a3a2c; color: #4ade80; } .status-item .actions { display: flex; align-items: center; gap: .25rem; flex-shrink: 0; @@ -135,6 +147,7 @@ HTML = """\
TV + Gabi's computer + Gaja's computer
+ @@ -178,7 +191,15 @@ HTML = """\ try { const res = await fetch('https://noom.cc/off/status'); const data = await res.json(); - document.getElementById('status').innerHTML = data.map(s => { + + // Curfew status + const curfew = data.curfew; + const curfewEl = document.getElementById('curfew'); + curfewEl.className = curfew.in_curfew ? 'blocked' : 'allowed'; + curfewEl.textContent = (curfew.in_curfew ? '🔴 ' : '🟢 ') + curfew.message; + + // Device status + document.getElementById('status').innerHTML = data.devices.map(s => { const devId = s.title === 'TV' ? 'tv' : s.title.includes("Gabi") ? 'gabi' : 'gaja'; return `