Files
pi-pm3/app/backend/api/system.py
michael 4f35df1781 Initial commit - Phase 3/4
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 13:46:22 -08:00

890 lines
26 KiB
Python

"""System API endpoints.
Refactored to use services for business logic.
Session management uses PM3Service, system operations use SystemService.
"""
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
from typing import Optional, Dict
from ..services.container import container
from ..managers.ups_manager import get_ups_manager
from ..managers.ble_manager import get_ble_manager
router = APIRouter()
class CreateSessionRequest(BaseModel):
force_takeover: bool = False
device_id: Optional[str] = None # Device to create session for
class CreateSessionResponse(BaseModel):
success: bool
session_id: Optional[str] = None
device_id: Optional[str] = None
error: Optional[str] = None
class SessionInfo(BaseModel):
session_id: str
device_id: Optional[str]
client_ip: str
created_at: float
last_activity: float
time_remaining: float
class CPUCoreInfo(BaseModel):
"""Per-core CPU information."""
core_id: int
percent: float
online: bool = True # Whether this core is currently enabled
class CPUInfo(BaseModel):
"""CPU information including per-core data."""
count: int
percent: float
temperature: Optional[float] = None
per_core: list[CPUCoreInfo] = []
load_average: Optional[list[float]] = None
class SystemInfo(BaseModel):
"""System information response."""
hostname: str
uptime: float
cpu_temp: Optional[float]
cpu: Optional[CPUInfo] = None
memory_used: float
memory_total: float
disk_used: float
disk_total: float
@router.post("/session/create", response_model=CreateSessionResponse)
async def create_session(request: Request, body: CreateSessionRequest):
"""Create a new session for PM3 access.
Uses PM3Service for session management.
Optionally specify device_id for multi-device support.
"""
# Get client IP from request
client_ip = request.client.host if request.client else "unknown"
user_agent = request.headers.get("user-agent")
result = await container.pm3_service.create_session(
client_ip=client_ip,
user_agent=user_agent,
force_takeover=body.force_takeover,
device_id=body.device_id
)
if result.success:
return CreateSessionResponse(
success=True,
session_id=result.data["session_id"],
device_id=result.data.get("device_id"),
error=None
)
else:
return CreateSessionResponse(
success=False,
session_id=None,
device_id=None,
error=result.error.message
)
@router.post("/session/{session_id}/release")
async def release_session(session_id: str, device_id: Optional[str] = None):
"""Release an active session.
Uses PM3Service for session management.
Args:
session_id: Session ID to release
device_id: Optional device ID for faster lookup
"""
result = await container.pm3_service.release_session(session_id, device_id)
if not result.success:
raise HTTPException(
status_code=404,
detail=result.error.message
)
return {"success": True, "message": result.data["message"]}
@router.get("/session/active", response_model=Optional[SessionInfo])
async def get_active_session(device_id: Optional[str] = None):
"""Get information about the active session for a device.
Uses PM3Service (via SessionManager) for session info.
Args:
device_id: Optional device ID. If None, returns any active session.
"""
# Access session manager through container for consistency
session = container.session_manager.get_active_session(device_id)
if not session:
return None
import time
from .. import config
time_remaining = config.SESSION_TIMEOUT - (time.time() - session.last_activity)
return SessionInfo(
session_id=session.session_id,
device_id=session.device_id,
client_ip=session.client_ip,
created_at=session.created_at,
last_activity=session.last_activity,
time_remaining=max(0, time_remaining)
)
@router.get("/sessions/all")
async def get_all_sessions():
"""Get all active sessions across all devices.
Returns a list of all active sessions with their device IDs.
"""
result = container.pm3_service.get_all_sessions()
return result.data
@router.get("/info", response_model=SystemInfo)
async def get_system_info():
"""Get system information.
Refactored to use SystemService for business logic.
"""
from ..services.container import container
result = await container.system_service.get_info()
if not result.success:
raise HTTPException(
status_code=500,
detail=result.error.message
)
cpu_data = result.data["cpu"]
memory_data = result.data["memory"]
disk_data = result.data["disk"]
# Build per-core CPU info
per_core = [
CPUCoreInfo(
core_id=c["core_id"],
percent=c["percent"],
online=c.get("online", True)
)
for c in cpu_data.get("per_core", [])
]
cpu_info = CPUInfo(
count=cpu_data.get("count", 0),
percent=cpu_data.get("percent", 0.0),
temperature=cpu_data.get("temperature"),
per_core=per_core,
load_average=cpu_data.get("load_average")
)
return SystemInfo(
hostname=result.data["hostname"],
uptime=result.data["uptime"],
cpu_temp=cpu_data.get("temperature"),
cpu=cpu_info,
memory_used=memory_data["used"],
memory_total=memory_data["total"],
disk_used=disk_data["used"],
disk_total=disk_data["total"]
)
@router.get("/config")
async def get_config():
"""Get system configuration (non-sensitive values)."""
from .. import config
from ..services.container import container
# Get WiFi mode from manager, default to AP mode
wifi_mode = container.wifi_manager._current_mode.value if container.wifi_manager else "ap"
return {
"pm3_device": config.PM3_DEVICE,
"session_timeout": config.SESSION_TIMEOUT,
"wifi_mode": wifi_mode,
"ble_enabled": config.BLE_ENABLED,
"auth_enabled": config.AUTH_ENABLED,
"https_enabled": config.HTTPS_ENABLED
}
@router.post("/restart")
async def restart_system(delay: int = 0):
"""Restart the system.
Refactored to use SystemService for business logic.
Args:
delay: Delay in seconds before restart (default: 0)
"""
from ..services.container import container
result = await container.system_service.restart(delay=delay)
if not result.success:
raise HTTPException(
status_code=500,
detail=result.error.message
)
return {"success": True, "message": result.data["message"]}
@router.post("/shutdown")
async def shutdown_system(delay: int = 0):
"""Initiate system shutdown.
Refactored to use SystemService for business logic.
Args:
delay: Delay in seconds before shutdown (default: 0)
"""
from ..services.container import container
result = await container.system_service.shutdown(delay=delay)
if not result.success:
raise HTTPException(
status_code=500,
detail=result.error.message
)
return {"success": True, "message": result.data["message"]}
class UPSStatusResponse(BaseModel):
"""UPS status response model."""
battery_percentage: float
voltage: float
current: float
power_source: str
battery_status: str
time_remaining: Optional[int] = None
temperature: Optional[float] = None
last_updated: Optional[str] = None
is_available: bool
error_message: Optional[str] = None
class UPSThresholdsRequest(BaseModel):
"""Request to set UPS battery thresholds."""
shutdown_threshold: Optional[float] = None
warning_threshold: Optional[float] = None
class PowerRestrictionsResponse(BaseModel):
"""Power restrictions response model."""
restricted: bool
reason: Optional[str] = None
power_source: str
ups_available: bool
battery_percentage: Optional[float] = None
allow_firmware_flash: bool
allow_bootloader_flash: bool
allow_intensive_operations: bool
message: Optional[str] = None
warning: Optional[str] = None
shutdown_imminent: Optional[bool] = None
@router.get("/ups/status", response_model=UPSStatusResponse)
async def get_ups_status():
"""Get current UPS battery status."""
ups_manager = get_ups_manager()
status = await ups_manager.get_status()
return UPSStatusResponse(
battery_percentage=status.battery_percentage,
voltage=status.voltage,
current=status.current,
power_source=status.power_source.value,
battery_status=status.battery_status.value,
time_remaining=status.time_remaining,
temperature=status.temperature,
last_updated=status.last_updated,
is_available=status.is_available,
error_message=status.error_message
)
@router.post("/ups/thresholds")
async def set_ups_thresholds(request: UPSThresholdsRequest):
"""Set UPS battery thresholds for warnings and shutdown."""
ups_manager = get_ups_manager()
if request.shutdown_threshold is not None:
ups_manager.set_shutdown_threshold(request.shutdown_threshold)
if request.warning_threshold is not None:
ups_manager.set_warning_threshold(request.warning_threshold)
return {
"success": True,
"message": "UPS thresholds updated"
}
@router.post("/ups/shutdown")
async def trigger_ups_shutdown(delay: int = 30):
"""Trigger safe shutdown via UPS manager.
Args:
delay: Delay in seconds before shutdown (default: 30)
"""
ups_manager = get_ups_manager()
await ups_manager.shutdown_device(delay=delay)
return {
"success": True,
"message": f"Shutdown initiated with {delay}s delay"
}
@router.get("/power/restrictions", response_model=PowerRestrictionsResponse)
async def get_power_restrictions():
"""Get current power restrictions based on UPS/battery state.
Returns power policy information including:
- Whether operations are restricted
- Current power source (AC, battery, or assumed AC if no UPS)
- Battery level (if UPS present)
- Which operations are allowed (firmware flash, bootloader flash, etc.)
- User-friendly messages and warnings
This endpoint is critical for determining whether power-intensive
operations (like firmware flashing) should be allowed.
If UPS hardware is not detected, assumes stable AC power and allows
all operations (user responsibility to ensure power stability).
"""
ups_manager = get_ups_manager()
restrictions = ups_manager.get_power_restrictions()
return PowerRestrictionsResponse(**restrictions)
class PiModelResponse(BaseModel):
"""Pi model information response."""
model: str
model_short: str
total_cores: int
default_active_cores: int
min_cores: int
max_cores: int
class CPUCoresConfigResponse(BaseModel):
"""CPU cores configuration response."""
total_cores: int
online_cores: int
configured_cores: Optional[int] = None # From cmdline.txt maxcpus parameter
configurable_cores: list[int]
pi_model: Optional[PiModelResponse] = None
class SetCPUCoresRequest(BaseModel):
"""Request to set CPU cores."""
num_cores: int
persist: bool = True # Save to config for boot persistence
reboot: bool = True # Reboot after saving
@router.get("/cpu/cores", response_model=CPUCoresConfigResponse)
async def get_cpu_cores():
"""Get CPU cores configuration.
Returns information about:
- Total physical cores
- Currently online cores
- Which cores can be toggled (core 0 is always on)
- Pi model information with recommended defaults
"""
result = await container.system_service.get_cpu_cores_config()
if not result.success:
raise HTTPException(
status_code=500,
detail=result.error.message
)
# Convert pi_model dict to response model if present
pi_model_data = result.data.get("pi_model")
pi_model = None
if pi_model_data:
pi_model = PiModelResponse(**pi_model_data)
return CPUCoresConfigResponse(
total_cores=result.data["total_cores"],
online_cores=result.data["online_cores"],
configured_cores=result.data.get("configured_cores"),
configurable_cores=result.data["configurable_cores"],
pi_model=pi_model
)
@router.post("/cpu/cores")
async def set_cpu_cores(request: SetCPUCoresRequest):
"""Set the number of active CPU cores.
Core 0 is always active. This endpoint enables/disables cores 1 through N.
For Pi Zero 2 W, the recommended default is 2 cores (out of 4) for
better thermal management and power efficiency.
Args:
num_cores: Number of cores to keep active (1 to max_cores)
persist: Save setting to config file (default: True)
reboot: Reboot system after saving (default: True)
"""
result = await container.system_service.set_cpu_cores(
num_cores=request.num_cores,
persist=request.persist,
reboot=request.reboot
)
if not result.success:
raise HTTPException(
status_code=400 if result.error.code == "invalid_cores" else 500,
detail=result.error.message
)
return {
"success": True,
"message": result.data["message"],
"requested": result.data.get("requested"),
"actual": result.data.get("actual"),
"rebooting": result.data.get("rebooting", False)
}
@router.get("/pi/model", response_model=PiModelResponse)
async def get_pi_model():
"""Get Raspberry Pi model information.
Returns the detected Pi model with recommended CPU core settings.
"""
result = await container.system_service.get_pi_model()
if not result.success:
raise HTTPException(
status_code=500,
detail=result.error.message
)
return PiModelResponse(**result.data)
class BLEStatusResponse(BaseModel):
"""BLE status response model."""
enabled: bool
available: bool
advertising: bool
connected_devices: int
device_name: str
queued_notifications: int
class SendNotificationRequest(BaseModel):
"""Request to send a BLE notification."""
type: str
message: str
data: Optional[Dict] = None
@router.get("/ble/status", response_model=BLEStatusResponse)
async def get_ble_status():
"""Get BLE manager status."""
ble_manager = get_ble_manager()
status = await ble_manager.get_status()
return BLEStatusResponse(**status)
@router.post("/ble/advertising/start")
async def start_ble_advertising():
"""Start BLE advertising."""
ble_manager = get_ble_manager()
if not ble_manager.is_available():
raise HTTPException(status_code=503, detail="BLE not available")
await ble_manager.start_advertising()
return {
"success": True,
"message": "BLE advertising started"
}
@router.post("/ble/advertising/stop")
async def stop_ble_advertising():
"""Stop BLE advertising."""
ble_manager = get_ble_manager()
await ble_manager.stop_advertising()
return {
"success": True,
"message": "BLE advertising stopped"
}
@router.post("/ble/notify")
async def send_ble_notification(request: SendNotificationRequest):
"""Send a BLE notification to connected devices."""
ble_manager = get_ble_manager()
if not ble_manager.is_available():
raise HTTPException(status_code=503, detail="BLE not available")
from ..managers.ble_manager import NotificationType
# Validate notification type
try:
notification_type = NotificationType(request.type)
except ValueError:
raise HTTPException(status_code=400, detail=f"Invalid notification type: {request.type}")
await ble_manager.send_notification(
notification_type=notification_type,
message=request.message,
data=request.data
)
return {
"success": True,
"message": "Notification sent"
}
# -----------------------------------------------------------------------------
# SSL/HTTPS API
# -----------------------------------------------------------------------------
class SSLCertificateInfo(BaseModel):
"""SSL certificate information."""
enabled: bool
certificate_exists: bool
certificate_path: Optional[str] = None
key_path: Optional[str] = None
subject: Optional[str] = None
issuer: Optional[str] = None
not_before: Optional[str] = None
not_after: Optional[str] = None
san: Optional[list[str]] = None # Subject Alternative Names
fingerprint_sha256: Optional[str] = None
class SSLRegenerateRequest(BaseModel):
"""Request to regenerate SSL certificate."""
reload_nginx: bool = True
@router.get("/ssl/info", response_model=SSLCertificateInfo)
async def get_ssl_info():
"""Get SSL certificate information.
Returns details about the current SSL configuration including:
- Whether HTTPS is enabled
- Certificate existence and paths
- Certificate subject, issuer, validity dates
- Subject Alternative Names (SANs)
- SHA256 fingerprint
"""
import os
import subprocess
from .. import config
cert_path = "/opt/dangerous-pi/ssl/dangerous-pi.crt"
key_path = "/opt/dangerous-pi/ssl/dangerous-pi.key"
# Check if HTTPS is enabled and certificate exists
https_enabled = config.HTTPS_ENABLED
cert_exists = os.path.exists(cert_path)
key_exists = os.path.exists(key_path)
if not cert_exists:
return SSLCertificateInfo(
enabled=https_enabled,
certificate_exists=False,
certificate_path=cert_path,
key_path=key_path
)
# Parse certificate details using openssl
cert_info = {
"subject": None,
"issuer": None,
"not_before": None,
"not_after": None,
"san": [],
"fingerprint": None
}
try:
# Get subject
result = subprocess.run(
["openssl", "x509", "-in", cert_path, "-noout", "-subject"],
capture_output=True, text=True, timeout=5
)
if result.returncode == 0:
cert_info["subject"] = result.stdout.strip().replace("subject=", "")
# Get issuer
result = subprocess.run(
["openssl", "x509", "-in", cert_path, "-noout", "-issuer"],
capture_output=True, text=True, timeout=5
)
if result.returncode == 0:
cert_info["issuer"] = result.stdout.strip().replace("issuer=", "")
# Get validity dates
result = subprocess.run(
["openssl", "x509", "-in", cert_path, "-noout", "-dates"],
capture_output=True, text=True, timeout=5
)
if result.returncode == 0:
for line in result.stdout.strip().split("\n"):
if line.startswith("notBefore="):
cert_info["not_before"] = line.replace("notBefore=", "")
elif line.startswith("notAfter="):
cert_info["not_after"] = line.replace("notAfter=", "")
# Get SANs
result = subprocess.run(
["openssl", "x509", "-in", cert_path, "-noout", "-ext", "subjectAltName"],
capture_output=True, text=True, timeout=5
)
if result.returncode == 0 and "subjectAltName" in result.stdout:
# Parse SANs from output like "DNS:localhost, IP:192.168.4.1"
san_line = result.stdout.strip()
for line in san_line.split("\n"):
if "DNS:" in line or "IP:" in line:
# Split by comma and clean up
sans = [s.strip() for s in line.split(",")]
cert_info["san"] = [s for s in sans if s.startswith(("DNS:", "IP:"))]
break
# Get SHA256 fingerprint
result = subprocess.run(
["openssl", "x509", "-in", cert_path, "-noout", "-fingerprint", "-sha256"],
capture_output=True, text=True, timeout=5
)
if result.returncode == 0:
# Format: "sha256 Fingerprint=XX:XX:XX..."
fingerprint = result.stdout.strip()
if "=" in fingerprint:
cert_info["fingerprint"] = fingerprint.split("=", 1)[1]
except subprocess.TimeoutExpired:
pass # Return what we have
except Exception:
pass # Return what we have
return SSLCertificateInfo(
enabled=https_enabled,
certificate_exists=cert_exists and key_exists,
certificate_path=cert_path,
key_path=key_path,
subject=cert_info["subject"],
issuer=cert_info["issuer"],
not_before=cert_info["not_before"],
not_after=cert_info["not_after"],
san=cert_info["san"] if cert_info["san"] else None,
fingerprint_sha256=cert_info["fingerprint"]
)
@router.post("/ssl/regenerate")
async def regenerate_ssl_certificate(request: SSLRegenerateRequest):
"""Regenerate the self-signed SSL certificate.
This will:
1. Generate a new EC P-256 key and self-signed certificate
2. Optionally reload nginx to pick up the new certificate
The new certificate will be valid for 10 years and include SANs for:
- 192.168.4.1 (AP gateway IP)
- dangerous-pi.local (mDNS hostname)
- localhost
Args:
reload_nginx: Whether to reload nginx after regeneration (default: True)
Returns:
Success status and certificate info
"""
import subprocess
import os
script_path = "/opt/dangerous-pi/scripts/generate-ssl-cert.sh"
# Check if script exists
if not os.path.exists(script_path):
raise HTTPException(
status_code=500,
detail="SSL certificate generation script not found"
)
try:
# Run certificate generation with --force flag
result = subprocess.run(
[script_path, "--force"],
capture_output=True,
text=True,
timeout=30
)
if result.returncode != 0:
raise HTTPException(
status_code=500,
detail=f"Certificate generation failed: {result.stderr}"
)
# Reload nginx if requested
nginx_reloaded = False
if request.reload_nginx:
try:
nginx_result = subprocess.run(
["systemctl", "reload", "nginx"],
capture_output=True,
text=True,
timeout=10
)
nginx_reloaded = nginx_result.returncode == 0
except Exception:
pass # Non-fatal, certificate was still generated
return {
"success": True,
"message": "SSL certificate regenerated successfully",
"nginx_reloaded": nginx_reloaded,
"output": result.stdout
}
except subprocess.TimeoutExpired:
raise HTTPException(
status_code=500,
detail="Certificate generation timed out"
)
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Certificate generation error: {str(e)}"
)
# -----------------------------------------------------------------------------
# Header Widgets API
# -----------------------------------------------------------------------------
class WidgetResponse(BaseModel):
"""Header widget response model."""
id: str
source: str
severity: str
message: str
dismissible: bool
icon: Optional[str] = None
action_label: Optional[str] = None
action_url: Optional[str] = None
created_at: str
expires_at: Optional[str] = None
@router.get("/widgets", response_model=list[WidgetResponse])
async def get_header_widgets():
"""Get all active header widgets.
Returns widgets from:
- System managers (UPS, PM3, Updates)
- Enabled plugins
Widgets are sorted by severity (error > warning > info > success).
"""
from ..managers.plugin_manager import get_plugin_manager
plugin_manager = get_plugin_manager()
widgets = plugin_manager.get_active_widgets()
# Sort by severity (error first, then warning, info, success)
severity_order = {"error": 0, "warning": 1, "info": 2, "success": 3}
widgets.sort(key=lambda w: severity_order.get(w.severity.value, 99))
return [
WidgetResponse(
id=w.id,
source=w.source,
severity=w.severity.value,
message=w.message,
dismissible=w.dismissible,
icon=w.icon,
action_label=w.action_label,
action_url=w.action_url,
created_at=w.created_at or "",
expires_at=w.expires_at
)
for w in widgets
]
@router.post("/widgets/{widget_id}/dismiss")
async def dismiss_widget(widget_id: str):
"""Dismiss a header widget.
Dismissed widgets will not reappear until the server restarts
or the widget is explicitly re-registered.
Args:
widget_id: Full widget ID (e.g., "ups.hardware_missing", "plugin.hello_world.status")
Returns:
Success status
"""
from ..managers.plugin_manager import get_plugin_manager
plugin_manager = get_plugin_manager()
success = plugin_manager.dismiss_widget(widget_id)
if not success:
raise HTTPException(
status_code=404,
detail=f"Widget '{widget_id}' not found or not dismissible"
)
return {"success": True, "widget_id": widget_id}
@router.post("/widgets/clear-dismissed")
async def clear_dismissed_widgets():
"""Clear all dismissed widgets, allowing them to reappear.
This is useful if a user wants to see previously dismissed
notifications again.
"""
from ..managers.plugin_manager import get_plugin_manager
plugin_manager = get_plugin_manager()
plugin_manager.clear_dismissed()
return {"success": True, "message": "Dismissed widgets cleared"}