Files
pi-pm3/app/backend/api/updates.py
michael a9acdb85ce 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>
2026-03-04 12:01:01 -08:00

266 lines
8.4 KiB
Python

"""Update management API endpoints.
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, List
from fastapi import APIRouter, HTTPException, Path
from pydantic import BaseModel
from ..services.container import container
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
current_version: str
latest_version: Optional[str] = None
release_date: Optional[str] = None
changelog: Optional[str] = None
is_prerelease: bool = False
download_size: Optional[int] = None
message: Optional[str] = None
components: List[ComponentUpdateInfo] = []
plugins: List[PluginUpdateInfo] = []
class UpdateProgressResponse(BaseModel):
"""Response model for update progress."""
status: str
current_version: str
available_version: Optional[str] = None
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):
"""Request model for release notes."""
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."""
codes = {
"update_check_error": 500,
"no_update_available": 400,
"download_failed": 500,
"update_download_error": 500,
"no_update_downloaded": 400,
"installation_failed": 500,
"update_install_error": 500,
"progress_error": 500,
"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)
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,
)
# ---------------------------------------------------------------------------
# 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"]},
)
except Exception:
pass
return UpdateCheckResponse(**result.data)
@router.get("/progress", response_model=UpdateProgressResponse)
async def get_update_progress():
"""Get current update progress."""
result = await container.update_service.get_progress()
_raise_on_error(result)
return UpdateProgressResponse(**result.data)
@router.post("/download")
async def download_update(body: Optional[ComponentDownloadRequest] = None):
"""Download available updates.
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()
_raise_on_error(result)
return {"message": result.data["message"]}
@router.post("/install")
async def install_update(body: Optional[ComponentDownloadRequest] = None):
"""Install downloaded updates.
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)
# BLE notification
try:
ble_manager = get_ble_manager()
await ble_manager.send_notification(
NotificationType.UPDATE_COMPLETE,
"Update installed successfully",
{"restart_required": True},
)
except Exception:
pass
return {
"message": result.data["message"],
"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."""
result = await container.update_service.get_release_notes(request.version)
_raise_on_error(result)
return {
"version": result.data["version"],
"notes": result.data["release_notes"],
}
@router.get("/current-version")
async def get_current_version():
"""Get current system version."""
result = await container.update_service.get_progress()
_raise_on_error(result)
return {
"version": result.data["current_version"],
"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