Skip to content
Merged
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions config/modbus.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions operations/c8y_SerialConfiguration
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[exec]
topic = "c8y/s/dc/modbus"
on_message = "3"
command = "python3 -m tedge_modbus.operations c8y_SerialConfiguration"
3 changes: 3 additions & 0 deletions tedge_modbus/operations/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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()
Expand Down
3 changes: 2 additions & 1 deletion tedge_modbus/operations/c8y_modbus_configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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),
Expand Down
11 changes: 3 additions & 8 deletions tedge_modbus/operations/c8y_modbus_device.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"
Expand Down
68 changes: 68 additions & 0 deletions tedge_modbus/operations/c8y_serial_configuration.py
Original file line number Diff line number Diff line change
@@ -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",
)
58 changes: 49 additions & 9 deletions tedge_modbus/reader/reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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 = {}
Expand Down Expand Up @@ -406,18 +429,35 @@ 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"""
for device in 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
)
Expand Down
1 change: 1 addition & 0 deletions tedge_modbus/reader/smartresttemplates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
10 changes: 10 additions & 0 deletions tests/c8y_SerialConfiguration/device.robot
Original file line number Diff line number Diff line change
@@ -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