Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement MVC architecture #31

Merged
merged 1 commit into from
Jul 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading