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

@@ -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