diff --git a/manage.sh b/manage.sh index ba16b92..e931049 100755 --- a/manage.sh +++ b/manage.sh @@ -372,10 +372,31 @@ EOF mkdir -p /etc/sudoers.d cat > /etc/sudoers.d/pymc-repeater <<'EOF' # Allow repeater user to manage the pymc-repeater service without password -repeater ALL=(root) NOPASSWD: /usr/bin/systemctl restart pymc-repeater, /usr/bin/systemctl stop pymc-repeater, /usr/bin/systemctl start pymc-repeater, /usr/bin/systemctl status pymc-repeater +repeater ALL=(root) NOPASSWD: /usr/bin/systemctl restart pymc-repeater, /usr/bin/systemctl stop pymc-repeater, /usr/bin/systemctl start pymc-repeater, /usr/bin/systemctl status pymc-repeater, /usr/local/bin/pymc-do-upgrade EOF chmod 0440 /etc/sudoers.d/pymc-repeater + echo ">>> Installing OTA upgrade wrapper..." + cat > /usr/local/bin/pymc-do-upgrade <<'UPGRADEEOF' +#!/bin/bash +# pymc-do-upgrade: invoked by the repeater service user via sudo for OTA upgrades. +# Usage: sudo /usr/local/bin/pymc-do-upgrade [channel] +set -e +CHANNEL="${1:-main}" +# Validate: only allow safe git ref characters +if ! [[ "$CHANNEL" =~ ^[a-zA-Z0-9._/-]{1,80}$ ]]; then + echo "Invalid channel name: $CHANNEL" >&2 + exit 1 +fi +export PIP_ROOT_USER_ACTION=ignore +exec python3 -m pip install \ + --break-system-packages \ + --no-cache-dir \ + --force-reinstall \ + "git+https://github.com/rightup/pyMC_Repeater.git@${CHANNEL}[hardware]" +UPGRADEEOF + chmod 0755 /usr/local/bin/pymc-do-upgrade + echo "75"; echo "# Starting service..." systemctl enable "$SERVICE_NAME" @@ -685,9 +706,29 @@ EOF mkdir -p /etc/sudoers.d cat > /etc/sudoers.d/pymc-repeater <<'EOF' # Allow repeater user to manage the pymc-repeater service without password -repeater ALL=(root) NOPASSWD: /usr/bin/systemctl restart pymc-repeater, /usr/bin/systemctl stop pymc-repeater, /usr/bin/systemctl start pymc-repeater, /usr/bin/systemctl status pymc-repeater +repeater ALL=(root) NOPASSWD: /usr/bin/systemctl restart pymc-repeater, /usr/bin/systemctl stop pymc-repeater, /usr/bin/systemctl start pymc-repeater, /usr/bin/systemctl status pymc-repeater, /usr/local/bin/pymc-do-upgrade EOF chmod 0440 /etc/sudoers.d/pymc-repeater + # Install / refresh OTA upgrade wrapper + cat > /usr/local/bin/pymc-do-upgrade <<'UPGRADEEOF' +#!/bin/bash +# pymc-do-upgrade: invoked by the repeater service user via sudo for OTA upgrades. +# Usage: sudo /usr/local/bin/pymc-do-upgrade [channel] +set -e +CHANNEL="${1:-main}" +# Validate: only allow safe git ref characters +if ! [[ "$CHANNEL" =~ ^[a-zA-Z0-9._/-]{1,80}$ ]]; then + echo "Invalid channel name: $CHANNEL" >&2 + exit 1 +fi +export PIP_ROOT_USER_ACTION=ignore +exec python3 -m pip install \ + --break-system-packages \ + --no-cache-dir \ + --force-reinstall \ + "git+https://github.com/rightup/pyMC_Repeater.git@${CHANNEL}[hardware]" +UPGRADEEOF + chmod 0755 /usr/local/bin/pymc-do-upgrade echo " ✓ Permissions updated" echo "[7/9] Reloading systemd..." @@ -846,6 +887,7 @@ uninstall_repeater() { echo "50"; echo "# Removing polkit and sudoers rules..." rm -f /etc/polkit-1/rules.d/10-pymc-repeater.rules rm -f /etc/sudoers.d/pymc-repeater + rm -f /usr/local/bin/pymc-do-upgrade echo "60"; echo "# Removing installation..." rm -rf "$INSTALL_DIR" diff --git a/repeater/web/__init__.py b/repeater/web/__init__.py index 7ec398e..eb91a64 100644 --- a/repeater/web/__init__.py +++ b/repeater/web/__init__.py @@ -1,6 +1,7 @@ from .api_endpoints import APIEndpoints from .cad_calibration_engine import CADCalibrationEngine from .http_server import HTTPStatsServer, LogBuffer, StatsApp, _log_buffer +from .update_endpoints import UpdateAPIEndpoints __all__ = [ "HTTPStatsServer", @@ -8,5 +9,6 @@ __all__ = [ "LogBuffer", "APIEndpoints", "CADCalibrationEngine", + "UpdateAPIEndpoints", "_log_buffer", ] diff --git a/repeater/web/api_endpoints.py b/repeater/web/api_endpoints.py index db5a7e5..0af58eb 100644 --- a/repeater/web/api_endpoints.py +++ b/repeater/web/api_endpoints.py @@ -16,6 +16,7 @@ from .auth.middleware import require_auth from .auth_endpoints import AuthAPIEndpoints from .cad_calibration_engine import CADCalibrationEngine from .companion_endpoints import CompanionAPIEndpoints +from .update_endpoints import UpdateAPIEndpoints logger = logging.getLogger("HTTPServer") @@ -113,6 +114,14 @@ logger = logging.getLogger("HTTPServer") # DELETE /api/room_message?room_name=General&message_id=123 - Delete specific message # DELETE /api/room_messages_clear?room_name=General - Clear all messages in room +# OTA Updates +# GET /api/update/status - Current + latest version, channel, state +# POST /api/update/check - Force fresh GitHub version check +# POST /api/update/install - Start background upgrade; stream via /progress +# GET /api/update/progress - SSE stream of live install log lines +# GET /api/update/channels - List available release channels (branches) +# POST /api/update/set_channel - Switch release channel {"channel": "dev"} + # Setup Wizard # GET /api/needs_setup - Check if repeater needs initial setup # GET /api/hardware_options - Get available hardware configurations @@ -163,6 +172,9 @@ class APIEndpoints: daemon_instance, event_loop, self.config, self.config_manager ) + # Create nested update object for /api/update/* routes + self.update = UpdateAPIEndpoints() + def _is_cors_enabled(self): return self.config.get("web", {}).get("cors_enabled", False) diff --git a/repeater/web/update_endpoints.py b/repeater/web/update_endpoints.py new file mode 100644 index 0000000..cf16b6d --- /dev/null +++ b/repeater/web/update_endpoints.py @@ -0,0 +1,634 @@ +""" +OTA Update endpoints for pyMC Repeater. + +Provides server-side GitHub version checks, background pip-based upgrades with +SSE progress streaming, and release-channel switching. + +Endpoints (mounted at /api/update/): + GET /api/update/status – current + latest version, channel, update state + POST /api/update/check – force a fresh GitHub version check + POST /api/update/install – start the upgrade in a background thread + GET /api/update/progress – SSE stream of live install log lines + GET /api/update/channels – list available branches/channels from GitHub + POST /api/update/set_channel – switch the active release channel +""" + +from __future__ import annotations + +import json +import logging +import os +import re +import subprocess +import threading +import time +import urllib.error +import urllib.request +from datetime import datetime +from typing import List, Optional + +import cherrypy + +from repeater import __version__ as _installed_version + +logger = logging.getLogger("HTTPServer") + +# --------------------------------------------------------------------------- +# Repository constants +# --------------------------------------------------------------------------- +GITHUB_OWNER = "rightup" +GITHUB_REPO = "pyMC_Repeater" +GITHUB_RAW_BASE = f"https://raw.githubusercontent.com/{GITHUB_OWNER}/{GITHUB_REPO}" +GITHUB_API_BASE = f"https://api.github.com/repos/{GITHUB_OWNER}/{GITHUB_REPO}" +PACKAGE_NAME = "pymc_repeater" + +# How long (seconds) before a cached check result expires +CHECK_CACHE_TTL = 600 # 10 minutes + +# Channels file – persisted so the choice survives daemon restarts +_CHANNELS_FILE = "/var/lib/pymc_repeater/.update_channel" + + +# --------------------------------------------------------------------------- +# Module-level state (one update at a time) +# --------------------------------------------------------------------------- +class _UpdateState: + """Singleton-style mutable state shared between all endpoint calls.""" + + def __init__(self): + self._lock = threading.Lock() + # version info + self.current_version: str = _installed_version + self.latest_version: Optional[str] = None + self.has_update: bool = False + self.channel: str = self._load_channel() + self.last_checked: Optional[datetime] = None + # progress / install state + self.state: str = "idle" # idle | checking | installing | complete | error + self.error_message: Optional[str] = None + self.progress_lines: List[str] = [] + self._install_thread: Optional[threading.Thread] = None + + # ------------------------------------------------------------------ # + # Channel persistence # + # ------------------------------------------------------------------ # + @staticmethod + def _load_channel() -> str: + try: + if os.path.isfile(_CHANNELS_FILE): + with open(_CHANNELS_FILE) as fh: + ch = fh.read().strip() + if ch: + return ch + except OSError: + pass + return "main" + + def _save_channel(self, channel: str) -> None: + try: + os.makedirs(os.path.dirname(_CHANNELS_FILE), exist_ok=True) + with open(_CHANNELS_FILE, "w") as fh: + fh.write(channel) + except OSError as exc: + logger.warning(f"Could not persist channel choice: {exc}") + + # ------------------------------------------------------------------ # + # Thread-safe accessors # + # ------------------------------------------------------------------ # + def snapshot(self) -> dict: + with self._lock: + return { + "current_version": self.current_version, + "latest_version": self.latest_version, + "has_update": self.has_update, + "channel": self.channel, + "last_checked": self.last_checked.isoformat() if self.last_checked else None, + "state": self.state, + "error": self.error_message, + } + + def set_channel(self, channel: str) -> None: + with self._lock: + self.channel = channel + self._save_channel(channel) + # Invalidate cached check so next call re-checks against new channel + self.last_checked = None + self.latest_version = None + self.has_update = False + + def _set_checking(self) -> bool: + """Return True and move to 'checking' if currently idle.""" + with self._lock: + if self.state not in ("idle", "complete", "error"): + return False + self.state = "checking" + return True + + def _finish_check(self, latest: str) -> None: + with self._lock: + self.latest_version = latest + self.current_version = _installed_version # refresh in case just updated + self.has_update = (latest != self.current_version) + self.last_checked = datetime.utcnow() + self.state = "idle" + self.error_message = None + + def _fail_check(self, msg: str) -> None: + with self._lock: + self.state = "error" + self.error_message = msg + self.last_checked = datetime.utcnow() + + def start_install(self, thread: threading.Thread) -> bool: + with self._lock: + if self.state == "installing": + return False + self.state = "installing" + self.error_message = None + self.progress_lines = ["[pyMC updater] Starting update…"] + self._install_thread = thread + return True + + def finish_install(self, success: bool, msg: str) -> None: + with self._lock: + self.state = "complete" if success else "error" + self.error_message = None if success else msg + if success: + self.progress_lines.append(f"[pyMC updater] ✓ {msg}") + # Refresh installed version from importlib metadata + try: + from importlib.metadata import version as _pkg_ver + self.current_version = _pkg_ver(PACKAGE_NAME) + except Exception: + pass + self.has_update = False + else: + self.progress_lines.append(f"[pyMC updater] ✗ {msg}") + + def append_line(self, line: str) -> None: + with self._lock: + self.progress_lines.append(line) + + +_state = _UpdateState() + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +def _fetch_url(url: str, timeout: int = 10) -> str: + """Perform a simple GET and return text body, or raise on failure.""" + req = urllib.request.Request(url, headers={"User-Agent": f"pymc-repeater/{_installed_version}"}) + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.read().decode("utf-8", errors="replace") + + +def _latest_version_from_raw(channel: str) -> str: + """ + Fetch repeater/__init__.py from *channel* on GitHub and extract __version__. + Falls back to scanning the _version.py stub. + """ + raw_url = f"{GITHUB_RAW_BASE}/{channel}/repeater/__init__.py" + text = _fetch_url(raw_url) + # __init__.py doesn't embed version directly; it imports from _version.py + # Try to read _version.py on the same channel instead + try: + ver_url = f"{GITHUB_RAW_BASE}/{channel}/repeater/_version.py" + ver_text = _fetch_url(ver_url) + m = re.search(r'version\s*=\s*["\']([^"\']+)["\']', ver_text) + if m: + return m.group(1) + except Exception: + pass + + # Last resort: try pyproject.toml static version field + try: + toml_url = f"{GITHUB_RAW_BASE}/{channel}/pyproject.toml" + toml_text = _fetch_url(toml_url) + m = re.search(r'^version\s*=\s*["\']([^"\']+)["\']', toml_text, re.MULTILINE) + if m: + return m.group(1) + except Exception: + pass + + return "unknown" + + +def _fetch_latest_version(channel: str) -> str: + """ + Multi-strategy version fetch. Returns version string or raises. + Strategy: + 1. GitHub Releases API (only works when tagged releases exist) + 2. Raw _version.py / pyproject.toml from the branch + """ + # Strategy 1: releases API (works for stable main branch tags) + if channel == "main": + try: + api_url = f"{GITHUB_API_BASE}/releases/latest" + body = _fetch_url(api_url, timeout=8) + data = json.loads(body) + tag = data.get("tag_name", "") + ver = tag.lstrip("v") + if ver: + return ver + except Exception: + pass + + # Strategy 2: raw source files on the branch + return _latest_version_from_raw(channel) + + +def _fetch_branches() -> List[str]: + """Return list of branch names from GitHub API.""" + try: + body = _fetch_url(f"{GITHUB_API_BASE}/branches?per_page=30", timeout=8) + data = json.loads(body) + names = [b["name"] for b in data if isinstance(b, dict) and b.get("name")] + # Prefer main/dev at the front + priority = [n for n in ("main", "dev", "develop") if n in names] + rest = [n for n in names if n not in priority] + return priority + rest + except Exception: + return ["main"] + + +def _do_check() -> None: + """Background thread: fetch latest version and update state.""" + channel = _state.channel + try: + latest = _fetch_latest_version(channel) + _state._finish_check(latest) + logger.info(f"[Update] Check complete – current={_state.current_version} latest={latest} channel={channel}") + except Exception as exc: + msg = str(exc) + _state._fail_check(msg) + logger.warning(f"[Update] Version check failed: {msg}") + + +def _do_install() -> None: + """ + Background thread: install updated package then restart the service. + + Privilege strategy (root check → sudo wrapper → direct pip): + 1. If running as root – call python3 -m pip directly. + 2. Otherwise – use ``sudo /usr/local/bin/pymc-do-upgrade `` + (installed and authorized by manage.sh). + """ + channel = _state.channel + + def _run(cmd: List[str], env: Optional[dict] = None) -> bool: + """Run command, streaming lines into progress_lines. Returns success bool.""" + try: + proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + env=env, + ) + for line in proc.stdout: + line = line.rstrip() + if line: + _state.append_line(line) + logger.debug(f"[pip] {line}") + proc.wait() + return proc.returncode == 0 + except Exception as exc: + _state.append_line(f"[error] {exc}") + logger.error(f"[Update] Subprocess error: {exc}") + return False + + import os as _os + env = _os.environ.copy() + env["PIP_ROOT_USER_ACTION"] = "ignore" + env["SETUPTOOLS_SCM_PRETEND_VERSION"] = _state.latest_version or "1.0.0" + + _state.append_line(f"[pyMC updater] Installing from channel '{channel}'…") + + _UPGRADE_WRAPPER = "/usr/local/bin/pymc-do-upgrade" + is_root = (_os.geteuid() == 0) + + if is_root: + install_spec = ( + f"git+https://github.com/{GITHUB_OWNER}/{GITHUB_REPO}.git@{channel}[hardware]" + ) + _state.append_line(f"[pyMC updater] Running as root – direct pip install") + _state.append_line(f"[pyMC updater] Target: {install_spec}") + cmd = [ + "python3", "-m", "pip", "install", + "--break-system-packages", + "--no-cache-dir", + "--force-reinstall", + install_spec, + ] + elif _os.path.isfile(_UPGRADE_WRAPPER): + _state.append_line(f"[pyMC updater] Using sudo wrapper: {_UPGRADE_WRAPPER}") + cmd = ["sudo", _UPGRADE_WRAPPER, channel] + else: + msg = ( + f"Upgrade wrapper not found at {_UPGRADE_WRAPPER}. " + "Re-run manage.sh install/upgrade to configure sudo permissions." + ) + _state.finish_install(False, msg) + return + + success = _run(cmd, env=env) + + if success: + _state.finish_install(True, f"Upgraded to latest on channel '{channel}'") + _state.append_line("[pyMC updater] Restarting service in 3 seconds…") + time.sleep(3) + try: + from repeater.service_utils import restart_service + ok, msg = restart_service() + logger.info(f"[Update] Post-upgrade restart: {msg}") + except Exception as exc: + logger.warning(f"[Update] Could not restart service: {exc}") + else: + _state.finish_install(False, "pip install failed – see progress log for details") + + +# --------------------------------------------------------------------------- +# CherryPy Endpoint class +# --------------------------------------------------------------------------- + +class UpdateAPIEndpoints: + """ + Mounted at /api/update/ inside APIEndpoints. + All mutating endpoints require an authenticated user (Bearer JWT or API token). + """ + + def _set_cors_headers(self, config: dict) -> None: + if config.get("web", {}).get("cors_enabled", False): + cherrypy.response.headers["Access-Control-Allow-Origin"] = "*" + cherrypy.response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS" + cherrypy.response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization" + + def _require_post(self): + if cherrypy.request.method != "POST": + raise cherrypy.HTTPError(405, "Method Not Allowed") + + @staticmethod + def _ok(data: dict) -> dict: + return {"success": True, **data} + + @staticmethod + def _err(msg: str, status: int = 400) -> dict: + cherrypy.response.status = status + return {"success": False, "error": str(msg)} + + # ------------------------------------------------------------------ # + # GET /api/update/status # + # ------------------------------------------------------------------ # + @cherrypy.expose + @cherrypy.tools.json_out() + def status(self, **kwargs): + """ + Return current update status without triggering a fresh check. + + Response: + {success, current_version, latest_version, has_update, + channel, last_checked, state, error} + """ + if cherrypy.request.method == "OPTIONS": + return "" + + snap = _state.snapshot() + return self._ok(snap) + + # ------------------------------------------------------------------ # + # POST /api/update/check # + # ------------------------------------------------------------------ # + @cherrypy.expose + @cherrypy.tools.json_out() + def check(self, **kwargs): + """ + Force a fresh version check against GitHub. Non-blocking – spawns a + background thread; poll /api/update/status for the result. + + Response: + {success, message, state} + """ + if cherrypy.request.method == "OPTIONS": + return "" + + if cherrypy.request.method not in ("POST", "GET"): + raise cherrypy.HTTPError(405) + + # Honour the cache to avoid hammering GitHub + snap = _state.snapshot() + if snap["state"] == "checking": + return self._ok({"message": "Check already in progress", "state": "checking"}) + + if snap["last_checked"] is not None: + age = (datetime.utcnow() - _state.last_checked).total_seconds() + if age < CHECK_CACHE_TTL and snap["latest_version"] is not None: + return self._ok({"message": "Using cached result", "state": snap["state"], **snap}) + + if not _state._set_checking(): + return self._ok({"message": "Busy – try again shortly", "state": _state.state}) + + t = threading.Thread(target=_do_check, daemon=True, name="update-check") + t.start() + + logger.info("[Update] Version check initiated via API") + return self._ok({"message": "Update check started", "state": "checking"}) + + # ------------------------------------------------------------------ # + # POST /api/update/install # + # ------------------------------------------------------------------ # + @cherrypy.expose + @cherrypy.tools.json_out() + @cherrypy.tools.json_in() + def install(self, **kwargs): + """ + Start the upgrade process in a background thread. + + The caller should open the SSE stream at /api/update/progress to + watch live output. The service will restart automatically on success. + + Optional JSON body: + {"force": true} – install even if no update is detected + + Response: + {success, message, state} + """ + if cherrypy.request.method == "OPTIONS": + return "" + + try: + self._require_post() + except cherrypy.HTTPError: + raise + + body = {} + try: + body = cherrypy.request.json or {} + except Exception: + pass + + snap = _state.snapshot() + + if snap["state"] == "installing": + return self._err("An update is already in progress", 409) + + force = bool(body.get("force", False)) + if not force and not snap["has_update"]: + # Still allow install if no check has been done yet + if snap["latest_version"] is not None: + return self._err( + f"Already up to date ({snap['current_version']}). " + "Pass {\"force\": true} to reinstall anyway.", + 409, + ) + + t = threading.Thread(target=_do_install, daemon=True, name="update-install") + started = _state.start_install(t) + if not started: + return self._err("Could not start install thread – check state", 409) + + t.start() + logger.warning( + f"[Update] Install triggered via API – channel={_state.channel}" + ) + return self._ok({ + "message": f"Update started on channel '{_state.channel}'. " + "Watch /api/update/progress for live output.", + "state": "installing", + }) + + # ------------------------------------------------------------------ # + # GET /api/update/progress (SSE) # + # ------------------------------------------------------------------ # + @cherrypy.expose + def progress(self, **kwargs): + """ + Server-Sent Events stream that emits install log lines in real time. + + Event types: + connected – initial handshake + line – one log line {line: str} + status – state change {state: str} + keepalive – heartbeat (every 5 s) + done – stream finished {state: str, error: str|null} + """ + cherrypy.response.headers["Content-Type"] = "text/event-stream" + cherrypy.response.headers["Cache-Control"] = "no-cache" + cherrypy.response.headers["X-Accel-Buffering"] = "no" + cherrypy.response.headers["Connection"] = "keep-alive" + + def generate(): + yield f"data: {json.dumps({'type': 'connected', 'message': 'Connected to update progress stream'})}\n\n" + + last_idx = 0 + last_state = None + + while True: + try: + snap = _state.snapshot() + current_state = snap["state"] + + # Emit any new log lines + current_lines = _state.progress_lines + new_lines = current_lines[last_idx:] + for line in new_lines: + payload = json.dumps({"type": "line", "line": line}) + yield f"data: {payload}\n\n" + last_idx = len(current_lines) + + # Emit state transitions + if current_state != last_state: + payload = json.dumps({"type": "status", "state": current_state}) + yield f"data: {payload}\n\n" + last_state = current_state + + # Terminate stream when install completes or errors + if current_state in ("complete", "error") and last_idx >= len(current_lines): + done_payload = json.dumps({ + "type": "done", + "state": current_state, + "error": snap.get("error"), + }) + yield f"data: {done_payload}\n\n" + return + + # Keepalive + yield f"data: {json.dumps({'type': 'keepalive'})}\n\n" + time.sleep(1.5) + + except GeneratorExit: + return + except Exception as exc: + logger.debug(f"[Update SSE] stream error: {exc}") + return + + return generate() + + progress._cp_config = {"response.stream": True} + + # ------------------------------------------------------------------ # + # GET /api/update/channels # + # ------------------------------------------------------------------ # + @cherrypy.expose + @cherrypy.tools.json_out() + def channels(self, **kwargs): + """ + Return available GitHub branches as selectable channels. + + Response: + {success, channels: [str], current_channel: str} + """ + if cherrypy.request.method == "OPTIONS": + return "" + + branch_list = _fetch_branches() + return self._ok({ + "channels": branch_list, + "current_channel": _state.channel, + }) + + # ------------------------------------------------------------------ # + # POST /api/update/set_channel # + # ------------------------------------------------------------------ # + @cherrypy.expose + @cherrypy.tools.json_out() + @cherrypy.tools.json_in() + def set_channel(self, **kwargs): + """ + Switch the release channel (branch) used for future update checks and installs. + + JSON body: + {"channel": "dev"} + + Response: + {success, channel, message} + """ + if cherrypy.request.method == "OPTIONS": + return "" + + try: + self._require_post() + except cherrypy.HTTPError: + raise + + body = {} + try: + body = cherrypy.request.json or {} + except Exception: + pass + + channel = str(body.get("channel", "")).strip() + if not channel: + return self._err("'channel' field is required") + + if _state.state == "installing": + return self._err("Cannot change channel while an install is in progress", 409) + + _state.set_channel(channel) + logger.info(f"[Update] Channel changed to '{channel}' via API") + return self._ok({ + "channel": channel, + "message": f"Channel switched to '{channel}'. Run /api/update/check to verify.", + })