|
| 1 | +# Copyright 2025. ThingsBoard |
| 2 | +# |
| 3 | +# Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 | +# you may not use this file except in compliance with the License. |
| 5 | +# You may obtain a copy of the License at |
| 6 | +# |
| 7 | +# http://www.apache.org/licenses/LICENSE-2.0 |
| 8 | +# |
| 9 | +# Unless required by applicable law or agreed to in writing, software |
| 10 | +# distributed under the License is distributed on an "AS IS" BASIS, |
| 11 | +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 12 | +# See the License for the specific language governing permissions and |
| 13 | +# limitations under the License. |
| 14 | + |
| 15 | +from threading import Thread |
| 16 | +import asyncio |
| 17 | +from unittest.mock import patch, MagicMock, AsyncMock |
| 18 | +from tests.unit.connectors.modbus.modbus_base_test import ModbusBaseTestCase |
| 19 | + |
| 20 | + |
| 21 | +class ModbusOnAttributeUpdatesTestCase(ModbusBaseTestCase): |
| 22 | + |
| 23 | + async def asyncSetUp(self): |
| 24 | + await super().asyncSetUp() |
| 25 | + self.slave = self.connector._AsyncModbusConnector__slaves[0] |
| 26 | + self.slave.uplink_converter_config.is_readable = False |
| 27 | + self._loop_thread = Thread(target=self.connector.loop.run_forever, daemon=True) |
| 28 | + self._loop_thread.start() |
| 29 | + self.slave.downlink_converter = MagicMock(name="downlink") |
| 30 | + self.slave.connect = AsyncMock(return_value=True) |
| 31 | + self.slave.write = AsyncMock() |
| 32 | + |
| 33 | + def _create_task_on_connector_loop(coro, args, kwargs): |
| 34 | + return asyncio.run_coroutine_threadsafe(coro(*args, **kwargs), self.connector.loop) |
| 35 | + |
| 36 | + self._create_task_on_connector_loop = _create_task_on_connector_loop |
| 37 | + |
| 38 | + async def asyncTearDown(self): |
| 39 | + self.connector.loop.call_soon_threadsafe(self.connector.loop.stop) |
| 40 | + self._loop_thread.join(timeout=1) |
| 41 | + await super().asyncTearDown() |
| 42 | + |
| 43 | + async def test_on_attribute_update_success(self): |
| 44 | + payload = {'device': 'Demo Device', 'data': {'attr16': 333}} |
| 45 | + self.slave.downlink_converter.convert.return_value = [333] |
| 46 | + with patch.object(self.connector, |
| 47 | + "_AsyncModbusConnector__create_on_attribute_update_task", |
| 48 | + side_effect=self._create_task_on_connector_loop) as create_task_mock, \ |
| 49 | + self.assertLogs("Modbus test", level="DEBUG") as logcap: |
| 50 | + self.connector.on_attributes_update(payload) |
| 51 | + |
| 52 | + self.assertEqual(create_task_mock.call_count, 1) |
| 53 | + func, args, kwargs = create_task_mock.call_args.args |
| 54 | + device, to_process, configuration = args |
| 55 | + self.assertIs(device, self.slave) |
| 56 | + self.assertEqual(configuration['tag'], 'attr16') |
| 57 | + self.assertEqual(configuration['functionCode'], 6) |
| 58 | + self.assertEqual(configuration['address'], 4) |
| 59 | + self.slave.write.assert_awaited_once_with(6, 4, 333) |
| 60 | + self.assertTrue(any("Attribute update processed successfully" in m for m in logcap.output)) |
| 61 | + |
| 62 | + async def test_on_attribute_update_multiple_parameters(self): |
| 63 | + payload = {'device': 'Demo Device', |
| 64 | + 'data': {'attr16': 111, 'attrFloat32': 79.99}} |
| 65 | + self.slave.downlink_converter.convert.side_effect = [ |
| 66 | + [111], |
| 67 | + [0x70A4, 0x4145] |
| 68 | + ] |
| 69 | + |
| 70 | + with patch.object(self.connector, |
| 71 | + "_AsyncModbusConnector__create_on_attribute_update_task", |
| 72 | + side_effect=self._create_task_on_connector_loop) as create_task_mock: |
| 73 | + self.connector.on_attributes_update(payload) |
| 74 | + self.assertEqual(create_task_mock.call_count, 2) |
| 75 | + self.slave.write.assert_any_await(6, 4, 111) |
| 76 | + self.slave.write.assert_any_await(16, 5, [0x70A4, 0x4145]) |
| 77 | + self.assertEqual(self.slave.write.await_count, 2) |
| 78 | + |
| 79 | + call1_configuration = self.slave.downlink_converter.convert.call_args_list[0][0][0] |
| 80 | + call2_configuration = self.slave.downlink_converter.convert.call_args_list[1][0][0] |
| 81 | + self.assertEqual(call1_configuration.function_code, 6) |
| 82 | + self.assertEqual(call1_configuration.address, 4) |
| 83 | + self.assertEqual(call2_configuration.function_code, 16) |
| 84 | + self.assertEqual(call2_configuration.address, 5) |
| 85 | + |
| 86 | + async def test_on_attribute_update_no_attribute_update_mapping(self): |
| 87 | + self.slave.attributes_updates_config = [] |
| 88 | + payload = {'device': 'Demo Device', 'data': {'attr16': 123}} |
| 89 | + |
| 90 | + with patch.object(self.connector, |
| 91 | + "_AsyncModbusConnector__create_on_attribute_update_task") as ct_mock, \ |
| 92 | + self.assertLogs("Modbus test", level="ERROR") as logcap: |
| 93 | + self.connector.on_attributes_update(payload) |
| 94 | + |
| 95 | + ct_mock.assert_not_called() |
| 96 | + self.slave.write.assert_not_awaited() |
| 97 | + self.assertTrue(any("No attribute mapping found" in m for m in logcap.output)) |
| 98 | + |
| 99 | + async def test_on_attribute_update_no_matching_attribute_name_with_config(self): |
| 100 | + payload = {'device': 'Demo Device', 'data': {'invalid_attribute': 111}} |
| 101 | + |
| 102 | + with patch.object(self.connector, |
| 103 | + "_AsyncModbusConnector__create_on_attribute_update_task") as ct_mock, \ |
| 104 | + self.assertLogs("Modbus test", level="ERROR") as logcap: |
| 105 | + self.connector.on_attributes_update(payload) |
| 106 | + |
| 107 | + ct_mock.assert_not_called() |
| 108 | + self.slave.write.assert_not_awaited() |
| 109 | + self.assertTrue(any("No attributes found that match attributes section" in m for m in logcap.output)) |
| 110 | + |
| 111 | + async def test_on_attribute_update_timeout(self): |
| 112 | + payload = {'device': 'Demo Device', 'data': {'attr16': 333}} |
| 113 | + fake_task = MagicMock() |
| 114 | + fake_task.done.return_value = False |
| 115 | + |
| 116 | + with patch.object(self.connector, |
| 117 | + "_AsyncModbusConnector__create_on_attribute_update_task", |
| 118 | + return_value=fake_task) as ct_mock, \ |
| 119 | + patch.object(self.connector, |
| 120 | + "_AsyncModbusConnector__wait_task_with_timeout", |
| 121 | + return_value=(False, None)) as waiter_mock, \ |
| 122 | + self.assertLogs("Modbus test", level="ERROR") as logcap: |
| 123 | + self.connector.on_attributes_update(payload) |
| 124 | + |
| 125 | + ct_mock.assert_called_once() |
| 126 | + waiter_mock.assert_called_once() |
| 127 | + self.slave.write.assert_not_awaited() |
| 128 | + self.assertTrue(any("timed out" in m.lower() for m in logcap.output)) |
| 129 | + |
| 130 | + async def test_on_attribute_update_invalid_data_type(self): |
| 131 | + payload = {'device': 'Demo Device', 'data': {'attr16': 'abba'}} |
| 132 | + self.slave.downlink_converter.convert.side_effect = ValueError("bad cast") |
| 133 | + |
| 134 | + with patch.object(self.connector, |
| 135 | + "_AsyncModbusConnector__create_on_attribute_update_task", |
| 136 | + side_effect=self._create_task_on_connector_loop) as ct_mock, \ |
| 137 | + self.assertLogs("Modbus test", level="ERROR") as logcap: |
| 138 | + self.connector.on_attributes_update(payload) |
| 139 | + |
| 140 | + self.assertEqual(ct_mock.call_count, 1) |
| 141 | + self.slave.write.assert_not_awaited() |
| 142 | + self.assertTrue(any("Could not process attribute update" in m for m in logcap.output)) |
| 143 | + |
| 144 | + async def test_on_attribute_update_partial_failure_continues(self): |
| 145 | + payload = {'device': 'Demo Device', |
| 146 | + 'data': {'attr16': 'rrrr', 'attrFloat32': 80.1}} |
| 147 | + |
| 148 | + self.slave.downlink_converter.convert.side_effect = [ |
| 149 | + ValueError("bad cast"), |
| 150 | + [0x70A4, 0x4145] |
| 151 | + ] |
| 152 | + |
| 153 | + with patch.object(self.connector, |
| 154 | + "_AsyncModbusConnector__create_on_attribute_update_task", |
| 155 | + side_effect=self._create_task_on_connector_loop) as ct_mock, \ |
| 156 | + self.assertLogs("Modbus test", level="ERROR") as logcap: |
| 157 | + self.connector.on_attributes_update(payload) |
| 158 | + |
| 159 | + self.assertEqual(ct_mock.call_count, 2) |
| 160 | + self.slave.write.assert_any_await(16, 5, [0x70A4, 0x4145]) |
| 161 | + self.assertTrue(any("Could not process attribute update" in m for m in logcap.output)) |
0 commit comments