From 9d929b57c88a87ccf5456efdbeb6021a8d6d8273 Mon Sep 17 00:00:00 2001 From: Benjamin Braatz Date: Tue, 30 Mar 2021 03:20:31 +0200 Subject: [PATCH] Initial commit --- .gitignore | 4 + LICENSE | 19 ++++ README.md | 14 +++ conf.json | 33 +++++++ controlpi_plugins/modbus.py | 184 ++++++++++++++++++++++++++++++++++++ doc/index.md | 28 ++++++ setup.py | 24 +++++ 7 files changed, 306 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 conf.json create mode 100644 controlpi_plugins/modbus.py create mode 100644 doc/index.md create mode 100644 setup.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf6579d --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +dist/ +controlpi_modbus.egg-info/ +venv/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ebb8ac1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2021 Graph-IT GmbH + +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/README.md b/README.md new file mode 100644 index 0000000..8151b29 --- /dev/null +++ b/README.md @@ -0,0 +1,14 @@ +# ControlPi Plugin for Modbus communication +This distribution package contains a plugin for the +[ControlPi system](https://docs.graph-it.com/graphit/controlpi), that +<…> + +Documentation (in German) can be found at [doc/index.md](doc/index.md) in +the source repository and at +[https://docs.graph-it.com/graphit/controlpi-modbus/](https://docs.graph-it.com/graphit/controlpi-modbus/). +Code documentation (in English) including doctests is contained in the +source files. +An API documentation generated by pdoc3 can be found at +[doc/controlpi_plugins/index.html](doc/controlpi_plugins/index.html) in the source +repository and at +[https://docs.graph-it.com/graphit/controlpi-modbus/controlpi_plugins/](https://docs.graph-it.com/graphit/controlpi-modbus/controlpi_plugins/). diff --git a/conf.json b/conf.json new file mode 100644 index 0000000..c3f5a18 --- /dev/null +++ b/conf.json @@ -0,0 +1,33 @@ +{ + "Websocket Server": { + "plugin": "WSServer", + "port": 8080, + "web": { + "/": { "module": "controlpi_plugins.wsserver", + "location": "Debug" } + } + }, + "Modbus": { + "device": "/dev/ttyS0", + "test device": "/dev/ttyUSB0", + "baudrate": 115200, + "slave types": { + "Hitachi SJ-P1": { + "slaves": { "FU-1": 1 }, + "coils": { + "Op": { + "address": 0, + "name": "Operation command" + } + }, + "holding registers": { + "Set frequency": { + "address": 10501, + "name": "RS485 Set frequency", + "count": 2 + } + } + } + } + } +} diff --git a/controlpi_plugins/modbus.py b/controlpi_plugins/modbus.py new file mode 100644 index 0000000..ed8d867 --- /dev/null +++ b/controlpi_plugins/modbus.py @@ -0,0 +1,184 @@ +"""Modbus implementation. + +… + +TODO: documentation, doctests +""" +from controlpi import BasePlugin, Message, MessageTemplate + +from typing import List + + +def crc_slow(message: bytes) -> bytes: + """Compute CRC for message. + + A 16 bit CRC as specified in the Modbus specification is computed for + the given message and this CRC is returned as two bytes (low byte + first). + + (This is the slow version without precomputed table.) + + >>> crc_slow(bytes([0x08, 0x01, 0x00, 0x06, 0x00, 0x06])).hex() + '5c90' + >>> crc_slow(bytes([0x08, 0x01, 0x01, 0x17])).hex() + '121a' + >>> crc_slow(bytes([0x05, 0x03, 0x03, 0xE8, 0x00, 0x03])).hex() + '843f' + >>> crc_slow(bytes([0x05, 0x03, 0x06, 0x00, 0x07, 0x00, 0x00, 0x17, + ... 0x70])).hex() + 'a861' + >>> crc_slow(bytes([0x0A, 0x05, 0x00, 0x00, 0xFF, 0x00])).hex() + '8d41' + >>> crc_slow(bytes([0x01, 0x06, 0x2F, 0x4D, 0x13, 0x88])).hex() + '1c5f' + >>> crc_slow(bytes([0x05, 0x0F, 0x00, 0x06, 0x00, 0x06, 0x02, 0x17, + ... 0x00])).hex() + 'db3e' + >>> crc_slow(bytes([0x05, 0x0f, 0x00, 0x06, 0x00, 0x06])).hex() + '344c' + >>> crc_slow(bytes([0x01, 0x10, 0x2B, 0x01, 0x00, 0x02, 0x04, 0x00, + ... 0x04, 0x93, 0xE0])).hex() + 'f42b' + >>> crc_slow(bytes([0x01, 0x10, 0x2B, 0x01, 0x00, 0x02])).hex() + '19ec' + >>> crc_slow(bytes([0x01, 0x17, 0x27, 0x10, 0x00, 0x02, 0x2A, 0xF8, + ... 0x00, 0x02, 0x04, 0x00, 0x00, 0x13, 0x88])).hex() + '964d' + >>> crc_slow(bytes([0x01, 0x17, 0x04, 0x00, 0x00, 0x13, 0x88])).hex() + 'f471' + """ + crc = 0xFFFF + for byte in message: + crc ^= byte + for bit in range(8): + lsb = crc & 1 + crc >>= 1 + if lsb: + crc ^= 0xA001 + return bytes([crc & 0xFF, crc >> 8]) + + +_crc_table: List[int] = [] + + +def crc(message: bytes) -> bytes: + """Compute CRC for message. + + A 16 bit CRC as specified in the Modbus specification is computed for + the given message and this CRC is returned as two bytes (low byte + first). + + >>> crc(bytes([0x08, 0x01, 0x00, 0x06, 0x00, 0x06])).hex() + '5c90' + >>> crc(bytes([0x08, 0x01, 0x01, 0x17])).hex() + '121a' + >>> crc(bytes([0x05, 0x03, 0x03, 0xE8, 0x00, 0x03])).hex() + '843f' + >>> crc(bytes([0x05, 0x03, 0x06, 0x00, 0x07, 0x00, 0x00, 0x17, + ... 0x70])).hex() + 'a861' + >>> crc(bytes([0x0A, 0x05, 0x00, 0x00, 0xFF, 0x00])).hex() + '8d41' + >>> crc(bytes([0x01, 0x06, 0x2F, 0x4D, 0x13, 0x88])).hex() + '1c5f' + >>> crc(bytes([0x05, 0x0F, 0x00, 0x06, 0x00, 0x06, 0x02, 0x17, + ... 0x00])).hex() + 'db3e' + >>> crc(bytes([0x05, 0x0f, 0x00, 0x06, 0x00, 0x06])).hex() + '344c' + >>> crc(bytes([0x01, 0x10, 0x2B, 0x01, 0x00, 0x02, 0x04, 0x00, + ... 0x04, 0x93, 0xE0])).hex() + 'f42b' + >>> crc(bytes([0x01, 0x10, 0x2B, 0x01, 0x00, 0x02])).hex() + '19ec' + >>> crc(bytes([0x01, 0x17, 0x27, 0x10, 0x00, 0x02, 0x2A, 0xF8, + ... 0x00, 0x02, 0x04, 0x00, 0x00, 0x13, 0x88])).hex() + '964d' + >>> crc(bytes([0x01, 0x17, 0x04, 0x00, 0x00, 0x13, 0x88])).hex() + 'f471' + """ + global _crc_table + if not _crc_table: + for crc in range(256): + for bit in range(8): + lsb = crc & 1 + crc >>= 1 + if lsb: + crc ^= 0xA001 + _crc_table.append(crc) + crc = 0xFFFF + for byte in message: + crc ^= byte + table_value = _crc_table[crc & 0xFF] + crc >>= 8 + crc ^= table_value + return bytes([crc & 0xFF, crc >> 8]) + + +class Modbus(BasePlugin): + """… plugin. + + Do this and that. + """ + + CONF_SCHEMA = {'properties': + {'device': {'type': 'string'}, + 'test device': {'type': 'string'}, + 'baudrate': {'type': 'integer'}, + 'slave types': + {'type': 'object', + 'patternProperties': + {'^.+$': + {'type': 'object', + 'properties': + {'slaves': + {'type': 'object', + 'patternProperties': + {'^.+$': + {'type': 'integer', + 'minimum': 1, + 'maximum': 247}}}, + 'coils': + {'type': 'object', + 'patternProperties': + {'^.+$': + {'type': 'object', + 'properties': + {'address': + {'type': 'integer', + 'minimum': 0, + 'maximum': 65535}, + 'name': + {'type': 'string'}}, + 'required': ['address']}}}, + 'holding registers': + {'type': 'object', + 'patternProperties': + {'^.+$': + {'type': 'object', + 'properties': + {'address': + {'type': 'integer', + 'minimum': 0, + 'maximum': 65535}, + 'name': + {'type': 'string'}, + 'count': + {'type': 'integer', + 'minimum': 1}}, + 'required': ['address']}}}}}}}}, + 'required': ['device']} + + async def _receive(self, message: Message) -> None: + await self.bus.send(Message(self.name, {'spam': self.conf['spam']})) + + def process_conf(self) -> None: + """Register plugin as bus client.""" + message = Message(self.name, {'spam': self.conf['spam']}) + sends = [MessageTemplate.from_message(message)] + receives = [MessageTemplate({'target': {'const': self.name}})] + self.bus.register(self.name, 'Plugin', sends, receives, self._receive) + + async def run(self) -> None: + """Send initial message.""" + await self.bus.send(Message(self.name, {'spam': self.conf['spam']})) diff --git a/doc/index.md b/doc/index.md new file mode 100644 index 0000000..a213743 --- /dev/null +++ b/doc/index.md @@ -0,0 +1,28 @@ +# ControlPi Plugin für Modbus-Kommunikation +Dieses Paket enthält ein Plugin für das ControlPi-System, mit dem <…> + +## Benutzung +… + +## Installation +Eine ausführliche Dokumentation ist in der Dokumentation der +[ControlPi-Infrastruktur](https://docs.graph-it.com/graphit/controlpi) zu +finden. + +Der Code dieses Plugins kann mit git geclonet werden: +```sh +$ git clone git://git.graph-it.com/graphit/controlpi-modbus.git +``` +(Falls Zugang zu diesem Server per SSH besteht und Änderungen gepusht +werden sollen, sollte stattdessen die SSH-URL benutzt werden.) + +Dann kann es editierbar in ein virtuelles Environment installiert werden: +```sh +(venv)$ pip install --editable +``` + +Auf dem Raspberry Pi (oder wenn keine Code-Änderungen gewünscht sind) kann +es auch direkt, ohne einen git-Clone installiert werden: +```sh +(venv)$ pip install git+git://git.graph-it.com/graphit/controlpi-modbus.git +``` diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..c690cea --- /dev/null +++ b/setup.py @@ -0,0 +1,24 @@ +import setuptools + +with open("README.md", "r") as readme_file: + long_description = readme_file.read() + +setuptools.setup( + name="controlpi-modbus", + version="0.1.0", + author="Graph-IT GmbH", + author_email="info@graph-it.com", + description="ControlPi Plugin for Modbus communication", + long_description=long_description, + long_description_content_type="text/markdown", + url="http://docs.graph-it.com/graphit/controlpi-modbus", + packages=["controlpi_plugins"], + install_requires=[ + "controlpi @ git+git://git.graph-it.com/graphit/controlpi.git", + ], + classifiers=[ + "Programming Language :: Python", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + ], +) -- 2.34.1