Files
VoCat/internal/device/snapshot.go
T
f949001480 fix: support non-Quectel Qualcomm modems and fix 410 dongle AT timeouts (#40)
1. Vendor-neutral modem compatibility:
   - Discovery switched from a vendor-ID whitelist to detecting the QMI
     channel directly (an interface bound to the kernel qmi_wwan driver),
     so SIMCom, Sierra, Telit and other Qualcomm-based modules are found
     automatically while MBIM-only devices stay excluded
   - AT port responses now distinguish an AT command error from firmware
     incompatibility: ERROR / +CME ERROR is returned as a normal response
     (200) instead of being folded into a 502, which only a real transport
     failure produces

2. Fixed the 410 dongle's AT command timeouts:
   - Default WWAN AT port switched from wwan0at0 to wwan0at1: ModemManager
     marks the first AT port that answers its probe as primary (at1 on the
     tested UFI dongles) and closes AT ports once initialization finishes,
     so at1 is the responsive, idle channel for vocat while MM uses the
     QMI port for control
   - Drain the WWAN input buffer before each command write, discarding the
     late bytes of a previous timed-out command so they cannot pollute the
     next response's parsing
   - AT+CGSN now uses an independent short timeout instead of inheriting
     the refresh's 30s deadline (on MHI modems it returns the IMEI line
     but never a final OK). Previously every refresh held the device lock
     for the full 30s, queueing AT terminal commands behind it for 10-20s
   - The QMI UIM ICCID fallback only runs when AT+CPIN? already proved a
     READY card, so a SIM-less slot no longer blocks refresh waiting out
     its long timeout

Tests: added WWAN drain cleanup, drain-before-write ordering, CGSN timeout
bound, skip-QMI-ICCID-without-SIM, CommandError-as-200, WWAN at1 port
selection and vendor-neutral discovery cases. go vet and go test ./... pass.

Co-authored-by: Test <[email protected]>
2026-08-16 23:34:12 +08:00

643 lines
19 KiB
Go

package device
import (
"context"
"encoding/csv"
"encoding/hex"
"fmt"
"io"
"strconv"
"strings"
"time"
"unicode"
"unicode/utf16"
"vocat/internal/modem"
)
func (manager *Manager) readSnapshot(
ctx context.Context,
id string,
candidate modem.Candidate,
backend string,
previousICCID string,
previousSnapshot *Snapshot,
client modem.Client,
) (Snapshot, error) {
snapshot := Snapshot{
DeviceID: id,
Port: candidate.ATPort.OpenPath(),
OperatingMode: -1,
UpdatedAt: time.Now().UTC(),
}
ati, err := manager.command(ctx, client, "ATI")
if err != nil {
return snapshot, fmt.Errorf("probe modem: %w", err)
}
snapshot.Responsive = true
snapshot.Manufacturer, snapshot.Model, snapshot.Firmware = parseATI(ati.Lines)
if snapshot.Model == "" && !strings.EqualFold(candidate.Product, "Android") {
snapshot.Model = candidate.Product
}
// Native MHI/QMI devices expose their immutable modem identity through DMS.
// Read it before any SIM-dependent AT probes: a missing/bad card can make
// those commands slow or fail, but must never prevent IMEI from appearing.
if strings.EqualFold(strings.TrimSpace(backend), "qmi") && isNativeQMICandidate(candidate) {
qmiContext, cancelQMI := manager.withTimeout(ctx, manager.commandTimeout*5)
qmiIMEI, qmiErr := manager.readNativeQMIIMEI(qmiContext, candidate)
cancelQMI()
if qmiErr == nil {
snapshot.IMEI = qmiIMEI
} else {
snapshot.Warnings = append(snapshot.Warnings, "read IMEI via QMI DMS: "+qmiErr.Error())
}
}
optional := func(command string) (modem.Response, bool) {
response, commandErr := manager.command(ctx, client, command)
if commandErr != nil {
snapshot.Warnings = append(snapshot.Warnings, commandErr.Error())
return response, false
}
return response, true
}
if response, ok := optional("AT+CPIN?"); ok {
snapshot.SIMStatus, snapshot.SIMReady = parseCPIN(response)
}
ccid, ccidErr := manager.command(ctx, client, "AT+CCID")
if ccidErr != nil {
ccid, ccidErr = manager.command(ctx, client, "AT+QCCID")
}
if ccidErr != nil && strings.EqualFold(strings.TrimSpace(backend), "qmi") && isNativeQMICandidate(candidate) &&
strings.EqualFold(strings.TrimSpace(snapshot.SIMStatus), "READY") {
// Without a READY SIM the QMI UIM ICCID read blocks until its (long)
// timeout, and every refresh holds the device lock while it does so,
// starving the AT terminal. Only fall back to QMI when the AT CPIN
// probe already proved a card is present.
qmiContext, cancelQMI := manager.withTimeout(ctx, manager.commandTimeout*5)
qmiICCID, qmiErr := manager.readNativeQMIICCID(qmiContext, candidate)
cancelQMI()
if qmiErr == nil {
snapshot.ICCID = qmiICCID
ccidErr = nil
} else {
snapshot.Warnings = append(snapshot.Warnings, "read ICCID via QMI UIM: "+qmiErr.Error())
}
}
if ccidErr != nil {
snapshot.Warnings = append(snapshot.Warnings, "read ICCID: "+ccidErr.Error())
} else {
if snapshot.ICCID == "" {
snapshot.ICCID = parseICCIDIdentifier(ccid, []string{"+CCID:", "+QCCID:"}, 18, 22)
}
}
previousICCID = strings.TrimSpace(previousICCID)
if previousICCID != "" && snapshot.ICCID != "" && !strings.EqualFold(previousICCID, snapshot.ICCID) {
// A different physical SIM must never inherit the previous card's
// permission to use cellular RF. Disable RF before reading serving-cell
// or operator state; policy reconciliation will then start VoWiFi.
if _, err := manager.command(ctx, client, "AT+CFUN=4"); err != nil {
return snapshot, fmt.Errorf("protect changed SIM with RF off: %w", err)
}
snapshot.SIMChanged = true
}
if response, ok := optional("AT+CIMI"); ok {
snapshot.IMSI = parseIdentifier(response, []string{"+CIMI:"}, 10, 18)
}
// EF_SPN is the SIM-issued brand (for example "Lebara"), which is distinct
// from the IMSI sponsor/core PLMN. A Lebara UK subscription may therefore
// legitimately carry a Vodafone NL IMSI while still presenting Lebara as
// its customer-facing operator. Failure is intentionally silent because
// EF_SPN is optional and some physical SIMs deny CRSM access to it.
if response, spnErr := manager.command(ctx, client, "AT+CRSM=176,28486,0,0,17"); spnErr == nil {
snapshot.SPN = parseSPN(response)
}
if previousSnapshot != nil && previousSnapshot.IdentityFilesRead &&
strings.EqualFold(strings.TrimSpace(previousSnapshot.ICCID), strings.TrimSpace(snapshot.ICCID)) {
snapshot.MNCLength = previousSnapshot.MNCLength
snapshot.GID1 = previousSnapshot.GID1
snapshot.GID2 = previousSnapshot.GID2
snapshot.IdentityFilesRead = true
} else {
// Android's carrier resolver does not identify MVNOs from MCC/MNC alone.
// Read these files once per inserted ICCID and cache even an empty result;
// repeatedly probing unsupported EFs would add avoidable modem traffic.
if efAD := manager.readTransparentSIMFile(ctx, client, 28589); len(efAD) >= 4 {
if length := int(efAD[3] & 0x0f); length == 2 || length == 3 {
snapshot.MNCLength = length
}
}
snapshot.GID1 = encodeSIMGroupID(manager.readTransparentSIMFile(ctx, client, 28478))
snapshot.GID2 = encodeSIMGroupID(manager.readTransparentSIMFile(ctx, client, 28479))
snapshot.IdentityFilesRead = true
}
if response, ok := optional("AT+CSQ"); ok {
snapshot.SignalRaw, snapshot.SignalPercent, snapshot.RSSIDBm = parseCSQ(response)
}
servingPLMN := ""
if response, ok := optional(`AT+QENG="servingcell"`); ok {
metrics := parseQENG(response)
servingPLMN = metrics.PLMN
snapshot.AccessTech = metrics.AccessTech
snapshot.Band = metrics.Band
snapshot.Channel = metrics.Channel
snapshot.RSRP = metrics.RSRP
snapshot.RSRQ = metrics.RSRQ
snapshot.SINR = metrics.SINR
if metrics.RSSI != nil {
snapshot.RSSIDBm = metrics.RSSI
}
}
if response, ok := optional("AT+COPS?"); ok {
operator := parseCOPS(response)
if operator.Code != "" {
snapshot.OperatorCode = operator.Code
} else {
snapshot.OperatorCode = servingPLMN
}
snapshot.OperatorName = carrierNameForPLMN(snapshot.OperatorCode, operator.Name)
if snapshot.AccessTech == "" {
snapshot.AccessTech = operator.AccessTech
}
}
for _, command := range []string{"AT+CEREG?", "AT+CGREG?", "AT+CREG?"} {
response, registrationErr := manager.command(ctx, client, command)
if registrationErr != nil {
continue
}
if status, found := parseRegistrationStatus(response); found {
snapshot.RegistrationStatus = status
snapshot.RegistrationSource = strings.TrimSuffix(strings.TrimPrefix(command, "AT+"), "?")
break
}
}
if strings.EqualFold(backend, "qmi") {
registration, found := readPlatformRegistration(ctx, candidate)
if found {
snapshot.RegistrationStatus = registration.Status
snapshot.RegistrationSource = "QMI NAS"
snapshot.PSAttached = registration.PSAttached
if registration.PLMN != "" {
snapshot.OperatorCode = registration.PLMN
snapshot.OperatorName = carrierNameForPLMN(registration.PLMN, registration.Name)
}
}
}
if snapshot.RegistrationSource == "" && (snapshot.OperatorName != "" || snapshot.OperatorCode != "") {
// Older firmware can omit registration queries while COPS still proves
// that an operator is selected.
snapshot.RegistrationStatus = 1
snapshot.RegistrationSource = "COPS"
}
if snapshot.IMEI == "" {
// AT+CGSN on some MHI modems (the UFI dongle behind the OpenStick 410)
// returns the IMEI line but never a final OK, so it would block until the
// caller's deadline (30s during a periodic refresh) and starve every other
// device operation behind the lock. Give it an independent short timeout
// and let the WWAN transport's drain discard the trailing stale bytes.
cgsnCtx, cancelCGSN := context.WithTimeout(ctx, manager.commandTimeout)
cgsnResponse, cgsnErr := manager.command(cgsnCtx, client, "AT+CGSN")
cancelCGSN()
if cgsnErr == nil {
if imei := parseIdentifier(cgsnResponse, []string{"+CGSN:", "+GSN:"}, 14, 17); imei != "" {
snapshot.IMEI = imei
}
}
}
if snapshot.IMEI == "" && strings.EqualFold(strings.TrimSpace(backend), "qmi") && isNativeQMICandidate(candidate) {
qmiContext, cancelQMI := manager.withTimeout(ctx, manager.commandTimeout*5)
qmiIMEI, qmiErr := manager.readNativeQMIIMEI(qmiContext, candidate)
cancelQMI()
if qmiErr == nil {
snapshot.IMEI = qmiIMEI
} else {
snapshot.Warnings = append(snapshot.Warnings, "read IMEI via QMI DMS: "+qmiErr.Error())
}
}
if snapshot.IMEI == "" && previousSnapshot != nil {
// IMEI is hardware identity and does not change with the inserted card.
// Preserve a prior successful read across a transient QMI/AT failure.
snapshot.IMEI = previousSnapshot.IMEI
}
if response, ok := optional("AT+CFUN?"); ok {
if mode, found := parseCFUN(response); found {
snapshot.OperatingMode = mode
snapshot.ModeKnown = true
snapshot.FlightMode = isRadioOffMode(mode)
snapshot.RadioOff = snapshot.FlightMode
}
}
phone, warnings := manager.readPhoneNumber(ctx, client)
snapshot.Phone = phone
snapshot.Warnings = append(snapshot.Warnings, warnings...)
snapshot.UpdatedAt = time.Now().UTC()
return snapshot, nil
}
func (manager *Manager) readTransparentSIMFile(ctx context.Context, client modem.Client, fileID int) []byte {
response, err := manager.command(ctx, client, fmt.Sprintf("AT+CRSM=192,%d,0,0,0", fileID))
if err != nil {
return nil
}
size := transparentSIMFileSize(crsmPayload(response))
if size <= 0 || size > 64 {
return nil
}
response, err = manager.command(ctx, client, fmt.Sprintf("AT+CRSM=176,%d,0,0,%d", fileID, size))
if err != nil {
return nil
}
return crsmPayload(response)
}
func transparentSIMFileSize(payload []byte) int {
// USIM FCP templates contain file size in tag 0x80. Skip the outer 0x62
// template and walk its immediate TLVs.
content := payload
if len(content) >= 2 && content[0] == 0x62 {
length, header, ok := berLength(content[1:])
if !ok || 1+header+length > len(content) {
return 0
}
content = content[1+header : 1+header+length]
}
for offset := 0; offset+2 <= len(content); {
tag := content[offset]
length, header, ok := berLength(content[offset+1:])
start := offset + 1 + header
end := start + length
if !ok || end > len(content) {
break
}
if tag == 0x80 && (length == 1 || length == 2) {
size := 0
for _, value := range content[start:end] {
size = size<<8 | int(value)
}
return size
}
offset = end
}
// Legacy GSM GET RESPONSE data stores file size in bytes 2 and 3.
if len(payload) >= 4 && payload[0] != 0x62 {
return int(payload[2])<<8 | int(payload[3])
}
return 0
}
func berLength(value []byte) (length, header int, ok bool) {
if len(value) == 0 {
return 0, 0, false
}
if value[0] < 0x80 {
return int(value[0]), 1, true
}
count := int(value[0] & 0x7f)
if count == 0 || count > 2 || len(value) < count+1 {
return 0, 0, false
}
for _, item := range value[1 : count+1] {
length = length<<8 | int(item)
}
return length, count + 1, true
}
func encodeSIMGroupID(value []byte) string {
if len(value) == 0 {
return ""
}
allPadding := true
for _, item := range value {
if item != 0xff {
allPadding = false
break
}
}
if allPadding {
return ""
}
return strings.ToUpper(hex.EncodeToString(value))
}
func parseSPN(response modem.Response) string {
value := valueAfterPrefix(response, "+CRSM:")
fields := csvValues(value)
if len(fields) < 3 {
return ""
}
sw1, sw1Err := strconv.Atoi(strings.TrimSpace(fields[0]))
sw2, sw2Err := strconv.Atoi(strings.TrimSpace(fields[1]))
if sw1Err != nil || sw2Err != nil || (sw1 != 0x90 && sw1 != 0x91 && sw1 != 0x9f) || sw2 < 0 || sw2 > 255 {
return ""
}
raw, err := hex.DecodeString(strings.Trim(strings.TrimSpace(fields[2]), `"`))
if err != nil || len(raw) < 2 {
return ""
}
alpha := raw[1:] // byte 0 is the display-condition bit field.
for len(alpha) > 0 && (alpha[len(alpha)-1] == 0xff || alpha[len(alpha)-1] == 0x00) {
alpha = alpha[:len(alpha)-1]
}
if len(alpha) == 0 {
return ""
}
if alpha[0] == 0x80 {
ucs2 := alpha[1:]
if len(ucs2)%2 != 0 {
ucs2 = ucs2[:len(ucs2)-1]
}
units := make([]uint16, 0, len(ucs2)/2)
for index := 0; index+1 < len(ucs2); index += 2 {
unit := uint16(ucs2[index])<<8 | uint16(ucs2[index+1])
if unit != 0xffff && unit != 0 {
units = append(units, unit)
}
}
return strings.TrimSpace(string(utf16.Decode(units)))
}
// EF_SPN uses the unpacked GSM default alphabet. Its printable Latin subset
// is byte-compatible with UTF-8/ASCII and covers operator brands in practice.
printable := make([]byte, 0, len(alpha))
for _, value := range alpha {
if value >= 0x20 && value <= 0x7e {
printable = append(printable, value)
}
}
return strings.TrimSpace(string(printable))
}
func parseRegistrationStatus(response modem.Response) (int, bool) {
for _, prefix := range []string{"+CEREG:", "+CGREG:", "+CREG:"} {
values := csvValues(valueAfterPrefix(response, prefix))
if len(values) == 0 {
continue
}
index := 0
// Query responses are <n>,<stat>; unsolicited responses are <stat>.
if len(values) >= 2 {
index = 1
}
status, err := strconv.Atoi(strings.TrimSpace(values[index]))
if err == nil && status >= 0 && status <= 10 {
return status, true
}
}
return 0, false
}
func parseATI(lines []string) (manufacturer, model, firmware string) {
for _, line := range lines {
line = strings.TrimSpace(line)
upper := strings.ToUpper(line)
switch {
case strings.HasPrefix(upper, "REVISION:"):
firmware = strings.TrimSpace(strings.SplitN(line, ":", 2)[1])
case strings.Contains(upper, "QUECTEL"):
manufacturer = line
case strings.HasPrefix(upper, "EC20") || strings.HasPrefix(upper, "EC25"):
model = line
}
}
return
}
func parseCPIN(response modem.Response) (string, bool) {
value := strings.ToUpper(valueAfterPrefix(response, "+CPIN:"))
switch {
case strings.Contains(value, "READY"):
return "ready", true
case strings.Contains(value, "SIM PIN"):
return "pin_required", false
case strings.Contains(value, "SIM PUK"):
return "puk_required", false
case strings.Contains(value, "NOT INSERTED"):
return "not_inserted", false
case value == "":
return "unknown", false
default:
return strings.ToLower(strings.ReplaceAll(value, " ", "_")), false
}
}
func parseCSQ(response modem.Response) (raw, percent, dbm *int) {
values := csvValues(valueAfterPrefix(response, "+CSQ:"))
if len(values) == 0 {
return nil, nil, nil
}
value, err := strconv.Atoi(values[0])
if err != nil || value < 0 || value > 31 {
return nil, nil, nil
}
raw = intPointer(value)
scaled := (value*100 + 15) / 31
percent = intPointer(scaled)
signalDBM := -113 + value*2
dbm = intPointer(signalDBM)
return
}
type qengMetrics struct {
PLMN string
AccessTech string
Band string
Channel string
RSSI *int
RSRP *int
RSRQ *int
SINR *int
}
func parseQENG(response modem.Response) qengMetrics {
for _, line := range response.Lines {
if !strings.HasPrefix(strings.ToUpper(strings.TrimSpace(line)), "+QENG:") {
continue
}
values := csvValues(strings.TrimSpace(strings.SplitN(line, ":", 2)[1]))
if len(values) < 3 || !strings.EqualFold(values[0], "servingcell") {
continue
}
result := qengMetrics{AccessTech: strings.ToUpper(values[2])}
if strings.EqualFold(values[2], "LTE") && len(values) >= 17 {
if decimalDigits(values[4], 3, 3) && decimalDigits(values[5], 2, 3) {
result.PLMN = values[4] + values[5]
}
result.Channel = values[8]
if values[9] != "" {
result.Band = "B" + values[9]
}
result.RSRP = parseOptionalInt(values[13])
result.RSRQ = parseOptionalInt(values[14])
result.RSSI = parseOptionalInt(values[15])
result.SINR = parseOptionalInt(values[16])
}
return result
}
return qengMetrics{}
}
func decimalDigits(value string, minimum, maximum int) bool {
value = strings.TrimSpace(value)
return len(value) >= minimum && len(value) <= maximum && strings.IndexFunc(value, func(character rune) bool {
return character < '0' || character > '9'
}) < 0
}
type operatorInfo struct {
Name string
Code string
AccessTech string
}
func parseCOPS(response modem.Response) operatorInfo {
values := csvValues(valueAfterPrefix(response, "+COPS:"))
if len(values) < 3 {
return operatorInfo{}
}
result := operatorInfo{Name: values[2]}
format, _ := strconv.Atoi(values[1])
if format == 2 {
result.Code = values[2]
result.Name = ""
}
if len(values) >= 4 {
result.AccessTech = accessTechnology(values[3])
}
return result
}
func accessTechnology(value string) string {
switch strings.TrimSpace(value) {
case "0":
return "GSM"
case "2":
return "UTRAN"
case "3":
return "EDGE"
case "4":
return "HSDPA"
case "5":
return "HSUPA"
case "6":
return "HSPA"
case "7":
return "LTE"
case "9":
return "NR5G"
default:
return ""
}
}
func parseCFUN(response modem.Response) (int, bool) {
values := csvValues(valueAfterPrefix(response, "+CFUN:"))
if len(values) == 0 {
return 0, false
}
mode, err := strconv.Atoi(values[0])
return mode, err == nil
}
func isRadioOffMode(mode int) bool {
return mode == 0 || mode == 4
}
func valueAfterPrefix(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 csvValues(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 firstDigitLine(response modem.Response, minimum, maximum int) string {
for _, line := range response.Lines {
value := strings.TrimSpace(line)
if len(value) < minimum || len(value) > maximum {
continue
}
if strings.IndexFunc(value, func(character rune) bool {
return !unicode.IsDigit(character)
}) < 0 {
return value
}
}
return ""
}
func parseIdentifier(
response modem.Response,
prefixes []string,
minimum, maximum int,
) string {
for _, prefix := range prefixes {
value := strings.Trim(valueAfterPrefix(response, prefix), `" `)
if len(value) >= minimum && len(value) <= maximum &&
strings.IndexFunc(value, func(character rune) bool {
return !unicode.IsDigit(character)
}) < 0 {
return value
}
}
return firstDigitLine(response, minimum, maximum)
}
// parseICCIDIdentifier accepts all trailing hexadecimal F nibbles exposed from
// the fixed 10-octet EF-ICCID representation. A 19-digit ICCID has one filler
// nibble while an 18-digit ICCID has two; neither is part of the identifier.
func parseICCIDIdentifier(
response modem.Response,
prefixes []string,
minimum, maximum int,
) string {
normalize := func(value string) string {
value = strings.Trim(value, `" `)
value = strings.TrimRight(value, "Ff")
if len(value) >= minimum && len(value) <= maximum &&
strings.IndexFunc(value, func(character rune) bool { return !unicode.IsDigit(character) }) < 0 {
return value
}
return ""
}
for _, prefix := range prefixes {
if value := normalize(valueAfterPrefix(response, prefix)); value != "" {
return value
}
}
for _, line := range response.Lines {
if value := normalize(strings.TrimSpace(line)); value != "" {
return value
}
}
return ""
}
func parseOptionalInt(value string) *int {
number, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil {
return nil
}
return intPointer(number)
}
func intPointer(value int) *int {
return &value
}