2 Commits
Author SHA1 Message Date
MengMengCode 22487dbb1f Add PC/SC backend support for USB SIM readers
- Implemented a new `unsupportedBackend` in `backend_stub.go` to handle unsupported platforms.
- Created a `Service` struct in `service.go` to manage interactions with smart cards, including session management and identity reading.
- Added methods for reading identity, checking readiness, and authenticating with USIM applications.
- Introduced a `scriptedCard` for testing purposes in `service_test.go` to simulate card responses.
- Defined necessary types and error handling in `types.go` for better clarity and usability.
- Developed a `PCSCAdapter` in `vowifi/pcsc_adapter.go` to integrate PC/SC service with Wi-Fi calling functionalities.
- Added a new SVG icon for USB SIM readers in `public/sim-reader.svg`.
- Implemented tests to ensure correct functionality and error handling in various scenarios.
2026-08-12 02:33:32 +08:00
Meng MengandGitHub f9bb38aabe Add star history badge to README 2026-08-11 23:52:46 +08:00
45 changed files with 1970 additions and 84 deletions
+2
View File
@@ -316,3 +316,5 @@ cd web && npm run build
## License
See [LICENSE](LICENSE).
[![MengMengCode/VoCat Star History](https://mengmeng.meteor-history.com/api/embed/MengMengCode/VoCat.svg?sig=sdeXRVxAoY3yLWgXL7JViY2USYIN3t9neJ6ScPvgUAo&theme=light&style=xkcd&color=dd4528&background=ffffff&textColor=000000&width=900&height=600&lineWidth=3&showTitle=true&showLegend=true&showDots=false&v=0.0.14)](https://meteor-history.com)
+70 -4
View File
@@ -27,6 +27,7 @@ import (
"vocat/internal/extensions"
"vocat/internal/httpsmode"
"vocat/internal/loghub"
"vocat/internal/pcsc"
"vocat/internal/server"
"vocat/internal/store"
"vocat/internal/update"
@@ -184,7 +185,8 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
return err
}
deviceManager, err := device.NewManager(device.Options{})
cardReaders := pcsc.New()
deviceManager, err := device.NewManager(device.Options{CardReaders: cardReaders})
if err != nil {
return fmt.Errorf("create device manager: %w", err)
}
@@ -220,6 +222,7 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
logger,
database,
deviceManager,
cardReaders,
)
if err != nil {
return fmt.Errorf("configure VoWiFi runtime: %w", err)
@@ -362,6 +365,12 @@ func configureDeviceBackends(
if mapErr != nil {
continue
}
if config.DeviceType == store.DeviceTypeUSBSIMReader {
if err := manager.SetSIMPin(entry.ID, config.SIMPIN); err != nil {
logger.Warn("configure USB SIM reader", "device_id", config.ID, "error", err)
}
continue
}
if err := manager.SetBackend(entry.ID, config.DeviceBackend); err != nil {
logger.Warn("configure device backend", "device_id", config.ID, "backend", config.DeviceBackend, "error", err)
}
@@ -384,6 +393,9 @@ func restoreDefaultCellularRadios(
}
mapper := integration.ATMapper{Store: database, Devices: manager}
for _, config := range configs {
if config.DeviceType == store.DeviceTypeUSBSIMReader {
continue
}
if config.VoWiFiEnabled {
continue
}
@@ -431,6 +443,9 @@ func restoreConfiguredCellularData(
}
mapper := integration.ATMapper{Store: database, Devices: manager}
for _, config := range configs {
if config.DeviceType == store.DeviceTypeUSBSIMReader {
continue
}
if !config.NetworkEnabled || config.VoWiFiEnabled {
continue
}
@@ -482,6 +497,9 @@ func disableAllDeveloperCellularData(
}
mapper := integration.ATMapper{Store: database, Devices: manager}
for _, config := range configs {
if config.DeviceType == store.DeviceTypeUSBSIMReader {
continue
}
entry, err := mapper.Get(config.ID)
if err != nil {
continue
@@ -536,12 +554,13 @@ func configureVoWiFiRuntime(
logger *slog.Logger,
database *store.Store,
deviceManager *device.Manager,
cardReaders *pcsc.Service,
) (*vowifiruntime.Manager, error) {
mapper := integration.ATMapper{
Store: database,
Devices: deviceManager,
}
adapter, err := vowifi.NewEC20Adapter(mapper, vowifi.EC20AdapterOptions{
ec20Adapter, err := vowifi.NewEC20Adapter(mapper, vowifi.EC20AdapterOptions{
// The test deployment is deliberately non-cellular. VoWiFi teardown
// may restore CFUN, but it must never reactivate a PDP context.
RestoreCellularData: false,
@@ -556,6 +575,16 @@ func configureVoWiFiRuntime(
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 {
return pcsc.Selector{}, "", resolveErr
}
return pcsc.Selector{USBPath: config.USBPath, ReaderName: config.ControlDevice}, config.SIMPIN, nil
})
if err != nil {
return nil, err
}
projector := integration.StateProjector{
Store: database,
Devices: mapper,
@@ -568,6 +597,10 @@ func configureVoWiFiRuntime(
if err != nil {
return nil, fmt.Errorf("load device %q VoWiFi config: %w", deviceID, err)
}
adapter := vowifiDeviceAdapter(ec20Adapter)
if deviceConfig.DeviceType == store.DeviceTypeUSBSIMReader {
adapter = pcscAdapter
}
return newVoWiFiOrchestrator(deviceConfig, database, adapter)
},
})
@@ -601,10 +634,16 @@ func configureVoWiFiRuntime(
return manager, nil
}
type vowifiDeviceAdapter interface {
vowifi.SIMIdentityReader
vowifi.AKAProvider
vowifi.RadioController
}
func newVoWiFiOrchestrator(
deviceConfig store.Device,
database *store.Store,
adapter *vowifi.EC20Adapter,
adapter vowifiDeviceAdapter,
) (*vowifi.Orchestrator, error) {
apn := deviceConfig.APN
if apn == "" {
@@ -734,9 +773,18 @@ func provisionDiscoveredDevices(
candidate := discovered.Candidate
backend := "at"
control := candidate.ATPort.OpenPath()
deviceType := store.DeviceTypePCIeEC20EC25
esimTransport := backend
if candidate.QMIControl != "" {
backend = "qmi"
control = candidate.QMIControl
esimTransport = backend
}
if candidate.HardwareKind == pcsc.HardwareKind {
backend = "pcsc"
control = candidate.ReaderName
deviceType = store.DeviceTypeUSBSIMReader
esimTransport = "pcsc"
}
name := candidate.Product
if name == "" || strings.EqualFold(name, "Android") {
@@ -745,6 +793,7 @@ func provisionDiscoveredDevices(
if err := database.UpsertDevice(ctx, store.Device{
ID: discovered.ID,
Name: name,
DeviceType: deviceType,
Interface: candidate.NetworkInterface,
ControlDevice: control,
ATPort: candidate.ATPort.OpenPath(),
@@ -755,7 +804,7 @@ func provisionDiscoveredDevices(
StopBits: 1,
Parity: "none",
DeviceBackend: backend,
ESIMTransport: backend,
ESIMTransport: esimTransport,
NetworkEnabled: false,
SMSEnabled: true,
VoWiFiEnabled: true,
@@ -931,6 +980,7 @@ func reconcileCardPolicies(
manager *device.Manager,
vowifiManager *vowifiruntime.Manager,
) {
observedCards := make(map[string]string)
reconcile := func() {
policies, policyListErr := database.ListCardPolicies(ctx)
if policyListErr == nil {
@@ -953,12 +1003,26 @@ func reconcileCardPolicies(
for _, config := range configs {
entry, mapErr := mapper.Get(config.ID)
if mapErr != nil || entry.Snapshot == nil {
if config.DeviceType == store.DeviceTypeUSBSIMReader && observedCards[config.ID] != "missing" {
if state, stateErr := vowifiManager.State(config.ID); stateErr == nil && state.ICCID != "" {
_, _ = vowifiManager.RequestReconnect(config.ID)
}
observedCards[config.ID] = "missing"
}
continue
}
iccid := strings.TrimSpace(entry.Snapshot.ICCID)
if iccid == "" {
if config.DeviceType == store.DeviceTypeUSBSIMReader && observedCards[config.ID] != "missing" {
if state, stateErr := vowifiManager.State(config.ID); stateErr == nil && state.ICCID != "" {
_, _ = vowifiManager.RequestReconnect(config.ID)
}
observedCards[config.ID] = "missing"
}
continue
}
previousObserved := observedCards[config.ID]
observedCards[config.ID] = iccid
policy, policyErr := database.CardPolicy(ctx, iccid)
if policyErr != nil {
continue
@@ -1001,6 +1065,8 @@ func reconcileCardPolicies(
_, _ = vowifiManager.RequestEnabled(config.ID, true)
case state.ICCID != "" && !strings.EqualFold(strings.TrimSpace(state.ICCID), iccid):
_, _ = vowifiManager.RequestReconnect(config.ID)
case config.DeviceType == store.DeviceTypeUSBSIMReader && previousObserved == "missing":
_, _ = vowifiManager.RequestReconnect(config.ID)
}
continue
}
+2
View File
@@ -3,6 +3,7 @@ module vocat
go 1.25.0
require (
github.com/ElMostafaIdrassi/goscard v1.0.0
github.com/coder/websocket v1.8.15
go.bug.st/serial v1.6.4
golang.org/x/crypto v0.41.0
@@ -14,6 +15,7 @@ require (
require (
github.com/creack/goselect v0.1.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.8.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
+6 -2
View File
@@ -1,3 +1,5 @@
github.com/ElMostafaIdrassi/goscard v1.0.0 h1:RDG5QrqrQBUoi5MkzM4zILdYf8qDn62daYZszqvdgx0=
github.com/ElMostafaIdrassi/goscard v1.0.0/go.mod h1:uGOakQe2fFlW2cVlr9cv6x07uelrf0j0aKPbR7jGgfg=
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0=
@@ -6,6 +8,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I=
github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -18,8 +22,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A=
go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
+3
View File
@@ -274,6 +274,9 @@ func (manager *Manager) SetFlight(
if err := manager.validateActive(id, state); err != nil {
return FlightResult{}, err
}
if manager.candidateFor(state).HardwareKind == "pcsc" {
return FlightResult{PreviousMode: 4, CurrentMode: 4, FlightMode: true, RadioOff: true}, nil
}
client, err := manager.clientLocked(ctx, state, manager.candidateFor(state))
if err != nil {
manager.setResult(id, state, nil, err)
+121 -26
View File
@@ -10,6 +10,7 @@ import (
"vocat/internal/i18n"
"vocat/internal/modem"
"vocat/internal/pcsc"
)
// eUICC / eSIM (LPA, SGP.22) access over the modem's AT+CSIM APDU passthrough.
@@ -190,9 +191,11 @@ func parseCSIM(response modem.Response) ([]byte, int, error) {
// euiccChannel is an open logical channel to the eUICC's ISD-R.
type euiccChannel struct {
manager *Manager
id string
channel int
manager *Manager
id string
channel int
pcscSession *pcsc.Session
resetOnClose bool
}
// csimAPDUTimeout bounds a single AT+CSIM exchange. Loading a BoundProfilePackage
@@ -264,6 +267,14 @@ func (manager *Manager) openEuiccOnce(ctx context.Context, id string) (*euiccCha
}
func (manager *Manager) openEuiccOnceAID(ctx context.Context, id, aidHex string) (*euiccChannel, error) {
state, lookupErr := manager.lookup(id)
if lookupErr != nil {
return nil, lookupErr
}
candidate := manager.candidateFor(state)
if candidate.HardwareKind == pcsc.HardwareKind {
return manager.openPCSCEuiccOnceAID(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.
@@ -302,6 +313,37 @@ func (manager *Manager) openEuiccOnceAID(ctx context.Context, id, aidHex string)
return channel, 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,
})
if err != nil {
return nil, err
}
payload, sw, err := session.Transmit(ctx, []byte{0x00, 0x70, 0x00, 0x00, 0x01})
if err != nil || sw != 0x9000 || len(payload) != 1 {
session.Close()
if err != nil {
return nil, fmt.Errorf("esim: PC/SC MANAGE CHANNEL: %w", err)
}
return nil, errNoLogicalChannel
}
channel := &euiccChannel{manager: manager, id: id, channel: int(payload[0]), pcscSession: session}
aidHex = strings.ToUpper(strings.TrimSpace(aidHex))
aid, err := hex.DecodeString(aidHex)
if err != nil || len(aid) == 0 || len(aid) > 255 {
channel.close(context.Background())
return nil, fmt.Errorf("esim: invalid ISD-R AID %q", aidHex)
}
selectAID := append([]byte{byte(channel.channel), 0xA4, 0x04, 0x00, byte(len(aid))}, aid...)
_, selectSW, err := channel.transmit(ctx, selectAID, 0x00)
if err != nil || selectSW != 0x9000 {
channel.close(context.Background())
return nil, errNoEUICC
}
return channel, nil
}
// discoverEuiccAIDs detects eSTK multi-SE and alternate-ISD-R cards without
// changing any profile state. The vendor product applet and candidate ISD-R
// applications are selected only as read-only capability probes. Per
@@ -352,7 +394,23 @@ func isTransientEuiccCME(err error) bool {
// close releases the logical channel (MANAGE CHANNEL close).
func (channel *euiccChannel) close(ctx context.Context) {
closeAPDU := []byte{0x00, 0x70, 0x80, byte(channel.channel), 0x00}
_, _, _ = channel.manager.csim(ctx, channel.id, closeAPDU)
_, _, _ = channel.exchange(ctx, closeAPDU)
if channel.pcscSession != nil {
if channel.resetOnClose {
_ = channel.pcscSession.CloseWithReset()
} else {
_ = channel.pcscSession.Close()
}
channel.pcscSession = nil
}
}
func (channel *euiccChannel) exchange(ctx context.Context, apdu []byte) ([]byte, int, error) {
if channel.pcscSession != nil {
payload, sw, err := channel.pcscSession.Transmit(ctx, apdu)
return payload, int(sw), err
}
return channel.manager.csim(ctx, channel.id, apdu)
}
// transmit sends one APDU on the logical channel (CLA high nibble from insClass,
@@ -360,7 +418,7 @@ func (channel *euiccChannel) close(ctx context.Context) {
// and returns the assembled payload.
func (channel *euiccChannel) transmit(ctx context.Context, apdu []byte, insClass byte) ([]byte, int, error) {
apdu[0] = (apdu[0] & 0xF0) | byte(channel.channel)
payload, sw, err := channel.manager.csim(ctx, channel.id, apdu)
payload, sw, err := channel.exchange(ctx, apdu)
if err != nil {
return nil, 0, err
}
@@ -369,7 +427,7 @@ func (channel *euiccChannel) transmit(ctx context.Context, apdu []byte, insClass
for sw>>8 == 0x61 && guard < 24 {
guard++
getResponse := []byte{0x80 | byte(channel.channel), 0xC0, 0x00, 0x00, byte(sw & 0xFF)}
frag, nextSW, err := channel.manager.csim(ctx, channel.id, getResponse)
frag, nextSW, err := channel.exchange(ctx, getResponse)
if err != nil {
return nil, 0, err
}
@@ -610,6 +668,7 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid
// 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)
channel.resetOnClose = channel.pcscSession != nil
channel.close(closeContext)
cancelClose()
if err != nil {
@@ -780,9 +839,11 @@ 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)
_ = manager.rebootForProfileSwitch(resetContext, id)
cancelReset()
if !manager.isPCSCDevice(id) {
resetContext, cancelReset := context.WithTimeout(context.Background(), manager.longTimeout)
_ = manager.rebootForProfileSwitch(resetContext, id)
cancelReset()
}
manager.refreshAfterProfileSwitch(id)
}
@@ -795,6 +856,20 @@ func (manager *Manager) recoverAfterProfileSwitch(id string) {
// the next attempt. All errors are swallowed: this is best-effort self-healing
// and setResult already records the last failure for the UI.
func (manager *Manager) refreshAfterProfileSwitch(id string) {
if manager.isPCSCDevice(id) {
time.Sleep(750 * time.Millisecond)
for attempt := 0; attempt < 10; attempt++ {
ctx, cancel := context.WithTimeout(context.Background(), manager.commandTimeout*4)
_, _ = manager.Discover(ctx)
_, err := manager.Refresh(ctx, id)
cancel()
if err == nil {
return
}
time.Sleep(time.Second)
}
return
}
const (
settle = 8 * time.Second
interval = 4 * time.Second
@@ -819,6 +894,14 @@ func (manager *Manager) refreshAfterProfileSwitch(id string) {
}
}
func (manager *Manager) isPCSCDevice(id string) bool {
state, err := manager.lookup(id)
if err != nil {
return false
}
return manager.candidateFor(state).HardwareKind == pcsc.HardwareKind
}
// enableProfileResult extracts the EnableProfile result code (tag 80) from the
// ES10c response body. ok is false when no result code is present.
func enableProfileResult(payload []byte) (int, bool) {
@@ -888,25 +971,37 @@ func (manager *Manager) verifySwitchedICCID(ctx context.Context, id, expected st
var lastICCID string
var lastErr error
for attempt := 0; attempt < attempts; attempt++ {
for _, command := range []string{"AT+CCID", "AT+QCCID"} {
commandContext, cancel := context.WithTimeout(ctx, manager.commandTimeout)
response, err := manager.ExecuteAT(commandContext, id, command)
cancel()
if err != nil {
lastErr = err
continue
if manager.isPCSCDevice(id) {
snapshot, err := manager.Refresh(ctx, id)
if err == nil {
lastICCID = strings.TrimSpace(snapshot.ICCID)
if lastICCID == expected {
return nil
}
err = fmt.Errorf("reader still reports ICCID %s", lastICCID)
}
live := parseICCIDIdentifier(response, []string{"+CCID:", "+QCCID:"}, 18, 22)
if live == "" {
lastErr = errors.New("modem response contained no valid ICCID")
continue
lastErr = err
} else {
for _, command := range []string{"AT+CCID", "AT+QCCID"} {
commandContext, cancel := context.WithTimeout(ctx, manager.commandTimeout)
response, err := manager.ExecuteAT(commandContext, id, command)
cancel()
if err != nil {
lastErr = err
continue
}
live := parseICCIDIdentifier(response, []string{"+CCID:", "+QCCID:"}, 18, 22)
if live == "" {
lastErr = errors.New("modem response contained no valid ICCID")
continue
}
lastICCID = live
if live == expected {
return nil
}
lastErr = fmt.Errorf("modem still reports ICCID %s", live)
break
}
lastICCID = live
if live == expected {
return nil
}
lastErr = fmt.Errorf("modem still reports ICCID %s", live)
break
}
if attempt+1 < attempts {
select {
+9 -1
View File
@@ -52,7 +52,7 @@ func (channel *euiccChannel) storeDataChained(ctx context.Context, derRequest []
if err != nil {
return nil, err
}
if sw != 0x9000 {
if !es10StatusOK(sw) {
return nil, fmt.Errorf("%w: SW=%04X", errESIMSW, sw)
}
assembled = append(assembled, payload...)
@@ -62,6 +62,14 @@ func (channel *euiccChannel) storeDataChained(ctx context.Context, derRequest []
return assembled, nil
}
// 91xx is a successful UICC result with a proactive SIM Toolkit command
// pending. EnableProfile commonly returns it on direct PC/SC transports because
// the requested refresh is delivered to the terminal rather than consumed by
// modem firmware. Resetting the card after the operation applies that refresh.
func es10StatusOK(sw int) bool {
return sw == 0x9000 || sw>>8 == 0x91
}
// getEUICCChallenge (ES10c, BF2E) returns the eUICC challenge bytes.
func (channel *euiccChannel) getEUICCChallenge(ctx context.Context) ([]byte, error) {
payload, err := channel.es10(ctx, []byte{0xBF, 0x2E, 0x00})
+13
View File
@@ -155,3 +155,16 @@ func TestEuiccFreeNVRAM(t *testing.T) {
t.Fatalf("expected ok=false when extCardResource absent")
}
}
func TestES10StatusAcceptsProactiveRefresh(t *testing.T) {
for _, status := range []int{0x9000, 0x9100, 0x910B, 0x91FF} {
if !es10StatusOK(status) {
t.Fatalf("status %04X should be successful", status)
}
}
for _, status := range []int{0x6A82, 0x6985, 0x9200} {
if es10StatusOK(status) {
t.Fatalf("status %04X should fail", status)
}
}
}
+77 -4
View File
@@ -10,6 +10,7 @@ import (
"time"
"vocat/internal/modem"
"vocat/internal/pcsc"
)
type Options struct {
@@ -19,6 +20,7 @@ type Options struct {
LongTimeout time.Duration
SMSTimeout time.Duration
ScanTimeout time.Duration
CardReaders *pcsc.Service
}
type Manager struct {
@@ -34,6 +36,7 @@ type Manager struct {
longTimeout time.Duration
smsTimeout time.Duration
scanTimeout time.Duration
cardReaders *pcsc.Service
started bool
devices map[string]*managedDevice
ussdSessions map[string]ussdSession
@@ -59,6 +62,7 @@ type managedDevice struct {
discovered bool
preFlightMode *int
resetClientOnLock bool
simPIN string
}
func NewManager(options Options) (*Manager, error) {
@@ -82,6 +86,9 @@ func NewManager(options Options) (*Manager, error) {
// AT+COPS=? can take well over a minute while the modem sweeps every band.
options.ScanTimeout = 150 * time.Second
}
if options.CardReaders == nil {
options.CardReaders = pcsc.New()
}
return &Manager{
discoverer: options.Discoverer,
opener: options.Opener,
@@ -89,6 +96,7 @@ func NewManager(options Options) (*Manager, error) {
longTimeout: options.LongTimeout,
smsTimeout: options.SMSTimeout,
scanTimeout: options.ScanTimeout,
cardReaders: options.CardReaders,
devices: make(map[string]*managedDevice),
ussdSessions: make(map[string]ussdSession),
esimRecoveries: make(map[string]chan struct{}),
@@ -146,9 +154,23 @@ func (manager *Manager) Discover(ctx context.Context) ([]Device, error) {
if ctx == nil {
ctx = context.Background()
}
candidates, err := manager.discoverer.Discover(ctx)
if err != nil {
return nil, err
candidates, modemErr := manager.discoverer.Discover(ctx)
readers, readerErr := manager.cardReaders.Readers(ctx)
if readerErr == nil {
for _, reader := range readers {
candidates = append(candidates, modem.Candidate{
ID: pcsc.DeviceID(reader), HardwareKind: pcsc.HardwareKind,
ReaderName: reader.Name, USBPath: reader.USBPath,
VendorID: reader.VendorID, ProductID: reader.ProductID,
Manufacturer: reader.Manufacturer, Product: reader.Product,
})
}
}
if modemErr != nil && readerErr != nil && !errors.Is(readerErr, pcsc.ErrUnsupported) && !errors.Is(readerErr, pcsc.ErrUnavailable) {
return nil, errors.Join(modemErr, readerErr)
}
if modemErr != nil && len(candidates) == 0 {
return nil, modemErr
}
seen := make(map[string]struct{}, len(candidates))
@@ -360,6 +382,9 @@ func (manager *Manager) Refresh(ctx context.Context, id string) (Snapshot, error
return Snapshot{}, err
}
candidate := manager.candidateFor(state)
if candidate.HardwareKind == pcsc.HardwareKind {
return manager.refreshCardReader(ctx, id, state, candidate)
}
backend := manager.backendFor(state)
client, err := manager.clientLocked(ctx, state, candidate)
if err != nil {
@@ -375,11 +400,59 @@ func (manager *Manager) Refresh(ctx context.Context, id string) (Snapshot, error
return snapshot, err
}
func (manager *Manager) refreshCardReader(ctx context.Context, id string, state *managedDevice, candidate modem.Candidate) (Snapshot, error) {
result := Snapshot{
DeviceID: id, Port: candidate.ReaderName, Responsive: true,
Manufacturer: candidate.Manufacturer, Model: candidate.Product,
AccessTech: "Wi-Fi", RegistrationSource: "pcsc", OperatingMode: 4,
ModeKnown: true, FlightMode: true, RadioOff: true, UpdatedAt: time.Now().UTC(),
}
previousICCID := state.lastICCID
card, err := manager.cardReaders.Snapshot(ctx, pcsc.Selector{USBPath: candidate.USBPath, ReaderName: candidate.ReaderName}, state.simPIN)
if err != nil {
switch {
case errors.Is(err, pcsc.ErrNoCard):
result.SIMStatus = ""
err = nil
case errors.Is(err, pcsc.ErrPINRequired), errors.Is(err, pcsc.ErrPINTriesLow), errors.Is(err, pcsc.ErrPINRejected):
result.SIMStatus = "SIM PIN"
result.Warnings = []string{err.Error()}
err = nil
default:
manager.setResult(id, state, &result, err)
return result, err
}
} else {
result.SIMStatus = "READY"
result.SIMReady = true
result.ICCID = card.Identity.ICCID
result.IMSI = card.Identity.IMSI
result.SPN = card.Identity.SPN
result.SIMChanged = previousICCID != "" && !strings.EqualFold(previousICCID, result.ICCID)
state.lastICCID = result.ICCID
}
manager.setResult(id, state, &result, err)
return result, err
}
// SetSIMPin updates the in-memory PIN used for protected USIM files and AKA.
// It is deliberately never retained in runtime snapshots or logs.
func (manager *Manager) SetSIMPin(id, pin string) error {
manager.mu.Lock()
defer manager.mu.Unlock()
state := manager.devices[id]
if state == nil || !state.discovered {
return ErrNotFound
}
state.simPIN = strings.TrimSpace(pin)
return nil
}
// SetBackend selects which control plane supplies registration and data state.
// AT remains available in either mode for UICC, RF, SMS, voice and diagnostics.
func (manager *Manager) SetBackend(id, backend string) error {
backend = strings.ToLower(strings.TrimSpace(backend))
if backend != "at" && backend != "qmi" {
if backend != "at" && backend != "qmi" && backend != "pcsc" {
return fmt.Errorf("unsupported device backend %q", backend)
}
manager.mu.Lock()
+37
View File
@@ -6,8 +6,45 @@ import (
"testing"
"vocat/internal/modem"
"vocat/internal/pcsc"
)
type testPCSCBackend struct{ readers []pcsc.Reader }
func (backend testPCSCBackend) Readers(context.Context) ([]pcsc.Reader, error) {
return append([]pcsc.Reader(nil), backend.readers...), nil
}
func (testPCSCBackend) Open(context.Context, pcsc.Selector) (pcsc.Card, error) {
return nil, pcsc.ErrNoCard
}
func TestManagerDiscoversWiFiCallingOnlyReaderWithoutATPort(t *testing.T) {
manager, err := NewManager(Options{
Discoverer: staticDiscoverer{}, Opener: &staticOpener{},
CardReaders: pcsc.NewWithBackend(testPCSCBackend{readers: []pcsc.Reader{{
Name: "Alcor Link AK9563 00 00", USBPath: "1-3", Product: "AK9563",
}}}),
})
if err != nil {
t.Fatal(err)
}
if err := manager.Start(context.Background()); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = manager.Stop(context.Background()) })
items := manager.List()
if len(items) != 1 || items[0].Candidate.HardwareKind != pcsc.HardwareKind || items[0].Candidate.HasATPort() {
t.Fatalf("discovered readers = %#v", items)
}
snapshot, err := manager.Refresh(context.Background(), items[0].ID)
if err != nil {
t.Fatal(err)
}
if !snapshot.Responsive || snapshot.SIMReady || snapshot.SIMStatus != "" || !snapshot.FlightMode {
t.Fatalf("reader snapshot = %#v", snapshot)
}
}
func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) {
client := &transcriptClient{steps: []clientStep{
{
+1
View File
@@ -11,6 +11,7 @@ var (
ErrNotStarted = errors.New("device manager is not started")
ErrNotFound = errors.New("device not found")
ErrNoATPort = errors.New("device has no usable AT port")
ErrUnsupportedCapability = errors.New("device does not support this capability")
ErrSMSPromptUnsupported = errors.New("device AT client does not support SMS prompt mode")
ErrSMSInvalidRecipient = errors.New("invalid SMS recipient")
ErrSMSEmpty = errors.New("SMS text is empty")
+2
View File
@@ -44,6 +44,8 @@ func (p Port) OpenPath() string {
}
type Candidate struct {
HardwareKind string `json:"hardwareKind,omitempty"`
ReaderName string `json:"readerName,omitempty"`
ID string `json:"id"`
VendorID string `json:"vendorId"`
ProductID string `json:"productId"`
+272
View File
@@ -0,0 +1,272 @@
//go:build linux && (amd64 || arm64)
package pcsc
import (
"context"
"encoding/binary"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"github.com/ElMostafaIdrassi/goscard"
)
type nativeBackend struct {
initializeOnce sync.Once
initializeErr error
}
func newNativeBackend() Backend { return &nativeBackend{} }
func (backend *nativeBackend) initialize() error {
backend.initializeOnce.Do(func() {
if err := goscard.Initialize(goscard.NewDefaultLogger(goscard.LogLevelNone)); err != nil {
backend.initializeErr = fmt.Errorf("%w: pcsc-lite client library could not be loaded", ErrUnavailable)
}
})
return backend.initializeErr
}
func (backend *nativeBackend) Readers(ctx context.Context) ([]Reader, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if err := backend.initialize(); err != nil {
return nil, err
}
cardContext, _, err := goscard.NewContext(goscard.SCardScopeSystem, nil, nil)
if err != nil {
return nil, fmt.Errorf("%w: pcscd is not reachable", ErrUnavailable)
}
defer cardContext.Release()
names, _, err := cardContext.ListReaders(nil)
if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "no readers") {
return []Reader{}, nil
}
return nil, fmt.Errorf("pcsc: list readers: %w", err)
}
presentNames, atrs, _, _ := cardContext.ListReadersWithCardPresent(nil)
present := make(map[string]string, len(presentNames))
for index, name := range presentNames {
atr := ""
if index < len(atrs) {
atr = atrs[index]
}
present[name] = atr
}
readers := make([]Reader, 0, len(names))
for _, name := range names {
reader := Reader{Name: name}
reader.ATR, reader.CardPresent = present[name]
if path, ok := backend.readerUSBPath(cardContext, name); ok {
reader.USBPath = path
reader.VendorID = readSysfsText(path, "idVendor")
reader.ProductID = readSysfsText(path, "idProduct")
reader.Manufacturer = readSysfsText(path, "manufacturer")
reader.Product = readSysfsText(path, "product")
} else {
reader.USBPath = "pcsc:" + name
}
if reader.Product == "" {
reader.Product = strings.TrimSpace(strings.TrimSuffix(name, " 00 00"))
}
readers = append(readers, reader)
}
return readers, nil
}
func (backend *nativeBackend) readerUSBPath(cardContext goscard.Context, name string) (string, bool) {
card, _, err := cardContext.Connect(name, goscard.SCardShareDirect, goscard.SCardProtocolT0|goscard.SCardProtocolT1)
if err != nil {
return "", false
}
defer card.Disconnect(goscard.SCardLeaveCard)
attribute, _, err := card.GetAttrib(goscard.SCardAttrChannelID)
if err != nil || len(attribute) < 4 {
return "", false
}
channel := binary.LittleEndian.Uint32(attribute[:4])
if channel>>16 != 0x0020 {
return "", false
}
bus, device := int((channel>>8)&0xFF), int(channel&0xFF)
entries, err := os.ReadDir("/sys/bus/usb/devices")
if err != nil {
return "", false
}
for _, entry := range entries {
if !entry.IsDir() && entry.Type()&os.ModeSymlink == 0 {
continue
}
path := filepath.Join("/sys/bus/usb/devices", entry.Name())
entryBus, busErr := readSysfsInt(path, "busnum")
entryDevice, deviceErr := readSysfsInt(path, "devnum")
if busErr == nil && deviceErr == nil && entryBus == bus && entryDevice == device {
return entry.Name(), true
}
}
return "", false
}
func (backend *nativeBackend) Open(ctx context.Context, selector Selector) (Card, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
readers, err := backend.Readers(ctx)
if err != nil {
return nil, err
}
reader, ok := matchReader(readers, selector)
if !ok {
return nil, ErrReaderNotFound
}
if !reader.CardPresent {
return nil, ErrNoCard
}
cardContext, _, err := goscard.NewContext(goscard.SCardScopeSystem, nil, nil)
if err != nil {
return nil, fmt.Errorf("%w: create context", ErrUnavailable)
}
card, _, err := cardContext.Connect(reader.Name, goscard.SCardShareShared, goscard.SCardProtocolT0|goscard.SCardProtocolT1)
if err != nil {
cardContext.Release()
return nil, fmt.Errorf("pcsc: connect reader: %w", err)
}
if _, err := card.BeginTransaction(); err != nil {
card.Disconnect(goscard.SCardLeaveCard)
cardContext.Release()
return nil, fmt.Errorf("pcsc: begin card transaction: %w", err)
}
return &nativeCard{context: &cardContext, card: &card}, nil
}
type nativeCard struct {
context *goscard.Context
card *goscard.Card
closed bool
}
func (card *nativeCard) Transmit(ctx context.Context, command []byte) ([]byte, uint16, error) {
if card == nil || card.card == nil || card.closed {
return nil, 0, errors.New("pcsc: card session is closed")
}
if err := ctx.Err(); err != nil {
return nil, 0, err
}
return card.transmit(ctx, append([]byte(nil), command...), 0)
}
// TransmitRaw performs exactly one APDU exchange. Stateful eUICC callers need
// to observe 61xx themselves because GET RESPONSE must target their logical
// channel rather than the basic channel.
func (card *nativeCard) TransmitRaw(ctx context.Context, command []byte) ([]byte, uint16, error) {
if card == nil || card.card == nil || card.closed {
return nil, 0, errors.New("pcsc: card session is closed")
}
if err := ctx.Err(); err != nil {
return nil, 0, err
}
pci := goscard.SCardIoRequestT0
if card.card.ActiveProtocol() == goscard.SCardProtocolT1 {
pci = goscard.SCardIoRequestT1
}
response, _, err := card.card.Transmit(&pci, append([]byte(nil), command...), nil)
if err != nil {
return nil, 0, err
}
if len(response) < 2 {
return nil, 0, errors.New("pcsc: APDU response omitted its status word")
}
if err := ctx.Err(); err != nil {
return nil, 0, err
}
last := len(response) - 2
return append([]byte(nil), response[:last]...), uint16(response[last])<<8 | uint16(response[last+1]), nil
}
func (card *nativeCard) transmit(ctx context.Context, command []byte, depth int) ([]byte, uint16, error) {
if depth > 8 {
return nil, 0, errors.New("pcsc: too many APDU continuations")
}
pci := goscard.SCardIoRequestT0
if card.card.ActiveProtocol() == goscard.SCardProtocolT1 {
pci = goscard.SCardIoRequestT1
}
response, _, err := card.card.Transmit(&pci, command, nil)
if err != nil {
return nil, 0, err
}
if len(response) < 2 {
return nil, 0, errors.New("pcsc: APDU response omitted its status word")
}
data := append([]byte(nil), response[:len(response)-2]...)
sw1, sw2 := response[len(response)-2], response[len(response)-1]
if sw1 == 0x6C && len(command) >= 5 {
retry := append([]byte(nil), command...)
retry[len(retry)-1] = sw2
return card.transmit(ctx, retry, depth+1)
}
if sw1 == 0x61 || sw1 == 0x9F {
more, sw, err := card.transmit(ctx, []byte{0x00, 0xC0, 0x00, 0x00, sw2}, depth+1)
if err != nil {
return nil, 0, err
}
return append(data, more...), sw, nil
}
if err := ctx.Err(); err != nil {
return nil, 0, err
}
return data, uint16(sw1)<<8 | uint16(sw2), nil
}
func (card *nativeCard) Close() error {
return card.close(goscard.SCardLeaveCard)
}
func (card *nativeCard) CloseWithReset() error {
return card.close(goscard.SCardResetCard)
}
func (card *nativeCard) close(disposition goscard.SCardDisposition) error {
if card == nil || card.closed {
return nil
}
card.closed = true
var result []error
if card.card != nil {
if _, err := card.card.EndTransaction(disposition); err != nil {
result = append(result, err)
}
if _, err := card.card.Disconnect(disposition); err != nil {
result = append(result, err)
}
}
if card.context != nil {
if _, err := card.context.Release(); err != nil {
result = append(result, err)
}
}
return errors.Join(result...)
}
func readSysfsText(usbPath, name string) string {
value, err := os.ReadFile(filepath.Join("/sys/bus/usb/devices", usbPath, name))
if err != nil {
return ""
}
return strings.TrimSpace(string(value))
}
func readSysfsInt(path, name string) (int, error) {
value, err := os.ReadFile(filepath.Join(path, name))
if err != nil {
return 0, err
}
return strconv.Atoi(strings.TrimSpace(string(value)))
}
+17
View File
@@ -0,0 +1,17 @@
//go:build !linux || (!amd64 && !arm64)
package pcsc
import "context"
type unsupportedBackend struct{}
func newNativeBackend() Backend { return unsupportedBackend{} }
func (unsupportedBackend) Readers(context.Context) ([]Reader, error) {
return nil, ErrUnsupported
}
func (unsupportedBackend) Open(context.Context, Selector) (Card, error) {
return nil, ErrUnsupported
}
+610
View File
@@ -0,0 +1,610 @@
package pcsc
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strings"
"sync"
)
const usimAIDPrefix = "A0000000871002"
type Service struct {
mu sync.Mutex
backend Backend
}
// Session is an exclusive connection to one smart card. It is used by eUICC
// operations which must keep the same PC/SC transaction and logical channel
// alive across a sequence of APDUs.
type Session struct {
service *Service
card Card
closed bool
}
func New() *Service {
return &Service{backend: newNativeBackend()}
}
func NewWithBackend(backend Backend) *Service {
return &Service{backend: backend}
}
func DeviceID(reader Reader) string {
identity := strings.TrimSpace(reader.USBPath)
if identity == "" {
identity = strings.TrimSpace(reader.Name)
}
sum := sha256.Sum256([]byte(identity))
return "reader-" + hex.EncodeToString(sum[:8])
}
func (service *Service) Readers(ctx context.Context) ([]Reader, error) {
if service == nil || service.backend == nil {
return nil, ErrUnavailable
}
service.mu.Lock()
defer service.mu.Unlock()
return service.backend.Readers(ctx)
}
// OpenSession opens one card and holds the service lock until Close. Callers
// must close the returned session; this prevents AKA/identity reads from
// interleaving with a stateful ES10 transaction.
func (service *Service) OpenSession(ctx context.Context, selector Selector) (*Session, error) {
if service == nil || service.backend == nil {
return nil, ErrUnavailable
}
if err := selector.validate(); err != nil {
return nil, err
}
service.mu.Lock()
card, err := service.backend.Open(ctx, selector)
if err != nil {
service.mu.Unlock()
return nil, err
}
return &Session{service: service, card: card}, nil
}
// Transmit sends one raw APDU. Unlike the ordinary SIM helpers, it leaves
// 61xx continuation handling to the eUICC logical-channel implementation so
// GET RESPONSE uses the correct channel CLA.
func (session *Session) Transmit(ctx context.Context, command []byte) ([]byte, uint16, error) {
if session == nil || session.card == nil || session.closed {
return nil, 0, errors.New("pcsc: card session is closed")
}
if raw, ok := session.card.(interface {
TransmitRaw(context.Context, []byte) ([]byte, uint16, error)
}); ok {
return raw.TransmitRaw(ctx, command)
}
return session.card.Transmit(ctx, command)
}
func (session *Session) Close() error {
return session.close(false)
}
// CloseWithReset resets the card while releasing the PC/SC connection. eUICC
// EnableProfile requires this refresh boundary before the newly enabled USIM
// application and ICCID become visible to subsequent callers.
func (session *Session) CloseWithReset() error {
return session.close(true)
}
func (session *Session) close(reset bool) error {
if session == nil || session.closed {
return nil
}
session.closed = true
var err error
if resetter, ok := session.card.(interface{ CloseWithReset() error }); reset && ok {
err = resetter.CloseWithReset()
} else {
err = session.card.Close()
}
session.service.mu.Unlock()
return err
}
func (service *Service) Snapshot(ctx context.Context, selector Selector, pin string) (Snapshot, error) {
readers, err := service.Readers(ctx)
if err != nil {
return Snapshot{}, err
}
reader, ok := matchReader(readers, selector)
if !ok {
return Snapshot{}, ErrReaderNotFound
}
result := Snapshot{Reader: reader}
if !reader.CardPresent {
return result, ErrNoCard
}
identity, err := service.ReadIdentity(ctx, selector, pin)
result.Identity = identity
return result, err
}
func (service *Service) ReadIdentity(ctx context.Context, selector Selector, pin string) (Identity, error) {
if service == nil || service.backend == nil {
return Identity{}, ErrUnavailable
}
if err := selector.validate(); err != nil {
return Identity{}, err
}
service.mu.Lock()
defer service.mu.Unlock()
card, err := service.backend.Open(ctx, selector)
if err != nil {
return Identity{}, err
}
defer card.Close()
return readIdentity(ctx, card, pin)
}
func (service *Service) CheckReady(
ctx context.Context,
selector Selector,
expectedICCID string,
pin string,
) (string, error) {
if service == nil || service.backend == nil {
return "", ErrUnavailable
}
service.mu.Lock()
defer service.mu.Unlock()
card, err := service.backend.Open(ctx, selector)
if err != nil {
return "", err
}
defer card.Close()
iccid, err := readICCID(ctx, card)
if err != nil {
return "", err
}
if expected := strings.TrimSpace(expectedICCID); expected != "" && !strings.EqualFold(expected, iccid) {
return "", ErrCardChanged
}
aid, err := selectUSIM(ctx, card)
if err != nil {
return "", err
}
if err := verifyPIN(ctx, card, pin); err != nil {
return "", err
}
return strings.ToUpper(hex.EncodeToString(aid)), nil
}
func (service *Service) Authenticate(
ctx context.Context,
selector Selector,
expectedICCID string,
pin string,
challenge AKAChallenge,
) (AKAResult, error) {
if service == nil || service.backend == nil {
return AKAResult{}, ErrUnavailable
}
service.mu.Lock()
defer service.mu.Unlock()
card, err := service.backend.Open(ctx, selector)
if err != nil {
return AKAResult{}, err
}
defer card.Close()
iccid, err := readICCID(ctx, card)
if err != nil {
return AKAResult{}, err
}
if expected := strings.TrimSpace(expectedICCID); expected != "" && !strings.EqualFold(expected, iccid) {
return AKAResult{}, ErrCardChanged
}
if _, err := selectUSIM(ctx, card); err != nil {
return AKAResult{}, err
}
if err := verifyPIN(ctx, card, pin); err != nil {
return AKAResult{}, err
}
apdu := make([]byte, 0, 40)
apdu = append(apdu, 0x00, 0x88, 0x00, 0x81, 0x22, 0x10)
apdu = append(apdu, challenge.RAND[:]...)
apdu = append(apdu, 0x10)
apdu = append(apdu, challenge.AUTN[:]...)
apdu = append(apdu, 0x00)
data, sw, err := card.Transmit(ctx, apdu)
if err != nil {
return AKAResult{}, errors.New("pcsc: USIM authentication transport failed")
}
if sw == 0x9862 {
return AKAResult{}, ErrAKARejected
}
if sw != 0x9000 {
return AKAResult{}, fmt.Errorf("pcsc: USIM authentication failed with status %04X", sw)
}
return parseAKAResponse(data)
}
func matchReader(readers []Reader, selector Selector) (Reader, bool) {
path := strings.TrimSpace(selector.USBPath)
name := strings.TrimSpace(selector.ReaderName)
for _, reader := range readers {
if path != "" && reader.USBPath == path {
return reader, true
}
}
for _, reader := range readers {
if name != "" && reader.Name == name {
return reader, true
}
}
return Reader{}, false
}
func readIdentity(ctx context.Context, card Card, pin string) (Identity, error) {
identity := Identity{PINTries: -1}
iccid, err := readICCID(ctx, card)
if err != nil {
return identity, err
}
identity.ICCID = iccid
aid, err := selectUSIM(ctx, card)
if err != nil {
return identity, err
}
identity.USIMAID = append([]byte(nil), aid...)
if err := verifyPIN(ctx, card, pin); err != nil {
identity.PINRequired = errors.Is(err, ErrPINRequired) || errors.Is(err, ErrPINTriesLow)
var pinErr *PINError
if errors.As(err, &pinErr) {
identity.PINTries = pinErr.Tries
}
return identity, err
}
if err := selectFile(ctx, card, []byte{0x6F, 0x07}); err != nil {
return identity, fmt.Errorf("pcsc: select EF_IMSI: %w", err)
}
imsiData, err := readBinary(ctx, card, 9)
if err != nil {
return identity, fmt.Errorf("pcsc: read EF_IMSI: %w", err)
}
identity.IMSI, err = decodeIMSI(imsiData)
if err != nil {
return identity, err
}
if _, selectErr := selectApplication(ctx, card, aid); selectErr == nil {
if selectErr = selectFile(ctx, card, []byte{0x6F, 0xAD}); selectErr == nil {
if data, readErr := readBinary(ctx, card, 4); readErr == nil && len(data) >= 4 {
length := int(data[3] & 0x0f)
if length == 2 || length == 3 {
identity.MNCLength = length
}
}
}
}
if _, selectErr := selectApplication(ctx, card, aid); selectErr == nil {
identity.SPN = readSPN(ctx, card)
}
if _, selectErr := selectApplication(ctx, card, aid); selectErr == nil {
identity.SMSC = readSMSC(ctx, card)
}
return identity, nil
}
func readICCID(ctx context.Context, card Card) (string, error) {
if err := selectMF(ctx, card); err != nil {
return "", err
}
if err := selectFile(ctx, card, []byte{0x2F, 0xE2}); err != nil {
return "", fmt.Errorf("pcsc: select EF_ICCID: %w", err)
}
data, err := readBinary(ctx, card, 10)
if err != nil {
return "", fmt.Errorf("pcsc: read EF_ICCID: %w", err)
}
value := decodeSwappedBCD(data, false)
if len(value) < 18 || len(value) > 22 {
return "", errors.New("pcsc: card returned an invalid ICCID")
}
return value, nil
}
func selectUSIM(ctx context.Context, card Card) ([]byte, error) {
if err := selectMF(ctx, card); err != nil {
return nil, err
}
if err := selectFile(ctx, card, []byte{0x2F, 0x00}); err != nil {
return nil, fmt.Errorf("pcsc: select EF_DIR: %w", err)
}
var usimAID []byte
for record := 1; record <= 32; record++ {
data, sw, err := card.Transmit(ctx, []byte{0x00, 0xB2, byte(record), 0x04, 0x00})
if err != nil {
return nil, err
}
if sw == 0x6A83 || sw == 0x9402 {
break
}
if sw != 0x9000 {
continue
}
aid := findTLV(data, 0x4F)
if len(aid) == 0 {
continue
}
if strings.HasPrefix(strings.ToUpper(hex.EncodeToString(aid)), usimAIDPrefix) {
usimAID = append([]byte(nil), aid...)
break
}
}
if len(usimAID) == 0 {
return nil, ErrUSIMUnavailable
}
if _, err := selectApplication(ctx, card, usimAID); err != nil {
return nil, err
}
return usimAID, nil
}
func selectMF(ctx context.Context, card Card) error {
_, sw, err := card.Transmit(ctx, []byte{0x00, 0xA4, 0x00, 0x04, 0x02, 0x3F, 0x00, 0x00})
return requireStatus("select MF", sw, err)
}
func selectFile(ctx context.Context, card Card, fileID []byte) error {
if len(fileID) != 2 {
return errors.New("pcsc: invalid file identifier")
}
apdu := []byte{0x00, 0xA4, 0x00, 0x04, 0x02, fileID[0], fileID[1], 0x00}
_, sw, err := card.Transmit(ctx, apdu)
return requireStatus("select file", sw, err)
}
func selectApplication(ctx context.Context, card Card, aid []byte) ([]byte, error) {
if len(aid) == 0 || len(aid) > 32 {
return nil, errors.New("pcsc: invalid USIM AID")
}
apdu := []byte{0x00, 0xA4, 0x04, 0x04, byte(len(aid))}
apdu = append(apdu, aid...)
apdu = append(apdu, 0x00)
data, sw, err := card.Transmit(ctx, apdu)
if err := requireStatus("select USIM application", sw, err); err != nil {
return nil, err
}
return data, nil
}
func readBinary(ctx context.Context, card Card, length int) ([]byte, error) {
if length <= 0 || length > 256 {
return nil, errors.New("pcsc: invalid binary read length")
}
le := byte(length)
if length == 256 {
le = 0
}
data, sw, err := card.Transmit(ctx, []byte{0x00, 0xB0, 0x00, 0x00, le})
if err := requireStatus("read binary", sw, err); err != nil {
return nil, err
}
return data, nil
}
func verifyPIN(ctx context.Context, card Card, pin string) error {
pin = strings.TrimSpace(pin)
if pin == "" {
return nil
}
if len(pin) < 4 || len(pin) > 8 || !decimalDigits(pin) {
return errors.New("pcsc: SIM PIN must contain 4 to 8 digits")
}
_, sw, err := card.Transmit(ctx, []byte{0x00, 0x20, 0x00, 0x01, 0x00})
if err != nil {
return errors.New("pcsc: SIM PIN status check failed")
}
if sw == 0x9000 {
return nil
}
tries := -1
if sw&0xFFF0 == 0x63C0 {
tries = int(sw & 0x000F)
if tries <= 2 {
return &PINError{Kind: ErrPINTriesLow, Tries: tries}
}
}
body := bytes.Repeat([]byte{0xFF}, 8)
copy(body, []byte(pin))
apdu := append([]byte{0x00, 0x20, 0x00, 0x01, 0x08}, body...)
_, sw, err = card.Transmit(ctx, apdu)
if err != nil {
return errors.New("pcsc: SIM PIN verification transport failed")
}
if sw == 0x9000 {
return nil
}
if sw&0xFFF0 == 0x63C0 {
return &PINError{Kind: ErrPINRejected, Tries: int(sw & 0x000F)}
}
return ErrPINRejected
}
func requireStatus(operation string, sw uint16, err error) error {
if err != nil {
return fmt.Errorf("pcsc: %s transport failed", operation)
}
if sw == 0x9000 {
return nil
}
if sw == 0x6982 || sw == 0x9804 {
return &PINError{Kind: ErrPINRequired, Tries: -1}
}
return fmt.Errorf("pcsc: %s failed with status %04X", operation, sw)
}
func decodeSwappedBCD(value []byte, dropFirstNibble bool) string {
var result strings.Builder
for _, octet := range value {
for _, nibble := range []byte{octet & 0x0F, octet >> 4} {
if dropFirstNibble {
dropFirstNibble = false
continue
}
if nibble == 0x0F {
return result.String()
}
if nibble > 9 {
return ""
}
result.WriteByte('0' + nibble)
}
}
return result.String()
}
func decodeIMSI(data []byte) (string, error) {
if len(data) < 2 {
return "", errors.New("pcsc: EF_IMSI is too short")
}
length := int(data[0])
if length <= 0 || length > len(data)-1 {
return "", errors.New("pcsc: EF_IMSI has an invalid length")
}
value := decodeSwappedBCD(data[1:1+length], true)
if len(value) < 10 || len(value) > 18 || !decimalDigits(value) {
return "", errors.New("pcsc: card returned an invalid IMSI")
}
return value, nil
}
func decimalDigits(value string) bool {
if value == "" {
return false
}
for _, character := range value {
if character < '0' || character > '9' {
return false
}
}
return true
}
func findTLV(data []byte, wanted byte) []byte {
for len(data) >= 2 {
tag := data[0]
data = data[1:]
length, consumed, ok := decodeTLVLength(data)
if !ok || consumed+length > len(data) {
return nil
}
value := data[consumed : consumed+length]
if tag == wanted {
return append([]byte(nil), value...)
}
if tag&0x20 != 0 {
if nested := findTLV(value, wanted); len(nested) > 0 {
return nested
}
}
data = data[consumed+length:]
}
return nil
}
func decodeTLVLength(data []byte) (length, consumed int, ok bool) {
if len(data) == 0 {
return 0, 0, false
}
if data[0]&0x80 == 0 {
return int(data[0]), 1, true
}
count := int(data[0] & 0x7F)
if count < 1 || count > 2 || len(data) < 1+count {
return 0, 0, false
}
length = 0
for _, octet := range data[1 : 1+count] {
length = length<<8 | int(octet)
}
return length, 1 + count, true
}
func parseAKAResponse(data []byte) (AKAResult, error) {
if len(data) < 2 {
return AKAResult{}, errors.New("pcsc: USIM returned a short AKA response")
}
switch data[0] {
case 0xDB:
res, rest, ok := takeLV(data[1:])
if !ok || len(res) < 4 || len(res) > 16 {
return AKAResult{}, errors.New("pcsc: USIM returned an invalid AKA RES")
}
ck, rest, ok := takeLV(rest)
if !ok || len(ck) != 16 {
return AKAResult{}, errors.New("pcsc: USIM returned an invalid AKA CK")
}
ik, rest, ok := takeLV(rest)
if !ok || len(ik) != 16 {
return AKAResult{}, errors.New("pcsc: USIM returned an invalid AKA IK")
}
if len(rest) > 0 {
kc, tail, valid := takeLV(rest)
if !valid || len(kc) != 8 || len(tail) != 0 {
return AKAResult{}, errors.New("pcsc: USIM returned invalid trailing AKA material")
}
}
return AKAResult{RES: append([]byte(nil), res...), CK: append([]byte(nil), ck...), IK: append([]byte(nil), ik...)}, nil
case 0xDC:
auts, tail, ok := takeLV(data[1:])
if !ok || len(auts) != 14 || len(tail) != 0 {
return AKAResult{}, errors.New("pcsc: USIM returned invalid AKA synchronization evidence")
}
return AKAResult{AUTS: append([]byte(nil), auts...), SynchronizationFailure: true}, nil
default:
return AKAResult{}, errors.New("pcsc: USIM returned an unsupported AKA response")
}
}
func takeLV(data []byte) (value, rest []byte, ok bool) {
if len(data) == 0 || int(data[0]) > len(data)-1 {
return nil, data, false
}
length := int(data[0])
return data[1 : 1+length], data[1+length:], true
}
func readSPN(ctx context.Context, card Card) string {
if err := selectFile(ctx, card, []byte{0x6F, 0x46}); err != nil {
return ""
}
data, err := readBinary(ctx, card, 17)
if err != nil || len(data) < 2 {
return ""
}
return strings.TrimSpace(strings.TrimRight(string(data[1:]), "\x00\xFF"))
}
func readSMSC(ctx context.Context, card Card) string {
if err := selectFile(ctx, card, []byte{0x6F, 0x42}); err != nil {
return ""
}
data, sw, err := card.Transmit(ctx, []byte{0x00, 0xB2, 0x01, 0x04, 0x00})
if err != nil || sw != 0x9000 || len(data) < 15 {
return ""
}
sca := data[len(data)-15 : len(data)-3]
if len(sca) < 2 || sca[0] < 2 || int(sca[0]) > len(sca)-1 {
return ""
}
digits := decodeSwappedBCD(sca[2:1+int(sca[0])], false)
if !decimalDigits(digits) {
return ""
}
if sca[1]&0x70 == 0x10 {
return "+" + digits
}
return digits
}
+84
View File
@@ -0,0 +1,84 @@
package pcsc
import (
"bytes"
"context"
"errors"
"testing"
)
type scriptedReply struct {
data []byte
sw uint16
}
type scriptedCard struct {
replies []scriptedReply
calls [][]byte
}
func (card *scriptedCard) Transmit(_ context.Context, command []byte) ([]byte, uint16, error) {
card.calls = append(card.calls, append([]byte(nil), command...))
if len(card.replies) == 0 {
return nil, 0, errors.New("unexpected APDU")
}
reply := card.replies[0]
card.replies = card.replies[1:]
return append([]byte(nil), reply.data...), reply.sw, nil
}
func (*scriptedCard) Close() error { return nil }
func TestDecodeIdentifiers(t *testing.T) {
if got := decodeSwappedBCD([]byte{0x98, 0x10, 0x32, 0x54, 0xF6}, false); got != "890123456" {
t.Fatalf("ICCID BCD = %q", got)
}
imsi, err := decodeIMSI([]byte{0x08, 0x19, 0x32, 0x54, 0x76, 0x98, 0x10, 0x32, 0x54})
if err != nil {
t.Fatal(err)
}
if imsi != "123456789012345" {
t.Fatalf("IMSI = %q", imsi)
}
}
func TestVerifyPINRefusesLowAttemptCount(t *testing.T) {
card := &scriptedCard{replies: []scriptedReply{{sw: 0x63C2}}}
err := verifyPIN(context.Background(), card, "1234")
if !errors.Is(err, ErrPINTriesLow) {
t.Fatalf("error = %v", err)
}
if len(card.calls) != 1 {
t.Fatalf("APDU calls = %d, PIN must not be submitted", len(card.calls))
}
}
func TestParseAKAResponse(t *testing.T) {
data := []byte{0xDB, 0x08, 1, 2, 3, 4, 5, 6, 7, 8, 0x10}
data = append(data, bytes.Repeat([]byte{0xAA}, 16)...)
data = append(data, 0x10)
data = append(data, bytes.Repeat([]byte{0xBB}, 16)...)
result, err := parseAKAResponse(data)
if err != nil {
t.Fatal(err)
}
if len(result.RES) != 8 || len(result.CK) != 16 || len(result.IK) != 16 || result.SynchronizationFailure {
t.Fatalf("unexpected AKA result: %#v", result)
}
syncResult, err := parseAKAResponse(append([]byte{0xDC, 0x0E}, bytes.Repeat([]byte{0xCC}, 14)...))
if err != nil {
t.Fatal(err)
}
if !syncResult.SynchronizationFailure || len(syncResult.AUTS) != 14 {
t.Fatalf("unexpected sync result: %#v", syncResult)
}
}
func TestDeviceIDUsesStableUSBPath(t *testing.T) {
a := DeviceID(Reader{Name: "reader 00 00", USBPath: "1-3"})
b := DeviceID(Reader{Name: "renamed reader", USBPath: "1-3"})
if a != b || a == "" {
t.Fatalf("device IDs = %q, %q", a, b)
}
}
+107
View File
@@ -0,0 +1,107 @@
package pcsc
import (
"context"
"errors"
"fmt"
"strings"
)
const HardwareKind = "pcsc"
var (
ErrUnsupported = errors.New("pcsc: platform is not supported")
ErrUnavailable = errors.New("pcsc: service is unavailable")
ErrReaderNotFound = errors.New("pcsc: reader not found")
ErrNoCard = errors.New("pcsc: no card is inserted")
ErrPINRequired = errors.New("pcsc: SIM PIN is required")
ErrPINTriesLow = errors.New("pcsc: refusing PIN verification because too few attempts remain")
ErrPINRejected = errors.New("pcsc: SIM PIN was rejected")
ErrUSIMUnavailable = errors.New("pcsc: no usable USIM application was found")
ErrCardChanged = errors.New("pcsc: card identity changed during authentication")
ErrAKARejected = errors.New("pcsc: USIM rejected the network authentication token")
)
type Reader struct {
Name string
USBPath string
VendorID string
ProductID string
Manufacturer string
Product string
CardPresent bool
ATR string
}
type Selector struct {
USBPath string
ReaderName string
}
func (selector Selector) validate() error {
if strings.TrimSpace(selector.USBPath) == "" && strings.TrimSpace(selector.ReaderName) == "" {
return errors.New("pcsc: reader selector is empty")
}
return nil
}
type Identity struct {
ICCID string
IMSI string
MNCLength int
USIMAID []byte
SMSC string
SPN string
PINRequired bool
PINTries int
}
type Snapshot struct {
Reader Reader
Identity Identity
}
type AKAChallenge struct {
RAND [16]byte
AUTN [16]byte
}
type AKAResult struct {
RES []byte
CK []byte
IK []byte
AUTS []byte
SynchronizationFailure bool
}
type PINError struct {
Kind error
Tries int
}
func (err *PINError) Error() string {
if err == nil {
return "pcsc: SIM PIN error"
}
if err.Tries >= 0 {
return fmt.Sprintf("%v (%d attempts remain)", err.Kind, err.Tries)
}
return err.Kind.Error()
}
func (err *PINError) Unwrap() error {
if err == nil {
return nil
}
return err.Kind
}
type Card interface {
Transmit(context.Context, []byte) ([]byte, uint16, error)
Close() error
}
type Backend interface {
Readers(context.Context) ([]Reader, error)
Open(context.Context, Selector) (Card, error)
}
+49 -1
View File
@@ -231,6 +231,9 @@ func (s *Server) ensureAutomaticTaskProfile(ctx context.Context, task store.Auto
if err != nil {
return store.Device{}, device.Device{}, "", fmt.Errorf("read device: %w", err)
}
if err := validateAutomaticTaskDeviceCapabilities(config, task.TaskType, task.Environment); err != nil {
return store.Device{}, device.Device{}, "", err
}
entry, physicalID, present := s.physicalForConfig(config)
if !present || entry.Snapshot == nil {
return store.Device{}, device.Device{}, "", errors.New("configured device is offline")
@@ -511,6 +514,25 @@ func (s *Server) restoreAutomaticTaskEnvironment(physicalID string, snapshot aut
if err := s.store.UpsertDevice(cleanupContext, config); err != nil {
restoreErrors = append(restoreErrors, fmt.Errorf("persist device policy: %w", err))
}
if config.DeviceType == store.DeviceTypeUSBSIMReader {
if s.vowifi == nil {
return errors.Join(append(restoreErrors, errors.New("VoWiFi runtime is unavailable"))...)
}
state, stateErr := s.vowifi.State(config.ID)
if policy.VoWiFiEnabled {
if stateErr == nil && state.Enabled {
_, stateErr = s.vowifi.RequestReconnect(config.ID)
} else {
_, stateErr = s.vowifi.RequestEnabled(config.ID, true)
}
} else if stateErr == nil && (state.Enabled || state.Active) {
_, stateErr = s.vowifi.RequestEnabled(config.ID, false)
}
if stateErr != nil {
restoreErrors = append(restoreErrors, fmt.Errorf("restore reader VoWiFi: %w", stateErr))
}
return errors.Join(restoreErrors...)
}
if policy.VoWiFiEnabled {
if _, err := s.devices.SetNetwork(cleanupContext, physicalID, s.cardNetworkRequest(cleanupContext, physicalID, config, policy, false)); err != nil {
@@ -710,6 +732,15 @@ func (s *Server) handleAutomaticTaskRunNow(w http.ResponseWriter, r *http.Reques
s.writeStoreError(w, err)
return
}
config, err := s.store.Device(r.Context(), task.DeviceID)
if err != nil {
s.writeStoreError(w, err)
return
}
if err := validateAutomaticTaskDeviceCapabilities(config, task.TaskType, task.Environment); err != nil {
writeError(w, http.StatusConflict, "wifi_calling_only_device", err.Error())
return
}
run, err := s.store.QueueAutomaticTaskNow(r.Context(), task)
if err != nil {
s.writeStoreError(w, err)
@@ -745,7 +776,8 @@ func (s *Server) decodeAutomaticTask(r *http.Request, id int64) (store.Automatic
if request.Name == "" || request.DeviceID == "" || request.ProfileICCID == "" {
return store.AutomaticTask{}, errors.New("name, device, and eSIM profile are required")
}
if _, err := s.store.Device(r.Context(), request.DeviceID); err != nil {
selectedDevice, err := s.store.Device(r.Context(), request.DeviceID)
if err != nil {
return store.AutomaticTask{}, errors.New("selected device does not exist")
}
if request.Environment != "vowifi" && request.Environment != "cellular" {
@@ -757,6 +789,9 @@ func (s *Server) decodeAutomaticTask(r *http.Request, id int64) (store.Automatic
if request.TaskType == "public_ip" && request.Environment != "cellular" {
return store.AutomaticTask{}, errors.New("public IP tasks must use cellular direct mode")
}
if err := validateAutomaticTaskDeviceCapabilities(selectedDevice, request.TaskType, request.Environment); err != nil {
return store.AutomaticTask{}, err
}
if request.IntervalDays < 1 || request.IntervalDays > 365 || request.RetryCount < 0 || request.RetryCount > 10 {
return store.AutomaticTask{}, errors.New("interval_days must be 1-365 and retry_count must be 0-10")
}
@@ -796,6 +831,19 @@ func (s *Server) decodeAutomaticTask(r *http.Request, id int64) (store.Automatic
return task, nil
}
func validateAutomaticTaskDeviceCapabilities(config store.Device, taskType, environment string) error {
if config.DeviceType != store.DeviceTypeUSBSIMReader {
return nil
}
if environment != "vowifi" {
return errors.New("USB SIM reader tasks must use the VoWiFi environment")
}
if taskType != "sms" && taskType != "call" {
return errors.New("USB SIM readers support only VoWiFi SMS and call tasks")
}
return nil
}
func nextAutomaticRun(date, clock string, intervalDays int, now time.Time) (time.Time, error) {
location := now.Location()
start, err := time.ParseInLocation("2006-01-02 15:04", strings.TrimSpace(date)+" "+strings.TrimSpace(clock), location)
+21
View File
@@ -3,6 +3,8 @@ package server
import (
"testing"
"time"
"vocat/internal/store"
)
func TestNextAutomaticRunUsesIntervalAndLocalClock(t *testing.T) {
@@ -18,6 +20,25 @@ func TestNextAutomaticRunUsesIntervalAndLocalClock(t *testing.T) {
}
}
func TestUSBSIMReaderAutomaticTasksRequireVoWiFi(t *testing.T) {
reader := store.Device{DeviceType: store.DeviceTypeUSBSIMReader}
for _, test := range []struct {
taskType string
environment string
wantError bool
}{
{taskType: "sms", environment: "vowifi"},
{taskType: "call", environment: "vowifi"},
{taskType: "sms", environment: "cellular", wantError: true},
{taskType: "public_ip", environment: "cellular", wantError: true},
} {
err := validateAutomaticTaskDeviceCapabilities(reader, test.taskType, test.environment)
if (err != nil) != test.wantError {
t.Errorf("type=%s environment=%s error=%v", test.taskType, test.environment, err)
}
}
}
func TestAutomaticSMSRetrySafetyPreventsDuplicateSubmission(t *testing.T) {
unsafe := []byte(`{"data":{"parts_attempted":1,"parts_accepted":1,"retry_safe":false}}`)
if automaticSMSRetrySafe(unsafe) {
+67 -3
View File
@@ -66,6 +66,7 @@ type deviceConfigPayload struct {
USBPath string `json:"usb_path"`
AudioDevice string `json:"audio_device"`
ModemIMEI string `json:"modem_imei"`
SIMPIN string `json:"sim_pin"`
APN string `json:"apn"`
ProxyPort int `json:"proxy_port"`
BaudRate int `json:"baud_rate"`
@@ -97,6 +98,7 @@ func (payload deviceConfigPayload) toStoreDevice() store.Device {
USBPath: strings.TrimSpace(payload.USBPath),
AudioDevice: strings.TrimSpace(payload.AudioDevice),
ModemIMEI: strings.TrimSpace(payload.ModemIMEI),
SIMPIN: strings.TrimSpace(payload.SIMPIN),
APN: strings.TrimSpace(payload.APN),
ProxyPort: payload.ProxyPort,
BaudRate: payload.BaudRate,
@@ -246,6 +248,12 @@ func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) bool {
config.NetworkEnabled = false
}
fillConfigFromPhysical(&config, *selected)
if pinSetter, ok := s.devices.(interface{ SetSIMPin(string, string) error }); ok {
if err := pinSetter.SetSIMPin(selected.ID, config.SIMPIN); err != nil {
s.writeDeviceError(w, err)
return true
}
}
if selector, ok := s.devices.(interface{ SetBackend(string, string) error }); ok {
if err := selector.SetBackend(selected.ID, config.DeviceBackend); err != nil {
s.writeDeviceError(w, err)
@@ -363,6 +371,8 @@ func (s *Server) handleDiscoveredDevices(w http.ResponseWriter, r *http.Request)
}
}
result = append(result, map[string]any{
"hardware_kind": candidate.HardwareKind,
"reader_name": candidate.ReaderName,
"discovery_key": entry.ID,
"control_path": controlPath,
"net_interface": candidate.NetworkInterface,
@@ -374,10 +384,10 @@ func (s *Server) handleDiscoveredDevices(w http.ResponseWriter, r *http.Request)
"at_port": candidate.ATPort.OpenPath(),
"imei": snapshotString(entry.Snapshot, func(snapshot *device.Snapshot) string { return snapshot.IMEI }),
"mode": backendMode(candidate),
"network_capable": candidate.NetworkInterface != "" || candidate.QMIControl != "",
"network_capable": candidate.HardwareKind != "pcsc" && (candidate.NetworkInterface != "" || candidate.QMIControl != ""),
"configured": configuredID != "",
"configured_id": configuredID,
"degraded": !candidate.HasATPort(),
"degraded": candidate.HardwareKind != "pcsc" && !candidate.HasATPort(),
})
}
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"devices": result}})
@@ -459,7 +469,16 @@ func (s *Server) handleDevicePath(
if next.Name == id && strings.TrimSpace(payload.Name) == "" {
next.Name = config.Name
}
if next.SIMPIN == "" || next.SIMPIN == store.SecretMask {
next.SIMPIN = config.SIMPIN
}
if _, physicalID, present := s.physicalForConfig(next); present {
if pinSetter, ok := s.devices.(interface{ SetSIMPin(string, string) error }); ok {
if err := pinSetter.SetSIMPin(physicalID, next.SIMPIN); err != nil {
s.writeDeviceError(w, err)
return true
}
}
if selector, ok := s.devices.(interface{ SetBackend(string, string) error }); ok {
if err := selector.SetBackend(physicalID, next.DeviceBackend); err != nil {
s.writeDeviceError(w, err)
@@ -482,6 +501,16 @@ func (s *Server) handleDevicePath(
}
entry, physicalID, physicalPresent := s.physicalForConfig(config)
if config.DeviceType == store.DeviceTypeUSBSIMReader && len(tail) > 0 {
operation := strings.Join(tail, "/")
unsupported := tail[0] == "network" || tail[0] == "operator_selection" ||
operation == "actions/at" || operation == "actions/ussd" || operation == "actions/ussd/continue" ||
operation == "actions/ussd/cancel" || operation == "actions/reboot" || operation == "usbnet-mode"
if unsupported {
writeError(w, http.StatusConflict, "wifi_calling_only_device", "USB SIM readers support WiFi Calling, IMS SMS and calls only")
return true
}
}
if len(tail) > 0 && tail[0] == "esim" {
return s.handleESIM(w, r, tail[1:], physicalID, physicalPresent, config.ID)
}
@@ -1803,6 +1832,10 @@ func deviceStatus(entry device.Device) map[string]any {
}
func storedDeviceConfig(config store.Device) map[string]any {
simPIN := ""
if strings.TrimSpace(config.SIMPIN) != "" {
simPIN = store.SecretMask
}
return map[string]any{
"id": config.ID,
"name": config.Name,
@@ -1813,6 +1846,7 @@ func storedDeviceConfig(config store.Device) map[string]any {
"usb_path": config.USBPath,
"audio_device": config.AudioDevice,
"modem_imei": config.ModemIMEI,
"sim_pin": simPIN,
"apn": config.APN,
"proxy_port": config.ProxyPort,
"baud_rate": config.BaudRate,
@@ -1832,6 +1866,17 @@ func storedDeviceConfig(config store.Device) map[string]any {
func fillConfigFromPhysical(config *store.Device, entry device.Device) {
candidate := entry.Candidate
if candidate.HardwareKind == "pcsc" {
config.DeviceType = store.DeviceTypeUSBSIMReader
config.ControlDevice = candidate.ReaderName
config.ATPort = ""
config.Interface = ""
config.DeviceBackend = "pcsc"
config.ESIMTransport = "pcsc"
config.NetworkEnabled = false
config.SMSEnabled = true
config.VoWiFiEnabled = true
}
if config.Interface == "" {
config.Interface = candidate.NetworkInterface
}
@@ -1914,13 +1959,29 @@ func modemSummary(snapshot *device.Snapshot, phone string, phoneSource string) m
"reg_status": snapshot.RegistrationStatus,
"reg_status_text": registrationText(snapshot),
"ps_attached": snapshot.PSAttached,
"sim_inserted": snapshot.SIMStatus != "",
"sim_inserted": snapshotHasSIM(snapshot),
"operating_mode": snapshot.OperatingMode,
"phone_number": phone,
"phone_number_source": phoneSource,
}
}
func snapshotHasSIM(snapshot *device.Snapshot) bool {
if snapshot == nil {
return false
}
if snapshot.SIMReady || strings.TrimSpace(snapshot.ICCID) != "" || strings.TrimSpace(snapshot.IMSI) != "" {
return true
}
switch strings.ToLower(strings.TrimSpace(snapshot.SIMStatus)) {
case "", "unknown", "not_inserted", "not inserted", "absent":
return false
default:
// PIN/PUK and other explicit UICC states prove that a card is present.
return true
}
}
func idleVoWiFiRuntime(id string, snapshot *device.Snapshot) map[string]any {
iccid := ""
imsi := ""
@@ -1970,6 +2031,9 @@ func deviceName(entry device.Device) string {
}
func backendMode(candidate modem.Candidate) string {
if candidate.HardwareKind == "pcsc" {
return "pcsc"
}
if candidate.QMIControl != "" {
return "qmi"
}
+21
View File
@@ -115,3 +115,24 @@ func TestConfiguredDeviceSummaryMarksIdleRuntimeAsNotInUse(t *testing.T) {
t.Fatalf("summary = %#v", got)
}
}
func TestSnapshotHasSIMDoesNotTreatUnknownStatusAsInserted(t *testing.T) {
for _, snapshot := range []*device.Snapshot{
{IMEI: "867123456789012"},
{IMEI: "867123456789012", SIMStatus: "unknown"},
{IMEI: "867123456789012", SIMStatus: "not_inserted"},
} {
if snapshotHasSIM(snapshot) {
t.Fatalf("snapshot was reported with a SIM: %#v", snapshot)
}
}
for _, snapshot := range []*device.Snapshot{
{SIMStatus: "pin_required"},
{ICCID: "89441000400128014257"},
{SIMReady: true},
} {
if !snapshotHasSIM(snapshot) {
t.Fatalf("snapshot was reported without a SIM: %#v", snapshot)
}
}
}
+21
View File
@@ -1,11 +1,13 @@
package server
import (
"context"
"errors"
"net/http"
"strings"
"vocat/internal/exportproxy"
"vocat/internal/store"
)
func (s *Server) routeExportProxyAPI(w http.ResponseWriter, r *http.Request, cleanPath string) bool {
@@ -33,6 +35,9 @@ func (s *Server) routeExportProxyAPI(w http.ResponseWriter, r *http.Request, cle
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return true
}
if s.rejectUnsupportedExportProxyDevice(w, r.Context(), config.DeviceID) {
return true
}
created, err := s.exportProxy.Create(r.Context(), config)
if err != nil {
s.writeExportProxyError(w, err)
@@ -71,6 +76,9 @@ func (s *Server) routeExportProxyAPI(w http.ResponseWriter, r *http.Request, cle
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return true
}
if s.rejectUnsupportedExportProxyDevice(w, r.Context(), config.DeviceID) {
return true
}
updated, err := s.exportProxy.Update(r.Context(), id, config)
if err != nil {
s.writeExportProxyError(w, err)
@@ -90,6 +98,19 @@ func (s *Server) routeExportProxyAPI(w http.ResponseWriter, r *http.Request, cle
return true
}
func (s *Server) rejectUnsupportedExportProxyDevice(w http.ResponseWriter, ctx context.Context, deviceID string) bool {
config, err := s.store.Device(ctx, strings.TrimSpace(deviceID))
if err != nil {
s.writeStoreError(w, err)
return true
}
if config.DeviceType == store.DeviceTypeUSBSIMReader {
writeError(w, http.StatusConflict, "wifi_calling_only_device", "USB SIM readers cannot export cellular data as a proxy")
return true
}
return false
}
func (s *Server) writeExportProxyError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, exportproxy.ErrDisabled):
@@ -0,0 +1,31 @@
package server
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"vocat/internal/store"
)
func TestExportProxyRejectsUSBSIMReader(t *testing.T) {
database, err := store.Open(context.Background(), ":memory:")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
if err := database.UpsertDevice(context.Background(), store.Device{
ID: "reader-1", Name: "USB SIM Reader", DeviceType: store.DeviceTypeUSBSIMReader,
}); err != nil {
t.Fatal(err)
}
server := &Server{store: database}
response := httptest.NewRecorder()
if !server.rejectUnsupportedExportProxyDevice(response, context.Background(), "reader-1") {
t.Fatal("reader was accepted as an export-proxy device")
}
if response.Code != http.StatusConflict {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
}
+25
View File
@@ -93,3 +93,28 @@ func TestProfileProxyBindingRejectsSameICCIDOnDifferentProxy(t *testing.T) {
t.Fatalf("binding after rejected rebind = %+v, %v", binding, err)
}
}
func TestProfileProxyBindingSupportsPCSCReader(t *testing.T) {
server, database, controller := newProfileBindingTestServer(t)
readerICCID := "89104100000028106378"
if err := database.UpsertDevice(context.Background(), store.Device{
ID: "reader-1", Name: "USB SIM Reader", DeviceType: store.DeviceTypeUSBSIMReader,
}); err != nil {
t.Fatal(err)
}
controller.state = vowifi.State{DeviceID: "reader-1", ICCID: readerICCID, Enabled: true}
response := profileBindingRequest(t, server, http.MethodPost, `{
"upstream_proxy_id":"route-1",
"bindings":[{"device_id":"reader-1","iccid":"89104100000028106378","profile_name":"Reader Profile"}]
}`)
if response.Code != http.StatusOK {
t.Fatalf("POST status = %d, body = %s", response.Code, response.Body.String())
}
binding, err := database.DeviceProxyBinding(context.Background(), readerICCID)
if err != nil || binding.DeviceID != "reader-1" || binding.UpstreamProxyID != "route-1" {
t.Fatalf("reader binding = %+v, %v", binding, err)
}
if controller.reconnects != 1 {
t.Fatalf("reader reconnects = %d, want 1", controller.reconnects)
}
}
+11
View File
@@ -546,6 +546,13 @@ func (s *Server) syncModemSMS(ctx context.Context, onlyDevice string) {
if onlyDevice != "" && config.ID != onlyDevice {
continue
}
// A PC/SC USB reader has no modem storage or AT command channel. Its
// messages are delivered by the active VoWiFi IMS session, so attempting
// an AT+CMGL catch-up scan would only poison the reader's health state
// with ErrNoATPort.
if !supportsModemSMSStorage(config) {
continue
}
// Do not queue CMGL traffic on the same serial actor while VoWiFi is
// reading the SIM or running AKA. Once the session is stable, resume the
// SM/ME scan as a catch-up path: an SMS submitted while the card was
@@ -659,6 +666,10 @@ func (s *Server) syncModemSMS(ctx context.Context, onlyDevice string) {
}
}
func supportsModemSMSStorage(config store.Device) bool {
return store.NormalizeDeviceType(config.DeviceType) != store.DeviceTypeUSBSIMReader
}
func shouldDeferModemSMSSync(state vowifi.State, stateErr error) bool {
if stateErr != nil || !state.Enabled {
return false
+9
View File
@@ -108,6 +108,15 @@ func TestNormalizeSMSDeviceFilter(t *testing.T) {
}
}
func TestSupportsModemSMSStorageRejectsUSBReader(t *testing.T) {
if supportsModemSMSStorage(store.Device{DeviceType: store.DeviceTypeUSBSIMReader}) {
t.Fatal("USB SIM reader must not be polled with modem SMS AT commands")
}
if !supportsModemSMSStorage(store.Device{DeviceType: store.DeviceTypePCIeEC20EC25}) {
t.Fatal("cellular modem should retain modem SMS storage synchronization")
}
}
func TestSMSSendOutcome(t *testing.T) {
tests := []struct {
name string
+24 -7
View File
@@ -17,6 +17,7 @@ const (
DeviceTypeWiFi410 = "wifi_410"
DeviceTypeDJI4G = "dji_4g"
DeviceTypePCIeEC20EC25 = "pcie_ec20_ec25"
DeviceTypeUSBSIMReader = "usb_sim_reader"
)
// NormalizeDeviceType returns a stable persisted device type identifier.
@@ -27,6 +28,8 @@ func NormalizeDeviceType(value string) string {
return DeviceTypeWiFi410
case DeviceTypeDJI4G:
return DeviceTypeDJI4G
case DeviceTypeUSBSIMReader:
return DeviceTypeUSBSIMReader
case "", DeviceTypePCIeEC20EC25:
return DeviceTypePCIeEC20EC25
default:
@@ -132,16 +135,29 @@ func upsertDevice(ctx context.Context, executor contextExecer, value Device) err
value.DeviceBackend = "at"
}
value.DeviceBackend = strings.ToLower(strings.TrimSpace(value.DeviceBackend))
if value.DeviceBackend != "at" && value.DeviceBackend != "qmi" {
if value.DeviceBackend != "at" && value.DeviceBackend != "qmi" && value.DeviceBackend != "pcsc" {
return fmt.Errorf("unsupported device backend %q", value.DeviceBackend)
}
if value.ESIMTransport == "" {
value.ESIMTransport = "at"
}
value.ESIMTransport = strings.ToLower(strings.TrimSpace(value.ESIMTransport))
if value.ESIMTransport != "at" && value.ESIMTransport != "qmi" {
if value.ESIMTransport != "at" && value.ESIMTransport != "qmi" && value.ESIMTransport != "pcsc" && value.ESIMTransport != "none" {
return fmt.Errorf("unsupported eSIM transport %q", value.ESIMTransport)
}
value.SIMPIN = strings.TrimSpace(value.SIMPIN)
if value.SIMPIN != "" {
if len(value.SIMPIN) < 4 || len(value.SIMPIN) > 8 || strings.Trim(value.SIMPIN, "0123456789") != "" {
return errors.New("SIM PIN must contain 4 to 8 digits")
}
}
if value.DeviceType == DeviceTypeUSBSIMReader {
value.DeviceBackend = "pcsc"
value.ESIMTransport = "pcsc"
value.NetworkEnabled = false
value.SMSEnabled = true
value.VoWiFiEnabled = true
}
extra, err := normalizeJSONObject(value.Extra)
if err != nil {
return fmt.Errorf("normalize device extra data: %w", err)
@@ -159,12 +175,12 @@ func upsertDevice(ctx context.Context, executor contextExecer, value Device) err
_, err = executor.ExecContext(ctx, `
INSERT INTO devices (
id, name, device_type, interface, control_device, at_port, usb_path,
audio_device, modem_imei, apn, proxy_port, baud_rate,
audio_device, modem_imei, sim_pin, apn, proxy_port, baud_rate,
data_bits, stop_bits, parity, device_backend, esim_transport,
qmi_use_proxy, qmi_proxy_path, qmi_proxy_executable,
network_enabled, sms_enabled, vowifi_enabled, extra_json,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
device_type = excluded.device_type,
@@ -174,6 +190,7 @@ func upsertDevice(ctx context.Context, executor contextExecer, value Device) err
usb_path = excluded.usb_path,
audio_device = excluded.audio_device,
modem_imei = excluded.modem_imei,
sim_pin = excluded.sim_pin,
apn = excluded.apn,
proxy_port = excluded.proxy_port,
baud_rate = excluded.baud_rate,
@@ -192,7 +209,7 @@ func upsertDevice(ctx context.Context, executor contextExecer, value Device) err
updated_at = excluded.updated_at
`,
value.ID, value.Name, value.DeviceType, value.Interface, value.ControlDevice, value.ATPort,
value.USBPath, value.AudioDevice, value.ModemIMEI, value.APN,
value.USBPath, value.AudioDevice, value.ModemIMEI, value.SIMPIN, value.APN,
value.ProxyPort, value.BaudRate, value.DataBits, value.StopBits,
value.Parity, value.DeviceBackend, value.ESIMTransport,
boolInt(value.QMIUseProxy), value.QMIProxyPath, value.QMIProxyExecutable,
@@ -282,7 +299,7 @@ func (s *Store) DeleteDevice(ctx context.Context, id string) error {
const deviceSelect = `
SELECT id, name, device_type, interface, control_device, at_port, usb_path,
audio_device, modem_imei, apn, proxy_port, baud_rate, data_bits,
audio_device, modem_imei, sim_pin, apn, proxy_port, baud_rate, data_bits,
stop_bits, parity, device_backend, esim_transport, qmi_use_proxy,
qmi_proxy_path, qmi_proxy_executable, network_enabled, sms_enabled,
vowifi_enabled, extra_json, created_at, updated_at
@@ -295,7 +312,7 @@ func scanDevice(row rowScanner) (Device, error) {
var createdAt, updatedAt int64
err := row.Scan(
&value.ID, &value.Name, &value.DeviceType, &value.Interface, &value.ControlDevice,
&value.ATPort, &value.USBPath, &value.AudioDevice, &value.ModemIMEI,
&value.ATPort, &value.USBPath, &value.AudioDevice, &value.ModemIMEI, &value.SIMPIN,
&value.APN, &value.ProxyPort, &value.BaudRate, &value.DataBits,
&value.StopBits, &value.Parity, &value.DeviceBackend,
&value.ESIMTransport, &qmiUseProxy, &value.QMIProxyPath,
+25
View File
@@ -366,6 +366,31 @@ func TestDeviceStateRoundTripAndCascade(t *testing.T) {
}
}
func TestUSBSIMReaderConfigurationIsWiFiCallingOnly(t *testing.T) {
ctx := context.Background()
database := openTestStore(t, ":memory:")
err := database.UpsertDevice(ctx, Device{
ID: "reader-1", Name: "USB SIM", DeviceType: DeviceTypeUSBSIMReader,
USBPath: "1-3", ControlDevice: "Reader 00 00", SIMPIN: "1234",
DeviceBackend: "at", ESIMTransport: "at", NetworkEnabled: true,
})
if err != nil {
t.Fatal(err)
}
got, err := database.Device(ctx, "reader-1")
if err != nil {
t.Fatal(err)
}
if got.DeviceBackend != "pcsc" || got.ESIMTransport != "pcsc" || got.NetworkEnabled || !got.SMSEnabled || !got.VoWiFiEnabled || got.SIMPIN != "1234" {
t.Fatalf("reader config = %+v", got)
}
bad := got
bad.SIMPIN = "12x4"
if err := database.UpsertDevice(ctx, bad); err == nil {
t.Fatal("non-numeric SIM PIN was accepted")
}
}
func TestSMSPersistenceAndDerivedThreads(t *testing.T) {
ctx := context.Background()
database := openTestStore(t, ":memory:")
+4
View File
@@ -260,6 +260,10 @@ func migrationStatements(version int) []string {
`ALTER TABLE card_policies
ADD COLUMN custom_phone_number TEXT NOT NULL DEFAULT ''`,
}
case 16:
return []string{
`ALTER TABLE devices ADD COLUMN sim_pin TEXT NOT NULL DEFAULT ''`,
}
default:
return nil
}
+1
View File
@@ -24,6 +24,7 @@ type Device struct {
USBPath string
AudioDevice string
ModemIMEI string
SIMPIN string
APN string
ProxyPort int
BaudRate int
+3 -2
View File
@@ -13,7 +13,7 @@ import (
_ "modernc.org/sqlite"
)
const schemaVersion = 15
const schemaVersion = 16
var ErrNotFound = errors.New("store: not found")
@@ -122,7 +122,8 @@ func migrate(ctx context.Context, db *sql.DB) error {
// migration are still safe and must be applied.
duplicateAdditiveColumn := (nextVersion == 7 && strings.Contains(statement, "ADD COLUMN modem_imei")) ||
(nextVersion == 8 && strings.Contains(statement, "ADD COLUMN device_type")) ||
(nextVersion == 14 && strings.Contains(statement, "ADD COLUMN"))
(nextVersion == 14 && strings.Contains(statement, "ADD COLUMN")) ||
(nextVersion == 16 && strings.Contains(statement, "ADD COLUMN sim_pin"))
if duplicateAdditiveColumn && strings.Contains(strings.ToLower(err.Error()), "duplicate column name") {
continue
}
+124
View File
@@ -0,0 +1,124 @@
package vowifi
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"vocat/internal/pcsc"
)
type PCSCBindingResolver func(context.Context, string) (pcsc.Selector, string, error)
// PCSCAdapter uses a directly attached USB smart-card reader as the UICC for
// Wi-Fi Calling. It deliberately exposes no cellular-radio behaviour.
type PCSCAdapter struct {
service *pcsc.Service
resolve PCSCBindingResolver
mu sync.RWMutex
bindings map[string]string
}
var (
_ SIMIdentityReader = (*PCSCAdapter)(nil)
_ SMSCenterReader = (*PCSCAdapter)(nil)
_ AKAProvider = (*PCSCAdapter)(nil)
_ RadioController = (*PCSCAdapter)(nil)
)
func NewPCSCAdapter(service *pcsc.Service, resolver PCSCBindingResolver) (*PCSCAdapter, error) {
if service == nil || resolver == nil {
return nil, errors.New("vocat: PC/SC service and reader resolver are required")
}
return &PCSCAdapter{service: service, resolve: resolver, bindings: make(map[string]string)}, nil
}
func (adapter *PCSCAdapter) ReadIdentity(ctx context.Context, deviceID string) (SIMIdentity, error) {
selector, pin, err := adapter.resolve(ctx, strings.TrimSpace(deviceID))
if err != nil {
return SIMIdentity{}, err
}
identity, err := adapter.service.ReadIdentity(ctx, selector, pin)
if err != nil {
return SIMIdentity{}, fmt.Errorf("read USB SIM identity: %w", err)
}
if len(identity.IMSI) < 5 {
return SIMIdentity{}, errors.New("vocat: USB SIM reader returned an invalid IMSI")
}
adapter.mu.Lock()
adapter.bindings[identity.ICCID] = strings.TrimSpace(deviceID)
adapter.mu.Unlock()
mncLength := identity.MNCLength
if mncLength != 2 && mncLength != 3 {
if mcc, mnc, ok := assignedHomePLMN(identity.IMSI); ok {
return SIMIdentity{ICCID: identity.ICCID, IMSI: identity.IMSI, HomeMCC: mcc, HomeMNC: mnc, SMSC: identity.SMSC}, nil
}
return SIMIdentity{}, ErrEC20MNCUnavailable
}
if len(identity.IMSI) < 3+mncLength {
return SIMIdentity{}, errors.New("vocat: USB SIM IMSI is shorter than its EF_AD home PLMN")
}
return SIMIdentity{
ICCID: identity.ICCID, IMSI: identity.IMSI,
HomeMCC: identity.IMSI[:3], HomeMNC: identity.IMSI[3 : 3+mncLength],
SMSC: identity.SMSC,
}, nil
}
func (adapter *PCSCAdapter) ReadSMSCenter(ctx context.Context, deviceID string) (string, error) {
selector, pin, err := adapter.resolve(ctx, strings.TrimSpace(deviceID))
if err != nil {
return "", err
}
identity, err := adapter.service.ReadIdentity(ctx, selector, pin)
if err != nil {
return "", err
}
if strings.TrimSpace(identity.SMSC) == "" {
return "", errors.New("vocat: USB SIM does not expose a service-centre address")
}
return identity.SMSC, nil
}
func (adapter *PCSCAdapter) CheckReady(ctx context.Context, identity SIMIdentity) (AKAEvidence, error) {
selector, pin, err := adapter.resolve(ctx, adapter.deviceID(identity))
if err != nil {
return AKAEvidence{}, err
}
aid, err := adapter.service.CheckReady(ctx, selector, identity.ICCID, pin)
if err != nil {
return AKAEvidence{}, fmt.Errorf("check USB SIM AKA application: %w", err)
}
return AKAEvidence{Ready: true, Application: aid}, nil
}
func (adapter *PCSCAdapter) deviceID(identity SIMIdentity) string {
adapter.mu.RLock()
deviceID := adapter.bindings[identity.ICCID]
adapter.mu.RUnlock()
return deviceID
}
func (adapter *PCSCAdapter) Authenticate(ctx context.Context, identity SIMIdentity, challenge AKAChallenge) (AKAResult, error) {
selector, pin, err := adapter.resolve(ctx, adapter.deviceID(identity))
if err != nil {
return AKAResult{}, err
}
result, err := adapter.service.Authenticate(ctx, selector, identity.ICCID, pin, pcsc.AKAChallenge(challenge))
if err != nil {
if errors.Is(err, pcsc.ErrAKARejected) {
return AKAResult{}, errors.Join(ErrEC20AKAMACFailure, err)
}
return AKAResult{}, fmt.Errorf("authenticate with USB SIM: %w", err)
}
return AKAResult(result), nil
}
func (*PCSCAdapter) Snapshot(context.Context, string) (RadioSnapshot, error) {
return RadioSnapshot{OperatingMode: 4, PureAirplanePolicy: true}, nil
}
func (*PCSCAdapter) StopCellularData(context.Context, string) error { return nil }
func (*PCSCAdapter) EnterVoWiFiRFOff(context.Context, string) error { return nil }
func (*PCSCAdapter) Restore(context.Context, string, RadioSnapshot) error { return nil }
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="USB SIM reader">
<defs><linearGradient id="a" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#64748b"/><stop offset="1" stop-color="#1e293b"/></linearGradient></defs>
<rect x="12" y="31" width="104" height="68" rx="14" fill="url(#a)"/>
<rect x="25" y="43" width="55" height="42" rx="7" fill="#0f172a" stroke="#94a3b8" stroke-width="2"/>
<path d="M40 51h24l8 8v18H40z" fill="#facc15"/><path d="M44 60h24M50 54v23M60 54v23" stroke="#a16207" stroke-width="2"/>
<rect x="91" y="51" width="25" height="29" rx="4" fill="#cbd5e1"/>
<path d="M98 58h11M98 65h11M98 72h11" stroke="#64748b" stroke-width="3"/>
<circle cx="101" cy="89" r="3" fill="#22c55e"/>
</svg>

After

Width:  |  Height:  |  Size: 752 B

@@ -15,9 +15,10 @@ export interface CardPolicyPanelProps {
policy: CardPolicy | null;
deviceOnline: boolean;
onPolicyChanged: () => void | Promise<void>;
wifiCallingOnly?: boolean;
}
export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolicyChanged }: CardPolicyPanelProps) {
export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolicyChanged, wifiCallingOnly = false }: CardPolicyPanelProps) {
const { t } = useI18n();
const operable = deviceOnline && !!iccid;
const currentPolicy = policy?.iccid === iccid ? policy : null;
@@ -66,7 +67,7 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
</div>
<div>
<div className="text-lg font-bold text-gray-900 dark:text-white">{t("卡策略")}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{t("VoWiFi / 飞行模式 开关跟着 SIM 卡走,切换即时生效")}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{wifiCallingOnly ? t("USB SIM 读卡器仅用于 WiFi Calling,策略跟随 ICCID 保存") : t("VoWiFi / 飞行模式 开关跟着 SIM 卡走,切换即时生效")}</div>
</div>
</div>
{!iccid ? (
@@ -119,7 +120,7 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
</div>
</div>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
<PolicySwitchCard
<PolicySwitchCard
title="VoWiFi"
subtitle={t("启用时强制关闭蜂窝射频;关闭 VoWiFi 后仍保持飞行模式")}
tone="orange"
@@ -129,7 +130,7 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
failed={toggles.vowifiFailed}
onToggle={toggles.onVoWiFiToggle}
/>
<PolicySwitchCard
{!wifiCallingOnly ? <PolicySwitchCard
title={t("飞行模式")}
subtitle={t("只有手动关闭此开关才允许设备连接基站")}
tone="indigo"
@@ -138,15 +139,15 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
pending={toggles.airplanePending}
failed={toggles.airplaneFailed}
onToggle={toggles.onAirplaneToggle}
/>
/> : null}
</div>
<CardPolicyAPN
{!wifiCallingOnly ? <CardPolicyAPN
deviceId={deviceId}
iccid={iccid}
policy={currentPolicy}
deviceOnline={deviceOnline}
onSaved={onPolicyChanged}
/>
/> : null}
</div>
) : null}
</div>
@@ -30,7 +30,7 @@ function isQmiMode(d?: DiscoveredDevice | null): boolean {
}
function modeLabel(d?: DiscoveredDevice | null): string {
const m = String(d?.mode || "unknown").toLowerCase();
return m === "qmi" ? "QMI" : m === "mbim" ? "MBIM" : m === "ecm" ? "ECM" : m === "rndis" ? "RNDIS" : m === "ncm" ? "NCM" : "UNKNOWN";
return m === "pcsc" ? "PC/SC" : m === "qmi" ? "QMI" : m === "mbim" ? "MBIM" : m === "ecm" ? "ECM" : m === "rndis" ? "RNDIS" : m === "ncm" ? "NCM" : "UNKNOWN";
}
function Field({ label, children }: { label: ReactNode; children: ReactNode }) {
@@ -47,6 +47,7 @@ export function DeviceAddDialog(props: DeviceAddDialogProps) {
const { addSelected, addConfig } = props;
const fixedQmi = isQmiControl(addSelected?.controlPath || addConfig?.controlDevice);
const isMbim = String(addSelected?.mode || "").toLowerCase() === "mbim";
const isReader = addSelected?.hardwareKind === "pcsc" || String(addSelected?.mode || "").toLowerCase() === "pcsc";
useEffect(() => {
if (fixedQmi && addConfig.deviceBackend !== "qmi") props.onConfigChange({ ...addConfig, deviceBackend: "qmi" });
@@ -57,7 +58,7 @@ export function DeviceAddDialog(props: DeviceAddDialogProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMbim]);
const backendOptions = [
const backendOptions = isReader ? [{ value: "pcsc", label: "PC/SC" }] : [
...(isMbim
? []
: [
@@ -161,6 +162,9 @@ export function DeviceAddDialog(props: DeviceAddDialogProps) {
<Field label={t("控制设备")}>
<Input value={addConfig.controlDevice} disabled />
</Field>
{isReader ? <Field label="SIM PIN">
<Input type="password" value={addConfig.simPin} onChange={(e) => set({ simPin: e.target.value })} maxLength={8} inputMode="numeric" placeholder={t("仅在 SIM 启用 PIN 时填写")} />
</Field> : null}
<div className="flex items-center justify-between rounded-xl border border-gray-200 bg-gray-50 p-3">
<div>
<div className="text-sm font-bold text-gray-800">{t("设备后端模式")}</div>
@@ -173,7 +177,7 @@ export function DeviceAddDialog(props: DeviceAddDialogProps) {
onChange={(v) => set({ deviceBackend: v })}
className="w-[110px]"
placeholder="AT"
disabled={fixedQmi || isMbim}
disabled={fixedQmi || isMbim || isReader}
options={backendOptions}
/>
</div>
@@ -34,13 +34,14 @@ export function DeviceConfigTab({ editConfig, deviceStatus, saving, deleting, on
const usbPath = deviceStatus?.usbPath || editConfig?.usbPath;
const isQmi = isQmiControl(controlDevice);
const isMbim = String(editConfig?.deviceBackend || "").toLowerCase() === "mbim";
const isReader = editConfig?.deviceType === "usb_sim_reader";
useEffect(() => {
if (isQmi && editConfig && editConfig.deviceBackend !== "qmi") onEditConfig({ ...editConfig, deviceBackend: "qmi" });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isQmi]);
const backendOptions = [
const backendOptions = isReader ? [{ value: "pcsc", label: "PC/SC" }] : [
...(isMbim
? []
: [
@@ -106,6 +107,9 @@ export function DeviceConfigTab({ editConfig, deviceStatus, saving, deleting, on
<Field label={t("控制设备")}>
<Input value={controlDevice || ""} disabled placeholder={t("由系统自动探测")} />
</Field>
{isReader ? <Field label="SIM PIN">
<Input type="password" value={editConfig.simPin || ""} onChange={(e) => onEditConfig({ ...editConfig, simPin: e.target.value })} maxLength={8} inputMode="numeric" placeholder={t("留空表示不修改;仅在 SIM 启用 PIN 时填写")} />
</Field> : null}
<div className="ui-panel-muted space-y-2 p-3">
<div className="flex items-center justify-between">
<div>
@@ -123,7 +127,7 @@ export function DeviceConfigTab({ editConfig, deviceStatus, saving, deleting, on
onChange={(v) => onEditConfig({ ...editConfig, deviceBackend: v as DeviceConfig["deviceBackend"] })}
className="w-[120px]"
placeholder="AT"
disabled={isQmi || isMbim}
disabled={isQmi || isMbim || isReader}
options={backendOptions}
/>
</div>
@@ -15,6 +15,7 @@ export interface DeviceDetailHeaderProps {
onReconnectVowifi: () => void;
onRebootModem: () => void;
onOpenSms: () => void;
wifiCallingOnly?: boolean;
}
export function DeviceDetailHeader(props: DeviceDetailHeaderProps) {
@@ -42,7 +43,7 @@ export function DeviceDetailHeader(props: DeviceDetailHeaderProps) {
<Button loading={props.reconnectingVoWiFi} onClick={props.onReconnectVowifi} className="ui-glass-border !border-0" icon={<ArrowSyncRegular />}>
{t("重连 VoWiFi")}
</Button>
) : device.developerEnabled ? (
) : device.developerEnabled && !props.wifiCallingOnly ? (
<div
className="ui-glass-border flex h-8 items-center gap-2 rounded-lg px-3 text-sm text-gray-700 dark:text-gray-200"
title={t("蜂窝数据仅进入 Export Proxy 的受保护路由,不会成为主机默认出口")}
@@ -58,9 +59,9 @@ export function DeviceDetailHeader(props: DeviceDetailHeaderProps) {
/>
</div>
) : null}
<Button loading={props.rebooting} onClick={props.onRebootModem} className="ui-glass-border !border-0 hover:!text-red-600" icon={<PowerRegular />}>
{!props.wifiCallingOnly ? <Button loading={props.rebooting} onClick={props.onRebootModem} className="ui-glass-border !border-0 hover:!text-red-600" icon={<PowerRegular />}>
{t("重启模组")}
</Button>
</Button> : null}
<Button onClick={props.onOpenSms} className="ui-glass-border !border-0" icon={<ChatRegular />}>
{t("短信")}
</Button>
@@ -26,12 +26,13 @@ export function DeviceOverviewTab(props: DeviceOverviewTabProps) {
const { t } = useI18n();
const [operatorOpen, setOperatorOpen] = useState(false);
const { device } = props;
const wifiCallingOnly = device.deviceType === "usb_sim_reader";
return (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<div className={`grid grid-cols-1 gap-4 ${wifiCallingOnly ? "lg:grid-cols-2" : "lg:grid-cols-3"}`}>
<div className="ui-panel-muted p-4">
<div className="mb-3 text-xs font-bold uppercase tracking-wider text-gray-500">{t("运行状态")}</div>
{isVoWiFiInUse(device) ? (
{isVoWiFiInUse(device) && !(device.modem?.imei && device.modem?.simInserted === false) ? (
<OverviewVowifiCard device={device} />
) : (
<OverviewNetworkCard device={device} onOpenOperatorSelection={() => setOperatorOpen(true)} />
@@ -44,16 +45,16 @@ export function DeviceOverviewTab(props: DeviceOverviewTabProps) {
e911Starting={props.e911Starting}
onSetupE911={props.onSetupE911}
/>
<OverviewNetworkPanel
{!wifiCallingOnly ? <OverviewNetworkPanel
device={device}
trafficMinuteRx={props.trafficMinuteRx}
trafficMinuteTx={props.trafficMinuteTx}
trafficSpeedRx={props.trafficSpeedRx}
trafficSpeedTx={props.trafficSpeedTx}
/>
/> : null}
</div>
{device.developerEnabled && device.networkEnabled && device.id ? <OverviewTrafficChart deviceId={device.id} /> : null}
{device?.id ? (
{device?.id && !wifiCallingOnly ? (
<OperatorSelectionDialog
open={operatorOpen}
deviceId={device.id}
@@ -26,17 +26,20 @@ export function OverviewNetworkCard({ device, onOpenOperatorSelection }: { devic
const modem = device.modem;
const online = isDeviceOnline(device);
const cellularRegistered = isRegistered(device);
const simMissing = !!modem?.imei && modem?.simInserted === false;
// A persisted runtime may briefly describe the old session while disable is
// being cleaned up. Desired policy is authoritative for the overview badge.
const vowifiRegistered = !!device.vowifiEnabled && !!(device.vowifiActive || device.vowifiRuntime?.smsReady);
const registered = cellularRegistered || vowifiRegistered;
const registered = !simMissing && (cellularRegistered || vowifiRegistered);
const radioOffForVowifi = vowifiRegistered && (modem?.operatingMode === 0 || modem?.operatingMode === 4 || device.flightMode);
const tone = isRecoveringPhase(device.lifecyclePhase) ? "warning" : online ? (registered ? "success" : "warning") : "danger";
let statusText: string;
const phaseLabel = lifecycleLabel(device.lifecyclePhase);
if (phaseLabel && device.lifecyclePhase !== "online" && device.lifecyclePhase !== "offline") statusText = phaseLabel;
else if (online) {
else if (online) {
if (simMissing) statusText = t("SIM卡未插入");
else
statusText = registered
? ""
: device.registrationStateLabel === "searching"
@@ -49,7 +52,9 @@ export function OverviewNetworkCard({ device, onOpenOperatorSelection }: { devic
const level = signalLevel(modem?.signalDbm);
const sigTone = signalTone(modem?.signalDbm);
const netMode = [modem?.networkDuplex, modem?.networkMode].filter(Boolean).join(" ");
const cellularRegistrationText = modem?.regStatus === 5
const cellularRegistrationText = simMissing
? t("SIM卡未插入")
: modem?.regStatus === 5
? t("已驻网(漫游)")
: modem?.regStatus === 1
? t("已驻网")
+1
View File
@@ -44,6 +44,7 @@ export interface AddDeviceForm {
atPort: string;
controlDevice: string;
deviceBackend: string;
simPin: string;
}
export interface LoadError {
+1
View File
@@ -6,6 +6,7 @@ export const DEVICE_TYPES: ReadonlyArray<{ value: DeviceType; label: string; ima
{ value: "wifi_410", label: "410 WiFi 棒(高通芯片)", image: "/410.png" },
{ value: "dji_4g", label: "大疆 4G 模块(移远芯片)", image: "/dj.png" },
{ value: "pcie_ec20_ec25", label: "PCIe EC20/EC25(移远芯片)", image: "/ec20.png" },
{ value: "usb_sim_reader", label: "USB SIM 读卡器(仅 WiFi Calling", image: "/sim-reader.svg" },
];
export function normalizeDeviceType(value?: string | null): DeviceType {
+7 -1
View File
@@ -5,6 +5,10 @@
* 富文本片段(嵌套链接/代码块的说明框)不走字典,在组件里按语言分支渲染。
*/
export const EN_DICT: Record<string, string> = {
"USB SIM 读卡器(仅 WiFi Calling": "USB SIM Reader (WiFi Calling only)",
"仅在 SIM 启用 PIN 时填写": "Only enter this when SIM PIN is enabled",
"留空表示不修改;仅在 SIM 启用 PIN 时填写": "Leave blank to keep unchanged; only enter this when SIM PIN is enabled",
"USB SIM 读卡器仅用于 WiFi Calling,策略跟随 ICCID 保存": "The USB SIM reader is for WiFi Calling only; policy is saved per ICCID",
// Cellular APN profiles.
"蜂窝 APN": "Cellular APN",
"APN 列表和启用状态跟随当前 ICCID/Profile 保存": "APN profiles and the active selection are saved per ICCID/Profile",
@@ -765,7 +769,9 @@ export const EN_DICT: Record<string, string> = {
"暂无设备": "No devices",
"最后原因": "Last Reason",
"最后成功:": "Last success: ",
"未检测到 eUICC": "No eUICC detected",
"未检测到 eUICC": "No eUICC detected",
"SIM卡未插入": "No SIM card inserted",
"USB SIM读卡器仅支持VoWiFi短信和通话任务": "USB SIM readers support VoWiFi SMS and call tasks only",
"未生效": "Not in effect",
"未知": "Unknown",
"未知日期": "Unknown date",
+26 -4
View File
@@ -251,7 +251,7 @@ export default function AutomaticTasksPage() {
function edit(task?: AutomaticTask) {
const deviceId = task?.deviceId || devices[0]?.id || "";
const next = task ? {
let next = task ? {
id: task.id,
name: task.name,
enabled: task.enabled,
@@ -269,13 +269,21 @@ export default function AutomaticTasksPage() {
message: task.payload?.message || "",
durationSeconds: task.payload?.durationSeconds || 30,
} : emptyForm(deviceId);
if (devices.find((device) => device.id === deviceId)?.deviceType === "usb_sim_reader") {
next = { ...next, taskType: next.taskType === "public_ip" ? "sms" : next.taskType, environment: "vowifi" };
}
setForm(next);
setOpen(true);
void loadProfiles(deviceId, next.profileIccid);
}
function chooseDevice(deviceId: string) {
setForm((current) => ({ ...current, deviceId, profileIccid: "", profileAid: "" }));
const reader = devices.find((device) => device.id === deviceId)?.deviceType === "usb_sim_reader";
setForm((current) => ({
...current, deviceId, profileIccid: "", profileAid: "",
taskType: reader && current.taskType === "public_ip" ? "sms" : current.taskType,
environment: reader ? "vowifi" : current.environment,
}));
void loadProfiles(deviceId);
}
@@ -285,6 +293,7 @@ export default function AutomaticTasksPage() {
}
function chooseTaskType(taskType: TaskType) {
if (deviceByID.get(form.deviceId)?.deviceType === "usb_sim_reader" && taskType === "public_ip") return;
setForm((current) => ({
...current,
taskType,
@@ -296,6 +305,9 @@ export default function AutomaticTasksPage() {
if (!form.name.trim()) return message.warning(t("请输入任务名称"));
if (!form.deviceId) return message.warning(t("请选择设备"));
if (!form.profileIccid) return message.warning(t("请选择 eSIM Profile"));
if (deviceByID.get(form.deviceId)?.deviceType === "usb_sim_reader" && (form.environment !== "vowifi" || form.taskType === "public_ip")) {
return message.warning(t("USB SIM读卡器仅支持VoWiFi短信和通话任务"));
}
if (form.taskType !== "public_ip" && !form.phone.trim()) return message.warning(t("请输入号码"));
if (form.taskType === "sms" && !form.message.trim()) return message.warning(t("请输入短信内容"));
setSaving(true);
@@ -377,6 +389,15 @@ export default function AutomaticTasksPage() {
const taskTypeLabel = (value: TaskType) => ({ sms: t("发送短信"), call: t("拨打电话并自动挂断"), public_ip: t("获取漫游公网 IP") })[value];
const environmentLabel = (value: TaskEnvironment) => value === "vowifi" ? "VoWiFi" : t("基站直连");
const selectedTaskDeviceIsReader = deviceByID.get(form.deviceId)?.deviceType === "usb_sim_reader";
const taskTypeOptions = [
{ value: "sms", label: t("发送短信") },
{ value: "call", label: t("拨打电话并自动挂断") },
...(!selectedTaskDeviceIsReader ? [{ value: "public_ip", label: t("开启漫游流量并获取一次公网 IP") }] : []),
];
const environmentOptions = selectedTaskDeviceIsReader
? [{ value: "vowifi", label: "VoWiFi" }]
: [{ value: "vowifi", label: "VoWiFi" }, { value: "cellular", label: t("基站直连(自动选网)") }];
return (
<div className="mx-auto max-w-7xl">
@@ -473,8 +494,9 @@ export default function AutomaticTasksPage() {
<div className="md:col-span-2"><label className={fieldLabel}>{t("任务名称")}</label><Input value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} placeholder={t("例如:每日短信保活")} /></div>
<div><label className={fieldLabel}>{t("设备")}</label><Select value={form.deviceId} onChange={chooseDevice} options={devices.map((device) => ({ value: device.id, label: `${device.name || device.id} (${device.id})` }))} /></div>
<div><label className={fieldLabel}>{t("eSIM Profile")}</label><Select value={form.profileIccid} onChange={chooseProfile} disabled={profileLoading || !form.deviceId} placeholder={profileLoading ? t("读取 Profile 中...") : t("请选择 Profile")} options={profiles.map((profile) => ({ value: profile.iccid, label: profile.label }))} /></div>
<div><label className={fieldLabel}>{t("任务类型")}</label><Select value={form.taskType} onChange={(value) => chooseTaskType(value as TaskType)} options={[{ value: "sms", label: t("发送短信") }, { value: "call", label: t("拨打电话并自动挂断") }, { value: "public_ip", label: t("开启漫游流量并获取一次公网 IP") }]} /></div>
<div><label className={fieldLabel}>{t("执行环境")}</label><Select value={form.environment} onChange={(value) => setForm({ ...form, environment: value as TaskEnvironment })} disabled={form.taskType === "public_ip"} options={[{ value: "vowifi", label: "VoWiFi" }, { value: "cellular", label: t("基站直连(自动选网)") }]} /></div>
<div><label className={fieldLabel}>{t("任务类型")}</label><Select value={form.taskType} onChange={(value) => chooseTaskType(value as TaskType)} options={taskTypeOptions} /></div>
<div><label className={fieldLabel}>{t("执行环境")}</label><Select value={form.environment} onChange={(value) => setForm({ ...form, environment: value as TaskEnvironment })} disabled={form.taskType === "public_ip" || selectedTaskDeviceIsReader} options={environmentOptions} /></div>
{selectedTaskDeviceIsReader ? <div className="md:col-span-2 rounded-lg border border-sky-200 bg-sky-50 p-3 text-sm text-sky-700 dark:border-sky-500/20 dark:bg-sky-500/10 dark:text-sky-300">{t("USB SIM读卡器仅支持VoWiFi短信和通话任务")}</div> : null}
{form.taskType !== "public_ip" ? <div><label className={fieldLabel}>{t("号码")}</label><Input value={form.phone} onChange={(event) => setForm({ ...form, phone: event.target.value })} placeholder="+447700900123" /></div> : null}
{form.taskType === "call" ? <div><label className={fieldLabel}>{t("自动挂断")}</label><Input type="number" min={1} max={600} value={form.durationSeconds} suffix="s" onChange={(event) => setForm({ ...form, durationSeconds: Number(event.target.value) })} /></div> : null}
+12 -3
View File
@@ -32,6 +32,7 @@ const EMPTY_ADD: AddDeviceForm = {
atPort: "",
controlDevice: "",
deviceBackend: "at",
simPin: "",
};
export default function DevicesPage() {
@@ -373,7 +374,8 @@ export default function DevicesPage() {
setAddSelected(d);
setAddConfig((prev) => {
const mode = String(d.mode || "").toLowerCase();
const backend = mode === "mbim" ? "mbim" : isQmiControl(d.controlPath) || (mode === "qmi" && d.controlPath) ? "qmi" : "at";
const isReader = d.hardwareKind === "pcsc" || mode === "pcsc";
const backend = isReader ? "pcsc" : mode === "mbim" ? "mbim" : isQmiControl(d.controlPath) || (mode === "qmi" && d.controlPath) ? "qmi" : "at";
return {
...prev,
interface: d.netInterface || "",
@@ -382,6 +384,8 @@ export default function DevicesPage() {
modemImei: d.imei || "",
usbPath: d.usbPath || "",
deviceBackend: backend,
deviceType: isReader ? "usb_sim_reader" : prev.deviceType,
esimTransport: isReader ? "pcsc" : backend,
};
});
}, []);
@@ -574,6 +578,10 @@ export default function DevicesPage() {
const unconfiguredDiscovered = useMemo(() => discovered.filter((d) => !d.configured), [discovered]);
const detailOnline = isDeviceOnline(detail);
const isReader = detail?.deviceType === "usb_sim_reader";
useEffect(() => {
if (isReader && ["at", "ussd"].includes(activeTab)) setActiveTab("overview");
}, [isReader, activeTab]);
const addAtLimit = deviceLimit > 0 && list.length >= deviceLimit;
const tabItems = [
{ key: "overview", label: t("概览") },
@@ -582,7 +590,7 @@ export default function DevicesPage() {
{ key: "ussd", label: t("USSD") },
{ key: "config", label: t("配置") },
{ key: "card", label: t("卡策略") },
];
].filter((tab) => !isReader || !["at", "ussd"].includes(tab.key));
const overviewNode = detail ? (
<div className="space-y-4">
@@ -673,6 +681,7 @@ export default function DevicesPage() {
onReconnectVowifi={handleReconnectVoWiFi}
onRebootModem={handleRebootModem}
onOpenSms={handleOpenSms}
wifiCallingOnly={isReader}
/>
<div className="device-detail-tabs ui-card p-6">
<Tabs tabs={tabItems} value={activeTab} onChange={handleTabChange} />
@@ -697,7 +706,7 @@ export default function DevicesPage() {
<DeviceConfigTab editConfig={editConfig} deviceStatus={detail} saving={saving} deleting={deleting} onSave={handleSaveConfig} onDelete={handleDeleteDevice} onEditConfig={setEditConfig} />
) : null}
{activeTab === "card" ? (
<CardPolicyPanel deviceId={detail.id} iccid={detail.modem?.iccid} policy={cardPolicy} deviceOnline={detailOnline} onPolicyChanged={handlePolicyChanged} />
<CardPolicyPanel deviceId={detail.id} iccid={detail.modem?.iccid} policy={cardPolicy} deviceOnline={detailOnline} onPolicyChanged={handlePolicyChanged} wifiCallingOnly={isReader} />
) : null}
</div>
</div>
+6 -3
View File
@@ -1,6 +1,6 @@
export type ApiStatus = "ok" | "error";
export type DeviceType = "wifi_410" | "dji_4g" | "pcie_ec20_ec25";
export type DeviceType = "wifi_410" | "dji_4g" | "pcie_ec20_ec25" | "usb_sim_reader";
export interface Session {
authenticated: boolean;
@@ -167,6 +167,8 @@ export interface DeviceStatus {
}
export interface DiscoveredDevice {
hardwareKind?: string;
readerName?: string;
discoveryKey: string;
controlPath: string;
netInterface: string;
@@ -196,14 +198,15 @@ export interface DeviceConfig {
usbPath: string;
audioDevice?: string;
modemImei?: string;
simPin?: string;
apn: string;
proxyPort: number;
baudRate: number;
dataBits: number;
stopBits: number;
parity: string;
deviceBackend: "at" | "qmi";
esimTransport: "at" | "qmi";
deviceBackend: "at" | "qmi" | "pcsc";
esimTransport: "at" | "qmi" | "pcsc" | "none";
qmiUseProxy: boolean;
qmiProxyPath?: string;
qmiProxyExecutable?: string;