8a21809f18
- implement ONVIF motion watcher to handle motion events - add configuration options for motion hold time and ONVIF URL - remap motion mode from "onvif" to "api" for compatibility - log ONVIF motion watcher activity for better debugging feat(onvif): implement event subscription for motion detection - create PullPoint subscription to receive motion events - implement methods for pulling messages and renewing subscriptions - handle event requests and responses specific to motion detection test(onvif): add unit tests for motion event parsing and subscription - create tests for parsing various motion event XML responses - verify correct handling of multiple notifications and edge cases - test resolving event addresses for ONVIF clients fix(hksv): improve motion detection logging - log warnings when accessory or character not found during motion detection - log number of listeners notified during motion state changes feat(hap): add listener count method - introduce method to retrieve the number of listeners for a character feat(onvif): enhance ONVIF client with event URL handling - extract event URL from ONVIF device response for subscription management
446 lines
11 KiB
Go
446 lines
11 KiB
Go
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"
|
|
)
|
|
|
|
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"`
|
|
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)
|
|
|
|
log = app.GetLogger("homekit")
|
|
|
|
streams.HandleFunc("homekit", streamHandler)
|
|
|
|
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]*hksv.Server{}
|
|
servers = map[string]*hksv.Server{}
|
|
var entries []*mdns.ServiceEntry
|
|
|
|
for id, conf := range cfg.Mod {
|
|
stream := streams.Get(id)
|
|
if stream == nil {
|
|
log.Warn().Msgf("[homekit] missing stream: %s", id)
|
|
continue
|
|
}
|
|
|
|
var proxyURL string
|
|
if url := findHomeKitURL(stream.Sources()); url != "" {
|
|
proxyURL = url
|
|
}
|
|
|
|
// 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).Str("stream", id).Msg("[homekit] create server failed")
|
|
continue
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
entry := srv.MDNSEntry()
|
|
entries = append(entries, entry)
|
|
|
|
host := entry.Host(mdns.ServiceHAP)
|
|
hosts[host] = srv
|
|
servers[id] = srv
|
|
|
|
log.Trace().Msgf("[homekit] new server: %s", entry)
|
|
}
|
|
|
|
api.HandleFunc(hap.PathPairSetup, hapHandler)
|
|
api.HandleFunc(hap.PathPairVerify, hapHandler)
|
|
|
|
go func() {
|
|
if err := mdns.Serve(mdns.ServiceHAP, entries); err != nil {
|
|
log.Error().Err(err).Caller().Send()
|
|
}
|
|
}()
|
|
}
|
|
|
|
var log zerolog.Logger
|
|
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 {
|
|
return nil, errors.New("homekit: can't work without SRTP server")
|
|
}
|
|
|
|
rawURL, rawQuery, _ := strings.Cut(rawURL, "#")
|
|
client, err := homekit.Dial(rawURL, srtp.Server)
|
|
if client != nil && rawQuery != "" {
|
|
query := streams.ParseQuery(rawQuery)
|
|
client.MaxWidth = core.Atoi(query.Get("maxwidth"))
|
|
client.MaxHeight = core.Atoi(query.Get("maxheight"))
|
|
client.Bitrate = parseBitrate(query.Get("bitrate"))
|
|
}
|
|
|
|
return client, err
|
|
}
|
|
|
|
func resolve(host string) *hksv.Server {
|
|
if len(hosts) == 1 {
|
|
for _, srv := range hosts {
|
|
return srv
|
|
}
|
|
}
|
|
if srv, ok := hosts[host]; ok {
|
|
return srv
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func hapHandler(w http.ResponseWriter, r *http.Request) {
|
|
srv := resolve(r.Host)
|
|
if srv == nil {
|
|
log.Error().Msg("[homekit] unknown host: " + r.Host)
|
|
return
|
|
}
|
|
srv.Handle(w, r)
|
|
}
|
|
|
|
func findHomeKitURL(sources []string) string {
|
|
if len(sources) == 0 {
|
|
return ""
|
|
}
|
|
|
|
url := sources[0]
|
|
if strings.HasPrefix(url, "homekit") {
|
|
return url
|
|
}
|
|
|
|
if strings.HasPrefix(url, "hass") {
|
|
location, _ := streams.Location(url)
|
|
if strings.HasPrefix(location, "homekit") {
|
|
return location
|
|
}
|
|
}
|
|
|
|
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 {
|
|
return 0
|
|
}
|
|
|
|
var k int
|
|
switch n--; s[n] {
|
|
case 'K':
|
|
k = 1024
|
|
s = s[:n]
|
|
case 'M':
|
|
k = 1024 * 1024
|
|
s = s[:n]
|
|
default:
|
|
k = 1
|
|
}
|
|
|
|
return k * core.Atoi(s)
|
|
}
|