Skip to content

Commit

Permalink
feat(plc4py): Modbus write support (#1745)
Browse files Browse the repository at this point in the history
* feat(plc4py): get started on some modbus writing

* feat(plc4py): clean up comparisons of PlcValues

* feat(plc4py): Add some manual modbus tests

* feat(plc4py): Modbus write stuff

* feat(plc4py/modbus): Writing now works

* feat(plc4py/modbus): Time to work on word swapping

* feat(plc4py/modbus): Ignore tests when server isn't available

* feat(plc4py/modbus): Support for word swapping

* feat(plc4py/modbus): Support for word swapping

* feat(plc4py): Fix support for parameters

* feat(plc4py): start looking at error handling

* feat(plc4py/umas): start fixing up umas

* feat(plc4py): rename byte swap enum

* feat(plc4py/modbus): Review new code
  • Loading branch information
hutcheb authored Sep 8, 2024
1 parent 8e36f75 commit fe2ccdb
Show file tree
Hide file tree
Showing 22 changed files with 678 additions and 83 deletions.
7 changes: 7 additions & 0 deletions plc4py/plc4py/api/value/PlcValue.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ def __len__(self):
return len(self.value)
return 1

def __eq__(self, other):
"""Compare PlcValue with another or a native data type"""
if isinstance(other, PlcValue):
return (self.value == other.value) and (self.__class__.__name__ == other.__class__.__name__)
else:
return other == self.value


class PlcResponseCode(Enum):
OK = auto()
Expand Down
4 changes: 4 additions & 0 deletions plc4py/plc4py/drivers/modbus/ModbusConfiguration.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
#
from plc4py.spi.configuration.PlcConfiguration import PlcConfiguration

from plc4py.utils.GenericTypes import ByteOrder


class ModbusConfiguration(PlcConfiguration):
"""
Expand All @@ -39,3 +41,5 @@ def __init__(self, url):
self.port = self.port or 502
# Get the unit identifier from the parameters, default to 1
self.unit_identifier = self.parameters.get("unit_identifier", 1)
# Specifies the byte/word order of the payload
self.byte_order = ByteOrder[self.parameters.get("byte_order", "BIG_ENDIAN")]
12 changes: 11 additions & 1 deletion plc4py/plc4py/drivers/modbus/ModbusConnection.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
PlcRequest,
PlcWriteRequest,
ReadRequestBuilder,
WriteRequestBuilder,
)
from plc4py.api.messages.PlcResponse import (
PlcResponse,
Expand All @@ -39,7 +40,10 @@
from plc4py.drivers.modbus.ModbusTag import ModbusTagBuilder
from plc4py.drivers.PlcDriverLoader import PlcDriverLoader
from plc4py.spi.messages.PlcReader import DefaultPlcReader
from plc4py.spi.messages.PlcRequest import DefaultReadRequestBuilder
from plc4py.spi.messages.PlcRequest import (
DefaultReadRequestBuilder,
DefaultWriteRequestBuilder,
)
from plc4py.spi.messages.PlcWriter import DefaultPlcWriter
from plc4py.spi.transport.Plc4xBaseTransport import Plc4xBaseTransport
from plc4py.spi.transport.TCPTransport import TCPTransport
Expand Down Expand Up @@ -136,6 +140,12 @@ def read_request_builder(self) -> ReadRequestBuilder:
"""
return DefaultReadRequestBuilder(ModbusTagBuilder)

def write_request_builder(self) -> WriteRequestBuilder:
"""
:return: write request builder.
"""
return DefaultWriteRequestBuilder(ModbusTagBuilder)


class ModbusDriver(PlcDriver):
def __init__(self):
Expand Down
137 changes: 125 additions & 12 deletions plc4py/plc4py/drivers/modbus/ModbusDevice.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import logging
from asyncio import Transport
from dataclasses import dataclass, field
from math import ceil
from typing import Dict, List

from bitarray import bitarray
Expand Down Expand Up @@ -53,9 +54,19 @@
from plc4py.spi.generation.ReadBuffer import ReadBuffer, ReadBufferByteBased
from plc4py.spi.generation.WriteBuffer import WriteBufferByteBased
from plc4py.spi.messages.utils.ResponseItem import ResponseItem
from plc4py.spi.values.PlcValues import PlcList, PlcNull
from plc4py.spi.values.PlcValues import PlcList, PlcNull, PlcBOOL
from plc4py.utils.GenericTypes import AtomicInteger, ByteOrder

from plc4py.protocols.modbus.readwrite.ModbusPDUWriteMultipleCoilsRequest import (
ModbusPDUWriteMultipleCoilsRequest,
)
from plc4py.protocols.modbus.readwrite.ModbusPDUWriteMultipleHoldingRegistersRequest import (
ModbusPDUWriteMultipleHoldingRegistersRequestBuilder,
)

from plc4py.drivers.modbus.ModbusTag import ModbusTag
from plc4py.protocols.modbus.readwrite.ModbusDataType import ModbusDataType


@dataclass
class ModbusDevice:
Expand Down Expand Up @@ -85,13 +96,25 @@ async def read(
message_future = loop.create_future()

if isinstance(tag, ModbusTagCoil):
if tag.data_type.value != ModbusDataType.BOOL.value:
raise NotImplementedError(
f"Only BOOL data types can be used with the coil register area"
)
pdu = ModbusPDUReadCoilsRequest(tag.address, tag.quantity)
elif isinstance(tag, ModbusTagDiscreteInput):
if tag.data_type.value != ModbusDataType.BOOL.value:
raise NotImplementedError(
f"Only BOOL data types can be used with the digital input register area"
)
pdu = ModbusPDUReadDiscreteInputsRequest(tag.address, tag.quantity)
elif isinstance(tag, ModbusTagInputRegister):
pdu = ModbusPDUReadInputRegistersRequest(tag.address, tag.quantity)
number_of_registers_per_item = tag.data_type.data_type_size / 2
number_of_registers = ceil(tag.quantity * number_of_registers_per_item)
pdu = ModbusPDUReadInputRegistersRequest(tag.address, number_of_registers)
elif isinstance(tag, ModbusTagHoldingRegister):
pdu = ModbusPDUReadHoldingRegistersRequest(tag.address, tag.quantity)
number_of_registers_per_item = tag.data_type.data_type_size / 2
number_of_registers = ceil(tag.quantity * number_of_registers_per_item)
pdu = ModbusPDUReadHoldingRegistersRequest(tag.address, number_of_registers)
else:
raise NotImplementedError(
"Modbus tag type not implemented " + str(tag.__class__)
Expand Down Expand Up @@ -128,20 +151,36 @@ async def read(
return response

if isinstance(tag, ModbusTagCoil) or isinstance(tag, ModbusTagDiscreteInput):
# As we need to do some funky stuff with the ordering of bits when reading bits
# We aren't using the DataItem code for it

# Normalize the array so we can just read the bits in one by one
a = bitarray()
a.frombytes(bytearray(result.value))
a.bytereverse()
read_buffer = ReadBufferByteBased(bytearray(a), ByteOrder.BIG_ENDIAN)
read_buffer = ReadBufferByteBased(
bytearray(a), self._configuration.byte_order
)

# If it's an array we need to wrap it in a PlcList
quantity = request.tags[request.tag_names[0]].quantity
if quantity == 1:
returned_value = PlcBOOL(read_buffer.read_bit(""))
else:
returned_array = []
for _ in range(quantity):
returned_array.append(PlcBOOL(read_buffer.read_bit("")))
returned_value = PlcList(returned_array)
else:
read_buffer = ReadBufferByteBased(
bytearray(result.value), ByteOrder.BIG_ENDIAN
bytearray(result.value), self._configuration.byte_order
)
returned_value = DataItem.static_parse(
read_buffer,
request.tags[request.tag_names[0]].data_type,
request.tags[request.tag_names[0]].quantity,
True,
)
returned_value = DataItem.static_parse(
read_buffer,
request.tags[request.tag_names[0]].data_type,
request.tags[request.tag_names[0]].quantity,
True,
)

response_item = ResponseItem(PlcResponseCode.OK, returned_value)

Expand All @@ -156,4 +195,78 @@ async def write(
"""
Writes one field from the Modbus Device
"""
pass
if len(request.tags) > 1:
raise NotImplementedError(
"The Modbus driver only supports writing single tags at once"
)
if len(request.tags) == 0:
raise PlcRuntimeException("No tags have been specified to write")
tag = request.tags[request.tag_names[0]]
logging.debug(f"Writing tag {str(tag)} from Modbus Device")

# Create future to be returned when a value is returned
loop = asyncio.get_running_loop()
message_future = loop.create_future()
values = request.values[request.tag_names[0]]
if isinstance(tag, ModbusTagCoil):
pdu = ModbusPDUWriteMultipleCoilsRequest(tag.address, tag.quantity, values)
elif isinstance(tag, ModbusTagDiscreteInput):
raise PlcRuntimeException(
"Modbus doesn't support writing to discrete inputs"
)
elif isinstance(tag, ModbusTagInputRegister):
raise PlcRuntimeException(
"Modbus doesn't support writing to input registers"
)
elif isinstance(tag, ModbusTagHoldingRegister):
values = self._serialize_data_items(tag, values)
quantity = tag.quantity * (tag.data_type.data_type_size / 2)
pdu = ModbusPDUWriteMultipleHoldingRegistersRequestBuilder(
tag.address, quantity, values
).build()
else:
raise NotImplementedError(
"Modbus tag type not implemented " + str(tag.__class__)
)

adu = ModbusTcpADU(
False,
self._transaction_generator.increment(),
self._configuration.unit_identifier,
pdu,
)
write_buffer = WriteBufferByteBased(adu.length_in_bytes(), ByteOrder.BIG_ENDIAN)
adu.serialize(write_buffer)

protocol = transport.protocol
protocol.write_wait_for_response(
write_buffer.get_bytes(),
transport,
adu.transaction_identifier,
message_future,
)

await message_future
result = message_future.result()
if isinstance(result, ModbusPDUError):
response_item = ResponseItem(PlcResponseCode.INVALID_ADDRESS, None)
else:
response_item = ResponseItem(PlcResponseCode.OK, None)
write_response = PlcWriteResponse(
PlcResponseCode.OK, {request.tag_names[0]: response_item}
)
return write_response

def _serialize_data_items(self, tag: ModbusTag, values: PlcValue) -> List[int]:
length = tag.quantity * tag.data_type.data_type_size
write_buffer = WriteBufferByteBased(length, self._configuration.byte_order)

DataItem.static_serialize(
write_buffer,
values,
tag.data_type,
tag.quantity,
True,
self._configuration.byte_order,
)
return list(write_buffer.get_bytes().tobytes())
2 changes: 1 addition & 1 deletion plc4py/plc4py/drivers/modbus/ModbusTag.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ def create(cls, address_string):
)

data_type = (
ModbusDataType(matcher.group("datatype"))
ModbusDataType[matcher.group("datatype")]
if "datatype" in matcher.groupdict()
and matcher.group("datatype") is not None
else cls._DEFAULT_DATA_TYPE
Expand Down
2 changes: 1 addition & 1 deletion plc4py/plc4py/drivers/umas/UmasVariables.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ def build(self) -> UmasVariable:
variable = UmasCustomVariable(
self.tag_name,
data_type,
self.tag_reference.block,
self.block,
self.tag_reference.offset,
children,
)
Expand Down
123 changes: 123 additions & 0 deletions plc4py/plc4py/protocols/umas/readwrite/UmasPDUErrorResponse.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
#

from dataclasses import dataclass

from plc4py.api.exceptions.exceptions import PlcRuntimeException
from plc4py.api.exceptions.exceptions import SerializationException
from plc4py.api.messages.PlcMessage import PlcMessage
from plc4py.protocols.umas.readwrite.UmasPDUItem import UmasPDUItem
from plc4py.spi.generation.ReadBuffer import ReadBuffer
from plc4py.spi.generation.WriteBuffer import WriteBuffer
from plc4py.utils.GenericTypes import ByteOrder
from typing import Any
from typing import ClassVar
from typing import List
import math


@dataclass
class UmasPDUErrorResponse(UmasPDUItem):
block: List[int]
# Arguments.
byte_length: int
# Accessors for discriminator values.
umas_function_key: ClassVar[int] = 0xFD
umas_request_function_key: ClassVar[int] = 0

def serialize_umas_pduitem_child(self, write_buffer: WriteBuffer):
write_buffer.push_context("UmasPDUErrorResponse")

# Array Field (block)
write_buffer.write_simple_array(
self.block, write_buffer.write_unsigned_byte, logical_name="block"
)

write_buffer.pop_context("UmasPDUErrorResponse")

def length_in_bytes(self) -> int:
return int(math.ceil(float(self.length_in_bits() / 8.0)))

def length_in_bits(self) -> int:
length_in_bits: int = super().length_in_bits()
_value: UmasPDUErrorResponse = self

# Array field
if self.block is not None:
length_in_bits += 8 * len(self.block)

return length_in_bits

@staticmethod
def static_parse_builder(
read_buffer: ReadBuffer, umas_request_function_key: int, byte_length: int
):
read_buffer.push_context("UmasPDUErrorResponse")

if isinstance(umas_request_function_key, str):
umas_request_function_key = int(umas_request_function_key)
if isinstance(byte_length, str):
byte_length = int(byte_length)

block: List[Any] = read_buffer.read_array_field(
logical_name="block",
read_function=read_buffer.read_unsigned_byte,
count=byte_length - int(2),
byte_order=ByteOrder.LITTLE_ENDIAN,
umas_request_function_key=umas_request_function_key,
byte_length=byte_length,
)

read_buffer.pop_context("UmasPDUErrorResponse")
# Create the instance
return UmasPDUErrorResponseBuilder(block)

def equals(self, o: object) -> bool:
if self == o:
return True

if not isinstance(o, UmasPDUErrorResponse):
return False

that: UmasPDUErrorResponse = UmasPDUErrorResponse(o)
return (self.block == that.block) and super().equals(that) and True

def hash_code(self) -> int:
return hash(self)

def __str__(self) -> str:
pass
# write_buffer_box_based: WriteBufferBoxBased = WriteBufferBoxBased(True, True)
# try:
# write_buffer_box_based.writeSerializable(self)
# except SerializationException as e:
# raise PlcRuntimeException(e)

# return "\n" + str(write_buffer_box_based.get_box()) + "\n"


@dataclass
class UmasPDUErrorResponseBuilder:
block: List[int]

def build(self, byte_length: int, pairing_key) -> UmasPDUErrorResponse:
umas_pduerror_response: UmasPDUErrorResponse = UmasPDUErrorResponse(
byte_length, pairing_key, self.block
)
return umas_pduerror_response
Loading

0 comments on commit fe2ccdb

Please sign in to comment.