Skip to content

Commit

Permalink
Merge pull request smarthomeNG#939 from ivan73/develop_modbus_tcp
Browse files Browse the repository at this point in the history
  • Loading branch information
Morg42 authored Jun 26, 2024
2 parents eb406f8 + de29f4e commit 1b3730f
Show file tree
Hide file tree
Showing 3 changed files with 158 additions and 50 deletions.
202 changes: 153 additions & 49 deletions modbus_tcp/__init__.py
Original file line number Diff line number Diff line change
@@ -1,31 +1,30 @@
#!/usr/bin/env python3
# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab
#########################################################################
# Copyright 2022 De Filippis Ivan
# Copyright 2022 Ronny Schulz
# Copyright 2023 Bernd Meiners
# Copyright 2022 De Filippis Ivan
# Copyright 2022 Ronny Schulz
# Copyright 2023 Bernd Meiners
#########################################################################
# This file is part of SmartHomeNG.
# This file is part of SmartHomeNG.
#
# Modbus_TCP plugin for SmartHomeNG
# Modbus_TCP plugin for SmartHomeNG
#
# SmartHomeNG is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# SmartHomeNG is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# SmartHomeNG is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# SmartHomeNG is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with SmartHomeNG. If not, see <http://www.gnu.org/licenses/>.
# You should have received a copy of the GNU General Public License
# along with SmartHomeNG. If not, see <http://www.gnu.org/licenses/>.
#
#########################################################################

from lib.model.smartplugin import *
from lib.item import Items
from lib.model.smartplugin import SmartPlugin
from datetime import datetime
import threading

Expand All @@ -37,6 +36,8 @@

from pymodbus.client.tcp import ModbusTcpClient

import logging

AttrAddress = 'modBusAddress'
AttrType = 'modBusDataType'
AttrFactor = 'modBusFactor'
Expand All @@ -46,14 +47,13 @@
AttrObjectType = 'modBusObjectType'
AttrDirection = 'modBusDirection'


class modbus_tcp(SmartPlugin):
"""
This class provides a Plugin for SmarthomeNG to read and or write to modbus
devices.
"""

PLUGIN_VERSION = '1.0.11'
PLUGIN_VERSION = '1.0.12'

def __init__(self, sh, *args, **kwargs):
"""
Expand All @@ -63,21 +63,28 @@ def __init__(self, sh, *args, **kwargs):

self.logger.info('Init modbus_tcp plugin')

# Disable logging from imported modul 'pymodbus'
if not self.logger.isEnabledFor(logging.DEBUG):
disable_logger = logging.getLogger('pymodbus')
if disable_logger is not None:
self.logger.info(f'change logging level from: {disable_logger} to CRITICAL')
disable_logger.setLevel(logging.CRITICAL)

# Call init code of parent class (SmartPlugin)
super().__init__()

self._host = self.get_parameter_value('host')
self._port = self.get_parameter_value('port')

self._cycle = self.get_parameter_value('cycle') # the frequency in seconds how often the device should be accessed
self._cycle = self.get_parameter_value('cycle') # the frequency in seconds how often the device should be accessed
if self._cycle == 0:
self._cycle = None
self._crontab = self.get_parameter_value('crontab') # the more complex way to specify the device query frequency
self._crontab = self.get_parameter_value('crontab') # the more complex way to specify the device query frequency
if self._crontab == '':
self._crontab = None
if not (self._cycle or self._crontab):
self.logger.error(f"{self.get_fullname()}: no update cycle or crontab set. Modbus will not be queried automatically")

self._slaveUnit = int(self.get_parameter_value('slaveUnit'))
self._slaveUnitRegisterDependend = False

Expand All @@ -99,24 +106,69 @@ def run(self):
Run method for the plugin
"""
self.logger.debug(f"Plugin '{self.get_fullname()}': run method called")
if self.alive:
return
self.alive = True
self.set_suspend(by='run()')

if self._cycle or self._crontab:
# self.get_shortname()
self.scheduler_add('poll_device_' + self._host, self.poll_device, cycle=self._cycle, cron=self._crontab, prio=5)
#self.scheduler_add(self.get_shortname(), self._update_values_callback, prio=5, cycle=self._update_cycle, cron=self._update_crontab, next=shtime.now())
self.logger.debug(f"Plugin '{self.get_fullname()}': run method finished")
self.error_count = 0 # Initialize error count
if not self.suspended:
self._create_cyclic_scheduler()
self.logger.debug(f"Plugin '{self.get_fullname()}': run method finished ")

def _create_cyclic_scheduler(self):
self.scheduler_add('poll_device_' + self._host, self.poll_device, cycle=self._cycle, cron=self._crontab, prio=5)

def _remove_cyclic_scheduler(self):
self.scheduler_remove('poll_device_' + self._host)

def stop(self):
"""
Stop method for the plugin
"""
self.alive = False
self.logger.debug(f"Plugin '{self.get_fullname()}': stop method called")
self.scheduler_remove('poll_device_' + self._host)
self._remove_cyclic_scheduler()
self._Mclient.close()
self.connected = False
self.logger.debug(f"Plugin '{self.get_fullname()}': stop method finished")

# sh.plugins.return_plugin('pluginName').suspend()
def set_suspend(self, suspend_active=None, by=None):
"""
enable / disable suspend mode: open/close connections, schedulers
"""

if suspend_active is None:
if self._suspend_item is not None:
# if no parameter set, try to use item setting
suspend_active = bool(self._suspend_item())
else:
# if not available, default to "resume" (non-breaking default)
suspend_active = False

# print debug logging
if suspend_active:
msg = 'Suspend mode enabled'
else:
msg = 'Suspend mode disabled'
if by:
msg += f' (set by {by})'
self.logger.debug(msg)

# activate selected mode, use smartplugin methods
if suspend_active:
self.suspend(by)
else:
self.resume(by)

if suspend_active:
self._remove_cyclic_scheduler()
else:
self._create_cyclic_scheduler()


def parse_item(self, item):
"""
Default plugin parse_item method. Is called when the plugin is initialized.
Expand All @@ -125,6 +177,14 @@ def parse_item(self, item):
:param item: The item to process.
"""

# check for suspend item
if item.property.path == self._suspend_item_path:
self.logger.debug(f'suspend item {item.property.path} registered')
self._suspend_item = item
self.add_item(item, updating=True)
return self.update_item

if self.has_iattr(item.conf, AttrAddress):
self.logger.debug(f"parse item: {item}")
regAddr = int(self.get_iattr_value(item.conf, AttrAddress))
Expand All @@ -147,7 +207,7 @@ def parse_item(self, item):
if self.has_iattr(item.conf, AttrObjectType):
objectType = self.get_iattr_value(item.conf, AttrObjectType)

reg = str(objectType) # dictionary key: objectType.regAddr.slaveUnit // HoldingRegister.528.1
reg = str(objectType) # dictionary key: objectType.regAddr.slaveUnit // HoldingRegister.528.1
reg += '.'
reg += str(regAddr)
reg += '.'
Expand All @@ -161,19 +221,19 @@ def parse_item(self, item):
byteOrderStr = self.get_iattr_value(item.conf, AttrByteOrder)
if self.has_iattr(item.conf, AttrWordOrder):
wordOrderStr = self.get_iattr_value(item.conf, AttrWordOrder)

try: # den letzten Teil des Strings extrahieren, in Großbuchstaben und in Endian-Konstante wandeln
byteOrder = Endian[(str(byteOrderStr).split('.')[-1]).upper()]
byteOrder = Endian[(str(byteOrderStr).split('.')[-1]).upper()]
except Exception as e:
self.logger.warning(f"Invalid byteOrder -> default(Endian.BIG) is used. Error:{e}")
byteOrder = Endian.BIG

try: # den letzten Teil des Strings extrahieren, in Großbuchstaben und in Endian-Konstante wandeln
wordOrder = Endian[(str(wordOrderStr).split('.')[-1]).upper()]
wordOrder = Endian[(str(wordOrderStr).split('.')[-1]).upper()]
except Exception as e:
self.logger.warning(f"Invalid byteOrder -> default(Endian.BIG) is used. Error:{e}")
wordOrder = Endian.BIG

regPara = {'regAddr': regAddr, 'slaveUnit': slaveUnit, 'dataType': dataType, 'factor': factor,
'byteOrder': byteOrder,
'wordOrder': wordOrder, 'item': item, 'value': value, 'objectType': objectType,
Expand All @@ -190,6 +250,22 @@ def parse_item(self, item):
self.logger.warning("Invalid data direction -> default(read) is used")
self._regToRead.update({reg: regPara})

def log_error(self, message):
"""
Logs an error message based on error count
"""
if self.logger.isEnabledFor(logging.DEBUG):
self.logger.error(message)
else:
if self.error_count < 10:
self.logger.error(message)
elif self.error_count < 100:
if self.error_count % 10 == 0:
self.logger.error(f"{message} [Logging suppressed every 10th error]")
else:
if self.error_count % 100 == 0:
self.logger.error(f"{message} [Logging suppressed every 100th error]")

def poll_device(self):
"""
Polls for updates of the device
Expand All @@ -198,19 +274,26 @@ def poll_device(self):
changes on it's own, but has to be polled to get the actual status.
It is called by the scheduler which is set within run() method.
"""
if self.suspended:
return

with self.lock:
try:
if self._Mclient.connect():
self.logger.info(f"connected to {str(self._Mclient)}")
self.logger.debug(f"connected to {str(self._Mclient)}")
self.connected = True
self.error_count = 0
else:
self.logger.error(f"could not connect to {self._host}:{self._port}")
self.error_count += 1
# Logs an error message based on error count
self.log_error(f"could not connect to {self._host}:{self._port}, connection_attempts: {self.error_count}")
self.connected = False
return

except Exception as e:
self.logger.error(f"connection exception: {str(self._Mclient)} {e}")
self.error_count += 1
# Logs an error message based on error count
self.log_error(f"connection exception: {str(self._Mclient)} {e}, errors: {self.error_count}")
self.connected = False
return

Expand Down Expand Up @@ -247,6 +330,7 @@ def poll_device(self):
except Exception as e:
self.logger.error(f"something went wrong in the poll_device function: {e}")


# called each time an item changes.
def update_item(self, item, caller=None, source=None, dest=None):
"""
Expand All @@ -265,6 +349,21 @@ def update_item(self, item, caller=None, source=None, dest=None):
slaveUnit = self._slaveUnit
dataDirection = 'read'

# check for suspend item
if item is self._suspend_item:
if caller != self.get_shortname():
self.logger.debug(f'Suspend item changed to {item()}')
self.set_suspend(item(), by=f'suspend item {item.property.path}')
return

if self.suspended:
if self.suspend_log_update is None or self.suspend_log_update is False: # debug - Nachricht nur 1x ausgeben
self.logger.info('Plugin is suspended, data will not be written')
self.suspend_log_update = True
return
else:
self.suspend_log_update = False

if caller == self.get_fullname():
# self.logger.debug(f'item was changed by the plugin itself - caller:{caller} source:{source} dest:{dest}')
return
Expand All @@ -287,10 +386,10 @@ def update_item(self, item, caller=None, source=None, dest=None):
self._slaveUnitRegisterDependend = True
if self.has_iattr(item.conf, AttrObjectType):
objectType = self.get_iattr_value(item.conf, AttrObjectType)
else:
return
# else:
# self.logger.debug(f'update_item:{item} default modBusObjectTyp: {objectType}')

reg = str(objectType) # Dict-key: HoldingRegister.528.1 *** objectType.regAddr.slaveUnit ***
reg = str(objectType) # Dict-key: HoldingRegister.528.1 *** objectType.regAddr.slaveUnit ***
reg += '.'
reg += str(regAddr)
reg += '.'
Expand All @@ -301,15 +400,20 @@ def update_item(self, item, caller=None, source=None, dest=None):
self.logger.debug(f'update_item:{item} value:{item()} regToWrite: {reg}')
try:
if self._Mclient.connect():
self.logger.info(f"connected to {str(self._Mclient)}")
self.logger.debug(f"connected to {str(self._Mclient)}")
self.connected = True
self.error_count = 0
else:
self.logger.error(f"could not connect to {self._host}:{self._port}")
self.error_count += 1
# Logs an error message based on error count
self.log_error(f"could not connect to {self._host}:{self._port}, connection_attempts: {self.error_count}")
self.connected = False
return

except Exception as e:
self.logger.error(f"connection exception: {str(self._Mclient)} {e}")
self.error_count += 1
# Logs an error message based on error count
self.log_error(f"connection exception: {str(self._Mclient)} {e}, errors: {self.error_count}")
self.connected = False
return

Expand All @@ -327,16 +431,16 @@ def __write_Registers(self, regPara, value):
bo = regPara['byteOrder']
wo = regPara['wordOrder']
dataTypeStr = regPara['dataType']
dataType = ''.join(filter(str.isalpha, dataTypeStr)) # vom dataType die Ziffen entfernen z.B. uint16 = uint
registerCount = 0 # Anzahl der zu schreibenden Register (Words)
dataType = ''.join(filter(str.isalpha, dataTypeStr)) # vom dataType die Ziffen entfernen z.B. uint16 = uint
registerCount = 0 # Anzahl der zu schreibenden Register (Words)

try:
bits = int(''.join(filter(str.isdigit, dataTypeStr))) # bit-Zahl aus aus dataType z.B. uint16 = 16
bits = int(''.join(filter(str.isdigit, dataTypeStr))) # bit-Zahl aus aus dataType z.B. uint16 = 16
except:
bits = 16

if dataType.lower() == 'string':
registerCount = int(bits / 2) # bei string: bits = bytes !! string16 -> 16Byte - 8 registerCount
registerCount = int(bits / 2) # bei string: bits = bytes !! string16 -> 16Byte - 8 registerCount
else:
registerCount = int(bits / 16)

Expand Down Expand Up @@ -376,11 +480,11 @@ def __write_Registers(self, regPara, value):
builder.add_string(value)
elif dataType.lower() == 'bit':
if objectType == 'Coil' or objectType == 'DiscreteInput':
if not isinstance(value, bool): # test is boolean
if not isinstance(value, bool): # test is boolean
self.logger.error(f"Value is not boolean: {value}")
return
else:
if set(value).issubset({'0', '1'}) and bool(value): # test is bit-string '00110101'
if set(value).issubset({'0', '1'}) and bool(value): # test is bit-string '00110101'
builder.add_bits(value)
else:
self.logger.error(f"Value is not a bitstring: {value}")
Expand Down Expand Up @@ -437,7 +541,7 @@ def __read_Registers(self, regPara):
bits = 16

if dataType.lower() == 'string':
registerCount = int(bits / 2) # bei string: bits = bytes !! string16 -> 16Byte - 8 registerCount
registerCount = int(bits / 2) # bei string: bits = bytes !! string16 -> 16Byte - 8 registerCount
else:
registerCount = int(bits / 16)

Expand Down
Loading

0 comments on commit 1b3730f

Please sign in to comment.