Files
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

375 lines
9.9 KiB
Go

package device
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"strconv"
"strings"
"time"
"unicode/utf16"
"vocat/internal/modem"
)
// USSD dialog states derived from the +CUSD <n> result code.
const (
ussdStatusFinal = "final" // 0: no further user action required
ussdStatusAwaitingInput = "awaiting_input" // 1: network expects more input
ussdStatusTerminated = "terminated" // 2: terminated by the network
ussdStatusFailed = "failed" // 3/4/5: local answer, unsupported, or timeout
)
// USSD begins a USSD dialog. When the network expects more input the result
// carries an "awaiting_input" status and a session id that can be continued or
// cancelled; otherwise the dialog is complete and self-contained.
func (manager *Manager) USSD(
ctx context.Context,
id string,
code string,
) (USSDResult, error) {
code = strings.TrimSpace(code)
if !validServiceCode(code) {
return USSDResult{}, errors.New("invalid USSD service code")
}
result, err := manager.runUSSD(ctx, id, fmt.Sprintf(`AT+CUSD=1,"%s",15`, code))
if err != nil {
return result, err
}
result.Code = code
if result.Status == ussdStatusAwaitingInput {
result.SessionID = manager.openUSSDSession(id)
result.Continueable = true
}
return result, nil
}
// ContinueUSSD sends follow-up input on an open USSD dialog.
func (manager *Manager) ContinueUSSD(
ctx context.Context,
sessionID string,
input string,
) (USSDResult, error) {
deviceID, err := manager.ussdSessionDevice(sessionID)
if err != nil {
return USSDResult{}, err
}
input = strings.TrimSpace(input)
if input == "" || len(input) > 182 || strings.ContainsAny(input, "\"\r\n") {
return USSDResult{}, errors.New("invalid USSD input")
}
result, err := manager.runUSSD(ctx, deviceID, fmt.Sprintf(`AT+CUSD=1,"%s",15`, input))
if err != nil {
return result, err
}
result.Code = input
if result.Status == ussdStatusAwaitingInput {
result.SessionID = sessionID
result.Continueable = true
} else {
manager.dropUSSDSession(sessionID)
}
return result, nil
}
// CancelUSSD terminates an open USSD dialog with AT+CUSD=2. A session abort
// returns OK without a +CUSD result, so unlike start/continue it does not wait
// for an unsolicited response.
func (manager *Manager) CancelUSSD(ctx context.Context, sessionID string) error {
deviceID, err := manager.ussdSessionDevice(sessionID)
if err != nil {
return err
}
defer manager.dropUSSDSession(sessionID)
state, err := manager.lookup(deviceID)
if err != nil {
return err
}
state.opMu.Lock()
defer state.opMu.Unlock()
if err := manager.validateActive(deviceID, state); err != nil {
return err
}
client, err := manager.clientLocked(ctx, state, manager.candidateFor(state))
if err != nil {
manager.setResult(deviceID, state, nil, err)
return err
}
commandCtx, cancel := manager.withTimeout(ctx, manager.commandTimeout)
_, err = client.Execute(commandCtx, "AT+CUSD=2")
cancel()
manager.setResult(deviceID, state, nil, err)
return err
}
// runUSSD issues one CUSD command and waits for the +CUSD unsolicited result.
func (manager *Manager) runUSSD(
ctx context.Context,
id string,
command string,
) (USSDResult, error) {
state, err := manager.lookup(id)
if err != nil {
return USSDResult{}, err
}
state.opMu.Lock()
defer state.opMu.Unlock()
if err := manager.validateActive(id, state); err != nil {
return USSDResult{}, err
}
client, err := manager.clientLocked(ctx, state, manager.candidateFor(state))
if err != nil {
manager.setResult(id, state, nil, err)
return USSDResult{}, err
}
commandCtx, cancelCommand := manager.withTimeout(ctx, manager.commandTimeout)
_, err = client.Execute(commandCtx, command)
cancelCommand()
if err != nil {
manager.setResult(id, state, nil, err)
return USSDResult{}, err
}
waitCtx, cancelWait := manager.withTimeout(ctx, manager.longTimeout)
defer cancelWait()
line, err := client.WaitURC(waitCtx, func(line string) bool {
return strings.HasPrefix(strings.ToUpper(strings.TrimSpace(line)), "+CUSD:")
})
if err != nil {
manager.setResult(id, state, nil, err)
return USSDResult{}, err
}
result, err := parseUSSDResponse(line)
manager.setResult(id, state, nil, err)
return result, err
}
func (manager *Manager) openUSSDSession(deviceID string) string {
var token [8]byte
_, _ = rand.Read(token[:])
id := hex.EncodeToString(token[:])
manager.mu.Lock()
manager.ussdSessions[id] = ussdSession{deviceID: deviceID, createdAt: time.Now().UTC()}
manager.mu.Unlock()
return id
}
func (manager *Manager) ussdSessionDevice(sessionID string) (string, error) {
manager.mu.RLock()
session, ok := manager.ussdSessions[strings.TrimSpace(sessionID)]
manager.mu.RUnlock()
if !ok {
return "", ErrUSSDSessionNotFound
}
return session.deviceID, nil
}
func (manager *Manager) dropUSSDSession(sessionID string) {
manager.mu.Lock()
delete(manager.ussdSessions, strings.TrimSpace(sessionID))
manager.mu.Unlock()
}
// parseUSSDResponse parses a +CUSD unsolicited result line, capturing the dialog
// state byte, the decoded text, and the data coding scheme.
func parseUSSDResponse(line string) (USSDResult, error) {
if !strings.HasPrefix(strings.ToUpper(strings.TrimSpace(line)), "+CUSD:") {
return USSDResult{}, errors.New("invalid CUSD response")
}
values := csvValues(strings.TrimSpace(strings.SplitN(line, ":", 2)[1]))
if len(values) < 2 {
return USSDResult{}, errors.New("CUSD response has no text")
}
code, _ := strconv.Atoi(strings.TrimSpace(values[0]))
payload := strings.Trim(values[1], `"`)
var dcs *int
if len(values) >= 3 {
if value, err := strconv.Atoi(values[2]); err == nil {
dcs = intPointer(value)
}
}
text := payload
if (dcs != nil && *dcs == 72) || looksLikeUCS2(payload) {
if decoded := decodeUCS2(payload); decoded != "" {
text = decoded
}
}
return USSDResult{
Text: text,
Raw: line,
DCS: dcs,
Status: ussdStatusFromCode(code),
}, nil
}
func ussdStatusFromCode(code int) string {
switch code {
case 0:
return ussdStatusFinal
case 1:
return ussdStatusAwaitingInput
case 2:
return ussdStatusTerminated
default:
return ussdStatusFailed
}
}
func looksLikeUCS2(value string) bool {
if len(value) < 4 || len(value)%4 != 0 {
return false
}
if _, err := hex.DecodeString(value); err != nil {
return false
}
return strings.HasPrefix(value, "00") ||
strings.IndexFunc(value, func(character rune) bool {
return character >= 'A' && character <= 'F' ||
character >= 'a' && character <= 'f'
}) >= 0
}
func decodeUCS2(value string) string {
if len(value) < 4 || len(value)%4 != 0 {
return ""
}
units := make([]uint16, 0, len(value)/4)
for index := 0; index < len(value); index += 4 {
unit, err := strconv.ParseUint(value[index:index+4], 16, 16)
if err != nil {
return ""
}
units = append(units, uint16(unit))
}
return string(utf16.Decode(units))
}
func validServiceCode(value string) bool {
if value == "" || len(value) > 40 {
return false
}
for _, character := range value {
if character < '0' || character > '9' {
if character != '*' && character != '#' && character != '+' {
return false
}
}
}
return true
}
func (manager *Manager) SetFlight(
ctx context.Context,
id string,
enabled bool,
) (FlightResult, error) {
state, err := manager.lookup(id)
if err != nil {
return FlightResult{}, err
}
state.opMu.Lock()
defer state.opMu.Unlock()
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)
return FlightResult{}, err
}
previous, err := manager.readOperatingMode(ctx, client)
if err != nil {
manager.setResult(id, state, nil, err)
return FlightResult{}, err
}
target := previous
if enabled {
if !isRadioOffMode(previous) {
saved := previous
state.preFlightMode = &saved
target = 4
}
} else if isRadioOffMode(previous) {
target = 1
if state.preFlightMode != nil && !isRadioOffMode(*state.preFlightMode) {
target = *state.preFlightMode
}
}
changed := target != previous
if changed {
if _, err := manager.command(
ctx,
client,
fmt.Sprintf("AT+CFUN=%d", target),
); err != nil {
manager.setResult(id, state, nil, err)
return FlightResult{
PreviousMode: previous,
CurrentMode: previous,
FlightMode: isRadioOffMode(previous),
RadioOff: isRadioOffMode(previous),
}, err
}
}
current, err := manager.readOperatingMode(ctx, client)
if err != nil {
manager.setResult(id, state, nil, err)
return FlightResult{
PreviousMode: previous,
CurrentMode: target,
Changed: changed,
FlightMode: isRadioOffMode(target),
RadioOff: isRadioOffMode(target),
}, err
}
if !enabled && !isRadioOffMode(current) {
state.preFlightMode = nil
}
manager.updateSnapshotMode(id, state, current)
manager.setResult(id, state, nil, nil)
return FlightResult{
PreviousMode: previous,
CurrentMode: current,
Changed: changed,
FlightMode: isRadioOffMode(current),
RadioOff: isRadioOffMode(current),
}, nil
}
func (manager *Manager) readOperatingMode(
ctx context.Context,
client modem.Client,
) (int, error) {
response, err := manager.command(ctx, client, "AT+CFUN?")
if err != nil {
return 0, err
}
mode, ok := parseCFUN(response)
if !ok {
return 0, errors.New("modem did not return a valid +CFUN value")
}
return mode, nil
}
func (manager *Manager) updateSnapshotMode(
id string,
state *managedDevice,
mode int,
) {
manager.mu.Lock()
defer manager.mu.Unlock()
if manager.devices[id] != state || state.snapshot == nil {
return
}
state.snapshot.OperatingMode = mode
state.snapshot.ModeKnown = true
state.snapshot.FlightMode = isRadioOffMode(mode)
state.snapshot.RadioOff = state.snapshot.FlightMode
}