diff --git a/README.md b/README.md index ea1e82e..0fea48a 100755 --- a/README.md +++ b/README.md @@ -71,6 +71,28 @@ In order to install this application, Please see the file winbuild.bat in this directory, or use the MSI file build by github actions on any commit +### USB2CAN (8devices Korlan) Support on Windows + +The DroneCAN GUI Tool now includes support for 8devices USB2CAN (Korlan) adapters on Windows. + +**Setup:** +1. Connect your 8devices USB2CAN adapter +2. Run the setup script to install the required DLL: + ``` + python setup_usb2can.py + ``` +3. The USB2CAN adapter will now appear in the interface selection dropdown as "8devices USB2CAN (channel_id)" + +**Features:** +- Automatic detection of USB2CAN adapters +- Native integration with DroneCAN protocol +- Full support for all GUI tool features + +**Requirements:** +- 8devices USB2CAN adapter (Korlan) +- Windows 10/11 (x64 or x86) +- DLL files included in `bin/usb2can_canal_v2.0.0/` + ## Installing on macOS OSX support is a bit lacking in the way that installation doesn't create an entry in the applications menu, diff --git a/bin/dronecan_gui_tool_launcher b/bin/dronecan_gui_tool_launcher new file mode 100644 index 0000000..f6b73d6 --- /dev/null +++ b/bin/dronecan_gui_tool_launcher @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2016 UAVCAN Development Team +# +# This software is distributed under the terms of the MIT License. +# +# Author: Pavel Kirienko +# + +import os +import sys +import multiprocessing + +# +# When frozen, stdout/stderr are None, causing nasty exceptions. This workaround silences them. +# +class SupermassiveBlackHole: + def write(self, *_): + pass + + def read(self, *_): + pass + + def flush(self): + pass + + def close(self): + pass + +try: + sys.stdout.flush() + sys.stderr.flush() +except AttributeError: + sys.__stdout__ = sys.stdout = SupermassiveBlackHole() + sys.__stderr__ = sys.stderr = SupermassiveBlackHole() + sys.__stdin__ = sys.stdin = SupermassiveBlackHole() + +# +# Calling main directly. +# The 'if' wrapper is absolutely needed because we're spawning new processes with 'multiprocessing'; refer +# to the Python docs for more info. +# +if __name__ == '__main__': + multiprocessing.freeze_support() + + # Import and run main - avoid relative imports by running as absolute import + import dronecan_gui_tool.main + dronecan_gui_tool.main.main() \ No newline at end of file diff --git a/bin/usb2can_canal_v2.0.0/x64/Release/usb2can.dll b/bin/usb2can_canal_v2.0.0/x64/Release/usb2can.dll new file mode 100644 index 0000000..6d756b3 Binary files /dev/null and b/bin/usb2can_canal_v2.0.0/x64/Release/usb2can.dll differ diff --git a/bin/usb2can_canal_v2.0.0/x64/Release/usb2can.lib b/bin/usb2can_canal_v2.0.0/x64/Release/usb2can.lib new file mode 100644 index 0000000..ffeb392 Binary files /dev/null and b/bin/usb2can_canal_v2.0.0/x64/Release/usb2can.lib differ diff --git a/bin/usb2can_canal_v2.0.0/x86/Release/usb2can.dll b/bin/usb2can_canal_v2.0.0/x86/Release/usb2can.dll new file mode 100644 index 0000000..d58f036 Binary files /dev/null and b/bin/usb2can_canal_v2.0.0/x86/Release/usb2can.dll differ diff --git a/bin/usb2can_canal_v2.0.0/x86/Release/usb2can.lib b/bin/usb2can_canal_v2.0.0/x86/Release/usb2can.lib new file mode 100644 index 0000000..7d8c1da Binary files /dev/null and b/bin/usb2can_canal_v2.0.0/x86/Release/usb2can.lib differ diff --git a/dronecan_gui_tool/main.py b/dronecan_gui_tool/main.py index 611ffc2..922f1d3 100644 --- a/dronecan_gui_tool/main.py +++ b/dronecan_gui_tool/main.py @@ -70,6 +70,11 @@ import dronecan +# Ensure can.__version__ is defined (cx_Freeze builds lack importlib.metadata) +import can +if not hasattr(can, '__version__'): + can.__version__ = '4.0.0' + from PyQt5.QtWidgets import QApplication, QMainWindow, QWidget, QVBoxLayout, QSplitter, QAction from PyQt5.QtGui import QKeySequence, QDesktopServices from PyQt5.QtCore import QTimer, Qt, QUrl @@ -100,6 +105,68 @@ NODE_NAME = 'org.dronecan.gui_tool' +def _patch_python_can_driver(): + """Fix the bundled dronecan PythonCAN backend for USB2CAN (and other python-can buses). + + dronecan.driver.python_can.PythonCAN.receive() multiplies the timeout by 1000, + as if python-can used milliseconds. python-can Bus.recv() uses seconds, so + node.spin(0.1) blocks for 100 seconds and keeps the adapter locked. A second + GUI instance then fails with CanalOpen() error code 0. + """ + try: + from dronecan.driver.python_can import PythonCAN + from dronecan.driver.common import CANFrame + except Exception: + logger.warning('Could not patch python-can driver', exc_info=True) + return + + if getattr(PythonCAN, '_dronecan_gui_receive_patched', False): + return + + def receive(self, timeout=None): + self._check_write_feedback() + try: + # python-can Bus.recv() expects seconds, or None to wait forever + msg = self._bus.recv(timeout=timeout) + if msg is not None: + ts_mono = time.monotonic() + ts_real = time.time() + + if ts_real and not ts_mono: + ts_mono = self._convert_real_to_monotonic(ts_real) + + frame = CANFrame( + msg.arbitration_id, + msg.data, + True, + ts_monotonic=ts_mono, + ts_real=ts_real, + ) + self._rx_hook(frame) + return frame + except Exception: + logger.error('Receive exception', exc_info=True) + + PythonCAN.receive = receive + PythonCAN._dronecan_gui_receive_patched = True + logger.info('Patched python-can receive() to use second timeouts') + + +def _resolve_usb2can_dll(): + """Return the bundled usb2can.dll path for this process architecture.""" + import platform + + if getattr(sys, 'frozen', False): + gui_tool_dir = os.path.dirname(sys.executable) + else: + gui_tool_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + arch = platform.machine().lower() + subdir = 'x64' if arch in ('amd64', 'x86_64', 'x64') else 'x86' + dll_path = os.path.join(gui_tool_dir, 'bin', 'usb2can_canal_v2.0.0', subdir, 'Release', 'usb2can.dll') + return dll_path + + class MainWindow(QMainWindow): MAX_SUCCESSIVE_NODE_ERRORS = 1000 @@ -581,6 +648,8 @@ def closeEvent(self, qcloseevent): def main(): logger.info('Starting the application') + logger.info('Log file: %s', log_file.name) + _patch_python_can_driver() app = QApplication(sys.argv) while True: @@ -629,10 +698,71 @@ def main(): node_info.software_version.major = __version__[0] node_info.software_version.minor = __version__[1] - node = dronecan.make_node(iface, - node_info=node_info, - mode=dronecan.uavcan.protocol.NodeStatus().MODE_OPERATIONAL, - **iface_kwargs) + # Handle USB2CAN interface specification (format: "usb2can:channel") + actual_iface = iface + if iface and ':' in iface: + bustype, channel = iface.split(':', 1) + if bustype == 'usb2can': + # 8devices examples use a lowercase serial; WMI often returns uppercase + actual_iface = channel.strip() + iface_kwargs['bustype'] = 'usb2can' + + dll_path = _resolve_usb2can_dll() + if not os.path.isfile(dll_path): + raise RuntimeError('USB2CAN DLL not found at %s' % dll_path) + iface_kwargs['dll'] = dll_path + + # Add the DLL directory to PATH so usb2can.dll can load its dependencies + dll_dir = os.path.dirname(dll_path) + if dll_dir not in os.environ.get('PATH', ''): + os.environ['PATH'] = dll_dir + ';' + os.environ.get('PATH', '') + if hasattr(os, 'add_dll_directory'): + try: + os.add_dll_directory(dll_dir) + except (FileNotFoundError, OSError): + pass + + logger.info('Using USB2CAN interface: channel=%s, dll=%s (frozen=%s)', + actual_iface, dll_path, getattr(sys, 'frozen', False)) + elif bustype == 'pcan': + actual_iface = channel + iface_kwargs['bustype'] = 'pcan' + logger.info('Using PCAN interface: channel=%s', channel) + + # CanalOpen() can fail once if the adapter is still settling or a + # previous instance has just released it. Retry a couple of times. + attempts = 3 if iface_kwargs.get('bustype') == 'usb2can' else 1 + node = None + last_open_error = None + for attempt in range(1, attempts + 1): + try: + node = dronecan.make_node(actual_iface, + node_info=node_info, + mode=dronecan.uavcan.protocol.NodeStatus().MODE_OPERATIONAL, + **iface_kwargs) + break + except Exception as ex: + last_open_error = ex + if attempt < attempts and 'CanalOpen' in str(ex): + logger.warning('USB2CAN CanalOpen attempt %d/%d failed, retrying: %s', + attempt, attempts, ex) + time.sleep(0.5) + continue + raise + if node is None: + raise last_open_error + + # Monkey-patch flush_tx_buffer for bus drivers that don't implement it + # (e.g. usb2can). The dronecan PythonCAN writer thread calls flush_tx_buffer() + # after every send, but not all python-can backends provide it. + try: + can_bus = node._can_driver._bus + can_bus.flush_tx_buffer() + except NotImplementedError: + can_bus.flush_tx_buffer = lambda: None + logger.info('Patched flush_tx_buffer for %s backend', type(can_bus).__name__) + except AttributeError: + pass if iface_kwargs["filtered"]: setup_filtering(node) @@ -652,7 +782,18 @@ def main(): iface = None except Exception as ex: logger.error('DroneCAN node init failed', exc_info=True) - show_error('Fatal error', 'Could not initialize DroneCAN node', ex, blocking=True) + if 'CanalOpen' in str(ex) or iface_kwargs.get('bustype') == 'usb2can': + show_error( + 'USB2CAN adapter error', + 'Could not open the 8devices USB2CAN adapter.\n\n' + 'This usually means another program already has it open ' + '(including another DroneCAN GUI Tool window). ' + 'Close other CAN tools, unplug and replug the adapter, then try again.', + ex, + blocking=True, + ) + else: + show_error('Fatal error', 'Could not initialize DroneCAN node', ex, blocking=True) else: break diff --git a/dronecan_gui_tool/setup_window.py b/dronecan_gui_tool/setup_window.py index 227eedf..af156cc 100644 --- a/dronecan_gui_tool/setup_window.py +++ b/dronecan_gui_tool/setup_window.py @@ -65,6 +65,37 @@ def _mavcan_interfaces(): return [] return ['mavcan::14550'] +def _detect_usb2can_serials(): + """Return serial numbers of attached 8devices USB2CAN adapters via WMI. + + python-can's find_serial_devices() crashes when a WMI object disappears + between enumeration and Get(); scan defensively instead. + """ + serials = [] + try: + import pythoncom + import win32com.client + pythoncom.CoInitialize() + wmi = win32com.client.GetObject('winmgmts:') + for usb_controller in wmi.InstancesOf('Win32_USBControllerDevice'): + try: + dependent = usb_controller.Dependent + if not dependent: + continue + usb_device = wmi.Get(dependent) + except Exception: + continue + name = getattr(usb_device, 'Name', None) or '' + device_id = getattr(usb_device, 'DeviceID', None) or '' + if 'USB2CAN' in name or 'USB2CAN' in device_id: + serial = device_id.split('\\')[-1].strip() + if serial and serial not in serials: + serials.append(serial) + except Exception as ex: + logger.warning('USB2CAN WMI scan failed: %s', ex, exc_info=True) + return serials + + def list_ifaces(): """Returns dictionary, where key is description, value is the OS assigned name of the port""" logger.debug('Updating iface list...') @@ -96,38 +127,65 @@ def list_ifaces(): # Windows, Mac, whatever from PyQt5 import QtSerialPort - out = OrderedDict() + # Collect ports with priority handling for USB2CAN adapters + regular_ports = [] + for port in QtSerialPort.QSerialPortInfo.availablePorts(): if sys.platform == 'darwin': if 'tty' in port.systemLocation(): if port.systemLocation() not in MACOS_SERIAL_PORTS_FILTER: - out[port.systemLocation()] = port.systemLocation() + regular_ports.append((port.systemLocation(), port.systemLocation())) else: sys_name = port.systemLocation() sys_alpha = re.sub(r'[^a-zA-Z0-9]', '', sys_name) description = port.description() - # show the COM port in parentheses to make it clearer which port it is - out["%s (%s)" % (description, sys_alpha)] = sys_name - mifaces = _mavcan_interfaces() - mifaces += ["mcast:0", "mcast:1"] - for x in mifaces: - out[x] = x + # Korlan presents a COM port, but DroneCAN must open it via the + # native CANAL/usb2can backend, not as SLCAN. Skip the COM entry. + if description and 'USB2CAN' in description.upper(): + logger.debug('Skipping USB2CAN COM port %s (%s)', sys_name, description) + continue + # show the COM port in parentheses to make it clearer which port it is + display_name = "%s (%s)" % (description, sys_alpha) + regular_ports.append((display_name, sys_name)) + # USB2CAN first so it is selected by default when present + out = OrderedDict() try: if sys.platform != 'darwin': - from can import detect_available_configs try: import pythoncom pythoncom.CoInitialize() except Exception: pass - for interface in detect_available_configs(): - if interface['interface'] == "pcan": - out[interface['channel']] = interface['channel'] + + # Do not call detect_available_configs() — it probes every + # python-can backend (socketcand, vector, ...) every 0.5s. + for channel in _detect_usb2can_serials(): + display_name = "8devices USB2CAN (%s)" % channel + interface_spec = "usb2can:%s" % channel + out[display_name] = interface_spec + logger.info('Added USB2CAN interface: %s -> %s', display_name, interface_spec) + + try: + from can.interfaces.pcan.pcan import PcanBus + for interface in PcanBus._detect_available_configs(): + channel = interface.get('channel') + if channel: + out[channel] = channel + except Exception: + logger.debug('PCAN detection skipped', exc_info=True) except Exception as ex: logger.warning('Could not load can interfaces: %s', ex, exc_info=True) + for display_name, sys_name in regular_ports: + out[display_name] = sys_name + + mifaces = _mavcan_interfaces() + mifaces += ["mcast:0", "mcast:1"] + for x in mifaces: + out[x] = x + return out diff --git a/setup.py b/setup.py index dea9620..1079328 100755 --- a/setup.py +++ b/setup.py @@ -122,6 +122,7 @@ 'qtpy', 'qtconsole', 'easywebdav', + 'python-can', # Add python-can to unpacked eggs (package name is python-can, import name is can) ] unpacked_eggs_dir = os.path.join('build', 'hatched_eggs') sys.path.insert(0, unpacked_eggs_dir) @@ -144,6 +145,7 @@ import jupyter_client import traitlets import numpy + import can # Add python-can import # Oh, Windows, never change. missing_dlls = glob.glob(os.path.join(os.path.dirname(numpy.core.__file__), '*.dll')) @@ -157,6 +159,21 @@ 'zmq', 'pygments', 'jupyter_client', + # Add python-can and related packages + 'can', + 'can.interfaces', + 'can.interfaces.usb2can', + 'can.interfaces.usb2can.usb2canabstractionlayer', + 'can.interfaces.usb2can.usb2canInterface', + 'can.interfaces.usb2can.serial_selector', + 'can.interfaces.serial', + 'can.interfaces.pcan', + 'can.interfaces.vector', + 'can.interfaces.kvaser', + 'can.interfaces.ixxat', + 'can.interfaces.socketcan', + 'can.interfaces.socketcand', + 'can.interfaces.virtual', ], 'include_msvcr': True, 'include_files': [ @@ -172,6 +189,12 @@ os.path.join(unpacked_eggs_dir, os.path.dirname(jupyter_client.__file__)), os.path.join(unpacked_eggs_dir, os.path.dirname(traitlets.__file__)), os.path.join(unpacked_eggs_dir, os.path.dirname(numpy.__file__)), + os.path.join(unpacked_eggs_dir, os.path.dirname(can.__file__)), # Include python-can package files + # Explicitly include USB2CAN interface files + (os.path.join(unpacked_eggs_dir, os.path.dirname(can.__file__), 'interfaces', 'usb2can'), + 'can/interfaces/usb2can'), + # Include USB2CAN DLL files + ('bin/usb2can_canal_v2.0.0', 'bin/usb2can_canal_v2.0.0'), ] + missing_dlls, }, 'bdist_msi': { @@ -180,8 +203,9 @@ }, } args['executables'] = [ - cx_Freeze.Executable(os.path.join('bin', PACKAGE_NAME), + cx_Freeze.Executable(os.path.join('bin', PACKAGE_NAME + '_launcher'), base='Win32GUI', + target_name=PACKAGE_NAME + '.exe', icon='icons/logo.ico', shortcut_name=HUMAN_FRIENDLY_NAME, shortcut_dir='ProgramMenuFolder'), diff --git a/setup_usb2can.py b/setup_usb2can.py new file mode 100644 index 0000000..a44b26e --- /dev/null +++ b/setup_usb2can.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +""" +USB2CAN setup script for DroneCAN GUI Tool + +This script sets up the USB2CAN DLL for use with the DroneCAN GUI Tool. +It copies the appropriate DLL to a location where it can be found by python-can. +""" + +import os +import shutil +import platform +import sys + +def setup_usb2can_dll(): + """Copy the USB2CAN DLL to an accessible location""" + print("Setting up USB2CAN DLL for DroneCAN GUI Tool...") + + # Get the current directory (should be the gui_tool root) + current_dir = os.path.dirname(os.path.abspath(__file__)) + + # Determine the correct DLL based on architecture + if platform.machine().lower() in ['amd64', 'x86_64', 'x64']: + dll_source = os.path.join(current_dir, 'bin', 'usb2can_canal_v2.0.0', 'x64', 'Release', 'usb2can.dll') + arch = 'x64' + else: + dll_source = os.path.join(current_dir, 'bin', 'usb2can_canal_v2.0.0', 'x86', 'Release', 'usb2can.dll') + arch = 'x86' + + if not os.path.exists(dll_source): + print(f"Error: USB2CAN DLL not found at {dll_source}") + return False + + # Copy to the current directory (where the script is run from) + dll_dest = os.path.join(current_dir, 'usb2can.dll') + + try: + shutil.copy2(dll_source, dll_dest) + print(f"Successfully copied {arch} USB2CAN DLL to {dll_dest}") + return True + except Exception as e: + print(f"Error copying DLL: {e}") + return False + +if __name__ == "__main__": + success = setup_usb2can_dll() + if success: + print("\n✅ USB2CAN setup completed successfully!") + print("You can now use 8devices USB2CAN adapters with the DroneCAN GUI Tool.") + else: + print("\n❌ USB2CAN setup failed!") + sys.exit(1) \ No newline at end of file diff --git a/test_usb2can_detection.py b/test_usb2can_detection.py new file mode 100644 index 0000000..ed6e3c5 --- /dev/null +++ b/test_usb2can_detection.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +""" +Test script to specifically check USB2CAN interface detection +""" + +import sys +import logging + +# Setup logging to see warnings and errors +logging.basicConfig(level=logging.WARNING) +logger = logging.getLogger(__name__) + +print("=== Testing USB2CAN Interface Detection ===") +print(f"Python version: {sys.version}") + +try: + # Import the setup window module which handles interface detection + sys.path.insert(0, r'C:\Users\bluea\OneDrive\Documents\GitHub\gui_tool') + from dronecan_gui_tool.setup_window import list_ifaces + + print("\n=== Calling list_ifaces() ===") + interfaces = list_ifaces() + + print(f"Found {len(interfaces)} interfaces:") + usb2can_found = False + + for i, (display_name, interface_spec) in enumerate(interfaces.items()): + print(f" {i+1}. '{display_name}' -> '{interface_spec}'") + if 'usb2can' in display_name.lower() or 'usb2can:' in interface_spec: + print(f" *** USB2CAN INTERFACE FOUND! ***") + usb2can_found = True + + if not usb2can_found: + print("\n⚠ No USB2CAN interfaces were detected!") + print(" This means the 8devices USB2CAN adapter is not showing up.") + else: + print("\n✅ USB2CAN interface detection is working!") + +except Exception as e: + print(f"✗ Error during interface detection: {e}") + import traceback + traceback.print_exc() + +print("\n=== Test completed ===") \ No newline at end of file diff --git a/winbuild_venv.bat b/winbuild_venv.bat new file mode 100644 index 0000000..e4a054d --- /dev/null +++ b/winbuild_venv.bat @@ -0,0 +1,33 @@ +@echo on + +rem - this is a sample build script for building gui_tool MSI file under windows, it assumes the following: +rem - you already have a python.org python installed (at least 3.10) tested on 3.10.2 +rem - you have a git checkout of [the correct] gui_tool [release] here + +rem - how to use: +rem - step 1 - edit the script to change the PATH below to include your python 3.10 install directory +rem - step 2 - open a command prompt +rem - step 3 - run winbuild_venv.bat in the gui_tool directory + +rem NOTE: you need visual studio installed, with the C++ build tools + +rem Use the virtual environment Python instead of system Python for compatibility +SET VENV_PYTHON=C:\Users\bluea\OneDrive\Documents\GitHub\gui_tool\.venv\Scripts\python.exe + +%VENV_PYTHON% --version + +%VENV_PYTHON% -m pip install -U cx_Freeze +%VENV_PYTHON% -m pip install -U pymavlink +%VENV_PYTHON% -m pip install -U pywin32 +%VENV_PYTHON% -m pip install -U python-can +%VENV_PYTHON% -m pip install -U . + +rem show pip sizes for debug +%VENV_PYTHON% pip_sizes.py + +rem make the .msi +%VENV_PYTHON% setup.py install +%VENV_PYTHON% setup.py bdist_msi + +rem find the binary in 'dist' folder +dir dist \ No newline at end of file