mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-21 23:33:42 +08:00
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:
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"`,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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))
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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) {}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ type Server struct {
|
||||
netTraffic *liveNetTracker
|
||||
publicIPMu sync.RWMutex
|
||||
publicIPs map[string]cachedPublicIP
|
||||
automaticTasks *automaticTaskScheduler
|
||||
}
|
||||
|
||||
func New(options Options) (*Server, error) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+893
-65
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -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, ¬ify,
|
||||
&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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
const schemaVersion = 8
|
||||
const schemaVersion = 10
|
||||
|
||||
var ErrNotFound = errors.New("store: not found")
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user