feat(homekit): implement motion detection with configurable threshold and add motion detector functionality
This commit is contained in:
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user