From 4a39ed3d314e459ebfa08a92cfa3b783f935d80f Mon Sep 17 00:00:00 2001 From: jirong0214 <48742417+jirong0214@users.noreply.github.com> Date: Sat, 22 Aug 2026 02:37:35 +0800 Subject: [PATCH] feat: add per-card cellular IMS SMS policy (#77) Co-authored-by: geekouc --- cmd/vocat/main.go | 92 +++++++--- internal/device/cellular_ims.go | 158 ++++++++++++++++ internal/device/cellular_ims_test.go | 80 +++++++++ internal/server/device_api.go | 170 +++++++++++++++++- internal/server/device_features_api_test.go | 45 +++++ internal/server/settings_api.go | 47 +++-- internal/server/settings_api_test.go | 11 +- internal/store/domain_test.go | 8 +- internal/store/migrations.go | 9 + internal/store/models.go | 22 +-- internal/store/settings.go | 20 ++- internal/store/store.go | 2 +- .../components/devices/CardPolicyPanel.tsx | 12 ++ .../devices/CellularIMSPolicyCard.tsx | 114 ++++++++++++ .../devices/EsimCardPolicyInline.tsx | 15 +- web/src/components/devices/deviceActions.ts | 19 ++ .../devices/useCardPolicyToggles.ts | 22 ++- web/src/types.ts | 2 + 18 files changed, 781 insertions(+), 67 deletions(-) create mode 100644 internal/device/cellular_ims.go create mode 100644 internal/device/cellular_ims_test.go create mode 100644 web/src/components/devices/CellularIMSPolicyCard.tsx diff --git a/cmd/vocat/main.go b/cmd/vocat/main.go index a5350aa..c153c5b 100644 --- a/cmd/vocat/main.go +++ b/cmd/vocat/main.go @@ -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 diff --git a/internal/device/cellular_ims.go b/internal/device/cellular_ims.go new file mode 100644 index 0000000..749c296 --- /dev/null +++ b/internal/device/cellular_ims.go @@ -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 +} diff --git a/internal/device/cellular_ims_test.go b/internal/device/cellular_ims_test.go new file mode 100644 index 0000000..8ba884d --- /dev/null +++ b/internal/device/cellular_ims_test.go @@ -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) +} diff --git a/internal/server/device_api.go b/internal/server/device_api.go index 5fdb58c..57826d8 100644 --- a/internal/server/device_api.go +++ b/internal/server/device_api.go @@ -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, diff --git a/internal/server/device_features_api_test.go b/internal/server/device_features_api_test.go index e09dc0f..10d0569 100644 --- a/internal/server/device_features_api_test.go +++ b/internal/server/device_features_api_test.go @@ -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 diff --git a/internal/server/settings_api.go b/internal/server/settings_api.go index 32ef28d..c1638f3 100644 --- a/internal/server/settings_api.go +++ b/internal/server/settings_api.go @@ -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 diff --git a/internal/server/settings_api_test.go b/internal/server/settings_api_test.go index 0de5769..4557736 100644 --- a/internal/server/settings_api_test.go +++ b/internal/server/settings_api_test.go @@ -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" { diff --git a/internal/store/domain_test.go b/internal/store/domain_test.go index ed433f0..734f9ee 100644 --- a/internal/store/domain_test.go +++ b/internal/store/domain_test.go @@ -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) } } diff --git a/internal/store/migrations.go b/internal/store/migrations.go index f3c7222..5652961 100644 --- a/internal/store/migrations.go +++ b/internal/store/migrations.go @@ -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 } diff --git a/internal/store/models.go b/internal/store/models.go index 4297c0e..a6486d1 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -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 { diff --git a/internal/store/settings.go b/internal/store/settings.go index faf52b3..a1953d0 100644 --- a/internal/store/settings.go +++ b/internal/store/settings.go @@ -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 diff --git a/internal/store/store.go b/internal/store/store.go index 8ead87f..5e7b50b 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -14,7 +14,7 @@ import ( _ "modernc.org/sqlite" ) -const schemaVersion = 19 +const schemaVersion = 20 var ErrNotFound = errors.New("store: not found") diff --git a/web/src/components/devices/CardPolicyPanel.tsx b/web/src/components/devices/CardPolicyPanel.tsx index 363f96b..1817069 100644 --- a/web/src/components/devices/CardPolicyPanel.tsx +++ b/web/src/components/devices/CardPolicyPanel.tsx @@ -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 ? : null} {!wifiCallingOnly ? void | Promise; +} + +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> | 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( +
+ {value && status?.registered ? ( +

{t("当前蜂窝 IMS 已正常注册,通常无需强制启用;继续操作仍会改为强制模式。")}

+ ) : null} + {value && !status?.registered && status?.csKnown && status.csRegistered ? ( +

{t("当前已注册 CS 域,短信可能正在通过 CS 正常工作;强制 IMS 后能否注册取决于运营商、漫游网络和 MBN。")}

+ ) : null} + {value && live && !status ? ( +

{t("无法读取当前 CS/IMS 状态,继续后将直接尝试应用强制 IMS 配置。")}

+ ) : null} + {!value ? ( +

{t("关闭此开关只会恢复 MBN/运营商默认 IMS 行为,并不保证蜂窝 IMS 会被禁用。")}

+ ) : null} + {!managed ?

{t("确认后 VoCat 将开始按此 ICCID 管理蜂窝 IMS 配置,切换其他卡时不会沿用本卡策略。")}

: null} + {vowifiEnabled ? ( +

{t("当前 VoWiFi 已开启且蜂窝射频关闭;此设置会保存,但蜂窝 IMS 需在关闭 VoWiFi并恢复蜂窝射频后才能注册。")}

+ ) : airplaneEnabled ? ( +

{t("当前处于飞行模式;此设置会保存,但蜂窝 IMS 需在关闭飞行模式后才能注册。")}

+ ) : null} + {!live ?

{t("此卡当前未激活或设备离线,配置将在此卡激活并上线后应用。")}

: null} +

+ {live + ? t("确认后将应用配置并重启模组,蜂窝数据、短信和通话可能短暂断联。") + : t("此卡激活后应用配置时会重启模组,蜂窝数据、短信和通话可能短暂断联。")} +

+
, + 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 void toggle(value)} + />; +} diff --git a/web/src/components/devices/EsimCardPolicyInline.tsx b/web/src/components/devices/EsimCardPolicyInline.tsx index e7b7182..48ab715 100644 --- a/web/src/components/devices/EsimCardPolicyInline.tsx +++ b/web/src/components/devices/EsimCardPolicyInline.tsx @@ -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 ?
{noteText}
: null} -
+
+ { void load(); onPolicyChanged(); }} + />
(`/devices/${deviceId}/cellular-ims`); +} +export function setCellularIMS(deviceId: string, enabled: boolean) { + return api(`/devices/${deviceId}/cellular-ims`, { method: "PATCH", body: { enabled } }); } export function updateCardPolicy(iccid: string, body: CardPolicyUpdate) { return api(`/cards/${iccid}/policy`, { method: "PUT", body }); diff --git a/web/src/components/devices/useCardPolicyToggles.ts b/web/src/components/devices/useCardPolicyToggles.ts index ed8b233..309583d 100644 --- a/web/src/components/devices/useCardPolicyToggles.ts +++ b/web/src/components/devices/useCardPolicyToggles.ts @@ -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, diff --git a/web/src/types.ts b/web/src/types.ts index 616c7da..c717853 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -273,6 +273,8 @@ export interface CardPolicy { apn?: string; ipVersion?: string; customPhoneNumber?: string; + cellularImsEnabled: boolean; + cellularImsManaged: boolean; source?: string; createdAt?: string; updatedAt?: string;