| @@ -10,6 +10,7 @@ import ( | |||||
| "os/signal" | "os/signal" | ||||
| "path/filepath" | "path/filepath" | ||||
| "strconv" | "strconv" | ||||
| "strings" | |||||
| "sync" | "sync" | ||||
| "syscall" | "syscall" | ||||
| "time" | "time" | ||||
| @@ -97,6 +98,36 @@ type sourceManager struct { | |||||
| newSource func(cfg config.Config) (sdr.Source, error) | newSource func(cfg config.Config) (sdr.Source, error) | ||||
| } | } | ||||
| func (m *sourceManager) Restart(cfg config.Config) error { | |||||
| m.mu.Lock() | |||||
| defer m.mu.Unlock() | |||||
| old := m.src | |||||
| _ = old.Stop() | |||||
| next, err := m.newSource(cfg) | |||||
| if err != nil { | |||||
| _ = old.Start() | |||||
| m.src = old | |||||
| return err | |||||
| } | |||||
| if err := next.Start(); err != nil { | |||||
| _ = next.Stop() | |||||
| _ = old.Start() | |||||
| m.src = old | |||||
| return err | |||||
| } | |||||
| m.src = next | |||||
| return nil | |||||
| } | |||||
| func (m *sourceManager) Stats() sdr.SourceStats { | |||||
| m.mu.RLock() | |||||
| defer m.mu.RUnlock() | |||||
| if sp, ok := m.src.(sdr.StatsProvider); ok { | |||||
| return sp.Stats() | |||||
| } | |||||
| return sdr.SourceStats{} | |||||
| } | |||||
| func newSourceManager(src sdr.Source, newSource func(cfg config.Config) (sdr.Source, error)) *sourceManager { | func newSourceManager(src sdr.Source, newSource func(cfg config.Config) (sdr.Source, error)) *sourceManager { | ||||
| return &sourceManager{src: src, newSource: newSource} | return &sourceManager{src: src, newSource: newSource} | ||||
| } | } | ||||
| @@ -463,6 +494,11 @@ func runDSP(ctx context.Context, src sdr.Source, cfg config.Config, det *detecto | |||||
| iq, err := src.ReadIQ(cfg.FFTSize) | iq, err := src.ReadIQ(cfg.FFTSize) | ||||
| if err != nil { | if err != nil { | ||||
| log.Printf("read IQ: %v", err) | log.Printf("read IQ: %v", err) | ||||
| if strings.Contains(err.Error(), "timeout") { | |||||
| if err := src.Restart(cfg); err != nil { | |||||
| log.Printf("restart failed: %v", err) | |||||
| } | |||||
| } | |||||
| continue | continue | ||||
| } | } | ||||
| if !gotSamples { | if !gotSamples { | ||||
| @@ -13,9 +13,10 @@ type ConfigurableSource interface { | |||||
| } | } | ||||
| type SourceStats struct { | type SourceStats struct { | ||||
| BufferSamples int `json:"buffer_samples"` | |||||
| Dropped uint64 `json:"dropped"` | |||||
| Resets uint64 `json:"resets"` | |||||
| BufferSamples int `json:"buffer_samples"` | |||||
| Dropped uint64 `json:"dropped"` | |||||
| Resets uint64 `json:"resets"` | |||||
| LastSampleAgoMs int64 `json:"last_sample_ago_ms"` | |||||
| } | } | ||||
| type StatsProvider interface { | type StatsProvider interface { | ||||
| @@ -97,6 +97,7 @@ type Source struct { | |||||
| bwKHz int | bwKHz int | ||||
| dropped uint64 | dropped uint64 | ||||
| resets uint64 | resets uint64 | ||||
| lastSample time.Time | |||||
| cond *sync.Cond | cond *sync.Cond | ||||
| } | } | ||||
| @@ -284,7 +285,11 @@ func (s *Source) appendRing(samples []complex64) { | |||||
| func (s *Source) Stats() sdr.SourceStats { | func (s *Source) Stats() sdr.SourceStats { | ||||
| s.mu.Lock() | s.mu.Lock() | ||||
| defer s.mu.Unlock() | defer s.mu.Unlock() | ||||
| return sdr.SourceStats{BufferSamples: s.size, Dropped: s.dropped, Resets: s.resets} | |||||
| ago := int64(-1) | |||||
| if !s.lastSample.IsZero() { | |||||
| ago = time.Since(s.lastSample).Milliseconds() | |||||
| } | |||||
| return sdr.SourceStats{BufferSamples: s.size, Dropped: s.dropped, Resets: s.resets, LastSampleAgoMs: ago} | |||||
| } | } | ||||
| func (s *Source) Flush() { | func (s *Source) Flush() { | ||||
| @@ -383,6 +388,7 @@ func goStreamCallback(xi *C.short, xq *C.short, numSamples C.uint, reset C.uint, | |||||
| iq[i] = complex(re, im) | iq[i] = complex(re, im) | ||||
| } | } | ||||
| src.mu.Lock() | src.mu.Lock() | ||||
| src.lastSample = time.Now() | |||||
| src.appendRing(iq) | src.appendRing(iq) | ||||
| src.mu.Unlock() | src.mu.Unlock() | ||||
| } | } | ||||
| @@ -353,7 +353,8 @@ function renderSpectrum() { | |||||
| const binHz = sample_rate / n; | const binHz = sample_rate / n; | ||||
| const gpuState = gpuInfo.active ? 'GPU:ON' : (gpuInfo.available ? 'GPU:OFF' : 'GPU:N/A'); | const gpuState = gpuInfo.active ? 'GPU:ON' : (gpuInfo.available ? 'GPU:OFF' : 'GPU:N/A'); | ||||
| metaEl.textContent = `Center ${(center_hz/1e6).toFixed(3)} MHz | Span ${(span/1e6).toFixed(3)} MHz | Res ${binHz.toFixed(1)} Hz/bin | Buf ${stats.buffer_samples} Drop ${stats.dropped} Reset ${stats.resets} | ${gpuState}`; | |||||
| const lastAge = stats.last_sample_ago_ms >= 0 ? `${stats.last_sample_ago_ms}ms` : 'n/a'; | |||||
| metaEl.textContent = `Center ${(center_hz/1e6).toFixed(3)} MHz | Span ${(span/1e6).toFixed(3)} MHz | Res ${binHz.toFixed(1)} Hz/bin | Buf ${stats.buffer_samples} Drop ${stats.dropped} Reset ${stats.resets} Last ${lastAge} | ${gpuState}`; | |||||
| } | } | ||||
| function renderWaterfall() { | function renderWaterfall() { | ||||