mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-19 14:23:42 +08:00
352 lines
8.8 KiB
Go
352 lines
8.8 KiB
Go
package device
|
|
|
|
import (
|
|
"context"
|
|
"encoding/csv"
|
|
"fmt"
|
|
"io"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
|
|
"vocat/internal/modem"
|
|
)
|
|
|
|
func (manager *Manager) readSnapshot(
|
|
ctx context.Context,
|
|
id string,
|
|
candidate modem.Candidate,
|
|
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
|
|
}
|
|
|
|
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)
|
|
}
|
|
if response, ok := optional("AT+CSQ"); ok {
|
|
snapshot.SignalRaw, snapshot.SignalPercent, snapshot.RSSIDBm = parseCSQ(response)
|
|
}
|
|
if response, ok := optional(`AT+QENG="servingcell"`); ok {
|
|
metrics := parseQENG(response)
|
|
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)
|
|
snapshot.OperatorName = operator.Name
|
|
snapshot.OperatorCode = operator.Code
|
|
if snapshot.AccessTech == "" {
|
|
snapshot.AccessTech = operator.AccessTech
|
|
}
|
|
}
|
|
if response, ok := optional("AT+CGSN"); ok {
|
|
snapshot.IMEI = parseIdentifier(
|
|
response,
|
|
[]string{"+CGSN:", "+GSN:"},
|
|
14,
|
|
17,
|
|
)
|
|
}
|
|
|
|
ccid, ccidErr := manager.command(ctx, client, "AT+CCID")
|
|
if ccidErr != nil {
|
|
ccid, ccidErr = manager.command(ctx, client, "AT+QCCID")
|
|
}
|
|
if ccidErr != nil {
|
|
snapshot.Warnings = append(snapshot.Warnings, "read ICCID: "+ccidErr.Error())
|
|
} else {
|
|
snapshot.ICCID = parseICCIDIdentifier(ccid, []string{"+CCID:", "+QCCID:"}, 18, 22)
|
|
}
|
|
if response, ok := optional("AT+CIMI"); ok {
|
|
snapshot.IMSI = parseIdentifier(response, []string{"+CIMI:"}, 10, 18)
|
|
}
|
|
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 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 {
|
|
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 {
|
|
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{}
|
|
}
|
|
|
|
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
|
|
}
|