This commit is contained in:
2025-05-13 01:34:53 +03:00
parent 427735e23d
commit 83f3f1c7d4
945 changed files with 633484 additions and 0 deletions
@@ -0,0 +1,20 @@
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(
os.path.dirname(os.path.realpath(__file__)))),
"Firmware", "fibre", "python"))
# Syntactic sugar to make usage more intuative.
# Try/pass used to break install-time dep issues
try:
import fibre
find_any = fibre.find_any
find_all = fibre.find_all
except:
pass
# Standard convention is to add a __version__ attribute to the package
from .version import get_version_str
__version__ = get_version_str()
del get_version_str
@@ -0,0 +1,69 @@
import jinja2
import os
import json
def get_flat_endpoint_list(json, prefix, id_offset):
flat_list = []
for item in json:
item = item.copy()
if 'id' in item:
item['id'] -= id_offset
if 'type' in item:
if item['type'] in {'int8', 'uint8', 'int16', 'uint16', 'int32', 'uint32', 'int64', 'uint64'}:
item['type'] += '_t'
is_property = True
elif item['type'] in {'bool', 'float'}:
is_property = True
elif item['type'] in {'function'}:
if len(item.get('arguments', [])) == 0 and len(item.get('inputs', [])) == 0 and len(item.get('outputs', [])) == 0:
item['type'] = 'void'
is_property = True
else:
is_property = False
else:
is_property = False
if is_property:
item['name'] = prefix + item['name']
flat_list.append(item)
if 'members' in item:
flat_list = flat_list + get_flat_endpoint_list(item['members'], prefix + item['name'] + '.', id_offset)
return flat_list
def generate_code(odrv, template_file, output_file):
json_data = odrv._json_data
json_crc = odrv._json_crc
axis0_json = [item for item in json_data if item['name'].startswith("axis0")][0]
axis1_json = [item for item in json_data if item['name'].startswith("axis1")][0]
json_data = [item for item in json_data if not item['name'].startswith("axis")]
endpoints = get_flat_endpoint_list(json_data, '', 0)
per_axis_offset = axis1_json['members'][0]['id'] - axis0_json['members'][0]['id']
axis_endpoints = get_flat_endpoint_list(axis0_json['members'], 'axis.', 0)
axis_endpoints_copy = get_flat_endpoint_list(axis1_json['members'], 'axis.', per_axis_offset)
if axis_endpoints != axis_endpoints_copy:
raise Exception("axis0 and axis1 don't look exactly equal")
env = jinja2.Environment(
#loader = jinja2.FileSystemLoader("/Data/Projects/")
#trim_blocks=True,
#lstrip_blocks=True
)
# Expose helper functions to jinja template code
#env.filters["delimit"] = camel_case_to_words
#import ipdb; ipdb.set_trace()
# Load and render template
template = env.from_string(template_file.read())
output = template.render(
json_crc=json_crc,
endpoints=endpoints,
per_axis_offset=per_axis_offset,
axis_endpoints=axis_endpoints,
output_name=os.path.basename(output_file.name)
)
# Output
output_file.write(output)
@@ -0,0 +1,82 @@
import json
import os
import tempfile
import fibre.remote_object
from odrive.utils import OperationAbortedException, yes_no_prompt
def get_dict(obj, is_config_object):
result = {}
for (k,v) in obj._remote_attributes.items():
if isinstance(v, fibre.remote_object.RemoteProperty) and is_config_object:
result[k] = v.get_value()
elif isinstance(v, fibre.remote_object.RemoteObject):
sub_dict = get_dict(v, k == 'config')
if sub_dict != {}:
result[k] = sub_dict
return result
def set_dict(obj, path, config_dict):
errors = []
for (k,v) in config_dict.items():
name = path + ("." if path != "" else "") + k
if not k in obj._remote_attributes:
errors.append("Could not restore {}: property not found on device".format(name))
continue
remote_attribute = obj._remote_attributes[k]
if isinstance(remote_attribute, fibre.remote_object.RemoteObject):
errors += set_dict(remote_attribute, name, v)
else:
try:
remote_attribute.set_value(v)
except Exception as ex:
errors.append("Could not restore {}: {}".format(name, str(ex)))
return errors
def get_temp_config_filename(device):
serial_number = fibre.utils.get_serial_number_str(device)
safe_serial_number = ''.join(filter(str.isalnum, serial_number))
return os.path.join(tempfile.gettempdir(), 'odrive-config-{}.json'.format(safe_serial_number))
def backup_config(device, filename, logger):
"""
Exports the configuration of an ODrive to a JSON file.
If no file name is provided, the file is placed into a
temporary directory.
"""
if filename is None:
filename = get_temp_config_filename(device)
logger.info("Saving configuration to {}...".format(filename))
if os.path.exists(filename):
if not yes_no_prompt("The file {} already exists. Do you want to override it?".format(filename), True):
raise OperationAbortedException()
data = get_dict(device, False)
with open(filename, 'w') as file:
json.dump(data, file)
logger.info("Configuration saved.")
def restore_config(device, filename, logger):
"""
Restores the configuration stored in a file
"""
if filename is None:
filename = get_temp_config_filename(device)
with open(filename) as file:
data = json.load(file)
logger.info("Restoring configuration from {}...".format(filename))
errors = set_dict(device, "", data)
for error in errors:
logger.info(error)
if errors:
logger.warn("Some of the configuration could not be restored.")
device.save_configuration()
logger.info("Configuration restored.")
@@ -0,0 +1,477 @@
#!/usr/bin/env python
"""
Tool for flashing .hex files to the ODrive via the STM built-in USB DFU mode.
"""
from __future__ import print_function
import argparse
import sys
import time
import threading
import platform
import struct
import requests
import re
import io
import os
import usb.core
import fibre
import odrive
from odrive.utils import Event, OperationAbortedException
from odrive.dfuse import *
try:
from intelhex import IntelHex
except:
sudo_prefix = "" if platform.system() == "Windows" else "sudo "
print("You need intelhex for this ({}pip install IntelHex)".format(sudo_prefix), file=sys.stderr)
sys.exit(1)
def get_fw_version_string(fw_version):
if (fw_version[0], fw_version[1], fw_version[2]) == (0, 0, 0):
return "[unknown version]"
else:
return "v{}.{}.{}{}".format(fw_version[0], fw_version[1], fw_version[2], "-dev" if fw_version[3] else "")
def get_hw_version_string(hw_version):
if hw_version == (0, 0, 0):
return "[unknown version]"
else:
return "v{}.{}{}".format(hw_version[0], hw_version[1], ("-" + str(hw_version[2]) + "V") if hw_version[2] > 0 else "")
def populate_sectors(sectors, hexfile):
"""
Checks for which on-device sectors there is data in the hex file and
returns a (sector, data) tuple for each touched sector where data
is a byte array of the same size as the sector.
"""
for sector in sectors:
addr = sector['addr']
size = sector['len']
# check if any segment from the hexfile overlaps with this sector
touched = False
for (start, end) in hexfile.segments():
if start < addr and end > addr:
touched = True
break
elif start >= addr and start < addr + size:
touched = True
break
if touched:
# TODO: verify if the section is writable
yield (sector, hexfile.tobinarray(addr, addr + size - 1))
def get_first_mismatch_index(array1, array2):
"""
Compares two arrays and returns the index of the
first unequal item or None if both arrays are equal
"""
if len(array1) != len(array2):
raise Exception("arrays must be same size")
for pos in range(len(array1)):
if (array1[pos] != array2[pos]):
return pos
return None
def dump_otp(dfudev):
"""
Dumps the contents of the one-time-programmable
memory for debugging purposes.
The OTP is used to determine the board version.
"""
# 512 Byte OTP
otp_sector = [s for s in dfudev.sectors if s['name'] == 'OTP Memory' and s['addr'] == 0x1fff7800][0]
data = dfudev.read_sector(otp_sector)
print(' '.join('{:02X}'.format(x) for x in data))
# 16 lock bytes
otp_lock_sector = [s for s in dfudev.sectors if s['name'] == 'OTP Memory' and s['addr'] == 0x1fff7A00][0]
data = dfudev.read_sector(otp_lock_sector)
print(' '.join('{:02X}'.format(x) for x in data))
class Firmware():
def __init__(self):
self.fw_version = (0, 0, 0, True)
self.hw_version = (0, 0, 0)
@staticmethod
def is_newer(a, b):
a_num = (a[0], a[1], a[2])
b_num = (b[0], b[1], b[2])
if a_num == (0, 0, 0) or b_num == (0, 0, 0):
return False # Cannot compare unknown versions
return a_num > b_num or (a_num == b_num and not a[3] and b[3])
def __gt__(self, other):
"""
Compares two firmware versions. If both versions are equal, the
prerelease version is considered older than the release version.
"""
if not isinstance(other, tuple):
other = other.fw_version
return Firmware.is_newer(self.fw_version, other)
def __lt__(self, other):
"""
Compares two firmware versions. If both versions are equal, the
prerelease version is considered older than the release version.
"""
if not isinstance(other, tuple):
other = other.fw_version
return Firmware.is_newer(other, self.fw_version)
def is_compatible(self, hw_version):
"""
Determines if this firmware is compatible
with the specified hardware version
"""
return self.hw_version == hw_version
class FirmwareFromGithub(Firmware):
"""
Represents a firmware asset
"""
def __init__(self, release_json, asset_json):
Firmware.__init__(self)
if release_json['draft'] or release_json['prerelease']:
release_json['tag_name'] += "*"
self.fw_version = odrive.version.version_str_to_tuple(release_json['tag_name'])
hw_version_regex = r'.*v([0-9]+).([0-9]+)(-(?P<voltage>[0-9]+)V)?.hex'
hw_version_match = re.search(hw_version_regex, asset_json['name'])
self.hw_version = (int(hw_version_match[1]),
int(hw_version_match[2]),
int(hw_version_match.groupdict().get('voltage') or 0))
self.github_asset_id = asset_json['id']
self.hex = None
# no technical reason to fetch this - just interesting
self.download_count = asset_json['download_count']
def get_as_hex(self):
"""
Returns the content of the firmware in as a binary array in Intel Hex format
"""
if self.hex is None:
print("Downloading firmware {}...".format(get_fw_version_string(self.fw_version)))
response = requests.get('https://api.github.com/repos/madcowswe/ODrive/releases/assets/' + str(self.github_asset_id),
headers={'Accept': 'application/octet-stream'})
if response.status_code != 200:
raise Exception("failed to download firmware")
self.hex = response.content
return io.StringIO(self.hex.decode('utf-8'))
class FirmwareFromFile(Firmware):
def __init__(self, file):
Firmware.__init__(self)
self._file = file
def get_as_hex(self):
return self._file
def get_all_github_firmwares():
response = requests.get('https://api.github.com/repos/madcowswe/ODrive/releases')
if response.status_code != 200:
raise Exception("could not fetch releases")
response_json = response.json()
for release_json in response_json:
for asset_json in release_json['assets']:
try:
if asset_json['name'].lower().endswith('.hex'):
fw = FirmwareFromGithub(release_json, asset_json)
yield fw
except Exception as ex:
print(ex)
def get_newest_firmware(hw_version):
"""
Returns the newest available firmware for the specified hardware version
"""
firmwares = get_all_github_firmwares()
firmwares = filter(lambda fw: not fw.fw_version[3], firmwares) # ignore prereleases
firmwares = filter(lambda fw: fw.hw_version == hw_version, firmwares)
firmwares = list(firmwares)
firmwares.sort()
return firmwares[-1] if len(firmwares) else None
def show_deferred_message(message, cancellation_token):
"""
Shows a message after 10s, unless cancellation_token gets set.
"""
def show_message_thread(message, cancellation_token):
for _ in range(1,10):
if cancellation_token.is_set():
return
time.sleep(1)
if not cancellation_token.is_set():
print(message)
t = threading.Thread(target=show_message_thread, args=(message, cancellation_token))
t.daemon = True
t.start()
def put_into_dfu_mode(device, cancellation_token):
"""
Puts the specified device into DFU mode
"""
if not hasattr(device, "enter_dfu_mode"):
print("The firmware on device {} cannot soft enter DFU mode.\n"
"Please remove power, put the DFU switch into DFU mode,\n"
"then apply power again. Then try again.\n"
"If it still doesn't work, you can try to use the DeFuse app or \n"
"dfu-util, see the odrive documentation.\n"
"You can also flash the firmware using STLink (`make flash`)"
.format(device.__channel__.usb_device.serial_number))
return
print("Putting device {} into DFU mode...".format(device.__channel__.usb_device.serial_number))
try:
device.enter_dfu_mode()
except fibre.ChannelBrokenException:
pass # this is expected because the device reboots
if platform.system() == "Windows":
show_deferred_message("Still waiting for the device to reappear.\n"
"Use the Zadig utility to set the driver of 'STM32 BOOTLOADER' to libusb-win32.",
cancellation_token)
def find_device_in_dfu_mode(serial_number, cancellation_token):
"""
Polls libusb until a device in DFU mode is found
"""
while not cancellation_token.is_set():
params = {} if serial_number == None else {'serial_number': serial_number}
stm_device = usb.core.find(idVendor=0x0483, idProduct=0xdf11, **params)
if stm_device != None:
return stm_device
time.sleep(1)
return None
def update_device(device, firmware, logger, cancellation_token):
"""
Updates the specified device with the specified firmware.
The device passed to this function can either be in
normal mode or in DFU mode.
The firmware should be an instance of Firmware or None.
If firmware is None, the newest firmware for the device is
downloaded from GitHub releases.
"""
if isinstance(device, usb.core.Device):
serial_number = device.serial_number
dfudev = DfuDevice(device)
if (logger._verbose):
logger.debug("OTP:")
dump_otp(dfudev)
# Read hardware version from one-time-programmable memory
otp_sector = [s for s in dfudev.sectors if s['name'] == 'OTP Memory' and s['addr'] == 0x1fff7800][0]
otp_data = dfudev.read_sector(otp_sector)
if otp_data[0] == 0:
otp_data = otp_data[16:]
if otp_data[0] == 0xfe:
hw_version = (otp_data[3], otp_data[4], otp_data[5])
else:
hw_version = (0, 0, 0)
else:
serial_number = device.__channel__.usb_device.serial_number
dfudev = None
# Read hardware version as reported from firmware
hw_version_major = device.hw_version_major if hasattr(device, 'hw_version_major') else 0
hw_version_minor = device.hw_version_minor if hasattr(device, 'hw_version_minor') else 0
hw_version_variant = device.hw_version_variant if hasattr(device, 'hw_version_variant') else 0
hw_version = (hw_version_major, hw_version_minor, hw_version_variant)
if hw_version < (3, 5, 0):
print(" DFU mode is not supported on board version 3.4 or earlier.")
print(" This is because entering DFU mode on such a device would")
print(" break the brake resistor FETs under some circumstances.")
print("Warning: DFU mode is not supported on ODrives earlier than v3.5 unless you perform a hardware mod.")
if not odrive.utils.yes_no_prompt("Do you still want to continue?", False):
raise OperationAbortedException()
fw_version_major = device.fw_version_major if hasattr(device, 'fw_version_major') else 0
fw_version_minor = device.fw_version_minor if hasattr(device, 'fw_version_minor') else 0
fw_version_revision = device.fw_version_revision if hasattr(device, 'fw_version_revision') else 0
fw_version_prerelease = device.fw_version_prerelease if hasattr(device, 'fw_version_prerelease') else True
fw_version = (fw_version_major, fw_version_minor, fw_version_revision, fw_version_prerelease)
print("Found ODrive {} ({}) with firmware {}{}".format(
serial_number,
get_hw_version_string(hw_version),
get_fw_version_string(fw_version),
" in DFU mode" if dfudev is not None else ""))
if firmware is None:
if hw_version == (0, 0, 0):
if dfudev is None:
suggestion = 'You have to manually flash an up-to-date firmware to make automatic checks work. Run `odrivetool dfu --help` for more info.'
else:
suggestion = 'Run "make write_otp" to program the board version.'
raise Exception('Cannot check online for new firmware because the board version is unknown. ' + suggestion)
print("Checking online for newest firmware...", end='')
firmware = get_newest_firmware(hw_version)
if firmware is None:
raise Exception("could not find any firmware release for this board version")
print(" found {}".format(get_fw_version_string(firmware.fw_version)))
if firmware.fw_version <= fw_version:
print()
if firmware.fw_version < fw_version:
print("Warning: you are about to flash firmware {} which is older than the firmware on the device ({}).".format(
get_fw_version_string(firmware.fw_version),
get_fw_version_string(fw_version)))
else:
print("You are about to flash firmware {} which is the same version as the firmware on the device ({}).".format(
get_fw_version_string(firmware.fw_version),
get_fw_version_string(fw_version)))
if not odrive.utils.yes_no_prompt("Do you want to flash this firmware anyway?", False):
raise OperationAbortedException()
# load hex file
# TODO: Either use the elf format or pack a custom format with a manifest.
# This way we can for instance verify the target board version and only
# have to publish one file for every board (instead of elf AND hex files).
hexfile = IntelHex(firmware.get_as_hex())
logger.debug("Contiguous segments in hex file:")
for start, end in hexfile.segments():
logger.debug(" {:08X} to {:08X}".format(start, end - 1))
# Back up configuration
if dfudev is None:
do_backup_config = device.user_config_loaded if hasattr(device, 'user_config_loaded') else False
if do_backup_config:
odrive.configuration.backup_config(device, None, logger)
elif not odrive.utils.yes_no_prompt("The configuration cannot be backed up because the device is already in DFU mode. The configuration may be lost after updating. Do you want to continue anyway?", True):
raise OperationAbortedException()
# Put the device into DFU mode if it's not already in DFU mode
if dfudev is None:
find_odrive_cancellation_token = Event(cancellation_token)
put_into_dfu_mode(device, find_odrive_cancellation_token)
stm_device = find_device_in_dfu_mode(serial_number, cancellation_token)
find_odrive_cancellation_token.set()
dfudev = DfuDevice(stm_device)
logger.debug("Sectors on device: ")
for sector in dfudev.sectors:
logger.debug(" {:08X} to {:08X} ({})".format(
sector['addr'],
sector['addr'] + sector['len'] - 1,
sector['name']))
# fill sectors with data
touched_sectors = list(populate_sectors(dfudev.sectors, hexfile))
logger.debug("The following sectors will be flashed: ")
for sector,_ in touched_sectors:
logger.debug(" {:08X} to {:08X}".format(sector['addr'], sector['addr'] + sector['len'] - 1))
# Erase
try:
for i, (sector, data) in enumerate(touched_sectors):
print("Erasing... (sector {}/{}) \r".format(i, len(touched_sectors)), end='', flush=True)
dfudev.erase_sector(sector)
print('Erasing... done \r', end='', flush=True)
finally:
print('', flush=True)
# Flash
try:
for i, (sector, data) in enumerate(touched_sectors):
print("Flashing... (sector {}/{}) \r".format(i, len(touched_sectors)), end='', flush=True)
dfudev.write_sector(sector, data)
print('Flashing... done \r', end='', flush=True)
finally:
print('', flush=True)
# Verify
try:
for i, (sector, expected_data) in enumerate(touched_sectors):
print("Verifying... (sector {}/{}) \r".format(i, len(touched_sectors)), end='', flush=True)
observed_data = dfudev.read_sector(sector)
mismatch_pos = get_first_mismatch_index(observed_data, expected_data)
if not mismatch_pos is None:
mismatch_pos -= mismatch_pos % 16
observed_snippet = ' '.join('{:02X}'.format(x) for x in observed_data[mismatch_pos:mismatch_pos+16])
expected_snippet = ' '.join('{:02X}'.format(x) for x in expected_data[mismatch_pos:mismatch_pos+16])
raise RuntimeError("Verification failed around address 0x{:08X}:\n".format(sector['addr'] + mismatch_pos) +
" expected: " + expected_snippet + "\n"
" observed: " + observed_snippet)
print('Verifying... done \r', end='', flush=True)
finally:
print('', flush=True)
# If the flash operation failed for some reason, your device is bricked now.
# You can unbrick it as long as the device remains powered on.
# (or always with an STLink)
# So for debugging you should comment this last part out.
# Jump to application
dfudev.jump_to_application(0x08000000)
logger.info("Waiting for the device to reappear...")
device = odrive.find_any("usb", serial_number,
cancellation_token, cancellation_token, timeout=30)
if do_backup_config:
odrive.configuration.restore_config(device, None, logger)
os.remove(odrive.configuration.get_temp_config_filename(device))
logger.success("Device firmware update successful.")
def launch_dfu(args, logger, cancellation_token):
"""
Waits for a device that matches args.path and args.serial_number
and then upgrades the device's firmware.
"""
serial_number = args.serial_number
find_odrive_cancellation_token = Event(cancellation_token)
logger.info("Waiting for ODrive...")
devices = [None, None]
# Start background thread to scan for ODrives in DFU mode
def find_device_in_dfu_mode_thread():
devices[0] = find_device_in_dfu_mode(serial_number, find_odrive_cancellation_token)
find_odrive_cancellation_token.set()
t = threading.Thread(target=find_device_in_dfu_mode_thread)
t.daemon = True
t.start()
# Scan for ODrives not in DFU mode
# We only scan on USB because DFU is only implemented over USB
devices[1] = odrive.find_any("usb", serial_number,
find_odrive_cancellation_token, cancellation_token)
find_odrive_cancellation_token.set()
device = devices[0] or devices[1]
firmware = FirmwareFromFile(args.file) if args.file else None
update_device(device, firmware, logger, cancellation_token)
# Note: the flashed image can be verified using: (0x12000 is the number of bytes to read)
# $ openocd -f interface/stlink-v2.cfg -f target/stm32f4x.cfg -c init -c flash\ read_bank\ 0\ image.bin\ 0\ 0x12000 -c exit
# $ hexdump -C image.bin > image.bin.txt
#
# If you compare this with a reference image that was flashed with the STLink, you will see
# minor differences. This is because this script fills undefined sections with 0xff.
# $ diff image_ref.bin.txt image.bin.txt
# 21c21
# < *
# ---
# > 00000180 d9 47 00 08 d9 47 00 08 ff ff ff ff ff ff ff ff |.G...G..........|
# 2553c2553
# < 00009fc0 9e 46 70 47 00 00 00 00 52 20 96 3c 46 76 50 76 |.FpG....R .<FvPv|
# ---
# > 00009fc0 9e 46 70 47 ff ff ff ff 52 20 96 3c 46 76 50 76 |.FpG....R .<FvPv|
@@ -0,0 +1,6 @@
The Python dfuse tool was written by Paul Liétar.
Minor modifications were made for this project.
Original source: https://github.com/plietar/dfuse-tool
The license for this module is unclear.
@@ -0,0 +1,219 @@
import usb.util
import time
import fractions
import array
from odrive.dfuse.DfuState import DfuState
DFU_REQUEST_SEND = 0x21
DFU_REQUEST_RECEIVE = 0xa1
DFU_DETACH = 0x00
DFU_DNLOAD = 0x01
DFU_UPLOAD = 0x02
DFU_GETSTATUS = 0x03
DFU_CLRSTATUS = 0x04
DFU_GETSTATE = 0x05
DFU_ABORT = 0x06
SIZE_MULTIPLIERS = {' ': 1, 'K': 1024, 'M' : 1024*1024}
MAX_TRANSFER_SIZE = 2048
# Order is LSB first
def address_to_4bytes(a):
return [ a % 256, (a >> 8)%256, (a >> 16)%256, (a >> 24)%256 ]
class DfuDevice:
def __init__(self, device, timeout = None):
self.dev = device
self.timeout = timeout
self.cfg = self.dev[0]
self.intf = None
#self.dev.reset()
self.cfg.set()
self.sectors = list(self.get_device_sectors())
def alternates(self):
return [(usb.util.get_string(self.dev, intf.iInterface), intf) for intf in self.cfg]
def set_alternate(self, intf):
if isinstance(intf, tuple):
self.intf = intf[1]
else:
self.intf = intf
self.intf.set_altsetting()
def control_msg(self, requestType, request, value, buffer, timeout=None):
return self.dev.ctrl_transfer(requestType, request, value, self.intf.bInterfaceNumber, buffer, timeout=timeout)
def detach(self, timeout):
return self.control_msg(DFU_REQUEST_SEND, DFU_DETACH, timeout, None)
def dnload(self, blockNum, data):
cnt = self.control_msg(DFU_REQUEST_SEND, DFU_DNLOAD, blockNum, list(data))
return cnt
def upload(self, blockNum, size):
return self.control_msg(DFU_REQUEST_RECEIVE, DFU_UPLOAD, blockNum, size)
def get_status(self, timeout=None):
status = self.control_msg(DFU_REQUEST_RECEIVE, DFU_GETSTATUS, 0, 6, timeout=timeout)
return (status[0], status[4], status[1] + (status[2] << 8) + (status[3] << 16), status[5])
def clear_status(self):
self.control_msg(DFU_REQUEST_SEND, DFU_CLRSTATUS, 0, None)
def get_state(self):
return self.control_msg(DFU_REQUEST_RECEIVE, DFU_GETSTATE, 0, 1)[0]
def abort(self):
self.control_msg(DFU_REQUEST_RECEIVE, DFU_ABORT, 0, 0)
def set_address(self, ap):
return self.dnload(0x0, [0x21] + address_to_4bytes(ap))
def write(self, block, data):
return self.dnload(block + 2, data)
def read(self, block, size):
return self.upload(block + 2, size)
def erase(self, pa):
return self.dnload(0x0, [0x41] + address_to_4bytes(pa))
def leave(self):
return self.dnload(0x0, []) # Just send an empty data.
def wait_while_state(self, state, timeout=None):
if not isinstance(state, (list, tuple)):
states = (state,)
else:
states = state
try:
status = self.get_status()
except:
time.sleep(0.100)
status = self.get_status()
while (status[1] in states):
claimed_timeout = status[2]
actual_timeout = int(max(timeout or 0, claimed_timeout))
#print("timeout = %f, claimed = %f" % (timeout, status[2]))
#time.sleep(timeout)
status = self.get_status(timeout=actual_timeout)
return status
## High level functions ##
# by ODrive Robotics
def get_device_sectors(self):
"""
Returns a list of all sectors on the device.
Each sector is represented as a dictionary with the following keys:
- name: name of the associated memory region (e.g. "Internal Flash")
- alt: USB alternate setting associated with this memory region
- addr: Start address of the sector (e.g. 0x08004000 for the second flash sectors)
- baseaddr: Start address of the memory region associated with the sector
(e.g. 0x08000000 for all flash sectors)
- len: Number of bytes in the sector
"""
for name, alt in self.alternates():
# example for name:
# '@Internal Flash /0x08000000/04*016Kg,01*064Kg,07*128Kg'
label, baseaddr, layout = name.split('/')
baseaddr = int(baseaddr, 0) # convert hex to decimal
addr = baseaddr
for sector in layout.split(','):
repeat, size = map(int, sector[:-2].split('*'))
size *= SIZE_MULTIPLIERS[sector[-2].upper()]
mode = sector[-1]
while repeat > 0:
# TODO: verify if the section is writable
yield {
'name': label.strip().strip('@'),
'alt': alt,
'baseaddr': baseaddr,
'addr': addr,
'len': size,
'mode': mode
}
addr += size
repeat -= 1
def set_alternate_safe(self, alt):
self.set_alternate(alt)
if self.get_state() == DfuState.DFU_ERROR:
self.clear_status()
self.wait_while_state(DfuState.DFU_ERROR)
#def clear_error(self)
def set_address_safe(self, addr):
self.set_address(addr)
status = self.wait_while_state(DfuState.DFU_DOWNLOAD_BUSY)
if status[1] != DfuState.DFU_DOWNLOAD_IDLE:
raise RuntimeError("An error occured. Device Status: {!r}".format(status))
# take device out of DFU_DOWNLOAD_SYNC and into DFU_IDLE
self.abort()
status = self.wait_while_state(DfuState.DFU_DOWNLOAD_SYNC)
if status[1] != DfuState.DFU_IDLE:
raise RuntimeError("An error occured. Device Status: {!r}".format(status))
def erase_sector(self, sector):
self.set_alternate_safe(sector['alt'])
self.erase(sector['addr'])
status = self.wait_while_state(DfuState.DFU_DOWNLOAD_BUSY, timeout=sector['len']/32)
if status[1] != DfuState.DFU_DOWNLOAD_IDLE:
raise RuntimeError("An error occured. Device Status: {!r}".format(status))
def write_sector(self, sector, data):
self.set_alternate_safe(sector['alt'])
self.set_address_safe(sector['addr'])
transfer_size = fractions.gcd(sector['len'], MAX_TRANSFER_SIZE)
blocks = [data[i:i + transfer_size] for i in range(0, len(data), transfer_size)]
for blocknum, block in enumerate(blocks):
#print('write to {:08X} ({} bytes)'.format(
# sector['addr'] + blocknum * TRANSFER_SIZE, len(block)))
self.write(blocknum, block)
status = self.wait_while_state(DfuState.DFU_DOWNLOAD_BUSY)
if status[1] != DfuState.DFU_DOWNLOAD_IDLE:
raise RuntimeError("An error occured. Device Status: {!r}".format(status))
def read_sector(self, sector):
"""
Reads data from the specified sector
Returns: a byte array containing the data
"""
self.set_alternate_safe(sector['alt'])
self.set_address_safe(sector['addr'])
transfer_size = fractions.gcd(sector['len'], MAX_TRANSFER_SIZE)
#blocknum_offset = int((sector['addr'] - sector['baseaddr']) / transfer_size)
data = array.array(u'B')
for blocknum in range(int(sector['len'] / transfer_size)):
#print('read at {:08X}'.format(sector['addr'] + blocknum * TRANSFER_SIZE))
deviceBlock = self.read(blocknum, transfer_size)
data.extend(deviceBlock)
self.abort() # take device into DFU_IDLE
return data
def jump_to_application(self, address):
self.set_address_safe(address)
#self.set_address(address)
#status = self.wait_while_state(DfuState.DFU_DOWNLOAD_BUSY)
#if status[1] != DfuState.DFU_DOWNLOAD_IDLE:
# raise RuntimeError("An error occured. Device Status: {}".format(status[1]))
self.leave()
status = self.wait_while_state(DfuState.DFU_MANIFEST_SYNC)
if status[1] != DfuState.DFU_MANIFEST:
raise RuntimeError("An error occured. Device Status: {}".format(status[1]))
@@ -0,0 +1,66 @@
import argparse
import sys
import struct
import binascii
def named(tuple,names):
return dict(zip(names,tuple))
def parse(fmt,data,names):
return named(struct.unpack(fmt,data),names)
def fileunpack(f, fmt, names):
n = struct.calcsize(fmt)
return parse(fmt, f.read(n), names)
class DfuFile:
def __init__(self, path):
self.targets = list()
self.devInfo = dict()
try:
dfufile = open(path, 'rb')
except:
raise argparse.ArgumentTypeError('Could not open file %r' % path)
with dfufile:
header = fileunpack(dfufile, "<5sBLB", ('signature', 'version', 'size', 'targets'))
if header['signature'] != b'DfuSe':
raise argparse.ArgumentTypeError('File signature does not match')
if header['version'] != 1:
raise argparse.ArgumentTypeError('Unsupport DfuSe file version')
for t in range(header['targets']):
target_prefix = fileunpack(dfufile, "<6sBL255sLL", ('signature', 'alternate', 'named', 'name', 'size', 'elements'))
if target_prefix['signature'] != b'Target':
raise argparse.ArgumentTypeError('Target signature does not match')
target = {
'name': target_prefix['name'].decode('ascii').rstrip('\0'),
'alternate': target_prefix['alternate'],
'elements': list()
}
for e in range(target_prefix['elements']):
element_prefix = fileunpack(dfufile,"<LL", ('address', 'size'))
element = {
'address': element_prefix['address'],
'data': dfufile.read(element_prefix['size'])
}
target['elements'].append(element)
self.targets.append(target)
suffix = fileunpack(dfufile, "<HHHH3sBL", ('fwVersion', 'pid', 'vid', 'dfuSpec', 'signature', 'length', 'crc'))
if suffix['signature'] != b'UFD':
raise argparse.ArgumentTypeError('File\'s suffix signature does not match')
self.devInfo = dict(suffix)
del(self.devInfo['signature'])
del(self.devInfo['length'])
del(self.devInfo['crc'])
@@ -0,0 +1,13 @@
class DfuState():
APP_IDLE = 0x00
APP_DETACH = 0x01
DFU_IDLE = 0x02
DFU_DOWNLOAD_SYNC = 0x03
DFU_DOWNLOAD_BUSY = 0x04
DFU_DOWNLOAD_IDLE = 0x05
DFU_MANIFEST_SYNC = 0x06
DFU_MANIFEST = 0x07
DFU_MANIFEST_WAIT_RESET = 0x08
DFU_UPLOAD_IDLE = 0x09
DFU_ERROR = 0x0a
@@ -0,0 +1,18 @@
class DfuStatus:
OK = 0x00
ERROR_TARGET = 0x01
ERROR_FILE = 0x02
ERROR_WRITE = 0x03
ERROR_ERASE = 0x04
ERROR_CHECK_ERASED = 0x05
ERROR_PROG = 0x06
ERROR_VERIFY = 0x07
ERROR_ADDRESS = 0x08
ERROR_NOTDONE = 0x09
ERROR_FIRMWARE = 0x0a
ERROR_VENDOR = 0x0b
ERROR_USBR = 0x0c
ERROR_POR = 0x0d
ERROR_UNKNOWN = 0x0e
ERROR_STALLEDPKT = 0x0f
@@ -0,0 +1,4 @@
from .DfuDevice import DfuDevice
from .DfuStatus import DfuStatus
from .DfuState import DfuState
from .DfuFile import DfuFile
@@ -0,0 +1,147 @@
# TODO: This file is dangerous because the enums could potentially change between API versions. Should transmit as part of the JSON.
# To regenerate this file, nagivate to the top level of the ODrive repository and run:
# python Firmware/interface_generator_stub.py --definitions Firmware/odrive-interface.yaml --template tools/enums_template.j2 --output tools/odrive/enums.py
# ODrive.Can.Protocol
PROTOCOL_SIMPLE = 0
# ODrive.Axis.AxisState
AXIS_STATE_UNDEFINED = 0
AXIS_STATE_IDLE = 1
AXIS_STATE_STARTUP_SEQUENCE = 2
AXIS_STATE_FULL_CALIBRATION_SEQUENCE = 3
AXIS_STATE_MOTOR_CALIBRATION = 4
AXIS_STATE_SENSORLESS_CONTROL = 5
AXIS_STATE_ENCODER_INDEX_SEARCH = 6
AXIS_STATE_ENCODER_OFFSET_CALIBRATION = 7
AXIS_STATE_CLOSED_LOOP_CONTROL = 8
AXIS_STATE_LOCKIN_SPIN = 9
AXIS_STATE_ENCODER_DIR_FIND = 10
AXIS_STATE_HOMING = 11
# ODrive.ThermistorCurrentLimiter.Error
THERMISTOR_CURRENT_LIMITER_ERROR_NONE = 0x00000000
THERMISTOR_CURRENT_LIMITER_ERROR_OVER_TEMP = 0x00000001
# ODrive.Encoder.Mode
ENCODER_MODE_INCREMENTAL = 0
ENCODER_MODE_HALL = 1
ENCODER_MODE_SINCOS = 2
ENCODER_MODE_SPI_ABS_CUI = 256
ENCODER_MODE_SPI_ABS_AMS = 257
ENCODER_MODE_SPI_ABS_AEAT = 258
# ODrive.Controller.ControlMode
CONTROL_MODE_VOLTAGE_CONTROL = 0
CONTROL_MODE_TORQUE_CONTROL = 1
CONTROL_MODE_VELOCITY_CONTROL = 2
CONTROL_MODE_POSITION_CONTROL = 3
# ODrive.Controller.InputMode
INPUT_MODE_INACTIVE = 0
INPUT_MODE_PASSTHROUGH = 1
INPUT_MODE_VEL_RAMP = 2
INPUT_MODE_POS_FILTER = 3
INPUT_MODE_MIX_CHANNELS = 4
INPUT_MODE_TRAP_TRAJ = 5
INPUT_MODE_TORQUE_RAMP = 6
INPUT_MODE_MIRROR = 7
# ODrive.Motor.MotorType
MOTOR_TYPE_HIGH_CURRENT = 0
MOTOR_TYPE_GIMBAL = 2
MOTOR_TYPE_ACIM = 3
# ODrive.Can.Error
CAN_ERROR_NONE = 0x00000000
CAN_ERROR_DUPLICATE_CAN_IDS = 0x00000001
# ODrive.Axis.Error
AXIS_ERROR_NONE = 0x00000000
AXIS_ERROR_INVALID_STATE = 0x00000001
AXIS_ERROR_DC_BUS_UNDER_VOLTAGE = 0x00000002
AXIS_ERROR_DC_BUS_OVER_VOLTAGE = 0x00000004
AXIS_ERROR_CURRENT_MEASUREMENT_TIMEOUT = 0x00000008
AXIS_ERROR_BRAKE_RESISTOR_DISARMED = 0x00000010
AXIS_ERROR_MOTOR_DISARMED = 0x00000020
AXIS_ERROR_MOTOR_FAILED = 0x00000040
AXIS_ERROR_SENSORLESS_ESTIMATOR_FAILED = 0x00000080
AXIS_ERROR_ENCODER_FAILED = 0x00000100
AXIS_ERROR_CONTROLLER_FAILED = 0x00000200
AXIS_ERROR_POS_CTRL_DURING_SENSORLESS = 0x00000400
AXIS_ERROR_WATCHDOG_TIMER_EXPIRED = 0x00000800
AXIS_ERROR_MIN_ENDSTOP_PRESSED = 0x00001000
AXIS_ERROR_MAX_ENDSTOP_PRESSED = 0x00002000
AXIS_ERROR_ESTOP_REQUESTED = 0x00004000
AXIS_ERROR_HOMING_WITHOUT_ENDSTOP = 0x00020000
AXIS_ERROR_OVER_TEMP = 0x00040000
# ODrive.Axis.LockinState
LOCKIN_STATE_INACTIVE = 0
LOCKIN_STATE_RAMP = 1
LOCKIN_STATE_ACCELERATE = 2
LOCKIN_STATE_CONST_VEL = 3
# ODrive.Motor.Error
MOTOR_ERROR_NONE = 0x00000000
MOTOR_ERROR_PHASE_RESISTANCE_OUT_OF_RANGE = 0x00000001
MOTOR_ERROR_PHASE_INDUCTANCE_OUT_OF_RANGE = 0x00000002
MOTOR_ERROR_ADC_FAILED = 0x00000004
MOTOR_ERROR_DRV_FAULT = 0x00000008
MOTOR_ERROR_CONTROL_DEADLINE_MISSED = 0x00000010
MOTOR_ERROR_NOT_IMPLEMENTED_MOTOR_TYPE = 0x00000020
MOTOR_ERROR_BRAKE_CURRENT_OUT_OF_RANGE = 0x00000040
MOTOR_ERROR_MODULATION_MAGNITUDE = 0x00000080
MOTOR_ERROR_BRAKE_DEADTIME_VIOLATION = 0x00000100
MOTOR_ERROR_UNEXPECTED_TIMER_CALLBACK = 0x00000200
MOTOR_ERROR_CURRENT_SENSE_SATURATION = 0x00000400
MOTOR_ERROR_CURRENT_LIMIT_VIOLATION = 0x00001000
MOTOR_ERROR_BRAKE_DUTY_CYCLE_NAN = 0x00002000
MOTOR_ERROR_DC_BUS_OVER_REGEN_CURRENT = 0x00004000
MOTOR_ERROR_DC_BUS_OVER_CURRENT = 0x00008000
# ODrive.Motor.ArmedState
ARMED_STATE_DISARMED = 0
ARMED_STATE_WAITING_FOR_TIMINGS = 1
ARMED_STATE_WAITING_FOR_UPDATE = 2
ARMED_STATE_ARMED = 3
# ODrive.Motor.GateDriver.DrvFault
DRV_FAULT_NO_FAULT = 0x00000000
DRV_FAULT_FET_LOW_C_OVERCURRENT = 0x00000001
DRV_FAULT_FET_HIGH_C_OVERCURRENT = 0x00000002
DRV_FAULT_FET_LOW_B_OVERCURRENT = 0x00000004
DRV_FAULT_FET_HIGH_B_OVERCURRENT = 0x00000008
DRV_FAULT_FET_LOW_A_OVERCURRENT = 0x00000010
DRV_FAULT_FET_HIGH_A_OVERCURRENT = 0x00000020
DRV_FAULT_OVERTEMPERATURE_WARNING = 0x00000040
DRV_FAULT_OVERTEMPERATURE_SHUTDOWN = 0x00000080
DRV_FAULT_P_VDD_UNDERVOLTAGE = 0x00000100
DRV_FAULT_G_VDD_UNDERVOLTAGE = 0x00000200
DRV_FAULT_G_VDD_OVERVOLTAGE = 0x00000400
# ODrive.Controller.Error
CONTROLLER_ERROR_NONE = 0x00000000
CONTROLLER_ERROR_OVERSPEED = 0x00000001
CONTROLLER_ERROR_INVALID_INPUT_MODE = 0x00000002
CONTROLLER_ERROR_UNSTABLE_GAIN = 0x00000004
CONTROLLER_ERROR_INVALID_MIRROR_AXIS = 0x00000008
CONTROLLER_ERROR_INVALID_LOAD_ENCODER = 0x00000010
CONTROLLER_ERROR_INVALID_ESTIMATE = 0x00000020
# ODrive.Encoder.Error
ENCODER_ERROR_NONE = 0x00000000
ENCODER_ERROR_UNSTABLE_GAIN = 0x00000001
ENCODER_ERROR_CPR_POLEPAIRS_MISMATCH = 0x00000002
ENCODER_ERROR_NO_RESPONSE = 0x00000004
ENCODER_ERROR_UNSUPPORTED_ENCODER_MODE = 0x00000008
ENCODER_ERROR_ILLEGAL_HALL_STATE = 0x00000010
ENCODER_ERROR_INDEX_NOT_FOUND_YET = 0x00000020
ENCODER_ERROR_ABS_SPI_TIMEOUT = 0x00000040
ENCODER_ERROR_ABS_SPI_COM_FAIL = 0x00000080
ENCODER_ERROR_ABS_SPI_NOT_READY = 0x00000100
# ODrive.SensorlessEstimator.Error
SENSORLESS_ESTIMATOR_ERROR_NONE = 0x00000000
SENSORLESS_ESTIMATOR_ERROR_UNSTABLE_GAIN = 0x00000001
@@ -0,0 +1,101 @@
import sys
import platform
import threading
import fibre
import odrive
import odrive.enums
from odrive.utils import calculate_thermistor_coeffs, set_motor_thermistor_coeffs, start_liveplotter, dump_errors, oscilloscope_dump, BulkCapture, step_and_plot
def print_banner():
print("Website: https://odriverobotics.com/")
print("Docs: https://docs.odriverobotics.com/")
print("Forums: https://discourse.odriverobotics.com/")
print("Discord: https://discord.gg/k3ZZ3mS")
print("Github: https://github.com/madcowswe/ODrive/")
print()
print('Please connect your ODrive.')
print('You can also type help() or quit().')
def print_help(args, have_devices):
print('')
if have_devices:
print('Connect your ODrive to {} and power it up.'.format(args.path))
print('After that, the following message should appear:')
print(' "Connected to ODrive [serial number] as odrv0"')
print('')
print('Once the ODrive is connected, type "odrv0." and press <tab>')
else:
print('Type "odrv0." and press <tab>')
print('This will present you with all the properties that you can reference')
print('')
print('For example: "odrv0.axis0.encoder.pos_estimate"')
print('will print the current encoder position on axis 0')
print('and "odrv0.axis0.controller.input_pos = 0.5"')
print('will send axis 0 to 0.5 turns')
print('')
interactive_variables = {}
discovered_devices = []
def did_discover_device(odrive, logger, app_shutdown_token):
"""
Handles the discovery of new devices by displaying a
message and making the device available to the interactive
console
"""
serial_number = odrive.serial_number if hasattr(odrive, 'serial_number') else "[unknown serial number]"
if serial_number in discovered_devices:
verb = "Reconnected"
index = discovered_devices.index(serial_number)
else:
verb = "Connected"
discovered_devices.append(serial_number)
index = len(discovered_devices) - 1
interactive_name = "odrv" + str(index)
# Publish new ODrive to interactive console
interactive_variables[interactive_name] = odrive
globals()[interactive_name] = odrive # Add to globals so tab complete works
logger.notify("{} to ODrive {:012X} as {}".format(verb, serial_number, interactive_name))
# Subscribe to disappearance of the device
odrive.__channel__._channel_broken.subscribe(lambda: did_lose_device(interactive_name, logger, app_shutdown_token))
def did_lose_device(interactive_name, logger, app_shutdown_token):
"""
Handles the disappearance of a device by displaying
a message.
"""
if not app_shutdown_token.is_set():
logger.warn("Oh no {} disappeared".format(interactive_name))
def launch_shell(args, logger, app_shutdown_token):
"""
Launches an interactive python or IPython command line
interface.
As ODrives are connected they are made available as
"odrv0", "odrv1", ...
"""
interactive_variables = {
'start_liveplotter': start_liveplotter,
'dump_errors': dump_errors,
'oscilloscope_dump': oscilloscope_dump,
'BulkCapture': BulkCapture,
'step_and_plot': step_and_plot,
'calculate_thermistor_coeffs': calculate_thermistor_coeffs,
'set_motor_thermistor_coeffs': set_motor_thermistor_coeffs
}
# Expose all enums from odrive.enums
interactive_variables.update({k: v for (k, v) in odrive.enums.__dict__.items() if not k.startswith("_")})
fibre.launch_shell(args,
interactive_variables,
print_banner, print_help,
logger, app_shutdown_token,
branding_short="odrv", branding_long="ODrive")
@@ -0,0 +1,113 @@
import test_runner
import time
import math
import os
import numpy as np
from odrive.enums import *
from test_runner import *
teensy_code_template = """
void setup() {
analogWriteResolution(10);
// base clock of the PWM timer is 150MHz (on Teensy 4.0)
int freq = 150000000/1024; // ~146.5kHz PWM frequency
analogWriteFrequency({analog_out}, freq);
// for filtering, assuming we have a 150 Ohm resistor, we need a capacitor of
// 1/(150000000/1024)*2*pi/150 = 2.85954744646751e-07 F, that's ~0.33uF
//pinMode({lpf_enable}, OUTPUT);
}
int i = 0;
void loop() {
i++;
i = i & 0x3ff;
if (digitalRead({analog_reset}))
i = 0;
analogWrite({analog_out}, i);
delay(1);
}
"""
class TestAnalogInput():
"""
Verifies the Analog input.
The Teensy generates a PWM signal with a duty cycle that follows a sawtooth signal
with a period of 1 second. The signal should be connected to the ODrive's
analog input through a low-pass-filter.
___ ___
Teensy PWM ----|___|-------o---------|___|----- ODrive Analog Input
150 Ohm | 150 Ohm
===
| 330nF
|
GND
"""
def get_test_cases(self, testrig: TestRig):
for odrive in testrig.get_components(ODriveComponent):
for odrive_gpio_num, odrive_gpio in [(2, odrive.gpio3), (3, odrive.gpio4)]:
analog_out_options = []
lpf_gpio = [gpio for lpf in testrig.get_connected_components(odrive_gpio, LowPassFilterComponent)
for gpio in testrig.get_connected_components(lpf.en, LinuxGpioComponent)]
for teensy_gpio in testrig.get_connected_components(odrive_gpio, TeensyGpio):
teensy = teensy_gpio.parent
analog_reset_options = []
for gpio in teensy.gpios:
for local_gpio in testrig.get_connected_components(gpio, LinuxGpioComponent):
analog_reset_options.append((gpio, local_gpio))
analog_out_options.append((teensy, teensy_gpio, analog_reset_options))
yield (odrive, lpf_gpio, odrive_gpio_num, analog_out_options)
def run_test(self, odrive: ODriveComponent, lpf_enable: LinuxGpioComponent, analog_in_num: int, teensy: TeensyComponent, teensy_analog_out: Component, teensy_analog_reset: Component, analog_reset_gpio: LinuxGpioComponent, logger: Logger):
code = teensy_code_template.replace("{analog_out}", str(teensy_analog_out.num)).replace("{analog_reset}", str(teensy_analog_reset.num)) #.replace("lpf_enable", str(lpf_enable.num))
teensy.compile_and_program(code)
analog_reset_gpio.config(output=True)
analog_reset_gpio.write(True)
lpf_enable.config(output=True)
lpf_enable.write(False)
logger.debug("Set up analog input...")
min_val = -20000
max_val = 20000
period = 1.025 # period in teensy code is 1s, but due to tiny overhead it's a bit longer
analog_mapping = [
None, #odrive.handle.config.gpio1_analog_mapping,
None, #odrive.handle.config.gpio2_analog_mapping,
odrive.handle.config.gpio3_analog_mapping,
odrive.handle.config.gpio4_analog_mapping,
None, #odrive.handle.config.gpio5_analog_mapping,
][analog_in_num]
odrive.unuse_gpios()
analog_mapping.endpoint = odrive.handle.axis0.controller._remote_attributes['input_pos']
analog_mapping.min = min_val
analog_mapping.max = max_val
odrive.save_config_and_reboot()
analog_reset_gpio.write(False)
data = record_log(lambda: [odrive.handle.axis0.controller.input_pos], duration=5.0)
# Expect mean error to be at most 2% (of the full scale).
# Expect there to be less than 2% outliers, where an outlier is anything that is more than 5% (of full scale) away from the expected value.
full_range = abs(max_val - min_val)
slope, offset, fitted_curve = fit_sawtooth(data, min_val, max_val, sigma=30)
test_assert_eq(slope, (max_val - min_val) / period, accuracy=0.005)
test_curve_fit(data, fitted_curve, max_mean_err = full_range * 0.02, inlier_range = full_range * 0.05, max_outliers = len(data[:,0]) * 0.02)
if __name__ == '__main__':
test_runner.run(TestAnalogInput())
@@ -0,0 +1,241 @@
import test_runner
import time
from math import pi
import os
from fibre.utils import Logger
from test_runner import *
from odrive.enums import *
class TestMotorCalibration():
"""
Runs the motor calibration (phase inductance and phase resistance measurement)
and checks if the measurements match the expectation.
"""
def get_test_cases(self, testrig: TestRig):
"""Returns all axes that are connected to a motor, along with the corresponding motor(s)"""
for odrive in testrig.get_components(ODriveComponent):
for axis in odrive.axes:
for motor in testrig.get_connected_components(axis, MotorComponent):
yield (axis, motor)
def run_test(self, axis_ctx: ODriveAxisComponent, motor_ctx: MotorComponent, logger: Logger):
# reset old calibration values
if axis_ctx.handle.encoder.config.mode != ENCODER_MODE_INCREMENTAL:
axis_ctx.handle.encoder.config.mode = ENCODER_MODE_INCREMENTAL
axis_ctx.parent.save_config_and_reboot()
axis_ctx.handle.motor.config.phase_resistance = 0.0
axis_ctx.handle.motor.config.phase_inductance = 0.0
axis_ctx.handle.motor.config.pre_calibrated = False
axis_ctx.handle.config.enable_watchdog = False
axis_ctx.handle.clear_errors()
# run calibration
request_state(axis_ctx, AXIS_STATE_MOTOR_CALIBRATION)
time.sleep(6)
test_assert_eq(axis_ctx.handle.current_state, AXIS_STATE_IDLE)
test_assert_no_error(axis_ctx)
# check if measurements match expectation
test_assert_eq(axis_ctx.handle.motor.config.phase_resistance, float(motor_ctx.yaml['phase-resistance']), accuracy=0.2)
test_assert_eq(axis_ctx.handle.motor.config.phase_inductance, float(motor_ctx.yaml['phase-inductance']), accuracy=0.5)
test_assert_eq(axis_ctx.handle.motor.is_calibrated, True)
class TestDisconnectedMotorCalibration():
"""
Tests if the motor calibration fails as expected if the phases are floating.
"""
def get_test_cases(self, testrig: TestRig):
"""Returns all axes that are disconnected"""
for odrive in testrig.get_components(ODriveComponent):
for axis in odrive.axes:
if axis.yaml == 'floating':
yield (axis,)
def run_test(self, axis_ctx: ODriveAxisComponent, logger: Logger):
axis = axis_ctx.handle
# reset old calibration values
axis_ctx.handle.motor.config.phase_resistance = 0.0
axis_ctx.handle.motor.config.phase_inductance = 0.0
axis_ctx.handle.motor.config.pre_calibrated = False
axis_ctx.handle.clear_errors()
# run test
request_state(axis_ctx, AXIS_STATE_MOTOR_CALIBRATION)
time.sleep(6)
test_assert_eq(axis_ctx.handle.current_state, AXIS_STATE_IDLE)
test_assert_eq(axis_ctx.handle.error, AXIS_ERROR_MOTOR_FAILED)
test_assert_eq(axis_ctx.handle.motor.error, MOTOR_ERROR_PHASE_RESISTANCE_OUT_OF_RANGE)
class TestEncoderDirFind():
"""
Runs the encoder index search.
"""
def get_test_cases(self, testrig: TestRig):
for odrive in testrig.get_components(ODriveComponent):
for num in range(2):
encoders = testrig.get_connected_components({
'a': (odrive.encoders[num].a, False),
'b': (odrive.encoders[num].b, False)
}, EncoderComponent)
motors = testrig.get_connected_components(odrive.axes[num], MotorComponent)
for motor, encoder in itertools.product(motors, encoders):
if encoder.impl in testrig.get_connected_components(motor):
yield (odrive.axes[num], motor, encoder)
def run_test(self, axis_ctx: ODriveAxisComponent, motor_ctx: MotorComponent, enc_ctx: EncoderComponent, logger: Logger):
axis = axis_ctx.handle
time.sleep(1.0) # wait for PLLs to stabilize
# Set motor calibration values
axis_ctx.handle.motor.config.phase_resistance = float(motor_ctx.yaml['phase-resistance'])
axis_ctx.handle.motor.config.phase_inductance = float(motor_ctx.yaml['phase-inductance'])
axis_ctx.handle.motor.config.pre_calibrated = True
# Set calibration settings
axis_ctx.handle.motor.config.direction = 0
axis_ctx.handle.config.calibration_lockin.vel = 12.566 # 2 electrical revolutions per second
axis_ctx.handle.clear_errors()
# run test
request_state(axis_ctx, AXIS_STATE_ENCODER_DIR_FIND)
time.sleep(4) # actual calibration takes 3 seconds
test_assert_eq(axis_ctx.handle.current_state, AXIS_STATE_IDLE)
test_assert_no_error(axis_ctx)
test_assert_eq(axis_ctx.handle.motor.config.direction in [-1, 1], True)
class TestEncoderOffsetCalibration():
"""
Runs the encoder index search.
"""
def get_test_cases(self, testrig: TestRig):
for odrive in testrig.get_components(ODriveComponent):
for num in range(2):
encoders = testrig.get_connected_components({
'a': (odrive.encoders[num].a, False),
'b': (odrive.encoders[num].b, False)
}, EncoderComponent)
motors = testrig.get_connected_components(odrive.axes[num], MotorComponent)
for motor, encoder in itertools.product(motors, encoders):
if encoder.impl in testrig.get_connected_components(motor):
yield (odrive.axes[num], motor, encoder)
def run_test(self, axis_ctx: ODriveAxisComponent, motor_ctx: MotorComponent, enc_ctx: EncoderComponent, logger: Logger):
axis = axis_ctx.handle
time.sleep(1.0) # wait for PLLs to stabilize
# Set motor calibration values
axis_ctx.handle.motor.config.phase_resistance = float(motor_ctx.yaml['phase-resistance'])
axis_ctx.handle.motor.config.phase_inductance = float(motor_ctx.yaml['phase-inductance'])
axis_ctx.handle.motor.config.pre_calibrated = True
# Set calibration settings
axis_ctx.handle.motor.config.direction = 0
axis_ctx.handle.encoder.config.use_index = False
axis_ctx.handle.encoder.config.calib_scan_omega = 12.566 # 2 electrical revolutions per second
axis_ctx.handle.encoder.config.calib_scan_distance = 50.265 # 8 revolutions
axis_ctx.handle.clear_errors()
# run test
request_state(axis_ctx, AXIS_STATE_ENCODER_OFFSET_CALIBRATION)
time.sleep(9) # actual calibration takes 8 seconds
test_assert_eq(axis_ctx.handle.current_state, AXIS_STATE_IDLE)
test_assert_no_error(axis_ctx)
test_assert_eq(axis_ctx.handle.encoder.is_ready, True)
test_assert_eq(axis_ctx.handle.motor.config.direction in [-1, 1], True)
class TestEncoderIndexSearch():
"""
Runs the encoder index search.
The index pin is triggered manually after three seconds from the testbench
host's GPIO.
"""
def get_test_cases(self, testrig: TestRig):
for odrive in testrig.get_components(ODriveComponent):
for num in range(2):
encoders = testrig.get_connected_components({
'a': (odrive.encoders[num].a, False),
'b': (odrive.encoders[num].b, False)
}, EncoderComponent)
motors = testrig.get_connected_components(odrive.axes[num], MotorComponent)
z_gpio = list(testrig.get_connected_components((odrive.encoders[num].z, False), LinuxGpioComponent))
for motor, encoder in itertools.product(motors, encoders):
if encoder.impl in testrig.get_connected_components(motor):
yield (odrive.axes[num], motor, encoder, z_gpio)
def run_test(self, axis_ctx: ODriveAxisComponent, motor_ctx: MotorComponent, enc_ctx: EncoderComponent, z_gpio: LinuxGpioComponent, logger: Logger):
axis = axis_ctx.handle
cpr = int(enc_ctx.yaml['cpr'])
z_gpio.config(output=True)
z_gpio.write(False)
time.sleep(1.0) # wait for PLLs to stabilize
# Set motor calibration values
axis_ctx.handle.motor.config.phase_resistance = float(motor_ctx.yaml['phase-resistance'])
axis_ctx.handle.motor.config.phase_inductance = float(motor_ctx.yaml['phase-inductance'])
axis_ctx.handle.motor.config.pre_calibrated = True
# Set calibration settings
axis_ctx.handle.config.calibration_lockin.vel = 12.566 # 2 electrical revolutions per second
axis_ctx.handle.clear_errors()
# run test
request_state(axis_ctx, AXIS_STATE_ENCODER_INDEX_SEARCH)
time.sleep(3)
test_assert_eq(axis_ctx.handle.encoder.index_found, False)
time.sleep(0.1)
z_gpio.write(True)
test_assert_eq(axis_ctx.handle.encoder.index_found, True)
z_gpio.write(False)
test_assert_eq(axis_ctx.handle.current_state, AXIS_STATE_IDLE)
test_assert_no_error(axis_ctx)
test_assert_eq(axis_ctx.handle.encoder.shadow_count, 0.0, range=50)
test_assert_eq(modpm(axis_ctx.handle.encoder.count_in_cpr, cpr), 0.0, range=50)
test_assert_eq(axis_ctx.handle.encoder.pos_estimate, 0.0, range=50)
test_assert_eq(modpm(axis_ctx.handle.encoder.pos_cpr, cpr), 0.0, range=50)
test_assert_eq(axis_ctx.handle.encoder.pos_abs, 0.0, range=50)
if __name__ == '__main__':
test_runner.run([
TestMotorCalibration(),
TestDisconnectedMotorCalibration(),
TestEncoderDirFind(),
TestEncoderOffsetCalibration(),
TestEncoderIndexSearch()
])
@@ -0,0 +1,235 @@
import test_runner
import struct
import can
import asyncio
import time
import math
from fibre.utils import Logger
from odrive.enums import *
from test_runner import *
# Each argument is described as tuple (name, format, scale).
# Struct format codes: https://docs.python.org/2/library/struct.html
command_set = {
'heartbeat': (0x001, [('error', 'I', 1), ('current_state', 'I', 1)]), # tested
'estop': (0x002, []), # tested
'get_motor_error': (0x003, [('motor_error', 'I', 1)]), # untested
'get_encoder_error': (0x004, [('encoder_error', 'I', 1)]), # untested
'get_sensorless_error': (0x005, [('sensorless_error', 'I', 1)]), # untested
'set_node_id': (0x006, [('node_id', 'I', 1)]), # tested
'set_requested_state': (0x007, [('requested_state', 'I', 1)]), # tested
# 0x008 not yet implemented
'get_encoder_estimates': (0x009, [('encoder_pos_estimate', 'f', 1), ('encoder_vel_estimate', 'f', 1)]), # partially tested
'get_encoder_count': (0x00a, [('encoder_shadow_count', 'i', 1), ('encoder_count', 'i', 1)]), # partially tested
'set_controller_modes': (0x00b, [('control_mode', 'i', 1), ('input_mode', 'i', 1)]), # tested
'set_input_pos': (0x00c, [('input_pos', 'f', 1), ('vel_ff', 'h', 0.001), ('torque_ff', 'h', 0.001)]), # tested
'set_input_vel': (0x00d, [('input_vel', 'f', 1), ('torque_ff', 'f', 1)]), # tested
'set_input_torque': (0x00e, [('input_torque', 'f', 1)]), # tested
'set_velocity_limit': (0x00f, [('velocity_limit', 'f', 1)]), # tested
'start_anticogging': (0x010, []), # untested
'set_traj_vel_limit': (0x011, [('traj_vel_limit', 'f', 1)]), # tested
'set_traj_accel_limits': (0x012, [('traj_accel_limit', 'f', 1), ('traj_decel_limit', 'f', 1)]), # tested
'set_traj_inertia': (0x013, [('inertia', 'f', 1)]), # tested
'get_iq': (0x014, [('iq_setpoint', 'f', 1), ('iq_measured', 'f', 1)]), # untested
'get_sensorless_estimates': (0x015, [('sensorless_pos_estimate', 'f', 1), ('sensorless_vel_estimate', 'f', 1)]), # untested
'reboot': (0x016, []), # tested
'get_vbus_voltage': (0x017, [('vbus_voltage', 'f', 1)]), # tested
'clear_errors': (0x018, []), # partially tested
}
def command(bus, node_id_, extended_id, cmd_name, **kwargs):
cmd_spec = command_set[cmd_name]
cmd_id = cmd_spec[0]
fmt = '<' + ''.join([f for (n, f, s) in cmd_spec[1]]) # all little endian
if (sorted([n for (n, f, s) in cmd_spec[1]]) != sorted(kwargs.keys())):
raise Exception("expected arguments: " + str([n for (n, f, s) in cmd_spec[1]]))
fields = [((kwargs[n] / s) if f == 'f' else int(kwargs[n] / s)) for (n, f, s) in cmd_spec[1]]
data = struct.pack(fmt, *fields)
msg = can.Message(arbitration_id=((node_id_ << 5) | cmd_id), extended_id=extended_id, data=data)
bus.send(msg)
async def record_messages(bus, node_id, extended_id, cmd_name, timeout = 5.0):
"""
Returns an async generator that yields a dictionary for each CAN message that
is received, provided that the CAN ID matches the expected value.
"""
cmd_spec = command_set[cmd_name]
cmd_id = cmd_spec[0]
fmt = '<' + ''.join([f for (n, f, s) in cmd_spec[1]]) # all little endian
reader = can.AsyncBufferedReader()
notifier = can.Notifier(bus, [reader], timeout = timeout, loop = asyncio.get_event_loop())
try:
# The timeout in can.Notifier only triggers if no new messages are received at all,
# so we need a second monitoring method.
start = time.monotonic()
while True:
msg = await reader.get_message()
if ((msg.arbitration_id == ((node_id << 5) | cmd_id)) and (msg.is_extended_id == extended_id) and not msg.is_remote_frame):
fields = struct.unpack(fmt, msg.data[:(struct.calcsize(fmt))])
res = {n: (fields[i] * s) for (i, (n, f, s)) in enumerate(cmd_spec[1])}
res['t'] = time.monotonic()
yield res
if (time.monotonic() - start) > timeout:
break
finally:
notifier.stop()
async def request(bus, node_id, extended_id, cmd_name, timeout = 1.0):
cmd_spec = command_set[cmd_name]
cmd_id = cmd_spec[0]
msg_generator = record_messages(bus, node_id, extended_id, cmd_name, timeout)
msg = can.Message(arbitration_id=((node_id << 5) | cmd_id), extended_id=extended_id, data=[], is_remote_frame=True)
bus.send(msg)
async for msg in msg_generator:
return msg
raise TimeoutError()
async def get_all(async_iterator):
return [x async for x in async_iterator]
class TestSimpleCAN():
def get_test_cases(self, testrig: TestRig):
for odrive in testrig.get_components(ODriveComponent):
can_interfaces = list(testrig.get_connected_components(odrive.can, CanInterfaceComponent))
yield (odrive, can_interfaces, 0, False) # standard ID
yield (odrive, can_interfaces, 0xfedcba, True) # extended ID
def run_test(self, odrive: ODriveComponent, canbus: CanInterfaceComponent, node_id: int, extended_id: bool, logger: Logger):
# make sure no gpio input is overwriting our values
odrive.unuse_gpios()
axis = odrive.handle.axis0
axis.config.enable_watchdog = False
axis.clear_errors()
axis.config.can_node_id = node_id
axis.config.can_node_id_extended = extended_id
time.sleep(0.1)
def my_cmd(cmd_name, **kwargs): command(canbus.handle, node_id, extended_id, cmd_name, **kwargs)
def my_req(cmd_name, **kwargs): return asyncio.run(request(canbus.handle, node_id, extended_id, cmd_name, **kwargs))
def fence(): my_req('get_vbus_voltage') # fence to ensure the CAN command was sent
test_assert_eq(my_req('get_vbus_voltage')['vbus_voltage'], odrive.handle.vbus_voltage, accuracy=0.01)
my_cmd('set_node_id', node_id=node_id+20)
asyncio.run(request(canbus.handle, node_id+20, extended_id, 'get_vbus_voltage'))
test_assert_eq(axis.config.can_node_id, node_id+20)
# Reset node ID to default value
command(canbus.handle, node_id+20, extended_id, 'set_node_id', node_id=node_id)
fence()
test_assert_eq(axis.config.can_node_id, node_id)
# Check that extended node IDs are not carelessly projected to 6-bit IDs
extended_id = not extended_id
my_cmd('estop') # should not be accepted
extended_id = not extended_id
fence()
test_assert_eq(axis.error, AXIS_ERROR_NONE)
axis.encoder.set_linear_count(123)
test_assert_eq(my_req('get_encoder_estimates')['encoder_pos_estimate'], 123.0 / axis.encoder.config.cpr, accuracy=0.01)
test_assert_eq(my_req('get_encoder_count')['encoder_shadow_count'], 123.0, accuracy=0.01)
my_cmd('clear_errors')
fence()
test_assert_eq(axis.error, 0)
my_cmd('estop')
fence()
test_assert_eq(axis.error, AXIS_ERROR_ESTOP_REQUESTED)
my_cmd('set_requested_state', requested_state=42) # illegal state - should assert axis error
fence()
test_assert_eq(axis.current_state, 1) # idle
test_assert_eq(axis.error, AXIS_ERROR_ESTOP_REQUESTED | AXIS_ERROR_INVALID_STATE)
my_cmd('clear_errors')
fence()
test_assert_eq(axis.error, 0)
my_cmd('set_controller_modes', control_mode=1, input_mode=5) # current conrol, traprzoidal trajectory
fence()
test_assert_eq(axis.controller.config.control_mode, 1)
test_assert_eq(axis.controller.config.input_mode, 5)
# Reset to safe values
my_cmd('set_controller_modes', control_mode=3, input_mode=1) # position control, passthrough
fence()
test_assert_eq(axis.controller.config.control_mode, 3)
test_assert_eq(axis.controller.config.input_mode, 1)
axis.controller.input_pos = 1234
axis.controller.input_vel = 1234
axis.controller.input_torque = 1234
my_cmd('set_input_pos', input_pos=1.23, vel_ff=1.2, torque_ff=3.4)
fence()
test_assert_eq(axis.controller.input_pos, 1.23, range=0.1)
test_assert_eq(axis.controller.input_vel, 1.2, range=0.01)
test_assert_eq(axis.controller.input_torque, 3.4, range=0.001)
axis.controller.config.control_mode = CONTROL_MODE_VELOCITY_CONTROL
my_cmd('set_input_vel', input_vel=-10.5, torque_ff=0.1234)
fence()
test_assert_eq(axis.controller.input_vel, -10.5, range=0.01)
test_assert_eq(axis.controller.input_torque, 0.1234, range=0.01)
axis.controller.config.control_mode = CONTROL_MODE_TORQUE_CONTROL
my_cmd('set_input_torque', input_torque=0.1)
fence()
test_assert_eq(axis.controller.input_torque, 0.1, range=0.01)
my_cmd('set_velocity_limit', velocity_limit=2.345678)
fence()
test_assert_eq(axis.controller.config.vel_limit, 2.345678, range=0.001)
my_cmd('set_traj_vel_limit', traj_vel_limit=123.456)
fence()
test_assert_eq(axis.trap_traj.config.vel_limit, 123.456, range=0.0001)
my_cmd('set_traj_accel_limits', traj_accel_limit=98.231, traj_decel_limit=-12.234)
fence()
test_assert_eq(axis.trap_traj.config.accel_limit, 98.231, range=0.0001)
test_assert_eq(axis.trap_traj.config.decel_limit, -12.234, range=0.0001)
my_cmd('set_traj_inertia', inertia=55.086)
fence()
test_assert_eq(axis.controller.config.inertia, 55.086, range=0.0001)
# any CAN cmd will feed the watchdog
test_watchdog(axis, lambda: my_cmd('set_input_torque', input_torque=0.0), logger)
logger.debug('testing heartbeat...')
# note that this will include the heartbeats that were received during the
# watchdog test (which takes 4.8s).
heartbeats = asyncio.run(get_all(record_messages(canbus.handle, node_id, extended_id, 'heartbeat', timeout = 1.0)))
test_assert_eq(len(heartbeats), 5.8 / 0.1, accuracy=0.05)
test_assert_eq([msg['error'] for msg in heartbeats[0:35]], [0] * 35) # before watchdog expiry
test_assert_eq([msg['error'] for msg in heartbeats[-10:]], [AXIS_ERROR_WATCHDOG_TIMER_EXPIRED] * 10) # after watchdog expiry
test_assert_eq([msg['current_state'] for msg in heartbeats], [1] * len(heartbeats))
logger.debug('testing reboot...')
my_cmd('reboot')
time.sleep(0.5)
if len(odrive.handle._remote_attributes) != 0:
raise TestFailed("device didn't seem to reboot")
odrive.handle = None
time.sleep(2.0)
odrive.prepare(logger)
if __name__ == '__main__':
test_runner.run(TestSimpleCAN())
@@ -0,0 +1,391 @@
import test_runner
import time
from math import pi, inf
import os
from fibre.utils import Logger
from test_runner import *
from odrive.enums import *
class TestClosedLoopControlBase():
"""
Base class for close loop control tests.
"""
def get_test_cases(self, testrig: TestRig):
for odrive in testrig.get_components(ODriveComponent):
for num in range(2):
encoders = testrig.get_connected_components({
'a': (odrive.encoders[num].a, False),
'b': (odrive.encoders[num].b, False)
}, EncoderComponent)
motors = testrig.get_connected_components(odrive.axes[num], MotorComponent)
for motor, encoder in itertools.product(motors, encoders):
if encoder.impl in testrig.get_connected_components(motor):
yield (odrive.axes[num], motor, encoder)
def prepare(self, axis_ctx: ODriveAxisComponent, motor_ctx: MotorComponent, enc_ctx: EncoderComponent, logger: Logger):
# Make sure there are no funny configurations active
logger.debug('Setting up clean configuration...')
axis_ctx.parent.erase_config_and_reboot()
# Set motor calibration values
axis_ctx.handle.motor.config.phase_resistance = float(motor_ctx.yaml['phase-resistance'])
axis_ctx.handle.motor.config.phase_inductance = float(motor_ctx.yaml['phase-inductance'])
axis_ctx.handle.motor.config.pre_calibrated = True
# Set calibration settings
axis_ctx.handle.motor.config.direction = 0
axis_ctx.handle.encoder.config.use_index = False
axis_ctx.handle.encoder.config.calib_scan_omega = 12.566 # 2 electrical revolutions per second
axis_ctx.handle.encoder.config.calib_scan_distance = 50.265 # 8 revolutions
axis_ctx.handle.encoder.config.bandwidth = 1000
axis_ctx.handle.clear_errors()
logger.debug('Calibrating encoder offset...')
request_state(axis_ctx, AXIS_STATE_ENCODER_OFFSET_CALIBRATION)
time.sleep(9) # actual calibration takes 8 seconds
test_assert_eq(axis_ctx.handle.current_state, AXIS_STATE_IDLE)
test_assert_no_error(axis_ctx)
# Return a context that can be used in a with-statement.
class safe_terminator():
def __enter__(self):
pass
def __exit__(self, exc_type, exc_val, exc_tb):
logger.debug('clearing config...')
axis_ctx.handle.requested_state = AXIS_STATE_IDLE
time.sleep(0.005)
axis_ctx.parent.erase_config_and_reboot()
return safe_terminator()
class TestClosedLoopControl(TestClosedLoopControlBase):
"""
Tests position and velocity control
"""
def run_test(self, axis_ctx: ODriveAxisComponent, motor_ctx: MotorComponent, enc_ctx: EncoderComponent, logger: Logger):
with self.prepare(axis_ctx, motor_ctx, enc_ctx, logger):
nominal_rps = 1.0
nominal_vel = nominal_rps
logger.debug(f'Testing closed loop velocity control at {nominal_rps} rounds/s...')
axis_ctx.handle.controller.config.control_mode = CONTROL_MODE_VELOCITY_CONTROL
axis_ctx.handle.controller.config.input_mode = INPUT_MODE_PASSTHROUGH
axis_ctx.handle.controller.input_vel = 0
request_state(axis_ctx, AXIS_STATE_CLOSED_LOOP_CONTROL)
axis_ctx.handle.controller.input_vel = nominal_vel
data = record_log(lambda: [axis_ctx.handle.encoder.vel_estimate, axis_ctx.handle.encoder.pos_estimate], duration=5.0)
test_assert_eq(axis_ctx.handle.current_state, AXIS_STATE_CLOSED_LOOP_CONTROL)
test_assert_no_error(axis_ctx)
request_state(axis_ctx, AXIS_STATE_IDLE)
# encoder.vel_estimate
slope, offset, fitted_curve = fit_line(data[:,(0,1)])
test_assert_eq(slope, 0.0, range = nominal_vel * 0.02)
test_assert_eq(offset, nominal_vel, accuracy = 0.05)
test_curve_fit(data[:,(0,1)], fitted_curve, max_mean_err = nominal_vel * 0.3, inlier_range = nominal_vel * 0.5, max_outliers = len(data[:,0]) * 0.1)
# encoder.pos_estimate
slope, offset, fitted_curve = fit_line(data[:,(0,2)])
test_assert_eq(slope, nominal_vel, accuracy = 0.01)
test_curve_fit(data[:,(0,2)], fitted_curve, max_mean_err = nominal_vel * 0.01, inlier_range = nominal_vel * 0.1, max_outliers = len(data[:,0]) * 0.01)
logger.debug(f'Testing closed loop position control...')
axis_ctx.handle.controller.config.control_mode = CONTROL_MODE_POSITION_CONTROL
axis_ctx.handle.controller.input_pos = 0
axis_ctx.handle.controller.config.vel_limit = 5.0 # max 5 rps
axis_ctx.handle.encoder.set_linear_count(0)
request_state(axis_ctx, AXIS_STATE_CLOSED_LOOP_CONTROL)
# Test small position changes
test_pos = 5000 / float(enc_ctx.yaml['cpr'])
axis_ctx.handle.controller.input_pos = test_pos
time.sleep(0.3)
test_assert_no_error(axis_ctx)
test_assert_eq(axis_ctx.handle.encoder.pos_estimate, test_pos, range=0.4*test_pos) # large range needed because of cogging torque
axis_ctx.handle.controller.input_pos = -1 * test_pos
time.sleep(0.3)
test_assert_no_error(axis_ctx)
test_assert_eq(axis_ctx.handle.encoder.pos_estimate, -1 * test_pos, range=0.4*test_pos)
axis_ctx.handle.controller.input_pos = 0
time.sleep(0.3)
nominal_vel = 5.0
axis_ctx.handle.controller.input_pos = nominal_vel * 2.0 # 10 turns (takes 2 seconds)
# Test large position change with bounded velocity
data = record_log(lambda: [axis_ctx.handle.encoder.vel_estimate, axis_ctx.handle.encoder.pos_estimate], duration=4.0)
test_assert_eq(axis_ctx.handle.current_state, AXIS_STATE_CLOSED_LOOP_CONTROL)
test_assert_no_error(axis_ctx)
request_state(axis_ctx, AXIS_STATE_IDLE)
data_motion = data[data[:,0] < 1.9]
data_still = data[data[:,0] > 2.1]
# encoder.vel_estimate
slope, offset, fitted_curve = fit_line(data_motion[:,(0,1)])
test_assert_eq(slope, 0.0, range = nominal_vel * 0.05)
test_assert_eq(offset, nominal_vel, accuracy = 0.05)
test_curve_fit(data_motion[:,(0,1)], fitted_curve, max_mean_err = nominal_vel * 0.05, inlier_range = nominal_vel * 0.1, max_outliers = len(data[:,0]) * 0.01)
# encoder.pos_estimate
slope, offset, fitted_curve = fit_line(data_motion[:,(0,2)])
test_assert_eq(slope, nominal_vel, accuracy = 0.01)
test_curve_fit(data_motion[:,(0,2)], fitted_curve, max_mean_err = nominal_vel * 0.01, inlier_range = nominal_vel * 0.1, max_outliers = len(data[:,0]) * 0.01)
# encoder.vel_estimate
slope, offset, fitted_curve = fit_line(data_still[:,(0,1)])
test_assert_eq(slope, 0.0, range = nominal_vel * 0.05)
test_assert_eq(offset, 0.0, range = nominal_vel * 0.05)
test_curve_fit(data_still[:,(0,1)], fitted_curve, max_mean_err = nominal_vel * 0.05, inlier_range = nominal_vel * 0.1, max_outliers = len(data[:,0]) * 0.01)
# encoder.pos_estimate
slope, offset, fitted_curve = fit_line(data_still[:,(0,2)])
test_assert_eq(slope, 0.0, range = nominal_vel * 0.05)
test_assert_eq(offset, nominal_vel*2, range = nominal_vel * 0.02)
test_curve_fit(data_still[:,(0,2)], fitted_curve, max_mean_err = nominal_vel * 0.01, inlier_range = nominal_vel * 0.01, max_outliers = len(data[:,0]) * 0.01)
class TestRegenProtection(TestClosedLoopControlBase):
"""
Tries to brake with a disabled brake resistor.
This should result in a low level error disabling all power outputs.
Note: If this test fails then try to run it at a DC voltage of 24V.
Ibus seems to be more noisy/sensitive at lower DC voltages.
"""
def run_test(self, axis_ctx: ODriveAxisComponent, motor_ctx: MotorComponent, enc_ctx: EncoderComponent, logger: Logger):
with self.prepare(axis_ctx, motor_ctx, enc_ctx, logger):
nominal_rps = 15.0
nominal_vel = nominal_rps
max_current = 30.0
# Accept a bit of noise on Ibus
axis_ctx.parent.handle.config.dc_max_negative_current = -0.2
logger.debug(f'Brake control test from {nominal_rps} rounds/s...')
axis_ctx.handle.controller.config.vel_limit = 25.0 # max 15 rps
axis_ctx.handle.motor.config.current_lim = max_current
axis_ctx.handle.controller.config.control_mode = CONTROL_MODE_VELOCITY_CONTROL
axis_ctx.handle.controller.config.input_mode = INPUT_MODE_PASSTHROUGH
request_state(axis_ctx, AXIS_STATE_CLOSED_LOOP_CONTROL)
# accelerate...
axis_ctx.handle.controller.input_vel = nominal_vel
time.sleep(1.0)
test_assert_no_error(axis_ctx)
# ... and brake
axis_ctx.handle.controller.input_vel = 0
time.sleep(1.0)
test_assert_no_error(axis_ctx)
# once more, but this time without brake resistor
axis_ctx.parent.handle.config.brake_resistance = 0
# accelerate...
axis_ctx.handle.controller.input_vel = nominal_vel
time.sleep(1.0)
test_assert_no_error(axis_ctx)
# ... and brake
axis_ctx.handle.controller.input_vel = 0 # this should fail almost instantaneously
time.sleep(0.1)
test_assert_eq(axis_ctx.handle.error, AXIS_ERROR_MOTOR_DISARMED | AXIS_ERROR_BRAKE_RESISTOR_DISARMED)
test_assert_eq(axis_ctx.handle.motor.error, MOTOR_ERROR_DC_BUS_OVER_REGEN_CURRENT)
class TestVelLimitInTorqueControl(TestClosedLoopControlBase):
"""
Ensures that the current setpoint in torque control is always within the
parallelogram that arises from -Ilim, +Ilim, vel_limit and vel_gain.
"""
def run_test(self, axis_ctx: ODriveAxisComponent, motor_ctx: MotorComponent, enc_ctx: EncoderComponent, logger: Logger):
with self.prepare(axis_ctx, motor_ctx, enc_ctx, logger):
max_rps = 20.0
max_vel = max_rps
absolute_max_vel = max_vel * 1.2
max_current = 30.0
torque_constant = 0.0305 #correct for 5065 motor
axis_ctx.handle.controller.config.vel_gain /= 10 # reduce the slope to make it easier to see what's going on
vel_gain = axis_ctx.handle.controller.config.vel_gain
direction = axis_ctx.handle.motor.config.direction
logger.debug(f'vel gain is {vel_gain}')
axis_ctx.handle.controller.config.vel_limit = max_vel
axis_ctx.handle.controller.config.vel_limit_tolerance = inf # disable hard limit on velocity
axis_ctx.handle.motor.config.current_lim = max_current
axis_ctx.handle.motor.config.torque_constant = torque_constant
axis_ctx.handle.controller.config.control_mode = CONTROL_MODE_TORQUE_CONTROL
# Returns the expected limited setpoint for a given velocity and current
def get_expected_setpoint(input_setpoint, velocity):
return clamp(clamp(input_setpoint / torque_constant, (velocity + max_vel) * -vel_gain / torque_constant, (velocity - max_vel) * -vel_gain / torque_constant), -max_current, max_current) * direction
def data_getter():
# sample velocity twice to avoid systematic bias
velocity0 = axis_ctx.handle.encoder.vel_estimate
current_setpoint = axis_ctx.handle.motor.current_control.Iq_setpoint
velocity1 = axis_ctx.handle.encoder.vel_estimate
velocity = ((velocity0 + velocity1) / 2)
# Abort immediately if the absolute limits are exceeded
test_assert_within(current_setpoint, -max_current, max_current)
test_assert_within(velocity, -absolute_max_vel, absolute_max_vel)
return input_torque, velocity, current_setpoint, get_expected_setpoint(input_torque, velocity)
axis_ctx.handle.controller.input_torque = input_torque = 0.0
request_state(axis_ctx, AXIS_STATE_CLOSED_LOOP_CONTROL)
# Move the system around its operating envelope
axis_ctx.handle.controller.input_torque = input_torque = 2.0 * torque_constant
dataA = record_log(data_getter, duration=1.0)
axis_ctx.handle.controller.input_torque = input_torque = -2.0 * torque_constant
dataA = np.concatenate([dataA, record_log(data_getter, duration=1.0)])
axis_ctx.handle.controller.input_torque = input_torque = 4.0 * torque_constant
dataA = np.concatenate([dataA, record_log(data_getter, duration=1.0)])
axis_ctx.handle.controller.input_torque = input_torque = -4.0 * torque_constant
dataA = np.concatenate([dataA, record_log(data_getter, duration=1.0)])
# Shrink the operating envelope while motor is moving faster than the envelope allows
max_rps = 5.0
max_vel = max_rps
axis_ctx.handle.controller.config.vel_limit = max_vel
# Move the system around its operating envelope
axis_ctx.handle.controller.input_torque = input_torque = 2.0 * torque_constant
dataB = record_log(data_getter, duration=1.0)
axis_ctx.handle.controller.input_torque = input_torque = -2.0 * torque_constant
dataB = np.concatenate([dataB, record_log(data_getter, duration=1.0)])
axis_ctx.handle.controller.input_torque = input_torque = 4.0 * torque_constant
dataB = np.concatenate([dataB, record_log(data_getter, duration=1.0)])
axis_ctx.handle.controller.input_torque = input_torque = -4.0 * torque_constant
dataB = np.concatenate([dataB, record_log(data_getter, duration=1.0)])
# Try the shrink maneuver again at positive velocity
axis_ctx.handle.controller.config.vel_limit = 20.0
axis_ctx.handle.controller.input_torque = 4.0 * torque_constant
time.sleep(0.5)
axis_ctx.handle.controller.config.vel_limit = max_vel
axis_ctx.handle.controller.input_torque = input_torque = 2.0 * torque_constant
dataB = np.concatenate([dataB, record_log(data_getter, duration=1.0)])
test_assert_no_error(axis_ctx)
axis_ctx.handle.requested_state=1
test_curve_fit(dataA[:,(0,3)], dataA[:,4], max_mean_err=0.02, inlier_range=0.05, max_outliers=len(dataA[:,0]*0.01))
test_curve_fit(dataB[:,(0,3)], dataB[:,4], max_mean_err=0.1, inlier_range=0.2, max_outliers=len(dataB[:,0])*0.01)
class TestTorqueLimit(TestClosedLoopControlBase):
"""
Checks that the torque limit is respected in position, velocity, and torque control modes
"""
def run_test(self, axis_ctx: ODriveAxisComponent, motor_ctx: MotorComponent, enc_ctx: EncoderComponent, logger: Logger):
with self.prepare(axis_ctx, motor_ctx, enc_ctx, logger):
max_rps = 15.0
max_vel = max_rps
max_current = 30.0
max_torque = 0.1 # must be less than max_current * torque_constant.
torque_constant = axis_ctx.handle.motor.config.torque_constant
test_pos = 5
test_vel = 10
test_torque = 0.5
axis_ctx.handle.controller.config.vel_limit = max_vel
axis_ctx.handle.motor.config.current_lim = max_current
axis_ctx.handle.motor.config.torque_lim = inf #disable torque limit
axis_ctx.handle.controller.config.control_mode = CONTROL_MODE_VELOCITY_CONTROL
def data_getter():
current_setpoint = axis_ctx.handle.motor.current_control.Iq_setpoint
torque_setpoint = current_setpoint * torque_constant
torque_limit = axis_ctx.handle.motor.config.torque_lim
# Abort immediately if the absolute limits are exceeded
test_assert_within(current_setpoint, -max_current, max_current)
test_assert_within(torque_setpoint, -torque_limit, torque_limit)
return max_current, current_setpoint, torque_limit, torque_setpoint
# begin test
axis_ctx.handle.motor.config.torque_lim = max_torque
request_state(axis_ctx, AXIS_STATE_CLOSED_LOOP_CONTROL)
# step input positions
logger.debug('input_pos step test')
axis_ctx.handle.controller.config.control_mode = CONTROL_MODE_POSITION_CONTROL
axis_ctx.handle.controller.input_pos = test_pos
dataPos = record_log(data_getter, duration=1.0)
axis_ctx.handle.controller.input_pos = -test_pos
dataPos = np.concatenate([dataPos, record_log(data_getter, duration=1.0)])
axis_ctx.handle.controller.input_pos = test_pos
dataPos = np.concatenate([dataPos, record_log(data_getter, duration=1.0)])
axis_ctx.handle.controller.input_pos = -test_pos
dataPos = np.concatenate([dataPos, record_log(data_getter, duration=1.0)])
time.sleep(0.5)
test_assert_no_error(axis_ctx)
# step input velocities
logger.debug('input_vel step test')
axis_ctx.handle.controller.config.control_mode = CONTROL_MODE_VELOCITY_CONTROL
axis_ctx.handle.controller.input_vel = test_vel
dataVel = record_log(data_getter, duration=1.0)
axis_ctx.handle.controller.input_vel = -test_vel
dataVel = np.concatenate([dataVel, record_log(data_getter, duration=1.0)])
axis_ctx.handle.controller.input_vel = test_vel
dataVel = np.concatenate([dataVel, record_log(data_getter, duration=1.0)])
axis_ctx.handle.controller.input_vel = -test_vel
dataVel = np.concatenate([dataVel, record_log(data_getter, duration=1.0)])
axis_ctx.handle.controller.input_vel = 0
time.sleep(0.5)
# step input torques
logger.debug('input_torque step test')
axis_ctx.handle.controller.config.control_mode = CONTROL_MODE_TORQUE_CONTROL
axis_ctx.handle.controller.input_torque = test_torque
dataTq = record_log(data_getter, duration=1.0)
axis_ctx.handle.controller.input_torque = -test_torque
dataTq = np.concatenate([dataTq, record_log(data_getter, duration=1.0)])
axis_ctx.handle.controller.input_torque = test_torque
dataTq = np.concatenate([dataTq, record_log(data_getter, duration=1.0)])
axis_ctx.handle.controller.input_torque = -test_torque
dataTq = np.concatenate([dataTq, record_log(data_getter, duration=1.0)])
axis_ctx.handle.controller.input_torque = 0
time.sleep(0.5)
# did we pass?
test_assert_no_error(axis_ctx)
axis_ctx.handle.requested_state=1
if __name__ == '__main__':
test_runner.run([
TestClosedLoopControl(),
TestRegenProtection(),
TestVelLimitInTorqueControl(),
TestTorqueLimit()
])
@@ -0,0 +1,512 @@
import test_runner
import time
from math import pi
import os
from fibre.utils import Logger
from odrive.enums import *
from test_runner import *
class TestEncoderBase():
"""
Base class for encoder tests.
TODO: incremental encoder doesn't use this yet.
All encoder tests expect the encoder to run at a constant velocity.
This can be achieved by generating an encoder signal with a Teensy.
During 5 seconds, several variables are recorded and then compared against
the expected waveform. This is either a straight line, a sawtooth function
or a constant.
"""
def run_generic_encoder_test(self, encoder, true_cpr, true_rps, noise=1):
encoder.config.cpr = true_cpr
true_cps = true_cpr * true_rps
encoder.set_linear_count(0) # prevent numerical errors
data = record_log(lambda: [
encoder.shadow_count,
encoder.count_in_cpr,
encoder.phase,
encoder.pos_estimate_counts,
encoder.pos_cpr_counts,
encoder.vel_estimate_counts,
], duration=5.0)
short_period = (abs(1 / true_rps) < 5.0)
reverse = (true_rps < 0)
# encoder.shadow_count
slope, offset, fitted_curve = fit_line(data[:,(0,1)])
test_assert_eq(slope, true_cps, accuracy=0.005)
test_curve_fit(data[:,(0,1)], fitted_curve, max_mean_err = true_cpr * 0.02, inlier_range = true_cpr * 0.02, max_outliers = len(data[:,0]) * 0.02)
# encoder.count_in_cpr
slope, offset, fitted_curve = fit_sawtooth(data[:,(0,2)], true_cpr if reverse else 0, 0 if reverse else true_cpr)
test_assert_eq(slope, true_cps, accuracy=0.005)
test_curve_fit(data[:,(0,2)], fitted_curve, max_mean_err = true_cpr * 0.02, inlier_range = true_cpr * 0.02, max_outliers = len(data[:,0]) * 0.02)
# encoder.phase
slope, offset, fitted_curve = fit_sawtooth(data[:,(0,3)], pi if reverse else -pi, -pi if reverse else pi, sigma=5)
test_assert_eq(slope / 7, 2*pi*true_rps, accuracy=0.05)
test_curve_fit(data[:,(0,3)], fitted_curve, max_mean_err = true_cpr * 0.02, inlier_range = true_cpr * 0.02, max_outliers = len(data[:,0]) * 0.02)
# encoder.pos_estimate
slope, offset, fitted_curve = fit_line(data[:,(0,4)])
test_assert_eq(slope, true_cps, accuracy=0.005)
test_curve_fit(data[:,(0,4)], fitted_curve, max_mean_err = true_cpr * 0.02, inlier_range = true_cpr * 0.02, max_outliers = len(data[:,0]) * 0.02)
# encoder.pos_cpr
slope, offset, fitted_curve = fit_sawtooth(data[:,(0,5)], true_cpr if reverse else 0, 0 if reverse else true_cpr)
test_assert_eq(slope, true_cps, accuracy=0.005)
test_curve_fit(data[:,(0,5)], fitted_curve, max_mean_err = true_cpr * 0.05, inlier_range = true_cpr * 0.05, max_outliers = len(data[:,0]) * 0.02)
# encoder.vel_estimate
slope, offset, fitted_curve = fit_line(data[:,(0,6)])
test_assert_eq(slope, 0.0, range = true_cpr * abs(true_rps) * 0.01)
test_assert_eq(offset, true_cpr * true_rps, accuracy = 0.02)
test_curve_fit(data[:,(0,6)], fitted_curve, max_mean_err = true_cpr * 0.05, inlier_range = true_cpr * 0.05 * noise, max_outliers = len(data[:,0]) * 0.05)
teensy_incremental_encoder_emulation_code = """
void setup() {
pinMode({enc_a}, OUTPUT);
pinMode({enc_b}, OUTPUT);
}
int cpr = 8192;
int rpm = 30;
// the loop routine runs over and over again forever:
void loop() {
int microseconds_per_count = (1000000 * 60 / cpr / rpm);
for (;;) {
digitalWrite({enc_a}, HIGH);
delayMicroseconds(microseconds_per_count);
digitalWrite({enc_b}, HIGH);
delayMicroseconds(microseconds_per_count);
digitalWrite({enc_a}, LOW);
delayMicroseconds(microseconds_per_count);
digitalWrite({enc_b}, LOW);
delayMicroseconds(microseconds_per_count);
}
}
"""
class TestIncrementalEncoder(TestEncoderBase):
def get_test_cases(self, testrig: TestRig):
for odrive in testrig.get_components(ODriveComponent):
for encoder in odrive.encoders:
# Find the Teensy that is connected to the encoder pins and the corresponding Teensy GPIOs
gpio_conns = [
testrig.get_directly_connected_components(encoder.a),
testrig.get_directly_connected_components(encoder.b),
]
valid_combinations = [
(combination[0].parent,) + tuple(combination)
for combination in itertools.product(*gpio_conns)
if ((len(set(c.parent for c in combination)) == 1) and isinstance(combination[0].parent, TeensyComponent))
]
yield (encoder, valid_combinations)
def run_test(self, enc: ODriveEncoderComponent, teensy: TeensyComponent, teensy_gpio_a: TeensyGpio, teensy_gpio_b: TeensyGpio, logger: Logger):
true_cps = 8192*0.5 # counts per second generated by the virtual encoder
code = teensy_incremental_encoder_emulation_code.replace("{enc_a}", str(teensy_gpio_a.num)).replace("{enc_b}", str(teensy_gpio_b.num))
teensy.compile_and_program(code)
if enc.handle.config.mode != ENCODER_MODE_INCREMENTAL:
enc.handle.config.mode = ENCODER_MODE_INCREMENTAL
enc.parent.save_config_and_reboot()
else:
time.sleep(1.0) # wait for PLLs to stabilize
enc.handle.config.bandwidth = 1000
logger.debug("testing with 8192 CPR...")
self.run_generic_encoder_test(enc.handle, 8192, true_cps / 8192)
logger.debug("testing with 65536 CPR...")
self.run_generic_encoder_test(enc.handle, 65536, true_cps / 65536)
enc.handle.config.cpr = 8192
teensy_sin_cos_encoder_emulation_code = """
void setup() {
analogWriteResolution(10);
int freq = 150000000/1024; // ~146.5kHz PWM frequency
analogWriteFrequency({enc_sin}, freq);
analogWriteFrequency({enc_cos}, freq);
}
float rps = 1.0f;
float pos = 0;
void loop() {
pos += 0.001f * rps;
if (pos > 1.0f)
pos -= 1.0f;
analogWrite({enc_sin}, (int)(512.0f + 512.0f * sin(2.0f * M_PI * pos)));
analogWrite({enc_cos}, (int)(512.0f + 512.0f * cos(2.0f * M_PI * pos)));
delay(1);
}
"""
class TestSinCosEncoder(TestEncoderBase):
def get_test_cases(self, testrig: TestRig):
for odrive in testrig.get_components(ODriveComponent):
gpio_conns = [
testrig.get_directly_connected_components(odrive.gpio3),
testrig.get_directly_connected_components(odrive.gpio4),
]
valid_combinations = [
(combination[0].parent,) + tuple(combination)
for combination in itertools.product(*gpio_conns)
if ((len(set(c.parent for c in combination)) == 1) and isinstance(combination[0].parent, TeensyComponent))
]
yield (odrive.encoders[0], valid_combinations)
def run_test(self, enc: ODriveEncoderComponent, teensy: TeensyComponent, teensy_gpio_sin: TeensyGpio, teensy_gpio_cos: TeensyGpio, logger: Logger):
code = teensy_sin_cos_encoder_emulation_code.replace("{enc_sin}", str(teensy_gpio_sin.num)).replace("{enc_cos}", str(teensy_gpio_cos.num))
teensy.compile_and_program(code)
if enc.handle.config.mode != ENCODER_MODE_SINCOS:
enc.parent.unuse_gpios()
enc.handle.config.mode = ENCODER_MODE_SINCOS
enc.parent.save_config_and_reboot()
else:
time.sleep(1.0) # wait for PLLs to stabilize
enc.handle.config.bandwidth = 100
self.run_generic_encoder_test(enc.handle, 6283, 1.0, 2.0)
teensy_hall_effect_encoder_emulation_code = """
void setup() {
pinMode({hall_a}, OUTPUT);
pinMode({hall_b}, OUTPUT);
pinMode({hall_c}, OUTPUT);
digitalWrite({hall_a}, HIGH);
}
int cpr = 90; // 15 pole-pairs. Value suggested in hoverboard.md
float rps = 1.0f;
int us_per_count = (1000000.0f / cpr / rps);
void loop() {
digitalWrite({hall_b}, HIGH);
delayMicroseconds(us_per_count);
digitalWrite({hall_a}, LOW);
delayMicroseconds(us_per_count);
digitalWrite({hall_c}, HIGH);
delayMicroseconds(us_per_count);
digitalWrite({hall_b}, LOW);
delayMicroseconds(us_per_count);
digitalWrite({hall_a}, HIGH);
delayMicroseconds(us_per_count);
digitalWrite({hall_c}, LOW);
delayMicroseconds(us_per_count);
}
"""
class TestHallEffectEncoder(TestEncoderBase):
def get_test_cases(self, testrig: TestRig):
for odrive in testrig.get_components(ODriveComponent):
for encoder in odrive.encoders:
# Find the Teensy that is connected to the encoder pins and the corresponding Teensy GPIOs
gpio_conns = [
testrig.get_directly_connected_components(encoder.a),
testrig.get_directly_connected_components(encoder.b),
testrig.get_directly_connected_components(encoder.z),
]
valid_combinations = [
(combination[0].parent,) + tuple(combination)
for combination in itertools.product(*gpio_conns)
if ((len(set(c.parent for c in combination)) == 1) and isinstance(combination[0].parent, TeensyComponent))
]
yield (encoder, valid_combinations)
def run_test(self, enc: ODriveEncoderComponent, teensy: TeensyComponent, teensy_gpio_a: TeensyGpio, teensy_gpio_b: TeensyGpio, teensy_gpio_c: TeensyGpio, logger: Logger):
true_cpr = 90
true_rps = 1.0
code = teensy_hall_effect_encoder_emulation_code.replace("{hall_a}", str(teensy_gpio_a.num)).replace("{hall_b}", str(teensy_gpio_b.num)).replace("{hall_c}", str(teensy_gpio_c.num))
teensy.compile_and_program(code)
if enc.handle.config.mode != ENCODER_MODE_HALL:
enc.handle.config.mode = ENCODER_MODE_HALL
enc.parent.save_config_and_reboot()
else:
time.sleep(1.0) # wait for PLLs to stabilize
enc.handle.config.bandwidth = 100
self.run_generic_encoder_test(enc.handle, true_cpr, true_rps)
enc.handle.config.cpr = 8192
# This encoder emulation mimics the specification given in the following datasheets:
#
# With {mode} == ENCODER_MODE_SPI_ABS_CUI:
# AMT23xx: https://www.cuidevices.com/product/resource/amt23.pdf
#
# With {mode} == ENCODER_MODE_SPI_ABS_AMS:
# AS5047P: https://ams.com/documents/20143/36005/AS5047P_DS000324_2-00.pdf/a7d44138-51f1-2f6e-c8b6-2577b369ace8
# AS5048A/AS5048B: https://ams.com/documents/20143/36005/AS5048_DS000298_4-00.pdf/910aef1f-6cd3-cbda-9d09-41f152104832
# => Only the read command on address 0x3fff is currently implemented.
teensy_spi_encoder_emulation_code = """
#define ENCODER_MODE_SPI_ABS_CUI 0x100
#define ENCODER_MODE_SPI_ABS_AMS 0x101
#define ENCODER_MODE_SPI_ABS_AEAT 0x102
static float rps = 1.0f;
static uint32_t cpr = 16384;
static uint32_t us_per_revolution = (uint32_t)(1000000.0f / rps);
static uint16_t spi_txd = 0; // first output word: NOP
static uint32_t zerotime = 0;
void setup() {
pinMode({ncs}, INPUT_PULLUP);
}
uint16_t get_pos_now() {
uint32_t time = micros();
return ((uint64_t)((time - zerotime) % us_per_revolution)) * cpr / us_per_revolution;
}
#if {mode} == ENCODER_MODE_SPI_ABS_AMS
uint8_t ams_parity(uint16_t v) {
v ^= v >> 8;
v ^= v >> 4;
v ^= v >> 2;
v ^= v >> 1;
return v & 1;
}
uint16_t handle_command(uint16_t cmd) {
const uint16_t ERROR_RESPONSE = 0xc000; // error flag and parity bit set
if (ams_parity(cmd)) {
return ERROR_RESPONSE;
}
if (!(cmd & 14)) { // write not supported
return ERROR_RESPONSE;
}
uint16_t addr = cmd & 0x3fff;
uint16_t data;
switch (addr) {
case 0x3fff: data = get_pos_now(); break;
default: return ERROR_RESPONSE;
}
return data | (ams_parity(data) << 15);
}
#endif
#if {mode} == ENCODER_MODE_SPI_ABS_CUI
uint8_t cui_parity(uint16_t v) {
v ^= v >> 8;
v ^= v >> 4;
v ^= v >> 2;
return ~v & 3;
}
uint16_t handle_command(uint16_t cmd) {
(void) cmd; // input not used on CUI
// Test the cui_parity function itself with the example given in the datasheet
if ((0x21AB | (cui_parity(0x21AB) << 14)) != 0x61AB) {
return 0x0000;
}
uint16_t data = get_pos_now();
return data | (cui_parity(data) << 14);
}
#endif
void loop() {
while (digitalReadFast({reset})) {
zerotime = micros();
}
if (!digitalReadFast({ncs})) {
static uint16_t spi_rxd = 0;
pinMode({miso}, OUTPUT);
for (;;) {
while (!digitalReadFast({sck}))
if (digitalReadFast({ncs}))
goto cs_deasserted;
// Rising edge: Push output bit
bool output_bit = spi_txd & 0x8000;
digitalWriteFast({miso}, output_bit);
spi_txd <<= 1;
while (digitalReadFast({sck}))
if (digitalReadFast({ncs}))
goto cs_deasserted;
// Falling edge: Sample input bit (only in AMS mode)
#if {mode} == ENCODER_MODE_SPI_ABS_AMS
bool input_bit = digitalReadFast({mosi});
spi_rxd <<= 1;
if (input_bit) {
spi_rxd |= 1;
} else {
spi_rxd &= ~1;
}
#endif
}
cs_deasserted:
// chip deselected: Process command
pinMode({miso}, INPUT);
spi_txd = handle_command(spi_rxd);
}
}
"""
class TestSpiEncoder(TestEncoderBase):
def __init__(self, mode: int):
self.mode = mode
def get_test_cases(self, testrig: TestRig):
for odrive in testrig.get_components(ODriveComponent):
for encoder in odrive.encoders:
odrive_ncs_gpio = odrive.gpio7 # this GPIO choice is completely arbitrary
gpio_conns = [
testrig.get_connected_components(odrive.sck, TeensyGpio),
testrig.get_connected_components(odrive.miso, TeensyGpio),
testrig.get_connected_components(odrive.mosi, TeensyGpio),
testrig.get_connected_components(odrive_ncs_gpio, TeensyGpio),
]
valid_combinations = []
for combination in itertools.product(*gpio_conns):
if (len(set(c.parent for c in combination)) != 1):
continue
teensy = combination[0].parent
reset_pin_options = []
for gpio in teensy.gpios:
for local_gpio in testrig.get_connected_components(gpio, LinuxGpioComponent):
reset_pin_options.append((gpio, local_gpio))
valid_combinations.append((teensy, *combination, reset_pin_options))
yield (encoder, 7, valid_combinations)
def run_test(self, enc: ODriveEncoderComponent, odrive_ncs_gpio: int, teensy: TeensyComponent, teensy_gpio_sck: TeensyGpio, teensy_gpio_miso: TeensyGpio, teensy_gpio_mosi: TeensyGpio, teensy_gpio_ncs: TeensyGpio, teensy_gpio_reset: TeensyGpio, reset_gpio: LinuxGpioComponent, logger: Logger):
true_cpr = 16384
true_rps = 1.0
reset_gpio.config(output=True) # hold encoder and disable its SPI
reset_gpio.write(True)
code = (teensy_spi_encoder_emulation_code
.replace("{sck}", str(teensy_gpio_sck.num))
.replace("{miso}", str(teensy_gpio_miso.num))
.replace("{mosi}", str(teensy_gpio_mosi.num))
.replace("{ncs}", str(teensy_gpio_ncs.num))
.replace("{reset}", str(teensy_gpio_reset.num))
.replace("{mode}", str(self.mode)))
teensy.compile_and_program(code)
logger.debug(f'Configuring absolute encoder in mode 0x{self.mode:x}...')
enc.handle.config.mode = self.mode
enc.handle.config.abs_spi_cs_gpio_pin = odrive_ncs_gpio
enc.handle.config.cpr = true_cpr
# Also put the other encoder into SPI mode to make it more interesting
other_enc = enc.parent.encoders[1 - enc.num]
other_enc.handle.config.mode = self.mode
other_enc.handle.config.abs_spi_cs_gpio_pin = odrive_ncs_gpio
other_enc.handle.config.cpr = true_cpr
enc.parent.save_config_and_reboot()
time.sleep(1.0)
logger.debug('Testing absolute readings and SPI errors...')
# Encoder is still disabled - expect recurring error
enc.handle.error = 0
time.sleep(0.002)
# This fails from time to time because the pull-up on the ODrive only manages
# to pull MISO to 1.8V, leaving it in the undefined range.
test_assert_eq(enc.handle.error, ENCODER_ERROR_ABS_SPI_COM_FAIL)
# Enable encoder and expect error to go away
reset_gpio.write(False)
release_time = time.monotonic()
enc.handle.error = 0
time.sleep(0.002)
test_assert_eq(enc.handle.error, 0)
# Check absolute position after 1.5s
time.sleep(1.5)
true_delta_t = time.monotonic() - release_time
test_assert_eq(enc.handle.pos_abs, (true_delta_t * true_rps * true_cpr) % true_cpr, range = true_cpr*0.001)
test_assert_eq(enc.handle.error, 0)
reset_gpio.write(True)
time.sleep(0.002)
test_assert_eq(enc.handle.error, ENCODER_ERROR_ABS_SPI_COM_FAIL)
reset_gpio.write(False)
release_time = time.monotonic()
enc.handle.error = 0
time.sleep(0.002)
test_assert_eq(enc.handle.error, 0)
# Check absolute position after 1.5s
time.sleep(1.5)
true_delta_t = time.monotonic() - release_time
test_assert_eq(enc.handle.pos_abs, (true_delta_t * true_rps * true_cpr) % true_cpr, range = true_cpr*0.001)
self.run_generic_encoder_test(enc.handle, true_cpr, true_rps)
enc.handle.config.cpr = 8192
if __name__ == '__main__':
test_runner.run([
TestIncrementalEncoder(),
TestSinCosEncoder(),
TestHallEffectEncoder(),
TestSpiEncoder(ENCODER_MODE_SPI_ABS_AMS),
TestSpiEncoder(ENCODER_MODE_SPI_ABS_CUI),
])
@@ -0,0 +1,34 @@
import odrive
from odrive.enums import *
from odrive.utils import *
print("finding an odrive...")
odrv0 = odrive.find_any()
print('Odrive found')
odrv0.axis1.controller.config.vel_limit = 50000
odrv0.axis1.controller.config.control_mode = CONTROL_MODE_POSITION_CONTROL
odrv0.axis1.controller.config.input_mode = INPUT_MODE_PASSTHROUGH
odrv0.axis1.encoder.config.cpr = 2400
odrv0.axis1.encoder.config.bandwidth = 1000
odrv0.axis1.motor.config.calibration_current = 5
odrv0.axis1.motor.config.current_lim = 5
odrv0.axis1.controller.config.homing_speed = 5000
odrv0.config.brake_resistance = 0
odrv0.axis0.min_endstop.config.gpio_num = 6
odrv0.axis0.min_endstop.config.enabled = True
odrv0.axis0.min_endstop.config.offset = -1000
odrv0.axis0.max_endstop.config.gpio_num = 5
odrv0.axis0.max_endstop.config.enabled = True
odrv0.axis1.min_endstop.config.gpio_num = 8
odrv0.axis1.min_endstop.config.enabled = True
odrv0.axis1.min_endstop.config.offset = -1000
odrv0.axis1.max_endstop.config.gpio_num = 7
odrv0.axis1.max_endstop.config.enabled = True
odrv0.axis1.config.startup_encoder_offset_calibration = True
odrv0.axis1.config.startup_motor_calibration = True
odrv0.axis1.config.startup_homing = True
odrv0.axis1.config.startup_closed_loop_control = True
@@ -0,0 +1,56 @@
import test_runner
import time
from fibre.utils import Logger
from odrive.enums import *
from test_runner import *
class FibreFunctionalTest():
"""
Tests basic protocol functionality.
"""
def get_test_cases(self, testrig: TestRig):
return testrig.get_components(ODriveComponent)
def run_test(self, odrive: ODriveComponent, logger: Logger):
# Test property read/write
odrive.handle.test_property = 42
test_assert_eq(odrive.handle.test_property, 42)
odrive.handle.test_property = 0xffffffff
test_assert_eq(odrive.handle.test_property, 0xffffffff)
# Test function call
val = odrive.handle.get_adc_voltage(0)
test_assert_within(val, 0.01, 3.29)
# Test custom setter (aka property write hook)
odrive.handle.axis0.motor.config.phase_resistance = 1
odrive.handle.axis0.motor.config.phase_inductance = 1
odrive.handle.axis0.motor.config.current_control_bandwidth = 1000
old_gain = odrive.handle.axis0.motor.current_control.p_gain
test_assert_eq(old_gain, 1000, accuracy=0.0001) # must be non-zero for subsequent check to work
odrive.handle.axis0.motor.config.current_control_bandwidth /= 2
test_assert_eq(odrive.handle.axis0.motor.current_control.p_gain, old_gain / 2, accuracy=0.0001)
class FibreBurnInTest():
"""
Tests continuous usage of the protocol.
"""
def get_test_cases(self, testrig: TestRig):
return testrig.get_components(ODriveComponent)
def run_test(self, odrive: ODriveComponent, logger: Logger):
data = record_log(lambda: [odrive.handle.vbus_voltage], duration=10.0)
expected_data = np.mean(data[:,1]) * np.ones(data[:,1].size)
test_curve_fit(data, expected_data, max_mean_err = 0.1, inlier_range = 0.5, max_outliers = 0)
if __name__ == '__main__':
test_runner.run([
FibreFunctionalTest(),
FibreBurnInTest(),
])
@@ -0,0 +1,230 @@
# this test runs the motor using CAN
# TODO - run a motor using all common use cases (uart, step/dir, pwm)
import test_runner
import struct
import can
import asyncio
import time
import math
from fibre.utils import Logger
from odrive.enums import *
from test_runner import *
# Each argument is described as tuple (name, format, scale).
# Struct format codes: https://docs.python.org/2/library/struct.html
command_set = {
'heartbeat': (0x001, [('error', 'I', 1), ('current_state', 'I', 1)]), # tested
'estop': (0x002, []), # tested
'get_motor_error': (0x003, [('motor_error', 'I', 1)]), # untested
'get_encoder_error': (0x004, [('encoder_error', 'I', 1)]), # untested
'get_sensorless_error': (0x005, [('sensorless_error', 'I', 1)]), # untested
'set_node_id': (0x006, [('node_id', 'I', 1)]), # tested
'set_requested_state': (0x007, [('requested_state', 'I', 1)]), # tested
# 0x008 not yet implemented
'get_encoder_estimates': (0x009, [('encoder_pos_estimate', 'f', 1), ('encoder_vel_estimate', 'f', 1)]), # partially tested
'get_encoder_count': (0x00a, [('encoder_shadow_count', 'i', 1), ('encoder_count', 'i', 1)]), # partially tested
'set_controller_modes': (0x00b, [('control_mode', 'i', 1), ('input_mode', 'i', 1)]), # tested
'set_input_pos': (0x00c, [('input_pos', 'f', 1), ('vel_ff', 'h', 0.001), ('torque_ff', 'h', 0.001)]), # tested
'set_input_vel': (0x00d, [('input_vel', 'f', 1), ('torque_ff', 'f', 1)]), # tested
'set_input_torque': (0x00e, [('input_torque', 'f', 1)]), # tested
'set_velocity_limit': (0x00f, [('velocity_limit', 'f', 1)]), # tested
'start_anticogging': (0x010, []), # untested
'set_traj_vel_limit': (0x011, [('traj_vel_limit', 'f', 1)]), # tested
'set_traj_accel_limits': (0x012, [('traj_accel_limit', 'f', 1), ('traj_decel_limit', 'f', 1)]), # tested
'set_traj_inertia': (0x013, [('inertia', 'f', 1)]), # tested
'get_iq': (0x014, [('iq_setpoint', 'f', 1), ('iq_measured', 'f', 1)]), # untested
'get_sensorless_estimates': (0x015, [('sensorless_pos_estimate', 'f', 1), ('sensorless_vel_estimate', 'f', 1)]), # untested
'reboot': (0x016, []), # tested
'get_vbus_voltage': (0x017, [('vbus_voltage', 'f', 1)]), # tested
'clear_errors': (0x018, []), # partially tested
}
def command(bus, node_id_, extended_id, cmd_name, **kwargs):
cmd_spec = command_set[cmd_name]
cmd_id = cmd_spec[0]
fmt = '<' + ''.join([f for (n, f, s) in cmd_spec[1]]) # all little endian
if (sorted([n for (n, f, s) in cmd_spec[1]]) != sorted(kwargs.keys())):
raise Exception("expected arguments: " + str([n for (n, f, s) in cmd_spec[1]]))
fields = [((kwargs[n] / s) if f == 'f' else int(kwargs[n] / s)) for (n, f, s) in cmd_spec[1]]
data = struct.pack(fmt, *fields)
msg = can.Message(arbitration_id=((node_id_ << 5) | cmd_id), extended_id=extended_id, data=data)
bus.send(msg)
async def record_messages(bus, node_id, extended_id, cmd_name, timeout = 5.0):
"""
Returns an async generator that yields a dictionary for each CAN message that
is received, provided that the CAN ID matches the expected value.
"""
cmd_spec = command_set[cmd_name]
cmd_id = cmd_spec[0]
fmt = '<' + ''.join([f for (n, f, s) in cmd_spec[1]]) # all little endian
reader = can.AsyncBufferedReader()
notifier = can.Notifier(bus, [reader], timeout = timeout, loop = asyncio.get_event_loop())
try:
# The timeout in can.Notifier only triggers if no new messages are received at all,
# so we need a second monitoring method.
start = time.monotonic()
while True:
msg = await reader.get_message()
if ((msg.arbitration_id == ((node_id << 5) | cmd_id)) and (msg.is_extended_id == extended_id) and not msg.is_remote_frame):
fields = struct.unpack(fmt, msg.data[:(struct.calcsize(fmt))])
res = {n: (fields[i] * s) for (i, (n, f, s)) in enumerate(cmd_spec[1])}
res['t'] = time.monotonic()
yield res
if (time.monotonic() - start) > timeout:
break
finally:
notifier.stop()
async def request(bus, node_id, extended_id, cmd_name, timeout = 1.0):
cmd_spec = command_set[cmd_name]
cmd_id = cmd_spec[0]
msg_generator = record_messages(bus, node_id, extended_id, cmd_name, timeout)
msg = can.Message(arbitration_id=((node_id << 5) | cmd_id), extended_id=extended_id, data=[], is_remote_frame=True)
bus.send(msg)
async for msg in msg_generator:
return msg
raise TimeoutError()
async def get_all(async_iterator):
return [x async for x in async_iterator]
class TestSimpleCANClosedLoop():
def prepare(self, odrive: ODriveComponent, canbus: CanInterfaceComponent, axis_ctx: ODriveAxisComponent, motor_ctx: MotorComponent, enc_ctx: EncoderComponent, node_id: int, extended_id: bool, logger: Logger):
# Make sure there are no funny configurations active
logger.debug('Setting up clean configuration...')
axis_ctx.parent.erase_config_and_reboot()
# run calibration
axis_ctx.handle.requested_state = AXIS_STATE_FULL_CALIBRATION_SEQUENCE
while axis_ctx.handle.current_state != AXIS_STATE_IDLE:
time.sleep(1)
test_assert_eq(axis_ctx.handle.current_state, AXIS_STATE_IDLE)
test_assert_no_error(axis_ctx)
# Return a context that can be used in a with-statement.
class safe_terminator():
def __enter__(self):
pass
def __exit__(self, exc_type, exc_val, exc_tb):
logger.debug('clearing config...')
axis_ctx.handle.requested_state = AXIS_STATE_IDLE
time.sleep(0.005)
axis_ctx.parent.erase_config_and_reboot()
return safe_terminator()
def get_test_cases(self, testrig: TestRig):
for odrive in testrig.get_components(ODriveComponent):
can_interfaces = list(testrig.get_connected_components(odrive.can, CanInterfaceComponent))
for num in range(2):
encoders = testrig.get_connected_components({
'a': (odrive.encoders[num].a, False),
'b': (odrive.encoders[num].b, False)
}, EncoderComponent)
motors = testrig.get_connected_components(odrive.axes[num], MotorComponent)
for motor, encoder in itertools.product(motors, encoders):
if encoder.impl in testrig.get_connected_components(motor):
yield (odrive, can_interfaces, odrive.axes[num], motor, encoder, 0, False)
def run_test(self, odrive: ODriveComponent, canbus: CanInterfaceComponent, axis_ctx: ODriveAxisComponent, motor_ctx: MotorComponent, enc_ctx: EncoderComponent, node_id: int, extended_id: bool, logger: Logger):
# this test is a sanity check to make sure that closed loop operation works
# actual testing of closed loop functionality should be tested using closed_loop_test.py
with self.prepare(odrive, canbus, axis_ctx, motor_ctx, enc_ctx, node_id, extended_id, logger):
def my_cmd(cmd_name, **kwargs): command(canbus.handle, node_id, extended_id, cmd_name, **kwargs)
def my_req(cmd_name, **kwargs): return asyncio.run(request(canbus.handle, node_id, extended_id, cmd_name, **kwargs))
def fence(): my_req('get_vbus_voltage') # fence to ensure the CAN command was sent
# make sure no gpio input is overwriting our values
odrive.unuse_gpios()
axis_ctx.handle.config.enable_watchdog = False
axis_ctx.handle.clear_errors()
axis_ctx.handle.config.can_node_id = node_id
axis_ctx.handle.config.can_node_id_extended = extended_id
time.sleep(0.1)
my_cmd('set_node_id', node_id=node_id+20)
asyncio.run(request(canbus.handle, node_id+20, extended_id, 'get_vbus_voltage'))
test_assert_eq(axis_ctx.handle.config.can_node_id, node_id+20)
# Reset node ID to default value
command(canbus.handle, node_id+20, extended_id, 'set_node_id', node_id=node_id)
fence()
test_assert_eq(axis_ctx.handle.config.can_node_id, node_id)
vel_limit = 15.0
nominal_vel = 10.0
axis_ctx.handle.controller.config.vel_limit = vel_limit
axis_ctx.handle.motor.config.current_lim = 30.0
my_cmd('set_requested_state', requested_state = AXIS_STATE_CLOSED_LOOP_CONTROL)
fence()
test_assert_eq(axis_ctx.handle.current_state, AXIS_STATE_CLOSED_LOOP_CONTROL)
test_assert_no_error(axis_ctx)
start_pos = axis_ctx.handle.encoder.pos_estimate
# position test
logger.debug('Position control test')
my_cmd('set_controller_modes', control_mode=CONTROL_MODE_POSITION_CONTROL, input_mode=INPUT_MODE_PASSTHROUGH) # position control, passthrough
fence()
my_cmd('set_input_pos', input_pos=1.0, vel_ff=0, torque_ff=0)
fence()
test_assert_eq(axis_ctx.handle.controller.input_pos, 1.0, range=0.1)
time.sleep(2)
test_assert_eq(axis_ctx.handle.encoder.pos_estimate, start_pos + 1.0, range=0.1)
my_cmd('set_input_pos', input_pos=0, vel_ff=0, torque_ff=0)
fence()
time.sleep(2)
test_assert_no_error(axis_ctx)
# velocity test
logger.debug('Velocity control test')
my_cmd('set_controller_modes', control_mode=CONTROL_MODE_VELOCITY_CONTROL, input_mode=INPUT_MODE_PASSTHROUGH) # velocity control, passthrough
fence()
my_cmd('set_input_vel', input_vel = nominal_vel, torque_ff=0)
fence()
time.sleep(5)
test_assert_eq(axis_ctx.handle.encoder.vel_estimate, nominal_vel, range=nominal_vel * 0.05) # big range here due to cogging and other issues
my_cmd('set_input_vel', input_vel = 0, torque_ff=0)
fence()
time.sleep(2)
test_assert_no_error(axis_ctx)
# torque test
logger.debug('Torque control test')
my_cmd('set_controller_modes', control_mode=CONTROL_MODE_TORQUE_CONTROL, input_mode=INPUT_MODE_PASSTHROUGH) # torque control, passthrough
fence()
my_cmd('set_input_torque', input_torque=0.5)
fence()
time.sleep(5)
test_assert_eq(axis_ctx.handle.controller.input_torque, 0.5, range=0.1)
my_cmd('set_input_torque', input_torque = 0)
fence()
time.sleep(2)
test_assert_no_error(axis_ctx)
# go back to idle
my_cmd('set_requested_state', requested_state = AXIS_STATE_IDLE)
fence()
test_assert_eq(axis_ctx.handle.current_state, AXIS_STATE_IDLE)
if __name__ == '__main__':
test_runner.run(TestSimpleCANClosedLoop())
@@ -0,0 +1,30 @@
import test_runner
from fibre.utils import Logger
from test_runner import *
class EncoderPassthrough():
"""
Does nothing except passing encoder0 through.
"""
def get_test_cases(self, testrig: TestRig):
for odrive in testrig.get_components(ODriveComponent):
for num in range(1):
encoders = testrig.get_connected_components({
'a': (odrive.encoders[num].a, False),
'b': (odrive.encoders[num].b, False),
'z': (odrive.encoders[num].z, False)
}, EncoderComponent)
motors = testrig.get_connected_components(odrive.axes[num], MotorComponent)
for motor, encoder in itertools.product(motors, encoders):
if encoder.impl in testrig.get_connected_components(motor):
yield (odrive.axes[num], motor, encoder)
def run_test(self, axis_ctx: ODriveAxisComponent, motor_ctx: MotorComponent, enc_ctx: EncoderComponent, logger: Logger):
logger.debug(f'Encoder {axis_ctx.num} was passed through')
if __name__ == '__main__':
test_runner.run(EncoderPassthrough())
@@ -0,0 +1,46 @@
import test_runner
import time
from math import pi
import os
import fibre
from fibre.utils import Logger
from test_runner import *
class TestStoreAndReboot():
"""
Stores the current configuration to NVM and reboots.
"""
def get_test_cases(self, testrig: TestRig):
for odrive in testrig.get_components(ODriveComponent):
yield (odrive,)
def run_with_values(self, odrive: ODriveComponent, values: list, logger: Logger):
logger.debug("storing configuration and rebooting...")
for value in values:
odrive.handle.config.brake_resistance = value
odrive.handle.save_configuration()
try:
odrive.handle.reboot()
except fibre.ChannelBrokenException:
pass # this is expected
odrive.handle = None
time.sleep(2)
odrive.prepare(logger)
logger.debug("verifying configuration after reboot...")
test_assert_eq(odrive.handle.config.brake_resistance, values[-1], accuracy=0.01)
def run_test(self, odrive: ODriveComponent, logger: Logger):
self.run_with_values(odrive, [0.5, 1.0, 1.5], logger)
self.run_with_values(odrive, [2.5, 3.7], logger)
self.run_with_values(odrive, [0.47], logger)
if __name__ == '__main__':
test_runner.run(TestStoreAndReboot())
@@ -0,0 +1,718 @@
from __future__ import print_function
import subprocess
import shlex
import math
import time
import sys
import threading
import fibre
import odrive
from odrive.enums import *
import odrive.utils
import numpy as np
import functools
print = functools.partial(print, flush=True)
import abc
ABC = abc.ABC
class PreconditionsNotMet(Exception):
pass
class AxisTestContext():
def __init__(self, name: str, yaml: dict, odrv_ctx: ODriveTestContext):
self.handle = None
self.yaml = yaml
self.name = name
self.lock = threading.Lock()
self.odrv_ctx = odrv_ctx
def get_errors(axis_ctx: AxisTestContext):
errors = []
if axis_ctx.handle.motor.error != 0:
errors.append("motor failed with error 0x{:04X}".format(axis_ctx.handle.motor.error))
if axis_ctx.handle.encoder.error != 0:
errors.append("encoder failed with error 0x{:04X}".format(axis_ctx.handle.encoder.error))
if axis_ctx.handle.sensorless_estimator.error != 0:
errors.append("sensorless_estimator failed with error 0x{:04X}".format(axis_ctx.handle.sensorless_estimator.error))
if axis_ctx.handle.error != 0:
errors.append("axis failed with error 0x{:04X}".format(axis_ctx.handle.error))
elif len(errors) > 0:
errors.append("and by the way: axis reports no error even though there is one")
return errors
def dump_errors(axis_ctx: AxisTestContext, logger):
errors = get_errors(axis_ctx)
if len(errors):
logger.error("errors on " + axis_ctx.name)
for error in errors:
logger.error(error)
def clear_errors(axis_ctx: AxisTestContext):
axis_ctx.handle.error = 0
axis_ctx.handle.encoder.error = 0
axis_ctx.handle.motor.error = 0
axis_ctx.handle.sensorless_estimator.error = 0
def test_assert_no_error(axis_ctx: AxisTestContext):
errors = get_errors(axis_ctx)
if len(errors) > 0:
raise TestFailed("\n".join(errors))
def run(command_line, logger, timeout=None):
"""
Runs a shell command in the current directory
"""
logger.debug("invoke: " + command_line)
cmd = shlex.split(command_line)
result = subprocess.run(cmd, timeout=timeout,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT)
if result.returncode != 0:
logger.error(result.stdout.decode(sys.stdout.encoding))
raise TestFailed("command {} failed".format(command_line))
def request_state(axis_ctx: AxisTestContext, state, expect_success=True):
axis_ctx.handle.requested_state = state
time.sleep(0.001)
if expect_success:
test_assert_eq(axis_ctx.handle.current_state, state)
else:
test_assert_eq(axis_ctx.handle.current_state, AXIS_STATE_IDLE)
test_assert_eq(axis_ctx.handle.error, AXIS_ERROR_INVALID_STATE)
axis_ctx.handle.error = AXIS_ERROR_NONE # reset error
def set_limits(axis_ctx: AxisTestContext, logger, vel_limit=20000, current_limit=10):
"""
Sets the velocity and current limits for the axis, subject to the following constraints:
- the arguments given to this function are not exceeded
- max motor current is not exceeded
- max brake resistor power divided by two is not exceeded (here velocity takes precedence over current)
"""
max_rpm = vel_limit / axis_ctx.yaml['encoder-cpr'] * 60
max_emf_voltage = max_rpm / axis_ctx.yaml['motor-kv']
max_brake_power = axis_ctx.odrv_ctx.yaml['max-brake-power'] / 2 * 0.8 # 20% safety margin
max_motor_current = max_brake_power / max_emf_voltage
logger.debug("velocity limit = {} => V_emf = {:.3}V, I_lim = {:.3}A".format(vel_limit, max_emf_voltage, max_motor_current))
# Bound current limit based on the motor's current limit and the brake resistor current limit
current_limit = min(current_limit, axis_ctx.yaml['motor-max-current'], max_motor_current)
# TODO: set as an atomic operation
axis_ctx.handle.motor.config.current_lim = current_limit
axis_ctx.handle.controller.config.vel_limit = vel_limit
def get_max_rpm(axis_ctx: AxisTestContext):
# Calculate theoretical max velocity in rpm based on the nominal
# V_bus and motor KV rating.
# The KV-rating assumes square-waves on the motor phases (hexagonal space vector trajectory)
# whereas the ODrive modulates the space vector around a circular trajectory.
# See Fig 4.28 here: http://krex.k-state.edu/dspace/bitstream/handle/2097/1507/JamesMevey2009.pdf
effective_bus_voltage = axis_ctx.odrv_ctx.yaml['vbus-voltage']
effective_bus_voltage *= (2/math.sqrt(3)) / (4/math.pi) # roughtly 90%
# The ODrive only goes to 80% modulation depth in order to save some time for the ADC measurements.
# See FOC_current in motor.cpp.
effective_bus_voltage *= 0.8
# If we are using a higher bus voltage than rated: use rated voltage,
# since that is an effective speed rating of the motor
voltage_for_speed = min(effective_bus_voltage, axis_ctx.yaml['motor-max-voltage'])
base_speed_rpm = voltage_for_speed * axis_ctx.yaml['motor-kv']
#but don't go over encoder max rpm
rated_rpm = min(base_speed_rpm, axis_ctx.yaml['encoder-max-rpm'])
return rated_rpm
def get_sensorless_vel(axis_ctx: AxisTestContext, vel):
return vel * 2 * math.pi / axis_ctx.yaml['encoder-cpr'] * axis_ctx.yaml['motor-pole-pairs']
class ODriveTest(ABC):
"""
Tests inheriting from this class get full ownership of the ODrive
being tested. However no guarantees are made for the mechanical
state of the axes.
The test can demand exclusive run time which means that the host will
not run any other test at the same time. This can be used if the test
invokes a command that's so lame that it can't run twice concurrently.
"""
def __init__(self, exclusive=False):
self._exclusive = exclusive
def check_preconditions(self, odrv_ctx: ODriveTestContext, logger):
pass
@abc.abstractmethod
def run_test(self, odrv_ctx: ODriveTestContext, logger):
pass
class AxisTest(ABC):
"""
Tests inheriting from this class get ownership of one axis of
an ODrive. If the axis is mechanically coupled to another
axis, the other axis is guaranteed to be disabled (high impedance)
during this test.
"""
def check_preconditions(self, axis_ctx: AxisTestContext, logger):
test_assert_no_error(axis_ctx)
test_assert_eq(axis_ctx.handle.current_state, AXIS_STATE_IDLE)
if (abs(axis_ctx.handle.encoder.vel_estimate) > 100):
logger.warn("axis still in motion, delaying 2 sec...")
time.sleep(2)
test_assert_eq(axis_ctx.handle.encoder.vel_estimate, 0, range=500)
test_assert_eq(axis_ctx.odrv_ctx.handle.config.dc_bus_undervoltage_trip_level, axis_ctx.odrv_ctx.yaml['vbus-voltage'] * 0.85, accuracy=0.001)
test_assert_eq(axis_ctx.odrv_ctx.handle.config.dc_bus_overvoltage_trip_level, axis_ctx.odrv_ctx.yaml['vbus-voltage'] * 1.08, accuracy=0.001)
#test_assert_eq(axis_ctx.odrv_ctx.handle.config.dc_bus_undervoltage_trip_level, axis_ctx.odrv_ctx.yaml['vbus-voltage'] * 0.96, accuracy=0.001)
#test_assert_eq(axis_ctx.odrv_ctx.handle.config.dc_bus_overvoltage_trip_level, axis_ctx.odrv_ctx.yaml['vbus-voltage'] * 1.04, accuracy=0.001)
@abc.abstractmethod
def run_test(self, axis_ctx: AxisTestContext, logger):
pass
class DualAxisTest(ABC):
"""
Tests using this scope get ownership of two axes that are mechanically
coupled.
"""
def check_preconditions(self, axis0_ctx: AxisTestContext, axis1_ctx: AxisTestContext, logger):
test_assert_no_error(axis0_ctx)
test_assert_no_error(axis1_ctx)
test_assert_eq(axis0_ctx.handle.current_state, AXIS_STATE_IDLE)
test_assert_eq(axis1_ctx.handle.current_state, AXIS_STATE_IDLE)
if (abs(axis0_ctx.handle.encoder.vel_estimate) > 100) or (abs(axis1_ctx.handle.encoder.vel_estimate) > 100):
logger.warn("some axis still in motion, delaying 2 sec...")
time.sleep(2)
test_assert_eq(axis0_ctx.handle.encoder.vel_estimate, 0, range=500)
test_assert_eq(axis1_ctx.handle.encoder.vel_estimate, 0, range=500)
@abc.abstractmethod
def run_test(self, axis0_ctx: AxisTestContext, axis1_ctx: AxisTestContext, logger):
pass
class TestDiscoverAndGotoIdle(ODriveTest):
def run_test(self, odrv_ctx: ODriveTestContext, logger):
odrv_ctx.rediscover()
clear_errors(odrv_ctx.axes[0])
clear_errors(odrv_ctx.axes[1])
request_state(odrv_ctx.axes[0], AXIS_STATE_IDLE)
request_state(odrv_ctx.axes[1], AXIS_STATE_IDLE)
class TestFlashAndErase(ODriveTest):
def __init__(self):
ODriveTest.__init__(self, exclusive=True)
def run_test(self, odrv_ctx: ODriveTestContext, logger):
# Set board-version and compile
with open("tup.config", mode="w") as tup_config:
tup_config.write("CONFIG_STRICT=true\n")
tup_config.write("CONFIG_BOARD_VERSION={}\n".format(odrv_ctx.yaml['board-version']))
#exit(1)
run("make", logger, timeout=10)
run("make flash PROGRAMMER='" + odrv_ctx.yaml['programmer'] + "'", logger, timeout=20)
# FIXME: device does not reboot correctly after erasing config this way
#run("make erase_config PROGRAMMER='" + test_rig.programmer + "'", timeout=10)
logger.debug("waiting for ODrive...")
odrv_ctx.rediscover()
# ensure the correct odrive is returned
test_assert_eq(format(odrv_ctx.handle.serial_number, 'x').upper(), odrv_ctx.yaml['serial-number'])
# erase configuration and reboot
logger.debug("erasing old configuration...")
odrv_ctx.handle.erase_configuration()
#time.sleep(0.1)
try:
# FIXME: sometimes the device does not reappear after this ("no response - probably incompatible")
# this is a firmware issue since it persists when unplugging/replugging
# but goes away when power cycling the device
odrv_ctx.handle.reboot()
except fibre.ChannelBrokenException:
pass # this is expected
time.sleep(0.5)
class TestSetup(ODriveTest):
"""
Preconditions: ODrive is unconfigured and just rebooted
"""
def run_test(self, odrv_ctx: ODriveTestContext, logger):
odrv_ctx.rediscover()
# initial protocol tests and setup
logger.debug("setting up ODrive...")
odrv_ctx.handle.config.enable_uart = True
test_assert_eq(odrv_ctx.handle.config.enable_uart, True)
odrv_ctx.handle.config.enable_uart = False
test_assert_eq(odrv_ctx.handle.config.enable_uart, False)
odrv_ctx.handle.config.brake_resistance = 1.0
test_assert_eq(odrv_ctx.handle.config.brake_resistance, 1.0)
odrv_ctx.handle.config.brake_resistance = odrv_ctx.yaml['brake-resistance']
test_assert_eq(odrv_ctx.handle.config.brake_resistance, odrv_ctx.yaml['brake-resistance'], accuracy=0.01)
odrv_ctx.handle.config.dc_bus_undervoltage_trip_level = odrv_ctx.yaml['vbus-voltage'] * 0.85
odrv_ctx.handle.config.dc_bus_overvoltage_trip_level = odrv_ctx.yaml['vbus-voltage'] * 1.08
test_assert_eq(odrv_ctx.handle.config.dc_bus_undervoltage_trip_level, odrv_ctx.yaml['vbus-voltage'] * 0.85, accuracy=0.001)
test_assert_eq(odrv_ctx.handle.config.dc_bus_overvoltage_trip_level, odrv_ctx.yaml['vbus-voltage'] * 1.08, accuracy=0.001)
# firmware has 1500ms startup delay
time.sleep(2)
logger.debug("ensure we're in idle state")
test_assert_eq(odrv_ctx.handle.axis0.current_state, AXIS_STATE_IDLE)
test_assert_eq(odrv_ctx.handle.axis1.current_state, AXIS_STATE_IDLE)
class TestMotorCalibration(AxisTest):
"""
Tests motor calibration.
The calibration results are compared against well known test rig values.
Preconditions: The motor must be uncalibrated.
Postconditions: The motor will be calibrated after this test.
"""
def check_preconditions(self, axis_ctx: AxisTestContext, logger):
super(TestMotorCalibration, self).check_preconditions(axis_ctx, logger)
test_assert_eq(axis_ctx.handle.motor.is_calibrated, False)
def run_test(self, axis_ctx: AxisTestContext, logger):
logger.debug("try to enter closed loop control (should be rejected)")
request_state(axis_ctx, AXIS_STATE_CLOSED_LOOP_CONTROL, expect_success=False)
logger.debug("try to start encoder index search (should be rejected)")
request_state(axis_ctx, AXIS_STATE_ENCODER_INDEX_SEARCH, expect_success=False)
logger.debug("try to start encoder offset calibration (should be rejected)")
request_state(axis_ctx, AXIS_STATE_ENCODER_OFFSET_CALIBRATION, expect_success=False)
logger.debug("motor calibration (takes about 4.5 seconds)")
axis_ctx.handle.motor.config.pole_pairs = axis_ctx.yaml['motor-pole-pairs']
request_state(axis_ctx, AXIS_STATE_MOTOR_CALIBRATION)
time.sleep(6)
test_assert_eq(axis_ctx.handle.current_state, AXIS_STATE_IDLE)
test_assert_no_error(axis_ctx)
test_assert_eq(axis_ctx.handle.motor.config.phase_resistance, axis_ctx.yaml['motor-phase-resistance'], accuracy=0.2)
test_assert_eq(axis_ctx.handle.motor.config.phase_inductance, axis_ctx.yaml['motor-phase-inductance'], accuracy=0.5)
axis_ctx.handle.motor.config.pre_calibrated = True
class TestEncoderOffsetCalibration(AxisTest):
"""
Tests encoder offset calibration.
Preconditions: The encoder must be non-ready.
Postconditions: The encoder will be ready after this test.
"""
def __init__(self, pass_if_ready=False):
AxisTest.__init__(self)
self._pass_if_ready = pass_if_ready
def check_preconditions(self, axis_ctx: AxisTestContext, logger):
super(TestEncoderOffsetCalibration, self).check_preconditions(axis_ctx, logger)
if not self._pass_if_ready:
test_assert_eq(axis_ctx.handle.encoder.is_ready, False)
def run_test(self, axis_ctx: AxisTestContext, logger):
if (self._pass_if_ready and axis_ctx.handle.encoder.is_ready):
logger.debug("encoder already ready, skipping this test")
return
logger.debug("try to enter closed loop control (should be rejected)")
request_state(axis_ctx, AXIS_STATE_CLOSED_LOOP_CONTROL, expect_success=False)
logger.debug("encoder offset calibration (takes about 9.5 seconds)")
axis_ctx.handle.encoder.config.cpr = axis_ctx.yaml['encoder-cpr'] # TODO: test setting a wrong CPR
request_state(axis_ctx, AXIS_STATE_ENCODER_OFFSET_CALIBRATION)
# TODO: ensure the encoder calibration doesn't do crap
time.sleep(11)
test_assert_eq(axis_ctx.handle.current_state, AXIS_STATE_IDLE)
test_assert_no_error(axis_ctx)
test_assert_eq(axis_ctx.handle.motor.config.direction, axis_ctx.yaml['motor-direction'])
axis_ctx.handle.encoder.config.pre_calibrated = True
class TestClosedLoopControl(AxisTest):
"""
Tests closed loop position control and velocity control
and verifies that the sensorless estimator works
Precondition: The axis is calibrated and ready for closed loop control
"""
def check_preconditions(self, axis_ctx: AxisTestContext, logger):
super(TestClosedLoopControl, self).check_preconditions(axis_ctx, logger)
test_assert_eq(axis_ctx.handle.motor.is_calibrated, True)
test_assert_eq(axis_ctx.handle.encoder.is_ready, True)
def run_test(self, axis_ctx: AxisTestContext, logger):
logger.debug("closed loop control: test tiny position changes")
axis_ctx.handle.requested_state = AXIS_STATE_CLOSED_LOOP_CONTROL
time.sleep(0.001)
test_assert_eq(axis_ctx.handle.current_state, AXIS_STATE_CLOSED_LOOP_CONTROL)
time.sleep(0.1) # give the PLL some time to settle
init_pos = axis_ctx.handle.encoder.pos_estimate
axis_ctx.handle.controller.set_pos_setpoint(init_pos+1000, 0, 0)
time.sleep(0.5)
test_assert_eq(axis_ctx.handle.encoder.pos_estimate, init_pos+1000, range=200)
axis_ctx.handle.controller.set_pos_setpoint(init_pos-1000, 0, 0)
time.sleep(0.5)
test_assert_eq(axis_ctx.handle.encoder.pos_estimate, init_pos-1000, range=400)
logger.debug("closed loop control: test vel_limit")
axis_ctx.handle.controller.set_pos_setpoint(50000, 0, 0)
axis_ctx.handle.controller.config.vel_limit = 40000
time.sleep(0.3)
test_assert_eq(axis_ctx.handle.encoder.vel_estimate, 40000, range=4000)
expected_sensorless_estimation = 40000 * 2 * math.pi / axis_ctx.yaml['encoder-cpr'] * axis_ctx.yaml['motor-pole-pairs']
test_assert_eq(axis_ctx.handle.sensorless_estimator.vel_estimate, expected_sensorless_estimation, range=50)
time.sleep(3)
test_assert_eq(axis_ctx.handle.encoder.vel_estimate, 0, range=1000)
time.sleep(0.5)
request_state(axis_ctx, AXIS_STATE_IDLE)
class TestHighVelocity(AxisTest):
"""
Spins the motor up to it's max speed during a period of 10s.
The commanded max speed is based on the motor's KV rating and nominal V_bus,
however due to several factors the theoretical limit is about 72% of that.
The test passes if the motor follows the commanded ramp closely up to 90% of
the theoretical limit (and if no errors occur along the way).
"""
def __init__(self, override_current_limit=None, load_current=0, brake=True):
"""
param override_current_limit: If None, the test selects a current limit that is guaranteed
not to fry the brake resistor. If you override the limit, you're
on your own.
"""
self._override_current_limit = override_current_limit
self._load_current = load_current
self._brake = brake
def check_preconditions(self, axis_ctx: AxisTestContext, logger):
# time.sleep(2.5) #delay in case load needs time to stop moving
super(TestHighVelocity, self).check_preconditions(axis_ctx, logger)
test_assert_eq(axis_ctx.handle.motor.is_calibrated, True)
test_assert_eq(axis_ctx.handle.encoder.is_ready, True)
def run_test(self, axis_ctx: AxisTestContext, logger):
rated_limit = get_max_rpm(axis_ctx) / 60 * axis_ctx.yaml['encoder-cpr']
expected_limit = rated_limit
# TODO: remove the following two lines, but for now we want to stay away from the modulation depth limit
expected_limit *= 0.6
rated_limit = expected_limit
# Add a 10% margin to account for
expected_limit *= 0.9
logger.debug("rated max speed: {}, expected max speed: >= {}".format(rated_limit, expected_limit))
#theoretical_limit = 100000
# Set the current limit accordingly so we don't burn the brake resistor while slowing down
if self._override_current_limit is None:
set_limits(axis_ctx, logger, vel_limit=rated_limit, current_limit=50)
else:
axis_ctx.handle.motor.config.current_lim = self._override_current_limit
axis_ctx.handle.controller.config.vel_limit = rated_limit
axis_ctx.handle.controller.set_vel_setpoint(0, 0)
request_state(axis_ctx, AXIS_STATE_CLOSED_LOOP_CONTROL)
logger.debug("Drive current {}A, Load current {}A".format(axis_ctx.handle.motor.config.current_lim, self._load_current))
ramp_up_time = 15.0
max_measured_vel = 0.0
logger.debug("ramping to {} over {} s".format(rated_limit, ramp_up_time))
t_0 = time.monotonic()
last_print = t_0
while True:
ratio = (time.monotonic() - t_0) / ramp_up_time
if ratio >= 1:
break
#TODO based on integrator gain and torque ramp rate
expected_ramp_lag = 1.0 * (rated_limit / ramp_up_time)
expected_lag = 0
# While ramping up we want to remain within +-5% of the setpoint.
# However we accept if we can only approach 80% of the theoretical limit.
vel_setpoint = ratio * rated_limit
expected_velocity = max(vel_setpoint - expected_lag, 0)
vel_range = max(0.05*expected_velocity, max(expected_lag+expected_ramp_lag, 2000))
if expected_velocity - vel_range > expected_limit:
vel_range = expected_velocity - expected_limit
# set and measure velocity
axis_ctx.handle.controller.set_vel_setpoint(vel_setpoint, 0)
measured_vel = axis_ctx.handle.encoder.vel_estimate
max_measured_vel = max(measured_vel, max_measured_vel)
test_assert_eq(measured_vel, expected_velocity, range=vel_range)
test_assert_no_error(axis_ctx)
# log progress
if time.monotonic() - last_print > 1:
last_print = time.monotonic()
logger.debug("ramping up: commanded {}, expected {}, measured {} ".format(vel_setpoint, expected_velocity, measured_vel))
time.sleep(0.001)
logger.debug("reached top speed of {} counts/sec".format(max_measured_vel))
if self._brake:
axis_ctx.handle.controller.set_vel_setpoint(0, 0)
time.sleep(0.5)
# If the velocity integrator at work, it may now work against slowing down.
test_assert_eq(axis_ctx.handle.encoder.vel_estimate, 0, range=rated_limit*0.3)
# TODO: this is not a good bound, but the encoder float resolution results in a bad velocity estimate after this many turns
time.sleep(0.5)
test_assert_eq(axis_ctx.handle.encoder.vel_estimate, 0, range=2000)
request_state(axis_ctx, AXIS_STATE_IDLE)
test_assert_no_error(axis_ctx)
class TestHighVelocityInViscousFluid(DualAxisTest):
"""
Runs TestHighVelocity on one motor while using the other motor as a load.
The load is created by running velocity control with setpoint 0.
"""
def __init__(self, load_current=10, driver_current=20):
self._load_current = load_current
self._driver_current = driver_current
def run_test(self, axis0_ctx: AxisTestContext, axis1_ctx: AxisTestContext, logger):
load_ctx = axis0_ctx
driver_ctx = axis1_ctx
if driver_ctx.name == 'top-odrive.black':
# odrive.utils.start_liveplotter(lambda: [driver_ctx.odrv_ctx.handle.vbus_voltage])
odrive.utils.start_liveplotter(lambda: [driver_ctx.handle.motor.current_control.Iq_measured,
driver_ctx.handle.motor.current_control.Iq_setpoint])
# Set up viscous fluid load
logger.debug("activating load on {}...".format(load_ctx.name))
load_ctx.handle.controller.config.vel_integrator_gain = 0
load_ctx.handle.motor.config.current_lim = self._load_current
load_ctx.odrv_ctx.handle.config.brake_resistance = 0 # disable brake resistance, the power will go into the bus
load_ctx.handle.controller.set_vel_setpoint(0, 0)
request_state(load_ctx, AXIS_STATE_CLOSED_LOOP_CONTROL)
driver_test = TestHighVelocity(
override_current_limit=self._driver_current,
load_current=self._load_current, brake=False)
driver_test.check_preconditions(driver_ctx, logger)
driver_test.run_test(driver_ctx, logger)
# put load to idle as quickly as possible, otherwise, because the brake resistor is disabled,
# it will try to put the braking power into the power rail where it has nowhere to go.
request_state(load_ctx, AXIS_STATE_IDLE)
request_state(driver_ctx, AXIS_STATE_IDLE)
class TestSelfLoadedPosVelDistribution(DualAxisTest):
"""
Uses an ODrive mechanically connected to itself to test a distribution of
speeds and currents. Since it's connected to itself, we can be a lot less
strict about the brake resistor power use.
"""
def __init__(self, rpm_range=1000, load_current_range=10, driver_current_lim=20):
self._rpm_range = rpm_range
self._load_current_range = load_current_range
self._driver_current_lim = driver_current_lim
def run_test(self, axis0_ctx: AxisTestContext, axis1_ctx: AxisTestContext, logger):
load_ctx = axis0_ctx
driver_ctx = axis1_ctx
logger.debug("Iload range: {} A, Idriver: {} A".format(self._load_current_range, self._driver_current_lim))
# max speed for rig in counts/s for each encoder (may be different CPR)
max_rpm = min(self._rpm_range, get_max_rpm(driver_ctx), get_max_rpm(load_ctx))
driver_max_speed = max_rpm / 60 * driver_ctx.yaml['encoder-cpr']
load_max_speed = max_rpm / 60 * load_ctx.yaml['encoder-cpr']
logger.debug("RPM range: {} = driver {} = load {}".format(max_rpm, driver_max_speed, load_max_speed))
# Set up velocity controlled load
logger.debug("activating load on {}".format(load_ctx.name))
load_ctx.handle.controller.config.vel_integrator_gain = 0
load_ctx.handle.controller.config.vel_limit = load_max_speed
load_ctx.handle.motor.config.current_lim = 0 #load current to be set during runtime
load_ctx.handle.controller.set_vel_setpoint(0, 0) # vel sign also set during runtime
request_state(load_ctx, AXIS_STATE_CLOSED_LOOP_CONTROL)
# Set up velocity controlled driver
logger.debug("activating driver on {}".format(driver_ctx.name))
driver_ctx.handle.motor.config.current_lim = self._driver_current_lim
driver_ctx.handle.controller.config.vel_limit = driver_max_speed
driver_ctx.handle.controller.set_vel_setpoint(0, 0)
request_state(driver_ctx, AXIS_STATE_CLOSED_LOOP_CONTROL)
# Spiral parameters
command_rate = 500.0 #Hz (nominal, achived rate is less due to time.sleep approx)
test_duration = 20.0 #s
num_cycles = 3.0 # number of spiral "rotations"
t_0 = time.monotonic()
t_ratio = 0
last_print = t_0
while t_ratio < 1:
t_ratio = (time.monotonic() - t_0) / test_duration
phase = 2 * math.pi * num_cycles * t_ratio
driver_speed = t_ratio * driver_max_speed * math.sin(phase)
# print(driver_speed)
driver_ctx.handle.controller.set_vel_setpoint(driver_speed, 0)
load_current = t_ratio * self._load_current_range * math.cos(phase)
Iload_mag = abs(load_current)
Iload_sign = np.sign(load_current)
# print("I: {}, vel {}".format(Iload_mag, Iload_sign * load_max_speed))
load_ctx.handle.motor.config.current_lim = Iload_mag
load_ctx.handle.controller.set_vel_setpoint(Iload_sign * load_max_speed, 0)
test_assert_no_error(driver_ctx)
test_assert_no_error(load_ctx)
# log progress
if time.monotonic() - last_print > 1:
last_print = time.monotonic()
logger.debug("Envelope -- vel: {:.2f}, I: {:.2f}".format(t_ratio * driver_max_speed, t_ratio * self._load_current_range))
time.sleep(1/command_rate)
request_state(load_ctx, AXIS_STATE_IDLE)
request_state(driver_ctx, AXIS_STATE_IDLE)
test_assert_no_error(driver_ctx)
test_assert_no_error(load_ctx)
class TestVelCtrlVsPosCtrl(DualAxisTest):
"""
Uses one ODrive as a load operating in velocity control mode.
The other ODrive tries to "fight" against the load in position mode.
"""
def run_test(self, axis0_ctx: AxisTestContext, axis1_ctx: AxisTestContext, logger):
load_ctx = axis0_ctx
driver_ctx = axis1_ctx
# Set up viscous fluid load
logger.debug("activating load on {}...".format(load_ctx.name))
load_ctx.handle.controller.config.vel_integrator_gain = 0
load_ctx.handle.controller.vel_integrator_torque = 0
set_limits(load_ctx, logger, vel_limit=100000, current_limit=50)
load_ctx.handle.controller.set_vel_setpoint(0, 0)
request_state(load_ctx, AXIS_STATE_CLOSED_LOOP_CONTROL)
# Turn to some position
logger.debug("using {} as driver against load, vel=100000...".format(driver_ctx.name))
set_limits(driver_ctx, logger, vel_limit=100000, current_limit=50)
init_pos = driver_ctx.handle.encoder.pos_estimate
driver_ctx.handle.controller.set_pos_setpoint(init_pos + 100000, 0, 0)
request_state(driver_ctx, AXIS_STATE_CLOSED_LOOP_CONTROL)
for _ in range(int(4000/5)):
logger.debug(str(driver_ctx.handle.motor.current_control.Iq_setpoint))
time.sleep(0.005)
test_assert_no_error(load_ctx)
test_assert_no_error(driver_ctx)
logger.debug("using {} as driver against load, vel=20000...".format(driver_ctx.name))
set_limits(driver_ctx, logger, vel_limit=20000, current_limit=50)
init_pos = driver_ctx.handle.encoder.pos_estimate
driver_ctx.handle.controller.set_pos_setpoint(init_pos + 100000, 0, 0)
request_state(driver_ctx, AXIS_STATE_CLOSED_LOOP_CONTROL)
#for _ in range(int(5*4000/5)):
# logger.debug(str(driver_ctx.handle.motor.current_control.Iq_setpoint))
# time.sleep(0.005)
time.sleep(7)
odrive.utils.print_drv_regs("load motor ({})".format(load_ctx.name), load_ctx.handle.motor)
odrive.utils.print_drv_regs("driver motor ({})".format(driver_ctx.name), driver_ctx.handle.motor)
test_assert_no_error(load_ctx)
test_assert_no_error(driver_ctx)
## Turn to another position
#logger.debug("controlling against load, vel=40000...")
#set_limits(axis1_ctx, logger, vel_limit=40000, current_limit=20)
#init_pos = axis1_ctx.handle.encoder.pos_estimate
#axis1_ctx.handle.controller.set_pos_setpoint(init_pos + 100000, 0, 0)
#request_state(axis1_ctx, AXIS_STATE_CLOSED_LOOP_CONTROL)
# ASCII protocol helper functions
def gcode_calc_checksum(data):
from functools import reduce
return reduce(lambda a, b: a ^ b, data)
def gcode_append_checksum(data):
return data + b'*' + str(gcode_calc_checksum(data)).encode('ascii')
def get_lines(port):
buf = port.get_bytes(512, time.monotonic() + 0.2)
return [line.rstrip(b'\r') for line in buf.split(b'\n') if line.rstrip(b'\r')]
class TestAsciiProtocol(ODriveTest):
def run_test(self, odrv_ctx: ODriveTestContext, logger):
import odrive.serial_transport
port = odrive.serial_transport.SerialStreamTransport(odrv_ctx.yaml['uart'], 115200)
# send garbage to throw the device off track
port.process_bytes(b"garbage\r\n\r\0trash\n")
port.process_bytes(b"\n") # start a new clean line
get_lines(port) # flush RX buffer
# info command without checksum
port.process_bytes(b"i\n")
# check if it reports the serial number (among other things)
lines = get_lines(port)
expected_line = ('Serial number: ' + odrv_ctx.yaml['serial-number']).encode('ascii')
if not expected_line in lines:
raise Exception("expected {} in ASCII protocol response but got {}".format(expected_line, str(lines)))
# info command with checksum
port.process_bytes(gcode_append_checksum(b"i") + b" ; a useless comment\n")
# check if it reports the serial number with checksum (among other things)
lines = get_lines(port)
expected_line = gcode_append_checksum(('Serial number: ' + odrv_ctx.yaml['serial-number']).encode('ascii'))
if not expected_line in lines:
raise Exception("expected {} in ASCII protocol response but got {}".format(expected_line, str(lines)))
port.process_bytes(b"p 0 2000 -10 0.002\n")
time.sleep(0.01) # 1ms is too short, 2ms usually works, 10ms for good measure
test_assert_eq(odrv_ctx.handle.axis0.controller.pos_setpoint, 2000, accuracy=0.001)
test_assert_eq(odrv_ctx.handle.axis0.controller.vel_setpoint, -10, accuracy=0.001)
test_assert_eq(odrv_ctx.handle.axis0.controller.current_setpoint, 0.002, accuracy=0.001)
port.process_bytes(b"v 1 -21.1 0.32\n")
time.sleep(0.01)
test_assert_eq(odrv_ctx.handle.axis1.controller.vel_setpoint, -21.1, accuracy=0.001)
test_assert_eq(odrv_ctx.handle.axis1.controller.current_setpoint, 0.32, accuracy=0.001)
port.process_bytes(b"c 0 0.1\n")
time.sleep(0.01)
test_assert_eq(odrv_ctx.handle.axis0.controller.current_setpoint, 0.1, accuracy=0.001)
# write arbitrary parameter
port.process_bytes(b"w axis0.controller.pos_setpoint -123.456 ; comment\n")
time.sleep(0.01)
test_assert_eq(odrv_ctx.handle.axis0.controller.pos_setpoint, -123.456, accuracy=0.001)
port.process_bytes(b"r axis0.controller.pos_setpoint\n")
lines = get_lines(port)
expected_line = b'-123.4560'
if lines != [expected_line]:
raise Exception("expected {} in ASCII protocol response but got {}".format(expected_line, str(lines)))
# read/write enums
port.process_bytes(b"r axis0.error\n")
lines = get_lines(port)
expected_line = b'0'
if lines != [expected_line]:
raise Exception("expected {} in ASCII protocol response but got {}".format(expected_line, str(lines)))
test_assert_eq(odrv_ctx.axes[0].handle.current_state, AXIS_STATE_CLOSED_LOOP_CONTROL)
port.process_bytes(b"w axis0.requested_state {}\n".format(AXIS_STATE_IDLE))
time.sleep(0.01)
test_assert_eq(odrv_ctx.axes[0].handle.current_state, AXIS_STATE_IDLE)
# disable axes
odrv_ctx.handle.axis0.controller.set_pos_setpoint(0, 0, 0)
odrv_ctx.handle.axis1.controller.set_pos_setpoint(0, 0, 0)
request_state(odrv_ctx.axes[0], AXIS_STATE_IDLE)
request_state(odrv_ctx.axes[1], AXIS_STATE_IDLE)
class TestSensorlessControl(AxisTest):
def run_test(self, axis_ctx: AxisTestContext, logger):
odrv0.axis0.controller.config.vel_gain = 5 / get_sensorless_vel(axis_ctx, 10000)
odrv0.axis0.controller.config.vel_integrator_gain = 10 / get_sensorless_vel(axis_ctx, 10000)
target_vel = get_sensorless_vel(axis_ctx, 20000)
axis_ctx.handle.controller.set_vel_setpoint(target_vel, 0)
request_state(axis_ctx, AXIS_STATE_SENSORLESS_CONTROL)
# wait for spinup
time.sleep(2)
test_assert_eq(odrv0.axis0.encoder.vel_estimate, target_vel, range=2000)
request_state(axis_ctx, AXIS_STATE_IDLE)
@@ -0,0 +1,89 @@
import test_runner
import time
import math
import os
from odrive.enums import *
from test_runner import *
teensy_code_template = """
float position = 0; // between 0 and 1
float velocity = 1; // [position per second]
void setup() {
pinMode({pwm_gpio}, OUTPUT);
}
// the loop routine runs over and over again forever:
void loop() {
int high_microseconds = 1000 + (int)(position * 1000.0f);
digitalWrite({pwm_gpio}, HIGH);
delayMicroseconds(high_microseconds);
digitalWrite({pwm_gpio}, LOW);
// Wait for a total of 20ms.
// delayMicroseconds() only works well for values <= 16383
delayMicroseconds(10000 - high_microseconds);
delayMicroseconds(10000);
position += velocity * 0.02;
while (position > 1.0)
position -= 1.0;
}
"""
class TestPwmInput():
"""
Verifies the PWM input.
The Teensy generates a PWM signal that goes from 0% (1ms high) to 100% (2ms high)
in 1 second and then resumes at 0%.
Note: this test is currently only written for ODrive 3.6 (or similar GPIO layout).
"""
def get_test_cases(self, testrig: TestRig):
for odrive in testrig.get_components(ODriveComponent):
# Run a separate test for each PWM-capable GPIO. Use different min/max settings for each test.
yield (odrive, 1, -50, 200, list(testrig.get_connected_components(odrive.gpio1, TeensyGpio)))
yield (odrive, 2, 20, 400, list(testrig.get_connected_components(odrive.gpio2, TeensyGpio)))
yield (odrive, 3, -1000, 0, list(testrig.get_connected_components(odrive.gpio3, TeensyGpio)))
yield (odrive, 4, -20000, 20000, list(testrig.get_connected_components(odrive.gpio4, TeensyGpio)))
def run_test(self, odrive: ODriveComponent, odrive_gpio_num: int, min_val: float, max_val: float, teensy_gpio: Component, logger: Logger):
teensy = teensy_gpio.parent
code = teensy_code_template.replace("{pwm_gpio}", str(teensy_gpio.num))
teensy.compile_and_program(code)
logger.debug("Set up PWM input...")
odrive.unuse_gpios()
pwm_mapping = [
odrive.handle.config.gpio1_pwm_mapping,
odrive.handle.config.gpio2_pwm_mapping,
odrive.handle.config.gpio3_pwm_mapping,
odrive.handle.config.gpio4_pwm_mapping
][odrive_gpio_num - 1]
pwm_mapping.endpoint = odrive.handle.axis0.controller._remote_attributes['input_pos']
pwm_mapping.min = min_val
pwm_mapping.max = max_val
odrive.save_config_and_reboot()
data = record_log(lambda: [odrive.handle.axis0.controller.input_pos], duration=5.0)
full_scale = max_val - min_val
slope, offset, fitted_curve = fit_sawtooth(data, min_val, max_val)
test_assert_eq(slope, full_scale / 1.0, accuracy=0.001)
test_curve_fit(data, fitted_curve, max_mean_err = full_scale * 0.05, inlier_range = full_scale * 0.05, max_outliers = len(data[:,0]) * 0.01)
if __name__ == '__main__':
test_runner.run(TestPwmInput())
@@ -0,0 +1,92 @@
import test_runner
import struct
import asyncio
import time
from fibre.utils import Logger
from odrive.enums import *
from test_runner import *
class TestStepDir():
"""
Tests Step/Dir input.
Not all possible combinations are tested, but each axis and each GPIO
participates in at least one test case.
The tests are conducted while the axis is in idle.
"""
def get_test_cases(self, testrig: TestRig):
for odrive in testrig.get_components(ODriveComponent):
gpio_conns = [
list(testrig.get_connected_components((odrive.gpio1, False), LinuxGpioComponent)),
list(testrig.get_connected_components((odrive.gpio2, False), LinuxGpioComponent)),
#list(testrig.get_connected_components((odrive.gpio3, False), LinuxGpioComponent)), # connected to LPF on test rig
#list(testrig.get_connected_components((odrive.gpio4, False), LinuxGpioComponent)), # connected to LPF on test rig
list(testrig.get_connected_components((odrive.gpio5, False), LinuxGpioComponent)),
list(testrig.get_connected_components((odrive.gpio6, False), LinuxGpioComponent)),
list(testrig.get_connected_components((odrive.gpio7, False), LinuxGpioComponent)),
list(testrig.get_connected_components((odrive.gpio8, False), LinuxGpioComponent)),
]
yield (odrive.axes[0], 1, gpio_conns[0], 2, gpio_conns[1])
yield (odrive.axes[0], 5, gpio_conns[2], 6, gpio_conns[3])
yield (odrive.axes[0], 7, gpio_conns[4], 8, gpio_conns[5]) # broken
# yield (odrive.axes[0], 7, gpio_conns[6], 8, gpio_conns[7]) # broken
yield (odrive.axes[1], 7, gpio_conns[4], 8, gpio_conns[5])
def run_test(self, axis: ODriveAxisComponent, step_gpio_num: int, step_gpio: LinuxGpioComponent, dir_gpio_num: int, dir_gpio: LinuxGpioComponent, logger: Logger):
step_gpio.config(output=True)
step_gpio.write(False)
dir_gpio.config(output=True)
dir_gpio.write(True)
if axis.num == 0:
axis.parent.handle.config.enable_uart = False
axis.handle.config.enable_step_dir = True
axis.handle.config.step_dir_always_on = True # needed for testing
axis.handle.config.step_gpio_pin = step_gpio_num
axis.handle.config.dir_gpio_pin = dir_gpio_num
request_state(axis, AXIS_STATE_IDLE) # apply step_dir_always_on config
ref = axis.handle.controller.input_pos
axis.handle.config.turns_per_step = turns_per_step = 10
# On the RPi 4 a ~5kHz GPIO signal can be generated from Python
for i in range(100):
step_gpio.write(True)
step_gpio.write(False)
test_assert_eq(axis.handle.controller.input_pos, ref + (i + 1) * turns_per_step, range = 0.4 * turns_per_step)
ref = axis.handle.controller.input_pos
dir_gpio.write(False)
for i in range(100):
step_gpio.write(True)
step_gpio.write(False)
test_assert_eq(axis.handle.controller.input_pos, ref - (i + 1) * turns_per_step, range = 0.4 * turns_per_step)
ref = axis.handle.controller.input_pos
dir_gpio.write(True)
axis.handle.config.turns_per_step = turns_per_step = 1
for i in range(100):
step_gpio.write(True)
step_gpio.write(False)
test_assert_eq(axis.handle.controller.input_pos, ref + (i + 1) * turns_per_step, range = 0.4 * turns_per_step)
ref = axis.handle.controller.input_pos
axis.handle.config.turns_per_step = turns_per_step = -1
for i in range(100):
step_gpio.write(True)
step_gpio.write(False)
test_assert_eq(axis.handle.controller.input_pos, ref + (i + 1) * turns_per_step, range = 0.4 * abs(turns_per_step))
if __name__ == '__main__':
test_runner.run(TestStepDir())
@@ -0,0 +1,811 @@
# Provides utilities for standalone test scripts.
# This script is not intended to be run directly.
import sys, os
sys.path.append(os.path.join(os.path.dirname(__file__), '..', '..'))
import stat
import odrive
from odrive.enums import *
import fibre
from fibre import Logger, Event
import argparse
import yaml
from inspect import signature
import itertools
import time
import tempfile
import io
from typing import Union, Tuple
# needed for curve fitting
import numpy as np
import scipy.optimize
import scipy.ndimage.filters
# Assert utils ----------------------------------------------------------------#
class TestFailed(Exception):
def __init__(self, message):
Exception.__init__(self, message)
def test_assert_eq(observed, expected, range=None, accuracy=None):
sign = lambda x: 1 if x >= 0 else -1
# Comparision with absolute range
if not range is None:
if (observed < expected - range) or (observed > expected + range):
raise TestFailed("value out of range: expected {}+-{} but observed {}".format(expected, range, observed))
# Comparision with relative range
elif not accuracy is None:
if sign(observed) != sign(expected) or (abs(observed) < abs(expected) * (1 - accuracy)) or (abs(observed) > abs(expected) * (1 + accuracy)):
raise TestFailed("value out of range: expected {}+-{}% but observed {}".format(expected, accuracy*100.0, observed))
# Exact comparision
else:
if observed != expected:
raise TestFailed("value mismatch: expected {} but observed {}".format(expected, observed))
def test_assert_within(observed, lower_bound, upper_bound, accuracy=0.0):
"""
Checks if the value is within the closed interval [lower_bound, upper_bound]
The permissible range can be expanded in both direction by the coefficiont "accuracy".
I.e. accuracy of 1.0 would expand the range by a total factor of 3.0
"""
lower_bound, upper_bound = (
(lower_bound - (upper_bound - lower_bound) * accuracy),
(upper_bound + (upper_bound - lower_bound) * accuracy)
)
if (observed < lower_bound) or (observed > upper_bound):
raise TestFailed(f"the oberved value {observed} is outside the interval [{lower_bound}, {upper_bound}]")
# Other utils -----------------------------------------------------------------#
def disjoint_sets(list_of_sets: list):
while len(list_of_sets):
current_set, list_of_sets = list_of_sets[0], list_of_sets[1:]
did_update = True
while did_update:
did_update = False
for i, s in enumerate(list_of_sets):
if len(current_set.intersection(s)):
current_set = current_set.union(s)
list_of_sets = list_of_sets[:i] + list_of_sets[(i+1):]
did_update = True
yield current_set
def is_list_like(arg):
return hasattr(arg, '__iter__') and not isinstance(arg, str)
def all_unique(lst):
seen = list()
return not any(i in seen or seen.append(i) for i in lst)
def modpm(val, range):
return ((val + (range / 2)) % range) - (range / 2)
def clamp(val, lower_bound, upper_bound):
return min(max(val, lower_bound), upper_bound)
def record_log(data_getter, duration=5.0):
logger.debug(f"Recording log for {duration}s...")
data = []
start = time.monotonic()
while time.monotonic() - start < duration:
data.append((time.monotonic() - start,) + tuple(data_getter()))
return np.array(data)
def save_log(data, id=None):
import json
filename = '/tmp/log{}.json'.format('' if id is None else str(id))
with open(filename, 'w+') as fp:
json.dump(data.tolist(), fp, indent=2)
print(f'data saved to {filename}')
def fit_line(data):
func = lambda x, a, b: x*a + b
slope, offset = scipy.optimize.curve_fit(func, data[:,0], data[:,1], [1.0, 0])[0]
return slope, offset, func(data[:,0], slope, offset)
def fit_sawtooth(data, min_val, max_val, sigma=10):
"""
Fits the data to a sawtooth function.
Returns the average absolute error and the number of outliers.
The sample data must span at least one full period.
data is expected to contain one row (t, y) for each sample.
"""
# Sawtooth function with free parameters for period and x-shift
func = lambda x, a, b: np.mod(a * x + b, max_val - min_val) + min_val
# Fit period and x-shift
mid_point = (min_val + max_val) / 2
filtered_data = scipy.ndimage.filters.gaussian_filter(data[:,1], sigma=sigma)
if max_val > min_val:
zero_crossings = data[np.where((filtered_data[:-1] > mid_point) & (filtered_data[1:] < mid_point))[0], 0]
else:
zero_crossings = data[np.where((filtered_data[:-1] < mid_point) & (filtered_data[1:] > mid_point))[0], 0]
if len(zero_crossings) == 0:
# No zero-crossing - fit simple line
slope, offset, _ = fit_line(data)
elif len(zero_crossings) == 1:
# One zero-crossing - fit line based on the longer half
z_index = np.where(data[:,0] > zero_crossings[0])[0][0]
if z_index > len(data[:,0]):
slope, offset, _ = fit_line(data[:z_index])
else:
slope, offset, _ = fit_line(data[z_index:])
else:
# Two or more zero-crossings - determine period based on average distance between zero-crossings
period = (zero_crossings[1:] - zero_crossings[:-1]).mean()
slope = (max_val - min_val) / period
#shift = scipy.optimize.curve_fit(lambda x, b: func(x, period, b), data[:,0], data[:,1], [0.0])[0][0]
if np.std(np.mod(zero_crossings, period)) < np.std(np.mod(zero_crossings + period/2, period)):
shift = np.mean(np.mod(zero_crossings, period))
else:
shift = np.mean(np.mod(zero_crossings + period/2, period)) - period/2
offset = -slope * shift
return slope, offset, func(data[:,0], slope, offset)
def test_curve_fit(data, fitted_curve, max_mean_err, inlier_range, max_outliers):
diffs = data[:,1] - fitted_curve
mean_err = np.abs(diffs).mean()
if mean_err > max_mean_err:
save_log(np.concatenate([data, np.array([fitted_curve]).transpose()], 1))
raise TestFailed("curve fit has too large mean error: {} > {}".format(mean_err, max_mean_err))
outliers = np.count_nonzero((diffs > inlier_range) | (diffs < -inlier_range))
if outliers > max_outliers:
save_log(np.concatenate([data, np.array([fitted_curve]).transpose()], 1))
raise TestFailed("curve fit has too many outliers (err > {}): {} > {}".format(inlier_range, outliers, max_outliers))
def test_watchdog(axis, feed_func, logger: Logger):
"""
Tests the watchdog of one axis, using the provided function to feed the watchdog.
This test assumes that the testing host has no more than 300ms random delays.
"""
start = time.monotonic()
axis.config.enable_watchdog = False
axis.error = 0
axis.config.watchdog_timeout = 1.0
axis.watchdog_feed()
axis.config.enable_watchdog = True
test_assert_eq(axis.error, 0)
for _ in range(5): # keep the watchdog alive for 3.5 seconds
time.sleep(0.7)
logger.debug('feeding watchdog at {}s'.format(time.monotonic() - start))
feed_func()
err = axis.error
logger.debug('checking error at {}s'.format(time.monotonic() - start))
test_assert_eq(err, 0)
logger.debug('letting watchdog expire...')
time.sleep(1.3) # let the watchdog expire
test_assert_eq(axis.error, AXIS_ERROR_WATCHDOG_TIMER_EXPIRED)
# Test Components -------------------------------------------------------------#
class Component(object):
def __init__(self, parent):
self.parent = parent
class ODriveComponent(Component):
def __init__(self, yaml: dict):
self.handle = None
self.yaml = yaml
#self.axes = [ODriveAxisComponent(None), ODriveAxisComponent(None)]
self.encoders = [ODriveEncoderComponent(self, 0, yaml['encoder0']), ODriveEncoderComponent(self, 1, yaml['encoder1'])]
self.axes = [ODriveAxisComponent(self, 0, yaml['motor0']), ODriveAxisComponent(self, 1, yaml['motor1'])]
for i in range(1,9):
self.__setattr__('gpio' + str(i), Component(self))
self.can = Component(self)
self.sck = Component(self)
self.miso = Component(self)
self.mosi = Component(self)
def get_subcomponents(self):
for enc_ctx in self.encoders:
yield 'encoder' + str(enc_ctx.num), enc_ctx
for axis_ctx in self.axes:
yield 'axis' + str(axis_ctx.num), axis_ctx
for i in range(1,9):
yield ('gpio' + str(i)), getattr(self, 'gpio' + str(i))
yield 'can', self.can
yield 'spi.sck', self.sck
yield 'spi.miso', self.miso
yield 'spi.mosi', self.mosi
def prepare(self, logger: Logger):
"""
Connects to the ODrive
"""
if not self.handle is None:
return
logger.debug('waiting for {} ({})'.format(self.yaml['name'], self.yaml['serial-number']))
self.handle = odrive.find_any(
path="usb", serial_number=self.yaml['serial-number'], timeout=60)#, printer=print)
assert(self.handle)
#for axis_idx, axis_ctx in enumerate(self.axes):
# axis_ctx.handle = self.handle.__dict__['axis{}'.format(axis_idx)]
for encoder_idx, encoder_ctx in enumerate(self.encoders):
encoder_ctx.handle = self.handle.__dict__['axis{}'.format(encoder_idx)].encoder
# TODO: distinguish between axis and motor context
for axis_idx, axis_ctx in enumerate(self.axes):
axis_ctx.handle = self.handle.__dict__['axis{}'.format(axis_idx)]
def unuse_gpios(self):
self.handle.config.enable_uart = False
self.handle.axis0.config.enable_step_dir = False
self.handle.axis1.config.enable_step_dir = False
self.handle.config.gpio1_pwm_mapping.endpoint = None
self.handle.config.gpio2_pwm_mapping.endpoint = None
self.handle.config.gpio3_pwm_mapping.endpoint = None
self.handle.config.gpio4_pwm_mapping.endpoint = None
self.handle.config.gpio3_analog_mapping.endpoint = None
self.handle.config.gpio4_analog_mapping.endpoint = None
def save_config_and_reboot(self):
self.handle.save_configuration()
try:
self.handle.reboot()
except fibre.ChannelBrokenException:
pass # this is expected
self.handle = None
time.sleep(2)
self.prepare(logger)
def erase_config_and_reboot(self):
try:
self.handle.erase_configuration()
except fibre.ChannelBrokenException:
pass # this is expected
self.handle = None
time.sleep(2)
self.prepare(logger)
class MotorComponent(Component):
def __init__(self, yaml: dict):
self.yaml = yaml
def prepare(self, logger: Logger):
pass
class ODriveAxisComponent(Component):
def __init__(self, parent: ODriveComponent, num: int, yaml: dict):
Component.__init__(self, parent)
self.handle = None
self.yaml = yaml # TODO: this is bad naming
self.num = num
def prepare(self, logger: Logger):
self.parent.prepare(logger)
class ODriveEncoderComponent(Component):
def __init__(self, parent: ODriveComponent, num: int, yaml: dict):
Component.__init__(self, parent)
self.handle = None
self.yaml = yaml
self.num = num
self.z = Component(self)
self.a = Component(self)
self.b = Component(self)
def get_subcomponents(self):
return [('z', self.z), ('a', self.a), ('b', self.b)]
def prepare(self, logger: Logger):
self.parent.prepare(logger)
class EncoderComponent(Component):
def __init__(self, parent: Component, yaml: dict):
Component.__init__(self, parent)
self.yaml = yaml
self.z = Component(self)
self.a = Component(self)
self.b = Component(self)
def get_subcomponents(self):
return [('z', self.z), ('a', self.a), ('b', self.b)]
class GeneralPurposeComponent(Component):
def __init__(self, yaml: dict):
self.components = {}
for component_yaml in yaml.get('components', []):
if component_yaml['type'] == 'can':
self.components[component_yaml['name']] = CanInterfaceComponent(self, component_yaml)
if component_yaml['type'] == 'uart':
self.components[component_yaml['name']] = SerialPortComponent(self, component_yaml)
if component_yaml['type'] == 'gpio':
self.components['gpio' + str(component_yaml['num'])] = LinuxGpioComponent(self, component_yaml)
def get_subcomponents(self):
return self.components.items()
class LinuxGpioComponent(Component):
def __init__(self, parent: Component, yaml: dict):
Component.__init__(self, parent)
self.num = int(yaml['num'])
def config(self, output: bool):
with open("/sys/class/gpio/gpio{}/direction".format(self.num), "w") as fp:
fp.write('out' if output else '0')
def write(self, state: bool):
with open("/sys/class/gpio/gpio{}/value".format(self.num), "w") as fp:
fp.write('1' if state else '0')
class SerialPortComponent(Component):
def __init__(self, parent: Component, yaml: dict):
Component.__init__(self, parent)
self.yaml = yaml
def get_subcomponents(self):
yield 'tx', Component(self)
yield 'rx', Component(self)
def open(self, baudrate: int):
import serial
return serial.Serial(self.yaml['port'], baudrate, timeout=1)
class CanInterfaceComponent(Component):
def __init__(self, parent: Component, yaml: dict):
Component.__init__(self, parent)
self.handle = None
self.yaml = yaml
def prepare(self, logger: Logger):
if not self.handle is None:
return
import can
self.handle = can.interface.Bus(bustype='socketcan', channel=self.yaml['interface'], bitrate=250000)
class TeensyGpio(Component):
def __init__(self, parent: Component, num: int):
Component.__init__(self, parent)
self.num = num
class TeensyComponent(Component):
def __init__(self, testrig, yaml: dict):
self.testrig = testrig
self.yaml = yaml
self.gpios = [TeensyGpio(self, i) for i in range(24)]
self.routes = []
self.previous_routes = object()
def get_subcomponents(self):
for i, gpio in enumerate(self.gpios):
yield ('gpio' + str(i)), gpio
yield 'program', Component(self)
def add_route(self, input: TeensyGpio, output: TeensyGpio, noise_enable: TeensyGpio):
self.routes.append((input, output, noise_enable))
def commit_routing_config(self, logger: Logger):
if self.previous_routes == self.routes:
self.routes = []
return
code = ''
code += 'bool noise = false;\n'
code += 'void setup() {\n'
for i, o, n in self.routes:
code += ' pinMode({}, OUTPUT);\n'.format(o.num)
code += '}\n'
code += 'void loop() {\n'
code += ' noise = !noise;\n'
for i, o, n in self.routes:
if n:
# with noise enable
code += ' digitalWrite({}, digitalRead({}) ? noise : digitalRead({}));\n'.format(o.num, n.num, i.num)
else:
# no noise enable
code += ' digitalWrite({}, digitalRead({}));\n'.format(o.num, i.num)
code += '}\n'
self.compile_and_program(code)
self.previous_routes = self.routes
self.routes = []
def compile(self, sketchfile, hexfile):
env = os.environ.copy()
env['ARDUINO_COMPILE_DESTINATION'] = hexfile
run_shell(
['arduino', '--board', 'teensy:avr:teensy40', '--verify', sketchfile],
logger, env = env, timeout = 120)
def program(self, hex_file_path: str, logger: Logger):
"""
Programs the specified hex file onto the Teensy.
To reset the Teensy, a GPIO of the local system must be connected to the
Teensy's "Program" pin.
"""
# todo: this should be treated like a regular setup resource
program_gpio = self.testrig.get_directly_connected_components(self.testrig.get_component_name(self) + '.program')[0]
# Put Teensy into program mode by pulling it's program pin down
program_gpio.config(output = True)
program_gpio.write(False)
time.sleep(0.1)
program_gpio.write(True)
run_shell(["teensy-loader-cli", "-mmcu=imxrt1062", "-w", hex_file_path], logger, timeout = 5)
time.sleep(0.5) # give it some time to boot
def compile_and_program(self, code: str):
with tempfile.TemporaryDirectory() as temp_dir:
with open(os.path.join(temp_dir, 'code.ino'), 'w+') as code_fp:
code_fp.write(code)
code_fp.flush()
code_fp.seek(0)
print('Writing code to teensy: ')
print(code_fp.read())
with tempfile.NamedTemporaryFile(suffix='.hex') as hex_fp:
self.compile(code_fp.name, hex_fp.name)
self.program(hex_fp.name, logger)
class LowPassFilterComponent(Component):
def __init__(self, parent: Component):
Component.__init__(self, parent)
self.en = Component(self)
def get_subcomponents(self):
yield 'en', self.en
class ProxiedComponent(Component):
def __init__(self, impl, *gpio_tuples):
"""
Each element in gpio_tuples should be a tuple of the form:
(teensy: TeensyComponent, gpio_in, gpio_out, gpio_noise_enable)
"""
Component.__init__(self, getattr(impl, 'parent', None))
self.impl = impl
assert(all([len(t) == 4 for t in gpio_tuples]))
self.gpio_tuples = list(gpio_tuples)
def __repr__(self):
return testrig.get_component_name(self.impl) + ' (routed via ' + ', '.join((testrig.get_component_name(t) + ': ' + str(i.num) + ' => ' + str(o.num)) for t, i, o, n in self.gpio_tuples) + ')'
def __eq__(self, obj):
return isinstance(obj, ProxiedComponent) and (self.impl == obj.impl) # and (self.gpio_tuples == obj.gpio_tuples)
def prepare(self):
for teensy, gpio_in, gpio_out, gpio_noise_enable in self.gpio_tuples:
teensy.add_route(gpio_in, gpio_out, gpio_noise_enable)
class TestRig():
def __init__(self, yaml: dict, logger: Logger):
# Contains all components (including subcomponents).
# Ports are components too.
self.components_by_name = {} # {'name': object, ...}
self.names_by_component = {} # {'name': object, ...}
def add_component(name, component):
self.components_by_name[name] = component
self.names_by_component[component] = name
if hasattr(component, 'get_subcomponents'):
for subname, subcomponent in component.get_subcomponents():
add_component(name + '.' + subname, subcomponent)
for component_yaml in yaml['components']:
if component_yaml['type'] == 'odrive':
add_component(component_yaml['name'], ODriveComponent(component_yaml))
elif component_yaml['type'] == 'generalpurpose':
add_component(component_yaml['name'], GeneralPurposeComponent(component_yaml))
elif component_yaml['type'] == 'teensy':
add_component(component_yaml['name'], TeensyComponent(self, component_yaml))
elif component_yaml['type'] == 'motor':
add_component(component_yaml['name'], MotorComponent(component_yaml))
elif component_yaml['type'] == 'encoder':
add_component(component_yaml['name'], EncoderComponent(self, component_yaml))
elif component_yaml['type'] == 'lpf':
add_component(component_yaml['name'], LowPassFilterComponent(self))
else:
logger.warn('test rig has unsupported component ' + component_yaml['type'])
continue
# List of disjunct sets, where each set holds references of the mutually connected components
self.connections = []
for connection_yaml in yaml['connections']:
self.connections.append(set(self.components_by_name[name] for name in connection_yaml))
self.connections = list(disjoint_sets(self.connections))
# Dict for fast lookup of the connection sets for each port
self.net_by_component = {}
for s in self.connections:
for port in s:
self.net_by_component[port] = s
def get_components(self, t: type):
"""Returns a tuple (name, component) for all components that are of the specified type"""
return (comp for comp in self.names_by_component.keys() if isinstance(comp, t))
def get_component_name(self, component: Component):
if isinstance(component, ProxiedComponent):
return self.names_by_component[component.impl]
else:
return self.names_by_component[component]
def get_directly_connected_components(self, component: Union[str, Component]):
"""
Returns all components that are directly connected to the specified
component, excluding the specified component itself.
"""
if isinstance(component, str):
component = self.components_by_name[component]
result = self.net_by_component.get(component, set([component]))
return [c for c in result if (c != component)]
def get_connected_components(self, src: Union[dict, Tuple[Union[Component, str], bool]], comp_type: type = None):
"""
Returns all components that are either directly or indirectly (through a
Teensy) connected to the specified component(s).
component: Either:
- A component object.
- A component name given as string.
- A tuple of the form (comp, dir) where comp is a component object
or name and dir specifies the data direction.
The direction is required if routing through a Teensy should be
considered.
- A dict {sumcomponent: val} where subcomponent is a string
such as 'tx' or 'rx' and val is of one of the forms described above.
A type can be specified to filter the connected components.
"""
if isinstance(src, dict):
component_list = []
for name, subsrc in src.items():
component_list.append([c for c in self.get_connected_components(subsrc) if self.get_component_name(c).endswith('.' + name)])
for combination in itertools.product(*component_list):
if len(set(c.parent for c in combination)) != 1:
continue # parent of the components don't match
proxied_dst = combination[0].parent
if comp_type and not isinstance(proxied_dst, comp_type):
continue # not the requested type
gpio_tuples = [c2 for c in combination for c2 in c.gpio_tuples if isinstance(c, ProxiedComponent)]
if len(gpio_tuples):
yield ProxiedComponent(proxied_dst, *gpio_tuples)
else:
yield proxied_dst
else:
if isinstance(src, tuple):
src, dir = src
else:
dir = None
for dst in self.get_directly_connected_components(src):
if (not comp_type) or isinstance(dst, comp_type):
yield dst
if (not dir is None) and isinstance(getattr(dst, 'parent', None), TeensyComponent):
teensy = dst.parent
for gpio2 in teensy.gpios:
for proxied_dst in self.get_directly_connected_components(gpio2):
if (not comp_type) or isinstance(proxied_dst, comp_type):
yield ProxiedComponent(proxied_dst, (teensy, dst if dir else gpio2, gpio2 if dir else dst, None))
# Helper functions ------------------------------------------------------------#
def request_state(axis_ctx: ODriveAxisComponent, state, expect_success=True):
axis_ctx.handle.requested_state = state
time.sleep(0.001)
if expect_success:
test_assert_eq(axis_ctx.handle.current_state, state)
else:
test_assert_eq(axis_ctx.handle.current_state, AXIS_STATE_IDLE)
test_assert_eq(axis_ctx.handle.error, AXIS_ERROR_INVALID_STATE)
axis_ctx.handle.error = AXIS_ERROR_NONE # reset error
def get_errors(axis_ctx: ODriveAxisComponent):
errors = []
if axis_ctx.handle.motor.error != 0:
errors.append("motor failed with error 0x{:04X}".format(axis_ctx.handle.motor.error))
if axis_ctx.handle.encoder.error != 0:
errors.append("encoder failed with error 0x{:04X}".format(axis_ctx.handle.encoder.error))
if axis_ctx.handle.sensorless_estimator.error != 0:
errors.append("sensorless_estimator failed with error 0x{:04X}".format(axis_ctx.handle.sensorless_estimator.error))
if axis_ctx.handle.error != 0:
errors.append("axis failed with error 0x{:04X}".format(axis_ctx.handle.error))
elif len(errors) > 0:
errors.append("and by the way: axis reports no error even though there is one")
return errors
def test_assert_no_error(axis_ctx: ODriveAxisComponent):
errors = get_errors(axis_ctx)
if len(errors) > 0:
raise TestFailed("\n".join(errors))
def run_shell(command_line, logger, env=None, timeout=None):
"""
Runs a shell command in the current directory
"""
import shlex
import subprocess
logger.debug("invoke: " + str(command_line))
if isinstance(command_line, list):
cmd = command_line
else:
cmd = shlex.split(command_line)
result = subprocess.run(cmd, timeout=timeout,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
env=env)
if result.returncode != 0:
logger.error(result.stdout.decode(sys.stdout.encoding))
raise TestFailed("command {} failed".format(command_line))
def get_combinations(param_options):
if isinstance(param_options, tuple):
if len(param_options) > 0:
for part1, part2 in itertools.product(
get_combinations(param_options[0]),
get_combinations(param_options[1:]) if (len(param_options) > 1) else [()]):
assert(isinstance(part1, tuple))
assert(isinstance(part2, tuple))
yield part1 + part2
elif is_list_like(param_options):
for item in param_options:
for c in get_combinations(item):
yield c
else:
yield (param_options,)
def select_params(param_options):
# Select parameters from the resource list
# (this could be arbitrarily complex to improve parallelization of the tests)
for combination in get_combinations(param_options):
if all_unique([x for x in combination if isinstance(x, Component)]):
return list(combination)
return None
def run(tests):
if not isinstance(tests, list):
tests = [tests]
for test in tests:
# The result of get_test_cases can be described in ABNF grammar:
# test-case-list = *arglist
# arglist = *flexible-arg
# flexible-arg = component / *argvariant
# argvariant = component / arglist
#
# If for a particular test-case, the components are not given plainly
# but in some selectable form, the test driver will select exactly one
# of those options.
# In other words, it will bring arglist from the form *flexible-arg
# into the form *component before calling the test.
#
# All of the provided test-cases are executed. If none is provided,
# a warning is reported. A warning is also reported if for a particular
# test case no component combination can be resolved.
test_cases = list(test.get_test_cases(testrig))
if len(test_cases) == 0:
logger.warn('no test cases are available to conduct the test {}'.format(type(test).__name__))
continue
for test_case in test_cases:
params = select_params(test_case)
if params is None:
logger.warn('no resources are available to conduct the test {}'.format(type(test).__name__))
continue
logger.notify('* preparing {} with {}...'.format(type(test).__name__,
[(testrig.get_component_name(p) if isinstance(p, Component) else str(p)) for p in params]))
teensies = set()
for param in params:
if isinstance(param, ProxiedComponent):
param.prepare()
for teensy, _, _, _ in param.gpio_tuples:
teensies.add(teensy)
for teensy in teensies:
teensy.commit_routing_config(logger)
# prepare all components
teensies = set()
for param in params:
if isinstance(param, ProxiedComponent):
continue
if hasattr(param, 'prepare'):
param.prepare(logger)
logger.notify('* running {} on {}...'.format(type(test).__name__,
[(testrig.get_component_name(p) if isinstance(p, Component) else str(p)) for p in params]))
# Resolve routed components
for i, param in enumerate(params):
if isinstance(param, ProxiedComponent):
params[i] = param.impl
test.run_test(*params, logger)
logger.success('All tests passed!')
# Load test engine ------------------------------------------------------------#
# Parse arguments
parser = argparse.ArgumentParser(description='ODrive automated test tool\n')
parser.add_argument("--ignore", metavar='DEVICE', action='store', nargs='+',
help="Ignore (disable) one or more components of the test rig")
# TODO: implement
parser.add_argument("--test-rig-yaml", type=argparse.FileType('r'), required=True,
help="test rig YAML file")
parser.add_argument("--setup-host", action='store_true', default=False,
help="configure operating system functions such as GPIOs (requires root)")
parser.set_defaults(ignore=[])
args = parser.parse_args()
# Load objects
test_rig_yaml = yaml.load(args.test_rig_yaml, Loader=yaml.BaseLoader)
logger = Logger()
testrig = TestRig(test_rig_yaml, logger)
if args.setup_host:
for gpio in testrig.get_components(LinuxGpioComponent):
num = gpio.num
logger.debug('exporting GPIO ' + str(num) + ' to user space...')
if not os.path.isdir("/sys/class/gpio/gpio{}".format(num)):
with open("/sys/class/gpio/export", "w") as fp:
fp.write(str(num))
os.chmod("/sys/class/gpio/gpio{}/value".format(num), stat.S_IRWXU | stat.S_IRWXG | stat.S_IRWXO)
os.chmod("/sys/class/gpio/gpio{}/direction".format(num), stat.S_IRWXU | stat.S_IRWXG | stat.S_IRWXO)
for port in testrig.get_components(SerialPortComponent):
logger.debug('changing permissions on ' + port.yaml['port'] + '...')
os.chmod(port.yaml['port'], stat.S_IRWXU | stat.S_IRWXG | stat.S_IRWXO)
if len(list(testrig.get_components(TeensyComponent))):
# This breaks the annoying teensy loader that shows up on every compile
logger.debug('modifying teensyduino installation...')
if not os.path.isfile('/usr/share/arduino/hardware/tools/teensy_post_compile_old'):
os.rename('/usr/share/arduino/hardware/tools/teensy_post_compile', '/usr/share/arduino/hardware/tools/teensy_post_compile_old')
with open('/usr/share/arduino/hardware/tools/teensy_post_compile', 'w') as scr:
scr.write('#!/usr/bin/env bash\n')
scr.write('if [ "$ARDUINO_COMPILE_DESTINATION" != "" ]; then\n')
scr.write(' cp -r ${2#-path=}/*.ino.hex ${ARDUINO_COMPILE_DESTINATION}\n')
scr.write('fi\n')
os.chmod('/usr/share/arduino/hardware/tools/teensy_post_compile', stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH)
# Bring up CAN interface(s)
for intf in testrig.get_components(CanInterfaceComponent):
name = intf.yaml['interface']
logger.debug('bringing up {}...'.format(name))
run_shell('ip link set dev {} down'.format(name), logger)
run_shell('ip link set dev {} type can bitrate 250000'.format(name), logger)
run_shell('ip link set dev {} type can loopback off'.format(name), logger)
run_shell('ip link set dev {} up'.format(name), logger)
@@ -0,0 +1,301 @@
import test_runner
import struct
import time
import os
import io
import functools
import operator
from fibre.utils import Logger
from odrive.enums import *
from test_runner import *
def append_checksum(command):
return command + b'*' + str(functools.reduce(operator.xor, command)).encode('ascii')
def strip_checksum(command):
command, _, checksum = command.partition(b'*')
test_assert_eq(int(checksum.strip()), functools.reduce(operator.xor, command))
return command
def reset_state(ser):
"""Resets the state of the ASCII protocol by flushing all buffers"""
ser.flushOutput() # ensure that all previous bytes are sent
time.sleep(0.1) # wait for ODrive to handle last input (buffer might be full)
ser.write(b'\n') # terminate line
ser.flushOutput() # ensure that end-of-line is sent
time.sleep(0.1) # wait for any response that this may generate
ser.flushInput() # discard response
class TestUartAscii():
"""
Tests the most important functions of the ASCII protocol.
"""
def get_test_cases(self, testrig: TestRig):
for odrive in testrig.get_components(ODriveComponent):
ports = list(testrig.get_connected_components({
'rx': (odrive.gpio1, True),
'tx': (odrive.gpio2, False)
}, SerialPortComponent))
yield (odrive, ports)
def run_test(self, odrive: ODriveComponent, port: SerialPortComponent, logger: Logger):
logger.debug('Enabling UART...')
# GPIOs might be in use by something other than UART and some components
# might be configured so that they would fail in the later test.
odrive.erase_config_and_reboot()
odrive.handle.config.enable_uart = True
with port.open(115200) as ser:
# reset port to known state
reset_state(ser)
# Read a top-level attribute
ser.write(b'r vbus_voltage\n')
response = float(ser.readline().strip())
test_assert_eq(response, odrive.handle.vbus_voltage, accuracy=0.1)
# Read an unknown attribute
ser.write(b'r blahblah\n')
response = ser.readline().strip()
test_assert_eq(response, b'invalid property')
# Send command with delays in between
for byte in b'r vbus_voltage\n':
ser.write([byte])
time.sleep(0.1)
response = float(ser.readline().strip())
test_assert_eq(response, odrive.handle.vbus_voltage, accuracy=0.1)
# Test GCode checksum and comments
ser.write(b'r vbus_voltage *12\n') # invalid checksum
test_assert_eq(ser.readline(), b'')
ser.write(append_checksum(b'r vbus_voltage ') + b' ; this is a comment\n') # valid checksum
response = float(strip_checksum(ser.readline()).strip())
test_assert_eq(response, odrive.handle.vbus_voltage, accuracy=0.1)
# Read an attribute with a long name
ser.write(b'r axis0.motor.current_control.v_current_control_integral_d\n')
response = float(ser.readline().strip())
test_assert_eq(response, odrive.handle.axis0.motor.current_control.v_current_control_integral_d, accuracy=0.1)
# Write an attribute
ser.write(b'w test_property 12345\n')
ser.write(b'r test_property\n')
response = int(ser.readline().strip())
test_assert_eq(response, 12345)
# Test custom setter (aka property write hook)
odrive.handle.axis0.motor.config.phase_resistance = 1
odrive.handle.axis0.motor.config.phase_inductance = 1
odrive.handle.axis0.motor.config.current_control_bandwidth = 1000
old_gain = odrive.handle.axis0.motor.current_control.p_gain
test_assert_eq(old_gain, 1000, accuracy=0.0001) # must be non-zero for subsequent check to work
ser.write('w axis0.motor.config.current_control_bandwidth {}\n'.format(odrive.handle.axis0.motor.config.current_control_bandwidth / 2).encode('ascii'))
test_assert_eq(ser.readline(), b'')
test_assert_eq(odrive.handle.axis0.motor.current_control.p_gain, old_gain / 2, accuracy=0.0001)
# Test 'c', 'v', 'p', 'q' and 'f' commands
odrive.handle.axis0.controller.input_torque = 0
ser.write(b'c 0 12.5\n')
test_assert_eq(ser.readline(), b'')
test_assert_eq(odrive.handle.axis0.controller.input_torque, 12.5, accuracy=0.001)
test_assert_eq(odrive.handle.axis0.controller.config.control_mode, CONTROL_MODE_TORQUE_CONTROL)
odrive.handle.axis0.controller.input_vel = 0
odrive.handle.axis0.controller.input_torque = 0
ser.write(b'v 0 567.8 12.5\n')
test_assert_eq(ser.readline(), b'')
test_assert_eq(odrive.handle.axis0.controller.input_vel, 567.8, accuracy=0.001)
test_assert_eq(odrive.handle.axis0.controller.input_torque, 12.5, accuracy=0.001)
test_assert_eq(odrive.handle.axis0.controller.config.control_mode, CONTROL_MODE_VELOCITY_CONTROL)
odrive.handle.axis0.controller.input_pos = 0
odrive.handle.axis0.controller.input_vel = 0
odrive.handle.axis0.controller.input_torque = 0
ser.write(b'p 0 123.4 567.8 12.5\n')
test_assert_eq(ser.readline(), b'')
test_assert_eq(odrive.handle.axis0.controller.input_pos, 123.4, accuracy=0.001)
test_assert_eq(odrive.handle.axis0.controller.input_vel, 567.8, accuracy=0.001)
test_assert_eq(odrive.handle.axis0.controller.input_torque, 12.5, accuracy=0.001)
test_assert_eq(odrive.handle.axis0.controller.config.control_mode, CONTROL_MODE_POSITION_CONTROL)
odrive.handle.axis0.controller.input_pos = 0
odrive.handle.axis0.controller.config.vel_limit = 0
odrive.handle.axis0.motor.config.current_lim = 0
ser.write(b'q 0 123.4 567.8 12.5\n')
test_assert_eq(ser.readline(), b'')
test_assert_eq(odrive.handle.axis0.controller.input_pos, 123.4, accuracy=0.001)
test_assert_eq(odrive.handle.axis0.controller.config.vel_limit, 567.8, accuracy=0.001)
test_assert_eq(odrive.handle.axis0.motor.config.torque_lim, 12.5, accuracy=0.001)
test_assert_eq(odrive.handle.axis0.controller.config.control_mode, CONTROL_MODE_POSITION_CONTROL)
ser.write(b'f 0\n')
response = ser.readline().strip()
test_assert_eq(float(response.split()[0]), odrive.handle.axis0.encoder.pos_estimate, accuracy=0.001)
test_assert_eq(float(response.split()[1]), odrive.handle.axis0.encoder.vel_estimate, accuracy=0.001)
test_watchdog(odrive.handle.axis0, lambda: ser.write(b'u 0\n'), logger)
test_assert_eq(ser.readline(), b'') # check if the device remained silent during the test
# TODO: test cases for 't', 'ss', 'se', 'sr' commands
class TestUartBaudrate():
"""
Tests if the UART baudrate setting works as intended.
"""
def get_test_cases(self, testrig: TestRig):
for odrive in testrig.get_components(ODriveComponent):
ports = list(testrig.get_connected_components({
'rx': (odrive.gpio1, True),
'tx': (odrive.gpio2, False)
}, SerialPortComponent))
yield (odrive, ports)
def run_test(self, odrive: ODriveComponent, port: SerialPortComponent, logger: Logger):
odrive.handle.axis0.config.enable_step_dir = False
odrive.handle.config.enable_uart = True
odrive.handle.config.uart_baudrate = 9600
odrive.save_config_and_reboot()
# Control test: talk to the ODrive with the wrong baudrate
with port.open(115200) as ser:
# reset port to known state
reset_state(ser)
ser.write(b'r vbus_voltage\n')
test_assert_eq(ser.readline().strip(), b'')
with port.open(9600) as ser:
# reset port to known state
reset_state(ser)
# Check if protocol works
ser.write(b'r vbus_voltage\n')
response = float(ser.readline().strip())
test_assert_eq(response, odrive.handle.vbus_voltage, accuracy=0.1)
odrive.handle.config.uart_baudrate = 115200
odrive.save_config_and_reboot()
class TestUartBurnIn():
"""
Tests if the ASCII protocol can handle 64kB of random data being thrown at it.
"""
def get_test_cases(self, testrig: TestRig):
for odrive in testrig.get_components(ODriveComponent):
ports = list(testrig.get_connected_components({
'rx': (odrive.gpio1, True),
'tx': (odrive.gpio2, False)
}, SerialPortComponent))
yield (odrive, ports)
def run_test(self, odrive: ODriveComponent, port: SerialPortComponent, logger: Logger):
odrive.handle.axis0.config.enable_step_dir = False
odrive.handle.config.enable_uart = True
with port.open(115200) as ser:
with open('/dev/random', 'rb') as rand:
buf = rand.read(65536)
ser.write(buf)
# reset port to known state
reset_state(ser)
# Check if protocol still works
ser.write(b'r vbus_voltage\n')
response = float(ser.readline().strip())
test_assert_eq(response, odrive.handle.vbus_voltage, accuracy=0.1)
class TestUartNoise():
"""
Tests if the UART can handle invalid signals.
"""
def get_test_cases(self, testrig: TestRig):
for odrive in testrig.get_components(ODriveComponent):
# For every ODrive, find a connected serial port which has a teensy
# in between, so that we can inject noise,
ports = list(testrig.get_connected_components({
'rx': (odrive.gpio1, True),
'tx': (odrive.gpio2, False)
}, SerialPortComponent))
# Hack the bus objects to enable noise_enable functionality on the TX line.
def get_noise_gpio(bus):
teensy = bus.gpio_tuples[1][0]
for teensy_gpio in teensy.gpios:
for other_gpio in testrig.get_directly_connected_components(teensy_gpio):
if isinstance(other_gpio, LinuxGpioComponent):
return teensy_gpio, other_gpio
return None
for idx, bus in enumerate(ports):
noise_gpio_on_teensy, noise_gpio_on_rpi = get_noise_gpio(bus)
assert(noise_gpio_on_rpi)
t, i, o, _ = bus.gpio_tuples[1]
bus.gpio_tuples[1] = (t, i, o, noise_gpio_on_teensy)
ports[idx] = (bus, noise_gpio_on_rpi)
yield (odrive, ports)
def run_test(self, odrive: ODriveComponent, port: SerialPortComponent, noise_enable: LinuxGpioComponent, logger: Logger):
noise_enable.config(output=True)
noise_enable.write(False)
time.sleep(0.1)
odrive.handle.axis0.config.enable_step_dir = False
odrive.handle.config.enable_uart = True
with port.open(115200) as ser:
# reset port to known state
reset_state(ser)
# Enable square wave of ~1.6MHz on the ODrive's RX line
noise_enable.write(True)
time.sleep(0.1)
reset_state(ser)
time.sleep(1.0)
# Read an attribute (should fail because the command is not passed through)
ser.write(b'r vbus_voltage\n')
test_assert_eq(ser.readline(), b'')
# Disable square wave
noise_enable.write(False)
# Give receiver some time to recover
time.sleep(0.1)
# reset port to known state
reset_state(ser)
# Try again
ser.write(b'r vbus_voltage\n')
response = float(ser.readline().strip())
test_assert_eq(response, odrive.handle.vbus_voltage, accuracy=0.1)
if __name__ == '__main__':
test_runner.run([
TestUartAscii(),
TestUartBaudrate(),
TestUartBurnIn(),
TestUartNoise(),
])
@@ -0,0 +1,363 @@
from __future__ import print_function
import sys
import time
import threading
import platform
import subprocess
import os
import numpy as np
import matplotlib.pyplot as plt
from fibre.utils import Event
import odrive.enums
from odrive.enums import *
try:
if platform.system() == 'Windows':
import win32console
import colorama
colorama.init()
except ImportError:
print("Could not init terminal features.")
print("Refer to install instructions at http://docs.odriverobotics.com/#downloading-and-installing-tools")
sys.stdout.flush()
pass
_VT100Colors = {
'green': '\x1b[92;1m',
'cyan': '\x1b[96;1m',
'yellow': '\x1b[93;1m',
'red': '\x1b[91;1m',
'default': '\x1b[0m'
}
def calculate_thermistor_coeffs(degree, Rload, R_25, Beta, Tmin, Tmax, plot = False):
T_25 = 25 + 273.15 #Kelvin
temps = np.linspace(Tmin, Tmax, 1000)
tempsK = temps + 273.15
# https://en.wikipedia.org/wiki/Thermistor#B_or_%CE%B2_parameter_equation
r_inf = R_25 * np.exp(-Beta/T_25)
R_temps = r_inf * np.exp(Beta/tempsK)
V = Rload / (Rload + R_temps)
fit = np.polyfit(V, temps, degree)
p1 = np.poly1d(fit)
fit_temps = p1(V)
if plot:
print(fit)
plt.plot(V, temps, label='actual')
plt.plot(V, fit_temps, label='fit')
plt.xlabel('normalized voltage')
plt.ylabel('Temp [C]')
plt.legend(loc=0)
plt.show()
return p1
class OperationAbortedException(Exception):
pass
def set_motor_thermistor_coeffs(axis, Rload, R_25, Beta, Tmin, TMax):
coeffs = calculate_thermistor_coeffs(3, Rload, R_25, Beta, Tmin, TMax)
axis.motor_thermistor.config.poly_coefficient_0 = float(coeffs[3])
axis.motor_thermistor.config.poly_coefficient_1 = float(coeffs[2])
axis.motor_thermistor.config.poly_coefficient_2 = float(coeffs[1])
axis.motor_thermistor.config.poly_coefficient_3 = float(coeffs[0])
def dump_errors(odrv, clear=False):
axes = [(name, axis) for name, axis in odrv._remote_attributes.items() if 'axis' in name]
axes.sort()
for name, axis in axes:
print(name)
# Flatten axis and submodules
# (name, remote_obj, errorcode)
module_decode_map = [
('axis', axis, {k: v for k, v in odrive.enums.__dict__ .items() if k.startswith("AXIS_ERROR_")}),
('motor', axis.motor, {k: v for k, v in odrive.enums.__dict__ .items() if k.startswith("MOTOR_ERROR_")}),
('fet_thermistor', axis.fet_thermistor, {k: v for k, v in odrive.enums.__dict__ .items() if k.startswith("THERMISTOR_CURRENT_LIMITER_ERROR")}),
('motor_thermistor', axis.motor_thermistor, {k: v for k, v in odrive.enums.__dict__ .items() if k.startswith("THERMISTOR_CURRENT_LIMITER_ERROR")}),
('encoder', axis.encoder, {k: v for k, v in odrive.enums.__dict__ .items() if k.startswith("ENCODER_ERROR_")}),
('controller', axis.controller, {k: v for k, v in odrive.enums.__dict__ .items() if k.startswith("CONTROLLER_ERROR_")}),
]
# Module error decode
for name, remote_obj, errorcodes in module_decode_map:
prefix = ' '*2 + name + ": "
if (remote_obj.error != 0):
foundError = False
print(prefix + _VT100Colors['red'] + "Error(s):" + _VT100Colors['default'])
errorcodes_tup = [(name, val) for name, val in errorcodes.items() if 'ERROR_' in name]
for codename, codeval in errorcodes_tup:
if remote_obj.error & codeval != 0:
foundError = True
print(" " + codename)
if not foundError:
print(" " + 'UNKNOWN ERROR!')
if clear:
remote_obj.error = 0
else:
print(prefix + _VT100Colors['green'] + "no error" + _VT100Colors['default'])
def oscilloscope_dump(odrv, num_vals, filename='oscilloscope.csv'):
with open(filename, 'w') as f:
for x in range(num_vals):
f.write(str(odrv.get_oscilloscope_val(x)))
f.write('\n')
data_rate = 100
plot_rate = 10
num_samples = 1000
def start_liveplotter(get_var_callback):
"""
Starts a liveplotter.
The variable that is plotted is retrieved from get_var_callback.
This function returns immediately and the liveplotter quits when
the user closes it.
"""
import matplotlib.pyplot as plt
cancellation_token = Event()
global vals
vals = []
def fetch_data():
global vals
while not cancellation_token.is_set():
try:
data = get_var_callback()
except Exception as ex:
print(str(ex))
time.sleep(1)
continue
vals.append(data)
if len(vals) > num_samples:
vals = vals[-num_samples:]
time.sleep(1/data_rate)
# TODO: use animation for better UI performance, see:
# https://matplotlib.org/examples/animation/simple_anim.html
def plot_data():
global vals
plt.ion()
# Make sure the script terminates when the user closes the plotter
def did_close(evt):
cancellation_token.set()
fig = plt.figure()
fig.canvas.mpl_connect('close_event', did_close)
while not cancellation_token.is_set():
plt.clf()
plt.plot(vals)
plt.legend(list(range(len(vals))))
fig.canvas.draw()
fig.canvas.start_event_loop(1/plot_rate)
fetch_t = threading.Thread(target=fetch_data)
fetch_t.daemon = True
fetch_t.start()
plot_t = threading.Thread(target=plot_data)
plot_t.daemon = True
plot_t.start()
return cancellation_token;
#plot_data()
class BulkCapture:
'''
Asynchronously captures a bulk set of data when instance is created.
get_var_callback: a function that returns the data you want to collect (see the example below)
data_rate: Rate in hz
length: Length of time to capture in seconds
Example Usage:
capture = BulkCapture(lambda :[odrv0.axis0.encoder.pos_estimate, odrv0.axis0.controller.pos_setpoint])
# Do stuff while capturing (like sending position commands)
capture.event.wait() # When you're done doing stuff, wait for the capture to be completed.
print(capture.data) # Do stuff with the data
capture.plot_data() # Helper method to plot the data
'''
def __init__(self,
get_var_callback,
data_rate=500.0,
duration=2.0):
from threading import Event, Thread
import numpy as np
self.get_var_callback = get_var_callback
self.event = Event()
def loop():
vals = []
start_time = time.monotonic()
period = 1.0 / data_rate
while time.monotonic() - start_time < duration:
try:
data = get_var_callback()
except Exception as ex:
print(str(ex))
print("Waiting 1 second before next data point")
time.sleep(1)
continue
relative_time = time.monotonic() - start_time
vals.append([relative_time] + data)
time.sleep(period - (relative_time % period)) # this ensures consistently timed samples
self.data = np.array(vals) # A lock is not really necessary due to the event
print("Capture complete")
achieved_data_rate = len(self.data) / self.data[-1, 0]
if achieved_data_rate < (data_rate * 0.9):
print("Achieved average data rate: {}Hz".format(achieved_data_rate))
print("If this rate is significantly lower than what you specified, consider lowering it below the achieved value for more consistent sampling.")
self.event.set() # tell the main thread that the bulk capture is complete
Thread(target=loop, daemon=True).start()
def plot(self):
import matplotlib.pyplot as plt
import inspect
from textwrap import wrap
plt.plot(self.data[:,0], self.data[:,1:])
plt.xlabel("Time (seconds)")
title = (str(inspect.getsource(self.get_var_callback))
.strip("['\\n']")
.split(" = ")[1])
plt.title("\n".join(wrap(title, 60)))
plt.legend(range(self.data.shape[1]-1))
plt.show()
def step_and_plot( axis,
step_size=100.0,
settle_time=0.5,
data_rate=500.0,
ctrl_mode=CONTROL_MODE_POSITION_CONTROL):
if ctrl_mode is CONTROL_MODE_POSITION_CONTROL:
get_var_callback = lambda :[axis.encoder.pos_estimate, axis.controller.pos_setpoint]
initial_setpoint = axis.encoder.pos_estimate
def set_setpoint(setpoint):
axis.controller.pos_setpoint = setpoint
elif ctrl_mode is CONTROL_MODE_VELOCITY_CONTROL:
get_var_callback = lambda :[axis.encoder.vel_estimate, axis.controller.vel_setpoint]
initial_setpoint = 0
def set_setpoint(setpoint):
axis.controller.vel_setpoint = setpoint
else:
print("Invalid control mode")
return
initial_settle_time = 0.5
initial_control_mode = axis.controller.config.control_mode # Set it back afterwards
print(initial_control_mode)
axis.controller.config.control_mode = ctrl_mode
axis.requested_state = AXIS_STATE_CLOSED_LOOP_CONTROL
capture = BulkCapture(get_var_callback,
data_rate=data_rate,
duration=initial_settle_time + settle_time)
set_setpoint(initial_setpoint)
time.sleep(initial_settle_time)
set_setpoint(initial_setpoint + step_size) # relative/incremental movement
capture.event.wait() # wait for Bulk Capture to be complete
axis.requested_state = AXIS_STATE_IDLE
axis.controller.config.control_mode = initial_control_mode
capture.plot()
def print_drv_regs(name, motor):
"""
Dumps the current gate driver regisers for the specified motor
"""
fault = motor.gate_driver.drv_fault
status_reg_1 = motor.gate_driver.status_reg_1
status_reg_2 = motor.gate_driver.status_reg_2
ctrl_reg_1 = motor.gate_driver.ctrl_reg_1
ctrl_reg_2 = motor.gate_driver.ctrl_reg_2
print(name + ": " + str(fault))
print("DRV Fault Code: " + str(fault))
print("Status Reg 1: " + str(status_reg_1) + " (" + format(status_reg_1, '#010b') + ")")
print("Status Reg 2: " + str(status_reg_2) + " (" + format(status_reg_2, '#010b') + ")")
print("Control Reg 1: " + str(ctrl_reg_1) + " (" + format(ctrl_reg_1, '#013b') + ")")
print("Control Reg 2: " + str(ctrl_reg_2) + " (" + format(ctrl_reg_2, '#09b') + ")")
def show_oscilloscope(odrv):
size = 18000
values = []
for i in range(size):
values.append(odrv.get_oscilloscope_val(i))
import matplotlib.pyplot as plt
plt.plot(values)
plt.show()
def rate_test(device):
"""
Tests how many integers per second can be transmitted
"""
# import matplotlib.pyplot as plt
# plt.ion()
print("reading 10000 values...")
numFrames = 10000
vals = []
for _ in range(numFrames):
vals.append(device.axis0.loop_counter)
loopsPerFrame = (vals[-1] - vals[0])/numFrames
loopsPerSec = (168000000/(6*3500))
FramePerSec = loopsPerSec/loopsPerFrame
print("Frames per second: " + str(FramePerSec))
# plt.plot(vals)
# plt.show(block=True)
def usb_burn_in_test(get_var_callback, cancellation_token):
"""
Starts background threads that read a values form the USB device in a spin-loop
"""
def fetch_data():
global vals
i = 0
while not cancellation_token.is_set():
try:
get_var_callback()
i += 1
except Exception as ex:
print(str(ex))
time.sleep(1)
i = 0
continue
if i % 1000 == 0:
print("read {} values".format(i))
threading.Thread(target=fetch_data, daemon=True).start()
def yes_no_prompt(question, default=None):
if default is None:
question += " [y/n] "
elif default == True:
question += " [Y/n] "
elif default == False:
question += " [y/N] "
while True:
print(question, end='')
choice = input().lower()
if choice in {'yes', 'y'}:
return True
elif choice in {'no', 'n'}:
return False
elif choice == '' and default is not None:
return default
@@ -0,0 +1,98 @@
import re
import subprocess
import os
import sys
import platform
def version_str_to_tuple(version_string):
"""
Converts a version string to a tuple of the form
(major, minor, revision, prerelease)
Example: "fw-v0.3.6-23" => (0, 3, 6, True)
If version_string does not match the pattern above, this function throws an
Exception.
"""
regex=r'.*v([0-9]+)\.([0-9]+)\.([0-9]+)(.*)'
if not re.match(regex, version_string):
raise Exception()
return (int(re.sub(regex, r"\1", version_string)),
int(re.sub(regex, r"\2", version_string)),
int(re.sub(regex, r"\3", version_string)),
(re.sub(regex, r"\4", version_string) != ""))
def get_version_from_git():
script_dir = os.path.dirname(os.path.realpath(__file__))
try:
# Determine the current git commit version
git_tag = subprocess.check_output(["git", "describe", "--always", "--tags", "--dirty=*"],
cwd=script_dir)
git_tag = git_tag.decode(sys.stdout.encoding).rstrip('\n')
(major, minor, revision, is_prerelease) = version_str_to_tuple(git_tag)
# if is_prerelease:
# revision += 1
return git_tag, major, minor, revision, is_prerelease
except Exception as ex:
print(ex)
return "[unknown version]", 0, 0, 0, 1
def get_version_str(git_only=False, is_post_release=False, bump_rev=False, release_override=False):
"""
Returns the versions of the tools
If git_only is true, the version.txt file is ignored even
if it is present.
"""
script_dir = os.path.dirname(os.path.realpath(__file__))
# Try to read the version.txt file that is generated during
# the packaging step
version_file_path = os.path.join(script_dir, 'version.txt')
if os.path.exists(version_file_path) and git_only == False:
with open(version_file_path) as version_file:
return version_file.readline().rstrip('\n')
_, major, minor, revision, unreleased = get_version_from_git()
if bump_rev:
revision += 1
version = '{}.{}.{}'.format(major, minor, revision)
if is_post_release:
version += ".post"
elif not release_override and unreleased:
version += ".dev"
return version
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser(description='Version Dump\n')
parser.add_argument("--output", type=argparse.FileType('w'), default='-',
help="C header output file")
args = parser.parse_args()
git_name, major, minor, revision, unreleased = get_version_from_git()
print('Firmware version {}.{}.{}{} ({})'.format(
major, minor, revision, '-dev' if unreleased else '',
git_name))
#args.output.write('const unsigned char fw_version = "{}"\n'.format(git_name))
args.output.write('const unsigned char fw_version_major_ = {};\n'.format(major))
args.output.write('const unsigned char fw_version_minor_ = {};\n'.format(minor))
args.output.write('const unsigned char fw_version_revision_ = {};\n'.format(revision))
args.output.write('const unsigned char fw_version_unreleased_ = {};\n'.format(1 if unreleased else 0))
def setup_udev_rules(logger):
if platform.system() != 'Linux':
if logger: logger.error("This command only makes sense on Linux")
return
if os.getuid() != 0:
if logger: logger.warn("you should run this as root, otherwise it will probably not work")
with open('/etc/udev/rules.d/91-odrive.rules', 'w') as file:
file.write('SUBSYSTEM=="usb", ATTR{idVendor}=="1209", ATTR{idProduct}=="0d3[0-9]", MODE="0666", ENV{ID_MM_DEVICE_IGNORE}="1"\n')
subprocess.check_call(["udevadm", "control", "--reload-rules"])
subprocess.check_call(["udevadm", "trigger"])
if logger: logger.info('udev rules configured successfully')