From b19ae2240ab79ea802f177eb1cda89ccfaf459de Mon Sep 17 00:00:00 2001 From: MengMengCode <227010654+MengMengCode@users.noreply.github.com> Date: Sun, 16 Aug 2026 13:33:55 +0800 Subject: [PATCH] FIX #25 --- internal/device/esim.go | 61 ++++++++++++++++++++++++++++++++++-- internal/device/esim_test.go | 32 +++++++++++++++++++ internal/server/esim_api.go | 9 +++--- 3 files changed, 95 insertions(+), 7 deletions(-) diff --git a/internal/device/esim.go b/internal/device/esim.go index a7b5ffb..71990b3 100644 --- a/internal/device/esim.go +++ b/internal/device/esim.go @@ -829,7 +829,8 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid // stays a sibling of A0, directly under BF31. // EnableProfile is a non-idempotent commit. Once its APDU starts, a browser // disconnect or reverse-proxy timeout must not cancel it halfway through and - // skip the modem reset, otherwise EC20 remains in SIM failure (+CME 13). + // skip post-commit recovery; EC20 may otherwise remain in SIM failure + // (+CME 13). commitContext, cancelCommit := context.WithTimeout(context.WithoutCancel(ctx), csimAPDUTimeout) payload, err := channel.es10(commitContext, der) cancelCommit() @@ -900,6 +901,32 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid return err } manager.markCachedProfileEnabled(id, iccid) + // EnableProfile already requested an eUICC REFRESH. Some AT modems consume + // that proactive command and expose the new subscription immediately, so a + // full CFUN=1,1 reset would only add downtime. Give those devices a short + // chance to prove that their SIM cache is current; modems that keep reporting + // the old ICCID continue through the established reboot/recovery path below. + if manager.canVerifyProfileSwitchWithoutRestart(id) { + probeContext, cancelProbe := context.WithTimeout( + context.WithoutCancel(ctx), + profileSwitchRefreshProbeTimeout(manager), + ) + probeErr := manager.verifySwitchedICCIDAttempts(probeContext, id, iccid, 3, time.Second) + cancelProbe() + if probeErr == nil { + // Repopulate the cached snapshot while the AT transport is still live. + // Verification above is authoritative, so snapshot refresh remains + // best-effort just as it is after the legacy reboot path. + refreshContext, cancelRefresh := context.WithTimeout( + context.WithoutCancel(ctx), + manager.longTimeout, + ) + _, _ = manager.Refresh(refreshContext, id) + cancelRefresh() + manager.unlockESIM() + return nil + } + } // The eUICC accepted the target profile. Reset and repopulate the modem in // 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. @@ -1182,13 +1209,41 @@ func profileSwitchVerificationTimeout(manager *Manager) time.Duration { return timeout } +func profileSwitchRefreshProbeTimeout(manager *Manager) time.Duration { + // Allow both standard ICCID commands to consume one ordinary command + // timeout, plus a small window for the eUICC REFRESH to settle. Keep the + // optimisation bounded so an older modem reaches its required reboot soon. + timeout := manager.commandTimeout*2 + time.Second + if timeout < 3*time.Second { + return 3 * time.Second + } + if timeout > 10*time.Second { + return 10 * time.Second + } + return timeout +} + +func (manager *Manager) canVerifyProfileSwitchWithoutRestart(id string) bool { + _, native, err := manager.nativeQMIControl(id) + return err == nil && !native && !manager.isPCSCDevice(id) +} + // verifySwitchedICCID performs a fresh baseband read after recovery. An ES10c // result of zero only means the eUICC accepted the operation; the state change // is finalized by REFRESH/reset. The UI must not report success until the modem // is actually exposing the requested ICCID. func (manager *Manager) verifySwitchedICCID(ctx context.Context, id, expected string) error { + return manager.verifySwitchedICCIDAttempts(ctx, id, expected, 6, 2*time.Second) +} + +func (manager *Manager) verifySwitchedICCIDAttempts( + ctx context.Context, + id string, + expected string, + attempts int, + interval time.Duration, +) error { expected = strings.TrimSpace(expected) - const attempts = 6 var lastICCID string var lastErr error for attempt := 0; attempt < attempts; attempt++ { @@ -1250,7 +1305,7 @@ func (manager *Manager) verifySwitchedICCID(ctx context.Context, id, expected st } if attempt+1 < attempts { select { - case <-time.After(2 * time.Second): + case <-time.After(interval): case <-ctx.Done(): return fmt.Errorf("esim: verify enabled profile %s: %w", expected, ctx.Err()) } diff --git a/internal/device/esim_test.go b/internal/device/esim_test.go index 757e051..d5bc92e 100644 --- a/internal/device/esim_test.go +++ b/internal/device/esim_test.go @@ -207,6 +207,38 @@ func TestVerifySwitchedICCIDReadsLiveModem(t *testing.T) { client.assertDone(t) } +func TestVerifySwitchedICCIDAttemptsAllowsProactiveRefreshToSettle(t *testing.T) { + const target = "89492026266006792824" + client := &transcriptClient{steps: []clientStep{ + {command: "AT+CCID", response: okResponse("+CCID: 89441000400128014257F")}, + {command: "AT+CCID", response: okResponse("+CCID: " + target + "F")}, + }} + manager, id := newStartedTestManager(t, client) + if !manager.canVerifyProfileSwitchWithoutRestart(id) { + t.Fatal("AT modem should be eligible for refresh verification before restart") + } + if err := manager.verifySwitchedICCIDAttempts(context.Background(), id, target, 2, 0); err != nil { + t.Fatalf("verifySwitchedICCIDAttempts: %v", err) + } + client.assertDone(t) +} + +func TestProfileSwitchRefreshProbeTimeoutIsBounded(t *testing.T) { + for _, test := range []struct { + command time.Duration + want time.Duration + }{ + {command: 100 * time.Millisecond, want: 3 * time.Second}, + {command: 3 * time.Second, want: 7 * time.Second}, + {command: 30 * time.Second, want: 10 * time.Second}, + } { + manager := &Manager{commandTimeout: test.command} + if got := profileSwitchRefreshProbeTimeout(manager); got != test.want { + t.Fatalf("command timeout %s: probe timeout = %s, want %s", test.command, got, test.want) + } + } +} + func TestEUMManufacturerForWatchData(t *testing.T) { if got := eumManufacturerForEID("35840574202500000125000001855764"); got != "WatchData Technologies Ltd." { t.Fatalf("manufacturer = %q", got) diff --git a/internal/server/esim_api.go b/internal/server/esim_api.go index dd0b84e..a997a2c 100644 --- a/internal/server/esim_api.go +++ b/internal/server/esim_api.go @@ -427,14 +427,15 @@ func (s *Server) handleEsimSwitch(w http.ResponseWriter, r *http.Request, config return } // Profile operations run with RF disabled. The eUICC remains accessible in - // CFUN=4, and the recovery path reapplies CFUN=4 as soon as the AT port comes - // back after the mandatory modem reset. + // CFUN=4. Devices that consume the requested eUICC REFRESH stay online; + // older AT modems enter the reset recovery path and reapply CFUN=4 when the + // port returns. if _, err := s.devices.SetFlight(r.Context(), physicalID, true); err != nil { s.writeDeviceError(w, err) return } - // A confirmed profile switch includes the EC20 reset and a live ICCID read, - // which normally takes longer than the server's ordinary response deadline. + // A confirmed profile switch always includes a live ICCID read and may also + // include the EC20 reset fallback, so it can exceed the ordinary deadline. controller := http.NewResponseController(w) _ = controller.SetWriteDeadline(time.Time{}) aidHex := firstNonEmpty(request.AIDHex, request.AIDHexCamel)