odrive 0.6.9.dev0__py37-none-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- odrive/__init__.py +142 -0
- odrive/api_client.py +54 -0
- odrive/config.py +1296 -0
- odrive/crypto.py +369 -0
- odrive/data/brakeRs.json +18 -0
- odrive/data/drvs.json +82 -0
- odrive/data/encoders.json +86 -0
- odrive/data/motors.json +128 -0
- odrive/data/odrive-micro-x1.json +48 -0
- odrive/data/odrive-micro-x3.json +40 -0
- odrive/data/odrive-micro-x4.json +40 -0
- odrive/data/odrive-s1-x4.json +52 -0
- odrive/data/odrive-v4.4.json +58 -0
- odrive/data/schema-brakeR.json +6 -0
- odrive/data/schema-drv.json +6 -0
- odrive/data/schema-encoders.json +6 -0
- odrive/data/schema-motors.json +6 -0
- odrive/data/schema-odrive.json +5 -0
- odrive/data/schema.json +244 -0
- odrive/database.py +184 -0
- odrive/dfu.py +195 -0
- odrive/dfuse/DfuDevice.py +297 -0
- odrive/dfuse/__init__.py +1 -0
- odrive/enums.py +415 -0
- odrive/firmware.py +102 -0
- odrive/hw_version.py +67 -0
- odrive/legacy.py +73 -0
- odrive/legacy_config.py +123 -0
- odrive/legacy_dfu.py +442 -0
- odrive/lib/libodrive-windows-x64.dll +0 -0
- odrive/libodrive.py +325 -0
- odrive/pyfibre/fibre/__init__.py +4 -0
- odrive/pyfibre/fibre/libfibre-windows-amd64.dll +0 -0
- odrive/pyfibre/fibre/libfibre.py +1078 -0
- odrive/pyfibre/fibre/libwinpthread-1.dll +0 -0
- odrive/pyfibre/fibre/shell.py +162 -0
- odrive/pyfibre/fibre/utils.py +133 -0
- odrive/release_api.py +184 -0
- odrive/rich_text.py +134 -0
- odrive/shell.py +144 -0
- odrive/utils.py +881 -0
- odrive/version.py +1 -0
- odrive-0.6.9.dev0.data/scripts/odrive_demo.py +50 -0
- odrive-0.6.9.dev0.data/scripts/odrivetool +263 -0
- odrive-0.6.9.dev0.data/scripts/odrivetool.bat +2 -0
- odrive-0.6.9.dev0.dist-info/METADATA +22 -0
- odrive-0.6.9.dev0.dist-info/RECORD +49 -0
- odrive-0.6.9.dev0.dist-info/WHEEL +5 -0
- odrive-0.6.9.dev0.dist-info/top_level.txt +1 -0
|
Binary file
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
|
|
2
|
+
import sys
|
|
3
|
+
import platform
|
|
4
|
+
import threading
|
|
5
|
+
import fibre
|
|
6
|
+
|
|
7
|
+
async def discovered_device(device,
|
|
8
|
+
interactive_variables, objects, discovered_devices,
|
|
9
|
+
mount, shutdown_token, logger):
|
|
10
|
+
"""
|
|
11
|
+
Handles the discovery of new devices by displaying a
|
|
12
|
+
message and making the device available to the interactive
|
|
13
|
+
console
|
|
14
|
+
"""
|
|
15
|
+
mount_result = await mount(device)
|
|
16
|
+
if mount_result is None:
|
|
17
|
+
logger.debug("ignoring device")
|
|
18
|
+
return
|
|
19
|
+
|
|
20
|
+
display_name, serial_number, var_name = mount_result
|
|
21
|
+
|
|
22
|
+
if serial_number in discovered_devices:
|
|
23
|
+
verb = "Reconnected"
|
|
24
|
+
index = discovered_devices.index(serial_number)
|
|
25
|
+
else:
|
|
26
|
+
verb = "Connected"
|
|
27
|
+
discovered_devices.append(serial_number)
|
|
28
|
+
index = len(discovered_devices) - 1
|
|
29
|
+
|
|
30
|
+
var_name = var_name + str(index)
|
|
31
|
+
|
|
32
|
+
# Publish new device to interactive console
|
|
33
|
+
interactive_variables[var_name] = device
|
|
34
|
+
device.__sealed__ = False
|
|
35
|
+
try:
|
|
36
|
+
device.__name__ = var_name
|
|
37
|
+
finally:
|
|
38
|
+
device.__sealed__ = True
|
|
39
|
+
objects.append(device)
|
|
40
|
+
globals()[var_name] = device # Add to globals so tab complete works
|
|
41
|
+
logger.notify("{} to {} as {}".format(verb, display_name, var_name))
|
|
42
|
+
|
|
43
|
+
# Subscribe to disappearance of the device
|
|
44
|
+
device._on_lost.add_done_callback(lambda x: lost_device(var_name, shutdown_token, logger))
|
|
45
|
+
|
|
46
|
+
def lost_device(interactive_name, shutdown_token, logger):
|
|
47
|
+
"""
|
|
48
|
+
Handles the disappearance of a device by displaying
|
|
49
|
+
a message.
|
|
50
|
+
"""
|
|
51
|
+
if not shutdown_token[0]:
|
|
52
|
+
logger.warn("Oh no {} disappeared".format(interactive_name))
|
|
53
|
+
|
|
54
|
+
def get_user_name(interactive_variables, obj):
|
|
55
|
+
queue = [(k, v) for k, v in interactive_variables.items() if isinstance(v, fibre.libfibre.RemoteObject)]
|
|
56
|
+
|
|
57
|
+
if not isinstance(obj, fibre.libfibre.RemoteObject):
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
while len(queue):
|
|
61
|
+
k, v = queue.pop(0)
|
|
62
|
+
if v == obj:
|
|
63
|
+
return k
|
|
64
|
+
for key in dir(v.__class__):
|
|
65
|
+
class_member = getattr(v.__class__, key)
|
|
66
|
+
if not key.startswith('_') and isinstance(class_member, fibre.libfibre.RemoteAttribute):
|
|
67
|
+
queue.append((k + "." + (key if not class_member._magic_getter else "_" + key + "_property"), class_member._get_obj(v)))
|
|
68
|
+
|
|
69
|
+
return "anonymous_remote_object_" + str(self._obj_handle)
|
|
70
|
+
|
|
71
|
+
def launch_shell(args, mount,
|
|
72
|
+
interactive_variables,
|
|
73
|
+
objects,
|
|
74
|
+
print_banner, print_help,
|
|
75
|
+
logger):
|
|
76
|
+
"""
|
|
77
|
+
Launches an interactive python or IPython command line
|
|
78
|
+
interface.
|
|
79
|
+
As devices are connected they are made available as
|
|
80
|
+
"dev0", "dev1", ...
|
|
81
|
+
The names of the variables can be customized by setting branding_short.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
discovered_devices = []
|
|
85
|
+
shutdown_token = [False]
|
|
86
|
+
globals().update(interactive_variables)
|
|
87
|
+
|
|
88
|
+
fibre.libfibre.get_user_name = lambda obj: get_user_name(interactive_variables, obj)
|
|
89
|
+
|
|
90
|
+
# Connect to device
|
|
91
|
+
with fibre.Domain(args.path) as domain:
|
|
92
|
+
on_discovery = lambda dev: discovered_device(dev, interactive_variables, objects, discovered_devices, mount, shutdown_token, logger)
|
|
93
|
+
discovery = domain.run_discovery(on_discovery)
|
|
94
|
+
|
|
95
|
+
# Check if IPython is installed
|
|
96
|
+
if args.no_ipython:
|
|
97
|
+
use_ipython = False
|
|
98
|
+
else:
|
|
99
|
+
try:
|
|
100
|
+
import IPython
|
|
101
|
+
use_ipython = True
|
|
102
|
+
except:
|
|
103
|
+
print("Warning: you don't have IPython installed.")
|
|
104
|
+
print("If you want to have an improved interactive console with pretty colors,")
|
|
105
|
+
print("you should install IPython\n")
|
|
106
|
+
use_ipython = False
|
|
107
|
+
|
|
108
|
+
interactive_variables["help"] = lambda: print_help(args, len(discovered_devices) > 0)
|
|
109
|
+
|
|
110
|
+
# If IPython is installed, embed IPython shell, otherwise embed regular shell
|
|
111
|
+
if use_ipython:
|
|
112
|
+
# Override help function # pylint: disable=W0612
|
|
113
|
+
help = lambda: print_help(args, len(discovered_devices) > 0)
|
|
114
|
+
# to fix broken "%run -i script.py"
|
|
115
|
+
locals()['__name__'] = globals()['__name__']
|
|
116
|
+
|
|
117
|
+
if IPython.core.getipython.get_ipython() is None:
|
|
118
|
+
console = IPython.terminal.embed.InteractiveShellEmbed.instance(banner1='')
|
|
119
|
+
else:
|
|
120
|
+
console = IPython.terminal.embed.InteractiveShellEmbed(banner1='')
|
|
121
|
+
|
|
122
|
+
# hack to make IPython look like the regular console
|
|
123
|
+
console.runcode = console.run_cell
|
|
124
|
+
interact = console
|
|
125
|
+
|
|
126
|
+
# Catch ObjectLostError (since disconnect is not always an error)
|
|
127
|
+
default_exception_hook = console._showtraceback
|
|
128
|
+
def filtered_exception_hook(ex_class, ex, trace):
|
|
129
|
+
if(ex_class.__module__+'.'+ex_class.__name__ != 'fibre.libfibre.ObjectLostError'):
|
|
130
|
+
default_exception_hook(ex_class,ex,trace)
|
|
131
|
+
|
|
132
|
+
console._showtraceback = filtered_exception_hook
|
|
133
|
+
else:
|
|
134
|
+
# Enable tab complete if possible
|
|
135
|
+
try:
|
|
136
|
+
import readline # Works only on Unix
|
|
137
|
+
readline.parse_and_bind("tab: complete")
|
|
138
|
+
except:
|
|
139
|
+
sudo_prefix = "" if platform.system() == "Windows" else "sudo "
|
|
140
|
+
print("Warning: could not enable tab-complete. User experience will suffer.\n"
|
|
141
|
+
"Run `{}pip install readline` and then restart this script to fix this."
|
|
142
|
+
.format(sudo_prefix))
|
|
143
|
+
|
|
144
|
+
import code
|
|
145
|
+
console = code.InteractiveConsole(locals=interactive_variables)
|
|
146
|
+
interact = lambda: console.interact(banner='')
|
|
147
|
+
|
|
148
|
+
# Catch ObjectLostError (since disconnect is not alway an error)
|
|
149
|
+
console.runcode("import sys")
|
|
150
|
+
console.runcode("default_exception_hook = sys.excepthook")
|
|
151
|
+
console.runcode("def filtered_exception_hook(ex_class, ex, trace):\n"
|
|
152
|
+
" if ex_class.__module__ + '.' + ex_class.__name__ != 'fibre.libfibre.ObjectLostError':\n"
|
|
153
|
+
" default_exception_hook(ex_class,ex,trace)")
|
|
154
|
+
console.runcode("sys.excepthook=filtered_exception_hook")
|
|
155
|
+
|
|
156
|
+
# Launch shell
|
|
157
|
+
print_banner()
|
|
158
|
+
logger._skip_bottom_line = True
|
|
159
|
+
interact()
|
|
160
|
+
|
|
161
|
+
shutdown_token[0] = True
|
|
162
|
+
discovery.stop()
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
|
|
2
|
+
import sys
|
|
3
|
+
import time
|
|
4
|
+
import threading
|
|
5
|
+
import platform
|
|
6
|
+
import subprocess
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
if platform.system() == 'Windows':
|
|
11
|
+
import win32console
|
|
12
|
+
# TODO: we should win32console anyway so we could just omit colorama
|
|
13
|
+
import colorama
|
|
14
|
+
colorama.init()
|
|
15
|
+
except ImportError:
|
|
16
|
+
print("Could not init terminal features.")
|
|
17
|
+
sys.stdout.flush()
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
## Log utils ##
|
|
22
|
+
|
|
23
|
+
# TODO: use "logging" from Python standard library instead of this
|
|
24
|
+
class Logger():
|
|
25
|
+
"""
|
|
26
|
+
Logs messages to stdout
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
COLOR_DEFAULT = 0
|
|
30
|
+
COLOR_GREEN = 1
|
|
31
|
+
COLOR_CYAN = 2
|
|
32
|
+
COLOR_YELLOW = 3
|
|
33
|
+
COLOR_RED = 4
|
|
34
|
+
|
|
35
|
+
_VT100Colors = {
|
|
36
|
+
COLOR_GREEN: '\x1b[92;1m',
|
|
37
|
+
COLOR_CYAN: '\x1b[96;1m',
|
|
38
|
+
COLOR_YELLOW: '\x1b[93;1m',
|
|
39
|
+
COLOR_RED: '\x1b[91;1m',
|
|
40
|
+
COLOR_DEFAULT: '\x1b[0m'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
_Win32Colors = {
|
|
44
|
+
COLOR_GREEN: 0x0A,
|
|
45
|
+
COLOR_CYAN: 0x0B,
|
|
46
|
+
COLOR_YELLOW: 0x0E,
|
|
47
|
+
COLOR_RED: 0x0C,
|
|
48
|
+
COLOR_DEFAULT: 0x07
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
def __init__(self, verbose=True):
|
|
52
|
+
self._prefix = ''
|
|
53
|
+
self._skip_bottom_line = False # If true, messages are printed one line above the cursor
|
|
54
|
+
self._verbose = verbose
|
|
55
|
+
self._print_lock = threading.Lock()
|
|
56
|
+
if platform.system() == 'Windows':
|
|
57
|
+
self._stdout_buf = win32console.GetStdHandle(win32console.STD_OUTPUT_HANDLE)
|
|
58
|
+
|
|
59
|
+
def indent(self, prefix=' '):
|
|
60
|
+
indented_logger = Logger()
|
|
61
|
+
indented_logger._prefix = self._prefix + prefix
|
|
62
|
+
return indented_logger
|
|
63
|
+
|
|
64
|
+
def print_on_second_last_line(self, text, color):
|
|
65
|
+
"""
|
|
66
|
+
Prints a text on the second last line.
|
|
67
|
+
This can be used to print a message above the command
|
|
68
|
+
prompt. If the command prompt spans multiple lines
|
|
69
|
+
there will be glitches.
|
|
70
|
+
If the printed text spans multiple lines there will also
|
|
71
|
+
be glitches (though this could be fixed).
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
if platform.system() == 'Windows':
|
|
75
|
+
# Windows <10 doesn't understand VT100 escape codes and the colorama
|
|
76
|
+
# also doesn't support the specific escape codes we need so we use the
|
|
77
|
+
# native Win32 API.
|
|
78
|
+
info = self._stdout_buf.GetConsoleScreenBufferInfo()
|
|
79
|
+
cursor_pos = info['CursorPosition']
|
|
80
|
+
scroll_rect=win32console.PySMALL_RECTType(
|
|
81
|
+
Left=0, Top=1,
|
|
82
|
+
Right=info['Window'].Right,
|
|
83
|
+
Bottom=cursor_pos.Y-1)
|
|
84
|
+
scroll_dest = win32console.PyCOORDType(scroll_rect.Left, scroll_rect.Top-1)
|
|
85
|
+
self._stdout_buf.ScrollConsoleScreenBuffer(
|
|
86
|
+
scroll_rect, scroll_rect, scroll_dest, # clipping rect is same as scroll rect
|
|
87
|
+
u' ', Logger._Win32Colors[color]) # fill with empty cells with the desired color attributes
|
|
88
|
+
line_start = win32console.PyCOORDType(0, cursor_pos.Y-1)
|
|
89
|
+
self._stdout_buf.WriteConsoleOutputCharacter(text, line_start)
|
|
90
|
+
|
|
91
|
+
else:
|
|
92
|
+
# Assume we're in a terminal that interprets VT100 escape codes.
|
|
93
|
+
# TODO: test on macOS
|
|
94
|
+
|
|
95
|
+
# Escape character sequence:
|
|
96
|
+
# ESC 7: store cursor position
|
|
97
|
+
# ESC 1A: move cursor up by one
|
|
98
|
+
# ESC 1S: scroll entire viewport by one
|
|
99
|
+
# ESC 1L: insert 1 line at cursor position
|
|
100
|
+
# (print text)
|
|
101
|
+
# ESC 8: restore old cursor position
|
|
102
|
+
|
|
103
|
+
self._print_lock.acquire()
|
|
104
|
+
sys.stdout.write('\x1b7\x1b[1A\x1b[1S\x1b[1L')
|
|
105
|
+
sys.stdout.write(Logger._VT100Colors[color] + text + Logger._VT100Colors[Logger.COLOR_DEFAULT])
|
|
106
|
+
sys.stdout.write('\x1b8')
|
|
107
|
+
sys.stdout.flush()
|
|
108
|
+
self._print_lock.release()
|
|
109
|
+
|
|
110
|
+
def print_colored(self, text, color):
|
|
111
|
+
if self._skip_bottom_line:
|
|
112
|
+
self.print_on_second_last_line(text, color)
|
|
113
|
+
else:
|
|
114
|
+
# On Windows, colorama does the job of interpreting the VT100 escape sequences
|
|
115
|
+
self._print_lock.acquire()
|
|
116
|
+
sys.stdout.write(Logger._VT100Colors[color] + text + Logger._VT100Colors[Logger.COLOR_DEFAULT] + '\n')
|
|
117
|
+
sys.stdout.flush()
|
|
118
|
+
self._print_lock.release()
|
|
119
|
+
|
|
120
|
+
def debug(self, text):
|
|
121
|
+
if self._verbose:
|
|
122
|
+
self.print_colored(self._prefix + text, Logger.COLOR_DEFAULT)
|
|
123
|
+
def success(self, text):
|
|
124
|
+
self.print_colored(self._prefix + text, Logger.COLOR_GREEN)
|
|
125
|
+
def info(self, text):
|
|
126
|
+
self.print_colored(self._prefix + text, Logger.COLOR_DEFAULT)
|
|
127
|
+
def notify(self, text):
|
|
128
|
+
self.print_colored(self._prefix + text, Logger.COLOR_CYAN)
|
|
129
|
+
def warn(self, text):
|
|
130
|
+
self.print_colored(self._prefix + text, Logger.COLOR_YELLOW)
|
|
131
|
+
def error(self, text):
|
|
132
|
+
# TODO: write to stderr
|
|
133
|
+
self.print_colored(self._prefix + text, Logger.COLOR_RED)
|
odrive/release_api.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import enum
|
|
2
|
+
import hashlib
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import appdirs
|
|
8
|
+
|
|
9
|
+
from odrive.api_client import ApiClient
|
|
10
|
+
from odrive.crypto import safe_b64decode, safe_b64encode
|
|
11
|
+
from odrive.hw_version import HwVersion
|
|
12
|
+
|
|
13
|
+
class ReleaseApi():
|
|
14
|
+
BASE_URL = '/releases'
|
|
15
|
+
|
|
16
|
+
def __init__(self, api_client: 'ApiClient'):
|
|
17
|
+
self._api_client = api_client
|
|
18
|
+
|
|
19
|
+
async def get_index(self, release_type: str):
|
|
20
|
+
outputs = await self._api_client.call('GET', ReleaseApi.BASE_URL + '/' + release_type + '/index')
|
|
21
|
+
return ReleaseIndex(outputs['files'], outputs['commits'], outputs['channels'])
|
|
22
|
+
|
|
23
|
+
async def load(self, manifest: dict, force_download: bool = False):
|
|
24
|
+
"""
|
|
25
|
+
Downloads a firmware file and saves it in a cache directory.
|
|
26
|
+
If the file was already cached before, it is loaded from disk instead
|
|
27
|
+
(unless force_download is True).
|
|
28
|
+
Returns the path of the cached file.
|
|
29
|
+
"""
|
|
30
|
+
cache_dir = os.path.join(appdirs.user_cache_dir("odrivetool", "ODrive Robotics"), 'firmware')
|
|
31
|
+
os.makedirs(cache_dir, exist_ok=True)
|
|
32
|
+
cache_path = os.path.join(cache_dir, manifest['content_key'] + '.elf')
|
|
33
|
+
|
|
34
|
+
if force_download or not os.path.isfile(cache_path):
|
|
35
|
+
content = await self._api_client.download(manifest['url'])
|
|
36
|
+
with open(cache_path, 'wb') as fp:
|
|
37
|
+
fp.write(content)
|
|
38
|
+
|
|
39
|
+
return cache_path
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class VersionRelationship(enum.Enum):
|
|
43
|
+
UNKNOWN = enum.auto()
|
|
44
|
+
EQUAL = enum.auto()
|
|
45
|
+
UPGRADE = enum.auto()
|
|
46
|
+
DOWNGRADE = enum.auto()
|
|
47
|
+
|
|
48
|
+
class ChannelNotFoundError(Exception):
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
class FirmwareNotFoundError(Exception):
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
class ReleaseIndex():
|
|
55
|
+
def __init__(self, files, commits, channels):
|
|
56
|
+
self._files = {safe_b64decode(f['content']): f for f in files}
|
|
57
|
+
self._commits = commits
|
|
58
|
+
for c in commits:
|
|
59
|
+
c['content'] = safe_b64decode(c['content'])
|
|
60
|
+
for c in commits:
|
|
61
|
+
if 'board' in c:
|
|
62
|
+
c['board'] = HwVersion.from_tuple(c['board'])
|
|
63
|
+
self._open_channels = {c['channel']: c['commits'] for c in channels if not c['closed']}
|
|
64
|
+
self._closed_channels = {c['channel']: c['commits'] for c in channels if c['closed']}
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def open_channel_names(self):
|
|
68
|
+
return list(self._open_channels.keys())
|
|
69
|
+
|
|
70
|
+
def _get_filtered_versions(self, channel_info: list, has_file: Optional[str], **qualifiers: dict) -> list:
|
|
71
|
+
"""
|
|
72
|
+
Returns a list of version info objects where all qualifiers match and
|
|
73
|
+
where the version name is contained in channel_info.
|
|
74
|
+
|
|
75
|
+
The list is ordered "most relevant first".
|
|
76
|
+
|
|
77
|
+
Specifically, it is sorted on two levels:
|
|
78
|
+
version name (as ordered in channel_info)
|
|
79
|
+
variant ("internal" before "public" before other or unspecified)
|
|
80
|
+
"""
|
|
81
|
+
if 'product' in qualifiers:
|
|
82
|
+
import warnings
|
|
83
|
+
warnings.warn('The "product" qualifier is deprecated. Use "board" instead.', DeprecationWarning, stacklevel=0)
|
|
84
|
+
|
|
85
|
+
version_infos = [
|
|
86
|
+
c for c in self._commits
|
|
87
|
+
if (c['commit_hash'] in channel_info) and (has_file is None or has_file in c.get('index', [])) and all((k in c) and (c[k] == v) for k, v in qualifiers.items())
|
|
88
|
+
]
|
|
89
|
+
return sorted(version_infos, key=lambda c: (
|
|
90
|
+
channel_info.index(c['commit_hash']),
|
|
91
|
+
{'internal': 0, 'public': 1}.get(c.get('variant', None), 2)))
|
|
92
|
+
|
|
93
|
+
def _get_channel_info(self, channel: str):
|
|
94
|
+
if channel in self._open_channels:
|
|
95
|
+
return self._open_channels[channel]
|
|
96
|
+
elif channel in self._closed_channels:
|
|
97
|
+
return self._closed_channels[channel]
|
|
98
|
+
else:
|
|
99
|
+
raise ChannelNotFoundError(f"Channel {channel} not found.")
|
|
100
|
+
|
|
101
|
+
def _get_manifest(self, version_info, file: Optional[str] = None):
|
|
102
|
+
content_key = version_info['content']
|
|
103
|
+
|
|
104
|
+
url = self._files[content_key]['url']
|
|
105
|
+
if not file is None:
|
|
106
|
+
base_url = url[:url.rfind('/')] # substring up to last /
|
|
107
|
+
url = base_url + '/' + file
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
'content_key': safe_b64encode(content_key),
|
|
111
|
+
'commit_hash': version_info['commit_hash'],
|
|
112
|
+
'release_date': self._files[content_key]['release_date'],
|
|
113
|
+
'url': url
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
def get_latest(self, channel: str, file: Optional[str] = None, **qualifiers: dict):
|
|
117
|
+
"""
|
|
118
|
+
Checks for the latest firmware on the specified channel with the specified
|
|
119
|
+
qualifiers (product, app).
|
|
120
|
+
|
|
121
|
+
If the specified channel is not found or empty, an exception is thrown.
|
|
122
|
+
Returns a metadata manifest for the release that was found.
|
|
123
|
+
"""
|
|
124
|
+
channel_info = list(reversed(self._get_channel_info(channel)))
|
|
125
|
+
filtered_versions = self._get_filtered_versions(channel_info, has_file=file, **qualifiers)
|
|
126
|
+
|
|
127
|
+
if len(filtered_versions) == 0:
|
|
128
|
+
raise FirmwareNotFoundError()
|
|
129
|
+
|
|
130
|
+
return self._get_manifest(filtered_versions[0], file)
|
|
131
|
+
|
|
132
|
+
def get_version(self, version: str, file: Optional[str] = None, **qualifiers: dict):
|
|
133
|
+
"""
|
|
134
|
+
Looks up the release information for the specified commit with the
|
|
135
|
+
specified qualifiers (product, app).
|
|
136
|
+
|
|
137
|
+
Returns a metadata manifest for the release that was found.
|
|
138
|
+
"""
|
|
139
|
+
filtered_versions = self._get_filtered_versions(channel_info=[version], has_file=file, **qualifiers)
|
|
140
|
+
|
|
141
|
+
if len(filtered_versions) == 0:
|
|
142
|
+
raise FirmwareNotFoundError()
|
|
143
|
+
|
|
144
|
+
return self._get_manifest(filtered_versions[0], file)
|
|
145
|
+
|
|
146
|
+
def compare(self, from_version: Optional[str], to_version: str, channel: str, **qualifiers: dict):
|
|
147
|
+
"""
|
|
148
|
+
Checks if the specified transition is an upgrade or not.
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
return VersionRelationship.UNKNOWN # TODO
|
|
152
|
+
|
|
153
|
+
if from_version is None:
|
|
154
|
+
return VersionRelationship.UNKNOWN
|
|
155
|
+
|
|
156
|
+
filtered_commits = self._get_filtered_versions(**qualifiers)
|
|
157
|
+
|
|
158
|
+
short_hashes = {h[:8]: h for h in filtered_commits.keys()}
|
|
159
|
+
from_version = short_hashes.get(from_version, from_version)
|
|
160
|
+
|
|
161
|
+
if not from_version in filtered_commits or not to_version in filtered_commits:
|
|
162
|
+
return VersionRelationship.UNKNOWN
|
|
163
|
+
|
|
164
|
+
from_file = filtered_commits[from_version]
|
|
165
|
+
to_file = filtered_commits[to_version]
|
|
166
|
+
|
|
167
|
+
if from_file == to_file:
|
|
168
|
+
return VersionRelationship.EQUAL
|
|
169
|
+
|
|
170
|
+
filtered_channel_commits = self._get_filtered_channel_commits(channel, filtered_commits)
|
|
171
|
+
|
|
172
|
+
if not from_file in filtered_channel_commits or not to_file in filtered_channel_commits:
|
|
173
|
+
return VersionRelationship.UNKNOWN
|
|
174
|
+
|
|
175
|
+
if filtered_channel_commits.index(from_file) < filtered_channel_commits.index(to_file):
|
|
176
|
+
return VersionRelationship.UPGRADE
|
|
177
|
+
else:
|
|
178
|
+
return VersionRelationship.DOWNGRADE
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def format_version(version: str):
|
|
182
|
+
if re.match("^[0-9a-fA-F]*$", version) and (len(version) == 8 or len(version) == 40):
|
|
183
|
+
return version.lower()[:8]
|
|
184
|
+
return version
|
odrive/rich_text.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
|
|
2
|
+
import enum
|
|
3
|
+
from typing import Iterable, Union, Optional, List, Tuple, Sequence, cast
|
|
4
|
+
|
|
5
|
+
class Style(enum.IntEnum):
|
|
6
|
+
NONE = 0x00
|
|
7
|
+
BOLD = 0x01
|
|
8
|
+
ITALIC = 0x02
|
|
9
|
+
UNDERLINE = 0x04
|
|
10
|
+
|
|
11
|
+
class Color(enum.Enum):
|
|
12
|
+
DEFAULT = enum.auto() # the terminal's default color
|
|
13
|
+
COLOR_MASK = enum.auto()
|
|
14
|
+
RED = enum.auto()
|
|
15
|
+
YELLOW = enum.auto()
|
|
16
|
+
GREEN = enum.auto()
|
|
17
|
+
CYAN = enum.auto()
|
|
18
|
+
BLUE = enum.auto()
|
|
19
|
+
MAGENTA = enum.auto()
|
|
20
|
+
GRAY = enum.auto()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RichText():
|
|
24
|
+
"""
|
|
25
|
+
Represents a text consisting of styled/colored string segments.
|
|
26
|
+
"""
|
|
27
|
+
segments: List[Tuple[str, Color, Color, Style]]
|
|
28
|
+
|
|
29
|
+
def __init__(self, val: Union[str, 'RichText', Sequence[Union[str, 'RichText']]], foreground: Optional[Color] = None, background: Optional[Color] = None, style: Optional[Style] = None):
|
|
30
|
+
"""
|
|
31
|
+
val: A string, a RichText object or a list of strings or RichText
|
|
32
|
+
objects.
|
|
33
|
+
style: If specified, overrides the style of all segments in `val`.
|
|
34
|
+
If not specified, uncolored strings are set to the default color.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
if not (isinstance(val, tuple) or isinstance(val, list)):
|
|
38
|
+
val = [cast(Union[str, 'RichText'], val)]
|
|
39
|
+
|
|
40
|
+
self.segments = []
|
|
41
|
+
for segment in val:
|
|
42
|
+
if isinstance(segment, str):
|
|
43
|
+
self.segments.append((segment, Color.DEFAULT, Color.DEFAULT, Style.NONE))
|
|
44
|
+
elif isinstance(segment, RichText):
|
|
45
|
+
self.segments.extend(segment.segments)
|
|
46
|
+
else:
|
|
47
|
+
raise Exception("unsupported argument")
|
|
48
|
+
|
|
49
|
+
# Override attributes
|
|
50
|
+
for i, (seg_str, seg_foreground, seg_background, seg_style) in enumerate(self.segments):
|
|
51
|
+
if not style is None:
|
|
52
|
+
seg_style = style
|
|
53
|
+
if not foreground is None:
|
|
54
|
+
seg_foreground = foreground
|
|
55
|
+
if not background is None:
|
|
56
|
+
seg_background = background
|
|
57
|
+
self.segments[i] = (seg_str, seg_foreground, seg_background, seg_style)
|
|
58
|
+
|
|
59
|
+
# Remove empty segments
|
|
60
|
+
i = 0
|
|
61
|
+
while i < len(self.segments):
|
|
62
|
+
if self.segments[i][0] == '':
|
|
63
|
+
self.segments.pop(i)
|
|
64
|
+
else:
|
|
65
|
+
i += 1
|
|
66
|
+
|
|
67
|
+
# Coalesce segments with identical attributes
|
|
68
|
+
i = 1
|
|
69
|
+
while i < len(self.segments):
|
|
70
|
+
if self.segments[i - 1][1:] == self.segments[i][1:]:
|
|
71
|
+
self.segments[i - 1] = (self.segments[i - 1][0] + self.segments.pop(i)[0],) + self.segments[i - 1][1:]
|
|
72
|
+
else:
|
|
73
|
+
i += 1
|
|
74
|
+
|
|
75
|
+
def __add__(self, other):
|
|
76
|
+
if isinstance(other, str) or isinstance(other, RichText):
|
|
77
|
+
return RichText([self, other])
|
|
78
|
+
else:
|
|
79
|
+
raise NotImplementedError(f"can only concatenate str and RichText (not {type(other).__name__}) to RichText.")
|
|
80
|
+
|
|
81
|
+
def __radd__(self, other):
|
|
82
|
+
if isinstance(other, str) or isinstance(other, RichText):
|
|
83
|
+
return RichText([other, self])
|
|
84
|
+
else:
|
|
85
|
+
raise NotImplementedError(f"can only concatenate RichText to str and RichText (not {type(other).__name__}).")
|
|
86
|
+
|
|
87
|
+
def __repr__(self):
|
|
88
|
+
return ''.join([s[0] for s in self.segments])
|
|
89
|
+
|
|
90
|
+
def join(self, others: Iterable[Union['RichText', str]]):
|
|
91
|
+
"""
|
|
92
|
+
Analogous to str.join()
|
|
93
|
+
"""
|
|
94
|
+
segments: List[Union[str, RichText]] = []
|
|
95
|
+
for item in others:
|
|
96
|
+
segments.append(self)
|
|
97
|
+
segments.append(item)
|
|
98
|
+
return RichText(segments[1:]) if len(segments) > 0 else RichText("")
|
|
99
|
+
|
|
100
|
+
def replace(self, old: str, new: str):
|
|
101
|
+
new_segments = [
|
|
102
|
+
RichText(seg[0].replace(old, new), *seg[1:])
|
|
103
|
+
for seg in self.segments
|
|
104
|
+
]
|
|
105
|
+
return RichText(new_segments)
|
|
106
|
+
|
|
107
|
+
_VT100Colors = {
|
|
108
|
+
Color.DEFAULT: 39,
|
|
109
|
+
Color.GREEN: 92,
|
|
110
|
+
Color.CYAN: 96,
|
|
111
|
+
Color.YELLOW: 93,
|
|
112
|
+
Color.RED: 91,
|
|
113
|
+
Color.GRAY: 97,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
def print_rich_text(text: RichText):
|
|
117
|
+
"""
|
|
118
|
+
Prints `text` to stdout using VT100 escape codes for styling.
|
|
119
|
+
"""
|
|
120
|
+
assert isinstance(text, RichText), type(text)
|
|
121
|
+
output = ''
|
|
122
|
+
for substr, foreground, background, style in text.segments:
|
|
123
|
+
output += '\x1b[0' # reset all text attributes
|
|
124
|
+
output += ';' + str(_VT100Colors[foreground])
|
|
125
|
+
output += ';' + str(_VT100Colors[background] + 10)
|
|
126
|
+
if style & Style.BOLD:
|
|
127
|
+
output += ';1'
|
|
128
|
+
if style & Style.UNDERLINE:
|
|
129
|
+
output += ';4'
|
|
130
|
+
output += 'm' + substr
|
|
131
|
+
|
|
132
|
+
if len(text.segments) > 0:
|
|
133
|
+
output += '\x1b[0m' # reset all attributes
|
|
134
|
+
print(output)
|