| @@ -0,0 +1,262 @@ | |||
| package main | |||
| import ( | |||
| "context" | |||
| "encoding/json" | |||
| "log" | |||
| "net/http" | |||
| "os" | |||
| "path/filepath" | |||
| "strconv" | |||
| "strings" | |||
| "sync" | |||
| "time" | |||
| "sdr-visual-suite/internal/config" | |||
| "sdr-visual-suite/internal/detector" | |||
| "sdr-visual-suite/internal/events" | |||
| fftutil "sdr-visual-suite/internal/fft" | |||
| "sdr-visual-suite/internal/recorder" | |||
| "sdr-visual-suite/internal/runtime" | |||
| ) | |||
| func registerAPIHandlers(mux *http.ServeMux, cfgPath string, cfgManager *runtime.Manager, srcMgr *sourceManager, dspUpdates chan dspUpdate, gpuState *gpuStatus, recMgr *recorder.Manager, sigSnap *signalSnapshot, eventMu *sync.RWMutex) { | |||
| mux.HandleFunc("/api/config", func(w http.ResponseWriter, r *http.Request) { | |||
| w.Header().Set("Content-Type", "application/json") | |||
| switch r.Method { | |||
| case http.MethodGet: | |||
| _ = json.NewEncoder(w).Encode(cfgManager.Snapshot()) | |||
| case http.MethodPost: | |||
| var update runtime.ConfigUpdate | |||
| if err := json.NewDecoder(r.Body).Decode(&update); err != nil { | |||
| http.Error(w, "invalid json", http.StatusBadRequest) | |||
| return | |||
| } | |||
| prev := cfgManager.Snapshot() | |||
| next, err := cfgManager.ApplyConfig(update) | |||
| if err != nil { | |||
| http.Error(w, err.Error(), http.StatusBadRequest) | |||
| return | |||
| } | |||
| sourceChanged := prev.CenterHz != next.CenterHz || prev.SampleRate != next.SampleRate || prev.GainDb != next.GainDb || prev.AGC != next.AGC || prev.TunerBwKHz != next.TunerBwKHz | |||
| if sourceChanged { | |||
| if err := srcMgr.ApplyConfig(next); err != nil { | |||
| cfgManager.Replace(prev) | |||
| http.Error(w, "failed to apply source config", http.StatusInternalServerError) | |||
| return | |||
| } | |||
| } | |||
| if err := config.Save(cfgPath, next); err != nil { | |||
| log.Printf("config save failed: %v", err) | |||
| } | |||
| detChanged := prev.Detector.ThresholdDb != next.Detector.ThresholdDb || | |||
| prev.Detector.MinDurationMs != next.Detector.MinDurationMs || | |||
| prev.Detector.HoldMs != next.Detector.HoldMs || | |||
| prev.Detector.EmaAlpha != next.Detector.EmaAlpha || | |||
| prev.Detector.HysteresisDb != next.Detector.HysteresisDb || | |||
| prev.Detector.MinStableFrames != next.Detector.MinStableFrames || | |||
| prev.Detector.GapToleranceMs != next.Detector.GapToleranceMs || | |||
| prev.Detector.CFARMode != next.Detector.CFARMode || | |||
| prev.Detector.CFARGuardCells != next.Detector.CFARGuardCells || | |||
| prev.Detector.CFARTrainCells != next.Detector.CFARTrainCells || | |||
| prev.Detector.CFARRank != next.Detector.CFARRank || | |||
| prev.Detector.CFARScaleDb != next.Detector.CFARScaleDb || | |||
| prev.Detector.CFARWrapAround != next.Detector.CFARWrapAround || | |||
| prev.SampleRate != next.SampleRate || | |||
| prev.FFTSize != next.FFTSize | |||
| windowChanged := prev.FFTSize != next.FFTSize | |||
| var newDet *detector.Detector | |||
| var newWindow []float64 | |||
| if detChanged { | |||
| newDet = detector.New(next.Detector, next.SampleRate, next.FFTSize) | |||
| } | |||
| if windowChanged { | |||
| newWindow = fftutil.Hann(next.FFTSize) | |||
| } | |||
| pushDSPUpdate(dspUpdates, dspUpdate{cfg: next, det: newDet, window: newWindow, dcBlock: next.DCBlock, iqBalance: next.IQBalance, useGPUFFT: next.UseGPUFFT}) | |||
| _ = json.NewEncoder(w).Encode(next) | |||
| default: | |||
| http.Error(w, "method not allowed", http.StatusMethodNotAllowed) | |||
| } | |||
| }) | |||
| mux.HandleFunc("/api/sdr/settings", func(w http.ResponseWriter, r *http.Request) { | |||
| w.Header().Set("Content-Type", "application/json") | |||
| if r.Method != http.MethodPost { | |||
| http.Error(w, "method not allowed", http.StatusMethodNotAllowed) | |||
| return | |||
| } | |||
| var update runtime.SettingsUpdate | |||
| if err := json.NewDecoder(r.Body).Decode(&update); err != nil { | |||
| http.Error(w, "invalid json", http.StatusBadRequest) | |||
| return | |||
| } | |||
| prev := cfgManager.Snapshot() | |||
| next, err := cfgManager.ApplySettings(update) | |||
| if err != nil { | |||
| http.Error(w, err.Error(), http.StatusBadRequest) | |||
| return | |||
| } | |||
| if prev.AGC != next.AGC || prev.TunerBwKHz != next.TunerBwKHz { | |||
| if err := srcMgr.ApplyConfig(next); err != nil { | |||
| cfgManager.Replace(prev) | |||
| http.Error(w, "failed to apply sdr settings", http.StatusInternalServerError) | |||
| return | |||
| } | |||
| } | |||
| if prev.DCBlock != next.DCBlock || prev.IQBalance != next.IQBalance { | |||
| pushDSPUpdate(dspUpdates, dspUpdate{cfg: next, dcBlock: next.DCBlock, iqBalance: next.IQBalance}) | |||
| } | |||
| if err := config.Save(cfgPath, next); err != nil { | |||
| log.Printf("config save failed: %v", err) | |||
| } | |||
| _ = json.NewEncoder(w).Encode(next) | |||
| }) | |||
| mux.HandleFunc("/api/stats", func(w http.ResponseWriter, r *http.Request) { | |||
| w.Header().Set("Content-Type", "application/json") | |||
| _ = json.NewEncoder(w).Encode(srcMgr.Stats()) | |||
| }) | |||
| mux.HandleFunc("/api/gpu", func(w http.ResponseWriter, r *http.Request) { | |||
| w.Header().Set("Content-Type", "application/json") | |||
| _ = json.NewEncoder(w).Encode(gpuState.snapshot()) | |||
| }) | |||
| mux.HandleFunc("/api/events", func(w http.ResponseWriter, r *http.Request) { | |||
| w.Header().Set("Content-Type", "application/json") | |||
| limit := 200 | |||
| if v := r.URL.Query().Get("limit"); v != "" { | |||
| if parsed, err := strconv.Atoi(v); err == nil { | |||
| limit = parsed | |||
| } | |||
| } | |||
| var since time.Time | |||
| if v := r.URL.Query().Get("since"); v != "" { | |||
| if parsed, err := parseSince(v); err == nil { | |||
| since = parsed | |||
| } else { | |||
| http.Error(w, "invalid since", http.StatusBadRequest) | |||
| return | |||
| } | |||
| } | |||
| snap := cfgManager.Snapshot() | |||
| eventMu.RLock() | |||
| evs, err := events.ReadRecent(snap.EventPath, limit, since) | |||
| eventMu.RUnlock() | |||
| if err != nil { | |||
| http.Error(w, "failed to read events", http.StatusInternalServerError) | |||
| return | |||
| } | |||
| _ = json.NewEncoder(w).Encode(evs) | |||
| }) | |||
| mux.HandleFunc("/api/signals", func(w http.ResponseWriter, r *http.Request) { | |||
| w.Header().Set("Content-Type", "application/json") | |||
| if sigSnap == nil { | |||
| _ = json.NewEncoder(w).Encode([]detector.Signal{}) | |||
| return | |||
| } | |||
| _ = json.NewEncoder(w).Encode(sigSnap.get()) | |||
| }) | |||
| mux.HandleFunc("/api/decoders", func(w http.ResponseWriter, r *http.Request) { | |||
| w.Header().Set("Content-Type", "application/json") | |||
| _ = json.NewEncoder(w).Encode(decoderKeys(cfgManager.Snapshot())) | |||
| }) | |||
| mux.HandleFunc("/api/recordings", func(w http.ResponseWriter, r *http.Request) { | |||
| if r.Method != http.MethodGet { | |||
| http.Error(w, "method not allowed", http.StatusMethodNotAllowed) | |||
| return | |||
| } | |||
| w.Header().Set("Content-Type", "application/json") | |||
| snap := cfgManager.Snapshot() | |||
| list, err := recorder.ListRecordings(snap.Recorder.OutputDir) | |||
| if err != nil { | |||
| http.Error(w, "failed to list recordings", http.StatusInternalServerError) | |||
| return | |||
| } | |||
| _ = json.NewEncoder(w).Encode(list) | |||
| }) | |||
| mux.HandleFunc("/api/recordings/", func(w http.ResponseWriter, r *http.Request) { | |||
| w.Header().Set("Content-Type", "application/json") | |||
| id := strings.TrimPrefix(r.URL.Path, "/api/recordings/") | |||
| if id == "" { | |||
| http.Error(w, "missing id", http.StatusBadRequest) | |||
| return | |||
| } | |||
| snap := cfgManager.Snapshot() | |||
| base := filepath.Clean(filepath.Join(snap.Recorder.OutputDir, id)) | |||
| if !strings.HasPrefix(base, filepath.Clean(snap.Recorder.OutputDir)) { | |||
| http.Error(w, "invalid path", http.StatusBadRequest) | |||
| return | |||
| } | |||
| if r.URL.Path == "/api/recordings/"+id+"/audio" { | |||
| http.ServeFile(w, r, filepath.Join(base, "audio.wav")) | |||
| return | |||
| } | |||
| if r.URL.Path == "/api/recordings/"+id+"/iq" { | |||
| http.ServeFile(w, r, filepath.Join(base, "signal.cf32")) | |||
| return | |||
| } | |||
| if r.URL.Path == "/api/recordings/"+id+"/decode" { | |||
| mode := r.URL.Query().Get("mode") | |||
| cmd := buildDecoderMap(cfgManager.Snapshot())[mode] | |||
| if cmd == "" { | |||
| http.Error(w, "decoder not configured", http.StatusBadRequest) | |||
| return | |||
| } | |||
| meta, err := recorder.ReadMeta(filepath.Join(base, "meta.json")) | |||
| if err != nil { | |||
| http.Error(w, "meta read failed", http.StatusInternalServerError) | |||
| return | |||
| } | |||
| audioPath := filepath.Join(base, "audio.wav") | |||
| if _, errStat := os.Stat(audioPath); errStat != nil { | |||
| audioPath = "" | |||
| } | |||
| res, err := recorder.DecodeOnDemand(cmd, filepath.Join(base, "signal.cf32"), meta.SampleRate, audioPath) | |||
| if err != nil { | |||
| http.Error(w, res.Stderr, http.StatusInternalServerError) | |||
| return | |||
| } | |||
| _ = json.NewEncoder(w).Encode(res) | |||
| return | |||
| } | |||
| http.ServeFile(w, r, filepath.Join(base, "meta.json")) | |||
| }) | |||
| mux.HandleFunc("/api/demod", func(w http.ResponseWriter, r *http.Request) { | |||
| if r.Method != http.MethodGet { | |||
| http.Error(w, "method not allowed", http.StatusMethodNotAllowed) | |||
| return | |||
| } | |||
| q := r.URL.Query() | |||
| freq, _ := strconv.ParseFloat(q.Get("freq"), 64) | |||
| bw, _ := strconv.ParseFloat(q.Get("bw"), 64) | |||
| sec, _ := strconv.Atoi(q.Get("sec")) | |||
| if sec < 1 { | |||
| sec = 1 | |||
| } | |||
| if sec > 10 { | |||
| sec = 10 | |||
| } | |||
| mode := q.Get("mode") | |||
| data, _, err := recMgr.DemodLive(freq, bw, mode, sec) | |||
| if err != nil { | |||
| http.Error(w, err.Error(), http.StatusBadRequest) | |||
| return | |||
| } | |||
| w.Header().Set("Content-Type", "audio/wav") | |||
| _, _ = w.Write(data) | |||
| }) | |||
| } | |||
| func newHTTPServer(addr string, webRoot string, h *hub, cfgPath string, cfgManager *runtime.Manager, srcMgr *sourceManager, dspUpdates chan dspUpdate, gpuState *gpuStatus, recMgr *recorder.Manager, sigSnap *signalSnapshot, eventMu *sync.RWMutex) *http.Server { | |||
| mux := http.NewServeMux() | |||
| registerWSHandlers(mux, h) | |||
| registerAPIHandlers(mux, cfgPath, cfgManager, srcMgr, dspUpdates, gpuState, recMgr, sigSnap, eventMu) | |||
| mux.Handle("/", http.FileServer(http.Dir(webRoot))) | |||
| return &http.Server{Addr: addr, Handler: mux} | |||
| } | |||
| func shutdownServer(server *http.Server) { | |||
| ctxTimeout, cancelTimeout := context.WithTimeout(context.Background(), 5*time.Second) | |||
| defer cancelTimeout() | |||
| _ = server.Shutdown(ctxTimeout) | |||
| } | |||
| @@ -2,24 +2,18 @@ package main | |||
| import ( | |||
| "context" | |||
| "encoding/json" | |||
| "flag" | |||
| "log" | |||
| "net/http" | |||
| "os" | |||
| "os/signal" | |||
| "path/filepath" | |||
| "strconv" | |||
| "strings" | |||
| "sync" | |||
| "syscall" | |||
| "time" | |||
| "github.com/gorilla/websocket" | |||
| "sdr-visual-suite/internal/config" | |||
| "sdr-visual-suite/internal/detector" | |||
| "sdr-visual-suite/internal/events" | |||
| fftutil "sdr-visual-suite/internal/fft" | |||
| "sdr-visual-suite/internal/fft/gpufft" | |||
| "sdr-visual-suite/internal/mock" | |||
| @@ -113,308 +107,7 @@ func main() { | |||
| go runDSP(ctx, srcMgr, cfg, det, window, h, eventFile, eventMu, dspUpdates, gpuState, recMgr, sigSnap) | |||
| upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { | |||
| origin := r.Header.Get("Origin") | |||
| if origin == "" || origin == "null" { | |||
| return true | |||
| } | |||
| // allow same-host or any local IP | |||
| return true | |||
| }} | |||
| http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { | |||
| conn, err := upgrader.Upgrade(w, r, nil) | |||
| if err != nil { | |||
| log.Printf("ws upgrade failed: %v (origin: %s)", err, r.Header.Get("Origin")) | |||
| return | |||
| } | |||
| c := &client{conn: conn, send: make(chan []byte, 32), done: make(chan struct{})} | |||
| h.add(c) | |||
| defer func() { | |||
| h.remove(c) | |||
| _ = conn.Close() | |||
| }() | |||
| conn.SetReadDeadline(time.Now().Add(60 * time.Second)) | |||
| conn.SetPongHandler(func(string) error { | |||
| conn.SetReadDeadline(time.Now().Add(60 * time.Second)) | |||
| return nil | |||
| }) | |||
| go func() { | |||
| ping := time.NewTicker(30 * time.Second) | |||
| defer ping.Stop() | |||
| for { | |||
| select { | |||
| case msg, ok := <-c.send: | |||
| if !ok { | |||
| return | |||
| } | |||
| _ = conn.SetWriteDeadline(time.Now().Add(200 * time.Millisecond)) | |||
| if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil { | |||
| return | |||
| } | |||
| case <-ping.C: | |||
| _ = conn.SetWriteDeadline(time.Now().Add(5 * time.Second)) | |||
| if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil { | |||
| log.Printf("ws ping error: %v", err) | |||
| return | |||
| } | |||
| } | |||
| } | |||
| }() | |||
| for { | |||
| _, _, err := conn.ReadMessage() | |||
| if err != nil { | |||
| return | |||
| } | |||
| } | |||
| }) | |||
| http.HandleFunc("/api/config", func(w http.ResponseWriter, r *http.Request) { | |||
| w.Header().Set("Content-Type", "application/json") | |||
| switch r.Method { | |||
| case http.MethodGet: | |||
| _ = json.NewEncoder(w).Encode(cfgManager.Snapshot()) | |||
| case http.MethodPost: | |||
| var update runtime.ConfigUpdate | |||
| if err := json.NewDecoder(r.Body).Decode(&update); err != nil { | |||
| http.Error(w, "invalid json", http.StatusBadRequest) | |||
| return | |||
| } | |||
| prev := cfgManager.Snapshot() | |||
| next, err := cfgManager.ApplyConfig(update) | |||
| if err != nil { | |||
| http.Error(w, err.Error(), http.StatusBadRequest) | |||
| return | |||
| } | |||
| sourceChanged := prev.CenterHz != next.CenterHz || prev.SampleRate != next.SampleRate || prev.GainDb != next.GainDb || prev.AGC != next.AGC || prev.TunerBwKHz != next.TunerBwKHz | |||
| if sourceChanged { | |||
| if err := srcMgr.ApplyConfig(next); err != nil { | |||
| cfgManager.Replace(prev) | |||
| http.Error(w, "failed to apply source config", http.StatusInternalServerError) | |||
| return | |||
| } | |||
| } | |||
| if err := config.Save(cfgPath, next); err != nil { | |||
| log.Printf("config save failed: %v", err) | |||
| } | |||
| detChanged := prev.Detector.ThresholdDb != next.Detector.ThresholdDb || | |||
| prev.Detector.MinDurationMs != next.Detector.MinDurationMs || | |||
| prev.Detector.HoldMs != next.Detector.HoldMs || | |||
| prev.Detector.EmaAlpha != next.Detector.EmaAlpha || | |||
| prev.Detector.HysteresisDb != next.Detector.HysteresisDb || | |||
| prev.Detector.MinStableFrames != next.Detector.MinStableFrames || | |||
| prev.Detector.GapToleranceMs != next.Detector.GapToleranceMs || | |||
| prev.Detector.CFARMode != next.Detector.CFARMode || | |||
| prev.Detector.CFARGuardCells != next.Detector.CFARGuardCells || | |||
| prev.Detector.CFARTrainCells != next.Detector.CFARTrainCells || | |||
| prev.Detector.CFARRank != next.Detector.CFARRank || | |||
| prev.Detector.CFARScaleDb != next.Detector.CFARScaleDb || | |||
| prev.Detector.CFARWrapAround != next.Detector.CFARWrapAround || | |||
| prev.SampleRate != next.SampleRate || | |||
| prev.FFTSize != next.FFTSize | |||
| windowChanged := prev.FFTSize != next.FFTSize | |||
| var newDet *detector.Detector | |||
| var newWindow []float64 | |||
| if detChanged { | |||
| newDet = detector.New(next.Detector, next.SampleRate, next.FFTSize) | |||
| } | |||
| if windowChanged { | |||
| newWindow = fftutil.Hann(next.FFTSize) | |||
| } | |||
| pushDSPUpdate(dspUpdates, dspUpdate{ | |||
| cfg: next, | |||
| det: newDet, | |||
| window: newWindow, | |||
| dcBlock: next.DCBlock, | |||
| iqBalance: next.IQBalance, | |||
| useGPUFFT: next.UseGPUFFT, | |||
| }) | |||
| _ = json.NewEncoder(w).Encode(next) | |||
| default: | |||
| http.Error(w, "method not allowed", http.StatusMethodNotAllowed) | |||
| } | |||
| }) | |||
| http.HandleFunc("/api/sdr/settings", func(w http.ResponseWriter, r *http.Request) { | |||
| w.Header().Set("Content-Type", "application/json") | |||
| if r.Method != http.MethodPost { | |||
| http.Error(w, "method not allowed", http.StatusMethodNotAllowed) | |||
| return | |||
| } | |||
| var update runtime.SettingsUpdate | |||
| if err := json.NewDecoder(r.Body).Decode(&update); err != nil { | |||
| http.Error(w, "invalid json", http.StatusBadRequest) | |||
| return | |||
| } | |||
| prev := cfgManager.Snapshot() | |||
| next, err := cfgManager.ApplySettings(update) | |||
| if err != nil { | |||
| http.Error(w, err.Error(), http.StatusBadRequest) | |||
| return | |||
| } | |||
| if prev.AGC != next.AGC || prev.TunerBwKHz != next.TunerBwKHz { | |||
| if err := srcMgr.ApplyConfig(next); err != nil { | |||
| cfgManager.Replace(prev) | |||
| http.Error(w, "failed to apply sdr settings", http.StatusInternalServerError) | |||
| return | |||
| } | |||
| } | |||
| if prev.DCBlock != next.DCBlock || prev.IQBalance != next.IQBalance { | |||
| pushDSPUpdate(dspUpdates, dspUpdate{ | |||
| cfg: next, | |||
| dcBlock: next.DCBlock, | |||
| iqBalance: next.IQBalance, | |||
| }) | |||
| } | |||
| if err := config.Save(cfgPath, next); err != nil { | |||
| log.Printf("config save failed: %v", err) | |||
| } | |||
| _ = json.NewEncoder(w).Encode(next) | |||
| }) | |||
| http.HandleFunc("/api/stats", func(w http.ResponseWriter, r *http.Request) { | |||
| w.Header().Set("Content-Type", "application/json") | |||
| _ = json.NewEncoder(w).Encode(srcMgr.Stats()) | |||
| }) | |||
| http.HandleFunc("/api/gpu", func(w http.ResponseWriter, r *http.Request) { | |||
| w.Header().Set("Content-Type", "application/json") | |||
| _ = json.NewEncoder(w).Encode(gpuState.snapshot()) | |||
| }) | |||
| http.HandleFunc("/api/events", func(w http.ResponseWriter, r *http.Request) { | |||
| w.Header().Set("Content-Type", "application/json") | |||
| limit := 200 | |||
| if v := r.URL.Query().Get("limit"); v != "" { | |||
| if parsed, err := strconv.Atoi(v); err == nil { | |||
| limit = parsed | |||
| } | |||
| } | |||
| var since time.Time | |||
| if v := r.URL.Query().Get("since"); v != "" { | |||
| if parsed, err := parseSince(v); err == nil { | |||
| since = parsed | |||
| } else { | |||
| http.Error(w, "invalid since", http.StatusBadRequest) | |||
| return | |||
| } | |||
| } | |||
| snap := cfgManager.Snapshot() | |||
| eventMu.RLock() | |||
| evs, err := events.ReadRecent(snap.EventPath, limit, since) | |||
| eventMu.RUnlock() | |||
| if err != nil { | |||
| http.Error(w, "failed to read events", http.StatusInternalServerError) | |||
| return | |||
| } | |||
| _ = json.NewEncoder(w).Encode(evs) | |||
| }) | |||
| http.HandleFunc("/api/signals", func(w http.ResponseWriter, r *http.Request) { | |||
| w.Header().Set("Content-Type", "application/json") | |||
| if sigSnap == nil { | |||
| _ = json.NewEncoder(w).Encode([]detector.Signal{}) | |||
| return | |||
| } | |||
| _ = json.NewEncoder(w).Encode(sigSnap.get()) | |||
| }) | |||
| http.HandleFunc("/api/decoders", func(w http.ResponseWriter, r *http.Request) { | |||
| w.Header().Set("Content-Type", "application/json") | |||
| _ = json.NewEncoder(w).Encode(decoderKeys(cfgManager.Snapshot())) | |||
| }) | |||
| http.HandleFunc("/api/recordings", func(w http.ResponseWriter, r *http.Request) { | |||
| if r.Method != http.MethodGet { | |||
| http.Error(w, "method not allowed", http.StatusMethodNotAllowed) | |||
| return | |||
| } | |||
| w.Header().Set("Content-Type", "application/json") | |||
| snap := cfgManager.Snapshot() | |||
| list, err := recorder.ListRecordings(snap.Recorder.OutputDir) | |||
| if err != nil { | |||
| http.Error(w, "failed to list recordings", http.StatusInternalServerError) | |||
| return | |||
| } | |||
| _ = json.NewEncoder(w).Encode(list) | |||
| }) | |||
| http.HandleFunc("/api/recordings/", func(w http.ResponseWriter, r *http.Request) { | |||
| w.Header().Set("Content-Type", "application/json") | |||
| id := strings.TrimPrefix(r.URL.Path, "/api/recordings/") | |||
| if id == "" { | |||
| http.Error(w, "missing id", http.StatusBadRequest) | |||
| return | |||
| } | |||
| snap := cfgManager.Snapshot() | |||
| base := filepath.Clean(filepath.Join(snap.Recorder.OutputDir, id)) | |||
| if !strings.HasPrefix(base, filepath.Clean(snap.Recorder.OutputDir)) { | |||
| http.Error(w, "invalid path", http.StatusBadRequest) | |||
| return | |||
| } | |||
| if r.URL.Path == "/api/recordings/"+id+"/audio" { | |||
| http.ServeFile(w, r, filepath.Join(base, "audio.wav")) | |||
| return | |||
| } | |||
| if r.URL.Path == "/api/recordings/"+id+"/iq" { | |||
| http.ServeFile(w, r, filepath.Join(base, "signal.cf32")) | |||
| return | |||
| } | |||
| if r.URL.Path == "/api/recordings/"+id+"/decode" { | |||
| mode := r.URL.Query().Get("mode") | |||
| cmd := buildDecoderMap(cfgManager.Snapshot())[mode] | |||
| if cmd == "" { | |||
| http.Error(w, "decoder not configured", http.StatusBadRequest) | |||
| return | |||
| } | |||
| meta, err := recorder.ReadMeta(filepath.Join(base, "meta.json")) | |||
| if err != nil { | |||
| http.Error(w, "meta read failed", http.StatusInternalServerError) | |||
| return | |||
| } | |||
| audioPath := filepath.Join(base, "audio.wav") | |||
| if _, errStat := os.Stat(audioPath); errStat != nil { | |||
| audioPath = "" | |||
| } | |||
| res, err := recorder.DecodeOnDemand(cmd, filepath.Join(base, "signal.cf32"), meta.SampleRate, audioPath) | |||
| if err != nil { | |||
| http.Error(w, res.Stderr, http.StatusInternalServerError) | |||
| return | |||
| } | |||
| _ = json.NewEncoder(w).Encode(res) | |||
| return | |||
| } | |||
| // default: meta.json | |||
| http.ServeFile(w, r, filepath.Join(base, "meta.json")) | |||
| }) | |||
| http.HandleFunc("/api/demod", func(w http.ResponseWriter, r *http.Request) { | |||
| if r.Method != http.MethodGet { | |||
| http.Error(w, "method not allowed", http.StatusMethodNotAllowed) | |||
| return | |||
| } | |||
| q := r.URL.Query() | |||
| freq, _ := strconv.ParseFloat(q.Get("freq"), 64) | |||
| bw, _ := strconv.ParseFloat(q.Get("bw"), 64) | |||
| sec, _ := strconv.Atoi(q.Get("sec")) | |||
| if sec < 1 { | |||
| sec = 1 | |||
| } | |||
| if sec > 10 { | |||
| sec = 10 | |||
| } | |||
| mode := q.Get("mode") | |||
| data, _, err := recMgr.DemodLive(freq, bw, mode, sec) | |||
| if err != nil { | |||
| http.Error(w, err.Error(), http.StatusBadRequest) | |||
| return | |||
| } | |||
| w.Header().Set("Content-Type", "audio/wav") | |||
| _, _ = w.Write(data) | |||
| }) | |||
| http.Handle("/", http.FileServer(http.Dir(cfg.WebRoot))) | |||
| server := &http.Server{Addr: cfg.WebAddr} | |||
| server := newHTTPServer(cfg.WebAddr, cfg.WebRoot, h, cfgPath, cfgManager, srcMgr, dspUpdates, gpuState, recMgr, sigSnap, eventMu) | |||
| go func() { | |||
| log.Printf("web listening on %s", cfg.WebAddr) | |||
| if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { | |||
| @@ -425,9 +118,9 @@ func main() { | |||
| stop := make(chan os.Signal, 1) | |||
| signal.Notify(stop, os.Interrupt, syscall.SIGTERM) | |||
| <-stop | |||
| ctxTimeout, cancelTimeout := context.WithTimeout(context.Background(), 5*time.Second) | |||
| defer cancelTimeout() | |||
| _ = server.Shutdown(ctxTimeout) | |||
| shutdownServer(server) | |||
| } | |||
| @@ -0,0 +1,66 @@ | |||
| package main | |||
| import ( | |||
| "log" | |||
| "net/http" | |||
| "time" | |||
| "github.com/gorilla/websocket" | |||
| ) | |||
| func registerWSHandlers(mux *http.ServeMux, h *hub) { | |||
| upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { | |||
| origin := r.Header.Get("Origin") | |||
| if origin == "" || origin == "null" { | |||
| return true | |||
| } | |||
| return true | |||
| }} | |||
| mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { | |||
| conn, err := upgrader.Upgrade(w, r, nil) | |||
| if err != nil { | |||
| log.Printf("ws upgrade failed: %v (origin: %s)", err, r.Header.Get("Origin")) | |||
| return | |||
| } | |||
| c := &client{conn: conn, send: make(chan []byte, 32), done: make(chan struct{})} | |||
| h.add(c) | |||
| defer func() { | |||
| h.remove(c) | |||
| _ = conn.Close() | |||
| }() | |||
| conn.SetReadDeadline(time.Now().Add(60 * time.Second)) | |||
| conn.SetPongHandler(func(string) error { | |||
| conn.SetReadDeadline(time.Now().Add(60 * time.Second)) | |||
| return nil | |||
| }) | |||
| go func() { | |||
| ping := time.NewTicker(30 * time.Second) | |||
| defer ping.Stop() | |||
| for { | |||
| select { | |||
| case msg, ok := <-c.send: | |||
| if !ok { | |||
| return | |||
| } | |||
| _ = conn.SetWriteDeadline(time.Now().Add(200 * time.Millisecond)) | |||
| if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil { | |||
| return | |||
| } | |||
| case <-ping.C: | |||
| _ = conn.SetWriteDeadline(time.Now().Add(5 * time.Second)) | |||
| if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil { | |||
| log.Printf("ws ping error: %v", err) | |||
| return | |||
| } | |||
| } | |||
| } | |||
| }() | |||
| for { | |||
| _, _, err := conn.ReadMessage() | |||
| if err != nil { | |||
| return | |||
| } | |||
| } | |||
| }) | |||
| } | |||