diff --git a/cmd/vocat/main.go b/cmd/vocat/main.go index 765c4ac..2b78ab3 100644 --- a/cmd/vocat/main.go +++ b/cmd/vocat/main.go @@ -581,6 +581,13 @@ func configureVoWiFiRuntime( if err != nil { return nil, err } + nativeQMIAdapter, err := vowifi.NewNativeQMIAdapter(nativeQMIControllerMapper{Mapper: mapper, Devices: deviceManager}, func(deviceID string) bool { + deviceConfig, configErr := database.Device(context.Background(), deviceID) + return configErr == nil && deviceConfig.VoWiFiEnabled + }) + if err != nil { + return nil, err + } pcscAdapter, err := vowifi.NewPCSCAdapter(cardReaders, func(ctx context.Context, deviceID string) (pcsc.Selector, string, error) { config, resolveErr := database.Device(ctx, strings.TrimSpace(deviceID)) if resolveErr != nil { @@ -606,6 +613,8 @@ func configureVoWiFiRuntime( adapter := vowifiDeviceAdapter(ec20Adapter) if deviceConfig.DeviceType == store.DeviceTypeUSBSIMReader { adapter = pcscAdapter + } else if deviceConfig.DeviceType == store.DeviceTypeWiFi410 { + adapter = nativeQMIAdapter } return newVoWiFiOrchestrator(deviceConfig, database, adapter) }, @@ -856,6 +865,7 @@ func provisionDiscoveredDevices( if name == "" || strings.EqualFold(name, "Android") { name = "Quectel EC20 / EC25" } + supportsSMS := deviceType != store.DeviceTypeWiFi410 if err := database.UpsertDevice(ctx, store.Device{ ID: discovered.ID, Name: name, @@ -872,7 +882,7 @@ func provisionDiscoveredDevices( DeviceBackend: backend, ESIMTransport: esimTransport, NetworkEnabled: false, - SMSEnabled: true, + SMSEnabled: supportsSMS, VoWiFiEnabled: true, }); err != nil { return err diff --git a/cmd/vocat/native_qmi_vowifi.go b/cmd/vocat/native_qmi_vowifi.go new file mode 100644 index 0000000..e4ce41b --- /dev/null +++ b/cmd/vocat/native_qmi_vowifi.go @@ -0,0 +1,71 @@ +package main + +import ( + "context" + + "vocat/internal/device" + "vocat/internal/vowifi/integration" +) + +// nativeQMIControllerMapper keeps the configured Web/API device ID stable +// while Linux exposes the physical MHI modem under its discovery ID. +type nativeQMIControllerMapper struct { + Mapper integration.ATMapper + Devices *device.Manager +} + +func (mapper nativeQMIControllerMapper) physical(configuredID string) (string, error) { + entry, err := mapper.Mapper.Get(configuredID) + if err != nil { + return "", err + } + return entry.ID, nil +} + +func (mapper nativeQMIControllerMapper) ReadNativeQMIIdentity(ctx context.Context, id string) (string, string, string, string, string, error) { + physical, err := mapper.physical(id) + if err != nil { + return "", "", "", "", "", err + } + return mapper.Devices.ReadNativeQMIIdentity(ctx, physical) +} + +func (mapper nativeQMIControllerMapper) ProbeNativeQMIApplication(ctx context.Context, id, preference string) ([]byte, string, error) { + physical, err := mapper.physical(id) + if err != nil { + return nil, "", err + } + return mapper.Devices.ProbeNativeQMIApplication(ctx, physical, preference) +} + +func (mapper nativeQMIControllerMapper) AuthenticateNativeQMI(ctx context.Context, id string, aid, apdu []byte) ([]byte, error) { + physical, err := mapper.physical(id) + if err != nil { + return nil, err + } + return mapper.Devices.AuthenticateNativeQMI(ctx, physical, aid, apdu) +} + +func (mapper nativeQMIControllerMapper) NativeQMIRadioSnapshot(ctx context.Context, id string) (int, bool, error) { + physical, err := mapper.physical(id) + if err != nil { + return 0, false, err + } + return mapper.Devices.NativeQMIRadioSnapshot(ctx, physical) +} + +func (mapper nativeQMIControllerMapper) StopNativeQMICellularData(ctx context.Context, id string) error { + physical, err := mapper.physical(id) + if err != nil { + return err + } + return mapper.Devices.StopNativeQMICellularData(ctx, physical) +} + +func (mapper nativeQMIControllerMapper) SetNativeQMIRadioOff(ctx context.Context, id string, off bool) error { + physical, err := mapper.physical(id) + if err != nil { + return err + } + return mapper.Devices.SetNativeQMIRadioOff(ctx, physical, off) +} diff --git a/internal/device/carrier_db.go b/internal/device/carrier_db.go index fc8ba5c..8bf4289 100644 --- a/internal/device/carrier_db.go +++ b/internal/device/carrier_db.go @@ -104,7 +104,7 @@ func CarrierForIMSI(imsi string) (plmn, name, countryCode string, ok bool) { // several customer-facing carriers authenticate through the same home PLMN. func CarrierForSIM(identity CarrierIdentity) (plmn, name, countryCode string, ok bool) { imsi := strings.TrimSpace(identity.IMSI) - if !decimalDigits(imsi, 5, 20) { + if !decimalDigits(imsi, 5, 20) || IsPlaceholderIMSI(imsi) { return "", "", "", false } plmns := carrierPLMNCandidates(imsi, identity.MNCLength) diff --git a/internal/device/controls_test.go b/internal/device/controls_test.go index 6eabb05..f0a9450 100644 --- a/internal/device/controls_test.go +++ b/internal/device/controls_test.go @@ -11,14 +11,23 @@ import ( ) type fakeQMIRadioSession struct { - mode qmi.OperatingMode - getModes []qmi.OperatingMode - setModes []qmi.OperatingMode - getErr error - setErr error - closeCount int - iccid string - iccidErr error + mode qmi.OperatingMode + getModes []qmi.OperatingMode + setModes []qmi.OperatingMode + getErr error + setErr error + closeCount int + iccid string + iccidErr error + imei string + imeiErr error + openedAIDs [][]byte + openChannel byte + openErr error + closedChannels []byte + apdus [][]byte + apduResponse []byte + apduErr error } func (session *fakeQMIRadioSession) GetOperatingMode(context.Context) (qmi.OperatingMode, error) { @@ -48,6 +57,31 @@ func (session *fakeQMIRadioSession) GetICCID(context.Context) (string, error) { return session.iccid, session.iccidErr } +func (session *fakeQMIRadioSession) GetIMEI(context.Context) (string, error) { + return session.imei, session.imeiErr +} + +func (session *fakeQMIRadioSession) OpenLogicalChannel(_ context.Context, _ uint8, aid []byte) (byte, error) { + session.openedAIDs = append(session.openedAIDs, append([]byte(nil), aid...)) + if session.openErr != nil { + return 0, session.openErr + } + if session.openChannel == 0 { + return 1, nil + } + return session.openChannel, nil +} + +func (session *fakeQMIRadioSession) CloseLogicalChannel(_ context.Context, _ uint8, channel uint8) error { + session.closedChannels = append(session.closedChannels, channel) + return nil +} + +func (session *fakeQMIRadioSession) SendAPDU(_ context.Context, _ uint8, _ uint8, command []byte) ([]byte, error) { + session.apdus = append(session.apdus, append([]byte(nil), command...)) + return append([]byte(nil), session.apduResponse...), session.apduErr +} + func newStartedNativeQMITestManager(t *testing.T) (*Manager, *staticOpener, string) { t.Helper() const id = "wwan0" diff --git a/internal/device/esim.go b/internal/device/esim.go index 254cc0b..a7b5ffb 100644 --- a/internal/device/esim.go +++ b/internal/device/esim.go @@ -8,6 +8,8 @@ import ( "strings" "time" + "github.com/iniwex5/quectel-qmi-go/pkg/qmi" + "vocat/internal/i18n" "vocat/internal/modem" "vocat/internal/pcsc" @@ -159,12 +161,20 @@ func encodeICCID(digits string) ([]byte, error) { } func buildEnableProfileRequest(iccid string) ([]byte, error) { + return buildEnableProfileRequestWithRefresh(iccid, true) +} + +func buildEnableProfileRequestWithRefresh(iccid string, refresh bool) ([]byte, error) { bcd, err := encodeICCID(iccid) if err != nil { return nil, err } profileID := derConstruct(0xA0, derEncode(0x5A, bcd)) - return derConstruct(0xBF31, profileID, derEncode(0x81, []byte{0xFF})), nil + refreshFlag := byte(0x00) + if refresh { + refreshFlag = 0xFF + } + return derConstruct(0xBF31, profileID, derEncode(0x81, []byte{refreshFlag})), nil } // parseCSIM extracts the payload and status word from an AT+CSIM response. @@ -195,9 +205,74 @@ type euiccChannel struct { id string channel int pcscSession *pcsc.Session + qmiSession nativeQMIEuiccSession + qmiSlot uint8 resetOnClose bool } +func (channel *euiccChannel) registerProfileRefresh(ctx context.Context) (bool, error) { + refreshSession, ok := channel.qmiSession.(nativeQMIRefreshSession) + if !ok { + return false, nil + } + if err := refreshSession.RegisterUIMRefresh(ctx); err != nil { + var unsupported *qmi.NotSupportedError + if errors.As(err, &unsupported) { + return false, nil + } + return false, err + } + return true, nil +} + +func (channel *euiccChannel) completeProfileRefresh(ctx context.Context) error { + refreshSession, ok := channel.qmiSession.(nativeQMIRefreshSession) + if !ok { + return nil + } + return refreshSession.CompleteUIMRefresh(ctx) +} + +func (channel *euiccChannel) acknowledgeProfileRefresh(ctx context.Context) error { + refreshSession, ok := channel.qmiSession.(nativeQMIRefreshSession) + if !ok { + return nil + } + return refreshSession.AcknowledgeUIMRefresh(ctx) +} + +func (channel *euiccChannel) recoverCATBusy(ctx context.Context) error { + if channel.qmiSession == nil { + return nil + } + // A power cycle must happen while the CAT2 client remains registered, or + // the card can issue its first proactive command before VoCat is listening + // and immediately become busy again. + if channel.channel > 0 { + _ = channel.qmiSession.CloseLogicalChannel(ctx, channel.qmiSlot, byte(channel.channel)) + channel.channel = 0 + } + power, ok := channel.qmiSession.(interface { + PowerOffSIM(context.Context, uint8) error + PowerOnSIM(context.Context, uint8) error + }) + if !ok { + return nil + } + if err := power.PowerOffSIM(ctx, channel.qmiSlot); err != nil { + return err + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(time.Second): + } + if err := power.PowerOnSIM(ctx, channel.qmiSlot); err != nil { + return err + } + return channel.completeProfileRefresh(ctx) +} + // csimAPDUTimeout bounds a single AT+CSIM exchange. Loading a BoundProfilePackage // makes the eUICC decrypt/write sizeable SCP03t segments on-card, which can exceed // the modem's default 3s command timeout, so eSIM APDUs get a longer budget. @@ -289,6 +364,9 @@ func (manager *Manager) openEuiccOnceAID(ctx context.Context, id, aidHex string) if candidate.HardwareKind == pcsc.HardwareKind { return manager.openPCSCEuiccOnceAID(ctx, id, candidate, aidHex) } + if strings.EqualFold(manager.backendFor(state), "qmi") && isNativeQMICandidate(candidate) { + return manager.openQMIEuiccOnceAID(ctx, id, candidate, aidHex) + } // MANAGE CHANNEL (open): 00 70 00 00 01 -> " 90 00". This EC20 // firmware requires the explicit one-byte expected length: Le=00 opens a // channel but then rejects SELECT ISD-R at the AT+CSIM layer. @@ -327,6 +405,38 @@ func (manager *Manager) openEuiccOnceAID(ctx context.Context, id, aidHex string) return channel, nil } +func (manager *Manager) openQMIEuiccOnceAID(ctx context.Context, id string, candidate modem.Candidate, aidHex string) (*euiccChannel, error) { + aidHex = strings.ToUpper(strings.TrimSpace(aidHex)) + aid, err := hex.DecodeString(aidHex) + if err != nil || len(aid) == 0 || len(aid) > 255 { + return nil, fmt.Errorf("esim: invalid ISD-R AID %q", aidHex) + } + if manager.qmiRadioOpener == nil { + return nil, errors.New("esim: QMI UIM transport is unavailable") + } + openContext, cancel := context.WithTimeout(ctx, csimAPDUTimeout) + defer cancel() + radioSession, err := manager.qmiRadioOpener(openContext, candidate.QMIControl) + if err != nil { + return nil, fmt.Errorf("esim: open QMI UIM transport: %w", err) + } + session, ok := radioSession.(nativeQMIEuiccSession) + if !ok { + _ = radioSession.Close() + return nil, errors.New("esim: QMI UIM transport does not support logical channels") + } + const slot uint8 = 1 + logicalChannel, err := session.OpenLogicalChannel(openContext, slot, aid) + if err != nil { + _ = session.Close() + return nil, fmt.Errorf("%w: %v", errNoEUICC, err) + } + return &euiccChannel{ + manager: manager, id: id, channel: int(logicalChannel), + qmiSession: session, qmiSlot: slot, + }, nil +} + func (manager *Manager) openPCSCEuiccOnceAID(ctx context.Context, id string, candidate modem.Candidate, aidHex string) (*euiccChannel, error) { session, err := manager.cardReaders.OpenSession(ctx, pcsc.Selector{ USBPath: candidate.USBPath, ReaderName: candidate.ReaderName, @@ -407,6 +517,14 @@ func isTransientEuiccCME(err error) bool { // close releases the logical channel (MANAGE CHANNEL close). func (channel *euiccChannel) close(ctx context.Context) { + if channel.qmiSession != nil { + if channel.channel > 0 { + _ = channel.qmiSession.CloseLogicalChannel(ctx, channel.qmiSlot, byte(channel.channel)) + } + _ = channel.qmiSession.Close() + channel.qmiSession = nil + return + } closeAPDU := []byte{0x00, 0x70, 0x80, byte(channel.channel), 0x00} _, _, _ = channel.exchange(ctx, closeAPDU) if channel.pcscSession != nil { @@ -420,6 +538,17 @@ func (channel *euiccChannel) close(ctx context.Context) { } func (channel *euiccChannel) exchange(ctx context.Context, apdu []byte) ([]byte, int, error) { + if channel.qmiSession != nil { + raw, err := channel.qmiSession.SendAPDU(ctx, channel.qmiSlot, byte(channel.channel), apdu) + if err != nil { + return nil, 0, err + } + if len(raw) < 2 { + return nil, 0, fmt.Errorf("esim: short QMI UIM APDU response") + } + sw := int(raw[len(raw)-2])<<8 | int(raw[len(raw)-1]) + return raw[:len(raw)-2], sw, nil + } if channel.pcscSession != nil { payload, sw, err := channel.pcscSession.Transmit(ctx, apdu) return payload, int(sw), err @@ -652,9 +781,9 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid if iccid == "" { return errors.New("esim: an ICCID is required") } - der, err := buildEnableProfileRequest(iccid) - if err != nil { - return err + _, nativeQMI, nativeErr := manager.nativeQMIControl(id) + if nativeErr != nil { + return nativeErr } manager.lockESIM() if err := manager.waitForESIMRecovery(ctx, id); err != nil { @@ -666,6 +795,31 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid manager.unlockESIM() return err } + refreshRequested := !nativeQMI + if nativeQMI { + refreshContext, cancelRefresh := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) + refreshRequested, err = channel.registerProfileRefresh(refreshContext) + cancelRefresh() + if err != nil { + channel.close(context.Background()) + manager.unlockESIM() + return fmt.Errorf("esim: register QMI UIM refresh: %w", err) + } + // After a refresh=true attempt reports catBusy, retry without asking the + // eUICC to start another REFRESH proactive command. SGP.22 permits the + // card to terminate the pre-existing proactive session in this mode; the + // native-QMI recovery below performs the required SIM reset and cache + // reload on behalf of the device. + if attempt, _ := ctx.Value(esimCATBusyRetryKey{}).(int); attempt > 0 { + refreshRequested = false + } + } + der, err := buildEnableProfileRequestWithRefresh(iccid, refreshRequested) + if err != nil { + channel.close(context.Background()) + manager.unlockESIM() + return err + } // EnableProfile request (SGP.22 ES10c, per lpac): // BF31 { A0 { 5A } 81 01 FF } (refresh = yes) @@ -679,6 +833,33 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid commitContext, cancelCommit := context.WithTimeout(context.WithoutCancel(ctx), csimAPDUTimeout) payload, err := channel.es10(commitContext, der) cancelCommit() + // A rejected EnableProfile (for example CAT busy) does not emit REFRESH. + // Parse the card-level result before waiting for an indication, otherwise + // every retry needlessly waits for the refresh timeout. + resultBeforeClose, resultPresentBeforeClose := enableProfileResult(payload) + if err == nil && resultPresentBeforeClose && byte(resultBeforeClose) == 5 && nativeQMI { + // Registering CAT2 may immediately deliver a proactive command that was + // already pending before EnableProfile. Drain it on catBusy so the raw + // REFRESH command receives its terminal response before the retry. + catContext, cancelCAT := context.WithTimeout(context.Background(), 3*time.Second) + _ = channel.completeProfileRefresh(catContext) + cancelCAT() + if attempt, _ := ctx.Value(esimCATBusyRetryKey{}).(int); attempt == 0 { + recoveryContext, cancelRecovery := context.WithTimeout(context.Background(), 12*time.Second) + _ = channel.recoverCATBusy(recoveryContext) + cancelRecovery() + } + ackContext, cancelAck := context.WithTimeout(context.Background(), 5*time.Second) + _ = channel.acknowledgeProfileRefresh(ackContext) + cancelAck() + } + if err == nil && resultPresentBeforeClose && + enableProfileResponseError(byte(resultBeforeClose), payload) == nil && + refreshRequested && nativeQMI { + refreshContext, cancelRefresh := context.WithTimeout(context.Background(), 20*time.Second) + _ = channel.completeProfileRefresh(refreshContext) + cancelRefresh() + } // Release the logical channel before any reset: openEuicc's csim holds // opMu only for the duration of each APDU, so by here the lock is free. closeContext, cancelClose := context.WithTimeout(context.Background(), csimAPDUTimeout) @@ -703,6 +884,18 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid return fmt.Errorf("esim: unexpected EnableProfile response %s", strings.ToUpper(hex.EncodeToString(payload))) } if err := enableProfileResponseError(byte(result), payload); err != nil { + if errors.Is(err, ErrESIMEnableCATBusy) { + attempt, _ := ctx.Value(esimCATBusyRetryKey{}).(int) + if attempt < 11 { + manager.unlockESIM() + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(2 * time.Second): + } + return manager.ESIMSwitchProfile(context.WithValue(ctx, esimCATBusyRetryKey{}, attempt+1), id, iccid, aidHex) + } + } manager.unlockESIM() return err } @@ -721,6 +914,8 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid return manager.verifySwitchedICCID(verifyContext, id, iccid) } +type esimCATBusyRetryKey struct{} + func (manager *Manager) startProfileSwitchRecovery(id string) { done := make(chan struct{}) manager.esimRecoveryMu.Lock() @@ -853,6 +1048,18 @@ func (manager *Manager) renameCachedProfile(id, iccid, nickname string) { // initiating HTTP request. EC20 commonly drops the AT port while processing // CFUN=1,1, so the reset error is intentionally followed by discovery retries. func (manager *Manager) recoverAfterProfileSwitch(id string) { + resetContext, cancelReset := context.WithTimeout(context.Background(), manager.longTimeout) + if native, err := manager.powerCycleNativeQMISIM(resetContext, id); native { + cancelReset() + if err == nil { + time.Sleep(1500 * time.Millisecond) + } + // Native WWAN identity and profile verification are both QMI-backed. + // Do not enter the AT refresh path: OpenStick firmware can accept the + // switch while timing out every EC20-specific AT identity command. + return + } + cancelReset() if !manager.isPCSCDevice(id) { resetContext, cancelReset := context.WithTimeout(context.Background(), manager.longTimeout) _ = manager.rebootForProfileSwitch(resetContext, id) @@ -985,7 +1192,31 @@ func (manager *Manager) verifySwitchedICCID(ctx context.Context, id, expected st var lastICCID string var lastErr error for attempt := 0; attempt < attempts; attempt++ { - if manager.isPCSCDevice(id) { + if control, native, nativeErr := manager.nativeQMIControl(id); native { + if nativeErr != nil { + lastErr = nativeErr + } else { + state, lookupErr := manager.lookup(id) + if lookupErr != nil { + lastErr = lookupErr + } else { + candidate := manager.candidateFor(state) + candidate.QMIControl = control + live, readErr := manager.readNativeQMIICCID(ctx, candidate) + if readErr == nil { + lastICCID = strings.TrimSpace(live) + if lastICCID == expected { + return nil + } + lastErr = fmt.Errorf("native QMI still reports ICCID %s", lastICCID) + } else { + lastErr = readErr + } + } + } + } else if nativeErr != nil { + lastErr = nativeErr + } else if manager.isPCSCDevice(id) { snapshot, err := manager.Refresh(ctx, id) if err == nil { lastICCID = strings.TrimSpace(snapshot.ICCID) diff --git a/internal/device/esim_test.go b/internal/device/esim_test.go index 348553a..757e051 100644 --- a/internal/device/esim_test.go +++ b/internal/device/esim_test.go @@ -1,6 +1,7 @@ package device import ( + "bytes" "context" "encoding/hex" "errors" @@ -283,6 +284,41 @@ func TestDiscoverEuiccAIDsFindsXeSIMAlternateISDR(t *testing.T) { client.assertDone(t) } +func TestNativeQMIUsesUIMLogicalChannelForEUICC(t *testing.T) { + manager, _, id := newStartedNativeQMITestManager(t) + if err := manager.SetBackend(id, "qmi"); err != nil { + t.Fatal(err) + } + session := &fakeQMIRadioSession{ + openChannel: 3, + apduResponse: []byte{0xDE, 0xAD, 0x90, 0x00}, + } + manager.qmiRadioOpener = func(context.Context, string) (qmiRadioSession, error) { + return session, nil + } + channel, err := manager.openEuiccAID(context.Background(), id, isdRAID) + if err != nil { + t.Fatalf("open QMI eUICC: %v", err) + } + payload, sw, err := channel.transmit(context.Background(), []byte{0x80, 0xCA, 0x00, 0x00, 0x00}, 0x80) + if err != nil { + t.Fatalf("transmit QMI APDU: %v", err) + } + if !bytes.Equal(payload, []byte{0xDE, 0xAD}) || sw != 0x9000 { + t.Fatalf("QMI APDU response = %X/%04X", payload, sw) + } + channel.close(context.Background()) + if len(session.openedAIDs) != 1 || strings.ToUpper(hex.EncodeToString(session.openedAIDs[0])) != isdRAID { + t.Fatalf("opened AIDs = %X", session.openedAIDs) + } + if len(session.apdus) != 1 || session.apdus[0][0] != 0x83 { + t.Fatalf("QMI APDUs = %X", session.apdus) + } + if len(session.closedChannels) != 1 || session.closedChannels[0] != 3 || session.closeCount != 1 { + t.Fatalf("closed channels/session = %v/%d", session.closedChannels, session.closeCount) + } +} + func TestEUICCChannelStuckWrapsTransientCME(t *testing.T) { cause := &modem.CommandError{ Command: `AT+CSIM=10,"0070000001"`, diff --git a/internal/device/flight_qmi.go b/internal/device/flight_qmi.go index ce00689..1d2b9fc 100644 --- a/internal/device/flight_qmi.go +++ b/internal/device/flight_qmi.go @@ -2,8 +2,10 @@ package device import ( "context" + "encoding/binary" "errors" "fmt" + "log/slog" "strings" "sync" "time" @@ -25,6 +27,48 @@ type nativeQMIICCIDSession interface { GetICCID(context.Context) (string, error) } +type nativeQMIIMEISession interface { + GetIMEI(context.Context) (string, error) +} + +type nativeQMIEuiccSession interface { + qmiRadioSession + OpenLogicalChannel(context.Context, uint8, []byte) (byte, error) + CloseLogicalChannel(context.Context, uint8, uint8) error + SendAPDU(context.Context, uint8, uint8, []byte) ([]byte, error) +} + +// nativeQMIRefreshSession is implemented by production QMI sessions that can +// participate in the modem's UIM REFRESH state machine. Keep it separate from +// nativeQMIEuiccSession so transcript fakes and older QMI implementations can +// continue to use the APDU transport without pretending to handle indications. +type nativeQMIRefreshSession interface { + RegisterUIMRefresh(context.Context) error + CompleteUIMRefresh(context.Context) error + AcknowledgeUIMRefresh(context.Context) error +} + +type nativeQMIUIMResetSession interface { + ResetUIM(context.Context) error +} + +type nativeQMIVoWiFiSession interface { + qmiRadioSession + GetICCID(context.Context) (string, error) + GetIMEI(context.Context) (string, error) + GetIMSI(context.Context) (string, error) + GetNativeMCCMNC(context.Context) (string, string, error) + GetUSIMAID(context.Context) ([]byte, error) + GetISIMAID(context.Context) ([]byte, error) + GetServingSystem(context.Context) (*qmi.ServingSystem, error) + AttachDetach(context.Context, bool) error + OpenLogicalChannel(context.Context, uint8, []byte) (byte, error) + CloseLogicalChannel(context.Context, uint8, uint8) error + SendAPDU(context.Context, uint8, uint8, []byte) ([]byte, error) + PowerOffSIM(context.Context, uint8) error + PowerOnSIM(context.Context, uint8) error +} + // nativeQMIControl identifies the QMI control node exposed by native WWAN // devices. USB serial modems may also advertise a control path, but only the // wwanN/qmiN pairing is safe to operate through the native QMI path. @@ -47,6 +91,7 @@ type productionQMIRadioSession struct { dms *qmi.DMSService nas *qmi.NASService nasErr error + catID uint8 uimMu sync.Mutex uim *qmi.UIMService lease *qmiport.Lease @@ -158,19 +203,408 @@ func openQMIRadioSession(ctx context.Context, controlDevice string) (qmiRadioSes } func (session *productionQMIRadioSession) GetICCID(ctx context.Context) (string, error) { + uim, err := session.uimService(ctx) + if err != nil { + return "", err + } + return uim.GetICCID(ctx) +} + +func (session *productionQMIRadioSession) GetIMSI(ctx context.Context) (string, error) { + uim, err := session.uimService(ctx) + if err != nil { + return "", err + } + return uim.GetIMSI(ctx) +} + +func (session *productionQMIRadioSession) GetNativeMCCMNC(ctx context.Context) (string, string, error) { + uim, err := session.uimService(ctx) + if err != nil { + return "", "", err + } + return uim.GetNativeMCCMNC(ctx) +} + +func (session *productionQMIRadioSession) GetUSIMAID(ctx context.Context) ([]byte, error) { + uim, err := session.uimService(ctx) + if err != nil { + return nil, err + } + return uim.GetUSIMAID(ctx) +} + +func (session *productionQMIRadioSession) GetISIMAID(ctx context.Context) ([]byte, error) { + uim, err := session.uimService(ctx) + if err != nil { + return nil, err + } + return uim.GetISIMAID(ctx) +} + +func (session *productionQMIRadioSession) PowerOffSIM(ctx context.Context, slot uint8) error { + uim, err := session.uimService(ctx) + if err != nil { + return err + } + return uim.PowerOffSIM(ctx, slot) +} + +func (session *productionQMIRadioSession) PowerOnSIM(ctx context.Context, slot uint8) error { + uim, err := session.uimService(ctx) + if err != nil { + return err + } + return uim.PowerOnSIM(ctx, slot) +} + +func (session *productionQMIRadioSession) ResetUIM(ctx context.Context) error { + uim, err := session.uimService(ctx) + if err != nil { + return err + } + return uim.Reset(ctx) +} + +func (session *productionQMIRadioSession) uimService(ctx context.Context) (*qmi.UIMService, error) { if session == nil || session.client == nil { - return "", errors.New("QMI UIM session is unavailable") + return nil, errors.New("QMI UIM session is unavailable") } session.uimMu.Lock() defer session.uimMu.Unlock() if session.uim == nil { uim, err := qmi.NewUIMServiceWithContext(ctx, session.client) if err != nil { - return "", err + return nil, err } session.uim = uim } - return session.uim.GetICCID(ctx) + return session.uim, nil +} + +func (session *productionQMIRadioSession) OpenLogicalChannel(ctx context.Context, slot uint8, aid []byte) (byte, error) { + uim, err := session.uimService(ctx) + if err != nil { + return 0, err + } + return uim.OpenLogicalChannel(ctx, slot, aid) +} + +func (session *productionQMIRadioSession) CloseLogicalChannel(ctx context.Context, slot, channel uint8) error { + uim, err := session.uimService(ctx) + if err != nil { + return err + } + return uim.CloseLogicalChannel(ctx, slot, channel) +} + +func (session *productionQMIRadioSession) SendAPDU(ctx context.Context, slot, channel uint8, command []byte) ([]byte, error) { + uim, err := session.uimService(ctx) + if err != nil { + return nil, err + } + return uim.SendAPDU(ctx, slot, channel, command) +} + +// RegisterUIMRefresh mirrors the terminal registration used by libqmi for a +// physical card slot. EnableProfile(refresh=true) may cause the eUICC to issue +// a proactive REFRESH; without a registered terminal the card remains CAT busy +// after the profile has changed and rejects the next profile operation. +func (session *productionQMIRadioSession) RegisterUIMRefresh(ctx context.Context) error { + uim, err := session.uimService(ctx) + if err != nil { + return err + } + if err := uim.RefreshRegisterAll(ctx, qmi.UIMRefreshRegisterAllRequest{ + SessionType: qmi.UIMSessionTypeCardSlot1, + RegisterFlag: true, + }); err != nil { + return err + } + if session.catID == 0 { + clientID, err := session.client.AllocateClientIDWithContext(ctx, qmi.ServiceCAT2) + if err != nil { + return fmt.Errorf("allocate QMI CAT2 client: %w", err) + } + session.catID = clientID + } + configuration, configErr := session.client.SendRequest(ctx, qmi.ServiceCAT2, session.catID, 0x002E, nil) + if configErr == nil && configuration.CheckResult() == nil { + if modeTLV := qmi.FindTLV(configuration.TLVs, 0x10); modeTLV != nil && len(modeTLV.Value) > 0 { + slog.Info("QMI CAT2 configuration", "mode", modeTLV.Value[0]) + } + } + response, err := session.client.SendRequest(ctx, qmi.ServiceCAT2, session.catID, 0x0001, []qmi.TLV{ + // Claim the raw proactive-command events implemented by this CAT2 + // generation (bits 0..22 and 24..25). A profile can leave any STK + // command pending, not only REFRESH, and SGP.22 forbids profile changes + // while that proactive session is unanswered. + {Type: 0x10, Value: []byte{0xFF, 0xFF, 0x7F, 0x03}}, + // Slot mask bit 0 selects slot 1. + {Type: 0x12, Value: []byte{0x01}}, + }) + if err != nil { + return fmt.Errorf("register QMI CAT2 refresh: %w", err) + } + if err := response.CheckResult(); err != nil { + return fmt.Errorf("register QMI CAT2 refresh: %w", err) + } + for _, tlv := range response.TLVs { + if tlv.Type >= 0x10 && tlv.Type <= 0x12 { + slog.Info("QMI CAT2 registration response", "tlv", fmt.Sprintf("0x%02X", tlv.Type), "value", fmt.Sprintf("%X", tlv.Value)) + } + } + return nil +} + +// CompleteUIMRefresh consumes refresh indications on the same QMI client that +// registered for them. Qualcomm requires RefreshComplete only for START +// indications whose mode is not RESET; RESET is completed by the modem itself. +func (session *productionQMIRadioSession) CompleteUIMRefresh(ctx context.Context) error { + if session == nil || session.client == nil { + return errors.New("QMI UIM refresh session is unavailable") + } + uim, err := session.uimService(ctx) + if err != nil { + return err + } + refreshCompleted := false + uimEnded := false + catEnded := false + for { + select { + case <-ctx.Done(): + // Some firmware handles a RESET internally and never forwards an + // indication to this client. A missing indication is therefore not + // a failed profile commit. + return nil + case event, ok := <-session.client.Events(): + if !ok { + return nil + } + if event.ServiceID == qmi.ServiceCAT2 && event.MessageID == 0x0001 { + for _, eventTLV := range event.Packet.TLVs { + slog.Info("QMI CAT2 event", "tlv", fmt.Sprintf("0x%02X", eventTLV.Type), "length", len(eventTLV.Value)) + } + if tlv := qmi.FindTLV(event.Packet.TLVs, 0x19); tlv != nil && len(tlv.Value) >= 4 { + mode := uint16(tlv.Value[0]) | uint16(tlv.Value[1])<<8 + stage := uint16(tlv.Value[2]) | uint16(tlv.Value[3])<<8 + slog.Info("QMI CAT2 profile refresh", "stage", stage, "mode", mode) + if stage == 3 { + return errors.New("QMI CAT2 refresh ended with failure") + } + } + // UIM refresh completion is not a CAT terminal response. Qualcomm + // delivers the raw proactive command in a command-specific TLV; send + // a response carrying that command's reference ID. Unsupported UI STK + // commands receive the standards-defined "beyond terminal + // capabilities" result, which still closes the proactive session. + for _, commandTLV := range event.Packet.TLVs { + if !isRawCATCommandTLV(commandTLV.Type) { + continue + } + ref, terminalResponse, commandType, responseOK := catProactiveTerminalResponse(commandTLV.Value) + if !responseOK { + continue + } + if err := session.sendCATTerminalResponse(ctx, ref, terminalResponse); err != nil { + return err + } + slog.Info("QMI CAT2 terminal response sent", "reference", ref, "command", fmt.Sprintf("0x%02X", commandType)) + break + } + if tlv := qmi.FindTLV(event.Packet.TLVs, 0x1A); tlv != nil && len(tlv.Value) > 0 { + // Older MDM8916 CAT2 firmware encodes this enum in one byte; + // newer interface descriptions model it as a 32-bit value. + reason := uint32(tlv.Value[0]) + if len(tlv.Value) >= 4 { + reason |= uint32(tlv.Value[1])<<8 | uint32(tlv.Value[2])<<16 | uint32(tlv.Value[3])<<24 + } + slog.Info("QMI CAT2 proactive session ended", "reason", reason) + catEnded = true + if uimEnded { + return nil + } + } + continue + } + if event.Type != qmi.EventUIMRefresh { + continue + } + info, parseErr := qmi.ParseUIMRefreshIndication(event.Packet) + if parseErr != nil { + return parseErr + } + const ( + refreshStageWaitForOK = uint8(0) + refreshStageStart = uint8(1) + refreshStageSuccess = uint8(2) + refreshStageFailure = uint8(3) + refreshModeReset = uint8(0) + ) + slog.Info("QMI UIM profile refresh", "stage", info.Stage, "mode", info.Mode) + switch info.Stage { + case refreshStageWaitForOK: + // Registration without a vote advances on its own. Keep the UIM + // client alive for the subsequent START and END indications. + continue + case refreshStageStart: + if info.Mode == refreshModeReset || refreshCompleted { + continue + } + // libqmi intentionally uses CARD_SLOT_1 here rather than echoing + // the provisioning session from the indication. + _ = uim.RefreshComplete(ctx, qmi.UIMRefreshCompleteRequest{ + SessionType: qmi.UIMSessionTypeCardSlot1, + RefreshSuccess: true, + }) + refreshCompleted = true + continue + case refreshStageSuccess: + uimEnded = true + if catEnded { + return nil + } + continue + case refreshStageFailure: + return errors.New("QMI UIM refresh ended with failure") + default: + continue + } + } + } +} + +func (session *productionQMIRadioSession) sendCATTerminalResponse(ctx context.Context, reference uint32, terminalResponse []byte) error { + value := make([]byte, 0, 6+len(terminalResponse)) + value = binary.LittleEndian.AppendUint32(value, reference) + value = binary.LittleEndian.AppendUint16(value, uint16(len(terminalResponse))) + value = append(value, terminalResponse...) + response, err := session.client.SendRequest(ctx, qmi.ServiceCAT2, session.catID, 0x0021, []qmi.TLV{ + {Type: 0x01, Value: value}, + {Type: 0x10, Value: []byte{0x01}}, // CAT slot 1 (not a slot mask) + }) + if err != nil { + return fmt.Errorf("send QMI CAT2 refresh terminal response: %w", err) + } + if err := response.CheckResult(); err != nil { + return fmt.Errorf("send QMI CAT2 refresh terminal response: %w", err) + } + return nil +} + +// catProactiveTerminalResponse extracts a raw CAT command carried as +// {reference:uint32LE, length:uint16LE, BER-TLV command} and creates the +// standards-shaped terminal response. VoCat has no interactive STK UI, so +// commands other than REFRESH/MORE TIME are explicitly reported unsupported. +func catProactiveTerminalResponse(raw []byte) (uint32, []byte, byte, bool) { + if len(raw) < 8 { + return 0, nil, 0, false + } + reference := binary.LittleEndian.Uint32(raw[:4]) + commandLength := int(binary.LittleEndian.Uint16(raw[4:6])) + if commandLength <= 0 || commandLength > len(raw)-6 { + return 0, nil, 0, false + } + command := raw[6 : 6+commandLength] + if len(command) < 2 || command[0] != 0xD0 { + return 0, nil, 0, false + } + bodyLength, lengthBytes, ok := catBERLength(command[1:]) + if !ok || 1+lengthBytes+bodyLength > len(command) { + return 0, nil, 0, false + } + body := command[1+lengthBytes : 1+lengthBytes+bodyLength] + for offset := 0; offset < len(body); { + tag := body[offset] + offset++ + length, consumed, ok := catBERLength(body[offset:]) + if !ok || offset+consumed+length > len(body) { + return 0, nil, 0, false + } + offset += consumed + value := body[offset : offset+length] + offset += length + if tag&0x7F != 0x01 || len(value) < 3 { + continue + } + result := byte(0x30) // command beyond terminal capabilities + if value[1] == 0x01 || value[1] == 0x02 { // REFRESH or MORE TIME + result = 0x00 // command performed successfully + } + terminalResponse := []byte{ + 0x81, 0x03, value[0], value[1], value[2], // command details + 0x82, 0x02, 0x82, 0x81, // terminal -> UICC + 0x83, 0x01, result, + } + return reference, terminalResponse, value[1], true + } + return 0, nil, 0, false +} + +func catRefreshTerminalResponse(raw []byte) (uint32, []byte, bool) { + reference, response, commandType, ok := catProactiveTerminalResponse(raw) + return reference, response, ok && commandType == 0x01 +} + +func isRawCATCommandTLV(tag byte) bool { + switch tag { + case 0x10, 0x11, 0x12, 0x13, 0x14, 0x17, 0x18, + 0x47, 0x48, 0x49, 0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F, + 0x51, 0x52, 0x53, 0x54, 0x66, 0x6A: + return true + default: + return false + } +} + +func catBERLength(raw []byte) (length int, consumed int, ok bool) { + if len(raw) == 0 { + return 0, 0, false + } + switch raw[0] { + case 0x81: + if len(raw) < 2 { + return 0, 0, false + } + return int(raw[1]), 2, true + case 0x82: + if len(raw) < 3 { + return 0, 0, false + } + return int(raw[1])<<8 | int(raw[2]), 3, true + default: + if raw[0]&0x80 != 0 { + return 0, 0, false + } + return int(raw[0]), 1, true + } +} + +// AcknowledgeUIMRefresh is a recovery vote for a refresh that predates this +// QMI client. Qualcomm documents RefreshComplete as harmless when no vote is +// pending; it lets a newly started service release a stale CAT-busy condition +// left by an interrupted LPA/terminal transaction. +func (session *productionQMIRadioSession) AcknowledgeUIMRefresh(ctx context.Context) error { + uim, err := session.uimService(ctx) + if err != nil { + return err + } + return uim.RefreshComplete(ctx, qmi.UIMRefreshCompleteRequest{ + SessionType: qmi.UIMSessionTypeCardSlot1, + RefreshSuccess: true, + }) +} + +func (session *productionQMIRadioSession) GetIMEI(ctx context.Context) (string, error) { + if session == nil || session.dms == nil { + return "", errors.New("QMI DMS identity session is unavailable") + } + info, err := session.dms.GetDeviceSerialNumbers(ctx) + if err != nil { + return "", err + } + return info.IMEI, nil } func (session *productionQMIRadioSession) GetOperatingMode(ctx context.Context) (qmi.OperatingMode, error) { @@ -200,6 +634,10 @@ func (session *productionQMIRadioSession) Close() error { closeErrors = append(closeErrors, session.nas.Close()) session.nas = nil } + if session.client != nil && session.catID != 0 { + closeErrors = append(closeErrors, session.client.ReleaseClientID(qmi.ServiceCAT2, session.catID)) + session.catID = 0 + } if session.client != nil { closeErrors = append(closeErrors, session.client.Close()) session.client = nil diff --git a/internal/device/flight_qmi_cat_test.go b/internal/device/flight_qmi_cat_test.go new file mode 100644 index 0000000..4cf307e --- /dev/null +++ b/internal/device/flight_qmi_cat_test.go @@ -0,0 +1,52 @@ +package device + +import ( + "bytes" + "testing" +) + +func TestCATRefreshTerminalResponse(t *testing.T) { + raw := []byte{ + 0x44, 0x33, 0x22, 0x11, // reference + 0x0B, 0x00, // command length + 0xD0, 0x09, + 0x81, 0x03, 0x07, 0x01, 0x00, + 0x82, 0x02, 0x81, 0x82, + } + reference, response, ok := catRefreshTerminalResponse(raw) + if !ok { + t.Fatal("catRefreshTerminalResponse() did not recognize REFRESH") + } + if reference != 0x11223344 { + t.Fatalf("reference = 0x%08X", reference) + } + want := []byte{ + 0x81, 0x03, 0x07, 0x01, 0x00, + 0x82, 0x02, 0x82, 0x81, + 0x83, 0x01, 0x00, + } + if !bytes.Equal(response, want) { + t.Fatalf("response = % X, want % X", response, want) + } +} + +func TestCATRefreshTerminalResponseRejectsOtherCommands(t *testing.T) { + raw := []byte{ + 0x01, 0x00, 0x00, 0x00, + 0x0B, 0x00, + 0xD0, 0x09, + 0x81, 0x03, 0x01, 0x21, 0x00, // DISPLAY TEXT + 0x82, 0x02, 0x81, 0x02, + } + if _, _, ok := catRefreshTerminalResponse(raw); ok { + t.Fatal("catRefreshTerminalResponse() accepted a non-REFRESH command") + } +} + +func TestCATRefreshTerminalResponseSupportsLongBERLength(t *testing.T) { + command := []byte{0xD0, 0x81, 0x09, 0x81, 0x03, 0x02, 0x01, 0x01, 0x82, 0x02, 0x81, 0x82} + raw := append([]byte{0x02, 0x00, 0x00, 0x00, byte(len(command)), 0x00}, command...) + if _, _, ok := catRefreshTerminalResponse(raw); !ok { + t.Fatal("catRefreshTerminalResponse() rejected 0x81 BER length") + } +} diff --git a/internal/device/identity_qmi.go b/internal/device/identity_qmi.go index 5762c40..85e20e8 100644 --- a/internal/device/identity_qmi.go +++ b/internal/device/identity_qmi.go @@ -37,3 +37,27 @@ func (manager *Manager) readNativeQMIICCID(ctx context.Context, candidate modem. } return iccid, nil } + +func (manager *Manager) readNativeQMIIMEI(ctx context.Context, candidate modem.Candidate) (string, error) { + if manager.qmiRadioOpener == nil { + return "", errors.New("QMI DMS IMEI reader is unavailable") + } + session, err := manager.qmiRadioOpener(ctx, candidate.QMIControl) + if err != nil { + return "", err + } + defer session.Close() + reader, ok := session.(nativeQMIIMEISession) + if !ok { + return "", errors.New("QMI session does not expose DMS IMEI reading") + } + value, err := reader.GetIMEI(ctx) + if err != nil { + return "", fmt.Errorf("read device serial numbers: %w", err) + } + imei := parseIdentifier(modem.Response{Lines: []string{value}}, nil, 14, 17) + if imei == "" { + return "", errors.New("QMI DMS returned an invalid IMEI") + } + return imei, nil +} diff --git a/internal/device/manager_test.go b/internal/device/manager_test.go index e6a58cb..abae029 100644 --- a/internal/device/manager_test.go +++ b/internal/device/manager_test.go @@ -179,7 +179,6 @@ func TestManagerRefreshReadsNativeWWANICCIDThroughQMIUIM(t *testing.T) { {command: `AT+QENG="servingcell"`, response: okResponse(`+QENG: "servingcell","SEARCH"`)}, {command: "AT+COPS?", response: okResponse("+COPS: 0")}, {command: "AT+CEREG?", response: okResponse("+CEREG: 0,2")}, - {command: "AT+CGSN", response: okResponse("867123456789012")}, {command: "AT+CFUN?", response: okResponse("+CFUN: 1")}, {command: "AT+CNUM", response: okResponse(`+CNUM: "","+8613800138000",145`)}, }} @@ -201,7 +200,7 @@ func TestManagerRefreshReadsNativeWWANICCIDThroughQMIUIM(t *testing.T) { } t.Cleanup(func() { _ = manager.Stop(context.Background()) }) manager.qmiRadioOpener = func(context.Context, string) (qmiRadioSession, error) { - return &fakeQMIRadioSession{iccid: "89441000400316034372"}, nil + return &fakeQMIRadioSession{iccid: "89441000400316034372", imei: "861716070416510"}, nil } if err := manager.SetBackend("mhi-wwan0", "qmi"); err != nil { t.Fatal(err) @@ -211,7 +210,7 @@ func TestManagerRefreshReadsNativeWWANICCIDThroughQMIUIM(t *testing.T) { if err != nil { t.Fatalf("Refresh: %v", err) } - if snapshot.ICCID != "89441000400316034372" || !snapshot.SIMReady { + if snapshot.ICCID != "89441000400316034372" || snapshot.IMEI != "861716070416510" || !snapshot.SIMReady { t.Fatalf("native QMI identity = %#v", snapshot) } client.assertDone(t) diff --git a/internal/device/region.go b/internal/device/region.go index 027cf15..0a0a4a1 100644 --- a/internal/device/region.go +++ b/internal/device/region.go @@ -34,6 +34,9 @@ func CardMCCMNCWithLength(imsi string, mncLength int) (mcc string, mnc string) { strings.IndexFunc(digits, func(r rune) bool { return !unicode.IsDigit(r) }) >= 0 { return "", "" } + if IsPlaceholderIMSI(digits) { + return "", "" + } mcc = digits[:3] mnc = digits[3:] if mncLength != 2 && mncLength != 3 { @@ -45,6 +48,18 @@ func CardMCCMNCWithLength(imsi string, mncLength int) (mcc string, mnc string) { return mcc, mnc } +// IsPlaceholderIMSI recognizes an unprovisioned/test identity structurally, +// without tying the decision to a vendor-specific hard-coded ICCID. A valid +// subscriber identity cannot consist of an MCC followed only by zeroes; white +// cards commonly ship in exactly that state before a real profile is enabled. +func IsPlaceholderIMSI(imsi string) bool { + digits := strings.TrimSpace(imsi) + if len(digits) < 10 || strings.IndexFunc(digits, func(r rune) bool { return !unicode.IsDigit(r) }) >= 0 { + return false + } + return strings.Trim(digits[3:], "0") == "" +} + // RegionBlockReason returns a human-readable reason when the SIM identified by // the IMSI belongs to a blocked region. It returns an empty string when the // card is allowed or when the IMSI is unavailable: only a confirmed blocked diff --git a/internal/device/region_test.go b/internal/device/region_test.go index 40e90e6..1ca0585 100644 --- a/internal/device/region_test.go +++ b/internal/device/region_test.go @@ -33,6 +33,22 @@ func TestCardMCCMNC(t *testing.T) { } } +func TestPlaceholderIMSIIsNotTreatedAsARealCarrier(t *testing.T) { + t.Parallel() + if !IsPlaceholderIMSI("460000000000000") { + t.Fatal("all-zero subscriber identity should be treated as an unprovisioned placeholder") + } + if IsPlaceholderIMSI("460001234567890") { + t.Fatal("real subscriber identity was classified as a placeholder") + } + if mcc, mnc := CardMCCMNC("460000000000000"); mcc != "" || mnc != "" { + t.Fatalf("placeholder MCC/MNC = %q/%q, want empty", mcc, mnc) + } + if reason := RegionBlockReason("460000000000000"); reason != "" { + t.Fatalf("placeholder identity was region-blocked: %s", reason) + } +} + func TestRegionBlockReason(t *testing.T) { t.Parallel() for _, imsi := range []string{"460001234567890", "461001234567890"} { diff --git a/internal/device/snapshot.go b/internal/device/snapshot.go index 5760e39..392a504 100644 --- a/internal/device/snapshot.go +++ b/internal/device/snapshot.go @@ -39,6 +39,19 @@ func (manager *Manager) readSnapshot( if snapshot.Model == "" && !strings.EqualFold(candidate.Product, "Android") { snapshot.Model = candidate.Product } + // Native MHI/QMI devices expose their immutable modem identity through DMS. + // Read it before any SIM-dependent AT probes: a missing/bad card can make + // those commands slow or fail, but must never prevent IMEI from appearing. + if strings.EqualFold(strings.TrimSpace(backend), "qmi") && isNativeQMICandidate(candidate) { + qmiContext, cancelQMI := manager.withTimeout(ctx, manager.commandTimeout*5) + qmiIMEI, qmiErr := manager.readNativeQMIIMEI(qmiContext, candidate) + cancelQMI() + if qmiErr == nil { + snapshot.IMEI = qmiIMEI + } else { + snapshot.Warnings = append(snapshot.Warnings, "read IMEI via QMI DMS: "+qmiErr.Error()) + } + } optional := func(command string) (modem.Response, bool) { response, commandErr := manager.command(ctx, client, command) @@ -172,13 +185,31 @@ func (manager *Manager) readSnapshot( snapshot.RegistrationStatus = 1 snapshot.RegistrationSource = "COPS" } - if response, ok := optional("AT+CGSN"); ok { - snapshot.IMEI = parseIdentifier( - response, - []string{"+CGSN:", "+GSN:"}, - 14, - 17, - ) + if snapshot.IMEI == "" { + response, ok := optional("AT+CGSN") + if ok { + snapshot.IMEI = parseIdentifier( + response, + []string{"+CGSN:", "+GSN:"}, + 14, + 17, + ) + } + } + if snapshot.IMEI == "" && strings.EqualFold(strings.TrimSpace(backend), "qmi") && isNativeQMICandidate(candidate) { + qmiContext, cancelQMI := manager.withTimeout(ctx, manager.commandTimeout*5) + qmiIMEI, qmiErr := manager.readNativeQMIIMEI(qmiContext, candidate) + cancelQMI() + if qmiErr == nil { + snapshot.IMEI = qmiIMEI + } else { + snapshot.Warnings = append(snapshot.Warnings, "read IMEI via QMI DMS: "+qmiErr.Error()) + } + } + if snapshot.IMEI == "" && previousSnapshot != nil { + // IMEI is hardware identity and does not change with the inserted card. + // Preserve a prior successful read across a transient QMI/AT failure. + snapshot.IMEI = previousSnapshot.IMEI } if response, ok := optional("AT+CFUN?"); ok { diff --git a/internal/device/vowifi_qmi.go b/internal/device/vowifi_qmi.go new file mode 100644 index 0000000..01298ae --- /dev/null +++ b/internal/device/vowifi_qmi.go @@ -0,0 +1,194 @@ +package device + +import ( + "context" + "errors" + "fmt" + "strings" + "time" +) + +func (manager *Manager) withNativeQMIVoWiFiSession(ctx context.Context, id string, fn func(nativeQMIVoWiFiSession) error) error { + control, native, err := manager.nativeQMIControl(id) + if err != nil { + return err + } + if !native { + return errors.New("native QMI control is unavailable") + } + session, err := manager.qmiRadioOpener(ctx, control) + if err != nil { + return fmt.Errorf("open native QMI control: %w", err) + } + defer session.Close() + qmiSession, ok := session.(nativeQMIVoWiFiSession) + if !ok { + return errors.New("native QMI session lacks UIM/NAS support") + } + return fn(qmiSession) +} + +// ReadNativeQMIIdentity supplies the live subscription identity without using +// an AT port. The primitive return values intentionally keep device independent +// from the VoWiFi package while satisfying its narrow controller interface. +func (manager *Manager) ReadNativeQMIIdentity(ctx context.Context, id string) (iccid, imsi, imei, mcc, mnc string, err error) { + err = manager.withNativeQMIVoWiFiSession(ctx, id, func(session nativeQMIVoWiFiSession) error { + if iccid, err = session.GetICCID(ctx); err != nil { + return fmt.Errorf("read QMI ICCID: %w", err) + } + if imsi, err = session.GetIMSI(ctx); err != nil { + return fmt.Errorf("read QMI IMSI: %w", err) + } + if imei, err = session.GetIMEI(ctx); err != nil { + return fmt.Errorf("read QMI IMEI: %w", err) + } + if mcc, mnc, err = session.GetNativeMCCMNC(ctx); err != nil { + return fmt.Errorf("read QMI home PLMN: %w", err) + } + return nil + }) + return +} + +func (manager *Manager) ProbeNativeQMIApplication(ctx context.Context, id, preference string) (aid []byte, application string, err error) { + err = manager.withNativeQMIVoWiFiSession(ctx, id, func(session nativeQMIVoWiFiSession) error { + if strings.EqualFold(strings.TrimSpace(preference), "isim_strict") { + aid, err = session.GetISIMAID(ctx) + application = "ISIM" + return err + } + if aid, err = session.GetUSIMAID(ctx); err == nil { + application = "USIM" + return nil + } + aid, err = session.GetISIMAID(ctx) + application = "ISIM" + return err + }) + return +} + +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) + if openErr != nil { + return fmt.Errorf("open QMI UIM logical channel: %w", openErr) + } + command := append([]byte(nil), apdu...) + response, err = session.SendAPDU(ctx, 1, channel, command) + // ISO/IEC 7816-4 procedure bytes are transport-level continuation, + // not an AKA rejection. QMI exposes the raw status words, so follow + // 61xx/9Fxx with GET RESPONSE and retry 6Cxx with the advised Le while + // the same logical channel is still open. + for step := 0; err == nil && step < 4 && len(response) >= 2; step++ { + sw1, sw2 := response[len(response)-2], response[len(response)-1] + switch sw1 { + case 0x61, 0x9f: + response, err = session.SendAPDU(ctx, 1, channel, []byte{0x00, 0xc0, 0x00, 0x00, sw2}) + case 0x6c: + if len(command) < 5 { + step = 4 + continue + } + command[len(command)-1] = sw2 + response, err = session.SendAPDU(ctx, 1, channel, command) + default: + step = 4 + } + } + closeErr := session.CloseLogicalChannel(ctx, 1, channel) + return errors.Join(err, closeErr) + }) + return +} + +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) + if modeErr != nil { + return modeErr + } + mode = qmiModeAsCFUN(qmiMode) + serving, servingErr := session.GetServingSystem(ctx) + if servingErr == nil && serving != nil { + psAttached = serving.PSAttached + } + // An RF-off modem commonly rejects NAS serving-system queries; DMS mode + // remains sufficient evidence and data cannot be attached while RF is off. + if servingErr != nil && !isQMIRadioOffMode(qmiMode) { + return servingErr + } + return nil + }) + return +} + +func (manager *Manager) StopNativeQMICellularData(ctx context.Context, id string) error { + return manager.withNativeQMIVoWiFiSession(ctx, id, func(session nativeQMIVoWiFiSession) error { + serving, err := session.GetServingSystem(ctx) + if err != nil { + return nil + } + if serving == nil || !serving.PSAttached { + return nil + } + if err := session.AttachDetach(ctx, false); err != nil { + return err + } + deadline := time.NewTicker(250 * time.Millisecond) + defer deadline.Stop() + for attempt := 0; attempt < 12; attempt++ { + current, readErr := session.GetServingSystem(ctx) + if readErr == nil && (current == nil || !current.PSAttached) { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-deadline.C: + } + } + return errors.New("native QMI packet service remained attached") + }) +} + +func (manager *Manager) SetNativeQMIRadioOff(ctx context.Context, id string, off bool) error { + _, err := manager.SetFlight(ctx, id, off) + return err +} + +func (manager *Manager) powerCycleNativeQMISIM(ctx context.Context, id string) (bool, error) { + control, native, err := manager.nativeQMIControl(id) + if err != nil || !native { + return native, err + } + session, err := manager.qmiRadioOpener(ctx, control) + if err != nil { + return true, err + } + defer session.Close() + uim, ok := session.(nativeQMIVoWiFiSession) + if !ok { + return true, errors.New("native QMI session lacks SIM power control") + } + if resetter, ok := session.(nativeQMIUIMResetSession); ok { + _ = resetter.ResetUIM(ctx) + } + if err := uim.PowerOffSIM(ctx, 1); err != nil { + return true, err + } + select { + case <-ctx.Done(): + return true, ctx.Err() + case <-time.After(3 * time.Second): + } + if err := uim.PowerOnSIM(ctx, 1); err != nil { + return true, err + } + select { + case <-ctx.Done(): + return true, ctx.Err() + case <-time.After(time.Second): + } + return true, nil +} diff --git a/internal/server/device_api.go b/internal/server/device_api.go index 2748ae5..34d5495 100644 --- a/internal/server/device_api.go +++ b/internal/server/device_api.go @@ -239,10 +239,14 @@ func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) bool { return true } config := payload.toStoreDevice() + isNative410 := config.DeviceType == store.DeviceTypeWiFi410 // Newly added hardware starts fail-closed: RF is disabled immediately and - // VoWiFi becomes the desired service. Cellular registration is only - // restored by the user's later airplane-mode-off action. + // VoWiFi becomes the desired service on supported devices. Native 410 + // uses its QMI UIM/DMS/NAS adapter; only cellular SMS remains unavailable. config.VoWiFiEnabled = true + if isNative410 { + config.SMSEnabled = false + } config.NetworkEnabled = false if !s.developerActive(r.Context()) { config.NetworkEnabled = false @@ -284,7 +288,7 @@ func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) bool { } } } - if s.vowifi != nil { + if s.vowifi != nil && config.VoWiFiEnabled { if _, err := s.vowifi.RequestEnabled(config.ID, true); err != nil { s.logger.Warn("new device saved in safe airplane mode but VoWiFi start was not queued", "device_id", config.ID, "error", err) } @@ -512,6 +516,10 @@ func (s *Server) handleDevicePath( } entry, physicalID, physicalPresent := s.physicalForConfig(config) + if config.DeviceType == store.DeviceTypeWiFi410 && native410UnsupportedOperation(tail) { + writeError(w, http.StatusNotImplemented, "device_feature_unsupported", "this feature is not supported by the native OpenStick 410 backend") + return true + } if config.DeviceType == store.DeviceTypeUSBSIMReader && len(tail) > 0 { operation := strings.Join(tail, "/") unsupported := tail[0] == "network" || tail[0] == "operator_selection" || @@ -663,6 +671,14 @@ func (s *Server) handleDevicePath( return true } +func native410UnsupportedOperation(tail []string) bool { + if len(tail) == 0 { + return false + } + operation := strings.Join(tail, "/") + return tail[0] == "calls" || operation == "actions/reboot" +} + func (s *Server) handleUSBNetMode(w http.ResponseWriter, r *http.Request, physicalID string) bool { switch r.Method { case http.MethodGet: diff --git a/internal/server/device_features_api_test.go b/internal/server/device_features_api_test.go index d09dfb0..baa98ee 100644 --- a/internal/server/device_features_api_test.go +++ b/internal/server/device_features_api_test.go @@ -31,6 +31,29 @@ func decodeData(t *testing.T, recorder *httptest.ResponseRecorder) map[string]an return envelope.Data } +func TestNative410UnsupportedOperations(t *testing.T) { + tests := []struct { + path []string + unsupported bool + }{ + {path: []string{"esim"}}, + {path: []string{"esim", "profiles"}}, + {path: []string{"vowifi"}}, + {path: []string{"vowifi", "actions", "reconnect"}}, + {path: []string{"calls"}, unsupported: true}, + {path: []string{"actions", "reboot"}, unsupported: true}, + {path: []string{"actions", "refresh"}}, + {path: []string{"actions", "at"}}, + {path: []string{"flight-mode"}}, + {path: []string{"operator_selection"}}, + } + for _, test := range tests { + if got := native410UnsupportedOperation(test.path); got != test.unsupported { + t.Errorf("native410UnsupportedOperation(%v) = %v, want %v", test.path, got, test.unsupported) + } + } +} + func TestParseModemAPNProfiles(t *testing.T) { profiles := parseModemAPNProfiles([]string{ `+CGDCONT: 1,"IPV4V6","internet","0.0.0.0",0,0`, diff --git a/internal/server/esim_api.go b/internal/server/esim_api.go index b4b2156..dd0b84e 100644 --- a/internal/server/esim_api.go +++ b/internal/server/esim_api.go @@ -11,6 +11,7 @@ import ( "vocat/internal/device" "vocat/internal/store" + "vocat/internal/vowifi" ) func esimUnavailable(w http.ResponseWriter) { @@ -400,6 +401,31 @@ func (s *Server) handleEsimSwitch(w http.ResponseWriter, r *http.Request, config writeError(w, http.StatusBadRequest, "invalid_request", "iccid is required") return } + endMaintenance := func() {} + if maintenance, ok := s.vowifi.(VoWiFiMaintenanceController); ok { + if err := maintenance.BeginMaintenance(configuredID); err != nil { + s.writeDeviceError(w, fmt.Errorf("prepare VoWiFi for profile switch: %w", err)) + return + } + released := false + endMaintenance = func() { + if !released { + released = true + maintenance.EndMaintenance(configuredID) + } + } + defer endMaintenance() + } + // A live VoWiFi runtime owns the SIM/QMI session while AKA, IMS and SMS are + // active. Tear it down before touching flight mode or the ISD-R logical + // channel; otherwise native-WWAN devices wait on the QMI lease until the HTTP + // request times out. This only changes the runtime desired state. The saved + // per-ICCID policy is left intact and the target profile's policy is restored + // after the verified switch below. + if err := s.quiesceVoWiFiForProfileSwitch(r.Context(), configuredID); err != nil { + s.writeDeviceError(w, err) + 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. @@ -454,6 +480,10 @@ func (s *Server) handleEsimSwitch(w http.ResponseWriter, r *http.Request, config s.writeStoreError(w, err) return } + // The target profile is now active and its persisted policy has replaced the + // old runtime configuration. Allow reconciliation again before requesting + // the target profile's desired VoWiFi state. + endMaintenance() canRestoreFlightImmediately := s.vowifi == nil if s.vowifi != nil { state, stateErr := s.vowifi.State(configuredID) @@ -484,6 +514,40 @@ func (s *Server) handleEsimSwitch(w http.ResponseWriter, r *http.Request, config }}) } +func (s *Server) quiesceVoWiFiForProfileSwitch(ctx context.Context, configuredID string) error { + if s.vowifi == nil { + return nil + } + state, err := s.vowifi.State(configuredID) + if err != nil { + return fmt.Errorf("stop VoWiFi before switching profile: %w", err) + } + if !state.Enabled && !state.Active && state.Phase == vowifi.PhaseIdle { + return nil + } + if _, err := s.vowifi.RequestEnabled(configuredID, false); err != nil { + return fmt.Errorf("stop VoWiFi before switching profile: %w", err) + } + waitContext, cancel := context.WithTimeout(ctx, 45*time.Second) + defer cancel() + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + for { + state, err = s.vowifi.State(configuredID) + if err != nil { + return fmt.Errorf("wait for VoWiFi to stop before switching profile: %w", err) + } + if !state.Enabled && !state.Active && state.Phase == vowifi.PhaseIdle { + return nil + } + select { + case <-waitContext.Done(): + return fmt.Errorf("wait for VoWiFi to stop before switching profile: %w", waitContext.Err()) + case <-ticker.C: + } + } +} + func (s *Server) handleEsimDisable(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool) { if s.devices == nil { writeError(w, http.StatusServiceUnavailable, "device_manager_unavailable", "device manager is unavailable") diff --git a/internal/server/server.go b/internal/server/server.go index d1a55f4..6da48bc 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -167,6 +167,11 @@ type VoWiFiController interface { RequestReconnect(string) (vowifi.State, error) } +type VoWiFiMaintenanceController interface { + BeginMaintenance(string) error + EndMaintenance(string) +} + type VoWiFiCallController interface { Calls(string) ([]vowifi.Call, error) DialCall(context.Context, string, string) (vowifi.Call, error) diff --git a/internal/server/sms_api.go b/internal/server/sms_api.go index 5379b98..2b75494 100644 --- a/internal/server/sms_api.go +++ b/internal/server/sms_api.go @@ -236,6 +236,10 @@ func (s *Server) handleSMSSend(w http.ResponseWriter, r *http.Request) { s.writeStoreError(w, err) return } + if store.NormalizeDeviceType(config.DeviceType) == store.DeviceTypeWiFi410 { + writeError(w, http.StatusNotImplemented, "device_feature_unsupported", "SMS is not supported by the native OpenStick 410 backend") + return + } entry, physicalID, present := s.physicalForConfig(config) if !s.requirePhysicalDevice(w, present) { return @@ -667,7 +671,8 @@ func (s *Server) syncModemSMS(ctx context.Context, onlyDevice string) { } func supportsModemSMSStorage(config store.Device) bool { - return store.NormalizeDeviceType(config.DeviceType) != store.DeviceTypeUSBSIMReader + deviceType := store.NormalizeDeviceType(config.DeviceType) + return deviceType != store.DeviceTypeUSBSIMReader && deviceType != store.DeviceTypeWiFi410 } func shouldDeferModemSMSSync(state vowifi.State, stateErr error) bool { diff --git a/internal/server/sms_api_test.go b/internal/server/sms_api_test.go index bfbd80c..67b97c4 100644 --- a/internal/server/sms_api_test.go +++ b/internal/server/sms_api_test.go @@ -57,6 +57,15 @@ func TestSMSThreadAllDevicesUsesIMSIFilter(t *testing.T) { } } +func TestNative410DoesNotUseModemSMSStorage(t *testing.T) { + if supportsModemSMSStorage(store.Device{DeviceType: store.DeviceTypeWiFi410}) { + t.Fatal("native OpenStick 410 unexpectedly enabled modem SMS storage polling") + } + if !supportsModemSMSStorage(store.Device{DeviceType: store.DeviceTypePCIeEC20EC25}) { + t.Fatal("EC20 modem SMS storage polling was disabled") + } +} + func TestSMSThreadConfiguredDeviceUsesStableIMEI(t *testing.T) { ctx := context.Background() database, err := store.Open(ctx, ":memory:") diff --git a/internal/vowifi/native_qmi_adapter.go b/internal/vowifi/native_qmi_adapter.go new file mode 100644 index 0000000..2186dfb --- /dev/null +++ b/internal/vowifi/native_qmi_adapter.go @@ -0,0 +1,169 @@ +package vowifi + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" +) + +// NativeQMIController is implemented by device.Manager. It exposes only the +// QMI UIM/DMS/NAS primitives needed by VoWiFi and keeps transport ownership in +// the device layer. +type NativeQMIController interface { + ReadNativeQMIIdentity(context.Context, string) (iccid, imsi, imei, mcc, mnc string, err error) + ProbeNativeQMIApplication(context.Context, string, string) ([]byte, string, error) + AuthenticateNativeQMI(context.Context, string, []byte, []byte) ([]byte, error) + NativeQMIRadioSnapshot(context.Context, string) (mode int, psAttached bool, err error) + StopNativeQMICellularData(context.Context, string) error + SetNativeQMIRadioOff(context.Context, string, bool) error +} + +type NativeQMIAdapter struct { + controller NativeQMIController + pureAirplanePolicy func(string) bool + mu sync.Mutex + bindings map[string]nativeQMIBinding +} + +type nativeQMIBinding struct { + deviceID string + iccid string + imsi string + aid []byte + application string +} + +var _ SIMIdentityReader = (*NativeQMIAdapter)(nil) +var _ PreferredAKAProvider = (*NativeQMIAdapter)(nil) +var _ RadioController = (*NativeQMIAdapter)(nil) + +func NewNativeQMIAdapter(controller NativeQMIController, purePolicy func(string) bool) (*NativeQMIAdapter, error) { + if controller == nil { + return nil, errors.New("vocat: native QMI controller is required") + } + return &NativeQMIAdapter{controller: controller, pureAirplanePolicy: purePolicy, bindings: make(map[string]nativeQMIBinding)}, nil +} + +func (adapter *NativeQMIAdapter) ReadIdentity(ctx context.Context, deviceID string) (SIMIdentity, error) { + deviceID = strings.TrimSpace(deviceID) + if deviceID == "" { + return SIMIdentity{}, errors.New("vocat: native QMI device ID is required") + } + iccid, imsi, imei, mcc, mnc, err := adapter.controller.ReadNativeQMIIdentity(ctx, deviceID) + if err != nil { + return SIMIdentity{}, err + } + identity := applyAssignedCarrierRoute(SIMIdentity{ICCID: strings.TrimSpace(iccid), IMSI: strings.TrimSpace(imsi), IMEI: strings.TrimSpace(imei), HomeMCC: strings.TrimSpace(mcc), HomeMNC: strings.TrimSpace(mnc)}) + if err := identity.validate(); err != nil { + return SIMIdentity{}, err + } + adapter.mu.Lock() + adapter.bindings[identity.ICCID] = nativeQMIBinding{deviceID: deviceID, iccid: identity.ICCID, imsi: identity.IMSI} + adapter.mu.Unlock() + return identity, nil +} + +func (adapter *NativeQMIAdapter) binding(identity SIMIdentity) (nativeQMIBinding, error) { + adapter.mu.Lock() + binding, ok := adapter.bindings[strings.TrimSpace(identity.ICCID)] + adapter.mu.Unlock() + if !ok { + return nativeQMIBinding{}, errors.New("vocat: native QMI SIM identity is not bound to a device") + } + if binding.imsi != strings.TrimSpace(identity.IMSI) { + return nativeQMIBinding{}, ErrEC20IdentityChanged + } + return binding, nil +} + +func (adapter *NativeQMIAdapter) verify(ctx context.Context, binding nativeQMIBinding) error { + iccid, imsi, _, _, _, err := adapter.controller.ReadNativeQMIIdentity(ctx, binding.deviceID) + if err != nil { + return err + } + if strings.TrimSpace(iccid) != binding.iccid || strings.TrimSpace(imsi) != binding.imsi { + return ErrEC20IdentityChanged + } + return nil +} + +func (adapter *NativeQMIAdapter) CheckReady(ctx context.Context, identity SIMIdentity) (AKAEvidence, error) { + binding, err := adapter.binding(identity) + if err != nil { + return AKAEvidence{}, err + } + if err := adapter.verify(ctx, binding); err != nil { + return AKAEvidence{}, err + } + aid, application, err := adapter.controller.ProbeNativeQMIApplication(ctx, binding.deviceID, "") + if err != nil { + return AKAEvidence{}, fmt.Errorf("%w: %v", ErrEC20ApplicationAbsent, err) + } + binding.aid, binding.application = append([]byte(nil), aid...), application + adapter.mu.Lock() + adapter.bindings[binding.iccid] = binding + adapter.mu.Unlock() + return AKAEvidence{Ready: true, Application: application}, nil +} + +func (adapter *NativeQMIAdapter) Authenticate(ctx context.Context, identity SIMIdentity, challenge AKAChallenge) (AKAResult, error) { + return adapter.AuthenticateWithPreference(ctx, identity, challenge, "") +} + +func (adapter *NativeQMIAdapter) AuthenticateWithPreference(ctx context.Context, identity SIMIdentity, challenge AKAChallenge, preference string) (AKAResult, error) { + binding, err := adapter.binding(identity) + if err != nil { + return AKAResult{}, err + } + strictISIM := strings.EqualFold(strings.TrimSpace(preference), "isim_strict") + if len(binding.aid) == 0 || (strictISIM && binding.application != "ISIM") { + aid, application, probeErr := adapter.controller.ProbeNativeQMIApplication(ctx, binding.deviceID, preference) + if probeErr != nil { + return AKAResult{}, fmt.Errorf("%w: %v", ErrEC20ApplicationAbsent, probeErr) + } + binding.aid, binding.application = append([]byte(nil), aid...), application + adapter.mu.Lock() + adapter.bindings[binding.iccid] = binding + adapter.mu.Unlock() + } + if strictISIM && binding.application != "ISIM" { + return AKAResult{}, ErrEC20ApplicationAbsent + } + if err := adapter.verify(ctx, binding); err != nil { + return AKAResult{}, err + } + raw, err := adapter.controller.AuthenticateNativeQMI(ctx, binding.deviceID, binding.aid, buildUSIMAuthenticateAPDU(challenge)) + if err != nil { + return AKAResult{}, ErrEC20AKACommand + } + return parseUSIMAuthenticateResponse(raw) +} + +func (adapter *NativeQMIAdapter) Snapshot(ctx context.Context, deviceID string) (RadioSnapshot, error) { + mode, attached, err := adapter.controller.NativeQMIRadioSnapshot(ctx, deviceID) + if err != nil { + return RadioSnapshot{}, err + } + pure := false + if adapter.pureAirplanePolicy != nil { + pure = adapter.pureAirplanePolicy(deviceID) + } + return RadioSnapshot{CellularDataEnabled: attached, OperatingMode: mode, PureAirplanePolicy: pure}, nil +} + +func (adapter *NativeQMIAdapter) StopCellularData(ctx context.Context, deviceID string) error { + return adapter.controller.StopNativeQMICellularData(ctx, deviceID) +} + +func (adapter *NativeQMIAdapter) EnterVoWiFiRFOff(ctx context.Context, deviceID string) error { + return adapter.controller.SetNativeQMIRadioOff(ctx, deviceID, true) +} + +func (adapter *NativeQMIAdapter) Restore(ctx context.Context, deviceID string, snapshot RadioSnapshot) error { + // A user VoWiFi policy is fail-closed. Otherwise restore whether the modem + // was online, never a packet context that was detached for VoWiFi. + off := snapshot.PureAirplanePolicy || snapshot.OperatingMode != 1 + return adapter.controller.SetNativeQMIRadioOff(ctx, deviceID, off) +} diff --git a/internal/vowifi/runtime/manager.go b/internal/vowifi/runtime/manager.go index 8d3d4a6..791e3ad 100644 --- a/internal/vowifi/runtime/manager.go +++ b/internal/vowifi/runtime/manager.go @@ -56,6 +56,7 @@ type Manager struct { type entry struct { orchestrator *vowifi.Orchestrator + maintenance bool busy bool reconnectPending bool disablePending bool @@ -66,6 +67,36 @@ type entry struct { stopWatch func() } +// BeginMaintenance temporarily suppresses background enable requests while a +// caller performs an exclusive SIM operation such as switching eSIM profiles. +// Disable requests remain allowed so the current runtime can release QMI/UIM. +func (manager *Manager) BeginMaintenance(deviceID string) error { + if err := manager.Ensure(manager.ctx, deviceID); err != nil { + return err + } + manager.mu.Lock() + defer manager.mu.Unlock() + if manager.closed { + return ErrClosed + } + item := manager.entries[deviceID] + if item == nil { + return ErrNotRegistered + } + item.maintenance = true + return nil +} + +// EndMaintenance re-enables ordinary desired-state reconciliation. The caller +// then applies the newly active profile's persisted policy. +func (manager *Manager) EndMaintenance(deviceID string) { + manager.mu.Lock() + if item := manager.entries[deviceID]; item != nil { + item.maintenance = false + } + manager.mu.Unlock() +} + func New(options Options) *Manager { if options.Logger == nil { options.Logger = slog.Default() @@ -210,6 +241,11 @@ func (manager *Manager) RequestEnabled(deviceID string, enabled bool) (vowifi.St } manager.mu.Lock() item := manager.entries[deviceID] + if item.maintenance && enabled { + state := item.orchestrator.State() + manager.mu.Unlock() + return state, nil + } item.desiredEnabled = enabled if item.busy { manager.logger.Info( diff --git a/web/src/components/devices/CardPolicyPanel.tsx b/web/src/components/devices/CardPolicyPanel.tsx index 373573c..363f96b 100644 --- a/web/src/components/devices/CardPolicyPanel.tsx +++ b/web/src/components/devices/CardPolicyPanel.tsx @@ -14,11 +14,12 @@ export interface CardPolicyPanelProps { iccid?: string; policy: CardPolicy | null; deviceOnline: boolean; - onPolicyChanged: () => void | Promise; + onPolicyChanged: () => void | Promise; wifiCallingOnly?: boolean; + vowifiUnsupported?: boolean; } -export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolicyChanged, wifiCallingOnly = false }: CardPolicyPanelProps) { +export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolicyChanged, wifiCallingOnly = false, vowifiUnsupported = false }: CardPolicyPanelProps) { const { t } = useI18n(); const operable = deviceOnline && !!iccid; const currentPolicy = policy?.iccid === iccid ? policy : null; @@ -120,7 +121,7 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
- + /> : null} {!wifiCallingOnly ? void; onOpenSms: () => void; wifiCallingOnly?: boolean; + modemControlOnly?: boolean; } export function DeviceDetailHeader(props: DeviceDetailHeaderProps) { @@ -59,12 +60,12 @@ export function DeviceDetailHeader(props: DeviceDetailHeaderProps) { />
) : null} - {!props.wifiCallingOnly ? : null} - + : null} diff --git a/web/src/pages/DevicesPage.tsx b/web/src/pages/DevicesPage.tsx index fb40abe..de7ac35 100644 --- a/web/src/pages/DevicesPage.tsx +++ b/web/src/pages/DevicesPage.tsx @@ -593,6 +593,7 @@ export default function DevicesPage() { const detailOnline = isDeviceOnline(detail); const isReader = detail?.deviceType === "usb_sim_reader"; + const isNative410 = detail?.deviceType === "wifi_410"; useEffect(() => { if (isReader && ["at", "ussd"].includes(activeTab)) setActiveTab("overview"); }, [isReader, activeTab]); @@ -696,6 +697,7 @@ export default function DevicesPage() { onRebootModem={handleRebootModem} onOpenSms={handleOpenSms} wifiCallingOnly={isReader} + modemControlOnly={isNative410} />