Parcourir la source

feat(notifications): custom data fields for Home Assistant notify services (#1441)

When an HA notification provider targets a notify service (e.g.
notify.mobile_app_myphone), a new optional Data (JSON) field is
forwarded as the service call's nested "data" object - the same
place HA automations put mobile push options like priority, ttl,
channel, and group. ttl: 0 + priority: high make Android pushes
arrive immediately; channel gives printer alerts their own sound.

JSON rather than key=value lines so numbers stay numbers and nested
options work. Validated on both ends: the UI rejects malformed JSON
before saving, and the sender fails loudly instead of posting a
half-built payload. Only included when configured - the default
persistent_notification.create path is unchanged, as its schema
rejects unknown keys.
maziggy il y a 1 mois
Parent
commit
49f9d7120d

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.2.6b1] - Unreleased
 
 ### Added
+- **Home Assistant notifications can carry custom data fields (#1441)** — When a notification provider targets an HA notify service (e.g. `notify.mobile_app_myphone`), a new optional **Data (JSON)** field is forwarded as the service call's nested `data` object — the same place HA automations put mobile push options like `priority`, `ttl`, `channel`, and `group`. `ttl: 0` + `priority: high` make Android pushes arrive immediately instead of batched, and `channel` gives printer alerts their own notification channel/sound. The field is JSON (not key=value lines) so numbers stay numbers (`ttl: 0`) and nested options work. Validated on both ends: the UI rejects malformed JSON before saving, and the sender fails loudly with a clear message rather than posting a half-built payload. Only included when configured — the default persistent-notification path is unchanged, as its schema rejects unknown keys. Translated in all locales; wiki updated. Covered by backend and frontend tests.
 - **Energy usage now feeds the statistics that previously only knew about filament (#1432)** — Bambuddy has measured per-print energy via an attached smart plug for a while (the plug's lifetime counter is captured at print start and the delta stored with the print), but two stats surfaces ignored it. First, the **Most Expensive** record on the Statistics page ranked prints by filament cost alone, so a cheap-filament print with hours of heated-chamber time could never win; it now ranks by filament + measured energy cost (prints without a smart plug simply compete on filament cost, as before). Second, **Filament Trends** gained an **Energy Over Time** chart — per-day kWh (per-hour for short ranges, per-week for long ones), with the range's total kWh and energy cost in the header. The chart only appears when the selected range actually contains measured energy data, so setups without smart plugs see no change. The `/archives/slim` stats feed now carries each run's `energy_kwh`/`energy_cost`. Translated in all locales. Covered by backend and frontend tests.
 
 ### Fixed

+ 18 - 0
backend/app/services/notification_service.py

@@ -741,6 +741,24 @@ class NotificationService:
             "message": message,
         }
 
+        # Optional custom service-data (#1441), forwarded as HA's nested "data"
+        # object so mobile-app push options (priority, ttl, channel, group, ...)
+        # reach the notify service. Only included when configured — the default
+        # persistent_notification.create schema rejects unknown keys.
+        raw_data = config.get("data")
+        if raw_data:
+            if isinstance(raw_data, str):
+                try:
+                    parsed_data = json.loads(raw_data)
+                except json.JSONDecodeError as e:
+                    return False, f"Invalid JSON in the Data field: {e}"
+            else:
+                parsed_data = raw_data
+            if not isinstance(parsed_data, dict):
+                return False, 'The Data field must be a JSON object, e.g. {"priority": "high", "ttl": 0}'
+            if parsed_data:
+                payload["data"] = parsed_data
+
         client = await self._get_client()
         response = await client.post(url, json=payload, headers=headers)
 

+ 107 - 0
backend/tests/unit/services/test_notification_service.py

@@ -877,6 +877,113 @@ class TestHomeAssistantProvider:
             assert payload["title"] == "Test Title"
             assert payload["message"] == "Test Message"
 
+    @pytest.mark.asyncio
+    async def test_send_homeassistant_custom_data_merged(self, service):
+        """Custom service-data (#1441) is forwarded as HA's nested "data" object
+        so mobile-app push options (priority, ttl, channel, ...) reach the
+        notify service."""
+        mock_response = MagicMock()
+        mock_response.status_code = 200
+
+        mock_client = AsyncMock()
+        mock_client.post = AsyncMock(return_value=mock_response)
+
+        mock_db = AsyncMock()
+
+        with (
+            patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get_client,
+            patch(
+                "backend.app.api.routes.settings.get_homeassistant_settings",
+                new_callable=AsyncMock,
+            ) as mock_ha_settings,
+        ):
+            mock_get_client.return_value = mock_client
+            mock_ha_settings.return_value = {
+                "ha_url": "http://ha.local:8123",
+                "ha_token": "test-token-123",
+                "ha_enabled": True,
+            }
+
+            config = {
+                "service": "notify.mobile_app_myphone",
+                "data": '{"priority": "high", "ttl": 0, "channel": "3D Printing"}',
+            }
+            success, _ = await service._send_homeassistant(config, "Title", "Body", db=mock_db)
+
+            assert success is True
+            call_args = mock_client.post.call_args
+            assert call_args[0][0] == "http://ha.local:8123/api/services/notify/mobile_app_myphone"
+            payload = call_args.kwargs.get("json") or call_args[1].get("json")
+            assert payload["data"] == {"priority": "high", "ttl": 0, "channel": "3D Printing"}
+            # ttl must survive as a number, not a string — that's why the
+            # field is JSON rather than key=value lines.
+            assert payload["data"]["ttl"] == 0
+
+    @pytest.mark.asyncio
+    async def test_send_homeassistant_without_data_omits_key(self, service):
+        """Without configured data the payload carries no "data" key — the
+        default persistent_notification.create schema rejects unknown keys."""
+        mock_response = MagicMock()
+        mock_response.status_code = 200
+
+        mock_client = AsyncMock()
+        mock_client.post = AsyncMock(return_value=mock_response)
+
+        mock_db = AsyncMock()
+
+        with (
+            patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get_client,
+            patch(
+                "backend.app.api.routes.settings.get_homeassistant_settings",
+                new_callable=AsyncMock,
+            ) as mock_ha_settings,
+        ):
+            mock_get_client.return_value = mock_client
+            mock_ha_settings.return_value = {
+                "ha_url": "http://ha.local:8123",
+                "ha_token": "test-token-123",
+                "ha_enabled": True,
+            }
+
+            success, _ = await service._send_homeassistant({}, "Title", "Body", db=mock_db)
+
+            assert success is True
+            payload = mock_client.post.call_args.kwargs.get("json") or mock_client.post.call_args[1].get("json")
+            assert "data" not in payload
+
+    @pytest.mark.asyncio
+    async def test_send_homeassistant_invalid_data_rejected(self, service):
+        """Malformed JSON and non-object JSON in the data field fail loudly
+        instead of sending a half-built payload."""
+        mock_db = AsyncMock()
+
+        with (
+            patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get_client,
+            patch(
+                "backend.app.api.routes.settings.get_homeassistant_settings",
+                new_callable=AsyncMock,
+            ) as mock_ha_settings,
+        ):
+            mock_client = AsyncMock()
+            mock_get_client.return_value = mock_client
+            mock_ha_settings.return_value = {
+                "ha_url": "http://ha.local:8123",
+                "ha_token": "test-token-123",
+                "ha_enabled": True,
+            }
+
+            success, message = await service._send_homeassistant(
+                {"data": "{priority: high}"}, "Title", "Body", db=mock_db
+            )
+            assert success is False
+            assert "Invalid JSON" in message
+
+            success, message = await service._send_homeassistant({"data": '["a", "b"]'}, "Title", "Body", db=mock_db)
+            assert success is False
+            assert "JSON object" in message
+
+            mock_client.post.assert_not_called()
+
     @pytest.mark.asyncio
     async def test_send_homeassistant_no_db_no_env(self, service):
         """Verify HA provider fails gracefully without DB or env vars."""

+ 63 - 0
frontend/src/__tests__/components/AddNotificationModal.test.tsx

@@ -386,3 +386,66 @@ describe('AddNotificationModal — AI Failure Detection toggle (#1794)', () => {
     expect(within(priorityRoot).getByText('AI Failure Detection')).toBeInTheDocument();
   });
 });
+
+describe('AddNotificationModal — Home Assistant custom data (#1441)', () => {
+  const haProvider = () =>
+    buildProvider({
+      provider_type: 'homeassistant',
+      config: { service: 'notify.mobile_app_myphone' },
+    });
+
+  it('renders the Data (JSON) textarea for the homeassistant provider', async () => {
+    render(<AddNotificationModal provider={haProvider()} onClose={() => undefined} />);
+
+    await screen.findByDisplayValue('My ntfy');
+    expect(screen.getByText(/data \(json, optional\)/i)).toBeInTheDocument();
+    expect(screen.getByPlaceholderText(/"priority": "high"/)).toBeInTheDocument();
+  });
+
+  it('rejects malformed JSON in the Data field on save', async () => {
+    const patchSpy = vi.fn();
+    server.use(
+      http.patch('*/api/v1/notifications/1', () => {
+        patchSpy();
+        return HttpResponse.json({ id: 1 });
+      }),
+    );
+
+    const onClose = vi.fn();
+    const user = userEvent.setup();
+    render(<AddNotificationModal provider={haProvider()} onClose={onClose} />);
+
+    const textarea = await screen.findByPlaceholderText(/"priority": "high"/);
+    await user.type(textarea, '{{priority: high}');
+    await user.click(screen.getByRole('button', { name: /^save$/i }));
+
+    expect(await screen.findByText(/must be a valid JSON object/i)).toBeInTheDocument();
+    expect(patchSpy).not.toHaveBeenCalled();
+    expect(onClose).not.toHaveBeenCalled();
+  });
+
+  it('round-trips valid Data JSON into config on save', async () => {
+    let captured: { config: Record<string, unknown> } | null = null;
+    server.use(
+      http.patch('*/api/v1/notifications/1', async ({ request }) => {
+        captured = (await request.json()) as { config: Record<string, unknown> };
+        return HttpResponse.json({ id: 1 });
+      }),
+    );
+
+    const onClose = vi.fn();
+    const user = userEvent.setup();
+    render(<AddNotificationModal provider={haProvider()} onClose={onClose} />);
+
+    const textarea = await screen.findByPlaceholderText(/"priority": "high"/);
+    await user.type(textarea, '{{"ttl": 0}');
+    await user.click(screen.getByRole('button', { name: /^save$/i }));
+
+    await waitFor(() => expect(onClose).toHaveBeenCalled());
+    expect(captured).not.toBeNull();
+    expect(captured!.config).toMatchObject({
+      service: 'notify.mobile_app_myphone',
+      data: '{"ttl": 0}',
+    });
+  });
+});

+ 27 - 0
frontend/src/components/AddNotificationModal.tsx

@@ -144,6 +144,21 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
       }
     }
 
+    // HA custom service-data must be a JSON object (#1441)
+    if (providerType === 'homeassistant' && config.data?.trim()) {
+      let parsed: unknown;
+      try {
+        parsed = JSON.parse(config.data);
+      } catch {
+        setError(t('notifications.haDataInvalid'));
+        return;
+      }
+      if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
+        setError(t('notifications.haDataInvalid'));
+        return;
+      }
+    }
+
     const finalConfig: Record<string, unknown> =
       providerType === 'ntfy' && Object.keys(eventPriorities).length > 0
         ? { ...config, event_priorities: eventPriorities }
@@ -265,6 +280,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
       case 'homeassistant':
         return [
           { key: 'service', label: 'Home Assistant Service', placeholder: 'notify.mobile_app_myphone', type: 'text', required: false },
+          { key: 'data', label: 'Data (JSON, optional)', placeholder: '{"priority": "high", "ttl": 0, "channel": "3D Printing"}', type: 'textarea', required: false },
         ];
       default:
         return [];
@@ -368,6 +384,17 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
                       </option>
                     ))}
                   </select>
+                ) : field.type === 'textarea' ? (
+                  <textarea
+                    value={config[field.key] || ''}
+                    onChange={(e) => {
+                      setConfig({ ...config, [field.key]: e.target.value });
+                      setTestResult(null);
+                    }}
+                    placeholder={field.placeholder}
+                    rows={3}
+                    className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none font-mono text-sm"
+                  />
                 ) : (
                   <input
                     type={field.type}

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

@@ -5565,6 +5565,7 @@ export default {
     add: 'Hinzufügen',
     nameRequired: 'Name ist erforderlich',
     fieldRequired: '{{field}} ist erforderlich',
+    haDataInvalid: 'Das Datenfeld muss ein gültiges JSON-Objekt sein, z. B. {"priority": "high", "ttl": 0}',
     // Config field labels
     phoneNumber: 'Telefonnummer',
     apiKey: 'API-Schlüssel',

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

@@ -5609,6 +5609,7 @@ export default {
     add: 'Add',
     nameRequired: 'Name is required',
     fieldRequired: '{{field}} is required',
+    haDataInvalid: 'The Data field must be a valid JSON object, e.g. {"priority": "high", "ttl": 0}',
     // Config field labels
     phoneNumber: 'Phone Number',
     apiKey: 'API Key',

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

@@ -5574,6 +5574,7 @@ export default {
     add: 'Añadir',
     nameRequired: 'El nombre es obligatorio',
     fieldRequired: '{{field}} es obligatorio',
+    haDataInvalid: 'El campo Data debe ser un objeto JSON válido, p. ej. {"priority": "high", "ttl": 0}',
     // Config field labels
     phoneNumber: 'Número de teléfono',
     apiKey: 'Clave API',

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

@@ -5555,6 +5555,7 @@ export default {
     add: 'Ajouter',
     nameRequired: 'Le nom est requis',
     fieldRequired: '{{field}} est requis',
+    haDataInvalid: 'Le champ Data doit être un objet JSON valide, p. ex. {"priority": "high", "ttl": 0}',
     // Config field labels
     phoneNumber: 'Numéro de téléphone',
     apiKey: 'Clé API',

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

@@ -5554,6 +5554,7 @@ export default {
     add: 'Aggiungi',
     nameRequired: 'Il nome è obbligatorio',
     fieldRequired: '{{field}} è obbligatorio',
+    haDataInvalid: 'Il campo Data deve essere un oggetto JSON valido, ad es. {"priority": "high", "ttl": 0}',
     // Config field labels
     phoneNumber: 'Numero di telefono',
     apiKey: 'Chiave API',

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

@@ -5566,6 +5566,7 @@ export default {
     add: '追加',
     nameRequired: '名前は必須です',
     fieldRequired: '{{field}}は必須です',
+    haDataInvalid: 'Dataフィールドは有効なJSONオブジェクトである必要があります(例: {"priority": "high", "ttl": 0})',
     // Config field labels
     phoneNumber: '電話番号',
     apiKey: 'APIキー',

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

@@ -5279,6 +5279,7 @@ export default {
     add: '추가',
     nameRequired: '이름이 필요합니다',
     fieldRequired: '{{field}}이(가) 필요합니다',
+    haDataInvalid: 'Data 필드는 유효한 JSON 객체여야 합니다(예: {"priority": "high", "ttl": 0})',
     phoneNumber: '전화번호',
     apiKey: 'API 키',
     serverUrl: '서버 URL',

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

@@ -5554,6 +5554,7 @@ export default {
     add: 'Adicionar',
     nameRequired: 'Nome é obrigatório',
     fieldRequired: '{{field}} é obrigatório',
+    haDataInvalid: 'O campo Data deve ser um objeto JSON válido, por ex. {"priority": "high", "ttl": 0}',
     // Config field labels
     phoneNumber: 'Número de Telefone',
     apiKey: 'Chave da API',

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

@@ -5266,6 +5266,7 @@ export default {
     add: "Добавить",
     nameRequired: "Укажите название",
     fieldRequired: "Поле «{{field}}» обязательно",
+    haDataInvalid: 'Поле Data должно быть корректным JSON-объектом, напр. {"priority": "high", "ttl": 0}',
     phoneNumber: "Номер телефона",
     apiKey: "Ключ API",
     serverUrl: "URL сервера",

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

@@ -5515,6 +5515,7 @@ export default {
     add: 'Ekle',
     nameRequired: 'Ad gerekli',
     fieldRequired: '{{field}} gerekli',
+    haDataInvalid: 'Data alanı geçerli bir JSON nesnesi olmalıdır, örn. {"priority": "high", "ttl": 0}',
     phoneNumber: 'Telefon Numarası',
     apiKey: 'API Anahtarı',
     serverUrl: 'Sunucu URL',

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

@@ -5554,6 +5554,7 @@ export default {
     add: '添加',
     nameRequired: '名称为必填项',
     fieldRequired: '{{field}}为必填项',
+    haDataInvalid: 'Data 字段必须是有效的 JSON 对象,例如 {"priority": "high", "ttl": 0}',
     // Config field labels
     phoneNumber: '电话号码',
     apiKey: 'API 密钥',

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

@@ -5554,6 +5554,7 @@ export default {
     add: '新增',
     nameRequired: '名稱為必填項',
     fieldRequired: '{{field}}為必填項',
+    haDataInvalid: 'Data 欄位必須是有效的 JSON 物件,例如 {"priority": "high", "ttl": 0}',
     // Config field labels
     phoneNumber: '電話號碼',
     apiKey: 'API 金鑰',

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/index-DW6dSr9L.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-BnC7vrvV.js"></script>
+    <script type="module" crossorigin src="/assets/index-DW6dSr9L.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-Di24iyOw.css">
   </head>
   <body>

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff