From d39d59f1eddd1a1f96bb5b328fe28e2bc59359f4 Mon Sep 17 00:00:00 2001 From: Jan Svabenik Date: Sun, 5 Apr 2026 17:33:51 +0200 Subject: [PATCH] Expose runtime indicator in status --- docs/pro-runtime-hardening-workboard.md | 2 + internal/control/control.go | 76 ++++++++++++++++++------- internal/control/control_test.go | 64 ++++++++++++++++----- 3 files changed, 110 insertions(+), 32 deletions(-) diff --git a/docs/pro-runtime-hardening-workboard.md b/docs/pro-runtime-hardening-workboard.md index f1618a3..6854400 100644 --- a/docs/pro-runtime-hardening-workboard.md +++ b/docs/pro-runtime-hardening-workboard.md @@ -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). | --- diff --git a/internal/control/control.go b/internal/control/control.go index 9f3420a..9e750ff 100644 --- a/internal/control/control.go +++ b/internal/control/control.go @@ -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) diff --git a/internal/control/control_test.go b/internal/control/control_test.go index fc01438..2f92406 100644 --- a/internal/control/control_test.go +++ b/internal/control/control_test.go @@ -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 } -