Forráskód Böngészése

feat(updates): detect HA Supervisor addon and defer update UI to it (#1167)

  Bambuddy already supports running as a Home Assistant addon
  (HA_URL/HA_TOKEN env-var integration since #283, community addon at
  hobbypunk90/homeassistant-addon-bambuddy), but the update UI was
  oblivious to it: HA addon users saw the in-app "Update available"
  banner and, on Settings, the docker-compose snippet — neither of
  which they can act on, since the HA Supervisor owns the addon
  lifecycle.

  Detection uses the SUPERVISOR_TOKEN env var that HA Supervisor
  injects into every addon container; no other environment sets it,
  so the check has zero false-positive surface.

  Backend:
    - new _is_ha_addon() helper in routes/updates.py
    - /updates/check now returns is_ha_addon: bool and extends
      update_method to 'git' | 'docker' | 'ha_addon'
    - /updates/apply checks HA before Docker (HA addons ARE Docker
      containers, so checking docker first would mis-classify) and
      returns an HA-specific message that points to Settings →
      Add-ons → Bambuddy in HA
    - response keeps is_docker: true alongside is_ha_addon: true so
      older frontend bundles still hit a managed-deployment branch
      instead of rendering an Install button that can't work

  Frontend:
    - SettingsPage update card branches on is_ha_addon BEFORE
      is_docker; HA users get a Supervisor-targeted message instead
      of the docker-compose snippet
    - Layout update banner is suppressed for HA addons — HA
      Supervisor surfaces its own update notification natively, so
      Bambuddy's banner would be duplicate noise linking to a page
      that just says "update via HA"
    - Plain Docker deployments are unaffected

  i18n: settings.updateViaHomeAssistant added to all 8 locales with
  full native translations.

  Tests: 3 backend unit tests for _is_ha_addon (present, absent,
  empty-string treated as unset), 3 backend integration tests
  (HA-precedes-Docker rejection on apply; HA branch on check; plain
  Docker branch on check), 2 SettingsPage tests pinning the
  mutually-exclusive UI rendering, 2 Layout tests pinning banner
  suppression for HA and retention for plain Docker.
maziggy 4 hónapja
szülő
commit
4aea4be2bd

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 0 - 0
CHANGELOG.md


+ 33 - 2
backend/app/api/routes/updates.py

@@ -54,6 +54,16 @@ def _is_docker_environment() -> bool:
     return False
 
 
+def _is_ha_addon() -> bool:
+    """Detect if running as a Home Assistant Supervisor addon.
+
+    HA Supervisor injects ``SUPERVISOR_TOKEN`` into every addon container;
+    the variable is not set in any other environment, so a single env-var
+    check is sufficient with no false-positive surface.
+    """
+    return bool(os.environ.get("SUPERVISOR_TOKEN"))
+
+
 def _find_executable(name: str) -> str | None:
     """Find an executable in PATH or common locations."""
     # Try standard PATH first
@@ -355,6 +365,13 @@ async def check_for_updates(
             }
 
             is_docker = _is_docker_environment()
+            is_ha_addon = _is_ha_addon()
+            if is_ha_addon:
+                update_method = "ha_addon"
+            elif is_docker:
+                update_method = "docker"
+            else:
+                update_method = "git"
             return {
                 "update_available": update_available,
                 "current_version": APP_VERSION,
@@ -364,7 +381,8 @@ async def check_for_updates(
                 "release_url": release_url,
                 "published_at": published_at,
                 "is_docker": is_docker,
-                "update_method": "docker" if is_docker else "git",
+                "is_ha_addon": is_ha_addon,
+                "update_method": update_method,
             }
 
     except httpx.HTTPError as e:
@@ -669,7 +687,20 @@ async def apply_update(
             "status": _update_status,
         }
 
-    # Check if running in Docker
+    # Check for managed deployment shapes that own the update lifecycle.
+    # HA addons are also Docker, so check HA first to surface the more
+    # specific message.
+    if _is_ha_addon():
+        return {
+            "success": False,
+            "is_ha_addon": True,
+            "is_docker": True,
+            "message": (
+                "Bambuddy is running as a Home Assistant addon. "
+                "Updates are managed by the Home Assistant Supervisor "
+                "(Settings → Add-ons → Bambuddy → Update)."
+            ),
+        }
     if _is_docker_environment():
         return {
             "success": False,

+ 140 - 1
backend/tests/integration/test_updates_api.py

@@ -15,11 +15,34 @@ class TestUpdatesAPI:
 
     @pytest.mark.asyncio
     async def test_apply_update_docker_rejection(self, async_client: AsyncClient):
-        with patch("backend.app.api.routes.updates._is_docker_environment", return_value=True):
+        with (
+            patch("backend.app.api.routes.updates._is_ha_addon", return_value=False),
+            patch("backend.app.api.routes.updates._is_docker_environment", return_value=True),
+        ):
             response = await async_client.post("/api/v1/updates/apply")
         result = response.json()
         assert result["success"] is False
         assert result["is_docker"] is True
+        assert result.get("is_ha_addon") is not True
+        # Docker message tells the user to docker compose, not HA.
+        assert "Docker Compose" in result["message"]
+
+    @pytest.mark.asyncio
+    async def test_apply_update_ha_addon_rejection(self, async_client: AsyncClient):
+        """HA addons are also Docker, so the route must check HA first and
+        return the HA-specific message — otherwise users see "run docker
+        compose" advice they can't follow."""
+        with (
+            patch("backend.app.api.routes.updates._is_ha_addon", return_value=True),
+            patch("backend.app.api.routes.updates._is_docker_environment", return_value=True),
+        ):
+            response = await async_client.post("/api/v1/updates/apply")
+        result = response.json()
+        assert result["success"] is False
+        assert result["is_ha_addon"] is True
+        assert result["is_docker"] is True
+        assert "Home Assistant" in result["message"]
+        assert "Docker Compose" not in result["message"]
 
     @pytest.mark.asyncio
     async def test_apply_update_non_docker(self, async_client: AsyncClient):
@@ -27,6 +50,7 @@ class TestUpdatesAPI:
         to prevent side effects (network call to GitHub releases API + actual
         git/pip subprocesses)."""
         with (
+            patch("backend.app.api.routes.updates._is_ha_addon", return_value=False),
             patch("backend.app.api.routes.updates._is_docker_environment", return_value=False),
             patch(
                 "backend.app.api.routes.updates._discover_target_release",
@@ -44,6 +68,119 @@ class TestUpdatesAPI:
         with patch("os.path.exists", return_value=True):
             assert _is_docker_environment() is True
 
+    def test_is_ha_addon_detects_supervisor_token(self):
+        """HA Supervisor sets SUPERVISOR_TOKEN on every addon container.
+        That env-var alone is the canonical HA-addon signal."""
+        from backend.app.api.routes.updates import _is_ha_addon
+
+        with patch.dict("os.environ", {"SUPERVISOR_TOKEN": "abc123"}, clear=False):
+            assert _is_ha_addon() is True
+
+    def test_is_ha_addon_false_outside_supervisor(self):
+        from backend.app.api.routes.updates import _is_ha_addon
+
+        with patch.dict("os.environ", {}, clear=True):
+            assert _is_ha_addon() is False
+
+    def test_is_ha_addon_empty_token_treated_as_unset(self):
+        """An empty string is not a real token — guard against shells that
+        export the variable empty."""
+        from backend.app.api.routes.updates import _is_ha_addon
+
+        with patch.dict("os.environ", {"SUPERVISOR_TOKEN": ""}, clear=False):
+            assert _is_ha_addon() is False
+
+    @pytest.mark.asyncio
+    async def test_check_returns_ha_addon_flag_and_method(self, async_client: AsyncClient):
+        """`/updates/check` must surface the deployment shape so the frontend
+        can pick the right CTA. HA must take precedence over Docker because
+        HA addons run *inside* a Docker container — checking docker first
+        would mis-classify them."""
+        import httpx as _httpx
+
+        fake_release = {
+            "tag_name": "v999.9.9",
+            "name": "Far Future Release",
+            "body": "",
+            "html_url": "https://example.invalid/r",
+            "published_at": "2099-01-01T00:00:00Z",
+        }
+
+        class _Resp:
+            status_code = 200
+
+            def raise_for_status(self):
+                return None
+
+            def json(self):
+                return [fake_release]
+
+        class _FakeClient:
+            async def __aenter__(self):
+                return self
+
+            async def __aexit__(self, *_):
+                return None
+
+            async def get(self, *_, **__):
+                return _Resp()
+
+        with (
+            patch.object(_httpx, "AsyncClient", _FakeClient),
+            patch("backend.app.api.routes.updates._is_ha_addon", return_value=True),
+            patch("backend.app.api.routes.updates._is_docker_environment", return_value=True),
+        ):
+            response = await async_client.get("/api/v1/updates/check")
+        body = response.json()
+        assert body["is_ha_addon"] is True
+        assert body["update_method"] == "ha_addon"
+        # is_docker is preserved alongside so older frontend bundles still
+        # hit a managed-deployment branch (degrades to Docker UX) instead of
+        # rendering the in-app Install button.
+        assert body["is_docker"] is True
+
+    @pytest.mark.asyncio
+    async def test_check_docker_only_returns_docker_method(self, async_client: AsyncClient):
+        import httpx as _httpx
+
+        fake_release = {
+            "tag_name": "v999.9.9",
+            "name": "Far Future Release",
+            "body": "",
+            "html_url": "https://example.invalid/r",
+            "published_at": "2099-01-01T00:00:00Z",
+        }
+
+        class _Resp:
+            status_code = 200
+
+            def raise_for_status(self):
+                return None
+
+            def json(self):
+                return [fake_release]
+
+        class _FakeClient:
+            async def __aenter__(self):
+                return self
+
+            async def __aexit__(self, *_):
+                return None
+
+            async def get(self, *_, **__):
+                return _Resp()
+
+        with (
+            patch.object(_httpx, "AsyncClient", _FakeClient),
+            patch("backend.app.api.routes.updates._is_ha_addon", return_value=False),
+            patch("backend.app.api.routes.updates._is_docker_environment", return_value=True),
+        ):
+            response = await async_client.get("/api/v1/updates/check")
+        body = response.json()
+        assert body["is_ha_addon"] is False
+        assert body["is_docker"] is True
+        assert body["update_method"] == "docker"
+
     def test_parse_version(self):
         from backend.app.api.routes.updates import parse_version
 
@@ -261,6 +398,7 @@ class TestUpdatesAPI:
             return "v0.2.4b1"
 
         with (
+            patch.object(updates_module, "_is_ha_addon", return_value=False),
             patch.object(updates_module, "_is_docker_environment", return_value=False),
             patch.object(updates_module, "_perform_update", side_effect=fake_perform_update),
             patch.object(updates_module, "_discover_target_release", side_effect=fake_discover),
@@ -289,6 +427,7 @@ class TestUpdatesAPI:
         updates_module._update_status = {"status": "idle", "progress": 0, "message": "", "error": None}
 
         with (
+            patch.object(updates_module, "_is_ha_addon", return_value=False),
             patch.object(updates_module, "_is_docker_environment", return_value=False),
             patch.object(updates_module, "_discover_target_release", side_effect=fake_discover),
         ):

+ 50 - 0
frontend/src/__tests__/components/Layout.test.tsx

@@ -255,4 +255,54 @@ describe('Layout', () => {
       });
     });
   });
+
+  describe('update banner suppression for HA addon', () => {
+    // HA Supervisor surfaces its own update notification natively in the HA
+    // UI, so the in-app banner would be duplicate noise that links to a page
+    // that just says "update via HA". Suppress it for HA addon deployments.
+    it('hides the update-available banner when running as an HA addon', async () => {
+      server.use(
+        http.get('/api/v1/updates/check', () => {
+          return HttpResponse.json({
+            update_available: true,
+            current_version: '0.2.4',
+            latest_version: '0.2.5',
+            is_docker: true,
+            is_ha_addon: true,
+            update_method: 'ha_addon',
+          });
+        }),
+      );
+
+      render(<Layout />);
+
+      await waitFor(() => {
+        const sidebar = document.querySelector('aside');
+        expect(sidebar).toBeInTheDocument();
+      });
+
+      expect(document.body.textContent).not.toContain('Update available');
+    });
+
+    it('still shows the update-available banner for plain Docker deployments', async () => {
+      server.use(
+        http.get('/api/v1/updates/check', () => {
+          return HttpResponse.json({
+            update_available: true,
+            current_version: '0.2.4',
+            latest_version: '0.2.5',
+            is_docker: true,
+            is_ha_addon: false,
+            update_method: 'docker',
+          });
+        }),
+      );
+
+      render(<Layout />);
+
+      await waitFor(() => {
+        expect(document.body.textContent).toContain('0.2.5');
+      });
+    });
+  });
 });

+ 67 - 0
frontend/src/__tests__/pages/SettingsPage.test.tsx

@@ -174,6 +174,73 @@ describe('SettingsPage', () => {
     });
   });
 
+  describe('update CTA per deployment shape', () => {
+    // The update card branches on the deployment shape returned by
+    // /updates/check. Each branch is mutually exclusive — verify the right
+    // one wins so HA addon users never see the docker-compose snippet
+    // (which they can't run from inside an HA addon container) and Docker
+    // users never see the in-app Install button (which would no-op).
+    const renderWithUpdateCheck = async (
+      checkBody: Record<string, unknown>,
+    ) => {
+      server.use(
+        http.get('/api/v1/settings/', () =>
+          HttpResponse.json({ ...mockSettings, check_updates: true }),
+        ),
+        http.get('/api/v1/updates/check', () => HttpResponse.json(checkBody)),
+      );
+      render(<SettingsPage />);
+      await waitFor(() => {
+        expect(screen.getByText('Updates')).toBeInTheDocument();
+      });
+    };
+
+    it('shows the HA Supervisor message when running as an HA addon', async () => {
+      await renderWithUpdateCheck({
+        update_available: true,
+        current_version: '0.2.4',
+        latest_version: '0.2.5',
+        release_name: '0.2.5',
+        release_notes: '',
+        release_url: 'https://example.invalid/r',
+        published_at: '2099-01-01T00:00:00Z',
+        is_docker: true,
+        is_ha_addon: true,
+        update_method: 'ha_addon',
+      });
+
+      await waitFor(() => {
+        expect(
+          screen.getByText(/Home Assistant Supervisor/i),
+        ).toBeInTheDocument();
+      });
+      // Docker hint must NOT render — HA branch wins.
+      expect(screen.queryByText('docker compose pull && docker compose up -d')).not.toBeInTheDocument();
+      expect(screen.queryByRole('button', { name: /install update/i })).not.toBeInTheDocument();
+    });
+
+    it('shows the docker-compose snippet for Docker (non-HA) deployments', async () => {
+      await renderWithUpdateCheck({
+        update_available: true,
+        current_version: '0.2.4',
+        latest_version: '0.2.5',
+        release_name: '0.2.5',
+        release_notes: '',
+        release_url: 'https://example.invalid/r',
+        published_at: '2099-01-01T00:00:00Z',
+        is_docker: true,
+        is_ha_addon: false,
+        update_method: 'docker',
+      });
+
+      await waitFor(() => {
+        expect(screen.getByText('docker compose pull && docker compose up -d')).toBeInTheDocument();
+      });
+      expect(screen.queryByText(/Home Assistant Supervisor/i)).not.toBeInTheDocument();
+      expect(screen.queryByRole('button', { name: /install update/i })).not.toBeInTheDocument();
+    });
+  });
+
   describe('tabs navigation', () => {
     it('can switch to Network tab', async () => {
       const user = userEvent.setup();

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

@@ -2354,7 +2354,8 @@ export interface UpdateCheckResult {
   error?: string;
   message?: string;
   is_docker?: boolean;
-  update_method?: 'docker' | 'git';
+  is_ha_addon?: boolean;
+  update_method?: 'docker' | 'git' | 'ha_addon';
 }
 
 export interface UpdateStatus {
@@ -4417,7 +4418,7 @@ export const api = {
   getVersion: () => request<VersionInfo>('/updates/version'),
   checkForUpdates: () => request<UpdateCheckResult>('/updates/check'),
   applyUpdate: () =>
-    request<{ success: boolean; message: string; status?: UpdateStatus; is_docker?: boolean }>('/updates/apply', {
+    request<{ success: boolean; message: string; status?: UpdateStatus; is_docker?: boolean; is_ha_addon?: boolean }>('/updates/apply', {
       method: 'POST',
     }),
   getUpdateStatus: () => request<UpdateStatus>('/updates/status'),

+ 6 - 2
frontend/src/components/Layout.tsx

@@ -377,10 +377,14 @@ export function Layout() {
     setDragOverId(null);
   };
 
-  // Show update banner if update available and not dismissed for this version
+  // Show update banner if update available and not dismissed for this version.
+  // Suppressed when running as a Home Assistant addon — HA Supervisor surfaces
+  // its own update notification in the HA UI, so the in-app banner is duplicate
+  // noise that links to a page that just says "update via HA."
   const showUpdateBanner = updateCheck?.update_available &&
     updateCheck.latest_version &&
-    updateCheck.latest_version !== dismissedUpdateVersion;
+    updateCheck.latest_version !== dismissedUpdateVersion &&
+    !updateCheck.is_ha_addon;
 
   const dismissUpdateBanner = () => {
     if (updateCheck?.latest_version) {

+ 1 - 0
frontend/src/i18n/locales/de.ts

@@ -2068,6 +2068,7 @@ export default {
     updateAvailableVersion: 'Update verfügbar: v{{version}}',
     releaseNotes: 'Versionshinweise',
     updateViaDocker: 'Update über Docker Compose:',
+    updateViaHomeAssistant: 'Updates werden vom Home Assistant Supervisor verwaltet. Öffne Einstellungen → Add-ons → Bambuddy in Home Assistant, um die neue Version zu installieren.',
     installUpdate: 'Update installieren',
     latestVersionRunning: 'Sie verwenden die neueste Version',
     failedToCheckUpdates: 'Update-Prüfung fehlgeschlagen: {{error}}',

+ 1 - 0
frontend/src/i18n/locales/en.ts

@@ -2071,6 +2071,7 @@ export default {
     updateAvailableVersion: 'Update available: v{{version}}',
     releaseNotes: 'Release Notes',
     updateViaDocker: 'Update via Docker Compose:',
+    updateViaHomeAssistant: 'Updates are managed by the Home Assistant Supervisor. Open Settings → Add-ons → Bambuddy in Home Assistant to install the new version.',
     installUpdate: 'Install Update',
     latestVersionRunning: "You're running the latest version",
     failedToCheckUpdates: 'Failed to check for updates: {{error}}',

+ 1 - 0
frontend/src/i18n/locales/fr.ts

@@ -2019,6 +2019,7 @@ export default {
     updateAvailableVersion: 'Mise à jour disponible : v{{version}}',
     releaseNotes: 'Notes de version',
     updateViaDocker: 'Mettre à jour via Docker Compose :',
+    updateViaHomeAssistant: 'Les mises à jour sont gérées par le superviseur Home Assistant. Ouvrez Paramètres → Modules complémentaires → Bambuddy dans Home Assistant pour installer la nouvelle version.',
     installUpdate: 'Installer la mise à jour',
     latestVersionRunning: 'Vous utilisez la dernière version',
     failedToCheckUpdates: 'Échec de la vérification des mises à jour : {{error}}',

+ 1 - 0
frontend/src/i18n/locales/it.ts

@@ -2018,6 +2018,7 @@ export default {
     updateAvailableVersion: 'Aggiornamento disponibile: v{{version}}',
     releaseNotes: 'Note di rilascio',
     updateViaDocker: 'Aggiorna tramite Docker Compose:',
+    updateViaHomeAssistant: 'Gli aggiornamenti sono gestiti dal Supervisor di Home Assistant. Apri Impostazioni → Add-on → Bambuddy in Home Assistant per installare la nuova versione.',
     installUpdate: 'Installa aggiornamento',
     latestVersionRunning: 'Stai usando l\'ultima versione',
     failedToCheckUpdates: 'Controllo aggiornamenti fallito: {{error}}',

+ 1 - 0
frontend/src/i18n/locales/ja.ts

@@ -2067,6 +2067,7 @@ export default {
     updateAvailableVersion: 'アップデート利用可能: v{{version}}',
     releaseNotes: 'リリースノート',
     updateViaDocker: 'Docker Composeでアップデート:',
+    updateViaHomeAssistant: 'アップデートはHome Assistant Supervisorによって管理されます。Home Assistantの設定→アドオン→Bambuddyを開いて新しいバージョンをインストールしてください。',
     installUpdate: 'アップデートをインストール',
     latestVersionRunning: '最新バージョンを使用しています',
     failedToCheckUpdates: 'アップデートの確認に失敗しました: {{error}}',

+ 1 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -2018,6 +2018,7 @@ export default {
     updateAvailableVersion: 'Atualização disponível: v{{version}}',
     releaseNotes: 'Notas da versão',
     updateViaDocker: 'Atualizar via Docker Compose:',
+    updateViaHomeAssistant: 'As atualizações são gerenciadas pelo Supervisor do Home Assistant. Abra Configurações → Complementos → Bambuddy no Home Assistant para instalar a nova versão.',
     installUpdate: 'Instalar atualização',
     latestVersionRunning: 'Você está usando a versão mais recente',
     failedToCheckUpdates: 'Falha ao verificar atualizações: {{error}}',

+ 1 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -2062,6 +2062,7 @@ export default {
     updateAvailableVersion: '可用更新:v{{version}}',
     releaseNotes: '发布说明',
     updateViaDocker: '通过 Docker Compose 更新:',
+    updateViaHomeAssistant: '更新由 Home Assistant Supervisor 管理。请在 Home Assistant 中打开 设置 → 加载项 → Bambuddy 以安装新版本。',
     installUpdate: '安装更新',
     latestVersionRunning: '您正在运行最新版本',
     failedToCheckUpdates: '检查更新失败:{{error}}',

+ 1 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -2062,6 +2062,7 @@ export default {
     updateAvailableVersion: '可用更新:v{{version}}',
     releaseNotes: '發布說明',
     updateViaDocker: '透過 Docker Compose 更新:',
+    updateViaHomeAssistant: '更新由 Home Assistant Supervisor 管理。請在 Home Assistant 中開啟 設定 → 附加元件 → Bambuddy 以安裝新版本。',
     installUpdate: '安裝更新',
     latestVersionRunning: '您正在執行最新版本',
     failedToCheckUpdates: '檢查更新失敗:{{error}}',

+ 7 - 1
frontend/src/pages/SettingsPage.tsx

@@ -769,7 +769,7 @@ export function SettingsPage() {
   const applyUpdateMutation = useMutation({
     mutationFn: api.applyUpdate,
     onSuccess: (data) => {
-      if (data.is_docker) {
+      if (data.is_ha_addon || data.is_docker) {
         showToast(data.message, 'error');
       } else {
         refetchUpdateStatus();
@@ -2271,6 +2271,12 @@ export function SettingsPage() {
                       <div className="mt-3 p-2 bg-red-500/20 rounded text-sm text-red-400">
                         {updateStatus.error || updateStatus.message}
                       </div>
+                    ) : updateCheck?.is_ha_addon ? (
+                      <div className="mt-3 p-3 bg-bambu-dark-tertiary rounded-lg">
+                        <p className="text-sm text-bambu-gray">
+                          {t('settings.updateViaHomeAssistant')}
+                        </p>
+                      </div>
                     ) : updateCheck?.is_docker ? (
                       <div className="mt-3 p-3 bg-bambu-dark-tertiary rounded-lg">
                         <p className="text-sm text-bambu-gray mb-2">

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 0 - 0
static/assets/index-D4GSGeyj.js


+ 1 - 1
static/index.html

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

Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott