| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253 |
- package server
- import (
- "encoding/json"
- "fmt"
- "net/http"
- "time"
- "vocat/internal/device"
- "vocat/internal/store"
- )
- // beginSSE prepares a response for Server-Sent Events and returns its response
- // controller for explicit flushes.
- func beginSSE(w http.ResponseWriter) *http.ResponseController {
- controller := http.NewResponseController(w)
- _ = controller.SetWriteDeadline(time.Time{})
- w.Header().Set("Content-Type", "text/event-stream")
- w.Header().Set("Cache-Control", "no-cache, no-store")
- w.Header().Set("Connection", "keep-alive")
- w.Header().Set("X-Accel-Buffering", "no")
- w.WriteHeader(http.StatusOK)
- return controller
- }
- func writeSSEEvent(w http.ResponseWriter, controller *http.ResponseController, event string, data any) error {
- payload, err := json.Marshal(data)
- if err != nil {
- return err
- }
- if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, payload); err != nil {
- return err
- }
- return controller.Flush()
- }
- // handleOverviewStream pushes the device's overview as SSE events so the UI can
- // watch link/SIM/VoWiFi state change live instead of polling.
- func (s *Server) handleOverviewStream(
- w http.ResponseWriter,
- r *http.Request,
- config store.Device,
- entry device.Device,
- physicalPresent bool,
- ) bool {
- if !requireMethod(w, r, http.MethodGet) {
- return true
- }
- controller := beginSSE(w)
- if err := writeSSEEvent(w, controller, "connected", map[string]any{}); err != nil {
- return true
- }
- ticker := time.NewTicker(2 * time.Second)
- defer ticker.Stop()
- for {
- select {
- case <-r.Context().Done():
- return true
- case <-ticker.C:
- currentEntry, _, present := s.physicalForConfig(config)
- overview := s.configuredDeviceOverview(config, currentEntry, present)
- if err := writeSSEEvent(w, controller, "overview", overview); err != nil {
- return true
- }
- }
- }
- }
- // operatorCandidateWire maps a scanned network to the candidate shape the SPA
- // reads. The SSE stream is not run through the api client's camelizer, so keys
- // are emitted already-camelCase (the blocking endpoint's camelize leaves them
- // unchanged). includesPcsDigit is a North-American PCS PLMN concept the modem
- // layer does not derive, so it is always false here.
- func operatorCandidateWire(op device.ScannedOperator) map[string]any {
- rats := []string{}
- if op.Act != "" {
- rats = []string{op.Act}
- }
- return map[string]any{
- "status": op.Status,
- "operatorName": op.Name,
- "shortName": op.Short,
- "plmn": op.Numeric,
- "rats": rats,
- "includesPcsDigit": false,
- }
- }
- func operatorCandidatesWire(operators []device.ScannedOperator) []map[string]any {
- candidates := make([]map[string]any, 0, len(operators))
- for _, op := range operators {
- candidates = append(candidates, operatorCandidateWire(op))
- }
- return candidates
- }
- // handleOperatorScan runs a blocking, abortable operator scan and returns the
- // discovered networks in one response.
- func (s *Server) handleOperatorScan(w http.ResponseWriter, r *http.Request, physicalID string) bool {
- if !requireMethod(w, r, http.MethodGet) {
- return true
- }
- result, err := s.devices.ScanOperators(r.Context(), physicalID)
- if err != nil {
- s.writeDeviceError(w, err)
- return true
- }
- writeJSON(w, http.StatusOK, map[string]any{
- "data": map[string]any{
- "scanId": fmt.Sprintf("scan-%d", time.Now().UnixNano()),
- "status": result.Status,
- "candidates": operatorCandidatesWire(result.Operators),
- },
- })
- return true
- }
- // handleOperatorScanStream reports scan progress over SSE: an initial "running"
- // event followed by a terminal "complete" (with candidates) or "failed" event.
- func (s *Server) handleOperatorScanStream(w http.ResponseWriter, r *http.Request, physicalID string) bool {
- if !requireMethod(w, r, http.MethodGet) {
- return true
- }
- controller := beginSSE(w)
- scanID := fmt.Sprintf("scan-%d", time.Now().UnixNano())
- if err := writeSSEEvent(w, controller, "operator_scan", map[string]any{
- "scanId": scanID,
- "status": "running",
- }); err != nil {
- return true
- }
- result, err := s.devices.ScanOperators(r.Context(), physicalID)
- if err != nil {
- _ = writeSSEEvent(w, controller, "operator_scan", map[string]any{
- "scanId": scanID,
- "status": "failed",
- "message": err.Error(),
- "retryable": true,
- })
- return true
- }
- _ = writeSSEEvent(w, controller, "operator_scan", map[string]any{
- "scanId": scanID,
- "status": result.Status,
- "candidates": operatorCandidatesWire(result.Operators),
- })
- return true
- }
- // handleUSSDContinue continues an open USSD dialog. The session id (returned by
- // the initial ussd request) selects the device.
- func (s *Server) handleUSSDContinue(w http.ResponseWriter, r *http.Request) bool {
- if !requireMethod(w, r, http.MethodPost) {
- return true
- }
- var request struct {
- SessionID string `json:"session_id"`
- Session string `json:"sessionId"`
- Input string `json:"input"`
- Command string `json:"command"`
- TimeoutMs int `json:"timeout_ms"`
- }
- if err := s.decodeJSON(w, r, &request); err != nil {
- writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
- return true
- }
- sessionID := firstNonEmpty(request.SessionID, request.Session)
- if sessionID == "" {
- writeError(w, http.StatusBadRequest, "invalid_request", "session_id is required")
- return true
- }
- input := firstNonEmpty(request.Input, request.Command)
- ctx, cancel := actionRequestContext(r.Context(), request.TimeoutMs)
- defer cancel()
- result, err := s.devices.ContinueUSSD(ctx, sessionID, input)
- if err != nil {
- s.writeDeviceError(w, err)
- return true
- }
- writeUSSDResult(w, result)
- return true
- }
- // handleUSSDCancel aborts an open USSD dialog.
- func (s *Server) handleUSSDCancel(w http.ResponseWriter, r *http.Request) bool {
- if !requireMethod(w, r, http.MethodPost) {
- return true
- }
- var request struct {
- SessionID string `json:"session_id"`
- Session string `json:"sessionId"`
- }
- if err := s.decodeJSON(w, r, &request); err != nil {
- writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
- return true
- }
- sessionID := firstNonEmpty(request.SessionID, request.Session)
- if sessionID == "" {
- writeError(w, http.StatusBadRequest, "invalid_request", "session_id is required")
- return true
- }
- if err := s.devices.CancelUSSD(r.Context(), sessionID); err != nil {
- s.writeDeviceError(w, err)
- return true
- }
- writeJSON(w, http.StatusOK, map[string]any{
- "data": map[string]any{"cancelled": true, "session_id": sessionID},
- })
- return true
- }
- func writeUSSDResult(w http.ResponseWriter, result device.USSDResult) {
- writeJSON(w, http.StatusOK, map[string]any{
- "data": map[string]any{
- "result": map[string]any{
- "status": result.Status,
- "text": result.Text,
- "raw": result.Raw,
- "dcs": result.DCS,
- "continueable": result.Continueable,
- },
- "session_id": result.SessionID,
- },
- })
- }
- // handleFixUSBNet sets the USB network mode on a discovered-but-unmanaged modem,
- // addressed by its AT port. Used to rescue a modem stuck in the wrong USB mode
- // before it is taken over.
- func (s *Server) handleFixUSBNet(w http.ResponseWriter, r *http.Request) bool {
- if !requireMethod(w, r, http.MethodPost) {
- return true
- }
- var request struct {
- ATPort string `json:"at_port"`
- Mode *int `json:"mode"`
- }
- if err := s.decodeJSON(w, r, &request); err != nil {
- writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
- return true
- }
- mode := 0
- if request.Mode != nil {
- mode = *request.Mode
- }
- result, err := s.devices.SetUSBNetModeByPort(r.Context(), request.ATPort, mode)
- if err != nil {
- s.writeDeviceError(w, err)
- return true
- }
- writeJSON(w, http.StatusOK, map[string]any{"data": result})
- return true
- }
|