From d7fe09ec9e7ce7c3fa974799bbf4d353ca7aecb1 Mon Sep 17 00:00:00 2001 From: Julius Vogt Date: Thu, 12 Mar 2026 13:52:55 +0100 Subject: [PATCH 1/2] Add HomeKit doorbell button press event support Subscribe to the Programmable Switch Event characteristic (HAP type "73") on HomeKit doorbells and forward presses to a configurable webhook URL, enabling Home Assistant automations without unpairing the camera from go2rtc. Adds a persistent background HAP connection (separate from the video stream) with auto-reconnect, 30s keepalive pings, and an SSE endpoint at /api/homekit/events for debugging. New config section: events: doorbell: stream: "my-doorbell" webhook: "http://homeassistant.local:8123/api/webhook/doorbell" Supports single_press, double_press, and long_press events. Co-Authored-By: Claude Opus 4.6 --- internal/homekit/api.go | 30 ++++ internal/homekit/events.go | 286 ++++++++++++++++++++++++++++++++ internal/homekit/events_test.go | 143 ++++++++++++++++ internal/homekit/homekit.go | 3 + pkg/hap/client.go | 31 ++++ 5 files changed, 493 insertions(+) create mode 100644 internal/homekit/events.go create mode 100644 internal/homekit/events_test.go diff --git a/internal/homekit/api.go b/internal/homekit/api.go index 885a40fa0..ade5fe5d0 100644 --- a/internal/homekit/api.go +++ b/internal/homekit/api.go @@ -1,6 +1,7 @@ package homekit import ( + "encoding/json" "errors" "fmt" "io" @@ -168,6 +169,35 @@ func apiUnpair(id string) error { return app.PatchConfig([]string{"streams", id}, nil) } +func apiHomekitEvents(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming not supported", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + flusher.Flush() + + ch := make(chan DoorbellEvent, 8) + addSSEListener(ch) + defer removeSSEListener(ch) + + ctx := r.Context() + for { + select { + case <-ctx.Done(): + return + case ev := <-ch: + data, _ := json.Marshal(ev) + fmt.Fprintf(w, "event: doorbell\ndata: %s\n\n", data) + flusher.Flush() + } + } +} + func findHomeKitURLs() map[string]*url.URL { urls := map[string]*url.URL{} for name, sources := range streams.GetAllSources() { diff --git a/internal/homekit/events.go b/internal/homekit/events.go new file mode 100644 index 000000000..4a59c1e07 --- /dev/null +++ b/internal/homekit/events.go @@ -0,0 +1,286 @@ +package homekit + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "sync" + "time" + + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/hap" +) + +// HAP service/characteristic types for doorbell +const ( + TypeDoorbellService = "121" // Doorbell service + TypeProgrammableSwitchEvent = "73" // Programmable Switch Event characteristic + TypeStatelessProgrammableSwitch = "89" // Stateless Programmable Switch service (fallback) +) + +// Programmable Switch Event values +const ( + SwitchEventSinglePress = 0 + SwitchEventDoublePress = 1 + SwitchEventLongPress = 2 +) + +// DoorbellEvent is sent to webhook and SSE listeners when the doorbell is pressed. +type DoorbellEvent struct { + Stream string `json:"stream"` + Event string `json:"event"` + Value int `json:"value"` + Timestamp string `json:"timestamp"` +} + +type eventConfig struct { + Stream string `yaml:"stream"` + Webhook string `yaml:"webhook"` +} + +var ( + sseListenersMu sync.Mutex + sseListeners []chan DoorbellEvent +) + +func addSSEListener(ch chan DoorbellEvent) { + sseListenersMu.Lock() + sseListeners = append(sseListeners, ch) + sseListenersMu.Unlock() +} + +func removeSSEListener(ch chan DoorbellEvent) { + sseListenersMu.Lock() + for i, l := range sseListeners { + if l == ch { + sseListeners = append(sseListeners[:i], sseListeners[i+1:]...) + break + } + } + sseListenersMu.Unlock() +} + +func notifySSEListeners(ev DoorbellEvent) { + sseListenersMu.Lock() + for _, ch := range sseListeners { + select { + case ch <- ev: + default: // don't block if listener is slow + } + } + sseListenersMu.Unlock() +} + +func initEvents() { + var cfg struct { + Mod map[string]eventConfig `yaml:"events"` + } + app.LoadConfig(&cfg) + + if cfg.Mod == nil { + return + } + + for name, conf := range cfg.Mod { + streamName := conf.Stream + if streamName == "" { + streamName = name + } + go eventLoop(name, streamName, conf.Webhook) + } +} + +func eventLoop(name, streamName, webhookURL string) { + const reconnectDelay = 10 * time.Second + + for { + err := runEventListener(name, streamName, webhookURL) + if err != nil { + log.Error().Err(err).Msgf("[events] %s listener failed, reconnecting in %s", name, reconnectDelay) + } else { + log.Warn().Msgf("[events] %s listener disconnected, reconnecting in %s", name, reconnectDelay) + } + time.Sleep(reconnectDelay) + } +} + +func runEventListener(name, streamName, webhookURL string) error { + // Get the homekit URL from the stream + stream := streams.Get(streamName) + if stream == nil { + return fmt.Errorf("stream %q not found", streamName) + } + + rawURL := findHomeKitURL(stream.Sources()) + if rawURL == "" { + return fmt.Errorf("stream %q has no homekit source", streamName) + } + + log.Info().Msgf("[events] %s: connecting to %s", name, streamName) + + client, err := hap.Dial(rawURL) + if err != nil { + return fmt.Errorf("dial: %w", err) + } + defer client.Close() + + // Find the Programmable Switch Event characteristic + acc, err := client.GetFirstAccessory() + if err != nil { + return fmt.Errorf("get accessories: %w", err) + } + + switchEventIID := findSwitchEventIID(acc) + if switchEventIID == 0 { + return fmt.Errorf("no Programmable Switch Event characteristic found on %q", streamName) + } + + log.Info().Msgf("[events] %s: found switch event characteristic IID=%d", name, switchEventIID) + + // Channel to signal when connection drops + done := make(chan error, 1) + + // Set up event handler before starting the events reader + client.OnEvent = func(res *http.Response) { + body, err := io.ReadAll(res.Body) + if err != nil { + log.Error().Err(err).Msgf("[events] %s: read event body", name) + return + } + + var chars hap.JSONCharacters + if err := json.Unmarshal(body, &chars); err != nil { + log.Error().Err(err).Msgf("[events] %s: unmarshal event", name) + return + } + + for _, char := range chars.Value { + if char.IID != switchEventIID { + continue + } + + value := 0 + if v, ok := char.Value.(float64); ok { + value = int(v) + } + + eventName := "single_press" + switch value { + case SwitchEventDoublePress: + eventName = "double_press" + case SwitchEventLongPress: + eventName = "long_press" + } + + log.Info().Msgf("[events] %s: doorbell %s", name, eventName) + + ev := DoorbellEvent{ + Stream: name, + Event: eventName, + Value: value, + Timestamp: time.Now().UTC().Format(time.RFC3339), + } + + notifySSEListeners(ev) + + if webhookURL != "" { + go fireWebhook(webhookURL, ev) + } + } + } + + // Start the events reader goroutine — this demuxes events from + // regular HTTP responses on the encrypted HAP connection. + client.StartEventsReader() + + // Subscribe to the Programmable Switch Event characteristic + if err := client.SubscribeEvent(switchEventIID); err != nil { + return fmt.Errorf("subscribe event: %w", err) + } + + log.Info().Msgf("[events] %s: subscribed to doorbell events", name) + + // Keep the connection alive by reading until it drops. + // The eventsReader goroutine handles all incoming data; when the + // connection closes, the res channel will be closed and we'll get + // an error or zero-value read here. + // + // We use a keepalive ping (reading a characteristic) to detect + // dead connections faster than TCP timeouts. + go func() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for range ticker.C { + if _, err := client.GetCharacters(fmt.Sprintf("1.%d", switchEventIID)); err != nil { + done <- fmt.Errorf("keepalive failed: %w", err) + return + } + } + }() + + return <-done +} + +// findSwitchEventIID searches the accessory for a Programmable Switch Event +// characteristic. It first looks in the Doorbell service (type "121"), then +// falls back to Stateless Programmable Switch (type "89"). +func findSwitchEventIID(acc *hap.Accessory) uint64 { + // Try Doorbell service first + for _, svc := range acc.Services { + if svc.Type == TypeDoorbellService { + for _, char := range svc.Characters { + if char.Type == TypeProgrammableSwitchEvent { + return char.IID + } + } + } + } + + // Fallback: Stateless Programmable Switch service + for _, svc := range acc.Services { + if svc.Type == TypeStatelessProgrammableSwitch { + for _, char := range svc.Characters { + if char.Type == TypeProgrammableSwitchEvent { + return char.IID + } + } + } + } + + // Last resort: any service with the characteristic + for _, svc := range acc.Services { + for _, char := range svc.Characters { + if char.Type == TypeProgrammableSwitchEvent { + return char.IID + } + } + } + + return 0 +} + +func fireWebhook(url string, ev DoorbellEvent) { + body, err := json.Marshal(ev) + if err != nil { + log.Error().Err(err).Msg("[events] marshal webhook body") + return + } + + httpClient := &http.Client{Timeout: 10 * time.Second} + resp, err := httpClient.Post(url, "application/json", bytes.NewReader(body)) + if err != nil { + log.Error().Err(err).Msgf("[events] webhook POST to %s", url) + return + } + defer resp.Body.Close() + _, _ = io.ReadAll(resp.Body) + + if resp.StatusCode >= 400 { + log.Warn().Msgf("[events] webhook %s returned status %d", url, resp.StatusCode) + } else { + log.Trace().Msgf("[events] webhook %s returned status %d", url, resp.StatusCode) + } +} diff --git a/internal/homekit/events_test.go b/internal/homekit/events_test.go new file mode 100644 index 000000000..aef03b5d6 --- /dev/null +++ b/internal/homekit/events_test.go @@ -0,0 +1,143 @@ +package homekit + +import ( + "testing" + + "github.com/AlexxIT/go2rtc/pkg/hap" +) + +func TestFindSwitchEventIID_DoorbellService(t *testing.T) { + acc := &hap.Accessory{ + AID: 1, + Services: []*hap.Service{ + {Type: "3E"}, // AccessoryInformation + { + Type: TypeDoorbellService, // "121" + Characters: []*hap.Character{ + {Type: "73", IID: 900}, + }, + }, + }, + } + + iid := findSwitchEventIID(acc) + if iid != 900 { + t.Fatalf("expected IID 900, got %d", iid) + } +} + +func TestFindSwitchEventIID_StatelessSwitch(t *testing.T) { + acc := &hap.Accessory{ + AID: 1, + Services: []*hap.Service{ + {Type: "3E"}, + { + Type: TypeStatelessProgrammableSwitch, // "89" + Characters: []*hap.Character{ + {Type: "73", IID: 500}, + }, + }, + }, + } + + iid := findSwitchEventIID(acc) + if iid != 500 { + t.Fatalf("expected IID 500, got %d", iid) + } +} + +func TestFindSwitchEventIID_DoorbellPreferred(t *testing.T) { + // If both Doorbell and StatelessProgrammableSwitch exist, + // the Doorbell service should be preferred. + acc := &hap.Accessory{ + AID: 1, + Services: []*hap.Service{ + { + Type: TypeStatelessProgrammableSwitch, + Characters: []*hap.Character{ + {Type: "73", IID: 500}, + }, + }, + { + Type: TypeDoorbellService, + Characters: []*hap.Character{ + {Type: "73", IID: 900}, + }, + }, + }, + } + + iid := findSwitchEventIID(acc) + if iid != 900 { + t.Fatalf("expected IID 900 (doorbell preferred), got %d", iid) + } +} + +func TestFindSwitchEventIID_FallbackAnyService(t *testing.T) { + acc := &hap.Accessory{ + AID: 1, + Services: []*hap.Service{ + { + Type: "FF", // unknown service + Characters: []*hap.Character{ + {Type: "73", IID: 123}, + }, + }, + }, + } + + iid := findSwitchEventIID(acc) + if iid != 123 { + t.Fatalf("expected IID 123 (fallback), got %d", iid) + } +} + +func TestFindSwitchEventIID_NotFound(t *testing.T) { + acc := &hap.Accessory{ + AID: 1, + Services: []*hap.Service{ + { + Type: "110", // CameraRTPStreamManagement + Characters: []*hap.Character{ + {Type: "114", IID: 10}, + }, + }, + }, + } + + iid := findSwitchEventIID(acc) + if iid != 0 { + t.Fatalf("expected IID 0 (not found), got %d", iid) + } +} + +func TestSSEListeners(t *testing.T) { + ch := make(chan DoorbellEvent, 8) + addSSEListener(ch) + + ev := DoorbellEvent{ + Stream: "test", + Event: "single_press", + Value: 0, + } + notifySSEListeners(ev) + + select { + case got := <-ch: + if got.Stream != "test" || got.Event != "single_press" { + t.Fatalf("unexpected event: %+v", got) + } + default: + t.Fatal("expected event on channel") + } + + removeSSEListener(ch) + notifySSEListeners(ev) + + select { + case <-ch: + t.Fatal("should not receive event after removal") + default: + // expected + } +} diff --git a/internal/homekit/homekit.go b/internal/homekit/homekit.go index 59b84b3ba..27c67f9a4 100644 --- a/internal/homekit/homekit.go +++ b/internal/homekit/homekit.go @@ -36,8 +36,11 @@ func Init() { api.HandleFunc("api/homekit", apiHomekit) api.HandleFunc("api/homekit/accessories", apiHomekitAccessories) + api.HandleFunc("api/homekit/events", apiHomekitEvents) api.HandleFunc("api/discovery/homekit", apiDiscovery) + initEvents() + if cfg.Mod == nil { return } diff --git a/pkg/hap/client.go b/pkg/hap/client.go index ed4faa02c..4d6307ae5 100644 --- a/pkg/hap/client.go +++ b/pkg/hap/client.go @@ -239,6 +239,37 @@ func (c *Client) Close() error { return c.Conn.Close() } +// StartEventsReader starts the background goroutine that demuxes regular +// HTTP responses from EVENT/1.0 push notifications on the encrypted +// connection. Must be called before SubscribeEvent and before any +// concurrent request/response exchange that could race with events. +func (c *Client) StartEventsReader() { + go c.eventsReader() +} + +// SubscribeEvent asks the accessory to push EVENT notifications for the +// characteristic identified by its IID. The events will arrive on the +// OnEvent callback. StartEventsReader must be called first. +func (c *Client) SubscribeEvent(iid uint64) error { + v := JSONCharacters{ + Value: []JSONCharacter{ + {AID: DeviceAID, IID: iid, Event: true}, + }, + } + body, err := json.Marshal(v) + if err != nil { + return err + } + + res, err := c.Put(PathCharacteristics, MimeJSON, bytes.NewReader(body)) + if err != nil { + return err + } + + _, _ = io.ReadAll(res.Body) + return nil +} + func (c *Client) eventsReader() { c.res = make(chan *http.Response) From d32a0ee99f9f11fa84d23544cf8adace08ab350f Mon Sep 17 00:00:00 2001 From: Julius Vogt Date: Thu, 26 Mar 2026 16:10:20 +0100 Subject: [PATCH 2/2] Address review feedback: harden webhook, add docs - Shared HTTP client with redirect blocking and URL validation - Drain response body with LimitReader, granular status logging - Add doorbell events section to homekit README - Add /api/homekit/events SSE endpoint to OpenAPI schema Co-Authored-By: Sergey Krashevich Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/homekit/README.md | 58 ++++++++++++++++++++++++++++++++++++ internal/homekit/events.go | 60 +++++++++++++++++++++++++++++++++----- website/api/openapi.yaml | 14 +++++++++ 3 files changed, 124 insertions(+), 8 deletions(-) diff --git a/internal/homekit/README.md b/internal/homekit/README.md index 0e78fcc58..2ccc750df 100644 --- a/internal/homekit/README.md +++ b/internal/homekit/README.md @@ -95,3 +95,61 @@ streams: homekit: aqara1: # same stream ID from streams list ``` + +## Doorbell Events + +You can subscribe to doorbell button press events from paired HomeKit doorbells (e.g. Aqara G4). Events are forwarded to a webhook URL and/or available via an SSE endpoint. + +This runs a separate HAP connection alongside the video stream, with auto-reconnect and keepalive pings. + +### Events Configuration + +```yaml +streams: + doorbell: homekit://... + +events: + doorbell: + stream: "doorbell" # references the stream name above (optional, defaults to event name) + webhook: "http://homeassistant.local:8123/api/webhook/doorbell_rang" +``` + +The webhook receives a JSON POST on each button press: + +```json +{ + "stream": "doorbell", + "event": "single_press", + "value": 0, + "timestamp": "2026-03-12T10:30:00Z" +} +``` + +Supported events: `single_press`, `double_press`, `long_press`. + +### SSE Endpoint + +Connect to `/api/homekit/events` for a Server-Sent Events stream of all doorbell events: + +``` +GET /api/homekit/events + +event: doorbell +data: {"stream":"doorbell","event":"single_press","value":0,"timestamp":"..."} +``` + +### Home Assistant Example + +```yaml +automation: + - alias: "Doorbell Rang" + trigger: + - platform: webhook + webhook_id: doorbell_rang + local_only: true + action: + - service: notify.mobile_app_phone + data: + title: "Doorbell" + message: "Someone is at the door!" +``` diff --git a/internal/homekit/events.go b/internal/homekit/events.go index 4a59c1e07..cf1d87619 100644 --- a/internal/homekit/events.go +++ b/internal/homekit/events.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + neturl "net/url" "sync" "time" @@ -41,7 +42,18 @@ type eventConfig struct { Webhook string `yaml:"webhook"` } +const maxWebhookResponseBody = 1 << 20 // 1 MiB + var ( + webhookHTTPClient = &http.Client{ + Timeout: 10 * time.Second, + // Redirects can silently move a webhook to another host. + // Keep the configured destination explicit. + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + sseListenersMu sync.Mutex sseListeners []chan DoorbellEvent ) @@ -263,24 +275,56 @@ func findSwitchEventIID(acc *hap.Accessory) uint64 { } func fireWebhook(url string, ev DoorbellEvent) { + webhookURL, err := validateWebhookURL(url) + if err != nil { + log.Error().Err(err).Str("url", url).Msg("[events] invalid webhook URL") + return + } + body, err := json.Marshal(ev) if err != nil { log.Error().Err(err).Msg("[events] marshal webhook body") return } - httpClient := &http.Client{Timeout: 10 * time.Second} - resp, err := httpClient.Post(url, "application/json", bytes.NewReader(body)) + req, err := http.NewRequest(http.MethodPost, webhookURL.String(), bytes.NewReader(body)) if err != nil { - log.Error().Err(err).Msgf("[events] webhook POST to %s", url) + log.Error().Err(err).Str("url", url).Msg("[events] build webhook request") + return + } + req.Header.Set("Content-Type", "application/json") + + resp, err := webhookHTTPClient.Do(req) + if err != nil { + log.Error().Err(err).Str("url", url).Msg("[events] webhook POST") return } defer resp.Body.Close() - _, _ = io.ReadAll(resp.Body) - if resp.StatusCode >= 400 { - log.Warn().Msgf("[events] webhook %s returned status %d", url, resp.StatusCode) - } else { - log.Trace().Msgf("[events] webhook %s returned status %d", url, resp.StatusCode) + if _, err = io.Copy(io.Discard, io.LimitReader(resp.Body, maxWebhookResponseBody)); err != nil { + log.Debug().Err(err).Str("url", url).Msg("[events] drain webhook response") + } + + switch { + case resp.StatusCode >= 400: + log.Warn().Str("url", url).Int("status", resp.StatusCode).Msg("[events] webhook returned error status") + case resp.StatusCode >= 300: + log.Warn().Str("url", url).Int("status", resp.StatusCode).Msg("[events] webhook returned redirect status") + default: + log.Trace().Str("url", url).Int("status", resp.StatusCode).Msg("[events] webhook returned status") + } +} + +func validateWebhookURL(rawURL string) (*neturl.URL, error) { + webhookURL, err := neturl.Parse(rawURL) + if err != nil { + return nil, fmt.Errorf("parse webhook URL: %w", err) + } + if webhookURL.Scheme != "http" && webhookURL.Scheme != "https" { + return nil, fmt.Errorf("unsupported webhook scheme %q", webhookURL.Scheme) + } + if webhookURL.Host == "" { + return nil, fmt.Errorf("webhook URL missing host") } + return webhookURL, nil } diff --git a/website/api/openapi.yaml b/website/api/openapi.yaml index b61105724..85fde74ea 100644 --- a/website/api/openapi.yaml +++ b/website/api/openapi.yaml @@ -1077,6 +1077,20 @@ paths: "404": description: Stream not found + /api/homekit/events: + get: + summary: Doorbell events (SSE) + description: | + Server-Sent Events stream of HomeKit doorbell button press events. + Events are sent with type `doorbell` and a JSON data payload. + tags: [ HomeKit ] + responses: + "200": + description: SSE stream + content: + text/event-stream: + example: "event: doorbell\ndata: {\"stream\":\"doorbell\",\"event\":\"single_press\",\"value\":0,\"timestamp\":\"2026-03-12T10:30:00Z\"}\n\n" + /pair-setup: post: summary: HomeKit Pair Setup (HAP)