Skip to content

Commit

Permalink
Implement MVC architecture (#31)
Browse files Browse the repository at this point in the history
  • Loading branch information
Tbruno25 authored Jul 10, 2024
1 parent d13e8f3 commit aec363f
Show file tree
Hide file tree
Showing 23 changed files with 958 additions and 838 deletions.
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 <[email protected]>"]
Expand All @@ -10,14 +10,15 @@ 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"
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"
Expand All @@ -28,13 +29,15 @@ pyvirtualdisplay = [
]
msvc-runtime = {version = "^14.34.31931", platform = "windows"}


[build-system]
requires = ["poetry-core>=1.0.0"]
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__"
6 changes: 6 additions & 0 deletions src/can_explorer/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
7 changes: 4 additions & 3 deletions src/can_explorer/__main__.py
Original file line number Diff line number Diff line change
@@ -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()
223 changes: 58 additions & 165 deletions src/can_explorer/app.py
Original file line number Diff line number Diff line change
@@ -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()
Loading

0 comments on commit aec363f

Please sign in to comment.