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

WS-03: align outputDrive and desired/applied state

tags/v0.9.0
Jan Svabenik 1 месяц назад
Родитель
Сommit
19716e26ee
5 измененных файлов: 90 добавлений и 26 удалений
  1. +3
    -1
      docs/API.md
  2. +7
    -6
      docs/pro-runtime-hardening-workboard.md
  3. +1
    -1
      internal/config/config.go
  4. +17
    -18
      internal/control/control.go
  5. +62
    -0
      internal/control/control_test.go

+ 3
- 1
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. |


+ 7
- 6
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. |

---



+ 1
- 1
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")


+ 17
- 18
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)
}


+ 62
- 0
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 }


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