diff --git a/docs/README.md b/docs/README.md index 5981e5a..86a22b0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,6 +17,24 @@ The current no-hardware source can be parameterized via config: - `audio.toneRightHz` - `audio.toneAmplitude` +### HTTP control surface + +Available endpoints: +- `GET /healthz` +- `GET /status` +- `GET /dry-run` +- `GET /config` +- `POST /config` + +Current patchable runtime fields via `POST /config`: +- `frequencyMHz` +- `outputDrive` +- `toneLeftHz` +- `toneRightHz` +- `toneAmplitude` +- `ps` +- `radioText` + ### Internal DSP module - `cd internal` - `go test ./...` diff --git a/internal/control/control.go b/internal/control/control.go index e8ca1ab..68afedc 100644 --- a/internal/control/control.go +++ b/internal/control/control.go @@ -14,6 +14,16 @@ type Server struct { cfg config.Config } +type ConfigPatch struct { + FrequencyMHz *float64 `json:"frequencyMHz,omitempty"` + OutputDrive *float64 `json:"outputDrive,omitempty"` + ToneLeftHz *float64 `json:"toneLeftHz,omitempty"` + ToneRightHz *float64 `json:"toneRightHz,omitempty"` + ToneAmplitude *float64 `json:"toneAmplitude,omitempty"` + PS *string `json:"ps,omitempty"` + RadioText *string `json:"radioText,omitempty"` +} + func NewServer(cfg config.Config) *Server { return &Server{cfg: cfg} } func (s *Server) Handler() http.Handler { @@ -21,6 +31,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("/healthz", s.handleHealth) mux.HandleFunc("/status", s.handleStatus) mux.HandleFunc("/dry-run", s.handleDryRun) + mux.HandleFunc("/config", s.handleConfig) return mux } @@ -41,6 +52,8 @@ func (s *Server) handleStatus(w http.ResponseWriter, _ *http.Request) { "frequencyMHz": cfg.FM.FrequencyMHz, "stereoEnabled": cfg.FM.StereoEnabled, "rdsEnabled": cfg.RDS.Enabled, + "toneLeftHz": cfg.Audio.ToneLeftHz, + "toneRightHz": cfg.Audio.ToneRightHz, }) } @@ -52,3 +65,56 @@ func (s *Server) handleDryRun(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(drypkg.Generate(cfg)) } + +func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + s.mu.RLock() + cfg := s.cfg + s.mu.RUnlock() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(cfg) + case http.MethodPost: + var patch ConfigPatch + if err := json.NewDecoder(r.Body).Decode(&patch); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + 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 err := next.Validate(); err != nil { + s.mu.Unlock() + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + s.cfg = next + s.mu.Unlock() + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} diff --git a/internal/control/control_test.go b/internal/control/control_test.go index 16f4259..6eb33ee 100644 --- a/internal/control/control_test.go +++ b/internal/control/control_test.go @@ -1,6 +1,7 @@ package control import ( + "bytes" "encoding/json" "net/http" "net/http/httptest" @@ -52,3 +53,21 @@ func TestDryRunEndpoint(t *testing.T) { t.Fatalf("unexpected mode: %v", body["mode"]) } } + +func TestConfigPatch(t *testing.T) { + srv := NewServer(cfgpkg.Default()) + body := []byte(`{"toneLeftHz":900,"radioText":"hello world"}`) + req := httptest.NewRequest(http.MethodPost, "/config", bytes.NewReader(body)) + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String()) + } + + getReq := httptest.NewRequest(http.MethodGet, "/config", nil) + getRec := httptest.NewRecorder() + srv.Handler().ServeHTTP(getRec, getReq) + if getRec.Code != http.StatusOK { + t.Fatalf("unexpected status: %d", getRec.Code) + } +}