diff --git a/README.md b/README.md index 42b4f89..ff8edea 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Plugin for polling Modbus devices and publishing the data to thin-edge.io. If us ## Overview -The plugin regularly polls Modbus devices and publishes the data to the thin-edge.io broker. The plugin is based on the [pymodbus](https://pymodbus.readthedocs.io/en/latest/) library. After installing, the plugin can be configured by changing the `modbus.toml` and `devices.toml` files. The plugin comes with an example config [4] with comments to get you started. Adding multiple servers should also be as simple as adding additional `[[device]]` sections for each IP address you want to poll. +The plugin regularly polls Modbus devices and publishes the data to the thin-edge.io broker. The plugin is based on the [pymodbus](https://pymodbus.readthedocs.io/en/latest/) library. After installing, the plugin can be configured by changing the `modbus.toml` and `devices.toml` files. The plugin comes with an example config [4] with comments to get you started. Adding multiple servers should also be as simple as adding additional `[[device]]` sections for each IP address or serial address you want to poll. ## Requirements @@ -83,9 +83,10 @@ If used with Cumulocity IoT, the plugins can be managed via the Device Managemen ### modbus.toml -This includes the basic configuration for the plugin such as poll rate and the connection to thin-edge.io (the MQTT broker needs to match the one of tedge and is probably the default `localhost:1883`). +This includes the basic configuration for the plugin such as poll rate and the connection to thin-edge.io (the MQTT broker needs to match the one of tedge and is probably the default `localhost:1883`). It also includes the configuration of the main serial port used by modbus RTU devices. Make sure the serial port is properly configured to for the hardware in use. - poll rate +- serial configuration - connection to thin-edge.io (MQTT broker needs to match the one of tedge) - log level (e.g. INFO, WARN, ERROR) @@ -154,6 +155,7 @@ As of now, the plugin only supports the following operations: - c8y_ModbusDevice - Mapping of registers to Measurements with c8y_ModbusConfiguration +- c8y_SerialConfiguration To create a Cloud Fieldbus Device in Cumulocity IoT, you need first to create a Modbus protocol. Open the Device protocols page in your Device Management and add a new Modbus protocol. The configuration of your protocol depends on your Modbus Server. If you are using the Modbus Demo simulator, the you can use the following configuration: @@ -164,6 +166,8 @@ After creating the protocol, you can add a new Cloud Fieldbus Device. Select the ![Image](./doc/tcp_device.png) +For adding a modbus RTU device you need to use unit-ID of the slave device in the configuration. + ## Testing To run the tests locally, you need to provide your Cumulocity credentials as environment variables in a .env file: diff --git a/config/modbus.toml b/config/modbus.toml index ce7cffd..05ec58a 100644 --- a/config/modbus.toml +++ b/config/modbus.toml @@ -2,6 +2,13 @@ pollinterval=2 loglevel="INFO" +[serial] +port="/dev/ttyRS485" +baudrate=9600 +stopbits=2 +parity="N" +databits=8 + [thinedge] mqtthost="127.0.0.1" mqttport=1883 \ No newline at end of file diff --git a/operations/c8y_SerialConfiguration b/operations/c8y_SerialConfiguration new file mode 100644 index 0000000..ae2e717 --- /dev/null +++ b/operations/c8y_SerialConfiguration @@ -0,0 +1,4 @@ +[exec] + topic = "c8y/s/dc/modbus" + on_message = "3" + command = "python3 -m tedge_modbus.operations c8y_SerialConfiguration" \ No newline at end of file diff --git a/tedge_modbus/operations/__main__.py b/tedge_modbus/operations/__main__.py index 832cc7f..2847690 100644 --- a/tedge_modbus/operations/__main__.py +++ b/tedge_modbus/operations/__main__.py @@ -6,6 +6,7 @@ from . import c8y_modbus_configuration from . import c8y_modbus_device from . import c8y_registers +from . import c8y_serial_configuration from .context import Context @@ -20,6 +21,8 @@ def main(): run = c8y_modbus_device.run elif command == "c8y_Registers": run = c8y_registers.run + elif command == "c8y_SerialConfiguration": + run = c8y_serial_configuration.run arguments = sys.argv[2].split(",") if len(sys.argv) > 2 else [] context = Context() diff --git a/tedge_modbus/operations/c8y_modbus_configuration.py b/tedge_modbus/operations/c8y_modbus_configuration.py index a5f4c0f..42beca6 100644 --- a/tedge_modbus/operations/c8y_modbus_configuration.py +++ b/tedge_modbus/operations/c8y_modbus_configuration.py @@ -34,7 +34,7 @@ def run(arguments, context: Context): modbus_config["modbus"]["pollinterval"] = polling_rate # Save to file - logger.info("Saving new configuration to %s", context.base_config_path) + logger.info("Saving new modbus configuration to %s", context.base_config_path) with open(context.base_config_path, "w", encoding="utf8") as f: toml.dump(modbus_config, f) @@ -45,6 +45,7 @@ def run(arguments, context: Context): "transmitRate": transmit_rate, "pollingRate": polling_rate, } + # pylint: disable=duplicate-code mqtt_publish( topic="te/device/main///twin/c8y_ModbusConfiguration", payload=json.dumps(config), diff --git a/tedge_modbus/operations/c8y_modbus_device.py b/tedge_modbus/operations/c8y_modbus_device.py index d439221..c7d9fd4 100644 --- a/tedge_modbus/operations/c8y_modbus_device.py +++ b/tedge_modbus/operations/c8y_modbus_device.py @@ -42,8 +42,9 @@ def get_device_from_mapping(target: ModebusDevice, mapping): device = {} device["name"] = target.child_name device["address"] = int(target.modbus_address) - device["ip"] = target.modbus_server - device["port"] = 502 + if target.modbus_type == "TCP": + device["ip"] = target.modbus_server + device["port"] = 502 device["protocol"] = target.modbus_type device["littlewordendian"] = True @@ -100,12 +101,6 @@ def run(arguments, context: Context): config_path = context.config_dir / "devices.toml" target = parse_arguments(arguments) - # Fail if modbus_type is not TCP - if target.modbus_type != "TCP": - raise ValueError( - "Expected modbus_type to be TCP. Got " + target.modbus_type + "." - ) - # Update external id of child device logger.debug("Create external id for child device %s", target.device_id) url = f"{context.c8y_proxy}/identity/globalIds/{target.device_id}/externalIds" diff --git a/tedge_modbus/operations/c8y_serial_configuration.py b/tedge_modbus/operations/c8y_serial_configuration.py new file mode 100644 index 0000000..d71ed84 --- /dev/null +++ b/tedge_modbus/operations/c8y_serial_configuration.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Cumulocity IoT SerialConfiguration operation handler""" +import json +import logging +import toml +from paho.mqtt.publish import single as mqtt_publish + +from .context import Context + +logger = logging.getLogger(__name__) +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) + + +def run(arguments, context: Context): + """Run c8y_SerialConfiguration operation handler""" + if len(arguments) != 6: + raise ValueError( + f"Expected 6 arguments in smart rest template. Got {len(arguments)}" + ) + # Get device configuration + modbus_config = context.base_config + loglevel = modbus_config["modbus"]["loglevel"] or "INFO" + logger.setLevel(getattr(logging, loglevel.upper(), logging.INFO)) + logger.info("New c8y_SerialConfiguration operation") + logger.debug("Current configuration: %s", modbus_config) + baud_rate = int(arguments[2]) + stop_bits = int(arguments[3]) + parity = arguments[4] + data_bits = int(arguments[5]) + logger.debug( + "baudRate: %d, stopBits: %d, parity: %s, dataBits: %d", + baud_rate, + stop_bits, + parity, + data_bits, + ) + + # Update configuration + modbus_config["serial"]["baudrate"] = baud_rate + modbus_config["serial"]["stopbits"] = stop_bits + modbus_config["serial"]["parity"] = parity + modbus_config["serial"]["databits"] = data_bits + + # Save to file + logger.info("Saving new serial configuration to %s", context.base_config_path) + with open(context.base_config_path, "w", encoding="utf8") as f: + toml.dump(modbus_config, f) + + # Update managedObject + logger.debug("Updating managedObject with new configuration") + # pylint: disable=duplicate-code + config = { + "baudRate": baud_rate, + "stopBits": stop_bits, + "parity": parity, + "dataBits": data_bits, + } + mqtt_publish( + topic="te/device/main///twin/c8y_SerialConfiguration", + payload=json.dumps(config), + qos=1, + retain=True, + hostname=context.broker, + port=context.port, + client_id="c8y_SerialConfiguration-operation-client", + ) diff --git a/tedge_modbus/reader/reader.py b/tedge_modbus/reader/reader.py index d420419..cd4b10a 100644 --- a/tedge_modbus/reader/reader.py +++ b/tedge_modbus/reader/reader.py @@ -11,7 +11,7 @@ import tomli from paho.mqtt import client as mqtt_client -from pymodbus.client.tcp import ModbusTcpClient +from pymodbus.client import ModbusTcpClient, ModbusSerialClient from pymodbus.exceptions import ConnectionException from watchdog.events import FileSystemEventHandler, DirModifiedEvent, FileModifiedEvent from watchdog.observers import Observer @@ -81,6 +81,12 @@ def reread_config(self): new_devices = self.read_device_definition( f"{self.config_dir}/{DEVICES_CONFIG_NAME}" ) + # Add Serial Config into Device Config + for device in new_devices["device"]: + if device["protocol"] == "RTU": + for key in self.base_config["serial"]: + if device.get(key, None) is None: + device[key] = self.base_config["serial"][key] if ( len(new_devices) >= 1 and new_devices.get("device") @@ -268,16 +274,33 @@ def poll_device(self, device, poll_model, mapper): (device, poll_model, mapper), ) + def get_modbus_client(self, device): + """Get Modbus client""" + if device["protocol"] == "RTU": + return ModbusSerialClient( + port=device["port"], + baudrate=device["baudrate"], + stopbits=device["stopbits"], + parity=device["parity"], + bytesize=device["databits"], + ) + if device["protocol"] == "TCP": + return ModbusTcpClient( + host=device["ip"], + port=device["port"], + # TODO: Check if these parameters really supported by ModbusTcpClient? + auto_open=True, + auto_close=True, + debug=True, + ) + raise ValueError( + "Expected protocol to be RTU or TCP. Got " + device["protocol"] + "." + ) + def get_data_from_device(self, device, poll_model): """Get Modbus information from the device""" # pylint: disable=too-many-locals - client = ModbusTcpClient( - host=device["ip"], - port=device["port"], - auto_open=True, - auto_close=True, - debug=True, - ) + client = self.get_modbus_client(device) holding_register, input_registers, coils, discrete_input = poll_model hr_results = {} ir_result = {} @@ -406,6 +429,22 @@ def update_base_config_on_device(self, base_config): self.send_tedge_message( MappedMessage(json.dumps(config), topic), retain=True, qos=1 ) + if base_config.get("serial") is None: + return + topic = "te/device/main///twin/c8y_SerialConfiguration" + baud_rate = base_config["serial"].get("baudrate") + stop_bits = base_config["serial"].get("stopbits") + parity = base_config["serial"].get("parity") + data_bits = base_config["serial"].get("databits") + config = { + "baudRate": baud_rate, + "stopBits": stop_bits, + "parity": parity, + "dataBits": data_bits, + } + self.send_tedge_message( + MappedMessage(json.dumps(config), topic), retain=True, qos=1 + ) def update_modbus_info_on_child_devices(self, devices): """Update the modbus information for the child devices""" @@ -413,11 +452,12 @@ def update_modbus_info_on_child_devices(self, devices): self.logger.debug("Update modbus info on child device") topic = f"te/device/{device['name']}///twin/c8y_ModbusDevice" config = { - "ipAddress": device["ip"], "port": device["port"], "address": device["address"], "protocol": device["protocol"], } + if device["protocol"] == "TCP": + config["ipAddress"] = device["ip"] self.send_tedge_message( MappedMessage(json.dumps(config), topic), retain=True, qos=1 ) diff --git a/tedge_modbus/reader/smartresttemplates.py b/tedge_modbus/reader/smartresttemplates.py index 3692856..89e9ee0 100644 --- a/tedge_modbus/reader/smartresttemplates.py +++ b/tedge_modbus/reader/smartresttemplates.py @@ -4,4 +4,5 @@ SMARTREST_TEMPLATES = [ "11,1,,c8y_ModbusConfiguration,c8y_ModbusConfiguration.transmitRate,c8y_ModbusConfiguration.pollingRate", "11,2,,c8y_ModbusDevice,c8y_ModbusDevice.protocol,c8y_ModbusDevice.address,c8y_ModbusDevice.name,c8y_ModbusDevice.ipAddress,c8y_ModbusDevice.id,c8y_ModbusDevice.type", + "11,3,,c8y_SerialConfiguration,c8y_SerialConfiguration.baudRate,c8y_SerialConfiguration.stopBits,c8y_SerialConfiguration.parity,c8y_SerialConfiguration.dataBits", ] diff --git a/tests/c8y_SerialConfiguration/device.robot b/tests/c8y_SerialConfiguration/device.robot new file mode 100644 index 0000000..a2e05ff --- /dev/null +++ b/tests/c8y_SerialConfiguration/device.robot @@ -0,0 +1,10 @@ +*** Settings *** +Resource ../resources/common.robot +Library Cumulocity + +Suite Setup Set Main Device + + +*** Test Cases *** +Device should support the operation c8y_SerialConfiguration + Cumulocity.Should Contain Supported Operations c8y_SerialConfiguration