diff --git a/cmd/fmrtx/main.go b/cmd/fmrtx/main.go index 9bc15ed..05472da 100644 --- a/cmd/fmrtx/main.go +++ b/cmd/fmrtx/main.go @@ -268,6 +268,7 @@ func (b *txBridge) TXStats() map[string]any { "queue": s.Queue, "runtimeIndicator": s.RuntimeIndicator, "runtimeAlert": s.RuntimeAlert, + "appliedFrequencyMHz": s.AppliedFrequencyMHz, "degradedTransitions": s.DegradedTransitions, "mutedTransitions": s.MutedTransitions, "faultedTransitions": s.FaultedTransitions, diff --git a/cmd/fmrtx/main_test.go b/cmd/fmrtx/main_test.go index cb68607..f8d7cbc 100644 --- a/cmd/fmrtx/main_test.go +++ b/cmd/fmrtx/main_test.go @@ -45,6 +45,17 @@ func TestTxBridgeExportsQueueStats(t *testing.T) { if indicator != apppkg.RuntimeIndicatorQueueCritical { t.Fatalf("runtime indicator should be queueCritical, got %s", indicator) } + freqRaw, ok := stats["appliedFrequencyMHz"] + if !ok { + t.Fatalf("missing appliedFrequencyMHz") + } + freq, ok := freqRaw.(float64) + if !ok { + t.Fatalf("appliedFrequencyMHz type mismatch: %T", freqRaw) + } + if freq != cfg.FM.FrequencyMHz { + t.Fatalf("applied frequency mismatch: want %v got %v", cfg.FM.FrequencyMHz, freq) + } if historyRaw, ok := stats["faultHistory"]; !ok { t.Fatalf("expected faultHistory in tx stats") } else if history, ok := historyRaw.([]apppkg.FaultEvent); !ok { diff --git a/docs/API.md b/docs/API.md index c97206e..58d3ac1 100644 --- a/docs/API.md +++ b/docs/API.md @@ -65,6 +65,7 @@ Live engine and driver telemetry. Only populated when TX is active. "engine": { "state": "running", "runtimeStateDurationSeconds": 12.4, + "appliedFrequencyMHz": 100.0, "chunksProduced": 12345, "totalSamples": 1408950000, "underruns": 0, @@ -118,8 +119,12 @@ Live engine and driver telemetry. Only populated when TX is active. `transitionHistory` liefert die jüngsten Übergänge (from/to, severity, timestamp) damit API und UI die Runtime History synchronisieren können. +`engine.appliedFrequencyMHz` meldet die zuletzt tatsächlich getunte Frequenz auf der Hardware, sodass man sie mit dem gewünschten `/config`-Wert vergleichen und ausstehende Live-Updates sofort entdecken kann. + `driver.underrunStreak` reports how many consecutive reads returned silence, and `driver.maxUnderrunStreak` captures the longest such run since the engine started. Together they help differentiate short glitches from persistent underrun storms and can be plotted alongside queue health sparkline telemetry. +`lastFault.reason` kann jetzt auch `writeTimeout` lauten, wenn der Treiber Schreibaufrufe wiederholt verweigert oder blockiert. Die Control-Plane hebt solche Driver-Faults hervor, damit man Blockaden im Writer-Pfad ohne Log-Search sieht. + `controlAudit` mirrors the control plane's HTTP reject counters (405/415/413/400). Whenever the HTTP server rejects a request (method not allowed, unsupported media type, body too large, or unexpected body), the respective counter increments — this lets runtime telemetry spot abusive clients without polluting the runtime state payload. diff --git a/internal/app/engine.go b/internal/app/engine.go index ba824ef..8348b52 100644 --- a/internal/app/engine.go +++ b/internal/app/engine.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "log" + "math" "sync" "sync/atomic" "time" @@ -86,6 +87,7 @@ type EngineStats struct { Queue output.QueueStats `json:"queue"` RuntimeIndicator RuntimeIndicator `json:"runtimeIndicator"` RuntimeAlert string `json:"runtimeAlert,omitempty"` + AppliedFrequencyMHz float64 `json:"appliedFrequencyMHz"` LastFault *FaultEvent `json:"lastFault,omitempty"` DegradedTransitions uint64 `json:"degradedTransitions"` MutedTransitions uint64 `json:"mutedTransitions"` @@ -172,6 +174,8 @@ type Engine struct { // Live config: pending frequency change, applied between chunks pendingFreq atomic.Pointer[float64] + // Most recently tuned frequency (Hz) + appliedFreqHz atomic.Uint64 // Live audio stream (optional) streamSrc *audio.StreamSource @@ -246,6 +250,8 @@ func NewEngine(cfg cfgpkg.Config, driver platform.SoapyDriver) *Engine { faultHistory: make([]FaultEvent, 0, faultHistoryCapacity), transitionHistory: make([]RuntimeTransition, 0, runtimeTransitionHistoryCapacity), } + initFreqHz := cfg.FM.FrequencyMHz * 1e6 + engine.appliedFreqHz.Store(math.Float64bits(initFreqHz)) engine.setRuntimeState(RuntimeStateIdle) return engine } @@ -439,6 +445,7 @@ func (e *Engine) Stats() EngineStats { Queue: queue, RuntimeIndicator: ri, RuntimeAlert: runtimeAlert(queue.Health, hasRecentLateBuffers), + AppliedFrequencyMHz: e.appliedFrequencyMHz(), LastFault: lastFault, DegradedTransitions: e.degradedTransitions.Load(), MutedTransitions: e.mutedTransitions.Load(), @@ -449,6 +456,11 @@ func (e *Engine) Stats() EngineStats { } } +func (e *Engine) appliedFrequencyMHz() float64 { + bits := e.appliedFreqHz.Load() + return math.Float64frombits(bits) / 1e6 +} + func runtimeIndicator(queueHealth output.QueueHealth, recentLateBuffers bool) RuntimeIndicator { switch { case queueHealth == output.QueueHealthCritical: @@ -502,6 +514,7 @@ func (e *Engine) run(ctx context.Context) { if err := e.driver.Tune(ctx, *pf); err != nil { e.lastError.Store(fmt.Sprintf("tune: %v", err)) } else { + e.appliedFreqHz.Store(math.Float64bits(*pf)) log.Printf("engine: tuned to %.3f MHz", *pf/1e6) } } @@ -603,6 +616,7 @@ func (e *Engine) writerLoop(ctx context.Context) { if ctx.Err() != nil { return } + e.recordFault(FaultReasonWriteTimeout, FaultSeverityWarn, fmt.Sprintf("driver write error: %v", err)) e.lastError.Store(err.Error()) e.underruns.Add(1) select { diff --git a/internal/app/fault_test.go b/internal/app/fault_test.go index 4637e25..fa0bb61 100644 --- a/internal/app/fault_test.go +++ b/internal/app/fault_test.go @@ -1,7 +1,10 @@ package app import ( + "context" + "errors" "testing" + "time" cfgpkg "github.com/jan/fm-rds-tx/internal/config" "github.com/jan/fm-rds-tx/internal/output" @@ -70,3 +73,48 @@ func TestEngineRecordsLateBufferFault(t *testing.T) { t.Fatalf("expected warn severity, got %s", last.Severity) } } + +func TestEngineRecordsWriteTimeoutFault(t *testing.T) { + cfg := cfgpkg.Default() + driver := platform.NewSimulatedDriver(&writeErrorBackend{}) + eng := NewEngine(cfg, driver) + eng.SetChunkDuration(10 * time.Millisecond) + + ctx := context.Background() + if err := eng.Start(ctx); err != nil { + t.Fatalf("start: %v", err) + } + time.Sleep(120 * time.Millisecond) + if err := eng.Stop(ctx); err != nil { + t.Fatalf("stop: %v", err) + } + + last := eng.LastFault() + if last == nil { + t.Fatal("expected write timeout fault") + } + if last.Reason != FaultReasonWriteTimeout { + t.Fatalf("expected writeTimeout reason, got %s", last.Reason) + } + if last.Severity != FaultSeverityWarn { + t.Fatalf("expected warn severity, got %s", last.Severity) + } +} + +type writeErrorBackend struct{} + +func (writeErrorBackend) Configure(context.Context, output.BackendConfig) error { return nil } +func (writeErrorBackend) Write(context.Context, *output.CompositeFrame) (int, error) { + return 0, errors.New("write timeout") +} +func (writeErrorBackend) Flush(context.Context) error { return nil } +func (writeErrorBackend) Close(context.Context) error { return nil } +func (writeErrorBackend) Info() output.BackendInfo { + return output.BackendInfo{ + Name: "write-error", + Description: "backend that rejects writes", + Capabilities: output.BackendCapabilities{ + SupportsComposite: true, + }, + } +} diff --git a/internal/control/ui.html b/internal/control/ui.html index 38f1170..5c09e35 100644 --- a/internal/control/ui.html +++ b/internal/control/ui.html @@ -233,6 +233,24 @@ button { user-select: none; } margin-left: 5px; } +.freq-note { + display: flex; + gap: 12px; + margin-top: 6px; + font-size: 11px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 1px; +} +.freq-note-item { + display: inline-flex; + align-items: center; + gap: 4px; +} +.freq-note.mismatch .freq-note-item { + color: var(--amber); +} + .tx-actions { display: flex; flex-wrap: wrap; @@ -967,6 +985,10 @@ input.input-error {
Carrier
---.-MHz
+
+ Applied: -- + Desired: -- +
@@ -1916,8 +1938,21 @@ function render() { const driver = runtime.driver || {}; const audioStream = runtime.audioStream || null; - const freq = effectiveValue('frequencyMHz') ?? cfg.fm?.frequencyMHz; - updateHTML('freq-display', `${typeof freq === 'number' ? freq.toFixed(1) : '---.-'}MHz`); + const appliedRaw = engine.appliedFrequencyMHz; + const appliedFreq = Number.isFinite(Number(appliedRaw)) ? Number(appliedRaw) : null; + const desiredRaw = cfg.fm?.frequencyMHz; + const desiredFreq = Number.isFinite(Number(desiredRaw)) ? Number(desiredRaw) : null; + const displayFreq = appliedFreq ?? effectiveValue('frequencyMHz') ?? desiredFreq; + updateHTML('freq-display', `${typeof displayFreq === 'number' ? displayFreq.toFixed(1) : '---.-'}MHz`); + const appliedLabel = appliedFreq != null ? `Applied ${appliedFreq.toFixed(1)} MHz` : 'Applied --'; + const desiredLabel = desiredFreq != null ? `Desired ${desiredFreq.toFixed(1)} MHz` : 'Desired --'; + updateText('freq-applied', appliedLabel); + updateText('freq-desired', desiredLabel); + const noteEl = $('freq-note'); + if (noteEl) { + const mismatch = appliedFreq != null && desiredFreq != null && !nearlyEqual(appliedFreq, desiredFreq, 0.001); + noteEl.classList.toggle('mismatch', mismatch); + } updateText('badge-backend', cfg.backend?.kind || cfg.backend || '--'); updateText('badge-mode', engine.state && engine.state !== 'idle' ? 'TX Active' : 'Control Plane');