device_features_api.go 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253
  1. package server
  2. import (
  3. "encoding/json"
  4. "fmt"
  5. "net/http"
  6. "time"
  7. "vocat/internal/device"
  8. "vocat/internal/store"
  9. )
  10. // beginSSE prepares a response for Server-Sent Events and returns its response
  11. // controller for explicit flushes.
  12. func beginSSE(w http.ResponseWriter) *http.ResponseController {
  13. controller := http.NewResponseController(w)
  14. _ = controller.SetWriteDeadline(time.Time{})
  15. w.Header().Set("Content-Type", "text/event-stream")
  16. w.Header().Set("Cache-Control", "no-cache, no-store")
  17. w.Header().Set("Connection", "keep-alive")
  18. w.Header().Set("X-Accel-Buffering", "no")
  19. w.WriteHeader(http.StatusOK)
  20. return controller
  21. }
  22. func writeSSEEvent(w http.ResponseWriter, controller *http.ResponseController, event string, data any) error {
  23. payload, err := json.Marshal(data)
  24. if err != nil {
  25. return err
  26. }
  27. if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, payload); err != nil {
  28. return err
  29. }
  30. return controller.Flush()
  31. }
  32. // handleOverviewStream pushes the device's overview as SSE events so the UI can
  33. // watch link/SIM/VoWiFi state change live instead of polling.
  34. func (s *Server) handleOverviewStream(
  35. w http.ResponseWriter,
  36. r *http.Request,
  37. config store.Device,
  38. entry device.Device,
  39. physicalPresent bool,
  40. ) bool {
  41. if !requireMethod(w, r, http.MethodGet) {
  42. return true
  43. }
  44. controller := beginSSE(w)
  45. if err := writeSSEEvent(w, controller, "connected", map[string]any{}); err != nil {
  46. return true
  47. }
  48. ticker := time.NewTicker(2 * time.Second)
  49. defer ticker.Stop()
  50. for {
  51. select {
  52. case <-r.Context().Done():
  53. return true
  54. case <-ticker.C:
  55. currentEntry, _, present := s.physicalForConfig(config)
  56. overview := s.configuredDeviceOverview(config, currentEntry, present)
  57. if err := writeSSEEvent(w, controller, "overview", overview); err != nil {
  58. return true
  59. }
  60. }
  61. }
  62. }
  63. // operatorCandidateWire maps a scanned network to the candidate shape the SPA
  64. // reads. The SSE stream is not run through the api client's camelizer, so keys
  65. // are emitted already-camelCase (the blocking endpoint's camelize leaves them
  66. // unchanged). includesPcsDigit is a North-American PCS PLMN concept the modem
  67. // layer does not derive, so it is always false here.
  68. func operatorCandidateWire(op device.ScannedOperator) map[string]any {
  69. rats := []string{}
  70. if op.Act != "" {
  71. rats = []string{op.Act}
  72. }
  73. return map[string]any{
  74. "status": op.Status,
  75. "operatorName": op.Name,
  76. "shortName": op.Short,
  77. "plmn": op.Numeric,
  78. "rats": rats,
  79. "includesPcsDigit": false,
  80. }
  81. }
  82. func operatorCandidatesWire(operators []device.ScannedOperator) []map[string]any {
  83. candidates := make([]map[string]any, 0, len(operators))
  84. for _, op := range operators {
  85. candidates = append(candidates, operatorCandidateWire(op))
  86. }
  87. return candidates
  88. }
  89. // handleOperatorScan runs a blocking, abortable operator scan and returns the
  90. // discovered networks in one response.
  91. func (s *Server) handleOperatorScan(w http.ResponseWriter, r *http.Request, physicalID string) bool {
  92. if !requireMethod(w, r, http.MethodGet) {
  93. return true
  94. }
  95. result, err := s.devices.ScanOperators(r.Context(), physicalID)
  96. if err != nil {
  97. s.writeDeviceError(w, err)
  98. return true
  99. }
  100. writeJSON(w, http.StatusOK, map[string]any{
  101. "data": map[string]any{
  102. "scanId": fmt.Sprintf("scan-%d", time.Now().UnixNano()),
  103. "status": result.Status,
  104. "candidates": operatorCandidatesWire(result.Operators),
  105. },
  106. })
  107. return true
  108. }
  109. // handleOperatorScanStream reports scan progress over SSE: an initial "running"
  110. // event followed by a terminal "complete" (with candidates) or "failed" event.
  111. func (s *Server) handleOperatorScanStream(w http.ResponseWriter, r *http.Request, physicalID string) bool {
  112. if !requireMethod(w, r, http.MethodGet) {
  113. return true
  114. }
  115. controller := beginSSE(w)
  116. scanID := fmt.Sprintf("scan-%d", time.Now().UnixNano())
  117. if err := writeSSEEvent(w, controller, "operator_scan", map[string]any{
  118. "scanId": scanID,
  119. "status": "running",
  120. }); err != nil {
  121. return true
  122. }
  123. result, err := s.devices.ScanOperators(r.Context(), physicalID)
  124. if err != nil {
  125. _ = writeSSEEvent(w, controller, "operator_scan", map[string]any{
  126. "scanId": scanID,
  127. "status": "failed",
  128. "message": err.Error(),
  129. "retryable": true,
  130. })
  131. return true
  132. }
  133. _ = writeSSEEvent(w, controller, "operator_scan", map[string]any{
  134. "scanId": scanID,
  135. "status": result.Status,
  136. "candidates": operatorCandidatesWire(result.Operators),
  137. })
  138. return true
  139. }
  140. // handleUSSDContinue continues an open USSD dialog. The session id (returned by
  141. // the initial ussd request) selects the device.
  142. func (s *Server) handleUSSDContinue(w http.ResponseWriter, r *http.Request) bool {
  143. if !requireMethod(w, r, http.MethodPost) {
  144. return true
  145. }
  146. var request struct {
  147. SessionID string `json:"session_id"`
  148. Session string `json:"sessionId"`
  149. Input string `json:"input"`
  150. Command string `json:"command"`
  151. TimeoutMs int `json:"timeout_ms"`
  152. }
  153. if err := s.decodeJSON(w, r, &request); err != nil {
  154. writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
  155. return true
  156. }
  157. sessionID := firstNonEmpty(request.SessionID, request.Session)
  158. if sessionID == "" {
  159. writeError(w, http.StatusBadRequest, "invalid_request", "session_id is required")
  160. return true
  161. }
  162. input := firstNonEmpty(request.Input, request.Command)
  163. ctx, cancel := actionRequestContext(r.Context(), request.TimeoutMs)
  164. defer cancel()
  165. result, err := s.devices.ContinueUSSD(ctx, sessionID, input)
  166. if err != nil {
  167. s.writeDeviceError(w, err)
  168. return true
  169. }
  170. writeUSSDResult(w, result)
  171. return true
  172. }
  173. // handleUSSDCancel aborts an open USSD dialog.
  174. func (s *Server) handleUSSDCancel(w http.ResponseWriter, r *http.Request) bool {
  175. if !requireMethod(w, r, http.MethodPost) {
  176. return true
  177. }
  178. var request struct {
  179. SessionID string `json:"session_id"`
  180. Session string `json:"sessionId"`
  181. }
  182. if err := s.decodeJSON(w, r, &request); err != nil {
  183. writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
  184. return true
  185. }
  186. sessionID := firstNonEmpty(request.SessionID, request.Session)
  187. if sessionID == "" {
  188. writeError(w, http.StatusBadRequest, "invalid_request", "session_id is required")
  189. return true
  190. }
  191. if err := s.devices.CancelUSSD(r.Context(), sessionID); err != nil {
  192. s.writeDeviceError(w, err)
  193. return true
  194. }
  195. writeJSON(w, http.StatusOK, map[string]any{
  196. "data": map[string]any{"cancelled": true, "session_id": sessionID},
  197. })
  198. return true
  199. }
  200. func writeUSSDResult(w http.ResponseWriter, result device.USSDResult) {
  201. writeJSON(w, http.StatusOK, map[string]any{
  202. "data": map[string]any{
  203. "result": map[string]any{
  204. "status": result.Status,
  205. "text": result.Text,
  206. "raw": result.Raw,
  207. "dcs": result.DCS,
  208. "continueable": result.Continueable,
  209. },
  210. "session_id": result.SessionID,
  211. },
  212. })
  213. }
  214. // handleFixUSBNet sets the USB network mode on a discovered-but-unmanaged modem,
  215. // addressed by its AT port. Used to rescue a modem stuck in the wrong USB mode
  216. // before it is taken over.
  217. func (s *Server) handleFixUSBNet(w http.ResponseWriter, r *http.Request) bool {
  218. if !requireMethod(w, r, http.MethodPost) {
  219. return true
  220. }
  221. var request struct {
  222. ATPort string `json:"at_port"`
  223. Mode *int `json:"mode"`
  224. }
  225. if err := s.decodeJSON(w, r, &request); err != nil {
  226. writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
  227. return true
  228. }
  229. mode := 0
  230. if request.Mode != nil {
  231. mode = *request.Mode
  232. }
  233. result, err := s.devices.SetUSBNetModeByPort(r.Context(), request.ATPort, mode)
  234. if err != nil {
  235. s.writeDeviceError(w, err)
  236. return true
  237. }
  238. writeJSON(w, http.StatusOK, map[string]any{"data": result})
  239. return true
  240. }