mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-18 05:43:42 +08:00
1. Vendor-neutral modem compatibility:
- Discovery switched from a vendor-ID whitelist to detecting the QMI
channel directly (an interface bound to the kernel qmi_wwan driver),
so SIMCom, Sierra, Telit and other Qualcomm-based modules are found
automatically while MBIM-only devices stay excluded
- AT port responses now distinguish an AT command error from firmware
incompatibility: ERROR / +CME ERROR is returned as a normal response
(200) instead of being folded into a 502, which only a real transport
failure produces
2. Fixed the 410 dongle's AT command timeouts:
- Default WWAN AT port switched from wwan0at0 to wwan0at1: ModemManager
marks the first AT port that answers its probe as primary (at1 on the
tested UFI dongles) and closes AT ports once initialization finishes,
so at1 is the responsive, idle channel for vocat while MM uses the
QMI port for control
- Drain the WWAN input buffer before each command write, discarding the
late bytes of a previous timed-out command so they cannot pollute the
next response's parsing
- AT+CGSN now uses an independent short timeout instead of inheriting
the refresh's 30s deadline (on MHI modems it returns the IMEI line
but never a final OK). Previously every refresh held the device lock
for the full 30s, queueing AT terminal commands behind it for 10-20s
- The QMI UIM ICCID fallback only runs when AT+CPIN? already proved a
READY card, so a SIM-less slot no longer blocks refresh waiting out
its long timeout
Tests: added WWAN drain cleanup, drain-before-write ordering, CGSN timeout
bound, skip-QMI-ICCID-without-SIM, CommandError-as-200, WWAN at1 port
selection and vendor-neutral discovery cases. go vet and go test ./... pass.
Co-authored-by: Test <[email protected]>
565 lines
17 KiB
Go
565 lines
17 KiB
Go
package modem
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
const (
|
|
djiVendorID = "2ca3"
|
|
dji4GProductID = "4006"
|
|
)
|
|
type SysFSDiscoverer struct {
|
|
SysRoot string
|
|
DevRoot string
|
|
}
|
|
|
|
func NewSysFSDiscoverer(sysRoot, devRoot string) *SysFSDiscoverer {
|
|
return &SysFSDiscoverer{
|
|
SysRoot: filepath.Clean(sysRoot),
|
|
DevRoot: filepath.Clean(devRoot),
|
|
}
|
|
}
|
|
|
|
type discoveredUSBDevice struct {
|
|
candidate Candidate
|
|
ports map[string]Port
|
|
}
|
|
|
|
func (d *SysFSDiscoverer) Discover(ctx context.Context) ([]Candidate, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
usbRoot := filepath.Join(d.SysRoot, "bus", "usb", "devices")
|
|
entries, err := os.ReadDir(usbRoot)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
entries = nil
|
|
} else {
|
|
return nil, fmt.Errorf("discover USB QMI modems: %w", err)
|
|
}
|
|
}
|
|
|
|
// Candidate modems are identified by kernel driver binding instead of a
|
|
// vendor-ID whitelist. qmi_wwan only binds Qualcomm QMI control interfaces,
|
|
// so any USB device with a bound interface exposes a live QMI channel. This
|
|
// keeps discovery vendor-neutral (SIMCom, Sierra, Telit and other
|
|
// Qualcomm-based modems are found automatically) while MBIM-only devices
|
|
// stay out, because cdc_mbim binds their control interface instead and the
|
|
// project has no MBIM backend.
|
|
qmiBound := d.qmiWWANBoundDevices()
|
|
|
|
aliases := readSerialAliases(filepath.Join(d.DevRoot, "serial", "by-id"))
|
|
devices := make(map[string]*discoveredUSBDevice)
|
|
for _, entry := range entries {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
interfaceNumber, ok := parseUSBInterfaceName(entry.Name())
|
|
if !ok {
|
|
continue
|
|
}
|
|
interfacePath := filepath.Join(usbRoot, entry.Name())
|
|
resolvedInterface, err := filepath.EvalSymlinks(interfacePath)
|
|
if err != nil {
|
|
resolvedInterface = interfacePath
|
|
}
|
|
if value, err := readHexByte(filepath.Join(resolvedInterface, "bInterfaceNumber")); err == nil {
|
|
interfaceNumber = value
|
|
}
|
|
|
|
deviceName := strings.SplitN(entry.Name(), ":", 2)[0]
|
|
devicePath := filepath.Join(usbRoot, deviceName)
|
|
resolvedDevice, err := filepath.EvalSymlinks(devicePath)
|
|
if err != nil {
|
|
resolvedDevice = devicePath
|
|
}
|
|
vendorID := strings.ToLower(readTrimmed(filepath.Join(resolvedDevice, "idVendor")))
|
|
productID := strings.ToLower(readTrimmed(filepath.Join(resolvedDevice, "idProduct")))
|
|
if _, bound := qmiBound[deviceName]; !bound && !IsDJI4GUSB(vendorID, productID) {
|
|
continue
|
|
}
|
|
|
|
state := devices[deviceName]
|
|
if state == nil {
|
|
serialNumber := readTrimmed(filepath.Join(resolvedDevice, "serial"))
|
|
state = &discoveredUSBDevice{
|
|
candidate: Candidate{
|
|
ID: candidateID(vendorID, productID, serialNumber, deviceName),
|
|
VendorID: vendorID,
|
|
ProductID: productID,
|
|
Manufacturer: readTrimmed(filepath.Join(resolvedDevice, "manufacturer")),
|
|
Product: readTrimmed(filepath.Join(resolvedDevice, "product")),
|
|
SerialNumber: serialNumber,
|
|
USBPath: devicePath,
|
|
},
|
|
ports: make(map[string]Port),
|
|
}
|
|
devices[deviceName] = state
|
|
}
|
|
|
|
ttyNames, qmiControls, networkInterfaces := scanUSBInterface(resolvedInterface)
|
|
for _, name := range ttyNames {
|
|
if !strings.HasPrefix(name, "ttyUSB") && !strings.HasPrefix(name, "ttyACM") {
|
|
continue
|
|
}
|
|
path := filepath.Join(d.DevRoot, name)
|
|
state.ports[name] = Port{
|
|
Path: path,
|
|
StablePath: aliases[name],
|
|
Name: name,
|
|
InterfaceNumber: interfaceNumber,
|
|
Role: quecPortRole(interfaceNumber, name),
|
|
}
|
|
}
|
|
if state.candidate.QMIControl == "" && len(qmiControls) > 0 {
|
|
state.candidate.QMIControl = filepath.Join(d.DevRoot, qmiControls[0])
|
|
}
|
|
if state.candidate.NetworkInterface == "" && len(networkInterfaces) > 0 {
|
|
state.candidate.NetworkInterface = networkInterfaces[0]
|
|
}
|
|
}
|
|
|
|
result := make([]Candidate, 0, len(devices))
|
|
for _, state := range devices {
|
|
state.candidate.Ports = make([]Port, 0, len(state.ports))
|
|
for _, port := range state.ports {
|
|
state.candidate.Ports = append(state.candidate.Ports, port)
|
|
}
|
|
sort.Slice(state.candidate.Ports, func(i, j int) bool {
|
|
left, right := state.candidate.Ports[i], state.candidate.Ports[j]
|
|
if left.InterfaceNumber != right.InterfaceNumber {
|
|
return left.InterfaceNumber < right.InterfaceNumber
|
|
}
|
|
return left.Name < right.Name
|
|
})
|
|
assignQuectelPortRoles(state.candidate.Ports)
|
|
state.candidate.ATPort = selectATPort(state.candidate.Ports)
|
|
if !state.candidate.HasATPort() {
|
|
// A bound QMI interface proves the modem is alive, but the snapshot,
|
|
// SMS, USSD and eSIM (AT+CSIM) paths all require an AT port. A missing
|
|
// ttyUSB/ttyACM node almost always means the option/qcserial driver
|
|
// does not claim the serial interfaces (often a missing PID in its
|
|
// device-ID table), not that the module lacks an AT interface.
|
|
state.candidate.DiscoveryIssue = "at_port_missing"
|
|
}
|
|
result = append(result, state.candidate)
|
|
}
|
|
wwanCandidates, err := d.discoverWWAN(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result = append(result, wwanCandidates...)
|
|
sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID })
|
|
return result, nil
|
|
}
|
|
|
|
// IsDJI4GUSB reports whether a USB identity belongs to the first-generation
|
|
// DJI/Baiwang 4G module. It keeps the factory 2ca3:4006 identity usable without
|
|
// requiring a persistent AT+QCFG USB identity rewrite to Quectel 2c7c:0125.
|
|
func IsDJI4GUSB(vendorID, productID string) bool {
|
|
return strings.EqualFold(strings.TrimSpace(vendorID), djiVendorID) &&
|
|
strings.EqualFold(strings.TrimSpace(productID), dji4GProductID)
|
|
}
|
|
|
|
type discoveredWWANDevice struct {
|
|
index string
|
|
ports []Port
|
|
qmiNames []string
|
|
sysPath string
|
|
}
|
|
|
|
// discoverWWAN covers PCIe/MHI modems exposed through Linux's wwan subsystem,
|
|
// for example /dev/wwan0at0 and /dev/wwan0qmi0. These devices do not appear on
|
|
// the USB bus and therefore need a separate discovery path.
|
|
func (d *SysFSDiscoverer) discoverWWAN(ctx context.Context) ([]Candidate, error) {
|
|
classRoot := filepath.Join(d.SysRoot, "class", "wwan")
|
|
classEntries, err := os.ReadDir(classRoot)
|
|
if err != nil {
|
|
if !os.IsNotExist(err) {
|
|
return nil, fmt.Errorf("discover PCIe/MHI WWAN devices: %w", err)
|
|
}
|
|
classEntries = nil
|
|
}
|
|
|
|
// Normal kernels expose these ports in /sys/class/wwan. Also inspect /dev
|
|
// because some downstream MHI packages create the character devices but do
|
|
// not populate the class directory in the host namespace/container.
|
|
portNames := make(map[string]struct{})
|
|
for _, entry := range classEntries {
|
|
portNames[entry.Name()] = struct{}{}
|
|
}
|
|
if devEntries, devErr := os.ReadDir(d.DevRoot); devErr == nil {
|
|
for _, entry := range devEntries {
|
|
if _, _, _, ok := parseWWANPortName(entry.Name()); ok {
|
|
portNames[entry.Name()] = struct{}{}
|
|
}
|
|
}
|
|
} else if !os.IsNotExist(devErr) {
|
|
return nil, fmt.Errorf("inspect WWAN device nodes: %w", devErr)
|
|
}
|
|
names := make([]string, 0, len(portNames))
|
|
for name := range portNames {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
|
|
groups := make(map[string]*discoveredWWANDevice)
|
|
for _, name := range names {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
index, kind, portIndex, ok := parseWWANPortName(name)
|
|
if !ok {
|
|
continue
|
|
}
|
|
group := groups[index]
|
|
if group == nil {
|
|
group = &discoveredWWANDevice{index: index}
|
|
groups[index] = group
|
|
}
|
|
classPath := filepath.Join(classRoot, name)
|
|
if resolved, resolveErr := filepath.EvalSymlinks(classPath); resolveErr == nil {
|
|
group.sysPath = filepath.Dir(resolved)
|
|
}
|
|
switch kind {
|
|
case "at":
|
|
group.ports = append(group.ports, Port{
|
|
Path: filepath.Join(d.DevRoot, name), Name: name,
|
|
InterfaceNumber: portIndex, Role: PortRoleAT,
|
|
})
|
|
case "qmi":
|
|
group.qmiNames = append(group.qmiNames, name)
|
|
}
|
|
}
|
|
result := make([]Candidate, 0, len(groups))
|
|
for _, group := range groups {
|
|
sort.Slice(group.ports, func(i, j int) bool {
|
|
return group.ports[i].InterfaceNumber < group.ports[j].InterfaceNumber
|
|
})
|
|
sort.Strings(group.qmiNames)
|
|
if len(group.ports) == 0 && len(group.qmiNames) == 0 {
|
|
continue
|
|
}
|
|
if group.sysPath == "" {
|
|
group.sysPath = filepath.Join(classRoot, "wwan"+group.index)
|
|
}
|
|
vendorID, productID := readPCIIdentity(group.sysPath, d.SysRoot)
|
|
manufacturer := ""
|
|
if vendorID == "17cb" {
|
|
manufacturer = "Qualcomm"
|
|
}
|
|
candidate := Candidate{
|
|
HardwareKind: "wwan", ID: "mhi-wwan" + group.index,
|
|
VendorID: vendorID, ProductID: productID, Manufacturer: manufacturer,
|
|
Product: "PCIe/MHI WWAN modem", USBPath: group.sysPath,
|
|
Ports: group.ports, NetworkInterface: selectWWANNetworkInterface(d.SysRoot, group.index),
|
|
}
|
|
if len(group.ports) > 0 {
|
|
candidate.ATPort = selectWWANATPort(group.ports)
|
|
}
|
|
if len(group.qmiNames) > 0 {
|
|
candidate.QMIControl = filepath.Join(d.DevRoot, group.qmiNames[0])
|
|
}
|
|
result = append(result, candidate)
|
|
}
|
|
sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID })
|
|
return result, nil
|
|
}
|
|
|
|
// selectWWANATPort prefers the secondary AT port (…at1) over the primary
|
|
// (…at0) when both exist, falling back to the first AT port otherwise. Some
|
|
// Qualcomm MHI modems (notably the UFI dongle behind the OpenStick 410) answer
|
|
// on at1 immediately while at0 delays every response by 10-20 seconds, so the
|
|
// secondary port is the usable AT channel.
|
|
func selectWWANATPort(ports []Port) Port {
|
|
for _, port := range ports {
|
|
if port.InterfaceNumber == 1 {
|
|
return port
|
|
}
|
|
}
|
|
return ports[0]
|
|
}
|
|
|
|
func parseWWANPortName(name string) (index, kind string, portIndex int, ok bool) {
|
|
if !strings.HasPrefix(name, "wwan") {
|
|
return "", "", 0, false
|
|
}
|
|
rest := strings.TrimPrefix(name, "wwan")
|
|
cut := 0
|
|
for cut < len(rest) && rest[cut] >= '0' && rest[cut] <= '9' {
|
|
cut++
|
|
}
|
|
if cut == 0 {
|
|
return "", "", 0, false
|
|
}
|
|
index, rest = rest[:cut], rest[cut:]
|
|
for _, candidateKind := range []string{"at", "qmi"} {
|
|
if !strings.HasPrefix(rest, candidateKind) {
|
|
continue
|
|
}
|
|
numberText := strings.TrimPrefix(rest, candidateKind)
|
|
number, err := strconv.Atoi(numberText)
|
|
if err != nil || number < 0 {
|
|
return "", "", 0, false
|
|
}
|
|
return index, candidateKind, number, true
|
|
}
|
|
return "", "", 0, false
|
|
}
|
|
|
|
func selectWWANNetworkInterface(sysRoot, index string) string {
|
|
exact := "wwan" + index
|
|
if _, err := os.Stat(filepath.Join(sysRoot, "class", "net", exact)); err == nil {
|
|
return exact
|
|
}
|
|
entries, _ := os.ReadDir(filepath.Join(sysRoot, "class", "net"))
|
|
for _, entry := range entries {
|
|
if strings.HasPrefix(entry.Name(), exact) {
|
|
return entry.Name()
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func readPCIIdentity(path, sysRoot string) (vendorID, productID string) {
|
|
root := filepath.Clean(sysRoot)
|
|
for current := filepath.Clean(path); current != "." && current != string(filepath.Separator); current = filepath.Dir(current) {
|
|
vendor := strings.TrimPrefix(strings.ToLower(readTrimmed(filepath.Join(current, "vendor"))), "0x")
|
|
device := strings.TrimPrefix(strings.ToLower(readTrimmed(filepath.Join(current, "device"))), "0x")
|
|
if vendor != "" && device != "" {
|
|
return vendor, device
|
|
}
|
|
if current == root {
|
|
break
|
|
}
|
|
}
|
|
return "", ""
|
|
}
|
|
|
|
func parseUSBInterfaceName(name string) (int, bool) {
|
|
_, suffix, ok := strings.Cut(name, ":")
|
|
if !ok {
|
|
return 0, false
|
|
}
|
|
_, numberText, ok := strings.Cut(suffix, ".")
|
|
if !ok || numberText == "" {
|
|
return 0, false
|
|
}
|
|
number, err := strconv.ParseInt(numberText, 10, 32)
|
|
return int(number), err == nil
|
|
}
|
|
|
|
func readHexByte(path string) (int, error) {
|
|
value := readTrimmed(path)
|
|
number, err := strconv.ParseUint(value, 16, 8)
|
|
return int(number), err
|
|
}
|
|
|
|
func readTrimmed(path string) string {
|
|
value, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(string(value))
|
|
}
|
|
|
|
func scanUSBInterface(root string) (ttyNames, qmiControls, networkInterfaces []string) {
|
|
ttySeen := make(map[string]struct{})
|
|
qmiSeen := make(map[string]struct{})
|
|
netSeen := make(map[string]struct{})
|
|
_ = filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
name := entry.Name()
|
|
switch {
|
|
case entry.IsDir() && (strings.HasPrefix(name, "ttyUSB") || strings.HasPrefix(name, "ttyACM")):
|
|
ttySeen[name] = struct{}{}
|
|
case strings.HasPrefix(name, "cdc-wdm"):
|
|
qmiSeen[name] = struct{}{}
|
|
case entry.IsDir() && filepath.Base(filepath.Dir(path)) == "net":
|
|
netSeen[name] = struct{}{}
|
|
}
|
|
return nil
|
|
})
|
|
for name := range ttySeen {
|
|
ttyNames = append(ttyNames, name)
|
|
}
|
|
for name := range qmiSeen {
|
|
qmiControls = append(qmiControls, name)
|
|
}
|
|
for name := range netSeen {
|
|
networkInterfaces = append(networkInterfaces, name)
|
|
}
|
|
sort.Strings(ttyNames)
|
|
sort.Strings(qmiControls)
|
|
sort.Strings(networkInterfaces)
|
|
return
|
|
}
|
|
|
|
func readSerialAliases(root string) map[string]string {
|
|
result := make(map[string]string)
|
|
entries, err := os.ReadDir(root)
|
|
if err != nil {
|
|
return result
|
|
}
|
|
for _, entry := range entries {
|
|
path := filepath.Join(root, entry.Name())
|
|
target, err := os.Readlink(path)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
name := filepath.Base(filepath.Clean(target))
|
|
if strings.HasPrefix(name, "ttyUSB") || strings.HasPrefix(name, "ttyACM") {
|
|
if existing := result[name]; existing == "" || path < existing {
|
|
result[name] = path
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// qmiWWANBoundDevices returns the set of USB device paths (for example "1-6"
|
|
// or the hub-attached "1-4.3.2") that currently have at least one interface
|
|
// bound to the kernel's qmi_wwan driver. Interface entries in the driver
|
|
// directory are named "<device-path>:<interface>.<altsetting>", so the part
|
|
// before the first colon is the owning USB device. The qmi_wwan driver only
|
|
// binds Qualcomm QMI control interfaces, so membership doubles as a vendor-
|
|
// neutral "this is a live QMI modem" signal.
|
|
func (d *SysFSDiscoverer) qmiWWANBoundDevices() map[string]struct{} {
|
|
driverRoot := filepath.Join(d.SysRoot, "bus", "usb", "drivers", "qmi_wwan")
|
|
entries, err := os.ReadDir(driverRoot)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
devices := make(map[string]struct{})
|
|
for _, entry := range entries {
|
|
// The driver directory also holds control files (bind, unbind, uevent,
|
|
// module, new_id, ...); only names containing a colon are interfaces.
|
|
deviceName, _, ok := strings.Cut(entry.Name(), ":")
|
|
if !ok || deviceName == "" {
|
|
continue
|
|
}
|
|
devices[deviceName] = struct{}{}
|
|
}
|
|
return devices
|
|
}
|
|
|
|
func candidateID(vendorID, productID, serialNumber, usbName string) string {
|
|
prefix := "usb-" + sanitizeID(vendorID)
|
|
serialNumber = strings.TrimSpace(serialNumber)
|
|
if serialNumber != "" && !strings.EqualFold(serialNumber, "android") {
|
|
// A surprising number of EC20/EC25 carrier boards expose the same
|
|
// factory/default USB serial number. The device manager is keyed by this
|
|
// value, so using the serial alone silently collapsed two modems connected
|
|
// to the same hub into one entry. Include the physical USB topology in the
|
|
// discovery key; configured devices remain stable through ATMapper's
|
|
// USB-path/IMEI matching even when Linux renumbers ttyUSB nodes.
|
|
return prefix + "-" + sanitizeID(serialNumber+"-"+usbName)
|
|
}
|
|
return prefix + "-" + sanitizeID(productID+"-"+usbName)
|
|
}
|
|
|
|
func sanitizeID(value string) string {
|
|
value = strings.ToLower(strings.TrimSpace(value))
|
|
var result strings.Builder
|
|
for _, character := range value {
|
|
if character >= 'a' && character <= 'z' ||
|
|
character >= '0' && character <= '9' ||
|
|
character == '-' || character == '_' {
|
|
result.WriteRune(character)
|
|
} else {
|
|
result.WriteByte('-')
|
|
}
|
|
}
|
|
return strings.Trim(result.String(), "-")
|
|
}
|
|
|
|
func assignQuectelPortRoles(ports []Port) {
|
|
// ttyUSB numbers are allocated globally by Linux. A second modem therefore
|
|
// commonly exposes ttyUSB4..ttyUSB7, so absolute tty names cannot identify
|
|
// the logical AT port. Infer the Quectel composition once per physical USB
|
|
// device and assign roles from that device's interface numbers.
|
|
base := 0x02
|
|
for _, port := range ports {
|
|
if port.InterfaceNumber <= 0x01 {
|
|
base = 0x00
|
|
break
|
|
}
|
|
}
|
|
for index := range ports {
|
|
switch ports[index].InterfaceNumber - base {
|
|
case 0:
|
|
ports[index].Role = PortRoleDiagnostic
|
|
case 1:
|
|
ports[index].Role = PortRoleNMEA
|
|
case 2:
|
|
ports[index].Role = PortRoleAT
|
|
case 3:
|
|
ports[index].Role = PortRoleModem
|
|
default:
|
|
ports[index].Role = PortRoleUnknown
|
|
}
|
|
}
|
|
}
|
|
|
|
func quecPortRole(interfaceNumber int, name string) PortRole {
|
|
// Initial best effort. assignQuectelPortRoles replaces this once every
|
|
// interface belonging to the same physical modem has been collected.
|
|
switch interfaceNumber {
|
|
case 0x00:
|
|
return PortRoleDiagnostic
|
|
case 0x01:
|
|
return PortRoleNMEA
|
|
case 0x02:
|
|
return PortRoleAT
|
|
case 0x03:
|
|
return PortRoleModem
|
|
default:
|
|
if name == "ttyUSB2" {
|
|
return PortRoleAT
|
|
}
|
|
return PortRoleUnknown
|
|
}
|
|
}
|
|
|
|
func selectATPort(ports []Port) Port {
|
|
var best Port
|
|
bestScore := 0
|
|
for _, port := range ports {
|
|
score := 0
|
|
switch {
|
|
case port.Role == PortRoleAT:
|
|
score = 120
|
|
case port.Name == "ttyUSB2":
|
|
score = 100
|
|
case port.InterfaceNumber == 0x04:
|
|
score = 90
|
|
case port.InterfaceNumber == 0x05:
|
|
score = 40
|
|
case port.Role == PortRoleModem:
|
|
score = 30
|
|
}
|
|
if score > bestScore {
|
|
best, bestScore = port, score
|
|
}
|
|
}
|
|
if bestScore <= 0 {
|
|
return Port{}
|
|
}
|
|
return best
|
|
}
|
|
|
|
type unsupportedDiscoverer struct{}
|
|
|
|
func (unsupportedDiscoverer) Discover(context.Context) ([]Candidate, error) {
|
|
return nil, ErrUnsupportedPlatform
|
|
}
|