diff --git a/cmd/vocat/main.go b/cmd/vocat/main.go index 0351dfd..6866770 100644 --- a/cmd/vocat/main.go +++ b/cmd/vocat/main.go @@ -13,6 +13,7 @@ import ( "os/signal" "path/filepath" "strings" + "sync" "syscall" "time" @@ -193,6 +194,7 @@ func run(logger *slog.Logger, logs *loghub.Hub) error { if err := provisionDiscoveredDevices(startupContext, database, deviceManager); err != nil { logger.Warn("automatic first-run device provisioning failed", "error", err) } + configureDeviceBackends(startupContext, logger, database, deviceManager) restoreDefaultCellularRadios(startupContext, logger, database, deviceManager) defer func() { stopContext, cancel := context.WithTimeout(context.Background(), 5*time.Second) @@ -222,6 +224,7 @@ func run(logger *slog.Logger, logs *loghub.Hub) error { if err != nil { return fmt.Errorf("configure VoWiFi runtime: %w", err) } + go reconcileCardPolicies(pollContext, logger, database, deviceManager, vowifiManager) defer func() { stopContext, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() @@ -254,6 +257,7 @@ func run(logger *slog.Logger, logs *loghub.Hub) error { go handler.StartSMSSyncLoop(pollContext, 15*time.Second) handler.StartTelegramBot(pollContext) handler.StartSMSNotificationDispatchers(pollContext) + handler.StartAutomaticTasks(pollContext) serverConfig := func(handler http.Handler) *http.Server { return &http.Server{ @@ -341,11 +345,32 @@ func run(logger *slog.Logger, logs *loghub.Hub) error { return nil } -// restoreDefaultCellularRadios repairs an interrupted VoWiFi teardown. CFUN=4 -// survives process restarts, while the in-memory radio checkpoint does not. If -// VoWiFi is disabled and the current SIM has no explicit airplane policy, the -// automatic/default policy is cellular service and the modem must return to -// CFUN=1. +func configureDeviceBackends( + ctx context.Context, + logger *slog.Logger, + database *store.Store, + manager *device.Manager, +) { + configs, err := database.ListDevices(ctx) + if err != nil { + logger.Warn("configure device backends: list devices", "error", err) + return + } + mapper := integration.ATMapper{Store: database, Devices: manager} + for _, config := range configs { + entry, mapErr := mapper.Get(config.ID) + if mapErr != nil { + continue + } + if err := manager.SetBackend(entry.ID, config.DeviceBackend); err != nil { + logger.Warn("configure device backend", "device_id", config.ID, "backend", config.DeviceBackend, "error", err) + } + } +} + +// restoreDefaultCellularRadios applies an explicitly saved cellular policy +// after restart. Missing policies remain RF-off and are claimed by the safe +// default policy; there is no automatic cellular fallback. func restoreDefaultCellularRadios( ctx context.Context, logger *slog.Logger, @@ -367,11 +392,16 @@ func restoreDefaultCellularRadios( continue } iccid := strings.TrimSpace(entry.Snapshot.ICCID) + if iccid == "" { + continue + } if iccid != "" { policy, policyErr := database.CardPolicy(ctx, iccid) switch { case policyErr == nil && policy.AirplaneEnabled: continue + case errors.Is(policyErr, store.ErrNotFound): + continue case policyErr != nil && !errors.Is(policyErr, store.ErrNotFound): logger.Warn("startup cellular recovery: read card policy", "device_id", config.ID, "error", policyErr) continue @@ -410,7 +440,7 @@ func restoreConfiguredCellularData( } dataContext, cancel := context.WithTimeout(ctx, 60*time.Second) _, err = manager.SetNetwork(dataContext, entry.ID, device.NetworkRequest{ - Enabled: true, APN: config.APN, IPVersion: "IPV4V6", + Enabled: true, APN: config.APN, IPVersion: "IPV4V6", Backend: config.DeviceBackend, }) cancel() if err != nil { @@ -439,7 +469,7 @@ func disableAllDeveloperCellularData( continue } disableContext, cancel := context.WithTimeout(ctx, 30*time.Second) - _, err = manager.SetNetwork(disableContext, entry.ID, device.NetworkRequest{Enabled: false}) + _, err = manager.SetNetwork(disableContext, entry.ID, device.NetworkRequest{Enabled: false, Backend: config.DeviceBackend}) cancel() if err != nil && ctx.Err() == nil { logger.Warn("developer cleanup: stop cellular data", "device_id", config.ID, "error", err) @@ -497,6 +527,13 @@ func configureVoWiFiRuntime( // The test deployment is deliberately non-cellular. VoWiFi teardown // may restore CFUN, but it must never reactivate a PDP context. RestoreCellularData: false, + // VoWiFi is always fail-closed with respect to cellular RF. Its teardown + // leaves CFUN=4; only the explicit airplane-mode-off endpoint restores + // CFUN=1. + PureAirplanePolicy: func(deviceID string) bool { + deviceConfig, configErr := database.Device(context.Background(), deviceID) + return configErr == nil && deviceConfig.VoWiFiEnabled + }, }) if err != nil { return nil, err @@ -528,6 +565,15 @@ func configureVoWiFiRuntime( return nil, fmt.Errorf("register device %q VoWiFi runtime: %w", deviceConfig.ID, err) } if deviceConfig.VoWiFiEnabled { + if entry, mapErr := mapper.Get(deviceConfig.ID); mapErr == nil { + flightContext, cancelFlight := context.WithTimeout(ctx, 10*time.Second) + _, flightErr := deviceManager.SetFlight(flightContext, entry.ID, true) + cancelFlight() + if flightErr != nil { + _ = manager.Close(context.Background()) + return nil, fmt.Errorf("protect device %q before VoWiFi startup: %w", deviceConfig.ID, flightErr) + } + } if _, err := manager.RequestEnabled(deviceConfig.ID, true); err != nil { _ = manager.Close(context.Background()) return nil, fmt.Errorf("start device %q VoWiFi policy: %w", deviceConfig.ID, err) @@ -694,7 +740,7 @@ func provisionDiscoveredDevices( ESIMTransport: backend, NetworkEnabled: false, SMSEnabled: true, - VoWiFiEnabled: false, + VoWiFiEnabled: true, }); err != nil { return err } @@ -754,20 +800,42 @@ func pollDeviceSnapshots( logger.Debug("periodic modem discovery failed", "error", err) return } - for _, entry := range manager.List() { + // Hotplug can replace the physical discovery ID. Rebind each configured + // device's selected QMI/AT control plane before collecting its snapshot. + configureDeviceBackends(ctx, logger, database, manager) + entries := manager.List() + // Each physical modem owns its own operation lock. Refresh them in + // parallel so a slow or wedged EC20 on one hub port cannot delay signal + // and identity updates for every other modem by 30 seconds at a time. + var refreshGroup sync.WaitGroup + refreshSlots := make(chan struct{}, 4) + for _, entry := range entries { if !entry.Discovered { continue } - refreshContext, cancelRefresh := context.WithTimeout(ctx, 30*time.Second) - snapshot, err := manager.Refresh(refreshContext, entry.ID) - cancelRefresh() - if err != nil && ctx.Err() == nil { - logger.Warn("modem snapshot refresh failed", "device_id", entry.ID, "error", err) - } - if err == nil && ctx.Err() == nil { - enforceCardRegion(ctx, logger, database, manager, entry.ID, &snapshot) - } + entry := entry + refreshGroup.Add(1) + go func() { + defer refreshGroup.Done() + select { + case refreshSlots <- struct{}{}: + defer func() { <-refreshSlots }() + case <-ctx.Done(): + return + } + refreshContext, cancelRefresh := context.WithTimeout(ctx, 30*time.Second) + snapshot, refreshErr := manager.Refresh(refreshContext, entry.ID) + cancelRefresh() + if refreshErr != nil && ctx.Err() == nil { + logger.Warn("modem snapshot refresh failed", "device_id", entry.ID, "error", refreshErr) + } + if refreshErr == nil && ctx.Err() == nil { + enforceCardRegion(ctx, logger, database, manager, entry.ID, &snapshot) + enforceDefaultSafeCardPolicy(ctx, logger, database, manager, entry.ID, &snapshot) + } + }() } + refreshGroup.Wait() } refresh() ticker := time.NewTicker(30 * time.Second) @@ -782,6 +850,158 @@ func pollDeviceSnapshots( } } +// enforceDefaultSafeCardPolicy handles a newly inserted physical SIM or a +// profile that has never had a policy. RF is turned off before the default is +// persisted; the VoWiFi runtime reconciler then starts service asynchronously. +func enforceDefaultSafeCardPolicy( + ctx context.Context, + logger *slog.Logger, + database *store.Store, + manager *device.Manager, + physicalID string, + snapshot *device.Snapshot, +) { + if snapshot == nil || !snapshot.SIMReady || strings.TrimSpace(snapshot.ICCID) == "" || + device.RegionBlockReason(snapshot.IMSI) != "" { + return + } + iccid := strings.TrimSpace(snapshot.ICCID) + if _, err := database.CardPolicy(ctx, iccid); err == nil && !snapshot.SIMChanged { + return + } else if !errors.Is(err, store.ErrNotFound) { + logger.Warn("default card policy: read policy", "iccid", iccid, "error", err) + return + } + flightContext, cancel := context.WithTimeout(ctx, 10*time.Second) + _, err := manager.SetFlight(flightContext, physicalID, true) + cancel() + if err != nil { + logger.Warn("default card policy: failed to establish airplane mode", "device_id", physicalID, "iccid", iccid, "error", err) + return + } + if err := database.UpsertCardPolicy(ctx, store.CardPolicy{ + ICCID: iccid, VoWiFiEnabled: true, AirplaneEnabled: true, + IPVersion: "IPV4V6", Source: "default", + }); err != nil { + logger.Warn("default card policy: persist policy", "iccid", iccid, "error", err) + return + } + mapper := integration.ATMapper{Store: database, Devices: manager} + configs, err := database.ListDevices(ctx) + if err != nil { + return + } + for _, config := range configs { + entry, mapErr := mapper.Get(config.ID) + if mapErr != nil || entry.ID != physicalID { + continue + } + config.NetworkEnabled = false + config.VoWiFiEnabled = true + if err := database.UpsertDevice(ctx, config); err != nil { + logger.Warn("default card policy: update device policy", "device_id", config.ID, "error", err) + } + break + } + logger.Info("new SIM protected by default VoWiFi/airplane policy", "device_id", physicalID, "iccid", iccid) +} + +func reconcileCardPolicies( + ctx context.Context, + logger *slog.Logger, + database *store.Store, + manager *device.Manager, + vowifiManager *vowifiruntime.Manager, +) { + reconcile := func() { + policies, policyListErr := database.ListCardPolicies(ctx) + if policyListErr == nil { + for _, policy := range policies { + if !policy.VoWiFiEnabled || (policy.AirplaneEnabled && !policy.NetworkEnabled) { + continue + } + policy.AirplaneEnabled = true + policy.NetworkEnabled = false + if err := database.UpsertCardPolicy(ctx, policy); err != nil { + logger.Warn("reconcile card policy: normalize stored RF-safe VoWiFi policy", "iccid", policy.ICCID, "error", err) + } + } + } + configs, err := database.ListDevices(ctx) + if err != nil { + return + } + mapper := integration.ATMapper{Store: database, Devices: manager} + for _, config := range configs { + entry, mapErr := mapper.Get(config.ID) + if mapErr != nil || entry.Snapshot == nil { + continue + } + iccid := strings.TrimSpace(entry.Snapshot.ICCID) + if iccid == "" { + continue + } + policy, policyErr := database.CardPolicy(ctx, iccid) + if policyErr != nil { + continue + } + if policy.VoWiFiEnabled && (!policy.AirplaneEnabled || policy.NetworkEnabled) { + policy.AirplaneEnabled = true + policy.NetworkEnabled = false + if err := database.UpsertCardPolicy(ctx, policy); err != nil { + logger.Warn("reconcile card policy: normalize RF-safe VoWiFi policy", "device_id", config.ID, "iccid", iccid, "error", err) + continue + } + } + if config.VoWiFiEnabled != policy.VoWiFiEnabled || (policy.VoWiFiEnabled && config.NetworkEnabled) { + config.VoWiFiEnabled = policy.VoWiFiEnabled + if policy.VoWiFiEnabled { + config.NetworkEnabled = false + } + if err := database.UpsertDevice(ctx, config); err != nil { + logger.Warn("reconcile card policy: update device", "device_id", config.ID, "error", err) + continue + } + } + state, stateErr := vowifiManager.State(config.ID) + if policy.VoWiFiEnabled { + if !entry.Snapshot.FlightMode { + flightContext, cancel := context.WithTimeout(ctx, 10*time.Second) + _, _ = manager.SetFlight(flightContext, entry.ID, true) + cancel() + } + switch { + case stateErr != nil || !state.Enabled: + _, _ = vowifiManager.RequestEnabled(config.ID, true) + case state.ICCID != "" && !strings.EqualFold(strings.TrimSpace(state.ICCID), iccid): + _, _ = vowifiManager.RequestReconnect(config.ID) + } + continue + } + if stateErr == nil && state.Enabled { + _, _ = vowifiManager.RequestEnabled(config.ID, false) + continue + } + if policy.AirplaneEnabled != entry.Snapshot.FlightMode { + flightContext, cancel := context.WithTimeout(ctx, 10*time.Second) + _, _ = manager.SetFlight(flightContext, entry.ID, policy.AirplaneEnabled) + cancel() + } + } + } + reconcile() + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + reconcile() + } + } +} + // cardPolicySourceRegionBlock marks a card policy that was written automatically // because the inserted SIM belongs to a region the product does not serve. It // doubles as the persistent record that the radio was forced off by us, so the @@ -848,10 +1068,10 @@ func enforceCardRegion( liftCardRegionBlock(ctx, logger, database, manager, id, snapshot) } -// liftCardRegionBlock reverses an automatic region block once the current SIM -// is positively confirmed to be allowed. It restores the radio only when an -// outstanding auto-forced block exists, so it never overrides a flight mode the -// user enabled deliberately. +// liftCardRegionBlock removes the regional marker once an allowed SIM is +// confirmed. It deliberately does not restore RF: the replacement SIM is +// picked up by enforceDefaultSafeCardPolicy and remains in airplane/VoWiFi +// mode until an explicit user action. func liftCardRegionBlock( ctx context.Context, logger *slog.Logger, @@ -876,18 +1096,6 @@ func liftCardRegionBlock( if len(outstanding) == 0 { return } - if snapshot.FlightMode { - flightContext, cancelFlight := context.WithTimeout(ctx, 30*time.Second) - _, err := manager.SetFlight(flightContext, id, false) - cancelFlight() - if err != nil && ctx.Err() == nil { - logger.Warn( - "region block: failed to restore radio", - "device_id", id, "error", err, - ) - return - } - } for _, policy := range outstanding { if err := database.DeleteCardPolicy(ctx, policy.ICCID); err != nil && ctx.Err() == nil { logger.Warn( @@ -897,7 +1105,7 @@ func liftCardRegionBlock( } } logger.Info( - "region block lifted; SIM is allowed", + "region marker removed; allowed SIM remains RF protected", "device_id", id, "iccid", snapshot.ICCID, "imsi", snapshot.IMSI, ) } diff --git a/cmd/vocat/main_test.go b/cmd/vocat/main_test.go index 8240572..90fc88e 100644 --- a/cmd/vocat/main_test.go +++ b/cmd/vocat/main_test.go @@ -152,11 +152,7 @@ func TestEnforceCardRegionSkipsRadioWhenAlreadyOff(t *testing.T) { } func TestEnforceCardRegionLiftsBlockForAllowedSIM(t *testing.T) { - client := &fakeModemClient{steps: []fakeStep{ - {command: "AT+CFUN?", lines: []string{"+CFUN: 4"}}, - {command: "AT+CFUN=1"}, - {command: "AT+CFUN?", lines: []string{"+CFUN: 1"}}, - }} + client := &fakeModemClient{} manager := newRegionTestManager(t, client) database := newRegionTestStore(t) diff --git a/internal/device/carrier_db.go b/internal/device/carrier_db.go index 7cb912c..9b23640 100644 --- a/internal/device/carrier_db.go +++ b/internal/device/carrier_db.go @@ -42,3 +42,25 @@ func CarrierForPLMN(plmn string) (name, countryCode string, ok bool) { } return name, countryCode, true } + +// CarrierForIMSI resolves the home PLMN carried by an IMSI. MNCs may contain +// either two or three digits, so prefer an exact six-digit database match and +// then fall back to the five-digit form. This avoids treating the first three +// subscriber digits as a three-digit MNC for networks such as 234-33. +func CarrierForIMSI(imsi string) (plmn, name, countryCode string, ok bool) { + imsi = strings.TrimSpace(imsi) + if !decimalDigits(imsi, 5, 20) { + return "", "", "", false + } + for _, length := range []int{6, 5} { + if len(imsi) < length { + continue + } + candidate := imsi[:length] + carrier, country, found := CarrierForPLMN(candidate) + if found { + return candidate, carrier, country, true + } + } + return "", "", "", false +} diff --git a/internal/device/data.go b/internal/device/data.go index 8dc6fbc..4d8c961 100644 --- a/internal/device/data.go +++ b/internal/device/data.go @@ -43,7 +43,21 @@ func (manager *Manager) SetNetwork( } } candidate := manager.candidateFor(state) - if candidate.QMIControl != "" && candidate.NetworkInterface != "" { + backend := strings.ToLower(strings.TrimSpace(request.Backend)) + if backend == "" { + if candidate.QMIControl != "" && candidate.NetworkInterface != "" { + backend = "qmi" + } else { + backend = "at" + } + } + if backend != "at" && backend != "qmi" { + return NetworkResult{}, fmt.Errorf("unsupported cellular data backend %q", request.Backend) + } + if backend == "qmi" { + if candidate.QMIControl == "" || candidate.NetworkInterface == "" { + return NetworkResult{}, fmt.Errorf("%w: QMI control device and network interface are required", ErrDataBackendUnavailable) + } return setQMINetwork(ctx, candidate, request.Enabled, apn, ipVersion) } diff --git a/internal/device/esim.go b/internal/device/esim.go index 5b9fa9e..438c43a 100644 --- a/internal/device/esim.go +++ b/internal/device/esim.go @@ -26,6 +26,12 @@ import ( // isdRAID is the standard ISD-R AID that hosts the LPA functions (ES10). const isdRAID = "A0000005591010FFFFFFFF8900000100" +// xesimISDRAID is the alternate ISD-R application exposed by XeSIM cards. +// It implements the same ES10 interface, but is not selectable through the +// standard ...0100 AID. Selecting it is a read-only capability probe; profile +// state is never changed during discovery. +const xesimISDRAID = "A0000005591010FFFFFFFF8900000177" + // eSTK multi-SE products expose each eUICC storage through its own vendor // ISD-R AID. The standard GSMA AID aliases one of them, so probing only that // AID silently hides the second storage. @@ -296,20 +302,32 @@ func (manager *Manager) openEuiccOnceAID(ctx context.Context, id, aidHex string) return channel, nil } -// discoverEuiccAIDs detects eSTK multi-SE cards without changing any profile -// state. The vendor product applet is selected only as a read-only capability -// probe; when present, both vendor ISD-R AIDs are tried. Per OpenEUICC's eSTK -// integration, the generic GSMA AID is not appended after an eSTK SE opens, -// because it aliases one of the same storages. +// discoverEuiccAIDs detects eSTK multi-SE and alternate-ISD-R cards without +// changing any profile state. The vendor product applet and candidate ISD-R +// applications are selected only as read-only capability probes. Per +// OpenEUICC's eSTK integration, generic AIDs are not appended after an eSTK SE +// opens, because the standard AID aliases one of the same storages. func (manager *Manager) discoverEuiccAIDs(ctx context.Context, id string) []string { product, err := manager.openEuiccAID(ctx, id, estkProductAID) - if err != nil { - return []string{isdRAID} + if err == nil { + product.close(context.Background()) + + var found []string + for _, aid := range []string{estkSE0AID, estkSE1AID} { + channel, err := manager.openEuiccAID(ctx, id, aid) + if err != nil { + continue + } + channel.close(context.Background()) + found = append(found, aid) + } + if len(found) > 0 { + return found + } } - product.close(context.Background()) var found []string - for _, aid := range []string{estkSE0AID, estkSE1AID} { + for _, aid := range []string{isdRAID, xesimISDRAID} { channel, err := manager.openEuiccAID(ctx, id, aid) if err != nil { continue @@ -317,10 +335,12 @@ func (manager *Manager) discoverEuiccAIDs(ctx context.Context, id string) []stri channel.close(context.Background()) found = append(found, aid) } - if len(found) == 0 { - return []string{isdRAID} + if len(found) > 0 { + return found } - return found + // Preserve the old error path for a physical SIM with no eUICC. The caller + // retries the standard AID once and returns ErrNoEUICC to the HTTP layer. + return []string{isdRAID} } func isTransientEuiccCME(err error) bool { @@ -531,18 +551,27 @@ func (manager *Manager) ESIMListProfiles(ctx context.Context, id string) (EsimIn } return EsimInfo{}, errESIMRecovering } - channel, err := manager.openEuicc(ctx, id) - if err != nil { - return EsimInfo{}, err + var lastErr error + for _, aid := range manager.discoverEuiccAIDs(ctx, id) { + channel, err := manager.openEuiccAID(ctx, id, aid) + if err != nil { + lastErr = err + continue + } + payload, err := channel.es10(ctx, []byte{0xBF, 0x2D, 0x00}) // GetProfilesInfo + channel.close(context.Background()) + if err != nil { + lastErr = err + continue + } + info := EsimInfo{AID: aid, Profiles: parseProfilesInfo(payload)} + manager.cacheESIMInfo(id, info) + return info, nil } - defer channel.close(context.Background()) - payload, err := channel.es10(ctx, []byte{0xBF, 0x2D, 0x00}) // GetProfilesInfo - if err != nil { - return EsimInfo{}, err + if lastErr != nil { + return EsimInfo{}, lastErr } - info := EsimInfo{Profiles: parseProfilesInfo(payload)} - manager.cacheESIMInfo(id, info) - return info, nil + return EsimInfo{}, ErrNoEUICC } // ESIMSwitchProfile enables one profile by ICCID via ES10c EnableProfile. @@ -774,7 +803,14 @@ func (manager *Manager) refreshAfterProfileSwitch(id string) { time.Sleep(settle) for attempt := 0; attempt < attempts; attempt++ { ctx, cancel := context.WithTimeout(context.Background(), manager.commandTimeout*4) - _, err := manager.Refresh(ctx, id) + _, _ = manager.Discover(ctx) + _, flightErr := manager.SetFlight(ctx, id, true) + var err error + if flightErr == nil { + _, err = manager.Refresh(ctx, id) + } else { + err = flightErr + } cancel() if err == nil { return diff --git a/internal/device/esim_download.go b/internal/device/esim_download.go index 2f97e97..e04e3be 100644 --- a/internal/device/esim_download.go +++ b/internal/device/esim_download.go @@ -217,17 +217,26 @@ type EsimChipInfo struct { func (manager *Manager) ESIMChipInfo(ctx context.Context, id string) (*EsimChipInfo, error) { manager.esimMu.Lock() defer manager.esimMu.Unlock() - channel, err := manager.openEuicc(ctx, id) - if err != nil { - return nil, err - } - defer channel.close(context.Background()) - info, err := readEsimChipInfo(ctx, channel, isdRAID) - if err != nil { - return nil, err + var lastErr error + for _, aid := range manager.discoverEuiccAIDs(ctx, id) { + channel, err := manager.openEuiccAID(ctx, id, aid) + if err != nil { + lastErr = err + continue + } + info, err := readEsimChipInfo(ctx, channel, aid) + channel.close(context.Background()) + if err != nil { + lastErr = err + continue + } + return &info, nil } - return &info, nil + if lastErr != nil { + return nil, lastErr + } + return nil, ErrNoEUICC } func readEsimChipInfo(ctx context.Context, channel *euiccChannel, aidHex string) (EsimChipInfo, error) { diff --git a/internal/device/esim_test.go b/internal/device/esim_test.go index febd201..14baab7 100644 --- a/internal/device/esim_test.go +++ b/internal/device/esim_test.go @@ -244,6 +244,45 @@ func TestTransientEuiccCMEClassification(t *testing.T) { } } +func TestDiscoverEuiccAIDsFindsXeSIMAlternateISDR(t *testing.T) { + manageChannel := clientStep{ + command: `AT+CSIM=10,"0070000001"`, + response: okResponse(`+CSIM: 6,"019000"`), + } + closeChannel := clientStep{ + command: `AT+CSIM=10,"0070800100"`, + response: okResponse(`+CSIM: 4,"9000"`), + } + selectStep := func(aid, response string) clientStep { + return clientStep{ + command: fmt.Sprintf(`AT+CSIM=42,"01A4040010%s"`, aid), + response: okResponse(fmt.Sprintf(`+CSIM: 4,"%s"`, response)), + } + } + + client := &transcriptClient{steps: []clientStep{ + // No eSTK product applet on this card. + manageChannel, + selectStep(estkProductAID, "6A82"), + closeChannel, + // XeSIM does not expose the standard GSMA ...0100 application. + manageChannel, + selectStep(isdRAID, "6A82"), + closeChannel, + // Its dedicated ...0177 ISD-R is selectable. + manageChannel, + selectStep(xesimISDRAID, "9000"), + closeChannel, + }} + manager, id := newStartedTestManager(t, client) + + aids := manager.discoverEuiccAIDs(context.Background(), id) + if len(aids) != 1 || aids[0] != xesimISDRAID { + t.Fatalf("discovered AIDs = %#v, want XeSIM %s", aids, xesimISDRAID) + } + client.assertDone(t) +} + func TestEUICCChannelStuckWrapsTransientCME(t *testing.T) { cause := &modem.CommandError{ Command: `AT+CSIM=10,"0070000001"`, diff --git a/internal/device/manager.go b/internal/device/manager.go index 438d64f..4d65093 100644 --- a/internal/device/manager.go +++ b/internal/device/manager.go @@ -50,6 +50,8 @@ type ussdSession struct { type managedDevice struct { opMu sync.Mutex candidate modem.Candidate + backend string + lastICCID string client modem.Client snapshot *Snapshot lastError string @@ -358,16 +360,44 @@ func (manager *Manager) Refresh(ctx context.Context, id string) (Snapshot, error return Snapshot{}, err } candidate := manager.candidateFor(state) + backend := manager.backendFor(state) client, err := manager.clientLocked(ctx, state, candidate) if err != nil { manager.setResult(id, state, nil, err) return Snapshot{}, err } - snapshot, err := manager.readSnapshot(ctx, id, candidate, client) + previousICCID := state.lastICCID + snapshot, err := manager.readSnapshot(ctx, id, candidate, backend, previousICCID, client) + if err == nil && strings.TrimSpace(snapshot.ICCID) != "" { + state.lastICCID = strings.TrimSpace(snapshot.ICCID) + } manager.setResult(id, state, &snapshot, err) return snapshot, err } +// SetBackend selects which control plane supplies registration and data state. +// AT remains available in either mode for UICC, RF, SMS, voice and diagnostics. +func (manager *Manager) SetBackend(id, backend string) error { + backend = strings.ToLower(strings.TrimSpace(backend)) + if backend != "at" && backend != "qmi" { + return fmt.Errorf("unsupported device backend %q", backend) + } + manager.mu.Lock() + defer manager.mu.Unlock() + state := manager.devices[id] + if state == nil || !state.discovered { + return ErrNotFound + } + state.backend = backend + return nil +} + +func (manager *Manager) backendFor(state *managedDevice) string { + manager.mu.RLock() + defer manager.mu.RUnlock() + return state.backend +} + func (manager *Manager) ExecuteAT( ctx context.Context, id string, diff --git a/internal/device/manager_test.go b/internal/device/manager_test.go index 5f7de2a..b1a6361 100644 --- a/internal/device/manager_test.go +++ b/internal/device/manager_test.go @@ -19,6 +19,14 @@ func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) { ), }, {command: "AT+CPIN?", response: okResponse("+CPIN: READY")}, + { + command: "AT+CCID", + response: modem.Response{Final: "+CME ERROR: 100"}, + err: errors.New("CCID unsupported"), + }, + {command: "AT+QCCID", response: okResponse("+QCCID: 8986001234567890123F")}, + {command: "AT+CIMI", response: okResponse("460001234567890")}, + {command: "AT+CRSM=176,28486,0,0,17", response: okResponse(`+CRSM: 144,0,"00434D4343FFFFFFFFFFFFFFFFFFFFFFFF"`)}, {command: "AT+CSQ", response: okResponse("+CSQ: 20,99")}, { command: `AT+QENG="servingcell"`, @@ -29,13 +37,6 @@ func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) { {command: "AT+COPS?", response: okResponse(`+COPS: 0,0,"China Mobile",7`)}, {command: "AT+CEREG?", response: okResponse(`+CEREG: 0,5`)}, {command: "AT+CGSN", response: okResponse("867123456789012")}, - { - command: "AT+CCID", - response: modem.Response{Final: "+CME ERROR: 100"}, - err: errors.New("CCID unsupported"), - }, - {command: "AT+QCCID", response: okResponse("+QCCID: 8986001234567890123F")}, - {command: "AT+CIMI", response: okResponse("460001234567890")}, {command: "AT+CFUN?", response: okResponse("+CFUN: 1")}, {command: "AT+CNUM", response: okResponse(`+CNUM: "","+8613800138000",145`)}, }} @@ -74,7 +75,7 @@ func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) { } if snapshot.IMEI != "867123456789012" || snapshot.ICCID != "8986001234567890123" || - snapshot.IMSI != "460001234567890" { + snapshot.IMSI != "460001234567890" || snapshot.SPN != "CMCC" { t.Fatalf("subscriber identifiers = %#v", snapshot) } if !snapshot.ModeKnown || snapshot.OperatingMode != 1 || @@ -96,6 +97,18 @@ func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) { client.assertDone(t) } +func TestParseSPNASCIIAndUCS2(t *testing.T) { + if got := parseSPN(okResponse(`+CRSM: 144,0,"004C6562617261FFFFFFFFFFFFFFFFFFFF"`)); got != "Lebara" { + t.Fatalf("ASCII SPN = %q", got) + } + if got := parseSPN(okResponse(`+CRSM: 144,0,"0080004C00650062006100720061FFFF"`)); got != "Lebara" { + t.Fatalf("UCS2 SPN = %q", got) + } + if got := parseSPN(okResponse(`+CRSM: 106,130,""`)); got != "" { + t.Fatalf("failed CRSM SPN = %q", got) + } +} + func TestParseICCIDIdentifierStripsTwoFillerNibbles(t *testing.T) { response := modem.Response{Lines: []string{"+CCID: 894921007608519523FF"}} if got := parseICCIDIdentifier(response, []string{"+CCID:", "+QCCID:"}, 18, 22); got != "894921007608519523" { @@ -122,6 +135,57 @@ func TestManagerRequiresStartAndKnownDevice(t *testing.T) { } } +func TestManagerBackendSelectionIsExplicit(t *testing.T) { + manager, id := newStartedTestManager(t, &transcriptClient{}) + if err := manager.SetBackend(id, "qmi"); err != nil { + t.Fatal(err) + } + state, err := manager.lookup(id) + if err != nil { + t.Fatal(err) + } + if got := manager.backendFor(state); got != "qmi" { + t.Fatalf("backend = %q, want qmi", got) + } + if err := manager.SetBackend(id, "mbim"); err == nil { + t.Fatal("unsupported backend was accepted") + } +} + +func TestManagerForcesRFOffBeforeInspectingChangedSIMNetwork(t *testing.T) { + client := &transcriptClient{steps: []clientStep{ + {command: "ATI", response: okResponse("Quectel", "EC20", "Revision: test")}, + {command: "AT+CPIN?", response: okResponse("+CPIN: READY")}, + {command: "AT+CCID", response: okResponse("+CCID: 8900000000000000002")}, + // This must precede CIMI, signal, serving-cell and operator queries. + {command: "AT+CFUN=4", response: okResponse()}, + {command: "AT+CIMI", response: okResponse("234150000000002")}, + {command: "AT+CRSM=176,28486,0,0,17", response: okResponse(`+CRSM: 144,0,"004C6562617261FFFFFFFFFFFFFFFFFFFF"`)}, + {command: "AT+CSQ", response: okResponse("+CSQ: 99,99")}, + {command: `AT+QENG="servingcell"`, response: okResponse(`+QENG: "servingcell","SEARCH"`)}, + {command: "AT+COPS?", response: okResponse("+COPS: 0")}, + {command: "AT+CEREG?", response: okResponse("+CEREG: 0,0")}, + {command: "AT+CGSN", response: okResponse("867123456789012")}, + {command: "AT+CFUN?", response: okResponse("+CFUN: 4")}, + {command: "AT+CNUM", response: okResponse(`+CNUM: "","+447700900002",145`)}, + }} + manager, id := newStartedTestManager(t, client) + state, err := manager.lookup(id) + if err != nil { + t.Fatal(err) + } + state.lastICCID = "8900000000000000001" + + snapshot, err := manager.Refresh(context.Background(), id) + if err != nil { + t.Fatal(err) + } + if !snapshot.SIMChanged || !snapshot.FlightMode || snapshot.OperatingMode != 4 { + t.Fatalf("changed SIM snapshot = %#v", snapshot) + } + client.assertDone(t) +} + func TestExecuteSensitiveATDoesNotPersistCommandOrModemError(t *testing.T) { const secretCommand = `AT+CSIM=78,"00880081221000112233445566778899AABBCCDDEEFF1000112233445566778899AABBCCDDEEFF00"` client := &transcriptClient{steps: []clientStep{{ diff --git a/internal/device/mccmnc.json b/internal/device/mccmnc.json index f622126..97f6ddb 100644 --- a/internal/device/mccmnc.json +++ b/internal/device/mccmnc.json @@ -1 +1 @@ -{"c":{"00101":["Test Network, Used by GSM test equipment",""],"20201":["Cosmote","gr"],"20202":["Cosmote","gr"],"20203":["OTE","gr"],"20204":["OSE","gr"],"20205":["Vodafone","gr"],"20207":["AMD Telecom","gr"],"20209":["Info Quest S.A.","gr"],"20210":["Telestet","gr"],"20212":["Yuboto","gr"],"20214":["CyTa Mobile","gr"],"20215":["BWS","gr"],"20216":["Inter Telecom","gr"],"202299":["AMD Telecom","gr"],"202999":["Fix Line","gr"],"20400":["Intovoice","nl"],"20402":["T-Mobile","nl"],"20403":["Voiceworks NL","nl"],"20404":["Vodafone","nl"],"20405":["ElephantTalk","nl"],"20406":["Vectone Mobile","nl"],"20407":["Move / Teleena","nl"],"20408":["KPN Mobiel","nl"],"20409":["Lycamobile","nl"],"20410":["KPN","nl"],"20412":["KPN Mobiel","nl"],"20414":["6GMOBILE BV","nl"],"20415":["Ziggo","nl"],"20416":["Odido","nl"],"20417":["Intercity Mobile Communications BV","nl"],"20418":["Ziggo Services","nl"],"20420":["T-Mobile","nl"],"20421":["NS Railinfrabeheer B.V.","nl"],"20423":["KORE","nl"],"20424":["Private Mobility","nl"],"20426":["SpeakUp","nl"],"20427":["L-mobi","nl"],"20428":["Lancelot","nl"],"20429":["Tismi","nl"],"204299":["88 mobile","nl"],"20430":["ASPIDER Solutions","nl"],"20433":["Truphone","nl"],"20463":["MessageBird","nl"],"20465":["AGMS","nl"],"20468":["Unify Mobile","nl"],"20469":["KPN Lab","nl"],"20498":["Lancelot","nl"],"204999":["Fix Line","nl"],"20600":["Proximus","be"],"20601":["Proximus","be"],"20602":["Infrabel","be"],"20604":["Proximus","be"],"20605":["Telenet","be"],"20606":["Lycamobile","be"],"20607":["Vectone Mobile","be"],"20608":["VOOmobile","be"],"20610":["Orange","be"],"20620":["BASE","be"],"20623":["Dust Mobile","be"],"20625":["Dense Air","be"],"20628":["Bics","be"],"206299":["FEBO","be"],"20630":["Unleashed","be"],"20633":["Ericsson","be"],"20634":["onoff","be"],"20699":["Lancelot","be"],"206999":["Fix Line","be"],"20800":["Tel/Te","fr"],"20801":["Orange","fr"],"20802":["Orange","fr"],"20803":["MobiquiThings","fr"],"20804":["Netcom Group","fr"],"20805":["Globalstar Europe","fr"],"20806":["Globalstar Europe","fr"],"20807":["Globalstar Europe","fr"],"20808":["SFR","fr"],"20809":["SFR","fr"],"20810":["SFR","fr"],"20811":["SFR","fr"],"20812":["Truphone","fr"],"20813":["SFR","fr"],"20814":["Free Mobile","fr"],"20815":["Free","fr"],"20816":["Free Mobile","fr"],"20817":["Legos","fr"],"208180":["Private FR","fr"],"20820":["Bouygues Telecom","fr"],"20821":["Bouygues Telecom","fr"],"20822":["Transatel","fr"],"20823":["Virgin","fr"],"20824":["MobiquiThings","fr"],"20825":["Lycamobile","fr"],"20826":["NRJ","fr"],"20827":["Coriolis","fr"],"20828":["Airmob","fr"],"20829":["Orange","fr"],"208299":["Add-On Multimedia","fr"],"20830":["Syma Mobile","fr"],"20831":["Vectone Mobile","fr"],"20832":["Orange","fr"],"20834":["Cellhire","fr"],"20835":["Free Mobile","fr"],"20836":["Free Mobile","fr"],"20837":["IP Directions","fr"],"20838":["Lebara","fr"],"20839":["Networth Telecom","fr"],"208506":["Airbus FR","fr"],"20888":["Bouygues Telecom","fr"],"20889":["Hub One","fr"],"20891":["Orange","fr"],"20892":["IP Directions","fr"],"20894":["Halys","fr"],"208999":["Fix Line","fr"],"21201":["Monaco Telecom","mc"],"21210":["MONACO TELECOM","mc"],"21303":["Mobiland","ad"],"21401":["Vodafone","es"],"21402":["Altecom","es"],"21403":["Orange","es"],"21404":["Yoigo","es"],"21405":["Movistar","es"],"21406":["Euskaltel","es"],"21407":["Movistar","es"],"21408":["Euskaltel","es"],"21409":["Orange","es"],"21410":["Zinnia","es"],"21411":["Orange","es"],"21412":["Venus Movil","es"],"21414":["Avatel Movil","es"],"21415":["BT Espana SAU","es"],"21416":["mobil R","es"],"21417":["mobil R","es"],"21418":["ONO","es"],"21419":["Simyo","es"],"21420":["Fonyou Telecom","es"],"21421":["Jazz Telecom SAU","es"],"21422":["Digi Spain","es"],"21423":["Yoigo","es"],"21425":["Lycamobile","es"],"21426":["Lleida","es"],"21427":["Truphone","es"],"21429":["Yoigo","es"],"214299":["ACN","es"],"21432":["ION Mobile","es"],"21433":["Yoigo","es"],"21434":["ION Mobile","es"],"21435":["SUMA movil","es"],"21436":["Alai","es"],"21437":["Vodafone","es"],"21438":["Movistar","es"],"214999":["Fix Line","es"],"21601":["Yettel","hu"],"21602":["MVM NET","hu"],"21603":["Digi","hu"],"216299":["Antenna","hu"],"21630":["Magyar Telekom","hu"],"21670":["Vodafone","hu"],"21671":["UPC Magyarorszag Kft.","hu"],"216999":["Fix line","hu"],"21803":["Eronet Mobile Communications Ltd.","ba"],"21805":["MOBI'S (Mobilina Srpske)","ba"],"21890":["GSMBIH","ba"],"21901":["Hrvatski Telekom","hr"],"21902":["Telemach","hr"],"21910":["A1/Tomato","hr"],"21912":["TELE FOCUS","hr"],"21920":["Hrvatski Telekom","hr"],"219999":["Fix Line","hr"],"22001":["Yettel","rs"],"22002":["Yettel","rs"],"22003":["Telekom Srbija a.d.","rs"],"22005":["A1 SRB","rs"],"22011":["Globaltel","rs"],"22020":["VIP","rs"],"220299":["Failed Calls","rs"],"22101":["Vala","xk"],"22102":["IPKO","xk"],"22103":["MTS","xk"],"22106":["Dardafon.Net LLC","xk"],"22107":["D3 mobile","xk"],"221299":["MTS","xk"],"22200":["Premium Numbers","it"],"22201":["TIM","it"],"22202":["Elsacom","it"],"22206":["Vodafone","it"],"22207":["Kena","it"],"22208":["Fastweb SpA","it"],"22210":["Vodafone","it"],"222299":["A-Tono","it"],"22230":["RFI","it"],"22233":["Poste Mobile","it"],"22234":["BT mobile","it"],"22235":["Lycamobile","it"],"22236":["Digi Italy","it"],"22237":["WindTre / Hi3G","it"],"22239":["SMS.it / LINK Mobility","it"],"22240":["Agile Telecom","it"],"22242":["Enel","it"],"22243":["Telecom Italia Mobile","it"],"22244":["Mundio","it"],"22248":["Telecom Italia Mobile","it"],"22249":["Vianova Mobile","it"],"22250":["Iliad","it"],"22251":["ho.","it"],"22253":["WEB CoopVoce","it"],"22254":["Plintron","it"],"22256":["Spusu IT","it"],"22258":["rdcom","it"],"22277":["IPSE 2000","it"],"22288":["WINDTRE","it"],"22298":["Blu","it"],"22299":["WINDTRE","it"],"222999":["Fix Line","it"],"225299":["Failed Calls","va"],"22601":["Vodafone","ro"],"22602":["Romtelecom SA","ro"],"22603":["Telekom","ro"],"22604":["Telekom Romania","ro"],"22605":["Digi.Mobil","ro"],"22606":["Telekom Romania","ro"],"22610":["Orange","ro"],"22611":["Enigma Systems","ro"],"22616":["Lycamobile","ro"],"226299":["Iristel","ro"],"22801":["Swisscom","ch"],"22802":["Sunrise","ch"],"22803":["Salt","ch"],"22805":["Comfone AG","ch"],"22806":["SBB AG","ch"],"22807":["IN&Phone SA","ch"],"22808":["Tele2 Telecommunications AG","ch"],"22809":["Comfone","ch"],"22812":["Sunrise","ch"],"22851":["Bebbicell AG","ch"],"22852":["Mundio Mobile AG","ch"],"22853":["Sunrise","ch"],"22854":["Lycamobile","ch"],"22858":["Beeone","ch"],"22859":["Vectone Mobile","ch"],"22860":["Sunrise","ch"],"22862":["Telecom26","ch"],"22865":["Nexphone","ch"],"22866":["Inovia","ch"],"22869":["MTEL","ch"],"22870":["Tismi","ch"],"22871":["Spusu CH","ch"],"228999":["Fix Line","ch"],"23001":["T-Mobile","cz"],"23002":["O2","cz"],"23003":["Vodafone","cz"],"23004":["Mobilkom a.s.","cz"],"23005":["PODA","cz"],"23007":["T-Mobile","cz"],"23008":["Compatel","cz"],"23009":["Uniphone","cz"],"230299":["+4U Mobile","cz"],"23098":["Sprava Zeleznicni Dopravni Cesty","cz"],"23099":["Vodafone","cz"],"230999":["Fix Line","cz"],"23101":["Orange","sk"],"23102":["Slovak Telekom","sk"],"23103":["4ka SK","sk"],"23104":["Eurotel, UMTS","sk"],"23105":["Orange, UMTS","sk"],"23106":["O2","sk"],"23107":["Orange","sk"],"23108":["Uniphone","sk"],"23115":["Orange","sk"],"231299":["Vonage","sk"],"23150":["Telekom","sk"],"23199":["ZSR","sk"],"23201":["A1 Telekom","at"],"23202":["A1 Telekom","at"],"23203":["Magenta Telekom","at"],"23204":["T-Mobile / Magenta","at"],"23205":["Drei","at"],"23206":["Hutchison Drei / 3","at"],"23207":["Magenta Telekom","at"],"23208":["Telefonica Austria","at"],"23209":["A1 Telekom","at"],"23210":["Drei","at"],"23211":["A1 Telekom","at"],"23212":["A1 Telekom","at"],"23213":["T-Mobile / Magenta","at"],"23214":["Hutchinson Drei","at"],"23215":["T-Mobile / Magenta","at"],"23216":["Hutchinson Drei","at"],"23217":["Spusu AT","at"],"23218":["smartspace","at"],"23219":["Hutchinson Drei","at"],"23220":["Mtel","at"],"23222":["Plintron","at"],"23223":["T-Mobile / Magenta","at"],"23224":["Smartel Services","at"],"23225":["Holding Graz","at"],"23226":["LIWEST Mobil","at"],"23227":["Tismi","at"],"232299":["ArgoNET","at"],"23291":["OBB Infrastruktur","at"],"232999":["Fix Line","at"],"23400":["British Telecom","gb"],"23401":["Mapesbury Communications Ltd.","gb"],"23402":["O2","gb"],"23403":["Jersey Telenet Ltd","gb"],"23404":["FMS Solutions Ltd","gb"],"23405":["Spitfire Network Services Ltd","gb"],"23406":["Internet One Ltd","gb"],"23407":["Cable and Wireless plc","gb"],"23408":["BT OnePhone","gb"],"23409":["Wire9 Telecom plc","gb"],"23410":["O2","gb"],"23411":["O2","gb"],"23412":["Ntework Rail Infrastructure Ltd","gb"],"23413":["Ntework Rail Infrastructure Ltd","gb"],"23414":["Hay Systems Ltd","gb"],"23415":["Vodafone","gb"],"23416":["Opal Telecom Ltd","gb"],"23417":["Flextel Ltd","gb"],"23418":["Wire9 Telecom plc","gb"],"23419":["Teleware plc","gb"],"23420":["Three Mobile","gb"],"23422":["Telesign Mobile","gb"],"23423":["Icron Network","gb"],"23424":["Greenfone","gb"],"23425":["Truphone","gb"],"23426":["Lycamobile","gb"],"23427":["Tata Communications Ltd","gb"],"23428":["Marathon Telecom","gb"],"23429":["aql","gb"],"23430":["EE","gb"],"23431":["EE","gb"],"23432":["EE","gb"],"23433":["EE","gb"],"23434":["Orange","gb"],"23435":["JSC Ingenicum","gb"],"23436":["Sure Isle of Man","gb"],"23437":["Synectiv","gb"],"23438":["Virgin Mobile","gb"],"23439":["Gamma","gb"],"23440":["Spusu GB","gb"],"23450":["Jersey Telecom","gb"],"23451":["now broadband","gb"],"23453":["TANGO","gb"],"23455":["Cable and Wireless Guensey Ltd","gb"],"23456":["NCSC","gb"],"23457":["Sky","gb"],"23458":["Manx Telecom","gb"],"23471":["Emergency Services Network","gb"],"23472":["Hanhaa Mobile","gb"],"23474":["Pareteum","gb"],"23475":["Inquam Telecom (Holdings) Ltd.","gb"],"23476":["British Telecom","gb"],"23477":["Vodafone","gb"],"23478":["Airwave mmO2 Ltd","gb"],"23486":["EE","gb"],"23489":["Vodafone","gb"],"23491":["Vodafone","gb"],"23492":["Vodafone","gb"],"23494":["Three Mobile","gb"],"23495":["Network Rail","gb"],"23499":["08Direct","gb"],"234998":["Virgin Mobile","gb"],"234999":["Fix Line","gb"],"23502":["Everyth. Ev.wh.","gb"],"23594":["Three Mobile","gb"],"23801":["TDC Mobil","dk"],"23802":["Telenor","dk"],"23803":["MIGway A/S","dk"],"23804":["Nexcon.io","dk"],"23806":["3","dk"],"23807":["Barablu Mobile Ltd.","dk"],"23808":["Voxbone / Bandwidth","dk"],"23810":["TDC Mobil","dk"],"23812":["Lycamobile","dk"],"23813":["Compatel","dk"],"23814":["Monty Mobile","dk"],"23815":["Net 1","dk"],"23816":["Tismi","dk"],"23817":["Gotanet","dk"],"23820":["Telia","dk"],"23823":["Banedanmark","dk"],"23825":["Viahub","dk"],"23828":["LINK Mobility","dk"],"23830":["Telia","dk"],"23842":["Greenwave","dk"],"23866":["Telenor","dk"],"23873":["Onomondo","dk"],"23877":["Tele2","dk"],"23888":["Cobira","dk"],"23896":["Telia","dk"],"238999":["Fix Line","dk"],"24001":["Telia Sverige AB","se"],"24002":["3 (Hi3G Access AB)","se"],"24003":["Nordisk Mobiltelefon AS","se"],"24004":["3G Infrastructure Services AB","se"],"24005":["Svenska UMTS-Nät AB","se"],"24006":["Vimla","se"],"24007":["Tele2/Comviq Sverige/Com Hem","se"],"24008":["Telenor Sverige AB","se"],"24009":["Telenor Sweden (not used)","se"],"24010":["Spring Mobil AB","se"],"24011":["Linholmen Science Park AB","se"],"24012":["Barablu Mobile Scandinavia Ltd","se"],"24013":["Ventelo Sverige AB","se"],"24014":["TDC Mobil A/S","se"],"24015":["Wireless Maingate Nordic AB","se"],"24016":["42IT AB","se"],"24017":["Gotanet","se"],"24018":["Messit / Minicall","se"],"24019":["Vectone Mobile","se"],"24020":["Wireless Maingate Message Services AB","se"],"24021":["Banverket","se"],"24022":["EUtel","se"],"24023":["Infobip","se"],"24024":["Telenor","se"],"24025":["Monty Mobile","se"],"24026":["Twilio","se"],"24027":["Globetouch","se"],"24028":["LINK Mobility","se"],"24029":["MI Carrier Services","se"],"24030":["NextGen Mobile Ltd (CardBoardFish)","se"],"24031":["Rebtel","se"],"24032":["Compatel","se"],"24033":["Mobile Arts","se"],"24035":["42 Telecom","se"],"24036":["interactive digital media / IDM","se"],"24037":["Sinch","se"],"24038":["Voxbone / Bandwidth","se"],"24039":["Primlight","se"],"24040":["Netmore","se"],"24042":["Telenor Connexion","se"],"24043":["MobiWeb","se"],"24044":["Telenabler","se"],"24045":["Spirius","se"],"24046":["Viahub","se"],"24047":["Viatel","se"],"24048":["Tismi","se"],"24050":["Telavox","se"],"24063":["Fink Telecom","se"],"240999":["Fix Line","se"],"24201":["Telenor","no"],"242017":["Ventelo AS","no"],"24202":["Telia","no"],"24203":["Teletopia Mobile Communications AS","no"],"24204":["Tele2 Norge AS","no"],"24205":["OneCall","no"],"24206":["ICE","no"],"24207":["Ventelo AS","no"],"24208":["TDC Mobil A/S","no"],"24209":["com4","no"],"24210":["Nkom","no"],"24212":["Telenor","no"],"24214":["Ice Norway","no"],"24215":["eRate","no"],"24216":["Iristel","no"],"24220":["BANE NOR","no"],"24221":["BANE NOR","no"],"24222":["Altibox Mobil","no"],"24223":["Lycamobile","no"],"242299":["bigblu","no"],"242999":["Fix Line","no"],"24403":["DNA","fi"],"24404":["Finnet Networks Ltd.","fi"],"24405":["Elisa","fi"],"24406":["Elisa","fi"],"24407":["Nokia Test Network","fi"],"24408":["Unknown","fi"],"24409":["Finnet Group","fi"],"24410":["TDC","fi"],"24411":["Viahub","fi"],"24412":["DNA","fi"],"24413":["DNA","fi"],"24414":["Alands Mobiltelefon AB","fi"],"24415":["Telit","fi"],"24416":["Oy Finland Tele2 AB","fi"],"24421":["Elisa","fi"],"24424":["Nord Connect","fi"],"24426":["Compatel","fi"],"24429":["Scnl Truphone","fi"],"244299":["Benemen","fi"],"24432":["Voxbone / Bandwidth","fi"],"24433":["VIRVE","fi"],"24435":["Ukko Mobile","fi"],"24436":["Telia","fi"],"24437":["Tismi","fi"],"24438":["NSN","fi"],"24439":["NSN","fi"],"24440":["NSN","fi"],"24441":["NSN","fi"],"24442":["Viahub","fi"],"24443":["Telavox","fi"],"24445":["VIRVE","fi"],"24446":["VIRVE","fi"],"24447":["VIRVE","fi"],"24482":["interactive digital media / IDM","fi"],"24491":["Telia","fi"],"24601":["Telia","lt"],"24602":["BITĖ","lt"],"24603":["Tele2","lt"],"24605":["LTG","lt"],"24606":["Mediafon","lt"],"246299":["SkyCall","lt"],"24701":["LMT","lv"],"24702":["Tele2/ZZ","lv"],"24703":["Telekom Baltija","lv"],"24704":["Beta Telecom","lv"],"24705":["Bite","lv"],"24706":["SIA Rigatta","lv"],"24707":["SIA Master Telecom","lv"],"24708":["VENTA Mobile","lv"],"24709":["XOmobile","lv"],"24710":["LMT","lv"],"247299":["Premium Numbers","lv"],"24801":["Telia","ee"],"24802":["Elisa","ee"],"24803":["Tele2","ee"],"24804":["OY Top Connect","ee"],"24805":["AS Bravocom Mobiil","ee"],"24806":["OY ViaTel","ee"],"24807":["Televõrgu AS","ee"],"24813":["Telia","ee"],"24871":["Siseministeerium (Ministry of Interior)","ee"],"25001":["МТС","ru"],"25002":["MegaFon","ru"],"25003":["Tele2","ru"],"25004":["Sibchallenge","ru"],"25005":["Tele2","ru"],"250050":["Sberbank-Telecom","ru"],"25007":["BM Telecom","ru"],"25009":["Skylink","ru"],"25010":["Don Telecom","ru"],"25011":["Orensot","ru"],"25012":["Tele2","ru"],"25013":["Kuban GSM","ru"],"25015":["ZAO SMARTS","ru"],"25016":["New Telephone Company","ru"],"25017":["Tele2","ru"],"25019":["Volgograd Mobile","ru"],"25020":["Tele2","ru"],"25026":["VTB Mobile","ru"],"25028":["Extel","ru"],"250299":["A-Mobile","ru"],"25032":["Win Mobile","ru"],"25033":["SEVTELECOM","ru"],"25034":["Krymtelecom","ru"],"25035":["Motiv","ru"],"25039":["Tele2","ru"],"25042":["MTT","ru"],"25044":["Stuvtelesot","ru"],"25047":["Next Mobile","ru"],"25048":["Global Telecom","ru"],"25050":["Sberbank","ru"],"25054":["Letai Mobile","ru"],"25055":["Glonass","ru"],"25057":["Matrix Mobile","ru"],"25060":["Volna Mobile","ru"],"25062":["Tinkoff","ru"],"25077":["Glonass","ru"],"25092":["Printelefone","ru"],"25093":["Telecom XXI","ru"],"25097":["Phoenix","ru"],"25099":["Билайн","ru"],"250999":["Fix Line","ru"],"25501":["Ukrainian Mobile Communication, UMC","ua"],"25502":["T-Mobile - UA","ua"],"25503":["Kyivstar GSM","ua"],"25504":["International Telecommunications Ltd.","ua"],"25505":["Golden Telecom","ua"],"25506":["Astelit","ua"],"25507":["Ukrtelecom","ua"],"25521":["CJSC - Telesystems of Ukraine","ua"],"25539":["Golden Telecom","ua"],"25550":["Vodafone","ua"],"25567":["KyivStar","ua"],"25568":["Kyivstar","ua"],"25599":["Phoenix","ua"],"25701":["A1 BY","by"],"25702":["MTS","by"],"25703":["BelCel JV","by"],"25704":["life:)","by"],"25901":["Orange Moldova GSM","md"],"25902":["Moldcell","md"],"25903":["Unite","md"],"25904":["Eventis Mobile GSM","md"],"25905":["Unité","md"],"25999":["Unite","md"],"26001":["Plus","pl"],"26002":["T-Mobile","pl"],"26003":["Orange","pl"],"26004":["Tele2 Polska (Tele2 Polska Sp. Z.o.o.)","pl"],"26005":["IDEA (UMTS)/PTK Centertel sp. Z.o.o.","pl"],"26006":["PLAY","pl"],"26007":["Premium internet","pl"],"26008":["E-Telko","pl"],"26009":["Telekomunikacja Kolejowa (GSM-R)","pl"],"26010":["Telefony Opalenickie","pl"],"26011":["NORDISK Polska","pl"],"26012":["Cyfrowy Polsat","pl"],"26013":["Move","pl"],"26014":["Move","pl"],"26015":["Aero2","pl"],"26016":["Aero2","pl"],"26017":["Aero2","pl"],"26018":["AMD Telecom","pl"],"26019":["NetBalt","pl"],"26020":["Tismi","pl"],"26022":["Twilio","pl"],"26027":["Ntel Solutions","pl"],"260299":["3S","pl"],"26032":["Compatel","pl"],"26034":["T-Mobile","pl"],"26035":["PKP","pl"],"26036":["Mundio Mobile Sp. z o.o.","pl"],"26038":["CallFreedom Sp. z o.o.","pl"],"26039":["Voxbone / Bandwidth","pl"],"26041":["EZ Mobile","pl"],"26042":["MobiWeb","pl"],"26044":["Rebtel","pl"],"26045":["Virgin Mobile","pl"],"26047":["SMSHIGHWAY","pl"],"26048":["Agile Telecom","pl"],"26049":["Messagebird","pl"],"26090":["Polska Spolka Gazownictwa","pl"],"26097":["Politechnika Lodzka Uczelniane","pl"],"26098":["Play","pl"],"260999":["Fix Line","pl"],"26201":["Telekom","de"],"26202":["Vodafone","de"],"26203":["O2","de"],"26204":["Vodafone","de"],"26205":["Telefonica / E-Plus","de"],"26206":["Telekom","de"],"26207":["O2","de"],"26208":["Telefonica / O2","de"],"26209":["Vodafone Lab","de"],"26210":["Arcor AG & Co.","de"],"26211":["O2","de"],"26212":["Dolphin Telecom (Deutschland) GmbH","de"],"26213":["Mobilcom Multimedia GmbH","de"],"26214":["Group 3G UMTS GmbH (Quam)","de"],"26215":["Airdata AG","de"],"26216":["Telefonica / O2","de"],"26217":["Telefonica / E-Plus","de"],"26220":["Voiceworks DE","de"],"26221":["Multiconnect","de"],"26222":["sipgate","de"],"26223":["1&1","de"],"26224":["TelcoVillage","de"],"262299":["1&1","de"],"26233":["sipgate","de"],"26242":["Vodafone","de"],"26243":["Lycamobile","de"],"26276":["Siemens AG, ICMNPGUSTA","de"],"26277":["Telefonica / E-Plus","de"],"26278":["Telekom / T-mobile","de"],"262999":["Fix Line","de"],"26601":["Gibtelecom GSM","gi"],"26606":["CTS Mobile","gi"],"26609":["Cloud9 Mobile Communications","gi"],"266299":["GibFibreSpeed","gi"],"266999":["Fix Line","gi"],"26801":["Vodafone","pt"],"26802":["Digi Portugal","pt"],"26803":["NOS","pt"],"26804":["Lycamobile","pt"],"26805":["Oniway - Inforcomunicaçôes, S.A.","pt"],"26806":["MEO","pt"],"26807":["NOS","pt"],"26808":["MEO","pt"],"268299":["NOWO","pt"],"26880":["MEO","pt"],"26891":["Vodafone","pt"],"26893":["NOS","pt"],"268999":["Fix Line","pt"],"27001":["P&T Luxembourg","lu"],"27002":["MTX","lu"],"27005":["Luxembourg Online","lu"],"27010":["Blue Communications","lu"],"270299":["Bouygues Telecom","lu"],"27077":["Tango","lu"],"27081":["e-LUX Mobile","lu"],"27099":["Orange","lu"],"270999":["Fix Line","lu"],"27201":["Vodafone","ie"],"27202":["3","ie"],"27203":["Meteor Mobile Communications Ltd.","ie"],"27204":["Access Telecom","ie"],"27205":["3","ie"],"27207":["Eircom","ie"],"27208":["Meteor / eir mobile","ie"],"27209":["Clever Communications Ltd.","ie"],"27211":["Tesco Mobile","ie"],"27213":["Lycamobile","ie"],"27215":["Virgin Media","ie"],"27217":["3","ie"],"27225":["Sky IE","ie"],"27401":["Iceland Telecom Ltd.","is"],"27402":["Tal hf","is"],"27403":["Islandssimi GSM ehf","is"],"27404":["IMC Islande ehf","is"],"27405":["Vodafone","is"],"27407":["IceCell ehf","is"],"27408":["Siminn","is"],"27409":["Amitelo","is"],"27411":["Nova","is"],"27412":["Vodafone","is"],"27416":["Tismi","is"],"27431":["Siminn","is"],"27601":["One / AMC","al"],"27602":["Vodafone","al"],"27603":["Eagle Mobile","al"],"27604":["PLUS Communication Sh.a","al"],"27801":["Epic","mt"],"27821":["go mobile","mt"],"27830":["GO Mobile","mt"],"27877":["Melita","mt"],"278999":["Fix Line","mt"],"28001":["CYTA","cy"],"28002":["Cytamobile-Vodafone","cy"],"28010":["epic","cy"],"28020":["PrimeTel","cy"],"28022":["Cablenet","cy"],"280999":["Fix Line","cy"],"28201":["Geocell Ltd.","ge"],"28202":["Magti GSM Ltd.","ge"],"28203":["Iberiatel Ltd.","ge"],"28204":["Mobitel Ltd.","ge"],"28205":["Silknet","ge"],"28207":["GlobalCell","ge"],"28208":["Silknet","ge"],"28210":["Premium Net","ge"],"28211":["Mobilive","ge"],"28212":["Telecom 1","ge"],"28222":["MyPhone","ge"],"28301":["ArmenTel","am"],"28304":["Karabakh Telecom","am"],"28305":["K Telecom CJSC","am"],"28310":["Orange","am"],"28401":["A1","bg"],"28403":["VIVACOM","bg"],"28405":["Yettel","bg"],"28406":["Vivacom","bg"],"28411":["bulsatcom","bg"],"28413":["MAX TELECOM","bg"],"28601":["Paycell | Turkcell","tr"],"28602":["Vodafone","tr"],"28603":["Türk Telekom","tr"],"28604":["Türk Telekom","tr"],"286299":["Asistan Telekom","tr"],"286999":["Fix Line","tr"],"28801":["Faroese Telecom - GSM","fo"],"28802":["Kall GSM","fo"],"28803":["Tosa","fo"],"28967":["Aquafon","ge"],"28968":["A-Mobile","ge"],"28988":["A-Mobile","ge"],"29001":["Tele Greenland","gl"],"29201":["SMT - San Marino Telecom","sm"],"292299":["TeleneT","sm"],"29310":["Slovenske zeleznice","si"],"29320":["Compatel","si"],"293299":["HOT mobil","si"],"29340":["SI Mobil","si"],"29341":["Telekom Slovenije","si"],"29364":["T-2 d.o.o.","si"],"29370":["Telemach","si"],"29386":["Elektro Gorenjska","si"],"293999":["Fix Line","si"],"29401":["Mkedonski Telecom AD Skopje","mk"],"29402":["Cosmofon","mk"],"29403":["Nov Operator","mk"],"29404":["Lycamobile","mk"],"29411":["Mobik","mk"],"294299":["Failed Calls","mk"],"29475":["A1","mk"],"29501":["Telecom FL AG","li"],"29502":["Viag Europlatform AG","li"],"29505":["Mobilkom (Liechstein) AG","li"],"29506":["CUBIC","li"],"29507":["First Mobile AG","li"],"29509":["EMnify","li"],"295299":["Datamobile","li"],"29577":["Tele2 AG","li"],"29701":["ONE","me"],"29702":["Crnogorski Telekom","me"],"29703":["MTEL d.o.o. Podgorica","me"],"302130":["Xplornet","ca"],"302131":["Xplornet","ca"],"302220":["Telus Mobility","ca"],"302270":["EastLink","ca"],"302290":["Airtel Wireless","ca"],"302320":["Chatr Mobile","ca"],"30236":["Clearnet","ca"],"302360":["Clearnet","ca"],"302361":["Clearnet","ca"],"302370":["FIDO (Rogers AT&T/ Microcell)","ca"],"302380":["DMTS Mobility","ca"],"302490":["Freedom Mobile","ca"],"302500":["Videotron","ca"],"302510":["Videotron","ca"],"302520":["Videotron","ca"],"302610":["Bell Mobility","ca"],"30262":["Ice Wireless","ca"],"30263":["Aliant Mobility","ca"],"302630":["Bell Mobility","ca"],"30264":["Bell Mobility","ca"],"302640":["Bell Mobility","ca"],"302651":["Bell Mobility","ca"],"302652":["BC Tel Mobility","ca"],"302653":["Telus Mobility","ca"],"302654":["Sask Tel Mobility","ca"],"302655":["MTS Mobility","ca"],"302656":["Tbay Mobility","ca"],"302657":["Quebectel Mobility","ca"],"302660":["MTS Mobility","ca"],"30267":["CityTel Mobility","ca"],"302670":["CityWest Mobility","ca"],"30268":["Sask Tel Mobility","ca"],"302680":["Sask Tel Mobility","ca"],"302681":["Sask Tel Mobility","ca"],"302701":["NB Tel Mobility","ca"],"302702":["MT&T Mobility","ca"],"302703":["New Tel Mobility","ca"],"30271":["Globalstar","ca"],"302710":["Globalstar Canada","ca"],"30272":["Rogers","ca"],"302720":["Rogers","ca"],"302760":["Public Mobile","ca"],"302780":["Sask Tel Mobility","ca"],"302781":["Sask Tel Mobility","ca"],"30801":["St. Pierre-et-Miquelon Télécom","pm"],"30808":["St. Pierre-et-Miquelon Télécom","pm"],"310003":["Unknown","us"],"310004":["Verizon Wireless","us"],"310010":["MCI","us"],"310011":["Northstar","us"],"310012":["Verizon Wireless","us"],"310013":["Mobile Tel Inc.","us"],"310014":["Testing US","us"],"310016":["Leap Wireless International Inc.","us"],"310017":["North Sight Communications Inc.","us"],"310020":["Union Telephone Company","us"],"310023":["C Spire","us"],"310026":["T-Mobile - US","us"],"310028":["ALU Test-SIM","us"],"310030":["AT&T","us"],"310032":["IT&E OverSeas","gu"],"310033":["Guam Teleph. Auth","gu"],"310034":["Nevada Wireless LLC","us"],"310040":["MTA Communications dba MTA Wireless","us"],"310050":["ACS Wireless Inc.","us"],"31006":["Consolidated Telcom","us"],"310060":["Consolidated Telcom","us"],"310070":["AT&T","us"],"310080":["Corr Wireless Communications LLC","us"],"310090":["Edge Wireless LLC","us"],"310100":["New Mexico RSA 4 East Ltd. Partnership","us"],"310110":["Pacific Telecom Inc","us"],"310120":["Sprint","us"],"310130":["Carolina West Wireless","us"],"31014":["Testing","us"],"310140":["GTA Wireless LLC","us"],"31015":["Unknown","us"],"310150":["Cricket Wireless","us"],"310160":["T-Mobile - US","us"],"310170":["AT&T","us"],"310180":["West Central Wireless","us"],"310190":["Alaska Wireless Communications LLC","us"],"310200":["T-Mobile - US","us"],"310210":["T-Mobile - US","us"],"310220":["T-Mobile - US","us"],"31023":["Unknown","us"],"310230":["T-Mobile - US","us"],"31024":["Unknown","us"],"310240":["T-Mobile - US","us"],"31025":["Unknown","us"],"310250":["T-Mobile - US","us"],"31026":["T-Mobile - US","us"],"310260":["T-Mobile - US","us"],"310270":["T-Mobile - US","us"],"310280":["AT&T","us"],"310290":["Nep Cellcorp Inc.","us"],"310300":["T-Mobile - US","us"],"31031":["T-Mobile","us"],"310310":["T-Mobile - US","us"],"310320":["Smith Bagley Inc, dba Cellular One","us"],"310330":["AN Subsidiary LLC","us"],"31034":["Nevada Wireless LLC","us"],"310340":["High Plains Midwest LLC, dba Wetlink Communications","us"],"310350":["Mohave Cellular L.P.","us"],"310360":["Cellular Network Partnership dba Pioneer Cellular","us"],"310370":["Guamcell Cellular and Paging","us"],"31038":["USA 3650 AT&T","us"],"310380":["AT&T","us"],"310390":["TX-11 Acquistion LLC","us"],"310400":["Wave Runner LLC","us"],"310410":["AT&T","us"],"310420":["Cincinnati Bell Wireless LLC","us"],"310430":["Alaska Digitel LLC","us"],"310440":["Numerex Corp.","us"],"310450":["North East Cellular Inc.","us"],"31046":["SIMMETRY","us"],"310460":["TMP Corporation","us"],"310470":["nTelos","us"],"310480":["Choice Phone LLC","us"],"310490":["T-Mobile - US","us"],"310500":["Public Service Cellular, Inc.","us"],"310510":["Airtel Wireless LLC","us"],"310520":["VeriSign","us"],"310530":["T-Mobile - US","us"],"310540":["Oklahoma Western Telephone Company","us"],"310550":["Wireless Solutions International","us"],"310560":["AT&T","us"],"310570":["MTPCS LLC","us"],"310580":["Inland Cellular","us"],"310590":["Verizon Wireless","us"],"310591":["Verizon Wireless","us"],"310592":["Verizon Wireless","us"],"310593":["Verizon Wireless","us"],"310594":["Verizon Wireless","us"],"310595":["Verizon Wireless","us"],"310596":["Verizon Wireless","us"],"310597":["Verizon Wireless","us"],"310598":["Verizon Wireless","us"],"310599":["Verizon Wireless","us"],"31060":["Consolidated Telcom","us"],"310600":["New-Cell Inc.","us"],"310610":["Elkhart Telephone Co. Inc. dba Epic Touch Co.","us"],"310620":["Coleman County Telecommunications Inc. (Trans Texas PCS)","us"],"310640":["T-Mobile - US","us"],"310650":["Jasper Wireless Inc.","us"],"310660":["T-Mobile - US","us"],"310670":["AT&T Mobility Vanguard Services","us"],"310680":["AT&T","us"],"310690":["Limitless Mobile","us"],"310700":["Cross Valiant Cellular Partnership","us"],"310710":["Arctic Slopo Telephone Association Cooperative","us"],"310720":["Wireless Solutions International Inc.","us"],"310730":["Sea Mobile","us"],"310740":["Telemetrix Inc.","us"],"310750":["East Kentucky Network LLC dba Appalachian Wireless","us"],"310760":["Panhandle Telecommunications Systems Inc.","us"],"310770":["Iowa Wireless Services LLC dba I Wireless","us"],"310780":["Connect Net Inc","us"],"310790":["PinPoint Communications Inc.","us"],"310800":["T-Mobile - US","us"],"310810":["Brazos Cellular Communications Ltd.","us"],"310820":["South Canaan Cellular Communications Co. LP","us"],"310830":["Caprock Cellular Ltd. Partnership","us"],"310840":["Edge Mobile LLC","us"],"310850":["Aeris Communications, Inc.","us"],"310860":["TX RSA 15B2, LP dba Five Star Wireless","us"],"310870":["Kaplan Telephone Company Inc.","us"],"310880":["Advantage Cellular Systems, Inc.","us"],"310890":["Verizon Wireless","us"],"310900":["Mid-Rivers","us"],"310910":["Southern IL RSA Partnership dba First Cellular of Southern Illinois","us"],"310920":["James Valley","us"],"310930":["Copper Valley Wireless","us"],"310940":["Poka Lambro Telco Ltd.","us"],"310950":["AT&T","us"],"310960":["UBET Wireless","us"],"310970":["Globalstar USA","us"],"310980":["AT&T Wireless Inc.","us"],"310990":["Evolve","us"],"310995":["Android Emulator","us"],"310999":["Various Networks","us"],"311000":["Mid-Tex Cellular Ltd.","us"],"311010":["Chariton Valley Communications Corp., Inc.","us"],"311020":["Missouri RSA No. 5 Partnership","us"],"311030":["Indigo Wireless, Inc.","us"],"311040":["Commet Wireless, LLC","us"],"311050":["Thumb Cellular Limited Partnership","us"],"311060":["Space Data Corporation","us"],"311070":["Easterbrooke Cellular Corporation","us"],"311080":["Pine Telephone Company dba Pine Cellular","us"],"311090":["Siouxland PCS","us"],"311100":["NexTech Wireless","us"],"311110":["Alltel Communications Inc.","us"],"311120":["Choice Phone LLC","us"],"311140":["MBO Wireless Inc./Cross Telephone Company","us"],"311150":["Wilkes Cellular Inc.","us"],"311170":["PetroCom LLC","us"],"311180":["AT&T","us"],"311190":["Cellular Properties Inc.","us"],"311200":["ARINC","us"],"311210":["Farmers Cellular Telephone","us"],"311220":["U.S. Cellular","us"],"311221":["U.S. Cellular","us"],"311222":["U.S. Cellular","us"],"311223":["U.S. Cellular","us"],"311224":["U.S. Cellular","us"],"311225":["U.S. Cellular","us"],"311226":["U.S. Cellular","us"],"311227":["U.S. Cellular","us"],"311228":["U.S. Cellular","us"],"311229":["U.S. Cellular","us"],"311230":["C Spire","us"],"311240":["Cordova Wireless Communications Inc","us"],"311250":["Wave Runner LLC","us"],"311260":["SLO Cellular Inc. dba CellularOne of San Luis Obispo","us"],"311270":["Verizon Wireless","us"],"311271":["Alltel Communications Inc.","us"],"311272":["Alltel Communications Inc.","us"],"311273":["Alltel Communications Inc.","us"],"311274":["Alltel Communications Inc.","us"],"311275":["Alltel Communications Inc.","us"],"311276":["Alltel Communications Inc.","us"],"311277":["Alltel Communications Inc.","us"],"311278":["Alltel Communications Inc.","us"],"311279":["Alltel Communications Inc.","us"],"311280":["Verizon Wireless","us"],"311281":["Verizon Wireless","us"],"311282":["Verizon Wireless","us"],"311283":["Verizon Wireless","us"],"311284":["Verizon Wireless","us"],"311285":["Verizon Wireless","us"],"311286":["Verizon Wireless","us"],"311287":["Verizon Wireless","us"],"311288":["Verizon Wireless","us"],"311289":["Verizon Wireless","us"],"311290":["Pinpoint Wireless Inc.","us"],"311300":["Rutal Cellular Corporation","us"],"311310":["Leaco Rural Telephone Company Inc","us"],"311311":["Farmers","us"],"311320":["Commnet Wireless LLC","us"],"311330":["Bag Tussel Wireless LLC","us"],"311340":["Illinois Valley Cellular","us"],"311350":["Torrestar Networks Inc","us"],"311360":["Stelera Wireless LLC","us"],"311370":["GCI Communications Corp.","us"],"311380":["GreenFly LLC","us"],"311390":["Midwest Wireless Holdings LLC","us"],"311400":["Testing US","us"],"311410":["Iowa RSA No.2 Ltd Partnership","us"],"311420":["northwestcell","us"],"311430":["Chat Mobility","us"],"311440":["Bluegrass Cellular LLC","us"],"311450":["PTCI","us"],"311460":["Fisher Wireless Services Inc","us"],"311470":["Vitelcom Cellular Inc dba Innovative Wireless","us"],"311480":["Verizon Wireless","us"],"311481":["Verizon Wireless","us"],"311482":["Verizon Wireless","us"],"311483":["Verizon Wireless","us"],"311484":["Verizon Wireless","us"],"311485":["Verizon Wireless","us"],"311486":["Verizon Wireless","us"],"311487":["Verizon Wireless","us"],"311488":["Verizon Wireless","us"],"311489":["Verizon Wireless","us"],"311490":["T-Mobile - US","us"],"311500":["CTC Telecom Inc","us"],"311510":["Benton-Lian Wireless","us"],"311520":["Crossroads Wireless Inc","us"],"311530":["Wireless Communications Venture","us"],"311540":["Keystone Wireless Inc","us"],"311550":["Commnet Midwest LLC","us"],"311580":["U.S. Cellular","us"],"311581":["U.S. Cellular","us"],"311582":["U.S. Cellular","us"],"311583":["U.S. Cellular","us"],"311584":["U.S. Cellular","us"],"311585":["U.S. Cellular","us"],"311586":["U.S. Cellular","us"],"311587":["U.S. Cellular","us"],"311588":["U.S. Cellular","us"],"311589":["U.S. Cellular","us"],"311590":["California RSA No. 3 Limited Partnership","us"],"311600":["COX","us"],"311610":["North Dakota Network Company","us"],"311650":["United Wireless Communications Inc.","us"],"311660":["T-Mobile - Private 5G","us"],"311670":["Pine Belt Cellular, Inc.","us"],"311710":["Northeast Wireless Networks LLC","us"],"311740":["TelAlaska Cellular","us"],"311750":["Cleartalk","us"],"311780":["ASTCA","us"],"311800":["Bluegrass Wireless LLC","us"],"311810":["Bluegrass Wireless LLC","us"],"311830":["Thumb Cellular Limited Partnership","us"],"311860":["Uintah Basin Electronics Telecommunications Inc.","us"],"311870":["Boost","us"],"311880":["Sprint Spectrum","us"],"311882":["T-Mobile - US","us"],"311910":["MobileNation","us"],"311920":["Missouri RSA No 5 Partnership","us"],"311930":["Syringa","us"],"312010":["Missouri RSA No 5 Partnership","us"],"312030":["Cross Wireless Telephone Co.","us"],"312040":["Custer Telephone Cooperative Inc.","us"],"312090":["Allied Wireless Communications Corporation","us"],"312120":["East Kentucky Network LLC","us"],"312130":["East Kentucky Network LLC","us"],"312160":["Chat Mobility","us"],"312170":["Iowa RSA No. 2 Limited Partnership","us"],"312180":["Keystone Wireless LLC","us"],"312190":["Sprint Spectrum","us"],"312220":["Missouri RSA No 5 Partnership","us"],"312230":["North Dakota Network Company","us"],"312250":["T-Mobile - US","us"],"312270":["Cellular Network Partnership LLC","us"],"312280":["Cellular Network Partnership LLC","us"],"312290":["strata","us"],"312380":["Copper Valley Wireless","us"],"312420":["NexTech Ota","us"],"312530":["Sprint","us"],"312570":["Blue Wireless","us"],"312580":["Google CBRS","us"],"312670":["FirstNet (Lab)","us"],"312870":["GigSky","us"],"313100":["FirstNet","us"],"313110":["FirstNet","us"],"313120":["FirstNet","us"],"313130":["FirstNet","us"],"313140":["FirstNet","us"],"313380":["OptimERA Wireless","us"],"313390":["Optimum","us"],"313450":["Spectrum Mobile","us"],"313460":["Mobi","us"],"313770":["TANGO","us"],"313790":["Liberty Mobile","us"],"314020":["Spectrum+","us"],"314200":["Xfinity MSO","us"],"314240":["Xfinity Mobile 2.0","us"],"314420":["Cox MSO","us"],"314720":["OXIO","us"],"314730":["TextNow Wireless","us"],"315010":["CBRS","us"],"316010":["Nextel Communications Inc.","us"],"316011":["Southern Communications Services Inc.","us"],"33000":["Open Mobile","pr"],"33011":["Claro PR","pr"],"330110":["Claro PR","pr"],"33401":["AT&T MX","mx"],"334010":["NEXTEL","mx"],"33402":["Telcel","mx"],"334020":["Telcel","mx"],"33403":["Movistar","mx"],"334030":["Movistar","mx"],"33404":["AT&T/IUSACell","mx"],"334040":["AT&T MX","mx"],"33405":["AT&T/IUSACell","mx"],"334050":["AT&T MX","mx"],"334060":["SAI PCS","mx"],"334070":["AT&T MX","mx"],"334080":["AT&T MX","mx"],"33409":["AT&T MX","mx"],"334090":["AT&T MX","mx"],"334130":["Alestra Servicios Moviles","mx"],"334140":["ALTAN - Internal Use","mx"],"334170":["OXIO","mx"],"33450":["AT&T/IUSACell","mx"],"338020":["Cable & Wireless Jamaica Ltd.","jm"],"33805":["Mossel (Jamaica) Ltd.","jm"],"338050":["Mossel (Jamaica) Ltd.","jm"],"338070":["Claro","jm"],"338110":["Cable & Wireless","jm"],"33818":["Cable & Wireless","jm"],"338180":["Cable & Wireless","jm"],"34001":["Orange Caraïbe Mobiles","gf"],"34002":["Outremer Telecom","gf"],"34003":["Saint Martin et Saint Barthelemy Telcell Sarl","gf"],"34008":["Dauphin Telecom SU (Guadeloupe Telecom)","gp"],"34011":["TelCell GSM","gf"],"34012":["UTS Caraibe","mq"],"34020":["Digicel","gf"],"34080":["Dauphin Telecom","gf"],"342050":["Digicel","bb"],"342299":["Failed Calls","bb"],"342600":["Cable & Wireless (Barbados) Ltd.","bb"],"342750":["Digicel","bb"],"342810":["Cingular Wireless","bb"],"342820":["Sunbeach Communications","bb"],"34403":["APUA PCS","ag"],"344030":["imobile / APUA","ag"],"34492":["Flow","ag"],"344920":["Cable & Wireless (Antigua)","ag"],"344921":["FLOW","ag"],"34493":["Digicel","ag"],"344930":["AT&T Wireless (Antigua)","ag"],"346001":["Logic","ky"],"346006":["Digicel Ltd.","ky"],"346050":["Digicel","ky"],"346140":["Cable & Wireless (Cayman)","ky"],"348170":["Cable & Wireless","vg"],"348570":["Caribbean Cellular Telephone, Boatphone Ltd.","vg"],"34877":["Digicel","vg"],"348770":["Digicel","vg"],"350000":["Bermuda Digital Communications Ltd (BDC)","bm"],"350007":["Paradise Mobile","bm"],"35001":["Digicel","bm"],"35002":["M3 Wireless Ltd","bm"],"350299":["Failed Calls","bm"],"35099":["CellOne Ltd","bm"],"352030":["Digicel","gd"],"352050":["Digicel","gd"],"352110":["Grenada:Lime","gd"],"354860":["Cable & Wireless","ms"],"356110":["FLOW","kn"],"35650":["Digicel","kn"],"35670":["UTS Cariglobe","kn"],"358110":["Cable & Wireless","lc"],"35830":["Cingular Wireless","lc"],"35850":["Digicel (St Lucia) Limited","lc"],"360050":["Digicel","vc"],"36010":["Cingular","vc"],"360100":["Cingular","vc"],"360110":["Cable & Wireless (St. Vincent & the Grenadines) Ltd","vc"],"36070":["Digicel","vc"],"36251":["TELCELL GSM","an"],"362630":["Cingular Wireless","an"],"36269":["CT GSM","cw"],"36291":["SETEL GSM","an"],"36295":["EOCG Wireless NV","cw"],"362951":["UTS Wireless","an"],"362999":["Fix Line","bq"],"36301":["SETAR","aw"],"36302":["Digicel","aw"],"363020":["Digicel","aw"],"36320":["Digicel","aw"],"363299":["MIO","aw"],"36403":["Smart Communications","bs"],"364039":["BTC","bs"],"36430":["Cybercell / BaTelCo","bs"],"36439":["Cybercell / BaTelCo","bs"],"364390":["Bahamas Telecommunications","bs"],"36449":["ALIV BS","bs"],"364490":["Aliv","bs"],"365010":["Weblinks Limited","ai"],"365840":["Cable & Wireless","ai"],"365850":["Digicel","ai"],"366020":["Cingular Wireless/Digicel","dm"],"366050":["Wireless Ventures (Dominica) Ltd (Digicel Dominica)","dm"],"366110":["Cable & Wireless","dm"],"36801":["ETECSA","cu"],"368999":["Fix Line","cu"],"37001":["Altice Dominicana","do"],"37002":["Claro RD","do"],"370020":["Claro RD","do"],"37003":["Tricom S.A.","do"],"37004":["CentennialDominicana","do"],"37005":["Wind Telecom","do"],"37201":["Comcel","ht"],"37202":["Digicel","ht"],"37203":["Rectel","ht"],"37412":["TSTT Mobile","tt"],"374120":["Bmobile/TSTT","tt"],"374122":["TSTT Mobile","tt"],"374123":["TSTT Mobile","tt"],"374124":["TSTT Mobile","tt"],"374125":["TSTT Mobile","tt"],"374126":["TSTT Mobile","tt"],"374127":["TSTT Mobile","tt"],"374128":["TSTT Mobile","tt"],"374129":["TSTT Mobile","tt"],"37413":["Digicel Trinidad and Tobago Ltd.","tt"],"374130":["Digicel Trinidad and Tobago Ltd.","tt"],"374140":["LaqTel Ltd.","tt"],"376050":["Digicel TCI Ltd","tc"],"376350":["Cable & Wireless West Indies Ltd (Turks & Caicos)","tc"],"376352":["IslandCom Communications Ltd.","tc"],"37650":["Digicel","vi"],"40001":["Azercell Limited Liability Joint Venture","az"],"40002":["Bakcell Limited Liabil ity Company","az"],"40003":["Catel JV","az"],"40004":["Azerphone LLC","az"],"40006":["Naxtel","az"],"40101":["Beeline","kz"],"40102":["Kcell/activ","kz"],"40107":["Tele2/Altel","kz"],"40177":["Tele2/Altel","kz"],"40211":["Bhutan Telecom Ltd","bt"],"40217":["B-Mobile of Bhutan Telecom","bt"],"40277":["TashiCell","bt"],"40401":["Vi","in"],"40402":["Airtel","in"],"40403":["Airtel","in"],"40404":["Vi","in"],"404045":["Bharti Airtel Limited (Karnataka) (India)","in"],"40405":["Vi","in"],"40407":["Vi","in"],"40409":["Reliance","in"],"40410":["Airtel","in"],"40411":["Vi","in"],"40412":["Vi","in"],"40413":["Vi","in"],"40414":["Vi","in"],"40415":["Vi","in"],"40416":["Airtel","in"],"40417":["Aircel","in"],"40418":["Reliance","in"],"40419":["Vi","in"],"40420":["Vi","in"],"40421":["BPL Mobile Communications Ltd.","in"],"40422":["Vi","in"],"40424":["Vi","in"],"40425":["Aircel Ltd.","in"],"40427":["Vi","in"],"40428":["Aircel Ltd.","in"],"40429":["Aircel Ltd.","in"],"40430":["Vi","in"],"40431":["Airtel","in"],"40433":["Aircel","in"],"40434":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40436":["Reliance","in"],"40437":["Aircel Ltd.","in"],"40438":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40439":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40440":["Airtel","in"],"40441":["RPG Cellular","in"],"40442":["Aircel Ltd.","in"],"40443":["Vi","in"],"40444":["Vi","in"],"40445":["Airtel","in"],"40446":["Vi","in"],"40448":["Dishnet Wireless","in"],"40449":["Airtel","in"],"40450":["Reliance","in"],"40451":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40452":["Reliance","in"],"40453":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40454":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40455":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40456":["Vi","in"],"40457":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40458":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40459":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40460":["Vi","in"],"40462":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40464":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40465":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40466":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40467":["Reliance","in"],"40468":["Mahanagar Telephone Nigam Ltd.","in"],"40469":["Mahanagar Telephone Nigam Ltd.","in"],"40470":["Airtel","in"],"40471":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40472":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40473":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40474":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40475":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40476":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40477":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40478":["Vi","in"],"40479":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40480":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40481":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40482":["Vi","in"],"40483":["Reliable Internet Services Ltd.","in"],"40484":["Vi","in"],"40485":["Reliance","in"],"40486":["Vi","in"],"40487":["Vi","in"],"40488":["Vi","in"],"40489":["Vi","in"],"40490":["Airtel","in"],"40491":["Aircel Ltd.","in"],"40492":["Airtel","in"],"40493":["Airtel","in"],"40494":["Airtel","in"],"40495":["Airtel","in"],"40496":["Airtel","in"],"40497":["Airtel","in"],"40498":["Airtel","in"],"404998":["Fix Line","in"],"404999":["Various Networks","in"],"40501":["Reliance","in"],"405025":["TATA DOCOMO","in"],"405026":["TATA DOCOMO","in"],"405027":["TATA DOCOMO","in"],"405028":["TATA DOCOMO","in"],"405029":["TATA DOCOMO","in"],"40503":["Reliance","in"],"405030":["TATA DOCOMO","in"],"405031":["TATA DOCOMO","in"],"405032":["TATA DOCOMO","in"],"405033":["TATA DOCOMO","in"],"405034":["TATA DOCOMO","in"],"405035":["TATA DOCOMO","in"],"405036":["TATA DOCOMO","in"],"405037":["TATA DOCOMO","in"],"405038":["TATA DOCOMO","in"],"405039":["TATA DOCOMO","in"],"40504":["Reliance","in"],"405040":["TATA DOCOMO","in"],"405041":["TATA DOCOMO","in"],"405042":["TATA DOCOMO","in"],"405043":["TATA DOCOMO","in"],"405044":["TATA DOCOMO","in"],"405045":["TATA DOCOMO","in"],"405046":["TATA DOCOMO","in"],"405047":["TATA DOCOMO","in"],"40505":["Reliance","in"],"40506":["Reliance","in"],"40507":["Reliance","in"],"40508":["Reliance","in"],"40509":["Reliance","in"],"40510":["Reliance","in"],"40511":["Reliance","in"],"40512":["Reliance","in"],"40513":["Reliance","in"],"40514":["Reliance","in"],"40515":["Reliance","in"],"40517":["Reliance","in"],"40518":["Reliance","in"],"40519":["Reliance","in"],"40520":["Reliance","in"],"40521":["Reliance","in"],"40522":["Reliance","in"],"40523":["Reliance","in"],"40545":["Vi","in"],"40551":["Airtel","in"],"40552":["Airtel","in"],"40553":["Airtel","in"],"40554":["Airtel","in"],"40555":["Airtel","in"],"40556":["Airtel","in"],"40566":["Vi","in"],"40567":["Vi","in"],"40570":["Vi","in"],"405750":["Vi","in"],"405751":["Vi","in"],"405752":["Vi","in"],"405753":["Vi","in"],"405754":["Vi","in"],"405755":["Vi","in"],"405756":["Vi","in"],"405799":["Vi","in"],"405800":["Aircel Ltd.","in"],"405801":["Aircel Ltd.","in"],"405802":["Aircel Ltd.","in"],"405803":["Aircel Ltd.","in"],"405804":["Aircel Ltd.","in"],"405805":["Aircel Ltd.","in"],"405806":["Aircel Ltd.","in"],"405807":["Aircel Ltd.","in"],"405808":["Aircel Ltd.","in"],"405809":["Aircel Ltd.","in"],"405810":["Aircel Ltd.","in"],"405811":["Aircel Ltd.","in"],"405812":["Aircel Ltd.","in"],"405813":["Uninor","in"],"405814":["Uninor","in"],"405815":["Uninor","in"],"405816":["Uninor","in"],"405817":["Uninor","in"],"405818":["Uninor","in"],"405819":["Uninor","in"],"405820":["Uninor","in"],"405821":["Uninor","in"],"405822":["Uninor","in"],"405823":["Videocon","in"],"405824":["Videocon","in"],"405825":["Videocon","in"],"405826":["Videocon","in"],"405827":["Videocon","in"],"405828":["Videocon","in"],"405829":["Videocon","in"],"405830":["Videocon","in"],"405832":["Videocon","in"],"405833":["Videocon","in"],"405834":["Videocon","in"],"405835":["Videocon","in"],"405836":["Videocon","in"],"405837":["Videocon","in"],"405838":["Videocon","in"],"405840":["Reliance Jio","in"],"405841":["Videocon","in"],"405842":["Videocon","in"],"405843":["Videocon","in"],"405844":["Uninor","in"],"405845":["Vi","in"],"405846":["Vi","in"],"405847":["Vi","in"],"405848":["Vi","in"],"405849":["Vi","in"],"405850":["Vi","in"],"405851":["Vi","in"],"405852":["Vi","in"],"405853":["Vi","in"],"405854":["Reliance Jio","in"],"405855":["Reliance Jio","in"],"405856":["Reliance Jio","in"],"405857":["Reliance Jio","in"],"405858":["Reliance Jio","in"],"405859":["Reliance Jio","in"],"405860":["Reliance Jio","in"],"405861":["Reliance Jio","in"],"405862":["Reliance Jio","in"],"405863":["Reliance Jio","in"],"405864":["Reliance Jio","in"],"405865":["Reliance Jio","in"],"405866":["Reliance Jio","in"],"405867":["Reliance Jio","in"],"405868":["Reliance Jio","in"],"405869":["Reliance Jio","in"],"40587":["Reliance Telecom Private","in"],"405870":["Reliance Jio","in"],"405871":["Reliance Jio","in"],"405872":["Reliance Jio","in"],"405873":["Reliance Jio","in"],"405874":["Reliance Jio","in"],"405875":["Uninor","in"],"405876":["Uninor","in"],"405877":["Uninor","in"],"405878":["Uninor","in"],"405879":["Uninor","in"],"405880":["Uninor","in"],"405881":["STEL","in"],"405882":["STEL","in"],"405883":["STEL","in"],"405884":["STEL","in"],"405885":["STEL","in"],"405886":["STEL","in"],"405908":["Vi","in"],"405909":["Vi","in"],"405910":["Vi","in"],"405911":["Vi","in"],"405912":["Cheers","in"],"405913":["Cheers","in"],"405914":["Cheers","in"],"405915":["Cheers","in"],"405916":["Cheers","in"],"405917":["Cheers","in"],"405918":["Cheers","in"],"405919":["Cheers","in"],"405920":["Cheers","in"],"405921":["Cheers","in"],"405922":["Cheers","in"],"405923":["Cheers","in"],"405925":["Uninor","in"],"405926":["Uninor","in"],"405927":["Uninor","in"],"405928":["Uninor","in"],"405929":["Uninor","in"],"405930":["Cheers","in"],"405932":["Videocon","in"],"41001":["Jazz","pk"],"41003":["PAK Telecom Mobile Ltd. (UFONE)","pk"],"41004":["Zong","pk"],"41005":["SCOM","pk"],"41006":["Telenor","pk"],"41007":["Jazz","pk"],"41008":["Instaphone","pk"],"410299":["Failed Calls","pk"],"41201":["AWCC","af"],"41203":["WaselTelecom (WT)","af"],"41220":["Roshan","af"],"41230":["New1","af"],"41240":["Areeba Afghanistan","af"],"41250":["Etisalat","af"],"41280":["Mobifone","af"],"41288":["Afghan Telecom","af"],"41301":["Sri Lanka Telecom Mobitel","lk"],"41302":["Dialog Sri Lanka","lk"],"41303":["Celtel Lanka Ltd.","lk"],"41305":["Airtel Lanka","lk"],"41308":["Hutchison Telecommunications Lanka","lk"],"41401":["Myanmar Post and Telecommunication","mm"],"41405":["Ooredoo Myanmar","mm"],"41406":["Telenor","mm"],"41409":["Mytel","mm"],"414999":["Fix Line (Myanmar","mm"],"41501":["Alfa","lb"],"41503":["MTC Touch","lb"],"41505":["Ogero Mobile","lb"],"41515":["Connect","lb"],"41532":["Cellis","lb"],"41533":["Cellis","lb"],"41534":["Cellis","lb"],"41535":["Cellis","lb"],"41536":["Libancell","lb"],"41537":["Libancell","lb"],"41538":["Libancell","lb"],"41539":["Libancell","lb"],"41601":["Fastlink","jo"],"41602":["Xpress","jo"],"41603":["Umniah","jo"],"41677":["Orange Jordan","jo"],"416770":["Orange Jordan","jo"],"416999":["Fix Line","jo"],"41701":["Syriatel","sy"],"41702":["Spacetel Syria","sy"],"41709":["Syrian Telecom","sy"],"41750":["Rcell","sy"],"41805":["Asiacell","iq"],"41808":["SanaTel","iq"],"41820":["Zain Iraq","iq"],"41830":["Zain Iraq","iq"],"41840":["Korek","iq"],"41845":["Mobitel","iq"],"41862":["Itisaluna","iq"],"41866":["Fastlink","iq"],"41877":["SevenNet Layers","iq"],"41882":["Korek","iq"],"41892":["Omnnea","iq"],"41902":["Zain","kw"],"41903":["Ooredoo","kw"],"41904":["STC","kw"],"419999":["Fix Line","kw"],"42001":["STC","sa"],"42003":["Mobily","sa"],"42004":["Zain Saudi Arabia","sa"],"42005":["Virgin","sa"],"42006":["Lebara Mobile","sa"],"42007":["Zain","sa"],"42101":["SabaFon","ye"],"42102":["Spacetel Yemen","ye"],"42103":["YemenMobile","ye"],"42104":["HiTS-UNITEL","ye"],"42111":["YemenMobile","ye"],"42122":["YemenMobile","ye"],"421999":["Fix Line","ye"],"42202":["Omantel","om"],"42203":["Ooredoo","om"],"42204":["Omantel","om"],"42206":["Vodafone Oman","om"],"42402":["e& UAE","ae"],"42403":["du","ae"],"42501":["Partner Communications Co. Ltd.","il"],"42502":["Cellcom Israel Ltd.","il"],"42503":["Pelephone Communications Ltd.","il"],"42505":["Jawwal","ps"],"42506":["Ooredoo","ps"],"42507":["Hot Mobile","il"],"42508":["Golan Telecom","il"],"42509":["We4G","il"],"42510":["Partner Communications Co. Ltd.","il"],"42512":["Pelephone","il"],"42513":["Ituran","il"],"42514":["Alon Cellular Ltd","il"],"42515":["Home Cellular","il"],"42516":["Rami Levy","il"],"42517":["Von waves","il"],"42519":["019 Mobile","il"],"42522":["Maskyoo","il"],"42523":["Beezz","il"],"42526":["Annatel","il"],"425299":["Annatel Mobile","il"],"42577":["Hot Mobile","il"],"42601":["Batelco","bh"],"42602":["Zain Bahrain","bh"],"42604":["stc BH","bh"],"42605":["Batelco","bh"],"426299":["Failed Calls","bh"],"426999":["Fix Line","bh"],"42701":["Ooredoo","qa"],"42702":["Vodafone","qa"],"42800":["Skytel Co. Ltd","mn"],"42888":["Unitel","mn"],"42891":["Skytel","mn"],"42898":["G.Mobile","mn"],"42899":["Mobicom","mn"],"42901":["Nepal Telecommunications","np"],"42902":["Ncell","np"],"42903":["Nepal Telecommunications","np"],"42904":["Smart Telecom","np"],"429999":["Fix Line","np"],"43002":["Etisalat","ae"],"43102":["Etisalat","ae"],"43211":["IR-MCI (Hamrahe Avval)","ir"],"43214":["Telecommunication Kish Co. (KIFZO)","ir"],"43219":["MTCE (Espadan)","ir"],"43220":["Rightel","ir"],"43232":["Taliya","ir"],"43235":["Irancell","ir"],"43270":["MTCE","ir"],"43293":["Farzanegan Pars","ir"],"432999":["Fix Line","ir"],"43401":["Buztel","uz"],"43402":["Uzmacom","uz"],"43404":["Daewoo Unitel","uz"],"43405":["Coscom","uz"],"43406":["Perfectum Mobile","uz"],"43407":["Uzdunrobita","uz"],"43601":["JC Somoncom","tj"],"43602":["CJSC Indigo Tajikistan","tj"],"43603":["TT mobile","tj"],"43604":["Babilon-Mobile","tj"],"43605":["CTJTHSC Tajik-tel","tj"],"43612":["Tcell","tj"],"43701":["Beeline","kg"],"43702":["KT Mobile","kg"],"43703":["AkTel LLC","kg"],"43705":["MegaCom","kg"],"43709":["O!","kg"],"43710":["Saima","kg"],"437299":["Failed Calls","kg"],"43801":["Barash Communication Technologies (BCTI)","tm"],"43802":["TM-Cell","tm"],"44000":["eMobile","jp"],"44001":["NTT DoCoMo","jp"],"44002":["NTT DoCoMo","jp"],"44003":["IIJmio","jp"],"44004":["SoftBank","jp"],"44005":["SoftBank","jp"],"44006":["SoftBank","jp"],"44007":["KDDI","jp"],"44008":["KDDI","jp"],"44009":["NTT DoCoMo","jp"],"44010":["DOCOMO MVNO","jp"],"44011":["Rakuten Mobile(MNO)","jp"],"44012":["NTT DoCoMo","jp"],"44013":["OCN MOBILE ONE","jp"],"44014":["NTT DoCoMo","jp"],"44015":["NTT DoCoMo","jp"],"44016":["NTT DoCoMo","jp"],"44017":["NTT DoCoMo","jp"],"44018":["NTT DoCoMo","jp"],"44019":["NTT DoCoMo","jp"],"44020":["SoftBank","jp"],"44021":["NTT DoCoMo","jp"],"44022":["NTT DoCoMo","jp"],"44023":["NTT DoCoMo","jp"],"44024":["NTT DoCoMo","jp"],"44025":["NTT DoCoMo","jp"],"44026":["NTT DoCoMo","jp"],"44027":["NTT DoCoMo","jp"],"44028":["NTT DoCoMo","jp"],"44029":["NTT DoCoMo","jp"],"44030":["NTT DoCoMo","jp"],"44031":["NTT DoCoMo","jp"],"44032":["NTT DoCoMo","jp"],"44033":["NTT DoCoMo","jp"],"44034":["NTT DoCoMo","jp"],"44035":["NTT DoCoMo","jp"],"44036":["NTT DoCoMo","jp"],"44037":["NTT DoCoMo","jp"],"44038":["NTT DoCoMo","jp"],"44039":["NTT DoCoMo","jp"],"44040":["SoftBank","jp"],"44041":["SoftBank","jp"],"44042":["SoftBank","jp"],"44043":["SoftBank","jp"],"44044":["SoftBank","jp"],"44045":["SoftBank","jp"],"44046":["SoftBank","jp"],"44047":["SoftBank","jp"],"44048":["SoftBank","jp"],"44049":["NTT DoCoMo","jp"],"44050":["KDDI","jp"],"44051":["KDDI","jp"],"44052":["KDDI","jp"],"44053":["KDDI","jp"],"44054":["KDDI","jp"],"44055":["KDDI","jp"],"44056":["KDDI","jp"],"44058":["NTT DoCoMo","jp"],"44060":["NTT DoCoMo","jp"],"44061":["NTT DoCoMo","jp"],"44062":["NTT DoCoMo","jp"],"44063":["NTT DoCoMo","jp"],"44064":["NTT DoCoMo","jp"],"44065":["NTT DoCoMo","jp"],"44066":["NTT DoCoMo","jp"],"44067":["NTT DoCoMo","jp"],"44068":["NTT DoCoMo","jp"],"44069":["NTT DoCoMo","jp"],"44070":["KDDI","jp"],"44071":["KDDI","jp"],"44072":["KDDI","jp"],"44073":["KDDI","jp"],"44074":["KDDI","jp"],"44075":["KDDI","jp"],"44076":["KDDI","jp"],"44077":["KDDI","jp"],"44078":["Okinawa Cellular","jp"],"44079":["KDDI","jp"],"44080":["KDDI","jp"],"44081":["KDDI","jp"],"44082":["KDDI","jp"],"44083":["KDDI","jp"],"44084":["KDDI","jp"],"44085":["KDDI","jp"],"44086":["KDDI","jp"],"44087":["NTT DoCoMo","jp"],"44088":["KDDI","jp"],"44089":["KDDI","jp"],"44090":["SoftBank","jp"],"44092":["SoftBank","jp"],"44093":["SoftBank","jp"],"44094":["SoftBank","jp"],"44095":["SoftBank","jp"],"44096":["SoftBank","jp"],"44097":["SoftBank","jp"],"44098":["SoftBank","jp"],"44099":["NTT DoCoMo","jp"],"44100":["Wireless City Planning","jp"],"44140":["NTT DoCoMo","jp"],"44141":["NTT DoCoMo","jp"],"44142":["NTT DoCoMo","jp"],"44143":["NTT DoCoMo","jp"],"44144":["NTT DoCoMo","jp"],"44145":["NTT DoCoMo","jp"],"44161":["SoftBank","jp"],"44162":["SoftBank","jp"],"44163":["SoftBank","jp"],"44164":["SoftBank","jp"],"44165":["SoftBank","jp"],"44170":["KDDI","jp"],"44190":["NTT DoCoMo","jp"],"44191":["NTT DoCoMo","jp"],"44192":["NTT DoCoMo","jp"],"44193":["NTT DoCoMo","jp"],"44194":["NTT DoCoMo","jp"],"44198":["NTT DoCoMo","jp"],"44199":["NTT DoCoMo","jp"],"450006":["LG U+","kr"],"45002":["KT","kr"],"45003":["SK Telecom","kr"],"45004":["KT","kr"],"45005":["SK Telecom","kr"],"45006":["LG U+","kr"],"45007":["KT Powertel","kr"],"45008":["KT","kr"],"45011":["SK Telink","kr"],"45012":["SK Telecom","kr"],"450299":["Failed Calls","kr"],"45201":["Mobifone","vn"],"45202":["Vinaphone","vn"],"45203":["S-Fone/Telecom","vn"],"45204":["Viettel Telecom","vn"],"45205":["Vietnamobile","vn"],"45206":["Viettel","vn"],"45207":["Gmobile","vn"],"45208":["Viettel Mobile","vn"],"45209":["Wintel","vn"],"45400":["1O1O / csl / Club Sim","hk"],"45401":["MVNO/CITIC","hk"],"45402":["3G Radio System/HKCSL3G","hk"],"45403":["Hutchison HK","hk"],"45404":["Hutchison 2G","hk"],"45405":["Hutchison 2G","hk"],"45406":["SmarTone HK","hk"],"45407":["MVNO/China Unicom International Ltd.","hk"],"45408":["MVNO/Trident","hk"],"45409":["MVNO/China Motion Telecom (HK) Ltd.","hk"],"45410":["GSM1800New World PCS Ltd.","hk"],"45411":["MVNO/CHKTL","hk"],"45412":["中國移動香港 China Mobile HK","hk"],"45413":["中國移動香港 China Mobile HK","hk"],"45414":["H3G/Hutchinson","hk"],"45415":["SmarTone HK","hk"],"45416":["PCCW","hk"],"45417":["SmarTone HK","hk"],"45418":["GSM7800/Hong Kong CSL Ltd.","hk"],"45419":["1O1O / csl / Club Sim","hk"],"45420":["Public Mobile Networks/Reserved","hk"],"45421":["Public Mobile Networks/Reserved","hk"],"45422":["Public Mobile Networks/Reserved","hk"],"45423":["Public Mobile Networks/Reserved","hk"],"45424":["Public Mobile Networks/Reserved","hk"],"45425":["Public Mobile Networks/Reserved","hk"],"45426":["Public Mobile Networks/Reserved","hk"],"45427":["Public Mobile Networks/Reserved","hk"],"45428":["Public Mobile Networks/Reserved","hk"],"45429":["Public Mobile Networks/Reserved","hk"],"45430":["Public Mobile Networks/Reserved","hk"],"45431":["Public Mobile Networks/Reserved","hk"],"45432":["Public Mobile Networks/Reserved","hk"],"45433":["Public Mobile Networks/Reserved","hk"],"45434":["Public Mobile Networks/Reserved","hk"],"45435":["Public Mobile Networks/Reserved","hk"],"45436":["Public Mobile Networks/Reserved","hk"],"45437":["Public Mobile Networks/Reserved","hk"],"45438":["Public Mobile Networks/Reserved","hk"],"45439":["Public Mobile Networks/Reserved","hk"],"45440":["shared by private TETRA systems","hk"],"45447":["shared by private TETRA systems","hk"],"45500":["Smartone Mobile Communications (Macao) Ltd.","mo"],"45501":["CTM","mo"],"45502":["China Telecom","mo"],"45503":["Hutchison Telecom","mo"],"45504":["CTM","mo"],"45505":["Hutchison Telephone Co. Ltd","mo"],"45506":["Smartone Mobile","mo"],"45601":["Mobitel (Cam GSM)","kh"],"45602":["Smart","kh"],"45603":["S Telecom (CDMA) (reserved)","kh"],"45604":["qb","kh"],"45605":["Smart","kh"],"45606":["Smart","kh"],"45608":["Metfone","kh"],"45609":["Sotelco/Beeline","kh"],"45611":["SEATEL","kh"],"45618":["Camshin (Shinawatra)","kh"],"456299":["CooTel","kh"],"45701":["Lao Telecommunications","la"],"45702":["ETL Mobile","la"],"45703":["Unitel","la"],"45708":["Millicom","la"],"46000":["China Mobile","cn"],"46001":["China Unicom","cn"],"46002":["China Mobile","cn"],"46003":["China Telecom","cn"],"46004":["China Mobile","cn"],"46005":["China Telecom","cn"],"46006":["China Unicom","cn"],"46007":["China Mobile","cn"],"46008":["China Mobile","cn"],"46009":["China Unicom","cn"],"46010":["China Unicom","cn"],"46011":["China Telecom","cn"],"46012":["China Telecom","cn"],"46015":["China Broadnet","cn"],"46020":["China Mobile","cn"],"460999":["Fix Line","cn"],"46601":["遠傳電信 Far EasTone Telecom","tw"],"46602":["遠傳電信 Far EasTone Telecom","tw"],"46603":["遠傳電信 Far EasTone Telecom","tw"],"46605":["遠傳電信Far EasTone Telecom(原亞太電信)","tw"],"46606":["Tuntex Telecom","tw"],"46607":["Far EasTone","tw"],"46609":["Vmax Telecom","tw"],"46610":["Global Mobile Corp.","tw"],"46611":["中華電信_Chunghwa Telecom","tw"],"46656":["International Telecom Co. Ltd (FITEL)","tw"],"46668":["ACeS Taiwan - ACeS Taiwan Telecommunications Co Ltd","tw"],"46688":["KG Telecom","tw"],"46689":["台灣大哥大(原台灣之星) Taiwan Mobile Telecom","tw"],"46690":["T-Star/VIBO","tw"],"46692":["中華電信_Chunghwa Telecom","tw"],"46693":["MobiTai Communications","tw"],"46697":["台灣大哥大 Taiwan Mobile Telecom","tw"],"46699":["TransAsia Telecoms","tw"],"467192":["Koryolink","kp"],"467193":["Sun Net","kp"],"467299":["Failed Calls","kp"],"47001":["Grameenphone","bd"],"47002":["Aktel","bd"],"47003":["Mobile 2000","bd"],"47004":["TeleTalk","bd"],"47005":["Citycell","bd"],"47006":["Citycell","bd"],"47007":["Airtel BD","bd"],"47201":["DhiMobile","mv"],"47202":["Ooredoo","mv"],"50200":["Art900","my"],"50201":["Art900","my"],"50210":["Digi","my"],"50211":["unifi mobile","my"],"50212":["Maxis/Hotlink","my"],"50213":["Celcom","my"],"50214":["Telekom Malaysia","my"],"502143":["Digi","my"],"502146":["Digi","my"],"502150":["Tune Talk","my"],"502151":["Baraka Telecom Sdn Bhd","my"],"502152":["Yes 5G","my"],"502153":["unifi mobile","my"],"502154":["TT dotCom","my"],"502155":["Samata Communications Sdn Bhd","my"],"502156":["Altel Communications","my"],"50216":["Digi","my"],"50217":["TimeCel","my"],"50218":["U Mobile","my"],"50219":["Celcom","my"],"502195":["XOX Com","my"],"502198":["Celcom","my"],"50220":["Electcoms Wireless Sdn Bhd","my"],"502299":["MKN","my"],"502999":["Fix Line","my"],"50501":["Telstra","au"],"50502":["Optus","au"],"50503":["Vodafone","au"],"50504":["Department of Defence","au"],"50505":["The Ozitel Network Pty. Ltd.","au"],"50506":["Hutchison 3G Australia Pty. Ltd.","au"],"50507":["Vodafone","au"],"50508":["One.Tel GSM 1800 Pty. Ltd.","au"],"50509":["Airnet Commercial Australia Ltd.","au"],"50510":["Norfolk Telecom","au"],"50511":["Telstra","au"],"50512":["Hutchison Telecommunications (Australia) Pty. Ltd.","au"],"50513":["RailCorp","au"],"50514":["AAPT Ltd.","au"],"50516":["VicTrack","au"],"50519":["Lycamobile","au"],"50524":["Advanced Communications Technologies Pty. Ltd.","au"],"50526":["Sinch","au"],"505299":["ACMA","au"],"50530":["Compatel","au"],"50535":["MessageBird","au"],"50539":["Telstra","au"],"50550":["Pivotel","au"],"50552":["OptiTel","au"],"50557":["CiFi","au"],"50571":["Telstra","au"],"50572":["Telstra","au"],"50588":["Pivotel","au"],"50590":["Optus","au"],"50599":["One.Tel GSM 1800 Pty. Ltd.","au"],"505999":["Fix Line","au"],"51000":["PSN","id"],"51001":["Indosat","id"],"51007":["Flexi (PT Telkom) (CDMA)","id"],"51008":["XL/AXIS","id"],"51009":["Smartfren","id"],"51010":["Telkomsel","id"],"51011":["XL/AXIS","id"],"51021":["Indosat - M3","id"],"51027":["PT Sampoerna Telekomunikasi Indonesia (STI)","id"],"51028":["Smartfren","id"],"51089":["3","id"],"51099":["Esia (PT Bakrie Telecom) (CDMA)","id"],"510999":["Fix Line","id"],"51401":["Telkomcel","tl"],"51402":["Timor Telecom","tl"],"51403":["Viettel","tl"],"514299":["Failed Calls","tl"],"514999":["Fix Line","tl"],"51501":["Islacom","ph"],"51502":["Globe Telecom","ph"],"51503":["Smart Communications","ph"],"51505":["Smart","ph"],"51518":["Redinternet","ph"],"51588":["Next Mobile","ph"],"515999":["Fix Line","ph"],"52000":["CAT CDMA","th"],"52001":["AIS GSM","th"],"52002":["CAT CDMA","th"],"52003":["AIS","th"],"52004":["TrueMove H 4G LTE","th"],"52005":["dtac","th"],"52015":["ACT Mobile","th"],"52018":["dtac","th"],"52020":["ACeS","th"],"52023":["Digital Phone Co.","th"],"52047":["TOT","th"],"52099":["True Move","th"],"520999":["Fix Line","th"],"52501":["Singtel","sg"],"52502":["Singtel","sg"],"52503":["M1","sg"],"52504":["Sunsurf","sg"],"52505":["StarHub","sg"],"52506":["Starhub","sg"],"52507":["Singtel","sg"],"52512":["Digital Trunked Radio Network","sg"],"525999":["Fix Line","sg"],"52801":["Telekom Brunei Bhd (TelBru)","bn"],"52802":["B-Mobile","bn"],"52811":["DST Com","bn"],"53000":["Reserved for AMPS MIN based IMSI's","nz"],"53001":["Vodafone","nz"],"53002":["Teleom New Zealand CDMA Network","nz"],"53003":["Woosh Wireless - CDMA Network","nz"],"53004":["Telstra","nz"],"53005":["Spark","nz"],"53024":["2degrees","nz"],"53028":["2degrees","nz"],"530999":["Fix Line","nz"],"53701":["Vodafone","pg"],"53702":["Vodafone","pg"],"53703":["Digicel Ltd","pg"],"537999":["Fix Line","pg"],"53901":["Tonga Communications Corporation","to"],"53943":["Shoreline Communication","to"],"53988":["Digicel","to"],"539999":["Fix Line","to"],"54001":["BREEZE","sb"],"54002":["Vodafone","sb"],"54010":["BREEZE","sb"],"54100":["AIL","vu"],"54101":["SMILE","vu"],"54105":["Digicel","vu"],"54201":["Vodafone","fj"],"54202":["Digicel","fj"],"54301":["Manuia","wf"],"543299":["Failed Calls","wf"],"54411":["Bluesky","as"],"544780":["ASTCA Mobile","as"],"54501":["Kiribati - TSKL","ki"],"54509":["Kiribati Frigate","ki"],"54601":["OPT Mobilis","nc"],"54705":["Viti","pf"],"54715":["Pacific Mobile Telecom (PMT)","pf"],"54720":["Tikiphone","pf"],"54801":["Telecom Cook","ck"],"54901":["Telecom Samoa Cellular Ltd.","ws"],"54927":["GoMobile SamoaTel Ltd","ws"],"549999":["Fix Line","ws"],"55001":["FSM Telecom","fm"],"551299":["Failed Calls","mh"],"55201":["Palau National Communications Corp. (a.k.a. PNCC)","pw"],"55202":["PECI/PalauTel (Palau","pw"],"55280":["Palau Mobile","pw"],"55301":["Tuvalu Telecommunication Corporation (TTC)","tv"],"55501":["Niue Telecom","nu"],"60201":["Orange Egypt","eg"],"60202":["Vodafone","eg"],"60203":["Etisalat","eg"],"60204":["WE","eg"],"602299":["Failed Calls","eg"],"60301":["Algérie Telecom","dz"],"60302":["Orascom Telecom Algérie","dz"],"60303":["Ooredoo","dz"],"60400":["Méditélécom","ma"],"60401":["Maroc","ma"],"60402":["inwi","ma"],"60404":["Al Houria Telecom","ma"],"60405":["inwi","ma"],"60406":["IAM","ma"],"60499":["Al Houria Telecom","ma"],"60501":["Orange Tunisie","tn"],"60502":["Tunisie Telecom","tn"],"60503":["Ooredoo Tunisia","tn"],"60506":["Lycamobile","tn"],"605999":["Fix Line","tn"],"60600":["Libyana","ly"],"60601":["Madar","ly"],"60602":["Al-Jeel","ly"],"60603":["Libya Phone","ly"],"60606":["Hatef","ly"],"60701":["Gamcel","gm"],"60702":["Africell","gm"],"60703":["Comium Services Ltd","gm"],"60704":["QCell","gm"],"60801":["Orange Senegal","sn"],"60802":["Sentel GSM","sn"],"60803":["Expresso","sn"],"60804":["HAYO","sn"],"608299":["2s Mobile","sn"],"60901":["Mattel S.A.","mr"],"60902":["Chinguitel S.A.","mr"],"60910":["Mauritel Mobiles","mr"],"61001":["Malitel","ml"],"61002":["Orange Mali","ml"],"61003":["Telecel","ml"],"61101":["Orange","gn"],"61102":["Sotelgui","gn"],"61103":["Intercel","gn"],"61104":["MTN/Areeba","gn"],"61105":["Cellcom Guinée SA","gn"],"61201":["Comstar","ci"],"61202":["Atlantique Cellulaire","ci"],"61203":["Orange Côte d'Ivoire","ci"],"61204":["Comium Côte d'Ivoire","ci"],"61205":["Loteny Telecom","ci"],"61206":["Oricel Côte d'Ivoire","ci"],"61207":["Aircomm Côte d'Ivoire","ci"],"61301":["Onatal (Telmob)","bf"],"61302":["Orange","bf"],"61303":["Telecel","bf"],"61401":["Sahel.Com","ne"],"61402":["Airtel Niger","ne"],"61403":["Telecel","ne"],"61404":["Orange Niger","ne"],"61501":["Togo Telecom","tg"],"61502":["Telecel/MOOV","tg"],"61503":["Moov Togo","tg"],"61601":["Libercom","bj"],"61602":["Telecel","bj"],"61603":["Spacetel Benin","bj"],"61604":["Bell Benin Communications","bj"],"61605":["Glo Communications Benin","bj"],"61701":["Orange Mauritius","mu"],"61702":["Mahanagar Telephone (Mauritius) Ltd.","mu"],"61703":["Chili","mu"],"61710":["Emtel","mu"],"61801":["Lonestar","lr"],"61802":["Libercell","lr"],"61804":["Comium Liberia","lr"],"61807":["Celcom","lr"],"61820":["LIBTELCO","lr"],"61901":["Orange","sl"],"61902":["Millicom","sl"],"61903":["Africell","sl"],"61904":["Comium (Sierra Leone) Ltd.","sl"],"61905":["Lintel (Sierra Leone) Ltd.","sl"],"61907":["Qcell","sl"],"61925":["Mobitel","sl"],"619299":["IPTel","sl"],"61940":["Datatel (SL) Ltd GSM","sl"],"61950":["Dtatel (SL) Ltd CDMA","sl"],"62001":["MTN","gh"],"62002":["Vodafone","gh"],"62003":["AirtelTigo","gh"],"62004":["Kasapa Telecom Ltd.","gh"],"62005":["National Security","gh"],"62006":["AirtelTigo","gh"],"62007":["Globacom","gh"],"62008":["Surfline","gh"],"620299":["Comsys","gh"],"62101":["Visafone","ng"],"62120":["Airtel Nigeria","ng"],"62125":["Visafone","ng"],"62127":["Smile","ng"],"621299":["Alpha Technologies","ng"],"62130":["MTN Nigeria Communications","ng"],"62140":["Nigeria Telecommunications Ltd.","ng"],"62150":["Glo","ng"],"62160":["9Pay","ng"],"62199":["Starcomms","ng"],"62201":["Airtel Chad","td"],"62202":["Tchad Mobile","td"],"62203":["Tigo/Milicom/Tchad Mobile","td"],"62204":["Salam","td"],"62301":["Centrafrique Telecom Plus (CTP)","cf"],"62302":["Telecel Centrafrique (TC)","cf"],"62303":["Orange Centrafricaine","cf"],"62304":["Nationlink","cf"],"623299":["Failed Calls","cf"],"62401":["Mobile Telephone Networks Cameroon","cm"],"62402":["Orange Cameroun","cm"],"62404":["Nexttel","cm"],"62501":["Cabo Verde Telecom","cv"],"62502":["T+Telecomunicaçôes","cv"],"62601":["Companhia Santomese de Telecomunicaçôes","st"],"62602":["Unitel","st"],"62701":["Orange","gq"],"62703":["Hits-GE","gq"],"627299":["Failed Calls","gq"],"62801":["Libertis S.A.","ga"],"62802":["Telecel Gabon S.A.","ga"],"62803":["Airtel Gabon","ga"],"62804":["Azur","ga"],"628299":["Failed Calls","ga"],"62901":["Airtel Congo","cg"],"62902":["Azur SA (ETC)","cg"],"62907":["Warid","cg"],"62910":["Libertis Telecom","cg"],"63001":["Vodacom Congo RDC sprl","cd"],"63002":["Airtel","cd"],"63005":["Supercell Sprl","cd"],"630299":["Failed Calls","cd"],"63086":["Orange RDC","cd"],"63088":["Yozma Timeturns","cd"],"63089":["Tigo","cd"],"63090":["Africell","cd"],"63102":["Unitel","ao"],"63104":["MOVICEL","ao"],"63201":["Guinétel S.A.","gw"],"63202":["Spacetel Guiné-Bissau S.A.","gw"],"63203":["Orange","gw"],"63207":["Guinetel","gw"],"632999":["Fix\tLine","gw"],"63301":["Cable & Wireless (Seychelles) Ltd.","sc"],"63302":["Mediatech International Ltd.","sc"],"63305":["Intelvision","sc"],"63310":["Airtel Seychelles","sc"],"63400":["Canar Telecom","sd"],"63401":["SD Mobitel","sd"],"63402":["Areeba-Sudan","sd"],"63403":["MTN","sd"],"63405":["Canar Telecom","sd"],"63406":["Zain","sd"],"63407":["Sudani","sd"],"63408":["Canar Telecom","sd"],"63409":["Privet","sd"],"63415":["Sudani One","sd"],"63422":["MTN","sd"],"634999":["Fix Line","sd"],"63510":["MTN Rwandacell","rw"],"63512":["Rwandatel","rw"],"63513":["Airtel Rwanda","rw"],"63514":["Airtel Rwanda","rw"],"63601":["ETH MTN","et"],"63602":["Safaricom Telecommunications Ethiopia","et"],"63701":["Telesom","so"],"63704":["Somafone","so"],"63710":["Nationlink","so"],"63719":["Hormuud","so"],"63725":["Hormuud","so"],"637299":["AirSom","so"],"63730":["Golis Telecommunications Company","so"],"63750":["Hormuud","so"],"63757":["Unitel","so"],"63760":["Nationlink","so"],"63770":["Onkod","so"],"63771":["Somtel","so"],"63782":["Telcom","so"],"63801":["Evatis","dj"],"63901":["Safaricom","ke"],"63902":["Safaricom","ke"],"63903":["Airtel Kenya","ke"],"63904":["Mobile Pay","ke"],"63905":["Yu","ke"],"63906":["Finserve Africa","ke"],"63907":["Telkom","ke"],"63909":["Homeland Media","ke"],"63910":["Jamii Telecommunications","ke"],"63911":["Jambo Telcoms","ke"],"63912":["Infura","ke"],"639299":["eferio","ke"],"64001":["Tri Telecomm. Ltd.","tz"],"64002":["TIGO","tz"],"64003":["Zantel","tz"],"64004":["Vodacom","tz"],"64005":["Airtel","tz"],"64006":["Sasatel Tanzania","tz"],"64007":["Life Tanzania","tz"],"64008":["Benson Informatics Ltd","tz"],"64009":["Halotel / Viettel","tz"],"64011":["Smile Communications","tz"],"64013":["WiAfrica","tz"],"64014":["MO Mobile","tz"],"64099":["Mkulima African Telecommunication","tz"],"64101":["Airtel Uganda","ug"],"64104":["Lycamobile","ug"],"64110":["MTN Uganda Ltd.","ug"],"64111":["Uganda Telecom Ltd.","ug"],"64114":["House of Integrated Technology and Systems Uganda Ltd","ug"],"64118":["Suretelecom Uganda Ltd","ug"],"64122":["Airtel Uganda","ug"],"64130":["K2 Telecom Ltd","ug"],"64133":["Smile","ug"],"64166":["i-Tel Ltd","ug"],"641999":["Fix Line","ug"],"64201":["Spacetel Burundi","bi"],"64202":["Safaris","bi"],"64203":["Telecel Burundi Company","bi"],"64207":["Smart Mobile","bi"],"64208":["Lumitel/Viettel","bi"],"64282":["Leo","bi"],"642999":["Fix\tLine","bi"],"64301":["T.D.M. GSM","mz"],"64303":["Movitel","mz"],"64304":["Vodacom","mz"],"64501":["Airtel Zambia","zm"],"64502":["Telecel Zambia Ltd.","zm"],"64503":["Zamtel","zm"],"645299":["Failed Calls","zm"],"64601":["Airtel Madagascar","mg"],"64602":["Orange Madagascar","mg"],"64603":["Sacel","mg"],"64604":["Telecom Malagasy Mobile","mg"],"646299":["Bip","mg"],"64700":["Orange La Réunion","re"],"64701":["Maore Mobile","yt"],"64702":["Telco OI","re"],"64703":["Free RE","re"],"64704":["Zeop_RE","re"],"64710":["Société Réunionnaise du Radiotéléphone","yt"],"64801":["Net One","zw"],"64803":["Telecel","zw"],"64804":["Econet","zw"],"64901":["Mobile Telecommunications Ltd.","na"],"64902":["switch","na"],"64903":["Powercom Pty Ltd","na"],"649299":["Demshi","na"],"65001":["Telekom Network Ltd.","mw"],"65002":["ZERO2","mw"],"65010":["Airtel Malawi","mw"],"65101":["VCL","ls"],"65102":["Econet Ezin-cel","ls"],"65201":["Mascom Wireless (Pty) Ltd.","bw"],"65202":["Orange Botswana (Pty) Ltd.","bw"],"65204":["beMobile","bw"],"65301":["EswatiniTelecom","sz"],"65302":["Eswatini Mobile","sz"],"65310":["Swazi MTN","sz"],"65401":["HURI - SNPT","km"],"65402":["Telma","km"],"654299":["Failed Calls","km"],"65501":["Vodacom","za"],"65502":["Telkom","za"],"65505":["Telkom","za"],"65506":["Sentech (Pty) Ltd.","za"],"65507":["Cell C (Pty) Ltd.","za"],"65510":["MTN","za"],"65511":["SAPS Gauteng","za"],"65512":["MTN","za"],"65519":["rain","za"],"65521":["Cape Town Metropolitan Council","za"],"655299":["Lycamobile","za"],"65530":["Bokamoso Consortium","za"],"65531":["Karabo Telecoms (Pty) Ltd.","za"],"65532":["Ilizwi Telecommunications","za"],"65533":["Thinta Thinta Telecommunications","za"],"65534":["Bokone Telecoms","za"],"65535":["Kingdom Communications","za"],"65536":["Amatole Telecommunication Services","za"],"65538":["rain","za"],"65573":["rain","za"],"65574":["rain","za"],"65701":["Eritel","er"],"658299":["Failed Calls","sh"],"65902":["MTN","ss"],"65903":["Gemtel Ltd (South Sudan","ss"],"65904":["Network of The World Ltd (NOW) (South Sudan","ss"],"65906":["Zain","ss"],"659299":["Digitel","ss"],"702099":["Smart","bz"],"702299":["Failed Calls","bz"],"70267":["Belize Telecommunications Ltd.","bz"],"70268":["International Telecommunications Ltd. (INTELCO)","bz"],"70269":["Smart","bz"],"70299":["Smart","bz"],"70401":["Claro GT","gt"],"70402":["Comunicaciones Celulares S.A.","gt"],"70403":["Movistar","gt"],"704030":["Movistar","gt"],"70601":["Claro SV","sv"],"70602":["Digicel, S.A. de C.V.","sv"],"70603":["Tigo","sv"],"70604":["Movistar","sv"],"706040":["Movistar","sv"],"70605":["INTELFON SA de CV","sv"],"708001":["Claro HN","hn"],"708002":["Celtel","hn"],"70801":["Claro HN","hn"],"70802":["Celtel","hn"],"708020":["Celtel","hn"],"708030":["HonduTel","hn"],"70804":["Digicel","hn"],"708040":["Digicel","hn"],"70830":["Hondutel","hn"],"70840":["Digicel","hn"],"71021":["Claro NI","ni"],"71030":["Movistar (Telefonía Celular de Nicaragua)","ni"],"710300":["Movistar (Telefonía Celular de Nicaragua)","ni"],"71070":["Yota Nicaragua","ni"],"71073":["Servicios de Comunicaciones, S.A. (SERCOM)","ni"],"710730":["Servicios de Comunicaciones, S.A. (SERCOM)","ni"],"710999":["Fix Line","ni"],"71201":["KOLBI ICE","cr"],"712019":["Tuyo","cr"],"71202":["KOLBI ICE","cr"],"71203":["Claro CR","cr"],"71204":["Liberty","cr"],"712190":["Tuyo","cr"],"71220":["Virtualis","cr"],"712999":["Fix Line","cr"],"71401":["Cable & Wireless Panama S.A.","pa"],"71402":["Movistar","pa"],"714020":["Movistar","pa"],"71403":["Claro PA","pa"],"71404":["Digicel","pa"],"714040":["Digicel","pa"],"714999":["Fix Line","pa"],"71601":["GlobalStar","pe"],"71602":["GlobalStar","pe"],"71606":["Movistar","pe"],"71607":["Nextel","pe"],"71610":["Claro PE","pe"],"71615":["Bitel","pe"],"71617":["Entel","pe"],"71620":["Claro /Amer.Mov./TIM","pe"],"722007":["Movistar","ar"],"722010":["Movistar","ar"],"722020":["Nextel Argentina srl","ar"],"722031":["Claro","ar"],"722034":["Personal","ar"],"72207":["Movistar","ar"],"722070":["Movistar","ar"],"722210":["IMOWI","ar"],"722299":["Express","ar"],"72231":["Claro AR","ar"],"722310":["Claro AR","ar"],"722320":["Compañía de Telefonos del Interior Norte S.A.","ar"],"722330":["Compañía de Telefonos del Interior S.A.","ar"],"72234":["Telecom Personal S.A.","ar"],"722340":["Telecom Personal S.A.","ar"],"722341":["Telecom Personal S.A.","ar"],"72236":["Argentina:Nuestro","ar"],"722999":["Fix Line","ar"],"72400":["Nextel","br"],"72401":["CRT Cellular","br"],"72402":["TIM","br"],"72403":["TIM","br"],"72404":["TIM","br"],"72405":["Claro BR","br"],"72406":["Vivo","br"],"72407":["Sercontel Cel","br"],"72408":["Maxitel MG","br"],"72409":["Telepar Cel","br"],"72410":["Vivo","br"],"72411":["Vivo","br"],"72412":["Americel","br"],"72413":["Telesp Cel","br"],"72414":["Maxitel BA","br"],"72415":["Sercomtel","br"],"72416":["Brasil Telecom GSM","br"],"72417":["Ceterp Cel","br"],"72418":["Datora","br"],"72419":["Telemig Cel","br"],"72421":["Telerj Cel","br"],"72423":["Vivo","br"],"72424":["Oi","br"],"72425":["Telebrasilia Cel","br"],"72426":["AmericaNet","br"],"72427":["Telegoias Cel","br"],"72429":["Unifique","br"],"72430":["Oi","br"],"72431":["Oi","br"],"72432":["Algar Telecom","br"],"72433":["Algar Telecom","br"],"72434":["Algar Telecom","br"],"72435":["Telebahia Cel","br"],"72437":["Telergipe Cel","br"],"72438":["Claro BR","br"],"72439":["Nextel","br"],"72441":["Telpe Cel","br"],"72443":["Telepisa Cel","br"],"72445":["Telpa Cel","br"],"72447":["Telern Cel","br"],"72448":["Teleceara Cel","br"],"72451":["Telma Cel","br"],"72453":["Telepara Cel","br"],"72454":["TIM","br"],"72455":["Teleamazon Cel","br"],"72457":["Teleamapa Cel","br"],"72459":["Telaima Cel","br"],"72477":["Brisanet","br"],"73000":["TESAM SA","cl"],"73001":["Entel","cl"],"73002":["Movistar","cl"],"73003":["Claro CL","cl"],"73004":["WOM","cl"],"73005":["Multikom S.A.","cl"],"73006":["Blue Two Chile SA","cl"],"73007":["Movistar","cl"],"73008":["VTR Banda Ancha SA","cl"],"73009":["WOM","cl"],"73010":["Entel","cl"],"73011":["Celupago SA","cl"],"73012":["Telestar Movil SA","cl"],"73013":["Tribe Mobile SPA","cl"],"73014":["Netline Telefonica Movil Ltda","cl"],"73015":["Cibeles Telecom SA","cl"],"73019":["Sociedad Falabella Movil SPA","cl"],"73026":["Entel","cl"],"732001":["Colombia Telecomunicaciones S.A. - Telecom","co"],"732002":["Edatel S.A.","co"],"732020":["Emtelsa","co"],"732099":["Emcali","co"],"732101":["Claro CO","co"],"732102":["Bellsouth Colombia S.A.","co"],"732103":["Colombia Móvil S.A.","co"],"732111":["Colombia Móvil S.A.","co"],"732123":["Movistar","co"],"732130":["WOM","co"],"732142":["UNE","co"],"732154":["Virgin Mobile","co"],"732165":["Tigo","co"],"732187":["ETB 4G","co"],"732199":["SUMA movil","co"],"732220":["Libre Tecnologias","co"],"732230":["Setroc Mobile","co"],"732240":["Flash Mobile","co"],"732299":["ATnet","co"],"732360":["WOM","co"],"732666":["Claro","co"],"732999":["Fix Line","co"],"73401":["Infonet","ve"],"73402":["Corporación Digitel","ve"],"73403":["Digicel","ve"],"73404":["Movistar","ve"],"73406":["Telecomunicaciones Movilnet, C.A.","ve"],"73601":["Nuevatel S.A.","bo"],"73602":["ENTEL S.A.","bo"],"73603":["Telecel S.A.","bo"],"738002":["GT&T Cellink Plus","gy"],"73801":["Cel*Star (Guyana) Inc.","gy"],"73802":["GT&T Cellink Plus","gy"],"74000":["Movistar","ec"],"740000":["Failed Call(s)","ec"],"74001":["Claro EC","ec"],"740010":["Claro EC","ec"],"74002":["Telecsa S.A.","ec"],"74003":["Tuenti","ec"],"74401":["Hola Paraguay S.A.","py"],"74402":["Claro PY","py"],"74403":["Compañia Privada de Comunicaciones S.A.","py"],"74404":["Telecel","py"],"74405":["Personal","py"],"74406":["Hola Paraguay S.A.","py"],"74601":["Telesur","sr"],"74602":["Telesur","sr"],"74603":["Digicel","sr"],"74604":["Intelsur","sr"],"746999":["Fix Line","sr"],"74800":["Ancel","uy"],"74801":["Ancel","uy"],"74803":["Ancel","uy"],"74807":["Movistar","uy"],"74810":["Claro UY","uy"],"750001":["Sure","fk"],"90101":["ICO Global Communications","n/a"],"90102":["Sense Communications International AS","n/a"],"90103":["Iridium Satellite, LLC (GMSS)","n/a"],"90104":["Globalstar","n/a"],"90105":["Thuraya RMSS Network","n/a"],"90106":["Thuraya Satellite Telecommunications Company","n/a"],"90107":["Ellipso","n/a"],"90109":["Tele1 Europe","n/a"],"90110":["Asia Cellular Satellite (AceS)","n/a"],"90111":["Inmarsat Ltd.","n/a"],"90112":["Maritime Communications Partner AS (MCP network)","n/a"],"90113":["Global Networks, Inc.","n/a"],"90114":["Telenor GSM - services in aircraft","n/a"],"90115":["SITA GSM services in aircraft (On Air)","n/a"],"90116":["Jasper Systems, Inc.","n/a"],"90117":["Jersey Telecom","n/a"],"90118":["AT&T Mobility (Wireless Maritime Services)","n/a"],"90119":["Vodafone","n/a"],"90120":["Intermatica","n/a"],"90121":["Seanet Maritime Communications","n/a"],"90122":["Denver Consultants Ltd","n/a"],"90128":["Vodafone GDSP","n/a"],"90137":["Transatel","n/a"],"90158":["Bics","n/a"],"90188":["Telecommunications for Disaster Relief (TDR) (OCHA)","n/a"],"90198":["Skylo","n/a"]},"i":{"202":"gr","204":"nl","206":"be","208":"fr","212":"mc","213":"ad","214":"es","216":"hu","218":"ba","219":"hr","220":"rs","221":"xk","222":"it","225":"va","226":"ro","228":"ch","230":"cz","231":"sk","232":"at","234":"gb","235":"gb","238":"dk","240":"se","242":"no","244":"fi","246":"lt","247":"lv","248":"ee","250":"ru","255":"ua","257":"by","259":"md","260":"pl","262":"de","266":"gi","268":"pt","270":"lu","272":"ie","274":"is","276":"al","278":"mt","280":"cy","282":"ge","283":"am","284":"bg","286":"tr","288":"fo","289":"ge","290":"gl","292":"sm","293":"si","294":"mk","295":"li","297":"me","302":"ca","308":"pm","310":"us","311":"us","312":"us","313":"us","314":"us","315":"us","316":"us","330":"pr","334":"mx","338":"jm","340":"gf","342":"bb","344":"ag","346":"ky","348":"vg","350":"bm","352":"gd","354":"ms","356":"kn","358":"lc","360":"vc","362":"bq","363":"aw","364":"bs","365":"ai","366":"dm","368":"cu","370":"do","372":"ht","374":"tt","376":"tc","400":"az","401":"kz","402":"bt","404":"in","405":"in","406":"in","410":"pk","412":"af","413":"lk","414":"mm","415":"lb","416":"jo","417":"sy","418":"iq","419":"kw","420":"sa","421":"ye","422":"om","424":"ae","425":"il","426":"bh","427":"qa","428":"mn","429":"np","430":"ae","431":"ae","432":"ir","434":"uz","436":"tj","437":"kg","438":"tm","440":"jp","441":"jp","450":"kr","452":"vn","454":"hk","455":"mo","456":"kh","457":"la","460":"cn","461":"cn","466":"tw","467":"kp","470":"bd","472":"mv","502":"my","505":"au","510":"id","514":"tl","515":"ph","520":"th","525":"sg","528":"bn","530":"nz","537":"pg","539":"to","540":"sb","541":"vu","542":"fj","543":"wf","544":"as","545":"ki","546":"nc","547":"pf","548":"ck","549":"ws","550":"fm","551":"mh","552":"pw","553":"tv","555":"nu","602":"eg","603":"dz","604":"ma","605":"tn","606":"ly","607":"gm","608":"sn","609":"mr","610":"ml","611":"gn","612":"ci","613":"bf","614":"ne","615":"tg","616":"bj","617":"mu","618":"lr","619":"sl","620":"gh","621":"ng","622":"td","623":"cf","624":"cm","625":"cv","626":"st","627":"gq","628":"ga","629":"cg","630":"cd","631":"ao","632":"gw","633":"sc","634":"sd","635":"rw","636":"et","637":"so","638":"dj","639":"ke","640":"tz","641":"ug","642":"bi","643":"mz","645":"zm","646":"mg","647":"yt","648":"zw","649":"na","650":"mw","651":"ls","652":"bw","653":"sz","654":"km","655":"za","657":"er","658":"sh","659":"ss","702":"bz","704":"gt","706":"sv","708":"hn","710":"ni","712":"cr","714":"pa","716":"pe","722":"ar","724":"br","730":"cl","732":"co","734":"ve","736":"bo","738":"gy","740":"ec","744":"py","746":"sr","748":"uy","750":"fk","901":"n/a"},"t":["302","310","311","312","313","314","315","316","334","338"],"meta":{"source":"Android Open Source Project carrier_list.textpb","source_url":"https://android.googlesource.com/platform/packages/providers/TelephonyProvider/+/master/assets/latest_carrier_id/carrier_list.textpb","aosp_version":"134217771","aosp_generic_records":1672}} +{"c":{"00101":["Test Network, Used by GSM test equipment",""],"20201":["Cosmote","gr"],"20202":["Cosmote","gr"],"20203":["OTE","gr"],"20204":["OSE","gr"],"20205":["Vodafone","gr"],"20207":["AMD Telecom","gr"],"20209":["Info Quest S.A.","gr"],"20210":["Telestet","gr"],"20212":["Yuboto","gr"],"20214":["CyTa Mobile","gr"],"20215":["BWS","gr"],"20216":["Inter Telecom","gr"],"202299":["AMD Telecom","gr"],"202999":["Fix Line","gr"],"20400":["Intovoice","nl"],"20402":["T-Mobile","nl"],"20403":["Voiceworks NL","nl"],"20404":["Vodafone","nl"],"20405":["ElephantTalk","nl"],"20406":["Vectone Mobile","nl"],"20407":["Move / Teleena","nl"],"20408":["KPN Mobiel","nl"],"20409":["Lycamobile","nl"],"20410":["KPN","nl"],"20412":["KPN Mobiel","nl"],"20414":["6GMOBILE BV","nl"],"20415":["Ziggo","nl"],"20416":["Odido","nl"],"20417":["Intercity Mobile Communications BV","nl"],"20418":["Ziggo Services","nl"],"20420":["T-Mobile","nl"],"20421":["NS Railinfrabeheer B.V.","nl"],"20423":["KORE","nl"],"20424":["Private Mobility","nl"],"20426":["SpeakUp","nl"],"20427":["L-mobi","nl"],"20428":["Lancelot","nl"],"20429":["Tismi","nl"],"204299":["88 mobile","nl"],"20430":["ASPIDER Solutions","nl"],"20433":["Truphone","nl"],"20463":["MessageBird","nl"],"20465":["AGMS","nl"],"20468":["Unify Mobile","nl"],"20469":["KPN Lab","nl"],"20498":["Lancelot","nl"],"204999":["Fix Line","nl"],"20600":["Proximus","be"],"20601":["Proximus","be"],"20602":["Infrabel","be"],"20604":["Proximus","be"],"20605":["Telenet","be"],"20606":["Lycamobile","be"],"20607":["Vectone Mobile","be"],"20608":["VOOmobile","be"],"20610":["Orange","be"],"20620":["BASE","be"],"20623":["Dust Mobile","be"],"20625":["Dense Air","be"],"20628":["Bics","be"],"206299":["FEBO","be"],"20630":["Unleashed","be"],"20633":["Ericsson","be"],"20634":["onoff","be"],"20699":["Lancelot","be"],"206999":["Fix Line","be"],"20800":["Tel/Te","fr"],"20801":["Orange","fr"],"20802":["Orange","fr"],"20803":["MobiquiThings","fr"],"20804":["Netcom Group","fr"],"20805":["Globalstar Europe","fr"],"20806":["Globalstar Europe","fr"],"20807":["Globalstar Europe","fr"],"20808":["SFR","fr"],"20809":["SFR","fr"],"20810":["SFR","fr"],"20811":["SFR","fr"],"20812":["Truphone","fr"],"20813":["SFR","fr"],"20814":["Free Mobile","fr"],"20815":["Free","fr"],"20816":["Free Mobile","fr"],"20817":["Legos","fr"],"208180":["Private FR","fr"],"20820":["Bouygues Telecom","fr"],"20821":["Bouygues Telecom","fr"],"20822":["Transatel","fr"],"20823":["Virgin","fr"],"20824":["MobiquiThings","fr"],"20825":["Lycamobile","fr"],"20826":["NRJ","fr"],"20827":["Coriolis","fr"],"20828":["Airmob","fr"],"20829":["Orange","fr"],"208299":["Add-On Multimedia","fr"],"20830":["Syma Mobile","fr"],"20831":["Vectone Mobile","fr"],"20832":["Orange","fr"],"20834":["Cellhire","fr"],"20835":["Free Mobile","fr"],"20836":["Free Mobile","fr"],"20837":["IP Directions","fr"],"20838":["Lebara","fr"],"20839":["Networth Telecom","fr"],"208506":["Airbus FR","fr"],"20888":["Bouygues Telecom","fr"],"20889":["Hub One","fr"],"20891":["Orange","fr"],"20892":["IP Directions","fr"],"20894":["Halys","fr"],"208999":["Fix Line","fr"],"21201":["Monaco Telecom","mc"],"21210":["MONACO TELECOM","mc"],"21303":["Mobiland","ad"],"21401":["Vodafone","es"],"21402":["Altecom","es"],"21403":["Orange","es"],"21404":["Yoigo","es"],"21405":["Movistar","es"],"21406":["Euskaltel","es"],"21407":["Movistar","es"],"21408":["Euskaltel","es"],"21409":["Orange","es"],"21410":["Zinnia","es"],"21411":["Orange","es"],"21412":["Venus Movil","es"],"21414":["Avatel Movil","es"],"21415":["BT Espana SAU","es"],"21416":["mobil R","es"],"21417":["mobil R","es"],"21418":["ONO","es"],"21419":["Simyo","es"],"21420":["Fonyou Telecom","es"],"21421":["Jazz Telecom SAU","es"],"21422":["Digi Spain","es"],"21423":["Yoigo","es"],"21425":["Lycamobile","es"],"21426":["Lleida","es"],"21427":["Truphone","es"],"21429":["Yoigo","es"],"214299":["ACN","es"],"21432":["ION Mobile","es"],"21433":["Yoigo","es"],"21434":["ION Mobile","es"],"21435":["SUMA movil","es"],"21436":["Alai","es"],"21437":["Vodafone","es"],"21438":["Movistar","es"],"214999":["Fix Line","es"],"21601":["Yettel","hu"],"21602":["MVM NET","hu"],"21603":["Digi","hu"],"216299":["Antenna","hu"],"21630":["Magyar Telekom","hu"],"21670":["Vodafone","hu"],"21671":["UPC Magyarorszag Kft.","hu"],"216999":["Fix line","hu"],"21803":["Eronet Mobile Communications Ltd.","ba"],"21805":["MOBI'S (Mobilina Srpske)","ba"],"21890":["GSMBIH","ba"],"21901":["Hrvatski Telekom","hr"],"21902":["Telemach","hr"],"21910":["A1/Tomato","hr"],"21912":["TELE FOCUS","hr"],"21920":["Hrvatski Telekom","hr"],"219999":["Fix Line","hr"],"22001":["Yettel","rs"],"22002":["Yettel","rs"],"22003":["Telekom Srbija a.d.","rs"],"22005":["A1 SRB","rs"],"22011":["Globaltel","rs"],"22020":["VIP","rs"],"220299":["Failed Calls","rs"],"22101":["Vala","xk"],"22102":["IPKO","xk"],"22103":["MTS","xk"],"22106":["Dardafon.Net LLC","xk"],"22107":["D3 mobile","xk"],"221299":["MTS","xk"],"22200":["Premium Numbers","it"],"22201":["TIM","it"],"22202":["Elsacom","it"],"22206":["Vodafone","it"],"22207":["Kena","it"],"22208":["Fastweb SpA","it"],"22210":["Vodafone","it"],"222299":["A-Tono","it"],"22230":["RFI","it"],"22233":["Poste Mobile","it"],"22234":["BT mobile","it"],"22235":["Lycamobile","it"],"22236":["Digi Italy","it"],"22237":["WindTre / Hi3G","it"],"22239":["SMS.it / LINK Mobility","it"],"22240":["Agile Telecom","it"],"22242":["Enel","it"],"22243":["Telecom Italia Mobile","it"],"22244":["Mundio","it"],"22248":["Telecom Italia Mobile","it"],"22249":["Vianova Mobile","it"],"22250":["Iliad","it"],"22251":["ho.","it"],"22253":["WEB CoopVoce","it"],"22254":["Plintron","it"],"22256":["Spusu IT","it"],"22258":["rdcom","it"],"22277":["IPSE 2000","it"],"22288":["WINDTRE","it"],"22298":["Blu","it"],"22299":["WINDTRE","it"],"222999":["Fix Line","it"],"225299":["Failed Calls","va"],"22601":["Vodafone","ro"],"22602":["Romtelecom SA","ro"],"22603":["Telekom","ro"],"22604":["Telekom Romania","ro"],"22605":["Digi.Mobil","ro"],"22606":["Telekom Romania","ro"],"22610":["Orange","ro"],"22611":["Enigma Systems","ro"],"22616":["Lycamobile","ro"],"226299":["Iristel","ro"],"22801":["Swisscom","ch"],"22802":["Sunrise","ch"],"22803":["Salt","ch"],"22805":["Comfone AG","ch"],"22806":["SBB AG","ch"],"22807":["IN&Phone SA","ch"],"22808":["Tele2 Telecommunications AG","ch"],"22809":["Comfone","ch"],"22812":["Sunrise","ch"],"22851":["Bebbicell AG","ch"],"22852":["Mundio Mobile AG","ch"],"22853":["Sunrise","ch"],"22854":["Lycamobile","ch"],"22858":["Beeone","ch"],"22859":["Vectone Mobile","ch"],"22860":["Sunrise","ch"],"22862":["Telecom26","ch"],"22865":["Nexphone","ch"],"22866":["Inovia","ch"],"22869":["MTEL","ch"],"22870":["Tismi","ch"],"22871":["Spusu CH","ch"],"228999":["Fix Line","ch"],"23001":["T-Mobile","cz"],"23002":["O2","cz"],"23003":["Vodafone","cz"],"23004":["Mobilkom a.s.","cz"],"23005":["PODA","cz"],"23007":["T-Mobile","cz"],"23008":["Compatel","cz"],"23009":["Uniphone","cz"],"230299":["+4U Mobile","cz"],"23098":["Sprava Zeleznicni Dopravni Cesty","cz"],"23099":["Vodafone","cz"],"230999":["Fix Line","cz"],"23101":["Orange","sk"],"23102":["Slovak Telekom","sk"],"23103":["4ka SK","sk"],"23104":["Eurotel, UMTS","sk"],"23105":["Orange, UMTS","sk"],"23106":["O2","sk"],"23107":["Orange","sk"],"23108":["Uniphone","sk"],"23115":["Orange","sk"],"231299":["Vonage","sk"],"23150":["Telekom","sk"],"23199":["ZSR","sk"],"23201":["A1 Telekom","at"],"23202":["A1 Telekom","at"],"23203":["Magenta Telekom","at"],"23204":["T-Mobile / Magenta","at"],"23205":["Drei","at"],"23206":["Hutchison Drei / 3","at"],"23207":["Magenta Telekom","at"],"23208":["Telefonica Austria","at"],"23209":["A1 Telekom","at"],"23210":["Drei","at"],"23211":["A1 Telekom","at"],"23212":["A1 Telekom","at"],"23213":["T-Mobile / Magenta","at"],"23214":["Hutchinson Drei","at"],"23215":["T-Mobile / Magenta","at"],"23216":["Hutchinson Drei","at"],"23217":["Spusu AT","at"],"23218":["smartspace","at"],"23219":["Hutchinson Drei","at"],"23220":["Mtel","at"],"23222":["Plintron","at"],"23223":["T-Mobile / Magenta","at"],"23224":["Smartel Services","at"],"23225":["Holding Graz","at"],"23226":["LIWEST Mobil","at"],"23227":["Tismi","at"],"232299":["ArgoNET","at"],"23291":["OBB Infrastruktur","at"],"232999":["Fix Line","at"],"23400":["British Telecom","gb"],"23401":["Mapesbury Communications Ltd.","gb"],"23402":["O2","gb"],"23403":["Jersey Telenet Ltd","gb"],"23404":["FMS Solutions Ltd","gb"],"23405":["Spitfire Network Services Ltd","gb"],"23406":["Internet One Ltd","gb"],"23407":["Cable and Wireless plc","gb"],"23408":["BT OnePhone","gb"],"23409":["Wire9 Telecom plc","gb"],"23410":["O2","gb"],"23411":["O2","gb"],"23412":["Ntework Rail Infrastructure Ltd","gb"],"23413":["Ntework Rail Infrastructure Ltd","gb"],"23414":["Hay Systems Ltd","gb"],"23415":["Vodafone","gb"],"23416":["Opal Telecom Ltd","gb"],"23417":["Flextel Ltd","gb"],"23418":["Wire9 Telecom plc","gb"],"23419":["Teleware plc","gb"],"23420":["Three Mobile","gb"],"23422":["Telesign Mobile","gb"],"23423":["Icron Network","gb"],"23424":["Greenfone","gb"],"23425":["Truphone","gb"],"23426":["Lycamobile","gb"],"23427":["Tata Communications Ltd","gb"],"23428":["Marathon Telecom","gb"],"23429":["aql","gb"],"23430":["EE","gb"],"23431":["EE","gb"],"23432":["EE","gb"],"23433":["EE","gb"],"23434":["Orange","gb"],"23435":["JSC Ingenicum","gb"],"23436":["Sure Isle of Man","gb"],"23437":["Synectiv","gb"],"23438":["Virgin Mobile","gb"],"23439":["Gamma","gb"],"23440":["Spusu GB","gb"],"23450":["Jersey Telecom","gb"],"23451":["now broadband","gb"],"23453":["TANGO","gb"],"23455":["Cable and Wireless Guensey Ltd","gb"],"23456":["NCSC","gb"],"23457":["Sky","gb"],"23458":["Manx Telecom","gb"],"23471":["Emergency Services Network","gb"],"23472":["Hanhaa Mobile","gb"],"23474":["Pareteum","gb"],"23475":["Inquam Telecom (Holdings) Ltd.","gb"],"23476":["British Telecom","gb"],"23477":["Vodafone","gb"],"23478":["Airwave mmO2 Ltd","gb"],"23486":["EE","gb"],"23487":["Lebara","gb"],"23489":["Vodafone","gb"],"23491":["Vodafone","gb"],"23492":["Vodafone","gb"],"23494":["Three Mobile","gb"],"23495":["Network Rail","gb"],"23499":["08Direct","gb"],"234998":["Virgin Mobile","gb"],"234999":["Fix Line","gb"],"23502":["Everyth. Ev.wh.","gb"],"23594":["Three Mobile","gb"],"23801":["TDC Mobil","dk"],"23802":["Telenor","dk"],"23803":["MIGway A/S","dk"],"23804":["Nexcon.io","dk"],"23806":["3","dk"],"23807":["Barablu Mobile Ltd.","dk"],"23808":["Voxbone / Bandwidth","dk"],"23810":["TDC Mobil","dk"],"23812":["Lycamobile","dk"],"23813":["Compatel","dk"],"23814":["Monty Mobile","dk"],"23815":["Net 1","dk"],"23816":["Tismi","dk"],"23817":["Gotanet","dk"],"23820":["Telia","dk"],"23823":["Banedanmark","dk"],"23825":["Viahub","dk"],"23828":["LINK Mobility","dk"],"23830":["Telia","dk"],"23842":["Greenwave","dk"],"23866":["Telenor","dk"],"23873":["Onomondo","dk"],"23877":["Tele2","dk"],"23888":["Cobira","dk"],"23896":["Telia","dk"],"238999":["Fix Line","dk"],"24001":["Telia Sverige AB","se"],"24002":["3 (Hi3G Access AB)","se"],"24003":["Nordisk Mobiltelefon AS","se"],"24004":["3G Infrastructure Services AB","se"],"24005":["Svenska UMTS-Nät AB","se"],"24006":["Vimla","se"],"24007":["Tele2/Comviq Sverige/Com Hem","se"],"24008":["Telenor Sverige AB","se"],"24009":["Telenor Sweden (not used)","se"],"24010":["Spring Mobil AB","se"],"24011":["Linholmen Science Park AB","se"],"24012":["Barablu Mobile Scandinavia Ltd","se"],"24013":["Ventelo Sverige AB","se"],"24014":["TDC Mobil A/S","se"],"24015":["Wireless Maingate Nordic AB","se"],"24016":["42IT AB","se"],"24017":["Gotanet","se"],"24018":["Messit / Minicall","se"],"24019":["Vectone Mobile","se"],"24020":["Wireless Maingate Message Services AB","se"],"24021":["Banverket","se"],"24022":["EUtel","se"],"24023":["Infobip","se"],"24024":["Telenor","se"],"24025":["Monty Mobile","se"],"24026":["Twilio","se"],"24027":["Globetouch","se"],"24028":["LINK Mobility","se"],"24029":["MI Carrier Services","se"],"24030":["NextGen Mobile Ltd (CardBoardFish)","se"],"24031":["Rebtel","se"],"24032":["Compatel","se"],"24033":["Mobile Arts","se"],"24035":["42 Telecom","se"],"24036":["interactive digital media / IDM","se"],"24037":["Sinch","se"],"24038":["Voxbone / Bandwidth","se"],"24039":["Primlight","se"],"24040":["Netmore","se"],"24042":["Telenor Connexion","se"],"24043":["MobiWeb","se"],"24044":["Telenabler","se"],"24045":["Spirius","se"],"24046":["Viahub","se"],"24047":["Viatel","se"],"24048":["Tismi","se"],"24050":["Telavox","se"],"24063":["Fink Telecom","se"],"240999":["Fix Line","se"],"24201":["Telenor","no"],"242017":["Ventelo AS","no"],"24202":["Telia","no"],"24203":["Teletopia Mobile Communications AS","no"],"24204":["Tele2 Norge AS","no"],"24205":["OneCall","no"],"24206":["ICE","no"],"24207":["Ventelo AS","no"],"24208":["TDC Mobil A/S","no"],"24209":["com4","no"],"24210":["Nkom","no"],"24212":["Telenor","no"],"24214":["Ice Norway","no"],"24215":["eRate","no"],"24216":["Iristel","no"],"24220":["BANE NOR","no"],"24221":["BANE NOR","no"],"24222":["Altibox Mobil","no"],"24223":["Lycamobile","no"],"242299":["bigblu","no"],"242999":["Fix Line","no"],"24403":["DNA","fi"],"24404":["Finnet Networks Ltd.","fi"],"24405":["Elisa","fi"],"24406":["Elisa","fi"],"24407":["Nokia Test Network","fi"],"24408":["Unknown","fi"],"24409":["Finnet Group","fi"],"24410":["TDC","fi"],"24411":["Viahub","fi"],"24412":["DNA","fi"],"24413":["DNA","fi"],"24414":["Alands Mobiltelefon AB","fi"],"24415":["Telit","fi"],"24416":["Oy Finland Tele2 AB","fi"],"24421":["Elisa","fi"],"24424":["Nord Connect","fi"],"24426":["Compatel","fi"],"24429":["Scnl Truphone","fi"],"244299":["Benemen","fi"],"24432":["Voxbone / Bandwidth","fi"],"24433":["VIRVE","fi"],"24435":["Ukko Mobile","fi"],"24436":["Telia","fi"],"24437":["Tismi","fi"],"24438":["NSN","fi"],"24439":["NSN","fi"],"24440":["NSN","fi"],"24441":["NSN","fi"],"24442":["Viahub","fi"],"24443":["Telavox","fi"],"24445":["VIRVE","fi"],"24446":["VIRVE","fi"],"24447":["VIRVE","fi"],"24482":["interactive digital media / IDM","fi"],"24491":["Telia","fi"],"24601":["Telia","lt"],"24602":["BITĖ","lt"],"24603":["Tele2","lt"],"24605":["LTG","lt"],"24606":["Mediafon","lt"],"246299":["SkyCall","lt"],"24701":["LMT","lv"],"24702":["Tele2/ZZ","lv"],"24703":["Telekom Baltija","lv"],"24704":["Beta Telecom","lv"],"24705":["Bite","lv"],"24706":["SIA Rigatta","lv"],"24707":["SIA Master Telecom","lv"],"24708":["VENTA Mobile","lv"],"24709":["XOmobile","lv"],"24710":["LMT","lv"],"247299":["Premium Numbers","lv"],"24801":["Telia","ee"],"24802":["Elisa","ee"],"24803":["Tele2","ee"],"24804":["OY Top Connect","ee"],"24805":["AS Bravocom Mobiil","ee"],"24806":["OY ViaTel","ee"],"24807":["Televõrgu AS","ee"],"24813":["Telia","ee"],"24871":["Siseministeerium (Ministry of Interior)","ee"],"25001":["МТС","ru"],"25002":["MegaFon","ru"],"25003":["Tele2","ru"],"25004":["Sibchallenge","ru"],"25005":["Tele2","ru"],"250050":["Sberbank-Telecom","ru"],"25007":["BM Telecom","ru"],"25009":["Skylink","ru"],"25010":["Don Telecom","ru"],"25011":["Orensot","ru"],"25012":["Tele2","ru"],"25013":["Kuban GSM","ru"],"25015":["ZAO SMARTS","ru"],"25016":["New Telephone Company","ru"],"25017":["Tele2","ru"],"25019":["Volgograd Mobile","ru"],"25020":["Tele2","ru"],"25026":["VTB Mobile","ru"],"25028":["Extel","ru"],"250299":["A-Mobile","ru"],"25032":["Win Mobile","ru"],"25033":["SEVTELECOM","ru"],"25034":["Krymtelecom","ru"],"25035":["Motiv","ru"],"25039":["Tele2","ru"],"25042":["MTT","ru"],"25044":["Stuvtelesot","ru"],"25047":["Next Mobile","ru"],"25048":["Global Telecom","ru"],"25050":["Sberbank","ru"],"25054":["Letai Mobile","ru"],"25055":["Glonass","ru"],"25057":["Matrix Mobile","ru"],"25060":["Volna Mobile","ru"],"25062":["Tinkoff","ru"],"25077":["Glonass","ru"],"25092":["Printelefone","ru"],"25093":["Telecom XXI","ru"],"25097":["Phoenix","ru"],"25099":["Билайн","ru"],"250999":["Fix Line","ru"],"25501":["Ukrainian Mobile Communication, UMC","ua"],"25502":["T-Mobile - UA","ua"],"25503":["Kyivstar GSM","ua"],"25504":["International Telecommunications Ltd.","ua"],"25505":["Golden Telecom","ua"],"25506":["Astelit","ua"],"25507":["Ukrtelecom","ua"],"25521":["CJSC - Telesystems of Ukraine","ua"],"25539":["Golden Telecom","ua"],"25550":["Vodafone","ua"],"25567":["KyivStar","ua"],"25568":["Kyivstar","ua"],"25599":["Phoenix","ua"],"25701":["A1 BY","by"],"25702":["MTS","by"],"25703":["BelCel JV","by"],"25704":["life:)","by"],"25901":["Orange Moldova GSM","md"],"25902":["Moldcell","md"],"25903":["Unite","md"],"25904":["Eventis Mobile GSM","md"],"25905":["Unité","md"],"25999":["Unite","md"],"26001":["Plus","pl"],"26002":["T-Mobile","pl"],"26003":["Orange","pl"],"26004":["Tele2 Polska (Tele2 Polska Sp. Z.o.o.)","pl"],"26005":["IDEA (UMTS)/PTK Centertel sp. Z.o.o.","pl"],"26006":["PLAY","pl"],"26007":["Premium internet","pl"],"26008":["E-Telko","pl"],"26009":["Telekomunikacja Kolejowa (GSM-R)","pl"],"26010":["Telefony Opalenickie","pl"],"26011":["NORDISK Polska","pl"],"26012":["Cyfrowy Polsat","pl"],"26013":["Move","pl"],"26014":["Move","pl"],"26015":["Aero2","pl"],"26016":["Aero2","pl"],"26017":["Aero2","pl"],"26018":["AMD Telecom","pl"],"26019":["NetBalt","pl"],"26020":["Tismi","pl"],"26022":["Twilio","pl"],"26027":["Ntel Solutions","pl"],"260299":["3S","pl"],"26032":["Compatel","pl"],"26034":["T-Mobile","pl"],"26035":["PKP","pl"],"26036":["Mundio Mobile Sp. z o.o.","pl"],"26038":["CallFreedom Sp. z o.o.","pl"],"26039":["Voxbone / Bandwidth","pl"],"26041":["EZ Mobile","pl"],"26042":["MobiWeb","pl"],"26044":["Rebtel","pl"],"26045":["Virgin Mobile","pl"],"26047":["SMSHIGHWAY","pl"],"26048":["Agile Telecom","pl"],"26049":["Messagebird","pl"],"26090":["Polska Spolka Gazownictwa","pl"],"26097":["Politechnika Lodzka Uczelniane","pl"],"26098":["Play","pl"],"260999":["Fix Line","pl"],"26201":["Telekom","de"],"26202":["Vodafone","de"],"26203":["O2","de"],"26204":["Vodafone","de"],"26205":["Telefonica / E-Plus","de"],"26206":["Telekom","de"],"26207":["O2","de"],"26208":["Telefonica / O2","de"],"26209":["Vodafone Lab","de"],"26210":["Arcor AG & Co.","de"],"26211":["O2","de"],"26212":["Dolphin Telecom (Deutschland) GmbH","de"],"26213":["Mobilcom Multimedia GmbH","de"],"26214":["Group 3G UMTS GmbH (Quam)","de"],"26215":["Airdata AG","de"],"26216":["Telefonica / O2","de"],"26217":["Telefonica / E-Plus","de"],"26220":["Voiceworks DE","de"],"26221":["Multiconnect","de"],"26222":["sipgate","de"],"26223":["1&1","de"],"26224":["TelcoVillage","de"],"262299":["1&1","de"],"26233":["sipgate","de"],"26242":["Vodafone","de"],"26243":["Lycamobile","de"],"26276":["Siemens AG, ICMNPGUSTA","de"],"26277":["Telefonica / E-Plus","de"],"26278":["Telekom / T-mobile","de"],"262999":["Fix Line","de"],"26601":["Gibtelecom GSM","gi"],"26606":["CTS Mobile","gi"],"26609":["Cloud9 Mobile Communications","gi"],"266299":["GibFibreSpeed","gi"],"266999":["Fix Line","gi"],"26801":["Vodafone","pt"],"26802":["Digi Portugal","pt"],"26803":["NOS","pt"],"26804":["Lycamobile","pt"],"26805":["Oniway - Inforcomunicaçôes, S.A.","pt"],"26806":["MEO","pt"],"26807":["NOS","pt"],"26808":["MEO","pt"],"268299":["NOWO","pt"],"26880":["MEO","pt"],"26891":["Vodafone","pt"],"26893":["NOS","pt"],"268999":["Fix Line","pt"],"27001":["P&T Luxembourg","lu"],"27002":["MTX","lu"],"27005":["Luxembourg Online","lu"],"27010":["Blue Communications","lu"],"270299":["Bouygues Telecom","lu"],"27077":["Tango","lu"],"27081":["e-LUX Mobile","lu"],"27099":["Orange","lu"],"270999":["Fix Line","lu"],"27201":["Vodafone","ie"],"27202":["3","ie"],"27203":["Meteor Mobile Communications Ltd.","ie"],"27204":["Access Telecom","ie"],"27205":["3","ie"],"27207":["Eircom","ie"],"27208":["Meteor / eir mobile","ie"],"27209":["Clever Communications Ltd.","ie"],"27211":["Tesco Mobile","ie"],"27213":["Lycamobile","ie"],"27215":["Virgin Media","ie"],"27217":["3","ie"],"27225":["Sky IE","ie"],"27401":["Iceland Telecom Ltd.","is"],"27402":["Tal hf","is"],"27403":["Islandssimi GSM ehf","is"],"27404":["IMC Islande ehf","is"],"27405":["Vodafone","is"],"27407":["IceCell ehf","is"],"27408":["Siminn","is"],"27409":["Amitelo","is"],"27411":["Nova","is"],"27412":["Vodafone","is"],"27416":["Tismi","is"],"27431":["Siminn","is"],"27601":["One / AMC","al"],"27602":["Vodafone","al"],"27603":["Eagle Mobile","al"],"27604":["PLUS Communication Sh.a","al"],"27801":["Epic","mt"],"27821":["go mobile","mt"],"27830":["GO Mobile","mt"],"27877":["Melita","mt"],"278999":["Fix Line","mt"],"28001":["CYTA","cy"],"28002":["Cytamobile-Vodafone","cy"],"28010":["epic","cy"],"28020":["PrimeTel","cy"],"28022":["Cablenet","cy"],"280999":["Fix Line","cy"],"28201":["Geocell Ltd.","ge"],"28202":["Magti GSM Ltd.","ge"],"28203":["Iberiatel Ltd.","ge"],"28204":["Mobitel Ltd.","ge"],"28205":["Silknet","ge"],"28207":["GlobalCell","ge"],"28208":["Silknet","ge"],"28210":["Premium Net","ge"],"28211":["Mobilive","ge"],"28212":["Telecom 1","ge"],"28222":["MyPhone","ge"],"28301":["ArmenTel","am"],"28304":["Karabakh Telecom","am"],"28305":["K Telecom CJSC","am"],"28310":["Orange","am"],"28401":["A1","bg"],"28403":["VIVACOM","bg"],"28405":["Yettel","bg"],"28406":["Vivacom","bg"],"28411":["bulsatcom","bg"],"28413":["MAX TELECOM","bg"],"28601":["Paycell | Turkcell","tr"],"28602":["Vodafone","tr"],"28603":["Türk Telekom","tr"],"28604":["Türk Telekom","tr"],"286299":["Asistan Telekom","tr"],"286999":["Fix Line","tr"],"28801":["Faroese Telecom - GSM","fo"],"28802":["Kall GSM","fo"],"28803":["Tosa","fo"],"28967":["Aquafon","ge"],"28968":["A-Mobile","ge"],"28988":["A-Mobile","ge"],"29001":["Tele Greenland","gl"],"29201":["SMT - San Marino Telecom","sm"],"292299":["TeleneT","sm"],"29310":["Slovenske zeleznice","si"],"29320":["Compatel","si"],"293299":["HOT mobil","si"],"29340":["SI Mobil","si"],"29341":["Telekom Slovenije","si"],"29364":["T-2 d.o.o.","si"],"29370":["Telemach","si"],"29386":["Elektro Gorenjska","si"],"293999":["Fix Line","si"],"29401":["Mkedonski Telecom AD Skopje","mk"],"29402":["Cosmofon","mk"],"29403":["Nov Operator","mk"],"29404":["Lycamobile","mk"],"29411":["Mobik","mk"],"294299":["Failed Calls","mk"],"29475":["A1","mk"],"29501":["Telecom FL AG","li"],"29502":["Viag Europlatform AG","li"],"29505":["Mobilkom (Liechstein) AG","li"],"29506":["CUBIC","li"],"29507":["First Mobile AG","li"],"29509":["EMnify","li"],"295299":["Datamobile","li"],"29577":["Tele2 AG","li"],"29701":["ONE","me"],"29702":["Crnogorski Telekom","me"],"29703":["MTEL d.o.o. Podgorica","me"],"302130":["Xplornet","ca"],"302131":["Xplornet","ca"],"302220":["Telus Mobility","ca"],"302270":["EastLink","ca"],"302290":["Airtel Wireless","ca"],"302320":["Chatr Mobile","ca"],"30236":["Clearnet","ca"],"302360":["Clearnet","ca"],"302361":["Clearnet","ca"],"302370":["FIDO (Rogers AT&T/ Microcell)","ca"],"302380":["DMTS Mobility","ca"],"302490":["Freedom Mobile","ca"],"302500":["Videotron","ca"],"302510":["Videotron","ca"],"302520":["Videotron","ca"],"302610":["Bell Mobility","ca"],"30262":["Ice Wireless","ca"],"30263":["Aliant Mobility","ca"],"302630":["Bell Mobility","ca"],"30264":["Bell Mobility","ca"],"302640":["Bell Mobility","ca"],"302651":["Bell Mobility","ca"],"302652":["BC Tel Mobility","ca"],"302653":["Telus Mobility","ca"],"302654":["Sask Tel Mobility","ca"],"302655":["MTS Mobility","ca"],"302656":["Tbay Mobility","ca"],"302657":["Quebectel Mobility","ca"],"302660":["MTS Mobility","ca"],"30267":["CityTel Mobility","ca"],"302670":["CityWest Mobility","ca"],"30268":["Sask Tel Mobility","ca"],"302680":["Sask Tel Mobility","ca"],"302681":["Sask Tel Mobility","ca"],"302701":["NB Tel Mobility","ca"],"302702":["MT&T Mobility","ca"],"302703":["New Tel Mobility","ca"],"30271":["Globalstar","ca"],"302710":["Globalstar Canada","ca"],"30272":["Rogers","ca"],"302720":["Rogers","ca"],"302760":["Public Mobile","ca"],"302780":["Sask Tel Mobility","ca"],"302781":["Sask Tel Mobility","ca"],"30801":["St. Pierre-et-Miquelon Télécom","pm"],"30808":["St. Pierre-et-Miquelon Télécom","pm"],"310003":["Unknown","us"],"310004":["Verizon Wireless","us"],"310010":["MCI","us"],"310011":["Northstar","us"],"310012":["Verizon Wireless","us"],"310013":["Mobile Tel Inc.","us"],"310014":["Testing US","us"],"310016":["Leap Wireless International Inc.","us"],"310017":["North Sight Communications Inc.","us"],"310020":["Union Telephone Company","us"],"310023":["C Spire","us"],"310026":["T-Mobile - US","us"],"310028":["ALU Test-SIM","us"],"310030":["AT&T","us"],"310032":["IT&E OverSeas","gu"],"310033":["Guam Teleph. Auth","gu"],"310034":["Nevada Wireless LLC","us"],"310040":["MTA Communications dba MTA Wireless","us"],"310050":["ACS Wireless Inc.","us"],"31006":["Consolidated Telcom","us"],"310060":["Consolidated Telcom","us"],"310070":["AT&T","us"],"310080":["Corr Wireless Communications LLC","us"],"310090":["Edge Wireless LLC","us"],"310100":["New Mexico RSA 4 East Ltd. Partnership","us"],"310110":["Pacific Telecom Inc","us"],"310120":["Sprint","us"],"310130":["Carolina West Wireless","us"],"31014":["Testing","us"],"310140":["GTA Wireless LLC","us"],"31015":["Unknown","us"],"310150":["Cricket Wireless","us"],"310160":["T-Mobile - US","us"],"310170":["AT&T","us"],"310180":["West Central Wireless","us"],"310190":["Alaska Wireless Communications LLC","us"],"310200":["T-Mobile - US","us"],"310210":["T-Mobile - US","us"],"310220":["T-Mobile - US","us"],"31023":["Unknown","us"],"310230":["T-Mobile - US","us"],"31024":["Unknown","us"],"310240":["T-Mobile - US","us"],"31025":["Unknown","us"],"310250":["T-Mobile - US","us"],"31026":["T-Mobile - US","us"],"310260":["T-Mobile - US","us"],"310270":["T-Mobile - US","us"],"310280":["AT&T","us"],"310290":["Nep Cellcorp Inc.","us"],"310300":["T-Mobile - US","us"],"31031":["T-Mobile","us"],"310310":["T-Mobile - US","us"],"310320":["Smith Bagley Inc, dba Cellular One","us"],"310330":["AN Subsidiary LLC","us"],"31034":["Nevada Wireless LLC","us"],"310340":["High Plains Midwest LLC, dba Wetlink Communications","us"],"310350":["Mohave Cellular L.P.","us"],"310360":["Cellular Network Partnership dba Pioneer Cellular","us"],"310370":["Guamcell Cellular and Paging","us"],"31038":["USA 3650 AT&T","us"],"310380":["AT&T","us"],"310390":["TX-11 Acquistion LLC","us"],"310400":["Wave Runner LLC","us"],"310410":["AT&T","us"],"310420":["Cincinnati Bell Wireless LLC","us"],"310430":["Alaska Digitel LLC","us"],"310440":["Numerex Corp.","us"],"310450":["North East Cellular Inc.","us"],"31046":["SIMMETRY","us"],"310460":["TMP Corporation","us"],"310470":["nTelos","us"],"310480":["Choice Phone LLC","us"],"310490":["T-Mobile - US","us"],"310500":["Public Service Cellular, Inc.","us"],"310510":["Airtel Wireless LLC","us"],"310520":["VeriSign","us"],"310530":["T-Mobile - US","us"],"310540":["Oklahoma Western Telephone Company","us"],"310550":["Wireless Solutions International","us"],"310560":["AT&T","us"],"310570":["MTPCS LLC","us"],"310580":["Inland Cellular","us"],"310590":["Verizon Wireless","us"],"310591":["Verizon Wireless","us"],"310592":["Verizon Wireless","us"],"310593":["Verizon Wireless","us"],"310594":["Verizon Wireless","us"],"310595":["Verizon Wireless","us"],"310596":["Verizon Wireless","us"],"310597":["Verizon Wireless","us"],"310598":["Verizon Wireless","us"],"310599":["Verizon Wireless","us"],"31060":["Consolidated Telcom","us"],"310600":["New-Cell Inc.","us"],"310610":["Elkhart Telephone Co. Inc. dba Epic Touch Co.","us"],"310620":["Coleman County Telecommunications Inc. (Trans Texas PCS)","us"],"310640":["T-Mobile - US","us"],"310650":["Jasper Wireless Inc.","us"],"310660":["T-Mobile - US","us"],"310670":["AT&T Mobility Vanguard Services","us"],"310680":["AT&T","us"],"310690":["Limitless Mobile","us"],"310700":["Cross Valiant Cellular Partnership","us"],"310710":["Arctic Slopo Telephone Association Cooperative","us"],"310720":["Wireless Solutions International Inc.","us"],"310730":["Sea Mobile","us"],"310740":["Telemetrix Inc.","us"],"310750":["East Kentucky Network LLC dba Appalachian Wireless","us"],"310760":["Panhandle Telecommunications Systems Inc.","us"],"310770":["Iowa Wireless Services LLC dba I Wireless","us"],"310780":["Connect Net Inc","us"],"310790":["PinPoint Communications Inc.","us"],"310800":["T-Mobile - US","us"],"310810":["Brazos Cellular Communications Ltd.","us"],"310820":["South Canaan Cellular Communications Co. LP","us"],"310830":["Caprock Cellular Ltd. Partnership","us"],"310840":["Edge Mobile LLC","us"],"310850":["Aeris Communications, Inc.","us"],"310860":["TX RSA 15B2, LP dba Five Star Wireless","us"],"310870":["Kaplan Telephone Company Inc.","us"],"310880":["Advantage Cellular Systems, Inc.","us"],"310890":["Verizon Wireless","us"],"310900":["Mid-Rivers","us"],"310910":["Southern IL RSA Partnership dba First Cellular of Southern Illinois","us"],"310920":["James Valley","us"],"310930":["Copper Valley Wireless","us"],"310940":["Poka Lambro Telco Ltd.","us"],"310950":["AT&T","us"],"310960":["UBET Wireless","us"],"310970":["Globalstar USA","us"],"310980":["AT&T Wireless Inc.","us"],"310990":["Evolve","us"],"310995":["Android Emulator","us"],"310999":["Various Networks","us"],"311000":["Mid-Tex Cellular Ltd.","us"],"311010":["Chariton Valley Communications Corp., Inc.","us"],"311020":["Missouri RSA No. 5 Partnership","us"],"311030":["Indigo Wireless, Inc.","us"],"311040":["Commet Wireless, LLC","us"],"311050":["Thumb Cellular Limited Partnership","us"],"311060":["Space Data Corporation","us"],"311070":["Easterbrooke Cellular Corporation","us"],"311080":["Pine Telephone Company dba Pine Cellular","us"],"311090":["Siouxland PCS","us"],"311100":["NexTech Wireless","us"],"311110":["Alltel Communications Inc.","us"],"311120":["Choice Phone LLC","us"],"311140":["MBO Wireless Inc./Cross Telephone Company","us"],"311150":["Wilkes Cellular Inc.","us"],"311170":["PetroCom LLC","us"],"311180":["AT&T","us"],"311190":["Cellular Properties Inc.","us"],"311200":["ARINC","us"],"311210":["Farmers Cellular Telephone","us"],"311220":["U.S. Cellular","us"],"311221":["U.S. Cellular","us"],"311222":["U.S. Cellular","us"],"311223":["U.S. Cellular","us"],"311224":["U.S. Cellular","us"],"311225":["U.S. Cellular","us"],"311226":["U.S. Cellular","us"],"311227":["U.S. Cellular","us"],"311228":["U.S. Cellular","us"],"311229":["U.S. Cellular","us"],"311230":["C Spire","us"],"311240":["Cordova Wireless Communications Inc","us"],"311250":["Wave Runner LLC","us"],"311260":["SLO Cellular Inc. dba CellularOne of San Luis Obispo","us"],"311270":["Verizon Wireless","us"],"311271":["Alltel Communications Inc.","us"],"311272":["Alltel Communications Inc.","us"],"311273":["Alltel Communications Inc.","us"],"311274":["Alltel Communications Inc.","us"],"311275":["Alltel Communications Inc.","us"],"311276":["Alltel Communications Inc.","us"],"311277":["Alltel Communications Inc.","us"],"311278":["Alltel Communications Inc.","us"],"311279":["Alltel Communications Inc.","us"],"311280":["Verizon Wireless","us"],"311281":["Verizon Wireless","us"],"311282":["Verizon Wireless","us"],"311283":["Verizon Wireless","us"],"311284":["Verizon Wireless","us"],"311285":["Verizon Wireless","us"],"311286":["Verizon Wireless","us"],"311287":["Verizon Wireless","us"],"311288":["Verizon Wireless","us"],"311289":["Verizon Wireless","us"],"311290":["Pinpoint Wireless Inc.","us"],"311300":["Rutal Cellular Corporation","us"],"311310":["Leaco Rural Telephone Company Inc","us"],"311311":["Farmers","us"],"311320":["Commnet Wireless LLC","us"],"311330":["Bag Tussel Wireless LLC","us"],"311340":["Illinois Valley Cellular","us"],"311350":["Torrestar Networks Inc","us"],"311360":["Stelera Wireless LLC","us"],"311370":["GCI Communications Corp.","us"],"311380":["GreenFly LLC","us"],"311390":["Midwest Wireless Holdings LLC","us"],"311400":["Testing US","us"],"311410":["Iowa RSA No.2 Ltd Partnership","us"],"311420":["northwestcell","us"],"311430":["Chat Mobility","us"],"311440":["Bluegrass Cellular LLC","us"],"311450":["PTCI","us"],"311460":["Fisher Wireless Services Inc","us"],"311470":["Vitelcom Cellular Inc dba Innovative Wireless","us"],"311480":["Verizon Wireless","us"],"311481":["Verizon Wireless","us"],"311482":["Verizon Wireless","us"],"311483":["Verizon Wireless","us"],"311484":["Verizon Wireless","us"],"311485":["Verizon Wireless","us"],"311486":["Verizon Wireless","us"],"311487":["Verizon Wireless","us"],"311488":["Verizon Wireless","us"],"311489":["Verizon Wireless","us"],"311490":["T-Mobile - US","us"],"311500":["CTC Telecom Inc","us"],"311510":["Benton-Lian Wireless","us"],"311520":["Crossroads Wireless Inc","us"],"311530":["Wireless Communications Venture","us"],"311540":["Keystone Wireless Inc","us"],"311550":["Commnet Midwest LLC","us"],"311580":["U.S. Cellular","us"],"311581":["U.S. Cellular","us"],"311582":["U.S. Cellular","us"],"311583":["U.S. Cellular","us"],"311584":["U.S. Cellular","us"],"311585":["U.S. Cellular","us"],"311586":["U.S. Cellular","us"],"311587":["U.S. Cellular","us"],"311588":["U.S. Cellular","us"],"311589":["U.S. Cellular","us"],"311590":["California RSA No. 3 Limited Partnership","us"],"311600":["COX","us"],"311610":["North Dakota Network Company","us"],"311650":["United Wireless Communications Inc.","us"],"311660":["T-Mobile - Private 5G","us"],"311670":["Pine Belt Cellular, Inc.","us"],"311710":["Northeast Wireless Networks LLC","us"],"311740":["TelAlaska Cellular","us"],"311750":["Cleartalk","us"],"311780":["ASTCA","us"],"311800":["Bluegrass Wireless LLC","us"],"311810":["Bluegrass Wireless LLC","us"],"311830":["Thumb Cellular Limited Partnership","us"],"311860":["Uintah Basin Electronics Telecommunications Inc.","us"],"311870":["Boost","us"],"311880":["Sprint Spectrum","us"],"311882":["T-Mobile - US","us"],"311910":["MobileNation","us"],"311920":["Missouri RSA No 5 Partnership","us"],"311930":["Syringa","us"],"312010":["Missouri RSA No 5 Partnership","us"],"312030":["Cross Wireless Telephone Co.","us"],"312040":["Custer Telephone Cooperative Inc.","us"],"312090":["Allied Wireless Communications Corporation","us"],"312120":["East Kentucky Network LLC","us"],"312130":["East Kentucky Network LLC","us"],"312160":["Chat Mobility","us"],"312170":["Iowa RSA No. 2 Limited Partnership","us"],"312180":["Keystone Wireless LLC","us"],"312190":["Sprint Spectrum","us"],"312220":["Missouri RSA No 5 Partnership","us"],"312230":["North Dakota Network Company","us"],"312250":["T-Mobile - US","us"],"312270":["Cellular Network Partnership LLC","us"],"312280":["Cellular Network Partnership LLC","us"],"312290":["strata","us"],"312380":["Copper Valley Wireless","us"],"312420":["NexTech Ota","us"],"312530":["Sprint","us"],"312570":["Blue Wireless","us"],"312580":["Google CBRS","us"],"312670":["FirstNet (Lab)","us"],"312870":["GigSky","us"],"313100":["FirstNet","us"],"313110":["FirstNet","us"],"313120":["FirstNet","us"],"313130":["FirstNet","us"],"313140":["FirstNet","us"],"313380":["OptimERA Wireless","us"],"313390":["Optimum","us"],"313450":["Spectrum Mobile","us"],"313460":["Mobi","us"],"313770":["TANGO","us"],"313790":["Liberty Mobile","us"],"314020":["Spectrum+","us"],"314200":["Xfinity MSO","us"],"314240":["Xfinity Mobile 2.0","us"],"314420":["Cox MSO","us"],"314720":["OXIO","us"],"314730":["TextNow Wireless","us"],"315010":["CBRS","us"],"316010":["Nextel Communications Inc.","us"],"316011":["Southern Communications Services Inc.","us"],"33000":["Open Mobile","pr"],"33011":["Claro PR","pr"],"330110":["Claro PR","pr"],"33401":["AT&T MX","mx"],"334010":["NEXTEL","mx"],"33402":["Telcel","mx"],"334020":["Telcel","mx"],"33403":["Movistar","mx"],"334030":["Movistar","mx"],"33404":["AT&T/IUSACell","mx"],"334040":["AT&T MX","mx"],"33405":["AT&T/IUSACell","mx"],"334050":["AT&T MX","mx"],"334060":["SAI PCS","mx"],"334070":["AT&T MX","mx"],"334080":["AT&T MX","mx"],"33409":["AT&T MX","mx"],"334090":["AT&T MX","mx"],"334130":["Alestra Servicios Moviles","mx"],"334140":["ALTAN - Internal Use","mx"],"334170":["OXIO","mx"],"33450":["AT&T/IUSACell","mx"],"338020":["Cable & Wireless Jamaica Ltd.","jm"],"33805":["Mossel (Jamaica) Ltd.","jm"],"338050":["Mossel (Jamaica) Ltd.","jm"],"338070":["Claro","jm"],"338110":["Cable & Wireless","jm"],"33818":["Cable & Wireless","jm"],"338180":["Cable & Wireless","jm"],"34001":["Orange Caraïbe Mobiles","gf"],"34002":["Outremer Telecom","gf"],"34003":["Saint Martin et Saint Barthelemy Telcell Sarl","gf"],"34008":["Dauphin Telecom SU (Guadeloupe Telecom)","gp"],"34011":["TelCell GSM","gf"],"34012":["UTS Caraibe","mq"],"34020":["Digicel","gf"],"34080":["Dauphin Telecom","gf"],"342050":["Digicel","bb"],"342299":["Failed Calls","bb"],"342600":["Cable & Wireless (Barbados) Ltd.","bb"],"342750":["Digicel","bb"],"342810":["Cingular Wireless","bb"],"342820":["Sunbeach Communications","bb"],"34403":["APUA PCS","ag"],"344030":["imobile / APUA","ag"],"34492":["Flow","ag"],"344920":["Cable & Wireless (Antigua)","ag"],"344921":["FLOW","ag"],"34493":["Digicel","ag"],"344930":["AT&T Wireless (Antigua)","ag"],"346001":["Logic","ky"],"346006":["Digicel Ltd.","ky"],"346050":["Digicel","ky"],"346140":["Cable & Wireless (Cayman)","ky"],"348170":["Cable & Wireless","vg"],"348570":["Caribbean Cellular Telephone, Boatphone Ltd.","vg"],"34877":["Digicel","vg"],"348770":["Digicel","vg"],"350000":["Bermuda Digital Communications Ltd (BDC)","bm"],"350007":["Paradise Mobile","bm"],"35001":["Digicel","bm"],"35002":["M3 Wireless Ltd","bm"],"350299":["Failed Calls","bm"],"35099":["CellOne Ltd","bm"],"352030":["Digicel","gd"],"352050":["Digicel","gd"],"352110":["Grenada:Lime","gd"],"354860":["Cable & Wireless","ms"],"356110":["FLOW","kn"],"35650":["Digicel","kn"],"35670":["UTS Cariglobe","kn"],"358110":["Cable & Wireless","lc"],"35830":["Cingular Wireless","lc"],"35850":["Digicel (St Lucia) Limited","lc"],"360050":["Digicel","vc"],"36010":["Cingular","vc"],"360100":["Cingular","vc"],"360110":["Cable & Wireless (St. Vincent & the Grenadines) Ltd","vc"],"36070":["Digicel","vc"],"36251":["TELCELL GSM","an"],"362630":["Cingular Wireless","an"],"36269":["CT GSM","cw"],"36291":["SETEL GSM","an"],"36295":["EOCG Wireless NV","cw"],"362951":["UTS Wireless","an"],"362999":["Fix Line","bq"],"36301":["SETAR","aw"],"36302":["Digicel","aw"],"363020":["Digicel","aw"],"36320":["Digicel","aw"],"363299":["MIO","aw"],"36403":["Smart Communications","bs"],"364039":["BTC","bs"],"36430":["Cybercell / BaTelCo","bs"],"36439":["Cybercell / BaTelCo","bs"],"364390":["Bahamas Telecommunications","bs"],"36449":["ALIV BS","bs"],"364490":["Aliv","bs"],"365010":["Weblinks Limited","ai"],"365840":["Cable & Wireless","ai"],"365850":["Digicel","ai"],"366020":["Cingular Wireless/Digicel","dm"],"366050":["Wireless Ventures (Dominica) Ltd (Digicel Dominica)","dm"],"366110":["Cable & Wireless","dm"],"36801":["ETECSA","cu"],"368999":["Fix Line","cu"],"37001":["Altice Dominicana","do"],"37002":["Claro RD","do"],"370020":["Claro RD","do"],"37003":["Tricom S.A.","do"],"37004":["CentennialDominicana","do"],"37005":["Wind Telecom","do"],"37201":["Comcel","ht"],"37202":["Digicel","ht"],"37203":["Rectel","ht"],"37412":["TSTT Mobile","tt"],"374120":["Bmobile/TSTT","tt"],"374122":["TSTT Mobile","tt"],"374123":["TSTT Mobile","tt"],"374124":["TSTT Mobile","tt"],"374125":["TSTT Mobile","tt"],"374126":["TSTT Mobile","tt"],"374127":["TSTT Mobile","tt"],"374128":["TSTT Mobile","tt"],"374129":["TSTT Mobile","tt"],"37413":["Digicel Trinidad and Tobago Ltd.","tt"],"374130":["Digicel Trinidad and Tobago Ltd.","tt"],"374140":["LaqTel Ltd.","tt"],"376050":["Digicel TCI Ltd","tc"],"376350":["Cable & Wireless West Indies Ltd (Turks & Caicos)","tc"],"376352":["IslandCom Communications Ltd.","tc"],"37650":["Digicel","vi"],"40001":["Azercell Limited Liability Joint Venture","az"],"40002":["Bakcell Limited Liabil ity Company","az"],"40003":["Catel JV","az"],"40004":["Azerphone LLC","az"],"40006":["Naxtel","az"],"40101":["Beeline","kz"],"40102":["Kcell/activ","kz"],"40107":["Tele2/Altel","kz"],"40177":["Tele2/Altel","kz"],"40211":["Bhutan Telecom Ltd","bt"],"40217":["B-Mobile of Bhutan Telecom","bt"],"40277":["TashiCell","bt"],"40401":["Vi","in"],"40402":["Airtel","in"],"40403":["Airtel","in"],"40404":["Vi","in"],"404045":["Bharti Airtel Limited (Karnataka) (India)","in"],"40405":["Vi","in"],"40407":["Vi","in"],"40409":["Reliance","in"],"40410":["Airtel","in"],"40411":["Vi","in"],"40412":["Vi","in"],"40413":["Vi","in"],"40414":["Vi","in"],"40415":["Vi","in"],"40416":["Airtel","in"],"40417":["Aircel","in"],"40418":["Reliance","in"],"40419":["Vi","in"],"40420":["Vi","in"],"40421":["BPL Mobile Communications Ltd.","in"],"40422":["Vi","in"],"40424":["Vi","in"],"40425":["Aircel Ltd.","in"],"40427":["Vi","in"],"40428":["Aircel Ltd.","in"],"40429":["Aircel Ltd.","in"],"40430":["Vi","in"],"40431":["Airtel","in"],"40433":["Aircel","in"],"40434":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40436":["Reliance","in"],"40437":["Aircel Ltd.","in"],"40438":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40439":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40440":["Airtel","in"],"40441":["RPG Cellular","in"],"40442":["Aircel Ltd.","in"],"40443":["Vi","in"],"40444":["Vi","in"],"40445":["Airtel","in"],"40446":["Vi","in"],"40448":["Dishnet Wireless","in"],"40449":["Airtel","in"],"40450":["Reliance","in"],"40451":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40452":["Reliance","in"],"40453":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40454":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40455":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40456":["Vi","in"],"40457":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40458":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40459":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40460":["Vi","in"],"40462":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40464":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40465":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40466":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40467":["Reliance","in"],"40468":["Mahanagar Telephone Nigam Ltd.","in"],"40469":["Mahanagar Telephone Nigam Ltd.","in"],"40470":["Airtel","in"],"40471":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40472":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40473":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40474":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40475":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40476":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40477":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40478":["Vi","in"],"40479":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40480":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40481":["Bharat Sanchar Nigam Ltd. (BSNL)","in"],"40482":["Vi","in"],"40483":["Reliable Internet Services Ltd.","in"],"40484":["Vi","in"],"40485":["Reliance","in"],"40486":["Vi","in"],"40487":["Vi","in"],"40488":["Vi","in"],"40489":["Vi","in"],"40490":["Airtel","in"],"40491":["Aircel Ltd.","in"],"40492":["Airtel","in"],"40493":["Airtel","in"],"40494":["Airtel","in"],"40495":["Airtel","in"],"40496":["Airtel","in"],"40497":["Airtel","in"],"40498":["Airtel","in"],"404998":["Fix Line","in"],"404999":["Various Networks","in"],"40501":["Reliance","in"],"405025":["TATA DOCOMO","in"],"405026":["TATA DOCOMO","in"],"405027":["TATA DOCOMO","in"],"405028":["TATA DOCOMO","in"],"405029":["TATA DOCOMO","in"],"40503":["Reliance","in"],"405030":["TATA DOCOMO","in"],"405031":["TATA DOCOMO","in"],"405032":["TATA DOCOMO","in"],"405033":["TATA DOCOMO","in"],"405034":["TATA DOCOMO","in"],"405035":["TATA DOCOMO","in"],"405036":["TATA DOCOMO","in"],"405037":["TATA DOCOMO","in"],"405038":["TATA DOCOMO","in"],"405039":["TATA DOCOMO","in"],"40504":["Reliance","in"],"405040":["TATA DOCOMO","in"],"405041":["TATA DOCOMO","in"],"405042":["TATA DOCOMO","in"],"405043":["TATA DOCOMO","in"],"405044":["TATA DOCOMO","in"],"405045":["TATA DOCOMO","in"],"405046":["TATA DOCOMO","in"],"405047":["TATA DOCOMO","in"],"40505":["Reliance","in"],"40506":["Reliance","in"],"40507":["Reliance","in"],"40508":["Reliance","in"],"40509":["Reliance","in"],"40510":["Reliance","in"],"40511":["Reliance","in"],"40512":["Reliance","in"],"40513":["Reliance","in"],"40514":["Reliance","in"],"40515":["Reliance","in"],"40517":["Reliance","in"],"40518":["Reliance","in"],"40519":["Reliance","in"],"40520":["Reliance","in"],"40521":["Reliance","in"],"40522":["Reliance","in"],"40523":["Reliance","in"],"40545":["Vi","in"],"40551":["Airtel","in"],"40552":["Airtel","in"],"40553":["Airtel","in"],"40554":["Airtel","in"],"40555":["Airtel","in"],"40556":["Airtel","in"],"40566":["Vi","in"],"40567":["Vi","in"],"40570":["Vi","in"],"405750":["Vi","in"],"405751":["Vi","in"],"405752":["Vi","in"],"405753":["Vi","in"],"405754":["Vi","in"],"405755":["Vi","in"],"405756":["Vi","in"],"405799":["Vi","in"],"405800":["Aircel Ltd.","in"],"405801":["Aircel Ltd.","in"],"405802":["Aircel Ltd.","in"],"405803":["Aircel Ltd.","in"],"405804":["Aircel Ltd.","in"],"405805":["Aircel Ltd.","in"],"405806":["Aircel Ltd.","in"],"405807":["Aircel Ltd.","in"],"405808":["Aircel Ltd.","in"],"405809":["Aircel Ltd.","in"],"405810":["Aircel Ltd.","in"],"405811":["Aircel Ltd.","in"],"405812":["Aircel Ltd.","in"],"405813":["Uninor","in"],"405814":["Uninor","in"],"405815":["Uninor","in"],"405816":["Uninor","in"],"405817":["Uninor","in"],"405818":["Uninor","in"],"405819":["Uninor","in"],"405820":["Uninor","in"],"405821":["Uninor","in"],"405822":["Uninor","in"],"405823":["Videocon","in"],"405824":["Videocon","in"],"405825":["Videocon","in"],"405826":["Videocon","in"],"405827":["Videocon","in"],"405828":["Videocon","in"],"405829":["Videocon","in"],"405830":["Videocon","in"],"405832":["Videocon","in"],"405833":["Videocon","in"],"405834":["Videocon","in"],"405835":["Videocon","in"],"405836":["Videocon","in"],"405837":["Videocon","in"],"405838":["Videocon","in"],"405840":["Reliance Jio","in"],"405841":["Videocon","in"],"405842":["Videocon","in"],"405843":["Videocon","in"],"405844":["Uninor","in"],"405845":["Vi","in"],"405846":["Vi","in"],"405847":["Vi","in"],"405848":["Vi","in"],"405849":["Vi","in"],"405850":["Vi","in"],"405851":["Vi","in"],"405852":["Vi","in"],"405853":["Vi","in"],"405854":["Reliance Jio","in"],"405855":["Reliance Jio","in"],"405856":["Reliance Jio","in"],"405857":["Reliance Jio","in"],"405858":["Reliance Jio","in"],"405859":["Reliance Jio","in"],"405860":["Reliance Jio","in"],"405861":["Reliance Jio","in"],"405862":["Reliance Jio","in"],"405863":["Reliance Jio","in"],"405864":["Reliance Jio","in"],"405865":["Reliance Jio","in"],"405866":["Reliance Jio","in"],"405867":["Reliance Jio","in"],"405868":["Reliance Jio","in"],"405869":["Reliance Jio","in"],"40587":["Reliance Telecom Private","in"],"405870":["Reliance Jio","in"],"405871":["Reliance Jio","in"],"405872":["Reliance Jio","in"],"405873":["Reliance Jio","in"],"405874":["Reliance Jio","in"],"405875":["Uninor","in"],"405876":["Uninor","in"],"405877":["Uninor","in"],"405878":["Uninor","in"],"405879":["Uninor","in"],"405880":["Uninor","in"],"405881":["STEL","in"],"405882":["STEL","in"],"405883":["STEL","in"],"405884":["STEL","in"],"405885":["STEL","in"],"405886":["STEL","in"],"405908":["Vi","in"],"405909":["Vi","in"],"405910":["Vi","in"],"405911":["Vi","in"],"405912":["Cheers","in"],"405913":["Cheers","in"],"405914":["Cheers","in"],"405915":["Cheers","in"],"405916":["Cheers","in"],"405917":["Cheers","in"],"405918":["Cheers","in"],"405919":["Cheers","in"],"405920":["Cheers","in"],"405921":["Cheers","in"],"405922":["Cheers","in"],"405923":["Cheers","in"],"405925":["Uninor","in"],"405926":["Uninor","in"],"405927":["Uninor","in"],"405928":["Uninor","in"],"405929":["Uninor","in"],"405930":["Cheers","in"],"405932":["Videocon","in"],"41001":["Jazz","pk"],"41003":["PAK Telecom Mobile Ltd. (UFONE)","pk"],"41004":["Zong","pk"],"41005":["SCOM","pk"],"41006":["Telenor","pk"],"41007":["Jazz","pk"],"41008":["Instaphone","pk"],"410299":["Failed Calls","pk"],"41201":["AWCC","af"],"41203":["WaselTelecom (WT)","af"],"41220":["Roshan","af"],"41230":["New1","af"],"41240":["Areeba Afghanistan","af"],"41250":["Etisalat","af"],"41280":["Mobifone","af"],"41288":["Afghan Telecom","af"],"41301":["Sri Lanka Telecom Mobitel","lk"],"41302":["Dialog Sri Lanka","lk"],"41303":["Celtel Lanka Ltd.","lk"],"41305":["Airtel Lanka","lk"],"41308":["Hutchison Telecommunications Lanka","lk"],"41401":["Myanmar Post and Telecommunication","mm"],"41405":["Ooredoo Myanmar","mm"],"41406":["Telenor","mm"],"41409":["Mytel","mm"],"414999":["Fix Line (Myanmar","mm"],"41501":["Alfa","lb"],"41503":["MTC Touch","lb"],"41505":["Ogero Mobile","lb"],"41515":["Connect","lb"],"41532":["Cellis","lb"],"41533":["Cellis","lb"],"41534":["Cellis","lb"],"41535":["Cellis","lb"],"41536":["Libancell","lb"],"41537":["Libancell","lb"],"41538":["Libancell","lb"],"41539":["Libancell","lb"],"41601":["Fastlink","jo"],"41602":["Xpress","jo"],"41603":["Umniah","jo"],"41677":["Orange Jordan","jo"],"416770":["Orange Jordan","jo"],"416999":["Fix Line","jo"],"41701":["Syriatel","sy"],"41702":["Spacetel Syria","sy"],"41709":["Syrian Telecom","sy"],"41750":["Rcell","sy"],"41805":["Asiacell","iq"],"41808":["SanaTel","iq"],"41820":["Zain Iraq","iq"],"41830":["Zain Iraq","iq"],"41840":["Korek","iq"],"41845":["Mobitel","iq"],"41862":["Itisaluna","iq"],"41866":["Fastlink","iq"],"41877":["SevenNet Layers","iq"],"41882":["Korek","iq"],"41892":["Omnnea","iq"],"41902":["Zain","kw"],"41903":["Ooredoo","kw"],"41904":["STC","kw"],"419999":["Fix Line","kw"],"42001":["STC","sa"],"42003":["Mobily","sa"],"42004":["Zain Saudi Arabia","sa"],"42005":["Virgin","sa"],"42006":["Lebara Mobile","sa"],"42007":["Zain","sa"],"42101":["SabaFon","ye"],"42102":["Spacetel Yemen","ye"],"42103":["YemenMobile","ye"],"42104":["HiTS-UNITEL","ye"],"42111":["YemenMobile","ye"],"42122":["YemenMobile","ye"],"421999":["Fix Line","ye"],"42202":["Omantel","om"],"42203":["Ooredoo","om"],"42204":["Omantel","om"],"42206":["Vodafone Oman","om"],"42402":["e& UAE","ae"],"42403":["du","ae"],"42501":["Partner Communications Co. Ltd.","il"],"42502":["Cellcom Israel Ltd.","il"],"42503":["Pelephone Communications Ltd.","il"],"42505":["Jawwal","ps"],"42506":["Ooredoo","ps"],"42507":["Hot Mobile","il"],"42508":["Golan Telecom","il"],"42509":["We4G","il"],"42510":["Partner Communications Co. Ltd.","il"],"42512":["Pelephone","il"],"42513":["Ituran","il"],"42514":["Alon Cellular Ltd","il"],"42515":["Home Cellular","il"],"42516":["Rami Levy","il"],"42517":["Von waves","il"],"42519":["019 Mobile","il"],"42522":["Maskyoo","il"],"42523":["Beezz","il"],"42526":["Annatel","il"],"425299":["Annatel Mobile","il"],"42577":["Hot Mobile","il"],"42601":["Batelco","bh"],"42602":["Zain Bahrain","bh"],"42604":["stc BH","bh"],"42605":["Batelco","bh"],"426299":["Failed Calls","bh"],"426999":["Fix Line","bh"],"42701":["Ooredoo","qa"],"42702":["Vodafone","qa"],"42800":["Skytel Co. Ltd","mn"],"42888":["Unitel","mn"],"42891":["Skytel","mn"],"42898":["G.Mobile","mn"],"42899":["Mobicom","mn"],"42901":["Nepal Telecommunications","np"],"42902":["Ncell","np"],"42903":["Nepal Telecommunications","np"],"42904":["Smart Telecom","np"],"429999":["Fix Line","np"],"43002":["Etisalat","ae"],"43102":["Etisalat","ae"],"43211":["IR-MCI (Hamrahe Avval)","ir"],"43214":["Telecommunication Kish Co. (KIFZO)","ir"],"43219":["MTCE (Espadan)","ir"],"43220":["Rightel","ir"],"43232":["Taliya","ir"],"43235":["Irancell","ir"],"43270":["MTCE","ir"],"43293":["Farzanegan Pars","ir"],"432999":["Fix Line","ir"],"43401":["Buztel","uz"],"43402":["Uzmacom","uz"],"43404":["Daewoo Unitel","uz"],"43405":["Coscom","uz"],"43406":["Perfectum Mobile","uz"],"43407":["Uzdunrobita","uz"],"43601":["JC Somoncom","tj"],"43602":["CJSC Indigo Tajikistan","tj"],"43603":["TT mobile","tj"],"43604":["Babilon-Mobile","tj"],"43605":["CTJTHSC Tajik-tel","tj"],"43612":["Tcell","tj"],"43701":["Beeline","kg"],"43702":["KT Mobile","kg"],"43703":["AkTel LLC","kg"],"43705":["MegaCom","kg"],"43709":["O!","kg"],"43710":["Saima","kg"],"437299":["Failed Calls","kg"],"43801":["Barash Communication Technologies (BCTI)","tm"],"43802":["TM-Cell","tm"],"44000":["eMobile","jp"],"44001":["NTT DoCoMo","jp"],"44002":["NTT DoCoMo","jp"],"44003":["IIJmio","jp"],"44004":["SoftBank","jp"],"44005":["SoftBank","jp"],"44006":["SoftBank","jp"],"44007":["KDDI","jp"],"44008":["KDDI","jp"],"44009":["NTT DoCoMo","jp"],"44010":["DOCOMO MVNO","jp"],"44011":["Rakuten Mobile(MNO)","jp"],"44012":["NTT DoCoMo","jp"],"44013":["OCN MOBILE ONE","jp"],"44014":["NTT DoCoMo","jp"],"44015":["NTT DoCoMo","jp"],"44016":["NTT DoCoMo","jp"],"44017":["NTT DoCoMo","jp"],"44018":["NTT DoCoMo","jp"],"44019":["NTT DoCoMo","jp"],"44020":["SoftBank","jp"],"44021":["NTT DoCoMo","jp"],"44022":["NTT DoCoMo","jp"],"44023":["NTT DoCoMo","jp"],"44024":["NTT DoCoMo","jp"],"44025":["NTT DoCoMo","jp"],"44026":["NTT DoCoMo","jp"],"44027":["NTT DoCoMo","jp"],"44028":["NTT DoCoMo","jp"],"44029":["NTT DoCoMo","jp"],"44030":["NTT DoCoMo","jp"],"44031":["NTT DoCoMo","jp"],"44032":["NTT DoCoMo","jp"],"44033":["NTT DoCoMo","jp"],"44034":["NTT DoCoMo","jp"],"44035":["NTT DoCoMo","jp"],"44036":["NTT DoCoMo","jp"],"44037":["NTT DoCoMo","jp"],"44038":["NTT DoCoMo","jp"],"44039":["NTT DoCoMo","jp"],"44040":["SoftBank","jp"],"44041":["SoftBank","jp"],"44042":["SoftBank","jp"],"44043":["SoftBank","jp"],"44044":["SoftBank","jp"],"44045":["SoftBank","jp"],"44046":["SoftBank","jp"],"44047":["SoftBank","jp"],"44048":["SoftBank","jp"],"44049":["NTT DoCoMo","jp"],"44050":["KDDI","jp"],"44051":["KDDI","jp"],"44052":["KDDI","jp"],"44053":["KDDI","jp"],"44054":["KDDI","jp"],"44055":["KDDI","jp"],"44056":["KDDI","jp"],"44058":["NTT DoCoMo","jp"],"44060":["NTT DoCoMo","jp"],"44061":["NTT DoCoMo","jp"],"44062":["NTT DoCoMo","jp"],"44063":["NTT DoCoMo","jp"],"44064":["NTT DoCoMo","jp"],"44065":["NTT DoCoMo","jp"],"44066":["NTT DoCoMo","jp"],"44067":["NTT DoCoMo","jp"],"44068":["NTT DoCoMo","jp"],"44069":["NTT DoCoMo","jp"],"44070":["KDDI","jp"],"44071":["KDDI","jp"],"44072":["KDDI","jp"],"44073":["KDDI","jp"],"44074":["KDDI","jp"],"44075":["KDDI","jp"],"44076":["KDDI","jp"],"44077":["KDDI","jp"],"44078":["Okinawa Cellular","jp"],"44079":["KDDI","jp"],"44080":["KDDI","jp"],"44081":["KDDI","jp"],"44082":["KDDI","jp"],"44083":["KDDI","jp"],"44084":["KDDI","jp"],"44085":["KDDI","jp"],"44086":["KDDI","jp"],"44087":["NTT DoCoMo","jp"],"44088":["KDDI","jp"],"44089":["KDDI","jp"],"44090":["SoftBank","jp"],"44092":["SoftBank","jp"],"44093":["SoftBank","jp"],"44094":["SoftBank","jp"],"44095":["SoftBank","jp"],"44096":["SoftBank","jp"],"44097":["SoftBank","jp"],"44098":["SoftBank","jp"],"44099":["NTT DoCoMo","jp"],"44100":["Wireless City Planning","jp"],"44140":["NTT DoCoMo","jp"],"44141":["NTT DoCoMo","jp"],"44142":["NTT DoCoMo","jp"],"44143":["NTT DoCoMo","jp"],"44144":["NTT DoCoMo","jp"],"44145":["NTT DoCoMo","jp"],"44161":["SoftBank","jp"],"44162":["SoftBank","jp"],"44163":["SoftBank","jp"],"44164":["SoftBank","jp"],"44165":["SoftBank","jp"],"44170":["KDDI","jp"],"44190":["NTT DoCoMo","jp"],"44191":["NTT DoCoMo","jp"],"44192":["NTT DoCoMo","jp"],"44193":["NTT DoCoMo","jp"],"44194":["NTT DoCoMo","jp"],"44198":["NTT DoCoMo","jp"],"44199":["NTT DoCoMo","jp"],"450006":["LG U+","kr"],"45002":["KT","kr"],"45003":["SK Telecom","kr"],"45004":["KT","kr"],"45005":["SK Telecom","kr"],"45006":["LG U+","kr"],"45007":["KT Powertel","kr"],"45008":["KT","kr"],"45011":["SK Telink","kr"],"45012":["SK Telecom","kr"],"450299":["Failed Calls","kr"],"45201":["Mobifone","vn"],"45202":["Vinaphone","vn"],"45203":["S-Fone/Telecom","vn"],"45204":["Viettel Telecom","vn"],"45205":["Vietnamobile","vn"],"45206":["Viettel","vn"],"45207":["Gmobile","vn"],"45208":["Viettel Mobile","vn"],"45209":["Wintel","vn"],"45400":["1O1O / csl / Club Sim","hk"],"45401":["MVNO/CITIC","hk"],"45402":["3G Radio System/HKCSL3G","hk"],"45403":["Hutchison HK","hk"],"45404":["Hutchison 2G","hk"],"45405":["Hutchison 2G","hk"],"45406":["SmarTone HK","hk"],"45407":["MVNO/China Unicom International Ltd.","hk"],"45408":["MVNO/Trident","hk"],"45409":["MVNO/China Motion Telecom (HK) Ltd.","hk"],"45410":["GSM1800New World PCS Ltd.","hk"],"45411":["MVNO/CHKTL","hk"],"45412":["中國移動香港 China Mobile HK","hk"],"45413":["中國移動香港 China Mobile HK","hk"],"45414":["H3G/Hutchinson","hk"],"45415":["SmarTone HK","hk"],"45416":["PCCW","hk"],"45417":["SmarTone HK","hk"],"45418":["GSM7800/Hong Kong CSL Ltd.","hk"],"45419":["1O1O / csl / Club Sim","hk"],"45420":["Public Mobile Networks/Reserved","hk"],"45421":["Public Mobile Networks/Reserved","hk"],"45422":["Public Mobile Networks/Reserved","hk"],"45423":["Public Mobile Networks/Reserved","hk"],"45424":["Public Mobile Networks/Reserved","hk"],"45425":["Public Mobile Networks/Reserved","hk"],"45426":["Public Mobile Networks/Reserved","hk"],"45427":["Public Mobile Networks/Reserved","hk"],"45428":["Public Mobile Networks/Reserved","hk"],"45429":["Public Mobile Networks/Reserved","hk"],"45430":["Public Mobile Networks/Reserved","hk"],"45431":["Public Mobile Networks/Reserved","hk"],"45432":["Public Mobile Networks/Reserved","hk"],"45433":["Public Mobile Networks/Reserved","hk"],"45434":["Public Mobile Networks/Reserved","hk"],"45435":["Public Mobile Networks/Reserved","hk"],"45436":["Public Mobile Networks/Reserved","hk"],"45437":["Public Mobile Networks/Reserved","hk"],"45438":["Public Mobile Networks/Reserved","hk"],"45439":["Public Mobile Networks/Reserved","hk"],"45440":["shared by private TETRA systems","hk"],"45447":["shared by private TETRA systems","hk"],"45500":["Smartone Mobile Communications (Macao) Ltd.","mo"],"45501":["CTM","mo"],"45502":["China Telecom","mo"],"45503":["Hutchison Telecom","mo"],"45504":["CTM","mo"],"45505":["Hutchison Telephone Co. Ltd","mo"],"45506":["Smartone Mobile","mo"],"45601":["Mobitel (Cam GSM)","kh"],"45602":["Smart","kh"],"45603":["S Telecom (CDMA) (reserved)","kh"],"45604":["qb","kh"],"45605":["Smart","kh"],"45606":["Smart","kh"],"45608":["Metfone","kh"],"45609":["Sotelco/Beeline","kh"],"45611":["SEATEL","kh"],"45618":["Camshin (Shinawatra)","kh"],"456299":["CooTel","kh"],"45701":["Lao Telecommunications","la"],"45702":["ETL Mobile","la"],"45703":["Unitel","la"],"45708":["Millicom","la"],"46000":["China Mobile","cn"],"46001":["China Unicom","cn"],"46002":["China Mobile","cn"],"46003":["China Telecom","cn"],"46004":["China Mobile","cn"],"46005":["China Telecom","cn"],"46006":["China Unicom","cn"],"46007":["China Mobile","cn"],"46008":["China Mobile","cn"],"46009":["China Unicom","cn"],"46010":["China Unicom","cn"],"46011":["China Telecom","cn"],"46012":["China Telecom","cn"],"46015":["China Broadnet","cn"],"46020":["China Mobile","cn"],"460999":["Fix Line","cn"],"46601":["遠傳電信 Far EasTone Telecom","tw"],"46602":["遠傳電信 Far EasTone Telecom","tw"],"46603":["遠傳電信 Far EasTone Telecom","tw"],"46605":["遠傳電信Far EasTone Telecom(原亞太電信)","tw"],"46606":["Tuntex Telecom","tw"],"46607":["Far EasTone","tw"],"46609":["Vmax Telecom","tw"],"46610":["Global Mobile Corp.","tw"],"46611":["中華電信_Chunghwa Telecom","tw"],"46656":["International Telecom Co. Ltd (FITEL)","tw"],"46668":["ACeS Taiwan - ACeS Taiwan Telecommunications Co Ltd","tw"],"46688":["KG Telecom","tw"],"46689":["台灣大哥大(原台灣之星) Taiwan Mobile Telecom","tw"],"46690":["T-Star/VIBO","tw"],"46692":["中華電信_Chunghwa Telecom","tw"],"46693":["MobiTai Communications","tw"],"46697":["台灣大哥大 Taiwan Mobile Telecom","tw"],"46699":["TransAsia Telecoms","tw"],"467192":["Koryolink","kp"],"467193":["Sun Net","kp"],"467299":["Failed Calls","kp"],"47001":["Grameenphone","bd"],"47002":["Aktel","bd"],"47003":["Mobile 2000","bd"],"47004":["TeleTalk","bd"],"47005":["Citycell","bd"],"47006":["Citycell","bd"],"47007":["Airtel BD","bd"],"47201":["DhiMobile","mv"],"47202":["Ooredoo","mv"],"50200":["Art900","my"],"50201":["Art900","my"],"50210":["Digi","my"],"50211":["unifi mobile","my"],"50212":["Maxis/Hotlink","my"],"50213":["Celcom","my"],"50214":["Telekom Malaysia","my"],"502143":["Digi","my"],"502146":["Digi","my"],"502150":["Tune Talk","my"],"502151":["Baraka Telecom Sdn Bhd","my"],"502152":["Yes 5G","my"],"502153":["unifi mobile","my"],"502154":["TT dotCom","my"],"502155":["Samata Communications Sdn Bhd","my"],"502156":["Altel Communications","my"],"50216":["Digi","my"],"50217":["TimeCel","my"],"50218":["U Mobile","my"],"50219":["Celcom","my"],"502195":["XOX Com","my"],"502198":["Celcom","my"],"50220":["Electcoms Wireless Sdn Bhd","my"],"502299":["MKN","my"],"502999":["Fix Line","my"],"50501":["Telstra","au"],"50502":["Optus","au"],"50503":["Vodafone","au"],"50504":["Department of Defence","au"],"50505":["The Ozitel Network Pty. Ltd.","au"],"50506":["Hutchison 3G Australia Pty. Ltd.","au"],"50507":["Vodafone","au"],"50508":["One.Tel GSM 1800 Pty. Ltd.","au"],"50509":["Airnet Commercial Australia Ltd.","au"],"50510":["Norfolk Telecom","au"],"50511":["Telstra","au"],"50512":["Hutchison Telecommunications (Australia) Pty. Ltd.","au"],"50513":["RailCorp","au"],"50514":["AAPT Ltd.","au"],"50516":["VicTrack","au"],"50519":["Lycamobile","au"],"50524":["Advanced Communications Technologies Pty. Ltd.","au"],"50526":["Sinch","au"],"505299":["ACMA","au"],"50530":["Compatel","au"],"50535":["MessageBird","au"],"50539":["Telstra","au"],"50550":["Pivotel","au"],"50552":["OptiTel","au"],"50557":["CiFi","au"],"50571":["Telstra","au"],"50572":["Telstra","au"],"50588":["Pivotel","au"],"50590":["Optus","au"],"50599":["One.Tel GSM 1800 Pty. Ltd.","au"],"505999":["Fix Line","au"],"51000":["PSN","id"],"51001":["Indosat","id"],"51007":["Flexi (PT Telkom) (CDMA)","id"],"51008":["XL/AXIS","id"],"51009":["Smartfren","id"],"51010":["Telkomsel","id"],"51011":["XL/AXIS","id"],"51021":["Indosat - M3","id"],"51027":["PT Sampoerna Telekomunikasi Indonesia (STI)","id"],"51028":["Smartfren","id"],"51089":["3","id"],"51099":["Esia (PT Bakrie Telecom) (CDMA)","id"],"510999":["Fix Line","id"],"51401":["Telkomcel","tl"],"51402":["Timor Telecom","tl"],"51403":["Viettel","tl"],"514299":["Failed Calls","tl"],"514999":["Fix Line","tl"],"51501":["Islacom","ph"],"51502":["Globe Telecom","ph"],"51503":["Smart Communications","ph"],"51505":["Smart","ph"],"51518":["Redinternet","ph"],"51588":["Next Mobile","ph"],"515999":["Fix Line","ph"],"52000":["CAT CDMA","th"],"52001":["AIS GSM","th"],"52002":["CAT CDMA","th"],"52003":["AIS","th"],"52004":["TrueMove H 4G LTE","th"],"52005":["dtac","th"],"52015":["ACT Mobile","th"],"52018":["dtac","th"],"52020":["ACeS","th"],"52023":["Digital Phone Co.","th"],"52047":["TOT","th"],"52099":["True Move","th"],"520999":["Fix Line","th"],"52501":["Singtel","sg"],"52502":["Singtel","sg"],"52503":["M1","sg"],"52504":["Sunsurf","sg"],"52505":["StarHub","sg"],"52506":["Starhub","sg"],"52507":["Singtel","sg"],"52512":["Digital Trunked Radio Network","sg"],"525999":["Fix Line","sg"],"52801":["Telekom Brunei Bhd (TelBru)","bn"],"52802":["B-Mobile","bn"],"52811":["DST Com","bn"],"53000":["Reserved for AMPS MIN based IMSI's","nz"],"53001":["Vodafone","nz"],"53002":["Teleom New Zealand CDMA Network","nz"],"53003":["Woosh Wireless - CDMA Network","nz"],"53004":["Telstra","nz"],"53005":["Spark","nz"],"53024":["2degrees","nz"],"53028":["2degrees","nz"],"530999":["Fix Line","nz"],"53701":["Vodafone","pg"],"53702":["Vodafone","pg"],"53703":["Digicel Ltd","pg"],"537999":["Fix Line","pg"],"53901":["Tonga Communications Corporation","to"],"53943":["Shoreline Communication","to"],"53988":["Digicel","to"],"539999":["Fix Line","to"],"54001":["BREEZE","sb"],"54002":["Vodafone","sb"],"54010":["BREEZE","sb"],"54100":["AIL","vu"],"54101":["SMILE","vu"],"54105":["Digicel","vu"],"54201":["Vodafone","fj"],"54202":["Digicel","fj"],"54301":["Manuia","wf"],"543299":["Failed Calls","wf"],"54411":["Bluesky","as"],"544780":["ASTCA Mobile","as"],"54501":["Kiribati - TSKL","ki"],"54509":["Kiribati Frigate","ki"],"54601":["OPT Mobilis","nc"],"54705":["Viti","pf"],"54715":["Pacific Mobile Telecom (PMT)","pf"],"54720":["Tikiphone","pf"],"54801":["Telecom Cook","ck"],"54901":["Telecom Samoa Cellular Ltd.","ws"],"54927":["GoMobile SamoaTel Ltd","ws"],"549999":["Fix Line","ws"],"55001":["FSM Telecom","fm"],"551299":["Failed Calls","mh"],"55201":["Palau National Communications Corp. (a.k.a. PNCC)","pw"],"55202":["PECI/PalauTel (Palau","pw"],"55280":["Palau Mobile","pw"],"55301":["Tuvalu Telecommunication Corporation (TTC)","tv"],"55501":["Niue Telecom","nu"],"60201":["Orange Egypt","eg"],"60202":["Vodafone","eg"],"60203":["Etisalat","eg"],"60204":["WE","eg"],"602299":["Failed Calls","eg"],"60301":["Algérie Telecom","dz"],"60302":["Orascom Telecom Algérie","dz"],"60303":["Ooredoo","dz"],"60400":["Méditélécom","ma"],"60401":["Maroc","ma"],"60402":["inwi","ma"],"60404":["Al Houria Telecom","ma"],"60405":["inwi","ma"],"60406":["IAM","ma"],"60499":["Al Houria Telecom","ma"],"60501":["Orange Tunisie","tn"],"60502":["Tunisie Telecom","tn"],"60503":["Ooredoo Tunisia","tn"],"60506":["Lycamobile","tn"],"605999":["Fix Line","tn"],"60600":["Libyana","ly"],"60601":["Madar","ly"],"60602":["Al-Jeel","ly"],"60603":["Libya Phone","ly"],"60606":["Hatef","ly"],"60701":["Gamcel","gm"],"60702":["Africell","gm"],"60703":["Comium Services Ltd","gm"],"60704":["QCell","gm"],"60801":["Orange Senegal","sn"],"60802":["Sentel GSM","sn"],"60803":["Expresso","sn"],"60804":["HAYO","sn"],"608299":["2s Mobile","sn"],"60901":["Mattel S.A.","mr"],"60902":["Chinguitel S.A.","mr"],"60910":["Mauritel Mobiles","mr"],"61001":["Malitel","ml"],"61002":["Orange Mali","ml"],"61003":["Telecel","ml"],"61101":["Orange","gn"],"61102":["Sotelgui","gn"],"61103":["Intercel","gn"],"61104":["MTN/Areeba","gn"],"61105":["Cellcom Guinée SA","gn"],"61201":["Comstar","ci"],"61202":["Atlantique Cellulaire","ci"],"61203":["Orange Côte d'Ivoire","ci"],"61204":["Comium Côte d'Ivoire","ci"],"61205":["Loteny Telecom","ci"],"61206":["Oricel Côte d'Ivoire","ci"],"61207":["Aircomm Côte d'Ivoire","ci"],"61301":["Onatal (Telmob)","bf"],"61302":["Orange","bf"],"61303":["Telecel","bf"],"61401":["Sahel.Com","ne"],"61402":["Airtel Niger","ne"],"61403":["Telecel","ne"],"61404":["Orange Niger","ne"],"61501":["Togo Telecom","tg"],"61502":["Telecel/MOOV","tg"],"61503":["Moov Togo","tg"],"61601":["Libercom","bj"],"61602":["Telecel","bj"],"61603":["Spacetel Benin","bj"],"61604":["Bell Benin Communications","bj"],"61605":["Glo Communications Benin","bj"],"61701":["Orange Mauritius","mu"],"61702":["Mahanagar Telephone (Mauritius) Ltd.","mu"],"61703":["Chili","mu"],"61710":["Emtel","mu"],"61801":["Lonestar","lr"],"61802":["Libercell","lr"],"61804":["Comium Liberia","lr"],"61807":["Celcom","lr"],"61820":["LIBTELCO","lr"],"61901":["Orange","sl"],"61902":["Millicom","sl"],"61903":["Africell","sl"],"61904":["Comium (Sierra Leone) Ltd.","sl"],"61905":["Lintel (Sierra Leone) Ltd.","sl"],"61907":["Qcell","sl"],"61925":["Mobitel","sl"],"619299":["IPTel","sl"],"61940":["Datatel (SL) Ltd GSM","sl"],"61950":["Dtatel (SL) Ltd CDMA","sl"],"62001":["MTN","gh"],"62002":["Vodafone","gh"],"62003":["AirtelTigo","gh"],"62004":["Kasapa Telecom Ltd.","gh"],"62005":["National Security","gh"],"62006":["AirtelTigo","gh"],"62007":["Globacom","gh"],"62008":["Surfline","gh"],"620299":["Comsys","gh"],"62101":["Visafone","ng"],"62120":["Airtel Nigeria","ng"],"62125":["Visafone","ng"],"62127":["Smile","ng"],"621299":["Alpha Technologies","ng"],"62130":["MTN Nigeria Communications","ng"],"62140":["Nigeria Telecommunications Ltd.","ng"],"62150":["Glo","ng"],"62160":["9Pay","ng"],"62199":["Starcomms","ng"],"62201":["Airtel Chad","td"],"62202":["Tchad Mobile","td"],"62203":["Tigo/Milicom/Tchad Mobile","td"],"62204":["Salam","td"],"62301":["Centrafrique Telecom Plus (CTP)","cf"],"62302":["Telecel Centrafrique (TC)","cf"],"62303":["Orange Centrafricaine","cf"],"62304":["Nationlink","cf"],"623299":["Failed Calls","cf"],"62401":["Mobile Telephone Networks Cameroon","cm"],"62402":["Orange Cameroun","cm"],"62404":["Nexttel","cm"],"62501":["Cabo Verde Telecom","cv"],"62502":["T+Telecomunicaçôes","cv"],"62601":["Companhia Santomese de Telecomunicaçôes","st"],"62602":["Unitel","st"],"62701":["Orange","gq"],"62703":["Hits-GE","gq"],"627299":["Failed Calls","gq"],"62801":["Libertis S.A.","ga"],"62802":["Telecel Gabon S.A.","ga"],"62803":["Airtel Gabon","ga"],"62804":["Azur","ga"],"628299":["Failed Calls","ga"],"62901":["Airtel Congo","cg"],"62902":["Azur SA (ETC)","cg"],"62907":["Warid","cg"],"62910":["Libertis Telecom","cg"],"63001":["Vodacom Congo RDC sprl","cd"],"63002":["Airtel","cd"],"63005":["Supercell Sprl","cd"],"630299":["Failed Calls","cd"],"63086":["Orange RDC","cd"],"63088":["Yozma Timeturns","cd"],"63089":["Tigo","cd"],"63090":["Africell","cd"],"63102":["Unitel","ao"],"63104":["MOVICEL","ao"],"63201":["Guinétel S.A.","gw"],"63202":["Spacetel Guiné-Bissau S.A.","gw"],"63203":["Orange","gw"],"63207":["Guinetel","gw"],"632999":["Fix\tLine","gw"],"63301":["Cable & Wireless (Seychelles) Ltd.","sc"],"63302":["Mediatech International Ltd.","sc"],"63305":["Intelvision","sc"],"63310":["Airtel Seychelles","sc"],"63400":["Canar Telecom","sd"],"63401":["SD Mobitel","sd"],"63402":["Areeba-Sudan","sd"],"63403":["MTN","sd"],"63405":["Canar Telecom","sd"],"63406":["Zain","sd"],"63407":["Sudani","sd"],"63408":["Canar Telecom","sd"],"63409":["Privet","sd"],"63415":["Sudani One","sd"],"63422":["MTN","sd"],"634999":["Fix Line","sd"],"63510":["MTN Rwandacell","rw"],"63512":["Rwandatel","rw"],"63513":["Airtel Rwanda","rw"],"63514":["Airtel Rwanda","rw"],"63601":["ETH MTN","et"],"63602":["Safaricom Telecommunications Ethiopia","et"],"63701":["Telesom","so"],"63704":["Somafone","so"],"63710":["Nationlink","so"],"63719":["Hormuud","so"],"63725":["Hormuud","so"],"637299":["AirSom","so"],"63730":["Golis Telecommunications Company","so"],"63750":["Hormuud","so"],"63757":["Unitel","so"],"63760":["Nationlink","so"],"63770":["Onkod","so"],"63771":["Somtel","so"],"63782":["Telcom","so"],"63801":["Evatis","dj"],"63901":["Safaricom","ke"],"63902":["Safaricom","ke"],"63903":["Airtel Kenya","ke"],"63904":["Mobile Pay","ke"],"63905":["Yu","ke"],"63906":["Finserve Africa","ke"],"63907":["Telkom","ke"],"63909":["Homeland Media","ke"],"63910":["Jamii Telecommunications","ke"],"63911":["Jambo Telcoms","ke"],"63912":["Infura","ke"],"639299":["eferio","ke"],"64001":["Tri Telecomm. Ltd.","tz"],"64002":["TIGO","tz"],"64003":["Zantel","tz"],"64004":["Vodacom","tz"],"64005":["Airtel","tz"],"64006":["Sasatel Tanzania","tz"],"64007":["Life Tanzania","tz"],"64008":["Benson Informatics Ltd","tz"],"64009":["Halotel / Viettel","tz"],"64011":["Smile Communications","tz"],"64013":["WiAfrica","tz"],"64014":["MO Mobile","tz"],"64099":["Mkulima African Telecommunication","tz"],"64101":["Airtel Uganda","ug"],"64104":["Lycamobile","ug"],"64110":["MTN Uganda Ltd.","ug"],"64111":["Uganda Telecom Ltd.","ug"],"64114":["House of Integrated Technology and Systems Uganda Ltd","ug"],"64118":["Suretelecom Uganda Ltd","ug"],"64122":["Airtel Uganda","ug"],"64130":["K2 Telecom Ltd","ug"],"64133":["Smile","ug"],"64166":["i-Tel Ltd","ug"],"641999":["Fix Line","ug"],"64201":["Spacetel Burundi","bi"],"64202":["Safaris","bi"],"64203":["Telecel Burundi Company","bi"],"64207":["Smart Mobile","bi"],"64208":["Lumitel/Viettel","bi"],"64282":["Leo","bi"],"642999":["Fix\tLine","bi"],"64301":["T.D.M. GSM","mz"],"64303":["Movitel","mz"],"64304":["Vodacom","mz"],"64501":["Airtel Zambia","zm"],"64502":["Telecel Zambia Ltd.","zm"],"64503":["Zamtel","zm"],"645299":["Failed Calls","zm"],"64601":["Airtel Madagascar","mg"],"64602":["Orange Madagascar","mg"],"64603":["Sacel","mg"],"64604":["Telecom Malagasy Mobile","mg"],"646299":["Bip","mg"],"64700":["Orange La Réunion","re"],"64701":["Maore Mobile","yt"],"64702":["Telco OI","re"],"64703":["Free RE","re"],"64704":["Zeop_RE","re"],"64710":["Société Réunionnaise du Radiotéléphone","yt"],"64801":["Net One","zw"],"64803":["Telecel","zw"],"64804":["Econet","zw"],"64901":["Mobile Telecommunications Ltd.","na"],"64902":["switch","na"],"64903":["Powercom Pty Ltd","na"],"649299":["Demshi","na"],"65001":["Telekom Network Ltd.","mw"],"65002":["ZERO2","mw"],"65010":["Airtel Malawi","mw"],"65101":["VCL","ls"],"65102":["Econet Ezin-cel","ls"],"65201":["Mascom Wireless (Pty) Ltd.","bw"],"65202":["Orange Botswana (Pty) Ltd.","bw"],"65204":["beMobile","bw"],"65301":["EswatiniTelecom","sz"],"65302":["Eswatini Mobile","sz"],"65310":["Swazi MTN","sz"],"65401":["HURI - SNPT","km"],"65402":["Telma","km"],"654299":["Failed Calls","km"],"65501":["Vodacom","za"],"65502":["Telkom","za"],"65505":["Telkom","za"],"65506":["Sentech (Pty) Ltd.","za"],"65507":["Cell C (Pty) Ltd.","za"],"65510":["MTN","za"],"65511":["SAPS Gauteng","za"],"65512":["MTN","za"],"65519":["rain","za"],"65521":["Cape Town Metropolitan Council","za"],"655299":["Lycamobile","za"],"65530":["Bokamoso Consortium","za"],"65531":["Karabo Telecoms (Pty) Ltd.","za"],"65532":["Ilizwi Telecommunications","za"],"65533":["Thinta Thinta Telecommunications","za"],"65534":["Bokone Telecoms","za"],"65535":["Kingdom Communications","za"],"65536":["Amatole Telecommunication Services","za"],"65538":["rain","za"],"65573":["rain","za"],"65574":["rain","za"],"65701":["Eritel","er"],"658299":["Failed Calls","sh"],"65902":["MTN","ss"],"65903":["Gemtel Ltd (South Sudan","ss"],"65904":["Network of The World Ltd (NOW) (South Sudan","ss"],"65906":["Zain","ss"],"659299":["Digitel","ss"],"702099":["Smart","bz"],"702299":["Failed Calls","bz"],"70267":["Belize Telecommunications Ltd.","bz"],"70268":["International Telecommunications Ltd. (INTELCO)","bz"],"70269":["Smart","bz"],"70299":["Smart","bz"],"70401":["Claro GT","gt"],"70402":["Comunicaciones Celulares S.A.","gt"],"70403":["Movistar","gt"],"704030":["Movistar","gt"],"70601":["Claro SV","sv"],"70602":["Digicel, S.A. de C.V.","sv"],"70603":["Tigo","sv"],"70604":["Movistar","sv"],"706040":["Movistar","sv"],"70605":["INTELFON SA de CV","sv"],"708001":["Claro HN","hn"],"708002":["Celtel","hn"],"70801":["Claro HN","hn"],"70802":["Celtel","hn"],"708020":["Celtel","hn"],"708030":["HonduTel","hn"],"70804":["Digicel","hn"],"708040":["Digicel","hn"],"70830":["Hondutel","hn"],"70840":["Digicel","hn"],"71021":["Claro NI","ni"],"71030":["Movistar (Telefonía Celular de Nicaragua)","ni"],"710300":["Movistar (Telefonía Celular de Nicaragua)","ni"],"71070":["Yota Nicaragua","ni"],"71073":["Servicios de Comunicaciones, S.A. (SERCOM)","ni"],"710730":["Servicios de Comunicaciones, S.A. (SERCOM)","ni"],"710999":["Fix Line","ni"],"71201":["KOLBI ICE","cr"],"712019":["Tuyo","cr"],"71202":["KOLBI ICE","cr"],"71203":["Claro CR","cr"],"71204":["Liberty","cr"],"712190":["Tuyo","cr"],"71220":["Virtualis","cr"],"712999":["Fix Line","cr"],"71401":["Cable & Wireless Panama S.A.","pa"],"71402":["Movistar","pa"],"714020":["Movistar","pa"],"71403":["Claro PA","pa"],"71404":["Digicel","pa"],"714040":["Digicel","pa"],"714999":["Fix Line","pa"],"71601":["GlobalStar","pe"],"71602":["GlobalStar","pe"],"71606":["Movistar","pe"],"71607":["Nextel","pe"],"71610":["Claro PE","pe"],"71615":["Bitel","pe"],"71617":["Entel","pe"],"71620":["Claro /Amer.Mov./TIM","pe"],"722007":["Movistar","ar"],"722010":["Movistar","ar"],"722020":["Nextel Argentina srl","ar"],"722031":["Claro","ar"],"722034":["Personal","ar"],"72207":["Movistar","ar"],"722070":["Movistar","ar"],"722210":["IMOWI","ar"],"722299":["Express","ar"],"72231":["Claro AR","ar"],"722310":["Claro AR","ar"],"722320":["Compañía de Telefonos del Interior Norte S.A.","ar"],"722330":["Compañía de Telefonos del Interior S.A.","ar"],"72234":["Telecom Personal S.A.","ar"],"722340":["Telecom Personal S.A.","ar"],"722341":["Telecom Personal S.A.","ar"],"72236":["Argentina:Nuestro","ar"],"722999":["Fix Line","ar"],"72400":["Nextel","br"],"72401":["CRT Cellular","br"],"72402":["TIM","br"],"72403":["TIM","br"],"72404":["TIM","br"],"72405":["Claro BR","br"],"72406":["Vivo","br"],"72407":["Sercontel Cel","br"],"72408":["Maxitel MG","br"],"72409":["Telepar Cel","br"],"72410":["Vivo","br"],"72411":["Vivo","br"],"72412":["Americel","br"],"72413":["Telesp Cel","br"],"72414":["Maxitel BA","br"],"72415":["Sercomtel","br"],"72416":["Brasil Telecom GSM","br"],"72417":["Ceterp Cel","br"],"72418":["Datora","br"],"72419":["Telemig Cel","br"],"72421":["Telerj Cel","br"],"72423":["Vivo","br"],"72424":["Oi","br"],"72425":["Telebrasilia Cel","br"],"72426":["AmericaNet","br"],"72427":["Telegoias Cel","br"],"72429":["Unifique","br"],"72430":["Oi","br"],"72431":["Oi","br"],"72432":["Algar Telecom","br"],"72433":["Algar Telecom","br"],"72434":["Algar Telecom","br"],"72435":["Telebahia Cel","br"],"72437":["Telergipe Cel","br"],"72438":["Claro BR","br"],"72439":["Nextel","br"],"72441":["Telpe Cel","br"],"72443":["Telepisa Cel","br"],"72445":["Telpa Cel","br"],"72447":["Telern Cel","br"],"72448":["Teleceara Cel","br"],"72451":["Telma Cel","br"],"72453":["Telepara Cel","br"],"72454":["TIM","br"],"72455":["Teleamazon Cel","br"],"72457":["Teleamapa Cel","br"],"72459":["Telaima Cel","br"],"72477":["Brisanet","br"],"73000":["TESAM SA","cl"],"73001":["Entel","cl"],"73002":["Movistar","cl"],"73003":["Claro CL","cl"],"73004":["WOM","cl"],"73005":["Multikom S.A.","cl"],"73006":["Blue Two Chile SA","cl"],"73007":["Movistar","cl"],"73008":["VTR Banda Ancha SA","cl"],"73009":["WOM","cl"],"73010":["Entel","cl"],"73011":["Celupago SA","cl"],"73012":["Telestar Movil SA","cl"],"73013":["Tribe Mobile SPA","cl"],"73014":["Netline Telefonica Movil Ltda","cl"],"73015":["Cibeles Telecom SA","cl"],"73019":["Sociedad Falabella Movil SPA","cl"],"73026":["Entel","cl"],"732001":["Colombia Telecomunicaciones S.A. - Telecom","co"],"732002":["Edatel S.A.","co"],"732020":["Emtelsa","co"],"732099":["Emcali","co"],"732101":["Claro CO","co"],"732102":["Bellsouth Colombia S.A.","co"],"732103":["Colombia Móvil S.A.","co"],"732111":["Colombia Móvil S.A.","co"],"732123":["Movistar","co"],"732130":["WOM","co"],"732142":["UNE","co"],"732154":["Virgin Mobile","co"],"732165":["Tigo","co"],"732187":["ETB 4G","co"],"732199":["SUMA movil","co"],"732220":["Libre Tecnologias","co"],"732230":["Setroc Mobile","co"],"732240":["Flash Mobile","co"],"732299":["ATnet","co"],"732360":["WOM","co"],"732666":["Claro","co"],"732999":["Fix Line","co"],"73401":["Infonet","ve"],"73402":["Corporación Digitel","ve"],"73403":["Digicel","ve"],"73404":["Movistar","ve"],"73406":["Telecomunicaciones Movilnet, C.A.","ve"],"73601":["Nuevatel S.A.","bo"],"73602":["ENTEL S.A.","bo"],"73603":["Telecel S.A.","bo"],"738002":["GT&T Cellink Plus","gy"],"73801":["Cel*Star (Guyana) Inc.","gy"],"73802":["GT&T Cellink Plus","gy"],"74000":["Movistar","ec"],"740000":["Failed Call(s)","ec"],"74001":["Claro EC","ec"],"740010":["Claro EC","ec"],"74002":["Telecsa S.A.","ec"],"74003":["Tuenti","ec"],"74401":["Hola Paraguay S.A.","py"],"74402":["Claro PY","py"],"74403":["Compañia Privada de Comunicaciones S.A.","py"],"74404":["Telecel","py"],"74405":["Personal","py"],"74406":["Hola Paraguay S.A.","py"],"74601":["Telesur","sr"],"74602":["Telesur","sr"],"74603":["Digicel","sr"],"74604":["Intelsur","sr"],"746999":["Fix Line","sr"],"74800":["Ancel","uy"],"74801":["Ancel","uy"],"74803":["Ancel","uy"],"74807":["Movistar","uy"],"74810":["Claro UY","uy"],"750001":["Sure","fk"],"90101":["ICO Global Communications","n/a"],"90102":["Sense Communications International AS","n/a"],"90103":["Iridium Satellite, LLC (GMSS)","n/a"],"90104":["Globalstar","n/a"],"90105":["Thuraya RMSS Network","n/a"],"90106":["Thuraya Satellite Telecommunications Company","n/a"],"90107":["Ellipso","n/a"],"90109":["Tele1 Europe","n/a"],"90110":["Asia Cellular Satellite (AceS)","n/a"],"90111":["Inmarsat Ltd.","n/a"],"90112":["Maritime Communications Partner AS (MCP network)","n/a"],"90113":["Global Networks, Inc.","n/a"],"90114":["Telenor GSM - services in aircraft","n/a"],"90115":["SITA GSM services in aircraft (On Air)","n/a"],"90116":["Jasper Systems, Inc.","n/a"],"90117":["Jersey Telecom","n/a"],"90118":["AT&T Mobility (Wireless Maritime Services)","n/a"],"90119":["Vodafone","n/a"],"90120":["Intermatica","n/a"],"90121":["Seanet Maritime Communications","n/a"],"90122":["Denver Consultants Ltd","n/a"],"90128":["Vodafone GDSP","n/a"],"90137":["Transatel","n/a"],"90158":["Bics","n/a"],"90188":["Telecommunications for Disaster Relief (TDR) (OCHA)","n/a"],"90198":["Skylo","n/a"]},"i":{"202":"gr","204":"nl","206":"be","208":"fr","212":"mc","213":"ad","214":"es","216":"hu","218":"ba","219":"hr","220":"rs","221":"xk","222":"it","225":"va","226":"ro","228":"ch","230":"cz","231":"sk","232":"at","234":"gb","235":"gb","238":"dk","240":"se","242":"no","244":"fi","246":"lt","247":"lv","248":"ee","250":"ru","255":"ua","257":"by","259":"md","260":"pl","262":"de","266":"gi","268":"pt","270":"lu","272":"ie","274":"is","276":"al","278":"mt","280":"cy","282":"ge","283":"am","284":"bg","286":"tr","288":"fo","289":"ge","290":"gl","292":"sm","293":"si","294":"mk","295":"li","297":"me","302":"ca","308":"pm","310":"us","311":"us","312":"us","313":"us","314":"us","315":"us","316":"us","330":"pr","334":"mx","338":"jm","340":"gf","342":"bb","344":"ag","346":"ky","348":"vg","350":"bm","352":"gd","354":"ms","356":"kn","358":"lc","360":"vc","362":"bq","363":"aw","364":"bs","365":"ai","366":"dm","368":"cu","370":"do","372":"ht","374":"tt","376":"tc","400":"az","401":"kz","402":"bt","404":"in","405":"in","406":"in","410":"pk","412":"af","413":"lk","414":"mm","415":"lb","416":"jo","417":"sy","418":"iq","419":"kw","420":"sa","421":"ye","422":"om","424":"ae","425":"il","426":"bh","427":"qa","428":"mn","429":"np","430":"ae","431":"ae","432":"ir","434":"uz","436":"tj","437":"kg","438":"tm","440":"jp","441":"jp","450":"kr","452":"vn","454":"hk","455":"mo","456":"kh","457":"la","460":"cn","461":"cn","466":"tw","467":"kp","470":"bd","472":"mv","502":"my","505":"au","510":"id","514":"tl","515":"ph","520":"th","525":"sg","528":"bn","530":"nz","537":"pg","539":"to","540":"sb","541":"vu","542":"fj","543":"wf","544":"as","545":"ki","546":"nc","547":"pf","548":"ck","549":"ws","550":"fm","551":"mh","552":"pw","553":"tv","555":"nu","602":"eg","603":"dz","604":"ma","605":"tn","606":"ly","607":"gm","608":"sn","609":"mr","610":"ml","611":"gn","612":"ci","613":"bf","614":"ne","615":"tg","616":"bj","617":"mu","618":"lr","619":"sl","620":"gh","621":"ng","622":"td","623":"cf","624":"cm","625":"cv","626":"st","627":"gq","628":"ga","629":"cg","630":"cd","631":"ao","632":"gw","633":"sc","634":"sd","635":"rw","636":"et","637":"so","638":"dj","639":"ke","640":"tz","641":"ug","642":"bi","643":"mz","645":"zm","646":"mg","647":"yt","648":"zw","649":"na","650":"mw","651":"ls","652":"bw","653":"sz","654":"km","655":"za","657":"er","658":"sh","659":"ss","702":"bz","704":"gt","706":"sv","708":"hn","710":"ni","712":"cr","714":"pa","716":"pe","722":"ar","724":"br","730":"cl","732":"co","734":"ve","736":"bo","738":"gy","740":"ec","744":"py","746":"sr","748":"uy","750":"fk","901":"n/a"},"t":["302","310","311","312","313","314","315","316","334","338"],"meta":{"source":"Android Open Source Project carrier_list.textpb","source_url":"https://android.googlesource.com/platform/packages/providers/TelephonyProvider/+/master/assets/latest_carrier_id/carrier_list.textpb","aosp_version":"134217771","aosp_generic_records":1672}} \ No newline at end of file diff --git a/internal/device/scan_test.go b/internal/device/scan_test.go index cfbb081..498e3b2 100644 --- a/internal/device/scan_test.go +++ b/internal/device/scan_test.go @@ -40,6 +40,7 @@ func TestCarrierNameForPLMNUsesGlobalDatabase(t *testing.T) { func TestCarrierForPLMNReturnsCountryCode(t *testing.T) { tests := map[string]string{ "23415": "GB", + "23487": "GB", "26202": "DE", "310260": "US", "22201": "IT", @@ -53,3 +54,22 @@ func TestCarrierForPLMNReturnsCountryCode(t *testing.T) { } } } + +func TestCarrierForIMSIHandlesTwoAndThreeDigitMNCs(t *testing.T) { + tests := []struct { + imsi string + wantPLMN string + wantCountry string + }{ + {imsi: "234336570710174", wantPLMN: "23433", wantCountry: "GB"}, + {imsi: "234159609054263", wantPLMN: "23415", wantCountry: "GB"}, + {imsi: "234870123456789", wantPLMN: "23487", wantCountry: "GB"}, + {imsi: "310260123456789", wantPLMN: "310260", wantCountry: "US"}, + } + for _, item := range tests { + plmn, name, country, ok := CarrierForIMSI(item.imsi) + if !ok || plmn != item.wantPLMN || name == "" || country != item.wantCountry { + t.Errorf("CarrierForIMSI(%q) = (%q, %q, %q, %v), want PLMN %q and country %q", item.imsi, plmn, name, country, ok, item.wantPLMN, item.wantCountry) + } + } +} diff --git a/internal/device/snapshot.go b/internal/device/snapshot.go index b50b24b..4ddb9f8 100644 --- a/internal/device/snapshot.go +++ b/internal/device/snapshot.go @@ -3,12 +3,14 @@ package device import ( "context" "encoding/csv" + "encoding/hex" "fmt" "io" "strconv" "strings" "time" "unicode" + "unicode/utf16" "vocat/internal/modem" ) @@ -17,6 +19,8 @@ func (manager *Manager) readSnapshot( ctx context.Context, id string, candidate modem.Candidate, + backend string, + previousICCID string, client modem.Client, ) (Snapshot, error) { snapshot := Snapshot{ @@ -47,6 +51,36 @@ func (manager *Manager) readSnapshot( if response, ok := optional("AT+CPIN?"); ok { snapshot.SIMStatus, snapshot.SIMReady = parseCPIN(response) } + ccid, ccidErr := manager.command(ctx, client, "AT+CCID") + if ccidErr != nil { + ccid, ccidErr = manager.command(ctx, client, "AT+QCCID") + } + if ccidErr != nil { + snapshot.Warnings = append(snapshot.Warnings, "read ICCID: "+ccidErr.Error()) + } else { + snapshot.ICCID = parseICCIDIdentifier(ccid, []string{"+CCID:", "+QCCID:"}, 18, 22) + } + previousICCID = strings.TrimSpace(previousICCID) + if previousICCID != "" && snapshot.ICCID != "" && !strings.EqualFold(previousICCID, snapshot.ICCID) { + // A different physical SIM must never inherit the previous card's + // permission to use cellular RF. Disable RF before reading serving-cell + // or operator state; policy reconciliation will then start VoWiFi. + if _, err := manager.command(ctx, client, "AT+CFUN=4"); err != nil { + return snapshot, fmt.Errorf("protect changed SIM with RF off: %w", err) + } + snapshot.SIMChanged = true + } + if response, ok := optional("AT+CIMI"); ok { + snapshot.IMSI = parseIdentifier(response, []string{"+CIMI:"}, 10, 18) + } + // EF_SPN is the SIM-issued brand (for example "Lebara"), which is distinct + // from the IMSI sponsor/core PLMN. A Lebara UK subscription may therefore + // legitimately carry a Vodafone NL IMSI while still presenting Lebara as + // its customer-facing operator. Failure is intentionally silent because + // EF_SPN is optional and some physical SIMs deny CRSM access to it. + if response, spnErr := manager.command(ctx, client, "AT+CRSM=176,28486,0,0,17"); spnErr == nil { + snapshot.SPN = parseSPN(response) + } if response, ok := optional("AT+CSQ"); ok { snapshot.SignalRaw, snapshot.SignalPercent, snapshot.RSSIDBm = parseCSQ(response) } @@ -87,13 +121,16 @@ func (manager *Manager) readSnapshot( break } } - if registration, found := readPlatformRegistration(ctx, candidate); found { - snapshot.RegistrationStatus = registration.Status - snapshot.RegistrationSource = "QMI NAS" - snapshot.PSAttached = registration.PSAttached - if registration.PLMN != "" { - snapshot.OperatorCode = registration.PLMN - snapshot.OperatorName = carrierNameForPLMN(registration.PLMN, registration.Name) + if strings.EqualFold(backend, "qmi") { + registration, found := readPlatformRegistration(ctx, candidate) + if found { + snapshot.RegistrationStatus = registration.Status + snapshot.RegistrationSource = "QMI NAS" + snapshot.PSAttached = registration.PSAttached + if registration.PLMN != "" { + snapshot.OperatorCode = registration.PLMN + snapshot.OperatorName = carrierNameForPLMN(registration.PLMN, registration.Name) + } } } if snapshot.RegistrationSource == "" && (snapshot.OperatorName != "" || snapshot.OperatorCode != "") { @@ -111,18 +148,6 @@ func (manager *Manager) readSnapshot( ) } - ccid, ccidErr := manager.command(ctx, client, "AT+CCID") - if ccidErr != nil { - ccid, ccidErr = manager.command(ctx, client, "AT+QCCID") - } - if ccidErr != nil { - snapshot.Warnings = append(snapshot.Warnings, "read ICCID: "+ccidErr.Error()) - } else { - snapshot.ICCID = parseICCIDIdentifier(ccid, []string{"+CCID:", "+QCCID:"}, 18, 22) - } - if response, ok := optional("AT+CIMI"); ok { - snapshot.IMSI = parseIdentifier(response, []string{"+CIMI:"}, 10, 18) - } if response, ok := optional("AT+CFUN?"); ok { if mode, found := parseCFUN(response); found { snapshot.OperatingMode = mode @@ -139,6 +164,53 @@ func (manager *Manager) readSnapshot( return snapshot, nil } +func parseSPN(response modem.Response) string { + value := valueAfterPrefix(response, "+CRSM:") + fields := csvValues(value) + if len(fields) < 3 { + return "" + } + sw1, sw1Err := strconv.Atoi(strings.TrimSpace(fields[0])) + sw2, sw2Err := strconv.Atoi(strings.TrimSpace(fields[1])) + if sw1Err != nil || sw2Err != nil || (sw1 != 0x90 && sw1 != 0x91 && sw1 != 0x9f) || sw2 < 0 || sw2 > 255 { + return "" + } + raw, err := hex.DecodeString(strings.Trim(strings.TrimSpace(fields[2]), `"`)) + if err != nil || len(raw) < 2 { + return "" + } + alpha := raw[1:] // byte 0 is the display-condition bit field. + for len(alpha) > 0 && (alpha[len(alpha)-1] == 0xff || alpha[len(alpha)-1] == 0x00) { + alpha = alpha[:len(alpha)-1] + } + if len(alpha) == 0 { + return "" + } + if alpha[0] == 0x80 { + ucs2 := alpha[1:] + if len(ucs2)%2 != 0 { + ucs2 = ucs2[:len(ucs2)-1] + } + units := make([]uint16, 0, len(ucs2)/2) + for index := 0; index+1 < len(ucs2); index += 2 { + unit := uint16(ucs2[index])<<8 | uint16(ucs2[index+1]) + if unit != 0xffff && unit != 0 { + units = append(units, unit) + } + } + return strings.TrimSpace(string(utf16.Decode(units))) + } + // EF_SPN uses the unpacked GSM default alphabet. Its printable Latin subset + // is byte-compatible with UTF-8/ASCII and covers operator brands in practice. + printable := make([]byte, 0, len(alpha)) + for _, value := range alpha { + if value >= 0x20 && value <= 0x7e { + printable = append(printable, value) + } + } + return strings.TrimSpace(string(printable)) +} + func parseRegistrationStatus(response modem.Response) (int, bool) { for _, prefix := range []string{"+CEREG:", "+CGREG:", "+CREG:"} { values := csvValues(valueAfterPrefix(response, prefix)) diff --git a/internal/device/types.go b/internal/device/types.go index 36e2617..fe148ca 100644 --- a/internal/device/types.go +++ b/internal/device/types.go @@ -27,6 +27,7 @@ type NetworkRequest struct { Enabled bool `json:"enabled"` APN string `json:"apn"` IPVersion string `json:"ipVersion"` + Backend string `json:"backend,omitempty"` } type NetworkResult struct { @@ -81,6 +82,7 @@ type Snapshot struct { Firmware string `json:"firmware"` SIMStatus string `json:"simStatus"` SIMReady bool `json:"simReady"` + SIMChanged bool `json:"simChanged,omitempty"` SignalRaw *int `json:"signalRaw,omitempty"` SignalPercent *int `json:"signalPercent,omitempty"` RSSIDBm *int `json:"rssiDbm,omitempty"` @@ -98,6 +100,7 @@ type Snapshot struct { IMEI string `json:"imei"` ICCID string `json:"iccid"` IMSI string `json:"imsi"` + SPN string `json:"spn,omitempty"` OperatingMode int `json:"operatingMode"` ModeKnown bool `json:"modeKnown"` FlightMode bool `json:"flightMode"` diff --git a/internal/modem/discovery.go b/internal/modem/discovery.go index d510481..69d8671 100644 --- a/internal/modem/discovery.go +++ b/internal/modem/discovery.go @@ -221,7 +221,13 @@ func readSerialAliases(root string) map[string]string { func candidateID(productID, serialNumber, usbName string) string { serialNumber = strings.TrimSpace(serialNumber) if serialNumber != "" && !strings.EqualFold(serialNumber, "android") { - return "quectel-" + sanitizeID(serialNumber) + // A surprising number of EC20/EC25 carrier boards expose the same + // factory/default USB serial number. The device manager is keyed by this + // value, so using the serial alone silently collapsed two modems connected + // to the same hub into one entry. Include the physical USB topology in the + // discovery key; configured devices remain stable through ATMapper's + // USB-path/IMEI matching even when Linux renumbers ttyUSB nodes. + return "quectel-" + sanitizeID(serialNumber+"-"+usbName) } return "quectel-" + sanitizeID(productID+"-"+usbName) } diff --git a/internal/modem/discovery_test.go b/internal/modem/discovery_test.go index 950306c..0dd1c72 100644 --- a/internal/modem/discovery_test.go +++ b/internal/modem/discovery_test.go @@ -171,6 +171,51 @@ func TestSysFSDiscoverySelectsATPortForSecondQMIUSBModem(t *testing.T) { } } +func TestSysFSDiscoveryDoesNotCollapseModemsWithSharedFactorySerial(t *testing.T) { + root := t.TempDir() + sysRoot := filepath.Join(root, "sys") + devRoot := filepath.Join(root, "dev") + usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices") + + for index, item := range []struct { + usbName string + ttyBase int + }{ + {usbName: "1-5.1", ttyBase: 0}, + {usbName: "1-5.2", ttyBase: 4}, + } { + mustWrite(t, filepath.Join(usbRoot, item.usbName, "idVendor"), "2c7c\n") + mustWrite(t, filepath.Join(usbRoot, item.usbName, "idProduct"), "0125\n") + mustWrite(t, filepath.Join(usbRoot, item.usbName, "serial"), "0123456789ABCDEF\n") + for number := 0; number < 4; number++ { + interfaceName := item.usbName + ":1." + strconv.Itoa(number) + tty := fmt.Sprintf("ttyUSB%d", item.ttyBase+number) + mustWrite(t, filepath.Join(usbRoot, interfaceName, "bInterfaceNumber"), fmt.Sprintf("%02x\n", number)) + mustMkdir(t, filepath.Join(usbRoot, interfaceName, tty, "tty", tty)) + } + mustMkdir(t, filepath.Join(usbRoot, item.usbName+":1.4", "usbmisc", fmt.Sprintf("cdc-wdm%d", index))) + } + + candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background()) + if err != nil { + t.Fatalf("Discover: %v", err) + } + if len(candidates) != 2 { + t.Fatalf("got %d candidates, want 2", len(candidates)) + } + if candidates[0].ID == candidates[1].ID { + t.Fatalf("shared factory serial collapsed discovery IDs to %q", candidates[0].ID) + } + for _, candidate := range candidates { + if candidate.SerialNumber != "0123456789ABCDEF" { + t.Fatalf("serial = %q", candidate.SerialNumber) + } + if candidate.ATPort.Role != PortRoleAT { + t.Fatalf("AT port = %#v", candidate.ATPort) + } + } +} + func TestSysFSDiscoveryIgnoresNonQuectelUSB(t *testing.T) { root := t.TempDir() usbRoot := filepath.Join(root, "sys", "bus", "usb", "devices") diff --git a/internal/server/automatic_task_notifications.go b/internal/server/automatic_task_notifications.go new file mode 100644 index 0000000..e71f8f6 --- /dev/null +++ b/internal/server/automatic_task_notifications.go @@ -0,0 +1,306 @@ +package server + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "crypto/tls" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "mime" + "net" + "net/http" + "net/mail" + "net/smtp" + "strconv" + "strings" + "time" + + "vocat/internal/store" +) + +type automaticTaskNotification struct { + Title string + Text string + Time time.Time + Task store.AutomaticTask + Run store.AutomaticTaskRun +} + +func (s *Server) notifyAutomaticTask(ctx context.Context, task store.AutomaticTask, run store.AutomaticTaskRun) { + deviceLabel := task.DeviceID + if configured, err := s.store.Device(ctx, task.DeviceID); err == nil { + deviceLabel = firstNonEmpty(configured.Name, configured.ID) + } + status := "成功" + detail := firstNonEmpty(run.Output, "任务已完成") + if run.Status != "success" { + status = "失败" + detail = firstNonEmpty(run.Error, "未知错误") + } + taskType := map[string]string{"sms": "发送短信", "call": "拨打电话", "public_ip": "获取漫游公网 IP"}[task.TaskType] + environment := map[string]string{"vowifi": "VoWiFi", "cellular": "基站直连"}[task.Environment] + notification := automaticTaskNotification{ + Title: "自动任务执行" + status, + Text: strings.Join([]string{ + "自动任务执行" + status, + "任务 " + task.Name, + "设备 " + deviceLabel, + "类型 " + firstNonEmpty(taskType, task.TaskType), + "环境 " + firstNonEmpty(environment, task.Environment), + "时间 " + run.FinishedAt.Local().Format("2006-01-02 15:04:05"), + "结果 " + detail, + }, "\n"), + Time: run.FinishedAt, Task: task, Run: run, + } + for _, channel := range []string{"telegram", "bark", "email", "pushplus", "webhook"} { + setting, err := s.store.NotificationSetting(ctx, channel) + if errors.Is(err, store.ErrNotFound) || (err == nil && !setting.Enabled) { + continue + } + if err != nil { + s.logger.Warn("read automatic task notification setting", "channel", channel, "error", err) + continue + } + var config map[string]any + if err := json.Unmarshal(setting.Config, &config); err != nil { + s.logger.Warn("decode automatic task notification setting", "channel", channel, "error", err) + continue + } + if err := sendAutomaticTaskNotification(ctx, channel, config, notification); err != nil { + s.logger.Warn("send automatic task notification", "channel", channel, "task_id", task.ID, "error", err) + } + } +} + +func sendAutomaticTaskNotification(ctx context.Context, channel string, config map[string]any, message automaticTaskNotification) error { + switch channel { + case "telegram": + return sendTelegramTextNotification(ctx, config, message.Text) + case "bark": + return sendBarkTextNotification(ctx, config, message.Title, message.Text) + case "email": + return sendEmailTextNotification(ctx, config, message.Title, message.Text) + case "pushplus": + return sendPushplusTextNotification(ctx, config, message.Title, message.Text) + case "webhook": + return sendAutomaticTaskWebhook(ctx, config, message) + default: + return fmt.Errorf("unsupported notification channel %q", channel) + } +} + +func sendTelegramTextNotification(ctx context.Context, config map[string]any, text string) error { + token := configString(config, "bot_token") + parsed, err := validateTelegramAPIURL(ctx, configString(config, "base_url"), token, "sendMessage") + if err != nil { + return err + } + client, err := restrictedHTTPClient(ctx, 8*time.Second, configString(config, "proxy")) + if err != nil { + return err + } + payload, _ := json.Marshal(map[string]any{"chat_id": configString(config, "chat_id"), "text": text}) + request, err := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), bytes.NewReader(payload)) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("User-Agent", "vocat-automatic-task/1") + return performNotificationRequest(client, request, true) +} + +func sendBarkTextNotification(ctx context.Context, config map[string]any, title, text string) error { + client, err := restrictedHTTPClient(ctx, 8*time.Second, "") + if err != nil { + return err + } + payload := map[string]any{"title": title, "body": text} + for _, field := range []string{"group", "icon", "level"} { + if value := configString(config, field); value != "" { + payload[field] = value + } + } + encoded, _ := json.Marshal(payload) + for _, destination := range configStrings(config, "urls") { + parsed, err := validateOutboundURL(ctx, destination, false) + if err != nil { + return err + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), bytes.NewReader(encoded)) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/json; charset=utf-8") + request.Header.Set("User-Agent", "vocat-automatic-task/1") + if err := performNotificationRequest(client, request, false); err != nil { + return err + } + } + return nil +} + +func sendPushplusTextNotification(ctx context.Context, config map[string]any, title, text string) error { + destination, err := validateOutboundURL(ctx, "https://www.pushplus.plus/send", true) + if err != nil { + return err + } + payload := map[string]any{"token": configString(config, "token"), "title": title, "content": text, "template": "txt", "timestamp": time.Now().UnixMilli()} + if topic := configString(config, "topic"); topic != "" { + payload["topic"] = topic + } + if channel := configString(config, "channel"); channel != "" { + payload["channel"] = channel + } + encoded, _ := json.Marshal(payload) + client, err := restrictedHTTPClient(ctx, 8*time.Second, "") + if err != nil { + return err + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, destination.String(), bytes.NewReader(encoded)) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/json; charset=utf-8") + request.Header.Set("User-Agent", "vocat-automatic-task/1") + response, err := client.Do(request) + if err != nil { + return err + } + defer response.Body.Close() + body, _ := io.ReadAll(io.LimitReader(response.Body, 64<<10)) + var result struct { + Code int `json:"code"` + Msg string `json:"msg"` + } + if response.StatusCode < 200 || response.StatusCode >= 300 || json.Unmarshal(body, &result) != nil || result.Code != 200 { + return fmt.Errorf("%w: Pushplus HTTP %d code %d %s", errProviderRejected, response.StatusCode, result.Code, result.Msg) + } + return nil +} + +func sendAutomaticTaskWebhook(ctx context.Context, config map[string]any, message automaticTaskNotification) error { + payload, _ := json.Marshal(map[string]any{ + "event": "automatic_task.completed", "message": message.Text, + "timestamp": message.Time.UTC().Format(time.RFC3339), "task_id": message.Task.ID, + "task_name": message.Task.Name, "device_id": message.Task.DeviceID, + "task_type": message.Task.TaskType, "environment": message.Task.Environment, + "status": message.Run.Status, "attempts": message.Run.Attempts, + "output": message.Run.Output, "error": message.Run.Error, + }) + client, err := restrictedHTTPClient(ctx, durationMilliseconds(configInt(config, "timeout_ms"), 5*time.Second), "") + if err != nil { + return err + } + for _, destination := range configStrings(config, "urls") { + parsed, err := validateOutboundURL(ctx, destination, false) + if err != nil { + return err + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), bytes.NewReader(payload)) + if err != nil { + return err + } + for name, value := range configStringMap(config, "headers") { + request.Header.Set(name, value) + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("User-Agent", "vocat-automatic-task/1") + if secret := configString(config, "secret"); secret != "" { + signature := hmac.New(sha256.New, []byte(secret)) + _, _ = signature.Write(payload) + request.Header.Set("X-vocat-Signature", "sha256="+hex.EncodeToString(signature.Sum(nil))) + } + if err := performNotificationRequest(client, request, false); err != nil { + return err + } + } + return nil +} + +func sendEmailTextNotification(ctx context.Context, config map[string]any, subject, text string) error { + host := strings.TrimSpace(configString(config, "smtp_host")) + port := configInt(config, "smtp_port") + if port == 0 { + port = 587 + } + timeout := 8 * time.Second + connection, err := dialRestricted(ctx, "tcp", net.JoinHostPort(host, strconv.Itoa(port)), timeout) + if err != nil { + return err + } + defer connection.Close() + if err := connection.SetDeadline(time.Now().Add(timeout)); err != nil { + return err + } + tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12, ServerName: host} + useSSL, _ := config["use_ssl"].(bool) + implicitTLS := port == 465 || useSSL + if implicitTLS { + secure := tls.Client(connection, tlsConfig) + if err := secure.HandshakeContext(ctx); err != nil { + return err + } + connection = secure + } + client, err := smtp.NewClient(connection, host) + if err != nil { + return err + } + defer client.Close() + if !implicitTLS { + if available, _ := client.Extension("STARTTLS"); !available { + return errors.New("SMTP server does not offer STARTTLS") + } + if err := client.StartTLS(tlsConfig); err != nil { + return err + } + } + username, password := configString(config, "username"), configString(config, "password") + if username != "" { + if err := client.Auth(smtp.PlainAuth("", username, password, host)); err != nil { + return err + } + } + from, err := mail.ParseAddress(configString(config, "from_address")) + if err != nil { + return err + } + var recipients []*mail.Address + for _, item := range configStrings(config, "to_addresses") { + address, err := mail.ParseAddress(item) + if err != nil { + return err + } + recipients = append(recipients, address) + } + if err := client.Mail(from.Address); err != nil { + return err + } + for _, recipient := range recipients { + if err := client.Rcpt(recipient.Address); err != nil { + return err + } + } + writer, err := client.Data() + if err != nil { + return err + } + email := strings.Join([]string{ + "Date: " + time.Now().UTC().Format(time.RFC1123Z), "From: " + from.String(), + "To: " + joinMailAddresses(recipients), "Subject: " + mime.QEncoding.Encode("UTF-8", subject), + "MIME-Version: 1.0", "Content-Type: text/plain; charset=UTF-8", "Content-Transfer-Encoding: 8bit", "", text, "", + }, "\r\n") + if _, err := io.WriteString(writer, email); err != nil { + _ = writer.Close() + return err + } + if err := writer.Close(); err != nil { + return err + } + return client.Quit() +} diff --git a/internal/server/automatic_tasks.go b/internal/server/automatic_tasks.go new file mode 100644 index 0000000..807fcc7 --- /dev/null +++ b/internal/server/automatic_tasks.go @@ -0,0 +1,651 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync" + "time" + + "vocat/internal/device" + "vocat/internal/exportproxy" + "vocat/internal/store" +) + +const ( + automaticTaskPollInterval = 5 * time.Second + automaticTaskMaxRuntime = 8 * time.Minute +) + +type automaticTaskPayload struct { + Phone string `json:"phone,omitempty"` + Message string `json:"message,omitempty"` + DurationSeconds int `json:"duration_seconds,omitempty"` +} + +type automaticTaskExecutionError struct { + err error + retryable bool +} + +func (value automaticTaskExecutionError) Error() string { return value.err.Error() } +func (value automaticTaskExecutionError) Unwrap() error { return value.err } + +type automaticTaskScheduler struct { + server *Server + ctx context.Context + mu sync.Mutex + queues map[string]chan store.AutomaticTaskRun +} + +func (s *Server) StartAutomaticTasks(ctx context.Context) { + if ctx == nil { + ctx = context.Background() + } + scheduler := &automaticTaskScheduler{server: s, ctx: ctx, queues: make(map[string]chan store.AutomaticTaskRun)} + s.automaticTasks = scheduler + go scheduler.run() +} + +func (scheduler *automaticTaskScheduler) run() { + ticker := time.NewTicker(automaticTaskPollInterval) + defer ticker.Stop() + scheduler.claim() + for { + select { + case <-scheduler.ctx.Done(): + return + case <-ticker.C: + scheduler.claim() + } + } +} + +func (scheduler *automaticTaskScheduler) claim() { + runs, err := scheduler.server.store.ClaimDueAutomaticTasks(scheduler.ctx, time.Now().UTC(), 50) + if err != nil { + scheduler.server.logger.Warn("claim automatic tasks", "error", err) + return + } + for _, run := range runs { + scheduler.enqueue(run) + } +} + +func (scheduler *automaticTaskScheduler) enqueue(run store.AutomaticTaskRun) { + deviceID := strings.TrimSpace(run.DeviceID) + scheduler.mu.Lock() + queue := scheduler.queues[deviceID] + if queue == nil { + queue = make(chan store.AutomaticTaskRun, 100) + scheduler.queues[deviceID] = queue + go scheduler.worker(deviceID, queue) + } + scheduler.mu.Unlock() + select { + case queue <- run: + case <-scheduler.ctx.Done(): + } +} + +func (scheduler *automaticTaskScheduler) worker(deviceID string, queue <-chan store.AutomaticTaskRun) { + for { + select { + case <-scheduler.ctx.Done(): + return + case run := <-queue: + scheduler.execute(run) + } + } +} + +func (scheduler *automaticTaskScheduler) execute(run store.AutomaticTaskRun) { + task, err := scheduler.server.store.AutomaticTask(scheduler.ctx, run.TaskID) + if err != nil { + run.Status, run.Error, run.FinishedAt = "failed", err.Error(), time.Now().UTC() + _ = scheduler.server.store.UpdateAutomaticTaskRun(context.Background(), run) + return + } + run.Status, run.StartedAt = "running", time.Now().UTC() + _ = scheduler.server.store.UpdateAutomaticTaskRun(context.Background(), run) + var output string + for attempt := 1; attempt <= task.RetryCount+1; attempt++ { + run.Attempts = attempt + _ = scheduler.server.store.UpdateAutomaticTaskRun(context.Background(), run) + operationContext, cancel := context.WithTimeout(scheduler.ctx, automaticTaskMaxRuntime) + output, err = scheduler.server.executeAutomaticTask(operationContext, task) + cancel() + if err == nil { + break + } + var executionError automaticTaskExecutionError + if errors.As(err, &executionError) && !executionError.retryable { + break + } + if attempt <= task.RetryCount { + scheduler.server.logger.Warn("automatic task attempt failed", "task_id", task.ID, "device_id", task.DeviceID, "attempt", attempt, "error", err) + select { + case <-scheduler.ctx.Done(): + break + case <-time.After(time.Duration(attempt*5) * time.Second): + } + } + } + run.FinishedAt = time.Now().UTC() + if err == nil { + run.Status, run.Output, run.Error = "success", output, "" + } else { + run.Status, run.Error = "failed", err.Error() + } + if updateErr := scheduler.server.store.UpdateAutomaticTaskRun(context.Background(), run); updateErr != nil { + scheduler.server.logger.Warn("finish automatic task run", "run_id", run.ID, "error", updateErr) + } + if task.Notify { + go scheduler.server.notifyAutomaticTask(context.Background(), task, run) + } +} + +func (s *Server) executeAutomaticTask(ctx context.Context, task store.AutomaticTask) (string, error) { + config, entry, physicalID, err := s.ensureAutomaticTaskProfile(ctx, task) + if err != nil { + return "", err + } + networkWasEnabled := config.NetworkEnabled + if err := s.prepareAutomaticTaskEnvironment(ctx, &config, entry, physicalID, task); err != nil { + return "", err + } + var payload automaticTaskPayload + if err := json.Unmarshal(task.Payload, &payload); err != nil { + return "", fmt.Errorf("decode task payload: %w", err) + } + switch task.TaskType { + case "sms": + return s.executeAutomaticSMS(ctx, task, payload) + case "call": + return s.executeAutomaticCall(ctx, task, payload) + case "public_ip": + return s.executeAutomaticPublicIP(ctx, config, physicalID, task.ProfileICCID, networkWasEnabled) + default: + return "", fmt.Errorf("unsupported automatic task type %q", task.TaskType) + } +} + +func (s *Server) ensureAutomaticTaskProfile(ctx context.Context, task store.AutomaticTask) (store.Device, device.Device, string, error) { + config, err := s.store.Device(ctx, task.DeviceID) + if err != nil { + return store.Device{}, device.Device{}, "", fmt.Errorf("read device: %w", err) + } + entry, physicalID, present := s.physicalForConfig(config) + if !present || entry.Snapshot == nil { + return store.Device{}, device.Device{}, "", errors.New("configured device is offline") + } + if strings.EqualFold(strings.TrimSpace(entry.Snapshot.ICCID), strings.TrimSpace(task.ProfileICCID)) { + return config, entry, physicalID, nil + } + if _, err := s.devices.SetFlight(ctx, physicalID, true); err != nil { + return store.Device{}, device.Device{}, "", fmt.Errorf("enter airplane mode before profile switch: %w", err) + } + if err := s.devices.ESIMSwitchProfile(ctx, physicalID, task.ProfileICCID, task.ProfileAID); err != nil { + return store.Device{}, device.Device{}, "", fmt.Errorf("switch eSIM profile: %w", err) + } + entry, physicalID, present = s.physicalForConfig(config) + if !present { + return store.Device{}, device.Device{}, "", errors.New("device did not recover after profile switch") + } + snapshot, err := s.devices.Refresh(ctx, physicalID) + if err != nil { + return store.Device{}, device.Device{}, "", fmt.Errorf("verify switched profile: %w", err) + } + if !strings.EqualFold(strings.TrimSpace(snapshot.ICCID), strings.TrimSpace(task.ProfileICCID)) { + return store.Device{}, device.Device{}, "", fmt.Errorf("profile verification failed: current ICCID is %s", firstNonEmpty(snapshot.ICCID, "unavailable")) + } + entry.Snapshot = &snapshot + return config, entry, physicalID, nil +} + +func (s *Server) prepareAutomaticTaskEnvironment(ctx context.Context, config *store.Device, entry device.Device, physicalID string, task store.AutomaticTask) error { + iccid := strings.TrimSpace(task.ProfileICCID) + if task.Environment == "vowifi" { + if task.TaskType == "public_ip" { + return errors.New("public IP tasks cannot run over VoWiFi") + } + if _, err := s.devices.SetFlight(ctx, physicalID, true); err != nil { + return fmt.Errorf("enable airplane mode for VoWiFi: %w", err) + } + config.VoWiFiEnabled, config.NetworkEnabled = true, false + if err := s.store.UpsertDevice(ctx, *config); err != nil { + return err + } + if err := s.store.UpsertCardPolicy(ctx, store.CardPolicy{ICCID: iccid, VoWiFiEnabled: true, AirplaneEnabled: true, IPVersion: "IPV4V6", Source: "automatic_task"}); err != nil { + return err + } + if s.vowifi == nil { + return errors.New("VoWiFi runtime is unavailable") + } + state, stateErr := s.vowifi.State(config.ID) + stateMatchesCard := state.ICCID == "" || strings.EqualFold(strings.TrimSpace(state.ICCID), iccid) + if stateErr == nil && stateMatchesCard && state.IMSReady && (task.TaskType != "sms" || state.SMSReady) { + return nil + } + if stateErr == nil && state.Enabled { + _, stateErr = s.vowifi.RequestReconnect(config.ID) + } else { + _, stateErr = s.vowifi.RequestEnabled(config.ID, true) + } + if stateErr != nil { + return fmt.Errorf("start VoWiFi: %w", stateErr) + } + return s.waitAutomaticVoWiFi(ctx, config.ID, iccid, task.TaskType == "sms") + } + if s.vowifi != nil { + if state, stateErr := s.vowifi.State(config.ID); stateErr == nil && (state.Enabled || state.Active) { + if _, stateErr = s.vowifi.RequestEnabled(config.ID, false); stateErr != nil { + return fmt.Errorf("stop VoWiFi: %w", stateErr) + } + if err := s.waitAutomaticVoWiFiStopped(ctx, config.ID); err != nil { + return err + } + } + } + config.VoWiFiEnabled = false + config.NetworkEnabled = task.TaskType == "public_ip" + if err := s.store.UpsertDevice(ctx, *config); err != nil { + return err + } + if err := s.store.UpsertCardPolicy(ctx, store.CardPolicy{ICCID: iccid, NetworkEnabled: config.NetworkEnabled, VoWiFiEnabled: false, AirplaneEnabled: false, APN: config.APN, IPVersion: "IPV4V6", Source: "automatic_task"}); err != nil { + return err + } + if task.TaskType != "public_ip" { + if _, err := s.devices.SetNetwork(ctx, physicalID, device.NetworkRequest{Enabled: false, APN: config.APN, IPVersion: "IPV4V6", Backend: config.DeviceBackend}); err != nil { + s.logger.Warn("automatic task could not stop unused cellular data", "device_id", config.ID, "error", err) + } + } + if _, err := s.devices.SetFlight(ctx, physicalID, false); err != nil { + return fmt.Errorf("enable cellular radio: %w", err) + } + if _, err := s.devices.SetOperatorSelection(ctx, physicalID, true, "", nil); err != nil { + return fmt.Errorf("enable automatic network selection: %w", err) + } + if _, err := s.devices.ReRegisterOperator(ctx, physicalID); err != nil { + return fmt.Errorf("re-register cellular network: %w", err) + } + if err := s.waitAutomaticCellular(ctx, physicalID, task.TaskType == "public_ip"); err != nil { + return err + } + if task.TaskType == "public_ip" { + if !s.developerActive(ctx) { + return errors.New("roaming public IP tasks require developer mode") + } + if _, err := s.devices.SetNetwork(ctx, physicalID, device.NetworkRequest{Enabled: true, APN: config.APN, IPVersion: "IPV4V6", Backend: config.DeviceBackend}); err != nil { + s.rollbackAutomaticNetwork(config.ID, physicalID, iccid, *config) + return fmt.Errorf("start roaming data: %w", err) + } + } + return nil +} + +func (s *Server) waitAutomaticVoWiFi(ctx context.Context, deviceID, iccid string, requireSMS bool) error { + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + for { + state, err := s.vowifi.State(deviceID) + if err == nil && state.IMSReady && (!requireSMS || state.SMSReady) && (state.ICCID == "" || strings.EqualFold(state.ICCID, iccid)) { + return nil + } + if err == nil && state.LastError != "" && !state.Active && !state.Enabled { + return errors.New(state.LastError) + } + select { + case <-ctx.Done(): + if err == nil && state.LastError != "" { + return fmt.Errorf("wait for VoWiFi readiness: %s", state.LastError) + } + return fmt.Errorf("wait for VoWiFi readiness: %w", ctx.Err()) + case <-ticker.C: + } + } +} + +func (s *Server) waitAutomaticVoWiFiStopped(ctx context.Context, deviceID string) error { + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + for { + state, err := s.vowifi.State(deviceID) + if err != nil || (!state.Active && !state.Enabled) { + return nil + } + select { + case <-ctx.Done(): + return fmt.Errorf("wait for VoWiFi shutdown: %w", ctx.Err()) + case <-ticker.C: + } + } +} + +func (s *Server) waitAutomaticCellular(ctx context.Context, physicalID string, requirePacketAttach bool) error { + ticker := time.NewTicker(3 * time.Second) + defer ticker.Stop() + stableSamples := 0 + for { + snapshot, err := s.devices.Refresh(ctx, physicalID) + registered := err == nil && (snapshot.RegistrationStatus == 1 || snapshot.RegistrationStatus == 5) + if registered && (!requirePacketAttach || snapshot.PSAttached) { + stableSamples++ + if stableSamples >= 2 { + return nil + } + } else { + stableSamples = 0 + } + if err == nil && snapshot.RegistrationStatus == 3 { + return errors.New("cellular network registration was denied") + } + select { + case <-ctx.Done(): + return fmt.Errorf("wait for cellular registration: %w", ctx.Err()) + case <-ticker.C: + } + } +} + +func (s *Server) executeAutomaticSMS(ctx context.Context, task store.AutomaticTask, payload automaticTaskPayload) (string, error) { + body, _ := json.Marshal(map[string]any{"device_id": task.DeviceID, "phone": payload.Phone, "message": payload.Message}) + recorder := httptest.NewRecorder() + request := httptest.NewRequestWithContext(ctx, http.MethodPost, "/api/sms/send", bytes.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + s.handleSMSSend(recorder, request) + if recorder.Code < 200 || recorder.Code >= 300 { + failure := fmt.Errorf("send SMS failed (HTTP %d): %s", recorder.Code, compactAutomaticResponse(recorder.Body.Bytes())) + // Once any part reached the modem/IMS transaction, retrying the whole + // message could deliver a duplicate. Preparation failures remain safe to + // retry according to the configured count. + return "", automaticTaskExecutionError{err: failure, retryable: automaticSMSRetrySafe(recorder.Body.Bytes())} + } + return "短信已提交到 " + payload.Phone, nil +} + +func automaticSMSRetrySafe(body []byte) bool { + var payload struct { + Data struct { + PartsAttempted int `json:"parts_attempted"` + PartsAccepted int `json:"parts_accepted"` + RetrySafe *bool `json:"retry_safe"` + } `json:"data"` + } + if json.Unmarshal(body, &payload) != nil { + return false + } + if payload.Data.RetrySafe != nil { + return *payload.Data.RetrySafe + } + return payload.Data.PartsAttempted == 0 && payload.Data.PartsAccepted == 0 +} + +func (s *Server) executeAutomaticCall(ctx context.Context, task store.AutomaticTask, payload automaticTaskPayload) (string, error) { + config, err := s.store.Device(ctx, task.DeviceID) + if err != nil { + return "", err + } + _, physicalID, present := s.physicalForConfig(config) + if !present { + return "", errors.New("configured device is offline") + } + body, _ := json.Marshal(map[string]any{"number": payload.Phone, "duration_seconds": payload.DurationSeconds}) + recorder := httptest.NewRecorder() + request := httptest.NewRequestWithContext(ctx, http.MethodPost, "/api/devices/calls/dial", bytes.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + s.handleCallAction(recorder, request, config, physicalID, "dial") + if recorder.Code < 200 || recorder.Code >= 300 { + return "", fmt.Errorf("dial failed (HTTP %d): %s", recorder.Code, compactAutomaticResponse(recorder.Body.Bytes())) + } + return fmt.Sprintf("已拨打 %s,将在 %d 秒后自动挂断", payload.Phone, payload.DurationSeconds), nil +} + +func (s *Server) executeAutomaticPublicIP(ctx context.Context, config store.Device, physicalID, iccid string, networkWasEnabled bool) (string, error) { + if !networkWasEnabled { + defer s.rollbackAutomaticNetwork(config.ID, physicalID, iccid, config) + } + if strings.TrimSpace(config.Interface) == "" { + return "", errors.New("device has no cellular network interface") + } + info, err := exportproxy.LookupPublicIP(ctx, config.Interface) + if err != nil { + return "", fmt.Errorf("detect roaming public IP: %w", err) + } + s.savePublicIP(config.ID, iccid, info) + return strings.TrimSpace(fmt.Sprintf("公网 IP %s · %s %s", info.IP, info.CountryCode, info.Region)), nil +} + +func (s *Server) rollbackAutomaticNetwork(deviceID, physicalID, iccid string, config store.Device) { + cleanupContext, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if _, err := s.devices.SetNetwork(cleanupContext, physicalID, device.NetworkRequest{Enabled: false, APN: config.APN, IPVersion: "IPV4V6", Backend: config.DeviceBackend}); err != nil { + s.logger.Warn("stop one-shot automatic roaming data", "device_id", deviceID, "error", err) + } + config.NetworkEnabled = false + if err := s.store.UpsertDevice(cleanupContext, config); err != nil { + s.logger.Warn("restore automatic roaming data setting", "device_id", deviceID, "error", err) + } + if err := s.store.UpsertCardPolicy(cleanupContext, store.CardPolicy{ICCID: iccid, NetworkEnabled: false, VoWiFiEnabled: false, AirplaneEnabled: false, APN: config.APN, IPVersion: "IPV4V6", Source: "automatic_task"}); err != nil { + s.logger.Warn("restore automatic roaming card policy", "device_id", deviceID, "error", err) + } +} + +func compactAutomaticResponse(body []byte) string { + var payload map[string]any + if json.Unmarshal(body, &payload) == nil { + if apiErr, ok := payload["error"].(map[string]any); ok { + return firstNonEmpty(fmt.Sprint(apiErr["message"]), fmt.Sprint(apiErr["code"]), "request failed") + } + } + return strings.TrimSpace(string(body)) +} + +func (s *Server) routeAutomaticTasksAPI(w http.ResponseWriter, r *http.Request, cleanPath string) bool { + segments := splitAPIPath(cleanPath) + if len(segments) == 0 || segments[0] != "automatic-tasks" { + return false + } + if len(segments) == 1 { + s.handleAutomaticTasks(w, r) + return true + } + id, err := strconv.ParseInt(segments[1], 10, 64) + if err != nil || id <= 0 { + writeError(w, http.StatusBadRequest, "invalid_task_id", "automatic task ID is invalid") + return true + } + if len(segments) == 2 { + s.handleAutomaticTask(w, r, id) + return true + } + if len(segments) == 3 && segments[2] == "run" { + s.handleAutomaticTaskRunNow(w, r, id) + return true + } + writeError(w, http.StatusNotFound, "not_found", "automatic task endpoint not found") + return true +} + +func (s *Server) handleAutomaticTasks(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + tasks, err := s.store.ListAutomaticTasks(r.Context()) + if err != nil { + s.writeStoreError(w, err) + return + } + runs, err := s.store.ListAutomaticTaskRuns(r.Context(), 100) + if err != nil { + s.writeStoreError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"tasks": tasks, "runs": runs}}) + case http.MethodPost: + task, err := s.decodeAutomaticTask(r, 0) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_automatic_task", err.Error()) + return + } + saved, err := s.store.SaveAutomaticTask(r.Context(), task) + if err != nil { + s.writeStoreError(w, err) + return + } + writeJSON(w, http.StatusCreated, map[string]any{"data": saved}) + default: + w.Header().Set("Allow", "GET, POST") + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + } +} + +func (s *Server) handleAutomaticTask(w http.ResponseWriter, r *http.Request, id int64) { + switch r.Method { + case http.MethodPut: + task, err := s.decodeAutomaticTask(r, id) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_automatic_task", err.Error()) + return + } + saved, err := s.store.SaveAutomaticTask(r.Context(), task) + if err != nil { + s.writeStoreError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"data": saved}) + case http.MethodDelete: + if err := s.store.DeleteAutomaticTask(r.Context(), id); err != nil { + s.writeStoreError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"deleted": true}}) + default: + w.Header().Set("Allow", "PUT, DELETE") + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + } +} + +func (s *Server) handleAutomaticTaskRunNow(w http.ResponseWriter, r *http.Request, id int64) { + if !requireMethod(w, r, http.MethodPost) { + return + } + if s.automaticTasks == nil { + writeError(w, http.StatusServiceUnavailable, "scheduler_unavailable", "automatic task scheduler is unavailable") + return + } + task, err := s.store.AutomaticTask(r.Context(), id) + if err != nil { + s.writeStoreError(w, err) + return + } + run, err := s.store.QueueAutomaticTaskNow(r.Context(), task) + if err != nil { + s.writeStoreError(w, err) + return + } + s.automaticTasks.enqueue(run) + writeJSON(w, http.StatusAccepted, map[string]any{"data": run}) +} + +func (s *Server) decodeAutomaticTask(r *http.Request, id int64) (store.AutomaticTask, error) { + var request struct { + Name string `json:"name"` + Enabled bool `json:"enabled"` + DeviceID string `json:"device_id"` + ProfileICCID string `json:"profile_iccid"` + ProfileAID string `json:"profile_aid"` + TaskType string `json:"task_type"` + Environment string `json:"environment"` + IntervalDays int `json:"interval_days"` + StartDate string `json:"start_date"` + RunTime string `json:"run_time"` + Timezone string `json:"timezone"` + RetryCount int `json:"retry_count"` + Notify bool `json:"notify"` + Payload automaticTaskPayload `json:"payload"` + } + if err := s.decodeJSON(nilResponseWriter{}, r, &request); err != nil { + return store.AutomaticTask{}, err + } + request.Name, request.DeviceID = strings.TrimSpace(request.Name), strings.TrimSpace(request.DeviceID) + request.ProfileICCID, request.ProfileAID = strings.TrimSpace(request.ProfileICCID), strings.TrimSpace(request.ProfileAID) + request.TaskType, request.Environment = strings.ToLower(strings.TrimSpace(request.TaskType)), strings.ToLower(strings.TrimSpace(request.Environment)) + if request.Name == "" || request.DeviceID == "" || request.ProfileICCID == "" { + return store.AutomaticTask{}, errors.New("name, device, and eSIM profile are required") + } + if _, err := s.store.Device(r.Context(), request.DeviceID); err != nil { + return store.AutomaticTask{}, errors.New("selected device does not exist") + } + if request.Environment != "vowifi" && request.Environment != "cellular" { + return store.AutomaticTask{}, errors.New("environment must be vowifi or cellular") + } + if request.TaskType != "sms" && request.TaskType != "call" && request.TaskType != "public_ip" { + return store.AutomaticTask{}, errors.New("unsupported task type") + } + if request.TaskType == "public_ip" && request.Environment != "cellular" { + return store.AutomaticTask{}, errors.New("public IP tasks must use cellular direct mode") + } + if request.IntervalDays < 1 || request.IntervalDays > 365 || request.RetryCount < 0 || request.RetryCount > 10 { + return store.AutomaticTask{}, errors.New("interval_days must be 1-365 and retry_count must be 0-10") + } + if request.TaskType == "sms" { + if !validDialNumber(request.Payload.Phone) || strings.TrimSpace(request.Payload.Message) == "" { + return store.AutomaticTask{}, errors.New("SMS phone and message are required") + } + if blocked, reason := blockedSMSDestination(request.Payload.Phone); blocked { + return store.AutomaticTask{}, errors.New(reason) + } + } + if request.TaskType == "call" && (!validDialNumber(request.Payload.Phone) || request.Payload.DurationSeconds < 1 || request.Payload.DurationSeconds > 600) { + return store.AutomaticTask{}, errors.New("call phone is required and automatic hang-up must be 1-600 seconds") + } + request.Timezone = strings.TrimSpace(request.Timezone) + if request.Timezone == "" { + request.Timezone = time.Local.String() + } + location, err := time.LoadLocation(request.Timezone) + if err != nil { + return store.AutomaticTask{}, errors.New("timezone must be a valid IANA time zone") + } + nextRun, err := nextAutomaticRun(request.StartDate, request.RunTime, request.IntervalDays, time.Now().In(location)) + if err != nil { + return store.AutomaticTask{}, err + } + payload, _ := json.Marshal(request.Payload) + task := store.AutomaticTask{ID: id, Name: request.Name, Enabled: request.Enabled, DeviceID: request.DeviceID, + ProfileICCID: request.ProfileICCID, ProfileAID: request.ProfileAID, TaskType: request.TaskType, + Environment: request.Environment, IntervalDays: request.IntervalDays, StartDate: request.StartDate, + RunTime: request.RunTime, Timezone: request.Timezone, Payload: payload, RetryCount: request.RetryCount, Notify: request.Notify, NextRunAt: nextRun.UTC()} + if id != 0 { + if previous, previousErr := s.store.AutomaticTask(r.Context(), id); previousErr == nil { + task.CreatedAt, task.LastRunAt, task.LastStatus, task.LastError = previous.CreatedAt, previous.LastRunAt, previous.LastStatus, previous.LastError + } + } + return task, nil +} + +func nextAutomaticRun(date, clock string, intervalDays int, now time.Time) (time.Time, error) { + location := now.Location() + start, err := time.ParseInLocation("2006-01-02 15:04", strings.TrimSpace(date)+" "+strings.TrimSpace(clock), location) + if err != nil { + return time.Time{}, errors.New("start_date and run_time must use YYYY-MM-DD and HH:MM") + } + for start.Before(now) { + start = start.AddDate(0, 0, intervalDays) + } + return start, nil +} + +// nilResponseWriter is used only because decodeJSON's size/error contract is +// shared with HTTP handlers; decode errors are returned to the real handler. +type nilResponseWriter struct{} + +func (nilResponseWriter) Header() http.Header { return make(http.Header) } +func (nilResponseWriter) Write([]byte) (int, error) { return 0, nil } +func (nilResponseWriter) WriteHeader(statusCode int) {} diff --git a/internal/server/automatic_tasks_test.go b/internal/server/automatic_tasks_test.go new file mode 100644 index 0000000..9d48971 --- /dev/null +++ b/internal/server/automatic_tasks_test.go @@ -0,0 +1,30 @@ +package server + +import ( + "testing" + "time" +) + +func TestNextAutomaticRunUsesIntervalAndLocalClock(t *testing.T) { + location := time.FixedZone("test", 8*60*60) + now := time.Date(2026, 8, 10, 12, 0, 0, 0, location) + next, err := nextAutomaticRun("2026-08-01", "09:30", 3, now) + if err != nil { + t.Fatal(err) + } + want := time.Date(2026, 8, 13, 9, 30, 0, 0, location) + if !next.Equal(want) { + t.Fatalf("next run = %v, want %v", next, want) + } +} + +func TestAutomaticSMSRetrySafetyPreventsDuplicateSubmission(t *testing.T) { + unsafe := []byte(`{"data":{"parts_attempted":1,"parts_accepted":1,"retry_safe":false}}`) + if automaticSMSRetrySafe(unsafe) { + t.Fatal("partially submitted SMS was considered safe to retry") + } + safe := []byte(`{"data":{"parts_attempted":0,"parts_accepted":0}}`) + if !automaticSMSRetrySafe(safe) { + t.Fatal("unattempted SMS was not considered safe to retry") + } +} diff --git a/internal/server/device_api.go b/internal/server/device_api.go index 6b3e060..1b392ff 100644 --- a/internal/server/device_api.go +++ b/internal/server/device_api.go @@ -236,14 +236,46 @@ func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) bool { 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 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 != "" { + if err := s.store.UpsertCardPolicy(r.Context(), store.CardPolicy{ + ICCID: iccid, VoWiFiEnabled: true, AirplaneEnabled: true, + IPVersion: "IPV4V6", Source: "default", + }); err != nil { + s.writeStoreError(w, err) + 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", @@ -416,9 +448,20 @@ func (s *Server) handleDevicePath( } 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 _, physicalID, present := s.physicalForConfig(next); present { + 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 @@ -435,7 +478,7 @@ func (s *Server) handleDevicePath( entry, physicalID, physicalPresent := s.physicalForConfig(config) if len(tail) > 0 && tail[0] == "esim" { - return s.handleESIM(w, r, tail[1:], physicalID, physicalPresent) + return s.handleESIM(w, r, tail[1:], physicalID, physicalPresent, config.ID) } switch strings.Join(tail, "/") { case "overview": @@ -503,7 +546,7 @@ func (s *Server) handleDevicePath( if !s.requirePhysicalDevice(w, physicalPresent) { return true } - return s.handleFlightMode(w, r, physicalID) + return s.handleFlightMode(w, r, config, physicalID) case "network": if !s.requirePhysicalDevice(w, physicalPresent) { return true @@ -756,9 +799,59 @@ func (s *Server) handleVoWiFiEnabled( } } + // 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 } @@ -780,6 +873,7 @@ func (s *Server) handleVoWiFiEnabled( 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", @@ -983,7 +1077,7 @@ func (s *Server) handleUSSD(w http.ResponseWriter, r *http.Request, id string) b return true } -func (s *Server) handleFlightMode(w http.ResponseWriter, r *http.Request, id string) bool { +func (s *Server) handleFlightMode(w http.ResponseWriter, r *http.Request, config store.Device, physicalID string) bool { if !requireMethod(w, r, http.MethodPatch) { return true } @@ -994,7 +1088,11 @@ func (s *Server) handleFlightMode(w http.ResponseWriter, r *http.Request, id str writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) return true } - result, err := s.devices.SetFlight(r.Context(), id, request.Enabled) + 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 @@ -1002,7 +1100,7 @@ func (s *Server) handleFlightMode(w http.ResponseWriter, r *http.Request, id str // 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(id); getErr == nil && entry.Snapshot != nil { + 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) @@ -1073,7 +1171,7 @@ func (s *Server) handleCellularData( controller := http.NewResponseController(w) _ = controller.SetWriteDeadline(time.Time{}) result, err := s.devices.SetNetwork(r.Context(), physicalID, device.NetworkRequest{ - Enabled: request.Enabled, APN: apn, IPVersion: "IPV4V6", + Enabled: request.Enabled, APN: apn, IPVersion: "IPV4V6", Backend: config.DeviceBackend, }) if err != nil { s.writeDeviceError(w, err) @@ -1087,7 +1185,7 @@ func (s *Server) handleCellularData( if err := s.store.UpsertDevice(r.Context(), config); err != nil { rollbackContext, cancel := context.WithTimeout(context.Background(), 20*time.Second) _, _ = s.devices.SetNetwork(rollbackContext, physicalID, device.NetworkRequest{ - Enabled: previous, APN: config.APN, IPVersion: "IPV4V6", + Enabled: previous, APN: config.APN, IPVersion: "IPV4V6", Backend: config.DeviceBackend, }) cancel() s.writeStoreError(w, err) @@ -1266,28 +1364,46 @@ func (s *Server) configuredDeviceSummary( result["network_connected"] = config.NetworkEnabled result["data_connected"] = config.NetworkEnabled result["vowifi_enabled"] = config.VoWiFiEnabled - 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) + 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)) } } - runtimeMatchesCard := currentICCID == "" || runtime.ICCID == "" || - strings.EqualFold(currentICCID, strings.TrimSpace(runtime.ICCID)) - var runtimeResponse map[string]any - 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) - } - result["vowifi_runtime"] = runtimeResponse - result["vowifi_active"] = config.VoWiFiEnabled && runtimeMatchesCard && runtime.TunnelReady } + 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 { @@ -1390,9 +1506,14 @@ func (s *Server) configuredDeviceStatus( } 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, @@ -1416,6 +1537,63 @@ func storedVoWiFiRuntime(runtime store.VoWiFiRuntime) map[string]any { } } +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 { @@ -1569,6 +1747,7 @@ func modemSummary(snapshot *device.Snapshot, phone string, phoneSource string) m "operator": "", "native_mcc": "", "native_mnc": "", + "native_spn": "", "operator_country_code": "", "card_mcc": "", "card_mnc": "", @@ -1598,6 +1777,7 @@ func modemSummary(snapshot *device.Snapshot, phone string, phoneSource string) m "operator": snapshot.OperatorName, "native_mcc": mcc, "native_mnc": mnc, + "native_spn": snapshot.SPN, "operator_country_code": operatorCountryCode, "card_mcc": cardMCC, "card_mnc": cardMNC, @@ -1643,6 +1823,8 @@ func idleVoWiFiRuntime(id string, snapshot *device.Snapshot) map[string]any { return map[string]any{ "device_id": id, "phase": "idle", + "enabled": false, + "active": false, "dataplane_mode": "", "iccid": iccid, "imsi": imsi, diff --git a/internal/server/device_features_api_test.go b/internal/server/device_features_api_test.go index 3fb80ad..f907c76 100644 --- a/internal/server/device_features_api_test.go +++ b/internal/server/device_features_api_test.go @@ -31,6 +31,28 @@ func decodeData(t *testing.T, recorder *httptest.ResponseRecorder) map[string]an return envelope.Data } +type esimAIDCaptureController struct { + fakeDeviceController + switchAID string + disableAID string + renameAID string +} + +func (controller *esimAIDCaptureController) ESIMSwitchProfile(_ context.Context, _, _, aidHex string) error { + controller.switchAID = aidHex + return nil +} + +func (controller *esimAIDCaptureController) ESIMDisableProfile(_ context.Context, _, _, aidHex string) error { + controller.disableAID = aidHex + return nil +} + +func (controller *esimAIDCaptureController) ESIMRenameProfile(_ context.Context, _, _, _, aidHex string) error { + controller.renameAID = aidHex + return nil +} + func TestAttachSingleEUICCIdentityFillsProfileGroupMetadataKey(t *testing.T) { groups := []map[string]any{{"eid": "", "aidHex": "", "profiles": []any{}}} chipInfo := map[string]any{ @@ -256,9 +278,18 @@ func TestHandleESIMShapes(t *testing.T) { } // Switch happy path: a present device + fake controller switches by ICCID. - present := &Server{logger: regionTestLogger(), maxRequestBodyBytes: 4096, devices: fakeDeviceController{}} + database, err := store.Open(context.Background(), ":memory:") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := database.UpsertDevice(context.Background(), store.Device{ID: "dev1", Name: "dev1"}); err != nil { + t.Fatal(err) + } + controller := &esimAIDCaptureController{} + present := &Server{store: database, logger: regionTestLogger(), maxRequestBodyBytes: 4096, devices: controller} swOK := httptest.NewRecorder() - swReq := httptest.NewRequest(http.MethodPost, "/esim/actions/switch", strings.NewReader(`{"iccid":"8900000000000000001","aid_hex":"A0"}`)) + swReq := httptest.NewRequest(http.MethodPost, "/esim/actions/switch", strings.NewReader(`{"iccid":"8900000000000000001","aidHex":"A0000005591010FFFFFFFF8900000177"}`)) swReq.Header.Set("Content-Type", "application/json") present.handleESIM(swOK, swReq, []string{"actions", "switch"}, "dev1", true) if swOK.Code != http.StatusOK { @@ -267,10 +298,13 @@ func TestHandleESIMShapes(t *testing.T) { if data := decodeData(t, swOK); data["status"] != "switched" || data["verified"] != true { t.Fatalf("switch data = %v", data) } + if controller.switchAID != "A0000005591010FFFFFFFF8900000177" { + t.Fatalf("switch AID = %q, want XeSIM camelCase AID", controller.switchAID) + } // Disable happy path routes the active profile to ES10c DisableProfile. disableOK := httptest.NewRecorder() - disableReq := httptest.NewRequest(http.MethodPost, "/esim/actions/disable", strings.NewReader(`{"iccid":"8900000000000000001","aid_hex":"A0000005591010FFFFFFFF8900000100"}`)) + disableReq := httptest.NewRequest(http.MethodPost, "/esim/actions/disable", strings.NewReader(`{"iccid":"8900000000000000001","aidHex":"A0000005591010FFFFFFFF8900000177"}`)) disableReq.Header.Set("Content-Type", "application/json") present.handleESIM(disableOK, disableReq, []string{"actions", "disable"}, "dev1", true) if disableOK.Code != http.StatusOK { @@ -279,10 +313,13 @@ func TestHandleESIMShapes(t *testing.T) { if data := decodeData(t, disableOK); data["status"] != "disabled" || data["recovering"] != true { t.Fatalf("disable data = %v", data) } + if controller.disableAID != "A0000005591010FFFFFFFF8900000177" { + t.Fatalf("disable AID = %q, want XeSIM camelCase AID", controller.disableAID) + } // Rename happy path routes PATCH to ES10c SetNickname support. renameOK := httptest.NewRecorder() - renameReq := httptest.NewRequest(http.MethodPatch, "/esim/profiles/8900000000000000001", strings.NewReader(`{"name":"Test profile","aid_hex":"A0000005591010FFFFFFFF8900000100"}`)) + renameReq := httptest.NewRequest(http.MethodPatch, "/esim/profiles/8900000000000000001", strings.NewReader(`{"name":"Test profile","aidHex":"A0000005591010FFFFFFFF8900000177"}`)) renameReq.Header.Set("Content-Type", "application/json") present.handleESIM(renameOK, renameReq, []string{"profiles", "8900000000000000001"}, "dev1", true) if renameOK.Code != http.StatusOK { @@ -291,6 +328,9 @@ func TestHandleESIMShapes(t *testing.T) { if data := decodeData(t, renameOK); data["status"] != "renamed" || data["name"] != "Test profile" { t.Fatalf("rename data = %v", data) } + if controller.renameAID != "A0000005591010FFFFFFFF8900000177" { + t.Fatalf("rename AID = %q, want XeSIM camelCase AID", controller.renameAID) + } // Download on a present device but with no smdp address reports 400. dlNoSmdp := httptest.NewRecorder() diff --git a/internal/server/device_summary_test.go b/internal/server/device_summary_test.go index 155b028..dcbe56a 100644 --- a/internal/server/device_summary_test.go +++ b/internal/server/device_summary_test.go @@ -7,6 +7,7 @@ import ( "vocat/internal/device" "vocat/internal/store" + "vocat/internal/vowifi" ) func TestConfiguredDeviceSummaryIgnoresVoWiFiRuntimeFromPreviousSIM(t *testing.T) { @@ -49,3 +50,68 @@ func TestConfiguredDeviceSummaryIgnoresVoWiFiRuntimeFromPreviousSIM(t *testing.T t.Fatalf("runtime = %#v", got["vowifi_runtime"]) } } + +func TestConfiguredDeviceSummaryPrefersLiveVoWiFiStateOverStoredShutdownState(t *testing.T) { + database, err := store.Open(context.Background(), ":memory:") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := database.UpsertDevice(context.Background(), store.Device{ID: "ec20_1", Name: "EC20"}); err != nil { + t.Fatal(err) + } + if err := database.UpsertVoWiFiRuntime(context.Background(), store.VoWiFiRuntime{ + DeviceID: "ec20_1", + Phase: "idle", + ICCID: "89104100000028106378", + LastReason: "disabled", + UpdatedAt: time.Now().UTC(), + }); err != nil { + t.Fatal(err) + } + live := vowifi.State{ + DeviceID: "ec20_1", + Phase: vowifi.PhaseTunnelReady, + Enabled: true, + Active: true, + ICCID: "89104100000028106378", + SIMReady: true, + AccessReady: true, + TunnelReady: true, + LastReason: "ipsec_tunnel_ready", + UpdatedAt: time.Now().UTC(), + } + s := &Server{store: database, vowifi: &fakeVoWiFiController{state: live}} + entry := &device.Device{ID: "physical", Snapshot: &device.Snapshot{ICCID: live.ICCID}} + got := s.configuredDeviceSummary(store.Device{ID: "ec20_1", VoWiFiEnabled: true}, entry) + runtime, ok := got["vowifi_runtime"].(map[string]any) + if !ok || runtime["phase"] != string(vowifi.PhaseTunnelReady) || runtime["enabled"] != true { + t.Fatalf("runtime = %#v", got["vowifi_runtime"]) + } + if got["vowifi_active"] != true { + t.Fatalf("vowifi_active = %#v", got["vowifi_active"]) + } +} + +func TestConfiguredDeviceSummaryMarksIdleRuntimeAsNotInUse(t *testing.T) { + database, err := store.Open(context.Background(), ":memory:") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + s := &Server{ + store: database, + vowifi: &fakeVoWiFiController{state: vowifi.State{ + DeviceID: "ec20_1", + Phase: vowifi.PhaseIdle, + Enabled: false, + LastReason: "disabled", + UpdatedAt: time.Now().UTC(), + }}, + } + got := s.configuredDeviceSummary(store.Device{ID: "ec20_1", VoWiFiEnabled: true}, nil) + runtime := got["vowifi_runtime"].(map[string]any) + if runtime["enabled"] != false || got["vowifi_active"] != false { + t.Fatalf("summary = %#v", got) + } +} diff --git a/internal/server/esim_api.go b/internal/server/esim_api.go index 51b4db8..3c0a811 100644 --- a/internal/server/esim_api.go +++ b/internal/server/esim_api.go @@ -8,6 +8,7 @@ import ( "time" "vocat/internal/device" + "vocat/internal/store" ) func esimUnavailable(w http.ResponseWriter) { @@ -15,7 +16,11 @@ func esimUnavailable(w http.ResponseWriter) { } // handleESIM routes every /devices/{id}/esim* path. -func (s *Server) handleESIM(w http.ResponseWriter, r *http.Request, rest []string, physicalID string, physicalPresent bool) bool { +func (s *Server) handleESIM(w http.ResponseWriter, r *http.Request, rest []string, physicalID string, physicalPresent bool, configuredIDs ...string) bool { + configuredID := physicalID + if len(configuredIDs) > 0 && strings.TrimSpace(configuredIDs[0]) != "" { + configuredID = strings.TrimSpace(configuredIDs[0]) + } if len(rest) == 0 || (len(rest) == 1 && strings.TrimSpace(rest[0]) == "") { if !requireMethod(w, r, http.MethodGet) { return true @@ -60,7 +65,7 @@ func (s *Server) handleESIM(w http.ResponseWriter, r *http.Request, rest []strin if !requireMethod(w, r, http.MethodPost) { return true } - s.handleEsimSwitch(w, r, physicalID, physicalPresent) + s.handleEsimSwitch(w, r, configuredID, physicalID, physicalPresent) return true } if len(rest) == 2 && rest[1] == "disable" { @@ -295,8 +300,9 @@ func (s *Server) handleEsimRename(w http.ResponseWriter, r *http.Request, physic return } var request struct { - Name string `json:"name"` - AIDHex string `json:"aid_hex"` // accepted for the multi-eUICC SPA contract; ICCID addresses the profile + Name string `json:"name"` + AIDHex string `json:"aid_hex"` + AIDHexCamel string `json:"aidHex"` } if err := s.decodeJSON(w, r, &request); err != nil { writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) @@ -307,7 +313,8 @@ func (s *Server) handleEsimRename(w http.ResponseWriter, r *http.Request, physic writeError(w, http.StatusBadRequest, "invalid_request", "profile nickname is required") return } - if err := s.devices.ESIMRenameProfile(r.Context(), physicalID, iccid, nickname, request.AIDHex); err != nil { + aidHex := firstNonEmpty(request.AIDHex, request.AIDHexCamel) + if err := s.devices.ESIMRenameProfile(r.Context(), physicalID, iccid, nickname, aidHex); err != nil { s.writeDeviceError(w, err) return } @@ -316,7 +323,7 @@ func (s *Server) handleEsimRename(w http.ResponseWriter, r *http.Request, physic // handleEsimSwitch enables one already-installed profile by ICCID (切卡). The // eUICC EnableProfile command needs no authentication key. -func (s *Server) handleEsimSwitch(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool) { +func (s *Server) handleEsimSwitch(w http.ResponseWriter, r *http.Request, configuredID string, physicalID string, physicalPresent bool) { if s.devices == nil { writeError(w, http.StatusServiceUnavailable, "device_manager_unavailable", "device manager is unavailable") return @@ -326,8 +333,9 @@ func (s *Server) handleEsimSwitch(w http.ResponseWriter, r *http.Request, physic return } var request struct { - ICCID string `json:"iccid"` - AIDHex string `json:"aid_hex"` // accepted for contract compatibility; switching keys off iccid + ICCID string `json:"iccid"` + AIDHex string `json:"aid_hex"` + AIDHexCamel string `json:"aidHex"` } if err := s.decodeJSON(w, r, &request); err != nil { writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) @@ -338,14 +346,56 @@ func (s *Server) handleEsimSwitch(w http.ResponseWriter, r *http.Request, physic writeError(w, http.StatusBadRequest, "invalid_request", "iccid is required") return } + // Profile operations run with RF disabled. The eUICC remains accessible in + // CFUN=4, and the recovery path reapplies CFUN=4 as soon as the AT port comes + // back after the mandatory modem reset. + if _, err := s.devices.SetFlight(r.Context(), physicalID, true); err != nil { + s.writeDeviceError(w, err) + return + } // A confirmed profile switch includes the EC20 reset and a live ICCID read, // which normally takes longer than the server's ordinary response deadline. controller := http.NewResponseController(w) _ = controller.SetWriteDeadline(time.Time{}) - if err := s.devices.ESIMSwitchProfile(r.Context(), physicalID, iccid, request.AIDHex); err != nil { + aidHex := firstNonEmpty(request.AIDHex, request.AIDHexCamel) + if err := s.devices.ESIMSwitchProfile(r.Context(), physicalID, iccid, aidHex); err != nil { s.writeDeviceError(w, err) return } + if _, err := s.devices.SetFlight(r.Context(), physicalID, true); err != nil { + s.writeDeviceError(w, err) + return + } + if err := s.store.UpsertCardPolicy(r.Context(), store.CardPolicy{ + ICCID: iccid, VoWiFiEnabled: true, AirplaneEnabled: true, + IPVersion: "IPV4V6", Source: "default", + }); err != nil { + s.writeStoreError(w, err) + return + } + config, err := s.store.Device(r.Context(), configuredID) + if err != nil { + s.writeStoreError(w, err) + return + } + config.VoWiFiEnabled = true + config.NetworkEnabled = false + if err := s.store.UpsertDevice(r.Context(), config); err != nil { + s.writeStoreError(w, err) + return + } + if s.vowifi != nil { + state, stateErr := s.vowifi.State(configuredID) + switch { + case stateErr == nil && state.Enabled: + _, err = s.vowifi.RequestReconnect(configuredID) + default: + _, err = s.vowifi.RequestEnabled(configuredID, true) + } + if err != nil { + s.logger.Warn("profile switched in safe airplane mode but VoWiFi start was not queued", "device_id", configuredID, "iccid", iccid, "error", err) + } + } writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"status": "switched", "iccid": iccid, "verified": true}}) } @@ -359,8 +409,9 @@ func (s *Server) handleEsimDisable(w http.ResponseWriter, r *http.Request, physi return } var request struct { - ICCID string `json:"iccid"` - AIDHex string `json:"aid_hex"` // accepted for the multi-eUICC SPA contract; disabling keys off ICCID + ICCID string `json:"iccid"` + AIDHex string `json:"aid_hex"` + AIDHexCamel string `json:"aidHex"` } if err := s.decodeJSON(w, r, &request); err != nil { writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) @@ -371,7 +422,8 @@ func (s *Server) handleEsimDisable(w http.ResponseWriter, r *http.Request, physi writeError(w, http.StatusBadRequest, "invalid_request", "iccid is required") return } - if err := s.devices.ESIMDisableProfile(r.Context(), physicalID, iccid, request.AIDHex); err != nil { + aidHex := firstNonEmpty(request.AIDHex, request.AIDHexCamel) + if err := s.devices.ESIMDisableProfile(r.Context(), physicalID, iccid, aidHex); err != nil { s.writeDeviceError(w, err) return } diff --git a/internal/server/general_api.go b/internal/server/general_api.go index a2293f1..8c56cb6 100644 --- a/internal/server/general_api.go +++ b/internal/server/general_api.go @@ -24,6 +24,9 @@ import ( func (s *Server) routeGeneralAPI(w http.ResponseWriter, r *http.Request) bool { cleanPath := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api"), "/") + if s.routeAutomaticTasksAPI(w, r, cleanPath) { + return true + } if s.routeExtensionAPI(w, r, cleanPath) { return true } diff --git a/internal/server/server.go b/internal/server/server.go index ee0732b..f5fb2ae 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -84,6 +84,7 @@ type Server struct { netTraffic *liveNetTracker publicIPMu sync.RWMutex publicIPs map[string]cachedPublicIP + automaticTasks *automaticTaskScheduler } func New(options Options) (*Server, error) { diff --git a/internal/server/settings_api.go b/internal/server/settings_api.go index 5768cb5..074bf63 100644 --- a/internal/server/settings_api.go +++ b/internal/server/settings_api.go @@ -1168,11 +1168,11 @@ func (s *Server) liveCardPolicyFlags(ctx context.Context, iccid string) (vowifi, if !strings.EqualFold(strings.TrimSpace(entry.Snapshot.ICCID), clean) { continue } - // VoWiFi deliberately puts the modem into RF-off mode while the SWu/IMS - // path owns service. That physical CFUN state is not the user's separate - // airplane-mode policy; exposing both toggles as enabled is contradictory - // and makes the UI unable to represent the active policy correctly. - return config.VoWiFiEnabled, entry.Snapshot.FlightMode && !config.VoWiFiEnabled, true + // VoWiFi is an RF-off service mode. Surface that fact explicitly: while + // VoWiFi is selected both switches are on, but the airplane switch is + // read-only in the UI. Once VoWiFi is disabled, airplane remains on until + // the user explicitly turns it off. + return config.VoWiFiEnabled, config.VoWiFiEnabled || entry.Snapshot.FlightMode, true } return false, false, false } @@ -1193,9 +1193,11 @@ func (s *Server) handleCardPolicy(w http.ResponseWriter, r *http.Request, iccid policy, err := s.store.CardPolicy(r.Context(), iccid) if errors.Is(err, store.ErrNotFound) { policy = store.CardPolicy{ - ICCID: iccid, - IPVersion: "IPV4V6", - Source: "default", + ICCID: iccid, + VoWiFiEnabled: true, + AirplaneEnabled: true, + IPVersion: "IPV4V6", + Source: "default", } } else if err != nil { s.writeStoreError(w, err) @@ -1250,14 +1252,11 @@ func (s *Server) handleCardPolicy(w http.ResponseWriter, r *http.Request, iccid ) return } - if *request.VoWiFiEnabled && *request.AirplaneEnabled { - writeError( - w, - http.StatusBadRequest, - "invalid_card_policy", - "VoWiFi and airplane mode cannot both be enabled", - ) - return + // VoWiFi always owns an RF-off modem. Store airplane=true even when an + // older client omits that implication, so disabling VoWiFi cannot expose a + // brief cellular attach window. + if *request.VoWiFiEnabled { + *request.AirplaneEnabled = true } policy := store.CardPolicy{ ICCID: iccid, diff --git a/internal/server/settings_api_test.go b/internal/server/settings_api_test.go index 679f815..d18de15 100644 --- a/internal/server/settings_api_test.go +++ b/internal/server/settings_api_test.go @@ -424,7 +424,8 @@ func TestCardPolicyDefaultValidationAndPersistence(t *testing.T) { response := decodeSettingsResponse(t, recorder) policy := response["data"].(map[string]any) if policy["iccid"] != iccid || policy["source"] != "default" || - policy["ip_version"] != "IPV4V6" { + policy["ip_version"] != "IPV4V6" || policy["vowifi_enabled"] != true || + policy["airplane_enabled"] != true { t.Fatalf("default policy = %#v", policy) } @@ -434,8 +435,8 @@ func TestCardPolicyDefaultValidationAndPersistence(t *testing.T) { "/api/cards/"+iccid+"/policy", `{"vowifi_enabled":true,"airplane_enabled":true,"apn":"ims","ip_version":"IPV4V6"}`, ) - if recorder.Code != http.StatusBadRequest { - t.Fatalf("conflicting policy status = %d, body = %s", recorder.Code, recorder.Body) + if recorder.Code != http.StatusOK { + t.Fatalf("RF-safe policy status = %d, body = %s", recorder.Code, recorder.Body) } recorder = test.request( @@ -450,11 +451,11 @@ func TestCardPolicyDefaultValidationAndPersistence(t *testing.T) { response = decodeSettingsResponse(t, recorder) policy = response["data"].(map[string]any) if policy["source"] != "manual" || policy["vowifi_enabled"] != true || - policy["ip_version"] != "IPV4V6" { + policy["airplane_enabled"] != true || policy["ip_version"] != "IPV4V6" { t.Fatalf("saved policy = %#v", policy) } stored, err := test.database.CardPolicy(context.Background(), iccid) - if err != nil || !stored.VoWiFiEnabled || stored.APN != "ims" { + if err != nil || !stored.VoWiFiEnabled || !stored.AirplaneEnabled || stored.APN != "ims" { t.Fatalf("stored policy = %+v, %v", stored, err) } diff --git a/internal/server/telegram_bot.go b/internal/server/telegram_bot.go index cc570ef..5794356 100644 --- a/internal/server/telegram_bot.go +++ b/internal/server/telegram_bot.go @@ -28,6 +28,8 @@ const ( telegramPollInterval = 3 * time.Second telegramNotificationPeriod = 2 * time.Second telegramConfirmationTTL = 2 * time.Minute + telegramMenuTTL = 15 * time.Minute + telegramInputTTL = 10 * time.Minute telegramMaxDialDuration = 10 * time.Minute ) @@ -53,6 +55,9 @@ type telegramBot struct { callMu sync.Mutex activeDials map[string]struct{} + + inputMu sync.Mutex + inputs map[string]telegramInputState } type telegramPendingAction struct { @@ -68,6 +73,15 @@ type telegramPendingAction struct { TargetICCID string } +type telegramInputState struct { + Kind string + DeviceID string + Argument string + ChatID int64 + AdminID int64 + CreatedAt time.Time +} + type telegramAPIResponse struct { OK bool `json:"ok"` Description string `json:"description"` @@ -113,6 +127,7 @@ func (s *Server) StartTelegramBot(ctx context.Context) { server: s, pending: make(map[string]telegramPendingAction), activeDials: make(map[string]struct{}), + inputs: make(map[string]telegramInputState), } go bot.poll(ctx) go bot.notifyInboundSMS(ctx) @@ -189,21 +204,14 @@ func (bot *telegramBot) bootstrap(ctx context.Context, config telegramRuntimeCon } } commands := []map[string]string{ - {"command": "status", "description": "查看设备状态"}, - {"command": "esim", "description": "查看已安装 eSIM Profile"}, - {"command": "wfc", "description": "管理 WiFi Calling"}, - {"command": "sms", "description": "发送短信(需要确认)"}, - {"command": "call", "description": "限时拨号并自动挂断(需要确认)"}, - {"command": "calls", "description": "查看当前通话"}, - {"command": "answer", "description": "接听当前来电"}, - {"command": "hangup", "description": "挂断通话"}, - {"command": "at", "description": "向指定设备发送安全 AT 指令"}, - {"command": "ussd", "description": "向指定设备发送 USSD 指令"}, - {"command": "ussd_reply", "description": "回复交互式 USSD 会话"}, - {"command": "ussd_cancel", "description": "取消交互式 USSD 会话"}, - {"command": "help", "description": "查看命令帮助"}, + {"command": "menu", "description": "打开可视化操作菜单"}, + {"command": "status", "description": "查看全部设备状态"}, + {"command": "cancel", "description": "取消当前输入或操作"}, + {"command": "help", "description": "查看帮助"}, + } + if err := bot.call(requestContext, config, "setMyCommands", map[string]any{"commands": commands}, nil); err != nil { + bot.warn("register Telegram command menu", err) } - _ = bot.call(requestContext, config, "setMyCommands", map[string]any{"commands": commands}, nil) return offset, nil } @@ -240,18 +248,42 @@ func (bot *telegramBot) handleUpdate(ctx context.Context, config telegramRuntime return } command, remainder := parseTelegramCommand(message.Text) + if _, waiting := bot.input(message.Chat.ID, message.From.ID); waiting && + command != "menu" && command != "start" && command != "cancel" { + bot.handleInputMessage(ctx, config, message) + return + } if command == "" { + bot.handleInputMessage(ctx, config, message) return } switch command { - case "start", "menu", "help": + case "start", "menu": + bot.sendMainMenu(ctx, config, message.Chat.ID) + case "help": bot.sendHelp(ctx, config, message.Chat.ID) + case "cancel": + bot.clearInput(message.Chat.ID, message.From.ID) + bot.sendText(ctx, config, message.Chat.ID, "已取消当前输入或操作。", bot.homeKeyboard()) case "status", "devices": bot.sendDeviceStatus(ctx, config, message.Chat.ID, strings.TrimSpace(remainder)) case "esim": - bot.sendESIMProfiles(ctx, config, message.Chat.ID, strings.TrimSpace(remainder)) + deviceID := strings.TrimSpace(remainder) + if deviceID == "" { + bot.sendDevicePicker(ctx, config, message.Chat.ID, message.From.ID, "esim") + } else { + bot.sendESIMProfiles(ctx, config, message.Chat.ID, message.From.ID, deviceID) + } case "switch": parts := strings.Fields(remainder) + if len(parts) == 0 { + bot.sendDevicePicker(ctx, config, message.Chat.ID, message.From.ID, "esim") + return + } + if len(parts) == 1 { + bot.sendESIMProfiles(ctx, config, message.Chat.ID, message.From.ID, parts[0]) + return + } if len(parts) != 2 { bot.sendText(ctx, config, message.Chat.ID, "用法:/switch <设备ID> <目标ICCID>", nil) return @@ -259,13 +291,29 @@ func (bot *telegramBot) handleUpdate(ctx context.Context, config telegramRuntime bot.confirmESIMSwitch(ctx, config, message.Chat.ID, message.From.ID, parts[0], parts[1]) case "wfc", "wificalling": parts := strings.Fields(remainder) + if len(parts) == 0 { + bot.sendDevicePicker(ctx, config, message.Chat.ID, message.From.ID, "wfc") + return + } + if len(parts) == 1 { + bot.sendVoWiFiMenu(ctx, config, message.Chat.ID, message.From.ID, parts[0]) + return + } if len(parts) != 2 { - bot.sendText(ctx, config, message.Chat.ID, "用法:/wfc <设备ID> ", nil) + bot.sendText(ctx, config, message.Chat.ID, "请选择菜单按钮,或使用 /wfc <设备ID> 。", bot.homeKeyboard()) return } bot.handleVoWiFi(ctx, config, message.Chat.ID, message.From.ID, parts[0], parts[1]) case "sms": parts := splitTelegramArguments(remainder, 3) + if len(parts) == 0 { + bot.sendDevicePicker(ctx, config, message.Chat.ID, message.From.ID, "sms") + return + } + if len(parts) == 1 { + bot.beginInput(ctx, config, message.Chat.ID, message.From.ID, parts[0], "sms_phone", "请输入收件人号码:") + return + } if len(parts) != 3 { bot.sendText(ctx, config, message.Chat.ID, "用法:/sms <设备ID> <号码> <短信内容>", nil) return @@ -273,6 +321,14 @@ func (bot *telegramBot) handleUpdate(ctx context.Context, config telegramRuntime bot.confirmSMS(ctx, config, message.Chat.ID, message.From.ID, parts[0], parts[1], parts[2]) case "call": parts := strings.Fields(remainder) + if len(parts) == 0 { + bot.sendDevicePicker(ctx, config, message.Chat.ID, message.From.ID, "call") + return + } + if len(parts) == 1 { + bot.beginInput(ctx, config, message.Chat.ID, message.From.ID, parts[0], "call_number", "请输入要拨打的电话号码:") + return + } if len(parts) != 3 { bot.sendText(ctx, config, message.Chat.ID, "用法:/call <设备ID> <号码> <持续秒数>\n拨号后将在指定时间自动挂断,不处理通话音频。", nil) return @@ -284,13 +340,33 @@ func (bot *telegramBot) handleUpdate(ctx context.Context, config telegramRuntime } bot.confirmCall(ctx, config, message.Chat.ID, message.From.ID, parts[0], parts[1], time.Duration(seconds)*time.Second) case "answer": - bot.executeSimpleCallAction(ctx, config, message.Chat.ID, message.From.ID, strings.TrimSpace(remainder), "answer") + if strings.TrimSpace(remainder) == "" { + bot.sendDevicePicker(ctx, config, message.Chat.ID, message.From.ID, "answer") + } else { + bot.executeSimpleCallAction(ctx, config, message.Chat.ID, message.From.ID, strings.TrimSpace(remainder), "answer") + } case "hangup": - bot.executeSimpleCallAction(ctx, config, message.Chat.ID, message.From.ID, strings.TrimSpace(remainder), "hangup") + if strings.TrimSpace(remainder) == "" { + bot.sendDevicePicker(ctx, config, message.Chat.ID, message.From.ID, "hangup") + } else { + bot.executeSimpleCallAction(ctx, config, message.Chat.ID, message.From.ID, strings.TrimSpace(remainder), "hangup") + } case "calls": - bot.executeSimpleCallAction(ctx, config, message.Chat.ID, message.From.ID, strings.TrimSpace(remainder), "status") + if strings.TrimSpace(remainder) == "" { + bot.sendDevicePicker(ctx, config, message.Chat.ID, message.From.ID, "calls") + } else { + bot.executeSimpleCallAction(ctx, config, message.Chat.ID, message.From.ID, strings.TrimSpace(remainder), "status") + } case "at": parts := splitTelegramArguments(remainder, 2) + if len(parts) == 0 { + bot.sendDevicePicker(ctx, config, message.Chat.ID, message.From.ID, "at") + return + } + if len(parts) == 1 { + bot.beginInput(ctx, config, message.Chat.ID, message.From.ID, parts[0], "at", "请直接发送一条 AT 指令,例如 AT+CSQ:") + return + } if len(parts) != 2 { bot.sendText(ctx, config, message.Chat.ID, "用法:/at <设备ID> \n示例:/at EC20 AT+CSQ", nil) return @@ -298,6 +374,14 @@ func (bot *telegramBot) handleUpdate(ctx context.Context, config telegramRuntime bot.handleATCommand(ctx, config, message.Chat.ID, message.From.ID, parts[0], parts[1]) case "ussd": parts := strings.Fields(remainder) + if len(parts) == 0 { + bot.sendDevicePicker(ctx, config, message.Chat.ID, message.From.ID, "ussd") + return + } + if len(parts) == 1 { + bot.beginInput(ctx, config, message.Chat.ID, message.From.ID, parts[0], "ussd", "请直接发送 USSD 代码,例如 *100#:") + return + } if len(parts) != 2 { bot.sendText(ctx, config, message.Chat.ID, "用法:/ussd <设备ID> \n示例:/ussd EC20 *100#", nil) return @@ -318,18 +402,100 @@ func (bot *telegramBot) handleUpdate(ctx context.Context, config telegramRuntime } bot.handleUSSDCancel(ctx, config, message.Chat.ID, message.From.ID, sessionID) default: - bot.sendText(ctx, config, message.Chat.ID, "未知命令。发送 /help 查看可用操作。", nil) + bot.sendText(ctx, config, message.Chat.ID, "未知命令,请使用下方操作菜单。", bot.homeKeyboard()) } } func (bot *telegramBot) handleCallback(ctx context.Context, config telegramRuntimeConfig, callback *telegramCallbackQuery) { data := strings.TrimSpace(callback.Data) + chatID, adminID := callback.Message.Chat.ID, callback.From.ID + if data == "menu:home" || data == "menu:devices" { + if data == "menu:home" { + bot.sendMainMenu(ctx, config, chatID) + } else { + bot.sendDevicePicker(ctx, config, chatID, adminID, "") + } + return + } if data == "menu:status" { - bot.sendDeviceStatus(ctx, config, callback.Message.Chat.ID, "") + bot.sendDeviceStatus(ctx, config, chatID, "") return } if data == "menu:help" { - bot.sendHelp(ctx, config, callback.Message.Chat.ID) + bot.sendHelp(ctx, config, chatID) + return + } + if data == "input:cancel" { + bot.clearInput(chatID, adminID) + bot.sendText(ctx, config, chatID, "已取消输入。", bot.homeKeyboard()) + return + } + if strings.HasPrefix(data, "pick:") { + action, ok := bot.takePending(strings.TrimPrefix(data, "pick:"), chatID, adminID) + if !ok || action.Kind != "menu_pick" { + bot.sendExpiredMenu(ctx, config, chatID) + return + } + bot.dispatchDeviceChoice(ctx, config, chatID, adminID, action.DeviceID, action.Argument) + return + } + if prefix, token, operation, ok := parseTelegramMenuCallback(data); ok { + consume := prefix == "es" || prefix == "dur" || prefix == "uc" + var action telegramPendingAction + var found bool + if consume { + action, found = bot.takePending(token, chatID, adminID) + } else { + action, found = bot.getPending(token, chatID, adminID) + } + if !found { + bot.sendExpiredMenu(ctx, config, chatID) + return + } + switch prefix { + case "d": + if action.Kind != "menu_device" { + bot.sendExpiredMenu(ctx, config, chatID) + return + } + bot.dispatchDeviceChoice(ctx, config, chatID, adminID, action.DeviceID, operation) + case "w": + if action.Kind != "menu_device" { + bot.sendExpiredMenu(ctx, config, chatID) + return + } + bot.handleVoWiFi(ctx, config, chatID, adminID, action.DeviceID, operation) + case "call": + if action.Kind != "menu_device" { + bot.sendExpiredMenu(ctx, config, chatID) + return + } + bot.dispatchCallAction(ctx, config, chatID, adminID, action.DeviceID, operation) + case "es": + if action.Kind != "menu_esim_profile" { + bot.sendExpiredMenu(ctx, config, chatID) + return + } + bot.confirmESIMSwitch(ctx, config, chatID, adminID, action.DeviceID, action.TargetICCID) + case "dur": + if action.Kind != "menu_call_duration" { + bot.sendExpiredMenu(ctx, config, chatID) + return + } + seconds, err := strconv.Atoi(operation) + if err != nil || seconds < 1 || time.Duration(seconds)*time.Second > telegramMaxDialDuration { + bot.sendText(ctx, config, chatID, "自动挂断时间无效。", bot.homeKeyboard()) + return + } + bot.confirmCall(ctx, config, chatID, adminID, action.DeviceID, action.Argument, time.Duration(seconds)*time.Second) + case "uc": + if action.Kind != "menu_ussd_session" { + bot.sendExpiredMenu(ctx, config, chatID) + return + } + bot.clearInput(chatID, adminID) + bot.handleUSSDCancel(ctx, config, chatID, adminID, action.Argument) + } return } decision, token, found := strings.Cut(data, ":") @@ -342,7 +508,7 @@ func (bot *telegramBot) handleCallback(ctx context.Context, config telegramRunti return } if decision == "cancel" { - bot.sendText(ctx, config, callback.Message.Chat.ID, "操作已取消。", nil) + bot.sendText(ctx, config, callback.Message.Chat.ID, "操作已取消。", bot.homeKeyboard()) return } switch action.Kind { @@ -360,9 +526,271 @@ func (bot *telegramBot) handleCallback(ctx context.Context, config telegramRunti } } +func parseTelegramMenuCallback(data string) (prefix, token, operation string, ok bool) { + parts := strings.SplitN(data, ":", 3) + if len(parts) != 3 || parts[1] == "" || parts[2] == "" { + return "", "", "", false + } + switch parts[0] { + case "d", "w", "call", "es", "dur", "uc": + return parts[0], parts[1], parts[2], true + default: + return "", "", "", false + } +} + +func telegramKeyboard(rows ...[]map[string]string) map[string]any { + return map[string]any{"inline_keyboard": rows} +} + +func telegramButton(text, data string) map[string]string { + return map[string]string{"text": text, "callback_data": data} +} + +func (bot *telegramBot) homeKeyboard() map[string]any { + return telegramKeyboard([]map[string]string{ + telegramButton("📱 选择设备", "menu:devices"), + telegramButton("🏠 主菜单", "menu:home"), + }) +} + +func (bot *telegramBot) sendMainMenu(ctx context.Context, config telegramRuntimeConfig, chatID int64) { + bot.clearInput(chatID, config.AdminID) + bot.sendText(ctx, config, chatID, + "Vocat 控制中心\n\n请选择设备或直接查看全部设备状态。发送 /menu 可随时返回这里。", + telegramKeyboard( + []map[string]string{telegramButton("📱 设备操作", "menu:devices")}, + []map[string]string{ + telegramButton("📊 全部状态", "menu:status"), + telegramButton("❓ 帮助", "menu:help"), + }, + ), + ) +} + +func (bot *telegramBot) sendDevicePicker( + ctx context.Context, + config telegramRuntimeConfig, + chatID, adminID int64, + next string, +) { + configs, err := bot.server.store.ListDevices(ctx) + if err != nil { + bot.sendText(ctx, config, chatID, "读取设备失败:"+err.Error(), bot.homeKeyboard()) + return + } + rows := make([][]map[string]string, 0, len(configs)+1) + for _, stored := range configs { + _, _, present := bot.server.physicalForConfig(stored) + icon := "⚫" + if present { + icon = "🟢" + } + token, tokenErr := bot.putPending(telegramPendingAction{ + Kind: "menu_pick", DeviceID: stored.ID, Argument: next, + ChatID: chatID, AdminID: adminID, CreatedAt: time.Now(), + }) + if tokenErr != nil { + continue + } + label := fmt.Sprintf("%s %s", icon, firstNonEmpty(stored.Name, stored.ID)) + if stored.Name != "" && stored.Name != stored.ID { + label += " · " + stored.ID + } + rows = append(rows, []map[string]string{telegramButton(truncateTelegramButton(label), "pick:"+token)}) + } + rows = append(rows, []map[string]string{telegramButton("🏠 主菜单", "menu:home")}) + if len(configs) == 0 { + bot.sendText(ctx, config, chatID, "当前没有已配置设备。", telegramKeyboard(rows...)) + return + } + title := "请选择要操作的设备" + if next != "" { + title += ":" + } + bot.sendText(ctx, config, chatID, title, telegramKeyboard(rows...)) +} + +func truncateTelegramButton(value string) string { + value = strings.TrimSpace(value) + runes := []rune(value) + if len(runes) <= 48 { + return value + } + return string(runes[:47]) + "…" +} + +func (bot *telegramBot) sendDeviceMenu( + ctx context.Context, + config telegramRuntimeConfig, + chatID, adminID int64, + deviceID string, +) { + stored, err := bot.server.store.Device(ctx, deviceID) + if err != nil { + bot.sendText(ctx, config, chatID, "读取设备失败:"+err.Error(), bot.homeKeyboard()) + return + } + _, _, present := bot.server.physicalForConfig(stored) + token, err := bot.putPending(telegramPendingAction{ + Kind: "menu_device", DeviceID: deviceID, ChatID: chatID, AdminID: adminID, CreatedAt: time.Now(), + }) + if err != nil { + bot.sendText(ctx, config, chatID, "创建设备菜单失败:"+err.Error(), bot.homeKeyboard()) + return + } + status := "⚫ 离线" + if present { + status = "🟢 在线" + } + bot.sendText(ctx, config, chatID, + fmt.Sprintf("📡 %s\n设备 ID:%s\n状态:%s\n\n请选择功能:", firstNonEmpty(stored.Name, deviceID), deviceID, status), + telegramKeyboard( + []map[string]string{ + telegramButton("📊 状态", "d:"+token+":status"), + telegramButton("📲 eSIM", "d:"+token+":esim"), + }, + []map[string]string{ + telegramButton("📶 VoWiFi", "d:"+token+":wfc"), + telegramButton("✉️ 发送短信", "d:"+token+":sms"), + }, + []map[string]string{ + telegramButton("📞 通话", "d:"+token+":call"), + telegramButton("🛠 AT / USSD", "d:"+token+":tools"), + }, + []map[string]string{ + telegramButton("⬅️ 设备列表", "menu:devices"), + telegramButton("🏠 主菜单", "menu:home"), + }, + ), + ) +} + +func (bot *telegramBot) dispatchDeviceChoice( + ctx context.Context, + config telegramRuntimeConfig, + chatID, adminID int64, + deviceID, operation string, +) { + switch operation { + case "": + bot.sendDeviceMenu(ctx, config, chatID, adminID, deviceID) + case "status": + bot.sendDeviceStatus(ctx, config, chatID, deviceID) + case "esim": + bot.sendESIMProfiles(ctx, config, chatID, adminID, deviceID) + case "wfc": + bot.sendVoWiFiMenu(ctx, config, chatID, adminID, deviceID) + case "sms": + bot.beginInput(ctx, config, chatID, adminID, deviceID, "sms_phone", "请输入收件人号码:") + case "call": + bot.sendCallMenu(ctx, config, chatID, adminID, deviceID) + case "tools": + bot.sendToolsMenu(ctx, config, chatID, adminID, deviceID) + case "at": + bot.beginInput(ctx, config, chatID, adminID, deviceID, "at", "请直接发送一条 AT 指令,例如 AT+CSQ:") + case "ussd": + bot.beginInput(ctx, config, chatID, adminID, deviceID, "ussd", "请直接发送 USSD 代码,例如 *100#:") + case "answer", "hangup", "calls": + action := map[string]string{"answer": "answer", "hangup": "hangup", "calls": "status"}[operation] + bot.executeSimpleCallAction(ctx, config, chatID, adminID, deviceID, action) + default: + bot.sendDeviceMenu(ctx, config, chatID, adminID, deviceID) + } +} + +func (bot *telegramBot) sendVoWiFiMenu(ctx context.Context, config telegramRuntimeConfig, chatID, adminID int64, deviceID string) { + stored, _, _, err := bot.device(deviceID) + if err != nil { + bot.sendText(ctx, config, chatID, "VoWiFi 不可用:"+err.Error(), bot.homeKeyboard()) + return + } + if bot.server.vowifi == nil { + bot.sendText(ctx, config, chatID, "VoWiFi runtime 不可用。", bot.homeKeyboard()) + return + } + state, stateErr := bot.server.vowifi.State(deviceID) + if stateErr != nil { + bot.sendText(ctx, config, chatID, "读取 VoWiFi 状态失败:"+stateErr.Error(), bot.homeKeyboard()) + return + } + token, err := bot.putPending(telegramPendingAction{Kind: "menu_device", DeviceID: deviceID, ChatID: chatID, AdminID: adminID, CreatedAt: time.Now()}) + if err != nil { + return + } + bot.sendText(ctx, config, chatID, + fmt.Sprintf("📶 %s · VoWiFi\n策略:%s\n阶段:%s\nTunnel:%t · IMS:%t · SMS:%t", deviceID, map[bool]string{true: "已启用", false: "已关闭"}[stored.VoWiFiEnabled], firstNonEmpty(string(state.Phase), "idle"), state.TunnelReady, state.IMSReady, state.SMSReady), + telegramKeyboard( + []map[string]string{ + telegramButton("✅ 开启", "w:"+token+":on"), + telegramButton("⛔ 关闭", "w:"+token+":off"), + }, + []map[string]string{ + telegramButton("🔄 重新连接", "w:"+token+":reconnect"), + telegramButton("📊 刷新状态", "w:"+token+":status"), + }, + []map[string]string{telegramButton("⬅️ 设备功能", "d:"+token+":menu")}, + ), + ) +} + +func (bot *telegramBot) sendCallMenu(ctx context.Context, config telegramRuntimeConfig, chatID, adminID int64, deviceID string) { + if _, _, _, err := bot.device(deviceID); err != nil { + bot.sendText(ctx, config, chatID, "通话功能不可用:"+err.Error(), bot.homeKeyboard()) + return + } + token, err := bot.putPending(telegramPendingAction{Kind: "menu_device", DeviceID: deviceID, ChatID: chatID, AdminID: adminID, CreatedAt: time.Now()}) + if err != nil { + return + } + bot.sendText(ctx, config, chatID, "📞 "+deviceID+" · 通话\n请选择操作:", + telegramKeyboard( + []map[string]string{telegramButton("📱 拨打电话", "call:"+token+":dial")}, + []map[string]string{ + telegramButton("✅ 接听", "call:"+token+":answer"), + telegramButton("🔴 挂断", "call:"+token+":hangup"), + }, + []map[string]string{telegramButton("📋 当前通话", "call:"+token+":status")}, + []map[string]string{telegramButton("⬅️ 设备功能", "d:"+token+":menu")}, + ), + ) +} + +func (bot *telegramBot) dispatchCallAction(ctx context.Context, config telegramRuntimeConfig, chatID, adminID int64, deviceID, operation string) { + switch operation { + case "dial": + bot.beginInput(ctx, config, chatID, adminID, deviceID, "call_number", "请输入要拨打的电话号码:") + case "answer", "hangup", "status": + bot.executeSimpleCallAction(ctx, config, chatID, adminID, deviceID, operation) + default: + bot.sendCallMenu(ctx, config, chatID, adminID, deviceID) + } +} + +func (bot *telegramBot) sendToolsMenu(ctx context.Context, config telegramRuntimeConfig, chatID, adminID int64, deviceID string) { + token, err := bot.putPending(telegramPendingAction{Kind: "menu_device", DeviceID: deviceID, ChatID: chatID, AdminID: adminID, CreatedAt: time.Now()}) + if err != nil { + return + } + bot.sendText(ctx, config, chatID, "🛠 "+deviceID+" · 调试与运营商指令\n请选择输入类型:", + telegramKeyboard( + []map[string]string{ + telegramButton("⌨️ AT 指令", "d:"+token+":at"), + telegramButton("📟 USSD", "d:"+token+":ussd"), + }, + []map[string]string{telegramButton("⬅️ 设备功能", "d:"+token+":menu")}, + ), + ) +} + +func (bot *telegramBot) sendExpiredMenu(ctx context.Context, config telegramRuntimeConfig, chatID int64) { + bot.sendText(ctx, config, chatID, "该菜单已过期,请重新选择。", bot.homeKeyboard()) +} + func (bot *telegramBot) sendHelp(ctx context.Context, config telegramRuntimeConfig, chatID int64) { text := strings.Join([]string{ - "vocat Telegram 控制", "", + "Vocat Telegram 控制", "", + "推荐直接发送 /menu,使用按钮完成设备和功能选择。以下命令仅用于兼容和高级操作:", "", "/status [设备ID] — 查看设备、SIM、蜂窝与 VoWiFi 状态", "/esim <设备ID> — 只读查看已安装 Profile", "/switch <设备ID> — 切换到已安装 Profile(需确认)", @@ -379,10 +807,10 @@ func (bot *telegramBot) sendHelp(ctx context.Context, config telegramRuntimeConf "", "Bot 不提供 eSIM 下载、删除或改名,也不采集或转发通话音频。控制命令只接受设置中的 Admin ID。", }, "\n") - keyboard := map[string]any{"inline_keyboard": [][]map[string]string{{ - {"text": "📊 设备状态", "callback_data": "menu:status"}, - {"text": "❓ 帮助", "callback_data": "menu:help"}, - }}} + keyboard := telegramKeyboard( + []map[string]string{telegramButton("📱 使用操作菜单", "menu:devices")}, + []map[string]string{telegramButton("🏠 主菜单", "menu:home")}, + ) bot.sendText(ctx, config, chatID, text, keyboard) } @@ -399,29 +827,47 @@ func (bot *telegramBot) sendDeviceStatus(ctx context.Context, config telegramRun } entry, _, present := bot.server.physicalForConfig(stored) lines := []string{fmt.Sprintf("📡 %s (%s)", firstNonEmpty(stored.Name, stored.ID), stored.ID)} + var wfcState *vowifi.State + if bot.server.vowifi != nil { + if state, stateErr := bot.server.vowifi.State(stored.ID); stateErr == nil { + wfcState = &state + } + } if !present { lines = append(lines, "设备:离线") } else { - lines = append(lines, "设备:在线") + lines = append(lines, "设备:在线 · "+strings.ToUpper(firstNonEmpty(stored.DeviceBackend, "AT"))) if snapshot := entry.Snapshot; snapshot != nil { + associationNumber := "" + if snapshot.ICCID != "" { + if association, associationErr := bot.server.store.PhoneAssociation(ctx, snapshot.ICCID); associationErr == nil { + associationNumber = association.Number + } + } lines = append(lines, "SIM:"+map[bool]string{true: "Ready", false: firstNonEmpty(snapshot.SIMStatus, "未就绪")}[snapshot.SIMReady], + "IMEI:"+firstNonEmpty(snapshot.IMEI, stored.ModemIMEI, "--"), "ICCID:"+firstNonEmpty(snapshot.ICCID, "--"), "IMSI:"+firstNonEmpty(snapshot.IMSI, "--"), - "号码:"+firstNonEmpty(snapshot.Phone.Number, "--"), - "运营商:"+firstNonEmpty(snapshot.OperatorName, snapshot.OperatorCode, "--"), + "号码:"+resolveTelegramPhoneNumber(associationNumber, wfcState, snapshot), + "原运营商:"+telegramHomeCarrier(snapshot.IMSI, snapshot.SPN), + "当前网络:"+telegramCurrentNetwork(snapshot), "蜂窝模式:"+map[bool]string{true: "飞行模式", false: "开启"}[snapshot.FlightMode], ) + if module := telegramModuleLine(snapshot); module != "" { + lines = append(lines, module) + } + if signal := telegramSignalLine(snapshot); signal != "" { + lines = append(lines, signal) + } } } - if bot.server.vowifi != nil { - if state, stateErr := bot.server.vowifi.State(stored.ID); stateErr == nil { - lines = append(lines, - fmt.Sprintf("VoWiFi:%s · Tunnel=%t IMS=%t SMS=%t", firstNonEmpty(string(state.Phase), "idle"), state.TunnelReady, state.IMSReady, state.SMSReady), - ) - if state.LastError != "" { - lines = append(lines, "最后错误:"+state.LastError) - } + if wfcState != nil { + lines = append(lines, + fmt.Sprintf("VoWiFi:%s · Tunnel=%t IMS=%t SMS=%t", firstNonEmpty(string(wfcState.Phase), "idle"), wfcState.TunnelReady, wfcState.IMSReady, wfcState.SMSReady), + ) + if wfcState.LastError != "" { + lines = append(lines, "最后错误:"+wfcState.LastError) } } blocks = append(blocks, strings.Join(lines, "\n")) @@ -430,10 +876,168 @@ func (bot *telegramBot) sendDeviceStatus(ctx context.Context, config telegramRun bot.sendText(ctx, config, chatID, "未找到设备 "+onlyID, nil) return } - bot.sendText(ctx, config, chatID, strings.Join(blocks, "\n\n"), nil) + bot.sendText(ctx, config, chatID, strings.Join(blocks, "\n\n"), bot.homeKeyboard()) } -func (bot *telegramBot) sendESIMProfiles(ctx context.Context, config telegramRuntimeConfig, chatID int64, deviceID string) { +func resolveTelegramPhoneNumber(associationNumber string, state *vowifi.State, snapshot *device.Snapshot) string { + if usableTelegramPhoneNumber(associationNumber) { + return strings.TrimSpace(associationNumber) + } + currentICCID := "" + if snapshot != nil { + currentICCID = strings.TrimSpace(snapshot.ICCID) + } + if state != nil && usableTelegramPhoneNumber(state.PhoneNumber) { + stateICCID := strings.TrimSpace(state.ICCID) + if currentICCID == "" || (stateICCID != "" && strings.EqualFold(currentICCID, stateICCID)) { + return strings.TrimSpace(state.PhoneNumber) + } + } + if snapshot != nil && usableTelegramPhoneNumber(snapshot.Phone.Number) { + return strings.TrimSpace(snapshot.Phone.Number) + } + return "--" +} + +func usableTelegramPhoneNumber(value string) bool { + value = strings.TrimSpace(value) + if strings.HasPrefix(value, "+") { + value = value[1:] + } + var digits []byte + for index := 0; index < len(value); index++ { + switch character := value[index]; { + case character >= '0' && character <= '9': + digits = append(digits, character) + case character == ' ' || character == '-' || character == '(' || character == ')': + continue + default: + return false + } + } + if len(digits) < 5 || len(digits) > 20 { + return false + } + allSame := true + for _, digit := range digits[1:] { + if digit != digits[0] { + allSame = false + break + } + } + return !allSame +} + +func telegramHomeCarrier(imsi string, spn ...string) string { + plmn, name, country, ok := device.CarrierForIMSI(imsi) + if !ok { + if len(spn) > 0 && strings.TrimSpace(spn[0]) != "" { + return strings.TrimSpace(spn[0]) + } + return "--" + } + if len(spn) > 0 && strings.TrimSpace(spn[0]) != "" { + brand := strings.TrimSpace(spn[0]) + brandCountry := country + if strings.Contains(strings.ToLower(brand), "lebara") && strings.HasPrefix(strings.TrimSpace(imsi), "20404") { + brandCountry = "GB" + } + return strings.TrimSpace(strings.Join([]string{telegramCountryFlag(brandCountry), brand, "(认证核心 " + plmn + ")"}, " ")) + } + return strings.TrimSpace(strings.Join([]string{telegramCountryFlag(country), name, "(" + plmn + ")"}, " ")) +} + +func telegramCurrentNetwork(snapshot *device.Snapshot) string { + if snapshot == nil { + return "--" + } + if snapshot.FlightMode || snapshot.RadioOff { + return "--(飞行模式)" + } + operatorName := strings.TrimSpace(snapshot.OperatorName) + country := "" + if databaseName, databaseCountry, ok := device.CarrierForPLMN(snapshot.OperatorCode); ok { + if operatorName == "" { + operatorName = databaseName + } + country = databaseCountry + } + operator := firstNonEmpty(operatorName, strings.TrimSpace(snapshot.OperatorCode), "--") + if flag := telegramCountryFlag(country); flag != "" && operator != "--" { + operator = flag + " " + operator + } + parts := []string{operator, telegramRegistrationText(snapshot.RegistrationStatus)} + if radio := strings.TrimSpace(strings.Join([]string{snapshot.AccessTech, snapshot.Band}, " ")); radio != "" { + parts = append(parts, radio) + } + return strings.Join(parts, " · ") +} + +func telegramRegistrationText(status int) string { + switch status { + case 1: + return "已驻网" + case 5: + return "已驻网(漫游)" + case 2: + return "搜索中" + case 3: + return "驻网被拒绝" + default: + return "未驻网" + } +} + +func telegramCountryFlag(country string) string { + country = strings.ToUpper(strings.TrimSpace(country)) + if len(country) != 2 || country[0] < 'A' || country[0] > 'Z' || country[1] < 'A' || country[1] > 'Z' { + return "" + } + return string([]rune{ + rune(0x1F1E6) + rune(country[0]-'A'), + rune(0x1F1E6) + rune(country[1]-'A'), + }) +} + +func telegramModuleLine(snapshot *device.Snapshot) string { + if snapshot == nil { + return "" + } + description := strings.TrimSpace(strings.Join([]string{snapshot.Manufacturer, snapshot.Model}, " ")) + if description == "" && strings.TrimSpace(snapshot.Firmware) == "" { + return "" + } + parts := []string{firstNonEmpty(description, "--")} + if strings.TrimSpace(snapshot.Firmware) != "" { + parts = append(parts, snapshot.Firmware) + } + return "模块:" + strings.Join(parts, " · ") +} + +func telegramSignalLine(snapshot *device.Snapshot) string { + if snapshot == nil || snapshot.FlightMode || snapshot.RadioOff { + return "" + } + metrics := make([]string, 0, 4) + if snapshot.RSSIDBm != nil { + metrics = append(metrics, fmt.Sprintf("%d dBm", *snapshot.RSSIDBm)) + } + if snapshot.RSRP != nil { + metrics = append(metrics, fmt.Sprintf("RSRP %d", *snapshot.RSRP)) + } + if snapshot.RSRQ != nil { + metrics = append(metrics, fmt.Sprintf("RSRQ %d", *snapshot.RSRQ)) + } + if snapshot.SINR != nil { + metrics = append(metrics, fmt.Sprintf("SINR %d", *snapshot.SINR)) + } + if len(metrics) == 0 { + return "" + } + return "信号:" + strings.Join(metrics, " · ") +} + +func (bot *telegramBot) sendESIMProfiles(ctx context.Context, config telegramRuntimeConfig, chatID, adminID int64, deviceID string) { if deviceID == "" { bot.sendText(ctx, config, chatID, "用法:/esim <设备ID>", nil) return @@ -455,6 +1059,7 @@ func (bot *telegramBot) sendESIMProfiles(ctx context.Context, config telegramRun return } lines := []string{"📲 " + deviceID + " 已安装 Profile(只读)"} + rows := make([][]map[string]string, 0) for index, group := range inventory { lines = append(lines, fmt.Sprintf("\neUICC #%d · …%s", index+1, tailDigits(group.Info.EID, 4))) for _, profile := range group.Info.Profiles { @@ -464,10 +1069,23 @@ func (bot *telegramBot) sendESIMProfiles(ctx context.Context, config telegramRun } name := firstNonEmpty(profile.Nickname, profile.Name, profile.ServiceProvider, "未命名") lines = append(lines, fmt.Sprintf("• %s · %s\n %s", name, state, profile.ICCID)) + if profile.State != 1 { + token, tokenErr := bot.putPending(telegramPendingAction{ + Kind: "menu_esim_profile", DeviceID: deviceID, TargetICCID: profile.ICCID, + TargetAID: group.Info.AID, ChatID: chatID, AdminID: adminID, CreatedAt: time.Now(), + }) + if tokenErr == nil { + label := "切换到 " + name + " · …" + tailDigits(profile.ICCID, 4) + rows = append(rows, []map[string]string{telegramButton(truncateTelegramButton(label), "es:"+token+":select")}) + } + } } } - lines = append(lines, "\n切换:/switch "+deviceID+" <目标ICCID>") - bot.sendText(ctx, config, chatID, strings.Join(lines, "\n"), nil) + rows = append(rows, []map[string]string{ + telegramButton("⬅️ 设备列表", "menu:devices"), + telegramButton("🏠 主菜单", "menu:home"), + }) + bot.sendText(ctx, config, chatID, strings.Join(lines, "\n"), telegramKeyboard(rows...)) } func (bot *telegramBot) confirmESIMSwitch(ctx context.Context, config telegramRuntimeConfig, chatID, adminID int64, deviceID, iccid string) { @@ -568,6 +1186,120 @@ func (bot *telegramBot) askConfirmation(ctx context.Context, config telegramRunt bot.sendText(ctx, config, action.ChatID, text, keyboard) } +func telegramInputKey(chatID, adminID int64) string { + return strconv.FormatInt(chatID, 10) + ":" + strconv.FormatInt(adminID, 10) +} + +func (bot *telegramBot) beginInput( + ctx context.Context, + config telegramRuntimeConfig, + chatID, adminID int64, + deviceID, kind, prompt string, +) { + if _, _, _, err := bot.device(deviceID); err != nil { + bot.sendText(ctx, config, chatID, "设备不可用:"+err.Error(), bot.homeKeyboard()) + return + } + bot.setInput(telegramInputState{ + Kind: kind, DeviceID: deviceID, ChatID: chatID, AdminID: adminID, CreatedAt: time.Now(), + }) + bot.sendText(ctx, config, chatID, prompt+"\n\n设备:"+deviceID, + telegramKeyboard([]map[string]string{telegramButton("❌ 取消", "input:cancel")})) +} + +func (bot *telegramBot) setInput(state telegramInputState) { + bot.inputMu.Lock() + defer bot.inputMu.Unlock() + if bot.inputs == nil { + bot.inputs = make(map[string]telegramInputState) + } + state.CreatedAt = time.Now() + bot.inputs[telegramInputKey(state.ChatID, state.AdminID)] = state +} + +func (bot *telegramBot) clearInput(chatID, adminID int64) { + bot.inputMu.Lock() + defer bot.inputMu.Unlock() + delete(bot.inputs, telegramInputKey(chatID, adminID)) +} + +func (bot *telegramBot) input(chatID, adminID int64) (telegramInputState, bool) { + bot.inputMu.Lock() + defer bot.inputMu.Unlock() + state, ok := bot.inputs[telegramInputKey(chatID, adminID)] + if ok && time.Since(state.CreatedAt) > telegramInputTTL { + delete(bot.inputs, telegramInputKey(chatID, adminID)) + return telegramInputState{}, false + } + return state, ok +} + +func (bot *telegramBot) handleInputMessage(ctx context.Context, config telegramRuntimeConfig, message *telegramMessage) { + state, ok := bot.input(message.Chat.ID, message.From.ID) + if !ok { + bot.sendMainMenu(ctx, config, message.Chat.ID) + return + } + value := strings.TrimSpace(message.Text) + if value == "" { + return + } + switch state.Kind { + case "sms_phone": + if blocked, reason := blockedSMSDestination(value); blocked { + bot.sendText(ctx, config, message.Chat.ID, "号码不可用:"+reason+"\n请重新输入号码。", telegramKeyboard([]map[string]string{telegramButton("❌ 取消", "input:cancel")})) + return + } + state.Kind = "sms_text" + state.Argument = value + bot.setInput(state) + bot.sendText(ctx, config, message.Chat.ID, "请输入短信内容:\n\n收件人:"+value, + telegramKeyboard([]map[string]string{telegramButton("❌ 取消", "input:cancel")})) + case "sms_text": + bot.clearInput(message.Chat.ID, message.From.ID) + bot.confirmSMS(ctx, config, message.Chat.ID, message.From.ID, state.DeviceID, state.Argument, value) + case "call_number": + if !validTelegramDialNumber(value) { + bot.sendText(ctx, config, message.Chat.ID, "号码无效,请输入 3–20 位数字,可带前导 +。", + telegramKeyboard([]map[string]string{telegramButton("❌ 取消", "input:cancel")})) + return + } + bot.clearInput(message.Chat.ID, message.From.ID) + token, err := bot.putPending(telegramPendingAction{ + Kind: "menu_call_duration", DeviceID: state.DeviceID, Argument: value, + ChatID: message.Chat.ID, AdminID: message.From.ID, CreatedAt: time.Now(), + }) + if err != nil { + bot.sendText(ctx, config, message.Chat.ID, "创建拨号操作失败:"+err.Error(), bot.homeKeyboard()) + return + } + bot.sendText(ctx, config, message.Chat.ID, "请选择自动挂断时间:\n\n号码:"+value, + telegramKeyboard( + []map[string]string{ + telegramButton("10 秒", "dur:"+token+":10"), + telegramButton("30 秒", "dur:"+token+":30"), + }, + []map[string]string{ + telegramButton("60 秒", "dur:"+token+":60"), + telegramButton("120 秒", "dur:"+token+":120"), + }, + []map[string]string{telegramButton("❌ 取消", "input:cancel")}, + )) + case "at": + bot.clearInput(message.Chat.ID, message.From.ID) + bot.handleATCommand(ctx, config, message.Chat.ID, message.From.ID, state.DeviceID, value) + case "ussd": + bot.clearInput(message.Chat.ID, message.From.ID) + bot.handleUSSDCommand(ctx, config, message.Chat.ID, message.From.ID, state.DeviceID, value) + case "ussd_reply": + bot.clearInput(message.Chat.ID, message.From.ID) + bot.handleUSSDReply(ctx, config, message.Chat.ID, message.From.ID, state.Argument, value) + default: + bot.clearInput(message.Chat.ID, message.From.ID) + bot.sendMainMenu(ctx, config, message.Chat.ID) + } +} + func (bot *telegramBot) putPending(action telegramPendingAction) (string, error) { raw := make([]byte, 8) if _, err := cryptorand.Read(raw); err != nil { @@ -577,8 +1309,11 @@ func (bot *telegramBot) putPending(action telegramPendingAction) (string, error) bot.pendingMu.Lock() defer bot.pendingMu.Unlock() now := time.Now() + if bot.pending == nil { + bot.pending = make(map[string]telegramPendingAction) + } for key, value := range bot.pending { - if now.Sub(value.CreatedAt) > telegramConfirmationTTL { + if now.Sub(value.CreatedAt) > telegramPendingLifetime(value.Kind) { delete(bot.pending, key) } } @@ -586,6 +1321,26 @@ func (bot *telegramBot) putPending(action telegramPendingAction) (string, error) return token, nil } +func telegramPendingLifetime(kind string) time.Duration { + if strings.HasPrefix(kind, "menu_") { + return telegramMenuTTL + } + return telegramConfirmationTTL +} + +func (bot *telegramBot) getPending(token string, chatID, adminID int64) (telegramPendingAction, bool) { + bot.pendingMu.Lock() + defer bot.pendingMu.Unlock() + action, ok := bot.pending[token] + if !ok || action.ChatID != chatID || action.AdminID != adminID || time.Since(action.CreatedAt) > telegramPendingLifetime(action.Kind) { + if ok { + delete(bot.pending, token) + } + return telegramPendingAction{}, false + } + return action, true +} + func (bot *telegramBot) takePending(token string, chatID, adminID int64) (telegramPendingAction, bool) { bot.pendingMu.Lock() defer bot.pendingMu.Unlock() @@ -593,7 +1348,7 @@ func (bot *telegramBot) takePending(token string, chatID, adminID int64) (telegr if ok { delete(bot.pending, token) } - if !ok || action.ChatID != chatID || action.AdminID != adminID || time.Since(action.CreatedAt) > telegramConfirmationTTL { + if !ok || action.ChatID != chatID || action.AdminID != adminID || time.Since(action.CreatedAt) > telegramPendingLifetime(action.Kind) { return telegramPendingAction{}, false } return action, true @@ -680,9 +1435,9 @@ func (bot *telegramBot) executeSimpleCallAction(ctx context.Context, config tele outcome := "success" if err != nil { outcome = "failure" - bot.sendText(ctx, config, chatID, "通话操作失败:"+err.Error(), nil) + bot.sendText(ctx, config, chatID, "通话操作失败:"+err.Error(), bot.homeKeyboard()) } else { - bot.sendText(ctx, config, chatID, result, nil) + bot.sendText(ctx, config, chatID, result, bot.homeKeyboard()) } bot.server.recordAudit(ctx, fmt.Sprintf("telegram:%d", adminID), "telegram.call."+action, "device", deviceID, outcome, firstNonEmpty(transport, "unknown")) } @@ -1207,9 +1962,9 @@ func (bot *telegramBot) handleATCommand(ctx context.Context, config telegramRunt outcome := "success" if err != nil { outcome = "failure" - bot.sendText(ctx, config, chatID, "AT 指令执行失败:"+err.Error(), nil) + bot.sendText(ctx, config, chatID, "AT 指令执行失败:"+err.Error(), bot.homeKeyboard()) } else { - bot.sendText(ctx, config, chatID, result, nil) + bot.sendText(ctx, config, chatID, result, bot.homeKeyboard()) } bot.server.recordAudit(ctx, fmt.Sprintf("telegram:%d", adminID), "telegram.at.execute", "device", deviceID, outcome, "telegram") } @@ -1237,9 +1992,9 @@ func (bot *telegramBot) handleUSSDCommand(ctx context.Context, config telegramRu outcome := "success" if err != nil { outcome = "failure" - bot.sendText(ctx, config, chatID, "USSD 指令执行失败:"+err.Error(), nil) + bot.sendText(ctx, config, chatID, "USSD 指令执行失败:"+err.Error(), bot.homeKeyboard()) } else { - bot.sendText(ctx, config, chatID, formatTelegramUSSD(deviceID, result), nil) + bot.sendUSSDResult(ctx, config, chatID, adminID, deviceID, result) } bot.server.recordAudit(ctx, fmt.Sprintf("telegram:%d", adminID), "telegram.ussd.start", "device", deviceID, outcome, "telegram") } @@ -1261,9 +2016,9 @@ func (bot *telegramBot) handleUSSDReply(ctx context.Context, config telegramRunt outcome := "success" if err != nil { outcome = "failure" - bot.sendText(ctx, config, chatID, "USSD 回复失败:"+err.Error(), nil) + bot.sendText(ctx, config, chatID, "USSD 回复失败:"+err.Error(), bot.homeKeyboard()) } else { - bot.sendText(ctx, config, chatID, formatTelegramUSSD("", result), nil) + bot.sendUSSDResult(ctx, config, chatID, adminID, "", result) } bot.server.recordAudit(ctx, fmt.Sprintf("telegram:%d", adminID), "telegram.ussd.reply", "ussd_session", "interactive", outcome, "telegram") } @@ -1275,13 +2030,45 @@ func (bot *telegramBot) handleUSSDCancel(ctx context.Context, config telegramRun outcome := "success" if err != nil { outcome = "failure" - bot.sendText(ctx, config, chatID, "取消 USSD 会话失败:"+err.Error(), nil) + bot.sendText(ctx, config, chatID, "取消 USSD 会话失败:"+err.Error(), bot.homeKeyboard()) } else { - bot.sendText(ctx, config, chatID, "USSD 会话已取消。", nil) + bot.sendText(ctx, config, chatID, "USSD 会话已取消。", bot.homeKeyboard()) } bot.server.recordAudit(ctx, fmt.Sprintf("telegram:%d", adminID), "telegram.ussd.cancel", "ussd_session", "interactive", outcome, "telegram") } +func (bot *telegramBot) sendUSSDResult( + ctx context.Context, + config telegramRuntimeConfig, + chatID, adminID int64, + deviceID string, + result device.USSDResult, +) { + if !result.Continueable || strings.TrimSpace(result.SessionID) == "" { + bot.clearInput(chatID, adminID) + bot.sendText(ctx, config, chatID, formatTelegramUSSD(deviceID, result), bot.homeKeyboard()) + return + } + sessionID := strings.TrimSpace(result.SessionID) + bot.setInput(telegramInputState{ + Kind: "ussd_reply", DeviceID: deviceID, Argument: sessionID, + ChatID: chatID, AdminID: adminID, CreatedAt: time.Now(), + }) + token, err := bot.putPending(telegramPendingAction{ + Kind: "menu_ussd_session", Argument: sessionID, + ChatID: chatID, AdminID: adminID, CreatedAt: time.Now(), + }) + if err != nil { + bot.sendText(ctx, config, chatID, formatTelegramUSSD(deviceID, result), bot.homeKeyboard()) + return + } + bot.sendText(ctx, config, chatID, formatTelegramUSSD(deviceID, result), + telegramKeyboard( + []map[string]string{telegramButton("❌ 取消 USSD 会话", "uc:"+token+":cancel")}, + []map[string]string{telegramButton("🏠 主菜单", "menu:home")}, + )) +} + func formatTelegramUSSD(deviceID string, result device.USSDResult) string { lines := make([]string, 0, 7) if strings.TrimSpace(deviceID) != "" { @@ -1301,21 +2088,20 @@ func formatTelegramUSSD(deviceID string, result device.USSDResult) string { if result.Continueable && strings.TrimSpace(result.SessionID) != "" { lines = append(lines, "\n网络正在等待输入。", - "回复:/ussd_reply "+result.SessionID+" <内容>", - "取消:/ussd_cancel "+result.SessionID, + "请直接发送回复内容,或点击下方按钮取消会话。", ) } return strings.Join(lines, "\n") } func (bot *telegramBot) handleVoWiFi(ctx context.Context, config telegramRuntimeConfig, chatID, adminID int64, deviceID, operation string) { - stored, entry, _, err := bot.device(deviceID) + stored, entry, physicalID, err := bot.device(deviceID) if err != nil { - bot.sendText(ctx, config, chatID, "VoWiFi 操作失败:"+err.Error(), nil) + bot.sendText(ctx, config, chatID, "VoWiFi 操作失败:"+err.Error(), bot.homeKeyboard()) return } if bot.server.vowifi == nil { - bot.sendText(ctx, config, chatID, "VoWiFi runtime 不可用。", nil) + bot.sendText(ctx, config, chatID, "VoWiFi runtime 不可用。", bot.homeKeyboard()) return } operation = strings.ToLower(strings.TrimSpace(operation)) @@ -1332,6 +2118,10 @@ func (bot *telegramBot) handleVoWiFi(ctx context.Context, config telegramRuntime switch operation { case "on", "off": enabled := operation == "on" + if enabled && stored.NetworkEnabled { + bot.sendText(ctx, config, chatID, "VoWiFi 操作失败:请先关闭漫游数据。", bot.homeKeyboard()) + return + } if enabled && entry.Snapshot != nil { if reason := device.RegionBlockReason(entry.Snapshot.IMSI); reason != "" { bot.sendText(ctx, config, chatID, "VoWiFi 操作被拒绝:"+reason, nil) @@ -1339,6 +2129,36 @@ func (bot *telegramBot) handleVoWiFi(ctx context.Context, config telegramRuntime } } previous := stored.VoWiFiEnabled + // Telegram follows the same fail-closed transaction as the web API: + // RF is disabled before either starting or stopping VoWiFi. Stopping it + // never implicitly permits cellular registration. + if _, err = bot.server.devices.SetFlight(ctx, physicalID, true); err != nil { + bot.sendText(ctx, config, chatID, "VoWiFi 操作失败:无法进入飞行模式:"+err.Error(), bot.homeKeyboard()) + return + } + iccid := "" + if entry.Snapshot != nil { + iccid = strings.TrimSpace(entry.Snapshot.ICCID) + } + if iccid != "" { + policy, policyErr := bot.server.store.CardPolicy(ctx, iccid) + if errors.Is(policyErr, store.ErrNotFound) { + policy = store.CardPolicy{ICCID: iccid, IPVersion: "IPV4V6"} + policyErr = nil + } + if policyErr != nil { + bot.sendText(ctx, config, chatID, "VoWiFi 操作失败:"+policyErr.Error(), bot.homeKeyboard()) + return + } + policy.VoWiFiEnabled = enabled + policy.AirplaneEnabled = true + policy.NetworkEnabled = false + policy.Source = "manual" + if err = bot.server.store.UpsertCardPolicy(ctx, policy); err != nil { + bot.sendText(ctx, config, chatID, "VoWiFi 操作失败:"+err.Error(), bot.homeKeyboard()) + return + } + } stored.VoWiFiEnabled = enabled if err = bot.server.store.UpsertDevice(ctx, stored); err == nil { state, err = bot.server.vowifi.RequestEnabled(deviceID, enabled) @@ -1346,6 +2166,14 @@ func (bot *telegramBot) handleVoWiFi(ctx context.Context, config telegramRuntime if err != nil { stored.VoWiFiEnabled = previous _ = bot.server.store.UpsertDevice(ctx, stored) + if iccid != "" { + if policy, policyErr := bot.server.store.CardPolicy(ctx, iccid); policyErr == nil { + policy.VoWiFiEnabled = previous + policy.AirplaneEnabled = true + policy.NetworkEnabled = false + _ = bot.server.store.UpsertCardPolicy(ctx, policy) + } + } if errors.Is(err, vowifiruntime.ErrOperationInProgress) && state.Enabled == enabled { err = nil } @@ -1363,9 +2191,9 @@ func (bot *telegramBot) handleVoWiFi(ctx context.Context, config telegramRuntime outcome := "success" if err != nil { outcome = "failure" - bot.sendText(ctx, config, chatID, "VoWiFi 操作失败:"+err.Error(), nil) + bot.sendText(ctx, config, chatID, "VoWiFi 操作失败:"+err.Error(), bot.homeKeyboard()) } else { - bot.sendText(ctx, config, chatID, "VoWiFi 操作已受理。\n"+formatTelegramVoWiFiState(state), nil) + bot.sendText(ctx, config, chatID, "VoWiFi 操作已受理。\n"+formatTelegramVoWiFiState(state), bot.homeKeyboard()) } bot.server.recordAudit(ctx, fmt.Sprintf("telegram:%d", adminID), "telegram.vowifi."+operation, "device", deviceID, outcome, "telegram") } @@ -1374,9 +2202,9 @@ func (bot *telegramBot) finishAction(ctx context.Context, config telegramRuntime outcome := "success" if err != nil { outcome = "failure" - bot.sendText(ctx, config, action.ChatID, "操作失败:"+err.Error(), nil) + bot.sendText(ctx, config, action.ChatID, "操作失败:"+err.Error(), bot.homeKeyboard()) } else { - bot.sendText(ctx, config, action.ChatID, "✅ "+result, nil) + bot.sendText(ctx, config, action.ChatID, "✅ "+result, bot.homeKeyboard()) } bot.server.recordAudit(ctx, fmt.Sprintf("telegram:%d", action.AdminID), auditAction, "device", action.DeviceID, outcome, "telegram") } diff --git a/internal/server/telegram_bot_test.go b/internal/server/telegram_bot_test.go index 6ed625b..509e005 100644 --- a/internal/server/telegram_bot_test.go +++ b/internal/server/telegram_bot_test.go @@ -87,6 +87,59 @@ func TestValidTelegramDialNumber(t *testing.T) { } } +func TestResolveTelegramPhoneNumberPrefersCurrentSIMAssociation(t *testing.T) { + snapshot := &device.Snapshot{ + ICCID: "89441000400128013903", + Phone: device.PhoneNumber{Number: "00000000000"}, + } + state := &vowifi.State{ + ICCID: snapshot.ICCID, + PhoneNumber: "+447386125520", + } + if got := resolveTelegramPhoneNumber("+447700900123", state, snapshot); got != "+447700900123" { + t.Fatalf("resolved association number = %q", got) + } + if got := resolveTelegramPhoneNumber("", state, snapshot); got != "+447386125520" { + t.Fatalf("resolved IMS number = %q", got) + } +} + +func TestResolveTelegramPhoneNumberRejectsPlaceholderAndStaleRuntime(t *testing.T) { + snapshot := &device.Snapshot{ + ICCID: "current-card", + Phone: device.PhoneNumber{Number: "00000000000"}, + } + state := &vowifi.State{ + ICCID: "previous-card", + PhoneNumber: "+447386083638", + } + if got := resolveTelegramPhoneNumber("", state, snapshot); got != "--" { + t.Fatalf("stale or placeholder number leaked as %q", got) + } + for _, placeholder := range []string{"00000000000", "1111111111", "+0000000000", "not-a-number"} { + if usableTelegramPhoneNumber(placeholder) { + t.Errorf("placeholder %q was accepted", placeholder) + } + } +} + +func TestTelegramCarrierPresentationSeparatesHomeAndServingNetworks(t *testing.T) { + if got := telegramHomeCarrier("234336570710174"); !strings.Contains(got, "🇬🇧") || !strings.Contains(got, "23433") { + t.Fatalf("home carrier = %q", got) + } + if got := telegramHomeCarrier("204040123456789", "Lebara"); !strings.Contains(got, "Lebara") || !strings.Contains(got, "20404") || !strings.Contains(got, "🇬🇧") || strings.Contains(got, "🇳🇱") { + t.Fatalf("branded foreign-core carrier = %q", got) + } + flight := &device.Snapshot{FlightMode: true, OperatorName: "stale network", RegistrationStatus: 1} + if got := telegramCurrentNetwork(flight); got != "--(飞行模式)" { + t.Fatalf("flight-mode serving network = %q", got) + } + serving := &device.Snapshot{OperatorCode: "46001", RegistrationStatus: 5, AccessTech: "LTE", Band: "B3"} + if got := telegramCurrentNetwork(serving); !strings.Contains(got, "🇨🇳") || !strings.Contains(got, "已驻网(漫游)") { + t.Fatalf("serving network = %q", got) + } +} + func TestTelegramPendingActionIsAuthorizedOneShot(t *testing.T) { bot := &telegramBot{pending: make(map[string]telegramPendingAction)} action := telegramPendingAction{Kind: "call", ChatID: -1001, AdminID: 42, CreatedAt: time.Now()} @@ -102,6 +155,61 @@ func TestTelegramPendingActionIsAuthorizedOneShot(t *testing.T) { } } +func TestTelegramMenuCallbackParsing(t *testing.T) { + prefix, token, operation, ok := parseTelegramMenuCallback("call:0123456789abcdef:answer") + if !ok || prefix != "call" || token != "0123456789abcdef" || operation != "answer" { + t.Fatalf("parsed callback = %q %q %q %t", prefix, token, operation, ok) + } + for _, invalid := range []string{"", "call:token", "unknown:token:op", "d::status"} { + if _, _, _, ok := parseTelegramMenuCallback(invalid); ok { + t.Fatalf("invalid callback %q was accepted", invalid) + } + } +} + +func TestTelegramMenuPendingCanBeReusedButConfirmationCannot(t *testing.T) { + bot := &telegramBot{pending: make(map[string]telegramPendingAction)} + menuToken, err := bot.putPending(telegramPendingAction{ + Kind: "menu_device", DeviceID: "EC20", ChatID: 1, AdminID: 2, CreatedAt: time.Now(), + }) + if err != nil { + t.Fatal(err) + } + if _, ok := bot.getPending(menuToken, 1, 2); !ok { + t.Fatal("first menu lookup failed") + } + if _, ok := bot.getPending(menuToken, 1, 2); !ok { + t.Fatal("menu token was unexpectedly consumed") + } + confirmToken, err := bot.putPending(telegramPendingAction{ + Kind: "sms", ChatID: 1, AdminID: 2, CreatedAt: time.Now(), + }) + if err != nil { + t.Fatal(err) + } + if _, ok := bot.takePending(confirmToken, 1, 2); !ok { + t.Fatal("confirmation token lookup failed") + } + if _, ok := bot.takePending(confirmToken, 1, 2); ok { + t.Fatal("confirmation token was reusable") + } +} + +func TestTelegramInputStateIsScopedAndCancelable(t *testing.T) { + bot := &telegramBot{inputs: make(map[string]telegramInputState)} + bot.setInput(telegramInputState{Kind: "sms_phone", DeviceID: "EC20", ChatID: 10, AdminID: 20}) + if state, ok := bot.input(10, 20); !ok || state.DeviceID != "EC20" || state.Kind != "sms_phone" { + t.Fatalf("input state = %#v, %t", state, ok) + } + if _, ok := bot.input(10, 21); ok { + t.Fatal("another administrator read the input state") + } + bot.clearInput(10, 20) + if _, ok := bot.input(10, 20); ok { + t.Fatal("cleared input state remained available") + } +} + func TestFormatTelegramATIncludesFinalResult(t *testing.T) { if got := formatTelegramAT(modem.Response{Final: "OK"}); got != "OK" { t.Fatalf("formatTelegramAT(OK) = %q", got) @@ -166,7 +274,7 @@ func TestTelegramExecutesInteractiveUSSDForConfiguredDevice(t *testing.T) { } formatted := formatTelegramUSSD("EC20", result) for _, expected := range []string{ - "设备:EC20", "状态:awaiting_input", "1. Balance", "/ussd_reply 0123456789abcdef", "/ussd_cancel 0123456789abcdef", + "设备:EC20", "状态:awaiting_input", "1. Balance", "请直接发送回复内容", } { if !strings.Contains(formatted, expected) { t.Fatalf("USSD result %q does not contain %q", formatted, expected) diff --git a/internal/store/automatic_tasks.go b/internal/store/automatic_tasks.go new file mode 100644 index 0000000..4aeeaa0 --- /dev/null +++ b/internal/store/automatic_tasks.go @@ -0,0 +1,241 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" +) + +const automaticTaskSelect = ` + SELECT id, name, enabled, device_id, profile_iccid, profile_aid, + task_type, environment, interval_days, start_date, run_time, + timezone, payload_json, retry_count, notify, next_run_at, last_run_at, + last_status, last_error, created_at, updated_at + FROM automatic_tasks` + +func (s *Store) SaveAutomaticTask(ctx context.Context, value AutomaticTask) (AutomaticTask, error) { + now := time.Now().UTC() + if strings.TrimSpace(value.Timezone) == "" { + value.Timezone = time.Local.String() + } + if value.CreatedAt.IsZero() { + value.CreatedAt = now + } + value.UpdatedAt = now + if len(value.Payload) == 0 { + value.Payload = []byte(`{}`) + } + if value.ID == 0 { + result, err := s.db.ExecContext(ctx, `INSERT INTO automatic_tasks ( + name, enabled, device_id, profile_iccid, profile_aid, task_type, + environment, interval_days, start_date, run_time, timezone, payload_json, + retry_count, notify, next_run_at, last_run_at, last_status, + last_error, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + strings.TrimSpace(value.Name), value.Enabled, strings.TrimSpace(value.DeviceID), + strings.TrimSpace(value.ProfileICCID), strings.TrimSpace(value.ProfileAID), + value.TaskType, value.Environment, value.IntervalDays, value.StartDate, + value.RunTime, value.Timezone, string(value.Payload), value.RetryCount, value.Notify, + value.NextRunAt.Unix(), unixOrZero(value.LastRunAt), value.LastStatus, + value.LastError, value.CreatedAt.Unix(), value.UpdatedAt.Unix()) + if err != nil { + return AutomaticTask{}, fmt.Errorf("create automatic task: %w", err) + } + value.ID, _ = result.LastInsertId() + } else { + result, err := s.db.ExecContext(ctx, `UPDATE automatic_tasks SET + name = ?, enabled = ?, device_id = ?, profile_iccid = ?, profile_aid = ?, + task_type = ?, environment = ?, interval_days = ?, start_date = ?, + run_time = ?, timezone = ?, payload_json = ?, retry_count = ?, notify = ?, + next_run_at = ?, updated_at = ? WHERE id = ?`, + strings.TrimSpace(value.Name), value.Enabled, strings.TrimSpace(value.DeviceID), + strings.TrimSpace(value.ProfileICCID), strings.TrimSpace(value.ProfileAID), + value.TaskType, value.Environment, value.IntervalDays, value.StartDate, + value.RunTime, value.Timezone, string(value.Payload), value.RetryCount, value.Notify, + value.NextRunAt.Unix(), value.UpdatedAt.Unix(), value.ID) + if err != nil { + return AutomaticTask{}, fmt.Errorf("update automatic task %d: %w", value.ID, err) + } + if count, _ := result.RowsAffected(); count == 0 { + return AutomaticTask{}, ErrNotFound + } + } + return s.AutomaticTask(ctx, value.ID) +} + +func (s *Store) AutomaticTask(ctx context.Context, id int64) (AutomaticTask, error) { + return scanAutomaticTask(s.db.QueryRowContext(ctx, automaticTaskSelect+` WHERE id = ?`, id)) +} + +func (s *Store) ListAutomaticTasks(ctx context.Context) ([]AutomaticTask, error) { + rows, err := s.db.QueryContext(ctx, automaticTaskSelect+` ORDER BY created_at DESC, id DESC`) + if err != nil { + return nil, fmt.Errorf("list automatic tasks: %w", err) + } + defer rows.Close() + var result []AutomaticTask + for rows.Next() { + value, scanErr := scanAutomaticTask(rows) + if scanErr != nil { + return nil, scanErr + } + result = append(result, value) + } + return result, rows.Err() +} + +func (s *Store) DeleteAutomaticTask(ctx context.Context, id int64) error { + result, err := s.db.ExecContext(ctx, `DELETE FROM automatic_tasks WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("delete automatic task %d: %w", id, err) + } + if count, _ := result.RowsAffected(); count == 0 { + return ErrNotFound + } + return nil +} + +func (s *Store) ClaimDueAutomaticTasks(ctx context.Context, now time.Time, limit int) ([]AutomaticTaskRun, error) { + if limit <= 0 || limit > 100 { + limit = 50 + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, err + } + defer tx.Rollback() + rows, err := tx.QueryContext(ctx, automaticTaskSelect+` + WHERE enabled = 1 AND next_run_at <= ? ORDER BY next_run_at, id LIMIT ?`, now.Unix(), limit) + if err != nil { + return nil, err + } + var tasks []AutomaticTask + for rows.Next() { + task, scanErr := scanAutomaticTask(rows) + if scanErr != nil { + rows.Close() + return nil, scanErr + } + tasks = append(tasks, task) + } + rows.Close() + result := make([]AutomaticTaskRun, 0, len(tasks)) + for _, task := range tasks { + next := task.NextRunAt + location := time.Local + if loaded, loadErr := time.LoadLocation(task.Timezone); loadErr == nil { + location = loaded + } + for !next.After(now) { + next = next.In(location).AddDate(0, 0, task.IntervalDays).UTC() + } + if _, err = tx.ExecContext(ctx, `UPDATE automatic_tasks SET next_run_at = ?, updated_at = ? WHERE id = ?`, next.Unix(), now.Unix(), task.ID); err != nil { + return nil, err + } + created, createErr := tx.ExecContext(ctx, `INSERT INTO automatic_task_runs ( + task_id, device_id, scheduled_at, status, created_at, updated_at + ) VALUES (?, ?, ?, 'queued', ?, ?)`, task.ID, task.DeviceID, task.NextRunAt.Unix(), now.Unix(), now.Unix()) + if createErr != nil { + return nil, createErr + } + runID, _ := created.LastInsertId() + result = append(result, AutomaticTaskRun{ID: runID, TaskID: task.ID, DeviceID: task.DeviceID, ScheduledAt: task.NextRunAt, Status: "queued", CreatedAt: now, UpdatedAt: now}) + } + if err := tx.Commit(); err != nil { + return nil, err + } + return result, nil +} + +func (s *Store) QueueAutomaticTaskNow(ctx context.Context, task AutomaticTask) (AutomaticTaskRun, error) { + now := time.Now().UTC() + result, err := s.db.ExecContext(ctx, `INSERT INTO automatic_task_runs ( + task_id, device_id, scheduled_at, status, created_at, updated_at + ) VALUES (?, ?, ?, 'queued', ?, ?)`, task.ID, task.DeviceID, now.Unix(), now.Unix(), now.Unix()) + if err != nil { + return AutomaticTaskRun{}, fmt.Errorf("queue automatic task: %w", err) + } + id, _ := result.LastInsertId() + return AutomaticTaskRun{ID: id, TaskID: task.ID, DeviceID: task.DeviceID, ScheduledAt: now, Status: "queued", CreatedAt: now, UpdatedAt: now}, nil +} + +func (s *Store) UpdateAutomaticTaskRun(ctx context.Context, run AutomaticTaskRun) error { + now := time.Now().UTC() + _, err := s.db.ExecContext(ctx, `UPDATE automatic_task_runs SET + started_at = ?, finished_at = ?, status = ?, attempts = ?, output = ?, error = ?, updated_at = ? + WHERE id = ?`, unixOrZero(run.StartedAt), unixOrZero(run.FinishedAt), run.Status, + run.Attempts, run.Output, run.Error, now.Unix(), run.ID) + if err != nil { + return fmt.Errorf("update automatic task run %d: %w", run.ID, err) + } + if run.Status == "success" || run.Status == "failed" { + _, err = s.db.ExecContext(ctx, `UPDATE automatic_tasks SET + last_run_at = ?, last_status = ?, last_error = ?, updated_at = ? WHERE id = ?`, + run.FinishedAt.Unix(), run.Status, run.Error, now.Unix(), run.TaskID) + } + return err +} + +func (s *Store) ListAutomaticTaskRuns(ctx context.Context, limit int) ([]AutomaticTaskRun, error) { + if limit <= 0 || limit > 500 { + limit = 100 + } + rows, err := s.db.QueryContext(ctx, `SELECT id, task_id, device_id, scheduled_at, + started_at, finished_at, status, attempts, output, error, created_at, updated_at + FROM automatic_task_runs ORDER BY id DESC LIMIT ?`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var result []AutomaticTaskRun + for rows.Next() { + var value AutomaticTaskRun + var scheduled, started, finished, created, updated int64 + if err := rows.Scan(&value.ID, &value.TaskID, &value.DeviceID, &scheduled, &started, + &finished, &value.Status, &value.Attempts, &value.Output, &value.Error, &created, &updated); err != nil { + return nil, err + } + value.ScheduledAt, value.StartedAt, value.FinishedAt = time.Unix(scheduled, 0).UTC(), timeFromUnix(started), timeFromUnix(finished) + value.CreatedAt, value.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC() + result = append(result, value) + } + return result, rows.Err() +} + +func scanAutomaticTask(row rowScanner) (AutomaticTask, error) { + var value AutomaticTask + var enabled, notify bool + var payload string + var nextRun, lastRun, created, updated int64 + if err := row.Scan(&value.ID, &value.Name, &enabled, &value.DeviceID, &value.ProfileICCID, + &value.ProfileAID, &value.TaskType, &value.Environment, &value.IntervalDays, + &value.StartDate, &value.RunTime, &value.Timezone, &payload, &value.RetryCount, ¬ify, + &nextRun, &lastRun, &value.LastStatus, &value.LastError, &created, &updated); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return AutomaticTask{}, ErrNotFound + } + return AutomaticTask{}, err + } + value.Enabled, value.Notify = enabled, notify + value.Payload = []byte(payload) + value.NextRunAt, value.LastRunAt = time.Unix(nextRun, 0).UTC(), timeFromUnix(lastRun) + value.CreatedAt, value.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC() + return value, nil +} + +func unixOrZero(value time.Time) int64 { + if value.IsZero() { + return 0 + } + return value.Unix() +} + +func timeFromUnix(value int64) time.Time { + if value <= 0 { + return time.Time{} + } + return time.Unix(value, 0).UTC() +} diff --git a/internal/store/automatic_tasks_test.go b/internal/store/automatic_tasks_test.go new file mode 100644 index 0000000..14b5e0e --- /dev/null +++ b/internal/store/automatic_tasks_test.go @@ -0,0 +1,72 @@ +package store + +import ( + "context" + "encoding/json" + "path/filepath" + "testing" + "time" +) + +func TestAutomaticTasksAreClaimedInDeviceQueueOrderAndAdvanceSchedule(t *testing.T) { + ctx := context.Background() + database := openTestStore(t, filepath.Join(t.TempDir(), "automatic-tasks.db")) + mustSaveDevice(t, database, "ec20", "EC20") + now := time.Now().UTC().Truncate(time.Second) + for index := 0; index < 2; index++ { + payload, _ := json.Marshal(map[string]any{"phone": "10086", "message": "test"}) + if _, err := database.SaveAutomaticTask(ctx, AutomaticTask{ + Name: "task", Enabled: true, DeviceID: "ec20", ProfileICCID: "8944100000000000000", + TaskType: "sms", Environment: "vowifi", IntervalDays: 2, + StartDate: "2026-08-10", RunTime: "12:00", Timezone: "Asia/Shanghai", Payload: payload, + NextRunAt: now.Add(time.Duration(index-2) * time.Minute), + }); err != nil { + t.Fatal(err) + } + } + runs, err := database.ClaimDueAutomaticTasks(ctx, now, 10) + if err != nil { + t.Fatal(err) + } + if len(runs) != 2 || runs[0].DeviceID != "ec20" || runs[1].DeviceID != "ec20" || runs[0].TaskID >= runs[1].TaskID { + t.Fatalf("claimed runs = %+v", runs) + } + tasks, err := database.ListAutomaticTasks(ctx) + if err != nil { + t.Fatal(err) + } + for _, task := range tasks { + if !task.NextRunAt.After(now) { + t.Fatalf("task %d next run was not advanced: %v", task.ID, task.NextRunAt) + } + } + second, err := database.ClaimDueAutomaticTasks(ctx, now, 10) + if err != nil || len(second) != 0 { + t.Fatalf("same schedule claimed twice: %+v, %v", second, err) + } +} + +func TestDeletingAutomaticTaskRemovesRunHistory(t *testing.T) { + ctx := context.Background() + database := openTestStore(t, filepath.Join(t.TempDir(), "automatic-task-delete.db")) + mustSaveDevice(t, database, "ec20", "EC20") + task, err := database.SaveAutomaticTask(ctx, AutomaticTask{ + Name: "task", Enabled: true, DeviceID: "ec20", ProfileICCID: "one", + TaskType: "call", Environment: "cellular", IntervalDays: 1, + StartDate: "2026-08-10", RunTime: "12:00", Timezone: "Asia/Shanghai", Payload: []byte(`{"phone":"10086","duration_seconds":10}`), + NextRunAt: time.Now().Add(time.Hour), + }) + if err != nil { + t.Fatal(err) + } + if _, err := database.QueueAutomaticTaskNow(ctx, task); err != nil { + t.Fatal(err) + } + if err := database.DeleteAutomaticTask(ctx, task.ID); err != nil { + t.Fatal(err) + } + runs, err := database.ListAutomaticTaskRuns(ctx, 10) + if err != nil || len(runs) != 0 { + t.Fatalf("orphan runs = %+v, %v", runs, err) + } +} diff --git a/internal/store/domain_test.go b/internal/store/domain_test.go index fe7b839..a2cb420 100644 --- a/internal/store/domain_test.go +++ b/internal/store/domain_test.go @@ -105,6 +105,43 @@ func TestMigration7BackfillsSMSModemIMEI(t *testing.T) { } } +func TestMigration9NormalizesVoWiFiAirplanePolicy(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "rf-safe-policy.db") + raw, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + for version := 1; version <= 8; version++ { + for _, statement := range migrationStatements(version) { + if _, err := raw.ExecContext(ctx, statement); err != nil { + t.Fatalf("create v%d schema: %v", version, err) + } + } + } + if _, err := raw.ExecContext(ctx, ` + INSERT INTO card_policies ( + iccid, network_enabled, vowifi_enabled, airplane_enabled, + created_at, updated_at + ) VALUES ('8900000000000000001', 0, 1, 0, 100, 100); + PRAGMA user_version = 8; + `); err != nil { + t.Fatal(err) + } + if err := raw.Close(); err != nil { + t.Fatal(err) + } + + database := openTestStore(t, path) + policy, err := database.CardPolicy(ctx, "8900000000000000001") + if err != nil { + t.Fatal(err) + } + if !policy.VoWiFiEnabled || !policy.AirplaneEnabled || policy.NetworkEnabled { + t.Fatalf("migrated policy = %#v, want VoWiFi+airplane with data off", policy) + } +} + func TestMigration8DefaultsExistingDevicesToPCIeType(t *testing.T) { ctx := context.Background() path := filepath.Join(t.TempDir(), "device-type.db") @@ -709,14 +746,18 @@ func TestEventsPoliciesAndTraffic(t *testing.T) { t.Fatal(err) } if err := database.UpsertCardPolicy(ctx, CardPolicy{ - ICCID: "invalid", VoWiFiEnabled: true, AirplaneEnabled: true, - }); err == nil { - t.Fatal("invalid mutually exclusive card policy was accepted") + ICCID: "89860002", VoWiFiEnabled: true, AirplaneEnabled: true, + }); err != nil { + t.Fatalf("RF-safe VoWiFi policy was rejected: %v", err) } policy, err := database.CardPolicy(ctx, "89860001") if err != nil || !policy.VoWiFiEnabled { t.Fatalf("CardPolicy() = %+v, %v", policy, err) } + safePolicy, err := database.CardPolicy(ctx, "89860002") + if err != nil || !safePolicy.VoWiFiEnabled || !safePolicy.AirplaneEnabled { + t.Fatalf("safe CardPolicy() = %+v, %v", safePolicy, err) + } period := old.Truncate(time.Hour) if err := database.UpsertTrafficBucket(ctx, TrafficBucket{ diff --git a/internal/store/migrations.go b/internal/store/migrations.go index 834233c..03d86f1 100644 --- a/internal/store/migrations.go +++ b/internal/store/migrations.go @@ -111,6 +111,81 @@ func migrationStatements(version int) []string { `ALTER TABLE devices ADD COLUMN device_type TEXT NOT NULL DEFAULT 'pcie_ec20_ec25'`, } + case 9: + return []string{ + // VoWiFi deliberately owns airplane mode. Earlier schemas treated + // these flags as mutually exclusive, which made the RF-safe state + // impossible to persist. Rebuild the table without changing rows. + `ALTER TABLE card_policies RENAME TO card_policies_v8`, + `CREATE TABLE card_policies ( + iccid TEXT PRIMARY KEY, + network_enabled INTEGER NOT NULL DEFAULT 0 CHECK (network_enabled IN (0, 1)), + vowifi_enabled INTEGER NOT NULL DEFAULT 0 CHECK (vowifi_enabled IN (0, 1)), + airplane_enabled INTEGER NOT NULL DEFAULT 0 CHECK (airplane_enabled IN (0, 1)), + apn TEXT NOT NULL DEFAULT '', + ip_version TEXT NOT NULL DEFAULT '', + source TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + )`, + `INSERT INTO card_policies ( + iccid, network_enabled, vowifi_enabled, airplane_enabled, + apn, ip_version, source, created_at, updated_at + ) SELECT + iccid, network_enabled, vowifi_enabled, airplane_enabled, + apn, ip_version, source, created_at, updated_at + FROM card_policies_v8`, + `UPDATE card_policies + SET airplane_enabled = 1, network_enabled = 0 + WHERE vowifi_enabled = 1`, + `DROP TABLE card_policies_v8`, + } + case 10: + return []string{ + `CREATE TABLE IF NOT EXISTS automatic_tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)), + device_id TEXT NOT NULL, + profile_iccid TEXT NOT NULL, + profile_aid TEXT NOT NULL DEFAULT '', + task_type TEXT NOT NULL CHECK (task_type IN ('sms', 'call', 'public_ip')), + environment TEXT NOT NULL CHECK (environment IN ('vowifi', 'cellular')), + interval_days INTEGER NOT NULL CHECK (interval_days BETWEEN 1 AND 365), + start_date TEXT NOT NULL, + run_time TEXT NOT NULL, + timezone TEXT NOT NULL DEFAULT 'Local', + payload_json TEXT NOT NULL DEFAULT '{}', + retry_count INTEGER NOT NULL DEFAULT 0 CHECK (retry_count BETWEEN 0 AND 10), + notify INTEGER NOT NULL DEFAULT 0 CHECK (notify IN (0, 1)), + next_run_at INTEGER NOT NULL, + last_run_at INTEGER NOT NULL DEFAULT 0, + last_status TEXT NOT NULL DEFAULT '', + last_error TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE + )`, + `CREATE INDEX IF NOT EXISTS automatic_tasks_due_idx ON automatic_tasks(enabled, next_run_at, id)`, + `CREATE INDEX IF NOT EXISTS automatic_tasks_device_idx ON automatic_tasks(device_id, next_run_at, id)`, + `CREATE TABLE IF NOT EXISTS automatic_task_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id INTEGER NOT NULL, + device_id TEXT NOT NULL, + scheduled_at INTEGER NOT NULL, + started_at INTEGER NOT NULL DEFAULT 0, + finished_at INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL CHECK (status IN ('queued', 'running', 'success', 'failed')), + attempts INTEGER NOT NULL DEFAULT 0, + output TEXT NOT NULL DEFAULT '', + error TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (task_id) REFERENCES automatic_tasks(id) ON DELETE CASCADE + )`, + `CREATE INDEX IF NOT EXISTS automatic_task_runs_task_idx ON automatic_task_runs(task_id, id DESC)`, + `CREATE INDEX IF NOT EXISTS automatic_task_runs_status_idx ON automatic_task_runs(status, id)`, + } default: return nil } diff --git a/internal/store/models.go b/internal/store/models.go index 34cb38c..eab9e6c 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -124,6 +124,45 @@ type PhoneAssociation struct { UpdatedAt time.Time } +type AutomaticTask struct { + ID int64 `json:"id"` + Name string `json:"name"` + Enabled bool `json:"enabled"` + DeviceID string `json:"device_id"` + ProfileICCID string `json:"profile_iccid"` + ProfileAID string `json:"profile_aid"` + TaskType string `json:"task_type"` + Environment string `json:"environment"` + IntervalDays int `json:"interval_days"` + StartDate string `json:"start_date"` + RunTime string `json:"run_time"` + Timezone string `json:"timezone"` + Payload json.RawMessage `json:"payload"` + RetryCount int `json:"retry_count"` + Notify bool `json:"notify"` + NextRunAt time.Time `json:"next_run_at"` + LastRunAt time.Time `json:"last_run_at,omitempty"` + LastStatus string `json:"last_status"` + LastError string `json:"last_error"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type AutomaticTaskRun struct { + ID int64 `json:"id"` + TaskID int64 `json:"task_id"` + DeviceID string `json:"device_id"` + ScheduledAt time.Time `json:"scheduled_at"` + StartedAt time.Time `json:"started_at,omitempty"` + FinishedAt time.Time `json:"finished_at,omitempty"` + Status string `json:"status"` + Attempts int `json:"attempts"` + Output string `json:"output"` + Error string `json:"error"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + type SMSMessage struct { ID int64 MessageID string diff --git a/internal/store/settings.go b/internal/store/settings.go index 7aefd06..3e9bb26 100644 --- a/internal/store/settings.go +++ b/internal/store/settings.go @@ -369,9 +369,6 @@ func (s *Store) UpsertCardPolicy(ctx context.Context, value CardPolicy) error { default: return fmt.Errorf("unsupported card policy IP version %q", value.IPVersion) } - if value.VoWiFiEnabled && value.AirplaneEnabled { - return errors.New("VoWiFi and airplane mode cannot both be enabled") - } now := time.Now().UTC() createdAt := value.CreatedAt if createdAt.IsZero() { diff --git a/internal/store/store.go b/internal/store/store.go index a214e89..2dfb792 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -13,7 +13,7 @@ import ( _ "modernc.org/sqlite" ) -const schemaVersion = 8 +const schemaVersion = 10 var ErrNotFound = errors.New("store: not found") diff --git a/internal/vowifi/ec20_adapter.go b/internal/vowifi/ec20_adapter.go index 0fa94ac..537311b 100644 --- a/internal/vowifi/ec20_adapter.go +++ b/internal/vowifi/ec20_adapter.go @@ -222,14 +222,29 @@ func (adapter *EC20Adapter) readHomePLMN( } // Exact assigned HPLMN prefixes are data, not an MNC-length heuristic. The // target Vodafone UK SIM is 234/15. Unknown assignments remain fail-closed. - for prefix, mncLength := range map[string]int{"23415": 2} { - if strings.HasPrefix(imsi, prefix) { - return imsi[:3], imsi[3 : 3+mncLength], nil - } + if mcc, mnc, ok := assignedHomePLMN(imsi); ok { + return mcc, mnc, nil } return "", "", efErr } +func assignedHomePLMN(imsi string) (mcc, mnc string, ok bool) { + assignments := []struct { + prefix string + mncLength int + }{ + {prefix: "20404", mncLength: 2}, // Vodafone NL core; some Lebara subscriptions. + {prefix: "23415", mncLength: 2}, // Vodafone UK. + {prefix: "23487", mncLength: 2}, // Lebara Mobile UK. + } + for _, assignment := range assignments { + if strings.HasPrefix(imsi, assignment.prefix) { + return imsi[:3], imsi[3 : 3+assignment.mncLength], true + } + } + return "", "", false +} + func validConfiguredHomePLMN(imsi, mcc, mnc string) bool { if !validDigits(mcc, 3, 3) || !validDigits(mnc, 2, 3) { return false @@ -658,15 +673,22 @@ func (adapter *EC20Adapter) Restore( if snapshot.OperatingMode < 0 { return errors.New("vocat: invalid EC20 radio snapshot") } + targetMode := snapshot.OperatingMode + if snapshot.PureAirplanePolicy { + // VoWiFi teardown is intentionally fail-closed. Even if the modem was in + // CFUN=1 before setup, disabling VoWiFi must leave RF off until the user + // explicitly turns airplane mode off through the separate control. + targetMode = 4 + } currentMode, err := adapter.readOperatingMode(ctx, deviceID) if err != nil { return err } - if currentMode != snapshot.OperatingMode { + if currentMode != targetMode { if _, err := adapter.execute( ctx, deviceID, - fmt.Sprintf("AT+CFUN=%d", snapshot.OperatingMode), + fmt.Sprintf("AT+CFUN=%d", targetMode), ); err != nil { return fmt.Errorf("restore EC20 operating mode: %w", err) } @@ -675,11 +697,11 @@ func (adapter *EC20Adapter) Restore( if err != nil { return err } - if currentMode != snapshot.OperatingMode { + if currentMode != targetMode { return fmt.Errorf( "vocat: EC20 restore reported CFUN=%d, expected %d", currentMode, - snapshot.OperatingMode, + targetMode, ) } @@ -697,7 +719,7 @@ func (adapter *EC20Adapter) Restore( return errors.New("vocat: EC20 snapshot has active data in RF-off mode") } desiredCIDs := checkpoint.activeCIDs - if !adapter.options.RestoreCellularData { + if !adapter.options.RestoreCellularData || targetMode == 0 || targetMode == 4 { desiredCIDs = nil } if err := adapter.reconcileActiveCIDs( diff --git a/internal/vowifi/ec20_adapter_test.go b/internal/vowifi/ec20_adapter_test.go index 21d5149..6d28cdf 100644 --- a/internal/vowifi/ec20_adapter_test.go +++ b/internal/vowifi/ec20_adapter_test.go @@ -416,6 +416,20 @@ func TestEC20AdapterReadsExplicitHomePLMNAndKnownAssignmentFallback( } } +func TestAssignedHomePLMNIncludesLebaraUKCores(t *testing.T) { + tests := map[string]string{ + "204040123456789": "204/04", + "234150123456789": "234/15", + "234870123456789": "234/87", + } + for imsi, want := range tests { + mcc, mnc, ok := assignedHomePLMN(imsi) + if got := mcc + "/" + mnc; !ok || got != want { + t.Errorf("assignedHomePLMN(%q) = %q, %v; want %q", imsi, got, ok, want) + } + } +} + func TestEC20AdapterRadioTransactionRestoresCFUNAndPDPContexts( t *testing.T, ) { @@ -440,16 +454,14 @@ func TestEC20AdapterRadioTransactionRestoresCFUNAndPDPContexts( lines: []string{"+CGACT: 1,0", "+CGACT: 2,0"}, }, {command: "AT+CFUN?", lines: []string{"+CFUN: 4"}}, - {command: "AT+CFUN=1"}, - {command: "AT+CFUN?", lines: []string{"+CFUN: 1"}}, + {command: "AT+CFUN?", lines: []string{"+CFUN: 4"}}, { command: "AT+CGACT?", lines: []string{"+CGACT: 1,0", "+CGACT: 2,0"}, }, - {command: "AT+CGACT=1,1"}, { command: "AT+CGACT?", - lines: []string{"+CGACT: 1,1", "+CGACT: 2,0"}, + lines: []string{"+CGACT: 1,0", "+CGACT: 2,0"}, }, }, } diff --git a/internal/vowifi/ike/provider.go b/internal/vowifi/ike/provider.go index 78bec5f..03a77a7 100644 --- a/internal/vowifi/ike/provider.go +++ b/internal/vowifi/ike/provider.go @@ -110,7 +110,7 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques }() group := uint16(dhMODP2048) - legacyFirst := request.Identity.HomeMCC == "234" && request.Identity.HomeMNC == "15" + legacyFirst := legacyIKEProfile(request.Identity.HomeMCC, request.Identity.HomeMNC) if legacyFirst { group = dhMODP1024 } @@ -544,6 +544,15 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques return session, nil } +func legacyIKEProfile(mcc, mnc string) bool { + // Vodafone's UK and Netherlands ePDGs use the legacy group-2/SHA-1-first + // proposal ordering. Some Lebara UK subscriptions carry a 204-04 IMSI from + // that Vodafone NL core; treating them as a generic modern network causes + // IKE_SA_INIT to fail before EAP-AKA even begins. + plmn := strings.TrimSpace(mcc) + strings.TrimLeft(strings.TrimSpace(mnc), "0") + return plmn == "23415" || plmn == "2044" +} + func buildInitialEAPOnlyAuth( idi payload, requestedIDr payload, diff --git a/internal/vowifi/ike/provider_test.go b/internal/vowifi/ike/provider_test.go index c2e3277..224118a 100644 --- a/internal/vowifi/ike/provider_test.go +++ b/internal/vowifi/ike/provider_test.go @@ -16,6 +16,24 @@ var errFirstAuthObserved = errors.New("test: first IKE_AUTH observed") type constantReader struct{ value byte } +func TestLegacyIKEProfileIncludesVodafoneHostedLebaraCore(t *testing.T) { + for _, item := range []struct { + mcc string + mnc string + }{ + {mcc: "234", mnc: "15"}, + {mcc: "204", mnc: "04"}, + {mcc: "204", mnc: "004"}, + } { + if !legacyIKEProfile(item.mcc, item.mnc) { + t.Errorf("legacyIKEProfile(%q, %q) = false", item.mcc, item.mnc) + } + } + if legacyIKEProfile("234", "87") { + t.Fatal("Lebara's 234-87 core must use the modern IKE profile") + } +} + func (reader constantReader) Read(destination []byte) (int, error) { for index := range destination { destination[index] = reader.value diff --git a/internal/vowifi/integration/at.go b/internal/vowifi/integration/at.go index bdc7174..4613502 100644 --- a/internal/vowifi/integration/at.go +++ b/internal/vowifi/integration/at.go @@ -63,33 +63,57 @@ func (mapper ATMapper) resolve( if mapper.Store == nil || mapper.Devices == nil { return "", errors.New("vowifi AT mapper is not configured") } - if entry, err := mapper.Devices.Get(configuredID); err == nil && entry.Discovered { - return entry.ID, nil - } config, err := mapper.Store.Device(ctx, configuredID) if err != nil { return "", err } + // Score every physical candidate before choosing one. Returning the first + // partial match made two EC20s on the same hub vulnerable to iteration order: + // a stale USB path on one configured row could win before the other + // candidate's AT port, QMI node, or live IMEI was considered. The result was + // two logical devices issuing APDUs to the same SIM. + bestID := "" + bestScore := 0 for _, entry := range mapper.Devices.List() { if !entry.Discovered { continue } - candidate := entry.Candidate - switch { - case config.ATPort != "" && - (config.ATPort == candidate.ATPort.Path || - config.ATPort == candidate.ATPort.OpenPath()): - return entry.ID, nil - case config.USBPath != "" && config.USBPath == candidate.USBPath: - return entry.ID, nil - case config.ControlDevice != "" && - (config.ControlDevice == candidate.QMIControl || - config.ControlDevice == candidate.ATPort.OpenPath()): - return entry.ID, nil - case config.ModemIMEI != "" && entry.Snapshot != nil && - config.ModemIMEI == strings.TrimSpace(entry.Snapshot.IMEI): - return entry.ID, nil + score := physicalMatchScore(configuredID, config, entry) + if score > bestScore { + bestID = entry.ID + bestScore = score } } + if bestID != "" { + return bestID, nil + } return "", device.ErrNotFound } + +func physicalMatchScore(configuredID string, config store.Device, entry device.Device) int { + candidate := entry.Candidate + score := 0 + // A live modem identity is the strongest evidence and must override stale + // Linux node names or a USB topology saved before devices were rearranged. + if config.ModemIMEI != "" && entry.Snapshot != nil && + strings.EqualFold(strings.TrimSpace(config.ModemIMEI), strings.TrimSpace(entry.Snapshot.IMEI)) { + score += 10000 + } + if config.ATPort != "" && + (config.ATPort == candidate.ATPort.Path || config.ATPort == candidate.ATPort.OpenPath()) { + score += 300 + } + if config.ControlDevice != "" && + (config.ControlDevice == candidate.QMIControl || config.ControlDevice == candidate.ATPort.OpenPath()) { + score += 300 + } + if config.USBPath != "" && config.USBPath == candidate.USBPath { + score += 100 + } + // Discovery IDs are not persistent user IDs. Treat an exact text match only + // as a weak hint so it cannot override physical identity evidence. + if entry.ID == configuredID { + score += 25 + } + return score +} diff --git a/internal/vowifi/integration/at_test.go b/internal/vowifi/integration/at_test.go index 438d0ba..64fd0f9 100644 --- a/internal/vowifi/integration/at_test.go +++ b/internal/vowifi/integration/at_test.go @@ -87,3 +87,88 @@ func TestATMapperResolvesConfiguredIDByStableATPath(t *testing.T) { t.Fatalf("ExecuteSensitiveAT physical ID = %q", devices.sensitiveID) } } + +func TestATMapperScoresAllCandidatesBeforeUsingStaleUSBPath(t *testing.T) { + database := testStore(t) + if err := database.UpsertDevice(context.Background(), store.Device{ + ID: "ec20_1", + Name: "EC20 1", + ATPort: "/dev/ttyUSB2", + ControlDevice: "/dev/cdc-wdm0", + // Simulate metadata left from a formerly swapped hub mapping. + USBPath: "/sys/bus/usb/devices/1-6", + ModemIMEI: "111111111111111", + }); err != nil { + t.Fatal(err) + } + devices := &fakeATDevices{entries: []device.Device{ + { + ID: "quectel-0125-1-6", + Discovered: true, + Candidate: modem.Candidate{ + USBPath: "/sys/bus/usb/devices/1-6", + QMIControl: "/dev/cdc-wdm1", + ATPort: modem.Port{Path: "/dev/ttyUSB6"}, + }, + }, + { + ID: "quectel-0306-1-5", + Discovered: true, + Candidate: modem.Candidate{ + USBPath: "/sys/bus/usb/devices/1-5", + QMIControl: "/dev/cdc-wdm0", + ATPort: modem.Port{Path: "/dev/ttyUSB2"}, + }, + }, + }} + mapper := ATMapper{Store: database, Devices: devices} + if _, err := mapper.ExecuteAT(context.Background(), "ec20_1", "AT+CIMI"); err != nil { + t.Fatal(err) + } + if devices.executedID != "quectel-0306-1-5" { + t.Fatalf("ExecuteAT physical ID = %q, want coherent AT/QMI candidate", devices.executedID) + } +} + +func TestATMapperPrefersLiveIMEIOverAllStalePaths(t *testing.T) { + database := testStore(t) + if err := database.UpsertDevice(context.Background(), store.Device{ + ID: "ec20_1", + Name: "EC20 1", + ATPort: "/dev/ttyUSB2", + ControlDevice: "/dev/cdc-wdm0", + USBPath: "/sys/bus/usb/devices/1-5", + ModemIMEI: "222222222222222", + }); err != nil { + t.Fatal(err) + } + devices := &fakeATDevices{entries: []device.Device{ + { + ID: "old-paths", + Discovered: true, + Candidate: modem.Candidate{ + USBPath: "/sys/bus/usb/devices/1-5", + QMIControl: "/dev/cdc-wdm0", + ATPort: modem.Port{Path: "/dev/ttyUSB2"}, + }, + Snapshot: &device.Snapshot{IMEI: "111111111111111"}, + }, + { + ID: "live-imei", + Discovered: true, + Candidate: modem.Candidate{ + USBPath: "/sys/bus/usb/devices/2-3", + QMIControl: "/dev/cdc-wdm4", + ATPort: modem.Port{Path: "/dev/ttyUSB10"}, + }, + Snapshot: &device.Snapshot{IMEI: "222222222222222"}, + }, + }} + mapper := ATMapper{Store: database, Devices: devices} + if _, err := mapper.ExecuteAT(context.Background(), "ec20_1", "AT+CIMI"); err != nil { + t.Fatal(err) + } + if devices.executedID != "live-imei" { + t.Fatalf("ExecuteAT physical ID = %q, want live IMEI candidate", devices.executedID) + } +} diff --git a/internal/vowifi/orchestrator.go b/internal/vowifi/orchestrator.go index 2ab939d..6b00d3c 100644 --- a/internal/vowifi/orchestrator.go +++ b/internal/vowifi/orchestrator.go @@ -106,8 +106,8 @@ func (orchestrator *Orchestrator) Subscribe(buffer int) (<-chan State, func()) { } // Enable executes one evidence-backed transaction. The order intentionally -// follows the working Linux/QMI path: live identity and home PLMN, AKA -// availability, ePDG derivation, runtime-owned RF off, cellular-data stop, +// follows the working Linux/QMI path: snapshot and disable cellular RF first, +// then read the live identity/home PLMN, verify AKA availability, derive ePDG, // country proxy resolution, SWu tunnel, IMS registration, and SMS readiness. func (orchestrator *Orchestrator) Enable(ctx context.Context) (State, error) { if ctx == nil { @@ -143,20 +143,37 @@ func (orchestrator *Orchestrator) Enable(ctx context.Context) (State, error) { } }) + // A failed attempt deliberately retains the original radio checkpoint and + // keeps CFUN=4. Automatic retries must rebuild only the Wi-Fi/IKE/IMS layers; + // restoring CFUN=1 between attempts can briefly register on a visited network + // and trigger roaming/welcome SMS messages. Explicit Disable is the only path + // that restores the pre-VoWiFi radio mode. + orchestrator.mu.Lock() + retained := orchestrator.resources + orchestrator.mu.Unlock() runtimeContext, runtimeCancel := context.WithCancel(context.Background()) resources := &runtimeResources{cancel: runtimeCancel} + if current.Phase == PhaseFailed && retained != nil && retained.radioChanged { + resources.radio = retained.radio + resources.radioChanged = true + } orchestrator.mu.Lock() orchestrator.resources = resources orchestrator.mu.Unlock() setupContext, stopSetup := mergedContext(ctx, runtimeContext) defer stopSetup() + var err error fail := func(stage Phase, cause error) (State, error) { runtimeCancel() - cleanupErrors := orchestrator.cleanup(resources) + cleanupErrors := orchestrator.cleanupSessions(resources) orchestrator.mu.Lock() - orchestrator.resources = nil + if resources.radioChanged { + orchestrator.resources = resources + } else { + orchestrator.resources = nil + } orchestrator.mu.Unlock() orchestrator.mutate(func(state *State) { @@ -181,6 +198,28 @@ func (orchestrator *Orchestrator) Enable(ctx context.Context) (State, error) { return orchestrator.State(), stageError } + if !resources.radioChanged { + resources.radio, err = orchestrator.deps.Radio.Snapshot(setupContext, orchestrator.options.DeviceID) + if err != nil { + return fail(PhaseAccessReady, err) + } + orchestrator.mutate(func(state *State) { + state.PureAirplanePolicy = resources.radio.PureAirplanePolicy + }) + // Mark the transaction before the mutating call: a provider may return + // an error after partially changing the modem. + resources.radioChanged = true + } + // RF-off is established before any SIM/AKA probing. Those operations are + // local UICC APDUs and remain available in CFUN=4; no serving-cell attach is + // required or permitted during VoWiFi setup. + if err := orchestrator.deps.Radio.EnterVoWiFiRFOff(setupContext, orchestrator.options.DeviceID); err != nil { + return fail(PhaseAccessReady, err) + } + if err := orchestrator.deps.Radio.StopCellularData(setupContext, orchestrator.options.DeviceID); err != nil { + return fail(PhaseAccessReady, err) + } + identity, err := orchestrator.deps.SIM.ReadIdentity(setupContext, orchestrator.options.DeviceID) if err != nil { return fail(PhaseSIMReady, err) @@ -216,29 +255,6 @@ func (orchestrator *Orchestrator) Enable(ctx context.Context) (State, error) { if err != nil { return fail(PhaseAccessReady, err) } - resources.radio, err = orchestrator.deps.Radio.Snapshot(setupContext, orchestrator.options.DeviceID) - if err != nil { - return fail(PhaseAccessReady, err) - } - orchestrator.mutate(func(state *State) { - state.PureAirplanePolicy = resources.radio.PureAirplanePolicy - }) - // Mark the radio transaction before the first mutating call: a provider - // may return an error after partially changing the modem. - resources.radioChanged = true - // Enter RF-off before reconciling PDP contexts. Some QMI-capable EC20 - // firmware automatically owns CID 1 while CFUN=1 and rejects a direct - // CGACT=0 command even though the Linux data interface is down. CFUN=4 - // tears down packet service at the baseband; StopCellularData then acts as - // a fail-closed verification and removes any context that unexpectedly - // survived RF-off. - if err := orchestrator.deps.Radio.EnterVoWiFiRFOff(setupContext, orchestrator.options.DeviceID); err != nil { - return fail(PhaseAccessReady, err) - } - if err := orchestrator.deps.Radio.StopCellularData(setupContext, orchestrator.options.DeviceID); err != nil { - return fail(PhaseAccessReady, err) - } - proxy, err := orchestrator.deps.Proxy.Resolve(setupContext, ProxyRequest{ DeviceID: orchestrator.options.DeviceID, HomeMCC: strings.TrimSpace(identity.HomeMCC), @@ -484,14 +500,32 @@ func (orchestrator *Orchestrator) Reconnect(ctx context.Context) (State, error) if !current.Enabled && current.Phase == PhaseIdle { return current, ErrNotRunning } - // Teardown during a reconnect is best-effort. Disable already releases the - // local IMS, tunnel, and radio resources, so a non-fatal cleanup error - // (e.g. the network rejecting SIP deregistration) must not block the - // rebuild — otherwise the device wedges in PhaseFailed. Only propagate - // errors that prevented the teardown itself (e.g. the operation lock). - if _, err := orchestrator.Disable(ctx); err != nil && !errors.Is(err, ErrCleanupIncomplete) { + if ctx == nil { + ctx = context.Background() + } + if err := orchestrator.lockOperation(ctx); err != nil { return orchestrator.State(), err } + orchestrator.mu.Lock() + resources := orchestrator.resources + orchestrator.mu.Unlock() + if resources != nil && resources.cancel != nil { + resources.cancel() + } + cleanupErrors := orchestrator.cleanupSessions(resources) + orchestrator.mutate(func(state *State) { + state.Phase = PhaseFailed + state.Enabled = true + state.Active = false + state.TunnelReady = false + state.IMSReady = false + state.SMSReady = false + state.LastReason = "reconnect_requested" + state.CleanupErrors = append([]string(nil), cleanupErrors...) + }) + orchestrator.unlockOperation() + // Keep the radio checkpoint and CFUN=4 across a reconnect. Re-enabling RF + // for even a short window defeats airplane-first VoWiFi behavior. return orchestrator.Enable(ctx) } @@ -679,6 +713,25 @@ func securityAuditFromEvidence(evidence TunnelEvidence) SecurityAudit { } func (orchestrator *Orchestrator) cleanup(resources *runtimeResources) []string { + if resources == nil { + return nil + } + cleanupErrors := orchestrator.cleanupSessions(resources) + if resources.radioChanged { + if err := orchestrator.cleanupCall(func(ctx context.Context) error { + return orchestrator.deps.Radio.Restore(ctx, orchestrator.options.DeviceID, resources.radio) + }); err != nil { + cleanupErrors = append(cleanupErrors, "restore radio: "+err.Error()) + } + resources.radioChanged = false + } + return cleanupErrors +} + +// cleanupSessions releases network-layer resources without restoring cellular +// RF. It is used while VoWiFi remains the desired policy, including failed +// automatic retries and manual reconnects. +func (orchestrator *Orchestrator) cleanupSessions(resources *runtimeResources) []string { if resources == nil { return nil } @@ -695,14 +748,6 @@ func (orchestrator *Orchestrator) cleanup(resources *runtimeResources) []string } resources.tunnel = nil } - if resources.radioChanged { - if err := orchestrator.cleanupCall(func(ctx context.Context) error { - return orchestrator.deps.Radio.Restore(ctx, orchestrator.options.DeviceID, resources.radio) - }); err != nil { - cleanupErrors = append(cleanupErrors, "restore radio: "+err.Error()) - } - resources.radioChanged = false - } return cleanupErrors } @@ -802,10 +847,14 @@ func (orchestrator *Orchestrator) watchRuntimeFailure( if !current { return } - cleanupErrors := orchestrator.cleanup(resources) + cleanupErrors := orchestrator.cleanupSessions(resources) orchestrator.mu.Lock() if orchestrator.resources == resources { - orchestrator.resources = nil + if resources.radioChanged { + orchestrator.resources = resources + } else { + orchestrator.resources = nil + } } orchestrator.mu.Unlock() orchestrator.mutate(func(state *State) { diff --git a/internal/vowifi/orchestrator_test.go b/internal/vowifi/orchestrator_test.go index 889303b..2cd9731 100644 --- a/internal/vowifi/orchestrator_test.go +++ b/internal/vowifi/orchestrator_test.go @@ -366,11 +366,11 @@ func TestEnableUsesEvidenceBackedOrderAndDisableRollsBackInReverse(t *testing.T) } wantEnableCalls := []string{ - "sim.identity", - "aka.ready", "radio.snapshot", "radio.rf_off", "radio.stop_data", + "sim.identity", + "aka.ready", "proxy.resolve", "tunnel.start", "tunnel.evidence", @@ -422,26 +422,10 @@ func TestEnableFailuresCleanUpEveryAcquiredLayer(t *testing.T) { {name: "identity", failCall: "sim.identity"}, {name: "aka", failCall: "aka.ready"}, {name: "radio snapshot", failCall: "radio.snapshot"}, - { - name: "stop data can partially mutate", - failCall: "radio.stop_data", - wantCleanupTail: []string{"radio.restore"}, - }, - { - name: "rf off", - failCall: "radio.rf_off", - wantCleanupTail: []string{"radio.restore"}, - }, - { - name: "proxy", - failCall: "proxy.resolve", - wantCleanupTail: []string{"radio.restore"}, - }, - { - name: "tunnel start", - failCall: "tunnel.start", - wantCleanupTail: []string{"radio.restore"}, - }, + {name: "stop data can partially mutate", failCall: "radio.stop_data"}, + {name: "rf off", failCall: "radio.rf_off"}, + {name: "proxy", failCall: "proxy.resolve"}, + {name: "tunnel start", failCall: "tunnel.start"}, { name: "tunnel evidence", mutate: func(environment *fakeEnvironment) { @@ -449,12 +433,12 @@ func TestEnableFailuresCleanUpEveryAcquiredLayer(t *testing.T) { environment.tunnelEvidence.ResponderAUTH = ResponderAUTHUnknown }, wantError: ErrTunnelNotEstablished, - wantCleanupTail: []string{"tunnel.close", "radio.restore"}, + wantCleanupTail: []string{"tunnel.close"}, }, { name: "IMS start", failCall: "ims.start", - wantCleanupTail: []string{"tunnel.close", "radio.restore"}, + wantCleanupTail: []string{"tunnel.close"}, }, { name: "IMS registration evidence", @@ -462,12 +446,12 @@ func TestEnableFailuresCleanUpEveryAcquiredLayer(t *testing.T) { environment.imsEvidence.Registered = false }, wantError: ErrIMSNotRegistered, - wantCleanupTail: []string{"ims.close", "tunnel.close", "radio.restore"}, + wantCleanupTail: []string{"ims.close", "tunnel.close"}, }, { name: "SMS activation", failCall: "ims.sms", - wantCleanupTail: []string{"ims.close", "tunnel.close", "radio.restore"}, + wantCleanupTail: []string{"ims.close", "tunnel.close"}, }, { name: "SMS evidence", @@ -475,7 +459,7 @@ func TestEnableFailuresCleanUpEveryAcquiredLayer(t *testing.T) { environment.smsEvidence.Ready = false }, wantError: ErrSMSNotReady, - wantCleanupTail: []string{"ims.close", "tunnel.close", "radio.restore"}, + wantCleanupTail: []string{"ims.close", "tunnel.close"}, }, } @@ -501,6 +485,9 @@ func TestEnableFailuresCleanUpEveryAcquiredLayer(t *testing.T) { state.TunnelReady || state.IMSReady || state.SMSReady { t.Fatalf("failed state = %+v", state) } + if environment.callCount("radio.restore") != 0 { + t.Fatalf("failed VoWiFi attempt re-enabled cellular RF: %#v", environment.callsSnapshot()) + } if len(test.wantCleanupTail) > 0 { calls := environment.callsSnapshot() if len(calls) < len(test.wantCleanupTail) { @@ -701,6 +688,34 @@ func TestRetryAfterFailureCreatesANewAttempt(t *testing.T) { if environment.callCount("tunnel.start") != 2 { t.Fatalf("tunnel.start count = %d", environment.callCount("tunnel.start")) } + if environment.callCount("radio.snapshot") != 1 || environment.callCount("radio.restore") != 0 { + t.Fatalf("retry must retain RF-off checkpoint: %#v", environment.callsSnapshot()) + } +} + +func TestFailedEnableRestoresRadioOnlyOnExplicitDisable(t *testing.T) { + environment := newFakeEnvironment() + environment.setFailure("tunnel.start", 1) + orchestrator := newTestOrchestrator(t, environment, false) + + state, err := orchestrator.Enable(context.Background()) + if err == nil || state.Phase != PhaseFailed || !state.Enabled { + t.Fatalf("Enable() = (%+v, %v)", state, err) + } + if environment.callCount("radio.restore") != 0 { + t.Fatalf("failed enable restored cellular RF: %#v", environment.callsSnapshot()) + } + + state, err = orchestrator.Disable(context.Background()) + if err != nil { + t.Fatalf("Disable() error = %v", err) + } + if state.Phase != PhaseIdle || state.Enabled { + t.Fatalf("Disable() state = %+v", state) + } + if environment.callCount("radio.restore") != 1 { + t.Fatalf("explicit disable did not restore cellular RF: %#v", environment.callsSnapshot()) + } } func TestReconnectClosesThenRebuildsTheRuntime(t *testing.T) { @@ -719,7 +734,8 @@ func TestReconnectClosesThenRebuildsTheRuntime(t *testing.T) { } if environment.callCount("tunnel.start") != 2 || environment.callCount("tunnel.close") != 1 || - environment.callCount("radio.restore") != 1 { + environment.callCount("radio.restore") != 0 || + environment.callCount("radio.snapshot") != 1 { t.Fatalf("calls = %#v", environment.callsSnapshot()) } } @@ -745,7 +761,8 @@ func TestReconnectToleratesCleanupFailureAndRebuilds(t *testing.T) { } if environment.callCount("ims.close") != 1 || environment.callCount("tunnel.close") != 1 || - environment.callCount("radio.restore") != 1 || + environment.callCount("radio.restore") != 0 || + environment.callCount("radio.snapshot") != 1 || environment.callCount("tunnel.start") != 2 { t.Fatalf("calls = %#v", environment.callsSnapshot()) } @@ -781,7 +798,7 @@ func TestRuntimeTunnelFailureRevokesReadinessAndCleansEveryLayer(t *testing.T) { } calls := environment.callsSnapshot() - wantTail := []string{"ims.close", "tunnel.close", "radio.restore"} + wantTail := []string{"ims.close", "tunnel.close"} if len(calls) < len(wantTail) || !reflect.DeepEqual(calls[len(calls)-len(wantTail):], wantTail) { t.Fatalf("runtime failure cleanup tail = %#v", calls) @@ -853,7 +870,7 @@ func TestSubscriptionPublishesOrderedEvidencePhases(t *testing.T) { } } -func TestCleanupAttemptsEveryLayerAndReportsAllErrors(t *testing.T) { +func TestFailedAttemptKeepsRadioOffUntilExplicitDisable(t *testing.T) { environment := newFakeEnvironment() environment.setFailure("ims.sms", 1) environment.setFailure("ims.close", 1) @@ -865,19 +882,26 @@ func TestCleanupAttemptsEveryLayerAndReportsAllErrors(t *testing.T) { if err == nil { t.Fatal("Enable() unexpectedly succeeded") } - if len(state.CleanupErrors) != 3 { + if len(state.CleanupErrors) != 2 { t.Fatalf("cleanup errors = %#v", state.CleanupErrors) } calls := environment.callsSnapshot() - wantTail := []string{"ims.close", "tunnel.close", "radio.restore"} - if !reflect.DeepEqual(calls[len(calls)-3:], wantTail) { - t.Fatalf("cleanup tail = %#v", calls[len(calls)-3:]) + wantTail := []string{"ims.close", "tunnel.close"} + if !reflect.DeepEqual(calls[len(calls)-2:], wantTail) { + t.Fatalf("cleanup tail = %#v", calls[len(calls)-2:]) } - for _, text := range []string{"close IMS", "close tunnel", "restore radio"} { + for _, text := range []string{"close IMS", "close tunnel"} { if !strings.Contains(err.Error(), text) { t.Fatalf("error %q does not contain %q", err, text) } } + if environment.callCount("radio.restore") != 0 { + t.Fatalf("failed attempt restored RF unexpectedly: %#v", calls) + } + if _, disableErr := orchestrator.Disable(context.Background()); disableErr == nil || + !strings.Contains(disableErr.Error(), "restore radio") { + t.Fatalf("Disable() error = %v, want retained radio restore failure", disableErr) + } } func TestDisableCleanupWarningStillSettlesIdle(t *testing.T) { diff --git a/internal/vowifi/runtime/manager.go b/internal/vowifi/runtime/manager.go index 38878fa..8d3d4a6 100644 --- a/internal/vowifi/runtime/manager.go +++ b/internal/vowifi/runtime/manager.go @@ -211,12 +211,26 @@ func (manager *Manager) RequestEnabled(deviceID string, enabled bool) (vowifi.St manager.mu.Lock() item := manager.entries[deviceID] item.desiredEnabled = enabled - if !enabled && item.busy { - item.disablePending = true + if item.busy { + manager.logger.Info( + "VoWiFi desired state updated while lifecycle operation is active", + "device_id", deviceID, + "enabled", enabled, + ) + // The switch is a desired-state control, not a one-shot command. A user + // can change it again while a slow IKE/IMS transaction is still winding + // down. Accept the newest value and let runOperations reconcile the + // runtime after the current operation completes. Returning busy here used + // to let the database and runtime diverge (configured on, runtime idle). + if enabled { + item.disablePending = false + } else { + item.disablePending = true + } cancel := item.operationCancel state := item.orchestrator.State() manager.mu.Unlock() - if cancel != nil { + if !enabled && cancel != nil { cancel() } return state, nil @@ -366,6 +380,7 @@ func (manager *Manager) startOperation( manager.wg.Add(1) manager.mu.Unlock() + manager.logger.Debug("VoWiFi lifecycle operation queued", "device_id", deviceID) go manager.runOperations(deviceID, item, operation) return item.orchestrator.State(), nil } @@ -376,6 +391,7 @@ func (manager *Manager) runOperations( operation func(context.Context, *vowifi.Orchestrator) error, ) { defer manager.wg.Done() + manager.logger.Debug("VoWiFi lifecycle worker started", "device_id", deviceID) for { ctx, cancel := context.WithTimeout(manager.ctx, manager.operationTimeout) manager.mu.Lock() @@ -389,6 +405,7 @@ func (manager *Manager) runOperations( } item.operationCancel = cancel manager.mu.Unlock() + manager.logger.Debug("VoWiFi lifecycle operation executing", "device_id", deviceID) err := operation(ctx, item.orchestrator) cancel() if err != nil && @@ -431,6 +448,25 @@ func (manager *Manager) runOperations( } continue } + // Reconcile a switch change that arrived while the previous lifecycle + // operation was busy. Keep using the same worker so enable/disable can + // never overlap on the modem or tunnel resources. + if item.desiredEnabled && !state.Enabled { + manager.mu.Unlock() + operation = func(ctx context.Context, orchestrator *vowifi.Orchestrator) error { + _, err := orchestrator.Enable(ctx) + return err + } + continue + } + if !item.desiredEnabled && state.Enabled { + manager.mu.Unlock() + operation = func(ctx context.Context, orchestrator *vowifi.Orchestrator) error { + _, err := orchestrator.Disable(ctx) + return err + } + continue + } item.busy = false shouldRetry := item.desiredEnabled && state.Phase == vowifi.PhaseFailed if !shouldRetry && state.Phase != vowifi.PhaseFailed { diff --git a/internal/vowifi/runtime/manager_test.go b/internal/vowifi/runtime/manager_test.go index 4a0809f..9e389e2 100644 --- a/internal/vowifi/runtime/manager_test.go +++ b/internal/vowifi/runtime/manager_test.go @@ -267,6 +267,89 @@ func TestManagerStopsAutomaticRetryWhenPolicyIsDisabled(t *testing.T) { } } +func TestManagerAcceptsRepeatedEnableWhileBusy(t *testing.T) { + manager := New(Options{OperationTimeout: time.Second}) + t.Cleanup(func() { _ = manager.Close(context.Background()) }) + if err := manager.Register(testOrchestrator(t, "ec20")); err != nil { + t.Fatal(err) + } + + started := make(chan struct{}) + release := make(chan struct{}) + if _, err := manager.startOperation("ec20", false, func(context.Context, *vowifi.Orchestrator) error { + close(started) + <-release + return nil + }); err != nil { + t.Fatal(err) + } + <-started + if _, err := manager.RequestEnabled("ec20", true); err != nil { + t.Fatalf("repeated desired state was rejected: %v", err) + } + close(release) + + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + state, err := manager.State("ec20") + if err != nil { + t.Fatal(err) + } + if state.Phase == vowifi.PhaseSMSReady { + return + } + time.Sleep(time.Millisecond) + } + t.Fatal("desired enable was not reconciled after the active operation") +} + +func TestManagerReEnablesWhenSwitchChangesDuringDisable(t *testing.T) { + manager := New(Options{OperationTimeout: time.Second}) + t.Cleanup(func() { _ = manager.Close(context.Background()) }) + if err := manager.Register(testOrchestrator(t, "ec20")); err != nil { + t.Fatal(err) + } + if _, err := manager.RequestEnabled("ec20", true); err != nil { + t.Fatal(err) + } + waitForPhase := func(want vowifi.Phase) { + t.Helper() + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + state, err := manager.State("ec20") + if err != nil { + t.Fatal(err) + } + if state.Phase == want { + return + } + time.Sleep(time.Millisecond) + } + t.Fatalf("phase did not become %s", want) + } + waitForPhase(vowifi.PhaseSMSReady) + + started := make(chan struct{}) + release := make(chan struct{}) + if _, err := manager.startOperation("ec20", false, func(ctx context.Context, orchestrator *vowifi.Orchestrator) error { + close(started) + <-release + _, err := orchestrator.Disable(ctx) + return err + }); err != nil { + t.Fatal(err) + } + <-started + manager.mu.Lock() + manager.entries["ec20"].desiredEnabled = false + manager.mu.Unlock() + if _, err := manager.RequestEnabled("ec20", true); err != nil { + t.Fatalf("enable while disable is active: %v", err) + } + close(release) + waitForPhase(vowifi.PhaseSMSReady) +} + func TestManagerRejectsUnknownDevice(t *testing.T) { manager := New(Options{}) t.Cleanup(func() { @@ -348,8 +431,9 @@ func TestManagerCoalescesReconnectWhileLifecycleOperationIsBusy(t *testing.T) { if _, err := manager.RequestReconnect("ec20"); err != nil { t.Fatalf("second queued reconnect error = %v", err) } - if _, err := manager.RequestEnabled("ec20", true); !errors.Is(err, ErrOperationInProgress) { - t.Fatalf("non-reconnect operation error = %v, want ErrOperationInProgress", err) + if _, err := manager.RequestEnabled("ec20", true); err != nil { + close(release) + t.Fatalf("repeated desired enable was rejected: %v", err) } manager.mu.Lock() pending := manager.entries["ec20"].reconnectPending diff --git a/web/src/App.tsx b/web/src/App.tsx index b6d0ea0..203b5a2 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -14,6 +14,7 @@ import DevicesPage from "./pages/DevicesPage"; import ProxyPage from "./pages/ProxyPage"; import ExportProxyPage from "./pages/ExportProxyPage"; import SmsPage from "./pages/SmsPage"; +import AutomaticTasksPage from "./pages/AutomaticTasksPage"; import LogsPage from "./pages/LogsPage"; import SettingsPage from "./pages/SettingsPage"; import ExtensionPage from "./pages/ExtensionPage"; @@ -113,6 +114,7 @@ function AppRoot() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/components/devices/CardPolicyPanel.tsx b/web/src/components/devices/CardPolicyPanel.tsx index cfafa3d..03da731 100644 --- a/web/src/components/devices/CardPolicyPanel.tsx +++ b/web/src/components/devices/CardPolicyPanel.tsx @@ -61,7 +61,7 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
{t("设备运行模式")}
- {isQmi ? t("此类设备固定 QMI,AT 口仅用于终端") : isMbim ? t("此类设备固定 MBIM,AT 口仅用于终端") : t("AT=传统串口 / QMI=纯 QMI")} + {isQmi + ? t("QMI 负责驻网状态与数据会话;AT 负责 SIM/eSIM、射频、短信、通话和终端指令") + : isMbim + ? t("MBIM 负责数据会话;AT 负责 SIM/eSIM、射频、短信、通话和终端指令") + : t("AT 模式通过串口管理驻网与 PDP 数据会话")}
setForm({ ...form, name: event.target.value })} placeholder={t("例如:每日短信保活")} /> +
({ value: profile.iccid, label: profile.label }))} />
+
setForm({ ...form, environment: value as TaskEnvironment })} disabled={form.taskType === "public_ip"} options={[{ value: "vowifi", label: "VoWiFi" }, { value: "cellular", label: t("基站直连(自动选网)") }]} />
+ + {form.taskType !== "public_ip" ?
setForm({ ...form, phone: event.target.value })} placeholder="+447700900123" />
: null} + {form.taskType === "call" ?
setForm({ ...form, durationSeconds: Number(event.target.value) })} />
: null} + {form.taskType === "sms" ?