feat: auto-detect DJI 4G modules (#32)

This commit is contained in:
Rain Seven
2026-08-16 17:08:51 +08:00
committed by GitHub
parent 5eee89a92a
commit 82494f519b
6 changed files with 76 additions and 4 deletions
+20 -3
View File
@@ -11,7 +11,11 @@ import (
"strings"
)
const quectelVendorID = "2c7c"
const (
quectelVendorID = "2c7c"
djiVendorID = "2ca3"
dji4GProductID = "4006"
)
type SysFSDiscoverer struct {
SysRoot string
@@ -70,13 +74,13 @@ func (d *SysFSDiscoverer) Discover(ctx context.Context) ([]Candidate, error) {
resolvedDevice = devicePath
}
vendorID := strings.ToLower(readTrimmed(filepath.Join(resolvedDevice, "idVendor")))
if vendorID != quectelVendorID {
productID := strings.ToLower(readTrimmed(filepath.Join(resolvedDevice, "idProduct")))
if !isSupportedUSBModem(vendorID, productID) {
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{
@@ -141,6 +145,19 @@ func (d *SysFSDiscoverer) Discover(ctx context.Context) ([]Candidate, error) {
return result, nil
}
func isSupportedUSBModem(vendorID, productID string) bool {
return strings.EqualFold(strings.TrimSpace(vendorID), quectelVendorID) ||
IsDJI4GUSB(vendorID, productID)
}
// 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
+23
View File
@@ -2,6 +2,29 @@ package modem
import "testing"
func TestSupportedUSBModemIdentity(t *testing.T) {
tests := []struct {
name string
vendorID string
productID string
want bool
}{
{name: "Quectel", vendorID: "2c7c", productID: "0125", want: true},
{name: "DJI 4G module", vendorID: "2ca3", productID: "4006", want: true},
{name: "DJI 4G module uppercase", vendorID: "2CA3", productID: "4006", want: true},
{name: "unrelated DJI device", vendorID: "2ca3", productID: "001f", want: false},
{name: "unrelated USB device", vendorID: "0403", productID: "6001", want: false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := isSupportedUSBModem(test.vendorID, test.productID); got != test.want {
t.Fatalf("isSupportedUSBModem(%q, %q) = %v, want %v", test.vendorID, test.productID, got, test.want)
}
})
}
}
func TestSelectATPortPrefersTTYUSB2AcrossUSBCompositions(t *testing.T) {
ports := []Port{
{Name: "ttyUSB2", InterfaceNumber: 0x02, Role: PortRoleDiagnostic},
+13
View File
@@ -387,6 +387,7 @@ func (s *Server) handleDiscoveredDevices(w http.ResponseWriter, r *http.Request)
result = append(result, map[string]any{
"hardware_kind": candidate.HardwareKind,
"reader_name": candidate.ReaderName,
"device_type": discoveredDeviceType(candidate),
"discovery_key": entry.ID,
"control_path": controlPath,
"net_interface": candidate.NetworkInterface,
@@ -1913,6 +1914,8 @@ func fillConfigFromPhysical(config *store.Device, entry device.Device) {
config.NetworkEnabled = false
config.SMSEnabled = true
config.VoWiFiEnabled = true
} else if modem.IsDJI4GUSB(candidate.VendorID, candidate.ProductID) {
config.DeviceType = store.DeviceTypeDJI4G
}
if config.Interface == "" {
config.Interface = candidate.NetworkInterface
@@ -1937,6 +1940,16 @@ func fillConfigFromPhysical(config *store.Device, entry device.Device) {
}
}
func discoveredDeviceType(candidate modem.Candidate) string {
if candidate.HardwareKind == "pcsc" {
return store.DeviceTypeUSBSIMReader
}
if modem.IsDJI4GUSB(candidate.VendorID, candidate.ProductID) {
return store.DeviceTypeDJI4G
}
return ""
}
func modemSummary(snapshot *device.Snapshot, phone string, phoneSource string) map[string]any {
if snapshot == nil {
return map[string]any{
+18
View File
@@ -6,10 +6,28 @@ import (
"time"
"vocat/internal/device"
"vocat/internal/modem"
"vocat/internal/store"
"vocat/internal/vowifi"
)
func TestFillConfigFromPhysicalClassifiesDJI4G(t *testing.T) {
config := store.Device{DeviceType: store.DeviceTypePCIeEC20EC25}
entry := device.Device{Candidate: modem.Candidate{
VendorID: "2ca3",
ProductID: "4006",
}}
fillConfigFromPhysical(&config, entry)
if config.DeviceType != store.DeviceTypeDJI4G {
t.Fatalf("device type = %q, want %q", config.DeviceType, store.DeviceTypeDJI4G)
}
if got := discoveredDeviceType(entry.Candidate); got != store.DeviceTypeDJI4G {
t.Fatalf("discovered device type = %q, want %q", got, store.DeviceTypeDJI4G)
}
}
func TestConfiguredDeviceSummaryIgnoresVoWiFiRuntimeFromPreviousSIM(t *testing.T) {
database, err := store.Open(context.Background(), ":memory:")
if err != nil {
+1 -1
View File
@@ -398,7 +398,7 @@ export default function DevicesPage() {
modemImei: d.imei || "",
usbPath: d.usbPath || "",
deviceBackend: backend,
deviceType: isReader ? "usb_sim_reader" : prev.deviceType,
deviceType: d.deviceType || (isReader ? "usb_sim_reader" : prev.deviceType),
esimTransport: isReader ? "pcsc" : backend,
};
});
+1
View File
@@ -174,6 +174,7 @@ export interface DeviceStatus {
export interface DiscoveredDevice {
hardwareKind?: string;
readerName?: string;
deviceType?: DeviceType;
discoveryKey: string;
controlPath: string;
netInterface: string;