Files

670 lines
18 KiB
Go

package device
import (
"context"
"errors"
"fmt"
"sort"
"strings"
"sync"
"time"
"vocat/internal/modem"
"vocat/internal/pcsc"
)
type Options struct {
Discoverer modem.Discoverer
Opener modem.Opener
CommandTimeout time.Duration
LongTimeout time.Duration
SMSTimeout time.Duration
ScanTimeout time.Duration
CardReaders *pcsc.Service
}
type Manager struct {
mu sync.RWMutex
uiccMu sync.Mutex // serializes all multi-command UICC/APDU transactions
esimMu sync.Mutex // serializes eSIM card access (list/switch/download)
esimRecoveryMu sync.Mutex
esimRecoveries map[string]chan struct{}
esimCacheMu sync.RWMutex
esimCache map[string]EsimInfo
discoverer modem.Discoverer
opener modem.Opener
commandTimeout time.Duration
longTimeout time.Duration
smsTimeout time.Duration
scanTimeout time.Duration
cardReaders *pcsc.Service
started bool
devices map[string]*managedDevice
ussdSessions map[string]ussdSession
}
// LockUICC and UnlockUICC allow another in-process UICC client (currently the
// VoWiFi AKA adapter) to share the same transaction boundary as eSIM ES10.
// Individual AT commands are already serialized per modem, but a logical-
// channel transaction spans several commands and must not be interleaved.
func (manager *Manager) LockUICC() { manager.uiccMu.Lock() }
func (manager *Manager) UnlockUICC() { manager.uiccMu.Unlock() }
func (manager *Manager) lockESIM() {
manager.esimMu.Lock()
manager.uiccMu.Lock()
}
func (manager *Manager) unlockESIM() {
manager.uiccMu.Unlock()
manager.esimMu.Unlock()
}
// ussdSession tracks an open USSD dialog on a device so a follow-up Continue or
// Cancel can be routed back to the right modem. The modem owns the actual
// network session; this map only records which device a session id belongs to.
type ussdSession struct {
deviceID string
createdAt time.Time
}
type managedDevice struct {
opMu sync.Mutex
candidate modem.Candidate
backend string
lastICCID string
client modem.Client
snapshot *Snapshot
lastError string
lastUpdated time.Time
discovered bool
preFlightMode *int
resetClientOnLock bool
simPIN string
}
func NewManager(options Options) (*Manager, error) {
if options.Discoverer == nil {
options.Discoverer = modem.NewSystemDiscoverer()
}
if options.Opener == nil {
options.Opener = modem.SerialOpener{}
}
if options.CommandTimeout <= 0 {
options.CommandTimeout = 3 * time.Second
}
if options.LongTimeout <= 0 {
options.LongTimeout = 45 * time.Second
}
if options.SMSTimeout <= 0 {
// Quectel documents a maximum AT+CMGS response time of 120 seconds.
options.SMSTimeout = 125 * time.Second
}
if options.ScanTimeout <= 0 {
// 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,
commandTimeout: options.CommandTimeout,
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{}),
esimCache: make(map[string]EsimInfo),
}, nil
}
func (manager *Manager) Start(ctx context.Context) error {
manager.mu.Lock()
if manager.started {
manager.mu.Unlock()
return nil
}
manager.mu.Unlock()
if _, err := manager.Discover(ctx); err != nil {
return err
}
manager.mu.Lock()
manager.started = true
manager.mu.Unlock()
return nil
}
func (manager *Manager) Stop(ctx context.Context) error {
if ctx == nil {
ctx = context.Background()
}
manager.mu.Lock()
manager.started = false
states := make([]*managedDevice, 0, len(manager.devices))
for _, state := range manager.devices {
states = append(states, state)
}
manager.mu.Unlock()
var closeErrors []error
for _, state := range states {
if err := ctx.Err(); err != nil {
return errors.Join(append(closeErrors, err)...)
}
state.opMu.Lock()
if state.client != nil {
if err := state.client.Close(); err != nil {
closeErrors = append(closeErrors, err)
}
state.client = nil
}
state.opMu.Unlock()
}
return errors.Join(closeErrors...)
}
func (manager *Manager) Discover(ctx context.Context) ([]Device, error) {
if ctx == nil {
ctx = context.Background()
}
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))
manager.mu.Lock()
for _, candidate := range candidates {
if strings.TrimSpace(candidate.ID) == "" {
continue
}
seen[candidate.ID] = struct{}{}
state := manager.devices[candidate.ID]
if state == nil {
manager.devices[candidate.ID] = &managedDevice{
candidate: candidate,
discovered: true,
}
continue
}
if state.candidate.ATPort.OpenPath() != candidate.ATPort.OpenPath() {
state.resetClientOnLock = true
}
state.candidate = candidate
state.discovered = true
}
var stale []*managedDevice
for id, state := range manager.devices {
if _, ok := seen[id]; ok {
continue
}
state.discovered = false
stale = append(stale, state)
}
manager.mu.Unlock()
for _, state := range stale {
state.opMu.Lock()
if state.client != nil {
_ = state.client.Close()
state.client = nil
}
state.opMu.Unlock()
}
manager.resetChangedClients()
return manager.List(), nil
}
func (manager *Manager) resetChangedClients() {
manager.mu.Lock()
states := make([]*managedDevice, 0, len(manager.devices))
for _, state := range manager.devices {
if state.resetClientOnLock {
states = append(states, state)
state.resetClientOnLock = false
}
}
manager.mu.Unlock()
for _, state := range states {
state.opMu.Lock()
if state.client != nil {
_ = state.client.Close()
state.client = nil
}
state.opMu.Unlock()
}
}
func (manager *Manager) List() []Device {
manager.mu.RLock()
result := make([]Device, 0, len(manager.devices))
for id, state := range manager.devices {
result = append(result, copyDevice(id, state))
}
manager.mu.RUnlock()
sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID })
return result
}
func (manager *Manager) Get(id string) (Device, error) {
manager.mu.RLock()
state := manager.devices[id]
if state == nil {
manager.mu.RUnlock()
return Device{}, ErrNotFound
}
result := copyDevice(id, state)
manager.mu.RUnlock()
return result, nil
}
func copyDevice(id string, state *managedDevice) Device {
var snapshot *Snapshot
if state.snapshot != nil {
value := *state.snapshot
value.Warnings = append([]string(nil), value.Warnings...)
snapshot = &value
}
return Device{
ID: id,
Candidate: copyCandidate(state.candidate),
Snapshot: snapshot,
LastError: state.lastError,
Discovered: state.discovered,
LastUpdated: state.lastUpdated,
}
}
func copyCandidate(candidate modem.Candidate) modem.Candidate {
candidate.Ports = append([]modem.Port(nil), candidate.Ports...)
return candidate
}
func (manager *Manager) lookup(id string) (*managedDevice, error) {
manager.mu.RLock()
defer manager.mu.RUnlock()
if !manager.started {
return nil, ErrNotStarted
}
state := manager.devices[id]
if state == nil || !state.discovered {
return nil, ErrNotFound
}
return state, nil
}
func (manager *Manager) clientLocked(
ctx context.Context,
state *managedDevice,
candidate modem.Candidate,
) (modem.Client, error) {
if state.client != nil {
if poisoned, ok := state.client.(modem.PoisonedClient); ok && poisoned.Poisoned() {
// The cached session hit a transport-fatal error (a failed
// write/drain/read or a closed serial line); the underlying fd is
// wedged and every subsequent command reuses the corpse, so the
// device stays stuck on EIO forever. Discard it and reopen so the
// next AT/CSIM call self-heals. AT-level failures (CommandError,
// command timeout) do not poison — those leave a healthy transport
// that reopening would only destroy over a transient +CME ERROR.
_ = state.client.Close()
state.client = nil
} else {
return state.client, nil
}
}
if !candidate.HasATPort() {
return nil, ErrNoATPort
}
client, err := manager.opener.Open(ctx, candidate.ATPort)
if err != nil {
return nil, err
}
state.client = client
return client, nil
}
func (manager *Manager) setResult(
id string,
state *managedDevice,
snapshot *Snapshot,
err error,
) {
manager.mu.Lock()
defer manager.mu.Unlock()
if manager.devices[id] != state {
return
}
if snapshot != nil {
value := *snapshot
value.Warnings = append([]string(nil), snapshot.Warnings...)
state.snapshot = &value
state.lastUpdated = snapshot.UpdatedAt
}
if err != nil {
state.lastError = err.Error()
} else {
state.lastError = ""
}
}
func (manager *Manager) candidateFor(state *managedDevice) modem.Candidate {
manager.mu.RLock()
defer manager.mu.RUnlock()
return copyCandidate(state.candidate)
}
func (manager *Manager) validateActive(
id string,
state *managedDevice,
) error {
manager.mu.RLock()
defer manager.mu.RUnlock()
if !manager.started {
return ErrNotStarted
}
current := manager.devices[id]
if current != state || !state.discovered {
return ErrNotFound
}
return nil
}
func (manager *Manager) Refresh(ctx context.Context, id string) (Snapshot, error) {
state, err := manager.lookup(id)
if err != nil {
return Snapshot{}, err
}
state.opMu.Lock()
defer state.opMu.Unlock()
if err := manager.validateActive(id, state); err != nil {
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 {
manager.setResult(id, state, nil, err)
return Snapshot{}, err
}
previousICCID := state.lastICCID
snapshot, err := manager.readSnapshot(ctx, id, candidate, backend, previousICCID, client)
if err == nil && strings.TrimSpace(snapshot.ICCID) != "" {
state.lastICCID = strings.TrimSpace(snapshot.ICCID)
}
manager.setResult(id, state, &snapshot, err)
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" && backend != "pcsc" {
return fmt.Errorf("unsupported device backend %q", backend)
}
manager.mu.Lock()
defer manager.mu.Unlock()
state := manager.devices[id]
if state == nil || !state.discovered {
return ErrNotFound
}
state.backend = backend
return nil
}
func (manager *Manager) backendFor(state *managedDevice) string {
manager.mu.RLock()
defer manager.mu.RUnlock()
return state.backend
}
func (manager *Manager) ExecuteAT(
ctx context.Context,
id string,
command string,
) (modem.Response, error) {
state, err := manager.lookup(id)
if err != nil {
return modem.Response{}, err
}
state.opMu.Lock()
defer state.opMu.Unlock()
if err := manager.validateActive(id, state); err != nil {
return modem.Response{}, err
}
client, err := manager.clientLocked(ctx, state, manager.candidateFor(state))
if err != nil {
manager.setResult(id, state, nil, err)
return modem.Response{}, err
}
commandCtx, cancel := manager.withTimeout(ctx, manager.commandTimeout)
defer cancel()
response, err := client.Execute(commandCtx, command)
manager.setResult(id, state, nil, err)
return response, err
}
// ExecuteSensitiveAT runs an AT command whose payload contains short-lived
// authentication material. The original transport error is returned to the
// caller, but it is never retained in the device snapshot because a
// modem.CommandError may include the full command.
func (manager *Manager) ExecuteSensitiveAT(
ctx context.Context,
id string,
command string,
) (modem.Response, error) {
state, err := manager.lookup(id)
if err != nil {
return modem.Response{}, err
}
state.opMu.Lock()
defer state.opMu.Unlock()
if err := manager.validateActive(id, state); err != nil {
return modem.Response{}, err
}
client, err := manager.clientLocked(ctx, state, manager.candidateFor(state))
if err != nil {
manager.setResult(
id,
state,
nil,
errors.New("sensitive AT command could not open the modem"),
)
return modem.Response{}, err
}
commandCtx, cancel := manager.withTimeout(ctx, manager.commandTimeout)
defer cancel()
response, err := client.Execute(commandCtx, command)
recordedErr := err
if err != nil {
recordedErr = errors.New("sensitive AT command failed")
}
manager.setResult(id, state, nil, recordedErr)
return response, err
}
func (manager *Manager) Reboot(ctx context.Context, id string) error {
state, err := manager.lookup(id)
if err != nil {
return err
}
state.opMu.Lock()
defer state.opMu.Unlock()
if err := manager.validateActive(id, state); err != nil {
return err
}
client, err := manager.clientLocked(ctx, state, manager.candidateFor(state))
if err != nil {
manager.setResult(id, state, nil, err)
return err
}
commandCtx, cancel := manager.withTimeout(ctx, manager.longTimeout)
defer cancel()
_, err = client.Execute(commandCtx, "AT+CFUN=1,1")
if closeErr := client.Close(); err == nil {
err = closeErr
}
state.client = nil
state.preFlightMode = nil
manager.clearSnapshot(id, state)
manager.setResult(id, state, nil, err)
return err
}
// rebootForProfileSwitch is the post-EnableProfile modem reset. After the eUICC
// marks a new profile active, the modem keeps the old SIM cached and lands in
// SIM failure (-CME 13) until it is bounced. ESIMSwitchProfile has already
// released opMu by the time it calls this, so the reset is safe to take the
// lock. This mirrors Reboot but is separate so the call site can't recurse into
// a guarded-reset path.
func (manager *Manager) rebootForProfileSwitch(ctx context.Context, id string) error {
state, err := manager.lookup(id)
if err != nil {
return err
}
state.opMu.Lock()
defer state.opMu.Unlock()
if err := manager.validateActive(id, state); err != nil {
return err
}
client, err := manager.clientLocked(ctx, state, manager.candidateFor(state))
if err != nil {
manager.setResult(id, state, nil, err)
return err
}
commandCtx, cancel := manager.withTimeout(ctx, manager.longTimeout)
defer cancel()
_, err = client.Execute(commandCtx, "AT+CFUN=1,1")
if closeErr := client.Close(); err == nil {
err = closeErr
}
state.client = nil
state.preFlightMode = nil
manager.clearSnapshot(id, state)
manager.setResult(id, state, nil, err)
return err
}
func (manager *Manager) clearSnapshot(id string, state *managedDevice) {
manager.mu.Lock()
defer manager.mu.Unlock()
if manager.devices[id] == state {
state.snapshot = nil
}
}
func (manager *Manager) withTimeout(
ctx context.Context,
timeout time.Duration,
) (context.Context, context.CancelFunc) {
if ctx == nil {
ctx = context.Background()
}
if _, ok := ctx.Deadline(); ok {
return context.WithCancel(ctx)
}
return context.WithTimeout(ctx, timeout)
}
func (manager *Manager) command(
ctx context.Context,
client modem.Client,
command string,
) (modem.Response, error) {
commandCtx, cancel := manager.withTimeout(ctx, manager.commandTimeout)
defer cancel()
response, err := client.Execute(commandCtx, command)
if err != nil {
return response, fmt.Errorf("%s: %w", command, err)
}
return response, nil
}
// sensitiveCommand executes an AT command containing credentials or other
// authentication material. Modem errors commonly echo the complete command,
// so neither the returned error nor the retained device state may wrap it.
func (manager *Manager) sensitiveCommand(
ctx context.Context,
client modem.Client,
command string,
) (modem.Response, error) {
commandCtx, cancel := manager.withTimeout(ctx, manager.commandTimeout)
defer cancel()
response, err := client.Execute(commandCtx, command)
if err != nil {
return response, errors.New("sensitive modem command failed")
}
return response, nil
}