2 Commits
26 changed files with 1534 additions and 40 deletions
+11 -1
View File
@@ -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
+71
View File
@@ -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)
}
+1 -1
View File
@@ -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)
+42 -8
View File
@@ -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"
+236 -5
View File
@@ -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 -> "<channel> 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 <iccid bcd> } 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)
+36
View File
@@ -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"`,
+441 -3
View File
@@ -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
+52
View File
@@ -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")
}
}
+24
View File
@@ -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
}
+2 -3
View File
@@ -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)
+15
View File
@@ -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
+16
View File
@@ -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"} {
+38 -7
View File
@@ -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 {
+194
View File
@@ -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
}
+19 -3
View File
@@ -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:
@@ -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`,
+64
View File
@@ -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")
+5
View File
@@ -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)
+6 -1
View File
@@ -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 {
+9
View File
@@ -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:")
+169
View File
@@ -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)
}
+36
View File
@@ -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(
+13 -1
View File
@@ -298,6 +298,18 @@ detect_arch() {
# --- Download + verify -------------------------------------------------------
VOCAT_TMP=""
# curl transfer options for the binary download. -f makes curl fail on HTTP
# errors and -L follows the release-asset redirect. On an interactive terminal
# we show a single-line progress bar so a multi-megabyte download gives visible
# feedback; otherwise (piped, cron, systemd) we stay quiet but still surface
# errors via -S.
if [ -t 2 ]; then
CURL_DL_OPTS=(-fSL --progress-bar)
else
CURL_DL_OPTS=(-fsSL)
fi
download_and_verify() {
VOCAT_TMP=$(mktemp -d)
trap 'rm -rf "$VOCAT_TMP"' EXIT
@@ -307,7 +319,7 @@ download_and_verify() {
asset="vocat-linux-${ARCH_FALLBACK}"
fi
msg "下载 $asset ..." "Downloading $asset ..."
curl -fsSL -o "${VOCAT_TMP}/vocat" "${base}/${asset}" || die "下载二进制失败。" "Failed to download the binary."
curl "${CURL_DL_OPTS[@]}" -o "${VOCAT_TMP}/vocat" "${base}/${asset}" || die "下载二进制失败。" "Failed to download the binary."
curl -fsSL -o "${VOCAT_TMP}/SHA256SUMS" "${base}/SHA256SUMS" || die "下载 SHA256SUMS 失败。" "Failed to download SHA256SUMS."
local expected actual
@@ -14,11 +14,12 @@ export interface CardPolicyPanelProps {
iccid?: string;
policy: CardPolicy | null;
deviceOnline: boolean;
onPolicyChanged: () => void | Promise<void>;
onPolicyChanged: () => void | Promise<void>;
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
</div>
</div>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
<PolicySwitchCard
{!vowifiUnsupported ? <PolicySwitchCard
title="VoWiFi"
subtitle={t("启用时强制关闭蜂窝射频;关闭 VoWiFi 后仍保持飞行模式")}
tone="orange"
@@ -129,7 +130,7 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
pending={toggles.vowifiPending}
failed={toggles.vowifiFailed}
onToggle={toggles.onVoWiFiToggle}
/>
/> : null}
{!wifiCallingOnly ? <PolicySwitchCard
title={t("飞行模式")}
subtitle={t("只有手动关闭此开关才允许设备连接基站")}
@@ -16,6 +16,7 @@ export interface DeviceDetailHeaderProps {
onRebootModem: () => void;
onOpenSms: () => void;
wifiCallingOnly?: boolean;
modemControlOnly?: boolean;
}
export function DeviceDetailHeader(props: DeviceDetailHeaderProps) {
@@ -59,12 +60,12 @@ export function DeviceDetailHeader(props: DeviceDetailHeaderProps) {
/>
</div>
) : null}
{!props.wifiCallingOnly ? <Button loading={props.rebooting} onClick={props.onRebootModem} className="ui-glass-border !border-0 hover:!text-red-600" icon={<PowerRegular />}>
{!props.wifiCallingOnly && !props.modemControlOnly ? <Button loading={props.rebooting} onClick={props.onRebootModem} className="ui-glass-border !border-0 hover:!text-red-600" icon={<PowerRegular />}>
{t("重启模组")}
</Button> : null}
<Button onClick={props.onOpenSms} className="ui-glass-border !border-0" icon={<ChatRegular />}>
{!props.modemControlOnly ? <Button onClick={props.onOpenSms} className="ui-glass-border !border-0" icon={<ChatRegular />}>
{t("短信")}
</Button>
</Button> : null}
</div>
</div>
</div>
+2
View File
@@ -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}
/>
<div className="device-detail-tabs ui-card p-6">
<Tabs tabs={tabItems} value={activeTab} onChange={handleTabChange} />