mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-22 07:43:43 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9327f9a7cb |
+71
-21
@@ -496,26 +496,7 @@ func restoreConfiguredCellularData(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
networkRequest := device.NetworkRequest{
|
networkRequest := configuredCellularNetworkRequest(ctx, database, config, entry.Snapshot)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dataContext, cancel := context.WithTimeout(ctx, 60*time.Second)
|
dataContext, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||||
_, err = manager.SetNetwork(dataContext, entry.ID, networkRequest)
|
_, err = manager.SetNetwork(dataContext, entry.ID, networkRequest)
|
||||||
cancel()
|
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(
|
func disableAllDeveloperCellularData(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
logger *slog.Logger,
|
logger *slog.Logger,
|
||||||
@@ -1161,7 +1176,7 @@ func enforceDefaultSafeCardPolicy(
|
|||||||
}
|
}
|
||||||
if err := database.UpsertCardPolicy(ctx, store.CardPolicy{
|
if err := database.UpsertCardPolicy(ctx, store.CardPolicy{
|
||||||
ICCID: iccid, VoWiFiEnabled: true, AirplaneEnabled: true,
|
ICCID: iccid, VoWiFiEnabled: true, AirplaneEnabled: true,
|
||||||
IPVersion: "IPV4V6", Source: "default",
|
IPVersion: "IPV4V6", Source: "default", CellularIMSManaged: true,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
logger.Warn("default card policy: persist policy", "iccid", iccid, "error", err)
|
logger.Warn("default card policy: persist policy", "iccid", iccid, "error", err)
|
||||||
return
|
return
|
||||||
@@ -1195,6 +1210,9 @@ func reconcileCardPolicies(
|
|||||||
) {
|
) {
|
||||||
observedCards := make(map[string]string)
|
observedCards := make(map[string]string)
|
||||||
wifi410StartupNotBefore := time.Now().Add(wifi410VoWiFiStartupDelay)
|
wifi410StartupNotBefore := time.Now().Add(wifi410VoWiFiStartupDelay)
|
||||||
|
imsApplied := make(map[string]string)
|
||||||
|
imsRetryAfter := make(map[string]time.Time)
|
||||||
|
imsDataRestorePending := make(map[string]bool)
|
||||||
reconcile := func() {
|
reconcile := func() {
|
||||||
policies, policyListErr := database.ListCardPolicies(ctx)
|
policies, policyListErr := database.ListCardPolicies(ctx)
|
||||||
if policyListErr == nil {
|
if policyListErr == nil {
|
||||||
@@ -1241,6 +1259,38 @@ func reconcileCardPolicies(
|
|||||||
if policyErr != nil {
|
if policyErr != nil {
|
||||||
continue
|
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) {
|
if policy.VoWiFiEnabled && (!policy.AirplaneEnabled || policy.NetworkEnabled) {
|
||||||
policy.AirplaneEnabled = true
|
policy.AirplaneEnabled = true
|
||||||
policy.NetworkEnabled = false
|
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)
|
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 {
|
type deviceConfigPayload struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
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) {
|
if errors.Is(policyErr, store.ErrNotFound) {
|
||||||
policyErr = s.store.UpsertCardPolicy(r.Context(), store.CardPolicy{
|
policyErr = s.store.UpsertCardPolicy(r.Context(), store.CardPolicy{
|
||||||
ICCID: iccid, VoWiFiEnabled: true, AirplaneEnabled: true,
|
ICCID: iccid, VoWiFiEnabled: true, AirplaneEnabled: true,
|
||||||
IPVersion: "IPV4V6", Source: "default",
|
IPVersion: "IPV4V6", Source: "default", CellularIMSManaged: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if policyErr != nil {
|
if policyErr != nil {
|
||||||
@@ -614,6 +619,11 @@ func (s *Server) handleDevicePath(
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return s.handleAPNProfiles(w, r, physicalID)
|
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":
|
case "network/public-ip":
|
||||||
if !s.requirePhysicalDevice(w, physicalPresent) {
|
if !s.requirePhysicalDevice(w, physicalPresent) {
|
||||||
return true
|
return true
|
||||||
@@ -1389,6 +1399,164 @@ func (s *Server) handleAPNProfiles(w http.ResponseWriter, r *http.Request, physi
|
|||||||
return true
|
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(
|
func (s *Server) handleCellularData(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
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 {
|
type esimAIDCaptureController struct {
|
||||||
fakeDeviceController
|
fakeDeviceController
|
||||||
switchAID string
|
switchAID string
|
||||||
|
|||||||
@@ -1410,13 +1410,15 @@ func (s *Server) handleCardPolicy(w http.ResponseWriter, r *http.Request, iccid
|
|||||||
APN *string `json:"apn"`
|
APN *string `json:"apn"`
|
||||||
IPVersion *string `json:"ip_version"`
|
IPVersion *string `json:"ip_version"`
|
||||||
CustomPhoneNumber *string `json:"custom_phone_number"`
|
CustomPhoneNumber *string `json:"custom_phone_number"`
|
||||||
|
CellularIMSEnabled *bool `json:"cellular_ims_enabled"`
|
||||||
}
|
}
|
||||||
if err := s.decodeJSON(w, r, &request); err != nil {
|
if err := s.decodeJSON(w, r, &request); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if request.VoWiFiEnabled == nil && request.AirplaneEnabled == nil &&
|
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(
|
writeError(
|
||||||
w,
|
w,
|
||||||
http.StatusBadRequest,
|
http.StatusBadRequest,
|
||||||
@@ -1464,6 +1466,10 @@ func (s *Server) handleCardPolicy(w http.ResponseWriter, r *http.Request, iccid
|
|||||||
}
|
}
|
||||||
policy.CustomPhoneNumber = phoneNumber
|
policy.CustomPhoneNumber = phoneNumber
|
||||||
}
|
}
|
||||||
|
if request.CellularIMSEnabled != nil {
|
||||||
|
policy.CellularIMSEnabled = *request.CellularIMSEnabled
|
||||||
|
policy.CellularIMSManaged = true
|
||||||
|
}
|
||||||
if request.VoWiFiEnabled != nil {
|
if request.VoWiFiEnabled != nil {
|
||||||
policy.VoWiFiEnabled = *request.VoWiFiEnabled
|
policy.VoWiFiEnabled = *request.VoWiFiEnabled
|
||||||
}
|
}
|
||||||
@@ -1504,6 +1510,7 @@ func defaultCardPolicy(iccid string) store.CardPolicy {
|
|||||||
AirplaneEnabled: true,
|
AirplaneEnabled: true,
|
||||||
IPVersion: "IPV4V6",
|
IPVersion: "IPV4V6",
|
||||||
Source: "default",
|
Source: "default",
|
||||||
|
CellularIMSManaged: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1797,6 +1804,8 @@ func cardPolicyResponse(policy store.CardPolicy) map[string]any {
|
|||||||
"apn": policy.APN,
|
"apn": policy.APN,
|
||||||
"ip_version": policy.IPVersion,
|
"ip_version": policy.IPVersion,
|
||||||
"custom_phone_number": policy.CustomPhoneNumber,
|
"custom_phone_number": policy.CustomPhoneNumber,
|
||||||
|
"cellular_ims_enabled": policy.CellularIMSEnabled,
|
||||||
|
"cellular_ims_managed": policy.CellularIMSManaged,
|
||||||
"source": policy.Source,
|
"source": policy.Source,
|
||||||
}
|
}
|
||||||
if !policy.CreatedAt.IsZero() {
|
if !policy.CreatedAt.IsZero() {
|
||||||
|
|||||||
@@ -668,7 +668,8 @@ func TestCardPolicyDefaultValidationAndPersistence(t *testing.T) {
|
|||||||
policy := response["data"].(map[string]any)
|
policy := response["data"].(map[string]any)
|
||||||
if policy["iccid"] != iccid || policy["source"] != "default" ||
|
if policy["iccid"] != iccid || policy["source"] != "default" ||
|
||||||
policy["ip_version"] != "IPV4V6" || policy["vowifi_enabled"] != true ||
|
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)
|
t.Fatalf("default policy = %#v", policy)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -681,6 +682,14 @@ func TestCardPolicyDefaultValidationAndPersistence(t *testing.T) {
|
|||||||
if recorder.Code != http.StatusOK {
|
if recorder.Code != http.StatusOK {
|
||||||
t.Fatalf("custom phone policy status = %d, body = %s", recorder.Code, recorder.Body)
|
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)
|
response = decodeSettingsResponse(t, recorder)
|
||||||
policy = response["data"].(map[string]any)
|
policy = response["data"].(map[string]any)
|
||||||
if policy["custom_phone_number"] != "+8613800138000" {
|
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 {
|
if err := database.db.QueryRowContext(ctx, `PRAGMA user_version`).Scan(&version); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if version != 19 {
|
if version != schemaVersion {
|
||||||
t.Fatalf("schema version = %d, want 19", version)
|
t.Fatalf("schema version = %d, want %d", version, schemaVersion)
|
||||||
}
|
}
|
||||||
for _, column := range []string{
|
for _, column := range []string{
|
||||||
"ims_apn", "ims_private_identity", "ims_public_identity", "ims_sms_center",
|
"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 {
|
if err := database.db.QueryRowContext(ctx, `PRAGMA user_version`).Scan(&version); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if version != 19 {
|
if version != schemaVersion {
|
||||||
t.Fatalf("schema version = %d, want 19", version)
|
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_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))`,
|
`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:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -483,6 +483,8 @@ type CardPolicy struct {
|
|||||||
APN string
|
APN string
|
||||||
IPVersion string
|
IPVersion string
|
||||||
CustomPhoneNumber string
|
CustomPhoneNumber string
|
||||||
|
CellularIMSEnabled bool
|
||||||
|
CellularIMSManaged bool
|
||||||
Source string
|
Source string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
|
|||||||
@@ -386,8 +386,9 @@ func (s *Store) UpsertCardPolicy(ctx context.Context, value CardPolicy) error {
|
|||||||
_, err := s.db.ExecContext(ctx, `
|
_, err := s.db.ExecContext(ctx, `
|
||||||
INSERT INTO card_policies (
|
INSERT INTO card_policies (
|
||||||
iccid, network_enabled, vowifi_enabled, airplane_enabled,
|
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,
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
source, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(iccid) DO UPDATE SET
|
ON CONFLICT(iccid) DO UPDATE SET
|
||||||
network_enabled = excluded.network_enabled,
|
network_enabled = excluded.network_enabled,
|
||||||
vowifi_enabled = excluded.vowifi_enabled,
|
vowifi_enabled = excluded.vowifi_enabled,
|
||||||
@@ -395,12 +396,15 @@ func (s *Store) UpsertCardPolicy(ctx context.Context, value CardPolicy) error {
|
|||||||
apn = excluded.apn,
|
apn = excluded.apn,
|
||||||
ip_version = excluded.ip_version,
|
ip_version = excluded.ip_version,
|
||||||
custom_phone_number = excluded.custom_phone_number,
|
custom_phone_number = excluded.custom_phone_number,
|
||||||
|
cellular_ims_enabled = excluded.cellular_ims_enabled,
|
||||||
|
cellular_ims_managed = excluded.cellular_ims_managed,
|
||||||
source = excluded.source,
|
source = excluded.source,
|
||||||
updated_at = excluded.updated_at
|
updated_at = excluded.updated_at
|
||||||
`,
|
`,
|
||||||
value.ICCID, boolInt(value.NetworkEnabled), boolInt(value.VoWiFiEnabled),
|
value.ICCID, boolInt(value.NetworkEnabled), boolInt(value.VoWiFiEnabled),
|
||||||
boolInt(value.AirplaneEnabled), value.APN, value.IPVersion,
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("upsert card policy %q: %w", value.ICCID, err)
|
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 = `
|
const cardPolicySelect = `
|
||||||
SELECT iccid, network_enabled, vowifi_enabled, airplane_enabled,
|
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`
|
FROM card_policies`
|
||||||
|
|
||||||
func cardPolicy(row rowScanner) (CardPolicy, error) {
|
func cardPolicy(row rowScanner) (CardPolicy, error) {
|
||||||
var value CardPolicy
|
var value CardPolicy
|
||||||
var networkEnabled, vowifiEnabled, airplaneEnabled int
|
var networkEnabled, vowifiEnabled, airplaneEnabled, cellularIMSEnabled, cellularIMSManaged int
|
||||||
var createdAt, updatedAt int64
|
var createdAt, updatedAt int64
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&value.ICCID, &networkEnabled, &vowifiEnabled, &airplaneEnabled,
|
&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) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return CardPolicy{}, ErrNotFound
|
return CardPolicy{}, ErrNotFound
|
||||||
@@ -466,6 +472,8 @@ func cardPolicy(row rowScanner) (CardPolicy, error) {
|
|||||||
value.NetworkEnabled = networkEnabled != 0
|
value.NetworkEnabled = networkEnabled != 0
|
||||||
value.VoWiFiEnabled = vowifiEnabled != 0
|
value.VoWiFiEnabled = vowifiEnabled != 0
|
||||||
value.AirplaneEnabled = airplaneEnabled != 0
|
value.AirplaneEnabled = airplaneEnabled != 0
|
||||||
|
value.CellularIMSEnabled = cellularIMSEnabled != 0
|
||||||
|
value.CellularIMSManaged = cellularIMSManaged != 0
|
||||||
value.CreatedAt = time.Unix(createdAt, 0).UTC()
|
value.CreatedAt = time.Unix(createdAt, 0).UTC()
|
||||||
value.UpdatedAt = time.Unix(updatedAt, 0).UTC()
|
value.UpdatedAt = time.Unix(updatedAt, 0).UTC()
|
||||||
return value, nil
|
return value, nil
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
|
|
||||||
const schemaVersion = 19
|
const schemaVersion = 20
|
||||||
|
|
||||||
var ErrNotFound = errors.New("store: not found")
|
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 { Button, Input, Tag, message } from "../ui";
|
||||||
import { PolicySwitchCard } from "./PolicySwitchCard";
|
import { PolicySwitchCard } from "./PolicySwitchCard";
|
||||||
import { CardPolicyAPN } from "./CardPolicyAPN";
|
import { CardPolicyAPN } from "./CardPolicyAPN";
|
||||||
|
import { CellularIMSPolicyCard } from "./CellularIMSPolicyCard";
|
||||||
import { useCardPolicyToggles } from "./useCardPolicyToggles";
|
import { useCardPolicyToggles } from "./useCardPolicyToggles";
|
||||||
import { enableVoWiFi, disableVoWiFi, setFlightMode, updateCardPolicy } from "./deviceActions";
|
import { enableVoWiFi, disableVoWiFi, setFlightMode, updateCardPolicy } from "./deviceActions";
|
||||||
import type { CardPolicy } from "../../types";
|
import type { CardPolicy } from "../../types";
|
||||||
@@ -141,6 +142,17 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
|
|||||||
failed={toggles.airplaneFailed}
|
failed={toggles.airplaneFailed}
|
||||||
onToggle={toggles.onAirplaneToggle}
|
onToggle={toggles.onAirplaneToggle}
|
||||||
/> : null}
|
/> : 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>
|
</div>
|
||||||
{!wifiCallingOnly ? <CardPolicyAPN
|
{!wifiCallingOnly ? <CardPolicyAPN
|
||||||
deviceId={deviceId}
|
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 { Button, Spinner } from "../ui";
|
||||||
import { PolicySwitchCard } from "./PolicySwitchCard";
|
import { PolicySwitchCard } from "./PolicySwitchCard";
|
||||||
import { CardPolicyAPN } from "./CardPolicyAPN";
|
import { CardPolicyAPN } from "./CardPolicyAPN";
|
||||||
|
import { CellularIMSPolicyCard } from "./CellularIMSPolicyCard";
|
||||||
import { useCardPolicyToggles } from "./useCardPolicyToggles";
|
import { useCardPolicyToggles } from "./useCardPolicyToggles";
|
||||||
import { getCardPolicy, putCardPolicy, enableVoWiFi, disableVoWiFi, setFlightMode } from "./deviceActions";
|
import { getCardPolicy, putCardPolicy, enableVoWiFi, disableVoWiFi, setFlightMode } from "./deviceActions";
|
||||||
import type { CardPolicy } from "../../types";
|
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}
|
{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
|
<PolicySwitchCard
|
||||||
compact
|
compact
|
||||||
title="VoWiFi"
|
title="VoWiFi"
|
||||||
@@ -87,6 +88,18 @@ export function EsimCardPolicyInline({ deviceId, iccid, isActiveCard, deviceOnli
|
|||||||
failed={toggles.airplaneFailed}
|
failed={toggles.airplaneFailed}
|
||||||
onToggle={toggles.onAirplaneToggle}
|
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>
|
</div>
|
||||||
<CardPolicyAPN
|
<CardPolicyAPN
|
||||||
deviceId={deviceId}
|
deviceId={deviceId}
|
||||||
|
|||||||
@@ -28,6 +28,25 @@ export interface CardPolicyUpdate {
|
|||||||
apn?: string;
|
apn?: string;
|
||||||
ipVersion?: "IP" | "IPV6" | "IPV4V6";
|
ipVersion?: "IP" | "IPV6" | "IPV4V6";
|
||||||
customPhoneNumber?: string;
|
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) {
|
export function updateCardPolicy(iccid: string, body: CardPolicyUpdate) {
|
||||||
return api<CardPolicy>(`/cards/${iccid}/policy`, { method: "PUT", body });
|
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);
|
const localRef = useRef(local);
|
||||||
localRef.current = 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(() => {
|
useEffect(() => {
|
||||||
if (!source) return;
|
if (sourceVoWiFiEnabled === undefined || sourceAirplaneEnabled === undefined) return;
|
||||||
setLocal({ vowifiEnabled: source.vowifiEnabled, airplaneEnabled: source.airplaneEnabled });
|
setLocal((current) => {
|
||||||
|
if (
|
||||||
|
current.vowifiEnabled === sourceVoWiFiEnabled &&
|
||||||
|
current.airplaneEnabled === sourceAirplaneEnabled
|
||||||
|
) return current;
|
||||||
|
return {
|
||||||
|
vowifiEnabled: sourceVoWiFiEnabled,
|
||||||
|
airplaneEnabled: sourceAirplaneEnabled,
|
||||||
|
};
|
||||||
|
});
|
||||||
setVowifiFailed(false);
|
setVowifiFailed(false);
|
||||||
setAirplaneFailed(false);
|
setAirplaneFailed(false);
|
||||||
}, [source]);
|
}, [sourceVoWiFiEnabled, sourceAirplaneEnabled]);
|
||||||
|
|
||||||
async function toggle(
|
async function toggle(
|
||||||
field: Field,
|
field: Field,
|
||||||
|
|||||||
@@ -273,6 +273,8 @@ export interface CardPolicy {
|
|||||||
apn?: string;
|
apn?: string;
|
||||||
ipVersion?: string;
|
ipVersion?: string;
|
||||||
customPhoneNumber?: string;
|
customPhoneNumber?: string;
|
||||||
|
cellularImsEnabled: boolean;
|
||||||
|
cellularImsManaged: boolean;
|
||||||
source?: string;
|
source?: string;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user