| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351 |
- 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
- }
|