From 4333e488979190ac4e2ad3e57c9473f49d5c78ea Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Sat, 30 May 2026 19:58:26 +0200 Subject: [PATCH 01/10] add whois request --- .../protocol/python/src/protocol_bacnet.py | 47 ++++++++++++++++--- .../protocol/python/src/protocol_module.py | 4 +- 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/modules/test/protocol/python/src/protocol_bacnet.py b/modules/test/protocol/python/src/protocol_bacnet.py index 0981b7c89..69e7e2439 100644 --- a/modules/test/protocol/python/src/protocol_bacnet.py +++ b/modules/test/protocol/python/src/protocol_bacnet.py @@ -14,6 +14,7 @@ """Module to run all the BACnet related methods for testing""" import BAC0 +from bacpypes3.pdu import Address from dataclasses import dataclass import logging import json @@ -64,12 +65,13 @@ def __init__(self, self.device_hw_addr = device_hw_addr self._bin_dir = bin_dir - async def discover(self, local_ip): + async def discover(self, local_ip, device_ip): LOGGER.info('Performing BACnet discovery...') - self.bacnet = BAC0.lite(local_ip) + self.devices = [] + self.bacnet = BAC0.connect(local_ip) LOGGER.info('Local BACnet object: ' + str(self.bacnet)) try: - await self.bacnet._discover(global_broadcast=True) # pylint: disable=protected-access + await self.bacnet._discover(global_broadcast=True, timeout=10) # pylint: disable=protected-access except Exception as e: # pylint: disable=W0718 LOGGER.error(e) LOGGER.info('BACnet discovery complete') @@ -77,16 +79,47 @@ async def discover(self, local_ip): bac0_log = f.read() LOGGER.info('BAC0 Log:\n' + bac0_log) # Extract discovered devices as a BACnetDevice. - self.devices = [] + LOGGER.info('discoveredDevices: ' + str(self.bacnet.discoveredDevices)) if self.bacnet.discoveredDevices is not None: for device_info in self.bacnet.discoveredDevices.values(): - self.devices.append( - BACnetDevice( + device = BACnetDevice( device_id=str(device_info['object_instance'][1]), ip=str(device_info['address']) ) - ) + LOGGER.info(f'Discovered BACnet device: {device}') + self.devices.append(device) LOGGER.info('BACnet devices found: ' + str(len(self.devices))) + self.bacnet.discoveredDevices = {} + try: + await self.bacnet._discover(timeout=10) # pylint: disable=protected-access + except Exception as e: # pylint: disable=W0718 + LOGGER.error(e) + LOGGER.info('BACnet discovery complete') + with open(BAC0_LOG, 'r', encoding='utf-8') as f: + bac0_log = f.read() + LOGGER.info('BAC0 Log:\n' + bac0_log) + LOGGER.info('discoveredDevices: ' + str(self.bacnet.discoveredDevices)) + if self.bacnet.discoveredDevices is not None: + for device_info in self.bacnet.discoveredDevices.values(): + device = BACnetDevice( + device_id=str(device_info['object_instance'][1]), + ip=str(device_info['address']) + ) + LOGGER.info(f'Discovered BACnet device: {device}') + res = await self.bacnet.this_application.app.who_is( + low_limit=0, + high_limit=4194303, + address=Address(f'{device_ip}:47808'), + timeout=10, + ) + LOGGER.info(f'WhoIs result: {res}') + for iam in res: + instance = iam.iAmDeviceIdentifier[1] + obj_type = iam.iAmDeviceIdentifier[0] + address = str(iam.pduSource) + vendor_id = getattr(iam, 'vendorID', None) + LOGGER.info(f'''Device type: {obj_type}, instance: {instance}, + address: {address}, vendor_id: {vendor_id}''') # Check if the device being tested is in the discovered devices list # discover needs to be called before this method is invoked diff --git a/modules/test/protocol/python/src/protocol_module.py b/modules/test/protocol/python/src/protocol_module.py index 646f912d1..8077b88ee 100644 --- a/modules/test/protocol/python/src/protocol_module.py +++ b/modules/test/protocol/python/src/protocol_module.py @@ -49,9 +49,9 @@ def _protocol_valid_bacnet(self): local_address += '/24' try: loop = asyncio.get_running_loop() - loop.run_until_complete(self._bacnet.discover(local_address)) + loop.run_until_complete(self._bacnet.discover(local_address, self._device_ipv4_addr)) except RuntimeError: - asyncio.run(self._bacnet.discover(local_address)) + asyncio.run(self._bacnet.discover(local_address, self._device_ipv4_addr)) result = self._bacnet.validate_device() if result[0]: self._supports_bacnet = True From c1a585703164ca019d95f3f91af05affb79a0ade Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Sat, 30 May 2026 20:03:37 +0200 Subject: [PATCH 02/10] pylint --- modules/test/protocol/python/src/protocol_module.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/modules/test/protocol/python/src/protocol_module.py b/modules/test/protocol/python/src/protocol_module.py index 8077b88ee..38a1b4031 100644 --- a/modules/test/protocol/python/src/protocol_module.py +++ b/modules/test/protocol/python/src/protocol_module.py @@ -49,9 +49,13 @@ def _protocol_valid_bacnet(self): local_address += '/24' try: loop = asyncio.get_running_loop() - loop.run_until_complete(self._bacnet.discover(local_address, self._device_ipv4_addr)) + loop.run_until_complete( + self._bacnet.discover(local_address, self._device_ipv4_addr) + ) except RuntimeError: - asyncio.run(self._bacnet.discover(local_address, self._device_ipv4_addr)) + asyncio.run(self._bacnet.discover( + local_address, self._device_ipv4_addr) + ) result = self._bacnet.validate_device() if result[0]: self._supports_bacnet = True From ee54370918aa599ea006b8a47602d3a31cc49fe1 Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Fri, 5 Jun 2026 17:42:03 +0200 Subject: [PATCH 03/10] device discovery from captured packets --- .../protocol/bin/get_bacnet_i-am_packets.sh | 27 ++++++ .../protocol/python/src/protocol_bacnet.py | 88 ++++++++++++------- 2 files changed, 85 insertions(+), 30 deletions(-) create mode 100644 modules/test/protocol/bin/get_bacnet_i-am_packets.sh diff --git a/modules/test/protocol/bin/get_bacnet_i-am_packets.sh b/modules/test/protocol/bin/get_bacnet_i-am_packets.sh new file mode 100644 index 000000000..c26066988 --- /dev/null +++ b/modules/test/protocol/bin/get_bacnet_i-am_packets.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +# Copyright 2026 Google LLC +# +# Licensed 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. + +#!/bin/bash + +CAPTURE_FILE="$1" +SRC_IP="$2" + +TSHARK_FILTER="bacnet && ip.src == $SRC_IP" +TSHARK_OUTPUT="-T json -e ip.src -e ip.dst -e eth.src -e eth.dst -e bacapp.instance_number -e _ws.col.Info" + +response=$(tshark -r "$CAPTURE_FILE" $TSHARK_OUTPUT -Y "$TSHARK_FILTER") + +echo "$response" \ No newline at end of file diff --git a/modules/test/protocol/python/src/protocol_bacnet.py b/modules/test/protocol/python/src/protocol_bacnet.py index 69e7e2439..da8dfcf6a 100644 --- a/modules/test/protocol/python/src/protocol_bacnet.py +++ b/modules/test/protocol/python/src/protocol_bacnet.py @@ -89,37 +89,41 @@ async def discover(self, local_ip, device_ip): LOGGER.info(f'Discovered BACnet device: {device}') self.devices.append(device) LOGGER.info('BACnet devices found: ' + str(len(self.devices))) - self.bacnet.discoveredDevices = {} - try: - await self.bacnet._discover(timeout=10) # pylint: disable=protected-access - except Exception as e: # pylint: disable=W0718 - LOGGER.error(e) - LOGGER.info('BACnet discovery complete') - with open(BAC0_LOG, 'r', encoding='utf-8') as f: - bac0_log = f.read() - LOGGER.info('BAC0 Log:\n' + bac0_log) - LOGGER.info('discoveredDevices: ' + str(self.bacnet.discoveredDevices)) - if self.bacnet.discoveredDevices is not None: - for device_info in self.bacnet.discoveredDevices.values(): + if not self.devices: + try: + await self.bacnet._discover(timeout=10) # pylint: disable=protected-access + except Exception as e: # pylint: disable=W0718 + LOGGER.error(e) + LOGGER.info('BACnet discovery complete') + with open(BAC0_LOG, 'r', encoding='utf-8') as f: + bac0_log = f.read() + LOGGER.info('BAC0 Log:\n' + bac0_log) + LOGGER.info('discoveredDevices: ' + str(self.bacnet.discoveredDevices)) + if self.bacnet.discoveredDevices is not None: + for device_info in self.bacnet.discoveredDevices.values(): + device = BACnetDevice( + device_id=str(device_info['object_instance'][1]), + ip=str(device_info['address']) + ) + self.devices.append(device) + LOGGER.info(f'Discovered BACnet device: {device}') + if not self.devices: + res = await self.bacnet.this_application.app.who_is( + low_limit=0, + high_limit=4194303, + address=Address(f'{device_ip}:47808'), + timeout=10, + ) + for iam in res: + instance = iam.iAmDeviceIdentifier[1] + address = str(iam.pduSource) device = BACnetDevice( - device_id=str(device_info['object_instance'][1]), - ip=str(device_info['address']) - ) - LOGGER.info(f'Discovered BACnet device: {device}') - res = await self.bacnet.this_application.app.who_is( - low_limit=0, - high_limit=4194303, - address=Address(f'{device_ip}:47808'), - timeout=10, - ) - LOGGER.info(f'WhoIs result: {res}') - for iam in res: - instance = iam.iAmDeviceIdentifier[1] - obj_type = iam.iAmDeviceIdentifier[0] - address = str(iam.pduSource) - vendor_id = getattr(iam, 'vendorID', None) - LOGGER.info(f'''Device type: {obj_type}, instance: {instance}, - address: {address}, vendor_id: {vendor_id}''') + device_id=str(instance), + ip=str(address) + ) + self.devices.append(device) + if not self.devices: + self.devices = self._discover_from_packets(device_ip) # Check if the device being tested is in the discovered devices list # discover needs to be called before this method is invoked @@ -211,3 +215,27 @@ def get_bacnet_packets( command = f'{bin_file} {args}' response = util.run_command(command) return json.loads(response[0].strip()) + + def _discover_from_packets(self, device_ip: str) -> list[BACnetDevice]: + discovered = set() + capture_file = os.path.join(self._captures_dir, self._capture_file) + LOGGER.info(f'Discovering BACnet devices from packets in {capture_file}...') + bin_file = self._bin_dir + '/get_bacnet_i-am_packets.sh' + args = f'"{capture_file}" {device_ip}' + command = f'{bin_file} {args}' + response = util.run_command(command) + packets = json.loads(response[0].strip()) + for packet in packets: + info = packet['_source']['layers']['_ws.col.info'][0] + if 'i-Am' in info: + discovered.add( + ( + packet['_source']['layers']['bacapp.instance_number'][0], + packet['_source']['layers']['ip.src'][0] + ) + ) + LOGGER.info(f'Discovered BACnet devices from packets: {discovered}') + return [ + BACnetDevice(device_id=device_id, ip=ip) + for device_id, ip in discovered + ] From 4129ed3296217714b365caccc3d51c58fbf1bb66 Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Tue, 16 Jun 2026 19:38:09 +0200 Subject: [PATCH 04/10] readmultiple --- .../protocol/python/src/protocol_bacnet.py | 35 +++++++++++++------ 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/modules/test/protocol/python/src/protocol_bacnet.py b/modules/test/protocol/python/src/protocol_bacnet.py index f565a3bd8..46798d6d8 100644 --- a/modules/test/protocol/python/src/protocol_bacnet.py +++ b/modules/test/protocol/python/src/protocol_bacnet.py @@ -130,6 +130,7 @@ async def discover(self, local_ip, device_ip): def validate_device(self): result = None description = '' + try: if len(self.devices) > 0: result = True @@ -157,17 +158,29 @@ async def validate_protocol_version( f'Resolving protocol version for BACnet device: {device.device_id}' ) try: - dut = await BAC0.device( - device.ip, - device.device_id, - self.bacnet - ) - version = await dut.read_property( - ('device', device.device_id, 'protocolVersion') - ) - revision = await dut.read_property( - ('device', device.device_id, 'protocolRevision') - ) + + + try: + dut = await BAC0.device( + device.ip, + device.device_id, + self.bacnet + ) + version = await dut.read_property( + ('device', device.device_id, 'protocolVersion') + ) + revision = await dut.read_property( + ('device', device.device_id, 'protocolRevision') + ) + except AttributeError: + cmd = f'{device.ip} device {device.device_id} protocolVersion protocolRevision' + + results = await self.bacnet.readMultiple(cmd) + + LOGGER.info(f'BACnet readMultiple results: {results}') + if len(results) == 2: + version = results[0] + revision = results[1] if version is None or revision is None: result = False result_description = ( From 3f2184dee2f70a43e241d3687459d3098a97d8cf Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Wed, 17 Jun 2026 10:33:21 +0200 Subject: [PATCH 05/10] pylint --- modules/test/protocol/python/src/protocol_bacnet.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/modules/test/protocol/python/src/protocol_bacnet.py b/modules/test/protocol/python/src/protocol_bacnet.py index 46798d6d8..6ca834d00 100644 --- a/modules/test/protocol/python/src/protocol_bacnet.py +++ b/modules/test/protocol/python/src/protocol_bacnet.py @@ -158,8 +158,6 @@ async def validate_protocol_version( f'Resolving protocol version for BACnet device: {device.device_id}' ) try: - - try: dut = await BAC0.device( device.ip, @@ -173,8 +171,9 @@ async def validate_protocol_version( ('device', device.device_id, 'protocolRevision') ) except AttributeError: - cmd = f'{device.ip} device {device.device_id} protocolVersion protocolRevision' - + ip = device.ip + d_id = device.device_id + cmd = f'{ip} device {d_id} protocolVersion protocolRevision' results = await self.bacnet.readMultiple(cmd) LOGGER.info(f'BACnet readMultiple results: {results}') From 722cd8f47264f041b380f2737a3c13c7fb797ee2 Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Wed, 17 Jun 2026 18:49:12 +0200 Subject: [PATCH 06/10] clear devices cache --- .../protocol/python/src/protocol_bacnet.py | 46 ++++++++++--------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/modules/test/protocol/python/src/protocol_bacnet.py b/modules/test/protocol/python/src/protocol_bacnet.py index 6ca834d00..d34b06c64 100644 --- a/modules/test/protocol/python/src/protocol_bacnet.py +++ b/modules/test/protocol/python/src/protocol_bacnet.py @@ -15,6 +15,8 @@ import BAC0 from bacpypes3.pdu import Address +from bacpypes3.app import DeviceInfo +from bacpypes3.basetypes import Segmentation from dataclasses import dataclass import logging import json @@ -157,29 +159,29 @@ async def validate_protocol_version( LOGGER.info( f'Resolving protocol version for BACnet device: {device.device_id}' ) + version = None + revision = None try: - try: - dut = await BAC0.device( - device.ip, - device.device_id, - self.bacnet - ) - version = await dut.read_property( - ('device', device.device_id, 'protocolVersion') - ) - revision = await dut.read_property( - ('device', device.device_id, 'protocolRevision') - ) - except AttributeError: - ip = device.ip - d_id = device.device_id - cmd = f'{ip} device {d_id} protocolVersion protocolRevision' - results = await self.bacnet.readMultiple(cmd) - - LOGGER.info(f'BACnet readMultiple results: {results}') - if len(results) == 2: - version = results[0] - revision = results[1] + dev_info = DeviceInfo() + dev_info.device_instance = device.device_id + dev_info.device_address = Address(device.ip) + dev_info.max_apdu_length_accepted = 1476 # Стандарт для BACnet/IP + dev_info.segmentation_supported = Segmentation.noSegmentation + dev_info.vendor_id = 0 + + await self.bacnet.this_application.app.device_info_cache.set_device_info(dev_info) + LOGGER.info(f"Manually injected device {device.device_id} ({device.ip}) into BAC0 cache.") + except Exception as cache_err: + LOGGER.warning(f"Failed to pre-populate BAC0 cache: {cache_err}") + try: + ip = device.ip + d_id = device.device_id + cmd = f'{ip} device {d_id} protocolVersion protocolRevision' + results = await self.bacnet.readMultiple(cmd) + LOGGER.info(f'BACnet readMultiple results: {results}') + if len(results) == 2: + version = results[0] + revision = results[1] if version is None or revision is None: result = False result_description = ( From a6b6a30c36b7bed5e68cf8c046fb2f59394924eb Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Wed, 17 Jun 2026 18:52:07 +0200 Subject: [PATCH 07/10] pylint --- .../test/protocol/python/src/protocol_bacnet.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/modules/test/protocol/python/src/protocol_bacnet.py b/modules/test/protocol/python/src/protocol_bacnet.py index d34b06c64..f67284efd 100644 --- a/modules/test/protocol/python/src/protocol_bacnet.py +++ b/modules/test/protocol/python/src/protocol_bacnet.py @@ -161,21 +161,22 @@ async def validate_protocol_version( ) version = None revision = None + ip = device.ip + d_id = device.device_id try: dev_info = DeviceInfo() - dev_info.device_instance = device.device_id - dev_info.device_address = Address(device.ip) + dev_info.device_instance = d_id + dev_info.device_address = Address(ip) dev_info.max_apdu_length_accepted = 1476 # Стандарт для BACnet/IP dev_info.segmentation_supported = Segmentation.noSegmentation dev_info.vendor_id = 0 - - await self.bacnet.this_application.app.device_info_cache.set_device_info(dev_info) - LOGGER.info(f"Manually injected device {device.device_id} ({device.ip}) into BAC0 cache.") + await self.bacnet.this_application.app.device_info_cache.set_device_info( + dev_info + ) + LOGGER.info(f"Manually injected device {d_id} {ip} into BAC0 cache.") except Exception as cache_err: LOGGER.warning(f"Failed to pre-populate BAC0 cache: {cache_err}") try: - ip = device.ip - d_id = device.device_id cmd = f'{ip} device {d_id} protocolVersion protocolRevision' results = await self.bacnet.readMultiple(cmd) LOGGER.info(f'BACnet readMultiple results: {results}') From 0a5b53e5e4af22f46f451dd5068a979bc6be323b Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Wed, 17 Jun 2026 18:58:16 +0200 Subject: [PATCH 08/10] pylint --- modules/test/protocol/python/src/protocol_bacnet.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/test/protocol/python/src/protocol_bacnet.py b/modules/test/protocol/python/src/protocol_bacnet.py index f67284efd..bb968c824 100644 --- a/modules/test/protocol/python/src/protocol_bacnet.py +++ b/modules/test/protocol/python/src/protocol_bacnet.py @@ -173,9 +173,9 @@ async def validate_protocol_version( await self.bacnet.this_application.app.device_info_cache.set_device_info( dev_info ) - LOGGER.info(f"Manually injected device {d_id} {ip} into BAC0 cache.") + LOGGER.info(f'Manually injected device {d_id} {ip} into BAC0 cache.') except Exception as cache_err: - LOGGER.warning(f"Failed to pre-populate BAC0 cache: {cache_err}") + LOGGER.warning(f'Failed to pre-populate BAC0 cache: {cache_err}' ) try: cmd = f'{ip} device {d_id} protocolVersion protocolRevision' results = await self.bacnet.readMultiple(cmd) From cb00ce2125ba99b7a7576c8f94ac97e24fb1fcdf Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Mon, 22 Jun 2026 17:58:36 +0200 Subject: [PATCH 09/10] device info --- modules/test/protocol/python/src/protocol_bacnet.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/modules/test/protocol/python/src/protocol_bacnet.py b/modules/test/protocol/python/src/protocol_bacnet.py index bb968c824..d26581b6e 100644 --- a/modules/test/protocol/python/src/protocol_bacnet.py +++ b/modules/test/protocol/python/src/protocol_bacnet.py @@ -164,12 +164,13 @@ async def validate_protocol_version( ip = device.ip d_id = device.device_id try: - dev_info = DeviceInfo() - dev_info.device_instance = d_id - dev_info.device_address = Address(ip) - dev_info.max_apdu_length_accepted = 1476 # Стандарт для BACnet/IP - dev_info.segmentation_supported = Segmentation.noSegmentation - dev_info.vendor_id = 0 + dev_info = DeviceInfo( + device_instance=int(d_id), + device_address=Address(ip), + max_apdu_length_accepted=1476, + segmentation_supported=Segmentation.noSegmentation, + vendor_identifier=0 + ) await self.bacnet.this_application.app.device_info_cache.set_device_info( dev_info ) From 666ea0ef8a8554131bfa6103c9b64376d29cc7ff Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Tue, 30 Jun 2026 15:08:37 +0200 Subject: [PATCH 10/10] low level version defenition --- .../protocol/python/src/protocol_bacnet.py | 213 ++++++++++++++---- 1 file changed, 173 insertions(+), 40 deletions(-) diff --git a/modules/test/protocol/python/src/protocol_bacnet.py b/modules/test/protocol/python/src/protocol_bacnet.py index d26581b6e..1e492dc0f 100644 --- a/modules/test/protocol/python/src/protocol_bacnet.py +++ b/modules/test/protocol/python/src/protocol_bacnet.py @@ -14,18 +14,18 @@ """Module to run all the BACnet related methods for testing""" import BAC0 +import socket +import asyncio +from bacpypes3.pdu import Address +from bacpypes3.apdu import IAmRequest +from bacpypes3.primitivedata import ObjectIdentifier from bacpypes3.pdu import Address -from bacpypes3.app import DeviceInfo -from bacpypes3.basetypes import Segmentation from dataclasses import dataclass import logging import json from common import util import os -from BAC0.core.io.IOExceptions import (UnknownPropertyError, - ReadPropertyException, - NoResponseFromController, - DeviceNotConnected) + LOGGER = None BAC0_LOG = '/root/.BAC0/BAC0.log' @@ -151,55 +151,188 @@ def validate_device(self): except Exception: # pylint: disable=W0718 LOGGER.error('Error occurred when validating device', exc_info=True) return result, description + + def clear_bacnet_caches(self): + LOGGER.info("Clearing BACnet device caches...") + + if hasattr(self, 'bacnet') and self.bacnet: + if hasattr(self.bacnet, 'devices') and isinstance(self.bacnet.devices, dict): + self.bacnet.devices.clear() + LOGGER.info("BAC0 device registry cleared.") + + try: + if (hasattr(self.bacnet, 'this_application') + and self.bacnet.this_application + and hasattr(self.bacnet.this_application, 'app')): + + app = self.bacnet.this_application.app + if hasattr(app, 'device_info_cache') and app.device_info_cache: + cache = app.device_info_cache + if hasattr(cache, 'clear'): + cache.clear() + elif hasattr(cache, '_cache') and isinstance(cache._cache, dict): + cache._cache.clear() + elif hasattr(cache, 'cache') and isinstance(cache.cache, dict): + cache.cache.clear() + LOGGER.info('bacpypes3 DeviceInfoCache cleared.') + except Exception as e: + LOGGER.warning(f'Non-critical error while clearing bacpypes3 cache: {e}') async def validate_protocol_version( self, device: BACnetDevice ) -> tuple[bool, str]: - LOGGER.info( - f'Resolving protocol version for BACnet device: {device.device_id}' - ) + version = None revision = None + result = False + result_description = 'Unknown error' ip = device.ip d_id = device.device_id - try: - dev_info = DeviceInfo( - device_instance=int(d_id), - device_address=Address(ip), - max_apdu_length_accepted=1476, - segmentation_supported=Segmentation.noSegmentation, - vendor_identifier=0 + + LOGGER.info( + f'Resolving protocol version for BACnet device: {d_id}' ) - await self.bacnet.this_application.app.device_info_cache.set_device_info( - dev_info - ) - LOGGER.info(f'Manually injected device {d_id} {ip} into BAC0 cache.') - except Exception as cache_err: - LOGGER.warning(f'Failed to pre-populate BAC0 cache: {cache_err}' ) + + self.clear_bacnet_caches() try: - cmd = f'{ip} device {d_id} protocolVersion protocolRevision' + LOGGER.info('Step 1: Attempting via BAC0 with IAmRequest injection...') + + iam_packet = IAmRequest( + iAmDeviceIdentifier=ObjectIdentifier(('device', int(d_id))), + maxAPDULengthAccepted=1476, + segmentationSupported=3, + vendorID=0 + ) + iam_packet.pduSource = Address(device.ip) + + await self.bacnet.this_application.app.device_info_cache.set_device_info(iam_packet) + + cmd = f'{device.ip} device {device.device_id} protocolVersion protocolRevision' results = await self.bacnet.readMultiple(cmd) - LOGGER.info(f'BACnet readMultiple results: {results}') - if len(results) == 2: - version = results[0] - revision = results[1] - if version is None or revision is None: + LOGGER.info(f'BAC0 readMultiple raw results: {results}') + + if isinstance(results, list) and len(results) == 2: + version, revision = results[0], results[1] + elif isinstance(results, dict): + version = results.get('protocolVersion') + revision = results.get('protocolRevision') + + if version is not None and revision is not None: + result = True + result_description = f'Successfully resolved: {version}.{revision}' + LOGGER.info(result_description) + + except Exception as bac0_err: + LOGGER.warning(f'BAC0 attempt failed: {bac0_err}') + + if hasattr(self, 'bacnet') and self.bacnet: + try: + LOGGER.info('Stopping BAC0 instance to free up UDP port 47808...') + if hasattr(self.bacnet, 'disconnect'): + self.bacnet.disconnect() + elif (hasattr(self.bacnet, 'this_application') + and self.bacnet.this_application): + self.bacnet.this_application.close() + LOGGER.info('BAC0 stopped. Waiting for OS to release the socket...') + except Exception as close_err: + LOGGER.warning(f'Non-critical error during BAC0 shutdown: {close_err}') + + await asyncio.sleep(3) + + LOGGER.info('Step 2: Starting raw Python UDP socket fallback...') + try: + loop = asyncio.get_running_loop() + version, revision = await loop.run_in_executor( + None, + self._hex_socket_request, + ip, + int(d_id) + ) + + if version is not None and revision is not None: + result = True + result_description = f'Successfully resolved via Raw Hex Socket: {version}.{revision}' + else: result = False - result_description = ( - f'Failed to resolve protocol version: version={version}, ' - f'revision={revision}') + result_description = 'Both BAC0 and Raw Socket fallback failed to get version data' LOGGER.error(result_description) - else: - protocol_version = f'{version}.{revision}' - result = True - result_description = f'Device uses BACnet version {protocol_version}' - except (UnknownPropertyError, ReadPropertyException, - NoResponseFromController, DeviceNotConnected) as e: - result = False - result_description = f'Failed to resolve protocol version {e}' - LOGGER.error(result_description) + except Exception as e: + LOGGER.error(f'Raw packet error: {close_err}') + + return result, result_description + def _hex_socket_request( + self, + ip: str, + device_id: int + ) -> tuple[int | None, int | None]: + version = None + revision = None + + obj_id_int = (8 << 22) | device_id + obj_bytes = obj_id_int.to_bytes(4, byteorder='big') + + req_version = bytearray([ + 0x81, 0x0a, 0x00, 0x11, + 0x01, 0x04, + 0x00, 0x05, 0x01, + 0x0c, + 0x0c + ]) + req_version.extend(obj_bytes) + req_version.extend([0x19, 0x62]) + + req_revision = bytearray([ + 0x81, 0x0a, 0x00, 0x11, + 0x01, 0x04, + 0x00, 0x05, 0x02, + 0x0c, + 0x0c + ]) + req_revision.extend(obj_bytes) + req_revision.extend([0x19, 0x8b]) + + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.settimeout(2.5) + + try: + LOGGER.info(f"Sending raw hex ReadProperty(protocolVersion) to {ip}:47808") + sock.sendto(req_version, (ip, 47808)) + data, _ = sock.recvfrom(1024) + + idx = data.find(b'\x3e') + if idx != -1 and len(data) > idx + 2: + tag = data[idx+1] + if tag == 0x21: + version = data[idx+2] + elif tag == 0x22 and len(data) > idx + 3: + version = int.from_bytes(data[idx+2:idx+4], byteorder='big') + + LOGGER.info(f"RAW packet version {version}") + + LOGGER.info(f"Sending raw hex ReadProperty(protocolRevision) to {ip}:47808") + sock.sendto(req_revision, (ip, 47808)) + data, _ = sock.recvfrom(1024) + + idx = data.find(b'\x3e') + if idx != -1 and len(data) > idx + 2: + tag = data[idx+1] + if tag == 0x21: + revision = data[idx+2] + elif tag == 0x22 and len(data) > idx + 3: + revision = int.from_bytes(data[idx+2:idx+4], byteorder='big') + LOGGER.info(f"RAW packet revision {revision}") + + except socket.timeout: + LOGGER.error("Raw socket timeout: Device did not respond to unicast request.") + except Exception as e: + LOGGER.error(f"Raw socket exception occurred: {e}") + finally: + sock.close() + + return version, revision + # Validate that all traffic to/from BACnet device from # discovered object id matches the MAC address of the device