diff --git a/.gitignore b/.gitignore index 176b3b1..ae30bb6 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,6 @@ __pycache__/ *.egg-info/ build/ -dist/ \ No newline at end of file +dist/ + +*.code-workspace diff --git a/README.rst b/README.rst index 7d81a45..ac4eec7 100644 --- a/README.rst +++ b/README.rst @@ -165,20 +165,20 @@ type. TCP ^^^ -The following is how to open and initialize a TCP Device, where the slave ID is set to 1, the IP address of the TCP +The following is how to open and initialize a TCP Device, where the Unit ID is set to 1, the IP address of the TCP device is 127.0.0.1, and the port is 8502:: >>> import sunspec2.modbus.client as client - >>> d = client.SunSpecModbusClientDeviceTCP(slave_id=1, ipaddr='127.0.0.1', ipport=8502) + >>> d = client.SunSpecModbusClientDeviceTCP(unit_id=1, ipaddr='127.0.0.1', ipport=8502) RTU ^^^ -The following to open and initialize a RTU Device, where the slave ID is set to 1, and the name of the serial port is +The following to open and initialize a RTU Device, where the Unit ID is set to 1, and the name of the serial port is COM2:: >>> import sunspec2.modbus.client as client - >>> d = client.SunSpecModbusClientDeviceRTU(slave_id=1, name="COM2") + >>> d = client.SunSpecModbusClientDeviceRTU(unit_id=1, name="COM2") Device Image ^^^^^^^^^^^^ @@ -209,7 +209,7 @@ model ID. The first key is the model ID as an int, the second key is the model n that a device may contain more than one model with the same model ID, the dictionary keys refer to a list of model objects with that ID. Both keys refer to the same model list for a model ID. - >>> d = client.SunSpecModbusClientDeviceTCP(slave_id=1, ipaddr='127.0.0.1', ipport=8502) + >>> d = client.SunSpecModbusClientDeviceTCP(unit_id=1, ipaddr='127.0.0.1', ipport=8502) >>> d.scan() @@ -261,6 +261,81 @@ After assigning the value on the point object, "Ena", write() must be called in consider it a good Modbus practice to read after every write to check if the operation was successful, but it is not required. In this example, we perform a read() after a write(). +Accessing Multiple Unit IDs (Single Connection) +----------------------------------------------- +For devices that support multiple unit IDs through a single connection, you can use the +``scan_units()`` method to discover SunSpec models on multiple units, and then access them through the ``Units`` collection. + +This is particularly useful for devices that only allow one TCP connection but have multiple devices connected +via serial with different unit IDs. + +Scanning Multiple Unit IDs +^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Use ``scan_units(unit_ids)`` to discover SunSpec models on multiple unit IDs: :: + + >>> import sunspec2.modbus.client as client + >>> d = client.SunSpecModbusClientDeviceTCP(unit_id=1, ipaddr='192.168.1.100', ipport=502) + >>> d.connect() + + # Scan multiple units for SunSpec models + >>> d.scan_units([1, 2, 3]) + + # Access models from the default unit (unit_id=1) directly + >>> manufacturer = d.common[0].Mn.value + >>> power = d.inverter[0].W.value + + # Access models from specific units via Units collection + >>> unit1_common = d.Units[1].common[0] + >>> unit2_inverter = d.Units[2].inverter[0] + >>> unit3_meter = d.Units[3].meter[0] + + # Both approaches work for the default unit + >>> assert d.common[0].Mn.value == d.Units[1].common[0].Mn.value + +Reading from Different Unit IDs +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +You can also read raw register data from specific unit IDs using ``read_unit(unit_id, addr, count)``: :: + + # Read raw register data from different units + >>> data1 = d.read(40000, 10) # Default unit (unit_id=1) + >>> data2 = d.read_unit(2, 40000, 10) # Unit ID 2 + >>> data3 = d.read_unit(3, 40000, 10) # Unit ID 3 + +Writing to Different Unit IDs +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Use ``write_unit(unit_id, addr, data)`` to write to a specific unit ID: :: + + >>> # Write to unit_id=2 + >>> d.write_unit(2, 40100, b'\\x00\\x01\\x00\\x02') + + # Write to unit_id=3 + >>> d.write_unit(3, 40100, b'\\x00\\x03\\x00\\x04') + +Example: Multi-Unit Setup +^^^^^^^^^^^^^^^^^^^^^^^^^ +Here's a complete example for accessing multiple units through a single TCP connection: :: + + >>> import sunspec2.modbus.client as client + >>> + >>> # Create a single TCP connection to the device gateway + >>> d = client.SunSpecModbusClientDeviceTCP(unit_id=1, ipaddr='192.168.1.100', ipport=502) + >>> d.connect() + >>> + >>> # Read SunSpec identifier from multiple units + >>> unit1_sunspec = d.read(40000, 2) # Default unit_id=1 + >>> unit2_sunspec = d.read_unit(2, 40000, 2) # Unit ID 2 + >>> unit3_sunspec = d.read_unit(3, 40000, 2) # Unit ID 3 + >>> + >>> print(f"Unit 1 SunSpec ID: {unit1_sunspec}") + >>> print(f"Unit 2 SunSpec ID: {unit2_sunspec}") + >>> print(f"Unit 3 SunSpec ID: {unit3_sunspec}") + >>> + >>> # Close the connection when done + >>> d.close() + +The ``read_unit()`` and ``write_unit()`` methods are available for both TCP and RTU device types, providing consistent +functionality across different connection types. + Additional Information ---------------------- The groups and points in a group are contained in ordered groups and points dictionaries if needed. Repeating groups are @@ -296,7 +371,7 @@ This section will go over the full steps on how to set a volt-var curve. Initialize device, and run device discovery with scan(): :: - >>> d = client.SunSpecModbusClientDeviceRTU(slave_id=1, name="COM2") + >>> d = client.SunSpecModbusClientDeviceRTU(unit_id=1, name="COM2") >>> d.scan() Confirm that model 705 (DERVoltVar) is on the device: :: diff --git a/scripts/suns.py b/scripts/suns.py index 6c3bb86..d916261 100644 --- a/scripts/suns.py +++ b/scripts/suns.py @@ -18,7 +18,7 @@ -o: output mode for data (text, xml) -x: export model description (slang, xml) -t: transport type: tcp or rtu (default: tcp) - -a: modbus slave address (default: 1) + -a: modbus unit identifier (default: 1) -i: ip address to use for modbus tcp (default: localhost) -P: port number for modbus tcp (default: 502) -p: serial port for modbus rtu (default: /dev/ttyUSB0) @@ -46,7 +46,7 @@ help='transport type: rtu, tcp, file [default: tcp]') parser.add_option('-a', metavar=' ', type='int', default=1, - help='modbus slave address [default: 1]') + help='modbus unit identifier [default: 1]') parser.add_option('-i', metavar=' ', default='localhost', help='ip address to use for modbus tcp [default: localhost]') @@ -72,10 +72,10 @@ try: if options.t == 'tcp': - sd = client.SunSpecModbusClientDeviceTCP(slave_id=options.a, ipaddr=options.i, ipport=options.P, + sd = client.SunSpecModbusClientDeviceTCP(unit_id=options.a, ipaddr=options.i, ipport=options.P, timeout=options.T) elif options.t == 'rtu': - sd = client.SunSpecModbusClientDeviceRTU(slave_id=options.a, name=options.p, baudrate=options.b, + sd = client.SunSpecModbusClientDeviceRTU(unit_id=options.a, name=options.p, baudrate=options.b, parity=options.R, timeout=options.T) elif options.t == 'file': sd = file_client.FileClientDevice(filename=options.m) diff --git a/sunspec2/docs/pysunspec.rst b/sunspec2/docs/pysunspec.rst index 10812db..118e7d1 100644 --- a/sunspec2/docs/pysunspec.rst +++ b/sunspec2/docs/pysunspec.rst @@ -146,20 +146,20 @@ type. TCP ^^^ -The following is how to open and initialize a TCP Device, where the slave ID is set to 1, the IP address of the TCP +The following is how to open and initialize a TCP Device, where the Unit ID is set to 1, the IP address of the TCP device is 127.0.0.1, and the port is 8502:: >>> import sunspec2.modbus.client as client - >>> d = client.SunSpecModbusClientDeviceTCP(slave_id=1, ipaddr='127.0.0.1', ipport=8502) + >>> d = client.SunSpecModbusClientDeviceTCP(unit_id=1, ipaddr='127.0.0.1', ipport=8502) RTU ^^^ -The following to open and initialize a RTU Device, where the slave ID is set to 1, and the name of the serial port is +The following to open and initialize a RTU Device, where the Unit ID is set to 1, and the name of the serial port is COM2:: >>> import sunspec2.modbus.client as client - >>> d = client.SunSpecModbusClientDeviceRTU(slave_id=1, name="COM2") + >>> d = client.SunSpecModbusClientDeviceRTU(unit_id=1, name="COM2") Device Image ^^^^^^^^^^^^ @@ -190,7 +190,7 @@ model ID. The first key is the model ID as an int, the second key is the model n that a device may contain more than one model with the same model ID, the dictionary keys refer to a list of model objects with that ID. Both keys refer to the same model list for a model ID. - >>> d = client.SunSpecModbusClientDeviceTCP(slave_id=1, ipaddr='127.0.0.1', ipport=8502) + >>> d = client.SunSpecModbusClientDeviceTCP(unit_id=1, ipaddr='127.0.0.1', ipport=8502) >>> d.scan() @@ -242,6 +242,84 @@ After assigning the value on the point object, "Ena", write() must be called in consider it a good Modbus practice to read after every write to check if the operation was successful, but it is not required. In this example, we perform a read() after a write(). +Accessing Multiple Unit IDs (Single Connection) +----------------------------------------------- +For devices that support multiple unit IDs through a single connection, you can use the +``scan_units()`` method to discover SunSpec models on multiple units, and then access them through the ``Units`` collection. + +This is particularly useful for devices that only allow one TCP connection but have multiple devices connected +via serial with different unit IDs. + +Scanning Multiple Unit IDs +^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Use ``scan_units(unit_ids)`` to discover SunSpec models on multiple unit IDs: :: + + >>> import sunspec2.modbus.client as client + >>> d = client.SunSpecModbusClientDeviceTCP(unit_id=1, ipaddr='192.168.1.100', ipport=502) + >>> d.connect() + + # Scan multiple units for SunSpec models + >>> d.scan_units([1, 2, 3]) + + # Access models from the default unit (unit_id=1) directly + >>> manufacturer = d.common[0].Mn.value + >>> power = d.inverter[0].W.value + + # Access models from specific units via Units collection + >>> unit1_common = d.Units[1].common[0] + >>> unit2_inverter = d.Units[2].inverter[0] + >>> unit3_meter = d.Units[3].meter[0] + + # Both approaches work for the default unit + >>> assert d.common[0].Mn.value == d.Units[1].common[0].Mn.value + +Reading from Different Unit IDs +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +You can also read raw register data from specific unit IDs using ``read_unit(unit_id, addr, count)``: :: + + # Read raw register data from different units + >>> data1 = d.read(40000, 10) # Default unit (unit_id=1) + >>> data2 = d.read_unit(2, 40000, 10) # Unit ID 2 + >>> data3 = d.read_unit(3, 40000, 10) # Unit ID 3 + +Writing to Different Unit IDs +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Use ``write_unit(unit_id, addr, data)`` to write to a specific unit ID: :: + + >>> # Write to unit_id=2 + >>> d.write_unit(2, 40100, b'\\x00\\x01\\x00\\x02') + + # Write to unit_id=3 + >>> d.write_unit(3, 40100, b'\\x00\\x03\\x00\\x04') + +Example: Multi-Unit Setup +^^^^^^^^^^^^^^^^^^^^^^^^^ +Here's a complete example for accessing multiple units through a single TCP connection: :: + + >>> import sunspec2.modbus.client as client + >>> + >>> # Create a single TCP connection to the device gateway + >>> d = client.SunSpecModbusClientDeviceTCP(unit_id=1, ipaddr='192.168.1.100', ipport=502) + >>> d.connect() + >>> + >>> # Scan multiple units for SunSpec models + >>> d.scan_units([1, 2, 3]) + >>> + >>> # Access models from the default unit directly + >>> print(f"Default unit manufacturer: {d.common[0].Mn.value}") + >>> print(f"Default unit power: {d.inverter[0].W.value}") + >>> + >>> # Access models from specific units + >>> print(f"Unit 1 manufacturer: {d.Units[1].common[0].Mn.value}") + >>> print(f"Unit 2 power: {d.Units[2].inverter[0].W.value}") + >>> print(f"Unit 3 energy: {d.Units[3].meter[0].TotWhExp.value}") + >>> + >>> # Close the connection when done + >>> d.close() + +The ``scan_units()``, ``read_unit()`` and ``write_unit()`` methods are available for both TCP and RTU device types, providing consistent +functionality across different connection types. + Additional Information ---------------------- The groups and points in a group are contained in ordered groups and points dictionaries if needed. Repeating groups are @@ -277,7 +355,7 @@ This section will go over the full steps on how to set a volt-var curve. Initialize device, and run device discovery with scan(): :: - >>> d = client.SunSpecModbusClientDeviceRTU(slave_id=1, name="COM2") + >>> d = client.SunSpecModbusClientDeviceRTU(unit_id=1, name="COM2") >>> d.scan() Confirm that model 705 (DERVoltVar) is on the device: :: diff --git a/sunspec2/modbus/client.py b/sunspec2/modbus/client.py index 00eef90..5c0fe3f 100644 --- a/sunspec2/modbus/client.py +++ b/sunspec2/modbus/client.py @@ -22,6 +22,8 @@ import time import uuid +import warnings +from collections import UserDict from sunspec2 import mdef, device, mb import sunspec2.modbus.modbus as modbus_client @@ -185,6 +187,78 @@ def read(self, len=None): SunSpecModbusClientGroup.read(self, len=self.len + 2) +class SunSpecModbusClientUnit(device.Device): + """A device proxy that represents a specific unit ID on a parent device. + + This class acts like a regular Device but delegates all Modbus communication + to the parent device using the specified unit ID. The parent device handles + all connection management. + """ + + def __init__(self, parent_device, unit_id, model_class=SunSpecModbusClientModel): + device.Device.__init__(self, model_class=model_class) + self.parent_device = parent_device + self.unit_id = unit_id + self.did = f"{parent_device.did}_unit_{unit_id}" + + def is_connected(self): + """Check if the parent device is connected.""" + return self.parent_device.is_connected() + + def read(self, addr, count): + """Read from this unit using the parent device's read_unit method.""" + return self.parent_device.read_unit(self.unit_id, addr, count) + + def write(self, addr, data): + """Write to this unit using the parent device's write_unit method.""" + return self.parent_device.write_unit(self.unit_id, addr, data) + + +class SunSpecModbusClientUnitCollection(UserDict): + """A collection that provides access to different unit IDs as device-like objects. + + Units are only available after being scanned with scan_units(). + + Usage: + # Scan units to discover their models + d.scan_units([1, 2, 3]) + + # Access unit models through the Units collection + d.Units[1].common[0].Mn.value # Access unit 1's common model + d.Units[2].DERVoltVar[0].Ena.value # Access unit 2's DERVoltVar model + + # Read/write directly to a unit + data = d.Units[3].read(40000, 10) + d.Units[3].write(40100, data) + + # Accessing unscanned units raises KeyError + d.Units[99] # Raises: KeyError: "Unit 99 has not been scanned. Use scan_units([99]) first." + """ + + def __init__(self, parent_device): + super().__init__() + self.parent_device = parent_device + + def __getitem__(self, unit_id): + """Get a SunSpecModbusClientUnit for the specified unit ID. + + Raises KeyError if the unit has not been scanned yet. + """ + if unit_id not in self.data: + raise KeyError(f"Unit {unit_id} has not been scanned. Use scan_units([{unit_id}]) first.") + return self.data[unit_id] + + def _create_unit(self, unit_id): + """Internal method to create a unit device during scanning.""" + if unit_id not in self.data: + self.data[unit_id] = SunSpecModbusClientUnit( + self.parent_device, + unit_id, + model_class=self.parent_device.model_class + ) + return self.data[unit_id] + + class SunSpecModbusClientDevice(device.Device): def __init__(self, model_class=SunSpecModbusClientModel): device.Device.__init__(self, model_class=model_class) @@ -192,6 +266,7 @@ def __init__(self, model_class=SunSpecModbusClientModel): self.retry_count = 2 self.base_addr_list = [40000, 0, 50000] self.base_addr = None + self.Units = SunSpecModbusClientUnitCollection(self) def connect(self): pass @@ -213,34 +288,122 @@ def read(self, addr, count): def write(self, addr, data): return + # must be overridden by Modbus protocol implementation + def read_unit(self, unit_id, addr, count): + """Read Modbus device registers using a specific unit ID. + + Parameters: + unit_id : + Modbus Unit Identifier to use for this request. + addr : + Starting Modbus address. + count : + Read length in Modbus registers. + Returns: + Byte string containing register contents. + """ + return '' + + # must be overridden by Modbus protocol implementation + def write_unit(self, unit_id, addr, data): + """Write Modbus device registers using a specific unit ID. + + Parameters: + unit_id : + Modbus Unit Identifier to use for this request. + addr : + Starting Modbus address. + data : + Byte string containing register contents. + """ + return + def scan(self, progress=None, delay=None, connect=True, full_model_read=True): """Scan all the models of the physical device and create the corresponding model objects within the device object based on the SunSpec model definitions. + + This method scans the default unit ID and adds models directly to the device. """ self.base_addr = None self.delete_models() - data = '' - error = '' - connected = False + # Use scan_units to scan the default unit ID + # This will populate both the Units collection and the main device + self.scan_units([self.unit_id], progress=progress, delay=delay, + connect=connect, full_model_read=full_model_read) + + def scan_units(self, unit_ids, progress=None, delay=None, connect=True, full_model_read=True): + """Scan multiple unit IDs and create corresponding unit objects with their models. + This method scans each specified unit ID for SunSpec models and creates + SunSpecModbusClientUnit objects that can be accessed via the Units collection. + + Parameters: + unit_ids : + List of Modbus Unit Identifiers to scan. + progress : + Progress callback function. + delay : + Delay between operations in seconds. + connect : + Whether to connect/disconnect automatically. + full_model_read : + Whether to perform full model reads during scan. + + Example: + # Scan units 1, 2, and 3 + d.scan_units([1, 2, 3]) + + # Access models from different units + unit1_common = d.Units[1].common[0] + unit2_inverter = d.Units[2].inverter[0] + """ + if not isinstance(unit_ids, (list, tuple)): + unit_ids = [unit_ids] + + connected = False if connect: self.connect() connected = True - if delay is not None: - time.sleep(delay) + try: + for unit_id in unit_ids: + if progress is not None: + cont = progress(f'Scanning unit {unit_id}') + if not cont: + break + + self._scan_single_unit(unit_id, progress, delay, full_model_read) + + finally: + if connected: + self.disconnect() + + def _scan_single_unit(self, unit_id, progress=None, delay=None, full_model_read=True): + """Scan a single unit ID and populate its models.""" + base_addr = None + data = '' + error = '' + + # Get or create the unit device for this unit ID + unit_device = self.Units._create_unit(unit_id) + + # Clean up any existing models in the unit device before scanning + unit_device.delete_models() + + if delay is not None: + time.sleep(delay) error_dict = {} - if self.base_addr is None: + if base_addr is None: for addr in self.base_addr_list: error_dict[addr] = '' try: - data = self.read(addr, 3) + data = self.read_unit(unit_id, addr, 3) if data: if data[:4] == b'SunS': - self.base_addr = addr + base_addr = addr break else: error_dict[addr] = 'Device responded - not SunSpec register map' @@ -258,40 +421,45 @@ def scan(self, progress=None, delay=None, connect=True, full_model_read=True): if delay is not None: time.sleep(delay) - error = 'Error scanning SunSpec base addresses. \n' + error = f'Error scanning SunSpec base addresses for unit {unit_id}. \n' for k, v in error_dict.items(): error += 'Base address %s error = %s. \n' % (k, v) - if self.base_addr is not None: + if base_addr is not None: model_id_data = data[4:6] model_id = mb.data_to_u16(model_id_data) - addr = self.base_addr + 2 + addr = base_addr + 2 mid = 0 while model_id != mb.SUNS_END_MODEL_ID: # read model and model len separately due to some devices not supplying # count for the end model id - model_len_data = self.read(addr + 1, 1) + model_len_data = self.read_unit(unit_id, addr + 1, 1) if model_len_data and len(model_len_data) == 2: if progress is not None: - cont = progress('Scanning model %s' % model_id) + cont = progress(f'Scanning unit {unit_id} model {model_id}') if not cont: raise SunSpecModbusClientError('Device scan terminated') model_len = mb.data_to_u16(model_len_data) - # read model data - ### model_data = self.read(addr, model_len + 2) + # read model data and add to the unit device model_data = model_id_data + model_len_data model = self.model_class(model_id=model_id, model_addr=addr, model_len=model_len, data=model_data, - mb_device=self) + mb_device=unit_device) if full_model_read and model.model_def: model.read() - model.mid = '%s_%s' % (self.did, mid) + model.mid = f'{unit_device.did}_{mid}' mid += 1 - self.add_model(model) + unit_device.add_model(model) + + # If this is the default unit, also add the same model to the main device + if unit_id == self.unit_id: + # Add the same model instance to the main device + # This ensures both the unit and main device share the exact same data + self.add_model(model) addr += model_len + 2 - model_id_data = self.read(addr, 1) + model_id_data = self.read_unit(unit_id, addr, 1) if model_id_data and len(model_id_data) == 2: model_id = mb.data_to_u16(model_id_data) else: @@ -305,16 +473,52 @@ def scan(self, progress=None, delay=None, connect=True, full_model_read=True): else: raise SunSpecModbusClientError(error) - if connected: - self.disconnect() - - class SunSpecModbusClientDeviceTCP(SunSpecModbusClientDevice): - def __init__(self, slave_id=1, ipaddr='127.0.0.1', ipport=502, timeout=None, ctx=None, trace_func=None, + """Provides access to a Modbus RTU device. + Parameters: + unit_id : + Modbus Unit Identifier. + ipaddr : + IP address of the Modbus TCP device. + ipport : + Port number for Modbus TCP. Default is 502 if not specified. + timeout : + Modbus request timeout in seconds. Fractional seconds are permitted + such as .5. + ctx : + Context variable to be used by the object creator. Not used by the + modbus module. + trace_func : + Trace function to use for detailed logging. No detailed logging is + perform is a trace function is not supplied. + max_count : + Maximum register count for a single Modbus request. + max_write_count : + Maximum register count for a single Modbus write request. + model_class : + Model class to use for creating models in the device. Default is + :class:`sunspec2.modbus.client.SunSpecModbusClientModel`. + slave_id : [DEPRECATED] Use unit_id instead. + Raises: + SunSpecModbusClientError: Raised for any general modbus client error. + SunSpecModbusClientTimeoutError: Raised for a modbus client request timeout. + SunSpecModbusClientException: Raised for an exception response to a modbus + client request. + """ + + def __init__(self, unit_id=1, ipaddr='127.0.0.1', ipport=502, timeout=None, ctx=None, trace_func=None, max_count=modbus_client.REQ_COUNT_MAX, max_write_count=modbus_client.REQ_WRITE_COUNT_MAX, - model_class=SunSpecModbusClientModel): + model_class=SunSpecModbusClientModel, slave_id=None): SunSpecModbusClientDevice.__init__(self, model_class=model_class) - self.slave_id = slave_id + if unit_id == 1 and slave_id is not None: + unit_id = slave_id + if slave_id is not None: + warnings.warn( + "The 'slave_id' parameter is deprecated and will be removed in a future version. Use 'unit_id' instead.", + DeprecationWarning, + stacklevel=2 + ) + self.unit_id = unit_id self.ipaddr = ipaddr self.ipport = ipport self.timeout = timeout @@ -324,7 +528,7 @@ def __init__(self, slave_id=1, ipaddr='127.0.0.1', ipport=502, timeout=None, ctx self.max_count = max_count self.max_write_count = max_write_count - self.client = modbus_client.ModbusClientTCP(slave_id=slave_id, ipaddr=ipaddr, ipport=ipport, timeout=timeout, + self.client = modbus_client.ModbusClientTCP(unit_id=unit_id, ipaddr=ipaddr, ipport=ipport, timeout=timeout, ctx=ctx, trace_func=trace_func, max_count=modbus_client.REQ_COUNT_MAX, max_write_count=modbus_client.REQ_WRITE_COUNT_MAX) @@ -342,17 +546,49 @@ def is_connected(self): return self.client.is_connected() def read(self, addr, count, op=modbus_client.FUNC_READ_HOLDING): - return self.client.read(addr, count, op) + """Read Modbus device registers using the default unit ID.""" + return self.read_unit(self.unit_id, addr, count, op) def write(self, addr, data): - return self.client.write(addr, data) + """Write Modbus device registers using the default unit ID.""" + return self.write_unit(self.unit_id, addr, data) + + def read_unit(self, unit_id, addr, count, op=modbus_client.FUNC_READ_HOLDING): + """Read Modbus device registers using a specific unit ID. + + Parameters: + unit_id : + Modbus Unit Identifier to use for this request. + addr : + Starting Modbus address. + count : + Read length in Modbus registers. + op : + Modbus function code for request. + Returns: + Byte string containing register contents. + """ + return self.client.read(addr, count, op, unit_id=unit_id) + + def write_unit(self, unit_id, addr, data): + """Write Modbus device registers using a specific unit ID. + + Parameters: + unit_id : + Modbus Unit Identifier to use for this request. + addr : + Starting Modbus address. + data : + Byte string containing register contents. + """ + return self.client.write(addr, data, unit_id=unit_id) class SunSpecModbusClientDeviceRTU(SunSpecModbusClientDevice): """Provides access to a Modbus RTU device. Parameters: - slave_id : - Modbus slave id. + unit_id : + Modbus Unit Identifier. name : Name of the serial port such as 'com4' or '/dev/ttyUSB0'. baudrate : @@ -373,6 +609,7 @@ class SunSpecModbusClientDeviceRTU(SunSpecModbusClientDevice): perform is a trace function is not supplied. max_count : Maximum register count for a single Modbus request. + slave_id : [DEPRECATED] Use unit_id instead. Raises: SunSpecModbusClientError: Raised for any general modbus client error. SunSpecModbusClientTimeoutError: Raised for a modbus client request timeout. @@ -380,12 +617,26 @@ class SunSpecModbusClientDeviceRTU(SunSpecModbusClientDevice): client request. """ - def __init__(self, slave_id, name, baudrate=None, parity=None, timeout=None, ctx=None, trace_func=None, + def __init__(self, unit_id=None, name=None, baudrate=None, parity=None, timeout=None, ctx=None, trace_func=None, max_count=modbus_client.REQ_COUNT_MAX, max_write_count=modbus_client.REQ_WRITE_COUNT_MAX, - model_class=SunSpecModbusClientModel): + model_class=SunSpecModbusClientModel, slave_id=None): # test if this super class init is needed SunSpecModbusClientDevice.__init__(self, model_class=model_class) - self.slave_id = slave_id + # Backward compatibility for slave_id + if unit_id is not None: + self.unit_id = unit_id + elif slave_id is not None: + self.unit_id = slave_id + else: + raise ValueError("unit_id must be provided") + if name is None: + raise ValueError("name must be provided") + if slave_id is not None: + warnings.warn( + "The 'slave_id' parameter is deprecated and will be removed in a future version. Use 'unit_id' instead.", + DeprecationWarning, + stacklevel=2 + ) self.name = name self.client = None self.ctx = ctx @@ -396,7 +647,7 @@ def __init__(self, slave_id, name, baudrate=None, parity=None, timeout=None, ctx self.client = modbus_client.modbus_rtu_client(name, baudrate, parity, timeout) if self.client is None: raise SunSpecModbusClientError('No modbus rtu client set for device') - self.client.add_device(self.slave_id, self) + self.client.add_device(self.unit_id, self) def open(self): self.client.open() @@ -406,11 +657,22 @@ def close(self): """ if self.client: - self.client.remove_device(self.slave_id) + self.client.remove_device(self.unit_id) def read(self, addr, count, op=modbus_client.FUNC_READ_HOLDING): - """Read Modbus device registers. + """Read Modbus device registers using the default unit ID.""" + return self.read_unit(self.unit_id, addr, count, op) + + def write(self, addr, data): + """Write Modbus device registers using the default unit ID.""" + return self.write_unit(self.unit_id, addr, data) + + def read_unit(self, unit_id, addr, count, op=modbus_client.FUNC_READ_HOLDING): + """Read Modbus device registers using a specific unit ID. + Parameters: + unit_id : + Modbus Unit Identifier to use for this request. addr : Starting Modbus address. count : @@ -420,16 +682,17 @@ def read(self, addr, count, op=modbus_client.FUNC_READ_HOLDING): Returns: Byte string containing register contents. """ + return self.client.read(unit_id, addr, count, op=op, max_count=self.max_count) - return self.client.read(self.slave_id, addr, count, op=op, max_count=self.max_count) + def write_unit(self, unit_id, addr, data): + """Write Modbus device registers using a specific unit ID. - def write(self, addr, data): - """Write Modbus device registers. Parameters: + unit_id : + Modbus Unit Identifier to use for this request. addr : Starting Modbus address. - count : + data : Byte string containing register contents. """ - - return self.client.write(self.slave_id, addr, data, max_write_count=self.max_write_count) + return self.client.write(unit_id, addr, data, max_write_count=self.max_write_count) diff --git a/sunspec2/modbus/modbus.py b/sunspec2/modbus/modbus.py index 200dad8..552dfed 100644 --- a/sunspec2/modbus/modbus.py +++ b/sunspec2/modbus/modbus.py @@ -223,43 +223,43 @@ def close(self): except Exception as e: raise ModbusClientError('Serial close error: %s' % str(e)) - def add_device(self, slave_id, device): + def add_device(self, unit_id, device): """Add a device to the RTU client. Parameters: - slave_id : - Modbus slave id. + unit_id : + Modbus Unit Identifier. device : Device to add to the client. """ - self.devices[slave_id] = device + self.devices[unit_id] = device - def remove_device(self, slave_id): + def remove_device(self, unit_id): """Remove a device from the RTU client. Parameters: - slave_id : - Modbus slave id. + unit_id : + Modbus Unit Identifier. """ - if self.devices.get(slave_id): - del self.devices[slave_id] + if self.devices.get(unit_id): + del self.devices[unit_id] # if no more devices using the client interface, close and remove the client if len(self.devices) == 0: self.close() modbus_rtu_client_remove(self.name) - def _read(self, slave_id, addr, count, op=FUNC_READ_HOLDING): + def _read(self, unit_id, addr, count, op=FUNC_READ_HOLDING): resp = bytearray() len_remaining = 5 len_found = False except_code = None - req = struct.pack('>BBHH', int(slave_id), op, int(addr), int(count)) + req = struct.pack('>BBHH', int(unit_id), op, int(addr), int(count)) req += struct.pack('>H', computeCRC(req)) if self.trace_func: - # s = '{}:{}[addr={}] ->'.format(self.name, str(slave_id), addr) + # s = '{}:{}[addr={}] ->'.format(self.name, str(unit_id), addr) s = '> ' for c in req: s += '%02X' % c @@ -289,7 +289,7 @@ def _read(self, slave_id, addr, count, op=FUNC_READ_HOLDING): raise ModbusClientTimeout('Response timeout') if self.trace_func: - # s = '{}:{}[addr={}] <--'.format(self.name, str(slave_id), addr) + # s = '{}:{}[addr={}] <--'.format(self.name, str(unit_id), addr) s = '< ' for c in resp: s += '%02X' % c @@ -304,11 +304,11 @@ def _read(self, slave_id, addr, count, op=FUNC_READ_HOLDING): return resp[3:-2] - def read(self, slave_id, addr, count, op=FUNC_READ_HOLDING, max_count=REQ_COUNT_MAX): + def read(self, unit_id, addr, count, op=FUNC_READ_HOLDING, max_count=REQ_COUNT_MAX): """ Parameters: - slave_id : - Modbus slave id. + unit_id : + Modbus Unit Identifier. addr : Starting Modbus address. count : @@ -330,7 +330,7 @@ def read(self, slave_id, addr, count, op=FUNC_READ_HOLDING, max_count=REQ_COUNT_ read_count = max_count else: read_count = count - data = self._read(slave_id, addr + read_offset, read_count, op=op) + data = self._read(unit_id, addr + read_offset, read_count, op=op) if data: resp += data count -= read_count @@ -342,7 +342,7 @@ def read(self, slave_id, addr, count, op=FUNC_READ_HOLDING, max_count=REQ_COUNT_ return bytes(resp) - def _write(self, slave_id, addr, data): + def _write(self, unit_id, addr, data): resp = bytearray() len_remaining = 5 len_found = False @@ -351,13 +351,13 @@ def _write(self, slave_id, addr, data): len_data = len(data) count = int(len_data/2) - req = struct.pack('>BBHHB', int(slave_id), func, int(addr), count, len_data) + req = struct.pack('>BBHHB', int(unit_id), func, int(addr), count, len_data) req += data req += struct.pack('>H', computeCRC(req)) if self.trace_func: - # s = '{}:{}[addr={}] ->'.format(self.name, str(slave_id), addr) + # s = '{}:{}[addr={}] ->'.format(self.name, str(unit_id), addr) s = '> ' for c in req: s += '%02X' % c @@ -388,7 +388,7 @@ def _write(self, slave_id, addr, data): raise ModbusClientTimeout('Response timeout') if self.trace_func: - # s = '{}:{}[addr={}] <--'.format(self.name, str(slave_id), addr) + # s = '{}:{}[addr={}] <--'.format(self.name, str(unit_id), addr) s = '< ' for c in resp: s += '%02X' % c @@ -401,23 +401,23 @@ def _write(self, slave_id, addr, data): if except_code: raise ModbusClientException('Modbus exception: %d' % except_code) else: - resp_slave_id, resp_func, resp_addr, resp_count, resp_crc = struct.unpack('>BBHHH', bytes(resp)) - if resp_slave_id != slave_id or resp_func != func or resp_addr != addr or resp_count != count: + resp_unit_id, resp_func, resp_addr, resp_count, resp_crc = struct.unpack('>BBHHH', bytes(resp)) + if resp_unit_id != unit_id or resp_func != func or resp_addr != addr or resp_count != count: raise ModbusClientError('Modbus response format error') - def _write_single(self, slave_id, addr, data): + def _write_single(self, unit_id, addr, data): resp = bytearray() len_remaining = 5 len_found = False except_code = None func = FUNC_WRITE_SINGLE - req = struct.pack('>BBH', int(slave_id), func, int(addr)) + req = struct.pack('>BBH', int(unit_id), func, int(addr)) req += data req += struct.pack('>H', computeCRC(req)) if self.trace_func: - # s = '{}:{}[addr={}] ->'.format(self.name, str(slave_id), addr) + # s = '{}:{}[addr={}] ->'.format(self.name, str(unit_id), addr) s = '> ' for c in req: s += '%02X' % c @@ -448,7 +448,7 @@ def _write_single(self, slave_id, addr, data): raise ModbusClientTimeout('Response timeout') if self.trace_func: - # s = '{}:{}[addr={}] <--'.format(self.name, str(slave_id), addr) + # s = '{}:{}[addr={}] <--'.format(self.name, str(unit_id), addr) s = '< ' for c in resp: s += '%02X' % c @@ -461,16 +461,16 @@ def _write_single(self, slave_id, addr, data): if except_code: raise ModbusClientException('Modbus exception: %d' % except_code) else: - resp_slave_id, resp_func, resp_addr, resp_data, _ = struct.unpack('>BBHHH', bytes(resp)) - if (resp_slave_id != slave_id or resp_func != func or resp_addr != addr or + resp_unit_id, resp_func, resp_addr, resp_data, _ = struct.unpack('>BBHHH', bytes(resp)) + if (resp_unit_id != unit_id or resp_func != func or resp_addr != addr or resp_data != int.from_bytes(data, 'big')): raise ModbusClientError('Modbus response error') - def write(self, slave_id, addr, data, max_write_count=REQ_WRITE_COUNT_MAX): + def write(self, unit_id, addr, data, max_write_count=REQ_WRITE_COUNT_MAX): """ Parameters: - slave_id : - Modbus slave id. + unit_id : + Modbus Unit Identifier. addr : Starting Modbus address. data : @@ -484,7 +484,7 @@ def write(self, slave_id, addr, data, max_write_count=REQ_WRITE_COUNT_MAX): if self.serial is not None: if count == 1: # If only one register, use Func Code 0x06 - self._write_single(slave_id, addr, data) + self._write_single(unit_id, addr, data) else: while count > 0: if count > max_write_count: @@ -493,7 +493,7 @@ def write(self, slave_id, addr, data, max_write_count=REQ_WRITE_COUNT_MAX): write_count = count start = int(write_offset * 2) end = int((write_offset + write_count) * 2) - self._write(slave_id, addr + write_offset, data[start:end]) + self._write(unit_id, addr + write_offset, data[start:end]) count -= write_count write_offset += write_count else: @@ -501,9 +501,9 @@ def write(self, slave_id, addr, data, max_write_count=REQ_WRITE_COUNT_MAX): class ModbusClientTCP: - def __init__(self, slave_id=1, ipaddr='127.0.0.1', ipport=502, timeout=None, ctx=None, trace_func=None, + def __init__(self, unit_id=1, ipaddr='127.0.0.1', ipport=502, timeout=None, ctx=None, trace_func=None, max_count=REQ_COUNT_MAX, max_write_count=REQ_WRITE_COUNT_MAX): - self.slave_id = slave_id + self.unit_id = unit_id self.ipaddr = ipaddr self.ipport = ipport self.timeout = timeout @@ -557,16 +557,18 @@ def disconnect(self): def is_connected(self): return self.socket - def _read(self, addr, count, op=FUNC_READ_HOLDING): + def _read(self, addr, count, op=FUNC_READ_HOLDING, unit_id=None): resp = bytearray() len_remaining = TCP_HDR_LEN + TCP_RESP_MIN_LEN len_found = False except_code = None - req = struct.pack('>HHHBBHH', 0, 0, TCP_READ_REQ_LEN, int(self.slave_id), op, int(addr), int(count)) + # Use provided unit_id or fall back to instance unit_id + effective_unit_id = unit_id if unit_id is not None else self.unit_id + req = struct.pack('>HHHBBHH', 0, 0, TCP_READ_REQ_LEN, int(effective_unit_id), op, int(addr), int(count)) if self.trace_func: - # s = '%s:%s:%s[addr=%s] ->' % (self.ipaddr, str(self.ipport), str(self.slave_id), addr) + # s = '%s:%s:%s[addr=%s] ->' % (self.ipaddr, str(self.ipport), str(effective_unit_id), addr) s = '> ' for c in req: s += '%02X' % c @@ -593,7 +595,7 @@ def _read(self, addr, count, op=FUNC_READ_HOLDING): except_code = resp[TCP_HDR_LEN + 2] if self.trace_func: - # s = '%s:%s:%s[addr=%s] <--' % (self.ipaddr, str(self.ipport), str(self.slave_id), addr) + # s = '%s:%s:%s[addr=%s] <--' % (self.ipaddr, str(self.ipport), str(self.unit_id), addr) s ='< ' for c in resp: s += '%02X' % c @@ -604,7 +606,7 @@ def _read(self, addr, count, op=FUNC_READ_HOLDING): return resp[(TCP_HDR_LEN + 3):] - def read(self, addr, count, op=FUNC_READ_HOLDING): + def read(self, addr, count, op=FUNC_READ_HOLDING, unit_id=None): """ Read Modbus device registers. If no connection exists to the destination, one is created and disconnected at the end of the request. @@ -619,6 +621,10 @@ def read(self, addr, count, op=FUNC_READ_HOLDING): op : Modbus function code for request. + unit_id : + Optional unit ID to use for this request. If not provided, + uses the instance's default unit_id. + Returns: Byte string containing register contents. @@ -638,7 +644,7 @@ def read(self, addr, count, op=FUNC_READ_HOLDING): read_count = self.max_count else: read_count = count - data = self._read(addr + read_offset, read_count, op=op) + data = self._read(addr + read_offset, read_count, op=op, unit_id=unit_id) if data: resp += data @@ -654,7 +660,7 @@ def read(self, addr, count, op=FUNC_READ_HOLDING): return bytes(resp) - def _write(self, addr, data): + def _write(self, addr, data, unit_id=None): resp = bytearray() len_remaining = TCP_HDR_LEN + TCP_RESP_MIN_LEN len_found = False @@ -663,12 +669,14 @@ def _write(self, addr, data): write_len = len(data) write_count = int(write_len/2) - req = struct.pack('>HHHBBHHB', 0, 0, TCP_WRITE_MULT_REQ_LEN + write_len, int(self.slave_id), + # Use provided unit_id or fall back to instance unit_id + effective_unit_id = unit_id if unit_id is not None else self.unit_id + req = struct.pack('>HHHBBHHB', 0, 0, TCP_WRITE_MULT_REQ_LEN + write_len, int(effective_unit_id), func, int(addr), write_count, write_len) req += data if self.trace_func: - # s = '%s:%s:%s[addr=%s] ->' % (self.ipaddr, str(self.ipport), str(self.slave_id), addr) + # s = '%s:%s:%s[addr=%s] ->' % (self.ipaddr, str(self.ipport), str(effective_unit_id), addr) s = '> ' for c in req: s += '%02X' % c @@ -695,7 +703,7 @@ def _write(self, addr, data): except_code = resp[TCP_HDR_LEN + 2] if self.trace_func: - # s = '%s:%s:%s[addr=%s] <--' % (self.ipaddr, str(self.ipport), str(self.slave_id), addr) + # s = '%s:%s:%s[addr=%s] <--' % (self.ipaddr, str(self.ipport), str(self.unit_id), addr) s = '< ' for c in resp: s += '%02X' % c @@ -704,7 +712,7 @@ def _write(self, addr, data): if except_code: raise ModbusClientException('Modbus exception: %d' % except_code) - def _write_single(self, addr, data): + def _write_single(self, addr, data, unit_id=None): """ Write Single Modbus device register """ @@ -716,12 +724,14 @@ def _write_single(self, addr, data): func = FUNC_WRITE_SINGLE write_len = len(data) - req = struct.pack('>HHHBBH', 0, 0, TCP_WRITE_SINGLE_REQ_LEN + write_len, int(self.slave_id), + # Use provided unit_id or fall back to instance unit_id + effective_unit_id = unit_id if unit_id is not None else self.unit_id + req = struct.pack('>HHHBBH', 0, 0, TCP_WRITE_SINGLE_REQ_LEN + write_len, int(effective_unit_id), func, int(addr)) req += data if self.trace_func: - # s = '%s:%s:%s[addr=%s] ->' % (self.ipaddr, str(self.ipport), str(self.slave_id), addr) + # s = '%s:%s:%s[addr=%s] ->' % (self.ipaddr, str(self.ipport), str(effective_unit_id), addr) s = '> ' for c in req: s += '%02X' % c @@ -748,7 +758,7 @@ def _write_single(self, addr, data): except_code = resp[TCP_HDR_LEN + 2] if self.trace_func: - # s = '%s:%s:%s[addr=%s] <--' % (self.ipaddr, str(self.ipport), str(self.slave_id), addr) + # s = '%s:%s:%s[addr=%s] <--' % (self.ipaddr, str(self.ipport), str(self.unit_id), addr) s = '< ' for c in resp: s += '%02X' % c @@ -757,7 +767,7 @@ def _write_single(self, addr, data): if except_code: raise ModbusClientException('Modbus exception: %d' % except_code) - def write(self, addr, data): + def write(self, addr, data, unit_id=None): """ Write Modbus device registers. If no connection exists to the destination, one is created and disconnected at the end of the request. @@ -768,6 +778,10 @@ def write(self, addr, data): data : Byte string containing register contents. + + unit_id : + Optional unit ID to use for this request. If not provided, + uses the instance's default unit_id. """ write_offset = 0 local_connect = False @@ -779,7 +793,7 @@ def write(self, addr, data): try: if count == 1: - self._write_single(addr, data) # If only one register, use Func Code 0x06 + self._write_single(addr, data, unit_id=unit_id) # If only one register, use Func Code 0x06 else: while count > 0: if count > self.max_write_count: @@ -788,7 +802,7 @@ def write(self, addr, data): write_count = count start = write_offset * 2 end = int((write_offset + write_count) * 2) - self._write(addr + write_offset, data[start:end]) + self._write(addr + write_offset, data[start:end], unit_id=unit_id) count -= write_count write_offset += write_count finally: diff --git a/sunspec2/tests/test_modbus_client.py b/sunspec2/tests/test_modbus_client.py index c0fdf4c..c52345d 100644 --- a/sunspec2/tests/test_modbus_client.py +++ b/sunspec2/tests/test_modbus_client.py @@ -60,7 +60,7 @@ def test_read(self, monkeypatch): assert not d_tcp.common[0].SN.dirty # rtu - d_rtu = client.SunSpecModbusClientDeviceRTU(slave_id=1, name="COM2") + d_rtu = client.SunSpecModbusClientDeviceRTU(unit_id=1, name="COM2") rtu_buffer = [b'\x01\x03\x06Su', b'nS\x00\x01\x8d\xe4', b'\x01\x03\x02\x00B', @@ -111,7 +111,7 @@ def test_write(self, monkeypatch): monkeypatch.setattr(client.SunSpecModbusClientDeviceTCP, 'disconnect', MockSocket.mock_tcp_connect) # tcp - d_tcp = client.SunSpecModbusClientDeviceTCP(slave_id=1, ipaddr='127.0.0.1', ipport=8502) + d_tcp = client.SunSpecModbusClientDeviceTCP(unit_id=1, ipaddr='127.0.0.1', ipport=8502) # simulate a sequence of exchanges with the device tcp_buffer = [b'\x00\x00\x00\x00\x00\t\x01\x03\x06', # Readback first 6 registers b'SunS\x00\x01', # SunSpec ID + common model header (ID = 1) @@ -167,7 +167,7 @@ def test_write(self, monkeypatch): assert not d_tcp.common[0].SN.dirty # rtu - d_rtu = client.SunSpecModbusClientDeviceRTU(slave_id=1, name="COM2") + d_rtu = client.SunSpecModbusClientDeviceRTU(unit_id=1, name="COM2") rtu_buffer = [ # simulate a sequence of responses from the device scan b'\x01\x03\x06Su', b'nS\x00\x01\x8d\xe4', # Response: SunSpec ID + common model header (ID = 1) + CRC @@ -206,7 +206,7 @@ def test_write(self, monkeypatch): d_rtu.client.serial._set_buffer(rtu_read_buffer) d_rtu.common[0].write() - # 0x01: Slave Address (1). + # 0x01: Unit Address (1). # 0x03: Function Code (Read Holding Registers). T # 0x02: Byte Count (2). This tells you that the response contains 2 bytes of data. # 0x0002: Data (2 in decimal). This is the actual data read from the holding registers. DA = 2 @@ -228,7 +228,7 @@ def test_get_text(self, monkeypatch): monkeypatch.setattr(client.SunSpecModbusClientDeviceTCP, 'disconnect', MockSocket.mock_tcp_connect) # tcp - d_tcp = client.SunSpecModbusClientDeviceTCP(slave_id=1, ipaddr='127.0.0.1', ipport=8502) + d_tcp = client.SunSpecModbusClientDeviceTCP(unit_id=1, ipaddr='127.0.0.1', ipport=8502) tcp_buffer = [b'\x00\x00\x00\x00\x00\t\x01\x03\x06', b'SunS\x00\x01', b'\x00\x00\x00\x00\x00\x05\x01\x03\x02', @@ -258,7 +258,7 @@ def test_get_text(self, monkeypatch): expected_output = ' SN sn-123456789\n' assert d_tcp.common[0].SN.get_text() == expected_output - d_rtu = client.SunSpecModbusClientDeviceRTU(slave_id=1, name="COM2") + d_rtu = client.SunSpecModbusClientDeviceRTU(unit_id=1, name="COM2") rtu_buffer = [b'\x01\x03\x06Su', b'nS\x00\x01\x8d\xe4', b'\x01\x03\x02\x00B', @@ -297,7 +297,7 @@ def test_read(self, monkeypatch): monkeypatch.setattr(client.SunSpecModbusClientDeviceTCP, 'disconnect', MockSocket.mock_tcp_connect) # tcp - d_tcp = client.SunSpecModbusClientDeviceTCP(slave_id=1, ipaddr='127.0.0.1', ipport=8502) + d_tcp = client.SunSpecModbusClientDeviceTCP(unit_id=1, ipaddr='127.0.0.1', ipport=8502) tcp_buffer = [b'\x00\x00\x00\x00\x00\t\x01\x03\x06', b'SunS\x00\x01', b'\x00\x00\x00\x00\x00\x05\x01\x03\x02', @@ -413,7 +413,7 @@ def test_write(self, monkeypatch): monkeypatch.setattr(client.SunSpecModbusClientDeviceTCP, 'disconnect', MockSocket.mock_tcp_connect) # tcp - d_tcp = client.SunSpecModbusClientDeviceTCP(slave_id=1, ipaddr='127.0.0.1', ipport=8502) + d_tcp = client.SunSpecModbusClientDeviceTCP(unit_id=1, ipaddr='127.0.0.1', ipport=8502) tcp_buffer = [b'\x00\x00\x00\x00\x00\t\x01\x03\x06', b'SunS\x00\x01', b'\x00\x00\x00\x00\x00\x05\x01\x03\x02', @@ -483,7 +483,7 @@ def test_write(self, monkeypatch): assert not d_tcp.common[0].Vr.dirty # rtu - d_rtu = client.SunSpecModbusClientDeviceRTU(slave_id=1, name="COM2") + d_rtu = client.SunSpecModbusClientDeviceRTU(unit_id=1, name="COM2") rtu_buffer = [ b'\x01\x03\x06Su', b'nS\x00\x01\x8d\xe4', @@ -550,7 +550,7 @@ def test_get_text(self, monkeypatch): monkeypatch.setattr(client.SunSpecModbusClientDeviceTCP, 'disconnect', MockSocket.mock_tcp_connect) # tcp - d_tcp = client.SunSpecModbusClientDeviceTCP(slave_id=1, ipaddr='127.0.0.1', ipport=8502) + d_tcp = client.SunSpecModbusClientDeviceTCP(unit_id=1, ipaddr='127.0.0.1', ipport=8502) tcp_buffer = [b'\x00\x00\x00\x00\x00\t\x01\x03\x06', b'SunS\x00\x01', b'\x00\x00\x00\x00\x00\x05\x01\x03\x02', @@ -588,7 +588,7 @@ def test_get_text(self, monkeypatch): assert d_tcp.common[0].get_text() == expected_output # rtu - d_rtu = client.SunSpecModbusClientDeviceRTU(slave_id=1, name="COM2") + d_rtu = client.SunSpecModbusClientDeviceRTU(unit_id=1, name="COM2") rtu_buffer = [b'\x01\x03\x06Su', b'nS\x00\x01\x8d\xe4', b'\x01\x03\x02\x00B', @@ -620,7 +620,7 @@ def test_get_text(self, monkeypatch): class TestSunSpecModbusClientModel: def test___init__(self, monkeypatch): - d_rtu = client.SunSpecModbusClientDeviceRTU(slave_id=1, name="COM2") + d_rtu = client.SunSpecModbusClientDeviceRTU(unit_id=1, name="COM2") monkeypatch.setattr(serial, 'Serial', MockPort.mock_port) rtu_buffer = [ @@ -738,7 +738,7 @@ def test_get_text(self, monkeypatch): monkeypatch.setattr(client.SunSpecModbusClientDeviceTCP, 'disconnect', MockSocket.mock_tcp_connect) # tcp - d_tcp = client.SunSpecModbusClientDeviceTCP(slave_id=1, ipaddr='127.0.0.1', ipport=8502) + d_tcp = client.SunSpecModbusClientDeviceTCP(unit_id=1, ipaddr='127.0.0.1', ipport=8502) tcp_buffer = [b'\x00\x00\x00\x00\x00\t\x01\x03\x06', b'SunS\x00\x01', b'\x00\x00\x00\x00\x00\x05\x01\x03\x02', @@ -776,7 +776,7 @@ def test_get_text(self, monkeypatch): assert d_tcp.common[0].get_text() == expected_output # rtu - d_rtu = client.SunSpecModbusClientDeviceRTU(slave_id=1, name="COM2") + d_rtu = client.SunSpecModbusClientDeviceRTU(unit_id=1, name="COM2") rtu_buffer = [b'\x01\x03\x06Su', b'nS\x00\x01\x8d\xe4', b'\x01\x03\x02\x00B', @@ -806,7 +806,7 @@ def test_get_text(self, monkeypatch): assert d_rtu.common[0].get_text() == expected_output def test_read(self, monkeypatch): - d_rtu = client.SunSpecModbusClientDeviceRTU(slave_id=1, name="COM2") + d_rtu = client.SunSpecModbusClientDeviceRTU(unit_id=1, name="COM2") monkeypatch.setattr(serial, 'Serial', MockPort.mock_port) rtu_buffer = [b'\x01\x83\x02\xc0\xf1', @@ -1151,7 +1151,7 @@ def test_get_text(self, monkeypatch): class TestSunSpecModbusClientDeviceTCP: def test___init__(self): d = client.SunSpecModbusClientDeviceTCP() - assert d.slave_id == 1 + assert d.unit_id == 1 assert d.ipaddr == '127.0.0.1' assert d.ipport == 502 assert d.timeout is None @@ -1313,7 +1313,7 @@ class TestSunSpecModbusClientDeviceRTU: def test___init__(self, monkeypatch): monkeypatch.setattr(serial, 'Serial', MockPort.mock_port) d = client.SunSpecModbusClientDeviceRTU(1, "COMM2") - assert d.slave_id == 1 + assert d.unit_id == 1 assert d.name == "COMM2" assert d.client.__class__.__name__ == "ModbusClientRTU" assert d.ctx is None @@ -1497,6 +1497,207 @@ def test_write(self): assert d.models['common'][0].SN.cvalue == 'sn-000' +class TestSunSpecModbusClientDeviceMultipleUnits: + """Test the new read_unit and write_unit functionality for issue #107""" + + def test_tcp_read_unit(self, monkeypatch): + """Test reading from different unit IDs using TCP""" + monkeypatch.setattr(socket, 'socket', MockSocket.mock_socket) + d = client.SunSpecModbusClientDeviceTCP(unit_id=1, ipaddr='127.0.0.1', ipport=502) + d.client.connect() + + # Test reading from unit ID 2 (different from default unit ID 1) + buffer = [b'\x00\x00\x00\x00\x00\t\x02\x03\x06', # Note unit ID 2 in response + b'SunS\x00\x01'] + d.client.socket._set_buffer(buffer) + + # Read from unit ID 2 + result = d.read_unit(2, 40000, 3) + assert result == buffer[1] + + # Verify the request was sent with unit ID 2 + expected_req = b'\x00\x00\x00\x00\x00\x06\x02\x03\x9c@\x00\x03' # Unit ID 2 + assert d.client.socket.request[0] == expected_req + + def test_tcp_write_unit(self, monkeypatch): + """Test writing to different unit IDs using TCP""" + monkeypatch.setattr(socket, 'socket', MockSocket.mock_socket) + d = client.SunSpecModbusClientDeviceTCP(unit_id=1, ipaddr='127.0.0.1', ipport=502) + d.client.connect() + + data_to_write = b'test\x00\x00' + buffer = [b'\x00\x00\x00\x00\x00\x06\x03\x10\x9c', # Unit ID 3 in response + b't\x00\x03'] + d.client.socket._set_buffer(buffer) + + # Write to unit ID 3 + d.write_unit(3, 40052, data_to_write) + + # Verify the request was sent with unit ID 3 + expected_req = b'\x00\x00\x00\x00\x00\r\x03\x10\x9ct\x00\x03\x06test\x00\x00' # Unit ID 3 + assert d.client.socket.request[0] == expected_req + + def test_rtu_read_unit(self, monkeypatch): + """Test reading from different unit IDs using RTU""" + monkeypatch.setattr(serial, 'Serial', MockPort.mock_port) + d = client.SunSpecModbusClientDeviceRTU(unit_id=1, name="COM2") + d.open() + + # Test reading from unit ID 2 + # Create proper response with correct CRC + response_data = b'\x02\x03\x06SunS\x00\x01' + crc = suns_modbus.computeCRC(response_data) + response_with_crc = response_data + struct.pack('>H', crc) + + in_buff = [response_with_crc[:4], # First part + response_with_crc[4:]] # Second part with CRC + d.client.serial._set_buffer(in_buff) + + # Read from unit ID 2 + result = d.read_unit(2, 40000, 3) + expected_result = b'SunS\x00\x01' # The actual data without headers/CRC + assert result == expected_result + + # Verify the request was sent with unit ID 2 + request_data = b'\x02\x03\x9c@\x00\x03' + request_crc = suns_modbus.computeCRC(request_data) + expected_req = request_data + struct.pack('>H', request_crc) + assert d.client.serial.request[0] == expected_req + + def test_scan_units(self, monkeypatch): + """Test that scan_units uses the correct unit IDs in requests""" + monkeypatch.setattr(socket, 'socket', MockSocket.mock_socket) + d = client.SunSpecModbusClientDeviceTCP(unit_id=1, ipaddr='127.0.0.1', ipport=502) + d.client.connect() + + # Mock response that will cause scan to fail quickly but still test unit ID usage + # We'll provide responses that don't contain SunS, so scan will fail after trying all base addresses + buffer = [ + # Unit 2 - Address 0 - no SunS + b'\x00\x00\x00\x00\x00\t\x02\x03\x06', + b'\x00\x00\x00\x00\x00\x00', # Not SunS + # Unit 2 - Address 40000 - no SunS + b'\x00\x00\x00\x00\x00\t\x02\x03\x06', + b'\x00\x00\x00\x00\x00\x00', # Not SunS + # Unit 2 - Address 50000 - no SunS + b'\x00\x00\x00\x00\x00\t\x02\x03\x06', + b'\x00\x00\x00\x00\x00\x00', # Not SunS + ] + d.client.socket._set_buffer(buffer) + + # Scan unit ID 2 - expect it to fail but verify unit ID usage + try: + d.scan_units([2], connect=False) + except client.SunSpecModbusClientError: + pass # Expected to fail since we don't provide valid SunS responses + + # Verify requests were sent with unit ID 2 + assert len(d.client.socket.request) >= 3 # At least 3 requests for unit 2 + + # Check that unit ID 2 was used in all requests + for req in d.client.socket.request: + assert req[6] == 2 # Unit ID should be 2 in all requests + + def test_units_collection(self, monkeypatch): + """Test the Units collection functionality""" + monkeypatch.setattr(socket, 'socket', MockSocket.mock_socket) + d = client.SunSpecModbusClientDeviceTCP(unit_id=1, ipaddr='127.0.0.1', ipport=502) + + # Test that Units collection exists + assert hasattr(d, 'Units') + assert isinstance(d.Units, client.SunSpecModbusClientUnitCollection) + + # Test that accessing unscanned unit raises KeyError + try: + unit2 = d.Units[2] + assert False, "Should have raised KeyError for unscanned unit" + except KeyError as e: + assert "Unit 2 has not been scanned" in str(e) + + # Test that after scanning, unit is accessible + d.client.connect() + buffer = [ + b'\x00\x00\x00\x00\x00\t\x02\x03\x06', + b'SunS\x00\x01' + ] + d.client.socket._set_buffer(buffer) + + # Mock scan_units to create the unit + d.Units._create_unit(2) + + # Now unit should be accessible + unit2 = d.Units[2] + assert isinstance(unit2, client.SunSpecModbusClientUnit) + assert unit2.unit_id == 2 + assert unit2.parent_device is d + + # Test that the same unit device is returned on subsequent access + unit2_again = d.Units[2] + assert unit2 is unit2_again + + # Test different unit IDs create different unit devices + d.Units._create_unit(3) + unit3 = d.Units[3] + assert unit3 is not unit2 + assert unit3.unit_id == 3 + + def test_unit_device_delegation(self, monkeypatch): + """Test that SunSpecModbusClientUnit properly delegates to parent device""" + monkeypatch.setattr(socket, 'socket', MockSocket.mock_socket) + d = client.SunSpecModbusClientDeviceTCP(unit_id=1, ipaddr='127.0.0.1', ipport=502) + d.client.connect() + + # Set up mock response + buffer = [ + b'\x00\x00\x00\x00\x00\t\x02\x03\x06', + b'SunS\x00\x01' + ] + d.client.socket._set_buffer(buffer) + + # Test read delegation + d.Units._create_unit(2) + unit2 = d.Units[2] + data = unit2.read(40000, 3) + + # Verify the request was sent with unit ID 2 + assert len(d.client.socket.request) >= 1 + assert d.client.socket.request[0][6] == 2 # Unit ID should be 2 + assert data == b'SunS\x00\x01' + + # Test is_connected delegation + assert unit2.is_connected() == d.is_connected() # Should delegate to parent + + def test_default_unit_access(self, monkeypatch): + """Test that scanning the default unit makes models accessible directly on the device""" + monkeypatch.setattr(socket, 'socket', MockSocket.mock_socket) + d = client.SunSpecModbusClientDeviceTCP(unit_id=1, ipaddr='127.0.0.1', ipport=502) + d.client.connect() + + # Mock a successful scan response for unit 1 (default unit) + buffer = [ + # Find SunS at address 40000 + b'\x00\x00\x00\x00\x00\t\x01\x03\x06', + b'SunS\x00\x01', # SunS + model ID 1 (common) + # Read model length + b'\x00\x00\x00\x00\x00\x05\x01\x03\x02', + b'\x00\x42', # Model length = 66 + # Read next model ID (end model) + b'\x00\x00\x00\x00\x00\x05\x01\x03\x02', + b'\xff\xff' # End model ID + ] + d.client.socket._set_buffer(buffer) + + # Scan the default unit (unit 1) + try: + d.scan_units([1], connect=False, full_model_read=False) + except: + pass # May fail due to incomplete mock, but we just want to test model creation + + # Verify that models are accessible both ways + # Note: This test verifies the concept, actual model access would need more complete mocking + assert hasattr(d, 'Units') + assert 1 in d.Units.data # Unit 1 should be in the Units collection + if __name__ == "__main__": pass diff --git a/sunspec2/tests/test_modbus_modbus.py b/sunspec2/tests/test_modbus_modbus.py index e4ac92d..6171e46 100644 --- a/sunspec2/tests/test_modbus_modbus.py +++ b/sunspec2/tests/test_modbus_modbus.py @@ -126,7 +126,7 @@ def test_write(self, monkeypatch): class TestModbusClientTCP: def test___init__(self): c = modbus_client.ModbusClientTCP() - assert c.slave_id == 1 + assert c.unit_id == 1 assert c.ipaddr == '127.0.0.1' assert c.ipport == 502 assert c.timeout == 2