From 1c2513d4cdbb8632a7c8363d24c3ea006e397be1 Mon Sep 17 00:00:00 2001 From: sunnyhmz7010 <52040067+sunnyhmz7010@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:26:37 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E6=92=A4=E5=9B=9E=E4=B8=8A=E6=AC=A1=20SIM?= =?UTF-8?q?=20=E5=85=BC=E5=AE=B9=E4=BF=AE=E5=A4=8D=E4=B8=8E=20eSIM=20?= =?UTF-8?q?=E6=8E=A2=E6=B5=8B=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/device/esim.go | 67 +--- internal/device/esim_download.go | 68 +--- internal/device/esim_qmi_probe_test.go | 406 -------------------- internal/device/esim_test.go | 36 -- internal/device/logging.go | 4 - internal/device/logging_test.go | 2 +- internal/device/vowifi_qmi.go | 13 +- internal/device/vowifi_qmi_cleanup_test.go | 153 -------- internal/server/device_api.go | 2 - internal/server/device_features_api_test.go | 25 -- internal/server/esim_error_api_test.go | 62 --- internal/vowifi/ec20_adapter.go | 61 +-- internal/vowifi/ec20_isim_fallback_test.go | 199 ---------- web/src/api.ts | 5 +- web/src/lib/i18n-en.ts | 1 - 15 files changed, 38 insertions(+), 1066 deletions(-) delete mode 100644 internal/device/esim_qmi_probe_test.go delete mode 100644 internal/device/vowifi_qmi_cleanup_test.go delete mode 100644 internal/server/esim_error_api_test.go delete mode 100644 internal/vowifi/ec20_isim_fallback_test.go diff --git a/internal/device/esim.go b/internal/device/esim.go index b2001e59..e21f09e5 100644 --- a/internal/device/esim.go +++ b/internal/device/esim.go @@ -346,31 +346,6 @@ func (manager *Manager) csim(ctx context.Context, id string, apdu []byte) ([]byt return parseCSIM(response) } -// probeATLogicalChannel requires the UICC lock. It only opens and closes its -// own temporary channel; no application is selected and no profile is changed. -func (manager *Manager) probeATLogicalChannel(ctx context.Context, id string) error { - probeContext, cancelProbe := context.WithTimeout(ctx, 2*time.Second) - payload, sw, err := manager.csim(probeContext, id, []byte{0x00, 0x70, 0x00, 0x00, 0x01}) - cancelProbe() - if err != nil { - return fmt.Errorf("esim: open temporary AT channel: %w", err) - } - if sw != 0x9000 || len(payload) != 1 || payload[0] == 0 || payload[0] > 19 { - return fmt.Errorf("esim: invalid temporary AT channel response (SW=%04X)", sw) - } - // 即使页面请求已取消,也用独立且有界的上下文清理刚分配的通道。 - closeContext, cancelClose := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second) - defer cancelClose() - _, sw, err = manager.csim(closeContext, id, []byte{0x00, 0x70, 0x80, payload[0], 0x00}) - if err != nil { - return fmt.Errorf("esim: close temporary AT channel %d: %w", payload[0], err) - } - if sw != 0x9000 { - return fmt.Errorf("esim: close temporary AT channel %d (SW=%04X)", payload[0], sw) - } - return nil -} - // openEuicc opens a logical channel and selects the ISD-R AID on it. func (manager *Manager) openEuicc(ctx context.Context, id string) (*euiccChannel, error) { return manager.openEuiccAID(ctx, id, isdRAID) @@ -434,7 +409,6 @@ func (manager *Manager) openEuiccOnce(ctx context.Context, id string) (*euiccCha } func (manager *Manager) openEuiccOnceAID(ctx context.Context, id, aidHex string) (*euiccChannel, error) { - aidHex = strings.ToUpper(strings.TrimSpace(aidHex)) state, lookupErr := manager.lookup(id) if lookupErr != nil { return nil, lookupErr @@ -451,14 +425,15 @@ func (manager *Manager) openEuiccOnceAID(ctx context.Context, id, aidHex string) // channel but then rejects SELECT ISD-R at the AT+CSIM layer. payload, sw, err := manager.csim(ctx, id, []byte{0x00, 0x70, 0x00, 0x00, 0x01}) if err != nil { - return nil, fmt.Errorf("esim: AT MANAGE CHANNEL AID=%s: %w", aidHex, err) + return nil, err } if sw != 0x9000 || len(payload) != 1 { - return nil, fmt.Errorf("%w: AT MANAGE CHANNEL AID=%s SW=%04X response_bytes=%d", errNoLogicalChannel, aidHex, sw, len(payload)) + return nil, errNoLogicalChannel } channel := &euiccChannel{manager: manager, id: id, channel: int(payload[0])} // SELECT ISD-R by AID on the logical channel: CLA=channel, INS=A4, P1=04. + aidHex = strings.ToUpper(strings.TrimSpace(aidHex)) aid, err := hex.DecodeString(aidHex) if err != nil || len(aid) == 0 || len(aid) > 255 { channel.close(context.Background()) @@ -468,21 +443,17 @@ func (manager *Manager) openEuiccOnceAID(ctx context.Context, id, aidHex string) _, sw, err = manager.csim(ctx, id, selectAID) if err != nil { channel.close(context.Background()) - return nil, fmt.Errorf("esim: AT SELECT AID=%s: %w", aidHex, err) + return nil, err } if sw>>8 == 0x61 { // Drain the select FCP the card is holding with a proper GET RESPONSE // (CLA=0x80|channel, INS=0xC0). transmit() injects the channel into the // CLA low nibble, so the first byte here stays 0x80. - _, sw, err = channel.transmit(ctx, []byte{0x80, 0xC0, 0x00, 0x00, byte(sw & 0xFF)}, 0x80) - if err != nil { - channel.close(context.Background()) - return nil, fmt.Errorf("esim: AT SELECT GET RESPONSE AID=%s: %w", aidHex, err) - } + _, sw, _ = channel.transmit(ctx, []byte{0x80, 0xC0, 0x00, 0x00, byte(sw & 0xFF)}, 0x80) } if sw != 0x9000 { channel.close(context.Background()) - return nil, fmt.Errorf("%w: AT SELECT AID=%s SW=%04X", errNoEUICC, aidHex, sw) + return nil, errNoEUICC } return channel, nil } @@ -514,7 +485,7 @@ func (manager *Manager) openQMIEuiccOnceAID(ctx context.Context, id string, cand } if err != nil { _ = session.Close() - return nil, fmt.Errorf("%w: QMI OpenLogicalChannel AID=%s: %s", errNoEUICC, aidHex, HardwareErrorDetail(err)) + return nil, fmt.Errorf("%w: %v", errNoEUICC, err) } return &euiccChannel{ manager: manager, id: id, channel: int(logicalChannel), @@ -535,7 +506,7 @@ func (manager *Manager) openPCSCEuiccOnceAID(ctx context.Context, id string, can if err != nil { return nil, fmt.Errorf("esim: PC/SC MANAGE CHANNEL: %w", err) } - return nil, fmt.Errorf("%w: PC/SC MANAGE CHANNEL SW=%04X response_bytes=%d", errNoLogicalChannel, sw, len(payload)) + return nil, errNoLogicalChannel } channel := &euiccChannel{manager: manager, id: id, channel: int(payload[0]), pcscSession: session} aidHex = strings.ToUpper(strings.TrimSpace(aidHex)) @@ -548,10 +519,7 @@ func (manager *Manager) openPCSCEuiccOnceAID(ctx context.Context, id string, can _, selectSW, err := channel.transmit(ctx, selectAID, 0x00) if err != nil || selectSW != 0x9000 { channel.close(context.Background()) - if err != nil { - return nil, fmt.Errorf("%w: PC/SC SELECT AID=%s: %s", errNoEUICC, aidHex, HardwareErrorDetail(err)) - } - return nil, fmt.Errorf("%w: PC/SC SELECT AID=%s SW=%04X", errNoEUICC, aidHex, selectSW) + return nil, errNoEUICC } return channel, nil } @@ -562,13 +530,6 @@ func (manager *Manager) openPCSCEuiccOnceAID(ctx context.Context, id string, can // 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 { - aids, _ := manager.discoverEuiccAIDsWithErrors(ctx, id) - return aids -} - -// 保留候选应用的探测失败,供最终未读到任何 eUICC 时记录;不将普通 SIM 的缺失应用单独报错。 -func (manager *Manager) discoverEuiccAIDsWithErrors(ctx context.Context, id string) ([]string, error) { - var failures []error product, err := manager.openEuiccAID(ctx, id, estkProductAID) if err == nil { product.close(context.Background()) @@ -577,35 +538,31 @@ func (manager *Manager) discoverEuiccAIDsWithErrors(ctx context.Context, id stri for _, aid := range []string{estkSE0AID, estkSE1AID} { channel, err := manager.openEuiccAID(ctx, id, aid) if err != nil { - failures = append(failures, err) continue } channel.close(context.Background()) found = append(found, aid) } if len(found) > 0 { - return found, errors.Join(failures...) + return found } - } else { - failures = append(failures, err) } var found []string for _, aid := range []string{isdRAID, xesimISDRAID} { channel, err := manager.openEuiccAID(ctx, id, aid) if err != nil { - failures = append(failures, err) continue } channel.close(context.Background()) found = append(found, aid) } if len(found) > 0 { - return found, errors.Join(failures...) + 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}, errors.Join(failures...) + return []string{isdRAID} } func isTransientEuiccCME(err error) bool { diff --git a/internal/device/esim_download.go b/internal/device/esim_download.go index e7152b15..19e6e462 100644 --- a/internal/device/esim_download.go +++ b/internal/device/esim_download.go @@ -3,7 +3,6 @@ package device import ( "context" "errors" - "fmt" "strings" "time" ) @@ -284,8 +283,8 @@ func readEsimChipInfo(ctx context.Context, channel *euiccChannel, aidHex string) } // ESIMInventory reads every independently addressable eUICC storage exposed by -// the inserted card without changing profiles. With QMI configured, a missing -// eUICC permits one temporary AT channel open/close before a fresh inventory. +// the inserted card. It is entirely read-only: only SELECT, GetProfilesInfo, +// GetEuiccData, GetEuiccInfo2 and GetEuiccConfiguredAddresses are issued. func (manager *Manager) ESIMInventory(ctx context.Context, id string) ([]EsimInventoryEntry, error) { ctx, cancel := boundESIMContext(ctx) defer cancel() @@ -297,75 +296,24 @@ func (manager *Manager) ESIMInventory(ctx context.Context, id string) ([]EsimInv return nil, errESIMRecovering } - entries, err := manager.esimInventoryOnce(ctx, id) - if err != nil && manager.logger != nil { - if errors.Is(err, ErrNoEUICC) { - // 此时尚不能区分普通 SIM 与暂时检测失败,使用信息级别并保留首次读取详情。 - manager.logger.Info("eUICC inventory initial: not detected", "device_id", id, "error", HardwareErrorDetail(err)) - } else { - manager.logger.Warn("eUICC inventory initial: read failed", "device_id", id, "error", HardwareErrorDetail(err)) - } - } - if !errors.Is(err, ErrNoEUICC) { - return entries, err - } - if ctx.Err() != nil { - return nil, ctx.Err() - } - state, lookupErr := manager.lookup(id) - if lookupErr != nil { - return nil, lookupErr - } - candidate := manager.candidateFor(state) - // USB EC20 的 QMI 配置也进入此处,其 eSIM 实际走 AT+CSIM;不能只允许原生 QMI 设备。 - if !strings.EqualFold(manager.esimTransportFor(state), "qmi") || !candidate.HasATPort() { - return entries, err - } - // 仍持有 UICC 锁;只探测本次分配的通道,不选择应用或重置 SIM。 - if probeErr := manager.probeATLogicalChannel(ctx, id); probeErr != nil { - if manager.logger != nil { - manager.logger.Warn("eUICC AT channel probe failed", "device_id", id, "error", HardwareErrorDetail(probeErr)) - } - // 首次未发现的详情已记录;不能保留 ErrNoEUICC,否则 HTTP 层会将探测异常当作正常空结果。 - return nil, probeErr - } - if ctx.Err() != nil { - return nil, ctx.Err() - } - entries, err = manager.esimInventoryOnce(ctx, id) - if manager.logger != nil { - if errors.Is(err, ErrNoEUICC) { - // 普通 SIM 不支持 eUICC 是正常结果,不记录为硬件故障。 - manager.logger.Info("eUICC not detected after AT channel probe", "device_id", id, "error", HardwareErrorDetail(err)) - } else if err != nil { - manager.logger.Warn("eUICC inventory retry after AT channel probe failed", "device_id", id, "error", HardwareErrorDetail(err)) - } else { - manager.logger.Info("eUICC inventory recovered after AT channel probe", "device_id", id) - } - } - return entries, err -} - -// esimInventoryOnce requires the caller to hold the eSIM and UICC locks. -func (manager *Manager) esimInventoryOnce(ctx context.Context, id string) ([]EsimInventoryEntry, error) { - aids, discoveryErr := manager.discoverEuiccAIDsWithErrors(ctx, id) + aids := manager.discoverEuiccAIDs(ctx, id) entries := make([]EsimInventoryEntry, 0, len(aids)) var lastErr error for _, aid := range aids { channel, err := manager.openEuiccAID(ctx, id, aid) if err != nil { - lastErr = fmt.Errorf("esim: open application AID=%s: %w", aid, err) + lastErr = err continue } profilePayload, profileErr := channel.es10(ctx, []byte{0xBF, 0x2D, 0x00}) chip, chipErr := readEsimChipInfo(ctx, channel, aid) channel.close(context.Background()) if profileErr != nil { - lastErr = fmt.Errorf("esim: GetProfilesInfo AID=%s: %w", aid, profileErr) + lastErr = profileErr continue } if chipErr != nil { - lastErr = fmt.Errorf("esim: read chip info AID=%s: %w", aid, chipErr) + lastErr = chipErr continue } info := EsimInfo{EID: chip.EID, AID: aid, Profiles: parseProfilesInfo(profilePayload)} @@ -373,10 +321,6 @@ func (manager *Manager) esimInventoryOnce(ctx context.Context, id string) ([]Esi } if len(entries) == 0 { if lastErr != nil { - if discoveryErr != nil { - // 只补充已脱敏的诊断文本,保持 lastErr 原有的错误分类与恢复条件。 - return nil, fmt.Errorf("%w; discovery: %s", lastErr, HardwareErrorDetail(discoveryErr)) - } return nil, lastErr } return nil, ErrNoEUICC diff --git a/internal/device/esim_qmi_probe_test.go b/internal/device/esim_qmi_probe_test.go deleted file mode 100644 index 99772d8e..00000000 --- a/internal/device/esim_qmi_probe_test.go +++ /dev/null @@ -1,406 +0,0 @@ -package device - -import ( - "bytes" - "context" - "encoding/hex" - "errors" - "fmt" - "log/slog" - "strings" - "testing" - "time" - - "vocat/internal/loghub" - "vocat/internal/modem" -) - -// 未实现的 QMI 能力会直接使测试失败,避免探测意外触发射频或 SIM 重置。 -type inventoryProbeQMISession struct { - nativeQMIEuiccSession - t *testing.T - available bool - profileErr error - opened bool - closed bool -} - -func (session *inventoryProbeQMISession) OpenLogicalChannel(_ context.Context, slot uint8, aid []byte) (byte, error) { - if slot != 1 { - session.t.Fatalf("unexpected QMI slot %d", slot) - } - aidHex := strings.ToUpper(hex.EncodeToString(aid)) - if aidHex == estkProductAID || (session.available && (aidHex == estkSE0AID || aidHex == estkSE1AID)) { - session.opened = true - return 7, nil - } - return 0, errors.New("QMI eUICC application not found") -} - -func (session *inventoryProbeQMISession) CloseLogicalChannel(_ context.Context, slot, channel uint8) error { - if slot != 1 || channel != 7 || !session.opened || session.closed { - session.t.Fatalf("closed an unowned QMI channel: slot=%d channel=%d", slot, channel) - } - session.closed = true - return nil -} - -func (session *inventoryProbeQMISession) SendAPDU(_ context.Context, slot, channel uint8, apdu []byte) ([]byte, error) { - if slot != 1 || channel != 7 || !session.opened || session.closed { - session.t.Fatalf("unexpected QMI APDU: slot=%d channel=%d", slot, channel) - } - var payload []byte - switch { - case bytes.Contains(apdu, []byte{0xBF, 0x2D}): - if session.profileErr != nil { - return nil, session.profileErr - } - iccid, err := encodeICCID("8900000000000000001") - if err != nil { - session.t.Fatal(err) - } - payload = derConstruct(0xBF2D, derConstruct(0xE3, derEncode(0x5A, iccid))) - case bytes.Contains(apdu, []byte{0xBF, 0x3E}): - payload = derConstruct(0xBF3E, derEncode(0x5A, bytes.Repeat([]byte{0x89}, 16))) - case bytes.Contains(apdu, []byte{0xBF, 0x22}): - payload = []byte{0xBF, 0x22, 0x00} - case bytes.Contains(apdu, []byte{0xBF, 0x3C}): - payload = []byte{0xBF, 0x3C, 0x00} - default: - session.t.Fatalf("unexpected APDU %X", apdu) - } - return append(payload, 0x90, 0x00), nil -} - -func (session *inventoryProbeQMISession) Close() error { - if session.opened && !session.closed { - session.t.Fatal("QMI session released before its channel was closed") - } - return nil -} - -type inventoryProbeATClient struct { - *transcriptClient - afterExecute func(context.Context, string) -} - -func (client *inventoryProbeATClient) Execute(ctx context.Context, command string) (modem.Response, error) { - response, err := client.transcriptClient.Execute(ctx, command) - if client.afterExecute != nil { - client.afterExecute(ctx, command) - } - return response, err -} - -func TestESIMInventoryQMIATChannelProbe(t *testing.T) { - profileFailure := errors.New("profile read failed") - openFailure := errors.New("AT channel open failed") - closeFailure := errors.New("AT channel close failed") - for _, test := range []struct { - name string - initiallyReady bool - recovers bool - noATPort bool - cancelBeforeProbe bool - cancelAfterOpen bool - profileErr error - openErr error - closeErr error - closeResponse string - wantErr error - wantErrText string - wantProbe bool - wantRetry bool - }{ - {name: "healthy", initiallyReady: true}, - {name: "recovers_two_storages", recovers: true, wantProbe: true, wantRetry: true}, - {name: "still_missing_retries_only_once", wantProbe: true, wantRetry: true, wantErr: ErrNoEUICC}, - {name: "no_AT_port", noATPort: true, wantErr: ErrNoEUICC}, - {name: "profile_error_is_not_retried", initiallyReady: true, profileErr: profileFailure, wantErr: profileFailure}, - {name: "canceled_before_probe", cancelBeforeProbe: true, wantErr: context.Canceled}, - {name: "canceled_after_open_still_closes", cancelAfterOpen: true, wantProbe: true, wantErr: context.Canceled}, - {name: "open_failed", openErr: openFailure, wantProbe: true, wantErr: openFailure}, - {name: "open_deadline", openErr: context.DeadlineExceeded, wantProbe: true, wantErr: context.DeadlineExceeded}, - {name: "open_command_timeout", openErr: modem.ErrCommandTimeout, wantProbe: true, wantErr: modem.ErrCommandTimeout}, - {name: "open_canceled", openErr: context.Canceled, wantProbe: true, wantErr: context.Canceled}, - {name: "close_failed", closeErr: closeFailure, wantProbe: true, wantErr: closeFailure}, - {name: "close_deadline", closeErr: context.DeadlineExceeded, wantProbe: true, wantErr: context.DeadlineExceeded}, - {name: "close_command_timeout", closeErr: modem.ErrCommandTimeout, wantProbe: true, wantErr: modem.ErrCommandTimeout}, - {name: "close_rejected", closeResponse: `+CSIM: 4,"6A81"`, wantProbe: true, wantErrText: "close temporary AT channel 2 (SW=6A81)"}, - } { - t.Run(test.name, func(t *testing.T) { - manager, opener, id := newStartedNativeQMITestManager(t) - if err := manager.SetBackend(id, "qmi"); err != nil { - t.Fatal(err) - } - hub := loghub.New(nil, 100) - manager.logger = slog.New(hub) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - ready := test.initiallyReady - qmiOpens, opensBeforeProbe := 0, 0 - manager.qmiRadioOpener = func(context.Context, string) (qmiRadioSession, error) { - qmiOpens++ - if test.cancelBeforeProbe { - cancel() - } - return &inventoryProbeQMISession{t: t, available: ready, profileErr: test.profileErr}, nil - } - client := &inventoryProbeATClient{transcriptClient: &transcriptClient{}} - opener.client = client - if test.noATPort { - state, _ := manager.lookup(id) - state.candidate.ATPort = modem.Port{} - } - if test.wantProbe { - client.steps = append(client.steps, clientStep{ - command: `AT+CSIM=10,"0070000001"`, response: okResponse(`+CSIM: 6,"029000"`), err: test.openErr, - }) - if test.openErr == nil { - closeResponse := test.closeResponse - if closeResponse == "" { - closeResponse = `+CSIM: 4,"9000"` - } - client.steps = append(client.steps, clientStep{ - command: `AT+CSIM=10,"0070800200"`, response: okResponse(closeResponse), err: test.closeErr, - }) - } - } - client.afterExecute = func(commandContext context.Context, command string) { - if manager.uiccMu.TryLock() { - manager.uiccMu.Unlock() - t.Fatal("AT probe did not hold the shared UICC lock") - } - if commandContext.Err() != nil { - t.Fatalf("AT probe/cleanup context already canceled: %v", commandContext.Err()) - } - deadline, ok := commandContext.Deadline() - if !ok || time.Until(deadline) <= 0 || time.Until(deadline) > 2*time.Second { - t.Fatalf("AT probe/cleanup must be bounded to two seconds: %v", deadline) - } - if command == `AT+CSIM=10,"0070000001"` { - opensBeforeProbe = qmiOpens - if test.cancelAfterOpen { - cancel() - } - } else if test.closeErr == nil { - ready = test.recovers - } - } - - entries, err := manager.ESIMInventory(ctx, id) - if test.wantErrText != "" { - if err == nil || !strings.Contains(err.Error(), test.wantErrText) { - t.Fatalf("inventory error = %v, want %q", err, test.wantErrText) - } - } else if !errors.Is(err, test.wantErr) { - t.Fatalf("inventory error = %v, want %v", err, test.wantErr) - } - // 探测异常不能同时被归类为普通 SIM 的正常缺失,否则 HTTP 层会吞掉异常。 - if errors.Is(err, ErrNoEUICC) != errors.Is(test.wantErr, ErrNoEUICC) { - t.Fatalf("inventory error has unexpected no-eUICC classification: %v", err) - } - if err == nil && (len(entries) != 2 || len(entries[0].Info.Profiles) != 1 || len(entries[1].Info.Profiles) != 1) { - t.Fatalf("expected a fresh QMI inventory with two storages: %#v", entries) - } - if test.wantProbe { - if opensBeforeProbe == 0 || (qmiOpens > opensBeforeProbe) != test.wantRetry { - t.Fatalf("QMI opens before/after probe = %d/%d, retry=%v", opensBeforeProbe, qmiOpens, test.wantRetry) - } - if len(hub.History(10, slog.LevelInfo, "AT channel probe")) != 1 && !test.cancelAfterOpen { - t.Fatal("probe outcome was not logged exactly once") - } - } else if len(hub.History(10, slog.LevelInfo, "AT channel probe")) != 0 { - t.Fatal("inventory logged a probe that was not attempted") - } - initialLogs := hub.History(10, slog.LevelInfo, "eUICC inventory initial") - if test.initiallyReady && test.profileErr == nil { - if len(initialLogs) != 0 { - t.Fatal("healthy inventory logged an initial failure") - } - } else { - if len(initialLogs) != 1 { - t.Fatalf("initial inventory failure logs = %d, want one", len(initialLogs)) - } - detail := fmt.Sprint(initialLogs[0].Fields["error"]) - if test.profileErr != nil { - if initialLogs[0].Level != "warn" || !strings.Contains(detail, "GetProfilesInfo") || !strings.Contains(detail, estkSE1AID) { - t.Fatalf("missing profile failure context: %+v", initialLogs[0]) - } - } else if initialLogs[0].Level != "info" || !strings.Contains(detail, "QMI OpenLogicalChannel") || !strings.Contains(detail, estkSE0AID) { - t.Fatalf("missing discovery failure context: %+v", initialLogs[0]) - } - } - client.assertDone(t) - }) - } -} - -func TestESIMInventoryATTransportDoesNotAddChannelProbe(t *testing.T) { - var steps []clientStep - for _, aid := range []string{estkProductAID, isdRAID, xesimISDRAID, isdRAID} { - steps = append(steps, - clientStep{command: `AT+CSIM=10,"0070000001"`, response: okResponse(`+CSIM: 6,"029000"`)}, - clientStep{command: `AT+CSIM=42,"02A4040010` + aid + `"`, response: okResponse(`+CSIM: 4,"6A82"`)}, - clientStep{command: `AT+CSIM=10,"0070800200"`, response: okResponse(`+CSIM: 4,"9000"`)}, - ) - } - client := &transcriptClient{steps: steps} - manager, id := newStartedTestManager(t, client) - if _, err := manager.ESIMInventory(context.Background(), id); !errors.Is(err, ErrNoEUICC) { - t.Fatalf("inventory error = %v, want no eUICC", err) - } - client.assertDone(t) -} - -// USB EC20 虽配置为 QMI,现有 eSIM 读取实际使用 AT+CSIM;恢复重试也必须覆盖它。 -func TestESIMInventoryUSBEC20ChannelProbe(t *testing.T) { - for _, scenario := range []string{"healthy", "recovers", "recovers_with_discovery_CME", "healthy_then_missing_recovers", "ordinary_SIM_stays_empty"} { - t.Run(scenario, func(t *testing.T) { - step := func(apdu, response string) clientStep { - return clientStep{ - command: fmt.Sprintf(`AT+CSIM=%d,"%s"`, len(apdu), apdu), - response: okResponse(fmt.Sprintf(`+CSIM: %d,"%s"`, len(response), response)), - } - } - open := step("0070000001", "029000") - close := step("0070800200", "9000") - selectAID := func(aid, result string) clientStep { - return step("02A4040010"+aid, result) - } - var missing []clientStep - for _, aid := range []string{estkProductAID, isdRAID, xesimISDRAID, isdRAID} { - missing = append(missing, open, selectAID(aid, "6A82"), close) - } - if scenario == "recovers_with_discovery_CME" { - missing[1].err = &modem.CommandError{Command: missing[1].command, Final: "+CME ERROR: 13"} - } - var readable []clientStep - for _, aid := range []string{estkProductAID, estkSE0AID, estkSE1AID} { - readable = append(readable, open, selectAID(aid, "9000"), close) - } - iccid, err := encodeICCID("8900000000000000001") - if err != nil { - t.Fatal(err) - } - profilePayload := derConstruct(0xBF2D, derConstruct(0xE3, derEncode(0x5A, iccid))) - eidPayload := derConstruct(0xBF3E, derEncode(0x5A, bytes.Repeat([]byte{0x89}, 16))) - for _, aid := range []string{estkSE0AID, estkSE1AID} { - payload := profilePayload - if aid == estkSE1AID { - payload = []byte{0xBF, 0x2D, 0x00} - } - readable = append(readable, - open, selectAID(aid, "9000"), - // 与现场相同:GetProfilesInfo 返回 6100,再读取完整 GET RESPONSE。 - step("82E2910003BF2D0000", "6100"), - step("82C0000000", strings.ToUpper(hex.EncodeToString(payload))+"9000"), - step("82E2910006BF3E035C015A00", strings.ToUpper(hex.EncodeToString(eidPayload))+"9000"), - step("82E2910003BF220000", "BF22009000"), - step("82E2910003BF3C0000", "BF3C009000"), close, - ) - } - steps := readable - if scenario != "healthy" { - steps = append(append([]clientStep(nil), missing...), open, close) - if scenario != "ordinary_SIM_stays_empty" { - steps = append(steps, readable...) - } else { - steps = append(steps, missing...) - } - } - if scenario == "healthy_then_missing_recovers" { - steps = append(append([]clientStep(nil), readable...), steps...) - } - client := &transcriptClient{steps: steps} - manager, id := newStartedTestManager(t, client) - state, _ := manager.lookup(id) - state.candidate.QMIControl = "/dev/cdc-wdm0" - state.candidate.NetworkInterface = "wws27u1i4" - if isNativeQMICandidate(state.candidate) { - t.Fatal("USB EC20 fixture incorrectly matches native QMI") - } - if err := manager.SetBackend(id, "qmi"); err != nil { - t.Fatal(err) - } - if err := manager.SetESIMTransport(id, "qmi"); err != nil { - t.Fatal(err) - } - manager.qmiRadioOpener = func(context.Context, string) (qmiRadioSession, error) { - t.Fatal("USB EC20 eSIM unexpectedly used the native QMI path") - return nil, errors.New("unexpected native QMI open") - } - hub := loghub.New(nil, 100) - manager.logger = slog.New(hub) - if scenario == "healthy_then_missing_recovers" { - if entries, err := manager.ESIMInventory(context.Background(), id); err != nil || len(entries) != 2 { - t.Fatalf("initially healthy inventory = %+v, error = %v", entries, err) - } - if len(hub.History(10, slog.LevelInfo, "")) != 0 { - t.Fatal("healthy inventory added diagnostic logs") - } - } - entries, err := manager.ESIMInventory(context.Background(), id) - if scenario == "ordinary_SIM_stays_empty" { - if !errors.Is(err, ErrNoEUICC) { - t.Fatalf("ordinary SIM error = %v, want ErrNoEUICC", err) - } - } else if err != nil || len(entries) != 2 || len(entries[0].Info.Profiles) != 1 || len(entries[1].Info.Profiles) != 0 { - t.Fatalf("AT inventory after probe = %+v, error = %v", entries, err) - } - if len(hub.History(10, slog.LevelWarn, "")) != 0 { - t.Fatal("healthy or ordinary SIM was reported as a hardware failure") - } - logs := hub.History(10, slog.LevelInfo, "") - if scenario == "healthy" { - if len(logs) != 0 { - t.Fatalf("healthy inventory added logs: %+v", logs) - } - } else { - initialLogs := hub.History(10, slog.LevelInfo, "eUICC inventory initial: not detected") - if len(logs) != 2 || len(initialLogs) != 1 { - t.Fatalf("expected initial failure and probe outcome: %+v", logs) - } - detail := fmt.Sprint(initialLogs[0].Fields["error"]) - for _, want := range []string{"AT SELECT", "SW=6A82", estkProductAID, isdRAID, xesimISDRAID} { - if !strings.Contains(detail, loghub.RedactString(want)) { - t.Fatalf("initial failure detail %q missing %q", detail, want) - } - } - if initialLogs[0].Fields["device_id"] != id || strings.Contains(detail, "AT+CSIM=") { - t.Fatalf("invalid or unredacted initial failure log: %+v", initialLogs[0]) - } - if scenario == "recovers_with_discovery_CME" && !strings.Contains(detail, "AT+CSIM failed: +CME ERROR: 13") { - t.Fatalf("discarded discovery CME error was not retained: %q", detail) - } - wantOutcome := "eUICC inventory recovered after AT channel probe" - if scenario == "ordinary_SIM_stays_empty" { - wantOutcome = "eUICC not detected after AT channel probe" - } - if outcomes := hub.History(10, slog.LevelInfo, wantOutcome); len(outcomes) != 1 { - t.Fatalf("expected one %q outcome: %+v", wantOutcome, logs) - } - } - // 严格命令序列同时限制探测为一次,并禁止 CFUN、写 Profile 或关闭他人通道。 - client.assertDone(t) - }) - } -} - -func TestProbeATLogicalChannelRejectsInvalidResponses(t *testing.T) { - for _, response := range []string{`+CSIM: 4,"6A81"`, `+CSIM: 4,"9000"`, `+CSIM: 6,"009000"`, `+CSIM: 6,"149000"`} { - t.Run(response, func(t *testing.T) { - client := &transcriptClient{steps: []clientStep{{ - command: `AT+CSIM=10,"0070000001"`, response: okResponse(response), - }}} - manager, id := newStartedTestManager(t, client) - manager.LockUICC() - err := manager.probeATLogicalChannel(context.Background(), id) - manager.UnlockUICC() - if err == nil { - t.Fatal("invalid channel allocation was accepted") - } - client.assertDone(t) - }) - } -} diff --git a/internal/device/esim_test.go b/internal/device/esim_test.go index a7c1af69..f29b814a 100644 --- a/internal/device/esim_test.go +++ b/internal/device/esim_test.go @@ -449,42 +449,6 @@ func TestEuiccSASTrimsCardPadding(t *testing.T) { } } -func TestOpenEuiccGETResponsePreservesTransportError(t *testing.T) { - for _, test := range []struct { - name string - cause error - }{ - {name: "transient_CME", cause: &modem.CommandError{Command: `AT+CSIM=10,"81C0000010"`, Final: "+CME ERROR: 0"}}, - {name: "timeout", cause: modem.ErrCommandTimeout}, - {name: "canceled", cause: context.Canceled}, - {name: "deadline", cause: context.DeadlineExceeded}, - } { - t.Run(test.name, func(t *testing.T) { - client := &transcriptClient{steps: []clientStep{ - {command: `AT+CSIM=10,"0070000001"`, response: okResponse(`+CSIM: 6,"019000"`)}, - {command: fmt.Sprintf(`AT+CSIM=42,"01A4040010%s"`, isdRAID), response: okResponse(`+CSIM: 4,"6110"`)}, - {command: `AT+CSIM=10,"81C0000010"`, err: test.cause}, - {command: `AT+CSIM=10,"0070800100"`, response: okResponse(`+CSIM: 4,"9000"`)}, - }} - manager, id := newStartedTestManager(t, client) - channel, err := manager.openEuiccOnceAID(context.Background(), id, isdRAID) - if channel != nil || !errors.Is(err, test.cause) { - t.Fatalf("open eUICC = %v, %v; want original transport error %v", channel, err, test.cause) - } - if errors.Is(err, errNoEUICC) { - t.Fatalf("transport error must not indicate absent eUICC: %v", err) - } - if !strings.Contains(err.Error(), "GET RESPONSE AID="+isdRAID) { - t.Fatalf("transport error lost the SELECT AID context: %v", err) - } - if isTransientEuiccCME(err) != isTransientEuiccCME(test.cause) { - t.Fatalf("transport error changed transient CME classification: %v", err) - } - client.assertDone(t) - }) - } -} - func TestTransientEuiccCMEClassification(t *testing.T) { err := fmt.Errorf("select ISD-R: %w", &modem.CommandError{ Command: `AT+CSIM=42,"01A40400"`, diff --git a/internal/device/logging.go b/internal/device/logging.go index 84fb36b6..eb0d8bdc 100644 --- a/internal/device/logging.go +++ b/internal/device/logging.go @@ -39,10 +39,6 @@ func HardwareErrorDetail(err error) string { func redactCommandErrors(detail string, err error) string { if commandErr, ok := err.(*modem.CommandError); ok { detail = strings.ReplaceAll(detail, commandErr.Error(), safeCommandError(commandErr)) - // manager.command 等外层包装也可能再次带上完整命令,不能只脱敏最内层错误。 - if commandErr.Command != "" { - detail = strings.ReplaceAll(detail, commandErr.Command, safeATCommandName(commandErr.Command)) - } } switch wrapped := err.(type) { case interface{ Unwrap() []error }: diff --git a/internal/device/logging_test.go b/internal/device/logging_test.go index 07ae3d73..d4b352e1 100644 --- a/internal/device/logging_test.go +++ b/internal/device/logging_test.go @@ -19,7 +19,7 @@ func TestHardwareErrorDetailRedactsATPayload(t *testing.T) { Final: "+CME ERROR: 13", Lines: []string{payload}, } - err := fmt.Errorf("select ISIM: %w", errors.Join(errors.New("reader reset failed"), fmt.Errorf("%s: %w", commandErr.Command, commandErr))) + err := fmt.Errorf("select ISIM: %w", errors.Join(errors.New("reader reset failed"), commandErr)) detail := HardwareErrorDetail(err) if strings.Contains(detail, payload) || strings.Contains(detail, "AT+CSIM=") { t.Fatalf("hardware error exposed AT payload: %q", detail) diff --git a/internal/device/vowifi_qmi.go b/internal/device/vowifi_qmi.go index e33d6389..9ca05d68 100644 --- a/internal/device/vowifi_qmi.go +++ b/internal/device/vowifi_qmi.go @@ -98,18 +98,7 @@ func (manager *Manager) AuthenticateNativeQMI(ctx context.Context, id string, ai step = 4 } } - // 鉴权超时或取消后仍须尝试释放本次打开的通道,清理最多等待两秒。 - closeContext, cancelClose := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second) - closeErr := session.CloseLogicalChannel(closeContext, 1, channel) - cancelClose() - if closeErr != nil && manager.logger != nil { - manager.logger.Warn("close QMI UIM authentication channel failed", - "device_id", id, - "slot", 1, - "channel", channel, - "error", HardwareErrorDetail(closeErr), - ) - } + closeErr := session.CloseLogicalChannel(ctx, 1, channel) return errors.Join(err, closeErr) }) return diff --git a/internal/device/vowifi_qmi_cleanup_test.go b/internal/device/vowifi_qmi_cleanup_test.go deleted file mode 100644 index bd40a7c6..00000000 --- a/internal/device/vowifi_qmi_cleanup_test.go +++ /dev/null @@ -1,153 +0,0 @@ -package device - -import ( - "bytes" - "context" - "errors" - "log/slog" - "testing" - "time" - - "vocat/internal/loghub" -) - -// 只覆盖鉴权需要的调用,意外使用其他 QMI 能力会使测试失败。 -type authenticationQMISession struct { - nativeQMIVoWiFiSession - openChannel func(context.Context, uint8, []byte) (byte, error) - sendAPDU func(context.Context, uint8, uint8, []byte) ([]byte, error) - closeChannel func(context.Context, uint8, uint8) error - closeCount int -} - -func (session *authenticationQMISession) OpenLogicalChannel(ctx context.Context, slot uint8, aid []byte) (byte, error) { - return session.openChannel(ctx, slot, aid) -} - -func (session *authenticationQMISession) SendAPDU(ctx context.Context, slot, channel uint8, apdu []byte) ([]byte, error) { - return session.sendAPDU(ctx, slot, channel, apdu) -} - -func (session *authenticationQMISession) CloseLogicalChannel(ctx context.Context, slot, channel uint8) error { - return session.closeChannel(ctx, slot, channel) -} - -func (session *authenticationQMISession) Close() error { - session.closeCount++ - return nil -} - -func TestAuthenticateNativeQMIChannelCleanup(t *testing.T) { - apduFailure := errors.New("authentication APDU failed") - closeFailure := errors.New("logical channel close failed") - openFailure := errors.New("logical channel open failed") - for _, test := range []struct { - name string - cancelRequest bool - requestTimeout time.Duration - apduErr error - closeErr error - openErr error - }{ - {name: "success"}, - {name: "request_canceled", cancelRequest: true, apduErr: context.Canceled}, - {name: "request_deadline_exceeded", requestTimeout: 100 * time.Millisecond, apduErr: context.DeadlineExceeded}, - {name: "authentication_failed", apduErr: apduFailure}, - {name: "close_failed", closeErr: closeFailure}, - {name: "authentication_and_close_failed", apduErr: apduFailure, closeErr: closeFailure}, - {name: "open_failed", openErr: openFailure}, - } { - t.Run(test.name, func(t *testing.T) { - manager, _, id := newStartedNativeQMITestManager(t) - hub := loghub.New(nil, 100) - manager.logger = slog.New(hub) - ctx, cancel := context.WithCancel(context.Background()) - if test.requestTimeout > 0 { - cancel() - ctx, cancel = context.WithTimeout(context.Background(), test.requestTimeout) - } - defer cancel() - - const ownedChannel uint8 = 7 - aid := []byte{0xA0, 0x00, 0x00, 0x00, 0x87, 0x10, 0x02} - apdu := []byte{0x00, 0x88, 0x00, 0x81, 0x00} - wantResponse := []byte{0x90, 0x00} - var cleanupContext context.Context - sendCount, closeCount := 0, 0 - session := &authenticationQMISession{ - openChannel: func(_ context.Context, slot uint8, gotAID []byte) (byte, error) { - if slot != 1 || !bytes.Equal(gotAID, aid) { - t.Fatalf("open channel: slot=%d AID=%X", slot, gotAID) - } - return ownedChannel, test.openErr - }, - sendAPDU: func(requestContext context.Context, slot, channel uint8, gotAPDU []byte) ([]byte, error) { - sendCount++ - if requestContext != ctx || slot != 1 || channel != ownedChannel || !bytes.Equal(gotAPDU, apdu) { - t.Fatalf("unexpected authentication request: slot=%d channel=%d APDU=%X", slot, channel, gotAPDU) - } - if test.cancelRequest { - cancel() - } - if test.requestTimeout > 0 { - <-requestContext.Done() - } - if test.apduErr != nil { - return nil, test.apduErr - } - return wantResponse, nil - }, - closeChannel: func(closeContext context.Context, slot, channel uint8) error { - closeCount++ - if slot != 1 || channel != ownedChannel { - t.Fatalf("closed an unowned channel: slot=%d channel=%d", slot, channel) - } - // 模拟底层库:已取消的上下文不能发送关闭命令。 - if err := closeContext.Err(); err != nil { - t.Fatalf("cleanup was canceled by the authentication request: %v", err) - } - deadline, ok := closeContext.Deadline() - if !ok || time.Until(deadline) <= 0 || time.Until(deadline) > 2*time.Second { - t.Fatalf("cleanup needs its own deadline within two seconds: %v, %v", deadline, ok) - } - cleanupContext = closeContext - return test.closeErr - }, - } - manager.qmiRadioOpener = func(context.Context, string) (qmiRadioSession, error) { - return session, nil - } - - response, err := manager.AuthenticateNativeQMI(ctx, id, aid, apdu) - for _, wantErr := range []error{test.openErr, test.apduErr, test.closeErr} { - if wantErr != nil && !errors.Is(err, wantErr) { - t.Fatalf("authentication error = %v, missing %v", err, wantErr) - } - } - if test.openErr == nil && test.apduErr == nil && test.closeErr == nil && err != nil { - t.Fatalf("successful authentication returned %v", err) - } - if test.openErr == nil && test.apduErr == nil && !bytes.Equal(response, wantResponse) { - t.Fatalf("authentication response changed: %X", response) - } - wantCalls := 1 - if test.openErr != nil { - wantCalls = 0 - } - if sendCount != wantCalls || closeCount != wantCalls || session.closeCount != 1 { - t.Fatalf("send=%d channel close=%d session close=%d; want %d, %d, 1", sendCount, closeCount, session.closeCount, wantCalls, wantCalls) - } - if cleanupContext != nil && !errors.Is(cleanupContext.Err(), context.Canceled) { - t.Fatalf("cleanup context not released after return: %v", cleanupContext.Err()) - } - entries := hub.History(10, slog.LevelDebug, "") - if test.closeErr == nil { - if len(entries) != 0 { - t.Fatalf("successful cleanup produced logs: %#v", entries) - } - } else if len(entries) != 1 || entries[0].Fields["device_id"] != id || entries[0].Fields["channel"] != uint64(ownedChannel) || entries[0].Fields["error"] != closeFailure.Error() { - t.Fatalf("missing cleanup failure diagnostics: %#v", entries) - } - }) - } -} diff --git a/internal/server/device_api.go b/internal/server/device_api.go index 8c6ccfb9..fa61d146 100644 --- a/internal/server/device_api.go +++ b/internal/server/device_api.go @@ -1870,8 +1870,6 @@ func (s *Server) writeDeviceError(w http.ResponseWriter, err error) { writeError(w, http.StatusBadRequest, "invalid_apn", "APN must contain only letters, digits, dots, underscores, or hyphens") case errors.Is(err, device.ErrRegionBlocked): writeError(w, http.StatusForbidden, "region_blocked", err.Error()) - case errors.Is(err, device.ErrRFOffRestart): - writeError(w, http.StatusConflict, "rf_off_restart_blocked", "Cellular RF is disabled, so the modem restart was blocked. Disable VoWiFi and airplane mode before restarting; this will enable cellular RF.") case errors.Is(err, context.DeadlineExceeded), errors.Is(err, modem.ErrCommandTimeout): writeError(w, http.StatusGatewayTimeout, "modem_timeout", "the modem did not answer before the command timeout") case errors.Is(err, context.Canceled): diff --git a/internal/server/device_features_api_test.go b/internal/server/device_features_api_test.go index 8eaae4f7..50e29754 100644 --- a/internal/server/device_features_api_test.go +++ b/internal/server/device_features_api_test.go @@ -194,31 +194,6 @@ func TestManualModemRebootInvalidatesConnectedDataRuntime(t *testing.T) { } } -func TestWriteDeviceErrorExplainsRFOffRestart(t *testing.T) { - t.Parallel() - server := &Server{logger: regionTestLogger()} - for _, cause := range []error{ - device.ErrRFOffRestart, - errors.Join(errors.New("defer MBN selection requiring online restart"), device.ErrRFOffRestart), - } { - recorder := httptest.NewRecorder() - server.writeDeviceError(recorder, cause) - if recorder.Code != http.StatusConflict { - t.Fatalf("status = %d, want 409; body = %s", recorder.Code, recorder.Body) - } - var envelope errorEnvelope - if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil { - t.Fatal(err) - } - if envelope.Error.Code != "rf_off_restart_blocked" { - t.Fatalf("error code = %q, want rf_off_restart_blocked", envelope.Error.Code) - } - if !strings.Contains(envelope.Error.Message, "enable cellular RF") { - t.Fatalf("error does not explain the RF consequence: %q", envelope.Error.Message) - } - } -} - type esimAIDCaptureController struct { fakeDeviceController switchAID string diff --git a/internal/server/esim_error_api_test.go b/internal/server/esim_error_api_test.go deleted file mode 100644 index 9079d0eb..00000000 --- a/internal/server/esim_error_api_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package server - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "net/http" - "net/http/httptest" - "testing" - - "vocat/internal/device" - "vocat/internal/modem" -) - -type esimInventoryErrorController struct { - fakeDeviceController - inventoryErr error -} - -func (controller esimInventoryErrorController) ESIMInventory(context.Context, string) ([]device.EsimInventoryEntry, error) { - return nil, controller.inventoryErr -} - -func TestESIMOverviewDistinguishesMissingCardFromProbeFailure(t *testing.T) { - for _, test := range []struct { - name string - err error - wantStatus int - wantCode string - }{ - {name: "ordinary_SIM", err: fmt.Errorf("esim: open application: %w", device.ErrNoEUICC), wantStatus: http.StatusOK}, - {name: "probe_open_deadline", err: fmt.Errorf("esim: open temporary AT channel: %w", context.DeadlineExceeded), wantStatus: http.StatusGatewayTimeout, wantCode: "modem_timeout"}, - {name: "probe_close_timeout", err: fmt.Errorf("esim: close temporary AT channel 2: %w", modem.ErrCommandTimeout), wantStatus: http.StatusGatewayTimeout, wantCode: "modem_timeout"}, - {name: "probe_canceled", err: fmt.Errorf("esim: open temporary AT channel: %w", context.Canceled), wantStatus: http.StatusRequestTimeout, wantCode: "request_canceled"}, - {name: "probe_close_rejected", err: errors.New("esim: close temporary AT channel 2 (SW=6A81)"), wantStatus: http.StatusBadGateway, wantCode: "modem_error"}, - } { - t.Run(test.name, func(t *testing.T) { - server := &Server{logger: regionTestLogger(), devices: esimInventoryErrorController{inventoryErr: test.err}} - recorder := httptest.NewRecorder() - server.writeEsimOverview(recorder, httptest.NewRequest(http.MethodGet, "/esim", nil), "dev1", true) - if recorder.Code != test.wantStatus { - t.Fatalf("status = %d, want %d; body = %s", recorder.Code, test.wantStatus, recorder.Body.String()) - } - if test.wantStatus == http.StatusOK { - data := decodeData(t, recorder) - profiles, ok := data["profiles"].([]any) - if data["chipInfo"] != nil || !ok || len(profiles) != 0 { - t.Fatalf("ordinary SIM should return an empty overview: %#v", data) - } - return - } - var envelope errorEnvelope - if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil { - t.Fatal(err) - } - if envelope.Error.Code != test.wantCode { - t.Fatalf("error code = %q, want %q", envelope.Error.Code, test.wantCode) - } - }) - } -} diff --git a/internal/vowifi/ec20_adapter.go b/internal/vowifi/ec20_adapter.go index ca76191c..48df835c 100644 --- a/internal/vowifi/ec20_adapter.go +++ b/internal/vowifi/ec20_adapter.go @@ -515,7 +515,7 @@ func (adapter *EC20Adapter) authenticateWithApplication( } binding.aid = aid binding.application = application - // 保留已验证的 CSIM 访问方式;仍须成功选择 ISIM 后才能鉴权。 + binding.basicChannel = false adapter.mu.Lock() adapter.bindings[binding.iccid] = binding adapter.mu.Unlock() @@ -551,10 +551,22 @@ func (adapter *EC20Adapter) authenticateWithApplication( apdu := buildUSIMAuthenticateAPDU(challenge) var raw []byte if binding.basicChannel { - raw, err = adapter.authenticateBasicApplication(ctx, binding, apdu) - if err != nil { + if err := adapter.selectBasicApplication( + ctx, + binding.deviceID, + binding.aid, + ); err != nil { return AKAResult{}, err } + raw, err = adapter.transmitBasicAPDU( + ctx, + binding.deviceID, + apdu, + true, + ) + if err != nil { + return AKAResult{}, ErrEC20AKACommand + } } else { channel, err := adapter.openLogicalChannel( ctx, @@ -562,25 +574,7 @@ func (adapter *EC20Adapter) authenticateWithApplication( binding.aid, ) if err != nil { - var commandErr *modem.CommandError - if binding.application != "ISIM" || ctx.Err() != nil || - !errors.As(err, &commandErr) || commandErr.Final != "ERROR" { - return AKAResult{}, err - } - // 仅在 CCHO 明确拒绝时复用已有 CSIM 路径,不降级到 USIM。 - raw, basicErr := adapter.authenticateBasicApplication(ctx, binding, apdu) - if basicErr != nil { - if errors.Is(basicErr, ErrEC20AKACommand) { - // SELECT 已成功,仅保留 CCHO 原因,不再标记应用不存在。 - return AKAResult{}, errors.Join(fmt.Errorf("open application: %w", commandErr), basicErr) - } - return AKAResult{}, errors.Join(err, basicErr) - } - binding.basicChannel = true - adapter.mu.Lock() - adapter.bindings[binding.iccid] = binding - adapter.mu.Unlock() - return parseUSIMAuthenticateResponse(raw) + return AKAResult{}, err } var commandErr error raw, commandErr = adapter.transmitLogicalAPDU( @@ -607,17 +601,6 @@ func (adapter *EC20Adapter) authenticateWithApplication( return parseUSIMAuthenticateResponse(raw) } -func (adapter *EC20Adapter) authenticateBasicApplication(ctx context.Context, binding ec20SIMBinding, apdu []byte) ([]byte, error) { - if err := adapter.selectBasicApplication(ctx, binding.deviceID, binding.aid); err != nil { - return nil, err - } - raw, err := adapter.transmitBasicAPDU(ctx, binding.deviceID, apdu, true) - if err != nil { - return nil, ErrEC20AKACommand - } - return raw, nil -} - func buildUSIMAuthenticateAPDU(challenge AKAChallenge) []byte { // TS 31.102 AUTHENTICATE, 3G security context (P2=0x81): // Lc=34, then LV(RAND) and LV(AUTN), followed by Le. @@ -1075,13 +1058,6 @@ func (adapter *EC20Adapter) discoverPreferredAKAApplication( aidPrefix string, application string, ) (string, string, error) { - // CUAD 不可用时会读 EF_DIR,多条基本通道命令必须持有同一事务锁。 - adapter.apduMu.Lock() - defer adapter.apduMu.Unlock() - if locker, ok := adapter.executor.(EC20UICCLocker); ok { - locker.LockUICC() - defer locker.UnlockUICC() - } response, err := adapter.execute(ctx, deviceID, "AT+CUAD") if err == nil { data, parseErr := parseCUADData(response) @@ -1093,9 +1069,6 @@ func (adapter *EC20Adapter) discoverPreferredAKAApplication( } } } - if discovered, discoverErr := adapter.discoverBasicApplicationAID(ctx, deviceID, aidPrefix); discoverErr == nil { - return discovered, application, nil - } // AT+CUAD is optional. Returning the standard AID prefix still lets CCHO // perform the authoritative application probe on older EC20 firmware. return aidPrefix, application, nil @@ -1170,7 +1143,7 @@ func (adapter *EC20Adapter) openLogicalChannel( fmt.Sprintf(`AT+CCHO="%s"`, aid), ) if err != nil { - return 0, fmt.Errorf("%w: open application: %w", ErrEC20ApplicationAbsent, err) + return 0, fmt.Errorf("%w: open application", ErrEC20ApplicationAbsent) } value := valueAfterATPrefix(response, "+CCHO:") if value == "" { diff --git a/internal/vowifi/ec20_isim_fallback_test.go b/internal/vowifi/ec20_isim_fallback_test.go deleted file mode 100644 index d6d17dae..00000000 --- a/internal/vowifi/ec20_isim_fallback_test.go +++ /dev/null @@ -1,199 +0,0 @@ -package vowifi - -import ( - "bytes" - "context" - "encoding/hex" - "errors" - "fmt" - "strings" - "sync" - "testing" - - "vocat/internal/modem" -) - -type isimFallbackTranscript struct { - *ec20Transcript - uiccMu sync.Mutex -} - -func (transcript *isimFallbackTranscript) LockUICC() { transcript.uiccMu.Lock() } -func (transcript *isimFallbackTranscript) UnlockUICC() { transcript.uiccMu.Unlock() } - -func (transcript *isimFallbackTranscript) assertLocked(command string) { - if command != "AT+CCID" && command != "AT+CUAD" && transcript.uiccMu.TryLock() { - transcript.uiccMu.Unlock() - transcript.t.Fatalf("ISIM discovery/authentication was not protected by the UICC lock: %s", command) - } -} - -func (transcript *isimFallbackTranscript) ExecuteAT(ctx context.Context, id, command string) (modem.Response, error) { - transcript.assertLocked(command) - return transcript.ec20Transcript.ExecuteAT(ctx, id, command) -} - -func (transcript *isimFallbackTranscript) ExecuteSensitiveAT(ctx context.Context, id, command string) (modem.Response, error) { - transcript.assertLocked(command) - return transcript.ec20Transcript.ExecuteSensitiveAT(ctx, id, command) -} - -func TestEC20ISIMStrictDiscoversFullAIDAndUsesCompatibleTransport(t *testing.T) { - const fullISIM = "A0000000871004FFFFFFFF8903020000" - const fullUSIM = "A0000000871002FFFFFFFF8903020000" - identity := SIMIdentity{ICCID: "8901000000000000001", IMSI: "310280000000001"} - openFailure := &modem.CommandError{Command: `AT+CCHO="` + fullISIM + `"`, Final: "ERROR"} - for _, test := range []struct { - name string - fallback bool - knownBasic bool - response []byte - }{ - {name: "full_AID_CCHO_succeeds", response: successfulUSIMResponse()}, - {name: "CCHO_rejected_uses_CSIM", fallback: true, response: successfulUSIMResponse()}, - {name: "CSIM_preserves_synchronization_failure", fallback: true, response: synchronizationFailureUSIMResponse()}, - {name: "preserves_known_CSIM_transport", fallback: true, knownBasic: true, response: successfulUSIMResponse()}, - } { - t.Run(test.name, func(t *testing.T) { - var challenge AKAChallenge - authAPDU := strings.ToUpper(hex.EncodeToString(buildUSIMAuthenticateAPDU(challenge))) - csimAuth := fmt.Sprintf(`AT+CSIM=%d,"%s"`, len(authAPDU), authAPDU) - encodedResponse := strings.ToUpper(hex.EncodeToString(test.response)) - selectISIM := `AT+CSIM=42,"00A4040410` + fullISIM + `"` - steps := []ec20TranscriptStep{ - {command: "AT+CUAD", err: errors.New("CUAD unsupported"), final: "+CME ERROR: 13"}, - {command: `AT+CSIM=16,"00A40004023F0000"`, lines: []string{`+CSIM: 4,"9000"`}}, - {command: `AT+CSIM=16,"00A40004022F0000"`, lines: []string{`+CSIM: 4,"9000"`}}, - {command: `AT+CSIM=10,"00B2010400"`, lines: []string{`+CSIM: 44,"61124F10` + fullUSIM + `9000"`}}, - {command: `AT+CSIM=10,"00B2020400"`, lines: []string{`+CSIM: 44,"61124F10` + fullISIM + `9000"`}}, - {command: "AT+CCID", lines: []string{"+CCID: " + identity.ICCID}}, - } - if test.fallback { - if !test.knownBasic { - steps = append(steps, ec20TranscriptStep{command: `AT+CCHO="` + fullISIM + `"`, err: openFailure, final: "ERROR"}) - } - steps = append(steps, - ec20TranscriptStep{command: selectISIM, lines: []string{`+CSIM: 4,"9000"`}}, - ec20TranscriptStep{command: csimAuth, sensitive: true, lines: []string{fmt.Sprintf(`+CSIM: %d,"%s"`, len(encodedResponse), encodedResponse)}}, - ) - } else { - steps = append(steps, - ec20TranscriptStep{command: `AT+CCHO="` + fullISIM + `"`, lines: []string{"+CCHO: 2"}}, - ec20TranscriptStep{command: fmt.Sprintf(`AT+CGLA=2,%d,"%s"`, len(authAPDU), authAPDU), sensitive: true, lines: []string{fmt.Sprintf(`+CGLA: %d,"%s"`, len(encodedResponse), encodedResponse)}}, - ec20TranscriptStep{command: "AT+CCHC=2"}, - ) - } - // 第二次鉴权复用已验证的完整 AID 和访问方式,不重复 CUAD/目录探测。 - steps = append(steps, ec20TranscriptStep{command: "AT+CCID", lines: []string{"+CCID: " + identity.ICCID}}) - if test.fallback { - steps = append(steps, - ec20TranscriptStep{command: selectISIM, lines: []string{`+CSIM: 4,"9000"`}}, - ec20TranscriptStep{command: csimAuth, sensitive: true, lines: []string{fmt.Sprintf(`+CSIM: %d,"%s"`, len(encodedResponse), encodedResponse)}}, - ) - } else { - steps = append(steps, - ec20TranscriptStep{command: `AT+CCHO="` + fullISIM + `"`, lines: []string{"+CCHO: 2"}}, - ec20TranscriptStep{command: fmt.Sprintf(`AT+CGLA=2,%d,"%s"`, len(authAPDU), authAPDU), sensitive: true, lines: []string{fmt.Sprintf(`+CGLA: %d,"%s"`, len(encodedResponse), encodedResponse)}}, - ec20TranscriptStep{command: "AT+CCHC=2"}, - ) - } - transcript := &isimFallbackTranscript{ec20Transcript: &ec20Transcript{t: t, steps: steps}} - adapter, err := NewEC20Adapter(transcript, EC20AdapterOptions{}) - if err != nil { - t.Fatal(err) - } - adapter.bindings[identity.ICCID] = ec20SIMBinding{deviceID: "ec20-1", iccid: identity.ICCID, imsi: identity.IMSI, aid: fullUSIM, application: "USIM", basicChannel: test.knownBasic} - for attempt := 0; attempt < 2; attempt++ { - result, err := adapter.AuthenticateWithPreference(context.Background(), identity, challenge, "isim_strict") - if err != nil { - t.Fatal(err) - } - if bytes.Equal(test.response, synchronizationFailureUSIMResponse()) { - if !result.SynchronizationFailure || len(result.AUTS) != 14 { - t.Fatalf("invalid synchronization failure: %#v", result) - } - } else if !bytes.Equal(result.RES, []byte{1, 2, 3, 4, 5, 6, 7, 8}) { - t.Fatalf("invalid authentication response: %#v", result) - } - } - binding, err := adapter.bindingFor(identity) - if err != nil || binding.aid != fullISIM || binding.application != "ISIM" || binding.basicChannel != test.fallback { - t.Fatalf("ISIM binding changed unexpectedly: %#v, %v", binding, err) - } - transcript.assertDone() - }) - } -} - -func TestEC20ISIMFallbackDoesNotDowngradeOrHideFailures(t *testing.T) { - const fullISIM = "A0000000871004FFFFFFFF8903020000" - const secret = "authentication material must not appear in errors" - identity := SIMIdentity{ICCID: "8901000000000000001", IMSI: "310280000000001"} - for _, test := range []struct { - name string - application string - openErr error - selectSW string - authSW string - authErr error - wantErr error - }{ - {name: "ISIM_absent", application: "ISIM", selectSW: "6A82", wantErr: ErrEC20ApplicationAbsent}, - {name: "transport_timeout", application: "ISIM", openErr: context.DeadlineExceeded, wantErr: context.DeadlineExceeded}, - {name: "request_canceled", application: "ISIM", openErr: context.Canceled, wantErr: context.Canceled}, - {name: "USIM_failure_unchanged", application: "USIM", wantErr: ErrEC20ApplicationAbsent}, - {name: "MAC_failure_preserved", application: "ISIM", selectSW: "9000", authSW: "9862", wantErr: ErrEC20AKAMACFailure}, - {name: "sensitive_error_redacted", application: "ISIM", selectSW: "9000", authErr: errors.New(secret), wantErr: ErrEC20AKACommand}, - } { - t.Run(test.name, func(t *testing.T) { - aid := fullISIM - preference := "isim_strict" - if test.application == "USIM" { - aid = usimAIDPrefix - preference = "" - } - openErr := test.openErr - if openErr == nil { - openErr = &modem.CommandError{Command: `AT+CCHO="` + aid + `"`, Final: "ERROR"} - } - steps := []ec20TranscriptStep{ - {command: "AT+CCID", lines: []string{"+CCID: " + identity.ICCID}}, - {command: `AT+CCHO="` + aid + `"`, err: openErr, final: "ERROR"}, - } - if test.selectSW != "" { - steps = append(steps, ec20TranscriptStep{command: `AT+CSIM=42,"00A4040410` + fullISIM + `"`, lines: []string{`+CSIM: 4,"` + test.selectSW + `"`}}) - } - if test.authSW != "" || test.authErr != nil { - authAPDU := strings.ToUpper(hex.EncodeToString(buildUSIMAuthenticateAPDU(AKAChallenge{}))) - steps = append(steps, ec20TranscriptStep{ - command: fmt.Sprintf(`AT+CSIM=%d,"%s"`, len(authAPDU), authAPDU), sensitive: true, - lines: []string{`+CSIM: 4,"` + test.authSW + `"`}, err: test.authErr, - }) - } - transcript := &isimFallbackTranscript{ec20Transcript: &ec20Transcript{t: t, steps: steps}} - adapter, err := NewEC20Adapter(transcript, EC20AdapterOptions{}) - if err != nil { - t.Fatal(err) - } - adapter.bindings[identity.ICCID] = ec20SIMBinding{deviceID: "ec20-1", iccid: identity.ICCID, imsi: identity.IMSI, aid: aid, application: test.application} - _, err = adapter.AuthenticateWithPreference(context.Background(), identity, AKAChallenge{}, preference) - if !errors.Is(err, test.wantErr) { - t.Fatalf("authentication error = %v, want %v", err, test.wantErr) - } - if test.selectSW == "9000" && errors.Is(err, ErrEC20ApplicationAbsent) { - t.Fatalf("selected application must not be classified as absent: %v", err) - } - if test.authErr != nil && !errors.Is(err, openErr) { - t.Fatalf("authentication error lost the CCHO cause: %v", err) - } - if strings.Contains(err.Error(), secret) { - t.Fatalf("authentication error exposed sensitive details: %v", err) - } - binding, _ := adapter.bindingFor(identity) - if binding.application != test.application { - t.Fatalf("application was silently changed: %#v", binding) - } - transcript.assertDone() - }) - } -} diff --git a/web/src/api.ts b/web/src/api.ts index 50679e3f..60edd083 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -219,10 +219,7 @@ export function updateLoggingSettings(settings: { export function apiMessage(error: unknown) { if (error instanceof ApiError) { const suffix = error.requestId ? `(${tl("请求")} ${error.requestId})` : ""; - const message = error.code === "rf_off_restart_blocked" - ? tl("蜂窝射频已关闭,已阻止整机重启。关闭 VoWiFi 和飞行模式后才能重启,这会开启蜂窝射频。") - : error.message; - return `${message}${suffix}`; + return `${error.message}${suffix}`; } if (error instanceof Error) return error.message; return tl("请求未完成,检查服务状态后重试"); diff --git a/web/src/lib/i18n-en.ts b/web/src/lib/i18n-en.ts index f443eeb6..7a8e1c1a 100644 --- a/web/src/lib/i18n-en.ts +++ b/web/src/lib/i18n-en.ts @@ -5,7 +5,6 @@ * 富文本片段(嵌套链接/代码块的说明框)不走字典,在组件里按语言分支渲染。 */ export const EN_DICT: Record = { - "蜂窝射频已关闭,已阻止整机重启。关闭 VoWiFi 和飞行模式后才能重启,这会开启蜂窝射频。": "Cellular RF is disabled, so the modem restart was blocked. Disable VoWiFi and airplane mode before restarting; this will enable cellular RF.", "清空配置": "Clear configuration", "清空后将移除该渠道的账号、地址和凭据,其他选项恢复默认值。启用状态保持不变,点击“保存通知配置”后生效。": "This removes accounts, addresses and credentials and restores other options to defaults. The enabled state stays unchanged. Click Save notification settings to apply.", "启用 MeoW 推送": "Enable MeoW notifications", From 045f012a50cb3dd994c123e43ae7789c9bd8f465 Mon Sep 17 00:00:00 2001 From: sunnyhmz7010 <52040067+sunnyhmz7010@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:26:38 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E5=AF=B9=E6=98=8E=E7=A1=AE=E7=9A=84=20eSTK?= =?UTF-8?q?=20=E7=AE=A1=E7=90=86=E5=BA=94=E7=94=A8=E5=BC=82=E5=B8=B8?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E9=87=8D=E7=BD=AE=E4=B8=80=E6=AC=A1=20SIM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/device/esim.go | 38 ++++- internal/device/esim_download.go | 5 +- internal/device/esim_sim_reset.go | 74 +++++++++ internal/device/esim_sim_reset_test.go | 132 ++++++++++++++++ internal/device/manager.go | 23 ++- internal/server/esim_api.go | 16 +- internal/server/esim_sim_reset.go | 104 +++++++++++++ internal/server/esim_sim_reset_test.go | 208 +++++++++++++++++++++++++ internal/server/server.go | 1 + 9 files changed, 584 insertions(+), 17 deletions(-) create mode 100644 internal/device/esim_sim_reset.go create mode 100644 internal/device/esim_sim_reset_test.go create mode 100644 internal/server/esim_sim_reset.go create mode 100644 internal/server/esim_sim_reset_test.go diff --git a/internal/device/esim.go b/internal/device/esim.go index e21f09e5..9183d9c1 100644 --- a/internal/device/esim.go +++ b/internal/device/esim.go @@ -64,6 +64,18 @@ var ( // HTTP layer can render the empty state instead of an error. var ErrNoEUICC = errNoEUICC +// ErrESIMManagementUnavailable 表示 eSTK 产品应用可访问,但两个管理应用均返回 6A82。 +// 普通 SIM 的 ErrNoEUICC 不属于此类异常,不能据此重置 SIM。 +var ErrESIMManagementUnavailable = errors.New("esim: eSTK management applications are unavailable (SW=6A82)") + +type euiccSelectError struct{ sw int } + +func (err *euiccSelectError) Error() string { + return fmt.Sprintf("%s (SELECT SW=%04X)", errNoEUICC, err.sw) +} + +func (*euiccSelectError) Unwrap() error { return errNoEUICC } + // ErrEUICCChannelStuck means the modem kept rejecting MANAGE CHANNEL or // SELECT ISD-R with the EC20's non-descriptive +CME ERROR: 0 after retries. // This is observed after SIM hot-swap and requires a modem restart; repeating @@ -453,7 +465,7 @@ func (manager *Manager) openEuiccOnceAID(ctx context.Context, id, aidHex string) } if sw != 0x9000 { channel.close(context.Background()) - return nil, errNoEUICC + return nil, &euiccSelectError{sw: sw} } return channel, nil } @@ -530,22 +542,37 @@ func (manager *Manager) openPCSCEuiccOnceAID(ctx context.Context, id string, can // 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 { + aids, err := manager.discoverEuiccAIDsForInventory(ctx, id) + if err != nil { + return []string{isdRAID} + } + return aids +} + +func (manager *Manager) discoverEuiccAIDsForInventory(ctx context.Context, id string) ([]string, error) { + managementMissing := false product, err := manager.openEuiccAID(ctx, id, estkProductAID) if err == nil { product.close(context.Background()) var found []string + missing := 0 for _, aid := range []string{estkSE0AID, estkSE1AID} { channel, err := manager.openEuiccAID(ctx, id, aid) if err != nil { + var selectErr *euiccSelectError + if errors.As(err, &selectErr) && selectErr.sw == 0x6A82 { + missing++ + } continue } channel.close(context.Background()) found = append(found, aid) } if len(found) > 0 { - return found + return found, nil } + managementMissing = missing == 2 } var found []string @@ -558,11 +585,14 @@ func (manager *Manager) discoverEuiccAIDs(ctx context.Context, id string) []stri found = append(found, aid) } if len(found) > 0 { - return found + return found, nil + } + if managementMissing { + return nil, ErrESIMManagementUnavailable } // 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} + return []string{isdRAID}, nil } func isTransientEuiccCME(err error) bool { diff --git a/internal/device/esim_download.go b/internal/device/esim_download.go index 19e6e462..71f80e7b 100644 --- a/internal/device/esim_download.go +++ b/internal/device/esim_download.go @@ -296,7 +296,10 @@ func (manager *Manager) ESIMInventory(ctx context.Context, id string) ([]EsimInv return nil, errESIMRecovering } - aids := manager.discoverEuiccAIDs(ctx, id) + aids, err := manager.discoverEuiccAIDsForInventory(ctx, id) + if err != nil { + return nil, err + } entries := make([]EsimInventoryEntry, 0, len(aids)) var lastErr error for _, aid := range aids { diff --git a/internal/device/esim_sim_reset.go b/internal/device/esim_sim_reset.go new file mode 100644 index 00000000..9df39bff --- /dev/null +++ b/internal/device/esim_sim_reset.go @@ -0,0 +1,74 @@ +package device + +import ( + "context" + "errors" + "fmt" + "time" +) + +// ResetSIM 仅重载已经关闭射频的 AT 模组 SIM,不切换 profile,也不执行在线模组重启。 +// 调用者必须先停止 VoWiFi;UICC 锁防止重置与其他卡操作交错。 +func (manager *Manager) ResetSIM(ctx context.Context, id string) error { + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + if err := manager.lockESIMContext(ctx); err != nil { + return err + } + defer manager.unlockESIM() + state, err := manager.lookup(id) + if err != nil { + return err + } + candidate := manager.candidateFor(state) + if !candidate.HasATPort() || isNativeQMICandidate(candidate) { + return errors.New("SIM reset requires an AT modem") + } + if manager.esimRecoveryActive(id) { + return errESIMRecovering + } + state.opMu.Lock() + defer state.opMu.Unlock() + if err := manager.validateActive(id, state); err != nil { + return err + } + client, err := manager.clientLocked(ctx, state, candidate) + if err != nil { + return err + } + mode, err := manager.readOperatingMode(ctx, client) + if err != nil { + return err + } + if mode != 4 { + return errors.New("SIM reset requires cellular RF to be disabled (CFUN=4)") + } + if err := manager.softResetSIMLocked(ctx, id, state, client); err != nil { + return err + } + var lastErr error + for { + response, err := manager.command(ctx, client, "AT+CPIN?") + if err == nil { + status, ready := parseCPIN(response) + if ready { + mode, err := manager.readOperatingMode(ctx, client) + if err != nil { + return err + } + if mode != 4 { + return errors.New("SIM reset did not preserve CFUN=4") + } + return nil + } + lastErr = fmt.Errorf("SIM is not ready: %s", status) + } else { + lastErr = err + } + select { + case <-ctx.Done(): + return fmt.Errorf("wait for SIM after reset: %w", errors.Join(ctx.Err(), lastErr)) + case <-time.After(500 * time.Millisecond): + } + } +} diff --git a/internal/device/esim_sim_reset_test.go b/internal/device/esim_sim_reset_test.go new file mode 100644 index 00000000..49572acd --- /dev/null +++ b/internal/device/esim_sim_reset_test.go @@ -0,0 +1,132 @@ +package device + +import ( + "context" + "errors" + "fmt" + "testing" + + "vocat/internal/modem" +) + +func simResetSelectSteps(aid, sw string) []clientStep { + return []clientStep{ + {command: `AT+CSIM=10,"0070000001"`, response: okResponse(`+CSIM: 6,"019000"`)}, + {command: fmt.Sprintf(`AT+CSIM=42,"01A4040010%s"`, aid), response: okResponse(fmt.Sprintf(`+CSIM: 4,"%s"`, sw))}, + {command: `AT+CSIM=10,"0070800100"`, response: okResponse(`+CSIM: 4,"9000"`)}, + } +} + +func TestESIMManagementFailureRequiresLiveESTKAndTwoMissingApplications(t *testing.T) { + for _, test := range []struct { + name, productSW, se0SW, se1SW, standardSW string + wantRecovery bool + }{ + {"ordinary_SIM", "6A82", "", "", "6A82", false}, + {"estk_management_missing", "9000", "6A82", "6A82", "6A82", true}, + {"different_card_error", "9000", "6985", "6A82", "6A82", false}, + {"one_SE_available", "9000", "9000", "6A82", "", false}, + {"generic_management_available", "9000", "6A82", "6A82", "9000", false}, + } { + t.Run(test.name, func(t *testing.T) { + steps := simResetSelectSteps(estkProductAID, test.productSW) + if test.productSW == "9000" { + steps = append(steps, simResetSelectSteps(estkSE0AID, test.se0SW)...) + steps = append(steps, simResetSelectSteps(estkSE1AID, test.se1SW)...) + } + if test.standardSW != "" { + steps = append(steps, simResetSelectSteps(isdRAID, test.standardSW)...) + steps = append(steps, simResetSelectSteps(xesimISDRAID, "6A82")...) + } + client := &transcriptClient{steps: steps} + manager, id := newStartedTestManager(t, client) + _, err := manager.discoverEuiccAIDsForInventory(context.Background(), id) + if got := errors.Is(err, ErrESIMManagementUnavailable); got != test.wantRecovery { + t.Fatalf("recovery required = %v, want %v; error=%v", got, test.wantRecovery, err) + } + if test.wantRecovery && errors.Is(err, ErrNoEUICC) { + t.Fatal("known eSTK failure was classified as an ordinary SIM") + } + client.assertDone(t) + }) + } +} + +func TestOrdinarySIMInventoryDoesNotAddChannelProbeOrReset(t *testing.T) { + var steps []clientStep + for _, aid := range []string{estkProductAID, isdRAID, xesimISDRAID, isdRAID} { + steps = append(steps, simResetSelectSteps(aid, "6A82")...) + } + client := &transcriptClient{steps: steps} + manager, id := newStartedTestManager(t, client) + if _, err := manager.ESIMInventory(context.Background(), id); !errors.Is(err, ErrNoEUICC) { + t.Fatalf("inventory error = %v", err) + } + client.assertDone(t) +} + +func TestResetSIMWaitsUntilReadyAndKeepsRFOff(t *testing.T) { + client := &transcriptClient{steps: []clientStep{ + {command: "AT+CFUN?", response: okResponse("+CFUN: 4")}, + {command: "AT+CFUN=0", response: okResponse()}, + {command: "AT+CFUN=4", response: okResponse()}, + {command: "AT+CPIN?", err: &modem.CommandError{Final: "+CME ERROR: 14"}}, + {command: "AT+CPIN?", response: okResponse("+CPIN: READY")}, + {command: "AT+CFUN?", response: okResponse("+CFUN: 4")}, + }} + manager, id := newStartedTestManager(t, client) + if err := manager.ResetSIM(context.Background(), id); err != nil { + t.Fatal(err) + } + client.assertDone(t) +} + +func TestResetSIMDoesNotInterruptAnOnlineModem(t *testing.T) { + client := &transcriptClient{steps: []clientStep{ + {command: "AT+CFUN?", response: okResponse("+CFUN: 1")}, + }} + manager, id := newStartedTestManager(t, client) + if err := manager.ResetSIM(context.Background(), id); err == nil { + t.Fatal("reset an online SIM") + } + client.assertDone(t) +} + +type cancelAfterSIMPowerDown struct { + *transcriptClient + cancel context.CancelFunc +} + +func (client *cancelAfterSIMPowerDown) Execute(ctx context.Context, command string) (modem.Response, error) { + response, err := client.transcriptClient.Execute(ctx, command) + if command == "AT+CFUN=0" && err == nil { + client.cancel() + } + return response, err +} + +func TestSIMPowerUpSurvivesCanceledRequest(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + transcript := &transcriptClient{steps: []clientStep{ + {command: "AT+CFUN=0", response: okResponse()}, + {command: "AT+CFUN=4", response: okResponse()}, + }} + manager, id := newStartedTestManager(t, &cancelAfterSIMPowerDown{transcript, cancel}) + if err := manager.softResetForProfileSwitch(ctx, id); !errors.Is(err, context.Canceled) { + t.Fatalf("reset error = %v, want cancellation", err) + } + transcript.assertDone(t) +} + +func TestSIMPowerUpAttemptedAfterUnconfirmedPowerDown(t *testing.T) { + client := &transcriptClient{steps: []clientStep{ + {command: "AT+CFUN=0", err: modem.ErrCommandTimeout}, + {command: "AT+CFUN=4", response: okResponse()}, + }} + manager, id := newStartedTestManager(t, client) + if err := manager.softResetForProfileSwitch(context.Background(), id); !errors.Is(err, modem.ErrCommandTimeout) { + t.Fatalf("reset error = %v, want timeout", err) + } + client.assertDone(t) +} diff --git a/internal/device/manager.go b/internal/device/manager.go index e8f1f3ba..8b1b29af 100644 --- a/internal/device/manager.go +++ b/internal/device/manager.go @@ -904,18 +904,30 @@ func (manager *Manager) softResetForProfileSwitch(ctx context.Context, id string manager.setResult(id, state, nil, err) return err } + return manager.softResetSIMLocked(ctx, id, state, client) +} + +// softResetSIMLocked 要求持有 opMu,使模式验证与 SIM 重置不能被其他模组操作插入。 +func (manager *Manager) softResetSIMLocked(ctx context.Context, id string, state *managedDevice, client modem.Client) error { state.dataMu.Lock() invalidateQMINetworkSession(state, manager.candidateFor(state)) state.dataMu.Unlock() - commandCtx, cancel := manager.withTimeout(ctx, manager.commandTimeout) + commandCtx, cancel := context.WithTimeout(ctx, manager.commandTimeout) defer cancel() // 1. Cycle SIM interface to minimum functionality / clear cached SIM files if manager.logger != nil { manager.logger.Info("recovering SIM with cellular RF disabled", "category", "hardware", "device_id", id, - "recovery_source", "profile_switch", "target_cfun", 4) + "recovery_source", "sim_reset", "target_cfun", 4) } if _, err := client.Execute(commandCtx, "AT+CFUN=0"); err != nil { + // 超时或取消时不能确认 CFUN=0 是否已生效,仍尝试恢复到 RF 关闭的工作模式。 + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, modem.ErrCommandTimeout) { + reloadCtx, cancelReload := context.WithTimeout(context.WithoutCancel(ctx), manager.commandTimeout) + _, reloadErr := client.Execute(reloadCtx, "AT+CFUN=4") + cancelReload() + err = errors.Join(err, reloadErr) + } err = fmt.Errorf("power down SIM for profile recovery: %w", err) manager.clearSnapshot(id, state) manager.setResult(id, state, nil, err) @@ -924,13 +936,16 @@ func (manager *Manager) softResetForProfileSwitch(ctx context.Context, id string select { case <-ctx.Done(): - return ctx.Err() case <-time.After(500 * time.Millisecond): } // 2. Reload the USIM without enabling RF. Snapshots can be nil or partial // during REFRESH; their zero-value FlightMode does not authorize CFUN=1. - _, err = client.Execute(commandCtx, "AT+CFUN=4") + // CFUN=0 成功后必须独立完成重新上电,不能因页面取消或原命令超时留在最低功能模式。 + reloadCtx, cancelReload := context.WithTimeout(context.WithoutCancel(ctx), manager.commandTimeout) + defer cancelReload() + _, err := client.Execute(reloadCtx, "AT+CFUN=4") + err = errors.Join(ctx.Err(), err) manager.clearSnapshot(id, state) manager.setResult(id, state, nil, err) diff --git a/internal/server/esim_api.go b/internal/server/esim_api.go index 14084115..49ba0e41 100644 --- a/internal/server/esim_api.go +++ b/internal/server/esim_api.go @@ -33,7 +33,7 @@ func (s *Server) handleESIM(w http.ResponseWriter, r *http.Request, rest []strin if !requireMethod(w, r, http.MethodGet) { return true } - s.writeEsimOverview(w, r, physicalID, physicalPresent) + s.writeEsimOverview(w, r, physicalID, physicalPresent, configuredID) return true } @@ -43,7 +43,7 @@ func (s *Server) handleESIM(w http.ResponseWriter, r *http.Request, rest []strin if !requireMethod(w, r, http.MethodGet) { return true } - s.writeEsimGroups(w, r, physicalID, physicalPresent) + s.writeEsimGroups(w, r, physicalID, physicalPresent, configuredID) return true } if len(rest) == 2 && r.Method == http.MethodDelete { @@ -148,11 +148,11 @@ func (s *Server) handleEsimNotificationRetry(w http.ResponseWriter, r *http.Requ // esimInfo loads the eUICC profile list. The string result is "ok" (use info), // "empty" (no usable eUICC — render the empty state), or "error" (an error // response has already been written). -func (s *Server) esimInfo(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool) (string, []device.EsimInventoryEntry) { +func (s *Server) esimInfo(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool, configuredID string) (string, []device.EsimInventoryEntry) { if s.devices == nil || !physicalPresent { return "empty", nil } - info, err := s.devices.ESIMInventory(r.Context(), physicalID) + info, err := s.readESIMInventory(r.Context(), configuredID, physicalID) if err != nil { if errors.Is(err, device.ErrNoEUICC) { return "empty", nil @@ -164,8 +164,8 @@ func (s *Server) esimInfo(w http.ResponseWriter, r *http.Request, physicalID str } // writeEsimOverview returns { chipInfo, profiles } for the eSIM tab. -func (s *Server) writeEsimOverview(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool) { - status, info := s.esimInfo(w, r, physicalID, physicalPresent) +func (s *Server) writeEsimOverview(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool, configuredID string) { + status, info := s.esimInfo(w, r, physicalID, physicalPresent, configuredID) switch status { case "error": return @@ -276,8 +276,8 @@ func (s *Server) esimChipInfo(r *http.Request, physicalID string) map[string]any } // writeEsimGroups returns just the profile groups for the /esim/profiles call. -func (s *Server) writeEsimGroups(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool) { - status, info := s.esimInfo(w, r, physicalID, physicalPresent) +func (s *Server) writeEsimGroups(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool, configuredID string) { + status, info := s.esimInfo(w, r, physicalID, physicalPresent, configuredID) switch status { case "error": return diff --git a/internal/server/esim_sim_reset.go b/internal/server/esim_sim_reset.go new file mode 100644 index 00000000..39261c7f --- /dev/null +++ b/internal/server/esim_sim_reset.go @@ -0,0 +1,104 @@ +package server + +import ( + "context" + "errors" + "fmt" + "time" + + "vocat/internal/device" + "vocat/internal/store" +) + +type simResetController interface { + ResetSIM(context.Context, string) error +} + +// readESIMInventory 仅对已确认的 eSTK 管理应用异常尝试一次恢复。 +// 检测返回后已释放 UICC 锁,才能停止需要该锁完成鉴权清理的 VoWiFi。 +func (s *Server) readESIMInventory(ctx context.Context, configuredID, physicalID string) ([]device.EsimInventoryEntry, error) { + info, err := s.devices.ESIMInventory(ctx, physicalID) + if err == nil { + s.smsSyncMu.Lock() + delete(s.esimSIMResetAttempted, physicalID) + s.smsSyncMu.Unlock() + return info, nil + } + resetter, supported := s.devices.(simResetController) + if !supported || s.store == nil || !errors.Is(err, device.ErrESIMManagementUnavailable) { + return info, err + } + // 与切换 profile 和 SIM 短信同步串行;并发页面请求必须先重新读取,不能沿用过期的失败。 + s.smsSyncMu.Lock() + defer s.smsSyncMu.Unlock() + if ctx.Err() != nil { + return nil, ctx.Err() + } + info, err = s.devices.ESIMInventory(ctx, physicalID) + if !errors.Is(err, device.ErrESIMManagementUnavailable) { + if err == nil { + delete(s.esimSIMResetAttempted, physicalID) + } + return info, err + } + if s.esimSIMResetAttempted[physicalID] { + return nil, err + } + config, configErr := s.store.Device(ctx, configuredID) + if configErr != nil { + return nil, configErr + } + if config.DeviceType != store.DeviceTypePCIeEC20EC25 { + return nil, err + } + if s.esimSIMResetAttempted == nil { + s.esimSIMResetAttempted = make(map[string]bool) + } + s.esimSIMResetAttempted[physicalID] = true + // 开始停止运行时后,由独立且有界的上下文完成整个恢复和收尾。 + recoveryCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 90*time.Second) + defer cancel() + info, err = s.resetSIMForESIM(recoveryCtx, configuredID, physicalID, resetter) + if err == nil { + delete(s.esimSIMResetAttempted, physicalID) + } else if s.logger != nil { + // 同次故障仅记录这一次真正的恢复失败,普通 SIM 不进入此分支。 + s.logger.Warn("eSIM SIM reset recovery failed", "device_id", configuredID, "error", device.HardwareErrorDetail(err)) + } + return info, err +} + +func (s *Server) resetSIMForESIM(ctx context.Context, configuredID, physicalID string, resetter simResetController) (info []device.EsimInventoryEntry, err error) { + endMaintenance := func() {} + if maintenance, ok := s.vowifi.(VoWiFiMaintenanceController); ok { + if err := maintenance.BeginMaintenance(configuredID); err != nil { + return nil, fmt.Errorf("prepare VoWiFi for SIM reset: %w", err) + } + endMaintenance = func() { maintenance.EndMaintenance(configuredID) } + } + defer func() { + endMaintenance() + restoreCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 15*time.Second) + defer cancel() + // 重新读取保存的配置,避免恢复期间覆盖用户刚修改的 VoWiFi 意愿。 + config, restoreErr := s.store.Device(restoreCtx, configuredID) + if restoreErr == nil && s.vowifi != nil { + _, restoreErr = s.vowifi.RequestEnabled(configuredID, config.VoWiFiEnabled) + } + if restoreErr != nil { + err = errors.Join(err, fmt.Errorf("restore VoWiFi after SIM reset: %w", restoreErr)) + } + }() + if err = s.quiesceVoWiFiForProfileSwitch(ctx, configuredID); err != nil { + return nil, err + } + if err = resetter.ResetSIM(ctx, physicalID); err != nil { + return nil, err + } + info, err = s.devices.ESIMInventory(ctx, physicalID) + if errors.Is(err, device.ErrNoEUICC) { + // 已明确识别到 eSTK 异常;恢复后仍无法识别,不能伪装成普通 SIM 的正常空结果。 + return nil, fmt.Errorf("%w after SIM reset", device.ErrESIMManagementUnavailable) + } + return info, err +} diff --git a/internal/server/esim_sim_reset_test.go b/internal/server/esim_sim_reset_test.go new file mode 100644 index 00000000..854261ce --- /dev/null +++ b/internal/server/esim_sim_reset_test.go @@ -0,0 +1,208 @@ +package server + +import ( + "context" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "vocat/internal/device" + "vocat/internal/loghub" + "vocat/internal/store" +) + +type simResetTestController struct { + fakeDeviceController + mu sync.Mutex + inventoryErr error + afterResetErr error + resetErr error + resets int + vowifi *fakeEsimVoWiFiController + onReset func(context.Context) error +} + +func (controller *simResetTestController) ESIMInventory(ctx context.Context, _ string) ([]device.EsimInventoryEntry, error) { + if ctx.Err() != nil { + return nil, ctx.Err() + } + controller.mu.Lock() + defer controller.mu.Unlock() + if controller.inventoryErr != nil { + return nil, controller.inventoryErr + } + return []device.EsimInventoryEntry{{Info: device.EsimInfo{EID: "test-eid"}}}, nil +} + +func (controller *simResetTestController) ResetSIM(ctx context.Context, _ string) error { + if controller.vowifi.enabled { + return errors.New("SIM reset began before VoWiFi stopped") + } + controller.mu.Lock() + controller.resets++ + controller.mu.Unlock() + if controller.onReset != nil { + if err := controller.onReset(ctx); err != nil { + return err + } + } + controller.mu.Lock() + defer controller.mu.Unlock() + if controller.resetErr != nil { + return controller.resetErr + } + controller.inventoryErr = controller.afterResetErr + return nil +} + +func newSIMResetTestServer(t *testing.T, controller *simResetTestController) (*Server, *loghub.Hub) { + t.Helper() + 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: "configured", Name: "test", DeviceType: store.DeviceTypePCIeEC20EC25, VoWiFiEnabled: true, + }); err != nil { + t.Fatal(err) + } + hub := loghub.New(slog.NewTextHandler(io.Discard, nil), 100) + controller.vowifi = &fakeEsimVoWiFiController{enabled: true} + return &Server{store: database, devices: controller, vowifi: controller.vowifi, logger: slog.New(hub)}, hub +} + +func TestOrdinarySIMRequestsStayEmptyWithoutResetOrLogs(t *testing.T) { + controller := &simResetTestController{inventoryErr: device.ErrNoEUICC} + server, hub := newSIMResetTestServer(t, controller) + for range 4 { + recorder := httptest.NewRecorder() + server.handleESIM(recorder, httptest.NewRequest(http.MethodGet, "/esim", nil), nil, "physical", true, "configured") + if recorder.Code != http.StatusOK || decodeData(t, recorder)["chipInfo"] != nil { + t.Fatalf("ordinary SIM response = %d %s", recorder.Code, recorder.Body.String()) + } + } + if controller.resets != 0 || !controller.vowifi.enabled { + t.Fatalf("ordinary SIM interrupted: resets=%d, VoWiFi=%v", controller.resets, controller.vowifi.enabled) + } + if logs := hub.History(100, slog.LevelDebug, ""); len(logs) != 0 { + t.Fatalf("ordinary SIM produced logs: %+v", logs) + } +} + +func TestESIMSIMResetFailureIsAttemptedOnceAndRestoresVoWiFi(t *testing.T) { + for _, test := range []struct { + name string + resetErr error + afterResetErr error + wantSuccess bool + }{ + {name: "recovered", wantSuccess: true}, + {name: "still_missing", afterResetErr: device.ErrESIMManagementUnavailable}, + {name: "not_detected_after_reset", afterResetErr: device.ErrNoEUICC}, + {name: "reset_rejected", resetErr: errors.New("reset rejected")}, + } { + t.Run(test.name, func(t *testing.T) { + controller := &simResetTestController{ + inventoryErr: device.ErrESIMManagementUnavailable, resetErr: test.resetErr, afterResetErr: test.afterResetErr, + } + server, hub := newSIMResetTestServer(t, controller) + info, err := server.readESIMInventory(context.Background(), "configured", "physical") + if (err == nil) != test.wantSuccess { + t.Fatalf("success = %v, want %v; error=%v", err == nil, test.wantSuccess, err) + } + if test.wantSuccess && len(info) != 1 { + t.Fatalf("restored inventory = %+v", info) + } + if test.afterResetErr == device.ErrNoEUICC && errors.Is(err, device.ErrNoEUICC) { + t.Fatal("known recovery failure was hidden as an ordinary SIM") + } + for range 3 { + _, _ = server.readESIMInventory(context.Background(), "configured", "physical") + } + if controller.resets != 1 || !controller.vowifi.enabled { + t.Fatalf("resets=%d, restored VoWiFi=%v", controller.resets, controller.vowifi.enabled) + } + logs := hub.History(100, slog.LevelWarn, "eSIM SIM reset recovery failed") + if !test.wantSuccess && len(logs) != 1 { + t.Fatalf("repeated recovery logs: %+v", logs) + } + }) + } +} + +func TestESIMSIMResetConcurrentReadersShareOneRecovery(t *testing.T) { + entered, release := make(chan struct{}), make(chan struct{}) + controller := &simResetTestController{inventoryErr: device.ErrESIMManagementUnavailable} + controller.onReset = func(context.Context) error { + close(entered) + <-release + return nil + } + server, _ := newSIMResetTestServer(t, controller) + var readers sync.WaitGroup + errorsSeen := make(chan error, 5) + read := func() { + defer readers.Done() + _, err := server.readESIMInventory(context.Background(), "configured", "physical") + errorsSeen <- err + } + readers.Add(1) + go read() + <-entered + for range 4 { + readers.Add(1) + go read() + } + close(release) + readers.Wait() + close(errorsSeen) + for err := range errorsSeen { + if err != nil { + t.Fatal(err) + } + } + if controller.resets != 1 { + t.Fatalf("concurrent readers performed %d resets", controller.resets) + } +} + +func TestESIMSIMResetCompletesAfterRequestCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + controller := &simResetTestController{inventoryErr: device.ErrESIMManagementUnavailable} + controller.onReset = func(recoveryCtx context.Context) error { + cancel() + return recoveryCtx.Err() + } + server, _ := newSIMResetTestServer(t, controller) + if _, err := server.readESIMInventory(ctx, "configured", "physical"); err != nil { + t.Fatalf("committed recovery canceled with browser: %v", err) + } + if !controller.vowifi.enabled { + t.Fatal("VoWiFi was not restored") + } +} + +func TestESIMSIMResetRestoresLatestSavedVoWiFiSetting(t *testing.T) { + controller := &simResetTestController{inventoryErr: device.ErrESIMManagementUnavailable} + server, _ := newSIMResetTestServer(t, controller) + controller.onReset = func(ctx context.Context) error { + config, err := server.store.Device(ctx, "configured") + if err != nil { + return err + } + config.VoWiFiEnabled = false + return server.store.UpsertDevice(ctx, config) + } + if _, err := server.readESIMInventory(context.Background(), "configured", "physical"); err != nil { + t.Fatal(err) + } + if controller.vowifi.enabled { + t.Fatal("recovery overrode the newly disabled VoWiFi setting") + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 968ef939..372ea89d 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -90,6 +90,7 @@ type Server struct { lookupPublicIP func(context.Context, string) (exportproxy.PublicIPInfo, error) automaticTasks *automaticTaskScheduler smsSyncMu sync.Mutex + esimSIMResetAttempted map[string]bool // 由 smsSyncMu 保护;读取成功后允许下一次故障恢复。 smsStorageMu sync.Mutex smsStorage map[string]device.SMSStorageUsage cellularDataOnce sync.Once From e233cef259cd402ac02a7b7bef39385e1c0989be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A5=BD=E5=90=8D=E5=AD=97?= Date: Fri, 9 Oct 2026 19:57:02 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E4=BF=AE=E5=A4=8DPR167=20review=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/device/esim.go | 6 +- internal/device/esim_test.go | 20 ++++ internal/device/logging.go | 4 + internal/device/logging_test.go | 2 +- internal/device/vowifi_qmi.go | 5 +- internal/device/vowifi_qmi_cleanup_test.go | 60 ++++++++++ internal/vowifi/ec20_adapter.go | 108 ++++++++++------- internal/vowifi/ec20_isim_fallback_test.go | 130 +++++++++++++++++++++ 8 files changed, 292 insertions(+), 43 deletions(-) create mode 100644 internal/device/vowifi_qmi_cleanup_test.go create mode 100644 internal/vowifi/ec20_isim_fallback_test.go diff --git a/internal/device/esim.go b/internal/device/esim.go index 9183d9c1..eb040197 100644 --- a/internal/device/esim.go +++ b/internal/device/esim.go @@ -461,7 +461,11 @@ func (manager *Manager) openEuiccOnceAID(ctx context.Context, id, aidHex string) // Drain the select FCP the card is holding with a proper GET RESPONSE // (CLA=0x80|channel, INS=0xC0). transmit() injects the channel into the // CLA low nibble, so the first byte here stays 0x80. - _, sw, _ = channel.transmit(ctx, []byte{0x80, 0xC0, 0x00, 0x00, byte(sw & 0xFF)}, 0x80) + _, sw, err = channel.transmit(ctx, []byte{0x80, 0xC0, 0x00, 0x00, byte(sw & 0xFF)}, 0x80) + if err != nil { + channel.close(context.Background()) + return nil, fmt.Errorf("esim: AT SELECT GET RESPONSE AID=%s: %w", aidHex, err) + } } if sw != 0x9000 { channel.close(context.Background()) diff --git a/internal/device/esim_test.go b/internal/device/esim_test.go index f29b814a..4a6fb298 100644 --- a/internal/device/esim_test.go +++ b/internal/device/esim_test.go @@ -449,6 +449,26 @@ func TestEuiccSASTrimsCardPadding(t *testing.T) { } } +func TestOpenEuiccGETResponsePreservesTransportError(t *testing.T) { + for _, cause := range []error{modem.ErrCommandTimeout, context.Canceled, context.DeadlineExceeded, + &modem.CommandError{Command: `AT+CSIM=10,"81C0000010"`, Final: "+CME ERROR: 0"}} { + t.Run(cause.Error(), func(t *testing.T) { + client := &transcriptClient{steps: []clientStep{ + {command: `AT+CSIM=10,"0070000001"`, response: okResponse(`+CSIM: 6,"019000"`)}, + {command: fmt.Sprintf(`AT+CSIM=42,"01A4040010%s"`, isdRAID), response: okResponse(`+CSIM: 4,"6110"`)}, + {command: `AT+CSIM=10,"81C0000010"`, err: cause}, + {command: `AT+CSIM=10,"0070800100"`, response: okResponse(`+CSIM: 4,"9000"`)}, + }} + manager, id := newStartedTestManager(t, client) + channel, err := manager.openEuiccOnceAID(context.Background(), id, isdRAID) + if channel != nil || !errors.Is(err, cause) || errors.Is(err, errNoEUICC) { + t.Fatalf("open eUICC = %v, %v; want transport error %v, not absent eUICC", channel, err, cause) + } + client.assertDone(t) + }) + } +} + func TestTransientEuiccCMEClassification(t *testing.T) { err := fmt.Errorf("select ISD-R: %w", &modem.CommandError{ Command: `AT+CSIM=42,"01A40400"`, diff --git a/internal/device/logging.go b/internal/device/logging.go index eb0d8bdc..222590bb 100644 --- a/internal/device/logging.go +++ b/internal/device/logging.go @@ -39,6 +39,10 @@ func HardwareErrorDetail(err error) string { func redactCommandErrors(detail string, err error) string { if commandErr, ok := err.(*modem.CommandError); ok { detail = strings.ReplaceAll(detail, commandErr.Error(), safeCommandError(commandErr)) + // 外层包装可能再次包含完整命令,也需要脱敏。 + if commandErr.Command != "" { + detail = strings.ReplaceAll(detail, commandErr.Command, safeATCommandName(commandErr.Command)) + } } switch wrapped := err.(type) { case interface{ Unwrap() []error }: diff --git a/internal/device/logging_test.go b/internal/device/logging_test.go index d4b352e1..07ae3d73 100644 --- a/internal/device/logging_test.go +++ b/internal/device/logging_test.go @@ -19,7 +19,7 @@ func TestHardwareErrorDetailRedactsATPayload(t *testing.T) { Final: "+CME ERROR: 13", Lines: []string{payload}, } - err := fmt.Errorf("select ISIM: %w", errors.Join(errors.New("reader reset failed"), commandErr)) + err := fmt.Errorf("select ISIM: %w", errors.Join(errors.New("reader reset failed"), fmt.Errorf("%s: %w", commandErr.Command, commandErr))) detail := HardwareErrorDetail(err) if strings.Contains(detail, payload) || strings.Contains(detail, "AT+CSIM=") { t.Fatalf("hardware error exposed AT payload: %q", detail) diff --git a/internal/device/vowifi_qmi.go b/internal/device/vowifi_qmi.go index 9ca05d68..6584a4ce 100644 --- a/internal/device/vowifi_qmi.go +++ b/internal/device/vowifi_qmi.go @@ -98,7 +98,10 @@ func (manager *Manager) AuthenticateNativeQMI(ctx context.Context, id string, ai step = 4 } } - closeErr := session.CloseLogicalChannel(ctx, 1, channel) + // 请求取消后仍释放本次打开的通道,清理最多等待两秒。 + closeContext, cancelClose := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second) + closeErr := session.CloseLogicalChannel(closeContext, 1, channel) + cancelClose() return errors.Join(err, closeErr) }) return diff --git a/internal/device/vowifi_qmi_cleanup_test.go b/internal/device/vowifi_qmi_cleanup_test.go new file mode 100644 index 00000000..c0167dbb --- /dev/null +++ b/internal/device/vowifi_qmi_cleanup_test.go @@ -0,0 +1,60 @@ +package device + +import ( + "context" + "errors" + "testing" + "time" +) + +type authenticationQMISession struct { + nativeQMIVoWiFiSession + cancel context.CancelFunc + t *testing.T + cleanup context.Context + closeErr error + closed int +} + +func (s *authenticationQMISession) OpenLogicalChannel(context.Context, uint8, []byte) (byte, error) { + return 7, nil +} + +func (s *authenticationQMISession) SendAPDU(ctx context.Context, slot, channel uint8, apdu []byte) ([]byte, error) { + s.cancel() + return nil, ctx.Err() +} + +func (s *authenticationQMISession) CloseLogicalChannel(ctx context.Context, slot, channel uint8) error { + if ctx.Err() != nil || slot != 1 || channel != 7 { + s.t.Fatalf("invalid cleanup: context=%v slot=%d channel=%d", ctx.Err(), slot, channel) + } + deadline, ok := ctx.Deadline() + if !ok || time.Until(deadline) <= 0 || time.Until(deadline) > 2*time.Second { + s.t.Fatalf("cleanup needs a bounded independent deadline: %v", deadline) + } + s.cleanup = ctx + return s.closeErr +} + +func (s *authenticationQMISession) Close() error { + s.closed++ + return nil +} + +func TestAuthenticateNativeQMIChannelCleanup(t *testing.T) { + for _, closeErr := range []error{nil, errors.New("close failed")} { + manager, _, id := newStartedNativeQMITestManager(t) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + session := &authenticationQMISession{cancel: cancel, t: t, closeErr: closeErr} + manager.qmiRadioOpener = func(context.Context, string) (qmiRadioSession, error) { return session, nil } + _, err := manager.AuthenticateNativeQMI(ctx, id, []byte{0xA0}, []byte{0x00, 0x88, 0x00, 0x81, 0x00}) + if !errors.Is(err, context.Canceled) || (closeErr != nil && !errors.Is(err, closeErr)) { + t.Fatalf("lost authentication or cleanup error: %v", err) + } + if session.cleanup == nil || !errors.Is(session.cleanup.Err(), context.Canceled) || session.closed != 1 { + t.Fatalf("cleanup context or session not released: %#v", session) + } + } +} diff --git a/internal/vowifi/ec20_adapter.go b/internal/vowifi/ec20_adapter.go index 48df835c..610a8dfe 100644 --- a/internal/vowifi/ec20_adapter.go +++ b/internal/vowifi/ec20_adapter.go @@ -515,10 +515,7 @@ func (adapter *EC20Adapter) authenticateWithApplication( } binding.aid = aid binding.application = application - binding.basicChannel = false - adapter.mu.Lock() - adapter.bindings[binding.iccid] = binding - adapter.mu.Unlock() + // 保留已验证的 CSIM 访问方式,验证新应用成功后再缓存绑定。 } if binding.aid == "" { if _, err := adapter.CheckReady(ctx, identity); err != nil { @@ -551,21 +548,9 @@ func (adapter *EC20Adapter) authenticateWithApplication( apdu := buildUSIMAuthenticateAPDU(challenge) var raw []byte if binding.basicChannel { - if err := adapter.selectBasicApplication( - ctx, - binding.deviceID, - binding.aid, - ); err != nil { - return AKAResult{}, err - } - raw, err = adapter.transmitBasicAPDU( - ctx, - binding.deviceID, - apdu, - true, - ) + raw, err = adapter.authenticateBasicApplication(ctx, binding, apdu) if err != nil { - return AKAResult{}, ErrEC20AKACommand + return AKAResult{}, err } } else { channel, err := adapter.openLogicalChannel( @@ -574,31 +559,64 @@ func (adapter *EC20Adapter) authenticateWithApplication( binding.aid, ) if err != nil { - return AKAResult{}, err - } - var commandErr error - raw, commandErr = adapter.transmitLogicalAPDU( - ctx, - binding.deviceID, - channel, - apdu, - true, - ) - closeErr := adapter.closeLogicalChannelWithCleanup( - binding.deviceID, - channel, - ) - if commandErr != nil { + var commandErr *modem.CommandError + if binding.application != "ISIM" || ctx.Err() != nil || + !errors.As(err, &commandErr) || commandErr.Final != "ERROR" { + return AKAResult{}, err + } + // 仅在 CCHO 明确拒绝时尝试 CSIM,严格 ISIM 不降级为 USIM。 + var basicErr error + raw, basicErr = adapter.authenticateBasicApplication(ctx, binding, apdu) + if basicErr != nil { + if errors.Is(basicErr, ErrEC20AKACommand) { + return AKAResult{}, errors.Join(fmt.Errorf("open application: %w", commandErr), basicErr) + } + return AKAResult{}, errors.Join(err, basicErr) + } + binding.basicChannel = true + } else { + var commandErr error + raw, commandErr = adapter.transmitLogicalAPDU( + ctx, + binding.deviceID, + channel, + apdu, + true, + ) + closeErr := adapter.closeLogicalChannelWithCleanup( + binding.deviceID, + channel, + ) + if commandErr != nil { + if closeErr != nil { + return AKAResult{}, errors.Join(commandErr, closeErr) + } + return AKAResult{}, commandErr + } if closeErr != nil { - return AKAResult{}, errors.Join(commandErr, closeErr) + return AKAResult{}, closeErr } - return AKAResult{}, commandErr - } - if closeErr != nil { - return AKAResult{}, closeErr } } - return parseUSIMAuthenticateResponse(raw) + result, err := parseUSIMAuthenticateResponse(raw) + if err != nil { + return AKAResult{}, err + } + adapter.mu.Lock() + adapter.bindings[binding.iccid] = binding + adapter.mu.Unlock() + return result, nil +} + +func (adapter *EC20Adapter) authenticateBasicApplication(ctx context.Context, binding ec20SIMBinding, apdu []byte) ([]byte, error) { + if err := adapter.selectBasicApplication(ctx, binding.deviceID, binding.aid); err != nil { + return nil, err + } + raw, err := adapter.transmitBasicAPDU(ctx, binding.deviceID, apdu, true) + if err != nil { + return nil, ErrEC20AKACommand + } + return raw, nil } func buildUSIMAuthenticateAPDU(challenge AKAChallenge) []byte { @@ -1058,6 +1076,13 @@ func (adapter *EC20Adapter) discoverPreferredAKAApplication( aidPrefix string, application string, ) (string, string, error) { + // EF_DIR 发现使用多条基本通道命令,必须持有同一事务锁。 + adapter.apduMu.Lock() + defer adapter.apduMu.Unlock() + if locker, ok := adapter.executor.(EC20UICCLocker); ok { + locker.LockUICC() + defer locker.UnlockUICC() + } response, err := adapter.execute(ctx, deviceID, "AT+CUAD") if err == nil { data, parseErr := parseCUADData(response) @@ -1069,6 +1094,9 @@ func (adapter *EC20Adapter) discoverPreferredAKAApplication( } } } + if discovered, discoverErr := adapter.discoverBasicApplicationAID(ctx, deviceID, aidPrefix); discoverErr == nil { + return discovered, application, nil + } // AT+CUAD is optional. Returning the standard AID prefix still lets CCHO // perform the authoritative application probe on older EC20 firmware. return aidPrefix, application, nil @@ -1143,7 +1171,7 @@ func (adapter *EC20Adapter) openLogicalChannel( fmt.Sprintf(`AT+CCHO="%s"`, aid), ) if err != nil { - return 0, fmt.Errorf("%w: open application", ErrEC20ApplicationAbsent) + return 0, fmt.Errorf("%w: open application: %w", ErrEC20ApplicationAbsent, err) } value := valueAfterATPrefix(response, "+CCHO:") if value == "" { diff --git a/internal/vowifi/ec20_isim_fallback_test.go b/internal/vowifi/ec20_isim_fallback_test.go new file mode 100644 index 00000000..4a157302 --- /dev/null +++ b/internal/vowifi/ec20_isim_fallback_test.go @@ -0,0 +1,130 @@ +package vowifi + +import ( + "context" + "encoding/hex" + "errors" + "fmt" + "strings" + "sync" + "testing" + + "vocat/internal/modem" +) + +type isimFallbackTranscript struct { + *ec20Transcript + uiccMu sync.Mutex +} + +func (s *isimFallbackTranscript) LockUICC() { s.uiccMu.Lock() } +func (s *isimFallbackTranscript) UnlockUICC() { s.uiccMu.Unlock() } +func (s *isimFallbackTranscript) assertLocked(command string) { + if command != "AT+CCID" && command != "AT+CUAD" && s.uiccMu.TryLock() { + s.uiccMu.Unlock() + s.t.Fatalf("UICC transaction not locked: %s", command) + } +} +func (s *isimFallbackTranscript) ExecuteAT(ctx context.Context, id, command string) (modem.Response, error) { + s.assertLocked(command) + return s.ec20Transcript.ExecuteAT(ctx, id, command) +} +func (s *isimFallbackTranscript) ExecuteSensitiveAT(ctx context.Context, id, command string) (modem.Response, error) { + s.assertLocked(command) + return s.ec20Transcript.ExecuteSensitiveAT(ctx, id, command) +} + +func TestEC20ISIMStrictDiscoveryAndTransport(t *testing.T) { + const fullISIM = "A0000000871004FFFFFFFF8903020000" + const fullUSIM = "A0000000871002FFFFFFFF8903020000" + identity := SIMIdentity{ICCID: "8901000000000000001", IMSI: "310280000000001"} + for _, test := range []struct { + name string + knownBasic, fallback, selectFail, authFail, syncFailure bool + }{ + {name: "logical"}, + {name: "CCHO_rejected", fallback: true}, + {name: "known_basic", knownBasic: true, fallback: true}, + {name: "select_failure_keeps_binding", knownBasic: true, fallback: true, selectFail: true}, + {name: "auth_failure_keeps_binding", fallback: true, authFail: true}, + {name: "synchronization_failure", fallback: true, syncFailure: true}, + } { + t.Run(test.name, func(t *testing.T) { + // CUAD 只列出 USIM,完整 ISIM AID 必须从 EF_DIR 获取。 + steps := []ec20TranscriptStep{ + {command: "AT+CUAD", lines: []string{`+CUAD: "61124F10` + fullUSIM + `"`}}, + {command: `AT+CSIM=16,"00A40004023F0000"`, lines: []string{`+CSIM: 4,"9000"`}}, + {command: `AT+CSIM=16,"00A40004022F0000"`, lines: []string{`+CSIM: 4,"9000"`}}, + {command: `AT+CSIM=10,"00B2010400"`, lines: []string{`+CSIM: 44,"61124F10` + fullUSIM + `9000"`}}, + {command: `AT+CSIM=10,"00B2020400"`, lines: []string{`+CSIM: 44,"61124F10` + fullISIM + `9000"`}}, + } + authAPDU := strings.ToUpper(hex.EncodeToString(buildUSIMAuthenticateAPDU(AKAChallenge{}))) + response := successfulUSIMResponse() + if test.syncFailure { + response = synchronizationFailureUSIMResponse() + } + encoded := strings.ToUpper(hex.EncodeToString(response)) + attempts := 2 + if test.selectFail || test.authFail { + attempts = 1 + } + for attempt := 0; attempt < attempts; attempt++ { + steps = append(steps, ec20TranscriptStep{command: "AT+CCID", lines: []string{"+CCID: " + identity.ICCID}}) + if test.fallback { + if !test.knownBasic && attempt == 0 { + command := `AT+CCHO="` + fullISIM + `"` + steps = append(steps, ec20TranscriptStep{command: command, err: &modem.CommandError{Command: command, Final: "ERROR"}, final: "ERROR"}) + } + sw := "9000" + if test.selectFail { + sw = "6A82" + } + steps = append(steps, ec20TranscriptStep{command: `AT+CSIM=42,"00A4040410` + fullISIM + `"`, lines: []string{`+CSIM: 4,"` + sw + `"`}}) + if !test.selectFail { + step := ec20TranscriptStep{command: fmt.Sprintf(`AT+CSIM=%d,"%s"`, len(authAPDU), authAPDU), sensitive: true, lines: []string{fmt.Sprintf(`+CSIM: %d,"%s"`, len(encoded), encoded)}} + if test.authFail { + step.err = errors.New("secret authentication payload") + } + steps = append(steps, step) + } + } else { + steps = append(steps, + ec20TranscriptStep{command: `AT+CCHO="` + fullISIM + `"`, lines: []string{"+CCHO: 2"}}, + ec20TranscriptStep{command: fmt.Sprintf(`AT+CGLA=2,%d,"%s"`, len(authAPDU), authAPDU), sensitive: true, lines: []string{fmt.Sprintf(`+CGLA: %d,"%s"`, len(encoded), encoded)}}, + ec20TranscriptStep{command: "AT+CCHC=2"}, + ) + } + } + transcript := &isimFallbackTranscript{ec20Transcript: &ec20Transcript{t: t, steps: steps}} + adapter, err := NewEC20Adapter(transcript, EC20AdapterOptions{}) + if err != nil { + t.Fatal(err) + } + original := ec20SIMBinding{deviceID: "ec20-1", iccid: identity.ICCID, imsi: identity.IMSI, aid: fullUSIM, application: "USIM", basicChannel: test.knownBasic} + adapter.bindings[identity.ICCID] = original + for attempt := 0; attempt < attempts; attempt++ { + result, err := adapter.AuthenticateWithPreference(context.Background(), identity, AKAChallenge{}, "isim_strict") + if test.selectFail { + if err == nil { + t.Fatal("select failure was not returned") + } + } else if test.authFail { + if !errors.Is(err, ErrEC20AKACommand) || errors.Is(err, ErrEC20ApplicationAbsent) || strings.Contains(err.Error(), "secret") { + t.Fatalf("auth error = %v", err) + } + } else if err != nil || result.SynchronizationFailure != test.syncFailure { + t.Fatalf("authentication = %#v, %v", result, err) + } + } + binding, _ := adapter.bindingFor(identity) + if test.selectFail || test.authFail { + if binding != original { + t.Fatalf("failed authentication changed binding: %#v", binding) + } + } else if binding.aid != fullISIM || binding.application != "ISIM" || binding.basicChannel != test.fallback { + t.Fatalf("incorrect cached binding: %#v", binding) + } + transcript.assertDone() + }) + } +}