diff --git a/cmd/vocat/main.go b/cmd/vocat/main.go index c7fd3df..a5350aa 100644 --- a/cmd/vocat/main.go +++ b/cmd/vocat/main.go @@ -684,7 +684,18 @@ func configureVoWiFiRuntime( ) } } - if _, err := manager.RequestEnabled(deviceConfig.ID, true); err != nil { + requestEnable := func() error { + _, requestErr := manager.RequestEnabled(deviceConfig.ID, true) + return requestErr + } + if err := requestVoWiFiStartup( + ctx, + logger, + deviceConfig.DeviceType, + deviceConfig.ID, + wifi410VoWiFiStartupDelay, + requestEnable, + ); err != nil { _ = manager.Close(context.Background()) return nil, fmt.Errorf("start device %q VoWiFi policy: %w", deviceConfig.ID, err) } @@ -696,8 +707,55 @@ func configureVoWiFiRuntime( const ( vowifiStartupRadioAttempts = 3 vowifiStartupRadioDelay = time.Second + wifi410VoWiFiStartupDelay = 80 * time.Second ) +// requestVoWiFiStartup delays only the persisted startup policy for OpenStick +// 410 devices. Their Qualcomm UIM and Vodafone ePDG path need a short quiet +// period after a cold boot; user-triggered reconnects and every other device +// type continue to execute immediately. +func requestVoWiFiStartup( + ctx context.Context, + logger *slog.Logger, + deviceType string, + deviceID string, + delay time.Duration, + request func() error, +) error { + if deviceType != store.DeviceTypeWiFi410 || delay <= 0 { + return request() + } + if logger == nil { + logger = slog.Default() + } + logger.Info( + "OpenStick 410 VoWiFi startup delayed", + "device_id", deviceID, + "delay", delay, + ) + go func() { + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return + case <-timer.C: + } + if err := request(); err != nil { + logger.Warn( + "OpenStick 410 delayed VoWiFi startup failed", + "device_id", deviceID, + "error", err, + ) + } + }() + return nil +} + +func shouldDelayWiFi410VoWiFi(deviceType string, now, notBefore time.Time) bool { + return deviceType == store.DeviceTypeWiFi410 && now.Before(notBefore) +} + type flightModeSetter interface { SetFlight(context.Context, string, bool) (device.FlightResult, error) } @@ -1136,6 +1194,7 @@ func reconcileCardPolicies( vowifiManager *vowifiruntime.Manager, ) { observedCards := make(map[string]string) + wifi410StartupNotBefore := time.Now().Add(wifi410VoWiFiStartupDelay) reconcile := func() { policies, policyListErr := database.ListCardPolicies(ctx) if policyListErr == nil { @@ -1217,6 +1276,9 @@ func reconcileCardPolicies( } switch { case stateErr != nil || !state.Enabled: + if shouldDelayWiFi410VoWiFi(config.DeviceType, time.Now(), wifi410StartupNotBefore) { + continue + } _, _ = vowifiManager.RequestEnabled(config.ID, true) case state.ICCID != "" && !strings.EqualFold(strings.TrimSpace(state.ICCID), iccid): _, _ = vowifiManager.RequestReconnect(config.ID) diff --git a/internal/device/esim.go b/internal/device/esim.go index c51a070..d4c6c96 100644 --- a/internal/device/esim.go +++ b/internal/device/esim.go @@ -439,6 +439,9 @@ func (manager *Manager) openQMIEuiccOnceAID(ctx context.Context, id string, cand } const slot uint8 = 1 logicalChannel, err := session.OpenLogicalChannel(openContext, slot, aid) + if recoverySession, recoveryOK := session.(nativeQMIChannelRecoverySession); recoveryOK && isQMIInsufficientResources(err) { + logicalChannel, err = openNativeQMIChannelWithRecovery(openContext, recoverySession, slot, aid) + } if err != nil { _ = session.Close() return nil, fmt.Errorf("%w: %v", errNoEUICC, err) diff --git a/internal/device/vowifi_qmi.go b/internal/device/vowifi_qmi.go index 01298ae..9b87d96 100644 --- a/internal/device/vowifi_qmi.go +++ b/internal/device/vowifi_qmi.go @@ -6,6 +6,8 @@ import ( "fmt" "strings" "time" + + "github.com/iniwex5/quectel-qmi-go/pkg/qmi" ) func (manager *Manager) withNativeQMIVoWiFiSession(ctx context.Context, id string, fn func(nativeQMIVoWiFiSession) error) error { @@ -70,7 +72,7 @@ func (manager *Manager) ProbeNativeQMIApplication(ctx context.Context, id, prefe func (manager *Manager) AuthenticateNativeQMI(ctx context.Context, id string, aid, apdu []byte) (response []byte, err error) { err = manager.withNativeQMIVoWiFiSession(ctx, id, func(session nativeQMIVoWiFiSession) error { - channel, openErr := session.OpenLogicalChannel(ctx, 1, aid) + channel, openErr := openNativeQMIChannelWithRecovery(ctx, session, 1, aid) if openErr != nil { return fmt.Errorf("open QMI UIM logical channel: %w", openErr) } @@ -102,6 +104,60 @@ func (manager *Manager) AuthenticateNativeQMI(ctx context.Context, id string, ai return } +type nativeQMIChannelRecoverySession interface { + OpenLogicalChannel(context.Context, uint8, []byte) (byte, error) + PowerOffSIM(context.Context, uint8) error + PowerOnSIM(context.Context, uint8) error +} + +// OpenStick 410 can leave the physical UICC powered but unable to allocate a +// logical channel after a SIM hot-swap. A UIM service reset alone does not +// clear that state; cycling the affected physical slot does. Recover only the +// precise QMI InsufficientResources response, then retry the original AID once. +func openNativeQMIChannelWithRecovery( + ctx context.Context, + session nativeQMIChannelRecoverySession, + slot uint8, + aid []byte, +) (byte, error) { + channel, err := session.OpenLogicalChannel(ctx, slot, aid) + if err == nil || !isQMIInsufficientResources(err) { + return channel, err + } + if resetter, ok := session.(nativeQMIUIMResetSession); ok { + _ = resetter.ResetUIM(ctx) + } + if powerErr := session.PowerOffSIM(ctx, slot); powerErr != nil { + return 0, errors.Join(err, fmt.Errorf("power off QMI UIM slot %d: %w", slot, powerErr)) + } + if waitErr := waitNativeQMIRecovery(ctx, 3*time.Second); waitErr != nil { + return 0, errors.Join(err, waitErr) + } + if powerErr := session.PowerOnSIM(ctx, slot); powerErr != nil { + return 0, errors.Join(err, fmt.Errorf("power on QMI UIM slot %d: %w", slot, powerErr)) + } + if waitErr := waitNativeQMIRecovery(ctx, 5*time.Second); waitErr != nil { + return 0, errors.Join(err, waitErr) + } + return session.OpenLogicalChannel(ctx, slot, aid) +} + +func isQMIInsufficientResources(err error) bool { + qmiErr := qmi.GetQMIError(err) + return qmiErr != nil && qmiErr.Service == qmi.ServiceUIM && qmiErr.ErrorCode == 0x0044 +} + +var waitNativeQMIRecovery = func(ctx context.Context, delay time.Duration) error { + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + func (manager *Manager) NativeQMIRadioSnapshot(ctx context.Context, id string) (mode int, psAttached bool, err error) { err = manager.withNativeQMIVoWiFiSession(ctx, id, func(session nativeQMIVoWiFiSession) error { qmiMode, modeErr := session.GetOperatingMode(ctx) diff --git a/internal/vowifi/ike/epdg_resolver.go b/internal/vowifi/ike/epdg_resolver.go index 654d5b1..edc9455 100644 --- a/internal/vowifi/ike/epdg_resolver.go +++ b/internal/vowifi/ike/epdg_resolver.go @@ -36,8 +36,12 @@ func resolveEPDG(ctx context.Context, resolver *net.Resolver, host string) ([]ne var systemErr error for _, targetHost := range hostsToTry { - addresses, err := resolver.LookupIPAddr(ctx, targetHost) + ips, err := resolver.LookupIP(ctx, "ip4", targetHost) if err == nil { + addresses := make([]net.IPAddr, 0, len(ips)) + for _, ip := range ips { + addresses = append(addresses, net.IPAddr{IP: ip}) + } valid := filterValidPublicEPDGAddresses(addresses) if len(valid) > 0 { return valid, nil diff --git a/internal/vowifi/native_qmi_adapter.go b/internal/vowifi/native_qmi_adapter.go index a661e0f..3ccffd1 100644 --- a/internal/vowifi/native_qmi_adapter.go +++ b/internal/vowifi/native_qmi_adapter.go @@ -144,7 +144,7 @@ func (adapter *NativeQMIAdapter) AuthenticateWithPreference(ctx context.Context, } raw, err := adapter.controller.AuthenticateNativeQMI(ctx, binding.deviceID, binding.aid, buildUSIMAuthenticateAPDU(challenge)) if err != nil { - return AKAResult{}, ErrEC20AKACommand + return AKAResult{}, fmt.Errorf("%w: %v", ErrEC20AKACommand, err) } return parseUSIMAuthenticateResponse(raw) }