diff --git a/CHANGELOG.md b/CHANGELOG.md index 29750ed1e..7fe656955 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py index e4b64a4b6..725829eef 100644 --- a/backend/app/services/notification_service.py +++ b/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) diff --git a/backend/tests/unit/services/test_notification_service.py b/backend/tests/unit/services/test_notification_service.py index 13452218d..f7172c9b8 100644 --- a/backend/tests/unit/services/test_notification_service.py +++ b/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.""" diff --git a/frontend/src/__tests__/components/AddNotificationModal.test.tsx b/frontend/src/__tests__/components/AddNotificationModal.test.tsx index abf9d8e56..48b446c3e 100644 --- a/frontend/src/__tests__/components/AddNotificationModal.test.tsx +++ b/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( 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(); + + 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 } | null = null; + server.use( + http.patch('*/api/v1/notifications/1', async ({ request }) => { + captured = (await request.json()) as { config: Record }; + return HttpResponse.json({ id: 1 }); + }), + ); + + const onClose = vi.fn(); + const user = userEvent.setup(); + render(); + + 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}', + }); + }); +}); diff --git a/frontend/src/components/AddNotificationModal.tsx b/frontend/src/components/AddNotificationModal.tsx index 85932329a..b26575bbb 100644 --- a/frontend/src/components/AddNotificationModal.tsx +++ b/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 = 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 ))} + ) : field.type === 'textarea' ? ( +