package server import ( "context" "encoding/csv" "encoding/json" "errors" "fmt" "net/http" "net/url" "strconv" "strings" "time" "vocat/internal/developer" "vocat/internal/device" "vocat/internal/i18n" "vocat/internal/modem" "vocat/internal/store" "vocat/internal/vowifi" vowifiruntime "vocat/internal/vowifi/runtime" ) // DeviceController is the narrow hardware boundary used by the HTTP layer. // device.Manager implements it; tests can provide a transcript-backed fake. type DeviceController interface { Discover(context.Context) ([]device.Device, error) List() []device.Device Get(string) (device.Device, error) Refresh(context.Context, string) (device.Snapshot, error) ExecuteAT(context.Context, string, string) (modem.Response, error) Reboot(context.Context, string) error USSD(context.Context, string, string) (device.USSDResult, error) ContinueUSSD(context.Context, string, string) (device.USSDResult, error) CancelUSSD(context.Context, string) error SetFlight(context.Context, string, bool) (device.FlightResult, error) SetNetwork(context.Context, string, device.NetworkRequest) (device.NetworkResult, error) USBNetMode(context.Context, string) (device.USBNetMode, error) SetUSBNetMode(context.Context, string, int) (device.USBNetMode, error) SetUSBNetModeByPort(context.Context, string, int) (device.USBNetMode, error) OperatorSelection(context.Context, string) (device.OperatorSelection, error) SetOperatorSelection(context.Context, string, bool, string, *int) (device.OperatorSelection, error) ReRegisterOperator(context.Context, string) (device.OperatorSelection, error) ScanOperators(context.Context, string) (device.OperatorScanResult, error) SendSMS(context.Context, string, string, string) (device.SMSSendResult, error) ListSMS(context.Context, string) ([]device.SMSMessage, error) ReadSMS(context.Context, string, int) (device.SMSMessage, error) DeleteSMS(context.Context, string, int) error ESIMInventory(context.Context, string) ([]device.EsimInventoryEntry, error) ESIMListProfiles(context.Context, string) (device.EsimInfo, error) ESIMSwitchProfile(context.Context, string, string, string) error ESIMDisableProfile(context.Context, string, string, string) error ESIMRenameProfile(context.Context, string, string, string, string) error ESIMDownloadProfile(context.Context, string, device.EsimDownloadParams, func(device.EsimProgress)) (*device.EsimDownloadResult, error) ESIMDeleteProfile(context.Context, string, string, string) (*device.EsimDeleteResult, error) ESIMChipInfo(context.Context, string) (*device.EsimChipInfo, error) } type deviceConfigPayload struct { ID string `json:"id"` Name string `json:"name"` DeviceType string `json:"device_type"` Interface string `json:"interface"` ControlDevice string `json:"control_device"` ATPort string `json:"at_port"` USBPath string `json:"usb_path"` AudioDevice string `json:"audio_device"` ModemIMEI string `json:"modem_imei"` SIMPIN string `json:"sim_pin"` APN string `json:"apn"` ProxyPort int `json:"proxy_port"` BaudRate int `json:"baud_rate"` DataBits int `json:"data_bits"` StopBits int `json:"stop_bits"` Parity string `json:"parity"` DeviceBackend string `json:"device_backend"` ESIMTransport string `json:"esim_transport"` QMIUseProxy bool `json:"qmi_use_proxy"` QMIProxyPath string `json:"qmi_proxy_path"` QMIProxyExecutable string `json:"qmi_proxy_executable"` NetworkEnabled bool `json:"network_enabled"` SMSEnabled bool `json:"sms_enabled"` VoWiFiEnabled bool `json:"vowifi_enabled"` } func (payload deviceConfigPayload) toStoreDevice() store.Device { name := strings.TrimSpace(payload.Name) if name == "" { name = payload.ID } return store.Device{ ID: strings.TrimSpace(payload.ID), Name: name, DeviceType: store.NormalizeDeviceType(payload.DeviceType), Interface: strings.TrimSpace(payload.Interface), ControlDevice: strings.TrimSpace(payload.ControlDevice), ATPort: strings.TrimSpace(payload.ATPort), USBPath: strings.TrimSpace(payload.USBPath), AudioDevice: strings.TrimSpace(payload.AudioDevice), ModemIMEI: strings.TrimSpace(payload.ModemIMEI), SIMPIN: strings.TrimSpace(payload.SIMPIN), APN: strings.TrimSpace(payload.APN), ProxyPort: payload.ProxyPort, BaudRate: payload.BaudRate, DataBits: payload.DataBits, StopBits: payload.StopBits, Parity: payload.Parity, DeviceBackend: payload.DeviceBackend, ESIMTransport: payload.ESIMTransport, QMIUseProxy: payload.QMIUseProxy, QMIProxyPath: strings.TrimSpace(payload.QMIProxyPath), QMIProxyExecutable: strings.TrimSpace(payload.QMIProxyExecutable), NetworkEnabled: payload.NetworkEnabled, SMSEnabled: payload.SMSEnabled, VoWiFiEnabled: payload.VoWiFiEnabled, } } func validDeviceID(value string) bool { value = strings.TrimSpace(value) if len(value) < 1 || len(value) > 64 { return false } for index, character := range value { if character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' || character >= '0' && character <= '9' || (index > 0 && (character == '.' || character == '_' || character == '-')) { continue } return false } return true } func (s *Server) routeDeviceAPI(w http.ResponseWriter, r *http.Request) bool { cleanPath := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api"), "/") switch cleanPath { case "dashboard/devices": if !requireMethod(w, r, http.MethodGet) { return true } writeJSON(w, http.StatusOK, map[string]any{"data": s.dashboardDevices()}) return true case "devices": return s.handleDevices(w, r) case "devices/discovered": return s.handleDiscoveredDevices(w, r) case "devices/actions/rescan": return s.handleDeviceRescan(w, r) case "device-mgmt/discovered/fix-usbnet": return s.handleFixUSBNet(w, r) } segments := splitAPIPath(cleanPath) if len(segments) >= 2 && segments[0] == "devices" { id := segments[1] if id == "" { writeError(w, http.StatusBadRequest, "invalid_device", "device ID is empty") return true } return s.handleDevicePath(w, r, id, segments[2:]) } return false } func splitAPIPath(value string) []string { raw := strings.Split(value, "/") result := make([]string, 0, len(raw)) for _, segment := range raw { decoded, err := url.PathUnescape(segment) if err != nil { decoded = segment } result = append(result, decoded) } return result } func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) bool { deviceLimit := developer.DeviceLimit(r.Context(), s.store, s.developerEnabled) switch r.Method { case http.MethodGet: writeJSON(w, http.StatusOK, map[string]any{ "data": map[string]any{ "device_limit": deviceLimit, "devices": s.deviceSummaries(), }, }) case http.MethodPost: if s.devices == nil { writeError(w, http.StatusServiceUnavailable, "device_manager_unavailable", "device manager is unavailable") return true } var request struct { Config json.RawMessage `json:"config"` } if err := s.decodeJSON(w, r, &request); err != nil { writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) return true } var payload deviceConfigPayload if err := json.Unmarshal(request.Config, &payload); err != nil { writeError(w, http.StatusBadRequest, "invalid_device_config", "device config must be a valid JSON object") return true } if !validDeviceID(payload.ID) { writeError(w, http.StatusBadRequest, "invalid_device_id", "device ID must use 1-64 letters, digits, dots, underscores, or hyphens") return true } if strings.TrimSpace(payload.DeviceType) == "" || store.NormalizeDeviceType(payload.DeviceType) == "" { writeError(w, http.StatusBadRequest, "invalid_device_type", "select a supported device type") return true } if _, err := s.store.Device(r.Context(), payload.ID); err == nil { writeError(w, http.StatusConflict, "device_exists", "a device with this ID already exists") return true } else if !errors.Is(err, store.ErrNotFound) { s.writeStoreError(w, err) return true } configured, err := s.store.ListDevices(r.Context()) if err != nil { s.writeStoreError(w, err) return true } if len(configured) >= deviceLimit { writeError(w, http.StatusConflict, "device_limit_reached", i18n.Tf("设备数量已达上限,最多只能添加 %d 台设备", deviceLimit)) return true } devices, err := s.devices.Discover(r.Context()) if err != nil { s.writeDeviceError(w, err) return true } selected := findDiscoveredDevice(devices, payload) if selected == nil { writeError(w, http.StatusNotFound, "device_not_found", "the selected Linux modem was not discovered") return true } config := payload.toStoreDevice() // Newly added hardware starts fail-closed: RF is disabled immediately and // VoWiFi becomes the desired service. Cellular registration is only // restored by the user's later airplane-mode-off action. config.VoWiFiEnabled = true config.NetworkEnabled = false if !s.developerActive(r.Context()) { config.NetworkEnabled = false } fillConfigFromPhysical(&config, *selected) if pinSetter, ok := s.devices.(interface{ SetSIMPin(string, string) error }); ok { if err := pinSetter.SetSIMPin(selected.ID, config.SIMPIN); err != nil { s.writeDeviceError(w, err) return true } } if selector, ok := s.devices.(interface{ SetBackend(string, string) error }); ok { if err := selector.SetBackend(selected.ID, config.DeviceBackend); err != nil { s.writeDeviceError(w, err) return true } } if _, err := s.devices.SetFlight(r.Context(), selected.ID, true); err != nil { s.writeDeviceError(w, err) return true } if err := s.store.UpsertDevice(r.Context(), config); err != nil { s.writeStoreError(w, err) return true } if selected.Snapshot != nil { iccid := strings.TrimSpace(selected.Snapshot.ICCID) if iccid != "" { _, policyErr := s.store.CardPolicy(r.Context(), iccid) if errors.Is(policyErr, store.ErrNotFound) { policyErr = s.store.UpsertCardPolicy(r.Context(), store.CardPolicy{ ICCID: iccid, VoWiFiEnabled: true, AirplaneEnabled: true, IPVersion: "IPV4V6", Source: "default", }) } if policyErr != nil { s.writeStoreError(w, policyErr) return true } } } if s.vowifi != nil { if _, err := s.vowifi.RequestEnabled(config.ID, true); err != nil { s.logger.Warn("new device saved in safe airplane mode but VoWiFi start was not queued", "device_id", config.ID, "error", err) } } writeJSON(w, http.StatusCreated, map[string]any{ "data": map[string]any{ "status": "created", "id": config.ID, "discovery_key": selected.ID, "physical_device": s.configuredDeviceSummary(config, selected), }, }) default: w.Header().Set("Allow", "GET, POST") writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") } return true } func findDiscoveredDevice(devices []device.Device, config deviceConfigPayload) *device.Device { if config.ModemIMEI != "" { for index := range devices { if devices[index].Snapshot != nil && devices[index].Snapshot.IMEI == config.ModemIMEI { return &devices[index] } } } if config.USBPath != "" { for index := range devices { if devices[index].Candidate.USBPath == config.USBPath { return &devices[index] } } } if config.ControlDevice != "" { for index := range devices { if devices[index].Candidate.QMIControl == config.ControlDevice { return &devices[index] } } } for index := range devices { candidate := devices[index].Candidate if config.ATPort != "" && (candidate.ATPort.Path == config.ATPort || candidate.ATPort.OpenPath() == config.ATPort) { return &devices[index] } } return nil } func (s *Server) handleDiscoveredDevices(w http.ResponseWriter, r *http.Request) bool { if !requireMethod(w, r, http.MethodGet) { return true } if s.devices == nil { writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"devices": []any{}}}) return true } devices := s.devices.List() configured, err := s.store.ListDevices(r.Context()) if err != nil { s.writeStoreError(w, err) return true } result := make([]map[string]any, 0, len(devices)) for _, entry := range devices { candidate := entry.Candidate atPorts := make([]string, 0, len(candidate.Ports)) for _, port := range candidate.Ports { if port.Role != modem.PortRoleDiagnostic { atPorts = append(atPorts, port.OpenPath()) } } controlPath := candidate.QMIControl if controlPath == "" { controlPath = candidate.ATPort.OpenPath() } configuredID := "" for _, config := range configured { if physicalMatchesConfig(entry, config) { configuredID = config.ID break } } result = append(result, map[string]any{ "hardware_kind": candidate.HardwareKind, "reader_name": candidate.ReaderName, "discovery_key": entry.ID, "control_path": controlPath, "net_interface": candidate.NetworkInterface, "usb_path": candidate.USBPath, "vendor_id": parseHexID(candidate.VendorID), "product_id": parseHexID(candidate.ProductID), "driver_name": "", "at_ports": atPorts, "at_port": candidate.ATPort.OpenPath(), "imei": snapshotString(entry.Snapshot, func(snapshot *device.Snapshot) string { return snapshot.IMEI }), "mode": backendMode(candidate), "network_capable": candidate.HardwareKind != "pcsc" && (candidate.NetworkInterface != "" || candidate.QMIControl != ""), "configured": configuredID != "", "configured_id": configuredID, "degraded": candidate.HardwareKind != "pcsc" && !candidate.HasATPort(), }) } writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"devices": result}}) return true } func parseHexID(value string) int64 { value = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(value)), "0x") number, _ := strconv.ParseInt(value, 16, 64) return number } func (s *Server) handleDeviceRescan(w http.ResponseWriter, r *http.Request) bool { if !requireMethod(w, r, http.MethodPost) { return true } if s.devices == nil { writeError(w, http.StatusServiceUnavailable, "device_manager_unavailable", "device manager is unavailable") return true } devices, err := s.devices.Discover(r.Context()) if err != nil { s.writeDeviceError(w, err) return true } writeJSON(w, http.StatusOK, map[string]any{ "data": map[string]any{"status": "ok", "devices": len(devices)}, }) return true } func (s *Server) handleDevicePath( w http.ResponseWriter, r *http.Request, id string, tail []string, ) bool { config, err := s.store.Device(r.Context(), id) if err != nil { s.writeStoreError(w, err) return true } if len(tail) == 0 { switch r.Method { case http.MethodDelete: if err := s.store.DeleteDevice(r.Context(), id); err != nil { s.writeStoreError(w, err) return true } writeJSON(w, http.StatusOK, map[string]any{ "data": map[string]any{"deleted": true, "physical_device_untouched": true}, }) case http.MethodPut: var request struct { Config json.RawMessage `json:"config"` } if err := s.decodeJSON(w, r, &request); err != nil { writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) return true } var payload deviceConfigPayload if err := json.Unmarshal(request.Config, &payload); err != nil { writeError(w, http.StatusBadRequest, "invalid_device_config", "device config must be a valid JSON object") return true } if payload.ID != "" && payload.ID != id { writeError(w, http.StatusConflict, "immutable_device_id", "device ID cannot be changed") return true } next := payload.toStoreDevice() if !s.developerActive(r.Context()) { next.NetworkEnabled = false } next.ID = id next.CreatedAt = config.CreatedAt // VoWiFi/airplane transitions are transactional device actions. A // general config save must not silently bypass their RF-safe ordering. next.VoWiFiEnabled = config.VoWiFiEnabled if next.Name == id && strings.TrimSpace(payload.Name) == "" { next.Name = config.Name } if next.SIMPIN == "" || next.SIMPIN == store.SecretMask { next.SIMPIN = config.SIMPIN } if _, physicalID, present := s.physicalForConfig(next); present { if pinSetter, ok := s.devices.(interface{ SetSIMPin(string, string) error }); ok { if err := pinSetter.SetSIMPin(physicalID, next.SIMPIN); err != nil { s.writeDeviceError(w, err) return true } } if selector, ok := s.devices.(interface{ SetBackend(string, string) error }); ok { if err := selector.SetBackend(physicalID, next.DeviceBackend); err != nil { s.writeDeviceError(w, err) return true } } } if err := s.store.UpsertDevice(r.Context(), next); err != nil { s.writeStoreError(w, err) return true } writeJSON(w, http.StatusOK, map[string]any{ "data": map[string]any{"status": "saved", "config": storedDeviceConfig(next)}, }) default: w.Header().Set("Allow", "DELETE, PUT") writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") } return true } entry, physicalID, physicalPresent := s.physicalForConfig(config) if config.DeviceType == store.DeviceTypeUSBSIMReader && len(tail) > 0 { operation := strings.Join(tail, "/") unsupported := tail[0] == "network" || tail[0] == "operator_selection" || operation == "actions/at" || operation == "actions/ussd" || operation == "actions/ussd/continue" || operation == "actions/ussd/cancel" || operation == "actions/reboot" || operation == "usbnet-mode" if unsupported { writeError(w, http.StatusConflict, "wifi_calling_only_device", "USB SIM readers support WiFi Calling, IMS SMS and calls only") return true } } if len(tail) > 0 && tail[0] == "esim" { return s.handleESIM(w, r, tail[1:], physicalID, physicalPresent, config.ID) } switch strings.Join(tail, "/") { case "overview": if !requireMethod(w, r, http.MethodGet) { return true } writeJSON(w, http.StatusOK, map[string]any{ "data": map[string]any{"devices": []any{s.configuredDeviceOverview(config, entry, physicalPresent)}}, }) case "overview/stream": return s.handleOverviewStream(w, r, config, entry, physicalPresent) case "status": if !requireMethod(w, r, http.MethodGet) { return true } writeJSON(w, http.StatusOK, map[string]any{"data": s.configuredDeviceStatus(config, entry, physicalPresent)}) case "config": if !requireMethod(w, r, http.MethodGet) { return true } writeJSON(w, http.StatusOK, map[string]any{ "data": map[string]any{"config": storedDeviceConfig(config)}, }) case "actions/refresh": if !requireMethod(w, r, http.MethodPost) { return true } if !s.requirePhysicalDevice(w, physicalPresent) { return true } snapshot, err := s.devices.Refresh(r.Context(), physicalID) if err != nil { s.writeDeviceError(w, err) return true } writeJSON(w, http.StatusOK, map[string]any{"data": snapshot}) case "actions/at": if !s.requirePhysicalDevice(w, physicalPresent) { return true } return s.handleAT(w, r, physicalID) case "actions/ussd": if !s.requirePhysicalDevice(w, physicalPresent) { return true } return s.handleUSSD(w, r, physicalID) case "actions/ussd/continue": return s.handleUSSDContinue(w, r) case "actions/ussd/cancel": return s.handleUSSDCancel(w, r) case "actions/reboot": if !requireMethod(w, r, http.MethodPost) { return true } if !s.requirePhysicalDevice(w, physicalPresent) { return true } if err := s.devices.Reboot(r.Context(), physicalID); err != nil { s.writeDeviceError(w, err) return true } s.clearPublicIP(config.ID) writeJSON(w, http.StatusAccepted, map[string]any{"data": map[string]any{"status": "rebooting"}}) case "flight-mode": if !s.requirePhysicalDevice(w, physicalPresent) { return true } return s.handleFlightMode(w, r, config, physicalID) case "network": if !s.requirePhysicalDevice(w, physicalPresent) { return true } return s.handleCellularData(w, r, config, physicalID) case "network/apns": if !s.requirePhysicalDevice(w, physicalPresent) { return true } return s.handleAPNProfiles(w, r, physicalID) case "network/public-ip": if !s.requirePhysicalDevice(w, physicalPresent) { return true } iccid := "" if entry.Snapshot != nil { iccid = entry.Snapshot.ICCID } return s.handleCellularPublicIP(w, r, config, iccid) case "usbnet-mode": if !s.requirePhysicalDevice(w, physicalPresent) { return true } return s.handleUSBNetMode(w, r, physicalID) case "operator_selection": if !s.requirePhysicalDevice(w, physicalPresent) { return true } return s.handleOperatorSelection(w, r, physicalID) case "operator_selection/reregister": if !s.requirePhysicalDevice(w, physicalPresent) { return true } return s.handleOperatorReRegister(w, r, physicalID) case "operator_selection/scan": if !s.requirePhysicalDevice(w, physicalPresent) { return true } return s.handleOperatorScan(w, r, physicalID) case "operator_selection/scan/stream": if !s.requirePhysicalDevice(w, physicalPresent) { return true } return s.handleOperatorScanStream(w, r, physicalID) case "vowifi": return s.handleVoWiFiEnabled(w, r, config, physicalPresent) case "vowifi/actions/reconnect": return s.handleVoWiFiReconnect(w, r, config, physicalPresent) case "vowifi/e911/websheet": return s.handleE911Websheet(w, r, config) case "calls": if !s.requirePhysicalDevice(w, physicalPresent) { return true } return s.handleCalls(w, r, config, physicalID) case "calls/dial", "calls/answer", "calls/hangup": if !s.requirePhysicalDevice(w, physicalPresent) { return true } return s.handleCallAction(w, r, config, physicalID, tail[1]) case "calls/media": if !s.requirePhysicalDevice(w, physicalPresent) { return true } return s.handleCallMedia(w, r, config) default: return false } return true } func (s *Server) handleUSBNetMode(w http.ResponseWriter, r *http.Request, physicalID string) bool { switch r.Method { case http.MethodGet: result, err := s.devices.USBNetMode(r.Context(), physicalID) if err != nil { s.writeDeviceError(w, err) return true } writeJSON(w, http.StatusOK, map[string]any{"data": result}) case http.MethodPatch: var request struct { Mode int `json:"mode"` } if err := s.decodeJSON(w, r, &request); err != nil { writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) return true } result, err := s.devices.SetUSBNetMode(r.Context(), physicalID, request.Mode) if err != nil { s.writeDeviceError(w, err) return true } writeJSON(w, http.StatusOK, map[string]any{ "data": map[string]any{ "mode": result.Mode, "name": result.Name, "reboot_required": true, }, }) default: w.Header().Set("Allow", "GET, PATCH") writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") } return true } // operatorSelectionWire is the current-selection shape the SPA reads. func operatorSelectionWire(sel device.OperatorSelection) map[string]any { mode := "automatic" if sel.Mode != 0 { mode = "manual" } return map[string]any{ "mode": mode, "plmn": sel.Operator, "access_technology": sel.AccessTechnology, } } // accessTechnologyValue maps a RAT name (as surfaced to the UI by the scan) back // to its numeric AT+COPS access technology. Returns nil for an unknown name. func accessTechnologyValue(name string) *int { var value int switch strings.ToUpper(strings.TrimSpace(name)) { case "GSM": value = 0 case "UTRAN", "UMTS", "WCDMA": value = 2 case "EDGE": value = 3 case "HSDPA": value = 4 case "HSUPA": value = 5 case "HSPA": value = 6 case "LTE": value = 7 case "NR5G", "NR": value = 9 default: return nil } return &value } func (s *Server) handleOperatorSelection(w http.ResponseWriter, r *http.Request, physicalID string) bool { switch r.Method { case http.MethodGet: result, err := s.devices.OperatorSelection(r.Context(), physicalID) if err != nil { s.writeDeviceError(w, err) return true } writeJSON(w, http.StatusOK, map[string]any{"data": operatorSelectionWire(result)}) case http.MethodPost, http.MethodPut, http.MethodPatch: // The SPA posts {mode, plmn, includes_pcs_digit, rat}; the legacy shape is // {automatic, plmn, access_technology}. Accept both. includes_pcs_digit is // accepted for contract compatibility but not currently applied to the command. var request struct { Mode string `json:"mode"` Automatic *bool `json:"automatic"` PLMN string `json:"plmn"` AccessTechnology *int `json:"access_technology"` Rat string `json:"rat"` IncludesPcsDigit bool `json:"includes_pcs_digit"` } if err := s.decodeJSON(w, r, &request); err != nil { writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) return true } automatic := false switch strings.ToLower(strings.TrimSpace(request.Mode)) { case "manual": automatic = false case "automatic": automatic = true default: if request.Automatic != nil { automatic = *request.Automatic } } accessTechnology := request.AccessTechnology if rat := strings.TrimSpace(request.Rat); rat != "" { accessTechnology = accessTechnologyValue(rat) } // Manual PLMN selection can take tens of seconds while the modem // searches for and registers on the requested network. The server's // WriteTimeout would otherwise cut the response off, so clear the // connection's write deadline for this request like the SSE paths do. controller := http.NewResponseController(w) _ = controller.SetWriteDeadline(time.Time{}) result, err := s.devices.SetOperatorSelection(r.Context(), physicalID, automatic, request.PLMN, accessTechnology) if err != nil { s.writeDeviceError(w, err) return true } writeJSON(w, http.StatusOK, map[string]any{"data": operatorSelectionWire(result)}) default: w.Header().Set("Allow", "GET, POST, PUT, PATCH") writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") } return true } func (s *Server) handleOperatorReRegister(w http.ResponseWriter, r *http.Request, physicalID string) bool { if !requireMethod(w, r, http.MethodPost) { return true } controller := http.NewResponseController(w) _ = controller.SetWriteDeadline(time.Time{}) result, err := s.devices.ReRegisterOperator(r.Context(), physicalID) if err != nil { s.writeDeviceError(w, err) return true } writeJSON(w, http.StatusOK, map[string]any{"data": operatorSelectionWire(result)}) return true } func (s *Server) handleVoWiFiEnabled( w http.ResponseWriter, r *http.Request, config store.Device, physicalPresent bool, ) bool { if !requireMethod(w, r, http.MethodPatch) { return true } var request struct { Enabled bool `json:"enabled"` } if err := s.decodeJSON(w, r, &request); err != nil { writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) return true } if s.vowifi == nil { writeError(w, http.StatusServiceUnavailable, "vowifi_provider_unavailable", "the VoWiFi runtime is unavailable") return true } if request.Enabled && !physicalPresent { writeError(w, http.StatusServiceUnavailable, "physical_device_missing", "the configured modem is not present on this Linux host") return true } if request.Enabled && config.NetworkEnabled { writeError(w, http.StatusConflict, "cellular_data_active", "disable roaming data before enabling VoWiFi") return true } if request.Enabled { entry, _, _ := s.physicalForConfig(config) imsi := snapshotString(entry.Snapshot, func(snapshot *device.Snapshot) string { return snapshot.IMSI }) if reason := device.RegionBlockReason(imsi); reason != "" { writeError(w, http.StatusForbidden, "region_blocked", reason) return true } } // Establish RF-off synchronously before changing the asynchronous VoWiFi // lifecycle. This removes the attach window both when entering VoWiFi and // when leaving it: teardown starts from CFUN=4 and is required to remain // there until the user explicitly disables airplane mode. previous := config.VoWiFiEnabled liveICCID := "" entry, physicalID, present := s.physicalForConfig(config) if present { if _, err := s.devices.SetFlight(r.Context(), physicalID, true); err != nil { s.writeDeviceError(w, err) return true } } if entry.Snapshot != nil { iccid := strings.TrimSpace(entry.Snapshot.ICCID) if iccid != "" { liveICCID = iccid policy, policyErr := s.store.CardPolicy(r.Context(), iccid) if errors.Is(policyErr, store.ErrNotFound) { policy = store.CardPolicy{ICCID: iccid, IPVersion: "IPV4V6"} policyErr = nil } if policyErr != nil { s.writeStoreError(w, policyErr) return true } policy.VoWiFiEnabled = request.Enabled policy.AirplaneEnabled = true policy.NetworkEnabled = false policy.Source = "manual" if err := s.store.UpsertCardPolicy(r.Context(), policy); err != nil { s.writeStoreError(w, err) return true } } } rollbackCardPolicy := func() { if liveICCID == "" { return } policy, policyErr := s.store.CardPolicy(context.Background(), liveICCID) if policyErr != nil { return } policy.VoWiFiEnabled = previous policy.AirplaneEnabled = true policy.NetworkEnabled = false _ = s.store.UpsertCardPolicy(context.Background(), policy) } config.VoWiFiEnabled = request.Enabled if err := s.store.UpsertDevice(r.Context(), config); err != nil { rollbackCardPolicy() s.writeStoreError(w, err) return true } state, err := s.vowifi.RequestEnabled(config.ID, request.Enabled) if err != nil { // Repeating the same desired state while its asynchronous transaction is // already running is idempotent. Treat it as accepted instead of making a // harmless double-click (or a stale browser refresh) surface vowifi_busy. if errors.Is(err, vowifiruntime.ErrOperationInProgress) && (state.Enabled == request.Enabled || previous == request.Enabled) { writeJSON(w, http.StatusAccepted, map[string]any{ "data": map[string]any{ "accepted": true, "enabled": request.Enabled, "status": "in_progress", "runtime": state, }, }) return true } config.VoWiFiEnabled = previous rollbackCardPolicy() if restoreErr := s.store.UpsertDevice(r.Context(), config); restoreErr != nil { s.logger.Error( "restore VoWiFi policy after rejected runtime operation", "device_id", config.ID, "error", restoreErr, ) } s.writeVoWiFiError(w, err) return true } writeJSON(w, http.StatusAccepted, map[string]any{ "data": map[string]any{ "accepted": true, "enabled": request.Enabled, "status": map[bool]string{true: "starting", false: "stopping"}[request.Enabled], "runtime": state, }, }) return true } func (s *Server) handleVoWiFiReconnect( w http.ResponseWriter, r *http.Request, config store.Device, physicalPresent bool, ) bool { if !requireMethod(w, r, http.MethodPost) { return true } if s.vowifi == nil { writeError(w, http.StatusServiceUnavailable, "vowifi_provider_unavailable", "the VoWiFi runtime is unavailable") return true } if !config.VoWiFiEnabled { writeError(w, http.StatusConflict, "vowifi_disabled", "enable VoWiFi before requesting a reconnect") return true } if !physicalPresent { writeError(w, http.StatusServiceUnavailable, "physical_device_missing", "the configured modem is not present on this Linux host") return true } state, err := s.vowifi.RequestReconnect(config.ID) if err != nil { s.writeVoWiFiError(w, err) return true } writeJSON(w, http.StatusAccepted, map[string]any{ "data": map[string]any{ "accepted": true, "status": "reconnecting", "runtime": state, }, }) return true } func (s *Server) writeVoWiFiError(w http.ResponseWriter, err error) { switch { case errors.Is(err, vowifiruntime.ErrNotRegistered): writeError(w, http.StatusServiceUnavailable, "vowifi_device_unavailable", "the configured device has no VoWiFi runtime") case errors.Is(err, vowifiruntime.ErrOperationInProgress): writeError(w, http.StatusConflict, "vowifi_busy", "another VoWiFi operation is still in progress") case errors.Is(err, vowifiruntime.ErrClosed): writeError(w, http.StatusServiceUnavailable, "vowifi_runtime_stopped", "the VoWiFi runtime is stopping") case errors.Is(err, vowifi.ErrNotRunning): writeError(w, http.StatusConflict, "vowifi_not_running", "VoWiFi is not running") default: s.logger.Warn("VoWiFi action rejected", "error", err) writeError(w, http.StatusBadGateway, "vowifi_error", err.Error()) } } // maxActionTimeoutMs bounds a client-supplied per-request timeout so a terminal // action cannot hold the device operation lock indefinitely. const maxActionTimeoutMs = 120000 // actionRequestContext applies the frontend's optional per-request timeout // (timeout_ms) to the request context. Manager.withTimeout preserves an // existing deadline, so setting one here makes the requested timeout take // effect; a non-positive or absent value leaves the manager's defaults in place. func actionRequestContext(parent context.Context, timeoutMs int) (context.Context, context.CancelFunc) { if parent == nil { parent = context.Background() } if timeoutMs <= 0 { return context.WithCancel(parent) } if timeoutMs > maxActionTimeoutMs { timeoutMs = maxActionTimeoutMs } return context.WithTimeout(parent, time.Duration(timeoutMs)*time.Millisecond) } func (s *Server) handleAT(w http.ResponseWriter, r *http.Request, id string) bool { if !requireMethod(w, r, http.MethodPost) { return true } var request struct { Command string `json:"cmd"` TimeoutMs int `json:"timeout_ms"` } if err := s.decodeJSON(w, r, &request); err != nil { writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) return true } command := strings.TrimSpace(request.Command) if err := validateATCommand(command); err != nil { writeError(w, http.StatusBadRequest, "unsafe_at_command", err.Error()) return true } ctx, cancel := actionRequestContext(r.Context(), request.TimeoutMs) defer cancel() response, err := s.devices.ExecuteAT(ctx, id, command) if err != nil { s.writeDeviceError(w, err) return true } text := response.Text() if response.Final != "" { if text != "" { text += "\n" } text += response.Final } writeJSON(w, http.StatusOK, map[string]any{ "data": map[string]any{ "response": text, "final": response.Final, "duration_ms": response.Duration.Milliseconds(), "urcs": response.URCs, }, }) return true } func validateATCommand(command string) error { upper := strings.ToUpper(command) if len(command) < 2 || len(command) > 512 || !strings.HasPrefix(upper, "AT") { return errors.New("AT command must start with AT and contain at most 512 characters") } if strings.ContainsAny(command, "\r\n\x00") { return errors.New("AT command must contain exactly one line") } canonical := strings.NewReplacer(" ", "", "\t", "").Replace(upper) for _, blocked := range []string{ `+QCFG="USBNET"`, `+QCFG="USBCFG"`, "+QPOWD", "+CFUN=", "+CGATT=", "+CGACT=", "+CGDATA", "+QNETDEVCTL=", "+QIACT", "+QIDEACT", "+CMGS", "+CMSS", "+CMGC", "+QCMGS", "+CUSD=", "D", "A", "H", } { for _, segment := range strings.Split(canonical[2:], ";") { if strings.HasPrefix(segment, blocked) { return fmt.Errorf("AT%s is reserved for a guarded device action", blocked) } } } return nil } func (s *Server) handleUSSD(w http.ResponseWriter, r *http.Request, id string) bool { if !requireMethod(w, r, http.MethodPost) { return true } var request struct { 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 } ctx, cancel := actionRequestContext(r.Context(), request.TimeoutMs) defer cancel() result, err := s.devices.USSD(ctx, id, request.Command) if err != nil { s.writeDeviceError(w, err) return true } writeJSON(w, http.StatusOK, map[string]any{ "data": map[string]any{ "result": result.Text, "raw": result.Raw, "dcs": result.DCS, }, }) return true } func (s *Server) handleFlightMode(w http.ResponseWriter, r *http.Request, config store.Device, physicalID string) bool { if !requireMethod(w, r, http.MethodPatch) { return true } var request struct { Enabled bool `json:"enabled"` } if err := s.decodeJSON(w, r, &request); err != nil { writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) return true } if config.VoWiFiEnabled { writeError(w, http.StatusConflict, "vowifi_owns_airplane_mode", "airplane mode is locked on while VoWiFi is enabled") return true } result, err := s.devices.SetFlight(r.Context(), physicalID, request.Enabled) if err != nil { s.writeDeviceError(w, err) return true } // Unlike VoWiFi, CFUN airplane state is not represented in the device row. // Persist it against the live ICCID so a restart can distinguish an // intentional airplane policy from an interrupted VoWiFi teardown. if entry, getErr := s.devices.Get(physicalID); getErr == nil && entry.Snapshot != nil { iccid := strings.TrimSpace(entry.Snapshot.ICCID) if iccid != "" { policy, policyErr := s.store.CardPolicy(r.Context(), iccid) if errors.Is(policyErr, store.ErrNotFound) { policy = store.CardPolicy{ICCID: iccid, IPVersion: "IPV4V6"} policyErr = nil } if policyErr != nil { s.writeStoreError(w, policyErr) return true } policy.AirplaneEnabled = request.Enabled if request.Enabled { policy.VoWiFiEnabled = false } policy.Source = "manual" if err := s.store.UpsertCardPolicy(r.Context(), policy); err != nil { s.writeStoreError(w, err) return true } } } writeJSON(w, http.StatusOK, map[string]any{"data": result}) return true } type modemAPNProfile struct { CID int `json:"cid"` APN string `json:"apn"` IPVersion string `json:"ip_version"` } func parseModemAPNProfiles(lines []string) []modemAPNProfile { profiles := make([]modemAPNProfile, 0) seen := make(map[string]bool) for _, line := range lines { line = strings.TrimSpace(line) prefix := strings.Index(strings.ToUpper(line), "+CGDCONT:") if prefix < 0 { continue } record, err := csv.NewReader(strings.NewReader(strings.TrimSpace(line[prefix+len("+CGDCONT:"):]))).Read() if err != nil || len(record) < 3 { continue } cid, err := strconv.Atoi(strings.TrimSpace(record[0])) if err != nil || cid < 1 { continue } ipVersion := strings.ToUpper(strings.TrimSpace(record[1])) if ipVersion == "IPV4" { ipVersion = "IP" } if ipVersion != "IP" && ipVersion != "IPV6" && ipVersion != "IPV4V6" { continue } apn := strings.TrimSpace(record[2]) if apn == "" || !device.ValidAPN(apn) { continue } key := strings.ToLower(apn) + "\x00" + ipVersion if seen[key] { continue } seen[key] = true profiles = append(profiles, modemAPNProfile{CID: cid, APN: apn, IPVersion: ipVersion}) } return profiles } func (s *Server) handleAPNProfiles(w http.ResponseWriter, r *http.Request, physicalID string) bool { if !requireMethod(w, r, http.MethodGet) { return true } response, err := s.devices.ExecuteAT(r.Context(), physicalID, "AT+CGDCONT?") if err != nil { s.writeDeviceError(w, err) return true } writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{ "items": parseModemAPNProfiles(response.Lines), }}) return true } func (s *Server) handleCellularData( w http.ResponseWriter, r *http.Request, config store.Device, physicalID string, ) bool { if !s.developerActive(r.Context()) { writeError(w, http.StatusForbidden, "developer_mode_required", "roaming data is available only in developer mode") return true } switch r.Method { case http.MethodGet: writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{ "enabled": config.NetworkEnabled, "interface": config.Interface, "apn": config.APN, "export_proxy_only": true, }}) case http.MethodPatch, http.MethodPut: var request struct { Enabled bool `json:"enabled"` APN string `json:"apn"` } if err := s.decodeJSON(w, r, &request); err != nil { writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) return true } if request.Enabled && config.VoWiFiEnabled { writeError(w, http.StatusConflict, "vowifi_owns_radio", "disable VoWiFi before enabling cellular roaming data") return true } if !request.Enabled && s.exportProxy != nil { if _, active := s.exportProxy.EnabledConfigForDevice(config.ID); active { writeError(w, http.StatusConflict, "export_proxy_active", i18n.T("请先禁用该设备已绑定的导出代理,再关闭漫游数据")) return true } } apn := strings.TrimSpace(request.APN) policyIPVersion := "IPV4V6" activeICCID := "" isRoaming := false var activePolicy store.CardPolicy var activeAPNProfile store.CardAPNProfile if entry, getErr := s.devices.Get(physicalID); getErr == nil && entry.Snapshot != nil { activeICCID = strings.TrimSpace(entry.Snapshot.ICCID) isRoaming = entry.Snapshot.RegistrationStatus == 5 if stored, policyErr := s.store.CardPolicy(r.Context(), activeICCID); policyErr == nil { activePolicy = stored if apn == "" { apn = strings.TrimSpace(stored.APN) } if stored.IPVersion != "" { policyIPVersion = stored.IPVersion } } } if apn == "" && activePolicy.ICCID == "" { apn = strings.TrimSpace(config.APN) } if !device.ValidAPN(apn) { writeError(w, http.StatusBadRequest, "invalid_apn", "APN must contain only letters, digits, dots, underscores, or hyphens") return true } if profile, profileErr := s.store.CardAPNProfileByAPN(r.Context(), activeICCID, apn, policyIPVersion); profileErr == nil { activeAPNProfile = profile } effectiveIPVersion := policyIPVersion if isRoaming && activeAPNProfile.RoamingIPVersion != "" { effectiveIPVersion = activeAPNProfile.RoamingIPVersion } networkRequest := device.NetworkRequest{ Enabled: request.Enabled, APN: apn, IPVersion: effectiveIPVersion, Username: activeAPNProfile.Username, Password: activeAPNProfile.Password, Authentication: activeAPNProfile.AuthType, Backend: config.DeviceBackend, } controller := http.NewResponseController(w) _ = controller.SetWriteDeadline(time.Time{}) result, err := s.devices.SetNetwork(r.Context(), physicalID, networkRequest) if err != nil { s.writeDeviceError(w, err) return true } previous := config.NetworkEnabled config.NetworkEnabled = request.Enabled config.APN = apn if err := s.store.UpsertDevice(r.Context(), config); err != nil { rollbackContext, cancel := context.WithTimeout(context.Background(), 20*time.Second) networkRequest.Enabled = previous networkRequest.APN = config.APN _, _ = s.devices.SetNetwork(rollbackContext, physicalID, networkRequest) cancel() s.writeStoreError(w, err) return true } if validICCID(activeICCID) { if activePolicy.ICCID == "" { activePolicy = defaultCardPolicy(activeICCID) } activePolicy.APN = apn activePolicy.IPVersion = policyIPVersion if strings.TrimSpace(request.APN) != "" { activePolicy.Source = "manual" } if err := s.store.UpsertCardPolicy(r.Context(), activePolicy); err != nil { s.logger.Warn("cellular APN active but card policy could not be updated", "device_id", config.ID, "iccid", activeICCID, "error", err) } } writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{ "enabled": result.Enabled, "interface": result.Interface, "backend": result.Backend, "export_proxy_only": true, }}) default: w.Header().Set("Allow", "GET, PATCH, PUT") writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") } return true } func (s *Server) requirePhysicalDevice(w http.ResponseWriter, present bool) bool { if s.devices == nil { writeError(w, http.StatusServiceUnavailable, "device_manager_unavailable", "device manager is unavailable") return false } if !present { writeError(w, http.StatusServiceUnavailable, "physical_device_missing", "the configured modem is not present on this Linux host") return false } return true } func (s *Server) writeDeviceError(w http.ResponseWriter, err error) { switch { case errors.Is(err, device.ErrNotFound): writeError(w, http.StatusNotFound, "device_not_found", "device was not found or is no longer present") case errors.Is(err, device.ErrNotStarted): writeError(w, http.StatusServiceUnavailable, "device_manager_not_started", "device manager is not started") case errors.Is(err, device.ErrNoATPort): writeError(w, http.StatusServiceUnavailable, "at_port_unavailable", "device has no usable AT port") case errors.Is(err, device.ErrDataBackendUnavailable): writeError(w, http.StatusNotImplemented, "data_backend_unavailable", err.Error()) case errors.Is(err, device.ErrEUICCChannelStuck): writeError(w, http.StatusServiceUnavailable, "euicc_channel_stuck", err.Error()) case errors.Is(err, device.ErrESIMDeleteProfileNotFound): writeError(w, http.StatusNotFound, "esim_profile_not_found", "The profile is no longer present on this eUICC. Refresh the profile list and try again.") case errors.Is(err, device.ErrESIMDeleteProfileNotDisabled): writeError(w, http.StatusConflict, "esim_profile_enabled", "The active profile cannot be deleted. Enable another profile first, then delete this disabled profile.") case errors.Is(err, device.ErrESIMDeleteDisallowedByPolicy): writeError(w, http.StatusConflict, "esim_delete_disallowed_by_policy", "This profile's policy does not allow it to be deleted.") case errors.Is(err, device.ErrESIMNicknameTooLong): writeError(w, http.StatusBadRequest, "esim_nickname_too_long", "Profile nickname must not exceed 64 characters.") case errors.Is(err, device.ErrESIMNicknameProfileNotFound): writeError(w, http.StatusNotFound, "esim_profile_not_found", "The profile is no longer present on this eUICC. Refresh the profile list and try again.") case errors.Is(err, device.ErrESIMDisableProfileNotFound): writeError(w, http.StatusNotFound, "esim_profile_not_found", "The profile is no longer present on this eUICC. Refresh the profile list and try again.") case errors.Is(err, device.ErrESIMProfileNotEnabled): writeError(w, http.StatusConflict, "esim_profile_not_enabled", "This profile is already disabled. Refresh the profile list before retrying.") case errors.Is(err, device.ErrESIMDisableDisallowedByPolicy): writeError(w, http.StatusConflict, "esim_disable_disallowed_by_policy", "This profile's policy does not allow it to be disabled.") case errors.Is(err, device.ErrESIMDisableCATBusy): writeError(w, http.StatusConflict, "esim_cat_busy", "The eUICC is busy with a SIM Toolkit operation. Wait a moment and retry disabling the profile.") case errors.Is(err, device.ErrInvalidNetworkAPN): writeError(w, http.StatusBadRequest, "invalid_apn", "APN must contain only letters, digits, dots, underscores, or hyphens") case errors.Is(err, device.ErrRegionBlocked): writeError(w, http.StatusForbidden, "region_blocked", err.Error()) case errors.Is(err, context.DeadlineExceeded), errors.Is(err, modem.ErrCommandTimeout): writeError(w, http.StatusGatewayTimeout, "modem_timeout", "the modem did not answer before the command timeout") case errors.Is(err, context.Canceled): writeError(w, http.StatusRequestTimeout, "request_canceled", "the modem request was canceled") default: // Device errors may echo an AT command. Authentication commands can // contain APN credentials, so keep raw errors out of logs and responses. s.logger.Warn("device operation failed") writeError(w, http.StatusBadGateway, "modem_error", "the device operation failed") } } func (s *Server) deviceSummaries() []map[string]any { configs, err := s.store.ListDevices(context.Background()) if err != nil { s.logger.Error("list configured devices", "error", err) return []map[string]any{} } result := make([]map[string]any, 0, len(configs)) for _, config := range configs { entry, _, present := s.physicalForConfig(config) if present { result = append(result, s.configuredDeviceSummary(config, &entry)) } else { result = append(result, s.configuredDeviceSummary(config, nil)) } } return result } func (s *Server) dashboardDevices() []map[string]any { devices := s.deviceSummaries() result := make([]map[string]any, 0, len(devices)) for _, entry := range devices { modemStatus, _ := entry["modem"].(map[string]any) runtime, _ := entry["vowifi_runtime"].(map[string]any) vowifiActive, _ := runtime["tunnel_ready"].(bool) result = append(result, map[string]any{ "id": entry["id"], "name": entry["name"], "device_type": entry["device_type"], "interface": entry["interface"], "proxy_port": entry["proxy_port"], "public_ip": entry["public_ip"], "healthy": entry["healthy"], "operator": modemStatus["operator"], "signal_dbm": modemStatus["signal_dbm"], "network_mode": modemStatus["network_mode"], "network_duplex": modemStatus["network_duplex"], "vowifi_active": vowifiActive, "vowifi_runtime": runtime, "network_connected": entry["network_connected"], "model": modemStatus["model"], }) } return result } func (s *Server) physicalForConfig(config store.Device) (device.Device, string, bool) { if s.devices == nil { return device.Device{ID: config.ID}, "", false } if entry, err := s.devices.Get(config.ID); err == nil && entry.Discovered { return entry, entry.ID, true } for _, entry := range s.devices.List() { if entry.Discovered && physicalMatchesConfig(entry, config) { return entry, entry.ID, true } } return device.Device{ID: config.ID}, "", false } func physicalMatchesConfig(entry device.Device, config store.Device) bool { candidate := entry.Candidate if entry.ID == config.ID { return true } if config.ModemIMEI != "" && entry.Snapshot != nil && entry.Snapshot.IMEI != "" { return config.ModemIMEI == entry.Snapshot.IMEI } if config.USBPath != "" && candidate.USBPath != "" { return config.USBPath == candidate.USBPath } // Control and serial device nodes are allocation-order dependent. They are // only legacy fallbacks when no physical USB path or readable IMEI exists. if config.ATPort != "" && (config.ATPort == candidate.ATPort.Path || config.ATPort == candidate.ATPort.OpenPath()) { return true } if config.ControlDevice != "" && config.ControlDevice == candidate.QMIControl { return true } return false } func (s *Server) configuredDeviceSummary( config store.Device, entry *device.Device, ) map[string]any { var result map[string]any if entry != nil { result = deviceSummary(*entry) } else { result = deviceSummary(device.Device{ID: config.ID}) } result["id"] = config.ID result["name"] = config.Name result["device_type"] = store.NormalizeDeviceType(config.DeviceType) result["interface"] = config.Interface result["proxy_port"] = config.ProxyPort result["esim_transport"] = config.ESIMTransport result["sms_enabled"] = config.SMSEnabled result["network_enabled"] = config.NetworkEnabled result["developer_enabled"] = s.developerActive(context.Background()) result["network_connected"] = config.NetworkEnabled result["data_connected"] = config.NetworkEnabled result["vowifi_enabled"] = config.VoWiFiEnabled var runtimeResponse map[string]any runtimeMatchesCard := true if s.vowifi != nil { if runtime, err := s.vowifi.State(config.ID); err == nil { runtimeMatchesCard = voWiFiRuntimeMatchesSnapshot(runtime.ICCID, entry) if runtimeMatchesCard { runtimeResponse = liveVoWiFiRuntime(runtime) } else { runtimeResponse = idleVoWiFiRuntime(config.ID, snapshotForEntry(entry)) } } } if runtimeResponse == nil { if runtime, err := s.store.VoWiFiRuntime(context.Background(), config.ID); err == nil { currentICCID := "" var currentSnapshot *device.Snapshot if entry != nil { currentSnapshot = entry.Snapshot if entry.Snapshot != nil { currentICCID = strings.TrimSpace(entry.Snapshot.ICCID) } } runtimeMatchesCard = currentICCID == "" || runtime.ICCID == "" || strings.EqualFold(currentICCID, strings.TrimSpace(runtime.ICCID)) if runtimeMatchesCard { runtimeResponse = storedVoWiFiRuntime(runtime) } else { // The saved IMS session belongs to a different eSIM profile. Never // project its registration or number onto the currently selected SIM. runtimeResponse = idleVoWiFiRuntime(config.ID, currentSnapshot) } } } if runtimeResponse == nil { runtimeResponse = idleVoWiFiRuntime(config.ID, snapshotForEntry(entry)) } result["vowifi_runtime"] = runtimeResponse runtimeEnabled, _ := runtimeResponse["enabled"].(bool) runtimeTunnelReady, _ := runtimeResponse["tunnel_ready"].(bool) result["vowifi_active"] = config.VoWiFiEnabled && runtimeMatchesCard && runtimeEnabled && runtimeTunnelReady // Numbers are SIM-owned data. Resolve the association by the live ICCID // instead of reusing the last VoWiFi runtime attached to this device ID. if entry != nil && entry.Snapshot != nil { currentICCID := strings.TrimSpace(entry.Snapshot.ICCID) if currentICCID != "" { if association, err := s.store.PhoneAssociation(context.Background(), currentICCID); err == nil { result["local_phone"] = association.Number result["phone_number_source"] = association.Source if modemStatus, ok := result["modem"].(map[string]any); ok { modemStatus["phone_number"] = association.Number modemStatus["phone_number_source"] = association.Source } } } } return result } func (s *Server) configuredDeviceOverview( config store.Device, entry device.Device, present bool, ) map[string]any { developerActive := s.developerActive(context.Background()) var physical *device.Device if present { physical = &entry } result := s.configuredDeviceSummary(config, physical) result["id"] = config.ID result["name"] = config.Name result["interface"] = config.Interface result["at_port"] = config.ATPort result["audio_device"] = config.AudioDevice result["backend_mode"] = config.DeviceBackend result["control_device"] = config.ControlDevice result["esim_transport"] = config.ESIMTransport result["sms_enabled"] = config.SMSEnabled result["network_enabled"] = developerActive && config.NetworkEnabled result["vowifi_enabled"] = config.VoWiFiEnabled result["radio_live_ok"] = present && entry.Snapshot != nil && entry.Snapshot.Responsive // Live network state: on-demand sample of the cellular interface counters, // kept warm by the 2s overview SSE cadence. Only meaningful when the modem // data path is enabled and an interface is configured. if developerActive && config.NetworkEnabled && strings.TrimSpace(config.Interface) != "" { live := s.netTraffic.sample(config.ID, config.Interface, time.Now()) result["private_ip"] = live.ipv4 result["traffic"] = map[string]string{ "rx": formatLiveBytes(float64(live.minuteRx)), "tx": formatLiveBytes(float64(live.minuteTx)), "rate": formatLiveBytes(live.rxRate) + "/s", "rate_tx": formatLiveBytes(live.txRate) + "/s", } result["traffic_raw"] = map[string]int64{ "rx": live.minuteRx, "tx": live.minuteTx, "rate": int64(live.rxRate), "rate_tx": int64(live.txRate), } result["traffic_meta"] = map[string]any{"status": live.status} } else { result["traffic"] = map[string]string{} result["traffic_raw"] = map[string]int64{} result["traffic_meta"] = map[string]any{} } return result } func (s *Server) configuredDeviceStatus( config store.Device, entry device.Device, present bool, ) map[string]any { var physical *device.Device if present { physical = &entry } summary := s.configuredDeviceSummary(config, physical) lastUpdated := time.Time{} if physical != nil { lastUpdated = physical.LastUpdated } result := map[string]any{ "healthy": summary["healthy"], "public_ip": summary["public_ip"], "network_connected": config.NetworkEnabled, "modem": summary["modem"], "vowifi": summary["vowifi_runtime"], "sim_service_table": map[string]any{}, "pnn": []any{}, "opl": []any{}, "last_hardware_refresh": lastUpdated, } result["id"] = config.ID result["name"] = config.Name result["interface"] = config.Interface result["proxy_port"] = config.ProxyPort return result } func storedVoWiFiRuntime(runtime store.VoWiFiRuntime) map[string]any { extra, _ := rawJSONObject(runtime.Extra).(map[string]any) enabled, _ := extra["enabled"].(bool) active, _ := extra["active"].(bool) return map[string]any{ "device_id": runtime.DeviceID, "phase": runtime.Phase, "enabled": enabled, "active": active, "dataplane_mode": runtime.DataplaneMode, "iccid": runtime.ICCID, "imsi": runtime.IMSI, "sim_ready": runtime.SIMReady, "access_ready": runtime.AccessReady, "tunnel_ready": runtime.TunnelReady, "ims_ready": runtime.IMSReady, "sms_ready": runtime.SMSReady, "reg_status": runtime.RegStatus, "reg_status_text": runtime.RegStatusText, "network_mode": runtime.NetworkMode, "local_phone": runtime.LocalPhone, "phone_number_source": runtime.PhoneNumberSource, "last_error_class": runtime.LastErrorClass, "last_error": runtime.LastError, "last_reason": runtime.LastReason, "updated_at": runtime.UpdatedAt, "tunnel": rawJSONObject(runtime.Tunnel), "imscore": rawJSONObject(runtime.IMSCore), "smsip": rawJSONObject(runtime.SMSIP), } } func liveVoWiFiRuntime(runtime vowifi.State) map[string]any { return map[string]any{ "device_id": runtime.DeviceID, "phase": string(runtime.Phase), "enabled": runtime.Enabled, "active": runtime.Active, "dataplane_mode": runtime.DataplaneMode, "iccid": runtime.ICCID, "imsi": runtime.IMSI, "sim_ready": runtime.SIMReady, "access_ready": runtime.AccessReady, "tunnel_ready": runtime.TunnelReady, "ims_ready": runtime.IMSReady, "sms_ready": runtime.SMSReady, "reg_status": map[bool]int{true: 1, false: 0}[runtime.IMSReady], "reg_status_text": map[bool]string{true: "registered", false: "not registered"}[runtime.IMSReady], "network_mode": "Wi-Fi", "local_phone": runtime.PhoneNumber, "phone_number_source": runtime.PhoneNumberSource, "last_error_class": runtime.LastErrorClass, "last_error": runtime.LastError, "last_reason": runtime.LastReason, "updated_at": runtime.UpdatedAt, "tunnel": map[string]any{ "established": runtime.TunnelReady, "name": runtime.TunnelName, "dataplane_mode": runtime.DataplaneMode, "epdg": runtime.EPDG, "proxy_mode": runtime.ProxyMode, "proxy_id": runtime.ProxyID, "security_audit": runtime.Security, }, "imscore": map[string]any{ "registered": runtime.IMSReady, "registration_state": runtime.IMSRegistration, "associated_number": runtime.PhoneNumber, "number_source": runtime.PhoneNumberSource, }, "smsip": map[string]any{"ready": runtime.SMSReady}, } } func snapshotForEntry(entry *device.Device) *device.Snapshot { if entry == nil { return nil } return entry.Snapshot } func voWiFiRuntimeMatchesSnapshot(runtimeICCID string, entry *device.Device) bool { current := strings.TrimSpace(snapshotString(snapshotForEntry(entry), func(snapshot *device.Snapshot) string { return snapshot.ICCID })) runtimeICCID = strings.TrimSpace(runtimeICCID) return current == "" || runtimeICCID == "" || strings.EqualFold(current, runtimeICCID) } func rawJSONObject(value json.RawMessage) any { var result any if len(value) != 0 && json.Unmarshal(value, &result) == nil { return result } return map[string]any{} } func deviceSummary(entry device.Device) map[string]any { snapshot := entry.Snapshot healthy := entry.Discovered && snapshot != nil && snapshot.Responsive && entry.LastError == "" phone := "" phoneSource := "" if snapshot != nil { phone = snapshot.Phone.Number phoneSource = snapshot.Phone.Source } mode := 0 modeKnown := false if snapshot != nil { mode = snapshot.OperatingMode modeKnown = snapshot.ModeKnown } runtime := idleVoWiFiRuntime(entry.ID, snapshot) summary := modemSummary(snapshot, phone, phoneSource) if model, _ := summary["model"].(string); strings.TrimSpace(model) == "" { if p := strings.TrimSpace(entry.Candidate.Product); p != "" { summary["model"] = p } else if id := strings.TrimSpace(entry.ID); id != "" { summary["model"] = id } } return map[string]any{ "id": entry.ID, "name": deviceName(entry), "running": entry.Discovered, "healthy": healthy, "control_online": healthy, "physical_present": entry.Discovered, "worker_running": entry.Discovered, "data_connected": false, "radio_registered": snapshot != nil && (snapshot.RegistrationStatus == 1 || snapshot.RegistrationStatus == 5), "lifecycle_phase": lifecyclePhase(entry), "lifecycle_reason": entry.LastError, "public_ip": "", "private_ip": "", "interface": entry.Candidate.NetworkInterface, "esim_transport": backendMode(entry.Candidate), "sms_enabled": true, "network_enabled": false, "vowifi_enabled": false, "vowifi_active": false, "vowifi_runtime": runtime, "modem": summary, "local_phone": phone, "phone_number_source": phoneSource, "network_connected": false, "registration_state_label": registrationLabel(snapshot), "flight_mode": modeKnown && (mode == 0 || mode == 4), } } func deviceOverview(entry device.Device) map[string]any { result := deviceSummary(entry) result["at_port"] = entry.Candidate.ATPort.OpenPath() result["audio_device"] = "" result["backend_mode"] = backendMode(entry.Candidate) result["control_device"] = firstNonEmpty(entry.Candidate.QMIControl, entry.Candidate.ATPort.OpenPath()) result["radio_live_ok"] = entry.Snapshot != nil && entry.Snapshot.Responsive result["traffic"] = map[string]string{} result["traffic_raw"] = map[string]int64{} result["traffic_meta"] = map[string]any{} return result } func deviceStatus(entry device.Device) map[string]any { summary := deviceSummary(entry) return map[string]any{ "id": entry.ID, "name": summary["name"], "healthy": summary["healthy"], "interface": summary["interface"], "public_ip": "", "proxy_port": 1080, "network_connected": false, "modem": summary["modem"], "vowifi": summary["vowifi_runtime"], "sim_service_table": map[string]any{}, "pnn": []any{}, "opl": []any{}, "last_hardware_refresh": entry.LastUpdated, } } func storedDeviceConfig(config store.Device) map[string]any { simPIN := "" if strings.TrimSpace(config.SIMPIN) != "" { simPIN = store.SecretMask } return map[string]any{ "id": config.ID, "name": config.Name, "device_type": store.NormalizeDeviceType(config.DeviceType), "interface": config.Interface, "control_device": config.ControlDevice, "at_port": config.ATPort, "usb_path": config.USBPath, "audio_device": config.AudioDevice, "modem_imei": config.ModemIMEI, "sim_pin": simPIN, "apn": config.APN, "proxy_port": config.ProxyPort, "baud_rate": config.BaudRate, "data_bits": config.DataBits, "stop_bits": config.StopBits, "parity": config.Parity, "device_backend": config.DeviceBackend, "esim_transport": config.ESIMTransport, "qmi_use_proxy": config.QMIUseProxy, "qmi_proxy_path": config.QMIProxyPath, "qmi_proxy_executable": config.QMIProxyExecutable, "network_enabled": config.NetworkEnabled, "sms_enabled": config.SMSEnabled, "vowifi_enabled": config.VoWiFiEnabled, } } func fillConfigFromPhysical(config *store.Device, entry device.Device) { candidate := entry.Candidate if candidate.HardwareKind == "pcsc" { config.DeviceType = store.DeviceTypeUSBSIMReader config.ControlDevice = candidate.ReaderName config.ATPort = "" config.Interface = "" config.DeviceBackend = "pcsc" config.ESIMTransport = "pcsc" config.NetworkEnabled = false config.SMSEnabled = true config.VoWiFiEnabled = true } if config.Interface == "" { config.Interface = candidate.NetworkInterface } if config.ControlDevice == "" { config.ControlDevice = firstNonEmpty(candidate.QMIControl, candidate.ATPort.OpenPath()) } if config.ATPort == "" { config.ATPort = candidate.ATPort.OpenPath() } if config.USBPath == "" { config.USBPath = candidate.USBPath } if config.ModemIMEI == "" && entry.Snapshot != nil { config.ModemIMEI = entry.Snapshot.IMEI } if config.DeviceBackend == "" { config.DeviceBackend = backendMode(candidate) } if config.ESIMTransport == "" { config.ESIMTransport = config.DeviceBackend } } func modemSummary(snapshot *device.Snapshot, phone string, phoneSource string) map[string]any { if snapshot == nil { return map[string]any{ "operator": "", "native_mcc": "", "native_mnc": "", "native_spn": "", "operator_country_code": "", "card_mcc": "", "card_mnc": "", "card_country": "", "service_blocked": false, "blocked_reason": "", "network_mode": "", "radio_band": "", "radio_channel": 0, "signal_dbm": 0, "signal_sinr": 0, "imei": "", "iccid": "", "reg_status": 0, "reg_status_text": "not refreshed", "sim_inserted": false, "phone_number": phone, "phone_number_source": phoneSource, "model": "", } } mcc, mnc := splitPLMN(snapshot.OperatorCode) _, operatorCountryCode, _ := device.CarrierForPLMN(snapshot.OperatorCode) cardMCC, cardMNC := device.CardMCCMNC(snapshot.IMSI) blockedReason := device.RegionBlockReason(snapshot.IMSI) return map[string]any{ "operator": snapshot.OperatorName, "native_mcc": mcc, "native_mnc": mnc, "native_spn": snapshot.SPN, "operator_country_code": operatorCountryCode, "card_mcc": cardMCC, "card_mnc": cardMNC, "card_country": countryNameForMCC(cardMCC), "service_blocked": blockedReason != "", "blocked_reason": blockedReason, "network_mode": snapshot.AccessTech, "network_duplex": "", "radio_band": snapshot.Band, "radio_channel": parseDecimal(snapshot.Channel), "signal_dbm": pointerInt(snapshot.RSSIDBm), "signal_rsrp": pointerInt(snapshot.RSRP), "signal_rsrq": pointerInt(snapshot.RSRQ), "signal_sinr": pointerInt(snapshot.SINR), "imei": snapshot.IMEI, "iccid": snapshot.ICCID, "imsi": snapshot.IMSI, "firmware": snapshot.Firmware, "model": snapshot.Model, "reg_status": snapshot.RegistrationStatus, "reg_status_text": registrationText(snapshot), "ps_attached": snapshot.PSAttached, "sim_inserted": snapshotHasSIM(snapshot), "operating_mode": snapshot.OperatingMode, "phone_number": phone, "phone_number_source": phoneSource, } } func snapshotHasSIM(snapshot *device.Snapshot) bool { if snapshot == nil { return false } if snapshot.SIMReady || strings.TrimSpace(snapshot.ICCID) != "" || strings.TrimSpace(snapshot.IMSI) != "" { return true } switch strings.ToLower(strings.TrimSpace(snapshot.SIMStatus)) { case "", "unknown", "not_inserted", "not inserted", "absent": return false default: // PIN/PUK and other explicit UICC states prove that a card is present. return true } } func idleVoWiFiRuntime(id string, snapshot *device.Snapshot) map[string]any { iccid := "" imsi := "" phone := "" source := "" simReady := false if snapshot != nil { iccid = snapshot.ICCID imsi = snapshot.IMSI phone = snapshot.Phone.Number source = snapshot.Phone.Source simReady = snapshot.SIMReady } return map[string]any{ "device_id": id, "phase": "idle", "enabled": false, "active": false, "dataplane_mode": "", "iccid": iccid, "imsi": imsi, "sim_ready": simReady, "access_ready": false, "tunnel_ready": false, "ims_ready": false, "sms_ready": false, "reg_status": 0, "reg_status_text": "not started", "network_mode": "", "local_phone": phone, "phone_number_source": source, "last_error_class": "", "last_error": "", "last_reason": "disabled", "updated_at": time.Now().UTC(), } } func deviceName(entry device.Device) string { if entry.Snapshot != nil && strings.TrimSpace(entry.Snapshot.Model) != "" { return entry.Snapshot.Model } if strings.TrimSpace(entry.Candidate.Product) != "" { return entry.Candidate.Product } return entry.ID } func backendMode(candidate modem.Candidate) string { if candidate.HardwareKind == "pcsc" { return "pcsc" } if candidate.QMIControl != "" { return "qmi" } return "at" } func lifecyclePhase(entry device.Device) string { switch { case !entry.Discovered: return "missing" case entry.LastError != "": return "degraded" case entry.Snapshot == nil: return "discovered" case entry.Snapshot.Responsive: return "ready" default: return "unresponsive" } } func registrationLabel(snapshot *device.Snapshot) string { if snapshot == nil { return "unknown" } switch snapshot.RegistrationStatus { case 1, 5: return "registered" case 2: return "searching" case 3: return "denied" default: return "unknown" } } func registrationText(snapshot *device.Snapshot) string { if snapshot == nil { return "unknown" } switch snapshot.RegistrationStatus { case 1: return "registered" case 5: return "registered (roaming)" case 2: return "searching" case 3: return "registration denied" case 0: return "not registered" default: return "unknown" } } func splitPLMN(value string) (string, string) { value = strings.TrimSpace(value) if len(value) < 5 { return "", "" } return value[:3], value[3:] } func parseDecimal(value string) int { number, _ := strconv.Atoi(strings.TrimSpace(value)) return number } func pointerInt(value *int) int { if value == nil { return 0 } return *value } func boolInt(value bool) int { if value { return 1 } return 0 } func snapshotString( snapshot *device.Snapshot, selector func(*device.Snapshot) string, ) string { if snapshot == nil { return "" } return selector(snapshot) } func firstNonEmpty(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { return value } } return "" }