| @@ -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 | 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 | 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 | 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 | ## WS-01 Verifikation | ||||
| | Datum | Fokus | Ergebnis | | | 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 | 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-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 | 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). | | |||||
| --- | --- | ||||
| @@ -44,8 +44,8 @@ type Server struct { | |||||
| mu sync.RWMutex | mu sync.RWMutex | ||||
| cfg config.Config | cfg config.Config | ||||
| tx TXController | 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 { | 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) { | func (s *Server) handleStatus(w http.ResponseWriter, _ *http.Request) { | ||||
| s.mu.RLock() | s.mu.RLock() | ||||
| cfg := s.cfg | cfg := s.cfg | ||||
| tx := s.tx | |||||
| s.mu.RUnlock() | 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", | "service": "fm-rds-tx", | ||||
| "backend": cfg.Backend.Kind, | "backend": cfg.Backend.Kind, | ||||
| "frequencyMHz": cfg.FM.FrequencyMHz, | "frequencyMHz": cfg.FM.FrequencyMHz, | ||||
| @@ -131,7 +131,17 @@ func (s *Server) handleStatus(w http.ResponseWriter, _ *http.Request) { | |||||
| "preEmphasisTauUS": cfg.FM.PreEmphasisTauUS, | "preEmphasisTauUS": cfg.FM.PreEmphasisTauUS, | ||||
| "limiterEnabled": cfg.FM.LimiterEnabled, | "limiterEnabled": cfg.FM.LimiterEnabled, | ||||
| "fmModulationEnabled": cfg.FM.FMModulationEnabled, | "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) { | 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) | // Update the server's config snapshot (for GET /config and /status) | ||||
| s.mu.Lock() | s.mu.Lock() | ||||
| next := s.cfg | 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 { | if err := next.Validate(); err != nil { | ||||
| s.mu.Unlock() | s.mu.Unlock() | ||||
| http.Error(w, err.Error(), http.StatusBadRequest) | http.Error(w, err.Error(), http.StatusBadRequest) | ||||
| @@ -15,28 +15,55 @@ func TestHealthz(t *testing.T) { | |||||
| srv := NewServer(cfgpkg.Default()) | srv := NewServer(cfgpkg.Default()) | ||||
| rec := httptest.NewRecorder() | rec := httptest.NewRecorder() | ||||
| srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil)) | 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) { | func TestStatus(t *testing.T) { | ||||
| srv := NewServer(cfgpkg.Default()) | srv := NewServer(cfgpkg.Default()) | ||||
| rec := httptest.NewRecorder() | rec := httptest.NewRecorder() | ||||
| srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/status", nil)) | 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 | var body map[string]any | ||||
| json.Unmarshal(rec.Body.Bytes(), &body) | 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) { | func TestDryRunEndpoint(t *testing.T) { | ||||
| srv := NewServer(cfgpkg.Default()) | srv := NewServer(cfgpkg.Default()) | ||||
| rec := httptest.NewRecorder() | rec := httptest.NewRecorder() | ||||
| srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/dry-run", nil)) | 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 | var body map[string]any | ||||
| json.Unmarshal(rec.Body.Bytes(), &body) | 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) { | func TestConfigPatch(t *testing.T) { | ||||
| @@ -44,21 +71,27 @@ func TestConfigPatch(t *testing.T) { | |||||
| body := []byte(`{"toneLeftHz":900,"radioText":"hello world","preEmphasisTauUS":75}`) | body := []byte(`{"toneLeftHz":900,"radioText":"hello world","preEmphasisTauUS":75}`) | ||||
| rec := httptest.NewRecorder() | rec := httptest.NewRecorder() | ||||
| srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/config", bytes.NewReader(body))) | 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) { | func TestRuntimeWithoutDriver(t *testing.T) { | ||||
| srv := NewServer(cfgpkg.Default()) | srv := NewServer(cfgpkg.Default()) | ||||
| rec := httptest.NewRecorder() | rec := httptest.NewRecorder() | ||||
| srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/runtime", nil)) | 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) { | func TestTXStartWithoutController(t *testing.T) { | ||||
| srv := NewServer(cfgpkg.Default()) | srv := NewServer(cfgpkg.Default()) | ||||
| rec := httptest.NewRecorder() | rec := httptest.NewRecorder() | ||||
| srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/tx/start", nil)) | 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) { | func TestConfigPatchUpdatesSnapshot(t *testing.T) { | ||||
| @@ -114,10 +147,15 @@ func TestConfigPatchEngineRejectsDoesNotUpdateSnapshot(t *testing.T) { | |||||
| type fakeTXController struct { | type fakeTXController struct { | ||||
| updateErr error | 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 } | func (f *fakeTXController) UpdateConfig(_ LivePatch) error { return f.updateErr } | ||||