Files
wled-screen-controller-mixed/contexts/auto-update-plan.md
alexei.dolgolyov 382a42755d feat: add auto-update system with release checking, notification UI, and install-type-aware apply
- Abstract ReleaseProvider interface (Gitea impl, swappable for GitHub/GitLab)
- Background UpdateService with periodic checks, debounce, dismissed version persistence
- Install type detection (installer/portable/docker/dev) with platform-aware asset matching
- Download with progress events, silent NSIS reinstall, portable ZIP/tarball swap scripts
- Version badge pulse animation, dismissible banner with icon buttons, Settings > Updates tab
- Single source of truth: pyproject.toml version via importlib.metadata, CI stamps tag with sed
- API: GET/POST status, check, dismiss, apply, GET/PUT settings
- i18n: en, ru, zh (27+ keys each)
2026-03-25 13:16:18 +03:00

5.1 KiB

Auto-Update Plan — Phase 1: Check & Notify

Created: 2026-03-25. Status: planned, not started.

Backend Architecture

Release Provider Abstraction

core/update/
    release_provider.py   — ABC: get_releases(), get_releases_page_url()
    gitea_provider.py     — Gitea REST API implementation
    version_check.py      — normalize_version(), is_newer() using packaging.version
    update_service.py     — Background asyncio task + state machine

ReleaseProvider interface — two methods:

  • get_releases(limit) → list[ReleaseInfo] — fetch releases (newest first)
  • get_releases_page_url() → str — link for "view on web"

GiteaReleaseProvider calls GET {base_url}/api/v1/repos/{repo}/releases. Swapping to GitHub later means implementing the same interface against api.github.com.

Data models:

@dataclass(frozen=True)
class AssetInfo:
    name: str          # "LedGrab-v0.3.0-win-x64.zip"
    size: int          # bytes
    download_url: str

@dataclass(frozen=True)
class ReleaseInfo:
    tag: str           # "v0.3.0"
    version: str       # "0.3.0"
    name: str          # "LedGrab v0.3.0"
    body: str          # release notes markdown
    prerelease: bool
    published_at: str  # ISO 8601
    assets: tuple[AssetInfo, ...]

Version Comparison

version_check.py — normalize Gitea tags to PEP 440:

  • v0.3.0-alpha.10.3.0a1
  • v0.3.0-beta.20.3.0b2
  • v0.3.0-rc.30.3.0rc3

Uses packaging.version.Version for comparison.

Update Service

Follows the AutoBackupEngine pattern:

  • Settings in Database.get_setting("auto_update")
  • asyncio.Task for periodic checks
  • 30s startup delay (avoid slowing boot)
  • 60s debounce on manual checks

State machine (Phase 1): IDLE → CHECKING → UPDATE_AVAILABLE

No download/apply in Phase 1 — just detection and notification.

Settings: enabled (bool), check_interval_hours (float), channel ("stable" | "pre-release")

Persisted state: dismissed_version, last_check (survives restarts)

API Endpoints

Method Path Purpose
GET /api/v1/system/update/status Current state + available version
POST /api/v1/system/update/check Trigger immediate check
POST /api/v1/system/update/dismiss Dismiss notification for current version
GET /api/v1/system/update/settings Get settings
PUT /api/v1/system/update/settings Update settings

Wiring

  • New get_update_service() in dependencies.py
  • UpdateService created in main.py lifespan, start()/stop() alongside other engines
  • Router registered in api/__init__.py
  • WebSocket event: update_available fired via processor_manager.fire_event()

Frontend

Version badge highlight

The existing #server-version pill in the header gets a CSS class has-update when a newer version exists — changes the background to var(--warning-color) with a subtle pulse, making it clickable to open the update panel in settings.

Notification popup

On server:update_available WebSocket event (and on page load if status says has_update and not dismissed):

  • A persistent dismissible banner slides in below the header (not the ephemeral 3s toast)
  • Shows: "Version {x.y.z} is available" + [View Release Notes] + [Dismiss]
  • Dismiss calls POST /dismiss and hides the bar for that version
  • Stored in localStorage so it doesn't re-show after page refresh for dismissed versions

Settings tab: "Updates"

New 5th tab in the settings modal:

  • Current version display
  • "Check for updates" button + spinner
  • Channel selector (stable / pre-release) via IconSelect
  • Auto-check toggle + interval selector
  • When update available: release name, notes preview, link to releases page

i18n keys

New update.* keys in en.json, ru.json, zh.json for all labels.

Files to Create

File Purpose
core/update/__init__.py Package init
core/update/release_provider.py Abstract provider interface + data models
core/update/gitea_provider.py Gitea API implementation
core/update/version_check.py Semver normalization + comparison
core/update/update_service.py Background service + state machine
api/routes/update.py REST endpoints
api/schemas/update.py Pydantic request/response models

Files to Modify

File Change
api/__init__.py Register update router
api/dependencies.py Add get_update_service()
main.py Create & start/stop UpdateService in lifespan
templates/modals/settings.html Add Updates tab
static/js/features/settings.ts Update check/settings UI logic
static/js/core/api.ts Version badge highlight on health check
static/css/layout.css .has-update styles for version badge
static/locales/en.json i18n keys
static/locales/ru.json i18n keys
static/locales/zh.json i18n keys

Future Phases (not in scope)

  • Phase 2: Download & stage artifacts
  • Phase 3: Apply update & restart (external updater script, NSIS silent mode)
  • Phase 4: Checksums, "What's new" dialog, update history