*
This commit is contained in:
@@ -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')
|
||||
Reference in New Issue
Block a user