Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
48 changes: 48 additions & 0 deletions bin/dronecan_gui_tool_launcher
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
#!/usr/bin/env python3
#
# Copyright (C) 2016 UAVCAN Development Team <uavcan.org>
#
# This software is distributed under the terms of the MIT License.
#
# Author: Pavel Kirienko <pavel.kirienko@zubax.com>
#

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()
Binary file added bin/usb2can_canal_v2.0.0/x64/Release/usb2can.dll
Binary file not shown.
Binary file added bin/usb2can_canal_v2.0.0/x64/Release/usb2can.lib
Binary file not shown.
Binary file added bin/usb2can_canal_v2.0.0/x86/Release/usb2can.dll
Binary file not shown.
Binary file added bin/usb2can_canal_v2.0.0/x86/Release/usb2can.lib
Binary file not shown.
151 changes: 146 additions & 5 deletions dronecan_gui_tool/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand All @@ -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

Expand Down
82 changes: 70 additions & 12 deletions dronecan_gui_tool/setup_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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...')
Expand Down Expand Up @@ -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


Expand Down
Loading