feat: add motion detection feature with EMA-based P-frame size analysis
- Implemented MotionDetector for detecting motion based on H.264 P-frame sizes. - Introduced adjustable sensitivity threshold for motion detection. - Added tests for various scenarios including motion detection, hold time, cooldown, and baseline adaptation. - Created hksvSession to manage HDS DataStream connections for HKSV recording. - Updated schema.json to include a new speaker option for 2-way audio support.
This commit is contained in:
@@ -79,6 +79,7 @@ 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)
|
||||
|
||||
@@ -1,431 +0,0 @@
|
||||
package homekit
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/aac"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/h264"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap/hds"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mp4"
|
||||
"github.com/pion/rtp"
|
||||
)
|
||||
|
||||
// hksvSession manages the HDS DataStream connection for HKSV recording
|
||||
type hksvSession struct {
|
||||
server *server
|
||||
hapConn *hap.Conn
|
||||
hdsConn *hds.Conn
|
||||
session *hds.Session
|
||||
|
||||
mu sync.Mutex
|
||||
consumer *hksvConsumer
|
||||
}
|
||||
|
||||
func newHKSVSession(srv *server, hapConn *hap.Conn, hdsConn *hds.Conn) *hksvSession {
|
||||
session := hds.NewSession(hdsConn)
|
||||
hs := &hksvSession{
|
||||
server: srv,
|
||||
hapConn: hapConn,
|
||||
hdsConn: hdsConn,
|
||||
session: session,
|
||||
}
|
||||
session.OnDataSendOpen = hs.handleOpen
|
||||
session.OnDataSendClose = hs.handleClose
|
||||
return hs
|
||||
}
|
||||
|
||||
func (hs *hksvSession) Run() error {
|
||||
return hs.session.Run()
|
||||
}
|
||||
|
||||
func (hs *hksvSession) Close() {
|
||||
hs.mu.Lock()
|
||||
defer hs.mu.Unlock()
|
||||
if hs.consumer != nil {
|
||||
hs.stopRecording()
|
||||
}
|
||||
_ = hs.session.Close()
|
||||
}
|
||||
|
||||
func (hs *hksvSession) handleOpen(streamID int) error {
|
||||
hs.mu.Lock()
|
||||
defer hs.mu.Unlock()
|
||||
|
||||
log.Debug().Str("stream", hs.server.stream).Int("streamID", streamID).Msg("[homekit] HKSV dataSend open")
|
||||
|
||||
if hs.consumer != nil {
|
||||
hs.stopRecording()
|
||||
}
|
||||
|
||||
// Try to use the pre-started consumer from pair-verify
|
||||
consumer := hs.server.takePreparedConsumer()
|
||||
if consumer != nil {
|
||||
log.Debug().Str("stream", hs.server.stream).Msg("[homekit] HKSV using prepared consumer")
|
||||
hs.consumer = consumer
|
||||
hs.server.AddConn(consumer)
|
||||
|
||||
// Activate: set the HDS session and send init + start streaming
|
||||
if err := consumer.activate(hs.session, streamID); err != nil {
|
||||
log.Error().Err(err).Str("stream", hs.server.stream).Msg("[homekit] HKSV activate failed")
|
||||
hs.stopRecording()
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fallback: create new consumer (will be slow ~3s)
|
||||
log.Debug().Str("stream", hs.server.stream).Msg("[homekit] HKSV no prepared consumer, creating new")
|
||||
consumer = newHKSVConsumer()
|
||||
|
||||
stream := streams.Get(hs.server.stream)
|
||||
if err := stream.AddConsumer(consumer); err != nil {
|
||||
log.Error().Err(err).Str("stream", hs.server.stream).Msg("[homekit] HKSV add consumer failed")
|
||||
return nil
|
||||
}
|
||||
|
||||
hs.consumer = consumer
|
||||
hs.server.AddConn(consumer)
|
||||
|
||||
go func() {
|
||||
if err := consumer.activate(hs.session, streamID); err != nil {
|
||||
log.Error().Err(err).Str("stream", hs.server.stream).Msg("[homekit] HKSV activate failed")
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (hs *hksvSession) handleClose(streamID int) error {
|
||||
hs.mu.Lock()
|
||||
defer hs.mu.Unlock()
|
||||
|
||||
log.Debug().Str("stream", hs.server.stream).Int("streamID", streamID).Msg("[homekit] HKSV dataSend close")
|
||||
|
||||
if hs.consumer != nil {
|
||||
hs.stopRecording()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (hs *hksvSession) stopRecording() {
|
||||
consumer := hs.consumer
|
||||
hs.consumer = nil
|
||||
|
||||
stream := streams.Get(hs.server.stream)
|
||||
stream.RemoveConsumer(consumer)
|
||||
_ = consumer.Stop()
|
||||
hs.server.DelConn(consumer)
|
||||
}
|
||||
|
||||
// hksvConsumer implements core.Consumer, generates fMP4 and sends over HDS.
|
||||
// It can be pre-started without an HDS session, buffering init data until activated.
|
||||
type hksvConsumer struct {
|
||||
core.Connection
|
||||
muxer *mp4.Muxer
|
||||
mu sync.Mutex
|
||||
done chan struct{}
|
||||
|
||||
// Set by activate() when HDS session is available
|
||||
session *hds.Session
|
||||
streamID int
|
||||
seqNum int
|
||||
active bool
|
||||
start bool // waiting for first keyframe
|
||||
|
||||
// GOP buffer - accumulate moof+mdat pairs, flush on next keyframe
|
||||
fragBuf []byte
|
||||
|
||||
// Pre-built init segment (built when tracks connect)
|
||||
initData []byte
|
||||
initErr error
|
||||
initDone chan struct{} // closed when init is ready
|
||||
}
|
||||
|
||||
func newHKSVConsumer() *hksvConsumer {
|
||||
medias := []*core.Media{
|
||||
{
|
||||
Kind: core.KindVideo,
|
||||
Direction: core.DirectionSendonly,
|
||||
Codecs: []*core.Codec{
|
||||
{Name: core.CodecH264},
|
||||
},
|
||||
},
|
||||
{
|
||||
Kind: core.KindAudio,
|
||||
Direction: core.DirectionSendonly,
|
||||
Codecs: []*core.Codec{
|
||||
{Name: core.CodecAAC},
|
||||
},
|
||||
},
|
||||
}
|
||||
return &hksvConsumer{
|
||||
Connection: core.Connection{
|
||||
ID: core.NewID(),
|
||||
FormatName: "hksv",
|
||||
Protocol: "hds",
|
||||
Medias: medias,
|
||||
},
|
||||
muxer: &mp4.Muxer{},
|
||||
done: make(chan struct{}),
|
||||
initDone: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *hksvConsumer) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiver) error {
|
||||
trackID := byte(len(c.Senders))
|
||||
|
||||
log.Debug().Str("codec", track.Codec.Name).Uint8("trackID", trackID).Msg("[homekit] HKSV AddTrack")
|
||||
|
||||
codec := track.Codec.Clone()
|
||||
handler := core.NewSender(media, codec)
|
||||
|
||||
switch track.Codec.Name {
|
||||
case core.CodecH264:
|
||||
handler.Handler = func(packet *rtp.Packet) {
|
||||
c.mu.Lock()
|
||||
if !c.active {
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if !c.start {
|
||||
if !h264.IsKeyframe(packet.Payload) {
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
c.start = true
|
||||
log.Debug().Int("payloadLen", len(packet.Payload)).Msg("[homekit] HKSV first keyframe")
|
||||
} else if h264.IsKeyframe(packet.Payload) && len(c.fragBuf) > 0 {
|
||||
// New keyframe = flush previous GOP as one mediaFragment
|
||||
c.flushFragment()
|
||||
}
|
||||
|
||||
b := c.muxer.GetPayload(trackID, packet)
|
||||
c.fragBuf = append(c.fragBuf, b...)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
if track.Codec.IsRTP() {
|
||||
handler.Handler = h264.RTPDepay(track.Codec, handler.Handler)
|
||||
} else {
|
||||
handler.Handler = h264.RepairAVCC(track.Codec, handler.Handler)
|
||||
}
|
||||
|
||||
case core.CodecAAC:
|
||||
handler.Handler = func(packet *rtp.Packet) {
|
||||
c.mu.Lock()
|
||||
if !c.active || !c.start {
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
b := c.muxer.GetPayload(trackID, packet)
|
||||
c.fragBuf = append(c.fragBuf, b...)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
if track.Codec.IsRTP() {
|
||||
handler.Handler = aac.RTPDepay(handler.Handler)
|
||||
}
|
||||
|
||||
default:
|
||||
return nil // skip unsupported codecs
|
||||
}
|
||||
|
||||
c.muxer.AddTrack(codec)
|
||||
handler.HandleRTP(track)
|
||||
c.Senders = append(c.Senders, handler)
|
||||
|
||||
// Build init segment when all expected tracks are ready (video + audio)
|
||||
select {
|
||||
case <-c.initDone:
|
||||
// already built
|
||||
default:
|
||||
if len(c.Senders) >= len(c.Medias) {
|
||||
initData, err := c.muxer.GetInit()
|
||||
c.initData = initData
|
||||
c.initErr = err
|
||||
close(c.initDone)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("[homekit] HKSV GetInit failed")
|
||||
} else {
|
||||
log.Debug().Int("initSize", len(initData)).Int("tracks", len(c.Senders)).Msg("[homekit] HKSV init segment ready")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// activate is called when the HDS session is ready (dataSend.open).
|
||||
// It sends the pre-built init segment and starts streaming.
|
||||
func (c *hksvConsumer) activate(session *hds.Session, streamID int) error {
|
||||
// Wait for init to be ready (should already be done if consumer was pre-started)
|
||||
select {
|
||||
case <-c.initDone:
|
||||
case <-time.After(5 * time.Second):
|
||||
return io.ErrClosedPipe
|
||||
}
|
||||
|
||||
if c.initErr != nil {
|
||||
return c.initErr
|
||||
}
|
||||
|
||||
log.Debug().Int("initSize", len(c.initData)).Msg("[homekit] HKSV sending init segment")
|
||||
|
||||
if err := session.SendMediaInit(streamID, c.initData); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debug().Msg("[homekit] HKSV init segment sent OK")
|
||||
|
||||
// Enable live streaming (seqNum=2 because init used seqNum=1)
|
||||
c.mu.Lock()
|
||||
c.session = session
|
||||
c.streamID = streamID
|
||||
c.seqNum = 2
|
||||
c.active = true
|
||||
c.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// flushFragment sends the accumulated GOP buffer as a single mediaFragment.
|
||||
// Must be called while holding c.mu.
|
||||
func (c *hksvConsumer) flushFragment() {
|
||||
fragment := c.fragBuf
|
||||
c.fragBuf = make([]byte, 0, len(fragment))
|
||||
|
||||
log.Debug().Int("fragSize", len(fragment)).Int("seq", c.seqNum).Msg("[homekit] HKSV flush fragment")
|
||||
|
||||
if err := c.session.SendMediaFragment(c.streamID, fragment, c.seqNum); err == nil {
|
||||
c.Send += len(fragment)
|
||||
}
|
||||
c.seqNum++
|
||||
}
|
||||
|
||||
func (c *hksvConsumer) WriteTo(io.Writer) (int64, error) {
|
||||
<-c.done
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (c *hksvConsumer) Stop() error {
|
||||
select {
|
||||
case <-c.done:
|
||||
default:
|
||||
close(c.done)
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.active = false
|
||||
c.mu.Unlock()
|
||||
return c.Connection.Stop()
|
||||
}
|
||||
|
||||
// acceptHDS opens a TCP listener for the HDS DataStream connection from the Home Hub
|
||||
func (s *server) acceptHDS(hapConn *hap.Conn, ln net.Listener, salt string) {
|
||||
defer ln.Close()
|
||||
|
||||
if tcpLn, ok := ln.(*net.TCPListener); ok {
|
||||
_ = tcpLn.SetDeadline(time.Now().Add(30 * time.Second))
|
||||
}
|
||||
|
||||
rawConn, err := ln.Accept()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("stream", s.stream).Msg("[homekit] HKSV accept failed")
|
||||
return
|
||||
}
|
||||
defer rawConn.Close()
|
||||
|
||||
// Create HDS encrypted connection (controller=false, we are accessory)
|
||||
hdsConn, err := hds.NewConn(rawConn, hapConn.SharedKey, salt, false)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("stream", s.stream).Msg("[homekit] HKSV hds conn failed")
|
||||
return
|
||||
}
|
||||
|
||||
s.AddConn(hdsConn)
|
||||
defer s.DelConn(hdsConn)
|
||||
|
||||
session := newHKSVSession(s, hapConn, hdsConn)
|
||||
|
||||
s.mu.Lock()
|
||||
s.hksvSession = session
|
||||
s.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
if s.hksvSession == session {
|
||||
s.hksvSession = nil
|
||||
}
|
||||
s.mu.Unlock()
|
||||
session.Close()
|
||||
}()
|
||||
|
||||
log.Debug().Str("stream", s.stream).Msg("[homekit] HKSV session started")
|
||||
|
||||
if err := session.Run(); err != nil {
|
||||
log.Debug().Err(err).Str("stream", s.stream).Msg("[homekit] HKSV session ended")
|
||||
}
|
||||
}
|
||||
|
||||
// prepareHKSVConsumer pre-starts a consumer and adds it to the stream.
|
||||
// When dataSend.open arrives, the consumer is ready immediately.
|
||||
func (s *server) prepareHKSVConsumer() {
|
||||
stream := streams.Get(s.stream)
|
||||
if stream == nil {
|
||||
return
|
||||
}
|
||||
|
||||
consumer := newHKSVConsumer()
|
||||
|
||||
if err := stream.AddConsumer(consumer); err != nil {
|
||||
log.Debug().Err(err).Str("stream", s.stream).Msg("[homekit] HKSV prepare consumer failed")
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug().Str("stream", s.stream).Msg("[homekit] HKSV consumer prepared")
|
||||
|
||||
s.mu.Lock()
|
||||
// Clean up any previous prepared consumer
|
||||
if s.preparedConsumer != nil {
|
||||
old := s.preparedConsumer
|
||||
s.preparedConsumer = nil
|
||||
s.mu.Unlock()
|
||||
stream.RemoveConsumer(old)
|
||||
_ = old.Stop()
|
||||
s.mu.Lock()
|
||||
}
|
||||
s.preparedConsumer = consumer
|
||||
s.mu.Unlock()
|
||||
|
||||
// Keep alive until used or timeout (60 seconds)
|
||||
select {
|
||||
case <-consumer.done:
|
||||
// consumer was stopped (used or server closed)
|
||||
case <-time.After(60 * time.Second):
|
||||
// timeout: clean up unused prepared consumer
|
||||
s.mu.Lock()
|
||||
if s.preparedConsumer == consumer {
|
||||
s.preparedConsumer = nil
|
||||
s.mu.Unlock()
|
||||
stream.RemoveConsumer(consumer)
|
||||
_ = consumer.Stop()
|
||||
log.Debug().Str("stream", s.stream).Msg("[homekit] HKSV prepared consumer expired")
|
||||
} else {
|
||||
s.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *server) takePreparedConsumer() *hksvConsumer {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
consumer := s.preparedConsumer
|
||||
s.preparedConsumer = nil
|
||||
return consumer
|
||||
}
|
||||
+177
-76
@@ -2,17 +2,23 @@ package homekit
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"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,15 +26,16 @@ 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"`
|
||||
Speaker *bool `yaml:"speaker"`
|
||||
} `yaml:"homekit"`
|
||||
}
|
||||
app.LoadConfig(&cfg)
|
||||
@@ -47,8 +54,8 @@ func Init() {
|
||||
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 {
|
||||
@@ -58,78 +65,46 @@ 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)
|
||||
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: conf.Motion,
|
||||
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)
|
||||
entry := srv.MDNSEntry()
|
||||
entries = append(entries, entry)
|
||||
|
||||
srv := &server{
|
||||
stream: id,
|
||||
pairings: conf.Pairings,
|
||||
setupID: setupID,
|
||||
}
|
||||
|
||||
srv.hap = &hap.Server{
|
||||
Pin: pin,
|
||||
DeviceID: deviceID,
|
||||
DevicePrivate: calcDevicePrivate(conf.DevicePrivate, id),
|
||||
GetClientPublic: srv.GetPair,
|
||||
}
|
||||
|
||||
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 if conf.HKSV {
|
||||
// 2. Act as HKSV camera
|
||||
srv.motionMode = conf.Motion
|
||||
srv.motionThreshold = conf.MotionThreshold
|
||||
if srv.motionThreshold <= 0 {
|
||||
srv.motionThreshold = motionThreshold
|
||||
}
|
||||
log.Debug().Str("stream", id).Str("motion", conf.Motion).Float64("threshold", srv.motionThreshold).Msg("[homekit] HKSV mode")
|
||||
if conf.CategoryID == "doorbell" {
|
||||
srv.accessory = camera.NewHKSVDoorbellAccessory("AlexxIT", "go2rtc", name, "-", app.Version)
|
||||
} else {
|
||||
srv.accessory = camera.NewHKSVAccessory("AlexxIT", "go2rtc", name, "-", app.Version)
|
||||
}
|
||||
} else {
|
||||
// 3. 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)
|
||||
@@ -143,8 +118,137 @@ 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
|
||||
consumer *homekit.Consumer
|
||||
}
|
||||
|
||||
func (h *go2rtcLiveStreamHandler) SetupEndpoints(conn net.Conn, offer *camera.SetupEndpointsRequest) (any, error) {
|
||||
consumer := homekit.NewConsumer(conn, srtp.Server)
|
||||
consumer.SetOffer(offer)
|
||||
|
||||
h.mu.Lock()
|
||||
h.consumer = consumer
|
||||
h.mu.Unlock()
|
||||
|
||||
answer := consumer.GetAnswer()
|
||||
v, err := tlv8.MarshalBase64(answer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (h *go2rtcLiveStreamHandler) GetEndpointsResponse() any {
|
||||
h.mu.Lock()
|
||||
consumer := h.consumer
|
||||
h.mu.Unlock()
|
||||
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 {
|
||||
h.mu.Lock()
|
||||
consumer := h.consumer
|
||||
h.mu.Unlock()
|
||||
|
||||
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 err := stream.AddConsumer(consumer); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go func() {
|
||||
_, _ = consumer.WriteTo(nil)
|
||||
stream.RemoveConsumer(consumer)
|
||||
connTracker.DelConn(consumer)
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *go2rtcLiveStreamHandler) StopStream(sessionID string, connTracker hksv.ConnTracker) error {
|
||||
h.mu.Lock()
|
||||
consumer := h.consumer
|
||||
h.mu.Unlock()
|
||||
|
||||
if consumer != nil && consumer.SessionID() == sessionID {
|
||||
_ = consumer.Stop()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func streamHandler(rawURL string) (core.Producer, error) {
|
||||
if srtp.Server == nil {
|
||||
@@ -163,7 +267,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
|
||||
@@ -176,9 +280,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)
|
||||
|
||||
@@ -1,598 +0,0 @@
|
||||
package homekit
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/sha512"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"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
|
||||
|
||||
// HKSV fields
|
||||
motionMode string // "api", "continuous", "detect"
|
||||
motionThreshold float64 // ratio threshold for "detect" mode (default 2.0)
|
||||
motionDetector *motionDetector
|
||||
hksvSession *hksvSession
|
||||
continuousMotion bool
|
||||
preparedConsumer *hksvConsumer
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// start motion on first Home Hub connection
|
||||
switch s.motionMode {
|
||||
case "detect":
|
||||
go s.startMotionDetector()
|
||||
case "continuous":
|
||||
go s.prepareHKSVConsumer()
|
||||
go s.startContinuousMotion()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
log.Debug().Str("stream", s.stream).Msgf("[homekit] handler started for %s", conn.RemoteAddr())
|
||||
|
||||
// If your iPhone goes to sleep, it will be an EOF error.
|
||||
if err = handler(controller); err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
log.Debug().Str("stream", s.stream).Msgf("[homekit] %s: connection closed (EOF)", conn.RemoteAddr())
|
||||
} else {
|
||||
log.Error().Err(err).Str("stream", s.stream).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 {
|
||||
log.Trace().Str("stream", s.stream).Msg("[homekit] GET /accessories")
|
||||
if log.Trace().Enabled() {
|
||||
if b, err := json.Marshal(s.accessory); err == nil {
|
||||
log.Trace().Str("stream", s.stream).RawJSON("accessory", b).Msg("[homekit] accessory JSON")
|
||||
}
|
||||
}
|
||||
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)
|
||||
}()
|
||||
}
|
||||
|
||||
case camera.TypeSetupDataStreamTransport:
|
||||
var req camera.SetupDataStreamTransportRequest
|
||||
if err := tlv8.UnmarshalBase64(value, &req); err != nil {
|
||||
log.Error().Err(err).Str("stream", s.stream).Msg("[homekit] HKSV parse ch131 failed")
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug().Str("stream", s.stream).Uint8("cmd", req.SessionCommandType).
|
||||
Uint8("transport", req.TransportType).Msg("[homekit] HKSV DataStream setup")
|
||||
|
||||
if req.SessionCommandType != 0 {
|
||||
// 0 = start, 1 = close
|
||||
log.Debug().Str("stream", s.stream).Msg("[homekit] HKSV DataStream close request")
|
||||
if s.hksvSession != nil {
|
||||
s.hksvSession.Close()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
accessoryKeySalt := core.RandString(32, 0)
|
||||
combinedSalt := req.ControllerKeySalt + accessoryKeySalt
|
||||
|
||||
ln, err := net.ListenTCP("tcp", nil)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("stream", s.stream).Msg("[homekit] HKSV listen failed")
|
||||
return
|
||||
}
|
||||
port := ln.Addr().(*net.TCPAddr).Port
|
||||
|
||||
resp := camera.SetupDataStreamTransportResponse{
|
||||
Status: 0,
|
||||
AccessoryKeySalt: accessoryKeySalt,
|
||||
}
|
||||
resp.TransportTypeSessionParameters.TCPListeningPort = uint16(port)
|
||||
|
||||
v, err := tlv8.MarshalBase64(resp)
|
||||
if err != nil {
|
||||
ln.Close()
|
||||
return
|
||||
}
|
||||
char.Value = v
|
||||
|
||||
log.Debug().Str("stream", s.stream).Int("port", port).Msg("[homekit] HKSV listening for HDS")
|
||||
|
||||
hapConn := conn.(*hap.Conn)
|
||||
go s.acceptHDS(hapConn, ln, combinedSalt)
|
||||
|
||||
case camera.TypeSelectedCameraRecordingConfiguration:
|
||||
log.Debug().Str("stream", s.stream).Str("motion", s.motionMode).Msg("[homekit] HKSV selected recording config")
|
||||
char.Value = value
|
||||
|
||||
switch s.motionMode {
|
||||
case "continuous":
|
||||
go s.startContinuousMotion()
|
||||
case "detect":
|
||||
go s.startMotionDetector()
|
||||
}
|
||||
|
||||
default:
|
||||
// Store value for all other writable characteristics
|
||||
char.Value = value
|
||||
}
|
||||
}
|
||||
|
||||
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 (s *server) SetMotionDetected(detected bool) {
|
||||
if s.accessory == nil {
|
||||
return
|
||||
}
|
||||
char := s.accessory.GetCharacter("22") // MotionDetected
|
||||
if char == nil {
|
||||
return
|
||||
}
|
||||
char.Value = detected
|
||||
_ = char.NotifyListeners(nil)
|
||||
log.Debug().Str("stream", s.stream).Bool("motion", detected).Msg("[homekit] motion")
|
||||
}
|
||||
|
||||
func (s *server) TriggerDoorbell() {
|
||||
if s.accessory == nil {
|
||||
return
|
||||
}
|
||||
char := s.accessory.GetCharacter("73") // ProgrammableSwitchEvent
|
||||
if char == nil {
|
||||
return
|
||||
}
|
||||
char.Value = 0 // SINGLE_PRESS
|
||||
_ = char.NotifyListeners(nil)
|
||||
log.Debug().Str("stream", s.stream).Msg("[homekit] doorbell")
|
||||
}
|
||||
|
||||
func (s *server) startMotionDetector() {
|
||||
s.mu.Lock()
|
||||
if s.motionDetector != nil {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
det := newMotionDetector(s)
|
||||
s.motionDetector = det
|
||||
s.mu.Unlock()
|
||||
|
||||
s.AddConn(det)
|
||||
|
||||
stream := streams.Get(s.stream)
|
||||
if err := stream.AddConsumer(det); err != nil {
|
||||
log.Error().Err(err).Str("stream", s.stream).Msg("[homekit] motion detector add consumer failed")
|
||||
s.DelConn(det)
|
||||
s.mu.Lock()
|
||||
s.motionDetector = nil
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug().Str("stream", s.stream).Msg("[homekit] motion detector started")
|
||||
|
||||
_, _ = det.WriteTo(nil) // blocks until Stop()
|
||||
|
||||
stream.RemoveConsumer(det)
|
||||
s.DelConn(det)
|
||||
|
||||
s.mu.Lock()
|
||||
if s.motionDetector == det {
|
||||
s.motionDetector = nil
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
log.Debug().Str("stream", s.stream).Msg("[homekit] motion detector stopped")
|
||||
}
|
||||
|
||||
func (s *server) stopMotionDetector() {
|
||||
s.mu.Lock()
|
||||
det := s.motionDetector
|
||||
s.mu.Unlock()
|
||||
if det != nil {
|
||||
_ = det.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func (s *server) startContinuousMotion() {
|
||||
s.mu.Lock()
|
||||
if s.continuousMotion {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.continuousMotion = true
|
||||
s.mu.Unlock()
|
||||
|
||||
log.Debug().Str("stream", s.stream).Msg("[homekit] continuous motion started")
|
||||
|
||||
// delay to allow Home Hub to subscribe to events
|
||||
time.Sleep(5 * time.Second)
|
||||
|
||||
s.SetMotionDetected(true)
|
||||
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
if s.accessory == nil {
|
||||
return
|
||||
}
|
||||
s.SetMotionDetected(true)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,636 @@
|
||||
# hksv - HomeKit Secure Video Library for Go
|
||||
|
||||
`hksv` is a standalone Go library that implements HomeKit Secure Video (HKSV) recording, motion detection, and HAP (HomeKit Accessory Protocol) camera server functionality. It can be used independently of go2rtc in any Go project that needs HKSV support.
|
||||
|
||||
## Author
|
||||
|
||||
Sergei "svk" Krashevich <svk@svk.su>
|
||||
|
||||
## Features
|
||||
|
||||
- **HKSV Recording** - Fragmented MP4 (fMP4) muxing with GOP-based buffering, sent over HDS (HomeKit DataStream)
|
||||
- **Motion Detection** - P-frame size analysis using EMA (Exponential Moving Average) baseline with configurable threshold
|
||||
- **HAP Server** - Full HomeKit pairing (SRP), encrypted communication, accessory management
|
||||
- **Proxy Mode** - Transparent proxy for existing HomeKit cameras
|
||||
- **Live Streaming** - Pluggable interface for RTP/SRTP live view (bring your own implementation)
|
||||
- **Zero internal dependencies** - Only depends on `pkg/` packages, never on `internal/`
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
pkg/hksv/
|
||||
hksv.go - Server, Config, interfaces (StreamProvider, PairingStore, etc.)
|
||||
consumer.go - HKSVConsumer: fMP4 muxer + GOP buffer + HDS sender
|
||||
session.go - hksvSession: HDS DataStream lifecycle management
|
||||
motion.go - MotionDetector: P-frame based motion detection
|
||||
helpers.go - Helper functions for ID/name generation
|
||||
consumer_test.go - Consumer tests and benchmarks
|
||||
motion_test.go - Motion detector tests and benchmarks
|
||||
```
|
||||
|
||||
### Dependency Graph
|
||||
|
||||
```
|
||||
pkg/hksv/
|
||||
-> pkg/core (Consumer, Connection, Media, Codec, Receiver, Sender)
|
||||
-> pkg/hap (Server, Conn, Accessory, Character)
|
||||
-> pkg/hap/hds (Conn, Session - encrypted DataStream)
|
||||
-> pkg/hap/camera (TLV8 structs, services, accessory factories)
|
||||
-> pkg/hap/tlv8 (marshal/unmarshal)
|
||||
-> pkg/homekit (ServerHandler, ProxyHandler, HandlerFunc)
|
||||
-> pkg/mp4 (Muxer - fMP4)
|
||||
-> pkg/h264 (IsKeyframe, RTPDepay, RepairAVCC)
|
||||
-> pkg/aac (RTPDepay)
|
||||
-> pkg/mdns (ServiceEntry for mDNS advertisement)
|
||||
-> github.com/pion/rtp
|
||||
-> github.com/rs/zerolog
|
||||
-> ZERO imports from internal/
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Minimal HKSV Camera
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hksv"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mdns"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func main() {
|
||||
logger := log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
|
||||
|
||||
srv, err := hksv.NewServer(hksv.Config{
|
||||
StreamName: "my-camera",
|
||||
Pin: "27041991",
|
||||
HKSV: true,
|
||||
MotionMode: "detect",
|
||||
Streams: &myStreamProvider{},
|
||||
Store: &myPairingStore{},
|
||||
Snapshots: &mySnapshotProvider{},
|
||||
Logger: logger,
|
||||
Port: 8080,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Fatal().Err(err).Msg("failed to create server")
|
||||
}
|
||||
|
||||
// Register HAP endpoints
|
||||
http.HandleFunc(hap.PathPairSetup, func(w http.ResponseWriter, r *http.Request) {
|
||||
srv.Handle(w, r)
|
||||
})
|
||||
http.HandleFunc(hap.PathPairVerify, func(w http.ResponseWriter, r *http.Request) {
|
||||
srv.Handle(w, r)
|
||||
})
|
||||
|
||||
// Advertise via mDNS
|
||||
entry := srv.MDNSEntry()
|
||||
go mdns.Serve(mdns.ServiceHAP, []*mdns.ServiceEntry{entry})
|
||||
|
||||
// Start HTTP server
|
||||
logger.Info().Msg("HomeKit camera running on :8080")
|
||||
http.ListenAndServe(":8080", nil)
|
||||
}
|
||||
```
|
||||
|
||||
### HKSV Camera with Live Streaming
|
||||
|
||||
```go
|
||||
srv, err := hksv.NewServer(hksv.Config{
|
||||
StreamName: "my-camera",
|
||||
Pin: "27041991",
|
||||
HKSV: true,
|
||||
MotionMode: "detect",
|
||||
|
||||
// Required interfaces
|
||||
Streams: &myStreamProvider{},
|
||||
Store: &myPairingStore{},
|
||||
Snapshots: &mySnapshotProvider{},
|
||||
LiveStream: &myLiveStreamHandler{}, // enables live view in Home app
|
||||
Logger: logger,
|
||||
Port: 8080,
|
||||
})
|
||||
```
|
||||
|
||||
### Basic Camera (no HKSV, live streaming only)
|
||||
|
||||
```go
|
||||
srv, err := hksv.NewServer(hksv.Config{
|
||||
StreamName: "basic-cam",
|
||||
Pin: "27041991",
|
||||
HKSV: false, // no HKSV recording
|
||||
|
||||
Streams: &myStreamProvider{},
|
||||
LiveStream: &myLiveStreamHandler{},
|
||||
Logger: logger,
|
||||
Port: 8080,
|
||||
})
|
||||
```
|
||||
|
||||
### Proxy Mode (transparent proxy for existing HomeKit camera)
|
||||
|
||||
```go
|
||||
srv, err := hksv.NewServer(hksv.Config{
|
||||
StreamName: "proxied-cam",
|
||||
Pin: "27041991",
|
||||
ProxyURL: "homekit://192.168.1.100:51827?device_id=AA:BB:CC:DD:EE:FF&...",
|
||||
|
||||
Logger: logger,
|
||||
Port: 8080,
|
||||
})
|
||||
```
|
||||
|
||||
### HomeKit Doorbell
|
||||
|
||||
```go
|
||||
srv, err := hksv.NewServer(hksv.Config{
|
||||
StreamName: "my-doorbell",
|
||||
Pin: "27041991",
|
||||
CategoryID: "doorbell", // creates doorbell accessory
|
||||
HKSV: true,
|
||||
MotionMode: "detect",
|
||||
|
||||
Streams: &myStreamProvider{},
|
||||
Store: &myPairingStore{},
|
||||
Snapshots: &mySnapshotProvider{},
|
||||
Logger: logger,
|
||||
Port: 8080,
|
||||
})
|
||||
|
||||
// Trigger doorbell press from external event
|
||||
srv.TriggerDoorbell()
|
||||
```
|
||||
|
||||
## Interfaces
|
||||
|
||||
The library uses dependency injection via four interfaces. You implement these to connect `hksv` to your own stream management, storage, and media pipeline.
|
||||
|
||||
### StreamProvider (required)
|
||||
|
||||
Connects HKSV consumers to your video/audio streams.
|
||||
|
||||
```go
|
||||
type StreamProvider interface {
|
||||
// AddConsumer connects a consumer to the named stream.
|
||||
// The consumer implements core.Consumer (AddTrack, WriteTo, Stop).
|
||||
AddConsumer(streamName string, consumer core.Consumer) error
|
||||
|
||||
// RemoveConsumer disconnects a consumer from the named stream.
|
||||
RemoveConsumer(streamName string, consumer core.Consumer)
|
||||
}
|
||||
```
|
||||
|
||||
**Example implementation:**
|
||||
|
||||
```go
|
||||
type myStreamProvider struct {
|
||||
streams map[string]*Stream // your stream registry
|
||||
}
|
||||
|
||||
func (p *myStreamProvider) AddConsumer(name string, cons core.Consumer) error {
|
||||
stream, ok := p.streams[name]
|
||||
if !ok {
|
||||
return fmt.Errorf("stream not found: %s", name)
|
||||
}
|
||||
return stream.AddConsumer(cons)
|
||||
}
|
||||
|
||||
func (p *myStreamProvider) RemoveConsumer(name string, cons core.Consumer) {
|
||||
if stream, ok := p.streams[name]; ok {
|
||||
stream.RemoveConsumer(cons)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### PairingStore (optional)
|
||||
|
||||
Persists HomeKit pairing data across restarts. If `nil`, pairings are lost on restart and the device must be re-paired.
|
||||
|
||||
```go
|
||||
type PairingStore interface {
|
||||
SavePairings(streamName string, pairings []string) error
|
||||
}
|
||||
```
|
||||
|
||||
**Example implementation (JSON file):**
|
||||
|
||||
```go
|
||||
type filePairingStore struct {
|
||||
path string
|
||||
}
|
||||
|
||||
func (s *filePairingStore) SavePairings(name string, pairings []string) error {
|
||||
data := map[string][]string{name: pairings}
|
||||
b, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(s.path, b, 0644)
|
||||
}
|
||||
```
|
||||
|
||||
### SnapshotProvider (optional)
|
||||
|
||||
Generates JPEG snapshots for HomeKit `/resource` requests (shown in the Home app timeline and notifications). If `nil`, snapshots are not available.
|
||||
|
||||
```go
|
||||
type SnapshotProvider interface {
|
||||
GetSnapshot(streamName string, width, height int) ([]byte, error)
|
||||
}
|
||||
```
|
||||
|
||||
**Example implementation (ffmpeg):**
|
||||
|
||||
```go
|
||||
type ffmpegSnapshotProvider struct {
|
||||
streams map[string]*Stream
|
||||
}
|
||||
|
||||
func (p *ffmpegSnapshotProvider) GetSnapshot(name string, w, h int) ([]byte, error) {
|
||||
stream := p.streams[name]
|
||||
if stream == nil {
|
||||
return nil, errors.New("stream not found")
|
||||
}
|
||||
|
||||
// Capture one keyframe from the stream
|
||||
frame, err := stream.CaptureKeyframe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Convert to JPEG using ffmpeg
|
||||
return ffmpegToJPEG(frame, w, h)
|
||||
}
|
||||
```
|
||||
|
||||
### LiveStreamHandler (optional)
|
||||
|
||||
Handles live-streaming requests from the Home app (RTP/SRTP setup). If `nil`, only HKSV recording is available (no live view).
|
||||
|
||||
```go
|
||||
type LiveStreamHandler interface {
|
||||
// SetupEndpoints handles a SetupEndpoints request (HAP characteristic 118).
|
||||
// Creates the RTP/SRTP consumer, returns the response value.
|
||||
SetupEndpoints(conn net.Conn, offer *camera.SetupEndpointsRequest) (any, error)
|
||||
|
||||
// GetEndpointsResponse returns the current endpoints response (for GET requests).
|
||||
GetEndpointsResponse() any
|
||||
|
||||
// StartStream starts RTP streaming with the given configuration.
|
||||
// The connTracker is used to register/unregister the live stream connection
|
||||
// on the HKSV server (for connection tracking and MarshalJSON).
|
||||
StartStream(streamName string, conf *camera.SelectedStreamConfiguration, connTracker ConnTracker) error
|
||||
|
||||
// StopStream stops a stream matching the given session ID.
|
||||
StopStream(sessionID string, connTracker ConnTracker) error
|
||||
}
|
||||
|
||||
type ConnTracker interface {
|
||||
AddConn(v any)
|
||||
DelConn(v any)
|
||||
}
|
||||
```
|
||||
|
||||
**Example implementation (SRTP-based):**
|
||||
|
||||
```go
|
||||
type srtpLiveStreamHandler struct {
|
||||
mu sync.Mutex
|
||||
consumer *homekit.Consumer
|
||||
srtp *srtp.Server
|
||||
streams map[string]*Stream
|
||||
}
|
||||
|
||||
func (h *srtpLiveStreamHandler) SetupEndpoints(conn net.Conn, offer *camera.SetupEndpointsRequest) (any, error) {
|
||||
consumer := homekit.NewConsumer(conn, h.srtp)
|
||||
consumer.SetOffer(offer)
|
||||
|
||||
h.mu.Lock()
|
||||
h.consumer = consumer
|
||||
h.mu.Unlock()
|
||||
|
||||
answer := consumer.GetAnswer()
|
||||
v, err := tlv8.MarshalBase64(answer)
|
||||
return v, err
|
||||
}
|
||||
|
||||
func (h *srtpLiveStreamHandler) GetEndpointsResponse() any {
|
||||
h.mu.Lock()
|
||||
consumer := h.consumer
|
||||
h.mu.Unlock()
|
||||
if consumer == nil {
|
||||
return nil
|
||||
}
|
||||
answer := consumer.GetAnswer()
|
||||
v, _ := tlv8.MarshalBase64(answer)
|
||||
return v
|
||||
}
|
||||
|
||||
func (h *srtpLiveStreamHandler) StartStream(streamName string, conf *camera.SelectedStreamConfiguration, ct hksv.ConnTracker) error {
|
||||
h.mu.Lock()
|
||||
consumer := h.consumer
|
||||
h.mu.Unlock()
|
||||
if consumer == nil {
|
||||
return errors.New("no consumer")
|
||||
}
|
||||
if !consumer.SetConfig(conf) {
|
||||
return errors.New("wrong config")
|
||||
}
|
||||
|
||||
ct.AddConn(consumer)
|
||||
stream := h.streams[streamName]
|
||||
if err := stream.AddConsumer(consumer); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go func() {
|
||||
_, _ = consumer.WriteTo(nil) // blocks until stream ends
|
||||
stream.RemoveConsumer(consumer)
|
||||
ct.DelConn(consumer)
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *srtpLiveStreamHandler) StopStream(sessionID string, ct hksv.ConnTracker) error {
|
||||
h.mu.Lock()
|
||||
consumer := h.consumer
|
||||
h.mu.Unlock()
|
||||
if consumer != nil && consumer.SessionID() == sessionID {
|
||||
_ = consumer.Stop()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
## Config Reference
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
// Required
|
||||
StreamName string // stream identifier (used for lookups)
|
||||
Pin string // HomeKit pairing PIN, e.g. "27041991" (default)
|
||||
Port uint16 // HAP HTTP port
|
||||
Logger zerolog.Logger // structured logger
|
||||
Streams StreamProvider // stream registry (required for HKSV/live/motion)
|
||||
|
||||
// Optional - server identity
|
||||
Name string // mDNS display name (auto-generated from DeviceID if empty)
|
||||
DeviceID string // MAC-like ID, e.g. "AA:BB:CC:DD:EE:FF" (auto-generated if empty)
|
||||
DevicePrivate string // ed25519 private key hex (auto-generated if empty)
|
||||
CategoryID string // "camera" (default), "doorbell", "bridge", or numeric
|
||||
Pairings []string // pre-existing pairings from storage
|
||||
|
||||
// Optional - mode
|
||||
ProxyURL string // if set, acts as transparent proxy (no local accessory)
|
||||
HKSV bool // enable HKSV recording support
|
||||
|
||||
// Optional - motion detection
|
||||
MotionMode string // "api" (external trigger), "continuous" (always on), "detect" (P-frame analysis)
|
||||
MotionThreshold float64 // ratio threshold for "detect" mode (default 2.0, lower = more sensitive)
|
||||
|
||||
// Optional - hardware
|
||||
Speaker *bool // include Speaker service for 2-way audio (default false)
|
||||
|
||||
// Optional - metadata
|
||||
UserAgent string // for mDNS TXTModel field
|
||||
Version string // for accessory firmware version
|
||||
|
||||
// Optional - persistence and features
|
||||
Store PairingStore // nil = pairings not persisted
|
||||
Snapshots SnapshotProvider // nil = no snapshot support
|
||||
LiveStream LiveStreamHandler // nil = no live streaming (HKSV recording only)
|
||||
}
|
||||
```
|
||||
|
||||
## Motion Detection
|
||||
|
||||
The library includes a built-in P-frame based motion detector that works without any external motion detection system.
|
||||
|
||||
### How It Works
|
||||
|
||||
1. During a **warmup phase** (30 P-frames), the detector establishes a baseline average frame size using fast EMA (alpha=0.1).
|
||||
2. After warmup, each P-frame size is compared against the baseline multiplied by the threshold.
|
||||
3. If `frame_size > baseline * threshold`, motion is detected.
|
||||
4. Motion stays active for a **hold period** (30 seconds) after the last trigger frame.
|
||||
5. After motion ends, there is a **cooldown period** (5 seconds) before new motion can be detected.
|
||||
6. The baseline is updated continuously with slow EMA (alpha=0.02) during idle periods.
|
||||
7. FPS is recalibrated every 150 frames for accurate hold/cooldown timing.
|
||||
|
||||
### Motion Modes
|
||||
|
||||
| Mode | Description |
|
||||
|------|-------------|
|
||||
| `"api"` | Motion is triggered externally via `srv.SetMotionDetected(true/false)` |
|
||||
| `"detect"` | Automatic P-frame analysis (starts on first Home Hub connection) |
|
||||
| `"continuous"` | Always reports motion every 30 seconds (for testing/always-record) |
|
||||
|
||||
### Using the MotionDetector Standalone
|
||||
|
||||
The `MotionDetector` can be used independently as a `core.Consumer`:
|
||||
|
||||
```go
|
||||
onMotion := func(detected bool) {
|
||||
if detected {
|
||||
log.Println("Motion started!")
|
||||
// start recording, send notification, etc.
|
||||
} else {
|
||||
log.Println("Motion ended")
|
||||
}
|
||||
}
|
||||
|
||||
detector := hksv.NewMotionDetector(2.0, onMotion, logger)
|
||||
|
||||
// Attach to a stream (detector implements core.Consumer)
|
||||
err := stream.AddConsumer(detector)
|
||||
|
||||
// Blocks until Stop() is called
|
||||
go func() {
|
||||
detector.WriteTo(nil)
|
||||
}()
|
||||
|
||||
// Later, stop the detector
|
||||
detector.Stop()
|
||||
```
|
||||
|
||||
## Server API
|
||||
|
||||
### Motion Control
|
||||
|
||||
```go
|
||||
// Trigger motion detected (for "api" mode or external sensors)
|
||||
srv.SetMotionDetected(true)
|
||||
|
||||
// Clear motion
|
||||
srv.SetMotionDetected(false)
|
||||
|
||||
// Trigger doorbell press event
|
||||
srv.TriggerDoorbell()
|
||||
```
|
||||
|
||||
### Connection Tracking
|
||||
|
||||
```go
|
||||
// Register a connection (for monitoring/JSON output)
|
||||
srv.AddConn(conn)
|
||||
|
||||
// Unregister a connection
|
||||
srv.DelConn(conn)
|
||||
```
|
||||
|
||||
### Pairing Management
|
||||
|
||||
```go
|
||||
// Add a new pairing (called automatically during HAP pair-setup)
|
||||
srv.AddPair(clientID, publicKey, hap.PermissionAdmin)
|
||||
|
||||
// Remove a pairing
|
||||
srv.DelPair(clientID)
|
||||
|
||||
// Get client's public key (used by HAP pair-verify)
|
||||
pubKey := srv.GetPair(clientID)
|
||||
```
|
||||
|
||||
### JSON Serialization
|
||||
|
||||
The server implements `json.Marshaler` for status reporting:
|
||||
|
||||
```go
|
||||
b, _ := json.Marshal(srv)
|
||||
// {"name":"go2rtc-A1B2","device_id":"AA:BB:CC:DD:EE:FF","paired":1,"category_id":"17","connections":[...]}
|
||||
|
||||
// If not paired, includes setup_code and setup_id for QR code generation
|
||||
// {"name":"go2rtc-A1B2","device_id":"AA:BB:CC:DD:EE:FF","setup_code":"195-50-224","setup_id":"A1B2"}
|
||||
```
|
||||
|
||||
### mDNS Advertisement
|
||||
|
||||
```go
|
||||
entry := srv.MDNSEntry()
|
||||
|
||||
// Start mDNS advertisement
|
||||
go mdns.Serve(mdns.ServiceHAP, []*mdns.ServiceEntry{entry})
|
||||
```
|
||||
|
||||
## Helper Functions
|
||||
|
||||
For deterministic ID generation from stream names:
|
||||
|
||||
```go
|
||||
// Generate a display name from a seed
|
||||
name := hksv.CalcName("", "my-camera")
|
||||
// => "go2rtc-A1B2" (deterministic from seed)
|
||||
|
||||
name = hksv.CalcName("My Camera", "")
|
||||
// => "My Camera" (uses provided name)
|
||||
|
||||
// Generate a MAC-like device ID
|
||||
deviceID := hksv.CalcDeviceID("", "my-camera")
|
||||
// => "AA:BB:CC:DD:EE:FF" (deterministic from seed)
|
||||
|
||||
// Generate an ed25519 private key
|
||||
privateKey := hksv.CalcDevicePrivate("", "my-camera")
|
||||
// => []byte{...} (deterministic 64-byte ed25519 key)
|
||||
|
||||
// Generate a setup ID for QR codes
|
||||
setupID := hksv.CalcSetupID("my-camera")
|
||||
// => "A1B2"
|
||||
|
||||
// Convert category string to HAP constant
|
||||
catID := hksv.CalcCategoryID("doorbell")
|
||||
// => "18" (hap.CategoryDoorbell)
|
||||
```
|
||||
|
||||
## Multiple Cameras
|
||||
|
||||
You can run multiple HKSV cameras on a single port. Each camera gets its own mDNS entry and is resolved by hostname:
|
||||
|
||||
```go
|
||||
cameras := []string{"front-door", "backyard", "garage"}
|
||||
var entries []*mdns.ServiceEntry
|
||||
|
||||
for _, name := range cameras {
|
||||
srv, _ := hksv.NewServer(hksv.Config{
|
||||
StreamName: name,
|
||||
Pin: "27041991",
|
||||
HKSV: true,
|
||||
MotionMode: "detect",
|
||||
Streams: provider,
|
||||
Logger: logger,
|
||||
Port: 8080,
|
||||
})
|
||||
|
||||
entry := srv.MDNSEntry()
|
||||
entries = append(entries, entry)
|
||||
|
||||
// Map hostname -> server for HTTP routing
|
||||
host := entry.Host(mdns.ServiceHAP)
|
||||
handlers[host] = srv
|
||||
}
|
||||
|
||||
// Single HTTP server handles all cameras
|
||||
http.HandleFunc(hap.PathPairSetup, func(w http.ResponseWriter, r *http.Request) {
|
||||
if srv := handlers[r.Host]; srv != nil {
|
||||
srv.Handle(w, r)
|
||||
}
|
||||
})
|
||||
http.HandleFunc(hap.PathPairVerify, func(w http.ResponseWriter, r *http.Request) {
|
||||
if srv := handlers[r.Host]; srv != nil {
|
||||
srv.Handle(w, r)
|
||||
}
|
||||
})
|
||||
|
||||
go mdns.Serve(mdns.ServiceHAP, entries)
|
||||
http.ListenAndServe(":8080", nil)
|
||||
```
|
||||
|
||||
## HKSV Recording Flow
|
||||
|
||||
Understanding the recording flow helps with debugging:
|
||||
|
||||
```
|
||||
1. Home Hub discovers camera via mDNS
|
||||
2. Home Hub connects -> PairSetup (first time) or PairVerify (subsequent)
|
||||
3. On PairVerify success:
|
||||
- If motion="detect": MotionDetector starts consuming the video stream
|
||||
- If motion="continuous": prepareHKSVConsumer() + startContinuousMotion()
|
||||
4. Motion detected -> SetMotionDetected(true) -> HAP event notification
|
||||
5. Home Hub receives motion event -> sets up HDS DataStream:
|
||||
- SetCharacteristic(TypeSetupDataStreamTransport) -> TCP listener created
|
||||
- Home Hub connects to TCP port -> encrypted HDS connection established
|
||||
- hksvSession created
|
||||
6. Home Hub opens dataSend stream:
|
||||
- handleOpen() -> takes prepared consumer (or creates new one)
|
||||
- consumer.Activate() -> sends fMP4 init segment over HDS
|
||||
- H264 keyframes trigger GOP flush -> mediaFragment sent over HDS
|
||||
7. Home Hub closes dataSend -> handleClose() -> consumer stopped
|
||||
8. Motion timeout -> SetMotionDetected(false)
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
go test ./pkg/hksv/...
|
||||
|
||||
# Run with verbose output
|
||||
go test -v ./pkg/hksv/...
|
||||
|
||||
# Run benchmarks
|
||||
go test -bench=. ./pkg/hksv/...
|
||||
|
||||
# Run specific test
|
||||
go test -v -run TestMotionDetector_BasicTrigger ./pkg/hksv/...
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Go 1.22+
|
||||
- Dependencies: `github.com/pion/rtp`, `github.com/rs/zerolog` (plus go2rtc `pkg/` packages)
|
||||
@@ -0,0 +1,257 @@
|
||||
// Author: Sergei "svk" Krashevich <svk@svk.su>
|
||||
package hksv
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/aac"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/h264"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap/hds"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mp4"
|
||||
"github.com/pion/rtp"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// HKSVConsumer implements core.Consumer, generates fMP4 and sends over HDS.
|
||||
// It can be pre-started without an HDS session, buffering init data until activated.
|
||||
type HKSVConsumer struct {
|
||||
core.Connection
|
||||
muxer *mp4.Muxer
|
||||
mu sync.Mutex
|
||||
done chan struct{}
|
||||
log zerolog.Logger
|
||||
|
||||
// Set by Activate() when HDS session is available
|
||||
session *hds.Session
|
||||
streamID int
|
||||
seqNum int
|
||||
active bool
|
||||
start bool // waiting for first keyframe
|
||||
|
||||
// GOP buffer - accumulate moof+mdat pairs, flush on next keyframe
|
||||
fragBuf []byte
|
||||
|
||||
// Pre-built init segment (built when tracks connect)
|
||||
initData []byte
|
||||
initErr error
|
||||
initDone chan struct{} // closed when init is ready
|
||||
}
|
||||
|
||||
// NewHKSVConsumer creates a new HKSV consumer that muxes H264+AAC into fMP4
|
||||
// and sends fragments over an HDS DataStream session.
|
||||
func NewHKSVConsumer(log zerolog.Logger) *HKSVConsumer {
|
||||
medias := []*core.Media{
|
||||
{
|
||||
Kind: core.KindVideo,
|
||||
Direction: core.DirectionSendonly,
|
||||
Codecs: []*core.Codec{
|
||||
{Name: core.CodecH264},
|
||||
},
|
||||
},
|
||||
{
|
||||
Kind: core.KindAudio,
|
||||
Direction: core.DirectionSendonly,
|
||||
Codecs: []*core.Codec{
|
||||
{Name: core.CodecAAC},
|
||||
},
|
||||
},
|
||||
}
|
||||
return &HKSVConsumer{
|
||||
Connection: core.Connection{
|
||||
ID: core.NewID(),
|
||||
FormatName: "hksv",
|
||||
Protocol: "hds",
|
||||
Medias: medias,
|
||||
},
|
||||
muxer: &mp4.Muxer{},
|
||||
done: make(chan struct{}),
|
||||
initDone: make(chan struct{}),
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *HKSVConsumer) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiver) error {
|
||||
// Reject late tracks after init segment is built (can't modify fMP4 header)
|
||||
select {
|
||||
case <-c.initDone:
|
||||
c.log.Debug().Str("codec", track.Codec.Name).Msg("[hksv] ignoring late track (init already built)")
|
||||
return nil
|
||||
default:
|
||||
}
|
||||
|
||||
trackID := byte(len(c.Senders))
|
||||
|
||||
c.log.Debug().Str("codec", track.Codec.Name).Uint8("trackID", trackID).Msg("[hksv] AddTrack")
|
||||
|
||||
codec := track.Codec.Clone()
|
||||
handler := core.NewSender(media, codec)
|
||||
|
||||
switch track.Codec.Name {
|
||||
case core.CodecH264:
|
||||
handler.Handler = func(packet *rtp.Packet) {
|
||||
c.mu.Lock()
|
||||
if !c.active {
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if !c.start {
|
||||
if !h264.IsKeyframe(packet.Payload) {
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
c.start = true
|
||||
c.log.Debug().Int("payloadLen", len(packet.Payload)).Msg("[hksv] first keyframe")
|
||||
} else if h264.IsKeyframe(packet.Payload) && len(c.fragBuf) > 0 {
|
||||
// New keyframe = flush previous GOP as one mediaFragment
|
||||
c.flushFragment()
|
||||
}
|
||||
|
||||
b := c.muxer.GetPayload(trackID, packet)
|
||||
c.fragBuf = append(c.fragBuf, b...)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
if track.Codec.IsRTP() {
|
||||
handler.Handler = h264.RTPDepay(track.Codec, handler.Handler)
|
||||
} else {
|
||||
handler.Handler = h264.RepairAVCC(track.Codec, handler.Handler)
|
||||
}
|
||||
|
||||
case core.CodecAAC:
|
||||
handler.Handler = func(packet *rtp.Packet) {
|
||||
c.mu.Lock()
|
||||
if !c.active || !c.start {
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
b := c.muxer.GetPayload(trackID, packet)
|
||||
c.fragBuf = append(c.fragBuf, b...)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
if track.Codec.IsRTP() {
|
||||
handler.Handler = aac.RTPDepay(handler.Handler)
|
||||
}
|
||||
|
||||
default:
|
||||
return nil // skip unsupported codecs
|
||||
}
|
||||
|
||||
c.muxer.AddTrack(codec)
|
||||
handler.HandleRTP(track)
|
||||
c.Senders = append(c.Senders, handler)
|
||||
|
||||
// Build init segment when all expected tracks are ready
|
||||
select {
|
||||
case <-c.initDone:
|
||||
// already built — ignore late tracks (init is immutable)
|
||||
default:
|
||||
if len(c.Senders) >= len(c.Medias) {
|
||||
c.buildInit()
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildInit creates the init segment from currently connected tracks.
|
||||
// Must only be called once (closes initDone).
|
||||
func (c *HKSVConsumer) buildInit() {
|
||||
initData, err := c.muxer.GetInit()
|
||||
c.initData = initData
|
||||
c.initErr = err
|
||||
close(c.initDone)
|
||||
if err != nil {
|
||||
c.log.Error().Err(err).Msg("[hksv] GetInit failed")
|
||||
} else {
|
||||
c.log.Debug().Int("initSize", len(initData)).Int("tracks", len(c.Senders)).Msg("[hksv] init segment ready")
|
||||
}
|
||||
}
|
||||
|
||||
// Activate is called when the HDS session is ready (dataSend.open).
|
||||
// It sends the pre-built init segment and starts streaming.
|
||||
func (c *HKSVConsumer) Activate(session *hds.Session, streamID int) error {
|
||||
// Wait for init to be ready (should already be done if consumer was pre-started)
|
||||
select {
|
||||
case <-c.initDone:
|
||||
case <-time.After(5 * time.Second):
|
||||
// Build init with whatever tracks we have (audio may be missing)
|
||||
select {
|
||||
case <-c.initDone:
|
||||
default:
|
||||
if len(c.Senders) > 0 {
|
||||
c.log.Warn().Int("tracks", len(c.Senders)).Msg("[hksv] init timeout, building with available tracks")
|
||||
c.buildInit()
|
||||
} else {
|
||||
return errors.New("hksv: no tracks connected after timeout")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if c.initErr != nil {
|
||||
return c.initErr
|
||||
}
|
||||
|
||||
c.log.Debug().Int("initSize", len(c.initData)).Msg("[hksv] sending init segment")
|
||||
|
||||
if err := session.SendMediaInit(streamID, c.initData); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.log.Debug().Msg("[hksv] init segment sent OK")
|
||||
|
||||
// Enable live streaming (seqNum=2 because init used seqNum=1)
|
||||
c.mu.Lock()
|
||||
c.session = session
|
||||
c.streamID = streamID
|
||||
c.seqNum = 2
|
||||
c.active = true
|
||||
c.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// flushFragment sends the accumulated GOP buffer as a single mediaFragment.
|
||||
// Must be called while holding c.mu.
|
||||
func (c *HKSVConsumer) flushFragment() {
|
||||
fragment := c.fragBuf
|
||||
c.fragBuf = make([]byte, 0, len(fragment))
|
||||
|
||||
c.log.Debug().Int("fragSize", len(fragment)).Int("seq", c.seqNum).Msg("[hksv] flush fragment")
|
||||
|
||||
if err := c.session.SendMediaFragment(c.streamID, fragment, c.seqNum); err == nil {
|
||||
c.Send += len(fragment)
|
||||
}
|
||||
c.seqNum++
|
||||
}
|
||||
|
||||
func (c *HKSVConsumer) WriteTo(io.Writer) (int64, error) {
|
||||
<-c.done
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (c *HKSVConsumer) Stop() error {
|
||||
select {
|
||||
case <-c.done:
|
||||
default:
|
||||
close(c.done)
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.active = false
|
||||
c.mu.Unlock()
|
||||
return c.Connection.Stop()
|
||||
}
|
||||
|
||||
// Done returns a channel that is closed when the consumer is stopped.
|
||||
func (c *HKSVConsumer) Done() <-chan struct{} {
|
||||
return c.done
|
||||
}
|
||||
|
||||
func (c *HKSVConsumer) String() string {
|
||||
return "hksv consumer"
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
package homekit
|
||||
// Author: Sergei "svk" Krashevich <svk@svk.su>
|
||||
package hksv
|
||||
|
||||
import (
|
||||
"net"
|
||||
@@ -8,9 +9,12 @@ import (
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap/hds"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var testLog = zerolog.Nop()
|
||||
|
||||
// newTestSessionPair creates connected HDS sessions for testing.
|
||||
func newTestSessionPair(t *testing.T) (accessory *hds.Session, controller *hds.Session) {
|
||||
t.Helper()
|
||||
@@ -29,7 +33,7 @@ func newTestSessionPair(t *testing.T) (accessory *hds.Session, controller *hds.S
|
||||
}
|
||||
|
||||
func TestHKSVConsumer_Creation(t *testing.T) {
|
||||
c := newHKSVConsumer()
|
||||
c := NewHKSVConsumer(testLog)
|
||||
|
||||
require.Equal(t, "hksv", c.FormatName)
|
||||
require.Equal(t, "hds", c.Protocol)
|
||||
@@ -51,9 +55,9 @@ func TestHKSVConsumer_Creation(t *testing.T) {
|
||||
|
||||
func TestHKSVConsumer_FlushFragment_SendsAndIncrements(t *testing.T) {
|
||||
acc, ctrl := newTestSessionPair(t)
|
||||
c := newHKSVConsumer()
|
||||
c := NewHKSVConsumer(testLog)
|
||||
|
||||
// Manually set up the consumer as if activate() was called
|
||||
// Manually set up the consumer as if Activate() was called
|
||||
c.session = acc
|
||||
c.streamID = 1
|
||||
c.seqNum = 2
|
||||
@@ -91,7 +95,7 @@ func TestHKSVConsumer_FlushFragment_SendsAndIncrements(t *testing.T) {
|
||||
|
||||
func TestHKSVConsumer_FlushFragment_MultipleFlushes(t *testing.T) {
|
||||
acc, ctrl := newTestSessionPair(t)
|
||||
c := newHKSVConsumer()
|
||||
c := NewHKSVConsumer(testLog)
|
||||
c.session = acc
|
||||
c.streamID = 1
|
||||
c.seqNum = 2
|
||||
@@ -133,7 +137,7 @@ func TestHKSVConsumer_FlushFragment_MultipleFlushes(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHKSVConsumer_FlushFragment_EmptyBuffer(t *testing.T) {
|
||||
c := newHKSVConsumer()
|
||||
c := NewHKSVConsumer(testLog)
|
||||
c.seqNum = 2
|
||||
|
||||
// flushFragment with empty/nil buffer should still increment seqNum
|
||||
@@ -149,7 +153,7 @@ func TestHKSVConsumer_FlushFragment_EmptyBuffer(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHKSVConsumer_BufferAccumulation(t *testing.T) {
|
||||
c := newHKSVConsumer()
|
||||
c := NewHKSVConsumer(testLog)
|
||||
c.active = true
|
||||
|
||||
data1 := []byte("chunk-1")
|
||||
@@ -166,7 +170,7 @@ func TestHKSVConsumer_BufferAccumulation(t *testing.T) {
|
||||
|
||||
func TestHKSVConsumer_ActivateSeqNum(t *testing.T) {
|
||||
acc, ctrl := newTestSessionPair(t)
|
||||
c := newHKSVConsumer()
|
||||
c := NewHKSVConsumer(testLog)
|
||||
|
||||
// Simulate init ready
|
||||
c.initData = []byte("fake-init")
|
||||
@@ -188,7 +192,7 @@ func TestHKSVConsumer_ActivateSeqNum(t *testing.T) {
|
||||
require.Equal(t, int64(1), meta["dataSequenceNumber"].(int64))
|
||||
}()
|
||||
|
||||
err := c.activate(acc, 5)
|
||||
err := c.Activate(acc, 5)
|
||||
require.NoError(t, err)
|
||||
<-done
|
||||
|
||||
@@ -200,7 +204,7 @@ func TestHKSVConsumer_ActivateSeqNum(t *testing.T) {
|
||||
|
||||
func TestHKSVConsumer_ActivateTimeout(t *testing.T) {
|
||||
acc, _ := newTestSessionPair(t)
|
||||
c := newHKSVConsumer()
|
||||
c := NewHKSVConsumer(testLog)
|
||||
// Don't close initDone — simulate init never becoming ready
|
||||
|
||||
// Override the timeout for faster test
|
||||
@@ -226,18 +230,18 @@ type timeoutError struct{}
|
||||
func (e *timeoutError) Error() string { return "activate timeout" }
|
||||
|
||||
func TestHKSVConsumer_ActivateWithError(t *testing.T) {
|
||||
c := newHKSVConsumer()
|
||||
c := NewHKSVConsumer(testLog)
|
||||
c.initErr = &timeoutError{}
|
||||
close(c.initDone)
|
||||
|
||||
acc, _ := newTestSessionPair(t)
|
||||
err := c.activate(acc, 1)
|
||||
err := c.Activate(acc, 1)
|
||||
require.Error(t, err)
|
||||
require.False(t, c.active)
|
||||
}
|
||||
|
||||
func TestHKSVConsumer_StopSafety(t *testing.T) {
|
||||
c := newHKSVConsumer()
|
||||
c := NewHKSVConsumer(testLog)
|
||||
c.active = true
|
||||
|
||||
// First stop
|
||||
@@ -251,7 +255,7 @@ func TestHKSVConsumer_StopSafety(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHKSVConsumer_StopDeactivates(t *testing.T) {
|
||||
c := newHKSVConsumer()
|
||||
c := NewHKSVConsumer(testLog)
|
||||
c.active = true
|
||||
c.start = true
|
||||
|
||||
@@ -261,7 +265,7 @@ func TestHKSVConsumer_StopDeactivates(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHKSVConsumer_WriteToDone(t *testing.T) {
|
||||
c := newHKSVConsumer()
|
||||
c := NewHKSVConsumer(testLog)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
@@ -289,7 +293,7 @@ func TestHKSVConsumer_WriteToDone(t *testing.T) {
|
||||
|
||||
func TestHKSVConsumer_GOPFlushIntegration(t *testing.T) {
|
||||
acc, ctrl := newTestSessionPair(t)
|
||||
c := newHKSVConsumer()
|
||||
c := NewHKSVConsumer(testLog)
|
||||
c.session = acc
|
||||
c.streamID = 1
|
||||
c.seqNum = 2
|
||||
@@ -338,7 +342,7 @@ func TestHKSVConsumer_GOPFlushIntegration(t *testing.T) {
|
||||
|
||||
func TestHKSVConsumer_FlushClearsBuffer(t *testing.T) {
|
||||
acc, ctrl := newTestSessionPair(t)
|
||||
c := newHKSVConsumer()
|
||||
c := NewHKSVConsumer(testLog)
|
||||
c.session = acc
|
||||
c.streamID = 1
|
||||
c.seqNum = 2
|
||||
@@ -369,7 +373,7 @@ func TestHKSVConsumer_FlushClearsBuffer(t *testing.T) {
|
||||
|
||||
func TestHKSVConsumer_SendTracking(t *testing.T) {
|
||||
acc, ctrl := newTestSessionPair(t)
|
||||
c := newHKSVConsumer()
|
||||
c := NewHKSVConsumer(testLog)
|
||||
c.session = acc
|
||||
c.streamID = 1
|
||||
c.seqNum = 2
|
||||
@@ -415,7 +419,7 @@ func BenchmarkHKSVConsumer_FlushFragment(b *testing.B) {
|
||||
}
|
||||
}()
|
||||
|
||||
c := newHKSVConsumer()
|
||||
c := NewHKSVConsumer(testLog)
|
||||
c.session = acc
|
||||
c.streamID = 1
|
||||
c.seqNum = 2
|
||||
@@ -434,7 +438,7 @@ func BenchmarkHKSVConsumer_FlushFragment(b *testing.B) {
|
||||
}
|
||||
|
||||
func BenchmarkHKSVConsumer_BufferAppend(b *testing.B) {
|
||||
c := newHKSVConsumer()
|
||||
c := NewHKSVConsumer(testLog)
|
||||
frame := make([]byte, 1500) // typical frame fragment
|
||||
|
||||
b.SetBytes(int64(len(frame)))
|
||||
@@ -450,7 +454,7 @@ func BenchmarkHKSVConsumer_BufferAppend(b *testing.B) {
|
||||
func BenchmarkHKSVConsumer_CreateAndStop(b *testing.B) {
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
c := newHKSVConsumer()
|
||||
c := NewHKSVConsumer(testLog)
|
||||
_ = c.Stop()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Author: Sergei "svk" Krashevich <svk@svk.su>
|
||||
package hksv
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/sha512"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap"
|
||||
)
|
||||
|
||||
// CalcName generates a HomeKit display name from a seed if name is empty.
|
||||
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])
|
||||
}
|
||||
|
||||
// CalcDeviceID generates a MAC-like device ID from a seed if deviceID is empty.
|
||||
func CalcDeviceID(deviceID, seed string) string {
|
||||
if deviceID != "" {
|
||||
if len(deviceID) >= 17 {
|
||||
return deviceID
|
||||
}
|
||||
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])
|
||||
}
|
||||
|
||||
// CalcDevicePrivate generates an ed25519 private key from a seed if private is empty.
|
||||
func CalcDevicePrivate(private, seed string) []byte {
|
||||
if private != "" {
|
||||
if b, _ := hex.DecodeString(private); len(b) == ed25519.PrivateKeySize {
|
||||
return b
|
||||
}
|
||||
seed = private
|
||||
}
|
||||
b := sha512.Sum512([]byte(seed))
|
||||
return ed25519.NewKeyFromSeed(b[:ed25519.SeedSize])
|
||||
}
|
||||
|
||||
// CalcSetupID generates a setup ID from a seed.
|
||||
func CalcSetupID(seed string) string {
|
||||
b := sha512.Sum512([]byte(seed))
|
||||
return fmt.Sprintf("%02X%02X", b[44], b[46])
|
||||
}
|
||||
|
||||
// CalcCategoryID converts a category string to a HAP category constant.
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,800 @@
|
||||
// Package hksv provides a reusable HomeKit Secure Video server library.
|
||||
//
|
||||
// It implements HKSV recording (fMP4 over HDS DataStream), motion detection,
|
||||
// and integrates with the HAP protocol for HomeKit pairing and communication.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// srv, err := hksv.NewServer(hksv.Config{
|
||||
// StreamName: "camera1",
|
||||
// Pin: "27041991",
|
||||
// HKSV: true,
|
||||
// MotionMode: "detect",
|
||||
// Streams: myStreamProvider,
|
||||
// Logger: logger,
|
||||
// Port: 8080,
|
||||
// })
|
||||
// // Register srv.Handle as HTTP handler for HAP paths
|
||||
// // Advertise srv.MDNSEntry() via mDNS
|
||||
//
|
||||
// Author: Sergei "svk" Krashevich <svk@svk.su>
|
||||
package hksv
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"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/mdns"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// StreamProvider provides access to media streams.
|
||||
// The host application implements this to connect the HKSV library
|
||||
// to its own stream management system.
|
||||
type StreamProvider interface {
|
||||
// AddConsumer connects a consumer to the named stream.
|
||||
AddConsumer(streamName string, consumer core.Consumer) error
|
||||
// RemoveConsumer disconnects a consumer from the named stream.
|
||||
RemoveConsumer(streamName string, consumer core.Consumer)
|
||||
}
|
||||
|
||||
// PairingStore persists HAP pairing data.
|
||||
type PairingStore interface {
|
||||
SavePairings(streamName string, pairings []string) error
|
||||
}
|
||||
|
||||
// SnapshotProvider generates JPEG snapshots for HomeKit /resource requests.
|
||||
type SnapshotProvider interface {
|
||||
GetSnapshot(streamName string, width, height int) ([]byte, error)
|
||||
}
|
||||
|
||||
// LiveStreamHandler handles live-streaming requests (SetupEndpoints, SelectedStreamConfiguration).
|
||||
// Implementation is external because it depends on SRTP.
|
||||
type LiveStreamHandler interface {
|
||||
// SetupEndpoints handles a SetupEndpoints request (ch118).
|
||||
// Returns the response to store as characteristic value.
|
||||
SetupEndpoints(conn net.Conn, offer *camera.SetupEndpointsRequest) (any, error)
|
||||
|
||||
// GetEndpointsResponse returns the current endpoints response (for GET requests).
|
||||
GetEndpointsResponse() any
|
||||
|
||||
// StartStream starts RTP streaming with the given configuration (ch117 command=start).
|
||||
// The connTracker is used to register/unregister the live stream connection.
|
||||
StartStream(streamName string, conf *camera.SelectedStreamConfiguration, connTracker ConnTracker) error
|
||||
|
||||
// StopStream stops a stream matching the given session ID.
|
||||
StopStream(sessionID string, connTracker ConnTracker) error
|
||||
}
|
||||
|
||||
// ConnTracker allows the live stream handler to track connections on the server.
|
||||
type ConnTracker interface {
|
||||
AddConn(v any)
|
||||
DelConn(v any)
|
||||
}
|
||||
|
||||
// Config for creating an HKSV server.
|
||||
type Config struct {
|
||||
StreamName string
|
||||
Pin string // HomeKit pairing PIN (e.g., "27041991")
|
||||
Name string // mDNS display name (auto-generated if empty)
|
||||
DeviceID string // MAC-like device ID (auto-generated if empty)
|
||||
DevicePrivate string // ed25519 private key hex (auto-generated if empty)
|
||||
CategoryID string // "camera" or "doorbell"
|
||||
Pairings []string // pre-existing pairings
|
||||
ProxyURL string // if set, acts as transparent proxy (no local accessory)
|
||||
HKSV bool
|
||||
MotionMode string // "api", "continuous", "detect"
|
||||
MotionThreshold float64 // ratio threshold for "detect" mode (default 2.0)
|
||||
Speaker *bool // include Speaker service for 2-way audio (default false)
|
||||
UserAgent string // for mDNS TXTModel field
|
||||
Version string // for accessory firmware version
|
||||
|
||||
// Dependencies (injected by host)
|
||||
Streams StreamProvider
|
||||
Store PairingStore // optional, nil = no persistence
|
||||
Snapshots SnapshotProvider // optional, nil = no snapshots
|
||||
LiveStream LiveStreamHandler // optional, nil = HKSV only (no live streaming)
|
||||
Logger zerolog.Logger
|
||||
|
||||
// Network
|
||||
Port uint16 // HAP HTTP port
|
||||
}
|
||||
|
||||
// Server is a complete HKSV camera server.
|
||||
type Server struct {
|
||||
hap *hap.Server
|
||||
mdns *mdns.ServiceEntry
|
||||
log zerolog.Logger
|
||||
|
||||
pairings []string
|
||||
conns []any
|
||||
mu sync.Mutex
|
||||
|
||||
accessory *hap.Accessory
|
||||
setupID string
|
||||
stream string // stream name
|
||||
|
||||
proxyURL string // transparent proxy URL
|
||||
|
||||
// Injected dependencies
|
||||
streams StreamProvider
|
||||
store PairingStore
|
||||
snapshots SnapshotProvider
|
||||
liveStream LiveStreamHandler
|
||||
|
||||
// HKSV fields
|
||||
motionMode string
|
||||
motionThreshold float64
|
||||
motionDetector *MotionDetector
|
||||
hksvSession *hksvSession
|
||||
continuousMotion bool
|
||||
preparedConsumer *HKSVConsumer
|
||||
}
|
||||
|
||||
// NewServer creates a new HKSV server with the given configuration.
|
||||
func NewServer(cfg Config) (*Server, error) {
|
||||
if cfg.Pin == "" {
|
||||
cfg.Pin = "27041991"
|
||||
}
|
||||
|
||||
pin, err := hap.SanitizePin(cfg.Pin)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hksv: invalid pin: %w", err)
|
||||
}
|
||||
|
||||
deviceID := CalcDeviceID(cfg.DeviceID, cfg.StreamName)
|
||||
name := CalcName(cfg.Name, deviceID)
|
||||
setupID := CalcSetupID(cfg.StreamName)
|
||||
|
||||
srv := &Server{
|
||||
stream: cfg.StreamName,
|
||||
pairings: cfg.Pairings,
|
||||
setupID: setupID,
|
||||
log: cfg.Logger,
|
||||
streams: cfg.Streams,
|
||||
store: cfg.Store,
|
||||
snapshots: cfg.Snapshots,
|
||||
liveStream: cfg.LiveStream,
|
||||
motionMode: cfg.MotionMode,
|
||||
motionThreshold: cfg.MotionThreshold,
|
||||
}
|
||||
|
||||
srv.hap = &hap.Server{
|
||||
Pin: pin,
|
||||
DeviceID: deviceID,
|
||||
DevicePrivate: CalcDevicePrivate(cfg.DevicePrivate, cfg.StreamName),
|
||||
GetClientPublic: srv.GetPair,
|
||||
}
|
||||
|
||||
categoryID := CalcCategoryID(cfg.CategoryID)
|
||||
|
||||
srv.mdns = &mdns.ServiceEntry{
|
||||
Name: name,
|
||||
Port: cfg.Port,
|
||||
Info: map[string]string{
|
||||
hap.TXTConfigNumber: "1",
|
||||
hap.TXTFeatureFlags: "0",
|
||||
hap.TXTDeviceID: deviceID,
|
||||
hap.TXTModel: cfg.UserAgent,
|
||||
hap.TXTProtoVersion: "1.1",
|
||||
hap.TXTStateNumber: "1",
|
||||
hap.TXTStatusFlags: hap.StatusNotPaired,
|
||||
hap.TXTCategory: categoryID,
|
||||
hap.TXTSetupHash: hap.SetupHash(setupID, deviceID),
|
||||
},
|
||||
}
|
||||
|
||||
srv.UpdateStatus()
|
||||
|
||||
if cfg.ProxyURL != "" {
|
||||
// Proxy mode: no local accessory
|
||||
srv.proxyURL = cfg.ProxyURL
|
||||
} else if cfg.HKSV {
|
||||
if srv.motionThreshold <= 0 {
|
||||
srv.motionThreshold = defaultThreshold
|
||||
}
|
||||
srv.log.Debug().Str("stream", cfg.StreamName).Str("motion", cfg.MotionMode).
|
||||
Float64("threshold", srv.motionThreshold).Msg("[hksv] HKSV mode")
|
||||
|
||||
if cfg.CategoryID == "doorbell" {
|
||||
srv.accessory = camera.NewHKSVDoorbellAccessory("AlexxIT", "go2rtc", name, "-", cfg.Version)
|
||||
} else {
|
||||
srv.accessory = camera.NewHKSVAccessory("AlexxIT", "go2rtc", name, "-", cfg.Version)
|
||||
}
|
||||
} else {
|
||||
srv.accessory = camera.NewAccessory("AlexxIT", "go2rtc", name, "-", cfg.Version)
|
||||
}
|
||||
|
||||
// Remove Speaker service unless explicitly enabled (default: disabled)
|
||||
if (cfg.Speaker == nil || !*cfg.Speaker) && srv.accessory != nil {
|
||||
filtered := srv.accessory.Services[:0]
|
||||
for _, svc := range srv.accessory.Services {
|
||||
if svc.Type != "113" { // 113 = Speaker
|
||||
filtered = append(filtered, svc)
|
||||
}
|
||||
}
|
||||
srv.accessory.Services = filtered
|
||||
srv.accessory.InitIID() // recalculate IIDs
|
||||
}
|
||||
|
||||
return srv, nil
|
||||
}
|
||||
|
||||
// MDNSEntry returns the mDNS service entry for advertisement.
|
||||
func (s *Server) MDNSEntry() *mdns.ServiceEntry {
|
||||
return s.mdns
|
||||
}
|
||||
|
||||
// Accessory returns the HAP accessory.
|
||||
func (s *Server) Accessory() *hap.Accessory {
|
||||
return s.accessory
|
||||
}
|
||||
|
||||
// StreamName returns the configured stream name.
|
||||
func (s *Server) StreamName() string {
|
||||
return s.stream
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// Handle processes an incoming HAP connection (called from your HTTP server).
|
||||
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 {
|
||||
s.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 {
|
||||
s.log.Debug().Err(err).Caller().Send()
|
||||
return
|
||||
}
|
||||
|
||||
s.log.Debug().Str("stream", s.stream).Str("client_id", id).Msgf("[hksv] %s: new conn", conn.RemoteAddr())
|
||||
|
||||
controller, err := hap.NewConn(conn, rw, key, false)
|
||||
if err != nil {
|
||||
s.log.Error().Err(err).Caller().Send()
|
||||
return
|
||||
}
|
||||
|
||||
s.AddConn(controller)
|
||||
defer s.DelConn(controller)
|
||||
|
||||
// start motion on first Home Hub connection
|
||||
switch s.motionMode {
|
||||
case "detect":
|
||||
go s.startMotionDetector()
|
||||
case "continuous":
|
||||
go s.prepareHKSVConsumer()
|
||||
go s.startContinuousMotion()
|
||||
}
|
||||
|
||||
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 {
|
||||
s.log.Error().Err(err).Caller().Send()
|
||||
return
|
||||
}
|
||||
handler = homekit.ProxyHandler(s, client.Conn)
|
||||
}
|
||||
|
||||
s.log.Debug().Str("stream", s.stream).Msgf("[hksv] handler started for %s", conn.RemoteAddr())
|
||||
|
||||
if err = handler(controller); err != nil {
|
||||
if errors.Is(err, io.EOF) || isClosedConnErr(err) {
|
||||
s.log.Debug().Str("stream", s.stream).Msgf("[hksv] %s: connection closed", conn.RemoteAddr())
|
||||
} else {
|
||||
s.log.Error().Err(err).Str("stream", s.stream).Caller().Send()
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AddConn registers a connection for tracking.
|
||||
func (s *Server) AddConn(v any) {
|
||||
s.log.Trace().Str("stream", s.stream).Msgf("[hksv] add conn %s", connLabel(v))
|
||||
s.mu.Lock()
|
||||
s.conns = append(s.conns, v)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// DelConn unregisters a connection.
|
||||
func (s *Server) DelConn(v any) {
|
||||
s.log.Trace().Str("stream", s.stream).Msgf("[hksv] del conn %s", connLabel(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()
|
||||
}
|
||||
|
||||
// connLabel returns a short human-readable label for a connection.
|
||||
func connLabel(v any) string {
|
||||
switch v := v.(type) {
|
||||
case *hap.Conn:
|
||||
return "hap " + v.RemoteAddr().String()
|
||||
case *hds.Conn:
|
||||
return "hds " + v.RemoteAddr().String()
|
||||
}
|
||||
if s, ok := v.(fmt.Stringer); ok {
|
||||
return s.String()
|
||||
}
|
||||
return fmt.Sprintf("%T", v)
|
||||
}
|
||||
|
||||
func (s *Server) UpdateStatus() {
|
||||
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) {
|
||||
s.log.Debug().Str("stream", s.stream).Msgf("[hksv] 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.savePairings()
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Server) DelPair(id string) {
|
||||
s.log.Debug().Str("stream", s.stream).Msgf("[hksv] 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.savePairings()
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Server) savePairings() {
|
||||
if s.store != nil {
|
||||
if err := s.store.SavePairings(s.stream, s.pairings); err != nil {
|
||||
s.log.Error().Err(err).Msgf("[hksv] can't save %s pairings=%v", s.stream, s.pairings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) GetAccessories(_ net.Conn) []*hap.Accessory {
|
||||
s.log.Trace().Str("stream", s.stream).Msg("[hksv] GET /accessories")
|
||||
if s.log.Trace().Enabled() {
|
||||
if b, err := json.Marshal(s.accessory); err == nil {
|
||||
s.log.Trace().Str("stream", s.stream).Str("accessory", string(b)).Msg("[hksv] accessory JSON")
|
||||
}
|
||||
}
|
||||
return []*hap.Accessory{s.accessory}
|
||||
}
|
||||
|
||||
func (s *Server) GetCharacteristic(conn net.Conn, aid uint8, iid uint64) any {
|
||||
s.log.Trace().Str("stream", s.stream).Msgf("[hksv] get char aid=%d iid=0x%x", aid, iid)
|
||||
|
||||
char := s.accessory.GetCharacterByID(iid)
|
||||
if char == nil {
|
||||
s.log.Warn().Msgf("[hksv] get unknown characteristic: %d", iid)
|
||||
return nil
|
||||
}
|
||||
|
||||
switch char.Type {
|
||||
case camera.TypeSetupEndpoints:
|
||||
if s.liveStream != nil {
|
||||
return s.liveStream.GetEndpointsResponse()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return char.Value
|
||||
}
|
||||
|
||||
func (s *Server) SetCharacteristic(conn net.Conn, aid uint8, iid uint64, value any) {
|
||||
s.log.Trace().Str("stream", s.stream).Msgf("[hksv] set char aid=%d iid=0x%x value=%v", aid, iid, value)
|
||||
|
||||
char := s.accessory.GetCharacterByID(iid)
|
||||
if char == nil {
|
||||
s.log.Warn().Msgf("[hksv] set unknown characteristic: %d", iid)
|
||||
return
|
||||
}
|
||||
|
||||
switch char.Type {
|
||||
case camera.TypeSetupEndpoints:
|
||||
if s.liveStream == nil {
|
||||
return
|
||||
}
|
||||
var offer camera.SetupEndpointsRequest
|
||||
if err := tlv8.UnmarshalBase64(value, &offer); err != nil {
|
||||
return
|
||||
}
|
||||
resp, err := s.liveStream.SetupEndpoints(conn, &offer)
|
||||
if err != nil {
|
||||
s.log.Error().Err(err).Msg("[hksv] setup endpoints failed")
|
||||
}
|
||||
_ = resp // stored by the handler
|
||||
|
||||
case camera.TypeSelectedStreamConfiguration:
|
||||
if s.liveStream == nil {
|
||||
return
|
||||
}
|
||||
var conf camera.SelectedStreamConfiguration
|
||||
if err := tlv8.UnmarshalBase64(value, &conf); err != nil {
|
||||
return
|
||||
}
|
||||
s.log.Trace().Str("stream", s.stream).Msgf("[hksv] stream id=%x cmd=%d", conf.Control.SessionID, conf.Control.Command)
|
||||
|
||||
switch conf.Control.Command {
|
||||
case camera.SessionCommandEnd:
|
||||
_ = s.liveStream.StopStream(conf.Control.SessionID, s)
|
||||
case camera.SessionCommandStart:
|
||||
_ = s.liveStream.StartStream(s.stream, &conf, s)
|
||||
}
|
||||
|
||||
case camera.TypeSetupDataStreamTransport:
|
||||
var req camera.SetupDataStreamTransportRequest
|
||||
if err := tlv8.UnmarshalBase64(value, &req); err != nil {
|
||||
s.log.Error().Err(err).Str("stream", s.stream).Msg("[hksv] parse ch131 failed")
|
||||
return
|
||||
}
|
||||
|
||||
s.log.Debug().Str("stream", s.stream).Uint8("cmd", req.SessionCommandType).
|
||||
Uint8("transport", req.TransportType).Msg("[hksv] DataStream setup")
|
||||
|
||||
if req.SessionCommandType != 0 {
|
||||
s.log.Debug().Str("stream", s.stream).Msg("[hksv] DataStream close request")
|
||||
if s.hksvSession != nil {
|
||||
s.hksvSession.Close()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
accessoryKeySalt := core.RandString(32, 0)
|
||||
combinedSalt := req.ControllerKeySalt + accessoryKeySalt
|
||||
|
||||
ln, err := net.ListenTCP("tcp", nil)
|
||||
if err != nil {
|
||||
s.log.Error().Err(err).Str("stream", s.stream).Msg("[hksv] listen failed")
|
||||
return
|
||||
}
|
||||
port := ln.Addr().(*net.TCPAddr).Port
|
||||
|
||||
resp := camera.SetupDataStreamTransportResponse{
|
||||
Status: 0,
|
||||
AccessoryKeySalt: accessoryKeySalt,
|
||||
}
|
||||
resp.TransportTypeSessionParameters.TCPListeningPort = uint16(port)
|
||||
|
||||
v, err := tlv8.MarshalBase64(resp)
|
||||
if err != nil {
|
||||
ln.Close()
|
||||
return
|
||||
}
|
||||
char.Value = v
|
||||
|
||||
s.log.Debug().Str("stream", s.stream).Int("port", port).Msg("[hksv] listening for HDS")
|
||||
|
||||
hapConn := conn.(*hap.Conn)
|
||||
go s.acceptHDS(hapConn, ln, combinedSalt)
|
||||
|
||||
case camera.TypeSelectedCameraRecordingConfiguration:
|
||||
s.log.Debug().Str("stream", s.stream).Str("motion", s.motionMode).Msg("[hksv] selected recording config")
|
||||
char.Value = value
|
||||
|
||||
switch s.motionMode {
|
||||
case "continuous":
|
||||
go s.startContinuousMotion()
|
||||
case "detect":
|
||||
go s.startMotionDetector()
|
||||
}
|
||||
|
||||
default:
|
||||
char.Value = value
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) GetImage(conn net.Conn, width, height int) []byte {
|
||||
s.log.Trace().Str("stream", s.stream).Msgf("[hksv] get image width=%d height=%d", width, height)
|
||||
|
||||
if s.snapshots == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
b, err := s.snapshots.GetSnapshot(s.stream, width, height)
|
||||
if err != nil {
|
||||
s.log.Error().Err(err).Msg("[hksv] snapshot failed")
|
||||
return nil
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// SetMotionDetected triggers or clears the motion detected characteristic.
|
||||
func (s *Server) SetMotionDetected(detected bool) {
|
||||
if s.accessory == nil {
|
||||
return
|
||||
}
|
||||
char := s.accessory.GetCharacter("22") // MotionDetected
|
||||
if char == nil {
|
||||
return
|
||||
}
|
||||
char.Value = detected
|
||||
_ = char.NotifyListeners(nil)
|
||||
s.log.Debug().Str("stream", s.stream).Bool("motion", detected).Msg("[hksv] motion")
|
||||
}
|
||||
|
||||
// TriggerDoorbell triggers a doorbell press event.
|
||||
func (s *Server) TriggerDoorbell() {
|
||||
if s.accessory == nil {
|
||||
return
|
||||
}
|
||||
char := s.accessory.GetCharacter("73") // ProgrammableSwitchEvent
|
||||
if char == nil {
|
||||
return
|
||||
}
|
||||
char.Value = 0 // SINGLE_PRESS
|
||||
_ = char.NotifyListeners(nil)
|
||||
s.log.Debug().Str("stream", s.stream).Msg("[hksv] doorbell")
|
||||
}
|
||||
|
||||
// acceptHDS opens a TCP listener for the HDS DataStream connection from the Home Hub
|
||||
func (s *Server) acceptHDS(hapConn *hap.Conn, ln net.Listener, salt string) {
|
||||
defer ln.Close()
|
||||
|
||||
if tcpLn, ok := ln.(*net.TCPListener); ok {
|
||||
_ = tcpLn.SetDeadline(time.Now().Add(30 * time.Second))
|
||||
}
|
||||
|
||||
rawConn, err := ln.Accept()
|
||||
if err != nil {
|
||||
s.log.Error().Err(err).Str("stream", s.stream).Msg("[hksv] accept failed")
|
||||
return
|
||||
}
|
||||
defer rawConn.Close()
|
||||
|
||||
hdsConn, err := hds.NewConn(rawConn, hapConn.SharedKey, salt, false)
|
||||
if err != nil {
|
||||
s.log.Error().Err(err).Str("stream", s.stream).Msg("[hksv] hds conn failed")
|
||||
return
|
||||
}
|
||||
|
||||
s.AddConn(hdsConn)
|
||||
defer s.DelConn(hdsConn)
|
||||
|
||||
session := newHKSVSession(s, hapConn, hdsConn)
|
||||
|
||||
s.mu.Lock()
|
||||
s.hksvSession = session
|
||||
s.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
if s.hksvSession == session {
|
||||
s.hksvSession = nil
|
||||
}
|
||||
s.mu.Unlock()
|
||||
session.Close()
|
||||
}()
|
||||
|
||||
s.log.Debug().Str("stream", s.stream).Msg("[hksv] session started")
|
||||
|
||||
if err := session.Run(); err != nil {
|
||||
s.log.Debug().Err(err).Str("stream", s.stream).Msg("[hksv] session ended")
|
||||
}
|
||||
}
|
||||
|
||||
// prepareHKSVConsumer pre-starts a consumer and adds it to the stream.
|
||||
func (s *Server) prepareHKSVConsumer() {
|
||||
consumer := NewHKSVConsumer(s.log)
|
||||
|
||||
if err := s.streams.AddConsumer(s.stream, consumer); err != nil {
|
||||
s.log.Debug().Err(err).Str("stream", s.stream).Msg("[hksv] prepare consumer failed")
|
||||
return
|
||||
}
|
||||
|
||||
s.log.Debug().Str("stream", s.stream).Msg("[hksv] consumer prepared")
|
||||
|
||||
s.mu.Lock()
|
||||
if s.preparedConsumer != nil {
|
||||
old := s.preparedConsumer
|
||||
s.preparedConsumer = nil
|
||||
s.mu.Unlock()
|
||||
s.streams.RemoveConsumer(s.stream, old)
|
||||
_ = old.Stop()
|
||||
s.mu.Lock()
|
||||
}
|
||||
s.preparedConsumer = consumer
|
||||
s.mu.Unlock()
|
||||
|
||||
// Keep alive until used or timeout (60 seconds)
|
||||
select {
|
||||
case <-consumer.Done():
|
||||
// consumer was stopped (used or server closed)
|
||||
case <-time.After(60 * time.Second):
|
||||
s.mu.Lock()
|
||||
if s.preparedConsumer == consumer {
|
||||
s.preparedConsumer = nil
|
||||
s.mu.Unlock()
|
||||
s.streams.RemoveConsumer(s.stream, consumer)
|
||||
_ = consumer.Stop()
|
||||
s.log.Debug().Str("stream", s.stream).Msg("[hksv] prepared consumer expired")
|
||||
} else {
|
||||
s.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) takePreparedConsumer() *HKSVConsumer {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
consumer := s.preparedConsumer
|
||||
s.preparedConsumer = nil
|
||||
return consumer
|
||||
}
|
||||
|
||||
func (s *Server) startMotionDetector() {
|
||||
s.mu.Lock()
|
||||
if s.motionDetector != nil {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
det := NewMotionDetector(s.motionThreshold, s.SetMotionDetected, s.log)
|
||||
s.motionDetector = det
|
||||
s.mu.Unlock()
|
||||
|
||||
s.AddConn(det)
|
||||
|
||||
if err := s.streams.AddConsumer(s.stream, det); err != nil {
|
||||
s.log.Error().Err(err).Str("stream", s.stream).Msg("[hksv] motion detector add consumer failed")
|
||||
s.DelConn(det)
|
||||
s.mu.Lock()
|
||||
s.motionDetector = nil
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
s.log.Debug().Str("stream", s.stream).Msg("[hksv] motion detector started")
|
||||
|
||||
_, _ = det.WriteTo(nil) // blocks until Stop()
|
||||
|
||||
s.streams.RemoveConsumer(s.stream, det)
|
||||
s.DelConn(det)
|
||||
|
||||
s.mu.Lock()
|
||||
if s.motionDetector == det {
|
||||
s.motionDetector = nil
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
s.log.Debug().Str("stream", s.stream).Msg("[hksv] motion detector stopped")
|
||||
}
|
||||
|
||||
func (s *Server) stopMotionDetector() {
|
||||
s.mu.Lock()
|
||||
det := s.motionDetector
|
||||
s.mu.Unlock()
|
||||
if det != nil {
|
||||
_ = det.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) startContinuousMotion() {
|
||||
s.mu.Lock()
|
||||
if s.continuousMotion {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.continuousMotion = true
|
||||
s.mu.Unlock()
|
||||
|
||||
s.log.Debug().Str("stream", s.stream).Msg("[hksv] continuous motion started")
|
||||
|
||||
// delay to allow Home Hub to subscribe to events
|
||||
time.Sleep(5 * time.Second)
|
||||
|
||||
s.SetMotionDetected(true)
|
||||
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
if s.accessory == nil {
|
||||
return
|
||||
}
|
||||
s.SetMotionDetected(true)
|
||||
}
|
||||
}
|
||||
|
||||
// isClosedConnErr checks if the error is a "use of closed network connection" error.
|
||||
// This happens when the remote side (e.g., iPhone) closes the TCP connection.
|
||||
func isClosedConnErr(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(err.Error(), "use of closed network connection")
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
package homekit
|
||||
// Author: Sergei "svk" Krashevich <svk@svk.su>
|
||||
package hksv
|
||||
|
||||
import (
|
||||
"io"
|
||||
@@ -7,11 +8,12 @@ import (
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/h264"
|
||||
"github.com/pion/rtp"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
const (
|
||||
motionWarmupFrames = 30
|
||||
motionThreshold = 2.0
|
||||
defaultThreshold = 2.0
|
||||
motionAlphaFast = 0.1
|
||||
motionAlphaSlow = 0.02
|
||||
motionHoldTime = 30 * time.Second
|
||||
@@ -22,10 +24,13 @@ const (
|
||||
motionTraceFrames = 150
|
||||
)
|
||||
|
||||
type motionDetector struct {
|
||||
// MotionDetector implements core.Consumer for P-frame based motion detection.
|
||||
// It analyzes H.264 P-frame sizes using an EMA baseline and triggers a callback
|
||||
// when the frame size exceeds the baseline by the configured threshold.
|
||||
type MotionDetector struct {
|
||||
core.Connection
|
||||
server *server
|
||||
done chan struct{}
|
||||
done chan struct{}
|
||||
log zerolog.Logger
|
||||
|
||||
// algorithm state (accessed only from Sender goroutine — no mutex needed)
|
||||
threshold float64
|
||||
@@ -49,10 +54,16 @@ type motionDetector struct {
|
||||
|
||||
// for testing: injectable time and callback
|
||||
now func() time.Time
|
||||
onMotion func(bool)
|
||||
OnMotion func(bool) `json:"-"` // callback when motion state changes
|
||||
}
|
||||
|
||||
func newMotionDetector(srv *server) *motionDetector {
|
||||
// NewMotionDetector creates a new motion detector with the given threshold and callback.
|
||||
// If threshold <= 0, the default of 2.0 is used.
|
||||
// onMotion is called when motion state changes (true=detected, false=ended).
|
||||
func NewMotionDetector(threshold float64, onMotion func(bool), log zerolog.Logger) *MotionDetector {
|
||||
if threshold <= 0 {
|
||||
threshold = defaultThreshold
|
||||
}
|
||||
medias := []*core.Media{
|
||||
{
|
||||
Kind: core.KindVideo,
|
||||
@@ -62,26 +73,23 @@ func newMotionDetector(srv *server) *motionDetector {
|
||||
},
|
||||
},
|
||||
}
|
||||
threshold := motionThreshold
|
||||
if srv != nil && srv.motionThreshold > 0 {
|
||||
threshold = srv.motionThreshold
|
||||
}
|
||||
return &motionDetector{
|
||||
return &MotionDetector{
|
||||
Connection: core.Connection{
|
||||
ID: core.NewID(),
|
||||
FormatName: "motion",
|
||||
Protocol: "detect",
|
||||
Medias: medias,
|
||||
},
|
||||
server: srv,
|
||||
threshold: threshold,
|
||||
done: make(chan struct{}),
|
||||
now: time.Now,
|
||||
OnMotion: onMotion,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *motionDetector) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiver) error {
|
||||
log.Debug().Str("stream", m.streamName()).Str("codec", track.Codec.Name).Msg("[homekit] motion: add track")
|
||||
func (m *MotionDetector) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiver) error {
|
||||
m.log.Debug().Str("codec", track.Codec.Name).Msg("[hksv] motion: add track")
|
||||
|
||||
codec := track.Codec.Clone()
|
||||
sender := core.NewSender(media, codec)
|
||||
@@ -101,28 +109,20 @@ func (m *motionDetector) AddTrack(media *core.Media, _ *core.Codec, track *core.
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *motionDetector) streamName() string {
|
||||
if m.server != nil {
|
||||
return m.server.stream
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m *motionDetector) calibrate() {
|
||||
// use default FPS — real FPS calibrated after first periodic check
|
||||
func (m *MotionDetector) calibrate() {
|
||||
m.holdBudget = int(motionHoldTime.Seconds() * motionDefaultFPS)
|
||||
m.cooldownBudget = int(motionCooldown.Seconds() * motionDefaultFPS)
|
||||
m.triggerLevel = int(m.baseline * m.threshold)
|
||||
m.lastFPSCheck = m.now()
|
||||
m.lastFPSFrame = m.frameCount
|
||||
|
||||
log.Debug().Str("stream", m.streamName()).
|
||||
m.log.Debug().
|
||||
Float64("baseline", m.baseline).
|
||||
Int("holdFrames", m.holdBudget).Int("cooldownFrames", m.cooldownBudget).
|
||||
Msg("[homekit] motion: warmup complete")
|
||||
Msg("[hksv] motion: warmup complete")
|
||||
}
|
||||
|
||||
func (m *motionDetector) handlePacket(packet *rtp.Packet) {
|
||||
func (m *MotionDetector) handlePacket(packet *rtp.Packet) {
|
||||
payload := packet.Payload
|
||||
if len(payload) < 5 {
|
||||
return
|
||||
@@ -166,9 +166,9 @@ func (m *motionDetector) handlePacket(packet *rtp.Packet) {
|
||||
if triggered && m.remainingCooldown <= 0 {
|
||||
m.motionActive = true
|
||||
m.remainingHold = m.holdBudget
|
||||
log.Debug().Str("stream", m.streamName()).
|
||||
m.log.Debug().
|
||||
Float64("ratio", float64(size)/m.baseline).
|
||||
Msg("[homekit] motion: ON")
|
||||
Msg("[hksv] motion: ON")
|
||||
m.setMotion(true)
|
||||
}
|
||||
|
||||
@@ -187,7 +187,7 @@ func (m *motionDetector) handlePacket(packet *rtp.Packet) {
|
||||
if m.remainingHold <= 0 {
|
||||
m.motionActive = false
|
||||
m.remainingCooldown = m.cooldownBudget
|
||||
log.Debug().Str("stream", m.streamName()).Msg("[homekit] motion: OFF (hold expired)")
|
||||
m.log.Debug().Msg("[hksv] motion: OFF (hold expired)")
|
||||
m.setMotion(false)
|
||||
}
|
||||
}
|
||||
@@ -207,34 +207,34 @@ func (m *motionDetector) handlePacket(packet *rtp.Packet) {
|
||||
m.lastFPSCheck = now
|
||||
m.lastFPSFrame = m.frameCount
|
||||
|
||||
log.Trace().Str("stream", m.streamName()).
|
||||
m.log.Trace().
|
||||
Float64("baseline", m.baseline).Float64("ratio", float64(size)/m.baseline).
|
||||
Bool("active", m.motionActive).Msg("[homekit] motion: status")
|
||||
Bool("active", m.motionActive).Msg("[hksv] motion: status")
|
||||
}
|
||||
}
|
||||
|
||||
func (m *motionDetector) setMotion(detected bool) {
|
||||
if m.onMotion != nil {
|
||||
m.onMotion(detected)
|
||||
return
|
||||
}
|
||||
if m.server != nil {
|
||||
m.server.SetMotionDetected(detected)
|
||||
func (m *MotionDetector) setMotion(detected bool) {
|
||||
if m.OnMotion != nil {
|
||||
m.OnMotion(detected)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *motionDetector) WriteTo(io.Writer) (int64, error) {
|
||||
func (m *MotionDetector) String() string {
|
||||
return "motion detector"
|
||||
}
|
||||
|
||||
func (m *MotionDetector) WriteTo(io.Writer) (int64, error) {
|
||||
<-m.done
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *motionDetector) Stop() error {
|
||||
func (m *MotionDetector) Stop() error {
|
||||
select {
|
||||
case <-m.done:
|
||||
default:
|
||||
if m.motionActive {
|
||||
m.motionActive = false
|
||||
log.Debug().Str("stream", m.streamName()).Msg("[homekit] motion: OFF (stop)")
|
||||
m.log.Debug().Msg("[hksv] motion: OFF (stop)")
|
||||
m.setMotion(false)
|
||||
}
|
||||
close(m.done)
|
||||
@@ -1,4 +1,5 @@
|
||||
package homekit
|
||||
// Author: Sergei "svk" Krashevich <svk@svk.su>
|
||||
package hksv
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
@@ -7,6 +8,7 @@ import (
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/h264"
|
||||
"github.com/pion/rtp"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// makeAVCC creates a fake AVCC packet with the given NAL type and total size.
|
||||
@@ -51,17 +53,16 @@ func (r *motionRecorder) lastCall() (bool, bool) {
|
||||
return r.calls[len(r.calls)-1], true
|
||||
}
|
||||
|
||||
func newTestDetector() (*motionDetector, *mockClock, *motionRecorder) {
|
||||
det := newMotionDetector(nil)
|
||||
clock := &mockClock{t: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}
|
||||
func newTestDetector() (*MotionDetector, *mockClock, *motionRecorder) {
|
||||
rec := &motionRecorder{}
|
||||
det := NewMotionDetector(0, rec.onMotion, zerolog.Nop())
|
||||
clock := &mockClock{t: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}
|
||||
det.now = clock.now
|
||||
det.onMotion = rec.onMotion
|
||||
return det, clock, rec
|
||||
}
|
||||
|
||||
// warmup feeds the detector with small P-frames to build baseline.
|
||||
func warmup(det *motionDetector, clock *mockClock, size int) {
|
||||
func warmup(det *MotionDetector, clock *mockClock, size int) {
|
||||
for i := 0; i < motionWarmupFrames; i++ {
|
||||
det.handlePacket(makePFrame(size))
|
||||
clock.advance(33 * time.Millisecond) // ~30fps
|
||||
@@ -69,7 +70,7 @@ func warmup(det *motionDetector, clock *mockClock, size int) {
|
||||
}
|
||||
|
||||
// warmupWithBudgets performs warmup then sets test-friendly hold/cooldown budgets.
|
||||
func warmupWithBudgets(det *motionDetector, clock *mockClock, size, hold, cooldown int) {
|
||||
func warmupWithBudgets(det *MotionDetector, clock *mockClock, size, hold, cooldown int) {
|
||||
warmup(det, clock, size)
|
||||
det.holdBudget = hold
|
||||
det.cooldownBudget = cooldown
|
||||
@@ -343,7 +344,7 @@ func TestMotionDetector_StopWithoutMotion(t *testing.T) {
|
||||
warmup(det, clock, 500)
|
||||
|
||||
rec := &motionRecorder{}
|
||||
det.onMotion = rec.onMotion
|
||||
det.OnMotion = rec.onMotion
|
||||
_ = det.Stop()
|
||||
|
||||
if len(rec.calls) != 0 {
|
||||
@@ -500,8 +501,7 @@ func BenchmarkMotionDetector_Warmup(b *testing.B) {
|
||||
pkt := makePFrame(500)
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
det := newMotionDetector(nil)
|
||||
det.onMotion = func(bool) {}
|
||||
det := NewMotionDetector(0, func(bool) {}, zerolog.Nop())
|
||||
det.now = time.Now
|
||||
for j := 0; j < motionWarmupFrames; j++ {
|
||||
det.handlePacket(pkt)
|
||||
@@ -0,0 +1,117 @@
|
||||
// Author: Sergei "svk" Krashevich <svk@svk.su>
|
||||
package hksv
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap/hds"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// hksvSession manages the HDS DataStream connection for HKSV recording
|
||||
type hksvSession struct {
|
||||
server *Server
|
||||
hapConn *hap.Conn
|
||||
hdsConn *hds.Conn
|
||||
session *hds.Session
|
||||
log zerolog.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
consumer *HKSVConsumer
|
||||
}
|
||||
|
||||
func newHKSVSession(srv *Server, hapConn *hap.Conn, hdsConn *hds.Conn) *hksvSession {
|
||||
session := hds.NewSession(hdsConn)
|
||||
hs := &hksvSession{
|
||||
server: srv,
|
||||
hapConn: hapConn,
|
||||
hdsConn: hdsConn,
|
||||
session: session,
|
||||
log: srv.log,
|
||||
}
|
||||
session.OnDataSendOpen = hs.handleOpen
|
||||
session.OnDataSendClose = hs.handleClose
|
||||
return hs
|
||||
}
|
||||
|
||||
func (hs *hksvSession) Run() error {
|
||||
return hs.session.Run()
|
||||
}
|
||||
|
||||
func (hs *hksvSession) Close() {
|
||||
hs.mu.Lock()
|
||||
defer hs.mu.Unlock()
|
||||
if hs.consumer != nil {
|
||||
hs.stopRecording()
|
||||
}
|
||||
_ = hs.session.Close()
|
||||
}
|
||||
|
||||
func (hs *hksvSession) handleOpen(streamID int) error {
|
||||
hs.mu.Lock()
|
||||
defer hs.mu.Unlock()
|
||||
|
||||
hs.log.Debug().Str("stream", hs.server.stream).Int("streamID", streamID).Msg("[hksv] dataSend open")
|
||||
|
||||
if hs.consumer != nil {
|
||||
hs.stopRecording()
|
||||
}
|
||||
|
||||
// Try to use the pre-started consumer from pair-verify
|
||||
consumer := hs.server.takePreparedConsumer()
|
||||
if consumer != nil {
|
||||
hs.log.Debug().Str("stream", hs.server.stream).Msg("[hksv] using prepared consumer")
|
||||
hs.consumer = consumer
|
||||
hs.server.AddConn(consumer)
|
||||
|
||||
// Activate: set the HDS session and send init + start streaming
|
||||
if err := consumer.Activate(hs.session, streamID); err != nil {
|
||||
hs.log.Error().Err(err).Str("stream", hs.server.stream).Msg("[hksv] activate failed")
|
||||
hs.stopRecording()
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fallback: create new consumer (will be slow ~3s)
|
||||
hs.log.Debug().Str("stream", hs.server.stream).Msg("[hksv] no prepared consumer, creating new")
|
||||
consumer = NewHKSVConsumer(hs.log)
|
||||
|
||||
if err := hs.server.streams.AddConsumer(hs.server.stream, consumer); err != nil {
|
||||
hs.log.Error().Err(err).Str("stream", hs.server.stream).Msg("[hksv] add consumer failed")
|
||||
return nil
|
||||
}
|
||||
|
||||
hs.consumer = consumer
|
||||
hs.server.AddConn(consumer)
|
||||
|
||||
go func() {
|
||||
if err := consumer.Activate(hs.session, streamID); err != nil {
|
||||
hs.log.Error().Err(err).Str("stream", hs.server.stream).Msg("[hksv] activate failed")
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (hs *hksvSession) handleClose(streamID int) error {
|
||||
hs.mu.Lock()
|
||||
defer hs.mu.Unlock()
|
||||
|
||||
hs.log.Debug().Str("stream", hs.server.stream).Int("streamID", streamID).Msg("[hksv] dataSend close")
|
||||
|
||||
if hs.consumer != nil {
|
||||
hs.stopRecording()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (hs *hksvSession) stopRecording() {
|
||||
consumer := hs.consumer
|
||||
hs.consumer = nil
|
||||
|
||||
hs.server.streams.RemoveConsumer(hs.server.stream, consumer)
|
||||
_ = consumer.Stop()
|
||||
hs.server.DelConn(consumer)
|
||||
}
|
||||
@@ -351,6 +351,11 @@
|
||||
"description": "Motion detection sensitivity threshold for `detect` mode. Lower values = more sensitive. Uses EMA-based P-frame size analysis.",
|
||||
"type": "number",
|
||||
"default": 2.0
|
||||
},
|
||||
"speaker": {
|
||||
"description": "Include Speaker service for 2-way audio (talk through the camera). Only enable if your camera has a physical speaker.",
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user