Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

911 lines
27KB

  1. package control
  2. import (
  3. _ "embed"
  4. "encoding/json"
  5. "errors"
  6. "io"
  7. "mime"
  8. "net/http"
  9. "strings"
  10. "sync"
  11. "sync/atomic"
  12. "time"
  13. "golang.org/x/net/websocket"
  14. "github.com/jan/fm-rds-tx/internal/audio"
  15. "github.com/jan/fm-rds-tx/internal/config"
  16. drypkg "github.com/jan/fm-rds-tx/internal/dryrun"
  17. "github.com/jan/fm-rds-tx/internal/ingest"
  18. offpkg "github.com/jan/fm-rds-tx/internal/offline"
  19. "github.com/jan/fm-rds-tx/internal/platform"
  20. )
  21. //go:embed ui.html
  22. var uiHTML []byte
  23. //go:embed logo.png
  24. var logoPNG []byte
  25. // TXController is an optional interface the Server uses to start/stop TX
  26. // and apply live config changes.
  27. type TXController interface {
  28. StartTX() error
  29. StopTX() error
  30. TXStats() map[string]any
  31. UpdateConfig(patch LivePatch) error
  32. ResetFault() error
  33. }
  34. // LivePatch mirrors the patchable fields from ConfigPatch for the engine.
  35. // nil = no change.
  36. type LivePatch struct {
  37. FrequencyMHz *float64
  38. OutputDrive *float64
  39. StereoEnabled *bool
  40. StereoMode *string
  41. PilotLevel *float64
  42. RDSInjection *float64
  43. RDSEnabled *bool
  44. LimiterEnabled *bool
  45. LimiterCeiling *float64
  46. PS *string
  47. RadioText *string
  48. TA *bool
  49. TP *bool
  50. ToneLeftHz *float64
  51. ToneRightHz *float64
  52. ToneAmplitude *float64
  53. AudioGain *float64
  54. CompositeClipperEnabled *bool
  55. }
  56. type Server struct {
  57. mu sync.RWMutex
  58. cfg config.Config
  59. tx TXController
  60. drv platform.SoapyDriver // optional, for runtime stats
  61. streamSrc *audio.StreamSource // optional, for live audio ring stats
  62. audioIngress AudioIngress // optional, for /audio/stream
  63. ingestRt IngestRuntime // optional, for /runtime ingest stats
  64. saveConfig func(config.Config) error
  65. hardReload func()
  66. // BUG-F fix: reloadPending prevents multiple concurrent goroutines from
  67. // calling hardReload when handleIngestSave is hit multiple times quickly.
  68. reloadPending atomic.Bool
  69. audit auditCounters
  70. telemetryHub *TelemetryHub
  71. }
  72. type AudioIngress interface {
  73. WritePCM16(data []byte) (int, error)
  74. }
  75. type IngestRuntime interface {
  76. Stats() ingest.Stats
  77. }
  78. type auditEvent string
  79. const (
  80. auditMethodNotAllowed auditEvent = "methodNotAllowed"
  81. auditUnsupportedMediaType auditEvent = "unsupportedMediaType"
  82. auditBodyTooLarge auditEvent = "bodyTooLarge"
  83. auditUnexpectedBody auditEvent = "unexpectedBody"
  84. )
  85. type auditCounters struct {
  86. methodNotAllowed uint64
  87. unsupportedMediaType uint64
  88. bodyTooLarge uint64
  89. unexpectedBody uint64
  90. }
  91. const (
  92. maxConfigBodyBytes = 64 << 10 // 64 KiB
  93. configContentTypeHeader = "application/json"
  94. noBodyErrMsg = "request must not include a body"
  95. audioStreamContentTypeError = "Content-Type must be application/octet-stream or audio/L16"
  96. audioStreamBodyLimitDefault = 512 << 20 // 512 MiB
  97. )
  98. var audioStreamAllowedMediaTypes = []string{
  99. "application/octet-stream",
  100. "audio/l16",
  101. }
  102. var audioStreamBodyLimit = int64(audioStreamBodyLimitDefault) // bytes allowed per /audio/stream request; tests may override.
  103. func anyToString(v any) string {
  104. if v == nil {
  105. return ""
  106. }
  107. if s, ok := v.(string); ok {
  108. return s
  109. }
  110. return ""
  111. }
  112. func isJSONContentType(r *http.Request) bool {
  113. ct := strings.TrimSpace(r.Header.Get("Content-Type"))
  114. if ct == "" {
  115. return false
  116. }
  117. mediaType, _, err := mime.ParseMediaType(ct)
  118. if err != nil {
  119. return false
  120. }
  121. return strings.EqualFold(mediaType, configContentTypeHeader)
  122. }
  123. type ConfigPatch struct {
  124. FrequencyMHz *float64 `json:"frequencyMHz,omitempty"`
  125. OutputDrive *float64 `json:"outputDrive,omitempty"`
  126. StereoEnabled *bool `json:"stereoEnabled,omitempty"`
  127. StereoMode *string `json:"stereoMode,omitempty"`
  128. PilotLevel *float64 `json:"pilotLevel,omitempty"`
  129. RDSInjection *float64 `json:"rdsInjection,omitempty"`
  130. RDSEnabled *bool `json:"rdsEnabled,omitempty"`
  131. ToneLeftHz *float64 `json:"toneLeftHz,omitempty"`
  132. ToneRightHz *float64 `json:"toneRightHz,omitempty"`
  133. ToneAmplitude *float64 `json:"toneAmplitude,omitempty"`
  134. PS *string `json:"ps,omitempty"`
  135. RadioText *string `json:"radioText,omitempty"`
  136. PreEmphasisTauUS *float64 `json:"preEmphasisTauUS,omitempty"`
  137. LimiterEnabled *bool `json:"limiterEnabled,omitempty"`
  138. LimiterCeiling *float64 `json:"limiterCeiling,omitempty"`
  139. AudioGain *float64 `json:"audioGain,omitempty"`
  140. PI *string `json:"pi,omitempty"`
  141. PTY *int `json:"pty,omitempty"`
  142. TP *bool `json:"tp,omitempty"`
  143. TA *bool `json:"ta,omitempty"`
  144. MS *bool `json:"ms,omitempty"`
  145. CTEnabled *bool `json:"ctEnabled,omitempty"`
  146. RTPlusEnabled *bool `json:"rtPlusEnabled,omitempty"`
  147. RTPlusSeparator *string `json:"rtPlusSeparator,omitempty"`
  148. PTYN *string `json:"ptyn,omitempty"`
  149. LPS *string `json:"lps,omitempty"`
  150. ERTEnabled *bool `json:"ertEnabled,omitempty"`
  151. ERT *string `json:"ert,omitempty"`
  152. RDS2Enabled *bool `json:"rds2Enabled,omitempty"`
  153. StationLogoPath *string `json:"stationLogoPath,omitempty"`
  154. AF *[]float64 `json:"af,omitempty"`
  155. BS412Enabled *bool `json:"bs412Enabled,omitempty"`
  156. BS412ThresholdDBr *float64 `json:"bs412ThresholdDBr,omitempty"`
  157. MpxGain *float64 `json:"mpxGain,omitempty"`
  158. CompositeClipperEnabled *bool `json:"compositeClipperEnabled,omitempty"`
  159. CompositeClipperIterations *int `json:"compositeClipperIterations,omitempty"`
  160. CompositeClipperSoftKnee *float64 `json:"compositeClipperSoftKnee,omitempty"`
  161. CompositeClipperLookaheadMs *float64 `json:"compositeClipperLookaheadMs,omitempty"`
  162. }
  163. type IngestSaveRequest struct {
  164. Ingest config.IngestConfig `json:"ingest"`
  165. }
  166. func NewServer(cfg config.Config) *Server {
  167. return &Server{cfg: cfg, telemetryHub: NewTelemetryHub()}
  168. }
  169. func hasRequestBody(r *http.Request) bool {
  170. if r.ContentLength > 0 {
  171. return true
  172. }
  173. for _, te := range r.TransferEncoding {
  174. if strings.EqualFold(te, "chunked") {
  175. return true
  176. }
  177. }
  178. return false
  179. }
  180. func (s *Server) rejectBody(w http.ResponseWriter, r *http.Request) bool {
  181. // Returns true when the request has an unexpected body and the error response
  182. // has already been written — callers should return immediately in that case.
  183. // Returns false when there is no body (happy path — request should proceed).
  184. if !hasRequestBody(r) {
  185. return false
  186. }
  187. s.recordAudit(auditUnexpectedBody)
  188. http.Error(w, noBodyErrMsg, http.StatusBadRequest)
  189. return true
  190. }
  191. func (s *Server) recordAudit(evt auditEvent) {
  192. switch evt {
  193. case auditMethodNotAllowed:
  194. atomic.AddUint64(&s.audit.methodNotAllowed, 1)
  195. case auditUnsupportedMediaType:
  196. atomic.AddUint64(&s.audit.unsupportedMediaType, 1)
  197. case auditBodyTooLarge:
  198. atomic.AddUint64(&s.audit.bodyTooLarge, 1)
  199. case auditUnexpectedBody:
  200. atomic.AddUint64(&s.audit.unexpectedBody, 1)
  201. }
  202. }
  203. func (s *Server) auditSnapshot() map[string]uint64 {
  204. return map[string]uint64{
  205. "methodNotAllowed": atomic.LoadUint64(&s.audit.methodNotAllowed),
  206. "unsupportedMediaType": atomic.LoadUint64(&s.audit.unsupportedMediaType),
  207. "bodyTooLarge": atomic.LoadUint64(&s.audit.bodyTooLarge),
  208. "unexpectedBody": atomic.LoadUint64(&s.audit.unexpectedBody),
  209. }
  210. }
  211. func isAudioStreamContentType(r *http.Request) bool {
  212. ct := strings.TrimSpace(r.Header.Get("Content-Type"))
  213. if ct == "" {
  214. return false
  215. }
  216. mediaType, _, err := mime.ParseMediaType(ct)
  217. if err != nil {
  218. return false
  219. }
  220. for _, allowed := range audioStreamAllowedMediaTypes {
  221. if strings.EqualFold(mediaType, allowed) {
  222. return true
  223. }
  224. }
  225. return false
  226. }
  227. func (s *Server) TelemetryHub() *TelemetryHub {
  228. return s.telemetryHub
  229. }
  230. func (s *Server) SetTXController(tx TXController) {
  231. s.mu.Lock()
  232. s.tx = tx
  233. s.mu.Unlock()
  234. }
  235. func (s *Server) SetDriver(drv platform.SoapyDriver) {
  236. s.mu.Lock()
  237. s.drv = drv
  238. s.mu.Unlock()
  239. }
  240. func (s *Server) SetStreamSource(src *audio.StreamSource) {
  241. s.mu.Lock()
  242. s.streamSrc = src
  243. s.mu.Unlock()
  244. }
  245. func (s *Server) SetAudioIngress(ingress AudioIngress) {
  246. s.mu.Lock()
  247. s.audioIngress = ingress
  248. s.mu.Unlock()
  249. }
  250. func (s *Server) SetIngestRuntime(rt IngestRuntime) {
  251. s.mu.Lock()
  252. s.ingestRt = rt
  253. s.mu.Unlock()
  254. }
  255. func (s *Server) SetConfigSaver(save func(config.Config) error) {
  256. s.mu.Lock()
  257. s.saveConfig = save
  258. s.mu.Unlock()
  259. }
  260. func (s *Server) SetHardReload(fn func()) {
  261. s.mu.Lock()
  262. s.hardReload = fn
  263. s.mu.Unlock()
  264. }
  265. func (s *Server) Handler() http.Handler {
  266. mux := http.NewServeMux()
  267. mux.HandleFunc("/", s.handleUI)
  268. mux.HandleFunc("/logo", s.handleLogo)
  269. mux.HandleFunc("/healthz", s.handleHealth)
  270. mux.HandleFunc("/status", s.handleStatus)
  271. mux.HandleFunc("/dry-run", s.handleDryRun)
  272. mux.HandleFunc("/config", s.handleConfig)
  273. mux.HandleFunc("/config/ingest/save", s.handleIngestSave)
  274. mux.HandleFunc("/runtime", s.handleRuntime)
  275. mux.HandleFunc("/measurements", s.handleMeasurements)
  276. mux.Handle("/ws/telemetry", websocket.Handler(s.handleTelemetryWS))
  277. mux.HandleFunc("/runtime/fault/reset", s.handleRuntimeFaultReset)
  278. mux.HandleFunc("/tx/start", s.handleTXStart)
  279. mux.HandleFunc("/tx/stop", s.handleTXStop)
  280. mux.HandleFunc("/audio/stream", s.handleAudioStream)
  281. return mux
  282. }
  283. func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
  284. w.Header().Set("Content-Type", "application/json")
  285. _ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
  286. }
  287. func (s *Server) handleUI(w http.ResponseWriter, r *http.Request) {
  288. if r.URL.Path != "/" {
  289. http.NotFound(w, r)
  290. return
  291. }
  292. w.Header().Set("Content-Type", "text/html; charset=utf-8")
  293. w.Header().Set("Cache-Control", "no-cache")
  294. w.Write(uiHTML)
  295. }
  296. func (s *Server) handleLogo(w http.ResponseWriter, r *http.Request) {
  297. w.Header().Set("Content-Type", "image/png")
  298. w.Header().Set("Cache-Control", "public, max-age=3600")
  299. w.Write(logoPNG)
  300. }
  301. func (s *Server) handleStatus(w http.ResponseWriter, _ *http.Request) {
  302. s.mu.RLock()
  303. cfg := s.cfg
  304. tx := s.tx
  305. s.mu.RUnlock()
  306. status := map[string]any{
  307. "service": "fm-rds-tx",
  308. "backend": cfg.Backend.Kind,
  309. "frequencyMHz": cfg.FM.FrequencyMHz,
  310. "stereoEnabled": cfg.FM.StereoEnabled,
  311. "stereoMode": cfg.FM.StereoMode,
  312. "rdsEnabled": cfg.RDS.Enabled,
  313. "preEmphasisTauUS": cfg.FM.PreEmphasisTauUS,
  314. "limiterEnabled": cfg.FM.LimiterEnabled,
  315. "fmModulationEnabled": cfg.FM.FMModulationEnabled,
  316. }
  317. if tx != nil {
  318. if stats := tx.TXStats(); stats != nil {
  319. if ri, ok := stats["runtimeIndicator"]; ok {
  320. status["runtimeIndicator"] = ri
  321. }
  322. if alert, ok := stats["runtimeAlert"]; ok {
  323. status["runtimeAlert"] = alert
  324. }
  325. if queue, ok := stats["queue"]; ok {
  326. status["queue"] = queue
  327. }
  328. if runtimeState, ok := stats["state"]; ok {
  329. status["runtimeState"] = runtimeState
  330. }
  331. }
  332. }
  333. w.Header().Set("Content-Type", "application/json")
  334. _ = json.NewEncoder(w).Encode(status)
  335. }
  336. func (s *Server) handleMeasurements(w http.ResponseWriter, _ *http.Request) {
  337. s.mu.RLock()
  338. tx := s.tx
  339. s.mu.RUnlock()
  340. result := map[string]any{"noData": true, "stale": true}
  341. if tx != nil {
  342. if stats := tx.TXStats(); stats != nil {
  343. if state, ok := stats["state"]; ok {
  344. result["state"] = state
  345. }
  346. if applied, ok := stats["appliedFrequencyMHz"]; ok {
  347. result["appliedFrequencyMHz"] = applied
  348. }
  349. if queue, ok := stats["queue"]; ok {
  350. result["queue"] = queue
  351. }
  352. if runtimeIndicator, ok := stats["runtimeIndicator"]; ok {
  353. result["runtimeIndicator"] = runtimeIndicator
  354. }
  355. if runtimeAlert, ok := stats["runtimeAlert"]; ok {
  356. result["runtimeAlert"] = runtimeAlert
  357. }
  358. if measurement, ok := stats["measurement"].(*offpkg.MeasurementSnapshot); ok && measurement != nil {
  359. state := strings.ToLower(strings.TrimSpace(anyToString(stats["state"])))
  360. fresh := !measurement.Timestamp.IsZero() && time.Since(measurement.Timestamp) <= 2*time.Second
  361. runningish := state == "running" || state == "degraded" || state == "muted" || state == "faulted" || state == "arming" || state == "prebuffering"
  362. result["measurement"] = measurement
  363. result["noData"] = false
  364. result["stale"] = !(fresh && runningish)
  365. }
  366. }
  367. }
  368. w.Header().Set("Content-Type", "application/json")
  369. _ = json.NewEncoder(w).Encode(result)
  370. }
  371. func (s *Server) handleTelemetryWS(ws *websocket.Conn) {
  372. if s.telemetryHub == nil {
  373. _ = ws.Close()
  374. return
  375. }
  376. _ = ws.SetDeadline(time.Now().Add(30 * time.Second))
  377. sub, unsubscribe := s.telemetryHub.Subscribe()
  378. defer unsubscribe()
  379. defer ws.Close()
  380. s.mu.RLock()
  381. tx := s.tx
  382. s.mu.RUnlock()
  383. if tx != nil {
  384. if stats := tx.TXStats(); stats != nil {
  385. if measurement, ok := stats["measurement"].(interface{ }); ok {
  386. if m, ok := measurement.(*offpkg.MeasurementSnapshot); ok && m != nil {
  387. _ = ws.SetWriteDeadline(time.Now().Add(2 * time.Second))
  388. if err := websocket.JSON.Send(ws, TelemetryMessage{Type: "measurement", TS: m.Timestamp, Seq: m.Sequence, Data: m}); err != nil {
  389. return
  390. }
  391. }
  392. }
  393. }
  394. }
  395. for {
  396. select {
  397. case <-sub.done:
  398. return
  399. case msg := <-sub.ch:
  400. _ = ws.SetWriteDeadline(time.Now().Add(2 * time.Second))
  401. if err := websocket.JSON.Send(ws, msg); err != nil {
  402. return
  403. }
  404. }
  405. }
  406. }
  407. func (s *Server) handleRuntime(w http.ResponseWriter, _ *http.Request) {
  408. s.mu.RLock()
  409. drv := s.drv
  410. tx := s.tx
  411. stream := s.streamSrc
  412. ingestRt := s.ingestRt
  413. s.mu.RUnlock()
  414. result := map[string]any{}
  415. if drv != nil {
  416. result["driver"] = drv.Stats()
  417. }
  418. if tx != nil {
  419. if stats := tx.TXStats(); stats != nil {
  420. result["engine"] = stats
  421. }
  422. }
  423. if stream != nil {
  424. result["audioStream"] = stream.Stats()
  425. }
  426. if ingestRt != nil {
  427. result["ingest"] = ingestRt.Stats()
  428. }
  429. result["controlAudit"] = s.auditSnapshot()
  430. w.Header().Set("Content-Type", "application/json")
  431. _ = json.NewEncoder(w).Encode(result)
  432. }
  433. func (s *Server) handleRuntimeFaultReset(w http.ResponseWriter, r *http.Request) {
  434. if r.Method != http.MethodPost {
  435. s.recordAudit(auditMethodNotAllowed)
  436. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  437. return
  438. }
  439. if s.rejectBody(w, r) { // BUG-01 fix: rejectBody returns true when rejected
  440. return
  441. }
  442. s.mu.RLock()
  443. tx := s.tx
  444. s.mu.RUnlock()
  445. if tx == nil {
  446. http.Error(w, "tx controller not available", http.StatusServiceUnavailable)
  447. return
  448. }
  449. if err := tx.ResetFault(); err != nil {
  450. http.Error(w, err.Error(), http.StatusConflict)
  451. return
  452. }
  453. w.Header().Set("Content-Type", "application/json")
  454. _ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
  455. }
  456. // handleAudioStream accepts raw S16LE PCM via HTTP POST and pushes
  457. // it into the configured ingest http-raw source. Use with:
  458. //
  459. // curl -X POST --data-binary @- http://host:8088/audio/stream < audio.raw
  460. // ffmpeg ... -f s16le -ar 44100 -ac 2 - | curl -X POST --data-binary @- http://host:8088/audio/stream
  461. func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) {
  462. if r.Method != http.MethodPost {
  463. s.recordAudit(auditMethodNotAllowed)
  464. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  465. return
  466. }
  467. if !isAudioStreamContentType(r) {
  468. s.recordAudit(auditUnsupportedMediaType)
  469. http.Error(w, audioStreamContentTypeError, http.StatusUnsupportedMediaType)
  470. return
  471. }
  472. s.mu.RLock()
  473. ingress := s.audioIngress
  474. s.mu.RUnlock()
  475. if ingress == nil {
  476. http.Error(w, "audio ingest not configured (use --audio-http with ingest runtime)", http.StatusServiceUnavailable)
  477. return
  478. }
  479. // BUG-10 fix: /audio/stream is a long-lived streaming endpoint.
  480. // The global HTTP server ReadTimeout (5s) and WriteTimeout (10s) would
  481. // kill connections mid-stream. Disable them per-request via ResponseController
  482. // (requires Go 1.20+, confirmed Go 1.22).
  483. rc := http.NewResponseController(w)
  484. _ = rc.SetReadDeadline(time.Time{})
  485. _ = rc.SetWriteDeadline(time.Time{})
  486. r.Body = http.MaxBytesReader(w, r.Body, audioStreamBodyLimit)
  487. // Read body in chunks and push to ring buffer
  488. buf := make([]byte, 32768)
  489. totalFrames := 0
  490. for {
  491. n, err := r.Body.Read(buf)
  492. if n > 0 {
  493. written, writeErr := ingress.WritePCM16(buf[:n])
  494. totalFrames += written
  495. if writeErr != nil {
  496. http.Error(w, writeErr.Error(), http.StatusServiceUnavailable)
  497. return
  498. }
  499. }
  500. if err != nil {
  501. if err == io.EOF {
  502. break
  503. }
  504. var maxErr *http.MaxBytesError
  505. if errors.As(err, &maxErr) {
  506. s.recordAudit(auditBodyTooLarge)
  507. http.Error(w, maxErr.Error(), http.StatusRequestEntityTooLarge)
  508. return
  509. }
  510. http.Error(w, err.Error(), http.StatusInternalServerError)
  511. return
  512. }
  513. }
  514. w.Header().Set("Content-Type", "application/json")
  515. _ = json.NewEncoder(w).Encode(map[string]any{
  516. "ok": true,
  517. "frames": totalFrames,
  518. })
  519. }
  520. func (s *Server) handleTXStart(w http.ResponseWriter, r *http.Request) {
  521. if r.Method != http.MethodPost {
  522. s.recordAudit(auditMethodNotAllowed)
  523. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  524. return
  525. }
  526. if s.rejectBody(w, r) { // BUG-01 fix: rejectBody returns true when rejected
  527. return
  528. }
  529. s.mu.RLock()
  530. tx := s.tx
  531. s.mu.RUnlock()
  532. if tx == nil {
  533. http.Error(w, "tx controller not available", http.StatusServiceUnavailable)
  534. return
  535. }
  536. if err := tx.StartTX(); err != nil {
  537. http.Error(w, err.Error(), http.StatusConflict)
  538. return
  539. }
  540. w.Header().Set("Content-Type", "application/json")
  541. _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "action": "started"})
  542. }
  543. func (s *Server) handleTXStop(w http.ResponseWriter, r *http.Request) {
  544. if r.Method != http.MethodPost {
  545. s.recordAudit(auditMethodNotAllowed)
  546. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  547. return
  548. }
  549. if s.rejectBody(w, r) { // BUG-01 fix: rejectBody returns true when rejected
  550. return
  551. }
  552. s.mu.RLock()
  553. tx := s.tx
  554. s.mu.RUnlock()
  555. if tx == nil {
  556. http.Error(w, "tx controller not available", http.StatusServiceUnavailable)
  557. return
  558. }
  559. go func() {
  560. _ = tx.StopTX()
  561. }()
  562. w.Header().Set("Content-Type", "application/json")
  563. _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "action": "stop-requested"})
  564. }
  565. func (s *Server) handleDryRun(w http.ResponseWriter, _ *http.Request) {
  566. s.mu.RLock()
  567. cfg := s.cfg
  568. s.mu.RUnlock()
  569. w.Header().Set("Content-Type", "application/json")
  570. _ = json.NewEncoder(w).Encode(drypkg.Generate(cfg))
  571. }
  572. func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
  573. switch r.Method {
  574. case http.MethodGet:
  575. s.mu.RLock()
  576. cfg := s.cfg
  577. s.mu.RUnlock()
  578. w.Header().Set("Content-Type", "application/json")
  579. _ = json.NewEncoder(w).Encode(cfg)
  580. case http.MethodPost:
  581. if !isJSONContentType(r) {
  582. s.recordAudit(auditUnsupportedMediaType)
  583. http.Error(w, "Content-Type must be application/json", http.StatusUnsupportedMediaType)
  584. return
  585. }
  586. r.Body = http.MaxBytesReader(w, r.Body, maxConfigBodyBytes)
  587. var patch ConfigPatch
  588. // BUG-4 fix: reject unknown JSON fields (typos) with 400 rather than
  589. // silently ignoring them (e.g. "outputDrvie" would succeed and do nothing).
  590. dec := json.NewDecoder(r.Body)
  591. dec.DisallowUnknownFields()
  592. if err := dec.Decode(&patch); err != nil {
  593. statusCode := http.StatusBadRequest
  594. if strings.Contains(err.Error(), "http: request body too large") {
  595. statusCode = http.StatusRequestEntityTooLarge
  596. s.recordAudit(auditBodyTooLarge)
  597. }
  598. http.Error(w, err.Error(), statusCode)
  599. return
  600. }
  601. // Update the server's config snapshot (for GET /config and /status)
  602. s.mu.Lock()
  603. next := s.cfg
  604. if patch.FrequencyMHz != nil {
  605. next.FM.FrequencyMHz = *patch.FrequencyMHz
  606. }
  607. if patch.OutputDrive != nil {
  608. next.FM.OutputDrive = *patch.OutputDrive
  609. }
  610. if patch.ToneLeftHz != nil {
  611. next.Audio.ToneLeftHz = *patch.ToneLeftHz
  612. }
  613. if patch.ToneRightHz != nil {
  614. next.Audio.ToneRightHz = *patch.ToneRightHz
  615. }
  616. if patch.ToneAmplitude != nil {
  617. next.Audio.ToneAmplitude = *patch.ToneAmplitude
  618. }
  619. if patch.AudioGain != nil {
  620. next.Audio.Gain = *patch.AudioGain
  621. }
  622. if patch.PS != nil {
  623. next.RDS.PS = *patch.PS
  624. }
  625. if patch.RadioText != nil {
  626. next.RDS.RadioText = *patch.RadioText
  627. }
  628. if patch.PI != nil {
  629. next.RDS.PI = *patch.PI
  630. }
  631. if patch.PTY != nil {
  632. next.RDS.PTY = *patch.PTY
  633. }
  634. if patch.TP != nil {
  635. next.RDS.TP = *patch.TP
  636. }
  637. if patch.TA != nil {
  638. next.RDS.TA = *patch.TA
  639. }
  640. if patch.MS != nil {
  641. next.RDS.MS = *patch.MS
  642. }
  643. if patch.CTEnabled != nil {
  644. next.RDS.CTEnabled = *patch.CTEnabled
  645. }
  646. if patch.RTPlusEnabled != nil {
  647. next.RDS.RTPlusEnabled = *patch.RTPlusEnabled
  648. }
  649. if patch.RTPlusSeparator != nil {
  650. next.RDS.RTPlusSeparator = *patch.RTPlusSeparator
  651. }
  652. if patch.PTYN != nil {
  653. next.RDS.PTYN = *patch.PTYN
  654. }
  655. if patch.LPS != nil {
  656. next.RDS.LPS = *patch.LPS
  657. }
  658. if patch.ERTEnabled != nil {
  659. next.RDS.ERTEnabled = *patch.ERTEnabled
  660. }
  661. if patch.ERT != nil {
  662. next.RDS.ERT = *patch.ERT
  663. }
  664. if patch.RDS2Enabled != nil {
  665. next.RDS.RDS2Enabled = *patch.RDS2Enabled
  666. }
  667. if patch.StationLogoPath != nil {
  668. next.RDS.StationLogoPath = *patch.StationLogoPath
  669. }
  670. if patch.AF != nil {
  671. next.RDS.AF = *patch.AF
  672. }
  673. if patch.PreEmphasisTauUS != nil {
  674. next.FM.PreEmphasisTauUS = *patch.PreEmphasisTauUS
  675. }
  676. if patch.StereoEnabled != nil {
  677. next.FM.StereoEnabled = *patch.StereoEnabled
  678. }
  679. if patch.StereoMode != nil {
  680. next.FM.StereoMode = *patch.StereoMode
  681. }
  682. if patch.LimiterEnabled != nil {
  683. next.FM.LimiterEnabled = *patch.LimiterEnabled
  684. }
  685. if patch.LimiterCeiling != nil {
  686. next.FM.LimiterCeiling = *patch.LimiterCeiling
  687. }
  688. if patch.RDSEnabled != nil {
  689. next.RDS.Enabled = *patch.RDSEnabled
  690. }
  691. if patch.PilotLevel != nil {
  692. next.FM.PilotLevel = *patch.PilotLevel
  693. }
  694. if patch.RDSInjection != nil {
  695. next.FM.RDSInjection = *patch.RDSInjection
  696. }
  697. if patch.BS412Enabled != nil {
  698. next.FM.BS412Enabled = *patch.BS412Enabled
  699. }
  700. if patch.BS412ThresholdDBr != nil {
  701. next.FM.BS412ThresholdDBr = *patch.BS412ThresholdDBr
  702. }
  703. if patch.MpxGain != nil {
  704. next.FM.MpxGain = *patch.MpxGain
  705. }
  706. if patch.CompositeClipperEnabled != nil {
  707. next.FM.CompositeClipper.Enabled = *patch.CompositeClipperEnabled
  708. }
  709. if patch.CompositeClipperIterations != nil {
  710. next.FM.CompositeClipper.Iterations = *patch.CompositeClipperIterations
  711. }
  712. if patch.CompositeClipperSoftKnee != nil {
  713. next.FM.CompositeClipper.SoftKnee = *patch.CompositeClipperSoftKnee
  714. }
  715. if patch.CompositeClipperLookaheadMs != nil {
  716. next.FM.CompositeClipper.LookaheadMs = *patch.CompositeClipperLookaheadMs
  717. }
  718. if err := next.Validate(); err != nil {
  719. s.mu.Unlock()
  720. http.Error(w, err.Error(), http.StatusBadRequest)
  721. return
  722. }
  723. lp := LivePatch{
  724. FrequencyMHz: patch.FrequencyMHz,
  725. OutputDrive: patch.OutputDrive,
  726. StereoEnabled: patch.StereoEnabled,
  727. StereoMode: patch.StereoMode,
  728. PilotLevel: patch.PilotLevel,
  729. RDSInjection: patch.RDSInjection,
  730. RDSEnabled: patch.RDSEnabled,
  731. LimiterEnabled: patch.LimiterEnabled,
  732. LimiterCeiling: patch.LimiterCeiling,
  733. PS: patch.PS,
  734. RadioText: patch.RadioText,
  735. TA: patch.TA,
  736. TP: patch.TP,
  737. ToneLeftHz: patch.ToneLeftHz,
  738. ToneRightHz: patch.ToneRightHz,
  739. ToneAmplitude: patch.ToneAmplitude,
  740. AudioGain: patch.AudioGain,
  741. CompositeClipperEnabled: patch.CompositeClipperEnabled,
  742. }
  743. // NEU-02 fix: determine whether any live-patchable fields are present,
  744. // then release the lock before calling UpdateConfig to avoid holding
  745. // s.mu across a potentially blocking engine call.
  746. tx := s.tx
  747. hasLiveFields := patch.FrequencyMHz != nil || patch.OutputDrive != nil ||
  748. patch.StereoEnabled != nil || patch.StereoMode != nil || patch.PilotLevel != nil ||
  749. patch.RDSInjection != nil || patch.RDSEnabled != nil ||
  750. patch.LimiterEnabled != nil || patch.LimiterCeiling != nil ||
  751. patch.PS != nil || patch.RadioText != nil || patch.TA != nil || patch.TP != nil ||
  752. patch.ToneLeftHz != nil || patch.ToneRightHz != nil ||
  753. patch.ToneAmplitude != nil || patch.AudioGain != nil ||
  754. patch.CompositeClipperEnabled != nil
  755. s.mu.Unlock()
  756. // Apply live fields to running engine outside the lock.
  757. if tx != nil && hasLiveFields {
  758. if err := tx.UpdateConfig(lp); err != nil {
  759. http.Error(w, err.Error(), http.StatusBadRequest)
  760. return
  761. }
  762. }
  763. // Persist the validated config snapshot when a config saver is available.
  764. // This ensures restart-required UI changes survive process restarts instead
  765. // of only updating the in-memory snapshot.
  766. s.mu.RLock()
  767. save := s.saveConfig
  768. s.mu.RUnlock()
  769. if save != nil {
  770. if err := save(next); err != nil {
  771. http.Error(w, err.Error(), http.StatusInternalServerError)
  772. return
  773. }
  774. }
  775. // Commit the server snapshot only after validation, optional persistence,
  776. // and any required live update succeeded.
  777. s.mu.Lock()
  778. s.cfg = next
  779. s.mu.Unlock()
  780. // NEU-03 fix: report live=true only when live-patchable fields were applied.
  781. live := tx != nil && hasLiveFields
  782. w.Header().Set("Content-Type", "application/json")
  783. _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "live": live})
  784. default:
  785. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  786. }
  787. }
  788. func (s *Server) handleIngestSave(w http.ResponseWriter, r *http.Request) {
  789. if r.Method != http.MethodPost {
  790. s.recordAudit(auditMethodNotAllowed)
  791. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  792. return
  793. }
  794. if !isJSONContentType(r) {
  795. s.recordAudit(auditUnsupportedMediaType)
  796. http.Error(w, "Content-Type must be application/json", http.StatusUnsupportedMediaType)
  797. return
  798. }
  799. r.Body = http.MaxBytesReader(w, r.Body, maxConfigBodyBytes)
  800. var req IngestSaveRequest
  801. if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
  802. statusCode := http.StatusBadRequest
  803. if strings.Contains(err.Error(), "http: request body too large") {
  804. statusCode = http.StatusRequestEntityTooLarge
  805. s.recordAudit(auditBodyTooLarge)
  806. }
  807. http.Error(w, err.Error(), statusCode)
  808. return
  809. }
  810. s.mu.Lock()
  811. next := s.cfg
  812. next.Ingest = req.Ingest
  813. if err := next.Validate(); err != nil {
  814. s.mu.Unlock()
  815. http.Error(w, err.Error(), http.StatusBadRequest)
  816. return
  817. }
  818. save := s.saveConfig
  819. reload := s.hardReload
  820. if save == nil {
  821. s.mu.Unlock()
  822. http.Error(w, "config save is not configured (start with --config <path>)", http.StatusServiceUnavailable)
  823. return
  824. }
  825. if err := save(next); err != nil {
  826. s.mu.Unlock()
  827. http.Error(w, err.Error(), http.StatusInternalServerError)
  828. return
  829. }
  830. s.cfg = next
  831. s.mu.Unlock()
  832. w.Header().Set("Content-Type", "application/json")
  833. reloadScheduled := reload != nil
  834. _ = json.NewEncoder(w).Encode(map[string]any{
  835. "ok": true,
  836. "saved": true,
  837. "reloadScheduled": reloadScheduled,
  838. })
  839. if reloadScheduled && s.reloadPending.CompareAndSwap(false, true) {
  840. go func(fn func()) {
  841. time.Sleep(250 * time.Millisecond)
  842. s.reloadPending.Store(false)
  843. fn()
  844. }(reload)
  845. }
  846. }