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 }