diff --git a/internal/homekit/README.md b/internal/homekit/README.md index 638bfa9f..390edef5 100644 --- a/internal/homekit/README.md +++ b/internal/homekit/README.md @@ -132,8 +132,21 @@ homekit: **Motion modes:** - `continuous` — MotionDetected is always true; Home Hub continuously receives video and decides what to save. Simplest setup, recommended for most cameras. +- `detect` — automatic motion detection by analyzing H264 P-frame sizes. No external dependencies or CPU-heavy decoding. Works with any H264 source and resolution. Compares each P-frame size against an adaptive baseline using EMA (exponential moving average). When a P-frame exceeds the threshold ratio, motion is triggered with a 30s hold time and 5s cooldown. - `api` — motion is triggered externally via HTTP API. Use this with Frigate, ONVIF events, or any other motion detection system. +**Motion detect config:** + +```yaml +homekit: + outdoor: + hksv: true + motion: detect + motion_threshold: 2.0 # P-frame size / baseline ratio to trigger motion (default: 2.0) +``` + +The `motion_threshold` controls sensitivity. Lower values = more sensitive. Typical values: 1.5 (high sensitivity) to 3.0 (low sensitivity). Default 2.0 works well for most real cameras with static scenes. + **Motion API:** ```bash diff --git a/internal/homekit/homekit.go b/internal/homekit/homekit.go index 275d78be..483edbb2 100644 --- a/internal/homekit/homekit.go +++ b/internal/homekit/homekit.go @@ -26,8 +26,9 @@ func Init() { DevicePrivate string `yaml:"device_private"` CategoryID string `yaml:"category_id"` Pairings []string `yaml:"pairings"` - HKSV bool `yaml:"hksv"` - Motion string `yaml:"motion"` + HKSV bool `yaml:"hksv"` + Motion string `yaml:"motion"` + MotionThreshold float64 `yaml:"motion_threshold"` } `yaml:"homekit"` } app.LoadConfig(&cfg) @@ -109,6 +110,11 @@ func Init() { } 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 { diff --git a/internal/homekit/motion.go b/internal/homekit/motion.go new file mode 100644 index 00000000..ed43d2c1 --- /dev/null +++ b/internal/homekit/motion.go @@ -0,0 +1,196 @@ +package homekit + +import ( + "io" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/h264" + "github.com/pion/rtp" +) + +const ( + motionWarmupFrames = 30 + motionThreshold = 2.0 + motionAlphaFast = 0.1 + motionAlphaSlow = 0.02 + motionHoldTime = 30 * time.Second + motionCooldown = 5 * time.Second +) + +type motionDetector struct { + core.Connection + server *server + done chan struct{} + + // algorithm state (accessed only from Sender goroutine — no mutex needed) + threshold float64 + baseline float64 + initialized bool + frameCount int + + // motion state + motionActive bool + lastMotion time.Time + lastOff time.Time + lastTrace time.Time + + // for testing: injectable time and callback + now func() time.Time + onMotion func(bool) +} + +func newMotionDetector(srv *server) *motionDetector { + medias := []*core.Media{ + { + Kind: core.KindVideo, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{ + {Name: core.CodecH264}, + }, + }, + } + threshold := motionThreshold + if srv != nil && srv.motionThreshold > 0 { + threshold = srv.motionThreshold + } + 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, + } +} + +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") + + codec := track.Codec.Clone() + sender := core.NewSender(media, codec) + + sender.Handler = func(packet *rtp.Packet) { + m.handlePacket(packet) + } + + if track.Codec.IsRTP() { + sender.Handler = h264.RTPDepay(track.Codec, sender.Handler) + } else { + sender.Handler = h264.RepairAVCC(track.Codec, sender.Handler) + } + + sender.HandleRTP(track) + m.Senders = append(m.Senders, sender) + return nil +} + +func (m *motionDetector) streamName() string { + if m.server != nil { + return m.server.stream + } + return "" +} + +func (m *motionDetector) handlePacket(packet *rtp.Packet) { + payload := packet.Payload + if len(payload) < 5 { + return + } + + // skip keyframes — always large, not informative for motion + if h264.IsKeyframe(payload) { + return + } + + size := float64(len(payload)) + m.frameCount++ + + if m.frameCount <= motionWarmupFrames { + // warmup: build baseline with fast EMA + if !m.initialized { + m.baseline = size + m.initialized = true + } else { + m.baseline += motionAlphaFast * (size - m.baseline) + } + if m.frameCount == motionWarmupFrames { + log.Debug().Str("stream", m.streamName()).Float64("baseline", m.baseline).Msg("[homekit] motion: warmup complete") + } + return + } + + now := m.now() + + if m.baseline > 0 { + ratio := size / m.baseline + + // periodic trace: once per 5 seconds + if now.Sub(m.lastTrace) >= 5*time.Second { + m.lastTrace = now + log.Trace().Str("stream", m.streamName()). + Float64("baseline", m.baseline).Float64("ratio", ratio). + Bool("active", m.motionActive).Msg("[homekit] motion: status") + } + + if ratio > m.threshold { + m.lastMotion = now + if !m.motionActive { + // check cooldown + if now.Sub(m.lastOff) >= motionCooldown { + m.motionActive = true + log.Debug().Str("stream", m.streamName()).Float64("ratio", ratio).Msg("[homekit] motion: ON") + m.setMotion(true) + } else { + log.Debug().Str("stream", m.streamName()).Float64("ratio", ratio).Dur("cooldown_left", motionCooldown-now.Sub(m.lastOff)).Msg("[homekit] motion: blocked by cooldown") + } + } + } + } + + // update baseline only when no active motion + if !m.motionActive { + m.baseline += motionAlphaSlow * (size - m.baseline) + } + + // check hold time expiry + if m.motionActive && now.Sub(m.lastMotion) >= motionHoldTime { + m.motionActive = false + m.lastOff = now + log.Debug().Str("stream", m.streamName()).Msg("[homekit] motion: OFF (hold expired)") + m.setMotion(false) + } +} + +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) WriteTo(io.Writer) (int64, error) { + <-m.done + return 0, nil +} + +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.setMotion(false) + } + close(m.done) + } + return m.Connection.Stop() +} diff --git a/internal/homekit/motion_test.go b/internal/homekit/motion_test.go new file mode 100644 index 00000000..0fcd02f6 --- /dev/null +++ b/internal/homekit/motion_test.go @@ -0,0 +1,467 @@ +package homekit + +import ( + "encoding/binary" + "testing" + "time" + + "github.com/AlexxIT/go2rtc/pkg/h264" + "github.com/pion/rtp" +) + +// makeAVCC creates a fake AVCC packet with the given NAL type and total size. +// Format: 4-byte big-endian length + NAL header + padding. +func makeAVCC(nalType byte, totalSize int) []byte { + if totalSize < 5 { + totalSize = 5 + } + b := make([]byte, totalSize) + binary.BigEndian.PutUint32(b[:4], uint32(totalSize-4)) + b[4] = nalType + return b +} + +func makePFrame(size int) *rtp.Packet { + return &rtp.Packet{Payload: makeAVCC(h264.NALUTypePFrame, size)} +} + +func makeIFrame(size int) *rtp.Packet { + return &rtp.Packet{Payload: makeAVCC(h264.NALUTypeIFrame, size)} +} + +type mockClock struct { + t time.Time +} + +func (c *mockClock) now() time.Time { return c.t } + +func (c *mockClock) advance(d time.Duration) { c.t = c.t.Add(d) } + +type motionRecorder struct { + calls []bool +} + +func (r *motionRecorder) onMotion(detected bool) { + r.calls = append(r.calls, detected) +} + +func (r *motionRecorder) lastCall() (bool, bool) { + if len(r.calls) == 0 { + return false, false + } + 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)} + rec := &motionRecorder{} + 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) { + for i := 0; i < motionWarmupFrames; i++ { + det.handlePacket(makePFrame(size)) + clock.advance(33 * time.Millisecond) // ~30fps + } +} + +func TestMotionDetector_NoMotion(t *testing.T) { + det, clock, rec := newTestDetector() + + warmup(det, clock, 500) + + // feed same-size P-frames — no motion + for i := 0; i < 100; i++ { + det.handlePacket(makePFrame(500)) + clock.advance(33 * time.Millisecond) + } + + if len(rec.calls) != 0 { + t.Fatalf("expected no motion calls, got %d: %v", len(rec.calls), rec.calls) + } +} + +func TestMotionDetector_MotionDetected(t *testing.T) { + det, clock, rec := newTestDetector() + + warmup(det, clock, 500) + + // large P-frame triggers motion + det.handlePacket(makePFrame(5000)) + clock.advance(33 * time.Millisecond) + + last, ok := rec.lastCall() + if !ok || !last { + t.Fatal("expected motion detected") + } +} + +func TestMotionDetector_HoldTime(t *testing.T) { + det, clock, rec := newTestDetector() + + warmup(det, clock, 500) + + // trigger motion + det.handlePacket(makePFrame(5000)) + clock.advance(33 * time.Millisecond) + + if len(rec.calls) != 1 || !rec.calls[0] { + t.Fatal("expected motion ON") + } + + // advance 20s with small frames — still active (< holdTime) + for i := 0; i < 60; i++ { + clock.advance(333 * time.Millisecond) + det.handlePacket(makePFrame(500)) + } + + // no OFF call yet + if len(rec.calls) != 1 { + t.Fatalf("expected only ON call during hold, got %v", rec.calls) + } + + // advance past holdTime (30s total) + for i := 0; i < 40; i++ { + clock.advance(333 * time.Millisecond) + det.handlePacket(makePFrame(500)) + } + + // now should have OFF + last, _ := rec.lastCall() + if last { + t.Fatal("expected motion OFF after hold time") + } +} + +func TestMotionDetector_Cooldown(t *testing.T) { + det, clock, rec := newTestDetector() + + warmup(det, clock, 500) + + // trigger and expire motion + det.handlePacket(makePFrame(5000)) + clock.advance(motionHoldTime + time.Second) + det.handlePacket(makePFrame(500)) // trigger hold time check + if len(rec.calls) != 2 || rec.calls[1] != false { + t.Fatalf("expected ON then OFF, got %v", rec.calls) + } + + // try to trigger again immediately — should be blocked by cooldown + det.handlePacket(makePFrame(5000)) + if len(rec.calls) != 2 { + t.Fatalf("expected cooldown to block re-trigger, got %v", rec.calls) + } + + // advance past cooldown + clock.advance(motionCooldown + time.Second) + det.handlePacket(makePFrame(5000)) + if len(rec.calls) != 3 || !rec.calls[2] { + t.Fatalf("expected motion ON after cooldown, got %v", rec.calls) + } +} + +func TestMotionDetector_SkipsKeyframes(t *testing.T) { + det, clock, rec := newTestDetector() + + warmup(det, clock, 500) + + // huge keyframe should not trigger motion + det.handlePacket(makeIFrame(50000)) + clock.advance(33 * time.Millisecond) + + if len(rec.calls) != 0 { + t.Fatal("keyframes should not trigger motion") + } + + // verify baseline didn't change by checking small P-frame doesn't trigger + det.handlePacket(makePFrame(500)) + if len(rec.calls) != 0 { + t.Fatal("baseline should be unaffected by keyframes") + } +} + +func TestMotionDetector_Warmup(t *testing.T) { + det, clock, rec := newTestDetector() + + // during warmup, even large frames should not trigger + for i := 0; i < motionWarmupFrames; i++ { + det.handlePacket(makePFrame(5000)) + clock.advance(33 * time.Millisecond) + } + + if len(rec.calls) != 0 { + t.Fatal("warmup should not trigger motion") + } +} + +func TestMotionDetector_BaselineFreeze(t *testing.T) { + det, clock, rec := newTestDetector() + + warmup(det, clock, 500) + baselineBefore := det.baseline + + // trigger motion + det.handlePacket(makePFrame(5000)) + clock.advance(33 * time.Millisecond) + + if len(rec.calls) != 1 || !rec.calls[0] { + t.Fatal("expected motion ON") + } + + // feed large frames during motion — baseline should not change + for i := 0; i < 50; i++ { + det.handlePacket(makePFrame(5000)) + clock.advance(100 * time.Millisecond) + } + + if det.baseline != baselineBefore { + t.Fatalf("baseline changed during motion: %f -> %f", baselineBefore, det.baseline) + } +} + +func TestMotionDetector_CustomThreshold(t *testing.T) { + det, clock, rec := newTestDetector() + det.threshold = 1.5 // lower threshold + + warmup(det, clock, 500) + + // 1.6x — below default 2.0 but above custom 1.5 + det.handlePacket(makePFrame(800)) + clock.advance(33 * time.Millisecond) + + if len(rec.calls) != 1 || !rec.calls[0] { + t.Fatalf("expected motion ON with custom threshold 1.5, got %v", rec.calls) + } +} + +func TestMotionDetector_CustomThresholdNoFalsePositive(t *testing.T) { + det, clock, rec := newTestDetector() + det.threshold = 3.0 // high threshold + + warmup(det, clock, 500) + + // 2.5x — above default 2.0 but below custom 3.0 + det.handlePacket(makePFrame(1250)) + clock.advance(33 * time.Millisecond) + + if len(rec.calls) != 0 { + t.Fatalf("expected no motion with high threshold 3.0, got %v", rec.calls) + } +} + +func TestMotionDetector_HoldTimeExtended(t *testing.T) { + det, clock, rec := newTestDetector() + + warmup(det, clock, 500) + + // trigger motion + det.handlePacket(makePFrame(5000)) + clock.advance(33 * time.Millisecond) + + if len(rec.calls) != 1 || !rec.calls[0] { + t.Fatal("expected motion ON") + } + + // advance 25s, then re-trigger — hold timer resets + clock.advance(25 * time.Second) + det.handlePacket(makePFrame(5000)) + + // advance another 25s (50s from first trigger, but only 25s from last) + for i := 0; i < 75; i++ { + clock.advance(333 * time.Millisecond) + det.handlePacket(makePFrame(500)) + } + + // should still be ON — hold timer was reset by second trigger + if len(rec.calls) != 1 { + t.Fatalf("expected hold time to be extended by re-trigger, got %v", rec.calls) + } + + // advance past hold time from last trigger + clock.advance(6 * time.Second) + det.handlePacket(makePFrame(500)) + + last, _ := rec.lastCall() + if last { + t.Fatal("expected motion OFF after extended hold expired") + } +} + +func TestMotionDetector_SmallPayloadIgnored(t *testing.T) { + det, clock, rec := newTestDetector() + + warmup(det, clock, 500) + + // payloads < 5 bytes should be silently ignored + det.handlePacket(&rtp.Packet{Payload: []byte{1, 2, 3, 4}}) + det.handlePacket(&rtp.Packet{Payload: nil}) + det.handlePacket(&rtp.Packet{Payload: []byte{}}) + + if len(rec.calls) != 0 { + t.Fatalf("small payloads should be ignored, got %v", rec.calls) + } +} + +func TestMotionDetector_BaselineAdapts(t *testing.T) { + det, clock, _ := newTestDetector() + + warmup(det, clock, 500) + baselineAfterWarmup := det.baseline + + // feed gradually larger frames (no motion active) — baseline should drift up + for i := 0; i < 200; i++ { + det.handlePacket(makePFrame(700)) + clock.advance(33 * time.Millisecond) + } + + if det.baseline <= baselineAfterWarmup { + t.Fatalf("baseline should adapt upward: before=%f after=%f", baselineAfterWarmup, det.baseline) + } +} + +func TestMotionDetector_DoubleStopSafe(t *testing.T) { + det, clock, rec := newTestDetector() + + warmup(det, clock, 500) + det.handlePacket(makePFrame(5000)) + + _ = det.Stop() + _ = det.Stop() // second stop should not panic + + if len(rec.calls) != 2 { // ON + OFF from first Stop + t.Fatalf("expected ON+OFF, got %v", rec.calls) + } +} + +func TestMotionDetector_StopWithoutMotion(t *testing.T) { + det, clock, _ := newTestDetector() + + warmup(det, clock, 500) + + // stop without ever triggering motion — should not call onMotion + rec := &motionRecorder{} + det.onMotion = rec.onMotion + _ = det.Stop() + + if len(rec.calls) != 0 { + t.Fatalf("stop without motion should not call onMotion, got %v", rec.calls) + } +} + +func TestMotionDetector_StopClearsMotion(t *testing.T) { + det, clock, rec := newTestDetector() + + warmup(det, clock, 500) + + det.handlePacket(makePFrame(5000)) + if len(rec.calls) != 1 || !rec.calls[0] { + t.Fatal("expected motion ON") + } + + _ = det.Stop() + + if len(rec.calls) != 2 || rec.calls[1] != false { + t.Fatalf("expected Stop to clear motion, got %v", rec.calls) + } +} + +func TestMotionDetector_WarmupBaseline(t *testing.T) { + det, clock, _ := newTestDetector() + + // feed varying sizes during warmup + for i := 0; i < motionWarmupFrames; i++ { + size := 400 + (i%5)*50 // 400-600 range + det.handlePacket(makePFrame(size)) + clock.advance(33 * time.Millisecond) + } + + // baseline should be a reasonable average, not zero or the last value + if det.baseline < 400 || det.baseline > 600 { + t.Fatalf("baseline should be in 400-600 range after varied warmup, got %f", det.baseline) + } +} + +func TestMotionDetector_MultipleCycles(t *testing.T) { + det, clock, rec := newTestDetector() + + warmup(det, clock, 500) + + // 3 full motion cycles: ON → hold → OFF → cooldown → ON ... + for cycle := 0; cycle < 3; cycle++ { + det.handlePacket(makePFrame(5000)) + clock.advance(motionHoldTime + time.Second) + det.handlePacket(makePFrame(500)) // trigger OFF + clock.advance(motionCooldown + time.Second) + } + + // expect 3 ON + 3 OFF = 6 calls + if len(rec.calls) != 6 { + t.Fatalf("expected 6 calls (3 cycles), got %d: %v", len(rec.calls), rec.calls) + } + for i, v := range rec.calls { + expected := i%2 == 0 // ON at 0,2,4; OFF at 1,3,5 + if v != expected { + t.Fatalf("call[%d] = %v, expected %v", i, v, expected) + } + } +} + +func BenchmarkMotionDetector_HandlePacket(b *testing.B) { + det, _, _ := newTestDetector() + warmup(det, &mockClock{t: time.Now()}, 500) + det.now = time.Now + + pkt := makePFrame(600) + b.ResetTimer() + for i := 0; i < b.N; i++ { + det.handlePacket(pkt) + } +} + +func BenchmarkMotionDetector_WithKeyframes(b *testing.B) { + det, _, _ := newTestDetector() + warmup(det, &mockClock{t: time.Now()}, 500) + det.now = time.Now + + pFrame := makePFrame(600) + iFrame := makeIFrame(10000) + b.ResetTimer() + for i := 0; i < b.N; i++ { + if i%30 == 0 { + det.handlePacket(iFrame) + } else { + det.handlePacket(pFrame) + } + } +} + +func BenchmarkMotionDetector_MotionActive(b *testing.B) { + det, clock, _ := newTestDetector() + warmup(det, clock, 500) + det.now = time.Now + + // trigger motion and keep it active + det.handlePacket(makePFrame(5000)) + pkt := makePFrame(5000) + b.ResetTimer() + for i := 0; i < b.N; i++ { + det.handlePacket(pkt) + } +} + +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.now = time.Now + for j := 0; j < motionWarmupFrames; j++ { + det.handlePacket(pkt) + } + } +} diff --git a/internal/homekit/server.go b/internal/homekit/server.go index 29987395..44c92578 100644 --- a/internal/homekit/server.go +++ b/internal/homekit/server.go @@ -45,8 +45,10 @@ type server struct { stream string // stream name from YAML // HKSV fields - motionMode string // "api", "continuous" - hksvSession *hksvSession + motionMode string // "api", "continuous", "detect" + motionThreshold float64 // ratio threshold for "detect" mode (default 2.0) + motionDetector *motionDetector + hksvSession *hksvSession } func (s *server) MarshalJSON() ([]byte, error) { @@ -111,6 +113,11 @@ func (s *server) Handle(w http.ResponseWriter, r *http.Request) { s.AddConn(controller) defer s.DelConn(controller) + // start motion detector on first Home Hub connection + if s.motionMode == "detect" { + go s.startMotionDetector() + } + var handler homekit.HandlerFunc switch { @@ -387,11 +394,14 @@ func (s *server) SetCharacteristic(conn net.Conn, aid uint8, iid uint64, value a go s.acceptHDS(hapConn, ln, combinedSalt) case camera.TypeSelectedCameraRecordingConfiguration: - log.Debug().Str("stream", s.stream).Msg("[homekit] HKSV selected recording config") + log.Debug().Str("stream", s.stream).Str("motion", s.motionMode).Msg("[homekit] HKSV selected recording config") char.Value = value - if s.motionMode == "continuous" { + switch s.motionMode { + case "continuous": go s.startContinuousMotion() + case "detect": + go s.startMotionDetector() } default: @@ -453,6 +463,53 @@ func (s *server) TriggerDoorbell() { 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.SetMotionDetected(true)