Просмотр исходного кода

feat: surface applied frequency and write faults

tags/v0.9.0
Jan 1 месяц назад
Родитель
Сommit
868bd55c7a
6 измененных файлов: 116 добавлений и 2 удалений
  1. +1
    -0
      cmd/fmrtx/main.go
  2. +11
    -0
      cmd/fmrtx/main_test.go
  3. +5
    -0
      docs/API.md
  4. +14
    -0
      internal/app/engine.go
  5. +48
    -0
      internal/app/fault_test.go
  6. +37
    -2
      internal/control/ui.html

+ 1
- 0
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,


+ 11
- 0
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 {


+ 5
- 0
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.


+ 14
- 0
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 {


+ 48
- 0
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,
},
}
}

+ 37
- 2
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 {
<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');


Загрузка…
Отмена
Сохранить