feat(hksv): add motion detection API and enhance server handling for consumers

This commit is contained in:
Sergey Krashevich
2026-03-07 18:54:50 +03:00
parent 9c901dc995
commit e3d1085a6d
6 changed files with 143 additions and 38 deletions
+4
View File
@@ -170,6 +170,10 @@ Set threshold between "noise" and "real motion". In this example, 2.0 is a good
**Motion API:** **Motion API:**
```bash ```bash
# Get motion status
curl "http://localhost:1984/api/homekit/motion?id=outdoor"
# → {"id":"outdoor","motion":false}
# Trigger motion start # Trigger motion start
curl -X POST "http://localhost:1984/api/homekit/motion?id=outdoor" curl -X POST "http://localhost:1984/api/homekit/motion?id=outdoor"
+67 -14
View File
@@ -1,6 +1,7 @@
package homekit package homekit
import ( import (
"encoding/json"
"errors" "errors"
"net" "net"
"net/http" "net/http"
@@ -179,16 +180,18 @@ func (s *go2rtcSnapshotProvider) GetSnapshot(streamName string, width, height in
// go2rtcLiveStreamHandler implements hksv.LiveStreamHandler // go2rtcLiveStreamHandler implements hksv.LiveStreamHandler
type go2rtcLiveStreamHandler struct { type go2rtcLiveStreamHandler struct {
mu sync.Mutex mu sync.Mutex
consumer *homekit.Consumer consumers map[string]*homekit.Consumer
lastSessionID string
} }
func (h *go2rtcLiveStreamHandler) SetupEndpoints(conn net.Conn, offer *camera.SetupEndpointsRequest) (any, error) { func (h *go2rtcLiveStreamHandler) SetupEndpoints(conn net.Conn, offer *camera.SetupEndpointsRequest) (any, error) {
consumer := homekit.NewConsumer(conn, srtp.Server) consumer := homekit.NewConsumer(conn, srtp.Server)
consumer.SetOffer(offer) consumer.SetOffer(offer)
h.mu.Lock() old := h.setConsumer(offer.SessionID, consumer)
h.consumer = consumer if old != nil && old != consumer {
h.mu.Unlock() _ = old.Stop()
}
answer := consumer.GetAnswer() answer := consumer.GetAnswer()
v, err := tlv8.MarshalBase64(answer) v, err := tlv8.MarshalBase64(answer)
@@ -199,9 +202,7 @@ func (h *go2rtcLiveStreamHandler) SetupEndpoints(conn net.Conn, offer *camera.Se
} }
func (h *go2rtcLiveStreamHandler) GetEndpointsResponse() any { func (h *go2rtcLiveStreamHandler) GetEndpointsResponse() any {
h.mu.Lock() consumer := h.latestConsumer()
consumer := h.consumer
h.mu.Unlock()
if consumer == nil { if consumer == nil {
return 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 { func (h *go2rtcLiveStreamHandler) StartStream(streamName string, conf *camera.SelectedStreamConfiguration, connTracker hksv.ConnTracker) error {
h.mu.Lock() sessionID := conf.Control.SessionID
consumer := h.consumer consumer := h.getConsumer(sessionID)
h.mu.Unlock()
if consumer == nil { if consumer == nil {
return errors.New("no consumer") return errors.New("no consumer")
@@ -226,7 +226,12 @@ func (h *go2rtcLiveStreamHandler) StartStream(streamName string, conf *camera.Se
connTracker.AddConn(consumer) connTracker.AddConn(consumer)
stream := streams.Get(streamName) stream := streams.Get(streamName)
if stream == nil {
connTracker.DelConn(consumer)
return errors.New("stream not found: " + streamName)
}
if err := stream.AddConsumer(consumer); err != nil { if err := stream.AddConsumer(consumer); err != nil {
connTracker.DelConn(consumer)
return err return err
} }
@@ -234,22 +239,64 @@ func (h *go2rtcLiveStreamHandler) StartStream(streamName string, conf *camera.Se
_, _ = consumer.WriteTo(nil) _, _ = consumer.WriteTo(nil)
stream.RemoveConsumer(consumer) stream.RemoveConsumer(consumer)
connTracker.DelConn(consumer) connTracker.DelConn(consumer)
h.removeConsumer(sessionID, consumer)
}() }()
return nil return nil
} }
func (h *go2rtcLiveStreamHandler) StopStream(sessionID string, connTracker hksv.ConnTracker) error { func (h *go2rtcLiveStreamHandler) StopStream(sessionID string, connTracker hksv.ConnTracker) error {
h.mu.Lock() consumer := h.getConsumer(sessionID)
consumer := h.consumer
h.mu.Unlock()
if consumer != nil && consumer.SessionID() == sessionID { if consumer != nil {
_ = consumer.Stop() _ = consumer.Stop()
h.removeConsumer(sessionID, consumer)
} }
return nil 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) { func streamHandler(rawURL string) (core.Producer, error) {
if srtp.Server == nil { if srtp.Server == nil {
return nil, errors.New("homekit: can't work without SRTP server") return nil, errors.New("homekit: can't work without SRTP server")
@@ -316,6 +363,12 @@ func apiMotion(w http.ResponseWriter, r *http.Request) {
return return
} }
switch r.Method { 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": case "POST":
srv.SetMotionDetected(true) srv.SetMotionDetected(true)
case "DELETE": case "DELETE":
+3
View File
@@ -465,6 +465,9 @@ detector.Stop()
### Motion Control ### Motion Control
```go ```go
// Check current motion status
detected := srv.MotionDetected()
// Trigger motion detected (for "api" mode or external sensors) // Trigger motion detected (for "api" mode or external sensors)
srv.SetMotionDetected(true) srv.SetMotionDetected(true)
+17 -1
View File
@@ -497,8 +497,11 @@ func (s *Server) SetCharacteristic(conn net.Conn, aid uint8, iid uint64, value a
resp, err := s.liveStream.SetupEndpoints(conn, &offer) resp, err := s.liveStream.SetupEndpoints(conn, &offer)
if err != nil { if err != nil {
s.log.Error().Err(err).Msg("[hksv] setup endpoints failed") 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: case camera.TypeSelectedStreamConfiguration:
if s.liveStream == nil { 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") 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. // TriggerDoorbell triggers a doorbell press event.
func (s *Server) TriggerDoorbell() { func (s *Server) TriggerDoorbell() {
if s.accessory == nil { if s.accessory == nil {
+12 -10
View File
@@ -77,6 +77,16 @@ func ServerHandler(server Server) HandlerFunc {
} }
var writeResponses []hap.JSONCharacter 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 { for _, c := range v.Value {
if c.Value != nil { if c.Value != nil {
@@ -84,31 +94,23 @@ func ServerHandler(server Server) HandlerFunc {
} }
if c.Event != nil { if c.Event != nil {
// subscribe/unsubscribe to events // subscribe/unsubscribe to events
accs := server.GetAccessories(conn) if char := findChar(c.AID, c.IID); char != nil {
for _, acc := range accs {
if char := acc.GetCharacterByID(c.IID); char != nil {
if ev, ok := c.Event.(bool); ok && ev { if ev, ok := c.Event.(bool); ok && ev {
char.AddListener(conn) char.AddListener(conn)
} else { } else {
char.RemoveListener(conn) char.RemoveListener(conn)
} }
break
}
} }
} }
if c.R != nil && *c.R { if c.R != nil && *c.R {
// write-response: return updated value // write-response: return updated value
accs := server.GetAccessories(conn) if char := findChar(c.AID, c.IID); char != nil {
for _, acc := range accs {
if char := acc.GetCharacterByID(c.IID); char != nil {
writeResponses = append(writeResponses, hap.JSONCharacter{ writeResponses = append(writeResponses, hap.JSONCharacter{
AID: c.AID, AID: c.AID,
IID: c.IID, IID: c.IID,
Status: 0, Status: 0,
Value: char.Value, Value: char.Value,
}) })
break
}
} }
} }
} }
+27
View File
@@ -1060,6 +1060,33 @@ paths:
description: Stream not found description: Stream not found
/api/homekit/motion: /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: post:
summary: Trigger motion detection for HKSV camera summary: Trigger motion detection for HKSV camera
description: Sets MotionDetected characteristic to true, which triggers the Home Hub to start recording. description: Sets MotionDetected characteristic to true, which triggers the Home Hub to start recording.