Initial commit
authorBenjamin Braatz <bb@bbraatz.eu>
Tue, 30 Mar 2021 01:20:31 +0000 (03:20 +0200)
committerBenjamin Braatz <bb@bbraatz.eu>
Tue, 30 Mar 2021 01:20:31 +0000 (03:20 +0200)
.gitignore [new file with mode: 0644]
LICENSE [new file with mode: 0644]
README.md [new file with mode: 0644]
conf.json [new file with mode: 0644]
controlpi_plugins/modbus.py [new file with mode: 0644]
doc/index.md [new file with mode: 0644]
setup.py [new file with mode: 0644]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..bf6579d
--- /dev/null
@@ -0,0 +1,4 @@
+__pycache__/
+dist/
+controlpi_modbus.egg-info/
+venv/
diff --git a/LICENSE b/LICENSE
new file mode 100644 (file)
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 (file)
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 (file)
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 (file)
index 0000000..ed8d867
--- /dev/null
@@ -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 (file)
index 0000000..a213743
--- /dev/null
@@ -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 <Pfad zum Code-Repository>
+```
+
+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 (file)
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",
+    ],
+)