diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fcc1fd8..46ca674 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,7 @@ jobs: test: runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: os: [macos-latest, ubuntu-latest, windows-latest] # Temporarily limit versions diff --git a/CHANGELOG.md b/CHANGELOG.md index 05b1637..5953c30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Re-implemented using MVC design pattern + ### Added -- Prevent applying settings while active +- Prevent user from applying new settings while active + +### Removed + +- Internal global variables --- ## [0.1.8] - 2024-06-26 diff --git a/pyproject.toml b/pyproject.toml index 7585ff8..c12150c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "can-explorer" -version = "0.1.8" +version = "0.2.0" description = "Visualize CAN bus payloads in real time" license = "GPL-3.0-or-later" authors = ["TJ "] @@ -10,7 +10,7 @@ repository = "https://github.com/Tbruno25/can-explorer" keywords = ["can", "bus", "canbus", "can_bus", "dbc"] [tool.poetry.dependencies] -python = ">=3.8, <4.0" +python = ">=3.10, <4.0" pyserial = "^3.5" python-can = "^4.1.0" dearpygui = "^1.9.0" @@ -18,6 +18,7 @@ dearpygui-ext = "^0.9.5" [tool.poetry.group.dev.dependencies] pytest = "^7.2.2" +pytest-order = "^1.2.1" mypy = "^1.2.0" pillow = "^10.3.0" pyautogui = "^0.9.54" @@ -28,7 +29,6 @@ pyvirtualdisplay = [ ] msvc-runtime = {version = "^14.34.31931", platform = "windows"} - [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" @@ -36,5 +36,8 @@ build-backend = "poetry.core.masonry.api" [tool.mypy] ignore_missing_imports = true +[tool.ruff.per-file-ignores] +"__init__.py" = ["F401"] + [tool.poetry.scripts] can-explorer = "can_explorer.__main__:__main__" diff --git a/src/can_explorer/__init__.py b/src/can_explorer/__init__.py index e69de29..bfd1edd 100644 --- a/src/can_explorer/__init__.py +++ b/src/can_explorer/__init__.py @@ -0,0 +1,6 @@ +from can_explorer.app import CanExplorer +from can_explorer.controllers import Controller +from can_explorer.models import PlotModel +from can_explorer.views import MainView + +_all__ = ["PlotModel", "MainView", "Controller", "CanExplorer"] diff --git a/src/can_explorer/__main__.py b/src/can_explorer/__main__.py index 7fb1857..3f17246 100644 --- a/src/can_explorer/__main__.py +++ b/src/can_explorer/__main__.py @@ -1,17 +1,18 @@ import argparse import sys -from can_explorer import app +from can_explorer import CanExplorer from can_explorer.resources.demo import demo_config parser = argparse.ArgumentParser() parser.add_argument("--demo", action="store_true") args = parser.parse_args() +app = CanExplorer() if args.demo: - app.main(demo_config) + app.run(test_config=demo_config) else: - app.main() + app.run() sys.exit() diff --git a/src/can_explorer/app.py b/src/can_explorer/app.py index d2daa1c..d560916 100644 --- a/src/can_explorer/app.py +++ b/src/can_explorer/app.py @@ -1,187 +1,80 @@ -import enum +from __future__ import annotations + import logging import sys -import threading -from typing import Callable, Optional +from collections.abc import Callable -import can -import can.player import dearpygui.dearpygui as dpg -from can_explorer import can_bus, layout, plotting -from can_explorer.layout import Default - - -class State(enum.Flag): - ACTIVE = True - STOPPED = False - - -class MainApp: - _rate = 0.05 - _cancel = threading.Event() - _state = State.STOPPED - _worker: threading.Thread - - bus: Optional[can.bus.BusABC] = None - can_recorder = can_bus.Recorder() - plot_manager = plotting.PlotManager() - - @property - def state(self) -> State: - return self._state - - def is_active(self) -> bool: - return bool(self._state) - - def repopulate(self) -> None: - """ - Repopulate all plots in ascending order. - """ - self.plot_manager.clear_all() - for can_id, payload_buffer in sorted(self.can_recorder.items()): - self.plot_manager.add(can_id, payload_buffer) - - def _get_worker(self) -> threading.Thread: - """ - Get the main loop worker thread. - - Returns: - threading.Thread: Worker - """ - - def loop() -> None: - while not self._cancel.wait(self._rate): - # Note: must convert can_recorder to avoid runtime error - for can_id in tuple(self.can_recorder): - if can_id not in self.plot_manager(): - self.repopulate() - break - else: - self.plot_manager.update(can_id) - self._cancel.clear() - - return threading.Thread(target=loop, daemon=True) - - def start(self) -> None: - """ - Initialize and start app loop. - - Raises: - Exception: If CAN bus does not exist. - """ - if self.bus is None: - raise RuntimeError("Must apply settings before starting") - - self.can_recorder.set_bus(self.bus) - self.can_recorder.start() - - self._worker = self._get_worker() - self._worker.start() - - self._state = State.ACTIVE - - def stop(self) -> None: - """ - Stop the app loop. - """ - self.can_recorder.stop() - self._cancel.set() - self._worker.join() - - self._state = State.STOPPED - - def set_bus(self, bus: can.BusABC) -> None: - """ - Set CAN bus to use during app loop. - """ - self.bus = bus - - -app = MainApp() - - -def start_stop_button_callback(sender, app_data, user_data) -> None: - app.stop() if app.is_active() else app.start() - layout.set_main_button_label(app.state) - - -def clear_button_callback(sender, app_data, user_data) -> None: - app.plot_manager.clear_all() - - -def plot_buffer_slider_callback(sender, app_data, user_data) -> None: - app.plot_manager.set_limit(layout.get_settings_plot_buffer()) - - -def plot_height_slider_callback(sender, app_data, user_data) -> None: - app.plot_manager.set_height(layout.get_settings_plot_height()) - - -def settings_apply_button_callback(sender, app_data, user_data) -> None: - user_settings = dict( - interface=layout.get_settings_interface(), - channel=layout.get_settings_channel(), - bitrate=layout.get_settings_baudrate(), - ) - if app.is_active(): - raise RuntimeError("App must be stopped before applying new settings") - bus = can.Bus(**{k: v for k, v in user_settings.items() if v}) # type: ignore - app.set_bus(bus) - - -def settings_can_id_format_callback(sender, app_data, user_data) -> None: - app.plot_manager.set_id_format(layout.get_settings_id_format()) - app.repopulate() - - -def setup(): - dpg.create_context() +from can_explorer import can_bus +from can_explorer.configs import Default +from can_explorer.controllers import Controller +from can_explorer.models import PlotModel +from can_explorer.views import MainView - with dpg.window() as app_main: - layout.create() - layout.set_settings_interface_options(can_bus.INTERFACES) - layout.set_settings_baudrate_options(can_bus.BAUDRATES) - layout.set_settings_apply_button_callback(settings_apply_button_callback) - layout.set_settings_can_id_format_callback(settings_can_id_format_callback) +class CanExplorer: + def __init__(self) -> None: + self.model = PlotModel() + self.view = MainView() + self.controller = Controller(self.model, self.view) - layout.set_main_button_label(app.state) - layout.set_main_button_callback(start_stop_button_callback) - layout.set_clear_button_callback(clear_button_callback) + def setup(self): + dpg.create_context() - layout.set_plot_buffer_slider_callback(plot_buffer_slider_callback) - layout.set_plot_height_slider_callback(plot_height_slider_callback) + with dpg.window() as app: + self.view.setup() - dpg.create_viewport(title=Default.TITLE, width=Default.WIDTH, height=Default.HEIGHT) - dpg.set_viewport_resize_callback(layout.resize) - dpg.setup_dearpygui() - layout.resize() + self.view.settings.set_interface_options(can_bus.INTERFACES) + self.view.settings.set_baudrate_options(can_bus.BAUDRATES) + self.view.settings.set_apply_button_callback( + self.controller.settings_apply_button_callback + ) + self.view.settings.set_can_id_format_callback( + self.controller.settings_can_id_format_callback + ) - dpg.set_primary_window(app_main, True) + self.view.set_main_button_label(self.controller.state) + self.view.set_main_button_callback(self.controller.start_stop_button_callback) + self.view.set_clear_button_callback(self.controller.clear_button_callback) + self.view.set_plot_buffer_slider_callback( + self.controller.plot_buffer_slider_callback + ) + self.view.set_plot_height_slider_callback( + self.controller.plot_height_slider_callback + ) -def teardown(): - dpg.destroy_context() + dpg.create_viewport( + title=Default.TITLE, width=Default.WIDTH, height=Default.HEIGHT + ) + dpg.set_viewport_resize_callback(self.view.resize) + dpg.setup_dearpygui() + self.view.resize() + dpg.set_primary_window(app, True) -def exception_handler(exc_type, exc_value, exc_traceback): - logging.debug(msg="ExceptionHandler", exc_info=(exc_type, exc_value, exc_traceback)) - layout.popup_error(name=exc_type.__name__, info=exc_value) + @staticmethod + def teardown(): + dpg.destroy_context() + def exception_handler(self, exc_type, exc_value, exc_traceback): + logging.debug( + msg="ExceptionHandler", exc_info=(exc_type, exc_value, exc_traceback) + ) + self.view.popup_error(name=exc_type.__name__, info=exc_value) -def main(test_config: Optional[Callable] = None): - setup() + def run(self, test_config: Callable | None = None, show: bool = True): + self.setup() - if test_config: - test_config() + if test_config: + test_config(self) - sys.excepthook = exception_handler - dpg.show_viewport() - dpg.start_dearpygui() + sys.excepthook = self.exception_handler - teardown() + if show: + dpg.show_viewport() + dpg.start_dearpygui() -if __name__ == "__main__": - main() + self.teardown() diff --git a/src/can_explorer/can_bus.py b/src/can_explorer/can_bus.py index ab0d7f9..d4b8073 100644 --- a/src/can_explorer/can_bus.py +++ b/src/can_explorer/can_bus.py @@ -1,34 +1,37 @@ from __future__ import annotations from collections import defaultdict, deque +from random import randint from typing import Final +from can import Message from can.bus import BusABC from can.interfaces import VALID_INTERFACES -from can.listener import Listener +from can.listener import Listener as _Listener from can.notifier import Notifier +from can_explorer.configs import Default + INTERFACES: Final = sorted(list(VALID_INTERFACES)) _BAUDRATES = [33_333, 125_000, 250_000, 500_000, 1_000_000] BAUDRATES: Final = [format(i, "_d") for i in _BAUDRATES] -class _Listener(Listener): - def __init__(self, buffer: Recorder, *args, **kwargs): - self.buffer = buffer - super().__init__(*args, **kwargs) - - def on_message_received(self, msg) -> None: - val = int.from_bytes(msg.data, byteorder="big") - self.buffer[msg.arbitration_id].append(val) +def generate_random_can_message() -> Message: + """ + Generate a random CAN message. + """ + message_id = randint(1, 25) + data_length = randint(1, 8) + data = (randint(0, 255) for _ in range(data_length)) + return Message(arbitration_id=message_id, data=data) class PayloadBuffer(deque): - MIN = 50 - MAX = 2500 - def __init__(self): + self.MIN = Default.BUFFER_MIN + self.MAX = Default.BUFFER_MAX super().__init__([0] * self.MAX, maxlen=self.MAX) def __getitem__(self, index) -> tuple: # type: ignore [override] @@ -39,14 +42,23 @@ def __getitem__(self, index) -> tuple: # type: ignore [override] return tuple(deque.__getitem__(self, index)) -class Recorder(defaultdict): +class Listener(_Listener): + def __init__(self, recorder: Recorder, *args, **kwargs): + self.recorder = recorder + super().__init__(*args, **kwargs) + + def on_message_received(self, message) -> None: + self.recorder.add_message(message) + + +class Recorder: _active = False - _notifier: Notifier - _listener: _Listener _bus: BusABC + _listener: Listener + _notifier: Notifier def __init__(self): - super().__init__(PayloadBuffer) + self._data = defaultdict(PayloadBuffer) def is_active(self) -> bool: return self._active @@ -55,7 +67,10 @@ def start(self) -> None: if self.is_active(): return - self._listener = _Listener(self) + if self._bus is None: + raise Exception("Error: must set bus before starting.") + + self._listener = Listener(self) self._notifier = Notifier(self._bus, [self._listener]) self._active = True @@ -63,8 +78,18 @@ def stop(self) -> None: if not self.is_active(): return - self._notifier.stop() # type: ignore [union-attr] + self._notifier.stop() self._active = False + def add_message(self, message: Message) -> None: + val = int.from_bytes(message.data, byteorder="big") + self._data[message.arbitration_id].append(val) + + def clear_data(self) -> None: + self._data.clear() + + def get_data(self) -> dict: + return self._data.copy() + def set_bus(self, bus: BusABC) -> None: self._bus = bus diff --git a/src/can_explorer/configs.py b/src/can_explorer/configs.py new file mode 100644 index 0000000..99bed51 --- /dev/null +++ b/src/can_explorer/configs.py @@ -0,0 +1,23 @@ +from typing import Final + +from can_explorer.resources import DIR_PATH as RESOURCES_DIR +from can_explorer.resources import HOST_OS + + +class Default: + WIDTH: Final = 600 + HEIGHT: Final = 600 + REFRESH_RATE: Final = 0.05 + BACKGROUND: Final = (50, 50, 50, 255) + FONT_HEIGHT: Final = 14 + PLOT_HEIGHT: Final = 100 + PLOT_HEIGHT_MAX: Final = 500 + BUFFER_MIN: Final = 50 + BUFFER_MAX: Final = 2500 + BUFFER_SIZE: Final = 100 + ID_FORMAT: Final = hex + LABEL_COLUMN_WIDTH: Final = 15 + PLOT_COLUMN_WIDTH: Final = 85 + TITLE: Final = "CAN Explorer" + FONT: Final = RESOURCES_DIR / "Inter-Medium.ttf" + FOOTER_OFFSET: Final = 50 if HOST_OS == "linux" else 85 diff --git a/src/can_explorer/controllers.py b/src/can_explorer/controllers.py new file mode 100644 index 0000000..aa445b0 --- /dev/null +++ b/src/can_explorer/controllers.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +import enum +from threading import current_thread +from typing import cast + +import can +from can.bus import BusABC + +from can_explorer.can_bus import Recorder +from can_explorer.configs import Default +from can_explorer.models import PlotModel +from can_explorer.resources import StoppableThread +from can_explorer.views import MainView + + +class State(enum.Flag): + ACTIVE = True + STOPPED = False + + +class Controller: + def __init__( + self, + model: PlotModel, + view: MainView, + bus: BusABC | None = None, + recorder: Recorder | None = None, + refresh_rate: float | None = Default.REFRESH_RATE, + ) -> None: + self.model = model + self.view = view + self.recorder = Recorder() if recorder is None else recorder + + self._bus = bus + self._rate = refresh_rate + self._state = State.STOPPED + self._worker: StoppableThread | None = None + + @property + def state(self) -> State: + return self._state + + @property + def bus(self) -> BusABC | None: + return self._bus + + @property + def worker(self) -> StoppableThread: + if self._worker is None: + raise RuntimeError("Worker not set.") + return self._worker + + def is_active(self) -> bool: + return bool(self.state) + + def set_bus(self, bus: can.BusABC) -> None: + """ + Set CAN bus to use during controller loop. + """ + self._bus = bus + + def start(self) -> None: + """ + Initialize and start the controller loop. + + Raises: + Exception: If CAN bus does not exist. + """ + + if self.bus is None: + raise RuntimeError("Must apply settings before starting") + + self.recorder.set_bus(self.bus) + self.recorder.start() + + self.create_worker_thread() + self.worker.start() + + self.view.set_main_button_label(True) + + self._state = State.ACTIVE + + def stop(self) -> None: + """ + Stop the controller loop. + """ + self.recorder.stop() + self.worker.stop() + + self.view.set_main_button_label(False) + + self._state = State.STOPPED + + def repopulate(self) -> None: + """ + Repopulate all plots in ascending order. + """ + self.view.plot.clear() + for can_id, plot_data in sorted(self.model.get_plots().items()): + self.view.plot.update(can_id, plot_data) + + def _worker_loop(self) -> None: + thread = cast(StoppableThread, current_thread()) + while not thread.cancel.wait(self._rate): + current_data = self.recorder.get_data() + current_plots = self.model.get_plots() + refresh = False + + for can_id, payloads in current_data.items(): + if can_id not in current_plots: + refresh = True + + self.model.update(can_id, payloads) + plot_data = self.model.get_plot_data(can_id) + self.view.plot.update(can_id, plot_data) + + if refresh: + self.repopulate() + + def create_worker_thread(self) -> None: + self._worker = StoppableThread(target=self._worker_loop, daemon=True) + + def start_stop_button_callback(self, *args, **kwargs) -> None: + self.stop() if self.is_active() else self.start() + + def clear_button_callback(self, *args, **kwargs) -> None: + self.model.clear() + self.view.plot.clear() + + def plot_buffer_slider_callback(self, *args, **kwargs) -> None: + self.model.set_limit(self.view.get_plot_buffer()) + for can_id, payloads in self.recorder.get_data().items(): + self.model.update(can_id, payloads) + plot_data = self.model.get_plot_data(can_id) + self.view.plot.update(can_id, plot_data) + + def plot_height_slider_callback(self, *args, **kwargs) -> None: + self.view.plot.clear() + self.view.plot.set_height(self.view.get_plot_height()) + self.repopulate() + + def settings_apply_button_callback(self, *args, **kwargs) -> None: + if self.is_active(): + raise RuntimeError("App must be stopped before applying new settings") + + user_settings = dict( + interface=self.view.settings.get_interface(), + channel=self.view.settings.get_channel(), + baudrate=self.view.settings.get_baudrate(), + ) + + bus = can.Bus(**{k: v for k, v in user_settings.items() if v}) # type: ignore [arg-type] + self.set_bus(bus) + + def settings_can_id_format_callback(self, *args, **kwargs) -> None: + self.view.plot.set_format(self.view.settings.get_id_format()) + self.repopulate() diff --git a/src/can_explorer/layout.py b/src/can_explorer/layout.py deleted file mode 100644 index 2920b8b..0000000 --- a/src/can_explorer/layout.py +++ /dev/null @@ -1,353 +0,0 @@ -from enum import Enum, Flag, auto, unique -from typing import Callable, Final, Iterable, Union, cast - -import dearpygui.dearpygui as dpg -from dearpygui_ext.themes import create_theme_imgui_light - -from can_explorer.can_bus import PayloadBuffer -from can_explorer.resources import DIR_PATH as RESOURCES_DIR -from can_explorer.resources import HOST_OS, Percentage - - -class Default: - WIDTH: Final = 600 - HEIGHT: Final = 600 - FONT_HEIGHT: Final = 14 - PLOT_HEIGHT: Final = 100 - BUFFER_SIZE: Final = 100 - ID_FORMAT: Final = hex - TITLE: Final = "CAN Explorer" - FONT: Final = RESOURCES_DIR / "Inter-Medium.ttf" - FOOTER_OFFSET: Final = 50 if HOST_OS == "linux" else 85 - - -class Font: - DEFAULT: int - LABEL: int - - -class Theme: - DEFAULT: int - LIGHT: int - - -@unique -class Tag(str, Enum): - HEADER = auto() - BODY = auto() - FOOTER = auto() - MAIN_BUTTON = auto() - CLEAR_BUTTON = auto() - TAB_VIEWER = auto() - TAB_SETTINGS = auto() - SETTINGS_PLOT_BUFFER = auto() - SETTINGS_PLOT_HEIGHT = auto() - SETTINGS_INTERFACE = auto() - SETTINGS_CHANNEL = auto() - SETTINGS_BAUDRATE = auto() - SETTINGS_APPLY = auto() - SETTINGS_ID_FORMAT = auto() - - -class PercentageWidthTableRow: - # https://github.com/hoffstadt/DearPyGui/discussions/1306 - - def __init__(self, **kwargs): - self.table_id = dpg.add_table( - header_row=False, - policy=dpg.mvTable_SizingStretchProp, - **kwargs, - ) - self.stage_id = dpg.add_stage() - dpg.push_container_stack(self.stage_id) - - def add_widget(self, uuid, percentage): - dpg.add_table_column( - init_width_or_weight=percentage / 100.0, parent=self.table_id - ) - dpg.set_item_width(uuid, -1) - - def submit(self): - dpg.pop_container_stack() - with dpg.table_row(parent=self.table_id): - dpg.unstage(self.stage_id) - - -class PlotTable(PercentageWidthTableRow): - COLUMN_1_WIDTH: Final = 15 - COLUMN_2_WIDTH: Final = 85 - - def __init__(self, **kwargs): - super().__init__(parent=Tag.TAB_VIEWER, **kwargs) - - def add_label(self, uuid): - return super().add_widget(uuid, self.COLUMN_1_WIDTH) - - def add_plot(self, uuid): - return super().add_widget(uuid, self.COLUMN_2_WIDTH) - - -def _init_fonts(): - global Font - with dpg.font_registry(): - Font.DEFAULT = dpg.add_font(Default.FONT, Default.FONT_HEIGHT) - Font.LABEL = dpg.add_font(Default.FONT, Default.FONT_HEIGHT * 1.75) - - dpg.bind_font(Font.DEFAULT) - - -def _init_themes(): - global Theme - - default_background = (50, 50, 50, 255) - with dpg.theme() as default: - with dpg.theme_component(dpg.mvButton, enabled_state=False): - dpg.add_theme_color(dpg.mvThemeCol_ButtonHovered, default_background) - dpg.add_theme_color(dpg.mvThemeCol_ButtonActive, default_background) - - Theme.DEFAULT = default - Theme.LIGHT = create_theme_imgui_light() - - dpg.bind_theme(default) - - -def _header() -> None: - def tab_callback(sender, app_data, user_data) -> None: - current_tab = dpg.get_item_label(app_data) - - if current_tab == "Viewer": - dpg.configure_item(Tag.TAB_VIEWER, show=True) - dpg.configure_item(Tag.TAB_SETTINGS, show=False) - else: - dpg.configure_item(Tag.TAB_VIEWER, show=False) - dpg.configure_item(Tag.TAB_SETTINGS, show=True) - - with dpg.tab_bar(tag=Tag.HEADER, callback=tab_callback): - dpg.add_tab(label="Viewer") - dpg.add_tab(label="Settings") - - -def _body() -> None: - with dpg.child_window(tag=Tag.BODY, border=False): - with dpg.group(tag=Tag.TAB_VIEWER, show=True): - _viewer_tab() - with dpg.group(tag=Tag.TAB_SETTINGS, show=False): - _settings_tab() - - -def _footer() -> None: - with dpg.child_window(tag=Tag.FOOTER, height=110, border=False, no_scrollbar=True): - dpg.add_spacer(height=2) - dpg.add_separator() - dpg.add_spacer(height=2) - - with dpg.table(header_row=False): - dpg.add_table_column() - dpg.add_table_column() - with dpg.table_row(): - with dpg.group(horizontal=True): - dpg.add_text("Message Buffer Size") - dpg.add_spacer() - dpg.add_slider_int( - tag=Tag.SETTINGS_PLOT_BUFFER, - width=-1, - default_value=Percentage.get( - Default.BUFFER_SIZE, PayloadBuffer.MAX - ), - min_value=2, - max_value=100, - clamped=True, - format="%d%%", - ) - - with dpg.group(horizontal=True): - dpg.add_text("Plot Height") - dpg.add_spacer() - dpg.add_slider_int( - tag=Tag.SETTINGS_PLOT_HEIGHT, - width=-1, - default_value=Percentage.get(Default.PLOT_HEIGHT, 500), - min_value=10, - max_value=100, - clamped=True, - format="%d%%", - ) - dpg.add_spacer(height=2) - - dpg.add_separator() - dpg.add_spacer(height=2) - with dpg.group(horizontal=True): - dpg.add_button( - tag=Tag.MAIN_BUTTON, - width=-100, - height=50, - ) - dpg.add_button( - tag=Tag.CLEAR_BUTTON, - label="Clear", - width=-1, - height=50, - ) - - -def _viewer_tab() -> None: - ... - - -def _settings_tab() -> None: - with dpg.collapsing_header(label="CAN Bus", default_open=True): - dpg.add_combo(tag=Tag.SETTINGS_INTERFACE, label="Interface") - dpg.add_input_text(tag=Tag.SETTINGS_CHANNEL, label="Channel") - dpg.add_combo(tag=Tag.SETTINGS_BAUDRATE, label="Baudrate") - dpg.add_spacer(height=5) - dpg.add_button(tag=Tag.SETTINGS_APPLY, label="Apply", height=30) - dpg.add_spacer(height=5) - - with dpg.collapsing_header(label="GUI"): - with dpg.group(horizontal=True): - dpg.add_text("ID Format") - dpg.add_radio_button( - ["Hex", "Dec"], - tag=Tag.SETTINGS_ID_FORMAT, - horizontal=True, - ) - with dpg.group(horizontal=True): - dpg.add_text("Theme") - dpg.add_radio_button( - ["Default", "Light"], - horizontal=True, - callback=lambda sender: dpg.bind_theme( - getattr(Theme, dpg.get_value(sender).upper()) - ), - ) - - dpg.add_button( - label="Launch Font Manager", width=-1, callback=dpg.show_font_manager - ) - dpg.add_button( - label="Launch Style Editor", width=-1, callback=dpg.show_style_editor - ) - dpg.add_spacer(height=5) - - -def create() -> None: - _init_fonts() - _init_themes() - _header() - _body() - _footer() - - -def resize() -> None: - dpg.set_item_height( - Tag.BODY, - ( - dpg.get_viewport_height() - - dpg.get_item_height(Tag.FOOTER) - - Default.FOOTER_OFFSET - ), - ) - dpg.set_item_width(Tag.SETTINGS_APPLY, dpg.get_viewport_width() // 4) - - -def popup_error(name: Union[str, Exception], info: Union[str, Exception]) -> None: - # https://github.com/hoffstadt/DearPyGui/discussions/1308 - - # guarantee these commands happen in the same frame - with dpg.mutex(): - viewport_width = dpg.get_viewport_client_width() - viewport_height = dpg.get_viewport_client_height() - - with dpg.window(label="ERROR", modal=True, no_close=True) as modal_id: - dpg.add_text(name, color=(255, 0, 0)) # red - dpg.add_separator() - dpg.add_text(info) - with dpg.group(): - dpg.add_button( - label="Close", - width=-1, - user_data=(modal_id, True), - callback=lambda sender, app_data, user_data: dpg.delete_item( - user_data[0] - ), - ) - - # guarantee these commands happen in another frame - dpg.split_frame() - width = dpg.get_item_width(modal_id) - height = dpg.get_item_height(modal_id) - dpg.set_item_pos( - modal_id, - [viewport_width // 2 - width // 2, viewport_height // 2 - height // 2], - ) - - -def get_settings_plot_buffer() -> int: - max_value = PayloadBuffer.MAX - percentage = dpg.get_value(Tag.SETTINGS_PLOT_BUFFER) - return Percentage.reverse(percentage, max_value) - - -def get_settings_plot_height() -> int: - max_value = 500 # px - percentage = dpg.get_value(Tag.SETTINGS_PLOT_HEIGHT) - return Percentage.reverse(percentage, max_value) - - -def get_settings_interface() -> str: - return dpg.get_value(Tag.SETTINGS_INTERFACE) - - -def get_settings_channel() -> str: - return dpg.get_value(Tag.SETTINGS_CHANNEL) - - -def get_settings_baudrate() -> int: - return dpg.get_value(Tag.SETTINGS_BAUDRATE) - - -def get_settings_id_format() -> Callable: - return cast( - Callable, hex if dpg.get_value(Tag.SETTINGS_ID_FORMAT).lower() == "hex" else int - ) - - -def set_main_button_label(state: Flag) -> None: - labels = ("Stop", "Start") - dpg.set_item_label(Tag.MAIN_BUTTON, labels[not state]) - - -def set_main_button_callback(callback: Callable) -> None: - dpg.configure_item(Tag.MAIN_BUTTON, callback=callback) - - -def set_clear_button_callback(callback: Callable) -> None: - dpg.configure_item(Tag.CLEAR_BUTTON, callback=callback) - - -def set_plot_buffer_slider_callback(callback: Callable) -> None: - dpg.configure_item(Tag.SETTINGS_PLOT_BUFFER, callback=callback) - - -def set_plot_height_slider_callback(callback: Callable) -> None: - dpg.configure_item(Tag.SETTINGS_PLOT_HEIGHT, callback=callback) - - -def set_settings_apply_button_callback(callback: Callable) -> None: - dpg.configure_item(Tag.SETTINGS_APPLY, callback=callback) - - -def set_settings_can_id_format_callback(callback: Callable) -> None: - dpg.configure_item(Tag.SETTINGS_ID_FORMAT, callback=callback) - - -def set_settings_interface_options(iterable: Iterable[str], default: str = "") -> None: - dpg.configure_item(Tag.SETTINGS_INTERFACE, items=iterable, default_value=default) - - -def set_settings_channel_options(default: str = "") -> None: - dpg.configure_item(Tag.SETTINGS_CHANNEL, default_value=default) - - -def set_settings_baudrate_options(iterable: Iterable[str], default: str = "") -> None: - dpg.configure_item(Tag.SETTINGS_BAUDRATE, items=iterable, default_value=default) diff --git a/src/can_explorer/models.py b/src/can_explorer/models.py new file mode 100644 index 0000000..cdf468e --- /dev/null +++ b/src/can_explorer/models.py @@ -0,0 +1,54 @@ +from collections.abc import Collection + +from can_explorer.can_bus import PayloadBuffer +from can_explorer.configs import Default +from can_explorer.plotting import PlotData + + +def convert_payloads(payloads: Collection) -> PlotData: + return PlotData( + x=tuple(range(len(payloads))), + y=tuple(payloads), + ) + + +class PlotModel: + _len = Default.BUFFER_SIZE + + def __init__(self) -> None: + self._plot: dict[int, PlotData] = {} + + def update(self, can_id: int, payloads: PayloadBuffer) -> None: + """ + Update a plot. + + Args: + can_id (int) + payloads (PayloadBuffer) + """ + plot_data = convert_payloads(payloads[-self._len :]) + self._plot[can_id] = plot_data + + def get_plot_data(self, can_id: int) -> PlotData: + return self._plot[can_id] + + def get_plots(self) -> dict: + """ + Get all plots. + """ + return self._plot.copy() + + def clear(self) -> None: + """ + Remove all plots. + """ + self._plot.clear() + + def set_limit(self, limit: int) -> None: + """ + Set the number of values to plot on the x axis. + + Args: + limit (int): N values + """ + self._len = limit diff --git a/src/can_explorer/plotting.py b/src/can_explorer/plotting.py index 5847dc8..385cf87 100644 --- a/src/can_explorer/plotting.py +++ b/src/can_explorer/plotting.py @@ -1,218 +1,101 @@ from __future__ import annotations -from typing import Callable, Dict, Iterable +from collections.abc import Collection +from dataclasses import dataclass +from typing import Any import dearpygui.dearpygui as dpg -from can_explorer.can_bus import PayloadBuffer -from can_explorer.layout import Default, Font, PlotTable -from can_explorer.resources import generate_tag +from can_explorer.configs import Default +from can_explorer.tags import generate_tag -class Config: - LABEL = dict(enabled=False) +@dataclass +class PlotData: + x: Collection + y: Collection - PLOT = dict( - no_title=True, - no_menus=True, - no_child=True, - no_mouse_pos=True, - no_highlight=True, - no_box_select=True, - ) - X_AXIS = dict(axis=dpg.mvXAxis, lock_min=True, lock_max=True, no_tick_labels=True) +class LabelItem(str): + def __new__(cls) -> LabelItem: + label = dpg.add_button( + tag=str(generate_tag()), + enabled=False, + ) - Y_AXIS = dict(axis=dpg.mvYAxis, lock_min=True, lock_max=True, no_tick_labels=True) + return super().__new__(cls, label) -class Plot(str): +class PlotItem(str): x_axis: str y_axis: str series: str - def __new__(cls, x: Iterable, y: Iterable) -> Plot: - with dpg.plot(tag=str(generate_tag()), **Config.PLOT) as plot: + def __new__(cls, data: PlotData) -> PlotItem: + with dpg.plot( + tag=str(generate_tag()), + no_title=True, + no_menus=True, + no_child=True, + no_mouse_pos=True, + no_highlight=True, + no_box_select=True, + ) as plot: plot = super().__new__(cls, plot) - plot.x_axis = dpg.add_plot_axis(**Config.X_AXIS) - plot.y_axis = dpg.add_plot_axis(**Config.Y_AXIS) - plot.series = dpg.add_line_series(parent=plot.y_axis, x=x, y=y) + plot.x_axis = dpg.add_plot_axis( + axis=dpg.mvXAxis, lock_min=True, lock_max=True, no_tick_labels=True + ) + plot.y_axis = dpg.add_plot_axis( + axis=dpg.mvYAxis, lock_min=True, lock_max=True, no_tick_labels=True + ) + plot.series = dpg.add_line_series(parent=plot.y_axis, x=data.x, y=data.y) return plot - def update(self, x: Iterable, y: Iterable) -> None: - dpg.set_axis_limits(self.x_axis, min(x), max(x)) - dpg.set_axis_limits(self.y_axis, min(y), max(y)) - dpg.configure_item(self.series, x=x, y=y) - - -class Label(str): - def __new__(cls) -> Label: - label = dpg.add_button( - tag=str(generate_tag()), - **Config.LABEL, - ) - dpg.bind_item_font(label, Font.LABEL) + def update(self, data: PlotData) -> None: + dpg.set_axis_limits(self.x_axis, min(data.x), max(data.x)) + dpg.set_axis_limits(self.y_axis, min(data.y), max(data.y)) + dpg.configure_item(self.series, x=data.x, y=data.y) - return super().__new__(cls, label) +class _PercentageWidthTableRow: + # https://github.com/hoffstadt/DearPyGui/discussions/1306 -class Row: - table: PlotTable - label: Label - plot: Plot - height: int - label_format: Callable - - def __init__( - self, can_id: int, id_format: Callable, height: int, x: Iterable, y: Iterable - ) -> None: - self._can_id = can_id - self.table = PlotTable() - self.label = Label() - self.plot = Plot(x, y) - self.table.add_label(self.label) - self.table.add_plot(self.plot) - self.table.submit() - self.set_label(id_format) - self.set_height(height) - - def set_height(self, height: int) -> None: - dpg.set_item_height(self.label, height) - dpg.set_item_height(self.plot, height) - self.height = height - - def set_label(self, id_format: Callable) -> None: - dpg.set_item_label(self.label, id_format(self._can_id)) - self.label_format = id_format - - def delete(self) -> None: - dpg.delete_item(self.table.table_id) - - -class AxisData(dict): - x: tuple - y: tuple - - def __init__(self, payloads: Iterable): - x = tuple(range(len(payloads))) # type: ignore [arg-type] - y = tuple(payloads) - super().__init__(dict(x=x, y=y)) - - -class PlotManager: - row: Dict[int, Row] = {} - payload: Dict[int, PayloadBuffer] = {} - _height = Default.PLOT_HEIGHT - _x_limit = Default.BUFFER_SIZE - _id_format: Callable = Default.ID_FORMAT - - def __call__(self) -> dict[int, Row]: - """ - Get all of the currently active plots. - - Returns: - dict[int, Row]: Plots - """ - return self.row - - def _slice(self, payloads: PayloadBuffer) -> Iterable: - """ - Reduce the number of payloads by returning the N newest amount. - - Note: N == current PlotManager limit value - - Args: - payloads (PayloadBuffer) + def __init__(self, parent: int, **kwargs) -> None: + self.table_id = dpg.add_table( + parent=parent, + header_row=False, + policy=dpg.mvTable_SizingStretchProp, + **kwargs, + ) + self.stage_id = dpg.add_stage() + dpg.push_container_stack(self.stage_id) - Returns: - Iterable: Reduced payloads - """ - return payloads[len(payloads) - self._x_limit :] + def add_widget(self, uuid: Any, percentage: float) -> None: + dpg.add_table_column( + init_width_or_weight=percentage / 100.0, parent=self.table_id + ) + dpg.set_item_width(uuid, -1) - def add(self, can_id: int, payloads: PayloadBuffer) -> None: - """ - Create a new plot. + def submit(self) -> None: + dpg.pop_container_stack() + with dpg.table_row(parent=self.table_id): + dpg.unstage(self.stage_id) - Args: - can_id (int) - payloads (PayloadBuffer) - Raises: - Exception: If plot already exists - """ - if can_id in self.row: - raise Exception(f"Error: id {can_id} already exists") +class PlotRow: + label: LabelItem + plot: PlotItem - row = Row( - can_id, self._id_format, self._height, **AxisData(self._slice(payloads)) - ) + def __init__(self, parent: int, can_id: int, data: PlotData) -> None: + row = _PercentageWidthTableRow(parent) # Must create first + self._id = row.table_id + self.can_id = can_id + self.label = LabelItem() + self.plot = PlotItem(data) + row.add_widget(self.label, Default.LABEL_COLUMN_WIDTH) + row.add_widget(self.plot, Default.PLOT_COLUMN_WIDTH) + row.submit() - self.payload[can_id] = payloads - self.row[can_id] = row - - def delete(self, can_id: int) -> None: - """ - Remove a plot. - - Args: - can_id (int) - """ - self.payload[can_id].pop() - self.row[can_id].delete() - self.row.pop(can_id) - - def update(self, can_id: int) -> None: - """ - Update a plot. - - Args: - can_id (int) - payloads (PayloadBuffer) - """ - row = self.row[can_id] - - row.plot.update(**AxisData(self._slice(self.payload[can_id]))) - - def clear_all(self) -> None: - """ - Remove all plots. - """ - while self.row: - self.delete(list(self.row).pop()) - - def set_height(self, height: int) -> None: - """ - Set height to use for plots. - - Args: - height (int): Height in pixels - """ - self._height = height - - for row in self.row.values(): - row.set_height(self._height) - - def set_id_format(self, id_format: Callable) -> None: - """ - Set the format CAN id's will be displayed as. - - Args: - id_format (Callable) - """ - self._id_format = id_format - - for row in self.row.values(): - row.set_label(self._id_format) - - def set_limit(self, x_limit: int) -> None: - """ - Set the number of payloads to plot on the x axis. - - Args: - x_limit (int): Number of payloads - """ - self._x_limit = x_limit - - for can_id in self.row: - self.update(can_id) + def delete(self) -> None: + dpg.delete_item(self._id) diff --git a/src/can_explorer/resources/__init__.py b/src/can_explorer/resources/__init__.py index e19141b..ec25ebd 100644 --- a/src/can_explorer/resources/__init__.py +++ b/src/can_explorer/resources/__init__.py @@ -1,8 +1,10 @@ import pathlib import platform -import uuid +import threading from typing import Any, Final +import can + DIR_PATH: Final = pathlib.Path(__file__).parent HOST_OS: Final = platform.system().lower() @@ -21,13 +23,6 @@ def frozen(value: Any) -> property: return property(fget=lambda _: value) -def generate_tag() -> int: - """ - Generate a random, unique tag. - """ - return hash(uuid.uuid4()) - - class Percentage: @staticmethod def get(n1: float, n2: float) -> int: @@ -56,3 +51,28 @@ def reverse(percentage: float, total: float) -> int: int: Original value """ return int((percentage * total) / 100.0) + + +class StoppableThread(threading.Thread): + """ + Basic thread that can be stopped during long running loops. + + StoppableThread.cancel should be used as the while loop flag. + + threading.current_thread can be used to access the thread from + within the target function. + + Excample: + + while not current_thread().cancel.wait(1): + ... + + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.cancel = threading.Event() + + def stop(self): + self.cancel.set() + self.join() diff --git a/src/can_explorer/resources/demo.py b/src/can_explorer/resources/demo.py index c65b8c9..997e1bf 100644 --- a/src/can_explorer/resources/demo.py +++ b/src/can_explorer/resources/demo.py @@ -4,17 +4,17 @@ import can.player -from can_explorer import app, layout +from can_explorer import CanExplorer DEMO_FILE = Path(__file__).parent / "ic_sim.log" -def demo_config() -> None: +def demo_config(app: CanExplorer) -> None: # Use the virtual interface as default - layout.set_settings_interface_options(iterable=[""], default="virtual") + app.controller.view.settings.set_interface_options(iterable=[""], default="virtual") # Simulate the apply button press - app.settings_apply_button_callback(None, None, None) + app.controller.settings_apply_button_callback(None, None, None) # Play simulated logfile sys.argv = [sys.argv[0], "-i", "virtual", str(DEMO_FILE)] diff --git a/src/can_explorer/tags.py b/src/can_explorer/tags.py new file mode 100644 index 0000000..ce14098 --- /dev/null +++ b/src/can_explorer/tags.py @@ -0,0 +1,37 @@ +import uuid +from dataclasses import dataclass + + +def generate_tag() -> int: + """ + Generate a random, unique tag. + """ + return hash(uuid.uuid4()) + + +@dataclass(frozen=True, slots=True) +class Tag: + """ + Creates tag reference instances for the view to utilize. + + Once instantiated the tag values cannot be modified. + """ + + header: int + body: int + footer: int + main_button: int + clear_button: int + plot_buffer_slider: int + plot_height_slider: int + plot_tab: int + settings_tab: int + settings_interface: int + settings_channel: int + settings_baudrate: int + settings_apply: int + settings_id_format: int + + def __init__(self) -> None: + for tag_name in self.__slots__: + object.__setattr__(self, tag_name, generate_tag()) diff --git a/src/can_explorer/views.py b/src/can_explorer/views.py new file mode 100644 index 0000000..3c12a41 --- /dev/null +++ b/src/can_explorer/views.py @@ -0,0 +1,369 @@ +from __future__ import annotations + +from collections.abc import Callable, Collection +from dataclasses import dataclass +from typing import cast + +import dearpygui.dearpygui as dpg +from dearpygui_ext.themes import create_theme_imgui_light +from wrapt import synchronized + +from can_explorer.configs import Default +from can_explorer.plotting import PlotData, PlotRow +from can_explorer.resources import Percentage +from can_explorer.tags import Tag + +# Some dpg functionality is not thread safe +# ie: adding and removing widgets +# The synchronized decorator is used to provide +# the instance with a lock for thread safety +# https://github.com/GrahamDumpleton/wrapt/blob/develop/blog/07-the-missing-synchronized-decorator.md + + +class PlotView: + _format: Callable = Default.ID_FORMAT + _height: int = Default.PLOT_HEIGHT + + def __init__(self, parent: MainView) -> None: + self._parent = parent + self._row: dict[int, PlotRow] = {} + self.tag = parent.tag + + def setup(self) -> None: + pass + + @synchronized + def _add_row(self, can_id: int, plot_data: PlotData) -> None: + row = PlotRow(self.tag.plot_tab, can_id, plot_data) + dpg.bind_item_font(row.label, self._parent.font.large) + self._set_row_format(row, self._format) + self._set_row_height(row, self._height) + self._row[can_id] = row + + @staticmethod + def _set_row_height(row: PlotRow, height: int) -> None: + dpg.set_item_height(row.label, height) + dpg.set_item_height(row.plot, height) + + @staticmethod + def _set_row_format(row: PlotRow, id_format: Callable) -> None: + dpg.set_item_label(row.label, id_format(row.can_id)) + + def get_rows(self) -> dict: + return self._row.copy() + + @synchronized + def update(self, can_id: int, plot_data: PlotData) -> None: + if can_id not in self._row: + self._add_row(can_id, plot_data) + else: + self._row[can_id].plot.update(plot_data) + + @synchronized + def remove(self, can_id: int) -> None: + row = self._row.pop(can_id) + row.delete() + + @synchronized + def clear(self) -> None: + for can_id in self.get_rows(): + self.remove(can_id) + + @synchronized + def set_format(self, id_format: Callable) -> None: + """ + Set the format CAN id's will be displayed as. + + Args: + id_format (Callable) + """ + for row in self.get_rows().values(): + self._set_row_format(row, id_format) + + self._format = id_format + + @synchronized + def set_height(self, height: int) -> None: + """ + Set the height of all plots. + + Args: + height (int) + """ + for row in self.get_rows().values(): + self._set_row_height(row, height) + + self._height = height + + +class SettingsView: + def __init__(self, parent: MainView) -> None: + self._parent = parent + self.tag = parent.tag + + def setup(self) -> None: + with dpg.collapsing_header(label="CAN Bus", default_open=True): + dpg.add_combo(tag=self.tag.settings_interface, label="Interface") + dpg.add_input_text(tag=self.tag.settings_channel, label="Channel") + dpg.add_combo(tag=self.tag.settings_baudrate, label="Baudrate") + dpg.add_spacer(height=5) + dpg.add_button(tag=self.tag.settings_apply, label="Apply", height=30) + dpg.add_spacer(height=5) + + with dpg.collapsing_header(label="GUI"): + with dpg.group(horizontal=True): + dpg.add_text("ID Format") + dpg.add_radio_button( + ["Hex", "Dec"], + tag=self.tag.settings_id_format, + horizontal=True, + ) + with dpg.group(horizontal=True): + dpg.add_text("Theme") + dpg.add_radio_button( + ["Default", "Light"], + horizontal=True, + callback=lambda sender: dpg.bind_theme( + getattr(self._parent.theme, dpg.get_value(sender).lower()) + ), + ) + + dpg.add_button( + label="Launch Font Manager", width=-1, callback=dpg.show_font_manager + ) + dpg.add_button( + label="Launch Style Editor", width=-1, callback=dpg.show_style_editor + ) + dpg.add_spacer(height=5) + + def get_interface(self) -> str: + return dpg.get_value(self.tag.settings_interface) + + def get_channel(self) -> str: + return dpg.get_value(self.tag.settings_channel) + + def get_baudrate(self) -> int: + return dpg.get_value(self.tag.settings_baudrate) + + def get_id_format(self) -> Callable: + return cast( + Callable, + hex if dpg.get_value(self.tag.settings_id_format).lower() == "hex" else int, + ) + + def set_apply_button_callback(self, callback: Callable) -> None: + dpg.configure_item(self.tag.settings_apply, callback=callback) + + def set_can_id_format_callback(self, callback: Callable) -> None: + dpg.configure_item(self.tag.settings_id_format, callback=callback) + + def set_interface_options( + self, iterable: Collection[str], default: str = "" + ) -> None: + dpg.configure_item( + self.tag.settings_interface, items=iterable, default_value=default + ) + + def set_channel(self, channel: str = "") -> None: + dpg.configure_item(self.tag.settings_channel, default_value=channel) + + def set_baudrate_options( + self, iterable: Collection[str], default: str = "" + ) -> None: + dpg.configure_item( + self.tag.settings_baudrate, items=iterable, default_value=default + ) + + +@dataclass +class Font: + default: int + large: int + + +@dataclass +class Theme: + default: int + light: int + + +class MainView: + font: Font + theme: Theme + + def __init__(self) -> None: + self.tag = Tag() + self.plot = PlotView(self) + self.settings = SettingsView(self) + + def setup(self) -> None: + self._fonts() + self._themes() + self._header() + self._body() + self._footer() + + def _fonts(self): + with dpg.font_registry(): + self.font = Font( + default=dpg.add_font(Default.FONT, Default.FONT_HEIGHT), + large=dpg.add_font(Default.FONT, Default.FONT_HEIGHT * 1.75), + ) + + dpg.bind_font(self.font.default) + + def _themes(self): + with dpg.theme() as default: + with dpg.theme_component(dpg.mvButton, enabled_state=False): + dpg.add_theme_color(dpg.mvThemeCol_ButtonHovered, Default.BACKGROUND) + dpg.add_theme_color(dpg.mvThemeCol_ButtonActive, Default.BACKGROUND) + + self.theme = Theme(default=default, light=create_theme_imgui_light()) + + dpg.bind_theme(self.theme.default) + + def _header(self) -> None: + def tab_callback(sender, app_data, user_data) -> None: + current_tab = dpg.get_item_label(app_data) + + if current_tab == "Viewer": + dpg.configure_item(self.tag.plot_tab, show=True) + dpg.configure_item(self.tag.settings_tab, show=False) + else: + dpg.configure_item(self.tag.plot_tab, show=False) + dpg.configure_item(self.tag.settings_tab, show=True) + + with dpg.tab_bar(tag=self.tag.header, callback=tab_callback): + dpg.add_tab(label="Viewer") + dpg.add_tab(label="Settings") + + def _body(self) -> None: + with dpg.child_window(tag=self.tag.body, border=False): + with dpg.group(tag=self.tag.plot_tab, show=True): + self.plot.setup() + with dpg.group(tag=self.tag.settings_tab, show=False): + self.settings.setup() + + def _footer(self) -> None: + with dpg.child_window( + tag=self.tag.footer, height=110, border=False, no_scrollbar=True + ): + dpg.add_spacer(height=2) + dpg.add_separator() + dpg.add_spacer(height=2) + + with dpg.table(header_row=False): + dpg.add_table_column() + dpg.add_table_column() + with dpg.table_row(): + with dpg.group(horizontal=True): + dpg.add_text("Message Buffer Size") + dpg.add_spacer() + dpg.add_slider_int( + tag=self.tag.plot_buffer_slider, + width=-1, + default_value=Percentage.get( + Default.BUFFER_SIZE, Default.BUFFER_MAX + ), + min_value=2, + max_value=100, + clamped=True, + format="%d%%", + ) + + with dpg.group(horizontal=True): + dpg.add_text("Plot Height") + dpg.add_spacer() + dpg.add_slider_int( + tag=self.tag.plot_height_slider, + width=-1, + default_value=Percentage.get( + Default.PLOT_HEIGHT, Default.PLOT_HEIGHT_MAX + ), + min_value=10, + max_value=100, + clamped=True, + format="%d%%", + ) + dpg.add_spacer(height=2) + + dpg.add_separator() + dpg.add_spacer(height=2) + with dpg.group(horizontal=True): + dpg.add_button( + tag=self.tag.main_button, + width=-100, + height=50, + ) + dpg.add_button( + tag=self.tag.clear_button, + label="Clear", + width=-1, + height=50, + ) + + def resize(self) -> None: + dpg.set_item_height( + self.tag.body, + ( + dpg.get_viewport_height() + - dpg.get_item_height(self.tag.footer) + - Default.FOOTER_OFFSET + ), + ) + dpg.set_item_width(self.tag.settings_apply, dpg.get_viewport_width() // 4) + + @staticmethod + def popup_error(name: str | Exception, info: str | Exception) -> None: + # https://github.com/hoffstadt/DearPyGui/discussions/1308 + + # guarantee these commands happen in the same frame + with dpg.mutex(): + viewport_width = dpg.get_viewport_client_width() + viewport_height = dpg.get_viewport_client_height() + + with dpg.window(label="ERROR", modal=True, no_close=True) as modal_id: + dpg.add_text(name, color=(255, 0, 0)) # red + dpg.add_separator() + dpg.add_text(info) + with dpg.group(): + dpg.add_button( + label="Close", + width=-1, + user_data=(modal_id, True), + callback=lambda sender, app_data, user_data: dpg.delete_item( + user_data[0] + ), + ) + + # guarantee these commands happen in another frame + dpg.split_frame() + width = dpg.get_item_width(modal_id) + height = dpg.get_item_height(modal_id) + dpg.set_item_pos( + modal_id, + [viewport_width // 2 - width // 2, viewport_height // 2 - height // 2], + ) + + def get_plot_buffer(self) -> int: + percentage = dpg.get_value(self.tag.plot_buffer_slider) + return Percentage.reverse(percentage, Default.BUFFER_MAX) + + def get_plot_height(self) -> int: + percentage = dpg.get_value(self.tag.plot_height_slider) + return Percentage.reverse(percentage, Default.PLOT_HEIGHT_MAX) + + def set_main_button_label(self, state: bool) -> None: + dpg.set_item_label(self.tag.main_button, ("Stop", "Start")[not state]) + + def set_main_button_callback(self, callback: Callable) -> None: + dpg.configure_item(self.tag.main_button, callback=callback) + + def set_clear_button_callback(self, callback: Callable) -> None: + dpg.configure_item(self.tag.clear_button, callback=callback) + + def set_plot_buffer_slider_callback(self, callback: Callable) -> None: + dpg.configure_item(self.tag.plot_buffer_slider, callback=callback) + + def set_plot_height_slider_callback(self, callback: Callable) -> None: + dpg.configure_item(self.tag.plot_height_slider, callback=callback) diff --git a/tests/conftest.py b/tests/conftest.py index aeac016..ed5c3a0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,59 +1,51 @@ -from unittest.mock import Mock, patch +from unittest.mock import MagicMock -import can_explorer +import can import pytest +from can_explorer.app import CanExplorer +from can_explorer.controllers import Controller @pytest.fixture -def mock_buffer(): - with patch("can_explorer.can_bus.PayloadBuffer", autospec=True) as mock: - yield mock +def vbus1(): + yield can.interface.Bus(interface="virtual", channel="pytest") @pytest.fixture -def mock_listener(): - with patch("can_explorer.can_bus._Listener", autospec=True) as mock: - yield mock +def vbus2(): + yield can.interface.Bus(interface="virtual", channel="pytest") @pytest.fixture -def mock_notifier(): - with patch("can_explorer.can_bus.Notifier", autospec=True) as mock: - yield mock +def app(): + ce = CanExplorer() + ce.setup() + yield ce + ce.teardown() @pytest.fixture -def fake_recorder(mock_listener, mock_notifier): - recorder = can_explorer.can_bus.Recorder() - - with patch("can_explorer.can_bus.Recorder") as mock: - mock.return_value = recorder - - yield recorder +def controller(app): + yield app.controller @pytest.fixture -def fake_manager(): - with patch("can_explorer.plotting.Row"): - manager = can_explorer.plotting.PlotManager() +def view(app): + yield app.view - with patch("can_explorer.plotting.PlotManager") as mock: - mock.return_value = manager - yield manager +@pytest.fixture +def tag(view): + yield view.tag @pytest.fixture -def fake_app(fake_manager, fake_recorder): - main_app = can_explorer.app.MainApp() - main_app.plot_manager = fake_manager - main_app.can_recorder = fake_recorder - main_app.set_bus(Mock()) - yield main_app +def model(app): + yield app.model @pytest.fixture -def app(): - can_explorer.app.setup() - yield can_explorer.app.app - can_explorer.app.teardown() +def fake_controller(model): + yield Controller( + model=model, recorder=MagicMock(), bus=MagicMock(), view=MagicMock() + ) diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..b0c9e9e --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,20 @@ +import dearpygui.dearpygui as dpg +import pytest + + +def test_app_must_apply_settings_before_running(tag, controller): + dpg.set_value(tag.settings_interface, None) + with pytest.raises(RuntimeError): + controller.start_stop_button_callback() + + +def test_app_must_be_inactive_to_apply_settings(tag, controller): + dpg.set_value(tag.settings_interface, "virtual") + controller.settings_apply_button_callback() + + controller.start_stop_button_callback() + assert controller.is_active() + + dpg.set_value(tag.settings_interface, None) + with pytest.raises(RuntimeError): + controller.settings_apply_button_callback() diff --git a/tests/test_controller.py b/tests/test_controller.py new file mode 100644 index 0000000..3c467cb --- /dev/null +++ b/tests/test_controller.py @@ -0,0 +1,31 @@ +import time + +from can_explorer.can_bus import generate_random_can_message + + +def test_controller_starts_worker(fake_controller): + fake_controller.start() + assert fake_controller.is_active() + assert fake_controller.worker.is_alive() + + +def test_controller_stops_worker(fake_controller): + fake_controller.start() + assert fake_controller.is_active() + assert fake_controller.worker.is_alive() + fake_controller.stop() + assert not fake_controller.is_active() + assert not fake_controller.worker.is_alive() + + +def test_controller_populates_data_in_ascending_order(controller, view, vbus1, vbus2): + controller.set_bus(vbus1) + controller.start() + + for _ in range(100): + message = generate_random_can_message() + vbus2.send(message) + + time.sleep(0.1) + plots = view.plot.get_rows() + assert list(plots) == sorted(plots) diff --git a/tests/test_gui.py b/tests/test_gui.py index 40dd4db..1c55dff 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -5,15 +5,18 @@ from pathlib import Path from time import sleep -import can_explorer.app import pyautogui import pytest +from can_explorer.app import CanExplorer +from can_explorer.configs import Default from can_explorer.resources import HOST_OS from can_explorer.resources.demo import demo_config from tests.resources import WITHIN_CI from tests.resources.gui_components import Gui +pytestmark = pytest.mark.order(1) + if HOST_OS == "linux": import Xlib.display from pyvirtualdisplay.smartdisplay import SmartDisplay @@ -50,8 +53,9 @@ def virtual_display(): @pytest.fixture -def gui_process(virtual_display): - proc = mp.Process(target=can_explorer.app.main, args=(demo_config,)) +def process(virtual_display): + app = CanExplorer() + proc = mp.Process(target=app.run, args=(demo_config,)) proc.start() sleep(1) @@ -61,10 +65,10 @@ def gui_process(virtual_display): @pytest.fixture -def virtual_gui(request, gui_process): +def virtual_gui(request, process): if HOST_OS == "windows": # Ensure window is active - app_title = can_explorer.app.Default.TITLE + app_title = Default.TITLE app_window = pygetwindow.getWindowsWithTitle(app_title)[0] app_window.restore() diff --git a/tests/test_plotting.py b/tests/test_model.py similarity index 100% rename from tests/test_plotting.py rename to tests/test_model.py diff --git a/tests/test_view.py b/tests/test_view.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_xapp.py b/tests/test_xapp.py deleted file mode 100644 index 670cf1c..0000000 --- a/tests/test_xapp.py +++ /dev/null @@ -1,55 +0,0 @@ -from random import sample -from time import sleep - -import dearpygui.dearpygui as dpg -import pytest -from can_explorer.app import settings_apply_button_callback -from can_explorer.layout import Tag - -DELAY = 0.1 - - -def test_app_starts_worker(fake_app): - fake_app.start() - assert fake_app._worker.is_alive() - - -def test_app_stops_worker(fake_app): - fake_app.start() - assert fake_app.is_active() - assert fake_app._worker.is_alive() - fake_app.stop() - assert not fake_app._worker.is_alive() - - -def test_app_populates_data_in_ascending_order(fake_app, fake_manager, fake_recorder): - data = sample(range(250), 25) - - fake_app.start() - - for i in data: - fake_recorder[i] = [0] - - sleep(DELAY) - sorted_data = list(sorted(data)) - sorted_keys = list(fake_manager.row.keys()) - - assert sorted_data == sorted_keys - - -def test_app_must_apply_settings_before_running(app): - dpg.set_value(Tag.SETTINGS_INTERFACE, None) - with pytest.raises(RuntimeError): - app.start() - - -def test_app_must_be_inactive_to_apply_settings(app): - dpg.set_value(Tag.SETTINGS_INTERFACE, "virtual") - settings_apply_button_callback(None, None, None) - - app.start() - assert app.is_active() - - dpg.set_value(Tag.SETTINGS_INTERFACE, None) - with pytest.raises(RuntimeError): - settings_apply_button_callback(None, None, None)