Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 38 additions & 47 deletions internal/device/esim.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -346,31 +358,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)
Expand Down Expand Up @@ -434,7 +421,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
Expand All @@ -451,14 +437,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())
Expand All @@ -468,7 +455,7 @@ 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
Expand All @@ -482,7 +469,7 @@ func (manager *Manager) openEuiccOnceAID(ctx context.Context, id, aidHex string)
}
if sw != 0x9000 {
channel.close(context.Background())
return nil, fmt.Errorf("%w: AT SELECT AID=%s SW=%04X", errNoEUICC, aidHex, sw)
return nil, &euiccSelectError{sw: sw}
}
return channel, nil
}
Expand Down Expand Up @@ -514,7 +501,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),
Expand All @@ -535,7 +522,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))
Expand All @@ -548,10 +535,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
}
Expand All @@ -562,50 +546,57 @@ 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)
aids, err := manager.discoverEuiccAIDsForInventory(ctx, id)
if err != nil {
return []string{isdRAID}
}
return aids
}

// 保留候选应用的探测失败,供最终未读到任何 eUICC 时记录;不将普通 SIM 的缺失应用单独报错。
func (manager *Manager) discoverEuiccAIDsWithErrors(ctx context.Context, id string) ([]string, error) {
var failures []error
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 {
failures = append(failures, err)
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, errors.Join(failures...)
return found, nil
}
} else {
failures = append(failures, err)
managementMissing = missing == 2
}

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, 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}, errors.Join(failures...)
return []string{isdRAID}, nil
}

func isTransientEuiccCME(err error) bool {
Expand Down
69 changes: 8 additions & 61 deletions internal/device/esim_download.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package device
import (
"context"
"errors"
"fmt"
"strings"
"time"
)
Expand Down Expand Up @@ -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()
Expand All @@ -297,86 +296,34 @@ 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)
}
aids, err := manager.discoverEuiccAIDsForInventory(ctx, id)
if err != nil {
return nil, err
}
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)
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)}
entries = append(entries, EsimInventoryEntry{Info: info, Chip: chip})
}
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
Expand Down
Loading
Loading