mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-13 03:13:43 +08:00
- Added `automatic_tasks.go` and `automatic_tasks_test.go` for backend logic and testing of automatic tasks. - Created `automatic_tasks_test.go` to validate task claiming and deletion behavior. - Developed `AutomaticTasksPage.tsx` for frontend management of automatic tasks, including task creation, editing, and execution. - Integrated device and eSIM profile selection for task configuration. - Implemented automatic task scheduling and retry logic in the backend.
1396 lines
35 KiB
Go
1396 lines
35 KiB
Go
package vowifi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/csv"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"vocat/internal/modem"
|
|
)
|
|
|
|
var (
|
|
ErrEC20SIMNotReady = errors.New("vocat: EC20 SIM is not ready")
|
|
ErrEC20MNCUnavailable = errors.New("vocat: EC20 SIM does not expose an explicit MNC length")
|
|
ErrEC20ApplicationAbsent = errors.New("vocat: EC20 has no usable USIM or ISIM application")
|
|
ErrEC20IdentityChanged = errors.New("vocat: EC20 SIM identity changed during authentication")
|
|
ErrEC20AKACommand = errors.New("vocat: EC20 USIM AUTHENTICATE command failed")
|
|
ErrEC20AKAResponse = errors.New("vocat: EC20 returned an invalid USIM AUTHENTICATE response")
|
|
ErrEC20AKAMACFailure = errors.New("vocat: EC20 USIM rejected the network authentication token")
|
|
)
|
|
|
|
const (
|
|
usimAIDPrefix = "A0000000871002"
|
|
isimAIDPrefix = "A0000000871004"
|
|
efADDecimal = 28589 // 0x6FAD
|
|
channelCleanupTimeout = 3 * time.Second
|
|
)
|
|
|
|
// EC20ATExecutor is deliberately the same narrow shape as
|
|
// device.Manager.ExecuteAT. Production code passes the device manager directly;
|
|
// tests can use an evidence transcript without opening any serial device.
|
|
type EC20ATExecutor interface {
|
|
ExecuteAT(context.Context, string, string) (modem.Response, error)
|
|
}
|
|
|
|
// EC20SensitiveATExecutor is implemented by device.Manager so an APDU carrying
|
|
// RAND and AUTN is not retained as a device error. The fallback exists only for
|
|
// small deterministic test executors.
|
|
type EC20SensitiveATExecutor interface {
|
|
ExecuteSensitiveAT(context.Context, string, string) (modem.Response, error)
|
|
}
|
|
|
|
type EC20AdapterOptions struct {
|
|
// PureAirplanePolicy reports the independent user policy. The adapter only
|
|
// changes the transactional CFUN projection used by VoWiFi and never
|
|
// changes this policy.
|
|
PureAirplanePolicy func(deviceID string) bool
|
|
|
|
// HomePLMN supplies an explicit operator configuration when EF_AD omits
|
|
// the MNC length. The returned MCC/MNC must exactly prefix the live IMSI.
|
|
HomePLMN func(deviceID, iccid, imsi string) (mcc, mnc string, ok bool)
|
|
|
|
// RestoreCellularData permits reactivating PDP contexts that were active
|
|
// before the VoWiFi transaction. It is deliberately false by default:
|
|
// VoWiFi must never start billable cellular data unless an operator has
|
|
// explicitly opted in to that separate behavior.
|
|
RestoreCellularData bool
|
|
}
|
|
|
|
// EC20Adapter implements SIMIdentityReader, AKAProvider, and RadioController
|
|
// using standardized AT commands on the AT port selected by device.Manager.
|
|
// It never discovers or opens serial ports itself, so ttyUSB0 diagnostic cannot
|
|
// be selected accidentally.
|
|
type EC20Adapter struct {
|
|
executor EC20ATExecutor
|
|
options EC20AdapterOptions
|
|
|
|
apduMu sync.Mutex
|
|
mu sync.Mutex
|
|
bindings map[string]ec20SIMBinding
|
|
checkpoints map[string]ec20RadioCheckpoint
|
|
}
|
|
|
|
type ec20SIMBinding struct {
|
|
deviceID string
|
|
iccid string
|
|
imsi string
|
|
aid string
|
|
application string
|
|
basicChannel bool
|
|
}
|
|
|
|
type ec20RadioCheckpoint struct {
|
|
activeCIDs []int
|
|
}
|
|
|
|
var (
|
|
_ SIMIdentityReader = (*EC20Adapter)(nil)
|
|
_ AKAProvider = (*EC20Adapter)(nil)
|
|
_ RadioController = (*EC20Adapter)(nil)
|
|
)
|
|
|
|
func NewEC20Adapter(
|
|
executor EC20ATExecutor,
|
|
options EC20AdapterOptions,
|
|
) (*EC20Adapter, error) {
|
|
if executor == nil {
|
|
return nil, errors.New("vocat: EC20 AT executor is required")
|
|
}
|
|
return &EC20Adapter{
|
|
executor: executor,
|
|
options: options,
|
|
bindings: make(map[string]ec20SIMBinding),
|
|
checkpoints: make(map[string]ec20RadioCheckpoint),
|
|
}, nil
|
|
}
|
|
|
|
func (adapter *EC20Adapter) ReadIdentity(
|
|
ctx context.Context,
|
|
deviceID string,
|
|
) (SIMIdentity, error) {
|
|
deviceID = strings.TrimSpace(deviceID)
|
|
if deviceID == "" {
|
|
return SIMIdentity{}, errors.New("vocat: EC20 device ID is required")
|
|
}
|
|
|
|
pin, err := adapter.execute(ctx, deviceID, "AT+CPIN?")
|
|
if err != nil {
|
|
return SIMIdentity{}, fmt.Errorf("read EC20 SIM state: %w", err)
|
|
}
|
|
if !responseContainsValue(pin, "+CPIN:", "READY") {
|
|
return SIMIdentity{}, ErrEC20SIMNotReady
|
|
}
|
|
|
|
imsiResponse, err := adapter.execute(ctx, deviceID, "AT+CIMI")
|
|
if err != nil {
|
|
return SIMIdentity{}, fmt.Errorf("read EC20 IMSI: %w", err)
|
|
}
|
|
imsi := digitIdentifier(imsiResponse, []string{"+CIMI:"}, 10, 18)
|
|
if imsi == "" {
|
|
return SIMIdentity{}, errors.New("vocat: EC20 returned no valid IMSI")
|
|
}
|
|
iccid, err := adapter.readICCID(ctx, deviceID)
|
|
if err != nil {
|
|
return SIMIdentity{}, err
|
|
}
|
|
|
|
imeiResponse, err := adapter.execute(ctx, deviceID, "AT+CGSN")
|
|
if err != nil {
|
|
return SIMIdentity{}, fmt.Errorf("read EC20 IMEI: %w", err)
|
|
}
|
|
imei := digitIdentifier(imeiResponse, []string{"+CGSN:", "+GSN:"}, 14, 17)
|
|
if imei == "" {
|
|
return SIMIdentity{}, errors.New("vocat: EC20 returned no valid IMEI")
|
|
}
|
|
|
|
homeMCC, homeMNC, err := adapter.readHomePLMN(
|
|
ctx,
|
|
deviceID,
|
|
iccid,
|
|
imsi,
|
|
)
|
|
if err != nil {
|
|
return SIMIdentity{}, err
|
|
}
|
|
identity := SIMIdentity{
|
|
ICCID: iccid,
|
|
IMSI: imsi,
|
|
IMEI: imei,
|
|
HomeMCC: homeMCC,
|
|
HomeMNC: homeMNC,
|
|
}
|
|
adapter.mu.Lock()
|
|
adapter.bindings[iccid] = ec20SIMBinding{
|
|
deviceID: deviceID,
|
|
iccid: iccid,
|
|
imsi: imsi,
|
|
}
|
|
adapter.mu.Unlock()
|
|
return identity, nil
|
|
}
|
|
|
|
// ReadSMSCenter returns the service-centre address configured by the SIM.
|
|
// AT+CSCA? is read-only and remains available while cellular RF is disabled
|
|
// for a Wi-Fi Calling session.
|
|
func (adapter *EC20Adapter) ReadSMSCenter(ctx context.Context, deviceID string) (string, error) {
|
|
response, err := adapter.execute(ctx, strings.TrimSpace(deviceID), "AT+CSCA?")
|
|
if err != nil {
|
|
return "", fmt.Errorf("read EC20 SMS service centre: %w", err)
|
|
}
|
|
fields := parseCSV(valueAfterATPrefix(response, "+CSCA:"))
|
|
if len(fields) == 0 {
|
|
return "", errors.New("vocat: EC20 returned no SMS service-centre address")
|
|
}
|
|
value := strings.Trim(strings.TrimSpace(fields[0]), `"`)
|
|
digits := strings.TrimPrefix(value, "+")
|
|
if !validDigits(digits, 3, 20) {
|
|
return "", errors.New("vocat: EC20 returned an invalid SMS service-centre address")
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
func (adapter *EC20Adapter) readHomePLMN(
|
|
ctx context.Context,
|
|
deviceID string,
|
|
iccid string,
|
|
imsi string,
|
|
) (string, string, error) {
|
|
mncLength, efErr := adapter.readExplicitMNCLength(ctx, deviceID)
|
|
if efErr == nil {
|
|
if len(imsi) < 3+mncLength {
|
|
return "", "", errors.New(
|
|
"vocat: IMSI is shorter than the EF_AD home PLMN",
|
|
)
|
|
}
|
|
return imsi[:3], imsi[3 : 3+mncLength], nil
|
|
}
|
|
if adapter.options.HomePLMN != nil {
|
|
mcc, mnc, ok := adapter.options.HomePLMN(deviceID, iccid, imsi)
|
|
mcc = strings.TrimSpace(mcc)
|
|
mnc = strings.TrimSpace(mnc)
|
|
if ok && validConfiguredHomePLMN(imsi, mcc, mnc) {
|
|
return mcc, mnc, nil
|
|
}
|
|
}
|
|
// Exact assigned HPLMN prefixes are data, not an MNC-length heuristic. The
|
|
// target Vodafone UK SIM is 234/15. Unknown assignments remain fail-closed.
|
|
if mcc, mnc, ok := assignedHomePLMN(imsi); ok {
|
|
return mcc, mnc, nil
|
|
}
|
|
return "", "", efErr
|
|
}
|
|
|
|
func assignedHomePLMN(imsi string) (mcc, mnc string, ok bool) {
|
|
assignments := []struct {
|
|
prefix string
|
|
mncLength int
|
|
}{
|
|
{prefix: "20404", mncLength: 2}, // Vodafone NL core; some Lebara subscriptions.
|
|
{prefix: "23415", mncLength: 2}, // Vodafone UK.
|
|
{prefix: "23487", mncLength: 2}, // Lebara Mobile UK.
|
|
}
|
|
for _, assignment := range assignments {
|
|
if strings.HasPrefix(imsi, assignment.prefix) {
|
|
return imsi[:3], imsi[3 : 3+assignment.mncLength], true
|
|
}
|
|
}
|
|
return "", "", false
|
|
}
|
|
|
|
func validConfiguredHomePLMN(imsi, mcc, mnc string) bool {
|
|
if !validDigits(mcc, 3, 3) || !validDigits(mnc, 2, 3) {
|
|
return false
|
|
}
|
|
return strings.HasPrefix(imsi, mcc+mnc)
|
|
}
|
|
|
|
func (adapter *EC20Adapter) readExplicitMNCLength(
|
|
ctx context.Context,
|
|
deviceID string,
|
|
) (int, error) {
|
|
commands := []string{
|
|
fmt.Sprintf("AT+CRSM=176,%d,0,0,4", efADDecimal),
|
|
fmt.Sprintf("AT+CRSM=176,%d,0,0,0", efADDecimal),
|
|
}
|
|
var lastErr error
|
|
for _, command := range commands {
|
|
response, err := adapter.execute(ctx, deviceID, command)
|
|
if err != nil {
|
|
lastErr = err
|
|
continue
|
|
}
|
|
data, err := parseCRSMData(response)
|
|
if err != nil {
|
|
lastErr = err
|
|
continue
|
|
}
|
|
if len(data) < 4 {
|
|
lastErr = ErrEC20MNCUnavailable
|
|
continue
|
|
}
|
|
length := int(data[3] & 0x0f)
|
|
if length == 2 || length == 3 {
|
|
return length, nil
|
|
}
|
|
lastErr = ErrEC20MNCUnavailable
|
|
}
|
|
if lastErr == nil {
|
|
lastErr = ErrEC20MNCUnavailable
|
|
}
|
|
return 0, fmt.Errorf("%w: %v", ErrEC20MNCUnavailable, lastErr)
|
|
}
|
|
|
|
func (adapter *EC20Adapter) readICCID(
|
|
ctx context.Context,
|
|
deviceID string,
|
|
) (string, error) {
|
|
var lastErr error
|
|
for attempt := 0; attempt < 3; attempt++ {
|
|
for _, command := range []string{"AT+CCID", "AT+QCCID"} {
|
|
response, err := adapter.execute(ctx, deviceID, command)
|
|
if err != nil {
|
|
lastErr = err
|
|
continue
|
|
}
|
|
value := iccidIdentifier(
|
|
response,
|
|
[]string{"+CCID:", "+QCCID:"},
|
|
18,
|
|
22,
|
|
)
|
|
if value != "" {
|
|
return value, nil
|
|
}
|
|
lastErr = errors.New("response contained no valid ICCID")
|
|
}
|
|
if attempt < 2 {
|
|
select {
|
|
case <-ctx.Done():
|
|
return "", ctx.Err()
|
|
case <-time.After(100 * time.Millisecond):
|
|
}
|
|
}
|
|
}
|
|
return "", fmt.Errorf("read EC20 ICCID: %w", lastErr)
|
|
}
|
|
|
|
func (adapter *EC20Adapter) CheckReady(
|
|
ctx context.Context,
|
|
identity SIMIdentity,
|
|
) (AKAEvidence, error) {
|
|
binding, err := adapter.bindingFor(identity)
|
|
if err != nil {
|
|
return AKAEvidence{}, err
|
|
}
|
|
if err := adapter.verifyLiveICCID(ctx, binding); err != nil {
|
|
return AKAEvidence{}, err
|
|
}
|
|
|
|
// CCHO/CGLA/GET RESPONSE/CCHC is one UICC transaction. Serialize it
|
|
// across the adapter so periodic device refreshes or another AKA exchange
|
|
// cannot insert an APDU between a 61xx response and GET RESPONSE.
|
|
adapter.apduMu.Lock()
|
|
defer adapter.apduMu.Unlock()
|
|
|
|
aid, application, err := adapter.discoverAKAApplication(ctx, binding.deviceID)
|
|
if err != nil {
|
|
return AKAEvidence{}, err
|
|
}
|
|
channel, err := adapter.openLogicalChannel(ctx, binding.deviceID, aid)
|
|
basicChannel := false
|
|
if err == nil {
|
|
if err := adapter.closeLogicalChannelWithCleanup(
|
|
binding.deviceID,
|
|
channel,
|
|
); err != nil {
|
|
return AKAEvidence{}, err
|
|
}
|
|
} else {
|
|
// Several EC20 firmware branches reject CCHO/CGLA even though their
|
|
// basic-channel CSIM implementation is standards compliant.
|
|
if err := adapter.selectBasicApplication(
|
|
ctx,
|
|
binding.deviceID,
|
|
aid,
|
|
); err != nil {
|
|
return AKAEvidence{}, errors.Join(
|
|
ErrEC20ApplicationAbsent,
|
|
err,
|
|
)
|
|
}
|
|
basicChannel = true
|
|
}
|
|
|
|
binding.aid = aid
|
|
binding.application = application
|
|
binding.basicChannel = basicChannel
|
|
adapter.mu.Lock()
|
|
adapter.bindings[binding.iccid] = binding
|
|
adapter.mu.Unlock()
|
|
return AKAEvidence{Ready: true, Application: application}, nil
|
|
}
|
|
|
|
func (adapter *EC20Adapter) Authenticate(
|
|
ctx context.Context,
|
|
identity SIMIdentity,
|
|
challenge AKAChallenge,
|
|
) (AKAResult, error) {
|
|
binding, err := adapter.bindingFor(identity)
|
|
if err != nil {
|
|
return AKAResult{}, err
|
|
}
|
|
if binding.aid == "" {
|
|
if _, err := adapter.CheckReady(ctx, identity); err != nil {
|
|
return AKAResult{}, err
|
|
}
|
|
binding, err = adapter.bindingFor(identity)
|
|
if err != nil {
|
|
return AKAResult{}, err
|
|
}
|
|
}
|
|
if err := adapter.verifyLiveICCID(ctx, binding); err != nil {
|
|
return AKAResult{}, err
|
|
}
|
|
|
|
adapter.apduMu.Lock()
|
|
defer adapter.apduMu.Unlock()
|
|
|
|
apdu := buildUSIMAuthenticateAPDU(challenge)
|
|
var raw []byte
|
|
if binding.basicChannel {
|
|
if err := adapter.selectBasicApplication(
|
|
ctx,
|
|
binding.deviceID,
|
|
binding.aid,
|
|
); err != nil {
|
|
return AKAResult{}, err
|
|
}
|
|
raw, err = adapter.transmitBasicAPDU(
|
|
ctx,
|
|
binding.deviceID,
|
|
apdu,
|
|
true,
|
|
)
|
|
if err != nil {
|
|
return AKAResult{}, ErrEC20AKACommand
|
|
}
|
|
} else {
|
|
channel, err := adapter.openLogicalChannel(
|
|
ctx,
|
|
binding.deviceID,
|
|
binding.aid,
|
|
)
|
|
if err != nil {
|
|
return AKAResult{}, err
|
|
}
|
|
var commandErr error
|
|
raw, commandErr = adapter.transmitLogicalAPDU(
|
|
ctx,
|
|
binding.deviceID,
|
|
channel,
|
|
apdu,
|
|
true,
|
|
)
|
|
closeErr := adapter.closeLogicalChannelWithCleanup(
|
|
binding.deviceID,
|
|
channel,
|
|
)
|
|
if commandErr != nil {
|
|
if closeErr != nil {
|
|
return AKAResult{}, errors.Join(commandErr, closeErr)
|
|
}
|
|
return AKAResult{}, commandErr
|
|
}
|
|
if closeErr != nil {
|
|
return AKAResult{}, closeErr
|
|
}
|
|
}
|
|
return parseUSIMAuthenticateResponse(raw)
|
|
}
|
|
|
|
func buildUSIMAuthenticateAPDU(challenge AKAChallenge) []byte {
|
|
// TS 31.102 AUTHENTICATE, 3G security context (P2=0x81):
|
|
// Lc=34, then LV(RAND) and LV(AUTN), followed by Le.
|
|
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)
|
|
return apdu
|
|
}
|
|
|
|
func parseUSIMAuthenticateResponse(raw []byte) (AKAResult, error) {
|
|
if len(raw) < 2 {
|
|
return AKAResult{}, fmt.Errorf(
|
|
"%w: response length %d has no status word",
|
|
ErrEC20AKAResponse,
|
|
len(raw),
|
|
)
|
|
}
|
|
status := uint16(raw[len(raw)-2])<<8 | uint16(raw[len(raw)-1])
|
|
body := raw[:len(raw)-2]
|
|
if status != 0x9000 {
|
|
switch status {
|
|
case 0x9862:
|
|
return AKAResult{}, ErrEC20AKAMACFailure
|
|
default:
|
|
return AKAResult{}, fmt.Errorf(
|
|
"%w: status word %04X",
|
|
ErrEC20AKAResponse,
|
|
status,
|
|
)
|
|
}
|
|
}
|
|
if len(body) < 2 {
|
|
return AKAResult{}, fmt.Errorf(
|
|
"%w: response body length %d is too short",
|
|
ErrEC20AKAResponse,
|
|
len(body),
|
|
)
|
|
}
|
|
tag := body[0]
|
|
value := body[1:]
|
|
|
|
switch tag {
|
|
case 0xdb:
|
|
res, rest, ok := takeLV(value)
|
|
if !ok {
|
|
return AKAResult{}, fmt.Errorf(
|
|
"%w: malformed RES field in %d-byte success value",
|
|
ErrEC20AKAResponse,
|
|
len(value),
|
|
)
|
|
}
|
|
if len(res) < 4 || len(res) > 16 {
|
|
return AKAResult{}, fmt.Errorf(
|
|
"%w: invalid RES length %d",
|
|
ErrEC20AKAResponse,
|
|
len(res),
|
|
)
|
|
}
|
|
ck, rest, ok := takeLV(rest)
|
|
if !ok || len(ck) != 16 {
|
|
return AKAResult{}, fmt.Errorf(
|
|
"%w: invalid CK length %d",
|
|
ErrEC20AKAResponse,
|
|
len(ck),
|
|
)
|
|
}
|
|
ik, rest, ok := takeLV(rest)
|
|
if !ok || len(ik) != 16 {
|
|
return AKAResult{}, fmt.Errorf(
|
|
"%w: invalid IK length %d",
|
|
ErrEC20AKAResponse,
|
|
len(ik),
|
|
)
|
|
}
|
|
// Kc is present on many USIMs. It is not needed by EAP-AKA, but when
|
|
// present its LV still has to be structurally valid.
|
|
if len(rest) > 0 {
|
|
kc, tail, ok := takeLV(rest)
|
|
if !ok || len(kc) != 8 || len(tail) != 0 {
|
|
return AKAResult{}, fmt.Errorf(
|
|
"%w: invalid optional Kc length %d with %d trailing bytes",
|
|
ErrEC20AKAResponse,
|
|
len(kc),
|
|
len(tail),
|
|
)
|
|
}
|
|
}
|
|
return AKAResult{
|
|
RES: append([]byte(nil), res...),
|
|
CK: append([]byte(nil), ck...),
|
|
IK: append([]byte(nil), ik...),
|
|
}, nil
|
|
case 0xdc:
|
|
auts, tail, ok := takeLV(value)
|
|
if !ok || len(auts) != 14 || len(tail) != 0 {
|
|
return AKAResult{}, fmt.Errorf(
|
|
"%w: invalid AUTS length %d with %d trailing bytes",
|
|
ErrEC20AKAResponse,
|
|
len(auts),
|
|
len(tail),
|
|
)
|
|
}
|
|
return AKAResult{
|
|
AUTS: append([]byte(nil), auts...),
|
|
SynchronizationFailure: true,
|
|
}, nil
|
|
default:
|
|
return AKAResult{}, fmt.Errorf(
|
|
"%w: unsupported response tag %02X with %d value bytes",
|
|
ErrEC20AKAResponse,
|
|
tag,
|
|
len(value),
|
|
)
|
|
}
|
|
}
|
|
|
|
func takeLV(value []byte) (field, rest []byte, ok bool) {
|
|
if len(value) == 0 {
|
|
return nil, value, false
|
|
}
|
|
length := int(value[0])
|
|
if length > len(value)-1 {
|
|
return nil, value, false
|
|
}
|
|
return value[1 : 1+length], value[1+length:], true
|
|
}
|
|
|
|
func (adapter *EC20Adapter) Snapshot(
|
|
ctx context.Context,
|
|
deviceID string,
|
|
) (RadioSnapshot, error) {
|
|
mode, err := adapter.readOperatingMode(ctx, deviceID)
|
|
if err != nil {
|
|
return RadioSnapshot{}, err
|
|
}
|
|
active, err := adapter.readActiveCIDs(ctx, deviceID)
|
|
if err != nil {
|
|
return RadioSnapshot{}, err
|
|
}
|
|
adapter.mu.Lock()
|
|
adapter.checkpoints[deviceID] = ec20RadioCheckpoint{
|
|
activeCIDs: append([]int(nil), active...),
|
|
}
|
|
adapter.mu.Unlock()
|
|
|
|
purePolicy := false
|
|
if adapter.options.PureAirplanePolicy != nil {
|
|
purePolicy = adapter.options.PureAirplanePolicy(deviceID)
|
|
}
|
|
return RadioSnapshot{
|
|
CellularDataEnabled: len(active) > 0,
|
|
OperatingMode: mode,
|
|
PureAirplanePolicy: purePolicy,
|
|
}, nil
|
|
}
|
|
|
|
func (adapter *EC20Adapter) StopCellularData(
|
|
ctx context.Context,
|
|
deviceID string,
|
|
) error {
|
|
active, err := adapter.readActiveCIDs(ctx, deviceID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, cid := range active {
|
|
if _, err := adapter.execute(
|
|
ctx,
|
|
deviceID,
|
|
fmt.Sprintf("AT+CGACT=0,%d", cid),
|
|
); err != nil {
|
|
return fmt.Errorf("deactivate EC20 PDP context %d: %w", cid, err)
|
|
}
|
|
}
|
|
remaining, err := adapter.readActiveCIDs(ctx, deviceID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(remaining) != 0 {
|
|
return errors.New("vocat: EC20 cellular data remained active")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (adapter *EC20Adapter) EnterVoWiFiRFOff(
|
|
ctx context.Context,
|
|
deviceID string,
|
|
) error {
|
|
mode, err := adapter.readOperatingMode(ctx, deviceID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if mode != 4 {
|
|
if _, err := adapter.execute(ctx, deviceID, "AT+CFUN=4"); err != nil {
|
|
return fmt.Errorf("enter EC20 RF-off mode: %w", err)
|
|
}
|
|
}
|
|
mode, err = adapter.readOperatingMode(ctx, deviceID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if mode != 4 {
|
|
return fmt.Errorf("vocat: EC20 reported CFUN=%d after RF-off request", mode)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (adapter *EC20Adapter) Restore(
|
|
ctx context.Context,
|
|
deviceID string,
|
|
snapshot RadioSnapshot,
|
|
) error {
|
|
if snapshot.OperatingMode < 0 {
|
|
return errors.New("vocat: invalid EC20 radio snapshot")
|
|
}
|
|
targetMode := snapshot.OperatingMode
|
|
if snapshot.PureAirplanePolicy {
|
|
// VoWiFi teardown is intentionally fail-closed. Even if the modem was in
|
|
// CFUN=1 before setup, disabling VoWiFi must leave RF off until the user
|
|
// explicitly turns airplane mode off through the separate control.
|
|
targetMode = 4
|
|
}
|
|
currentMode, err := adapter.readOperatingMode(ctx, deviceID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if currentMode != targetMode {
|
|
if _, err := adapter.execute(
|
|
ctx,
|
|
deviceID,
|
|
fmt.Sprintf("AT+CFUN=%d", targetMode),
|
|
); err != nil {
|
|
return fmt.Errorf("restore EC20 operating mode: %w", err)
|
|
}
|
|
}
|
|
currentMode, err = adapter.readOperatingMode(ctx, deviceID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if currentMode != targetMode {
|
|
return fmt.Errorf(
|
|
"vocat: EC20 restore reported CFUN=%d, expected %d",
|
|
currentMode,
|
|
targetMode,
|
|
)
|
|
}
|
|
|
|
adapter.mu.Lock()
|
|
checkpoint, found := adapter.checkpoints[deviceID]
|
|
adapter.mu.Unlock()
|
|
if !found {
|
|
if snapshot.CellularDataEnabled {
|
|
return errors.New("vocat: EC20 PDP restore evidence is unavailable")
|
|
}
|
|
checkpoint.activeCIDs = nil
|
|
}
|
|
if (snapshot.OperatingMode == 0 || snapshot.OperatingMode == 4) &&
|
|
len(checkpoint.activeCIDs) > 0 {
|
|
return errors.New("vocat: EC20 snapshot has active data in RF-off mode")
|
|
}
|
|
desiredCIDs := checkpoint.activeCIDs
|
|
if !adapter.options.RestoreCellularData || targetMode == 0 || targetMode == 4 {
|
|
desiredCIDs = nil
|
|
}
|
|
if err := adapter.reconcileActiveCIDs(
|
|
ctx,
|
|
deviceID,
|
|
desiredCIDs,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
adapter.mu.Lock()
|
|
delete(adapter.checkpoints, deviceID)
|
|
adapter.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func (adapter *EC20Adapter) reconcileActiveCIDs(
|
|
ctx context.Context,
|
|
deviceID string,
|
|
desired []int,
|
|
) error {
|
|
current, err := adapter.readActiveCIDs(ctx, deviceID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
desiredSet := integerSet(desired)
|
|
currentSet := integerSet(current)
|
|
for _, cid := range current {
|
|
if _, wanted := desiredSet[cid]; wanted {
|
|
continue
|
|
}
|
|
if _, err := adapter.execute(
|
|
ctx,
|
|
deviceID,
|
|
fmt.Sprintf("AT+CGACT=0,%d", cid),
|
|
); err != nil {
|
|
return fmt.Errorf("restore EC20 PDP context %d: %w", cid, err)
|
|
}
|
|
}
|
|
for _, cid := range desired {
|
|
if _, active := currentSet[cid]; active {
|
|
continue
|
|
}
|
|
if _, err := adapter.execute(
|
|
ctx,
|
|
deviceID,
|
|
fmt.Sprintf("AT+CGACT=1,%d", cid),
|
|
); err != nil {
|
|
return fmt.Errorf("restore EC20 PDP context %d: %w", cid, err)
|
|
}
|
|
}
|
|
verified, err := adapter.readActiveCIDs(ctx, deviceID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !sameIntegers(verified, desired) {
|
|
return fmt.Errorf(
|
|
"vocat: EC20 PDP restore mismatch: active contexts %v",
|
|
verified,
|
|
)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (adapter *EC20Adapter) readOperatingMode(
|
|
ctx context.Context,
|
|
deviceID string,
|
|
) (int, error) {
|
|
response, err := adapter.execute(ctx, deviceID, "AT+CFUN?")
|
|
if err != nil {
|
|
return 0, fmt.Errorf("read EC20 operating mode: %w", err)
|
|
}
|
|
value := valueAfterATPrefix(response, "+CFUN:")
|
|
fields := parseCSV(value)
|
|
if len(fields) == 0 {
|
|
return 0, errors.New("vocat: EC20 returned no CFUN mode")
|
|
}
|
|
mode, err := strconv.Atoi(fields[0])
|
|
if err != nil || mode < 0 {
|
|
return 0, errors.New("vocat: EC20 returned an invalid CFUN mode")
|
|
}
|
|
return mode, nil
|
|
}
|
|
|
|
func (adapter *EC20Adapter) readActiveCIDs(
|
|
ctx context.Context,
|
|
deviceID string,
|
|
) ([]int, error) {
|
|
response, err := adapter.execute(ctx, deviceID, "AT+CGACT?")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read EC20 PDP contexts: %w", err)
|
|
}
|
|
var active []int
|
|
for _, line := range response.Lines {
|
|
line = strings.TrimSpace(line)
|
|
if !strings.HasPrefix(strings.ToUpper(line), "+CGACT:") {
|
|
continue
|
|
}
|
|
fields := parseCSV(strings.TrimSpace(line[len("+CGACT:"):]))
|
|
if len(fields) < 2 {
|
|
return nil, errors.New("vocat: EC20 returned an invalid CGACT record")
|
|
}
|
|
cid, cidErr := strconv.Atoi(fields[0])
|
|
state, stateErr := strconv.Atoi(fields[1])
|
|
if cidErr != nil || stateErr != nil || cid <= 0 || (state != 0 && state != 1) {
|
|
return nil, errors.New("vocat: EC20 returned an invalid CGACT record")
|
|
}
|
|
if state == 1 {
|
|
active = append(active, cid)
|
|
}
|
|
}
|
|
sort.Ints(active)
|
|
return uniqueIntegers(active), nil
|
|
}
|
|
|
|
func (adapter *EC20Adapter) bindingFor(
|
|
identity SIMIdentity,
|
|
) (ec20SIMBinding, error) {
|
|
iccid := strings.TrimSpace(identity.ICCID)
|
|
adapter.mu.Lock()
|
|
binding, ok := adapter.bindings[iccid]
|
|
adapter.mu.Unlock()
|
|
if !ok || iccid == "" {
|
|
return ec20SIMBinding{}, errors.New("vocat: EC20 SIM identity is not bound to a device")
|
|
}
|
|
if strings.TrimSpace(identity.IMSI) != binding.imsi {
|
|
return ec20SIMBinding{}, ErrEC20IdentityChanged
|
|
}
|
|
return binding, nil
|
|
}
|
|
|
|
func (adapter *EC20Adapter) verifyLiveICCID(
|
|
ctx context.Context,
|
|
binding ec20SIMBinding,
|
|
) error {
|
|
iccid, err := adapter.readICCID(ctx, binding.deviceID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if iccid != binding.iccid {
|
|
return ErrEC20IdentityChanged
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (adapter *EC20Adapter) discoverAKAApplication(
|
|
ctx context.Context,
|
|
deviceID string,
|
|
) (aid string, application string, err error) {
|
|
response, cuadErr := adapter.execute(ctx, deviceID, "AT+CUAD")
|
|
if cuadErr == nil {
|
|
data, parseErr := parseCUADData(response)
|
|
if parseErr == nil {
|
|
aids := collectApplicationAIDs(data)
|
|
for _, candidate := range aids {
|
|
if strings.HasPrefix(candidate, usimAIDPrefix) {
|
|
return candidate, "USIM", nil
|
|
}
|
|
}
|
|
for _, candidate := range aids {
|
|
if strings.HasPrefix(candidate, isimAIDPrefix) {
|
|
return candidate, "ISIM", nil
|
|
}
|
|
}
|
|
if len(aids) > 0 {
|
|
return "", "", ErrEC20ApplicationAbsent
|
|
}
|
|
}
|
|
}
|
|
|
|
// AT+CUAD is optional on older EC20 firmware. CCHO still provides a
|
|
// standards-based, evidence-bearing probe of the assigned USIM AID.
|
|
return usimAIDPrefix, "USIM", nil
|
|
}
|
|
|
|
func (adapter *EC20Adapter) openLogicalChannel(
|
|
ctx context.Context,
|
|
deviceID string,
|
|
aid string,
|
|
) (int, error) {
|
|
response, err := adapter.execute(
|
|
ctx,
|
|
deviceID,
|
|
fmt.Sprintf(`AT+CCHO="%s"`, aid),
|
|
)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("%w: open application", ErrEC20ApplicationAbsent)
|
|
}
|
|
value := valueAfterATPrefix(response, "+CCHO:")
|
|
if value == "" {
|
|
for _, line := range response.Lines {
|
|
line = strings.TrimSpace(line)
|
|
if _, parseErr := strconv.Atoi(line); parseErr == nil {
|
|
value = line
|
|
break
|
|
}
|
|
}
|
|
}
|
|
channel, err := strconv.Atoi(strings.TrimSpace(value))
|
|
if err != nil || channel < 1 || channel > 19 {
|
|
return 0, errors.New("vocat: EC20 returned an invalid logical channel")
|
|
}
|
|
return channel, nil
|
|
}
|
|
|
|
func (adapter *EC20Adapter) closeLogicalChannel(
|
|
ctx context.Context,
|
|
deviceID string,
|
|
channel int,
|
|
) error {
|
|
if _, err := adapter.execute(
|
|
ctx,
|
|
deviceID,
|
|
fmt.Sprintf("AT+CCHC=%d", channel),
|
|
); err != nil {
|
|
return fmt.Errorf("close EC20 logical channel: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (adapter *EC20Adapter) closeLogicalChannelWithCleanup(
|
|
deviceID string,
|
|
channel int,
|
|
) error {
|
|
ctx, cancel := context.WithTimeout(
|
|
context.Background(),
|
|
channelCleanupTimeout,
|
|
)
|
|
defer cancel()
|
|
return adapter.closeLogicalChannel(ctx, deviceID, channel)
|
|
}
|
|
|
|
func (adapter *EC20Adapter) selectBasicApplication(
|
|
ctx context.Context,
|
|
deviceID string,
|
|
aid string,
|
|
) error {
|
|
aidBytes, err := hex.DecodeString(aid)
|
|
if err != nil || len(aidBytes) == 0 || len(aidBytes) > 255 {
|
|
return errors.New("vocat: invalid USIM application identifier")
|
|
}
|
|
// SELECT by DF name, request the first/only matching application and FCP.
|
|
apdu := []byte{0x00, 0xa4, 0x04, 0x04, byte(len(aidBytes))}
|
|
apdu = append(apdu, aidBytes...)
|
|
raw, err := adapter.transmitBasicAPDU(ctx, deviceID, apdu, false)
|
|
if err != nil {
|
|
return fmt.Errorf("select EC20 basic-channel application: %w", err)
|
|
}
|
|
_, status, err := splitAPDUStatus(raw)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if status != 0x9000 {
|
|
return fmt.Errorf(
|
|
"vocat: EC20 basic-channel SELECT returned %04X",
|
|
status,
|
|
)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (adapter *EC20Adapter) transmitBasicAPDU(
|
|
ctx context.Context,
|
|
deviceID string,
|
|
apdu []byte,
|
|
sensitive bool,
|
|
) ([]byte, error) {
|
|
if len(apdu) == 0 || len(apdu) > 261 {
|
|
return nil, errors.New("vocat: invalid EC20 APDU length")
|
|
}
|
|
var collected []byte
|
|
current := append([]byte(nil), apdu...)
|
|
for exchange := 0; exchange < 4; exchange++ {
|
|
command := fmt.Sprintf(
|
|
`AT+CSIM=%d,"%s"`,
|
|
len(current)*2,
|
|
strings.ToUpper(hex.EncodeToString(current)),
|
|
)
|
|
var response modem.Response
|
|
var err error
|
|
if sensitive {
|
|
response, err = adapter.executeSensitive(ctx, deviceID, command)
|
|
} else {
|
|
response, err = adapter.execute(ctx, deviceID, command)
|
|
}
|
|
if err != nil {
|
|
return nil, errors.New("vocat: EC20 CSIM exchange failed")
|
|
}
|
|
raw, err := parseCSIMData(response)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
body, status, err := splitAPDUStatus(raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
collected = append(collected, body...)
|
|
sw1 := byte(status >> 8)
|
|
if sw1 != 0x61 && sw1 != 0x9f {
|
|
collected = append(collected, byte(status>>8), byte(status))
|
|
return collected, nil
|
|
}
|
|
// GET RESPONSE on the same basic channel. Le=0 means 256 bytes.
|
|
current = []byte{0x00, 0xc0, 0x00, 0x00, byte(status)}
|
|
}
|
|
return nil, errors.New("vocat: EC20 APDU response chaining exceeded limit")
|
|
}
|
|
|
|
func (adapter *EC20Adapter) transmitLogicalAPDU(
|
|
ctx context.Context,
|
|
deviceID string,
|
|
channel int,
|
|
apdu []byte,
|
|
sensitive bool,
|
|
) ([]byte, error) {
|
|
if channel < 1 || channel > 19 || len(apdu) == 0 || len(apdu) > 261 {
|
|
return nil, ErrEC20AKACommand
|
|
}
|
|
var collected []byte
|
|
current := append([]byte(nil), apdu...)
|
|
for exchange := 0; exchange < 4; exchange++ {
|
|
command := fmt.Sprintf(
|
|
`AT+CGLA=%d,%d,"%s"`,
|
|
channel,
|
|
len(current)*2,
|
|
strings.ToUpper(hex.EncodeToString(current)),
|
|
)
|
|
var response modem.Response
|
|
var err error
|
|
if sensitive {
|
|
response, err = adapter.executeSensitive(ctx, deviceID, command)
|
|
} else {
|
|
response, err = adapter.execute(ctx, deviceID, command)
|
|
}
|
|
if err != nil {
|
|
return nil, errors.Join(ErrEC20AKACommand, err)
|
|
}
|
|
raw, err := parseCGLAData(response)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrEC20AKAResponse, err)
|
|
}
|
|
body, status, err := splitAPDUStatus(raw)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrEC20AKAResponse, err)
|
|
}
|
|
collected = append(collected, body...)
|
|
sw1 := byte(status >> 8)
|
|
if sw1 != 0x61 && sw1 != 0x9f {
|
|
collected = append(collected, byte(status>>8), byte(status))
|
|
return collected, nil
|
|
}
|
|
// CGLA carries the logical-channel identifier separately, so GET
|
|
// RESPONSE retains the same interindustry CLA used by AUTHENTICATE.
|
|
// Le=0 means 256 bytes when SW2 is zero.
|
|
current = []byte{0x00, 0xc0, 0x00, 0x00, byte(status)}
|
|
}
|
|
return nil, fmt.Errorf(
|
|
"%w: logical-channel response chaining exceeded limit",
|
|
ErrEC20AKAResponse,
|
|
)
|
|
}
|
|
|
|
func splitAPDUStatus(raw []byte) ([]byte, uint16, error) {
|
|
if len(raw) < 2 {
|
|
return nil, 0, errors.New("vocat: EC20 APDU has no status word")
|
|
}
|
|
status := uint16(raw[len(raw)-2])<<8 | uint16(raw[len(raw)-1])
|
|
return raw[:len(raw)-2], status, nil
|
|
}
|
|
|
|
func (adapter *EC20Adapter) execute(
|
|
ctx context.Context,
|
|
deviceID string,
|
|
command string,
|
|
) (modem.Response, error) {
|
|
return adapter.executor.ExecuteAT(ctx, deviceID, command)
|
|
}
|
|
|
|
func (adapter *EC20Adapter) executeSensitive(
|
|
ctx context.Context,
|
|
deviceID string,
|
|
command string,
|
|
) (modem.Response, error) {
|
|
if executor, ok := adapter.executor.(EC20SensitiveATExecutor); ok {
|
|
return executor.ExecuteSensitiveAT(ctx, deviceID, command)
|
|
}
|
|
return adapter.executor.ExecuteAT(ctx, deviceID, command)
|
|
}
|
|
|
|
func parseCRSMData(response modem.Response) ([]byte, error) {
|
|
value := valueAfterATPrefix(response, "+CRSM:")
|
|
fields := parseCSV(value)
|
|
if len(fields) < 2 {
|
|
return nil, errors.New("invalid CRSM response")
|
|
}
|
|
sw1, err1 := strconv.Atoi(fields[0])
|
|
sw2, err2 := strconv.Atoi(fields[1])
|
|
if err1 != nil || err2 != nil {
|
|
return nil, errors.New("invalid CRSM status")
|
|
}
|
|
if sw1 != 0x90 && sw1 != 0x91 && sw1 != 0x9f {
|
|
return nil, fmt.Errorf("CRSM status %02X%02X", sw1, sw2)
|
|
}
|
|
if len(fields) < 3 {
|
|
return nil, errors.New("CRSM response has no data")
|
|
}
|
|
data, err := hex.DecodeString(strings.Trim(fields[2], `"`))
|
|
if err != nil {
|
|
return nil, errors.New("CRSM response data is not hexadecimal")
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
func parseCUADData(response modem.Response) ([]byte, error) {
|
|
fields := parseCSV(valueAfterATPrefix(response, "+CUAD:"))
|
|
if len(fields) == 0 {
|
|
return nil, errors.New("CUAD response has no data")
|
|
}
|
|
value := fields[len(fields)-1]
|
|
data, err := hex.DecodeString(strings.Trim(value, `"`))
|
|
if err != nil || len(data) == 0 {
|
|
return nil, errors.New("CUAD response data is invalid")
|
|
}
|
|
if len(data) >= 2 && data[len(data)-2] == 0x90 && data[len(data)-1] == 0x00 {
|
|
data = data[:len(data)-2]
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
func collectApplicationAIDs(data []byte) []string {
|
|
var result []string
|
|
var walk func([]byte)
|
|
walk = func(value []byte) {
|
|
for len(value) > 0 {
|
|
tag, constructed, body, consumed, err := decodeBERTLV(value)
|
|
if err != nil || consumed == 0 {
|
|
return
|
|
}
|
|
if len(tag) == 1 && tag[0] == 0x4f && len(body) > 0 {
|
|
result = append(result, strings.ToUpper(hex.EncodeToString(body)))
|
|
}
|
|
if constructed {
|
|
walk(body)
|
|
}
|
|
value = value[consumed:]
|
|
}
|
|
}
|
|
walk(data)
|
|
return result
|
|
}
|
|
|
|
func decodeBERTLV(data []byte) (
|
|
tag []byte,
|
|
constructed bool,
|
|
value []byte,
|
|
consumed int,
|
|
err error,
|
|
) {
|
|
if len(data) < 2 {
|
|
return nil, false, nil, 0, errors.New("short BER-TLV")
|
|
}
|
|
tagLength := 1
|
|
if data[0]&0x1f == 0x1f {
|
|
for {
|
|
if tagLength >= len(data) {
|
|
return nil, false, nil, 0, errors.New("short BER tag")
|
|
}
|
|
last := data[tagLength]&0x80 == 0
|
|
tagLength++
|
|
if last {
|
|
break
|
|
}
|
|
if tagLength > 4 {
|
|
return nil, false, nil, 0, errors.New("oversized BER tag")
|
|
}
|
|
}
|
|
}
|
|
body, bodyConsumed, err := decodeBERTLVValue(data[tagLength:])
|
|
if err != nil {
|
|
return nil, false, nil, 0, err
|
|
}
|
|
return data[:tagLength],
|
|
data[0]&0x20 != 0,
|
|
body,
|
|
tagLength + bodyConsumed,
|
|
nil
|
|
}
|
|
|
|
func decodeBERTLVValue(data []byte) ([]byte, int, error) {
|
|
if len(data) == 0 {
|
|
return nil, 0, errors.New("missing BER length")
|
|
}
|
|
length := int(data[0])
|
|
lengthOctets := 1
|
|
if data[0]&0x80 != 0 {
|
|
count := int(data[0] & 0x7f)
|
|
if count == 0 || count > 2 || len(data) < 1+count {
|
|
return nil, 0, errors.New("invalid BER length")
|
|
}
|
|
length = 0
|
|
for _, octet := range data[1 : 1+count] {
|
|
length = length<<8 | int(octet)
|
|
}
|
|
lengthOctets += count
|
|
}
|
|
if length > len(data)-lengthOctets {
|
|
return nil, 0, errors.New("short BER value")
|
|
}
|
|
return data[lengthOctets : lengthOctets+length],
|
|
lengthOctets + length,
|
|
nil
|
|
}
|
|
|
|
func parseCGLAData(response modem.Response) ([]byte, error) {
|
|
return parseATAPDUData(response, "+CGLA:")
|
|
}
|
|
|
|
func parseCSIMData(response modem.Response) ([]byte, error) {
|
|
return parseATAPDUData(response, "+CSIM:")
|
|
}
|
|
|
|
func parseATAPDUData(response modem.Response, prefix string) ([]byte, error) {
|
|
fields := parseCSV(valueAfterATPrefix(response, prefix))
|
|
if len(fields) < 2 {
|
|
return nil, errors.New("EC20 response has no APDU")
|
|
}
|
|
declared, err := strconv.Atoi(fields[0])
|
|
if err != nil || declared < 0 {
|
|
return nil, errors.New("EC20 response has invalid APDU length")
|
|
}
|
|
encoded := strings.Trim(fields[1], `" `)
|
|
data, err := hex.DecodeString(encoded)
|
|
if err != nil {
|
|
return nil, errors.New("EC20 APDU response is not hexadecimal")
|
|
}
|
|
if declared != len(encoded) && declared != len(data) {
|
|
return nil, errors.New("EC20 APDU response length mismatch")
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
func responseContainsValue(
|
|
response modem.Response,
|
|
prefix string,
|
|
expected string,
|
|
) bool {
|
|
return strings.EqualFold(
|
|
strings.Trim(strings.TrimSpace(valueAfterATPrefix(response, prefix)), `"`),
|
|
expected,
|
|
)
|
|
}
|
|
|
|
func valueAfterATPrefix(response modem.Response, prefix string) string {
|
|
for _, line := range response.Lines {
|
|
line = strings.TrimSpace(line)
|
|
if strings.HasPrefix(strings.ToUpper(line), strings.ToUpper(prefix)) {
|
|
return strings.TrimSpace(line[len(prefix):])
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func digitIdentifier(
|
|
response modem.Response,
|
|
prefixes []string,
|
|
minimum int,
|
|
maximum int,
|
|
) string {
|
|
for _, prefix := range prefixes {
|
|
value := strings.Trim(valueAfterATPrefix(response, prefix), `" `)
|
|
if validDigits(value, minimum, maximum) {
|
|
return value
|
|
}
|
|
}
|
|
for _, line := range response.Lines {
|
|
value := strings.TrimSpace(line)
|
|
if validDigits(value, minimum, maximum) {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func iccidIdentifier(
|
|
response modem.Response,
|
|
prefixes []string,
|
|
minimum int,
|
|
maximum int,
|
|
) string {
|
|
normalize := func(value string) string {
|
|
value = strings.Trim(value, `" `)
|
|
value = strings.TrimRight(value, "Ff")
|
|
if validDigits(value, minimum, maximum) {
|
|
return value
|
|
}
|
|
return ""
|
|
}
|
|
for _, prefix := range prefixes {
|
|
if value := normalize(valueAfterATPrefix(response, prefix)); value != "" {
|
|
return value
|
|
}
|
|
}
|
|
for _, line := range response.Lines {
|
|
if value := normalize(strings.TrimSpace(line)); value != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func validDigits(value string, minimum int, maximum int) bool {
|
|
if len(value) < minimum || len(value) > maximum {
|
|
return false
|
|
}
|
|
for _, character := range value {
|
|
if character < '0' || character > '9' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func parseCSV(value string) []string {
|
|
reader := csv.NewReader(strings.NewReader(value))
|
|
reader.TrimLeadingSpace = true
|
|
reader.LazyQuotes = true
|
|
record, err := reader.Read()
|
|
if err != nil && err != io.EOF {
|
|
return nil
|
|
}
|
|
for index := range record {
|
|
record[index] = strings.TrimSpace(record[index])
|
|
}
|
|
return record
|
|
}
|
|
|
|
func integerSet(values []int) map[int]struct{} {
|
|
result := make(map[int]struct{}, len(values))
|
|
for _, value := range values {
|
|
result[value] = struct{}{}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func uniqueIntegers(values []int) []int {
|
|
if len(values) < 2 {
|
|
return values
|
|
}
|
|
result := values[:1]
|
|
for _, value := range values[1:] {
|
|
if value != result[len(result)-1] {
|
|
result = append(result, value)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func sameIntegers(left, right []int) bool {
|
|
left = append([]int(nil), left...)
|
|
right = append([]int(nil), right...)
|
|
sort.Ints(left)
|
|
sort.Ints(right)
|
|
left = uniqueIntegers(left)
|
|
right = uniqueIntegers(right)
|
|
if len(left) != len(right) {
|
|
return false
|
|
}
|
|
for index := range left {
|
|
if left[index] != right[index] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|