Browse Source

Replace SpoolBuddy self-update with SSH-based updates from Bambuddy

  The daemon's self-update mechanism (git fetch/reset on its own code) was
  fragile: .git permission errors, self-modifying code mid-run, hardcoded
  main branch. Bambuddy now SSHes into the SpoolBuddy Pi and drives the
  update remotely — matching its own branch, with step-by-step progress
  via WebSocket. Install script updated with SSH access, sudoers entry,
  and --ssh-pubkey flag for pairing.
maziggy 2 months ago
parent
commit
bc1bba078b

+ 3 - 0
.gitignore

@@ -64,6 +64,9 @@ data/
 # JWT secret file (should be in data dir, but protect project root too)
 # JWT secret file (should be in data dir, but protect project root too)
 .jwt_secret
 .jwt_secret
 
 
+# SpoolBuddy SSH keys (generated at runtime for remote updates)
+spoolbuddy/ssh/
+
 # Security scan output
 # Security scan output
 *.sarif
 *.sarif
 
 

+ 1 - 0
CHANGELOG.md

@@ -6,6 +6,7 @@ All notable changes to Bambuddy will be documented in this file.
 
 
 ### Fixed
 ### Fixed
 - **SpoolBuddy Update Check Always Shows "Up to Date"** — The SpoolBuddy daemon update check compared the device's firmware version against GitHub releases instead of the running Bambuddy backend version. This meant the check could incorrectly report "up to date" even when the daemon was behind. Fixed by comparing directly against `APP_VERSION` from the backend config.
 - **SpoolBuddy Update Check Always Shows "Up to Date"** — The SpoolBuddy daemon update check compared the device's firmware version against GitHub releases instead of the running Bambuddy backend version. This meant the check could incorrectly report "up to date" even when the daemon was behind. Fixed by comparing directly against `APP_VERSION` from the backend config.
+- **SpoolBuddy Updates Now Use SSH** — Replaced the fragile self-update mechanism (daemon pulls its own code via git, permission errors on `.git/`, hardcoded `main` branch) with SSH-based updates driven by the Bambuddy backend. Bambuddy now SSHes into the SpoolBuddy Pi and runs git fetch/checkout, pip install, and systemctl restart remotely. Updates automatically use the same branch as Bambuddy. The install script now creates the `spoolbuddy` user with SSH access and a narrow sudoers entry for service restart only. A "Force Update" button allows re-deploying even when versions match. The SSH public key is shown in SpoolBuddy Settings for easy pairing.
 - **Virtual Printer Proxy A1 Printing Fails** ([#757](https://github.com/maziggy/bambuddy/issues/757)) — BambuStudio could not send prints to A1 (and potentially P1S) virtual printers in proxy mode. The slicer connects to undocumented proprietary ports 2024-2026 on these models, which the proxy was not forwarding, causing BambuStudio to show an access code dialog instead of printing. Added transparent TCP pass-through proxying for ports 2024-2026. These ports are silently ignored on models that don't use them (X1C, H2C, P2S). Reported by @Utility9298.
 - **Virtual Printer Proxy A1 Printing Fails** ([#757](https://github.com/maziggy/bambuddy/issues/757)) — BambuStudio could not send prints to A1 (and potentially P1S) virtual printers in proxy mode. The slicer connects to undocumented proprietary ports 2024-2026 on these models, which the proxy was not forwarding, causing BambuStudio to show an access code dialog instead of printing. Added transparent TCP pass-through proxying for ports 2024-2026. These ports are silently ignored on models that don't use them (X1C, H2C, P2S). Reported by @Utility9298.
 - **Spool Assignment on Empty AMS Slots** ([#784](https://github.com/maziggy/bambuddy/issues/784)) — Empty AMS slots (no physical spool detected) showed "Assign Spool" and "Configure" buttons in the hover popup. Assigning a spool to an empty slot created a stuck state because no "Unassign" button is available for empty slots. Removed both buttons from empty AMS and HT AMS slot popups. External spool holders are unaffected. Reported by @RosdasHH.
 - **Spool Assignment on Empty AMS Slots** ([#784](https://github.com/maziggy/bambuddy/issues/784)) — Empty AMS slots (no physical spool detected) showed "Assign Spool" and "Configure" buttons in the hover popup. Assigning a spool to an empty slot created a stuck state because no "Unassign" button is available for empty slots. Removed both buttons from empty AMS and HT AMS slot popups. External spool holders are unaffected. Reported by @RosdasHH.
 
 

+ 1 - 0
Dockerfile

@@ -25,6 +25,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
     ffmpeg \
     ffmpeg \
     iproute2 \
     iproute2 \
     libcap2-bin \
     libcap2-bin \
+    openssh-client \
     && rm -rf /var/lib/apt/lists/*
     && rm -rf /var/lib/apt/lists/*
 
 
 # Allow binding to privileged ports (e.g. 990/FTPS) as non-root user.
 # Allow binding to privileged ports (e.g. 990/FTPS) as non-root user.

+ 29 - 5
backend/app/api/routes/spoolbuddy.py

@@ -1,5 +1,6 @@
 """SpoolBuddy device management API routes."""
 """SpoolBuddy device management API routes."""
 
 
+import asyncio
 import logging
 import logging
 from datetime import datetime, timedelta, timezone
 from datetime import datetime, timedelta, timezone
 
 
@@ -597,10 +598,17 @@ async def check_daemon_update(
 @router.post("/devices/{device_id}/update")
 @router.post("/devices/{device_id}/update")
 async def trigger_daemon_update(
 async def trigger_daemon_update(
     device_id: str,
     device_id: str,
+    req: dict | None = None,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
 ):
-    """Trigger a daemon update on the SpoolBuddy device via pending_command."""
+    """Trigger a SpoolBuddy update over SSH.
+
+    Bambuddy SSHes into the device, pulls the matching branch, installs deps,
+    and restarts the daemon. Progress is broadcast via WebSocket.
+    """
+    from backend.app.services.spoolbuddy_ssh import perform_ssh_update
+
     result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
     result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
     device = result.scalar_one_or_none()
     device = result.scalar_one_or_none()
     if not device:
     if not device:
@@ -612,12 +620,11 @@ async def trigger_daemon_update(
     if device.update_status == "updating":
     if device.update_status == "updating":
         return {"status": "already_updating", "message": "Update already in progress"}
         return {"status": "already_updating", "message": "Update already in progress"}
 
 
-    device.pending_command = "update"
     device.update_status = "pending"
     device.update_status = "pending"
-    device.update_message = "Waiting for device to pick up update command..."
+    device.update_message = "Starting SSH update..."
     await db.commit()
     await db.commit()
 
 
-    logger.info("SpoolBuddy %s: update command queued", device_id)
+    logger.info("SpoolBuddy %s: SSH update triggered (ip=%s)", device_id, device.ip_address)
     await ws_manager.broadcast(
     await ws_manager.broadcast(
         {
         {
             "type": "spoolbuddy_update",
             "type": "spoolbuddy_update",
@@ -626,7 +633,24 @@ async def trigger_daemon_update(
         }
         }
     )
     )
 
 
-    return {"status": "ok", "message": "Update command sent to device"}
+    # Run the SSH update in the background
+    asyncio.create_task(perform_ssh_update(device_id, device.ip_address))
+
+    return {"status": "ok", "message": "SSH update started"}
+
+
+@router.get("/ssh/public-key")
+async def get_ssh_public_key(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+):
+    """Return the SSH public key for SpoolBuddy pairing."""
+    from backend.app.services.spoolbuddy_ssh import get_public_key
+
+    try:
+        key = await get_public_key()
+        return {"public_key": key}
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=f"Failed to get SSH key: {e}") from e
 
 
 
 
 @router.post("/devices/{device_id}/update-status")
 @router.post("/devices/{device_id}/update-status")

+ 233 - 0
backend/app/services/spoolbuddy_ssh.py

@@ -0,0 +1,233 @@
+"""SSH-based update service for SpoolBuddy devices.
+
+Instead of the daemon updating itself (fragile: permission issues, self-modifying
+code, hardcoded branch), Bambuddy SSHes into the SpoolBuddy Pi and drives the
+update remotely: git fetch/checkout, pip install, systemctl restart.
+"""
+
+import asyncio
+import logging
+import os
+import shutil
+from pathlib import Path
+
+from backend.app.core.config import settings
+
+logger = logging.getLogger(__name__)
+
+SSH_USER = "spoolbuddy"
+DEFAULT_INSTALL_PATH = "/opt/bambuddy"
+
+
+def _get_ssh_key_dir() -> Path:
+    """Return (and create if needed) the directory for SpoolBuddy SSH keys."""
+    key_dir = settings.base_dir / "spoolbuddy" / "ssh"
+    if not key_dir.exists():
+        key_dir.mkdir(mode=0o700, parents=True)
+    return key_dir
+
+
+async def get_or_create_keypair() -> tuple[Path, Path]:
+    """Return (private_key_path, public_key_path), generating if missing."""
+    key_dir = _get_ssh_key_dir()
+    private_key = key_dir / "id_ed25519"
+    public_key = key_dir / "id_ed25519.pub"
+
+    if private_key.exists() and public_key.exists():
+        return private_key, public_key
+
+    logger.info("Generating SSH keypair for SpoolBuddy updates")
+    proc = await asyncio.create_subprocess_exec(
+        "ssh-keygen",
+        "-t",
+        "ed25519",
+        "-f",
+        str(private_key),
+        "-N",
+        "",  # no passphrase
+        "-C",
+        "bambuddy-spoolbuddy",
+        stdout=asyncio.subprocess.PIPE,
+        stderr=asyncio.subprocess.PIPE,
+    )
+    _, stderr = await proc.communicate()
+    if proc.returncode != 0:
+        raise RuntimeError(f"ssh-keygen failed: {stderr.decode()[:200]}")
+
+    private_key.chmod(0o600)
+    logger.info("SSH keypair generated at %s", key_dir)
+    return private_key, public_key
+
+
+async def get_public_key() -> str:
+    """Return the SSH public key content for pairing."""
+    _, public_key = await get_or_create_keypair()
+    return public_key.read_text().strip()
+
+
+def detect_current_branch() -> str:
+    """Detect the git branch Bambuddy is running on.
+
+    For native installs, reads from the .git directory.
+    For Docker (no .git), falls back to GIT_BRANCH env var, then "main".
+    """
+    git_dir = settings.base_dir / ".git"
+    if git_dir.exists():
+        git_path = shutil.which("git") or "/usr/bin/git"
+        try:
+            import subprocess
+
+            result = subprocess.run(
+                [git_path, "rev-parse", "--abbrev-ref", "HEAD"],
+                cwd=str(settings.base_dir),
+                capture_output=True,
+                text=True,
+                timeout=5,
+            )
+            if result.returncode == 0 and result.stdout.strip():
+                return result.stdout.strip()
+        except Exception:
+            pass
+
+    return os.environ.get("GIT_BRANCH", "main")
+
+
+async def _run_ssh_command(
+    ip: str,
+    command: str,
+    private_key: Path,
+    timeout: int = 60,
+) -> tuple[int, str, str]:
+    """Execute a command on a SpoolBuddy device via SSH.
+
+    Returns (returncode, stdout, stderr).
+    """
+    ssh_path = shutil.which("ssh") or "/usr/bin/ssh"
+    proc = await asyncio.create_subprocess_exec(
+        ssh_path,
+        "-i",
+        str(private_key),
+        "-o",
+        "StrictHostKeyChecking=no",
+        "-o",
+        "UserKnownHostsFile=/dev/null",
+        "-o",
+        "ConnectTimeout=10",
+        "-o",
+        "BatchMode=yes",
+        "-o",
+        "LogLevel=ERROR",
+        f"{SSH_USER}@{ip}",
+        command,
+        stdout=asyncio.subprocess.PIPE,
+        stderr=asyncio.subprocess.PIPE,
+    )
+    try:
+        stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
+    except TimeoutError:
+        proc.kill()
+        await proc.communicate()
+        return -1, "", "SSH command timed out"
+
+    return proc.returncode, stdout.decode(), stderr.decode()
+
+
+async def perform_ssh_update(device_id: str, ip_address: str, install_path: str | None = None) -> None:
+    """SSH into a SpoolBuddy device and update it to match Bambuddy's branch.
+
+    Updates device.update_status/update_message in the DB and broadcasts
+    progress via WebSocket at each step.
+    """
+    from sqlalchemy import select
+
+    from backend.app.api.routes.spoolbuddy import ws_manager
+    from backend.app.core.database import async_session
+    from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
+
+    install_path = install_path or DEFAULT_INSTALL_PATH
+    branch = detect_current_branch()
+
+    async def _update_progress(status: str, message: str) -> None:
+        """Update device status in DB and broadcast via WebSocket."""
+        async with async_session() as db:
+            result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
+            device = result.scalar_one_or_none()
+            if device:
+                device.update_status = status
+                device.update_message = message[:255] if message else None
+                if status in ("complete", "error"):
+                    device.pending_command = None
+                await db.commit()
+
+        await ws_manager.broadcast(
+            {
+                "type": "spoolbuddy_update",
+                "device_id": device_id,
+                "update_status": status,
+                "update_message": message[:255] if message else None,
+            }
+        )
+
+    try:
+        private_key, _ = await get_or_create_keypair()
+
+        # Step 1: Test SSH connectivity
+        await _update_progress("updating", "Connecting via SSH...")
+        rc, _, stderr = await _run_ssh_command(ip_address, "echo ok", private_key)
+        if rc != 0:
+            await _update_progress("error", f"SSH connection failed: {stderr[:200]}")
+            return
+
+        # Step 2: Git fetch
+        await _update_progress("updating", f"Fetching latest code (branch: {branch})...")
+        rc, _, stderr = await _run_ssh_command(
+            ip_address,
+            f"cd {install_path} && git -c safe.directory={install_path} fetch origin {branch}",
+            private_key,
+            timeout=120,
+        )
+        if rc != 0:
+            await _update_progress("error", f"git fetch failed: {stderr[:200]}")
+            return
+
+        # Step 3: Git checkout + reset
+        await _update_progress("updating", "Applying update...")
+        rc, _, stderr = await _run_ssh_command(
+            ip_address,
+            f"cd {install_path} && git -c safe.directory={install_path} checkout {branch} "
+            f"&& git -c safe.directory={install_path} reset --hard origin/{branch}",
+            private_key,
+        )
+        if rc != 0:
+            await _update_progress("error", f"git checkout/reset failed: {stderr[:200]}")
+            return
+
+        # Step 4: Install dependencies
+        await _update_progress("updating", "Installing dependencies...")
+        venv_pip = f"{install_path}/spoolbuddy/venv/bin/pip"
+        rc, _, stderr = await _run_ssh_command(
+            ip_address,
+            f"{venv_pip} install --upgrade spidev gpiod smbus2 httpx 2>&1",
+            private_key,
+            timeout=120,
+        )
+        if rc != 0:
+            logger.warning("SpoolBuddy %s: pip install returned non-zero (continuing): %s", device_id, stderr[:200])
+
+        # Step 5: Restart service
+        await _update_progress("updating", "Restarting daemon...")
+        rc, _, stderr = await _run_ssh_command(
+            ip_address,
+            "sudo /usr/bin/systemctl restart spoolbuddy.service",
+            private_key,
+        )
+        if rc != 0:
+            await _update_progress("error", f"Service restart failed: {stderr[:200]}")
+            return
+
+        await _update_progress("complete", "Update complete, daemon restarting...")
+        logger.info("SpoolBuddy %s: SSH update complete (branch=%s)", device_id, branch)
+
+    except Exception as e:
+        logger.error("SpoolBuddy %s: SSH update failed: %s", device_id, e)
+        await _update_progress("error", f"Update failed: {str(e)[:200]}")

+ 15 - 45
backend/tests/integration/test_spoolbuddy.py

@@ -813,27 +813,19 @@ class TestDisplayEndpoints:
 class TestUpdateEndpoints:
 class TestUpdateEndpoints:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_trigger_update_queues_command(self, async_client: AsyncClient, device_factory):
+    async def test_trigger_update_starts_ssh_update(self, async_client: AsyncClient, device_factory):
         await device_factory(device_id="sb-upd")
         await device_factory(device_id="sb-upd")
 
 
-        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+        with (
+            patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
+            patch("backend.app.services.spoolbuddy_ssh.perform_ssh_update", new_callable=AsyncMock),
+        ):
             mock_ws.broadcast = AsyncMock()
             mock_ws.broadcast = AsyncMock()
             resp = await async_client.post(f"{API}/devices/sb-upd/update")
             resp = await async_client.post(f"{API}/devices/sb-upd/update")
 
 
         assert resp.status_code == 200
         assert resp.status_code == 200
         assert resp.json()["status"] == "ok"
         assert resp.json()["status"] == "ok"
 
 
-        # Verify heartbeat returns update command
-        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
-            mock_ws.broadcast = AsyncMock()
-            hb = await async_client.post(
-                f"{API}/devices/sb-upd/heartbeat",
-                json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
-            )
-
-        # update command is NOT cleared by heartbeat (cleared by update-status)
-        assert hb.json()["pending_command"] == "update"
-
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_trigger_update_offline_device_409(self, async_client: AsyncClient, device_factory):
     async def test_trigger_update_offline_device_409(self, async_client: AsyncClient, device_factory):
@@ -943,50 +935,25 @@ class TestUpdateEndpoints:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_update_check_returns_version_info(self, async_client: AsyncClient, device_factory):
     async def test_update_check_returns_version_info(self, async_client: AsyncClient, device_factory):
-        """GET /devices/{id}/update-check queries GitHub and returns version comparison."""
+        """GET /devices/{id}/update-check compares device version against APP_VERSION."""
         await device_factory(device_id="sb-uc", firmware_version="0.1.0")
         await device_factory(device_id="sb-uc", firmware_version="0.1.0")
 
 
-        mock_releases = [{"tag_name": "v0.2.0", "html_url": "https://github.com/test/releases/0.2.0"}]
-
-        mock_resp = MagicMock()
-        mock_resp.status_code = 200
-        mock_resp.json.return_value = mock_releases
-        mock_resp.raise_for_status = MagicMock()
-
-        mock_client = AsyncMock()
-        mock_client.get.return_value = mock_resp
-        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
-        mock_client.__aexit__ = AsyncMock(return_value=False)
-
-        with patch("httpx.AsyncClient", return_value=mock_client):
-            resp = await async_client.get(f"{API}/devices/sb-uc/update-check")
+        resp = await async_client.get(f"{API}/devices/sb-uc/update-check")
 
 
         assert resp.status_code == 200
         assert resp.status_code == 200
         data = resp.json()
         data = resp.json()
         assert data["current_version"] == "0.1.0"
         assert data["current_version"] == "0.1.0"
-        assert data["latest_version"] == "0.2.0"
+        assert data["latest_version"] is not None
         assert data["update_available"] is True
         assert data["update_available"] is True
-        assert data["release_url"] == "https://github.com/test/releases/0.2.0"
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_update_check_up_to_date(self, async_client: AsyncClient, device_factory):
     async def test_update_check_up_to_date(self, async_client: AsyncClient, device_factory):
-        await device_factory(device_id="sb-uc2", firmware_version="0.2.0")
-
-        mock_releases = [{"tag_name": "v0.2.0", "html_url": "https://github.com/test/releases/0.2.0"}]
+        from backend.app.core.config import APP_VERSION
 
 
-        mock_resp = MagicMock()
-        mock_resp.status_code = 200
-        mock_resp.json.return_value = mock_releases
-        mock_resp.raise_for_status = MagicMock()
+        await device_factory(device_id="sb-uc2", firmware_version=APP_VERSION)
 
 
-        mock_client = AsyncMock()
-        mock_client.get.return_value = mock_resp
-        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
-        mock_client.__aexit__ = AsyncMock(return_value=False)
-
-        with patch("httpx.AsyncClient", return_value=mock_client):
-            resp = await async_client.get(f"{API}/devices/sb-uc2/update-check")
+        resp = await async_client.get(f"{API}/devices/sb-uc2/update-check")
 
 
         assert resp.status_code == 200
         assert resp.status_code == 200
         assert resp.json()["update_available"] is False
         assert resp.json()["update_available"] is False
@@ -1002,7 +969,10 @@ class TestUpdateEndpoints:
     async def test_trigger_update_broadcasts_websocket(self, async_client: AsyncClient, device_factory):
     async def test_trigger_update_broadcasts_websocket(self, async_client: AsyncClient, device_factory):
         await device_factory(device_id="sb-upd-ws")
         await device_factory(device_id="sb-upd-ws")
 
 
-        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+        with (
+            patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
+            patch("backend.app.services.spoolbuddy_ssh.perform_ssh_update", new_callable=AsyncMock),
+        ):
             mock_ws.broadcast = AsyncMock()
             mock_ws.broadcast = AsyncMock()
             await async_client.post(f"{API}/devices/sb-upd-ws/update")
             await async_client.post(f"{API}/devices/sb-upd-ws/update")
 
 

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

@@ -5035,6 +5035,9 @@ export const spoolbuddyApi = {
       body: '{}',
       body: '{}',
     }),
     }),
 
 
+  getSSHPublicKey: () =>
+    request<{ public_key: string }>('/spoolbuddy/ssh/public-key'),
+
   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',

+ 77 - 5
frontend/src/pages/spoolbuddy/SpoolBuddySettingsPage.tsx

@@ -504,6 +504,8 @@ function UpdatesTab({ device }: { device: SpoolBuddyDevice }) {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const [applying, setApplying] = useState(false);
   const [applying, setApplying] = useState(false);
   const [error, setError] = useState<string | null>(null);
   const [error, setError] = useState<string | null>(null);
+  const [sshExpanded, setSSHExpanded] = useState(false);
+  const [copied, setCopied] = useState(false);
 
 
   const isUpdating = device.update_status === 'pending' || device.update_status === 'updating';
   const isUpdating = device.update_status === 'pending' || device.update_status === 'updating';
 
 
@@ -513,6 +515,13 @@ function UpdatesTab({ device }: { device: SpoolBuddyDevice }) {
     staleTime: 4 * 60 * 1000,
     staleTime: 4 * 60 * 1000,
   });
   });
 
 
+  const { data: sshKeyData } = useQuery({
+    queryKey: ['spoolbuddy-ssh-key'],
+    queryFn: () => spoolbuddyApi.getSSHPublicKey(),
+    enabled: sshExpanded,
+    staleTime: Infinity,
+  });
+
   const applyUpdate = async () => {
   const applyUpdate = async () => {
     setApplying(true);
     setApplying(true);
     setError(null);
     setError(null);
@@ -525,6 +534,14 @@ function UpdatesTab({ device }: { device: SpoolBuddyDevice }) {
     }
     }
   };
   };
 
 
+  const copyKey = () => {
+    if (sshKeyData?.public_key) {
+      navigator.clipboard.writeText(sshKeyData.public_key);
+      setCopied(true);
+      setTimeout(() => setCopied(false), 2000);
+    }
+  };
+
   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);
 
 
@@ -641,17 +658,72 @@ function UpdatesTab({ device }: { device: SpoolBuddyDevice }) {
                 </button>
                 </button>
               </div>
               </div>
             ) : (
             ) : (
-              <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-zinc-300">{t('spoolbuddy.settings.upToDate', 'Up to date')}</p>
+              <div className="space-y-3">
+                <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-zinc-300">{t('spoolbuddy.settings.upToDate', 'Up to date')}</p>
+                </div>
+                {/* Force update button — re-deploy even when versions match */}
+                <button
+                  onClick={applyUpdate}
+                  disabled={applying || isUpdating || !device.online}
+                  className="w-full px-4 py-2 rounded-lg text-xs font-medium bg-zinc-700 text-zinc-400 hover:bg-zinc-600 hover:text-zinc-200 disabled:opacity-40 transition-colors min-h-[36px]"
+                >
+                  {!device.online
+                    ? t('spoolbuddy.settings.deviceOffline', 'Device Offline')
+                    : t('spoolbuddy.settings.forceUpdate', 'Force Update')}
+                </button>
               </div>
               </div>
             )}
             )}
           </div>
           </div>
         )}
         )}
 
 
       </div>
       </div>
+
+      {/* SSH Setup */}
+      <div className="bg-zinc-800 rounded-lg p-4">
+        <button
+          onClick={() => setSSHExpanded(!sshExpanded)}
+          className="w-full flex justify-between items-center text-sm"
+        >
+          <span className="font-semibold text-zinc-300">
+            {t('spoolbuddy.settings.sshSetup', 'SSH Setup')}
+          </span>
+          <svg
+            className={`w-4 h-4 text-zinc-400 transition-transform ${sshExpanded ? 'rotate-180' : ''}`}
+            fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
+          >
+            <path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
+          </svg>
+        </button>
+
+        {sshExpanded && (
+          <div className="mt-3 space-y-3">
+            <p className="text-xs text-zinc-400">
+              {t('spoolbuddy.settings.sshDescription', 'Add this public key to your SpoolBuddy device to enable remote updates. Run the install script with --ssh-pubkey or add it to ~/.ssh/authorized_keys on the device.')}
+            </p>
+            {sshKeyData?.public_key ? (
+              <div className="relative">
+                <pre className="bg-zinc-900 rounded p-3 text-xs text-zinc-300 font-mono break-all whitespace-pre-wrap">
+                  {sshKeyData.public_key}
+                </pre>
+                <button
+                  onClick={copyKey}
+                  className="absolute top-2 right-2 px-2 py-1 rounded text-xs bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors"
+                >
+                  {copied ? t('common.copied', 'Copied!') : t('common.copy', 'Copy')}
+                </button>
+              </div>
+            ) : (
+              <div className="text-xs text-zinc-500 italic">
+                {t('spoolbuddy.settings.sshKeyLoading', 'Loading SSH key...')}
+              </div>
+            )}
+          </div>
+        )}
+      </div>
     </div>
     </div>
   );
   );
 }
 }

+ 1 - 97
spoolbuddy/daemon/main.py

@@ -3,7 +3,6 @@
 
 
 import asyncio
 import asyncio
 import logging
 import logging
-import shutil
 import socket
 import socket
 import sys
 import sys
 import time
 import time
@@ -123,93 +122,6 @@ 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"]
@@ -234,15 +146,7 @@ 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 == "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":
+            if 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)

+ 61 - 5
spoolbuddy/install/install.sh

@@ -16,6 +16,7 @@
 #   --api-key KEY        Bambuddy API key (required for spoolbuddy mode)
 #   --api-key KEY        Bambuddy API key (required for spoolbuddy mode)
 #   --path PATH          Installation directory (default: /opt/spoolbuddy or /opt/bambuddy)
 #   --path PATH          Installation directory (default: /opt/spoolbuddy or /opt/bambuddy)
 #   --port PORT          Bambuddy port (full mode only, default: 8000)
 #   --port PORT          Bambuddy port (full mode only, default: 8000)
+#   --ssh-pubkey KEY     Bambuddy SSH public key for remote updates
 #   --yes, -y            Non-interactive mode, accept defaults
 #   --yes, -y            Non-interactive mode, accept defaults
 #   --help, -h           Show this help message
 #   --help, -h           Show this help message
 #
 #
@@ -56,6 +57,7 @@ NON_INTERACTIVE="false"
 REBOOT_NEEDED="false"
 REBOOT_NEEDED="false"
 KIOSK_USER=""            # auto-detected from $SUDO_USER
 KIOSK_USER=""            # auto-detected from $SUDO_USER
 KIOSK_URL=""             # derived from $BAMBUDDY_URL/spoolbuddy?token=$API_KEY
 KIOSK_URL=""             # derived from $BAMBUDDY_URL/spoolbuddy?token=$API_KEY
+SSH_PUBKEY=""            # Bambuddy's SSH public key for remote updates
 
 
 # ─────────────────────────────────────────────────────────────────────────────
 # ─────────────────────────────────────────────────────────────────────────────
 # Helpers
 # Helpers
@@ -190,6 +192,7 @@ show_help() {
     echo "  --api-key KEY        Bambuddy API key (required for spoolbuddy mode)"
     echo "  --api-key KEY        Bambuddy API key (required for spoolbuddy mode)"
     echo "  --path PATH          Installation directory (default: /opt/spoolbuddy or /opt/bambuddy)"
     echo "  --path PATH          Installation directory (default: /opt/spoolbuddy or /opt/bambuddy)"
     echo "  --port PORT          Bambuddy port (full mode only, default: 8000)"
     echo "  --port PORT          Bambuddy port (full mode only, default: 8000)"
+    echo "  --ssh-pubkey KEY     Bambuddy SSH public key for remote updates"
     echo "  --yes, -y            Non-interactive mode, accept defaults"
     echo "  --yes, -y            Non-interactive mode, accept defaults"
     echo "  --help, -h           Show this help message"
     echo "  --help, -h           Show this help message"
     echo ""
     echo ""
@@ -354,9 +357,11 @@ install_system_packages() {
 create_spoolbuddy_user() {
 create_spoolbuddy_user() {
     if id "$SPOOLBUDDY_SERVICE_USER" &>/dev/null; then
     if id "$SPOOLBUDDY_SERVICE_USER" &>/dev/null; then
         info "User '$SPOOLBUDDY_SERVICE_USER' already exists"
         info "User '$SPOOLBUDDY_SERVICE_USER' already exists"
+        # Ensure existing installs get a real shell for SSH access
+        usermod --shell /bin/bash "$SPOOLBUDDY_SERVICE_USER" 2>/dev/null || true
     else
     else
         info "Creating service user '$SPOOLBUDDY_SERVICE_USER'..."
         info "Creating service user '$SPOOLBUDDY_SERVICE_USER'..."
-        useradd --system --shell /usr/sbin/nologin --home-dir "$INSTALL_PATH" "$SPOOLBUDDY_SERVICE_USER"
+        useradd --system --shell /bin/bash --home-dir "$INSTALL_PATH" "$SPOOLBUDDY_SERVICE_USER"
         success "Service user created"
         success "Service user created"
     fi
     fi
 
 
@@ -367,6 +372,12 @@ create_spoolbuddy_user() {
         fi
         fi
     done
     done
     success "User added to gpio, spi, i2c, video groups"
     success "User added to gpio, spi, i2c, video groups"
+
+    # Allow passwordless restart of the daemon (needed for SSH-based updates from Bambuddy)
+    echo "$SPOOLBUDDY_SERVICE_USER ALL=(root) NOPASSWD: /usr/bin/systemctl restart spoolbuddy.service" \
+        > /etc/sudoers.d/spoolbuddy
+    chmod 440 /etc/sudoers.d/spoolbuddy
+    success "Sudoers entry created for service restart"
 }
 }
 
 
 download_spoolbuddy() {
 download_spoolbuddy() {
@@ -376,13 +387,9 @@ download_spoolbuddy() {
         cd "$INSTALL_PATH"
         cd "$INSTALL_PATH"
         run_with_progress "Fetching updates" git fetch origin
         run_with_progress "Fetching updates" git fetch origin
         git reset --hard origin/main > /dev/null 2>&1
         git reset --hard origin/main > /dev/null 2>&1
-	cd "$INSTALL_PATH"
-        git checkout 0.2.2b1
     else
     else
         mkdir -p "$INSTALL_PATH"
         mkdir -p "$INSTALL_PATH"
         run_with_progress "Cloning repository" git clone "$GITHUB_REPO" "$INSTALL_PATH"
         run_with_progress "Cloning repository" git clone "$GITHUB_REPO" "$INSTALL_PATH"
-        cd "$INSTALL_PATH"
-        git checkout 0.2.2b1
     fi
     fi
 
 
     chown -R "$SPOOLBUDDY_SERVICE_USER:$SPOOLBUDDY_SERVICE_USER" "$INSTALL_PATH"
     chown -R "$SPOOLBUDDY_SERVICE_USER:$SPOOLBUDDY_SERVICE_USER" "$INSTALL_PATH"
@@ -419,6 +426,50 @@ EOF
     success "Configuration saved to $env_file"
     success "Configuration saved to $env_file"
 }
 }
 
 
+setup_ssh_key() {
+    info "Setting up SSH access for Bambuddy remote updates..."
+
+    local ssh_dir="$INSTALL_PATH/.ssh"
+    local auth_keys="$ssh_dir/authorized_keys"
+
+    mkdir -p "$ssh_dir"
+    chmod 700 "$ssh_dir"
+
+    # Get the public key from flag, prompt, or skip
+    local pubkey="$SSH_PUBKEY"
+
+    if [[ -z "$pubkey" && "$NON_INTERACTIVE" != "true" ]]; then
+        echo ""
+        echo -e "${BOLD}SSH Public Key for Remote Updates${NC}"
+        echo ""
+        echo "  Bambuddy can update SpoolBuddy remotely over SSH."
+        echo "  To enable this, paste the SSH public key from:"
+        echo "    Bambuddy → SpoolBuddy Settings → SSH Setup"
+        echo ""
+        echo "  (You can also set this up later by re-running the installer"
+        echo "   with --ssh-pubkey or by manually adding the key)"
+        echo ""
+        read -rp "  Paste SSH public key (or press Enter to skip): " pubkey
+    fi
+
+    if [[ -n "$pubkey" ]]; then
+        # Append key if not already present
+        if [[ -f "$auth_keys" ]] && grep -qF "$pubkey" "$auth_keys" 2>/dev/null; then
+            info "SSH key already present in authorized_keys"
+        else
+            echo "$pubkey" >> "$auth_keys"
+            success "SSH public key added"
+        fi
+    else
+        info "No SSH key provided, skipping (can be added later)"
+        # Ensure the file exists even if empty
+        touch "$auth_keys"
+    fi
+
+    chmod 600 "$auth_keys"
+    chown -R "$SPOOLBUDDY_SERVICE_USER:$SPOOLBUDDY_SERVICE_USER" "$ssh_dir"
+}
+
 create_spoolbuddy_service() {
 create_spoolbuddy_service() {
     info "Creating SpoolBuddy systemd service..."
     info "Creating SpoolBuddy systemd service..."
 
 
@@ -871,6 +922,10 @@ parse_args() {
                 BAMBUDDY_PORT="$2"
                 BAMBUDDY_PORT="$2"
                 shift 2
                 shift 2
                 ;;
                 ;;
+            --ssh-pubkey)
+                SSH_PUBKEY="$2"
+                shift 2
+                ;;
             --yes|-y)
             --yes|-y)
                 NON_INTERACTIVE="true"
                 NON_INTERACTIVE="true"
                 shift
                 shift
@@ -1058,6 +1113,7 @@ main() {
     info "Setting up SpoolBuddy..."
     info "Setting up SpoolBuddy..."
     setup_spoolbuddy_venv
     setup_spoolbuddy_venv
     create_spoolbuddy_env
     create_spoolbuddy_env
+    setup_ssh_key
     create_spoolbuddy_service
     create_spoolbuddy_service
     echo ""
     echo ""
 
 

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-ABN02nc3.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-Dn6SXRdj.js"></script>
+    <script type="module" crossorigin src="/assets/index-ABN02nc3.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>

Some files were not shown because too many files changed in this diff