| @@ -39,6 +39,12 @@ func registerAPIHandlers(mux *http.ServeMux, cfgPath string, cfgManager *runtime | |||||
| http.Error(w, err.Error(), http.StatusBadRequest) | http.Error(w, err.Error(), http.StatusBadRequest) | ||||
| return | return | ||||
| } | } | ||||
| if update.Pipeline != nil && update.Pipeline.Profile != nil { | |||||
| if prof, ok := pipeline.ResolveProfile(next, *update.Pipeline.Profile); ok { | |||||
| pipeline.MergeProfile(&next, prof) | |||||
| cfgManager.Replace(next) | |||||
| } | |||||
| } | |||||
| sourceChanged := prev.CenterHz != next.CenterHz || prev.SampleRate != next.SampleRate || prev.GainDb != next.GainDb || prev.AGC != next.AGC || prev.TunerBwKHz != next.TunerBwKHz | sourceChanged := prev.CenterHz != next.CenterHz || prev.SampleRate != next.SampleRate || prev.GainDb != next.GainDb || prev.AGC != next.AGC || prev.TunerBwKHz != next.TunerBwKHz | ||||
| if sourceChanged { | if sourceChanged { | ||||
| if err := srcMgr.ApplyConfig(next); err != nil { | if err := srcMgr.ApplyConfig(next); err != nil { | ||||
| @@ -45,3 +45,20 @@ func TestLoadConfig(t *testing.T) { | |||||
| t.Fatalf("event path default not applied") | t.Fatalf("event path default not applied") | ||||
| } | } | ||||
| } | } | ||||
| func TestProfileDefaultsPresent(t *testing.T) { | |||||
| cfg := Default() | |||||
| if len(cfg.Profiles) < 2 { | |||||
| t.Fatalf("expected built-in profiles") | |||||
| } | |||||
| found := false | |||||
| for _, p := range cfg.Profiles { | |||||
| if p.Name == "wideband-balanced" { | |||||
| found = true | |||||
| break | |||||
| } | |||||
| } | |||||
| if !found { | |||||
| t.Fatalf("missing wideband-balanced profile") | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,36 @@ | |||||
| package pipeline | |||||
| import "sdr-wideband-suite/internal/config" | |||||
| func ResolveProfile(cfg config.Config, name string) (config.ProfileConfig, bool) { | |||||
| for _, p := range cfg.Profiles { | |||||
| if p.Name == name { | |||||
| return p, true | |||||
| } | |||||
| } | |||||
| return config.ProfileConfig{}, false | |||||
| } | |||||
| func MergeProfile(cfg *config.Config, profile config.ProfileConfig) { | |||||
| if cfg == nil { | |||||
| return | |||||
| } | |||||
| if profile.Pipeline != nil { | |||||
| cfg.Pipeline = *profile.Pipeline | |||||
| } | |||||
| if profile.Surveillance != nil { | |||||
| cfg.Surveillance = *profile.Surveillance | |||||
| } | |||||
| if profile.Refinement != nil { | |||||
| cfg.Refinement = *profile.Refinement | |||||
| } | |||||
| if profile.Resources != nil { | |||||
| cfg.Resources = *profile.Resources | |||||
| } | |||||
| if cfg.Surveillance.AnalysisFFTSize > 0 { | |||||
| cfg.FFTSize = cfg.Surveillance.AnalysisFFTSize | |||||
| } | |||||
| if cfg.Surveillance.FrameRate > 0 { | |||||
| cfg.FrameRate = cfg.Surveillance.FrameRate | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,33 @@ | |||||
| package pipeline | |||||
| import ( | |||||
| "testing" | |||||
| "sdr-wideband-suite/internal/config" | |||||
| ) | |||||
| func TestResolveAndMergeProfile(t *testing.T) { | |||||
| cfg := config.Default() | |||||
| cfg.Profiles = append(cfg.Profiles, config.ProfileConfig{ | |||||
| Name: "custom-test", | |||||
| Description: "test profile", | |||||
| Pipeline: &config.PipelineConfig{Mode: "custom", Goals: config.PipelineGoalConfig{Intent: "custom-intent", MonitorSpanHz: 12.5e6}}, | |||||
| Surveillance: &config.SurveillanceConfig{AnalysisFFTSize: 16384, FrameRate: 8, Strategy: "single-resolution"}, | |||||
| Refinement: &config.RefinementConfig{Enabled: true, MaxConcurrent: 20, MinCandidateSNRDb: 4}, | |||||
| Resources: &config.ResourceConfig{PreferGPU: true, MaxRefinementJobs: 20, MaxRecordingStreams: 32}, | |||||
| }) | |||||
| p, ok := ResolveProfile(cfg, "custom-test") | |||||
| if !ok { | |||||
| t.Fatalf("expected profile") | |||||
| } | |||||
| MergeProfile(&cfg, p) | |||||
| if cfg.Pipeline.Mode != "custom" || cfg.Pipeline.Goals.Intent != "custom-intent" { | |||||
| t.Fatalf("pipeline not merged: %+v", cfg.Pipeline) | |||||
| } | |||||
| if cfg.FFTSize != 16384 || cfg.FrameRate != 8 { | |||||
| t.Fatalf("surveillance not merged into legacy fields: fft=%d fps=%d", cfg.FFTSize, cfg.FrameRate) | |||||
| } | |||||
| if cfg.Resources.MaxRefinementJobs != 20 { | |||||
| t.Fatalf("resources not merged: %+v", cfg.Resources) | |||||
| } | |||||
| } | |||||
| @@ -10,7 +10,8 @@ import ( | |||||
| ) | ) | ||||
| type PipelineUpdate struct { | type PipelineUpdate struct { | ||||
| Mode *string `json:"mode"` | |||||
| Mode *string `json:"mode"` | |||||
| Profile *string `json:"profile"` | |||||
| } | } | ||||
| type SurveillanceUpdate struct { | type SurveillanceUpdate struct { | ||||
| @@ -23,6 +23,7 @@ func TestApplyConfigUpdate(t *testing.T) { | |||||
| cfarScale := 5.5 | cfarScale := 5.5 | ||||
| mode := "wideband-balanced" | mode := "wideband-balanced" | ||||
| profile := "wideband-balanced" | |||||
| survFPS := 12 | survFPS := 12 | ||||
| maxRefJobs := 24 | maxRefJobs := 24 | ||||
| updated, err := mgr.ApplyConfig(ConfigUpdate{ | updated, err := mgr.ApplyConfig(ConfigUpdate{ | ||||
| @@ -30,7 +31,7 @@ func TestApplyConfigUpdate(t *testing.T) { | |||||
| SampleRate: &sampleRate, | SampleRate: &sampleRate, | ||||
| FFTSize: &fftSize, | FFTSize: &fftSize, | ||||
| TunerBwKHz: &bw, | TunerBwKHz: &bw, | ||||
| Pipeline: &PipelineUpdate{Mode: &mode}, | |||||
| Pipeline: &PipelineUpdate{Mode: &mode, Profile: &profile}, | |||||
| Surveillance: &SurveillanceUpdate{FrameRate: &survFPS}, | Surveillance: &SurveillanceUpdate{FrameRate: &survFPS}, | ||||
| Resources: &ResourcesUpdate{MaxRefinementJobs: &maxRefJobs}, | Resources: &ResourcesUpdate{MaxRefinementJobs: &maxRefJobs}, | ||||
| Detector: &DetectorUpdate{ | Detector: &DetectorUpdate{ | ||||