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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user