feat(phase8): Add MagicMirror smart display platform

- FastAPI backend for MagicMirror² management
- Module installation/removal from GitHub
- Display brightness and power controls
- Web UI with CRT-light theme and shared sidebar

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
CyberMind-FR 2026-03-28 15:45:28 +01:00
parent b849c7f42d
commit 43b56537de
9 changed files with 741 additions and 0 deletions

View File

@ -0,0 +1,309 @@
"""SecuBox MagicMirror API - Smart display platform management."""
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import subprocess
import json
import os
import re
app = FastAPI(title="SecuBox MagicMirror API", version="1.0.0")
MM_DIR = "/opt/MagicMirror"
CONFIG_FILE = f"{MM_DIR}/config/config.js"
MODULES_DIR = f"{MM_DIR}/modules"
SERVICE_NAME = "magicmirror"
class ModuleConfig(BaseModel):
module: str
position: str = "top_right"
config: dict = {}
class DisplayConfig(BaseModel):
port: int = 8080
address: str = "0.0.0.0"
ipWhitelist: list = []
language: str = "en"
timeFormat: int = 24
units: str = "metric"
def run_cmd(cmd: list, check: bool = True) -> tuple:
"""Run command and return (stdout, stderr, returncode)."""
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
return result.stdout, result.stderr, result.returncode
except subprocess.TimeoutExpired:
return "", "Command timed out", 1
except Exception as e:
return "", str(e), 1
def is_service_running() -> bool:
"""Check if MagicMirror service is running."""
_, _, code = run_cmd(["systemctl", "is-active", "--quiet", SERVICE_NAME], check=False)
return code == 0
def parse_config_js() -> dict:
"""Parse MagicMirror config.js to extract configuration."""
if not os.path.exists(CONFIG_FILE):
return {}
try:
with open(CONFIG_FILE, 'r') as f:
content = f.read()
# Extract modules array using regex (simplified parsing)
modules = []
module_pattern = r'\{\s*module:\s*["\']([^"\']+)["\']'
for match in re.finditer(module_pattern, content):
modules.append(match.group(1))
# Extract basic settings
port_match = re.search(r'port:\s*(\d+)', content)
lang_match = re.search(r'language:\s*["\'](\w+)["\']', content)
return {
"modules": modules,
"port": int(port_match.group(1)) if port_match else 8080,
"language": lang_match.group(1) if lang_match else "en"
}
except Exception:
return {}
def list_installed_modules() -> list:
"""List installed MagicMirror modules."""
modules = []
default_modules = ["alert", "calendar", "clock", "compliments", "currentweather",
"helloworld", "newsfeed", "weatherforecast", "updatenotification"]
# Add default modules
for mod in default_modules:
modules.append({"name": mod, "type": "default", "path": f"{MODULES_DIR}/default/{mod}"})
# Add third-party modules
if os.path.exists(MODULES_DIR):
for entry in os.listdir(MODULES_DIR):
path = os.path.join(MODULES_DIR, entry)
if os.path.isdir(path) and entry not in ["default", "node_modules"]:
modules.append({"name": entry, "type": "third-party", "path": path})
return modules
def list_available_modules() -> list:
"""Return list of popular MagicMirror modules for installation."""
return [
{"name": "MMM-GoogleCalendar", "description": "Google Calendar integration"},
{"name": "MMM-Spotify", "description": "Spotify now playing widget"},
{"name": "MMM-SystemStats", "description": "System CPU/RAM/disk stats"},
{"name": "MMM-Network-Signal", "description": "Network signal strength"},
{"name": "MMM-Wallpaper", "description": "Dynamic wallpaper backgrounds"},
{"name": "MMM-cryptocurrency", "description": "Cryptocurrency prices"},
{"name": "MMM-PIR-Sensor", "description": "Motion sensor screen control"},
{"name": "MMM-homeassistant-sensors", "description": "Home Assistant integration"},
{"name": "MMM-Facial-Recognition", "description": "Face recognition profiles"},
{"name": "MMM-voice-assistant", "description": "Voice control integration"},
]
@app.get("/health")
def health():
return {"status": "ok", "service": "magicmirror"}
@app.get("/status")
def get_status():
"""Get MagicMirror service status and configuration."""
running = is_service_running()
installed = os.path.exists(MM_DIR)
config = parse_config_js() if installed else {}
return {
"installed": installed,
"running": running,
"mm_dir": MM_DIR,
"config": config,
"module_count": len(config.get("modules", []))
}
@app.post("/start")
def start_service():
"""Start MagicMirror service."""
stdout, stderr, code = run_cmd(["systemctl", "start", SERVICE_NAME])
if code != 0:
raise HTTPException(status_code=500, detail=f"Failed to start: {stderr}")
return {"status": "started"}
@app.post("/stop")
def stop_service():
"""Stop MagicMirror service."""
stdout, stderr, code = run_cmd(["systemctl", "stop", SERVICE_NAME])
if code != 0:
raise HTTPException(status_code=500, detail=f"Failed to stop: {stderr}")
return {"status": "stopped"}
@app.post("/restart")
def restart_service():
"""Restart MagicMirror service."""
stdout, stderr, code = run_cmd(["systemctl", "restart", SERVICE_NAME])
if code != 0:
raise HTTPException(status_code=500, detail=f"Failed to restart: {stderr}")
return {"status": "restarted"}
@app.get("/modules")
def get_modules():
"""List all installed modules."""
return {"modules": list_installed_modules()}
@app.get("/modules/available")
def get_available_modules():
"""List popular modules available for installation."""
return {"modules": list_available_modules()}
@app.post("/modules/install/{module_name}")
def install_module(module_name: str):
"""Install a third-party MagicMirror module from GitHub."""
if not module_name.startswith("MMM-"):
raise HTTPException(status_code=400, detail="Module name must start with MMM-")
module_path = f"{MODULES_DIR}/{module_name}"
if os.path.exists(module_path):
raise HTTPException(status_code=409, detail="Module already installed")
# Clone from GitHub (most MM modules follow this pattern)
github_url = f"https://github.com/MichMich/{module_name}.git"
stdout, stderr, code = run_cmd(
["git", "clone", "--depth", "1", github_url, module_path],
check=False
)
if code != 0:
# Try alternative org patterns
for org in ["MagicMirrorOrg", "bugsounet"]:
github_url = f"https://github.com/{org}/{module_name}.git"
stdout, stderr, code = run_cmd(
["git", "clone", "--depth", "1", github_url, module_path],
check=False
)
if code == 0:
break
if code != 0:
raise HTTPException(status_code=500, detail=f"Failed to clone module: {stderr}")
# Run npm install if package.json exists
if os.path.exists(f"{module_path}/package.json"):
run_cmd(["npm", "install", "--prefix", module_path], check=False)
return {"status": "installed", "module": module_name, "path": module_path}
@app.delete("/modules/{module_name}")
def uninstall_module(module_name: str):
"""Uninstall a third-party module."""
module_path = f"{MODULES_DIR}/{module_name}"
if not os.path.exists(module_path):
raise HTTPException(status_code=404, detail="Module not found")
if module_name in ["default", "node_modules"]:
raise HTTPException(status_code=400, detail="Cannot remove system directories")
import shutil
shutil.rmtree(module_path)
return {"status": "uninstalled", "module": module_name}
@app.get("/config")
def get_config():
"""Get current MagicMirror configuration."""
if not os.path.exists(CONFIG_FILE):
raise HTTPException(status_code=404, detail="Config file not found")
with open(CONFIG_FILE, 'r') as f:
content = f.read()
return {"config_file": CONFIG_FILE, "content": content}
@app.get("/logs")
def get_logs(lines: int = 50):
"""Get recent MagicMirror logs."""
stdout, stderr, code = run_cmd(
["journalctl", "-u", SERVICE_NAME, "-n", str(lines), "--no-pager"]
)
return {"logs": stdout.split('\n') if stdout else []}
@app.get("/display")
def get_display_info():
"""Get display/screen information."""
# Check for display server
display_info = {
"DISPLAY": os.environ.get("DISPLAY", ""),
"WAYLAND_DISPLAY": os.environ.get("WAYLAND_DISPLAY", "")
}
# Get screen resolution if xrandr available
stdout, _, code = run_cmd(["xrandr", "--current"], check=False)
if code == 0:
for line in stdout.split('\n'):
if '*' in line: # Current resolution marked with *
parts = line.split()
if parts:
display_info["resolution"] = parts[0]
break
return display_info
@app.post("/display/brightness/{level}")
def set_brightness(level: int):
"""Set display brightness (0-100)."""
if not 0 <= level <= 100:
raise HTTPException(status_code=400, detail="Brightness must be 0-100")
# Try xrandr brightness
brightness = level / 100.0
stdout, stderr, code = run_cmd(
["xrandr", "--output", "HDMI-1", "--brightness", str(brightness)],
check=False
)
if code != 0:
# Try other common output names
for output in ["HDMI-0", "DP-1", "eDP-1", "VGA-1"]:
_, _, code = run_cmd(
["xrandr", "--output", output, "--brightness", str(brightness)],
check=False
)
if code == 0:
break
return {"brightness": level}
@app.post("/display/power/{state}")
def set_display_power(state: str):
"""Turn display on or off."""
if state not in ["on", "off"]:
raise HTTPException(status_code=400, detail="State must be 'on' or 'off'")
if state == "off":
run_cmd(["xset", "dpms", "force", "off"], check=False)
else:
run_cmd(["xset", "dpms", "force", "on"], check=False)
run_cmd(["xset", "s", "reset"], check=False)
return {"display": state}

View File

@ -0,0 +1,8 @@
secubox-magicmirror (1.0.0-1) stable; urgency=low
* Initial release
* MagicMirror service management
* Module installation and management
* Display brightness and power controls
-- SecuBox Team <team@secubox.local> Sat, 28 Mar 2026 10:00:00 +0000

View File

@ -0,0 +1,15 @@
Source: secubox-magicmirror
Section: web
Priority: optional
Maintainer: SecuBox Team <team@secubox.local>
Build-Depends: debhelper-compat (= 13)
Standards-Version: 4.6.0
Package: secubox-magicmirror
Architecture: all
Depends: ${misc:Depends}, python3, python3-fastapi, python3-uvicorn, secubox-core
Recommends: nodejs, npm, xserver-xorg, chromium
Description: SecuBox MagicMirror management
Smart display platform management for SecuBox.
Provides API and web UI for MagicMirror² configuration,
module management, and display controls.

View File

@ -0,0 +1,12 @@
#!/bin/bash
set -e
case "$1" in
configure)
install -d -o secubox -g secubox -m 750 /var/lib/secubox/magicmirror
systemctl daemon-reload
systemctl enable secubox-magicmirror.service
systemctl start secubox-magicmirror.service || true
systemctl reload nginx 2>/dev/null || true
;;
esac
#DEBHELPER#

View File

@ -0,0 +1,16 @@
#!/usr/bin/make -f
%:
dh $@
override_dh_auto_install:
install -d $(CURDIR)/debian/secubox-magicmirror/usr/lib/secubox/magicmirror/api
install -m 644 api/main.py $(CURDIR)/debian/secubox-magicmirror/usr/lib/secubox/magicmirror/api/
touch $(CURDIR)/debian/secubox-magicmirror/usr/lib/secubox/magicmirror/api/__init__.py
install -d $(CURDIR)/debian/secubox-magicmirror/usr/share/secubox/www/magicmirror
install -m 644 www/magicmirror/index.html $(CURDIR)/debian/secubox-magicmirror/usr/share/secubox/www/magicmirror/
install -d $(CURDIR)/debian/secubox-magicmirror/etc/nginx/secubox.d
install -m 644 nginx/magicmirror.conf $(CURDIR)/debian/secubox-magicmirror/etc/nginx/secubox.d/
install -d $(CURDIR)/debian/secubox-magicmirror/usr/share/secubox/menu.d
install -m 644 menu.d/819-magicmirror.json $(CURDIR)/debian/secubox-magicmirror/usr/share/secubox/menu.d/
install -d $(CURDIR)/debian/secubox-magicmirror/usr/lib/systemd/system
install -m 644 debian/secubox-magicmirror.service $(CURDIR)/debian/secubox-magicmirror/usr/lib/systemd/system/

View File

@ -0,0 +1,14 @@
[Unit]
Description=SecuBox MagicMirror API
After=network.target secubox-core.service
[Service]
Type=simple
User=root
WorkingDirectory=/usr/lib/secubox/magicmirror
ExecStart=/usr/bin/uvicorn api.main:app --uds /run/secubox/magicmirror.sock --log-level warning
UMask=0117
Restart=on-failure
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1 @@
{"id":"magicmirror","name":"MagicMirror","icon":"🪞","path":"/magicmirror/","category":"apps","order":819,"description":"Smart display platform"}

View File

@ -0,0 +1,2 @@
location /api/v1/magicmirror/ { proxy_pass http://unix:/run/secubox/magicmirror.sock; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
location /magicmirror/ { alias /usr/share/secubox/www/magicmirror/; try_files $uri $uri/ /magicmirror/index.html; }

View File

@ -0,0 +1,364 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MagicMirror - SecuBox</title>
<link rel="stylesheet" href="/shared/sidebar-light.css">
<link rel="stylesheet" href="/shared/crt-light.css">
<style>
.main-content {
margin-left: 200px;
padding: 2rem;
}
.status-card {
background: var(--card-bg, #1a1a2e);
border: 1px solid var(--border-color, #333);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.status-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0;
border-bottom: 1px solid var(--border-color, #333);
}
.status-row:last-child { border-bottom: none; }
.badge {
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.85rem;
font-weight: 500;
}
.badge-success { background: #2d5a27; color: #7fff7f; }
.badge-danger { background: #5a2727; color: #ff7f7f; }
.badge-warning { background: #5a5a27; color: #ffff7f; }
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
margin-right: 0.5rem;
}
.btn-primary { background: var(--accent-color, #4a9eff); color: #000; }
.btn-success { background: #27ae60; color: #fff; }
.btn-danger { background: #c0392b; color: #fff; }
.btn-secondary { background: #555; color: #fff; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.module-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.module-card {
background: var(--card-bg, #1a1a2e);
border: 1px solid var(--border-color, #333);
border-radius: 8px;
padding: 1rem;
}
.module-card h4 {
margin: 0 0 0.5rem 0;
color: var(--accent-color, #4a9eff);
}
.module-card .type {
font-size: 0.8rem;
color: #888;
margin-bottom: 0.5rem;
}
.section-title {
margin: 2rem 0 1rem 0;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--accent-color, #4a9eff);
}
.controls-row {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
align-items: center;
}
.slider-container {
display: flex;
align-items: center;
gap: 1rem;
}
.slider-container input[type="range"] {
width: 200px;
}
.slider-value {
min-width: 3rem;
text-align: center;
}
.log-box {
background: #0a0a0a;
border: 1px solid #333;
border-radius: 4px;
padding: 1rem;
font-family: monospace;
font-size: 0.85rem;
max-height: 300px;
overflow-y: auto;
white-space: pre-wrap;
color: #7fff7f;
}
.install-form {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.install-form input {
flex: 1;
padding: 0.5rem;
border: 1px solid #333;
border-radius: 4px;
background: #1a1a2e;
color: #fff;
}
</style>
</head>
<body>
<div id="sidebar-container"></div>
<div class="main-content">
<h1>🪞 MagicMirror</h1>
<p>Smart display platform management</p>
<div class="status-card">
<h3>Service Status</h3>
<div class="status-row">
<span>MagicMirror Service</span>
<span id="service-status" class="badge badge-warning">Checking...</span>
</div>
<div class="status-row">
<span>Installation</span>
<span id="install-status" class="badge badge-warning">Checking...</span>
</div>
<div class="status-row">
<span>Active Modules</span>
<span id="module-count">-</span>
</div>
<div class="status-row">
<span>Web Interface</span>
<span id="web-port">-</span>
</div>
<div style="margin-top: 1rem;">
<button class="btn btn-success" onclick="startService()">Start</button>
<button class="btn btn-danger" onclick="stopService()">Stop</button>
<button class="btn btn-secondary" onclick="restartService()">Restart</button>
<a id="open-mm" href="#" target="_blank" class="btn btn-primary" style="text-decoration: none; display: none;">Open MagicMirror</a>
</div>
</div>
<div class="status-card">
<h3>Display Controls</h3>
<div class="controls-row">
<button class="btn btn-success" onclick="displayPower('on')">Display On</button>
<button class="btn btn-danger" onclick="displayPower('off')">Display Off</button>
</div>
<div class="slider-container">
<label>Brightness:</label>
<input type="range" id="brightness" min="0" max="100" value="100" onchange="setBrightness(this.value)">
<span class="slider-value" id="brightness-value">100%</span>
</div>
</div>
<h2 class="section-title">Installed Modules</h2>
<div id="modules-grid" class="module-grid">
<p>Loading modules...</p>
</div>
<h2 class="section-title">Install Module</h2>
<div class="install-form">
<input type="text" id="module-name" placeholder="MMM-ModuleName (e.g., MMM-SystemStats)">
<button class="btn btn-primary" onclick="installModule()">Install</button>
</div>
<h3>Popular Modules</h3>
<div id="available-modules" class="module-grid">
<p>Loading available modules...</p>
</div>
<h2 class="section-title">Logs</h2>
<button class="btn btn-secondary" onclick="loadLogs()">Refresh Logs</button>
<div id="logs" class="log-box" style="margin-top: 1rem;">
Click "Refresh Logs" to load...
</div>
</div>
<script src="/shared/sidebar.js"></script>
<script>
const API = '/api/v1/magicmirror';
async function loadStatus() {
try {
const res = await fetch(`${API}/status`);
const data = await res.json();
document.getElementById('service-status').textContent = data.running ? 'Running' : 'Stopped';
document.getElementById('service-status').className = 'badge ' + (data.running ? 'badge-success' : 'badge-danger');
document.getElementById('install-status').textContent = data.installed ? 'Installed' : 'Not Installed';
document.getElementById('install-status').className = 'badge ' + (data.installed ? 'badge-success' : 'badge-danger');
document.getElementById('module-count').textContent = data.module_count || 0;
const port = data.config?.port || 8080;
document.getElementById('web-port').textContent = `Port ${port}`;
const openBtn = document.getElementById('open-mm');
if (data.running) {
openBtn.style.display = 'inline-block';
openBtn.href = `http://${window.location.hostname}:${port}`;
} else {
openBtn.style.display = 'none';
}
} catch (e) {
console.error('Failed to load status:', e);
}
}
async function loadModules() {
try {
const res = await fetch(`${API}/modules`);
const data = await res.json();
const grid = document.getElementById('modules-grid');
if (data.modules && data.modules.length > 0) {
grid.innerHTML = data.modules.map(m => `
<div class="module-card">
<h4>${m.name}</h4>
<div class="type">${m.type}</div>
${m.type === 'third-party' ?
`<button class="btn btn-danger" onclick="uninstallModule('${m.name}')">Uninstall</button>` :
''}
</div>
`).join('');
} else {
grid.innerHTML = '<p>No modules found</p>';
}
} catch (e) {
console.error('Failed to load modules:', e);
}
}
async function loadAvailableModules() {
try {
const res = await fetch(`${API}/modules/available`);
const data = await res.json();
const grid = document.getElementById('available-modules');
if (data.modules && data.modules.length > 0) {
grid.innerHTML = data.modules.map(m => `
<div class="module-card">
<h4>${m.name}</h4>
<div class="type">${m.description}</div>
<button class="btn btn-primary" onclick="installModuleByName('${m.name}')">Install</button>
</div>
`).join('');
}
} catch (e) {
console.error('Failed to load available modules:', e);
}
}
async function startService() {
try {
await fetch(`${API}/start`, { method: 'POST' });
loadStatus();
} catch (e) {
alert('Failed to start service: ' + e.message);
}
}
async function stopService() {
try {
await fetch(`${API}/stop`, { method: 'POST' });
loadStatus();
} catch (e) {
alert('Failed to stop service: ' + e.message);
}
}
async function restartService() {
try {
await fetch(`${API}/restart`, { method: 'POST' });
loadStatus();
} catch (e) {
alert('Failed to restart service: ' + e.message);
}
}
async function displayPower(state) {
try {
await fetch(`${API}/display/power/${state}`, { method: 'POST' });
} catch (e) {
alert('Failed to control display: ' + e.message);
}
}
async function setBrightness(level) {
document.getElementById('brightness-value').textContent = level + '%';
try {
await fetch(`${API}/display/brightness/${level}`, { method: 'POST' });
} catch (e) {
console.error('Failed to set brightness:', e);
}
}
async function installModule() {
const name = document.getElementById('module-name').value.trim();
if (!name) {
alert('Please enter a module name');
return;
}
await installModuleByName(name);
}
async function installModuleByName(name) {
try {
const res = await fetch(`${API}/modules/install/${name}`, { method: 'POST' });
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || 'Install failed');
}
alert('Module installed successfully! Restart MagicMirror to activate.');
loadModules();
} catch (e) {
alert('Failed to install module: ' + e.message);
}
}
async function uninstallModule(name) {
if (!confirm(`Uninstall ${name}?`)) return;
try {
await fetch(`${API}/modules/${name}`, { method: 'DELETE' });
loadModules();
} catch (e) {
alert('Failed to uninstall module: ' + e.message);
}
}
async function loadLogs() {
try {
const res = await fetch(`${API}/logs?lines=50`);
const data = await res.json();
document.getElementById('logs').textContent = data.logs?.join('\n') || 'No logs available';
} catch (e) {
document.getElementById('logs').textContent = 'Failed to load logs';
}
}
// Initialize
document.addEventListener('DOMContentLoaded', () => {
loadStatus();
loadModules();
loadAvailableModules();
});
// Auto-refresh status every 30s
setInterval(loadStatus, 30000);
</script>
</body>
</html>