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:
Sergey Krashevich
2026-03-06 19:58:15 +03:00
parent 593dce6eb9
commit c567831c91
13 changed files with 2135 additions and 1178 deletions
+1
View File
@@ -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)
-431
View File
@@ -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
View File
@@ -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)
-598
View File
@@ -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
}
+636
View File
@@ -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)
+257
View File
@@ -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()
}
}
+65
View File
@@ -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
}
+800
View File
@@ -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)
+117
View File
@@ -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)
}
+5
View File
@@ -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
}
}
}