Skip to content

Commit 50c189b

Browse files
authored
feat(frontend): add Home Assistant MQTT auto-discovery settings UI (#1750)
* feat(frontend): add Home Assistant MQTT auto-discovery settings UI - Add HomeAssistantSettings TypeScript interface and derived store - Add Home Assistant section in MQTT settings with enable checkbox, discovery prefix, and device name fields - Add Send Discovery button to manually trigger HA discovery messages - Add API endpoint POST /api/v2/integrations/mqtt/homeassistant/discovery - Reorganize MQTT settings: TLS/SSL near broker, HA settings grouped together - Add translations for all 8 languages (en, de, fi, fr, nl, es, pl, pt) - Extract DEFAULT_HOME_ASSISTANT_SETTINGS constant to reduce duplication * fix: address code review feedback for Home Assistant settings UI - Extract publishHomeAssistantDiscovery helper to reduce code duplication - Add nil-guard for p.Settings in TriggerHomeAssistantDiscovery - Fix Finnish typo: äänilähtellä → äänilähteellä - Fix French grammar: Lorsque activé → Lorsqu'elle est activée, Envoyer Discovery → Envoyer la découverte - Move retain translation keys to homeAssistant section (8 languages) - Add endpoint documentation to internal/api/v2/README.md - Use c.HandleError for consistent API v2 error responses - Update frontend to check response.ok instead of result.success --------- Co-authored-by: tphakala <tphakala@users.noreply.github.com>
1 parent d0f1b90 commit 50c189b

File tree

13 files changed

+485
-136
lines changed

13 files changed

+485
-136
lines changed

frontend/src/lib/desktop/features/settings/pages/IntegrationSettingsPage.svelte

Lines changed: 172 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@
4646
type MQTTSettings,
4747
type SettingsFormData,
4848
} from '$lib/stores/settings';
49-
import { TriangleAlert, Info } from '@lucide/svelte';
49+
import { TriangleAlert, Info, Send } from '@lucide/svelte';
50+
import { toastActions } from '$lib/stores/toast';
5051
import { loggers } from '$lib/utils/logger';
5152
import { safeArrayAccess } from '$lib/utils/security';
5253
import { hasSettingsChanged } from '$lib/utils/settingsChanges';
@@ -78,6 +79,11 @@
7879
enabled: false,
7980
skipVerify: false,
8081
},
82+
homeAssistant: {
83+
enabled: false,
84+
discoveryPrefix: 'homeassistant',
85+
deviceName: 'BirdNET-Go',
86+
},
8187
},
8288
observability: {
8389
prometheus: {
@@ -222,6 +228,77 @@
222228
});
223229
}
224230
231+
// Home Assistant default settings constant (DRY principle)
232+
const DEFAULT_HOME_ASSISTANT_SETTINGS = {
233+
enabled: false,
234+
discoveryPrefix: 'homeassistant',
235+
deviceName: 'BirdNET-Go',
236+
};
237+
238+
// Generic Home Assistant update function to reduce duplication
239+
function updateMQTTHomeAssistant<K extends keyof typeof DEFAULT_HOME_ASSISTANT_SETTINGS>(
240+
field: K,
241+
value: (typeof DEFAULT_HOME_ASSISTANT_SETTINGS)[K]
242+
) {
243+
settingsActions.updateSection('realtime', {
244+
mqtt: {
245+
...(settings.mqtt as MQTTSettings),
246+
homeAssistant: {
247+
...(settings.mqtt?.homeAssistant ?? DEFAULT_HOME_ASSISTANT_SETTINGS),
248+
[field]: value,
249+
},
250+
},
251+
});
252+
}
253+
254+
// Home Assistant update handlers (wrappers for type-safe binding in templates)
255+
function updateMQTTHomeAssistantEnabled(enabled: boolean) {
256+
updateMQTTHomeAssistant('enabled', enabled);
257+
}
258+
259+
function updateMQTTHomeAssistantPrefix(discoveryPrefix: string) {
260+
updateMQTTHomeAssistant('discoveryPrefix', discoveryPrefix);
261+
}
262+
263+
function updateMQTTHomeAssistantDeviceName(deviceName: string) {
264+
updateMQTTHomeAssistant('deviceName', deviceName);
265+
}
266+
267+
// Home Assistant discovery state and handler
268+
let isSendingDiscovery = $state(false);
269+
270+
async function handleSendDiscovery() {
271+
if (mqttHasChanges) {
272+
// Don't send discovery with unsaved changes
273+
return;
274+
}
275+
276+
isSendingDiscovery = true;
277+
try {
278+
const response = await fetch('/api/v2/integrations/mqtt/homeassistant/discovery', {
279+
method: 'POST',
280+
headers: {
281+
'Content-Type': 'application/json',
282+
'X-CSRF-Token': getCsrfToken() || '',
283+
},
284+
});
285+
286+
if (response.ok) {
287+
toastActions.success(t('settings.integration.mqtt.homeAssistant.discovery.success'));
288+
} else {
289+
const result = await response.json();
290+
toastActions.error(
291+
result.message || t('settings.integration.mqtt.homeAssistant.discovery.error')
292+
);
293+
}
294+
} catch (err) {
295+
logger.error('Failed to send Home Assistant discovery', err);
296+
toastActions.error(t('settings.integration.mqtt.homeAssistant.discovery.error'));
297+
} finally {
298+
isSendingDiscovery = false;
299+
}
300+
}
301+
225302
// Observability update handlers
226303
function updateObservabilityEnabled(enabled: boolean) {
227304
settingsActions.updateSection('realtime', {
@@ -813,6 +890,32 @@
813890
onchange={updateMQTTBroker}
814891
/>
815892

893+
<!-- TLS/SSL Security -->
894+
<div class="flex flex-col gap-2">
895+
<Checkbox
896+
checked={settings.mqtt?.tls?.enabled ?? false}
897+
label={t('settings.integration.mqtt.tls.enable')}
898+
disabled={!settings.mqtt?.enabled || store.isLoading || store.isSaving}
899+
onchange={updateMQTTTLSEnabled}
900+
/>
901+
902+
{#if settings.mqtt?.tls?.enabled}
903+
<Checkbox
904+
checked={settings.mqtt?.tls?.skipVerify ?? false}
905+
label={t('settings.integration.mqtt.tls.skipVerify')}
906+
disabled={!settings.mqtt?.enabled || store.isLoading || store.isSaving}
907+
onchange={updateMQTTTLSSkipVerify}
908+
/>
909+
910+
<div class="alert alert-info">
911+
<Info class="size-5" />
912+
<div>
913+
<span>{@html t('settings.integration.mqtt.tls.configNote')}</span>
914+
</div>
915+
</div>
916+
{/if}
917+
</div>
918+
816919
<TextInput
817920
id="mqtt-topic"
818921
value={settings.mqtt!.topic}
@@ -850,56 +953,95 @@
850953
</div>
851954
</div>
852955

853-
<!-- Message Settings Section -->
956+
<!-- Home Assistant Integration Section -->
854957
<div class="border-t border-base-300 pt-4 mt-2">
855958
<h3 class="text-sm font-medium mb-3">
856-
{t('settings.integration.mqtt.messageSettings.title')}
959+
{t('settings.integration.mqtt.homeAssistant.title')}
857960
</h3>
858961

859-
<!-- prettier-ignore -->
860962
<Checkbox
861-
checked={(settings.mqtt as MQTTSettings).retain ?? false}
862-
onchange={(checked) => updateMQTTRetain(checked)}
863-
label={t('settings.integration.mqtt.messageSettings.retain.label')}
963+
checked={settings.mqtt?.homeAssistant?.enabled ?? false}
964+
label={t('settings.integration.mqtt.homeAssistant.enable')}
864965
disabled={!settings.mqtt?.enabled || store.isLoading || store.isSaving}
966+
onchange={updateMQTTHomeAssistantEnabled}
865967
/>
866968

867-
<!-- Note about MQTT Retain for HomeAssistant -->
868969
<SettingsNote>
869-
<span>{@html t('settings.integration.mqtt.messageSettings.retain.note')}</span>
970+
<span>{@html t('settings.integration.mqtt.homeAssistant.description')}</span>
870971
</SettingsNote>
871-
</div>
872-
873-
<!-- TLS/SSL Security Section -->
874-
<div class="border-t border-base-300 pt-4 mt-2">
875-
<h3 class="text-sm font-medium mb-3">{t('settings.integration.mqtt.tls.title')}</h3>
876972

877-
<Checkbox
878-
checked={settings.mqtt?.tls?.enabled ?? false}
879-
label={t('settings.integration.mqtt.tls.enable')}
880-
disabled={!settings.mqtt?.enabled || store.isLoading || store.isSaving}
881-
onchange={updateMQTTTLSEnabled}
882-
/>
973+
{#if settings.mqtt?.homeAssistant?.enabled}
974+
<!-- Retain Messages for Home Assistant -->
975+
<div class="mt-4">
976+
<!-- prettier-ignore -->
977+
<Checkbox
978+
checked={(settings.mqtt as MQTTSettings).retain ?? false}
979+
onchange={(checked) => updateMQTTRetain(checked)}
980+
label={t('settings.integration.mqtt.homeAssistant.retain.label')}
981+
disabled={!settings.mqtt?.enabled || store.isLoading || store.isSaving}
982+
/>
983+
984+
<SettingsNote>
985+
<span>{@html t('settings.integration.mqtt.homeAssistant.retain.note')}</span>
986+
</SettingsNote>
987+
</div>
883988

884-
{#if settings.mqtt?.tls?.enabled}
885-
<Checkbox
886-
checked={settings.mqtt?.tls?.skipVerify ?? false}
887-
label={t('settings.integration.mqtt.tls.skipVerify')}
888-
disabled={!settings.mqtt?.enabled || store.isLoading || store.isSaving}
889-
onchange={updateMQTTTLSSkipVerify}
890-
/>
989+
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
990+
<TextInput
991+
id="mqtt-ha-prefix"
992+
value={settings.mqtt?.homeAssistant?.discoveryPrefix ?? 'homeassistant'}
993+
label={t('settings.integration.mqtt.homeAssistant.discoveryPrefix.label')}
994+
placeholder="homeassistant"
995+
disabled={!settings.mqtt?.enabled || store.isLoading || store.isSaving}
996+
onchange={updateMQTTHomeAssistantPrefix}
997+
/>
998+
999+
<TextInput
1000+
id="mqtt-ha-device-name"
1001+
value={settings.mqtt?.homeAssistant?.deviceName ?? 'BirdNET-Go'}
1002+
label={t('settings.integration.mqtt.homeAssistant.deviceName.label')}
1003+
placeholder="BirdNET-Go"
1004+
disabled={!settings.mqtt?.enabled || store.isLoading || store.isSaving}
1005+
onchange={updateMQTTHomeAssistantDeviceName}
1006+
/>
1007+
</div>
8911008

892-
<div class="alert alert-info">
1009+
<div class="alert alert-info mt-4">
8931010
<Info class="size-5" />
8941011
<div>
895-
<span>{@html t('settings.integration.mqtt.tls.configNote')}</span>
1012+
<span>{@html t('settings.integration.mqtt.homeAssistant.sensorsNote')}</span>
8961013
</div>
8971014
</div>
1015+
1016+
<div class="flex items-center gap-4 mt-4">
1017+
<button
1018+
class="btn btn-outline btn-sm"
1019+
disabled={!settings.mqtt?.enabled ||
1020+
store.isLoading ||
1021+
store.isSaving ||
1022+
isSendingDiscovery ||
1023+
mqttHasChanges}
1024+
onclick={handleSendDiscovery}
1025+
>
1026+
{#if isSendingDiscovery}
1027+
<span class="loading loading-spinner loading-xs"></span>
1028+
{:else}
1029+
<Send class="size-4" />
1030+
{/if}
1031+
{t('settings.integration.mqtt.homeAssistant.discovery.button')}
1032+
</button>
1033+
1034+
{#if mqttHasChanges}
1035+
<span class="text-sm text-warning">
1036+
{t('settings.integration.mqtt.homeAssistant.discovery.saveFirst')}
1037+
</span>
1038+
{/if}
1039+
</div>
8981040
{/if}
8991041
</div>
9001042

9011043
<!-- Test Connection -->
902-
<div class="space-y-4">
1044+
<div class="border-t border-base-300 pt-4 mt-2 space-y-4">
9031045
<div class="flex items-center gap-3">
9041046
<SettingsButton
9051047
onclick={testMQTT}

frontend/src/lib/stores/settings.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,12 @@ export interface BirdWeatherSettings {
244244
debug: boolean;
245245
}
246246

247+
export interface HomeAssistantSettings {
248+
enabled: boolean;
249+
discoveryPrefix: string; // Topic prefix (default: "homeassistant")
250+
deviceName: string; // Base device name (default: "BirdNET-Go")
251+
}
252+
247253
export interface MQTTSettings {
248254
enabled: boolean;
249255
broker: string;
@@ -256,6 +262,7 @@ export interface MQTTSettings {
256262
enabled: boolean;
257263
skipVerify: boolean;
258264
};
265+
homeAssistant?: HomeAssistantSettings;
259266
}
260267

261268
export interface ObservabilitySettings {
@@ -733,6 +740,11 @@ function createEmptySettings(): SettingsFormData {
733740
enabled: false,
734741
skipVerify: false,
735742
},
743+
homeAssistant: {
744+
enabled: false,
745+
discoveryPrefix: 'homeassistant',
746+
deviceName: 'BirdNET-Go',
747+
},
736748
},
737749
species: {
738750
include: [],
@@ -854,6 +866,11 @@ export const birdweatherSettings = derived(
854866

855867
export const mqttSettings = derived(settingsStore, $store => $store.formData.realtime?.mqtt);
856868

869+
export const homeAssistantSettings = derived(
870+
settingsStore,
871+
$store => $store.formData.realtime?.mqtt?.homeAssistant
872+
);
873+
857874
export const speciesSettings = derived(settingsStore, $store => $store.formData.realtime?.species);
858875

859876
export const dashboardSettings = derived(

frontend/static/messages/de.json

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1720,19 +1720,36 @@
17201720
"helpText": "Das MQTT-Passwort."
17211721
}
17221722
},
1723-
"messageSettings": {
1724-
"title": "Nachrichteneinstellungen",
1725-
"retain": {
1726-
"label": "Nachrichten beibehalten",
1727-
"note": "<strong>Home Assistant-Benutzer:</strong> Es wird empfohlen, das Retain-Flag für die Home Assistant-Integration zu aktivieren. Ohne Retain erscheinen MQTT-Sensoren als 'unbekannt', wenn Home Assistant neu startet."
1728-
}
1729-
},
17301723
"tls": {
17311724
"title": "TLS/SSL-Sicherheit",
17321725
"enable": "TLS/SSL aktivieren",
17331726
"skipVerify": "Zertifikatsüberprüfung überspringen",
17341727
"configNote": "<strong>TLS-Konfiguration:</strong><br />• Standard-TLS: Zertifikate für öffentliche Broker leer lassen<br />• Selbstsignierte Zertifikate: CA-Zertifikat bereitstellen<br />• Mutual TLS (mTLS): Alle drei Zertifikate bereitstellen"
17351728
},
1729+
"homeAssistant": {
1730+
"title": "Home Assistant Auto-Discovery",
1731+
"enable": "Home Assistant MQTT Discovery aktivieren",
1732+
"description": "Wenn aktiviert, registriert BirdNET-Go automatisch Geräte und Sensoren bei Home Assistant über das MQTT-Discovery-Protokoll. Geräte erscheinen ohne manuelle YAML-Konfiguration in der Home Assistant-Geräteregistrierung.",
1733+
"discoveryPrefix": {
1734+
"label": "Discovery-Präfix",
1735+
"helpText": "Das MQTT-Topic-Präfix für Home Assistant Discovery-Nachrichten. Standard ist 'homeassistant'. Ändern Sie dies nur, wenn Sie ein benutzerdefiniertes Discovery-Präfix in Home Assistant konfiguriert haben."
1736+
},
1737+
"deviceName": {
1738+
"label": "Gerätename",
1739+
"helpText": "Der Basisname für Geräte, wie sie in Home Assistant erscheinen. Jede Audioquelle hat ihr eigenes Gerät, das mit diesem BirdNET-Go-Hauptgerät verknüpft ist."
1740+
},
1741+
"sensorsNote": "<strong>Erstellte Sensoren:</strong> Für jede Audioquelle erhält Home Assistant: Letzte Art, Konfidenz, wissenschaftlicher Name und Schallpegel (wenn aktiviert). Das Bridge-Gerät enthält Online/Offline-Verfügbarkeitsstatus.",
1742+
"discovery": {
1743+
"button": "Discovery senden",
1744+
"success": "Discovery-Nachrichten an Home Assistant gesendet",
1745+
"error": "Senden der Discovery-Nachrichten fehlgeschlagen",
1746+
"saveFirst": "Einstellungen speichern, bevor Discovery gesendet wird"
1747+
},
1748+
"retain": {
1749+
"label": "Nachrichten beibehalten",
1750+
"note": "<strong>Empfohlen:</strong> Aktivieren Sie das Retain-Flag, um sicherzustellen, dass MQTT-Sensoren nach einem Neustart von Home Assistant Werte anzeigen."
1751+
}
1752+
},
17361753
"test": {
17371754
"button": "MQTT-Verbindung testen",
17381755
"loading": "Teste...",
@@ -2561,7 +2578,7 @@
25612578
"title": "Weather Information",
25622579
"noData": "No weather data",
25632580
"noDataAvailable": "No weather data available",
2564-
"loading": "Loading weather information...",
2581+
"loading": "Daten werden geladen...",
25652582
"labels": {
25662583
"temperature": "Temperature",
25672584
"weather": "Weather",
@@ -2570,7 +2587,6 @@
25702587
"pressure": "Pressure",
25712588
"cloudCover": "Cloud Cover"
25722589
},
2573-
"loading": "Daten werden geladen...",
25742590
"error": "Fehler beim Laden der Daten",
25752591
"sortBy": "Sortieren nach {column}",
25762592
"rowsPerPage": "Zeilen pro Seite",

0 commit comments

Comments
 (0)