Files
off/server.py
2026-06-09 09:45:55 +02:00

268 lines
9.7 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Simple web server: one button to turn off all kids' devices."""
import json
from http.server import HTTPServer, BaseHTTPRequestHandler
import config
from devices import shutdown_all, status_all, curfew_status, _set_budget, _get_budget
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/" or self.path == "/index.html":
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
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(response).encode())
else:
self.send_error(404)
def do_POST(self):
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length).decode() if content_length > 0 else ""
if self.path == "/run":
try:
actions = shutdown_all()
status = "ok"
except Exception as e:
actions = [{"icon": "", "title": "Error", "detail": str(e)}]
status = "error"
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({"status": status, "actions": actions}).encode())
elif self.path == "/budget":
try:
data = json.loads(body) if body else {}
dev_id = data.get("device", "")
delta = int(data.get("delta", 0))
current = _get_budget(dev_id)
new_budget = max(0, min(config.BUDGET_S, current + delta))
_set_budget(dev_id, new_budget)
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({"status": "ok"}).encode())
except Exception as e:
self.send_response(400)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({"status": "error", "detail": str(e)}).encode())
else:
self.send_error(404)
def log_message(self, format, *args):
pass
HTML = """\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kids Devices — OFF</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: system-ui, sans-serif;
display: flex; justify-content: center; align-items: center;
min-height: 100vh; background: #1a1a2e; color: #eee;
}
.card {
text-align: center; padding: 3rem; border-radius: 16px;
background: #16213e; box-shadow: 0 8px 32px rgba(0,0,0,.4);
max-width: 420px; width: 90%;
}
h1 { font-size: 1.5rem; margin-bottom: .5rem; }
p { color: #aaa; margin-bottom: 2rem; font-size: .9rem; }
button {
background: #e94560; color: #fff; border: none;
padding: 1rem 2.5rem; font-size: 1.2rem; font-weight: 700;
border-radius: 8px; cursor: pointer; transition: .2s;
}
button:hover { background: #c73650; }
button:disabled { opacity: .5; cursor: wait; }
#output {
margin-top: 1.5rem; text-align: left; display: none;
}
.action-item {
display: flex; align-items: flex-start; gap: .75rem;
padding: .75rem 1rem; margin-bottom: .5rem;
border-radius: 8px; background: #0f3460;
}
.action-item .icon { font-size: 1.3rem; flex-shrink: 0; }
.action-item .info { flex: 1; }
.action-item .title { font-weight: 600; font-size: .95rem; }
.action-item .detail { color: #aaa; font-size: .82rem; margin-top: .15rem; }
#status {
margin-bottom: 2rem;
}
.status-item {
display: flex; align-items: center;
padding: .4rem .75rem; margin-bottom: .3rem;
border-radius: 6px; background: #0f3460; font-size: .8rem;
gap: .5rem;
}
.status-item .center {
display: flex; align-items: center; gap: .5rem;
flex: 1; min-width: 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 .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;
}
.budget-icon {
background: none; border: none;
color: #666; font-size: 1rem; cursor: pointer;
padding: 0; line-height: 1; transition: .15s;
}
.budget-icon:hover { color: #fff; }
</style>
</head>
<body>
<div class="card">
<h1>📺 Kids Devices</h1>
<p>TV + Gabi's computer + Gaja's computer</p>
<div id="curfew"></div>
<div id="status"></div>
<button id="btn" onclick="run()">TURN OFF</button>
<div id="output"></div>
</div>
<script>
async function run() {
const btn = document.getElementById('btn');
const out = document.getElementById('output');
btn.disabled = true;
btn.textContent = '';
out.style.display = 'block';
out.innerHTML = '<div class="action-item"><span class="icon">⏳</span><div class="info"><div class="title">Running…</div></div></div>';
try {
const res = await fetch('https://noom.cc/off/run', { method: 'POST' });
const data = await res.json();
if (data.actions && data.actions.length) {
out.innerHTML = data.actions.map(a =>
`<div class="action-item">
<span class="icon">${a.icon}</span>
<div class="info">
<div class="title">${a.title}</div>
<div class="detail">${a.detail}</div>
</div>
</div>`
).join('');
} else {
out.textContent = 'No actions taken.';
}
} catch(e) {
out.innerHTML = `<div class="action-item">
<span class="icon">❌</span>
<div class="info"><div class="title">Error</div><div class="detail">${e.message}</div></div>
</div>`;
}
btn.disabled = false;
btn.textContent = 'TURN OFF';
}
async function refreshStatus() {
try {
const res = await fetch('https://noom.cc/off/status');
const data = await res.json();
// 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 `<div class="status-item">
<span class="center">
<span class="icon">${s.icon}</span>
<span class="name">${s.title}</span>
<span class="budget">${s.detail}</span>
</span>
<span class="actions">
<button class="budget-icon" onclick="adjustBudget('${devId}', -300)"></button>
<button class="budget-icon" onclick="adjustBudget('${devId}', 300)">+</button>
</span>
</div>`;
}).join('');
} catch(e) {
// Ignore status errors
}
}
function adjustBudget(device, delta) {
// Update UI immediately (optimistic update)
const items = document.querySelectorAll('.status-item');
const devMap = {tv: 0, gabi: 1, gaja: 2};
const idx = devMap[device];
if (idx !== undefined && items[idx]) {
const budgetEl = items[idx].querySelector('.budget');
const current = parseInt(budgetEl.textContent.match(/\\d+/)?.[0] || '0');
const maxBudget = {max_budget};
const newBudget = Math.max(0, Math.min(maxBudget, current + Math.round(delta / 60)));
const status = budgetEl.textContent.split(' · ')[0];
budgetEl.textContent = `${status} · ${newBudget} min left`;
}
// Send to server in background (fire-and-forget)
fetch('https://noom.cc/off/budget', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({device, delta})
}).catch(() => {});
}
refreshStatus();
setInterval(refreshStatus, 60000);
</script>
</body>
</html>
"""
# Inject config values into HTML template
HTML = HTML.replace("{max_budget}", str(int(config.BUDGET_S / 60)))
if __name__ == "__main__":
from devices import start_timer
from tui import start_tui
port = int(__import__("os").environ.get("PORT", "10000"))
start_timer()
start_tui(config.TICK_INTERVAL)
server = HTTPServer(("0.0.0.0", port), Handler)
print(f"🚀 Open http://localhost:{port}")
print(f"⏱️ Budget timer running (checks every {config.TICK_INTERVAL}s, reset at 7:00 AM)")
try:
server.serve_forever()
except KeyboardInterrupt:
from devices import stop_timer
stop_timer()