| @@ -143,6 +143,8 @@ go build -tags sdrplay ./cmd/sdrd | |||||
| - `POST /api/config` | - `POST /api/config` | ||||
| - `POST /api/sdr/settings` | - `POST /api/sdr/settings` | ||||
| - `GET /api/gpu` | - `GET /api/gpu` | ||||
| - `GET /api/pipeline/policy` | |||||
| - `GET /api/pipeline/recommendations` | |||||
| ### Signals / Events | ### Signals / Events | ||||
| - `GET /api/signals` → current live signals | - `GET /api/signals` → current live signals | ||||
| @@ -133,6 +133,21 @@ func registerAPIHandlers(mux *http.ServeMux, cfgPath string, cfgManager *runtime | |||||
| cfg := cfgManager.Snapshot() | cfg := cfgManager.Snapshot() | ||||
| _ = json.NewEncoder(w).Encode(pipeline.PolicyFromConfig(cfg)) | _ = json.NewEncoder(w).Encode(pipeline.PolicyFromConfig(cfg)) | ||||
| }) | }) | ||||
| mux.HandleFunc("/api/pipeline/recommendations", func(w http.ResponseWriter, r *http.Request) { | |||||
| w.Header().Set("Content-Type", "application/json") | |||||
| cfg := cfgManager.Snapshot() | |||||
| policy := pipeline.PolicyFromConfig(cfg) | |||||
| recommend := map[string]any{ | |||||
| "mode": policy.Mode, | |||||
| "intent": policy.Intent, | |||||
| "monitor_span_hz": policy.MonitorSpanHz, | |||||
| "signal_priorities": policy.SignalPriorities, | |||||
| "auto_record_classes": policy.AutoRecordClasses, | |||||
| "auto_decode_classes": policy.AutoDecodeClasses, | |||||
| "refinement_jobs": policy.MaxRefinementJobs, | |||||
| } | |||||
| _ = json.NewEncoder(w).Encode(recommend) | |||||
| }) | |||||
| mux.HandleFunc("/api/events", func(w http.ResponseWriter, r *http.Request) { | mux.HandleFunc("/api/events", func(w http.ResponseWriter, r *http.Request) { | ||||
| w.Header().Set("Content-Type", "application/json") | w.Header().Set("Content-Type", "application/json") | ||||
| limit := 200 | limit := 200 | ||||
| @@ -0,0 +1,29 @@ | |||||
| package pipeline | |||||
| import "strings" | |||||
| func WantsClass(values []string, class string) bool { | |||||
| if len(values) == 0 || class == "" { | |||||
| return false | |||||
| } | |||||
| for _, v := range values { | |||||
| if strings.EqualFold(strings.TrimSpace(v), class) { | |||||
| return true | |||||
| } | |||||
| } | |||||
| return false | |||||
| } | |||||
| func CandidatePriorityBoost(policy Policy, hint string) float64 { | |||||
| h := strings.ToLower(strings.TrimSpace(hint)) | |||||
| for i, want := range policy.SignalPriorities { | |||||
| w := strings.ToLower(strings.TrimSpace(want)) | |||||
| if w == "" { | |||||
| continue | |||||
| } | |||||
| if strings.Contains(h, w) || strings.Contains(w, h) { | |||||
| return float64(len(policy.SignalPriorities)-i) * 3.0 | |||||
| } | |||||
| } | |||||
| return 0 | |||||
| } | |||||
| @@ -0,0 +1,19 @@ | |||||
| package pipeline | |||||
| import "testing" | |||||
| func TestWantsClass(t *testing.T) { | |||||
| if !WantsClass([]string{"WFM", "DMR"}, "wfm") { | |||||
| t.Fatalf("expected case-insensitive match") | |||||
| } | |||||
| if WantsClass([]string{"DMR"}, "WFM") { | |||||
| t.Fatalf("unexpected match") | |||||
| } | |||||
| } | |||||
| func TestCandidatePriorityBoost(t *testing.T) { | |||||
| p := Policy{SignalPriorities: []string{"voice", "digital", "cw"}} | |||||
| if boost := CandidatePriorityBoost(p, "digital-burst"); boost <= 0 { | |||||
| t.Fatalf("expected positive boost, got %v", boost) | |||||
| } | |||||
| } | |||||
| @@ -19,7 +19,7 @@ func ScheduleCandidates(candidates []Candidate, policy Policy) []ScheduledCandid | |||||
| if c.SNRDb < policy.MinCandidateSNRDb { | if c.SNRDb < policy.MinCandidateSNRDb { | ||||
| continue | continue | ||||
| } | } | ||||
| priority := c.SNRDb | |||||
| priority := c.SNRDb + CandidatePriorityBoost(policy, c.Hint) | |||||
| if c.BandwidthHz > 0 { | if c.BandwidthHz > 0 { | ||||
| priority += minFloat64(c.BandwidthHz/25000.0, 6) | priority += minFloat64(c.BandwidthHz/25000.0, 6) | ||||
| } | } | ||||
| @@ -21,3 +21,14 @@ func TestScheduleCandidates(t *testing.T) { | |||||
| t.Fatalf("expected next strongest candidate second, got id=%d", got[1].Candidate.ID) | t.Fatalf("expected next strongest candidate second, got id=%d", got[1].Candidate.ID) | ||||
| } | } | ||||
| } | } | ||||
| func TestScheduleCandidatesPriorityBoost(t *testing.T) { | |||||
| policy := Policy{MaxRefinementJobs: 1, MinCandidateSNRDb: 0, SignalPriorities: []string{"digital"}} | |||||
| got := ScheduleCandidates([]Candidate{ | |||||
| {ID: 1, SNRDb: 15, Hint: "voice"}, | |||||
| {ID: 2, SNRDb: 14, Hint: "digital-burst"}, | |||||
| }, policy) | |||||
| if len(got) != 1 || got[0].Candidate.ID != 2 { | |||||
| t.Fatalf("expected priority boost to favor digital candidate, got %+v", got) | |||||
| } | |||||
| } | |||||
| @@ -32,6 +32,10 @@ type Refinement struct { | |||||
| func CandidatesFromSignals(signals []detector.Signal, source string) []Candidate { | func CandidatesFromSignals(signals []detector.Signal, source string) []Candidate { | ||||
| out := make([]Candidate, 0, len(signals)) | out := make([]Candidate, 0, len(signals)) | ||||
| for _, s := range signals { | for _, s := range signals { | ||||
| hint := "" | |||||
| if s.Class != nil { | |||||
| hint = string(s.Class.ModType) | |||||
| } | |||||
| out = append(out, Candidate{ | out = append(out, Candidate{ | ||||
| ID: s.ID, | ID: s.ID, | ||||
| CenterHz: s.CenterHz, | CenterHz: s.CenterHz, | ||||
| @@ -42,6 +46,7 @@ func CandidatesFromSignals(signals []detector.Signal, source string) []Candidate | |||||
| LastBin: s.LastBin, | LastBin: s.LastBin, | ||||
| NoiseDb: s.NoiseDb, | NoiseDb: s.NoiseDb, | ||||
| Source: source, | Source: source, | ||||
| Hint: hint, | |||||
| }) | }) | ||||
| } | } | ||||
| return out | return out | ||||