├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml └── workflows │ ├── codeql-analysis.yml │ ├── deployment.yaml │ └── unit-tests.yml ├── .gitignore ├── LICENSE ├── README.md ├── canopen_monitor ├── __init__.py ├── __main__.py ├── app.py ├── assets │ ├── dcfs │ │ ├── battery.dcf │ │ ├── c3.dcf │ │ ├── dxwifi.dcf │ │ ├── gps.xdd │ │ ├── imu.dcf │ │ ├── solar0.dcf │ │ ├── solar1.dcf │ │ ├── solar2.dcf │ │ ├── solar3.dcf │ │ └── star_tracker.dcf │ ├── devices.json │ ├── eds │ │ ├── CFC_OD.eds │ │ ├── GPS_OD.eds │ │ ├── live_OD.eds │ │ └── star_tracker_OD.eds │ ├── features.json │ ├── layout.json │ └── nodes.json ├── can │ ├── __init__.py │ ├── interface.py │ ├── magic_can_bus.py │ ├── message.py │ └── message_table.py ├── meta.py ├── parse │ ├── __init__.py │ ├── canopen.py │ ├── eds.py │ ├── emcy.py │ ├── hb.py │ ├── pdo.py │ ├── sdo.py │ ├── sync.py │ ├── time.py │ └── utilities.py └── ui │ ├── __init__.py │ ├── colum.py │ ├── grid.py │ ├── message_pane.py │ ├── pane.py │ └── windows.py ├── docs ├── Makefile ├── conf.py ├── development │ ├── can.rst │ ├── index.rst │ ├── parse.rst │ └── ui.rst ├── glossary.rst └── index.rst ├── scripts ├── interface-status.py ├── issue76_demo.py └── socketcan-dev.py ├── setup.py └── tests ├── __init__.py ├── spec_eds_parser.py ├── spec_emcy_parser.py ├── spec_hb_parser.py ├── spec_interface.py ├── spec_magic_can_bus.py ├── spec_meta.py ├── spec_pdo_parser.py ├── spec_sdo_parser.py ├── spec_sync_parser.py └── spec_time_parser.py /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Environment (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Version: [e.g. 22] 29 | 30 | **Additional context** 31 | Add any other context about the problem here. 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: feature request 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: Code Quality 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [ master ] 9 | schedule: 10 | - cron: '0 0 * * 0' 11 | 12 | jobs: 13 | analyze: 14 | name: Analyze 15 | runs-on: ubuntu-latest 16 | permissions: 17 | actions: read 18 | contents: read 19 | security-events: write 20 | 21 | strategy: 22 | fail-fast: false 23 | matrix: 24 | language: [ 'python' ] 25 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 26 | # Learn more: 27 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 28 | 29 | steps: 30 | - name: Checkout repository 31 | uses: actions/checkout@v2 32 | 33 | # Initializes the CodeQL tools for scanning. 34 | - name: Initialize CodeQL 35 | uses: github/codeql-action/init@v1 36 | with: 37 | languages: ${{ matrix.language }} 38 | # If you wish to specify custom queries, you can do so here or in a config file. 39 | # By default, queries listed here will override any specified in a config file. 40 | # Prefix the list here with "+" to use these queries and those in the config file. 41 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 42 | 43 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 44 | # If this step fails, then you should remove it and run the build manually (see below) 45 | - name: Autobuild 46 | uses: github/codeql-action/autobuild@v1 47 | 48 | # ℹ️ Command-line programs to run using the OS shell. 49 | # 📚 https://git.io/JvXDl 50 | 51 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 52 | # and modify them (or add more) to build your code if your project 53 | # uses a compiled language 54 | 55 | #- run: | 56 | # make bootstrap 57 | # make release 58 | 59 | - name: Perform CodeQL Analysis 60 | uses: github/codeql-action/analyze@v1 61 | -------------------------------------------------------------------------------- /.github/workflows/deployment.yaml: -------------------------------------------------------------------------------- 1 | name: Deploy to PyPi 2 | 3 | on: 4 | release: 5 | types: published 6 | 7 | jobs: 8 | deploy: 9 | 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Set up Python 15 | uses: actions/setup-python@v2 16 | with: 17 | python-version: 3.9 18 | 19 | - name: Update Pip 20 | run: python3 -m pip install --upgrade pip 21 | 22 | - name: Install regular and dev dependencies 23 | run: pip install .[dev] 24 | 25 | - name: Build and publish 26 | env: 27 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 28 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 29 | run: | 30 | python setup.py sdist bdist_wheel 31 | twine upload dist/* 32 | -------------------------------------------------------------------------------- /.github/workflows/unit-tests.yml: -------------------------------------------------------------------------------- 1 | name: Unit Tests 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Set up Python 17 | uses: actions/setup-python@v2 18 | with: 19 | python-version: 3.9 20 | 21 | - name: Update Pip 22 | run: python3 -m pip install --upgrade pip 23 | 24 | - name: Install regular and dev dependencies 25 | run: pip install .[dev] 26 | 27 | - name: Lint with flake8 28 | run: | 29 | flake8 canopen_monitor --count --select=E9,F63,F7,F82 --show-source --statistics 30 | flake8 canopen_monitor --count --exit-zero --max-complexity=30 --max-line-length=127 --statistics 31 | 32 | - name: Run unit tests 33 | run: python3 -m unittest tests/spec_*.py 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Custom 2 | dist 3 | build 4 | *egg* 5 | tests/config-env/ 6 | 7 | # Byte-compiled / optimized / DLL files 8 | __pycache__/ 9 | *.py[cod] 10 | *$py.class 11 | 12 | # C extensions 13 | *.so 14 | 15 | # Distribution / packaging 16 | .Python 17 | build/ 18 | develop-eggs/ 19 | dist/ 20 | downloads/ 21 | eggs/ 22 | .eggs/ 23 | lib/ 24 | lib64/ 25 | parts/ 26 | sdist/ 27 | var/ 28 | wheels/ 29 | pip-wheel-metadata/ 30 | share/python-wheels/ 31 | *.egg-info/ 32 | .installed.cfg 33 | *.egg 34 | MANIFEST 35 | 36 | # PyInstaller 37 | # Usually these files are written by a python script from a template 38 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 39 | *.manifest 40 | *.spec 41 | 42 | # Installer logs 43 | pip-log.txt 44 | pip-delete-this-directory.txt 45 | 46 | # Unit test / coverage reports 47 | htmlcov/ 48 | .tox/ 49 | .nox/ 50 | .coverage 51 | .coverage.* 52 | .cache 53 | nosetests.xml 54 | coverage.xml 55 | *.cover 56 | *.py,cover 57 | .hypothesis/ 58 | .pytest_cache/ 59 | 60 | # Translations 61 | *.mo 62 | *.pot 63 | 64 | # Django stuff: 65 | *.log 66 | local_settings.py 67 | db.sqlite3 68 | db.sqlite3-journal 69 | 70 | # Flask stuff: 71 | instance/ 72 | .webassets-cache 73 | 74 | # PyCharm stuff: 75 | .idea 76 | 77 | # Scrapy stuff: 78 | .scrapy 79 | 80 | # Sphinx documentation 81 | docs/_build/ 82 | 83 | # PyBuilder 84 | target/ 85 | 86 | # Jupyter Notebook 87 | .ipynb_checkpoints 88 | 89 | # IPython 90 | profile_default/ 91 | ipython_config.py 92 | 93 | # pyenv 94 | .python-version 95 | 96 | # pipenv 97 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 98 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 99 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 100 | # install all needed dependencies. 101 | #Pipfile.lock 102 | 103 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 104 | __pypackages__/ 105 | 106 | # Celery stuff 107 | celerybeat-schedule 108 | celerybeat.pid 109 | 110 | # SageMath parsed files 111 | *.sage.py 112 | 113 | # Environments 114 | .env 115 | .venv 116 | env/ 117 | venv/ 118 | ENV/ 119 | env.bak/ 120 | venv.bak/ 121 | 122 | # Spyder project settings 123 | .spyderproject 124 | .spyproject 125 | 126 | # Rope project settings 127 | .ropeproject 128 | 129 | # mkdocs documentation 130 | /site 131 | 132 | # mypy 133 | .mypy_cache/ 134 | .dmypy.json 135 | dmypy.json 136 | 137 | # Pyre type checker 138 | .pyre/ 139 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CANOpen Monitor 2 | 3 | [![license](https://img.shields.io/github/license/oresat/CANopen-monitor)](./LICENSE) 4 | [![CodeQL](https://github.com/oresat/CANopen-monitor/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/oresat/CANopen-monitor/actions/workflows/codeql-analysis.yml) 5 | [![pypi](https://img.shields.io/pypi/v/canopen-monitor)](https://pypi.org/project/canopen-monitor) 6 | [![read the docs](https://img.shields.io/readthedocs/canopen-monitor?color=blue&label=read%20the%20docs)](https://canopen-monitor.readthedocs.io) 7 | [![unit tests](https://img.shields.io/github/workflow/status/oresat/CANopen-monitor/Unit%20Tests?label=unit%20tests)](https://github.com/oresat/CANopen-monitor/actions?query=workflow%3A%22Unit+Tests%22) 8 | [![deployment](https://img.shields.io/github/workflow/status/oresat/CANopen-monitor/Deploy%20to%20PyPi?label=deployment)](https://github.com/oresat/CANopen-monitor/actions?query=workflow%3A%22Deploy+to+PyPi%22) 9 | [![bugs](https://img.shields.io/github/issues/oresat/CANopen-monitor/bug?color=red&label=bugs)](https://github.com/oresat/CANopen-monitor/labels/bug) 10 | [![feature requests](https://img.shields.io/github/issues/oresat/CANopen-monitor/feature%20request?color=purple&label=feature%20requests)](https://github.com/oresat/CANopen-monitor/labels/feature%20request) 11 | 12 | An NCurses-based TUI application for tracking activity over the CAN bus and decoding messages with provided EDS/OD files. 13 | 14 | *** 15 | 16 | # Quick Start 17 | 18 | ### Install 19 | 20 | `$` `pip install canopen-monitor` 21 | 22 | ### Run 23 | 24 | **Run the monitor, binding to `can0`** 25 | 26 | `$` `canopen-monitor -i can0` 27 | 28 | **Use this for an extensive help menu** 29 | 30 | `$` `canopen-monitor --help` 31 | 32 | *** 33 | 34 | # Configuration 35 | The default configurations provided by CANOpen Monitor can be found in 36 | [canopen_monitor/assets](./canopen_monitor/assets). These are the default 37 | assets provided. At runtime these configs are copied to 38 | `~/.config/canopen-monitor` where they can be modified and the changes 39 | will persist. 40 | 41 | EDS files are loaded from `~/.cache/canopen-monitor` 42 | *** 43 | 44 | # Development and Contribution 45 | 46 | ### Documentation 47 | 48 | Check out our [Read The Docs](https://canopen-monitor.readthedocs.io) pages for more info on the application sub-components and methods. 49 | 50 | ### Pre-Requisites 51 | * Linux 4.11 or greater (any distribution) 52 | 53 | * Python 3.8.5 or higher *(pyenv is recommended for managing different python versions, see [pyenv homepage](https://realpython.com/intro-to-pyenv/#build-dependencies) for information)* 54 | 55 | ### Install Locally 56 | 57 | #### Setup a virtual CAN signal generator 58 | `$` `sudo apt-get install can-utils` 59 | 60 | #### Start a virtual CAN 61 | `$` `sudo ip link add dev vcan0 type vcan` 62 | 63 | `$` `sudo ip link set up vcan0` 64 | 65 | #### Clone the repo 66 | `$` `git clone https://github.com/Boneill3/CANopen-monitor.git` 67 | 68 | `$` `cd CANopen-monitor` 69 | 70 | `$` `pip install -e .[dev]` 71 | 72 | *(Note: the `-e` flag creates a symbolic-link to your local development version. Set it once, and forget it)* 73 | 74 | #### Generate random messages with socketcan-dev 75 | `$` `chmod 700 socketcan-dev` 76 | 77 | `$` `./socketcan-dev.py --random-id --random-message -r` 78 | 79 | #### Start the monitor 80 | `$` `canopen-monitor` 81 | 82 | ### Create documentation locally 83 | 84 | `$` `make -C docs clean html` 85 | 86 | *(Note: documentation is configured to auto-build with ReadTheDocs on every push to master)* 87 | 88 | *** 89 | 90 | ### Message Types + COB ID Ranges: 91 | 92 | ###### [Wikipedia Table](https://en.wikipedia.org/wiki/CANopen#Predefined_Connection_Set.5B7.5D) 93 | 94 | ###### Abridged Table: 95 | 96 | | Name | COB ID Range | 97 | |-----------------|--------------| 98 | | SYNC | 080 | 99 | | EMCY | 080 + NodeID | 100 | | TPDO1 | 180 + NodeID | 101 | | RPDO1 | 200 + NodeID | 102 | | TPDO2 | 280 + NodeID | 103 | | RPDO2 | 300 + NodeID | 104 | | TPDO3 | 380 + NodeID | 105 | | RPDO3 | 400 + NodeID | 106 | | TPDO4 | 480 + NodeID | 107 | | RPDO4 | 500 + NodeID | 108 | | TSDO | 580 + NodeID | 109 | | RSDO | 600 + NodeID | 110 | | NMT (Heartbeat) | 700 + NodeID | 111 | -------------------------------------------------------------------------------- /canopen_monitor/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | MAJOR = 4 4 | MINOR = 1 5 | PATCH = 0 6 | 7 | APP_NAME = 'canopen-monitor' 8 | APP_DESCRIPTION = 'An NCurses-based TUI application for tracking activity' \ 9 | ' over the CAN bus and decoding messages with provided' \ 10 | ' EDS/OD files.' 11 | APP_VERSION = f'{MAJOR}.{MINOR}.{PATCH}' 12 | APP_AUTHOR = 'Dmitri McGuckin' 13 | APP_EMAIL = 'dmitri3@pdx.edu' 14 | APP_URL = 'https://github.com/oresat/CANopen-monitor' 15 | APP_LICENSE = 'GPL-3.0' 16 | 17 | MAINTAINER_NAME = 'Portland State Aerospace Society' 18 | MAINTAINER_EMAIL = 'oresat@pdx.edu' 19 | 20 | CONFIG_DIR = os.path.expanduser(f'~/.config/{APP_NAME}') 21 | CACHE_DIR = os.path.expanduser(f'~/.cache/{APP_NAME}') 22 | CONFIG_FORMAT_VERSION = 2 23 | 24 | LOG_FMT = '%(time)s $(message)s' 25 | LOG_DIR = CACHE_DIR + os.sep + 'logs' 26 | -------------------------------------------------------------------------------- /canopen_monitor/__main__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import time 4 | import logging 5 | import argparse 6 | from . import APP_NAME, \ 7 | APP_VERSION, \ 8 | APP_DESCRIPTION, \ 9 | CONFIG_DIR, \ 10 | CACHE_DIR, \ 11 | LOG_DIR 12 | from .app import App 13 | from .meta import Meta 14 | from .can import MagicCANBus, MessageTable 15 | from .parse import CANOpenParser, load_eds_files 16 | 17 | 18 | def init_dirs(): 19 | os.makedirs(CONFIG_DIR, exist_ok=True) 20 | os.makedirs(CACHE_DIR, exist_ok=True) 21 | os.makedirs(LOG_DIR, exist_ok=True) 22 | 23 | 24 | def init_logs(log_level): 25 | logging.basicConfig(filename=f'{LOG_DIR}/latest.log', level=log_level) 26 | logging.info(f'{APP_NAME} v{APP_VERSION}', extra={'time': time.ctime()}) 27 | 28 | 29 | def main(): 30 | parser = argparse.ArgumentParser(prog=APP_NAME, 31 | description=APP_DESCRIPTION, 32 | allow_abbrev=False) 33 | parser.add_argument('-i', '--interface', 34 | dest='interfaces', 35 | type=str, 36 | nargs='+', 37 | default=[], 38 | help='A list of interfaces to bind to.') 39 | parser.add_argument('--no-block', 40 | dest='no_block', 41 | action='store_true', 42 | default=False, 43 | help='Disable block-waiting for the Magic CAN Bus.' 44 | ' (Warning, this may produce undefined' 45 | ' behavior).') 46 | parser.add_argument('--log-level', 47 | dest='log_level', 48 | choices=['info', 'warn', 'debug', 'error', 'fatal'], 49 | default='info', 50 | help='Set the log levels. (Default: info)') 51 | parser.add_argument('-v', '--version', 52 | dest='version', 53 | action='store_true', 54 | default=False, 55 | help='Display the app version then exit.') 56 | args = parser.parse_args() 57 | 58 | if (args.version): 59 | print(f'{APP_NAME} v{APP_VERSION}\n\n{APP_DESCRIPTION}') 60 | sys.exit(0) 61 | 62 | log_level = getattr(logging, args.log_level.upper()) 63 | 64 | try: 65 | init_dirs() 66 | init_logs(log_level) 67 | 68 | meta = Meta(CONFIG_DIR, CACHE_DIR) 69 | features = meta.load_features() 70 | eds_configs = load_eds_files(CACHE_DIR, features.ecss_time) 71 | mt = MessageTable(CANOpenParser(eds_configs)) 72 | interfaces = meta.load_interfaces(args.interfaces) 73 | 74 | # Start the can bus and the curses app 75 | with MagicCANBus(interfaces, no_block=args.no_block) as bus, \ 76 | App(mt, eds_configs, bus, meta, features) as app: 77 | while True: 78 | # Bus updates 79 | for message in bus: 80 | if message is not None: 81 | mt += message 82 | 83 | # User Input updates 84 | app.handle_keyboard_input() 85 | 86 | # Sleep VERY briefly so we're not using 99% of the CPU 87 | time.sleep(0.01) 88 | 89 | # Draw update 90 | app.draw(bus.statuses) 91 | except KeyboardInterrupt: 92 | print('Goodbye!') 93 | 94 | 95 | if __name__ == '__main__': 96 | main() 97 | -------------------------------------------------------------------------------- /canopen_monitor/app.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import curses 3 | import curses.ascii 4 | import datetime as dt 5 | from enum import Enum 6 | from . import APP_NAME, \ 7 | APP_VERSION, \ 8 | APP_LICENSE, \ 9 | APP_AUTHOR, \ 10 | APP_DESCRIPTION, \ 11 | APP_URL 12 | from .can import MessageTable, \ 13 | MessageType, \ 14 | MagicCANBus 15 | from .ui import MessagePane, \ 16 | PopupWindow, \ 17 | InputPopup, \ 18 | SelectionPopup, \ 19 | Column 20 | from .meta import Meta, FeatureConfig 21 | 22 | # Key Constants not defined in curses 23 | # _UBUNTU key constants work in Ubuntu 24 | KEY_S_UP = 337 25 | KEY_S_DOWN = 336 26 | KEY_C_UP = 567 27 | KEY_C_UP_UBUNTU = 566 28 | KEY_C_DOWN = 526 29 | KEY_C_DOWN_UBUNTU = 525 30 | 31 | # Additional User Interface Related Constants 32 | VERTICAL_SCROLL_RATE = 16 33 | HORIZONTAL_SCROLL_RATE = 4 34 | 35 | 36 | def pad_hex(value: int, pad: int = 3) -> str: 37 | """ 38 | Convert integer value to a hex string with padding 39 | 40 | :param value: number of spaces to pad hex value 41 | :type value: int 42 | 43 | :param pad: the ammount of padding to add 44 | :type pad: int 45 | 46 | :return: padded string 47 | :rtype: str 48 | """ 49 | return f'0x{hex(value).upper()[2:].rjust(pad, "0")}' 50 | 51 | 52 | def trunc_timedelta(value: dt.timedelta, pad: int = 0): 53 | TIME_UNITS = {'d': 86400, 'h': 3600, 'm': 60, 's': 1, 'ms': 0.1} 54 | time_str = "" 55 | seconds = value.total_seconds() 56 | 57 | for name, unit_len in TIME_UNITS.items(): 58 | if(name == 'ms' and time_str != ''): 59 | continue 60 | res = int(seconds // unit_len) 61 | seconds -= (res * unit_len) 62 | 63 | if(res > 0): 64 | time_str += f'{res}{name}' 65 | 66 | return time_str 67 | 68 | 69 | class KeyMap(Enum): 70 | """ 71 | Enumerator of valid keyboard input 72 | value[0]: input name 73 | value[1]: input description 74 | value[2]: curses input value key 75 | """ 76 | 77 | F1 = {'name': 'F1', 'description': 'Toggle app info menu', 78 | 'key': curses.KEY_F1} 79 | F2 = {'name': 'F2', 'description': 'Toggle this menu', 'key': curses.KEY_F2} 80 | F3 = {'name': 'F3', 'description': 'DEPRECATED FUNCTION', 81 | 'key': curses.KEY_F3} 82 | F4 = {'name': 'F4', 'description': 'Toggle add interface', 83 | 'key': curses.KEY_F4} 84 | F5 = {'name': 'F5', 'description': 'Toggle remove interface', 85 | 'key': curses.KEY_F5} 86 | F6 = {'name': 'F6', 'description': 'Clear screen', 87 | 'key': curses.KEY_F6} 88 | UP_ARR = {'name': 'Up Arrow', 'description': 'Scroll pane up 1 row', 89 | 'key': curses.KEY_UP} 90 | DOWN_ARR = {'name': 'Down Arrow', 'description': 'Scroll pane down 1 row', 91 | 'key': curses.KEY_DOWN} 92 | LEFT_ARR = {'name': 'Left Arrow', 'description': 'Scroll pane left 4 cols', 93 | 'key': curses.KEY_LEFT} 94 | RIGHT_ARR = {'name': 'Right Arrow', 95 | 'description': 'Scroll pane right 4 cols', 96 | 'key': curses.KEY_RIGHT} 97 | S_UP_ARR = {'name': 'Shift + Up Arrow', 98 | 'description': 'Scroll pane up 16 rows', 'key': KEY_S_UP} 99 | S_DOWN_ARR = {'name': 'Shift + Down Arrow', 100 | 'description': 'Scroll pane down 16 rows', 'key': KEY_S_DOWN} 101 | C_UP_ARR = {'name': 'Ctrl + Up Arrow', 102 | 'description': 'Move pane selection up', 103 | 'key': [KEY_C_UP, KEY_C_UP_UBUNTU]} 104 | C_DOWN_ARR = {'name': 'Ctrl + Down Arrow', 105 | 'description': 'Move pane selection down', 106 | 'key': [KEY_C_DOWN, KEY_C_DOWN_UBUNTU]} 107 | RESIZE = {'name': 'Resize Terminal', 108 | 'description': 'Reset the dimensions of the app', 109 | 'key': curses.KEY_RESIZE} 110 | 111 | 112 | class App: 113 | """ 114 | The User Interface Container 115 | :param table: The table of CAN messages 116 | :type table: MessageTable 117 | 118 | :param selected_pane_pos: index of currently selected pane 119 | :type selected_pane_pos: int 120 | 121 | :param selected_pane: A reference to the currently selected Pane 122 | :type selected_pane: MessagePane 123 | """ 124 | 125 | def __init__(self: App, message_table: MessageTable, eds_configs: dict, 126 | bus: MagicCANBus, meta: Meta, features: FeatureConfig): 127 | """ 128 | App Initialization function 129 | :param message_table: Reference to shared message table object 130 | :type MessageTable 131 | :param features: Application feature settings 132 | :type features: FeatureConfig 133 | """ 134 | self.table = message_table 135 | self.eds_configs = eds_configs 136 | self.bus = bus 137 | self.selected_pane_pos = 0 138 | self.selected_pane = None 139 | self.meta = meta 140 | self.features = features 141 | self.key_dict = { 142 | KeyMap.UP_ARR.value['key']: self.up, 143 | KeyMap.S_UP_ARR.value['key']: self.shift_up, 144 | KeyMap.C_UP_ARR.value['key'][0]: self.ctrl_up, 145 | KeyMap.C_UP_ARR.value['key'][1]: self.ctrl_up, # Ubuntu key 146 | KeyMap.DOWN_ARR.value['key']: self.down, 147 | KeyMap.S_DOWN_ARR.value['key']: self.shift_down, 148 | KeyMap.C_DOWN_ARR.value['key'][0]: self.ctrl_down, 149 | KeyMap.C_DOWN_ARR.value['key'][1]: self.ctrl_down, # Ubuntu key 150 | KeyMap.LEFT_ARR.value['key']: self.left, 151 | KeyMap.RIGHT_ARR.value['key']: self.right, 152 | KeyMap.RESIZE.value['key']: self.resize, 153 | KeyMap.F1.value['key']: self.f1, 154 | KeyMap.F2.value['key']: self.f2, 155 | # TODO: F3 Disabled until easywin is replaced 156 | # KeyMap.F3.value['key']: self.f3, 157 | KeyMap.F4.value['key']: self.f4, 158 | KeyMap.F5.value['key']: self.f5, 159 | KeyMap.F6.value['key']: self.f6, 160 | } 161 | 162 | def __enter__(self: App) -> App: 163 | """ 164 | Enter the runtime context related to this object 165 | Create the user interface layout. Any changes to the layout should 166 | be done here. 167 | :return: self 168 | :type App 169 | """ 170 | # Monitor setup, take a snapshot of the terminal state 171 | self.screen = curses.initscr() # Initialize standard out 172 | self.screen.scrollok(True) # Enable window scroll 173 | self.screen.keypad(True) # Enable special key input 174 | self.screen.nodelay(True) # Disable user-input blocking 175 | curses.noecho() # disable user-input echo 176 | curses.curs_set(False) # Disable the cursor 177 | self.__init_color_pairs() # Enable colors and create pairs 178 | 179 | # Don't initialize any grids, sub-panes, or windows until standard io 180 | # screen has been initialized 181 | height, width = self.screen.getmaxyx() 182 | height -= 1 183 | self.info_win = PopupWindow(self.screen, 184 | header=f'{APP_NAME.title()}' 185 | f' v{APP_VERSION}', 186 | content=[f'author: {APP_AUTHOR}', 187 | f'license: {APP_LICENSE}', 188 | f'respository: {APP_URL}', 189 | '', 190 | 'Description:', 191 | f'{APP_DESCRIPTION}'], 192 | footer='F1: exit window', 193 | style=curses.color_pair(1)) 194 | self.hotkeys_win = PopupWindow(self.screen, 195 | header='Hotkeys', 196 | content=list( 197 | map(lambda x: 198 | f'{x.value["name"]}: {x.value["description"]}' 199 | f' ({x.value["key"]})', 200 | list(KeyMap))), 201 | footer='F2: exit window', 202 | style=curses.color_pair(1)) 203 | self.add_if_win = InputPopup(self.screen, 204 | header='Add Interface', 205 | footer='ENTER: save, F4: exit window', 206 | style=curses.color_pair(1)) 207 | self.remove_if_win = SelectionPopup(self.screen, 208 | header='Remove Interface', 209 | footer='ENTER: remove, F5: exit window', 210 | style=curses.color_pair(1)) 211 | self.hb_pane = MessagePane(cols=[Column('Node ID', 'node_name'), 212 | Column('State', 'state'), 213 | Column('Status', 'message'), 214 | Column('Error', 'error')], 215 | types=[MessageType.HEARTBEAT], 216 | parent=self.screen, 217 | height=int(height / 2) - 1, 218 | width=width, 219 | y=1, 220 | x=0, 221 | name='Heartbeats', 222 | message_table=self.table) 223 | self.misc_pane = MessagePane(cols=[Column('COB ID', 'arb_id', 224 | fmt_fn=pad_hex), 225 | Column('Node Name', 'node_name'), 226 | Column('Type', 'type'), 227 | Column('Age', 228 | 'age', 229 | trunc_timedelta), 230 | Column('Message', 'message'), 231 | Column('Error', 'error')], 232 | types=[MessageType.NMT, 233 | MessageType.SYNC, 234 | MessageType.TIME, 235 | MessageType.EMER, 236 | MessageType.SDO, 237 | MessageType.PDO], 238 | parent=self.screen, 239 | height=int(height / 2), 240 | width=width, 241 | y=int(height / 2), 242 | x=0, 243 | name='Miscellaneous', 244 | message_table=self.table) 245 | self.__select_pane(self.hb_pane, 0) 246 | self.popups = [self.hotkeys_win, self.info_win, self.add_if_win, 247 | self.remove_if_win] 248 | return self 249 | 250 | def __exit__(self: App, type, value, traceback) -> None: 251 | """ 252 | Exit the runtime context related to this object. 253 | Cleanup any curses settings to allow the terminal 254 | to return to normal 255 | :param type: exception type or None 256 | :param value: exception value or None 257 | :param traceback: exception traceback or None 258 | :return: None 259 | """ 260 | # Monitor destruction, restore terminal state 261 | curses.nocbreak() # Re-enable line-buffering 262 | curses.echo() # Enable user-input echo 263 | curses.curs_set(True) # Enable the cursor 264 | curses.resetty() # Restore the terminal state 265 | curses.endwin() # Destroy the virtual screen 266 | 267 | def up(self): 268 | """ 269 | Up arrow key scrolls pane up 1 row 270 | :return: None 271 | """ 272 | self.selected_pane.scroll_up() 273 | 274 | def shift_up(self): 275 | """ 276 | Shift + Up arrow key scrolls pane up 16 rows 277 | :return: None 278 | """ 279 | self.selected_pane.scroll_up(rate=VERTICAL_SCROLL_RATE) 280 | 281 | def ctrl_up(self): 282 | """ 283 | Ctrl + Up arrow key moves pane selection up 284 | :return: None 285 | """ 286 | self.__select_pane(self.hb_pane, 0) 287 | 288 | def down(self): 289 | """ 290 | Down arrow key scrolls pane down 1 row 291 | :return: None 292 | """ 293 | self.selected_pane.scroll_down() 294 | 295 | def shift_down(self): 296 | """ 297 | Shift + Down arrow key scrolls down pane 16 rows 298 | :return: 299 | """ 300 | self.selected_pane.scroll_down(rate=VERTICAL_SCROLL_RATE) 301 | 302 | def ctrl_down(self): 303 | """ 304 | Ctrl + Down arrow key moves pane selection down 305 | :return: None 306 | """ 307 | self.__select_pane(self.misc_pane, 1) 308 | 309 | def left(self): 310 | """ 311 | Left arrow key scrolls pane left 4 cols 312 | :return: None 313 | """ 314 | self.selected_pane.scroll_left(rate=HORIZONTAL_SCROLL_RATE) 315 | 316 | def right(self): 317 | """ 318 | Right arrow key scrolls pane right 4 cols 319 | :return: None 320 | """ 321 | self.selected_pane.scroll_right(rate=HORIZONTAL_SCROLL_RATE) 322 | 323 | def resize(self): 324 | """ 325 | Resets the dimensions of the app 326 | :return: None 327 | """ 328 | self.hb_pane._reset_scroll_positions() 329 | self.misc_pane._reset_scroll_positions() 330 | self.screen.clear() 331 | 332 | def f1(self): 333 | """ 334 | Toggle app info menu 335 | :return: None 336 | """ 337 | self.toggle_popup(self.info_win) 338 | 339 | def f2(self): 340 | """ 341 | Toggles KeyMap 342 | :return: None 343 | """ 344 | self.toggle_popup(self.hotkeys_win) 345 | 346 | def f3(self): 347 | """ 348 | DEPRECATED: The use of easygui has proven to add more dependency 349 | problems than remove so this function is being dprecated for now 350 | 351 | This function will stay here in order to not break the application 352 | input handler 353 | 354 | :return: None 355 | """ 356 | pass 357 | 358 | def f4(self) -> None: 359 | """ 360 | Toggles Add Interface Popup 361 | :return: None 362 | """ 363 | self.toggle_popup(self.add_if_win) 364 | 365 | def f5(self) -> None: 366 | """ 367 | Toggles Remove Interface Popup 368 | :return: None 369 | """ 370 | self.remove_if_win.content = self.bus.interface_list 371 | self.toggle_popup(self.remove_if_win) 372 | 373 | def f6(self) -> None: 374 | """ 375 | Clears screen by clearing all panes' messages and then clearing the panes themselves. 376 | :return: None 377 | """ 378 | self.hb_pane.clear_messages() 379 | self.misc_pane.clear_messages() 380 | self.hb_pane.clear() 381 | self.misc_pane.clear() 382 | 383 | def toggle_popup(self, selected_popup) -> None: 384 | for popup in self.popups: 385 | if popup != selected_popup and popup.enabled: 386 | popup.toggle() 387 | popup.clear() 388 | 389 | selected_popup.toggle() 390 | 391 | def handle_keyboard_input(self: App) -> None: 392 | """ 393 | Retrieves keyboard input and calls the associated key function 394 | """ 395 | keyboard_input = self.screen.getch() 396 | curses.flushinp() 397 | 398 | if self.add_if_win.enabled: 399 | if keyboard_input == curses.KEY_ENTER or \ 400 | keyboard_input == 10 or keyboard_input == 13: 401 | value = self.add_if_win.get_value() 402 | if value != "": 403 | self.bus.add_interface(value) 404 | self.meta.save_interfaces(self.bus) 405 | self.add_if_win.toggle() 406 | else: 407 | self.add_if_win.read_input(keyboard_input) 408 | 409 | elif self.remove_if_win.enabled: 410 | if keyboard_input == curses.KEY_ENTER or \ 411 | keyboard_input == 10 or keyboard_input == 13: 412 | value = self.remove_if_win.get_value() 413 | if value != "": 414 | self.bus.remove_interface(value) 415 | self.meta.save_interfaces(self.bus) 416 | self.remove_if_win.toggle() 417 | else: 418 | self.remove_if_win.read_input(keyboard_input) 419 | 420 | try: 421 | self.key_dict[keyboard_input]() 422 | except KeyError: 423 | ... 424 | 425 | def __init_color_pairs(self: App) -> None: 426 | """ 427 | Initialize color options used by curses 428 | :return: None 429 | """ 430 | curses.start_color() 431 | # Implied: color pair 0 is standard black and white 432 | curses.init_pair(1, curses.COLOR_GREEN, curses.COLOR_BLACK) 433 | curses.init_pair(2, curses.COLOR_YELLOW, curses.COLOR_BLACK) 434 | curses.init_pair(3, curses.COLOR_RED, curses.COLOR_BLACK) 435 | curses.init_pair(4, curses.COLOR_CYAN, curses.COLOR_BLACK) 436 | curses.init_pair(5, curses.COLOR_MAGENTA, curses.COLOR_BLACK) 437 | 438 | def __select_pane(self: App, pane: MessagePane, pos: int) -> None: 439 | """ 440 | Set Pane as Selected 441 | :param pane: Reference to selected Pane 442 | :param pos: Index of Selected Pane 443 | :return: None 444 | """ 445 | # Only undo previous selection if there was any 446 | if (self.selected_pane is not None): 447 | self.selected_pane.selected = False 448 | 449 | # Select the new pane and change internal Pane state to indicate it 450 | self.selected_pane = pane 451 | self.selected_pane_pos = pos 452 | self.selected_pane.selected = True 453 | 454 | def __draw_header(self: App, ifaces: [tuple]) -> None: 455 | """ 456 | Draw the header at the top of the interface 457 | :param ifaces: CAN Bus Interfaces 458 | :return: None 459 | """ 460 | # Draw the timestamp 461 | date_str = f'{dt.datetime.now().ctime()},' 462 | self.screen.addstr(0, 0, date_str) 463 | pos = len(date_str) + 1 464 | 465 | # Draw the interfaces 466 | for iface in ifaces: 467 | color = curses.color_pair(1) if iface[1] else curses.color_pair(3) 468 | sl = len(iface[0]) 469 | self.screen.addstr(0, pos, iface[0], color) 470 | pos += sl + 1 471 | 472 | def __draw__footer(self: App) -> None: 473 | """ 474 | Draw the footer at the bottom of the interface 475 | :return: None 476 | """ 477 | height, width = self.screen.getmaxyx() 478 | footer = ': Info, : Hotkeys, ' \ 479 | ': Add OD File, ' \ 480 | ': Add Interface, ' \ 481 | ' Remove Interface ' \ 482 | ' Clear Messages' 483 | self.screen.addstr(height - 1, 1, footer) 484 | 485 | def draw(self: App, ifaces: [tuple]) -> None: 486 | """ 487 | Draw the entire interface 488 | :param ifaces: CAN Bus Interfaces 489 | :return: None 490 | """ 491 | window_active = any(popup.enabled for popup in self.popups) 492 | self.__draw_header(ifaces) # Draw header info 493 | 494 | # Draw panes 495 | if (not window_active): 496 | self.hb_pane.draw() 497 | self.misc_pane.draw() 498 | 499 | # Draw windows 500 | for popup in self.popups: 501 | popup.draw() 502 | 503 | self.__draw__footer() 504 | 505 | def refresh(self: App) -> None: 506 | """ 507 | Refresh entire screen 508 | :return: None 509 | """ 510 | self.screen.refresh() 511 | -------------------------------------------------------------------------------- /canopen_monitor/assets/devices.json: -------------------------------------------------------------------------------- 1 | { 2 | "config_format_version": 2, 3 | "dead_timeout": 120, 4 | "devices": [ 5 | "can0" 6 | ], 7 | "stale_timeout": 60 8 | } 9 | -------------------------------------------------------------------------------- /canopen_monitor/assets/features.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0", 3 | "ecss_time": false 4 | } -------------------------------------------------------------------------------- /canopen_monitor/assets/layout.json: -------------------------------------------------------------------------------- 1 | { 2 | "config_format_version": 2, 3 | "data": [ 4 | { 5 | "data": [ 6 | { 7 | "capacity": null, 8 | "fields": { 9 | "COB ID": "arb_id", 10 | "Node Name": "node_name", 11 | "Interface": "interface", 12 | "State": "status", 13 | "Status": "parsed_msg" 14 | }, 15 | "frame_types": [ 16 | "HEARTBEAT" 17 | ], 18 | "name": "Hearbeats", 19 | "type": "message_table" 20 | }, 21 | { 22 | "capacity": null, 23 | "fields": [], 24 | "frame_types": [], 25 | "name": "Info", 26 | "type": "message_table" 27 | } 28 | ], 29 | "split": "vertical", 30 | "type": "grid" 31 | }, 32 | { 33 | "capacity": null, 34 | "fields": { 35 | "COB ID": "arb_id", 36 | "Node Name": "node_name", 37 | "Interface": "interface", 38 | "Type": "message_type", 39 | "Time Stamp": "timestamp", 40 | "Message": "parsed_msg" 41 | }, 42 | "frame_types": [ 43 | "NMT", 44 | "SYNC", 45 | "TIME", 46 | "EMER", 47 | "PDO1_TX", 48 | "PDO1_RX", 49 | "PDO2_TX", 50 | "PDO2_RX", 51 | "PDO3_TX", 52 | "PDO3_RX", 53 | "PDO4_TX", 54 | "PDO4_RX", 55 | "SDO_TX", 56 | "SDO_RX", 57 | "UKNOWN" 58 | ], 59 | "name": "Misc", 60 | "type": "message_table" 61 | } 62 | ], 63 | "split": "horizontal", 64 | "type": "grid" 65 | } 66 | -------------------------------------------------------------------------------- /canopen_monitor/assets/nodes.json: -------------------------------------------------------------------------------- 1 | { 2 | "config_format_version": 2, 3 | "nodes": { 4 | "40": "MDC" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /canopen_monitor/can/__init__.py: -------------------------------------------------------------------------------- 1 | """This module is primarily responsible for providing a reliable high-level 2 | interface to the CAN Bus as well as describing the format and structure of raw 3 | CAN messages according to the 4 | `CANOpen spec `_. 5 | """ 6 | from .message import Message, MessageState, MessageType 7 | from .message_table import MessageTable 8 | from .interface import Interface 9 | from .magic_can_bus import MagicCANBus 10 | 11 | __all__ = [ 12 | 'Message', 13 | "MessageState", 14 | "MessageType", 15 | "MessageTable", 16 | 'Interface', 17 | 'MagicCANBus', 18 | ] 19 | -------------------------------------------------------------------------------- /canopen_monitor/can/interface.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import time 3 | import psutil 4 | import socket 5 | import logging 6 | import datetime as dt 7 | from .message import Message 8 | from pyvit.hw.socketcan import SocketCanDev 9 | 10 | 11 | _SOCK_TIMEOUT = 0.1 12 | _STALE_INTERFACE = dt.timedelta(minutes=1) 13 | 14 | 15 | class Interface(SocketCanDev): 16 | """This is a model of a POSIX interface 17 | 18 | Used to manage a singular interface and any encoded messages streaming 19 | across it 20 | 21 | :param name: Name of the interface bound to 22 | :type name: str 23 | 24 | :param last_activity: Timestamp of the last activity on the interface 25 | :type last_activity: datetime.datetime 26 | """ 27 | 28 | def __init__(self: Interface, if_name: str): 29 | """Interface constructor 30 | 31 | :param if_name: The name of the interface to bind to 32 | :type if_name: str 33 | """ 34 | super().__init__(if_name) 35 | self.name = if_name 36 | self.last_activity = dt.datetime.now() 37 | self.socket.settimeout(_SOCK_TIMEOUT) 38 | 39 | def __enter__(self: Interface) -> Interface: 40 | """The entry point of an `Interface` in a `with` statement 41 | 42 | This binds to the socket interface name specified. 43 | 44 | .. warning:: 45 | This block-waits until the provided interface comes up before 46 | binding to the socket. 47 | 48 | :returns: Itself 49 | :rtype: Interface 50 | 51 | :Example: 52 | 53 | >>> with canopen_monitor.Interface('vcan0') as dev: 54 | >>> print(f'Message: {dev.recv()}') 55 | """ 56 | self.start() 57 | return self 58 | 59 | def __exit__(self: Interface, etype, evalue, traceback) -> None: 60 | """The exit point of an `Interface` in a `with` statement 61 | 62 | :param etype: The type of event 63 | :type etype: str 64 | 65 | :param evalue: The event 66 | :type evalue: str 67 | 68 | :param traceback: The traceback of the previously exited block 69 | :type traceback: TracebackException 70 | """ 71 | self.stop() 72 | 73 | def start(self: Interface, block_wait: bool = True) -> None: 74 | """A wrapper for `pyvit.hw.SocketCanDev.start()` 75 | 76 | If block-waiting is enabled, then instead of imediately binding to the 77 | interface, it waits for the state to change to `UP` first before 78 | binding. 79 | 80 | :param block_wait: Enables block-waiting 81 | :type block_wait: bool 82 | """ 83 | logging.info(f'Binding to socket {self.name} with last activity at {self.last_activity}') 84 | while(block_wait and not self.is_up): 85 | time.sleep(0.01) 86 | 87 | self.socket = socket.socket(socket.PF_CAN, 88 | socket.SOCK_RAW, 89 | socket.CAN_RAW) 90 | super().start() 91 | 92 | def stop(self: Interface) -> None: 93 | """A wrapper for `pyvit.hw.SocketCanDev.stop()` 94 | """ 95 | logging.info(f'Closing interface {self.name}') 96 | super().stop() 97 | self.socket.close() 98 | self.running = False 99 | 100 | def restart(self: Interface) -> None: 101 | """A macro-fuction for restarting the interface connection 102 | 103 | This is the same as doing: 104 | 105 | >>> iface.stop() 106 | >>> iface.start() 107 | """ 108 | logging.warn(f'Restarting interface {self.name}') 109 | self.stop() 110 | self.start(False) 111 | 112 | def recv(self: Interface) -> Message: 113 | """A wrapper for `pyvit.hw.SocketCanDev.recv()` 114 | 115 | Instead of returning a `can.Frame`, it intercepts the `recv()` and 116 | converts it to a `canopen_monitor.Message` at the last minute. 117 | 118 | :return: A loaded `canopen_monitor.Message` from the interface if a 119 | message is recieved within the configured SOCKET_TIMEOUT (default 120 | is 0.3 seconds), otherwise returns None 121 | :rtype: Message, None 122 | """ 123 | try: 124 | frame = super().recv() 125 | self.last_activity = dt.datetime.now() 126 | msg = Message(frame.arb_id, 127 | data=list(frame.data), 128 | frame_type=frame.frame_type, 129 | interface=self.name, 130 | timestamp=dt.datetime.now(), 131 | extended=frame.is_extended_id) 132 | logging.debug(f'Received from {self.name}: {msg}') 133 | return msg 134 | except socket.timeout: 135 | return None 136 | 137 | @property 138 | def is_up(self: Interface) -> bool: 139 | """Determines if the interface is in the `UP` state 140 | 141 | :returns: `True` if in the `UP` state `False` if in the `DOWN` state 142 | :rtype: bool 143 | """ 144 | if_dev = psutil.net_if_stats().get(self.name) 145 | if(if_dev is not None): 146 | return if_dev.isup 147 | return False 148 | 149 | @property 150 | def duplex(self: Interface) -> int: 151 | """Determines the duplex, if there is any 152 | 153 | :returns: Duplex value 154 | :rtype: int 155 | """ 156 | val = Interface.__get_if_data(self.name) 157 | return val.duplex if val is not None else None 158 | 159 | @property 160 | def speed(self: Interface) -> int: 161 | """Determines the Baud Rate of the bus, if any 162 | 163 | .. warning:: 164 | 165 | This will appear as `0` for virtual can interfaces. 166 | 167 | :return: Baud rate 168 | :rtype: int 169 | """ 170 | val = Interface.__get_if_data(self.name) 171 | return val.speed if val is not None else None 172 | 173 | @property 174 | def mtu(self: Interface) -> int: 175 | """Maximum Transmission Unit 176 | 177 | :return: Maximum size of a packet 178 | :rtype: int 179 | """ 180 | val = Interface.__get_if_data(self.name) 181 | return val.mtu if val is not None else None 182 | 183 | @property 184 | def age(self: Interface) -> dt.timedelta: 185 | """Deterimes the age of the message, since it was received 186 | 187 | :return: Age of the message 188 | :rtype: datetime.timedelta 189 | """ 190 | return dt.datetime.now() - self.last_activity 191 | 192 | def __str__(self: Interface) -> str: 193 | return self.name 194 | -------------------------------------------------------------------------------- /canopen_monitor/can/magic_can_bus.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from .interface import Interface 3 | from .message import Message 4 | import queue 5 | import threading as t 6 | 7 | 8 | class MagicCANBus: 9 | """This is a macro-manager for multiple CAN interfaces 10 | 11 | :param interfaces: The list of serialized Interface objects the bus is 12 | managing 13 | :type interfaces: [Interface] 14 | """ 15 | 16 | def __init__(self: MagicCANBus, if_names: [str], no_block: bool = False): 17 | self.interfaces = list(map(lambda x: Interface(x), if_names)) 18 | self.no_block = no_block 19 | self.keep_alive_list = dict() 20 | self.message_queue = queue.SimpleQueue() 21 | self.threads = [] 22 | 23 | @property 24 | def statuses(self: MagicCANBus) -> [tuple]: 25 | """This property is simply an aggregate of all of the interfaces and 26 | whether or not they both exist and are in the `UP` state 27 | 28 | :return: a list of tuples containing the interface names and a bool 29 | indication an `UP/DOWN` status 30 | :rtype: [tuple] 31 | """ 32 | return list(map(lambda x: (x.name, x.is_up), self.interfaces)) 33 | 34 | @property 35 | def interface_list(self: MagicCANBus) -> [str]: 36 | """A list of strings representing all interfaces 37 | :return: a list of strings indicating the name of each interface 38 | :rtype: [str] 39 | """ 40 | return list(map(lambda x: str(x), self.interfaces)) 41 | 42 | def add_interface(self: MagicCANBus, interface: str) -> None: 43 | """This will add an interface at runtime 44 | 45 | :param interface: The name of the interface to add 46 | :type interface: string""" 47 | 48 | # Check if interface is already existing 49 | interface_names = self.interface_list 50 | if interface in interface_names: 51 | return 52 | 53 | new_interface = Interface(interface) 54 | self.interfaces.append(new_interface) 55 | self.threads.append(self.start_handler(new_interface)) 56 | 57 | def remove_interface(self: MagicCANBus, interface: str) -> None: 58 | """This will remove an interface at runtime 59 | 60 | :param interface: The name of the interface to remove 61 | :type interface: string""" 62 | 63 | # Check if interface is already existing 64 | interface_names = self.interface_list 65 | if interface not in interface_names: 66 | return 67 | 68 | self.keep_alive_list[interface].clear() 69 | for thread in self.threads: 70 | if thread.name == f'canopen-monitor-{interface}': 71 | thread.join() 72 | self.threads.remove(thread) 73 | del self.keep_alive_list[interface] 74 | 75 | for existing_interface in self.interfaces: 76 | if str(existing_interface) == interface: 77 | self.interfaces.remove(existing_interface) 78 | 79 | def start_handler(self: MagicCANBus, iface: Interface) -> t.Thread: 80 | """This is a wrapper for starting a single interface listener thread 81 | This wrapper also creates a keep alive event for each thread which 82 | can be used to kill the thread. 83 | 84 | .. warning:: 85 | 86 | If for any reason, the interface cannot be listened to, (either 87 | it doesn't exist or there are permission issues in reading from 88 | it), then the default behavior is to stop listening for 89 | messages, block wait for the interface to come back up, then 90 | resume. It is possible that a thread starts but no listener 91 | starts due to a failure to bind to the interface. 92 | 93 | :param iface: The interface to bind to when listening for messages 94 | :type iface: Interface 95 | 96 | :return: The new listener thread spawned 97 | :rtype: threading.Thread 98 | """ 99 | self.keep_alive_list[iface.name] = t.Event() 100 | self.keep_alive_list[iface.name].set() 101 | 102 | tr = t.Thread(target=self.handler, 103 | name=f'canopen-monitor-{iface.name}', 104 | args=[iface], 105 | daemon=True) 106 | tr.start() 107 | return tr 108 | 109 | def handler(self: MagicCANBus, iface: Interface) -> None: 110 | """This is a handler for listening and block-waiting for messages on 111 | the CAN bus 112 | 113 | It will operate on the condition that the Magic Can Bus is still 114 | active, using thread-safe events. 115 | 116 | :param iface: The interface to bind to when listening for messages 117 | :type iface: Interface 118 | """ 119 | 120 | # If the interface is either deleted or goes down, the handler will 121 | # try to start it again and read messages as soon as possible 122 | while (self.keep_alive_list[iface.name].is_set()): 123 | try: 124 | # It is necessary to check `iface.is_up`, so that the handler 125 | # will not block on bus reading if the MCB is trying to 126 | # close all threads and destruct itself 127 | if iface.is_up: 128 | if not iface.running: 129 | iface.start() 130 | frame = iface.recv() 131 | if frame is not None: 132 | self.message_queue.put(frame, block=True) 133 | else: 134 | iface.stop() 135 | except OSError: 136 | iface.stop() 137 | pass 138 | iface.stop() 139 | 140 | def __enter__(self: MagicCANBus) -> MagicCANBus: 141 | self.threads = list(map(lambda x: self.start_handler(x), 142 | self.interfaces)) 143 | return self 144 | 145 | def __exit__(self: MagicCANBus, 146 | etype: str, 147 | evalue: str, 148 | traceback: any) -> None: 149 | for keep_alive in self.keep_alive_list.values(): 150 | keep_alive.clear() 151 | if (self.no_block): 152 | print('WARNING: Skipping wait-time for threads to close' 153 | ' gracefully.') 154 | else: 155 | print('Press to quit without waiting.') 156 | for tr in self.threads: 157 | print(f'Waiting for thread {tr} to end... ', end='') 158 | tr.join() 159 | print('Done!') 160 | 161 | def __iter__(self: MagicCANBus) -> MagicCANBus: 162 | return self 163 | 164 | def __next__(self: MagicCANBus) -> Message: 165 | if (self.message_queue.empty()): 166 | raise StopIteration 167 | return self.message_queue.get(block=True) 168 | 169 | def __str__(self: MagicCANBus) -> str: 170 | # Subtract 1 since the parent thread should not be counted 171 | alive_threads = t.active_count() - 1 172 | if_list = ', '.join(list(map(lambda x: str(x), self.interfaces))) 173 | return f"Magic Can Bus: {if_list}," \ 174 | f" pending messages: {self.message_queue.qsize()}" \ 175 | f" threads: {alive_threads}" 176 | -------------------------------------------------------------------------------- /canopen_monitor/can/message.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import datetime as dt 3 | from enum import Enum 4 | from pyvit.can import Frame 5 | 6 | STALE_TIME = dt.timedelta(seconds=5) 7 | DEAD_TIME = dt.timedelta(seconds=10) 8 | 9 | 10 | class MessageType(Enum): 11 | """This enumeration describes all of the ranges in the CANOpen spec that 12 | defines specific kinds of messages. 13 | 14 | See `wikipedia 15 | `_ 16 | for details 17 | """ 18 | # Regular CANOpen message types 19 | NMT = (0x0, 0x0) 20 | SYNC = (0x1, 0x7F) 21 | TIME = (0x100, 0x100) 22 | EMER = (0x80, 0x0FF) 23 | PDO1_TX = (0x180, 0x1FF) 24 | PDO1_RX = (0x200, 0x27F) 25 | PDO2_TX = (0x280, 0x2FF) 26 | PDO2_RX = (0x300, 0x37F) 27 | PDO3_TX = (0x380, 0x3FF) 28 | PDO3_RX = (0x400, 0x47F) 29 | PDO4_TX = (0x480, 0x4FF) 30 | PDO4_RX = (0x500, 0x57F) 31 | SDO_TX = (0x580, 0x5FF) 32 | SDO_RX = (0x600, 0x680) 33 | HEARTBEAT = (0x700, 0x7FF) 34 | 35 | # Special Types 36 | UKNOWN = (-0x1, -0x1) # Pseudo type unknown 37 | PDO = (0x180, 0x57F) # Super type PDO 38 | SDO = (0x580, 0x680) # Super type SDO 39 | 40 | def __init__(self, start, end): 41 | self.start = start 42 | self.end = end 43 | 44 | @property 45 | def supertype(self: MessageType) -> MessageType: 46 | """Determines the "Supertype" of a Message 47 | 48 | There are only two supertypes: MessageType.PDO and MessageType.SDO, 49 | and they emcompass all of the PDO_T/RX and SDO_T/RX ranges 50 | respectively. This simply returns which range the type is in if any, 51 | or MessageType.UNKNOWN if it's in neither supertype range. 52 | 53 | :return: The supertype of this type 54 | :rtype: MessageType 55 | """ 56 | if self.PDO.start <= self.start <= self.PDO.end: 57 | return MessageType['PDO'] 58 | elif self.SDO.start <= self.start <= self.SDO.end: 59 | return MessageType['SDO'] 60 | else: 61 | return MessageType['UKNOWN'] 62 | 63 | @staticmethod 64 | def cob_to_node(msg_type: MessageType, cob_id: int) -> int: 65 | """Determines the Node ID based on the given COB ID 66 | 67 | The COB ID is the raw ID sent with the CAN message, and the node id is 68 | simply the sub-id within the COB ID, which is used as a device 69 | identifier. 70 | 71 | :Example: 72 | 73 | If the COB ID is 0x621 74 | 75 | Then the Type is SDO_RX (an SDO being received) 76 | 77 | The start of the SDO_RX range is 0x600 78 | 79 | Therefore the Node ID is 0x621 - 0x600 = 0x21 80 | 81 | :param mtype: The message type 82 | :type mtype: MessageType 83 | 84 | :param cob_id: The Raw CAN Message COB ID 85 | :type cob_id: int 86 | 87 | :return: The Node ID 88 | :rtype: int 89 | """ 90 | return cob_id - msg_type.start 91 | 92 | @staticmethod 93 | def cob_id_to_type(cob_id: int) -> MessageType: 94 | """Determines the message type based on the COB ID 95 | 96 | :param cob_id: The Raw CAN Message COB ID 97 | :type cob_id: int 98 | 99 | :return: The message type (range) the COB ID fits into 100 | :rtype: MessageType 101 | """ 102 | for msg_type in list(MessageType): 103 | if msg_type.start <= cob_id <= msg_type.end: 104 | return msg_type 105 | return MessageType['UKNOWN'] 106 | 107 | def __str__(self) -> str: 108 | return self.name 109 | 110 | 111 | class MessageState(Enum): 112 | """This enumeration describes all possible states of a CAN Message 113 | 114 | +-----+----------+ 115 | |State|Age (sec) | 116 | +=====+==========+ 117 | |ALIVE|x<60 | 118 | +-----+----------+ 119 | |STALE|60<=x<=120| 120 | +-----+----------+ 121 | |DEAD |120<=x | 122 | +-----+----------+ 123 | """ 124 | ALIVE = 'Alive' 125 | STALE = 'Stale' 126 | DEAD = 'Dead' 127 | 128 | def __str__(self: MessageState) -> str: 129 | return self.value + ' ' 130 | 131 | 132 | class Message(Frame): 133 | """This class is a wrapper class for the `pyvit.can.Frame` class 134 | 135 | :ref: `See this for documentation on a PyVit Frame 136 | `_ 137 | 138 | It's primary purpose is to carry all of the same CAN message data as a 139 | frame, while adding age and state attributes as well. 140 | """ 141 | 142 | def __init__(self: Message, arb_id: int, **kwargs): 143 | super().__init__(arb_id, **kwargs) 144 | self.node_name = 'N/A' 145 | self.message = self.data 146 | 147 | @property 148 | def age(self: Message) -> dt.timedelta: 149 | """The age of the Message since it was received from the CAN bus 150 | 151 | :return: Age of the message 152 | :rtype: datetime.timedelta 153 | """ 154 | return dt.datetime.now() - self.timestamp 155 | 156 | @property 157 | def state(self: Message) -> MessageState: 158 | """The state of the message since it was received from the CAN bus 159 | 160 | :return: State of the message 161 | :rtype: MessageState 162 | """ 163 | if self.age >= DEAD_TIME: 164 | return MessageState['DEAD'] 165 | elif self.age >= STALE_TIME: 166 | return MessageState['STALE'] 167 | else: 168 | return MessageState['ALIVE'] 169 | 170 | @property 171 | def type(self: Message) -> MessageType: 172 | """Type of CAN Message 173 | 174 | :return: CAN Message Type 175 | :rtype: MessageType 176 | """ 177 | return MessageType.cob_id_to_type(self.arb_id) 178 | 179 | @property 180 | def supertype(self: Message) -> MessageType: 181 | """Super-Type of CAN Message 182 | 183 | :return: CAN Message Super-Type 184 | :rtype: MessageType 185 | """ 186 | return self.type.supertype 187 | 188 | @property 189 | def node_id(self: Message) -> int: 190 | """The Node ID, otherwise known as the unique device identifier 191 | 192 | This is a property that is arbitratily decided in an Object Dictionary 193 | and can sometimes have a name attatched to it 194 | 195 | .. example:: 196 | 197 | 0x621 and 0x721 are addressing the same device on the network, 198 | because both of them share the Node ID of 0x21 199 | 200 | :return: Node ID 201 | :rtype: int 202 | """ 203 | return MessageType.cob_to_node(self.type, self.arb_id) 204 | 205 | def __lt__(self: Message, src: Message): 206 | """Overloaded less-than operator, primarilly to support `sorted()` 207 | on a list of `Message`, such that it's sorted by COB ID 208 | 209 | :param src: The right-hand message to compare against 210 | :type src: Message 211 | 212 | .. example:: 213 | 214 | self < src 215 | """ 216 | return self._arb_id < src._arb_id 217 | -------------------------------------------------------------------------------- /canopen_monitor/can/message_table.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from .message import Message, MessageType 3 | 4 | 5 | class MessageTable: 6 | def __init__(self: MessageTable, parser=None): 7 | self.table = {} 8 | self.parser = parser 9 | 10 | def __add__(self: MessageTable, message: Message) -> MessageTable: 11 | if(self.parser is not None): 12 | message.node_name = self.parser.get_name(message) 13 | message.message, message.error = self.parser.parse(message) 14 | self.table[message.arb_id] = message 15 | return self 16 | 17 | def __len__(self: MessageTable) -> int: 18 | return len(self.table) 19 | 20 | def clear(self) -> None: 21 | """ 22 | Clear the table to remove all its messages. 23 | """ 24 | self.table = {} 25 | 26 | def filter(self: MessageTable, 27 | types: MessageType, 28 | start: int = 0, 29 | end: int = None, 30 | sort_by: str = 'arb_id', 31 | reverse=False) -> [Message]: 32 | end = len(self.table) if end is None else end 33 | messages = list(filter(lambda x: x.type in types 34 | or x.supertype in types, self.table.values())) 35 | slice = messages[start:end] 36 | return sorted(slice, key=lambda x: getattr(x, sort_by), reverse=reverse) 37 | 38 | def __contains__(self: MessageTable, node_id: int) -> bool: 39 | return node_id in self.table 40 | 41 | def __iter__(self: MessageTable) -> MessageTable: 42 | self.__keys = sorted(list(self.table.keys())) 43 | return self 44 | 45 | def __next__(self: MessageTable) -> Message: 46 | if(self.__start == self.__stop): 47 | raise StopIteration() 48 | message = self.table[self.__keys[self.__start]] 49 | self.__start += 1 50 | return message 51 | 52 | def __call__(self: MessageTable, 53 | start: int, 54 | stop: int = None) -> MessageTable: 55 | self.__stop = stop if stop < len(self.table) else len(self.table) 56 | self.__start = start if start < self.__stop else self.__stop 57 | return self 58 | -------------------------------------------------------------------------------- /canopen_monitor/meta.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from abc import ABC 3 | 4 | import os 5 | 6 | from .can import MagicCANBus 7 | from os import path 8 | import json 9 | from json import JSONDecodeError 10 | 11 | 12 | class Meta: 13 | def __init__(self, config_dir, cache_dir): 14 | self.config_dir = config_dir 15 | self.cache_dir = cache_dir 16 | self.interfaces_file = self.config_dir + '/interfaces.json' 17 | self.nodes_file = self.config_dir + '/nodes.json' 18 | self.feature_file = self.config_dir + '/features.json' 19 | 20 | def save_interfaces(self, mcb: MagicCANBus) -> None: 21 | interfaceConfig = InterfaceConfig() 22 | interfaceConfig.interfaces = mcb.interface_list 23 | write_config(self.interfaces_file, interfaceConfig) 24 | 25 | def load_interfaces(self, interface_args: [str]) -> [str]: 26 | interfaceConfig = InterfaceConfig() 27 | load_config(self.interfaces_file, interfaceConfig) 28 | for interface in interfaceConfig.interfaces: 29 | if interface not in interface_args: 30 | interface_args.append(interface) 31 | 32 | return interface_args 33 | 34 | def load_features(self) -> FeatureConfig: 35 | features = FeatureConfig() 36 | load_config(self.feature_file, features) 37 | return features 38 | 39 | def load_node_overrides(self) -> dict: 40 | pass 41 | 42 | 43 | def write_config(filename: str, config: Config) -> None: 44 | output = config.__dict__ 45 | with open(filename, "w") as f: 46 | json.dump(output, f, indent=4) 47 | f.truncate() 48 | 49 | 50 | def load_config(filename: str, config: Config) -> None: 51 | try: 52 | if not path.isfile(filename): 53 | return write_config(filename, config) 54 | 55 | with open(filename, "r") as f: 56 | json_data = json.load(f) 57 | 58 | parsed_version = str(json_data.get('version', '1.0')).split('.') 59 | 60 | # Major version mismatch or invalid version indicates a breaking change 61 | if len(parsed_version) != 2 or not parsed_version[0].isnumeric() or\ 62 | not parsed_version[1].isnumeric() or int(parsed_version[0]) != config.MAJOR: 63 | return overwrite_config(filename, config) 64 | 65 | return config.load(json_data) 66 | 67 | except (JSONDecodeError, OSError, IOError): 68 | return overwrite_config(filename, config) 69 | 70 | 71 | def overwrite_config(filename: str, config: Config) -> None: 72 | if path.isfile(filename): 73 | backup_filename = filename + ".bak" 74 | count = 1 75 | while path.isfile(backup_filename): 76 | backup_filename = filename + f"-{count}.bak" 77 | count += 1 78 | 79 | os.rename(filename, backup_filename) 80 | 81 | return write_config(filename, config) 82 | 83 | 84 | class Config(ABC): 85 | MAJOR = 1 86 | MINOR = 0 87 | 88 | def __init__(self, major: int, minor: int): 89 | self.version = f"{major}.{minor}" 90 | 91 | def load(self, data: dict) -> None: 92 | self.version = data.get('version', self.version) 93 | 94 | 95 | class FeatureConfig(Config): 96 | MAJOR = 1 97 | MINOR = 0 98 | 99 | def __init__(self): 100 | super().__init__(self.MAJOR, self.MINOR) 101 | self.ecss_time = False 102 | 103 | def load(self, data: dict) -> None: 104 | super().load(data) 105 | self.ecss_time = data.get('ecss_time', self.ecss_time) 106 | 107 | 108 | class InterfaceConfig(Config): 109 | MAJOR = 1 110 | MINOR = 0 111 | 112 | def __init__(self): 113 | super().__init__(self.MAJOR, self.MINOR) 114 | self.interfaces = [] 115 | 116 | def load(self, data: dict) -> None: 117 | super().load(data) 118 | 119 | loaded_interfaces = data.get('interfaces', self.interfaces) 120 | for interface in loaded_interfaces: 121 | self.interfaces.append(interface) 122 | -------------------------------------------------------------------------------- /canopen_monitor/parse/__init__.py: -------------------------------------------------------------------------------- 1 | """This module is primarily responsible for providing a high-level interface 2 | for parsing CANOpen messages according to Object Definiton files or Electronic 3 | Data Sheet files, provided by the end user. 4 | """ 5 | from .eds import EDS, load_eds_file, load_eds_files, DataType 6 | from .canopen import CANOpenParser 7 | 8 | __all__ = [ 9 | 'CANOpenParser', 10 | 'EDS', 11 | 'load_eds_file', 12 | 'DataType', 13 | 'load_eds_files' 14 | ] 15 | -------------------------------------------------------------------------------- /canopen_monitor/parse/canopen.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | from ..can import Message, MessageType 3 | from . import hb as HBParser, \ 4 | pdo as PDOParser, \ 5 | sync as SYNCParser, \ 6 | emcy as EMCYParser, \ 7 | time as TIMEParser 8 | from .sdo import SDOParser 9 | from .utilities import FailedValidationError, format_bytes 10 | 11 | 12 | class CANOpenParser: 13 | """ 14 | A convenience wrapper for the parse function 15 | """ 16 | def __init__(self, eds_configs: dict): 17 | self.sdo_parser = SDOParser() 18 | self.eds_configs = eds_configs 19 | 20 | def get_name(self, message: Message) -> Union[str, None]: 21 | # import ipdb; ipdb.set_trace() 22 | parser = self.eds_configs.get(message.node_id) 23 | return parser.device_commissioning.node_name \ 24 | if parser else hex(message.node_id) 25 | 26 | def parse(self, message: Message) -> (str, str): 27 | """ 28 | Detect the type of the given message and return the parsed version 29 | 30 | Arguments 31 | --------- 32 | @:param: message: a Message object containing the message 33 | 34 | Returns 35 | ------- 36 | `str`: The parsed message 37 | 38 | """ 39 | node_id = message.node_id 40 | eds_config = self.eds_configs.get(node_id) \ 41 | if node_id is not None else None 42 | 43 | # Detect message type and select the appropriate parse function 44 | if (message.type == MessageType.SYNC): 45 | parse_function = SYNCParser.parse 46 | elif (message.type == MessageType.EMER): 47 | parse_function = EMCYParser.parse 48 | elif (message.supertype == MessageType.PDO): 49 | parse_function = PDOParser.parse 50 | elif (message.supertype == MessageType.SDO): 51 | if self.sdo_parser.is_complete: 52 | self.sdo_parser = SDOParser() 53 | parse_function = self.sdo_parser.parse 54 | elif (message.type == MessageType.HEARTBEAT): 55 | parse_function = HBParser.parse 56 | elif (message.type == MessageType.TIME): 57 | parse_function = TIMEParser.parse 58 | else: 59 | parse_function = None 60 | 61 | # Call the parse function and save the result 62 | # On error, return the message data 63 | try: 64 | parsed_message = parse_function(message.arb_id, 65 | message.data, 66 | eds_config) 67 | error = "" 68 | except (FailedValidationError, TypeError) as exception: 69 | parsed_message = format_bytes(message.data) 70 | error = str(exception) 71 | 72 | return parsed_message, error 73 | -------------------------------------------------------------------------------- /canopen_monitor/parse/eds.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import copy 3 | import string 4 | from re import finditer 5 | from typing import Union 6 | from dateutil.parser import parse as dtparse 7 | import os 8 | from enum import Enum 9 | 10 | 11 | class DataType(Enum): 12 | BOOLEAN = '0x0001' 13 | INTEGER8 = '0x0002' 14 | INTEGER16 = '0x0003' 15 | INTEGER32 = '0x0004' 16 | UNSIGNED8 = '0x0005' 17 | UNSIGNED16 = '0x0006' 18 | UNSIGNED32 = '0x0007' 19 | REAL32 = '0x0008' 20 | VISIBLE_STRING = '0x0009' 21 | OCTET_STRING = '0x000A' 22 | UNICODE_STRING = '0x000B' 23 | TIME_OF_DAY = '0x000C' 24 | TIME_DIFFERENCE = '0x000D' 25 | DOMAIN = '0x000F' 26 | INTEGER24 = '0x0010' 27 | REAL64 = '0x0011' 28 | INTEGER40 = '0x0012' 29 | INTEGER48 = '0x0013' 30 | INTEGER56 = '0x0014' 31 | INTEGER64 = '0x0015' 32 | UNSIGNED24 = '0x0016' 33 | UNSIGNED40 = '0x0018' 34 | UNSIGNED48 = '0x0019' 35 | UNSIGNED56 = '0x001A' 36 | UNSIGNED64 = '0x001B' 37 | PDO_COMMUNICATION_PARAMETER = '0x0020' 38 | PDO_MAPPING = '0x0021' 39 | SDO_PARAMETER = '0x0022' 40 | IDENTITY = '0x0023' 41 | 42 | # Used by ECSS Time feature only 43 | ECSS_TIME = 'ECSS_TIME' 44 | 45 | # Data Type Groupings 46 | UNSIGNED_INTEGERS = (UNSIGNED8, UNSIGNED16, UNSIGNED32, UNSIGNED24, 47 | UNSIGNED40, UNSIGNED48, UNSIGNED56, UNSIGNED64) 48 | 49 | SIGNED_INTEGERS = (INTEGER8, INTEGER16, INTEGER32, INTEGER24, 50 | INTEGER40, INTEGER48, INTEGER56, INTEGER64) 51 | 52 | FLOATING_POINTS = (REAL32, REAL64) 53 | 54 | NON_FORMATTED = (DOMAIN, PDO_COMMUNICATION_PARAMETER, PDO_MAPPING, 55 | SDO_PARAMETER, IDENTITY) 56 | 57 | 58 | def camel_to_snake(old_str: str) -> str: 59 | """ 60 | Converts camel cased string to snake case, counting groups of repeated 61 | capital letters (such as "PDO") as one unit That is, string like 62 | "PDO_group" become "pdo_group" instead of "p_d_o_group" 63 | 64 | :param old_str: The string to convert to camel_case 65 | :type old_str: str 66 | 67 | :return: the camel-cased string 68 | :rtype: str 69 | """ 70 | # Find all groups that contains one or more capital letters followed by 71 | # one or more lowercase letters The new, camel_cased string will be built 72 | # up along the way 73 | new_str = "" 74 | for match in finditer('[A-Z0-9]+[a-z]*', old_str): 75 | span = match.span() 76 | substr = old_str[span[0]:span[1]] 77 | found_submatch = False 78 | 79 | # Add a "_" to the newstring to separate the current match group from 80 | # the previous It looks like we shouldn't need to worry about getting 81 | # "_strings_like_this", because they don't seem to happen 82 | if (span[0] != 0): 83 | new_str += '_' 84 | 85 | # Find all sub-groups of *more than one* capital letters within the 86 | # match group, and separate them with "_" characters, Append the 87 | # subgroups to the new_str as they are found If no subgroups are 88 | # found, just append the match group to the new_str 89 | for sub_match in finditer('[A-Z]+', substr): 90 | sub_span = sub_match.span() 91 | sub_substr = old_str[sub_span[0]:sub_span[1]] 92 | sub_length = sub_span[1] - sub_span[0] 93 | 94 | if (sub_length > 1): 95 | found_submatch = True 96 | 97 | first = sub_substr[:-1] 98 | second = substr.replace(first, '') 99 | 100 | new_str += '{}_{}'.format(first, second).lower() 101 | 102 | if (not found_submatch): 103 | new_str += substr.lower() 104 | 105 | return new_str 106 | 107 | 108 | class Metadata: 109 | def __init__(self, data): 110 | # Process all sub-data 111 | for e in data: 112 | # Skip comment lines 113 | if (e[0] == ';'): 114 | continue 115 | 116 | # Separate field name from field value 117 | key, value = e.split('=') 118 | 119 | # Create the proper field name 120 | key = camel_to_snake(key) 121 | 122 | # Turn date-time-like objects into datetimes 123 | if ('date' in key): 124 | value = dtparse(value).date() 125 | elif ('time' in key): 126 | value = dtparse(value).time() 127 | 128 | # Set the attribute 129 | self.__setattr__(key, value) 130 | 131 | 132 | class Index: 133 | """ 134 | Index Class is used to contain data from a single section of an .eds file 135 | Note: Not all possible properties are stored 136 | """ 137 | 138 | def __init__(self, data, index: Union[str, int], is_sub=False): 139 | # Determine if this is a parent index or a child index 140 | if not is_sub: 141 | self.sub_indices = {} 142 | self.index = index[2:] 143 | else: 144 | self.sub_indices = None 145 | self.index = str(index) 146 | 147 | self.is_sub = is_sub 148 | 149 | # Process all sub-data 150 | for e in data: 151 | # Skip commented lines 152 | if (e[0] == ';'): 153 | continue 154 | 155 | # Separate field name from field value 156 | key, value = e.split('=') 157 | 158 | value = convert_value(value) 159 | 160 | self.__setattr__(camel_to_snake(key), value) 161 | 162 | """ 163 | Add a subindex to an index object 164 | :param index: The subindex being added 165 | :type Index 166 | :raise ValueError: A subindex has already been added a this subindex 167 | """ 168 | 169 | def add(self, index: Index) -> None: 170 | if self.sub_indices.setdefault(int(index.index), index) != index: 171 | raise ValueError 172 | 173 | """ 174 | Add a subindex to an index object 175 | :param index: The subindex being added 176 | :type Index 177 | :raise ValueError: A subindex has already been added a this subindex 178 | """ 179 | 180 | def __getitem__(self, key: int): 181 | if key not in self.sub_indices: 182 | raise KeyError(f"{self.index}sub{key}") 183 | 184 | return self.sub_indices[key] 185 | 186 | def __len__(self) -> int: 187 | if (self.sub_indices is None): 188 | return 1 189 | else: 190 | return len(self.sub_indices) 191 | # return 1 + sum(map(lambda x: len(x), self.sub_indices)) 192 | 193 | 194 | def convert_value(value: str) -> Union[int, str]: 195 | # Turn number-like objects into numbers 196 | if (value != ''): 197 | if value.startswith("0x") and all(c in string.hexdigits for c in value): 198 | return int(value[2:], 16) 199 | if (all(c in string.digits for c in value)): 200 | return int(value, 10) 201 | elif (all(c in string.hexdigits for c in value)): 202 | return int(value, 16) 203 | else: 204 | return value 205 | 206 | 207 | class OD: 208 | def __init__(self): 209 | self.node_id = None 210 | self.indices = {} 211 | self.device_commissioning = None 212 | # tools section is optional per CiA 306 213 | self.tools = None 214 | self.file_info = None 215 | self.device_info = None 216 | self.dummy_usage = None 217 | # comments section is optional per CiA 306 218 | self.comments = None 219 | self.mandatory_objects = None 220 | self.optional_objects = None 221 | self.manufacturer_objects = None 222 | 223 | def extended_pdo_definition(self, offset: int) -> OD: 224 | # TODO: Move to constant with message types 225 | pdo_tx = 0x1A00 226 | pdo_tx_offset = 0x1A00 + (offset * 4) 227 | pdo_rx = 0x1600 228 | pdo_rx_offset = 0x1600 + (offset * 4) 229 | node = OD() 230 | node.node_id = copy.deepcopy(self.node_id) 231 | node.device_commissioning = copy.deepcopy(self.device_commissioning) 232 | node.tools = copy.deepcopy(self.tools) 233 | node.file_info = copy.deepcopy(self.file_info) 234 | node.device_info = copy.deepcopy(self.device_info) 235 | node.dummy_usage = copy.deepcopy(self.dummy_usage) 236 | node.comments = copy.deepcopy(self.dummy_usage) 237 | node.mandatory_objects = copy.deepcopy(self.dummy_usage) 238 | node.optional_objects = copy.deepcopy(self.optional_objects) 239 | node.manufacturer_objects = copy.deepcopy(self.manufacturer_objects) 240 | node.indices = copy.deepcopy(self.indices) 241 | 242 | if (pdo_tx_offset not in self and pdo_rx_offset not in self) or \ 243 | (self[pdo_tx_offset].parameter_name != "TPDO mapping parameter" 244 | and self[pdo_rx_offset].parameter_name != "RPDO mapping parameter"): 245 | 246 | raise KeyError("Extended PDO definitions not found") 247 | 248 | self.get_pdo_offset(node, pdo_tx, pdo_tx_offset) 249 | self.get_pdo_offset(node, pdo_rx, pdo_rx_offset) 250 | 251 | return node 252 | 253 | def get_pdo_offset(self, node: OD, start: int, offset: int): 254 | while offset in self: 255 | node[start] = copy.deepcopy(self[offset]) 256 | start += 1 257 | offset += 1 258 | if start % 4 == 0: 259 | break 260 | 261 | def __len__(self) -> int: 262 | return sum(map(lambda x: len(x), self.indices.values())) 263 | 264 | def __getitem__(self, key: Union[int, str]) -> Index: 265 | callable = hex if type(key) == int else str 266 | key = callable(key) 267 | if key not in self.indices: 268 | raise KeyError(key[2:]) 269 | 270 | return self.indices[key] 271 | 272 | def __setitem__(self, key, value): 273 | callable = hex if type(key) == int else str 274 | key = callable(key) 275 | self.indices[key] = value 276 | 277 | def __contains__(self, item): 278 | callable = hex if type(item) == int else str 279 | item = callable(item) 280 | return item in self.indices 281 | 282 | 283 | class EDS(OD): 284 | def __init__(self, eds_data: [str]): 285 | """Parse the array of EDS lines into a dictionary of Metadata/Index 286 | objects. 287 | 288 | :param eds_data: The list of raw lines from the EDS file. 289 | :type eds_data: [str] 290 | """ 291 | super().__init__() 292 | self.indices = {} 293 | 294 | prev = 0 295 | for i, line in enumerate(eds_data): 296 | if line == '' or i == len(eds_data) - 1: 297 | # Handle extra empty strings 298 | if prev == i: 299 | prev = i + 1 300 | continue 301 | 302 | section = eds_data[prev:i] 303 | id = section[0][1:-1].split('sub') 304 | 305 | if all(c in string.hexdigits for c in id[0]): 306 | index = hex(int(id[0], 16)) 307 | if len(id) == 1: 308 | self.indices[index] = Index(section[1:], index) 309 | else: 310 | self.indices[index] \ 311 | .add(Index(section[1:], int(id[1], 16), 312 | is_sub=True)) 313 | else: 314 | name = section[0][1:-1] 315 | self.__setattr__(camel_to_snake(name), 316 | Metadata(section[1:])) 317 | prev = i + 1 318 | 319 | if self.device_commissioning is not None: 320 | self.node_id = convert_value(self.device_commissioning.node_id) 321 | elif '0x2101' in self.indices.keys(): 322 | self.node_id = self['0x2101'].default_value 323 | else: 324 | self.node_id = None 325 | 326 | 327 | def load_eds_file(filepath: str, enable_ecss: bool = False) -> EDS: 328 | """Read in the EDS file, grab the raw lines, strip them of all escaped 329 | characters, then serialize into an `EDS` and return the resulting 330 | object. 331 | 332 | :param filepath: Path to an eds file 333 | :type filepath: str 334 | :param enable_ecss: Flag to enable ECSS time, defaults to False 335 | :type enable_ecss: bool, optional 336 | :return: The successfully serialized EDS file. 337 | :rtype: EDS 338 | """ 339 | with open(filepath) as file: 340 | od = EDS(list(map(lambda x: x.strip(), file.read().split('\n')))) 341 | if enable_ecss and 0x2101 in od: 342 | od[0x2101].data_type = DataType.ECSS_TIME.value 343 | return od 344 | 345 | 346 | def load_eds_files(filepath: str, enable_ecss: bool = False) -> dict: 347 | """Read a directory of OD files 348 | 349 | :param filepath: Directory to load files from 350 | :type filepath: str 351 | :param enable_ecss: Flag to enable ECSS time, defaults to False 352 | :type enable_ecss: bool, optional 353 | :return: dictionary of OD files with node id as key and OD as value 354 | :rtype: dict 355 | """ 356 | configs = {} 357 | for file in os.listdir(filepath): 358 | full_path = f'{filepath}/{file}' 359 | if file.lower().endswith(".eds") or file.lower().endswith(".dcf"): 360 | config = load_eds_file(full_path, enable_ecss) 361 | configs[config.node_id] = config 362 | try: 363 | i = 1 364 | while True: 365 | extended_node = config.extended_pdo_definition(i) 366 | configs[config.node_id+i] = extended_node 367 | i += 1 368 | except KeyError: 369 | ... 370 | 371 | return configs 372 | -------------------------------------------------------------------------------- /canopen_monitor/parse/emcy.py: -------------------------------------------------------------------------------- 1 | from .eds import EDS 2 | from .utilities import FailedValidationError 3 | 4 | 5 | def parse(cob_id: int, data: list, eds: EDS): 6 | if len(data) != 8: 7 | raise FailedValidationError(data, cob_id-0x80, cob_id, __name__, 8 | "Invalid EMCY message length") 9 | message = EMCY(data) 10 | return message.error_message 11 | 12 | 13 | class EMCY: 14 | """ 15 | 16 | 17 | .. code-block:: python 18 | 19 | 20 | +-------+------+--------+ 21 | | eec | er | msef | 22 | +-------+------+--------+ 23 | 0 1 2 3 7 24 | 25 | Definitions 26 | =========== 27 | * **eec**: Emergency Error Code 28 | * **er**: Error Register 29 | * **mcef**: Manufacturer Specific Error Code 30 | """ 31 | 32 | def __init__(self, raw_sdo: list): 33 | self.__emergency_error_code = raw_sdo[0:2] 34 | self.__error_register = raw_sdo[2] 35 | self.__manufacturer_specific_error_code = raw_sdo[3:8] 36 | self.__error_message = determine_error_message( 37 | self.__emergency_error_code) 38 | 39 | @property 40 | def emergency_error_code(self): 41 | return self.__emergency_error_code 42 | 43 | @property 44 | def error_register(self): 45 | return self.__error_register 46 | 47 | @property 48 | def manufacturer_specific_error_code(self): 49 | return self.__manufacturer_specific_error_code 50 | 51 | @property 52 | def error_message(self): 53 | return self.__error_message 54 | 55 | 56 | def determine_error_message(error_code: list): 57 | """ 58 | Generic Emergency Error Codes are defined here, but application specific 59 | error codes can be defined as well 60 | """ 61 | error_codes = { 62 | 0x0000: "Error reset or no error", 63 | 0x1000: "Generic error", 64 | 0x2000: "Current = generic error", 65 | 0x2100: "Current, CANopen device input side - generic", 66 | 0x2200: "Current inside the CANopen device - generic", 67 | 0x2300: "Current, CANopen device output side - generic", 68 | 0x3000: "Voltage = generic error", 69 | 0x3100: "Mains voltage - generic", 70 | 0x3200: "Voltage inside the CANopen device - generic", 71 | 0x3300: "Output voltage - generic", 72 | 0x4000: "Temperature - generic error", 73 | 0x4100: "Ambient temperature - generic", 74 | 0x4200: "Device temperature - generic", 75 | 0x5000: "CANopen device hardware - generic error", 76 | 0x6000: "CANopen device software - generic error", 77 | 0x6100: "Internal software - generic", 78 | 0x6200: "User software - generic", 79 | 0x6300: "Data set - generic", 80 | 0x7000: "Additional modules - generic error", 81 | 0x8000: "Monitoring - generic error", 82 | 0x8100: "Communication - generic", 83 | 0x8110: "CAN overrun (objects lost)", 84 | 0x8120: "CAN in error passive mode", 85 | 0x8130: "Life guard error on heartbeat error", 86 | 0x8140: "recovered from bus off", 87 | 0x8150: "CAN-ID collision", 88 | 0x8200: "Protocol error - generic", 89 | 0x8210: "PDO not processed due to length error", 90 | 0x8220: "PDO length exceeded", 91 | 0x8230: "DAM MPDO not processed, destination object not available", 92 | 0x8240: "Unexpected SYNC data length", 93 | 0x8250: "RPDO timeout", 94 | 0x9000: "External error - generic error", 95 | 0xF000: "Additional functions - generic error", 96 | 0xFF00: "Device specific - generic error" 97 | } 98 | 99 | # Safe conversion to int ok, because data is bytes 100 | ebytes = list(map(lambda x: hex(x)[2:], error_code)) 101 | error_id = int('0x' + ''.join(ebytes), 16) 102 | if error_id in error_codes.keys(): 103 | return error_codes[error_id] 104 | else: 105 | return "Error code not found" 106 | -------------------------------------------------------------------------------- /canopen_monitor/parse/hb.py: -------------------------------------------------------------------------------- 1 | from .eds import EDS 2 | from .utilities import FailedValidationError 3 | from ..can import MessageType 4 | 5 | STATE_BYTE_IDX = 0 6 | 7 | 8 | def parse(cob_id: int, data: list, eds_config: EDS): 9 | """ 10 | Parse Heartbeat message 11 | 12 | Arguments 13 | --------- 14 | :param data: a byte string containing the heartbeat message, byte 0 is the 15 | heartbeat state info. 16 | 17 | :return: the parsed message 18 | :rtype: str 19 | """ 20 | states = { 21 | 0x00: "Boot-up", 22 | 0x04: "Stopped", 23 | 0x05: "Operational", 24 | 0x7F: "Pre-operational" 25 | } 26 | 27 | node_id = MessageType.cob_to_node(MessageType.HEARTBEAT, cob_id) 28 | if len(data) < 1 or data[STATE_BYTE_IDX] not in states: 29 | raise FailedValidationError(data, node_id, cob_id, __name__, 30 | "Invalid heartbeat state detected") 31 | return states.get(data[STATE_BYTE_IDX]) 32 | -------------------------------------------------------------------------------- /canopen_monitor/parse/pdo.py: -------------------------------------------------------------------------------- 1 | import string 2 | from math import ceil, floor 3 | from .eds import EDS 4 | from .utilities import FailedValidationError, get_name, decode, format_bytes 5 | from ..can import MessageType 6 | 7 | PDO1_TX = 0x1A00 8 | PDO1_RX = 0x1600 9 | PDO2_TX = 0x1A01 10 | PDO2_RX = 0x1601 11 | PDO3_TX = 0x1A02 12 | PDO3_RX = 0x1602 13 | PDO4_TX = 0x1A03 14 | PDO4_RX = 0x1603 15 | 16 | 17 | def parse(cob_id: int, data: bytes, eds: EDS): 18 | """ 19 | PDO mappings come from the eds file and is dependent on the type ( 20 | Receiving/transmission PDO). Mapping value is made up of index subindex 21 | and size. For Example 0x31010120 Means 3101sub01 size 32bit 22 | 23 | The eds mapping is determined by the cob_id passed ot this function. That 24 | indicated which PDO record to look up in the EDS file. 25 | """ 26 | msg_type = MessageType.cob_id_to_type(cob_id) 27 | pdo_type = { 28 | MessageType.PDO1_TX: PDO1_TX, 29 | MessageType.PDO1_RX: PDO1_RX, 30 | MessageType.PDO2_TX: PDO2_TX, 31 | MessageType.PDO2_RX: PDO2_RX, 32 | MessageType.PDO3_TX: PDO3_TX, 33 | MessageType.PDO3_RX: PDO3_RX, 34 | MessageType.PDO4_TX: PDO4_TX, 35 | MessageType.PDO4_RX: PDO4_RX, 36 | MessageType.UKNOWN: None 37 | }[msg_type] 38 | 39 | if(not pdo_type or msg_type.supertype is not MessageType.PDO): 40 | raise FailedValidationError(data, 41 | cob_id - MessageType.PDO1_TX.value[0], 42 | cob_id, 43 | __name__, 44 | f"Unable to determine pdo type with given" 45 | f" cob_id {hex(cob_id)}, expected value" 46 | f" between {MessageType.PDO1_TX.value[0]}" 47 | f" and {MessageType.PDO4_RX.value[1] + 1}") 48 | 49 | if len(data) > 8 or len(data) < 1: 50 | raise FailedValidationError(data, 51 | cob_id - MessageType.PDO1_TX.value[0], 52 | cob_id, 53 | __name__, 54 | f"Invalid payload length {len(data)} " 55 | f"expected between 1 and 8") 56 | try: 57 | eds_elements = eds[hex(pdo_type)][0] 58 | except (TypeError, IndexError): 59 | raise FailedValidationError(data, 60 | cob_id - MessageType.PDO1_TX.value[0], 61 | cob_id, 62 | __name__, 63 | f"Unable to find eds data for pdo type " 64 | f"{hex(pdo_type)}") 65 | 66 | # default_value could be 2 or '0x02', this is meant to work with both 67 | if (c in string.hexdigits for c in str(eds_elements.default_value)): 68 | num_elements = int(str(eds_elements.default_value), 16) 69 | else: 70 | num_elements = int(str(eds_elements.default_value)) 71 | 72 | if num_elements < 0x40: 73 | return parse_pdo(num_elements, pdo_type, cob_id, eds, data) 74 | 75 | if num_elements in (0xFE, 0xFF): 76 | if len(data) != 8: 77 | raise FailedValidationError(data, 78 | cob_id - MessageType.PDO1_TX.value[0], 79 | cob_id, 80 | __name__, 81 | f"Invalid payload length {len(data)} " 82 | f"expected 8") 83 | return parse_mpdo(num_elements, pdo_type, eds, data, cob_id) 84 | 85 | raise FailedValidationError(data, 86 | cob_id - MessageType.PDO1_TX.value[0], 87 | cob_id, 88 | __name__, 89 | f"Invalid pdo mapping detected in eds file at " 90 | f"[{pdo_type}sub0]") 91 | 92 | 93 | def parse_pdo(num_elements, pdo_type, cob_id, eds, data): 94 | """ 95 | Parse pdo message. Message will include num_elements elements. Elements 96 | are processed in reverse order, from rightmost to leftmost 97 | """ 98 | output_string = "" 99 | data_start = 0 100 | for i in range(num_elements, 0, -1): 101 | try: 102 | eds_record = eds[hex(pdo_type)][i] 103 | except (TypeError, IndexError): 104 | raise FailedValidationError(data, 105 | cob_id - MessageType.PDO1_TX.value[0], 106 | cob_id, 107 | __name__, 108 | f"Unable to find eds data for pdo type" 109 | f" {hex(pdo_type)} index {i}") 110 | 111 | pdo_definition = int(eds_record.default_value, 16).to_bytes(4, "big") 112 | 113 | index = pdo_definition[0:3] 114 | size = pdo_definition[3] 115 | mask = 1 116 | for j in range(1, size): 117 | mask = mask << 1 118 | mask += 1 119 | 120 | # Possible exceptions from get_name are not caught because they indicate 121 | # an issue with the PDO definition in the OD file, which should be 122 | # checked when the file is loaded 123 | eds_details = get_name(eds, index) 124 | num_bytes = ceil(size / 8) 125 | 126 | start = len(data) - num_bytes - floor(data_start / 8) 127 | end = len(data) - floor(data_start / 8) 128 | masked_data = int.from_bytes(data[start:end], "big") & mask 129 | masked_data = masked_data >> data_start % 8 130 | masked_data = masked_data.to_bytes(num_bytes, "big") 131 | output_string = f"{eds_details[1]} -" \ 132 | f" {decode(eds_details[0], masked_data)}" + \ 133 | output_string 134 | if i > 1: 135 | output_string = " " + output_string 136 | data_start += size 137 | 138 | return output_string 139 | 140 | 141 | def parse_mpdo(num_elements, pdo_type, eds, data, cob_id): 142 | mpdo = MPDO(data) 143 | if mpdo.is_source_addressing and num_elements != 0xFE: 144 | raise FailedValidationError(data, 145 | cob_id - MessageType.PDO1_TX.value[0], 146 | cob_id, 147 | __name__, 148 | f"MPDO type and definition do not match. " 149 | f"Check eds file at [{pdo_type}sub0]") 150 | 151 | try: 152 | eds_details = get_name(eds, mpdo.index) 153 | except KeyError as e: 154 | raise FailedValidationError(data, 155 | cob_id - MessageType.PDO1_TX.value[0], 156 | cob_id, 157 | __name__, 158 | f"MPDO provided type index does not exist. " 159 | f"Check provided index {str(e)}") 160 | 161 | except ValueError: 162 | raise FailedValidationError(data, 163 | cob_id - MessageType.PDO1_TX.value[0], 164 | cob_id, 165 | __name__, 166 | f"MPDO provided type index is missing " 167 | f"attributes. Check OD file provided index " 168 | f"[{format_bytes(mpdo.index)}") 169 | 170 | return f"{eds_details[1]} - {decode(eds_details[0], mpdo.data)}" 171 | 172 | 173 | class MPDO: 174 | """ 175 | 176 | 177 | .. code-block:: python 178 | 179 | 180 | +-------------+---------+----------------+ 181 | | f addr | m | d | 182 | | 7 6_0 | | | 183 | +-------------+---------+----------------+ 184 | 0 1 3 4 7 185 | 186 | Definitions 187 | =========== 188 | * **f**: address type 189 | 0. Source addressing 190 | 1. Destination addressing 191 | 192 | * **addr**: node-ID of the MPDO consumer in destination addressing or MPDO 193 | producer in source addressing. 0. Shall be reserved in source addressing 194 | mode. Shall address all CANopen devices in the network that are configured 195 | for MPDO reception in destination addressing mode. 1..127. Shall address the 196 | CANopen device in the network with the very same node-ID 197 | 198 | * **m**: multiplexer. It represents the index/sub-index of the process data 199 | to be transferred by the MPDO. In source addressing this shall be used to 200 | identify the data from the transmitting CANopen device or in destination 201 | addressing addressing to identity the data on the receiving CANopen device. 202 | 203 | * **d**: process data. Data length lower than 4 bytes is filled up to fit 204 | 32-bit 205 | """ 206 | 207 | def __init__(self, raw_sdo: bytes): 208 | self.__is_source_addressing = raw_sdo[0] & 0x8 == 0x8 209 | self.__is_destination_addressing = not self.__is_source_addressing 210 | self.__addr = raw_sdo[0] & 0x7F 211 | self.__index = raw_sdo[1:4] 212 | self.__data = raw_sdo[4:8] 213 | 214 | @property 215 | def is_source_addressing(self): 216 | return self.__is_source_addressing 217 | 218 | @property 219 | def is_destination_addressing(self): 220 | return self.__is_destination_addressing 221 | 222 | @property 223 | def addr(self): 224 | return self.__addr 225 | 226 | @property 227 | def index(self): 228 | return self.__index 229 | 230 | @property 231 | def data(self): 232 | return self.__data 233 | -------------------------------------------------------------------------------- /canopen_monitor/parse/sync.py: -------------------------------------------------------------------------------- 1 | from .eds import EDS 2 | from .utilities import FailedValidationError, decode, DataType 3 | 4 | 5 | def parse(cob_id: int, data: bytes, eds: EDS): 6 | if len(data) > 1: 7 | raise FailedValidationError(data, cob_id, cob_id, __name__, 8 | f'SYNC message is outside of bounds ' 9 | f'limit of 1 byte, {len(data)} provided') 10 | return f'SYNC - {decode(DataType.UNSIGNED8.value, data)}' 11 | -------------------------------------------------------------------------------- /canopen_monitor/parse/time.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from typing import List 3 | 4 | from .eds import EDS 5 | from .utilities import FailedValidationError 6 | 7 | """ 8 | the Time-Stamp object represents an absolute time in milliseconds after 9 | midnight and the number of days since January 1, 1984. This is a bit sequence 10 | of length 48 (6 bytes). 11 | """ 12 | 13 | 14 | def parse(cob_id: int, data: List[int], eds: EDS): 15 | if len(data) != 6: 16 | raise FailedValidationError(data, cob_id, cob_id, __name__, 17 | "Invalid TIME message length") 18 | 19 | milliseconds = int.from_bytes(data[0:4], "little") 20 | days = int.from_bytes(data[4:6], "little") 21 | 22 | date = datetime.datetime(1984, 1, 1, 0, 0, 0) \ 23 | + datetime.timedelta(days=days, milliseconds=milliseconds) 24 | 25 | return f"Time - {date.strftime('%m/%d/%Y %H:%M:%S.%f')}" 26 | -------------------------------------------------------------------------------- /canopen_monitor/parse/utilities.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import array 3 | from datetime import datetime, timedelta 4 | from struct import unpack 5 | from typing import List, Union 6 | from .eds import DataType, EDS 7 | 8 | 9 | class FailedValidationError(Exception): 10 | """ 11 | Exception raised for validation errors found when parsing CAN messages 12 | 13 | Attributes 14 | ---------- 15 | bytes - The byte string representation of the message 16 | message - text describing the error (same as __str__) 17 | node_id - if of the node sending the message 18 | cob-id - message cob-id 19 | parse_type - message type that failed (ex: SDO, PDO) 20 | sub_type - sub-type of message that failed (ex: SDO Segment) or None 21 | """ 22 | 23 | def __init__(self, 24 | data, 25 | node_id, 26 | cob_id, 27 | parse_type, 28 | message="A Validation Error has occurred", 29 | sub_type=None): 30 | self.data = data 31 | self.node_id = node_id 32 | self.cob_id = cob_id 33 | self.parse_type = parse_type 34 | self.sub_type = sub_type 35 | self.message = message 36 | self.time_occured = datetime.now() 37 | super().__init__(self.message) 38 | 39 | 40 | def get_name(eds_config: EDS, index: Union[List[int], bytes]) -> (str, str): 41 | """ 42 | Get the name and data type for a given index 43 | :param eds_config: An EDS file for the current node 44 | :param index: the index and subindex to retrieve data from 45 | expected to be length 3. (not validated) 46 | :return: (str, str): a tuple containing the name and data type as a string 47 | :raise: IndexError: The index or subindex failed to find a value in the 48 | provided OD file 49 | :raise: ValueError: The provided index/subindex does not contain a 50 | parameter_name and data_type attribute 51 | """ 52 | index_bytes = list(map(lambda x: hex(x)[2:].rjust(2, '0'), index)) 53 | key = int('0x' + ''.join(index_bytes[:2]), 16) 54 | subindex_key = int('0x' + ''.join(index_bytes[2:3]), 16) 55 | 56 | current = eds_config[hex(key)] 57 | result = eds_config[hex(key)].parameter_name 58 | 59 | if len(current) > 0: 60 | result += " " + eds_config[hex(key)][subindex_key].parameter_name 61 | defined_type = eds_config[hex(key)][subindex_key].data_type 62 | else: 63 | defined_type = eds_config[hex(key)].data_type 64 | 65 | return defined_type, result 66 | 67 | 68 | def decode(defined_type: Union[str, DataType], data: List[int]) -> str: 69 | """ 70 | Decodes data by defined type 71 | :param defined_type: Hex constant for type 72 | :param data: list of ints to be decoded 73 | :return: Decoded data as string 74 | :raise: ValueError: Indicates datatype provided is not supported 75 | """ 76 | if defined_type in DataType.UNSIGNED_INTEGERS.value: 77 | result = str(int.from_bytes(data, byteorder="little", signed=False)) 78 | elif defined_type in DataType.SIGNED_INTEGERS.value: 79 | result = str(int.from_bytes(data, byteorder="little", signed=True)) 80 | elif defined_type == DataType.BOOLEAN.value: 81 | if int.from_bytes(data, byteorder="little", signed=False) > 0: 82 | result = str(True) 83 | else: 84 | result = str(False) 85 | elif defined_type in DataType.FLOATING_POINTS.value: 86 | data = array.array('B', data).tobytes() 87 | result = str(unpack('>f', data)[0]) 88 | elif defined_type == DataType.VISIBLE_STRING.value: 89 | data = array.array('B', data).tobytes() 90 | result = data.decode('utf-8') 91 | elif defined_type == DataType.OCTET_STRING.value: 92 | data = list(map(lambda x: hex(x)[2:].rjust(2, '0'), data)) 93 | result = '0x' + ''.join(data) 94 | elif defined_type == DataType.UNICODE_STRING.value: 95 | data = array.array('B', data).tobytes() 96 | result = data.decode('utf-16-be') 97 | elif defined_type == DataType.TIME_OF_DAY.value: 98 | delta = get_time_values(data) 99 | date = datetime(1984, 1, 1) + delta 100 | result = date.isoformat() 101 | elif defined_type == DataType.TIME_DIFFERENCE.value: 102 | result = str(get_time_values(data)) 103 | elif defined_type in DataType.NON_FORMATTED.value: 104 | result = format_bytes(data) 105 | elif defined_type == DataType.ECSS_TIME.value: 106 | # This is ECSS SCET Time 107 | # data[0:4]: Fine Time: Microseconds 108 | # data[4:8]: Coarse Time: Seconds 109 | coarse = data[4:8] 110 | fine = int.from_bytes(data[:4], byteorder="little", signed=False) 111 | coarse = int.from_bytes(data[4:8], byteorder="little", signed=False) 112 | delta = timedelta(seconds=coarse, microseconds=fine) 113 | date = datetime(1970, 1, 1) + delta 114 | result = date.isoformat() 115 | else: 116 | raise ValueError(f"Invalid data type {defined_type}. " 117 | f"Unable to decode data {str(data)}") 118 | 119 | return result 120 | 121 | 122 | def get_time_values(data: [int]) -> timedelta: 123 | # Component ms is the time in milliseconds after midnight. Component 124 | # days is the number of days since January 1, 1984. 125 | # Format UNSIGNED 28 (ms), VOID4, UNSIGNED 16 (Days) 126 | ms_raw = data[:4] 127 | ms_raw[3] = ms_raw[3] >> 4 128 | ms = int.from_bytes(ms_raw, byteorder="little", signed=False) 129 | days = int.from_bytes(data[5:7], byteorder="little", signed=False) 130 | return timedelta(days=days, milliseconds=ms) 131 | 132 | 133 | def format_bytes(data: Union[List[int], bytes]) -> str: 134 | return ' '.join(list(map(lambda x: hex(x)[2:] 135 | .upper() 136 | .rjust(2, '0'), 137 | data))) 138 | -------------------------------------------------------------------------------- /canopen_monitor/ui/__init__.py: -------------------------------------------------------------------------------- 1 | """This module is responsible for providing a high-level interface for elements 2 | of Curses UI and general user interaction with the app, 3 | """ 4 | from .pane import Pane 5 | from .colum import Column 6 | from .windows import PopupWindow, InputPopup, SelectionPopup 7 | from .message_pane import MessagePane 8 | 9 | __all__ = [ 10 | "Pane", 11 | "Column", 12 | "MessagePane", 13 | "PopupWindow", 14 | "InputPopup", 15 | "SelectionPopup", 16 | ] 17 | -------------------------------------------------------------------------------- /canopen_monitor/ui/colum.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | class Column: 5 | def __init__(self: Column, 6 | name: str, 7 | attr_name: str, 8 | fmt_fn: callable = str, 9 | padding: int = 2): 10 | self.name = name 11 | self.attr_name = attr_name 12 | self.fmt_fn = fmt_fn 13 | self.padding = padding 14 | self.length = len(name) + self.padding 15 | 16 | def update_length(self: Column, object: any) -> bool: 17 | obj_len = len(self.fmt_fn(getattr(object, self.attr_name))) \ 18 | + self.padding 19 | 20 | if(obj_len > self.length): 21 | self.length = obj_len 22 | return True 23 | return False 24 | 25 | @property 26 | def header(self: Column) -> str: 27 | return f'{self.name}{(" " * self.padding)}'.ljust(self.length, ' ') 28 | 29 | def format(self: Column, object: any) -> str: 30 | return f'{self.fmt_fn(getattr(object, self.attr_name))}' \ 31 | f'{(" " * self.padding)}' \ 32 | .ljust(self.length, ' ') 33 | -------------------------------------------------------------------------------- /canopen_monitor/ui/grid.py: -------------------------------------------------------------------------------- 1 | import curses 2 | import enum 3 | 4 | 5 | class Split(enum.Enum): 6 | HORIZONTAL = 0 7 | VERTICAL = 1 8 | 9 | 10 | class Grid: 11 | def __init__(self, parent=None, split=Split.VERTICAL): 12 | if(parent is None): 13 | self.parent = curses.newwin(0, 0, 0, 0) 14 | else: 15 | height, width = parent.getmaxyx() 16 | self.parent = curses.newwin(height - 1, width, 1, 0) 17 | 18 | self.split = split 19 | self.panels = [] 20 | 21 | def flatten(self): 22 | flat = [] 23 | for panel in self.panels: 24 | if(type(panel) is Grid): 25 | flat += panel.flatten() 26 | else: 27 | flat += [panel] 28 | return flat 29 | 30 | def draw(self): 31 | for panel in self.panels: 32 | panel.draw() 33 | 34 | def clear(self): 35 | for panel in self.panels: 36 | panel.clear() 37 | self.parent.clear() 38 | 39 | def add_panel(self, panel): 40 | self.panels.append(panel) 41 | self.resize() 42 | 43 | def add_frame(self, frame): 44 | for panel in self.panels: 45 | if(type(panel) is Grid): 46 | panel.add_frame(frame) 47 | else: 48 | if(panel.has_frame_type(frame)): 49 | panel.add(frame) 50 | 51 | def resize(self, parent=None): 52 | if(parent is not None): 53 | height, width = parent.getmaxyx() 54 | self.parent = curses.newwin(height - 1, width, 1, 0) 55 | 56 | p_height, p_width = self.parent.getmaxyx() 57 | py_offset, px_offset = self.parent.getbegyx() 58 | p_count = len(self.panels) 59 | 60 | for i, panel in enumerate(self.panels): 61 | if(self.split == Split.VERTICAL): 62 | width = int(p_width / p_count) 63 | height = p_height 64 | x_offset = i * width + px_offset 65 | y_offset = 0 + py_offset 66 | else: 67 | width = p_width 68 | height = int(p_height / p_count) 69 | x_offset = 0 + px_offset 70 | y_offset = i * height + py_offset 71 | panel.parent.resize(height, width) 72 | panel.parent.mvwin(y_offset, x_offset) 73 | 74 | if(type(panel) is Grid): 75 | panel.resize() 76 | -------------------------------------------------------------------------------- /canopen_monitor/ui/message_pane.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from .pane import Pane 3 | from .colum import Column 4 | from ..can import Message, MessageType, MessageTable 5 | import curses 6 | 7 | 8 | class MessagePane(Pane): 9 | """ 10 | A derivative of Pane customized specifically to list miscellaneous CAN 11 | messages stored in a MessageTable 12 | 13 | :param name: The name of the pane (to be printed in the top left) 14 | :type name: str 15 | 16 | :param cols: A dictionary describing the pane layout. The key is the Pane 17 | collumn name, the value is a tuple containing the Message attribute to 18 | map the collumn to, and the max collumn width respectively. 19 | :type cols: dict 20 | 21 | :param selected: An indicator that the current Pane is selected 22 | :type selected: bool 23 | 24 | :param table: The message table 25 | :type table: MessageTable 26 | """ 27 | 28 | def __init__(self: MessagePane, 29 | cols: [Column], 30 | types: [MessageType], 31 | name: str = '', 32 | parent: any = None, 33 | height: int = 1, 34 | width: int = 1, 35 | y: int = 0, 36 | x: int = 0, 37 | message_table: MessageTable = MessageTable()): 38 | super().__init__(parent=(parent or curses.newpad(0, 0)), 39 | height=height, 40 | width=width, 41 | y=y, 42 | x=x) 43 | 44 | # Pane details 45 | self._name = name 46 | self.cols = cols 47 | self.types = types 48 | self.__top = 0 49 | self.__top_max = 0 50 | self.__header_style = curses.color_pair(4) 51 | self.table = message_table 52 | 53 | # Cursor stuff 54 | self.cursor = 0 55 | self.cursor_min = 0 56 | self.cursor_max = self.d_height - 10 57 | 58 | def clear_messages(self: MessagePane) -> None: 59 | """ 60 | Clears the message table for the pane. 61 | """ 62 | self.table.clear() 63 | 64 | def resize(self: MessagePane, height: int, width: int) -> None: 65 | """ 66 | A wrapper for `Pane.resize()`. This intercepts a call for a resize 67 | in order to upate MessagePane-specific details that change on a resize 68 | event. The parent `resize()` gets called first and then MessagePane's 69 | details are updated. 70 | 71 | :param height: New virtual height 72 | :type height: int 73 | 74 | :param width: New virtual width 75 | :type width: int 76 | """ 77 | super().resize(height, width) 78 | p_height = self.d_height - 3 79 | table_size = len(self.table.filter(self.types)) 80 | occluded = table_size - self.__top - self.d_height + 3 81 | 82 | self.cursor_max = table_size if table_size < p_height else p_height 83 | self.__top_max = occluded if occluded > 0 else 0 84 | 85 | def _reset_scroll_positions(self: MessagePane) -> None: 86 | """ 87 | Reset the scroll positions. 88 | Initialize the y position to be zero. 89 | Initialize the x position to be zero. 90 | """ 91 | self.cursor = self.cursor_max 92 | self.scroll_position_y = 0 93 | self.scroll_position_x = 0 94 | 95 | @property 96 | def scroll_limit_y(self: MessagePane) -> int: 97 | """ 98 | The maximim rows the pad is allowed to shift by when scrolling 99 | """ 100 | return self.d_height - 2 101 | 102 | @property 103 | def scroll_limit_x(self: MessagePane) -> int: 104 | """ 105 | The maximim columns the pad is allowed to shift by when scrolling 106 | """ 107 | max_length = sum(list(map(lambda x: x.length, self.cols))) 108 | occluded = max_length - self.d_width + 7 109 | return occluded if(occluded > 0) else 0 110 | 111 | def scroll_up(self: MessagePane, rate: int = 1) -> None: 112 | """ 113 | This overrides `Pane.scroll_up()`. Instead of shifting the 114 | pad vertically, the slice of messages from the `MessageTable` is 115 | shifted. 116 | 117 | :param rate: Number of messages to scroll by 118 | :type rate: int 119 | """ 120 | # Record current cursor info for later scroll calculations 121 | prev = self.cursor 122 | min = 0 123 | 124 | # Move the cursor 125 | self.cursor -= rate 126 | 127 | # If the cursor is less than the minimum, reset it to the minimum then 128 | # do calculations for shifting the message table 129 | if(self.cursor < self.cursor_min): 130 | self.cursor = self.cursor_min 131 | 132 | # Deduct the amount of cursor movement from the message table 133 | # movement and reset shift to bounds if need be 134 | leftover = rate - prev 135 | self.__top -= leftover 136 | self.__top = min if(self.__top < min) else self.__top 137 | 138 | def scroll_down(self: MessagePane, rate: int = 1) -> None: 139 | """ 140 | This overrides `Pane.scroll_up()`. Instead of shifting the 141 | pad vertically, the slice of messages from the `MessageTable` is 142 | shifted. 143 | 144 | :param rate: Number of messages to scroll by 145 | :type rate: int 146 | """ 147 | # Record current cursor info for later scroll calculations 148 | prev = self.cursor 149 | max = self.__top + self.__top_max 150 | 151 | # Move the cursor 152 | self.cursor += rate 153 | 154 | # If the cursor is greater than the maximum, reset it to the minimum 155 | # then do calculations for shifting the message table 156 | if(self.cursor > (self.cursor_max - 1)): 157 | self.cursor = self.cursor_max - 1 158 | 159 | # Deduct the amount of cursor movement from the message table 160 | # movement and reset shift to bounds if need be 161 | leftover = rate - (self.cursor - prev) 162 | self.__top += leftover 163 | self.__top = max if(self.__top > max) else self.__top 164 | 165 | def __draw_header(self: Pane) -> None: 166 | """ 167 | Draw the table header at the top of the Pane 168 | 169 | This uses the `cols` dictionary to determine what to write 170 | """ 171 | self.add_line(f'{self._name}:' 172 | f' ({len(self.table.filter(self.types))} messages)', 173 | y=0, 174 | x=1, 175 | highlight=self.selected) 176 | self._pad.move(1, 1) 177 | for col in self.cols: 178 | self.add_line(col.header, 179 | highlight=True, 180 | color=curses.color_pair(4)) 181 | 182 | def draw(self: MessagePane) -> None: 183 | """ 184 | Draw all records from the MessageTable to the Pane 185 | """ 186 | super().draw() 187 | self.resize(self.v_height, self.v_width) 188 | 189 | # Get the messages to be displayed based on scroll positioning, 190 | # and adjust column widths accordingly 191 | draw_messages = self.table.filter(self.types, self.__top, self.__top + self.d_height - 3) 192 | self.__check_col_widths(draw_messages) 193 | 194 | # Draw the header and messages 195 | self.__draw_header() 196 | for i, message in enumerate(draw_messages): 197 | self._pad.move(2 + i, 1) 198 | for col in self.cols: 199 | self.add_line(col.format(message), 200 | highlight=((self.cursor == i) and self.selected)) 201 | # Refresh the Pane and end the draw cycle 202 | super().refresh() 203 | 204 | def __check_col_widths(self: MessagePane, messages: [Message]) -> None: 205 | """ 206 | Check the width of the message in Pane column. 207 | 208 | :param messages: The list of the messages 209 | :type messages: list 210 | """ 211 | for col in self.cols: 212 | for message in messages: 213 | if(col.update_length(message)): 214 | self._pad.clear() 215 | -------------------------------------------------------------------------------- /canopen_monitor/ui/pane.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import curses 3 | from abc import ABC, abstractmethod 4 | 5 | 6 | class Pane(ABC): 7 | """ 8 | Abstract Pane Class, contains a PAD and a window 9 | 10 | :param v_height: The virtual height of the embedded pad 11 | :type v_height: int 12 | 13 | :param v_width: The virtual width of the embedded pad 14 | :type v_width: int 15 | 16 | :param d_height: The drawn height of the embedded pad 17 | :type d_height: int 18 | 19 | :param d_width: The drawn width of the embedded pad 20 | :type d_width: int 21 | 22 | :param border: A style option for drawing a border around the pane 23 | :type border: bool 24 | """ 25 | 26 | def __init__(self: Pane, 27 | parent: any = None, 28 | height: int = 1, 29 | width: int = 1, 30 | y: int = 0, 31 | x: int = 0, 32 | border: bool = True, 33 | color_pair: int = 0): 34 | """ 35 | Abstract pane initialization 36 | 37 | :param border: Toggiling whether or not to draw a border 38 | :type border: bool 39 | :value border: True 40 | 41 | :param color_pair: The color pair bound in curses config to use 42 | :type color_pair: int 43 | :value color_pair: 4 44 | """ 45 | # Set virtual dimensions 46 | self.v_height = height 47 | self.v_width = width 48 | self.y = y 49 | self.x = x 50 | 51 | # Set or create the parent window 52 | self.parent = parent or curses.newwin(self.v_height, self.v_width) 53 | self._pad = curses.newpad(self.v_height, self.v_width) 54 | self._pad.scrollok(True) 55 | 56 | # Set the draw dimensions 57 | self.__reset_draw_dimensions() 58 | 59 | # Pane style options and state details 60 | self.border = border 61 | self.selected = False 62 | self._style = curses.color_pair(color_pair) 63 | self.needs_refresh = False 64 | self.scroll_position_y = 0 65 | self.scroll_position_x = 0 66 | 67 | @property 68 | def scroll_limit_y(self: Pane) -> int: 69 | """ 70 | Limit the scroll on the y axis 71 | """ 72 | return 0 73 | 74 | @property 75 | def scroll_limit_x(self: Pane) -> int: 76 | """ 77 | Limit the scroll on the x axis 78 | """ 79 | return 0 80 | 81 | @abstractmethod 82 | def draw(self: Pane) -> None: 83 | """ 84 | Abstract draw method, must be overwritten in child class 85 | draw should first resize the pad using: `super().resize(w, h)` 86 | then add content using: self._pad.addstr() 87 | then refresh using: `super().refresh()` 88 | 89 | abstract method will clear and handle border 90 | 91 | child class should also set _scroll_limit_x and _scroll_limit_y here 92 | """ 93 | if self.needs_refresh: 94 | self.refresh() 95 | 96 | self.parent.attron(self._style) 97 | self._pad.attron(self._style) 98 | 99 | if(self.border): 100 | self._pad.box() 101 | 102 | def resize(self: Pane, height: int, width: int) -> None: 103 | """ 104 | Resize the virtual pad and change internal variables to reflect that 105 | 106 | :param height: New virtual height 107 | :type height: int 108 | 109 | :param width: New virtual width 110 | :type width: int 111 | """ 112 | self.v_height = height 113 | self.v_width = width 114 | self.__reset_draw_dimensions() 115 | self._pad.resize(self.v_height, self.v_width) 116 | 117 | def __reset_draw_dimensions(self: Pane) -> None: 118 | """ 119 | Reset the pane dimensions. 120 | You can change the width and height of the pane. 121 | """ 122 | p_height, p_width = self.parent.getmaxyx() 123 | self.d_height = min(self.v_height, p_height - 1) 124 | self.d_width = min(self.v_width, p_width - 1) 125 | 126 | def clear(self: Pane) -> None: 127 | """ 128 | Clear all contents of pad and parent window 129 | 130 | .. warning:: 131 | 132 | This should only be used if an event changing the entire pane 133 | occurs. If used on every cycle, a flickering effect will occur, 134 | due to the slowness of the operation. 135 | """ 136 | self._pad.clear() 137 | self.parent.clear() 138 | 139 | def clear_line(self: Pane, y: int, style: any = None) -> None: 140 | """ 141 | Clears a single line of the Pane 142 | 143 | :param y: The line to clear 144 | :type y: int 145 | 146 | :param style: The background color to set when clearing the line 147 | :type style: int 148 | """ 149 | line_style = style or self._style 150 | self._pad.move(y, 1) 151 | # self._pad.addstr(y, 1, ' ' * (self.d_width - 2), curses.COLOR_BLUE) 152 | self._pad.attron(line_style) 153 | self._pad.clrtoeol() 154 | self._pad.attroff(line_style) 155 | 156 | def refresh(self: Pane) -> None: 157 | """ 158 | Refresh the pane based on configured draw dimensions 159 | """ 160 | self._pad.refresh(self.scroll_position_y, 161 | self.scroll_position_x, 162 | self.y, 163 | self.x, 164 | self.y + self.d_height, 165 | self.x + self.d_width) 166 | self.needs_refresh = False 167 | 168 | def scroll_up(self: Pane, rate: int = 1) -> bool: 169 | """ 170 | Scroll pad upwards 171 | 172 | .. note:: 173 | 174 | Scroll limit must be set by child class 175 | 176 | :param rate: Number of lines to scroll by 177 | :type rate: int 178 | 179 | :return: Indication of whether a limit was reached. False indicates a 180 | limit was reached and the pane cannot be scrolled further in that 181 | direction 182 | :rtype: bool 183 | """ 184 | self.scroll_position_y -= rate 185 | if self.scroll_position_y < 0: 186 | self.scroll_position_y = 0 187 | return False 188 | return True 189 | 190 | def scroll_down(self: Pane, rate: int = 1) -> bool: 191 | """ 192 | Scroll pad downwards 193 | 194 | .. note:: 195 | 196 | Scroll limit must be set by child class 197 | 198 | :param rate: Number of lines to scroll by 199 | :type rate: int 200 | 201 | :return: Indication of whether a limit was reached. False indicates a 202 | limit was reached and the pane cannot be scrolled further in that 203 | direction 204 | :rtype: bool 205 | """ 206 | self.scroll_position_y += rate 207 | if self.scroll_position_y > self.scroll_limit_y: 208 | self.scroll_position_y = self.scroll_limit_y 209 | return False 210 | return True 211 | 212 | def scroll_left(self: Pane, rate: int = 1) -> bool: 213 | """ 214 | Scroll pad left 215 | 216 | .. note:: 217 | 218 | Scroll limit must be set by child class 219 | 220 | :param rate: Number of lines to scroll by 221 | :type rate: int 222 | 223 | :return: Indication of whether a limit was reached. False indicates a 224 | limit was reached and the pane cannot be scrolled further in that 225 | direction 226 | :rtype: bool 227 | """ 228 | self.scroll_position_x -= rate 229 | if(self.scroll_position_x < 0): 230 | self.scroll_position_x = 0 231 | return False 232 | return True 233 | 234 | def scroll_right(self: Pane, rate: int = 1) -> bool: 235 | """ 236 | Scroll pad right 237 | 238 | .. note:: 239 | 240 | Scroll limit must be set by child class 241 | 242 | :param rate: Number of lines to scroll by 243 | :type rate: int 244 | 245 | :return: Indication of whether a limit was reached. False indicates a 246 | limit was reached and the pane cannot be scrolled further in that 247 | direction 248 | :rtype: bool 249 | """ 250 | self.scroll_position_x += rate 251 | if self.scroll_position_x > self.scroll_limit_x: 252 | self.scroll_position_x = self.scroll_limit_x 253 | return False 254 | return True 255 | 256 | def add_line(self: Pane, 257 | line: str, 258 | y: int = None, 259 | x: int = None, 260 | bold: bool = False, 261 | underline: bool = False, 262 | highlight: bool = False, 263 | color: any = None) -> None: 264 | """ 265 | Adds a line of text to the Pane and if needed, it handles the 266 | process of resizing the embedded pad 267 | 268 | :param line: Text to write to the Pane 269 | :type line: str 270 | 271 | :param y: Line's row position 272 | :type y: int 273 | 274 | :param x: Line's collumn position 275 | :type x: int 276 | 277 | :param bold: A style option to bold the line written 278 | :type bold: bool 279 | 280 | :param highlight: A syle option to highlight the line writte 281 | :type highlight: bool 282 | 283 | :param style: A color option for the line 284 | :type style: curses.style 285 | """ 286 | # Fill the current screen cursor position if none are specified 287 | if(y is None or x is None): 288 | y, x = self._pad.getyx() 289 | 290 | # Set the color option to the pane default if none was specified 291 | line_style = color or self._style 292 | 293 | # Widen pad when necessary 294 | new_width = len(line) + x 295 | if(new_width > self.v_width): 296 | self.resize(self.v_height, new_width) 297 | 298 | # Heighten the pad when necessary 299 | if(y > self.v_height): 300 | self.resize(y + 1, self.v_width) 301 | 302 | # Add style options 303 | if(bold): 304 | line_style |= curses.A_BOLD 305 | if(highlight): 306 | line_style |= curses.A_REVERSE 307 | if(underline): 308 | line_style |= curses.A_UNDERLINE 309 | 310 | # Add the line 311 | if(y < self.d_height): 312 | self._pad.addstr(y, x, line, line_style) 313 | -------------------------------------------------------------------------------- /canopen_monitor/ui/windows.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import curses 3 | import curses.ascii 4 | 5 | from .pane import Pane 6 | 7 | 8 | class PopupWindow(Pane): 9 | 10 | def __init__(self: PopupWindow, 11 | parent: any, 12 | header: str = 'Alert', 13 | content: [str] = [], 14 | footer: str = 'ESC: close', 15 | style: any = None): 16 | super().__init__(parent=(parent or curses.newpad(0, 0)), 17 | height=1, 18 | width=1, 19 | y=10, 20 | x=10) 21 | """Set an init to Popup window""" 22 | # Pop-up window properties 23 | self.setWindowProperties(header, content, footer) 24 | 25 | # Parent window dimensions (Usually should be STDOUT directly) 26 | p_height, p_width = self.parent.getmaxyx() 27 | 28 | # Break lines as necessary 29 | self.content = self.break_lines(int(2 * p_width / 3), self.content) 30 | 31 | # UI dimensions 32 | self.setUIDimension(p_height, p_width) 33 | 34 | # UI properties 35 | self.style = (style or curses.color_pair(0)) 36 | self._pad.attron(self.style) 37 | 38 | def setUIDimension(self, p_height, p_width): 39 | """Set UI Dimension (x,y) by giving parent 40 | height and width""" 41 | self.v_height = (len(self.content)) + 2 42 | width = len(self.header) + 2 43 | if (len(self.content) > 0): 44 | width = max(width, max(list(map(lambda x: len(x), self.content)))) 45 | self.v_width = width + 4 46 | self.y = int(((p_height + self.v_height) / 2) - self.v_height) 47 | self.x = int(((p_width + self.v_width) / 2) - self.v_width) 48 | 49 | def setWindowProperties(self: PopupWindow, header, content, footer): 50 | """Set default window properties""" 51 | self.header = header 52 | self.content = content 53 | self.footer = footer 54 | self.enabled = False 55 | 56 | def break_lines(self: PopupWindow, 57 | max_width: int, 58 | content: [str]) -> [str]: 59 | # Determine if some lines of content need to be broken up 60 | for i, line in enumerate(content): 61 | length = len(line) 62 | mid = int(length / 2) 63 | 64 | self.determine_to_break_content(content, i, length, line, max_width, 65 | mid) 66 | return content 67 | 68 | def determine_to_break_content(self, content, i, length, line, max_width, 69 | mid): 70 | if (length >= max_width): 71 | # Break the line at the next available space 72 | for j, c in enumerate(line[mid - 1:]): 73 | if (c == ' '): 74 | mid += j 75 | break 76 | self.apply_line_to_content_array(content, i, line, mid) 77 | 78 | def apply_line_to_content_array(self, content, i, line, mid): 79 | """Apply the line break to the content array""" 80 | content.pop(i) 81 | content.insert(i, line[:mid - 1]) 82 | content.insert(i + 1, line[mid:]) 83 | 84 | def toggle(self: PopupWindow) -> bool: 85 | self.enabled = not self.enabled 86 | return self.enabled 87 | 88 | def __draw_header(self: PopupWindow) -> None: 89 | """Add the header line to the window""" 90 | self.add_line(self.header, y=0, x=1, underline=True) 91 | 92 | def __draw__footer(self: PopupWindow) -> None: 93 | """Add the footer to the window""" 94 | f_width = len(self.footer) + 2 95 | self.add_line(self.footer, 96 | y=self.v_height - 1, 97 | x=self.v_width - f_width, 98 | underline=True) 99 | 100 | def __draw_content(self): 101 | """Read each line of the content and add to the window""" 102 | for i, line in enumerate(self.content): 103 | self.add_line(line, y=(1 + i), x=2) 104 | 105 | def draw(self: PopupWindow) -> None: 106 | if (self.enabled): 107 | super().resize(self.v_height, self.v_width) 108 | super().draw() 109 | self.__draw_header() 110 | self.__draw_content() 111 | self.__draw__footer() 112 | super().refresh() 113 | else: 114 | # super().clear() 115 | ... 116 | 117 | 118 | class InputPopup(PopupWindow): 119 | """ 120 | Input form creates a popup window for retrieving 121 | text input from the user 122 | 123 | :param parent: parent ui element 124 | :type: any 125 | :param header: header text of popup window 126 | :type: str 127 | :param footer: footer text of popup window 128 | :type: str 129 | :param style: style of window 130 | :type: any 131 | :param input_len: Maximum length of input text 132 | :type: int 133 | """ 134 | 135 | def __init__(self: InputPopup, 136 | parent: any, 137 | header: str = 'Alert', 138 | footer: str = 'ESC: close', 139 | style: any = None, 140 | input_len: int = 30, 141 | ): 142 | 143 | self.input_len = input_len 144 | content = [" " * self.input_len] 145 | super().__init__(parent, header, content, footer, style) 146 | self.cursor_loc = 0 147 | 148 | def read_input(self, keyboard_input: int) -> None: 149 | """ 150 | Read process keyboard input (ascii or backspace) 151 | 152 | :param keyboard_input: curses input character value from curses.getch 153 | :type: int 154 | """ 155 | if curses.ascii.isalnum(keyboard_input) and \ 156 | self.cursor_loc < self.input_len: 157 | temp = list(self.content[0]) 158 | temp[self.cursor_loc] = chr(keyboard_input) 159 | self.content[0] = "".join(temp) 160 | self.cursor_loc += 1 161 | elif keyboard_input == curses.KEY_BACKSPACE and self.cursor_loc > 0: 162 | self.cursor_loc -= 1 163 | temp = list(self.content[0]) 164 | temp[self.cursor_loc] = " " 165 | self.content[0] = "".join(temp) 166 | 167 | def toggle(self: InputPopup) -> bool: 168 | """ 169 | Toggle window and clear inserted text 170 | :return: value indicating whether the window is enabled 171 | :type: bool 172 | """ 173 | self.content = [" " * self.input_len] 174 | self.cursor_loc = 0 175 | return super().toggle() 176 | 177 | def get_value(self) -> str: 178 | """ 179 | Get the value of user input without trailing spaces 180 | :return: user input value 181 | :type: str 182 | """ 183 | return self.content[0].strip() 184 | 185 | 186 | class SelectionPopup(PopupWindow): 187 | """ 188 | Input form creates a popup window for selecting 189 | from a list of options 190 | 191 | :param parent: parent ui element 192 | :type: any 193 | :param header: header text of popup window 194 | :type: str 195 | :param footer: footer text of popup window 196 | :type: str 197 | :param style: style of window 198 | :type: any 199 | """ 200 | def __init__(self: SelectionPopup, 201 | parent: any, 202 | header: str = 'Alert', 203 | footer: str = 'ESC: close', 204 | style: any = None, 205 | ): 206 | content = [" " * 40] 207 | super().__init__(parent, header, content, footer, style) 208 | self.cursor_loc = 0 209 | 210 | def read_input(self: SelectionPopup, keyboard_input: int) -> None: 211 | """ 212 | Read process keyboard input (ascii or backspace) 213 | 214 | :param keyboard_input: curses input character value from curses.getch 215 | :type: int 216 | """ 217 | if keyboard_input == curses.KEY_UP and self.cursor_loc > 0: 218 | self.cursor_loc -= 1 219 | elif keyboard_input == curses.KEY_DOWN and self.cursor_loc < len( 220 | self.content) - 1: 221 | self.cursor_loc += 1 222 | 223 | def __draw_header(self: SelectionPopup) -> None: 224 | """Add the header line to the window""" 225 | self.add_line(self.header, y=0, x=1, underline=True) 226 | 227 | def __draw__footer(self: SelectionPopup) -> None: 228 | """Add the footer to the window""" 229 | f_width = len(self.footer) + 2 230 | self.add_line(self.footer, 231 | y=(self.v_height - 1), 232 | x=(self.v_width - f_width), 233 | underline=True) 234 | 235 | def __draw_content(self: SelectionPopup) -> None: 236 | """Read each line of the content and add to the window""" 237 | for i, line in enumerate(self.content): 238 | self.add_line(line, y=(1 + i), x=2, highlight=i == self.cursor_loc) 239 | 240 | def draw(self: SelectionPopup) -> None: 241 | if (self.enabled): 242 | super().resize(len(self.content)+2, self.v_width) 243 | if self.needs_refresh: 244 | self.refresh() 245 | 246 | self.parent.attron(self._style) 247 | self._pad.attron(self._style) 248 | 249 | if (self.border): 250 | self._pad.box() 251 | self.__draw_header() 252 | self.__draw_content() 253 | self.__draw__footer() 254 | super().refresh() 255 | 256 | def toggle(self: InputPopup) -> bool: 257 | """ 258 | Toggle window and reset selected item 259 | :return: value indicating whether the window is enabled 260 | :type: bool 261 | """ 262 | self.cursor_loc = 0 263 | self.clear() 264 | return super().toggle() 265 | 266 | def get_value(self): 267 | if self.cursor_loc >= len(self.content): 268 | return "" 269 | 270 | return self.content[self.cursor_loc] 271 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | import sys 15 | sys.path.insert(0, os.path.abspath('.')) 16 | 17 | 18 | # -- Project information ----------------------------------------------------- 19 | 20 | project = 'CANOpen Monitor' 21 | copyright = '2021, Portland State Aerospace Society' 22 | author = 'Portland State Aerospace Society' 23 | 24 | 25 | # -- General configuration --------------------------------------------------- 26 | 27 | # Add any Sphinx extension module names here, as strings. They can be 28 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 29 | # ones. 30 | extensions = [ 31 | 'sphinx.ext.todo', 32 | 'sphinx.ext.viewcode', 33 | 'sphinx.ext.autodoc', 34 | 'sphinx.ext.autosectionlabel' 35 | ] 36 | 37 | # Add any paths that contain templates here, relative to this directory. 38 | templates_path = ['_templates'] 39 | 40 | # List of patterns, relative to source directory, that match files and 41 | # directories to ignore when looking for source files. 42 | # This pattern also affects html_static_path and html_extra_path. 43 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 44 | 45 | 46 | # -- Options for HTML output ------------------------------------------------- 47 | 48 | # The theme to use for HTML and HTML Help pages. See the documentation for 49 | # a list of builtin themes. 50 | # 51 | html_theme = 'sphinx_rtd_theme' 52 | 53 | # Add any paths that contain custom static files (such as style sheets) here, 54 | # relative to this directory. They are copied after the builtin static files, 55 | # so a file named "default.css" will overwrite the builtin "default.css". 56 | html_static_path = [] 57 | -------------------------------------------------------------------------------- /docs/development/can.rst: -------------------------------------------------------------------------------- 1 | CAN Module 2 | ========== 3 | 4 | .. automodule:: canopen_monitor.can 5 | :members: 6 | :private-members: 7 | :undoc-members: 8 | -------------------------------------------------------------------------------- /docs/development/index.rst: -------------------------------------------------------------------------------- 1 | Development Reference 2 | ===================== 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | :caption: Contents: 7 | 8 | can 9 | parse 10 | ui 11 | 12 | Node Ranges to Types Map 13 | ======================== 14 | 15 | +----------------+--------+---------+ 16 | |Type |Range In|Range Out| 17 | +----------------+--------+---------+ 18 | |NMT node control|0 | | 19 | +----------------+--------+---------+ 20 | |SYNC |0x080 | | 21 | +----------------+--------+---------+ 22 | |Emergency |0x80 |0x100 | 23 | +----------------+--------+---------+ 24 | |Time Stamp |100 | | 25 | +----------------+--------+---------+ 26 | |PDO1 tx |0x180 |0x200 | 27 | +----------------+--------+---------+ 28 | |PDO1 rx |0x200 |0x280 | 29 | +----------------+--------+---------+ 30 | |PDO2 tx |0x280 |0x300 | 31 | +----------------+--------+---------+ 32 | |PDO2 rx |0x300 |0x380 | 33 | +----------------+--------+---------+ 34 | |PDO3 tx |0x380 |0x400 | 35 | +----------------+--------+---------+ 36 | |PDO3 rx |0x400 |0x480 | 37 | +----------------+--------+---------+ 38 | |PDO4 tx |0x480 |0x500 | 39 | +----------------+--------+---------+ 40 | |PDO4 rx |0x500 |0x580 | 41 | +----------------+--------+---------+ 42 | |SDO tx |0x580 |0x600 | 43 | +----------------+--------+---------+ 44 | |SDO rx |0x600 |0x680 | 45 | +----------------+--------+---------+ 46 | |Heartbeats |0x700 |0x7FF | 47 | +----------------+--------+---------+ 48 | -------------------------------------------------------------------------------- /docs/development/parse.rst: -------------------------------------------------------------------------------- 1 | Parse Module 2 | ============ 3 | 4 | .. automodule:: canopen_monitor.parse 5 | :members: 6 | :private-members: 7 | :undoc-members: 8 | -------------------------------------------------------------------------------- /docs/development/ui.rst: -------------------------------------------------------------------------------- 1 | UI Module 2 | ========= 3 | 4 | .. automodule:: canopen_monitor.ui 5 | :members: 6 | :private-members: 7 | :undoc-members: 8 | -------------------------------------------------------------------------------- /docs/glossary.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Glossary 3 | ======== 4 | 5 | .. glossary:: 6 | :sorted: 7 | 8 | Baud Rate 9 | The speed of messages sent over a communications bus. It is not 10 | directly linked but typically infers the interval that messages are 11 | sent or resent in a protocol. 12 | 13 | C3 14 | Command, communication, and control board. See 15 | https://github.com/oresat/oresat-c3 16 | 17 | CAN 18 | Control area network. A message bus for embedded systems. 19 | 20 | CAN ID 21 | CAN Identifier. This is the 11-bit CAN message identifier which is at 22 | the beginning of every CAN message on the bus. 23 | 24 | CANopen 25 | A communication protocol and device profile specification for a CAN 26 | bus defined by CAN in Automation. More info at https://can-cia.org/ 27 | 28 | CFC 29 | Cirrus Flux Camera. One of OreSat1 payloads and a Linux board. 30 | 31 | COB ID 32 | Communication object identifier. 33 | 34 | CubeSat 35 | A CubeSat is small satellite is made up of multiples of 10cm × 10cm × 36 | 10cm cubic units 37 | 38 | Daemon 39 | A long running process on Linux, which runs in the background. 40 | 41 | DLC 42 | Data Length Code. The operational code dictating the size of the data 43 | frame. 44 | 45 | EDS 46 | Electronic Data Sheet. This is an INI style or XML style formatted file. 47 | 48 | NCurses 49 | New Curses. An application programming interface for manipulating the 50 | standard terminal. Used for making terminal-based applications without 51 | the need for a GUI. 52 | 53 | MTU 54 | Maximum Transmission Unit. The maximum size of a packet. In context of 55 | this application, the MTU of a CAN packet is 108 bytes for a 56 | maximum-data-frame size of 64 bits (8 bytes). 57 | 58 | OreSat 59 | PSAS's open source CubeSat project. See their 60 | `homepage `_ for more details. 61 | 62 | OreSat0 63 | A 1U cube-satellite made and maintained by OreSat. 64 | 65 | OreSat1: 66 | A 2U cube-satellite made and maintained by OreSat. 67 | 68 | OLM 69 | OreSat Linux Manager. The front end daemon for all OreSat Linux boards. 70 | It converts CANopen message into DBus messages and vice versa. See 71 | https://github.com/oresat/oresat-linux-manager 72 | 73 | PDO 74 | Process Data Object. Inputs and outputs. Values of type rotational 75 | speed, voltage, frequency, electric current, etc. 76 | 77 | PSAS 78 | Portland State Aerosapce Society. A student aerospace group at 79 | Portland State University. See https://www.pdxaerospace.org/ 80 | 81 | SDO 82 | Service Data Object. Configuration settings, possibly node ID, baud 83 | rate, offset, gain, etc. 84 | 85 | SDR 86 | Software Define Radio. Radio communications that are traditionally 87 | implemented in hardware are instead implemented in software. 88 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | =========================================== 2 | Welcome to CANOpen Monitor's documentation! 3 | =========================================== 4 | 5 | CANOpen-Monitor is an NCurses-based TUI application for tracking activity 6 | over the CAN bus and decoding messages with provided EDS/OD files. 7 | 8 | .. warning:: 9 | This is still a work in progress. 10 | 11 | .. toctree:: 12 | :maxdepth: 2 13 | :caption: Contents: 14 | 15 | development/index 16 | 17 | Glossary and Terms 18 | ------------------ 19 | 20 | .. toctree:: 21 | :maxdepth: 2 22 | 23 | glossary 24 | 25 | Index 26 | ===== 27 | 28 | * :ref:`genindex` 29 | * :ref:`modindex` 30 | * :ref:`search` 31 | 32 | .. _OreSat Website: https://www.oresat.org/ 33 | .. _OreSat GitHub: https://github.com/oresat 34 | -------------------------------------------------------------------------------- /scripts/interface-status.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import argparse 3 | import datetime 4 | import time 5 | import psutil 6 | 7 | 8 | def main(): 9 | parser = argparse.ArgumentParser(prog='interface-status', 10 | description='Simple script to display' 11 | ' current status of a' 12 | ' device interface', 13 | allow_abbrev=False) 14 | parser.add_argument('-i', '--interface', 15 | dest='interface', 16 | type=str, 17 | nargs='?', 18 | default='vcan0', 19 | help='The interface whose status is to be displayed.') 20 | 21 | parser.add_argument('-d', '--delay', 22 | type=int, 23 | default=1, 24 | help='Adjust the status update delay time') 25 | 26 | args = parser.parse_args() 27 | 28 | while True: 29 | interface = psutil.net_if_stats().get(args.interface) 30 | exists = "EXISTS" if interface is not None else "DOES NOT EXIST" 31 | status = "IS UP" if (interface is not None and interface.isup) else "IS NOT UP" 32 | print("Interface " + args.interface + " " + exists + " and " + status + " | " + str(datetime.datetime.now())) 33 | time.sleep(args.delay) 34 | 35 | 36 | if __name__ == '__main__': 37 | main() 38 | -------------------------------------------------------------------------------- /scripts/issue76_demo.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import socket 3 | import struct 4 | import can 5 | import random 6 | 7 | _FRAME_FORMAT = "=IB3xBBBBBBBB" 8 | _FRAME_SIZE = struct.calcsize(_FRAME_FORMAT) 9 | _RANDOM_MESSAGE = can.Message(arbitration_id=random.randint(0x0, 0x7ff), 10 | data=[random.randint(0, 255) for _ in range(8)], 11 | is_extended_id=False) 12 | 13 | def interface_activity(cansocket): 14 | 15 | frames_received = 0 16 | errno19_count = 0 17 | timeouts = 0 18 | 19 | # create the device interface using index 11 and verify that the interface was created 20 | subprocess.call(['sudo', 'ip', 'link', 'add', 'index', '11', 'dev', 'vcan0', 'type', 'vcan']) 21 | subprocess.call(['sudo', 'ip', 'link', 'set', 'vcan0', 'up']) 22 | assert(subprocess.check_output(['ip', 'link', 'show']).decode('utf-8').find('11: vcan0') != -1) 23 | 24 | # create bus for sending messages and bind the socket to the channel 25 | bus = can.interface.Bus(channel='vcan0', bustype='socketcan') 26 | cansocket.bind(('vcan0',)) 27 | 28 | # send a message and try to receive it 29 | for i in range(10): 30 | bus.send(_RANDOM_MESSAGE) 31 | try: 32 | frame = cansocket.recv(_FRAME_SIZE) 33 | if frame is not None: 34 | frames_received += 1 35 | except socket.timeout: 36 | timeouts += 1 37 | except OSError as err: 38 | if err.errno == 19: 39 | errno19_count += 1 40 | 41 | print("Frames received: " + str(frames_received)) 42 | print("No such device errors: " + str(errno19_count)) 43 | print("Number of timeouts: " + str(timeouts)) 44 | 45 | # Remove the device interface and verify that it's been deleted 46 | subprocess.call(['sudo', 'ip', 'link', 'del', 'dev', 'vcan0']) 47 | assert (subprocess.check_output(['ip', 'link', 'show']).decode('utf-8').find('vcan0') == -1) 48 | 49 | 50 | test_cansocket = socket.socket(socket.PF_CAN, socket.SOCK_RAW, socket.CAN_RAW) 51 | test_cansocket.settimeout(0.1) 52 | 53 | print("\nFIRST TIME (WORKING)\n") 54 | interface_activity(test_cansocket) 55 | print("\nSECOND TIME (BROKEN)\n") 56 | interface_activity(test_cansocket) -------------------------------------------------------------------------------- /scripts/socketcan-dev.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import can 3 | import time 4 | import random 5 | import argparse 6 | import subprocess 7 | 8 | _FAILURE_STRING = 'Device "{}" does not exist.' 9 | _BUS_TYPE = 'socketcan' 10 | 11 | 12 | def create_vdev(name: str) -> bool: 13 | rc_create = subprocess.call(['sudo', 'ip', 'link', 'add', 14 | 'dev', name, 'type', 'vcan']) 15 | created = rc_create == 0 or rc_create == 2 16 | 17 | if(created): 18 | rc_netup = subprocess.call(['sudo', 'ip', 'link', 'set', name, 'up']) 19 | netup = rc_netup == 0 or rc_netup == 2 20 | else: 21 | netup = False 22 | 23 | return created and netup 24 | 25 | 26 | def destroy_vdev(name: str) -> bool: 27 | rc_destroy = subprocess.call(['sudo', 'ip', 'link', 'del', 'dev', name]) 28 | destroyed = rc_destroy == 0 or rc_destroy == 1 29 | return destroyed 30 | 31 | 32 | def send(channel: str, id: int, message: [int]): 33 | """:param id: Spam the bus with messages including the data id.""" 34 | bus = can.interface.Bus(channel=channel, bustype=_BUS_TYPE) 35 | msg = can.Message(arbitration_id=id, 36 | data=message, 37 | is_extended_id=False) 38 | bus.send(msg) 39 | 40 | 41 | def send_handle(args: dict, up: [str]): 42 | for i, c in enumerate(args.channels): 43 | if(up[i]): 44 | id = args.id + i 45 | send(c, id, args.message) 46 | msg_str = ' '.join(list(map(lambda x: hex(x).upper()[2:] 47 | .rjust(2, '0'), 48 | args.message))) 49 | print(f'[{time.ctime()}]:'.ljust(30, ' ') 50 | + f'{c}'.ljust(8, ' ') 51 | + f'{hex(id)}'.ljust(10, ' ') 52 | + f'{msg_str}'.ljust(25, ' ')) 53 | 54 | 55 | def send_handle_cycle(args: [any], up: [any]) -> None: 56 | # Regenerate the random things if flagged to do so 57 | if(args.random_id): 58 | args.id = random.randint(0x0, 0x7ff) 59 | if(args.random_message): 60 | args.message = [random.randint(0, 255) for _ in range(8)] 61 | 62 | # Send the things 63 | send_handle(args, up) 64 | time.sleep(args.delay) 65 | 66 | 67 | def main(): 68 | parser = argparse.ArgumentParser(prog='socketcan-dev', 69 | description='A simple SocketCan wrapper' 70 | ' for testing' 71 | ' canopen-monitor', 72 | allow_abbrev=False) 73 | parser.add_argument('-c', '--channels', 74 | type=str, 75 | nargs="+", 76 | default=['vcan0'], 77 | help='The channel to create and send CAN messages on') 78 | parser.add_argument('-d', '--delay', 79 | type=float, 80 | default=1, 81 | help='Adjust the message-send delay time, used in' 82 | ' conjunction with `-r`') 83 | parser.add_argument('-i', '--id', 84 | type=str, 85 | default='10', 86 | help='The COB ID to use for the messages') 87 | parser.add_argument('-n', '--no-destroy', 88 | dest='destroy', 89 | action='store_false', 90 | default=True, 91 | help='Stop socketcan-dev from destroying the channel' 92 | ' at the end of life') 93 | parser.add_argument('-m', '--message', 94 | type=str, 95 | nargs=8, 96 | default=['0', '0', '0', '1', '3', '1', '4', '1'], 97 | help='The 7 bytes to send as the CAN message') 98 | parser.add_argument('-r', '--repeat', 99 | dest='repeat', 100 | nargs='?', 101 | type=int, 102 | const=-1, 103 | default=None, 104 | help='Repeat sending the message N times, every so' 105 | ' often defined by -d, used in conjunction with' 106 | ' `-d`') 107 | parser.add_argument('--random-id', 108 | dest='random_id', 109 | action='store_true', 110 | default=False, 111 | help='Use a randomly generated ID (this disables -i)') 112 | parser.add_argument('--random-message', 113 | dest='random_message', 114 | action='store_true', 115 | default=False, 116 | help='Use a randomly generated message (this disables' 117 | ' -m)') 118 | args = parser.parse_args() 119 | 120 | # Interpret ID as hex 121 | if(args.random_id): 122 | args.id = random.randint(0x0, 0x7ff) 123 | else: 124 | args.id = int(args.id, 16) 125 | 126 | # Interpret message as hex 127 | if(args.random_message): 128 | args.message = [random.randint(0, 255) for _ in range(8)] 129 | else: 130 | args.message = list(map(lambda x: int(x, 16), args.message)) 131 | 132 | try: 133 | up = [] 134 | # Create the channels 135 | for c in args.channels: 136 | up.append(create_vdev(c)) 137 | 138 | # Quick-n-dirty banner 139 | print('Timestamp:'.ljust(30, ' ') 140 | + 'Channel'.ljust(8, ' ') 141 | + 'COB ID'.ljust(10, ' ') 142 | + 'Message'.ljust(25, ' ')) 143 | print(''.ljust(73, '-')) 144 | 145 | # Send repeatedly in instructed to do so 146 | if(args.repeat and args.repeat > 0): 147 | for _ in range(args.repeat): 148 | send_handle_cycle(args, up) 149 | else: 150 | while(args.repeat == -1): 151 | send_handle_cycle(args, up) 152 | 153 | except KeyboardInterrupt: 154 | print('Goodbye!') 155 | finally: 156 | if(args.destroy): 157 | for channel in args.channels: 158 | destroy_vdev(channel) 159 | 160 | 161 | if __name__ == '__main__': 162 | main() 163 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | import canopen_monitor as cm 3 | 4 | with open('README.md', 'r') as file: 5 | long_description = file.read() 6 | 7 | setuptools.setup( 8 | name=cm.APP_NAME, 9 | version=cm.APP_VERSION, 10 | author=cm.APP_AUTHOR, 11 | maintainer=cm.MAINTAINER_NAME, 12 | maintainer_email=cm.MAINTAINER_EMAIL, 13 | license=cm.APP_LICENSE, 14 | description=cm.APP_DESCRIPTION, 15 | long_description=long_description, 16 | long_description_content_type='text/markdown', 17 | url=cm.APP_URL, 18 | project_urls={ 19 | 'Documentation': 'https://canopen-monitor.readthedocs.io', 20 | 'Bug Tracking': 'https://github.com/oresat/CANopen-monitor/issues?q=is' 21 | '%3Aopen+is%3Aissue+label%3Abug' 22 | }, 23 | packages=setuptools.find_packages(), 24 | classifiers=[ 25 | "Programming Language :: Python :: 3", 26 | "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", 27 | "Operating System :: OS Independent", 28 | "Environment :: Console :: Curses", 29 | "Topic :: Scientific/Engineering :: Information Analysis", 30 | "Topic :: Software Development :: User Interfaces", 31 | "Topic :: System :: Monitoring", 32 | "Topic :: System :: Networking :: Monitoring :: Hardware Watchdog" 33 | ], 34 | install_requires=[ 35 | "pyvit >= 0.2.1", 36 | "psutil >= 5.8.0", 37 | "python-dateutil >= 2.8.1" 38 | ], 39 | extras_require={ 40 | "dev": [ 41 | "python-can", 42 | "setuptools", 43 | "wheel", 44 | "flake8", 45 | "twine", 46 | "sphinx", 47 | "sphinx_rtd_theme", 48 | ] 49 | }, 50 | python_requires='>=3.9.0', 51 | entry_points={ 52 | "console_scripts": [ 53 | f'{cm.APP_NAME} = canopen_monitor.__main__:main' 54 | ] 55 | } 56 | ) 57 | -------------------------------------------------------------------------------- /tests/spec_eds_parser.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from canopen_monitor import parse 3 | from unittest.mock import mock_open, patch, MagicMock 4 | from tests import TEST_EDS, TEST_DCF 5 | 6 | eds = parse.eds 7 | 8 | 9 | class TestEDS(unittest.TestCase): 10 | def setUp(self): 11 | with patch('builtins.open', mock_open(read_data=TEST_EDS)) as _: 12 | self.eds = eds.load_eds_file("star_tracker_OD.eds") 13 | 14 | def test_parse_index(self): 15 | """ 16 | EDS should allow for parsing index locations 17 | """ 18 | self.assertEqual("Device type", 19 | self.eds[hex(0x1000)].parameter_name, 20 | "Error parsing index") 21 | 22 | def test_parse_sub_index(self): 23 | """ 24 | EDS should allow for parsing sub-index locations 25 | """ 26 | self.assertEqual("unsigned8", 27 | self.eds[hex(0x1018)][0].parameter_name, 28 | "Error parsing sub-index") 29 | 30 | def test_parse_high_hex_index(self): 31 | """ 32 | EDS should allow for parsing of high (>9) index hex values 33 | """ 34 | self.assertEqual("TPDO mapping parameter", 35 | self.eds[hex(0x1A00)].parameter_name, 36 | "Error parsing high hex index") 37 | 38 | def test_parse_high_hex_sub_index(self): 39 | """ 40 | EDS should allow for parsing of high (>9) sub index hex values 41 | """ 42 | self.assertEqual("This is for testing", 43 | self.eds[hex(0x3002)][0xA].parameter_name, 44 | "Error parsing high hex sub-index") 45 | 46 | def test_named_sections(self): 47 | """ 48 | Some sections use names instead of hex values, this should test all 49 | valid names Currently the deadbeef problem exist, where a name made 50 | up of hex values will be treated as a hex location. This can be 51 | tested here if new named sections are added. 52 | """ 53 | self.assertEqual("OreSat Star Tracker Board Object Dictionary", 54 | self.eds.file_info.description, 55 | "Error parsing File Info named section") 56 | 57 | self.assertEqual("Portland State Aerospace Society", 58 | self.eds.device_info.vendor_name, 59 | "Error parsing File Info named section") 60 | 61 | self.assertEqual("0", 62 | self.eds.dummy_usage.dummy_0001, 63 | "Error parsing Dummy Usage named section") 64 | 65 | self.assertEqual("EDS File for CANopen device", 66 | self.eds.comments.line_1, 67 | "Error parsing Comments named section") 68 | 69 | self.assertEqual("3", 70 | self.eds.mandatory_objects.supported_objects, 71 | "Error parsing Comments named section") 72 | 73 | def test_last_index(self): 74 | """ 75 | Parsing should capture the last index if there is no newline 76 | """ 77 | file_check = TEST_EDS.splitlines() 78 | self.assertEqual("PDOMapping=0", 79 | file_check[len(file_check) - 1], 80 | "The last line in the EDS test file should not be " 81 | "blank") 82 | 83 | self.assertEqual("Last Aolved filepath", 84 | self.eds[hex(0x3102)].parameter_name, 85 | "Error parsing last index location") 86 | 87 | 88 | class TestDCF(unittest.TestCase): 89 | def setUp(self): 90 | with patch('builtins.open', mock_open(read_data=TEST_DCF)) as _: 91 | self.eds = eds.load_eds_file("star_tracker_OD.eds") 92 | 93 | def test_check_device_commissioning(self): 94 | """ 95 | DCF Parsing should allow for parsing node id 96 | """ 97 | self.assertEqual("10", 98 | self.eds.device_commissioning.node_id, 99 | "Error parsing device commissioning") 100 | 101 | def test_get_node_id(self): 102 | """ 103 | DCF Parsing set node id attribute 104 | """ 105 | self.assertEqual(10, 106 | self.eds.node_id, 107 | "Error parsing node id") 108 | 109 | def test_existing_spaces(self): 110 | """ 111 | DCF tests should test importing a file with arbitrary blank lines 112 | This test confirms that the test file contains those blank lines 113 | """ 114 | file_check = TEST_DCF.splitlines() 115 | self.assertEqual("", 116 | file_check[len(file_check) - 1], 117 | "The last line in the DCF Test file should be blank") 118 | 119 | self.assertEqual("", 120 | file_check[len(file_check) - 12], 121 | "There should be 2 blank lines before the last index") 122 | 123 | self.assertEqual("", 124 | file_check[len(file_check) - 13], 125 | "There should be 2 blank lines before the last index") 126 | 127 | 128 | class TestErrors(unittest.TestCase): 129 | def setUp(self): 130 | with patch('builtins.open', mock_open(read_data=TEST_EDS)) as _: 131 | self.eds = eds.load_eds_file("star_tracker_OD.eds") 132 | 133 | def test_invalid_index(self): 134 | """ 135 | OD file should throw a key error when accessing an invalid index 136 | """ 137 | with self.assertRaises(KeyError) as context: 138 | _ = self.eds[hex(0x9999)] 139 | 140 | self.assertEqual("'9999'", str(context.exception)) 141 | 142 | def test_invalid_subindex(self): 143 | """ 144 | OD file should throw a key error when accessing an invalid subindex 145 | with a valid index provided 146 | """ 147 | with self.assertRaises(KeyError) as context: 148 | _ = self.eds[hex(0x1003)][9] 149 | 150 | self.assertEqual("'1003sub9'", str(context.exception)) 151 | 152 | def test_invalid_subindex_when_no_subindices(self): 153 | """ 154 | OD file should throw a key error when accessing an invalid subindex 155 | with a valid index provided that does not contain any subindices 156 | """ 157 | with self.assertRaises(KeyError) as context: 158 | _ = self.eds[hex(0x1000)][1] 159 | 160 | self.assertEqual("'1000sub1'", str(context.exception)) 161 | 162 | 163 | class TestExtendedPDODefinition(unittest.TestCase): 164 | def setUp(self): 165 | # node id defined in file 166 | self.node_id = 10 167 | with patch('builtins.open', mock_open(read_data=TEST_DCF)) as _: 168 | with patch('os.listdir') as mocked_listdir: 169 | mocked_listdir.return_value = ["battery.dcf"] 170 | self.nodes = eds.load_eds_files("/") 171 | 172 | def test_load_PDOs(self): 173 | 174 | od = self.nodes.get(self.node_id) 175 | # RPDO 1 176 | self.assertEqual("RPDO mapping parameter", 177 | od[hex(0x1600)].parameter_name, 178 | "Base RPDO 1 definition not found") 179 | 180 | # RPDO 2 181 | self.assertEqual("RPDO mapping parameter", 182 | od[hex(0x1601)].parameter_name, 183 | "Base RPDO 2 definition not found") 184 | 185 | # RPDO 3 186 | self.assertEqual("RPDO mapping parameter", 187 | od[hex(0x1602)].parameter_name, 188 | "Base RPDO 3 definition not found") 189 | # RPDO 4 190 | self.assertEqual("RPDO mapping parameter", 191 | od[hex(0x1603)].parameter_name, 192 | "Base RPDO 4 definition not found") 193 | 194 | # TPDO 1 195 | self.assertEqual("TPDO mapping parameter", 196 | od[hex(0x1A00)].parameter_name, 197 | "Base TPDO 1 definition not found") 198 | 199 | # TPDO 2 200 | self.assertEqual("TPDO mapping parameter", 201 | od[hex(0x1A01)].parameter_name, 202 | "Base TPDO 2 definition not found") 203 | 204 | # TPDO 3 205 | self.assertEqual("TPDO mapping parameter", 206 | od[hex(0x1A02)].parameter_name, 207 | "Base TPDO 3 definition not found") 208 | # TPDO 4 209 | self.assertEqual("TPDO mapping parameter", 210 | od[hex(0x1A03)].parameter_name, 211 | "Base TPDO 4 definition not found") 212 | 213 | def test_load_Extended_PDOs(self): 214 | od = self.nodes.get(self.node_id + 1) 215 | 216 | self.assertIsNotNone(od, "Extended PDO node not set") 217 | 218 | # RPDO 1 219 | self.assertEqual("RPDO mapping parameter", 220 | od[0x1600].parameter_name, 221 | "Extended RPDO 1 definition not found") 222 | 223 | # RPDO 2 224 | self.assertEqual("RPDO mapping parameter", 225 | od[0x1601].parameter_name, 226 | "Extended RPDO 2 definition not found") 227 | 228 | # RPDO 3 229 | self.assertEqual("RPDO mapping parameter", 230 | od[0x1602].parameter_name, 231 | "Extended RPDO 3 definition not found") 232 | # RPDO 4 233 | self.assertEqual("RPDO mapping parameter", 234 | od[0x1603].parameter_name, 235 | "Extended RPDO 4 definition not found") 236 | 237 | # TPDO 1 238 | self.assertEqual("TPDO mapping parameter", 239 | od[0x1A00].parameter_name, 240 | "Extended TPDO 1 definition not found") 241 | 242 | # TPDO 2 243 | self.assertEqual("TPDO mapping parameter", 244 | od[0x1A01].parameter_name, 245 | "Extended TPDO 2 definition not found") 246 | 247 | # TPDO 3 248 | self.assertEqual("TPDO mapping parameter", 249 | od[0x1A02].parameter_name, 250 | "Extended TPDO 3 definition not found") 251 | # TPDO 4 252 | self.assertEqual("TPDO mapping parameter", 253 | od[0x1A03].parameter_name, 254 | "Extended TPDO 4 definition not found") 255 | 256 | def test_load_invalid_node(self): 257 | od = self.nodes.get(self.node_id + 5) 258 | with self.assertRaises(TypeError) as context: 259 | result = od[hex(0x1600)].parameter_name 260 | 261 | self.assertEqual("'NoneType' object is not subscriptable", str(context.exception)) 262 | -------------------------------------------------------------------------------- /tests/spec_emcy_parser.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from canopen_monitor.parse.emcy import parse 3 | from canopen_monitor.parse.utilities import FailedValidationError 4 | 5 | 6 | class TestEMCY(unittest.TestCase): 7 | """ 8 | Tests for the EMCY parser 9 | """ 10 | 11 | def test_EMCY(self): 12 | """ 13 | Test EMCY Message 14 | """ 15 | emcy_message = [0x81, 0x10, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0] 16 | self.assertEqual("CAN overrun (objects lost)", 17 | parse(0, emcy_message, 0), 18 | "Error on EMCY Message parse") 19 | 20 | def test_EMCY_invalid(self): 21 | """ 22 | Test EMCY Message with undefined message 23 | """ 24 | emcy_message = [0x81, 0x11, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0] 25 | self.assertEqual("Error code not found", 26 | parse(0, emcy_message, 0), 27 | "Error on EMCY Message parse with undefined error " 28 | "message") 29 | 30 | def test_EMCY_invalid_length(self): 31 | """ 32 | Test EMCY Message with undefined message 33 | """ 34 | emcy_message = [0x81, 0x11, 0x0] 35 | with self.assertRaises(FailedValidationError) as context: 36 | parse(0, emcy_message, 0) 37 | 38 | self.assertEqual("Invalid EMCY message length", str(context.exception)) 39 | -------------------------------------------------------------------------------- /tests/spec_hb_parser.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import mock_open, patch 3 | 4 | from canopen_monitor.parse import eds 5 | from canopen_monitor.parse.hb import parse 6 | from canopen_monitor.parse.utilities import FailedValidationError 7 | from tests import TEST_EDS 8 | 9 | 10 | class TestHB(unittest.TestCase): 11 | """ 12 | Tests for the Heartbeat parser 13 | """ 14 | 15 | def setUp(self) -> None: 16 | with patch('builtins.open', mock_open(read_data=TEST_EDS)) as m: 17 | self.eds = eds.load_eds_file("star_tracker_OD.eds") 18 | 19 | def test_HB(self): 20 | """ 21 | Test Heartbeat Message 22 | """ 23 | hb_message = [0x04] 24 | self.assertEqual("Stopped", 25 | parse(123, hb_message, self.eds), 26 | "Error on heartbeat Message parse") 27 | 28 | def test_HB_Invalid(self): 29 | """ 30 | Test Heartbeat Message with an invalid payload 31 | """ 32 | hb_message = [0xFF] 33 | with self.assertRaises(FailedValidationError) as context: 34 | parse(123, hb_message, self.eds) 35 | 36 | self.assertEqual("Invalid heartbeat state detected", 37 | str(context.exception)) 38 | 39 | def test_HB_Empty(self): 40 | """ 41 | Test Heartbeat Message with an invalid payload 42 | """ 43 | hb_message = [] 44 | with self.assertRaises(FailedValidationError) as context: 45 | parse(123, hb_message, self.eds) 46 | 47 | self.assertEqual("Invalid heartbeat state detected", str(context.exception)) 48 | -------------------------------------------------------------------------------- /tests/spec_interface.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from canopen_monitor import can 3 | from unittest.mock import MagicMock, patch 4 | 5 | 6 | class Interface_Spec(unittest.TestCase): 7 | """Tests for the Interface serial object""" 8 | 9 | @patch('psutil.net_if_stats') 10 | def setUp(self, net_if_stats): 11 | # Override the net_if_stats function from psutil 12 | net_if_stats.return_value = {'vcan0': {}} 13 | 14 | # Create a fake socket 15 | socket = MagicMock() 16 | socket.start.return_value = None 17 | 18 | # Create Interface 19 | self.iface = can.Interface('vcan0') 20 | self.iface.socket.close() 21 | self.iface.socket = socket 22 | 23 | @unittest.skip('Cannot patch psutil module') 24 | def test_active_loop(self): 25 | """Given a fake socket and an Interface 26 | When binding to the socket via a `with` block 27 | Then the socket should bind and the interface should change to the 28 | `UP` state and then shoud move to the `DOWN` state when exiting the 29 | `with` block 30 | """ 31 | with self.iface as iface: 32 | self.assertTrue(iface.is_up) 33 | self.assertFalse(iface.is_up) 34 | -------------------------------------------------------------------------------- /tests/spec_magic_can_bus.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import threading 3 | from canopen_monitor import can 4 | from unittest.mock import MagicMock 5 | 6 | 7 | class MagicCanBus_Spec(unittest.TestCase): 8 | """Tests for the Magic Can Bus""" 9 | 10 | def setUp(self): 11 | # Fake CAN frame 12 | generic_frame = MagicMock() 13 | 14 | # Create fake interfaces 15 | if0 = MagicMock() 16 | if0.name = 'vcan0' 17 | if0.is_up = True 18 | if0.recv.return_value = generic_frame 19 | if0.__str__.return_value = 'vcan0' 20 | 21 | if1 = MagicMock() 22 | if1.name = 'vcan1' 23 | if1.is_up = False 24 | if1.recv.return_value = generic_frame 25 | if1.__str__.return_value = 'vcan1' 26 | 27 | # Setup the bus with no interfaces and then overide with the fakes 28 | self.bus = can.MagicCANBus([]) 29 | self.bus.interfaces = [if0, if1] 30 | 31 | def test_statuses(self): 32 | """Given an MCB with 2 fake interfaces 33 | When calling the statuses proprty 34 | Then, the correct array of formatted tuples should be returned 35 | """ 36 | statuses = self.bus.statuses 37 | self.assertEqual(statuses, [('vcan0', True), ('vcan1', False)]) 38 | 39 | def test_handler(self): 40 | """Given an MCB with 2 interfaces 41 | When starting the bus listeners with a `with` block 42 | And calling the bus as an itterable 43 | Then the bus should start a separate thread and fill the queue with 44 | frames while the bus is open and then close the threads when the bus is 45 | closed 46 | """ 47 | with self.bus as bus: 48 | for frame in bus: 49 | self.assertIsNotNone(frame) 50 | # Active threads should only be 1 by the end, 1 being the parent 51 | self.assertEqual(threading.active_count(), 1) 52 | 53 | def test_str(self): 54 | """Given an MCB with 2 interfaces 55 | When calling repr() on the bus 56 | Then it should return a correctly formated string representation 57 | of the bus 58 | """ 59 | expected = 'Magic Can Bus: vcan0, vcan1, pending messages:' \ 60 | ' 0 threads: 0' 61 | actual = str(self.bus) 62 | self.assertEqual(expected, actual) 63 | -------------------------------------------------------------------------------- /tests/spec_meta.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import mock_open, patch, MagicMock, call 3 | from canopen_monitor.meta import Meta, load_config, Config, InterfaceConfig, FeatureConfig 4 | 5 | 6 | class TestMeta(unittest.TestCase): 7 | """ 8 | Tests for the Meta Class 9 | """ 10 | def setUp(self) -> None: 11 | self.meta = Meta("config_dir", "cache_dir") 12 | self.mcb = MagicMock() 13 | 14 | def test_save_single_interface(self): 15 | """ 16 | Test Save a single interface 17 | """ 18 | self.mcb.interface_list = ["vcan0"] 19 | with patch('builtins.open', mock_open()) as m: 20 | self.meta.save_interfaces(self.mcb) 21 | 22 | m.assert_called_once_with('config_dir/interfaces.json', 'w') 23 | calls = [call('{'), 24 | call('\n '), 25 | call('"version"'), 26 | call(': '), 27 | call(f'"{InterfaceConfig.MAJOR}.{InterfaceConfig.MINOR}"'), 28 | call(',\n '), 29 | call('"interfaces"'), 30 | call(': '), 31 | call('[\n "vcan0"'), 32 | call('\n '), 33 | call(']'), 34 | call('\n'), 35 | call('}')] 36 | 37 | self.assertEqual(calls, m().write.mock_calls, 38 | "json file not written out correctly") 39 | 40 | def test_save_multiple_interface(self): 41 | """ 42 | Test Save Multiple interfaces 43 | """ 44 | self.mcb.interface_list = ["vcan0", "vcan1"] 45 | with patch('builtins.open', mock_open()) as m: 46 | self.meta.save_interfaces(self.mcb) 47 | 48 | m.assert_called_once_with('config_dir/interfaces.json', 'w') 49 | calls = [call('{'), 50 | call('\n '), 51 | call('"version"'), 52 | call(': '), 53 | call(f'"{InterfaceConfig.MAJOR}.{InterfaceConfig.MINOR}"'), 54 | call(',\n '), 55 | call('"interfaces"'), 56 | call(': '), 57 | call('[\n "vcan0"'), 58 | call(',\n "vcan1"'), 59 | call('\n '), 60 | call(']'), 61 | call('\n'), 62 | call('}')] 63 | 64 | self.assertEqual(calls, m().write.mock_calls, 65 | "json file not written out correctly") 66 | 67 | def test_save_no_interface(self): 68 | """ 69 | Test Save No interfaces 70 | """ 71 | self.mcb.interface_list = [] 72 | with patch('builtins.open', mock_open()) as m: 73 | self.meta.save_interfaces(self.mcb) 74 | 75 | m.assert_called_once_with('config_dir/interfaces.json', 'w') 76 | calls = [call('{'), 77 | call('\n '), 78 | call('"version"'), 79 | call(': '), 80 | call(f'"{InterfaceConfig.MAJOR}.{InterfaceConfig.MINOR}"'), 81 | call(',\n '), 82 | call('"interfaces"'), 83 | call(': '), 84 | call('[]'), 85 | call('\n'), 86 | call('}')] 87 | 88 | self.assertEqual(calls, m().write.mock_calls, 89 | "json file not written out correctly") 90 | 91 | m().truncate.assert_called_once() 92 | 93 | def test_load_single_interface(self): 94 | """ 95 | Test load a single interface 96 | """ 97 | 98 | json = '{"interfaces": ["vcan0"]}' 99 | with patch('builtins.open', mock_open(read_data=json)) as m: 100 | with patch('os.path.isfile', return_value=True) as m_os: 101 | interfaces = self.meta.load_interfaces([]) 102 | 103 | m.assert_called_once_with('config_dir/interfaces.json', 'r') 104 | m_os.assert_called_once_with('config_dir/interfaces.json') 105 | self.assertEqual(["vcan0"], interfaces, 106 | "interfaces not loaded correctly") 107 | 108 | def test_load_multiple_interfaces(self): 109 | """ 110 | Test load of multiple interface 111 | """ 112 | 113 | json = '{"interfaces": ["vcan0", "vcan1"]}' 114 | with patch('builtins.open', mock_open(read_data=json)) as m: 115 | with patch('os.path.isfile', return_value=True) as m_os: 116 | interfaces = self.meta.load_interfaces([]) 117 | 118 | m.assert_called_once_with('config_dir/interfaces.json', 'r') 119 | m_os.assert_called_once_with('config_dir/interfaces.json') 120 | self.assertEqual(["vcan0", "vcan1"], interfaces, 121 | "interfaces not loaded correctly") 122 | 123 | def test_load_no_interfaces(self): 124 | """ 125 | Test load of no interfaces 126 | """ 127 | 128 | json = '{"interfaces": []}' 129 | with patch('builtins.open', mock_open(read_data=json)) as m: 130 | with patch('os.path.isfile', return_value=True) as m_os: 131 | interfaces = self.meta.load_interfaces([]) 132 | 133 | m.assert_called_once_with('config_dir/interfaces.json', 'r') 134 | m_os.assert_called_once_with('config_dir/interfaces.json') 135 | self.assertEqual([], interfaces, 136 | "interfaces not loaded correctly") 137 | 138 | def test_load_no_interfaces_file(self): 139 | """ 140 | Test load with no existing file 141 | """ 142 | 143 | with patch('builtins.open', mock_open()) as m: 144 | with patch('os.path.isfile', return_value=False) as m_os: 145 | interfaces = self.meta.load_interfaces([]) 146 | 147 | m_os.assert_called_once_with('config_dir/interfaces.json') 148 | self.assertEqual([], interfaces, 149 | "interfaces not loaded correctly") 150 | 151 | def test_load_features(self): 152 | """ 153 | Test loading features from feature file 154 | :return: 155 | """ 156 | json = f'{{"major": {FeatureConfig.MAJOR}, "minor": {FeatureConfig.MINOR}, "ecss_time": true }}' 157 | with patch('builtins.open', mock_open(read_data=json)) as m: 158 | with patch('os.path.isfile', return_value=True) as m_os: 159 | features = self.meta.load_features() 160 | 161 | m.assert_called_once_with('config_dir/features.json', 'r') 162 | m_os.assert_called_once_with('config_dir/features.json') 163 | self.assertEqual(True, features.ecss_time, 164 | "features not loaded correctly") 165 | 166 | def test_replace_breaking_config(self): 167 | """ 168 | Test loading an existing file with a breaking version 169 | Should create a backup and save a new file with defaults 170 | :return: 171 | """ 172 | def isfile(filename): 173 | if filename == "filename": 174 | return True 175 | else: 176 | return False 177 | 178 | isfileMock = MagicMock(side_effect=isfile) 179 | with patch('builtins.open', mock_open()) as m: 180 | with patch('os.path.isfile', isfileMock) as m_os: 181 | with patch('os.rename') as m_os_rename: 182 | config = Config(0, 0) 183 | load_config("filename", config) 184 | 185 | m_os.assert_has_calls([call("filename"), call("filename.bak")]) 186 | m_os_rename.assert_called_once_with('filename', 'filename.bak') 187 | m.assert_called_with('filename', 'w') 188 | 189 | def test_replace_breaking_config_with_existing_bak(self): 190 | """ 191 | Test loading an existing file with a breaking version 192 | when an existing .bak file exists 193 | :return: 194 | """ 195 | 196 | def isfile(filename): 197 | if filename == "filename" or filename == "filename.bak" or filename == "filename-1.bak": 198 | return True 199 | else: 200 | return False 201 | 202 | isfileMock = MagicMock(side_effect=isfile) 203 | with patch('builtins.open', mock_open()) as m: 204 | with patch('os.path.isfile', isfileMock) as m_os: 205 | with patch('os.rename') as m_os_rename: 206 | config = Config(0, 0) 207 | load_config("filename", config) 208 | 209 | m_os.assert_has_calls([call("filename"), call("filename.bak"), call("filename-1.bak")]) 210 | m_os_rename.assert_called_once_with('filename', 'filename-2.bak') 211 | m.assert_called_with('filename', 'w') 212 | -------------------------------------------------------------------------------- /tests/spec_pdo_parser.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import patch, mock_open 3 | 4 | from canopen_monitor.parse import eds, load_eds_files 5 | from canopen_monitor.parse.pdo import parse 6 | from canopen_monitor.parse.utilities import FailedValidationError 7 | from tests import TEST_EDS, BATTERY_DCF 8 | from canopen_monitor.parse.canopen import CANOpenParser 9 | from canopen_monitor.can import Message 10 | 11 | 12 | class TestPDO(unittest.TestCase): 13 | """ 14 | Tests for the SDO parser 15 | """ 16 | 17 | def setUp(self): 18 | """ 19 | Generate Mocked eds file 20 | """ 21 | with patch('builtins.open', mock_open(read_data=TEST_EDS)) as m: 22 | self.eds_data = eds.load_eds_file("star_tracker_OD.eds") 23 | 24 | def test_pdo(self): 25 | """ 26 | Test PDO transmit 27 | """ 28 | pdo_message = [0x3f, 0x80, 0x0, 0x0] 29 | self.assertEqual("Orientation orientation - 1.0", 30 | parse(0x180, pdo_message, self.eds_data), 31 | "Error on PDO Message parse") 32 | 33 | def test_pdo_with_multiple_elements(self): 34 | """ 35 | Test PDO transmit with multiple elements in message 36 | """ 37 | pdo_message = [0x3F, 0x80, 0x00, 0x00, 0x3F, 0xC0, 0x00, 0x00] 38 | self.assertEqual("Orientation orientation - 1.0 Orientation timestamp " 39 | "- 1.5", 40 | parse(0x280, pdo_message, self.eds_data), 41 | "Error on PDO Message parse (multiple)") 42 | 43 | def test_pdo_with_multiple_elements_complex(self): 44 | """ 45 | Test PDO transmit with multiple elements in message 46 | """ 47 | pdo_message = [0x01, 0x3F, 0xC0, 0x00, 0x00] 48 | self.assertEqual("Orientation boolean - True Orientation timestamp - " 49 | "1.5", 50 | parse(0x200, pdo_message, self.eds_data), 51 | "Error on PDO Message parse (multiple & complex)") 52 | 53 | pdo_message = [0x7F, 0x80, 0x00, 0x01] 54 | self.assertEqual("Orientation timestamp - 1.5 Orientation boolean - " 55 | "True", 56 | parse(0x300, pdo_message, self.eds_data), 57 | "Error on PDO Message parse (multiple & complex - " 58 | "reverse)") 59 | 60 | def test_mpdo_with_SAM(self): 61 | """ 62 | Test MPDO transmit with source addressing mode 63 | """ 64 | pdo_message = [0x00, 0x31, 0x01, 0x03, 0x3F, 0x80, 0x00, 0x00] 65 | self.assertEqual("Orientation orientation - 1.0", 66 | parse(0x380, pdo_message, self.eds_data), 67 | "Error on MPDO SAM Message parse") 68 | 69 | def test_pdo_transmit_with_invalid_index(self): 70 | """ 71 | Test PDO transmit with invalid OD File index 72 | An exception is returned here because this is due 73 | to an malformed OD file, not a malformed message 74 | """ 75 | pdo_message = [0x3f, 0x80, 0x0, 0x0] 76 | with self.assertRaises(KeyError) as context: 77 | parse(0x480, pdo_message, self.eds_data) 78 | 79 | self.assertEqual("'3101sub6'", str(context.exception)) 80 | 81 | def test_mpdo_with_invalid_index(self): 82 | """ 83 | Test MPDO transmit with source addressing mode and an invalid index 84 | This should return a Failed Validation Error 85 | """ 86 | pdo_message = [0x00, 0x31, 0x0A, 0x03, 0x3F, 0x80, 0x00, 0x00] 87 | with self.assertRaises(FailedValidationError) as context: 88 | parse(0x380, pdo_message, self.eds_data), 89 | 90 | self.assertEqual("MPDO provided type index does not exist. Check " 91 | "provided index '310a'", str(context.exception)) 92 | 93 | 94 | class TestExtendedPDODefinition(unittest.TestCase): 95 | """ 96 | Tests the extended PDO definitions. This is an integration test of the OD changes. 97 | If there is an issue here, the OD class is a good place to look for a 98 | resolution. 99 | """ 100 | 101 | def setUp(self): 102 | """ 103 | load eds files from folder 104 | """ 105 | with patch('builtins.open', mock_open(read_data=BATTERY_DCF)) as _: 106 | with patch('os.listdir') as mocked_listdir: 107 | mocked_listdir.return_value = ["battery.dcf"] 108 | self.parser = CANOpenParser(load_eds_files("/")) 109 | 110 | def test1(self): 111 | pdo_message = Message(0x184, 112 | data=[0xDF, 0x1D, 0xEC, 0x0E, 0xD8, 0x0E, 0xF0, 0x0E], 113 | frame_type=1, 114 | interface="vcan0", 115 | timestamp="", # datetime.datetime.now() 116 | extended=False) 117 | 118 | pdo_message.node_name = self.parser.get_name(pdo_message) 119 | pdo_message.message, pdo_message.error = self.parser.parse(pdo_message) 120 | 121 | self.assertEqual("Battery", pdo_message.node_name) 122 | self.assertEqual( 123 | "Battery Vbatt - 7647 Battery VCell max - 3820 Battery VCell min - 3800 Battery VCell - 3824", 124 | pdo_message.message) 125 | self.assertEqual("", pdo_message.error) 126 | 127 | def test2(self): 128 | pdo_message = Message(0x284, 129 | data=[0xF0, 0x0E, 0xEF, 0x0E, 0xF0, 0x0E, 0x00, 0x00], 130 | frame_type=1, 131 | interface="vcan0", 132 | timestamp="", # datetime.datetime.now() 133 | extended=False) 134 | 135 | pdo_message.node_name = self.parser.get_name(pdo_message) 136 | pdo_message.message, pdo_message.error = self.parser.parse(pdo_message) 137 | 138 | self.assertEqual("Battery", pdo_message.node_name) 139 | self.assertEqual( 140 | "Battery VCell1 - 3823 Battery VCell2 - 3824 Battery VCell avg - 0", 141 | pdo_message.message) 142 | self.assertEqual("", pdo_message.error) 143 | 144 | 145 | def test3(self): 146 | 147 | pdo_message = Message(0x384, 148 | data=[0x09, 0x00, 0x04, 0x00, 0x50, 0x00, 0x38, 0xFF], 149 | frame_type=1, 150 | interface="vcan0", 151 | timestamp="", # datetime.datetime.now() 152 | extended=False) 153 | 154 | pdo_message.node_name = self.parser.get_name(pdo_message) 155 | pdo_message.message, pdo_message.error = self.parser.parse(pdo_message) 156 | 157 | self.assertEqual("Battery", pdo_message.node_name) 158 | self.assertEqual( 159 | "Battery Current - 9 Battery Current avg - 4 Battery Current max - 80 Battery Current min - -200", 160 | pdo_message.message) 161 | self.assertEqual("", pdo_message.error) 162 | 163 | def test4(self): 164 | pdo_message = Message(0x484, 165 | data=[0x17, 0x00, 0x17, 0x00, 0x18, 0x00, 0x16, 0x00], 166 | frame_type=1, 167 | interface="vcan0", 168 | timestamp="", # datetime.datetime.now() 169 | extended=False) 170 | 171 | pdo_message.node_name = self.parser.get_name(pdo_message) 172 | pdo_message.message, pdo_message.error = self.parser.parse(pdo_message) 173 | 174 | self.assertEqual("Battery", pdo_message.node_name) 175 | self.assertEqual( 176 | "Battery Temperature - 23 Battery Temperature avg - 23 Battery Temperature max - 24 Battery Temperature min - 22", 177 | pdo_message.message) 178 | self.assertEqual("", pdo_message.error) 179 | 180 | 181 | def test5(self): 182 | pdo_message = Message(0x185, 183 | data=[0xDC, 0x05, 0xF5, 0x02, 0x32, 0x18], 184 | frame_type=1, 185 | interface="vcan0", 186 | timestamp="", # datetime.datetime.now() 187 | extended=False) 188 | 189 | pdo_message.node_name = self.parser.get_name(pdo_message) 190 | self.assertEqual("Battery", pdo_message.node_name) 191 | pdo_message.message, pdo_message.error = self.parser.parse(pdo_message) 192 | 193 | 194 | self.assertEqual( 195 | "Battery Full Capacity - 1500 Battery Reported Capacity - 757 Battery Reported State of Charge - 50 Battery State - 24", 196 | pdo_message.message) 197 | self.assertEqual("", pdo_message.error) 198 | 199 | def test6(self): 200 | pdo_message = Message(0x285, 201 | data=[0xE2, 0x1D, 0xEC, 0x0E, 0xD8, 0x0E, 0xF1, 202 | 0x0E], 203 | frame_type=1, 204 | interface="vcan0", 205 | timestamp="", # datetime.datetime.now() 206 | extended=False) 207 | 208 | pdo_message.node_name = self.parser.get_name(pdo_message) 209 | self.assertEqual("Battery", pdo_message.node_name) 210 | pdo_message.message, pdo_message.error = self.parser.parse(pdo_message) 211 | 212 | self.assertEqual( 213 | "Battery Vbatt - 7650 Battery VCell max - 3820 Battery VCell min - 3800 Battery VCell - 3825", 214 | pdo_message.message) 215 | self.assertEqual("", pdo_message.error) 216 | 217 | def test7(self): 218 | pdo_message = Message(0x385, 219 | data=[0xF1, 0x0E, 0xF1, 0x0E, 0xF2, 0x0E], 220 | frame_type=1, 221 | interface="vcan0", 222 | timestamp="", # datetime.datetime.now() 223 | extended=False) 224 | 225 | pdo_message.node_name = self.parser.get_name(pdo_message) 226 | self.assertEqual("Battery", pdo_message.node_name) 227 | pdo_message.message, pdo_message.error = self.parser.parse(pdo_message) 228 | 229 | self.assertEqual( 230 | "Battery VCell1 - 3825 Battery VCell2 - 3825 Battery VCell avg - 3826", 231 | pdo_message.message) 232 | self.assertEqual("", pdo_message.error) 233 | 234 | def test8(self): 235 | pdo_message = Message(0x485, 236 | data=[0x09, 0x00, 0x04, 0x00, 0x50, 0x38, 0xFF], 237 | frame_type=1, 238 | interface="vcan0", 239 | timestamp="", # datetime.datetime.now() 240 | extended=False) 241 | 242 | pdo_message.node_name = self.parser.get_name(pdo_message) 243 | self.assertEqual("Battery", pdo_message.node_name) 244 | pdo_message.message, pdo_message.error = self.parser.parse( 245 | pdo_message) 246 | 247 | self.assertEqual( 248 | "Battery Current - 0 Battery Current avg - 1024 Battery Current max - 20480 Battery Current min - -200", 249 | pdo_message.message) 250 | self.assertEqual("", pdo_message.error) 251 | 252 | def test9(self): 253 | pdo_message = Message(0x186, 254 | data=[0x15, 0x00, 0x15, 0x00, 0x16, 0x00, 0x15, 255 | 0x00], 256 | frame_type=1, 257 | interface="vcan0", 258 | timestamp="", # datetime.datetime.now() 259 | extended=False) 260 | 261 | pdo_message.node_name = self.parser.get_name(pdo_message) 262 | self.assertEqual("Battery", pdo_message.node_name) 263 | pdo_message.message, pdo_message.error = self.parser.parse( 264 | pdo_message) 265 | 266 | self.assertEqual( 267 | "Battery Temperature - 21 Battery Temperature avg - 21 Battery Temperature max - 22 Battery Temperature min - 21", 268 | pdo_message.message) 269 | self.assertEqual("", pdo_message.error) 270 | 271 | def test10(self): 272 | pdo_message = Message(0x286, 273 | data=[0xDC, 0x05, 0xF8, 0x02, 0x32, 0x18], 274 | frame_type=1, 275 | interface="vcan0", 276 | timestamp="", # datetime.datetime.now() 277 | extended=False) 278 | 279 | pdo_message.node_name = self.parser.get_name(pdo_message) 280 | self.assertEqual("Battery", pdo_message.node_name) 281 | pdo_message.message, pdo_message.error = self.parser.parse( 282 | pdo_message) 283 | 284 | self.assertEqual( 285 | "Battery Full Capacity - 1500 Battery Reported Capacity - 760 Battery Reported State of Charge - 50 Battery State - 24", 286 | pdo_message.message) 287 | self.assertEqual("", pdo_message.error) 288 | 289 | 290 | 291 | -------------------------------------------------------------------------------- /tests/spec_sync_parser.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from canopen_monitor.parse.sync import parse, FailedValidationError 3 | 4 | 5 | class TestSYNC(unittest.TestCase): 6 | """ 7 | Tests for the SYNC parser 8 | """ 9 | 10 | def test_SYNC(self): 11 | """ 12 | Test SYNC Message 13 | """ 14 | sync_message = b'\x81' 15 | self.assertEqual("SYNC - 129", 16 | parse(None, sync_message, None), 17 | "Error on SYNC Message parse") 18 | 19 | """ 20 | Tests for the SYNC parser with an empty payload which is legal 21 | """ 22 | 23 | def test_SYNC_empty(self): 24 | """ 25 | Test SYNC Message as empty payload 26 | """ 27 | sync_message = b'' 28 | self.assertEqual("SYNC - 0", 29 | parse(None, sync_message, None), 30 | "Error on SYNC empty Message parse") 31 | 32 | """ 33 | Tests for the SYNC parser with an invalid payload 34 | """ 35 | 36 | def test_SYNC_invalid(self): 37 | """ 38 | Test SYNC Message with an invalid payload 39 | """ 40 | sync_message = b'\x01\xFF' 41 | with self.assertRaises(FailedValidationError): 42 | parse(None, sync_message, None) 43 | -------------------------------------------------------------------------------- /tests/spec_time_parser.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import mock_open, patch 3 | 4 | from canopen_monitor.parse import time, eds 5 | from canopen_monitor.parse.utilities import FailedValidationError 6 | from tests import TEST_EDS 7 | 8 | 9 | class TestTIME(unittest.TestCase): 10 | """ 11 | Tests for the TIME parser 12 | """ 13 | def setUp(self) -> None: 14 | with patch('builtins.open', mock_open(read_data=TEST_EDS)) as m: 15 | self.eds = eds.load_eds_file("star_tracker_OD.eds") 16 | 17 | def test_TIME(self): 18 | """ 19 | Test TIME Message 20 | """ 21 | time_message = [0xE1, 0xC1, 0x97, 0x02, 0x59, 0x34] 22 | self.assertEqual("Time - 09/09/2020 12:05:00.001000", 23 | time.parse(123, time_message, self.eds), 24 | "Error on heartbeat Message parse") 25 | 26 | def test_TIME_empty(self): 27 | """ 28 | Test TIME Message with empty payload 29 | """ 30 | time_message = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00] 31 | self.assertEqual("Time - 01/01/1984 00:00:00.000000", 32 | time.parse(123, time_message, self.eds), 33 | "Error on time Message parse with empty payload") 34 | 35 | def test_TIME_invalid(self): 36 | """ 37 | Test TIME Message with an invalid payload 38 | """ 39 | time_message = [0xFF] 40 | with self.assertRaises(FailedValidationError) as context: 41 | time.parse(123, time_message, self.eds) 42 | 43 | self.assertEqual("Invalid TIME message length", 44 | str(context.exception)) 45 | --------------------------------------------------------------------------------