├── pyisolib ├── vt_objects │ ├── __init__.py │ ├── abstract_object.py │ ├── listed_items.py │ ├── soft_key_objects.py │ ├── object_pool.py │ ├── variable_objects.py │ ├── shape_objects.py │ ├── other_objects.py │ ├── attribute_objects.py │ ├── graphics_objects.py │ ├── output_field_objects.py │ ├── input_field_objects.py │ └── top_level_objects.py ├── pgns.py ├── functions.py ├── technical_data.py ├── __init__.py ├── macro_commands.py ├── packet_utils.py ├── extended_tp.py └── working_set.py ├── example ├── assets │ ├── logo.png │ ├── background.png │ └── buttons │ │ ├── auto.jpg │ │ ├── min.png │ │ ├── plus.png │ │ ├── stop.jpg │ │ ├── cancel.png │ │ ├── manual.jpg │ │ ├── reset.png │ │ ├── start.jpg │ │ ├── start-old.png │ │ └── calibration.png ├── readme.md ├── calculations.py ├── main.py └── make_pool_data.py ├── setup.py ├── README.md └── .gitignore /pyisolib/vt_objects/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GwnDaan/python-isobus-library/HEAD/example/assets/logo.png -------------------------------------------------------------------------------- /example/assets/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GwnDaan/python-isobus-library/HEAD/example/assets/background.png -------------------------------------------------------------------------------- /example/assets/buttons/auto.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GwnDaan/python-isobus-library/HEAD/example/assets/buttons/auto.jpg -------------------------------------------------------------------------------- /example/assets/buttons/min.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GwnDaan/python-isobus-library/HEAD/example/assets/buttons/min.png -------------------------------------------------------------------------------- /example/assets/buttons/plus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GwnDaan/python-isobus-library/HEAD/example/assets/buttons/plus.png -------------------------------------------------------------------------------- /example/assets/buttons/stop.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GwnDaan/python-isobus-library/HEAD/example/assets/buttons/stop.jpg -------------------------------------------------------------------------------- /example/assets/buttons/cancel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GwnDaan/python-isobus-library/HEAD/example/assets/buttons/cancel.png -------------------------------------------------------------------------------- /example/assets/buttons/manual.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GwnDaan/python-isobus-library/HEAD/example/assets/buttons/manual.jpg -------------------------------------------------------------------------------- /example/assets/buttons/reset.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GwnDaan/python-isobus-library/HEAD/example/assets/buttons/reset.png -------------------------------------------------------------------------------- /example/assets/buttons/start.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GwnDaan/python-isobus-library/HEAD/example/assets/buttons/start.jpg -------------------------------------------------------------------------------- /example/assets/buttons/start-old.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GwnDaan/python-isobus-library/HEAD/example/assets/buttons/start-old.png -------------------------------------------------------------------------------- /example/assets/buttons/calibration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GwnDaan/python-isobus-library/HEAD/example/assets/buttons/calibration.png -------------------------------------------------------------------------------- /pyisolib/pgns.py: -------------------------------------------------------------------------------- 1 | class PGNS: 2 | EEC1 = 61444 # Electronic Engine Controller 1 3 | VT_TO_ECU = 58880 4 | ECU_TO_VT = 59174 5 | WORKING_SET_MASTER = 65037 6 | -------------------------------------------------------------------------------- /pyisolib/functions.py: -------------------------------------------------------------------------------- 1 | class TechinalData: 2 | GET_MEMORY = 192 3 | GET_HARDWARE = 199 4 | GET_SOFT_KEYS = 194 5 | 6 | 7 | class TransferObjectPool: 8 | TRANSFER = 17 9 | END_OF_POOL = 18 10 | 11 | 12 | class Status: 13 | VT_STATUS = 254 14 | MAINTENANCE = 255 15 | 16 | 17 | class Activation: 18 | SOFT_KEY = 0 19 | 20 | 21 | class Commands: 22 | CHANGE_SIZE = 166 23 | CHANGE_NUMERIC_VALUE = 168 24 | CHANGE_ACTIVE_MASK = 173 25 | -------------------------------------------------------------------------------- /pyisolib/technical_data.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass 5 | class TechnicalData: 6 | vt_version: int = None 7 | 8 | # Width and height of available data mask 9 | screen_width: int = None # in pixels 10 | screen_height: int = None # in pixels 11 | 12 | # Width and height of soft key descriptor 13 | soft_key_width: int = None # in pixels 14 | soft_key_height: int = None # in pixels 15 | 16 | # Number of physical and virtual soft keys available 17 | soft_key_virtual_amount: int = None 18 | soft_key_physical_amount: int = None 19 | -------------------------------------------------------------------------------- /pyisolib/__init__.py: -------------------------------------------------------------------------------- 1 | from .working_set import WorkingSet 2 | from .vt_objects.graphics_objects import GraphicsObject 3 | from .vt_objects.listed_items import * 4 | from .vt_objects.top_level_objects import * 5 | from .vt_objects.shape_objects import * 6 | from .vt_objects.attribute_objects import * 7 | from .vt_objects.input_field_objects import * 8 | from .vt_objects.output_field_objects import * 9 | from .vt_objects.soft_key_objects import * 10 | from .vt_objects.variable_objects import * 11 | from .vt_objects.other_objects import * 12 | from .functions import * 13 | from .pgns import * 14 | from . import macro_commands 15 | -------------------------------------------------------------------------------- /pyisolib/vt_objects/abstract_object.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from dataclasses import dataclass, fields 3 | 4 | 5 | @dataclass 6 | class DataObject(ABC): 7 | def __post_init__(self): 8 | _validate(self) 9 | 10 | @abstractmethod 11 | def get_data(self) -> bytes: 12 | """Get this object as data to transmit it over ISOBUS.""" 13 | raise NotImplementedError() 14 | 15 | 16 | def _validate(instance): 17 | for field in fields(instance): 18 | attr = getattr(instance, field.name) 19 | if not isinstance(attr, field.type): 20 | raise ValueError(f"Field '{field.name}' is type {type(attr)}, but should be {field.type}") 21 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name="pyisolib", 5 | url="https://github.com/GwnDaan/python-isobus-library", 6 | version="1.0.0", 7 | packages=find_packages(exclude=['docs', 'examples']), 8 | author="Daan Steenbergen", 9 | description="ISOBUS stack implementation", 10 | keywords="ISOBUS ISO-11783", 11 | # license="MIT", 12 | # platforms=["any"], 13 | # classifiers=[ 14 | # "License :: OSI Approved :: MIT License", 15 | # "Operating System :: OS Independent", 16 | # "Programming Language :: Python :: 3", 17 | # "Intended Audience :: Developers", 18 | # "Topic :: Scientific/Engineering" 19 | # ], 20 | install_requires=[ 21 | "can-j1939>=2.0.4", 22 | "pillow>=9.0.0" 23 | ], 24 | ) -------------------------------------------------------------------------------- /pyisolib/vt_objects/listed_items.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from .abstract_object import DataObject 3 | from ..packet_utils import SignedInt, object_to_bytes 4 | 5 | 6 | @dataclass 7 | class ListedObject(DataObject): 8 | object_id: int 9 | x_location: int 10 | y_location: int 11 | 12 | # Overrides from DataObject 13 | def get_data(self): 14 | return object_to_bytes([self.object_id, SignedInt(self.x_location), SignedInt(self.y_location)], 2, 2, 2) 15 | 16 | 17 | @dataclass 18 | class ListedMacro(DataObject): 19 | event_id: int 20 | macro_id: int 21 | 22 | # Overrides from DataObject 23 | def get_data(self): 24 | return object_to_bytes([self.event_id, self.macro_id], 1, 1) 25 | 26 | 27 | @dataclass 28 | class ListedLanguage(DataObject): 29 | language_code: str 30 | 31 | # Overrides from DataObject 32 | def get_data(self): 33 | return object_to_bytes([self.language_code], 2) 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Python ISOBUS Library 2 | 3 | **Discontinued in favor of further development of [isobus++](https://github.com/ad3154/ISO11783-CAN-Stack). I plan to wrap it for Python when we release a stable version.** 4 | 5 | 6 | This project is created to quickly setup a new 'virtual terminal' for an implement connected to a tractor using ISOBUS. 7 | Get to know more about ISOBUS: [What is ISOBUS?](https://www.autopi.io/blog/what-is-isobus-and-iso11783/) 8 | 9 | ### Getting Started 10 | 11 | These instructions will give you a copy of the project up and running on 12 | your local machine for development and testing purposes. 13 | 14 | Requirements for the software and other tools to build, test and push 15 | - [Python 3.x](https://www.python.org/downloads/) 16 | - CANbus -> serial peripheral (e.g. a [RPI-can-hat](https://www.waveshare.com/rs485-can-hat.htm)) 17 | 18 | Install pyisobus with pip: 19 | 20 | $ pip install git+https://github.com/GwnDaan/python-isobus-library.git#egg=pyisobus 21 | 22 | ### Quick Start 23 | 24 | See the [example](https://github.com/GwnDaan/python-isobus-library/tree/master/example) on how to setup a virtual terminal yourself. 25 | -------------------------------------------------------------------------------- /pyisolib/vt_objects/soft_key_objects.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | 3 | from ..packet_utils import object_to_bytes 4 | from .abstract_object import DataObject 5 | 6 | 7 | @dataclass 8 | class SoftKeyObject(DataObject): 9 | _TYPE = 5 # Byte 3 10 | 11 | object_id: int # Byte 1-2 12 | background_color: int # Byte 4 13 | key_code: int # Byte 5 14 | objects: list = field(default_factory=list) # Number of objects = byte 5, repeated with starting byte 7 15 | macros: list = field(default_factory=list) # Number of macros = byte 6, repeated after objects 16 | 17 | # Overrides from DataObject 18 | def get_data(self): 19 | return object_to_bytes( 20 | [ 21 | self.object_id, 22 | self._TYPE, 23 | self.background_color, 24 | self.key_code, 25 | len(self.objects), 26 | len(self.macros), 27 | self.objects, 28 | self.macros, 29 | ], 30 | # The following are the byte_length of each data value 31 | 2, 32 | 1, 33 | 1, 34 | 1, 35 | 1, 36 | 1, 37 | 6, 38 | 2, 39 | ) 40 | -------------------------------------------------------------------------------- /pyisolib/macro_commands.py: -------------------------------------------------------------------------------- 1 | from .functions import Commands 2 | from .packet_utils import object_to_bytes 3 | 4 | 5 | def __convert_cmd(*args, completer=0xFF) -> bytes: 6 | data = bytes(0) 7 | for element in args: 8 | data += element if isinstance(element, bytes) else bytes([element]) 9 | 10 | # We only need to complete if len of data is less than 8. 11 | # The transport protocol and extended transport protocol will handle it themselves 12 | if len(data) < 8: 13 | data += bytes((completer for _ in range(8 - len(data)))) 14 | 15 | return data 16 | 17 | 18 | # ---------------------- 19 | # All the different cmds 20 | # ---------------------- 21 | 22 | 23 | def convert_numericvalue_cmd(object_id, new_value) -> bytes: 24 | return __convert_cmd( 25 | Commands.CHANGE_NUMERIC_VALUE, 26 | # Data follows below 27 | object_to_bytes( 28 | [object_id, 0xFF, new_value], 29 | # Byte-length follows below 30 | 2, 31 | 1, 32 | 4, 33 | ), 34 | ) 35 | 36 | 37 | def change_size_cmd(object_id, new_width, new_height) -> bytes: 38 | return __convert_cmd( 39 | Commands.CHANGE_SIZE, 40 | # Data follows below 41 | object_to_bytes( 42 | [object_id, new_width, new_height], 43 | # Byte-length follows below 44 | 2, 45 | 2, 46 | 2, 47 | ), 48 | ) 49 | 50 | 51 | def change_active_mask_cmd(object_id, new_mask_id) -> bytes: 52 | return __convert_cmd( 53 | Commands.CHANGE_ACTIVE_MASK, 54 | # Data follows below 55 | object_to_bytes( 56 | [object_id, new_mask_id], 57 | # Byte-length follows below 58 | 2, 59 | 2, 60 | ), 61 | ) 62 | -------------------------------------------------------------------------------- /example/readme.md: -------------------------------------------------------------------------------- 1 | # Fertilizer Spreader Example 2 | 3 | image 4 | 5 | This folder of the repository demonstrates basic usage of the `pyisolib` package. It contains a simple example of a terminal for controlling a implement through ISOBUS' Universal Terminal (UT). Note that, since this example is for demonstration purposes, the logic (e.g. reading sensors, controlling motors) of the implement itself is left out. 6 | 7 | # Hardware 8 | 9 | The parts used for this build are: 10 | - Raspberry Pi Zero 2 W 11 | - [CAN hat for Raspberry Pi](https://www.waveshare.com/rs485-can-hat.htm) 12 | 13 | The Raspberry Pi is mounted on the Fertilizer spreader, and connected via the CAN hat to the canbus via the corrosponding [connector](https://user-images.githubusercontent.com/29043784/184420066-8d0ee738-3c74-482c-a8d3-33697cc56a9f.png) on the back of the tractor. Note that there could be multiple connectors for ISOBUS in- and outside the cab of the tractor, so choose what is most convienent for you. 14 | 15 | # Software 16 | 17 | Design of the terminal has been done in [figma](https://www.figma.com/). You can see the design on the top, a real life photo still needs to be taken. 18 | 19 | - `main.py`: This file contains the startup/shutdown logic and listens to messages send on the canbus. 20 | - `calculations.py`: This part of the code holds the state of the implement locally and calculates the valve state when needed. 21 | - `make_pool_data.py`: This script generates an object pool data file for the fertilizer spreader terminal. This file will later be transfered to the UT via the `WorkingSet` class in the `pyisolib` package. This step is not necessary, but as generating the object pool is quite an heavy operation for the RPI Zero, doing it this way recudes the load time of the implement by a lot. 22 | -------------------------------------------------------------------------------- /pyisolib/vt_objects/object_pool.py: -------------------------------------------------------------------------------- 1 | from dataclasses import fields 2 | from .abstract_object import DataObject 3 | from .top_level_objects import DataMaskObject, WorkingSetObject 4 | 5 | 6 | class ObjectPool: 7 | def __init__(self): 8 | self._objects = [] 9 | self.file_name = None 10 | self.cached_data = None 11 | 12 | def add_object(self, object): 13 | if not isinstance(object, DataObject): 14 | raise RuntimeError(f"Expected instance of DataObject but found {type(object)}") 15 | self._objects.append(object) 16 | 17 | def is_ready(self): 18 | """Returns true if the pool is ready for transmission""" 19 | object_types = (type(object) for object in self._objects) 20 | return ((WorkingSetObject in object_types and DataMaskObject in object_types) or self.file_name is not None) and self.cached_data is not None 21 | 22 | def cache_data(self): 23 | """Get this pool as data to transmit it over ISOBUS.""" 24 | if self.file_name is not None: 25 | # We read from the file instead 26 | with open(self.file_name, "rb") as file: 27 | self.cached_data = file.read() 28 | else: 29 | result = bytearray() 30 | for object in self._objects: 31 | result.extend(object.get_data()) 32 | self.cached_data = bytes(result) # Set it after we got all data as it might return errors and then is_ready will flag incorrectly 33 | 34 | @staticmethod 35 | def save_pooldata_to_file(pool_class, file_name): 36 | result = bytearray() 37 | for field in fields(pool_class): 38 | object = field.default 39 | print(type(object)) 40 | if not isinstance(object, DataObject): 41 | raise ValueError(f"Expected instance of DataObject but found {type(object)}") 42 | 43 | result.extend(object.get_data()) 44 | 45 | file = open(file_name, "wb") 46 | file.write(result) 47 | -------------------------------------------------------------------------------- /pyisolib/vt_objects/variable_objects.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from .abstract_object import DataObject 3 | from ..packet_utils import object_to_bytes 4 | 5 | 6 | @dataclass 7 | class StringVariable(DataObject): 8 | _TYPE = 22 # Byte 3 9 | 10 | object_id: int # Byte 1-2 11 | length: int # Byte 4-5 12 | value: str # Starting from byte 6 13 | 14 | # Overrides from DataObject 15 | def get_data(self): 16 | value = self.value 17 | if len(value) < self.length: 18 | value += (" " for _ in range(self.length - len(value))) 19 | elif len(value) > self.length: 20 | raise ValueError(f"The length field ({self.length} is not equal to the lenght of the value", len(value)) 21 | 22 | return object_to_bytes( 23 | [ 24 | self.object_id, 25 | self._TYPE, 26 | self.length, 27 | self.value, 28 | ], 29 | # The following are the byte_length of each data value 30 | 2, 31 | 1, 32 | 2, 33 | len(self.value), 34 | ) 35 | 36 | 37 | @dataclass 38 | class NumberVariable(DataObject): 39 | _TYPE = 21 # Byte 3 40 | 41 | object_id: int # Byte 1-2 42 | value: int # Byte 4-7 43 | 44 | # Overrides from DataObject 45 | def get_data(self): 46 | return object_to_bytes( 47 | [ 48 | self.object_id, 49 | self._TYPE, 50 | self.value, 51 | ], 52 | # The following are the byte_length of each data value 53 | 2, 54 | 1, 55 | 4, 56 | ) 57 | 58 | 59 | @dataclass 60 | class BoolVariable(NumberVariable): 61 | """NOTE: this is not an object specified by the ISOBUS standard, but is implemented for easier usage""" 62 | 63 | value: bool # Byte 4-7 64 | 65 | # Overrides from NumberVariable 66 | def get_data(self): 67 | return object_to_bytes( 68 | [ 69 | self.object_id, 70 | self._TYPE, 71 | 1 if self.value else 0, 72 | ], 73 | # The following are the byte_length of each data value 74 | 2, 75 | 1, 76 | 4, 77 | ) 78 | -------------------------------------------------------------------------------- /pyisolib/vt_objects/shape_objects.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | 3 | from ..packet_utils import object_to_bytes 4 | from .abstract_object import DataObject 5 | 6 | 7 | @dataclass 8 | class LineObject(DataObject): 9 | _TYPE = 13 # Byte 3 10 | 11 | object_id: int # Byte 1-2 12 | line_attributes: int # Byte 4-5 13 | width: int # Byte 6-7 14 | height: int # Byte 8-9 15 | line_direction: int # Byte 10 16 | macros: list = field(default_factory=list) # Number of macros = byte 11, repeated with starting byte 12 17 | 18 | # Overrides from DataObject 19 | def get_data(self): 20 | return object_to_bytes( 21 | [ 22 | self.object_id, 23 | self._TYPE, 24 | self.line_attributes, 25 | self.width, 26 | self.height, 27 | self.line_direction, 28 | len(self.macros), 29 | self.macros, 30 | ], 31 | # The following are the byte_length of each data value 32 | 2, 33 | 1, 34 | 2, 35 | 2, 36 | 2, 37 | 1, 38 | 1, 39 | 2, 40 | ) 41 | 42 | 43 | @dataclass 44 | class RectangleObject(DataObject): 45 | _TYPE = 14 # Byte 3 46 | 47 | object_id: int # BYte 1-2 48 | line_attributes: int # Byte 4-5 49 | width: int # Byte 6-7 50 | height: int # Byte 8-9 51 | line_suppresion: int # Byte 10 52 | fill_attributes: int # Byte 11-12 53 | macros: list = field(default_factory=list) # Number of macros = byte 13, repeated with starting byte 14 54 | 55 | # Overrides from DataObject 56 | def get_data(self): 57 | return object_to_bytes( 58 | [ 59 | self.object_id, 60 | self._TYPE, 61 | self.line_attributes, 62 | self.width, 63 | self.height, 64 | self.line_suppresion, 65 | self.fill_attributes, 66 | len(self.macros), 67 | self.macros, 68 | ], 69 | # The following are the byte_length of each data value 70 | 2, 71 | 1, 72 | 2, 73 | 2, 74 | 2, 75 | 1, 76 | 2, 77 | 1, 78 | 2, 79 | ) 80 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # pytype static type analyzer 135 | .pytype/ 136 | 137 | # Cython debug symbols 138 | cython_debug/ -------------------------------------------------------------------------------- /pyisolib/vt_objects/other_objects.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | 3 | from ..packet_utils import object_to_bytes 4 | from .abstract_object import DataObject 5 | 6 | 7 | @dataclass 8 | class ButtonObject(DataObject): 9 | _TYPE = 6 # Byte 3 10 | 11 | object_id: int # Byte 1-2 12 | width: int # Byte 4-5 13 | height: int # Byte 6-7 14 | background_color: int # Byte 8 15 | border_color: int # Byte 9 16 | key_code: int # Byte 10 17 | options: int # Byte 11 18 | 19 | objects: list = field(default_factory=list) # Number of objects = byte 5, repeated with starting byte 7 20 | macros: list = field(default_factory=list) # Number of macros = byte 6, repeated after objects 21 | 22 | # Overrides from DataObject 23 | def get_data(self): 24 | return object_to_bytes( 25 | [ 26 | self.object_id, 27 | self._TYPE, 28 | self.width, 29 | self.height, 30 | self.background_color, 31 | self.border_color, 32 | self.key_code, 33 | self.options, 34 | len(self.objects), 35 | len(self.macros), 36 | self.objects, 37 | self.macros, 38 | ], 39 | # The following are the byte_length of each data value 40 | 2, 41 | 1, 42 | 2, 43 | 2, 44 | 1, 45 | 1, 46 | 1, 47 | 1, 48 | 1, 49 | 1, 50 | 6, 51 | 2, 52 | ) 53 | 54 | 55 | @dataclass 56 | class PointerObject(DataObject): 57 | _TYPE = 27 # Byte 3 58 | 59 | object_id: int # Byte 1-2 60 | value: int # Byte 4-5 61 | 62 | # Overrides from DataObject 63 | def get_data(self): 64 | return object_to_bytes( 65 | [ 66 | self.object_id, 67 | self._TYPE, 68 | self.value, 69 | ], 70 | # The following are the byte_length of each data value 71 | 2, 72 | 1, 73 | 2, 74 | ) 75 | 76 | 77 | @dataclass 78 | class MacroObject(DataObject): 79 | _TYPE = 28 # Byte 3 80 | 81 | object_id: int # Byte 1-2 82 | command: bytes # Byte 4-5 83 | 84 | # Overrides from DataObject 85 | def get_data(self): 86 | return object_to_bytes( 87 | [ 88 | self.object_id, 89 | self._TYPE, 90 | len(self.command), 91 | self.command, 92 | ], 93 | # The following are the byte_length of each data value 94 | 2, 95 | 1, 96 | 2, 97 | len(self.command), 98 | ) 99 | -------------------------------------------------------------------------------- /example/calculations.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | import logging 3 | import time 4 | 5 | from pyisolib.pyisolib import macro_commands 6 | from pyisolib.pyisolib.working_set import WorkingSet 7 | 8 | MAX_WIDTH_INDICATOR = 246 9 | 10 | CALIBRATION_VALUE = 18.50 * 10 # KG/MIN max 11 | WIDTH = 10 12 | 13 | 14 | @dataclass 15 | class Values: 16 | target_throughput: int = 300 # The default flow set to 300 17 | target_valve_percentage: int = 0 # Assume it's closed. Field should range from 0-100(%) 18 | 19 | # Measured, assume starting position is closed. Field should range from 0-100(%) 20 | current_valve_percentage: int = 0 21 | sum: int = 0 # The total sum spread 22 | last_sum_adjustment: int = 0 # Last update time 23 | 24 | running: bool = False # Whether or not the machine is currently running 25 | speed: float = 0 # Assume we are standing still when we have not received data yet 26 | pto_speed: float = 0 # See above 27 | 28 | automatic: bool = True # Default automatic weight system to true 29 | 30 | 31 | def update_valve(values: Values, ws: WorkingSet) -> Values: 32 | if values.running: 33 | 34 | # Calculate elapsed time since last update 35 | time_elapsed = time.time() - values.last_sum_adjustment 36 | values.last_sum_adjustment = time.time() 37 | 38 | # Add to total sum spread based on elapsed time 39 | values.sum += values.current_valve_percentage / 100 * CALIBRATION_VALUE * time_elapsed / 60 40 | ws.send_to_vt(macro_commands.convert_numericvalue_cmd(301, int(values.sum))) 41 | 42 | # Area per minute using (speed * width) 43 | # 60 is for 'km/h' to 'km/min', and 1000 for 'm' to 'km', and 100 for 'km^2' to 'ha' 44 | ha_per_minute = (values.speed / 60) * (WIDTH / 1000) * 100 45 | 46 | if values.automatic: 47 | 48 | # Calculate target valve percentage based on speed using (throughput * area_per_minute / throughput_max) 49 | # Where 100 is the conversion to percentage 50 | calculated_percentage = (values.target_throughput * ha_per_minute / CALIBRATION_VALUE) * 100 51 | 52 | # Max out at 100% 53 | if calculated_percentage > 100: 54 | calculated_percentage = 100 55 | 56 | if int(calculated_percentage) == int(values.target_valve_percentage): 57 | # Skip if we have same values 58 | return 59 | 60 | values.target_valve_percentage = calculated_percentage 61 | 62 | # Open valve to specific percentage 63 | set_ext_valve(values.target_valve_percentage) 64 | ws.send_to_vt(macro_commands.change_size_cmd(31, int(MAX_WIDTH_INDICATOR * values.target_valve_percentage / 100), 28)) 65 | else: 66 | # Close valve 67 | set_ext_valve(0) 68 | ws.send_to_vt(macro_commands.change_size_cmd(31, 0, 28)) 69 | 70 | 71 | def set_ext_valve(percentage): 72 | """ 73 | Set the external valve to a specific percentage 74 | """ 75 | logging.debug("Setting external valve to %d%%", percentage) 76 | # controller.set_target(percentage) 77 | -------------------------------------------------------------------------------- /pyisolib/vt_objects/attribute_objects.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | 3 | from ..packet_utils import object_to_bytes 4 | from .abstract_object import DataObject 5 | 6 | 7 | @dataclass 8 | class FontAttributes(DataObject): 9 | _TYPE = 23 # Byte 3 10 | 11 | object_id: int # Byte 1-2 12 | font_color: int # Byte 4 13 | font_size: int # Byte 5 14 | font_type: int # Byte 6 15 | font_style: int # Byte 7 16 | 17 | macros: list = field(default_factory=list) # Number of macros = byte 8, repeated with starting byte 9 18 | 19 | # Overrides from DataObject 20 | def get_data(self): 21 | return object_to_bytes( 22 | [ 23 | self.object_id, 24 | self._TYPE, 25 | self.font_color, 26 | self.font_size, 27 | self.font_type, 28 | self.font_style, 29 | len(self.macros), 30 | self.macros, 31 | ], 32 | # The following are the byte_length of each data value 33 | 2, 34 | 1, 35 | 1, 36 | 1, 37 | 1, 38 | 1, 39 | 1, 40 | 2, 41 | ) 42 | 43 | 44 | @dataclass 45 | class LineAttribute(DataObject): 46 | _TYPE = 24 # Byte 3 47 | 48 | object_id: int # Byte 1-2 49 | line_color: int # Byte 4 50 | line_width: int # Byte 5 51 | line_art: int # Byte 6-7 52 | 53 | macros: list = field(default_factory=list) # Number of macros = byte 8, repeated with starting byte 9 54 | 55 | # Overrides from DataObject 56 | def get_data(self): 57 | return object_to_bytes( 58 | [ 59 | self.object_id, 60 | self._TYPE, 61 | self.line_color, 62 | self.line_width, 63 | self.line_art, 64 | len(self.macros), 65 | self.macros, 66 | ], 67 | # The following are the byte_length of each data value 68 | 2, 69 | 1, 70 | 1, 71 | 1, 72 | 2, 73 | 1, 74 | 2, 75 | ) 76 | 77 | 78 | @dataclass 79 | class FillAttribute(DataObject): 80 | _TYPE = 25 # Byte 3 81 | 82 | object_id: int # Byte 1-2 83 | fill_type: int # Byte 4 84 | fill_color: int # Byte 5 85 | fill_pattern: int # Byte 6-7 86 | 87 | macros: list = field(default_factory=list) # Number of macros = byte 8, repeated with starting byte 9 88 | 89 | # Overrides from DataObject 90 | def get_data(self): 91 | return object_to_bytes( 92 | [ 93 | self.object_id, 94 | self._TYPE, 95 | self.fill_type, 96 | self.fill_color, 97 | self.fill_pattern, 98 | len(self.macros), 99 | self.macros, 100 | ], 101 | # The following are the byte_length of each data value 102 | 2, 103 | 1, 104 | 1, 105 | 1, 106 | 2, 107 | 1, 108 | 2, 109 | ) 110 | -------------------------------------------------------------------------------- /pyisolib/vt_objects/graphics_objects.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | 3 | from .abstract_object import DataObject 4 | from ..packet_utils import object_to_bytes 5 | 6 | from PIL import Image 7 | 8 | 9 | @dataclass 10 | class GraphicsObject(DataObject): 11 | _TYPE = 20 # Byte 3 12 | 13 | object_id: int # Byte 1-2 14 | new_width: int # Byte 4-5 15 | format: int # Byte 10 # TODO: implement format 16 | # options: int # Byte 11 17 | transparency_color: int # Byte 12 # TODO: implement in options 18 | image_path: str 19 | macros: list = field(default_factory=list) # Number of macros = byte 14, repeated after 'objects' 20 | 21 | # Overrides from DataObject 22 | def get_data(self): 23 | raw_picture_data = self._get_raw_image_data() 24 | encoded_picture_data = _run_length_encoding(raw_picture_data) 25 | 26 | useEncoded = len(encoded_picture_data) < len(raw_picture_data) 27 | picture_data = encoded_picture_data if useEncoded else raw_picture_data 28 | 29 | # TODO: better way of allowing options to be set 30 | if len(encoded_picture_data) < len(raw_picture_data): 31 | self.options = 4 32 | else: 33 | self.options = 0 34 | 35 | data = object_to_bytes( 36 | [ 37 | self.object_id, 38 | self._TYPE, 39 | self.new_width, 40 | self.picture_width, 41 | self.picture_height, 42 | self.format, 43 | self.options, 44 | self.transparency_color, 45 | len(picture_data), 46 | len(self.macros), 47 | picture_data, 48 | self.macros, 49 | ], 50 | # The following are the byte_length of each data value 51 | 2, 52 | 1, 53 | 2, 54 | 2, 55 | 2, 56 | 1, 57 | 1, 58 | 1, 59 | 4, 60 | 1, 61 | len(picture_data), 62 | 2, 63 | ) 64 | 65 | return data 66 | 67 | def _get_raw_image_data(self) -> bytes: 68 | image = Image.open(self.image_path) 69 | image.load() 70 | 71 | image = image.convert("RGB") 72 | 73 | self.picture_width = image.width 74 | self.picture_height = image.height 75 | 76 | data = bytearray() 77 | for y in range(self.picture_height): 78 | for x in range(self.picture_width): 79 | r, g, b = image.getpixel((x, y)) 80 | 81 | data.append(16 + _get_websafe(r, g, b)) 82 | return data 83 | 84 | 85 | tbl = tuple((int(i) + 25) // 51 for i in range(256)) 86 | 87 | 88 | def _get_websafe(*color): 89 | r, g, b = (tbl[c] for c in color) 90 | return r * 36 + g * 6 + b 91 | 92 | 93 | def _run_length_encoding(data): 94 | compressed = bytearray() 95 | 96 | # Current info 97 | count = 1 98 | color = data[0] 99 | 100 | for i in range(1, len(data)): 101 | if data[i] == color and count < 255: 102 | count = count + 1 103 | else: 104 | compressed.append(count) 105 | compressed.append(color) 106 | color = data[i] 107 | count = 1 108 | 109 | # Append the current info as we didn't do that yet 110 | compressed.append(count) 111 | compressed.append(color) 112 | return bytes(compressed) 113 | -------------------------------------------------------------------------------- /pyisolib/vt_objects/output_field_objects.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | 3 | from ..packet_utils import SignedInt, object_to_bytes 4 | from .abstract_object import DataObject 5 | 6 | 7 | @dataclass 8 | class _OutputFieldObject(DataObject): 9 | object_id: int # Byte 1-2 10 | width: int # Byte 4-5 11 | height: int # Byte 6-7 12 | background_color: int # Byte 8 13 | font_attributes: int # Byte 9-10 14 | options: int # Byte 11 15 | 16 | # Overrides from DataObject 17 | def get_data(self, type): 18 | return object_to_bytes( 19 | [ 20 | self.object_id, 21 | type, 22 | self.width, 23 | self.height, 24 | self.background_color, 25 | self.font_attributes, 26 | self.options, 27 | ], 28 | # The following are the byte_length of each data value 29 | 2, 30 | 1, 31 | 2, 32 | 2, 33 | 1, 34 | 2, 35 | 1, 36 | ) 37 | 38 | 39 | @dataclass 40 | class StringObject(_OutputFieldObject): 41 | _TYPE = 11 # Byte 3 42 | 43 | string_variable: int # Byte 12-13 44 | justification: int # Byte 14 45 | length: int # Byte 15-16 46 | value: str # Starting from byte 17 47 | 48 | macros: list = field(default_factory=list) # Number of macros = byte after value, repeated after byte of number of macros 49 | 50 | # Overrides from _OutputFieldObject 51 | def get_data(self): 52 | data = object_to_bytes( 53 | [ 54 | self.string_variable, 55 | self.justification, 56 | self.length, 57 | self.value, 58 | len(self.macros), 59 | self.macros, 60 | ], 61 | # The following are the byte_length of each data value 62 | 2, 63 | 1, 64 | 2, 65 | len(self.value), 66 | 1, 67 | 2, 68 | ) 69 | return super().get_data(self._TYPE) + data 70 | 71 | 72 | @dataclass 73 | class NumberObject(_OutputFieldObject): 74 | """The VT uses the following equation to display the value: 75 | displayedValue = (value + offset) * scaling_factor""" 76 | 77 | _TYPE = 12 # Byte 3 78 | 79 | number_variable: int # Byte 12-13 80 | value: int # Byte 14-17 81 | offset: int # Byte 18-21, 82 | scale: float # Byte 22-25 83 | number_of_decimals: int # Byte 26 84 | use_exponential_format: bool # 27 85 | justification: int # 28 86 | 87 | macros: list = field(default_factory=list) # Number of macros = byte 29, repeated with starting byte 30 88 | 89 | # Overrides from _OutputFieldObject 90 | def get_data(self): 91 | data = object_to_bytes( 92 | [ 93 | self.number_variable, 94 | self.value, 95 | SignedInt(self.offset), 96 | self.scale, 97 | self.number_of_decimals, 98 | self.use_exponential_format, 99 | self.justification, 100 | len(self.macros), 101 | self.macros, 102 | ], 103 | # The following are the byte_length of each data value 104 | 2, 105 | 4, 106 | 4, 107 | 4, 108 | 1, 109 | 1, 110 | 1, 111 | 1, 112 | 2, 113 | ) 114 | return super().get_data(self._TYPE) + data 115 | -------------------------------------------------------------------------------- /pyisolib/vt_objects/input_field_objects.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | 3 | from ..packet_utils import object_to_bytes 4 | from .abstract_object import DataObject 5 | 6 | 7 | @dataclass 8 | class BoolInputObject(DataObject): 9 | _TYPE = 7 # Byte 3 10 | 11 | object_id: int # Byte 1-2 12 | background_color: int # Byte 4 13 | width: int # Byte 5-6 14 | foreground_color: int # Byte 7-8 15 | bool_variable: int # Byte 9-10 16 | value: bool # Byte 11 17 | enabled: bool # Byte 12 18 | 19 | macros: list = field(default_factory=list) # Number of macros = byte 13, repeated with starting byte 14 20 | 21 | # Overrides from DataObject 22 | def get_data(self, type): 23 | return object_to_bytes( 24 | [ 25 | self.object_id, 26 | type, 27 | self.background_color, 28 | self.width, 29 | self.foreground_color, 30 | self.bool_variable, 31 | self.value, 32 | self.enabled, 33 | len(self.macros), 34 | self.macros, 35 | ], 36 | # The following are the byte_length of each data value 37 | 2, 38 | 1, 39 | 1, 40 | 2, 41 | 2, 42 | 2, 43 | 1, 44 | 1, 45 | 1, 46 | 2, 47 | ) 48 | 49 | 50 | @dataclass 51 | class NumberInputObject(DataObject): 52 | _TYPE = 9 # Byte 3 53 | 54 | object_id: int # Byte 1-2 55 | width: int # Byte 4-5 56 | height: int # Byte 6-7 57 | background_color: int # Byte 8 58 | font_attributes: int # Byte 9-10 59 | options: int # Byte 11 60 | 61 | number_variable: int # Byte 12-13 62 | value: int # Byte 14-17 63 | min_value: int # Byte 18-21 64 | max_value: int # Byte 22-25 65 | offset: int # Byte 26-29 66 | scale: int # byte 30-33 67 | number_of_decimals: int # Byte 34 68 | format: int # Byte 35 69 | justification: int # Byte 36 70 | options2: int # Byte 37 71 | 72 | macros: list = field(default_factory=list) # Number of macros = byte 38, repeated with starting byte 39 73 | 74 | # Overrides from _InputFieldObject 75 | def get_data(self): 76 | part1 = object_to_bytes( 77 | [ 78 | self.object_id, 79 | self._TYPE, 80 | self.width, 81 | self.height, 82 | self.background_color, 83 | self.font_attributes, 84 | self.options, 85 | ], 86 | # The following are the byte_length of each data value 87 | 2, 88 | 1, 89 | 2, 90 | 2, 91 | 1, 92 | 2, 93 | 1, 94 | ) 95 | part2 = object_to_bytes( 96 | [ 97 | self.number_variable, 98 | self.value, 99 | self.min_value, 100 | self.max_value, 101 | self.offset, 102 | self.scale, 103 | self.number_of_decimals, 104 | self.format, 105 | self.justification, 106 | self.options2, 107 | len(self.macros), 108 | self.macros, 109 | ], 110 | # The following are the byte_length of each data value 111 | 2, 112 | 4, 113 | 4, 114 | 4, 115 | 4, 116 | 4, 117 | 1, 118 | 1, 119 | 1, 120 | 1, 121 | 1, 122 | 2, 123 | ) 124 | return part1 + part2 125 | -------------------------------------------------------------------------------- /pyisolib/vt_objects/top_level_objects.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | 3 | from .abstract_object import DataObject 4 | from .listed_items import ListedObject 5 | from ..packet_utils import object_to_bytes 6 | 7 | 8 | @dataclass 9 | class WorkingSetObject(DataObject): 10 | _TYPE = 0 # Byte 3 11 | 12 | object_id: int # Byte 1-2 13 | background_color: int # Byte 4 14 | selectable: bool # 5 15 | active_mask_object_id: int # Byte 6-7 16 | objects: list # Number of objects = byte 8, repeated with starting byte 11 17 | macros: list = field(default_factory=list) # Number of macros = byte 9, repeated after 'objects' 18 | languages: list = field(default_factory=list) # Number of languages = byte 10, repeated after 'macros' 19 | 20 | # Overrides from DataObject 21 | def get_data(self): 22 | return object_to_bytes( 23 | [ 24 | self.object_id, 25 | self._TYPE, 26 | self.background_color, 27 | self.selectable, 28 | self.active_mask_object_id, 29 | len(self.objects), 30 | len(self.macros), 31 | len(self.languages), 32 | self.objects, 33 | self.macros, 34 | self.languages, 35 | ], 36 | # The following are the byte_length of each data value 37 | 2, 38 | 1, 39 | 1, 40 | 1, 41 | 2, 42 | 1, 43 | 1, 44 | 1, 45 | 6, 46 | 2, 47 | 2, 48 | ) 49 | 50 | 51 | @dataclass 52 | class DataMaskObject(DataObject): 53 | _TYPE = 1 # Byte 3 54 | 55 | object_id: int # Byte 1-2 56 | background_color: int # Byte 4 57 | soft_key_mask: int # Byte 5-6 58 | objects: list = field(default_factory=list) # Number of objects = byte 7, repeated with starting byte 9 59 | macros: list = field(default_factory=list) # Number of macros = byte 8, repeated after 'objects' 60 | 61 | # Overrides from DataObject 62 | def get_data(self): 63 | return object_to_bytes( 64 | [ 65 | self.object_id, 66 | self._TYPE, 67 | self.background_color, 68 | self.soft_key_mask, 69 | len(self.objects), 70 | len(self.macros), 71 | self.objects, 72 | self.macros, 73 | ], 74 | # The following are the byte_length of each data value 75 | 2, 76 | 1, 77 | 1, 78 | 2, 79 | 1, 80 | 1, 81 | 6, 82 | 2, 83 | ) 84 | 85 | 86 | @dataclass 87 | class SoftKeyMaskObject(DataObject): 88 | """NOTE: objects field doesn't take the normal ListedObject class but instead just integers referring to the object id""" 89 | 90 | _TYPE = 4 # Byte 3 91 | 92 | object_id: int # Byte 1-2 93 | background_color: int # Byte 4 94 | objects: list = field(default_factory=list) # Number of objects = byte 5, repeated with starting byte 7 95 | macros: list = field(default_factory=list) # Number of macros = byte 6, repeated after objects 96 | 97 | # Overrides from DataObject 98 | def get_data(self): 99 | return object_to_bytes( 100 | [ 101 | self.object_id, 102 | self._TYPE, 103 | self.background_color, 104 | len(self.objects), 105 | len(self.macros), 106 | self.objects, 107 | self.macros, 108 | ], 109 | # The following are the byte_length of each data value 110 | 2, 111 | 1, 112 | 1, 113 | 1, 114 | 1, 115 | 2, 116 | 2, 117 | ) 118 | 119 | 120 | @dataclass 121 | class AlarmMaskObject(DataObject): 122 | _TYPE = 2 # Byte 3 123 | 124 | object_id: int # Byte 1-2 125 | background_color: int # Byte 4 126 | soft_key_mask: int # Byte 5-6 127 | priorty: int # Byte 7 128 | acoustic_signal: int # Byte 8 129 | 130 | objects: list = field(default_factory=list) # Number of objects = byte 9, repeated with starting byte 11 131 | macros: list = field(default_factory=list) # Number of macros = byte 10, repeated after objects 132 | 133 | # Overrides from DataObject 134 | def get_data(self): 135 | return object_to_bytes( 136 | [ 137 | self.object_id, 138 | self._TYPE, 139 | self.background_color, 140 | self.soft_key_mask, 141 | self.priorty, 142 | self.acoustic_signal, 143 | len(self.objects), 144 | len(self.macros), 145 | self.objects, 146 | self.macros, 147 | ], 148 | # The following are the byte_length of each data value 149 | 2, 150 | 1, 151 | 1, 152 | 2, 153 | 1, 154 | 1, 155 | 1, 156 | 1, 157 | 6, 158 | 2, 159 | ) 160 | -------------------------------------------------------------------------------- /pyisolib/packet_utils.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from .vt_objects.abstract_object import DataObject 4 | 5 | 6 | def object_to_bytes(data: list, *byte_length) -> bytes: 7 | """Converts an complete object in the form of a list to bytes 8 | NOTE: the length of data and byte_length should be the same! 9 | :param list data: 10 | The list with data 11 | :param byte_length: 12 | Specifies how many bytes each value in data should be 13 | """ 14 | if len(data) > 0 and len(data) != len(byte_length): 15 | raise RuntimeError("The length of data is not equal to the length of byte_length", data, byte_length) 16 | 17 | result = bytes(0) 18 | for i in range(len(data)): 19 | # We do each type seperately 20 | 21 | value = data[i] 22 | length = byte_length[i] 23 | if isinstance(value, int): 24 | # Make sure that we have the int byte_length type 25 | if not isinstance(length, int): 26 | raise RuntimeError("We got an invalid byte_length type for 'int' data", length) 27 | result += value.to_bytes(length, "little") 28 | 29 | elif isinstance(value, SignedInt): 30 | # Make sure that we have the int byte_length type 31 | if not isinstance(length, int): 32 | raise RuntimeError("We got an invalid byte_length type for 'signedint' data", length) 33 | result += value.get_data(length) 34 | 35 | elif isinstance(value, float): 36 | # Make sure that we have the int byte_length type 37 | if length != 4: 38 | raise RuntimeError("We got type float but byte_length is not '4'", length) 39 | result += IEEE754(value) 40 | 41 | elif isinstance(value, bool): 42 | if length != 1: 43 | raise RuntimeError("We got type bool but byte_length is not '1'", length) 44 | result += bytes(1) if value else bytes(0) 45 | 46 | elif isinstance(value, str): 47 | # Make sure that we have the int byte_length type 48 | if not isinstance(length, int): 49 | raise RuntimeError("We got an invalid byte_length type for 'str' data", length) 50 | 51 | # TODO figure out if this is the correct way of displaying string 52 | encoded = value.encode("iso-8859-1") 53 | if len(encoded) != length: 54 | raise RuntimeError(f"The lenth of our encoded string ({len(encoded)}) is not equal to", length) 55 | result += encoded 56 | 57 | elif isinstance(value, list): 58 | # We generate new byte_lengths for each element in the list 59 | result += object_to_bytes(value, *(length for _ in range(len(value)))) 60 | 61 | elif isinstance(value, bytes) or isinstance(value, bytearray): 62 | if length != len(value): 63 | raise RuntimeError(f"We got type bytes but byte_length is not equal to length of bytes ({len(value)})", length) 64 | result += value 65 | 66 | elif isinstance(value, DataObject): 67 | # Make sure that we have the int byte_length type 68 | if not isinstance(length, int): 69 | raise RuntimeError("We got an invalid byte_length type for 'int' data", length) 70 | 71 | converted = value.get_data() 72 | if len(converted) != length: 73 | raise RuntimeError(f"The converted length ({len(converted)}) was not equal to the length provided: ", length) 74 | result += converted 75 | 76 | else: 77 | raise RuntimeError("We couldn't process the following type", type(value)) 78 | 79 | return result 80 | 81 | 82 | @dataclass 83 | class SignedInt(DataObject): 84 | value: int 85 | 86 | # Overrides from DataObject 87 | def get_data(self, length) -> bytes: 88 | return self.value.to_bytes(length, "little", signed=True) 89 | 90 | 91 | # TODO: below is copied and therefore only for testing 92 | # Function for converting decimal to binary 93 | def float_bin(my_number, places=3): 94 | my_whole, my_dec = str(my_number).split(".") 95 | my_whole = int(my_whole) 96 | res = (str(bin(my_whole)) + ".").replace("0b", "") 97 | 98 | for x in range(places): 99 | my_dec = str("0.") + str(my_dec) 100 | temp = "%1.20f" % (float(my_dec) * 2) 101 | my_whole, my_dec = temp.split(".") 102 | res += my_whole 103 | return res 104 | 105 | 106 | def IEEE754(n): 107 | # identifying whether the number 108 | # is positive or negative 109 | sign = 0 110 | if n < 0: 111 | sign = 1 112 | n = n * (-1) 113 | p = 30 114 | # convert float to binary 115 | dec = float_bin(n, places=p) 116 | 117 | dotPlace = dec.find(".") 118 | onePlace = dec.find("1") 119 | # finding the mantissa 120 | if onePlace > dotPlace: 121 | dec = dec.replace(".", "") 122 | onePlace -= 1 123 | dotPlace -= 1 124 | elif onePlace < dotPlace: 125 | dec = dec.replace(".", "") 126 | dotPlace -= 1 127 | mantissa = dec[onePlace + 1 :] 128 | 129 | # calculating the exponent(E) 130 | exponent = dotPlace - onePlace 131 | exponent_bits = exponent + 127 132 | 133 | # converting the exponent from 134 | # decimal to binary 135 | exponent_bits = bin(exponent_bits).replace("0b", "") 136 | 137 | mantissa = mantissa[0:23] 138 | 139 | # the IEEE754 notation in binary 140 | final = str(sign) + exponent_bits.zfill(8) + mantissa 141 | 142 | # convert the binary to hexadecimal 143 | # hstr = '0x%0*X' %((len(final) + 3) // 4, int(final, 2)) 144 | # return (hstr, final) 145 | return int(final, 2).to_bytes(len(final) // 8, byteorder="little") 146 | -------------------------------------------------------------------------------- /example/main.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | import os 3 | from main import Values 4 | from make_pool_data import PreparedPoolData 5 | from calculations import update_valve 6 | import pyisolib.macro_commands as macro_commands 7 | from pyisolib.functions import Activation 8 | from pyisolib.pyisolib.vt_objects.object_pool import ObjectPool 9 | from pyisolib.working_set import WorkingSet 10 | import logging 11 | import time 12 | import j1939 13 | 14 | 15 | logging.getLogger("j1939").setLevel(logging.DEBUG) 16 | logging.getLogger("can").setLevel(logging.DEBUG) 17 | 18 | # compose the name descriptor for the new ca 19 | name = j1939.Name( 20 | arbitrary_address_capable=0, 21 | industry_group=j1939.Name.IndustryGroup.AgriculturalAndForestry, 22 | vehicle_system_instance=0, 23 | vehicle_system=1, 24 | function=4, 25 | function_instance=0, 26 | ecu_instance=0, 27 | manufacturer_code=111, 28 | identity_number=1234567, 29 | ) # TODO: change parameters to your own values 30 | 31 | ca = j1939.ControllerApplication(name, 128) 32 | 33 | # create the ElectronicControlUnit (one ECU can hold multiple ControllerApplications) 34 | ecu = j1939.ElectronicControlUnit() 35 | ws = WorkingSet(ca) 36 | 37 | # Below we create an instance of Values where we will keep track of the current state of the machine 38 | values = Values() 39 | 40 | 41 | def main(): 42 | print("Initializing") 43 | 44 | # Start canbus communication with baudrate of 250kbps 45 | os.system("sudo ip link set can0 type can bitrate 250000") 46 | os.system("sudo ifconfig can0 up") 47 | os.system("sudo ifconfig can0 txqueuelen 1000") 48 | 49 | try: 50 | # Connect to the canbus 51 | ecu.connect(bustype="socketcan", channel="can0") 52 | ecu.add_ca(controller_application=ca) 53 | 54 | # Load the cached pool data file if present, otherwise create it. 55 | # See the README.md for more information on how to generate the pool data file AND when to update it 56 | POOL_DATA_FILE_NAME = "vtpooldata.bin" 57 | if os.path.isfile(POOL_DATA_FILE_NAME): 58 | ws.set_object_pool_file(POOL_DATA_FILE_NAME) 59 | else: 60 | pool_data = PreparedPoolData() 61 | ObjectPool.save_pooldata_to_file(pool_data, POOL_DATA_FILE_NAME) 62 | 63 | ca.start() 64 | print("Starting CA...") 65 | 66 | # Wait for controller apllication to be ready 67 | while ca.state != j1939.ControllerApplication.State.NORMAL: 68 | time.sleep(1) 69 | 70 | print("Starting WS...") 71 | ws.start() 72 | 73 | # Wait for the working set to operate normally (It must upload the objectpool etc.) 74 | while ws.state != WorkingSet.State.NORMAL: 75 | time.sleep(1) 76 | 77 | # Listen to functions now the workingset is started 78 | ws.add_listener(listen_functions, True) 79 | ws.add_listener(listen_canbus, False) 80 | print( 81 | f"""Specifications of VT: width {ws.technical_data.screen_width}, height {ws.technical_data.screen_height}, version {ws.technical_data.vt_version}, 82 | physical_softkey_amount {ws.technical_data.soft_key_physical_amount}, virtual_softkey_amount {ws.technical_data.soft_key_virtual_amount}, 83 | virtual_softkey_height {ws.technical_data.soft_key_height}, virtual_softkey_width {ws.technical_data.soft_key_width}""" 84 | ) 85 | 86 | print("Listening to potmeter") 87 | while True: 88 | # Output of controller.run should be the percentage of the valve (measured) 89 | # values.current_valve_percentage = controller.run() 90 | 91 | time.sleep(0.05) 92 | except KeyboardInterrupt: 93 | pass 94 | finally: 95 | print("Deinitializing") 96 | ca.stop() 97 | ecu.disconnect() 98 | os.system("sudo ifconfig can0 down") 99 | 100 | 101 | def listen_canbus(pgn, data): 102 | if pgn == 65096: # Ground based speed 103 | new_speed = (data[0] * 0.001 + data[1] * 0.256) * 3.65 104 | 105 | # Ignore if speed is still the same 106 | if new_speed == values.speed: 107 | return 108 | values.speed = new_speed 109 | 110 | # Send speed to display 111 | ws.send_to_vt(macro_commands.convert_numericvalue_cmd(302, int(values.speed * 10))) 112 | 113 | # Notify everything that valve size must be modified 114 | update_valve() 115 | 116 | elif pgn == 65091: # PTO speed 117 | values.pto_speed = int(int.from_bytes(data[0:2], "little") * 0.125) 118 | ws.send_to_vt(macro_commands.convert_numericvalue_cmd(303, int(values.pto_speed))) 119 | 120 | 121 | def listen_functions(function, data): 122 | if function == Activation.SOFT_KEY: 123 | code = data[0] 124 | if code != 0: # If code is not key released we ignore 125 | return 126 | 127 | object_id = int.from_bytes(data[1:3], "little") 128 | if object_id == 1002 or object_id == 1003: # Minus or plus throughtput button released 129 | 130 | if values.automatic: 131 | # We change the target throughtput if it's set to automatic 132 | if object_id == 1002: # Minus 133 | values.target_throughput = max(0, values.target_throughput - 10) # Take 10 of the current target output but make sure it stays above 0 134 | else: # Plus 135 | values.target_throughput += 10 # Add 10 136 | ws.send_to_vt(macro_commands.convert_numericvalue_cmd(300, values.target_throughput)) 137 | else: 138 | # We directly change the valve if it's set to manual 139 | values.target_valve_percentage += -5 if object_id == 1002 else 5 140 | values.target_valve_percentage = min(100, max(0, values.target_valve_percentage)) # Limit the percentage to 0-100% 141 | update_valve() # Update the external valve 142 | 143 | elif object_id == 1100: # Start button pressed 144 | assert values.running == False 145 | values.running = True 146 | update_valve() # Update the external valve 147 | print("Running!") 148 | 149 | elif object_id == 1102: # Stop button pressed 150 | assert values.running == True 151 | values.running = False 152 | update_valve() # Update the external valve 153 | print("Stopped!") 154 | 155 | elif object_id == 1110: # Manual button pressed 156 | assert values.automatic == True 157 | values.automatic = False 158 | ws.send_to_vt(macro_commands.convert_numericvalue_cmd(300, 0)) # Set the target flow seen by the user to 0 159 | print("Manual!") 160 | 161 | elif object_id == 1112: # Automatic button pressed 162 | assert values.automatic == False 163 | values.automatic = True 164 | ws.send_to_vt(macro_commands.convert_numericvalue_cmd(300, values.target_throughput)) # Set the target flow to the previous target flow 165 | print("Auto!") 166 | 167 | 168 | if __name__ == "__main__": 169 | main() 170 | -------------------------------------------------------------------------------- /example/make_pool_data.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | import pyisolib.macro_commands as macro_commands 3 | from pyisolib.vt_objects.graphics_objects import GraphicsObject 4 | from pyisolib.vt_objects.abstract_object import DataObject 5 | from pyisolib.vt_objects.attribute_objects import FillAttribute, FontAttributes, LineAttribute 6 | from pyisolib.vt_objects.listed_items import ListedMacro, ListedObject 7 | from pyisolib.vt_objects.object_pool import ObjectPool 8 | from pyisolib.vt_objects.other_objects import MacroObject, PointerObject 9 | from pyisolib.vt_objects.output_field_objects import NumberObject 10 | from pyisolib.vt_objects.shape_objects import RectangleObject 11 | from pyisolib.vt_objects.soft_key_objects import SoftKeyObject 12 | from pyisolib.vt_objects.top_level_objects import AlarmMaskObject, SoftKeyMaskObject, DataMaskObject, WorkingSetObject 13 | from pyisolib.vt_objects.variable_objects import NumberVariable 14 | 15 | from main import Values 16 | 17 | values = Values() # We use default values 18 | 19 | 20 | @dataclass 21 | class PreparedPoolData: 22 | 23 | # Background 24 | background: DataObject = GraphicsObject(20, 600, 2, 0, "assets/background.png") 25 | logo: DataObject = GraphicsObject(21, 80, 2, 0, "assets/logo.png") 26 | 27 | # Attributes 28 | style_line: DataObject = LineAttribute(100, 102, 2, 65535) # Line with color #FF66666 and width 2 29 | style_fill: DataObject = FillAttribute(101, 1, 0, 65535) # Fill with color of line attribute 30 | style_font: DataObject = FontAttributes(102, 0, 5, 0, 0) 31 | 32 | # Add the flow indicator 33 | flow_indicator: DataObject = RectangleObject(31, 100, 0, 28, 0, 101) 34 | 35 | # Target throughput number 36 | target_throughput_variable: DataObject = NumberVariable(300, values.target_throughput) 37 | target_throughput: DataObject = NumberObject(32, 69, 22, 0, style_font.object_id, 1, target_throughput_variable.object_id, 0, 0, 1.0, 0, False, 0) 38 | 39 | # Sum spread number 40 | sum_variable: DataObject = NumberVariable(301, values.sum) 41 | sum: DataObject = NumberObject(33, 69, 22, 0, style_font.object_id, 1, sum_variable.object_id, 0, 0, 1.0, 0, False, 0) 42 | 43 | # Speed number 44 | speed_variable: DataObject = NumberVariable(302, 0) # km/h default 0 45 | speed: DataObject = NumberObject(34, 96, 22, 0, style_font.object_id, 1, speed_variable.object_id, 0, 0, 0.1, 1, False, 0) 46 | 47 | # PTO speed number 48 | pto_speed_variable: DataObject = NumberVariable(303, 0) # pto default 0 49 | pto_speed: DataObject = NumberObject(35, 96, 22, 0, style_font.object_id, 1, pto_speed_variable.object_id, 0, 0, 1.0, 0, False, 0) 50 | 51 | # # Flow number 52 | # throughput_variable: DataObject = NumberVariable(304, values.current_throughput) 53 | # throughput: DataObject = NumberObject(36, 37, 22, 0, style_font.object_id, 1, throughput_variable.object_id, 0, 0, 1.0, 0, False, 0) 54 | 55 | # # Weight number 56 | # weight_variable: DataObject = NumberVariable(305, values.product_weight) 57 | # weight: DataObject = NumberObject(37, 37, 22, 0, style_font.object_id, 1, weight_variable.object_id, 0, 0, 1.0, 0, False, 0) 58 | 59 | # Top level objects 60 | datamask: DataObject = DataMaskObject( 61 | 1, 62 | 7, 63 | 2, 64 | [ 65 | # Background 66 | ListedObject(background.object_id, 0, 0), 67 | # Flow indicator 68 | ListedObject(flow_indicator.object_id, 177, 474), 69 | # Top info numbers 70 | ListedObject(target_throughput.object_id, 158, 40), 71 | ListedObject(sum.object_id, 158, 66), 72 | ListedObject(speed.object_id, 384, 40), 73 | ListedObject(pto_speed.object_id, 384, 66), 74 | # Bottom info numbers 75 | # ListedObject(throughput.object_id, 290, 523), ListedObject(weight.object_id, 290, 549), 76 | ], 77 | ) 78 | workingset: DataObject = WorkingSetObject(2002, 7, True, datamask.object_id, [ListedObject(logo.object_id, 0, 5)]) 79 | 80 | # ---------------- 81 | # Add virtual keys 82 | # ---------------- 83 | 84 | # Start stop 85 | start_icon: DataObject = GraphicsObject(1101, 80, 2, 0, "assets/buttons/start.jpg") 86 | start_button: DataObject = SoftKeyObject(1100, 0, 1, [ListedObject(start_icon.object_id, 0, 0)], [ListedMacro(25, 200)]) # Soft key for start button 87 | 88 | stop_icon: DataObject = GraphicsObject(1103, 80, 2, 0, "assets/buttons/stop.jpg") 89 | stop_button: DataObject = SoftKeyObject(1102, 0, 1, [ListedObject(stop_icon.object_id, 0, 0)], [ListedMacro(25, 201)]) # Soft key for stop button 90 | 91 | startstop_pointer: DataObject = PointerObject(1000, stop_button.object_id if values.running else start_button.object_id) # Create pointer soft key (id=1000) 92 | to_stop_macro: DataObject = MacroObject(200, macro_commands.convert_numericvalue_cmd(startstop_pointer.object_id, stop_button.object_id)) # Macro to change start to stop button 93 | to_start_macro: DataObject = MacroObject(201, macro_commands.convert_numericvalue_cmd(startstop_pointer.object_id, start_button.object_id)) # Macro to change stop to start button 94 | 95 | # Auto manual 96 | manual_icon: DataObject = GraphicsObject(1111, 80, 2, 0, "assets/buttons/manual.jpg") 97 | manual_button: DataObject = SoftKeyObject(1110, 0, 1, [ListedObject(manual_icon.object_id, 0, 0)], [ListedMacro(25, 202)]) # Create soft key for manual button 98 | 99 | auto_icon: DataObject = GraphicsObject(1113, 80, 2, 0, "assets/buttons/auto.jpg") 100 | auto_button: DataObject = SoftKeyObject(1112, 0, 1, [ListedObject(auto_icon.object_id, 0, 0)], [ListedMacro(25, 203)]) # Create soft key for automatic button 101 | 102 | automanual_pointer: DataObject = PointerObject(1001, manual_button.object_id if values.automatic else auto_button.object_id) # Create pointer soft key (id=1001) 103 | to_auto_macro: DataObject = MacroObject(202, macro_commands.convert_numericvalue_cmd(automanual_pointer.object_id, auto_button.object_id)) # Macro to change manual to auto button 104 | to_manual_macro: DataObject = MacroObject(203, macro_commands.convert_numericvalue_cmd(automanual_pointer.object_id, manual_button.object_id)) # Macro to change auto to manual button 105 | 106 | # minus throughput 107 | min_icon: DataObject = GraphicsObject(1120, 80, 2, 0, "assets/buttons/min.png") 108 | min_button: DataObject = SoftKeyObject(1002, 7, 1, [ListedObject(min_icon.object_id, 0, 0)]) # Create soft key (id=1002) and (child=1120) 109 | 110 | # plus throughput 111 | plus_icon: DataObject = GraphicsObject(1130, 80, 2, 0, "assets/buttons/plus.png") 112 | plus_button: DataObject = SoftKeyObject(1003, 7, 1, [ListedObject(plus_icon.object_id, 0, 0)]) # Create soft key (id=1003) and (child=1130) 113 | 114 | # reset sum 115 | reset_macro: DataObject = MacroObject(204, macro_commands.convert_numericvalue_cmd(sum_variable.object_id, 0)) # Reset the number variable 116 | reset_icon: DataObject = GraphicsObject(1140, 80, 2, 0, "assets/buttons/reset.png") 117 | reset_button: DataObject = SoftKeyObject(1004, 7, 1, [ListedObject(reset_icon.object_id, 0, 0)], [ListedMacro(25, reset_macro.object_id)]) # Create soft key (id=1004) and (child=1140) 118 | 119 | # Add keys to mask 120 | softkeymask: DataObject = SoftKeyMaskObject( 121 | 2, 7, [startstop_pointer.object_id, plus_button.object_id, reset_button.object_id, 65535, 65535, 65535, automanual_pointer.object_id, min_button.object_id] 122 | ) 123 | 124 | 125 | # Create the cached pool data file to be used on the ECU 126 | if __name__ == "__main__": 127 | pool_data = PreparedPoolData() 128 | ObjectPool.save_pooldata_to_file(pool_data, "vtpooldata.bin") 129 | -------------------------------------------------------------------------------- /pyisolib/extended_tp.py: -------------------------------------------------------------------------------- 1 | import math 2 | import time 3 | from j1939.controller_application import ControllerApplication 4 | from j1939.message_id import MessageId 5 | from j1939.parameter_group_number import ParameterGroupNumber 6 | 7 | 8 | class ExtendedTP: 9 | """Represents the Extended Transport Protocol defined in ISO-11783:3. NOTE: only the outgoing part is implemented""" 10 | 11 | class ControlByte: 12 | RTS = 20 # Request to send 13 | CTS = 21 # Clear to send 14 | DPO = 22 # Data packet offset 15 | EOMA = 23 # End-of-Message acknowledgment 16 | ABORT = 255 # Coonnection abort 17 | 18 | class ConnectionAbortReason: 19 | BUSY = 1 # Already in one or more connection managed sessions and cannot support another 20 | RESOURCES = 2 # System resources were needed for another task so this connection managed session was terminated 21 | TIMEOUT = 3 # A timeout occured 22 | CTS_WHILE_DT = 4 # CTS messages received when data transfer is in progress 23 | # TODO: add the rest of reasons 24 | # 251..255 Per ISO 11783-7 definitions - but there are none? 25 | 26 | class Timeout: 27 | """Timeouts according ISO 11783-3""" 28 | 29 | Tr = 0.200 # Response Time 30 | Th = 0.500 # Holding Time 31 | T1 = 0.750 32 | T2 = 1.250 33 | T3 = 1.250 34 | T4 = 1.050 35 | # timeout for multi packet broadcast messages 50..200ms 36 | # Tb = 0.050 37 | 38 | class State: 39 | WAITING_CTS = 0 # waiting for CTS 40 | SENDING_IN_CTS = 1 # sending packages (temporary state) 41 | SENDING_BM = 2 # sending broadcast packages 42 | ABORTED = 3 # Aborted session 43 | COMPLETED = 4 # Successfully send data 44 | 45 | def __init__(self, ca: ControllerApplication, priority: int): 46 | self.ca = ca 47 | self.priority = priority 48 | 49 | def send(self, data_page, pdu_format, pdu_specific, data): 50 | pgn = ParameterGroupNumber(data_page, pdu_format, pdu_specific) 51 | 52 | message_size = len(data) 53 | self.total_num_packets = math.ceil(message_size / 7) 54 | 55 | # send RTS 56 | pgn.pdu_specific = 0 # this is 0 for peer-to-peer transfer 57 | self.pgn_target = pgn.value 58 | self.dest_address = pdu_specific 59 | # init new buffer for this connection 60 | # self._snd_buffer[buffer_hash] = { 61 | # "pgn": pgn.value, 62 | # "priority": priority, 63 | # "message_size": message_size, 64 | # "num_packages": num_packets, 65 | # "data": data, 66 | # "state": self.SendBufferState.WAITING_CTS, 67 | # "deadline": time.time() + ExtendedTP.Timeout.T3, 68 | # 'src_address' : src_address, 69 | # 'dest_address' : pdu_specific, 70 | # 'next_packet_to_send' : 0, 71 | # 'next_wait_on_cts': 0, 72 | # } 73 | self.deadline = time.time() + ExtendedTP.Timeout.T3 74 | self.state = ExtendedTP.State.WAITING_CTS 75 | self.next_packet_to_send = 0 76 | self.next_wait_on_cts = 0 77 | self.data = bytearray(data) 78 | 79 | self.ca.subscribe(self.listen_cm) 80 | self.__send_rts(message_size) 81 | 82 | self.ca.add_timer(0, self.async_job) 83 | 84 | def async_job(self, _): 85 | next_wakeup = time.time() + 5.0 # wakeup in 5 seconds 86 | 87 | if self.deadline != 0: 88 | if self.deadline > time.time(): 89 | if next_wakeup > self.deadline: 90 | next_wakeup = self.deadline 91 | else: 92 | # deadline reached 93 | if self.state == self.State.WAITING_CTS: 94 | print("Deadline WAITING_CTS reached for snd_buffer src 0x%02X dst 0x%02X", self.ca.device_address, self.dest_address) 95 | self.__send_abort(ExtendedTP.ConnectionAbortReason.TIMEOUT) 96 | self.state = ExtendedTP.State.ABORTED 97 | elif self.state == self.State.SENDING_IN_CTS: 98 | while self.next_packet_to_send < self.total_num_packets: 99 | offset = self.next_packet_to_send * 7 100 | data = self.data[offset:] 101 | if len(data) > 7: 102 | data = data[:7] 103 | else: 104 | while len(data) < 7: 105 | data.append(255) 106 | 107 | self.next_packet_to_send += 1 108 | data.insert(0, self.sequence_number) 109 | self.sequence_number += 1 110 | self.__send_dt(data) 111 | 112 | # set end of message status 113 | if self.next_packet_to_send == self.next_wait_on_cts: 114 | # wait on next cts 115 | self.state = ExtendedTP.State.WAITING_CTS 116 | self.deadline = time.time() + self.Timeout.T3 117 | break 118 | # elif self.minimum_tp_rts_cts_dt_interval != None: 119 | # self.deadline = time.time() + self.minimum_tp_rts_cts_dt_interval 120 | # break 121 | 122 | # recalc next wakeup 123 | if next_wakeup > self.deadline: 124 | next_wakeup = self.deadline 125 | 126 | self.ca.add_timer(next_wakeup, self.async_job) 127 | 128 | def listen_cm(self, priority, pgn, src_address, timestamp, data): 129 | """Processes aa Extended Transport Protocol Connection Management (ETP.CM) message""" 130 | # if pgn != ParameterGroupNumber(0, 200, self.ca.device_address).value: 131 | if pgn != 51200: 132 | return 133 | 134 | control_byte = data[0] 135 | # pgn = data[5] | (data[6] << 8) | (data[7] << 16) 136 | 137 | if control_byte == ExtendedTP.ControlByte.CTS: 138 | if self.state == ExtendedTP.State.SENDING_IN_CTS: 139 | # print("Received CTS message but still sending, delaying...") 140 | time.sleep(ExtendedTP.Timeout.Th) # Delay CTS 141 | assert self.state == ExtendedTP.State.WAITING_CTS 142 | 143 | num_packages = data[1] 144 | next_package_number = int.from_bytes(data[2:6], "little") - 1 145 | if num_packages == 0: 146 | # SAE J1939/21 147 | # receiver requests a pause 148 | print("CTS: requested timeout") 149 | self.deadline = time.time() + self.Timeout.Th 150 | self.ca.add_timer(0, self.async_job) 151 | return 152 | 153 | if num_packages > self.total_num_packets: 154 | print("CTS: Allowed more packets %d than complete transmission %d", num_packages, self.total_num_packets) 155 | num_packages = self.total_num_packets 156 | if next_package_number + num_packages > self.total_num_packets: 157 | print("CTS: Allowed more packets %d than needed to complete transmission %d", num_packages, self.total_num_packets - next_package_number) 158 | num_packages = self.total_num_packets - next_package_number 159 | 160 | self.next_wait_on_cts = self.next_packet_to_send + num_packages 161 | self.sequence_number = 1 162 | # print(f"CTS: allowed {num_packages} more, index {next_package_number}, waitwhen {self.next_wait_on_cts}") 163 | 164 | self.__send_dpo(num_packages, next_package_number) 165 | self.state = ExtendedTP.State.SENDING_IN_CTS 166 | self.deadline = time.time() 167 | self.ca.add_timer(0, self.async_job) 168 | 169 | elif control_byte == ExtendedTP.ControlByte.EOMA: 170 | self.state = ExtendedTP.State.COMPLETED 171 | self.ca.add_timer(0, self.async_job) 172 | elif control_byte == ExtendedTP.ControlByte.ABORT: 173 | reason = data[1] 174 | print(f"CTS ABORTED: reason {reason}") 175 | 176 | else: 177 | raise RuntimeError(f"Received TP.CM with unknown control_byte {control_byte}") 178 | 179 | def __send_dt(self, data): 180 | pgn = ParameterGroupNumber(0, 199, self.dest_address) 181 | self.ca.send_message(self.priority, pgn.value, data) 182 | 183 | def __send_rts(self, message_size): 184 | pgn = ParameterGroupNumber(0, 200, self.dest_address) 185 | data = [ 186 | ExtendedTP.ControlByte.RTS, 187 | message_size & 0xFF, 188 | (message_size >> 8) & 0xFF, 189 | (message_size >> 16) & 0xFF, 190 | (message_size >> 24) & 0xFF, 191 | self.pgn_target & 0xFF, 192 | (self.pgn_target >> 8) & 0xFF, 193 | (self.pgn_target >> 16) & 0xFF, 194 | ] 195 | self.ca.send_message(self.priority, pgn.value, data) 196 | 197 | def __send_dpo(self, num_packets, data_offset): 198 | pgn = ParameterGroupNumber(0, 200, self.dest_address) 199 | data = [ 200 | ExtendedTP.ControlByte.DPO, 201 | num_packets, 202 | data_offset & 0xFF, 203 | (data_offset >> 8) & 0xFF, 204 | (data_offset >> 16) & 0xFF, 205 | self.pgn_target & 0xFF, 206 | (self.pgn_target >> 8) & 0xFF, 207 | (self.pgn_target >> 16) & 0xFF, 208 | ] 209 | self.ca.send_message(self.priority, pgn.value, data) 210 | 211 | def __send_abort(self, reason): 212 | pgn = ParameterGroupNumber(0, 200, self.dest_address) 213 | data = [ExtendedTP.ControlByte.ABORT, reason, 0xFF, 0xFF, 0xFF, self.pgn_target & 0xFF, (self.pgn_target >> 8) & 0xFF, (self.pgn_target >> 16) & 0xFF] 214 | self.ca.send_message(self.priority, pgn.value, data) 215 | -------------------------------------------------------------------------------- /pyisolib/working_set.py: -------------------------------------------------------------------------------- 1 | import j1939 2 | 3 | from . import functions 4 | from .extended_tp import ExtendedTP 5 | from .technical_data import TechnicalData 6 | from .pgns import PGNS 7 | from .vt_objects.object_pool import ObjectPool 8 | 9 | 10 | class WorkingSet: 11 | """WorkingSet for indentification to the virtual terminal (VT).""" 12 | 13 | class State: 14 | """The state of the working set, NOTE: it must be in increasing order""" 15 | 16 | NONE = 0 17 | UNKNOWN = 1 18 | AWAITING_VT_STATUS = 2 19 | ANNOUNCING_WORKING_MASTER = 3 20 | INIT_MAINTENANCE = 4 21 | AWAIT_HARDWARE = 5 22 | AWAIT_MEMORY = 6 23 | AWAIT_SOFT_KEYS = 7 24 | UPLOADING_POOL = 8 25 | AWAITING_POOL_COMPLETE = 9 26 | NORMAL = 10 27 | 28 | def __init__(self, ca: j1939.ControllerApplication): 29 | """ 30 | :param ControllerApplication ca: 31 | The controller application class 32 | """ 33 | self.ca = ca 34 | self.__state = WorkingSet.State.NONE 35 | self.__object_pool = ObjectPool() 36 | self.technical_data = TechnicalData() 37 | self.__listeners = [] 38 | self.__function_listeners = [] 39 | 40 | @property 41 | def state(self): 42 | return self.__state 43 | 44 | def add_listener(self, listener, is_function_listener: bool): 45 | """The function provided will be called with a packed recieved in the canbus 46 | :param listener: 47 | Should have the 'pgn/function' and 'data' parameter 48 | :param bool is_event_listener: 49 | Whether this is an event listener or not 50 | """ 51 | if is_function_listener: 52 | self.__function_listeners.append(listener) 53 | else: 54 | self.__listeners.append(listener) 55 | 56 | def start(self): 57 | """Start the working set. The controller application must be started and the object pool set!""" 58 | if self.ca.state != j1939.ControllerApplication.State.NORMAL: 59 | raise RuntimeError("ControllerApplication must be started before initializing WorkingSet!") 60 | 61 | self.__object_pool.cache_data() 62 | if not self.__object_pool.is_ready(): 63 | raise RuntimeError("The object pool is not yet ready, make sure you added objects!") 64 | 65 | self.ca.subscribe(self.__on_message) 66 | self.ca.add_timer(1, self.__tick, cookie=self.ca) # Send the maintenance message every second 67 | self.__state = WorkingSet.State.AWAITING_VT_STATUS 68 | 69 | def add_object_to_pool(self, object): 70 | if self.__state >= WorkingSet.State.UPLOADING_POOL: 71 | # TODO: implement realtime editing of pool 72 | raise NotImplementedError() 73 | 74 | self.__object_pool.add_object(object) 75 | 76 | def set_object_pool_file(self, file_name): 77 | self.__object_pool.file_name = file_name 78 | 79 | def __next_state(self): 80 | """Set current state to next""" 81 | if self.__state + 1 > WorkingSet.State.NORMAL: 82 | raise RuntimeError("Next state not possible as it is not defined.") 83 | else: 84 | self.__state += 1 85 | 86 | def __on_message(self, priority, pgn, sa, timestamp, data): 87 | """Used to receive message from the VT.""" 88 | if pgn == PGNS.VT_TO_ECU: 89 | # The format per message is different. However, the function format is common: 90 | # - byte 0: function 91 | function = data[0] 92 | if function == functions.Status.VT_STATUS: 93 | if self.__state == WorkingSet.State.AWAITING_VT_STATUS: 94 | self.__next_state() 95 | 96 | elif function == functions.TechinalData.GET_HARDWARE: 97 | assert self.__state == WorkingSet.State.AWAIT_HARDWARE 98 | self.technical_data.screen_width = int.from_bytes(data[4:6], "little") 99 | self.technical_data.screen_height = int.from_bytes(data[6:8], "little") 100 | 101 | # We completed hardware state, next is memory 102 | self.__next_state() 103 | mem_required = len(self.__object_pool.cached_data) 104 | print(f"Requesting memory info: {mem_required} bytes") 105 | self.send_to_vt(functions.TechinalData.GET_MEMORY, 0xFF, mem_required.to_bytes(4, "little")) 106 | 107 | elif function == functions.TechinalData.GET_MEMORY: 108 | assert self.__state == WorkingSet.State.AWAIT_MEMORY 109 | self.technical_data.vt_version = data[1] 110 | memory_error = data[2] 111 | 112 | # We check if the vt has enough memory. TODO reduce the requested memory size otherwise 113 | if memory_error == 1: 114 | raise RuntimeError("Not enough memory available to hold our object pool!") 115 | else: 116 | # We completed memory state, next is soft keys 117 | self.__next_state() 118 | print("Requesting softkey info") 119 | self.send_to_vt(functions.TechinalData.GET_SOFT_KEYS) 120 | 121 | elif function == functions.TechinalData.GET_SOFT_KEYS: 122 | assert self.__state == WorkingSet.State.AWAIT_SOFT_KEYS 123 | self.technical_data.soft_key_width = data[4] 124 | self.technical_data.soft_key_height = data[5] 125 | self.technical_data.soft_key_virtual_amount = data[6] 126 | self.technical_data.soft_key_physical_amount = data[7] 127 | 128 | self.__next_state() 129 | elif function == functions.TransferObjectPool.END_OF_POOL: 130 | error_code = data[1] 131 | if error_code != 0: 132 | # The pool was not valid! 133 | parent_faulty_object = int.from_bytes(data[2:4], "little") 134 | faulty_object = int.from_bytes(data[4:6], "little") 135 | object_pool_error_code = data[6] 136 | raise RuntimeError(f"END_OF_POOL_ERROR: error {error_code}, parent_faulty_object {parent_faulty_object}, faulty_object {faulty_object}, object_pool_error {object_pool_error_code}") 137 | else: 138 | self.__next_state() 139 | else: 140 | for listener in self.__function_listeners: 141 | listener(function, data[1:]) 142 | else: 143 | for listener in self.__listeners: 144 | listener(pgn, data) 145 | 146 | def __tick(self, _): 147 | """Check if we need to perform any actions""" 148 | 149 | # Announce the current working set as master if state is set 150 | if self.__state == WorkingSet.State.ANNOUNCING_WORKING_MASTER: 151 | self.send( 152 | PGNS.WORKING_SET_MASTER, 153 | 7, 154 | # Data follows below: 155 | 1, 156 | completer=0, 157 | ) 158 | self.__next_state() 159 | 160 | # Send the maintenance message IFF the state is the init maintenance state or above 161 | elif self.__state >= WorkingSet.State.INIT_MAINTENANCE: 162 | initializing = self.__state == WorkingSet.State.INIT_MAINTENANCE 163 | self.send_to_vt(functions.Status.MAINTENANCE, 1 if initializing else 0) 164 | 165 | # Get hardware and set to next state if we are currently initializing. 166 | if initializing: 167 | self.__next_state() 168 | print("Requesting hardware info") 169 | self.send_to_vt(functions.TechinalData.GET_HARDWARE) 170 | 171 | # # Upload the object pool IFF the state is set 172 | if self.__state == WorkingSet.State.UPLOADING_POOL: 173 | etp = self.send_to_vt(functions.TransferObjectPool.TRANSFER, self.__object_pool.cached_data) 174 | print(f"Uploading pool data (using etp: {etp is not None})") 175 | 176 | # Successfully uploaded the complete pool, tell the vt it is the end 177 | self.ca.add_timer(1 if etp else 5, self.send_end_of_pool, etp) 178 | self.__next_state() 179 | 180 | return True 181 | 182 | def send_end_of_pool(self, etp): 183 | if isinstance(etp, ExtendedTP): 184 | if etp.state != ExtendedTP.State.COMPLETED: 185 | return True # Request to run this again in the future 186 | print("Sending end of pool message") 187 | self.send_to_vt(functions.TransferObjectPool.END_OF_POOL) 188 | 189 | def send_to_vt(self, *data, length=8, completer=0xFF): 190 | """Completes and sends the args as pgn. 191 | 192 | :param int pgn: 193 | The parameter group number (pgn) used 194 | :param list args: 195 | The data which will be sent 196 | :param int length: 197 | The length of the data which will be sent 198 | :param int completer: 199 | The value which will be appended to the data to get the length. 200 | """ 201 | return self.send(PGNS.ECU_TO_VT, 7, *data, length=length, completer=completer) 202 | 203 | def send(self, pgn, priority, *args, length=8, completer=0xFF): 204 | """Completes and sends the args as pgn. 205 | 206 | :param int pgn: 207 | The parameter group number (pgn) used 208 | :param list args: 209 | The data which will be sent 210 | :param int length: 211 | The length of the data which will be sent 212 | :param int completer: 213 | The value which will be appended to the data to get the length. 214 | """ 215 | # Assert that we can actually send data 216 | if self.__state <= WorkingSet.State.AWAITING_VT_STATUS: 217 | raise RuntimeError("WorkingSet not yet ready to send data", self.__state) 218 | 219 | data = bytes(0) 220 | for element in args: 221 | data += element if isinstance(element, bytes) else bytes([element]) 222 | 223 | # We only need to complete if len of data is less than length. 224 | # The transport protocol and extended transport protocol will handle it themselves 225 | if len(data) < length: 226 | data += bytes((completer for _ in range(length - len(data)))) 227 | 228 | # Disasembling the pgn 229 | data_page = (pgn >> 16) & 0x01 230 | pdu_format = (pgn >> 8) & 0xFF 231 | pdu_specific = pgn & 0xFF 232 | 233 | # Send the actual pgn, if length is 1786 we use ETP otherwise we use j1939 python module to send it 234 | if len(data) >= 1786: 235 | etp = ExtendedTP(self.ca, priority) 236 | etp.send(data_page, pdu_format, pdu_specific, data) 237 | return etp 238 | else: 239 | success = self.ca.send_pgn(data_page, pdu_format, pdu_specific, priority, bytearray(data)) 240 | if not success: 241 | raise RuntimeError("Sending pgn failed!") 242 | --------------------------------------------------------------------------------