mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-13 03:13:43 +08:00
670 lines
18 KiB
Go
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
|
|
}
|