Move the off.sh script to a pure Python implementation -- devices.py.

This commit is contained in:
2026-06-08 22:59:39 +02:00
parent b15f87e3d5
commit c5b9036644
3 changed files with 140 additions and 147 deletions

135
devices.py Normal file
View File

@ -0,0 +1,135 @@
"""Device management: check status and power off kids' devices."""
from datetime import datetime
import subprocess
def _run(cmd: list[str], *, timeout: int = 10) -> subprocess.CompletedProcess | None:
"""Run a command, return CompletedProcess or None on error."""
try:
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
except (FileNotFoundError, subprocess.TimeoutExpired):
return None
# ── TV ────────────────────────────────────────────────────────────────
def tv_check() -> dict:
"""Check if the TV is reachable and powered on.
Returns an action dict describing the current state.
"""
result = _run(["ping", "-c", "1", "-W", "1", "192.168.1.12"])
if not result or result.returncode != 0:
return {"icon": "", "title": "TV", "detail": "Unreachable (ping failed)"}
_run(["adb", "connect", "192.168.1.12"])
result = _run(["adb", "shell", "dumpsys", "power"])
if result and "mWakefulness=Awake" in result.stdout:
return {"icon": "📺", "title": "TV", "detail": "Screen is on"}
return {"icon": "📺", "title": "TV", "detail": "Already off"}
def tv_turnoff() -> dict | None:
"""Send power key to the TV if it's on. Returns action dict or None."""
state = tv_check()
if state["detail"] != "Screen is on":
return None
_run(["adb", "shell", "input", "keyevent", "26"])
return {"icon": "📺", "title": "TV", "detail": "Screen turned off (power key sent)"}
# ── Gabi ──────────────────────────────────────────────────────────────
def gabi_check() -> bool:
"""Return True if user 'gabi' has an active login session."""
result = _run(["loginctl", "list-users"])
return result is not None and "gabi" in result.stdout
def gabi_turnoff() -> dict | None:
"""Log out Gabi if she's logged in. Returns action dict or None."""
if not gabi_check():
return {"icon": "💻", "title": "Gabi's PC", "detail": "Not logged in — no action needed"}
result = _run(["doas", "loginctl", "terminate-user", "gabi"])
if result and result.returncode == 0:
return {"icon": "💻", "title": "Gabi's PC", "detail": "Session terminated"}
return {"icon": "", "title": "Gabi's PC", "detail": "Failed to terminate session"}
# ── Gaja ──────────────────────────────────────────────────────────────
def gaja_check() -> bool:
"""Return True if user 'gaja' has an active login session on her PC."""
result = _run(["ping", "-c", "1", "-W", "1", "192.168.1.122"])
if not result or result.returncode != 0:
return False
check = _run(
["sshpass", "-p", "Nagaja", "ssh", "-o", "ConnectTimeout=5",
"-o", "StrictHostKeyChecking=no", "-F", "/dev/null",
"gaja@192.168.1.122",
"loginctl list-sessions --no-pager 2>/dev/null | grep -q gaja"]
)
return check is not None and check.returncode == 0
def gaja_turnoff() -> dict | None:
"""Log out Gaja if she's logged in. Returns action dict or None."""
# Ping check
result = _run(["ping", "-c", "1", "-W", "1", "192.168.1.122"])
if not result or result.returncode != 0:
return {"icon": "", "title": "Gaja's PC", "detail": "Unreachable (ping failed)"}
if not gaja_check():
return {"icon": "💻", "title": "Gaja's PC", "detail": "Not logged in — no action needed"}
_run(
["sshpass", "-p", "Nagaja", "ssh", "-o", "ConnectTimeout=5",
"-o", "StrictHostKeyChecking=no", "-F", "/dev/null",
"gaja@192.168.1.122",
"loginctl terminate-user gaja"],
timeout=5
)
return {"icon": "💻", "title": "Gaja's PC", "detail": "Logged out"}
# ── Orchestrator ──────────────────────────────────────────────────────
def shutdown_all() -> list[dict]:
"""Run all device checks and logouts. Returns ordered list of action dicts."""
actions = []
# Time window
now = datetime.now()
time_start = now.replace(hour=0, minute=0, second=0)
time_stop = now.replace(hour=23, minute=59, second=59)
if now < time_start:
diff = int((time_start - now).total_seconds())
actions.append({"icon": "", "title": "Time Window", "detail": f"Before active hours — {diff}s until start"})
elif now > time_stop:
diff = int((now - time_stop).total_seconds())
actions.append({"icon": "", "title": "Time Window", "detail": f"After active hours — {diff}s past end"})
else:
actions.append({"icon": "🟢", "title": "Active Hours", "detail": f"Executing for the next {time_stop - now}"})
# Check → act for each device
for check_fn, action_fn in [
(tv_check, tv_turnoff),
(gabi_check, gabi_turnoff),
(gaja_check, gaja_turnoff),
]:
status = check_fn()
if isinstance(status, dict):
actions.append(status) # e.g. unreachable
elif status is True:
result = action_fn()
if result:
actions.append(result)
return actions

57
off.sh
View File

@ -1,57 +0,0 @@
TIME_START=00:00
TIME_STOP=23:59
tv_check()
{
ping -c 1 -W 1 192.168.1.12 2>&1 > /dev/null || { echo "TV is unreachable."; return; }
adb connect 192.168.1.12
if adb shell dumpsys power | grep mWakefulness=Awake
then
adb shell input keyevent 26
else
echo "TV is turning OFF."
fi
}
gabi_check()
{
loginctl list-users | grep gabi || { echo 'Gabi is not online.'; return; }
doas loginctl terminate-user gabi
}
gaja_check()
{
ping -c 1 -W 1 192.168.1.122 2>&1 >/dev/null || { echo "Gaja's PC is unreachable."; return; }
# Check if Gaja has any sessions
sshpass -p Nagaja ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no -F /dev/null gaja@192.168.1.122 \
"loginctl list-sessions --no-pager 2>/dev/null | grep -q gaja" 2>&1
if [ $? -ne 0 ]; then
echo "Gaja is not online."
return
fi
# Terminate all of Gaja's sessions (SSH drops = success)
sshpass -p Nagaja ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no -F /dev/null gaja@192.168.1.122 \
"loginctl terminate-user gaja" 2>/dev/null
echo "Gaja's PC: logged out."
}
S=$(date -d "$TIME_START" +%s)
T=$(date -d "$TIME_STOP" +%s)
C=$(date +%s)
if [ $C -lt $S ]
then
echo "PRE: $((S - C)) seconds to go."
elif [ $C -gt $T ]
then
echo "POST: $((C - T)) seconds passed."
else
echo "ACTIVE: Executing for the next $((T - C)) seconds."
gabi_check
gaja_check
tv_check
fi
exit 0

View File

@ -2,80 +2,9 @@
"""Simple web server: one button to turn off all kids' devices.""" """Simple web server: one button to turn off all kids' devices."""
import json import json
import re
import subprocess
from pathlib import Path
from http.server import HTTPServer, BaseHTTPRequestHandler from http.server import HTTPServer, BaseHTTPRequestHandler
SCRIPT = Path(__file__).parent / "off.sh" from devices import shutdown_all
def parse_output(text: str) -> list[dict]:
"""Parse off.sh output into structured action items."""
actions = []
lines = text.strip().splitlines()
for line in lines:
line = line.strip()
if not line:
continue
# Time window messages
m = re.match(r"PRE:\s*(.+)seconds?\s*to\s*go\.", line)
if m:
actions.append({"icon": "", "title": "Time Window", "detail": f"Before active hours — {m.group(1)}s until start"})
continue
m = re.match(r"POST:\s*(.+)seconds?\s*passed\.", line)
if m:
actions.append({"icon": "", "title": "Time Window", "detail": f"After active hours — {m.group(1)}s past end"})
continue
m = re.match(r"ACTIVE:\s*(.+)", line)
if m:
actions.append({"icon": "🟢", "title": "Active Hours", "detail": m.group(1)})
continue
# TV checks
if "TV is unreachable" in line:
actions.append({"icon": "", "title": "TV", "detail": "Unreachable (ping failed)"})
continue
if "TV is turning OFF" in line:
actions.append({"icon": "📺", "title": "TV", "detail": "Already off — no action needed"})
continue
if "mWakefulness=Awake" in line:
actions.append({"icon": "📺", "title": "TV", "detail": "Screen turned off (power key sent)"})
continue
# User checks
if "Gabi is not online" in line:
actions.append({"icon": "💻", "title": "Gabi's PC", "detail": "Not logged in — no action needed"})
continue
m = re.match(r"terminate-user gabi\b", line)
if m or "session terminated" in line.lower():
actions.append({"icon": "💻", "title": "Gabi's PC", "detail": "Session terminated"})
continue
if "Gaja is not online" in line:
actions.append({"icon": "💻", "title": "Gaja's PC", "detail": "Not logged in — no action needed"})
continue
if "Gaja's PC is unreachable" in line:
actions.append({"icon": "", "title": "Gaja's PC", "detail": "Unreachable (ping failed)"})
continue
if "Gaja's PC: logged out" in line:
actions.append({"icon": "💻", "title": "Gaja's PC", "detail": "Logged out"})
continue
# If no structured items found, fallback to raw
if not actions:
actions.append({"icon": "📜", "title": "Output", "detail": text.strip() or "(empty)"})
return actions
class Handler(BaseHTTPRequestHandler): class Handler(BaseHTTPRequestHandler):
@ -91,26 +20,12 @@ class Handler(BaseHTTPRequestHandler):
def do_POST(self): def do_POST(self):
if self.path == "/run": if self.path == "/run":
try: try:
result = subprocess.run( actions = shutdown_all()
["bash", str(SCRIPT)], status = "ok"
capture_output=True,
text=True,
timeout=30,
)
raw_output = (result.stdout + result.stderr).strip() or "Script executed (no output)."
status = "ok" if result.returncode == 0 else f"exit {result.returncode}"
except FileNotFoundError:
raw_output = f"Script not found at {SCRIPT}"
status = "error"
except subprocess.TimeoutExpired:
raw_output = "Script timed out after 30 seconds."
status = "error"
except Exception as e: except Exception as e:
raw_output = str(e) actions = [{"icon": "", "title": "Error", "detail": str(e)}]
status = "error" status = "error"
actions = parse_output(raw_output) if status == "ok" else [{"icon": "", "title": "Error", "detail": raw_output}]
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()
@ -167,7 +82,7 @@ HTML = """\
<body> <body>
<div class="card"> <div class="card">
<h1>📺 Kids Devices</h1> <h1>📺 Kids Devices</h1>
<p>TV + Gabi's computer</p> <p>TV + Gabi's computer + Gaja's computer</p>
<button id="btn" onclick="run()">TURN OFF</button> <button id="btn" onclick="run()">TURN OFF</button>
<div id="output"></div> <div id="output"></div>
</div> </div>