mirror of
https://github.com/AlexxIT/go2rtc.git
synced 2026-04-22 15:47:06 +08:00
feat(homekit): implement motion detection with configurable threshold and add motion detector functionality
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user