diff --git a/README.md b/README.md index 944ba66..786f02b 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ DJOneHub 是一款面向**大疆第一代 4G 模块**的第三方 macOS 管理 | 功能 | 状态 | 说明 | | --- | --- | --- | | 模块自动识别 | 已实现 | 识别大疆第一代 4G 模块,并处理拔出、重新连接和换卡 | +| 多模块管理 | 已实现 | 同时枚举多个模块,按 USB 物理位置切换并隔离短信与 eSIM 会话 | | 模块状态 | 已实现 | 显示运营商、信号、网络制式、SIM 状态和当前工作模式 | | 短信管理 | 已实现 | 接收、发送、自动轮询、验证码提取及模块旧短信清理 | | eSIM Profile | 已实现 | 读取、下载、启用、改名和删除兼容 eUICC 卡片中的 Profile | @@ -227,6 +228,10 @@ AT+CNUM AT 指令可以改变网络注册、PDP、USB 模式、短信存储和 SIM 状态。不了解作用的指令不要直接执行,也不要照搬来源不明的刷机或写入命令。 +### 同时连接多个模块 + +连接两个或更多兼容模块时,页面顶部会显示“当前模块”选择器。每个模块使用 macOS 提供的 USB `locationID` 区分;切换后,状态、AT、短信和 eSIM 操作都会指向所选模块,短信缓存按模块隔离。切换模块不会启用、删除或改写任何 eSIM Profile。 + ## 常用命令 ```text diff --git a/cmd/djonehub-macos/main.go b/cmd/djonehub-macos/main.go index 7adea7f..833721c 100644 --- a/cmd/djonehub-macos/main.go +++ b/cmd/djonehub-macos/main.go @@ -73,6 +73,8 @@ type modulePhonebookEntry struct { } type app struct { + deviceSwitchMu sync.Mutex + selectedUSBDevice string modem *modem.Manager esimMu sync.RWMutex esim *esim.Manager @@ -87,6 +89,7 @@ type app struct { smsMu sync.RWMutex sms []receivedSMS + smsByDevice map[string][]receivedSMS smsSendMu sync.Mutex smsReassembler *smscodec.Reassembler @@ -201,15 +204,20 @@ func main() { port, err = discoverATPort() if err != nil { usbDevice := discoverDJIUSBDevice() - usbATDevice, usbATErr := openDJIUSBAT() + selectedUSBDevice := "" + if usbDevice != nil { + selectedUSBDevice = usbDevice.LocationID + } + usbATDevice, usbATErr := openDJIUSBAT(selectedUSBDevice) instance := &app{ - port: "未发现 AT 串口", - discoveryError: err.Error(), - usbDevice: usbDevice, - usbAT: usbATDevice, - smsPollInterval: 8 * time.Second, - smsAutoCleanupME: true, - smsReassembler: smscodec.NewReassembler(), + selectedUSBDevice: selectedUSBDevice, + port: "未发现 AT 串口", + discoveryError: err.Error(), + usbDevice: usbDevice, + usbAT: usbATDevice, + smsPollInterval: 8 * time.Second, + smsAutoCleanupME: true, + smsReassembler: smscodec.NewReassembler(), } if usbDevice != nil { log.Printf("DJI USB device detected without AT serial port: %s %s (%s:%s)", @@ -403,6 +411,25 @@ func discoverATPort() (string, error) { return "", fmt.Errorf("no AT-capable port found among %s", strings.Join(attempted, ", ")) } +type usbDeviceID struct { + vendorID int + productID int +} + +var supportedDJIUSBDeviceIDs = []usbDeviceID{ + {vendorID: 0x2ca3, productID: 0x4006}, + {vendorID: 0x2c7c, productID: 0x0125}, +} + +func isSupportedDJIUSBDevice(vendorID, productID int) bool { + for _, id := range supportedDJIUSBDeviceIDs { + if id.vendorID == vendorID && id.productID == productID { + return true + } + } + return false +} + func portScore(port string) int { name := strings.ToLower(port) if strings.Contains(name, "quectel") || strings.Contains(name, "dji") { @@ -417,29 +444,35 @@ func portScore(port string) int { return 0 } -func discoverDJIUSBDevice() *usbDeviceStatus { +func discoverDJIUSBDevices() []*usbDeviceStatus { out, err := exec.Command("ioreg", "-r", "-c", "IOUSBHostInterface", "-l", "-w", "0").Output() if err != nil { return nil } + return parseDJIUSBDevices(string(out)) +} - var device *usbDeviceStatus - for _, block := range strings.Split(string(out), "\n\n") { +func parseDJIUSBDevices(out string) []*usbDeviceStatus { + byLocation := make(map[string]*usbDeviceStatus) + for _, block := range strings.Split(out, "\n\n") { vendorID, okVendor := intProperty(block, "idVendor") productID, okProduct := intProperty(block, "idProduct") - if !okVendor || !okProduct || vendorID != 0x2ca3 { + if !okVendor || !okProduct || !isSupportedDJIUSBDevice(vendorID, productID) { continue } + locationID := formatHexProperty(block, "locationID") + device := byLocation[locationID] if device == nil { device = &usbDeviceStatus{ Product: stringProperty(block, "USB Product Name"), Vendor: stringProperty(block, "USB Vendor Name"), VendorID: fmt.Sprintf("%04x", vendorID), ProductID: fmt.Sprintf("%04x", productID), - LocationID: formatHexProperty(block, "locationID"), + LocationID: locationID, Speed: usbSpeedName(intPropertyOrZero(block, "USBSpeed")), Mode: "vendor-specific USB mode", } + byLocation[locationID] = device if strings.TrimSpace(device.Product) == "" { device.Product = "DJI 4G Module" } @@ -460,16 +493,28 @@ func discoverDJIUSBDevice() *usbDeviceStatus { } device.Interfaces = append(device.Interfaces, iface) } - if device == nil { - return nil + devices := make([]*usbDeviceStatus, 0, len(byLocation)) + for _, device := range byLocation { + sort.SliceStable(device.Interfaces, func(i, j int) bool { + return device.Interfaces[i].Number < device.Interfaces[j].Number + }) + if allVendorSpecific(device.Interfaces) { + device.Mode = "vendor-specific QMI/diagnostic mode" + } + devices = append(devices, device) } - sort.SliceStable(device.Interfaces, func(i, j int) bool { - return device.Interfaces[i].Number < device.Interfaces[j].Number + sort.SliceStable(devices, func(i, j int) bool { + return devices[i].LocationID < devices[j].LocationID }) - if allVendorSpecific(device.Interfaces) { - device.Mode = "vendor-specific QMI/diagnostic mode" + return devices +} + +func discoverDJIUSBDevice() *usbDeviceStatus { + devices := discoverDJIUSBDevices() + if len(devices) == 0 { + return nil } - return device + return devices[0] } func allVendorSpecific(interfaces []usbInterfaceStatus) bool { @@ -607,6 +652,8 @@ func (a *app) pollSMSOnce() error { if a.demo || a.modem != nil { return nil } + a.deviceSwitchMu.Lock() + defer a.deviceSwitchMu.Unlock() if err := a.ensureUSBAT(); err != nil { a.setSMSPollStatus(err) return err @@ -648,7 +695,7 @@ func (a *app) ensureUSBAT() error { } return errors.New("USB AT is cooling down after disconnect") } - dev, err := openDJIUSBAT() + dev, err := openDJIUSBAT(a.selectedUSBDevice) if err != nil { return err } @@ -698,6 +745,8 @@ func (a *app) markUSBATDetached(reason string) { func (a *app) routes() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /api/health", a.health) + mux.HandleFunc("GET /api/devices", a.listDevices) + mux.HandleFunc("POST /api/devices/select", a.selectDevice) mux.HandleFunc("GET /api/status", a.status) mux.HandleFunc("GET /api/sms", a.listSMS) mux.HandleFunc("GET /api/sms/status", a.smsStatus) @@ -738,10 +787,88 @@ func securityHeaders(next http.Handler) http.Handler { func (a *app) health(w http.ResponseWriter, _ *http.Request) { usbDevice := a.currentUSBDevice() + usbDevices := discoverDJIUSBDevices() esimManager, _ := a.currentESIMManager() writeJSON(w, http.StatusOK, map[string]any{ "ok": true, "port": a.port, "esim_available": a.demo || esimManager != nil, "demo": a.demo, - "usb_device": usbDevice, "discovery_error": a.discoveryError, + "usb_device": usbDevice, "usb_devices": usbDevices, "selected_usb_device": a.selectedUSBDevice, + "discovery_error": a.discoveryError, + }) +} + +func (a *app) listDevices(w http.ResponseWriter, _ *http.Request) { + devices := discoverDJIUSBDevices() + writeJSON(w, http.StatusOK, map[string]any{ + "devices": devices, + "selected_location_id": a.selectedUSBDevice, + }) +} + +func (a *app) selectDevice(w http.ResponseWriter, r *http.Request) { + if a.demo || a.modem != nil { + writeError(w, http.StatusBadRequest, "USB device selection is only available for direct USB AT mode") + return + } + var body struct { + LocationID string `json:"location_id"` + } + if !decodeJSON(w, r, &body) { + return + } + body.LocationID = strings.ToLower(strings.TrimSpace(body.LocationID)) + var selected *usbDeviceStatus + for _, device := range discoverDJIUSBDevices() { + if strings.ToLower(device.LocationID) == body.LocationID { + selected = device + break + } + } + if selected == nil { + writeError(w, http.StatusNotFound, "requested DJI USB device is not connected") + return + } + + a.deviceSwitchMu.Lock() + defer a.deviceSwitchMu.Unlock() + if a.selectedUSBDevice != selected.LocationID || a.usbAT == nil { + if a.usbAT != nil { + a.usbAT.Close() + a.usbAT = nil + } + a.esimMu.Lock() + oldESIM := a.esim + a.esim = nil + a.esimSwitchAllowed = false + a.esimMu.Unlock() + if oldESIM != nil { + oldESIM.NotifyModemReset() + } + a.smsMu.Lock() + if a.smsByDevice == nil { + a.smsByDevice = make(map[string][]receivedSMS) + } + if a.selectedUSBDevice != "" { + a.smsByDevice[a.selectedUSBDevice] = append([]receivedSMS(nil), a.sms...) + } + a.sms = append([]receivedSMS(nil), a.smsByDevice[selected.LocationID]...) + a.smsLastPoll = time.Time{} + a.smsLastPollError = "" + a.smsReassembler = smscodec.NewReassembler() + a.smsMu.Unlock() + a.selectedUSBDevice = selected.LocationID + a.usbDevice = selected + a.usbATBackoffUntil = time.Time{} + a.usbATBackoffErr = "" + if err := a.ensureUSBAT(); err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + log.Printf("selected DJI USB device %s", selected.LocationID) + } + writeJSON(w, http.StatusOK, map[string]any{ + "selected_location_id": a.selectedUSBDevice, + "device": selected, + "port": a.port, }) } @@ -767,6 +894,8 @@ func (a *app) status(w http.ResponseWriter, _ *http.Request) { return } if a.modem == nil { + a.deviceSwitchMu.Lock() + defer a.deviceSwitchMu.Unlock() // A libusb handle may survive a physical unplug. Refresh the macOS USB // inventory before using it so the UI never reports a stale connection. if a.usbAT != nil && a.currentUSBDevice() == nil { @@ -811,10 +940,19 @@ func (a *app) currentUSBDevice() *usbDeviceStatus { if a.modem != nil || a.demo { return a.usbDevice } - usbDevice := discoverDJIUSBDevice() + devices := discoverDJIUSBDevices() + if a.selectedUSBDevice == "" && len(devices) > 0 { + a.selectedUSBDevice = devices[0].LocationID + } + for _, device := range devices { + if device.LocationID == a.selectedUSBDevice { + a.usbDevice = device + return device + } + } // Never retain the last successful scan: that is stale after an unplug. - a.usbDevice = usbDevice - return usbDevice + a.usbDevice = nil + return nil } func (a *app) usbATStatus() (modem.DeviceStatus, error) { @@ -1230,6 +1368,8 @@ func (a *app) runATCommand(command string, timeout time.Duration) (string, error return response, nil } if a.modem == nil { + a.deviceSwitchMu.Lock() + defer a.deviceSwitchMu.Unlock() if err := a.ensureUSBAT(); err != nil { return "", err } diff --git a/cmd/djonehub-macos/main_test.go b/cmd/djonehub-macos/main_test.go index 1f80879..cbe29b8 100644 --- a/cmd/djonehub-macos/main_test.go +++ b/cmd/djonehub-macos/main_test.go @@ -1,6 +1,9 @@ package main -import "testing" +import ( + "strings" + "testing" +) func TestPortScore(t *testing.T) { tests := []struct { @@ -22,6 +25,69 @@ func TestPortScore(t *testing.T) { } } +func TestIsSupportedDJIUSBDevice(t *testing.T) { + tests := []struct { + name string + vendorID int + productID int + want bool + }{ + {name: "DJI management mode", vendorID: 0x2ca3, productID: 0x4006, want: true}, + {name: "Quectel compatible mode", vendorID: 0x2c7c, productID: 0x0125, want: true}, + {name: "unrelated Quectel device", vendorID: 0x2c7c, productID: 0x9999, want: false}, + {name: "unrelated USB device", vendorID: 0x1234, productID: 0x5678, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isSupportedDJIUSBDevice(tt.vendorID, tt.productID); got != tt.want { + t.Fatalf("isSupportedDJIUSBDevice(%04x:%04x) = %v, want %v", tt.vendorID, tt.productID, got, tt.want) + } + }) + } +} + +func TestParseDJIUSBDevicesKeepsIdenticalModulesSeparate(t *testing.T) { + block := func(location, iface string) string { + return strings.NewReplacer("LOCATION", location, "IFACE", iface).Replace(` + "idVendor" = 11388 + "idProduct" = 293 + "locationID" = LOCATION + "USB Product Name" = "Baiwang" + "USB Vendor Name" = "BAIWANG" + "USBSpeed" = 3 + "bInterfaceNumber" = IFACE + "bInterfaceClass" = 255 + "bInterfaceSubClass" = 0 + "bInterfaceProtocol" = 0 + "bNumEndpoints" = 3`) + } + raw := block("34668544", "0") + "\n\n" + block("34668544", "1") + "\n\n" + block("34734080", "0") + + devices := parseDJIUSBDevices(raw) + if len(devices) != 2 { + t.Fatalf("parseDJIUSBDevices returned %d devices, want 2", len(devices)) + } + if devices[0].LocationID != "0x2110000" || devices[1].LocationID != "0x2120000" { + t.Fatalf("unexpected locations: %q, %q", devices[0].LocationID, devices[1].LocationID) + } + if len(devices[0].Interfaces) != 2 || len(devices[1].Interfaces) != 1 { + t.Fatalf("interfaces were not grouped per physical module: %d, %d", len(devices[0].Interfaces), len(devices[1].Interfaces)) + } +} + +func TestParseUSBLocationID(t *testing.T) { + for _, value := range []string{"0x2110000", "2110000", "0X2110000"} { + got, err := parseUSBLocationID(value) + if err != nil || got != 0x02110000 { + t.Fatalf("parseUSBLocationID(%q) = %#x, %v", value, got, err) + } + } + if _, err := parseUSBLocationID("not-a-location"); err == nil { + t.Fatal("parseUSBLocationID accepted invalid input") + } +} + func TestParseUSBNetMode(t *testing.T) { for _, tt := range []struct { response string diff --git a/cmd/djonehub-macos/usbat_darwin.go b/cmd/djonehub-macos/usbat_darwin.go index caead6f..46cd941 100644 --- a/cmd/djonehub-macos/usbat_darwin.go +++ b/cmd/djonehub-macos/usbat_darwin.go @@ -12,20 +12,19 @@ import "C" import ( "errors" "fmt" + "strconv" "strings" "sync" "time" "unsafe" ) -const ( - djiUSBVendorID = 0x2ca3 - djiUSBProductID = 0x4006 -) - type usbAT struct { ctx *C.libusb_context handle *C.libusb_device_handle + vendorID int + productID int + locationID uint32 iface int endpointIn byte endpointOut byte @@ -38,15 +37,52 @@ type usbATCandidate struct { endpointOut byte } -func openDJIUSBAT() (*usbAT, error) { +func openDJIUSBAT(locationID string) (*usbAT, error) { var ctx *C.libusb_context if rc := C.libusb_init(&ctx); rc != 0 { return nil, fmt.Errorf("libusb init: %s", usbErrorName(rc)) } - handle := C.libusb_open_device_with_vid_pid(ctx, djiUSBVendorID, djiUSBProductID) + desiredLocation, err := parseUSBLocationID(locationID) + if err != nil { + C.libusb_exit(ctx) + return nil, err + } + var handle *C.libusb_device_handle + var selectedID usbDeviceID + var selectedLocation uint32 + var list **C.libusb_device + count := C.libusb_get_device_list(ctx, &list) + if count < 0 { + C.libusb_exit(ctx) + return nil, fmt.Errorf("list USB devices: %s", usbErrorName(C.int(count))) + } + defer C.libusb_free_device_list(list, 1) + for _, device := range unsafe.Slice(list, int(count)) { + var descriptor C.struct_libusb_device_descriptor + if C.libusb_get_device_descriptor(device, &descriptor) != 0 { + continue + } + vendorID, productID := int(descriptor.idVendor), int(descriptor.idProduct) + if !isSupportedDJIUSBDevice(vendorID, productID) { + continue + } + candidateLocation := libusbLocationID(device) + if desiredLocation != 0 && candidateLocation != desiredLocation { + continue + } + if C.libusb_open(device, &handle) != 0 || handle == nil { + continue + } + selectedID = usbDeviceID{vendorID: vendorID, productID: productID} + selectedLocation = candidateLocation + break + } if handle == nil { C.libusb_exit(ctx) - return nil, errors.New("DJI USB AT device 2ca3:4006 not found") + if desiredLocation != 0 { + return nil, fmt.Errorf("supported DJI USB AT device at 0x%x not found", desiredLocation) + } + return nil, errors.New("supported DJI USB AT device not found (expected 2ca3:4006 or 2c7c:0125)") } candidates, err := usbATCandidates(handle) if err != nil { @@ -63,6 +99,9 @@ func openDJIUSBAT() (*usbAT, error) { dev := &usbAT{ ctx: ctx, handle: handle, + vendorID: selectedID.vendorID, + productID: selectedID.productID, + locationID: selectedLocation, iface: candidate.iface, endpointIn: candidate.endpointIn, endpointOut: candidate.endpointOut, @@ -86,6 +125,28 @@ func openDJIUSBAT() (*usbAT, error) { return nil, errors.New("no USB bulk interface candidates found for DJI AT bridge") } +func parseUSBLocationID(value string) (uint32, error) { + value = strings.TrimSpace(strings.TrimPrefix(strings.ToLower(value), "0x")) + if value == "" { + return 0, nil + } + parsed, err := strconv.ParseUint(value, 16, 32) + if err != nil { + return 0, fmt.Errorf("invalid USB location ID %q: %w", value, err) + } + return uint32(parsed), nil +} + +func libusbLocationID(device *C.libusb_device) uint32 { + location := uint32(C.libusb_get_bus_number(device)) << 24 + var ports [8]C.uint8_t + count := int(C.libusb_get_port_numbers(device, &ports[0], C.int(len(ports)))) + for index := 0; index < count && index < 5; index++ { + location |= uint32(ports[index]&0x0f) << uint(20-index*4) + } + return location +} + func usbATCandidates(handle *C.libusb_device_handle) ([]usbATCandidate, error) { dev := C.libusb_get_device(handle) if dev == nil { @@ -295,8 +356,8 @@ func (u *usbAT) Description() string { if u == nil { return "USB AT" } - return fmt.Sprintf("USB AT · 2ca3:4006 interface %d out 0x%02x in 0x%02x", - u.iface, u.endpointOut, u.endpointIn) + return fmt.Sprintf("USB AT · %04x:%04x @ 0x%x interface %d out 0x%02x in 0x%02x", + u.vendorID, u.productID, u.locationID, u.iface, u.endpointOut, u.endpointIn) } func (u *usbAT) bulkWriteLocked(endpoint byte, payload []byte, timeout time.Duration) error { diff --git a/cmd/djonehub-macos/usbat_stub.go b/cmd/djonehub-macos/usbat_stub.go index 2275962..62b840f 100644 --- a/cmd/djonehub-macos/usbat_stub.go +++ b/cmd/djonehub-macos/usbat_stub.go @@ -9,7 +9,7 @@ import ( type usbAT struct{} -func openDJIUSBAT() (*usbAT, error) { +func openDJIUSBAT(string) (*usbAT, error) { return nil, errors.New("USB AT requires macOS cgo build with libusb") } diff --git a/cmd/djonehub-macos/web/app.js b/cmd/djonehub-macos/web/app.js index d535442..9161c7e 100644 --- a/cmd/djonehub-macos/web/app.js +++ b/cmd/djonehub-macos/web/app.js @@ -5,6 +5,7 @@ let esimHealthInFlight = false; let networkTrafficTimer = null; let networkTrafficPrevious = null; let networkTrafficInFlight = false; +let deviceSwitchInFlight = false; function setThemePreference(theme) { if (theme === "light" || theme === "dark") { @@ -64,6 +65,53 @@ async function api(path, options = {}) { return data; } +async function loadDevices() { + if (deviceSwitchInFlight) return; + try { + const result = await api("/api/devices"); + const devices = Array.isArray(result.devices) ? result.devices : []; + const picker = $("#device-picker"); + const select = $("#device-select"); + picker.hidden = devices.length < 2; + const options = devices.map((device, index) => { + const option = document.createElement("option"); + option.value = device.location_id; + option.textContent = `模块 ${index + 1} · ${device.location_id}`; + option.selected = device.location_id === result.selected_location_id; + return option; + }); + select.replaceChildren(...options); + } catch (error) { + $("#device-picker").hidden = true; + } +} + +async function selectDevice(locationID) { + const select = $("#device-select"); + deviceSwitchInFlight = true; + select.disabled = true; + try { + await api("/api/devices/select", { + method: "POST", + body: JSON.stringify({ location_id: locationID }), + }); + lastSMSCount = null; + networkTrafficPrevious = null; + $("#at-output").textContent = "已切换模块,等待命令"; + await Promise.all([loadStatus(), loadSMS()]); + const activeView = document.querySelector(".view.active")?.id; + if (activeView === "esim") await loadESIM(); + if (activeView === "network") await loadNetwork(); + notice(`已切换到 ${select.selectedOptions[0]?.textContent || locationID}`); + } catch (error) { + notice(`切换失败:${error.message}`); + } finally { + deviceSwitchInFlight = false; + select.disabled = false; + await loadDevices(); + } +} + function notice(message) { const el = $("#notice"); el.textContent = message; @@ -1044,7 +1092,7 @@ $("#at-form").addEventListener("submit", async (event) => { }); $("#refresh").addEventListener("click", async () => { - await Promise.all([loadStatus(), loadSMS()]); + await Promise.all([loadDevices(), loadStatus(), loadSMS()]); notice("状态已刷新"); }); $("#refresh-sms").addEventListener("click", async () => { @@ -1102,9 +1150,12 @@ $("#usbnet-mode-1").addEventListener("click", () => setUSBNetMode(1)); $("#usbnet-mode-2").addEventListener("click", () => setUSBNetMode(2)); $("#usbnet-mode-3").addEventListener("click", () => setUSBNetMode(3)); $("#reboot-module").addEventListener("click", rebootModule); +$("#device-select").addEventListener("change", (event) => selectDevice(event.currentTarget.value)); +loadDevices(); loadStatus(); loadSMS(); setNetworkTrafficPolling(true); setInterval(loadStatus, 10000); setInterval(loadSMS, 5000); +setInterval(loadDevices, 5000); diff --git a/cmd/djonehub-macos/web/index.html b/cmd/djonehub-macos/web/index.html index 090ebdc..03e61b6 100644 --- a/cmd/djonehub-macos/web/index.html +++ b/cmd/djonehub-macos/web/index.html @@ -20,6 +20,10 @@
正在连接大疆 4G 模块