diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 777ea6b11..219f72fe9 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -25,7 +25,7 @@ jobs: PYTEST_QT_API: PySide6 DCSPY_NO_MSG_BOXES: 1 run: | - python -m pytest -v -m 'not e2e' --img_precision 0 --disable-warnings --cov=dcspy --cov-report=xml --cov-report=html --cov-report=term-missing + python -m pytest -v -m 'not e2e and not qt6' --img_precision 0 --disable-warnings --cov=dcspy --cov-report=xml --cov-report=html --cov-report=term-missing - name: "Upload pytest results" uses: actions/upload-artifact@v4 diff --git a/.github/workflows/license.yml b/.github/workflows/license.yml index a668af7bb..c39f2e8f1 100644 --- a/.github/workflows/license.yml +++ b/.github/workflows/license.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-latest steps: - name: "Checkout" - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: "Set up Python 3.13" uses: actions/setup-python@v5 @@ -26,12 +26,13 @@ jobs: - name: "Check License" id: license_check_report - uses: pilosus/action-pip-license-checker@v2 + uses: pilosus/action-pip-license-checker@v3 with: requirements: 'requirements-all.txt' - fail: 'StrongCopyleft,Other,Error' + fail: 'StrongCopyleft,Error' totals: true headers: true + exclude: '(?i)^(pyside6|shiboken6).*' - name: "Print report" if: ${{ always() }} diff --git a/.github/workflows/style.yml b/.github/workflows/style.yml index f96b76778..fccac5df2 100644 --- a/.github/workflows/style.yml +++ b/.github/workflows/style.yml @@ -3,24 +3,11 @@ name: Style on: workflow_call jobs: - pycodestyle: - runs-on: ubuntu-latest - steps: - - name: "Checkout" - uses: actions/checkout@v4 - - - name: "Set up Python environment" - uses: ./.github/actions/setup-python - - - name: "Check PyCodeStyle" - run: | - pycodestyle --statistics --count src - interrogate: runs-on: ubuntu-latest steps: - name: "Checkout" - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: "Set up Python environment" uses: ./.github/actions/setup-python @@ -33,7 +20,7 @@ jobs: runs-on: ubuntu-latest steps: - name: "Checkout" - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: "Set up Python environment" uses: ./.github/actions/setup-python @@ -46,7 +33,7 @@ jobs: runs-on: ubuntu-latest steps: - name: "Checkout" - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: "Set up Python environment" uses: ./.github/actions/setup-python @@ -63,16 +50,3 @@ jobs: path: | mypyhtml/* retention-days: 4 - - flake8: - runs-on: ubuntu-latest - steps: - - name: "Checkout" - uses: actions/checkout@v4 - - - name: "Set up Python environment" - uses: ./.github/actions/setup-python - - - name: "Check flake8" - run: | - flake8 src diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6a38eb714..d11995dce 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -39,7 +39,7 @@ jobs: PYTEST_QT_API: PySide6 DCSPY_NO_MSG_BOXES: 1 run: | - python -m pytest -v -m 'not e2e' --img_precision 0 + python -m pytest -v -m 'not e2e and not qt6' --img_precision 0 - name: "Upload test results" uses: actions/upload-artifact@v4 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 770364dc0..759f65861 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -31,7 +31,7 @@ repos: exclude: 'helpers.py' - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.14.1 + rev: v1.17.1 hooks: - id: mypy exclude: '/qt_gui\.py$|/qtgui_rc\.py$|tests/|generate_ver_file\.py$' @@ -44,12 +44,6 @@ repos: exclude: '/qtgui_rc.py$|tests/' args: [--fix, --exit-non-zero-on-fix] - - repo: https://github.com/PyCQA/flake8 - rev: 7.1.1 - hooks: - - id: flake8 - exclude: '/qtgui_rc.py$|tests/' - - repo: https://github.com/asottile/pyupgrade rev: v3.19.1 hooks: diff --git a/mkdocs.yml b/mkdocs.yml index 12eed6974..16028ac4b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -2,7 +2,7 @@ site_name: DCSpy site_url: https://dcspy.readthedocs.io/ site_description: Software for integrating DCS Planes with Logitech keyboards (with and without LCD), mice and headphones. repo_url: https://github.com/emcek/dcspy -copyright: Copyright © 2019 Michał Plichta +copyright: Copyright © 2019-2025 Michał Plichta theme: name: material @@ -75,8 +75,6 @@ plugins: - https://docs.python.org/3/objects.inv paths: [ src ] options: - docstring_options: - ignore_init_summary: true docstring_section_style: table docstring_style: sphinx filters: [ "!^_" ] diff --git a/pyproject.toml b/pyproject.toml index 4d0794816..c91770df1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,7 +49,7 @@ dependencies = [ 'pydantic==2.10.5', 'pyside6==6.8.1', 'pyyaml==6.0.2', - 'requests==2.32.3', + 'requests==2.32.5', 'typing-extensions==4.12.2 ; python_full_version < "3.12"', ] @@ -71,7 +71,7 @@ test = [ 'interrogate==1.7.0', 'isort==5.13.2', 'lxml==5.3.0', - 'mypy==1.14.1', + 'mypy==1.17.1', 'pip-audit==2.7.3', 'pycodestyle==2.12.1', 'pytest==8.3.4', @@ -85,7 +85,7 @@ test = [ 'types-psutil==6.1.0.20241221', 'types-pyinstaller==6.11.0.20241028', 'types-pyyaml==6.0.12.20241230', - 'types-requests==2.32.0.20241016', + 'types-requests==2.32.4.20250809', ] docs = [ 'black==24.10.0', diff --git a/requirements.txt b/requirements.txt index 9948b8779..7bb67b308 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,5 +8,5 @@ psutil==6.1.1 pydantic==2.10.5 PySide6==6.8.1 PyYAML==6.0.2 -requests==2.32.3 +requests==2.32.5 typing_extensions==4.12.2; python_version < '3.12' diff --git a/requirements_test.txt b/requirements_test.txt index c366777de..7e5edc856 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -3,7 +3,7 @@ flake8==7.1.1 interrogate==1.7.0 isort==5.13.2 lxml==5.3.0 -mypy==1.14.1 +mypy==1.17.1 pip-audit==2.7.3 pycodestyle==2.12.1 pytest==8.3.4 @@ -17,4 +17,4 @@ types-Pillow==10.2.0.20240822 types-psutil==6.1.0.20241221 types-pyinstaller==6.11.0.20241028 types-PyYAML==6.0.12.20241230 -types-requests==2.32.0.20241016 +types-requests==2.32.4.20250809 diff --git a/src/dcspy/__init__.py b/src/dcspy/__init__.py index 158411d50..9147f11b0 100644 --- a/src/dcspy/__init__.py +++ b/src/dcspy/__init__.py @@ -38,4 +38,4 @@ def get_config_yaml_item(key: str, /, default: ConfigValue | None = None) -> Con :param default: Default value if key not found :return: Value from configuration """ - return load_yaml(full_path=default_yaml).get(key, default) + return load_yaml(full_path=default_yaml).get(key, default) # type: ignore[return-value] diff --git a/src/dcspy/dcsbios.py b/src/dcspy/dcsbios.py index 20554ee17..e027a4836 100644 --- a/src/dcspy/dcsbios.py +++ b/src/dcspy/dcsbios.py @@ -5,6 +5,8 @@ from functools import partial from struct import pack +from dcspy.utils import SignalHandler + class ParserState(Enum): """Protocol parser states.""" @@ -125,7 +127,7 @@ def _wait_for_sync(self) -> None: class StringBuffer: """String buffer for DCS-BIOS protocol.""" - def __init__(self, parser: ProtocolParser, address: int, max_length: int, callback: Callable) -> None: + def __init__(self, parser: ProtocolParser, address: int, max_length: int, callback: Callable, sig_handler: SignalHandler | None = None) -> None: """ Initialize instance. @@ -133,6 +135,7 @@ def __init__(self, parser: ProtocolParser, address: int, max_length: int, callba :param address: :param max_length: :param callback: + :param sig_handler: Qt signal handler for progress notification """ self.__address = address self.__length = max_length @@ -140,6 +143,7 @@ def __init__(self, parser: ProtocolParser, address: int, max_length: int, callba self.buffer = bytearray(max_length) self.callbacks: set[Callable] = set() self.callbacks.add(callback) + self.sig_handler = sig_handler parser.write_callbacks.add(partial(self.on_dcsbios_write)) def set_char(self, index: int, char: int) -> None: @@ -169,13 +173,23 @@ def on_dcsbios_write(self, address: int, data: int) -> None: if address == 0xfffe and self.__dirty: self.__dirty = False str_buff = self.buffer.split(sep=b'\x00', maxsplit=1)[0].decode('latin-1') - for callback in self.callbacks: - callback(str_buff) + self.check_callbacks(str_buff) + + def check_callbacks(self, str_buff: str) -> None: + """ + Perform callbacks on the given string buffer and optionally emit signal. + + :param str_buff: The string to be passed to the callbacks. + """ + for callback in self.callbacks: + callback(str_buff) + if self.sig_handler: + self.sig_handler.emit(sig_name='count', value=(1, 0)) class IntegerBuffer: """Integer buffer for DCS-BIOS protocol.""" - def __init__(self, parser: ProtocolParser, address: int, mask: int, shift_by: int, callback: Callable) -> None: + def __init__(self, parser: ProtocolParser, address: int, mask: int, shift_by: int, callback: Callable, sig_handler: SignalHandler | None = None) -> None: """ Initialize instance. @@ -184,6 +198,7 @@ def __init__(self, parser: ProtocolParser, address: int, mask: int, shift_by: in :param mask: :param shift_by: :param callback: + :param sig_handler: Qt signal handler for progress notification """ self.__address = address self.__mask = mask @@ -191,6 +206,7 @@ def __init__(self, parser: ProtocolParser, address: int, mask: int, shift_by: in self.__value = int() self.callbacks: set[Callable] = set() self.callbacks.add(callback) + self.sig_handler = sig_handler parser.write_callbacks.add(partial(self.on_dcsbios_write)) def on_dcsbios_write(self, address: int, data: int) -> None: @@ -204,5 +220,15 @@ def on_dcsbios_write(self, address: int, data: int) -> None: value = (data & self.__mask) >> self.__shift_by if self.__value != value: self.__value = value - for callback in self.callbacks: - callback(value) + self.check_callbacks(value) + + def check_callbacks(self, value: int) -> None: + """ + Perform callbacks on the given integer value and optionally emit signal. + + :param value: The value to be passed to the callbacks. + """ + for callback in self.callbacks: + callback(value) + if self.sig_handler: + self.sig_handler.emit(sig_name='count', value=(1, 0)) diff --git a/src/dcspy/logitech.py b/src/dcspy/logitech.py index 1a0ec1508..e00f0b22c 100644 --- a/src/dcspy/logitech.py +++ b/src/dcspy/logitech.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from copy import copy from functools import partial from importlib import import_module @@ -14,7 +16,7 @@ from dcspy.models import (KEY_DOWN, SEND_ADDR, SUPPORTED_CRAFTS, TIME_BETWEEN_REQUESTS, AnyButton, Color, Gkey, LcdButton, LcdType, LogitechDeviceModel, MouseButton) from dcspy.sdk import key_sdk, lcd_sdk -from dcspy.utils import get_full_bios_for_plane, get_planes_list, rgba +from dcspy.utils import SignalHandler, get_full_bios_for_plane, get_planes_list, rgba LOG = getLogger(__name__) @@ -22,7 +24,7 @@ class LogitechDevice: """General Logitech device.""" - def __init__(self, parser: dcsbios.ProtocolParser, sock: socket, model: LogitechDeviceModel) -> None: + def __init__(self, parser: dcsbios.ProtocolParser, sock: socket, model: LogitechDeviceModel, sig_handler: SignalHandler | None = None) -> None: """ General Logitech device. @@ -44,6 +46,7 @@ def __init__(self, parser: dcsbios.ProtocolParser, sock: socket, model: Logitech success = self.key_sdk.logi_gkey_init() LOG.debug(f'G-Key is connected: {success}') self.plane = BasicAircraft(self.model.lcd_info) + self.sig_handler = sig_handler def text(self, message: list[tuple[str, Color]]) -> None: """ @@ -152,7 +155,8 @@ def _setup_plane_callback(self) -> None: for ctrl_name in self.plane.bios_data: ctrl = plane_bios.get_ctrl(ctrl_name=ctrl_name) dcsbios_buffer = getattr(dcsbios, ctrl.output.klass) # type: ignore[union-attr] - dcsbios_buffer(parser=self.parser, callback=partial(self.plane.set_bios, ctrl_name), **ctrl.output.args.model_dump()) # type: ignore[union-attr] + dcsbios_buffer(parser=self.parser, callback=partial(self.plane.set_bios, ctrl_name), + sig_handler=self.sig_handler, **ctrl.output.args.model_dump()) # type: ignore[union-attr] def gkey_callback_handler(self, key_idx: int, mode: int, key_down: int, mouse: int) -> None: """ diff --git a/src/dcspy/qt_gui.py b/src/dcspy/qt_gui.py index 5dc5804da..adaeb90cc 100644 --- a/src/dcspy/qt_gui.py +++ b/src/dcspy/qt_gui.py @@ -13,16 +13,15 @@ from pprint import pformat from shutil import copy, copytree, rmtree, unpack_archive from tempfile import gettempdir -from threading import Event, Thread +from threading import Event from time import sleep -from typing import Any from webbrowser import open_new_tab from packaging import version from pydantic_core import ValidationError from PySide6 import __version__ as pyside6_ver -from PySide6.QtCore import QAbstractItemModel, QFile, QIODevice, QMetaObject, QObject, QRunnable, Qt, QThreadPool, Signal, SignalInstance, Slot, qVersion -from PySide6.QtGui import QAction, QActionGroup, QFont, QIcon, QPixmap, QShowEvent, QStandardItemModel +from PySide6.QtCore import QAbstractItemModel, QEvent, QFile, QIODevice, QMetaObject, QRunnable, Qt, QThreadPool, Signal, Slot, qVersion +from PySide6.QtGui import QAction, QActionGroup, QBrush, QFont, QIcon, QPainter, QPen, QPixmap, QShowEvent, QStandardItemModel from PySide6.QtUiTools import QUiLoader from PySide6.QtWidgets import (QApplication, QButtonGroup, QCheckBox, QComboBox, QCompleter, QDialog, QDockWidget, QFileDialog, QGroupBox, QLabel, QLineEdit, QListView, QMainWindow, QMenu, QMessageBox, QProgressBar, QPushButton, QRadioButton, QSlider, QSpinBox, QStatusBar, @@ -33,8 +32,8 @@ FontsConfig, Gkey, GuiPlaneInputRequest, LcdButton, LcdMono, LcdType, LogitechDeviceModel, MouseButton, MsgBoxTypes, Release, RequestType, SystemData) from dcspy.starter import dcspy_run -from dcspy.utils import (CloneProgress, check_bios_ver, check_dcs_bios_entry, check_dcs_ver, check_github_repo, check_ver_at_github, collect_debug_data, - count_files, defaults_cfg, download_file, generate_bios_jsons_with_lupa, get_all_git_refs, get_depiction_of_ctrls, +from dcspy.utils import (CloneProgress, SignalHandler, check_bios_ver, check_dcs_bios_entry, check_dcs_ver, check_github_repo, check_ver_at_github, + collect_debug_data, count_files, defaults_cfg, download_file, generate_bios_jsons_with_lupa, get_all_git_refs, get_depiction_of_ctrls, get_inputs_for_plane, get_list_of_ctrls, get_plane_aliases, get_planes_list, get_version_string, is_git_exec_present, is_git_object, load_yaml, proc_is_running, run_command, run_pip_command, save_yaml) @@ -48,8 +47,48 @@ 'rb_g600': 3, 'rb_g300': 3, 'rb_g400': 3, 'rb_g700': 3, 'rb_g9': 3, 'rb_mx518': 3, 'rb_g402': 3, 'rb_g502': 3, 'rb_g602': 3} +class CircleLabel(QLabel): + """Blinking green circle.""" + + def __init__(self, color_on: Qt.GlobalColor = Qt.GlobalColor.green, **kwargs) -> None: + """ + Initialize the object with the given parameters. + + :param color_on: The color when the label is in `on` state, default is green. + """ + super().__init__(**kwargs) + self.state = False + self.pen = QPen(Qt.GlobalColor.black) + self.brush_on = QBrush(color_on) + self.brush_off = QBrush(Qt.GlobalColor.transparent) + + def paintEvent(self, event: QEvent) -> None: + """ + Paint event. + + :param event: the paint event that triggered the method + """ + painter = QPainter(self) + brush = self.brush_on if self.state else self.brush_off + painter.setPen(self.pen) + painter.setBrush(brush) + painter.drawEllipse(3, 3, 14, 14) + painter.end() + + @Slot() + def blink(self) -> None: + """Blink label with color defined in constructor.""" + for _ in range(3): + self.state = not self.state + self.repaint() + sleep(0.05) + self.state = not self.state + self.repaint() + + class DcsPyQtGui(QMainWindow): """PySide6 GUI for DCSpy.""" + blink_label = Signal() def __init__(self, cli_args=Namespace(), cfg_dict: DcspyConfigYaml | None = None) -> None: """ @@ -91,6 +130,8 @@ def __init__(self, cli_args=Namespace(), cfg_dict: DcspyConfigYaml | None = None self.dw_device.setFloating(True) self.bg_rb_input_iface = QButtonGroup(self) self.bg_rb_device = QButtonGroup(self) + self.total_b = 0 + self.count = 0 self._init_tray() self._init_combo_plane() self._init_menu_bar() @@ -98,6 +139,7 @@ def __init__(self, cli_args=Namespace(), cfg_dict: DcspyConfigYaml | None = None self._init_settings() self._init_devices() self._init_autosave() + self._init_statusbar() self._trigger_refresh_data() if self.cb_autoupdate_bios.isChecked(): @@ -236,6 +278,16 @@ def _init_autosave(self) -> None: for widget_name, trigger_method in widget_dict.items(): getattr(getattr(self, widget_name), trigger_method).connect(self.save_configuration) + def _init_statusbar(self) -> None: + """Initialize the statusbar.""" + self.status_circle = CircleLabel() + self.status_circle.setMinimumSize(20, 20) + self.status_label = QLabel() + self.setStatusBar(self.statusbar) + self.statusbar.addWidget(self.status_circle) + self.statusbar.addWidget(self.status_label) + self.blink_label.connect(self.status_circle.blink) + def _trigger_refresh_data(self): """Refresh widgets states and regenerates data.""" try: @@ -987,15 +1039,14 @@ def _start_bios_update(self, silence: bool) -> None: :param silence: Perform action with silence """ if self.cb_bios_live.isChecked(): - clone_worker = GitCloneWorker(git_ref=self.le_bios_live.text(), bios_path=self.bios_path, to_path=self.bios_repo_path, - repo=BIOS_REPO_NAME, silence=silence) - signal_handlers = { + signals_dict = { 'progress': self._progress_by_abs_value, 'stage': self.statusbar.showMessage, 'error': self._error_during_bios_update, 'result': self._clone_bios_completed, } - clone_worker.setup_signal_handlers(signal_handlers=signal_handlers) + clone_worker = GitCloneWorker(git_ref=self.le_bios_live.text(), sig_handler=SignalHandler(signals_dict=signals_dict), bios_path=self.bios_path, + to_path=self.bios_repo_path, repo=BIOS_REPO_NAME, silence=silence) self.threadpool.start(clone_worker) else: try: @@ -1246,10 +1297,11 @@ def _remove_dcs_bios_repo_dir(self) -> None: def _stop_clicked(self) -> None: """Set event to stop DCSpy.""" self.run_in_background(job=partial(self._fake_progress, total_time=0.3), - signal_handlers={'progress': self._progress_by_abs_value}) + signals_dict={'progress': self._progress_by_abs_value}) for rb_key in self.bg_rb_device.buttons(): if not rb_key.isChecked(): rb_key.setEnabled(True) + self.event_set() self.statusbar.showMessage('Start again or close DCSpy') self.pb_start.setEnabled(True) self.a_start.setEnabled(True) @@ -1260,13 +1312,15 @@ def _stop_clicked(self) -> None: self.gb_fonts.setEnabled(True) if self.rb_g19.isChecked(): self.cb_ded_font.setEnabled(True) - self.event_set() + self.total_b = 0 + self.count = 0 + self.status_label.setText('DCSpy client stopped') def _start_clicked(self) -> None: """Run real application in thread.""" LOG.debug(f'Local DCS-BIOS version: {self._check_local_bios()}') - self.run_in_background(job=partial(self._fake_progress, total_time=0.5), - signal_handlers={'progress': self._progress_by_abs_value}) + signal_dict = {'progress': self._progress_by_abs_value} + self.run_in_background(job=partial(self._fake_progress, total_time=0.5), signals_dict=signal_dict) for rb_key in self.bg_rb_device.buttons(): if not rb_key.isChecked(): rb_key.setEnabled(False) @@ -1274,10 +1328,6 @@ def _start_clicked(self) -> None: fonts_cfg = FontsConfig(name=self.le_font_name.text(), **getattr(self, f'{self.device.lcd_name}_font')) self.device.lcd_info.set_fonts(fonts_cfg) self.event = Event() - app_params = {'model': self.device, 'event': self.event} - app_thread = Thread(target=dcspy_run, kwargs=app_params) - app_thread.name = 'dcspy-app' - LOG.debug(f'Starting thread {app_thread} for: {app_params}') self.pb_start.setEnabled(False) self.a_start.setEnabled(False) self.pb_stop.setEnabled(True) @@ -1285,9 +1335,42 @@ def _start_clicked(self) -> None: self.le_dcsdir.setEnabled(False) self.le_biosdir.setEnabled(False) self.gb_fonts.setEnabled(False) - app_thread.start() - alive = 'working' if app_thread.is_alive() else 'not working' - self.statusbar.showMessage(f'DCSpy client: {alive}') + signal_dict = { + 'error': self._error_from_client, + 'count': self._count_dcsbios_changes + } + self.run_in_background(job=partial(dcspy_run, model=self.device, event=self.event), signals_dict=signal_dict) + self.statusbar.showMessage('DCSpy client started') + + def _error_from_client(self, exc_tuple) -> None: + """ + Show message box with error details. + + :param exc_tuple: Exception tuple + """ + exc_type, exc_val, exc_tb = exc_tuple + tb_string = ''.join(exc_tb) + LOG.debug(f"Client error:\n{tb_string}") + self._show_custom_msg_box( + kind_of=QMessageBox.Icon.Critical, + title='Error', + text=f'Huston we have a problem! Exception type: {exc_type} with value:{exc_val}', + info_txt='Please copy details and report issue or post on Discord, see Help menu.', + detail_txt=tb_string) + self._stop_clicked() + + def _count_dcsbios_changes(self, count_data: tuple[int, int]) -> None: + """ + Update the count of events and total bytes received. + + :param count_data: A tuple containing the count of events and number of bytes. + """ + count, no_bytes = count_data + self.count += count + self.total_b += no_bytes + if count: + self.blink_label.emit() + self.status_label.setText(f'Events received: {self.count} | Bytes received: {self.bytes_auto_unit(self.total_b)}') # <=><=><=><=><=><=><=><=><=><=><=> configuration <=><=><=><=><=><=><=><=><=><=><=> def apply_configuration(self, cfg: dict) -> None: @@ -1414,7 +1497,7 @@ def dcs_path(self) -> Path: return Path(self.le_dcsdir.text()) # <=><=><=><=><=><=><=><=><=><=><=> helpers <=><=><=><=><=><=><=><=><=><=><=> - def run_in_background(self, job: partial | Callable, signal_handlers: dict[str, Callable]) -> None: + def run_in_background(self, job: partial | Callable, signals_dict: dict[str, Callable]) -> None: """ Worker with signals callback to schedule a GUI job in the background. @@ -1425,9 +1508,8 @@ def run_in_background(self, job: partial | Callable, signal_handlers: dict[str, :param job: GUI method or function to run in background :param signal_handlers: Signals as keys: finished, error, result, progress and values as callable """ - progress = True if 'progress' in signal_handlers.keys() else False - worker = Worker(func=job, with_progress=progress) - worker.setup_signal_handlers(signal_handlers=signal_handlers) + sig_handler = SignalHandler(signals_dict=signals_dict) + worker = Worker(func=job, sig_handler=sig_handler) if isinstance(job, partial): job_name = job.func.__name__ args = job.args @@ -1436,17 +1518,16 @@ def run_in_background(self, job: partial | Callable, signal_handlers: dict[str, job_name = job.__name__ args = tuple() kwargs = dict() - signals = {signal: handler.__name__ for signal, handler in signal_handlers.items()} - LOG.debug(f'bg job for: {job_name} args: {args} kwargs: {kwargs} signals {signals}') + LOG.debug(f'bg job for: {job_name} args: {args} kwargs: {kwargs} signals: {sig_handler}') self.threadpool.start(worker) @staticmethod - def _fake_progress(progress_callback: SignalInstance, total_time: int, steps: int = 100, + def _fake_progress(sig_handler: SignalHandler, total_time: int, steps: int = 100, clean_after: bool = True, **kwargs) -> None: """ Make fake progress for progressbar. - :param progress_callback: Signal to update progress bar + :param sig_handler: Qt signal handler for progress notification :param total_time: Time for fill-up whole bar (in seconds) :param steps: Number of steps (default 100) :param clean_after: Clean progress bar when finish @@ -1454,13 +1535,13 @@ def _fake_progress(progress_callback: SignalInstance, total_time: int, steps: in done_event = kwargs.get('done_event', Event()) for progress_step in range(1, steps + 1): sleep(total_time / steps) - progress_callback.emit(progress_step) + sig_handler.emit(sig_name='progress', value=progress_step) if done_event.is_set(): - progress_callback.emit(100) + sig_handler.emit(sig_name='progress', value=100) break if clean_after: sleep(0.5) - progress_callback.emit(0) + sig_handler.emit(sig_name='progress', value=0) def _progress_by_abs_value(self, value: int) -> None: """ @@ -1572,6 +1653,20 @@ def event_set(self) -> None: """Set event to close running thread.""" self.event.set() + @staticmethod + def bytes_auto_unit(no_of_bytes: int) -> str: + """ + Convert the given number of bytes to a string representation with appropriate unit. + + :param no_of_bytes: The number of bytes to convert. + :return: The string representation of the converted bytes. + """ + _bytes = float(no_of_bytes) + for unit in ['B', 'kB', 'MB', 'GB']: + if _bytes < 1024.0: + return f'{_bytes:,.1f} {unit}' + _bytes /= 1024.0 + def activated(self, reason: QSystemTrayIcon.ActivationReason) -> None: """ Signal of activation. @@ -1770,56 +1865,22 @@ def showEvent(self, event: QShowEvent): self.l_info.setText(text) -class WorkerSignals(QObject): - """ - Defines the signals available from a running worker thread. - - Supported signals are: - * finished - no data - * error - tuple with exctype, value, traceback.format_exc() - * result - object/any type - data returned from processing - * progress - float between zero (0) and one (1) as indication of progress - * stage - string with current stage - """ - - finished = Signal() - error = Signal(tuple) - result = Signal(object) - progress = Signal(int) - stage = Signal(str) - - -class WorkerSignalsMixIn: - """Worker signals Mixin.""" - - def __init__(self): - """Signal handler for WorkerSignals.""" - self.signals = WorkerSignals() - - def setup_signal_handlers(self, signal_handlers: dict[str, Callable[[Any], None]]) -> None: - """ - Connect handlers to signals. - - :param signal_handlers: Dict with signals and handlers as value. - """ - for signal, handler in signal_handlers.items(): - getattr(self.signals, signal).connect(handler) - - -class Worker(QRunnable, WorkerSignalsMixIn): +class Worker(QRunnable): """Runnable worker.""" - def __init__(self, func: partial, with_progress: bool) -> None: + def __init__(self, func: partial, sig_handler: SignalHandler) -> None: """ Worker thread. Inherits from QRunnable to handler worker thread setup, signals and wrap-up. :param func: The function callback to run on worker thread + :param sig_handler: Qt signal handler for progress notification """ super().__init__() self.func = func - if with_progress: - self.func.keywords['progress_callback'] = self.signals.progress + self.sig_handler = sig_handler + if sig_handler.got_signals_for_interface(): + self.func.keywords['sig_handler'] = sig_handler @Slot() def run(self) -> None: @@ -1827,25 +1888,29 @@ def run(self) -> None: try: result = self.func() except Exception: - exctype, value = sys.exc_info()[:2] - self.signals.error.emit((exctype, value, traceback.format_exc())) + exctype, value, tb = sys.exc_info() + exc_tb = traceback.format_exception(exctype, value, tb) + self.sig_handler.emit(sig_name='error', value=(exctype, value, exc_tb)) + # exctype, value = sys.exc_info()[:2] + # self.signals.error.emit((exctype, value, traceback.format_exc())) else: - self.signals.result.emit(result) + self.sig_handler.emit(sig_name='result', value=result) finally: - self.signals.finished.emit() + self.sig_handler.emit(sig_name='finished') -class GitCloneWorker(QRunnable, WorkerSignalsMixIn): +class GitCloneWorker(QRunnable): """Worker for git clone with reporting progress.""" - def __init__(self, git_ref: str, bios_path: Path, to_path: Path, repo: str, silence: bool = False) -> None: + def __init__(self, git_ref: str, sig_handler: SignalHandler, bios_path: Path, to_path: Path, repo: str, silence: bool = False) -> None: """ Inherits from QRunnable to handler worker thread setup, signals and wrap-up. :param git_ref: Git reference - :param repo: Valid git repository user/name + :param sig_handler: Qt signal handler for progress notification :param bios_path: Path to DCS-BIOS :param to_path: Path to which the repository should be cloned to + :param repo: Valid git repository user/name :param silence: Perform action with silence """ super().__init__() @@ -1854,13 +1919,14 @@ def __init__(self, git_ref: str, bios_path: Path, to_path: Path, repo: str, sile self.to_path = to_path self.bios_path = bios_path self.silence = silence + self.sig_handler = sig_handler @Slot() def run(self): """Clone repository and report progress using special object CloneProgress.""" try: sha = check_github_repo(git_ref=self.git_ref, update=True, repo=self.repo, repo_dir=self.to_path, - progress=CloneProgress(self.signals.progress, self.signals.stage)) + progress=CloneProgress(sig_handler=self.sig_handler)) if not self.bios_path.is_symlink(): target = self.to_path / 'Scripts' / 'DCS-BIOS' cmd_symlink = f'"New-Item -ItemType SymbolicLink -Path \\"{self.bios_path}\\" -Target \\"{target}\\"' @@ -1870,12 +1936,15 @@ def run(self): sleep(0.8) LOG.debug(f'Directory: {self.bios_path} is symbolic link: {self.bios_path.is_symlink()}') except Exception: - exctype, value = sys.exc_info()[:2] - self.signals.error.emit((exctype, value, traceback.format_exc())) + exctype, value, tb = sys.exc_info() + exc_tb = traceback.format_exception(exctype, value, tb) + self.sig_handler.emit(sig_name='error', value=(exctype, value, exc_tb)) + # exctype, value = sys.exc_info()[:2] + # self.signals.error.emit((exctype, value, traceback.format_exc())) else: - self.signals.result.emit((sha, self.silence)) + self.sig_handler.emit(sig_name='result', value=(sha, self.silence)) finally: - self.signals.finished.emit() + self.sig_handler.emit(sig_name='finished') class UiLoader(QUiLoader): diff --git a/src/dcspy/starter.py b/src/dcspy/starter.py index e225c34a9..2ee052803 100644 --- a/src/dcspy/starter.py +++ b/src/dcspy/starter.py @@ -10,7 +10,7 @@ from dcspy.dcsbios import ProtocolParser from dcspy.logitech import LogitechDevice from dcspy.models import DCSPY_REPO_NAME, MULTICAST_IP, RECV_ADDR, Color, LogitechDeviceModel -from dcspy.utils import check_bios_ver, get_version_string +from dcspy.utils import SignalHandler, check_bios_ver, get_version_string LOG = getLogger(__name__) LOOP_FLAG = True @@ -18,7 +18,8 @@ __version__ = '3.6.3' -def _handle_connection(logi_device: LogitechDevice, parser: ProtocolParser, sock: socket.socket, ver_string: str, event: Event) -> None: +def _handle_connection(logi_device: LogitechDevice, parser: ProtocolParser, sock: socket.socket, ver_string: str, + event: Event, sig_handler: SignalHandler) -> None: """ Handle the main loop where all the magic is happened. @@ -36,6 +37,7 @@ def _handle_connection(logi_device: LogitechDevice, parser: ProtocolParser, sock dcs_bios_resp = sock.recv(2048) for int_byte in dcs_bios_resp: parser.process_byte(int_byte) + sig_handler.emit(sig_name='count', value=(0, len(dcs_bios_resp))) start_time = time() _load_new_plane_if_detected(logi_device) logi_device.button_handle() @@ -106,20 +108,21 @@ def _prepare_socket() -> socket.socket: return sock -def dcspy_run(model: LogitechDeviceModel, event: Event) -> None: +def dcspy_run(model: LogitechDeviceModel, event: Event, sig_handler: SignalHandler) -> None: """ Real starting point of DCSpy. :param model: Logitech device model :param event: stop event for the main loop + :param sig_handler: Qt signal handler for progress notification """ with _prepare_socket() as dcs_sock: parser = ProtocolParser() - logi_dev = LogitechDevice(parser=parser, sock=dcs_sock, model=model) + logi_dev = LogitechDevice(parser=parser, sock=dcs_sock, model=model, sig_handler=sig_handler) LOG.info(f'Loading: {str(logi_dev)}') LOG.debug(f'Loading: {repr(logi_dev)}') dcspy_ver = get_version_string(repo=DCSPY_REPO_NAME, current_ver=__version__, check=bool(get_config_yaml_item('check_ver'))) - _handle_connection(logi_device=logi_dev, parser=parser, sock=dcs_sock, ver_string=dcspy_ver, event=event) + _handle_connection(logi_device=logi_dev, parser=parser, sock=dcs_sock, ver_string=dcspy_ver, event=event, sig_handler=sig_handler) LOG.info('DCSpy stopped.') logi_dev.display = [(' DCSpy ', Color.orange), ('DCSpy stopped', Color.red), diff --git a/src/dcspy/utils.py b/src/dcspy/utils.py index c6b2c992e..f48777754 100644 --- a/src/dcspy/utils.py +++ b/src/dcspy/utils.py @@ -23,6 +23,7 @@ from packaging import version from PIL import ImageColor from psutil import process_iter +from PySide6.QtCore import QObject, Signal from requests import get from dcspy.models import (CONFIG_YAML, CTRL_LIST_SEPARATOR, DEFAULT_YAML_FILE, AnyButton, BiosValue, Color, ControlDepiction, ControlKeyData, DcsBiosPlaneData, @@ -395,21 +396,84 @@ def get_all_git_refs(repo_dir: Path) -> list[str]: return refs +class WorkerSignals(QObject): + """ + Defines the signals available from a running worker thread. + + Supported signals are: + * finished - no data + * error - tuple with exctype, value, traceback.format_exc() + * result - object/any type - data returned from processing + * progress - float between zero (0) and one (1) as indication of progress + * stage - string with current stage + * count - tuple of int as count of events + """ + + finished = Signal() + error = Signal(tuple) + result = Signal(object) + progress = Signal(int) + stage = Signal(str) + count = Signal(tuple) + + +class SignalHandler: + """QtSignal handler for GUI notification.""" + + def __init__(self, signals_dict: dict[str, Callable], signals: QObject = WorkerSignals()) -> None: + """ + Use for passing signals function and emit to Qt GUI. + + :param signals_dict: The keys are the signal names, and the values are the corresponding handler functions. + :param signals: QObject used for handling signals, the default is WorkerSignals class. + """ + self._sig_handler = signals_dict + self.signals = signals + for signal, handler in signals_dict.items(): + getattr(self.signals, signal).connect(handler) + + def got_signals_for_interface(self) -> bool: + """ + Check if there are progress or count signals for the interface. + + :return: True if there are signals for the interface, False otherwise. + """ + if self._sig_handler.get('progress', False): + return True + if self._sig_handler.get('count', False): + return True + return False + + def emit(self, sig_name: str, **kwargs) -> None: + """ + Emit the signal with the name and value. + + :param sig_name: The name of the signal to emit. + """ + value = kwargs.get('value', 'No value set') + if value == 'No value set': + getattr(self.signals, sig_name).emit() + else: + getattr(self.signals, sig_name).emit(value) + + def __str__(self) -> str: + signals = {signal: handler.__name__ for signal, handler in self._sig_handler.items()} + return f'{signals}' + + class CloneProgress(git.RemoteProgress): """Handler providing an interface to parse progress information emitted by git.""" OP_CODES: ClassVar[list[str]] = ['BEGIN', 'CHECKING_OUT', 'COMPRESSING', 'COUNTING', 'END', 'FINDING_SOURCES', 'RECEIVING', 'RESOLVING', 'WRITING'] OP_CODE_MAP: ClassVar[dict[int, str]] = {getattr(git.RemoteProgress, _op_code): _op_code for _op_code in OP_CODES} - def __init__(self, progress, stage) -> None: + def __init__(self, sig_handler: SignalHandler) -> None: """ Initialize the progress handler. - :param progress: Progress Qt6 signal - :param stage: Report stage Qt6 signal + :param sig_handler: Qt signal handler for progress notification """ super().__init__() - self.progress_signal = progress - self.stage_signal = stage + self.sig_handler = sig_handler def get_curr_op(self, op_code: int) -> str: """ @@ -431,10 +495,10 @@ def update(self, op_code: int, cur_count, max_count=None, message: str = ''): :param message: It contains the number of bytes transferred. It may be used for other purposes as well. """ if op_code & git.RemoteProgress.BEGIN: - self.stage_signal.emit(f'Git clone: {self.get_curr_op(op_code)}') + self.sig_handler.emit(sig_name='stage', value=f'Git clone: {self.get_curr_op(op_code)}') percentage = int(cur_count / max_count * 100) if max_count else 0 - self.progress_signal.emit(percentage) + self.sig_handler.emit(sig_name='progress', value=percentage) def collect_debug_data() -> Path: diff --git a/tests/test_utils.py b/tests/test_utils.py index 8f0772d20..97609fa5d 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,7 +1,7 @@ from os import environ, makedirs from pathlib import Path from sys import platform -from unittest.mock import MagicMock, PropertyMock, mock_open, patch +from unittest.mock import MagicMock, Mock, PropertyMock, mock_open, patch from packaging import version from pytest import mark, raises @@ -402,23 +402,15 @@ def test_get_planes_list(test_dcs_bios): def test_clone_progress(): - from PySide6.QtCore import QObject, Signal - - class Signals(QObject): - progress = Signal(int) - stage = Signal(str) - def update_progress(progress): assert progress == 100 def update_label(stage): assert stage == 'Git clone: Counting' - signals = Signals() - signals.progress.connect(update_progress) - signals.stage.connect(update_label) - clone = utils.CloneProgress(signals.progress, signals.stage) - clone.update(5, 1, 1, 'test') + sig_handler = utils.SignalHandler(signals_dict={'stage': update_label, 'progress': update_progress}) + clone = utils.CloneProgress(sig_handler=sig_handler) + clone.update(5, 10, 10, 'test') @mark.skipif(condition=platform != 'win32', reason='Run only on Windows') @@ -478,3 +470,27 @@ def test_generate_bios_jsons_with_lupa(test_saved_games): ]) def test_color(color, mode, result): assert utils.rgba(color, mode=mode) == result + + +def test_emit_signal_handler(): + mm = Mock(spec=utils.WorkerSignals) + sh = utils.SignalHandler(signals_dict={'stage': print, 'progress': print, 'finished': print}, signals=mm) + + sh.emit(sig_name='stage', value='1') + mm.stage.emit.assert_called_once_with('1') + sh.emit(sig_name='progress', value=2) + mm.progress.emit.assert_called_once_with(2) + sh.emit(sig_name='finished') + mm.finished.emit.assert_called_once_with() + + +@mark.parametrize('sig_dict,result', [ + ({'count': print}, True), + ({'progress': print}, True), + ({'finished': print}, False), +]) +def test_got_signals_in_signal_handler(sig_dict, result): + mm = Mock(spec=utils.WorkerSignals) + sh = utils.SignalHandler(signals_dict=sig_dict, signals=mm) + + assert sh.got_signals_for_interface() is result diff --git a/uml/classes.puml b/uml/classes.puml index 7c4dfad26..d49f69097 100644 --- a/uml/classes.puml +++ b/uml/classes.puml @@ -15,14 +15,18 @@ package dcsbios { class StringBuffer { + buffer : bytearray + callbacks: Set[Callable] + + sig_handler: SignalHandler + __init__(parser, address, max_length, callback) + set_char(index, char) + on_dcsbios_write(address, data) + + check_callbacks(str) } class IntegerBuffer { + callbacks: Set[Callable] + + sig_handler: SignalHandler + __init__(parser, address, mask, shift_by, callback) + on_dcsbios_write(address, data) + + check_callbacks(int) } class ParserState <<(E,yellow)>> { ADDRESS_LOW = 1 @@ -47,6 +51,7 @@ package logitech { + lcdbutton_pressed = False : bool + model: LogitechDeviceModel + display(message: List[Tuple[str, Color]]) -> List[Tuple[str, Color]] + + sig_handler: SignalHandler + __init__(ProtocolParser, socket, FontsConfig) + detecting_plane() + load_new_plane(str) @@ -117,7 +122,16 @@ package utils { + get_request(Union[LcdButton, Gkey, MouseButton]]) -> RequestModel + set_request(Union[LcdButton, Gkey, MouseButton]], str) } + class SignalHandler{ + - _sig_handler: Dict[str, Callable] + + signals: WorkerSignals + + got_signals_for_interface() -> bool + + emit(str, object) + } KeyRequest -* BasicAircraft + SignalHandler -* LogitechDevice + SignalHandler -* IntegerBuffer + SignalHandler -* StringBuffer } package models {