Merge branch 'hksv' into beta
# Conflicts: # pkg/hap/hds/hds_test.go
This commit is contained in:
@@ -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.3–1.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.5–3.0 | Wind and shadows produce medium P-frames, need margin |
|
||||
| Busy street / complex scene | 3.0–5.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
@@ -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 {
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user