From 19716e26eeeef5c9dcfbb10e1356fe7c8d9ccbaf Mon Sep 17 00:00:00 2001 From: Jan Svabenik Date: Sun, 5 Apr 2026 12:24:31 +0200 Subject: [PATCH] WS-03: align outputDrive and desired/applied state --- docs/API.md | 4 +- docs/pro-runtime-hardening-workboard.md | 13 +++--- internal/config/config.go | 2 +- internal/control/control.go | 35 +++++++------- internal/control/control_test.go | 62 +++++++++++++++++++++++++ 5 files changed, 90 insertions(+), 26 deletions(-) diff --git a/docs/API.md b/docs/API.md index b29d676..78e0122 100644 --- a/docs/API.md +++ b/docs/API.md @@ -77,6 +77,8 @@ Full current configuration (all fields, including non-patchable). **Live parameter update.** Changes are applied to the running TX engine immediately — no restart required. Only include fields you want to change (PATCH semantics). +The control snapshot (GET /config) only reflects new values once they pass validation and, if the TX engine is running, after the live update succeeded. That keeps the API from reporting desired values that were rejected or still pending. + **Request body:** JSON with any subset of patchable fields. **Response:** @@ -92,7 +94,7 @@ Full current configuration (all fields, including non-patchable). | Field | Type | Range | Description | |---|---|---|---| | `frequencyMHz` | float | 65–110 | TX center frequency. Tunes hardware LO live. | -| `outputDrive` | float | 0–3 | Composite output level multiplier. | +| `outputDrive` | float | 0–10 | Composite output level multiplier (empfohlen 1..4). | | `stereoEnabled` | bool | | Enable/disable stereo (pilot + 38kHz subcarrier). | | `pilotLevel` | float | 0–0.2 | 19 kHz pilot injection level. | | `rdsInjection` | float | 0–0.15 | 57 kHz RDS subcarrier injection level. | diff --git a/docs/pro-runtime-hardening-workboard.md b/docs/pro-runtime-hardening-workboard.md index d1902a6..1a3866a 100644 --- a/docs/pro-runtime-hardening-workboard.md +++ b/docs/pro-runtime-hardening-workboard.md @@ -139,8 +139,8 @@ Wenn Semantik und Grenzwerte nicht sauber vereinheitlicht sind, bauen spätere R - weitere Inkonsistenzen erst beim Inventar sichtbar ### WS-03-T3 — DesiredConfig / AppliedConfig einführen -- **Status:** TODO -- **Owner:** offen +- **Status:** IN PROGRESS +- **Owner:** Lead Coderaffe - **Code-Orte:** - `internal/app/engine.go` - `internal/control/control.go` @@ -151,20 +151,21 @@ Wenn Semantik und Grenzwerte nicht sauber vereinheitlicht sind, bauen spätere R - tatsächlich angewandter Konfiguration - aktuellem Runtime-Zustand - **Nachweis:** - - API kann beide Sichten getrennt ausgeben - - partielle oder abgelehnte Übernahmen werden sichtbar + - `internal/control/control.go` wartet mit Snapshot-Updates, bis LivePatch erfolgreich war. + - `internal/control/control_test.go` deckt ab, dass abgelehnte Live-Updates keine neue `GET /config`-Ansicht schreiben. - **Restrisiken:** - - unsaubere Migration bestehender Statusantworten + - Die API liefert noch nicht beide Sichten gleichzeitig; weitere Workstreams müssen Desired/Applied explizit zurückgeben. ## WS-03 Entscheidungslog | Datum | Entscheidung | Notiz | |---|---|---| | 2026-04-05 | CFG-SEM-001: `fm.outputDrive` | Live-Validierung auf 0..10 angeglichen, Tests angepasst, Parameterinventar dokumentiert. | +| 2026-04-05 | WS-03-T3: Desired/Applied-Gate | Control-API zeigt Snapshots nur noch, wenn LivePatch erfolgreich angewendet wurde; Tests verhindern irreführende Wunschwerte. | ## WS-03 Verifikation | Datum | Fokus | Ergebnis | |---|---|---| -| 2026-04-05 | `go test ./...` | ✅ Bestätigt `Engine.UpdateConfig`, `LivePatch` und Parameter-Range sowie Inventar-Dokumentation. | +| 2026-04-05 | `go test ./...` | ✅ Bestätigt `Engine.UpdateConfig`, `LivePatch` und Parameter-Range sowie Inventar-Dokumentation. Neue Control-Tests sichern Desired/Applied-Gate. | --- diff --git a/internal/config/config.go b/internal/config/config.go index 768a40a..7654d17 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -136,7 +136,7 @@ func (c Config) Validate() error { return fmt.Errorf("fm.rdsInjection out of range") } if c.FM.OutputDrive < 0 || c.FM.OutputDrive > 10 { - return fmt.Errorf("fm.outputDrive out of range (0..3)") + return fmt.Errorf("fm.outputDrive out of range (0..10)") } if c.FM.CompositeRateHz < 96000 || c.FM.CompositeRateHz > 1520000 { return fmt.Errorf("fm.compositeRateHz out of range") diff --git a/internal/control/control.go b/internal/control/control.go index 7c74e7f..9f3420a 100644 --- a/internal/control/control.go +++ b/internal/control/control.go @@ -283,32 +283,31 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusBadRequest) return } - s.cfg = next + lp := LivePatch{ + FrequencyMHz: patch.FrequencyMHz, + OutputDrive: patch.OutputDrive, + StereoEnabled: patch.StereoEnabled, + PilotLevel: patch.PilotLevel, + RDSInjection: patch.RDSInjection, + RDSEnabled: patch.RDSEnabled, + LimiterEnabled: patch.LimiterEnabled, + LimiterCeiling: patch.LimiterCeiling, + PS: patch.PS, + RadioText: patch.RadioText, + } tx := s.tx - s.mu.Unlock() - - // Forward live-patchable params to running engine (if active) if tx != nil { - lp := LivePatch{ - FrequencyMHz: patch.FrequencyMHz, - OutputDrive: patch.OutputDrive, - StereoEnabled: patch.StereoEnabled, - PilotLevel: patch.PilotLevel, - RDSInjection: patch.RDSInjection, - RDSEnabled: patch.RDSEnabled, - LimiterEnabled: patch.LimiterEnabled, - LimiterCeiling: patch.LimiterCeiling, - PS: patch.PS, - RadioText: patch.RadioText, - } if err := tx.UpdateConfig(lp); err != nil { + s.mu.Unlock() http.Error(w, err.Error(), http.StatusBadRequest) return } } - + s.cfg = next + live := tx != nil + s.mu.Unlock() w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "live": tx != nil}) + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "live": live}) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } diff --git a/internal/control/control_test.go b/internal/control/control_test.go index 6172102..fc01438 100644 --- a/internal/control/control_test.go +++ b/internal/control/control_test.go @@ -3,6 +3,7 @@ package control import ( "bytes" "encoding/json" + "errors" "net/http" "net/http/httptest" "testing" @@ -59,3 +60,64 @@ func TestTXStartWithoutController(t *testing.T) { srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/tx/start", nil)) if rec.Code != http.StatusServiceUnavailable { t.Fatalf("expected 503, got %d", rec.Code) } } + +func TestConfigPatchUpdatesSnapshot(t *testing.T) { + srv := NewServer(cfgpkg.Default()) + srv.SetTXController(&fakeTXController{}) + + rec := httptest.NewRecorder() + body := []byte(`{"outputDrive":1.2}`) + srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/config", bytes.NewReader(body))) + if rec.Code != 200 { + t.Fatalf("status: %d", rec.Code) + } + var resp map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if live, ok := resp["live"].(bool); !ok || !live { + t.Fatalf("expected live true, got %v", resp["live"]) + } + + rec = httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/config", nil)) + var cfg cfgpkg.Config + if err := json.NewDecoder(rec.Body).Decode(&cfg); err != nil { + t.Fatalf("decode config: %v", err) + } + if cfg.FM.OutputDrive != 1.2 { + t.Fatalf("expected snapshot to reflect new drive, got %v", cfg.FM.OutputDrive) + } +} + +func TestConfigPatchEngineRejectsDoesNotUpdateSnapshot(t *testing.T) { + srv := NewServer(cfgpkg.Default()) + srv.SetTXController(&fakeTXController{updateErr: errors.New("boom")}) + + body := []byte(`{"outputDrive":2.2}`) + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/config", bytes.NewReader(body))) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rec.Code) + } + + rec = httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/config", nil)) + var cfg cfgpkg.Config + if err := json.NewDecoder(rec.Body).Decode(&cfg); err != nil { + t.Fatalf("decode config: %v", err) + } + if cfg.FM.OutputDrive != cfgpkg.Default().FM.OutputDrive { + t.Fatalf("expected snapshot untouched, got %v", cfg.FM.OutputDrive) + } +} + +type fakeTXController struct { + updateErr error +} + +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) UpdateConfig(_ LivePatch) error { return f.updateErr } +