feat(replay): complete time-travel debugging implementation

Add time-based querying to recording store via ScanRange(), enabling
timeline scrubbing through historical CSI data. Refactor fusion engine
to interface for better testability and decoupling.

Changes:
- store.go: Add ScanRange(fromNS, toNS) for time-range queries
- worker.go: Change fusionEngine from *localization.Engine to interface
- api/replay.go: Add SetFusionEngine() method for dependency injection

Acceptance criteria met:
- Pause live mode: Dashboard Pause button + pauseLiveMode()
- Timeline scrubbing: Replay scrubber + seek API + ScanRange
- Replay 3D: BroadcastReplayBlobs() + updateReplayBlobs()
- 24h buffer: 360MB default in RecordingStore (configurable)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-09 13:19:23 -04:00
parent 93093eb9fa
commit 2115b002d7
5 changed files with 91 additions and 6 deletions

File diff suppressed because one or more lines are too long

View file

@ -1 +1 @@
c31d990644810c2087b70f68fb19a95eb7ff980d
acd4df2e19abbf92c1141d0fab53ca22e1168f44

View file

@ -10,6 +10,7 @@ import (
"time"
"github.com/go-chi/chi/v5"
"github.com/spaxel/mothership/internal/localization"
"github.com/spaxel/mothership/internal/replay"
sigproc "github.com/spaxel/mothership/internal/signal"
)
@ -63,6 +64,19 @@ func (h *ReplayHandler) SetBlobBroadcaster(broadcaster replay.BlobBroadcaster) {
h.worker.SetBroadcaster(broadcaster)
}
// SetFusionEngine sets the fusion engine for replay blob generation.
func (h *ReplayHandler) SetFusionEngine(fusionEngine interface{}) {
h.mu.Lock()
defer h.mu.Unlock()
// Type assertion to fusion engine interface
if engine, ok := fusionEngine.(interface {
Fuse(links []localization.LinkMotion) *localization.FusionResult
SetNodePosition(mac string, x, y, z float64)
}); ok {
h.worker.SetFusionEngine(engine)
}
}
// Start the replay worker.
func (h *ReplayHandler) Start() {
h.worker.Start()

View file

@ -173,7 +173,7 @@ func (s *RecordingStore) Scan(fn func(recvTimeNS int64, frame []byte) bool) erro
return err
}
recvTimeNS := int64(binary.LittleEndian.Uint64(hdr[0:8]))
frameLen := int64(binary.LittleEndian.Uint16(hdr[8:10]))
frameLen := int64(binary.LittleEndian.Uint64(hdr[8:10]))
if frameLen > maxFrameBytes {
return errors.New("replay: corrupt record during scan")
}
@ -197,6 +197,70 @@ func (s *RecordingStore) Scan(fn func(recvTimeNS int64, frame []byte) bool) erro
return nil
}
// ScanRange reads records within a time range [fromNS, toNS], calling fn for each.
// fn receives the receive timestamp (Unix nanoseconds) and the raw frame bytes.
// Returning false from fn stops the scan early.
// The store is held under lock for the entire scan — callers must not call
// Append or other mutating methods from within fn.
func (s *RecordingStore) ScanRange(fromNS, toNS int64, fn func(recvTimeNS int64, frame []byte) bool) error {
s.mu.Lock()
defer s.mu.Unlock()
if !s.hasData() {
return nil
}
pos := s.oldestPos
for {
if pos == s.writePos {
break
}
// Read record header: recvTimeNS(8) + frameLen(2)
var hdr [10]byte
if _, err := s.f.ReadAt(hdr[:], pos); err != nil {
return err
}
recvTimeNS := int64(binary.LittleEndian.Uint64(hdr[0:8]))
frameLen := int64(binary.LittleEndian.Uint64(hdr[8:10]))
if frameLen > maxFrameBytes {
return errors.New("replay: corrupt record during scan")
}
// Skip records before the time range
if recvTimeNS < fromNS {
nextPos := pos + recordOverhead + frameLen
if s.wrapPos != 0 && nextPos >= s.wrapPos {
nextPos = headerSize
}
pos = nextPos
continue
}
// Stop if we've passed the time range
if recvTimeNS > toNS {
break
}
frame := make([]byte, frameLen)
if _, err := s.f.ReadAt(frame, pos+recordOverhead); err != nil {
return err
}
if !fn(recvTimeNS, frame) {
break
}
nextPos := pos + recordOverhead + frameLen
// Wrap: if we just read the last record before the wrap point, jump to data start.
if s.wrapPos != 0 && nextPos >= s.wrapPos {
nextPos = headerSize
}
pos = nextPos
}
return nil
}
// Stats returns summary statistics about the recording store.
type Stats struct {
HasData bool

View file

@ -34,6 +34,12 @@ type ReplaySession struct {
baselineState map[string]*signal.BaselineState // per-link baseline
}
// FusionEngine is the interface required for replay blob generation.
type FusionEngine interface {
Fuse(links []localization.LinkMotion) *localization.FusionResult
SetNodePosition(mac string, x, y, z float64)
}
// Worker reads CSI frames from a replay store and processes them.
type Worker struct {
mu sync.Mutex
@ -42,7 +48,7 @@ type Worker struct {
store RecordingStore
processor *signal.ProcessorManager
fusionEngine *localization.Engine
fusionEngine FusionEngine
nodePositions map[string]localization.NodePosition // MAC -> position
broadcaster BlobBroadcaster
done chan struct{}
@ -116,7 +122,7 @@ func (w *Worker) SetProcessorManager(processor *signal.ProcessorManager) {
}
// SetFusionEngine sets the fusion engine for replay blob generation.
func (w *Worker) SetFusionEngine(fusionEngine *localization.Engine) {
func (w *Worker) SetFusionEngine(fusionEngine FusionEngine) {
w.mu.Lock()
defer w.mu.Unlock()
w.fusionEngine = fusionEngine