Просмотр исходного кода

[Feature] SpoolBuddy OTA updates via Bambuddy UI

  SpoolBuddy devices can now be updated from Settings → Updates without
  SSH access. The daemon picks up an "update" command via its existing
  heartbeat, runs git fetch/reset + pip install, reports progress back
  to the backend, then exits for systemd to restart with the new code.

  Backend: update_status/update_message fields, trigger + status endpoints
  Daemon: _perform_update() handler, report_update_status() API method
  Frontend: "Apply Update" button with live progress in UpdatesTab
maziggy 2 месяцев назад
Родитель
Сommit
cf2203914d

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [0.2.3b1] - Unreleased
 ## [0.2.3b1] - Unreleased
 
 
 ### New Features
 ### New Features
+- **SpoolBuddy OTA Updates** — SpoolBuddy devices can now be updated directly from the Settings → Updates tab without SSH access. Click "Check for Updates" to see if a newer version is available, then "Apply Update" to trigger the update. The daemon picks up the command via its heartbeat, pulls the latest code from GitHub, installs dependencies, and restarts automatically via systemd. Live progress is shown in the UI with status messages from the device. Requires the device to be online.
 - **Select Plates to Queue** ([#777](https://github.com/maziggy/bambuddy/issues/777)) — Multi-plate 3MF files now support selecting a subset of plates to queue, instead of only "one plate" or "all plates". In add-to-queue mode, each plate has a checkbox for multi-select, with a "Select All / Deselect All" toggle. Reprint and edit modes remain single-select. Requested by @stringham.
 - **Select Plates to Queue** ([#777](https://github.com/maziggy/bambuddy/issues/777)) — Multi-plate 3MF files now support selecting a subset of plates to queue, instead of only "one plate" or "all plates". In add-to-queue mode, each plate has a checkbox for multi-select, with a "Select All / Deselect All" toggle. Reprint and edit modes remain single-select. Requested by @stringham.
 - **Camera Image Rotation** ([#672](https://github.com/maziggy/bambuddy/issues/672)) — Added per-printer camera rotation (0°, 90°, 180°, 270°) for cameras mounted in portrait or upside-down orientations. Configurable in Settings → Camera for each printer. Rotation applies to live stream, embedded viewer, stream overlay, and notification snapshots. Requested by @wrenoud.
 - **Camera Image Rotation** ([#672](https://github.com/maziggy/bambuddy/issues/672)) — Added per-printer camera rotation (0°, 90°, 180°, 270°) for cameras mounted in portrait or upside-down orientations. Configurable in Settings → Camera for each printer. Rotation applies to live stream, embedded viewer, stream overlay, and notification snapshots. Requested by @wrenoud.
 - **Per-User Email Notifications** ([#693](https://github.com/maziggy/bambuddy/pull/693)) — When Advanced Authentication is enabled, individual users can now receive email notifications for their own print jobs. A new "Notifications" page lets each user toggle notifications for print start, complete, failed, and stopped events. Only prints submitted by that user trigger their email — other users' prints are not affected. Requires SMTP to be configured and the "User Notifications" toggle enabled in Settings → Notifications. Administrators and Operators have access by default; Viewers do not. Contributed by @cadtoolbox.
 - **Per-User Email Notifications** ([#693](https://github.com/maziggy/bambuddy/pull/693)) — When Advanced Authentication is enabled, individual users can now receive email notifications for their own print jobs. A new "Notifications" page lets each user toggle notifications for print start, complete, failed, and stopped events. Only prints submitted by that user trigger their email — other users' prints are not affected. Requires SMTP to be configured and the "User Notifications" toggle enabled in Settings → Notifications. Administrators and Operators have access by default; Viewers do not. Contributed by @cadtoolbox.

+ 73 - 0
backend/app/api/routes/spoolbuddy.py

@@ -68,6 +68,8 @@ def _device_to_response(device: SpoolBuddyDevice) -> DeviceResponse:
         nfc_ok=device.nfc_ok,
         nfc_ok=device.nfc_ok,
         scale_ok=device.scale_ok,
         scale_ok=device.scale_ok,
         uptime_s=device.uptime_s,
         uptime_s=device.uptime_s,
+        update_status=device.update_status,
+        update_message=device.update_message,
         online=_is_online(device),
         online=_is_online(device),
         created_at=device.created_at,
         created_at=device.created_at,
         updated_at=device.updated_at,
         updated_at=device.updated_at,
@@ -636,6 +638,77 @@ async def check_daemon_update(
         }
         }
 
 
 
 
+@router.post("/devices/{device_id}/update")
+async def trigger_daemon_update(
+    device_id: str,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+):
+    """Trigger a daemon update on the SpoolBuddy device via pending_command."""
+    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
+    device = result.scalar_one_or_none()
+    if not device:
+        raise HTTPException(status_code=404, detail="Device not registered")
+
+    if not _is_online(device):
+        raise HTTPException(status_code=409, detail="Device is offline")
+
+    if device.update_status == "updating":
+        return {"status": "already_updating", "message": "Update already in progress"}
+
+    device.pending_command = "update"
+    device.update_status = "pending"
+    device.update_message = "Waiting for device to pick up update command..."
+    await db.commit()
+
+    logger.info("SpoolBuddy %s: update command queued", device_id)
+    await ws_manager.broadcast(
+        {
+            "type": "spoolbuddy_update",
+            "device_id": device_id,
+            "update_status": "pending",
+        }
+    )
+
+    return {"status": "ok", "message": "Update command sent to device"}
+
+
+@router.post("/devices/{device_id}/update-status")
+async def report_update_status(
+    device_id: str,
+    req: dict,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Daemon reports update progress back to the backend."""
+    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
+    device = result.scalar_one_or_none()
+    if not device:
+        raise HTTPException(status_code=404, detail="Device not registered")
+
+    status = req.get("status", "")
+    message = req.get("message", "")
+
+    if status in ("updating", "complete", "error"):
+        device.update_status = status
+        device.update_message = message[:255] if message else None
+        if status == "complete":
+            device.pending_command = None
+        await db.commit()
+
+        logger.info("SpoolBuddy %s: update status=%s msg=%s", device_id, status, message)
+        await ws_manager.broadcast(
+            {
+                "type": "spoolbuddy_update",
+                "device_id": device_id,
+                "update_status": status,
+                "update_message": message,
+            }
+        )
+
+    return {"status": "ok"}
+
+
 # --- Background watchdog ---
 # --- Background watchdog ---
 
 
 
 

+ 2 - 0
backend/app/models/spoolbuddy_device.py

@@ -29,6 +29,8 @@ class SpoolBuddyDevice(Base):
     last_seen: Mapped[datetime | None] = mapped_column(DateTime)
     last_seen: Mapped[datetime | None] = mapped_column(DateTime)
     pending_command: Mapped[str | None] = mapped_column(String(50))
     pending_command: Mapped[str | None] = mapped_column(String(50))
     pending_write_payload: Mapped[str | None] = mapped_column(Text, nullable=True)
     pending_write_payload: Mapped[str | None] = mapped_column(Text, nullable=True)
+    update_status: Mapped[str | None] = mapped_column(String(20), nullable=True)
+    update_message: Mapped[str | None] = mapped_column(String(255), nullable=True)
     nfc_ok: Mapped[bool] = mapped_column(Boolean, default=False)
     nfc_ok: Mapped[bool] = mapped_column(Boolean, default=False)
     scale_ok: Mapped[bool] = mapped_column(Boolean, default=False)
     scale_ok: Mapped[bool] = mapped_column(Boolean, default=False)
     uptime_s: Mapped[int] = mapped_column(Integer, default=0)
     uptime_s: Mapped[int] = mapped_column(Integer, default=0)

+ 2 - 0
backend/app/schemas/spoolbuddy.py

@@ -40,6 +40,8 @@ class DeviceResponse(BaseModel):
     nfc_ok: bool
     nfc_ok: bool
     scale_ok: bool
     scale_ok: bool
     uptime_s: int
     uptime_s: int
+    update_status: str | None = None
+    update_message: str | None = None
     online: bool = False
     online: bool = False
     created_at: datetime
     created_at: datetime
     updated_at: datetime
     updated_at: datetime

+ 8 - 0
frontend/src/api/client.ts

@@ -4983,6 +4983,8 @@ export interface SpoolBuddyDevice {
   nfc_ok: boolean;
   nfc_ok: boolean;
   scale_ok: boolean;
   scale_ok: boolean;
   uptime_s: number;
   uptime_s: number;
+  update_status: string | null;
+  update_message: string | null;
   online: boolean;
   online: boolean;
 }
 }
 
 
@@ -5028,6 +5030,12 @@ export const spoolbuddyApi = {
   checkDaemonUpdate: (deviceId: string, includeBeta?: boolean) =>
   checkDaemonUpdate: (deviceId: string, includeBeta?: boolean) =>
     request<DaemonUpdateCheck>(`/spoolbuddy/devices/${deviceId}/update-check?include_beta=${includeBeta ?? false}`),
     request<DaemonUpdateCheck>(`/spoolbuddy/devices/${deviceId}/update-check?include_beta=${includeBeta ?? false}`),
 
 
+  triggerUpdate: (deviceId: string) =>
+    request<{ status: string; message: string }>(`/spoolbuddy/devices/${deviceId}/update`, {
+      method: 'POST',
+      body: '{}',
+    }),
+
   writeTag: (deviceId: string, spoolId: number) =>
   writeTag: (deviceId: string, spoolId: number) =>
     request<{ status: string }>('/spoolbuddy/nfc/write-tag', {
     request<{ status: string }>('/spoolbuddy/nfc/write-tag', {
       method: 'POST',
       method: 'POST',

+ 79 - 8
frontend/src/pages/spoolbuddy/SpoolBuddySettingsPage.tsx

@@ -503,6 +503,7 @@ function ScaleTab({ device, weight, weightStable, rawAdc }: {
 function UpdatesTab({ device }: { device: SpoolBuddyDevice }) {
 function UpdatesTab({ device }: { device: SpoolBuddyDevice }) {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const [checking, setChecking] = useState(false);
   const [checking, setChecking] = useState(false);
+  const [applying, setApplying] = useState(false);
   const [updateResult, setUpdateResult] = useState<DaemonUpdateCheck | null>(null);
   const [updateResult, setUpdateResult] = useState<DaemonUpdateCheck | null>(null);
   const [error, setError] = useState<string | null>(null);
   const [error, setError] = useState<string | null>(null);
   const [includeBeta, setIncludeBeta] = useState(() => {
   const [includeBeta, setIncludeBeta] = useState(() => {
@@ -513,6 +514,8 @@ function UpdatesTab({ device }: { device: SpoolBuddyDevice }) {
     }
     }
   });
   });
 
 
+  const isUpdating = device.update_status === 'pending' || device.update_status === 'updating';
+
   const toggleBeta = () => {
   const toggleBeta = () => {
     const next = !includeBeta;
     const next = !includeBeta;
     setIncludeBeta(next);
     setIncludeBeta(next);
@@ -539,6 +542,18 @@ function UpdatesTab({ device }: { device: SpoolBuddyDevice }) {
     }
     }
   };
   };
 
 
+  const applyUpdate = async () => {
+    setApplying(true);
+    setError(null);
+    try {
+      await spoolbuddyApi.triggerUpdate(device.device_id);
+    } catch (e) {
+      setError(e instanceof Error ? e.message : 'Failed to trigger update');
+    } finally {
+      setApplying(false);
+    }
+  };
+
   // Show version from device, or from update check result if available
   // Show version from device, or from update check result if available
   const displayVersion = device.firmware_version
   const displayVersion = device.firmware_version
     || (updateResult?.current_version && updateResult.current_version !== '0.0.0' ? updateResult.current_version : null);
     || (updateResult?.current_version && updateResult.current_version !== '0.0.0' ? updateResult.current_version : null);
@@ -560,11 +575,50 @@ function UpdatesTab({ device }: { device: SpoolBuddyDevice }) {
         </div>
         </div>
       </div>
       </div>
 
 
+      {/* Update progress (shown when update is in progress) */}
+      {isUpdating && (
+        <div className="bg-zinc-800 rounded-lg p-4">
+          <div className="flex items-center gap-3">
+            <svg className="w-5 h-5 animate-spin text-green-400 flex-shrink-0" viewBox="0 0 24 24" fill="none">
+              <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
+              <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
+            </svg>
+            <div>
+              <p className="text-sm font-medium text-green-300">
+                {t('spoolbuddy.settings.updating', 'Updating...')}
+              </p>
+              <p className="text-xs text-zinc-400 mt-0.5">
+                {device.update_message || t('spoolbuddy.settings.updateWaiting', 'Waiting for device...')}
+              </p>
+            </div>
+          </div>
+        </div>
+      )}
+
+      {/* Update complete */}
+      {device.update_status === 'complete' && (
+        <div className="rounded-lg p-3 text-sm bg-green-900/30 border border-green-800">
+          <div className="flex items-center gap-2">
+            <svg className="w-4 h-4 text-green-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+              <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
+            </svg>
+            <p className="text-green-300">{device.update_message || t('spoolbuddy.settings.updateComplete', 'Update complete!')}</p>
+          </div>
+        </div>
+      )}
+
+      {/* Update error */}
+      {device.update_status === 'error' && (
+        <div className="rounded-lg p-3 text-sm bg-red-900/30 border border-red-800">
+          <p className="text-red-300">{device.update_message || t('spoolbuddy.settings.updateFailed', 'Update failed')}</p>
+        </div>
+      )}
+
       {/* Check for updates */}
       {/* Check for updates */}
       <div className="bg-zinc-800 rounded-lg p-4 space-y-3">
       <div className="bg-zinc-800 rounded-lg p-4 space-y-3">
         <button
         <button
           onClick={checkForUpdates}
           onClick={checkForUpdates}
-          disabled={checking}
+          disabled={checking || isUpdating}
           className="w-full px-4 py-2.5 rounded-lg text-sm font-medium bg-zinc-700 text-zinc-200 hover:bg-zinc-600 disabled:opacity-40 transition-colors min-h-[44px] flex items-center justify-center gap-2"
           className="w-full px-4 py-2.5 rounded-lg text-sm font-medium bg-zinc-700 text-zinc-200 hover:bg-zinc-600 disabled:opacity-40 transition-colors min-h-[44px] flex items-center justify-center gap-2"
         >
         >
           {checking && (
           {checking && (
@@ -591,13 +645,30 @@ function UpdatesTab({ device }: { device: SpoolBuddyDevice }) {
               : 'bg-zinc-700/50'
               : 'bg-zinc-700/50'
           }`}>
           }`}>
             {updateResult.update_available ? (
             {updateResult.update_available ? (
-              <div className="space-y-1">
-                <p className="text-green-300 font-medium">
-                  {t('spoolbuddy.settings.updateAvailable', 'Update available')}: v{updateResult.latest_version}
-                </p>
-                <p className="text-xs text-zinc-400">
-                  {t('spoolbuddy.settings.updateInstructions', 'Update via SSH: run the SpoolBuddy install script to upgrade.')}
-                </p>
+              <div className="space-y-3">
+                <div className="space-y-1">
+                  <p className="text-green-300 font-medium">
+                    {t('spoolbuddy.settings.updateAvailable', 'Update available')}: v{updateResult.latest_version}
+                  </p>
+                  <p className="text-xs text-zinc-400">
+                    {displayVersion ? `${displayVersion} → ${updateResult.latest_version}` : ''}
+                  </p>
+                </div>
+                <button
+                  onClick={applyUpdate}
+                  disabled={applying || isUpdating || !device.online}
+                  className="w-full px-4 py-2.5 rounded-lg text-sm font-medium bg-green-600 text-white hover:bg-green-700 disabled:opacity-40 transition-colors min-h-[44px] flex items-center justify-center gap-2"
+                >
+                  {applying && (
+                    <svg className="w-4 h-4 animate-spin" viewBox="0 0 24 24" fill="none">
+                      <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
+                      <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
+                    </svg>
+                  )}
+                  {!device.online
+                    ? t('spoolbuddy.settings.deviceOffline', 'Device Offline')
+                    : t('spoolbuddy.settings.applyUpdate', 'Apply Update')}
+                </button>
               </div>
               </div>
             ) : (
             ) : (
               <div className="flex items-center gap-2">
               <div className="flex items-center gap-2">

+ 6 - 0
spoolbuddy/daemon/api_client.py

@@ -182,3 +182,9 @@ class APIClient:
                 "message": message,
                 "message": message,
             },
             },
         )
         )
+
+    async def report_update_status(self, device_id: str, status: str, message: str = "") -> dict | None:
+        return await self._post(
+            f"/devices/{device_id}/update-status",
+            {"status": status, "message": message},
+        )

+ 97 - 1
spoolbuddy/daemon/main.py

@@ -3,6 +3,7 @@
 
 
 import asyncio
 import asyncio
 import logging
 import logging
+import shutil
 import socket
 import socket
 import sys
 import sys
 import time
 import time
@@ -122,6 +123,93 @@ async def scale_poll_loop(config: Config, api: APIClient, shared: dict):
         scale.close()
         scale.close()
 
 
 
 
+async def _perform_update(config: Config, api: APIClient):
+    """Pull latest code from git, install deps, then exit for systemd restart."""
+    # Determine repo root (install path) — daemon runs from <repo>/spoolbuddy/
+    repo_root = Path(__file__).resolve().parent.parent.parent
+
+    await api.report_update_status(config.device_id, "updating", "Fetching latest code...")
+
+    git_path = shutil.which("git") or "/usr/bin/git"
+    git_config = ["-c", f"safe.directory={repo_root}"]
+
+    # git fetch origin main
+    proc = await asyncio.create_subprocess_exec(
+        git_path,
+        *git_config,
+        "fetch",
+        "origin",
+        "main",
+        cwd=str(repo_root),
+        stdout=asyncio.subprocess.PIPE,
+        stderr=asyncio.subprocess.PIPE,
+    )
+    _, stderr = await proc.communicate()
+    if proc.returncode != 0:
+        msg = f"git fetch failed: {stderr.decode()[:200]}"
+        logger.error(msg)
+        await api.report_update_status(config.device_id, "error", msg)
+        return
+
+    await api.report_update_status(config.device_id, "updating", "Applying update...")
+
+    # git reset --hard origin/main
+    proc = await asyncio.create_subprocess_exec(
+        git_path,
+        *git_config,
+        "reset",
+        "--hard",
+        "origin/main",
+        cwd=str(repo_root),
+        stdout=asyncio.subprocess.PIPE,
+        stderr=asyncio.subprocess.PIPE,
+    )
+    _, stderr = await proc.communicate()
+    if proc.returncode != 0:
+        msg = f"git reset failed: {stderr.decode()[:200]}"
+        logger.error(msg)
+        await api.report_update_status(config.device_id, "error", msg)
+        return
+
+    await api.report_update_status(config.device_id, "updating", "Installing dependencies...")
+
+    # pip install daemon deps (use the venv pip)
+    venv_pip = repo_root / "spoolbuddy" / "venv" / "bin" / "pip"
+    pip_packages = ["spidev", "gpiod", "smbus2", "httpx"]
+
+    if venv_pip.exists():
+        proc = await asyncio.create_subprocess_exec(
+            str(venv_pip),
+            "install",
+            "--upgrade",
+            *pip_packages,
+            cwd=str(repo_root),
+            stdout=asyncio.subprocess.PIPE,
+            stderr=asyncio.subprocess.PIPE,
+        )
+    else:
+        proc = await asyncio.create_subprocess_exec(
+            sys.executable,
+            "-m",
+            "pip",
+            "install",
+            "--upgrade",
+            *pip_packages,
+            cwd=str(repo_root),
+            stdout=asyncio.subprocess.PIPE,
+            stderr=asyncio.subprocess.PIPE,
+        )
+    await proc.communicate()
+    if proc.returncode != 0:
+        logger.warning("pip install returned non-zero (continuing anyway)")
+
+    await api.report_update_status(config.device_id, "complete", "Update complete, restarting...")
+    logger.info("Update complete, exiting for systemd restart")
+
+    # Exit cleanly — systemd Restart=always will bring us back with the new code
+    sys.exit(0)
+
+
 async def heartbeat_loop(config: Config, api: APIClient, start_time: float, shared: dict):
 async def heartbeat_loop(config: Config, api: APIClient, start_time: float, shared: dict):
     """Periodic heartbeat to keep device registered and pick up commands."""
     """Periodic heartbeat to keep device registered and pick up commands."""
     display: DisplayControl = shared["display"]
     display: DisplayControl = shared["display"]
@@ -146,7 +234,15 @@ async def heartbeat_loop(config: Config, api: APIClient, start_time: float, shar
 
 
         if result:
         if result:
             cmd = result.get("pending_command")
             cmd = result.get("pending_command")
-            if cmd == "tare":
+            if cmd == "update":
+                logger.info("Update command received, starting update...")
+                try:
+                    await _perform_update(config, api)
+                except Exception as e:
+                    logger.error("Update failed: %s", e)
+                    await api.report_update_status(config.device_id, "error", str(e)[:255])
+                continue
+            elif cmd == "tare":
                 scale = shared.get("scale")
                 scale = shared.get("scale")
                 if scale and scale.ok:
                 if scale and scale.ok:
                     new_offset = await asyncio.to_thread(scale.tare)
                     new_offset = await asyncio.to_thread(scale.tare)

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-EtLQYE4Y.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-D5Vkuxg1.js"></script>
+    <script type="module" crossorigin src="/assets/index-EtLQYE4Y.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-CJ-drcFM.css">
     <link rel="stylesheet" crossorigin href="/assets/index-CJ-drcFM.css">
   </head>
   </head>
   <body>
   <body>

Некоторые файлы не были показаны из-за большого количества измененных файлов