Build optimization: pre-built PM3 binaries, ARM64 CI, base image caching

Replace PM3 compile-from-source in pi-gen with pre-built tarball extraction
(saves 43-58 min). Merge stagePM3 into stageDangerousPi as 02-pm3-install
substage, renumber all subsequent substages. Switch CI PM3 build to native
ARM64 runner (ubuntu-24.04-arm64) eliminating QEMU overhead. Add weekly
base-image workflow for pre-baking stages 0-2. Support PM3_TARBALL,
BASE_IMAGE, and APT_PROXY env vars in build-image.sh.

Also includes prior Phase 5 work: theme system, design system integration,
component update system, OS updates, CI build pipeline, and test results.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
michael
2026-03-04 12:01:01 -08:00
parent 2ec89041ef
commit a9acdb85ce
163 changed files with 8124 additions and 921 deletions

View File

@@ -3,13 +3,17 @@
Refactored to use services for business logic.
Session management uses PM3Service, system operations use SystemService.
"""
import json
from pathlib import Path
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
from typing import Optional, Dict
from typing import Optional, Dict, List
from .. import config
from ..services.container import container
from ..managers.ups_manager import get_ups_manager
from ..managers.ble_manager import get_ble_manager
from ..managers.os_update_manager import get_os_update_manager
router = APIRouter()
@@ -676,14 +680,14 @@ async def get_ssl_info():
["openssl", "x509", "-in", cert_path, "-noout", "-ext", "subjectAltName"],
capture_output=True, text=True, timeout=5
)
if result.returncode == 0 and "subjectAltName" in result.stdout:
if result.returncode == 0 and ("subjectAltName" in result.stdout or "Subject Alternative Name" 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:
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:"))]
cert_info["san"] = [s for s in sans if s.startswith(("DNS:", "IP"))]
break
# Get SHA256 fingerprint
@@ -738,10 +742,19 @@ async def regenerate_ssl_certificate(request: SSLRegenerateRequest):
import subprocess
import os
script_path = "/opt/dangerous-pi/scripts/generate-ssl-cert.sh"
# Check multiple possible locations for the script
script_candidates = [
"/opt/dangerous-pi/scripts/generate-ssl-cert.sh",
os.path.expanduser("~/dangerous-pi/scripts/generate-ssl-cert.sh"),
os.path.join(os.path.dirname(__file__), "../../../scripts/generate-ssl-cert.sh"),
]
script_path = None
for candidate in script_candidates:
if os.path.exists(candidate):
script_path = candidate
break
# Check if script exists
if not os.path.exists(script_path):
if not script_path:
raise HTTPException(
status_code=500,
detail="SSL certificate generation script not found"
@@ -975,4 +988,217 @@ async def clear_dismissed_widgets():
plugin_manager = get_plugin_manager()
plugin_manager.clear_dismissed()
# ---------------------------------------------------------------------------
# OS Updates API
# ---------------------------------------------------------------------------
@router.get("/os/info")
async def get_os_info():
"""Get OS-level system information.
Returns Debian version, kernel, architecture, uptime, hostname,
last apt update timestamp, auto-update setting, and reboot-required status.
"""
manager = get_os_update_manager()
return await manager.get_os_info()
@router.get("/os/updates")
async def get_os_updates(refresh: bool = False):
"""Get available OS package updates.
Returns a list of upgradable packages with current and available versions.
Results are cached for 1 hour unless refresh=True.
"""
manager = get_os_update_manager()
packages = await manager.check_available_updates(force_refresh=refresh)
return {
"count": len(packages),
"packages": packages,
"upgrading": manager.is_upgrading,
}
class OsUpgradeRequest(BaseModel):
"""Request to trigger an OS package upgrade."""
security_only: bool = False
@router.post("/os/update")
async def run_os_update(request: OsUpgradeRequest):
"""Trigger an OS package upgrade.
Runs `apt-get upgrade -y` (or unattended-upgrade --verbose for security-only).
Only one upgrade can run at a time.
Args:
security_only: If True, only install security updates
"""
manager = get_os_update_manager()
result = await manager.run_upgrade(security_only=request.security_only)
if not result.get("success"):
raise HTTPException(status_code=409 if "already in progress" in result.get("error", "") else 500,
detail=result.get("error", "Upgrade failed"))
return result
class AutoUpdatesRequest(BaseModel):
"""Request to toggle automatic security updates."""
enabled: bool
@router.post("/os/auto-updates")
async def toggle_auto_updates(request: AutoUpdatesRequest):
"""Toggle automatic security updates.
Writes AUTO_SECURITY_UPDATES to the .env file and restarts the
systemd timer that controls unattended-upgrades.
Args:
enabled: True to enable, False to disable automatic security updates
"""
manager = get_os_update_manager()
result = await manager.toggle_auto_updates(request.enabled)
if not result.get("success"):
raise HTTPException(status_code=500, detail=result.get("error", "Failed to toggle auto-updates"))
return result
# ---------------------------------------------------------------------------
# Theme registry
# ---------------------------------------------------------------------------
class ThemeDefinitionResponse(BaseModel):
"""A single theme definition."""
id: str
name: str
description: str = ""
supportsModes: List[str] = ["dark", "light", "auto"]
defaultMode: str = "dark"
author: str = ""
css_url: str = ""
source: str = "builtin" # "builtin" or "plugin"
class ThemeRegistryResponse(BaseModel):
"""Response for GET /api/system/themes."""
themes: List[ThemeDefinitionResponse]
def _scan_theme_dirs() -> List[ThemeDefinitionResponse]:
"""Scan themes directories for installed theme packages."""
themes_found: List[ThemeDefinitionResponse] = []
# Search paths: dev + production
themes_dirs = [
Path(__file__).parent.parent.parent / "frontend" / "themes", # dev
Path("/opt/dangerous-pi/app/frontend/themes"), # prod
]
seen_ids: set = set()
for themes_dir in themes_dirs:
if not themes_dir.is_dir():
continue
for entry in sorted(themes_dir.iterdir()):
if not entry.is_dir() or entry.name.startswith("."):
continue
theme_json = entry / "theme.json"
if not theme_json.exists():
continue
try:
meta = json.loads(theme_json.read_text())
theme_id = meta.get("id", entry.name)
if theme_id in seen_ids:
continue
seen_ids.add(theme_id)
themes_found.append(ThemeDefinitionResponse(
id=theme_id,
name=meta.get("name", theme_id),
description=meta.get("description", ""),
supportsModes=meta.get("supportsModes", ["dark", "light", "auto"]),
defaultMode=meta.get("defaultMode", "dark"),
author=meta.get("author", ""),
css_url=f"/themes/{theme_id}/tokens.css",
source="builtin",
))
except (json.JSONDecodeError, OSError) as exc:
print(f"Warning: bad theme.json in {entry}: {exc}")
return themes_found
def _scan_plugin_themes() -> List[ThemeDefinitionResponse]:
"""Collect themes registered by plugins via the theme_register hook."""
from ..managers.plugin_manager import get_plugin_manager
pm = get_plugin_manager()
themes: List[ThemeDefinitionResponse] = []
if "theme_register" not in pm._hooks:
return themes
import asyncio
results = []
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
# Hooks are called synchronously here since they are simple data returns
for callback in pm._hooks.get("theme_register", []):
try:
if asyncio.iscoroutinefunction(callback):
# Schedule in running loop if available
if loop:
import concurrent.futures
# Can't await in sync context; skip async hooks
continue
else:
continue
result = callback()
if result:
results.append(result)
except Exception as exc:
print(f"Warning: theme_register hook error: {exc}")
for r in results:
themes.append(ThemeDefinitionResponse(
id=r.get("id", "unknown"),
name=r.get("name", "Unknown Theme"),
description=r.get("description", ""),
supportsModes=r.get("supportsModes", ["dark", "light", "auto"]),
defaultMode=r.get("defaultMode", "dark"),
author=r.get("author", ""),
css_url=r.get("css_url", f"/themes/{r.get('id', 'unknown')}/tokens.css"),
source="plugin",
))
return themes
@router.get("/themes", response_model=ThemeRegistryResponse)
async def get_available_themes():
"""Get available themes from disk and plugin registry.
Scans the themes/ directory for installed theme packages
and collects themes registered by plugins via the theme_register hook.
"""
builtin = _scan_theme_dirs()
plugin_themes = _scan_plugin_themes()
# Merge, preferring builtin for duplicate IDs
seen = {t.id for t in builtin}
all_themes = list(builtin)
for pt in plugin_themes:
if pt.id not in seen:
all_themes.append(pt)
seen.add(pt.id)
return ThemeRegistryResponse(themes=all_themes)
return {"success": True, "message": "Dismissed widgets cleared"}

View File

@@ -2,9 +2,11 @@
Refactored to use UpdateService for all business logic.
Endpoints are now thin adapters that convert HTTP requests/responses.
Supports both legacy whole-system operations and per-component operations.
"""
from typing import Optional
from fastapi import APIRouter, HTTPException
from typing import Optional, List
from fastapi import APIRouter, HTTPException, Path
from pydantic import BaseModel
from ..services.container import container
@@ -14,6 +16,31 @@ from ..managers.ble_manager import get_ble_manager, NotificationType
router = APIRouter()
# ---------------------------------------------------------------------------
# Response / request models
# ---------------------------------------------------------------------------
class ComponentUpdateInfo(BaseModel):
"""Per-component update information."""
component_id: str
current_version: Optional[str] = None
available_version: str
changelog: str = ""
download_size: Optional[int] = None
compatible: bool = True
incompatible_reason: Optional[str] = None
class PluginUpdateInfo(BaseModel):
"""Per-plugin update information."""
plugin_id: str
current_version: str
available_version: str
changelog: str = ""
download_size: Optional[int] = None
source_repo: str = ""
class UpdateCheckResponse(BaseModel):
"""Response model for update check."""
update_available: bool
@@ -24,6 +51,8 @@ class UpdateCheckResponse(BaseModel):
is_prerelease: bool = False
download_size: Optional[int] = None
message: Optional[str] = None
components: List[ComponentUpdateInfo] = []
plugins: List[PluginUpdateInfo] = []
class UpdateProgressResponse(BaseModel):
@@ -34,6 +63,8 @@ class UpdateProgressResponse(BaseModel):
download_progress: float = 0.0
error_message: Optional[str] = None
last_check: Optional[str] = None
active_component: Optional[str] = None
components: List[dict] = []
class ReleaseNotesRequest(BaseModel):
@@ -41,15 +72,17 @@ class ReleaseNotesRequest(BaseModel):
version: Optional[str] = None
class ComponentDownloadRequest(BaseModel):
"""Optional request body for selective download."""
components: Optional[List[str]] = None
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _service_error_to_http_status(error_code: str) -> int:
"""Map service error codes to HTTP status codes.
Args:
error_code: Service error code
Returns:
HTTP status code
"""
"""Map service error codes to HTTP status codes."""
codes = {
"update_check_error": 500,
"no_update_available": 400,
@@ -62,35 +95,44 @@ def _service_error_to_http_status(error_code: str) -> int:
"release_notes_error": 500,
"check_download_error": 500,
"full_update_error": 500,
"manifest_error": 500,
"component_not_available": 400,
"component_download_error": 500,
"component_install_error": 500,
"rollback_error": 500,
}
return codes.get(error_code, 500)
@router.get("/check", response_model=UpdateCheckResponse)
async def check_for_updates():
"""Check for available updates.
Uses UpdateService for business logic.
"""
result = await container.update_service.check_for_updates()
def _raise_on_error(result):
"""Raise HTTPException if result indicates failure."""
if not result.success:
raise HTTPException(
status_code=_service_error_to_http_status(result.error.code),
detail=result.error.message
detail=result.error.message,
)
# Send BLE notification if update is available
# ---------------------------------------------------------------------------
# Legacy endpoints (backward-compatible)
# ---------------------------------------------------------------------------
@router.get("/check", response_model=UpdateCheckResponse)
async def check_for_updates():
"""Check for available updates (components + plugins)."""
result = await container.update_service.check_for_updates()
_raise_on_error(result)
# BLE notification
if result.data.get("update_available"):
try:
ble_manager = get_ble_manager()
await ble_manager.send_notification(
NotificationType.UPDATE_AVAILABLE,
f"Update available: v{result.data['latest_version']}",
{"version": result.data["latest_version"]}
{"version": result.data["latest_version"]},
)
except Exception:
# BLE notification failure shouldn't affect the response
pass
return UpdateCheckResponse(**result.data)
@@ -98,108 +140,126 @@ async def check_for_updates():
@router.get("/progress", response_model=UpdateProgressResponse)
async def get_update_progress():
"""Get current update progress.
Uses UpdateService for business logic.
"""
"""Get current update progress."""
result = await container.update_service.get_progress()
if not result.success:
raise HTTPException(
status_code=_service_error_to_http_status(result.error.code),
detail=result.error.message
)
_raise_on_error(result)
return UpdateProgressResponse(**result.data)
@router.post("/download")
async def download_update():
"""Download the available update.
async def download_update(body: Optional[ComponentDownloadRequest] = None):
"""Download available updates.
Uses UpdateService for business logic.
Without a body, downloads all available components (legacy behavior).
With ``{"components": ["frontend"]}``, downloads only the specified ones.
"""
if body and body.components:
results = {}
for comp_id in body.components:
r = await container.update_service.download_component(comp_id)
_raise_on_error(r)
results[comp_id] = r.data
return {"message": "Components downloaded", "components": results}
result = await container.update_service.download_update()
if not result.success:
raise HTTPException(
status_code=_service_error_to_http_status(result.error.code),
detail=result.error.message
)
_raise_on_error(result)
return {"message": result.data["message"]}
@router.post("/install")
async def install_update():
"""Install the downloaded update.
async def install_update(body: Optional[ComponentDownloadRequest] = None):
"""Install downloaded updates.
Uses UpdateService for business logic.
Without a body, installs all downloaded components (legacy behavior).
With ``{"components": ["frontend"]}``, installs only the specified ones.
"""
if body and body.components:
results = {}
for comp_id in body.components:
r = await container.update_service.install_component(comp_id)
_raise_on_error(r)
results[comp_id] = r.data
return {"message": "Components installed", "components": results}
result = await container.update_service.install_update()
_raise_on_error(result)
if not result.success:
raise HTTPException(
status_code=_service_error_to_http_status(result.error.code),
detail=result.error.message
)
# Send BLE notification
# BLE notification
try:
ble_manager = get_ble_manager()
await ble_manager.send_notification(
NotificationType.UPDATE_COMPLETE,
"Update installed successfully",
{"restart_required": True}
{"restart_required": True},
)
except Exception:
# BLE notification failure shouldn't affect the response
pass
return {
"message": result.data["message"],
"restart_required": result.data.get("requires_restart", True)
"restart_required": result.data.get("requires_restart", True),
}
@router.post("/release-notes", response_model=dict)
async def get_release_notes(request: ReleaseNotesRequest):
"""Get release notes for a specific version.
Uses UpdateService for business logic.
Args:
request: Version to get notes for (latest if not specified)
"""
"""Get release notes for a specific version."""
result = await container.update_service.get_release_notes(request.version)
if not result.success:
raise HTTPException(
status_code=_service_error_to_http_status(result.error.code),
detail=result.error.message
)
_raise_on_error(result)
return {
"version": result.data["version"],
"notes": result.data["release_notes"]
"notes": result.data["release_notes"],
}
@router.get("/current-version")
async def get_current_version():
"""Get current system version.
Uses UpdateService for business logic.
"""
"""Get current system version."""
result = await container.update_service.get_progress()
if not result.success:
raise HTTPException(
status_code=_service_error_to_http_status(result.error.code),
detail=result.error.message
)
_raise_on_error(result)
return {
"version": result.data["current_version"],
"last_check": result.data["last_check"]
"last_check": result.data["last_check"],
}
# ---------------------------------------------------------------------------
# Component-level endpoints
# ---------------------------------------------------------------------------
@router.get("/components")
async def get_installed_components():
"""Get installed component manifest."""
result = await container.update_service.get_installed_components()
_raise_on_error(result)
return result.data
@router.post("/components/{component_id}/download")
async def download_component(
component_id: str = Path(description="Component ID (pm3, frontend, backend, theme)"),
):
"""Download a single component update."""
result = await container.update_service.download_component(component_id)
_raise_on_error(result)
return result.data
@router.post("/components/{component_id}/install")
async def install_component(
component_id: str = Path(description="Component ID"),
):
"""Install a single downloaded component."""
result = await container.update_service.install_component(component_id)
_raise_on_error(result)
return result.data
@router.post("/components/{component_id}/rollback")
async def rollback_component(
component_id: str = Path(description="Component ID"),
):
"""Rollback a component to its backup."""
result = await container.update_service.rollback_component(component_id)
_raise_on_error(result)
return result.data

View File

@@ -30,6 +30,9 @@ VERSION = os.getenv("VERSION", "0.1.0")
# Update settings
GITHUB_REPO = os.getenv("GITHUB_REPO", "dangerous-tacos/dangerous-pi")
UPDATE_CHECK_INTERVAL = int(os.getenv("UPDATE_CHECK_INTERVAL", "3600")) # 1 hour
COMPONENT_MANIFEST_PATH = os.getenv(
"COMPONENT_MANIFEST_PATH", "/opt/dangerous-pi/component-manifest.json"
)
# Wi-Fi settings
WLAN_INTERFACE = os.getenv("WLAN_INTERFACE", "wlan0")

View File

@@ -204,6 +204,17 @@ app.include_router(plugins.router, prefix="/api/plugins", tags=["plugins"], depe
app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
app.include_router(ws_router, prefix="/ws", tags=["websocket"])
# Serve theme token CSS from themes/ directory
theme_paths = [
Path(__file__).parent.parent / "frontend" / "themes", # Development
Path("/opt/dangerous-pi/app/frontend/themes"), # Production
]
for tp in theme_paths:
if tp.exists() and tp.is_dir():
app.mount("/themes", StaticFiles(directory=str(tp)), name="themes")
print(f"🎨 Serving themes from: {tp}")
break
# Serve frontend static files if build directory exists
# In production, frontend is built and served from /opt/dangerous-pi/app/frontend/build
# Priority: 1) check relative to working directory, 2) check absolute production path

View File

@@ -210,7 +210,7 @@ class BLEManager:
}
async def _check_bluetooth_adapter(self) -> bool:
"""Check if Bluetooth adapter is available.
"""Check if Bluetooth adapter is available, unblocking and powering on if needed.
Returns:
True if adapter is available, False otherwise
@@ -224,8 +224,20 @@ class BLEManager:
)
stdout, stderr = await process.communicate()
# If we get output with "Controller", we have an adapter
return b"Controller" in stdout
if b"Controller" not in stdout:
return False
# Unblock Bluetooth if soft-blocked by rfkill
await self._run_cmd("rfkill", "unblock", "bluetooth")
# Power on the adapter via bluetoothctl
out = await self._run_cmd("bluetoothctl", "power", "on")
if b"succeeded" in out.lower() or b"yes" in out.lower():
logger.info("Bluetooth adapter powered on")
else:
logger.warning("Bluetooth power on response: %s", out.decode(errors='replace').strip())
return True
except FileNotFoundError:
logger.warning("bluetoothctl not found - BlueZ not installed")
@@ -234,6 +246,19 @@ class BLEManager:
logger.error("Error checking Bluetooth adapter: %s", e)
return False
async def _run_cmd(self, *args: str) -> bytes:
"""Run a command and return stdout."""
try:
process = await asyncio.create_subprocess_exec(
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, _ = await process.communicate()
return stdout
except FileNotFoundError:
return b""
async def _set_device_name(self, name: str):
"""Set the Bluetooth device name.

View File

@@ -0,0 +1,312 @@
"""OS Update Manager for Dangerous Pi.
Provides visibility into OS-level package updates and allows triggering
apt upgrades from the web UI. Works alongside unattended-upgrades which
handles automatic security patches.
"""
import asyncio
import os
import platform
import re
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional, List, Dict, Any
from .. import config
@dataclass
class OsPackageUpdate:
"""A single upgradable OS package."""
name: str
current_version: str
available_version: str
architecture: str = ""
origin: str = ""
@dataclass
class OsInfo:
"""OS-level system information."""
debian_version: str = ""
debian_codename: str = ""
kernel: str = ""
architecture: str = ""
uptime_seconds: float = 0
hostname: str = ""
last_apt_update: Optional[str] = None
auto_security_updates: bool = True
reboot_required: bool = False
class OsUpdateManager:
"""Manages OS-level package updates."""
def __init__(self):
self._cache: List[OsPackageUpdate] = []
self._cache_time: float = 0
self._cache_ttl: float = 3600 # 1 hour
self._upgrading: bool = False
self._upgrade_output: List[str] = []
self._env_path = Path(os.getenv("ENV_FILE", "/opt/dangerous-pi/.env"))
async def get_os_info(self) -> Dict[str, Any]:
"""Get OS-level system information."""
info = OsInfo()
# Debian version
try:
os_release = Path("/etc/os-release")
if os_release.exists():
content = os_release.read_text()
for line in content.splitlines():
if line.startswith("VERSION_ID="):
info.debian_version = line.split("=", 1)[1].strip('"')
elif line.startswith("VERSION_CODENAME="):
info.debian_codename = line.split("=", 1)[1].strip('"')
except Exception:
pass
# Kernel
info.kernel = platform.release()
# Architecture
info.architecture = platform.machine()
# Uptime
try:
uptime_path = Path("/proc/uptime")
if uptime_path.exists():
info.uptime_seconds = float(uptime_path.read_text().split()[0])
except Exception:
pass
# Hostname
info.hostname = platform.node()
# Last apt update (mtime of apt lists directory)
try:
apt_lists = Path("/var/lib/apt/lists")
if apt_lists.exists():
mtime = apt_lists.stat().st_mtime
from datetime import datetime, timezone
info.last_apt_update = datetime.fromtimestamp(
mtime, tz=timezone.utc
).isoformat()
except Exception:
pass
# Auto security updates setting
info.auto_security_updates = self._read_auto_updates_setting()
# Reboot required
info.reboot_required = Path("/var/run/reboot-required").exists()
return {
"debian_version": info.debian_version,
"debian_codename": info.debian_codename,
"kernel": info.kernel,
"architecture": info.architecture,
"uptime_seconds": info.uptime_seconds,
"hostname": info.hostname,
"last_apt_update": info.last_apt_update,
"auto_security_updates": info.auto_security_updates,
"reboot_required": info.reboot_required,
}
async def check_available_updates(self, force_refresh: bool = False) -> List[Dict[str, str]]:
"""Check for available OS package updates.
Returns cached results unless force_refresh=True or cache has expired.
"""
now = time.monotonic()
if not force_refresh and self._cache and (now - self._cache_time) < self._cache_ttl:
return [self._package_to_dict(p) for p in self._cache]
try:
proc = await asyncio.create_subprocess_exec(
"apt", "list", "--upgradable", "-qq",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=60)
output = stdout.decode(errors="replace").strip()
packages = []
for line in output.splitlines():
line = line.strip()
if not line or line.startswith("Listing"):
continue
pkg = self._parse_apt_line(line)
if pkg:
packages.append(pkg)
self._cache = packages
self._cache_time = now
except (asyncio.TimeoutError, Exception) as e:
# Return stale cache on error rather than failing
if not self._cache:
return [{"name": "error", "current_version": "", "available_version": str(e), "architecture": "", "origin": ""}]
return [self._package_to_dict(p) for p in self._cache]
async def run_upgrade(self, security_only: bool = False) -> Dict[str, Any]:
"""Trigger an apt upgrade.
Returns the result after completion. Only one upgrade can run at a time.
Uses create_subprocess_exec (not shell) to avoid injection risks.
"""
if self._upgrading:
return {"success": False, "error": "An upgrade is already in progress"}
self._upgrading = True
self._upgrade_output = []
try:
if security_only:
cmd = ["sudo", "unattended-upgrade", "--verbose"]
else:
cmd = ["sudo", "apt-get", "upgrade", "-y"]
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
env={**os.environ, "DEBIAN_FRONTEND": "noninteractive"},
)
while True:
line = await proc.stdout.readline()
if not line:
break
decoded = line.decode(errors="replace").rstrip()
self._upgrade_output.append(decoded)
await proc.wait()
# Invalidate cache after upgrade
self._cache = []
self._cache_time = 0
success = proc.returncode == 0
return {
"success": success,
"return_code": proc.returncode,
"output": self._upgrade_output,
"reboot_required": Path("/var/run/reboot-required").exists(),
}
except Exception as e:
return {"success": False, "error": str(e), "output": self._upgrade_output}
finally:
self._upgrading = False
async def toggle_auto_updates(self, enabled: bool) -> Dict[str, Any]:
"""Toggle automatic security updates by writing to .env and restarting the timer."""
try:
self._write_auto_updates_setting(enabled)
# Restart the toggle service to apply
proc = await asyncio.create_subprocess_exec(
"sudo", "systemctl", "restart", "dangerous-pi-auto-updates.service",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
await asyncio.wait_for(proc.communicate(), timeout=30)
return {
"success": True,
"auto_security_updates": enabled,
}
except Exception as e:
return {"success": False, "error": str(e)}
@property
def is_upgrading(self) -> bool:
return self._upgrading
@property
def upgrade_output(self) -> List[str]:
return list(self._upgrade_output)
# -----------------------------------------------------------------------
# Internal helpers
# -----------------------------------------------------------------------
def _parse_apt_line(self, line: str) -> Optional[OsPackageUpdate]:
"""Parse a line from `apt list --upgradable -qq`.
Format: package/origin version arch [upgradable from: old_version]
"""
match = re.match(
r"^(\S+?)(?:/(\S+))?\s+(\S+)\s+(\S+)\s+\[upgradable from:\s+(\S+)\]",
line,
)
if match:
return OsPackageUpdate(
name=match.group(1),
origin=match.group(2) or "",
available_version=match.group(3),
architecture=match.group(4),
current_version=match.group(5),
)
return None
def _package_to_dict(self, pkg: OsPackageUpdate) -> Dict[str, str]:
return {
"name": pkg.name,
"current_version": pkg.current_version,
"available_version": pkg.available_version,
"architecture": pkg.architecture,
"origin": pkg.origin,
}
def _read_auto_updates_setting(self) -> bool:
"""Read AUTO_SECURITY_UPDATES from .env file."""
try:
if self._env_path.exists():
for line in self._env_path.read_text().splitlines():
line = line.strip()
if line.startswith("AUTO_SECURITY_UPDATES="):
val = line.split("=", 1)[1].strip().lower()
return val != "false"
except Exception:
pass
return True # Default: enabled
def _write_auto_updates_setting(self, enabled: bool):
"""Write AUTO_SECURITY_UPDATES to .env file."""
value = "true" if enabled else "false"
env_line = f"AUTO_SECURITY_UPDATES={value}"
if not self._env_path.exists():
self._env_path.write_text(env_line + "\n")
return
lines = self._env_path.read_text().splitlines()
found = False
for i, line in enumerate(lines):
if line.strip().startswith("AUTO_SECURITY_UPDATES="):
lines[i] = env_line
found = True
break
if not found:
lines.append(env_line)
self._env_path.write_text("\n".join(lines) + "\n")
# Singleton
_os_update_manager: Optional[OsUpdateManager] = None
def get_os_update_manager() -> OsUpdateManager:
"""Get or create the OS update manager singleton."""
global _os_update_manager
if _os_update_manager is None:
_os_update_manager = OsUpdateManager()
return _os_update_manager

View File

@@ -8,6 +8,7 @@ import asyncio
import importlib.util
import inspect
import json
import re
import time
from dataclasses import dataclass, asdict, field
from datetime import datetime, timezone
@@ -16,6 +17,8 @@ from pathlib import Path
from typing import Optional, Dict, Any, List, Callable, Set
import sys
import aiohttp
from .. import config
@@ -400,6 +403,18 @@ class PluginBase:
)
@dataclass
class PluginUpdateInfo:
"""Information about an available plugin update."""
plugin_id: str
current_version: str
available_version: str
changelog: str = ""
download_url: str = ""
download_size: Optional[int] = None
source_repo: str = ""
class PluginManager:
"""Manages plugin loading, enabling, lifecycle, and header widgets."""
@@ -790,6 +805,121 @@ class PluginManager:
del self._header_widgets[widget_id]
print(f"Expired widget removed: {widget_id}")
# -------------------------------------------------------------------------
# Plugin Update Checking
# -------------------------------------------------------------------------
@staticmethod
def _extract_github_repo(homepage: Optional[str]) -> Optional[str]:
"""Extract 'owner/repo' from a GitHub URL.
Args:
homepage: Plugin homepage URL
Returns:
'owner/repo' string or None
"""
if not homepage:
return None
m = re.match(r"https?://github\.com/([^/]+/[^/]+?)(?:\.git)?/?$", homepage)
return m.group(1) if m else None
async def check_plugin_updates(self) -> List[PluginUpdateInfo]:
"""Check all installed plugins for available updates.
Queries each plugin's GitHub repo (derived from homepage) for
newer releases. Skips plugins without a GitHub homepage.
Returns:
List of PluginUpdateInfo for plugins with updates available
"""
updates: List[PluginUpdateInfo] = []
plugins_to_check = []
for plugin_id, info in self._plugins.items():
repo = self._extract_github_repo(info.metadata.homepage)
if repo:
plugins_to_check.append((plugin_id, info, repo))
if not plugins_to_check:
return updates
async with aiohttp.ClientSession() as session:
for plugin_id, info, repo in plugins_to_check:
try:
update = await self._check_single_plugin_update(
session, plugin_id, info, repo
)
if update:
updates.append(update)
except Exception as e:
print(f"Error checking updates for plugin {plugin_id}: {e}")
return updates
async def _check_single_plugin_update(
self,
session: aiohttp.ClientSession,
plugin_id: str,
info: PluginInfo,
repo: str,
) -> Optional[PluginUpdateInfo]:
"""Check a single plugin for available update.
Args:
session: aiohttp session
plugin_id: Plugin identifier
info: Current plugin info
repo: GitHub 'owner/repo' string
Returns:
PluginUpdateInfo if update available, None otherwise
"""
url = f"https://api.github.com/repos/{repo}/releases/latest"
async with session.get(url) as resp:
if resp.status != 200:
return None
data = await resp.json()
latest_tag = data.get("tag_name", "").lstrip("v")
current_ver = info.metadata.version
if not self._is_newer_plugin_version(latest_tag, current_ver):
return None
# Find a suitable download asset
download_url = ""
download_size = None
for asset in data.get("assets", []):
if asset["name"].endswith((".tar.gz", ".zip")):
download_url = asset["browser_download_url"]
download_size = asset.get("size")
break
# Fall back to source tarball
if not download_url:
download_url = data.get("tarball_url", "")
return PluginUpdateInfo(
plugin_id=plugin_id,
current_version=current_ver,
available_version=latest_tag,
changelog=data.get("body", ""),
download_url=download_url,
download_size=download_size,
source_repo=repo,
)
@staticmethod
def _is_newer_plugin_version(latest: str, current: str) -> bool:
"""Compare two version strings, returning True if latest > current."""
def parse(v: str) -> tuple:
return tuple(int(x) for x in re.split(r'[-+]', v)[0].split('.'))
try:
return parse(latest) > parse(current)
except (ValueError, AttributeError):
return latest != current
# Global plugin manager instance
_plugin_manager: Optional[PluginManager] = None

File diff suppressed because it is too large Load Diff

View File

@@ -3,9 +3,12 @@
This service provides software update operations that can be consumed by
multiple interfaces (REST API, BLE GATT, etc.).
"""
from typing import Optional, Dict, Any
from dataclasses import asdict
from typing import Optional, Dict, Any, List
from ..managers.update_manager import UpdateManager, UpdateProgress, UpdateStatus
from ..managers.update_manager import (
UpdateManager, UpdateProgress, UpdateStatus, ComponentId,
)
from .pm3_service import PM3ServiceError, PM3ServiceResult
@@ -156,6 +159,15 @@ class UpdateService:
try:
progress = await self.update_manager.get_progress()
comp_list = []
for comp_id, cp in progress.component_progress.items():
comp_list.append({
"component_id": cp.component_id,
"status": cp.status.value,
"download_progress": cp.download_progress,
"error_message": cp.error_message,
})
return PM3ServiceResult(
success=True,
data={
@@ -164,7 +176,9 @@ class UpdateService:
"available_version": progress.available_version,
"download_progress": progress.download_progress,
"error_message": progress.error_message,
"last_check": progress.last_check
"last_check": progress.last_check,
"active_component": progress.active_component,
"components": comp_list,
}
)
@@ -310,3 +324,151 @@ class UpdateService:
details=str(e)
)
)
# ------------------------------------------------------------------
# Component-level operations
# ------------------------------------------------------------------
async def get_installed_components(self) -> PM3ServiceResult:
"""Get the installed component manifest.
Returns:
PM3ServiceResult with component manifest data
"""
try:
manifest = self.update_manager.get_manifest()
components = {}
for comp_id, comp in manifest.components.items():
components[comp_id] = asdict(comp)
return PM3ServiceResult(
success=True,
data={
"schema_version": manifest.schema_version,
"system_version": manifest.system_version,
"components": components,
"plugins": manifest.plugins,
"last_updated": manifest.last_updated,
}
)
except Exception as e:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="manifest_error",
message="Failed to get component manifest",
details=str(e)
)
)
async def download_component(self, component_id: str) -> PM3ServiceResult:
"""Download a specific component update.
Args:
component_id: Component to download (pm3, frontend, backend, theme)
Returns:
PM3ServiceResult indicating download success/failure
"""
try:
success = await self.update_manager.download_component(component_id)
return PM3ServiceResult(
success=True,
data={
"message": f"Component {component_id} downloaded successfully",
"component_id": component_id,
"ready_to_install": True,
}
)
except ValueError as e:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="component_not_available",
message=str(e)
)
)
except Exception as e:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="component_download_error",
message=f"Error downloading {component_id}",
details=str(e)
)
)
async def install_component(self, component_id: str) -> PM3ServiceResult:
"""Install a downloaded component update.
Args:
component_id: Component to install
Returns:
PM3ServiceResult indicating install success/failure
"""
try:
success = await self.update_manager.install_component(component_id)
return PM3ServiceResult(
success=True,
data={
"message": f"Component {component_id} installed successfully",
"component_id": component_id,
"requires_restart": component_id in (
ComponentId.BACKEND, ComponentId.PM3
),
}
)
except ValueError as e:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="component_install_error",
message=str(e)
)
)
except Exception as e:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="component_install_error",
message=f"Error installing {component_id}",
details=str(e)
)
)
async def rollback_component(self, component_id: str) -> PM3ServiceResult:
"""Rollback a component to its previous version.
Args:
component_id: Component to rollback
Returns:
PM3ServiceResult indicating rollback success/failure
"""
try:
success = await self.update_manager.rollback_component(component_id)
return PM3ServiceResult(
success=True,
data={
"message": f"Component {component_id} rolled back successfully",
"component_id": component_id,
}
)
except ValueError as e:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="rollback_error",
message=str(e)
)
)
except Exception as e:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="rollback_error",
message=f"Error rolling back {component_id}",
details=str(e)
)
)