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.
This commit is contained in:
maziggy
2026-07-28 10:44:40 +02:00
parent d68724c689
commit 49f9d7120d
19 changed files with 242 additions and 14 deletions
+1
View File
@@ -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
@@ -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)
@@ -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."""
@@ -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}',
});
});
});
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -5566,6 +5566,7 @@ export default {
add: '追加',
nameRequired: '名前は必須です',
fieldRequired: '{{field}}は必須です',
haDataInvalid: 'Dataフィールドは有効なJSONオブジェクトである必要があります(例: {"priority": "high", "ttl": 0})',
// Config field labels
phoneNumber: '電話番号',
apiKey: 'APIキー',
+1
View File
@@ -5279,6 +5279,7 @@ export default {
add: '추가',
nameRequired: '이름이 필요합니다',
fieldRequired: '{{field}}이(가) 필요합니다',
haDataInvalid: 'Data 필드는 유효한 JSON 객체여야 합니다(예: {"priority": "high", "ttl": 0})',
phoneNumber: '전화번호',
apiKey: 'API 키',
serverUrl: '서버 URL',
+1
View File
@@ -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
View File
@@ -5266,6 +5266,7 @@ export default {
add: "Добавить",
nameRequired: "Укажите название",
fieldRequired: "Поле «{{field}}» обязательно",
haDataInvalid: 'Поле Data должно быть корректным JSON-объектом, напр. {"priority": "high", "ttl": 0}',
phoneNumber: "Номер телефона",
apiKey: "Ключ API",
serverUrl: "URL сервера",
+1
View File
@@ -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
View File
@@ -5554,6 +5554,7 @@ export default {
add: '添加',
nameRequired: '名称为必填项',
fieldRequired: '{{field}}为必填项',
haDataInvalid: 'Data 字段必须是有效的 JSON 对象,例如 {"priority": "high", "ttl": 0}',
// Config field labels
phoneNumber: '电话号码',
apiKey: 'API 密钥',
+1
View File
@@ -5554,6 +5554,7 @@ export default {
add: '新增',
nameRequired: '名稱為必填項',
fieldRequired: '{{field}}為必填項',
haDataInvalid: 'Data 欄位必須是有效的 JSON 物件,例如 {"priority": "high", "ttl": 0}',
// Config field labels
phoneNumber: '電話號碼',
apiKey: 'API 金鑰',
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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>