From 2780dd96de0a94f85cd83edfb107154a1dd76099 Mon Sep 17 00:00:00 2001 From: MengMengCode <227010654+MengMengCode@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:56:30 +0800 Subject: [PATCH] Make EC20 eSIM channels coexist with VoWiFi --- internal/device/esim.go | 32 +++++++++++++++------ internal/device/esim_delete.go | 4 +-- internal/device/esim_disable.go | 4 +-- internal/device/esim_download.go | 12 ++++---- internal/device/esim_notifications.go | 8 +++--- internal/device/esim_rename.go | 4 +-- internal/device/esim_test.go | 40 +++++++++++++++++++++++++++ internal/device/manager.go | 18 ++++++++++++ internal/vowifi/ec20_adapter.go | 13 +++++++++ internal/vowifi/integration/at.go | 17 ++++++++++++ 10 files changed, 127 insertions(+), 25 deletions(-) diff --git a/internal/device/esim.go b/internal/device/esim.go index 84a430e..254cc0b 100644 --- a/internal/device/esim.go +++ b/internal/device/esim.go @@ -246,6 +246,15 @@ func (manager *Manager) openEuiccAID(ctx context.Context, id, aidHex string) (*e return channel, nil } lastErr = err + if attempt == 0 && errors.Is(err, errNoLogicalChannel) && + manager.releaseStaleEuiccChannel(ctx, id) { + // EC20 firmware exposes only one MANAGE CHANNEL slot. A canceled or + // interrupted APDU transaction can leave channel 1 allocated, after + // which every eSIM page load returns 6A81 until reboot. Closing the + // orphan while holding the shared UICC transaction lock makes the + // operation self-healing without disturbing an active AKA exchange. + continue + } if !isTransientEuiccCME(err) { return nil, err } @@ -262,6 +271,11 @@ func (manager *Manager) openEuiccAID(ctx context.Context, id, aidHex string) (*e return nil, lastErr } +func (manager *Manager) releaseStaleEuiccChannel(ctx context.Context, id string) bool { + _, sw, err := manager.csim(ctx, id, []byte{0x00, 0x70, 0x80, 0x01, 0x00}) + return err == nil && sw == 0x9000 +} + func (manager *Manager) openEuiccOnce(ctx context.Context, id string) (*euiccChannel, error) { return manager.openEuiccOnceAID(ctx, id, isdRAID) } @@ -601,8 +615,8 @@ func validProfileICCID(iccid string) bool { // ESIMListProfiles reads the eUICC profile list via ES10c GetProfilesInfo. func (manager *Manager) ESIMListProfiles(ctx context.Context, id string) (EsimInfo, error) { - manager.esimMu.Lock() - defer manager.esimMu.Unlock() + manager.lockESIM() + defer manager.unlockESIM() if manager.esimRecoveryActive(id) { if cached, ok := manager.cachedESIMInfo(id); ok { return cached, nil @@ -642,14 +656,14 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid if err != nil { return err } - manager.esimMu.Lock() + manager.lockESIM() if err := manager.waitForESIMRecovery(ctx, id); err != nil { - manager.esimMu.Unlock() + manager.unlockESIM() return err } channel, err := manager.openEuiccAID(ctx, id, targetEuiccAID(aidHex)) if err != nil { - manager.esimMu.Unlock() + manager.unlockESIM() return err } @@ -676,7 +690,7 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid // detached reset is safe in either case and prevents an uncertain switch // from leaving the modem's SIM cache unusable. manager.startProfileSwitchRecovery(id) - manager.esimMu.Unlock() + manager.unlockESIM() return err } // A transport SW 9000 only means the APDU reached the eUICC. The real outcome @@ -685,11 +699,11 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid result, ok := enableProfileResult(payload) if !ok { manager.startProfileSwitchRecovery(id) - manager.esimMu.Unlock() + manager.unlockESIM() return fmt.Errorf("esim: unexpected EnableProfile response %s", strings.ToUpper(hex.EncodeToString(payload))) } if err := enableProfileResponseError(byte(result), payload); err != nil { - manager.esimMu.Unlock() + manager.unlockESIM() return err } manager.markCachedProfileEnabled(id, iccid) @@ -697,7 +711,7 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid // a detached recovery so it survives an HTTP disconnect, but keep this API // call pending until the live modem ICCID proves that the switch took effect. manager.startProfileSwitchRecovery(id) - manager.esimMu.Unlock() + manager.unlockESIM() verifyContext, cancelVerify := context.WithTimeout(context.WithoutCancel(ctx), profileSwitchVerificationTimeout(manager)) defer cancelVerify() diff --git a/internal/device/esim_delete.go b/internal/device/esim_delete.go index f31fdb1..a26a39f 100644 --- a/internal/device/esim_delete.go +++ b/internal/device/esim_delete.go @@ -69,8 +69,8 @@ func (manager *Manager) ESIMDeleteProfile(ctx context.Context, id, iccid, aidHex if err != nil { return nil, err } - manager.esimMu.Lock() - defer manager.esimMu.Unlock() + manager.lockESIM() + defer manager.unlockESIM() if err := manager.waitForESIMRecovery(ctx, id); err != nil { return nil, err } diff --git a/internal/device/esim_disable.go b/internal/device/esim_disable.go index 6ae2723..0a4aefd 100644 --- a/internal/device/esim_disable.go +++ b/internal/device/esim_disable.go @@ -63,8 +63,8 @@ func (manager *Manager) ESIMDisableProfile(ctx context.Context, id, iccid, aidHe if err != nil { return err } - manager.esimMu.Lock() - defer manager.esimMu.Unlock() + manager.lockESIM() + defer manager.unlockESIM() if err := manager.waitForESIMRecovery(ctx, id); err != nil { return err } diff --git a/internal/device/esim_download.go b/internal/device/esim_download.go index 22b759c..5942606 100644 --- a/internal/device/esim_download.go +++ b/internal/device/esim_download.go @@ -48,8 +48,8 @@ func (manager *Manager) ESIMDownloadProfile(ctx context.Context, id string, para } } - manager.esimMu.Lock() - defer manager.esimMu.Unlock() + manager.lockESIM() + defer manager.unlockESIM() report("preflight", "正在检查 eUICC 剩余空间...", 10) channel, err := manager.openEuiccAID(ctx, id, targetEuiccAID(params.AIDHex)) @@ -228,8 +228,8 @@ type EsimChipInfo struct { // ESIMChipInfo reads the eUICC's EID, EUICCInfo2, and configured addresses for // the chip header. It takes the eSIM lock like the other card ops. func (manager *Manager) ESIMChipInfo(ctx context.Context, id string) (*EsimChipInfo, error) { - manager.esimMu.Lock() - defer manager.esimMu.Unlock() + manager.lockESIM() + defer manager.unlockESIM() var lastErr error for _, aid := range manager.discoverEuiccAIDs(ctx, id) { @@ -286,8 +286,8 @@ func readEsimChipInfo(ctx context.Context, channel *euiccChannel, aidHex string) // 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) { - manager.esimMu.Lock() - defer manager.esimMu.Unlock() + manager.lockESIM() + defer manager.unlockESIM() if manager.esimRecoveryActive(id) { return nil, errESIMRecovering } diff --git a/internal/device/esim_notifications.go b/internal/device/esim_notifications.go index 2dddd3c..c841398 100644 --- a/internal/device/esim_notifications.go +++ b/internal/device/esim_notifications.go @@ -295,8 +295,8 @@ func (channel *euiccChannel) deliverPendingNotifications(ctx context.Context) er // ESIMNotifications returns the notifications retained across every eUICC // storage exposed by the physical card. func (manager *Manager) ESIMNotifications(ctx context.Context, id string) ([]EsimNotification, error) { - manager.esimMu.Lock() - defer manager.esimMu.Unlock() + manager.lockESIM() + defer manager.unlockESIM() if err := manager.waitForESIMRecovery(ctx, id); err != nil { return nil, err } @@ -331,8 +331,8 @@ func (manager *Manager) ESIMNotifications(ctx context.Context, id string) ([]Esi // ESIMRetryNotification sends one retained notification and removes it from the // eUICC only after the receiver returns the SGP.22 success acknowledgement. func (manager *Manager) ESIMRetryNotification(ctx context.Context, id, aidHex string, sequenceNumber uint64) error { - manager.esimMu.Lock() - defer manager.esimMu.Unlock() + manager.lockESIM() + defer manager.unlockESIM() if err := manager.waitForESIMRecovery(ctx, id); err != nil { return err } diff --git a/internal/device/esim_rename.go b/internal/device/esim_rename.go index e2557d0..750a4d4 100644 --- a/internal/device/esim_rename.go +++ b/internal/device/esim_rename.go @@ -49,8 +49,8 @@ func (manager *Manager) ESIMRenameProfile(ctx context.Context, id, iccid, nickna if err != nil { return err } - manager.esimMu.Lock() - defer manager.esimMu.Unlock() + manager.lockESIM() + defer manager.unlockESIM() if err := manager.waitForESIMRecovery(ctx, id); err != nil { return err } diff --git a/internal/device/esim_test.go b/internal/device/esim_test.go index 14baab7..348553a 100644 --- a/internal/device/esim_test.go +++ b/internal/device/esim_test.go @@ -294,6 +294,46 @@ func TestEUICCChannelStuckWrapsTransientCME(t *testing.T) { } } +func TestOpenEuiccRecoversOrphanedSingleLogicalChannel(t *testing.T) { + client := &transcriptClient{steps: []clientStep{ + { + command: `AT+CSIM=10,"0070000001"`, + response: okResponse(`+CSIM: 6,"006A81"`), + }, + { + command: `AT+CSIM=10,"0070800100"`, + response: okResponse(`+CSIM: 4,"9000"`), + }, + { + command: `AT+CSIM=10,"0070000001"`, + response: okResponse(`+CSIM: 6,"019000"`), + }, + { + command: fmt.Sprintf( + `AT+CSIM=42,"01A4040010%s"`, + isdRAID, + ), + response: okResponse(`+CSIM: 4,"9000"`), + }, + { + command: `AT+CSIM=10,"0070800100"`, + response: okResponse(`+CSIM: 4,"9000"`), + }, + }} + manager, id := newStartedTestManager(t, client) + + manager.lockESIM() + channel, err := manager.openEuiccAID(context.Background(), id, isdRAID) + if err == nil { + channel.close(context.Background()) + } + manager.unlockESIM() + if err != nil { + t.Fatalf("open eUICC after orphaned channel: %v", err) + } + client.assertDone(t) +} + func TestWaitForESIMRecovery(t *testing.T) { done := make(chan struct{}) manager := &Manager{esimRecoveries: map[string]chan struct{}{"dev": done}} diff --git a/internal/device/manager.go b/internal/device/manager.go index 4263f3f..09c8676 100644 --- a/internal/device/manager.go +++ b/internal/device/manager.go @@ -25,6 +25,7 @@ type Options struct { type Manager struct { mu sync.RWMutex + uiccMu sync.Mutex // serializes all multi-command UICC/APDU transactions esimMu sync.Mutex // serializes eSIM card access (list/switch/download) esimRecoveryMu sync.Mutex esimRecoveries map[string]chan struct{} @@ -42,6 +43,23 @@ type Manager struct { ussdSessions map[string]ussdSession } +// LockUICC and UnlockUICC allow another in-process UICC client (currently the +// VoWiFi AKA adapter) to share the same transaction boundary as eSIM ES10. +// Individual AT commands are already serialized per modem, but a logical- +// channel transaction spans several commands and must not be interleaved. +func (manager *Manager) LockUICC() { manager.uiccMu.Lock() } +func (manager *Manager) UnlockUICC() { manager.uiccMu.Unlock() } + +func (manager *Manager) lockESIM() { + manager.esimMu.Lock() + manager.uiccMu.Lock() +} + +func (manager *Manager) unlockESIM() { + manager.uiccMu.Unlock() + manager.esimMu.Unlock() +} + // ussdSession tracks an open USSD dialog on a device so a follow-up Continue or // Cancel can be routed back to the right modem. The modem owns the actual // network session; this map only records which device a session id belongs to. diff --git a/internal/vowifi/ec20_adapter.go b/internal/vowifi/ec20_adapter.go index 537311b..19ce5ea 100644 --- a/internal/vowifi/ec20_adapter.go +++ b/internal/vowifi/ec20_adapter.go @@ -47,6 +47,11 @@ type EC20SensitiveATExecutor interface { ExecuteSensitiveAT(context.Context, string, string) (modem.Response, error) } +type EC20UICCLocker interface { + LockUICC() + UnlockUICC() +} + type EC20AdapterOptions struct { // PureAirplanePolicy reports the independent user policy. The adapter only // changes the transactional CFUN projection used by VoWiFi and never @@ -339,6 +344,10 @@ func (adapter *EC20Adapter) CheckReady( // cannot insert an APDU between a 61xx response and GET RESPONSE. adapter.apduMu.Lock() defer adapter.apduMu.Unlock() + if locker, ok := adapter.executor.(EC20UICCLocker); ok { + locker.LockUICC() + defer locker.UnlockUICC() + } aid, application, err := adapter.discoverAKAApplication(ctx, binding.deviceID) if err != nil { @@ -402,6 +411,10 @@ func (adapter *EC20Adapter) Authenticate( adapter.apduMu.Lock() defer adapter.apduMu.Unlock() + if locker, ok := adapter.executor.(EC20UICCLocker); ok { + locker.LockUICC() + defer locker.UnlockUICC() + } apdu := buildUSIMAuthenticateAPDU(challenge) var raw []byte diff --git a/internal/vowifi/integration/at.go b/internal/vowifi/integration/at.go index 4613502..35f8c5d 100644 --- a/internal/vowifi/integration/at.go +++ b/internal/vowifi/integration/at.go @@ -24,6 +24,23 @@ type ATMapper struct { Devices ATDeviceController } +type uiccLocker interface { + LockUICC() + UnlockUICC() +} + +func (mapper ATMapper) LockUICC() { + if locker, ok := mapper.Devices.(uiccLocker); ok { + locker.LockUICC() + } +} + +func (mapper ATMapper) UnlockUICC() { + if locker, ok := mapper.Devices.(uiccLocker); ok { + locker.UnlockUICC() + } +} + func (mapper ATMapper) Get(configuredID string) (device.Device, error) { physicalID, err := mapper.resolve(context.Background(), configuredID) if err != nil {