Explorar el Código

Expose runtime indicator in status

tags/v0.9.0
Jan Svabenik hace 1 mes
padre
commit
d39d59f1ed
Se han modificado 3 ficheros con 110 adiciones y 32 borrados
  1. +2
    -0
      docs/pro-runtime-hardening-workboard.md
  2. +57
    -19
      internal/control/control.go
  3. +51
    -13
      internal/control/control_test.go

+ 2
- 0
docs/pro-runtime-hardening-workboard.md Ver fichero

@@ -249,6 +249,7 @@ Generator/Upsampler und Hardwarewriter werden als getrennte Stufen mit kleinem,
| 2026-04-05 | FrameQueue mit Engine-Integration | Queue lebt nach dem Upsampler auf DeviceFrame-Ebene, Kapazität via `runtime.frameQueueCapacity`, `EngineStats` zeigt `QueueStats`, Tests decken Timeouts und Counters ab. |
| 2026-04-05 | Queue-Health-Indikator | `QueueStats.Health` gibt `critical`/`low`/`normal` zurück und `txBridge` leitet `EngineStats.Queue` ins `/runtime`-JSON. |
| 2026-04-05 | Runtime-Indikator | `EngineStats.RuntimeIndicator` kombiniert `queue.health` + `lateBuffers`, `/runtime` zeigt `engine.runtimeIndicator`. |
| 2026-04-05 | /status runtime indicator | `/status` reuses `txBridge.TXStats()` and now reports `runtimeIndicator` alongside the config snapshot for quick ops. |

## WS-01 Verifikation
| Datum | Fokus | Ergebnis |
@@ -257,6 +258,7 @@ Generator/Upsampler und Hardwarewriter werden als getrennte Stufen mit kleinem,
| 2026-04-05 | Queue-Health-Indikator | go test ./... deckt `TestFrameQueueHealthIndicator` und `queue.health` ab. |
| 2026-04-05 | Runtime-Indikator | OK `go test ./...` deckt `runtimeIndicator` sowie `/runtime`-Exposition von `engine.runtimeIndicator`. |
| 2026-04-05 | Runtime API queue health | ✅ `/runtime` liefert jetzt `engine.queue.health` dank `txBridge.TXStats`. |
| 2026-04-05 | /status runtime indicator | ✅ `/status` gibt jetzt `runtimeIndicator` aus (`control_test` deckt den neuen Key). |

---



+ 57
- 19
internal/control/control.go Ver fichero

@@ -44,8 +44,8 @@ type Server struct {
mu sync.RWMutex
cfg config.Config
tx TXController
drv platform.SoapyDriver // optional, for runtime stats
streamSrc *audio.StreamSource // optional, for live audio ingest
drv platform.SoapyDriver // optional, for runtime stats
streamSrc *audio.StreamSource // optional, for live audio ingest
}

type ConfigPatch struct {
@@ -119,10 +119,10 @@ func (s *Server) handleUI(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleStatus(w http.ResponseWriter, _ *http.Request) {
s.mu.RLock()
cfg := s.cfg
tx := s.tx
s.mu.RUnlock()

w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
status := map[string]any{
"service": "fm-rds-tx",
"backend": cfg.Backend.Kind,
"frequencyMHz": cfg.FM.FrequencyMHz,
@@ -131,7 +131,17 @@ func (s *Server) handleStatus(w http.ResponseWriter, _ *http.Request) {
"preEmphasisTauUS": cfg.FM.PreEmphasisTauUS,
"limiterEnabled": cfg.FM.LimiterEnabled,
"fmModulationEnabled": cfg.FM.FMModulationEnabled,
})
}
if tx != nil {
if stats := tx.TXStats(); stats != nil {
if ri, ok := stats["runtimeIndicator"]; ok {
status["runtimeIndicator"] = ri
}
}
}

w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(status)
}

func (s *Server) handleRuntime(w http.ResponseWriter, _ *http.Request) {
@@ -264,20 +274,48 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
// Update the server's config snapshot (for GET /config and /status)
s.mu.Lock()
next := s.cfg
if patch.FrequencyMHz != nil { next.FM.FrequencyMHz = *patch.FrequencyMHz }
if patch.OutputDrive != nil { next.FM.OutputDrive = *patch.OutputDrive }
if patch.ToneLeftHz != nil { next.Audio.ToneLeftHz = *patch.ToneLeftHz }
if patch.ToneRightHz != nil { next.Audio.ToneRightHz = *patch.ToneRightHz }
if patch.ToneAmplitude != nil { next.Audio.ToneAmplitude = *patch.ToneAmplitude }
if patch.PS != nil { next.RDS.PS = *patch.PS }
if patch.RadioText != nil { next.RDS.RadioText = *patch.RadioText }
if patch.PreEmphasisTauUS != nil { next.FM.PreEmphasisTauUS = *patch.PreEmphasisTauUS }
if patch.StereoEnabled != nil { next.FM.StereoEnabled = *patch.StereoEnabled }
if patch.LimiterEnabled != nil { next.FM.LimiterEnabled = *patch.LimiterEnabled }
if patch.LimiterCeiling != nil { next.FM.LimiterCeiling = *patch.LimiterCeiling }
if patch.RDSEnabled != nil { next.RDS.Enabled = *patch.RDSEnabled }
if patch.PilotLevel != nil { next.FM.PilotLevel = *patch.PilotLevel }
if patch.RDSInjection != nil { next.FM.RDSInjection = *patch.RDSInjection }
if patch.FrequencyMHz != nil {
next.FM.FrequencyMHz = *patch.FrequencyMHz
}
if patch.OutputDrive != nil {
next.FM.OutputDrive = *patch.OutputDrive
}
if patch.ToneLeftHz != nil {
next.Audio.ToneLeftHz = *patch.ToneLeftHz
}
if patch.ToneRightHz != nil {
next.Audio.ToneRightHz = *patch.ToneRightHz
}
if patch.ToneAmplitude != nil {
next.Audio.ToneAmplitude = *patch.ToneAmplitude
}
if patch.PS != nil {
next.RDS.PS = *patch.PS
}
if patch.RadioText != nil {
next.RDS.RadioText = *patch.RadioText
}
if patch.PreEmphasisTauUS != nil {
next.FM.PreEmphasisTauUS = *patch.PreEmphasisTauUS
}
if patch.StereoEnabled != nil {
next.FM.StereoEnabled = *patch.StereoEnabled
}
if patch.LimiterEnabled != nil {
next.FM.LimiterEnabled = *patch.LimiterEnabled
}
if patch.LimiterCeiling != nil {
next.FM.LimiterCeiling = *patch.LimiterCeiling
}
if patch.RDSEnabled != nil {
next.RDS.Enabled = *patch.RDSEnabled
}
if patch.PilotLevel != nil {
next.FM.PilotLevel = *patch.PilotLevel
}
if patch.RDSInjection != nil {
next.FM.RDSInjection = *patch.RDSInjection
}
if err := next.Validate(); err != nil {
s.mu.Unlock()
http.Error(w, err.Error(), http.StatusBadRequest)


+ 51
- 13
internal/control/control_test.go Ver fichero

@@ -15,28 +15,55 @@ func TestHealthz(t *testing.T) {
srv := NewServer(cfgpkg.Default())
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if rec.Code != 200 { t.Fatalf("status: %d", rec.Code) }
if rec.Code != 200 {
t.Fatalf("status: %d", rec.Code)
}
}

func TestStatus(t *testing.T) {
srv := NewServer(cfgpkg.Default())
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/status", nil))
if rec.Code != 200 { t.Fatalf("status: %d", rec.Code) }
if rec.Code != 200 {
t.Fatalf("status: %d", rec.Code)
}
var body map[string]any
json.Unmarshal(rec.Body.Bytes(), &body)
if body["service"] != "fm-rds-tx" {
t.Fatal("missing service")
}
if _, ok := body["preEmphasisTauUS"]; !ok {
t.Fatal("missing preEmphasisTauUS")
}
}

func TestStatusReportsRuntimeIndicator(t *testing.T) {
srv := NewServer(cfgpkg.Default())
srv.SetTXController(&fakeTXController{stats: map[string]any{"runtimeIndicator": "degraded"}})
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/status", nil))
if rec.Code != 200 {
t.Fatalf("status: %d", rec.Code)
}
var body map[string]any
json.Unmarshal(rec.Body.Bytes(), &body)
if body["service"] != "fm-rds-tx" { t.Fatal("missing service") }
if _, ok := body["preEmphasisTauUS"]; !ok { t.Fatal("missing preEmphasisTauUS") }
if body["runtimeIndicator"] != "degraded" {
t.Fatalf("expected runtimeIndicator degraded, got %v", body["runtimeIndicator"])
}
}

func TestDryRunEndpoint(t *testing.T) {
srv := NewServer(cfgpkg.Default())
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/dry-run", nil))
if rec.Code != 200 { t.Fatalf("status: %d", rec.Code) }
if rec.Code != 200 {
t.Fatalf("status: %d", rec.Code)
}
var body map[string]any
json.Unmarshal(rec.Body.Bytes(), &body)
if body["mode"] != "dry-run" { t.Fatal("wrong mode") }
if body["mode"] != "dry-run" {
t.Fatal("wrong mode")
}
}

func TestConfigPatch(t *testing.T) {
@@ -44,21 +71,27 @@ func TestConfigPatch(t *testing.T) {
body := []byte(`{"toneLeftHz":900,"radioText":"hello world","preEmphasisTauUS":75}`)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/config", bytes.NewReader(body)))
if rec.Code != 200 { t.Fatalf("status: %d body=%s", rec.Code, rec.Body.String()) }
if rec.Code != 200 {
t.Fatalf("status: %d body=%s", rec.Code, rec.Body.String())
}
}

func TestRuntimeWithoutDriver(t *testing.T) {
srv := NewServer(cfgpkg.Default())
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/runtime", nil))
if rec.Code != 200 { t.Fatalf("status: %d", rec.Code) }
if rec.Code != 200 {
t.Fatalf("status: %d", rec.Code)
}
}

func TestTXStartWithoutController(t *testing.T) {
srv := NewServer(cfgpkg.Default())
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/tx/start", nil))
if rec.Code != http.StatusServiceUnavailable { t.Fatalf("expected 503, got %d", rec.Code) }
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503, got %d", rec.Code)
}
}

func TestConfigPatchUpdatesSnapshot(t *testing.T) {
@@ -114,10 +147,15 @@ func TestConfigPatchEngineRejectsDoesNotUpdateSnapshot(t *testing.T) {

type fakeTXController struct {
updateErr error
stats map[string]any
}

func (f *fakeTXController) StartTX() error { return nil }
func (f *fakeTXController) StopTX() error { return nil }
func (f *fakeTXController) TXStats() map[string]any { return map[string]any{} }
func (f *fakeTXController) StartTX() error { return nil }
func (f *fakeTXController) StopTX() error { return nil }
func (f *fakeTXController) TXStats() map[string]any {
if f.stats != nil {
return f.stats
}
return map[string]any{}
}
func (f *fakeTXController) UpdateConfig(_ LivePatch) error { return f.updateErr }


Cargando…
Cancelar
Guardar