Implement automatic task management with CRUD operations and UI integration

- Added `automatic_tasks.go` and `automatic_tasks_test.go` for backend logic and testing of automatic tasks.
- Created `automatic_tasks_test.go` to validate task claiming and deletion behavior.
- Developed `AutomaticTasksPage.tsx` for frontend management of automatic tasks, including task creation, editing, and execution.
- Integrated device and eSIM profile selection for task configuration.
- Implemented automatic task scheduling and retry logic in the backend.
This commit is contained in:
MengMengCode
2026-08-10 22:15:54 +08:00
parent 70191ce1e3
commit f4aaa3cfdd
66 changed files with 15338 additions and 462 deletions
+22
View File
@@ -42,3 +42,25 @@ func CarrierForPLMN(plmn string) (name, countryCode string, ok bool) {
}
return name, countryCode, true
}
// CarrierForIMSI resolves the home PLMN carried by an IMSI. MNCs may contain
// either two or three digits, so prefer an exact six-digit database match and
// then fall back to the five-digit form. This avoids treating the first three
// subscriber digits as a three-digit MNC for networks such as 234-33.
func CarrierForIMSI(imsi string) (plmn, name, countryCode string, ok bool) {
imsi = strings.TrimSpace(imsi)
if !decimalDigits(imsi, 5, 20) {
return "", "", "", false
}
for _, length := range []int{6, 5} {
if len(imsi) < length {
continue
}
candidate := imsi[:length]
carrier, country, found := CarrierForPLMN(candidate)
if found {
return candidate, carrier, country, true
}
}
return "", "", "", false
}
+15 -1
View File
@@ -43,7 +43,21 @@ func (manager *Manager) SetNetwork(
}
}
candidate := manager.candidateFor(state)
if candidate.QMIControl != "" && candidate.NetworkInterface != "" {
backend := strings.ToLower(strings.TrimSpace(request.Backend))
if backend == "" {
if candidate.QMIControl != "" && candidate.NetworkInterface != "" {
backend = "qmi"
} else {
backend = "at"
}
}
if backend != "at" && backend != "qmi" {
return NetworkResult{}, fmt.Errorf("unsupported cellular data backend %q", request.Backend)
}
if backend == "qmi" {
if candidate.QMIControl == "" || candidate.NetworkInterface == "" {
return NetworkResult{}, fmt.Errorf("%w: QMI control device and network interface are required", ErrDataBackendUnavailable)
}
return setQMINetwork(ctx, candidate, request.Enabled, apn, ipVersion)
}
+59 -23
View File
@@ -26,6 +26,12 @@ import (
// isdRAID is the standard ISD-R AID that hosts the LPA functions (ES10).
const isdRAID = "A0000005591010FFFFFFFF8900000100"
// xesimISDRAID is the alternate ISD-R application exposed by XeSIM cards.
// It implements the same ES10 interface, but is not selectable through the
// standard ...0100 AID. Selecting it is a read-only capability probe; profile
// state is never changed during discovery.
const xesimISDRAID = "A0000005591010FFFFFFFF8900000177"
// eSTK multi-SE products expose each eUICC storage through its own vendor
// ISD-R AID. The standard GSMA AID aliases one of them, so probing only that
// AID silently hides the second storage.
@@ -296,20 +302,32 @@ func (manager *Manager) openEuiccOnceAID(ctx context.Context, id, aidHex string)
return channel, nil
}
// discoverEuiccAIDs detects eSTK multi-SE cards without changing any profile
// state. The vendor product applet is selected only as a read-only capability
// probe; when present, both vendor ISD-R AIDs are tried. Per OpenEUICC's eSTK
// integration, the generic GSMA AID is not appended after an eSTK SE opens,
// because it aliases one of the same storages.
// discoverEuiccAIDs detects eSTK multi-SE and alternate-ISD-R cards without
// changing any profile state. The vendor product applet and candidate ISD-R
// applications are selected only as read-only capability probes. Per
// OpenEUICC's eSTK integration, generic AIDs are not appended after an eSTK SE
// opens, because the standard AID aliases one of the same storages.
func (manager *Manager) discoverEuiccAIDs(ctx context.Context, id string) []string {
product, err := manager.openEuiccAID(ctx, id, estkProductAID)
if err != nil {
return []string{isdRAID}
if err == nil {
product.close(context.Background())
var found []string
for _, aid := range []string{estkSE0AID, estkSE1AID} {
channel, err := manager.openEuiccAID(ctx, id, aid)
if err != nil {
continue
}
channel.close(context.Background())
found = append(found, aid)
}
if len(found) > 0 {
return found
}
}
product.close(context.Background())
var found []string
for _, aid := range []string{estkSE0AID, estkSE1AID} {
for _, aid := range []string{isdRAID, xesimISDRAID} {
channel, err := manager.openEuiccAID(ctx, id, aid)
if err != nil {
continue
@@ -317,10 +335,12 @@ func (manager *Manager) discoverEuiccAIDs(ctx context.Context, id string) []stri
channel.close(context.Background())
found = append(found, aid)
}
if len(found) == 0 {
return []string{isdRAID}
if len(found) > 0 {
return found
}
return found
// Preserve the old error path for a physical SIM with no eUICC. The caller
// retries the standard AID once and returns ErrNoEUICC to the HTTP layer.
return []string{isdRAID}
}
func isTransientEuiccCME(err error) bool {
@@ -531,18 +551,27 @@ func (manager *Manager) ESIMListProfiles(ctx context.Context, id string) (EsimIn
}
return EsimInfo{}, errESIMRecovering
}
channel, err := manager.openEuicc(ctx, id)
if err != nil {
return EsimInfo{}, err
var lastErr error
for _, aid := range manager.discoverEuiccAIDs(ctx, id) {
channel, err := manager.openEuiccAID(ctx, id, aid)
if err != nil {
lastErr = err
continue
}
payload, err := channel.es10(ctx, []byte{0xBF, 0x2D, 0x00}) // GetProfilesInfo
channel.close(context.Background())
if err != nil {
lastErr = err
continue
}
info := EsimInfo{AID: aid, Profiles: parseProfilesInfo(payload)}
manager.cacheESIMInfo(id, info)
return info, nil
}
defer channel.close(context.Background())
payload, err := channel.es10(ctx, []byte{0xBF, 0x2D, 0x00}) // GetProfilesInfo
if err != nil {
return EsimInfo{}, err
if lastErr != nil {
return EsimInfo{}, lastErr
}
info := EsimInfo{Profiles: parseProfilesInfo(payload)}
manager.cacheESIMInfo(id, info)
return info, nil
return EsimInfo{}, ErrNoEUICC
}
// ESIMSwitchProfile enables one profile by ICCID via ES10c EnableProfile.
@@ -774,7 +803,14 @@ func (manager *Manager) refreshAfterProfileSwitch(id string) {
time.Sleep(settle)
for attempt := 0; attempt < attempts; attempt++ {
ctx, cancel := context.WithTimeout(context.Background(), manager.commandTimeout*4)
_, err := manager.Refresh(ctx, id)
_, _ = manager.Discover(ctx)
_, flightErr := manager.SetFlight(ctx, id, true)
var err error
if flightErr == nil {
_, err = manager.Refresh(ctx, id)
} else {
err = flightErr
}
cancel()
if err == nil {
return
+18 -9
View File
@@ -217,17 +217,26 @@ type EsimChipInfo struct {
func (manager *Manager) ESIMChipInfo(ctx context.Context, id string) (*EsimChipInfo, error) {
manager.esimMu.Lock()
defer manager.esimMu.Unlock()
channel, err := manager.openEuicc(ctx, id)
if err != nil {
return nil, err
}
defer channel.close(context.Background())
info, err := readEsimChipInfo(ctx, channel, isdRAID)
if err != nil {
return nil, err
var lastErr error
for _, aid := range manager.discoverEuiccAIDs(ctx, id) {
channel, err := manager.openEuiccAID(ctx, id, aid)
if err != nil {
lastErr = err
continue
}
info, err := readEsimChipInfo(ctx, channel, aid)
channel.close(context.Background())
if err != nil {
lastErr = err
continue
}
return &info, nil
}
return &info, nil
if lastErr != nil {
return nil, lastErr
}
return nil, ErrNoEUICC
}
func readEsimChipInfo(ctx context.Context, channel *euiccChannel, aidHex string) (EsimChipInfo, error) {
+39
View File
@@ -244,6 +244,45 @@ func TestTransientEuiccCMEClassification(t *testing.T) {
}
}
func TestDiscoverEuiccAIDsFindsXeSIMAlternateISDR(t *testing.T) {
manageChannel := clientStep{
command: `AT+CSIM=10,"0070000001"`,
response: okResponse(`+CSIM: 6,"019000"`),
}
closeChannel := clientStep{
command: `AT+CSIM=10,"0070800100"`,
response: okResponse(`+CSIM: 4,"9000"`),
}
selectStep := func(aid, response string) clientStep {
return clientStep{
command: fmt.Sprintf(`AT+CSIM=42,"01A4040010%s"`, aid),
response: okResponse(fmt.Sprintf(`+CSIM: 4,"%s"`, response)),
}
}
client := &transcriptClient{steps: []clientStep{
// No eSTK product applet on this card.
manageChannel,
selectStep(estkProductAID, "6A82"),
closeChannel,
// XeSIM does not expose the standard GSMA ...0100 application.
manageChannel,
selectStep(isdRAID, "6A82"),
closeChannel,
// Its dedicated ...0177 ISD-R is selectable.
manageChannel,
selectStep(xesimISDRAID, "9000"),
closeChannel,
}}
manager, id := newStartedTestManager(t, client)
aids := manager.discoverEuiccAIDs(context.Background(), id)
if len(aids) != 1 || aids[0] != xesimISDRAID {
t.Fatalf("discovered AIDs = %#v, want XeSIM %s", aids, xesimISDRAID)
}
client.assertDone(t)
}
func TestEUICCChannelStuckWrapsTransientCME(t *testing.T) {
cause := &modem.CommandError{
Command: `AT+CSIM=10,"0070000001"`,
+31 -1
View File
@@ -50,6 +50,8 @@ type ussdSession struct {
type managedDevice struct {
opMu sync.Mutex
candidate modem.Candidate
backend string
lastICCID string
client modem.Client
snapshot *Snapshot
lastError string
@@ -358,16 +360,44 @@ func (manager *Manager) Refresh(ctx context.Context, id string) (Snapshot, error
return Snapshot{}, err
}
candidate := manager.candidateFor(state)
backend := manager.backendFor(state)
client, err := manager.clientLocked(ctx, state, candidate)
if err != nil {
manager.setResult(id, state, nil, err)
return Snapshot{}, err
}
snapshot, err := manager.readSnapshot(ctx, id, candidate, client)
previousICCID := state.lastICCID
snapshot, err := manager.readSnapshot(ctx, id, candidate, backend, previousICCID, client)
if err == nil && strings.TrimSpace(snapshot.ICCID) != "" {
state.lastICCID = strings.TrimSpace(snapshot.ICCID)
}
manager.setResult(id, state, &snapshot, err)
return snapshot, err
}
// SetBackend selects which control plane supplies registration and data state.
// AT remains available in either mode for UICC, RF, SMS, voice and diagnostics.
func (manager *Manager) SetBackend(id, backend string) error {
backend = strings.ToLower(strings.TrimSpace(backend))
if backend != "at" && backend != "qmi" {
return fmt.Errorf("unsupported device backend %q", backend)
}
manager.mu.Lock()
defer manager.mu.Unlock()
state := manager.devices[id]
if state == nil || !state.discovered {
return ErrNotFound
}
state.backend = backend
return nil
}
func (manager *Manager) backendFor(state *managedDevice) string {
manager.mu.RLock()
defer manager.mu.RUnlock()
return state.backend
}
func (manager *Manager) ExecuteAT(
ctx context.Context,
id string,
+72 -8
View File
@@ -19,6 +19,14 @@ func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) {
),
},
{command: "AT+CPIN?", response: okResponse("+CPIN: READY")},
{
command: "AT+CCID",
response: modem.Response{Final: "+CME ERROR: 100"},
err: errors.New("CCID unsupported"),
},
{command: "AT+QCCID", response: okResponse("+QCCID: 8986001234567890123F")},
{command: "AT+CIMI", response: okResponse("460001234567890")},
{command: "AT+CRSM=176,28486,0,0,17", response: okResponse(`+CRSM: 144,0,"00434D4343FFFFFFFFFFFFFFFFFFFFFFFF"`)},
{command: "AT+CSQ", response: okResponse("+CSQ: 20,99")},
{
command: `AT+QENG="servingcell"`,
@@ -29,13 +37,6 @@ func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) {
{command: "AT+COPS?", response: okResponse(`+COPS: 0,0,"China Mobile",7`)},
{command: "AT+CEREG?", response: okResponse(`+CEREG: 0,5`)},
{command: "AT+CGSN", response: okResponse("867123456789012")},
{
command: "AT+CCID",
response: modem.Response{Final: "+CME ERROR: 100"},
err: errors.New("CCID unsupported"),
},
{command: "AT+QCCID", response: okResponse("+QCCID: 8986001234567890123F")},
{command: "AT+CIMI", response: okResponse("460001234567890")},
{command: "AT+CFUN?", response: okResponse("+CFUN: 1")},
{command: "AT+CNUM", response: okResponse(`+CNUM: "","+8613800138000",145`)},
}}
@@ -74,7 +75,7 @@ func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) {
}
if snapshot.IMEI != "867123456789012" ||
snapshot.ICCID != "8986001234567890123" ||
snapshot.IMSI != "460001234567890" {
snapshot.IMSI != "460001234567890" || snapshot.SPN != "CMCC" {
t.Fatalf("subscriber identifiers = %#v", snapshot)
}
if !snapshot.ModeKnown || snapshot.OperatingMode != 1 ||
@@ -96,6 +97,18 @@ func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) {
client.assertDone(t)
}
func TestParseSPNASCIIAndUCS2(t *testing.T) {
if got := parseSPN(okResponse(`+CRSM: 144,0,"004C6562617261FFFFFFFFFFFFFFFFFFFF"`)); got != "Lebara" {
t.Fatalf("ASCII SPN = %q", got)
}
if got := parseSPN(okResponse(`+CRSM: 144,0,"0080004C00650062006100720061FFFF"`)); got != "Lebara" {
t.Fatalf("UCS2 SPN = %q", got)
}
if got := parseSPN(okResponse(`+CRSM: 106,130,""`)); got != "" {
t.Fatalf("failed CRSM SPN = %q", got)
}
}
func TestParseICCIDIdentifierStripsTwoFillerNibbles(t *testing.T) {
response := modem.Response{Lines: []string{"+CCID: 894921007608519523FF"}}
if got := parseICCIDIdentifier(response, []string{"+CCID:", "+QCCID:"}, 18, 22); got != "894921007608519523" {
@@ -122,6 +135,57 @@ func TestManagerRequiresStartAndKnownDevice(t *testing.T) {
}
}
func TestManagerBackendSelectionIsExplicit(t *testing.T) {
manager, id := newStartedTestManager(t, &transcriptClient{})
if err := manager.SetBackend(id, "qmi"); err != nil {
t.Fatal(err)
}
state, err := manager.lookup(id)
if err != nil {
t.Fatal(err)
}
if got := manager.backendFor(state); got != "qmi" {
t.Fatalf("backend = %q, want qmi", got)
}
if err := manager.SetBackend(id, "mbim"); err == nil {
t.Fatal("unsupported backend was accepted")
}
}
func TestManagerForcesRFOffBeforeInspectingChangedSIMNetwork(t *testing.T) {
client := &transcriptClient{steps: []clientStep{
{command: "ATI", response: okResponse("Quectel", "EC20", "Revision: test")},
{command: "AT+CPIN?", response: okResponse("+CPIN: READY")},
{command: "AT+CCID", response: okResponse("+CCID: 8900000000000000002")},
// This must precede CIMI, signal, serving-cell and operator queries.
{command: "AT+CFUN=4", response: okResponse()},
{command: "AT+CIMI", response: okResponse("234150000000002")},
{command: "AT+CRSM=176,28486,0,0,17", response: okResponse(`+CRSM: 144,0,"004C6562617261FFFFFFFFFFFFFFFFFFFF"`)},
{command: "AT+CSQ", response: okResponse("+CSQ: 99,99")},
{command: `AT+QENG="servingcell"`, response: okResponse(`+QENG: "servingcell","SEARCH"`)},
{command: "AT+COPS?", response: okResponse("+COPS: 0")},
{command: "AT+CEREG?", response: okResponse("+CEREG: 0,0")},
{command: "AT+CGSN", response: okResponse("867123456789012")},
{command: "AT+CFUN?", response: okResponse("+CFUN: 4")},
{command: "AT+CNUM", response: okResponse(`+CNUM: "","+447700900002",145`)},
}}
manager, id := newStartedTestManager(t, client)
state, err := manager.lookup(id)
if err != nil {
t.Fatal(err)
}
state.lastICCID = "8900000000000000001"
snapshot, err := manager.Refresh(context.Background(), id)
if err != nil {
t.Fatal(err)
}
if !snapshot.SIMChanged || !snapshot.FlightMode || snapshot.OperatingMode != 4 {
t.Fatalf("changed SIM snapshot = %#v", snapshot)
}
client.assertDone(t)
}
func TestExecuteSensitiveATDoesNotPersistCommandOrModemError(t *testing.T) {
const secretCommand = `AT+CSIM=78,"00880081221000112233445566778899AABBCCDDEEFF1000112233445566778899AABBCCDDEEFF00"`
client := &transcriptClient{steps: []clientStep{{
File diff suppressed because one or more lines are too long
+20
View File
@@ -40,6 +40,7 @@ func TestCarrierNameForPLMNUsesGlobalDatabase(t *testing.T) {
func TestCarrierForPLMNReturnsCountryCode(t *testing.T) {
tests := map[string]string{
"23415": "GB",
"23487": "GB",
"26202": "DE",
"310260": "US",
"22201": "IT",
@@ -53,3 +54,22 @@ func TestCarrierForPLMNReturnsCountryCode(t *testing.T) {
}
}
}
func TestCarrierForIMSIHandlesTwoAndThreeDigitMNCs(t *testing.T) {
tests := []struct {
imsi string
wantPLMN string
wantCountry string
}{
{imsi: "234336570710174", wantPLMN: "23433", wantCountry: "GB"},
{imsi: "234159609054263", wantPLMN: "23415", wantCountry: "GB"},
{imsi: "234870123456789", wantPLMN: "23487", wantCountry: "GB"},
{imsi: "310260123456789", wantPLMN: "310260", wantCountry: "US"},
}
for _, item := range tests {
plmn, name, country, ok := CarrierForIMSI(item.imsi)
if !ok || plmn != item.wantPLMN || name == "" || country != item.wantCountry {
t.Errorf("CarrierForIMSI(%q) = (%q, %q, %q, %v), want PLMN %q and country %q", item.imsi, plmn, name, country, ok, item.wantPLMN, item.wantCountry)
}
}
}
+91 -19
View File
@@ -3,12 +3,14 @@ package device
import (
"context"
"encoding/csv"
"encoding/hex"
"fmt"
"io"
"strconv"
"strings"
"time"
"unicode"
"unicode/utf16"
"vocat/internal/modem"
)
@@ -17,6 +19,8 @@ func (manager *Manager) readSnapshot(
ctx context.Context,
id string,
candidate modem.Candidate,
backend string,
previousICCID string,
client modem.Client,
) (Snapshot, error) {
snapshot := Snapshot{
@@ -47,6 +51,36 @@ func (manager *Manager) readSnapshot(
if response, ok := optional("AT+CPIN?"); ok {
snapshot.SIMStatus, snapshot.SIMReady = parseCPIN(response)
}
ccid, ccidErr := manager.command(ctx, client, "AT+CCID")
if ccidErr != nil {
ccid, ccidErr = manager.command(ctx, client, "AT+QCCID")
}
if ccidErr != nil {
snapshot.Warnings = append(snapshot.Warnings, "read ICCID: "+ccidErr.Error())
} else {
snapshot.ICCID = parseICCIDIdentifier(ccid, []string{"+CCID:", "+QCCID:"}, 18, 22)
}
previousICCID = strings.TrimSpace(previousICCID)
if previousICCID != "" && snapshot.ICCID != "" && !strings.EqualFold(previousICCID, snapshot.ICCID) {
// A different physical SIM must never inherit the previous card's
// permission to use cellular RF. Disable RF before reading serving-cell
// or operator state; policy reconciliation will then start VoWiFi.
if _, err := manager.command(ctx, client, "AT+CFUN=4"); err != nil {
return snapshot, fmt.Errorf("protect changed SIM with RF off: %w", err)
}
snapshot.SIMChanged = true
}
if response, ok := optional("AT+CIMI"); ok {
snapshot.IMSI = parseIdentifier(response, []string{"+CIMI:"}, 10, 18)
}
// EF_SPN is the SIM-issued brand (for example "Lebara"), which is distinct
// from the IMSI sponsor/core PLMN. A Lebara UK subscription may therefore
// legitimately carry a Vodafone NL IMSI while still presenting Lebara as
// its customer-facing operator. Failure is intentionally silent because
// EF_SPN is optional and some physical SIMs deny CRSM access to it.
if response, spnErr := manager.command(ctx, client, "AT+CRSM=176,28486,0,0,17"); spnErr == nil {
snapshot.SPN = parseSPN(response)
}
if response, ok := optional("AT+CSQ"); ok {
snapshot.SignalRaw, snapshot.SignalPercent, snapshot.RSSIDBm = parseCSQ(response)
}
@@ -87,13 +121,16 @@ func (manager *Manager) readSnapshot(
break
}
}
if registration, found := readPlatformRegistration(ctx, candidate); found {
snapshot.RegistrationStatus = registration.Status
snapshot.RegistrationSource = "QMI NAS"
snapshot.PSAttached = registration.PSAttached
if registration.PLMN != "" {
snapshot.OperatorCode = registration.PLMN
snapshot.OperatorName = carrierNameForPLMN(registration.PLMN, registration.Name)
if strings.EqualFold(backend, "qmi") {
registration, found := readPlatformRegistration(ctx, candidate)
if found {
snapshot.RegistrationStatus = registration.Status
snapshot.RegistrationSource = "QMI NAS"
snapshot.PSAttached = registration.PSAttached
if registration.PLMN != "" {
snapshot.OperatorCode = registration.PLMN
snapshot.OperatorName = carrierNameForPLMN(registration.PLMN, registration.Name)
}
}
}
if snapshot.RegistrationSource == "" && (snapshot.OperatorName != "" || snapshot.OperatorCode != "") {
@@ -111,18 +148,6 @@ func (manager *Manager) readSnapshot(
)
}
ccid, ccidErr := manager.command(ctx, client, "AT+CCID")
if ccidErr != nil {
ccid, ccidErr = manager.command(ctx, client, "AT+QCCID")
}
if ccidErr != nil {
snapshot.Warnings = append(snapshot.Warnings, "read ICCID: "+ccidErr.Error())
} else {
snapshot.ICCID = parseICCIDIdentifier(ccid, []string{"+CCID:", "+QCCID:"}, 18, 22)
}
if response, ok := optional("AT+CIMI"); ok {
snapshot.IMSI = parseIdentifier(response, []string{"+CIMI:"}, 10, 18)
}
if response, ok := optional("AT+CFUN?"); ok {
if mode, found := parseCFUN(response); found {
snapshot.OperatingMode = mode
@@ -139,6 +164,53 @@ func (manager *Manager) readSnapshot(
return snapshot, nil
}
func parseSPN(response modem.Response) string {
value := valueAfterPrefix(response, "+CRSM:")
fields := csvValues(value)
if len(fields) < 3 {
return ""
}
sw1, sw1Err := strconv.Atoi(strings.TrimSpace(fields[0]))
sw2, sw2Err := strconv.Atoi(strings.TrimSpace(fields[1]))
if sw1Err != nil || sw2Err != nil || (sw1 != 0x90 && sw1 != 0x91 && sw1 != 0x9f) || sw2 < 0 || sw2 > 255 {
return ""
}
raw, err := hex.DecodeString(strings.Trim(strings.TrimSpace(fields[2]), `"`))
if err != nil || len(raw) < 2 {
return ""
}
alpha := raw[1:] // byte 0 is the display-condition bit field.
for len(alpha) > 0 && (alpha[len(alpha)-1] == 0xff || alpha[len(alpha)-1] == 0x00) {
alpha = alpha[:len(alpha)-1]
}
if len(alpha) == 0 {
return ""
}
if alpha[0] == 0x80 {
ucs2 := alpha[1:]
if len(ucs2)%2 != 0 {
ucs2 = ucs2[:len(ucs2)-1]
}
units := make([]uint16, 0, len(ucs2)/2)
for index := 0; index+1 < len(ucs2); index += 2 {
unit := uint16(ucs2[index])<<8 | uint16(ucs2[index+1])
if unit != 0xffff && unit != 0 {
units = append(units, unit)
}
}
return strings.TrimSpace(string(utf16.Decode(units)))
}
// EF_SPN uses the unpacked GSM default alphabet. Its printable Latin subset
// is byte-compatible with UTF-8/ASCII and covers operator brands in practice.
printable := make([]byte, 0, len(alpha))
for _, value := range alpha {
if value >= 0x20 && value <= 0x7e {
printable = append(printable, value)
}
}
return strings.TrimSpace(string(printable))
}
func parseRegistrationStatus(response modem.Response) (int, bool) {
for _, prefix := range []string{"+CEREG:", "+CGREG:", "+CREG:"} {
values := csvValues(valueAfterPrefix(response, prefix))
+3
View File
@@ -27,6 +27,7 @@ type NetworkRequest struct {
Enabled bool `json:"enabled"`
APN string `json:"apn"`
IPVersion string `json:"ipVersion"`
Backend string `json:"backend,omitempty"`
}
type NetworkResult struct {
@@ -81,6 +82,7 @@ type Snapshot struct {
Firmware string `json:"firmware"`
SIMStatus string `json:"simStatus"`
SIMReady bool `json:"simReady"`
SIMChanged bool `json:"simChanged,omitempty"`
SignalRaw *int `json:"signalRaw,omitempty"`
SignalPercent *int `json:"signalPercent,omitempty"`
RSSIDBm *int `json:"rssiDbm,omitempty"`
@@ -98,6 +100,7 @@ type Snapshot struct {
IMEI string `json:"imei"`
ICCID string `json:"iccid"`
IMSI string `json:"imsi"`
SPN string `json:"spn,omitempty"`
OperatingMode int `json:"operatingMode"`
ModeKnown bool `json:"modeKnown"`
FlightMode bool `json:"flightMode"`
+7 -1
View File
@@ -221,7 +221,13 @@ func readSerialAliases(root string) map[string]string {
func candidateID(productID, serialNumber, usbName string) string {
serialNumber = strings.TrimSpace(serialNumber)
if serialNumber != "" && !strings.EqualFold(serialNumber, "android") {
return "quectel-" + sanitizeID(serialNumber)
// A surprising number of EC20/EC25 carrier boards expose the same
// factory/default USB serial number. The device manager is keyed by this
// value, so using the serial alone silently collapsed two modems connected
// to the same hub into one entry. Include the physical USB topology in the
// discovery key; configured devices remain stable through ATMapper's
// USB-path/IMEI matching even when Linux renumbers ttyUSB nodes.
return "quectel-" + sanitizeID(serialNumber+"-"+usbName)
}
return "quectel-" + sanitizeID(productID+"-"+usbName)
}
+45
View File
@@ -171,6 +171,51 @@ func TestSysFSDiscoverySelectsATPortForSecondQMIUSBModem(t *testing.T) {
}
}
func TestSysFSDiscoveryDoesNotCollapseModemsWithSharedFactorySerial(t *testing.T) {
root := t.TempDir()
sysRoot := filepath.Join(root, "sys")
devRoot := filepath.Join(root, "dev")
usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices")
for index, item := range []struct {
usbName string
ttyBase int
}{
{usbName: "1-5.1", ttyBase: 0},
{usbName: "1-5.2", ttyBase: 4},
} {
mustWrite(t, filepath.Join(usbRoot, item.usbName, "idVendor"), "2c7c\n")
mustWrite(t, filepath.Join(usbRoot, item.usbName, "idProduct"), "0125\n")
mustWrite(t, filepath.Join(usbRoot, item.usbName, "serial"), "0123456789ABCDEF\n")
for number := 0; number < 4; number++ {
interfaceName := item.usbName + ":1." + strconv.Itoa(number)
tty := fmt.Sprintf("ttyUSB%d", item.ttyBase+number)
mustWrite(t, filepath.Join(usbRoot, interfaceName, "bInterfaceNumber"), fmt.Sprintf("%02x\n", number))
mustMkdir(t, filepath.Join(usbRoot, interfaceName, tty, "tty", tty))
}
mustMkdir(t, filepath.Join(usbRoot, item.usbName+":1.4", "usbmisc", fmt.Sprintf("cdc-wdm%d", index)))
}
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
if err != nil {
t.Fatalf("Discover: %v", err)
}
if len(candidates) != 2 {
t.Fatalf("got %d candidates, want 2", len(candidates))
}
if candidates[0].ID == candidates[1].ID {
t.Fatalf("shared factory serial collapsed discovery IDs to %q", candidates[0].ID)
}
for _, candidate := range candidates {
if candidate.SerialNumber != "0123456789ABCDEF" {
t.Fatalf("serial = %q", candidate.SerialNumber)
}
if candidate.ATPort.Role != PortRoleAT {
t.Fatalf("AT port = %#v", candidate.ATPort)
}
}
}
func TestSysFSDiscoveryIgnoresNonQuectelUSB(t *testing.T) {
root := t.TempDir()
usbRoot := filepath.Join(root, "sys", "bus", "usb", "devices")
@@ -0,0 +1,306 @@
package server
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"crypto/tls"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"net"
"net/http"
"net/mail"
"net/smtp"
"strconv"
"strings"
"time"
"vocat/internal/store"
)
type automaticTaskNotification struct {
Title string
Text string
Time time.Time
Task store.AutomaticTask
Run store.AutomaticTaskRun
}
func (s *Server) notifyAutomaticTask(ctx context.Context, task store.AutomaticTask, run store.AutomaticTaskRun) {
deviceLabel := task.DeviceID
if configured, err := s.store.Device(ctx, task.DeviceID); err == nil {
deviceLabel = firstNonEmpty(configured.Name, configured.ID)
}
status := "成功"
detail := firstNonEmpty(run.Output, "任务已完成")
if run.Status != "success" {
status = "失败"
detail = firstNonEmpty(run.Error, "未知错误")
}
taskType := map[string]string{"sms": "发送短信", "call": "拨打电话", "public_ip": "获取漫游公网 IP"}[task.TaskType]
environment := map[string]string{"vowifi": "VoWiFi", "cellular": "基站直连"}[task.Environment]
notification := automaticTaskNotification{
Title: "自动任务执行" + status,
Text: strings.Join([]string{
"自动任务执行" + status,
"任务 " + task.Name,
"设备 " + deviceLabel,
"类型 " + firstNonEmpty(taskType, task.TaskType),
"环境 " + firstNonEmpty(environment, task.Environment),
"时间 " + run.FinishedAt.Local().Format("2006-01-02 15:04:05"),
"结果 " + detail,
}, "\n"),
Time: run.FinishedAt, Task: task, Run: run,
}
for _, channel := range []string{"telegram", "bark", "email", "pushplus", "webhook"} {
setting, err := s.store.NotificationSetting(ctx, channel)
if errors.Is(err, store.ErrNotFound) || (err == nil && !setting.Enabled) {
continue
}
if err != nil {
s.logger.Warn("read automatic task notification setting", "channel", channel, "error", err)
continue
}
var config map[string]any
if err := json.Unmarshal(setting.Config, &config); err != nil {
s.logger.Warn("decode automatic task notification setting", "channel", channel, "error", err)
continue
}
if err := sendAutomaticTaskNotification(ctx, channel, config, notification); err != nil {
s.logger.Warn("send automatic task notification", "channel", channel, "task_id", task.ID, "error", err)
}
}
}
func sendAutomaticTaskNotification(ctx context.Context, channel string, config map[string]any, message automaticTaskNotification) error {
switch channel {
case "telegram":
return sendTelegramTextNotification(ctx, config, message.Text)
case "bark":
return sendBarkTextNotification(ctx, config, message.Title, message.Text)
case "email":
return sendEmailTextNotification(ctx, config, message.Title, message.Text)
case "pushplus":
return sendPushplusTextNotification(ctx, config, message.Title, message.Text)
case "webhook":
return sendAutomaticTaskWebhook(ctx, config, message)
default:
return fmt.Errorf("unsupported notification channel %q", channel)
}
}
func sendTelegramTextNotification(ctx context.Context, config map[string]any, text string) error {
token := configString(config, "bot_token")
parsed, err := validateTelegramAPIURL(ctx, configString(config, "base_url"), token, "sendMessage")
if err != nil {
return err
}
client, err := restrictedHTTPClient(ctx, 8*time.Second, configString(config, "proxy"))
if err != nil {
return err
}
payload, _ := json.Marshal(map[string]any{"chat_id": configString(config, "chat_id"), "text": text})
request, err := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), bytes.NewReader(payload))
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "vocat-automatic-task/1")
return performNotificationRequest(client, request, true)
}
func sendBarkTextNotification(ctx context.Context, config map[string]any, title, text string) error {
client, err := restrictedHTTPClient(ctx, 8*time.Second, "")
if err != nil {
return err
}
payload := map[string]any{"title": title, "body": text}
for _, field := range []string{"group", "icon", "level"} {
if value := configString(config, field); value != "" {
payload[field] = value
}
}
encoded, _ := json.Marshal(payload)
for _, destination := range configStrings(config, "urls") {
parsed, err := validateOutboundURL(ctx, destination, false)
if err != nil {
return err
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), bytes.NewReader(encoded))
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json; charset=utf-8")
request.Header.Set("User-Agent", "vocat-automatic-task/1")
if err := performNotificationRequest(client, request, false); err != nil {
return err
}
}
return nil
}
func sendPushplusTextNotification(ctx context.Context, config map[string]any, title, text string) error {
destination, err := validateOutboundURL(ctx, "https://www.pushplus.plus/send", true)
if err != nil {
return err
}
payload := map[string]any{"token": configString(config, "token"), "title": title, "content": text, "template": "txt", "timestamp": time.Now().UnixMilli()}
if topic := configString(config, "topic"); topic != "" {
payload["topic"] = topic
}
if channel := configString(config, "channel"); channel != "" {
payload["channel"] = channel
}
encoded, _ := json.Marshal(payload)
client, err := restrictedHTTPClient(ctx, 8*time.Second, "")
if err != nil {
return err
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, destination.String(), bytes.NewReader(encoded))
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json; charset=utf-8")
request.Header.Set("User-Agent", "vocat-automatic-task/1")
response, err := client.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
body, _ := io.ReadAll(io.LimitReader(response.Body, 64<<10))
var result struct {
Code int `json:"code"`
Msg string `json:"msg"`
}
if response.StatusCode < 200 || response.StatusCode >= 300 || json.Unmarshal(body, &result) != nil || result.Code != 200 {
return fmt.Errorf("%w: Pushplus HTTP %d code %d %s", errProviderRejected, response.StatusCode, result.Code, result.Msg)
}
return nil
}
func sendAutomaticTaskWebhook(ctx context.Context, config map[string]any, message automaticTaskNotification) error {
payload, _ := json.Marshal(map[string]any{
"event": "automatic_task.completed", "message": message.Text,
"timestamp": message.Time.UTC().Format(time.RFC3339), "task_id": message.Task.ID,
"task_name": message.Task.Name, "device_id": message.Task.DeviceID,
"task_type": message.Task.TaskType, "environment": message.Task.Environment,
"status": message.Run.Status, "attempts": message.Run.Attempts,
"output": message.Run.Output, "error": message.Run.Error,
})
client, err := restrictedHTTPClient(ctx, durationMilliseconds(configInt(config, "timeout_ms"), 5*time.Second), "")
if err != nil {
return err
}
for _, destination := range configStrings(config, "urls") {
parsed, err := validateOutboundURL(ctx, destination, false)
if err != nil {
return err
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), bytes.NewReader(payload))
if err != nil {
return err
}
for name, value := range configStringMap(config, "headers") {
request.Header.Set(name, value)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "vocat-automatic-task/1")
if secret := configString(config, "secret"); secret != "" {
signature := hmac.New(sha256.New, []byte(secret))
_, _ = signature.Write(payload)
request.Header.Set("X-vocat-Signature", "sha256="+hex.EncodeToString(signature.Sum(nil)))
}
if err := performNotificationRequest(client, request, false); err != nil {
return err
}
}
return nil
}
func sendEmailTextNotification(ctx context.Context, config map[string]any, subject, text string) error {
host := strings.TrimSpace(configString(config, "smtp_host"))
port := configInt(config, "smtp_port")
if port == 0 {
port = 587
}
timeout := 8 * time.Second
connection, err := dialRestricted(ctx, "tcp", net.JoinHostPort(host, strconv.Itoa(port)), timeout)
if err != nil {
return err
}
defer connection.Close()
if err := connection.SetDeadline(time.Now().Add(timeout)); err != nil {
return err
}
tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12, ServerName: host}
useSSL, _ := config["use_ssl"].(bool)
implicitTLS := port == 465 || useSSL
if implicitTLS {
secure := tls.Client(connection, tlsConfig)
if err := secure.HandshakeContext(ctx); err != nil {
return err
}
connection = secure
}
client, err := smtp.NewClient(connection, host)
if err != nil {
return err
}
defer client.Close()
if !implicitTLS {
if available, _ := client.Extension("STARTTLS"); !available {
return errors.New("SMTP server does not offer STARTTLS")
}
if err := client.StartTLS(tlsConfig); err != nil {
return err
}
}
username, password := configString(config, "username"), configString(config, "password")
if username != "" {
if err := client.Auth(smtp.PlainAuth("", username, password, host)); err != nil {
return err
}
}
from, err := mail.ParseAddress(configString(config, "from_address"))
if err != nil {
return err
}
var recipients []*mail.Address
for _, item := range configStrings(config, "to_addresses") {
address, err := mail.ParseAddress(item)
if err != nil {
return err
}
recipients = append(recipients, address)
}
if err := client.Mail(from.Address); err != nil {
return err
}
for _, recipient := range recipients {
if err := client.Rcpt(recipient.Address); err != nil {
return err
}
}
writer, err := client.Data()
if err != nil {
return err
}
email := strings.Join([]string{
"Date: " + time.Now().UTC().Format(time.RFC1123Z), "From: " + from.String(),
"To: " + joinMailAddresses(recipients), "Subject: " + mime.QEncoding.Encode("UTF-8", subject),
"MIME-Version: 1.0", "Content-Type: text/plain; charset=UTF-8", "Content-Transfer-Encoding: 8bit", "", text, "",
}, "\r\n")
if _, err := io.WriteString(writer, email); err != nil {
_ = writer.Close()
return err
}
if err := writer.Close(); err != nil {
return err
}
return client.Quit()
}
+651
View File
@@ -0,0 +1,651 @@
package server
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"time"
"vocat/internal/device"
"vocat/internal/exportproxy"
"vocat/internal/store"
)
const (
automaticTaskPollInterval = 5 * time.Second
automaticTaskMaxRuntime = 8 * time.Minute
)
type automaticTaskPayload struct {
Phone string `json:"phone,omitempty"`
Message string `json:"message,omitempty"`
DurationSeconds int `json:"duration_seconds,omitempty"`
}
type automaticTaskExecutionError struct {
err error
retryable bool
}
func (value automaticTaskExecutionError) Error() string { return value.err.Error() }
func (value automaticTaskExecutionError) Unwrap() error { return value.err }
type automaticTaskScheduler struct {
server *Server
ctx context.Context
mu sync.Mutex
queues map[string]chan store.AutomaticTaskRun
}
func (s *Server) StartAutomaticTasks(ctx context.Context) {
if ctx == nil {
ctx = context.Background()
}
scheduler := &automaticTaskScheduler{server: s, ctx: ctx, queues: make(map[string]chan store.AutomaticTaskRun)}
s.automaticTasks = scheduler
go scheduler.run()
}
func (scheduler *automaticTaskScheduler) run() {
ticker := time.NewTicker(automaticTaskPollInterval)
defer ticker.Stop()
scheduler.claim()
for {
select {
case <-scheduler.ctx.Done():
return
case <-ticker.C:
scheduler.claim()
}
}
}
func (scheduler *automaticTaskScheduler) claim() {
runs, err := scheduler.server.store.ClaimDueAutomaticTasks(scheduler.ctx, time.Now().UTC(), 50)
if err != nil {
scheduler.server.logger.Warn("claim automatic tasks", "error", err)
return
}
for _, run := range runs {
scheduler.enqueue(run)
}
}
func (scheduler *automaticTaskScheduler) enqueue(run store.AutomaticTaskRun) {
deviceID := strings.TrimSpace(run.DeviceID)
scheduler.mu.Lock()
queue := scheduler.queues[deviceID]
if queue == nil {
queue = make(chan store.AutomaticTaskRun, 100)
scheduler.queues[deviceID] = queue
go scheduler.worker(deviceID, queue)
}
scheduler.mu.Unlock()
select {
case queue <- run:
case <-scheduler.ctx.Done():
}
}
func (scheduler *automaticTaskScheduler) worker(deviceID string, queue <-chan store.AutomaticTaskRun) {
for {
select {
case <-scheduler.ctx.Done():
return
case run := <-queue:
scheduler.execute(run)
}
}
}
func (scheduler *automaticTaskScheduler) execute(run store.AutomaticTaskRun) {
task, err := scheduler.server.store.AutomaticTask(scheduler.ctx, run.TaskID)
if err != nil {
run.Status, run.Error, run.FinishedAt = "failed", err.Error(), time.Now().UTC()
_ = scheduler.server.store.UpdateAutomaticTaskRun(context.Background(), run)
return
}
run.Status, run.StartedAt = "running", time.Now().UTC()
_ = scheduler.server.store.UpdateAutomaticTaskRun(context.Background(), run)
var output string
for attempt := 1; attempt <= task.RetryCount+1; attempt++ {
run.Attempts = attempt
_ = scheduler.server.store.UpdateAutomaticTaskRun(context.Background(), run)
operationContext, cancel := context.WithTimeout(scheduler.ctx, automaticTaskMaxRuntime)
output, err = scheduler.server.executeAutomaticTask(operationContext, task)
cancel()
if err == nil {
break
}
var executionError automaticTaskExecutionError
if errors.As(err, &executionError) && !executionError.retryable {
break
}
if attempt <= task.RetryCount {
scheduler.server.logger.Warn("automatic task attempt failed", "task_id", task.ID, "device_id", task.DeviceID, "attempt", attempt, "error", err)
select {
case <-scheduler.ctx.Done():
break
case <-time.After(time.Duration(attempt*5) * time.Second):
}
}
}
run.FinishedAt = time.Now().UTC()
if err == nil {
run.Status, run.Output, run.Error = "success", output, ""
} else {
run.Status, run.Error = "failed", err.Error()
}
if updateErr := scheduler.server.store.UpdateAutomaticTaskRun(context.Background(), run); updateErr != nil {
scheduler.server.logger.Warn("finish automatic task run", "run_id", run.ID, "error", updateErr)
}
if task.Notify {
go scheduler.server.notifyAutomaticTask(context.Background(), task, run)
}
}
func (s *Server) executeAutomaticTask(ctx context.Context, task store.AutomaticTask) (string, error) {
config, entry, physicalID, err := s.ensureAutomaticTaskProfile(ctx, task)
if err != nil {
return "", err
}
networkWasEnabled := config.NetworkEnabled
if err := s.prepareAutomaticTaskEnvironment(ctx, &config, entry, physicalID, task); err != nil {
return "", err
}
var payload automaticTaskPayload
if err := json.Unmarshal(task.Payload, &payload); err != nil {
return "", fmt.Errorf("decode task payload: %w", err)
}
switch task.TaskType {
case "sms":
return s.executeAutomaticSMS(ctx, task, payload)
case "call":
return s.executeAutomaticCall(ctx, task, payload)
case "public_ip":
return s.executeAutomaticPublicIP(ctx, config, physicalID, task.ProfileICCID, networkWasEnabled)
default:
return "", fmt.Errorf("unsupported automatic task type %q", task.TaskType)
}
}
func (s *Server) ensureAutomaticTaskProfile(ctx context.Context, task store.AutomaticTask) (store.Device, device.Device, string, error) {
config, err := s.store.Device(ctx, task.DeviceID)
if err != nil {
return store.Device{}, device.Device{}, "", fmt.Errorf("read device: %w", err)
}
entry, physicalID, present := s.physicalForConfig(config)
if !present || entry.Snapshot == nil {
return store.Device{}, device.Device{}, "", errors.New("configured device is offline")
}
if strings.EqualFold(strings.TrimSpace(entry.Snapshot.ICCID), strings.TrimSpace(task.ProfileICCID)) {
return config, entry, physicalID, nil
}
if _, err := s.devices.SetFlight(ctx, physicalID, true); err != nil {
return store.Device{}, device.Device{}, "", fmt.Errorf("enter airplane mode before profile switch: %w", err)
}
if err := s.devices.ESIMSwitchProfile(ctx, physicalID, task.ProfileICCID, task.ProfileAID); err != nil {
return store.Device{}, device.Device{}, "", fmt.Errorf("switch eSIM profile: %w", err)
}
entry, physicalID, present = s.physicalForConfig(config)
if !present {
return store.Device{}, device.Device{}, "", errors.New("device did not recover after profile switch")
}
snapshot, err := s.devices.Refresh(ctx, physicalID)
if err != nil {
return store.Device{}, device.Device{}, "", fmt.Errorf("verify switched profile: %w", err)
}
if !strings.EqualFold(strings.TrimSpace(snapshot.ICCID), strings.TrimSpace(task.ProfileICCID)) {
return store.Device{}, device.Device{}, "", fmt.Errorf("profile verification failed: current ICCID is %s", firstNonEmpty(snapshot.ICCID, "unavailable"))
}
entry.Snapshot = &snapshot
return config, entry, physicalID, nil
}
func (s *Server) prepareAutomaticTaskEnvironment(ctx context.Context, config *store.Device, entry device.Device, physicalID string, task store.AutomaticTask) error {
iccid := strings.TrimSpace(task.ProfileICCID)
if task.Environment == "vowifi" {
if task.TaskType == "public_ip" {
return errors.New("public IP tasks cannot run over VoWiFi")
}
if _, err := s.devices.SetFlight(ctx, physicalID, true); err != nil {
return fmt.Errorf("enable airplane mode for VoWiFi: %w", err)
}
config.VoWiFiEnabled, config.NetworkEnabled = true, false
if err := s.store.UpsertDevice(ctx, *config); err != nil {
return err
}
if err := s.store.UpsertCardPolicy(ctx, store.CardPolicy{ICCID: iccid, VoWiFiEnabled: true, AirplaneEnabled: true, IPVersion: "IPV4V6", Source: "automatic_task"}); err != nil {
return err
}
if s.vowifi == nil {
return errors.New("VoWiFi runtime is unavailable")
}
state, stateErr := s.vowifi.State(config.ID)
stateMatchesCard := state.ICCID == "" || strings.EqualFold(strings.TrimSpace(state.ICCID), iccid)
if stateErr == nil && stateMatchesCard && state.IMSReady && (task.TaskType != "sms" || state.SMSReady) {
return nil
}
if stateErr == nil && state.Enabled {
_, stateErr = s.vowifi.RequestReconnect(config.ID)
} else {
_, stateErr = s.vowifi.RequestEnabled(config.ID, true)
}
if stateErr != nil {
return fmt.Errorf("start VoWiFi: %w", stateErr)
}
return s.waitAutomaticVoWiFi(ctx, config.ID, iccid, task.TaskType == "sms")
}
if s.vowifi != nil {
if state, stateErr := s.vowifi.State(config.ID); stateErr == nil && (state.Enabled || state.Active) {
if _, stateErr = s.vowifi.RequestEnabled(config.ID, false); stateErr != nil {
return fmt.Errorf("stop VoWiFi: %w", stateErr)
}
if err := s.waitAutomaticVoWiFiStopped(ctx, config.ID); err != nil {
return err
}
}
}
config.VoWiFiEnabled = false
config.NetworkEnabled = task.TaskType == "public_ip"
if err := s.store.UpsertDevice(ctx, *config); err != nil {
return err
}
if err := s.store.UpsertCardPolicy(ctx, store.CardPolicy{ICCID: iccid, NetworkEnabled: config.NetworkEnabled, VoWiFiEnabled: false, AirplaneEnabled: false, APN: config.APN, IPVersion: "IPV4V6", Source: "automatic_task"}); err != nil {
return err
}
if task.TaskType != "public_ip" {
if _, err := s.devices.SetNetwork(ctx, physicalID, device.NetworkRequest{Enabled: false, APN: config.APN, IPVersion: "IPV4V6", Backend: config.DeviceBackend}); err != nil {
s.logger.Warn("automatic task could not stop unused cellular data", "device_id", config.ID, "error", err)
}
}
if _, err := s.devices.SetFlight(ctx, physicalID, false); err != nil {
return fmt.Errorf("enable cellular radio: %w", err)
}
if _, err := s.devices.SetOperatorSelection(ctx, physicalID, true, "", nil); err != nil {
return fmt.Errorf("enable automatic network selection: %w", err)
}
if _, err := s.devices.ReRegisterOperator(ctx, physicalID); err != nil {
return fmt.Errorf("re-register cellular network: %w", err)
}
if err := s.waitAutomaticCellular(ctx, physicalID, task.TaskType == "public_ip"); err != nil {
return err
}
if task.TaskType == "public_ip" {
if !s.developerActive(ctx) {
return errors.New("roaming public IP tasks require developer mode")
}
if _, err := s.devices.SetNetwork(ctx, physicalID, device.NetworkRequest{Enabled: true, APN: config.APN, IPVersion: "IPV4V6", Backend: config.DeviceBackend}); err != nil {
s.rollbackAutomaticNetwork(config.ID, physicalID, iccid, *config)
return fmt.Errorf("start roaming data: %w", err)
}
}
return nil
}
func (s *Server) waitAutomaticVoWiFi(ctx context.Context, deviceID, iccid string, requireSMS bool) error {
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
state, err := s.vowifi.State(deviceID)
if err == nil && state.IMSReady && (!requireSMS || state.SMSReady) && (state.ICCID == "" || strings.EqualFold(state.ICCID, iccid)) {
return nil
}
if err == nil && state.LastError != "" && !state.Active && !state.Enabled {
return errors.New(state.LastError)
}
select {
case <-ctx.Done():
if err == nil && state.LastError != "" {
return fmt.Errorf("wait for VoWiFi readiness: %s", state.LastError)
}
return fmt.Errorf("wait for VoWiFi readiness: %w", ctx.Err())
case <-ticker.C:
}
}
}
func (s *Server) waitAutomaticVoWiFiStopped(ctx context.Context, deviceID string) error {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
state, err := s.vowifi.State(deviceID)
if err != nil || (!state.Active && !state.Enabled) {
return nil
}
select {
case <-ctx.Done():
return fmt.Errorf("wait for VoWiFi shutdown: %w", ctx.Err())
case <-ticker.C:
}
}
}
func (s *Server) waitAutomaticCellular(ctx context.Context, physicalID string, requirePacketAttach bool) error {
ticker := time.NewTicker(3 * time.Second)
defer ticker.Stop()
stableSamples := 0
for {
snapshot, err := s.devices.Refresh(ctx, physicalID)
registered := err == nil && (snapshot.RegistrationStatus == 1 || snapshot.RegistrationStatus == 5)
if registered && (!requirePacketAttach || snapshot.PSAttached) {
stableSamples++
if stableSamples >= 2 {
return nil
}
} else {
stableSamples = 0
}
if err == nil && snapshot.RegistrationStatus == 3 {
return errors.New("cellular network registration was denied")
}
select {
case <-ctx.Done():
return fmt.Errorf("wait for cellular registration: %w", ctx.Err())
case <-ticker.C:
}
}
}
func (s *Server) executeAutomaticSMS(ctx context.Context, task store.AutomaticTask, payload automaticTaskPayload) (string, error) {
body, _ := json.Marshal(map[string]any{"device_id": task.DeviceID, "phone": payload.Phone, "message": payload.Message})
recorder := httptest.NewRecorder()
request := httptest.NewRequestWithContext(ctx, http.MethodPost, "/api/sms/send", bytes.NewReader(body))
request.Header.Set("Content-Type", "application/json")
s.handleSMSSend(recorder, request)
if recorder.Code < 200 || recorder.Code >= 300 {
failure := fmt.Errorf("send SMS failed (HTTP %d): %s", recorder.Code, compactAutomaticResponse(recorder.Body.Bytes()))
// Once any part reached the modem/IMS transaction, retrying the whole
// message could deliver a duplicate. Preparation failures remain safe to
// retry according to the configured count.
return "", automaticTaskExecutionError{err: failure, retryable: automaticSMSRetrySafe(recorder.Body.Bytes())}
}
return "短信已提交到 " + payload.Phone, nil
}
func automaticSMSRetrySafe(body []byte) bool {
var payload struct {
Data struct {
PartsAttempted int `json:"parts_attempted"`
PartsAccepted int `json:"parts_accepted"`
RetrySafe *bool `json:"retry_safe"`
} `json:"data"`
}
if json.Unmarshal(body, &payload) != nil {
return false
}
if payload.Data.RetrySafe != nil {
return *payload.Data.RetrySafe
}
return payload.Data.PartsAttempted == 0 && payload.Data.PartsAccepted == 0
}
func (s *Server) executeAutomaticCall(ctx context.Context, task store.AutomaticTask, payload automaticTaskPayload) (string, error) {
config, err := s.store.Device(ctx, task.DeviceID)
if err != nil {
return "", err
}
_, physicalID, present := s.physicalForConfig(config)
if !present {
return "", errors.New("configured device is offline")
}
body, _ := json.Marshal(map[string]any{"number": payload.Phone, "duration_seconds": payload.DurationSeconds})
recorder := httptest.NewRecorder()
request := httptest.NewRequestWithContext(ctx, http.MethodPost, "/api/devices/calls/dial", bytes.NewReader(body))
request.Header.Set("Content-Type", "application/json")
s.handleCallAction(recorder, request, config, physicalID, "dial")
if recorder.Code < 200 || recorder.Code >= 300 {
return "", fmt.Errorf("dial failed (HTTP %d): %s", recorder.Code, compactAutomaticResponse(recorder.Body.Bytes()))
}
return fmt.Sprintf("已拨打 %s,将在 %d 秒后自动挂断", payload.Phone, payload.DurationSeconds), nil
}
func (s *Server) executeAutomaticPublicIP(ctx context.Context, config store.Device, physicalID, iccid string, networkWasEnabled bool) (string, error) {
if !networkWasEnabled {
defer s.rollbackAutomaticNetwork(config.ID, physicalID, iccid, config)
}
if strings.TrimSpace(config.Interface) == "" {
return "", errors.New("device has no cellular network interface")
}
info, err := exportproxy.LookupPublicIP(ctx, config.Interface)
if err != nil {
return "", fmt.Errorf("detect roaming public IP: %w", err)
}
s.savePublicIP(config.ID, iccid, info)
return strings.TrimSpace(fmt.Sprintf("公网 IP %s · %s %s", info.IP, info.CountryCode, info.Region)), nil
}
func (s *Server) rollbackAutomaticNetwork(deviceID, physicalID, iccid string, config store.Device) {
cleanupContext, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if _, err := s.devices.SetNetwork(cleanupContext, physicalID, device.NetworkRequest{Enabled: false, APN: config.APN, IPVersion: "IPV4V6", Backend: config.DeviceBackend}); err != nil {
s.logger.Warn("stop one-shot automatic roaming data", "device_id", deviceID, "error", err)
}
config.NetworkEnabled = false
if err := s.store.UpsertDevice(cleanupContext, config); err != nil {
s.logger.Warn("restore automatic roaming data setting", "device_id", deviceID, "error", err)
}
if err := s.store.UpsertCardPolicy(cleanupContext, store.CardPolicy{ICCID: iccid, NetworkEnabled: false, VoWiFiEnabled: false, AirplaneEnabled: false, APN: config.APN, IPVersion: "IPV4V6", Source: "automatic_task"}); err != nil {
s.logger.Warn("restore automatic roaming card policy", "device_id", deviceID, "error", err)
}
}
func compactAutomaticResponse(body []byte) string {
var payload map[string]any
if json.Unmarshal(body, &payload) == nil {
if apiErr, ok := payload["error"].(map[string]any); ok {
return firstNonEmpty(fmt.Sprint(apiErr["message"]), fmt.Sprint(apiErr["code"]), "request failed")
}
}
return strings.TrimSpace(string(body))
}
func (s *Server) routeAutomaticTasksAPI(w http.ResponseWriter, r *http.Request, cleanPath string) bool {
segments := splitAPIPath(cleanPath)
if len(segments) == 0 || segments[0] != "automatic-tasks" {
return false
}
if len(segments) == 1 {
s.handleAutomaticTasks(w, r)
return true
}
id, err := strconv.ParseInt(segments[1], 10, 64)
if err != nil || id <= 0 {
writeError(w, http.StatusBadRequest, "invalid_task_id", "automatic task ID is invalid")
return true
}
if len(segments) == 2 {
s.handleAutomaticTask(w, r, id)
return true
}
if len(segments) == 3 && segments[2] == "run" {
s.handleAutomaticTaskRunNow(w, r, id)
return true
}
writeError(w, http.StatusNotFound, "not_found", "automatic task endpoint not found")
return true
}
func (s *Server) handleAutomaticTasks(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
tasks, err := s.store.ListAutomaticTasks(r.Context())
if err != nil {
s.writeStoreError(w, err)
return
}
runs, err := s.store.ListAutomaticTaskRuns(r.Context(), 100)
if err != nil {
s.writeStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"tasks": tasks, "runs": runs}})
case http.MethodPost:
task, err := s.decodeAutomaticTask(r, 0)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_automatic_task", err.Error())
return
}
saved, err := s.store.SaveAutomaticTask(r.Context(), task)
if err != nil {
s.writeStoreError(w, err)
return
}
writeJSON(w, http.StatusCreated, map[string]any{"data": saved})
default:
w.Header().Set("Allow", "GET, POST")
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
}
}
func (s *Server) handleAutomaticTask(w http.ResponseWriter, r *http.Request, id int64) {
switch r.Method {
case http.MethodPut:
task, err := s.decodeAutomaticTask(r, id)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_automatic_task", err.Error())
return
}
saved, err := s.store.SaveAutomaticTask(r.Context(), task)
if err != nil {
s.writeStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"data": saved})
case http.MethodDelete:
if err := s.store.DeleteAutomaticTask(r.Context(), id); err != nil {
s.writeStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"deleted": true}})
default:
w.Header().Set("Allow", "PUT, DELETE")
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
}
}
func (s *Server) handleAutomaticTaskRunNow(w http.ResponseWriter, r *http.Request, id int64) {
if !requireMethod(w, r, http.MethodPost) {
return
}
if s.automaticTasks == nil {
writeError(w, http.StatusServiceUnavailable, "scheduler_unavailable", "automatic task scheduler is unavailable")
return
}
task, err := s.store.AutomaticTask(r.Context(), id)
if err != nil {
s.writeStoreError(w, err)
return
}
run, err := s.store.QueueAutomaticTaskNow(r.Context(), task)
if err != nil {
s.writeStoreError(w, err)
return
}
s.automaticTasks.enqueue(run)
writeJSON(w, http.StatusAccepted, map[string]any{"data": run})
}
func (s *Server) decodeAutomaticTask(r *http.Request, id int64) (store.AutomaticTask, error) {
var request struct {
Name string `json:"name"`
Enabled bool `json:"enabled"`
DeviceID string `json:"device_id"`
ProfileICCID string `json:"profile_iccid"`
ProfileAID string `json:"profile_aid"`
TaskType string `json:"task_type"`
Environment string `json:"environment"`
IntervalDays int `json:"interval_days"`
StartDate string `json:"start_date"`
RunTime string `json:"run_time"`
Timezone string `json:"timezone"`
RetryCount int `json:"retry_count"`
Notify bool `json:"notify"`
Payload automaticTaskPayload `json:"payload"`
}
if err := s.decodeJSON(nilResponseWriter{}, r, &request); err != nil {
return store.AutomaticTask{}, err
}
request.Name, request.DeviceID = strings.TrimSpace(request.Name), strings.TrimSpace(request.DeviceID)
request.ProfileICCID, request.ProfileAID = strings.TrimSpace(request.ProfileICCID), strings.TrimSpace(request.ProfileAID)
request.TaskType, request.Environment = strings.ToLower(strings.TrimSpace(request.TaskType)), strings.ToLower(strings.TrimSpace(request.Environment))
if request.Name == "" || request.DeviceID == "" || request.ProfileICCID == "" {
return store.AutomaticTask{}, errors.New("name, device, and eSIM profile are required")
}
if _, err := s.store.Device(r.Context(), request.DeviceID); err != nil {
return store.AutomaticTask{}, errors.New("selected device does not exist")
}
if request.Environment != "vowifi" && request.Environment != "cellular" {
return store.AutomaticTask{}, errors.New("environment must be vowifi or cellular")
}
if request.TaskType != "sms" && request.TaskType != "call" && request.TaskType != "public_ip" {
return store.AutomaticTask{}, errors.New("unsupported task type")
}
if request.TaskType == "public_ip" && request.Environment != "cellular" {
return store.AutomaticTask{}, errors.New("public IP tasks must use cellular direct mode")
}
if request.IntervalDays < 1 || request.IntervalDays > 365 || request.RetryCount < 0 || request.RetryCount > 10 {
return store.AutomaticTask{}, errors.New("interval_days must be 1-365 and retry_count must be 0-10")
}
if request.TaskType == "sms" {
if !validDialNumber(request.Payload.Phone) || strings.TrimSpace(request.Payload.Message) == "" {
return store.AutomaticTask{}, errors.New("SMS phone and message are required")
}
if blocked, reason := blockedSMSDestination(request.Payload.Phone); blocked {
return store.AutomaticTask{}, errors.New(reason)
}
}
if request.TaskType == "call" && (!validDialNumber(request.Payload.Phone) || request.Payload.DurationSeconds < 1 || request.Payload.DurationSeconds > 600) {
return store.AutomaticTask{}, errors.New("call phone is required and automatic hang-up must be 1-600 seconds")
}
request.Timezone = strings.TrimSpace(request.Timezone)
if request.Timezone == "" {
request.Timezone = time.Local.String()
}
location, err := time.LoadLocation(request.Timezone)
if err != nil {
return store.AutomaticTask{}, errors.New("timezone must be a valid IANA time zone")
}
nextRun, err := nextAutomaticRun(request.StartDate, request.RunTime, request.IntervalDays, time.Now().In(location))
if err != nil {
return store.AutomaticTask{}, err
}
payload, _ := json.Marshal(request.Payload)
task := store.AutomaticTask{ID: id, Name: request.Name, Enabled: request.Enabled, DeviceID: request.DeviceID,
ProfileICCID: request.ProfileICCID, ProfileAID: request.ProfileAID, TaskType: request.TaskType,
Environment: request.Environment, IntervalDays: request.IntervalDays, StartDate: request.StartDate,
RunTime: request.RunTime, Timezone: request.Timezone, Payload: payload, RetryCount: request.RetryCount, Notify: request.Notify, NextRunAt: nextRun.UTC()}
if id != 0 {
if previous, previousErr := s.store.AutomaticTask(r.Context(), id); previousErr == nil {
task.CreatedAt, task.LastRunAt, task.LastStatus, task.LastError = previous.CreatedAt, previous.LastRunAt, previous.LastStatus, previous.LastError
}
}
return task, nil
}
func nextAutomaticRun(date, clock string, intervalDays int, now time.Time) (time.Time, error) {
location := now.Location()
start, err := time.ParseInLocation("2006-01-02 15:04", strings.TrimSpace(date)+" "+strings.TrimSpace(clock), location)
if err != nil {
return time.Time{}, errors.New("start_date and run_time must use YYYY-MM-DD and HH:MM")
}
for start.Before(now) {
start = start.AddDate(0, 0, intervalDays)
}
return start, nil
}
// nilResponseWriter is used only because decodeJSON's size/error contract is
// shared with HTTP handlers; decode errors are returned to the real handler.
type nilResponseWriter struct{}
func (nilResponseWriter) Header() http.Header { return make(http.Header) }
func (nilResponseWriter) Write([]byte) (int, error) { return 0, nil }
func (nilResponseWriter) WriteHeader(statusCode int) {}
+30
View File
@@ -0,0 +1,30 @@
package server
import (
"testing"
"time"
)
func TestNextAutomaticRunUsesIntervalAndLocalClock(t *testing.T) {
location := time.FixedZone("test", 8*60*60)
now := time.Date(2026, 8, 10, 12, 0, 0, 0, location)
next, err := nextAutomaticRun("2026-08-01", "09:30", 3, now)
if err != nil {
t.Fatal(err)
}
want := time.Date(2026, 8, 13, 9, 30, 0, 0, location)
if !next.Equal(want) {
t.Fatalf("next run = %v, want %v", next, want)
}
}
func TestAutomaticSMSRetrySafetyPreventsDuplicateSubmission(t *testing.T) {
unsafe := []byte(`{"data":{"parts_attempted":1,"parts_accepted":1,"retry_safe":false}}`)
if automaticSMSRetrySafe(unsafe) {
t.Fatal("partially submitted SMS was considered safe to retry")
}
safe := []byte(`{"data":{"parts_attempted":0,"parts_accepted":0}}`)
if !automaticSMSRetrySafe(safe) {
t.Fatal("unattempted SMS was not considered safe to retry")
}
}
+208 -26
View File
@@ -236,14 +236,46 @@ func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) bool {
return true
}
config := payload.toStoreDevice()
// Newly added hardware starts fail-closed: RF is disabled immediately and
// VoWiFi becomes the desired service. Cellular registration is only
// restored by the user's later airplane-mode-off action.
config.VoWiFiEnabled = true
config.NetworkEnabled = false
if !s.developerActive(r.Context()) {
config.NetworkEnabled = false
}
fillConfigFromPhysical(&config, *selected)
if selector, ok := s.devices.(interface{ SetBackend(string, string) error }); ok {
if err := selector.SetBackend(selected.ID, config.DeviceBackend); err != nil {
s.writeDeviceError(w, err)
return true
}
}
if _, err := s.devices.SetFlight(r.Context(), selected.ID, true); err != nil {
s.writeDeviceError(w, err)
return true
}
if err := s.store.UpsertDevice(r.Context(), config); err != nil {
s.writeStoreError(w, err)
return true
}
if selected.Snapshot != nil {
iccid := strings.TrimSpace(selected.Snapshot.ICCID)
if iccid != "" {
if err := s.store.UpsertCardPolicy(r.Context(), store.CardPolicy{
ICCID: iccid, VoWiFiEnabled: true, AirplaneEnabled: true,
IPVersion: "IPV4V6", Source: "default",
}); err != nil {
s.writeStoreError(w, err)
return true
}
}
}
if s.vowifi != nil {
if _, err := s.vowifi.RequestEnabled(config.ID, true); err != nil {
s.logger.Warn("new device saved in safe airplane mode but VoWiFi start was not queued", "device_id", config.ID, "error", err)
}
}
writeJSON(w, http.StatusCreated, map[string]any{
"data": map[string]any{
"status": "created",
@@ -416,9 +448,20 @@ func (s *Server) handleDevicePath(
}
next.ID = id
next.CreatedAt = config.CreatedAt
// VoWiFi/airplane transitions are transactional device actions. A
// general config save must not silently bypass their RF-safe ordering.
next.VoWiFiEnabled = config.VoWiFiEnabled
if next.Name == id && strings.TrimSpace(payload.Name) == "" {
next.Name = config.Name
}
if _, physicalID, present := s.physicalForConfig(next); present {
if selector, ok := s.devices.(interface{ SetBackend(string, string) error }); ok {
if err := selector.SetBackend(physicalID, next.DeviceBackend); err != nil {
s.writeDeviceError(w, err)
return true
}
}
}
if err := s.store.UpsertDevice(r.Context(), next); err != nil {
s.writeStoreError(w, err)
return true
@@ -435,7 +478,7 @@ func (s *Server) handleDevicePath(
entry, physicalID, physicalPresent := s.physicalForConfig(config)
if len(tail) > 0 && tail[0] == "esim" {
return s.handleESIM(w, r, tail[1:], physicalID, physicalPresent)
return s.handleESIM(w, r, tail[1:], physicalID, physicalPresent, config.ID)
}
switch strings.Join(tail, "/") {
case "overview":
@@ -503,7 +546,7 @@ func (s *Server) handleDevicePath(
if !s.requirePhysicalDevice(w, physicalPresent) {
return true
}
return s.handleFlightMode(w, r, physicalID)
return s.handleFlightMode(w, r, config, physicalID)
case "network":
if !s.requirePhysicalDevice(w, physicalPresent) {
return true
@@ -756,9 +799,59 @@ func (s *Server) handleVoWiFiEnabled(
}
}
// Establish RF-off synchronously before changing the asynchronous VoWiFi
// lifecycle. This removes the attach window both when entering VoWiFi and
// when leaving it: teardown starts from CFUN=4 and is required to remain
// there until the user explicitly disables airplane mode.
previous := config.VoWiFiEnabled
liveICCID := ""
entry, physicalID, present := s.physicalForConfig(config)
if present {
if _, err := s.devices.SetFlight(r.Context(), physicalID, true); err != nil {
s.writeDeviceError(w, err)
return true
}
}
if entry.Snapshot != nil {
iccid := strings.TrimSpace(entry.Snapshot.ICCID)
if iccid != "" {
liveICCID = iccid
policy, policyErr := s.store.CardPolicy(r.Context(), iccid)
if errors.Is(policyErr, store.ErrNotFound) {
policy = store.CardPolicy{ICCID: iccid, IPVersion: "IPV4V6"}
policyErr = nil
}
if policyErr != nil {
s.writeStoreError(w, policyErr)
return true
}
policy.VoWiFiEnabled = request.Enabled
policy.AirplaneEnabled = true
policy.NetworkEnabled = false
policy.Source = "manual"
if err := s.store.UpsertCardPolicy(r.Context(), policy); err != nil {
s.writeStoreError(w, err)
return true
}
}
}
rollbackCardPolicy := func() {
if liveICCID == "" {
return
}
policy, policyErr := s.store.CardPolicy(context.Background(), liveICCID)
if policyErr != nil {
return
}
policy.VoWiFiEnabled = previous
policy.AirplaneEnabled = true
policy.NetworkEnabled = false
_ = s.store.UpsertCardPolicy(context.Background(), policy)
}
config.VoWiFiEnabled = request.Enabled
if err := s.store.UpsertDevice(r.Context(), config); err != nil {
rollbackCardPolicy()
s.writeStoreError(w, err)
return true
}
@@ -780,6 +873,7 @@ func (s *Server) handleVoWiFiEnabled(
return true
}
config.VoWiFiEnabled = previous
rollbackCardPolicy()
if restoreErr := s.store.UpsertDevice(r.Context(), config); restoreErr != nil {
s.logger.Error(
"restore VoWiFi policy after rejected runtime operation",
@@ -983,7 +1077,7 @@ func (s *Server) handleUSSD(w http.ResponseWriter, r *http.Request, id string) b
return true
}
func (s *Server) handleFlightMode(w http.ResponseWriter, r *http.Request, id string) bool {
func (s *Server) handleFlightMode(w http.ResponseWriter, r *http.Request, config store.Device, physicalID string) bool {
if !requireMethod(w, r, http.MethodPatch) {
return true
}
@@ -994,7 +1088,11 @@ func (s *Server) handleFlightMode(w http.ResponseWriter, r *http.Request, id str
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return true
}
result, err := s.devices.SetFlight(r.Context(), id, request.Enabled)
if config.VoWiFiEnabled {
writeError(w, http.StatusConflict, "vowifi_owns_airplane_mode", "airplane mode is locked on while VoWiFi is enabled")
return true
}
result, err := s.devices.SetFlight(r.Context(), physicalID, request.Enabled)
if err != nil {
s.writeDeviceError(w, err)
return true
@@ -1002,7 +1100,7 @@ func (s *Server) handleFlightMode(w http.ResponseWriter, r *http.Request, id str
// Unlike VoWiFi, CFUN airplane state is not represented in the device row.
// Persist it against the live ICCID so a restart can distinguish an
// intentional airplane policy from an interrupted VoWiFi teardown.
if entry, getErr := s.devices.Get(id); getErr == nil && entry.Snapshot != nil {
if entry, getErr := s.devices.Get(physicalID); getErr == nil && entry.Snapshot != nil {
iccid := strings.TrimSpace(entry.Snapshot.ICCID)
if iccid != "" {
policy, policyErr := s.store.CardPolicy(r.Context(), iccid)
@@ -1073,7 +1171,7 @@ func (s *Server) handleCellularData(
controller := http.NewResponseController(w)
_ = controller.SetWriteDeadline(time.Time{})
result, err := s.devices.SetNetwork(r.Context(), physicalID, device.NetworkRequest{
Enabled: request.Enabled, APN: apn, IPVersion: "IPV4V6",
Enabled: request.Enabled, APN: apn, IPVersion: "IPV4V6", Backend: config.DeviceBackend,
})
if err != nil {
s.writeDeviceError(w, err)
@@ -1087,7 +1185,7 @@ func (s *Server) handleCellularData(
if err := s.store.UpsertDevice(r.Context(), config); err != nil {
rollbackContext, cancel := context.WithTimeout(context.Background(), 20*time.Second)
_, _ = s.devices.SetNetwork(rollbackContext, physicalID, device.NetworkRequest{
Enabled: previous, APN: config.APN, IPVersion: "IPV4V6",
Enabled: previous, APN: config.APN, IPVersion: "IPV4V6", Backend: config.DeviceBackend,
})
cancel()
s.writeStoreError(w, err)
@@ -1266,28 +1364,46 @@ func (s *Server) configuredDeviceSummary(
result["network_connected"] = config.NetworkEnabled
result["data_connected"] = config.NetworkEnabled
result["vowifi_enabled"] = config.VoWiFiEnabled
if runtime, err := s.store.VoWiFiRuntime(context.Background(), config.ID); err == nil {
currentICCID := ""
var currentSnapshot *device.Snapshot
if entry != nil {
currentSnapshot = entry.Snapshot
if entry.Snapshot != nil {
currentICCID = strings.TrimSpace(entry.Snapshot.ICCID)
var runtimeResponse map[string]any
runtimeMatchesCard := true
if s.vowifi != nil {
if runtime, err := s.vowifi.State(config.ID); err == nil {
runtimeMatchesCard = voWiFiRuntimeMatchesSnapshot(runtime.ICCID, entry)
if runtimeMatchesCard {
runtimeResponse = liveVoWiFiRuntime(runtime)
} else {
runtimeResponse = idleVoWiFiRuntime(config.ID, snapshotForEntry(entry))
}
}
runtimeMatchesCard := currentICCID == "" || runtime.ICCID == "" ||
strings.EqualFold(currentICCID, strings.TrimSpace(runtime.ICCID))
var runtimeResponse map[string]any
if runtimeMatchesCard {
runtimeResponse = storedVoWiFiRuntime(runtime)
} else {
// The saved IMS session belongs to a different eSIM profile. Never
// project its registration or number onto the currently selected SIM.
runtimeResponse = idleVoWiFiRuntime(config.ID, currentSnapshot)
}
result["vowifi_runtime"] = runtimeResponse
result["vowifi_active"] = config.VoWiFiEnabled && runtimeMatchesCard && runtime.TunnelReady
}
if runtimeResponse == nil {
if runtime, err := s.store.VoWiFiRuntime(context.Background(), config.ID); err == nil {
currentICCID := ""
var currentSnapshot *device.Snapshot
if entry != nil {
currentSnapshot = entry.Snapshot
if entry.Snapshot != nil {
currentICCID = strings.TrimSpace(entry.Snapshot.ICCID)
}
}
runtimeMatchesCard = currentICCID == "" || runtime.ICCID == "" ||
strings.EqualFold(currentICCID, strings.TrimSpace(runtime.ICCID))
if runtimeMatchesCard {
runtimeResponse = storedVoWiFiRuntime(runtime)
} else {
// The saved IMS session belongs to a different eSIM profile. Never
// project its registration or number onto the currently selected SIM.
runtimeResponse = idleVoWiFiRuntime(config.ID, currentSnapshot)
}
}
}
if runtimeResponse == nil {
runtimeResponse = idleVoWiFiRuntime(config.ID, snapshotForEntry(entry))
}
result["vowifi_runtime"] = runtimeResponse
runtimeEnabled, _ := runtimeResponse["enabled"].(bool)
runtimeTunnelReady, _ := runtimeResponse["tunnel_ready"].(bool)
result["vowifi_active"] = config.VoWiFiEnabled && runtimeMatchesCard && runtimeEnabled && runtimeTunnelReady
// Numbers are SIM-owned data. Resolve the association by the live ICCID
// instead of reusing the last VoWiFi runtime attached to this device ID.
if entry != nil && entry.Snapshot != nil {
@@ -1390,9 +1506,14 @@ func (s *Server) configuredDeviceStatus(
}
func storedVoWiFiRuntime(runtime store.VoWiFiRuntime) map[string]any {
extra, _ := rawJSONObject(runtime.Extra).(map[string]any)
enabled, _ := extra["enabled"].(bool)
active, _ := extra["active"].(bool)
return map[string]any{
"device_id": runtime.DeviceID,
"phase": runtime.Phase,
"enabled": enabled,
"active": active,
"dataplane_mode": runtime.DataplaneMode,
"iccid": runtime.ICCID,
"imsi": runtime.IMSI,
@@ -1416,6 +1537,63 @@ func storedVoWiFiRuntime(runtime store.VoWiFiRuntime) map[string]any {
}
}
func liveVoWiFiRuntime(runtime vowifi.State) map[string]any {
return map[string]any{
"device_id": runtime.DeviceID,
"phase": string(runtime.Phase),
"enabled": runtime.Enabled,
"active": runtime.Active,
"dataplane_mode": runtime.DataplaneMode,
"iccid": runtime.ICCID,
"imsi": runtime.IMSI,
"sim_ready": runtime.SIMReady,
"access_ready": runtime.AccessReady,
"tunnel_ready": runtime.TunnelReady,
"ims_ready": runtime.IMSReady,
"sms_ready": runtime.SMSReady,
"reg_status": map[bool]int{true: 1, false: 0}[runtime.IMSReady],
"reg_status_text": map[bool]string{true: "registered", false: "not registered"}[runtime.IMSReady],
"network_mode": "Wi-Fi",
"local_phone": runtime.PhoneNumber,
"phone_number_source": runtime.PhoneNumberSource,
"last_error_class": runtime.LastErrorClass,
"last_error": runtime.LastError,
"last_reason": runtime.LastReason,
"updated_at": runtime.UpdatedAt,
"tunnel": map[string]any{
"established": runtime.TunnelReady,
"name": runtime.TunnelName,
"dataplane_mode": runtime.DataplaneMode,
"epdg": runtime.EPDG,
"proxy_mode": runtime.ProxyMode,
"proxy_id": runtime.ProxyID,
"security_audit": runtime.Security,
},
"imscore": map[string]any{
"registered": runtime.IMSReady,
"registration_state": runtime.IMSRegistration,
"associated_number": runtime.PhoneNumber,
"number_source": runtime.PhoneNumberSource,
},
"smsip": map[string]any{"ready": runtime.SMSReady},
}
}
func snapshotForEntry(entry *device.Device) *device.Snapshot {
if entry == nil {
return nil
}
return entry.Snapshot
}
func voWiFiRuntimeMatchesSnapshot(runtimeICCID string, entry *device.Device) bool {
current := strings.TrimSpace(snapshotString(snapshotForEntry(entry), func(snapshot *device.Snapshot) string {
return snapshot.ICCID
}))
runtimeICCID = strings.TrimSpace(runtimeICCID)
return current == "" || runtimeICCID == "" || strings.EqualFold(current, runtimeICCID)
}
func rawJSONObject(value json.RawMessage) any {
var result any
if len(value) != 0 && json.Unmarshal(value, &result) == nil {
@@ -1569,6 +1747,7 @@ func modemSummary(snapshot *device.Snapshot, phone string, phoneSource string) m
"operator": "",
"native_mcc": "",
"native_mnc": "",
"native_spn": "",
"operator_country_code": "",
"card_mcc": "",
"card_mnc": "",
@@ -1598,6 +1777,7 @@ func modemSummary(snapshot *device.Snapshot, phone string, phoneSource string) m
"operator": snapshot.OperatorName,
"native_mcc": mcc,
"native_mnc": mnc,
"native_spn": snapshot.SPN,
"operator_country_code": operatorCountryCode,
"card_mcc": cardMCC,
"card_mnc": cardMNC,
@@ -1643,6 +1823,8 @@ func idleVoWiFiRuntime(id string, snapshot *device.Snapshot) map[string]any {
return map[string]any{
"device_id": id,
"phase": "idle",
"enabled": false,
"active": false,
"dataplane_mode": "",
"iccid": iccid,
"imsi": imsi,
+44 -4
View File
@@ -31,6 +31,28 @@ func decodeData(t *testing.T, recorder *httptest.ResponseRecorder) map[string]an
return envelope.Data
}
type esimAIDCaptureController struct {
fakeDeviceController
switchAID string
disableAID string
renameAID string
}
func (controller *esimAIDCaptureController) ESIMSwitchProfile(_ context.Context, _, _, aidHex string) error {
controller.switchAID = aidHex
return nil
}
func (controller *esimAIDCaptureController) ESIMDisableProfile(_ context.Context, _, _, aidHex string) error {
controller.disableAID = aidHex
return nil
}
func (controller *esimAIDCaptureController) ESIMRenameProfile(_ context.Context, _, _, _, aidHex string) error {
controller.renameAID = aidHex
return nil
}
func TestAttachSingleEUICCIdentityFillsProfileGroupMetadataKey(t *testing.T) {
groups := []map[string]any{{"eid": "", "aidHex": "", "profiles": []any{}}}
chipInfo := map[string]any{
@@ -256,9 +278,18 @@ func TestHandleESIMShapes(t *testing.T) {
}
// Switch happy path: a present device + fake controller switches by ICCID.
present := &Server{logger: regionTestLogger(), maxRequestBodyBytes: 4096, devices: fakeDeviceController{}}
database, err := store.Open(context.Background(), ":memory:")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
if err := database.UpsertDevice(context.Background(), store.Device{ID: "dev1", Name: "dev1"}); err != nil {
t.Fatal(err)
}
controller := &esimAIDCaptureController{}
present := &Server{store: database, logger: regionTestLogger(), maxRequestBodyBytes: 4096, devices: controller}
swOK := httptest.NewRecorder()
swReq := httptest.NewRequest(http.MethodPost, "/esim/actions/switch", strings.NewReader(`{"iccid":"8900000000000000001","aid_hex":"A0"}`))
swReq := httptest.NewRequest(http.MethodPost, "/esim/actions/switch", strings.NewReader(`{"iccid":"8900000000000000001","aidHex":"A0000005591010FFFFFFFF8900000177"}`))
swReq.Header.Set("Content-Type", "application/json")
present.handleESIM(swOK, swReq, []string{"actions", "switch"}, "dev1", true)
if swOK.Code != http.StatusOK {
@@ -267,10 +298,13 @@ func TestHandleESIMShapes(t *testing.T) {
if data := decodeData(t, swOK); data["status"] != "switched" || data["verified"] != true {
t.Fatalf("switch data = %v", data)
}
if controller.switchAID != "A0000005591010FFFFFFFF8900000177" {
t.Fatalf("switch AID = %q, want XeSIM camelCase AID", controller.switchAID)
}
// Disable happy path routes the active profile to ES10c DisableProfile.
disableOK := httptest.NewRecorder()
disableReq := httptest.NewRequest(http.MethodPost, "/esim/actions/disable", strings.NewReader(`{"iccid":"8900000000000000001","aid_hex":"A0000005591010FFFFFFFF8900000100"}`))
disableReq := httptest.NewRequest(http.MethodPost, "/esim/actions/disable", strings.NewReader(`{"iccid":"8900000000000000001","aidHex":"A0000005591010FFFFFFFF8900000177"}`))
disableReq.Header.Set("Content-Type", "application/json")
present.handleESIM(disableOK, disableReq, []string{"actions", "disable"}, "dev1", true)
if disableOK.Code != http.StatusOK {
@@ -279,10 +313,13 @@ func TestHandleESIMShapes(t *testing.T) {
if data := decodeData(t, disableOK); data["status"] != "disabled" || data["recovering"] != true {
t.Fatalf("disable data = %v", data)
}
if controller.disableAID != "A0000005591010FFFFFFFF8900000177" {
t.Fatalf("disable AID = %q, want XeSIM camelCase AID", controller.disableAID)
}
// Rename happy path routes PATCH to ES10c SetNickname support.
renameOK := httptest.NewRecorder()
renameReq := httptest.NewRequest(http.MethodPatch, "/esim/profiles/8900000000000000001", strings.NewReader(`{"name":"Test profile","aid_hex":"A0000005591010FFFFFFFF8900000100"}`))
renameReq := httptest.NewRequest(http.MethodPatch, "/esim/profiles/8900000000000000001", strings.NewReader(`{"name":"Test profile","aidHex":"A0000005591010FFFFFFFF8900000177"}`))
renameReq.Header.Set("Content-Type", "application/json")
present.handleESIM(renameOK, renameReq, []string{"profiles", "8900000000000000001"}, "dev1", true)
if renameOK.Code != http.StatusOK {
@@ -291,6 +328,9 @@ func TestHandleESIMShapes(t *testing.T) {
if data := decodeData(t, renameOK); data["status"] != "renamed" || data["name"] != "Test profile" {
t.Fatalf("rename data = %v", data)
}
if controller.renameAID != "A0000005591010FFFFFFFF8900000177" {
t.Fatalf("rename AID = %q, want XeSIM camelCase AID", controller.renameAID)
}
// Download on a present device but with no smdp address reports 400.
dlNoSmdp := httptest.NewRecorder()
+66
View File
@@ -7,6 +7,7 @@ import (
"vocat/internal/device"
"vocat/internal/store"
"vocat/internal/vowifi"
)
func TestConfiguredDeviceSummaryIgnoresVoWiFiRuntimeFromPreviousSIM(t *testing.T) {
@@ -49,3 +50,68 @@ func TestConfiguredDeviceSummaryIgnoresVoWiFiRuntimeFromPreviousSIM(t *testing.T
t.Fatalf("runtime = %#v", got["vowifi_runtime"])
}
}
func TestConfiguredDeviceSummaryPrefersLiveVoWiFiStateOverStoredShutdownState(t *testing.T) {
database, err := store.Open(context.Background(), ":memory:")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
if err := database.UpsertDevice(context.Background(), store.Device{ID: "ec20_1", Name: "EC20"}); err != nil {
t.Fatal(err)
}
if err := database.UpsertVoWiFiRuntime(context.Background(), store.VoWiFiRuntime{
DeviceID: "ec20_1",
Phase: "idle",
ICCID: "89104100000028106378",
LastReason: "disabled",
UpdatedAt: time.Now().UTC(),
}); err != nil {
t.Fatal(err)
}
live := vowifi.State{
DeviceID: "ec20_1",
Phase: vowifi.PhaseTunnelReady,
Enabled: true,
Active: true,
ICCID: "89104100000028106378",
SIMReady: true,
AccessReady: true,
TunnelReady: true,
LastReason: "ipsec_tunnel_ready",
UpdatedAt: time.Now().UTC(),
}
s := &Server{store: database, vowifi: &fakeVoWiFiController{state: live}}
entry := &device.Device{ID: "physical", Snapshot: &device.Snapshot{ICCID: live.ICCID}}
got := s.configuredDeviceSummary(store.Device{ID: "ec20_1", VoWiFiEnabled: true}, entry)
runtime, ok := got["vowifi_runtime"].(map[string]any)
if !ok || runtime["phase"] != string(vowifi.PhaseTunnelReady) || runtime["enabled"] != true {
t.Fatalf("runtime = %#v", got["vowifi_runtime"])
}
if got["vowifi_active"] != true {
t.Fatalf("vowifi_active = %#v", got["vowifi_active"])
}
}
func TestConfiguredDeviceSummaryMarksIdleRuntimeAsNotInUse(t *testing.T) {
database, err := store.Open(context.Background(), ":memory:")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
s := &Server{
store: database,
vowifi: &fakeVoWiFiController{state: vowifi.State{
DeviceID: "ec20_1",
Phase: vowifi.PhaseIdle,
Enabled: false,
LastReason: "disabled",
UpdatedAt: time.Now().UTC(),
}},
}
got := s.configuredDeviceSummary(store.Device{ID: "ec20_1", VoWiFiEnabled: true}, nil)
runtime := got["vowifi_runtime"].(map[string]any)
if runtime["enabled"] != false || got["vowifi_active"] != false {
t.Fatalf("summary = %#v", got)
}
}
+64 -12
View File
@@ -8,6 +8,7 @@ import (
"time"
"vocat/internal/device"
"vocat/internal/store"
)
func esimUnavailable(w http.ResponseWriter) {
@@ -15,7 +16,11 @@ func esimUnavailable(w http.ResponseWriter) {
}
// handleESIM routes every /devices/{id}/esim* path.
func (s *Server) handleESIM(w http.ResponseWriter, r *http.Request, rest []string, physicalID string, physicalPresent bool) bool {
func (s *Server) handleESIM(w http.ResponseWriter, r *http.Request, rest []string, physicalID string, physicalPresent bool, configuredIDs ...string) bool {
configuredID := physicalID
if len(configuredIDs) > 0 && strings.TrimSpace(configuredIDs[0]) != "" {
configuredID = strings.TrimSpace(configuredIDs[0])
}
if len(rest) == 0 || (len(rest) == 1 && strings.TrimSpace(rest[0]) == "") {
if !requireMethod(w, r, http.MethodGet) {
return true
@@ -60,7 +65,7 @@ func (s *Server) handleESIM(w http.ResponseWriter, r *http.Request, rest []strin
if !requireMethod(w, r, http.MethodPost) {
return true
}
s.handleEsimSwitch(w, r, physicalID, physicalPresent)
s.handleEsimSwitch(w, r, configuredID, physicalID, physicalPresent)
return true
}
if len(rest) == 2 && rest[1] == "disable" {
@@ -295,8 +300,9 @@ func (s *Server) handleEsimRename(w http.ResponseWriter, r *http.Request, physic
return
}
var request struct {
Name string `json:"name"`
AIDHex string `json:"aid_hex"` // accepted for the multi-eUICC SPA contract; ICCID addresses the profile
Name string `json:"name"`
AIDHex string `json:"aid_hex"`
AIDHexCamel string `json:"aidHex"`
}
if err := s.decodeJSON(w, r, &request); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
@@ -307,7 +313,8 @@ func (s *Server) handleEsimRename(w http.ResponseWriter, r *http.Request, physic
writeError(w, http.StatusBadRequest, "invalid_request", "profile nickname is required")
return
}
if err := s.devices.ESIMRenameProfile(r.Context(), physicalID, iccid, nickname, request.AIDHex); err != nil {
aidHex := firstNonEmpty(request.AIDHex, request.AIDHexCamel)
if err := s.devices.ESIMRenameProfile(r.Context(), physicalID, iccid, nickname, aidHex); err != nil {
s.writeDeviceError(w, err)
return
}
@@ -316,7 +323,7 @@ func (s *Server) handleEsimRename(w http.ResponseWriter, r *http.Request, physic
// handleEsimSwitch enables one already-installed profile by ICCID (切卡). The
// eUICC EnableProfile command needs no authentication key.
func (s *Server) handleEsimSwitch(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool) {
func (s *Server) handleEsimSwitch(w http.ResponseWriter, r *http.Request, configuredID string, physicalID string, physicalPresent bool) {
if s.devices == nil {
writeError(w, http.StatusServiceUnavailable, "device_manager_unavailable", "device manager is unavailable")
return
@@ -326,8 +333,9 @@ func (s *Server) handleEsimSwitch(w http.ResponseWriter, r *http.Request, physic
return
}
var request struct {
ICCID string `json:"iccid"`
AIDHex string `json:"aid_hex"` // accepted for contract compatibility; switching keys off iccid
ICCID string `json:"iccid"`
AIDHex string `json:"aid_hex"`
AIDHexCamel string `json:"aidHex"`
}
if err := s.decodeJSON(w, r, &request); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
@@ -338,14 +346,56 @@ func (s *Server) handleEsimSwitch(w http.ResponseWriter, r *http.Request, physic
writeError(w, http.StatusBadRequest, "invalid_request", "iccid is required")
return
}
// Profile operations run with RF disabled. The eUICC remains accessible in
// CFUN=4, and the recovery path reapplies CFUN=4 as soon as the AT port comes
// back after the mandatory modem reset.
if _, err := s.devices.SetFlight(r.Context(), physicalID, true); err != nil {
s.writeDeviceError(w, err)
return
}
// A confirmed profile switch includes the EC20 reset and a live ICCID read,
// which normally takes longer than the server's ordinary response deadline.
controller := http.NewResponseController(w)
_ = controller.SetWriteDeadline(time.Time{})
if err := s.devices.ESIMSwitchProfile(r.Context(), physicalID, iccid, request.AIDHex); err != nil {
aidHex := firstNonEmpty(request.AIDHex, request.AIDHexCamel)
if err := s.devices.ESIMSwitchProfile(r.Context(), physicalID, iccid, aidHex); err != nil {
s.writeDeviceError(w, err)
return
}
if _, err := s.devices.SetFlight(r.Context(), physicalID, true); err != nil {
s.writeDeviceError(w, err)
return
}
if err := s.store.UpsertCardPolicy(r.Context(), store.CardPolicy{
ICCID: iccid, VoWiFiEnabled: true, AirplaneEnabled: true,
IPVersion: "IPV4V6", Source: "default",
}); err != nil {
s.writeStoreError(w, err)
return
}
config, err := s.store.Device(r.Context(), configuredID)
if err != nil {
s.writeStoreError(w, err)
return
}
config.VoWiFiEnabled = true
config.NetworkEnabled = false
if err := s.store.UpsertDevice(r.Context(), config); err != nil {
s.writeStoreError(w, err)
return
}
if s.vowifi != nil {
state, stateErr := s.vowifi.State(configuredID)
switch {
case stateErr == nil && state.Enabled:
_, err = s.vowifi.RequestReconnect(configuredID)
default:
_, err = s.vowifi.RequestEnabled(configuredID, true)
}
if err != nil {
s.logger.Warn("profile switched in safe airplane mode but VoWiFi start was not queued", "device_id", configuredID, "iccid", iccid, "error", err)
}
}
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"status": "switched", "iccid": iccid, "verified": true}})
}
@@ -359,8 +409,9 @@ func (s *Server) handleEsimDisable(w http.ResponseWriter, r *http.Request, physi
return
}
var request struct {
ICCID string `json:"iccid"`
AIDHex string `json:"aid_hex"` // accepted for the multi-eUICC SPA contract; disabling keys off ICCID
ICCID string `json:"iccid"`
AIDHex string `json:"aid_hex"`
AIDHexCamel string `json:"aidHex"`
}
if err := s.decodeJSON(w, r, &request); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
@@ -371,7 +422,8 @@ func (s *Server) handleEsimDisable(w http.ResponseWriter, r *http.Request, physi
writeError(w, http.StatusBadRequest, "invalid_request", "iccid is required")
return
}
if err := s.devices.ESIMDisableProfile(r.Context(), physicalID, iccid, request.AIDHex); err != nil {
aidHex := firstNonEmpty(request.AIDHex, request.AIDHexCamel)
if err := s.devices.ESIMDisableProfile(r.Context(), physicalID, iccid, aidHex); err != nil {
s.writeDeviceError(w, err)
return
}
+3
View File
@@ -24,6 +24,9 @@ import (
func (s *Server) routeGeneralAPI(w http.ResponseWriter, r *http.Request) bool {
cleanPath := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api"), "/")
if s.routeAutomaticTasksAPI(w, r, cleanPath) {
return true
}
if s.routeExtensionAPI(w, r, cleanPath) {
return true
}
+1
View File
@@ -84,6 +84,7 @@ type Server struct {
netTraffic *liveNetTracker
publicIPMu sync.RWMutex
publicIPs map[string]cachedPublicIP
automaticTasks *automaticTaskScheduler
}
func New(options Options) (*Server, error) {
+15 -16
View File
@@ -1168,11 +1168,11 @@ func (s *Server) liveCardPolicyFlags(ctx context.Context, iccid string) (vowifi,
if !strings.EqualFold(strings.TrimSpace(entry.Snapshot.ICCID), clean) {
continue
}
// VoWiFi deliberately puts the modem into RF-off mode while the SWu/IMS
// path owns service. That physical CFUN state is not the user's separate
// airplane-mode policy; exposing both toggles as enabled is contradictory
// and makes the UI unable to represent the active policy correctly.
return config.VoWiFiEnabled, entry.Snapshot.FlightMode && !config.VoWiFiEnabled, true
// VoWiFi is an RF-off service mode. Surface that fact explicitly: while
// VoWiFi is selected both switches are on, but the airplane switch is
// read-only in the UI. Once VoWiFi is disabled, airplane remains on until
// the user explicitly turns it off.
return config.VoWiFiEnabled, config.VoWiFiEnabled || entry.Snapshot.FlightMode, true
}
return false, false, false
}
@@ -1193,9 +1193,11 @@ func (s *Server) handleCardPolicy(w http.ResponseWriter, r *http.Request, iccid
policy, err := s.store.CardPolicy(r.Context(), iccid)
if errors.Is(err, store.ErrNotFound) {
policy = store.CardPolicy{
ICCID: iccid,
IPVersion: "IPV4V6",
Source: "default",
ICCID: iccid,
VoWiFiEnabled: true,
AirplaneEnabled: true,
IPVersion: "IPV4V6",
Source: "default",
}
} else if err != nil {
s.writeStoreError(w, err)
@@ -1250,14 +1252,11 @@ func (s *Server) handleCardPolicy(w http.ResponseWriter, r *http.Request, iccid
)
return
}
if *request.VoWiFiEnabled && *request.AirplaneEnabled {
writeError(
w,
http.StatusBadRequest,
"invalid_card_policy",
"VoWiFi and airplane mode cannot both be enabled",
)
return
// VoWiFi always owns an RF-off modem. Store airplane=true even when an
// older client omits that implication, so disabling VoWiFi cannot expose a
// brief cellular attach window.
if *request.VoWiFiEnabled {
*request.AirplaneEnabled = true
}
policy := store.CardPolicy{
ICCID: iccid,
+6 -5
View File
@@ -424,7 +424,8 @@ func TestCardPolicyDefaultValidationAndPersistence(t *testing.T) {
response := decodeSettingsResponse(t, recorder)
policy := response["data"].(map[string]any)
if policy["iccid"] != iccid || policy["source"] != "default" ||
policy["ip_version"] != "IPV4V6" {
policy["ip_version"] != "IPV4V6" || policy["vowifi_enabled"] != true ||
policy["airplane_enabled"] != true {
t.Fatalf("default policy = %#v", policy)
}
@@ -434,8 +435,8 @@ func TestCardPolicyDefaultValidationAndPersistence(t *testing.T) {
"/api/cards/"+iccid+"/policy",
`{"vowifi_enabled":true,"airplane_enabled":true,"apn":"ims","ip_version":"IPV4V6"}`,
)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("conflicting policy status = %d, body = %s", recorder.Code, recorder.Body)
if recorder.Code != http.StatusOK {
t.Fatalf("RF-safe policy status = %d, body = %s", recorder.Code, recorder.Body)
}
recorder = test.request(
@@ -450,11 +451,11 @@ func TestCardPolicyDefaultValidationAndPersistence(t *testing.T) {
response = decodeSettingsResponse(t, recorder)
policy = response["data"].(map[string]any)
if policy["source"] != "manual" || policy["vowifi_enabled"] != true ||
policy["ip_version"] != "IPV4V6" {
policy["airplane_enabled"] != true || policy["ip_version"] != "IPV4V6" {
t.Fatalf("saved policy = %#v", policy)
}
stored, err := test.database.CardPolicy(context.Background(), iccid)
if err != nil || !stored.VoWiFiEnabled || stored.APN != "ims" {
if err != nil || !stored.VoWiFiEnabled || !stored.AirplaneEnabled || stored.APN != "ims" {
t.Fatalf("stored policy = %+v, %v", stored, err)
}
File diff suppressed because it is too large Load Diff
+109 -1
View File
@@ -87,6 +87,59 @@ func TestValidTelegramDialNumber(t *testing.T) {
}
}
func TestResolveTelegramPhoneNumberPrefersCurrentSIMAssociation(t *testing.T) {
snapshot := &device.Snapshot{
ICCID: "89441000400128013903",
Phone: device.PhoneNumber{Number: "00000000000"},
}
state := &vowifi.State{
ICCID: snapshot.ICCID,
PhoneNumber: "+447386125520",
}
if got := resolveTelegramPhoneNumber("+447700900123", state, snapshot); got != "+447700900123" {
t.Fatalf("resolved association number = %q", got)
}
if got := resolveTelegramPhoneNumber("", state, snapshot); got != "+447386125520" {
t.Fatalf("resolved IMS number = %q", got)
}
}
func TestResolveTelegramPhoneNumberRejectsPlaceholderAndStaleRuntime(t *testing.T) {
snapshot := &device.Snapshot{
ICCID: "current-card",
Phone: device.PhoneNumber{Number: "00000000000"},
}
state := &vowifi.State{
ICCID: "previous-card",
PhoneNumber: "+447386083638",
}
if got := resolveTelegramPhoneNumber("", state, snapshot); got != "--" {
t.Fatalf("stale or placeholder number leaked as %q", got)
}
for _, placeholder := range []string{"00000000000", "1111111111", "+0000000000", "not-a-number"} {
if usableTelegramPhoneNumber(placeholder) {
t.Errorf("placeholder %q was accepted", placeholder)
}
}
}
func TestTelegramCarrierPresentationSeparatesHomeAndServingNetworks(t *testing.T) {
if got := telegramHomeCarrier("234336570710174"); !strings.Contains(got, "🇬🇧") || !strings.Contains(got, "23433") {
t.Fatalf("home carrier = %q", got)
}
if got := telegramHomeCarrier("204040123456789", "Lebara"); !strings.Contains(got, "Lebara") || !strings.Contains(got, "20404") || !strings.Contains(got, "🇬🇧") || strings.Contains(got, "🇳🇱") {
t.Fatalf("branded foreign-core carrier = %q", got)
}
flight := &device.Snapshot{FlightMode: true, OperatorName: "stale network", RegistrationStatus: 1}
if got := telegramCurrentNetwork(flight); got != "--(飞行模式)" {
t.Fatalf("flight-mode serving network = %q", got)
}
serving := &device.Snapshot{OperatorCode: "46001", RegistrationStatus: 5, AccessTech: "LTE", Band: "B3"}
if got := telegramCurrentNetwork(serving); !strings.Contains(got, "🇨🇳") || !strings.Contains(got, "已驻网(漫游)") {
t.Fatalf("serving network = %q", got)
}
}
func TestTelegramPendingActionIsAuthorizedOneShot(t *testing.T) {
bot := &telegramBot{pending: make(map[string]telegramPendingAction)}
action := telegramPendingAction{Kind: "call", ChatID: -1001, AdminID: 42, CreatedAt: time.Now()}
@@ -102,6 +155,61 @@ func TestTelegramPendingActionIsAuthorizedOneShot(t *testing.T) {
}
}
func TestTelegramMenuCallbackParsing(t *testing.T) {
prefix, token, operation, ok := parseTelegramMenuCallback("call:0123456789abcdef:answer")
if !ok || prefix != "call" || token != "0123456789abcdef" || operation != "answer" {
t.Fatalf("parsed callback = %q %q %q %t", prefix, token, operation, ok)
}
for _, invalid := range []string{"", "call:token", "unknown:token:op", "d::status"} {
if _, _, _, ok := parseTelegramMenuCallback(invalid); ok {
t.Fatalf("invalid callback %q was accepted", invalid)
}
}
}
func TestTelegramMenuPendingCanBeReusedButConfirmationCannot(t *testing.T) {
bot := &telegramBot{pending: make(map[string]telegramPendingAction)}
menuToken, err := bot.putPending(telegramPendingAction{
Kind: "menu_device", DeviceID: "EC20", ChatID: 1, AdminID: 2, CreatedAt: time.Now(),
})
if err != nil {
t.Fatal(err)
}
if _, ok := bot.getPending(menuToken, 1, 2); !ok {
t.Fatal("first menu lookup failed")
}
if _, ok := bot.getPending(menuToken, 1, 2); !ok {
t.Fatal("menu token was unexpectedly consumed")
}
confirmToken, err := bot.putPending(telegramPendingAction{
Kind: "sms", ChatID: 1, AdminID: 2, CreatedAt: time.Now(),
})
if err != nil {
t.Fatal(err)
}
if _, ok := bot.takePending(confirmToken, 1, 2); !ok {
t.Fatal("confirmation token lookup failed")
}
if _, ok := bot.takePending(confirmToken, 1, 2); ok {
t.Fatal("confirmation token was reusable")
}
}
func TestTelegramInputStateIsScopedAndCancelable(t *testing.T) {
bot := &telegramBot{inputs: make(map[string]telegramInputState)}
bot.setInput(telegramInputState{Kind: "sms_phone", DeviceID: "EC20", ChatID: 10, AdminID: 20})
if state, ok := bot.input(10, 20); !ok || state.DeviceID != "EC20" || state.Kind != "sms_phone" {
t.Fatalf("input state = %#v, %t", state, ok)
}
if _, ok := bot.input(10, 21); ok {
t.Fatal("another administrator read the input state")
}
bot.clearInput(10, 20)
if _, ok := bot.input(10, 20); ok {
t.Fatal("cleared input state remained available")
}
}
func TestFormatTelegramATIncludesFinalResult(t *testing.T) {
if got := formatTelegramAT(modem.Response{Final: "OK"}); got != "OK" {
t.Fatalf("formatTelegramAT(OK) = %q", got)
@@ -166,7 +274,7 @@ func TestTelegramExecutesInteractiveUSSDForConfiguredDevice(t *testing.T) {
}
formatted := formatTelegramUSSD("EC20", result)
for _, expected := range []string{
"设备:EC20", "状态:awaiting_input", "1. Balance", "/ussd_reply 0123456789abcdef", "/ussd_cancel 0123456789abcdef",
"设备:EC20", "状态:awaiting_input", "1. Balance", "请直接发送回复内容",
} {
if !strings.Contains(formatted, expected) {
t.Fatalf("USSD result %q does not contain %q", formatted, expected)
+241
View File
@@ -0,0 +1,241 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
)
const automaticTaskSelect = `
SELECT id, name, enabled, device_id, profile_iccid, profile_aid,
task_type, environment, interval_days, start_date, run_time,
timezone, payload_json, retry_count, notify, next_run_at, last_run_at,
last_status, last_error, created_at, updated_at
FROM automatic_tasks`
func (s *Store) SaveAutomaticTask(ctx context.Context, value AutomaticTask) (AutomaticTask, error) {
now := time.Now().UTC()
if strings.TrimSpace(value.Timezone) == "" {
value.Timezone = time.Local.String()
}
if value.CreatedAt.IsZero() {
value.CreatedAt = now
}
value.UpdatedAt = now
if len(value.Payload) == 0 {
value.Payload = []byte(`{}`)
}
if value.ID == 0 {
result, err := s.db.ExecContext(ctx, `INSERT INTO automatic_tasks (
name, enabled, device_id, profile_iccid, profile_aid, task_type,
environment, interval_days, start_date, run_time, timezone, payload_json,
retry_count, notify, next_run_at, last_run_at, last_status,
last_error, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
strings.TrimSpace(value.Name), value.Enabled, strings.TrimSpace(value.DeviceID),
strings.TrimSpace(value.ProfileICCID), strings.TrimSpace(value.ProfileAID),
value.TaskType, value.Environment, value.IntervalDays, value.StartDate,
value.RunTime, value.Timezone, string(value.Payload), value.RetryCount, value.Notify,
value.NextRunAt.Unix(), unixOrZero(value.LastRunAt), value.LastStatus,
value.LastError, value.CreatedAt.Unix(), value.UpdatedAt.Unix())
if err != nil {
return AutomaticTask{}, fmt.Errorf("create automatic task: %w", err)
}
value.ID, _ = result.LastInsertId()
} else {
result, err := s.db.ExecContext(ctx, `UPDATE automatic_tasks SET
name = ?, enabled = ?, device_id = ?, profile_iccid = ?, profile_aid = ?,
task_type = ?, environment = ?, interval_days = ?, start_date = ?,
run_time = ?, timezone = ?, payload_json = ?, retry_count = ?, notify = ?,
next_run_at = ?, updated_at = ? WHERE id = ?`,
strings.TrimSpace(value.Name), value.Enabled, strings.TrimSpace(value.DeviceID),
strings.TrimSpace(value.ProfileICCID), strings.TrimSpace(value.ProfileAID),
value.TaskType, value.Environment, value.IntervalDays, value.StartDate,
value.RunTime, value.Timezone, string(value.Payload), value.RetryCount, value.Notify,
value.NextRunAt.Unix(), value.UpdatedAt.Unix(), value.ID)
if err != nil {
return AutomaticTask{}, fmt.Errorf("update automatic task %d: %w", value.ID, err)
}
if count, _ := result.RowsAffected(); count == 0 {
return AutomaticTask{}, ErrNotFound
}
}
return s.AutomaticTask(ctx, value.ID)
}
func (s *Store) AutomaticTask(ctx context.Context, id int64) (AutomaticTask, error) {
return scanAutomaticTask(s.db.QueryRowContext(ctx, automaticTaskSelect+` WHERE id = ?`, id))
}
func (s *Store) ListAutomaticTasks(ctx context.Context) ([]AutomaticTask, error) {
rows, err := s.db.QueryContext(ctx, automaticTaskSelect+` ORDER BY created_at DESC, id DESC`)
if err != nil {
return nil, fmt.Errorf("list automatic tasks: %w", err)
}
defer rows.Close()
var result []AutomaticTask
for rows.Next() {
value, scanErr := scanAutomaticTask(rows)
if scanErr != nil {
return nil, scanErr
}
result = append(result, value)
}
return result, rows.Err()
}
func (s *Store) DeleteAutomaticTask(ctx context.Context, id int64) error {
result, err := s.db.ExecContext(ctx, `DELETE FROM automatic_tasks WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete automatic task %d: %w", id, err)
}
if count, _ := result.RowsAffected(); count == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) ClaimDueAutomaticTasks(ctx context.Context, now time.Time, limit int) ([]AutomaticTaskRun, error) {
if limit <= 0 || limit > 100 {
limit = 50
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
rows, err := tx.QueryContext(ctx, automaticTaskSelect+`
WHERE enabled = 1 AND next_run_at <= ? ORDER BY next_run_at, id LIMIT ?`, now.Unix(), limit)
if err != nil {
return nil, err
}
var tasks []AutomaticTask
for rows.Next() {
task, scanErr := scanAutomaticTask(rows)
if scanErr != nil {
rows.Close()
return nil, scanErr
}
tasks = append(tasks, task)
}
rows.Close()
result := make([]AutomaticTaskRun, 0, len(tasks))
for _, task := range tasks {
next := task.NextRunAt
location := time.Local
if loaded, loadErr := time.LoadLocation(task.Timezone); loadErr == nil {
location = loaded
}
for !next.After(now) {
next = next.In(location).AddDate(0, 0, task.IntervalDays).UTC()
}
if _, err = tx.ExecContext(ctx, `UPDATE automatic_tasks SET next_run_at = ?, updated_at = ? WHERE id = ?`, next.Unix(), now.Unix(), task.ID); err != nil {
return nil, err
}
created, createErr := tx.ExecContext(ctx, `INSERT INTO automatic_task_runs (
task_id, device_id, scheduled_at, status, created_at, updated_at
) VALUES (?, ?, ?, 'queued', ?, ?)`, task.ID, task.DeviceID, task.NextRunAt.Unix(), now.Unix(), now.Unix())
if createErr != nil {
return nil, createErr
}
runID, _ := created.LastInsertId()
result = append(result, AutomaticTaskRun{ID: runID, TaskID: task.ID, DeviceID: task.DeviceID, ScheduledAt: task.NextRunAt, Status: "queued", CreatedAt: now, UpdatedAt: now})
}
if err := tx.Commit(); err != nil {
return nil, err
}
return result, nil
}
func (s *Store) QueueAutomaticTaskNow(ctx context.Context, task AutomaticTask) (AutomaticTaskRun, error) {
now := time.Now().UTC()
result, err := s.db.ExecContext(ctx, `INSERT INTO automatic_task_runs (
task_id, device_id, scheduled_at, status, created_at, updated_at
) VALUES (?, ?, ?, 'queued', ?, ?)`, task.ID, task.DeviceID, now.Unix(), now.Unix(), now.Unix())
if err != nil {
return AutomaticTaskRun{}, fmt.Errorf("queue automatic task: %w", err)
}
id, _ := result.LastInsertId()
return AutomaticTaskRun{ID: id, TaskID: task.ID, DeviceID: task.DeviceID, ScheduledAt: now, Status: "queued", CreatedAt: now, UpdatedAt: now}, nil
}
func (s *Store) UpdateAutomaticTaskRun(ctx context.Context, run AutomaticTaskRun) error {
now := time.Now().UTC()
_, err := s.db.ExecContext(ctx, `UPDATE automatic_task_runs SET
started_at = ?, finished_at = ?, status = ?, attempts = ?, output = ?, error = ?, updated_at = ?
WHERE id = ?`, unixOrZero(run.StartedAt), unixOrZero(run.FinishedAt), run.Status,
run.Attempts, run.Output, run.Error, now.Unix(), run.ID)
if err != nil {
return fmt.Errorf("update automatic task run %d: %w", run.ID, err)
}
if run.Status == "success" || run.Status == "failed" {
_, err = s.db.ExecContext(ctx, `UPDATE automatic_tasks SET
last_run_at = ?, last_status = ?, last_error = ?, updated_at = ? WHERE id = ?`,
run.FinishedAt.Unix(), run.Status, run.Error, now.Unix(), run.TaskID)
}
return err
}
func (s *Store) ListAutomaticTaskRuns(ctx context.Context, limit int) ([]AutomaticTaskRun, error) {
if limit <= 0 || limit > 500 {
limit = 100
}
rows, err := s.db.QueryContext(ctx, `SELECT id, task_id, device_id, scheduled_at,
started_at, finished_at, status, attempts, output, error, created_at, updated_at
FROM automatic_task_runs ORDER BY id DESC LIMIT ?`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var result []AutomaticTaskRun
for rows.Next() {
var value AutomaticTaskRun
var scheduled, started, finished, created, updated int64
if err := rows.Scan(&value.ID, &value.TaskID, &value.DeviceID, &scheduled, &started,
&finished, &value.Status, &value.Attempts, &value.Output, &value.Error, &created, &updated); err != nil {
return nil, err
}
value.ScheduledAt, value.StartedAt, value.FinishedAt = time.Unix(scheduled, 0).UTC(), timeFromUnix(started), timeFromUnix(finished)
value.CreatedAt, value.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC()
result = append(result, value)
}
return result, rows.Err()
}
func scanAutomaticTask(row rowScanner) (AutomaticTask, error) {
var value AutomaticTask
var enabled, notify bool
var payload string
var nextRun, lastRun, created, updated int64
if err := row.Scan(&value.ID, &value.Name, &enabled, &value.DeviceID, &value.ProfileICCID,
&value.ProfileAID, &value.TaskType, &value.Environment, &value.IntervalDays,
&value.StartDate, &value.RunTime, &value.Timezone, &payload, &value.RetryCount, &notify,
&nextRun, &lastRun, &value.LastStatus, &value.LastError, &created, &updated); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return AutomaticTask{}, ErrNotFound
}
return AutomaticTask{}, err
}
value.Enabled, value.Notify = enabled, notify
value.Payload = []byte(payload)
value.NextRunAt, value.LastRunAt = time.Unix(nextRun, 0).UTC(), timeFromUnix(lastRun)
value.CreatedAt, value.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC()
return value, nil
}
func unixOrZero(value time.Time) int64 {
if value.IsZero() {
return 0
}
return value.Unix()
}
func timeFromUnix(value int64) time.Time {
if value <= 0 {
return time.Time{}
}
return time.Unix(value, 0).UTC()
}
+72
View File
@@ -0,0 +1,72 @@
package store
import (
"context"
"encoding/json"
"path/filepath"
"testing"
"time"
)
func TestAutomaticTasksAreClaimedInDeviceQueueOrderAndAdvanceSchedule(t *testing.T) {
ctx := context.Background()
database := openTestStore(t, filepath.Join(t.TempDir(), "automatic-tasks.db"))
mustSaveDevice(t, database, "ec20", "EC20")
now := time.Now().UTC().Truncate(time.Second)
for index := 0; index < 2; index++ {
payload, _ := json.Marshal(map[string]any{"phone": "10086", "message": "test"})
if _, err := database.SaveAutomaticTask(ctx, AutomaticTask{
Name: "task", Enabled: true, DeviceID: "ec20", ProfileICCID: "8944100000000000000",
TaskType: "sms", Environment: "vowifi", IntervalDays: 2,
StartDate: "2026-08-10", RunTime: "12:00", Timezone: "Asia/Shanghai", Payload: payload,
NextRunAt: now.Add(time.Duration(index-2) * time.Minute),
}); err != nil {
t.Fatal(err)
}
}
runs, err := database.ClaimDueAutomaticTasks(ctx, now, 10)
if err != nil {
t.Fatal(err)
}
if len(runs) != 2 || runs[0].DeviceID != "ec20" || runs[1].DeviceID != "ec20" || runs[0].TaskID >= runs[1].TaskID {
t.Fatalf("claimed runs = %+v", runs)
}
tasks, err := database.ListAutomaticTasks(ctx)
if err != nil {
t.Fatal(err)
}
for _, task := range tasks {
if !task.NextRunAt.After(now) {
t.Fatalf("task %d next run was not advanced: %v", task.ID, task.NextRunAt)
}
}
second, err := database.ClaimDueAutomaticTasks(ctx, now, 10)
if err != nil || len(second) != 0 {
t.Fatalf("same schedule claimed twice: %+v, %v", second, err)
}
}
func TestDeletingAutomaticTaskRemovesRunHistory(t *testing.T) {
ctx := context.Background()
database := openTestStore(t, filepath.Join(t.TempDir(), "automatic-task-delete.db"))
mustSaveDevice(t, database, "ec20", "EC20")
task, err := database.SaveAutomaticTask(ctx, AutomaticTask{
Name: "task", Enabled: true, DeviceID: "ec20", ProfileICCID: "one",
TaskType: "call", Environment: "cellular", IntervalDays: 1,
StartDate: "2026-08-10", RunTime: "12:00", Timezone: "Asia/Shanghai", Payload: []byte(`{"phone":"10086","duration_seconds":10}`),
NextRunAt: time.Now().Add(time.Hour),
})
if err != nil {
t.Fatal(err)
}
if _, err := database.QueueAutomaticTaskNow(ctx, task); err != nil {
t.Fatal(err)
}
if err := database.DeleteAutomaticTask(ctx, task.ID); err != nil {
t.Fatal(err)
}
runs, err := database.ListAutomaticTaskRuns(ctx, 10)
if err != nil || len(runs) != 0 {
t.Fatalf("orphan runs = %+v, %v", runs, err)
}
}
+44 -3
View File
@@ -105,6 +105,43 @@ func TestMigration7BackfillsSMSModemIMEI(t *testing.T) {
}
}
func TestMigration9NormalizesVoWiFiAirplanePolicy(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "rf-safe-policy.db")
raw, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
for version := 1; version <= 8; version++ {
for _, statement := range migrationStatements(version) {
if _, err := raw.ExecContext(ctx, statement); err != nil {
t.Fatalf("create v%d schema: %v", version, err)
}
}
}
if _, err := raw.ExecContext(ctx, `
INSERT INTO card_policies (
iccid, network_enabled, vowifi_enabled, airplane_enabled,
created_at, updated_at
) VALUES ('8900000000000000001', 0, 1, 0, 100, 100);
PRAGMA user_version = 8;
`); err != nil {
t.Fatal(err)
}
if err := raw.Close(); err != nil {
t.Fatal(err)
}
database := openTestStore(t, path)
policy, err := database.CardPolicy(ctx, "8900000000000000001")
if err != nil {
t.Fatal(err)
}
if !policy.VoWiFiEnabled || !policy.AirplaneEnabled || policy.NetworkEnabled {
t.Fatalf("migrated policy = %#v, want VoWiFi+airplane with data off", policy)
}
}
func TestMigration8DefaultsExistingDevicesToPCIeType(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "device-type.db")
@@ -709,14 +746,18 @@ func TestEventsPoliciesAndTraffic(t *testing.T) {
t.Fatal(err)
}
if err := database.UpsertCardPolicy(ctx, CardPolicy{
ICCID: "invalid", VoWiFiEnabled: true, AirplaneEnabled: true,
}); err == nil {
t.Fatal("invalid mutually exclusive card policy was accepted")
ICCID: "89860002", VoWiFiEnabled: true, AirplaneEnabled: true,
}); err != nil {
t.Fatalf("RF-safe VoWiFi policy was rejected: %v", err)
}
policy, err := database.CardPolicy(ctx, "89860001")
if err != nil || !policy.VoWiFiEnabled {
t.Fatalf("CardPolicy() = %+v, %v", policy, err)
}
safePolicy, err := database.CardPolicy(ctx, "89860002")
if err != nil || !safePolicy.VoWiFiEnabled || !safePolicy.AirplaneEnabled {
t.Fatalf("safe CardPolicy() = %+v, %v", safePolicy, err)
}
period := old.Truncate(time.Hour)
if err := database.UpsertTrafficBucket(ctx, TrafficBucket{
+75
View File
@@ -111,6 +111,81 @@ func migrationStatements(version int) []string {
`ALTER TABLE devices
ADD COLUMN device_type TEXT NOT NULL DEFAULT 'pcie_ec20_ec25'`,
}
case 9:
return []string{
// VoWiFi deliberately owns airplane mode. Earlier schemas treated
// these flags as mutually exclusive, which made the RF-safe state
// impossible to persist. Rebuild the table without changing rows.
`ALTER TABLE card_policies RENAME TO card_policies_v8`,
`CREATE TABLE card_policies (
iccid TEXT PRIMARY KEY,
network_enabled INTEGER NOT NULL DEFAULT 0 CHECK (network_enabled IN (0, 1)),
vowifi_enabled INTEGER NOT NULL DEFAULT 0 CHECK (vowifi_enabled IN (0, 1)),
airplane_enabled INTEGER NOT NULL DEFAULT 0 CHECK (airplane_enabled IN (0, 1)),
apn TEXT NOT NULL DEFAULT '',
ip_version TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)`,
`INSERT INTO card_policies (
iccid, network_enabled, vowifi_enabled, airplane_enabled,
apn, ip_version, source, created_at, updated_at
) SELECT
iccid, network_enabled, vowifi_enabled, airplane_enabled,
apn, ip_version, source, created_at, updated_at
FROM card_policies_v8`,
`UPDATE card_policies
SET airplane_enabled = 1, network_enabled = 0
WHERE vowifi_enabled = 1`,
`DROP TABLE card_policies_v8`,
}
case 10:
return []string{
`CREATE TABLE IF NOT EXISTS automatic_tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
device_id TEXT NOT NULL,
profile_iccid TEXT NOT NULL,
profile_aid TEXT NOT NULL DEFAULT '',
task_type TEXT NOT NULL CHECK (task_type IN ('sms', 'call', 'public_ip')),
environment TEXT NOT NULL CHECK (environment IN ('vowifi', 'cellular')),
interval_days INTEGER NOT NULL CHECK (interval_days BETWEEN 1 AND 365),
start_date TEXT NOT NULL,
run_time TEXT NOT NULL,
timezone TEXT NOT NULL DEFAULT 'Local',
payload_json TEXT NOT NULL DEFAULT '{}',
retry_count INTEGER NOT NULL DEFAULT 0 CHECK (retry_count BETWEEN 0 AND 10),
notify INTEGER NOT NULL DEFAULT 0 CHECK (notify IN (0, 1)),
next_run_at INTEGER NOT NULL,
last_run_at INTEGER NOT NULL DEFAULT 0,
last_status TEXT NOT NULL DEFAULT '',
last_error TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE
)`,
`CREATE INDEX IF NOT EXISTS automatic_tasks_due_idx ON automatic_tasks(enabled, next_run_at, id)`,
`CREATE INDEX IF NOT EXISTS automatic_tasks_device_idx ON automatic_tasks(device_id, next_run_at, id)`,
`CREATE TABLE IF NOT EXISTS automatic_task_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL,
device_id TEXT NOT NULL,
scheduled_at INTEGER NOT NULL,
started_at INTEGER NOT NULL DEFAULT 0,
finished_at INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL CHECK (status IN ('queued', 'running', 'success', 'failed')),
attempts INTEGER NOT NULL DEFAULT 0,
output TEXT NOT NULL DEFAULT '',
error TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (task_id) REFERENCES automatic_tasks(id) ON DELETE CASCADE
)`,
`CREATE INDEX IF NOT EXISTS automatic_task_runs_task_idx ON automatic_task_runs(task_id, id DESC)`,
`CREATE INDEX IF NOT EXISTS automatic_task_runs_status_idx ON automatic_task_runs(status, id)`,
}
default:
return nil
}
+39
View File
@@ -124,6 +124,45 @@ type PhoneAssociation struct {
UpdatedAt time.Time
}
type AutomaticTask struct {
ID int64 `json:"id"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
DeviceID string `json:"device_id"`
ProfileICCID string `json:"profile_iccid"`
ProfileAID string `json:"profile_aid"`
TaskType string `json:"task_type"`
Environment string `json:"environment"`
IntervalDays int `json:"interval_days"`
StartDate string `json:"start_date"`
RunTime string `json:"run_time"`
Timezone string `json:"timezone"`
Payload json.RawMessage `json:"payload"`
RetryCount int `json:"retry_count"`
Notify bool `json:"notify"`
NextRunAt time.Time `json:"next_run_at"`
LastRunAt time.Time `json:"last_run_at,omitempty"`
LastStatus string `json:"last_status"`
LastError string `json:"last_error"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type AutomaticTaskRun struct {
ID int64 `json:"id"`
TaskID int64 `json:"task_id"`
DeviceID string `json:"device_id"`
ScheduledAt time.Time `json:"scheduled_at"`
StartedAt time.Time `json:"started_at,omitempty"`
FinishedAt time.Time `json:"finished_at,omitempty"`
Status string `json:"status"`
Attempts int `json:"attempts"`
Output string `json:"output"`
Error string `json:"error"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type SMSMessage struct {
ID int64
MessageID string
-3
View File
@@ -369,9 +369,6 @@ func (s *Store) UpsertCardPolicy(ctx context.Context, value CardPolicy) error {
default:
return fmt.Errorf("unsupported card policy IP version %q", value.IPVersion)
}
if value.VoWiFiEnabled && value.AirplaneEnabled {
return errors.New("VoWiFi and airplane mode cannot both be enabled")
}
now := time.Now().UTC()
createdAt := value.CreatedAt
if createdAt.IsZero() {
+1 -1
View File
@@ -13,7 +13,7 @@ import (
_ "modernc.org/sqlite"
)
const schemaVersion = 8
const schemaVersion = 10
var ErrNotFound = errors.New("store: not found")
+31 -9
View File
@@ -222,14 +222,29 @@ func (adapter *EC20Adapter) readHomePLMN(
}
// Exact assigned HPLMN prefixes are data, not an MNC-length heuristic. The
// target Vodafone UK SIM is 234/15. Unknown assignments remain fail-closed.
for prefix, mncLength := range map[string]int{"23415": 2} {
if strings.HasPrefix(imsi, prefix) {
return imsi[:3], imsi[3 : 3+mncLength], nil
}
if mcc, mnc, ok := assignedHomePLMN(imsi); ok {
return mcc, mnc, nil
}
return "", "", efErr
}
func assignedHomePLMN(imsi string) (mcc, mnc string, ok bool) {
assignments := []struct {
prefix string
mncLength int
}{
{prefix: "20404", mncLength: 2}, // Vodafone NL core; some Lebara subscriptions.
{prefix: "23415", mncLength: 2}, // Vodafone UK.
{prefix: "23487", mncLength: 2}, // Lebara Mobile UK.
}
for _, assignment := range assignments {
if strings.HasPrefix(imsi, assignment.prefix) {
return imsi[:3], imsi[3 : 3+assignment.mncLength], true
}
}
return "", "", false
}
func validConfiguredHomePLMN(imsi, mcc, mnc string) bool {
if !validDigits(mcc, 3, 3) || !validDigits(mnc, 2, 3) {
return false
@@ -658,15 +673,22 @@ func (adapter *EC20Adapter) Restore(
if snapshot.OperatingMode < 0 {
return errors.New("vocat: invalid EC20 radio snapshot")
}
targetMode := snapshot.OperatingMode
if snapshot.PureAirplanePolicy {
// VoWiFi teardown is intentionally fail-closed. Even if the modem was in
// CFUN=1 before setup, disabling VoWiFi must leave RF off until the user
// explicitly turns airplane mode off through the separate control.
targetMode = 4
}
currentMode, err := adapter.readOperatingMode(ctx, deviceID)
if err != nil {
return err
}
if currentMode != snapshot.OperatingMode {
if currentMode != targetMode {
if _, err := adapter.execute(
ctx,
deviceID,
fmt.Sprintf("AT+CFUN=%d", snapshot.OperatingMode),
fmt.Sprintf("AT+CFUN=%d", targetMode),
); err != nil {
return fmt.Errorf("restore EC20 operating mode: %w", err)
}
@@ -675,11 +697,11 @@ func (adapter *EC20Adapter) Restore(
if err != nil {
return err
}
if currentMode != snapshot.OperatingMode {
if currentMode != targetMode {
return fmt.Errorf(
"vocat: EC20 restore reported CFUN=%d, expected %d",
currentMode,
snapshot.OperatingMode,
targetMode,
)
}
@@ -697,7 +719,7 @@ func (adapter *EC20Adapter) Restore(
return errors.New("vocat: EC20 snapshot has active data in RF-off mode")
}
desiredCIDs := checkpoint.activeCIDs
if !adapter.options.RestoreCellularData {
if !adapter.options.RestoreCellularData || targetMode == 0 || targetMode == 4 {
desiredCIDs = nil
}
if err := adapter.reconcileActiveCIDs(
+16 -4
View File
@@ -416,6 +416,20 @@ func TestEC20AdapterReadsExplicitHomePLMNAndKnownAssignmentFallback(
}
}
func TestAssignedHomePLMNIncludesLebaraUKCores(t *testing.T) {
tests := map[string]string{
"204040123456789": "204/04",
"234150123456789": "234/15",
"234870123456789": "234/87",
}
for imsi, want := range tests {
mcc, mnc, ok := assignedHomePLMN(imsi)
if got := mcc + "/" + mnc; !ok || got != want {
t.Errorf("assignedHomePLMN(%q) = %q, %v; want %q", imsi, got, ok, want)
}
}
}
func TestEC20AdapterRadioTransactionRestoresCFUNAndPDPContexts(
t *testing.T,
) {
@@ -440,16 +454,14 @@ func TestEC20AdapterRadioTransactionRestoresCFUNAndPDPContexts(
lines: []string{"+CGACT: 1,0", "+CGACT: 2,0"},
},
{command: "AT+CFUN?", lines: []string{"+CFUN: 4"}},
{command: "AT+CFUN=1"},
{command: "AT+CFUN?", lines: []string{"+CFUN: 1"}},
{command: "AT+CFUN?", lines: []string{"+CFUN: 4"}},
{
command: "AT+CGACT?",
lines: []string{"+CGACT: 1,0", "+CGACT: 2,0"},
},
{command: "AT+CGACT=1,1"},
{
command: "AT+CGACT?",
lines: []string{"+CGACT: 1,1", "+CGACT: 2,0"},
lines: []string{"+CGACT: 1,0", "+CGACT: 2,0"},
},
},
}
+10 -1
View File
@@ -110,7 +110,7 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques
}()
group := uint16(dhMODP2048)
legacyFirst := request.Identity.HomeMCC == "234" && request.Identity.HomeMNC == "15"
legacyFirst := legacyIKEProfile(request.Identity.HomeMCC, request.Identity.HomeMNC)
if legacyFirst {
group = dhMODP1024
}
@@ -544,6 +544,15 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques
return session, nil
}
func legacyIKEProfile(mcc, mnc string) bool {
// Vodafone's UK and Netherlands ePDGs use the legacy group-2/SHA-1-first
// proposal ordering. Some Lebara UK subscriptions carry a 204-04 IMSI from
// that Vodafone NL core; treating them as a generic modern network causes
// IKE_SA_INIT to fail before EAP-AKA even begins.
plmn := strings.TrimSpace(mcc) + strings.TrimLeft(strings.TrimSpace(mnc), "0")
return plmn == "23415" || plmn == "2044"
}
func buildInitialEAPOnlyAuth(
idi payload,
requestedIDr payload,
+18
View File
@@ -16,6 +16,24 @@ var errFirstAuthObserved = errors.New("test: first IKE_AUTH observed")
type constantReader struct{ value byte }
func TestLegacyIKEProfileIncludesVodafoneHostedLebaraCore(t *testing.T) {
for _, item := range []struct {
mcc string
mnc string
}{
{mcc: "234", mnc: "15"},
{mcc: "204", mnc: "04"},
{mcc: "204", mnc: "004"},
} {
if !legacyIKEProfile(item.mcc, item.mnc) {
t.Errorf("legacyIKEProfile(%q, %q) = false", item.mcc, item.mnc)
}
}
if legacyIKEProfile("234", "87") {
t.Fatal("Lebara's 234-87 core must use the modern IKE profile")
}
}
func (reader constantReader) Read(destination []byte) (int, error) {
for index := range destination {
destination[index] = reader.value
+42 -18
View File
@@ -63,33 +63,57 @@ func (mapper ATMapper) resolve(
if mapper.Store == nil || mapper.Devices == nil {
return "", errors.New("vowifi AT mapper is not configured")
}
if entry, err := mapper.Devices.Get(configuredID); err == nil && entry.Discovered {
return entry.ID, nil
}
config, err := mapper.Store.Device(ctx, configuredID)
if err != nil {
return "", err
}
// Score every physical candidate before choosing one. Returning the first
// partial match made two EC20s on the same hub vulnerable to iteration order:
// a stale USB path on one configured row could win before the other
// candidate's AT port, QMI node, or live IMEI was considered. The result was
// two logical devices issuing APDUs to the same SIM.
bestID := ""
bestScore := 0
for _, entry := range mapper.Devices.List() {
if !entry.Discovered {
continue
}
candidate := entry.Candidate
switch {
case config.ATPort != "" &&
(config.ATPort == candidate.ATPort.Path ||
config.ATPort == candidate.ATPort.OpenPath()):
return entry.ID, nil
case config.USBPath != "" && config.USBPath == candidate.USBPath:
return entry.ID, nil
case config.ControlDevice != "" &&
(config.ControlDevice == candidate.QMIControl ||
config.ControlDevice == candidate.ATPort.OpenPath()):
return entry.ID, nil
case config.ModemIMEI != "" && entry.Snapshot != nil &&
config.ModemIMEI == strings.TrimSpace(entry.Snapshot.IMEI):
return entry.ID, nil
score := physicalMatchScore(configuredID, config, entry)
if score > bestScore {
bestID = entry.ID
bestScore = score
}
}
if bestID != "" {
return bestID, nil
}
return "", device.ErrNotFound
}
func physicalMatchScore(configuredID string, config store.Device, entry device.Device) int {
candidate := entry.Candidate
score := 0
// A live modem identity is the strongest evidence and must override stale
// Linux node names or a USB topology saved before devices were rearranged.
if config.ModemIMEI != "" && entry.Snapshot != nil &&
strings.EqualFold(strings.TrimSpace(config.ModemIMEI), strings.TrimSpace(entry.Snapshot.IMEI)) {
score += 10000
}
if config.ATPort != "" &&
(config.ATPort == candidate.ATPort.Path || config.ATPort == candidate.ATPort.OpenPath()) {
score += 300
}
if config.ControlDevice != "" &&
(config.ControlDevice == candidate.QMIControl || config.ControlDevice == candidate.ATPort.OpenPath()) {
score += 300
}
if config.USBPath != "" && config.USBPath == candidate.USBPath {
score += 100
}
// Discovery IDs are not persistent user IDs. Treat an exact text match only
// as a weak hint so it cannot override physical identity evidence.
if entry.ID == configuredID {
score += 25
}
return score
}
+85
View File
@@ -87,3 +87,88 @@ func TestATMapperResolvesConfiguredIDByStableATPath(t *testing.T) {
t.Fatalf("ExecuteSensitiveAT physical ID = %q", devices.sensitiveID)
}
}
func TestATMapperScoresAllCandidatesBeforeUsingStaleUSBPath(t *testing.T) {
database := testStore(t)
if err := database.UpsertDevice(context.Background(), store.Device{
ID: "ec20_1",
Name: "EC20 1",
ATPort: "/dev/ttyUSB2",
ControlDevice: "/dev/cdc-wdm0",
// Simulate metadata left from a formerly swapped hub mapping.
USBPath: "/sys/bus/usb/devices/1-6",
ModemIMEI: "111111111111111",
}); err != nil {
t.Fatal(err)
}
devices := &fakeATDevices{entries: []device.Device{
{
ID: "quectel-0125-1-6",
Discovered: true,
Candidate: modem.Candidate{
USBPath: "/sys/bus/usb/devices/1-6",
QMIControl: "/dev/cdc-wdm1",
ATPort: modem.Port{Path: "/dev/ttyUSB6"},
},
},
{
ID: "quectel-0306-1-5",
Discovered: true,
Candidate: modem.Candidate{
USBPath: "/sys/bus/usb/devices/1-5",
QMIControl: "/dev/cdc-wdm0",
ATPort: modem.Port{Path: "/dev/ttyUSB2"},
},
},
}}
mapper := ATMapper{Store: database, Devices: devices}
if _, err := mapper.ExecuteAT(context.Background(), "ec20_1", "AT+CIMI"); err != nil {
t.Fatal(err)
}
if devices.executedID != "quectel-0306-1-5" {
t.Fatalf("ExecuteAT physical ID = %q, want coherent AT/QMI candidate", devices.executedID)
}
}
func TestATMapperPrefersLiveIMEIOverAllStalePaths(t *testing.T) {
database := testStore(t)
if err := database.UpsertDevice(context.Background(), store.Device{
ID: "ec20_1",
Name: "EC20 1",
ATPort: "/dev/ttyUSB2",
ControlDevice: "/dev/cdc-wdm0",
USBPath: "/sys/bus/usb/devices/1-5",
ModemIMEI: "222222222222222",
}); err != nil {
t.Fatal(err)
}
devices := &fakeATDevices{entries: []device.Device{
{
ID: "old-paths",
Discovered: true,
Candidate: modem.Candidate{
USBPath: "/sys/bus/usb/devices/1-5",
QMIControl: "/dev/cdc-wdm0",
ATPort: modem.Port{Path: "/dev/ttyUSB2"},
},
Snapshot: &device.Snapshot{IMEI: "111111111111111"},
},
{
ID: "live-imei",
Discovered: true,
Candidate: modem.Candidate{
USBPath: "/sys/bus/usb/devices/2-3",
QMIControl: "/dev/cdc-wdm4",
ATPort: modem.Port{Path: "/dev/ttyUSB10"},
},
Snapshot: &device.Snapshot{IMEI: "222222222222222"},
},
}}
mapper := ATMapper{Store: database, Devices: devices}
if _, err := mapper.ExecuteAT(context.Background(), "ec20_1", "AT+CIMI"); err != nil {
t.Fatal(err)
}
if devices.executedID != "live-imei" {
t.Fatalf("ExecuteAT physical ID = %q, want live IMEI candidate", devices.executedID)
}
}
+92 -43
View File
@@ -106,8 +106,8 @@ func (orchestrator *Orchestrator) Subscribe(buffer int) (<-chan State, func()) {
}
// Enable executes one evidence-backed transaction. The order intentionally
// follows the working Linux/QMI path: live identity and home PLMN, AKA
// availability, ePDG derivation, runtime-owned RF off, cellular-data stop,
// follows the working Linux/QMI path: snapshot and disable cellular RF first,
// then read the live identity/home PLMN, verify AKA availability, derive ePDG,
// country proxy resolution, SWu tunnel, IMS registration, and SMS readiness.
func (orchestrator *Orchestrator) Enable(ctx context.Context) (State, error) {
if ctx == nil {
@@ -143,20 +143,37 @@ func (orchestrator *Orchestrator) Enable(ctx context.Context) (State, error) {
}
})
// A failed attempt deliberately retains the original radio checkpoint and
// keeps CFUN=4. Automatic retries must rebuild only the Wi-Fi/IKE/IMS layers;
// restoring CFUN=1 between attempts can briefly register on a visited network
// and trigger roaming/welcome SMS messages. Explicit Disable is the only path
// that restores the pre-VoWiFi radio mode.
orchestrator.mu.Lock()
retained := orchestrator.resources
orchestrator.mu.Unlock()
runtimeContext, runtimeCancel := context.WithCancel(context.Background())
resources := &runtimeResources{cancel: runtimeCancel}
if current.Phase == PhaseFailed && retained != nil && retained.radioChanged {
resources.radio = retained.radio
resources.radioChanged = true
}
orchestrator.mu.Lock()
orchestrator.resources = resources
orchestrator.mu.Unlock()
setupContext, stopSetup := mergedContext(ctx, runtimeContext)
defer stopSetup()
var err error
fail := func(stage Phase, cause error) (State, error) {
runtimeCancel()
cleanupErrors := orchestrator.cleanup(resources)
cleanupErrors := orchestrator.cleanupSessions(resources)
orchestrator.mu.Lock()
orchestrator.resources = nil
if resources.radioChanged {
orchestrator.resources = resources
} else {
orchestrator.resources = nil
}
orchestrator.mu.Unlock()
orchestrator.mutate(func(state *State) {
@@ -181,6 +198,28 @@ func (orchestrator *Orchestrator) Enable(ctx context.Context) (State, error) {
return orchestrator.State(), stageError
}
if !resources.radioChanged {
resources.radio, err = orchestrator.deps.Radio.Snapshot(setupContext, orchestrator.options.DeviceID)
if err != nil {
return fail(PhaseAccessReady, err)
}
orchestrator.mutate(func(state *State) {
state.PureAirplanePolicy = resources.radio.PureAirplanePolicy
})
// Mark the transaction before the mutating call: a provider may return
// an error after partially changing the modem.
resources.radioChanged = true
}
// RF-off is established before any SIM/AKA probing. Those operations are
// local UICC APDUs and remain available in CFUN=4; no serving-cell attach is
// required or permitted during VoWiFi setup.
if err := orchestrator.deps.Radio.EnterVoWiFiRFOff(setupContext, orchestrator.options.DeviceID); err != nil {
return fail(PhaseAccessReady, err)
}
if err := orchestrator.deps.Radio.StopCellularData(setupContext, orchestrator.options.DeviceID); err != nil {
return fail(PhaseAccessReady, err)
}
identity, err := orchestrator.deps.SIM.ReadIdentity(setupContext, orchestrator.options.DeviceID)
if err != nil {
return fail(PhaseSIMReady, err)
@@ -216,29 +255,6 @@ func (orchestrator *Orchestrator) Enable(ctx context.Context) (State, error) {
if err != nil {
return fail(PhaseAccessReady, err)
}
resources.radio, err = orchestrator.deps.Radio.Snapshot(setupContext, orchestrator.options.DeviceID)
if err != nil {
return fail(PhaseAccessReady, err)
}
orchestrator.mutate(func(state *State) {
state.PureAirplanePolicy = resources.radio.PureAirplanePolicy
})
// Mark the radio transaction before the first mutating call: a provider
// may return an error after partially changing the modem.
resources.radioChanged = true
// Enter RF-off before reconciling PDP contexts. Some QMI-capable EC20
// firmware automatically owns CID 1 while CFUN=1 and rejects a direct
// CGACT=0 command even though the Linux data interface is down. CFUN=4
// tears down packet service at the baseband; StopCellularData then acts as
// a fail-closed verification and removes any context that unexpectedly
// survived RF-off.
if err := orchestrator.deps.Radio.EnterVoWiFiRFOff(setupContext, orchestrator.options.DeviceID); err != nil {
return fail(PhaseAccessReady, err)
}
if err := orchestrator.deps.Radio.StopCellularData(setupContext, orchestrator.options.DeviceID); err != nil {
return fail(PhaseAccessReady, err)
}
proxy, err := orchestrator.deps.Proxy.Resolve(setupContext, ProxyRequest{
DeviceID: orchestrator.options.DeviceID,
HomeMCC: strings.TrimSpace(identity.HomeMCC),
@@ -484,14 +500,32 @@ func (orchestrator *Orchestrator) Reconnect(ctx context.Context) (State, error)
if !current.Enabled && current.Phase == PhaseIdle {
return current, ErrNotRunning
}
// Teardown during a reconnect is best-effort. Disable already releases the
// local IMS, tunnel, and radio resources, so a non-fatal cleanup error
// (e.g. the network rejecting SIP deregistration) must not block the
// rebuild — otherwise the device wedges in PhaseFailed. Only propagate
// errors that prevented the teardown itself (e.g. the operation lock).
if _, err := orchestrator.Disable(ctx); err != nil && !errors.Is(err, ErrCleanupIncomplete) {
if ctx == nil {
ctx = context.Background()
}
if err := orchestrator.lockOperation(ctx); err != nil {
return orchestrator.State(), err
}
orchestrator.mu.Lock()
resources := orchestrator.resources
orchestrator.mu.Unlock()
if resources != nil && resources.cancel != nil {
resources.cancel()
}
cleanupErrors := orchestrator.cleanupSessions(resources)
orchestrator.mutate(func(state *State) {
state.Phase = PhaseFailed
state.Enabled = true
state.Active = false
state.TunnelReady = false
state.IMSReady = false
state.SMSReady = false
state.LastReason = "reconnect_requested"
state.CleanupErrors = append([]string(nil), cleanupErrors...)
})
orchestrator.unlockOperation()
// Keep the radio checkpoint and CFUN=4 across a reconnect. Re-enabling RF
// for even a short window defeats airplane-first VoWiFi behavior.
return orchestrator.Enable(ctx)
}
@@ -679,6 +713,25 @@ func securityAuditFromEvidence(evidence TunnelEvidence) SecurityAudit {
}
func (orchestrator *Orchestrator) cleanup(resources *runtimeResources) []string {
if resources == nil {
return nil
}
cleanupErrors := orchestrator.cleanupSessions(resources)
if resources.radioChanged {
if err := orchestrator.cleanupCall(func(ctx context.Context) error {
return orchestrator.deps.Radio.Restore(ctx, orchestrator.options.DeviceID, resources.radio)
}); err != nil {
cleanupErrors = append(cleanupErrors, "restore radio: "+err.Error())
}
resources.radioChanged = false
}
return cleanupErrors
}
// cleanupSessions releases network-layer resources without restoring cellular
// RF. It is used while VoWiFi remains the desired policy, including failed
// automatic retries and manual reconnects.
func (orchestrator *Orchestrator) cleanupSessions(resources *runtimeResources) []string {
if resources == nil {
return nil
}
@@ -695,14 +748,6 @@ func (orchestrator *Orchestrator) cleanup(resources *runtimeResources) []string
}
resources.tunnel = nil
}
if resources.radioChanged {
if err := orchestrator.cleanupCall(func(ctx context.Context) error {
return orchestrator.deps.Radio.Restore(ctx, orchestrator.options.DeviceID, resources.radio)
}); err != nil {
cleanupErrors = append(cleanupErrors, "restore radio: "+err.Error())
}
resources.radioChanged = false
}
return cleanupErrors
}
@@ -802,10 +847,14 @@ func (orchestrator *Orchestrator) watchRuntimeFailure(
if !current {
return
}
cleanupErrors := orchestrator.cleanup(resources)
cleanupErrors := orchestrator.cleanupSessions(resources)
orchestrator.mu.Lock()
if orchestrator.resources == resources {
orchestrator.resources = nil
if resources.radioChanged {
orchestrator.resources = resources
} else {
orchestrator.resources = nil
}
}
orchestrator.mu.Unlock()
orchestrator.mutate(func(state *State) {
+60 -36
View File
@@ -366,11 +366,11 @@ func TestEnableUsesEvidenceBackedOrderAndDisableRollsBackInReverse(t *testing.T)
}
wantEnableCalls := []string{
"sim.identity",
"aka.ready",
"radio.snapshot",
"radio.rf_off",
"radio.stop_data",
"sim.identity",
"aka.ready",
"proxy.resolve",
"tunnel.start",
"tunnel.evidence",
@@ -422,26 +422,10 @@ func TestEnableFailuresCleanUpEveryAcquiredLayer(t *testing.T) {
{name: "identity", failCall: "sim.identity"},
{name: "aka", failCall: "aka.ready"},
{name: "radio snapshot", failCall: "radio.snapshot"},
{
name: "stop data can partially mutate",
failCall: "radio.stop_data",
wantCleanupTail: []string{"radio.restore"},
},
{
name: "rf off",
failCall: "radio.rf_off",
wantCleanupTail: []string{"radio.restore"},
},
{
name: "proxy",
failCall: "proxy.resolve",
wantCleanupTail: []string{"radio.restore"},
},
{
name: "tunnel start",
failCall: "tunnel.start",
wantCleanupTail: []string{"radio.restore"},
},
{name: "stop data can partially mutate", failCall: "radio.stop_data"},
{name: "rf off", failCall: "radio.rf_off"},
{name: "proxy", failCall: "proxy.resolve"},
{name: "tunnel start", failCall: "tunnel.start"},
{
name: "tunnel evidence",
mutate: func(environment *fakeEnvironment) {
@@ -449,12 +433,12 @@ func TestEnableFailuresCleanUpEveryAcquiredLayer(t *testing.T) {
environment.tunnelEvidence.ResponderAUTH = ResponderAUTHUnknown
},
wantError: ErrTunnelNotEstablished,
wantCleanupTail: []string{"tunnel.close", "radio.restore"},
wantCleanupTail: []string{"tunnel.close"},
},
{
name: "IMS start",
failCall: "ims.start",
wantCleanupTail: []string{"tunnel.close", "radio.restore"},
wantCleanupTail: []string{"tunnel.close"},
},
{
name: "IMS registration evidence",
@@ -462,12 +446,12 @@ func TestEnableFailuresCleanUpEveryAcquiredLayer(t *testing.T) {
environment.imsEvidence.Registered = false
},
wantError: ErrIMSNotRegistered,
wantCleanupTail: []string{"ims.close", "tunnel.close", "radio.restore"},
wantCleanupTail: []string{"ims.close", "tunnel.close"},
},
{
name: "SMS activation",
failCall: "ims.sms",
wantCleanupTail: []string{"ims.close", "tunnel.close", "radio.restore"},
wantCleanupTail: []string{"ims.close", "tunnel.close"},
},
{
name: "SMS evidence",
@@ -475,7 +459,7 @@ func TestEnableFailuresCleanUpEveryAcquiredLayer(t *testing.T) {
environment.smsEvidence.Ready = false
},
wantError: ErrSMSNotReady,
wantCleanupTail: []string{"ims.close", "tunnel.close", "radio.restore"},
wantCleanupTail: []string{"ims.close", "tunnel.close"},
},
}
@@ -501,6 +485,9 @@ func TestEnableFailuresCleanUpEveryAcquiredLayer(t *testing.T) {
state.TunnelReady || state.IMSReady || state.SMSReady {
t.Fatalf("failed state = %+v", state)
}
if environment.callCount("radio.restore") != 0 {
t.Fatalf("failed VoWiFi attempt re-enabled cellular RF: %#v", environment.callsSnapshot())
}
if len(test.wantCleanupTail) > 0 {
calls := environment.callsSnapshot()
if len(calls) < len(test.wantCleanupTail) {
@@ -701,6 +688,34 @@ func TestRetryAfterFailureCreatesANewAttempt(t *testing.T) {
if environment.callCount("tunnel.start") != 2 {
t.Fatalf("tunnel.start count = %d", environment.callCount("tunnel.start"))
}
if environment.callCount("radio.snapshot") != 1 || environment.callCount("radio.restore") != 0 {
t.Fatalf("retry must retain RF-off checkpoint: %#v", environment.callsSnapshot())
}
}
func TestFailedEnableRestoresRadioOnlyOnExplicitDisable(t *testing.T) {
environment := newFakeEnvironment()
environment.setFailure("tunnel.start", 1)
orchestrator := newTestOrchestrator(t, environment, false)
state, err := orchestrator.Enable(context.Background())
if err == nil || state.Phase != PhaseFailed || !state.Enabled {
t.Fatalf("Enable() = (%+v, %v)", state, err)
}
if environment.callCount("radio.restore") != 0 {
t.Fatalf("failed enable restored cellular RF: %#v", environment.callsSnapshot())
}
state, err = orchestrator.Disable(context.Background())
if err != nil {
t.Fatalf("Disable() error = %v", err)
}
if state.Phase != PhaseIdle || state.Enabled {
t.Fatalf("Disable() state = %+v", state)
}
if environment.callCount("radio.restore") != 1 {
t.Fatalf("explicit disable did not restore cellular RF: %#v", environment.callsSnapshot())
}
}
func TestReconnectClosesThenRebuildsTheRuntime(t *testing.T) {
@@ -719,7 +734,8 @@ func TestReconnectClosesThenRebuildsTheRuntime(t *testing.T) {
}
if environment.callCount("tunnel.start") != 2 ||
environment.callCount("tunnel.close") != 1 ||
environment.callCount("radio.restore") != 1 {
environment.callCount("radio.restore") != 0 ||
environment.callCount("radio.snapshot") != 1 {
t.Fatalf("calls = %#v", environment.callsSnapshot())
}
}
@@ -745,7 +761,8 @@ func TestReconnectToleratesCleanupFailureAndRebuilds(t *testing.T) {
}
if environment.callCount("ims.close") != 1 ||
environment.callCount("tunnel.close") != 1 ||
environment.callCount("radio.restore") != 1 ||
environment.callCount("radio.restore") != 0 ||
environment.callCount("radio.snapshot") != 1 ||
environment.callCount("tunnel.start") != 2 {
t.Fatalf("calls = %#v", environment.callsSnapshot())
}
@@ -781,7 +798,7 @@ func TestRuntimeTunnelFailureRevokesReadinessAndCleansEveryLayer(t *testing.T) {
}
calls := environment.callsSnapshot()
wantTail := []string{"ims.close", "tunnel.close", "radio.restore"}
wantTail := []string{"ims.close", "tunnel.close"}
if len(calls) < len(wantTail) ||
!reflect.DeepEqual(calls[len(calls)-len(wantTail):], wantTail) {
t.Fatalf("runtime failure cleanup tail = %#v", calls)
@@ -853,7 +870,7 @@ func TestSubscriptionPublishesOrderedEvidencePhases(t *testing.T) {
}
}
func TestCleanupAttemptsEveryLayerAndReportsAllErrors(t *testing.T) {
func TestFailedAttemptKeepsRadioOffUntilExplicitDisable(t *testing.T) {
environment := newFakeEnvironment()
environment.setFailure("ims.sms", 1)
environment.setFailure("ims.close", 1)
@@ -865,19 +882,26 @@ func TestCleanupAttemptsEveryLayerAndReportsAllErrors(t *testing.T) {
if err == nil {
t.Fatal("Enable() unexpectedly succeeded")
}
if len(state.CleanupErrors) != 3 {
if len(state.CleanupErrors) != 2 {
t.Fatalf("cleanup errors = %#v", state.CleanupErrors)
}
calls := environment.callsSnapshot()
wantTail := []string{"ims.close", "tunnel.close", "radio.restore"}
if !reflect.DeepEqual(calls[len(calls)-3:], wantTail) {
t.Fatalf("cleanup tail = %#v", calls[len(calls)-3:])
wantTail := []string{"ims.close", "tunnel.close"}
if !reflect.DeepEqual(calls[len(calls)-2:], wantTail) {
t.Fatalf("cleanup tail = %#v", calls[len(calls)-2:])
}
for _, text := range []string{"close IMS", "close tunnel", "restore radio"} {
for _, text := range []string{"close IMS", "close tunnel"} {
if !strings.Contains(err.Error(), text) {
t.Fatalf("error %q does not contain %q", err, text)
}
}
if environment.callCount("radio.restore") != 0 {
t.Fatalf("failed attempt restored RF unexpectedly: %#v", calls)
}
if _, disableErr := orchestrator.Disable(context.Background()); disableErr == nil ||
!strings.Contains(disableErr.Error(), "restore radio") {
t.Fatalf("Disable() error = %v, want retained radio restore failure", disableErr)
}
}
func TestDisableCleanupWarningStillSettlesIdle(t *testing.T) {
+39 -3
View File
@@ -211,12 +211,26 @@ func (manager *Manager) RequestEnabled(deviceID string, enabled bool) (vowifi.St
manager.mu.Lock()
item := manager.entries[deviceID]
item.desiredEnabled = enabled
if !enabled && item.busy {
item.disablePending = true
if item.busy {
manager.logger.Info(
"VoWiFi desired state updated while lifecycle operation is active",
"device_id", deviceID,
"enabled", enabled,
)
// The switch is a desired-state control, not a one-shot command. A user
// can change it again while a slow IKE/IMS transaction is still winding
// down. Accept the newest value and let runOperations reconcile the
// runtime after the current operation completes. Returning busy here used
// to let the database and runtime diverge (configured on, runtime idle).
if enabled {
item.disablePending = false
} else {
item.disablePending = true
}
cancel := item.operationCancel
state := item.orchestrator.State()
manager.mu.Unlock()
if cancel != nil {
if !enabled && cancel != nil {
cancel()
}
return state, nil
@@ -366,6 +380,7 @@ func (manager *Manager) startOperation(
manager.wg.Add(1)
manager.mu.Unlock()
manager.logger.Debug("VoWiFi lifecycle operation queued", "device_id", deviceID)
go manager.runOperations(deviceID, item, operation)
return item.orchestrator.State(), nil
}
@@ -376,6 +391,7 @@ func (manager *Manager) runOperations(
operation func(context.Context, *vowifi.Orchestrator) error,
) {
defer manager.wg.Done()
manager.logger.Debug("VoWiFi lifecycle worker started", "device_id", deviceID)
for {
ctx, cancel := context.WithTimeout(manager.ctx, manager.operationTimeout)
manager.mu.Lock()
@@ -389,6 +405,7 @@ func (manager *Manager) runOperations(
}
item.operationCancel = cancel
manager.mu.Unlock()
manager.logger.Debug("VoWiFi lifecycle operation executing", "device_id", deviceID)
err := operation(ctx, item.orchestrator)
cancel()
if err != nil &&
@@ -431,6 +448,25 @@ func (manager *Manager) runOperations(
}
continue
}
// Reconcile a switch change that arrived while the previous lifecycle
// operation was busy. Keep using the same worker so enable/disable can
// never overlap on the modem or tunnel resources.
if item.desiredEnabled && !state.Enabled {
manager.mu.Unlock()
operation = func(ctx context.Context, orchestrator *vowifi.Orchestrator) error {
_, err := orchestrator.Enable(ctx)
return err
}
continue
}
if !item.desiredEnabled && state.Enabled {
manager.mu.Unlock()
operation = func(ctx context.Context, orchestrator *vowifi.Orchestrator) error {
_, err := orchestrator.Disable(ctx)
return err
}
continue
}
item.busy = false
shouldRetry := item.desiredEnabled && state.Phase == vowifi.PhaseFailed
if !shouldRetry && state.Phase != vowifi.PhaseFailed {
+86 -2
View File
@@ -267,6 +267,89 @@ func TestManagerStopsAutomaticRetryWhenPolicyIsDisabled(t *testing.T) {
}
}
func TestManagerAcceptsRepeatedEnableWhileBusy(t *testing.T) {
manager := New(Options{OperationTimeout: time.Second})
t.Cleanup(func() { _ = manager.Close(context.Background()) })
if err := manager.Register(testOrchestrator(t, "ec20")); err != nil {
t.Fatal(err)
}
started := make(chan struct{})
release := make(chan struct{})
if _, err := manager.startOperation("ec20", false, func(context.Context, *vowifi.Orchestrator) error {
close(started)
<-release
return nil
}); err != nil {
t.Fatal(err)
}
<-started
if _, err := manager.RequestEnabled("ec20", true); err != nil {
t.Fatalf("repeated desired state was rejected: %v", err)
}
close(release)
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
state, err := manager.State("ec20")
if err != nil {
t.Fatal(err)
}
if state.Phase == vowifi.PhaseSMSReady {
return
}
time.Sleep(time.Millisecond)
}
t.Fatal("desired enable was not reconciled after the active operation")
}
func TestManagerReEnablesWhenSwitchChangesDuringDisable(t *testing.T) {
manager := New(Options{OperationTimeout: time.Second})
t.Cleanup(func() { _ = manager.Close(context.Background()) })
if err := manager.Register(testOrchestrator(t, "ec20")); err != nil {
t.Fatal(err)
}
if _, err := manager.RequestEnabled("ec20", true); err != nil {
t.Fatal(err)
}
waitForPhase := func(want vowifi.Phase) {
t.Helper()
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
state, err := manager.State("ec20")
if err != nil {
t.Fatal(err)
}
if state.Phase == want {
return
}
time.Sleep(time.Millisecond)
}
t.Fatalf("phase did not become %s", want)
}
waitForPhase(vowifi.PhaseSMSReady)
started := make(chan struct{})
release := make(chan struct{})
if _, err := manager.startOperation("ec20", false, func(ctx context.Context, orchestrator *vowifi.Orchestrator) error {
close(started)
<-release
_, err := orchestrator.Disable(ctx)
return err
}); err != nil {
t.Fatal(err)
}
<-started
manager.mu.Lock()
manager.entries["ec20"].desiredEnabled = false
manager.mu.Unlock()
if _, err := manager.RequestEnabled("ec20", true); err != nil {
t.Fatalf("enable while disable is active: %v", err)
}
close(release)
waitForPhase(vowifi.PhaseSMSReady)
}
func TestManagerRejectsUnknownDevice(t *testing.T) {
manager := New(Options{})
t.Cleanup(func() {
@@ -348,8 +431,9 @@ func TestManagerCoalescesReconnectWhileLifecycleOperationIsBusy(t *testing.T) {
if _, err := manager.RequestReconnect("ec20"); err != nil {
t.Fatalf("second queued reconnect error = %v", err)
}
if _, err := manager.RequestEnabled("ec20", true); !errors.Is(err, ErrOperationInProgress) {
t.Fatalf("non-reconnect operation error = %v, want ErrOperationInProgress", err)
if _, err := manager.RequestEnabled("ec20", true); err != nil {
close(release)
t.Fatalf("repeated desired enable was rejected: %v", err)
}
manager.mu.Lock()
pending := manager.entries["ec20"].reconnectPending