Skip to content

Commit 978cecf

Browse files
committed
BACnet: Setup wo discovery; force Int config params; removed waits#2130
1 parent 4f9f664 commit 978cecf

6 files changed

Lines changed: 706 additions & 39 deletions

File tree

docker/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ python /thingsboard_gateway/tb_gateway.py' > /start-gateway.sh && chmod +x /star
4747
python3 -m pip install --no-cache-dir --upgrade pip setuptools wheel && \
4848
python3 -m pip install --no-cache-dir cryptography && \
4949
python3 -m pip install --no-cache-dir -r requirements.txt && \
50-
RUN rustup self uninstall -y || { \
50+
rustup self uninstall -y || { \
5151
echo "rustup uninstall failed, removing manually..."; \
5252
rm -rf /root/.rustup /root/.cargo; \
5353
} && \
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# Copyright 2026. 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+
import logging
16+
from types import SimpleNamespace
17+
from unittest import IsolatedAsyncioTestCase
18+
from unittest.mock import AsyncMock
19+
20+
from thingsboard_gateway.connectors.bacnet.application import Application
21+
22+
23+
class TestApplicationReadFallback(IsolatedAsyncioTestCase):
24+
25+
async def asyncSetUp(self):
26+
self.app = Application.__new__(Application)
27+
self.app._Application__log = logging.getLogger("bacnet application fallback tests")
28+
29+
async def test_read_multiple_objects_falls_back_to_read_property(self):
30+
device = SimpleNamespace(details=SimpleNamespace(vendor_id=0, address="192.168.1.199:47808", object_id=101))
31+
object_list = [{
32+
"objectType": "analogInput",
33+
"objectId": "5",
34+
"propertyId": "presentValue"
35+
}]
36+
37+
async def fake_send_wrapper(func, err_msg=None, *args, **kwargs):
38+
if func.__name__ == "request":
39+
return None
40+
if func.__name__ == "read_property":
41+
return 42.5
42+
return None
43+
44+
self.app._Application__send_request_wrapper = AsyncMock(side_effect=fake_send_wrapper)
45+
46+
result = await self.app.read_multiple_objects(device, object_list)
47+
48+
self.assertEqual(len(result), 1)
49+
self.assertEqual(str(result[0][0]), "analog-input,5")
50+
self.assertEqual(str(result[0][1]), "present-value")
51+
self.assertEqual(result[0][3], 42.5)
52+
53+
async def test_read_multiple_objects_returns_empty_when_fallback_fails(self):
54+
device = SimpleNamespace(details=SimpleNamespace(vendor_id=0, address="192.168.1.199:47808", object_id=101))
55+
object_list = [{
56+
"objectType": "analogInput",
57+
"objectId": "5",
58+
"propertyId": "presentValue"
59+
}]
60+
61+
self.app._Application__send_request_wrapper = AsyncMock(return_value=None)
62+
63+
result = await self.app.read_multiple_objects(device, object_list)
64+
65+
self.assertEqual(result, [])
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
# Copyright 2026. 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+
import logging
16+
from types import SimpleNamespace
17+
from unittest import IsolatedAsyncioTestCase
18+
from unittest.mock import AsyncMock
19+
20+
from bacpypes3.basetypes import Segmentation
21+
from bacpypes3.pdu import Address
22+
23+
from thingsboard_gateway.connectors.bacnet.bacnet_connector import AsyncBACnetConnector
24+
25+
26+
class TestConfiguredIAmFallback(IsolatedAsyncioTestCase):
27+
28+
async def asyncSetUp(self):
29+
self.connector = AsyncBACnetConnector.__new__(AsyncBACnetConnector)
30+
self.connector._AsyncBACnetConnector__log = logging.getLogger("bacnet configured i-am tests")
31+
self.connector._AsyncBACnetConnector__application = AsyncMock()
32+
self.connector._AsyncBACnetConnector__stopped = False
33+
34+
async def test_build_i_am_from_device_config_without_probe(self):
35+
device_config = {
36+
"objectName": "manual-device",
37+
"vendorIdentifier": "221",
38+
"maxApduLengthAccepted": "2048",
39+
"segmentationSupported": "segmentedBoth"
40+
}
41+
42+
apdu = await self.connector._AsyncBACnetConnector__build_i_am_like_apdu(
43+
Address("192.168.1.199:47808"),
44+
101,
45+
device_config
46+
)
47+
48+
self.assertIsNotNone(apdu)
49+
self.assertEqual(apdu.iAmDeviceIdentifier, ("device", 101))
50+
self.assertEqual(apdu.deviceName, "manual-device")
51+
self.assertEqual(apdu.vendorID, 221)
52+
self.assertEqual(apdu.maxAPDULengthAccepted, 2048)
53+
self.assertEqual(apdu.segmentationSupported, Segmentation.segmentedBoth)
54+
self.connector._AsyncBACnetConnector__application.read_property.assert_not_called()
55+
56+
async def test_build_i_am_returns_defaults_without_probe(self):
57+
self.connector._AsyncBACnetConnector__application.read_property = AsyncMock(side_effect=RuntimeError("No response"))
58+
59+
apdu = await self.connector._AsyncBACnetConnector__build_i_am_like_apdu(
60+
Address("192.168.1.200:47808"),
61+
102,
62+
{}
63+
)
64+
65+
self.assertIsNotNone(apdu)
66+
self.assertEqual(apdu.deviceName, "102")
67+
self.assertEqual(apdu.vendorID, 0)
68+
self.assertEqual(apdu.maxAPDULengthAccepted, 50)
69+
self.assertEqual(apdu.segmentationSupported, Segmentation.noSegmentation)
70+
self.assertEqual(self.connector._AsyncBACnetConnector__application.read_property.await_count, 4)
71+
72+
async def test_build_i_am_with_partial_config_and_probe_fill(self):
73+
self.connector._AsyncBACnetConnector__application.read_property = AsyncMock(side_effect=[
74+
"probed-device-name",
75+
1476,
76+
Segmentation.segmentedTransmit
77+
])
78+
79+
device_config = {
80+
"vendorIdentifier": 99
81+
}
82+
83+
apdu = await self.connector._AsyncBACnetConnector__build_i_am_like_apdu(
84+
Address("192.168.1.201:47808"),
85+
103,
86+
device_config
87+
)
88+
89+
self.assertIsNotNone(apdu)
90+
self.assertEqual(apdu.deviceName, "probed-device-name")
91+
self.assertEqual(apdu.vendorID, 99)
92+
self.assertEqual(apdu.maxAPDULengthAccepted, 1476)
93+
self.assertEqual(apdu.segmentationSupported, Segmentation.segmentedTransmit)
94+
self.assertEqual(
95+
[call.args[2] for call in self.connector._AsyncBACnetConnector__application.read_property.await_args_list],
96+
["objectName", "maxApduLengthAccepted", "segmentationSupported"]
97+
)
98+
99+
async def test_discover_devices_defaults_to_setup_without_discovery(self):
100+
self.connector._AsyncBACnetConnector__config = {
101+
"devices": [
102+
{
103+
"address": "192.168.1.202:47808",
104+
"deviceId": 104
105+
}
106+
]
107+
}
108+
self.connector._AsyncBACnetConnector__add_configured_device_without_iam = AsyncMock()
109+
110+
await self.connector._AsyncBACnetConnector__discover_devices()
111+
112+
self.connector._AsyncBACnetConnector__add_configured_device_without_iam.assert_awaited_once()
113+
self.connector._AsyncBACnetConnector__application.do_who_is.assert_not_awaited()
114+
115+
async def test_discover_devices_runs_who_is_when_setup_without_discovery_disabled(self):
116+
self.connector._AsyncBACnetConnector__config = {
117+
"devices": [
118+
{
119+
"address": "192.168.1.202:47808",
120+
"deviceId": 104,
121+
"setupWithoutDiscovery": False
122+
}
123+
]
124+
}
125+
self.connector._AsyncBACnetConnector__add_configured_device_without_iam = AsyncMock()
126+
127+
await self.connector._AsyncBACnetConnector__discover_devices()
128+
129+
self.connector._AsyncBACnetConnector__application.do_who_is.assert_awaited_once_with(
130+
device_address="192.168.1.202:47808"
131+
)
132+
self.connector._AsyncBACnetConnector__add_configured_device_without_iam.assert_not_awaited()
133+
134+
async def test_discover_devices_uses_who_is_for_pattern_address(self):
135+
self.connector._AsyncBACnetConnector__config = {
136+
"devices": [
137+
{
138+
"address": "192.168.1.X:47808",
139+
"deviceId": 104
140+
}
141+
]
142+
}
143+
self.connector._AsyncBACnetConnector__add_configured_device_without_iam = AsyncMock()
144+
145+
await self.connector._AsyncBACnetConnector__discover_devices()
146+
147+
self.connector._AsyncBACnetConnector__application.do_who_is.assert_awaited_once()
148+
self.connector._AsyncBACnetConnector__add_configured_device_without_iam.assert_not_awaited()
149+
150+
async def test_set_additional_device_info_keeps_manual_name_when_probe_fails(self):
151+
self.connector._AsyncBACnetConnector__application.get_device_name = AsyncMock(return_value=None)
152+
apdu = SimpleNamespace(
153+
pduSource=Address("192.168.1.203:47808"),
154+
iAmDeviceIdentifier=("device", 105),
155+
deviceName="manual-name"
156+
)
157+
device_config = {
158+
"deviceInfo": {
159+
"deviceNameExpression": "BACnet Device ${objectName}",
160+
"deviceProfileExpression": "default"
161+
}
162+
}
163+
164+
await self.connector._AsyncBACnetConnector__set_additional_device_info_to_apdu(apdu, device_config)
165+
166+
self.assertEqual(apdu.deviceName, "manual-name")

0 commit comments

Comments
 (0)