diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3835eac --- /dev/null +++ b/.gitattributes @@ -0,0 +1,27 @@ +# Default behaviour +* text=crlf + +# All text files are CRLF +*.txt text eol=crlf +*.h text eol=crlf +*.c text eol=crlf +*.cpp text eol=crlf +*.hpp text eol=crlf +*.py text eol=crlf +*.json text eol=crlf +*.md text eol=crlf +*.puml text eol=crlf +*.plantuml text eol=crlf +*.ini text eol=crlf +*.bat text eol=crlf +*.yml text eol=crlf +*.toml text eol=crlf +*.cfg text eol=crlf + +# Doxygen files are generated by a tool as LF +doc/doxygen/* text eol=lf + +# Images should be treated as binary +*.png binary +*.jpg binary +*.jepg binary \ No newline at end of file diff --git a/README.md b/README.md index 2555b97..6cbb7e4 100644 --- a/README.md +++ b/README.md @@ -1,258 +1,271 @@ -# Serial Multiplexer Protocol (SerialMuxProt) - -Communication Protocol based on Streams. Uses Multiplexing to differentiate data channels. -It is originally being developed for the communication between the [RadonUlzer](https://github.com/BlueAndi/RadonUlzer) and the [DroidControlShip](https://github.com/BlueAndi/DroidControlShip) projects. - -## Table of Contents - -- [Installation](#installation) -- [Network Architecture](#network-architecture) -- [Frame](#frame) -- [Control Channel](#control-channel-channel-0) - - [SYNC](#sync) - - [SYNC_RSP](#sync) - - [SCRB](#scrb) - - [SCRB_RSP](#scrb) -- [Internal Architecture](#internal-architecture) -- [SerialMuxChannels](#serialmuxchannels) - ---- - -## Installation - -- Using PlatformIO CLI: - -```bash -pio pkg install --library "gabryelreyes/SerialMuxProt@^2.0.0" -``` - -- Adding library to `lib_deps` manually: - -```ini -lib_deps = - gabryelreyes/SerialMuxProt@^2.0.0 -``` - -## Network Architecture - -- Server-Client Architecture -- One-to-one. One Server to one client. - ---- - -## Frame - -The Protocol sends and received Frames of the following form: - -```cpp -/** Data container of the Frame Fields */ -typedef union _Frame -{ - /** Frame Fields */ - struct _Fields - { - /** Header */ - union _Header - { - /** Header Fields Struct */ - struct _HeaderFields - { - /** Channel ID */ - uint8_t m_channel; - - /** Channel DLC */ - uint8_t m_dlc; - - /** Frame Checksum */ - uint8_t m_checksum; - - } __attribute__((packed)) headerFields; /**< Header Fields */ - - /** Raw Header Data*/ - uint8_t rawHeader[HEADER_LEN]; - - } __attribute__((packed)) header; /**< Header */ - - /** Payload */ - struct _Payload - { - /** Data of the Frame */ - uint8_t m_data[MAX_DATA_LEN]; - - } __attribute__((packed)) payload; /**< Payload */ - - } __attribute__((packed)) fields; /**< Frame Fields */ - - /** Raw Frame Data */ - uint8_t raw[MAX_FRAME_LEN] = {0U}; - -} __attribute__((packed)) Frame; /**< Frame */ -``` - -### Header - -#### Channel Field - -- Length: 1 Byte. -- Channel on which the data is being sent. -- [Channel 0](#control-channel-channel-0) is reserved for the server. -- Channels 1 to 255 are "Data Channels". -- The Application can publish or subscribe to any of these channels using the channel's name. -- Client suscribes to a channel using [Channel 0](#control-channel-channel-0). - -#### Data Length Code (DLC) Field - -- Contains the size of the payload contained by the frame. - -#### Checksum Field - -- checksum = sum(Channel + DLC + Data Bytes) % UINT8_MAX - -### Payload Field - -- Data Length: Set by the DLC Field. -- Contains Application Data Bytes. - ---- - -## Control Channel (Channel 0) - -- Control Channel: Exchange of Commands. Can not be used to send data. -- Channel 0 has no **external** Callback. The state of the server can be polled by the application through getter functions ( or similar depending on the implementation). -- D0 (Data Byte 0) is used as a Command Byte. Defines the command that is being sent. -- Even-number *Commands* in D0 are used as Commands, while uneven-number *Commands* are used as the response to the immediately previous (n-1) command. - -### SYNC - -- D0 = 0x00 -- Server sends SYNC Command with current timestamp. -- Client responds with [SYNC_RSP](#sync). -- Server can calculate Round-Trip-Time. -- SYNC Package must be sent periodically depending on current [State](#state-machine). The period is also used as a timeout for the previous SYNC. -- Used as a "Heartbeat" or "keep-alive" by the client. - -### SYNC_RSP - -- D0 = 0x01 -- Client Response to [SYNC](#sync). -- Data Payload is the same timestamp as in SYNC Command. - -### SCRB - -- D0 = 0x02 -- Client sends the name of the channel it wants to suscribe to. -- Server responds with the number and the name of the requested channel, if it is found and valid. If the channel is not found, the response has channel number = 0. - -### SCRB_RSP - -- D0 = 0x03 -- Server Response to [SCRB](#scrb). -- Channel Number on Data Byte 1 (D1). -- Channel Name on the following bytes - ---- - -## Internal Architecture - -### Data - -- Information is sent directly from application to the Serial Driver. No queueing or buffering. -- The Protocol can send a maximum of 255 Bytes. - -### Channels - -```cpp -/** - * Channel Definition. - */ -struct Channel -{ - char m_name[CHANNEL_NAME_MAX_LEN]; /**< Name of the channel. */ - uint8_t m_dlc; /**< Payload length of channel */ - ChannelCallback m_callback; /**< Callback to provide received data to the application. */ - - /** - * Channel Constructor. - */ - Channel() : m_name{0U}, m_dlc(0U), m_callback(nullptr) - { - } -}; -``` - -- Channel has 3 members: Name, DLC, and callback function. - -### Channel Creation and Subscription - -![CreateSubscribeSequence](http://www.plantuml.com/plantuml/proxy?cache=no&src=https://raw.githubusercontent.com/gabryelreyes/SerialMuxProt/main/doc/SubscribeSequence.puml) - -#### Channel Creation - -- Application initializes a channel with a name and a DLC, protocol looks for a free channel number and returns its channel number to the application. -- If no channel is free, it returns 0 as it is an invalid Data Channel. - -#### Channel Subscription - -- Application can subscribe to a remote data channel by its name and a callback to the function that must be called when data is received in said channel. -- Function has no return value, as the response from the server is asynchron. - -### Callback - -```cpp -/** - * Channel Notification Prototype Callback. - * Provides the received data in the respective channel to the application. - * - * @param[in] payload Received data. - * @param[in] payloadSize Size of the received data. - * @param[in] userData User data provided by the application. - */ -typedef void (*ChannelCallback)(const uint8_t* payload, const uint8_t payloadSize, void* userData); -``` - -- Callback passes only a pointer to the received Buffer. Data must be copied by application. -- Memory is freed by the protocol after the callback is done. -- DLC is passed as payloadSize to the application. -- The `userData` pointer specified in the constructor is passed to the application. - -### State Machine - -#### Out-of-Sync - -- Client is disconnected/does not respond to SYNC Command. -- No external data is sent in this state. -- SYNC Period set to 1 second. - -#### Synced - -- Client is connected and responds to SYNC Commands. -- SYNC Period set to 5 seconds. - -### Event Callbacks - -It is possible to register `EventCallback` callbacks for the Synced and DeSynced events. These will be called once an event is triggered to notify the user. - -```cpp -/** - * Event Notification Prototype Callback. - * Provides a notification to the application on the event it is registered to. - * - * @param[in] userData User data provided by the application. - */ -typedef void (*EventCallback)(void* userData); - -/* Called on Sync. */ -bool registerOnSyncedCallback(EventCallback callback); - -/* Called on DeSync. */ -bool registerOnDeSyncedCallback(EventCallback callback); -``` - ---- - -## SerialMuxChannels - -The `SerialMuxChannels.h` file should be used to define the structures and channel information to be shared between two instances of the SerialMuxServer. -This file defines the Channel Names, DLCs, and the data structures of the payloads. -It is important to note that the structs must include the `packed` attribute in order to ensure the access to the data correctly. -A sample file can be found in [here](examples/SerialMuxChannels.h). +# Serial Multiplexer Protocol (SerialMuxProt) + +Communication Protocol based on Streams. Uses Multiplexing to differentiate data channels. +It is originally being developed for the communication between the [RadonUlzer](https://github.com/BlueAndi/RadonUlzer) and the [DroidControlShip](https://github.com/BlueAndi/DroidControlShip) projects. + +## Table of Contents + +- [Installation](#installation) +- [Network Architecture](#network-architecture) +- [Frame](#frame) +- [Control Channel](#control-channel-channel-0) + - [SYNC](#sync) + - [SYNC_RSP](#sync) + - [SCRB](#scrb) + - [SCRB_RSP](#scrb) +- [Internal Architecture](#internal-architecture) +- [SerialMuxChannels](#serialmuxchannels) + +--- + +## Installation + +- Using PlatformIO CLI: + +```bash +pio pkg install --library "gabryelreyes/SerialMuxProt@^2.0.0" +``` + +- Adding library to `lib_deps` manually: + +```ini +lib_deps = + gabryelreyes/SerialMuxProt@^2.0.0 +``` + +### Python Installation + +- Navigate to the root of the Python library: + +```bash +cd python/SerialMuxProt +``` +- Install the package using `pip`: + +```bash +pip install . +``` + +## Network Architecture + +- Server-Client Architecture +- One-to-one. One Server to one client. + +--- + +## Frame + +The Protocol sends and received Frames of the following form: + +```cpp +/** Data container of the Frame Fields */ +typedef union _Frame +{ + /** Frame Fields */ + struct _Fields + { + /** Header */ + union _Header + { + /** Header Fields Struct */ + struct _HeaderFields + { + /** Channel ID */ + uint8_t m_channel; + + /** Channel DLC */ + uint8_t m_dlc; + + /** Frame Checksum */ + uint8_t m_checksum; + + } __attribute__((packed)) headerFields; /**< Header Fields */ + + /** Raw Header Data*/ + uint8_t rawHeader[HEADER_LEN]; + + } __attribute__((packed)) header; /**< Header */ + + /** Payload */ + struct _Payload + { + /** Data of the Frame */ + uint8_t m_data[MAX_DATA_LEN]; + + } __attribute__((packed)) payload; /**< Payload */ + + } __attribute__((packed)) fields; /**< Frame Fields */ + + /** Raw Frame Data */ + uint8_t raw[MAX_FRAME_LEN] = {0U}; + +} __attribute__((packed)) Frame; /**< Frame */ +``` + +### Header + +#### Channel Field + +- Length: 1 Byte. +- Channel on which the data is being sent. +- [Channel 0](#control-channel-channel-0) is reserved for the server. +- Channels 1 to 255 are "Data Channels". +- The Application can publish or subscribe to any of these channels using the channel's name. +- Client suscribes to a channel using [Channel 0](#control-channel-channel-0). + +#### Data Length Code (DLC) Field + +- Contains the size of the payload contained by the frame. + +#### Checksum Field + +- checksum = sum(Channel + DLC + Data Bytes) % UINT8_MAX + +### Payload Field + +- Data Length: Set by the DLC Field. +- Contains Application Data Bytes. + +--- + +## Control Channel (Channel 0) + +- Control Channel: Exchange of Commands. Can not be used to send data. +- Channel 0 has no **external** Callback. The state of the server can be polled by the application through getter functions ( or similar depending on the implementation). +- D0 (Data Byte 0) is used as a Command Byte. Defines the command that is being sent. +- Even-number *Commands* in D0 are used as Commands, while uneven-number *Commands* are used as the response to the immediately previous (n-1) command. + +### SYNC + +- D0 = 0x00 +- Server sends SYNC Command with current timestamp. +- Client responds with [SYNC_RSP](#sync). +- Server can calculate Round-Trip-Time. +- SYNC Package must be sent periodically depending on current [State](#state-machine). The period is also used as a timeout for the previous SYNC. +- Used as a "Heartbeat" or "keep-alive" by the client. + +### SYNC_RSP + +- D0 = 0x01 +- Client Response to [SYNC](#sync). +- Data Payload is the same timestamp as in SYNC Command. + +### SCRB + +- D0 = 0x02 +- Client sends the name of the channel it wants to suscribe to. +- Server responds with the number and the name of the requested channel, if it is found and valid. If the channel is not found, the response has channel number = 0. + +### SCRB_RSP + +- D0 = 0x03 +- Server Response to [SCRB](#scrb). +- Channel Number on Data Byte 1 (D1). +- Channel Name on the following bytes + +--- + +## Internal Architecture + +### Data + +- Information is sent directly from application to the Serial Driver. No queueing or buffering. +- The Protocol can send a maximum of 255 Bytes. + +### Channels + +```cpp +/** + * Channel Definition. + */ +struct Channel +{ + char m_name[CHANNEL_NAME_MAX_LEN]; /**< Name of the channel. */ + uint8_t m_dlc; /**< Payload length of channel */ + ChannelCallback m_callback; /**< Callback to provide received data to the application. */ + + /** + * Channel Constructor. + */ + Channel() : m_name{0U}, m_dlc(0U), m_callback(nullptr) + { + } +}; +``` + +- Channel has 3 members: Name, DLC, and callback function. + +### Channel Creation and Subscription + +![CreateSubscribeSequence](http://www.plantuml.com/plantuml/proxy?cache=no&src=https://raw.githubusercontent.com/gabryelreyes/SerialMuxProt/main/doc/SubscribeSequence.puml) + +#### Channel Creation + +- Application initializes a channel with a name and a DLC, protocol looks for a free channel number and returns its channel number to the application. +- If no channel is free, it returns 0 as it is an invalid Data Channel. + +#### Channel Subscription + +- Application can subscribe to a remote data channel by its name and a callback to the function that must be called when data is received in said channel. +- Function has no return value, as the response from the server is asynchron. + +### Callback + +```cpp +/** + * Channel Notification Prototype Callback. + * Provides the received data in the respective channel to the application. + * + * @param[in] payload Received data. + * @param[in] payloadSize Size of the received data. + * @param[in] userData User data provided by the application. + */ +typedef void (*ChannelCallback)(const uint8_t* payload, const uint8_t payloadSize, void* userData); +``` + +- Callback passes only a pointer to the received Buffer. Data must be copied by application. +- Memory is freed by the protocol after the callback is done. +- DLC is passed as payloadSize to the application. +- The `userData` pointer specified in the constructor is passed to the application. + +### State Machine + +#### Out-of-Sync + +- Client is disconnected/does not respond to SYNC Command. +- No external data is sent in this state. +- SYNC Period set to 1 second. + +#### Synced + +- Client is connected and responds to SYNC Commands. +- SYNC Period set to 5 seconds. + +### Event Callbacks + +It is possible to register `EventCallback` callbacks for the Synced and DeSynced events. These will be called once an event is triggered to notify the user. + +```cpp +/** + * Event Notification Prototype Callback. + * Provides a notification to the application on the event it is registered to. + * + * @param[in] userData User data provided by the application. + */ +typedef void (*EventCallback)(void* userData); + +/* Called on Sync. */ +bool registerOnSyncedCallback(EventCallback callback); + +/* Called on DeSync. */ +bool registerOnDeSyncedCallback(EventCallback callback); +``` + +--- + +## SerialMuxChannels + +The `SerialMuxChannels.h` file should be used to define the structures and channel information to be shared between two instances of the SerialMuxServer. +This file defines the Channel Names, DLCs, and the data structures of the payloads. +It is important to note that the structs must include the `packed` attribute in order to ensure the access to the data correctly. +A sample file can be found in [here](examples/SerialMuxChannels.h). diff --git a/python/.gitignore b/python/.gitignore deleted file mode 100644 index c9f18c9..0000000 --- a/python/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -__pycache__ -venv \ No newline at end of file diff --git a/python/SerialMuxProt/.gitignore b/python/SerialMuxProt/.gitignore new file mode 100644 index 0000000..698e72b --- /dev/null +++ b/python/SerialMuxProt/.gitignore @@ -0,0 +1,170 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# Exported files +*export*.json + +# Certificates +*.pem +*.crt + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Development Scripts +dev/ diff --git a/python/SerialMuxProt/LICENSE b/python/SerialMuxProt/LICENSE new file mode 100644 index 0000000..ef3b2ec --- /dev/null +++ b/python/SerialMuxProt/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 - 2024 Gabryel Reyes + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/python/SerialMuxProt/README.md b/python/SerialMuxProt/README.md new file mode 100644 index 0000000..a4e3751 --- /dev/null +++ b/python/SerialMuxProt/README.md @@ -0,0 +1 @@ +# SerialMuxProt Python diff --git a/python/SerialMuxProt/doc/README.md b/python/SerialMuxProt/doc/README.md new file mode 100644 index 0000000..7d8b3af --- /dev/null +++ b/python/SerialMuxProt/doc/README.md @@ -0,0 +1 @@ +# Documentation \ No newline at end of file diff --git a/python/SerialMuxProt/examples/README.md b/python/SerialMuxProt/examples/README.md new file mode 100644 index 0000000..1b976a9 --- /dev/null +++ b/python/SerialMuxProt/examples/README.md @@ -0,0 +1 @@ +# Examples \ No newline at end of file diff --git a/python/__main__.py b/python/SerialMuxProt/examples/serial/__main__.py similarity index 94% rename from python/__main__.py rename to python/SerialMuxProt/examples/serial/__main__.py index 8003bde..a5629b7 100644 --- a/python/__main__.py +++ b/python/SerialMuxProt/examples/serial/__main__.py @@ -1,97 +1,97 @@ -""" Main programm entry point""" - -# MIT License -# -# Copyright (c) 2023 - 2024 Gabryel Reyes -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. -# - - -################################################################################ -# Imports -################################################################################ - -import sys -import time -from serial_client import SerialClient -from SerialMuxProt import SerialMuxProt - -################################################################################ -# Variables -################################################################################ - -g_socket = SerialClient("COM3", 115200) -smp_server = SerialMuxProt(10, g_socket) -START_TIME = round(time.time()*1000) - -################################################################################ -# Classes -################################################################################ - -################################################################################ -# Functions -################################################################################ - - -def get_milliseconds() -> int: - """ Get current system milliseconds """ - return round(time.time()*1000) - - -def millis() -> int: - """ Get current program milliseconds """ - current_time = get_milliseconds() - return current_time - START_TIME - - -def callback_timestamp(payload: bytearray) -> None: - """ Callback of TIMESTAMP Channel """ - - print(payload.hex()) - - -def main(): - """The program entry point function.adadadadada - Returns: - int: System exit status - """ - print("Starting System.") - last_time = 0 - - try: - g_socket.connect_to_server() - except Exception as err: # pylint: disable=broad-exception-caught - print(err) - return - - smp_server.subscribe_to_channel("LED", callback_timestamp) - - while True: - if (millis() - last_time) >= 5: - last_time = millis() - smp_server.process(millis()) - -################################################################################ -# Main -################################################################################ - - -if __name__ == "__main__": - sys.exit(main()) +""" Main programm entry point""" + +# MIT License +# +# Copyright (c) 2023 - 2024 Gabryel Reyes +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# + + +################################################################################ +# Imports +################################################################################ + +import sys +import time +from serial_client import SerialClient +from SerialMuxProt import Server + +################################################################################ +# Variables +################################################################################ + +g_socket = SerialClient("COM3", 115200) +smp_server = Server(10, g_socket) +START_TIME = round(time.time()*1000) + +################################################################################ +# Classes +################################################################################ + +################################################################################ +# Functions +################################################################################ + + +def get_milliseconds() -> int: + """ Get current system milliseconds """ + return round(time.time()*1000) + + +def millis() -> int: + """ Get current program milliseconds """ + current_time = get_milliseconds() + return current_time - START_TIME + + +def callback_timestamp(payload: bytearray) -> None: + """ Callback of TIMESTAMP Channel """ + + print(payload.hex()) + + +def main(): + """The program entry point function.adadadadada + Returns: + int: System exit status + """ + print("Starting System.") + last_time = 0 + + try: + g_socket.connect_to_server() + except Exception as err: # pylint: disable=broad-exception-caught + print(err) + return + + smp_server.subscribe_to_channel("LED", callback_timestamp) + + while True: + if (millis() - last_time) >= 5: + last_time = millis() + smp_server.process(millis()) + +################################################################################ +# Main +################################################################################ + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/python/requirements.txt b/python/SerialMuxProt/examples/serial/requirements.txt similarity index 100% rename from python/requirements.txt rename to python/SerialMuxProt/examples/serial/requirements.txt diff --git a/python/serial_client.py b/python/SerialMuxProt/examples/serial/serial_client.py similarity index 95% rename from python/serial_client.py rename to python/SerialMuxProt/examples/serial/serial_client.py index 0224716..49df4a6 100644 --- a/python/serial_client.py +++ b/python/SerialMuxProt/examples/serial/serial_client.py @@ -1,150 +1,151 @@ -""" Implementation of a Serial Client for Serial Communication """ - -# MIT License -# -# Copyright (c) 2023 - 2024 Gabryel Reyes -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. -# - - -################################################################################ -# Imports -################################################################################ - -import serial - -################################################################################ -# Variables -################################################################################ - -################################################################################ -# Classes -################################################################################ - - -class SerialClient: - """ - Class for Serial Communication - """ - - def __init__(self, port_name: str, port_speed: int) -> None: - """ SerialClient Constructor. - - Parameters - ---------- - port_name : str - Name of Serial Port. - port_speed : int - Speed of Serial Port. - """ - - self.__port_name = port_name - self.__port_speed = port_speed - self.__serial = serial.Serial(port_name, port_speed, timeout=1) - - def connect_to_server(self) -> None: - """ Connect to the specified Host and Port. """ - - try: - # Clean Serial before starting - available_bytes = self.available() - if available_bytes > 0: - self.read_bytes(available_bytes) - except: - print( - f"Failed to connect to \"{self.__port_name}\" on port {self.__port_speed}") - raise - - def disconnect_from_server(self) -> None: - """ Close the connection to Server. """ - - self.__serial.close() - - def write(self, payload : bytearray) -> int: - """ Sends Data to the Server. - - Parameters - ---------- - payload : bytearray - Payload to send. - - Returns - ---------- - Number of bytes sent - """ - - bytes_sent = 0 - - try: - bytes_sent = self.__serial.write(payload) - except BlockingIOError as err: - raise err - - return bytes_sent - - def available(self) -> int: - """ Check if there is anything available for reading - - Returns - ---------- - Number of bytes available for reading. - """ - - rcvd_data = b'' - try: - rcvd_data = self.__serial.in_waiting - except BlockingIOError: - # Exception thrown on non-blocking Serial when there is nothing to read. - pass - - return rcvd_data - - def read_bytes(self, length: int) -> tuple[int, bytearray]: - """ Read a given number of Bytes from Serial. - - Parameters - ---------- - lenght : int - Number of bytes to read. - - Returns - ---------- - Tuple: - - int: Number of bytes received. - - bytearray: Received data. - """ - - rcvd_data = b'' - try: - rcvd_data = self.__serial.read(length) - except BlockingIOError: - # Exception thrown on non-blocking Serial when there is nothing to read. - pass - - return len(rcvd_data), rcvd_data - - -################################################################################ -# Functions -################################################################################ - -################################################################################ -# Main -################################################################################ +""" Implementation of a Serial Client for Serial Communication """ + +# MIT License +# +# Copyright (c) 2023 - 2024 Gabryel Reyes +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# + + +################################################################################ +# Imports +################################################################################ + +import serial +from SerialMuxProt import Stream + +################################################################################ +# Variables +################################################################################ + +################################################################################ +# Classes +################################################################################ + + +class SerialClient(Stream): + """ + Class for Serial Communication + """ + + def __init__(self, port_name: str, port_speed: int) -> None: + """ SerialClient Constructor. + + Parameters + ---------- + port_name : str + Name of Serial Port. + port_speed : int + Speed of Serial Port. + """ + + self.__port_name = port_name + self.__port_speed = port_speed + self.__serial = serial.Serial(port_name, port_speed, timeout=1) + + def connect_to_server(self) -> None: + """ Connect to the specified Host and Port. """ + + try: + # Clean Serial before starting + available_bytes = self.available() + if available_bytes > 0: + self.read_bytes(available_bytes) + except: + print( + f"Failed to connect to \"{self.__port_name}\" on port {self.__port_speed}") + raise + + def disconnect_from_server(self) -> None: + """ Close the connection to Server. """ + + self.__serial.close() + + def write(self, payload: bytearray) -> int: + """ Sends Data to the Server. + + Parameters + ---------- + payload : bytearray + Payload to send. + + Returns + ---------- + Number of bytes sent + """ + + bytes_sent = 0 + + try: + bytes_sent = self.__serial.write(payload) + except BlockingIOError as err: + raise err + + return bytes_sent + + def available(self) -> int: + """ Check if there is anything available for reading + + Returns + ---------- + Number of bytes available for reading. + """ + + rcvd_data = b'' + try: + rcvd_data = self.__serial.in_waiting + except BlockingIOError: + # Exception thrown on non-blocking Serial when there is nothing to read. + pass + + return rcvd_data + + def read_bytes(self, length: int) -> tuple[int, bytearray]: + """ Read a given number of Bytes from Serial. + + Parameters + ---------- + lenght : int + Number of bytes to read. + + Returns + ---------- + Tuple: + - int: Number of bytes received. + - bytearray: Received data. + """ + + rcvd_data = b'' + try: + rcvd_data = self.__serial.read(length) + except BlockingIOError: + # Exception thrown on non-blocking Serial when there is nothing to read. + pass + + return len(rcvd_data), rcvd_data + + +################################################################################ +# Functions +################################################################################ + +################################################################################ +# Main +################################################################################ diff --git a/python/socket_client.py b/python/SerialMuxProt/examples/serial/socket_client.py similarity index 96% rename from python/socket_client.py rename to python/SerialMuxProt/examples/serial/socket_client.py index a5f1e90..6eca7e7 100644 --- a/python/socket_client.py +++ b/python/SerialMuxProt/examples/serial/socket_client.py @@ -1,153 +1,153 @@ -""" Implementation of a Socket Client for Inter-Process Communication """ - -# MIT License -# -# Copyright (c) 2023 - 2024 Gabryel Reyes -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. -# - - -################################################################################ -# Imports -################################################################################ - -import socket - -################################################################################ -# Variables -################################################################################ - -################################################################################ -# Classes -################################################################################ - - -class SocketClient: - """ - Class for Socket Communication - """ - - def __init__(self, server_address: str, port_number: int) -> None: - """ SocketClient Constructor. - - Parameters - ---------- - server_address : str - Address of the Server to connect to. - port_number : int - Port of of Server. - """ - - self.__server_address = server_address - self.__port_number = port_number - self.__socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - - def connect_to_server(self) -> None: - """ Connect to the specified Host and Port. """ - - try: - self.__socket.connect((self.__server_address, self.__port_number)) - self.__socket.setblocking(False) - - # Clean socket before starting - available_bytes = self.available() - if available_bytes > 0: - self.read_bytes(available_bytes) - except: - print( - f"Failed to connect to \"{self.__server_address}\" on port {self.__port_number}") - raise - - def disconnect_from_server(self) -> None: - """ Close the connection to Server. """ - - self.__socket.close() - - def write(self, payload : bytearray) -> int: - """ Sends Data to the Server. - - Parameters - ---------- - payload : bytearray - Payload to send. - - Returns - ---------- - Number of bytes sent - """ - - bytes_sent = 0 - - try: - bytes_sent = self.__socket.send(payload) - except BlockingIOError as err: - raise err - - return bytes_sent - - def available(self) -> int: - """ Check if there is anything available for reading - - Returns - ---------- - Number of bytes available for reading. - """ - - rcvd_data = b'' - try: - rcvd_data = self.__socket.recv(2048, socket.MSG_PEEK) - except BlockingIOError: - # Exception thrown on non-blocking socket when there is nothing to read. - pass - - return len(rcvd_data) - - def read_bytes(self, length: int) -> tuple[int, bytearray]: - """ Read a given number of Bytes from Socket. - - Parameters - ---------- - lenght : int - Number of bytes to read. - - Returns - ---------- - Tuple: - - int: Number of bytes received. - - bytearray: Received data. - """ - - rcvd_data = b'' - try: - rcvd_data = self.__socket.recv(length) - except BlockingIOError: - # Exception thrown on non-blocking socket when there is nothing to read. - pass - - return len(rcvd_data), rcvd_data - - -################################################################################ -# Functions -################################################################################ - -################################################################################ -# Main -################################################################################ +""" Implementation of a Socket Client for Inter-Process Communication """ + +# MIT License +# +# Copyright (c) 2023 - 2024 Gabryel Reyes +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# + + +################################################################################ +# Imports +################################################################################ + +import socket + +################################################################################ +# Variables +################################################################################ + +################################################################################ +# Classes +################################################################################ + + +class SocketClient: + """ + Class for Socket Communication + """ + + def __init__(self, server_address: str, port_number: int) -> None: + """ SocketClient Constructor. + + Parameters + ---------- + server_address : str + Address of the Server to connect to. + port_number : int + Port of of Server. + """ + + self.__server_address = server_address + self.__port_number = port_number + self.__socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + + def connect_to_server(self) -> None: + """ Connect to the specified Host and Port. """ + + try: + self.__socket.connect((self.__server_address, self.__port_number)) + self.__socket.setblocking(False) + + # Clean socket before starting + available_bytes = self.available() + if available_bytes > 0: + self.read_bytes(available_bytes) + except: + print( + f"Failed to connect to \"{self.__server_address}\" on port {self.__port_number}") + raise + + def disconnect_from_server(self) -> None: + """ Close the connection to Server. """ + + self.__socket.close() + + def write(self, payload : bytearray) -> int: + """ Sends Data to the Server. + + Parameters + ---------- + payload : bytearray + Payload to send. + + Returns + ---------- + Number of bytes sent + """ + + bytes_sent = 0 + + try: + bytes_sent = self.__socket.send(payload) + except BlockingIOError as err: + raise err + + return bytes_sent + + def available(self) -> int: + """ Check if there is anything available for reading + + Returns + ---------- + Number of bytes available for reading. + """ + + rcvd_data = b'' + try: + rcvd_data = self.__socket.recv(2048, socket.MSG_PEEK) + except BlockingIOError: + # Exception thrown on non-blocking socket when there is nothing to read. + pass + + return len(rcvd_data) + + def read_bytes(self, length: int) -> tuple[int, bytearray]: + """ Read a given number of Bytes from Socket. + + Parameters + ---------- + lenght : int + Number of bytes to read. + + Returns + ---------- + Tuple: + - int: Number of bytes received. + - bytearray: Received data. + """ + + rcvd_data = b'' + try: + rcvd_data = self.__socket.recv(length) + except BlockingIOError: + # Exception thrown on non-blocking socket when there is nothing to read. + pass + + return len(rcvd_data), rcvd_data + + +################################################################################ +# Functions +################################################################################ + +################################################################################ +# Main +################################################################################ diff --git a/python/SerialMuxProt/pyproject.toml b/python/SerialMuxProt/pyproject.toml new file mode 100644 index 0000000..d58aea2 --- /dev/null +++ b/python/SerialMuxProt/pyproject.toml @@ -0,0 +1,41 @@ +[build-system] +requires = ["setuptools", "setuptools-scm", "wheel", "toml"] +build-backend = "setuptools.build_meta" + +[project] +name = "SerialMuxProt" +version = "2.2.1" +description = "Communication Protocol based on Streams. Uses Multiplexing to differentiate data channels." +readme = "README.md" +requires-python = ">=3.9" +authors = [ + { name = "gabryelreyes", email = "gabryelrdiaz@gmail.com" } +] +license = {text = "BSD 3-Clause"} +classifiers = [ + "License :: OSI Approved :: BSD 3-Clause", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11" +] + +dependencies = [ + "toml>=0.10.2" +] + +[project.optional-dependencies] +test = [ + "pytest > 5.0.0", + "pytest-cov[all]" +] + +[project.urls] +documentation = "https://github.com/gabryelreyes/SerialMuxProt" +repository = "https://github.com/gabryelreyes/SerialMuxProt" +tracker = "https://github.com/gabryelreyes/SerialMuxProt" + +[tool.pytest.ini_options] +pythonpath = [ + "src" +] diff --git a/python/SerialMuxProt/setup.py b/python/SerialMuxProt/setup.py new file mode 100644 index 0000000..3ac6637 --- /dev/null +++ b/python/SerialMuxProt/setup.py @@ -0,0 +1,46 @@ +""" Tool setup """ +# MIT License +# +# Copyright (c) 2023 - 2024 Gabryel Reyes +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +################################################################################ +# Imports +################################################################################ +import setuptools + +################################################################################ +# Variables +################################################################################ + +################################################################################ +# Classes +################################################################################ + +################################################################################ +# Functions +################################################################################ + +################################################################################ +# Main +################################################################################ + +if __name__ == "__main__": + setuptools.setup() diff --git a/python/SerialMuxProt/src/SerialMuxProt/__init__.py b/python/SerialMuxProt/src/SerialMuxProt/__init__.py new file mode 100644 index 0000000..933cbcf --- /dev/null +++ b/python/SerialMuxProt/src/SerialMuxProt/__init__.py @@ -0,0 +1,32 @@ +"""__init__""" # pylint: disable=invalid-name + +# MIT License +# +# Copyright (c) 2023 - 2024 Gabryel Reyes +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# + + +################################################################################ +# Imports +################################################################################ + +from .server import Server +from .stream import Stream diff --git a/python/SerialMuxProt.py b/python/SerialMuxProt/src/SerialMuxProt/server.py similarity index 93% rename from python/SerialMuxProt.py rename to python/SerialMuxProt/src/SerialMuxProt/server.py index 212c46c..3b8d48c 100644 --- a/python/SerialMuxProt.py +++ b/python/SerialMuxProt/src/SerialMuxProt/server.py @@ -1,659 +1,664 @@ -""" Serial Multiplexer Protocol (SerialMuxProt) for lightweight communication. """ - -# MIT License -# -# Copyright (c) 2023 - 2024 Gabryel Reyes -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. -# - - -################################################################################ -# Imports -################################################################################ - -from dataclasses import dataclass -from struct import Struct -from socket_client import SocketClient - -################################################################################ -# Variables -################################################################################ - -################################################################################ -# Classes -################################################################################ - - -@dataclass(frozen=True) -class SerialMuxProtConstants: - """ Container Data class for SerialMuxProt Constants """ - - CHANNEL_LEN = 1 # Channel Field Length in Bytes - DLC_LEN = 1 # DLC Field Length in Bytes - CHECKSUM_LEN = 1 # Checksum Field Length in Bytes - HEADER_LEN = CHANNEL_LEN + DLC_LEN + \ - CHECKSUM_LEN # Length of Complete Header Field - MAX_DATA_LEN = 32 # Data Field Length in Bytes - MAX_FRAME_LEN = HEADER_LEN + MAX_DATA_LEN # Total Frame Length in Bytes - CHANNEL_NAME_MAX_LEN = 10 # Max length of channel name - # Available Bytes in Control Channel Payload for data. - CONTROL_CHANNEL_PAYLOAD_DATA_LENGTH = 4 - CONTROL_CHANNEL_CMD_BYTE_LENGTH = 1 # Lenght of Command in Bytes - CONTROL_CHANNEL_NUMBER = 0 # Number of Control Channel. - CONTROL_CHANNEL_PAYLOAD_LENGTH = CHANNEL_NAME_MAX_LEN + \ - CONTROL_CHANNEL_PAYLOAD_DATA_LENGTH + \ - CONTROL_CHANNEL_CMD_BYTE_LENGTH + \ - CHANNEL_LEN # DLC of Heartbeat Command. - # Index of the Command Byte of the Control Channel - CONTROL_CHANNEL_COMMAND_INDEX = 0 - # Index of the start of the payload of the Control Channel - CONTROL_CHANNEL_PAYLOAD_INDEX = 1 - HEATBEAT_PERIOD_SYNCED = 5000 # Period of Heartbeat when Synced. - HEATBEAT_PERIOD_UNSYNCED = 1000 # Period of Heartbeat when Unsynced - # Max number of attempts at receiving a Frame before resetting RX Buffer - MAX_RX_ATTEMPTS = MAX_FRAME_LEN - - @dataclass - class Commands(): - """ Enumeration of Commands of Control Channel. """ - SYNC = 0 - SYNC_RSP = 1 - SCRB = 2 - SCRB_RSP = 3 - - -@dataclass -class Channel(): - """ Channel Definition """ - - def __init__(self, channel_name: str = "", channel_dlc: int = 0, channel_callback=None) -> None: - self.name = channel_name - self.dlc = channel_dlc - self.callback = channel_callback - - -class Frame(): - """ Frame Defintion """ - - def __init__(self) -> None: - self.raw = bytearray(SerialMuxProtConstants.MAX_FRAME_LEN) - self.channel = 0 - self.dlc = 0 - self.checksum = 0 - self.payload = bytearray(SerialMuxProtConstants.MAX_DATA_LEN) - - def unpack_header(self): - """ Unpack/parse channel number and checksum from raw frame """ - header_packer = Struct(">3B") - self.channel, self.dlc, self.checksum = header_packer.unpack_from( - self.raw) - - def unpack_payload(self): - """ Unpack/parse payload from raw frame """ - self.payload = self.raw[3:] - - def pack_frame(self): - """ Pack header and payload into raw array""" - self.raw[0] = self.channel - self.raw[1] = self.dlc - self.raw[2] = self.checksum - self.raw[3:] = self.payload - - -@dataclass -class ChannelArrays: - """ Container Class for Channel Arrays and their counters """ - - def __init__(self, max_configured_channels: int) -> None: - self.number_of_rx_channels = 0 - self.number_of_tx_channels = 0 - self.number_of_pending_channels = 0 - self.rx_channels = [Channel() for x in range(max_configured_channels)] - self.tx_channels = [Channel() for x in range(max_configured_channels)] - self.pending_channels = [Channel() - for x in range(max_configured_channels)] - - -@dataclass -class SyncData: - """ Container Dataclass for Synchronization Data. """ - - def __init__(self) -> None: - self.is_synced = False - self.last_sync_response = 0 - self.last_sync_command = 0 - - -@dataclass -class RxData: - """ Container Dataclass for Receive Data and counters. """ - - def __init__(self) -> None: - self.received_bytes = 0 - self.rx_attempts = 0 - self.receive_frame = Frame() - - -class SerialMuxProt: - """ SerialMuxProt Server """ - - def __init__(self, max_configured_channels: int, stream: SocketClient) -> None: - self.__max_configured_channels = max_configured_channels - self.__stream = stream - self.__rx_data = RxData() - self.__sync_data = SyncData() - self.__channels = ChannelArrays(max_configured_channels) - - def process(self, current_timestamp: int) -> None: - """Manage the Server functions. - Call this function cyclic. - - Parameters - ---------- - current_timestamp : int - Time in milliseconds. - - """ - - # Periodic Heartbeat. - self.__heartbeat(current_timestamp) - - # Process RX data. - self.__process_rx() - - def send_data(self, channel_name: str, payload: bytearray) -> bool: - """Send a frame with the selected bytes. - - Parameters: - ---------- - channel_name : str - Channel to send frame to. - payload : bytearray - Byte buffer to be sent. - - Returns: - -------- - If payload succesfully sent, returns true. Otherwise, false. - """ - is_sent = False - channel_number = self.get_tx_channel_number(channel_name) - - if (SerialMuxProtConstants.CONTROL_CHANNEL_NUMBER != channel_number) and\ - (self.__sync_data.is_synced is True): - - is_sent = self.__send(channel_number, payload) - - return is_sent - - def is_synced(self) -> bool: - """ Returns current Sync state of the SerialMuxProt Server. """ - return self.__sync_data.is_synced - - def get_number_rx_channels(self) -> int: - """Get the number of configured RX channels.""" - return self.__channels.number_of_rx_channels - - def get_number_tx_channels(self) -> int: - """Get the number of configured TX channels.""" - return self.__channels.number_of_tx_channels - - def create_channel(self, name: str, dlc: int) -> int: - """Creates a new TX Channel on the server. - - Parameters: - ---------- - name : str - Name of the channel. It will not be checked if the name already exists. - dlc : int - Length of the payload of this channel. - - Returns: - -------- - The channel number if succesfully created, or 0 if not able to create new channel. - """ - name_length = len(name) - idx = 0 - - if (0 != name_length) and\ - (SerialMuxProtConstants.CHANNEL_NAME_MAX_LEN >= name_length) and\ - (0 != dlc) and\ - (SerialMuxProtConstants.MAX_DATA_LEN >= dlc) and\ - (self.__max_configured_channels > self.__channels.number_of_tx_channels): - - # Create Channel - self.__channels.tx_channels[self.__channels.number_of_tx_channels] = Channel( - name, dlc) - - # Increase Channel Counter - self.__channels.number_of_tx_channels += 1 - - # Provide Channel Number. Could be summarized with operation above. - idx = self.__channels.number_of_tx_channels - - return idx - - def subscribe_to_channel(self, name: str, callback) -> None: - """ Suscribe to a Channel to receive the incoming data. - - Parameters: - ---------- - name : str - Name of the Channel to suscribe to. - callback : function - Callback to return the incoming data. - """ - - if (SerialMuxProtConstants.CHANNEL_NAME_MAX_LEN >= len(name)) and\ - (callback is not None) and\ - (self.__max_configured_channels > self.__channels.number_of_pending_channels): - - # Save Name and Callback for channel creation after response - self.__channels.pending_channels[self.__channels.number_of_pending_channels] = Channel( - channel_name=name, channel_dlc=0, channel_callback=callback) - - # Increase Channel Counter - self.__channels.number_of_pending_channels += 1 - - def get_tx_channel_number(self, channel_name: str) -> int: - """Get Number of a TX channel by its name. - - Parameters: - ----------- - channel_name : str - Name of channel - - Returns: - -------- - Number of the Channel, or 0 if not channel with the name is present. - """ - - channel_number = 0 - for idx in range(self.__max_configured_channels): - if self.__channels.tx_channels[idx].name == channel_name: - channel_number = idx + 1 - break - - return channel_number - - def __heartbeat(self, current_timestamp: int) -> None: - """ Periodic heartbeat. - Sends SYNC Command depending on the current Sync state. - - Parameters - ---------- - current_timestamp : int - Time in milliseconds. - - """ - - heartbeat_period = SerialMuxProtConstants.HEATBEAT_PERIOD_UNSYNCED - - if self.__sync_data.is_synced is True: - heartbeat_period = SerialMuxProtConstants.HEATBEAT_PERIOD_SYNCED - - if (current_timestamp - self.__sync_data.last_sync_command) >= heartbeat_period: - - # Timeout - if self.__sync_data.last_sync_command != self.__sync_data.last_sync_response: - self.__sync_data.is_synced = False - - # Pack big-endian uint32 - packer = Struct(">L") - timestamp_array = packer.pack(current_timestamp) - - # Send SYNC Command - command = bytearray() - command.append(SerialMuxProtConstants.Commands.SYNC) - command.extend(timestamp_array) - - # Pad array if necessary - command = command.ljust( - SerialMuxProtConstants.CONTROL_CHANNEL_PAYLOAD_LENGTH, b'\x00') - - if self.__send(SerialMuxProtConstants.CONTROL_CHANNEL_NUMBER, command) is True: - self.__sync_data.last_sync_command = current_timestamp - - def __process_rx(self) -> None: - """ Receive and process RX Data. """ - - expected_bytes = 0 - dlc = 0 - - # Determine how many bytes to read. - if SerialMuxProtConstants.HEADER_LEN > self.__rx_data.received_bytes: - # Header must be read. - expected_bytes = SerialMuxProtConstants.HEADER_LEN - self.__rx_data.received_bytes - else: - # Header has been read. Get DLC of Rx Channel using header. - self.__rx_data.receive_frame.unpack_header() - # dlc = self.__get_channel_dlc(self.__receive_frame.channel, False) - dlc = self.__rx_data.receive_frame.dlc - - # DLC = 0 means that the channel does not exist. - if (0 != dlc) and (SerialMuxProtConstants.MAX_RX_ATTEMPTS >= self.__rx_data.rx_attempts): - remaining_payload_bytes = self.__rx_data.received_bytes - \ - SerialMuxProtConstants.HEADER_LEN - expected_bytes = dlc - remaining_payload_bytes - self.__rx_data.rx_attempts += 1 - - # Are we expecting to read anything? - if 0 != expected_bytes: - - # Read the required amount of bytes, if available. - if self.__stream.available() >= expected_bytes: - rcvd, data = self.__stream.read_bytes(expected_bytes) - self.__rx_data.receive_frame.raw[self.__rx_data.received_bytes:] = data - self.__rx_data.received_bytes += rcvd - - # Frame has been received. - if (0 != dlc) and ((SerialMuxProtConstants.HEADER_LEN + dlc) == self.__rx_data.received_bytes): - - # Check validity - if self.__is_frame_valid(self.__rx_data.receive_frame) is True: - channel_array_index = self.__rx_data.receive_frame.channel - 1 - self.__rx_data.receive_frame.unpack_payload() - - # Differenciate between Control and Data Channels. - if SerialMuxProtConstants.CONTROL_CHANNEL_NUMBER == self.__rx_data.receive_frame.channel: - self.__callback_control_channel( - self.__rx_data.receive_frame.payload) - elif self.__channels.rx_channels[channel_array_index].callback is not None: - self.__channels.rx_channels[channel_array_index].callback( - self.__rx_data.receive_frame.payload) - - # Frame received. Cleaning! - self.__clear_local_buffers() - else: - # Invalid Header. Delete Frame! - self.__clear_local_buffers() - - def __process_subscriptions(self) -> None: - """ Subscribe to any pending Channels if synced to server. """ - - # If synced and a channel is pending - if (self.__sync_data.is_synced is True) and\ - (0 < self.__channels.number_of_pending_channels): - # Channel Iterator - for pending_channel in self.__channels.pending_channels: - - # Channel is pending - if pending_channel.callback is not None: - - # Subscribe to Channel - request = bytearray( - SerialMuxProtConstants.CONTROL_CHANNEL_PAYLOAD_LENGTH) - request[0] = SerialMuxProtConstants.Commands.SCRB - request[6:] = bytearray(pending_channel.name, 'ascii') - - # Pad array if necessary - request = request.ljust( - SerialMuxProtConstants.CONTROL_CHANNEL_PAYLOAD_LENGTH, b'\x00') - - if self.__send(SerialMuxProtConstants.CONTROL_CHANNEL_NUMBER, request) is False: - # Fall out of sync if failed to send. - self.__sync_data.is_synced = False - break - - def __clear_local_buffers(self) -> None: - """ Clear Local RX Buffers """ - self.__rx_data.receive_frame = Frame() - self.__rx_data.received_bytes = 0 - self.__rx_data.rx_attempts = 0 - - def __get_tx_channel_dlc(self, channel_number: int) -> int: - """ Get the Payload Length of a channel. - - Parameters - ---------- - channel_number : int - Channel number to check. - - is_tx_channel : bool - Is the Channel a TX Channel? If false, will return value for an RX Channel instead. - - Returns - ------- - DLC of the channel, or 0 if channel is not found. - """ - dlc = 0 - - if SerialMuxProtConstants.CONTROL_CHANNEL_NUMBER == channel_number: - dlc = SerialMuxProtConstants.CONTROL_CHANNEL_PAYLOAD_LENGTH - else: - if self.__max_configured_channels >= channel_number: - channel_idx = channel_number - 1 - dlc = self.__channels.tx_channels[channel_idx].dlc - - return dlc - - def __is_frame_valid(self, frame: Frame) -> bool: - """ Check if a Frame is valid using its checksum. - - Parameters - ---------- - frame : Frame - Frame to be checked - - Returns: - True if the Frame's checksum is correct. Otherwise, False. - """ - - return self.__checksum(frame.raw) == frame.checksum - - def __checksum(self, raw_frame: bytearray) -> int: - """ - Performs simple checksum of the Frame to confirm its validity. - - Parameters: - ---------- - raw_frame : Frame - Frame to calculate checksum for - - Returns: - ------- - Checksum value - """ - newsum = raw_frame[0] + raw_frame[1] - - for idx in range(3, len(raw_frame)): - newsum += raw_frame[idx] - - newsum = newsum % 255 - - return newsum - - def __send(self, channel_number: int, payload: bytearray) -> bool: - """ Send a frame with the selected bytes. - - Parameters: - ---------- - channel_number : int - Channel to send frame to. - payload : bytearray - Payload to send - - Returns: - -------- - If payload succesfully sent, returns True. Otherwise, False. - """ - - frame_sent = False - channel_dlc = self.__get_tx_channel_dlc(channel_number) - - if (len(payload) == channel_dlc) and \ - ((self.__sync_data.is_synced is True) or - (SerialMuxProtConstants.CONTROL_CHANNEL_NUMBER == channel_number)): - written_bytes = 0 - new_frame = Frame() - new_frame.channel = channel_number - new_frame.dlc = channel_dlc - new_frame.payload = payload - new_frame.pack_frame() # Pack Header and payload in Frame - new_frame.checksum = self.__checksum(new_frame.raw) - new_frame.pack_frame() # Pack Checksum in Frame - - try: - written_bytes = self.__stream.write(new_frame.raw) - except Exception as excpt: # pylint: disable=broad-exception-caught - print(excpt) - - if written_bytes == (SerialMuxProtConstants.HEADER_LEN + channel_dlc): - frame_sent = True - - return frame_sent - - def __cmd_sync(self, payload: bytearray) -> None: - """ Control Channel Command: SYNC - - Parameters: - ----------- - payload : bytearray - Command Data of received frame - """ - - response = bytearray( - SerialMuxProtConstants.CONTROL_CHANNEL_PAYLOAD_LENGTH) - response[SerialMuxProtConstants.CONTROL_CHANNEL_COMMAND_INDEX] = SerialMuxProtConstants.Commands.SYNC_RSP - response[SerialMuxProtConstants.CONTROL_CHANNEL_PAYLOAD_INDEX:] = payload - - self.__send(SerialMuxProtConstants.CONTROL_CHANNEL_NUMBER, response) - - def __cmd_sync_rsp(self, payload: bytearray) -> None: - """ Control Channel Command: SYNC_RSP - - Parameters: - ----------- - payload : bytearray - Command Data of received frame - """ - - # Parse big-endian uint32 - unpacker = Struct(">L") - rcvd_timestamp = unpacker.unpack_from(payload)[0] - - if self.__sync_data.last_sync_command == rcvd_timestamp: - self.__sync_data.last_sync_response = self.__sync_data.last_sync_command - self.__sync_data.is_synced = True - - # Process pending Subscriptions - self.__process_subscriptions() - else: - self.__sync_data.is_synced = False - - def __cmd_scrb(self, payload: bytearray) -> None: - """ Control Channel Command: SCRB - - Parameters: - ----------- - payload : bytearray - Command Data of received frame - """ - - response = bytearray( - SerialMuxProtConstants.CONTROL_CHANNEL_PAYLOAD_LENGTH) - response[SerialMuxProtConstants.CONTROL_CHANNEL_COMMAND_INDEX] = SerialMuxProtConstants.Commands.SCRB_RSP - - # Parse name - channel_name = str(payload[5:], "ascii").strip('\x00') - response[5] = self.get_tx_channel_number(channel_name) - - # Name is always sent back. - response[6:] = bytearray(channel_name, 'ascii') - - # Pad array if necessary - response = response.ljust( - SerialMuxProtConstants.CONTROL_CHANNEL_PAYLOAD_LENGTH, b'\x00') - - if self.__send(SerialMuxProtConstants.CONTROL_CHANNEL_NUMBER, response) is False: - # Fall out of sync if failed to send. - self.__sync_data.is_synced = False - - def __cmd_scrb_rsp(self, payload: bytearray) -> None: - """ Control Channel Command: SCRB - - Parameters: - ----------- - payload : bytearray - Command Data of received frame - """ - - # Parse payload - channel_number = payload[4] - channel_name = str(payload[5:], "ascii").strip('\x00') - - if (self.__max_configured_channels >= channel_number) and \ - (0 < self.__channels.number_of_pending_channels): - - # Channel Iterator - for potential_channel in self.__channels.pending_channels: - # Check if a SCRB is pending and is the correct channel - if (potential_channel.callback is not None) and\ - (potential_channel.name == channel_name): - # Channel is found in the server - if 0 != channel_number: - channel_array_index = channel_number - 1 - - if self.__channels.rx_channels[channel_array_index].callback is None: - self.__channels.number_of_rx_channels += 1 - - self.__channels.rx_channels[channel_array_index] = Channel( - channel_name, 0, potential_channel.callback) - - # Channel is no longer pending - potential_channel = Channel() - - # Decrease Channel Counter - self.__channels.number_of_pending_channels -= 1 - - # Break out of iterator - break - - def __callback_control_channel(self, payload: bytearray) -> None: - """ Callback for the Control Channel - - Parameters: - ----------- - payload : bytearray - Payload of received frame - """ - if len(payload) != SerialMuxProtConstants.CONTROL_CHANNEL_PAYLOAD_LENGTH: - return - - cmd_byte = payload[SerialMuxProtConstants.CONTROL_CHANNEL_COMMAND_INDEX] - cmd_data = payload[SerialMuxProtConstants.CONTROL_CHANNEL_PAYLOAD_INDEX:] - - # Switch not available until Python 3.10 - # Find command handler - if SerialMuxProtConstants.Commands.SYNC == cmd_byte: - self.__cmd_sync(cmd_data) - elif SerialMuxProtConstants.Commands.SYNC_RSP == cmd_byte: - self.__cmd_sync_rsp(cmd_data) - elif SerialMuxProtConstants.Commands.SCRB == cmd_byte: - self.__cmd_scrb(cmd_data) - elif SerialMuxProtConstants.Commands.SCRB_RSP == cmd_byte: - self.__cmd_scrb_rsp(cmd_data) - -################################################################################ -# Functions -################################################################################ - -################################################################################ -# Main -################################################################################ +""" Serial Multiplexer Protocol (SerialMuxProt) for lightweight communication. """ + +# MIT License +# +# Copyright (c) 2023 - 2024 Gabryel Reyes +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# + + +################################################################################ +# Imports +################################################################################ + +from dataclasses import dataclass +from struct import Struct +from .stream import Stream + +################################################################################ +# Variables +################################################################################ + +################################################################################ +# Classes +################################################################################ + + +@dataclass(frozen=True) +class SerialMuxProtConstants: + """ Container Data class for SerialMuxProt Constants """ + + CHANNEL_LEN = 1 # Channel Field Length in Bytes + DLC_LEN = 1 # DLC Field Length in Bytes + CHECKSUM_LEN = 1 # Checksum Field Length in Bytes + HEADER_LEN = CHANNEL_LEN + DLC_LEN + \ + CHECKSUM_LEN # Length of Complete Header Field + MAX_DATA_LEN = 32 # Data Field Length in Bytes + MAX_FRAME_LEN = HEADER_LEN + MAX_DATA_LEN # Total Frame Length in Bytes + CHANNEL_NAME_MAX_LEN = 10 # Max length of channel name + # Available Bytes in Control Channel Payload for data. + CONTROL_CHANNEL_PAYLOAD_DATA_LENGTH = 4 + CONTROL_CHANNEL_CMD_BYTE_LENGTH = 1 # Lenght of Command in Bytes + CONTROL_CHANNEL_NUMBER = 0 # Number of Control Channel. + CONTROL_CHANNEL_PAYLOAD_LENGTH = CHANNEL_NAME_MAX_LEN + \ + CONTROL_CHANNEL_PAYLOAD_DATA_LENGTH + \ + CONTROL_CHANNEL_CMD_BYTE_LENGTH + \ + CHANNEL_LEN # DLC of Heartbeat Command. + # Index of the Command Byte of the Control Channel + CONTROL_CHANNEL_COMMAND_INDEX = 0 + # Index of the start of the payload of the Control Channel + CONTROL_CHANNEL_PAYLOAD_INDEX = 1 + HEATBEAT_PERIOD_SYNCED = 5000 # Period of Heartbeat when Synced. + HEATBEAT_PERIOD_UNSYNCED = 1000 # Period of Heartbeat when Unsynced + # Max number of attempts at receiving a Frame before resetting RX Buffer + MAX_RX_ATTEMPTS = MAX_FRAME_LEN + + @dataclass + class Commands(): + """ Enumeration of Commands of Control Channel. """ + SYNC = 0 + SYNC_RSP = 1 + SCRB = 2 + SCRB_RSP = 3 + + +@dataclass +class Channel(): + """ Channel Definition """ + + def __init__(self, channel_name: str = "", channel_dlc: int = 0, channel_callback=None) -> None: + self.name = channel_name + self.dlc = channel_dlc + self.callback = channel_callback + + +class Frame(): + """ Frame Defintion """ + + def __init__(self) -> None: + self.raw = bytearray(SerialMuxProtConstants.MAX_FRAME_LEN) + self.channel = 0 + self.dlc = 0 + self.checksum = 0 + self.payload = bytearray(SerialMuxProtConstants.MAX_DATA_LEN) + + def unpack_header(self): + """ Unpack/parse channel number and checksum from raw frame """ + header_packer = Struct(">3B") + self.channel, self.dlc, self.checksum = header_packer.unpack_from( + self.raw) + + def unpack_payload(self): + """ Unpack/parse payload from raw frame """ + self.payload = self.raw[3:] + + def pack_frame(self): + """ Pack header and payload into raw array""" + self.raw[0] = self.channel + self.raw[1] = self.dlc + self.raw[2] = self.checksum + self.raw[3:] = self.payload + + +@dataclass +class ChannelArrays: + """ Container Class for Channel Arrays and their counters """ + + def __init__(self, max_configured_channels: int) -> None: + self.number_of_rx_channels = 0 + self.number_of_tx_channels = 0 + self.number_of_pending_channels = 0 + self.rx_channels = [Channel() for x in range(max_configured_channels)] + self.tx_channels = [Channel() for x in range(max_configured_channels)] + self.pending_channels = [Channel() + for x in range(max_configured_channels)] + + +@dataclass +class SyncData: + """ Container Dataclass for Synchronization Data. """ + + def __init__(self) -> None: + self.is_synced = False + self.last_sync_response = 0 + self.last_sync_command = 0 + + +@dataclass +class RxData: + """ Container Dataclass for Receive Data and counters. """ + + def __init__(self) -> None: + self.received_bytes = 0 + self.rx_attempts = 0 + self.receive_frame = Frame() + + +class Server: + """ SerialMuxProt Server """ + + def __init__(self, max_configured_channels: int, stream: Stream) -> None: + self.__max_configured_channels = max_configured_channels + self.__stream = stream + self.__rx_data = RxData() + self.__sync_data = SyncData() + self.__channels = ChannelArrays(max_configured_channels) + + def process(self, current_timestamp: int) -> None: + """Manage the Server functions. + Call this function cyclic. + + Parameters + ---------- + current_timestamp: int + Time in milliseconds. + + """ + + # Periodic Heartbeat. + self.__heartbeat(current_timestamp) + + # Process RX data. + self.__process_rx() + + def send_data(self, channel_name: str, payload: bytearray) -> bool: + """Send a frame with the selected bytes. + + Parameters: + ---------- + channel_name: str + Channel to send frame to. + payload: bytearray + Byte buffer to be sent. + + Returns: + -------- + If payload succesfully sent, returns true. Otherwise, false. + """ + is_sent = False + channel_number = self.get_tx_channel_number(channel_name) + + if (SerialMuxProtConstants.CONTROL_CHANNEL_NUMBER != channel_number) and\ + (self.__sync_data.is_synced is True): + + is_sent = self.__send(channel_number, payload) + + return is_sent + + def is_synced(self) -> bool: + """ Returns current Sync state of the SerialMuxProt Server. """ + return self.__sync_data.is_synced + + def get_number_rx_channels(self) -> int: + """Get the number of configured RX channels.""" + return self.__channels.number_of_rx_channels + + def get_number_tx_channels(self) -> int: + """Get the number of configured TX channels.""" + return self.__channels.number_of_tx_channels + + def create_channel(self, name: str, dlc: int) -> int: + """Creates a new TX Channel on the server. + + Parameters: + ---------- + name: str + Name of the channel. It will not be checked if the name already exists. + dlc: int + Length of the payload of this channel. + + Returns: + -------- + The channel number if succesfully created, or 0 if not able to create new channel. + """ + name_length = len(name) + idx = 0 + + if (0 != name_length) and\ + (SerialMuxProtConstants.CHANNEL_NAME_MAX_LEN >= name_length) and\ + (0 != dlc) and\ + (SerialMuxProtConstants.MAX_DATA_LEN >= dlc) and\ + (self.__max_configured_channels > self.__channels.number_of_tx_channels): + + # Create Channel + self.__channels.tx_channels[self.__channels.number_of_tx_channels] = Channel( + name, dlc) + + # Increase Channel Counter + self.__channels.number_of_tx_channels += 1 + + # Provide Channel Number. Could be summarized with operation above. + idx = self.__channels.number_of_tx_channels + + return idx + + def subscribe_to_channel(self, name: str, callback) -> None: + """ Suscribe to a Channel to receive the incoming data. + + Parameters: + ---------- + name: str + Name of the Channel to suscribe to. + callback: function + Callback to return the incoming data. + """ + + if (SerialMuxProtConstants.CHANNEL_NAME_MAX_LEN >= len(name)) and\ + (callback is not None) and\ + (self.__max_configured_channels > self.__channels.number_of_pending_channels): + + # Save Name and Callback for channel creation after response + self.__channels.pending_channels[self.__channels.number_of_pending_channels] = Channel( + channel_name=name, channel_dlc=0, channel_callback=callback) + + # Increase Channel Counter + self.__channels.number_of_pending_channels += 1 + + def get_tx_channel_number(self, channel_name: str) -> int: + """Get Number of a TX channel by its name. + + Parameters: + ----------- + channel_name: str + Name of channel + + Returns: + -------- + Number of the Channel, or 0 if not channel with the name is present. + """ + + channel_number = 0 + for idx in range(self.__max_configured_channels): + if self.__channels.tx_channels[idx].name == channel_name: + channel_number = idx + 1 + break + + return channel_number + + def __heartbeat(self, current_timestamp: int) -> None: + """ Periodic heartbeat. + Sends SYNC Command depending on the current Sync state. + + Parameters + ---------- + current_timestamp: int + Time in milliseconds. + + """ + + heartbeat_period = SerialMuxProtConstants.HEATBEAT_PERIOD_UNSYNCED + + if self.__sync_data.is_synced is True: + heartbeat_period = SerialMuxProtConstants.HEATBEAT_PERIOD_SYNCED + + if (current_timestamp - self.__sync_data.last_sync_command) >= heartbeat_period: + + # Timeout + if self.__sync_data.last_sync_command != self.__sync_data.last_sync_response: + self.__sync_data.is_synced = False + + # Pack big-endian uint32 + packer = Struct(">L") + timestamp_array = packer.pack(current_timestamp) + + # Send SYNC Command + command = bytearray() + command.append(SerialMuxProtConstants.Commands.SYNC) + command.extend(timestamp_array) + + # Pad array if necessary + command = command.ljust( + SerialMuxProtConstants.CONTROL_CHANNEL_PAYLOAD_LENGTH, b'\x00') + + if self.__send(SerialMuxProtConstants.CONTROL_CHANNEL_NUMBER, command) is True: + self.__sync_data.last_sync_command = current_timestamp + + def __process_rx(self) -> None: + """ Receive and process RX Data. """ + + expected_bytes = 0 + dlc = 0 + + # Determine how many bytes to read. + if SerialMuxProtConstants.HEADER_LEN > self.__rx_data.received_bytes: + # Header must be read. + expected_bytes = SerialMuxProtConstants.HEADER_LEN - self.__rx_data.received_bytes + else: + # Header has been read. Get DLC of Rx Channel using header. + self.__rx_data.receive_frame.unpack_header() + # dlc = self.__get_channel_dlc(self.__receive_frame.channel, False) + dlc = self.__rx_data.receive_frame.dlc + + # DLC = 0 means that the channel does not exist. + if (0 != dlc) and \ + (SerialMuxProtConstants.MAX_RX_ATTEMPTS >= self.__rx_data.rx_attempts): + remaining_payload_bytes = self.__rx_data.received_bytes - \ + SerialMuxProtConstants.HEADER_LEN + expected_bytes = dlc - remaining_payload_bytes + self.__rx_data.rx_attempts += 1 + + # Are we expecting to read anything? + if 0 != expected_bytes: + + # Read the required amount of bytes, if available. + if self.__stream.available() >= expected_bytes: + rcvd, data = self.__stream.read_bytes(expected_bytes) + self.__rx_data.receive_frame.raw[self.__rx_data.received_bytes:] = data + self.__rx_data.received_bytes += rcvd + + # Frame has been received. + if (0 != dlc) and \ + ((SerialMuxProtConstants.HEADER_LEN + dlc) == self.__rx_data.received_bytes): + + # Check validity + if self.__is_frame_valid(self.__rx_data.receive_frame) is True: + channel_array_index = self.__rx_data.receive_frame.channel - 1 + self.__rx_data.receive_frame.unpack_payload() + + # Differenciate between Control and Data Channels. + # pylint: disable=line-too-long + if SerialMuxProtConstants.CONTROL_CHANNEL_NUMBER == self.__rx_data.receive_frame.channel: + self.__callback_control_channel( + self.__rx_data.receive_frame.payload) + elif self.__channels.rx_channels[channel_array_index].callback is not None: + self.__channels.rx_channels[channel_array_index].callback( + self.__rx_data.receive_frame.payload) + + # Frame received. Cleaning! + self.__clear_local_buffers() + else: + # Invalid Header. Delete Frame! + self.__clear_local_buffers() + + def __process_subscriptions(self) -> None: + """ Subscribe to any pending Channels if synced to server. """ + + # If synced and a channel is pending + if (self.__sync_data.is_synced is True) and\ + (0 < self.__channels.number_of_pending_channels): + # Channel Iterator + for pending_channel in self.__channels.pending_channels: + + # Channel is pending + if pending_channel.callback is not None: + + # Subscribe to Channel + request = bytearray( + SerialMuxProtConstants.CONTROL_CHANNEL_PAYLOAD_LENGTH) + request[0] = SerialMuxProtConstants.Commands.SCRB + request[6:] = bytearray(pending_channel.name, 'ascii') + + # Pad array if necessary + request = request.ljust( + SerialMuxProtConstants.CONTROL_CHANNEL_PAYLOAD_LENGTH, b'\x00') + + if self.__send(SerialMuxProtConstants.CONTROL_CHANNEL_NUMBER, request) is False: + # Fall out of sync if failed to send. + self.__sync_data.is_synced = False + break + + def __clear_local_buffers(self) -> None: + """ Clear Local RX Buffers """ + self.__rx_data.receive_frame = Frame() + self.__rx_data.received_bytes = 0 + self.__rx_data.rx_attempts = 0 + + def __get_tx_channel_dlc(self, channel_number: int) -> int: + """ Get the Payload Length of a channel. + + Parameters + ---------- + channel_number: int + Channel number to check. + + is_tx_channel: bool + Is the Channel a TX Channel? If false, will return value for an RX Channel instead. + + Returns + ------- + DLC of the channel, or 0 if channel is not found. + """ + dlc = 0 + + if SerialMuxProtConstants.CONTROL_CHANNEL_NUMBER == channel_number: + dlc = SerialMuxProtConstants.CONTROL_CHANNEL_PAYLOAD_LENGTH + else: + if self.__max_configured_channels >= channel_number: + channel_idx = channel_number - 1 + dlc = self.__channels.tx_channels[channel_idx].dlc + + return dlc + + def __is_frame_valid(self, frame: Frame) -> bool: + """ Check if a Frame is valid using its checksum. + + Parameters + ---------- + frame: Frame + Frame to be checked + + Returns: + True if the Frame's checksum is correct. Otherwise, False. + """ + + return self.__checksum(frame.raw) == frame.checksum + + def __checksum(self, raw_frame: bytearray) -> int: + """ + Performs simple checksum of the Frame to confirm its validity. + + Parameters: + ---------- + raw_frame: Frame + Frame to calculate checksum for + + Returns: + ------- + Checksum value + """ + newsum = raw_frame[0] + raw_frame[1] + + for idx in range(3, len(raw_frame)): + newsum += raw_frame[idx] + + newsum = newsum % 255 + + return newsum + + def __send(self, channel_number: int, payload: bytearray) -> bool: + """ Send a frame with the selected bytes. + + Parameters: + ---------- + channel_number: int + Channel to send frame to. + payload: bytearray + Payload to send + + Returns: + -------- + If payload succesfully sent, returns True. Otherwise, False. + """ + + frame_sent = False + channel_dlc = self.__get_tx_channel_dlc(channel_number) + + if (len(payload) == channel_dlc) and \ + ((self.__sync_data.is_synced is True) or + (SerialMuxProtConstants.CONTROL_CHANNEL_NUMBER == channel_number)): + written_bytes = 0 + new_frame = Frame() + new_frame.channel = channel_number + new_frame.dlc = channel_dlc + new_frame.payload = payload + new_frame.pack_frame() # Pack Header and payload in Frame + new_frame.checksum = self.__checksum(new_frame.raw) + new_frame.pack_frame() # Pack Checksum in Frame + + try: + written_bytes = self.__stream.write(new_frame.raw) + except Exception as excpt: # pylint: disable=broad-exception-caught + print(excpt) + + if written_bytes == (SerialMuxProtConstants.HEADER_LEN + channel_dlc): + frame_sent = True + + return frame_sent + + def __cmd_sync(self, payload: bytearray) -> None: + """ Control Channel Command: SYNC + + Parameters: + ----------- + payload: bytearray + Command Data of received frame + """ + + response = bytearray( + SerialMuxProtConstants.CONTROL_CHANNEL_PAYLOAD_LENGTH) + response[SerialMuxProtConstants.CONTROL_CHANNEL_COMMAND_INDEX] =\ + SerialMuxProtConstants.Commands.SYNC_RSP + response[SerialMuxProtConstants.CONTROL_CHANNEL_PAYLOAD_INDEX:] = payload + + self.__send(SerialMuxProtConstants.CONTROL_CHANNEL_NUMBER, response) + + def __cmd_sync_rsp(self, payload: bytearray) -> None: + """ Control Channel Command: SYNC_RSP + + Parameters: + ----------- + payload: bytearray + Command Data of received frame + """ + + # Parse big-endian uint32 + unpacker = Struct(">L") + rcvd_timestamp = unpacker.unpack_from(payload)[0] + + if self.__sync_data.last_sync_command == rcvd_timestamp: + self.__sync_data.last_sync_response = self.__sync_data.last_sync_command + self.__sync_data.is_synced = True + + # Process pending Subscriptions + self.__process_subscriptions() + else: + self.__sync_data.is_synced = False + + def __cmd_scrb(self, payload: bytearray) -> None: + """ Control Channel Command: SCRB + + Parameters: + ----------- + payload: bytearray + Command Data of received frame + """ + + response = bytearray( + SerialMuxProtConstants.CONTROL_CHANNEL_PAYLOAD_LENGTH) + response[SerialMuxProtConstants.CONTROL_CHANNEL_COMMAND_INDEX] = \ + SerialMuxProtConstants.Commands.SCRB_RSP + + # Parse name + channel_name = str(payload[5:], "ascii").strip('\x00') + response[5] = self.get_tx_channel_number(channel_name) + + # Name is always sent back. + response[6:] = bytearray(channel_name, 'ascii') + + # Pad array if necessary + response = response.ljust( + SerialMuxProtConstants.CONTROL_CHANNEL_PAYLOAD_LENGTH, b'\x00') + + if self.__send(SerialMuxProtConstants.CONTROL_CHANNEL_NUMBER, response) is False: + # Fall out of sync if failed to send. + self.__sync_data.is_synced = False + + def __cmd_scrb_rsp(self, payload: bytearray) -> None: + """ Control Channel Command: SCRB + + Parameters: + ----------- + payload: bytearray + Command Data of received frame + """ + + # Parse payload + channel_number = payload[4] + channel_name = str(payload[5:], "ascii").strip('\x00') + + if (self.__max_configured_channels >= channel_number) and \ + (0 < self.__channels.number_of_pending_channels): + + # Channel Iterator + for potential_channel in self.__channels.pending_channels: + # Check if a SCRB is pending and is the correct channel + if (potential_channel.callback is not None) and\ + (potential_channel.name == channel_name): + # Channel is found in the server + if 0 != channel_number: + channel_array_index = channel_number - 1 + + if self.__channels.rx_channels[channel_array_index].callback is None: + self.__channels.number_of_rx_channels += 1 + + self.__channels.rx_channels[channel_array_index] = Channel( + channel_name, 0, potential_channel.callback) + + # Channel is no longer pending + potential_channel = Channel() + + # Decrease Channel Counter + self.__channels.number_of_pending_channels -= 1 + + # Break out of iterator + break + + def __callback_control_channel(self, payload: bytearray) -> None: + """ Callback for the Control Channel + + Parameters: + ----------- + payload: bytearray + Payload of received frame + """ + if len(payload) != SerialMuxProtConstants.CONTROL_CHANNEL_PAYLOAD_LENGTH: + return + + cmd_byte = payload[SerialMuxProtConstants.CONTROL_CHANNEL_COMMAND_INDEX] + cmd_data = payload[SerialMuxProtConstants.CONTROL_CHANNEL_PAYLOAD_INDEX:] + + # Switch not available until Python 3.10 + # Find command handler + if SerialMuxProtConstants.Commands.SYNC == cmd_byte: + self.__cmd_sync(cmd_data) + elif SerialMuxProtConstants.Commands.SYNC_RSP == cmd_byte: + self.__cmd_sync_rsp(cmd_data) + elif SerialMuxProtConstants.Commands.SCRB == cmd_byte: + self.__cmd_scrb(cmd_data) + elif SerialMuxProtConstants.Commands.SCRB_RSP == cmd_byte: + self.__cmd_scrb_rsp(cmd_data) + +################################################################################ +# Functions +################################################################################ + +################################################################################ +# Main +################################################################################ diff --git a/python/SerialMuxProt/src/SerialMuxProt/stream.py b/python/SerialMuxProt/src/SerialMuxProt/stream.py new file mode 100644 index 0000000..0e4e3e0 --- /dev/null +++ b/python/SerialMuxProt/src/SerialMuxProt/stream.py @@ -0,0 +1,93 @@ +""" Stream interface for SerialMuxProt. """ + +# MIT License +# +# Copyright (c) 2023 - 2024 Gabryel Reyes +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# + + +################################################################################ +# Imports +################################################################################ + +from abc import ABC, abstractmethod + +################################################################################ +# Variables +################################################################################ + +################################################################################ +# Classes +################################################################################ + + +class Stream(ABC): + """ + Stream Interface for SerialMuxProt. + """ + + @abstractmethod + def available(self) -> int: + """ Get the number of bytes available in the stream. + + Returns: + -------- + Number of bytes available in the stream. + """ + + @abstractmethod + def read_bytes(self, length: int) -> tuple[int, bytearray]: + """ Read a number of bytes from the stream. + + Parameters: + ----------- + length : int + Number of bytes to read. + + Returns + ---------- + Tuple: + - int: Number of bytes received. + - bytearray: Received data. + """ + + @abstractmethod + def write(self, payload: bytearray) -> int: + """ + Write a bytearray to the stream. + + Parameters: + ----------- + payload: bytearray + Data to write to the stream. + + Returns: + -------- + Number of bytes written. + """ + +################################################################################ +# Functions +################################################################################ + +################################################################################ +# Main +################################################################################ diff --git a/python/SerialMuxProt/tests/__init__.py b/python/SerialMuxProt/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/SerialMuxProt/tests/test_empty.py b/python/SerialMuxProt/tests/test_empty.py new file mode 100644 index 0000000..90e261e --- /dev/null +++ b/python/SerialMuxProt/tests/test_empty.py @@ -0,0 +1,8 @@ +"""Tests +""" + + +def test_do_nothing(): + """The test case does nothing. Its just used to suppress any error + in the test to avoid that the CI alerts. + """