From 67a1f1d4cce3cfc061d02357375fe983a6a522ea Mon Sep 17 00:00:00 2001 From: Silver Valdvee Date: Mon, 20 Jun 2022 15:06:27 +0300 Subject: [PATCH] Update all dependencies and Python to the latest versions, autoformat using Black, release v1.1.0 (#48) Closes #44 Fixes #43 and fixes #47 by way of updating PyInstaller Co-authored-by: Pavel Kirienko --- .gitmodules | 12 +- README.md | 37 +- appveyor.yml | 44 +- kucher/__init__.py | 18 +- kucher/data_dir.py | 49 +- kucher/fuhrer/__init__.py | 73 +- kucher/libraries/construct | 1 - kucher/libraries/dataclasses | 1 - kucher/libraries/qasync | 1 + kucher/libraries/quamash | 1 - kucher/main.py | 37 +- kucher/model/device_model/__init__.py | 73 +- kucher/model/device_model/commander.py | 65 +- .../device_model/communicator/__init__.py | 13 +- .../device_model/communicator/communicator.py | 212 +-- .../device_model/communicator/messages.py | 381 ++--- kucher/model/device_model/connection.py | 407 ++++-- kucher/model/device_model/device_info_view.py | 162 +- .../model/device_model/general_status_view.py | 413 +++--- kucher/model/device_model/register.py | 96 +- .../device_model/task_statistics_view.py | 162 +- kucher/resources.py | 8 +- kucher/utils.py | 69 +- kucher/version.py | 4 +- kucher/view/device_model_representation.py | 93 +- kucher/view/main_window/__init__.py | 200 ++- .../device_management_widget/__init__.py | 222 +-- .../little_bobby_tables_widget.py | 54 +- .../port_discoverer.py | 43 +- .../view/main_window/log_widget/__init__.py | 122 +- kucher/view/main_window/main_widget.py | 46 +- .../register_view_widget/__init__.py | 296 ++-- .../register_view_widget/_mock_registers.py | 1300 ++++++++++++++--- .../register_view_widget/editor_delegate.py | 116 +- .../import_export_dialog.py | 233 +-- .../main_window/register_view_widget/model.py | 329 +++-- .../style_option_modifying_delegate.py | 16 +- .../register_view_widget/textual.py | 118 +- .../task_statistics_widget/__init__.py | 226 +-- .../telega_control_widget/__init__.py | 88 +- .../active_alerts_widget.py | 19 +- .../control_widget/__init__.py | 137 +- .../hardware_test_control_widget.py | 28 +- .../__init__.py | 47 +- .../calibration_widget.py | 38 +- .../phase_manipulation_widget.py | 97 +- .../scalar_control_widget.py | 154 +- .../control_widget/misc_control_widget.py | 119 +- .../motor_identification_control_widget.py | 60 +- .../control_widget/run_control_widget.py | 187 ++- .../dc_quantities_widget.py | 87 +- .../device_status_widget.py | 61 +- .../hardware_flag_counters_widget.py | 60 +- .../task_specific_status_widget/__init__.py | 42 +- .../task_specific_status_widget/base.py | 12 +- .../fault_status_widget.py | 97 +- .../hardware_test_status_widget.py | 13 +- .../motor_identification_status_widget.py | 13 +- .../placeholder_widget.py | 2 +- .../run_status_widget.py | 270 ++-- .../temperature_widget.py | 83 +- .../vsi_status_widget.py | 56 +- kucher/view/monitored_quantity.py | 78 +- kucher/view/tool_window_manager.py | 128 +- kucher/view/utils.py | 187 ++- kucher/view/widgets/__init__.py | 16 +- kucher/view/widgets/group_box_widget.py | 17 +- .../widgets/spinbox_linked_with_slider.py | 76 +- kucher/view/widgets/tool_window.py | 24 +- .../widgets/value_display_group_widget.py | 51 +- kucher/view/widgets/value_display_widget.py | 86 +- requirements-dev-linux.txt | 6 +- requirements-dev-windows.txt | 6 +- requirements.txt | 9 +- test_linux.sh | 3 +- test_windows.bat | 3 +- 76 files changed, 5229 insertions(+), 2984 deletions(-) delete mode 160000 kucher/libraries/construct delete mode 160000 kucher/libraries/dataclasses create mode 160000 kucher/libraries/qasync delete mode 160000 kucher/libraries/quamash diff --git a/.gitmodules b/.gitmodules index cae8328..1d47d80 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,12 +1,6 @@ [submodule "kucher/libraries/popcop"] path = kucher/libraries/popcop url = https://github.com/Zubax/popcop -[submodule "kucher/libraries/construct"] - path = kucher/libraries/construct - url = https://github.com/construct/construct -[submodule "kucher/libraries/dataclasses"] - path = kucher/libraries/dataclasses - url = https://github.com/ericvsmith/dataclasses -[submodule "kucher/libraries/quamash"] - path = kucher/libraries/quamash - url = https://github.com/harvimt/quamash +[submodule "kucher/libraries/qasync"] + path = kucher/libraries/qasync + url = https://github.com/CabbageDevelopment/qasync.git diff --git a/README.md b/README.md index d732383..3f3639a 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![Join the chat at https://gitter.im/Zubax/general](https://img.shields.io/badge/GITTER-join%20chat-green.svg)](https://gitter.im/Zubax/general) +[![Forum](https://img.shields.io/discourse/https/forum.zubax.com/users.svg?color=e00000)](https://forum.zubax.com) # Kucher @@ -35,9 +35,7 @@ Non-conforming contributions should not be accepted. This section describes how to configure the local system for development. An AMD64 GNU/Linux system is required. -Kucher requires Python version 3.6 or newer. -If your system uses an older version, please refer to the section below to install -Python 3.6 before continuing. +Kucher requires Python version 3.10 or newer. ```bash git clone --recursive https://github.com/Zubax/kucher @@ -67,37 +65,6 @@ On Windows: pytest ``` - -### Getting the right version of Python - -Kucher requires Python 3.6 or newer. -You can check whether you have the right version by running `python3 --version`. -If a newer Python is needed, and you're running Ubuntu or an Ubuntu-based distro such as Mint, -execute the following commands: - -```bash -sudo apt-get install -y git-core curl build-essential libsqlite3-dev -sudo apt-get install -y libbz2-dev libssl-dev libreadline-dev libsqlite3-dev tk-dev libpng-dev libfreetype6-dev -curl -L https://raw.githubusercontent.com/yyuu/pyenv-installer/master/bin/pyenv-installer | bash -``` - -Follow the instructions in the output of the last command above. -**WARNING:** If the above command tells you to use `~/.bash_profile`, -disregard that and use `~/.bashrc` instead. - -Reload the bash profile configuration -(e.g. close the current shell session and open a new one). -Then continue: - -``` -PYTHON_CONFIGURE_OPTS='--enable-shared' pyenv install 3.6.4 -pyenv global 3.6.4 -``` - -If there was a warning that `sqlite3` has not been compiled, -make sure to resolve it first before continuing - `sqlite3` is required by Kucher. -Now run `python3 --version` and ensure that you have v3.6 as default. - ### CI artifacts The CI builds redistributable release binaries automatically. diff --git a/appveyor.yml b/appveyor.yml index 92a28dd..896d2a3 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,44 +1,33 @@ environment: matrix: - # Windows & python 3.6 - - APPVEYOR_BUILD_WORKER_IMAGE: Visual Studio 2017 - PYTHON: "C:\\Python36-x64" + # Windows & python 3.10 + - APPVEYOR_BUILD_WORKER_IMAGE: Visual Studio 2019 + PYTHON: "C:\\Python310-x64" PYTHON_ARCH: "64" - # Windows & python 3.7 - - APPVEYOR_BUILD_WORKER_IMAGE: Visual Studio 2017 - PYTHON: "C:\\Python37-x64" - PYTHON_ARCH: "64" - - # Ubuntu & python 3.6 - - APPVEYOR_BUILD_WORKER_IMAGE: Ubuntu - PYTHON: "3.6" - - # Ubuntu & python 3.7 + # Ubuntu & python 3.10 - APPVEYOR_BUILD_WORKER_IMAGE: Ubuntu - PYTHON: "3.7" + PYTHON: "3.10" stack: python %PYTHON% - +build: false install: - "git submodule update --init --recursive" - cmd: "SET PATH=%PYTHON%;%PATH%" - - cmd: "SET PATH=C:\\Python36-x64\\Scripts;%PATH%" - - cmd: "SET PATH=C:\\Python37-x64\\Scripts;%PATH%" + - cmd: "SET PATH=C:\\Python310-x64\\Scripts;%PATH%" - sh: "lsb_release -a" - "python -V" - "pip --version" - - cmd: "python -m pip install -r requirements.txt" - - cmd: "python -m pip install -r requirements-dev-windows.txt" + - cmd: "python -m pip install --upgrade pip" + - cmd: "python -m pip install -r requirements.txt" + - cmd: "python -m pip install -r requirements-dev-windows.txt" + - sh: "sudo add-apt-repository -y ppa:deadsnakes/ppa" + - sh: "sudo apt-get -y install python3.10-dev" + - sh: "pip install --upgrade pip" - sh: "pip install -r requirements.txt" - sh: "pip install -r requirements-dev-linux.txt" - - sh: "sudo add-apt-repository -y ppa:deadsnakes/ppa " - sh: "sudo apt-get update" - - sh: "sudo apt-get -y install python3.6-dev" - - sh: "sudo apt-get -y install python3.7-dev" - - -build: off +#build: off test_script: - cmd: "test_windows.bat" @@ -46,7 +35,8 @@ test_script: # https://doc.qt.io/qt-5/embedded-linux.html # https://github.com/ariya/phantomjs/issues/14376 - sh: "export QT_QPA_PLATFORM=offscreen" - - sh: "bash test_linux.sh" + - sh: "pytest" + - sh: "black kucher/fuhrer kucher/model kucher/view kucher/*.py --check" after_test: - cmd: "7z a zubax-kucher.7z *" @@ -55,4 +45,4 @@ after_test: - sh: "bash build_linux.sh" - cmd: "build_windows.bat" - cmd: "appveyor PushArtifact dist\\Kucher.exe" - - sh: "appveyor PushArtifact dist/Kucher" + - sh: "appveyor PushArtifact dist/Kucher" \ No newline at end of file diff --git a/kucher/__init__.py b/kucher/__init__.py index bba3eb4..2ab3844 100644 --- a/kucher/__init__.py +++ b/kucher/__init__.py @@ -16,8 +16,8 @@ import os import sys -if sys.version_info[:2] < (3, 6): - raise ImportError('A newer version of Python is required') +if sys.version_info[:2] < (3, 10): + raise ImportError("A newer version of Python is required") from .version import * # noqa from .main import main # noqa @@ -27,20 +27,14 @@ # The list of paths defined here can also be used by external packaging tools such as PyInstaller. # _SOURCE_PATH = os.path.abspath(os.path.dirname(__file__)) -THIRDPARTY_PATH_ROOT = os.path.join(_SOURCE_PATH, 'libraries') +THIRDPARTY_PATH_ROOT = os.path.join(_SOURCE_PATH, "libraries") THIRDPARTY_PATH = [ os.path.join(THIRDPARTY_PATH_ROOT), - os.path.join(THIRDPARTY_PATH_ROOT, 'popcop', 'python'), - os.path.join(THIRDPARTY_PATH_ROOT, 'construct'), - os.path.join(THIRDPARTY_PATH_ROOT, 'quamash'), + os.path.join(THIRDPARTY_PATH_ROOT, "popcop", "python"), + os.path.join(THIRDPARTY_PATH_ROOT, "construct"), + os.path.join(THIRDPARTY_PATH_ROOT, "qasync"), ] -# 'dataclasses' module is included in Python libraries since version 3.7. For Python versions below, the dataclass -# module located in the 'libraries' directory will be used. It is not compatible with Python 3.7, so we only declare -# its path if Python version is below 3.7. Otherwise, the built-in module will be used by default. -if sys.version_info[:2] < (3, 7): - THIRDPARTY_PATH.append(os.path.join(THIRDPARTY_PATH_ROOT, 'dataclasses')) - for tp in THIRDPARTY_PATH: sys.path.insert(0, tp) diff --git a/kucher/data_dir.py b/kucher/data_dir.py index 617f512..a5ebcb3 100644 --- a/kucher/data_dir.py +++ b/kucher/data_dir.py @@ -24,13 +24,15 @@ _logger = getLogger(__name__) -if hasattr(sys, 'getwindowsversion'): - _appdata_env = os.getenv('LOCALAPPDATA') or os.getenv('APPDATA') - USER_SPECIFIC_DATA_DIR = os.path.abspath(os.path.join(_appdata_env, 'Zubax', 'Kucher')) +if hasattr(sys, "getwindowsversion"): + _appdata_env = os.getenv("LOCALAPPDATA") or os.getenv("APPDATA") + USER_SPECIFIC_DATA_DIR = os.path.abspath( + os.path.join(_appdata_env, "Zubax", "Kucher") + ) else: - USER_SPECIFIC_DATA_DIR = os.path.expanduser('~/.zubax/kucher') + USER_SPECIFIC_DATA_DIR = os.path.expanduser("~/.zubax/kucher") -LOG_DIR = os.path.join(USER_SPECIFIC_DATA_DIR, 'log') +LOG_DIR = os.path.join(USER_SPECIFIC_DATA_DIR, "log") _MAX_AGE_OF_LOG_FILE_IN_DAYS = 30 @@ -49,15 +51,19 @@ def _create_directory(*path_items): def _old_log_cleaner(): # noinspection PyBroadException try: - _logger.info('Old log cleaner is waiting...') + _logger.info("Old log cleaner is waiting...") # This delay is needed to avoid slowing down application startup, when disk access rates may be high time.sleep(10) - _logger.info('Old log cleaner is ready to work now') + _logger.info("Old log cleaner is ready to work now") - files_sorted_new_to_old = map(lambda x: os.path.join(LOG_DIR, x), os.listdir(LOG_DIR)) - files_sorted_new_to_old = list(sorted(files_sorted_new_to_old, key=lambda x: -os.path.getctime(x))) - _logger.info('Log files found: %r', files_sorted_new_to_old) + files_sorted_new_to_old = map( + lambda x: os.path.join(LOG_DIR, x), os.listdir(LOG_DIR) + ) + files_sorted_new_to_old = list( + sorted(files_sorted_new_to_old, key=lambda x: -os.path.getctime(x)) + ) + _logger.info("Log files found: %r", files_sorted_new_to_old) num_kept = 0 num_removed = 0 @@ -66,7 +72,9 @@ def _old_log_cleaner(): for f in files_sorted_new_to_old: creation_time = os.path.getctime(f) - too_old = (current_time - creation_time) / (24 * 3600) >= _MAX_AGE_OF_LOG_FILE_IN_DAYS + too_old = (current_time - creation_time) / ( + 24 * 3600 + ) >= _MAX_AGE_OF_LOG_FILE_IN_DAYS too_small = os.path.getsize(f) < _MIN_USEFUL_LOG_FILE_SIZE too_many = num_kept >= _MAX_LOG_FILES_TO_KEEP @@ -75,16 +83,21 @@ def _old_log_cleaner(): try: os.unlink(f) except Exception: - _logger.exception('Could not remove file %r', f) + _logger.exception("Could not remove file %r", f) else: - _logger.info(f'File {f} removed successfully; old={too_old} small={too_small} many={too_many}') + _logger.info( + f"File {f} removed successfully; old={too_old} small={too_small} many={too_many}" + ) num_removed += 1 else: num_kept += 1 - _logger.info('Background old log cleaner has finished successfully; total files removed: %r', num_removed) + _logger.info( + "Background old log cleaner has finished successfully; total files removed: %r", + num_removed, + ) except Exception: - _logger.exception('Background old log cleaner has failed') + _logger.exception("Background old log cleaner has failed") def init(): @@ -93,7 +106,9 @@ def init(): _create_directory(USER_SPECIFIC_DATA_DIR) _create_directory(LOG_DIR) except Exception: - _logger.exception('Could not create user-specific application data directories') + _logger.exception("Could not create user-specific application data directories") # Fire and forget - threading.Thread(target=_old_log_cleaner, name='old_log_cleaner', daemon=True).start() + threading.Thread( + target=_old_log_cleaner, name="old_log_cleaner", daemon=True + ).start() diff --git a/kucher/fuhrer/__init__.py b/kucher/fuhrer/__init__.py index d3ef495..16bb783 100644 --- a/kucher/fuhrer/__init__.py +++ b/kucher/fuhrer/__init__.py @@ -17,7 +17,11 @@ import functools from logging import getLogger from kucher.model import device_model -from kucher.model.device_model import DeviceModel, DeviceInfoView, ConnectionNotEstablishedException +from kucher.model.device_model import ( + DeviceModel, + DeviceInfoView, + ConnectionNotEstablishedException, +) from kucher.view.main_window import MainWindow from kucher.view import device_model_representation @@ -29,46 +33,67 @@ class Fuhrer: def __init__(self): self._device_model: DeviceModel = DeviceModel(asyncio.get_event_loop()) - self._main_window = MainWindow(on_close=self._on_main_window_close, - on_connection_request=self._on_connection_request, - on_disconnection_request=self._on_disconnection_request, - on_task_statistics_request=_return_none_if_not_connected( - self._device_model.get_task_statistics), - commander=self._device_model.commander) + self._main_window = MainWindow( + on_close=self._on_main_window_close, + on_connection_request=self._on_connection_request, + on_disconnection_request=self._on_disconnection_request, + on_task_statistics_request=_return_none_if_not_connected( + self._device_model.get_task_statistics + ), + commander=self._device_model.commander, + ) self._main_window.show() - self._device_model.device_status_update_event.connect(self._main_window.on_general_status_update) - self._device_model.connection_status_change_event.connect(self._on_connection_status_change) - self._device_model.log_line_reception_event.connect(self._main_window.on_log_line_reception) + self._device_model.device_status_update_event.connect( + self._main_window.on_general_status_update + ) + self._device_model.connection_status_change_event.connect( + self._on_connection_status_change + ) + self._device_model.log_line_reception_event.connect( + self._main_window.on_log_line_reception + ) self._should_stop = False def _on_main_window_close(self): - _logger.info('The main window is closing, asking the controller task to stop') + _logger.info("The main window is closing, asking the controller task to stop") self._should_stop = True - def _on_connection_status_change(self, device_info_or_error: typing.Union[DeviceInfoView, str, Exception]): + def _on_connection_status_change( + self, device_info_or_error: typing.Union[DeviceInfoView, str, Exception] + ): if isinstance(device_info_or_error, DeviceInfoView): - self._main_window.on_connection_established(_make_view_basic_device_info(device_info_or_error), - list(self._device_model.registers.values())) + self._main_window.on_connection_established( + _make_view_basic_device_info(device_info_or_error), + list(self._device_model.registers.values()), + ) elif isinstance(device_info_or_error, (str, Exception)): - reason = str(device_info_or_error) or repr(device_info_or_error) # Some exceptions may not contain text + reason = str(device_info_or_error) or repr( + device_info_or_error + ) # Some exceptions may not contain text self._main_window.on_connection_loss(reason) else: - raise TypeError(f'Invalid argument: {type(device_info_or_error)}') + raise TypeError(f"Invalid argument: {type(device_info_or_error)}") - async def _on_connection_request(self, port: str) -> device_model_representation.BasicDeviceInfo: + async def _on_connection_request( + self, port: str + ) -> device_model_representation.BasicDeviceInfo: assert not self._device_model.is_connected def on_progress_report(stage_description: str, progress: float): - self._main_window.on_connection_initialization_progress_report(stage_description, progress) + self._main_window.on_connection_initialization_progress_report( + stage_description, progress + ) - device_info = await self._device_model.connect(port_name=port, on_progress_report=on_progress_report) + device_info = await self._device_model.connect( + port_name=port, on_progress_report=on_progress_report + ) return _make_view_basic_device_info(device_info) async def _on_disconnection_request(self) -> None: - await self._device_model.disconnect('User request') + await self._device_model.disconnect("User request") async def run(self): # noinspection PyBroadException @@ -76,9 +101,9 @@ async def run(self): while not self._should_stop: await asyncio.sleep(1) except Exception: - _logger.exception('Unhandled exception in controller task') + _logger.exception("Unhandled exception in controller task") else: - _logger.info('Controller task is stopping normally') + _logger.info("Controller task is stopping normally") def _return_none_if_not_connected(target: typing.Callable): @@ -92,7 +117,9 @@ async def decorator(*args, **kwargs): return decorator -def _make_view_basic_device_info(di: device_model.DeviceInfoView) -> device_model_representation.BasicDeviceInfo: +def _make_view_basic_device_info( + di: device_model.DeviceInfoView, +) -> device_model_representation.BasicDeviceInfo: """ Decouples the model-specific device info representation from the view-specific device info representation. """ diff --git a/kucher/libraries/construct b/kucher/libraries/construct deleted file mode 160000 index cc6ec03..0000000 --- a/kucher/libraries/construct +++ /dev/null @@ -1 +0,0 @@ -Subproject commit cc6ec03bebc888d7b4a9aaab235ce321ebc44981 diff --git a/kucher/libraries/dataclasses b/kucher/libraries/dataclasses deleted file mode 160000 index e968e7f..0000000 --- a/kucher/libraries/dataclasses +++ /dev/null @@ -1 +0,0 @@ -Subproject commit e968e7f71870bb28cc0eeaa7c5ef3b274f0e2958 diff --git a/kucher/libraries/qasync b/kucher/libraries/qasync new file mode 160000 index 0000000..0fb247e --- /dev/null +++ b/kucher/libraries/qasync @@ -0,0 +1 @@ +Subproject commit 0fb247e35c292f74667ddd3978511d9754ebbf80 diff --git a/kucher/libraries/quamash b/kucher/libraries/quamash deleted file mode 160000 index e513b30..0000000 --- a/kucher/libraries/quamash +++ /dev/null @@ -1 +0,0 @@ -Subproject commit e513b30f137415c5e098602fa383e45debab85e7 diff --git a/kucher/main.py b/kucher/main.py index 537073a..8318693 100644 --- a/kucher/main.py +++ b/kucher/main.py @@ -17,19 +17,20 @@ import logging # Configuring logging before other packages are imported. -if '--debug' in sys.argv: - sys.argv.remove('--debug') +if "--debug" in sys.argv: + sys.argv.remove("--debug") LOGGING_LEVEL = logging.DEBUG else: LOGGING_LEVEL = logging.INFO -logging.basicConfig(stream=sys.stderr, - level=LOGGING_LEVEL, - format='%(asctime)s pid=%(process)-5d %(levelname)s: %(name)s: %(message)s') +logging.basicConfig( + stream=sys.stderr, + level=LOGGING_LEVEL, + format="%(asctime)s pid=%(process)-5d %(levelname)s: %(name)s: %(message)s", +) -logging.getLogger('quamash').setLevel(logging.INFO) -_logger = logging.getLogger(__name__.replace('__', '')) +_logger = logging.getLogger(__name__.replace("__", "")) def main() -> int: @@ -42,20 +43,26 @@ def main() -> int: import asyncio import datetime from PyQt5.QtWidgets import QApplication - from quamash import QEventLoop + from qasync import QEventLoop from . import data_dir, version, resources from .fuhrer import Fuhrer data_dir.init() # Only the main process will be logging into the file - log_file_name = os.path.join(data_dir.LOG_DIR, f'{datetime.datetime.now():%Y%m%d-%H%M%S}-{os.getpid()}.log') + log_file_name = os.path.join( + data_dir.LOG_DIR, f"{datetime.datetime.now():%Y%m%d-%H%M%S}-{os.getpid()}.log" + ) file_handler = logging.FileHandler(log_file_name) file_handler.setLevel(LOGGING_LEVEL) - file_handler.setFormatter(logging.Formatter('%(asctime)s pid=%(process)-5d %(levelname)-8s %(name)s: %(message)s')) + file_handler.setFormatter( + logging.Formatter( + "%(asctime)s pid=%(process)-5d %(levelname)-8s %(name)s: %(message)s" + ) + ) logging.root.addHandler(file_handler) - if '--profile' in sys.argv: + if "--profile" in sys.argv: try: # noinspection PyPep8Naming import cProfile as profile @@ -64,7 +71,7 @@ def main() -> int: def save_profile(): prof.disable() - prof.dump_stats(log_file_name.replace('.log', '.pstat')) + prof.dump_stats(log_file_name.replace(".log", ".pstat")) prof = profile.Profile() atexit.register(save_profile) @@ -76,7 +83,11 @@ def save_profile(): asyncio.set_event_loop(loop) # Running the application - _logger.info('Starting version %r; package root: %r', version.__version__, resources.PACKAGE_ROOT) + _logger.info( + "Starting version %r; package root: %r", + version.__version__, + resources.PACKAGE_ROOT, + ) with loop: ctrl = Fuhrer() loop.run_until_complete(ctrl.run()) diff --git a/kucher/model/device_model/__init__.py b/kucher/model/device_model/__init__.py index 2eb1a50..608ad68 100644 --- a/kucher/model/device_model/__init__.py +++ b/kucher/model/device_model/__init__.py @@ -19,8 +19,14 @@ from .communicator import MessageType, Message from .connection import connect, Connection, ConnectionNotEstablishedException from .device_info_view import DeviceInfoView -from .general_status_view import GeneralStatusView, TaskID, TaskSpecificStatusReport,\ - ControlMode, MotorIdentificationMode, LowLevelManipulationMode +from .general_status_view import ( + GeneralStatusView, + TaskID, + TaskSpecificStatusReport, + ControlMode, + MotorIdentificationMode, + LowLevelManipulationMode, +) from .task_statistics_view import TaskStatisticsView from .commander import Commander from .register import Register @@ -120,19 +126,22 @@ def registers(self) -> typing.Dict[str, Register]: return self._conn.registers if self.is_connected else {} async def connect( - self, - port_name: str, - on_progress_report: typing.Optional[typing.Callable[[str, float], None]] = None) -> DeviceInfoView: + self, + port_name: str, + on_progress_report: typing.Optional[typing.Callable[[str, float], None]] = None, + ) -> DeviceInfoView: await self.disconnect() assert not self._conn - self._conn = await connect(event_loop=self._event_loop, - port_name=port_name, - on_connection_loss=self._on_connection_loss, - on_general_status_update=self._evt_device_status_update, - on_log_line=self._evt_log_line, - on_progress_report=on_progress_report, - general_status_update_period=DEFAULT_GENERAL_STATUS_UPDATE_PERIOD) + self._conn = await connect( + event_loop=self._event_loop, + port_name=port_name, + on_connection_loss=self._on_connection_loss, + on_general_status_update=self._evt_device_status_update, + on_log_line=self._evt_log_line, + on_progress_report=on_progress_report, + general_status_update_period=DEFAULT_GENERAL_STATUS_UPDATE_PERIOD, + ) # Connect all registers to the consolidated event handler for r in self._conn.registers.values(): @@ -145,10 +154,10 @@ async def connect( return self._conn.device_info async def disconnect(self, reason: str = None): - _logger.info('Explicit disconnect request; reason: %r', reason) + _logger.info("Explicit disconnect request; reason: %r", reason) if self._conn: # noinspection PyTypeChecker - self._evt_connection_status_change(reason or 'Explicit disconnection') + self._evt_connection_status_change(reason or "Explicit disconnection") try: await self._conn.disconnect() finally: @@ -164,7 +173,9 @@ def device_info(self) -> typing.Optional[DeviceInfoView]: return self._conn.device_info @property - def last_general_status_with_timestamp(self) -> typing.Optional[typing.Tuple[float, GeneralStatusView]]: + def last_general_status_with_timestamp( + self, + ) -> typing.Optional[typing.Tuple[float, GeneralStatusView]]: if self._conn: return self._conn.last_general_status_with_timestamp @@ -174,10 +185,10 @@ async def get_task_statistics(self) -> TaskStatisticsView: if out is not None: return TaskStatisticsView.populate(out.fields) else: - raise RequestTimedOutException('Task statistics request has timed out') + raise RequestTimedOutException("Task statistics request has timed out") def _on_connection_loss(self, reason: typing.Union[str, Exception]): - _logger.info('Connection instance reported connection loss; reason: %r', reason) + _logger.info("Connection instance reported connection loss; reason: %r", reason) # The Connection instance will terminate itself, so we don't have to do anything, just clear the reference self._conn = None # noinspection PyTypeChecker @@ -185,8 +196,10 @@ def _on_connection_loss(self, reason: typing.Union[str, Exception]): def _ensure_connected(self): if not self.is_connected: - raise ConnectionNotEstablishedException('The requested operation could not be performed because ' - 'the device connection is not established') + raise ConnectionNotEstablishedException( + "The requested operation could not be performed because " + "the device connection is not established" + ) def _unittest_device_model_connection(): @@ -195,14 +208,18 @@ def _unittest_device_model_connection(): import glob import asyncio - port_glob = os.environ.get('KUCHER_TEST_PORT', None) + port_glob = os.environ.get("KUCHER_TEST_PORT", None) if not port_glob: - pytest.skip('Skipping because the environment variable KUCHER_TEST_PORT is not set. ' - 'In order to test the device connection, set that variable to a name or a glob of a serial port. ' - 'If a glob is used, it must evaluate to exactly one port, otherwise the test will fail.') + pytest.skip( + "Skipping because the environment variable KUCHER_TEST_PORT is not set. " + "In order to test the device connection, set that variable to a name or a glob of a serial port. " + "If a glob is used, it must evaluate to exactly one port, otherwise the test will fail." + ) port = glob.glob(port_glob) - assert len(port) == 1, f'The glob was supposed to resolve to exactly one port; got {len(port)} ports.' + assert ( + len(port) == 1 + ), f"The glob was supposed to resolve to exactly one port; got {len(port)} ports." port = port[0] loop = asyncio.get_event_loop() @@ -216,12 +233,12 @@ async def run(): def on_connection_status_changed(info): nonlocal num_connection_change_notifications num_connection_change_notifications += 1 - print(f'Connection status changed! Info:\n{info}') + print(f"Connection status changed! Info:\n{info}") def on_status_report(ts, rep): nonlocal num_status_reports num_status_reports += 1 - print(f'Status report at {ts}:\n{rep}') + print(f"Status report at {ts}:\n{rep}") dm.connection_status_change_event.connect(on_connection_status_changed) dm.device_status_update_event.connect(on_status_report) @@ -230,13 +247,13 @@ def on_status_report(ts, rep): assert num_status_reports == 0 assert num_connection_change_notifications == 0 - await dm.connect(port, lambda *args: print('Progress report:', *args)) + await dm.connect(port, lambda *args: print("Progress report:", *args)) assert dm.is_connected assert num_status_reports == 1 assert num_connection_change_notifications == 1 - print('Task statistics:') + print("Task statistics:") print(await dm.get_task_statistics()) await dm.disconnect() diff --git a/kucher/model/device_model/commander.py b/kucher/model/device_model/commander.py index 0bafabd..7bf47cc 100644 --- a/kucher/model/device_model/commander.py +++ b/kucher/model/device_model/commander.py @@ -15,8 +15,14 @@ import typing from logging import getLogger from .communicator import MessageType, Message -from .general_status_view import ControlMode, MotorIdentificationMode, LowLevelManipulationMode,\ - CONTROL_MODE_MAPPING, LOW_LEVEL_MANIPULATION_MODE_MAPPING, MOTOR_IDENTIFICATION_MODE_MAPPING +from .general_status_view import ( + ControlMode, + MotorIdentificationMode, + LowLevelManipulationMode, + CONTROL_MODE_MAPPING, + LOW_LEVEL_MANIPULATION_MODE_MAPPING, + MOTOR_IDENTIFICATION_MODE_MAPPING, +) SendCommandFunction = typing.Callable[[Message], typing.Awaitable[None]] @@ -40,56 +46,67 @@ async def run(self, mode: ControlMode, value: float): try: mode = self._reverse_control_mode_mapping[mode] except KeyError: - raise ValueError(f'Unsupported control mode: {mode!r}') from None + raise ValueError(f"Unsupported control mode: {mode!r}") from None else: - await self._send('run', mode=mode, value=float(value)) + await self._send("run", mode=mode, value=float(value)) async def stop(self): - _logger.info('Requesting stop') - await self._send('idle') + _logger.info("Requesting stop") + await self._send("idle") async def beep(self, frequency: float, duration: float): frequency = float(frequency) duration = float(duration) - _logger.info(f'Requesting beep at {frequency:.3f} Hz for {duration:.3} seconds') - await self._send('beep', frequency=frequency, duration=duration) + _logger.info(f"Requesting beep at {frequency:.3f} Hz for {duration:.3} seconds") + await self._send("beep", frequency=frequency, duration=duration) async def begin_hardware_test(self): - _logger.info('Requesting hardware test') - await self._send('hardware_test') + _logger.info("Requesting hardware test") + await self._send("hardware_test") async def begin_motor_identification(self, mode: MotorIdentificationMode): - _logger.info(f'Requesting motor ID with mode {mode!r}') + _logger.info(f"Requesting motor ID with mode {mode!r}") try: mode = self._reverse_motor_id_mode_mapping[mode] except KeyError: - raise ValueError(f'Unsupported motor identification mode: {mode!r}') from None + raise ValueError( + f"Unsupported motor identification mode: {mode!r}" + ) from None else: - await self._send('motor_identification', mode=mode) + await self._send("motor_identification", mode=mode) - async def low_level_manipulate(self, mode: LowLevelManipulationMode, *parameters: float): + async def low_level_manipulate( + self, mode: LowLevelManipulationMode, *parameters: float + ): parameters = list(map(float, parameters)) while len(parameters) < 4: parameters.append(0.0) if len(parameters) > 4: - raise ValueError(f'Too many parameters: {parameters!r}') + raise ValueError(f"Too many parameters: {parameters!r}") - assert len(parameters) == 4, 'Logic error' + assert len(parameters) == 4, "Logic error" try: mode = self._reverse_llm_mode_mapping[mode] except KeyError: - raise ValueError(f'Unsupported low-level manipulation mode: {mode!r}') from None + raise ValueError( + f"Unsupported low-level manipulation mode: {mode!r}" + ) from None else: - await self._send('low_level_manipulation', mode=mode, parameters=parameters) + await self._send("low_level_manipulation", mode=mode, parameters=parameters) async def emergency(self): - await self._send('fault', magic=0xBADC0FFE) - _logger.info('Emergency command sent') + await self._send("fault", magic=0xBADC0FFE) + _logger.info("Emergency command sent") async def _send(self, converted_task_id: str, **kwargs): - await self._sender(Message(MessageType.COMMAND, { - 'task_id': converted_task_id, - 'task_specific_command': kwargs, - })) + await self._sender( + Message( + MessageType.COMMAND, + { + "task_id": converted_task_id, + "task_specific_command": kwargs, + }, + ) + ) diff --git a/kucher/model/device_model/communicator/__init__.py b/kucher/model/device_model/communicator/__init__.py index 6755880..e7bf349 100644 --- a/kucher/model/device_model/communicator/__init__.py +++ b/kucher/model/device_model/communicator/__init__.py @@ -14,8 +14,17 @@ from .exceptions import CommunicatorException -from .communicator import Communicator, CommunicationChannelClosedException, LOOPBACK_PORT_NAME, AnyMessage +from .communicator import ( + Communicator, + CommunicationChannelClosedException, + LOOPBACK_PORT_NAME, + AnyMessage, +) from .messages import Message, MessageType, MessagingException -from .messages import UnsupportedVersionException, UnknownMessageException, InvalidFieldsException +from .messages import ( + UnsupportedVersionException, + UnknownMessageException, + InvalidFieldsException, +) from .messages import InvalidPayloadException diff --git a/kucher/model/device_model/communicator/communicator.py b/kucher/model/device_model/communicator/communicator.py index 0a92b61..f5da96a 100644 --- a/kucher/model/device_model/communicator/communicator.py +++ b/kucher/model/device_model/communicator/communicator.py @@ -23,11 +23,11 @@ from popcop.standard import MessageBase as StandardMessageBase from popcop.transport import ReceivedFrame -__all__ = ['Communicator', 'CommunicationChannelClosedException', 'LOOPBACK_PORT_NAME'] +__all__ = ["Communicator", "CommunicationChannelClosedException", "LOOPBACK_PORT_NAME"] MAX_PAYLOAD_SIZE = 1024 FRAME_TIMEOUT = 0.5 -LOOPBACK_PORT_NAME = 'loop://' +LOOPBACK_PORT_NAME = "loop://" STATE_CHECK_INTERVAL = 0.1 AnyMessage = typing.Union[Message, StandardMessageBase] @@ -50,24 +50,26 @@ class Communicator: IO_WORKER_ERROR_LIMIT = 100 - def __init__(self, - port_name: str, - event_loop: asyncio.AbstractEventLoop): + def __init__(self, port_name: str, event_loop: asyncio.AbstractEventLoop): """The constructor is blocking. Use the factory method new() in async contexts instead.""" self._event_loop = event_loop - self._ch = popcop.physical.serial_multiprocessing.Channel(port_name=port_name, - max_payload_size=MAX_PAYLOAD_SIZE, - frame_timeout=FRAME_TIMEOUT) + self._ch = popcop.physical.serial_multiprocessing.Channel( + port_name=port_name, + max_payload_size=MAX_PAYLOAD_SIZE, + frame_timeout=FRAME_TIMEOUT, + ) self._codec: Codec = None - self._log_queue = asyncio.Queue(loop=event_loop) - self._message_queue = asyncio.Queue(loop=event_loop) + self._log_queue = asyncio.Queue() + self._message_queue = asyncio.Queue() - self._pending_requests: typing.Set[typing.Tuple[typing.Callable, asyncio.Future]] = set() + self._pending_requests: typing.Set[ + typing.Tuple[typing.Callable, asyncio.Future] + ] = set() - self._thread_handle = threading.Thread(target=self._thread_entry, - name='communicator_io_worker', - daemon=True) + self._thread_handle = threading.Thread( + target=self._thread_entry, name="communicator_io_worker", daemon=True + ) self._thread_handle.start() def __del__(self): @@ -78,13 +80,15 @@ def __del__(self): pass @staticmethod - async def new(port_name: str, - event_loop: asyncio.AbstractEventLoop) -> 'Communicator': + async def new( + port_name: str, event_loop: asyncio.AbstractEventLoop + ) -> "Communicator": """ Use this method to create new instances of this class from async contexts. """ - return await event_loop.run_in_executor(None, lambda: Communicator(port_name=port_name, - event_loop=event_loop)) + return await event_loop.run_in_executor( + None, lambda: Communicator(port_name=port_name, event_loop=event_loop) + ) def _thread_entry(self): # This thread is NOT allowed to invoke any methods of this class, for thread safety reasons! @@ -97,49 +101,62 @@ def _thread_entry(self): ret = self._ch.receive(STATE_CHECK_INTERVAL) if isinstance(ret, bytes): ts = time.monotonic() - log_str = ret.decode(encoding='utf8', errors='replace') - _logger.debug('Received log string at %r: %r', ts, log_str) - self._event_loop.call_soon_threadsafe(self._log_queue.put_nowait, (ts, log_str)) + log_str = ret.decode(encoding="utf8", errors="replace") + _logger.debug("Received log string at %r: %r", ts, log_str) + self._event_loop.call_soon_threadsafe( + self._log_queue.put_nowait, (ts, log_str) + ) elif ret is not None: - _logger.debug('Received item: %r', ret) - self._event_loop.call_soon_threadsafe(self._process_received_item, ret) + _logger.debug("Received item: %r", ret) + self._event_loop.call_soon_threadsafe( + self._process_received_item, ret + ) except popcop.physical.serial_multiprocessing.ChannelClosedException as ex: - _logger.info('Stopping the IO worker thread because the channel is closed. Error: %r', ex) + _logger.info( + "Stopping the IO worker thread because the channel is closed. Error: %r", + ex, + ) break except Exception as ex: error_counter += 1 - _logger.exception(f'Unhandled exception in IO worker thread ' - f'({error_counter} of {self.IO_WORKER_ERROR_LIMIT}): {ex}') + _logger.exception( + f"Unhandled exception in IO worker thread " + f"({error_counter} of {self.IO_WORKER_ERROR_LIMIT}): {ex}" + ) if error_counter > self.IO_WORKER_ERROR_LIMIT: - _logger.error('Too many errors, stopping!') + _logger.error("Too many errors, stopping!") break else: error_counter = 0 - _logger.info('IO worker thread is stopping') + _logger.info("IO worker thread is stopping") # noinspection PyBroadException try: self._ch.close() except Exception: - _logger.exception('Could not close the channel properly') + _logger.exception("Could not close the channel properly") # This is required to un-block the waiting coroutines, if any. self._event_loop.call_soon_threadsafe(self._message_queue.put_nowait, None) self._event_loop.call_soon_threadsafe(self._log_queue.put_nowait, None) - def _process_received_item(self, item: typing.Union[ReceivedFrame, StandardMessageBase]) -> None: + def _process_received_item( + self, item: typing.Union[ReceivedFrame, StandardMessageBase] + ) -> None: if isinstance(item, StandardMessageBase): message = item elif isinstance(item, ReceivedFrame): if self._codec is None: - _logger.warning('Cannot decode application-specific frame because the codec is not yet initialized: %r', - item) + _logger.warning( + "Cannot decode application-specific frame because the codec is not yet initialized: %r", + item, + ) return # noinspection PyBroadException try: message = self._codec.decode(item) except Exception: - _logger.warning('Could not decode frame: %r', item, exc_info=True) + _logger.warning("Could not decode frame: %r", item, exc_info=True) return else: raise TypeError(f"Don't know how to handle this item: {item}") @@ -148,13 +165,18 @@ def _process_received_item(self, item: typing.Union[ReceivedFrame, StandardMessa for predicate, future in self._pending_requests: if not future.done() and predicate(message): at_least_one_match = True - _logger.debug('Matching response: %r %r', item, future) + _logger.debug("Matching response: %r %r", item, future) future.set_result(message) if not at_least_one_match: self._message_queue.put_nowait(message) - async def _do_send(self, message_or_type: typing.Union[Message, StandardMessageBase, StandardMessageType]): + async def _do_send( + self, + message_or_type: typing.Union[ + Message, StandardMessageBase, StandardMessageType + ], + ): """ This function is made async, but the current implementation does not require awaiting - we simply dump the message into the channel's queue non-blockingly and then return immediately. @@ -163,8 +185,10 @@ async def _do_send(self, message_or_type: typing.Union[Message, StandardMessageB try: if isinstance(message_or_type, (Message, MessageType)): if self._codec is None: - raise CommunicatorException('Codec is not yet initialized, ' - 'cannot send application-specific message') + raise CommunicatorException( + "Codec is not yet initialized, " + "cannot send application-specific message" + ) frame_type_code, payload = self._codec.encode(message_or_type) self._ch.send_application_specific(frame_type_code, payload) @@ -173,16 +197,19 @@ async def _do_send(self, message_or_type: typing.Union[Message, StandardMessageB self._ch.send_standard(message_or_type) else: - raise TypeError(f'Invalid message or message type {type(message_or_type)}: {message_or_type}') + raise TypeError( + f"Invalid message or message type {type(message_or_type)}: {message_or_type}" + ) except popcop.physical.serial_multiprocessing.ChannelClosedException as ex: raise CommunicationChannelClosedException from ex @staticmethod - def _match_message(reference: typing.Union[Message, - MessageType, - StandardMessageBase, - StandardMessageType], - candidate: AnyMessage) -> bool: + def _match_message( + reference: typing.Union[ + Message, MessageType, StandardMessageBase, StandardMessageType + ], + candidate: AnyMessage, + ) -> bool: # Eliminate prototypes if isinstance(reference, StandardMessageBase): reference = type(reference) @@ -209,18 +236,21 @@ async def send(self, message: AnyMessage): """ await self._do_send(message) - async def request(self, - message_or_type: typing.Union[Message, MessageType, StandardMessageBase, StandardMessageType], - timeout: typing.Optional[typing.Union[float, int]] = None, - predicate: typing.Optional[typing.Callable[[AnyMessage], bool]] = None) ->\ - typing.Optional[AnyMessage]: + async def request( + self, + message_or_type: typing.Union[ + Message, MessageType, StandardMessageBase, StandardMessageType + ], + timeout: typing.Optional[typing.Union[float, int]] = None, + predicate: typing.Optional[typing.Callable[[AnyMessage], bool]] = None, + ) -> typing.Optional[AnyMessage]: """ Sends a message, then awaits for a matching response. If no matching response was received before the timeout has expired, returns None. """ timeout = float(timeout or popcop.standard.DEFAULT_STANDARD_REQUEST_TIMEOUT) if timeout <= 0: - raise ValueError('A positive timeout is required') + raise ValueError("A positive timeout is required") await self._do_send(message_or_type) @@ -229,8 +259,11 @@ def super_predicate(item: AnyMessage) -> bool: try: return predicate(item) except Exception as ex: - _logger.exception('Unhandled exception in response predicate for message %r: %r', - message_or_type, ex) + _logger.exception( + "Unhandled exception in response predicate for message %r: %r", + message_or_type, + ex, + ) else: return self._match_message(message_or_type, item) @@ -238,7 +271,7 @@ def super_predicate(item: AnyMessage) -> bool: entry = super_predicate, future try: self._pending_requests.add(entry) - return await asyncio.wait_for(future, timeout, loop=self._event_loop) + return await asyncio.wait_for(future, timeout) except asyncio.TimeoutError: return None finally: @@ -269,9 +302,10 @@ async def read_log(self) -> typing.Tuple[float, str]: raise CommunicationChannelClosedException async def close(self): - await asyncio.gather(self._event_loop.run_in_executor(None, self._thread_handle.join), - self._event_loop.run_in_executor(None, self._ch.close), - loop=self._event_loop) + await asyncio.gather( + self._event_loop.run_in_executor(None, self._thread_handle.join), + self._event_loop.run_in_executor(None, self._ch.close), + ) # This is required to un-block the waiting coroutines, if any. self._message_queue.put_nowait(None) self._log_queue.put_nowait(None) @@ -306,17 +340,27 @@ async def _async_unittest_communicator_loopback(): # noinspection PyProtectedMember async def sender(): - com._ch.send_raw(b'Hello world!') + com._ch.send_raw(b"Hello world!") with raises(CommunicatorException): - await com.send(Message(MessageType.COMMAND, {'task_id': 'hardware_test', 'task_specific_command': {}})) + await com.send( + Message( + MessageType.COMMAND, + {"task_id": "hardware_test", "task_specific_command": {}}, + ) + ) com.set_protocol_version((1, 2)) - print('Sending COMMAND...') - await com.send(Message(MessageType.COMMAND, {'task_id': 'hardware_test', 'task_specific_command': {}})) - - print('Requesting GENERAL_STATUS...') + print("Sending COMMAND...") + await com.send( + Message( + MessageType.COMMAND, + {"task_id": "hardware_test", "task_specific_command": {}}, + ) + ) + + print("Requesting GENERAL_STATUS...") status_response = await com.request(Message(MessageType.GENERAL_STATUS), 1) - print('GENERAL_STATUS response:', status_response) + print("GENERAL_STATUS response:", status_response) assert isinstance(status_response, Message) assert status_response.type == MessageType.GENERAL_STATUS assert not status_response.fields @@ -328,29 +372,29 @@ async def sender(): async def receiver(): # This receiver will receive all messages that were not claimed by request() calls msg = await com.receive() - print('Received:', msg) + print("Received:", msg) assert isinstance(msg, Message) assert msg.type == MessageType.COMMAND - assert msg.fields.task_id == 'hardware_test' + assert msg.fields.task_id == "hardware_test" assert msg.fields.task_specific_command == {} async def log_reader(): - accumulator = '' + accumulator = "" while True: try: accumulator += (await com.read_log())[1] except CommunicationChannelClosedException: break - print('Log accumulator:', accumulator) - assert 'Hello world!'.startswith(accumulator) + print("Log accumulator:", accumulator) + assert "Hello world!".startswith(accumulator) with raises(CommunicationChannelClosedException): await com.read_log() async def closer(): assert com.is_open - await asyncio.sleep(5, loop=loop) + await asyncio.sleep(5) assert com.is_open await com.close() assert not com.is_open @@ -359,7 +403,12 @@ async def closer(): await com.close() with raises(CommunicationChannelClosedException): - await com.send(Message(MessageType.COMMAND, {'task_id': 'hardware_test', 'task_specific_command': {}})) + await com.send( + Message( + MessageType.COMMAND, + {"task_id": "hardware_test", "task_specific_command": {}}, + ) + ) with raises(CommunicationChannelClosedException): await com.read_log() @@ -369,15 +418,11 @@ async def closer(): # Testing idempotency again await com.close() - await asyncio.sleep(1, loop=loop) + await asyncio.sleep(1) await com.close() assert com.is_open - await asyncio.gather(sender(), - receiver(), - log_reader(), - closer(), - loop=loop) + await asyncio.gather(sender(), receiver(), log_reader(), closer()) def _unittest_communicator_loopback(): @@ -401,13 +446,13 @@ async def log_reader(): # noinspection PyProtectedMember async def closer(): assert com.is_open - await asyncio.sleep(1, loop=loop) + await asyncio.sleep(1) assert com.is_open - com._ch.close() # Simulate failure of the serial connection + com._ch.close() # Simulate failure of the serial connection assert not com.is_open - await asyncio.sleep(1, loop=loop) + await asyncio.sleep(1) with raises(CommunicationChannelClosedException): await com.send(popcop.standard.NodeInfoMessage()) @@ -423,15 +468,14 @@ async def closer(): # And again, because why not await com.close() - await asyncio.sleep(1, loop=loop) + await asyncio.sleep(1) await com.close() assert com.is_open - await asyncio.gather(receiver(), - log_reader(), - closer(), - loop=loop) + await asyncio.gather(receiver(), log_reader(), closer()) def _unittest_communicator_disconnect_detection(): - asyncio.get_event_loop().run_until_complete(_async_unittest_communicator_disconnect_detection()) + asyncio.get_event_loop().run_until_complete( + _async_unittest_communicator_disconnect_detection() + ) diff --git a/kucher/model/device_model/communicator/messages.py b/kucher/model/device_model/communicator/messages.py index 445d38b..91697e1 100644 --- a/kucher/model/device_model/communicator/messages.py +++ b/kucher/model/device_model/communicator/messages.py @@ -18,10 +18,11 @@ import typing import decimal import construct as con +from construct import GreedyRange, Slicing from .exceptions import CommunicatorException # Convenient type aliases - we only use little-endian byte order! -U8 = con.Int8ul +U8 = con.Int8ul U16 = con.Int16ul U32 = con.Int32ul U64 = con.Int64ul @@ -34,10 +35,11 @@ class OptionalFloatAdapter(con.Adapter): Floats can be optional; if no value is provided, they may be set to NaN. This adapter replaces NaN with None and vice versa. """ - def _encode(self, obj, context): - return float('nan') if obj is None else float(obj) - def _decode(self, obj, context): + def _encode(self, obj, context, path): + return float("nan") if obj is None else float(obj) + + def _decode(self, obj, context, path): return None if math.isnan(obj) else float(obj) @@ -46,46 +48,39 @@ class TimeAdapter(con.Adapter): """ Converts time representation between integral number of nanoseconds and a Decimal number of seconds. """ - MULTIPLIER = decimal.Decimal('1e9') - def _encode(self, obj, context): + MULTIPLIER = decimal.Decimal("1e9") + + def _encode(self, obj, context, path): return int(obj * self.MULTIPLIER) - def _decode(self, obj, context): + def _decode(self, obj, context, path): return decimal.Decimal(obj) / self.MULTIPLIER StatusFlagsFormat = con.FlagsEnum( U64, - # Alert flags are allocated at the bottom (from bit 0 upwards) dc_undervoltage=1 << 0, dc_overvoltage=1 << 1, dc_undercurrent=1 << 2, dc_overcurrent=1 << 3, - cpu_cold=1 << 4, cpu_overheating=1 << 5, vsi_cold=1 << 6, vsi_overheating=1 << 7, motor_cold=1 << 8, motor_overheating=1 << 9, - hardware_lvps_malfunction=1 << 10, hardware_fault=1 << 11, hardware_overload=1 << 12, - phase_current_measurement_malfunction=1 << 13, - # Non-error flags are allocated at the top (from bit 63 downwards) uavcan_node_up=1 << 56, can_data_link_up=1 << 57, - usb_connected=1 << 58, usb_power_supplied=1 << 59, - rcpwm_signal_detected=1 << 60, - phase_current_agc_high_gain_selected=1 << 61, vsi_modulating=1 << 62, vsi_enabled=1 << 63, @@ -94,7 +89,6 @@ def _decode(self, obj, context): DeviceCapabilityFlagsFormat = con.FlagsEnum( U64, - doubly_redundant_can_bus=1 << 0, battery_eliminator_circuit=1 << 1, ) @@ -108,7 +102,6 @@ def _decode(self, obj, context): TaskIDFormat = con.Enum( U8, - idle=0, fault=1, beep=2, @@ -121,7 +114,6 @@ def _decode(self, obj, context): ControlModeFormat = con.Enum( U8, - ratiometric_current=0, ratiometric_angular_velocity=1, ratiometric_voltage=2, @@ -133,7 +125,6 @@ def _decode(self, obj, context): MotorIdentificationModeFormat = con.Enum( U8, - r_l=0, phi=1, r_l_phi=2, @@ -142,7 +133,6 @@ def _decode(self, obj, context): LowLevelManipulationModeFormat = con.Enum( U8, - calibration=0, phase_manipulation=1, scalar_control=2, @@ -153,122 +143,137 @@ def _decode(self, obj, context): # This is because the firmware reports empty task-specific structures as a zeroed-out byte sequence of length one byte. # The firmware does that because in the world of C/C++ a sizeof() cannot be zero. # noinspection PyUnresolvedReferences -TaskSpecificStatusReportFormat = con.Switch(con.this.current_task_id, { - 'fault': con.Struct( - 'failed_task_id' / TaskIDFormat, - 'failed_task_exit_code' / U8, - ), - 'run': con.Select( - # New format starting from firmware v0.2 - added a new field 'torque' and one reserved four bytes long field - con.Struct( - 'stall_count' / U32, - 'demand_factor' / F32, - # Mechanical parameters - 'electrical_angular_velocity' / F32, - 'mechanical_angular_velocity' / F32, - 'torque' / F32, - # Rotating system parameters - 'u_dq' / con.Array(2, F32), - 'i_dq' / con.Array(2, F32), - # Control mode - 'mode' / ControlModeFormat, - # State flags - 'spinup_in_progress' / con.Flag, - 'rotation_reversed' / con.Flag, - 'controller_saturated' / con.Flag, +TaskSpecificStatusReportFormat = con.Switch( + con.this.current_task_id, + { + "fault": con.Struct( + "failed_task_id" / TaskIDFormat, + "failed_task_exit_code" / U8, ), - # An older format used in the firmware v0.1 - this one is shorter, hence it must be at the end of Select() - con.Struct( - 'stall_count' / U32, - 'demand_factor' / F32, - 'electrical_angular_velocity' / F32, - 'mechanical_angular_velocity' / F32, - 'u_dq' / con.Array(2, F32), - 'i_dq' / con.Array(2, F32), - 'mode' / ControlModeFormat, - 'spinup_in_progress' / con.Flag, - 'rotation_reversed' / con.Flag, - 'controller_saturated' / con.Flag, + "run": con.Select( + # New format starting from firmware v0.2 - added a new field 'torque' and one reserved four bytes long field + con.Struct( + "stall_count" / U32, + "demand_factor" / F32, + # Mechanical parameters + "electrical_angular_velocity" / F32, + "mechanical_angular_velocity" / F32, + "torque" / F32, + # Rotating system parameters + "u_dq" / con.Array(2, F32), + "i_dq" / con.Array(2, F32), + # Control mode + "mode" / ControlModeFormat, + # State flags + "spinup_in_progress" / con.Flag, + "rotation_reversed" / con.Flag, + "controller_saturated" / con.Flag, + ), + # An older format used in the firmware v0.1 - this one is shorter, hence it must be at the end of Select() + con.Struct( + "stall_count" / U32, + "demand_factor" / F32, + "electrical_angular_velocity" / F32, + "mechanical_angular_velocity" / F32, + "u_dq" / con.Array(2, F32), + "i_dq" / con.Array(2, F32), + "mode" / ControlModeFormat, + "spinup_in_progress" / con.Flag, + "rotation_reversed" / con.Flag, + "controller_saturated" / con.Flag, + ), ), - ), - 'hardware_test': con.Struct( - 'progress' / F32, - ), - 'motor_identification': con.Struct( - 'progress' / F32, - ), - 'low_level_manipulation': con.Struct( - 'mode' / LowLevelManipulationModeFormat, - ), -}, default=con.Padding(1)) + "hardware_test": con.Struct( + "progress" / F32, + ), + "motor_identification": con.Struct( + "progress" / F32, + ), + "low_level_manipulation": con.Struct( + "mode" / LowLevelManipulationModeFormat, + ), + }, + default=con.Padding(1), +) # noinspection PyUnresolvedReferences GeneralStatusMessageFormatV1 = con.Struct( - 'timestamp' / TimeAdapter(U64), - 'status_flags' / StatusFlagsFormat, - 'current_task_id' / TaskIDFormat, + "timestamp" / TimeAdapter(U64), + "status_flags" / StatusFlagsFormat, + "current_task_id" / TaskIDFormat, con.Padding(3), - 'temperature' / con.Struct( - 'cpu' / F32, - 'vsi' / F32, - 'motor' / OptionalFloatAdapter(F32), + "temperature" + / con.Struct( + "cpu" / F32, + "vsi" / F32, + "motor" / OptionalFloatAdapter(F32), ), - 'dc' / con.Struct( - 'voltage' / F32, - 'current' / F32, + "dc" + / con.Struct( + "voltage" / F32, + "current" / F32, ), - 'pwm' / con.Struct( - 'period' / F32, - 'dead_time' / F32, - 'upper_limit' / F32, + "pwm" + / con.Struct( + "period" / F32, + "dead_time" / F32, + "upper_limit" / F32, ), - 'hardware_flag_edge_counters' / con.Struct( - 'lvps_malfunction' / U32, - 'overload' / U32, - 'fault' / U32, + "hardware_flag_edge_counters" + / con.Struct( + "lvps_malfunction" / U32, + "overload" / U32, + "fault" / U32, ), - 'task_specific_status_report' / TaskSpecificStatusReportFormat, - con.Terminated # Every message format should be terminated! This enables format mismatch detection. + "task_specific_status_report" / TaskSpecificStatusReportFormat, + con.Terminated, # Every message format should be terminated! This enables format mismatch detection. ) def _unittest_general_status_message_v1(): from binascii import unhexlify from pprint import pprint - sample_idle = unhexlify('b0ec8b2300000000000000000000002c0000000063be9a4365d89643000000002a59ae4100000000ae7db23795' - 'bfd633f2eb613f00000000000000000000000000') + + sample_idle = unhexlify( + "b0ec8b2300000000000000000000002c0000000063be9a4365d89643000000002a59ae4100000000ae7db23795" + "bfd633f2eb613f00000000000000000000000000" + ) container = GeneralStatusMessageFormatV1.parse(sample_idle) pprint(container) - assert container.current_task_id == 'idle' + assert container.current_task_id == "idle" assert container.task_specific_status_report is None - assert container['status_flags']['phase_current_agc_high_gain_selected'] + assert container["status_flags"]["phase_current_agc_high_gain_selected"] assert not container.status_flags.can_data_link_up assert sample_idle == GeneralStatusMessageFormatV1.build(container) # noinspection PyUnresolvedReferences DeviceCharacteristicsMessageFormatV1 = con.Struct( - 'capability_flags' / DeviceCapabilityFlagsFormat, - 'vsi_model' / con.Struct( - 'resistance_per_phase' / con.Array(3, ('high' / F32 + 'low' / F32)), - 'gate_ton_toff_imbalance' / F32, - 'phase_current_measurement_error_variance' / F32, + "capability_flags" / DeviceCapabilityFlagsFormat, + "vsi_model" + / con.Struct( + "resistance_per_phase" / con.Array(3, ("high" / F32 + "low" / F32)), + "gate_ton_toff_imbalance" / F32, + "phase_current_measurement_error_variance" / F32, ), - 'limits' / con.Struct( - 'absolute_maximum_ratings' / con.Struct( - 'vsi_dc_voltage' / MathRangeFormat, + "limits" + / con.Struct( + "absolute_maximum_ratings" + / con.Struct( + "vsi_dc_voltage" / MathRangeFormat, ), - 'safe_operating_area' / con.Struct( - 'vsi_dc_voltage' / MathRangeFormat, - 'vsi_dc_current' / MathRangeFormat, - 'vsi_phase_current' / MathRangeFormat, - 'cpu_temperature' / MathRangeFormat, - 'vsi_temperature' / MathRangeFormat, + "safe_operating_area" + / con.Struct( + "vsi_dc_voltage" / MathRangeFormat, + "vsi_dc_current" / MathRangeFormat, + "vsi_phase_current" / MathRangeFormat, + "cpu_temperature" / MathRangeFormat, + "vsi_temperature" / MathRangeFormat, ), - 'phase_current_zero_bias_limit' / ('low_gain' / F32 + 'high_gain' / F32), + "phase_current_zero_bias_limit" / ("low_gain" / F32 + "high_gain" / F32), ), - con.Terminated # Every message format should be terminated! This enables format mismatch detection. + con.Terminated, # Every message format should be terminated! This enables format mismatch detection. ) @@ -276,9 +281,12 @@ def _unittest_device_characteristics_message_v1(): from binascii import unhexlify from pprint import pprint from pytest import approx - sample = unhexlify('03000000000000006f12833b4260e53b6f12833b4260e53b6f12833b6f12833b83fa3cb20000803f00008040f62878' - '420000304100004c420000c8c10000c8410000f0c10000f04166266c433393b143662669433313b343000000400000' - '003f') + + sample = unhexlify( + "03000000000000006f12833b4260e53b6f12833b4260e53b6f12833b6f12833b83fa3cb20000803f00008040f62878" + "420000304100004c420000c8c10000c8410000f0c10000f04166266c433393b143662669433313b343000000400000" + "003f" + ) container = DeviceCharacteristicsMessageFormatV1.parse(sample) pprint(container) assert container.vsi_model.resistance_per_phase[0].low == approx(7e-3) @@ -290,70 +298,76 @@ def _unittest_device_characteristics_message_v1(): # noinspection PyUnresolvedReferences CommandMessageFormatV1 = con.Struct( - 'task_id' / TaskIDFormat, + "task_id" / TaskIDFormat, con.Padding(3), - 'task_specific_command' / con.Switch(con.this.task_id, { - 'idle': con.Struct(), - 'fault': con.Struct( - 'magic' / U32, - ), - 'beep': con.Struct( - 'frequency' / F32, - 'duration' / F32, - ), - 'run': con.Struct( - 'mode' / ControlModeFormat, - con.Padding(3), - 'value' / F32, - ), - 'hardware_test': con.Struct(), - 'motor_identification': con.Struct( - 'mode' / MotorIdentificationModeFormat, - ), - 'low_level_manipulation': con.Struct( - 'mode' / LowLevelManipulationModeFormat, - con.Padding(3), - 'parameters' / con.Array(4, F32), - ), - }), - con.Terminated # This is only meaningful for parsing, but we add it anyway for consistency. + "task_specific_command" + / con.Switch( + con.this.task_id, + { + "idle": con.Struct(), + "fault": con.Struct( + "magic" / U32, + ), + "beep": con.Struct( + "frequency" / F32, + "duration" / F32, + ), + "run": con.Struct( + "mode" / ControlModeFormat, + con.Padding(3), + "value" / F32, + ), + "hardware_test": con.Struct(), + "motor_identification": con.Struct( + "mode" / MotorIdentificationModeFormat, + ), + "low_level_manipulation": con.Struct( + "mode" / LowLevelManipulationModeFormat, + con.Padding(3), + "parameters" / con.Array(4, F32), + ), + }, + ), + con.Terminated, # This is only meaningful for parsing, but we add it anyway for consistency. ) # noinspection PyUnresolvedReferences TaskStatisticsEntryFormatV1 = con.Struct( - 'last_started_at' / TimeAdapter(U64), - 'last_stopped_at' / TimeAdapter(U64), - 'total_run_time' / TimeAdapter(U64), - 'number_of_times_started' / U64, - 'number_of_times_failed' / U64, + "last_started_at" / TimeAdapter(U64), + "last_stopped_at" / TimeAdapter(U64), + "total_run_time" / TimeAdapter(U64), + "number_of_times_started" / U64, + "number_of_times_failed" / U64, con.Padding(6), - 'last_exit_code' / U8, - 'task_id' / TaskIDFormat, + "last_exit_code" / U8, + "task_id" / TaskIDFormat, ) # noinspection PyUnresolvedReferences TaskStatisticsMessageFormatV1 = con.Struct( - 'timestamp' / TimeAdapter(U64), - 'entries' / TaskStatisticsEntryFormatV1[7:8], - con.Terminated # Every message format should be terminated! This enables format mismatch detection. + "timestamp" / TimeAdapter(U64), + "entries" / Slicing(GreedyRange(TaskStatisticsEntryFormatV1), 1, 7, 8), + con.Terminated, # Every message format should be terminated! This enables format mismatch detection. ) def _unittest_task_statistics_message_v1(): from binascii import unhexlify from pprint import pprint + # One task per line below sample = unhexlify( - '283fbc0100000000' - 'ad0a2e0000000000d80a2e00000000003e0000000000000002000000000000000200000000000000000000000000c200' - 'd80a2e0000000000ad0a2e000000000002699d0100000000030000000000000000000000000000000000000000000001' - '000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002' - '000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003' - 'fd3f00000000000069e71e00000000006ba71e0000000000010000000000000001000000000000000000000000000204' - '000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005' - '000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006') + "283fbc0100000000" + "ad0a2e0000000000d80a2e00000000003e0000000000000002000000000000000200000000000000000000000000c200" + "d80a2e0000000000ad0a2e000000000002699d0100000000030000000000000000000000000000000000000000000001" + "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002" + "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003" + "fd3f00000000000069e71e00000000006ba71e0000000000010000000000000001000000000000000000000000000204" + "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005" + "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006" + ) container = TaskStatisticsMessageFormatV1.parse(sample) pprint(container) @@ -374,6 +388,7 @@ class UnsupportedVersionException(MessagingException): This exception is thrown when the Codec class detects that it doesn't know how to communicate with the given firmware version. """ + pass @@ -381,6 +396,7 @@ class UnknownMessageException(MessagingException): """ This exception is thrown when the Codec class is asked to encode or decode a message it doesn't know about. """ + pass @@ -388,6 +404,7 @@ class InvalidFieldsException(MessagingException): """ This exception is thrown when the Codec class is asked to encode fields that don't fit the message type. """ + pass @@ -395,6 +412,7 @@ class InvalidPayloadException(MessagingException): """ This exception is thrown when the Codec class is asked to decode an incorrect message. """ + pass @@ -403,12 +421,15 @@ class Message: Simple container type for messages. Contains type information and the message fields. Note that the type is read-only, whereas the fields can be changed. """ - def __init__(self, - message_type: MessageType, - fields: typing.Optional[typing.Mapping] = None, - timestamp: typing.Optional[float] = None): + + def __init__( + self, + message_type: MessageType, + fields: typing.Optional[typing.Mapping] = None, + timestamp: typing.Optional[float] = None, + ): if not isinstance(message_type, MessageType): - raise TypeError('Expected MessageType not %r' % message_type) + raise TypeError("Expected MessageType not %r" % message_type) self._type = message_type self._fields = con.Container(fields or {}) @@ -427,10 +448,10 @@ def timestamp(self) -> float: return self._timestamp def __str__(self): - return '%s:%s' % (self.type, self.fields) + return "%s:%s" % (self.type, self.fields) def __repr__(self): - return '%r:%r' % (self.type, self.fields) + return "%r:%r" % (self.type, self.fields) class Codec: @@ -438,9 +459,10 @@ class Codec: Use this class to encode and decode messages. It uses software version numbers to determine which message formats to use. """ + def __init__(self, version_major_minor: typing.Tuple[int, int]): if len(version_major_minor) != 2: - raise TypeError('Expected an iterable of size 2') + raise TypeError("Expected an iterable of size 2") # Currently we don't do much here. In the future we may want to add additional logic that would be # adjusting the behavior of the class by swapping the available message definitions. @@ -448,7 +470,9 @@ def __init__(self, version_major_minor: typing.Tuple[int, int]): self._version: typing.Tuple[int, int] = tuple(map(int, version_major_minor)) if self._version[0] > 1: - raise UnsupportedVersionException('Cannot communicate with version %r' % self._version) + raise UnsupportedVersionException( + "Cannot communicate with version %r" % self._version + ) # We could add lists of formats, e.g. using construct.Select, that could be tried consecutively # until the first working format is found. @@ -457,10 +481,13 @@ def __init__(self, version_major_minor: typing.Tuple[int, int]): # Beware that this will only work if message types are terminated, i.e. contain construct.Terminated at the end! # Therefore all message types must be terminated in order to facilitate this approach! self._type_mapping: typing.Dict[MessageType, typing.Tuple[int, con.Struct]] = { - MessageType.GENERAL_STATUS: (0, GeneralStatusMessageFormatV1), - MessageType.DEVICE_CHARACTERISTICS: (1, DeviceCharacteristicsMessageFormatV1), - MessageType.COMMAND: (2, CommandMessageFormatV1), - MessageType.TASK_STATISTICS: (3, TaskStatisticsMessageFormatV1), + MessageType.GENERAL_STATUS: (0, GeneralStatusMessageFormatV1), + MessageType.DEVICE_CHARACTERISTICS: ( + 1, + DeviceCharacteristicsMessageFormatV1, + ), + MessageType.COMMAND: (2, CommandMessageFormatV1), + MessageType.TASK_STATISTICS: (3, TaskStatisticsMessageFormatV1), } def decode(self, frame: popcop.transport.ReceivedFrame) -> Message: @@ -468,7 +495,9 @@ def decode(self, frame: popcop.transport.ReceivedFrame) -> Message: if frame_type_code == frame.frame_type_code: break else: - raise UnknownMessageException('Unknown frame type code when decoding: %r' % frame.frame_type_code) + raise UnknownMessageException( + "Unknown frame type code when decoding: %r" % frame.frame_type_code + ) try: if frame.payload: @@ -476,18 +505,22 @@ def decode(self, frame: popcop.transport.ReceivedFrame) -> Message: else: fields = con.Container() except Exception as ex: - raise InvalidPayloadException('Cannot decode message') from ex + raise InvalidPayloadException("Cannot decode message") from ex return Message(mt, fields, frame.timestamp) - def encode(self, message: typing.Union[Message, MessageType]) -> typing.Tuple[int, bytes]: + def encode( + self, message: typing.Union[Message, MessageType] + ) -> typing.Tuple[int, bytes]: if isinstance(message, MessageType): message = Message(message) try: frame_type_code, formatter = self._type_mapping[message.type] except KeyError: - raise UnknownMessageException('Unknown message type when encoding: %r' % message.type) + raise UnknownMessageException( + "Unknown message type when encoding: %r" % message.type + ) try: if len(message.fields): @@ -495,7 +528,7 @@ def encode(self, message: typing.Union[Message, MessageType]) -> typing.Tuple[in else: encoded = bytes() except Exception as ex: - raise InvalidFieldsException('Cannot encode message') from ex + raise InvalidFieldsException("Cannot encode message") from ex return frame_type_code, encoded @@ -507,10 +540,10 @@ def _unittest_codec(): c = Codec((1, 2)) msg = Message(MessageType.COMMAND) - msg.fields.task_id = 'run' + msg.fields.task_id = "run" msg.fields.task_specific_command = { - 'mode': 'current', - 'value': 123.456, + "mode": "current", + "value": 123.456, } ftp, payload = c.encode(msg) diff --git a/kucher/model/device_model/connection.py b/kucher/model/device_model/connection.py index c13e6d0..a7fae78 100644 --- a/kucher/model/device_model/connection.py +++ b/kucher/model/device_model/connection.py @@ -16,7 +16,13 @@ import popcop import typing import asyncio -from .communicator import Communicator, MessageType, Message, CommunicationChannelClosedException, AnyMessage +from .communicator import ( + Communicator, + MessageType, + Message, + CommunicationChannelClosedException, + AnyMessage, +) from .device_info_view import DeviceInfoView from .general_status_view import GeneralStatusView from . import register @@ -56,25 +62,34 @@ class Connection: If it is detected that the connection is lost, a callback will be invoked. """ - def __init__(self, - event_loop: asyncio.AbstractEventLoop, - communicator: Communicator, - device_info: DeviceInfoView, - initial_register_data_msgs: typing.List[popcop.standard.register.DataResponseMessage], - general_status_with_ts: typing.Tuple[float, GeneralStatusView], - on_connection_loss: typing.Callable[[typing.Union[str, Exception]], None], - on_general_status_update: typing.Callable[[float, GeneralStatusView], None], - on_log_line: typing.Callable[[float, str], None], - general_status_update_period: float): + def __init__( + self, + event_loop: asyncio.AbstractEventLoop, + communicator: Communicator, + device_info: DeviceInfoView, + initial_register_data_msgs: typing.List[ + popcop.standard.register.DataResponseMessage + ], + general_status_with_ts: typing.Tuple[float, GeneralStatusView], + on_connection_loss: typing.Callable[[typing.Union[str, Exception]], None], + on_general_status_update: typing.Callable[[float, GeneralStatusView], None], + on_log_line: typing.Callable[[float, str], None], + general_status_update_period: float, + ): self._event_loop = event_loop self._com: Communicator = communicator self._device_info = device_info self._last_general_status_with_timestamp = general_status_with_ts self._general_status_update_period = general_status_update_period - self._registers: typing.Dict[str, Register] = self._build_register_model(initial_register_data_msgs) - _logger.info('Constructed %d register model objects:\n%s\n', - len(self._registers), '\n'.join(map(str, self._registers.values()))) + self._registers: typing.Dict[str, Register] = self._build_register_model( + initial_register_data_msgs + ) + _logger.info( + "Constructed %d register model objects:\n%s\n", + len(self._registers), + "\n".join(map(str, self._registers.values())), + ) self._on_connection_loss = on_connection_loss self._on_general_status_update = on_general_status_update @@ -89,7 +104,9 @@ def device_info(self) -> DeviceInfoView: return self._device_info @property - def last_general_status_with_timestamp(self) -> typing.Tuple[float, GeneralStatusView]: + def last_general_status_with_timestamp( + self, + ) -> typing.Tuple[float, GeneralStatusView]: return self._last_general_status_with_timestamp @property @@ -100,74 +117,90 @@ def registers(self) -> typing.Dict[str, Register]: return self._registers async def disconnect(self): - self._on_connection_loss = lambda *_: None # Suppress further reporting + self._on_connection_loss = lambda *_: None # Suppress further reporting # noinspection PyBroadException try: await self._com.close() except Exception: - _logger.exception('Could not properly close the communicator. ' - 'The communicator is a big boy and should be able to sort its stuff out on its own. ' - 'Hey communicator, you suck!') + _logger.exception( + "Could not properly close the communicator. " + "The communicator is a big boy and should be able to sort its stuff out on its own. " + "Hey communicator, you suck!" + ) async def send(self, message: typing.Union[Message, popcop.standard.MessageBase]): try: await self._com.send(message) except CommunicationChannelClosedException as ex: - raise ConnectionNotEstablishedException('Could not send the message because the communication channel is ' - 'closed') from ex - - async def request(self, - message_or_type: typing.Union[Message, - MessageType, - popcop.standard.MessageBase, - typing.Type[popcop.standard.MessageBase]], - timeout: typing.Optional[typing.Union[float, int]] = None, - predicate: typing.Optional[typing.Callable[[typing.Union[Message, - popcop.standard.MessageBase]], - bool]] = None) ->\ - typing.Union[Message, popcop.standard.MessageBase]: + raise ConnectionNotEstablishedException( + "Could not send the message because the communication channel is " + "closed" + ) from ex + + async def request( + self, + message_or_type: typing.Union[ + Message, + MessageType, + popcop.standard.MessageBase, + typing.Type[popcop.standard.MessageBase], + ], + timeout: typing.Optional[typing.Union[float, int]] = None, + predicate: typing.Optional[ + typing.Callable[[typing.Union[Message, popcop.standard.MessageBase]], bool] + ] = None, + ) -> typing.Union[Message, popcop.standard.MessageBase]: try: - return await self._com.request(message_or_type, - timeout=timeout, - predicate=predicate) + return await self._com.request( + message_or_type, timeout=timeout, predicate=predicate + ) except CommunicationChannelClosedException as ex: - raise ConnectionNotEstablishedException('Could not complete the request because the communication channel ' - 'is closed') from ex + raise ConnectionNotEstablishedException( + "Could not complete the request because the communication channel " + "is closed" + ) from ex async def _handle_connection_loss(self, reason: typing.Union[str, Exception]): # noinspection PyBroadException try: self._on_connection_loss(reason) except Exception: - _logger.exception('Unhandled exception in the connection loss callback') + _logger.exception("Unhandled exception in the connection loss callback") await self.disconnect() async def _status_monitoring_task_entry_point(self): request_errors = 0 while True: - await asyncio.sleep(self._general_status_update_period, loop=self._event_loop) + await asyncio.sleep(self._general_status_update_period) - response = await self._com.request(MessageType.GENERAL_STATUS, - timeout=STATUS_REQUEST_TIMEOUT) + response = await self._com.request( + MessageType.GENERAL_STATUS, timeout=STATUS_REQUEST_TIMEOUT + ) if not response: request_errors += 1 if request_errors >= 3: request_errors = 0 - raise ConnectionLostException('Three general status requests have timed out') + raise ConnectionLostException( + "Three general status requests have timed out" + ) assert isinstance(response, Message) assert response.type == MessageType.GENERAL_STATUS if prev.timestamp > new.timestamp: - raise ConnectionLostException('Device has been restarted, connection lost') + raise ConnectionLostException( + "Device has been restarted, connection lost" + ) else: prev = self._last_general_status_with_timestamp[1] new = GeneralStatusView.populate(response.fields) request_errors = 0 self._last_general_status_with_timestamp = response.timestamp, new - self._on_general_status_update(*self._last_general_status_with_timestamp) + self._on_general_status_update( + *self._last_general_status_with_timestamp + ) async def _receiver_task_entry_point(self): while True: @@ -177,11 +210,13 @@ async def _receiver_task_entry_point(self): if isinstance(item, popcop.standard.register.DataResponseMessage): try: # noinspection PyProtectedMember - self._registers[item.name]._sync(item.value, item.timestamp, ts_mono) + self._registers[item.name]._sync( + item.value, item.timestamp, ts_mono + ) except KeyError: - _logger.exception('Unknown register name: %r', item.name) + _logger.exception("Unknown register name: %r", item.name) else: - _logger.warning('Unattended message: %r', item) + _logger.warning("Unattended message: %r", item) async def _log_reader_task_entry_point(self): while True: @@ -191,54 +226,65 @@ async def _log_reader_task_entry_point(self): def _launch_task(self, target): async def proxy(): - _logger.info('Starting task %r', target) + _logger.info("Starting task %r", target) # noinspection PyBroadException try: await target() except CommunicationChannelClosedException as ex: - _logger.info('Task %r is stopping because the communication channel is closed: %r', target, ex) + _logger.info( + "Task %r is stopping because the communication channel is closed: %r", + target, + ex, + ) await self._handle_connection_loss(ex) except Exception as ex: - _logger.exception('Unhandled exception in the task %r', target) + _logger.exception("Unhandled exception in the task %r", target) await self._handle_connection_loss(ex) else: - _logger.error('Unexpected termination of the task %r', target) - await self._handle_connection_loss('Unknown reason') # Should never happen! + _logger.error("Unexpected termination of the task %r", target) + await self._handle_connection_loss( + "Unknown reason" + ) # Should never happen! finally: - _logger.info('Task %r has stopped', target) + _logger.info("Task %r has stopped", target) self._event_loop.create_task(proxy()) - def _curry_register_set_get_executor(self, - name: str, - type_id: popcop.standard.register.ValueType) -> register.SetGetCallback: + def _curry_register_set_get_executor( + self, name: str, type_id: popcop.standard.register.ValueType + ) -> register.SetGetCallback: """ Returns an awaitable async function that modifies register state on the device itself. The function is bound to a particular register, whose name and type are specified in the arguments. """ + def predicate(item: AnyMessage) -> bool: if isinstance(item, popcop.standard.register.DataResponseMessage): return item.name == name async def executor(value: typing.Optional[register.StrictValueTypeAnnotation]): from popcop.standard.register import DataRequestMessage, DataResponseMessage + if value is None: - msg = DataRequestMessage(name=name) # None means that we're not setting the value, only reading + msg = DataRequestMessage( + name=name + ) # None means that we're not setting the value, only reading else: - msg = DataRequestMessage(name=name, - type_id=type_id, - value=value) + msg = DataRequestMessage(name=name, type_id=type_id, value=value) resp = await self.request(msg, predicate=predicate) - _logger.info('Register set/get result: %r -> %r', msg, resp) + _logger.info("Register set/get result: %r -> %r", msg, resp) assert isinstance(resp, DataResponseMessage) assert msg.name == resp.name == name return resp.value, resp.timestamp, time.monotonic() return executor - def _build_register_model(self, - initial_register_data_msgs: typing.List[popcop.standard.register.DataResponseMessage]) \ - -> typing.Dict[str, Register]: + def _build_register_model( + self, + initial_register_data_msgs: typing.List[ + popcop.standard.register.DataResponseMessage + ], + ) -> typing.Dict[str, Register]: index: typing.Dict[str, popcop.standard.register.DataResponseMessage] = { m.name: m for m in initial_register_data_msgs } @@ -249,35 +295,44 @@ def find_meta_value(name: str, type_id: Register.ValueType, suffix: str): if msg.type_id == type_id: return msg.value else: - _logger.error('Meta register type mismatch: expected %r, found %r', type_id, msg) + _logger.error( + "Meta register type mismatch: expected %r, found %r", + type_id, + msg, + ) - suffix_default = '=' - suffix_min = '<' - suffix_max = '>' + suffix_default = "=" + suffix_min = "<" + suffix_max = ">" out = {} for m in initial_register_data_msgs: if m.name[-1] not in (suffix_default, suffix_min, suffix_max): - out[m.name] = \ - Register(name=m.name, - value=m.value, - default_value=find_meta_value(m.name, m.type_id, suffix_default), - min_value=find_meta_value(m.name, m.type_id, suffix_min), - max_value=find_meta_value(m.name, m.type_id, suffix_max), - type_id=m.type_id, - update_timestamp_device_time=m.timestamp, - flags=m.flags, - set_get_callback=self._curry_register_set_get_executor(m.name, m.type_id)) + out[m.name] = Register( + name=m.name, + value=m.value, + default_value=find_meta_value(m.name, m.type_id, suffix_default), + min_value=find_meta_value(m.name, m.type_id, suffix_min), + max_value=find_meta_value(m.name, m.type_id, suffix_max), + type_id=m.type_id, + update_timestamp_device_time=m.timestamp, + flags=m.flags, + set_get_callback=self._curry_register_set_get_executor( + m.name, m.type_id + ), + ) return out -async def connect(event_loop: asyncio.AbstractEventLoop, - port_name: str, - on_connection_loss: typing.Callable[[typing.Union[str, Exception]], None], - on_general_status_update: typing.Callable[[float, GeneralStatusView], None], - on_log_line: typing.Callable[[float, str], None], - on_progress_report: typing.Optional[typing.Callable[[str, float], None]], - general_status_update_period: float) -> Connection: +async def connect( + event_loop: asyncio.AbstractEventLoop, + port_name: str, + on_connection_loss: typing.Callable[[typing.Union[str, Exception]], None], + on_general_status_update: typing.Callable[[float, GeneralStatusView], None], + on_log_line: typing.Callable[[float, str], None], + on_progress_report: typing.Optional[typing.Callable[[str, float], None]], + general_status_update_period: float, +) -> Connection: begun_at = time.monotonic() progress = 0.0 @@ -285,71 +340,94 @@ def report(stage: str, progress_increment: float = 0.01): nonlocal progress assert progress_increment > 0 progress = min(1.0, progress + progress_increment) - _logger.debug('Connection process on port %r reached a new stage %r', port_name, stage) + _logger.debug( + "Connection process on port %r reached a new stage %r", port_name, stage + ) if on_progress_report: on_progress_report(stage, progress) - report('I/O initialization') + report("I/O initialization") com = await Communicator.new(port_name, event_loop) try: - report('Device detection') + report("Device detection") node_info = await com.request(popcop.standard.NodeInfoMessage) - _logger.info('Node info of the connected device: %r', node_info) + _logger.info("Node info of the connected device: %r", node_info) if not node_info: - raise ConnectionAttemptFailedException('Node info request has timed out') + raise ConnectionAttemptFailedException("Node info request has timed out") assert isinstance(node_info, popcop.standard.NodeInfoMessage) - if node_info.node_name != 'com.zubax.telega': - raise IncompatibleDeviceException(f'The connected device is not compatible with this software: ' - f'{node_info}') + if node_info.node_name != "com.zubax.telega": + raise IncompatibleDeviceException( + f"The connected device is not compatible with this software: " + f"{node_info}" + ) if node_info.mode == popcop.standard.NodeInfoMessage.Mode.BOOTLOADER: - raise IncompatibleDeviceException('The connected device is in the bootloader mode. ' - 'This mode is not yet supported (but soon will be).') + raise IncompatibleDeviceException( + "The connected device is in the bootloader mode. " + "This mode is not yet supported (but soon will be)." + ) if node_info.mode != popcop.standard.NodeInfoMessage.Mode.NORMAL: - raise IncompatibleDeviceException(f'The connected device is in a wrong mode: {node_info.mode}') + raise IncompatibleDeviceException( + f"The connected device is in a wrong mode: {node_info.mode}" + ) # Configuring the communicator to use a specific version of the protocol. # From now on, we can use the application-specific messages, since the communicator knows how to # encode and decode them. - sw_major_minor = node_info.software_version_major, node_info.software_version_minor + sw_major_minor = ( + node_info.software_version_major, + node_info.software_version_minor, + ) com.set_protocol_version(sw_major_minor) - report('Device identification') + report("Device identification") characteristics = await com.request(MessageType.DEVICE_CHARACTERISTICS) - _logger.info('Device characteristics: %r', characteristics) + _logger.info("Device characteristics: %r", characteristics) if not characteristics: - raise ConnectionAttemptFailedException('Device capabilities request has timed out') + raise ConnectionAttemptFailedException( + "Device capabilities request has timed out" + ) - report('Device status request') + report("Device status request") general_status = await com.request(MessageType.GENERAL_STATUS) - _logger.info('General status: %r', general_status) + _logger.info("General status: %r", general_status) if not general_status: - raise ConnectionAttemptFailedException('General status request has timed out') + raise ConnectionAttemptFailedException( + "General status request has timed out" + ) - device_info = DeviceInfoView.populate(node_info_message=node_info, - characteristics_message=characteristics.fields) - _logger.info('Populated device info view: %r', device_info) + device_info = DeviceInfoView.populate( + node_info_message=node_info, characteristics_message=characteristics.fields + ) + _logger.info("Populated device info view: %r", device_info) # Requesting the list of all register names - this may take a while register_names = [] index = 0 while True: + def predicate(item: AnyMessage) -> bool: if isinstance(item, popcop.standard.register.DiscoveryResponseMessage): return item.index == index - discovered_future = com.request(popcop.standard.register.DiscoveryRequestMessage(index=index), - predicate=predicate) - report(f'Register discovery at index {index}', 1e-3) + discovered_future = com.request( + popcop.standard.register.DiscoveryRequestMessage(index=index), + predicate=predicate, + ) + report(f"Register discovery at index {index}", 1e-3) discovered = await discovered_future if not discovered: - raise ConnectionAttemptFailedException(f'Register discovery request at index {index} has timed out') + raise ConnectionAttemptFailedException( + f"Register discovery request at index {index} has timed out" + ) - assert isinstance(discovered, popcop.standard.register.DiscoveryResponseMessage) + assert isinstance( + discovered, popcop.standard.register.DiscoveryResponseMessage + ) assert discovered.index == index index += 1 if discovered.name: @@ -360,48 +438,67 @@ def predicate(item: AnyMessage) -> bool: del index del discovered del discovered_future - _logger.info('Discovered %d registers:\n%s\n', len(register_names), '\n'.join(map(str, register_names))) + _logger.info( + "Discovered %d registers:\n%s\n", + len(register_names), + "\n".join(map(str, register_names)), + ) # Requesting all registers now final_progress_increment = (1.0 - progress) / len(register_names) registers: typing.List[popcop.standard.register.DataResponseMessage] = [] for name in register_names: + def predicate(item: AnyMessage) -> bool: if isinstance(item, popcop.standard.register.DataResponseMessage): return item.name == name - data_future = com.request(popcop.standard.register.DataRequestMessage(name=name), - predicate=predicate) - report(f'Reading register {name!r}', final_progress_increment) + data_future = com.request( + popcop.standard.register.DataRequestMessage(name=name), + predicate=predicate, + ) + report(f"Reading register {name!r}", final_progress_increment) data = await data_future if not data: - raise ConnectionAttemptFailedException(f'Register read request with name {name!r} has timed out') + raise ConnectionAttemptFailedException( + f"Register read request with name {name!r} has timed out" + ) assert isinstance(data, popcop.standard.register.DataResponseMessage) assert data.name == name if data.value is not None: registers.append(data) else: - _logger.warning(f'Empty or unknown register ignored: {data}') + _logger.warning(f"Empty or unknown register ignored: {data}") - _logger.info('Read %d registers:\n%s\n', len(registers), '\n'.join(map(str, registers))) - report('Completed successfully', 1.0) + _logger.info( + "Read %d registers:\n%s\n", len(registers), "\n".join(map(str, registers)) + ) + report("Completed successfully", 1.0) except Exception: await com.close() raise - _logger.info('Connection on port %r established in %.1f seconds', port_name, time.monotonic() - begun_at) - - return Connection(event_loop=event_loop, - communicator=com, - device_info=device_info, - initial_register_data_msgs=registers, - general_status_with_ts=(general_status.timestamp, - GeneralStatusView.populate(general_status.fields)), - on_connection_loss=on_connection_loss, - on_general_status_update=on_general_status_update, - on_log_line=on_log_line, - general_status_update_period=general_status_update_period) + _logger.info( + "Connection on port %r established in %.1f seconds", + port_name, + time.monotonic() - begun_at, + ) + + return Connection( + event_loop=event_loop, + communicator=com, + device_info=device_info, + initial_register_data_msgs=registers, + general_status_with_ts=( + general_status.timestamp, + GeneralStatusView.populate(general_status.fields), + ), + on_connection_loss=on_connection_loss, + on_general_status_update=on_general_status_update, + on_log_line=on_log_line, + general_status_update_period=general_status_update_period, + ) def _unittest_connection(): @@ -410,14 +507,18 @@ def _unittest_connection(): import glob import asyncio - port_glob = os.environ.get('KUCHER_TEST_PORT', None) + port_glob = os.environ.get("KUCHER_TEST_PORT", None) if not port_glob: - pytest.skip('Skipping because the environment variable KUCHER_TEST_PORT is not set. ' - 'In order to test the device connection, set that variable to a name or a glob of a serial port. ' - 'If a glob is used, it must evaluate to exactly one port, otherwise the test will fail.') + pytest.skip( + "Skipping because the environment variable KUCHER_TEST_PORT is not set. " + "In order to test the device connection, set that variable to a name or a glob of a serial port. " + "If a glob is used, it must evaluate to exactly one port, otherwise the test will fail." + ) port = glob.glob(port_glob) - assert len(port) == 1, f'The glob was supposed to resolve to exactly one port; got {len(port)} ports.' + assert ( + len(port) == 1 + ), f"The glob was supposed to resolve to exactly one port; got {len(port)} ports." port = port[0] loop = asyncio.get_event_loop() @@ -430,43 +531,47 @@ async def run(): def on_connection_loss(reason): nonlocal num_connection_loss_notifications num_connection_loss_notifications += 1 - print(f'Connection lost! Reason: {reason}') + print(f"Connection lost! Reason: {reason}") def on_general_status_update(ts, rep): nonlocal num_status_reports num_status_reports += 1 - print(f'Status report at {ts}:\n{rep}') + print(f"Status report at {ts}:\n{rep}") assert num_status_reports == 0 assert num_connection_loss_notifications == 0 - print('Connecting...') - con = await connect(event_loop=loop, - port_name=port, - on_connection_loss=on_connection_loss, - on_general_status_update=on_general_status_update, - on_log_line=lambda *args: print('Log line:', *args), - on_progress_report=lambda *args: print('Progress report:', *args), - general_status_update_period=0.5) - print('Connected successfully') + print("Connecting...") + con = await connect( + event_loop=loop, + port_name=port, + on_connection_loss=on_connection_loss, + on_general_status_update=on_general_status_update, + on_log_line=lambda *args: print("Log line:", *args), + on_progress_report=lambda *args: print("Progress report:", *args), + general_status_update_period=0.5, + ) + print("Connected successfully") assert num_status_reports == 0 assert num_connection_loss_notifications == 0 - await asyncio.sleep(con._general_status_update_period * 2 + 0.4, loop=loop) + await asyncio.sleep(con._general_status_update_period * 2 + 0.4) assert num_status_reports == 2 assert num_connection_loss_notifications == 0 - assert 'zubax' in con.device_info.name - assert con.device_info.characteristics.vsi_model.resistance_per_phase[1].high > 0 + assert "zubax" in con.device_info.name + assert ( + con.device_info.characteristics.vsi_model.resistance_per_phase[1].high > 0 + ) assert con.last_general_status_with_timestamp[0] > 0 assert con.last_general_status_with_timestamp[1].timestamp > 0 # Simulate failure of the underlying port con._com._ch.close() - await asyncio.sleep(1, loop=loop) + await asyncio.sleep(1) assert num_status_reports == 2 assert num_connection_loss_notifications == 1 diff --git a/kucher/model/device_model/device_info_view.py b/kucher/model/device_model/device_info_view.py index 86dce4d..90aaaf6 100644 --- a/kucher/model/device_model/device_info_view.py +++ b/kucher/model/device_model/device_info_view.py @@ -16,10 +16,16 @@ import dataclasses import typing import datetime +from functools import partial _struct_view = dataclasses.dataclass(frozen=True) +def forward(_type, x): + """Remove _io parameter from the object""" + return _type(**{k: v for k, v in {**x}.items() if k != "_io"}) + + @_struct_view class SoftwareVersion: major: int @@ -70,7 +76,7 @@ class MathRange: class Characteristics: @_struct_view class Capabilities: - number_of_can_interfaces: int + number_of_can_interfaces: int battery_eliminator_circuit_available: bool @_struct_view @@ -78,78 +84,89 @@ class VSIModel: @_struct_view class HBR: high: float - low: float + low: float - resistance_per_phase: typing.Tuple[HBR, HBR, HBR] - gate_ton_toff_imbalance: float + resistance_per_phase: typing.Tuple[HBR, HBR, HBR] + gate_ton_toff_imbalance: float phase_current_measurement_error_variance: float @_struct_view class Limits: @_struct_view class AbsoluteMaximumRatings: - vsi_dc_voltage: MathRange + vsi_dc_voltage: MathRange @_struct_view class SafeOperatingArea: - vsi_dc_voltage: MathRange - vsi_dc_current: MathRange + vsi_dc_voltage: MathRange + vsi_dc_current: MathRange vsi_phase_current: MathRange - cpu_temperature: MathRange - vsi_temperature: MathRange + cpu_temperature: MathRange + vsi_temperature: MathRange @_struct_view class PhaseCurrentZeroBiasLimit: - low_gain: float + low_gain: float high_gain: float - absolute_maximum_ratings: AbsoluteMaximumRatings - safe_operating_area: SafeOperatingArea + absolute_maximum_ratings: AbsoluteMaximumRatings + safe_operating_area: SafeOperatingArea phase_current_zero_bias_limit: PhaseCurrentZeroBiasLimit @staticmethod def populate(msg: typing.Mapping): - amr = msg['absolute_maximum_ratings'] - soa = msg['safe_operating_area'] + amr = msg["absolute_maximum_ratings"] + soa = msg["safe_operating_area"] return Characteristics.Limits( absolute_maximum_ratings=Characteristics.Limits.AbsoluteMaximumRatings( - vsi_dc_voltage=MathRange(**amr['vsi_dc_voltage']), + vsi_dc_voltage=forward(MathRange, amr["vsi_dc_voltage"]), ), safe_operating_area=Characteristics.Limits.SafeOperatingArea( - vsi_dc_voltage=MathRange(**soa['vsi_dc_voltage']), - vsi_dc_current=MathRange(**soa['vsi_dc_current']), - vsi_phase_current=MathRange(**soa['vsi_phase_current']), - cpu_temperature=MathRange(**soa['cpu_temperature']), - vsi_temperature=MathRange(**soa['vsi_temperature']), + vsi_dc_voltage=forward(MathRange, soa["vsi_dc_voltage"]), + vsi_dc_current=forward(MathRange, soa["vsi_dc_current"]), + vsi_phase_current=forward(MathRange, soa["vsi_phase_current"]), + cpu_temperature=forward(MathRange, soa["cpu_temperature"]), + vsi_temperature=forward(MathRange, soa["vsi_temperature"]), ), phase_current_zero_bias_limit=Characteristics.Limits.PhaseCurrentZeroBiasLimit( - low_gain=msg['phase_current_zero_bias_limit']['low_gain'], - high_gain=msg['phase_current_zero_bias_limit']['high_gain'], + low_gain=msg["phase_current_zero_bias_limit"]["low_gain"], + high_gain=msg["phase_current_zero_bias_limit"]["high_gain"], ), ) capabilities: Capabilities - vsi_model: VSIModel - limits: Limits + vsi_model: VSIModel + limits: Limits @staticmethod - def populate(msg: typing.Mapping) -> 'Characteristics': + def populate(msg: typing.Mapping) -> "Characteristics": caps = Characteristics.Capabilities( - number_of_can_interfaces=int(msg['capability_flags']['doubly_redundant_can_bus']) + 1, - battery_eliminator_circuit_available=msg['capability_flags']['battery_eliminator_circuit'] + number_of_can_interfaces=int( + msg["capability_flags"]["doubly_redundant_can_bus"] + ) + + 1, + battery_eliminator_circuit_available=msg["capability_flags"][ + "battery_eliminator_circuit" + ], ) vsi_model = Characteristics.VSIModel( - resistance_per_phase=tuple(map(lambda x: Characteristics.VSIModel.HBR(**x), - msg['vsi_model']['resistance_per_phase'])), - gate_ton_toff_imbalance=msg['vsi_model']['gate_ton_toff_imbalance'], - phase_current_measurement_error_variance=msg['vsi_model']['phase_current_measurement_error_variance'], + resistance_per_phase=tuple( + map( + partial(forward, Characteristics.VSIModel.HBR), + msg["vsi_model"]["resistance_per_phase"], + ) + ), + gate_ton_toff_imbalance=msg["vsi_model"]["gate_ton_toff_imbalance"], + phase_current_measurement_error_variance=msg["vsi_model"][ + "phase_current_measurement_error_variance" + ], ) return Characteristics( capabilities=caps, vsi_model=vsi_model, - limits=Characteristics.Limits.populate(msg['limits']) + limits=Characteristics.Limits.populate(msg["limits"]), ) @@ -157,35 +174,43 @@ def _unittest_characteristics_populating(): from pytest import raises, approx sample = { - 'capability_flags': {'battery_eliminator_circuit': True, - 'doubly_redundant_can_bus': True}, - 'limits': {'absolute_maximum_ratings': {'vsi_dc_voltage': {'max': 62.040000915527344, - 'min': 4.0}}, - 'phase_current_zero_bias_limit': {'high_gain': 0.5, - 'low_gain': 2.0}, - 'safe_operating_area': {'cpu_temperature': {'max': 355.1499938964844, - 'min': 236.14999389648438}, - 'vsi_dc_current': {'max': 25.0, - 'min': -25.0}, - 'vsi_dc_voltage': {'max': 51.0, - 'min': 11.0}, - 'vsi_phase_current': {'max': 30.0, - 'min': -30.0}, - 'vsi_temperature': {'max': 358.1499938964844, - 'min': 233.14999389648438}}}, - 'vsi_model': {'gate_ton_toff_imbalance': -1.1000000021965661e-08, - 'phase_current_measurement_error_variance': 1.0, - 'resistance_per_phase': [{'high': 0.004000000189989805, - 'low': 0.007000000216066837}, - {'high': 0.004000000189989805, - 'low': 0.007000000216066837}, - {'high': 0.004000000189989805, - 'low': 0.004000000189989805}]} + "capability_flags": { + "battery_eliminator_circuit": True, + "doubly_redundant_can_bus": True, + }, + "limits": { + "absolute_maximum_ratings": { + "vsi_dc_voltage": {"max": 62.040000915527344, "min": 4.0} + }, + "phase_current_zero_bias_limit": {"high_gain": 0.5, "low_gain": 2.0}, + "safe_operating_area": { + "cpu_temperature": { + "max": 355.1499938964844, + "min": 236.14999389648438, + }, + "vsi_dc_current": {"max": 25.0, "min": -25.0}, + "vsi_dc_voltage": {"max": 51.0, "min": 11.0}, + "vsi_phase_current": {"max": 30.0, "min": -30.0}, + "vsi_temperature": { + "max": 358.1499938964844, + "min": 233.14999389648438, + }, + }, + }, + "vsi_model": { + "gate_ton_toff_imbalance": -1.1000000021965661e-08, + "phase_current_measurement_error_variance": 1.0, + "resistance_per_phase": [ + {"high": 0.004000000189989805, "low": 0.007000000216066837}, + {"high": 0.004000000189989805, "low": 0.007000000216066837}, + {"high": 0.004000000189989805, "low": 0.004000000189989805}, + ], + }, } - print('Sample:\n', sample, sep='') + print("Sample:\n", sample, sep="") pop = Characteristics.populate(sample) - print('Populated:\n', pop, sep='') + print("Populated:\n", pop, sep="") with raises(dataclasses.FrozenInstanceError): pop.limits = 123 @@ -198,20 +223,21 @@ def _unittest_characteristics_populating(): @_struct_view class DeviceInfoView: - name: str - description: str - build_environment_description: str - runtime_environment_description: str - software_version: SoftwareVersion - hardware_version: HardwareVersion - globally_unique_id: bytes - certificate_of_authenticity: bytes + name: str + description: str + build_environment_description: str + runtime_environment_description: str + software_version: SoftwareVersion + hardware_version: HardwareVersion + globally_unique_id: bytes + certificate_of_authenticity: bytes characteristics: Characteristics @staticmethod - def populate(node_info_message: NodeInfoMessage, - characteristics_message: typing.Mapping) -> 'DeviceInfoView': + def populate( + node_info_message: NodeInfoMessage, characteristics_message: typing.Mapping + ) -> "DeviceInfoView": return DeviceInfoView( name=node_info_message.node_name, description=node_info_message.node_description, diff --git a/kucher/model/device_model/general_status_view.py b/kucher/model/device_model/general_status_view.py index f417e1a..a695c52 100644 --- a/kucher/model/device_model/general_status_view.py +++ b/kucher/model/device_model/general_status_view.py @@ -18,117 +18,117 @@ from decimal import Decimal __all__ = [ - 'GeneralStatusView', - 'TaskID', - 'TaskSpecificStatusReport', - 'ControlMode', - 'MotorIdentificationMode', - 'LowLevelManipulationMode', - 'CONTROL_MODE_MAPPING', - 'LOW_LEVEL_MANIPULATION_MODE_MAPPING', - 'MOTOR_IDENTIFICATION_MODE_MAPPING', - 'TASK_ID_MAPPING', + "GeneralStatusView", + "TaskID", + "TaskSpecificStatusReport", + "ControlMode", + "MotorIdentificationMode", + "LowLevelManipulationMode", + "CONTROL_MODE_MAPPING", + "LOW_LEVEL_MANIPULATION_MODE_MAPPING", + "MOTOR_IDENTIFICATION_MODE_MAPPING", + "TASK_ID_MAPPING", ] _struct_view = dataclasses.dataclass(frozen=True) class TaskID(enum.Enum): - IDLE = enum.auto() - FAULT = enum.auto() - BEEP = enum.auto() - RUN = enum.auto() - HARDWARE_TEST = enum.auto() - MOTOR_IDENTIFICATION = enum.auto() - LOW_LEVEL_MANIPULATION = enum.auto() + IDLE = enum.auto() + FAULT = enum.auto() + BEEP = enum.auto() + RUN = enum.auto() + HARDWARE_TEST = enum.auto() + MOTOR_IDENTIFICATION = enum.auto() + LOW_LEVEL_MANIPULATION = enum.auto() class ControlMode(enum.Enum): - RATIOMETRIC_CURRENT = enum.auto() + RATIOMETRIC_CURRENT = enum.auto() RATIOMETRIC_ANGULAR_VELOCITY = enum.auto() - RATIOMETRIC_VOLTAGE = enum.auto() - CURRENT = enum.auto() - MECHANICAL_RPM = enum.auto() - VOLTAGE = enum.auto() + RATIOMETRIC_VOLTAGE = enum.auto() + CURRENT = enum.auto() + MECHANICAL_RPM = enum.auto() + VOLTAGE = enum.auto() CONTROL_MODE_MAPPING: typing.Dict[str, ControlMode] = { - 'ratiometric_current': ControlMode.RATIOMETRIC_CURRENT, - 'ratiometric_angular_velocity': ControlMode.RATIOMETRIC_ANGULAR_VELOCITY, - 'ratiometric_voltage': ControlMode.RATIOMETRIC_VOLTAGE, - 'current': ControlMode.CURRENT, - 'mechanical_rpm': ControlMode.MECHANICAL_RPM, - 'voltage': ControlMode.VOLTAGE, + "ratiometric_current": ControlMode.RATIOMETRIC_CURRENT, + "ratiometric_angular_velocity": ControlMode.RATIOMETRIC_ANGULAR_VELOCITY, + "ratiometric_voltage": ControlMode.RATIOMETRIC_VOLTAGE, + "current": ControlMode.CURRENT, + "mechanical_rpm": ControlMode.MECHANICAL_RPM, + "voltage": ControlMode.VOLTAGE, } class MotorIdentificationMode(enum.Enum): - R_L = enum.auto() - PHI = enum.auto() + R_L = enum.auto() + PHI = enum.auto() R_L_PHI = enum.auto() MOTOR_IDENTIFICATION_MODE_MAPPING = { - 'r_l': MotorIdentificationMode.R_L, - 'phi': MotorIdentificationMode.PHI, - 'r_l_phi': MotorIdentificationMode.R_L_PHI, + "r_l": MotorIdentificationMode.R_L, + "phi": MotorIdentificationMode.PHI, + "r_l_phi": MotorIdentificationMode.R_L_PHI, } class LowLevelManipulationMode(enum.Enum): - CALIBRATION = enum.auto() - PHASE_MANIPULATION = enum.auto() - SCALAR_CONTROL = enum.auto() + CALIBRATION = enum.auto() + PHASE_MANIPULATION = enum.auto() + SCALAR_CONTROL = enum.auto() LOW_LEVEL_MANIPULATION_MODE_MAPPING: typing.Dict[str, LowLevelManipulationMode] = { - 'calibration': LowLevelManipulationMode.CALIBRATION, - 'phase_manipulation': LowLevelManipulationMode.PHASE_MANIPULATION, - 'scalar_control': LowLevelManipulationMode.SCALAR_CONTROL, + "calibration": LowLevelManipulationMode.CALIBRATION, + "phase_manipulation": LowLevelManipulationMode.PHASE_MANIPULATION, + "scalar_control": LowLevelManipulationMode.SCALAR_CONTROL, } @_struct_view class AlertFlags: - dc_undervoltage: bool - dc_overvoltage: bool - dc_undercurrent: bool - dc_overcurrent: bool + dc_undervoltage: bool + dc_overvoltage: bool + dc_undercurrent: bool + dc_overcurrent: bool - cpu_cold: bool - cpu_overheating: bool - vsi_cold: bool - vsi_overheating: bool - motor_cold: bool - motor_overheating: bool + cpu_cold: bool + cpu_overheating: bool + vsi_cold: bool + vsi_overheating: bool + motor_cold: bool + motor_overheating: bool - hardware_lvps_malfunction: bool - hardware_fault: bool - hardware_overload: bool + hardware_lvps_malfunction: bool + hardware_fault: bool + hardware_overload: bool - phase_current_measurement_malfunction: bool + phase_current_measurement_malfunction: bool @_struct_view class StatusFlags: - uavcan_node_up: bool - can_data_link_up: bool + uavcan_node_up: bool + can_data_link_up: bool - usb_connected: bool - usb_power_supplied: bool + usb_connected: bool + usb_power_supplied: bool - rcpwm_signal_detected: bool + rcpwm_signal_detected: bool - phase_current_agc_high_gain_selected: bool - vsi_modulating: bool - vsi_enabled: bool + phase_current_agc_high_gain_selected: bool + vsi_modulating: bool + vsi_enabled: bool @_struct_view -class Temperature: # In Kelvin - cpu: float - vsi: float - motor: float +class Temperature: # In Kelvin + cpu: float + vsi: float + motor: float @staticmethod def convert_kelvin_to_celsius(x: float) -> float: @@ -137,74 +137,74 @@ def convert_kelvin_to_celsius(x: float) -> float: @_struct_view class DCQuantities: - voltage: float - current: float + voltage: float + current: float @_struct_view class PWMState: - period: float - dead_time: float - upper_limit: float + period: float + dead_time: float + upper_limit: float @_struct_view class HardwareFlagEdgeCounters: - lvps_malfunction: int - overload: int - fault: int + lvps_malfunction: int + overload: int + fault: int class TaskSpecificStatusReport: @_struct_view class Fault: - failed_task_id: TaskID - failed_task_exit_code: int + failed_task_id: TaskID + failed_task_exit_code: int @staticmethod def populate(fields: typing.Mapping): return TaskSpecificStatusReport.Fault( - failed_task_id=TASK_ID_MAPPING[fields['failed_task_id']][0], - failed_task_exit_code=fields['failed_task_exit_code'], + failed_task_id=TASK_ID_MAPPING[fields["failed_task_id"]][0], + failed_task_exit_code=fields["failed_task_exit_code"], ) @_struct_view class Run: - stall_count: int - demand_factor: float + stall_count: int + demand_factor: float # Mechanical parameters - electrical_angular_velocity: float - mechanical_angular_velocity: float - torque: float + electrical_angular_velocity: float + mechanical_angular_velocity: float + torque: float # Rotating system parameters - u_dq: typing.Tuple[float, float] - i_dq: typing.Tuple[float, float] + u_dq: typing.Tuple[float, float] + i_dq: typing.Tuple[float, float] # Control mode - mode: ControlMode + mode: ControlMode # Flags - spinup_in_progress: bool - rotation_reversed: bool - controller_saturated: bool + spinup_in_progress: bool + rotation_reversed: bool + controller_saturated: bool @staticmethod def populate(fields: typing.Mapping): def tuplize(what): return tuple(x for x in what) - mode = CONTROL_MODE_MAPPING[fields['mode']] + mode = CONTROL_MODE_MAPPING[fields["mode"]] return TaskSpecificStatusReport.Run( - stall_count=fields['stall_count'], - demand_factor=fields['demand_factor'], - electrical_angular_velocity=fields['electrical_angular_velocity'], - mechanical_angular_velocity=fields['mechanical_angular_velocity'], - torque=fields.get('torque', 0.0), # Not available until v0.2 - u_dq=tuplize(fields['u_dq']), - i_dq=tuplize(fields['i_dq']), + stall_count=fields["stall_count"], + demand_factor=fields["demand_factor"], + electrical_angular_velocity=fields["electrical_angular_velocity"], + mechanical_angular_velocity=fields["mechanical_angular_velocity"], + torque=fields.get("torque", 0.0), # Not available until v0.2 + u_dq=tuplize(fields["u_dq"]), + i_dq=tuplize(fields["i_dq"]), mode=mode, - spinup_in_progress=fields['spinup_in_progress'], - rotation_reversed=fields['rotation_reversed'], - controller_saturated=fields['controller_saturated'], + spinup_in_progress=fields["spinup_in_progress"], + rotation_reversed=fields["rotation_reversed"], + controller_saturated=fields["controller_saturated"], ) @_struct_view @@ -214,7 +214,7 @@ class HardwareTest: @staticmethod def populate(fields: typing.Mapping): return TaskSpecificStatusReport.HardwareTest( - progress=fields['progress'], + progress=fields["progress"], ) @_struct_view @@ -224,7 +224,7 @@ class MotorIdentification: @staticmethod def populate(fields: typing.Mapping): return TaskSpecificStatusReport.MotorIdentification( - progress=fields['progress'], + progress=fields["progress"], ) @_struct_view @@ -233,105 +233,121 @@ class LowLevelManipulation: @staticmethod def populate(fields: typing.Mapping): - mode = LOW_LEVEL_MANIPULATION_MODE_MAPPING[fields['mode']] + mode = LOW_LEVEL_MANIPULATION_MODE_MAPPING[fields["mode"]] return TaskSpecificStatusReport.LowLevelManipulation( mode=mode, ) - Union = typing.Union[Fault, Run, HardwareTest, MotorIdentification, LowLevelManipulation] + Union = typing.Union[ + Fault, Run, HardwareTest, MotorIdentification, LowLevelManipulation + ] TASK_ID_MAPPING = { - 'idle': (TaskID.IDLE, None), - 'fault': (TaskID.FAULT, TaskSpecificStatusReport.Fault), - 'beep': (TaskID.BEEP, None), - 'run': (TaskID.RUN, TaskSpecificStatusReport.Run), - 'hardware_test': (TaskID.HARDWARE_TEST, TaskSpecificStatusReport.HardwareTest), - 'motor_identification': (TaskID.MOTOR_IDENTIFICATION, TaskSpecificStatusReport.MotorIdentification), - 'low_level_manipulation': (TaskID.LOW_LEVEL_MANIPULATION, TaskSpecificStatusReport.LowLevelManipulation), + "idle": (TaskID.IDLE, None), + "fault": (TaskID.FAULT, TaskSpecificStatusReport.Fault), + "beep": (TaskID.BEEP, None), + "run": (TaskID.RUN, TaskSpecificStatusReport.Run), + "hardware_test": (TaskID.HARDWARE_TEST, TaskSpecificStatusReport.HardwareTest), + "motor_identification": ( + TaskID.MOTOR_IDENTIFICATION, + TaskSpecificStatusReport.MotorIdentification, + ), + "low_level_manipulation": ( + TaskID.LOW_LEVEL_MANIPULATION, + TaskSpecificStatusReport.LowLevelManipulation, + ), } @_struct_view class GeneralStatusView: - current_task_id: TaskID - timestamp: Decimal - alert_flags: AlertFlags - status_flags: StatusFlags - temperature: Temperature - dc: DCQuantities - pwm: PWMState - hardware_flag_edge_counters: HardwareFlagEdgeCounters - task_specific_status_report: typing.Optional[TaskSpecificStatusReport.Union] + current_task_id: TaskID + timestamp: Decimal + alert_flags: AlertFlags + status_flags: StatusFlags + temperature: Temperature + dc: DCQuantities + pwm: PWMState + hardware_flag_edge_counters: HardwareFlagEdgeCounters + task_specific_status_report: typing.Optional[TaskSpecificStatusReport.Union] @staticmethod - def populate(msg: typing.Mapping) -> 'GeneralStatusView': - task_id, task_specific_type = TASK_ID_MAPPING[msg['current_task_id']] + def populate(msg: typing.Mapping) -> "GeneralStatusView": + task_id, task_specific_type = TASK_ID_MAPPING[msg["current_task_id"]] def gf(name, default=None): - out = msg['status_flags'].get(name, default) + out = msg["status_flags"].get(name, default) if out is None: - raise ValueError(f'Flag is not available and default value not provided: {out}') + raise ValueError( + f"Flag is not available and default value not provided: {out}" + ) return out alert_flags = AlertFlags( - dc_undervoltage=gf('dc_undervoltage'), - dc_overvoltage=gf('dc_overvoltage'), - dc_undercurrent=gf('dc_undercurrent'), - dc_overcurrent=gf('dc_overcurrent'), - cpu_cold=gf('cpu_cold'), - cpu_overheating=gf('cpu_overheating'), - vsi_cold=gf('vsi_cold'), - vsi_overheating=gf('vsi_overheating'), - motor_cold=gf('motor_cold'), - motor_overheating=gf('motor_overheating'), - hardware_lvps_malfunction=gf('hardware_lvps_malfunction'), - hardware_fault=gf('hardware_fault'), - hardware_overload=gf('hardware_overload'), - phase_current_measurement_malfunction=gf('phase_current_measurement_malfunction'), + dc_undervoltage=gf("dc_undervoltage"), + dc_overvoltage=gf("dc_overvoltage"), + dc_undercurrent=gf("dc_undercurrent"), + dc_overcurrent=gf("dc_overcurrent"), + cpu_cold=gf("cpu_cold"), + cpu_overheating=gf("cpu_overheating"), + vsi_cold=gf("vsi_cold"), + vsi_overheating=gf("vsi_overheating"), + motor_cold=gf("motor_cold"), + motor_overheating=gf("motor_overheating"), + hardware_lvps_malfunction=gf("hardware_lvps_malfunction"), + hardware_fault=gf("hardware_fault"), + hardware_overload=gf("hardware_overload"), + phase_current_measurement_malfunction=gf( + "phase_current_measurement_malfunction" + ), ) status_flags = StatusFlags( - uavcan_node_up=gf('uavcan_node_up'), - can_data_link_up=gf('can_data_link_up'), - usb_connected=gf('usb_connected'), - usb_power_supplied=gf('usb_power_supplied'), - rcpwm_signal_detected=gf('rcpwm_signal_detected'), - phase_current_agc_high_gain_selected=gf('phase_current_agc_high_gain_selected'), - vsi_modulating=gf('vsi_modulating'), - vsi_enabled=gf('vsi_enabled'), + uavcan_node_up=gf("uavcan_node_up"), + can_data_link_up=gf("can_data_link_up"), + usb_connected=gf("usb_connected"), + usb_power_supplied=gf("usb_power_supplied"), + rcpwm_signal_detected=gf("rcpwm_signal_detected"), + phase_current_agc_high_gain_selected=gf( + "phase_current_agc_high_gain_selected" + ), + vsi_modulating=gf("vsi_modulating"), + vsi_enabled=gf("vsi_enabled"), ) temperature = Temperature( - cpu=msg['temperature']['cpu'], - vsi=msg['temperature']['vsi'], - motor=msg['temperature']['motor'], + cpu=msg["temperature"]["cpu"], + vsi=msg["temperature"]["vsi"], + motor=msg["temperature"]["motor"], ) dc = DCQuantities( - voltage=msg['dc']['voltage'], - current=msg['dc']['current'], + voltage=msg["dc"]["voltage"], + current=msg["dc"]["current"], ) pwm = PWMState( - period=msg['pwm']['period'], - dead_time=msg['pwm']['dead_time'], - upper_limit=msg['pwm']['upper_limit'], + period=msg["pwm"]["period"], + dead_time=msg["pwm"]["dead_time"], + upper_limit=msg["pwm"]["upper_limit"], ) hardware_flag_edge_counters = HardwareFlagEdgeCounters( - lvps_malfunction=msg['hardware_flag_edge_counters']['lvps_malfunction'], - overload=msg['hardware_flag_edge_counters']['overload'], - fault=msg['hardware_flag_edge_counters']['fault'], + lvps_malfunction=msg["hardware_flag_edge_counters"]["lvps_malfunction"], + overload=msg["hardware_flag_edge_counters"]["overload"], + fault=msg["hardware_flag_edge_counters"]["fault"], ) if task_specific_type: - task_specific_status_report = task_specific_type.populate(msg['task_specific_status_report']) + task_specific_status_report = task_specific_type.populate( + msg["task_specific_status_report"] + ) else: task_specific_status_report = None return GeneralStatusView( - timestamp=msg['timestamp'], + timestamp=msg["timestamp"], current_task_id=task_id, alert_flags=alert_flags, status_flags=status_flags, @@ -348,47 +364,54 @@ def _unittest_general_status_view(): from decimal import Decimal sample = { - 'current_task_id': 'idle', - 'dc': {'voltage': 14.907779693603516, - 'current': 0.0}, - 'hardware_flag_edge_counters': {'fault': 0, - 'lvps_malfunction': 0, - 'overload': 0}, - 'pwm': {'dead_time': 1.0000000116860974e-07, - 'period': 2.1277777705108747e-05, - 'upper_limit': 0.8825064897537231}, - 'status_flags': {'can_data_link_up': True, - 'cpu_cold': False, - 'cpu_overheating': False, - 'dc_overcurrent': False, - 'dc_overvoltage': False, - 'dc_undercurrent': False, - 'dc_undervoltage': False, - 'hardware_fault': False, - 'hardware_lvps_malfunction': False, - 'hardware_overload': False, - 'motor_cold': False, - 'motor_overheating': False, - 'phase_current_agc_high_gain_selected': True, - 'phase_current_measurement_malfunction': False, - 'rcpwm_signal_detected': False, - 'uavcan_node_up': True, - 'usb_connected': True, - 'usb_power_supplied': True, - 'vsi_cold': False, - 'vsi_enabled': False, - 'vsi_modulating': False, - 'vsi_overheating': False}, - 'task_specific_status_report': None, - 'temperature': {'cpu': 309.573974609375, - 'motor': 0.0, - 'vsi': 301.93438720703125}, - 'timestamp': Decimal('14.924033') + "current_task_id": "idle", + "dc": {"voltage": 14.907779693603516, "current": 0.0}, + "hardware_flag_edge_counters": { + "fault": 0, + "lvps_malfunction": 0, + "overload": 0, + }, + "pwm": { + "dead_time": 1.0000000116860974e-07, + "period": 2.1277777705108747e-05, + "upper_limit": 0.8825064897537231, + }, + "status_flags": { + "can_data_link_up": True, + "cpu_cold": False, + "cpu_overheating": False, + "dc_overcurrent": False, + "dc_overvoltage": False, + "dc_undercurrent": False, + "dc_undervoltage": False, + "hardware_fault": False, + "hardware_lvps_malfunction": False, + "hardware_overload": False, + "motor_cold": False, + "motor_overheating": False, + "phase_current_agc_high_gain_selected": True, + "phase_current_measurement_malfunction": False, + "rcpwm_signal_detected": False, + "uavcan_node_up": True, + "usb_connected": True, + "usb_power_supplied": True, + "vsi_cold": False, + "vsi_enabled": False, + "vsi_modulating": False, + "vsi_overheating": False, + }, + "task_specific_status_report": None, + "temperature": { + "cpu": 309.573974609375, + "motor": 0.0, + "vsi": 301.93438720703125, + }, + "timestamp": Decimal("14.924033"), } gs = GeneralStatusView.populate(sample) assert gs.current_task_id == TaskID.IDLE - assert gs.timestamp == Decimal('14.924033') + assert gs.timestamp == Decimal("14.924033") assert gs.temperature.cpu == approx(309.573974609375) diff --git a/kucher/model/device_model/register.py b/kucher/model/device_model/register.py index 8640157..8e926e7 100644 --- a/kucher/model/device_model/register.py +++ b/kucher/model/device_model/register.py @@ -18,7 +18,13 @@ import typing import itertools from decimal import Decimal -from popcop.standard.register import ValueType, Flags, ValueKind, VALUE_TYPE_TO_KIND, SCALAR_VALUE_TYPE_TO_NUMPY_TYPE +from popcop.standard.register import ( + ValueType, + Flags, + ValueKind, + VALUE_TYPE_TO_KIND, + SCALAR_VALUE_TYPE_TO_NUMPY_TYPE, +) from kucher.utils import Event @@ -37,10 +43,14 @@ float, ] -SetGetCallback = typing.Callable[[typing.Optional[StrictValueTypeAnnotation]], - typing.Awaitable[typing.Tuple[StrictValueTypeAnnotation, # Value - Decimal, # Device timestamp - float]]] # Monotonic timestamp +SetGetCallback = typing.Callable[ + [typing.Optional[StrictValueTypeAnnotation]], + typing.Awaitable[ + typing.Tuple[ + StrictValueTypeAnnotation, Decimal, float # Value # Device timestamp + ] + ], +] # Monotonic timestamp class Register: @@ -51,20 +61,23 @@ class Register: is ambiguous, an exception will be thrown. Note that this type is hashable and can be used in mappings like dict. """ + ValueType = ValueType ValueKind = ValueKind - def __init__(self, - name: str, - value: StrictValueTypeAnnotation, - default_value: typing.Optional[StrictValueTypeAnnotation], - min_value: typing.Optional[StrictValueTypeAnnotation], - max_value: typing.Optional[StrictValueTypeAnnotation], - type_id: ValueType, - flags: Flags, - update_timestamp_device_time: Decimal, - set_get_callback: SetGetCallback, - update_timestamp_monotonic: float = None): + def __init__( + self, + name: str, + value: StrictValueTypeAnnotation, + default_value: typing.Optional[StrictValueTypeAnnotation], + min_value: typing.Optional[StrictValueTypeAnnotation], + max_value: typing.Optional[StrictValueTypeAnnotation], + type_id: ValueType, + flags: Flags, + update_timestamp_device_time: Decimal, + set_get_callback: SetGetCallback, + update_timestamp_monotonic: float = None, + ): self._name = str(name) self._cached_value = value self._default_value = default_value @@ -72,7 +85,9 @@ def __init__(self, self._max_value = max_value self._type_id = ValueType(type_id) self._update_ts_device_time = Decimal(update_timestamp_device_time) - self._update_ts_monotonic = float(update_timestamp_monotonic or time.monotonic()) + self._update_ts_monotonic = float( + update_timestamp_monotonic or time.monotonic() + ) self._flags = flags self._set_get_callback = set_get_callback @@ -99,8 +114,7 @@ def cached_value_is_default_value(self) -> bool: if not self.has_default_value: return False - if self.type_id in (ValueType.F32, - ValueType.F64): + if self.type_id in (ValueType.F32, ValueType.F64): # Absolute tolerance equals the epsilon as per IEEE754 absolute_tolerance = { ValueType.F32: 1e-6, @@ -113,8 +127,14 @@ def cached_value_is_default_value(self) -> bool: ValueType.F64: 1e-13, }[self.type_id] - return all(map(lambda args: math.isclose(*args, rel_tol=relative_tolerance, abs_tol=absolute_tolerance), - itertools.zip_longest(self.cached_value, self.default_value))) + return all( + map( + lambda args: math.isclose( + *args, rel_tol=relative_tolerance, abs_tol=absolute_tolerance + ), + itertools.zip_longest(self.cached_value, self.default_value), + ) + ) else: return self.cached_value == self.default_value @@ -162,7 +182,9 @@ def update_event(self) -> Event: """ return self._update_event - async def write_through(self, value: RelaxedValueTypeAnnotation) -> StrictValueTypeAnnotation: + async def write_through( + self, value: RelaxedValueTypeAnnotation + ) -> StrictValueTypeAnnotation: """ Sets the provided value to the device, then requests the new value from the device, at the same time updating the cache with the latest state once the response is received. @@ -191,10 +213,12 @@ def get_numpy_type(type_id: ValueType) -> typing.Optional[numpy.dtype]: except KeyError: return None - def _sync(self, - value: RelaxedValueTypeAnnotation, - device_time: Decimal, - monotonic_time: float): + def _sync( + self, + value: RelaxedValueTypeAnnotation, + device_time: Decimal, + monotonic_time: float, + ): """This method is invoked from the Connection instance.""" self._cached_value = value self._update_ts_device_time = Decimal(device_time) @@ -213,21 +237,23 @@ def _stricten(value: RelaxedValueTypeAnnotation) -> StrictValueTypeAnnotation: return value else: try: - return [x for x in value] # Coerce to list + return [x for x in value] # Coerce to list except TypeError: - raise TypeError(f'Invalid type of register value: {type(value)!r}') + raise TypeError(f"Invalid type of register value: {type(value)!r}") def __str__(self): # Monotonic timestamps are imprecise, so we print them with a low number of decimal places. # Device-provided timestamps are extremely accurate (sub-microsecond resolution and precision). # We use "!s" with the enum, otherwise it prints as int (quite surprising). - out = f'name={self.name!r}, type_id={self.type_id!s}, ' \ - f'cached={self.cached_value!r}, default={self.default_value!r}, ' \ - f'min={self.min_value!r}, max={self.max_value!r}, ' \ - f'mutable={self.mutable}, persistent={self.persistent}, ' \ - f'ts_device={self.update_timestamp_device_time:.9f}, ts_mono={self.update_timestamp_monotonic:.3f}' - - return f'Register({out})' + out = ( + f"name={self.name!r}, type_id={self.type_id!s}, " + f"cached={self.cached_value!r}, default={self.default_value!r}, " + f"min={self.min_value!r}, max={self.max_value!r}, " + f"mutable={self.mutable}, persistent={self.persistent}, " + f"ts_device={self.update_timestamp_device_time:.9f}, ts_mono={self.update_timestamp_monotonic:.3f}" + ) + + return f"Register({out})" __repr__ = __str__ diff --git a/kucher/model/device_model/task_statistics_view.py b/kucher/model/device_model/task_statistics_view.py index 36dda5d..6a6f46f 100644 --- a/kucher/model/device_model/task_statistics_view.py +++ b/kucher/model/device_model/task_statistics_view.py @@ -17,43 +17,46 @@ from decimal import Decimal from .general_status_view import TaskID, TASK_ID_MAPPING -__all__ = ['TaskStatisticsView'] +__all__ = ["TaskStatisticsView"] _struct_view = dataclasses.dataclass(frozen=True) @_struct_view class SingleTaskStatistics: - last_started_at: Decimal - last_stopped_at: Decimal - total_run_time: Decimal - number_of_times_started: int - number_of_times_failed: int - last_exit_code: int + last_started_at: Decimal + last_stopped_at: Decimal + total_run_time: Decimal + number_of_times_started: int + number_of_times_failed: int + last_exit_code: int @staticmethod - def populate(msg: typing.Mapping) -> 'SingleTaskStatistics': + def populate(msg: typing.Mapping) -> "SingleTaskStatistics": return SingleTaskStatistics( - last_started_at=msg['last_started_at'], - last_stopped_at=msg['last_stopped_at'], - total_run_time=msg['total_run_time'], - number_of_times_started=msg['number_of_times_started'], - number_of_times_failed=msg['number_of_times_failed'], - last_exit_code=msg['last_exit_code'], + last_started_at=msg["last_started_at"], + last_stopped_at=msg["last_stopped_at"], + total_run_time=msg["total_run_time"], + number_of_times_started=msg["number_of_times_started"], + number_of_times_failed=msg["number_of_times_failed"], + last_exit_code=msg["last_exit_code"], ) @_struct_view class TaskStatisticsView: - timestamp: Decimal = Decimal() - entries: typing.Dict[TaskID, SingleTaskStatistics] = dataclasses.field(default_factory=lambda: {}) + timestamp: Decimal = Decimal() + entries: typing.Dict[TaskID, SingleTaskStatistics] = dataclasses.field( + default_factory=lambda: {} + ) @staticmethod - def populate(msg: typing.Mapping) -> 'TaskStatisticsView': + def populate(msg: typing.Mapping) -> "TaskStatisticsView": return TaskStatisticsView( - timestamp=msg['timestamp'], + timestamp=msg["timestamp"], entries={ - TASK_ID_MAPPING[item['task_id']][0]: SingleTaskStatistics.populate(item) for item in msg['entries'] + TASK_ID_MAPPING[item["task_id"]][0]: SingleTaskStatistics.populate(item) + for item in msg["entries"] }, ) @@ -62,57 +65,72 @@ def _unittest_task_statistics_view(): from decimal import Decimal sample = { - 'entries': [ - {'last_exit_code': 194, - 'last_started_at': Decimal('3.017389'), - 'last_stopped_at': Decimal('3.017432'), - 'number_of_times_failed': 2, - 'number_of_times_started': 2, - 'task_id': 'idle', - 'total_run_time': Decimal('0.000062')}, - {'last_exit_code': 0, - 'last_started_at': Decimal('3.017432'), - 'last_stopped_at': Decimal('3.017389'), - 'number_of_times_failed': 0, - 'number_of_times_started': 3, - 'task_id': 'fault', - 'total_run_time': Decimal('27.093250')}, - {'last_exit_code': 0, - 'last_started_at': Decimal('0.000000'), - 'last_stopped_at': Decimal('0.000000'), - 'number_of_times_failed': 0, - 'number_of_times_started': 0, - 'task_id': 'beep', - 'total_run_time': Decimal('0.000000')}, - {'last_exit_code': 0, - 'last_started_at': Decimal('0.000000'), - 'last_stopped_at': Decimal('0.000000'), - 'number_of_times_failed': 0, - 'number_of_times_started': 0, - 'task_id': 'run', - 'total_run_time': Decimal('0.000000')}, - {'last_exit_code': 2, - 'last_started_at': Decimal('0.016381'), - 'last_stopped_at': Decimal('2.025321'), - 'number_of_times_failed': 1, - 'number_of_times_started': 1, - 'task_id': 'hardware_test', - 'total_run_time': Decimal('2.008939')}, - {'last_exit_code': 0, - 'last_started_at': Decimal('0.000000'), - 'last_stopped_at': Decimal('0.000000'), - 'number_of_times_failed': 0, - 'number_of_times_started': 0, - 'task_id': 'motor_identification', - 'total_run_time': Decimal('0.000000')}, - {'last_exit_code': 0, - 'last_started_at': Decimal('0.000000'), - 'last_stopped_at': Decimal('0.000000'), - 'number_of_times_failed': 0, - 'number_of_times_started': 0, - 'task_id': 'low_level_manipulation', - 'total_run_time': Decimal('0.000000')}], - 'timestamp': Decimal('29.114152') + "entries": [ + { + "last_exit_code": 194, + "last_started_at": Decimal("3.017389"), + "last_stopped_at": Decimal("3.017432"), + "number_of_times_failed": 2, + "number_of_times_started": 2, + "task_id": "idle", + "total_run_time": Decimal("0.000062"), + }, + { + "last_exit_code": 0, + "last_started_at": Decimal("3.017432"), + "last_stopped_at": Decimal("3.017389"), + "number_of_times_failed": 0, + "number_of_times_started": 3, + "task_id": "fault", + "total_run_time": Decimal("27.093250"), + }, + { + "last_exit_code": 0, + "last_started_at": Decimal("0.000000"), + "last_stopped_at": Decimal("0.000000"), + "number_of_times_failed": 0, + "number_of_times_started": 0, + "task_id": "beep", + "total_run_time": Decimal("0.000000"), + }, + { + "last_exit_code": 0, + "last_started_at": Decimal("0.000000"), + "last_stopped_at": Decimal("0.000000"), + "number_of_times_failed": 0, + "number_of_times_started": 0, + "task_id": "run", + "total_run_time": Decimal("0.000000"), + }, + { + "last_exit_code": 2, + "last_started_at": Decimal("0.016381"), + "last_stopped_at": Decimal("2.025321"), + "number_of_times_failed": 1, + "number_of_times_started": 1, + "task_id": "hardware_test", + "total_run_time": Decimal("2.008939"), + }, + { + "last_exit_code": 0, + "last_started_at": Decimal("0.000000"), + "last_stopped_at": Decimal("0.000000"), + "number_of_times_failed": 0, + "number_of_times_started": 0, + "task_id": "motor_identification", + "total_run_time": Decimal("0.000000"), + }, + { + "last_exit_code": 0, + "last_started_at": Decimal("0.000000"), + "last_stopped_at": Decimal("0.000000"), + "number_of_times_failed": 0, + "number_of_times_started": 0, + "task_id": "low_level_manipulation", + "total_run_time": Decimal("0.000000"), + }, + ], + "timestamp": Decimal("29.114152"), } tsv = TaskStatisticsView.populate(sample) @@ -122,5 +140,5 @@ def _unittest_task_statistics_view(): assert tsv.entries[TaskID.HARDWARE_TEST].last_exit_code == 2 assert tsv.entries[TaskID.HARDWARE_TEST].number_of_times_started == 1 assert tsv.entries[TaskID.HARDWARE_TEST].number_of_times_failed == 1 - assert tsv.entries[TaskID.HARDWARE_TEST].last_started_at == Decimal('0.016381') - assert tsv.timestamp == Decimal('29.114152') + assert tsv.entries[TaskID.HARDWARE_TEST].last_started_at == Decimal("0.016381") + assert tsv.timestamp == Decimal("29.114152") diff --git a/kucher/resources.py b/kucher/resources.py index 10d6621..57bd7d8 100644 --- a/kucher/resources.py +++ b/kucher/resources.py @@ -15,7 +15,7 @@ import os import sys -if getattr(sys, 'frozen', False): +if getattr(sys, "frozen", False): # https://pythonhosted.org/PyInstaller/runtime-information.html # noinspection PyUnresolvedReferences, PyProtectedMember PACKAGE_ROOT = sys._MEIPASS @@ -24,9 +24,11 @@ def get_absolute_path(*relative_path_items: str, check_existence=False) -> str: - out = os.path.abspath(os.path.join(PACKAGE_ROOT, *relative_path_items)).replace('\\', '/') + out = os.path.abspath(os.path.join(PACKAGE_ROOT, *relative_path_items)).replace( + "\\", "/" + ) if check_existence: if not os.path.exists(out): - raise ValueError(f'The specified path does not exist: {out}') + raise ValueError(f"The specified path does not exist: {out}") return out diff --git a/kucher/utils.py b/kucher/utils.py index b6973f6..01103d9 100644 --- a/kucher/utils.py +++ b/kucher/utils.py @@ -29,6 +29,7 @@ def synchronized(method): https://github.com/GrahamDumpleton/wrapt/blob/develop/blog/07-the-missing-synchronized-decorator.md https://github.com/GrahamDumpleton/wrapt/blob/develop/blog/08-the-synchronized-decorator-as-context-manager.md """ + def decorator(self, *arg, **kws): with self._lock: return method(self, *arg, **kws) @@ -39,15 +40,15 @@ def decorator(self, *arg, **kws): class Event: def __init__(self): self._handlers: typing.Set[typing.Callable] = set() - self._logger = getLogger(__name__ + f'.Event[{self}]') + self._logger = getLogger(__name__ + f".Event[{self}]") - def connect(self, handler: typing.Callable) -> 'Event': - self._logger.debug('Adding new handler %r', handler) + def connect(self, handler: typing.Callable) -> "Event": + self._logger.debug("Adding new handler %r", handler) self._handlers.add(handler) return self # noinspection PyUnusedLocal - def connect_weak(self, instance, unbound_method: 'type(Event.connect)') -> 'Event': + def connect_weak(self, instance, unbound_method: "type(Event.connect)") -> "Event": """ Adds a weak handler that points to a method. This callback will be automatically removed when the pointed-to object is garbage collected. Observe that we require a reference to an instance @@ -58,15 +59,19 @@ def connect_weak(self, instance, unbound_method: 'type(Event.connect)') -> 'Even https://stackoverflow.com/questions/5394772/why-are-my-weakrefs-dead-in-the-water-when-they-point-to-a-method """ weak_instance = weakref.ref(instance) - instance_as_str = repr(instance) # We're formatting it right now because we can't keep strong references - instance = None # Erase to prevent accidental re-use + instance_as_str = repr( + instance + ) # We're formatting it right now because we can't keep strong references + instance = None # Erase to prevent accidental re-use if inspect.ismethod(unbound_method): - raise TypeError(f'Usage of bound methods, lambdas, and proxy functions as weak handlers is not ' - f'possible, because these are dedicated objects themselves and they will be ' - f'garbage collected immediately after the last strong reference is removed. ' - f'You should use unbound methods instead. ' - f'Here are the arguments that you tried to use: {instance}, {unbound_method}') + raise TypeError( + f"Usage of bound methods, lambdas, and proxy functions as weak handlers is not " + f"possible, because these are dedicated objects themselves and they will be " + f"garbage collected immediately after the last strong reference is removed. " + f"You should use unbound methods instead. " + f"Here are the arguments that you tried to use: {instance}, {unbound_method}" + ) # noinspection PyShadowingNames @functools.wraps(unbound_method) @@ -75,26 +80,30 @@ def proxy(*args, **kwargs): if instance is not None: return unbound_method(instance, *args, **kwargs) else: - self._logger.info('Weak reference has died: %r', instance_as_str) + self._logger.info("Weak reference has died: %r", instance_as_str) self.disconnect(proxy) return self.connect(proxy) - def disconnect(self, handler: typing.Callable) -> 'Event': - self._logger.debug('Removing handler %r', handler) + def disconnect(self, handler: typing.Callable) -> "Event": + self._logger.debug("Removing handler %r", handler) try: self._handlers.remove(handler) except LookupError: - raise ValueError(f'Handler {handler} is not registered') from None + raise ValueError(f"Handler {handler} is not registered") from None return self def emit(self, *args, **kwargs): - for handler in list(self._handlers): # The invocation list can be modified from callbacks! + for handler in list( + self._handlers + ): # The invocation list can be modified from callbacks! try: handler(*args, **kwargs) except Exception as ex: - self._logger.exception('Unhandled exception %r in the handler %r', ex, handler) + self._logger.exception( + "Unhandled exception %r in the handler %r", ex, handler + ) @property def num_handlers(self): @@ -114,27 +123,27 @@ def _unittest_event(): e = Event() assert e.num_handlers == 0 e() - e(123, '456') + e(123, "456") - acc = '' + acc = "" def acc_add(*s): nonlocal acc - acc += ''.join(s) + acc += "".join(s) e.connect(acc_add) - e('123', 'abc') - e('def') + e("123", "abc") + e("def") e() assert len(e) == 1 - assert acc == '123abcdef' + assert acc == "123abcdef" with raises(ValueError): e.disconnect(lambda a: None) e.disconnect(acc_add) assert len(e) == 0 - e(123, '456') + e(123, "456") # noinspection PyMethodMayBeStatic class Holder: @@ -143,21 +152,21 @@ def __init__(self, evt: Event): def receiver(self, *args): nonlocal acc - acc += ''.join(args) + acc += "".join(args) assert len(e) == 0 holder = Holder(e) assert len(e) == 1 - e(' Weak') + e(" Weak") assert len(e) == 1 del holder gc.collect() assert len(e) == 1 - e('Dead x_x') + e("Dead x_x") assert len(e) == 0 - assert acc == '123abcdef Weak' + assert acc == "123abcdef Weak" holder = Holder(e) assert len(e) == 1 @@ -169,6 +178,6 @@ def receiver(self, *args): gc.collect() assert len(e) == 1 - e('Dead x_x') + e("Dead x_x") assert len(e) == 0 - assert acc == '123abcdef Weak' + assert acc == "123abcdef Weak" diff --git a/kucher/version.py b/kucher/version.py index 93a8856..149e88f 100644 --- a/kucher/version.py +++ b/kucher/version.py @@ -13,8 +13,8 @@ # Author: Pavel Kirienko # -__version__ = 1, 0, 0 +__version__ = 1, 1, 0 -__author__ = "Pavel Kirienko" +__author__ = "Zubax Robotics" __license__ = "GPLv3" __email__ = "pavel.kirienko@zubax.com" diff --git a/kucher/view/device_model_representation.py b/kucher/view/device_model_representation.py index 89ac4ab..33e48cf 100644 --- a/kucher/view/device_model_representation.py +++ b/kucher/view/device_model_representation.py @@ -20,9 +20,21 @@ # We keep it this way while the codebase is new and fluid. In the future we may want to come up with an # independent state representation in View, and add a converter into Fuhrer. # noinspection PyUnresolvedReferences -from kucher.model.device_model import GeneralStatusView, TaskStatisticsView, TaskID, TaskSpecificStatusReport, Commander +from kucher.model.device_model import ( + GeneralStatusView, + TaskStatisticsView, + TaskID, + TaskSpecificStatusReport, + Commander, +) + # noinspection PyUnresolvedReferences -from kucher.model.device_model import ControlMode, MotorIdentificationMode, LowLevelManipulationMode +from kucher.model.device_model import ( + ControlMode, + MotorIdentificationMode, + LowLevelManipulationMode, +) + # noinspection PyUnresolvedReferences from kucher.model.device_model import Register @@ -30,13 +42,13 @@ _TASK_ID_TO_ICON_MAPPING: typing.Dict[TaskID, str] = { - TaskID.IDLE: 'sleep', - TaskID.FAULT: 'skull', - TaskID.BEEP: 'speaker', - TaskID.RUN: 'running', - TaskID.HARDWARE_TEST: 'pass-fail', - TaskID.MOTOR_IDENTIFICATION: 'caliper', - TaskID.LOW_LEVEL_MANIPULATION: 'hand-button', + TaskID.IDLE: "sleep", + TaskID.FAULT: "skull", + TaskID.BEEP: "speaker", + TaskID.RUN: "running", + TaskID.HARDWARE_TEST: "pass-fail", + TaskID.MOTOR_IDENTIFICATION: "caliper", + TaskID.LOW_LEVEL_MANIPULATION: "hand-button", } @@ -45,39 +57,52 @@ def get_icon_name_for_task_id(tid: TaskID) -> str: try: return _TASK_ID_TO_ICON_MAPPING[tid] except KeyError: - return 'question-mark' + return "question-mark" @cached -def get_human_friendly_task_name(tid: TaskID, - multi_line=False, - short=False) -> str: - words = str(tid).split('.')[-1].split('_') - out = ' '.join([w.capitalize() for w in words]) +def get_human_friendly_task_name(tid: TaskID, multi_line=False, short=False) -> str: + words = str(tid).split(".")[-1].split("_") + out = " ".join([w.capitalize() for w in words]) - if short and (' ' in out): # If short name requested, collapse multi-word names into acronyms - out = ''.join([w[0].upper() for w in out.split()]) + if short and ( + " " in out + ): # If short name requested, collapse multi-word names into acronyms + out = "".join([w[0].upper() for w in out.split()]) if multi_line: - out = '\n'.join(out.rsplit(' ', 1)) + out = "\n".join(out.rsplit(" ", 1)) return out @cached -def get_human_friendly_control_mode_name_and_its_icon_name(control_mode: ControlMode, - short=False) -> typing.Tuple[str, str]: +def get_human_friendly_control_mode_name_and_its_icon_name( + control_mode: ControlMode, short=False +) -> typing.Tuple[str, str]: try: full_name, short_name, icon_name = { - ControlMode.RATIOMETRIC_CURRENT: ('Ratiometric current', '%A', 'muscle-percent'), - ControlMode.RATIOMETRIC_ANGULAR_VELOCITY: ('Ratiometric RPM', '%\u03C9', 'rotation-percent'), - ControlMode.RATIOMETRIC_VOLTAGE: ('Ratiometric voltage', '%V', 'voltage-percent'), - ControlMode.CURRENT: ('Current', 'A', 'muscle'), - ControlMode.MECHANICAL_RPM: ('Mechanical RPM', 'RPM', 'rotation'), - ControlMode.VOLTAGE: ('Voltage', 'V', 'voltage'), + ControlMode.RATIOMETRIC_CURRENT: ( + "Ratiometric current", + "%A", + "muscle-percent", + ), + ControlMode.RATIOMETRIC_ANGULAR_VELOCITY: ( + "Ratiometric RPM", + "%\u03C9", + "rotation-percent", + ), + ControlMode.RATIOMETRIC_VOLTAGE: ( + "Ratiometric voltage", + "%V", + "voltage-percent", + ), + ControlMode.CURRENT: ("Current", "A", "muscle"), + ControlMode.MECHANICAL_RPM: ("Mechanical RPM", "RPM", "rotation"), + ControlMode.VOLTAGE: ("Voltage", "V", "voltage"), }[control_mode] except KeyError: - return str(control_mode).split('.')[-1].replace('_', ' '), 'question-mark' + return str(control_mode).split(".")[-1].replace("_", " "), "question-mark" else: return (short_name if short else full_name), icon_name @@ -104,10 +129,10 @@ class HardwareVersion: @dataclass class BasicDeviceInfo: - name: str - description: str - build_environment_description: str - runtime_environment_description: str - software_version: SoftwareVersion - hardware_version: HardwareVersion - globally_unique_id: bytes + name: str + description: str + build_environment_description: str + runtime_environment_description: str + software_version: SoftwareVersion + hardware_version: HardwareVersion + globally_unique_id: bytes diff --git a/kucher/view/main_window/__init__.py b/kucher/view/main_window/__init__.py index 67a9828..3cf18f3 100644 --- a/kucher/view/main_window/__init__.py +++ b/kucher/view/main_window/__init__.py @@ -19,31 +19,47 @@ from kucher.data_dir import LOG_DIR from kucher.view.utils import get_application_icon, get_icon, is_small_screen -from kucher.view.tool_window_manager import ToolWindowManager, ToolWindowLocation, ToolWindowGroupingCondition -from kucher.view.device_model_representation import GeneralStatusView, TaskStatisticsView, BasicDeviceInfo, Commander,\ - Register - -from .device_management_widget import ConnectionRequestCallback, DisconnectionRequestCallback +from kucher.view.tool_window_manager import ( + ToolWindowManager, + ToolWindowLocation, + ToolWindowGroupingCondition, +) +from kucher.view.device_model_representation import ( + GeneralStatusView, + TaskStatisticsView, + BasicDeviceInfo, + Commander, + Register, +) + +from .device_management_widget import ( + ConnectionRequestCallback, + DisconnectionRequestCallback, +) from .main_widget import MainWidget from .task_statistics_widget import TaskStatisticsWidget from .log_widget import LogWidget from .register_view_widget import RegisterViewWidget -_WINDOW_TITLE_PREFIX = 'Zubax Kucher' +_WINDOW_TITLE_PREFIX = "Zubax Kucher" -TaskStatisticsRequestCallback = typing.Callable[[], typing.Awaitable[typing.Optional[TaskStatisticsView]]] +TaskStatisticsRequestCallback = typing.Callable[ + [], typing.Awaitable[typing.Optional[TaskStatisticsView]] +] class MainWindow(QMainWindow): # noinspection PyCallByClass,PyUnresolvedReferences,PyArgumentList - def __init__(self, - on_close: typing.Callable[[], None], - on_connection_request: ConnectionRequestCallback, - on_disconnection_request: DisconnectionRequestCallback, - on_task_statistics_request: TaskStatisticsRequestCallback, - commander: Commander): + def __init__( + self, + on_close: typing.Callable[[], None], + on_connection_request: ConnectionRequestCallback, + on_disconnection_request: DisconnectionRequestCallback, + on_task_statistics_request: TaskStatisticsRequestCallback, + commander: Commander, + ): super(MainWindow, self).__init__() self.setWindowTitle(_WINDOW_TITLE_PREFIX) self.setWindowIcon(get_application_icon()) @@ -55,28 +71,35 @@ def __init__(self, self._registers: typing.List[Register] = None - self._main_widget = MainWidget(self, - on_connection_request=on_connection_request, - on_disconnection_request=on_disconnection_request, - commander=commander) + self._main_widget = MainWidget( + self, + on_connection_request=on_connection_request, + on_disconnection_request=on_disconnection_request, + commander=commander, + ) self._configure_file_menu() self._configure_tool_windows(on_task_statistics_request) self._configure_help_menu() - self._tool_window_manager.tool_window_resize_event.connect(lambda *_: self._readjust_size_policies()) - self._tool_window_manager.new_tool_window_event.connect(lambda *_: self._readjust_size_policies()) - self._tool_window_manager.tool_window_removed_event.connect(lambda *_: self._readjust_size_policies()) + self._tool_window_manager.tool_window_resize_event.connect( + lambda *_: self._readjust_size_policies() + ) + self._tool_window_manager.new_tool_window_event.connect( + lambda *_: self._readjust_size_policies() + ) + self._tool_window_manager.tool_window_removed_event.connect( + lambda *_: self._readjust_size_policies() + ) self._main_widget.resize_event.connect(self._readjust_size_policies) - self._main_widget.setSizePolicy(QSizePolicy.Minimum, - QSizePolicy.Minimum) + self._main_widget.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum) self.setCentralWidget(self._main_widget) - def on_connection_established(self, - device_info: BasicDeviceInfo, - registers: typing.List[Register]): + def on_connection_established( + self, device_info: BasicDeviceInfo, registers: typing.List[Register] + ): self._main_widget.on_connection_established(device_info) for w in self._tool_window_manager.select_widgets(LogWidget): w.on_device_connected(device_info) @@ -85,7 +108,9 @@ def on_connection_established(self, for w in self._tool_window_manager.select_widgets(RegisterViewWidget): w.setup(self._registers) - self.setWindowTitle(f'{_WINDOW_TITLE_PREFIX} - #{device_info.globally_unique_id.hex()}') + self.setWindowTitle( + f"{_WINDOW_TITLE_PREFIX} - #{device_info.globally_unique_id.hex()}" + ) def on_connection_loss(self, reason: str): self._main_widget.on_connection_loss(reason) @@ -98,10 +123,12 @@ def on_connection_loss(self, reason: str): self.setWindowTitle(_WINDOW_TITLE_PREFIX) - def on_connection_initialization_progress_report(self, - stage_description: str, - progress: float): - self._main_widget.on_connection_initialization_progress_report(stage_description, progress) + def on_connection_initialization_progress_report( + self, stage_description: str, progress: float + ): + self._main_widget.on_connection_initialization_progress_report( + stage_description, progress + ) def on_general_status_update(self, timestamp: float, status: GeneralStatusView): self._main_widget.on_general_status_update(timestamp, status) @@ -110,25 +137,31 @@ def on_log_line_reception(self, monotonic_timestamp: float, text: str): for w in self._tool_window_manager.select_widgets(LogWidget): w.append_lines([text]) - def _configure_tool_windows(self, - on_task_statistics_request: TaskStatisticsRequestCallback): - self._tool_window_manager.add_arrangement_rule(apply_to=[LogWidget, TaskStatisticsWidget], - group_when=ToolWindowGroupingCondition.ALWAYS, - location=ToolWindowLocation.BOTTOM) - - self._tool_window_manager.add_arrangement_rule(apply_to=[RegisterViewWidget], - group_when=ToolWindowGroupingCondition.ALWAYS, - location=ToolWindowLocation.RIGHT) - - self._tool_window_manager.register(lambda parent: TaskStatisticsWidget(parent, on_task_statistics_request), - 'Task statistics', - 'spreadsheet', - shown_by_default=not is_small_screen()) - - self._tool_window_manager.register(LogWidget, - 'Device log', - 'log', - shown_by_default=True) + def _configure_tool_windows( + self, on_task_statistics_request: TaskStatisticsRequestCallback + ): + self._tool_window_manager.add_arrangement_rule( + apply_to=[LogWidget, TaskStatisticsWidget], + group_when=ToolWindowGroupingCondition.ALWAYS, + location=ToolWindowLocation.BOTTOM, + ) + + self._tool_window_manager.add_arrangement_rule( + apply_to=[RegisterViewWidget], + group_when=ToolWindowGroupingCondition.ALWAYS, + location=ToolWindowLocation.RIGHT, + ) + + self._tool_window_manager.register( + lambda parent: TaskStatisticsWidget(parent, on_task_statistics_request), + "Task statistics", + "spreadsheet", + shown_by_default=not is_small_screen(), + ) + + self._tool_window_manager.register( + LogWidget, "Device log", "log", shown_by_default=True + ) def spawn_register_widget(parent: QWidget): w = RegisterViewWidget(parent) @@ -137,33 +170,42 @@ def spawn_register_widget(parent: QWidget): return w - self._tool_window_manager.register(spawn_register_widget, - 'Registers', - 'data', - shown_by_default=True) + self._tool_window_manager.register( + spawn_register_widget, "Registers", "data", shown_by_default=True + ) # noinspection PyCallByClass,PyUnresolvedReferences,PyArgumentList def _configure_file_menu(self): # File menu - quit_action = QAction(get_icon('exit'), '&Quit', self) + quit_action = QAction(get_icon("exit"), "&Quit", self) quit_action.triggered.connect(self._on_close) - file_menu = self.menuBar().addMenu('&File') + file_menu = self.menuBar().addMenu("&File") file_menu.addAction(quit_action) # noinspection PyCallByClass,PyUnresolvedReferences,PyArgumentList def _configure_help_menu(self): # Help menu - website_action = QAction(get_icon('www'), 'Open Zubax Robotics &website', self) - website_action.triggered.connect(lambda: QDesktopServices.openUrl(QUrl('https://zubax.com'))) - - knowledge_base_action = QAction(get_icon('knowledge'), 'Open Zubax &Knowledge Base website', self) - knowledge_base_action.triggered.connect(lambda: QDesktopServices.openUrl(QUrl('https://kb.zubax.com'))) - - show_log_directory_action = QAction(get_icon('log'), 'Open &log directory', self) - show_log_directory_action.triggered.connect(lambda: QDesktopServices.openUrl(QUrl.fromLocalFile(LOG_DIR))) - - help_menu = self.menuBar().addMenu('&Help') + website_action = QAction(get_icon("www"), "Open Zubax Robotics &website", self) + website_action.triggered.connect( + lambda: QDesktopServices.openUrl(QUrl("https://zubax.com")) + ) + + knowledge_base_action = QAction( + get_icon("knowledge"), "Open Zubax &Knowledge Base website", self + ) + knowledge_base_action.triggered.connect( + lambda: QDesktopServices.openUrl(QUrl("https://kb.zubax.com")) + ) + + show_log_directory_action = QAction( + get_icon("log"), "Open &log directory", self + ) + show_log_directory_action.triggered.connect( + lambda: QDesktopServices.openUrl(QUrl.fromLocalFile(LOG_DIR)) + ) + + help_menu = self.menuBar().addMenu("&Help") help_menu.addAction(website_action) help_menu.addAction(knowledge_base_action) help_menu.addAction(show_log_directory_action) @@ -176,16 +218,24 @@ def _readjust_size_policies(self): # TODO: remember the largest width hint, use it in order to prevent back and forth resizing when # TODO: the content changes? - docked_tb = \ - self._tool_window_manager.select_widgets(current_location=ToolWindowLocation.TOP) + \ - self._tool_window_manager.select_widgets(current_location=ToolWindowLocation.BOTTOM) - - docked_lr = \ - self._tool_window_manager.select_widgets(current_location=ToolWindowLocation.LEFT) + \ - self._tool_window_manager.select_widgets(current_location=ToolWindowLocation.RIGHT) - - self.centralWidget().setMaximumHeight(height_hint if len(docked_tb) else QWIDGETSIZE_MAX) - self.centralWidget().setMaximumWidth(width_hint if len(docked_lr) else QWIDGETSIZE_MAX) + docked_tb = self._tool_window_manager.select_widgets( + current_location=ToolWindowLocation.TOP + ) + self._tool_window_manager.select_widgets( + current_location=ToolWindowLocation.BOTTOM + ) + + docked_lr = self._tool_window_manager.select_widgets( + current_location=ToolWindowLocation.LEFT + ) + self._tool_window_manager.select_widgets( + current_location=ToolWindowLocation.RIGHT + ) + + self.centralWidget().setMaximumHeight( + height_hint if len(docked_tb) else QWIDGETSIZE_MAX + ) + self.centralWidget().setMaximumWidth( + width_hint if len(docked_lr) else QWIDGETSIZE_MAX + ) def closeEvent(self, event: QCloseEvent): self._on_close() diff --git a/kucher/view/main_window/device_management_widget/__init__.py b/kucher/view/main_window/device_management_widget/__init__.py index 13010e5..f8f50af 100644 --- a/kucher/view/main_window/device_management_widget/__init__.py +++ b/kucher/view/main_window/device_management_widget/__init__.py @@ -16,11 +16,25 @@ import string import asyncio from logging import getLogger -from PyQt5.QtWidgets import QWidget, QHBoxLayout, QComboBox, QCompleter, QStackedLayout, QLabel, QProgressBar +from PyQt5.QtWidgets import ( + QWidget, + QHBoxLayout, + QComboBox, + QCompleter, + QStackedLayout, + QLabel, + QProgressBar, +) from PyQt5.QtWidgets import QVBoxLayout from PyQt5.QtCore import QTimer, Qt -from kucher.view.utils import get_monospace_font, gui_test, make_button, show_error, get_icon +from kucher.view.utils import ( + get_monospace_font, + gui_test, + make_button, + show_error, + get_icon, +) from kucher.view.device_model_representation import BasicDeviceInfo from kucher.view.widgets import WidgetBase @@ -31,7 +45,7 @@ _logger = getLogger(__name__) -_STATUS_WHEN_NOT_CONNECTED = 'Not connected' +_STATUS_WHEN_NOT_CONNECTED = "Not connected" ConnectionRequestCallback = typing.Callable[[str], typing.Awaitable[BasicDeviceInfo]] @@ -40,15 +54,19 @@ class DeviceManagementWidget(WidgetBase): # noinspection PyArgumentList,PyUnresolvedReferences - def __init__(self, - parent: typing.Optional[QWidget], - on_connection_request: ConnectionRequestCallback, - on_disconnection_request: DisconnectionRequestCallback): + def __init__( + self, + parent: typing.Optional[QWidget], + on_connection_request: ConnectionRequestCallback, + on_disconnection_request: DisconnectionRequestCallback, + ): super(DeviceManagementWidget, self).__init__(parent) - self.setAttribute(Qt.WA_DeleteOnClose) # This is required to stop background timers! + self.setAttribute( + Qt.WA_DeleteOnClose + ) # This is required to stop background timers! self._port_discoverer = PortDiscoverer() - self._port_mapping: typing.Dict[str: str] = {} + self._port_mapping: typing.Dict[str:str] = {} self._port_combo = QComboBox(self) self._port_combo.setEditable(True) @@ -57,11 +75,16 @@ def __init__(self, self._port_combo.setFont(get_monospace_font()) self._port_combo.lineEdit().returnPressed.connect(self._on_confirmation) - self._connect_button = make_button(self, 'Connect', 'disconnected', on_clicked=self._on_confirmation) - self._connect_button.setEnabled(False) # Empty by default, therefore disabled + self._connect_button = make_button( + self, "Connect", "disconnected", on_clicked=self._on_confirmation + ) + self._connect_button.setEnabled(False) # Empty by default, therefore disabled self._port_combo.currentTextChanged.connect( - lambda: self._connect_button.setEnabled(bool(self._port_combo.currentText().strip()))) + lambda: self._connect_button.setEnabled( + bool(self._port_combo.currentText().strip()) + ) + ) self._status_text = QLabel(self) self._status_text.setText(_STATUS_WHEN_NOT_CONNECTED) @@ -85,8 +108,12 @@ def __init__(self, self._connection_established = False self._last_task: typing.Optional[asyncio.Task] = None - self._connection_request_callback: ConnectionRequestCallback = on_connection_request - self._disconnection_request_callback: DisconnectionRequestCallback = on_disconnection_request + self._connection_request_callback: ConnectionRequestCallback = ( + on_connection_request + ) + self._disconnection_request_callback: DisconnectionRequestCallback = ( + on_disconnection_request + ) # Layout self._overlay = QStackedLayout(self) @@ -104,11 +131,13 @@ def on_connection_loss(self, reason: str): """ if self._connection_established: self._switch_state_disconnected() - self._status_text.setText(f'Connection lost: {reason.strip() or "Unknown reason"}') + self._status_text.setText( + f'Connection lost: {reason.strip() or "Unknown reason"}' + ) - def on_connection_initialization_progress_report(self, - stage_description: str, - progress: float): + def on_connection_initialization_progress_report( + self, stage_description: str, progress: float + ): """ This method should be periodically invoked while connection is being initialized. :param stage_description: Human-readable short string displaying what is currently being done. @@ -117,16 +146,20 @@ def on_connection_initialization_progress_report(self, where 0 - nothing, 1 - all done. """ if self._overlay.currentIndex() != 1: - raise RuntimeError('Invalid usage: this method can only be invoked when connection initialization is ' - 'in progress. Currently it is not.') + raise RuntimeError( + "Invalid usage: this method can only be invoked when connection initialization is " + "in progress. Currently it is not." + ) # noinspection PyTypeChecker if not (0.0 <= progress <= 1.0): - _logger.error(f'Connection progress estimate falls outside of [0, 1]: {progress}') + _logger.error( + f"Connection progress estimate falls outside of [0, 1]: {progress}" + ) stage_description = stage_description.strip() if stage_description[-1] in string.ascii_letters: - stage_description += '...' + stage_description += "..." self._connection_progress_bar.setValue(int(progress * 100)) self._connection_progress_bar.setFormat(stage_description) @@ -138,7 +171,7 @@ def _init_overlay_widgets(self): operational = WidgetBase(self) operational_layout_top = QHBoxLayout() - operational_layout_top.addWidget(QLabel('Port:')) + operational_layout_top.addWidget(QLabel("Port:")) operational_layout_top.addWidget(self._port_combo, stretch=1) operational_layout_top.addWidget(self._connect_button) @@ -170,11 +203,13 @@ def _update_ports(self): try: ports = self._port_discoverer.get_ports() except Exception as ex: - _logger.exception('Could not list ports') - self.flash(f'Could not list ports: {ex}', duration=10) + _logger.exception("Could not list ports") + self.flash(f"Could not list ports: {ex}", duration=10) ports = [] - self._port_mapping = self._port_discoverer.display_ports(ports, self._port_combo) + self._port_mapping = self._port_discoverer.display_ports( + ports, self._port_combo + ) def _switch_state_connected(self, device_info: BasicDeviceInfo): self._connection_established = True @@ -183,10 +218,10 @@ def _switch_state_connected(self, device_info: BasicDeviceInfo): self._port_combo.setEnabled(False) self._connect_button.setEnabled(True) - self._connect_button.setText('Disconnect') - self._connect_button.setIcon(get_icon('connected')) + self._connect_button.setText("Disconnect") + self._connect_button.setIcon(get_icon("connected")) - self._status_text.setText('Connected') + self._status_text.setText("Connected") self._device_info_widget.set(device_info) def _switch_state_disconnected(self): @@ -196,8 +231,8 @@ def _switch_state_disconnected(self): self._port_combo.setEnabled(True) self._connect_button.setEnabled(True) - self._connect_button.setText('Connect') - self._connect_button.setIcon(get_icon('disconnected')) + self._connect_button.setText("Connect") + self._connect_button.setIcon(get_icon("disconnected")) self._device_info_widget.clear() self._status_text.setText(_STATUS_WHEN_NOT_CONNECTED) @@ -205,44 +240,50 @@ def _switch_state_disconnected(self): self._update_ports() async def _do_connect(self): - _logger.info('Connection initialization task spawned') + _logger.info("Connection initialization task spawned") try: - selected_port = self._port_mapping[str(self._port_combo.currentText()).strip()] + selected_port = self._port_mapping[ + str(self._port_combo.currentText()).strip() + ] except KeyError: selected_port = str(self._port_combo.currentText()).strip() # Activate the progress view and initialize it self._overlay.setCurrentIndex(1) self._connection_progress_bar.setValue(0) - self._connection_progress_bar.setFormat('Requesting connection...') + self._connection_progress_bar.setFormat("Requesting connection...") # noinspection PyBroadException try: - device_info: BasicDeviceInfo = await self._connection_request_callback(selected_port) + device_info: BasicDeviceInfo = await self._connection_request_callback( + selected_port + ) except Exception as ex: - show_error('Could not connect', - f'Connection via the port {selected_port} could not be established.', - f'Reason: {str(ex)}', - parent=self) + show_error( + "Could not connect", + f"Connection via the port {selected_port} could not be established.", + f"Reason: {str(ex)}", + parent=self, + ) self._switch_state_disconnected() else: assert device_info is not None self._switch_state_connected(device_info) async def _do_disconnect(self): - _logger.info('Connection termination task spawned') + _logger.info("Connection termination task spawned") # Activate the progress view and initialize it self._overlay.setCurrentIndex(1) self._connection_progress_bar.setValue(100) - self._connection_progress_bar.setFormat('Disconnecting, please wait...') + self._connection_progress_bar.setFormat("Disconnecting, please wait...") # noinspection PyBroadException try: await self._disconnection_request_callback() except Exception as ex: - _logger.exception('Disconnect request failed') - self.flash(f'Disconnection problem: {ex}', duration=10) + _logger.exception("Disconnect request failed") + self.flash(f"Disconnection problem: {ex}", duration=10) self._switch_state_disconnected() @@ -252,15 +293,21 @@ def _on_confirmation(self): self._connect_button.setEnabled(False) if (self._last_task is not None) and not self._last_task.done(): - show_error("I'm sorry Dave, I'm afraid I can't do that", - 'Cannot connect/disconnect while another connection/disconnection operation is still running', - f'Pending future: {self._last_task}', - self) + show_error( + "I'm sorry Dave, I'm afraid I can't do that", + "Cannot connect/disconnect while another connection/disconnection operation is still running", + f"Pending future: {self._last_task}", + self, + ) else: if not self._connection_established: - self._last_task = asyncio.get_event_loop().create_task(self._do_connect()) + self._last_task = asyncio.get_event_loop().create_task( + self._do_connect() + ) else: - self._last_task = asyncio.get_event_loop().create_task(self._do_disconnect()) + self._last_task = asyncio.get_event_loop().create_task( + self._do_disconnect() + ) # noinspection PyGlobalUndefined @@ -274,23 +321,23 @@ def list_prospective_ports_mock(*_): class Mock: @staticmethod def manufacturer(): - return 'Vendor' + return "Vendor" @staticmethod def description(): - return 'Product' + return "Product" @staticmethod def portName(): - return 'PortName' + return "PortName" @staticmethod def systemLocation(): - return 'SystemLocation' + return "SystemLocation" @staticmethod def serialNumber(): - return 'SerialNumber' + return "SerialNumber" return [Mock()] @@ -310,73 +357,79 @@ async def walk(): nonlocal good_night_sweet_prince, throw await asyncio.sleep(2) - print('Connect button click') + print("Connect button click") assert not widget._connection_established widget._on_confirmation() # Should be CONNECTED now await asyncio.sleep(10) - print('Disconnect button click') + print("Disconnect button click") assert widget._connection_established widget._on_confirmation() # Should be DISCONNECTED now await asyncio.sleep(2) - print('Connect button click with error') + print("Connect button click with error") assert not widget._connection_established throw = True widget._on_confirmation() # Should be DISCONNECTED now await asyncio.sleep(10) - print('Connect button click without error, connection loss later') + print("Connect button click without error, connection loss later") assert not widget._connection_established throw = False widget._on_confirmation() # Should be CONNECTED now await asyncio.sleep(10) - print('Connection loss') + print("Connection loss") assert widget._connection_established widget.on_connection_loss("You're half machine, half pussy!") # Should be DISCONNECTED now await asyncio.sleep(5) - print('Termination') + print("Termination") assert not widget._connection_established good_night_sweet_prince = True widget.close() async def on_connection_request(selected_port): - print('CONNECTION REQUESTED:', selected_port) - assert selected_port == 'SystemLocation' # See above + print("CONNECTION REQUESTED:", selected_port) + assert selected_port == "SystemLocation" # See above await asyncio.sleep(0.5) - widget.on_connection_initialization_progress_report('First stage', 0.2) + widget.on_connection_initialization_progress_report("First stage", 0.2) await asyncio.sleep(0.5) - widget.on_connection_initialization_progress_report('Second stage', 0.4) + widget.on_connection_initialization_progress_report("Second stage", 0.4) await asyncio.sleep(0.5) - widget.on_connection_initialization_progress_report('Third stage', 0.6) + widget.on_connection_initialization_progress_report("Third stage", 0.6) await asyncio.sleep(0.5) - widget.on_connection_initialization_progress_report('Fourth stage', 0.7) + widget.on_connection_initialization_progress_report("Fourth stage", 0.7) if throw: - raise RuntimeError('Houston we have a problem!') + raise RuntimeError("Houston we have a problem!") await asyncio.sleep(0.5) - widget.on_connection_initialization_progress_report('Success!', 1.0) - - from kucher.view.device_model_representation import SoftwareVersion, HardwareVersion - out = BasicDeviceInfo(name='com.zubax.whatever', - description='Joo Janta 200 Super-Chromatic Peril Sensitive Sunglasses', - globally_unique_id=b'0123456789abcdef', - build_environment_description='', - runtime_environment_description='', - software_version=SoftwareVersion(), - hardware_version=HardwareVersion()) + widget.on_connection_initialization_progress_report("Success!", 1.0) + + from kucher.view.device_model_representation import ( + SoftwareVersion, + HardwareVersion, + ) + + out = BasicDeviceInfo( + name="com.zubax.whatever", + description="Joo Janta 200 Super-Chromatic Peril Sensitive Sunglasses", + globally_unique_id=b"0123456789abcdef", + build_environment_description="", + runtime_environment_description="", + software_version=SoftwareVersion(), + hardware_version=HardwareVersion(), + ) out.software_version.major = 1 out.software_version.minor = 2 out.software_version.dirty_build = True @@ -386,20 +439,19 @@ async def on_connection_request(selected_port): return out async def on_disconnection_request(): - print('DISCONNECTION REQUESTED') + print("DISCONNECTION REQUESTED") await asyncio.sleep(0.5) app = QApplication([]) - widget = DeviceManagementWidget(None, - on_connection_request=on_connection_request, - on_disconnection_request=on_disconnection_request) + widget = DeviceManagementWidget( + None, + on_connection_request=on_connection_request, + on_disconnection_request=on_disconnection_request, + ) widget.show() - asyncio.get_event_loop().run_until_complete(asyncio.gather( - run_events(), - walk() - )) + asyncio.get_event_loop().run_until_complete(asyncio.gather(run_events(), walk())) # Restore the global state carefully PortDiscoverer.get_ports = original_get_ports diff --git a/kucher/view/main_window/device_management_widget/little_bobby_tables_widget.py b/kucher/view/main_window/device_management_widget/little_bobby_tables_widget.py index fb5fdc2..2079932 100644 --- a/kucher/view/main_window/device_management_widget/little_bobby_tables_widget.py +++ b/kucher/view/main_window/device_management_widget/little_bobby_tables_widget.py @@ -14,7 +14,13 @@ import os from logging import getLogger -from PyQt5.QtWidgets import QWidget, QTableWidget, QTableWidgetItem, QVBoxLayout, QApplication +from PyQt5.QtWidgets import ( + QWidget, + QTableWidget, + QTableWidgetItem, + QVBoxLayout, + QApplication, +) from PyQt5.QtGui import QKeyEvent, QKeySequence from PyQt5.QtCore import Qt @@ -35,9 +41,13 @@ def __init__(self, parent: QWidget): self._table.setColumnCount(1) self._table.horizontalHeader().hide() self._table.setFont(get_monospace_font()) - self._table.horizontalHeader().setSectionResizeMode(self._table.horizontalHeader().ResizeToContents) + self._table.horizontalHeader().setSectionResizeMode( + self._table.horizontalHeader().ResizeToContents + ) self._table.horizontalHeader().setStretchLastSection(True) - self._table.verticalHeader().setSectionResizeMode(self._table.verticalHeader().ResizeToContents) + self._table.verticalHeader().setSectionResizeMode( + self._table.verticalHeader().ResizeToContents + ) lay_on_macduff = QVBoxLayout() lay_on_macduff.addWidget(self._table) @@ -48,25 +58,27 @@ def set(self, device_info: BasicDeviceInfo): self.clear() sw_ver = device_info.software_version - sw_string = f'{sw_ver.major}.{sw_ver.minor}.{sw_ver.vcs_commit_id:08x}.{sw_ver.image_crc:16x}' + sw_string = f"{sw_ver.major}.{sw_ver.minor}.{sw_ver.vcs_commit_id:08x}.{sw_ver.image_crc:16x}" if sw_ver.dirty_build: - sw_string += '-dirty' + sw_string += "-dirty" if not sw_ver.release_build: - sw_string += '-debug' + sw_string += "-debug" hw_ver = device_info.hardware_version - self._assign_many([ - ('Device name', device_info.name), - ('Device description', device_info.description), - ('Software version', sw_string), - ('Software build time', sw_ver.build_timestamp_utc.isoformat()), - ('Hardware version', f'{hw_ver.major}.{hw_ver.minor}'), - ('Unique ID', device_info.globally_unique_id.hex()), - ('Runtime environment', device_info.runtime_environment_description), - ('Build environment', device_info.build_environment_description), - ]) + self._assign_many( + [ + ("Device name", device_info.name), + ("Device description", device_info.description), + ("Software version", sw_string), + ("Software build time", sw_ver.build_timestamp_utc.isoformat()), + ("Hardware version", f"{hw_ver.major}.{hw_ver.minor}"), + ("Unique ID", device_info.globally_unique_id.hex()), + ("Runtime environment", device_info.runtime_environment_description), + ("Build environment", device_info.build_environment_description), + ] + ) def clear(self): self._table.setRowCount(0) @@ -89,8 +101,12 @@ def make_item(value): # noinspection PyArgumentList def keyPressEvent(self, event: QKeyEvent): if event.matches(QKeySequence.Copy): - selected_rows = [x.row() for x in self._table.selectionModel().selectedIndexes()] - _logger.info('Copying the following rows to the clipboard: %r', selected_rows) + selected_rows = [ + x.row() for x in self._table.selectionModel().selectedIndexes() + ] + _logger.info( + "Copying the following rows to the clipboard: %r", selected_rows + ) if len(selected_rows) == 1: out_strings = [self._table.item(selected_rows[0], 0).text()] @@ -99,7 +115,7 @@ def keyPressEvent(self, event: QKeyEvent): for row in selected_rows: header = self._table.verticalHeaderItem(row).text() data = self._table.item(row, 0).text() - out_strings.append(f'{header}\t{data}') + out_strings.append(f"{header}\t{data}") if out_strings: QApplication.clipboard().setText(os.linesep.join(out_strings)) diff --git a/kucher/view/main_window/device_management_widget/port_discoverer.py b/kucher/view/main_window/device_management_widget/port_discoverer.py index 24ceb7f..5d2c371 100644 --- a/kucher/view/main_window/device_management_widget/port_discoverer.py +++ b/kucher/view/main_window/device_management_widget/port_discoverer.py @@ -30,7 +30,7 @@ # The objective is to always pre-select the best guess, best choice port, so that the user could connect in # just one click. Vendor-product pairs located at the beginning of the list are preferred. _PREFERABLE_VENDOR_PRODUCT_PATTERNS: typing.List[typing.Tuple[str, str]] = [ - ('*zubax*', '*telega*'), + ("*zubax*", "*telega*"), ] @@ -38,6 +38,7 @@ class PortDiscoverer: """ This is a bit low-effort, I mostly pulled this code from the UAVCAN GUI Tool. """ + def __init__(self): pass @@ -55,16 +56,22 @@ def get_score(pi: QtSerialPort.QSerialPortInfo) -> int: # noinspection PyBroadException try: - ports = sorted(ports, key=lambda pi: pi.manufacturer() + pi.description() + pi.systemLocation()) + ports = sorted( + ports, + key=lambda pi: pi.manufacturer() + + pi.description() + + pi.systemLocation(), + ) except Exception: - _logger.exception('Pre-sorting failed') + _logger.exception("Pre-sorting failed") ports = sorted(ports, key=lambda pi: -get_score(pi)) return list(ports) @staticmethod - def display_ports(ports: typing.List[QtSerialPort.QSerialPortInfo], - combo: QComboBox) -> typing.Dict[str, str]: + def display_ports( + ports: typing.List[QtSerialPort.QSerialPortInfo], combo: QComboBox + ) -> typing.Dict[str, str]: known_keys = set() remove_indices = [] was_empty = combo.count() == 0 @@ -72,7 +79,7 @@ def display_ports(ports: typing.List[QtSerialPort.QSerialPortInfo], def make_description(p: QtSerialPort.QSerialPortInfo) -> str: out = f'{p.portName()}: {p.manufacturer() or "Unknown vendor"} - {p.description() or "Unknown product"}' if p.serialNumber().strip(): - out += ' #' + str(p.serialNumber()) + out += " #" + str(p.serialNumber()) return out @@ -93,7 +100,7 @@ def make_description(p: QtSerialPort.QSerialPortInfo) -> str: known_keys.add(tx) if tx not in description_location_mapping: - _logger.debug('Removing port %r', tx) + _logger.debug("Removing port %r", tx) remove_indices.append(idx) # Removing - starting from the last item in order to retain indexes @@ -103,7 +110,7 @@ def make_description(p: QtSerialPort.QSerialPortInfo) -> str: # Adding new items - starting from the last item in order to retain the final order for key in list(description_location_mapping.keys())[::-1]: if key not in known_keys: - _logger.debug('Adding port %r', key) + _logger.debug("Adding port %r", key) try: combo.insertItem(0, description_icon_mapping[key], key) except KeyError: @@ -116,16 +123,24 @@ def make_description(p: QtSerialPort.QSerialPortInfo) -> str: return description_location_mapping -def _get_index_of_first_matching_preferable_pattern(pi: QtSerialPort.QSerialPortInfo) -> typing.Optional[int]: - for idx, (vendor_wildcard, product_wildcard) in enumerate(_PREFERABLE_VENDOR_PRODUCT_PATTERNS): - vendor_match = fnmatch.fnmatch(pi.manufacturer().lower(), vendor_wildcard.lower()) - product_match = fnmatch.fnmatch(pi.description().lower(), product_wildcard.lower()) +def _get_index_of_first_matching_preferable_pattern( + pi: QtSerialPort.QSerialPortInfo, +) -> typing.Optional[int]: + for idx, (vendor_wildcard, product_wildcard) in enumerate( + _PREFERABLE_VENDOR_PRODUCT_PATTERNS + ): + vendor_match = fnmatch.fnmatch( + pi.manufacturer().lower(), vendor_wildcard.lower() + ) + product_match = fnmatch.fnmatch( + pi.description().lower(), product_wildcard.lower() + ) if vendor_match and product_match: return idx def _get_port_icon(p: QtSerialPort.QSerialPortInfo) -> typing.Optional[QIcon]: if _get_index_of_first_matching_preferable_pattern(p) is not None: - return get_icon('zee') + return get_icon("zee") else: - return get_icon('question-mark') + return get_icon("question-mark") diff --git a/kucher/view/main_window/log_widget/__init__.py b/kucher/view/main_window/log_widget/__init__.py index 560768b..e22a4da 100644 --- a/kucher/view/main_window/log_widget/__init__.py +++ b/kucher/view/main_window/log_widget/__init__.py @@ -17,7 +17,14 @@ import datetime from dataclasses import dataclass from logging import getLogger -from PyQt5.QtWidgets import QWidget, QTableView, QLabel, QVBoxLayout, QHBoxLayout, QApplication +from PyQt5.QtWidgets import ( + QWidget, + QTableView, + QLabel, + QVBoxLayout, + QHBoxLayout, + QApplication, +) from PyQt5.QtCore import Qt, QAbstractTableModel, QModelIndex, QVariant from PyQt5.QtGui import QFontMetrics, QFont, QPalette, QKeyEvent, QKeySequence @@ -36,9 +43,13 @@ class LogWidget(WidgetBase): # noinspection PyUnresolvedReferences,PyArgumentList def __init__(self, parent: QWidget): super(LogWidget, self).__init__(parent) - self.setAttribute(Qt.WA_DeleteOnClose) # This is required to stop background timers! + self.setAttribute( + Qt.WA_DeleteOnClose + ) # This is required to stop background timers! - self._clear_button = make_button(self, 'Clear', 'delete-document', on_clicked=self._do_clear) + self._clear_button = make_button( + self, "Clear", "delete-document", on_clicked=self._do_clear + ) self._status_display = QLabel(self) self._model = _TableModel(self) @@ -64,31 +75,33 @@ def on_device_connected(self, di: BasicDeviceInfo): swv = di.software_version hwv = di.hardware_version - sw_str = f'{swv.major}.{swv.minor}.{swv.vcs_commit_id:08x}' + sw_str = f"{swv.major}.{swv.minor}.{swv.vcs_commit_id:08x}" if not swv.release_build: - sw_str += '-debug' + sw_str += "-debug" if swv.dirty_build: - sw_str += '-dirty' + sw_str += "-dirty" - hw_str = f'{hwv.major}.{hwv.minor}' + hw_str = f"{hwv.major}.{hwv.minor}" - self._model.append_special_event(f'Connected to {di.name!r} SW v{sw_str} HW v{hw_str} ' - f'#{di.globally_unique_id.hex()}') + self._model.append_special_event( + f"Connected to {di.name!r} SW v{sw_str} HW v{hw_str} " + f"#{di.globally_unique_id.hex()}" + ) def on_device_disconnected(self, reason: str): - self._model.append_special_event(f'Disconnected: {reason}') + self._model.append_special_event(f"Disconnected: {reason}") def _do_clear(self): self._model.clear() def _on_model_changed(self): - self._status_display.setText(f'{self._model.rowCount()} rows') + self._status_display.setText(f"{self._model.rowCount()} rows") class _TableView(QTableView): # noinspection PyUnresolvedReferences - def __init__(self, parent, model: '_TableModel'): + def __init__(self, parent, model: "_TableModel"): super(_TableView, self).__init__(parent) self._model = model @@ -96,12 +109,16 @@ def __init__(self, parent, model: '_TableModel'): model.layoutChanged.connect(self._do_scroll) - self.horizontalHeader().setSectionResizeMode(self.horizontalHeader().ResizeToContents) + self.horizontalHeader().setSectionResizeMode( + self.horizontalHeader().ResizeToContents + ) self.horizontalHeader().setStretchLastSection(True) # ResizeToContents may be inefficient, but it is necessary for proper word wrapping self.verticalHeader().setDefaultSectionSize(model.font_height) - self.verticalHeader().setSectionResizeMode(self.verticalHeader().ResizeToContents) + self.verticalHeader().setSectionResizeMode( + self.verticalHeader().ResizeToContents + ) self.setWordWrap(True) self.setSortingEnabled(False) @@ -112,8 +129,12 @@ def __init__(self, parent, model: '_TableModel'): # noinspection PyArgumentList def keyPressEvent(self, event: QKeyEvent): if event.matches(QKeySequence.Copy): - selected_indexes = [(x.row(), x.column()) for x in self.selectionModel().selectedIndexes()] - _logger.info('Copying the following items to the clipboard: %r', selected_indexes) + selected_indexes = [ + (x.row(), x.column()) for x in self.selectionModel().selectedIndexes() + ] + _logger.info( + "Copying the following items to the clipboard: %r", selected_indexes + ) # Dicts are ordered now. Yay! by_row = {} @@ -122,8 +143,16 @@ def keyPressEvent(self, event: QKeyEvent): out_strings = [] for row, column_list in by_row.items(): - out_strings.append('\t'.join([self._model.render_item_for_clipboard(self._model.index(row, col)) - for col in column_list])) + out_strings.append( + "\t".join( + [ + self._model.render_item_for_clipboard( + self._model.index(row, col) + ) + for col in column_list + ] + ) + ) if out_strings: QApplication.clipboard().setText(os.linesep.join(out_strings)) @@ -132,7 +161,9 @@ def keyPressEvent(self, event: QKeyEvent): def _do_scroll(self): try: - relative_scroll_position = self.verticalScrollBar().value() / self.verticalScrollBar().maximum() + relative_scroll_position = ( + self.verticalScrollBar().value() / self.verticalScrollBar().maximum() + ) except ZeroDivisionError: relative_scroll_position = 1.0 @@ -151,16 +182,16 @@ def decorator(self, *args, **kwargs): class _TableModel(QAbstractTableModel): # TODO: Print device time as well! That would require modifications to the device model classes. COLUMNS = [ - 'Local time', - 'Text', + "Local time", + "Text", ] @dataclass class Entry: - local_time: datetime.datetime - text: str + local_time: datetime.datetime + text: str line_is_terminated: bool - is_special_event: bool = False + is_special_event: bool = False def __init__(self, parent: QWidget): super(_TableModel, self).__init__(parent) @@ -174,9 +205,11 @@ def __init__(self, parent: QWidget): @property def font_height(self): - return max(QFontMetrics(self._monospace_font).height(), - QFontMetrics(self._special_event_font).height(), - QFontMetrics(QFont()).height()) + return max( + QFontMetrics(self._monospace_font).height(), + QFontMetrics(self._special_event_font).height(), + QFontMetrics(QFont()).height(), + ) def rowCount(self, _parent=None): return len(self._rows) @@ -200,12 +233,12 @@ def data(self, index: QModelIndex, role=None): if role == Qt.DisplayRole: if column == 0: - return entry.local_time.strftime('%H:%M:%S') + return entry.local_time.strftime("%H:%M:%S") if column == 1: return entry.text - raise ValueError(f'Invalid column index: {column}') + raise ValueError(f"Invalid column index: {column}") if role == Qt.TextAlignmentRole: return Qt.AlignLeft + Qt.AlignVCenter @@ -229,27 +262,32 @@ def append_lines(self, text_lines: typing.Iterable[str]): local_time = datetime.datetime.now() for text in text_lines: - this_line_is_terminated = text.endswith('\n') + this_line_is_terminated = text.endswith("\n") if not self._rows or self._rows[-1].line_is_terminated: - self._rows.append(self.Entry(local_time=local_time, - text=text.rstrip(), - line_is_terminated=this_line_is_terminated)) + self._rows.append( + self.Entry( + local_time=local_time, + text=text.rstrip(), + line_is_terminated=this_line_is_terminated, + ) + ) else: self._rows[-1].text += text.rstrip() self._rows[-1].line_is_terminated = this_line_is_terminated # Reporting that the last row has changed - index = self.index(self.rowCount() - 1, - self.columnCount() - 1) + index = self.index(self.rowCount() - 1, self.columnCount() - 1) self.dataChanged.emit(index, index) # noinspection PyUnresolvedReferences @_model_modifier def append_special_event(self, text: str): - entry = self.Entry(local_time=datetime.datetime.now(), - text=text, - line_is_terminated=True, - is_special_event=True) + entry = self.Entry( + local_time=datetime.datetime.now(), + text=text, + line_is_terminated=True, + is_special_event=True, + ) self._rows.append(entry) # noinspection PyUnresolvedReferences @@ -262,7 +300,7 @@ def render_item_for_clipboard(self, index: QModelIndex) -> str: column = index.column() if column == 0: - return entry.local_time.strftime('%Y-%m-%dT%H:%M:%S') + return entry.local_time.strftime("%Y-%m-%dT%H:%M:%S") if column == 1: return entry.text @@ -297,7 +335,9 @@ def go_go_go(): for it in range(5): go_go_go() - lw.append_lines([f'This is a very long line, its number is {it + 1}\n', 'Piggyback\n']) + lw.append_lines( + [f"This is a very long line, its number is {it + 1}\n", "Piggyback\n"] + ) go_go_go() diff --git a/kucher/view/main_window/main_widget.py b/kucher/view/main_window/main_widget.py index 8eee872..0609b26 100644 --- a/kucher/view/main_window/main_widget.py +++ b/kucher/view/main_window/main_widget.py @@ -19,30 +19,40 @@ from ..utils import get_icon from ..device_model_representation import GeneralStatusView, BasicDeviceInfo, Commander -from .device_management_widget import DeviceManagementWidget,\ - ConnectionRequestCallback, DisconnectionRequestCallback +from .device_management_widget import ( + DeviceManagementWidget, + ConnectionRequestCallback, + DisconnectionRequestCallback, +) from .telega_control_widget import TelegaControlWidget class MainWidget(QTabWidget): - def __init__(self, - parent: QWidget, - on_connection_request: ConnectionRequestCallback, - on_disconnection_request: DisconnectionRequestCallback, - commander: Commander): + def __init__( + self, + parent: QWidget, + on_connection_request: ConnectionRequestCallback, + on_disconnection_request: DisconnectionRequestCallback, + commander: Commander, + ): super(MainWidget, self).__init__(parent) self._resize_event = Event() - self._device_management_widget =\ - DeviceManagementWidget(self, - on_connection_request=on_connection_request, - on_disconnection_request=on_disconnection_request) + self._device_management_widget = DeviceManagementWidget( + self, + on_connection_request=on_connection_request, + on_disconnection_request=on_disconnection_request, + ) self._telega_control_widget = TelegaControlWidget(self, commander) - self.addTab(self._device_management_widget, get_icon('connector'), 'Device management') - self.addTab(self._telega_control_widget, get_icon('wagon'), 'Telega control panel') + self.addTab( + self._device_management_widget, get_icon("connector"), "Device management" + ) + self.addTab( + self._telega_control_widget, get_icon("wagon"), "Telega control panel" + ) self.setCurrentWidget(self._device_management_widget) @@ -59,11 +69,13 @@ def on_connection_loss(self, reason: str): self._device_management_widget.on_connection_loss(reason) self._telega_control_widget.on_connection_loss() - def on_connection_initialization_progress_report(self, - stage_description: str, - progress: float): + def on_connection_initialization_progress_report( + self, stage_description: str, progress: float + ): self.setCurrentWidget(self._device_management_widget) - self._device_management_widget.on_connection_initialization_progress_report(stage_description, progress) + self._device_management_widget.on_connection_initialization_progress_report( + stage_description, progress + ) def on_general_status_update(self, timestamp: float, status: GeneralStatusView): self._telega_control_widget.on_general_status_update(timestamp, status) diff --git a/kucher/view/main_window/register_view_widget/__init__.py b/kucher/view/main_window/register_view_widget/__init__.py index 84dc434..b8ddca5 100644 --- a/kucher/view/main_window/register_view_widget/__init__.py +++ b/kucher/view/main_window/register_view_widget/__init__.py @@ -17,11 +17,27 @@ import itertools from logging import getLogger from PyQt5.QtCore import Qt, QModelIndex -from PyQt5.QtWidgets import QWidget, QTreeView, QHeaderView, QStyleOptionViewItem, QComboBox, QAbstractItemView, \ - QLabel, QAction, QGridLayout +from PyQt5.QtWidgets import ( + QWidget, + QTreeView, + QHeaderView, + QStyleOptionViewItem, + QComboBox, + QAbstractItemView, + QLabel, + QAction, + QGridLayout, +) from kucher.view.widgets import WidgetBase -from kucher.view.utils import gui_test, make_button, lay_out_vertically, lay_out_horizontally, show_error, get_icon +from kucher.view.utils import ( + gui_test, + make_button, + lay_out_vertically, + lay_out_horizontally, + show_error, + get_icon, +) from kucher.view.device_model_representation import Register from .model import Model @@ -30,8 +46,8 @@ from .import_export_dialog import export_registers, import_registers -READ_SELECTED_SHORTCUT = 'Ctrl+R' # Like Reload -RESET_SELECTED_SHORTCUT = 'Ctrl+D' # Like Delete +READ_SELECTED_SHORTCUT = "Ctrl+R" # Like Reload +RESET_SELECTED_SHORTCUT = "Ctrl+D" # Like Delete _logger = getLogger(__name__) @@ -45,57 +61,85 @@ def __init__(self, parent: QWidget): self._running_task: asyncio.Task = None self._visibility_selector = QComboBox(self) - self._visibility_selector.addItem('Show all registers', lambda _: True) - self._visibility_selector.addItem('Only configuration parameters', lambda r: r.mutable and r.persistent) + self._visibility_selector.addItem("Show all registers", lambda _: True) + self._visibility_selector.addItem( + "Only configuration parameters", lambda r: r.mutable and r.persistent + ) # noinspection PyUnresolvedReferences - self._visibility_selector.currentIndexChanged.connect(lambda _: self._on_visibility_changed()) - - self._reset_selected_button = make_button(self, 'Reset selected', - icon_name='clear-symbol', - tool_tip=f'Reset the currently selected registers to their default ' - f'values. The restored values will be committed ' - f'immediately. This function is available only if a ' - f'default value is defined. [{RESET_SELECTED_SHORTCUT}]', - on_clicked=self._do_reset_selected) - - self._reset_all_button = make_button(self, 'Reset all', - icon_name='skull-crossbones', - tool_tip=f'Reset the all registers to their default ' - f'values. The restored values will be committed ' - f'immediately.', - on_clicked=self._do_reset_all) - - self._read_selected_button = make_button(self, 'Read selected', - icon_name='process', - tool_tip=f'Read the currently selected registers only ' - f'[{READ_SELECTED_SHORTCUT}]', - on_clicked=self._do_read_selected) - - self._read_all_button = make_button(self, 'Read all', - icon_name='process-plus', - tool_tip='Read all registers from the device', - on_clicked=self._do_read_all) - - self._export_button = make_button(self, 'Export', - icon_name='export', - tool_tip='Export configuration parameters', - on_clicked=self._do_export) - - self._import_button = make_button(self, 'Import', - icon_name='import', - tool_tip='Import configuration parameters', - on_clicked=self._do_import) - - self._expand_all_button = make_button(self, '', - icon_name='expand-arrow', - tool_tip='Expand all namespaces', - on_clicked=lambda: self._tree.expandAll()) - - self._collapse_all_button = make_button(self, '', - icon_name='collapse-arrow', - tool_tip='Collapse all namespaces', - on_clicked=lambda: self._tree.collapseAll()) + self._visibility_selector.currentIndexChanged.connect( + lambda _: self._on_visibility_changed() + ) + + self._reset_selected_button = make_button( + self, + "Reset selected", + icon_name="clear-symbol", + tool_tip=f"Reset the currently selected registers to their default " + f"values. The restored values will be committed " + f"immediately. This function is available only if a " + f"default value is defined. [{RESET_SELECTED_SHORTCUT}]", + on_clicked=self._do_reset_selected, + ) + + self._reset_all_button = make_button( + self, + "Reset all", + icon_name="skull-crossbones", + tool_tip=f"Reset the all registers to their default " + f"values. The restored values will be committed " + f"immediately.", + on_clicked=self._do_reset_all, + ) + + self._read_selected_button = make_button( + self, + "Read selected", + icon_name="process", + tool_tip=f"Read the currently selected registers only " + f"[{READ_SELECTED_SHORTCUT}]", + on_clicked=self._do_read_selected, + ) + + self._read_all_button = make_button( + self, + "Read all", + icon_name="process-plus", + tool_tip="Read all registers from the device", + on_clicked=self._do_read_all, + ) + + self._export_button = make_button( + self, + "Export", + icon_name="export", + tool_tip="Export configuration parameters", + on_clicked=self._do_export, + ) + + self._import_button = make_button( + self, + "Import", + icon_name="import", + tool_tip="Import configuration parameters", + on_clicked=self._do_import, + ) + + self._expand_all_button = make_button( + self, + "", + icon_name="expand-arrow", + tool_tip="Expand all namespaces", + on_clicked=lambda: self._tree.expandAll(), + ) + + self._collapse_all_button = make_button( + self, + "", + icon_name="collapse-arrow", + tool_tip="Collapse all namespaces", + on_clicked=lambda: self._tree.collapseAll(), + ) self._status_display = QLabel(self) self._status_display.setWordWrap(True) @@ -118,10 +162,12 @@ def __init__(self, parent: QWidget): # Not sure about this one. This hardcoded value may look bad on some platforms. self._tree.setIndentation(20) - def add_action(callback: typing.Callable[[], None], - icon_name: str, - name: str, - shortcut: typing.Optional[str] = None): + def add_action( + callback: typing.Callable[[], None], + icon_name: str, + name: str, + shortcut: typing.Optional[str] = None, + ): action = QAction(get_icon(icon_name), name, self) # noinspection PyUnresolvedReferences action.triggered.connect(callback) @@ -131,29 +177,45 @@ def add_action(callback: typing.Callable[[], None], try: action.setShortcutVisibleInContextMenu(True) except AttributeError: - pass # This feature is not available in PyQt before 5.10 + pass # This feature is not available in PyQt before 5.10 self._tree.addAction(action) - add_action(self._do_read_all, 'process-plus', 'Read all registers') - add_action(self._do_read_selected, 'process', 'Read selected registers', READ_SELECTED_SHORTCUT) - add_action(self._do_reset_selected, 'clear-symbol', 'Reset selected to default', RESET_SELECTED_SHORTCUT) + add_action(self._do_read_all, "process-plus", "Read all registers") + add_action( + self._do_read_selected, + "process", + "Read selected registers", + READ_SELECTED_SHORTCUT, + ) + add_action( + self._do_reset_selected, + "clear-symbol", + "Reset selected to default", + RESET_SELECTED_SHORTCUT, + ) self._tree.setItemDelegateForColumn( int(Model.ColumnIndices.VALUE), - EditorDelegate(self._tree, self._display_status)) + EditorDelegate(self._tree, self._display_status), + ) # It doesn't seem to be explicitly documented, but it seems to be necessary to select either top or bottom # decoration position in order to be able to use center alignment. Left or right positions do not work here. self._tree.setItemDelegateForColumn( int(Model.ColumnIndices.FLAGS), - StyleOptionModifyingDelegate(self._tree, - decoration_position=QStyleOptionViewItem.Top, # Important - decoration_alignment=Qt.AlignCenter)) + StyleOptionModifyingDelegate( + self._tree, + decoration_position=QStyleOptionViewItem.Top, # Important + decoration_alignment=Qt.AlignCenter, + ), + ) header: QHeaderView = self._tree.header() header.setSectionResizeMode(QHeaderView.ResizeToContents) - header.setStretchLastSection(False) # Horizontal scroll bar doesn't work if this is enabled + header.setStretchLastSection( + False + ) # Horizontal scroll bar doesn't work if this is enabled buttons_layout = QGridLayout() buttons_layout.addWidget(self._read_selected_button, 0, 0) @@ -175,7 +237,7 @@ def add_action(callback: typing.Callable[[], None], self._expand_all_button, self._collapse_all_button, ), - self._status_display + self._status_display, ) self.setLayout(layout) @@ -187,21 +249,27 @@ def setup(self, registers: typing.Iterable[Register]): self._registers = list(registers) self._on_visibility_changed() - def _replace_model(self, register_visibility_predicate: typing.Callable[[Register], bool]): + def _replace_model( + self, register_visibility_predicate: typing.Callable[[Register], bool] + ): # Cancel all operations that might be pending on the old model self._cancel_task() old_model = self._tree.model() # Configure the new model - filtered_registers = list(filter(register_visibility_predicate, self._registers)) + filtered_registers = list( + filter(register_visibility_predicate, self._registers) + ) # It is important to set the Tree widget as the parent in order to let the widget take ownership new_model = Model(self._tree, filtered_registers) - _logger.info('New model %r', new_model) + _logger.info("New model %r", new_model) self._tree.setModel(new_model) # The selection model is implicitly replaced when we replace the model, so it has to be reconfigured - self._tree.selectionModel().selectionChanged.connect(lambda *_: self._on_selection_changed()) + self._tree.selectionModel().selectionChanged.connect( + lambda *_: self._on_selection_changed() + ) # TODO: Something fishy is going on. Something keeps the old model alive when we're replacing it. # We could call deleteLater() on it, but it seems dangerous, because if that something ever decided @@ -209,9 +277,14 @@ def _replace_model(self, register_visibility_predicate: typing.Callable[[Registe # our hands. The horror! if old_model is not None: import gc + model_referrers = gc.get_referrers(old_model) if len(model_referrers) > 1: - _logger.warning('Extra references to the old model %r: %r', old_model, model_referrers) + _logger.warning( + "Extra references to the old model %r: %r", + old_model, + model_referrers, + ) # Update the widget - all root items are expanded by default for row in itertools.count(): @@ -228,7 +301,7 @@ def _replace_model(self, register_visibility_predicate: typing.Callable[[Registe self._export_button.setEnabled(len(filtered_registers) > 0) self._import_button.setEnabled(len(filtered_registers) > 0) - self._display_status(f'{len(filtered_registers)} registers loaded') + self._display_status(f"{len(filtered_registers)} registers loaded") def _on_visibility_changed(self): self._replace_model(self._visibility_selector.currentData()) @@ -236,7 +309,9 @@ def _on_visibility_changed(self): def _on_selection_changed(self): selected = self._get_selected_registers() - self._reset_selected_button.setEnabled(any(map(lambda r: r.has_default_value, selected))) + self._reset_selected_button.setEnabled( + any(map(lambda r: r.has_default_value, selected)) + ) self._read_selected_button.setEnabled(len(selected) > 0) def _do_read_selected(self): @@ -244,7 +319,7 @@ def _do_read_selected(self): if selected: self._read_specific(selected) else: - self._display_status('No registers are selected, nothing to read') + self._display_status("No registers are selected, nothing to read") def _do_reset_selected(self): rv = {} @@ -274,55 +349,71 @@ def _do_export(self): def _read_specific(self, registers: typing.List[Register]): total_registers_read = None - def progress_callback(register: Register, current_register_index: int, total_registers: int): + def progress_callback( + register: Register, current_register_index: int, total_registers: int + ): nonlocal total_registers_read total_registers_read = total_registers - self._display_status(f'Reading {register.name!r} ' - f'({current_register_index + 1} of {total_registers})') + self._display_status( + f"Reading {register.name!r} " + f"({current_register_index + 1} of {total_registers})" + ) async def executor(): try: - _logger.info('Reading registers: %r', [r.name for r in registers]) + _logger.info("Reading registers: %r", [r.name for r in registers]) mod: Model = self._tree.model() - await mod.read(registers=registers, - progress_callback=progress_callback) + await mod.read(registers=registers, progress_callback=progress_callback) except asyncio.CancelledError: - self._display_status(f'Read has been cancelled') + self._display_status(f"Read has been cancelled") raise except Exception as ex: - _logger.exception('Register read failed') - show_error('Read failed', 'Could not read registers', repr(ex), self) - self._display_status(f'Could not read registers: {ex!r}') + _logger.exception("Register read failed") + show_error("Read failed", "Could not read registers", repr(ex), self) + self._display_status(f"Could not read registers: {ex!r}") else: - self._display_status(f'{total_registers_read} registers have been read') + self._display_status(f"{total_registers_read} registers have been read") self._cancel_task() self._running_task = asyncio.get_event_loop().create_task(executor()) - def _write_specific(self, register_value_mapping: typing.Dict[Register, typing.Any]): + def _write_specific( + self, register_value_mapping: typing.Dict[Register, typing.Any] + ): total_registers_assigned = None - def progress_callback(register: Register, current_register_index: int, total_registers: int): + def progress_callback( + register: Register, current_register_index: int, total_registers: int + ): nonlocal total_registers_assigned total_registers_assigned = total_registers - self._display_status(f'Writing {register.name!r} ' - f'({current_register_index + 1} of {total_registers})') + self._display_status( + f"Writing {register.name!r} " + f"({current_register_index + 1} of {total_registers})" + ) async def executor(): try: - _logger.info('Writing registers: %r', [r.name for r in register_value_mapping.keys()]) + _logger.info( + "Writing registers: %r", + [r.name for r in register_value_mapping.keys()], + ) mod: Model = self._tree.model() - await mod.write(register_value_mapping=register_value_mapping, - progress_callback=progress_callback) + await mod.write( + register_value_mapping=register_value_mapping, + progress_callback=progress_callback, + ) except asyncio.CancelledError: - self._display_status(f'Write has been cancelled') + self._display_status(f"Write has been cancelled") raise except Exception as ex: - _logger.exception('Register write failed') - show_error('Write failed', 'Could not read registers', repr(ex), self) - self._display_status(f'Could not write registers: {ex!r}') + _logger.exception("Register write failed") + show_error("Write failed", "Could not read registers", repr(ex), self) + self._display_status(f"Could not write registers: {ex!r}") else: - self._display_status(f'{total_registers_assigned} registers have been written') + self._display_status( + f"{total_registers_assigned} registers have been written" + ) self._cancel_task() self._running_task = asyncio.get_event_loop().create_task(executor()) @@ -345,7 +436,7 @@ def _cancel_task(self): except Exception: pass else: - _logger.info('A running task had to be cancelled: %r', self._running_task) + _logger.info("A running task had to be cancelled: %r", self._running_task) finally: self._running_task = None @@ -381,9 +472,6 @@ async def walk(): await asyncio.sleep(10) good_night_sweet_prince = True - asyncio.get_event_loop().run_until_complete(asyncio.gather( - run_events(), - walk() - )) + asyncio.get_event_loop().run_until_complete(asyncio.gather(run_events(), walk())) win.close() diff --git a/kucher/view/main_window/register_view_widget/_mock_registers.py b/kucher/view/main_window/register_view_widget/_mock_registers.py index 2fbbf26..6bedf32 100644 --- a/kucher/view/main_window/register_view_widget/_mock_registers.py +++ b/kucher/view/main_window/register_view_widget/_mock_registers.py @@ -25,13 +25,15 @@ def get_mock_registers(): from popcop.standard.register import Flags, ValueType # noinspection PyShadowingBuiltins - def mock(cached, default, min, max, mutable, persistent, ts_device, ts_mono, **kwargs): + def mock( + cached, default, min, max, mutable, persistent, ts_device, ts_mono, **kwargs + ): flags = Flags() flags.mutable = mutable flags.persistent = persistent async def set_get_callback(value): - print('MOCK REGISTER WRITE/READ:', out, value) + print("MOCK REGISTER WRITE/READ:", out, value) if value is not None: await asyncio.sleep(3) else: @@ -40,210 +42,1098 @@ async def set_get_callback(value): return value, time.monotonic(), time.monotonic() - out = Register(value=cached, - default_value=default, - min_value=min, - max_value=max, - flags=flags, - update_timestamp_device_time=ts_device, - update_timestamp_monotonic=ts_mono, - set_get_callback=set_get_callback, - **kwargs) + out = Register( + value=cached, + default_value=default, + min_value=min, + max_value=max, + flags=flags, + update_timestamp_device_time=ts_device, + update_timestamp_monotonic=ts_mono, + set_get_callback=set_get_callback, + **kwargs + ) return out return [ - mock(name='exec_aux_command', type_id=ValueType.I16, cached=[-1], default=[-1], min=[-1], max=[9999], - mutable=True, persistent=True, ts_device=167.120176761, ts_mono=11474.095037309), - mock(name='vsi.pwm_freq_khz', type_id=ValueType.F32, cached=[45.000003814697266], default=[0.0], min=[0.0], - max=[50.0], mutable=True, persistent=True, ts_device=167.150843761, ts_mono=11474.095083372), - mock(name='ctl.spinup_durat', type_id=ValueType.F32, cached=[1.5], default=[1.5], min=[0.10000000149011612], - max=[10.0], mutable=True, persistent=True, ts_device=167.189638527, ts_mono=11474.095108553), - mock(name='ctl.num_attempts', type_id=ValueType.U32, cached=[100], default=[100], min=[1], max=[10000000], - mutable=True, persistent=True, ts_device=167.22092805, ts_mono=11474.095134015), - mock(name='ctl.vm_cci_comp', type_id=ValueType.BOOLEAN, cached=[False], default=[False], min=[False], - max=[True], mutable=True, persistent=True, ts_device=167.252589227, ts_mono=11474.095157437), - mock(name='ctl.vm_oversatur', type_id=ValueType.BOOLEAN, cached=[False], default=[False], min=[False], - max=[True], mutable=True, persistent=True, ts_device=167.283183261, ts_mono=11474.09518067), - mock(name='ctl.vm_pppwm_thr', type_id=ValueType.F32, cached=[0.949999988079071], default=[0.949999988079071], - min=[0.0], max=[1.0], mutable=True, persistent=True, ts_device=167.313057172, ts_mono=11474.095203923), - mock(name='m.num_poles', type_id=ValueType.U8, cached=[14], default=[0], min=[0], max=[200], mutable=True, - persistent=True, ts_device=167.343252161, ts_mono=11474.095226993), - mock(name='m.max_current', type_id=ValueType.F32, cached=[14.0], default=[0.0], min=[0.0], max=[200.0], - mutable=True, persistent=True, ts_device=167.369294972, ts_mono=11474.095250044), - mock(name='m.min_current', type_id=ValueType.F32, cached=[0.3499999940395355], default=[0.0], min=[0.0], - max=[50.0], mutable=True, persistent=True, ts_device=167.398127294, ts_mono=11474.095273253), - mock(name='m.spup_current_l', type_id=ValueType.F32, cached=[0.699999988079071], default=[0.0], min=[0.0], - max=[50.0], mutable=True, persistent=True, ts_device=167.427121272, ts_mono=11474.095296358), - mock(name='m.spup_current_h', type_id=ValueType.F32, cached=[7.0], default=[0.0], min=[0.0], max=[50.0], - mutable=True, persistent=True, ts_device=167.457667294, ts_mono=11474.095321345), - mock(name='m.phi_milliweber', type_id=ValueType.F32, cached=[0.9692422747612], default=[0.0], min=[0.0], - max=[500.0], mutable=True, persistent=True, ts_device=167.487963305, ts_mono=11474.095344579), - mock(name='m.rs_ohm', type_id=ValueType.F32, cached=[0.09571981430053711], default=[0.0], min=[0.0], max=[10.0], - mutable=True, persistent=True, ts_device=167.517682005, ts_mono=11474.095367282), - mock(name='m.ld_microhenry', type_id=ValueType.F32, cached=[12.47910213470459], default=[0.0], min=[0.0], - max=[500000.0], mutable=True, persistent=True, ts_device=167.54309675, ts_mono=11474.095390037), - mock(name='m.lq_microhenry', type_id=ValueType.F32, cached=[12.47910213470459], default=[0.0], min=[0.0], - max=[500000.0], mutable=True, persistent=True, ts_device=167.573339927, ts_mono=11474.09541249), - mock(name='m.min_eangvel', type_id=ValueType.F32, cached=[400.0], default=[400.0], min=[10.0], max=[1000.0], - mutable=True, persistent=True, ts_device=167.602944972, ts_mono=11474.095435058), - mock(name='m.max_eangvel', type_id=ValueType.F32, cached=[5000.0], default=[10000.0], min=[10.0], max=[20000.0], - mutable=True, persistent=True, ts_device=167.631806272, ts_mono=11474.095458084), - mock(name='m.current_ramp', type_id=ValueType.F32, cached=[100.0], default=[100.0], min=[0.10000000149011612], - max=[10000.0], mutable=True, persistent=True, ts_device=167.660413072, ts_mono=11474.095481023), - mock(name='m.voltage_ramp', type_id=ValueType.F32, cached=[20.0], default=[20.0], min=[0.009999999776482582], - max=[1000.0], mutable=True, persistent=True, ts_device=167.689644772, ts_mono=11474.095556567), - mock(name='m.eangvel_rampup', type_id=ValueType.F32, cached=[2000.0], default=[2000.0], - min=[0.009999999776482582], max=[1000000.0], mutable=True, persistent=True, ts_device=167.719028994, - ts_mono=11474.095586866), - mock(name='m.eangvel_rampdn', type_id=ValueType.F32, cached=[2000.0], default=[0.0], min=[0.0], max=[1000000.0], - mutable=True, persistent=True, ts_device=167.749470094, ts_mono=11474.095610592), - mock(name='m.eangvel_ctl_kp', type_id=ValueType.F32, cached=[0.003000000026077032], - default=[0.003000000026077032], min=[0.0], max=[100.0], mutable=True, persistent=True, - ts_device=167.77973525, ts_mono=11474.095634679), - mock(name='m.eangvel_ctl_ki', type_id=ValueType.F32, cached=[0.0010000000474974513], - default=[0.0010000000474974513], min=[0.0], max=[100.0], mutable=True, persistent=True, - ts_device=167.809852683, ts_mono=11474.09565974), - mock(name='m.current_ctl_bw', type_id=ValueType.F32, cached=[0.019999999552965164], - default=[0.019999999552965164], min=[9.999999747378752e-06], max=[0.5], mutable=True, persistent=True, - ts_device=167.840277894, ts_mono=11474.095685348), - mock(name='mid.phi.curr_mul', type_id=ValueType.F32, cached=[0.30000001192092896], - default=[0.30000001192092896], min=[0.10000000149011612], max=[1.0], mutable=True, persistent=True, - ts_device=167.870644161, ts_mono=11474.095708942), - mock(name='mid.phi.eangvel', type_id=ValueType.F32, cached=[300.0], default=[300.0], min=[50.0], max=[2000.0], - mutable=True, persistent=True, ts_device=167.900911227, ts_mono=11474.095732269), - mock(name='mid.phi.stall_th', type_id=ValueType.F32, cached=[5.0], default=[5.0], min=[2.0], max=[20.0], - mutable=True, persistent=True, ts_device=167.93105435, ts_mono=11474.095755858), - mock(name='mid.l.curr_mul', type_id=ValueType.F32, cached=[0.05999999865889549], default=[0.05999999865889549], - min=[0.009999999776482582], max=[0.5], mutable=True, persistent=True, ts_device=167.961478361, - ts_mono=11474.095782118), - mock(name='mid.l.curr_freq', type_id=ValueType.F32, cached=[900.0], default=[900.0], min=[50.0], max=[1500.0], - mutable=True, persistent=True, ts_device=167.99063795, ts_mono=11474.095807649), - mock(name='mid.r.curr_mul', type_id=ValueType.F32, cached=[0.30000001192092896], default=[0.30000001192092896], - min=[0.05000000074505806], max=[1.0], mutable=True, persistent=True, ts_device=168.02053605, - ts_mono=11474.095831341), - mock(name='o.type', type_id=ValueType.BOOLEAN, cached=[False], default=[False], min=[False], max=[True], - mutable=True, persistent=True, ts_device=168.048972783, ts_mono=11474.095858845), - mock(name='o.ekf.q_id', type_id=ValueType.F32, cached=[1000000.0], default=[30000.0], min=[0.10000000149011612], - max=[1000000000.0], mutable=True, persistent=True, ts_device=168.07176205, ts_mono=11474.095884825), - mock(name='o.ekf.q_iq', type_id=ValueType.F32, cached=[100000000.0], default=[300000.0], - min=[0.10000000149011612], max=[1000000000.0], mutable=True, persistent=True, ts_device=168.098169505, - ts_mono=11474.095908585), - mock(name='o.ekf.q_eangvel', type_id=ValueType.F32, cached=[1000000000.0], default=[300000000.0], - min=[0.10000000149011612], max=[1000000000.0], mutable=True, persistent=True, ts_device=168.124119783, - ts_mono=11474.095935612), - mock(name='o.ekf.p0_idq', type_id=ValueType.F32, cached=[0.0010000000474974513], - default=[0.0010000000474974513], min=[0.0], max=[1000000.0], mutable=True, persistent=True, - ts_device=168.152991494, ts_mono=11474.095976266), - mock(name='o.ekf.p0_eangvel', type_id=ValueType.F32, cached=[0.0010000000474974513], - default=[0.0010000000474974513], min=[0.0], max=[1000000.0], mutable=True, persistent=True, - ts_device=168.180930294, ts_mono=11474.096031996), - mock(name='o.ekf.cc_comp', type_id=ValueType.F32, cached=[0.0], default=[0.0], min=[0.0], max=[10.0], - mutable=True, persistent=True, ts_device=168.211163661, ts_mono=11474.096078469), - mock(name='o.mras.gain', type_id=ValueType.F32, cached=[150000.0], default=[150000.0], - min=[0.0010000000474974513], max=[1000000.0], mutable=True, persistent=True, ts_device=168.23962725, - ts_mono=11474.096112982), - mock(name='bec.can_pwr_on', type_id=ValueType.BOOLEAN, cached=[False], default=[False], min=[False], max=[True], - mutable=True, persistent=True, ts_device=168.266927538, ts_mono=11474.096137455), - mock(name='uavcan.esc_index', type_id=ValueType.U8, cached=[0], default=[0], min=[0], max=[15], mutable=True, - persistent=True, ts_device=168.295695294, ts_mono=11474.0961611), - mock(name='uavcan.esc_ttl', type_id=ValueType.F32, cached=[0.30000001192092896], default=[0.30000001192092896], - min=[0.10000000149011612], max=[10.0], mutable=True, persistent=True, ts_device=168.325321394, - ts_mono=11474.096187528), - mock(name='uavcan.esc_sint', type_id=ValueType.F32, cached=[0.05000000074505806], default=[0.05000000074505806], - min=[0.009999999776482582], max=[1.0], mutable=True, persistent=True, ts_device=168.354900616, - ts_mono=11474.096214092), - mock(name='uavcan.esc_sintp', type_id=ValueType.F32, cached=[0.5], default=[0.5], min=[0.009999999776482582], - max=[10.0], mutable=True, persistent=True, ts_device=168.384844983, ts_mono=11474.096241364), - mock(name='uavcan.esc_rcm', type_id=ValueType.U8, cached=[1], default=[1], min=[0], max=[2], mutable=True, - persistent=True, ts_device=168.415360083, ts_mono=11474.096265363), - mock(name='uavcan.node_id', type_id=ValueType.U8, cached=[0], default=[0], min=[0], max=[125], mutable=True, - persistent=True, ts_device=168.443844627, ts_mono=11474.096288244), - mock(name='uavcan.transfer_cnt.error', type_id=ValueType.U64, cached=[0], default=None, min=None, max=None, - mutable=False, persistent=False, ts_device=168.473500294, ts_mono=11474.09631309), - mock(name='uavcan.transfer_cnt.rx', type_id=ValueType.U64, cached=[0], default=None, min=None, max=None, - mutable=False, persistent=False, ts_device=168.482784538, ts_mono=11474.096334935), - mock(name='uavcan.transfer_cnt.tx', type_id=ValueType.U64, cached=[0], default=None, min=None, max=None, - mutable=False, persistent=False, ts_device=168.491846672, ts_mono=11474.09635874), - mock(name='ctrl.task_switch_count', type_id=ValueType.U32, cached=[0], default=None, min=None, max=None, - mutable=False, persistent=False, ts_device=168.500842172, ts_mono=11474.096381102), - mock(name='vsi.motor_temp', type_id=ValueType.F32, cached=[0.0], default=None, min=None, max=None, - mutable=False, persistent=False, ts_device=168.508704905, ts_mono=11474.096429273), - mock(name='vsi.cpu_temp', type_id=ValueType.F32, cached=[0.0], default=None, min=None, max=None, mutable=False, - persistent=False, ts_device=168.515967938, ts_mono=11474.096475768), - mock(name='vsi.vsi_temp', type_id=ValueType.F32, cached=[0.0], default=None, min=None, max=None, mutable=False, - persistent=False, ts_device=168.522875594, ts_mono=11474.096519214), - mock(name='vsi.pwm_irq_duration', type_id=ValueType.F32, cached=[0.0], default=None, min=None, max=None, - mutable=False, persistent=False, ts_device=168.530338527, ts_mono=11474.09654562), - mock(name='vsi.phase_voltage_error', type_id=ValueType.F32, cached=[0.0, 0.0, 0.0], default=None, min=None, - max=None, mutable=False, persistent=False, ts_device=168.53857885, ts_mono=11474.09657154), - mock(name='vsi.hw_flag_cnt.fault', type_id=ValueType.U32, cached=[0], default=None, min=None, max=None, - mutable=False, persistent=False, ts_device=168.547752038, ts_mono=11474.096593609), - mock(name='vsi.hw_flag_cnt.overload', type_id=ValueType.U32, cached=[0], default=None, min=None, max=None, - mutable=False, persistent=False, ts_device=168.556525427, ts_mono=11474.096651339), - mock(name='vsi.hw_flag_cnt.lvps_malfunction', type_id=ValueType.U32, cached=[0], default=None, min=None, - max=None, mutable=False, persistent=False, ts_device=168.566342738, ts_mono=11474.096678888), - mock(name='vsi.phase_voltage', type_id=ValueType.F32, cached=[0.0, 0.0, 0.0], default=None, min=None, max=None, - mutable=False, persistent=False, ts_device=168.575515927, ts_mono=11474.09671855), - mock(name='vsi.phase_current', type_id=ValueType.F32, cached=[0.0, 0.0], default=None, min=None, max=None, - mutable=False, persistent=False, ts_device=168.583911727, ts_mono=11474.096745765), - mock(name='vsi.dc_current_lpf', type_id=ValueType.F32, cached=[0.0], default=None, min=None, max=None, - mutable=False, persistent=False, ts_device=168.59195215, ts_mono=11474.096768286), - mock(name='vsi.dc_current', type_id=ValueType.F32, cached=[0.0], default=None, min=None, max=None, - mutable=False, persistent=False, ts_device=168.599614983, ts_mono=11474.096790113), - mock(name='vsi.dc_voltage_lpf', type_id=ValueType.F32, cached=[0.0], default=None, min=None, max=None, - mutable=False, persistent=False, ts_device=168.607077916, ts_mono=11474.096811819), - mock(name='vsi.dc_voltage', type_id=ValueType.F32, cached=[0.0], default=None, min=None, max=None, - mutable=False, persistent=False, ts_device=168.614829594, ts_mono=11474.096833574), - mock(name='vsi.current_gain_level', type_id=ValueType.BOOLEAN, cached=[True], default=None, min=None, max=None, - mutable=False, persistent=False, ts_device=168.622847805, ts_mono=11474.096857869), - mock(name='motor.pwm_setpoint', type_id=ValueType.F32, cached=[0.0, 0.0, 0.0], default=None, min=None, max=None, - mutable=False, persistent=False, ts_device=168.630754961, ts_mono=11474.09688225), - mock(name='motor.electrical_angle', type_id=ValueType.F32, cached=[0.0], default=None, min=None, max=None, - mutable=False, persistent=False, ts_device=168.639839305, ts_mono=11474.09690772), - mock(name='motor.scalar.frequency', type_id=ValueType.F32, cached=[0.0], default=None, min=None, max=None, - mutable=False, persistent=False, ts_device=168.648634905, ts_mono=11474.0969341), - mock(name='motor.id.phi_noise_threshold', type_id=ValueType.F32, cached=[0.0], default=None, min=None, max=None, - mutable=False, persistent=False, ts_device=168.657585983, ts_mono=11474.096956255), - mock(name='motor.id.phi_noise_sample', type_id=ValueType.F32, cached=[0.0], default=None, min=None, max=None, - mutable=False, persistent=False, ts_device=168.666981283, ts_mono=11474.096980231), - mock(name='motor.id.phi', type_id=ValueType.F32, cached=[0.0], default=None, min=None, max=None, mutable=False, - persistent=False, ts_device=168.674888438, ts_mono=11474.09700427), - mock(name='motor.id.raw_phi', type_id=ValueType.F32, cached=[0.0], default=None, min=None, max=None, - mutable=False, persistent=False, ts_device=168.682195894, ts_mono=11474.097026366), - mock(name='motor.setpoint_q', type_id=ValueType.F32, cached=[0.0], default=None, min=None, max=None, - mutable=False, persistent=False, ts_device=168.690036416, ts_mono=11474.0970501), - mock(name='motor.electrical_angular_velocity', type_id=ValueType.F32, cached=[0.0], default=None, min=None, - max=None, mutable=False, persistent=False, ts_device=168.699209605, ts_mono=11474.097072857), - mock(name='motor.u_dq', type_id=ValueType.F32, cached=[0.0, 0.0], default=None, min=None, max=None, - mutable=False, persistent=False, ts_device=168.707583194, ts_mono=11474.097096338), - mock(name='motor.i_dq', type_id=ValueType.F32, cached=[0.0, 0.0], default=None, min=None, max=None, - mutable=False, persistent=False, ts_device=168.714402005, ts_mono=11474.097118982), - mock(name='observer.variance.electrical_angle', type_id=ValueType.F32, cached=[0.0], default=None, min=None, - max=None, mutable=False, persistent=False, ts_device=168.723330872, ts_mono=11474.097144446), - mock(name='observer.variance.electrical_ang_vel', type_id=ValueType.F32, cached=[0.0], default=None, min=None, - max=None, mutable=False, persistent=False, ts_device=168.734192105, ts_mono=11474.097168879), - mock(name='observer.variance.i_q', type_id=ValueType.F32, cached=[0.0], default=None, min=None, max=None, - mutable=False, persistent=False, ts_device=168.743765094, ts_mono=11474.097190949), - mock(name='observer.variance.i_d', type_id=ValueType.F32, cached=[0.0], default=None, min=None, max=None, - mutable=False, persistent=False, ts_device=168.752205316, ts_mono=11474.097212606), - mock(name='observer.x', type_id=ValueType.F32, cached=[0.0, 0.0, 0.0, 0.0], default=None, min=None, max=None, - mutable=False, persistent=False, ts_device=168.768175105, ts_mono=11474.097234168), - mock(name='setpoint.elect_ang_vel_ctrl.integral', type_id=ValueType.F32, cached=[0.0], default=None, min=None, - max=None, mutable=False, persistent=False, ts_device=168.777970205, ts_mono=11474.097256111), - mock(name='setpoint.electrical_angular_velocity', type_id=ValueType.F32, cached=[0.0], default=None, min=None, - max=None, mutable=False, persistent=False, ts_device=168.789075761, ts_mono=11474.097282835), - mock(name='motor.i_q_pid.error_integral', type_id=ValueType.F32, cached=[0.0], default=None, min=None, max=None, - mutable=False, persistent=False, ts_device=168.799670461, ts_mono=11474.097305723), - mock(name='motor.i_d_pid.error_integral', type_id=ValueType.F32, cached=[0.0], default=None, min=None, max=None, - mutable=False, persistent=False, ts_device=168.809398927, ts_mono=11474.097327512), - mock(name='motor.passive_phase_modulation_on', type_id=ValueType.BOOLEAN, cached=[False], default=None, - min=None, max=None, mutable=False, persistent=False, ts_device=168.819704883, ts_mono=11474.097349266), - mock(name='motor.phase_voltage_setpoint', type_id=ValueType.F32, cached=[0.0, 0.0, 0.0], default=None, min=None, - max=None, mutable=False, persistent=False, ts_device=168.829588827, ts_mono=11474.097374602), - mock(name='motor.u_dq_setpoint', type_id=ValueType.F32, cached=[0.0, 0.0], default=None, min=None, max=None, - mutable=False, persistent=False, ts_device=168.839961416, ts_mono=11474.097399175), - mock(name='z.synthesized', type_id=ValueType.F32, cached=[0.0, 0.0], default=None, min=[-1.0, -2.0], - max=[32.112, 450000.25], mutable=True, persistent=False, ts_device=168.839961416, ts_mono=11474.097399175), - mock(name='z.long', type_id=ValueType.F64, cached=list(range(16)), default=None, min=list(range(16)), - max=list(range(16)), mutable=True, persistent=False, ts_device=168.839961416, ts_mono=11474.097399175), + mock( + name="exec_aux_command", + type_id=ValueType.I16, + cached=[-1], + default=[-1], + min=[-1], + max=[9999], + mutable=True, + persistent=True, + ts_device=167.120176761, + ts_mono=11474.095037309, + ), + mock( + name="vsi.pwm_freq_khz", + type_id=ValueType.F32, + cached=[45.000003814697266], + default=[0.0], + min=[0.0], + max=[50.0], + mutable=True, + persistent=True, + ts_device=167.150843761, + ts_mono=11474.095083372, + ), + mock( + name="ctl.spinup_durat", + type_id=ValueType.F32, + cached=[1.5], + default=[1.5], + min=[0.10000000149011612], + max=[10.0], + mutable=True, + persistent=True, + ts_device=167.189638527, + ts_mono=11474.095108553, + ), + mock( + name="ctl.num_attempts", + type_id=ValueType.U32, + cached=[100], + default=[100], + min=[1], + max=[10000000], + mutable=True, + persistent=True, + ts_device=167.22092805, + ts_mono=11474.095134015, + ), + mock( + name="ctl.vm_cci_comp", + type_id=ValueType.BOOLEAN, + cached=[False], + default=[False], + min=[False], + max=[True], + mutable=True, + persistent=True, + ts_device=167.252589227, + ts_mono=11474.095157437, + ), + mock( + name="ctl.vm_oversatur", + type_id=ValueType.BOOLEAN, + cached=[False], + default=[False], + min=[False], + max=[True], + mutable=True, + persistent=True, + ts_device=167.283183261, + ts_mono=11474.09518067, + ), + mock( + name="ctl.vm_pppwm_thr", + type_id=ValueType.F32, + cached=[0.949999988079071], + default=[0.949999988079071], + min=[0.0], + max=[1.0], + mutable=True, + persistent=True, + ts_device=167.313057172, + ts_mono=11474.095203923, + ), + mock( + name="m.num_poles", + type_id=ValueType.U8, + cached=[14], + default=[0], + min=[0], + max=[200], + mutable=True, + persistent=True, + ts_device=167.343252161, + ts_mono=11474.095226993, + ), + mock( + name="m.max_current", + type_id=ValueType.F32, + cached=[14.0], + default=[0.0], + min=[0.0], + max=[200.0], + mutable=True, + persistent=True, + ts_device=167.369294972, + ts_mono=11474.095250044, + ), + mock( + name="m.min_current", + type_id=ValueType.F32, + cached=[0.3499999940395355], + default=[0.0], + min=[0.0], + max=[50.0], + mutable=True, + persistent=True, + ts_device=167.398127294, + ts_mono=11474.095273253, + ), + mock( + name="m.spup_current_l", + type_id=ValueType.F32, + cached=[0.699999988079071], + default=[0.0], + min=[0.0], + max=[50.0], + mutable=True, + persistent=True, + ts_device=167.427121272, + ts_mono=11474.095296358, + ), + mock( + name="m.spup_current_h", + type_id=ValueType.F32, + cached=[7.0], + default=[0.0], + min=[0.0], + max=[50.0], + mutable=True, + persistent=True, + ts_device=167.457667294, + ts_mono=11474.095321345, + ), + mock( + name="m.phi_milliweber", + type_id=ValueType.F32, + cached=[0.9692422747612], + default=[0.0], + min=[0.0], + max=[500.0], + mutable=True, + persistent=True, + ts_device=167.487963305, + ts_mono=11474.095344579, + ), + mock( + name="m.rs_ohm", + type_id=ValueType.F32, + cached=[0.09571981430053711], + default=[0.0], + min=[0.0], + max=[10.0], + mutable=True, + persistent=True, + ts_device=167.517682005, + ts_mono=11474.095367282, + ), + mock( + name="m.ld_microhenry", + type_id=ValueType.F32, + cached=[12.47910213470459], + default=[0.0], + min=[0.0], + max=[500000.0], + mutable=True, + persistent=True, + ts_device=167.54309675, + ts_mono=11474.095390037, + ), + mock( + name="m.lq_microhenry", + type_id=ValueType.F32, + cached=[12.47910213470459], + default=[0.0], + min=[0.0], + max=[500000.0], + mutable=True, + persistent=True, + ts_device=167.573339927, + ts_mono=11474.09541249, + ), + mock( + name="m.min_eangvel", + type_id=ValueType.F32, + cached=[400.0], + default=[400.0], + min=[10.0], + max=[1000.0], + mutable=True, + persistent=True, + ts_device=167.602944972, + ts_mono=11474.095435058, + ), + mock( + name="m.max_eangvel", + type_id=ValueType.F32, + cached=[5000.0], + default=[10000.0], + min=[10.0], + max=[20000.0], + mutable=True, + persistent=True, + ts_device=167.631806272, + ts_mono=11474.095458084, + ), + mock( + name="m.current_ramp", + type_id=ValueType.F32, + cached=[100.0], + default=[100.0], + min=[0.10000000149011612], + max=[10000.0], + mutable=True, + persistent=True, + ts_device=167.660413072, + ts_mono=11474.095481023, + ), + mock( + name="m.voltage_ramp", + type_id=ValueType.F32, + cached=[20.0], + default=[20.0], + min=[0.009999999776482582], + max=[1000.0], + mutable=True, + persistent=True, + ts_device=167.689644772, + ts_mono=11474.095556567, + ), + mock( + name="m.eangvel_rampup", + type_id=ValueType.F32, + cached=[2000.0], + default=[2000.0], + min=[0.009999999776482582], + max=[1000000.0], + mutable=True, + persistent=True, + ts_device=167.719028994, + ts_mono=11474.095586866, + ), + mock( + name="m.eangvel_rampdn", + type_id=ValueType.F32, + cached=[2000.0], + default=[0.0], + min=[0.0], + max=[1000000.0], + mutable=True, + persistent=True, + ts_device=167.749470094, + ts_mono=11474.095610592, + ), + mock( + name="m.eangvel_ctl_kp", + type_id=ValueType.F32, + cached=[0.003000000026077032], + default=[0.003000000026077032], + min=[0.0], + max=[100.0], + mutable=True, + persistent=True, + ts_device=167.77973525, + ts_mono=11474.095634679, + ), + mock( + name="m.eangvel_ctl_ki", + type_id=ValueType.F32, + cached=[0.0010000000474974513], + default=[0.0010000000474974513], + min=[0.0], + max=[100.0], + mutable=True, + persistent=True, + ts_device=167.809852683, + ts_mono=11474.09565974, + ), + mock( + name="m.current_ctl_bw", + type_id=ValueType.F32, + cached=[0.019999999552965164], + default=[0.019999999552965164], + min=[9.999999747378752e-06], + max=[0.5], + mutable=True, + persistent=True, + ts_device=167.840277894, + ts_mono=11474.095685348, + ), + mock( + name="mid.phi.curr_mul", + type_id=ValueType.F32, + cached=[0.30000001192092896], + default=[0.30000001192092896], + min=[0.10000000149011612], + max=[1.0], + mutable=True, + persistent=True, + ts_device=167.870644161, + ts_mono=11474.095708942, + ), + mock( + name="mid.phi.eangvel", + type_id=ValueType.F32, + cached=[300.0], + default=[300.0], + min=[50.0], + max=[2000.0], + mutable=True, + persistent=True, + ts_device=167.900911227, + ts_mono=11474.095732269, + ), + mock( + name="mid.phi.stall_th", + type_id=ValueType.F32, + cached=[5.0], + default=[5.0], + min=[2.0], + max=[20.0], + mutable=True, + persistent=True, + ts_device=167.93105435, + ts_mono=11474.095755858, + ), + mock( + name="mid.l.curr_mul", + type_id=ValueType.F32, + cached=[0.05999999865889549], + default=[0.05999999865889549], + min=[0.009999999776482582], + max=[0.5], + mutable=True, + persistent=True, + ts_device=167.961478361, + ts_mono=11474.095782118, + ), + mock( + name="mid.l.curr_freq", + type_id=ValueType.F32, + cached=[900.0], + default=[900.0], + min=[50.0], + max=[1500.0], + mutable=True, + persistent=True, + ts_device=167.99063795, + ts_mono=11474.095807649, + ), + mock( + name="mid.r.curr_mul", + type_id=ValueType.F32, + cached=[0.30000001192092896], + default=[0.30000001192092896], + min=[0.05000000074505806], + max=[1.0], + mutable=True, + persistent=True, + ts_device=168.02053605, + ts_mono=11474.095831341, + ), + mock( + name="o.type", + type_id=ValueType.BOOLEAN, + cached=[False], + default=[False], + min=[False], + max=[True], + mutable=True, + persistent=True, + ts_device=168.048972783, + ts_mono=11474.095858845, + ), + mock( + name="o.ekf.q_id", + type_id=ValueType.F32, + cached=[1000000.0], + default=[30000.0], + min=[0.10000000149011612], + max=[1000000000.0], + mutable=True, + persistent=True, + ts_device=168.07176205, + ts_mono=11474.095884825, + ), + mock( + name="o.ekf.q_iq", + type_id=ValueType.F32, + cached=[100000000.0], + default=[300000.0], + min=[0.10000000149011612], + max=[1000000000.0], + mutable=True, + persistent=True, + ts_device=168.098169505, + ts_mono=11474.095908585, + ), + mock( + name="o.ekf.q_eangvel", + type_id=ValueType.F32, + cached=[1000000000.0], + default=[300000000.0], + min=[0.10000000149011612], + max=[1000000000.0], + mutable=True, + persistent=True, + ts_device=168.124119783, + ts_mono=11474.095935612, + ), + mock( + name="o.ekf.p0_idq", + type_id=ValueType.F32, + cached=[0.0010000000474974513], + default=[0.0010000000474974513], + min=[0.0], + max=[1000000.0], + mutable=True, + persistent=True, + ts_device=168.152991494, + ts_mono=11474.095976266, + ), + mock( + name="o.ekf.p0_eangvel", + type_id=ValueType.F32, + cached=[0.0010000000474974513], + default=[0.0010000000474974513], + min=[0.0], + max=[1000000.0], + mutable=True, + persistent=True, + ts_device=168.180930294, + ts_mono=11474.096031996, + ), + mock( + name="o.ekf.cc_comp", + type_id=ValueType.F32, + cached=[0.0], + default=[0.0], + min=[0.0], + max=[10.0], + mutable=True, + persistent=True, + ts_device=168.211163661, + ts_mono=11474.096078469, + ), + mock( + name="o.mras.gain", + type_id=ValueType.F32, + cached=[150000.0], + default=[150000.0], + min=[0.0010000000474974513], + max=[1000000.0], + mutable=True, + persistent=True, + ts_device=168.23962725, + ts_mono=11474.096112982, + ), + mock( + name="bec.can_pwr_on", + type_id=ValueType.BOOLEAN, + cached=[False], + default=[False], + min=[False], + max=[True], + mutable=True, + persistent=True, + ts_device=168.266927538, + ts_mono=11474.096137455, + ), + mock( + name="uavcan.esc_index", + type_id=ValueType.U8, + cached=[0], + default=[0], + min=[0], + max=[15], + mutable=True, + persistent=True, + ts_device=168.295695294, + ts_mono=11474.0961611, + ), + mock( + name="uavcan.esc_ttl", + type_id=ValueType.F32, + cached=[0.30000001192092896], + default=[0.30000001192092896], + min=[0.10000000149011612], + max=[10.0], + mutable=True, + persistent=True, + ts_device=168.325321394, + ts_mono=11474.096187528, + ), + mock( + name="uavcan.esc_sint", + type_id=ValueType.F32, + cached=[0.05000000074505806], + default=[0.05000000074505806], + min=[0.009999999776482582], + max=[1.0], + mutable=True, + persistent=True, + ts_device=168.354900616, + ts_mono=11474.096214092, + ), + mock( + name="uavcan.esc_sintp", + type_id=ValueType.F32, + cached=[0.5], + default=[0.5], + min=[0.009999999776482582], + max=[10.0], + mutable=True, + persistent=True, + ts_device=168.384844983, + ts_mono=11474.096241364, + ), + mock( + name="uavcan.esc_rcm", + type_id=ValueType.U8, + cached=[1], + default=[1], + min=[0], + max=[2], + mutable=True, + persistent=True, + ts_device=168.415360083, + ts_mono=11474.096265363, + ), + mock( + name="uavcan.node_id", + type_id=ValueType.U8, + cached=[0], + default=[0], + min=[0], + max=[125], + mutable=True, + persistent=True, + ts_device=168.443844627, + ts_mono=11474.096288244, + ), + mock( + name="uavcan.transfer_cnt.error", + type_id=ValueType.U64, + cached=[0], + default=None, + min=None, + max=None, + mutable=False, + persistent=False, + ts_device=168.473500294, + ts_mono=11474.09631309, + ), + mock( + name="uavcan.transfer_cnt.rx", + type_id=ValueType.U64, + cached=[0], + default=None, + min=None, + max=None, + mutable=False, + persistent=False, + ts_device=168.482784538, + ts_mono=11474.096334935, + ), + mock( + name="uavcan.transfer_cnt.tx", + type_id=ValueType.U64, + cached=[0], + default=None, + min=None, + max=None, + mutable=False, + persistent=False, + ts_device=168.491846672, + ts_mono=11474.09635874, + ), + mock( + name="ctrl.task_switch_count", + type_id=ValueType.U32, + cached=[0], + default=None, + min=None, + max=None, + mutable=False, + persistent=False, + ts_device=168.500842172, + ts_mono=11474.096381102, + ), + mock( + name="vsi.motor_temp", + type_id=ValueType.F32, + cached=[0.0], + default=None, + min=None, + max=None, + mutable=False, + persistent=False, + ts_device=168.508704905, + ts_mono=11474.096429273, + ), + mock( + name="vsi.cpu_temp", + type_id=ValueType.F32, + cached=[0.0], + default=None, + min=None, + max=None, + mutable=False, + persistent=False, + ts_device=168.515967938, + ts_mono=11474.096475768, + ), + mock( + name="vsi.vsi_temp", + type_id=ValueType.F32, + cached=[0.0], + default=None, + min=None, + max=None, + mutable=False, + persistent=False, + ts_device=168.522875594, + ts_mono=11474.096519214, + ), + mock( + name="vsi.pwm_irq_duration", + type_id=ValueType.F32, + cached=[0.0], + default=None, + min=None, + max=None, + mutable=False, + persistent=False, + ts_device=168.530338527, + ts_mono=11474.09654562, + ), + mock( + name="vsi.phase_voltage_error", + type_id=ValueType.F32, + cached=[0.0, 0.0, 0.0], + default=None, + min=None, + max=None, + mutable=False, + persistent=False, + ts_device=168.53857885, + ts_mono=11474.09657154, + ), + mock( + name="vsi.hw_flag_cnt.fault", + type_id=ValueType.U32, + cached=[0], + default=None, + min=None, + max=None, + mutable=False, + persistent=False, + ts_device=168.547752038, + ts_mono=11474.096593609, + ), + mock( + name="vsi.hw_flag_cnt.overload", + type_id=ValueType.U32, + cached=[0], + default=None, + min=None, + max=None, + mutable=False, + persistent=False, + ts_device=168.556525427, + ts_mono=11474.096651339, + ), + mock( + name="vsi.hw_flag_cnt.lvps_malfunction", + type_id=ValueType.U32, + cached=[0], + default=None, + min=None, + max=None, + mutable=False, + persistent=False, + ts_device=168.566342738, + ts_mono=11474.096678888, + ), + mock( + name="vsi.phase_voltage", + type_id=ValueType.F32, + cached=[0.0, 0.0, 0.0], + default=None, + min=None, + max=None, + mutable=False, + persistent=False, + ts_device=168.575515927, + ts_mono=11474.09671855, + ), + mock( + name="vsi.phase_current", + type_id=ValueType.F32, + cached=[0.0, 0.0], + default=None, + min=None, + max=None, + mutable=False, + persistent=False, + ts_device=168.583911727, + ts_mono=11474.096745765, + ), + mock( + name="vsi.dc_current_lpf", + type_id=ValueType.F32, + cached=[0.0], + default=None, + min=None, + max=None, + mutable=False, + persistent=False, + ts_device=168.59195215, + ts_mono=11474.096768286, + ), + mock( + name="vsi.dc_current", + type_id=ValueType.F32, + cached=[0.0], + default=None, + min=None, + max=None, + mutable=False, + persistent=False, + ts_device=168.599614983, + ts_mono=11474.096790113, + ), + mock( + name="vsi.dc_voltage_lpf", + type_id=ValueType.F32, + cached=[0.0], + default=None, + min=None, + max=None, + mutable=False, + persistent=False, + ts_device=168.607077916, + ts_mono=11474.096811819, + ), + mock( + name="vsi.dc_voltage", + type_id=ValueType.F32, + cached=[0.0], + default=None, + min=None, + max=None, + mutable=False, + persistent=False, + ts_device=168.614829594, + ts_mono=11474.096833574, + ), + mock( + name="vsi.current_gain_level", + type_id=ValueType.BOOLEAN, + cached=[True], + default=None, + min=None, + max=None, + mutable=False, + persistent=False, + ts_device=168.622847805, + ts_mono=11474.096857869, + ), + mock( + name="motor.pwm_setpoint", + type_id=ValueType.F32, + cached=[0.0, 0.0, 0.0], + default=None, + min=None, + max=None, + mutable=False, + persistent=False, + ts_device=168.630754961, + ts_mono=11474.09688225, + ), + mock( + name="motor.electrical_angle", + type_id=ValueType.F32, + cached=[0.0], + default=None, + min=None, + max=None, + mutable=False, + persistent=False, + ts_device=168.639839305, + ts_mono=11474.09690772, + ), + mock( + name="motor.scalar.frequency", + type_id=ValueType.F32, + cached=[0.0], + default=None, + min=None, + max=None, + mutable=False, + persistent=False, + ts_device=168.648634905, + ts_mono=11474.0969341, + ), + mock( + name="motor.id.phi_noise_threshold", + type_id=ValueType.F32, + cached=[0.0], + default=None, + min=None, + max=None, + mutable=False, + persistent=False, + ts_device=168.657585983, + ts_mono=11474.096956255, + ), + mock( + name="motor.id.phi_noise_sample", + type_id=ValueType.F32, + cached=[0.0], + default=None, + min=None, + max=None, + mutable=False, + persistent=False, + ts_device=168.666981283, + ts_mono=11474.096980231, + ), + mock( + name="motor.id.phi", + type_id=ValueType.F32, + cached=[0.0], + default=None, + min=None, + max=None, + mutable=False, + persistent=False, + ts_device=168.674888438, + ts_mono=11474.09700427, + ), + mock( + name="motor.id.raw_phi", + type_id=ValueType.F32, + cached=[0.0], + default=None, + min=None, + max=None, + mutable=False, + persistent=False, + ts_device=168.682195894, + ts_mono=11474.097026366, + ), + mock( + name="motor.setpoint_q", + type_id=ValueType.F32, + cached=[0.0], + default=None, + min=None, + max=None, + mutable=False, + persistent=False, + ts_device=168.690036416, + ts_mono=11474.0970501, + ), + mock( + name="motor.electrical_angular_velocity", + type_id=ValueType.F32, + cached=[0.0], + default=None, + min=None, + max=None, + mutable=False, + persistent=False, + ts_device=168.699209605, + ts_mono=11474.097072857, + ), + mock( + name="motor.u_dq", + type_id=ValueType.F32, + cached=[0.0, 0.0], + default=None, + min=None, + max=None, + mutable=False, + persistent=False, + ts_device=168.707583194, + ts_mono=11474.097096338, + ), + mock( + name="motor.i_dq", + type_id=ValueType.F32, + cached=[0.0, 0.0], + default=None, + min=None, + max=None, + mutable=False, + persistent=False, + ts_device=168.714402005, + ts_mono=11474.097118982, + ), + mock( + name="observer.variance.electrical_angle", + type_id=ValueType.F32, + cached=[0.0], + default=None, + min=None, + max=None, + mutable=False, + persistent=False, + ts_device=168.723330872, + ts_mono=11474.097144446, + ), + mock( + name="observer.variance.electrical_ang_vel", + type_id=ValueType.F32, + cached=[0.0], + default=None, + min=None, + max=None, + mutable=False, + persistent=False, + ts_device=168.734192105, + ts_mono=11474.097168879, + ), + mock( + name="observer.variance.i_q", + type_id=ValueType.F32, + cached=[0.0], + default=None, + min=None, + max=None, + mutable=False, + persistent=False, + ts_device=168.743765094, + ts_mono=11474.097190949, + ), + mock( + name="observer.variance.i_d", + type_id=ValueType.F32, + cached=[0.0], + default=None, + min=None, + max=None, + mutable=False, + persistent=False, + ts_device=168.752205316, + ts_mono=11474.097212606, + ), + mock( + name="observer.x", + type_id=ValueType.F32, + cached=[0.0, 0.0, 0.0, 0.0], + default=None, + min=None, + max=None, + mutable=False, + persistent=False, + ts_device=168.768175105, + ts_mono=11474.097234168, + ), + mock( + name="setpoint.elect_ang_vel_ctrl.integral", + type_id=ValueType.F32, + cached=[0.0], + default=None, + min=None, + max=None, + mutable=False, + persistent=False, + ts_device=168.777970205, + ts_mono=11474.097256111, + ), + mock( + name="setpoint.electrical_angular_velocity", + type_id=ValueType.F32, + cached=[0.0], + default=None, + min=None, + max=None, + mutable=False, + persistent=False, + ts_device=168.789075761, + ts_mono=11474.097282835, + ), + mock( + name="motor.i_q_pid.error_integral", + type_id=ValueType.F32, + cached=[0.0], + default=None, + min=None, + max=None, + mutable=False, + persistent=False, + ts_device=168.799670461, + ts_mono=11474.097305723, + ), + mock( + name="motor.i_d_pid.error_integral", + type_id=ValueType.F32, + cached=[0.0], + default=None, + min=None, + max=None, + mutable=False, + persistent=False, + ts_device=168.809398927, + ts_mono=11474.097327512, + ), + mock( + name="motor.passive_phase_modulation_on", + type_id=ValueType.BOOLEAN, + cached=[False], + default=None, + min=None, + max=None, + mutable=False, + persistent=False, + ts_device=168.819704883, + ts_mono=11474.097349266, + ), + mock( + name="motor.phase_voltage_setpoint", + type_id=ValueType.F32, + cached=[0.0, 0.0, 0.0], + default=None, + min=None, + max=None, + mutable=False, + persistent=False, + ts_device=168.829588827, + ts_mono=11474.097374602, + ), + mock( + name="motor.u_dq_setpoint", + type_id=ValueType.F32, + cached=[0.0, 0.0], + default=None, + min=None, + max=None, + mutable=False, + persistent=False, + ts_device=168.839961416, + ts_mono=11474.097399175, + ), + mock( + name="z.synthesized", + type_id=ValueType.F32, + cached=[0.0, 0.0], + default=None, + min=[-1.0, -2.0], + max=[32.112, 450000.25], + mutable=True, + persistent=False, + ts_device=168.839961416, + ts_mono=11474.097399175, + ), + mock( + name="z.long", + type_id=ValueType.F64, + cached=list(range(16)), + default=None, + min=list(range(16)), + max=list(range(16)), + mutable=True, + persistent=False, + ts_device=168.839961416, + ts_mono=11474.097399175, + ), ] diff --git a/kucher/view/main_window/register_view_widget/editor_delegate.py b/kucher/view/main_window/register_view_widget/editor_delegate.py index a875fa5..1e00972 100644 --- a/kucher/view/main_window/register_view_widget/editor_delegate.py +++ b/kucher/view/main_window/register_view_widget/editor_delegate.py @@ -16,8 +16,15 @@ import numpy import typing from logging import getLogger -from PyQt5.QtWidgets import QStyledItemDelegate, QWidget, QStyleOptionViewItem, QSpinBox, QDoubleSpinBox, \ - QPlainTextEdit, QComboBox +from PyQt5.QtWidgets import ( + QStyledItemDelegate, + QWidget, + QStyleOptionViewItem, + QSpinBox, + QDoubleSpinBox, + QPlainTextEdit, + QComboBox, +) from PyQt5.QtCore import Qt, QModelIndex, QObject, QAbstractItemModel, QRect, QSize from PyQt5.QtGui import QFontMetrics, QPainter @@ -39,13 +46,15 @@ class EditorDelegate(QStyledItemDelegate): Factory and manager of editing widgets for use with the Register view table. """ - def __init__(self, - parent: QObject, - message_display_callback: typing.Callable[[str], None]): + def __init__( + self, parent: QObject, message_display_callback: typing.Callable[[str], None] + ): super(EditorDelegate, self).__init__(parent) self._message_display_callback = message_display_callback - def createEditor(self, parent: QWidget, option: QStyleOptionViewItem, index: QModelIndex) -> QWidget: + def createEditor( + self, parent: QWidget, option: QStyleOptionViewItem, index: QModelIndex + ) -> QWidget: """ The set of editors that we have defined here are only good for small-dimensioned registers with a few values. They are not good for unstructured data and large arrays. For that, shall the need arise, we'll need to define @@ -54,31 +63,37 @@ def createEditor(self, parent: QWidget, option: QStyleOptionViewItem, index: QMo appears on top of the view. """ register = self._get_register_from_index(index) - _logger.info('Constructing editor for %r', register) + _logger.info("Constructing editor for %r", register) if self._can_use_bool_switch(register): editor = QComboBox(parent) editor.setEditable(False) - editor.addItem(get_icon('cancel'), 'False (0)') - editor.addItem(get_icon('ok'), 'True (1)') + editor.addItem(get_icon("cancel"), "False (0)") + editor.addItem(get_icon("ok"), "True (1)") elif self._can_use_spinbox(register): minimum, maximum = register.min_value[0], register.max_value[0] try: dtype = Register.get_numpy_type(register.type_id) - float_decimals = int(abs(math.log10(numpy.finfo(dtype).resolution)) + 0.5) + 1 + float_decimals = ( + int(abs(math.log10(numpy.finfo(dtype).resolution)) + 0.5) + 1 + ) except ValueError: float_decimals = None if float_decimals is not None: - step = (maximum - minimum) / _MIN_PREFERRED_NUMBER_OF_STEPS_IN_FULL_RANGE + step = ( + maximum - minimum + ) / _MIN_PREFERRED_NUMBER_OF_STEPS_IN_FULL_RANGE try: step = 10 ** round(math.log10(step)) except ValueError: - step = 1 # Math domain error corner case + step = 1 # Math domain error corner case - step = min(1.0, step) # Step can't be greater than one for UX reasons - _logger.info('Constructing QDoubleSpinBox with single step set to %r', step) + step = min(1.0, step) # Step can't be greater than one for UX reasons + _logger.info( + "Constructing QDoubleSpinBox with single step set to %r", step + ) editor = QDoubleSpinBox(parent) editor.setSingleStep(step) editor.setDecimals(float_decimals) @@ -90,11 +105,13 @@ def createEditor(self, parent: QWidget, option: QStyleOptionViewItem, index: QMo else: editor = QPlainTextEdit(parent) editor.setFont(get_monospace_font()) - editor.setMinimumWidth(QFontMetrics(editor.font()).width('9' * (MAX_LINE_LENGTH + 5))) + editor.setMinimumWidth( + QFontMetrics(editor.font()).width("9" * (MAX_LINE_LENGTH + 5)) + ) editor.setFont(Model.get_font()) - self._message_display_callback('Press Esc to cancel editing') + self._message_display_callback("Press Esc to cancel editing") return editor @@ -105,7 +122,11 @@ def setEditorData(self, editor: QWidget, index: QModelIndex): if isinstance(editor, QComboBox): assert self._can_use_bool_switch(register) editor.setCurrentIndex(int(bool(register.cached_value[0]))) - _logger.info('Value %r has been represented as %r', register.cached_value, editor.currentText()) + _logger.info( + "Value %r has been represented as %r", + register.cached_value, + editor.currentText(), + ) elif isinstance(editor, (QDoubleSpinBox, QSpinBox)): assert self._can_use_spinbox(register) editor.setValue(register.cached_value[0]) @@ -113,9 +134,11 @@ def setEditorData(self, editor: QWidget, index: QModelIndex): assert not self._can_use_spinbox(register) editor.setPlainText(display_value(register.cached_value, register.type_id)) else: - raise TypeError(f'Unexpected editor: {editor}') + raise TypeError(f"Unexpected editor: {editor}") - def setModelData(self, editor: QWidget, model: QAbstractItemModel, index: QModelIndex): + def setModelData( + self, editor: QWidget, model: QAbstractItemModel, index: QModelIndex + ): """Invoked ad the end of the editing session; data transferred from the editor to the model""" register = self._get_register_from_index(index) @@ -129,10 +152,12 @@ def setModelData(self, editor: QWidget, model: QAbstractItemModel, index: QModel # True # Wait what?! value = bool(editor.currentIndex()) - _logger.info('Value %r has been interpreted as %r', editor.currentText(), value) + _logger.info( + "Value %r has been interpreted as %r", editor.currentText(), value + ) elif isinstance(editor, (QDoubleSpinBox, QSpinBox)): assert self._can_use_spinbox(register) - editor.interpretText() # Beware!!1 + editor.interpretText() # Beware!!1 value = editor.value() elif isinstance(editor, QPlainTextEdit): assert not self._can_use_spinbox(register) @@ -140,17 +165,26 @@ def setModelData(self, editor: QWidget, model: QAbstractItemModel, index: QModel try: value = parse_value(text, register.type_id) except Exception as ex: - _logger.warning('The following value could not be parsed: %r', text, exc_info=True) - show_error('Invalid value', 'Could not parse the entered value', repr(ex), editor.window()) + _logger.warning( + "The following value could not be parsed: %r", text, exc_info=True + ) + show_error( + "Invalid value", + "Could not parse the entered value", + repr(ex), + editor.window(), + ) value = None else: - raise TypeError(f'Unexpected editor: {editor}') + raise TypeError(f"Unexpected editor: {editor}") # We're not going to touch the device here; instead, we're going to delegate that back to the Model instance. if value is not None: model.setData(index, value, Qt.EditRole) - def updateEditorGeometry(self, editor: QWidget, option: QStyleOptionViewItem, index: QModelIndex): + def updateEditorGeometry( + self, editor: QWidget, option: QStyleOptionViewItem, index: QModelIndex + ): """ http://doc.qt.io/qt-5/model-view-programming.html#delegate-classes """ @@ -192,10 +226,11 @@ def updateEditorGeometry(self, editor: QWidget, option: QStyleOptionViewItem, in y_offset = max(0, editor_size.height() - rect.height()) // 2 # We also have to make sure that we don't accidentally move it into negative coordinates! # That would hide part of the widget, unacceptable - editor.move(max(0, rect.x() - x_offset), - max(0, rect.y() - y_offset)) + editor.move(max(0, rect.x() - x_offset), max(0, rect.y() - y_offset)) - def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex): + def paint( + self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex + ): """ Reposition the icon to the right side. """ @@ -207,10 +242,14 @@ def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIn def _get_register_from_index(index: QModelIndex) -> Register: register = Model.get_register_from_index(index) if register is None: - raise ValueError(f'Logic error - the index {index} MUST contain a valid register reference') + raise ValueError( + f"Logic error - the index {index} MUST contain a valid register reference" + ) if register.type_id == Register.ValueType.EMPTY: - raise ValueError("Did not expect an empty register here (who needs them anyway? doesn't make sense)") + raise ValueError( + "Did not expect an empty register here (who needs them anyway? doesn't make sense)" + ) return register @@ -220,12 +259,13 @@ def _can_use_spinbox(register: Register) -> bool: # If the register is variable size, the user may set it to a single scalar using the vector editor, # and the next time it tries to edit it we still have to show the vector editor rather than scalar. # Hence we check the sizes of the min and max to prevent the user from being stuck with the scalar editor. - return \ - register.kind == Register.ValueKind.ARRAY_OF_SCALARS and \ - register.has_min_and_max_values and \ - len(register.cached_value) == 1 and \ - len(register.min_value) == 1 and \ - len(register.max_value) == 1 + return ( + register.kind == Register.ValueKind.ARRAY_OF_SCALARS + and register.has_min_and_max_values + and len(register.cached_value) == 1 + and len(register.min_value) == 1 + and len(register.max_value) == 1 + ) @staticmethod def _can_use_bool_switch(register: Register) -> bool: @@ -240,7 +280,9 @@ def _can_use_bool_switch(register: Register) -> bool: if register.has_default_value and len(register.default_value) != 1: return False - if register.has_min_and_max_values and (len(register.min_value) != 1 or len(register.max_value) != 1): + if register.has_min_and_max_values and ( + len(register.min_value) != 1 or len(register.max_value) != 1 + ): return False return True diff --git a/kucher/view/main_window/register_view_widget/import_export_dialog.py b/kucher/view/main_window/register_view_widget/import_export_dialog.py index 6cf4e2e..a25734c 100644 --- a/kucher/view/main_window/register_view_widget/import_export_dialog.py +++ b/kucher/view/main_window/register_view_widget/import_export_dialog.py @@ -21,13 +21,27 @@ import typing from logging import getLogger -from PyQt5.QtWidgets import QWidget, QFileDialog, QTableWidget, QTableWidgetItem, QDialog, QLabel, \ - QPushButton, QHeaderView, QMessageBox +from PyQt5.QtWidgets import ( + QWidget, + QFileDialog, + QTableWidget, + QTableWidgetItem, + QDialog, + QLabel, + QPushButton, + QHeaderView, + QMessageBox, +) from popcop.standard.register import ValueType from kucher.view.widgets import WidgetBase from kucher.view.device_model_representation import Register -from kucher.view.utils import show_error, lay_out_horizontally, lay_out_vertically, get_monospace_font +from kucher.view.utils import ( + show_error, + lay_out_horizontally, + lay_out_vertically, + get_monospace_font, +) _logger = getLogger(__name__) @@ -42,11 +56,11 @@ class CheckResult(enum.Enum): CHECK_RESULT_MAPPING: typing.Dict[CheckResult, str] = { - CheckResult.NOT_MUTABLE: 'This parameter cannot be modified on this device: ', - CheckResult.INCORRECT_TYPE: 'This parameter type is incorrect: ', - CheckResult.INCORRECT_DIMENSION: 'This parameter dimension is incorrect: ', - CheckResult.OUTSIDE_RANGE: 'This parameter value is outside permitted range: ', - CheckResult.UNKNOWN: 'Reason: ', + CheckResult.NOT_MUTABLE: "This parameter cannot be modified on this device: ", + CheckResult.INCORRECT_TYPE: "This parameter type is incorrect: ", + CheckResult.INCORRECT_DIMENSION: "This parameter dimension is incorrect: ", + CheckResult.OUTSIDE_RANGE: "This parameter value is outside permitted range: ", + CheckResult.UNKNOWN: "Reason: ", } @@ -57,18 +71,21 @@ def export_registers(parent: WidgetBase, registers: list): dialog_box = QWidget() dialog_box.setGeometry(10, 10, 1000, 700) - file_name, _ = QFileDialog.getSaveFileName(dialog_box, 'Export configuration file', 'config.yml', - 'YAML Files (*.yml *.yaml);;All Files (*)') + file_name, _ = QFileDialog.getSaveFileName( + dialog_box, + "Export configuration file", + "config.yml", + "YAML Files (*.yml *.yaml);;All Files (*)", + ) if not file_name: return try: - _file = open(file_name, 'w+') + _file = open(file_name, "w+") except Exception as ex: - show_error('Export failed', - f'Cannot open {file_name}', - f'Error: {str(ex)}', - parent) - _logger.exception(f'File {file_name} could not be open: {str(ex)}') + show_error( + "Export failed", f"Cannot open {file_name}", f"Error: {str(ex)}", parent + ) + _logger.exception(f"File {file_name} could not be open: {str(ex)}") return async def executor(): @@ -79,15 +96,14 @@ async def executor(): register_yaml[reg.name] = await reg.read_through() yaml.dump(register_yaml, _file) - display_sucess_message('Export successful', - f'Parameters have been successfully exported to:\n{file_name}', - parent) + display_sucess_message( + "Export successful", + f"Parameters have been successfully exported to:\n{file_name}", + parent, + ) except Exception as ex: - show_error('Export failed', - f'Parameters cannot be read.', - str(ex), - parent) - _logger.exception(f'Registers could not be read') + show_error("Export failed", f"Parameters cannot be read.", str(ex), parent) + _logger.exception(f"Registers could not be read") finally: _file.close() @@ -116,27 +132,29 @@ def import_registers(parent: WidgetBase, registers: list): """ dialog_box = QWidget() dialog_box.setGeometry(10, 10, 1000, 700) - file_name, _ = QFileDialog.getOpenFileName(dialog_box, 'Import configuration file', '', - 'YAML Files (*.yml *.yaml);;All Files (*)') + file_name, _ = QFileDialog.getOpenFileName( + dialog_box, + "Import configuration file", + "", + "YAML Files (*.yml *.yaml);;All Files (*)", + ) if not file_name: return try: - with open(file_name, 'r') as file: + with open(file_name, "r") as file: imported_registers = yaml.load(file, Loader=yaml.Loader) except IOError as ex: - _logger.exception(f'File {file_name} could not be open') - show_error('Import failed', - f'Cannot open {file_name}', - f'Error: {str(ex)}', - parent) + _logger.exception(f"File {file_name} could not be open") + show_error( + "Import failed", f"Cannot open {file_name}", f"Error: {str(ex)}", parent + ) return except Exception as ex: - _logger.exception(f'File {file_name} could not be parsed') - show_error('Import failed', - f'Cannot read {file_name}', - f'Error: {str(ex)}', - parent) + _logger.exception(f"File {file_name} could not be parsed") + show_error( + "Import failed", f"Cannot read {file_name}", f"Error: {str(ex)}", parent + ) return if imported_registers: @@ -152,37 +170,60 @@ def check_registers(registers: list, imported_registers: dict) -> CheckResult: for reg_check in registers: if reg_check.name in imported_registers: if not (reg_check.mutable and reg_check.persistent): - _logger.error(f'Import failed: this parameter cannot be modified on this device: {reg_check}') + _logger.error( + f"Import failed: this parameter cannot be modified on this device: {reg_check}" + ) return CheckResult.NOT_MUTABLE, reg_check.name elif not check_type(reg_check, imported_registers[reg_check.name]): - _logger.error(f'Import failed: this parameter type is incorrect {reg_check}') + _logger.error( + f"Import failed: this parameter type is incorrect {reg_check}" + ) return CheckResult.INCORRECT_TYPE, reg_check.name - elif len(imported_registers[reg_check.name]) != len(reg_check.cached_value): - _logger.error(f'Import failed: this parameter dimension is incorrect {reg_check}') + elif len(imported_registers[reg_check.name]) != len( + reg_check.cached_value + ): + _logger.error( + f"Import failed: this parameter dimension is incorrect {reg_check}" + ) return CheckResult.INCORRECT_DIMENSION, reg_check.name - elif reg_check.has_min_and_max_values and reg_check.type_id != Register.ValueType.BOOLEAN: - if not (reg_check.min_value <= imported_registers[reg_check.name] <= reg_check.max_value): - _logger.error(f'Import failed: this parameter value is outside permitted range {reg_check}') + elif ( + reg_check.has_min_and_max_values + and reg_check.type_id != Register.ValueType.BOOLEAN + ): + if not ( + reg_check.min_value + <= imported_registers[reg_check.name] + <= reg_check.max_value + ): + _logger.error( + f"Import failed: this parameter value is outside permitted range {reg_check}" + ) return CheckResult.OUTSIDE_RANGE, reg_check.name except Exception as ex: - _logger.exception(f'Could not write registers: {str(ex)}') + _logger.exception(f"Could not write registers: {str(ex)}") return CheckResult.UNKNOWN, str(ex) - return CheckResult.NO_ERROR, '' + return CheckResult.NO_ERROR, "" -def show_error_box(result: CheckResult, detail: str, file_name: str, parent: WidgetBase): - show_error('Import failed', - f'Cannot import {file_name}', - CHECK_RESULT_MAPPING[result] + detail, - parent) +def show_error_box( + result: CheckResult, detail: str, file_name: str, parent: WidgetBase +): + show_error( + "Import failed", + f"Cannot import {file_name}", + CHECK_RESULT_MAPPING[result] + detail, + parent, + ) -def write_registers(parent: WidgetBase, file_name: str, registers: list, imported_registers: dict): +def write_registers( + parent: WidgetBase, file_name: str, registers: list, imported_registers: dict +): async def executor(): unwritten_registers = [] for reg in imported_registers: @@ -195,27 +236,35 @@ async def executor(): except Exception as ex: _attempt += 1 - _logger.exception(f'Register {reg} could not be loaded (attempt {_attempt}/3)') + _logger.exception( + f"Register {reg} could not be loaded (attempt {_attempt}/3)" + ) if _attempt >= 3: try: old_reg = next((r for r in registers if r.name == reg)) old_value = old_reg.cached_value except Exception: - old_value = ['(unknown)'] + old_value = ["(unknown)"] - unwritten_registers.append([reg, old_value, imported_registers[reg]]) + unwritten_registers.append( + [reg, old_value, imported_registers[reg]] + ) break if unwritten_registers: - display_warning_message('Import successful', - f'{file_name} have been successfully imported.', - parent, - unwritten_registers) + display_warning_message( + "Import successful", + f"{file_name} have been successfully imported.", + parent, + unwritten_registers, + ) else: - display_sucess_message('Import successful', - f'{file_name} have been successfully imported.', - parent) + display_sucess_message( + "Import successful", + f"{file_name} have been successfully imported.", + parent, + ) asyncio.get_event_loop().create_task(executor()) @@ -224,27 +273,31 @@ def check_type(old_reg: Register, new_value: list) -> bool: """ Checks if all elements of new_value are the same type as old_reg value. """ - _int_types = (ValueType.I64, - ValueType.I32, - ValueType.I16, - ValueType.I8, - ValueType.U64, - ValueType.U32, - ValueType.U16, - ValueType.U8) - - _float_types = (ValueType.F32, - ValueType.F64) + _int_types = ( + ValueType.I64, + ValueType.I32, + ValueType.I16, + ValueType.I8, + ValueType.U64, + ValueType.U32, + ValueType.U16, + ValueType.U8, + ) + + _float_types = (ValueType.F32, ValueType.F64) # >>> isinstance(True, int) # True if all(map(lambda _type: isinstance(_type, bool), new_value)): return old_reg.type_id == ValueType.BOOLEAN else: - _type_float = all(map(lambda _type: isinstance(_type, float), new_value)) and (old_reg.type_id in _float_types) + _type_float = all(map(lambda _type: isinstance(_type, float), new_value)) and ( + old_reg.type_id in _float_types + ) # allow the user to enter an int if expected value is float - _type_int = all(map(lambda _type: isinstance(_type, int), new_value)) and \ - (old_reg.type_id in _float_types + _int_types) + _type_int = all(map(lambda _type: isinstance(_type, int), new_value)) and ( + old_reg.type_id in _float_types + _int_types + ) return _type_float or _type_int @@ -256,7 +309,9 @@ def display_sucess_message(title: str, text: str, parent: WidgetBase): mbox.show() -def display_warning_message(title: str, text: str, parent: WidgetBase, unwritten_registers: list): +def display_warning_message( + title: str, text: str, parent: WidgetBase, unwritten_registers: list +): _warning = QDialog(parent) _warning.setWindowTitle(title) @@ -265,7 +320,9 @@ def display_warning_message(title: str, text: str, parent: WidgetBase, unwritten _tableWidget.setRowCount(len(unwritten_registers)) _tableWidget.setColumnCount(3) - _tableWidget.setHorizontalHeaderLabels(['Full name', 'Current value', 'Requested value']) + _tableWidget.setHorizontalHeaderLabels( + ["Full name", "Current value", "Requested value"] + ) _tableWidget.horizontalHeader().setStretchLastSection(True) _header = _tableWidget.horizontalHeader() @@ -274,24 +331,32 @@ def display_warning_message(title: str, text: str, parent: WidgetBase, unwritten _header.setSectionResizeMode(2, QHeaderView.Stretch) _tableWidget.verticalHeader().hide() - _tableWidget.verticalHeader().setSectionResizeMode(_tableWidget.verticalHeader().ResizeToContents) + _tableWidget.verticalHeader().setSectionResizeMode( + _tableWidget.verticalHeader().ResizeToContents + ) for i in range(len(unwritten_registers)): _name = unwritten_registers[i][0] _current_value = unwritten_registers[i][1] _requested_value = unwritten_registers[i][2] - _tableWidget.setItem(i, 0, QTableWidgetItem(_name + ' ')) - _tableWidget.setItem(i, 1, QTableWidgetItem(', '.join(str(e) for e in _current_value))) - _tableWidget.setItem(i, 2, QTableWidgetItem(', '.join(str(e) for e in _requested_value))) + _tableWidget.setItem(i, 0, QTableWidgetItem(_name + " ")) + _tableWidget.setItem( + i, 1, QTableWidgetItem(", ".join(str(e) for e in _current_value)) + ) + _tableWidget.setItem( + i, 2, QTableWidgetItem(", ".join(str(e) for e in _requested_value)) + ) _btn_ok = QPushButton(_warning) - _btn_ok.setText('Ok') + _btn_ok.setText("Ok") _btn_ok.clicked.connect(_warning.close) _warning.setLayout( lay_out_vertically( lay_out_horizontally(QLabel(text, _warning)), - lay_out_horizontally(QLabel('Some configuration parameters could not be written:', _warning)), + lay_out_horizontally( + QLabel("Some configuration parameters could not be written:", _warning) + ), lay_out_horizontally(_tableWidget), lay_out_horizontally(_btn_ok), ) diff --git a/kucher/view/main_window/register_view_widget/model.py b/kucher/view/main_window/register_view_widget/model.py index 05d0bfc..577a238 100644 --- a/kucher/view/main_window/register_view_widget/model.py +++ b/kucher/view/main_window/register_view_widget/model.py @@ -23,7 +23,15 @@ from logging import getLogger from PyQt5.QtWidgets import QWidget from PyQt5.QtCore import Qt, QAbstractItemModel, QModelIndex, QVariant, QRect -from PyQt5.QtGui import QPalette, QFontMetrics, QFont, QPixmap, QPainter, QBitmap, QColor +from PyQt5.QtGui import ( + QPalette, + QFontMetrics, + QFont, + QPixmap, + QPainter, + QBitmap, + QColor, +) from kucher.view.utils import gui_test, get_monospace_font, get_icon_pixmap, cached from kucher.view.device_model_representation import Register @@ -34,7 +42,7 @@ _logger = getLogger(__name__) -_NAME_SEGMENT_SEPARATOR = '.' +_NAME_SEGMENT_SEPARATOR = "." # noinspection PyMethodOverriding @@ -45,31 +53,29 @@ class Model(QAbstractItemModel): """ _COLUMNS = [ - 'Tree', - 'Value', - 'Type', - 'Default', - 'Min', - 'Max', - 'Flags', - 'Device timestamp', - 'Full name', + "Tree", + "Value", + "Type", + "Default", + "Min", + "Max", + "Flags", + "Device timestamp", + "Full name", ] class ColumnIndices(enum.IntEnum): - NAME = 0 - VALUE = 1 - TYPE = 2 - DEFAULT = 3 - MIN = 4 - MAX = 5 - FLAGS = 6 + NAME = 0 + VALUE = 1 + TYPE = 2 + DEFAULT = 3 + MIN = 4 + MAX = 5 + FLAGS = 6 DEVICE_TIMESTAMP = 7 - FULL_NAME = 8 + FULL_NAME = 8 - def __init__(self, - parent: QWidget, - registers: typing.Iterable[Register]): + def __init__(self, parent: QWidget, registers: typing.Iterable[Register]): super(Model, self).__init__(parent) self._regular_font = self.get_font() @@ -88,37 +94,46 @@ def default_data_handler(*_) -> QVariant: return default_data_handler - self._data_role_dispatch: typing.DefaultDict[int, typing.Callable[[QModelIndex], typing.Any]] = \ - collections.defaultdict(make_default_data_handler) + self._data_role_dispatch: typing.DefaultDict[ + int, typing.Callable[[QModelIndex], typing.Any] + ] = collections.defaultdict(make_default_data_handler) - self._data_role_dispatch[Qt.DisplayRole] = self._data_display - self._data_role_dispatch[Qt.ToolTipRole] = self._data_tool_tip_status_tip - self._data_role_dispatch[Qt.StatusTipRole] = self._data_tool_tip_status_tip + self._data_role_dispatch[Qt.DisplayRole] = self._data_display + self._data_role_dispatch[Qt.ToolTipRole] = self._data_tool_tip_status_tip + self._data_role_dispatch[Qt.StatusTipRole] = self._data_tool_tip_status_tip self._data_role_dispatch[Qt.ForegroundRole] = self._data_foreground - self._data_role_dispatch[Qt.FontRole] = self._data_font + self._data_role_dispatch[Qt.FontRole] = self._data_font self._data_role_dispatch[Qt.DecorationRole] = self._data_decoration self._registers = list(sorted(registers, key=lambda x: x.name)) self._tree = _plant_tree(self._registers) - _logger.debug('Register tree for %r:\n%s\n', self, self._tree.to_pretty_string()) + _logger.debug( + "Register tree for %r:\n%s\n", self, self._tree.to_pretty_string() + ) # This map contains references from register name to the model index pointing to the zero column self._register_name_to_index_column_zero_map: typing.Dict[str, QModelIndex] = {} for index in self.iter_indices(): try: - self._register_name_to_index_column_zero_map[self._unwrap(index).register.name] = index + self._register_name_to_index_column_zero_map[ + self._unwrap(index).register.name + ] = index except AttributeError: pass - _logger.debug('Register look-up table: %r', self._register_name_to_index_column_zero_map) + _logger.debug( + "Register look-up table: %r", self._register_name_to_index_column_zero_map + ) # Set up register update callbacks decoupled via weak references # It is important to use weak references because we don't want the events to keep our object alive for r in self._registers: r.update_event.connect_weak(self, Model._on_register_update) - def iter_indices(self, root: QModelIndex = None) -> typing.Generator[QModelIndex, None, None]: + def iter_indices( + self, root: QModelIndex = None + ) -> typing.Generator[QModelIndex, None, None]: """ Iterates over all indexes in this model starting from :param root:. Returns a generator of QModelIndex. """ @@ -135,23 +150,29 @@ def iter_indices(self, root: QModelIndex = None) -> typing.Generator[QModelIndex def registers(self) -> typing.List[Register]: return self._registers - async def read(self, - registers: typing.Iterable[Register], - progress_callback: typing.Optional[typing.Callable[[Register, int, int], None]] = None): + async def read( + self, + registers: typing.Iterable[Register], + progress_callback: typing.Optional[ + typing.Callable[[Register, int, int], None] + ] = None, + ): """ :param registers: which ones to read :param progress_callback: (register: Register, current_register_index: int, total_registers: int) -> None """ registers = list(registers) - progress_callback = progress_callback if progress_callback is not None else lambda *_: None + progress_callback = ( + progress_callback if progress_callback is not None else lambda *_: None + ) - _logger.info('Read: %r registers to go', len(registers)) + _logger.info("Read: %r registers to go", len(registers)) # Mark all for update for r in registers: # Great Scott! One point twenty-one gigawatt of power! node = self._unwrap(self._register_name_to_index_column_zero_map[r.name]) - node.set_state(_Node.State.PENDING, 'Waiting for update...') + node.set_state(_Node.State.PENDING, "Waiting for update...") # Normally, we should invalidate the altered items, to signal the view that they should be redrawn. # However, when we do that, the Qt engine gets a seizure and takes a few seconds to # redraw the widget a hundred (sic!) times over, freezing completely for a few seconds! @@ -167,33 +188,41 @@ async def read(self, await r.read_through() except asyncio.CancelledError: for reg in registers: - self._unwrap(self._register_name_to_index_column_zero_map[reg.name]).set_state(_Node.State.DEFAULT) + self._unwrap( + self._register_name_to_index_column_zero_map[reg.name] + ).set_state(_Node.State.DEFAULT) raise except Exception as ex: - _logger.exception('Read progress: Could not read %r', r) - node.set_state(node.State.ERROR, f'Update failed: {ex}') + _logger.exception("Read progress: Could not read %r", r) + node.set_state(node.State.ERROR, f"Update failed: {ex}") else: - _logger.info('Read progress: Read %r', r) + _logger.info("Read progress: Read %r", r) node.set_state(node.State.DEFAULT) finally: self._perform_full_slow_invalidation(r) - async def write(self, - register_value_mapping: typing.Dict[Register, typing.Any], - progress_callback: typing.Optional[typing.Callable[[Register, int, int], None]] = None): + async def write( + self, + register_value_mapping: typing.Dict[Register, typing.Any], + progress_callback: typing.Optional[ + typing.Callable[[Register, int, int], None] + ] = None, + ): """ :param register_value_mapping: keys are registers, values are what to assign :param progress_callback: (register: Register, current_register_index: int, total_registers: int) -> None """ - progress_callback = progress_callback if progress_callback is not None else lambda *_: None + progress_callback = ( + progress_callback if progress_callback is not None else lambda *_: None + ) - _logger.info('Write: %r registers to go', len(register_value_mapping)) + _logger.info("Write: %r registers to go", len(register_value_mapping)) # Mark all for write for r, v in register_value_mapping.items(): # Great Scott! One point twenty-one gigawatt of power! node = self._unwrap(self._register_name_to_index_column_zero_map[r.name]) - node.set_state(_Node.State.PENDING, f'Waiting to write {v!r}...') + node.set_state(_Node.State.PENDING, f"Waiting to write {v!r}...") # Normally, we should invalidate the altered items, to signal the view that they should be redrawn. # However, when we do that, the Qt engine gets a seizure and takes a few seconds to # redraw the widget a hundred (sic!) times over, freezing completely for a few seconds! @@ -209,13 +238,15 @@ async def write(self, await r.write_through(value) except asyncio.CancelledError: for reg in register_value_mapping.keys(): - self._unwrap(self._register_name_to_index_column_zero_map[reg.name]).set_state(_Node.State.DEFAULT) + self._unwrap( + self._register_name_to_index_column_zero_map[reg.name] + ).set_state(_Node.State.DEFAULT) raise except Exception as ex: - _logger.exception('Write progress: Could not write %r', r) - node.set_state(node.State.ERROR, f'Write failed: {ex}') + _logger.exception("Write progress: Could not write %r", r) + node.set_state(node.State.ERROR, f"Write failed: {ex}") else: - _logger.info('Write progress: Wrote %r with %r', r, value) + _logger.info("Write progress: Wrote %r with %r", r, value) node.set_state(node.State.DEFAULT) finally: self._perform_full_slow_invalidation(r) @@ -288,7 +319,11 @@ def _data_display(self, index: QModelIndex) -> str: return display_value(node.register.max_value, node.register.type_id) if column == self.ColumnIndices.DEVICE_TIMESTAMP: - return str(datetime.timedelta(seconds=float(node.register.update_timestamp_device_time))) + return str( + datetime.timedelta( + seconds=float(node.register.update_timestamp_device_time) + ) + ) if column == self.ColumnIndices.FULL_NAME: return node.register.name @@ -308,22 +343,24 @@ def _data_tool_tip_status_tip(self, index: QModelIndex) -> str: out = f'This register is {"mutable" if node.register.mutable else "immutable"}. ' if node.register.mutable and node.register.has_default_value: if node.register.cached_value_is_default_value: - out += 'Current value is default value.' + out += "Current value is default value." else: - out += 'Current value differs from the default value.' + out += "Current value differs from the default value." return out if column == self.ColumnIndices.FLAGS: if node.register is not None: - return ', '.join([ - 'mutable' if node.register.mutable else 'immutable', - 'persistent' if node.register.persistent else 'not persistent', - ]).capitalize() + return ", ".join( + [ + "mutable" if node.register.mutable else "immutable", + "persistent" if node.register.persistent else "not persistent", + ] + ).capitalize() if column == self.ColumnIndices.DEVICE_TIMESTAMP: if node.register is not None: delta = time.monotonic() - node.register.update_timestamp_monotonic - return f'Last synchronized {round(delta)} seconds ago' + return f"Last synchronized {round(delta)} seconds ago" return str() @@ -331,7 +368,10 @@ def _data_foreground(self, index: QModelIndex) -> QColor: node = self._unwrap(index) palette = QPalette() if node.register and (self.flags(index) & Qt.ItemIsEditable): - if node.register.cached_value_is_default_value or not node.register.has_default_value: + if ( + node.register.cached_value_is_default_value + or not node.register.has_default_value + ): return palette.color(QPalette.Link) else: return palette.color(QPalette.LinkVisited) @@ -358,18 +398,23 @@ def _data_decoration(self, index: QModelIndex) -> typing.Union[QPixmap, QVariant if node.register is not None: if column == self.ColumnIndices.VALUE: try: - return get_icon_pixmap({ - _Node.State.PENDING: 'process', - _Node.State.SUCCESS: 'ok', - _Node.State.ERROR: 'error', - }[node.state], self._icon_size) + return get_icon_pixmap( + { + _Node.State.PENDING: "process", + _Node.State.SUCCESS: "ok", + _Node.State.ERROR: "error", + }[node.state], + self._icon_size, + ) except KeyError: pass if column == self.ColumnIndices.FLAGS: - return _draw_flags_icon(mutable=node.register.mutable, - persistent=node.register.persistent, - icon_size=self._icon_size) + return _draw_flags_icon( + mutable=node.register.mutable, + persistent=node.register.persistent, + icon_size=self._icon_size, + ) return QVariant() @@ -389,24 +434,30 @@ def setData(self, index: QModelIndex, value, role: int = None) -> bool: node = self._unwrap(index) if node.register is None: - raise ValueError(f'The specified index {index} has no register associated with it') + raise ValueError( + f"The specified index {index} has no register associated with it" + ) if not node.register.mutable: - raise ValueError(f'The register is immutable: {node.register}') + raise ValueError(f"The register is immutable: {node.register}") async def executor(): - node.set_state(node.State.PENDING, 'Write in progress...') + node.set_state(node.State.PENDING, "Write in progress...") # Note that we don't invalidate immediately - too slow; invalidate once at the end instead # noinspection PyBroadException try: - _logger.info('Writing register %r with %r', node.register, value) + _logger.info("Writing register %r with %r", node.register, value) new_value = await node.register.write_through(value) except Exception as ex: - _logger.exception('Could not write register %r', node.register) - node.set_state(node.State.ERROR, f'Write failed: {ex}') + _logger.exception("Could not write register %r", node.register) + node.set_state(node.State.ERROR, f"Write failed: {ex}") else: - _logger.info('Write to %r complete; new value: %r', node.register, new_value) - node.set_state(node.State.SUCCESS, 'Value has been written successfully') + _logger.info( + "Write to %r complete; new value: %r", node.register, new_value + ) + node.set_state( + node.State.SUCCESS, "Value has been written successfully" + ) finally: self._perform_full_slow_invalidation(index) @@ -432,7 +483,9 @@ def headerData(self, section: int, orientation: int, role: int = None): return QVariant() # noinspection PyArgumentList,PyUnresolvedReferences - def _perform_full_slow_invalidation(self, index_or_register: typing.Union[QModelIndex, Register]): + def _perform_full_slow_invalidation( + self, index_or_register: typing.Union[QModelIndex, Register] + ): """ The function is named this way in order to make it explicit that it has a ridiculous performance impact. It invokes the dataChanged() event, which under certain unclear circumstances may cause the view to @@ -444,7 +497,7 @@ def _perform_full_slow_invalidation(self, index_or_register: typing.Union[QModel elif isinstance(index_or_register, QModelIndex): index = index_or_register else: - raise TypeError(f'Unexpected type: {type(index_or_register)}') + raise TypeError(f"Unexpected type: {type(index_or_register)}") # Invalidate only value column. # Other columns are assumed to be updated by the view lazily, e.g. on focus change. @@ -453,10 +506,14 @@ def _perform_full_slow_invalidation(self, index_or_register: typing.Union[QModel which: QModelIndex = index.sibling(index.row(), self.ColumnIndices.VALUE) assert which.isValid() assert self._unwrap(which).register == self._unwrap(index).register - self.dataChanged.emit(which, which, [ - Qt.DisplayRole, - Qt.DecorationRole, - ]) + self.dataChanged.emit( + which, + which, + [ + Qt.DisplayRole, + Qt.DecorationRole, + ], + ) def _on_register_update(self, register: Register): """ @@ -468,23 +525,23 @@ def _on_register_update(self, register: Register): node = self._unwrap(self._register_name_to_index_column_zero_map[register.name]) node.set_state(node.State.DEFAULT) - def _resolve_parent_node(self, index: typing.Optional[QModelIndex]) -> '_Node': + def _resolve_parent_node(self, index: typing.Optional[QModelIndex]) -> "_Node": if index is None or not index.isValid(): return self._tree else: return self._unwrap(index) @staticmethod - def _unwrap(index: QModelIndex) -> '_Node': + def _unwrap(index: QModelIndex) -> "_Node": return index.internalPointer() def __str__(self): - return f'Model({len(self._registers)} registers, id=0x{id(self):x})' + return f"Model({len(self._registers)} registers, id=0x{id(self):x})" __repr__ = __str__ def __del__(self): - _logger.info('Model instance %r is being deleted', self) + _logger.info("Model instance %r is being deleted", self) @dataclasses.dataclass @@ -494,31 +551,32 @@ class _Node: Each element may have at most one register and an arbitrary number of children. Each child node is referred to by the name of its segment. """ - parent: typing.Optional['_Node'] # Only the root node doesn't have one - name: str - register: typing.Optional[Register] = None - children: typing.DefaultDict[str, '_Node'] = dataclasses.field(default_factory=dict) + + parent: typing.Optional["_Node"] # Only the root node doesn't have one + name: str + register: typing.Optional[Register] = None + children: typing.DefaultDict[str, "_Node"] = dataclasses.field(default_factory=dict) class State(enum.Enum): DEFAULT = enum.auto() PENDING = enum.auto() SUCCESS = enum.auto() - ERROR = enum.auto() + ERROR = enum.auto() - state: State = State.DEFAULT - message: str = '' + state: State = State.DEFAULT + message: str = "" - def set_state(self, state: '_Node.State', message: str = ''): + def set_state(self, state: "_Node.State", message: str = ""): self.message = message self.state = self.State(state) - def __getitem__(self, item: typing.Union[str, int]) -> '_Node': + def __getitem__(self, item: typing.Union[str, int]) -> "_Node": if isinstance(item, str): return self.children[item] else: return list(self.children.values())[item] - def __setitem__(self, item: str, value: '_Node'): + def __setitem__(self, item: str, value: "_Node"): self.children[item] = value def __contains__(self, item) -> bool: @@ -537,23 +595,37 @@ def index_in_parent(self) -> int: if nm == self.name: return index - raise ValueError(f'Invalid tree: item {self!r} is not owned by its parent {self.parent!r}') + raise ValueError( + f"Invalid tree: item {self!r} is not owned by its parent {self.parent!r}" + ) def to_pretty_string(self, _depth=0) -> str: """Traverses the tree in depth starting from the current node and returns a neat multi-line formatted string""" - return ''.join(map(lambda x: '\n'.join([(' ' * _depth * 4 + x[0]).ljust(40) + ' ' + str(x[1].register or ''), - x[1].to_pretty_string(_depth + 1)]), - self.children.items())).rstrip('\n' if not _depth else '') + return "".join( + map( + lambda x: "\n".join( + [ + (" " * _depth * 4 + x[0]).ljust(40) + + " " + + str(x[1].register or ""), + x[1].to_pretty_string(_depth + 1), + ] + ), + self.children.items(), + ) + ).rstrip("\n" if not _depth else "") def _plant_tree(registers: typing.Iterable[Register]) -> _Node: """ Transforms a flat list of registers into an hierarchical tree. """ - root = _Node(None, '') # No parent, no name + root = _Node(None, "") # No parent, no name for reg in registers: node = root - for segment in reg.name.split(_NAME_SEGMENT_SEPARATOR): # Traverse until the last segment + for segment in reg.name.split( + _NAME_SEGMENT_SEPARATOR + ): # Traverse until the last segment if segment not in node: node[segment] = _Node(node, segment) @@ -572,11 +644,11 @@ def _draw_flags_icon(mutable: bool, persistent: bool, icon_size: int) -> QPixmap This operation is quite resource-consuming (we're drawing pictures after all, ask Picasso), so we cache the results in a LRU cache. """ - icon_name = 'edit' if mutable else 'lock' + icon_name = "edit" if mutable else "lock" mutability = get_icon_pixmap(icon_name, icon_size) # https://youtu.be/AX2uz2XYkbo?t=21s - icon_name = 'save' if persistent else 'random-access-memory' + icon_name = "save" if persistent else "random-access-memory" persistence = get_icon_pixmap(icon_name, icon_size) icon_size_rect = QRect(0, 0, icon_size, icon_size) @@ -588,8 +660,9 @@ def _draw_flags_icon(mutable: bool, persistent: bool, icon_size: int) -> QPixmap painter = QPainter(pixmap) painter.drawPixmap(icon_size_rect, mutability, icon_size_rect) - painter.drawPixmap(QRect(icon_size, 0, icon_size, icon_size), - persistence, icon_size_rect) + painter.drawPixmap( + QRect(icon_size, 0, icon_size, icon_size), persistence, icon_size_rect + ) return pixmap @@ -598,18 +671,24 @@ def _unittest_register_tree(): # noinspection PyTypeChecker tree = _plant_tree(get_mock_registers()) - print('Register tree view:', tree.to_pretty_string(), sep='\n') + print("Register tree view:", tree.to_pretty_string(), sep="\n") - uavcan_transfer_cnt_tx = tree['uavcan']['transfer_cnt']['tx'] - assert uavcan_transfer_cnt_tx.register.name == 'uavcan.transfer_cnt.tx' - assert uavcan_transfer_cnt_tx.parent['tx'].register.name == 'uavcan.transfer_cnt.tx' + uavcan_transfer_cnt_tx = tree["uavcan"]["transfer_cnt"]["tx"] + assert uavcan_transfer_cnt_tx.register.name == "uavcan.transfer_cnt.tx" + assert uavcan_transfer_cnt_tx.parent["tx"].register.name == "uavcan.transfer_cnt.tx" # noinspection PyArgumentList @gui_test def _unittest_register_tree_model(): import gc - from PyQt5.QtWidgets import QApplication, QMainWindow, QTreeView, QHeaderView, QStyleOptionViewItem + from PyQt5.QtWidgets import ( + QApplication, + QMainWindow, + QTreeView, + QHeaderView, + QStyleOptionViewItem, + ) from ._mock_registers import get_mock_registers from .editor_delegate import EditorDelegate from .style_option_modifying_delegate import StyleOptionModifyingDelegate @@ -618,15 +697,24 @@ def _unittest_register_tree_model(): win = QMainWindow() tw = QTreeView(win) - tw.setItemDelegate(EditorDelegate(tw, lambda s: print('Editor display:', s))) - tw.setItemDelegateForColumn(0, StyleOptionModifyingDelegate(tw, decoration_position=QStyleOptionViewItem.Right)) - tw.setStyleSheet(''' + tw.setItemDelegate(EditorDelegate(tw, lambda s: print("Editor display:", s))) + tw.setItemDelegateForColumn( + 0, + StyleOptionModifyingDelegate( + tw, decoration_position=QStyleOptionViewItem.Right + ), + ) + tw.setStyleSheet( + """ QTreeView::item { padding: 0 5px; } - ''') + """ + ) header: QHeaderView = tw.header() header.setSectionResizeMode(QHeaderView.ResizeToContents) - header.setStretchLastSection(False) # Horizontal scroll bar doesn't work if this is enabled + header.setStretchLastSection( + False + ) # Horizontal scroll bar doesn't work if this is enabled registers = get_mock_registers() for r in registers: @@ -653,10 +741,7 @@ async def walk(): await asyncio.sleep(5) good_night_sweet_prince = True - asyncio.get_event_loop().run_until_complete(asyncio.gather( - run_events(), - walk() - )) + asyncio.get_event_loop().run_until_complete(asyncio.gather(run_events(), walk())) win.close() @@ -666,7 +751,7 @@ async def walk(): del app gc.collect() - print('Model references:', gc.get_referrers(model)) + print("Model references:", gc.get_referrers(model)) del model gc.collect() diff --git a/kucher/view/main_window/register_view_widget/style_option_modifying_delegate.py b/kucher/view/main_window/register_view_widget/style_option_modifying_delegate.py index 91e7003..fb6d30e 100644 --- a/kucher/view/main_window/register_view_widget/style_option_modifying_delegate.py +++ b/kucher/view/main_window/register_view_widget/style_option_modifying_delegate.py @@ -23,16 +23,20 @@ class StyleOptionModifyingDelegate(QStyledItemDelegate): http://doc.qt.io/qt-5/qstyleoptionviewitem.html """ - def __init__(self, - parent: QObject, - *, - decoration_position: int = None, - decoration_alignment: int = None): + def __init__( + self, + parent: QObject, + *, + decoration_position: int = None, + decoration_alignment: int = None + ): super(StyleOptionModifyingDelegate, self).__init__(parent) self._decoration_position = decoration_position self._decoration_alignment = decoration_alignment - def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex): + def paint( + self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex + ): if self._decoration_position is not None: option.decorationPosition = self._decoration_position diff --git a/kucher/view/main_window/register_view_widget/textual.py b/kucher/view/main_window/register_view_widget/textual.py index de8a838..486d4d5 100644 --- a/kucher/view/main_window/register_view_widget/textual.py +++ b/kucher/view/main_window/register_view_widget/textual.py @@ -46,8 +46,10 @@ def display_value(value, type_id: Register.ValueType) -> str: @functools.lru_cache(8192) -def _display_value_impl(value: typing.Union[type(None), str, bytes, int, float, bool, tuple], - type_id: Register.ValueType) -> str: +def _display_value_impl( + value: typing.Union[type(None), str, bytes, int, float, bool, tuple], + type_id: Register.ValueType, +) -> str: """ The value must be of a hashable type. List is not hashable, so it has to be converted to tuple first. The size of the cache must be a power of two (refer to the documentation for lru_cache for more info). @@ -56,7 +58,7 @@ def _display_value_impl(value: typing.Union[type(None), str, bytes, int, float, The unit test provided in this file shows about twenty-fold improvement in conversion speed with cache. """ if value is None: - return '' + return "" elif isinstance(value, (str, bytes)): return str(value) else: @@ -68,18 +70,20 @@ def _display_value_impl(value: typing.Union[type(None), str, bytes, int, float, dtype = Register.get_numpy_type(type_id) if dtype is None: - raise ValueError(f'Unknown type ID: {type_id!r}') + raise ValueError(f"Unknown type ID: {type_id!r}") return _display_array_of_scalars(value, dtype) def _display_array_of_scalars(value, dtype: numpy.dtype) -> str: # TODO: Format as rectangular arrays whenever possible - text = numpy.array2string(numpy.array(value, dtype=dtype), - max_line_width=MAX_LINE_LENGTH, - formatter=_get_numpy_formatter(dtype), - separator=', ') - text = text.strip('[]').replace('\n ', '\n') + text = numpy.array2string( + numpy.array(value, dtype=dtype), + max_line_width=MAX_LINE_LENGTH, + formatter=_get_numpy_formatter(dtype), + separator=", ", + ) + text = text.strip("[]").replace("\n ", "\n") return text @@ -88,54 +92,55 @@ def _get_numpy_formatter(dtype: numpy.dtype) -> dict: """Formatter construction can be very slow, we optimize it by caching the results""" try: if dtype == numpy.bool_: - return { - 'bool': '{:d}'.format # Formatting as integer to conserve space - } + return {"bool": "{:d}".format} # Formatting as integer to conserve space else: info = numpy.iinfo(dtype) item_length = max(len(str(info.max)), len(str(info.min))) - return { - 'int_kind': ('{' + f':{item_length}' + '}').format - } + return {"int_kind": ("{" + f":{item_length}" + "}").format} except ValueError: decimals = int(abs(math.log10(numpy.finfo(dtype).resolution)) + 0.5) - return { - 'float_kind': '{:#.@g}'.replace('@', str(decimals)).format - } + return {"float_kind": "{:#.@g}".replace("@", str(decimals)).format} def _unittest_display_value(): import time + tid = Register.ValueType begun = time.monotonic() # Running multiple tests in a row in order to evaluate the caching performance for _ in range(100): - assert display_value(True, tid.BOOLEAN) == 'True' - assert display_value(False, tid.BOOLEAN) == 'False' - assert display_value([True, False, True], tid.BOOLEAN) == '1, 0, 1' - - assert display_value(123, tid.U8) == '123' - assert display_value([-1, +12, -123], tid.I8) == ' -1, 12, -123' - assert display_value(123.456789, tid.F32) == '123.457' - assert display_value([123.456789, -12e-34], tid.F32) == '123.457, -1.20000e-33' - - assert display_value(list(range(9)), tid.F64) == ''' + assert display_value(True, tid.BOOLEAN) == "True" + assert display_value(False, tid.BOOLEAN) == "False" + assert display_value([True, False, True], tid.BOOLEAN) == "1, 0, 1" + + assert display_value(123, tid.U8) == "123" + assert display_value([-1, +12, -123], tid.I8) == " -1, 12, -123" + assert display_value(123.456789, tid.F32) == "123.457" + assert display_value([123.456789, -12e-34], tid.F32) == "123.457, -1.20000e-33" + + assert ( + display_value(list(range(9)), tid.F64) + == """ 0.00000000000000, 1.00000000000000, 2.00000000000000, 3.00000000000000, 4.00000000000000, 5.00000000000000, 6.00000000000000, 7.00000000000000, 8.00000000000000 -'''.strip() +""".strip() + ) - assert display_value(list(range(9)), tid.F32) == ''' + assert ( + display_value(list(range(9)), tid.F32) + == """ 0.00000, 1.00000, 2.00000, 3.00000, 4.00000, 5.00000, 6.00000, 7.00000, 8.00000 -'''.strip() +""".strip() + ) elapsed = time.monotonic() - begun - print(f'Display value test completed in {elapsed * 1e3:.1f} milliseconds') + print(f"Display value test completed in {elapsed * 1e3:.1f} milliseconds") def parse_value(text: str, type_id: Register.ValueType): @@ -143,7 +148,7 @@ def parse_value(text: str, type_id: Register.ValueType): Inverse to @ref display_value(). """ value = _parse_value_impl(text, type_id) - _logger.info('Value parser [with type %r]: %r --> %r', type_id, text, value) + _logger.info("Value parser [with type %r]: %r --> %r", type_id, text, value) return value @@ -155,32 +160,45 @@ def _parse_value_impl(text: str, type_id: Register.ValueType): return text if type_id == Register.ValueType.UNSTRUCTURED: - return text.encode('latin1') + return text.encode("latin1") def parse_scalar(x: str) -> typing.Union[int, float]: try: - return int(x, 0) # Supporting standard radix prefixes: 0x, 0b, 0o + return int(x, 0) # Supporting standard radix prefixes: 0x, 0b, 0o except ValueError: - return float(x) # Couldn't parse int, try float + return float(x) # Couldn't parse int, try float # Normalize the case, resolve some special values, normalize separators - text = text.lower().replace('true', '1').replace('false', '0').replace(',', ' ').strip() + text = ( + text.lower() + .replace("true", "1") + .replace("false", "0") + .replace(",", " ") + .strip() + ) return [parse_scalar(x) for x in text.split()] def _unittest_parse_value(): from pytest import approx + tid = Register.ValueType - assert parse_value('', tid.EMPTY) is None - assert parse_value('Arbitrary', tid.EMPTY) is None - assert parse_value('Arbitrary', tid.STRING) == 'Arbitrary' - assert parse_value('\x01\x02\x88\xFF', tid.UNSTRUCTURED) == bytes([1, 2, 0x88, 0xFF]) - assert parse_value('0', tid.BOOLEAN) == [False] - assert parse_value('True, false', tid.BOOLEAN) == [True, False] - assert parse_value('true, False', tid.I8) == [1, 0] - assert parse_value('0.123, 56.45', tid.F32) == [approx(0.123), approx(56.45)] - assert parse_value('0.123, 56.45, 123', tid.F64) == [approx(0.123), approx(56.45), 123] + assert parse_value("", tid.EMPTY) is None + assert parse_value("Arbitrary", tid.EMPTY) is None + assert parse_value("Arbitrary", tid.STRING) == "Arbitrary" + assert parse_value("\x01\x02\x88\xFF", tid.UNSTRUCTURED) == bytes( + [1, 2, 0x88, 0xFF] + ) + assert parse_value("0", tid.BOOLEAN) == [False] + assert parse_value("True, false", tid.BOOLEAN) == [True, False] + assert parse_value("true, False", tid.I8) == [1, 0] + assert parse_value("0.123, 56.45", tid.F32) == [approx(0.123), approx(56.45)] + assert parse_value("0.123, 56.45, 123", tid.F64) == [ + approx(0.123), + approx(56.45), + 123, + ] @cached @@ -191,11 +209,13 @@ def display_type(register: Register) -> str: TODO: dimensionality. Perhaps dimensionality should be considered in the register's __hash__() and __eq__()? TODO: Alternatively, just make the cache local rather than global. """ - out = str(register.type_id).split('.')[-1].lower() + out = str(register.type_id).split(".")[-1].lower() def get_vector_dimension(value): if value and not isinstance(value, (str, bytes)): - if len(value) != 1: # Length 1 is a scalar, dimensional annotation is redundant + if ( + len(value) != 1 + ): # Length 1 is a scalar, dimensional annotation is redundant return len(value) # Default value is preferred if available because it can't change dimensions at all, whereas the cached value @@ -206,6 +226,6 @@ def get_vector_dimension(value): dimension = get_vector_dimension(register.cached_value) if dimension is not None: - out += f'[{dimension}]' + out += f"[{dimension}]" return out diff --git a/kucher/view/main_window/task_statistics_widget/__init__.py b/kucher/view/main_window/task_statistics_widget/__init__.py index 8f55429..a942f9d 100644 --- a/kucher/view/main_window/task_statistics_widget/__init__.py +++ b/kucher/view/main_window/task_statistics_widget/__init__.py @@ -16,15 +16,28 @@ import asyncio import datetime from logging import getLogger -from PyQt5.QtWidgets import QWidget, QTableView, QHeaderView, QSpinBox, QCheckBox, QLabel, QVBoxLayout, QHBoxLayout +from PyQt5.QtWidgets import ( + QWidget, + QTableView, + QHeaderView, + QSpinBox, + QCheckBox, + QLabel, + QVBoxLayout, + QHBoxLayout, +) from PyQt5.QtWidgets import QAbstractItemView from PyQt5.QtCore import QTimer, Qt, QAbstractTableModel, QModelIndex, QVariant from PyQt5.QtGui import QFontMetrics, QFont from kucher.view.widgets import WidgetBase from kucher.view.utils import gui_test, get_icon_pixmap -from kucher.view.device_model_representation import TaskStatisticsView, TaskID, get_icon_name_for_task_id, \ - get_human_friendly_task_name +from kucher.view.device_model_representation import ( + TaskStatisticsView, + TaskID, + get_icon_name_for_task_id, + get_human_friendly_task_name, +) _DEFAULT_UPDATE_PERIOD = 2 @@ -35,15 +48,21 @@ class TaskStatisticsWidget(WidgetBase): # noinspection PyUnresolvedReferences,PyArgumentList - def __init__(self, - parent: QWidget, - async_update_delegate: typing.Callable[[], typing.Awaitable[typing.Optional[TaskStatisticsView]]]): + def __init__( + self, + parent: QWidget, + async_update_delegate: typing.Callable[ + [], typing.Awaitable[typing.Optional[TaskStatisticsView]] + ], + ): """ :param parent: :param async_update_delegate: Returns TaskStatisticsView if connected, None otherwise """ super(TaskStatisticsWidget, self).__init__(parent) - self.setAttribute(Qt.WA_DeleteOnClose) # This is required to stop background timers! + self.setAttribute( + Qt.WA_DeleteOnClose + ) # This is required to stop background timers! self._model = _TableModel(self) @@ -56,8 +75,10 @@ def launch_update_task(): if task is None or task.done(): task = asyncio.get_event_loop().create_task(self._do_update()) else: - self._display_status('Still updating...') - _logger.warning('Update task not launched because the previous one has not completed yet') + self._display_status("Still updating...") + _logger.warning( + "Update task not launched because the previous one has not completed yet" + ) self._update_timer = QTimer(self) self._update_timer.timeout.connect(launch_update_task) @@ -67,22 +88,25 @@ def launch_update_task(): self._update_interval_selector.setMaximum(10) self._update_interval_selector.setValue(_DEFAULT_UPDATE_PERIOD) self._update_interval_selector.valueChanged.connect( - lambda: self._update_timer.setInterval(self._update_interval_selector.value() * 1000)) + lambda: self._update_timer.setInterval( + self._update_interval_selector.value() * 1000 + ) + ) def on_update_enabler_toggled(): if self._update_enabler.isChecked(): - self._display_status() # Clear status - self._model.clear() # Remove obsolete data from the model (this will trigger view update later) - launch_update_task() # Request update ASAP + self._display_status() # Clear status + self._model.clear() # Remove obsolete data from the model (this will trigger view update later) + launch_update_task() # Request update ASAP self._update_interval_selector.setEnabled(True) self._update_timer.start(self._update_interval_selector.value() * 1000) else: - self._display_status('Disabled') + self._display_status("Disabled") self._table_view.setEnabled(False) self._update_interval_selector.setEnabled(False) self._update_timer.stop() - self._update_enabler = QCheckBox('Update every', self) + self._update_enabler = QCheckBox("Update every", self) self._update_enabler.setChecked(False) self._update_enabler.stateChanged.connect(on_update_enabler_toggled) @@ -98,7 +122,7 @@ def on_update_enabler_toggled(): controls_layout = QHBoxLayout() controls_layout.addWidget(self._update_enabler) controls_layout.addWidget(self._update_interval_selector) - controls_layout.addWidget(QLabel('seconds', self)) + controls_layout.addWidget(QLabel("seconds", self)) controls_layout.addStretch(1) controls_layout.addWidget(self._status_display) @@ -110,7 +134,7 @@ def on_update_enabler_toggled(): self.setMinimumSize(400, 100) def __del__(self): - _logger.debug('Widget deleted') + _logger.debug("Widget deleted") def _display_status(self, text=None): self._status_display.setText(text) @@ -118,18 +142,18 @@ def _display_status(self, text=None): async def _do_update(self): # noinspection PyBroadException try: - self._display_status('Updating...') + self._display_status("Updating...") data = await self._async_update_delegate() if data is not None: self._table_view.setEnabled(True) self._model.set_data(data) - self._display_status('OK') + self._display_status("OK") else: self._table_view.setEnabled(False) - self._display_status('Data not available') + self._display_status("Data not available") except Exception as ex: - _logger.exception('Update failed') - self._display_status(f'Error: {ex}') + _logger.exception("Update failed") + self._display_status(f"Error: {ex}") # noinspection PyArgumentList @@ -142,7 +166,7 @@ def _unittest_task_statistics_widget(): win.resize(800, 600) async def update_delegate() -> TaskStatisticsView: - print('UPDATE') + print("UPDATE") return _make_test_data() widget = TaskStatisticsWidget(win, update_delegate) @@ -165,7 +189,9 @@ def __init__(self, parent, model: QAbstractTableModel): super(_TableView, self).__init__(parent) self.setModel(model) - self.horizontalHeader().setSectionResizeMode(self.horizontalHeader().ResizeToContents) + self.horizontalHeader().setSectionResizeMode( + self.horizontalHeader().ResizeToContents + ) self.horizontalHeader().setStretchLastSection(True) self.verticalHeader().setSectionResizeMode(self.verticalHeader().Stretch) @@ -181,13 +207,13 @@ def __init__(self, parent, model: QAbstractTableModel): # noinspection PyMethodOverriding class _TableModel(QAbstractTableModel): COLUMNS = [ - ('Started', 'When the task was last started'), - ('Stopped', 'When the task was last stopped'), - ('Last RT', 'Last run time'), - ('Total RT', 'Total run time'), - ('Invocations', 'How many times times the task was started'), - ('Failures', 'How many times the task has failed'), - ('Exit code', 'Last exit code'), + ("Started", "When the task was last started"), + ("Stopped", "When the task was last stopped"), + ("Last RT", "Last run time"), + ("Total RT", "Total run time"), + ("Invocations", "How many times times the task was started"), + ("Failures", "How many times the task has failed"), + ("Exit code", "Last exit code"), ] def __init__(self, parent: QWidget): @@ -233,7 +259,10 @@ def headerData(self, section: int, orientation: int, role=None): if role == Qt.FontRole: if orientation == Qt.Vertical: - if list(self._data.entries.keys())[section] == self._get_running_task_id(): + if ( + list(self._data.entries.keys())[section] + == self._get_running_task_id() + ): font = QFont() font.setBold(True) return font @@ -250,7 +279,7 @@ def duration(secs): if secs > 0: return datetime.timedelta(seconds=float(secs)) else: - return '' + return "" if role == Qt.DisplayRole: if column == 0: @@ -277,13 +306,13 @@ def duration(secs): if column == 6: return str(entry.last_exit_code) - raise ValueError(f'Invalid column index: {column}') + raise ValueError(f"Invalid column index: {column}") if role == Qt.TextAlignmentRole: return Qt.AlignCenter if role == Qt.DecorationRole: - pass # Return icons if necessary + pass # Return icons if necessary return QVariant() @@ -308,12 +337,14 @@ def set_data(self, view: TaskStatisticsView): if number_of_columns_changed: self.headerDataChanged.emit(Qt.Horizontal, 0, self.columnCount()) - if number_of_rows_changed or (previous_running_task_id != self._get_running_task_id()): + if number_of_rows_changed or ( + previous_running_task_id != self._get_running_task_id() + ): self.headerDataChanged.emit(Qt.Vertical, 0, self.rowCount()) - self.dataChanged.emit(self.index(0, 0), - self.index(self.rowCount() - 1, - self.columnCount() - 1)) + self.dataChanged.emit( + self.index(0, 0), self.index(self.rowCount() - 1, self.columnCount() - 1) + ) def clear(self): self.set_data(TaskStatisticsView()) @@ -368,57 +399,72 @@ def _make_test_data(): from decimal import Decimal sample = { - 'entries': [ - {'last_exit_code': 194, - 'last_started_at': Decimal('3.017389'), - 'last_stopped_at': Decimal('3.017432'), - 'number_of_times_failed': 2, - 'number_of_times_started': 2, - 'task_id': 'idle', - 'total_run_time': Decimal('0.000062')}, - {'last_exit_code': 0, - 'last_started_at': Decimal('3.017432'), - 'last_stopped_at': Decimal('3.017389'), - 'number_of_times_failed': 0, - 'number_of_times_started': 3, - 'task_id': 'fault', - 'total_run_time': Decimal('27.093250')}, - {'last_exit_code': 0, - 'last_started_at': Decimal('0.000000'), - 'last_stopped_at': Decimal('0.000000'), - 'number_of_times_failed': 0, - 'number_of_times_started': 0, - 'task_id': 'beep', - 'total_run_time': Decimal('0.000000')}, - {'last_exit_code': 0, - 'last_started_at': Decimal('0.000000'), - 'last_stopped_at': Decimal('0.000000'), - 'number_of_times_failed': 0, - 'number_of_times_started': 0, - 'task_id': 'run', - 'total_run_time': Decimal('0.000000')}, - {'last_exit_code': 2, - 'last_started_at': Decimal('0.016381'), - 'last_stopped_at': Decimal('2.025321'), - 'number_of_times_failed': 1, - 'number_of_times_started': 1, - 'task_id': 'hardware_test', - 'total_run_time': Decimal('2.008939')}, - {'last_exit_code': 0, - 'last_started_at': Decimal('0.000000'), - 'last_stopped_at': Decimal('0.000000'), - 'number_of_times_failed': 0, - 'number_of_times_started': 0, - 'task_id': 'motor_identification', - 'total_run_time': Decimal('0.000000')}, - {'last_exit_code': 0, - 'last_started_at': Decimal('0.000000'), - 'last_stopped_at': Decimal('0.000000'), - 'number_of_times_failed': 0, - 'number_of_times_started': 0, - 'task_id': 'low_level_manipulation', - 'total_run_time': Decimal('0.000000')}], - 'timestamp': Decimal('29.114152') + "entries": [ + { + "last_exit_code": 194, + "last_started_at": Decimal("3.017389"), + "last_stopped_at": Decimal("3.017432"), + "number_of_times_failed": 2, + "number_of_times_started": 2, + "task_id": "idle", + "total_run_time": Decimal("0.000062"), + }, + { + "last_exit_code": 0, + "last_started_at": Decimal("3.017432"), + "last_stopped_at": Decimal("3.017389"), + "number_of_times_failed": 0, + "number_of_times_started": 3, + "task_id": "fault", + "total_run_time": Decimal("27.093250"), + }, + { + "last_exit_code": 0, + "last_started_at": Decimal("0.000000"), + "last_stopped_at": Decimal("0.000000"), + "number_of_times_failed": 0, + "number_of_times_started": 0, + "task_id": "beep", + "total_run_time": Decimal("0.000000"), + }, + { + "last_exit_code": 0, + "last_started_at": Decimal("0.000000"), + "last_stopped_at": Decimal("0.000000"), + "number_of_times_failed": 0, + "number_of_times_started": 0, + "task_id": "run", + "total_run_time": Decimal("0.000000"), + }, + { + "last_exit_code": 2, + "last_started_at": Decimal("0.016381"), + "last_stopped_at": Decimal("2.025321"), + "number_of_times_failed": 1, + "number_of_times_started": 1, + "task_id": "hardware_test", + "total_run_time": Decimal("2.008939"), + }, + { + "last_exit_code": 0, + "last_started_at": Decimal("0.000000"), + "last_stopped_at": Decimal("0.000000"), + "number_of_times_failed": 0, + "number_of_times_started": 0, + "task_id": "motor_identification", + "total_run_time": Decimal("0.000000"), + }, + { + "last_exit_code": 0, + "last_started_at": Decimal("0.000000"), + "last_stopped_at": Decimal("0.000000"), + "number_of_times_failed": 0, + "number_of_times_started": 0, + "task_id": "low_level_manipulation", + "total_run_time": Decimal("0.000000"), + }, + ], + "timestamp": Decimal("29.114152"), } return TaskStatisticsView.populate(sample) diff --git a/kucher/view/main_window/telega_control_widget/__init__.py b/kucher/view/main_window/telega_control_widget/__init__.py index 6257db9..e727281 100644 --- a/kucher/view/main_window/telega_control_widget/__init__.py +++ b/kucher/view/main_window/telega_control_widget/__init__.py @@ -30,9 +30,7 @@ class TelegaControlWidget(WidgetBase): - def __init__(self, - parent: QWidget, - commander: Commander): + def __init__(self, parent: QWidget, commander: Commander): super(TelegaControlWidget, self).__init__(parent) self._dc_quantities_widget = DCQuantitiesWidget(self) @@ -80,44 +78,66 @@ def on_connection_loss(self): def on_general_status_update(self, timestamp: float, s: GeneralStatusView): # DC quantities power = s.dc.voltage * s.dc.current - self._dc_quantities_widget.set(_make_monitored_quantity(s.dc.voltage, - s.alert_flags.dc_undervoltage, - s.alert_flags.dc_overvoltage), - _make_monitored_quantity(s.dc.current, - s.alert_flags.dc_undercurrent, - s.alert_flags.dc_overcurrent), - power) + self._dc_quantities_widget.set( + _make_monitored_quantity( + s.dc.voltage, + s.alert_flags.dc_undervoltage, + s.alert_flags.dc_overvoltage, + ), + _make_monitored_quantity( + s.dc.current, + s.alert_flags.dc_undercurrent, + s.alert_flags.dc_overcurrent, + ), + power, + ) # Temperature k2c = s.temperature.convert_kelvin_to_celsius - self._temperature_widget.set(_make_monitored_quantity(k2c(s.temperature.cpu), - s.alert_flags.cpu_cold, - s.alert_flags.cpu_overheating), - _make_monitored_quantity(k2c(s.temperature.vsi), - s.alert_flags.vsi_cold, - s.alert_flags.vsi_overheating), - _make_monitored_quantity(k2c(s.temperature.motor) if s.temperature.motor else None, - s.alert_flags.motor_cold, - s.alert_flags.motor_overheating)) + self._temperature_widget.set( + _make_monitored_quantity( + k2c(s.temperature.cpu), + s.alert_flags.cpu_cold, + s.alert_flags.cpu_overheating, + ), + _make_monitored_quantity( + k2c(s.temperature.vsi), + s.alert_flags.vsi_cold, + s.alert_flags.vsi_overheating, + ), + _make_monitored_quantity( + k2c(s.temperature.motor) if s.temperature.motor else None, + s.alert_flags.motor_cold, + s.alert_flags.motor_overheating, + ), + ) # Hardware flags hfc_fs = HardwareFlagCountersWidget.FlagState # noinspection PyArgumentList self._hardware_flag_counters_widget.set( - lvps_malfunction=hfc_fs(event_count=s.hardware_flag_edge_counters.lvps_malfunction, - active=s.alert_flags.hardware_lvps_malfunction), - overload=hfc_fs(event_count=s.hardware_flag_edge_counters.overload, - active=s.alert_flags.hardware_overload), - fault=hfc_fs(event_count=s.hardware_flag_edge_counters.fault, - active=s.alert_flags.hardware_fault)) + lvps_malfunction=hfc_fs( + event_count=s.hardware_flag_edge_counters.lvps_malfunction, + active=s.alert_flags.hardware_lvps_malfunction, + ), + overload=hfc_fs( + event_count=s.hardware_flag_edge_counters.overload, + active=s.alert_flags.hardware_overload, + ), + fault=hfc_fs( + event_count=s.hardware_flag_edge_counters.fault, + active=s.alert_flags.hardware_fault, + ), + ) # Device status - self._device_status_widget.set(s.current_task_id, - s.timestamp) + self._device_status_widget.set(s.current_task_id, s.timestamp) # VSI status - self._vsi_status_widget.set(1 / s.pwm.period, - s.status_flags.vsi_enabled, - s.status_flags.vsi_modulating, - s.status_flags.phase_current_agc_high_gain_selected) + self._vsi_status_widget.set( + 1 / s.pwm.period, + s.status_flags.vsi_enabled, + s.status_flags.vsi_modulating, + s.status_flags.phase_current_agc_high_gain_selected, + ) # Active alerts self._active_alerts_widget.set(s.alert_flags) @@ -129,9 +149,9 @@ def on_general_status_update(self, timestamp: float, s: GeneralStatusView): self._control_widget.on_general_status_update(timestamp, s) -def _make_monitored_quantity(value: float, - too_low: bool = False, - too_high: bool = False) -> MonitoredQuantity: +def _make_monitored_quantity( + value: float, too_low: bool = False, too_high: bool = False +) -> MonitoredQuantity: mq = MonitoredQuantity(value) if too_low: diff --git a/kucher/view/main_window/telega_control_widget/active_alerts_widget.py b/kucher/view/main_window/telega_control_widget/active_alerts_widget.py index bcfdf31..d2439e9 100644 --- a/kucher/view/main_window/telega_control_widget/active_alerts_widget.py +++ b/kucher/view/main_window/telega_control_widget/active_alerts_widget.py @@ -20,14 +20,14 @@ from kucher.view.utils import gui_test -_ICON_OK = 'ok-strong' -_ICON_ALERT = 'error' +_ICON_OK = "ok-strong" +_ICON_ALERT = "error" class ActiveAlertsWidget(GroupBoxWidget): # noinspection PyArgumentList,PyCallingNonCallable def __init__(self, parent: QWidget): - super(ActiveAlertsWidget, self).__init__(parent, 'Active alerts', _ICON_OK) + super(ActiveAlertsWidget, self).__init__(parent, "Active alerts", _ICON_OK) self._content = QLabel() self._content.setWordWrap(True) @@ -54,15 +54,15 @@ def set(self, flags): attrs = [] for at in dir(flags): - if at.startswith('_'): + if at.startswith("_"): continue val = getattr(flags, at) if not isinstance(val, bool): continue if val: - attrs.append(at.replace('_', ' ')) + attrs.append(at.replace("_", " ")) - text = ', '.join(attrs).upper() + text = ", ".join(attrs).upper() # Changing icons is very expensive if text != str(self._content.text()): @@ -70,7 +70,7 @@ def set(self, flags): self._content.setText(text) self.set_icon(_ICON_ALERT) else: - self._content.setText('') + self._content.setText("") self.set_icon(_ICON_OK) @@ -80,6 +80,7 @@ def _unittest_active_alerts_widget(): from dataclasses import dataclass import time from PyQt5.QtWidgets import QApplication, QMainWindow + app = QApplication([]) win = QMainWindow() @@ -94,8 +95,8 @@ def run_a_bit(): @dataclass class Instance: - flag_a: bool = False - flag_b_too: bool = False + flag_a: bool = False + flag_b_too: bool = False flag_c_as_well: bool = False run_a_bit() diff --git a/kucher/view/main_window/telega_control_widget/control_widget/__init__.py b/kucher/view/main_window/telega_control_widget/control_widget/__init__.py index 7f7954a..9be1e7b 100644 --- a/kucher/view/main_window/telega_control_widget/control_widget/__init__.py +++ b/kucher/view/main_window/telega_control_widget/control_widget/__init__.py @@ -21,7 +21,12 @@ from kucher.view.device_model_representation import Commander, GeneralStatusView from kucher.view.widgets.group_box_widget import GroupBoxWidget -from kucher.view.utils import make_button, lay_out_vertically, lay_out_horizontally, get_icon +from kucher.view.utils import ( + make_button, + lay_out_vertically, + lay_out_horizontally, + get_icon, +) from .base import SpecializedControlWidgetBase from .run_control_widget import RunControlWidget @@ -31,8 +36,8 @@ from .low_level_manipulation_control_widget import LowLevelManipulationControlWidget -STOP_SHORTCUT = 'Esc' -EMERGENCY_SHORTCUT = 'Ctrl+Space' +STOP_SHORTCUT = "Esc" +EMERGENCY_SHORTCUT = "Ctrl+Space" _logger = getLogger(__name__) @@ -40,28 +45,42 @@ class ControlWidget(GroupBoxWidget): # noinspection PyArgumentList,PyUnresolvedReferences - def __init__(self, - parent: QWidget, - commander: Commander): - super(ControlWidget, self).__init__(parent, 'Controls', 'adjust') + def __init__(self, parent: QWidget, commander: Commander): + super(ControlWidget, self).__init__(parent, "Controls", "adjust") self._commander: Commander = commander - self._last_seen_timestamped_general_status: typing.Optional[typing.Tuple[float, GeneralStatusView]] = None + self._last_seen_timestamped_general_status: typing.Optional[ + typing.Tuple[float, GeneralStatusView] + ] = None self._run_widget = RunControlWidget(self, commander) - self._motor_identification_widget = MotorIdentificationControlWidget(self, commander) + self._motor_identification_widget = MotorIdentificationControlWidget( + self, commander + ) self._hardware_test_widget = HardwareTestControlWidget(self, commander) self._misc_widget = MiscControlWidget(self, commander) - self._low_level_manipulation_widget = LowLevelManipulationControlWidget(self, commander) + self._low_level_manipulation_widget = LowLevelManipulationControlWidget( + self, commander + ) self._panel = QTabWidget(self) - self._panel.addTab(self._run_widget, get_icon('running'), 'Run') - self._panel.addTab(self._motor_identification_widget, get_icon('caliper'), 'Motor identification') - self._panel.addTab(self._hardware_test_widget, get_icon('pass-fail'), 'Self-test') - self._panel.addTab(self._misc_widget, get_icon('ellipsis'), 'Miscellaneous') - self._panel.addTab(self._low_level_manipulation_widget, get_icon('hand-button'), 'Low-level manipulation') + self._panel.addTab(self._run_widget, get_icon("running"), "Run") + self._panel.addTab( + self._motor_identification_widget, + get_icon("caliper"), + "Motor identification", + ) + self._panel.addTab( + self._hardware_test_widget, get_icon("pass-fail"), "Self-test" + ) + self._panel.addTab(self._misc_widget, get_icon("ellipsis"), "Miscellaneous") + self._panel.addTab( + self._low_level_manipulation_widget, + get_icon("hand-button"), + "Low-level manipulation", + ) self._current_widget: SpecializedControlWidgetBase = self._hardware_test_widget @@ -69,24 +88,28 @@ def __init__(self, self._panel.setCurrentWidget(self._run_widget) # Shared buttons - self._stop_button =\ - make_button(self, - text='Stop', - icon_name='stop', - tool_tip=f'Sends a regular stop command which instructs the controller to abandon the current' - f'task and activate the Idle task [{STOP_SHORTCUT}]', - on_clicked=self._do_regular_stop) - self._stop_button.setSizePolicy(QSizePolicy().MinimumExpanding, - QSizePolicy().MinimumExpanding) - - self._emergency_button =\ - make_button(self, - text='EMERGENCY\nSHUTDOWN', - tool_tip=f'Unconditionally disables and locks down the VSI until restarted ' - f'[{EMERGENCY_SHORTCUT}]', - on_clicked=self._do_emergency_stop) - self._emergency_button.setSizePolicy(QSizePolicy().MinimumExpanding, - QSizePolicy().MinimumExpanding) + self._stop_button = make_button( + self, + text="Stop", + icon_name="stop", + tool_tip=f"Sends a regular stop command which instructs the controller to abandon the current" + f"task and activate the Idle task [{STOP_SHORTCUT}]", + on_clicked=self._do_regular_stop, + ) + self._stop_button.setSizePolicy( + QSizePolicy().MinimumExpanding, QSizePolicy().MinimumExpanding + ) + + self._emergency_button = make_button( + self, + text="EMERGENCY\nSHUTDOWN", + tool_tip=f"Unconditionally disables and locks down the VSI until restarted " + f"[{EMERGENCY_SHORTCUT}]", + on_clicked=self._do_emergency_stop, + ) + self._emergency_button.setSizePolicy( + QSizePolicy().MinimumExpanding, QSizePolicy().MinimumExpanding + ) small_font = QFont() small_font.setPointSize(round(small_font.pointSize() * 0.8)) self._emergency_button.setFont(small_font) @@ -95,7 +118,9 @@ def __init__(self, self._stop_shortcut = QShortcut(QKeySequence(STOP_SHORTCUT), self.window()) self._stop_shortcut.setAutoRepeat(False) - self._emergency_shortcut = QShortcut(QKeySequence(EMERGENCY_SHORTCUT), self.window()) + self._emergency_shortcut = QShortcut( + QKeySequence(EMERGENCY_SHORTCUT), self.window() + ) self._emergency_shortcut.setAutoRepeat(False) self.setEnabled(False) @@ -114,10 +139,12 @@ def make_tiny_label(text: str, alignment: int) -> QLabel: (self._panel, 1), lay_out_vertically( (self._stop_button, 1), - make_tiny_label(f'\u2191 {STOP_SHORTCUT} \u2191', Qt.AlignTop), - make_tiny_label(f'\u2193 {EMERGENCY_SHORTCUT} \u2193', Qt.AlignBottom), + make_tiny_label(f"\u2191 {STOP_SHORTCUT} \u2191", Qt.AlignTop), + make_tiny_label( + f"\u2193 {EMERGENCY_SHORTCUT} \u2193", Qt.AlignBottom + ), (self._emergency_button, 1), - ) + ), ) ) @@ -141,52 +168,64 @@ def on_general_status_update(self, timestamp: float, s: GeneralStatusView): self._current_widget.on_general_status_update(timestamp, s) def _on_current_widget_changed(self, new_widget_index: int): - _logger.debug(f'The user has changed the active widget. ' - f'Stopping the previous widget, which was {self._current_widget!r}') + _logger.debug( + f"The user has changed the active widget. " + f"Stopping the previous widget, which was {self._current_widget!r}" + ) self._current_widget.stop() self._current_widget = self._panel.currentWidget() assert isinstance(self._current_widget, SpecializedControlWidgetBase) - _logger.debug(f'Starting the new widget (at index {new_widget_index}), which is {self._current_widget!r}') + _logger.debug( + f"Starting the new widget (at index {new_widget_index}), which is {self._current_widget!r}" + ) self._current_widget.start() # We also make sure to always provide the newly activated widget with the latest known general status, # in order to let it actualize its state faster. if self._last_seen_timestamped_general_status is not None: - self._current_widget.on_general_status_update(*self._last_seen_timestamped_general_status) + self._current_widget.on_general_status_update( + *self._last_seen_timestamped_general_status + ) # noinspection PyUnresolvedReferences def _enable(self): self._stop_shortcut.activated.connect(self._do_regular_stop) self._emergency_shortcut.activated.connect(self._do_emergency_stop) self.setEnabled(True) - self._emergency_button.setStyleSheet('''QPushButton { + self._emergency_button.setStyleSheet( + """QPushButton { background-color: #f00; font-weight: 600; color: #300; - }''') + }""" + ) # noinspection PyUnresolvedReferences def _disable(self): self._stop_shortcut.activated.disconnect(self._do_regular_stop) self._emergency_shortcut.activated.disconnect(self._do_emergency_stop) self.setEnabled(False) - self._emergency_button.setStyleSheet('') + self._emergency_button.setStyleSheet("") def _do_regular_stop(self): self._launch(self._commander.stop()) - _logger.info('Stop button clicked (or shortcut activated)') - self.window().statusBar().showMessage('Stop command has been sent. ' - 'The device may choose to disregard it, depending on the current task.') + _logger.info("Stop button clicked (or shortcut activated)") + self.window().statusBar().showMessage( + "Stop command has been sent. " + "The device may choose to disregard it, depending on the current task." + ) self._current_widget.stop() def _do_emergency_stop(self): for _ in range(3): self._launch(self._commander.emergency()) - _logger.warning('Emergency button clicked (or shortcut activated)') - self.window().statusBar().showMessage("DON'T PANIC. The hardware will remain unusable until restarted.") + _logger.warning("Emergency button clicked (or shortcut activated)") + self.window().statusBar().showMessage( + "DON'T PANIC. The hardware will remain unusable until restarted." + ) self._current_widget.stop() diff --git a/kucher/view/main_window/telega_control_widget/control_widget/hardware_test_control_widget.py b/kucher/view/main_window/telega_control_widget/control_widget/hardware_test_control_widget.py index a0aa1cb..b541123 100644 --- a/kucher/view/main_window/telega_control_widget/control_widget/hardware_test_control_widget.py +++ b/kucher/view/main_window/telega_control_widget/control_widget/hardware_test_control_widget.py @@ -21,19 +21,25 @@ class HardwareTestControlWidget(SpecializedControlWidgetBase): - def __init__(self, - parent: QWidget, - commander: Commander): + def __init__(self, parent: QWidget, commander: Commander): super(HardwareTestControlWidget, self).__init__(parent) self._commander = commander self.setLayout( lay_out_vertically( - QLabel('The motor must be connected in order for the self test to succeed.', self), + QLabel( + "The motor must be connected in order for the self test to succeed.", + self, + ), lay_out_horizontally( (None, 1), - make_button(self, text='Run self-test', icon_name='play', on_clicked=self._execute), + make_button( + self, + text="Run self-test", + icon_name="play", + on_clicked=self._execute, + ), (None, 1), ), (None, 1), @@ -44,11 +50,13 @@ def stop(self): self.setEnabled(False) def on_general_status_update(self, timestamp: float, s: GeneralStatusView): - if s.current_task_id in (TaskID.RUN, - TaskID.BEEP, - TaskID.HARDWARE_TEST, - TaskID.LOW_LEVEL_MANIPULATION, - TaskID.MOTOR_IDENTIFICATION): + if s.current_task_id in ( + TaskID.RUN, + TaskID.BEEP, + TaskID.HARDWARE_TEST, + TaskID.LOW_LEVEL_MANIPULATION, + TaskID.MOTOR_IDENTIFICATION, + ): self.setEnabled(False) else: self.setEnabled(True) diff --git a/kucher/view/main_window/telega_control_widget/control_widget/low_level_manipulation_control_widget/__init__.py b/kucher/view/main_window/telega_control_widget/control_widget/low_level_manipulation_control_widget/__init__.py index 78374c5..6da4e5c 100644 --- a/kucher/view/main_window/telega_control_widget/control_widget/low_level_manipulation_control_widget/__init__.py +++ b/kucher/view/main_window/telega_control_widget/control_widget/low_level_manipulation_control_widget/__init__.py @@ -17,7 +17,11 @@ from logging import getLogger from PyQt5.QtWidgets import QWidget, QTabWidget -from kucher.view.device_model_representation import Commander, GeneralStatusView, LowLevelManipulationMode +from kucher.view.device_model_representation import ( + Commander, + GeneralStatusView, + LowLevelManipulationMode, +) from kucher.view.utils import get_icon, lay_out_vertically from ..base import SpecializedControlWidgetBase @@ -29,12 +33,12 @@ class LowLevelManipulationControlWidget(SpecializedControlWidgetBase): # noinspection PyUnresolvedReferences - def __init__(self, - parent: QWidget, - commander: Commander): + def __init__(self, parent: QWidget, commander: Commander): super(LowLevelManipulationControlWidget, self).__init__(parent) - self._last_seen_timestamped_general_status: typing.Optional[typing.Tuple[float, GeneralStatusView]] = None + self._last_seen_timestamped_general_status: typing.Optional[ + typing.Tuple[float, GeneralStatusView] + ] = None self._tabs = QTabWidget(self) @@ -43,7 +47,9 @@ def __init__(self, tab_name, icon_name = widget.get_widget_name_and_icon_name() self._tabs.addTab(widget, get_icon(icon_name), tab_name) - self._current_widget: LowLevelManipulationControlSubWidgetBase = self._tabs.currentWidget() + self._current_widget: LowLevelManipulationControlSubWidgetBase = ( + self._tabs.currentWidget() + ) self._tabs.currentChanged.connect(self._on_current_widget_changed) # Presentation configuration. @@ -68,34 +74,43 @@ def on_general_status_update(self, timestamp: float, s: GeneralStatusView): self._current_widget.on_general_status_update(timestamp, s) def _on_current_widget_changed(self, new_widget_index: int): - _logger.debug(f'The user has changed the active widget. ' - f'Stopping the previous widget, which was {self._current_widget!r}') + _logger.debug( + f"The user has changed the active widget. " + f"Stopping the previous widget, which was {self._current_widget!r}" + ) self._current_widget.stop() self._current_widget = self._tabs.currentWidget() - assert isinstance(self._current_widget, LowLevelManipulationControlSubWidgetBase) + assert isinstance( + self._current_widget, LowLevelManipulationControlSubWidgetBase + ) - _logger.debug(f'Starting the new widget (at index {new_widget_index}), which is {self._current_widget!r}') + _logger.debug( + f"Starting the new widget (at index {new_widget_index}), which is {self._current_widget!r}" + ) self._current_widget.start() # We also make sure to always provide the newly activated widget with the latest known general status, # in order to let it actualize its state faster. if self._last_seen_timestamped_general_status is not None: - self._current_widget.on_general_status_update(*self._last_seen_timestamped_general_status) + self._current_widget.on_general_status_update( + *self._last_seen_timestamped_general_status + ) -_LLM_MODE_TO_WIDGET_TYPE_MAPPING: typing.Dict[LowLevelManipulationMode, - typing.Type[LowLevelManipulationControlSubWidgetBase]] = {} +_LLM_MODE_TO_WIDGET_TYPE_MAPPING: typing.Dict[ + LowLevelManipulationMode, typing.Type[LowLevelManipulationControlSubWidgetBase] +] = {} def _load_widgets(): for llm_mode in LowLevelManipulationMode: module_name = f'{str(llm_mode).split(".")[-1].lower()}_widget' - _logger.info(f'Loading module {module_name} for LLM mode {llm_mode!r}') + _logger.info(f"Loading module {module_name} for LLM mode {llm_mode!r}") try: - module = importlib.import_module('.' + module_name, __name__) + module = importlib.import_module("." + module_name, __name__) except ImportError: - _logger.exception('Module load failed') + _logger.exception("Module load failed") else: assert issubclass(module.Widget, LowLevelManipulationControlSubWidgetBase) _LLM_MODE_TO_WIDGET_TYPE_MAPPING[llm_mode] = module.Widget diff --git a/kucher/view/main_window/telega_control_widget/control_widget/low_level_manipulation_control_widget/calibration_widget.py b/kucher/view/main_window/telega_control_widget/control_widget/low_level_manipulation_control_widget/calibration_widget.py index 7998203..6882fa8 100644 --- a/kucher/view/main_window/telega_control_widget/control_widget/low_level_manipulation_control_widget/calibration_widget.py +++ b/kucher/view/main_window/telega_control_widget/control_widget/low_level_manipulation_control_widget/calibration_widget.py @@ -15,7 +15,12 @@ from logging import getLogger from PyQt5.QtWidgets import QWidget, QLabel -from kucher.model.device_model import Commander, LowLevelManipulationMode, GeneralStatusView, TaskID +from kucher.model.device_model import ( + Commander, + LowLevelManipulationMode, + GeneralStatusView, + TaskID, +) from kucher.view.utils import lay_out_vertically, lay_out_horizontally, make_button from .base import LowLevelManipulationControlSubWidgetBase @@ -25,38 +30,39 @@ class Widget(LowLevelManipulationControlSubWidgetBase): - def __init__(self, - parent: QWidget, - commander: Commander): + def __init__(self, parent: QWidget, commander: Commander): super(Widget, self).__init__(parent) self._commander = commander self.setLayout( lay_out_vertically( lay_out_horizontally( - QLabel('Calibrate the VSI hardware', self), - make_button(self, - text='Calibrate', - icon_name='scales', - on_clicked=self._execute), - (None, 1) + QLabel("Calibrate the VSI hardware", self), + make_button( + self, + text="Calibrate", + icon_name="scales", + on_clicked=self._execute, + ), + (None, 1), ), - (None, 1) + (None, 1), ) ) def get_widget_name_and_icon_name(self): - return 'Calibration', 'scales' + return "Calibration", "scales" def stop(self): pass def on_general_status_update(self, timestamp: float, s: GeneralStatusView): - if s.current_task_id in (TaskID.IDLE, - TaskID.FAULT): + if s.current_task_id in (TaskID.IDLE, TaskID.FAULT): self.setEnabled(True) else: self.setEnabled(False) def _execute(self): - _logger.info('Requesting calibration') - self._launch_async(self._commander.low_level_manipulate(LowLevelManipulationMode.CALIBRATION)) + _logger.info("Requesting calibration") + self._launch_async( + self._commander.low_level_manipulate(LowLevelManipulationMode.CALIBRATION) + ) diff --git a/kucher/view/main_window/telega_control_widget/control_widget/low_level_manipulation_control_widget/phase_manipulation_widget.py b/kucher/view/main_window/telega_control_widget/control_widget/low_level_manipulation_control_widget/phase_manipulation_widget.py index c7d829c..68f1c3d 100644 --- a/kucher/view/main_window/telega_control_widget/control_widget/low_level_manipulation_control_widget/phase_manipulation_widget.py +++ b/kucher/view/main_window/telega_control_widget/control_widget/low_level_manipulation_control_widget/phase_manipulation_widget.py @@ -17,7 +17,12 @@ from PyQt5.QtWidgets import QWidget, QLabel, QCheckBox from PyQt5.QtGui import QFont -from kucher.model.device_model import Commander, LowLevelManipulationMode, GeneralStatusView, TaskID +from kucher.model.device_model import ( + Commander, + LowLevelManipulationMode, + GeneralStatusView, + TaskID, +) from kucher.view.utils import lay_out_horizontally, get_icon, make_button from kucher.view.widgets.spinbox_linked_with_slider import SpinboxLinkedWithSlider @@ -26,7 +31,9 @@ _HUNDRED_PERCENT = 100 -_SEND_BUTTON_STYLESHEET_WHEN_ACTIVATED = 'QPushButton { background-color: #ff0; color: #330; font-weight: 600; }' +_SEND_BUTTON_STYLESHEET_WHEN_ACTIVATED = ( + "QPushButton { background-color: #ff0; color: #330; font-weight: 600; }" +) _logger = getLogger(__name__) @@ -34,42 +41,43 @@ class Widget(LowLevelManipulationControlSubWidgetBase): # noinspection PyUnresolvedReferences - def __init__(self, - parent: QWidget, - commander: Commander): + def __init__(self, parent: QWidget, commander: Commander): super(Widget, self).__init__(parent) self._commander = commander self._event_suppression_depth = 0 self._sync_checkbox = QCheckBox(self) - self._sync_checkbox.setIcon(get_icon('link')) + self._sync_checkbox.setIcon(get_icon("link")) self._sync_checkbox.setChecked(True) self._sync_checkbox.stateChanged.connect(self._on_sync_checkbox_changed) - self._sync_checkbox.setToolTip('Always same value for all phases') + self._sync_checkbox.setToolTip("Always same value for all phases") self._sync_checkbox.setStatusTip(self._sync_checkbox.toolTip()) - self._send_button = \ - make_button(self, - text='Execute', - icon_name='send-up', - tool_tip='Sends the command to the device; also, while this button is checked (pressed), ' - 'commands will be sent automatically every time the controls are changed by the user.', - checkable=True, - checked=False, - on_clicked=self._on_send_button_changed) + self._send_button = make_button( + self, + text="Execute", + icon_name="send-up", + tool_tip="Sends the command to the device; also, while this button is checked (pressed), " + "commands will be sent automatically every time the controls are changed by the user.", + checkable=True, + checked=False, + on_clicked=self._on_send_button_changed, + ) self._phase_controls = [ - SpinboxLinkedWithSlider(self, - minimum=0.0, - maximum=100.0, - step=1.0, - slider_orientation=SpinboxLinkedWithSlider.SliderOrientation.HORIZONTAL) + SpinboxLinkedWithSlider( + self, + minimum=0.0, + maximum=100.0, + step=1.0, + slider_orientation=SpinboxLinkedWithSlider.SliderOrientation.HORIZONTAL, + ) for _ in range(3) ] for pc in self._phase_controls: - pc.spinbox_suffix = ' %' + pc.spinbox_suffix = " %" pc.value_change_event.connect(self._on_any_control_changed) def make_fat_label(text: str) -> QLabel: @@ -82,21 +90,25 @@ def make_fat_label(text: str) -> QLabel: top_layout_items = [] for index, pc in enumerate(self._phase_controls): - top_layout_items.append(lay_out_horizontally( - make_fat_label('ABC'[index]), - pc.spinbox, - (pc.slider, 1), - )) + top_layout_items.append( + lay_out_horizontally( + make_fat_label("ABC"[index]), + pc.spinbox, + (pc.slider, 1), + ) + ) self.setLayout( - lay_out_horizontally(*(top_layout_items + [self._sync_checkbox, self._send_button])) + lay_out_horizontally( + *(top_layout_items + [self._sync_checkbox, self._send_button]) + ) ) def get_widget_name_and_icon_name(self): - return 'Phase manipulation', 'sine' + return "Phase manipulation", "sine" def stop(self): - self.setEnabled(False) # Safety first + self.setEnabled(False) # Safety first # This is not mandatory, but we do it anyway for extra safety. if self._send_button.isChecked(): @@ -111,13 +123,15 @@ def stop(self): # Restore the safest state by default self._sync_checkbox.setChecked(True) self._send_button.setChecked(False) - self._send_button.setStyleSheet('') + self._send_button.setStyleSheet("") def on_general_status_update(self, timestamp: float, s: GeneralStatusView): with self._with_events_suppressed(): - if s.current_task_id in (TaskID.IDLE, - TaskID.FAULT, - TaskID.LOW_LEVEL_MANIPULATION): + if s.current_task_id in ( + TaskID.IDLE, + TaskID.FAULT, + TaskID.LOW_LEVEL_MANIPULATION, + ): self.setEnabled(True) else: self.setEnabled(False) @@ -126,7 +140,7 @@ def _on_any_control_changed(self, new_value: float): if self._event_suppression_depth > 0: return - if not self.isEnabled(): # Safety feature + if not self.isEnabled(): # Safety feature return with self._with_events_suppressed(): @@ -140,20 +154,23 @@ def _on_any_control_changed(self, new_value: float): for v in vector: assert 0.0 <= v < 1.000001 - _logger.debug('Sending phase manipulation command %r', vector) - self._launch_async(self._commander.low_level_manipulate(LowLevelManipulationMode.PHASE_MANIPULATION, - *vector)) + _logger.debug("Sending phase manipulation command %r", vector) + self._launch_async( + self._commander.low_level_manipulate( + LowLevelManipulationMode.PHASE_MANIPULATION, *vector + ) + ) def _on_sync_checkbox_changed(self): if self._sync_checkbox.isChecked(): - self._on_any_control_changed(0.0) # Reset to zero to sync up + self._on_any_control_changed(0.0) # Reset to zero to sync up def _on_send_button_changed(self): if self._send_button.isChecked(): self._on_any_control_changed(self._phase_controls[0].value) self._send_button.setStyleSheet(_SEND_BUTTON_STYLESHEET_WHEN_ACTIVATED) else: - self._send_button.setStyleSheet('') + self._send_button.setStyleSheet("") @contextmanager def _with_events_suppressed(self): diff --git a/kucher/view/main_window/telega_control_widget/control_widget/low_level_manipulation_control_widget/scalar_control_widget.py b/kucher/view/main_window/telega_control_widget/control_widget/low_level_manipulation_control_widget/scalar_control_widget.py index 6f5df37..7bf7105 100644 --- a/kucher/view/main_window/telega_control_widget/control_widget/low_level_manipulation_control_widget/scalar_control_widget.py +++ b/kucher/view/main_window/telega_control_widget/control_widget/low_level_manipulation_control_widget/scalar_control_widget.py @@ -16,14 +16,21 @@ from logging import getLogger from PyQt5.QtWidgets import QWidget -from kucher.model.device_model import Commander, LowLevelManipulationMode, GeneralStatusView, TaskID +from kucher.model.device_model import ( + Commander, + LowLevelManipulationMode, + GeneralStatusView, + TaskID, +) from kucher.view.utils import lay_out_horizontally, make_button from kucher.view.widgets.spinbox_linked_with_slider import SpinboxLinkedWithSlider from .base import LowLevelManipulationControlSubWidgetBase -_SEND_BUTTON_STYLESHEET_WHEN_ACTIVATED = 'QPushButton { background-color: #ff0; color: #330; font-weight: 600; }' +_SEND_BUTTON_STYLESHEET_WHEN_ACTIVATED = ( + "QPushButton { background-color: #ff0; color: #330; font-weight: 600; }" +) _logger = getLogger(__name__) @@ -31,86 +38,102 @@ class Widget(LowLevelManipulationControlSubWidgetBase): # noinspection PyUnresolvedReferences - def __init__(self, - parent: QWidget, - commander: Commander): + def __init__(self, parent: QWidget, commander: Commander): super(Widget, self).__init__(parent) self._commander = commander self._event_suppression_depth = 0 - self._send_button = \ - make_button(self, - text='Execute', - icon_name='send-up', - tool_tip='Sends the command to the device; also, while this button is checked (pressed), ' - 'commands will be sent automatically every time the controls are changed by the user.', - checkable=True, - checked=False, - on_clicked=self._on_send_button_changed) - - self._volt_per_hertz_control =\ - SpinboxLinkedWithSlider(self, - minimum=0.001, - maximum=1.0, - step=0.001, - slider_orientation=SpinboxLinkedWithSlider.SliderOrientation.HORIZONTAL) - self._volt_per_hertz_control.spinbox_suffix = ' V/Hz' + self._send_button = make_button( + self, + text="Execute", + icon_name="send-up", + tool_tip="Sends the command to the device; also, while this button is checked (pressed), " + "commands will be sent automatically every time the controls are changed by the user.", + checkable=True, + checked=False, + on_clicked=self._on_send_button_changed, + ) + + self._volt_per_hertz_control = SpinboxLinkedWithSlider( + self, + minimum=0.001, + maximum=1.0, + step=0.001, + slider_orientation=SpinboxLinkedWithSlider.SliderOrientation.HORIZONTAL, + ) + self._volt_per_hertz_control.spinbox_suffix = " V/Hz" self._volt_per_hertz_control.num_decimals = 3 - self._volt_per_hertz_control.tool_tip = 'Amplitude, volt per hertz' + self._volt_per_hertz_control.tool_tip = "Amplitude, volt per hertz" self._volt_per_hertz_control.status_tip = self._volt_per_hertz_control.tool_tip self._volt_per_hertz_control.slider_visible = False - self._target_frequency_control =\ - SpinboxLinkedWithSlider(self, - minimum=-9999.0, - maximum=9999.0, - step=10.0, - slider_orientation=SpinboxLinkedWithSlider.SliderOrientation.HORIZONTAL) - self._target_frequency_control.spinbox_suffix = ' Hz' - self._target_frequency_control.tool_tip = 'Target frequency, hertz' - self._target_frequency_control.status_tip = self._target_frequency_control.tool_tip - - self._frequency_gradient_control =\ - SpinboxLinkedWithSlider(self, - minimum=0.0, - maximum=999.0, - step=1.0, - slider_orientation=SpinboxLinkedWithSlider.SliderOrientation.HORIZONTAL) - self._frequency_gradient_control.spinbox_suffix = ' Hz/s' - self._frequency_gradient_control.tool_tip = 'Frequency gradient, hertz per second' - self._frequency_gradient_control.status_tip = self._frequency_gradient_control.tool_tip + self._target_frequency_control = SpinboxLinkedWithSlider( + self, + minimum=-9999.0, + maximum=9999.0, + step=10.0, + slider_orientation=SpinboxLinkedWithSlider.SliderOrientation.HORIZONTAL, + ) + self._target_frequency_control.spinbox_suffix = " Hz" + self._target_frequency_control.tool_tip = "Target frequency, hertz" + self._target_frequency_control.status_tip = ( + self._target_frequency_control.tool_tip + ) + + self._frequency_gradient_control = SpinboxLinkedWithSlider( + self, + minimum=0.0, + maximum=999.0, + step=1.0, + slider_orientation=SpinboxLinkedWithSlider.SliderOrientation.HORIZONTAL, + ) + self._frequency_gradient_control.spinbox_suffix = " Hz/s" + self._frequency_gradient_control.tool_tip = ( + "Frequency gradient, hertz per second" + ) + self._frequency_gradient_control.status_tip = ( + self._frequency_gradient_control.tool_tip + ) self._frequency_gradient_control.slider_visible = False # Initial values - self._volt_per_hertz_control.value = 0.01 - self._target_frequency_control.value = 0.0 + self._volt_per_hertz_control.value = 0.01 + self._target_frequency_control.value = 0.0 self._frequency_gradient_control.value = 20.0 # Having configured the values, connecting the events - self._volt_per_hertz_control.value_change_event.connect(self._on_any_control_changed) - self._target_frequency_control.value_change_event.connect(self._on_any_control_changed) - self._frequency_gradient_control.value_change_event.connect(self._on_any_control_changed) + self._volt_per_hertz_control.value_change_event.connect( + self._on_any_control_changed + ) + self._target_frequency_control.value_change_event.connect( + self._on_any_control_changed + ) + self._frequency_gradient_control.value_change_event.connect( + self._on_any_control_changed + ) self.setLayout( lay_out_horizontally( self._volt_per_hertz_control.spinbox, self._frequency_gradient_control.spinbox, self._target_frequency_control.spinbox, - make_button(self, - icon_name='clear-symbol', - tool_tip='Reset the target frequency to zero', - on_clicked=self._on_target_frequency_clear_button_clicked), + make_button( + self, + icon_name="clear-symbol", + tool_tip="Reset the target frequency to zero", + on_clicked=self._on_target_frequency_clear_button_clicked, + ), self._target_frequency_control.slider, self._send_button, ) ) def get_widget_name_and_icon_name(self): - return 'Scalar control', 'frequency-f' + return "Scalar control", "frequency-f" def stop(self): - self.setEnabled(False) # Safety first + self.setEnabled(False) # Safety first # This is not mandatory, but we do it anyway for extra safety. if self._send_button.isChecked(): @@ -118,15 +141,17 @@ def stop(self): # Restore the safest state by default (but don't touch any settings except target frequency) self._send_button.setChecked(False) - self._send_button.setStyleSheet('') + self._send_button.setStyleSheet("") # This will not trigger any reaction from the callback because we're disabled already self._target_frequency_control.value = 0 def on_general_status_update(self, timestamp: float, s: GeneralStatusView): with self._with_events_suppressed(): - if s.current_task_id in (TaskID.IDLE, - TaskID.FAULT, - TaskID.LOW_LEVEL_MANIPULATION): + if s.current_task_id in ( + TaskID.IDLE, + TaskID.FAULT, + TaskID.LOW_LEVEL_MANIPULATION, + ): self.setEnabled(True) else: self.setEnabled(False) @@ -135,26 +160,29 @@ def _on_any_control_changed(self, *_): if self._event_suppression_depth > 0: return - if not self.isEnabled(): # Safety feature + if not self.isEnabled(): # Safety feature return - with self._with_events_suppressed(): # Paranoia + with self._with_events_suppressed(): # Paranoia if self._send_button.isChecked(): vector = [ self._volt_per_hertz_control.value, self._target_frequency_control.value, self._frequency_gradient_control.value, ] - _logger.debug('Sending scalar control command %r', vector) - self._launch_async(self._commander.low_level_manipulate(LowLevelManipulationMode.SCALAR_CONTROL, - *vector)) + _logger.debug("Sending scalar control command %r", vector) + self._launch_async( + self._commander.low_level_manipulate( + LowLevelManipulationMode.SCALAR_CONTROL, *vector + ) + ) def _on_send_button_changed(self): if self._send_button.isChecked(): self._on_any_control_changed() self._send_button.setStyleSheet(_SEND_BUTTON_STYLESHEET_WHEN_ACTIVATED) else: - self._send_button.setStyleSheet('') + self._send_button.setStyleSheet("") def _on_target_frequency_clear_button_clicked(self): self._target_frequency_control.value = 0 diff --git a/kucher/view/main_window/telega_control_widget/control_widget/misc_control_widget.py b/kucher/view/main_window/telega_control_widget/control_widget/misc_control_widget.py index dc55f35..bff7c48 100644 --- a/kucher/view/main_window/telega_control_widget/control_widget/misc_control_widget.py +++ b/kucher/view/main_window/telega_control_widget/control_widget/misc_control_widget.py @@ -19,7 +19,12 @@ from PyQt5.QtWidgets import QWidget, QDoubleSpinBox, QLabel from kucher.view.device_model_representation import Commander, GeneralStatusView, TaskID -from kucher.view.utils import make_button, lay_out_horizontally, lay_out_vertically, get_icon_path +from kucher.view.utils import ( + make_button, + lay_out_horizontally, + lay_out_vertically, + get_icon_path, +) from .base import SpecializedControlWidgetBase @@ -28,9 +33,7 @@ class MiscControlWidget(SpecializedControlWidgetBase): - def __init__(self, - parent: QWidget, - commander: Commander): + def __init__(self, parent: QWidget, commander: Commander): super(MiscControlWidget, self).__init__(parent) self._commander = commander @@ -39,25 +42,33 @@ def __init__(self, self._frequency_input = QDoubleSpinBox(self) self._frequency_input.setRange(100, 15000) self._frequency_input.setValue(3000) - self._frequency_input.setSuffix(' Hz') - self._frequency_input.setToolTip('Beep frequency, in hertz') + self._frequency_input.setSuffix(" Hz") + self._frequency_input.setToolTip("Beep frequency, in hertz") self._frequency_input.setStatusTip(self._frequency_input.toolTip()) self._duration_input = QDoubleSpinBox(self) self._duration_input.setRange(0.01, 3) self._duration_input.setValue(0.5) - self._duration_input.setSuffix(' s') - self._duration_input.setToolTip('Beep duration, in seconds') + self._duration_input.setSuffix(" s") + self._duration_input.setToolTip("Beep duration, in seconds") self._duration_input.setStatusTip(self._duration_input.toolTip()) - self._go_button = make_button(self, - text='Beep', - icon_name='speaker', - tool_tip='Sends a beep command to the device once', - on_clicked=self._beep_once) + self._go_button = make_button( + self, + text="Beep", + icon_name="speaker", + tool_tip="Sends a beep command to the device once", + on_clicked=self._beep_once, + ) - def stealthy(icon_name: str, music_factory: typing.Callable[[], typing.Iterable['_Note']]) -> QWidget: - b = make_button(self, icon_name=icon_name, on_clicked=lambda: self._begin_performance(music_factory())) + def stealthy( + icon_name: str, music_factory: typing.Callable[[], typing.Iterable["_Note"]] + ) -> QWidget: + b = make_button( + self, + icon_name=icon_name, + on_clicked=lambda: self._begin_performance(music_factory()), + ) b.setFlat(True) b.setFixedSize(4, 4) return b @@ -65,9 +76,9 @@ def stealthy(icon_name: str, music_factory: typing.Callable[[], typing.Iterable[ self.setLayout( lay_out_vertically( lay_out_horizontally( - QLabel('Frequency', self), + QLabel("Frequency", self), self._frequency_input, - QLabel('Duration', self), + QLabel("Duration", self), self._duration_input, self._go_button, (None, 1), @@ -75,8 +86,8 @@ def stealthy(icon_name: str, music_factory: typing.Callable[[], typing.Iterable[ (None, 1), lay_out_horizontally( (None, 1), - stealthy('darth-vader', _get_imperial_march), - ) + stealthy("darth-vader", _get_imperial_march), + ), ) ) @@ -84,9 +95,7 @@ def stop(self): self._performer_should_stop = True def on_general_status_update(self, timestamp: float, s: GeneralStatusView): - if s.current_task_id in (TaskID.BEEP, - TaskID.IDLE, - TaskID.FAULT): + if s.current_task_id in (TaskID.BEEP, TaskID.IDLE, TaskID.FAULT): self.setEnabled(True) else: self._performer_should_stop = True @@ -94,19 +103,23 @@ def on_general_status_update(self, timestamp: float, s: GeneralStatusView): def _beep_once(self): self._performer_should_stop = True - self._launch_async(self._commander.beep(frequency=self._frequency_input.value(), - duration=self._duration_input.value())) + self._launch_async( + self._commander.beep( + frequency=self._frequency_input.value(), + duration=self._duration_input.value(), + ) + ) - def _begin_performance(self, composition: typing.Iterable['_Note']): + def _begin_performance(self, composition: typing.Iterable["_Note"]): self._performer_should_stop = False self._launch_async(self._perform(composition)) - async def _perform(self, composition: typing.Iterable['_Note']): + async def _perform(self, composition: typing.Iterable["_Note"]): composition = list(composition) - _logger.info(f'Performer is starting with {len(composition)} notes') + _logger.info(f"Performer is starting with {len(composition)} notes") for note in composition: if self._performer_should_stop: - _logger.info('Performer stopping early because the owner said so') + _logger.info("Performer stopping early because the owner said so") break if note.frequency > 0: @@ -116,41 +129,43 @@ async def _perform(self, composition: typing.Iterable['_Note']): # However, every time a command gets ignored, the device sends back a warning, that may be # annoying for the user. So we don't repeat commands, and instead we inject a small additional # delay after each note to avoid note loss to bad synchronization. - await self._commander.beep(frequency=note.frequency, duration=note.duration) + await self._commander.beep( + frequency=note.frequency, duration=note.duration + ) # The extra delay needed because we're running not on a real-time system await asyncio.sleep(note.duration + 0.07) else: - _logger.info('Performer has finished') + _logger.info("Performer has finished") @dataclass(frozen=True) class _Note: - duration: float # second; this field is required - frequency: float = 0.0 # hertz; zero means pause + duration: float # second; this field is required + frequency: float = 0.0 # hertz; zero means pause class _OneLineOctave: - C = 261.6 - Csharp = 277.2 - D = 293.7 - Dsharp = 311.1 - E = 329.6 - F = 349.2 - Fsharp = 370.0 - G = 392.0 - Gsharp = 415.3 - A = 440.0 - Asharp = 466.2 - B = 493.9 - C1 = 523.2 - D1 = 555.4 - E1 = 587.3 - F1 = 622.2 - G1 = 659.2 - A1 = 698.4 - B1 = 739.9 - C2 = 784.0 + C = 261.6 + Csharp = 277.2 + D = 293.7 + Dsharp = 311.1 + E = 329.6 + F = 349.2 + Fsharp = 370.0 + G = 392.0 + Gsharp = 415.3 + A = 440.0 + Asharp = 466.2 + B = 493.9 + C1 = 523.2 + D1 = 555.4 + E1 = 587.3 + F1 = 622.2 + G1 = 659.2 + A1 = 698.4 + B1 = 739.9 + C2 = 784.0 # noinspection PyArgumentList diff --git a/kucher/view/main_window/telega_control_widget/control_widget/motor_identification_control_widget.py b/kucher/view/main_window/telega_control_widget/control_widget/motor_identification_control_widget.py index 46569b1..b5d32e7 100644 --- a/kucher/view/main_window/telega_control_widget/control_widget/motor_identification_control_widget.py +++ b/kucher/view/main_window/telega_control_widget/control_widget/motor_identification_control_widget.py @@ -14,7 +14,12 @@ from PyQt5.QtWidgets import QWidget, QPushButton, QComboBox, QLabel -from kucher.view.device_model_representation import Commander, GeneralStatusView, TaskID, MotorIdentificationMode +from kucher.view.device_model_representation import ( + Commander, + GeneralStatusView, + TaskID, + MotorIdentificationMode, +) from kucher.view.utils import get_icon, lay_out_vertically, lay_out_horizontally from .base import SpecializedControlWidgetBase @@ -22,41 +27,46 @@ class MotorIdentificationControlWidget(SpecializedControlWidgetBase): # noinspection PyUnresolvedReferences - def __init__(self, - parent: QWidget, - commander: Commander): + def __init__(self, parent: QWidget, commander: Commander): super(MotorIdentificationControlWidget, self).__init__(parent) self._commander = commander - self._mode_map = { - _humanize(mid): mid for mid in MotorIdentificationMode - } + self._mode_map = {_humanize(mid): mid for mid in MotorIdentificationMode} self._mode_selector = QComboBox(self) self._mode_selector.setEditable(False) # noinspection PyTypeChecker - self._mode_selector.addItems(map(_humanize, - sorted(MotorIdentificationMode, - key=lambda x: x != MotorIdentificationMode.R_L_PHI))) + self._mode_selector.addItems( + map( + _humanize, + sorted( + MotorIdentificationMode, + key=lambda x: x != MotorIdentificationMode.R_L_PHI, + ), + ) + ) - go_button = QPushButton(get_icon('play'), 'Launch', self) + go_button = QPushButton(get_icon("play"), "Launch", self) go_button.clicked.connect(self._execute) self.setLayout( lay_out_vertically( lay_out_horizontally( - QLabel('Select parameters to estimate:', self), + QLabel("Select parameters to estimate:", self), self._mode_selector, (None, 1), ), lay_out_horizontally( - QLabel('Then click', self), + QLabel("Then click", self), go_button, - QLabel('and wait. The process will take a few minutes to complete.', self), + QLabel( + "and wait. The process will take a few minutes to complete.", + self, + ), (None, 1), ), - (None, 1) + (None, 1), ) ) @@ -64,11 +74,13 @@ def stop(self): self.setEnabled(False) def on_general_status_update(self, timestamp: float, s: GeneralStatusView): - if s.current_task_id in (TaskID.RUN, - TaskID.BEEP, - TaskID.HARDWARE_TEST, - TaskID.LOW_LEVEL_MANIPULATION, - TaskID.MOTOR_IDENTIFICATION): + if s.current_task_id in ( + TaskID.RUN, + TaskID.BEEP, + TaskID.HARDWARE_TEST, + TaskID.LOW_LEVEL_MANIPULATION, + TaskID.MOTOR_IDENTIFICATION, + ): self.setEnabled(False) else: self.setEnabled(True) @@ -82,9 +94,9 @@ def _execute(self): def _humanize(mid: MotorIdentificationMode) -> str: try: return { - MotorIdentificationMode.R_L: 'Resistance, Inductance', - MotorIdentificationMode.PHI: 'Flux linkage', - MotorIdentificationMode.R_L_PHI: 'Resistance, Inductance, Flux linkage', + MotorIdentificationMode.R_L: "Resistance, Inductance", + MotorIdentificationMode.PHI: "Flux linkage", + MotorIdentificationMode.R_L_PHI: "Resistance, Inductance, Flux linkage", }[mid] except KeyError: - return str(mid).upper().split('.')[-1].replace('_', ' ') + return str(mid).upper().split(".")[-1].replace("_", " ") diff --git a/kucher/view/main_window/telega_control_widget/control_widget/run_control_widget.py b/kucher/view/main_window/telega_control_widget/control_widget/run_control_widget.py index 2a0da6d..9ae1138 100644 --- a/kucher/view/main_window/telega_control_widget/control_widget/run_control_widget.py +++ b/kucher/view/main_window/telega_control_widget/control_widget/run_control_widget.py @@ -18,8 +18,14 @@ from logging import getLogger from PyQt5.QtWidgets import QWidget, QCheckBox, QComboBox, QLabel -from kucher.view.device_model_representation import Commander, GeneralStatusView, ControlMode, TaskID, \ - TaskSpecificStatusReport, get_human_friendly_control_mode_name_and_its_icon_name +from kucher.view.device_model_representation import ( + Commander, + GeneralStatusView, + ControlMode, + TaskID, + TaskSpecificStatusReport, + get_human_friendly_control_mode_name_and_its_icon_name, +) from kucher.view.utils import get_icon, lay_out_vertically, lay_out_horizontally from kucher.view.widgets.spinbox_linked_with_slider import SpinboxLinkedWithSlider @@ -31,9 +37,7 @@ class RunControlWidget(SpecializedControlWidgetBase): # noinspection PyUnresolvedReferences,PyArgumentList - def __init__(self, - parent: QWidget, - commander: Commander): + def __init__(self, parent: QWidget, commander: Commander): from . import STOP_SHORTCUT super(RunControlWidget, self).__init__(parent) @@ -43,26 +47,35 @@ def __init__(self, self._last_status: typing.Optional[TaskSpecificStatusReport.Run] = None # noinspection PyTypeChecker - self._named_control_policies: typing.Dict[str, _ControlPolicy] = _make_named_control_policies() - - self._guru_mode_checkbox = QCheckBox('Guru', self) - self._guru_mode_checkbox.setToolTip("The Guru Mode is dangerous! " - "Use it only if you know what you're doing, and be ready for problems.") + self._named_control_policies: typing.Dict[ + str, _ControlPolicy + ] = _make_named_control_policies() + + self._guru_mode_checkbox = QCheckBox("Guru", self) + self._guru_mode_checkbox.setToolTip( + "The Guru Mode is dangerous! " + "Use it only if you know what you're doing, and be ready for problems." + ) self._guru_mode_checkbox.setStatusTip(self._guru_mode_checkbox.toolTip()) - self._guru_mode_checkbox.setIcon(get_icon('guru')) + self._guru_mode_checkbox.setIcon(get_icon("guru")) self._guru_mode_checkbox.toggled.connect(self._on_guru_mode_toggled) - self._setpoint_control =\ - SpinboxLinkedWithSlider(self, - slider_orientation=SpinboxLinkedWithSlider.SliderOrientation.HORIZONTAL) - self._setpoint_control.tool_tip = f'To stop the motor, press {STOP_SHORTCUT} or click the Stop button' + self._setpoint_control = SpinboxLinkedWithSlider( + self, + slider_orientation=SpinboxLinkedWithSlider.SliderOrientation.HORIZONTAL, + ) + self._setpoint_control.tool_tip = ( + f"To stop the motor, press {STOP_SHORTCUT} or click the Stop button" + ) self._setpoint_control.status_tip = self._setpoint_control.tool_tip self._setpoint_control.value_change_event.connect(self._on_setpoint_changed) self._setpoint_control.spinbox.setKeyboardTracking(False) self._mode_selector = QComboBox(self) self._mode_selector.setEditable(False) - self._mode_selector.currentIndexChanged.connect(lambda *_: self._on_control_mode_changed()) + self._mode_selector.currentIndexChanged.connect( + lambda *_: self._on_control_mode_changed() + ) for name, cp in self._named_control_policies.items(): if not cp.only_for_guru: self._mode_selector.addItem(get_icon(cp.icon_name), name) @@ -72,16 +85,16 @@ def __init__(self, self.setLayout( lay_out_vertically( lay_out_horizontally( - QLabel('Control mode', self), + QLabel("Control mode", self), self._mode_selector, (None, 1), - QLabel('Setpoint', self), + QLabel("Setpoint", self), (self._setpoint_control.spinbox, 1), (None, 1), self._guru_mode_checkbox, ), self._setpoint_control.slider, - (None, 1) + (None, 1), ) ) @@ -104,7 +117,9 @@ def on_general_status_update(self, timestamp: float, s: GeneralStatusView): self.setEnabled(False) self._setpoint_control.value = 0 - self._mode_selector.setEnabled((s.current_task_id == TaskID.IDLE) or self._guru_mode_checkbox.isChecked()) + self._mode_selector.setEnabled( + (s.current_task_id == TaskID.IDLE) or self._guru_mode_checkbox.isChecked() + ) if self.isEnabled() and (abs(self._setpoint_control.value) > 1e-6): # We do not emit zero setpoints periodically - that is not necessary because the device will @@ -117,7 +132,7 @@ def _emit_setpoint(self): cp = self._get_current_control_policy() value = self._setpoint_control.value if cp.is_ratiometric: - value *= 0.01 # Percent scaling + value *= 0.01 # Percent scaling self._launch_async(self._commander.run(mode=cp.mode, value=value)) @@ -127,7 +142,7 @@ def _on_setpoint_changed(self, _value: float): def _on_control_mode_changed(self): cp = self._get_current_control_policy() - _logger.info(f'New control mode: {cp}') + _logger.info(f"New control mode: {cp}") # Determining the initial value from the last received status if self._last_status is not None: @@ -136,21 +151,27 @@ def _on_control_mode_changed(self): else: initial_value = 0.0 - _logger.info(f'Initial value of the new control mode: {initial_value} {cp.unit}') + _logger.info( + f"Initial value of the new control mode: {initial_value} {cp.unit}" + ) assert cp.setpoint_range[1] > cp.setpoint_range[0] assert cp.setpoint_range[1] >= 0 assert cp.setpoint_range[0] <= 0 - self._setpoint_control.update_atomically(minimum=cp.setpoint_range[0], - maximum=cp.setpoint_range[1], - step=cp.setpoint_step, - value=initial_value) - self._setpoint_control.spinbox.setSuffix(f' {cp.unit}') + self._setpoint_control.update_atomically( + minimum=cp.setpoint_range[0], + maximum=cp.setpoint_range[1], + step=cp.setpoint_step, + value=initial_value, + ) + self._setpoint_control.spinbox.setSuffix(f" {cp.unit}") self._setpoint_control.slider_visible = cp.is_ratiometric def _on_guru_mode_toggled(self, active: bool): - _logger.warning(f'GURU MODE TOGGLED. New state: {"ACTIVE" if active else "inactive"}') + _logger.warning( + f'GURU MODE TOGGLED. New state: {"ACTIVE" if active else "inactive"}' + ) if self._get_current_control_policy().only_for_guru: self.stop() @@ -173,18 +194,20 @@ def _on_guru_mode_toggled(self, active: bool): # Updating the mode selector state - always enabled in guru mode self._mode_selector.setEnabled(active) - def _get_current_control_policy(self) -> '_ControlPolicy': + def _get_current_control_policy(self) -> "_ControlPolicy": return self._named_control_policies[self._mode_selector.currentText().strip()] @dataclass(frozen=True) class _ControlPolicy: - mode: ControlMode - unit: str - setpoint_range: typing.Tuple[float, float] - setpoint_step: float - only_for_guru: bool - get_value_from_status: typing.Callable[[TaskSpecificStatusReport.Run], float] = lambda _: 0.0 + mode: ControlMode + unit: str + setpoint_range: typing.Tuple[float, float] + setpoint_step: float + only_for_guru: bool + get_value_from_status: typing.Callable[ + [TaskSpecificStatusReport.Run], float + ] = lambda _: 0.0 @property def icon_name(self) -> str: @@ -196,7 +219,7 @@ def name(self) -> str: @property def is_ratiometric(self) -> bool: - return 'ratiometric' in str(self.mode).lower() + return "ratiometric" in str(self.mode).lower() def _make_named_control_policies(): @@ -211,46 +234,56 @@ def get_i_q(s: TaskSpecificStatusReport.Run) -> float: def get_u_q(s: TaskSpecificStatusReport.Run) -> float: return s.u_dq[1] - return {cp.name: cp for cp in [ - _ControlPolicy(mode=ControlMode.RATIOMETRIC_ANGULAR_VELOCITY, - unit='%rad/s', - setpoint_range=percent_range, - setpoint_step=0.1, - only_for_guru=False), - - _ControlPolicy(mode=ControlMode.MECHANICAL_RPM, - unit='RPM', - setpoint_range=(-999999, +999999), # 1e6 takes too much space - setpoint_step=10.0, - only_for_guru=False, - get_value_from_status=get_rpm), - - _ControlPolicy(mode=ControlMode.RATIOMETRIC_CURRENT, - unit='%A', - setpoint_range=percent_range, - setpoint_step=0.1, - only_for_guru=False), - - _ControlPolicy(mode=ControlMode.CURRENT, - unit='A', - setpoint_range=(-1e3, +1e3), - setpoint_step=0.1, - only_for_guru=False, - get_value_from_status=get_i_q), - - _ControlPolicy(mode=ControlMode.RATIOMETRIC_VOLTAGE, - unit='%V', - setpoint_range=percent_range, - setpoint_step=0.1, - only_for_guru=True), - - _ControlPolicy(mode=ControlMode.VOLTAGE, - unit='V', - setpoint_range=(-1e3, +1e3), - setpoint_step=0.1, - only_for_guru=True, - get_value_from_status=get_u_q), - ]} + return { + cp.name: cp + for cp in [ + _ControlPolicy( + mode=ControlMode.RATIOMETRIC_ANGULAR_VELOCITY, + unit="%rad/s", + setpoint_range=percent_range, + setpoint_step=0.1, + only_for_guru=False, + ), + _ControlPolicy( + mode=ControlMode.MECHANICAL_RPM, + unit="RPM", + setpoint_range=(-999999, +999999), # 1e6 takes too much space + setpoint_step=10.0, + only_for_guru=False, + get_value_from_status=get_rpm, + ), + _ControlPolicy( + mode=ControlMode.RATIOMETRIC_CURRENT, + unit="%A", + setpoint_range=percent_range, + setpoint_step=0.1, + only_for_guru=False, + ), + _ControlPolicy( + mode=ControlMode.CURRENT, + unit="A", + setpoint_range=(-1e3, +1e3), + setpoint_step=0.1, + only_for_guru=False, + get_value_from_status=get_i_q, + ), + _ControlPolicy( + mode=ControlMode.RATIOMETRIC_VOLTAGE, + unit="%V", + setpoint_range=percent_range, + setpoint_step=0.1, + only_for_guru=True, + ), + _ControlPolicy( + mode=ControlMode.VOLTAGE, + unit="V", + setpoint_range=(-1e3, +1e3), + setpoint_step=0.1, + only_for_guru=True, + get_value_from_status=get_u_q, + ), + ] + } def _angular_velocity_to_rpm(radian_per_sec: float) -> float: diff --git a/kucher/view/main_window/telega_control_widget/dc_quantities_widget.py b/kucher/view/main_window/telega_control_widget/dc_quantities_widget.py index e5d72a2..73b0cc7 100644 --- a/kucher/view/main_window/telega_control_widget/dc_quantities_widget.py +++ b/kucher/view/main_window/telega_control_widget/dc_quantities_widget.py @@ -16,49 +16,55 @@ from kucher.view.utils import gui_test from kucher.view.monitored_quantity import MonitoredQuantity, MonitoredQuantityPresenter -from kucher.view.widgets.value_display_group_widget import ValueDisplayGroupWidget, ValueDisplayWidget +from kucher.view.widgets.value_display_group_widget import ( + ValueDisplayGroupWidget, + ValueDisplayWidget, +) class DCQuantitiesWidget(ValueDisplayGroupWidget): # noinspection PyArgumentList,PyCallingNonCallable def __init__(self, parent: QWidget): - super(DCQuantitiesWidget, self).__init__(parent, - 'DC quantities', - 'electricity', - with_comments=True) + super(DCQuantitiesWidget, self).__init__( + parent, "DC quantities", "electricity", with_comments=True + ) dp = MonitoredQuantityPresenter.DisplayParameters style = ValueDisplayWidget.Style - placeholder = 'N/A' - - self._voltage = MonitoredQuantityPresenter(self.create_value_display('Voltage', placeholder), - '%.1f V', - params_default=dp(comment='OK', - icon_name='ok'), - params_when_low=dp(comment='Undervoltage', - icon_name='overload-negative', - style=style.ALERT_LOW), - params_when_high=dp(comment='Overvoltage', - icon_name='overload', - style=style.ALERT_HIGH)) - - self._current = MonitoredQuantityPresenter(self.create_value_display('Current', placeholder), - '%.1f A', - params_default=dp(comment='OK', - icon_name='ok'), - params_when_low=dp(comment='Recuperation overcurrent', - icon_name='overload-negative', - style=style.ALERT_LOW), - params_when_high=dp(comment='Overcurrent', - icon_name='overload', - style=style.ALERT_HIGH)) - - self._power = MonitoredQuantityPresenter(self.create_value_display('Power', placeholder), - '%.0f W') - - def set(self, - voltage: MonitoredQuantity, - current: MonitoredQuantity, - power: float): + placeholder = "N/A" + + self._voltage = MonitoredQuantityPresenter( + self.create_value_display("Voltage", placeholder), + "%.1f V", + params_default=dp(comment="OK", icon_name="ok"), + params_when_low=dp( + comment="Undervoltage", + icon_name="overload-negative", + style=style.ALERT_LOW, + ), + params_when_high=dp( + comment="Overvoltage", icon_name="overload", style=style.ALERT_HIGH + ), + ) + + self._current = MonitoredQuantityPresenter( + self.create_value_display("Current", placeholder), + "%.1f A", + params_default=dp(comment="OK", icon_name="ok"), + params_when_low=dp( + comment="Recuperation overcurrent", + icon_name="overload-negative", + style=style.ALERT_LOW, + ), + params_when_high=dp( + comment="Overcurrent", icon_name="overload", style=style.ALERT_HIGH + ), + ) + + self._power = MonitoredQuantityPresenter( + self.create_value_display("Power", placeholder), "%.0f W" + ) + + def set(self, voltage: MonitoredQuantity, current: MonitoredQuantity, power: float): self._voltage.display(voltage) self._current.display(current) self._power.display(power) @@ -69,6 +75,7 @@ def set(self, def _unittest_dc_quantities_widget(): import time from PyQt5.QtWidgets import QApplication, QMainWindow + app = QApplication([]) win = QMainWindow() @@ -89,9 +96,13 @@ def do_set(volt, amp): run_a_bit() do_set(12.34, 56.78) run_a_bit() - do_set(MonitoredQuantity(123, alert.TOO_HIGH), MonitoredQuantity(-56.78, alert.TOO_LOW)) + do_set( + MonitoredQuantity(123, alert.TOO_HIGH), MonitoredQuantity(-56.78, alert.TOO_LOW) + ) run_a_bit() - do_set(MonitoredQuantity(9, alert.TOO_LOW), MonitoredQuantity(156.78, alert.TOO_HIGH)) + do_set( + MonitoredQuantity(9, alert.TOO_LOW), MonitoredQuantity(156.78, alert.TOO_HIGH) + ) run_a_bit() win.close() diff --git a/kucher/view/main_window/telega_control_widget/device_status_widget.py b/kucher/view/main_window/telega_control_widget/device_status_widget.py index 87737e0..a55109c 100644 --- a/kucher/view/main_window/telega_control_widget/device_status_widget.py +++ b/kucher/view/main_window/telega_control_widget/device_status_widget.py @@ -19,27 +19,38 @@ from PyQt5.QtWidgets import QWidget from kucher.view.widgets.value_display_group_widget import ValueDisplayGroupWidget -from kucher.view.device_model_representation import TaskID, get_icon_name_for_task_id, get_human_friendly_task_name +from kucher.view.device_model_representation import ( + TaskID, + get_icon_name_for_task_id, + get_human_friendly_task_name, +) class DeviceStatusWidget(ValueDisplayGroupWidget): def __init__(self, parent: QWidget): - super(DeviceStatusWidget, self).__init__(parent, 'Device status', 'question-mark') - self.setToolTip('Use the Task Statistics view for more information') - - self._task_display = self.create_value_display('Current task', 'N/A') - - self._monotonic_time_display = self.create_value_display('Mono clock', 'N/A', - 'Steady monotonic clock measuring time since boot') - - self.create_value_display('') # Reserved/placeholder, needed for better alignment - self.create_value_display('') # Reserved/placeholder, needed for better alignment - - def set(self, - current_task_id: TaskID, - monotonic_device_time: Decimal): - self._task_display.set(get_human_friendly_task_name(current_task_id, short=True)) - self._task_display.setToolTip(str(current_task_id).split('.')[-1]) + super(DeviceStatusWidget, self).__init__( + parent, "Device status", "question-mark" + ) + self.setToolTip("Use the Task Statistics view for more information") + + self._task_display = self.create_value_display("Current task", "N/A") + + self._monotonic_time_display = self.create_value_display( + "Mono clock", "N/A", "Steady monotonic clock measuring time since boot" + ) + + self.create_value_display( + "" + ) # Reserved/placeholder, needed for better alignment + self.create_value_display( + "" + ) # Reserved/placeholder, needed for better alignment + + def set(self, current_task_id: TaskID, monotonic_device_time: Decimal): + self._task_display.set( + get_human_friendly_task_name(current_task_id, short=True) + ) + self._task_display.setToolTip(str(current_task_id).split(".")[-1]) self._task_display.setStatusTip(self._task_display.toolTip()) self._monotonic_time_display.set(_duration_to_string(monotonic_device_time)) @@ -48,7 +59,7 @@ def set(self, def reset(self): super(DeviceStatusWidget, self).reset() - self.set_icon('question-mark') + self.set_icon("question-mark") def _duration_to_string(dur: typing.Union[Decimal, float]) -> str: @@ -58,11 +69,15 @@ def _duration_to_string(dur: typing.Union[Decimal, float]) -> str: else: dur = int(round(dur, 0)) - return str(datetime.timedelta(seconds=float(dur))).replace(' days,', 'd').replace(' day,', 'd') + return ( + str(datetime.timedelta(seconds=float(dur))) + .replace(" days,", "d") + .replace(" day,", "d") + ) def _unittest_duration_to_string(): - assert _duration_to_string(0) == '0:00:00' - assert _duration_to_string(3600) == '1:00:00' - assert _duration_to_string(86400) == '1d 0:00:00' - assert _duration_to_string(2 * 86400 + 3600 + 60 + 7.123) == '2d 1:01:07' + assert _duration_to_string(0) == "0:00:00" + assert _duration_to_string(3600) == "1:00:00" + assert _duration_to_string(86400) == "1d 0:00:00" + assert _duration_to_string(2 * 86400 + 3600 + 60 + 7.123) == "2d 1:01:07" diff --git a/kucher/view/main_window/telega_control_widget/hardware_flag_counters_widget.py b/kucher/view/main_window/telega_control_widget/hardware_flag_counters_widget.py index c19a360..187e2b1 100644 --- a/kucher/view/main_window/telega_control_widget/hardware_flag_counters_widget.py +++ b/kucher/view/main_window/telega_control_widget/hardware_flag_counters_widget.py @@ -15,41 +15,42 @@ from dataclasses import dataclass from PyQt5.QtWidgets import QWidget -from kucher.view.widgets.value_display_group_widget import ValueDisplayGroupWidget, ValueDisplayWidget +from kucher.view.widgets.value_display_group_widget import ( + ValueDisplayGroupWidget, + ValueDisplayWidget, +) class HardwareFlagCountersWidget(ValueDisplayGroupWidget): @dataclass class FlagState: - event_count: int = 0 - active: bool = False + event_count: int = 0 + active: bool = False # noinspection PyArgumentList,PyCallingNonCallable def __init__(self, parent: QWidget): - super(HardwareFlagCountersWidget, self).__init__(parent, - 'HW flag cnt.', - 'integrated-circuit', - with_comments=True) - self.setToolTip('Hardware flag counters - how many times each flag was seen') + super(HardwareFlagCountersWidget, self).__init__( + parent, "HW flag cnt.", "integrated-circuit", with_comments=True + ) + self.setToolTip("Hardware flag counters - how many times each flag was seen") self.setStatusTip(self.toolTip()) - placeholder = '0' + placeholder = "0" - self._lvps_malfunction = self.create_value_display('LVPS mlf.', - placeholder, - tooltip='Low-voltage power supply malfunction') + self._lvps_malfunction = self.create_value_display( + "LVPS mlf.", placeholder, tooltip="Low-voltage power supply malfunction" + ) - self._overload = self.create_value_display('Overload', - placeholder, - tooltip='Critical hardware overload or critical overheating') + self._overload = self.create_value_display( + "Overload", + placeholder, + tooltip="Critical hardware overload or critical overheating", + ) - self._fault = self.create_value_display('Fault', - placeholder, - tooltip='Hardware fault flag') + self._fault = self.create_value_display( + "Fault", placeholder, tooltip="Hardware fault flag" + ) - def set(self, - lvps_malfunction: FlagState, - overload: FlagState, - fault: FlagState): + def set(self, lvps_malfunction: FlagState, overload: FlagState, fault: FlagState): self._display(lvps_malfunction, self._lvps_malfunction) self._display(overload, self._overload) self._display(fault, self._fault) @@ -58,14 +59,13 @@ def set(self, def _display(state, display_target: ValueDisplayWidget): if state.active: style = ValueDisplayWidget.Style.ALERT_ERROR - comment = 'Flag is set!' - icon_name = 'flag-red' + comment = "Flag is set!" + icon_name = "flag-red" else: style = ValueDisplayWidget.Style.NORMAL - comment = 'Flag is not set, OK' - icon_name = 'ok' + comment = "Flag is not set, OK" + icon_name = "ok" - display_target.set(str(state.event_count), - style=style, - comment=comment, - icon_name=icon_name) + display_target.set( + str(state.event_count), style=style, comment=comment, icon_name=icon_name + ) diff --git a/kucher/view/main_window/telega_control_widget/task_specific_status_widget/__init__.py b/kucher/view/main_window/telega_control_widget/task_specific_status_widget/__init__.py index c2a3033..0d3f75e 100644 --- a/kucher/view/main_window/telega_control_widget/task_specific_status_widget/__init__.py +++ b/kucher/view/main_window/telega_control_widget/task_specific_status_widget/__init__.py @@ -17,16 +17,20 @@ from logging import getLogger from PyQt5.QtWidgets import QWidget, QStackedLayout -from kucher.view.device_model_representation import GeneralStatusView, TaskID, get_icon_name_for_task_id, \ - get_human_friendly_task_name +from kucher.view.device_model_representation import ( + GeneralStatusView, + TaskID, + get_icon_name_for_task_id, + get_human_friendly_task_name, +) from kucher.view.widgets.group_box_widget import GroupBoxWidget from .placeholder_widget import PlaceholderWidget from .base import StatusWidgetBase -_DEFAULT_ICON = 'question-mark' -_DEFAULT_TITLE = 'Task-specific status information' +_DEFAULT_ICON = "question-mark" +_DEFAULT_TITLE = "Task-specific status information" _logger = getLogger(__name__) @@ -35,7 +39,9 @@ class TaskSpecificStatusWidget(GroupBoxWidget): # noinspection PyArgumentList def __init__(self, parent: QWidget): - super(TaskSpecificStatusWidget, self).__init__(parent, _DEFAULT_TITLE, _DEFAULT_ICON) + super(TaskSpecificStatusWidget, self).__init__( + parent, _DEFAULT_TITLE, _DEFAULT_ICON + ) self._placeholder_widget = PlaceholderWidget(self) @@ -52,7 +58,7 @@ def __init__(self, parent: QWidget): self._task_id_to_widget_mapping[tid] = w self._layout.addWidget(w) - _logger.info('Task ID to widget mapping: %r', self._task_id_to_widget_mapping) + _logger.info("Task ID to widget mapping: %r", self._task_id_to_widget_mapping) self._layout.setCurrentWidget(self._placeholder_widget) self.setLayout(self._layout) @@ -64,12 +70,16 @@ def on_general_status_update(self, timestamp: float, s: GeneralStatusView): try: w.on_general_status_update(timestamp, s) except Exception: - _logger.exception(f'Task-specific widget update failed; ' - f'task ID {s.current_task_id!r}, widget {type(w)!r}') + _logger.exception( + f"Task-specific widget update failed; " + f"task ID {s.current_task_id!r}, widget {type(w)!r}" + ) self.set_icon(get_icon_name_for_task_id(s.current_task_id)) - title = f'{get_human_friendly_task_name(s.current_task_id)} task status information' + title = ( + f"{get_human_friendly_task_name(s.current_task_id)} task status information" + ) if title != self.title(): self.setTitle(title) @@ -84,7 +94,9 @@ def _ensure_widget_active(self, new_widget: StatusWidgetBase): try: self._layout.currentWidget().reset() except Exception: - _logger.exception(f'Task-specific widget reset failed; widget {type(new_widget)!r}') + _logger.exception( + f"Task-specific widget reset failed; widget {type(new_widget)!r}" + ) self._layout.setCurrentWidget(new_widget) @@ -97,15 +109,17 @@ def _load_widgets(): for tid in TaskID: module_name = f'{str(tid).split(".")[-1].lower()}_status_widget' - _logger.info(f'Loading module {module_name} for task ID {tid!r}') + _logger.info(f"Loading module {module_name} for task ID {tid!r}") try: - module = importlib.import_module('.' + module_name, __name__) + module = importlib.import_module("." + module_name, __name__) except ImportError: - _logger.info(f'Module is not defined - no task-specific status info for task {tid!r} is available') + _logger.info( + f"Module is not defined - no task-specific status info for task {tid!r} is available" + ) else: assert issubclass(module.Widget, StatusWidgetBase) _TASK_ID_TO_WIDGET_TYPE_MAPPING[tid] = module.Widget - _logger.info(f'Module {module} loaded successfully') + _logger.info(f"Module {module} loaded successfully") _load_widgets() diff --git a/kucher/view/main_window/telega_control_widget/task_specific_status_widget/base.py b/kucher/view/main_window/telega_control_widget/task_specific_status_widget/base.py index 4d3bb1c..c13e987 100644 --- a/kucher/view/main_window/telega_control_widget/task_specific_status_widget/base.py +++ b/kucher/view/main_window/telega_control_widget/task_specific_status_widget/base.py @@ -18,7 +18,7 @@ from kucher.view.device_model_representation import GeneralStatusView -_TSSRType = typing.TypeVar('TSSR') +_TSSRType = typing.TypeVar("TSSR") class StatusWidgetBase(QWidget): @@ -33,9 +33,13 @@ def on_general_status_update(self, timestamp: float, s: GeneralStatusView): raise NotImplementedError @staticmethod - def _get_task_specific_status_report(expected_type: typing.Type[_TSSRType], s: GeneralStatusView) -> _TSSRType: + def _get_task_specific_status_report( + expected_type: typing.Type[_TSSRType], s: GeneralStatusView + ) -> _TSSRType: if isinstance(s.task_specific_status_report, expected_type): return s.task_specific_status_report else: - raise TypeError(f'task_specific_status_report was expected to be {expected_type!r}, ' - f'got {type(s.task_specific_status_report)!r}') + raise TypeError( + f"task_specific_status_report was expected to be {expected_type!r}, " + f"got {type(s.task_specific_status_report)!r}" + ) diff --git a/kucher/view/main_window/telega_control_widget/task_specific_status_widget/fault_status_widget.py b/kucher/view/main_window/telega_control_widget/task_specific_status_widget/fault_status_widget.py index fd658a6..c412ae8 100644 --- a/kucher/view/main_window/telega_control_widget/task_specific_status_widget/fault_status_widget.py +++ b/kucher/view/main_window/telega_control_widget/task_specific_status_widget/fault_status_widget.py @@ -19,9 +19,18 @@ from PyQt5.QtGui import QFont, QFontMetrics from PyQt5.QtCore import Qt -from kucher.view.utils import lay_out_horizontally, lay_out_vertically, get_monospace_font, get_icon_pixmap -from kucher.view.device_model_representation import GeneralStatusView, TaskSpecificStatusReport, \ - get_icon_name_for_task_id, get_human_friendly_task_name +from kucher.view.utils import ( + lay_out_horizontally, + lay_out_vertically, + get_monospace_font, + get_icon_pixmap, +) +from kucher.view.device_model_representation import ( + GeneralStatusView, + TaskSpecificStatusReport, + get_icon_name_for_task_id, + get_human_friendly_task_name, +) from kucher.resources import get_absolute_path from .base import StatusWidgetBase @@ -39,23 +48,31 @@ def __init__(self, parent: QWidget): self._task_icon_display = QLabel(self) self._task_name_display = self._make_line_display() - self._error_code_dec = self._make_line_display('Exit code in decimal') - self._error_code_hex = self._make_line_display('Same exit code in hexadecimal') - self._error_code_bin = self._make_line_display('Same exit code in binary, for extra convenience') + self._error_code_dec = self._make_line_display("Exit code in decimal") + self._error_code_hex = self._make_line_display("Same exit code in hexadecimal") + self._error_code_bin = self._make_line_display( + "Same exit code in binary, for extra convenience" + ) - self._error_description_display = self._make_line_display('Error description', False) + self._error_description_display = self._make_line_display( + "Error description", False + ) self._error_comment_display = self._make_text_display() self.setLayout( lay_out_vertically( - lay_out_horizontally(QLabel('The task', self), - self._task_icon_display, - (self._task_name_display, 3)), - lay_out_horizontally(QLabel('has failed with exit code', self), - (self._error_code_dec, 1), - (self._error_code_hex, 1), - (self._error_code_bin, 2), - QLabel('which means:', self)), + lay_out_horizontally( + QLabel("The task", self), + self._task_icon_display, + (self._task_name_display, 3), + ), + lay_out_horizontally( + QLabel("has failed with exit code", self), + (self._error_code_dec, 1), + (self._error_code_hex, 1), + (self._error_code_bin, 2), + QLabel("which means:", self), + ), lay_out_horizontally((self._error_description_display, 1)), lay_out_horizontally((self._error_comment_display, 1)), (None, 1), @@ -66,7 +83,7 @@ def reset(self): self._last_displayed = None self._task_icon_display.clear() self._task_name_display.clear() - self._task_name_display.setToolTip('') + self._task_name_display.setToolTip("") self._error_code_dec.clear() self._error_code_hex.clear() self._error_code_bin.clear() @@ -79,31 +96,47 @@ def on_general_status_update(self, timestamp: float, s: GeneralStatusView): self._last_displayed = tssr - pixmap = get_icon_pixmap(get_icon_name_for_task_id(tssr.failed_task_id), self._line_height) + pixmap = get_icon_pixmap( + get_icon_name_for_task_id(tssr.failed_task_id), self._line_height + ) self._task_icon_display.setPixmap(pixmap) - self._task_name_display.setText(str(tssr.failed_task_id).split('.')[-1]) - self._task_name_display.setToolTip(get_human_friendly_task_name(tssr.failed_task_id)) - - self._error_code_dec.setText(f'{tssr.failed_task_exit_code}') - self._error_code_hex.setText(f'0x{tssr.failed_task_exit_code:02X}') - self._error_code_bin.setText(f'0b{tssr.failed_task_exit_code:08b}') + self._task_name_display.setText(str(tssr.failed_task_id).split(".")[-1]) + self._task_name_display.setToolTip( + get_human_friendly_task_name(tssr.failed_task_id) + ) - file_name = get_absolute_path('view', 'main_window', 'telega_control_widget', 'task_specific_status_widget', - f'error_codes.yml', check_existence=True) - with open(file_name, 'r') as f: + self._error_code_dec.setText(f"{tssr.failed_task_exit_code}") + self._error_code_hex.setText(f"0x{tssr.failed_task_exit_code:02X}") + self._error_code_bin.setText(f"0b{tssr.failed_task_exit_code:08b}") + + file_name = get_absolute_path( + "view", + "main_window", + "telega_control_widget", + "task_specific_status_widget", + f"error_codes.yml", + check_existence=True, + ) + with open(file_name, "r") as f: error_codes = yaml.safe_load(f) - failed_task_name = str(tssr.failed_task_id).split('.')[-1] + failed_task_name = str(tssr.failed_task_id).split(".")[-1] - error = error_codes[failed_task_name].get(tssr.failed_task_exit_code, 'unknown error') - error_description = error.get('description', 'unknown error') if isinstance(error, dict) else error - error_comment = error.get('comment', '') if isinstance(error, dict) else '' + error = error_codes[failed_task_name].get( + tssr.failed_task_exit_code, "unknown error" + ) + error_description = ( + error.get("description", "unknown error") + if isinstance(error, dict) + else error + ) + error_comment = error.get("comment", "") if isinstance(error, dict) else "" self._error_description_display.setText(error_description) self._error_comment_display.setText(error_comment) - def _make_line_display(self, tool_tip: str = '', is_monospace: bool = True): + def _make_line_display(self, tool_tip: str = "", is_monospace: bool = True): o = QLineEdit(self) o.setReadOnly(True) if is_monospace: @@ -112,7 +145,7 @@ def _make_line_display(self, tool_tip: str = '', is_monospace: bool = True): o.setToolTip(tool_tip) return o - def _make_text_display(self, tool_tip: str = ''): + def _make_text_display(self, tool_tip: str = ""): o = QTextEdit(self) o.setReadOnly(True) o.setLineWrapMode(True) diff --git a/kucher/view/main_window/telega_control_widget/task_specific_status_widget/hardware_test_status_widget.py b/kucher/view/main_window/telega_control_widget/task_specific_status_widget/hardware_test_status_widget.py index f167a8c..9509d4f 100644 --- a/kucher/view/main_window/telega_control_widget/task_specific_status_widget/hardware_test_status_widget.py +++ b/kucher/view/main_window/telega_control_widget/task_specific_status_widget/hardware_test_status_widget.py @@ -14,7 +14,10 @@ from PyQt5.QtWidgets import QWidget, QProgressBar -from kucher.view.device_model_representation import GeneralStatusView, TaskSpecificStatusReport +from kucher.view.device_model_representation import ( + GeneralStatusView, + TaskSpecificStatusReport, +) from kucher.view.utils import lay_out_vertically from .base import StatusWidgetBase @@ -29,13 +32,13 @@ def __init__(self, parent: QWidget): self._progress_bar.setMinimum(0) self._progress_bar.setMaximum(100) - self.setLayout( - lay_out_vertically(self._progress_bar) - ) + self.setLayout(lay_out_vertically(self._progress_bar)) def reset(self): self._progress_bar.setValue(0) def on_general_status_update(self, timestamp: float, s: GeneralStatusView): - tssr = self._get_task_specific_status_report(TaskSpecificStatusReport.HardwareTest, s) + tssr = self._get_task_specific_status_report( + TaskSpecificStatusReport.HardwareTest, s + ) self._progress_bar.setValue(round(tssr.progress * 100)) diff --git a/kucher/view/main_window/telega_control_widget/task_specific_status_widget/motor_identification_status_widget.py b/kucher/view/main_window/telega_control_widget/task_specific_status_widget/motor_identification_status_widget.py index 6c9afd8..ac3b3c7 100644 --- a/kucher/view/main_window/telega_control_widget/task_specific_status_widget/motor_identification_status_widget.py +++ b/kucher/view/main_window/telega_control_widget/task_specific_status_widget/motor_identification_status_widget.py @@ -14,7 +14,10 @@ from PyQt5.QtWidgets import QWidget, QProgressBar -from kucher.view.device_model_representation import GeneralStatusView, TaskSpecificStatusReport +from kucher.view.device_model_representation import ( + GeneralStatusView, + TaskSpecificStatusReport, +) from kucher.view.utils import lay_out_vertically from .base import StatusWidgetBase @@ -29,13 +32,13 @@ def __init__(self, parent: QWidget): self._progress_bar.setMinimum(0) self._progress_bar.setMaximum(100) - self.setLayout( - lay_out_vertically(self._progress_bar) - ) + self.setLayout(lay_out_vertically(self._progress_bar)) def reset(self): self._progress_bar.setValue(0) def on_general_status_update(self, timestamp: float, s: GeneralStatusView): - tssr = self._get_task_specific_status_report(TaskSpecificStatusReport.MotorIdentification, s) + tssr = self._get_task_specific_status_report( + TaskSpecificStatusReport.MotorIdentification, s + ) self._progress_bar.setValue(round(tssr.progress * 100)) diff --git a/kucher/view/main_window/telega_control_widget/task_specific_status_widget/placeholder_widget.py b/kucher/view/main_window/telega_control_widget/task_specific_status_widget/placeholder_widget.py index 495ab3a..a33b10b 100644 --- a/kucher/view/main_window/telega_control_widget/task_specific_status_widget/placeholder_widget.py +++ b/kucher/view/main_window/telega_control_widget/task_specific_status_widget/placeholder_widget.py @@ -27,7 +27,7 @@ def __init__(self, parent: QWidget): super(PlaceholderWidget, self).__init__(parent) label = QLabel(self) - label.setText('Task-specific status information is not available') + label.setText("Task-specific status information is not available") label.setWordWrap(True) label.setAlignment(Qt.AlignCenter) diff --git a/kucher/view/main_window/telega_control_widget/task_specific_status_widget/run_status_widget.py b/kucher/view/main_window/telega_control_widget/task_specific_status_widget/run_status_widget.py index 58f7494..515c977 100644 --- a/kucher/view/main_window/telega_control_widget/task_specific_status_widget/run_status_widget.py +++ b/kucher/view/main_window/telega_control_widget/task_specific_status_widget/run_status_widget.py @@ -20,8 +20,11 @@ from kucher.view.utils import lay_out_vertically, lay_out_horizontally from kucher.view.widgets.value_display_widget import ValueDisplayWidget -from kucher.view.device_model_representation import GeneralStatusView, TaskSpecificStatusReport,\ - get_human_friendly_control_mode_name_and_its_icon_name +from kucher.view.device_model_representation import ( + GeneralStatusView, + TaskSpecificStatusReport, + get_human_friendly_control_mode_name_and_its_icon_name, +) from .base import StatusWidgetBase @@ -31,94 +34,107 @@ class Widget(StatusWidgetBase): def __init__(self, parent: QWidget): super(Widget, self).__init__(parent) - self._energy_conversion_efficiency_estimate = None # Used for filtering before displaying + self._energy_conversion_efficiency_estimate = ( + None # Used for filtering before displaying + ) - self._stall_count_display =\ - self._make_display('Stalls', - 'Number of times the rotor stalled since task activation') + self._stall_count_display = self._make_display( + "Stalls", "Number of times the rotor stalled since task activation" + ) - self._estimated_active_power_display =\ - self._make_display('Pactive', - 'For well-balanced systems, the estimated active power equals the DC power') + self._estimated_active_power_display = self._make_display( + "Pactive", + "For well-balanced systems, the estimated active power equals the DC power", + ) - self._demand_factor_display = \ - self._make_display('Demand', - 'Total powertrain demand factor') + self._demand_factor_display = self._make_display( + "Demand", "Total powertrain demand factor" + ) - self._mechanical_rpm_display = \ - self._make_display('\u03C9mechanical', - 'Mechanical revolutions per minute') + self._mechanical_rpm_display = self._make_display( + "\u03C9mechanical", "Mechanical revolutions per minute" + ) - self._current_frequency_display = \ - self._make_display('felectrical', - 'Frequency of three-phase currents and voltages') + self._current_frequency_display = self._make_display( + "felectrical", "Frequency of three-phase currents and voltages" + ) self._dq_display = _DQDisplayWidget(self) - self._torque_display =\ - self._make_display('\u03C4', - 'Estimated torque at the shaft') + self._torque_display = self._make_display( + "\u03C4", "Estimated torque at the shaft" + ) - self._mechanical_power_display =\ - self._make_display('Pmechanical', - 'Estimated mechanical power delivered to the shaft') + self._mechanical_power_display = self._make_display( + "Pmechanical", + "Estimated mechanical power delivered to the shaft", + ) - self._loss_power_display =\ - self._make_display('Ploss', - 'Estimated power loss, DC power input to motor shaft') + self._loss_power_display = self._make_display( + "Ploss", "Estimated power loss, DC power input to motor shaft" + ) - self._energy_conversion_efficiency_display =\ - self._make_display('\u03B7DC-S', - 'Estimated energy conversion efficiency, DC power input to motor shaft') + self._energy_conversion_efficiency_display = self._make_display( + "\u03B7DC-S", + "Estimated energy conversion efficiency, DC power input to motor shaft", + ) - self._control_mode_display = \ - self._make_display('Ctrl. mode', - 'Control mode used by the controller', - True) + self._control_mode_display = self._make_display( + "Ctrl. mode", "Control mode used by the controller", True + ) - self._reverse_flag_display = \ - self._make_display('Direction', - 'Direction of rotation', - True) + self._reverse_flag_display = self._make_display( + "Direction", "Direction of rotation", True + ) - self._spinup_flag_display = \ - self._make_display('Started?', - 'Whether the motor has started or still starting', - True) + self._spinup_flag_display = self._make_display( + "Started?", "Whether the motor has started or still starting", True + ) - self._saturation_flag_display = \ - self._make_display('CSSW', - 'Control System Saturation Warning', - True) + self._saturation_flag_display = self._make_display( + "CSSW", "Control System Saturation Warning", True + ) self.setLayout( lay_out_horizontally( - (lay_out_vertically( - self._mechanical_rpm_display, - self._current_frequency_display, - self._stall_count_display, - self._demand_factor_display, - ), 4), + ( + lay_out_vertically( + self._mechanical_rpm_display, + self._current_frequency_display, + self._stall_count_display, + self._demand_factor_display, + ), + 4, + ), _make_vertical_separator(self), - (lay_out_vertically( - self._dq_display, - self._estimated_active_power_display, - (None, 1), - ), 4), + ( + lay_out_vertically( + self._dq_display, + self._estimated_active_power_display, + (None, 1), + ), + 4, + ), _make_vertical_separator(self), - (lay_out_vertically( - self._torque_display, - self._mechanical_power_display, - self._loss_power_display, - self._energy_conversion_efficiency_display, - ), 3), + ( + lay_out_vertically( + self._torque_display, + self._mechanical_power_display, + self._loss_power_display, + self._energy_conversion_efficiency_display, + ), + 3, + ), _make_vertical_separator(self), - (lay_out_vertically( - self._control_mode_display, - self._reverse_flag_display, - self._spinup_flag_display, - self._saturation_flag_display, - ), 4), + ( + lay_out_vertically( + self._control_mode_display, + self._reverse_flag_display, + self._spinup_flag_display, + self._saturation_flag_display, + ), + 4, + ), ) ) @@ -128,7 +144,9 @@ def reset(self): num_reset += 1 ch.reset() - assert num_reset > 7 # Simple paranoid check that PyQt is working as I expect it to + assert ( + num_reset > 7 + ) # Simple paranoid check that PyQt is working as I expect it to self._dq_display.reset() @@ -137,19 +155,21 @@ def reset(self): def on_general_status_update(self, timestamp: float, s: GeneralStatusView): tssr = self._get_task_specific_status_report(TaskSpecificStatusReport.Run, s) - self._stall_count_display.set(f'{tssr.stall_count}') + self._stall_count_display.set(f"{tssr.stall_count}") - self._demand_factor_display.set(f'{tssr.demand_factor * 100.0:.0f}%') + self._demand_factor_display.set(f"{tssr.demand_factor * 100.0:.0f}%") self._mechanical_rpm_display.set( - f'{_angular_velocity_to_rpm(tssr.mechanical_angular_velocity):.0f} RPM') + f"{_angular_velocity_to_rpm(tssr.mechanical_angular_velocity):.0f} RPM" + ) self._current_frequency_display.set( - f'{_angular_velocity_to_frequency(tssr.electrical_angular_velocity):.1f} Hz') + f"{_angular_velocity_to_frequency(tssr.electrical_angular_velocity):.1f} Hz" + ) self._display_estimated_active_power(tssr) - self._torque_display.set(f'{tssr.torque:.2f} N m') + self._torque_display.set(f"{tssr.torque:.2f} N m") try: mechanical_power = tssr.torque * tssr.mechanical_angular_velocity @@ -158,43 +178,54 @@ def on_general_status_update(self, timestamp: float, s: GeneralStatusView): eta = mechanical_power / electrical_power if self._energy_conversion_efficiency_estimate is not None: - self._energy_conversion_efficiency_estimate += \ - (eta - self._energy_conversion_efficiency_estimate) * 0.2 + self._energy_conversion_efficiency_estimate += ( + eta - self._energy_conversion_efficiency_estimate + ) * 0.2 else: self._energy_conversion_efficiency_estimate = min(1.0, max(0.5, eta)) # Sanity check. The current revision of the firmware tends to report nonsensical torque estimates. # Until that is fixed, this workaround is going to stay here. if 0.7 < eta < 0.95: - self._mechanical_power_display.set(f'{mechanical_power:.0f} W') + self._mechanical_power_display.set(f"{mechanical_power:.0f} W") self._energy_conversion_efficiency_display.set( - f'{(100.0 * self._energy_conversion_efficiency_estimate):.0f}%') - self._loss_power_display.set(f'{loss_power:.0f} W') + f"{(100.0 * self._energy_conversion_efficiency_estimate):.0f}%" + ) + self._loss_power_display.set(f"{loss_power:.0f} W") else: - self._mechanical_power_display.set('N/A') - self._energy_conversion_efficiency_display.set('N/A') - self._loss_power_display.set('N/A') + self._mechanical_power_display.set("N/A") + self._energy_conversion_efficiency_display.set("N/A") + self._loss_power_display.set("N/A") except ZeroDivisionError: - self._mechanical_power_display.set('N/A') - self._energy_conversion_efficiency_display.set('N/A') - self._loss_power_display.set('N/A') + self._mechanical_power_display.set("N/A") + self._energy_conversion_efficiency_display.set("N/A") + self._loss_power_display.set("N/A") - self._dq_display.set(tssr.u_dq, - tssr.i_dq) + self._dq_display.set(tssr.u_dq, tssr.i_dq) - cm_name, cm_icon_name = get_human_friendly_control_mode_name_and_its_icon_name(tssr.mode, short=True) - self._control_mode_display.set(cm_name, - comment=str(tssr.mode).split('.')[-1], - icon_name=cm_icon_name) + cm_name, cm_icon_name = get_human_friendly_control_mode_name_and_its_icon_name( + tssr.mode, short=True + ) + self._control_mode_display.set( + cm_name, comment=str(tssr.mode).split(".")[-1], icon_name=cm_icon_name + ) - self._reverse_flag_display.set('Reverse' if tssr.rotation_reversed else 'Forward', - icon_name='jog-reverse' if tssr.rotation_reversed else 'jog-forward') + self._reverse_flag_display.set( + "Reverse" if tssr.rotation_reversed else "Forward", + icon_name="jog-reverse" if tssr.rotation_reversed else "jog-forward", + ) - self._spinup_flag_display.set('Starting' if tssr.spinup_in_progress else 'Started', - icon_name='warning' if tssr.spinup_in_progress else 'ok-strong') + self._spinup_flag_display.set( + "Starting" if tssr.spinup_in_progress else "Started", + icon_name="warning" if tssr.spinup_in_progress else "ok-strong", + ) - self._saturation_flag_display.set('Saturated' if tssr.controller_saturated else 'Normal', - icon_name='control-saturation' if tssr.controller_saturated else 'ok-strong') + self._saturation_flag_display.set( + "Saturated" if tssr.controller_saturated else "Normal", + icon_name="control-saturation" + if tssr.controller_saturated + else "ok-strong", + ) def _display_estimated_active_power(self, tssr: TaskSpecificStatusReport.Run): # We consider this logic part of the view rather than model because it essentially @@ -202,13 +233,14 @@ def _display_estimated_active_power(self, tssr: TaskSpecificStatusReport.Run): (u_d, u_q), (i_d, i_q) = tssr.u_dq, tssr.i_dq active_power = (u_d * i_d + u_q * i_q) * 3 / 2 - self._estimated_active_power_display.set(f'{active_power:.0f} W') + self._estimated_active_power_display.set(f"{active_power:.0f} W") - def _make_display(self, title: str, tooltip: str, with_comment: bool = False) -> ValueDisplayWidget: - return ValueDisplayWidget(self, - title=title, - with_comment=with_comment, - tooltip=tooltip) + def _make_display( + self, title: str, tooltip: str, with_comment: bool = False + ) -> ValueDisplayWidget: + return ValueDisplayWidget( + self, title=title, with_comment=with_comment, tooltip=tooltip + ) class _DQDisplayWidget(QWidget): @@ -216,7 +248,7 @@ class _DQDisplayWidget(QWidget): def __init__(self, parent: QWidget): super(_DQDisplayWidget, self).__init__(parent) - def make_label(text: str = '') -> QLabel: + def make_label(text: str = "") -> QLabel: w = QLabel(text, self) w.setAlignment(Qt.AlignVCenter | Qt.AlignRight) font = QFont() @@ -229,23 +261,23 @@ def make_label(text: str = '') -> QLabel: self._id = make_label() self._iq = make_label() - self._ud.setToolTip('Direct axis voltage') - self._uq.setToolTip('Quadrature axis voltage') - self._id.setToolTip('Direct axis current') - self._iq.setToolTip('Quadrature axis current') + self._ud.setToolTip("Direct axis voltage") + self._uq.setToolTip("Quadrature axis voltage") + self._id.setToolTip("Direct axis current") + self._iq.setToolTip("Quadrature axis current") # 0 1 2 # 1 Ud Uq # 2 Id Iq layout = QGridLayout(self) - layout.addWidget(QLabel('UDQ', self), 0, 0) - layout.addWidget(QLabel('IDQ', self), 1, 0) + layout.addWidget(QLabel("UDQ", self), 0, 0) + layout.addWidget(QLabel("IDQ", self), 1, 0) layout.addWidget(self._ud, 0, 1) layout.addWidget(self._uq, 0, 2) layout.addWidget(self._id, 1, 1) layout.addWidget(self._iq, 1, 2) - layout.addWidget(make_label('V'), 0, 3) - layout.addWidget(make_label('A'), 1, 3) + layout.addWidget(make_label("V"), 0, 3) + layout.addWidget(make_label("A"), 1, 3) layout.setColumnStretch(0, 4) layout.setColumnStretch(1, 2) layout.setColumnStretch(2, 2) @@ -253,11 +285,9 @@ def make_label(text: str = '') -> QLabel: layout.setContentsMargins(0, 0, 0, 0) self.setLayout(layout) - def set(self, - u_dq: typing.Tuple[float, float], - i_dq: typing.Tuple[float, float]): + def set(self, u_dq: typing.Tuple[float, float], i_dq: typing.Tuple[float, float]): def fmt(x: float) -> str: - return f'{x:.1f}' + return f"{x:.1f}" self._ud.setText(fmt(u_dq[0])) self._uq.setText(fmt(u_dq[1])) @@ -266,7 +296,7 @@ def fmt(x: float) -> str: def reset(self): for w in (self._ud, self._uq, self._id, self._iq): - w.setText('0') + w.setText("0") _2PI = math.pi * 2 @@ -285,6 +315,6 @@ def _make_vertical_separator(parent: QWidget) -> QWidget: # https://stackoverflow.com/questions/10053839/how-does-designer-create-a-line-widget line = QFrame(parent) line.setFrameShape(QFrame.VLine) - line.setStyleSheet('QFrame { color: palette(mid); };') - line.setMinimumWidth(QFontMetrics(QFont()).width('__') * 2) + line.setStyleSheet("QFrame { color: palette(mid); };") + line.setMinimumWidth(QFontMetrics(QFont()).width("__") * 2) return line diff --git a/kucher/view/main_window/telega_control_widget/temperature_widget.py b/kucher/view/main_window/telega_control_widget/temperature_widget.py index 3639797..1efa220 100644 --- a/kucher/view/main_window/telega_control_widget/temperature_widget.py +++ b/kucher/view/main_window/telega_control_widget/temperature_widget.py @@ -16,53 +16,55 @@ from kucher.view.utils import gui_test from kucher.view.monitored_quantity import MonitoredQuantity, MonitoredQuantityPresenter -from kucher.view.widgets.value_display_group_widget import ValueDisplayGroupWidget, ValueDisplayWidget +from kucher.view.widgets.value_display_group_widget import ( + ValueDisplayGroupWidget, + ValueDisplayWidget, +) class TemperatureWidget(ValueDisplayGroupWidget): # noinspection PyArgumentList,PyCallingNonCallable def __init__(self, parent: QWidget): - super(TemperatureWidget, self).__init__(parent, - 'Temperature', - 'thermometer', - with_comments=True) + super(TemperatureWidget, self).__init__( + parent, "Temperature", "thermometer", with_comments=True + ) dp = MonitoredQuantityPresenter.DisplayParameters style = ValueDisplayWidget.Style - placeholder = 'N/A' - - default = dp(comment='OK', - icon_name='ok') - - when_low = dp(comment='Cold', - icon_name='cold', - style=style.ALERT_LOW) - - when_high = dp(comment='Overheat', - icon_name='fire', - style=style.ALERT_HIGH) - - self._cpu = MonitoredQuantityPresenter(self.create_value_display('CPU', placeholder), - '%.0f \u00B0C', - params_default=default, - params_when_low=when_low, - params_when_high=when_high) - - self._vsi = MonitoredQuantityPresenter(self.create_value_display('VSI', placeholder), - '%.0f \u00B0C', - params_default=default, - params_when_low=when_low, - params_when_high=when_high) - - self._motor = MonitoredQuantityPresenter(self.create_value_display('Motor', placeholder), - '%.0f \u00B0C', - params_default=default, - params_when_low=when_low, - params_when_high=when_high) - - def set(self, - cpu: MonitoredQuantity, - vsi: MonitoredQuantity, - motor: MonitoredQuantity): + placeholder = "N/A" + + default = dp(comment="OK", icon_name="ok") + + when_low = dp(comment="Cold", icon_name="cold", style=style.ALERT_LOW) + + when_high = dp(comment="Overheat", icon_name="fire", style=style.ALERT_HIGH) + + self._cpu = MonitoredQuantityPresenter( + self.create_value_display("CPU", placeholder), + "%.0f \u00B0C", + params_default=default, + params_when_low=when_low, + params_when_high=when_high, + ) + + self._vsi = MonitoredQuantityPresenter( + self.create_value_display("VSI", placeholder), + "%.0f \u00B0C", + params_default=default, + params_when_low=when_low, + params_when_high=when_high, + ) + + self._motor = MonitoredQuantityPresenter( + self.create_value_display("Motor", placeholder), + "%.0f \u00B0C", + params_default=default, + params_when_low=when_low, + params_when_high=when_high, + ) + + def set( + self, cpu: MonitoredQuantity, vsi: MonitoredQuantity, motor: MonitoredQuantity + ): self._cpu.display(cpu) self._vsi.display(vsi) self._motor.display(motor) @@ -73,6 +75,7 @@ def set(self, def _unittest_temperature_widget(): import time from PyQt5.QtWidgets import QApplication, QMainWindow + app = QApplication([]) win = QMainWindow() diff --git a/kucher/view/main_window/telega_control_widget/vsi_status_widget.py b/kucher/view/main_window/telega_control_widget/vsi_status_widget.py index d2cf3b8..4a595c8 100644 --- a/kucher/view/main_window/telega_control_widget/vsi_status_widget.py +++ b/kucher/view/main_window/telega_control_widget/vsi_status_widget.py @@ -20,41 +20,47 @@ class VSIStatusWidget(ValueDisplayGroupWidget): # noinspection PyArgumentList,PyCallingNonCallable def __init__(self, parent: QWidget): - super(VSIStatusWidget, self).__init__(parent, 'VSI status', 'sine') - self.setToolTip('Voltage Source Inverter status') + super(VSIStatusWidget, self).__init__(parent, "VSI status", "sine") + self.setToolTip("Voltage Source Inverter status") - placeholder = 'N/A' + placeholder = "N/A" - self._vsi_driver_state = self.create_value_display('VSI state', - placeholder, - 'VSI driver state') + self._vsi_driver_state = self.create_value_display( + "VSI state", placeholder, "VSI driver state" + ) - self._pwm_frequency = self.create_value_display('PWM frq.', - placeholder, - 'PWM carrier frequency') + self._pwm_frequency = self.create_value_display( + "PWM frq.", placeholder, "PWM carrier frequency" + ) - self._current_agc_level = self.create_value_display('IAGC lvl.', - placeholder, - 'Automatic Gain Control state of current transducers') + self._current_agc_level = self.create_value_display( + "IAGC lvl.", + placeholder, + "Automatic Gain Control state of current transducers", + ) - def set(self, - pwm_frequency: float, - vsi_is_enabled: bool, - vsi_is_modulating: bool, - current_agc_is_high_level: bool): + def set( + self, + pwm_frequency: float, + vsi_is_enabled: bool, + vsi_is_modulating: bool, + current_agc_is_high_level: bool, + ): if vsi_is_modulating: - vsi_status_name = 'Modulating' - vsi_icon_name = 'sine' + vsi_status_name = "Modulating" + vsi_icon_name = "sine" elif vsi_is_enabled: - vsi_status_name = 'Armed' - vsi_icon_name = 'electricity' + vsi_status_name = "Armed" + vsi_icon_name = "electricity" else: - vsi_status_name = 'Idle' - vsi_icon_name = 'sleep' + vsi_status_name = "Idle" + vsi_icon_name = "sleep" self.set_icon(vsi_icon_name) self._vsi_driver_state.set(vsi_status_name) - self._pwm_frequency.set('%.1f kHz' % (pwm_frequency * 1e-3)) + self._pwm_frequency.set("%.1f kHz" % (pwm_frequency * 1e-3)) - self._current_agc_level.set('High gain' if current_agc_is_high_level else 'Low gain') + self._current_agc_level.set( + "High gain" if current_agc_is_high_level else "Low gain" + ) diff --git a/kucher/view/monitored_quantity.py b/kucher/view/monitored_quantity.py index 9866748..6845cdb 100644 --- a/kucher/view/monitored_quantity.py +++ b/kucher/view/monitored_quantity.py @@ -22,13 +22,15 @@ class MonitoredQuantity: class Alert(enum.Enum): - NONE = enum.auto() - TOO_LOW = enum.auto() + NONE = enum.auto() + TOO_LOW = enum.auto() TOO_HIGH = enum.auto() - def __init__(self, - value: typing.Union[int, float], - alert: 'typing.Optional[MonitoredQuantity.Alert]' = None): + def __init__( + self, + value: typing.Union[int, float], + alert: "typing.Optional[MonitoredQuantity.Alert]" = None, + ): self.value = float(value) if value is not None else None self.alert = alert or self.Alert.NONE @@ -39,7 +41,7 @@ def __int__(self): return int(self.value) def __str__(self): - return f'{self.value}({self.alert})' + return f"{self.value}({self.alert})" __repr__ = __str__ @@ -47,22 +49,26 @@ def __str__(self): class MonitoredQuantityPresenter: @dataclass class DisplayParameters: - comment: str = None - icon_name: str = None - style: ValueDisplayWidget.Style = ValueDisplayWidget.Style.NORMAL - - def __init__(self, - display_target: ValueDisplayWidget, - format_string: str, - params_default: DisplayParameters = None, - params_when_low: DisplayParameters = None, - params_when_high: DisplayParameters = None): + comment: str = None + icon_name: str = None + style: ValueDisplayWidget.Style = ValueDisplayWidget.Style.NORMAL + + def __init__( + self, + display_target: ValueDisplayWidget, + format_string: str, + params_default: DisplayParameters = None, + params_when_low: DisplayParameters = None, + params_when_high: DisplayParameters = None, + ): self._display_target = display_target self._format_string = format_string self._params = { - MonitoredQuantity.Alert.NONE: params_default or self.DisplayParameters(), - MonitoredQuantity.Alert.TOO_LOW: params_when_low or self.DisplayParameters(), - MonitoredQuantity.Alert.TOO_HIGH: params_when_high or self.DisplayParameters(), + MonitoredQuantity.Alert.NONE: params_default or self.DisplayParameters(), + MonitoredQuantity.Alert.TOO_LOW: params_when_low + or self.DisplayParameters(), + MonitoredQuantity.Alert.TOO_HIGH: params_when_high + or self.DisplayParameters(), } def display(self, quantity: typing.Union[int, float, MonitoredQuantity]): @@ -70,17 +76,16 @@ def display(self, quantity: typing.Union[int, float, MonitoredQuantity]): quantity = MonitoredQuantity(quantity) if quantity.value is None or math.isnan(quantity.value): - text = 'N/A' + text = "N/A" params = self.DisplayParameters() else: text = self._format_string % quantity.value params = self._params[quantity.alert] # Send over to the widget - self._display_target.set(text, - style=params.style, - comment=params.comment, - icon_name=params.icon_name) + self._display_target.set( + text, style=params.style, comment=params.comment, icon_name=params.icon_name + ) # noinspection PyArgumentList @@ -88,26 +93,35 @@ def display(self, quantity: typing.Union[int, float, MonitoredQuantity]): def _unittest_monitored_quantity_presenter(): import time from PyQt5.QtWidgets import QApplication, QMainWindow, QGroupBox, QHBoxLayout + app = QApplication([]) win = QMainWindow() container = QGroupBox(win) layout = QHBoxLayout() - a = ValueDisplayWidget(container, 'Raskolnikov', 'N/A', tooltip='This is Rodion', with_comment=True) + a = ValueDisplayWidget( + container, "Raskolnikov", "N/A", tooltip="This is Rodion", with_comment=True + ) layout.addWidget(a) container.setLayout(layout) win.setCentralWidget(container) win.show() - mqp = MonitoredQuantityPresenter(a, '%.1f \u00B0C', - params_default=MonitoredQuantityPresenter.DisplayParameters(comment='OK', - icon_name='ok'), - params_when_low=MonitoredQuantityPresenter.DisplayParameters(comment='Cold', - icon_name='cold'), - params_when_high=MonitoredQuantityPresenter.DisplayParameters(comment='Hot', - icon_name='fire')) + mqp = MonitoredQuantityPresenter( + a, + "%.1f \u00B0C", + params_default=MonitoredQuantityPresenter.DisplayParameters( + comment="OK", icon_name="ok" + ), + params_when_low=MonitoredQuantityPresenter.DisplayParameters( + comment="Cold", icon_name="cold" + ), + params_when_high=MonitoredQuantityPresenter.DisplayParameters( + comment="Hot", icon_name="fire" + ), + ) def run_a_bit(): for _ in range(1000): diff --git a/kucher/view/tool_window_manager.py b/kucher/view/tool_window_manager.py index dcc1102..bb2f66f 100644 --- a/kucher/view/tool_window_manager.py +++ b/kucher/view/tool_window_manager.py @@ -27,23 +27,23 @@ from .utils import get_icon, is_small_screen -_WidgetTypeVar = typing.TypeVar('W') +_WidgetTypeVar = typing.TypeVar("W") _logger = getLogger(__name__) class ToolWindowLocation(enum.IntEnum): - TOP = Qt.TopDockWidgetArea + TOP = Qt.TopDockWidgetArea BOTTOM = Qt.BottomDockWidgetArea - LEFT = Qt.LeftDockWidgetArea - RIGHT = Qt.RightDockWidgetArea + LEFT = Qt.LeftDockWidgetArea + RIGHT = Qt.RightDockWidgetArea class ToolWindowGroupingCondition(enum.Enum): - NEVER = enum.auto() - SAME_LOCATION = enum.auto() - ALWAYS = enum.auto() + NEVER = enum.auto() + SAME_LOCATION = enum.auto() + ALWAYS = enum.auto() class ToolWindowManager: @@ -63,22 +63,26 @@ def __init__(self, parent_window: QMainWindow): self._parent_window.tabifiedDockWidgetActivated.connect(self._reiconize) # Set up the appearance - self._parent_window.setTabPosition(Qt.TopDockWidgetArea, QTabWidget.North) + self._parent_window.setTabPosition(Qt.TopDockWidgetArea, QTabWidget.North) self._parent_window.setTabPosition(Qt.BottomDockWidgetArea, QTabWidget.South) - self._parent_window.setTabPosition(Qt.LeftDockWidgetArea, QTabWidget.South) - self._parent_window.setTabPosition(Qt.RightDockWidgetArea, QTabWidget.South) + self._parent_window.setTabPosition(Qt.LeftDockWidgetArea, QTabWidget.South) + self._parent_window.setTabPosition(Qt.RightDockWidgetArea, QTabWidget.South) # Now, most screens are wide but not very tall; we need to optimize the layout for that # More info (this is for Qt4 but works for Qt5 as well): https://doc.qt.io/archives/4.6/qt4-mainwindow.html - self._parent_window.setCorner(Qt.TopLeftCorner, Qt.LeftDockWidgetArea) - self._parent_window.setCorner(Qt.BottomLeftCorner, Qt.LeftDockWidgetArea) - self._parent_window.setCorner(Qt.TopRightCorner, Qt.RightDockWidgetArea) + self._parent_window.setCorner(Qt.TopLeftCorner, Qt.LeftDockWidgetArea) + self._parent_window.setCorner(Qt.BottomLeftCorner, Qt.LeftDockWidgetArea) + self._parent_window.setCorner(Qt.TopRightCorner, Qt.RightDockWidgetArea) self._parent_window.setCorner(Qt.BottomRightCorner, Qt.RightDockWidgetArea) # http://doc.qt.io/qt-5/qmainwindow.html#DockOption-enum - dock_options = self._parent_window.AnimatedDocks | self._parent_window.AllowTabbedDocks + dock_options = ( + self._parent_window.AnimatedDocks | self._parent_window.AllowTabbedDocks + ) if not is_small_screen(): - dock_options |= self._parent_window.AllowNestedDocks # This won't work well on small screens + dock_options |= ( + self._parent_window.AllowNestedDocks + ) # This won't work well on small screens self._parent_window.setDockOptions(dock_options) @@ -98,20 +102,23 @@ def tool_window_removed_event(self) -> Event: return self._tool_window_removed_event # noinspection PyUnresolvedReferences - def register(self, - factory: typing.Union[typing.Type[QWidget], - typing.Callable[[ToolWindow], QWidget]], - title: str, - icon_name: typing.Optional[str] = None, - allow_multiple_instances: bool = False, - shown_by_default: bool = False): + def register( + self, + factory: typing.Union[ + typing.Type[QWidget], typing.Callable[[ToolWindow], QWidget] + ], + title: str, + icon_name: typing.Optional[str] = None, + allow_multiple_instances: bool = False, + shown_by_default: bool = False, + ): """ Adds the specified tool WIDGET (not window) to the set of known tools. If requested, it can be instantiated automatically at the time of application startup. The class will automatically register the menu item and do all of the other boring boilerplate stuff. """ if self._menu is None: - self._menu = self._parent_window.menuBar().addMenu('&Tools') + self._menu = self._parent_window.menuBar().addMenu("&Tools") def spawn(): def terminate(): @@ -122,9 +129,7 @@ def terminate(): # noinspection PyBroadException try: # Instantiating the tool window and set up its widget using the client-provided factory - tw = ToolWindow(self._parent_window, - title=title, - icon_name=icon_name) + tw = ToolWindow(self._parent_window, title=title, icon_name=icon_name) tw.widget = factory(tw) # Set up the tool window @@ -143,9 +148,13 @@ def terminate(): self._new_tool_window_event.emit(tw) except Exception: - _logger.exception(f'Could not spawn tool window {title!r} with icon {icon_name!r}') + _logger.exception( + f"Could not spawn tool window {title!r} with icon {icon_name!r}" + ) else: - _logger.info(f'Spawned tool window {tw!r} {title!r} with icon {icon_name!r}') + _logger.info( + f"Spawned tool window {tw!r} {title!r} with icon {icon_name!r}" + ) icon = get_icon(icon_name) @@ -159,10 +168,12 @@ def terminate(): if shown_by_default: spawn() - def add_arrangement_rule(self, - apply_to: typing.Iterable[typing.Type[QWidget]], - group_when: ToolWindowGroupingCondition, - location: ToolWindowLocation): + def add_arrangement_rule( + self, + apply_to: typing.Iterable[typing.Type[QWidget]], + group_when: ToolWindowGroupingCondition, + location: ToolWindowLocation, + ): """ :param apply_to: :param group_when: Grouping policy: @@ -171,13 +182,17 @@ def add_arrangement_rule(self, ALWAYS - group always, regardless of the location :param location: Default placement in the main window """ - self._arrangement_rules.append(_ArrangementRule(apply_to=list(apply_to), - group_when=group_when, - location=location)) - - def select_widgets(self, - widget_type: typing.Type[_WidgetTypeVar] = QWidget, - current_location: typing.Optional[ToolWindowLocation] = None) -> typing.List[_WidgetTypeVar]: + self._arrangement_rules.append( + _ArrangementRule( + apply_to=list(apply_to), group_when=group_when, location=location + ) + ) + + def select_widgets( + self, + widget_type: typing.Type[_WidgetTypeVar] = QWidget, + current_location: typing.Optional[ToolWindowLocation] = None, + ) -> typing.List[_WidgetTypeVar]: """ Returns a list of references to the root widgets of all existing tool windows which are instances of the specified type. This can be used to broadcast events and such. @@ -196,19 +211,28 @@ def select_widgets(self, return out - def _select_tool_windows(self, widget_type: typing.Type[QWidget]) -> typing.List[ToolWindow]: + def _select_tool_windows( + self, widget_type: typing.Type[QWidget] + ) -> typing.List[ToolWindow]: return [win for win in self._children if isinstance(win.widget, widget_type)] - def _select_applicable_arrangement_rules(self, widget_type: typing.Type[QWidget]) -> \ - typing.List['_ArrangementRule']: - return [copy.deepcopy(ar) for ar in self._arrangement_rules if widget_type in ar.apply_to] + def _select_applicable_arrangement_rules( + self, widget_type: typing.Type[QWidget] + ) -> typing.List["_ArrangementRule"]: + return [ + copy.deepcopy(ar) + for ar in self._arrangement_rules + if widget_type in ar.apply_to + ] def _allocate(self, what: ToolWindow): widget_type = type(what.widget) rules = self._select_applicable_arrangement_rules(widget_type) if not rules: - raise ValueError(f'Arrangement rules for widget of type {widget_type} could not be found') + raise ValueError( + f"Arrangement rules for widget of type {widget_type} could not be found" + ) self._parent_window.addDockWidget(int(rules[0].location), what) @@ -219,7 +243,9 @@ def _allocate(self, what: ToolWindow): if applicable is not widget_type: matching_windows += self._select_tool_windows(applicable) - _logger.info(f'Existing tool windows matching the rule {ar} against {widget_type}: {matching_windows}') + _logger.info( + f"Existing tool windows matching the rule {ar} against {widget_type}: {matching_windows}" + ) if not matching_windows: continue @@ -252,8 +278,10 @@ def _reiconize(self, *_): # main window. Conveniently, the tab bars in the dock areas are direct descendants of the main window. # It is assumed that this can never be the case with other widgets, since tab bars are usually nested into # other widgets. - to_a_bar = self._parent_window.findChildren(QTabBar, '', Qt.FindDirectChildrenOnly) - for tab_walks in to_a_bar: # ha ha + to_a_bar = self._parent_window.findChildren( + QTabBar, "", Qt.FindDirectChildrenOnly + ) + for tab_walks in to_a_bar: # ha ha for index in range(tab_walks.count()): title = tab_walks.tabText(index) try: @@ -269,6 +297,6 @@ def _on_tool_window_resize(self, instance: ToolWindow): @dataclass class _ArrangementRule: - apply_to: typing.List[typing.Type[QWidget]] - group_when: ToolWindowGroupingCondition - location: ToolWindowLocation + apply_to: typing.List[typing.Type[QWidget]] + group_when: ToolWindowGroupingCondition + location: ToolWindowLocation diff --git a/kucher/view/utils.py b/kucher/view/utils.py index 15f0e56..a748b13 100644 --- a/kucher/view/utils.py +++ b/kucher/view/utils.py @@ -17,8 +17,16 @@ import typing import functools from logging import getLogger -from PyQt5.QtWidgets import QApplication, QWidget, QPushButton, QMessageBox, QLayout, QHBoxLayout, QVBoxLayout, \ - QBoxLayout +from PyQt5.QtWidgets import ( + QApplication, + QWidget, + QPushButton, + QMessageBox, + QLayout, + QHBoxLayout, + QVBoxLayout, + QBoxLayout, +) from PyQt5.QtGui import QFont, QFontInfo, QIcon, QPixmap from PyQt5.QtCore import Qt @@ -34,20 +42,20 @@ def get_application_icon() -> QIcon: - return get_icon('zee') + return get_icon("zee") @cached def get_icon_path(name: str) -> str: def attempt(ext: str) -> str: - return get_absolute_path('view', 'icons', f'{name}.{ext}', check_existence=True) + return get_absolute_path("view", "icons", f"{name}.{ext}", check_existence=True) try: - out = attempt('png') + out = attempt("png") except ValueError: - out = attempt('svg') + out = attempt("svg") - _logger.info(f'Icon {name!r} found at {out!r}') + _logger.info(f"Icon {name!r} found at {out!r}") return out @@ -66,7 +74,13 @@ def get_icon_pixmap(icon_name: str, width: int, height: int = None) -> QPixmap: height = height or width output = get_icon(icon_name).pixmap(width, height) elapsed = time.monotonic() - begun - _logger.info('Pixmap %r has been rendered with size %rx%r in %.6f seconds', icon_name, width, height, elapsed) + _logger.info( + "Pixmap %r has been rendered with size %rx%r in %.6f seconds", + icon_name, + width, + height, + elapsed, + ) return output @@ -80,18 +94,34 @@ def _get_monospace_font_impl(small=False) -> QFont: begun = time.monotonic() multiplier = 0.8 if small else 1.0 min_font_size = min(7, QFont().pointSize()) - preferred = ['Consolas', 'DejaVu Sans Mono', 'Monospace', 'Lucida Console', 'Monaco'] + preferred = [ + "Consolas", + "DejaVu Sans Mono", + "Monospace", + "Lucida Console", + "Monaco", + ] for name in preferred: font = QFont(name) if QFontInfo(font).fixedPitch(): - font.setPointSize(round(max(min_font_size, QFont().pointSize() * multiplier))) - _logger.info('Selected monospace font (%.6f seconds): %r', time.monotonic() - begun, font.toString()) + font.setPointSize( + round(max(min_font_size, QFont().pointSize() * multiplier)) + ) + _logger.info( + "Selected monospace font (%.6f seconds): %r", + time.monotonic() - begun, + font.toString(), + ) return font font = QFont() font.setStyleHint(QFont().Monospace) - font.setFamily('monospace') - _logger.info('Using fallback monospace font (%.6f seconds): %r', time.monotonic() - begun, font.toString()) + font.setFamily("monospace") + _logger.info( + "Using fallback monospace font (%.6f seconds): %r", + time.monotonic() - begun, + font.toString(), + ) return font @@ -102,17 +132,19 @@ def is_small_screen() -> bool: rect = QApplication.desktop().screenGeometry() w, h = rect.width(), rect.height() is_small = (w < 1000) or (h < 800) - _logger.info(f'Screen width and height: {w, h}, is small: {is_small}') + _logger.info(f"Screen width and height: {w, h}, is small: {is_small}") return is_small -def make_button(parent: QWidget, - text: str = '', - icon_name: typing.Optional[str] = None, - tool_tip: typing.Optional[str] = None, - checkable: bool = False, - checked: bool = False, - on_clicked: typing.Callable[[], None] = None) -> QPushButton: +def make_button( + parent: QWidget, + text: str = "", + icon_name: typing.Optional[str] = None, + tool_tip: typing.Optional[str] = None, + checkable: bool = False, + checked: bool = False, + on_clicked: typing.Callable[[], None] = None, +) -> QPushButton: b = QPushButton(text, parent) b.setFocusPolicy(Qt.NoFocus) if icon_name: @@ -123,7 +155,9 @@ def make_button(parent: QWidget, if checked and not checkable: checkable = True - _logger.error(f'A checked button must be checkable! text={text} icon_name={icon_name}') + _logger.error( + f"A checked button must be checkable! text={text} icon_name={icon_name}" + ) if checkable: b.setCheckable(True) @@ -135,12 +169,16 @@ def make_button(parent: QWidget, return b -def show_error(title: str, - text: str, - informative_text: str, - parent: typing.Optional[QWidget]) -> QMessageBox: - _logger.exception('Error window: title=%r, text=%r, informative_text=%r, parent=%r', - title, text, informative_text, parent) +def show_error( + title: str, text: str, informative_text: str, parent: typing.Optional[QWidget] +) -> QMessageBox: + _logger.exception( + "Error window: title=%r, text=%r, informative_text=%r, parent=%r", + title, + text, + informative_text, + parent, + ) mbox = QMessageBox(parent) @@ -152,7 +190,7 @@ def show_error(title: str, mbox.setIcon(QMessageBox.Critical) mbox.setStandardButtons(QMessageBox.Ok) - mbox.show() # Not exec() because we don't want it to block! + mbox.show() # Not exec() because we don't want it to block! return mbox @@ -163,7 +201,7 @@ def time_tracked(target: typing.Callable): Note that if the wrapped function throws, the statistics is not updated and not logged. """ worst = 0.0 - best = float('+inf') + best = float("+inf") total = 0.0 call_cnt = 0 @@ -180,8 +218,14 @@ def decorator(*args, **kwargs): total += execution_time call_cnt += 1 - getLogger(str(target.__module__)).debug('%r completed in %.3f s (worst %.3f, best %.3f, avg %.3f)', - target, execution_time, worst, best, total / call_cnt) + getLogger(str(target.__module__)).debug( + "%r completed in %.3f s (worst %.3f, best %.3f, avg %.3f)", + target, + execution_time, + worst, + best, + total / call_cnt, + ) return output @@ -189,12 +233,16 @@ def decorator(*args, **kwargs): # noinspection PyArgumentList -def lay_out(layout_object: QBoxLayout, - *items_or_items_with_stretch_factors: typing.Union[QWidget, - QLayout, - typing.Tuple[QWidget, int], - typing.Tuple[QLayout, int], - typing.Tuple[None, int]]): +def lay_out( + layout_object: QBoxLayout, + *items_or_items_with_stretch_factors: typing.Union[ + QWidget, + QLayout, + typing.Tuple[QWidget, int], + typing.Tuple[QLayout, int], + typing.Tuple[None, int], + ], +): for item in items_or_items_with_stretch_factors: if isinstance(item, tuple): item, stretch = item @@ -208,15 +256,19 @@ def lay_out(layout_object: QBoxLayout, elif item is None: layout_object.addStretch(stretch) else: - raise TypeError(f'Unexpected type: {type(item)!r}') + raise TypeError(f"Unexpected type: {type(item)!r}") # noinspection PyArgumentList -def lay_out_horizontally(*items_or_items_with_stretch_factors: typing.Union[QWidget, - QLayout, - typing.Tuple[QWidget, int], - typing.Tuple[QLayout, int], - typing.Tuple[None, int]]) -> QLayout: +def lay_out_horizontally( + *items_or_items_with_stretch_factors: typing.Union[ + QWidget, + QLayout, + typing.Tuple[QWidget, int], + typing.Tuple[QLayout, int], + typing.Tuple[None, int], + ] +) -> QLayout: """A simple convenience function that creates a horizontal layout in a Pythonic way""" inner = QHBoxLayout() lay_out(inner, *items_or_items_with_stretch_factors) @@ -224,11 +276,15 @@ def lay_out_horizontally(*items_or_items_with_stretch_factors: typing.Union[QWid # noinspection PyArgumentList -def lay_out_vertically(*items_or_items_with_stretch_factors: typing.Union[QWidget, - QLayout, - typing.Tuple[QWidget, int], - typing.Tuple[QLayout, int], - typing.Tuple[None, int]]) -> QLayout: +def lay_out_vertically( + *items_or_items_with_stretch_factors: typing.Union[ + QWidget, + QLayout, + typing.Tuple[QWidget, int], + typing.Tuple[QLayout, int], + typing.Tuple[None, int], + ] +) -> QLayout: """Like lay_out_horizontally(), but for vertical layouts.""" inner = QVBoxLayout() lay_out(inner, *items_or_items_with_stretch_factors) @@ -241,17 +297,20 @@ def gui_test(test_case_function: typing.Callable): It attempts to detect if there is a desktop environment available where the GUI could be rendered, and if not, it skips the decorated test. """ + @functools.wraps(test_case_function) def decorator(*args, **kwargs): # Observe that PyTest is NOT a runtime dependency; therefore, it must not be imported unless a test # function is invoked! import pytest - if not bool(os.getenv('DISPLAY', False)): - pytest.skip("GUI test skipped because this environment doesn't seem to be GUI-capable") + if not bool(os.getenv("DISPLAY", False)): + pytest.skip( + "GUI test skipped because this environment doesn't seem to be GUI-capable" + ) - if bool(os.environ.get('SKIP_SLOW_TESTS', False)): - pytest.skip('GUI test skipped because $SKIP_SLOW_TESTS is set') + if bool(os.environ.get("SKIP_SLOW_TESTS", False)): + pytest.skip("GUI test skipped because $SKIP_SLOW_TESTS is set") test_case_function(*args, **kwargs) @@ -261,9 +320,10 @@ def decorator(*args, **kwargs): @gui_test def _unittest_show_error(): from PyQt5.QtWidgets import QApplication + app = QApplication([]) # We don't have to act upon the returned object; we just need to keep a reference to keep it alive - mb = show_error('Error title', 'Error text', 'Informative text', None) + mb = show_error("Error title", "Error text", "Informative text", None) for _ in range(1000): time.sleep(0.002) app.processEvents() @@ -275,14 +335,25 @@ def _unittest_show_error(): def _unittest_icons(): import math import glob - from PyQt5.QtWidgets import QApplication, QMainWindow, QGroupBox, QGridLayout, QLabel, QSizePolicy + from PyQt5.QtWidgets import ( + QApplication, + QMainWindow, + QGroupBox, + QGridLayout, + QLabel, + QSizePolicy, + ) from PyQt5.QtGui import QFont, QFontMetrics app = QApplication([]) - all_icons = list(map(lambda x: os.path.splitext(os.path.basename(x))[0], - glob.glob(os.path.join(get_absolute_path('view', 'icons'), '*')))) - print('All icons:', len(all_icons), all_icons) + all_icons = list( + map( + lambda x: os.path.splitext(os.path.basename(x))[0], + glob.glob(os.path.join(get_absolute_path("view", "icons"), "*")), + ) + ) + print("All icons:", len(all_icons), all_icons) grid_size = int(math.ceil(math.sqrt(len(all_icons)))) diff --git a/kucher/view/widgets/__init__.py b/kucher/view/widgets/__init__.py index 36c2de5..38d208f 100644 --- a/kucher/view/widgets/__init__.py +++ b/kucher/view/widgets/__init__.py @@ -28,14 +28,20 @@ class WidgetBase(QWidget): def __init__(self, parent: typing.Optional[QWidget]): super(WidgetBase, self).__init__(parent) - def flash(self, message: str, *format_args, duration: typing.Optional[float] = None): + def flash( + self, message: str, *format_args, duration: typing.Optional[float] = None + ): """ Shows the specified message in the status bar of the parent window. """ duration_milliseconds = int((duration or 0) * 1000) formatted_message = message % format_args self.window().statusBar().showMessage(formatted_message, duration_milliseconds) - _logger.info('Flashing status bar message from %r for %.1f seconds: %r', - self, - (duration_milliseconds * 1e-3) if duration_milliseconds > 0 else float('inf'), - formatted_message) + _logger.info( + "Flashing status bar message from %r for %.1f seconds: %r", + self, + (duration_milliseconds * 1e-3) + if duration_milliseconds > 0 + else float("inf"), + formatted_message, + ) diff --git a/kucher/view/widgets/group_box_widget.py b/kucher/view/widgets/group_box_widget.py index e00d5d2..3949b49 100644 --- a/kucher/view/widgets/group_box_widget.py +++ b/kucher/view/widgets/group_box_widget.py @@ -23,10 +23,9 @@ class GroupBoxWidget(QGroupBox): - def __init__(self, - parent: QWidget, - title: str, - icon_name: typing.Optional[str] = None): + def __init__( + self, parent: QWidget, title: str, icon_name: typing.Optional[str] = None + ): super(GroupBoxWidget, self).__init__(title, parent) # Changing icons is very expensive, so we store last set icon in order to avoid re-setting it @@ -39,7 +38,7 @@ def set_icon(self, icon_name: str): if self._current_icon == icon_name: return - _logger.debug('Changing icon from %r to %r', self._current_icon, icon_name) + _logger.debug("Changing icon from %r to %r", self._current_icon, icon_name) self._current_icon = icon_name icon_size = QFontMetrics(QFont()).height() @@ -47,14 +46,16 @@ def set_icon(self, icon_name: str): # This hack adds a custom icon to the GroupBox: make it checkable, and then, using styling, override # the image of the check box with the custom icon. - self.setCheckable(True) # This is needed to make the icon visible - self.setStyleSheet(f''' + self.setCheckable(True) # This is needed to make the icon visible + self.setStyleSheet( + f""" QGroupBox::indicator {{ width: {icon_size}px; height: {icon_size}px; image: url({icon_path}); }} - ''') + """ + ) # We don't actually want it to be checkable, so override this thing to return it back to normal again # noinspection PyUnresolvedReferences diff --git a/kucher/view/widgets/spinbox_linked_with_slider.py b/kucher/view/widgets/spinbox_linked_with_slider.py index f290e14..ee707ee 100644 --- a/kucher/view/widgets/spinbox_linked_with_slider.py +++ b/kucher/view/widgets/spinbox_linked_with_slider.py @@ -63,22 +63,26 @@ class SpinboxLinkedWithSlider: class SliderOrientation(enum.IntEnum): HORIZONTAL = Qt.Horizontal - VERTICAL = Qt.Vertical + VERTICAL = Qt.Vertical # noinspection PyUnresolvedReferences - def __init__(self, - parent: QWidget, - minimum: float = 0.0, - maximum: float = 100.0, - step: float = 1.0, - slider_orientation: SliderOrientation = SliderOrientation.VERTICAL): + def __init__( + self, + parent: QWidget, + minimum: float = 0.0, + maximum: float = 100.0, + step: float = 1.0, + slider_orientation: SliderOrientation = SliderOrientation.VERTICAL, + ): self._events_suppression_depth = 0 # Instantiating the widgets self._box = QDoubleSpinBox(parent) self._sld = QSlider(int(slider_orientation), parent) - self._sld.setTickPosition(QSlider.TicksBothSides) # Perhaps expose this via API later + self._sld.setTickPosition( + QSlider.TicksBothSides + ) # Perhaps expose this via API later # This stuff breaks if I remove lambdas, no clue why, investigate later self._box.valueChanged[float].connect(lambda v: self._on_box_changed(v)) @@ -123,7 +127,7 @@ def minimum(self, value: float): with self._with_events_suppressed(): self._sld.setMinimum(self._value_to_int(value)) - _logger.debug('New minimum: %r %r', value, self._value_to_int(value)) + _logger.debug("New minimum: %r %r", value, self._value_to_int(value)) @property def maximum(self) -> float: @@ -138,7 +142,7 @@ def maximum(self, value: float): self._refresh_invariants() - _logger.debug('New maximum: %r %r', value, self._value_to_int(value)) + _logger.debug("New maximum: %r %r", value, self._value_to_int(value)) @property def step(self) -> float: @@ -147,7 +151,7 @@ def step(self) -> float: @step.setter def step(self, value: float): if not (value > 0): - raise ValueError(f'Step must be positive, got {value!r}') + raise ValueError(f"Step must be positive, got {value!r}") self._box.setSingleStep(value) @@ -158,8 +162,12 @@ def step(self, value: float): self._refresh_invariants() - _logger.debug('New step: %r; resulting range of the slider: [%r, %r]', - value, self._sld.minimum(), self._sld.maximum()) + _logger.debug( + "New step: %r; resulting range of the slider: [%r, %r]", + value, + self._sld.minimum(), + self._sld.maximum(), + ) @property def value(self) -> float: @@ -216,16 +224,20 @@ def slider_visible(self, value: bool): def set_range(self, minimum: float, maximum: float): if minimum >= maximum: - raise ValueError(f'Minimum must be less than maximum: min={minimum} max={maximum}') + raise ValueError( + f"Minimum must be less than maximum: min={minimum} max={maximum}" + ) self.minimum = minimum self.maximum = maximum - def update_atomically(self, - minimum: typing.Optional[float] = None, - maximum: typing.Optional[float] = None, - step: typing.Optional[float] = None, - value: typing.Optional[float] = None): + def update_atomically( + self, + minimum: typing.Optional[float] = None, + maximum: typing.Optional[float] = None, + step: typing.Optional[float] = None, + value: typing.Optional[float] = None, + ): """ This function updates all of the parameters, and invokes the change event only once at the end, provided that the new value is different from the old value. @@ -233,7 +245,9 @@ def update_atomically(self, """ if (minimum is not None) and (maximum is not None): if minimum >= maximum: - raise ValueError(f'Minimum must be less than maximum: min={minimum} max={maximum}') + raise ValueError( + f"Minimum must be less than maximum: min={minimum} max={maximum}" + ) original_value = self.value @@ -305,22 +319,22 @@ def _unittest_spinbox_linked_with_slider(): instances: typing.List[SpinboxLinkedWithSlider] = [] - def make(minimum: float, - maximum: float, - step: float) -> QLayout: - o = SpinboxLinkedWithSlider(widget, - minimum=minimum, - maximum=maximum, - step=step, - slider_orientation=SpinboxLinkedWithSlider.SliderOrientation.HORIZONTAL) + def make(minimum: float, maximum: float, step: float) -> QLayout: + o = SpinboxLinkedWithSlider( + widget, + minimum=minimum, + maximum=maximum, + step=step, + slider_orientation=SpinboxLinkedWithSlider.SliderOrientation.HORIZONTAL, + ) instances.append(o) return lay_out_horizontally((o.slider, 1), o.spinbox) win = QMainWindow() widget = QWidget(win) - widget.setLayout(lay_out_vertically(make(0, 100, 1), - make(-10, 10, 0.01), - make(-99999, 100, 100))) + widget.setLayout( + lay_out_vertically(make(0, 100, 1), make(-10, 10, 0.01), make(-99999, 100, 100)) + ) win.setCentralWidget(widget) win.show() diff --git a/kucher/view/widgets/tool_window.py b/kucher/view/widgets/tool_window.py index 015cc6b..bebbfb3 100644 --- a/kucher/view/widgets/tool_window.py +++ b/kucher/view/widgets/tool_window.py @@ -26,12 +26,16 @@ class ToolWindow(QDockWidget): # noinspection PyArgumentList - def __init__(self, - parent: QWidget, - title: typing.Optional[str] = None, - icon_name: typing.Optional[str] = None): + def __init__( + self, + parent: QWidget, + title: typing.Optional[str] = None, + icon_name: typing.Optional[str] = None, + ): super(QDockWidget, self).__init__(parent) - self.setAttribute(Qt.WA_DeleteOnClose) # This is required to stop background timers! + self.setAttribute( + Qt.WA_DeleteOnClose + ) # This is required to stop background timers! if title: self.setWindowTitle(title) @@ -43,10 +47,10 @@ def __init__(self, self._resize_event = Event() def __del__(self): - _logger.debug('Deleting %r', self) + _logger.debug("Deleting %r", self) def __str__(self): - return f'ToolWindow({self.widget!r})' + return f"ToolWindow({self.widget!r})" __repr__ = __str__ @@ -61,7 +65,7 @@ def resize_event(self) -> Event: return self._resize_event def set_icon(self, icon_name: str): - pass # TODO: Icons? + pass # TODO: Icons? @property def widget(self) -> QWidget: @@ -71,14 +75,14 @@ def widget(self) -> QWidget: @widget.setter def widget(self, widget: QWidget): if not isinstance(widget, QWidget): - raise TypeError(f'Expected QWidget, got {type(widget)}') + raise TypeError(f"Expected QWidget, got {type(widget)}") self.setWidget(widget) # noinspection PyCallingNonCallable,PyArgumentList def closeEvent(self, *args): super(ToolWindow, self).closeEvent(*args) - _logger.debug('Close event at %r', self) + _logger.debug("Close event at %r", self) self._close_event() # noinspection PyCallingNonCallable,PyArgumentList diff --git a/kucher/view/widgets/value_display_group_widget.py b/kucher/view/widgets/value_display_group_widget.py index 2deb85e..cf8bde7 100644 --- a/kucher/view/widgets/value_display_group_widget.py +++ b/kucher/view/widgets/value_display_group_widget.py @@ -20,11 +20,13 @@ class ValueDisplayGroupWidget(GroupBoxWidget): - def __init__(self, - parent: QWidget, - title: str, - icon_name: typing.Optional[str] = None, - with_comments: bool = False): + def __init__( + self, + parent: QWidget, + title: str, + icon_name: typing.Optional[str] = None, + with_comments: bool = False, + ): super(ValueDisplayGroupWidget, self).__init__(parent, title, icon_name) self._with_comments = with_comments @@ -33,15 +35,19 @@ def __init__(self, self.setLayout(self._inferior_layout) # noinspection PyArgumentList - def create_value_display(self, - title: str, - placeholder_text: typing.Optional[str] = None, - tooltip: typing.Optional[str] = None) -> ValueDisplayWidget: - inferior = ValueDisplayWidget(self, - title, - placeholder_text=placeholder_text, - with_comment=self._with_comments, - tooltip=tooltip) + def create_value_display( + self, + title: str, + placeholder_text: typing.Optional[str] = None, + tooltip: typing.Optional[str] = None, + ) -> ValueDisplayWidget: + inferior = ValueDisplayWidget( + self, + title, + placeholder_text=placeholder_text, + with_comment=self._with_comments, + tooltip=tooltip, + ) self._inferiors.append(inferior) self._inferior_layout.addWidget(inferior, stretch=1) return inferior @@ -56,15 +62,18 @@ def reset(self): def _unittest_value_display_group_widget(): import time from PyQt5.QtWidgets import QApplication, QMainWindow + app = QApplication([]) win = QMainWindow() - midget = ValueDisplayGroupWidget(win, 'Temperature', icon_name='thermometer', with_comments=True) + midget = ValueDisplayGroupWidget( + win, "Temperature", icon_name="thermometer", with_comments=True + ) - cpu = midget.create_value_display('CPU', 'N/A') - vsi = midget.create_value_display('VSI', 'N/A') - motor = midget.create_value_display('Motor', 'N/A') + cpu = midget.create_value_display("CPU", "N/A") + vsi = midget.create_value_display("VSI", "N/A") + motor = midget.create_value_display("Motor", "N/A") win.setCentralWidget(midget) win.show() @@ -76,9 +85,9 @@ def run_a_bit(): run_a_bit() - cpu.set('12', comment='OK', icon_name='ok') - vsi.set('123', comment='Overheating', icon_name='fire') - motor.set('-123', comment='Cold', icon_name='cold') + cpu.set("12", comment="OK", icon_name="ok") + vsi.set("123", comment="Overheating", icon_name="fire") + motor.set("-123", comment="Cold", icon_name="cold") run_a_bit() diff --git a/kucher/view/widgets/value_display_widget.py b/kucher/view/widgets/value_display_widget.py index ec92add..43303c4 100644 --- a/kucher/view/widgets/value_display_widget.py +++ b/kucher/view/widgets/value_display_widget.py @@ -30,21 +30,23 @@ class ValueDisplayWidget(WidgetBase): class Style(enum.Enum): - NORMAL = enum.auto() + NORMAL = enum.auto() ALERT_ERROR = enum.auto() - ALERT_HIGH = enum.auto() - ALERT_LOW = enum.auto() + ALERT_HIGH = enum.auto() + ALERT_LOW = enum.auto() # noinspection PyArgumentList - def __init__(self, - parent: QWidget, - title: str, - placeholder_text: typing.Optional[str] = None, - with_comment: bool = False, - tooltip: typing.Optional[str] = None): + def __init__( + self, + parent: QWidget, + title: str, + placeholder_text: typing.Optional[str] = None, + with_comment: bool = False, + tooltip: typing.Optional[str] = None, + ): super(ValueDisplayWidget, self).__init__(parent) - self._placeholder_text = str(placeholder_text or '') + self._placeholder_text = str(placeholder_text or "") self._value_display = QLabel(self) self._value_display.setAlignment(Qt.AlignVCenter | Qt.AlignRight) @@ -57,7 +59,7 @@ def __init__(self, else: self._comment = None - self._default_tooltip = str(tooltip or '') + self._default_tooltip = str(tooltip or "") self.setToolTip(self._default_tooltip) self.setStatusTip(self.toolTip()) @@ -82,11 +84,13 @@ def reset(self): if isinstance(self._comment, _Comment): self._comment.reset() - def set(self, - text: str, - style: 'typing.Optional[ValueDisplayWidget.Style]' = None, - comment: typing.Optional[str] = None, - icon_name: typing.Optional[str] = None): + def set( + self, + text: str, + style: "typing.Optional[ValueDisplayWidget.Style]" = None, + comment: typing.Optional[str] = None, + icon_name: typing.Optional[str] = None, + ): # TODO: handle style style = style or self.Style.NORMAL @@ -96,7 +100,9 @@ def set(self, self._comment.set_text(comment) self._comment.set_icon(icon_name) elif comment or icon_name: - warnings.warn('Attempting to set comment, but the instance is configured to not use one') + warnings.warn( + "Attempting to set comment, but the instance is configured to not use one" + ) # noinspection PyArgumentList @@ -104,17 +110,18 @@ def set(self, def _unittest_value_display_widget_main(): import time from PyQt5.QtWidgets import QApplication, QMainWindow, QGroupBox + app = QApplication([]) win = QMainWindow() container = QGroupBox(win) layout = QVBoxLayout() - a = ValueDisplayWidget(container, 'Vladimir', 'N/A', tooltip='This is Vladimir') + a = ValueDisplayWidget(container, "Vladimir", "N/A", tooltip="This is Vladimir") layout.addWidget(a) - b = ValueDisplayWidget(container, 'Dmitri', with_comment=True) - b.set('123.4 \u00B0C', comment='Init', icon_name='info') + b = ValueDisplayWidget(container, "Dmitri", with_comment=True) + b.set("123.4 \u00B0C", comment="Init", icon_name="info") layout.addWidget(b) container.setLayout(layout) @@ -127,11 +134,11 @@ def run_a_bit(): app.processEvents() run_a_bit() - b.set('12.3 \u00B0C', comment='OK', icon_name='ok') + b.set("12.3 \u00B0C", comment="OK", icon_name="ok") run_a_bit() - b.set('123.4 \u00B0C') + b.set("123.4 \u00B0C") run_a_bit() - b.set('-45.6 \u00B0C', comment='Cold', icon_name='cold') + b.set("-45.6 \u00B0C", comment="Cold", icon_name="cold") run_a_bit() win.close() @@ -142,7 +149,7 @@ class _Comment(QLabel): def __init__(self, parent: QWidget): super(_Comment, self).__init__(parent) self.setAlignment(Qt.AlignCenter) - self._icon_size = QFontMetrics(QFont()).height() # As large as font + self._icon_size = QFontMetrics(QFont()).height() # As large as font self._pixmap_cache: typing.Dict[str, QPixmap] = {} self._current_icon_name: typing.Optional[str] = None # Initializing defaults @@ -153,7 +160,7 @@ def reset(self): self.set_icon(None) def set_icon(self, icon_name: typing.Optional[str]): - icon_name = str(icon_name or '_empty') + icon_name = str(icon_name or "_empty") if icon_name == self._current_icon_name: return @@ -167,7 +174,7 @@ def set_icon(self, icon_name: typing.Optional[str]): self._current_icon_name = icon_name def set_text(self, text: typing.Optional[str]): - text = str(text or '') + text = str(text or "") self.setToolTip(text) self.setStatusTip(text) @@ -177,6 +184,7 @@ def set_text(self, text: typing.Optional[str]): def _unittest_value_display_widget_comment(): import time from PyQt5.QtWidgets import QApplication, QMainWindow, QGroupBox + app = QApplication([]) win = QMainWindow() @@ -192,14 +200,14 @@ def let_there_be_icon(text, icon_name): layout.addWidget(s) return s - a = let_there_be_icon('Cold', 'cold') + a = let_there_be_icon("Cold", "cold") b = let_there_be_icon(None, None) - c = let_there_be_icon(None, 'info') - let_there_be_icon('No icon', None) - let_there_be_icon('Fire', 'fire') - let_there_be_icon('Error', 'error') - let_there_be_icon('Warning', 'warning') - let_there_be_icon('Ok', 'ok') + c = let_there_be_icon(None, "info") + let_there_be_icon("No icon", None) + let_there_be_icon("Fire", "fire") + let_there_be_icon("Error", "error") + let_there_be_icon("Warning", "warning") + let_there_be_icon("Ok", "ok") layout.addStretch(1) container.setLayout(layout) @@ -212,14 +220,14 @@ def run_a_bit(): app.processEvents() run_a_bit() - a.set_icon('fire') - b.set_text('Text') - c.set_text('New icon') - c.set_icon('guru') + a.set_icon("fire") + b.set_text("Text") + c.set_text("New icon") + c.set_icon("guru") run_a_bit() c.set_icon(None) - b.set_text('Text') - a.set_text('New icon') + b.set_text("Text") + a.set_text("New icon") a.set_icon(None) run_a_bit() diff --git a/requirements-dev-linux.txt b/requirements-dev-linux.txt index 6b836db..e5f66d5 100644 --- a/requirements-dev-linux.txt +++ b/requirements-dev-linux.txt @@ -7,9 +7,9 @@ setuptools wheel # Test dependencies -pytest >= 3.3 -pycodestyle +pytest == 7.* +black == 22.* # Packaging tools -PyInstaller ~= 3.3 +PyInstaller == 5.* patchelf-wrapper~=1.0 diff --git a/requirements-dev-windows.txt b/requirements-dev-windows.txt index 022cf1d..0b7e4ed 100644 --- a/requirements-dev-windows.txt +++ b/requirements-dev-windows.txt @@ -7,8 +7,8 @@ setuptools wheel # Test dependencies -pytest >= 3.3 -pycodestyle +pytest == 7.* +black == 22.* # Packaging tools -PyInstaller ~= 3.3 +PyInstaller == 5.* diff --git a/requirements.txt b/requirements.txt index e9b28d9..28a6fc5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,15 +5,16 @@ # https://packages.ubuntu.com/bionic/python3-pyqt5 # https://github.com/pyinstaller/pyinstaller/issues/4293 -PyQt5 == 5.12.2 +PyQt5==5.* +construct==2.10.68 # https://packages.ubuntu.com/bionic/python3-serial -pyserial ~= 3.4 +pyserial==3.5 # https://packages.ubuntu.com/bionic/python3-numpy # Last version of numpy has problems with some modules when using pyinstaller # https://stackoverflow.com/questions/57264427/in-pyinstaller-why-wont-numpy-random-common-load-as-a-module -numpy == 1.16.2 +numpy==1.* # https://packages.ubuntu.com/bionic/python3-yaml -pyyaml ~= 5.1 +PyYAML==5.* diff --git a/test_linux.sh b/test_linux.sh index 576747e..c60f797 100644 --- a/test_linux.sh +++ b/test_linux.sh @@ -1,4 +1,3 @@ #!/bin/bash pytest - -pycodestyle +black kucher/fuhrer kucher/model kucher/view kucher/*.py --check diff --git a/test_windows.bat b/test_windows.bat index 913a54c..137ac94 100644 --- a/test_windows.bat +++ b/test_windows.bat @@ -1,3 +1,2 @@ pytest - -pycodestyle +python -m black kucher/fuhrer kucher/model kucher/view kucher/*.py --check