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

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