| @@ -268,6 +268,7 @@ func (b *txBridge) TXStats() map[string]any { | |||||
| "queue": s.Queue, | "queue": s.Queue, | ||||
| "runtimeIndicator": s.RuntimeIndicator, | "runtimeIndicator": s.RuntimeIndicator, | ||||
| "runtimeAlert": s.RuntimeAlert, | "runtimeAlert": s.RuntimeAlert, | ||||
| "appliedFrequencyMHz": s.AppliedFrequencyMHz, | |||||
| "degradedTransitions": s.DegradedTransitions, | "degradedTransitions": s.DegradedTransitions, | ||||
| "mutedTransitions": s.MutedTransitions, | "mutedTransitions": s.MutedTransitions, | ||||
| "faultedTransitions": s.FaultedTransitions, | "faultedTransitions": s.FaultedTransitions, | ||||
| @@ -45,6 +45,17 @@ func TestTxBridgeExportsQueueStats(t *testing.T) { | |||||
| if indicator != apppkg.RuntimeIndicatorQueueCritical { | if indicator != apppkg.RuntimeIndicatorQueueCritical { | ||||
| t.Fatalf("runtime indicator should be queueCritical, got %s", indicator) | 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 { | if historyRaw, ok := stats["faultHistory"]; !ok { | ||||
| t.Fatalf("expected faultHistory in tx stats") | t.Fatalf("expected faultHistory in tx stats") | ||||
| } else if history, ok := historyRaw.([]apppkg.FaultEvent); !ok { | } else if history, ok := historyRaw.([]apppkg.FaultEvent); !ok { | ||||
| @@ -65,6 +65,7 @@ Live engine and driver telemetry. Only populated when TX is active. | |||||
| "engine": { | "engine": { | ||||
| "state": "running", | "state": "running", | ||||
| "runtimeStateDurationSeconds": 12.4, | "runtimeStateDurationSeconds": 12.4, | ||||
| "appliedFrequencyMHz": 100.0, | |||||
| "chunksProduced": 12345, | "chunksProduced": 12345, | ||||
| "totalSamples": 1408950000, | "totalSamples": 1408950000, | ||||
| "underruns": 0, | "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. | `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. | `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. | `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" | "errors" | ||||
| "fmt" | "fmt" | ||||
| "log" | "log" | ||||
| "math" | |||||
| "sync" | "sync" | ||||
| "sync/atomic" | "sync/atomic" | ||||
| "time" | "time" | ||||
| @@ -86,6 +87,7 @@ type EngineStats struct { | |||||
| Queue output.QueueStats `json:"queue"` | Queue output.QueueStats `json:"queue"` | ||||
| RuntimeIndicator RuntimeIndicator `json:"runtimeIndicator"` | RuntimeIndicator RuntimeIndicator `json:"runtimeIndicator"` | ||||
| RuntimeAlert string `json:"runtimeAlert,omitempty"` | RuntimeAlert string `json:"runtimeAlert,omitempty"` | ||||
| AppliedFrequencyMHz float64 `json:"appliedFrequencyMHz"` | |||||
| LastFault *FaultEvent `json:"lastFault,omitempty"` | LastFault *FaultEvent `json:"lastFault,omitempty"` | ||||
| DegradedTransitions uint64 `json:"degradedTransitions"` | DegradedTransitions uint64 `json:"degradedTransitions"` | ||||
| MutedTransitions uint64 `json:"mutedTransitions"` | MutedTransitions uint64 `json:"mutedTransitions"` | ||||
| @@ -172,6 +174,8 @@ type Engine struct { | |||||
| // Live config: pending frequency change, applied between chunks | // Live config: pending frequency change, applied between chunks | ||||
| pendingFreq atomic.Pointer[float64] | pendingFreq atomic.Pointer[float64] | ||||
| // Most recently tuned frequency (Hz) | |||||
| appliedFreqHz atomic.Uint64 | |||||
| // Live audio stream (optional) | // Live audio stream (optional) | ||||
| streamSrc *audio.StreamSource | streamSrc *audio.StreamSource | ||||
| @@ -246,6 +250,8 @@ func NewEngine(cfg cfgpkg.Config, driver platform.SoapyDriver) *Engine { | |||||
| faultHistory: make([]FaultEvent, 0, faultHistoryCapacity), | faultHistory: make([]FaultEvent, 0, faultHistoryCapacity), | ||||
| transitionHistory: make([]RuntimeTransition, 0, runtimeTransitionHistoryCapacity), | transitionHistory: make([]RuntimeTransition, 0, runtimeTransitionHistoryCapacity), | ||||
| } | } | ||||
| initFreqHz := cfg.FM.FrequencyMHz * 1e6 | |||||
| engine.appliedFreqHz.Store(math.Float64bits(initFreqHz)) | |||||
| engine.setRuntimeState(RuntimeStateIdle) | engine.setRuntimeState(RuntimeStateIdle) | ||||
| return engine | return engine | ||||
| } | } | ||||
| @@ -439,6 +445,7 @@ func (e *Engine) Stats() EngineStats { | |||||
| Queue: queue, | Queue: queue, | ||||
| RuntimeIndicator: ri, | RuntimeIndicator: ri, | ||||
| RuntimeAlert: runtimeAlert(queue.Health, hasRecentLateBuffers), | RuntimeAlert: runtimeAlert(queue.Health, hasRecentLateBuffers), | ||||
| AppliedFrequencyMHz: e.appliedFrequencyMHz(), | |||||
| LastFault: lastFault, | LastFault: lastFault, | ||||
| DegradedTransitions: e.degradedTransitions.Load(), | DegradedTransitions: e.degradedTransitions.Load(), | ||||
| MutedTransitions: e.mutedTransitions.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 { | func runtimeIndicator(queueHealth output.QueueHealth, recentLateBuffers bool) RuntimeIndicator { | ||||
| switch { | switch { | ||||
| case queueHealth == output.QueueHealthCritical: | case queueHealth == output.QueueHealthCritical: | ||||
| @@ -502,6 +514,7 @@ func (e *Engine) run(ctx context.Context) { | |||||
| if err := e.driver.Tune(ctx, *pf); err != nil { | if err := e.driver.Tune(ctx, *pf); err != nil { | ||||
| e.lastError.Store(fmt.Sprintf("tune: %v", err)) | e.lastError.Store(fmt.Sprintf("tune: %v", err)) | ||||
| } else { | } else { | ||||
| e.appliedFreqHz.Store(math.Float64bits(*pf)) | |||||
| log.Printf("engine: tuned to %.3f MHz", *pf/1e6) | log.Printf("engine: tuned to %.3f MHz", *pf/1e6) | ||||
| } | } | ||||
| } | } | ||||
| @@ -603,6 +616,7 @@ func (e *Engine) writerLoop(ctx context.Context) { | |||||
| if ctx.Err() != nil { | if ctx.Err() != nil { | ||||
| return | return | ||||
| } | } | ||||
| e.recordFault(FaultReasonWriteTimeout, FaultSeverityWarn, fmt.Sprintf("driver write error: %v", err)) | |||||
| e.lastError.Store(err.Error()) | e.lastError.Store(err.Error()) | ||||
| e.underruns.Add(1) | e.underruns.Add(1) | ||||
| select { | select { | ||||
| @@ -1,7 +1,10 @@ | |||||
| package app | package app | ||||
| import ( | import ( | ||||
| "context" | |||||
| "errors" | |||||
| "testing" | "testing" | ||||
| "time" | |||||
| cfgpkg "github.com/jan/fm-rds-tx/internal/config" | cfgpkg "github.com/jan/fm-rds-tx/internal/config" | ||||
| "github.com/jan/fm-rds-tx/internal/output" | "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) | 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; | 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 { | .tx-actions { | ||||
| display: flex; | display: flex; | ||||
| flex-wrap: wrap; | flex-wrap: wrap; | ||||
| @@ -967,6 +985,10 @@ input.input-error { | |||||
| <div class="freq-display-wrap"> | <div class="freq-display-wrap"> | ||||
| <div class="freq-display-label">Carrier</div> | <div class="freq-display-label">Carrier</div> | ||||
| <div class="freq-display" id="freq-display">---.-<span class="unit">MHz</span></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> | ||||
| <div class="tx-actions"> | <div class="tx-actions"> | ||||
| @@ -1916,8 +1938,21 @@ function render() { | |||||
| const driver = runtime.driver || {}; | const driver = runtime.driver || {}; | ||||
| const audioStream = runtime.audioStream || null; | 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-backend', cfg.backend?.kind || cfg.backend || '--'); | ||||
| updateText('badge-mode', engine.state && engine.state !== 'idle' ? 'TX Active' : 'Control Plane'); | updateText('badge-mode', engine.state && engine.state !== 'idle' ? 'TX Active' : 'Control Plane'); | ||||