Skip to content

Commit d0f1b90

Browse files
authored
feat(mqtt): add Home Assistant MQTT auto-discovery support (#1749)
* feat(mqtt): add Home Assistant MQTT auto-discovery support Implements Home Assistant MQTT Discovery protocol for automatic device and sensor registration. This allows BirdNET-Go to automatically appear in Home Assistant without manual configuration. Features: - Bridge device representing the BirdNET-Go instance - Per-source devices linked via via_device - Sensors: species, confidence, scientific name, sound level - LWT (Last Will and Testament) for availability tracking - OnConnect handler for automatic discovery on reconnection - Proper cleanup via RemoveDiscovery Configuration: - realtime.mqtt.homeassistant.enabled: Enable/disable discovery - realtime.mqtt.homeassistant.discovery_prefix: Topic prefix (default: homeassistant) - realtime.mqtt.homeassistant.device_name: Base device name Closes #1717 * fix(mqtt): address code review feedback - Extract publishInternal helper to eliminate duplication between Publish and PublishWithRetain methods (~100 lines reduced) - Add RegisterHomeAssistantDiscovery exported method for reconfiguration - Register HA discovery handler in handleReconfigureMQTT so discovery messages are published after MQTT reconnection/reconfiguration --------- Co-authored-by: tphakala <tphakala@users.noreply.github.com>
1 parent 3e3680f commit d0f1b90

File tree

11 files changed

+1085
-46
lines changed

11 files changed

+1085
-46
lines changed

internal/analysis/control_monitor.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,10 @@ func (cm *ControlMonitor) handleReconfigureMQTT() {
260260
return
261261
}
262262

263+
// Register Home Assistant discovery handler before connecting
264+
// so the OnConnect handler fires on the initial connection
265+
cm.proc.RegisterHomeAssistantDiscovery(newClient, settings)
266+
263267
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
264268
if err := newClient.Connect(ctx); err != nil {
265269
cancel()

internal/analysis/processor/actions.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -994,9 +994,12 @@ func (a *BirdWeatherAction) Execute(data any) error {
994994
return nil
995995
}
996996

997+
// NoteWithBirdImage wraps a Note with bird image data for MQTT publishing.
998+
// The SourceID field enables Home Assistant to filter detections by source.
997999
type NoteWithBirdImage struct {
9981000
datastore.Note
999-
BirdImage imageprovider.BirdImage
1001+
SourceID string `json:"sourceId"` // Audio source ID for HA filtering
1002+
BirdImage imageprovider.BirdImage `json:"birdImage"`
10001003
}
10011004

10021005
// Execute sends the note to the MQTT broker
@@ -1052,8 +1055,12 @@ func (a *MqttAction) Execute(data any) error {
10521055
// Create a copy of the Note (source is already sanitized in SafeString field)
10531056
noteCopy := a.Note
10541057

1055-
// Wrap note with bird image (using copy)
1056-
noteWithBirdImage := NoteWithBirdImage{Note: noteCopy, BirdImage: birdImage}
1058+
// Wrap note with bird image (using copy) and include SourceID for HA filtering
1059+
noteWithBirdImage := NoteWithBirdImage{
1060+
Note: noteCopy,
1061+
SourceID: noteCopy.Source.ID,
1062+
BirdImage: birdImage,
1063+
}
10571064

10581065
// Create a JSON representation of the note
10591066
noteJson, err := json.Marshal(noteWithBirdImage)

internal/analysis/processor/mqtt.go

Lines changed: 103 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,17 @@ import (
77
"time"
88

99
"github.com/tphakala/birdnet-go/internal/conf"
10+
"github.com/tphakala/birdnet-go/internal/datastore"
1011
"github.com/tphakala/birdnet-go/internal/logger"
1112
"github.com/tphakala/birdnet-go/internal/mqtt"
13+
"github.com/tphakala/birdnet-go/internal/myaudio"
14+
)
15+
16+
const (
17+
// mqttConnectionTimeout is the timeout for MQTT connection attempts
18+
mqttConnectionTimeout = 30 * time.Second
19+
// discoveryPublishTimeout is the timeout for publishing discovery messages
20+
discoveryPublishTimeout = 30 * time.Second
1221
)
1322

1423
// GetMQTTClient safely returns the current MQTT client
@@ -28,10 +37,12 @@ func (p *Processor) SetMQTTClient(client mqtt.Client) {
2837
// DisconnectMQTTClient safely disconnects and removes the MQTT client
2938
func (p *Processor) DisconnectMQTTClient() {
3039
p.mqttMutex.Lock()
31-
defer p.mqttMutex.Unlock()
32-
if p.MqttClient != nil {
33-
p.MqttClient.Disconnect()
34-
p.MqttClient = nil
40+
client := p.MqttClient
41+
p.MqttClient = nil
42+
p.mqttMutex.Unlock()
43+
44+
if client != nil {
45+
client.Disconnect()
3546
}
3647
}
3748

@@ -49,6 +60,9 @@ func (p *Processor) PublishMQTT(ctx context.Context, topic, payload string) erro
4960

5061
// initializeMQTT initializes the MQTT client if enabled in settings
5162
func (p *Processor) initializeMQTT(settings *conf.Settings) {
63+
if settings == nil {
64+
return
65+
}
5266
if !settings.Realtime.MQTT.Enabled {
5367
return
5468
}
@@ -61,8 +75,13 @@ func (p *Processor) initializeMQTT(settings *conf.Settings) {
6175
return
6276
}
6377

64-
// Create a context with a 30-second timeout for the connection attempt
65-
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
78+
// Register Home Assistant discovery handler if enabled
79+
if settings.Realtime.MQTT.HomeAssistant.Enabled {
80+
p.registerHomeAssistantDiscovery(mqttClient, settings)
81+
}
82+
83+
// Create a context with a timeout for the connection attempt
84+
ctx, cancel := context.WithTimeout(context.Background(), mqttConnectionTimeout)
6685
defer cancel() // Ensure the cancel function is called to release resources
6786

6887
// Attempt to connect to the MQTT broker
@@ -74,3 +93,81 @@ func (p *Processor) initializeMQTT(settings *conf.Settings) {
7493
// Set the client only if connection was successful
7594
p.SetMQTTClient(mqttClient)
7695
}
96+
97+
// RegisterHomeAssistantDiscovery registers the OnConnect handler for Home Assistant discovery.
98+
// This is called during MQTT initialization and after MQTT reconfiguration.
99+
func (p *Processor) RegisterHomeAssistantDiscovery(client mqtt.Client, settings *conf.Settings) {
100+
if client == nil || settings == nil {
101+
return
102+
}
103+
if !settings.Realtime.MQTT.HomeAssistant.Enabled {
104+
return
105+
}
106+
p.registerHomeAssistantDiscovery(client, settings)
107+
}
108+
109+
// registerHomeAssistantDiscovery registers the OnConnect handler for Home Assistant discovery.
110+
func (p *Processor) registerHomeAssistantDiscovery(client mqtt.Client, settings *conf.Settings) {
111+
log := GetLogger()
112+
113+
// Create discovery configuration
114+
haSettings := settings.Realtime.MQTT.HomeAssistant
115+
discoveryConfig := mqtt.DiscoveryConfig{
116+
DiscoveryPrefix: haSettings.DiscoveryPrefix,
117+
BaseTopic: settings.Realtime.MQTT.Topic,
118+
DeviceName: haSettings.DeviceName,
119+
NodeID: settings.Main.Name,
120+
Version: settings.Version,
121+
}
122+
123+
// Create the discovery publisher
124+
publisher := mqtt.NewDiscoveryPublisher(client, &discoveryConfig)
125+
126+
// Register the OnConnect handler
127+
client.RegisterOnConnectHandler(func() {
128+
log.Info("MQTT connected, publishing Home Assistant discovery messages")
129+
130+
// Get audio sources from the registry
131+
sources := p.getAudioSourcesForDiscovery()
132+
133+
// Create a context for publishing
134+
ctx, cancel := context.WithTimeout(context.Background(), discoveryPublishTimeout)
135+
defer cancel()
136+
137+
// Publish discovery messages
138+
if err := publisher.PublishDiscovery(ctx, sources, settings); err != nil {
139+
log.Error("Failed to publish Home Assistant discovery",
140+
logger.Error(err))
141+
}
142+
})
143+
144+
log.Info("Home Assistant discovery handler registered",
145+
logger.String("discovery_prefix", haSettings.DiscoveryPrefix),
146+
logger.String("device_name", haSettings.DeviceName))
147+
}
148+
149+
// getAudioSourcesForDiscovery retrieves audio sources from the registry for HA discovery.
150+
func (p *Processor) getAudioSourcesForDiscovery() []datastore.AudioSource {
151+
registry := myaudio.GetRegistry()
152+
registrySources := registry.ListSources()
153+
154+
// Convert myaudio.AudioSource to datastore.AudioSource
155+
sources := make([]datastore.AudioSource, 0, len(registrySources))
156+
for _, src := range registrySources {
157+
sources = append(sources, datastore.AudioSource{
158+
ID: src.ID,
159+
SafeString: src.SafeString,
160+
DisplayName: src.DisplayName,
161+
})
162+
}
163+
164+
// If no sources registered yet, create a default source
165+
if len(sources) == 0 {
166+
sources = append(sources, datastore.AudioSource{
167+
ID: "default",
168+
DisplayName: "Default",
169+
})
170+
}
171+
172+
return sources
173+
}

internal/analysis/processor/mqtt_occurrence_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,16 @@ func (m *MockMqttClientWithCapture) TestConnection(_ context.Context, _ chan<- m
4949
// Not needed for test
5050
}
5151

52+
func (m *MockMqttClientWithCapture) PublishWithRetain(_ context.Context, topic, data string, _ bool) error {
53+
m.PublishedTopic = topic
54+
m.PublishedData = data
55+
return m.PublishError
56+
}
57+
58+
func (m *MockMqttClientWithCapture) RegisterOnConnectHandler(_ mqtt.OnConnectHandler) {
59+
// Not needed for test
60+
}
61+
5262
func TestMqttAction_IncludesOccurrence(t *testing.T) {
5363
// Create test note with occurrence value
5464
testNote := datastore.Note{

internal/analysis/sound_level_publish_test.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1566,6 +1566,17 @@ func (m *mockMQTTClient) SetControlChannel(ch chan string) {
15661566
// Not needed for our tests
15671567
}
15681568

1569+
func (m *mockMQTTClient) PublishWithRetain(ctx context.Context, topic, payload string, retain bool) error {
1570+
if m.publishFunc != nil {
1571+
return m.publishFunc(ctx, topic, payload)
1572+
}
1573+
return nil
1574+
}
1575+
1576+
func (m *mockMQTTClient) RegisterOnConnectHandler(handler mqtt.OnConnectHandler) {
1577+
// Not needed for our tests
1578+
}
1579+
15691580
// createMockProcessor creates a processor suitable for testing with minimal config
15701581
func createMockProcessor(publishFunc func(ctx context.Context, topic, payload string) error) *processor.Processor {
15711582
settings := &conf.Settings{

internal/conf/config.go

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -432,15 +432,16 @@ type RTSPSettings struct {
432432

433433
// MQTTSettings contains settings for MQTT integration.
434434
type MQTTSettings struct {
435-
Enabled bool `json:"enabled"` // true to enable MQTT
436-
Debug bool `json:"debug"` // true to enable MQTT debug
437-
Broker string `json:"broker"` // MQTT broker URL
438-
Topic string `json:"topic"` // MQTT topic
439-
Username string `json:"username"` // MQTT username
440-
Password string `json:"password"` // MQTT password
441-
Retain bool `json:"retain"` // true to retain messages
442-
RetrySettings RetrySettings `json:"retrySettings"` // settings for retry mechanism
443-
TLS MQTTTLSSettings `json:"tls"` // TLS/SSL configuration
435+
Enabled bool `json:"enabled"` // true to enable MQTT
436+
Debug bool `json:"debug"` // true to enable MQTT debug
437+
Broker string `json:"broker"` // MQTT broker URL
438+
Topic string `json:"topic"` // MQTT topic
439+
Username string `json:"username"` // MQTT username
440+
Password string `json:"password"` // MQTT password
441+
Retain bool `json:"retain"` // true to retain messages
442+
RetrySettings RetrySettings `json:"retrySettings"` // settings for retry mechanism
443+
TLS MQTTTLSSettings `json:"tls"` // TLS/SSL configuration
444+
HomeAssistant HomeAssistantSettings `json:"homeAssistant"` // Home Assistant auto-discovery settings
444445
}
445446

446447
// MQTTTLSSettings contains TLS/SSL configuration for secure MQTT connections
@@ -452,6 +453,13 @@ type MQTTTLSSettings struct {
452453
ClientKey string `yaml:"clientkey,omitempty" json:"clientKey,omitempty"` // path to client key file (managed internally)
453454
}
454455

456+
// HomeAssistantSettings contains settings for Home Assistant MQTT auto-discovery.
457+
type HomeAssistantSettings struct {
458+
Enabled bool `yaml:"enabled" json:"enabled"` // true to enable HA auto-discovery
459+
DiscoveryPrefix string `yaml:"discovery_prefix" json:"discoveryPrefix"` // HA discovery topic prefix (default: homeassistant)
460+
DeviceName string `yaml:"device_name" json:"deviceName"` // base name for devices (default: BirdNET-Go)
461+
}
462+
455463
// TelemetrySettings contains settings for telemetry.
456464
type TelemetrySettings struct {
457465
Enabled bool `json:"enabled"` // true to enable Prometheus compatible telemetry endpoint

internal/conf/defaults.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,11 @@ func setDefaultConfig() {
235235
viper.SetDefault("realtime.mqtt.retrysettings.maxdelay", 3600)
236236
viper.SetDefault("realtime.mqtt.retrysettings.backoffmultiplier", 2.0)
237237

238+
// Home Assistant MQTT auto-discovery configuration
239+
viper.SetDefault("realtime.mqtt.homeassistant.enabled", false)
240+
viper.SetDefault("realtime.mqtt.homeassistant.discovery_prefix", "homeassistant")
241+
viper.SetDefault("realtime.mqtt.homeassistant.device_name", "BirdNET-Go")
242+
238243
// Privacy filter configuration
239244
viper.SetDefault("realtime.privacyfilter.enabled", true)
240245
viper.SetDefault("realtime.privacyfilter.debug", false)

0 commit comments

Comments
 (0)