diff --git a/src/can_explorer/app.py b/src/can_explorer/app.py index 727a88a..293c858 100644 --- a/src/can_explorer/app.py +++ b/src/can_explorer/app.py @@ -7,8 +7,7 @@ import can import dearpygui.dearpygui as dpg -from can_explorer import can_bus -from can_explorer.configs import Default +from can_explorer.configs import CANBus, Default from can_explorer.controllers import Controller from can_explorer.models import PlotModel from can_explorer.tags import Tag @@ -50,8 +49,8 @@ def setup(self): main_window = self.view.ui.build() - self.view.settings.set_interface_options(can_bus.INTERFACES) - self.view.settings.set_baudrate_options(can_bus.BAUDRATES) + self.view.settings.set_interface_options(CANBus.INTERFACES) + self.view.settings.set_baudrate_options(CANBus.BAUDRATES) self.view.settings.set_apply_button_callback( self.controller.settings_apply_button_callback ) diff --git a/src/can_explorer/can_bus.py b/src/can_explorer/can_bus.py deleted file mode 100644 index d03a849..0000000 --- a/src/can_explorer/can_bus.py +++ /dev/null @@ -1,95 +0,0 @@ -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 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] - - -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): - 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] - # Add ability to utilize slicing - # Note: must convert deque to avoid runtime error - if isinstance(index, slice): - return tuple(self)[index.start : index.stop : index.step] - return tuple(deque.__getitem__(self, index)) - - -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 - _bus: BusABC | None - _listener: Listener - _notifier: Notifier - - def __init__(self): - self._data = defaultdict(PayloadBuffer) - - def is_active(self) -> bool: - return self._active - - def start(self) -> None: - if self.is_active(): - return - - 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 - - def stop(self) -> None: - if not self.is_active(): - return - - 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) -> None: - self._bus = bus diff --git a/src/can_explorer/configs.py b/src/can_explorer/configs.py index 99bed51..09d12a2 100644 --- a/src/can_explorer/configs.py +++ b/src/can_explorer/configs.py @@ -1,5 +1,7 @@ from typing import Final +from can.interfaces import VALID_INTERFACES + from can_explorer.resources import DIR_PATH as RESOURCES_DIR from can_explorer.resources import HOST_OS @@ -21,3 +23,9 @@ class Default: TITLE: Final = "CAN Explorer" FONT: Final = RESOURCES_DIR / "Inter-Medium.ttf" FOOTER_OFFSET: Final = 50 if HOST_OS == "linux" else 85 + + +class CANBus: + 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] diff --git a/src/can_explorer/controllers.py b/src/can_explorer/controllers.py index 6be8d40..b6183ca 100644 --- a/src/can_explorer/controllers.py +++ b/src/can_explorer/controllers.py @@ -1,16 +1,11 @@ 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 @@ -24,35 +19,27 @@ def __init__( self, model: PlotModel, view: MainView, - bus: BusABC | None = None, - recorder: Recorder | None = None, + bus: can.bus.BusABC | None = None, refresh_rate: float | None = Default.REFRESH_RATE, ) -> None: self.model = model self.view = view - self.recorder = recorder or Recorder() + self.notififer: can.Notifier | None = None 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: + def bus(self) -> can.bus.BusABC | None: if self._bus is None: raise RuntimeError("Must apply settings before starting") 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) @@ -73,14 +60,8 @@ def start(self) -> None: if self.state == State.RUNNING: raise RuntimeError("App is already running") - self.recorder.set_bus(self.bus) - self.recorder.start() - - self.create_worker_thread() - self.worker.start() - + self.notifier = can.Notifier(self.bus, [self._on_message_received]) self.view.set_main_button_label(True) - self._state = State.RUNNING def stop(self) -> None: @@ -90,38 +71,25 @@ def stop(self) -> None: if self.state == State.STOPPED: return - self.recorder.stop() - self.worker.stop() - + self.notifier.stop() self.view.set_main_button_label(False) - self._state = State.STOPPED - def _worker_loop(self) -> None: - thread = cast(StoppableThread, current_thread()) - while not thread.cancel.wait(self._rate): - current_data = self.recorder.get_data() - - for can_id, payloads in current_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 create_worker_thread(self) -> None: - self._worker = StoppableThread(target=self._worker_loop, daemon=True) + def _on_message_received(self, message: can.Message): + self.model.add_message(message) + plot_data = self.model.get_data(message.arbitration_id) + self.view.plot.update(message.arbitration_id, plot_data) 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) + for can_id in self.view.plot.get_rows(): + plot_data = self.model.get_data(can_id) self.view.plot.update(can_id, plot_data) def plot_height_slider_callback(self, *args, **kwargs) -> None: diff --git a/src/can_explorer/models.py b/src/can_explorer/models.py index cdf468e..4bc9313 100644 --- a/src/can_explorer/models.py +++ b/src/can_explorer/models.py @@ -1,48 +1,37 @@ -from collections.abc import Collection +from collections import defaultdict, deque + +import can -from can_explorer.can_bus import PayloadBuffer from can_explorer.configs import Default -from can_explorer.plotting import PlotData +from can_explorer.plotting import PlotData, convert_payloads -def convert_payloads(payloads: Collection) -> PlotData: - return PlotData( - x=tuple(range(len(payloads))), - y=tuple(payloads), - ) +class PayloadBuffer(deque): + 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] + # Add ability to utilize slicing + # Note: must convert deque to avoid runtime error + if isinstance(index, slice): + return tuple(self)[index.start : index.stop : index.step] + return tuple(deque.__getitem__(self, index)) -class PlotModel: - _len = Default.BUFFER_SIZE +class PlotModel: def __init__(self) -> None: - self._plot: dict[int, PlotData] = {} + self._data = defaultdict(PayloadBuffer) + self._len = Default.BUFFER_SIZE - 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 add_message(self, message: can.Message) -> None: + can_id = message.arbitration_id + val = int.from_bytes(message.data, byteorder="big") + self._data[can_id].append(val) - 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 get_data(self, can_id: int) -> PlotData: + return convert_payloads(self._data[can_id][-self._len :]) def set_limit(self, limit: int) -> None: """ diff --git a/src/can_explorer/plotting.py b/src/can_explorer/plotting.py index 3647408..a682921 100644 --- a/src/can_explorer/plotting.py +++ b/src/can_explorer/plotting.py @@ -10,6 +10,13 @@ from can_explorer.tags import generate_tag +def convert_payloads(payloads: Collection) -> PlotData: + return PlotData( + x=tuple(range(len(payloads))), + y=tuple(payloads), + ) + + @dataclass class PlotData: x: Collection diff --git a/src/can_explorer/resources/__init__.py b/src/can_explorer/resources/__init__.py index 5894779..76ce41e 100644 --- a/src/can_explorer/resources/__init__.py +++ b/src/can_explorer/resources/__init__.py @@ -1,24 +1,24 @@ import pathlib import platform import threading +from random import randint from typing import Any, Final +from can import Message + DIR_PATH: Final = pathlib.Path(__file__).parent HOST_OS: Final = platform.system().lower() -def frozen(value: Any) -> property: +def generate_random_can_message() -> Message: """ - Helper function to create an inline property. - - Args: - value (Any) - - Returns: - property + Generate a random CAN message. """ - return property(fget=lambda _: value) + 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 Percentage: @@ -49,28 +49,3 @@ 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/views.py b/src/can_explorer/views.py index 28b0ffb..a4ff081 100644 --- a/src/can_explorer/views.py +++ b/src/can_explorer/views.py @@ -75,8 +75,8 @@ def remove(self, can_id: int) -> None: @synchronized def clear(self) -> None: - for can_id in self._row_keys: - self.remove(can_id) + while self._row_keys: + self.remove(self._row_keys[0]) @synchronized def set_format(self, id_format: Callable) -> None: diff --git a/tests/test_can_bus.py b/tests/test_can_bus.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/test_controller.py b/tests/test_controller.py index 48fd799..fdd6b10 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -1,22 +1,7 @@ import time import pytest -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() +from can_explorer.resources import generate_random_can_message def test_controller_populates_data_in_ascending_order(app, controller, view, vbus):