| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517 |
- package server
- import (
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "net/http"
- "net/url"
- "strconv"
- "strings"
- "time"
- "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)
- 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"`
- 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"`
- 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,
- 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),
- 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
- }
- // maxDeviceLimit 是设备数量的软上限:达到上限后禁止再添加新设备。
- const maxDeviceLimit = 5
- func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) bool {
- switch r.Method {
- case http.MethodGet:
- writeJSON(w, http.StatusOK, map[string]any{
- "data": map[string]any{
- "device_limit": maxDeviceLimit,
- "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 _, 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) >= maxDeviceLimit {
- writeError(w, http.StatusConflict, "device_limit_reached", i18n.Tf("设备数量已达上限,最多只能添加 %d 台设备", maxDeviceLimit))
- 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()
- fillConfigFromPhysical(&config, *selected)
- if err := s.store.UpsertDevice(r.Context(), config); err != nil {
- s.writeStoreError(w, err)
- return true
- }
- 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 {
- for index := range devices {
- candidate := devices[index].Candidate
- if config.ATPort != "" &&
- (candidate.ATPort.Path == config.ATPort || candidate.ATPort.OpenPath() == config.ATPort) {
- return &devices[index]
- }
- if config.ControlDevice != "" && candidate.QMIControl == config.ControlDevice {
- return &devices[index]
- }
- if config.USBPath != "" && candidate.USBPath == config.USBPath {
- 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{
- "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.NetworkInterface != "" || candidate.QMIControl != "",
- "configured": configuredID != "",
- "configured_id": configuredID,
- "degraded": !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()
- next.ID = id
- next.CreatedAt = config.CreatedAt
- if next.Name == id && strings.TrimSpace(payload.Name) == "" {
- next.Name = config.Name
- }
- 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 len(tail) > 0 && tail[0] == "esim" {
- return s.handleESIM(w, r, tail[1:], physicalID, physicalPresent)
- }
- 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
- }
- 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, physicalID)
- 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/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)
- 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) 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 {
- 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
- }
- }
- previous := config.VoWiFiEnabled
- config.VoWiFiEnabled = request.Enabled
- if err := s.store.UpsertDevice(r.Context(), config); err != nil {
- 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
- 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, id 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
- }
- result, err := s.devices.SetFlight(r.Context(), id, request.Enabled)
- if err != nil {
- s.writeDeviceError(w, err)
- return true
- }
- writeJSON(w, http.StatusOK, map[string]any{"data": result})
- 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:
- s.logger.Warn("device operation failed", "error", err)
- writeError(w, http.StatusBadGateway, "modem_error", err.Error())
- }
- }
- 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"],
- "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": false,
- "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.ATPort != "" &&
- (config.ATPort == candidate.ATPort.Path || config.ATPort == candidate.ATPort.OpenPath()) {
- return true
- }
- if config.ControlDevice != "" && config.ControlDevice == candidate.QMIControl {
- return true
- }
- if config.USBPath != "" && config.USBPath == candidate.USBPath {
- return true
- }
- return config.ModemIMEI != "" &&
- entry.Snapshot != nil &&
- config.ModemIMEI == entry.Snapshot.IMEI
- }
- 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["interface"] = config.Interface
- result["proxy_port"] = config.ProxyPort
- result["esim_transport"] = config.ESIMTransport
- result["sms_enabled"] = config.SMSEnabled
- result["network_enabled"] = false
- result["vowifi_enabled"] = config.VoWiFiEnabled
- if runtime, err := s.store.VoWiFiRuntime(context.Background(), config.ID); err == nil {
- runtimeResponse := storedVoWiFiRuntime(runtime)
- result["vowifi_runtime"] = runtimeResponse
- result["vowifi_active"] = runtime.TunnelReady
- if runtime.LocalPhone != "" {
- // The SIM panel reads the top-level local_phone; keep modem.phone_number
- // in sync for the summary/overview consumers that read it there.
- result["local_phone"] = runtime.LocalPhone
- result["phone_number_source"] = runtime.PhoneNumberSource
- if modemStatus, ok := result["modem"].(map[string]any); ok {
- modemStatus["phone_number"] = runtime.LocalPhone
- modemStatus["phone_number_source"] = runtime.PhoneNumberSource
- }
- }
- }
- return result
- }
- func (s *Server) configuredDeviceOverview(
- config store.Device,
- entry device.Device,
- present bool,
- ) map[string]any {
- 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"] = false
- result["vowifi_enabled"] = config.VoWiFiEnabled
- result["radio_live_ok"] = present && 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 (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": false,
- "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 {
- return map[string]any{
- "device_id": runtime.DeviceID,
- "phase": runtime.Phase,
- "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 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.OperatorName != "",
- "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 {
- return map[string]any{
- "id": config.ID,
- "name": config.Name,
- "interface": config.Interface,
- "control_device": config.ControlDevice,
- "at_port": config.ATPort,
- "usb_path": config.USBPath,
- "audio_device": config.AudioDevice,
- "modem_imei": config.ModemIMEI,
- "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": false,
- "sms_enabled": config.SMSEnabled,
- "vowifi_enabled": config.VoWiFiEnabled,
- }
- }
- func fillConfigFromPhysical(config *store.Device, entry device.Device) {
- candidate := entry.Candidate
- 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": "",
- "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)
- cardMCC, cardMNC := device.CardMCCMNC(snapshot.IMSI)
- blockedReason := device.RegionBlockReason(snapshot.IMSI)
- return map[string]any{
- "operator": snapshot.OperatorName,
- "native_mcc": mcc,
- "native_mnc": mnc,
- "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": boolInt(snapshot.OperatorName != ""),
- "reg_status_text": registrationText(snapshot),
- "ps_attached": false,
- "sim_inserted": snapshot.SIMStatus != "",
- "operating_mode": snapshot.OperatingMode,
- "phone_number": phone,
- "phone_number_source": phoneSource,
- }
- }
- 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",
- "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.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 || snapshot.OperatorName == "" {
- return "unknown"
- }
- return "registered"
- }
- func registrationText(snapshot *device.Snapshot) string {
- if snapshot.OperatorName != "" {
- return "registered"
- }
- 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 ""
- }
|