Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
No puede seleccionar más de 25 temas Los temas deben comenzar con una letra o número, pueden incluir guiones ('-') y pueden tener hasta 35 caracteres de largo.

259 líneas
7.7KB

  1. package control
  2. import (
  3. _ "embed"
  4. "encoding/json"
  5. "net/http"
  6. "sync"
  7. "github.com/jan/fm-rds-tx/internal/config"
  8. drypkg "github.com/jan/fm-rds-tx/internal/dryrun"
  9. "github.com/jan/fm-rds-tx/internal/platform"
  10. )
  11. //go:embed ui.html
  12. var uiHTML []byte
  13. // TXController is an optional interface the Server uses to start/stop TX
  14. // and apply live config changes.
  15. type TXController interface {
  16. StartTX() error
  17. StopTX() error
  18. TXStats() map[string]any
  19. UpdateConfig(patch LivePatch) error
  20. }
  21. // LivePatch mirrors the patchable fields from ConfigPatch for the engine.
  22. // nil = no change.
  23. type LivePatch struct {
  24. FrequencyMHz *float64
  25. OutputDrive *float64
  26. StereoEnabled *bool
  27. PilotLevel *float64
  28. RDSInjection *float64
  29. RDSEnabled *bool
  30. LimiterEnabled *bool
  31. LimiterCeiling *float64
  32. PS *string
  33. RadioText *string
  34. }
  35. type Server struct {
  36. mu sync.RWMutex
  37. cfg config.Config
  38. tx TXController
  39. drv platform.SoapyDriver // optional, for runtime stats
  40. }
  41. type ConfigPatch struct {
  42. FrequencyMHz *float64 `json:"frequencyMHz,omitempty"`
  43. OutputDrive *float64 `json:"outputDrive,omitempty"`
  44. StereoEnabled *bool `json:"stereoEnabled,omitempty"`
  45. PilotLevel *float64 `json:"pilotLevel,omitempty"`
  46. RDSInjection *float64 `json:"rdsInjection,omitempty"`
  47. RDSEnabled *bool `json:"rdsEnabled,omitempty"`
  48. ToneLeftHz *float64 `json:"toneLeftHz,omitempty"`
  49. ToneRightHz *float64 `json:"toneRightHz,omitempty"`
  50. ToneAmplitude *float64 `json:"toneAmplitude,omitempty"`
  51. PS *string `json:"ps,omitempty"`
  52. RadioText *string `json:"radioText,omitempty"`
  53. PreEmphasisTauUS *float64 `json:"preEmphasisTauUS,omitempty"`
  54. LimiterEnabled *bool `json:"limiterEnabled,omitempty"`
  55. LimiterCeiling *float64 `json:"limiterCeiling,omitempty"`
  56. }
  57. func NewServer(cfg config.Config) *Server {
  58. return &Server{cfg: cfg}
  59. }
  60. func (s *Server) SetTXController(tx TXController) {
  61. s.mu.Lock()
  62. s.tx = tx
  63. s.mu.Unlock()
  64. }
  65. func (s *Server) SetDriver(drv platform.SoapyDriver) {
  66. s.mu.Lock()
  67. s.drv = drv
  68. s.mu.Unlock()
  69. }
  70. func (s *Server) Handler() http.Handler {
  71. mux := http.NewServeMux()
  72. mux.HandleFunc("/", s.handleUI)
  73. mux.HandleFunc("/healthz", s.handleHealth)
  74. mux.HandleFunc("/status", s.handleStatus)
  75. mux.HandleFunc("/dry-run", s.handleDryRun)
  76. mux.HandleFunc("/config", s.handleConfig)
  77. mux.HandleFunc("/runtime", s.handleRuntime)
  78. mux.HandleFunc("/tx/start", s.handleTXStart)
  79. mux.HandleFunc("/tx/stop", s.handleTXStop)
  80. return mux
  81. }
  82. func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
  83. w.Header().Set("Content-Type", "application/json")
  84. _ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
  85. }
  86. func (s *Server) handleUI(w http.ResponseWriter, r *http.Request) {
  87. if r.URL.Path != "/" {
  88. http.NotFound(w, r)
  89. return
  90. }
  91. w.Header().Set("Content-Type", "text/html; charset=utf-8")
  92. w.Header().Set("Cache-Control", "no-cache")
  93. w.Write(uiHTML)
  94. }
  95. func (s *Server) handleStatus(w http.ResponseWriter, _ *http.Request) {
  96. s.mu.RLock()
  97. cfg := s.cfg
  98. s.mu.RUnlock()
  99. w.Header().Set("Content-Type", "application/json")
  100. _ = json.NewEncoder(w).Encode(map[string]any{
  101. "service": "fm-rds-tx",
  102. "backend": cfg.Backend.Kind,
  103. "frequencyMHz": cfg.FM.FrequencyMHz,
  104. "stereoEnabled": cfg.FM.StereoEnabled,
  105. "rdsEnabled": cfg.RDS.Enabled,
  106. "preEmphasisTauUS": cfg.FM.PreEmphasisTauUS,
  107. "limiterEnabled": cfg.FM.LimiterEnabled,
  108. "fmModulationEnabled": cfg.FM.FMModulationEnabled,
  109. })
  110. }
  111. func (s *Server) handleRuntime(w http.ResponseWriter, _ *http.Request) {
  112. s.mu.RLock()
  113. drv := s.drv
  114. tx := s.tx
  115. s.mu.RUnlock()
  116. result := map[string]any{}
  117. if drv != nil {
  118. result["driver"] = drv.Stats()
  119. }
  120. if tx != nil {
  121. result["engine"] = tx.TXStats()
  122. }
  123. w.Header().Set("Content-Type", "application/json")
  124. _ = json.NewEncoder(w).Encode(result)
  125. }
  126. func (s *Server) handleTXStart(w http.ResponseWriter, r *http.Request) {
  127. if r.Method != http.MethodPost {
  128. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  129. return
  130. }
  131. s.mu.RLock()
  132. tx := s.tx
  133. s.mu.RUnlock()
  134. if tx == nil {
  135. http.Error(w, "tx controller not available", http.StatusServiceUnavailable)
  136. return
  137. }
  138. if err := tx.StartTX(); err != nil {
  139. http.Error(w, err.Error(), http.StatusConflict)
  140. return
  141. }
  142. w.Header().Set("Content-Type", "application/json")
  143. _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "action": "started"})
  144. }
  145. func (s *Server) handleTXStop(w http.ResponseWriter, r *http.Request) {
  146. if r.Method != http.MethodPost {
  147. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  148. return
  149. }
  150. s.mu.RLock()
  151. tx := s.tx
  152. s.mu.RUnlock()
  153. if tx == nil {
  154. http.Error(w, "tx controller not available", http.StatusServiceUnavailable)
  155. return
  156. }
  157. if err := tx.StopTX(); err != nil {
  158. http.Error(w, err.Error(), http.StatusInternalServerError)
  159. return
  160. }
  161. w.Header().Set("Content-Type", "application/json")
  162. _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "action": "stopped"})
  163. }
  164. func (s *Server) handleDryRun(w http.ResponseWriter, _ *http.Request) {
  165. s.mu.RLock()
  166. cfg := s.cfg
  167. s.mu.RUnlock()
  168. w.Header().Set("Content-Type", "application/json")
  169. _ = json.NewEncoder(w).Encode(drypkg.Generate(cfg))
  170. }
  171. func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
  172. switch r.Method {
  173. case http.MethodGet:
  174. s.mu.RLock()
  175. cfg := s.cfg
  176. s.mu.RUnlock()
  177. w.Header().Set("Content-Type", "application/json")
  178. _ = json.NewEncoder(w).Encode(cfg)
  179. case http.MethodPost:
  180. var patch ConfigPatch
  181. if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
  182. http.Error(w, err.Error(), http.StatusBadRequest)
  183. return
  184. }
  185. // Update the server's config snapshot (for GET /config and /status)
  186. s.mu.Lock()
  187. next := s.cfg
  188. if patch.FrequencyMHz != nil { next.FM.FrequencyMHz = *patch.FrequencyMHz }
  189. if patch.OutputDrive != nil { next.FM.OutputDrive = *patch.OutputDrive }
  190. if patch.ToneLeftHz != nil { next.Audio.ToneLeftHz = *patch.ToneLeftHz }
  191. if patch.ToneRightHz != nil { next.Audio.ToneRightHz = *patch.ToneRightHz }
  192. if patch.ToneAmplitude != nil { next.Audio.ToneAmplitude = *patch.ToneAmplitude }
  193. if patch.PS != nil { next.RDS.PS = *patch.PS }
  194. if patch.RadioText != nil { next.RDS.RadioText = *patch.RadioText }
  195. if patch.PreEmphasisTauUS != nil { next.FM.PreEmphasisTauUS = *patch.PreEmphasisTauUS }
  196. if patch.StereoEnabled != nil { next.FM.StereoEnabled = *patch.StereoEnabled }
  197. if patch.LimiterEnabled != nil { next.FM.LimiterEnabled = *patch.LimiterEnabled }
  198. if patch.LimiterCeiling != nil { next.FM.LimiterCeiling = *patch.LimiterCeiling }
  199. if patch.RDSEnabled != nil { next.RDS.Enabled = *patch.RDSEnabled }
  200. if patch.PilotLevel != nil { next.FM.PilotLevel = *patch.PilotLevel }
  201. if patch.RDSInjection != nil { next.FM.RDSInjection = *patch.RDSInjection }
  202. if err := next.Validate(); err != nil {
  203. s.mu.Unlock()
  204. http.Error(w, err.Error(), http.StatusBadRequest)
  205. return
  206. }
  207. s.cfg = next
  208. tx := s.tx
  209. s.mu.Unlock()
  210. // Forward live-patchable params to running engine (if active)
  211. if tx != nil {
  212. lp := LivePatch{
  213. FrequencyMHz: patch.FrequencyMHz,
  214. OutputDrive: patch.OutputDrive,
  215. StereoEnabled: patch.StereoEnabled,
  216. PilotLevel: patch.PilotLevel,
  217. RDSInjection: patch.RDSInjection,
  218. RDSEnabled: patch.RDSEnabled,
  219. LimiterEnabled: patch.LimiterEnabled,
  220. LimiterCeiling: patch.LimiterCeiling,
  221. PS: patch.PS,
  222. RadioText: patch.RadioText,
  223. }
  224. if err := tx.UpdateConfig(lp); err != nil {
  225. http.Error(w, err.Error(), http.StatusBadRequest)
  226. return
  227. }
  228. }
  229. w.Header().Set("Content-Type", "application/json")
  230. _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "live": tx != nil})
  231. default:
  232. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  233. }
  234. }