Implement curfew.
This commit is contained in:
101
devices.py
101
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"
|
STATE_FILE = Path(__file__).parent / ".device_state.json"
|
||||||
TICK_INTERVAL = 10 # seconds between budget checks
|
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) ─────────────────────────────────
|
# ── Budget state (persisted to disk) ─────────────────────────────────
|
||||||
|
|
||||||
@ -163,6 +169,94 @@ def _budget_to_minutes(budget_seconds: int) -> int:
|
|||||||
return max(0, budget_seconds // 60)
|
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) ──────────────────────
|
# ── Budget timer (runs every 60s in background) ──────────────────────
|
||||||
|
|
||||||
_timer_running = False
|
_timer_running = False
|
||||||
@ -179,6 +273,10 @@ 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
|
||||||
|
|
||||||
with _timer_lock:
|
with _timer_lock:
|
||||||
for dev_id, dev_info in DEVICES.items():
|
for dev_id, dev_info in DEVICES.items():
|
||||||
state = _load_state()
|
state = _load_state()
|
||||||
@ -249,6 +347,7 @@ def shutdown_all() -> list[dict]:
|
|||||||
|
|
||||||
def status_all() -> list[dict]:
|
def status_all() -> list[dict]:
|
||||||
"""Get current status + budget for all devices. Returns ordered list."""
|
"""Get current status + budget for all devices. Returns ordered list."""
|
||||||
|
curfew = curfew_status()
|
||||||
actions = []
|
actions = []
|
||||||
for dev_id, dev_info in DEVICES.items():
|
for dev_id, dev_info in DEVICES.items():
|
||||||
state = _load_state()
|
state = _load_state()
|
||||||
@ -277,4 +376,4 @@ def status_all() -> list[dict]:
|
|||||||
"title": dev_info["name"],
|
"title": dev_info["name"],
|
||||||
"detail": f"{detail} · {budget_minutes} min left",
|
"detail": f"{detail} · {budget_minutes} min left",
|
||||||
})
|
})
|
||||||
return actions
|
return actions, curfew
|
||||||
|
|||||||
27
server.py
27
server.py
@ -4,7 +4,7 @@
|
|||||||
import json
|
import json
|
||||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
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):
|
class Handler(BaseHTTPRequestHandler):
|
||||||
@ -15,10 +15,12 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(HTML.encode())
|
self.wfile.write(HTML.encode())
|
||||||
elif self.path == "/status":
|
elif self.path == "/status":
|
||||||
|
devices_status, curfew = status_all()
|
||||||
|
response = {"devices": devices_status, "curfew": curfew}
|
||||||
self.send_response(200)
|
self.send_response(200)
|
||||||
self.send_header("Content-Type", "application/json")
|
self.send_header("Content-Type", "application/json")
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(json.dumps(status_all()).encode())
|
self.wfile.write(json.dumps(response).encode())
|
||||||
else:
|
else:
|
||||||
self.send_error(404)
|
self.send_error(404)
|
||||||
|
|
||||||
@ -119,6 +121,16 @@ HTML = """\
|
|||||||
.status-item .icon { font-size: 1.1rem; flex-shrink: 0; }
|
.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 .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; }
|
.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 {
|
.status-item .actions {
|
||||||
display: flex; align-items: center; gap: .25rem;
|
display: flex; align-items: center; gap: .25rem;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@ -135,6 +147,7 @@ HTML = """\
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<h1>📺 Kids Devices</h1>
|
<h1>📺 Kids Devices</h1>
|
||||||
<p>TV + Gabi's computer + Gaja's computer</p>
|
<p>TV + Gabi's computer + Gaja's computer</p>
|
||||||
|
<div id="curfew"></div>
|
||||||
<div id="status"></div>
|
<div id="status"></div>
|
||||||
<button id="btn" onclick="run()">TURN OFF</button>
|
<button id="btn" onclick="run()">TURN OFF</button>
|
||||||
<div id="output"></div>
|
<div id="output"></div>
|
||||||
@ -178,7 +191,15 @@ HTML = """\
|
|||||||
try {
|
try {
|
||||||
const res = await fetch('https://noom.cc/off/status');
|
const res = await fetch('https://noom.cc/off/status');
|
||||||
const data = await res.json();
|
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';
|
const devId = s.title === 'TV' ? 'tv' : s.title.includes("Gabi") ? 'gabi' : 'gaja';
|
||||||
return `<div class="status-item">
|
return `<div class="status-item">
|
||||||
<span class="center">
|
<span class="center">
|
||||||
|
|||||||
Reference in New Issue
Block a user