From e3d1085a6df652c7dc949fc6476844f258792b6c Mon Sep 17 00:00:00 2001 From: Sergey Krashevich Date: Sat, 7 Mar 2026 18:54:50 +0300 Subject: [PATCH] feat(hksv): add motion detection API and enhance server handling for consumers --- internal/homekit/README.md | 4 ++ internal/homekit/homekit.go | 83 ++++++++++++++++++++++++++++++------- pkg/hksv/README.md | 3 ++ pkg/hksv/hksv.go | 22 ++++++++-- pkg/homekit/server.go | 42 ++++++++++--------- website/api/openapi.yaml | 27 ++++++++++++ 6 files changed, 143 insertions(+), 38 deletions(-) diff --git a/internal/homekit/README.md b/internal/homekit/README.md index f4fc85f1..8b0bac84 100644 --- a/internal/homekit/README.md +++ b/internal/homekit/README.md @@ -170,6 +170,10 @@ Set threshold between "noise" and "real motion". In this example, 2.0 is a good **Motion API:** ```bash +# Get motion status +curl "http://localhost:1984/api/homekit/motion?id=outdoor" +# → {"id":"outdoor","motion":false} + # Trigger motion start curl -X POST "http://localhost:1984/api/homekit/motion?id=outdoor" diff --git a/internal/homekit/homekit.go b/internal/homekit/homekit.go index 25584e6d..1a85c974 100644 --- a/internal/homekit/homekit.go +++ b/internal/homekit/homekit.go @@ -1,6 +1,7 @@ package homekit import ( + "encoding/json" "errors" "net" "net/http" @@ -178,17 +179,19 @@ func (s *go2rtcSnapshotProvider) GetSnapshot(streamName string, width, height in // go2rtcLiveStreamHandler implements hksv.LiveStreamHandler type go2rtcLiveStreamHandler struct { - mu sync.Mutex - consumer *homekit.Consumer + mu sync.Mutex + consumers map[string]*homekit.Consumer + lastSessionID string } func (h *go2rtcLiveStreamHandler) SetupEndpoints(conn net.Conn, offer *camera.SetupEndpointsRequest) (any, error) { consumer := homekit.NewConsumer(conn, srtp.Server) consumer.SetOffer(offer) - h.mu.Lock() - h.consumer = consumer - h.mu.Unlock() + old := h.setConsumer(offer.SessionID, consumer) + if old != nil && old != consumer { + _ = old.Stop() + } answer := consumer.GetAnswer() v, err := tlv8.MarshalBase64(answer) @@ -199,9 +202,7 @@ func (h *go2rtcLiveStreamHandler) SetupEndpoints(conn net.Conn, offer *camera.Se } func (h *go2rtcLiveStreamHandler) GetEndpointsResponse() any { - h.mu.Lock() - consumer := h.consumer - h.mu.Unlock() + consumer := h.latestConsumer() if consumer == nil { return nil } @@ -211,9 +212,8 @@ func (h *go2rtcLiveStreamHandler) GetEndpointsResponse() any { } func (h *go2rtcLiveStreamHandler) StartStream(streamName string, conf *camera.SelectedStreamConfiguration, connTracker hksv.ConnTracker) error { - h.mu.Lock() - consumer := h.consumer - h.mu.Unlock() + sessionID := conf.Control.SessionID + consumer := h.getConsumer(sessionID) if consumer == nil { return errors.New("no consumer") @@ -226,7 +226,12 @@ func (h *go2rtcLiveStreamHandler) StartStream(streamName string, conf *camera.Se connTracker.AddConn(consumer) stream := streams.Get(streamName) + if stream == nil { + connTracker.DelConn(consumer) + return errors.New("stream not found: " + streamName) + } if err := stream.AddConsumer(consumer); err != nil { + connTracker.DelConn(consumer) return err } @@ -234,22 +239,64 @@ func (h *go2rtcLiveStreamHandler) StartStream(streamName string, conf *camera.Se _, _ = consumer.WriteTo(nil) stream.RemoveConsumer(consumer) connTracker.DelConn(consumer) + h.removeConsumer(sessionID, consumer) }() return nil } func (h *go2rtcLiveStreamHandler) StopStream(sessionID string, connTracker hksv.ConnTracker) error { - h.mu.Lock() - consumer := h.consumer - h.mu.Unlock() + consumer := h.getConsumer(sessionID) - if consumer != nil && consumer.SessionID() == sessionID { + if consumer != nil { _ = consumer.Stop() + h.removeConsumer(sessionID, consumer) } return nil } +func (h *go2rtcLiveStreamHandler) setConsumer(sessionID string, consumer *homekit.Consumer) *homekit.Consumer { + h.mu.Lock() + defer h.mu.Unlock() + + if h.consumers == nil { + h.consumers = map[string]*homekit.Consumer{} + } + + old := h.consumers[sessionID] + h.consumers[sessionID] = consumer + h.lastSessionID = sessionID + return old +} + +func (h *go2rtcLiveStreamHandler) getConsumer(sessionID string) *homekit.Consumer { + h.mu.Lock() + defer h.mu.Unlock() + return h.consumers[sessionID] +} + +func (h *go2rtcLiveStreamHandler) latestConsumer() *homekit.Consumer { + h.mu.Lock() + defer h.mu.Unlock() + return h.consumers[h.lastSessionID] +} + +func (h *go2rtcLiveStreamHandler) removeConsumer(sessionID string, consumer *homekit.Consumer) { + h.mu.Lock() + defer h.mu.Unlock() + + if h.consumers[sessionID] == consumer { + delete(h.consumers, sessionID) + if h.lastSessionID == sessionID { + h.lastSessionID = "" + for id := range h.consumers { + h.lastSessionID = id + break + } + } + } +} + func streamHandler(rawURL string) (core.Producer, error) { if srtp.Server == nil { return nil, errors.New("homekit: can't work without SRTP server") @@ -316,6 +363,12 @@ func apiMotion(w http.ResponseWriter, r *http.Request) { return } switch r.Method { + case "GET": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": id, + "motion": srv.MotionDetected(), + }) case "POST": srv.SetMotionDetected(true) case "DELETE": diff --git a/pkg/hksv/README.md b/pkg/hksv/README.md index b766d08a..99b77b5b 100644 --- a/pkg/hksv/README.md +++ b/pkg/hksv/README.md @@ -465,6 +465,9 @@ detector.Stop() ### Motion Control ```go +// Check current motion status +detected := srv.MotionDetected() + // Trigger motion detected (for "api" mode or external sensors) srv.SetMotionDetected(true) diff --git a/pkg/hksv/hksv.go b/pkg/hksv/hksv.go index eae7727e..af0945ab 100644 --- a/pkg/hksv/hksv.go +++ b/pkg/hksv/hksv.go @@ -107,8 +107,8 @@ type Config struct { // Dependencies (injected by host) Streams StreamProvider - Store PairingStore // optional, nil = no persistence - Snapshots SnapshotProvider // optional, nil = no snapshots + Store PairingStore // optional, nil = no persistence + Snapshots SnapshotProvider // optional, nil = no snapshots LiveStream LiveStreamHandler // optional, nil = HKSV only (no live streaming) Logger zerolog.Logger @@ -497,8 +497,11 @@ func (s *Server) SetCharacteristic(conn net.Conn, aid uint8, iid uint64, value a resp, err := s.liveStream.SetupEndpoints(conn, &offer) if err != nil { s.log.Error().Err(err).Msg("[hksv] setup endpoints failed") + return } - _ = resp // stored by the handler + // Keep the latest response in characteristic value for write-response (r=true) + // and subsequent GET /characteristics reads. + char.Value = resp case camera.TypeSelectedStreamConfiguration: if s.liveStream == nil { @@ -608,6 +611,19 @@ func (s *Server) SetMotionDetected(detected bool) { s.log.Debug().Str("stream", s.stream).Bool("motion", detected).Msg("[hksv] motion") } +// MotionDetected returns the current motion detected state. +func (s *Server) MotionDetected() bool { + if s.accessory == nil { + return false + } + char := s.accessory.GetCharacter("22") // MotionDetected + if char == nil { + return false + } + v, _ := char.Value.(bool) + return v +} + // TriggerDoorbell triggers a doorbell press event. func (s *Server) TriggerDoorbell() { if s.accessory == nil { diff --git a/pkg/homekit/server.go b/pkg/homekit/server.go index 257245a8..91f13ecb 100644 --- a/pkg/homekit/server.go +++ b/pkg/homekit/server.go @@ -77,6 +77,16 @@ func ServerHandler(server Server) HandlerFunc { } var writeResponses []hap.JSONCharacter + findChar := func(aid uint8, iid uint64) *hap.Character { + accs := server.GetAccessories(conn) + for _, acc := range accs { + if acc.AID != aid { + continue + } + return acc.GetCharacterByID(iid) + } + return nil + } for _, c := range v.Value { if c.Value != nil { @@ -84,31 +94,23 @@ func ServerHandler(server Server) HandlerFunc { } if c.Event != nil { // subscribe/unsubscribe to events - accs := server.GetAccessories(conn) - for _, acc := range accs { - if char := acc.GetCharacterByID(c.IID); char != nil { - if ev, ok := c.Event.(bool); ok && ev { - char.AddListener(conn) - } else { - char.RemoveListener(conn) - } - break + if char := findChar(c.AID, c.IID); char != nil { + if ev, ok := c.Event.(bool); ok && ev { + char.AddListener(conn) + } else { + char.RemoveListener(conn) } } } if c.R != nil && *c.R { // write-response: return updated value - accs := server.GetAccessories(conn) - for _, acc := range accs { - if char := acc.GetCharacterByID(c.IID); char != nil { - writeResponses = append(writeResponses, hap.JSONCharacter{ - AID: c.AID, - IID: c.IID, - Status: 0, - Value: char.Value, - }) - break - } + if char := findChar(c.AID, c.IID); char != nil { + writeResponses = append(writeResponses, hap.JSONCharacter{ + AID: c.AID, + IID: c.IID, + Status: 0, + Value: char.Value, + }) } } } diff --git a/website/api/openapi.yaml b/website/api/openapi.yaml index c116728d..ca830834 100644 --- a/website/api/openapi.yaml +++ b/website/api/openapi.yaml @@ -1060,6 +1060,33 @@ paths: description: Stream not found /api/homekit/motion: + get: + summary: Get motion detection status + description: Returns current MotionDetected state for the HKSV camera. + tags: [ HomeKit ] + parameters: + - name: id + in: query + description: Stream name / server ID + required: true + schema: { type: string } + example: outdoor + responses: + "200": + description: Motion status + content: + application/json: + schema: + type: object + properties: + id: + type: string + example: outdoor + motion: + type: boolean + example: false + "404": + description: Server not found post: summary: Trigger motion detection for HKSV camera description: Sets MotionDetected characteristic to true, which triggers the Home Hub to start recording.