| @@ -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, | |||
| @@ -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 { | |||
| @@ -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. | |||
| @@ -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 { | |||
| @@ -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, | |||
| }, | |||
| } | |||
| } | |||
| @@ -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 { | |||
| <div class="freq-display-wrap"> | |||
| <div class="freq-display-label">Carrier</div> | |||
| <div class="freq-display" id="freq-display">---.-<span class="unit">MHz</span></div> | |||
| <div class="freq-note" id="freq-note"> | |||
| <span class="freq-note-item" id="freq-applied">Applied: --</span> | |||
| <span class="freq-note-item" id="freq-desired">Desired: --</span> | |||
| </div> | |||
| </div> | |||
| <div class="tx-actions"> | |||
| @@ -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) : '---.-'}<span class="unit">MHz</span>`); | |||
| 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) : '---.-'}<span class="unit">MHz</span>`); | |||
| 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'); | |||