Skip to content
Open
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
42 changes: 35 additions & 7 deletions dronecan/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
except ImportError:
# Fall back to importlib_resources backport for Python < 3.7
import importlib_resources

def get_resource_path(package, resource):
"""Get path to a resource file using importlib.resources"""
try:
Expand All @@ -40,7 +40,7 @@ def get_resource_path(package, resource):
except ImportError:
# Last resort fallback to pkg_resources
import pkg_resources

def get_resource_path(package, resource):
"""Get path to a resource file using pkg_resources"""
return pkg_resources.resource_filename(package, resource)
Expand Down Expand Up @@ -153,7 +153,7 @@ def load_dsdl(*paths, **args):
dsdl_path = str(get_resource_path(__name__, "dsdl_specs"))
except (ImportError, FileNotFoundError):
dsdl_path = None

# Check if we are a package, if not directly use relative DSDL path
if not dsdl_path or not os.path.exists(dsdl_path):
DSDL_paths = [ "../../DSDL", "../../../../../DroneCAN/DSDL", "../../../../dsdl"]
Expand All @@ -169,7 +169,8 @@ def load_dsdl(*paths, **args):
os.path.join(dsdl_path, "dronecan"),
os.path.join(dsdl_path, "ardupilot"),
os.path.join(dsdl_path, "com"),
os.path.join(dsdl_path, "cuav")] + paths
os.path.join(dsdl_path, "cuav"),
os.path.join(dsdl_path, "flytrex")] + paths
custom_path = os.path.join(os.path.expanduser("~"), "uavcan_vendor_specific_types")
if os.path.isdir(custom_path):
paths += [f for f in [os.path.join(custom_path, f) for f in os.listdir(custom_path)]
Expand Down Expand Up @@ -211,7 +212,7 @@ def create_instance(*args, **kwargs):
dtype.Request = create_instance_closure(dtype, _mode='request')
dtype.Response = create_instance_closure(dtype, _mode='response')

toplevel = ['dronecan', 'uavcan', 'ardupilot', 'com', 'cuav']
toplevel = ['dronecan', 'uavcan', 'ardupilot', 'com', 'cuav', 'flytrex']
for n in toplevel:
namespace = root_namespace._path(n)
MODULE.__dict__[n] = Namespace()
Expand All @@ -223,7 +224,7 @@ def create_instance(*args, **kwargs):
if str(ext_namespace) != "uavcan":
# noinspection PyUnresolvedReferences
MODULE.thirdparty.__dict__[str(ext_namespace)] = root_namespace.__dict__[ext_namespace]

__all__ = ["dsdl", "transport", "load_dsdl", "DATATYPES", "TYPENAMES"]


Expand All @@ -237,7 +238,34 @@ def create_instance(*args, **kwargs):


# Completing package initialization with loading default DSDL definitions
load_dsdl()
custom_dsdl_env = os.environ.get('DroneCAN_CUSTOM_DSDL_PATH')
if custom_dsdl_env and os.path.exists(custom_dsdl_env):
try:
logger.info(f"Loading custom DSDL from environment variable: {custom_dsdl_env}")
load_dsdl(custom_dsdl_env)
logger.info("Custom DSDL loaded successfully from environment variable.")
except Exception as ex:
logger.warning(f"Failed to load custom DSDL from {custom_dsdl_env}: {ex}")
# fallback to default logic below

else:
# Use the same default path logic as main.py
default_dsdl_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
default_dsdl_path = os.path.join(default_dsdl_root, "..", "public_regulated_data_types")
if os.path.exists(default_dsdl_path):
logger.info(f"Loading default DSDL from {default_dsdl_path}")
namespace_dirs = [
os.path.join(default_dsdl_path, "uavcan"),
os.path.join(default_dsdl_path, "dronecan"),
os.path.join(default_dsdl_path, "ardupilot"),
os.path.join(default_dsdl_path, "com"),
os.path.join(default_dsdl_path, "cuav"),
os.path.join(default_dsdl_path, "flytrex"),
]
load_dsdl(*namespace_dirs)
else:
logger.warning(f"Default DSDL path not found: {default_dsdl_path}, falling back to package logic.")
load_dsdl()


# Importing modules that may be dependent on the standard DSDL types
Expand Down
57 changes: 52 additions & 5 deletions dronecan/app/dynamic_node_id.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,23 @@ def set(self, unique_id, node_id):
self._modify('''insert or replace into allocation (node_id, unique_id) values (?, ?);''',
node_id, unique_id)

def remove_oldest_node_id(self):
"""
@brief Finds and removes the entry with the oldest timestamp (ts) from the allocation table.
@return The node ID of the removed entry, or None if the table is empty.
"""

c = self.db.cursor()
c.execute('SELECT node_id FROM allocation ORDER BY ts ASC LIMIT 1')
res = c.fetchone()
if res:
oldest_node_id = res[0]
c.execute('DELETE FROM allocation WHERE node_id = ?', (oldest_node_id,))
self.db.commit()
return oldest_node_id

return None

def get_node_id(self, unique_id):
assert isinstance(unique_id, bytes)
c = self.db.cursor()
Expand All @@ -80,7 +97,7 @@ def get_entries(self):
c.execute('''select unique_id, node_id from allocation order by ts desc''')
return list(c.fetchall())

def __init__(self, node, node_monitor, database_storage=None, dynamic_node_id_range=None):
def __init__(self, node, node_monitor, database_storage=None, dynamic_node_id_range=None, new_found_node_callback=None, node_disconnected_callback=None):
"""
:param node: Node instance.

Expand All @@ -90,11 +107,17 @@ def __init__(self, node, node_monitor, database_storage=None, dynamic_node_id_ra
If not provided, the allocation table will be kept in memory.

:param dynamic_node_id_range: Range of node ID available for dynamic allocation; defaults to [1, 125].

:param new_found_node_callback: Optional callback to be called when a new node is found. It is called before the node is assigned with an ID

:param node_disconnected_callback: Optional callback to be called when a node is disconnected. This is a node that had an ID assigned by the allocator and then disappeared from the bus.
"""
if node.is_anonymous:
raise UAVCANException('Dynamic node ID server cannot be launched on an anonymous node')

self._node_monitor = node_monitor
self._new_found_node_callback = new_found_node_callback
self._node_disconnected_callback = node_disconnected_callback

self._allocation_table = CentralizedServer.AllocationTable(database_storage or self.DATABASE_STORAGE_MEMORY)
self._query = bytes()
Expand Down Expand Up @@ -127,13 +150,29 @@ def _handle_monitor_event(self, event):
if self._allocation_table.get_unique_id(event.entry.node_id) is None:
self._allocation_table.set(unique_id, event.entry.node_id)

if event.event_id == event.EVENT_ID_OFFLINE:
if self._allocation_table.is_known_node_id(event.entry.node_id):
if self._node_disconnected_callback:
self._node_disconnected_callback(_unique_id_to_string(unique_id), event.entry.node_id)

def close(self):
"""Stops the instance and closes the allocation table storage.
"""
self._handle.remove()
self._node_monitor_event_handle.remove()
self._allocation_table.close()

def _allocate_new_node_id(self):
"""
@brief Allocates a new node ID from the dynamic node ID range.
@return The allocated node ID, or None if no IDs are available.
"""
for node_id in range(self._dynamic_node_id_range[1], self._dynamic_node_id_range[0] - 1, -1):
if not self._allocation_table.is_known_node_id(node_id):
return node_id

return None

def _on_allocation_message(self, e):
# TODO: request validation

Expand Down Expand Up @@ -202,10 +241,15 @@ def _on_allocation_message(self, e):
# If no ID was allocated in the above step (also if the requested
# ID was zero), allocate the highest unallocated node ID
if not node_allocated_id:
for node_id in range(self._dynamic_node_id_range[1], self._dynamic_node_id_range[0], -1):
if not self._allocation_table.is_known_node_id(node_id):
node_allocated_id = node_id
break
node_allocated_id = self._allocate_new_node_id()

if not node_allocated_id:
logger.error("[CentralizedServer] Couldn't allocate dynamic node ID")
# As per spec, remove the oldest entry and try again
removed_node_id = self._allocation_table.remove_oldest_node_id()
if removed_node_id:
logger.info("[CentralizedServer] Removed oldest node ID %d to free up space", removed_node_id)
node_allocated_id = self._allocate_new_node_id()

if node_allocated_id:
self._allocation_table.set(self._query, node_allocated_id)
Expand All @@ -219,6 +263,9 @@ def _on_allocation_message(self, e):
logger.info("[CentralizedServer] Allocated node ID %d to node with unique ID %s",
node_allocated_id, _unique_id_to_string(self._query))

# Call the callback if a new node is found
if self._new_found_node_callback:
self._new_found_node_callback(_unique_id_to_string(self._query), node_allocated_id)
self._query = bytes() # Resetting the state
else:
logger.error("[CentralizedServer] Couldn't allocate dynamic node ID")
43 changes: 39 additions & 4 deletions dronecan/driver/mavcan.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
#
'''
driver for CAN over MAVLink, using MAV_CMD_CAN_FORWARD and CAN_FRAME messages

Parent process death detection is most reliable on Python 3.8+ because
multiprocessing.parent_process() is used when available. On Windows with
Python 3.7, this API is unavailable and os.getppid() is not used as fallback,
so parent death detection is effectively disabled.
'''

import os
Expand Down Expand Up @@ -37,6 +42,19 @@ def __init__(self, command, data):
def io_process(url, bus, target_system, baudrate, tx_queue, rx_queue, exit_queue, parent_pid):
os.environ['MAVLINK20'] = '1'

# If the parent process dies unexpectedly, stop this IO process to avoid leaving a stale
# MAVLink connection running that can interfere with new instances.
parent_sentinel = None
mp_wait = None
has_parent_process_api = hasattr(multiprocessing, 'parent_process')
try:
parent = multiprocessing.parent_process() if has_parent_process_api else None
if parent is not None:
parent_sentinel = parent.sentinel
from multiprocessing.connection import wait as mp_wait # type: ignore
except Exception:
parent_sentinel = None
mp_wait = None
target_component = 0
last_enable = time.time()
conn = None
Expand Down Expand Up @@ -123,14 +141,31 @@ def handle_control_message(m):
connect()
enable_can_forward()

if os.name == 'nt' and not has_parent_process_api:
logger.warning('Python 3.8+ is recommended on Windows for parent process death detection in MAVCAN IO process')

while True:
try:
if mp_wait is not None and parent_sentinel is not None and mp_wait([parent_sentinel], timeout=0):
# Parent process is gone.
conn.close()
return
except Exception:
pass
if (not exit_queue.empty() and exit_queue.get() == "QUIT") or exit_proc:
conn.close()
return
if os.getppid() != parent_pid:
# ensure we die when parent dies
conn.close()
return
# Keep the old PID check only as a last-resort fallback on POSIX.
# On Windows with Python < 3.8, parent_process() is unavailable and
# this fallback is intentionally disabled, so parent death detection
# is effectively unavailable.
if parent_sentinel is None and os.name != 'nt':
try:
if os.getppid() != parent_pid:
conn.close()
return
except Exception:
pass
while not tx_queue.empty():
if (not exit_queue.empty() and exit_queue.get() == "QUIT") or exit_proc:
conn.close()
Expand Down
21 changes: 16 additions & 5 deletions dronecan/transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,19 @@ def __getitem__(self, idx):
else:
return self.__items[idx]

def __iter__(self):
# If __iter__ is not defined, Python falls back to index-based iteration
# (0, 1, 2, ...) and stops by catching IndexError. Some debuggers/UI tools
# treat that IndexError as an exception worth breaking on.
#
# Iterate over a snapshot to avoid IndexError if the underlying list is
# mutated while iterating.
for item in list(self.__items):
if isinstance(item, PrimitiveValue):
yield item.value if item._bits else 0
else:
yield item

def __setitem__(self, idx, value):
if idx >= self._type.max_size:
raise IndexError("Index {0} too large (max size {1})".format(idx, self._type.max_size))
Expand All @@ -389,11 +402,9 @@ def __eq__(self, other):
return list(self) == other

def clear(self):
try:
while True:
self.pop()
except IndexError:
pass
# Avoid relying on IndexError for loop termination (debuggers may break on it).
# This works for both static and dynamic arrays because __delitem__ supports slices.
del self[:]

def new_item(self):
return self.__item_ctor()
Expand Down
23 changes: 15 additions & 8 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

import os
import sys
import warnings

from setuptools import setup
from io import open

Expand All @@ -22,24 +24,29 @@

try:
if not os.path.exists('dronecan/dsdl_specs'):
os.symlink('../../DSDL', 'dronecan/dsdl_specs')
try:
os.symlink('../../DSDL', 'dronecan/dsdl_specs')
except OSError:
pass

args = dict(
version=__version__,
package_data={
'dronecan': [os.path.join(root[len('dronecan/'):], fname)
for root, dirs, files in os.walk('dronecan/dsdl_specs', followlinks=True)
for fname in files if fname.endswith('.uavcan')]
'dronecan': ['dronecan']
},
scripts = [ 'tools/dronecan_bridge.py' ]
)
# ensure dsdl specs are not empty
if len(args['package_data']['dronecan']) == 0:
raise Exception('DSDL specs empty or unavailable, please ensure ../DSDL is present relative to project root')
if len(args['package_data']['dronecan']) == 0:
warnings.warn('DSDL specs empty or unavailable, please ensure ../DSDL is present relative to project root. This is unusual.')

if sys.version_info[0] < 3:
args['install_requires'] = ['monotonic']

setup(**args)
finally:
if os.path.islink('dronecan/dsdl_specs'):
os.unlink('dronecan/dsdl_specs')
try:
if os.path.islink('dronecan/dsdl_specs'):
os.unlink('dronecan/dsdl_specs')
except OSError:
pass