2 Commits
Author SHA1 Message Date
MengMengCode 8a260e86f1 Implement automatic task management with CRUD operations and UI integration
- Added `automatic_tasks.go` and `automatic_tasks_test.go` for backend logic and testing of automatic tasks.
- Created `automatic_tasks_test.go` to validate task claiming and deletion behavior.
- Developed `AutomaticTasksPage.tsx` for frontend management of automatic tasks, including task creation, editing, and execution.
- Integrated device and eSIM profile selection for task configuration.
- Implemented automatic task scheduling and retry logic in the backend.
2026-08-10 22:15:54 +08:00
MengMengCode 5b8d1a86e8 feat: enhance Telegram call handling with VoWiFi and cellular support 2026-08-10 06:07:56 +08:00
69 changed files with 16085 additions and 506 deletions
+243 -35
View File
@@ -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,
)
}
+1 -5
View File
@@ -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)
+22
View File
@@ -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
}
+15 -1
View File
@@ -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)
}
+59 -23
View File
@@ -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
+18 -9
View File
@@ -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) {
+39
View File
@@ -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"`,
+31 -1
View File
@@ -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,
+72 -8
View File
@@ -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{{
File diff suppressed because one or more lines are too long
+20
View File
@@ -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)
}
}
}
+91 -19
View File
@@ -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))
+3
View File
@@ -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"`
+7 -1
View File
@@ -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)
}
+45
View File
@@ -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")
@@ -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()
}
+651
View File
@@ -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) {}
+30
View File
@@ -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")
}
}
+208 -26
View File
@@ -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,
+44 -4
View File
@@ -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()
+66
View File
@@ -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)
}
}
+64 -12
View File
@@ -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
}
+3
View File
@@ -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
}
+6 -1
View File
@@ -23,6 +23,7 @@ type fakeDeviceController struct {
entry device.Device
atResponse modem.Response
atErr error
atHandler func(string) (modem.Response, error)
scanResult device.OperatorScanResult
scanErr error
ussdResult device.USSDResult
@@ -44,7 +45,11 @@ func (f fakeDeviceController) Get(id string) (device.Device, error) {
func (f fakeDeviceController) Refresh(context.Context, string) (device.Snapshot, error) {
return device.Snapshot{}, nil
}
func (f fakeDeviceController) ExecuteAT(context.Context, string, string) (modem.Response, error) {
func (f fakeDeviceController) ExecuteAT(_ context.Context, _ string, command string) (modem.Response, error) {
if f.atHandler != nil {
return f.atHandler(command)
}
return f.atResponse, f.atErr
}
func (f fakeDeviceController) Reboot(context.Context, string) error { return nil }
+1
View File
@@ -84,6 +84,7 @@ type Server struct {
netTraffic *liveNetTracker
publicIPMu sync.RWMutex
publicIPs map[string]cachedPublicIP
automaticTasks *automaticTaskScheduler
}
func New(options Options) (*Server, error) {
+15 -16
View File
@@ -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,
+6 -5
View File
@@ -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)
}
File diff suppressed because it is too large Load Diff
+252 -1
View File
@@ -10,6 +10,7 @@ import (
"vocat/internal/device"
"vocat/internal/modem"
"vocat/internal/store"
"vocat/internal/vowifi"
)
func TestTelegramAPIURLSupportsBaseAndTemplate(t *testing.T) {
@@ -86,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()}
@@ -101,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)
@@ -165,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)
@@ -181,3 +290,145 @@ func TestTelegramErrorsRedactBotTokens(t *testing.T) {
t.Fatalf("redacted error = %q", redacted)
}
}
func TestTelegramCallTransportFollowsConfiguredCardMode(t *testing.T) {
controller := &telegramTestCallController{state: vowifi.State{IMSReady: true, Phase: vowifi.PhaseIMSReady}}
bot := &telegramBot{server: &Server{vowifi: controller}}
transport, gotController, err := bot.telegramCallTransport(
store.Device{ID: "EC20", VoWiFiEnabled: true},
device.Device{Snapshot: &device.Snapshot{FlightMode: true}},
)
if err != nil || transport != "vowifi" || gotController == nil {
t.Fatalf("VoWiFi route = %q, %#v, %v", transport, gotController, err)
}
transport, gotController, err = bot.telegramCallTransport(
store.Device{ID: "EC20", VoWiFiEnabled: false},
device.Device{Snapshot: &device.Snapshot{FlightMode: false}},
)
if err != nil || transport != "cellular" || gotController != nil {
t.Fatalf("cellular route = %q, %#v, %v", transport, gotController, err)
}
}
func TestTelegramCallTransportDoesNotFallBackFromUnreadyVoWiFi(t *testing.T) {
controller := &telegramTestCallController{state: vowifi.State{
Phase: vowifi.PhaseFailed, LastError: "SIP registration was rejected: SIP 403",
}}
bot := &telegramBot{server: &Server{vowifi: controller}}
_, _, err := bot.telegramCallTransport(
store.Device{ID: "EC20", VoWiFiEnabled: true},
device.Device{Snapshot: &device.Snapshot{FlightMode: true}},
)
if err == nil || !strings.Contains(err.Error(), "SIP 403") {
t.Fatalf("unready VoWiFi route error = %v", err)
}
}
func TestTelegramTimedVoWiFiCallUsesIMSAndHangsUpByCallID(t *testing.T) {
controller := &telegramTestCallController{state: vowifi.State{IMSReady: true}}
controller.dialResult = vowifi.Call{ID: "ims-call-1", Number: "+447700900123", Direction: "outgoing", State: "dialing"}
controller.calls = []vowifi.Call{{ID: "ims-call-1", Number: "+447700900123", Direction: "outgoing", State: "active"}}
bot := &telegramBot{server: &Server{vowifi: controller}}
result, err := bot.executeTimedVoWiFiCall(context.Background(), telegramRuntimeConfig{}, telegramPendingAction{
DeviceID: "EC20", Argument: "+447700900123", Duration: 20 * time.Millisecond,
}, controller)
if err != nil || !strings.Contains(result, "已接通") {
t.Fatalf("timed VoWiFi result = %q, %v", result, err)
}
if controller.dialed != "+447700900123" || len(controller.hungUp) != 1 || controller.hungUp[0] != "ims-call-1" {
t.Fatalf("IMS actions dial=%q hangup=%#v", controller.dialed, controller.hungUp)
}
}
func TestTelegramTimedCellularCallUsesATDCLCCAndATH(t *testing.T) {
commands := make([]string, 0, 3)
devices := fakeDeviceController{atHandler: func(command string) (modem.Response, error) {
commands = append(commands, command)
switch {
case strings.HasPrefix(command, "ATD"):
return modem.Response{Final: "OK"}, nil
case command == "AT+CLCC":
return modem.Response{Lines: []string{`+CLCC: 1,0,2,0,0,"+447700900123",145`}, Final: "OK"}, nil
case command == "ATH":
return modem.Response{Final: "OK"}, nil
default:
return modem.Response{}, errors.New("unexpected command")
}
}}
bot := &telegramBot{server: &Server{devices: devices}}
result, err := bot.executeTimedCellularCall(context.Background(), telegramRuntimeConfig{}, telegramPendingAction{
DeviceID: "EC20", Argument: "+447700900123", Duration: 20 * time.Millisecond,
}, "physical")
if err != nil || !strings.Contains(result, "正在拨号") {
t.Fatalf("timed cellular result = %q, %v", result, err)
}
joined := strings.Join(commands, ",")
for _, expected := range []string{"ATD+447700900123;", "AT+CLCC", "ATH"} {
if !strings.Contains(joined, expected) {
t.Fatalf("commands %q omit %q", joined, expected)
}
}
}
func TestTelegramVoWiFiFailureIncludesSIPDiagnostic(t *testing.T) {
err := telegramVoWiFiCallFailure(vowifi.Call{State: "failed", SIPCode: 403, Reason: "Forbidden"})
if !strings.Contains(err.Error(), "SIP 403") || !strings.Contains(err.Error(), "Forbidden") {
t.Fatalf("VoWiFi diagnostic = %q", err)
}
}
func TestTelegramVoWiFi487IsReportedAsCancelledOutcome(t *testing.T) {
result, err := telegramVoWiFiCallOutcome("888", vowifi.Call{
State: "failed", SIPCode: 487, Reason: "Request Terminated",
})
if err != nil || !strings.Contains(result, "取消或终止") || !strings.Contains(result, "SIP 487") {
t.Fatalf("487 outcome = %q, %v", result, err)
}
}
type telegramTestCallController struct {
state vowifi.State
calls []vowifi.Call
dialResult vowifi.Call
dialErr error
dialed string
hungUp []string
}
func (controller *telegramTestCallController) State(string) (vowifi.State, error) {
return controller.state, nil
}
func (controller *telegramTestCallController) RequestEnabled(string, bool) (vowifi.State, error) {
return controller.state, nil
}
func (controller *telegramTestCallController) RequestReconnect(string) (vowifi.State, error) {
return controller.state, nil
}
func (controller *telegramTestCallController) Calls(string) ([]vowifi.Call, error) {
return append([]vowifi.Call(nil), controller.calls...), nil
}
func (controller *telegramTestCallController) DialCall(_ context.Context, _ string, number string) (vowifi.Call, error) {
controller.dialed = number
return controller.dialResult, controller.dialErr
}
func (controller *telegramTestCallController) AnswerCall(_ context.Context, _ string, id string) (vowifi.Call, error) {
for _, call := range controller.calls {
if call.ID == id {
call.State = "active"
return call, nil
}
}
return vowifi.Call{}, errors.New("call not found")
}
func (controller *telegramTestCallController) HangupCall(_ context.Context, _ string, id string) error {
controller.hungUp = append(controller.hungUp, id)
return nil
}
+241
View File
@@ -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, &notify,
&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()
}
+72
View File
@@ -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)
}
}
+44 -3
View File
@@ -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{
+75
View File
@@ -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
}
+39
View File
@@ -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
-3
View File
@@ -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() {
+1 -1
View File
@@ -13,7 +13,7 @@ import (
_ "modernc.org/sqlite"
)
const schemaVersion = 8
const schemaVersion = 10
var ErrNotFound = errors.New("store: not found")
+31 -9
View File
@@ -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(
+16 -4
View File
@@ -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"},
},
},
}
+10 -1
View File
@@ -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,
+18
View File
@@ -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
+17
View File
@@ -156,6 +156,10 @@ func (session *Session) watchOutgoingCall(call *imsCall, key sipTransactionKey)
case <-session.refreshContext.Done():
return
case <-timer.C:
if session.callWasTerminated(call.callID) {
session.finishCall(call.callID, "ended", 0, "")
return
}
session.finishCall(call.callID, "failed", 0, "SIP INVITE transaction timed out")
return
case response := <-call.responses:
@@ -193,6 +197,11 @@ func (session *Session) watchOutgoingCall(call *imsCall, key sipTransactionKey)
}
session.setCallMediaReady(call.callID)
session.setCallState(call.callID, "active")
} else if session.callWasTerminated(call.callID) {
// CANCEL normally causes the pending INVITE transaction to finish
// with 487 Request Terminated. It is the expected response to our
// local hang-up, not a new network rejection.
session.finishCall(call.callID, "ended", response.StatusCode, response.Reason)
} else {
session.finishCall(call.callID, "failed", response.StatusCode, response.Reason)
}
@@ -241,6 +250,7 @@ func (session *Session) HangupCall(ctx context.Context, id string) error {
state := call.public.State
direction := call.public.Direction
request, respond := call.invite, call.respond
call.terminated = true
session.callMu.Unlock()
if direction == "incoming" && state == "ringing" && request != nil && respond != nil {
response, err := buildSIPResponseWithBody(request, 486, session.fromTag, nil)
@@ -264,6 +274,13 @@ func (session *Session) HangupCall(ctx context.Context, id string) error {
return err
}
func (session *Session) callWasTerminated(id string) bool {
session.callMu.Lock()
defer session.callMu.Unlock()
call := session.calls[id]
return call != nil && call.terminated
}
func (session *Session) handleCallRequest(request *sipRequest, respond func([]byte) error) bool {
switch request.Method {
case "INVITE":
+31
View File
@@ -4,6 +4,7 @@ import (
"context"
"strings"
"testing"
"time"
"vocat/internal/vowifi"
)
@@ -80,6 +81,36 @@ func TestRejectedOutgoingCallRetainsSIPReason(t *testing.T) {
}
}
func TestCancelledOutgoingInviteDoesNotBecomeFailedOn487(t *testing.T) {
call := &imsCall{
public: vowifi.Call{ID: "cancelled", State: "dialing"},
callID: "cancelled",
responses: make(chan *sipResponse, 1),
terminated: true,
}
session := &Session{
calls: map[string]*imsCall{call.callID: call},
transactions: make(map[sipTransactionKey]chan *sipResponse),
refreshContext: context.Background(),
}
key := sipTransactionKey{callID: call.callID, cseq: 1, method: "INVITE"}
go session.watchOutgoingCall(call, key)
call.responses <- &sipResponse{StatusCode: 487, Reason: "Request Terminated"}
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
calls := session.Calls()
if len(calls) == 1 && calls[0].EndedAt != nil {
if calls[0].State != "ended" || calls[0].SIPCode != 487 {
t.Fatalf("cancelled INVITE = %#v", calls[0])
}
return
}
time.Sleep(time.Millisecond)
}
t.Fatal("cancelled INVITE did not reach a terminal state")
}
func TestValidCallNumber(t *testing.T) {
if !validCallNumber("+447700900000") || validCallNumber("12\r\nBYE") {
t.Fatal("call number validation mismatch")
+42 -18
View File
@@ -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
}
+85
View File
@@ -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)
}
}
+92 -43
View File
@@ -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) {
+60 -36
View File
@@ -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) {
+39 -3
View File
@@ -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 {
+86 -2
View File
@@ -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
+2
View File
@@ -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() {
<Route path="proxy" element={<ProxyPage />} />
<Route path="export-proxy" element={<ExportProxyPage />} />
<Route path="sms" element={<SmsPage />} />
<Route path="automatic-tasks" element={<AutomaticTasksPage />} />
<Route path="extensions/:pluginId/:contributionId" element={<ExtensionPage />} />
<Route path="logs" element={<LogsPage />} />
<Route path="settings" element={<SettingsPage />} />
@@ -61,7 +61,7 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
<PolicySwitchCard
title="VoWiFi"
subtitle={t("启用后进飞行模式,不支持国内运营商")}
subtitle={t("启用时强制关闭蜂窝射频;关闭 VoWiFi 后仍保持飞行模式")}
tone="orange"
checked={local.vowifiEnabled}
disabled={!operable || toggles.vowifiPending}
@@ -71,7 +71,7 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
/>
<PolicySwitchCard
title={t("飞行模式")}
subtitle={t("射频关闭,断网;VoWiFi 开启时由其接管")}
subtitle={t("只有手动关闭此开关才允许设备连接基站")}
tone="indigo"
checked={local.airplaneEnabled}
disabled={!operable || local.vowifiEnabled || toggles.airplanePending}
@@ -111,7 +111,11 @@ export function DeviceConfigTab({ editConfig, deviceStatus, saving, deleting, on
<div>
<div className="text-sm font-bold text-gray-800 dark:text-gray-100">{t("设备运行模式")}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{isQmi ? t("此类设备固定 QMIAT 口仅用于终端") : isMbim ? t("此类设备固定 MBIMAT 口仅用于终端") : t("AT=传统串口 / QMI=纯 QMI")}
{isQmi
? t("QMI 负责驻网状态与数据会话;AT 负责 SIM/eSIM、射频、短信、通话和终端指令")
: isMbim
? t("MBIM 负责数据会话;AT 负责 SIM/eSIM、射频、短信、通话和终端指令")
: t("AT 模式通过串口管理驻网与 PDP 数据会话")}
</div>
</div>
<Select
@@ -3,6 +3,7 @@ import { Button, Switch } from "../ui";
import type { DeviceDetail } from "./types";
import { useI18n } from "../../lib/i18n";
import { deviceTypeImage } from "../../lib/deviceTypes";
import { isVoWiFiInUse } from "./shared";
export interface DeviceDetailHeaderProps {
device: DeviceDetail;
@@ -19,7 +20,7 @@ export interface DeviceDetailHeaderProps {
export function DeviceDetailHeader(props: DeviceDetailHeaderProps) {
const { t } = useI18n();
const { device } = props;
const vowifiInUse = !!device.vowifiEnabled;
const vowifiInUse = isVoWiFiInUse(device);
return (
<div className="ui-card p-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
@@ -1,6 +1,6 @@
import type { DeviceListItem } from "../../types";
import { Input, Select, Tag, ListSkeleton, EmptyState } from "../ui";
import { isDeviceOnline, isRegistered, lifecycleLabel } from "./shared";
import { isDeviceOnline, isRegistered, isVoWiFiInUse, lifecycleLabel } from "./shared";
import { DeviceListItemCard } from "./DeviceListItemCard";
import { tl, useI18n } from "../../lib/i18n";
@@ -40,7 +40,7 @@ function primaryLine(d: DeviceListItem): string {
}
function statusLine(d: DeviceListItem): string {
if (d?.vowifiEnabled) return "WiFi-Calling";
if (isVoWiFiInUse(d)) return "WiFi-Calling";
return primaryLine(d);
}
@@ -7,6 +7,7 @@ import { OverviewTrafficChart } from "./OverviewTrafficChart";
import { OperatorSelectionDialog } from "./OperatorSelectionDialog";
import type { DeviceDetail } from "./types";
import { useI18n } from "../../lib/i18n";
import { isVoWiFiInUse } from "./shared";
export interface DeviceOverviewTabProps {
device: DeviceDetail;
@@ -29,7 +30,7 @@ export function DeviceOverviewTab(props: DeviceOverviewTabProps) {
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<div className="ui-panel-muted p-4">
<div className="mb-3 text-xs font-bold uppercase tracking-wider text-gray-500">{t("运行状态")}</div>
{device?.vowifiEnabled ? (
{isVoWiFiInUse(device) ? (
<OverviewVowifiCard device={device} />
) : (
<OverviewNetworkCard device={device} onOpenOperatorSelection={() => setOperatorOpen(true)} />
@@ -4,7 +4,7 @@ import { FieldRow } from "./FieldRow";
import { useShowSensitive } from "./shared";
import type { DeviceDetail } from "./types";
import { useI18n } from "../../lib/i18n";
import { carrierIso, flagEmoji } from "../../lib/carrier";
import { carrierBrandIso, flagEmoji } from "../../lib/carrier";
export interface OverviewSimPanelProps {
device: DeviceDetail;
@@ -20,7 +20,7 @@ export function OverviewSimPanel({ device, simOperatorDisplay, e911Starting, onS
const sensitive = !showSensitive;
const activeEsim = (device.activeEsimProfileName || "").trim();
const flightOn = device.vowifiActive || modem?.operatingMode === 0 || modem?.operatingMode === 4;
const carrierFlag = flagEmoji(carrierIso(modem?.imsi));
const carrierFlag = flagEmoji(carrierBrandIso(modem?.nativeSpn, modem?.imsi));
const operatorValue =
carrierFlag && simOperatorDisplay !== "--" ? `${carrierFlag} ${simOperatorDisplay}` : simOperatorDisplay;
const backendLabel =
+20 -1
View File
@@ -59,6 +59,17 @@ export function isRegistered(device?: { modem?: { regStatus?: number } } | null)
return s === 1 || s === 5;
}
// The stored device flag is the desired policy, while runtime.enabled is the
// live owner of RF/IKE/IMS. A stale desired flag must not replace a healthy
// cellular overview with an all-red "disabled" VoWiFi pipeline.
export function isVoWiFiInUse(device?: {
vowifiEnabled?: boolean;
vowifiRuntime?: { enabled?: boolean };
} | null): boolean {
if (!device?.vowifiEnabled) return false;
return device.vowifiRuntime?.enabled !== false;
}
export interface StatusMeta {
label: string;
tag: "success" | "warning" | "danger";
@@ -248,7 +259,15 @@ export function simOperatorDisplay(device?: DeviceDetail | null): string {
const spn = String(modem?.nativeSpn ?? "").trim();
const name = oplPnnName(modem) || firstPnnName(modem?.pnn);
const plmn = plmnOf(modem);
if (spn) return withPlmn(spn, plmn);
// EF_SPN is the SIM's customer-facing brand. Do not append the currently
// visited PLMN: a roaming Lebara UK SIM on a Chinese network would otherwise
// be mislabeled as "Lebara (460xx)". Append the home/authentication PLMN
// resolved from IMSI instead, so GigSky on 222-01 renders as
// "GigSky (22201)" even while roaming.
if (spn) {
const home = lookupCarrier(modem?.imsi);
return withPlmn(spn, home ? home.mcc + home.mnc : cardPlmnOf(modem));
}
if (name) return withPlmn(name, plmn);
// Home ("original") carrier resolved from the SIM's IMSI via the MCC/MNC table.
// Readable even when the modem isn't camped (VoWiFi RF-off / flight mode).
@@ -13,10 +13,13 @@ export interface PolicyToggleImpl {
type Field = "vowifi" | "airplane";
// mutual-exclusion merge: vowifi on clears airplane; airplane on clears vowifi.
// RF-safe merge: VoWiFi always implies airplane mode. Turning VoWiFi off keeps
// airplane mode on; only the separate airplane switch can explicitly restore RF.
function mergePolicy(current: PolicyFlags, field: Field, value: boolean): PolicyFlags {
if (field === "vowifi") {
return value ? { vowifiEnabled: true, airplaneEnabled: false } : { ...current, vowifiEnabled: false };
return value
? { vowifiEnabled: true, airplaneEnabled: true }
: { vowifiEnabled: false, airplaneEnabled: true };
}
return value ? { vowifiEnabled: false, airplaneEnabled: true } : { ...current, airplaneEnabled: false };
}
+57 -74
View File
@@ -1,6 +1,6 @@
import { AddRegular, DeleteRegular, DesktopRegular, EditRegular, GlobeRegular } from "@fluentui/react-icons";
import { DeleteRegular, DesktopRegular, EditRegular, GlobeRegular } from "@fluentui/react-icons";
import type { UpstreamProxy } from "../../types";
import { Button, EmptyState, ErrorState, ListSkeleton, Tag } from "../ui";
import { Button, Tag } from "../ui";
import type { LoadError, UpstreamRow } from "./shared";
import { useI18n } from "../../lib/i18n";
@@ -9,90 +9,73 @@ export interface UpstreamSectionProps {
loading: boolean;
error: LoadError | null;
onRetry: () => void;
onNew: () => void;
onEdit: (proxy: UpstreamProxy) => void;
onDelete: (proxy: UpstreamProxy) => void;
onOpenBindings: (proxy: UpstreamProxy) => void;
}
function UpstreamRowCard({
row,
onEdit,
onDelete,
onOpenBindings,
}: {
row: UpstreamRow;
onEdit: (proxy: UpstreamProxy) => void;
onDelete: (proxy: UpstreamProxy) => void;
onOpenBindings: (proxy: UpstreamProxy) => void;
}) {
export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelete, onOpenBindings }: UpstreamSectionProps) {
const { t } = useI18n();
return (
<div className="ui-panel-muted flex flex-col gap-3 p-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex min-w-0 items-center gap-3">
<span className={`h-2.5 w-2.5 shrink-0 rounded-full ${row.enabled ? "bg-green-500" : "bg-gray-300"}`} />
<div className="min-w-0">
<div className="truncate font-bold text-gray-900 dark:text-white">{row.name || row.id}</div>
<div className="mt-0.5 truncate text-xs text-gray-500">
SOCKS5 · <span className="font-mono">{row.addr}</span>
{row.username ? <span> · {t("鉴权")}: {row.username}</span> : null}
</div>
</div>
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2">
<Tag type={row.enabled ? "success" : "info"}>{row.enabled ? t("已启用") : t("已禁用")}</Tag>
<div className="inline-flex items-center gap-1 rounded border border-indigo-200/60 bg-indigo-50 px-2 py-0.5 text-[11px] font-medium text-indigo-600 dark:border-indigo-800/40 dark:bg-indigo-900/20 dark:text-indigo-400">
<DesktopRegular className="text-[14px]" />
<span>{row.bindingCount} {t("台设备")}</span>
</div>
<div className="mx-0.5 hidden h-3.5 w-px bg-gray-200 dark:bg-gray-700 sm:block" />
<Button size="small" icon={<DesktopRegular />} onClick={() => onOpenBindings(row)}>
<span className="hidden sm:inline">{t("设备绑定")}</span>
</Button>
<Button size="small" icon={<EditRegular />} onClick={() => onEdit(row)} />
<Button size="small" variant="danger" icon={<DeleteRegular />} onClick={() => onDelete(row)} />
</div>
</div>
);
}
export function UpstreamSection({ rows, loading, error, onRetry, onNew, onEdit, onDelete, onOpenBindings }: UpstreamSectionProps) {
const { t } = useI18n();
return (
<div>
<div className="ui-card overflow-hidden">
{error ? (
<ErrorState className="mb-6" title={t("加载上游代理失败")} message={error.message} statusCode={error.status} retryText={t("重试")} onRetry={onRetry} />
) : null}
<div className="ui-card p-6">
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-[#0ea5e9] to-[#0284c7] text-white shadow-lg shadow-indigo-500/25">
<GlobeRegular className="text-[20px]" />
</div>
<div>
<div className="text-lg font-bold text-gray-900 dark:text-white">{t("VoWiFi 上游代理")}</div>
<div className="text-xs text-gray-500">{t("将设备的 VoWiFi 建链、IMS 和短信通信通过支持 UDP Associate 的 SOCKS5 代理传输。")}</div>
</div>
</div>
<Button variant="primary" className="!border-0" icon={<AddRegular />} onClick={onNew}>
{t("新增代理")}
</Button>
<div className="flex items-center justify-between gap-3 border-b border-red-200 bg-red-50 p-3 text-sm text-red-600 dark:border-red-500/20 dark:bg-red-500/10 dark:text-red-300">
<span className="min-w-0 truncate">
{t("加载上游代理失败")}{error.message}
{error.status ? `${error.status}` : ""}
</span>
<button type="button" className="shrink-0 font-medium underline underline-offset-2" onClick={onRetry}>
{t("重试")}
</button>
</div>
{loading && rows.length === 0 ? (
<ListSkeleton rows={2} />
) : rows.length === 0 ? (
<EmptyState
title={t("暂无上游代理")}
subtitle={t("点击“新增代理”创建 SOCKS5 上游代理,然后将需要使用它的设备直接绑定;未绑定设备默认直连。")}
/>
) : (
<div className="space-y-3">
) : null}
<div className="overflow-x-auto">
<table className="w-full min-w-[900px] text-left text-sm">
<thead className="border-b border-gray-100 bg-gray-50/70 text-xs uppercase tracking-wide text-gray-500 dark:border-white/10 dark:bg-white/[0.025]">
<tr>
<th className="px-4 py-3">{t("名称")}</th>
<th className="px-4 py-3">{t("协议")}</th>
<th className="px-4 py-3">{t("地址")}</th>
<th className="px-4 py-3">{t("鉴权")}</th>
<th className="px-4 py-3">{t("状态")}</th>
<th className="px-4 py-3">{t("设备绑定")}</th>
<th className="px-4 py-3 text-right">{t("操作")}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-white/10">
{rows.map((row) => (
<UpstreamRowCard key={row.id} row={row} onEdit={onEdit} onDelete={onDelete} onOpenBindings={onOpenBindings} />
<tr key={row.id} className="hover:bg-sky-50/40 dark:hover:bg-sky-500/[0.04]">
<td className="px-4 py-3 font-semibold">{row.name || row.id}</td>
<td className="px-4 py-3"><Tag type="primary">SOCKS5</Tag></td>
<td className="px-4 py-3 font-mono text-xs">{row.addr}</td>
<td className="px-4 py-3">{row.username || t("无")}</td>
<td className="px-4 py-3"><Tag type={row.enabled ? "success" : "info"}>{row.enabled ? t("已启用") : t("已禁用")}</Tag></td>
<td className="px-4 py-3">
<div className="inline-flex items-center gap-1 rounded border border-indigo-200/60 bg-indigo-50 px-2 py-0.5 text-[11px] font-medium text-indigo-600 dark:border-indigo-800/40 dark:bg-indigo-900/20 dark:text-indigo-400">
<DesktopRegular className="text-[14px]" />
<span>{row.bindingCount} {t("台设备")}</span>
</div>
</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-2">
<Button size="small" icon={<DesktopRegular />} onClick={() => onOpenBindings(row)}>{t("设备绑定")}</Button>
<Button size="small" icon={<EditRegular />} onClick={() => onEdit(row)}>{t("编辑")}</Button>
<Button size="small" variant="danger" plain icon={<DeleteRegular />} onClick={() => onDelete(row)}>{t("删除")}</Button>
</div>
</td>
</tr>
))}
</div>
)}
</tbody>
</table>
</div>
{!loading && !rows.length ? (
<div className="flex flex-col items-center justify-center px-6 py-16 text-center text-gray-400">
<GlobeRegular className="mb-3 text-4xl" />
<div className="text-sm">{t("暂无上游代理")}</div>
<div className="mt-1 text-xs">{t("点击“新增代理”创建 SOCKS5 上游代理,然后将需要使用它的设备直接绑定;未绑定设备默认直连。")}</div>
</div>
) : null}
{loading ? <div className="px-6 py-16 text-center text-sm text-gray-400">{t("加载中...")}</div> : null}
</div>
);
}
@@ -31,7 +31,7 @@ export function DeviceQuotaCard({
</CardIcon>
<CardTitle
title={zh ? "设备配额" : "Device quota"}
subtitle={zh ? "开发者模式下允许配置的设备数量" : "Configured device allowance in developer mode"}
subtitle={zh ? "最多允许配置的设备数量" : "Maximum number of configurable devices"}
/>
</div>
<div className="relative z-10 space-y-4">
@@ -46,8 +46,8 @@ export function DeviceQuotaCard({
/>
<p className="text-xs text-gray-500 dark:text-gray-400">
{zh
? `关闭开发者模式后会自动恢复为 ${value?.defaultDeviceLimit ?? 5} 台,不会删除已经添加的设备。`
: `Disabling developer mode restores ${value?.defaultDeviceLimit ?? 5}; existing devices are not deleted.`}
? `恢复默认配置后会自动恢复为 ${value?.defaultDeviceLimit ?? 5} 台,不会删除已经添加的设备。`
: `Restoring the default configuration resets the quota to ${value?.defaultDeviceLimit ?? 5}; existing devices are not deleted.`}
</p>
<Button variant="primary" loading={saving} disabled={loading} onClick={onSave} className="w-full !border-0">
{zh ? "保存设备配额" : "Save device quota"}
+3 -3
View File
@@ -29,7 +29,7 @@ export function HTTPSCard({
</CardIcon>
<CardTitle
title={zh ? "本机自签 HTTPS" : "Local self-signed HTTPS"}
subtitle={zh ? "为浏览器麦克风和安全连接提供 HTTPS" : "HTTPS for browser microphone and secure connections"}
subtitle={zh ? "为安全连接提供 HTTPS" : "HTTPS for secure connections"}
/>
</div>
<Switch checked={enabled} disabled={loading || saving} loading={saving} onChange={onToggle} />
@@ -48,8 +48,8 @@ export function HTTPSCard({
) : null}
<p className="text-xs text-amber-600 dark:text-amber-400">
{zh
? "自签证书需要在系统或浏览器中信任否则浏览器可能继续拒绝麦克风权限。"
: "Trust the self-signed certificate in the operating system or browser; otherwise microphone access may still be rejected."}
? "自签证书需要在系统或浏览器中信任否则浏览器可能继续提示连接不安全。"
: "Trust the self-signed certificate in the operating system or browser; otherwise the browser may keep warning that the connection is not secure."}
</p>
<Button onClick={() => window.open("/api/settings/https/certificate", "_blank")} disabled={loading}>
{zh ? "下载自签证书" : "Download certificate"}
@@ -5,6 +5,7 @@ import {
DocumentTextRegular,
GlobeRegular,
MailRegular,
SendClockRegular,
PanelLeftContractRegular,
PanelLeftExpandRegular,
RouterRegular,
@@ -30,6 +31,7 @@ const NAV = [
{ to: "/devices", label: "设备管理", icon: RouterRegular },
{ to: "/proxy", label: "代理管理", icon: GlobeRegular },
{ to: "/sms", label: "短信检测", icon: MailRegular },
{ to: "/automatic-tasks", label: "自动任务", icon: SendClockRegular },
{ to: "/logs", label: "实时日志", icon: DocumentTextRegular },
{ to: "/settings", label: "系统设置", icon: SettingsRegular },
];
+10
View File
@@ -48,6 +48,16 @@ export function carrierIso(imsi?: string): string {
return data.i[imsiDigits(imsi).slice(0, 3)] ?? "";
}
// carrierBrandIso keeps the normal IMSI country flag for branded/MVNO SIMs.
// Lebara UK's Vodafone-NL-hosted 204-04 eSIM is the one known exception: its
// customer-facing country is GB even though AKA must continue using 204-04.
export function carrierBrandIso(spn?: string, imsi?: string): string {
const brand = String(spn ?? "").trim().toLowerCase();
const digits = imsiDigits(imsi);
if (brand.includes("lebara") && digits.startsWith("20404")) return "gb";
return carrierIso(imsi);
}
// flagEmoji converts an alpha-2 country code to its regional-indicator flag emoji.
export function flagEmoji(iso?: string): string {
const s = String(iso ?? "").trim().toUpperCase();
+80 -5
View File
@@ -45,10 +45,8 @@ export const EN_DICT: Record<string, string> = {
"绑定设备": "Bind Device",
"台设备": "devices",
"鉴权": "Auth",
"无": "None",
"加载上游代理失败": "Failed to load upstream proxies",
"VoWiFi 上游代理": "VoWiFi Upstream Proxies",
"将设备的 VoWiFi 建链、IMS 和短信通信通过支持 UDP Associate 的 SOCKS5 代理传输。":
"Route device VoWiFi setup, IMS, and SMS communications through a SOCKS5 proxy with UDP Associate support.",
"暂无上游代理": "No upstream proxies",
"点击“新增代理”创建 SOCKS5 上游代理,然后将需要使用它的设备直接绑定;未绑定设备默认直连。":
"Create a SOCKS5 upstream proxy, then bind the devices that should use it. Unbound devices use a direct connection.",
@@ -77,6 +75,74 @@ export const EN_DICT: Record<string, string> = {
: "Devices",
: "Proxy",
: "SMS Test",
: "Automatic Tasks",
"按周期切换指定 eSIM Profile,并在设备串行队列中执行短信、通话或漫游公网 IP 任务": "Switch to a selected eSIM profile on schedule, then run SMS, call, or roaming public-IP jobs in a per-device queue",
: "Add Task",
"设备 / Profile": "Device / Profile",
: "Environment",
: "Schedule",
: "Next Run",
: "Last Result",
: "Notify on Completion",
: "No Notifications",
"失败重试 {count} 次": "Retry {count} times on failure",
: "Call and Auto Hang Up",
IP: "Get Roaming Public IP",
: "Cellular",
"每 {days} 天": "Every {days} days",
: "Run Now",
: "No automatic tasks",
"添加任务后,系统会按设备排队并在执行前校验目标 Profile": "Tasks are queued per device and the target profile is verified before execution",
: "Recent Runs",
: "Queued At",
: "Attempts",
: "Queued",
: "Running",
: "No run history",
: "Edit Automatic Task",
: "Add Automatic Task",
: "Task Name",
"例如:每日短信保活": "For example: Daily SMS keepalive",
"eSIM Profile": "eSIM Profile",
Profile中: "Loading profiles...",
Profile: "Select a profile",
: "Task Type",
IP: "Enable roaming data and get the public IP once",
"基站直连(自动选网)": "Cellular (automatic network selection)",
"该任务固定使用基站直连和自动选网;执行时会开启漫游数据,并通过模块接口访问 ipinfo.io。需要开启开发者模式。": "This task always uses cellular direct mode with automatic network selection. It enables roaming data and accesses ipinfo.io through the modem interface. Developer mode is required.",
: "First Run Date",
: "Run Time",
: "Interval",
: "Failure Retries",
"{count} 次": "{count}",
: "Enable Task",
: "Disabled tasks are not queued",
: "Send to every configured and enabled notification channel",
: "Enter a task name",
eSIMProfile: "Select an eSIM profile",
: "Task added to the device queue",
: "Delete this automatic task?",
: "Automatic task deleted",
: "Automatic task updated",
: "Automatic task created",
: "Edit",
: "Success",
"读取 Profile 中...": "Loading profiles...",
: "Number",
"获取漫游公网 IP": "Get Roaming Public IP",
: "Result",
"开启漫游流量并获取一次公网 IP": "Enable roaming data and get the public IP once",
: "Type",
: "Enter the message",
: "Enter a number",
"请选择 eSIM Profile": "Select an eSIM profile",
"请选择 Profile": "Select a profile",
: "Select a device",
"确定删除这个自动任务吗?": "Delete this automatic task?",
: "Task",
: "Failed",
: "Status",
: "Auto Hang Up",
: "Live Logs",
: "Settings",
: "Main navigation",
@@ -530,6 +596,10 @@ export const EN_DICT: Record<string, string> = {
"名称修改成功": "Name updated",
"否": "No",
"启用后进飞行模式,不支持国内运营商": "Once enabled it enters airplane mode; domestic carriers are not supported",
"启用时强制关闭蜂窝射频;关闭 VoWiFi 后仍保持飞行模式":
"Enabling forces cellular RF off; airplane mode remains on after VoWiFi is disabled.",
"只有手动关闭此开关才允许设备连接基站":
"The device may connect to a cellular base station only after you manually turn this switch off.",
"命令": "Command",
"命令 / 回复": "Command / Reply",
"回复": "Reply",
@@ -627,6 +697,12 @@ export const EN_DICT: Record<string, string> = {
"此类 WWAN QMI 设备运行后端固定为 QMI;AT 口仍会保留给 AT 终端。": "This WWAN QMI device is fixed to the QMI backend; the AT port remains available for the AT terminal.",
"此类设备固定 MBIMAT 口仅用于终端": "This device is fixed to MBIM; the AT port is for the terminal only",
"此类设备固定 QMIAT 口仅用于终端": "This device is fixed to QMI; the AT port is for the terminal only",
"QMI 负责驻网状态与数据会话;AT 负责 SIM/eSIM、射频、短信、通话和终端指令":
"QMI handles registration status and packet-data sessions; AT handles SIM/eSIM, RF, SMS, calls, and terminal commands.",
"MBIM 负责数据会话;AT 负责 SIM/eSIM、射频、短信、通话和终端指令":
"MBIM handles packet-data sessions; AT handles SIM/eSIM, RF, SMS, calls, and terminal commands.",
"AT 模式通过串口管理驻网与 PDP 数据会话":
"AT mode manages registration and PDP data sessions through the serial port.",
"注册状态": "Registration",
"浏览器限制,请手动复制": "Blocked by the browser; please copy manually",
"添加失败": "Add failed",
@@ -753,7 +829,7 @@ export const EN_DICT: Record<string, string> = {
"国家/地区": "Country / Region",
"公网 IP 检测失败": "Public IP detection failed",
"导出代理": "Export Proxy",
"将模块漫游数据导出为主机 HTTP 或 SOCKS5 代理;仅在开发者模式下可用": "Export modem roaming data as host HTTP or SOCKS5 proxies; available only in developer mode",
"将模块漫游数据导出为主机 HTTP 或 SOCKS5 代理": "Export modem roaming data as host HTTP or SOCKS5 proxies",
"添加代理": "Add Proxy",
"网络接口": "Network Interface",
"协议": "Protocol",
@@ -762,7 +838,6 @@ export const EN_DICT: Record<string, string> = {
"已停用": "Stopped",
"暂无导出代理配置": "No export proxies configured",
"先在设备页面开启漫游数据,再创建代理": "Enable roaming data on the device page before creating a proxy",
"代理出口使用受保护的蜂窝路由和独立 DNS,不会把模块数据设为主机默认网络。关闭开发者模式会停止漫游数据并永久删除这里的全部配置。": "Proxy traffic uses protected cellular routing and isolated DNS without becoming the host default network. Disabling developer mode stops roaming data and permanently deletes every configuration here.",
"编辑导出代理": "Edit Export Proxy",
"添加导出代理": "Add Export Proxy",
"例如:EC20 漫游出口": "For example: EC20 roaming exit",
+10687 -1
View File
File diff suppressed because one or more lines are too long
+429
View File
@@ -0,0 +1,429 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
AddRegular,
DeleteRegular,
EditRegular,
PlayRegular,
SendClockRegular,
} from "@fluentui/react-icons";
import { api, apiMessage } from "../api";
import type { DeviceListItem, DevicesResponse } from "../types";
import type { EsimProfileGroup } from "../components/devices/types";
import {
Button,
Input,
Modal,
PageHeader,
Select,
Switch,
Tag,
Textarea,
confirmDialog,
message,
} from "../components/ui";
import { useI18n } from "../lib/i18n";
type TaskType = "sms" | "call" | "public_ip";
type TaskEnvironment = "vowifi" | "cellular";
interface AutomaticTaskPayload {
phone?: string;
message?: string;
durationSeconds?: number;
}
interface AutomaticTask {
id: number;
name: string;
enabled: boolean;
deviceId: string;
profileIccid: string;
profileAid: string;
taskType: TaskType;
environment: TaskEnvironment;
intervalDays: number;
startDate: string;
runTime: string;
payload: AutomaticTaskPayload;
retryCount: number;
notify: boolean;
nextRunAt: string;
lastRunAt?: string;
lastStatus: string;
lastError: string;
}
interface AutomaticTaskRun {
id: number;
taskId: number;
deviceId: string;
scheduledAt: string;
startedAt?: string;
finishedAt?: string;
status: "queued" | "running" | "success" | "failed";
attempts: number;
output: string;
error: string;
}
interface TaskForm {
id: number;
name: string;
enabled: boolean;
deviceId: string;
profileIccid: string;
profileAid: string;
taskType: TaskType;
environment: TaskEnvironment;
intervalDays: number;
startDate: string;
runTime: string;
retryCount: number;
notify: boolean;
phone: string;
message: string;
durationSeconds: number;
}
interface ProfileOption {
iccid: string;
aidHex: string;
label: string;
}
function localDate(value = new Date()) {
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, "0");
const day = String(value.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function localTime(value = new Date(Date.now() + 5 * 60_000)) {
return `${String(value.getHours()).padStart(2, "0")}:${String(value.getMinutes()).padStart(2, "0")}`;
}
function emptyForm(deviceId = ""): TaskForm {
return {
id: 0,
name: "",
enabled: true,
deviceId,
profileIccid: "",
profileAid: "",
taskType: "sms",
environment: "vowifi",
intervalDays: 1,
startDate: localDate(),
runTime: localTime(),
retryCount: 1,
notify: true,
phone: "",
message: "",
durationSeconds: 30,
};
}
function formatDateTime(value?: string) {
if (!value || value.startsWith("0001-")) return "--";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? "--" : date.toLocaleString();
}
const fieldLabel = "mb-1.5 block text-sm font-semibold text-gray-700 dark:text-gray-200";
export default function AutomaticTasksPage() {
const { t } = useI18n();
const [tasks, setTasks] = useState<AutomaticTask[]>([]);
const [runs, setRuns] = useState<AutomaticTaskRun[]>([]);
const [devices, setDevices] = useState<DeviceListItem[]>([]);
const [profiles, setProfiles] = useState<ProfileOption[]>([]);
const [loading, setLoading] = useState(true);
const [profileLoading, setProfileLoading] = useState(false);
const [open, setOpen] = useState(false);
const [form, setForm] = useState<TaskForm>(() => emptyForm());
const [saving, setSaving] = useState(false);
const [busy, setBusy] = useState(0);
const load = useCallback(async (initial = false) => {
if (initial) setLoading(true);
try {
const [taskData, deviceData] = await Promise.all([
api<{ tasks?: AutomaticTask[]; runs?: AutomaticTaskRun[] }>("/automatic-tasks"),
api<DevicesResponse>("/devices"),
]);
setTasks(taskData.tasks || []);
setRuns(taskData.runs || []);
setDevices(deviceData.devices || []);
} catch (error) {
message.error(apiMessage(error));
} finally {
if (initial) setLoading(false);
}
}, []);
useEffect(() => {
void load(true);
const timer = window.setInterval(() => void load(), 5000);
return () => window.clearInterval(timer);
}, [load]);
const loadProfiles = useCallback(async (deviceId: string, keepICCID = "") => {
setProfiles([]);
if (!deviceId) return;
setProfileLoading(true);
try {
const data = await api<{ profiles?: EsimProfileGroup[] }>(`/devices/${encodeURIComponent(deviceId)}/esim`);
const options = (data.profiles || []).flatMap((group, groupIndex) =>
(group.profiles || []).map((profile) => ({
iccid: profile.iccid,
aidHex: group.aidHex || "",
label: `${profile.name || profile.serviceProviderName || `Profile ${groupIndex + 1}`} · ${profile.iccid}`,
})),
);
setProfiles(options);
setForm((current) => {
if (current.deviceId !== deviceId) return current;
const selected = options.find((item) => item.iccid === (keepICCID || current.profileIccid)) || options[0];
return selected ? { ...current, profileIccid: selected.iccid, profileAid: selected.aidHex } : current;
});
} catch (error) {
message.error(apiMessage(error));
} finally {
setProfileLoading(false);
}
}, []);
const deviceByID = useMemo(() => new Map(devices.map((device) => [device.id, device])), [devices]);
const taskByID = useMemo(() => new Map(tasks.map((task) => [task.id, task])), [tasks]);
function edit(task?: AutomaticTask) {
const deviceId = task?.deviceId || devices[0]?.id || "";
const next = task ? {
id: task.id,
name: task.name,
enabled: task.enabled,
deviceId: task.deviceId,
profileIccid: task.profileIccid,
profileAid: task.profileAid || "",
taskType: task.taskType,
environment: task.environment,
intervalDays: task.intervalDays,
startDate: task.startDate,
runTime: task.runTime,
retryCount: task.retryCount,
notify: task.notify,
phone: task.payload?.phone || "",
message: task.payload?.message || "",
durationSeconds: task.payload?.durationSeconds || 30,
} : emptyForm(deviceId);
setForm(next);
setOpen(true);
void loadProfiles(deviceId, next.profileIccid);
}
function chooseDevice(deviceId: string) {
setForm((current) => ({ ...current, deviceId, profileIccid: "", profileAid: "" }));
void loadProfiles(deviceId);
}
function chooseProfile(iccid: string) {
const selected = profiles.find((profile) => profile.iccid === iccid);
setForm((current) => ({ ...current, profileIccid: iccid, profileAid: selected?.aidHex || "" }));
}
function chooseTaskType(taskType: TaskType) {
setForm((current) => ({
...current,
taskType,
environment: taskType === "public_ip" ? "cellular" : current.environment,
}));
}
async function save() {
if (!form.name.trim()) return message.warning(t("请输入任务名称"));
if (!form.deviceId) return message.warning(t("请选择设备"));
if (!form.profileIccid) return message.warning(t("请选择 eSIM Profile"));
if (form.taskType !== "public_ip" && !form.phone.trim()) return message.warning(t("请输入号码"));
if (form.taskType === "sms" && !form.message.trim()) return message.warning(t("请输入短信内容"));
setSaving(true);
try {
const body = {
name: form.name,
enabled: form.enabled,
deviceId: form.deviceId,
profileIccid: form.profileIccid,
profileAid: form.profileAid,
taskType: form.taskType,
environment: form.environment,
intervalDays: Number(form.intervalDays),
startDate: form.startDate,
runTime: form.runTime,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
retryCount: Number(form.retryCount),
notify: form.notify,
payload: {
phone: form.phone,
message: form.message,
durationSeconds: Number(form.durationSeconds),
},
};
await api(form.id ? `/automatic-tasks/${form.id}` : "/automatic-tasks", {
method: form.id ? "PUT" : "POST",
body,
});
message.success(t(form.id ? "自动任务已更新" : "自动任务已创建"));
setOpen(false);
await load();
} catch (error) {
message.error(apiMessage(error));
} finally {
setSaving(false);
}
}
async function toggle(task: AutomaticTask) {
setBusy(task.id);
try {
await api(`/automatic-tasks/${task.id}`, { method: "PUT", body: { ...task, enabled: !task.enabled } });
await load();
} catch (error) {
message.error(apiMessage(error));
} finally {
setBusy(0);
}
}
async function runNow(task: AutomaticTask) {
setBusy(task.id);
try {
await api(`/automatic-tasks/${task.id}/run`, { method: "POST" });
message.success(t("任务已加入设备队列"));
await load();
} catch (error) {
message.error(apiMessage(error));
} finally {
setBusy(0);
}
}
async function remove(task: AutomaticTask) {
if (!await confirmDialog(t("确定删除这个自动任务吗?"), t("确认删除"), { type: "warning", confirmText: t("删除"), cancelText: t("取消") })) return;
setBusy(task.id);
try {
await api(`/automatic-tasks/${task.id}`, { method: "DELETE" });
message.success(t("自动任务已删除"));
await load();
} catch (error) {
message.error(apiMessage(error));
} finally {
setBusy(0);
}
}
const taskTypeLabel = (value: TaskType) => ({ sms: t("发送短信"), call: t("拨打电话并自动挂断"), public_ip: t("获取漫游公网 IP") })[value];
const environmentLabel = (value: TaskEnvironment) => value === "vowifi" ? "VoWiFi" : t("基站直连");
return (
<div className="mx-auto max-w-7xl">
<PageHeader
title={t("自动任务")}
subtitle={t("按周期切换指定 eSIM Profile,并在设备串行队列中执行短信、通话或漫游公网 IP 任务")}
actions={<Button variant="primary" icon={<AddRegular />} onClick={() => edit()} disabled={!devices.length}>{t("添加任务")}</Button>}
/>
<div className="ui-card overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full min-w-[1040px] text-left text-sm">
<thead className="border-b border-gray-100 bg-gray-50/70 text-xs uppercase tracking-wide text-gray-500 dark:border-white/10 dark:bg-white/[0.025]">
<tr>
<th className="px-4 py-3">{t("任务")}</th>
<th className="px-4 py-3">{t("设备 / Profile")}</th>
<th className="px-4 py-3">{t("类型")}</th>
<th className="px-4 py-3">{t("执行环境")}</th>
<th className="px-4 py-3">{t("周期")}</th>
<th className="px-4 py-3">{t("下次执行")}</th>
<th className="px-4 py-3">{t("上次结果")}</th>
<th className="px-4 py-3 text-right">{t("操作")}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-white/10">
{tasks.map((task) => (
<tr key={task.id} className="hover:bg-sky-50/40 dark:hover:bg-sky-500/[0.04]">
<td className="px-4 py-3">
<div className="flex items-center gap-2"><Switch checked={task.enabled} loading={busy === task.id} size="small" onChange={() => void toggle(task)} /><span className="font-semibold">{task.name}</span></div>
<div className="mt-1 text-xs text-gray-400">{task.notify ? t("完成后推送通知") : t("不推送通知")} · {t("失败重试 {count} 次").replace("{count}", String(task.retryCount))}</div>
</td>
<td className="px-4 py-3">
<div>{deviceByID.get(task.deviceId)?.name || task.deviceId}</div>
<div className="mt-1 font-mono text-xs text-gray-400">{task.profileIccid.slice(-8)}</div>
</td>
<td className="px-4 py-3">{taskTypeLabel(task.taskType)}</td>
<td className="px-4 py-3"><Tag type={task.environment === "vowifi" ? "primary" : "warning"}>{environmentLabel(task.environment)}</Tag></td>
<td className="px-4 py-3">{t("每 {days} 天").replace("{days}", String(task.intervalDays))} · {task.runTime}</td>
<td className="px-4 py-3 text-xs">{formatDateTime(task.nextRunAt)}</td>
<td className="px-4 py-3">
{task.lastStatus ? <Tag type={task.lastStatus === "success" ? "success" : "danger"}>{task.lastStatus === "success" ? t("成功") : t("失败")}</Tag> : <span className="text-gray-400">--</span>}
{task.lastError ? <div className="mt-1 max-w-[220px] truncate text-xs text-red-500" title={task.lastError}>{task.lastError}</div> : null}
</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-2">
<Button size="small" icon={<PlayRegular />} loading={busy === task.id} onClick={() => void runNow(task)}>{t("立即执行")}</Button>
<Button size="small" icon={<EditRegular />} onClick={() => edit(task)}>{t("编辑")}</Button>
<Button size="small" variant="danger" plain icon={<DeleteRegular />} loading={busy === task.id} onClick={() => void remove(task)}>{t("删除")}</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{!loading && !tasks.length ? (
<div className="flex flex-col items-center justify-center px-6 py-16 text-center text-gray-400">
<SendClockRegular className="mb-3 text-4xl" />
<div className="text-sm">{t("暂无自动任务")}</div>
<div className="mt-1 text-xs">{t("添加任务后,系统会按设备排队并在执行前校验目标 Profile")}</div>
</div>
) : null}
{loading ? <div className="px-6 py-16 text-center text-sm text-gray-400">{t("加载中...")}</div> : null}
</div>
<div className="ui-card mt-5 overflow-hidden">
<div className="border-b border-gray-100 px-5 py-4 dark:border-white/10"><h3 className="font-bold">{t("最近执行记录")}</h3></div>
<div className="overflow-x-auto">
<table className="w-full min-w-[800px] text-left text-sm">
<thead className="bg-gray-50/70 text-xs text-gray-500 dark:bg-white/[0.025]"><tr><th className="px-4 py-3">{t("任务")}</th><th className="px-4 py-3">{t("设备")}</th><th className="px-4 py-3">{t("状态")}</th><th className="px-4 py-3">{t("排队时间")}</th><th className="px-4 py-3">{t("尝试次数")}</th><th className="px-4 py-3">{t("结果")}</th></tr></thead>
<tbody className="divide-y divide-gray-100 dark:divide-white/10">
{runs.slice(0, 30).map((run) => (
<tr key={run.id}><td className="px-4 py-3 font-medium">{taskByID.get(run.taskId)?.name || `#${run.taskId}`}</td><td className="px-4 py-3">{deviceByID.get(run.deviceId)?.name || run.deviceId}</td><td className="px-4 py-3"><Tag type={run.status === "success" ? "success" : run.status === "failed" ? "danger" : run.status === "running" ? "warning" : "info"}>{({ queued: t("排队中"), running: t("执行中"), success: t("成功"), failed: t("失败") })[run.status]}</Tag></td><td className="px-4 py-3 text-xs">{formatDateTime(run.scheduledAt)}</td><td className="px-4 py-3">{run.attempts}</td><td className="px-4 py-3"><div className={run.error ? "max-w-md text-red-500" : "max-w-md text-gray-600 dark:text-gray-300"}>{run.error || run.output || "--"}</div></td></tr>
))}
</tbody>
</table>
</div>
{!runs.length ? <div className="p-8 text-center text-sm text-gray-400">{t("暂无执行记录")}</div> : null}
</div>
<Modal open={open} onClose={() => setOpen(false)} title={form.id ? t("编辑自动任务") : t("添加自动任务")} width="max-w-3xl">
<div className="grid gap-4 md:grid-cols-2">
<div className="md:col-span-2"><label className={fieldLabel}>{t("任务名称")}</label><Input value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} placeholder={t("例如:每日短信保活")} /></div>
<div><label className={fieldLabel}>{t("设备")}</label><Select value={form.deviceId} onChange={chooseDevice} options={devices.map((device) => ({ value: device.id, label: `${device.name || device.id} (${device.id})` }))} /></div>
<div><label className={fieldLabel}>{t("eSIM Profile")}</label><Select value={form.profileIccid} onChange={chooseProfile} disabled={profileLoading || !form.deviceId} placeholder={profileLoading ? t("读取 Profile 中...") : t("请选择 Profile")} options={profiles.map((profile) => ({ value: profile.iccid, label: profile.label }))} /></div>
<div><label className={fieldLabel}>{t("任务类型")}</label><Select value={form.taskType} onChange={(value) => chooseTaskType(value as TaskType)} options={[{ value: "sms", label: t("发送短信") }, { value: "call", label: t("拨打电话并自动挂断") }, { value: "public_ip", label: t("开启漫游流量并获取一次公网 IP") }]} /></div>
<div><label className={fieldLabel}>{t("执行环境")}</label><Select value={form.environment} onChange={(value) => setForm({ ...form, environment: value as TaskEnvironment })} disabled={form.taskType === "public_ip"} options={[{ value: "vowifi", label: "VoWiFi" }, { value: "cellular", label: t("基站直连(自动选网)") }]} /></div>
{form.taskType !== "public_ip" ? <div><label className={fieldLabel}>{t("号码")}</label><Input value={form.phone} onChange={(event) => setForm({ ...form, phone: event.target.value })} placeholder="+447700900123" /></div> : null}
{form.taskType === "call" ? <div><label className={fieldLabel}>{t("自动挂断")}</label><Input type="number" min={1} max={600} value={form.durationSeconds} suffix="s" onChange={(event) => setForm({ ...form, durationSeconds: Number(event.target.value) })} /></div> : null}
{form.taskType === "sms" ? <div className="md:col-span-2"><label className={fieldLabel}>{t("短信内容")}</label><Textarea rows={4} value={form.message} onChange={(event) => setForm({ ...form, message: event.target.value })} /></div> : null}
{form.taskType === "public_ip" ? <div className="md:col-span-2 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-700 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-300">{t("该任务固定使用基站直连和自动选网;执行时会开启漫游数据,并通过模块接口访问 ipinfo.io。需要开启开发者模式。")}</div> : null}
<div><label className={fieldLabel}>{t("首次执行日期")}</label><Input type="date" value={form.startDate} onChange={(event) => setForm({ ...form, startDate: event.target.value })} /></div>
<div><label className={fieldLabel}>{t("执行时间")}</label><Input type="time" value={form.runTime} onChange={(event) => setForm({ ...form, runTime: event.target.value })} /></div>
<div><label className={fieldLabel}>{t("执行周期")}</label><Input type="number" min={1} max={365} value={form.intervalDays} suffix={t("天")} onChange={(event) => setForm({ ...form, intervalDays: Number(event.target.value) })} /></div>
<div><label className={fieldLabel}>{t("任务失败重试次数")}</label><Select value={String(form.retryCount)} onChange={(value) => setForm({ ...form, retryCount: Number(value) })} options={Array.from({ length: 11 }, (_, count) => ({ value: String(count), label: t("{count} 次").replace("{count}", String(count)) }))} /></div>
<div className="flex items-center justify-between rounded-lg border border-gray-200 p-3 dark:border-white/10"><div><div className="text-sm font-semibold">{t("启用任务")}</div><div className="text-xs text-gray-400">{t("停用后不会进入执行队列")}</div></div><Switch checked={form.enabled} onChange={(enabled) => setForm({ ...form, enabled })} /></div>
<div className="flex items-center justify-between rounded-lg border border-gray-200 p-3 dark:border-white/10"><div><div className="text-sm font-semibold">{t("完成后推送通知")}</div><div className="text-xs text-gray-400">{t("发送到全部已配置并启用的通知渠道")}</div></div><Switch checked={form.notify} onChange={(notify) => setForm({ ...form, notify })} /></div>
</div>
<div className="mt-5 flex justify-end gap-2"><Button onClick={() => setOpen(false)}>{t("取消")}</Button><Button variant="primary" loading={saving} onClick={() => void save()}>{t("保存")}</Button></div>
</Modal>
</div>
);
}
+1 -5
View File
@@ -150,7 +150,7 @@ export default function ExportProxyPage() {
<div className="mx-auto max-w-7xl">
<PageHeader
title={t("导出代理")}
subtitle={t("将模块漫游数据导出为主机 HTTP 或 SOCKS5 代理;仅在开发者模式下可用")}
subtitle={t("将模块漫游数据导出为主机 HTTP 或 SOCKS5 代理")}
actions={<Button variant="primary" icon={<AddRegular />} onClick={() => edit()} disabled={!devices.length}>{t("添加代理")}</Button>}
/>
@@ -213,10 +213,6 @@ export default function ExportProxyPage() {
{loading ? <div className="px-6 py-16 text-center text-sm text-gray-400">{t("加载中...")}</div> : null}
</div>
<div className="ui-panel-muted mt-4 p-4 text-xs leading-6 text-gray-500">
{t("代理出口使用受保护的蜂窝路由和独立 DNS,不会把模块数据设为主机默认网络。关闭开发者模式会停止漫游数据并永久删除这里的全部配置。")}
</div>
<Modal
open={open}
onClose={() => setOpen(false)}
+7 -3
View File
@@ -1,8 +1,9 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { AddRegular } from "@fluentui/react-icons";
import { api, ApiError, apiMessage } from "../api";
import type { DeviceListItem, DeviceProxyBinding, DevicesResponse, UpstreamProxy } from "../types";
import { usePolling } from "../lib/usePolling";
import { PageHeader, confirmDialog, message } from "../components/ui";
import { Button, PageHeader, confirmDialog, message } from "../components/ui";
import {
emptyUpstreamForm,
ipv6AddrError,
@@ -233,13 +234,16 @@ export default function ProxyPage() {
return (
<div className="mx-auto max-w-7xl">
<PageHeader title={t("代理管理")} subtitle={t("管理 VoWiFi 上游代理和设备绑定")} />
<PageHeader
title={t("代理管理")}
subtitle={t("管理 VoWiFi 上游代理和设备绑定")}
actions={<Button variant="primary" icon={<AddRegular />} onClick={() => openUpstreamDialog()}>{t("新增代理")}</Button>}
/>
<UpstreamSection
rows={proxyRows}
loading={upstreamLoading}
error={upstreamError}
onRetry={() => loadUpstream(false)}
onNew={() => openUpstreamDialog()}
onEdit={openUpstreamDialog}
onDelete={removeUpstream}
onOpenBindings={openBindingsDialog}
+1 -1
View File
@@ -132,7 +132,7 @@ export default function SettingsPage() {
setDeveloperSettings(data);
setDeviceLimit(data.deviceLimit);
} catch (error) {
message.error(apiMessage(error) || (lang === "zh" ? "开发者配置加载失败" : "Failed to load developer settings"));
message.error(apiMessage(error) || (lang === "zh" ? "设备配额配置加载失败" : "Failed to load device quota settings"));
} finally {
setLoadingDeveloper(false);
}
+2
View File
@@ -31,6 +31,8 @@ export interface ApiErrorBody {
export interface VoWiFiRuntime {
deviceId: string;
phase: string;
enabled?: boolean;
active?: boolean;
dataplaneMode: string;
iccid: string;
imsi: string;