mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-22 07:43:43 +08:00
feat: add per-card cellular IMS SMS policy (#77)
Co-authored-by: geekouc <[email protected]>
This commit is contained in:
+71
-21
@@ -496,26 +496,7 @@ func restoreConfiguredCellularData(
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
networkRequest := device.NetworkRequest{
|
||||
Enabled: true, APN: config.APN, IPVersion: "IPV4V6", Backend: config.DeviceBackend,
|
||||
}
|
||||
if entry.Snapshot != nil {
|
||||
iccid := strings.TrimSpace(entry.Snapshot.ICCID)
|
||||
if policy, policyErr := database.CardPolicy(ctx, iccid); policyErr == nil {
|
||||
networkRequest.APN = policy.APN
|
||||
if policy.IPVersion != "" {
|
||||
networkRequest.IPVersion = policy.IPVersion
|
||||
}
|
||||
if profile, profileErr := database.CardAPNProfileByAPN(ctx, iccid, policy.APN, policy.IPVersion); profileErr == nil {
|
||||
networkRequest.Username = profile.Username
|
||||
networkRequest.Password = profile.Password
|
||||
networkRequest.Authentication = profile.AuthType
|
||||
if entry.Snapshot.RegistrationStatus == 5 && profile.RoamingIPVersion != "" {
|
||||
networkRequest.IPVersion = profile.RoamingIPVersion
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
networkRequest := configuredCellularNetworkRequest(ctx, database, config, entry.Snapshot)
|
||||
dataContext, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||
_, err = manager.SetNetwork(dataContext, entry.ID, networkRequest)
|
||||
cancel()
|
||||
@@ -527,6 +508,40 @@ func restoreConfiguredCellularData(
|
||||
}
|
||||
}
|
||||
|
||||
func configuredCellularNetworkRequest(
|
||||
ctx context.Context,
|
||||
database *store.Store,
|
||||
config store.Device,
|
||||
snapshot *device.Snapshot,
|
||||
) device.NetworkRequest {
|
||||
request := device.NetworkRequest{
|
||||
Enabled: true, APN: config.APN, IPVersion: "IPV4V6", Backend: config.DeviceBackend,
|
||||
}
|
||||
if snapshot == nil {
|
||||
return request
|
||||
}
|
||||
iccid := strings.TrimSpace(snapshot.ICCID)
|
||||
policy, err := database.CardPolicy(ctx, iccid)
|
||||
if err != nil {
|
||||
return request
|
||||
}
|
||||
request.APN = policy.APN
|
||||
if policy.IPVersion != "" {
|
||||
request.IPVersion = policy.IPVersion
|
||||
}
|
||||
profile, err := database.CardAPNProfileByAPN(ctx, iccid, policy.APN, policy.IPVersion)
|
||||
if err != nil {
|
||||
return request
|
||||
}
|
||||
request.Username = profile.Username
|
||||
request.Password = profile.Password
|
||||
request.Authentication = profile.AuthType
|
||||
if snapshot.RegistrationStatus == 5 && profile.RoamingIPVersion != "" {
|
||||
request.IPVersion = profile.RoamingIPVersion
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
func disableAllDeveloperCellularData(
|
||||
ctx context.Context,
|
||||
logger *slog.Logger,
|
||||
@@ -1161,7 +1176,7 @@ func enforceDefaultSafeCardPolicy(
|
||||
}
|
||||
if err := database.UpsertCardPolicy(ctx, store.CardPolicy{
|
||||
ICCID: iccid, VoWiFiEnabled: true, AirplaneEnabled: true,
|
||||
IPVersion: "IPV4V6", Source: "default",
|
||||
IPVersion: "IPV4V6", Source: "default", CellularIMSManaged: true,
|
||||
}); err != nil {
|
||||
logger.Warn("default card policy: persist policy", "iccid", iccid, "error", err)
|
||||
return
|
||||
@@ -1195,6 +1210,9 @@ func reconcileCardPolicies(
|
||||
) {
|
||||
observedCards := make(map[string]string)
|
||||
wifi410StartupNotBefore := time.Now().Add(wifi410VoWiFiStartupDelay)
|
||||
imsApplied := make(map[string]string)
|
||||
imsRetryAfter := make(map[string]time.Time)
|
||||
imsDataRestorePending := make(map[string]bool)
|
||||
reconcile := func() {
|
||||
policies, policyListErr := database.ListCardPolicies(ctx)
|
||||
if policyListErr == nil {
|
||||
@@ -1241,6 +1259,38 @@ func reconcileCardPolicies(
|
||||
if policyErr != nil {
|
||||
continue
|
||||
}
|
||||
imsKey := fmt.Sprintf("%s:%t", iccid, policy.CellularIMSEnabled)
|
||||
if policy.CellularIMSManaged && imsApplied[config.ID] != imsKey && !time.Now().Before(imsRetryAfter[config.ID]) {
|
||||
imsContext, cancelIMS := context.WithTimeout(ctx, 45*time.Second)
|
||||
status, imsErr := manager.SetCellularIMS(imsContext, entry.ID, policy.CellularIMSEnabled)
|
||||
cancelIMS()
|
||||
if imsErr != nil {
|
||||
imsRetryAfter[config.ID] = time.Now().Add(time.Minute)
|
||||
logger.Warn("reconcile cellular IMS policy failed", "device_id", config.ID, "iccid", iccid, "error", imsErr)
|
||||
} else {
|
||||
imsApplied[config.ID] = imsKey
|
||||
delete(imsRetryAfter, config.ID)
|
||||
logger.Info("reconciled cellular IMS policy", "device_id", config.ID, "iccid", iccid,
|
||||
"enabled", policy.CellularIMSEnabled, "registered", status.Registered,
|
||||
"changed", status.Changed, "rebooting", status.Rebooting)
|
||||
if status.Rebooting {
|
||||
imsDataRestorePending[config.ID] = config.NetworkEnabled && !config.VoWiFiEnabled
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
if imsDataRestorePending[config.ID] && config.NetworkEnabled && !policy.VoWiFiEnabled && entry.Snapshot.PSAttached {
|
||||
request := configuredCellularNetworkRequest(ctx, database, config, entry.Snapshot)
|
||||
restoreContext, cancelRestore := context.WithTimeout(ctx, 60*time.Second)
|
||||
_, restoreErr := manager.SetNetwork(restoreContext, entry.ID, request)
|
||||
cancelRestore()
|
||||
if restoreErr != nil {
|
||||
logger.Warn("reconcile cellular data after IMS reboot failed", "device_id", config.ID, "error", restoreErr)
|
||||
continue
|
||||
}
|
||||
delete(imsDataRestorePending, config.ID)
|
||||
logger.Info("restored cellular data after reconciled IMS reboot", "device_id", config.ID, "interface", config.Interface)
|
||||
}
|
||||
if policy.VoWiFiEnabled && (!policy.AirplaneEnabled || policy.NetworkEnabled) {
|
||||
policy.AirplaneEnabled = true
|
||||
policy.NetworkEnabled = false
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"vocat/internal/modem"
|
||||
)
|
||||
|
||||
// CellularIMSStatus is the Quectel baseband IMS switch and current registration
|
||||
// state. Configured is persistent module configuration; Registered is live and
|
||||
// may remain false until the operator finishes IMS registration.
|
||||
type CellularIMSStatus struct {
|
||||
Supported bool `json:"supported"`
|
||||
Configured bool `json:"configured"`
|
||||
Registered bool `json:"registered"`
|
||||
CSKnown bool `json:"csKnown"`
|
||||
CSRegistered bool `json:"csRegistered"`
|
||||
Changed bool `json:"changed,omitempty"`
|
||||
Rebooting bool `json:"rebooting,omitempty"`
|
||||
}
|
||||
|
||||
var cellularIMSLine = regexp.MustCompile(`(?i)^\+QCFG:\s*"ims"\s*,\s*([01])(?:\s*,\s*([01]))?\s*$`)
|
||||
var cellularCSLine = regexp.MustCompile(`(?i)^\+CREG:\s*(?:\d+\s*,\s*)?([0-9]+)(?:\s*,.*)?$`)
|
||||
|
||||
func parseCellularCSRegistration(lines []string) (registered, known bool) {
|
||||
for _, line := range lines {
|
||||
matches := cellularCSLine.FindStringSubmatch(strings.TrimSpace(line))
|
||||
if matches == nil {
|
||||
continue
|
||||
}
|
||||
status, err := strconv.Atoi(matches[1])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
return status == 1 || status == 5, true
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
func parseCellularIMSStatus(lines []string) (CellularIMSStatus, error) {
|
||||
for _, line := range lines {
|
||||
matches := cellularIMSLine.FindStringSubmatch(strings.TrimSpace(line))
|
||||
if matches == nil {
|
||||
continue
|
||||
}
|
||||
configured, _ := strconv.Atoi(matches[1])
|
||||
registered := 0
|
||||
if len(matches) > 2 && matches[2] != "" {
|
||||
registered, _ = strconv.Atoi(matches[2])
|
||||
}
|
||||
return CellularIMSStatus{
|
||||
Supported: true, Configured: configured == 1, Registered: registered == 1,
|
||||
}, nil
|
||||
}
|
||||
return CellularIMSStatus{}, errors.New("modem did not return a Quectel IMS status")
|
||||
}
|
||||
|
||||
func (manager *Manager) CellularIMS(ctx context.Context, id string) (CellularIMSStatus, error) {
|
||||
state, err := manager.lookup(id)
|
||||
if err != nil {
|
||||
return CellularIMSStatus{}, err
|
||||
}
|
||||
state.opMu.Lock()
|
||||
defer state.opMu.Unlock()
|
||||
if err := manager.validateActive(id, state); err != nil {
|
||||
return CellularIMSStatus{}, err
|
||||
}
|
||||
client, err := manager.clientLocked(ctx, state, manager.candidateFor(state))
|
||||
if err != nil {
|
||||
manager.setResult(id, state, nil, err)
|
||||
return CellularIMSStatus{}, err
|
||||
}
|
||||
status, err := manager.readCellularIMS(ctx, client)
|
||||
if err == nil {
|
||||
if response, csErr := manager.command(ctx, client, "AT+CREG?"); csErr == nil {
|
||||
status.CSRegistered, status.CSKnown = parseCellularCSRegistration(response.Lines)
|
||||
}
|
||||
}
|
||||
manager.setResult(id, state, nil, err)
|
||||
return status, err
|
||||
}
|
||||
|
||||
// SetCellularIMS changes the persistent Quectel IMS override. A full modem
|
||||
// restart is issued only when the configured value changes; this is essential
|
||||
// because QCFG may report the new setting before the baseband has loaded it.
|
||||
func (manager *Manager) SetCellularIMS(ctx context.Context, id string, enabled bool) (CellularIMSStatus, error) {
|
||||
state, err := manager.lookup(id)
|
||||
if err != nil {
|
||||
return CellularIMSStatus{}, err
|
||||
}
|
||||
state.opMu.Lock()
|
||||
defer state.opMu.Unlock()
|
||||
if err := manager.validateActive(id, state); err != nil {
|
||||
return CellularIMSStatus{}, err
|
||||
}
|
||||
client, err := manager.clientLocked(ctx, state, manager.candidateFor(state))
|
||||
if err != nil {
|
||||
manager.setResult(id, state, nil, err)
|
||||
return CellularIMSStatus{}, err
|
||||
}
|
||||
status, err := manager.readCellularIMS(ctx, client)
|
||||
if err != nil {
|
||||
manager.setResult(id, state, nil, err)
|
||||
return CellularIMSStatus{}, err
|
||||
}
|
||||
if status.Configured == enabled {
|
||||
manager.setResult(id, state, nil, nil)
|
||||
return status, nil
|
||||
}
|
||||
target := 0
|
||||
if enabled {
|
||||
target = 1
|
||||
}
|
||||
if _, err = manager.command(ctx, client, fmt.Sprintf(`AT+QCFG="ims",%d`, target)); err != nil {
|
||||
manager.setResult(id, state, nil, err)
|
||||
return CellularIMSStatus{}, err
|
||||
}
|
||||
status, err = manager.readCellularIMS(ctx, client)
|
||||
if err != nil {
|
||||
manager.setResult(id, state, nil, err)
|
||||
return CellularIMSStatus{}, err
|
||||
}
|
||||
if status.Configured != enabled {
|
||||
err = errors.New("modem did not retain the requested IMS setting")
|
||||
manager.setResult(id, state, nil, err)
|
||||
return CellularIMSStatus{}, err
|
||||
}
|
||||
status.Changed = true
|
||||
status.Rebooting = true
|
||||
rebootCtx, cancel := manager.withTimeout(ctx, manager.longTimeout)
|
||||
_, err = client.Execute(rebootCtx, "AT+CFUN=1,1")
|
||||
cancel()
|
||||
if closeErr := client.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
state.client = nil
|
||||
state.preFlightMode = nil
|
||||
manager.clearSnapshot(id, state)
|
||||
manager.setResult(id, state, nil, err)
|
||||
return status, err
|
||||
}
|
||||
|
||||
func (manager *Manager) readCellularIMS(ctx context.Context, client modem.Client) (CellularIMSStatus, error) {
|
||||
response, err := manager.command(ctx, client, `AT+QCFG="ims"`)
|
||||
if err != nil {
|
||||
return CellularIMSStatus{}, fmt.Errorf("query cellular IMS: %w", err)
|
||||
}
|
||||
status, err := parseCellularIMSStatus(response.Lines)
|
||||
if err != nil {
|
||||
return CellularIMSStatus{}, err
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"vocat/internal/modem"
|
||||
)
|
||||
|
||||
func TestParseCellularIMSStatus(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
line string
|
||||
configured, active bool
|
||||
}{
|
||||
{`+QCFG: "ims",0,0`, false, false},
|
||||
{`+QCFG: "ims",1,0`, true, false},
|
||||
{`+QCFG: "ims", 1, 1`, true, true},
|
||||
{`+QCFG: "ims",1`, true, false},
|
||||
} {
|
||||
status, err := parseCellularIMSStatus([]string{test.line})
|
||||
if err != nil || !status.Supported || status.Configured != test.configured || status.Registered != test.active {
|
||||
t.Errorf("parseCellularIMSStatus(%q) = %+v, %v", test.line, status, err)
|
||||
}
|
||||
}
|
||||
if _, err := parseCellularIMSStatus([]string{"OK"}); err == nil {
|
||||
t.Fatal("missing QCFG status was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCellularCSRegistration(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
line string
|
||||
registered bool
|
||||
}{
|
||||
{`+CREG: 0,1`, true},
|
||||
{`+CREG: 2,5,"1234","12345678",7`, true},
|
||||
{`+CREG: 0,3`, false},
|
||||
} {
|
||||
registered, known := parseCellularCSRegistration([]string{test.line})
|
||||
if !known || registered != test.registered {
|
||||
t.Errorf("parseCellularCSRegistration(%q) = %t, %t", test.line, registered, known)
|
||||
}
|
||||
}
|
||||
if _, known := parseCellularCSRegistration([]string{"OK"}); known {
|
||||
t.Fatal("missing CREG status was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCellularIMSEnablesAndRebootsOnlyOnce(t *testing.T) {
|
||||
client := &transcriptClient{steps: []clientStep{
|
||||
{command: `AT+QCFG="ims"`, response: modem.Response{Lines: []string{`+QCFG: "ims",0,0`}, Final: "OK"}},
|
||||
{command: `AT+QCFG="ims",1`, response: modem.Response{Final: "OK"}},
|
||||
{command: `AT+QCFG="ims"`, response: modem.Response{Lines: []string{`+QCFG: "ims",1,0`}, Final: "OK"}},
|
||||
{command: "AT+CFUN=1,1", response: modem.Response{Final: "OK"}},
|
||||
}}
|
||||
manager, id := newStartedTestManager(t, client)
|
||||
status, err := manager.SetCellularIMS(context.Background(), id, true)
|
||||
if err != nil || !status.Configured || !status.Changed || !status.Rebooting {
|
||||
t.Fatalf("SetCellularIMS = %+v, %v", status, err)
|
||||
}
|
||||
if client.closeCount != 1 {
|
||||
t.Fatalf("close count = %d, want 1", client.closeCount)
|
||||
}
|
||||
client.assertDone(t)
|
||||
}
|
||||
|
||||
func TestSetCellularIMSNoopDoesNotReboot(t *testing.T) {
|
||||
client := &transcriptClient{steps: []clientStep{
|
||||
{command: `AT+QCFG="ims"`, response: modem.Response{Lines: []string{`+QCFG: "ims",1,1`}, Final: "OK"}},
|
||||
}}
|
||||
manager, id := newStartedTestManager(t, client)
|
||||
status, err := manager.SetCellularIMS(context.Background(), id, true)
|
||||
if err != nil || status.Changed || status.Rebooting || !status.Registered {
|
||||
t.Fatalf("SetCellularIMS = %+v, %v", status, err)
|
||||
}
|
||||
if client.closeCount != 0 {
|
||||
t.Fatalf("close count = %d, want 0", client.closeCount)
|
||||
}
|
||||
client.assertDone(t)
|
||||
}
|
||||
@@ -56,6 +56,11 @@ type DeviceController interface {
|
||||
ESIMChipInfo(context.Context, string) (*device.EsimChipInfo, error)
|
||||
}
|
||||
|
||||
type cellularIMSController interface {
|
||||
CellularIMS(context.Context, string) (device.CellularIMSStatus, error)
|
||||
SetCellularIMS(context.Context, string, bool) (device.CellularIMSStatus, error)
|
||||
}
|
||||
|
||||
type deviceConfigPayload struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -282,7 +287,7 @@ func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) bool {
|
||||
if errors.Is(policyErr, store.ErrNotFound) {
|
||||
policyErr = s.store.UpsertCardPolicy(r.Context(), store.CardPolicy{
|
||||
ICCID: iccid, VoWiFiEnabled: true, AirplaneEnabled: true,
|
||||
IPVersion: "IPV4V6", Source: "default",
|
||||
IPVersion: "IPV4V6", Source: "default", CellularIMSManaged: true,
|
||||
})
|
||||
}
|
||||
if policyErr != nil {
|
||||
@@ -614,6 +619,11 @@ func (s *Server) handleDevicePath(
|
||||
return true
|
||||
}
|
||||
return s.handleAPNProfiles(w, r, physicalID)
|
||||
case "cellular-ims":
|
||||
if !s.requirePhysicalDevice(w, physicalPresent) {
|
||||
return true
|
||||
}
|
||||
return s.handleCellularIMS(w, r, config, physicalID)
|
||||
case "network/public-ip":
|
||||
if !s.requirePhysicalDevice(w, physicalPresent) {
|
||||
return true
|
||||
@@ -1389,6 +1399,164 @@ func (s *Server) handleAPNProfiles(w http.ResponseWriter, r *http.Request, physi
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) handleCellularIMS(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
config store.Device,
|
||||
physicalID string,
|
||||
) bool {
|
||||
controller, ok := s.devices.(cellularIMSController)
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotImplemented, "cellular_ims_unsupported", "cellular IMS control is unavailable")
|
||||
return true
|
||||
}
|
||||
entry, err := s.devices.Get(physicalID)
|
||||
if err != nil || entry.Snapshot == nil || !entry.Snapshot.SIMReady {
|
||||
writeError(w, http.StatusConflict, "sim_not_ready", "a ready SIM is required to configure cellular IMS")
|
||||
return true
|
||||
}
|
||||
iccid := strings.TrimSpace(entry.Snapshot.ICCID)
|
||||
if !validICCID(iccid) {
|
||||
writeError(w, http.StatusConflict, "iccid_unavailable", "the active SIM ICCID is unavailable")
|
||||
return true
|
||||
}
|
||||
policy, policyErr := s.store.CardPolicy(r.Context(), iccid)
|
||||
if errors.Is(policyErr, store.ErrNotFound) {
|
||||
policy = defaultCardPolicy(iccid)
|
||||
} else if policyErr != nil {
|
||||
s.writeStoreError(w, policyErr)
|
||||
return true
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
status, statusErr := controller.CellularIMS(r.Context(), physicalID)
|
||||
if statusErr != nil {
|
||||
writeError(w, http.StatusUnprocessableEntity, "cellular_ims_unsupported", statusErr.Error())
|
||||
return true
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": cellularIMSResponse(iccid, policy.CellularIMSEnabled, status)})
|
||||
case http.MethodPatch:
|
||||
var request struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
if err := s.decodeJSON(w, r, &request); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return true
|
||||
}
|
||||
if request.Enabled == nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_cellular_ims", "enabled is required")
|
||||
return true
|
||||
}
|
||||
policy.CellularIMSEnabled = *request.Enabled
|
||||
policy.CellularIMSManaged = true
|
||||
policy.Source = "manual"
|
||||
if policy.IPVersion == "" {
|
||||
policy.IPVersion = "IPV4V6"
|
||||
}
|
||||
if err := s.store.UpsertCardPolicy(r.Context(), policy); err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return true
|
||||
}
|
||||
status, applyErr := controller.SetCellularIMS(r.Context(), physicalID, *request.Enabled)
|
||||
if applyErr != nil {
|
||||
writeError(w, http.StatusBadGateway, "cellular_ims_apply_failed", "IMS policy was saved but could not be applied: "+applyErr.Error())
|
||||
return true
|
||||
}
|
||||
if status.Rebooting && config.NetworkEnabled && !config.VoWiFiEnabled {
|
||||
s.restoreCellularDataAfterIMSReboot(config.ID, physicalID, iccid)
|
||||
}
|
||||
statusCode := http.StatusOK
|
||||
if status.Rebooting {
|
||||
statusCode = http.StatusAccepted
|
||||
}
|
||||
writeJSON(w, statusCode, map[string]any{"data": cellularIMSResponse(iccid, *request.Enabled, status)})
|
||||
default:
|
||||
w.Header().Set("Allow", "GET, PATCH")
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// restoreCellularDataAfterIMSReboot rebuilds the QMI/AT data session destroyed
|
||||
// by AT+CFUN=1,1. The desired data state already lives in the device/card
|
||||
// policy; this only waits for the same SIM to register again before replaying it.
|
||||
func (s *Server) restoreCellularDataAfterIMSReboot(configID, physicalID, iccid string) {
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
defer cancel()
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
var lastErr error
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
s.logger.Warn("restore cellular data after IMS reboot timed out", "device_id", configID, "error", lastErr)
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
config, err := s.store.Device(ctx, configID)
|
||||
if err != nil || !config.NetworkEnabled || config.VoWiFiEnabled {
|
||||
return
|
||||
}
|
||||
entry, err := s.devices.Get(physicalID)
|
||||
if err != nil || entry.Snapshot == nil || !entry.Snapshot.SIMReady ||
|
||||
!strings.EqualFold(strings.TrimSpace(entry.Snapshot.ICCID), iccid) ||
|
||||
!entry.Snapshot.PSAttached {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
request := s.cellularNetworkRequest(ctx, config, entry.Snapshot)
|
||||
restoreCtx, cancelRestore := context.WithTimeout(ctx, 60*time.Second)
|
||||
_, err = s.devices.SetNetwork(restoreCtx, physicalID, request)
|
||||
cancelRestore()
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
s.logger.Info("restored cellular data after IMS reboot", "device_id", configID, "interface", config.Interface)
|
||||
return
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Server) cellularNetworkRequest(ctx context.Context, config store.Device, snapshot *device.Snapshot) device.NetworkRequest {
|
||||
request := device.NetworkRequest{
|
||||
Enabled: true, APN: strings.TrimSpace(config.APN), IPVersion: "IPV4V6", Backend: config.DeviceBackend,
|
||||
}
|
||||
if snapshot == nil {
|
||||
return request
|
||||
}
|
||||
iccid := strings.TrimSpace(snapshot.ICCID)
|
||||
policy, err := s.store.CardPolicy(ctx, iccid)
|
||||
if err != nil {
|
||||
return request
|
||||
}
|
||||
request.APN = strings.TrimSpace(policy.APN)
|
||||
if policy.IPVersion != "" {
|
||||
request.IPVersion = policy.IPVersion
|
||||
}
|
||||
profile, err := s.store.CardAPNProfileByAPN(ctx, iccid, policy.APN, policy.IPVersion)
|
||||
if err != nil {
|
||||
return request
|
||||
}
|
||||
request.Username = profile.Username
|
||||
request.Password = profile.Password
|
||||
request.Authentication = profile.AuthType
|
||||
if snapshot.RegistrationStatus == 5 && profile.RoamingIPVersion != "" {
|
||||
request.IPVersion = profile.RoamingIPVersion
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
func cellularIMSResponse(iccid string, desired bool, status device.CellularIMSStatus) map[string]any {
|
||||
return map[string]any{
|
||||
"iccid": iccid, "desired_enabled": desired, "supported": status.Supported,
|
||||
"configured": status.Configured, "registered": status.Registered,
|
||||
"cs_known": status.CSKnown, "cs_registered": status.CSRegistered,
|
||||
"changed": status.Changed, "rebooting": status.Rebooting,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleCellularData(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
|
||||
@@ -73,6 +73,51 @@ func TestParseModemAPNProfiles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type fakeCellularIMSController struct {
|
||||
fakeDeviceController
|
||||
status device.CellularIMSStatus
|
||||
setTo *bool
|
||||
setErr error
|
||||
}
|
||||
|
||||
func (controller *fakeCellularIMSController) CellularIMS(context.Context, string) (device.CellularIMSStatus, error) {
|
||||
return controller.status, controller.setErr
|
||||
}
|
||||
|
||||
func (controller *fakeCellularIMSController) SetCellularIMS(_ context.Context, _ string, enabled bool) (device.CellularIMSStatus, error) {
|
||||
controller.setTo = &enabled
|
||||
return controller.status, controller.setErr
|
||||
}
|
||||
|
||||
func TestCellularIMSPatchPersistsCurrentICCIDsPolicy(t *testing.T) {
|
||||
test := newSettingsAPITest(t)
|
||||
const iccid = "898520313000000590"
|
||||
controller := &fakeCellularIMSController{
|
||||
fakeDeviceController: fakeDeviceController{entry: device.Device{
|
||||
ID: "physical-1", Discovered: true,
|
||||
Snapshot: &device.Snapshot{DeviceID: "physical-1", SIMReady: true, ICCID: iccid},
|
||||
}},
|
||||
status: device.CellularIMSStatus{Supported: true, Configured: true, Changed: true, Rebooting: true},
|
||||
}
|
||||
test.server.devices = controller
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPatch, "/api/devices/configured-1/cellular-ims", strings.NewReader(`{"enabled":true}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
if !test.server.handleCellularIMS(recorder, request, store.Device{ID: "configured-1"}, "physical-1") {
|
||||
t.Fatal("handleCellularIMS returned false")
|
||||
}
|
||||
if recorder.Code != http.StatusAccepted {
|
||||
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body)
|
||||
}
|
||||
if controller.setTo == nil || !*controller.setTo {
|
||||
t.Fatalf("SetCellularIMS captured %v", controller.setTo)
|
||||
}
|
||||
policy, err := test.database.CardPolicy(context.Background(), iccid)
|
||||
if err != nil || !policy.CellularIMSManaged || !policy.CellularIMSEnabled {
|
||||
t.Fatalf("stored policy = %+v, %v", policy, err)
|
||||
}
|
||||
}
|
||||
|
||||
type esimAIDCaptureController struct {
|
||||
fakeDeviceController
|
||||
switchAID string
|
||||
|
||||
@@ -1405,18 +1405,20 @@ func (s *Server) handleCardPolicy(w http.ResponseWriter, r *http.Request, iccid
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": cardPolicyResponse(policy)})
|
||||
case http.MethodPut:
|
||||
var request struct {
|
||||
VoWiFiEnabled *bool `json:"vowifi_enabled"`
|
||||
AirplaneEnabled *bool `json:"airplane_enabled"`
|
||||
APN *string `json:"apn"`
|
||||
IPVersion *string `json:"ip_version"`
|
||||
CustomPhoneNumber *string `json:"custom_phone_number"`
|
||||
VoWiFiEnabled *bool `json:"vowifi_enabled"`
|
||||
AirplaneEnabled *bool `json:"airplane_enabled"`
|
||||
APN *string `json:"apn"`
|
||||
IPVersion *string `json:"ip_version"`
|
||||
CustomPhoneNumber *string `json:"custom_phone_number"`
|
||||
CellularIMSEnabled *bool `json:"cellular_ims_enabled"`
|
||||
}
|
||||
if err := s.decodeJSON(w, r, &request); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
if request.VoWiFiEnabled == nil && request.AirplaneEnabled == nil &&
|
||||
request.APN == nil && request.IPVersion == nil && request.CustomPhoneNumber == nil {
|
||||
request.APN == nil && request.IPVersion == nil && request.CustomPhoneNumber == nil &&
|
||||
request.CellularIMSEnabled == nil {
|
||||
writeError(
|
||||
w,
|
||||
http.StatusBadRequest,
|
||||
@@ -1464,6 +1466,10 @@ func (s *Server) handleCardPolicy(w http.ResponseWriter, r *http.Request, iccid
|
||||
}
|
||||
policy.CustomPhoneNumber = phoneNumber
|
||||
}
|
||||
if request.CellularIMSEnabled != nil {
|
||||
policy.CellularIMSEnabled = *request.CellularIMSEnabled
|
||||
policy.CellularIMSManaged = true
|
||||
}
|
||||
if request.VoWiFiEnabled != nil {
|
||||
policy.VoWiFiEnabled = *request.VoWiFiEnabled
|
||||
}
|
||||
@@ -1499,11 +1505,12 @@ func (s *Server) handleCardPolicy(w http.ResponseWriter, r *http.Request, iccid
|
||||
|
||||
func defaultCardPolicy(iccid string) store.CardPolicy {
|
||||
return store.CardPolicy{
|
||||
ICCID: strings.TrimSpace(iccid),
|
||||
VoWiFiEnabled: true,
|
||||
AirplaneEnabled: true,
|
||||
IPVersion: "IPV4V6",
|
||||
Source: "default",
|
||||
ICCID: strings.TrimSpace(iccid),
|
||||
VoWiFiEnabled: true,
|
||||
AirplaneEnabled: true,
|
||||
IPVersion: "IPV4V6",
|
||||
Source: "default",
|
||||
CellularIMSManaged: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1790,14 +1797,16 @@ func normalizeCustomPhoneNumber(value string) (string, error) {
|
||||
|
||||
func cardPolicyResponse(policy store.CardPolicy) map[string]any {
|
||||
response := map[string]any{
|
||||
"iccid": policy.ICCID,
|
||||
"network_enabled": false,
|
||||
"vowifi_enabled": policy.VoWiFiEnabled,
|
||||
"airplane_enabled": policy.AirplaneEnabled,
|
||||
"apn": policy.APN,
|
||||
"ip_version": policy.IPVersion,
|
||||
"custom_phone_number": policy.CustomPhoneNumber,
|
||||
"source": policy.Source,
|
||||
"iccid": policy.ICCID,
|
||||
"network_enabled": false,
|
||||
"vowifi_enabled": policy.VoWiFiEnabled,
|
||||
"airplane_enabled": policy.AirplaneEnabled,
|
||||
"apn": policy.APN,
|
||||
"ip_version": policy.IPVersion,
|
||||
"custom_phone_number": policy.CustomPhoneNumber,
|
||||
"cellular_ims_enabled": policy.CellularIMSEnabled,
|
||||
"cellular_ims_managed": policy.CellularIMSManaged,
|
||||
"source": policy.Source,
|
||||
}
|
||||
if !policy.CreatedAt.IsZero() {
|
||||
response["created_at"] = policy.CreatedAt
|
||||
|
||||
@@ -668,7 +668,8 @@ func TestCardPolicyDefaultValidationAndPersistence(t *testing.T) {
|
||||
policy := response["data"].(map[string]any)
|
||||
if policy["iccid"] != iccid || policy["source"] != "default" ||
|
||||
policy["ip_version"] != "IPV4V6" || policy["vowifi_enabled"] != true ||
|
||||
policy["airplane_enabled"] != true || policy["custom_phone_number"] != "" {
|
||||
policy["airplane_enabled"] != true || policy["custom_phone_number"] != "" ||
|
||||
policy["cellular_ims_enabled"] != false || policy["cellular_ims_managed"] != true {
|
||||
t.Fatalf("default policy = %#v", policy)
|
||||
}
|
||||
|
||||
@@ -681,6 +682,14 @@ func TestCardPolicyDefaultValidationAndPersistence(t *testing.T) {
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("custom phone policy status = %d, body = %s", recorder.Code, recorder.Body)
|
||||
}
|
||||
recorder = test.request(t, http.MethodPut, "/api/cards/"+iccid+"/policy", `{"cellular_ims_enabled":true}`)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("cellular IMS policy status = %d, body = %s", recorder.Code, recorder.Body)
|
||||
}
|
||||
storedIMS, err := test.database.CardPolicy(context.Background(), iccid)
|
||||
if err != nil || !storedIMS.CellularIMSEnabled || !storedIMS.CellularIMSManaged {
|
||||
t.Fatalf("stored cellular IMS policy = %+v, %v", storedIMS, err)
|
||||
}
|
||||
response = decodeSettingsResponse(t, recorder)
|
||||
policy = response["data"].(map[string]any)
|
||||
if policy["custom_phone_number"] != "+8613800138000" {
|
||||
|
||||
@@ -279,8 +279,8 @@ func TestMigration19AcceptsDevelopmentDatabaseAndPreservesCardData(t *testing.T)
|
||||
if err := database.db.QueryRowContext(ctx, `PRAGMA user_version`).Scan(&version); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if version != 19 {
|
||||
t.Fatalf("schema version = %d, want 19", version)
|
||||
if version != schemaVersion {
|
||||
t.Fatalf("schema version = %d, want %d", version, schemaVersion)
|
||||
}
|
||||
for _, column := range []string{
|
||||
"ims_apn", "ims_private_identity", "ims_public_identity", "ims_sms_center",
|
||||
@@ -333,8 +333,8 @@ func TestMigration19AcceptsDevelopmentColumnsAlreadyPresent(t *testing.T) {
|
||||
if err := database.db.QueryRowContext(ctx, `PRAGMA user_version`).Scan(&version); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if version != 19 {
|
||||
t.Fatalf("schema version = %d, want 19", version)
|
||||
if version != schemaVersion {
|
||||
t.Fatalf("schema version = %d, want %d", version, schemaVersion)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -381,6 +381,15 @@ func migrationStatements(version int) []string {
|
||||
`ALTER TABLE devices ADD COLUMN vowifi_allow_sha1 INTEGER NOT NULL DEFAULT 0 CHECK (vowifi_allow_sha1 IN (0, 1))`,
|
||||
`ALTER TABLE devices ADD COLUMN vowifi_use_modp1024 INTEGER NOT NULL DEFAULT 0 CHECK (vowifi_use_modp1024 IN (0, 1))`,
|
||||
}
|
||||
case 20:
|
||||
return []string{
|
||||
`ALTER TABLE card_policies
|
||||
ADD COLUMN cellular_ims_enabled INTEGER NOT NULL DEFAULT 0
|
||||
CHECK (cellular_ims_enabled IN (0, 1))`,
|
||||
`ALTER TABLE card_policies
|
||||
ADD COLUMN cellular_ims_managed INTEGER NOT NULL DEFAULT 0
|
||||
CHECK (cellular_ims_managed IN (0, 1))`,
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
+12
-10
@@ -476,16 +476,18 @@ type LogFilter struct {
|
||||
}
|
||||
|
||||
type CardPolicy struct {
|
||||
ICCID string
|
||||
NetworkEnabled bool
|
||||
VoWiFiEnabled bool
|
||||
AirplaneEnabled bool
|
||||
APN string
|
||||
IPVersion string
|
||||
CustomPhoneNumber string
|
||||
Source string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ICCID string
|
||||
NetworkEnabled bool
|
||||
VoWiFiEnabled bool
|
||||
AirplaneEnabled bool
|
||||
APN string
|
||||
IPVersion string
|
||||
CustomPhoneNumber string
|
||||
CellularIMSEnabled bool
|
||||
CellularIMSManaged bool
|
||||
Source string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type CardAPNProfile struct {
|
||||
|
||||
@@ -386,8 +386,9 @@ func (s *Store) UpsertCardPolicy(ctx context.Context, value CardPolicy) error {
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO card_policies (
|
||||
iccid, network_enabled, vowifi_enabled, airplane_enabled,
|
||||
apn, ip_version, custom_phone_number, source, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
apn, ip_version, custom_phone_number, cellular_ims_enabled, cellular_ims_managed,
|
||||
source, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(iccid) DO UPDATE SET
|
||||
network_enabled = excluded.network_enabled,
|
||||
vowifi_enabled = excluded.vowifi_enabled,
|
||||
@@ -395,12 +396,15 @@ func (s *Store) UpsertCardPolicy(ctx context.Context, value CardPolicy) error {
|
||||
apn = excluded.apn,
|
||||
ip_version = excluded.ip_version,
|
||||
custom_phone_number = excluded.custom_phone_number,
|
||||
cellular_ims_enabled = excluded.cellular_ims_enabled,
|
||||
cellular_ims_managed = excluded.cellular_ims_managed,
|
||||
source = excluded.source,
|
||||
updated_at = excluded.updated_at
|
||||
`,
|
||||
value.ICCID, boolInt(value.NetworkEnabled), boolInt(value.VoWiFiEnabled),
|
||||
boolInt(value.AirplaneEnabled), value.APN, value.IPVersion,
|
||||
value.CustomPhoneNumber, value.Source, createdAt.Unix(), updatedAt.Unix(),
|
||||
value.CustomPhoneNumber, boolInt(value.CellularIMSEnabled), boolInt(value.CellularIMSManaged), value.Source,
|
||||
createdAt.Unix(), updatedAt.Unix(),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert card policy %q: %w", value.ICCID, err)
|
||||
@@ -446,16 +450,18 @@ func (s *Store) DeleteCardPolicy(ctx context.Context, iccid string) error {
|
||||
|
||||
const cardPolicySelect = `
|
||||
SELECT iccid, network_enabled, vowifi_enabled, airplane_enabled,
|
||||
apn, ip_version, custom_phone_number, source, created_at, updated_at
|
||||
apn, ip_version, custom_phone_number, cellular_ims_enabled, cellular_ims_managed,
|
||||
source, created_at, updated_at
|
||||
FROM card_policies`
|
||||
|
||||
func cardPolicy(row rowScanner) (CardPolicy, error) {
|
||||
var value CardPolicy
|
||||
var networkEnabled, vowifiEnabled, airplaneEnabled int
|
||||
var networkEnabled, vowifiEnabled, airplaneEnabled, cellularIMSEnabled, cellularIMSManaged int
|
||||
var createdAt, updatedAt int64
|
||||
err := row.Scan(
|
||||
&value.ICCID, &networkEnabled, &vowifiEnabled, &airplaneEnabled,
|
||||
&value.APN, &value.IPVersion, &value.CustomPhoneNumber, &value.Source, &createdAt, &updatedAt,
|
||||
&value.APN, &value.IPVersion, &value.CustomPhoneNumber, &cellularIMSEnabled, &cellularIMSManaged,
|
||||
&value.Source, &createdAt, &updatedAt,
|
||||
)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return CardPolicy{}, ErrNotFound
|
||||
@@ -466,6 +472,8 @@ func cardPolicy(row rowScanner) (CardPolicy, error) {
|
||||
value.NetworkEnabled = networkEnabled != 0
|
||||
value.VoWiFiEnabled = vowifiEnabled != 0
|
||||
value.AirplaneEnabled = airplaneEnabled != 0
|
||||
value.CellularIMSEnabled = cellularIMSEnabled != 0
|
||||
value.CellularIMSManaged = cellularIMSManaged != 0
|
||||
value.CreatedAt = time.Unix(createdAt, 0).UTC()
|
||||
value.UpdatedAt = time.Unix(updatedAt, 0).UTC()
|
||||
return value, nil
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
const schemaVersion = 19
|
||||
const schemaVersion = 20
|
||||
|
||||
var ErrNotFound = errors.New("store: not found")
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { CardUiRegular } from "@fluentui/react-icons";
|
||||
import { Button, Input, Tag, message } from "../ui";
|
||||
import { PolicySwitchCard } from "./PolicySwitchCard";
|
||||
import { CardPolicyAPN } from "./CardPolicyAPN";
|
||||
import { CellularIMSPolicyCard } from "./CellularIMSPolicyCard";
|
||||
import { useCardPolicyToggles } from "./useCardPolicyToggles";
|
||||
import { enableVoWiFi, disableVoWiFi, setFlightMode, updateCardPolicy } from "./deviceActions";
|
||||
import type { CardPolicy } from "../../types";
|
||||
@@ -141,6 +142,17 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
|
||||
failed={toggles.airplaneFailed}
|
||||
onToggle={toggles.onAirplaneToggle}
|
||||
/> : null}
|
||||
{!wifiCallingOnly ? <CellularIMSPolicyCard
|
||||
deviceId={deviceId}
|
||||
iccid={iccid}
|
||||
enabled={currentPolicy?.cellularImsEnabled ?? false}
|
||||
managed={currentPolicy?.cellularImsManaged ?? false}
|
||||
live={operable}
|
||||
deviceOnline={deviceOnline}
|
||||
vowifiEnabled={local.vowifiEnabled}
|
||||
airplaneEnabled={local.airplaneEnabled}
|
||||
onChanged={onPolicyChanged}
|
||||
/> : null}
|
||||
</div>
|
||||
{!wifiCallingOnly ? <CardPolicyAPN
|
||||
deviceId={deviceId}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { apiMessage } from "../../api";
|
||||
import { useI18n } from "../../lib/i18n";
|
||||
import { confirmDialog, message } from "../ui";
|
||||
import { PolicySwitchCard } from "./PolicySwitchCard";
|
||||
import { getCellularIMS, setCellularIMS, updateCardPolicy } from "./deviceActions";
|
||||
|
||||
interface CellularIMSPolicyCardProps {
|
||||
deviceId: string;
|
||||
iccid: string;
|
||||
enabled: boolean;
|
||||
managed: boolean;
|
||||
live: boolean;
|
||||
deviceOnline: boolean;
|
||||
vowifiEnabled: boolean;
|
||||
airplaneEnabled: boolean;
|
||||
compact?: boolean;
|
||||
onChanged: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function CellularIMSPolicyCard({ deviceId, iccid, enabled, managed, live, deviceOnline, vowifiEnabled, airplaneEnabled, compact, onChanged }: CellularIMSPolicyCardProps) {
|
||||
const { t } = useI18n();
|
||||
const [local, setLocal] = useState(enabled);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [status, setStatus] = useState<Awaited<ReturnType<typeof getCellularIMS>> | null>(null);
|
||||
|
||||
useEffect(() => setLocal(enabled), [enabled, iccid]);
|
||||
|
||||
const loadStatus = useCallback(async () => {
|
||||
if (!live) {
|
||||
setStatus(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const status = await getCellularIMS(deviceId);
|
||||
setStatus(status);
|
||||
} catch {
|
||||
setStatus(null);
|
||||
}
|
||||
}, [deviceId, live]);
|
||||
|
||||
useEffect(() => { void loadStatus(); }, [loadStatus]);
|
||||
|
||||
const toggle = async (value: boolean) => {
|
||||
if (pending || !deviceOnline) return;
|
||||
const confirmed = await confirmDialog(
|
||||
<div className="space-y-2">
|
||||
{value && status?.registered ? (
|
||||
<p>{t("当前蜂窝 IMS 已正常注册,通常无需强制启用;继续操作仍会改为强制模式。")}</p>
|
||||
) : null}
|
||||
{value && !status?.registered && status?.csKnown && status.csRegistered ? (
|
||||
<p>{t("当前已注册 CS 域,短信可能正在通过 CS 正常工作;强制 IMS 后能否注册取决于运营商、漫游网络和 MBN。")}</p>
|
||||
) : null}
|
||||
{value && live && !status ? (
|
||||
<p>{t("无法读取当前 CS/IMS 状态,继续后将直接尝试应用强制 IMS 配置。")}</p>
|
||||
) : null}
|
||||
{!value ? (
|
||||
<p>{t("关闭此开关只会恢复 MBN/运营商默认 IMS 行为,并不保证蜂窝 IMS 会被禁用。")}</p>
|
||||
) : null}
|
||||
{!managed ? <p>{t("确认后 VoCat 将开始按此 ICCID 管理蜂窝 IMS 配置,切换其他卡时不会沿用本卡策略。")}</p> : null}
|
||||
{vowifiEnabled ? (
|
||||
<p>{t("当前 VoWiFi 已开启且蜂窝射频关闭;此设置会保存,但蜂窝 IMS 需在关闭 VoWiFi并恢复蜂窝射频后才能注册。")}</p>
|
||||
) : airplaneEnabled ? (
|
||||
<p>{t("当前处于飞行模式;此设置会保存,但蜂窝 IMS 需在关闭飞行模式后才能注册。")}</p>
|
||||
) : null}
|
||||
{!live ? <p>{t("此卡当前未激活或设备离线,配置将在此卡激活并上线后应用。")}</p> : null}
|
||||
<p className="font-medium text-amber-700 dark:text-amber-300">
|
||||
{live
|
||||
? t("确认后将应用配置并重启模组,蜂窝数据、短信和通话可能短暂断联。")
|
||||
: t("此卡激活后应用配置时会重启模组,蜂窝数据、短信和通话可能短暂断联。")}
|
||||
</p>
|
||||
</div>,
|
||||
value ? t("确认强制启用蜂窝 IMS 短信") : t("确认恢复默认 IMS 行为"),
|
||||
{ confirmText: value ? t("强制启用") : t("恢复默认"), type: "warning" },
|
||||
);
|
||||
if (!confirmed) return;
|
||||
const previous = local;
|
||||
setLocal(value);
|
||||
setPending(true);
|
||||
setFailed(false);
|
||||
try {
|
||||
if (live) {
|
||||
const status = await setCellularIMS(deviceId, value);
|
||||
setStatus(status);
|
||||
if (status.rebooting) message.success(t("IMS 配置已保存,模组正在重启"));
|
||||
else if (!status.changed) message.success(t("状态已一致,已跳过重启流程"));
|
||||
} else {
|
||||
await updateCardPolicy(iccid, { cellularImsEnabled: value });
|
||||
message.success(t("IMS 配置已保存,将在此卡激活后生效"));
|
||||
}
|
||||
await onChanged();
|
||||
} catch (error) {
|
||||
setLocal(previous);
|
||||
setFailed(true);
|
||||
message.error(apiMessage(error) || t("蜂窝 IMS 配置失败"));
|
||||
await onChanged();
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return <PolicySwitchCard
|
||||
compact={compact}
|
||||
title={t("强制启用蜂窝 IMS 短信")}
|
||||
subtitle={compact ? undefined : t("解决部分运营商无法收发短信的问题;该策略根据当前 ICCID/Profile 保存")}
|
||||
tone="indigo"
|
||||
checked={local}
|
||||
disabled={pending || !deviceOnline}
|
||||
pending={pending}
|
||||
failed={failed}
|
||||
onToggle={(value) => void toggle(value)}
|
||||
/>;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { Button, Spinner } from "../ui";
|
||||
import { PolicySwitchCard } from "./PolicySwitchCard";
|
||||
import { CardPolicyAPN } from "./CardPolicyAPN";
|
||||
import { CellularIMSPolicyCard } from "./CellularIMSPolicyCard";
|
||||
import { useCardPolicyToggles } from "./useCardPolicyToggles";
|
||||
import { getCardPolicy, putCardPolicy, enableVoWiFi, disableVoWiFi, setFlightMode } from "./deviceActions";
|
||||
import type { CardPolicy } from "../../types";
|
||||
@@ -68,7 +69,7 @@ export function EsimCardPolicyInline({ deviceId, iccid, isActiveCard, deviceOnli
|
||||
) : (
|
||||
<>
|
||||
{noteText ? <div className="text-[11px] text-amber-600 dark:text-amber-400">{noteText}</div> : null}
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
<PolicySwitchCard
|
||||
compact
|
||||
title="VoWiFi"
|
||||
@@ -87,6 +88,18 @@ export function EsimCardPolicyInline({ deviceId, iccid, isActiveCard, deviceOnli
|
||||
failed={toggles.airplaneFailed}
|
||||
onToggle={toggles.onAirplaneToggle}
|
||||
/>
|
||||
<CellularIMSPolicyCard
|
||||
compact
|
||||
deviceId={deviceId}
|
||||
iccid={iccid}
|
||||
enabled={policy?.cellularImsEnabled ?? false}
|
||||
managed={policy?.cellularImsManaged ?? false}
|
||||
live={mode === "live"}
|
||||
deviceOnline={deviceOnline}
|
||||
vowifiEnabled={local.vowifiEnabled}
|
||||
airplaneEnabled={local.airplaneEnabled}
|
||||
onChanged={() => { void load(); onPolicyChanged(); }}
|
||||
/>
|
||||
</div>
|
||||
<CardPolicyAPN
|
||||
deviceId={deviceId}
|
||||
|
||||
@@ -28,6 +28,25 @@ export interface CardPolicyUpdate {
|
||||
apn?: string;
|
||||
ipVersion?: "IP" | "IPV6" | "IPV4V6";
|
||||
customPhoneNumber?: string;
|
||||
cellularImsEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface CellularIMSStatus {
|
||||
iccid: string;
|
||||
desiredEnabled: boolean;
|
||||
supported: boolean;
|
||||
configured: boolean;
|
||||
registered: boolean;
|
||||
csKnown: boolean;
|
||||
csRegistered: boolean;
|
||||
changed: boolean;
|
||||
rebooting: boolean;
|
||||
}
|
||||
export function getCellularIMS(deviceId: string) {
|
||||
return api<CellularIMSStatus>(`/devices/${deviceId}/cellular-ims`);
|
||||
}
|
||||
export function setCellularIMS(deviceId: string, enabled: boolean) {
|
||||
return api<CellularIMSStatus>(`/devices/${deviceId}/cellular-ims`, { method: "PATCH", body: { enabled } });
|
||||
}
|
||||
export function updateCardPolicy(iccid: string, body: CardPolicyUpdate) {
|
||||
return api<CardPolicy>(`/cards/${iccid}/policy`, { method: "PUT", body });
|
||||
|
||||
@@ -37,12 +37,28 @@ export function useCardPolicyToggles(source: PolicyFlags | null, impl: PolicyTog
|
||||
const localRef = useRef(local);
|
||||
localRef.current = local;
|
||||
|
||||
// CardPolicyPanel derives `source` inline, so its object identity changes on
|
||||
// every render. Depend on the primitive fields instead; otherwise this
|
||||
// effect updates local state forever and prevents route transitions from
|
||||
// committing after the card-policy tab has mounted.
|
||||
const sourceVoWiFiEnabled = source?.vowifiEnabled;
|
||||
const sourceAirplaneEnabled = source?.airplaneEnabled;
|
||||
|
||||
useEffect(() => {
|
||||
if (!source) return;
|
||||
setLocal({ vowifiEnabled: source.vowifiEnabled, airplaneEnabled: source.airplaneEnabled });
|
||||
if (sourceVoWiFiEnabled === undefined || sourceAirplaneEnabled === undefined) return;
|
||||
setLocal((current) => {
|
||||
if (
|
||||
current.vowifiEnabled === sourceVoWiFiEnabled &&
|
||||
current.airplaneEnabled === sourceAirplaneEnabled
|
||||
) return current;
|
||||
return {
|
||||
vowifiEnabled: sourceVoWiFiEnabled,
|
||||
airplaneEnabled: sourceAirplaneEnabled,
|
||||
};
|
||||
});
|
||||
setVowifiFailed(false);
|
||||
setAirplaneFailed(false);
|
||||
}, [source]);
|
||||
}, [sourceVoWiFiEnabled, sourceAirplaneEnabled]);
|
||||
|
||||
async function toggle(
|
||||
field: Field,
|
||||
|
||||
@@ -273,6 +273,8 @@ export interface CardPolicy {
|
||||
apn?: string;
|
||||
ipVersion?: string;
|
||||
customPhoneNumber?: string;
|
||||
cellularImsEnabled: boolean;
|
||||
cellularImsManaged: boolean;
|
||||
source?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
|
||||
Reference in New Issue
Block a user