diff --git a/internal/modem/discovery.go b/internal/modem/discovery.go index 33aa786..7db8b0e 100644 --- a/internal/modem/discovery.go +++ b/internal/modem/discovery.go @@ -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 diff --git a/internal/modem/discovery_common_test.go b/internal/modem/discovery_common_test.go index 5ecc785..90a7664 100644 --- a/internal/modem/discovery_common_test.go +++ b/internal/modem/discovery_common_test.go @@ -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}, diff --git a/internal/server/device_api.go b/internal/server/device_api.go index 601f612..dd12a0f 100644 --- a/internal/server/device_api.go +++ b/internal/server/device_api.go @@ -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{ diff --git a/internal/server/device_summary_test.go b/internal/server/device_summary_test.go index bf52e3c..6c38b5a 100644 --- a/internal/server/device_summary_test.go +++ b/internal/server/device_summary_test.go @@ -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 { diff --git a/web/src/pages/DevicesPage.tsx b/web/src/pages/DevicesPage.tsx index de7ac35..96ce350 100644 --- a/web/src/pages/DevicesPage.tsx +++ b/web/src/pages/DevicesPage.tsx @@ -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, }; }); diff --git a/web/src/types.ts b/web/src/types.ts index e920eef..16c2b7b 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -174,6 +174,7 @@ export interface DeviceStatus { export interface DiscoveredDevice { hardwareKind?: string; readerName?: string; + deviceType?: DeviceType; discoveryKey: string; controlPath: string; netInterface: string;