Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

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