Merge branch 'hksv' into beta

# Conflicts:
#	pkg/hap/hds/hds_test.go
This commit is contained in:
Sergey Krashevich
2026-03-10 23:53:40 +03:00
35 changed files with 8399 additions and 481 deletions
+103
View File
@@ -79,6 +79,109 @@ homekit:
name: Dahua camera # custom camera name, default: generated from stream ID
device_id: dahua1 # custom ID, default: generated from stream ID
device_private: dahua1 # custom key, default: generated from stream ID
speaker: true # enable 2-way audio (default: false, enable only if camera has a speaker)
```
### HKSV (HomeKit Secure Video)
go2rtc can expose any camera as a HomeKit Secure Video (HKSV) camera. This allows Apple Home to record video clips to iCloud when motion is detected.
**Requirements:**
- Apple Home Hub (Apple TV, HomePod or iPad) on the same network
- iCloud storage plan with HomeKit Secure Video support
- Camera source with H264 video (AAC audio recommended)
**Minimal HKSV config**
```yaml
streams:
outdoor: rtsp://admin:password@192.168.1.123/stream1
homekit:
outdoor:
hksv: true # enable HomeKit Secure Video
motion: continuous # always report motion, Home Hub decides what to record
```
**Full HKSV config**
```yaml
streams:
outdoor:
- rtsp://admin:password@192.168.1.123/stream1
- ffmpeg:outdoor#video=h264#hardware # transcode to H264 if needed
- ffmpeg:outdoor#audio=aac # AAC-LC audio for HKSV recording
homekit:
outdoor:
pin: 12345678
name: Outdoor Camera
hksv: true
motion: api # motion triggered via API
```
**HKSV Doorbell config**
```yaml
homekit:
front_door:
category_id: doorbell
hksv: true
motion: api
```
**Motion modes:**
- `continuous` — MotionDetected is always true; Home Hub continuously receives video and decides what to save. Simplest setup, recommended for most cameras.
- `detect` — automatic motion detection by analyzing H264 P-frame sizes. No external dependencies or CPU-heavy decoding. Works with any H264 source and resolution. Compares each P-frame size against an adaptive baseline using EMA (exponential moving average). When a P-frame exceeds the threshold ratio, motion is triggered with a 30s hold time and 5s cooldown.
- `api` — motion is triggered externally via HTTP API. Use this with Frigate, ONVIF events, or any other motion detection system.
**Motion detect config:**
```yaml
homekit:
outdoor:
hksv: true
motion: detect
motion_threshold: 1.0 # P-frame size / baseline ratio to trigger motion (default: 2.0)
```
The `motion_threshold` controls sensitivity — it's the ratio of P-frame size to the adaptive baseline. When a P-frame exceeds `baseline × threshold`, motion is triggered.
| Scenario | threshold | Notes |
|---|---|---|
| Quiet indoor scene | 1.31.5 | Low noise, stable baseline, even small motion is visible |
| Standard camera (yard, hallway) | 2.0 (default) | Good balance between sensitivity and false positives |
| Outdoor with trees/shadows/wind | 2.53.0 | Wind and shadows produce medium P-frames, need margin |
| Busy street / complex scene | 3.05.0 | Lots of background motion, react only to large events |
Values below 1.0 are meaningless (triggers on every frame). Values above 5.0 require very large motion (person filling half the frame).
**How to tune:** set `log.level: trace` and watch `motion: status` lines — they show current `ratio`. Walk in front of the camera and note the ratio values:
```
motion: status baseline=5000 ratio=0.95 ← quiet
motion: status baseline=5000 ratio=3.21 ← person walked by
motion: status baseline=5000 ratio=1.40 ← shadow/wind
```
Set threshold between "noise" and "real motion". In this example, 2.0 is a good choice (ignores 1.4, catches 3.2).
**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"
# Clear motion
curl -X DELETE "http://localhost:1984/api/homekit/motion?id=outdoor"
# Trigger doorbell ring
curl -X POST "http://localhost:1984/api/homekit/doorbell?id=front_door"
```
**Proxy HomeKit camera**
+295 -61
View File
@@ -1,18 +1,26 @@
package homekit
import (
"encoding/json"
"errors"
"net"
"net/http"
"strings"
"sync"
"time"
"github.com/AlexxIT/go2rtc/internal/api"
"github.com/AlexxIT/go2rtc/internal/app"
"github.com/AlexxIT/go2rtc/internal/ffmpeg"
"github.com/AlexxIT/go2rtc/internal/srtp"
"github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/hap"
"github.com/AlexxIT/go2rtc/pkg/hap/camera"
"github.com/AlexxIT/go2rtc/pkg/hap/tlv8"
"github.com/AlexxIT/go2rtc/pkg/hksv"
"github.com/AlexxIT/go2rtc/pkg/homekit"
"github.com/AlexxIT/go2rtc/pkg/magic"
"github.com/AlexxIT/go2rtc/pkg/mdns"
"github.com/rs/zerolog"
)
@@ -20,12 +28,18 @@ import (
func Init() {
var cfg struct {
Mod map[string]struct {
Pin string `yaml:"pin"`
Name string `yaml:"name"`
DeviceID string `yaml:"device_id"`
DevicePrivate string `yaml:"device_private"`
CategoryID string `yaml:"category_id"`
Pairings []string `yaml:"pairings"`
Pin string `yaml:"pin"`
Name string `yaml:"name"`
DeviceID string `yaml:"device_id"`
DevicePrivate string `yaml:"device_private"`
CategoryID string `yaml:"category_id"`
Pairings []string `yaml:"pairings"`
HKSV bool `yaml:"hksv"`
Motion string `yaml:"motion"`
MotionThreshold float64 `yaml:"motion_threshold"`
MotionHoldTime float64 `yaml:"motion_hold_time"`
OnvifURL string `yaml:"onvif_url"`
Speaker *bool `yaml:"speaker"`
} `yaml:"homekit"`
}
app.LoadConfig(&cfg)
@@ -36,14 +50,16 @@ func Init() {
api.HandleFunc("api/homekit", apiHomekit)
api.HandleFunc("api/homekit/accessories", apiHomekitAccessories)
api.HandleFunc("api/homekit/motion", apiMotion)
api.HandleFunc("api/homekit/doorbell", apiDoorbell)
api.HandleFunc("api/discovery/homekit", apiDiscovery)
if cfg.Mod == nil {
return
}
hosts = map[string]*server{}
servers = map[string]*server{}
hosts = map[string]*hksv.Server{}
servers = map[string]*hksv.Server{}
var entries []*mdns.ServiceEntry
for id, conf := range cfg.Mod {
@@ -53,65 +69,74 @@ func Init() {
continue
}
if conf.Pin == "" {
conf.Pin = "19550224" // default PIN
var proxyURL string
if url := findHomeKitURL(stream.Sources()); url != "" {
proxyURL = url
}
pin, err := hap.SanitizePin(conf.Pin)
// Remap "onvif" → "api" for hksv.Server; ONVIF watcher drives motion externally.
motionMode := conf.Motion
if motionMode == "onvif" {
motionMode = "api"
}
srv, err := hksv.NewServer(hksv.Config{
StreamName: id,
Pin: conf.Pin,
Name: conf.Name,
DeviceID: conf.DeviceID,
DevicePrivate: conf.DevicePrivate,
CategoryID: conf.CategoryID,
Pairings: conf.Pairings,
ProxyURL: proxyURL,
HKSV: conf.HKSV,
MotionMode: motionMode,
MotionThreshold: conf.MotionThreshold,
Speaker: conf.Speaker,
UserAgent: app.UserAgent,
Version: app.Version,
Streams: &go2rtcStreamProvider{},
Store: &go2rtcPairingStore{},
Snapshots: &go2rtcSnapshotProvider{},
LiveStream: &go2rtcLiveStreamHandler{},
Logger: log,
Port: uint16(api.Port),
})
if err != nil {
log.Error().Err(err).Caller().Send()
log.Error().Err(err).Str("stream", id).Msg("[homekit] create server failed")
continue
}
deviceID := calcDeviceID(conf.DeviceID, id) // random MAC-address
name := calcName(conf.Name, deviceID)
setupID := calcSetupID(id)
srv := &server{
stream: id,
pairings: conf.Pairings,
setupID: setupID,
// Start ONVIF motion watcher if configured.
if conf.Motion == "onvif" {
onvifURL := conf.OnvifURL
if onvifURL == "" {
sources := stream.Sources()
log.Debug().Str("stream", id).Strs("sources", sources).
Msg("[homekit] onvif motion: searching for ONVIF URL in stream sources")
onvifURL = findOnvifURL(sources)
}
if onvifURL == "" {
log.Warn().Str("stream", id).Msg("[homekit] onvif motion: no ONVIF URL found, set onvif_url or use onvif:// stream source")
} else {
holdTime := time.Duration(conf.MotionHoldTime) * time.Second
if holdTime <= 0 {
holdTime = 30 * time.Second
}
log.Info().Str("stream", id).Str("onvif_url", onvifURL).
Dur("hold_time", holdTime).Msg("[homekit] starting ONVIF motion watcher")
startOnvifMotionWatcher(srv, onvifURL, holdTime, log)
}
}
srv.hap = &hap.Server{
Pin: pin,
DeviceID: deviceID,
DevicePrivate: calcDevicePrivate(conf.DevicePrivate, id),
GetClientPublic: srv.GetPair,
}
entry := srv.MDNSEntry()
entries = append(entries, entry)
srv.mdns = &mdns.ServiceEntry{
Name: name,
Port: uint16(api.Port),
Info: map[string]string{
hap.TXTConfigNumber: "1",
hap.TXTFeatureFlags: "0",
hap.TXTDeviceID: deviceID,
hap.TXTModel: app.UserAgent,
hap.TXTProtoVersion: "1.1",
hap.TXTStateNumber: "1",
hap.TXTStatusFlags: hap.StatusNotPaired,
hap.TXTCategory: calcCategoryID(conf.CategoryID),
hap.TXTSetupHash: hap.SetupHash(setupID, deviceID),
},
}
entries = append(entries, srv.mdns)
srv.UpdateStatus()
if url := findHomeKitURL(stream.Sources()); url != "" {
// 1. Act as transparent proxy for HomeKit camera
srv.proxyURL = url
} else {
// 2. Act as basic HomeKit camera
srv.accessory = camera.NewAccessory("AlexxIT", "go2rtc", name, "-", app.Version)
}
host := srv.mdns.Host(mdns.ServiceHAP)
host := entry.Host(mdns.ServiceHAP)
hosts[host] = srv
servers[id] = srv
log.Trace().Msgf("[homekit] new server: %s", srv.mdns)
log.Trace().Msgf("[homekit] new server: %s", entry)
}
api.HandleFunc(hap.PathPairSetup, hapHandler)
@@ -125,8 +150,183 @@ func Init() {
}
var log zerolog.Logger
var hosts map[string]*server
var servers map[string]*server
var hosts map[string]*hksv.Server
var servers map[string]*hksv.Server
// go2rtcStreamProvider implements hksv.StreamProvider
type go2rtcStreamProvider struct{}
func (p *go2rtcStreamProvider) AddConsumer(name string, cons core.Consumer) error {
stream := streams.Get(name)
if stream == nil {
return errors.New("stream not found: " + name)
}
return stream.AddConsumer(cons)
}
func (p *go2rtcStreamProvider) RemoveConsumer(name string, cons core.Consumer) {
if s := streams.Get(name); s != nil {
s.RemoveConsumer(cons)
}
}
// go2rtcPairingStore implements hksv.PairingStore
type go2rtcPairingStore struct{}
func (s *go2rtcPairingStore) SavePairings(name string, pairings []string) error {
return app.PatchConfig([]string{"homekit", name, "pairings"}, pairings)
}
// go2rtcSnapshotProvider implements hksv.SnapshotProvider
type go2rtcSnapshotProvider struct{}
func (s *go2rtcSnapshotProvider) GetSnapshot(streamName string, width, height int) ([]byte, error) {
stream := streams.Get(streamName)
if stream == nil {
return nil, errors.New("stream not found: " + streamName)
}
cons := magic.NewKeyframe()
if err := stream.AddConsumer(cons); err != nil {
return nil, err
}
once := &core.OnceBuffer{}
_, _ = cons.WriteTo(once)
b := once.Buffer()
stream.RemoveConsumer(cons)
switch cons.CodecName() {
case core.CodecH264, core.CodecH265:
var err error
if b, err = ffmpeg.JPEGWithScale(b, width, height); err != nil {
return nil, err
}
}
return b, nil
}
// go2rtcLiveStreamHandler implements hksv.LiveStreamHandler
type go2rtcLiveStreamHandler struct {
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)
old := h.setConsumer(offer.SessionID, consumer)
if old != nil && old != consumer {
_ = old.Stop()
}
answer := consumer.GetAnswer()
v, err := tlv8.MarshalBase64(answer)
if err != nil {
return nil, err
}
return v, nil
}
func (h *go2rtcLiveStreamHandler) GetEndpointsResponse() any {
consumer := h.latestConsumer()
if consumer == nil {
return nil
}
answer := consumer.GetAnswer()
v, _ := tlv8.MarshalBase64(answer)
return v
}
func (h *go2rtcLiveStreamHandler) StartStream(streamName string, conf *camera.SelectedStreamConfiguration, connTracker hksv.ConnTracker) error {
sessionID := conf.Control.SessionID
consumer := h.getConsumer(sessionID)
if consumer == nil {
return errors.New("no consumer")
}
if !consumer.SetConfig(conf) {
return errors.New("wrong config")
}
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
}
go func() {
_, _ = 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 {
consumer := h.getConsumer(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 {
@@ -145,7 +345,7 @@ func streamHandler(rawURL string) (core.Producer, error) {
return client, err
}
func resolve(host string) *server {
func resolve(host string) *hksv.Server {
if len(hosts) == 1 {
for _, srv := range hosts {
return srv
@@ -158,9 +358,6 @@ func resolve(host string) *server {
}
func hapHandler(w http.ResponseWriter, r *http.Request) {
// Can support multiple HomeKit cameras on single port ONLY for Apple devices.
// Doesn't support Home Assistant and any other open source projects
// because they don't send the host header in requests.
srv := resolve(r.Host)
if srv == nil {
log.Error().Msg("[homekit] unknown host: " + r.Host)
@@ -189,6 +386,43 @@ func findHomeKitURL(sources []string) string {
return ""
}
func apiMotion(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id")
srv := servers[id]
if srv == nil {
http.Error(w, "server not found: "+id, http.StatusNotFound)
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":
srv.SetMotionDetected(false)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func apiDoorbell(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
id := r.URL.Query().Get("id")
srv := servers[id]
if srv == nil {
http.Error(w, "server not found: "+id, http.StatusNotFound)
return
}
srv.TriggerDoorbell()
}
func parseBitrate(s string) int {
n := len(s)
if n == 0 {
+287
View File
@@ -0,0 +1,287 @@
package homekit
import (
"strings"
"sync"
"time"
"github.com/AlexxIT/go2rtc/pkg/hksv"
"github.com/AlexxIT/go2rtc/pkg/onvif"
"github.com/rs/zerolog"
)
const (
onvifSubscriptionTimeout = 60 * time.Second
onvifPullTimeout = 30 * time.Second
onvifMessageLimit = 10
onvifRenewMargin = 10 * time.Second
onvifMinReconnectDelay = 5 * time.Second
onvifMaxReconnectDelay = 60 * time.Second
)
type onvifPullPoint interface {
PullMessages(timeout time.Duration, limit int) ([]byte, error)
Renew(timeout time.Duration) error
Unsubscribe() error
}
type onvifPullPointFactory func(rawURL string, timeout time.Duration) (onvifPullPoint, error)
// onvifMotionWatcher subscribes to ONVIF PullPoint events
// and forwards motion state to an hksv.Server.
type onvifMotionWatcher struct {
srv *hksv.Server
onvifURL string
holdTime time.Duration
log zerolog.Logger
now func() time.Time
newPullPoint onvifPullPointFactory
subscriptionTimeout time.Duration
pullTimeout time.Duration
renewMargin time.Duration
messageLimit int
done chan struct{}
once sync.Once
}
func newOnvifMotionWatcher(srv *hksv.Server, onvifURL string, holdTime time.Duration, log zerolog.Logger) *onvifMotionWatcher {
return &onvifMotionWatcher{
srv: srv,
onvifURL: onvifURL,
holdTime: holdTime,
log: log,
now: time.Now,
newPullPoint: newOnvifPullPoint,
subscriptionTimeout: onvifSubscriptionTimeout,
pullTimeout: onvifPullTimeout,
renewMargin: onvifRenewMargin,
messageLimit: onvifMessageLimit,
done: make(chan struct{}),
}
}
// startOnvifMotionWatcher creates and starts a new ONVIF motion watcher.
func startOnvifMotionWatcher(srv *hksv.Server, onvifURL string, holdTime time.Duration, log zerolog.Logger) *onvifMotionWatcher {
w := newOnvifMotionWatcher(srv, onvifURL, holdTime, log)
go w.run()
return w
}
// stop shuts down the watcher goroutine.
func (w *onvifMotionWatcher) stop() {
w.once.Do(func() { close(w.done) })
}
// run is the main loop: create subscription, poll, handle events, reconnect on failure.
func (w *onvifMotionWatcher) run() {
w.log.Debug().Str("url", w.onvifURL).Dur("hold_time", w.holdTime).
Msg("[homekit] onvif motion watcher starting")
delay := onvifMinReconnectDelay
for {
select {
case <-w.done:
w.log.Debug().Msg("[homekit] onvif motion watcher stopped (before connect)")
return
default:
}
w.log.Debug().Str("url", w.onvifURL).Msg("[homekit] onvif motion connecting to camera")
err := w.connectAndPoll()
if err != nil {
w.log.Warn().Err(err).Str("url", w.onvifURL).Msg("[homekit] onvif motion error")
} else {
delay = onvifMinReconnectDelay
}
select {
case <-w.done:
w.log.Debug().Msg("[homekit] onvif motion watcher stopped (after poll)")
return
default:
}
w.log.Debug().Dur("delay", delay).Msg("[homekit] onvif motion reconnecting")
select {
case <-time.After(delay):
case <-w.done:
w.log.Debug().Msg("[homekit] onvif motion watcher stopped (during backoff)")
return
}
delay *= 2
if delay > onvifMaxReconnectDelay {
delay = onvifMaxReconnectDelay
}
}
}
// connectAndPoll creates a subscription and polls for events until an error occurs or stop is called.
func (w *onvifMotionWatcher) connectAndPoll() error {
w.log.Trace().Str("url", w.onvifURL).Dur("timeout", w.subscriptionTimeout).
Msg("[homekit] onvif motion: creating pull point subscription")
sub, err := w.newPullPoint(w.onvifURL, w.subscriptionTimeout)
if err != nil {
w.log.Debug().Err(err).Str("url", w.onvifURL).
Msg("[homekit] onvif motion: pull point creation failed")
return err
}
w.log.Info().Str("url", w.onvifURL).Msg("[homekit] onvif motion subscription created")
defer func() {
w.log.Trace().Msg("[homekit] onvif motion: unsubscribing")
_ = sub.Unsubscribe()
}()
// motionActive tracks whether we've reported motion=true to the HKSV server.
// Hold timer ensures motion stays active for at least holdTime after last trigger,
// regardless of whether the camera sends explicit "motion=false".
// This matches the behavior of the built-in MotionDetector (30s hold time).
motionActive := false
var holdTimer *time.Timer
defer func() {
if holdTimer != nil {
holdTimer.Stop()
}
}()
renewInterval := w.subscriptionRenewInterval()
renewAt := w.now().Add(renewInterval)
w.log.Trace().Dur("renew_interval", renewInterval).
Msg("[homekit] onvif motion: subscription renew scheduled")
pollCount := 0
for {
select {
case <-w.done:
w.log.Debug().Int("polls", pollCount).
Msg("[homekit] onvif motion: poll loop stopped")
return nil
default:
}
if !renewAt.After(w.now()) {
w.log.Trace().Msg("[homekit] onvif motion: renewing subscription")
if err := sub.Renew(w.subscriptionTimeout); err != nil {
w.log.Warn().Err(err).Msg("[homekit] onvif motion: renew failed")
return err
}
renewAt = w.now().Add(renewInterval)
w.log.Trace().Msg("[homekit] onvif motion: subscription renewed")
}
pullTimeout := w.nextPullTimeout(renewAt)
w.log.Trace().Dur("timeout", pullTimeout).Int("limit", w.messageLimit).
Int("poll", pollCount+1).Msg("[homekit] onvif motion: pulling messages")
b, err := sub.PullMessages(pullTimeout, w.messageLimit)
if err != nil {
w.log.Debug().Err(err).Int("polls", pollCount).
Msg("[homekit] onvif motion: pull messages failed")
return err
}
pollCount++
w.log.Trace().Int("bytes", len(b)).Int("poll", pollCount).
Msg("[homekit] onvif motion: pull response received")
if l := w.log.Trace(); l.Enabled() {
l.Str("body", string(b)).Msg("[homekit] onvif motion: raw response")
}
motion, found := onvif.ParseMotionEvents(b)
w.log.Trace().Bool("found", found).Bool("motion", motion).
Bool("active", motionActive).Msg("[homekit] onvif motion: parse result")
if !found {
w.log.Trace().Msg("[homekit] onvif motion: no motion events in response")
continue
}
if motion {
// Motion detected — activate and start/reset hold timer.
if !motionActive {
motionActive = true
w.srv.SetMotionDetected(true)
w.log.Debug().Msg("[homekit] onvif motion: detected")
} else {
w.log.Trace().Msg("[homekit] onvif motion: still active, resetting hold timer")
}
// Reset hold timer on every motion=true event.
if holdTimer != nil {
holdTimer.Stop()
}
holdTimer = time.AfterFunc(w.holdTime, func() {
motionActive = false
w.srv.SetMotionDetected(false)
w.log.Debug().Msg("[homekit] onvif motion: hold expired")
})
} else {
// Camera sent explicit motion=false.
// Do NOT clear immediately — let the hold timer handle it.
// This ensures motion stays active for at least holdTime,
// giving the Home Hub enough time to open the DataStream.
w.log.Debug().Dur("remaining_hold", w.holdTime).
Bool("active", motionActive).
Msg("[homekit] onvif motion: camera reported clear, waiting for hold timer")
}
}
}
func (w *onvifMotionWatcher) subscriptionRenewInterval() time.Duration {
interval := w.subscriptionTimeout - w.renewMargin
if interval <= 0 {
interval = w.subscriptionTimeout / 2
}
if interval <= 0 {
interval = time.Second
}
return interval
}
func (w *onvifMotionWatcher) nextPullTimeout(renewAt time.Time) time.Duration {
timeout := w.pullTimeout
if timeout <= 0 {
timeout = time.Second
}
if untilRenew := renewAt.Sub(w.now()); untilRenew > 0 && untilRenew < timeout {
timeout = untilRenew
}
if timeout <= 0 {
timeout = time.Second
}
return timeout
}
func newOnvifPullPoint(rawURL string, timeout time.Duration) (onvifPullPoint, error) {
client, err := onvif.NewClient(rawURL)
if err != nil {
return nil, err
}
return client.CreatePullPointSubscription(timeout)
}
// findOnvifURL looks for an onvif:// URL in stream sources.
func findOnvifURL(sources []string) string {
for _, src := range sources {
if strings.HasPrefix(src, "onvif://") || strings.HasPrefix(src, "onvif:") {
return src
}
}
return ""
}
+99
View File
@@ -0,0 +1,99 @@
package homekit
import (
"errors"
"testing"
"time"
"github.com/AlexxIT/go2rtc/pkg/hksv"
"github.com/rs/zerolog"
)
func TestOnvifMotionWatcherConnectAndPollRenewsBeforeLeaseExpires(t *testing.T) {
start := time.Unix(0, 0)
now := start
stopErr := errors.New("stop pull loop")
sub := &fakeOnvifPullPoint{
t: t,
now: &now,
pullErrAt: 3,
pullErr: stopErr,
}
w := newOnvifMotionWatcher(&hksv.Server{}, "onvif://camera", 30*time.Second, zerolog.Nop())
w.now = func() time.Time { return now }
w.newPullPoint = func(rawURL string, timeout time.Duration) (onvifPullPoint, error) {
if rawURL != "onvif://camera" {
t.Fatalf("unexpected ONVIF URL: %s", rawURL)
}
if timeout != 60*time.Second {
t.Fatalf("unexpected subscription timeout: %v", timeout)
}
return sub, nil
}
err := w.connectAndPoll()
if !errors.Is(err, stopErr) {
t.Fatalf("expected %v, got %v", stopErr, err)
}
wantPulls := []time.Duration{30 * time.Second, 20 * time.Second, 30 * time.Second}
if len(sub.pullTimeouts) != len(wantPulls) {
t.Fatalf("unexpected pull count: got %d want %d", len(sub.pullTimeouts), len(wantPulls))
}
for i, want := range wantPulls {
if sub.pullTimeouts[i] != want {
t.Fatalf("pull %d timeout mismatch: got %v want %v", i+1, sub.pullTimeouts[i], want)
}
}
if sub.renewCalls != 1 {
t.Fatalf("expected 1 renew call, got %d", sub.renewCalls)
}
if !sub.unsubscribed {
t.Fatal("expected unsubscribe on exit")
}
}
type fakeOnvifPullPoint struct {
t *testing.T
now *time.Time
pullTimeouts []time.Duration
renewCalls int
unsubscribed bool
pullErrAt int
pullErr error
}
func (f *fakeOnvifPullPoint) PullMessages(timeout time.Duration, limit int) ([]byte, error) {
if limit != 10 {
f.t.Fatalf("unexpected message limit: %d", limit)
}
f.pullTimeouts = append(f.pullTimeouts, timeout)
*f.now = f.now.Add(timeout)
if f.pullErrAt > 0 && len(f.pullTimeouts) == f.pullErrAt {
return nil, f.pullErr
}
return []byte(`<tev:PullMessagesResponse xmlns:tev="http://www.onvif.org/ver10/events/wsdl"/>`), nil
}
func (f *fakeOnvifPullPoint) Renew(timeout time.Duration) error {
if timeout != 60*time.Second {
f.t.Fatalf("unexpected renew timeout: %v", timeout)
}
f.renewCalls++
return nil
}
func (f *fakeOnvifPullPoint) Unsubscribe() error {
f.unsubscribed = true
return nil
}
-405
View File
@@ -1,405 +0,0 @@
package homekit
import (
"crypto/ed25519"
"crypto/sha512"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"slices"
"strings"
"sync"
"github.com/AlexxIT/go2rtc/internal/app"
"github.com/AlexxIT/go2rtc/internal/ffmpeg"
srtp2 "github.com/AlexxIT/go2rtc/internal/srtp"
"github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/hap"
"github.com/AlexxIT/go2rtc/pkg/hap/camera"
"github.com/AlexxIT/go2rtc/pkg/hap/hds"
"github.com/AlexxIT/go2rtc/pkg/hap/tlv8"
"github.com/AlexxIT/go2rtc/pkg/homekit"
"github.com/AlexxIT/go2rtc/pkg/magic"
"github.com/AlexxIT/go2rtc/pkg/mdns"
)
type server struct {
hap *hap.Server // server for HAP connection and encryption
mdns *mdns.ServiceEntry
pairings []string // pairings list
conns []any
mu sync.Mutex
accessory *hap.Accessory // HAP accessory
consumer *homekit.Consumer
proxyURL string
setupID string
stream string // stream name from YAML
}
func (s *server) MarshalJSON() ([]byte, error) {
v := struct {
Name string `json:"name"`
DeviceID string `json:"device_id"`
Paired int `json:"paired,omitempty"`
CategoryID string `json:"category_id,omitempty"`
SetupCode string `json:"setup_code,omitempty"`
SetupID string `json:"setup_id,omitempty"`
Conns []any `json:"connections,omitempty"`
}{
Name: s.mdns.Name,
DeviceID: s.mdns.Info[hap.TXTDeviceID],
CategoryID: s.mdns.Info[hap.TXTCategory],
Paired: len(s.pairings),
Conns: s.conns,
}
if v.Paired == 0 {
v.SetupCode = s.hap.Pin
v.SetupID = s.setupID
}
return json.Marshal(v)
}
func (s *server) Handle(w http.ResponseWriter, r *http.Request) {
conn, rw, err := w.(http.Hijacker).Hijack()
if err != nil {
return
}
defer conn.Close()
// Fix reading from Body after Hijack.
r.Body = io.NopCloser(rw)
switch r.RequestURI {
case hap.PathPairSetup:
id, key, err := s.hap.PairSetup(r, rw)
if err != nil {
log.Error().Err(err).Caller().Send()
return
}
s.AddPair(id, key, hap.PermissionAdmin)
case hap.PathPairVerify:
id, key, err := s.hap.PairVerify(r, rw)
if err != nil {
log.Debug().Err(err).Caller().Send()
return
}
log.Debug().Str("stream", s.stream).Str("client_id", id).Msgf("[homekit] %s: new conn", conn.RemoteAddr())
controller, err := hap.NewConn(conn, rw, key, false)
if err != nil {
log.Error().Err(err).Caller().Send()
return
}
s.AddConn(controller)
defer s.DelConn(controller)
var handler homekit.HandlerFunc
switch {
case s.accessory != nil:
handler = homekit.ServerHandler(s)
case s.proxyURL != "":
client, err := hap.Dial(s.proxyURL)
if err != nil {
log.Error().Err(err).Caller().Send()
return
}
handler = homekit.ProxyHandler(s, client.Conn)
}
// If your iPhone goes to sleep, it will be an EOF error.
if err = handler(controller); err != nil && !errors.Is(err, io.EOF) {
log.Error().Err(err).Caller().Send()
return
}
}
}
type logger struct {
v any
}
func (l logger) String() string {
switch v := l.v.(type) {
case *hap.Conn:
return "hap " + v.RemoteAddr().String()
case *hds.Conn:
return "hds " + v.RemoteAddr().String()
case *homekit.Consumer:
return "rtp " + v.RemoteAddr
}
return "unknown"
}
func (s *server) AddConn(v any) {
log.Trace().Str("stream", s.stream).Msgf("[homekit] add conn %s", logger{v})
s.mu.Lock()
s.conns = append(s.conns, v)
s.mu.Unlock()
}
func (s *server) DelConn(v any) {
log.Trace().Str("stream", s.stream).Msgf("[homekit] del conn %s", logger{v})
s.mu.Lock()
if i := slices.Index(s.conns, v); i >= 0 {
s.conns = slices.Delete(s.conns, i, i+1)
}
s.mu.Unlock()
}
func (s *server) UpdateStatus() {
// true status is important, or device may be offline in Apple Home
if len(s.pairings) == 0 {
s.mdns.Info[hap.TXTStatusFlags] = hap.StatusNotPaired
} else {
s.mdns.Info[hap.TXTStatusFlags] = hap.StatusPaired
}
}
func (s *server) pairIndex(id string) int {
id = "client_id=" + id
for i, pairing := range s.pairings {
if strings.HasPrefix(pairing, id) {
return i
}
}
return -1
}
func (s *server) GetPair(id string) []byte {
s.mu.Lock()
defer s.mu.Unlock()
if i := s.pairIndex(id); i >= 0 {
query, _ := url.ParseQuery(s.pairings[i])
b, _ := hex.DecodeString(query.Get("client_public"))
return b
}
return nil
}
func (s *server) AddPair(id string, public []byte, permissions byte) {
log.Debug().Str("stream", s.stream).Msgf("[homekit] add pair id=%s public=%x perm=%d", id, public, permissions)
s.mu.Lock()
if s.pairIndex(id) < 0 {
s.pairings = append(s.pairings, fmt.Sprintf(
"client_id=%s&client_public=%x&permissions=%d", id, public, permissions,
))
s.UpdateStatus()
s.PatchConfig()
}
s.mu.Unlock()
}
func (s *server) DelPair(id string) {
log.Debug().Str("stream", s.stream).Msgf("[homekit] del pair id=%s", id)
s.mu.Lock()
if i := s.pairIndex(id); i >= 0 {
s.pairings = append(s.pairings[:i], s.pairings[i+1:]...)
s.UpdateStatus()
s.PatchConfig()
}
s.mu.Unlock()
}
func (s *server) PatchConfig() {
if err := app.PatchConfig([]string{"homekit", s.stream, "pairings"}, s.pairings); err != nil {
log.Error().Err(err).Msgf(
"[homekit] can't save %s pairings=%v", s.stream, s.pairings,
)
}
}
func (s *server) GetAccessories(_ net.Conn) []*hap.Accessory {
return []*hap.Accessory{s.accessory}
}
func (s *server) GetCharacteristic(conn net.Conn, aid uint8, iid uint64) any {
log.Trace().Str("stream", s.stream).Msgf("[homekit] get char aid=%d iid=0x%x", aid, iid)
char := s.accessory.GetCharacterByID(iid)
if char == nil {
log.Warn().Msgf("[homekit] get unknown characteristic: %d", iid)
return nil
}
switch char.Type {
case camera.TypeSetupEndpoints:
consumer := s.consumer
if consumer == nil {
return nil
}
answer := consumer.GetAnswer()
v, err := tlv8.MarshalBase64(answer)
if err != nil {
return nil
}
return v
}
return char.Value
}
func (s *server) SetCharacteristic(conn net.Conn, aid uint8, iid uint64, value any) {
log.Trace().Str("stream", s.stream).Msgf("[homekit] set char aid=%d iid=0x%x value=%v", aid, iid, value)
char := s.accessory.GetCharacterByID(iid)
if char == nil {
log.Warn().Msgf("[homekit] set unknown characteristic: %d", iid)
return
}
switch char.Type {
case camera.TypeSetupEndpoints:
var offer camera.SetupEndpointsRequest
if err := tlv8.UnmarshalBase64(value, &offer); err != nil {
return
}
consumer := homekit.NewConsumer(conn, srtp2.Server)
consumer.SetOffer(&offer)
s.consumer = consumer
case camera.TypeSelectedStreamConfiguration:
var conf camera.SelectedStreamConfiguration
if err := tlv8.UnmarshalBase64(value, &conf); err != nil {
return
}
log.Trace().Str("stream", s.stream).Msgf("[homekit] stream id=%x cmd=%d", conf.Control.SessionID, conf.Control.Command)
switch conf.Control.Command {
case camera.SessionCommandEnd:
for _, consumer := range s.conns {
if consumer, ok := consumer.(*homekit.Consumer); ok {
if consumer.SessionID() == conf.Control.SessionID {
_ = consumer.Stop()
return
}
}
}
case camera.SessionCommandStart:
consumer := s.consumer
if consumer == nil {
return
}
if !consumer.SetConfig(&conf) {
log.Warn().Msgf("[homekit] wrong config")
return
}
s.AddConn(consumer)
stream := streams.Get(s.stream)
if err := stream.AddConsumer(consumer); err != nil {
return
}
go func() {
_, _ = consumer.WriteTo(nil)
stream.RemoveConsumer(consumer)
s.DelConn(consumer)
}()
}
}
}
func (s *server) GetImage(conn net.Conn, width, height int) []byte {
log.Trace().Str("stream", s.stream).Msgf("[homekit] get image width=%d height=%d", width, height)
stream := streams.Get(s.stream)
cons := magic.NewKeyframe()
if err := stream.AddConsumer(cons); err != nil {
return nil
}
once := &core.OnceBuffer{} // init and first frame
_, _ = cons.WriteTo(once)
b := once.Buffer()
stream.RemoveConsumer(cons)
switch cons.CodecName() {
case core.CodecH264, core.CodecH265:
var err error
if b, err = ffmpeg.JPEGWithScale(b, width, height); err != nil {
return nil
}
}
return b
}
func calcName(name, seed string) string {
if name != "" {
return name
}
b := sha512.Sum512([]byte(seed))
return fmt.Sprintf("go2rtc-%02X%02X", b[0], b[2])
}
func calcDeviceID(deviceID, seed string) string {
if deviceID != "" {
if len(deviceID) >= 17 {
// 1. Returd device_id as is (ex. AA:BB:CC:DD:EE:FF)
return deviceID
}
// 2. Use device_id as seed if not zero
seed = deviceID
}
b := sha512.Sum512([]byte(seed))
return fmt.Sprintf("%02X:%02X:%02X:%02X:%02X:%02X", b[32], b[34], b[36], b[38], b[40], b[42])
}
func calcDevicePrivate(private, seed string) []byte {
if private != "" {
// 1. Decode private from HEX string
if b, _ := hex.DecodeString(private); len(b) == ed25519.PrivateKeySize {
// 2. Return if OK
return b
}
// 3. Use private as seed if not zero
seed = private
}
b := sha512.Sum512([]byte(seed))
return ed25519.NewKeyFromSeed(b[:ed25519.SeedSize])
}
func calcSetupID(seed string) string {
b := sha512.Sum512([]byte(seed))
return fmt.Sprintf("%02X%02X", b[44], b[46])
}
func calcCategoryID(categoryID string) string {
switch categoryID {
case "bridge":
return hap.CategoryBridge
case "doorbell":
return hap.CategoryDoorbell
}
if core.Atoi(categoryID) > 0 {
return categoryID
}
return hap.CategoryCamera
}