perf(homekit): optimize motion detector with frame-based timing

Replace time.Now() calls in hot path with frame-based timing:
- Pre-compute triggerLevel (integer comparison instead of float division)
- Calibrate hold/cooldown budgets from FPS (default 30fps)
- Periodic FPS recalibration every 150 frames for accuracy
- Active motion path: 47ns → 3.6ns (13x faster)

Update schema.json with detect mode and motion_threshold.
Add threshold tuning guide to README.
This commit is contained in:
Sergey Krashevich
2026-03-05 08:50:27 +03:00
parent a591186da6
commit 78ef8fc064
4 changed files with 204 additions and 110 deletions
+101 -67
View File
@@ -10,7 +10,6 @@ import (
)
// 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
@@ -69,6 +68,13 @@ 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) {
warmup(det, clock, size)
det.holdBudget = hold
det.cooldownBudget = cooldown
}
func TestMotionDetector_NoMotion(t *testing.T) {
det, clock, rec := newTestDetector()
@@ -77,7 +83,6 @@ func TestMotionDetector_NoMotion(t *testing.T) {
// 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 {
@@ -92,7 +97,6 @@ func TestMotionDetector_MotionDetected(t *testing.T) {
// large P-frame triggers motion
det.handlePacket(makePFrame(5000))
clock.advance(33 * time.Millisecond)
last, ok := rec.lastCall()
if !ok || !last {
@@ -103,50 +107,43 @@ func TestMotionDetector_MotionDetected(t *testing.T) {
func TestMotionDetector_HoldTime(t *testing.T) {
det, clock, rec := newTestDetector()
warmup(det, clock, 500)
warmupWithBudgets(det, clock, 500, 30, 5)
// 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)
// send 20 non-triggered frames — still active (< holdBudget=30)
for i := 0; i < 20; i++ {
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)
// send 15 more (total 35 > holdBudget=30) — should turn OFF
for i := 0; i < 15; i++ {
det.handlePacket(makePFrame(500))
}
// now should have OFF
last, _ := rec.lastCall()
if last {
t.Fatal("expected motion OFF after hold time")
t.Fatal("expected motion OFF after hold budget exhausted")
}
}
func TestMotionDetector_Cooldown(t *testing.T) {
det, clock, rec := newTestDetector()
warmup(det, clock, 500)
warmupWithBudgets(det, clock, 500, 30, 5)
// trigger and expire motion
det.handlePacket(makePFrame(5000))
clock.advance(motionHoldTime + time.Second)
// feed enough small frames to hit a hold check interval
for i := 0; i < motionHoldCheckFrames+1; i++ {
for i := 0; i < 30; i++ {
det.handlePacket(makePFrame(500))
}
if len(rec.calls) != 2 || rec.calls[1] != false {
@@ -159,8 +156,12 @@ func TestMotionDetector_Cooldown(t *testing.T) {
t.Fatalf("expected cooldown to block re-trigger, got %v", rec.calls)
}
// advance past cooldown
clock.advance(motionCooldown + time.Second)
// send frames to expire cooldown (blocked trigger consumed 1 decrement)
for i := 0; i < 5; i++ {
det.handlePacket(makePFrame(500))
}
// now re-trigger should work
det.handlePacket(makePFrame(5000))
if len(rec.calls) != 3 || !rec.calls[2] {
t.Fatalf("expected motion ON after cooldown, got %v", rec.calls)
@@ -174,13 +175,12 @@ func TestMotionDetector_SkipsKeyframes(t *testing.T) {
// 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
// verify baseline didn't change
det.handlePacket(makePFrame(500))
if len(rec.calls) != 0 {
t.Fatal("baseline should be unaffected by keyframes")
@@ -209,7 +209,6 @@ func TestMotionDetector_BaselineFreeze(t *testing.T) {
// trigger motion
det.handlePacket(makePFrame(5000))
clock.advance(33 * time.Millisecond)
if len(rec.calls) != 1 || !rec.calls[0] {
t.Fatal("expected motion ON")
@@ -218,7 +217,6 @@ func TestMotionDetector_BaselineFreeze(t *testing.T) {
// 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 {
@@ -228,13 +226,12 @@ func TestMotionDetector_BaselineFreeze(t *testing.T) {
func TestMotionDetector_CustomThreshold(t *testing.T) {
det, clock, rec := newTestDetector()
det.threshold = 1.5 // lower threshold
det.threshold = 1.5
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)
@@ -243,13 +240,12 @@ func TestMotionDetector_CustomThreshold(t *testing.T) {
func TestMotionDetector_CustomThresholdNoFalsePositive(t *testing.T) {
det, clock, rec := newTestDetector()
det.threshold = 3.0 // high threshold
det.threshold = 3.0
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)
@@ -259,35 +255,35 @@ func TestMotionDetector_CustomThresholdNoFalsePositive(t *testing.T) {
func TestMotionDetector_HoldTimeExtended(t *testing.T) {
det, clock, rec := newTestDetector()
warmup(det, clock, 500)
warmupWithBudgets(det, clock, 500, 30, 5)
// 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)
// send 25 non-triggered frames (remainingHold 30→5)
for i := 0; i < 25; i++ {
det.handlePacket(makePFrame(500))
}
// should still be ON — hold timer was reset by second trigger
// re-trigger — remainingHold resets to 30
det.handlePacket(makePFrame(5000))
// send 25 more non-triggered (remainingHold 30→5)
for i := 0; i < 25; i++ {
det.handlePacket(makePFrame(500))
}
// should still be ON
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)
// feed enough frames to guarantee hitting hold check interval
for i := 0; i < motionHoldCheckFrames+1; i++ {
// send 10 more to exhaust hold
for i := 0; i < 10; i++ {
det.handlePacket(makePFrame(500))
}
@@ -302,7 +298,6 @@ func TestMotionDetector_SmallPayloadIgnored(t *testing.T) {
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{}})
@@ -318,10 +313,9 @@ func TestMotionDetector_BaselineAdapts(t *testing.T) {
warmup(det, clock, 500)
baselineAfterWarmup := det.baseline
// feed gradually larger frames (no motion active) — baseline should drift up
// feed gradually larger frames — baseline should drift up
for i := 0; i < 200; i++ {
det.handlePacket(makePFrame(700))
clock.advance(33 * time.Millisecond)
}
if det.baseline <= baselineAfterWarmup {
@@ -338,7 +332,7 @@ func TestMotionDetector_DoubleStopSafe(t *testing.T) {
_ = det.Stop()
_ = det.Stop() // second stop should not panic
if len(rec.calls) != 2 { // ON + OFF from first Stop
if len(rec.calls) != 2 {
t.Fatalf("expected ON+OFF, got %v", rec.calls)
}
}
@@ -348,7 +342,6 @@ func TestMotionDetector_StopWithoutMotion(t *testing.T) {
warmup(det, clock, 500)
// stop without ever triggering motion — should not call onMotion
rec := &motionRecorder{}
det.onMotion = rec.onMotion
_ = det.Stop()
@@ -378,51 +371,94 @@ func TestMotionDetector_StopClearsMotion(t *testing.T) {
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
size := 400 + (i%5)*50
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)
t.Fatalf("baseline should be in 400-600 range, got %f", det.baseline)
}
}
func TestMotionDetector_MultipleCycles(t *testing.T) {
det, clock, rec := newTestDetector()
warmup(det, clock, 500)
warmupWithBudgets(det, clock, 500, 30, 5)
// 3 full motion cycles: ON → hold → OFF → cooldown → ON ...
for cycle := 0; cycle < 3; cycle++ {
det.handlePacket(makePFrame(5000))
clock.advance(motionHoldTime + time.Second)
// feed enough frames to hit hold check interval
for i := 0; i < motionHoldCheckFrames+1; i++ {
det.handlePacket(makePFrame(5000)) // trigger ON
for i := 0; i < 30; i++ { // expire hold
det.handlePacket(makePFrame(500))
}
for i := 0; i < 6; i++ { // expire cooldown
det.handlePacket(makePFrame(500))
}
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
expected := i%2 == 0
if v != expected {
t.Fatalf("call[%d] = %v, expected %v", i, v, expected)
}
}
}
func TestMotionDetector_TriggerLevel(t *testing.T) {
det, clock, _ := newTestDetector()
warmup(det, clock, 500)
expected := int(det.baseline * det.threshold)
if det.triggerLevel != expected {
t.Fatalf("triggerLevel = %d, expected %d", det.triggerLevel, expected)
}
}
func TestMotionDetector_DefaultFPSCalibration(t *testing.T) {
det, clock, _ := newTestDetector()
warmup(det, clock, 500)
// calibrate uses default 30fps
expectedHold := int(motionHoldTime.Seconds() * motionDefaultFPS)
expectedCooldown := int(motionCooldown.Seconds() * motionDefaultFPS)
if det.holdBudget != expectedHold {
t.Fatalf("holdBudget = %d, expected %d", det.holdBudget, expectedHold)
}
if det.cooldownBudget != expectedCooldown {
t.Fatalf("cooldownBudget = %d, expected %d", det.cooldownBudget, expectedCooldown)
}
}
func TestMotionDetector_FPSRecalibration(t *testing.T) {
det, clock, _ := newTestDetector()
warmup(det, clock, 500)
// initial budgets use default 30fps
initialHold := det.holdBudget
// send motionTraceFrames frames with 100ms intervals → FPS=10
for i := 0; i < motionTraceFrames; i++ {
clock.advance(100 * time.Millisecond)
det.handlePacket(makePFrame(500))
}
// after recalibration, holdBudget should reflect ~10fps (±5% due to warmup tail)
expectedHold := int(motionHoldTime.Seconds() * 10.0) // ~300
if det.holdBudget < expectedHold-20 || det.holdBudget > expectedHold+20 {
t.Fatalf("holdBudget after recalibration = %d, expected ~%d (was %d)", det.holdBudget, expectedHold, initialHold)
}
}
func BenchmarkMotionDetector_HandlePacket(b *testing.B) {
det, _, _ := newTestDetector()
warmup(det, &mockClock{t: time.Now()}, 500)
det.now = time.Now
det, clock, _ := newTestDetector()
warmup(det, clock, 500)
pkt := makePFrame(600)
b.ResetTimer()
@@ -432,9 +468,8 @@ func BenchmarkMotionDetector_HandlePacket(b *testing.B) {
}
func BenchmarkMotionDetector_WithKeyframes(b *testing.B) {
det, _, _ := newTestDetector()
warmup(det, &mockClock{t: time.Now()}, 500)
det.now = time.Now
det, clock, _ := newTestDetector()
warmup(det, clock, 500)
pFrame := makePFrame(600)
iFrame := makeIFrame(10000)
@@ -451,7 +486,6 @@ func BenchmarkMotionDetector_WithKeyframes(b *testing.B) {
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))