Files
VoCat/internal/device/snapshot.go
T
MengMengCode 8a260e86f1 Implement automatic task management with CRUD operations and UI integration
- Added `automatic_tasks.go` and `automatic_tasks_test.go` for backend logic and testing of automatic tasks.
- Created `automatic_tasks_test.go` to validate task claiming and deletion behavior.
- Developed `AutomaticTasksPage.tsx` for frontend management of automatic tasks, including task creation, editing, and execution.
- Integrated device and eSIM profile selection for task configuration.
- Implemented automatic task scheduling and retry logic in the backend.
2026-08-10 22:15:54 +08:00

486 lines
14 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,
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)
}
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)
}
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 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 response, ok := optional("AT+CGSN"); ok {
snapshot.IMEI = parseIdentifier(
response,
[]string{"+CGSN:", "+GSN:"},
14,
17,
)
}
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 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
}