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.

221 line
6.0KB

  1. package control
  2. import (
  3. "encoding/json"
  4. "net/http"
  5. "sync"
  6. "github.com/jan/fm-rds-tx/internal/config"
  7. drypkg "github.com/jan/fm-rds-tx/internal/dryrun"
  8. "github.com/jan/fm-rds-tx/internal/platform"
  9. )
  10. // TXController is an optional interface the Server uses to start/stop TX.
  11. type TXController interface {
  12. StartTX() error
  13. StopTX() error
  14. TXStats() map[string]any
  15. }
  16. type Server struct {
  17. mu sync.RWMutex
  18. cfg config.Config
  19. tx TXController
  20. drv platform.SoapyDriver // optional, for runtime stats
  21. }
  22. type ConfigPatch struct {
  23. FrequencyMHz *float64 `json:"frequencyMHz,omitempty"`
  24. OutputDrive *float64 `json:"outputDrive,omitempty"`
  25. ToneLeftHz *float64 `json:"toneLeftHz,omitempty"`
  26. ToneRightHz *float64 `json:"toneRightHz,omitempty"`
  27. ToneAmplitude *float64 `json:"toneAmplitude,omitempty"`
  28. PS *string `json:"ps,omitempty"`
  29. RadioText *string `json:"radioText,omitempty"`
  30. PreEmphasisTauUS *float64 `json:"preEmphasisTauUS,omitempty"`
  31. LimiterEnabled *bool `json:"limiterEnabled,omitempty"`
  32. LimiterCeiling *float64 `json:"limiterCeiling,omitempty"`
  33. }
  34. func NewServer(cfg config.Config) *Server {
  35. return &Server{cfg: cfg}
  36. }
  37. func (s *Server) SetTXController(tx TXController) {
  38. s.mu.Lock()
  39. s.tx = tx
  40. s.mu.Unlock()
  41. }
  42. func (s *Server) SetDriver(drv platform.SoapyDriver) {
  43. s.mu.Lock()
  44. s.drv = drv
  45. s.mu.Unlock()
  46. }
  47. func (s *Server) Handler() http.Handler {
  48. mux := http.NewServeMux()
  49. mux.HandleFunc("/healthz", s.handleHealth)
  50. mux.HandleFunc("/status", s.handleStatus)
  51. mux.HandleFunc("/dry-run", s.handleDryRun)
  52. mux.HandleFunc("/config", s.handleConfig)
  53. mux.HandleFunc("/runtime", s.handleRuntime)
  54. mux.HandleFunc("/tx/start", s.handleTXStart)
  55. mux.HandleFunc("/tx/stop", s.handleTXStop)
  56. return mux
  57. }
  58. func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
  59. w.Header().Set("Content-Type", "application/json")
  60. _ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
  61. }
  62. func (s *Server) handleStatus(w http.ResponseWriter, _ *http.Request) {
  63. s.mu.RLock()
  64. cfg := s.cfg
  65. s.mu.RUnlock()
  66. w.Header().Set("Content-Type", "application/json")
  67. _ = json.NewEncoder(w).Encode(map[string]any{
  68. "service": "fm-rds-tx",
  69. "backend": cfg.Backend.Kind,
  70. "frequencyMHz": cfg.FM.FrequencyMHz,
  71. "stereoEnabled": cfg.FM.StereoEnabled,
  72. "rdsEnabled": cfg.RDS.Enabled,
  73. "preEmphasisTauUS": cfg.FM.PreEmphasisTauUS,
  74. "limiterEnabled": cfg.FM.LimiterEnabled,
  75. "fmModulationEnabled": cfg.FM.FMModulationEnabled,
  76. })
  77. }
  78. func (s *Server) handleRuntime(w http.ResponseWriter, _ *http.Request) {
  79. s.mu.RLock()
  80. drv := s.drv
  81. tx := s.tx
  82. s.mu.RUnlock()
  83. result := map[string]any{}
  84. if drv != nil {
  85. result["driver"] = drv.Stats()
  86. }
  87. if tx != nil {
  88. result["engine"] = tx.TXStats()
  89. }
  90. w.Header().Set("Content-Type", "application/json")
  91. _ = json.NewEncoder(w).Encode(result)
  92. }
  93. func (s *Server) handleTXStart(w http.ResponseWriter, r *http.Request) {
  94. if r.Method != http.MethodPost {
  95. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  96. return
  97. }
  98. s.mu.RLock()
  99. tx := s.tx
  100. s.mu.RUnlock()
  101. if tx == nil {
  102. http.Error(w, "tx controller not available", http.StatusServiceUnavailable)
  103. return
  104. }
  105. if err := tx.StartTX(); err != nil {
  106. http.Error(w, err.Error(), http.StatusConflict)
  107. return
  108. }
  109. w.Header().Set("Content-Type", "application/json")
  110. _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "action": "started"})
  111. }
  112. func (s *Server) handleTXStop(w http.ResponseWriter, r *http.Request) {
  113. if r.Method != http.MethodPost {
  114. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  115. return
  116. }
  117. s.mu.RLock()
  118. tx := s.tx
  119. s.mu.RUnlock()
  120. if tx == nil {
  121. http.Error(w, "tx controller not available", http.StatusServiceUnavailable)
  122. return
  123. }
  124. if err := tx.StopTX(); err != nil {
  125. http.Error(w, err.Error(), http.StatusInternalServerError)
  126. return
  127. }
  128. w.Header().Set("Content-Type", "application/json")
  129. _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "action": "stopped"})
  130. }
  131. func (s *Server) handleDryRun(w http.ResponseWriter, _ *http.Request) {
  132. s.mu.RLock()
  133. cfg := s.cfg
  134. s.mu.RUnlock()
  135. w.Header().Set("Content-Type", "application/json")
  136. _ = json.NewEncoder(w).Encode(drypkg.Generate(cfg))
  137. }
  138. func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
  139. switch r.Method {
  140. case http.MethodGet:
  141. s.mu.RLock()
  142. cfg := s.cfg
  143. s.mu.RUnlock()
  144. w.Header().Set("Content-Type", "application/json")
  145. _ = json.NewEncoder(w).Encode(cfg)
  146. case http.MethodPost:
  147. // TODO: config changes only update the control server's copy.
  148. // The running Engine/Generator holds its own snapshot and won't
  149. // pick up these changes until restarted. Wire up a hot-reload
  150. // path or document this limitation clearly for operators.
  151. var patch ConfigPatch
  152. if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
  153. http.Error(w, err.Error(), http.StatusBadRequest)
  154. return
  155. }
  156. s.mu.Lock()
  157. next := s.cfg
  158. if patch.FrequencyMHz != nil {
  159. next.FM.FrequencyMHz = *patch.FrequencyMHz
  160. }
  161. if patch.OutputDrive != nil {
  162. next.FM.OutputDrive = *patch.OutputDrive
  163. }
  164. if patch.ToneLeftHz != nil {
  165. next.Audio.ToneLeftHz = *patch.ToneLeftHz
  166. }
  167. if patch.ToneRightHz != nil {
  168. next.Audio.ToneRightHz = *patch.ToneRightHz
  169. }
  170. if patch.ToneAmplitude != nil {
  171. next.Audio.ToneAmplitude = *patch.ToneAmplitude
  172. }
  173. if patch.PS != nil {
  174. next.RDS.PS = *patch.PS
  175. }
  176. if patch.RadioText != nil {
  177. next.RDS.RadioText = *patch.RadioText
  178. }
  179. if patch.PreEmphasisTauUS != nil {
  180. next.FM.PreEmphasisTauUS = *patch.PreEmphasisTauUS
  181. }
  182. if patch.LimiterEnabled != nil {
  183. next.FM.LimiterEnabled = *patch.LimiterEnabled
  184. }
  185. if patch.LimiterCeiling != nil {
  186. next.FM.LimiterCeiling = *patch.LimiterCeiling
  187. }
  188. if err := next.Validate(); err != nil {
  189. s.mu.Unlock()
  190. http.Error(w, err.Error(), http.StatusBadRequest)
  191. return
  192. }
  193. s.cfg = next
  194. s.mu.Unlock()
  195. w.Header().Set("Content-Type", "application/json")
  196. _ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
  197. default:
  198. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  199. }
  200. }