Files

155 lines
4.3 KiB
Go

package device
import (
"context"
"strings"
"vocat/internal/modem"
)
// ScannedOperator is one network reported by an operator scan (AT+COPS=?).
type ScannedOperator struct {
// Status is "current", "available", "forbidden", or "unknown".
Status string `json:"status"`
Name string `json:"name"`
Short string `json:"shortName,omitempty"`
Numeric string `json:"numeric"`
Country string `json:"countryCode,omitempty"`
Act string `json:"act,omitempty"`
}
// OperatorScanResult is the outcome of a full operator scan.
type OperatorScanResult struct {
Status string `json:"status"` // "complete" or "failed"
Operators []ScannedOperator `json:"operators"`
}
// ScanOperators runs AT+COPS=? to list the networks the modem can currently
// see. The command is slow (tens of seconds, up to the modem's documented
// ceiling), so it uses the manager's scan timeout rather than the normal
// command timeout. It is abortable through the caller's context.
func (manager *Manager) ScanOperators(
ctx context.Context,
id string,
) (OperatorScanResult, error) {
state, err := manager.lookup(id)
if err != nil {
return OperatorScanResult{}, err
}
state.opMu.Lock()
defer state.opMu.Unlock()
if err := manager.validateActive(id, state); err != nil {
return OperatorScanResult{}, err
}
client, err := manager.clientLocked(ctx, state, manager.candidateFor(state))
if err != nil {
manager.setResult(id, state, nil, err)
return OperatorScanResult{}, err
}
scanContext, cancel := manager.withTimeout(ctx, manager.scanTimeout)
defer cancel()
response, err := client.Execute(scanContext, "AT+COPS=?")
if err != nil {
manager.setResult(id, state, nil, err)
return OperatorScanResult{Status: "failed", Operators: []ScannedOperator{}}, err
}
result := OperatorScanResult{
Status: "complete",
Operators: parseOperatorScan(response),
}
manager.setResult(id, state, nil, nil)
return result, nil
}
// parseOperatorScan parses the +COPS: list returned by AT+COPS=?. Each entry is
// a parenthesised tuple (stat,"long","short","numeric"[,act]).
func parseOperatorScan(response modem.Response) []ScannedOperator {
operators := make([]ScannedOperator, 0)
for _, line := range response.Lines {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(strings.ToUpper(trimmed), "+COPS:") {
continue
}
payload := strings.TrimSpace(trimmed[len("+COPS:"):])
for _, tuple := range extractScanTuples(payload) {
fields := csvValues(tuple)
if len(fields) < 4 {
continue
}
name, country, _ := CarrierForPLMN(fields[3])
if name == "" {
name = strings.TrimSpace(fields[1])
}
if name == "" {
name = strings.TrimSpace(fields[3])
}
operator := ScannedOperator{
Status: operatorScanStatus(fields[0]),
Name: name,
Short: fields[2],
Numeric: fields[3],
Country: country,
}
if len(fields) >= 5 {
operator.Act = accessTechnology(fields[4])
}
operators = append(operators, operator)
}
}
return operators
}
// carrierNameForPLMN resolves the numeric serving PLMN through the bundled
// global carrier database. Some EC20 firmware returns an empty, localized, or
// stale long name even though the MCC/MNC is correct. The numeric identity is
// the authoritative value used for network selection.
func carrierNameForPLMN(plmn, fallback string) string {
if name, _, ok := CarrierForPLMN(plmn); ok {
return name
}
if fallback = strings.TrimSpace(fallback); fallback != "" {
return fallback
}
return strings.TrimSpace(plmn)
}
// extractScanTuples returns the contents of each top-level parenthesised group,
// ignoring parentheses inside quoted strings.
func extractScanTuples(payload string) []string {
tuples := make([]string, 0)
depth := 0
start := -1
inQuote := false
for index, r := range payload {
switch {
case r == '"':
inQuote = !inQuote
case r == '(' && !inQuote:
if depth == 0 {
start = index + 1
}
depth++
case r == ')' && !inQuote:
depth--
if depth == 0 && start >= 0 {
tuples = append(tuples, payload[start:index])
start = -1
}
}
}
return tuples
}
func operatorScanStatus(code string) string {
switch strings.TrimSpace(code) {
case "1":
return "available"
case "2":
return "current"
case "3":
return "forbidden"
default:
return "unknown"
}
}