feat: add per-card cellular IMS SMS policy

This commit is contained in:
geekouc
2026-08-22 00:16:23 +08:00
parent b2daa972d2
commit 9327f9a7cb
18 changed files with 781 additions and 67 deletions
+71 -21
View File
@@ -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
+158
View File
@@ -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
}
+80
View File
@@ -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)
}
+169 -1
View File
@@ -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
+28 -19
View File
@@ -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
+10 -1
View File
@@ -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" {
+4 -4
View File
@@ -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)
}
}
+9
View File
@@ -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
View File
@@ -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 {
+14 -6
View File
@@ -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
+1 -1
View File
@@ -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,
+2
View File
@@ -273,6 +273,8 @@ export interface CardPolicy {
apn?: string;
ipVersion?: string;
customPhoneNumber?: string;
cellularImsEnabled: boolean;
cellularImsManaged: boolean;
source?: string;
createdAt?: string;
updatedAt?: string;