package modem import ( "context" "fmt" "io/fs" "os" "path/filepath" "sort" "strconv" "strings" ) const quectelVendorID = "2c7c" 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) { return nil, nil } return nil, fmt.Errorf("discover Quectel USB devices: %w", err) } 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"))) if vendorID != quectelVendorID { continue } state := devices[deviceName] if state == nil { productID := strings.ToLower(readTrimmed(filepath.Join(resolvedDevice, "idProduct"))) serialNumber := readTrimmed(filepath.Join(resolvedDevice, "serial")) state = &discoveredUSBDevice{ candidate: Candidate{ ID: candidateID(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 }) state.candidate.ATPort = selectATPort(state.candidate.Ports) result = append(result, state.candidate) } sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID }) return result, nil } 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 } func candidateID(productID, serialNumber, usbName string) string { serialNumber = strings.TrimSpace(serialNumber) if serialNumber != "" && !strings.EqualFold(serialNumber, "android") { return "quectel-" + sanitizeID(serialNumber) } return "quectel-" + 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 quecPortRole(interfaceNumber int, name string) PortRole { // Quectel exposes the same logical ports under more than one USB // composition. In both layouts seen on EC20/EC25 hardware the kernel // stable tty name is the stronger hint: ttyUSB0 is diagnostic and // ttyUSB2 is the primary AT port, even when their interface numbers are // 00/02 instead of 02/04. switch name { case "ttyUSB0": return PortRoleDiagnostic case "ttyUSB1": return PortRoleNMEA case "ttyUSB2": return PortRoleAT case "ttyUSB3": return PortRoleModem } switch interfaceNumber { case 0x02: return PortRoleDiagnostic case 0x03: return PortRoleNMEA case 0x04: return PortRoleAT case 0x05: 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.Name == "ttyUSB2": score = 120 case port.Role == PortRoleAT: 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 }