mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-13 03:13:43 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a260e86f1 | ||
|
|
5b8d1a86e8 |
+243
-35
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"`,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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))
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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) {}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -84,6 +84,7 @@ type Server struct {
|
||||
netTraffic *liveNetTracker
|
||||
publicIPMu sync.RWMutex
|
||||
publicIPs map[string]cachedPublicIP
|
||||
automaticTasks *automaticTaskScheduler
|
||||
}
|
||||
|
||||
func New(options Options) (*Server, error) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+1443
-108
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const automaticTaskSelect = `
|
||||
SELECT id, name, enabled, device_id, profile_iccid, profile_aid,
|
||||
task_type, environment, interval_days, start_date, run_time,
|
||||
timezone, payload_json, retry_count, notify, next_run_at, last_run_at,
|
||||
last_status, last_error, created_at, updated_at
|
||||
FROM automatic_tasks`
|
||||
|
||||
func (s *Store) SaveAutomaticTask(ctx context.Context, value AutomaticTask) (AutomaticTask, error) {
|
||||
now := time.Now().UTC()
|
||||
if strings.TrimSpace(value.Timezone) == "" {
|
||||
value.Timezone = time.Local.String()
|
||||
}
|
||||
if value.CreatedAt.IsZero() {
|
||||
value.CreatedAt = now
|
||||
}
|
||||
value.UpdatedAt = now
|
||||
if len(value.Payload) == 0 {
|
||||
value.Payload = []byte(`{}`)
|
||||
}
|
||||
if value.ID == 0 {
|
||||
result, err := s.db.ExecContext(ctx, `INSERT INTO automatic_tasks (
|
||||
name, enabled, device_id, profile_iccid, profile_aid, task_type,
|
||||
environment, interval_days, start_date, run_time, timezone, payload_json,
|
||||
retry_count, notify, next_run_at, last_run_at, last_status,
|
||||
last_error, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
strings.TrimSpace(value.Name), value.Enabled, strings.TrimSpace(value.DeviceID),
|
||||
strings.TrimSpace(value.ProfileICCID), strings.TrimSpace(value.ProfileAID),
|
||||
value.TaskType, value.Environment, value.IntervalDays, value.StartDate,
|
||||
value.RunTime, value.Timezone, string(value.Payload), value.RetryCount, value.Notify,
|
||||
value.NextRunAt.Unix(), unixOrZero(value.LastRunAt), value.LastStatus,
|
||||
value.LastError, value.CreatedAt.Unix(), value.UpdatedAt.Unix())
|
||||
if err != nil {
|
||||
return AutomaticTask{}, fmt.Errorf("create automatic task: %w", err)
|
||||
}
|
||||
value.ID, _ = result.LastInsertId()
|
||||
} else {
|
||||
result, err := s.db.ExecContext(ctx, `UPDATE automatic_tasks SET
|
||||
name = ?, enabled = ?, device_id = ?, profile_iccid = ?, profile_aid = ?,
|
||||
task_type = ?, environment = ?, interval_days = ?, start_date = ?,
|
||||
run_time = ?, timezone = ?, payload_json = ?, retry_count = ?, notify = ?,
|
||||
next_run_at = ?, updated_at = ? WHERE id = ?`,
|
||||
strings.TrimSpace(value.Name), value.Enabled, strings.TrimSpace(value.DeviceID),
|
||||
strings.TrimSpace(value.ProfileICCID), strings.TrimSpace(value.ProfileAID),
|
||||
value.TaskType, value.Environment, value.IntervalDays, value.StartDate,
|
||||
value.RunTime, value.Timezone, string(value.Payload), value.RetryCount, value.Notify,
|
||||
value.NextRunAt.Unix(), value.UpdatedAt.Unix(), value.ID)
|
||||
if err != nil {
|
||||
return AutomaticTask{}, fmt.Errorf("update automatic task %d: %w", value.ID, err)
|
||||
}
|
||||
if count, _ := result.RowsAffected(); count == 0 {
|
||||
return AutomaticTask{}, ErrNotFound
|
||||
}
|
||||
}
|
||||
return s.AutomaticTask(ctx, value.ID)
|
||||
}
|
||||
|
||||
func (s *Store) AutomaticTask(ctx context.Context, id int64) (AutomaticTask, error) {
|
||||
return scanAutomaticTask(s.db.QueryRowContext(ctx, automaticTaskSelect+` WHERE id = ?`, id))
|
||||
}
|
||||
|
||||
func (s *Store) ListAutomaticTasks(ctx context.Context) ([]AutomaticTask, error) {
|
||||
rows, err := s.db.QueryContext(ctx, automaticTaskSelect+` ORDER BY created_at DESC, id DESC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list automatic tasks: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var result []AutomaticTask
|
||||
for rows.Next() {
|
||||
value, scanErr := scanAutomaticTask(rows)
|
||||
if scanErr != nil {
|
||||
return nil, scanErr
|
||||
}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) DeleteAutomaticTask(ctx context.Context, id int64) error {
|
||||
result, err := s.db.ExecContext(ctx, `DELETE FROM automatic_tasks WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete automatic task %d: %w", id, err)
|
||||
}
|
||||
if count, _ := result.RowsAffected(); count == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ClaimDueAutomaticTasks(ctx context.Context, now time.Time, limit int) ([]AutomaticTaskRun, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 50
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
rows, err := tx.QueryContext(ctx, automaticTaskSelect+`
|
||||
WHERE enabled = 1 AND next_run_at <= ? ORDER BY next_run_at, id LIMIT ?`, now.Unix(), limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var tasks []AutomaticTask
|
||||
for rows.Next() {
|
||||
task, scanErr := scanAutomaticTask(rows)
|
||||
if scanErr != nil {
|
||||
rows.Close()
|
||||
return nil, scanErr
|
||||
}
|
||||
tasks = append(tasks, task)
|
||||
}
|
||||
rows.Close()
|
||||
result := make([]AutomaticTaskRun, 0, len(tasks))
|
||||
for _, task := range tasks {
|
||||
next := task.NextRunAt
|
||||
location := time.Local
|
||||
if loaded, loadErr := time.LoadLocation(task.Timezone); loadErr == nil {
|
||||
location = loaded
|
||||
}
|
||||
for !next.After(now) {
|
||||
next = next.In(location).AddDate(0, 0, task.IntervalDays).UTC()
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE automatic_tasks SET next_run_at = ?, updated_at = ? WHERE id = ?`, next.Unix(), now.Unix(), task.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
created, createErr := tx.ExecContext(ctx, `INSERT INTO automatic_task_runs (
|
||||
task_id, device_id, scheduled_at, status, created_at, updated_at
|
||||
) VALUES (?, ?, ?, 'queued', ?, ?)`, task.ID, task.DeviceID, task.NextRunAt.Unix(), now.Unix(), now.Unix())
|
||||
if createErr != nil {
|
||||
return nil, createErr
|
||||
}
|
||||
runID, _ := created.LastInsertId()
|
||||
result = append(result, AutomaticTaskRun{ID: runID, TaskID: task.ID, DeviceID: task.DeviceID, ScheduledAt: task.NextRunAt, Status: "queued", CreatedAt: now, UpdatedAt: now})
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Store) QueueAutomaticTaskNow(ctx context.Context, task AutomaticTask) (AutomaticTaskRun, error) {
|
||||
now := time.Now().UTC()
|
||||
result, err := s.db.ExecContext(ctx, `INSERT INTO automatic_task_runs (
|
||||
task_id, device_id, scheduled_at, status, created_at, updated_at
|
||||
) VALUES (?, ?, ?, 'queued', ?, ?)`, task.ID, task.DeviceID, now.Unix(), now.Unix(), now.Unix())
|
||||
if err != nil {
|
||||
return AutomaticTaskRun{}, fmt.Errorf("queue automatic task: %w", err)
|
||||
}
|
||||
id, _ := result.LastInsertId()
|
||||
return AutomaticTaskRun{ID: id, TaskID: task.ID, DeviceID: task.DeviceID, ScheduledAt: now, Status: "queued", CreatedAt: now, UpdatedAt: now}, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateAutomaticTaskRun(ctx context.Context, run AutomaticTaskRun) error {
|
||||
now := time.Now().UTC()
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE automatic_task_runs SET
|
||||
started_at = ?, finished_at = ?, status = ?, attempts = ?, output = ?, error = ?, updated_at = ?
|
||||
WHERE id = ?`, unixOrZero(run.StartedAt), unixOrZero(run.FinishedAt), run.Status,
|
||||
run.Attempts, run.Output, run.Error, now.Unix(), run.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update automatic task run %d: %w", run.ID, err)
|
||||
}
|
||||
if run.Status == "success" || run.Status == "failed" {
|
||||
_, err = s.db.ExecContext(ctx, `UPDATE automatic_tasks SET
|
||||
last_run_at = ?, last_status = ?, last_error = ?, updated_at = ? WHERE id = ?`,
|
||||
run.FinishedAt.Unix(), run.Status, run.Error, now.Unix(), run.TaskID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ListAutomaticTaskRuns(ctx context.Context, limit int) ([]AutomaticTaskRun, error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id, task_id, device_id, scheduled_at,
|
||||
started_at, finished_at, status, attempts, output, error, created_at, updated_at
|
||||
FROM automatic_task_runs ORDER BY id DESC LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var result []AutomaticTaskRun
|
||||
for rows.Next() {
|
||||
var value AutomaticTaskRun
|
||||
var scheduled, started, finished, created, updated int64
|
||||
if err := rows.Scan(&value.ID, &value.TaskID, &value.DeviceID, &scheduled, &started,
|
||||
&finished, &value.Status, &value.Attempts, &value.Output, &value.Error, &created, &updated); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value.ScheduledAt, value.StartedAt, value.FinishedAt = time.Unix(scheduled, 0).UTC(), timeFromUnix(started), timeFromUnix(finished)
|
||||
value.CreatedAt, value.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC()
|
||||
result = append(result, value)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func scanAutomaticTask(row rowScanner) (AutomaticTask, error) {
|
||||
var value AutomaticTask
|
||||
var enabled, notify bool
|
||||
var payload string
|
||||
var nextRun, lastRun, created, updated int64
|
||||
if err := row.Scan(&value.ID, &value.Name, &enabled, &value.DeviceID, &value.ProfileICCID,
|
||||
&value.ProfileAID, &value.TaskType, &value.Environment, &value.IntervalDays,
|
||||
&value.StartDate, &value.RunTime, &value.Timezone, &payload, &value.RetryCount, ¬ify,
|
||||
&nextRun, &lastRun, &value.LastStatus, &value.LastError, &created, &updated); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return AutomaticTask{}, ErrNotFound
|
||||
}
|
||||
return AutomaticTask{}, err
|
||||
}
|
||||
value.Enabled, value.Notify = enabled, notify
|
||||
value.Payload = []byte(payload)
|
||||
value.NextRunAt, value.LastRunAt = time.Unix(nextRun, 0).UTC(), timeFromUnix(lastRun)
|
||||
value.CreatedAt, value.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC()
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func unixOrZero(value time.Time) int64 {
|
||||
if value.IsZero() {
|
||||
return 0
|
||||
}
|
||||
return value.Unix()
|
||||
}
|
||||
|
||||
func timeFromUnix(value int64) time.Time {
|
||||
if value <= 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
return time.Unix(value, 0).UTC()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
const schemaVersion = 8
|
||||
const schemaVersion = 10
|
||||
|
||||
var ErrNotFound = errors.New("store: not found")
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("此类设备固定 QMI,AT 口仅用于终端") : isMbim ? t("此类设备固定 MBIM,AT 口仅用于终端") : t("AT=传统串口 / QMI=纯 QMI")}
|
||||
{isQmi
|
||||
? t("QMI 负责驻网状态与数据会话;AT 负责 SIM/eSIM、射频、短信、通话和终端指令")
|
||||
: isMbim
|
||||
? t("MBIM 负责数据会话;AT 负责 SIM/eSIM、射频、短信、通话和终端指令")
|
||||
: t("AT 模式通过串口管理驻网与 PDP 数据会话")}
|
||||
</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 =
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -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
@@ -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.",
|
||||
"此类设备固定 MBIM,AT 口仅用于终端": "This device is fixed to MBIM; the AT port is for the terminal only",
|
||||
"此类设备固定 QMI,AT 口仅用于终端": "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
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ export interface ApiErrorBody {
|
||||
export interface VoWiFiRuntime {
|
||||
deviceId: string;
|
||||
phase: string;
|
||||
enabled?: boolean;
|
||||
active?: boolean;
|
||||
dataplaneMode: string;
|
||||
iccid: string;
|
||||
imsi: string;
|
||||
|
||||
Reference in New Issue
Block a user