From 2d335ced1d7e279c92e44f7752b80c2f981753a3 Mon Sep 17 00:00:00 2001 From: "Christian Tremblay, ing" Date: Mon, 8 Feb 2021 22:54:47 -0500 Subject: [PATCH 01/11] This is an effort trying to improve read_mixin.py. This is related to PR #252 where Trendlog creation was clunky and subject to bugs. Old implementation was duplicating code between RPM and RP devices which was complicating code. I'm trying to remove duplicate code by using mixin classes. It is not perfect but at least, we won't have to make double code correction to similar functions. I don't like how _process_new_objects function needs to be handled but for now, BAC0 works and devices get created. I'm really suffering from a bad comprhesnion of segmentation and read property multiple support of old devices... but for now I have to live with what I did in the past. --- BAC0/core/devices/mixins/read_mixin.py | 683 +++++++++++-------------- BAC0/infos.py | 2 +- tests/manual_test_create_device.py | 20 +- tests/manualtest_cov.py | 18 +- 4 files changed, 334 insertions(+), 389 deletions(-) diff --git a/BAC0/core/devices/mixins/read_mixin.py b/BAC0/core/devices/mixins/read_mixin.py index 54b742c0..56b83768 100755 --- a/BAC0/core/devices/mixins/read_mixin.py +++ b/BAC0/core/devices/mixins/read_mixin.py @@ -24,7 +24,7 @@ # ------------------------------------------------------------------------------ - +# Requests processing def retrieve_type(obj_list, point_type_key): for point_type, point_address in obj_list: if point_type_key in str(point_type): @@ -38,21 +38,48 @@ def to_float_if_possible(val): return val -class ReadPropertyMultiple: +def batch_requests(request, points_per_request): """ - Handle ReadPropertyMultiple for a device + Generator for creating 'request batches'. Each batch contains a maximum of "points_per_request" + points to read. + :params: request a list of point_name as a list + :params: (int) points_per_request + :returns: (iter) list of point_name of size <= points_per_request """ + for i in range(0, len(request), points_per_request): + yield request[i : i + points_per_request] + + +class TrendLogCreationException(Exception): + pass - def _batches(self, request, points_per_request): - """ - Generator for creating 'request batches'. Each batch contains a maximum of "points_per_request" - points to read. - :params: request a list of point_name as a list - :params: (int) points_per_request - :returns: (iter) list of point_name of size <= points_per_request - """ - for i in range(0, len(request), points_per_request): - yield request[i : i + points_per_request] + +def create_trendlogs(objList, device): + trendlogs = {} + for each in retrieve_type(objList, "trendLog"): + point_address = str(each[1]) + try: + tl = TrendLog(point_address, device, read_log_on_creation=False) + if tl.properties.log_device_object_property is None: + raise TrendLogCreationException + ldop_type, ldop_addr = ( + tl.properties.log_device_object_property.objectIdentifier + ) + ldop_prop = tl.properties.log_device_object_property.propertyIdentifier + trendlogs["{}_{}_{}".format(ldop_type, ldop_addr, ldop_prop)] = ( + tl.properties.object_name, + tl, + ) + except TrendLogCreationException: + device._log.error("Problem creating {}".format(each)) + continue + return trendlogs + + +class ReadUtilsMixin: + """ + Handle ReadPropertyMultiple for a device + """ def _rpm_request_by_name(self, point_list): """ @@ -62,20 +89,277 @@ def _rpm_request_by_name(self, point_list): points = [] requests = [] for each in point_list: + prop_type, prop_addr = each str_list = [] point = self._findPoint(each, force_read=False) points.append(point) - str_list.append( - " {} {} presentValue".format( - point.properties.type, point.properties.address - ) - ) + str_list.append(" {} {} presentValue".format(prop_type, prop_addr)) rpm_param = "".join(str_list) requests.append(rpm_param) return (requests, points) + +class DiscoveryUtilsMixin: + """ + Those functions are used in the process of discovering points in a device + """ + + def read_objects_list(self, custom_object_list=None): + if custom_object_list: + objList = custom_object_list + else: + try: + objList = self.properties.network.read( + "{} device {} objectList".format( + self.properties.address, self.properties.device_id + ), + vendor_id=self.properties.vendor_id, + ) + + except NoResponseFromController: + self._log.error( + "No object list available. Please provide a custom list using the object_list parameter" + ) + objList = [] + + except (SegmentationNotSupported, BufferOverflow): + objList = [] + number_of_objects = self.properties.network.read( + "{} device {} objectList".format( + self.properties.address, self.properties.device_id + ), + arr_index=0, + vendor_id=self.properties.vendor_id, + ) + + for i in range(1, number_of_objects + 1): + objList.append( + self.properties.network.read( + "{} device {} objectList".format( + self.properties.address, self.properties.device_id + ), + arr_index=i, + vendor_id=self.properties.vendor_id, + ) + ) + return objList + + def _discoverPoints(self, custom_object_list=None): + objList = self.read_objects_list(custom_object_list=custom_object_list) + + points = [] + trendlogs = {} + + points.extend( + self._process_new_objects( + obj_cls=NumericPoint, obj_type="analog", objList=objList + ) + ) + points.extend( + self._process_new_objects( + obj_cls=BooleanPoint, obj_type="binary", objList=objList + ) + ) + points.extend( + self._process_new_objects( + obj_cls=EnumPoint, obj_type="multi", objList=objList + ) + ) + points.extend( + self._process_new_objects( + obj_cls=StringPoint, obj_type="characterstringValue", objList=objList + ) + ) + + # TrendLogs + trendlogs = create_trendlogs(objList, self) + + self._log.info("Ready!") + return (objList, points, trendlogs) + + def rp_discovered_values(self, discover_request, points_per_request): + values = [] + info_length = discover_request[1] + big_request = discover_request[0] + self._log.debug("Discover : %s" % big_request) + self._log.debug("Length : %s" % info_length) + + for request in batch_requests(big_request, points_per_request): + try: + request = "{} {}".format(self.properties.address, "".join(request)) + self._log.debug("RP_Request: %s " % request) + val = self.properties.network.read( + request, vendor_id=self.properties.vendor_id + ) + + except KeyError as error: + raise Exception("Unknown point name : {}".format(error)) + + # Save each value to history of each point + for points_info in batch_requests(val, info_length): + values.append(points_info) + + return values + + +class RPMObjectsProcessing: + def _process_new_objects( + self, obj_cls=None, obj_type=None, objList=None, points_per_request=5 + ): + """ + Template to generate BAC0 points instances from information coming from the network. + """ + request = [] + new_points = [] + if obj_type == "analog": + prop_list = "objectName presentValue units description" + elif obj_type == "binary": + prop_list = "objectName presentValue inactiveText activeText description" + elif obj_type == "multi": + prop_list = "objectName presentValue stateText description" + elif obj_type == "characterstringValue": + prop_list = "objectName presentValue" + else: + raise ValueError("Unsupported objectType") + + for points, address in retrieve_type(objList, obj_type): + request.append("{} {} {} ".format(points, address, prop_list)) + + def _find_propid_index(key): + _prop_list = prop_list.split(" ") + for i, each in enumerate(_prop_list): + if key == each: + return i + raise KeyError("{} not part of property list".format(key)) + + try: + self._log.debug("Request : %s" % request) + points_info = self.read_multiple( + "", + discover_request=(request, len(prop_list.split(" "))), + points_per_request=points_per_request, + ) + except SegmentationNotSupported: + raise + # Process responses and create point + i = 0 + for each in retrieve_type(objList, obj_type): + point_type = str(each[0]) + point_address = str(each[1]) + point_infos = points_info[i] + i += 1 + + pointName = point_infos[_find_propid_index("objectName")] + presentValue = point_infos[_find_propid_index("presentValue")] + if obj_type == "analog": + presentValue = float(presentValue) + elif obj_type == "multi": + presentValue = int(presentValue) + try: + point_description = point_infos[_find_propid_index("description")] + except KeyError: + point_description = "" + try: + point_units_state = point_infos[_find_propid_index("units")] + except KeyError: + try: + point_units_state = point_infos[_find_propid_index("stateText")] + except KeyError: + try: + _inactive = point_infos[_find_propid_index("inactiveText")] + _active = point_infos[_find_propid_index("activeText")] + point_units_state = (_inactive, _active) + except KeyError: + if obj_type == "binary": + point_units_state = ("OFF", "ON") + elif obj_type == "multi": + point_units_state = [""] + else: + point_units_state = None + + try: + new_points.append( + obj_cls( + pointType=point_type, + pointAddress=point_address, + pointName=pointName, + description=point_description, + presentValue=presentValue, + units_state=point_units_state, + device=self, + history_size=self.properties.history_size, + ) + ) + except IndexError: + self._log.warning( + "There has been a problem defining {} points. It is sometimes due to busy network. Please retry the device creation".format( + obj_type + ) + ) + raise + return new_points + + +class RPObjectsProcessing: + def _process_new_objects( + self, obj_cls=NumericPoint, obj_type="analog", objList=None + ): + _newpoints = [] + for each in retrieve_type(objList, obj_type): + point_type = str(each[0]) + point_address = str(each[1]) + + if obj_type == "analog": + units_state = self.read_single( + "{} {} units ".format(point_type, point_address) + ) + elif obj_type == "multi": + units_state = self.read_single( + "{} {} stateText ".format(point_type, point_address) + ) + elif obj_type == "binary": + units_state = ( + ( + self.read_single( + "{} {} inactiveText ".format(point_type, point_address) + ) + ), + ( + self.read_single( + "{} {} activeText ".format(point_type, point_address) + ) + ), + ) + else: + units_state = None + + presentValue = self.read_single( + "{} {} presentValue ".format(point_type, point_address) + ) + if obj_type == "analog": + presentValue = float(presentValue) + + _newpoints.append( + obj_cls( + pointType=point_type, + pointAddress=point_address, + pointName=self.read_single( + "{} {} objectName ".format(point_type, point_address) + ), + description=self.read_single( + "{} {} description ".format(point_type, point_address) + ), + presentValue=presentValue, + units_state=units_state, + device=self, + ) + ) + return _newpoints + + +class ReadPropertyMultiple(ReadUtilsMixin, DiscoveryUtilsMixin, RPMObjectsProcessing): def read_multiple( self, points_list, @@ -112,8 +396,10 @@ def read_multiple( values = [] info_length = discover_request[1] big_request = discover_request[0] + self._log.debug("Discover : %s" % big_request) + self._log.debug("Length : %s" % info_length) - for request in self._batches(big_request, points_per_request): + for request in batch_requests(big_request, points_per_request): try: request = "{} {}".format( self.properties.address, "".join(request) @@ -148,14 +434,14 @@ def read_multiple( ) else: - for points_info in self._batches(val, info_length): + for points_info in batch_requests(val, info_length): values.append(points_info) return values else: big_request = self._rpm_request_by_name(points_list) i = 0 - for request in self._batches(big_request[0], points_per_request): + for request in batch_requests(big_request[0], points_per_request): try: request = "{} {}".format( self.properties.address, "".join(request) @@ -185,30 +471,14 @@ def read_single( self, points_list, *, points_per_request=1, discover_request=(None, 4) ): if discover_request[0]: - values = [] - info_length = discover_request[1] - big_request = discover_request[0] - - for request in self._batches(big_request, points_per_request): - try: - request = "{} {}".format(self.properties.address, "".join(request)) - val = self.properties.network.read( - request, vendor_id=self.properties.vendor_id - ) - - except KeyError as error: - raise Exception("Unknown point name : {}".format(error)) - - # Save each value to history of each point - for points_info in self._batches(val, info_length): - values.append(points_info) - - return values + return self.rp_discovered_values( + discover_request, points_per_request=points_per_request + ) else: big_request = self._rpm_request_by_name(points_list) i = 0 - for request in self._batches(big_request[0], points_per_request): + for request in batch_requests(big_request[0], points_per_request): try: request = "{} {}".format(self.properties.address, "".join(request)) val = self.properties.network.read( @@ -222,184 +492,6 @@ def read_single( except KeyError as error: raise Exception("Unknown point name : {}".format(error)) - def _discoverPoints(self, custom_object_list=None): - if custom_object_list: - objList = custom_object_list - else: - try: - objList = self.properties.network.read( - "{} device {} objectList".format( - self.properties.address, self.properties.device_id - ), - vendor_id=self.properties.vendor_id, - ) - - except NoResponseFromController: - self._log.error( - "No object list available. Please provide a custom list using the object_list parameter" - ) - objList = [] - - except (SegmentationNotSupported, BufferOverflow): - objList = [] - number_of_objects = self.properties.network.read( - "{} device {} objectList".format( - self.properties.address, self.properties.device_id - ), - arr_index=0, - vendor_id=self.properties.vendor_id, - ) - - for i in range(1, number_of_objects + 1): - objList.append( - self.properties.network.read( - "{} device {} objectList".format( - self.properties.address, self.properties.device_id - ), - arr_index=i, - vendor_id=self.properties.vendor_id, - ) - ) - - points = [] - trendlogs = {} - - def _process_new_objects( - obj_cls=NumericPoint, obj_type="analog", objList=None, points_per_request=5 - ): - """ - Template to generate BAC0 points instances from information coming from the network. - """ - request = [] - new_points = [] - if obj_type == "analog": - prop_list = "objectName presentValue units description" - elif obj_type == "binary": - prop_list = ( - "objectName presentValue inactiveText activeText description" - ) - elif obj_type == "multi": - prop_list = "objectName presentValue stateText description" - elif obj_type == "characterstringValue": - prop_list = "objectName presentValue" - else: - raise ValueError("Unsupported objectType") - - list_of_obj = retrieve_type(objList, obj_type) - for points, address in list_of_obj: - request.append("{} {} {} ".format(points, address, prop_list)) - - def _find_propid_index(key): - _prop_list = prop_list.split(" ") - for i, each in enumerate(_prop_list): - if key == each: - return i - raise KeyError("{} not part of property list".format(key)) - - try: - points_info = self.read_multiple( - "", - discover_request=(request, len(prop_list.split(" "))), - points_per_request=points_per_request, - ) - except SegmentationNotSupported: - raise - # Process responses and create point - i = 0 - for each in retrieve_type(objList, obj_type): - point_type = str(each[0]) - point_address = str(each[1]) - point_infos = points_info[i] - i += 1 - - pointName = point_infos[_find_propid_index("objectName")] - presentValue = point_infos[_find_propid_index("presentValue")] - if obj_type == "analog": - presentValue = float(presentValue) - elif obj_type == "multi": - presentValue = int(presentValue) - try: - point_description = point_infos[_find_propid_index("description")] - except KeyError: - point_description = "" - try: - point_units_state = point_infos[_find_propid_index("units")] - except KeyError: - try: - point_units_state = point_infos[_find_propid_index("stateText")] - except KeyError: - try: - _inactive = point_infos[_find_propid_index("inactiveText")] - _active = point_infos[_find_propid_index("activeText")] - point_units_state = (_inactive, _active) - except KeyError: - if obj_type == "binary": - point_units_state = ("OFF", "ON") - elif obj_type == "multi": - point_units_state = [""] - else: - point_units_state = None - - try: - new_points.append( - obj_cls( - pointType=point_type, - pointAddress=point_address, - pointName=pointName, - description=point_description, - presentValue=presentValue, - units_state=point_units_state, - device=self, - history_size=self.properties.history_size, - ) - ) - except IndexError: - self._log.warning( - "There has been a problem defining {} points. It is sometimes due to busy network. Please retry the device creation".format( - obj_type - ) - ) - raise - return new_points - - points.extend( - _process_new_objects( - obj_cls=NumericPoint, obj_type="analog", objList=objList - ) - ) - points.extend( - _process_new_objects( - obj_cls=BooleanPoint, obj_type="binary", objList=objList - ) - ) - points.extend( - _process_new_objects(obj_cls=EnumPoint, obj_type="multi", objList=objList) - ) - points.extend( - _process_new_objects( - obj_cls=StringPoint, obj_type="characterstringValue", objList=objList - ) - ) - - # TrendLogs - for each in retrieve_type(objList, "trendLog"): - point_address = str(each[1]) - tl = TrendLog(point_address, self, read_log_on_creation=False) - if tl.properties.log_device_object_property is None: - continue - ldop_type, ldop_addr = ( - tl.properties.log_device_object_property.objectIdentifier - ) - ldop_prop = tl.properties.log_device_object_property.propertyIdentifier - trendlogs["{}_{}_{}".format(ldop_type, ldop_addr, ldop_prop)] = ( - tl.properties.object_name, - tl, - ) - - self._log.debug("RPM Mixin : %s | %s | %s", objList, points, trendlogs) - self._log.info("Ready!") - return (objList, points, trendlogs) - def poll(self, command="start", *, delay=10): """ Poll a point every x seconds (delay=x sec) @@ -476,42 +568,7 @@ def poll(self, command="start", *, delay=10): raise RuntimeError("Stop polling before redefining it") -class ReadProperty: - """ - Handle ReadProperty for a device - """ - - def _batches(self, request, points_per_request): - """ - Generator for creating 'request batches'. Each batch contains a maximum of "points_per_request" - points to read. - :params: request a list of point_name as a list - :params: (int) points_per_request - :returns: (iter) list of point_name of size <= points_per_request - """ - for i in range(0, len(request), points_per_request): - yield request[i : i + points_per_request] - - def _rpm_request_by_name(self, point_list): - """ - :param point_list: a list of point - :returns: (tuple) read request for each points, points - """ - points = [] - requests = [] - for each in point_list: - str_list = [] - point = self._findPoint(each, force_read=False) - points.append(point) - - str_list.append(" " + point.properties.type) - str_list.append(" " + str(point.properties.address)) - str_list.append(" presentValue") - rpm_param = "".join(str_list) - requests.append(rpm_param) - - return (requests, points) - +class ReadProperty(ReadUtilsMixin, DiscoveryUtilsMixin, RPObjectsProcessing): def read_multiple( self, points_list, *, points_per_request=1, discover_request=(None, 6) ): @@ -546,6 +603,7 @@ def read_multiple( def read_single(self, request, *, points_per_request=1, discover_request=(None, 4)): try: request = "{} {}".format(self.properties.address, "".join(request)) + self._log.debug("RP_Request: %s " % request) return self.properties.network.read( request, vendor_id=self.properties.vendor_id ) @@ -555,121 +613,6 @@ def read_single(self, request, *, points_per_request=1, discover_request=(None, except NoResponseFromController as error: return "" - def _discoverPoints(self, custom_object_list=None): - if custom_object_list: - objList = custom_object_list - else: - try: - objList = self.properties.network.read( - "{} device {} objectList".format( - self.properties.address, self.properties.device_id - ), - vendor_id=self.properties.vendor_id, - ) - - except SegmentationNotSupported: - objList = [] - number_of_objects = self.properties.network.read( - "{} device {} objectList".format( - self.properties.address, self.properties.device_id - ), - arr_index=0, - vendor_id=self.properties.vendor_id, - ) - - for i in range(1, number_of_objects + 1): - objList.append( - self.properties.network.read( - "{} device {} objectList".format( - self.properties.address, self.properties.device_id - ), - arr_index=i, - vendor_id=self.properties.vendor_id, - ) - ) - - points = [] - trendlogs = {} - - def _process_new_objects(obj_cls=NumericPoint, obj_type="analog", objList=None): - _newpoints = [] - for each in retrieve_type(objList, obj_type): - point_type = str(each[0]) - point_address = str(each[1]) - - if obj_type == "analog": - units_state = self.read_single( - "{} {} units ".format(point_type, point_address) - ) - elif obj_type == "multi": - units_state = self.read_single( - "{} {} stateText ".format(point_type, point_address) - ) - elif obj_type == "binary": - units_state = ( - ( - self.read_single( - "{} {} inactiveText ".format(point_type, point_address) - ) - ), - ( - self.read_single( - "{} {} activeText ".format(point_type, point_address) - ) - ), - ) - else: - units_state = None - - presentValue = self.read_single( - "{} {} presentValue ".format(point_type, point_address) - ) - if obj_type == "analog": - presentValue = float(presentValue) - - _newpoints.append( - obj_cls( - pointType=point_type, - pointAddress=point_address, - pointName=self.read_single( - "{} {} objectName ".format(point_type, point_address) - ), - description=self.read_single( - "{} {} description ".format(point_type, point_address) - ), - presentValue=presentValue, - units_state=units_state, - device=self, - ) - ) - return _newpoints - - points.extend(_process_new_objects(NumericPoint, "analog", objList)) - points.extend(_process_new_objects(BooleanPoint, "binary", objList)) - points.extend(_process_new_objects(EnumPoint, "multi", objList)) - points.extend( - _process_new_objects(StringPoint, "characterstringValue", objList) - ) - - for each in retrieve_type(objList, "trendLog"): - point_address = str(each[1]) - try: - tl = TrendLog(point_address, self) - except Exception: - self._log.error("Problem creating {}".format(each)) - continue - ldop_type, ldop_addr = ( - tl.properties.log_device_object_property.objectIdentifier - ) - ldop_prop = tl.properties.log_device_object_property.propertyIdentifier - trendlogs["{}_{}_{}".format(ldop_type, ldop_addr, ldop_prop)] = ( - tl.properties.object_name, - tl, - ) - - self._log.info("Ready!") - return (objList, points, trendlogs) - def poll(self, command="start", *, delay=120): """ Poll a point every x seconds (delay=x sec) diff --git a/BAC0/infos.py b/BAC0/infos.py index 6d47a4a3..2e03161f 100644 --- a/BAC0/infos.py +++ b/BAC0/infos.py @@ -12,5 +12,5 @@ __email__ = "christian.tremblay@servisys.com" __url__ = "https://github.com/ChristianTremblay/BAC0" __download_url__ = "https://github.com/ChristianTremblay/BAC0/archive/master.zip" -__version__ = "21.01.28dev2" +__version__ = "21.02.08dev" __license__ = "LGPLv3" diff --git a/tests/manual_test_create_device.py b/tests/manual_test_create_device.py index ef9da7da..0d274f6b 100644 --- a/tests/manual_test_create_device.py +++ b/tests/manual_test_create_device.py @@ -25,23 +25,23 @@ from BAC0.core.devices.local.object import ObjectFactory from BAC0.core.devices.local.models import make_state_text + def add_points(qty_per_type, device): # Start from fresh ObjectFactory.clear_objects() - basic_qty = qty_per_type -1 + basic_qty = qty_per_type - 1 # Analog Inputs - # Default... percent + # Default... percent for _ in range(basic_qty): _new_objects = analog_input(presentValue=99.9) _new_objects = multistate_value(presentValue=1) - # Supplemental with more details, for demonstration _new_objects = analog_input( - name='ZN-T', - properties={"units": 'degreesCelsius'}, + name="ZN-T", + properties={"units": "degreesCelsius"}, description="Zone Temperature", - presentValue=21 + presentValue=21, ) states = make_state_text(["Normal", "Alarm", "Super Emergency"]) @@ -67,6 +67,7 @@ def add_points(qty_per_type, device): _new_objects.add_objects_to_application(device) + def main(): bacnet = BAC0.lite() @@ -90,10 +91,11 @@ def main(): # Connect to test device using main network test_device = BAC0.device("{}:47809".format(ip), boid, bacnet, poll=10) - #test_device_30 = BAC0.device("{}:47810".format(ip_30), boid_30, bacnet, poll=0) - #test_device_300 = BAC0.device("{}:47811".format(ip_300), boid_300, bacnet, poll=0) + # test_device_30 = BAC0.device("{}:47810".format(ip_30), boid_30, bacnet, poll=0) + # test_device_300 = BAC0.device("{}:47811".format(ip_300), boid_300, bacnet, poll=0) while True: time.sleep(0.01) + if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/tests/manualtest_cov.py b/tests/manualtest_cov.py index ba214fdd..4ab5879d 100644 --- a/tests/manualtest_cov.py +++ b/tests/manualtest_cov.py @@ -3,24 +3,24 @@ from bacpypes.primitivedata import Real import time -device = BAC0.lite('192.168.211.55/24', deviceId=123) -client = BAC0.lite('192.168.212.12/24') +device = BAC0.lite("192.168.211.55/24", deviceId=123) +client = BAC0.lite("192.168.212.12/24") new_obj = analog_value() new_obj.add_objects_to_application(device) # From Server -dev_av = device.this_application.get_object_name('AV') +dev_av = device.this_application.get_object_name("AV") print(dev_av.covIncrement) # From client -dev = dev = BAC0.device('192.168.211.55', 123, client, poll=0) -av = dev['AV'] -dev['AV'].subscribe_cov(lifetime=0) +dev = dev = BAC0.device("192.168.211.55", 123, client, poll=0) +av = dev["AV"] +dev["AV"].subscribe_cov(lifetime=0) print() while True: - print(dev['AV']) + print(dev["AV"]) + time.sleep(1) + dev["AV"] += 1 time.sleep(1) - dev['AV'] += 1 - time.sleep(1) \ No newline at end of file From 794f5ebb88e57ad3c7e2ceea8e2711dd6824a34e Mon Sep 17 00:00:00 2001 From: "Christian Tremblay, ing" Date: Tue, 9 Feb 2021 14:46:14 -0500 Subject: [PATCH 02/11] Explicit better than implicit... issue #245 --- BAC0/sql/sql.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BAC0/sql/sql.py b/BAC0/sql/sql.py index 4dd3d4cb..92954ea4 100644 --- a/BAC0/sql/sql.py +++ b/BAC0/sql/sql.py @@ -155,7 +155,7 @@ def save(self, filename=None, resampling=None): filename = filename.split(".")[0] self.properties.db_name = filename else: - self.properties.db_name = "dev_{}".format(self.properties.device_id) + self.properties.db_name = "Device_{}".format(self.properties.device_id) if resampling is None: resampling = self.properties.save_resampling From d402d2092d04edfde28301bd90ec079f30c4106e Mon Sep 17 00:00:00 2001 From: "Christian Tremblay, ing" Date: Thu, 11 Feb 2021 20:55:52 -0500 Subject: [PATCH 03/11] Fixing bug in _rpm_request_by_name that was messing with polling and read_multiple. Collateral damage from reorganizing read_mixin. Related to issue #253 and PR #252 --- BAC0/core/devices/mixins/read_mixin.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/BAC0/core/devices/mixins/read_mixin.py b/BAC0/core/devices/mixins/read_mixin.py index 56b83768..03e582a2 100755 --- a/BAC0/core/devices/mixins/read_mixin.py +++ b/BAC0/core/devices/mixins/read_mixin.py @@ -89,12 +89,15 @@ def _rpm_request_by_name(self, point_list): points = [] requests = [] for each in point_list: - prop_type, prop_addr = each str_list = [] point = self._findPoint(each, force_read=False) points.append(point) - str_list.append(" {} {} presentValue".format(prop_type, prop_addr)) + str_list.append( + " {} {} presentValue".format( + point.properties.type, point.properties.address + ) + ) rpm_param = "".join(str_list) requests.append(rpm_param) @@ -439,6 +442,7 @@ def read_multiple( return values else: + self._log.debug("Read Multiple") big_request = self._rpm_request_by_name(points_list) i = 0 for request in batch_requests(big_request[0], points_per_request): @@ -446,6 +450,7 @@ def read_multiple( request = "{} {}".format( self.properties.address, "".join(request) ) + self._log.debug(request) val = self.properties.network.readMultiple( request, vendor_id=self.properties.vendor_id ) From 934bd26686015c72d807b29f20d91dabc16bb858 Mon Sep 17 00:00:00 2001 From: "Christian Tremblay, ing" Date: Thu, 11 Feb 2021 20:59:40 -0500 Subject: [PATCH 04/11] New feature allowing flexibility in changing description property. It is now possible to use dev['point'].update_description('New description super long') and it will work. write function have also been updated to detect if the property to be changed is description, to use this new function. Related to issue #246 --- BAC0/core/devices/Device.py | 47 ++++++++++++++++---------- BAC0/core/devices/Points.py | 63 +++++++++++++++++++++-------------- BAC0/core/functions/Text.py | 66 +++++++++++++++++++++++++++++++++++++ BAC0/scripts/Lite.py | 2 ++ 4 files changed, 136 insertions(+), 42 deletions(-) create mode 100644 BAC0/core/functions/Text.py diff --git a/BAC0/core/devices/Device.py b/BAC0/core/devices/Device.py index 417256ca..ed686aaa 100755 --- a/BAC0/core/devices/Device.py +++ b/BAC0/core/devices/Device.py @@ -723,24 +723,27 @@ def read_property(self, prop): return val def write_property(self, prop, value, priority=None): - if priority is not None: - priority = "- {}".format(priority) - if isinstance(prop, tuple): - _obj, _instance, _prop = prop + if prop == "description": + self.update_description(value) else: - raise ValueError( - "Please provide property using tuple with object, instance and property" - ) - try: - request = "{} {} {} {} {} {}".format( - self.properties.address, _obj, _instance, _prop, value, priority - ) - val = self.properties.network.write( - request, vendor_id=self.properties.vendor_id - ) - except KeyError as error: - raise Exception("Unknown property : {}".format(error)) - return val + if priority is not None: + priority = "- {}".format(priority) + if isinstance(prop, tuple): + _obj, _instance, _prop = prop + else: + raise ValueError( + "Please provide property using tuple with object, instance and property" + ) + try: + request = "{} {} {} {} {} {}".format( + self.properties.address, _obj, _instance, _prop, value, priority + ) + val = self.properties.network.write( + request, vendor_id=self.properties.vendor_id + ) + except KeyError as error: + raise Exception("Unknown property : {}".format(error)) + return val def update_bacnet_properties(self): """ @@ -773,6 +776,16 @@ def _bacnet_properties(self, update=False): def bacnet_properties(self): return self._bacnet_properties(update=True) + def update_description(self, value): + self.properties.network.send_text_write_request( + addr=self.properties.address, + obj_type="device", + obj_inst=int(self.device_id), + value=value, + prop_id="description", + ) + self.properties.description = self.read_property("description") + def __repr__(self): return "{} / Connected".format(self.properties.name) diff --git a/BAC0/core/devices/Points.py b/BAC0/core/devices/Points.py index b678571a..776a6d5a 100644 --- a/BAC0/core/devices/Points.py +++ b/BAC0/core/devices/Points.py @@ -351,33 +351,36 @@ def write(self, value, *, prop="presentValue", priority=""): :param priority: (int) priority to which write. """ - if priority != "": - if ( - isinstance(float(priority), float) - and float(priority) >= 1 - and float(priority) <= 16 - ): - priority = "- {}".format(priority) - else: - raise ValueError("Priority must be a number between 1 and 16") + if prop == "description": + self.update_description(value) + else: + if priority != "": + if ( + isinstance(float(priority), float) + and float(priority) >= 1 + and float(priority) <= 16 + ): + priority = "- {}".format(priority) + else: + raise ValueError("Priority must be a number between 1 and 16") - try: - self.properties.device.properties.network.write( - "{} {} {} {} {} {}".format( - self.properties.device.properties.address, - self.properties.type, - self.properties.address, - prop, - value, - priority, - ), - vendor_id=self.properties.device.properties.vendor_id, - ) - except NoResponseFromController: - raise + try: + self.properties.device.properties.network.write( + "{} {} {} {} {} {}".format( + self.properties.device.properties.address, + self.properties.type, + self.properties.address, + prop, + value, + priority, + ), + vendor_id=self.properties.device.properties.vendor_id, + ) + except NoResponseFromController: + raise - # Read after the write so history gets updated. - self.value + # Read after the write so history gets updated. + self.value def default(self, value): self.write(value, prop="relinquishDefault") @@ -598,6 +601,16 @@ def cancel_cov(self, callback=None): address, obj_tuple, callback=callback ) + def update_description(self, value): + self.properties.device.properties.network.send_text_write_request( + addr=self.properties.device.properties.address, + obj_type=self.properties.type, + obj_inst=int(self.properties.address), + value=value, + prop_id="description", + ) + self.properties.description = self.read_property("description") + # ------------------------------------------------------------------------------ diff --git a/BAC0/core/functions/Text.py b/BAC0/core/functions/Text.py new file mode 100644 index 00000000..a337fa62 --- /dev/null +++ b/BAC0/core/functions/Text.py @@ -0,0 +1,66 @@ +from bacpypes.iocb import IOCB +from bacpypes.core import deferred +from bacpypes.apdu import WritePropertyRequest, SimpleAckPDU +from bacpypes.primitivedata import CharacterString +from bacpypes.constructeddata import Any +from bacpypes.pdu import Address +from BAC0.core.io.IOExceptions import NoResponseFromController, WritePropertyException +from BAC0.core.io.Read import find_reason + + +class TextMixin: + """ + Mixin with functions to deal with text properties. + Adding features to "network" itself. + """ + + def send_text_write_request( + self, addr, obj_type, obj_inst, value, prop_id="description" + ): + request = self.build_text_write_request( + addr=addr, + obj_type=obj_type, + obj_inst=obj_inst, + value=value, + prop_id=prop_id, + ) + self.write_text_value(request) + + def build_text_write_request( + self, addr, obj_type, obj_inst, value, prop_id="description" + ): + request = WritePropertyRequest( + objectIdentifier=(obj_type, obj_inst), propertyIdentifier=prop_id + ) + request.pduDestination = Address(addr) + + _value = Any() + _value.cast_in(CharacterString(value)) + request.propertyValue = _value + + return request + + def write_text_value(self, request, timeout=10): + try: + iocb = IOCB(request) + iocb.set_timeout(timeout) + # pass to the BACnet stack + deferred(self.this_application.request_io, iocb) + + iocb.wait() # Wait for BACnet response + + if iocb.ioResponse: # successful response + apdu = iocb.ioResponse + + if not isinstance(apdu, SimpleAckPDU): # expect an ACK + self._log.error("Not an ack, see debug for more infos.") + return + + if iocb.ioError: # unsuccessful: error/reject/abort + apdu = iocb.ioError + reason = find_reason(apdu) + raise NoResponseFromController("APDU Abort Reason : {}".format(reason)) + + except WritePropertyException as error: + # construction error + self._log.error(("exception: {!r}".format(error))) diff --git a/BAC0/scripts/Lite.py b/BAC0/scripts/Lite.py index a2a13ee1..6c3b7516 100755 --- a/BAC0/scripts/Lite.py +++ b/BAC0/scripts/Lite.py @@ -40,6 +40,7 @@ class ReadWriteScript(BasicScript,ReadProperty,WriteProperty) from ..core.functions.DeviceCommunicationControl import DeviceCommunicationControl from ..core.functions.cov import CoV from ..core.functions.Schedule import Schedule +from ..core.functions.Text import TextMixin from ..core.io.Simulate import Simulation from ..core.devices.Points import Point from ..core.devices.Device import RPDeviceConnected, RPMDeviceConnected @@ -73,6 +74,7 @@ class Lite( DeviceCommunicationControl, CoV, Schedule, + TextMixin, ): """ Build a BACnet application to accept read and write requests. From 92e3d61064998aa47e0fa511f92458980f211d32 Mon Sep 17 00:00:00 2001 From: "Christian Tremblay, ing" Date: Thu, 11 Feb 2021 21:00:07 -0500 Subject: [PATCH 05/11] Bumping dev version --- BAC0/infos.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BAC0/infos.py b/BAC0/infos.py index 2e03161f..a1285fe4 100644 --- a/BAC0/infos.py +++ b/BAC0/infos.py @@ -12,5 +12,5 @@ __email__ = "christian.tremblay@servisys.com" __url__ = "https://github.com/ChristianTremblay/BAC0" __download_url__ = "https://github.com/ChristianTremblay/BAC0/archive/master.zip" -__version__ = "21.02.08dev" +__version__ = "21.02.11dev" __license__ = "LGPLv3" From 8958cab65ee4f9cd8d20bb5d76b57dedcd770b6e Mon Sep 17 00:00:00 2001 From: Guillaume Tamboise Date: Sat, 13 Feb 2021 10:21:50 +0100 Subject: [PATCH 06/11] document update object description --- doc/source/controller.rst | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/doc/source/controller.rst b/doc/source/controller.rst index 65f83f4a..223dc255 100644 --- a/doc/source/controller.rst +++ b/doc/source/controller.rst @@ -17,14 +17,16 @@ Example:: # which could be blocked in some cases. # You can also use : # bacnet = BAC0.lite() to force the script to load only minimum features. - # Please note that if Bokeh, Pandas or Flask are not installed, using connect() will in fact call the lite version. + # Please note that if Bokeh, Pandas or Flask are not installed, using connect() + # will in fact call the lite version. - # Get the list of devices seen on the network + # Query and display the list of devices seen on the network + bacnet.whois() bacnet.devices # Define a controller (this one is on MSTP #3, MAC addr 4, device ID 5504) mycontroller = BAC0.device('3:4', 5504, bacnet) - + # Get the list of "registered" devices bacnet.registered_devices @@ -258,9 +260,28 @@ You can read simple properties using :: device.read_property(prop) # this will return the priority array of AI1 -Write to property +Write property ........................... You can write to a property using :: prop = ('analogValue',1,'presentValue') - bacnet.write_property(prop,value=98,priority=7) + device.write_property(prop,value=98,priority=7) + + +Write description +........................... + +The **write_property** method will not work +to update a description if it contains a space. + +Instead, use **update_description** against a point:: + + device['AI_3'].update_description('Hello, World!') + +You can then read the description back, as a property:: + + device['AI_3'].read_property('description') + +or going back to the device:: + + device.read_property(('analogInput',3,'description')) From 4a24d8f703eb00edac87aa9fe8db5fe00f80c32c Mon Sep 17 00:00:00 2001 From: Safrone Date: Tue, 16 Feb 2021 12:53:37 -0500 Subject: [PATCH 07/11] Remove trailing comma to support python 3.5 Python 3.5 does not support trailing commas following a catch-all argument https://bugs.python.org/issue9232 --- BAC0/core/devices/Device.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BAC0/core/devices/Device.py b/BAC0/core/devices/Device.py index 417256ca..b11284fb 100755 --- a/BAC0/core/devices/Device.py +++ b/BAC0/core/devices/Device.py @@ -137,7 +137,7 @@ def __init__( save_resampling="1s", clear_history_on_save=False, history_size=None, - reconnect_on_failure=True, + reconnect_on_failure=True ): self.properties = DeviceProperties() From f71cfa6ffb4842c034bcd9d0221a1f5915bb59fd Mon Sep 17 00:00:00 2001 From: "Christian Tremblay, ing" Date: Fri, 19 Feb 2021 17:06:41 -0500 Subject: [PATCH 08/11] Changes required by ddcsequences. Virtual points more reliable, excluding them from polling though...Added a cache on read to eliminate multiple read per second for the same points. Required when multiple simulation are running. Now, only 1 read per second which should be enough for real world.High latency is now 60seconds instead of 5 seconds.... stop polluting stdout. --- BAC0/core/devices/Device.py | 9 +++++++++ BAC0/core/devices/Points.py | 28 +++++++++++++++++++++++++--- BAC0/core/devices/Virtuals.py | 28 ++++++++++++++++++++++------ BAC0/tasks/Poll.py | 2 +- BAC0/tasks/TaskManager.py | 3 ++- 5 files changed, 59 insertions(+), 11 deletions(-) diff --git a/BAC0/core/devices/Device.py b/BAC0/core/devices/Device.py index ed686aaa..117b2be5 100755 --- a/BAC0/core/devices/Device.py +++ b/BAC0/core/devices/Device.py @@ -52,6 +52,7 @@ from ...sql.sql import SQLMixin from ...tasks.DoOnce import DoOnce from .mixins.read_mixin import ReadPropertyMultiple, ReadProperty +from .Virtuals import VirtualPoint from ..utils.notes import note_and_log @@ -596,6 +597,14 @@ def __contains__(self, value): """ return value in self.points_name + @property + def pollable_points_name(self): + for each in self.points: + if not isinstance(each, VirtualPoint): + yield each.properties.name + else: + continue + @property def points_name(self): for each in self.points: diff --git a/BAC0/core/devices/Points.py b/BAC0/core/devices/Points.py index 776a6d5a..1497b629 100644 --- a/BAC0/core/devices/Points.py +++ b/BAC0/core/devices/Points.py @@ -9,7 +9,7 @@ """ # --- standard Python modules --- -from datetime import datetime +from datetime import datetime, timedelta from collections import namedtuple import time @@ -89,6 +89,8 @@ class Point: is added to a history table. Histories capture the changes to point values over time. """ + _cache_delta = timedelta(seconds=1) + def __init__( self, device=None, @@ -130,11 +132,19 @@ def __init__( self.cov_registered = False + self._cache = {"_previous_read": (None, None)} + @property def value(self): """ Retrieve value of the point """ + if ( + self._cache["_previous_read"][0] + and datetime.now() - self._cache["_previous_read"][0] < Point._cache_delta + ): + return self._cache["_previous_read"][1] + try: res = self.properties.device.properties.network.read( "{} {} {} presentValue".format( @@ -147,7 +157,7 @@ def value(self): # self._trend(res) except Exception as e: raise - + self._cache["_previous_read"] = (datetime.now(), res) return res def read_priority_array(self): @@ -566,7 +576,9 @@ def match_value(self, value, *, delay=5, use_last_value=False): self._match_task.running = False time.sleep(1) - self._match_task.task = Match_Value(value=value, point=self, delay=delay) + self._match_task.task = Match_Value( + value=value, point=self, delay=delay, use_last_value=use_last_value + ) self._match_task.task.start() self._match_task.running = True @@ -694,15 +706,25 @@ def __repr__(self): def __add__(self, other): return self.value + other + __radd__ = __add__ + def __sub__(self, other): return self.value - other + def __rsub__(self, other): + return other - self.value + def __mul__(self, other): return self.value * other + __rmul__ = __mul__ + def __truediv__(self, other): return self.value / other + def __rtruediv__(self, other): + return other / self.value + def __lt__(self, other): return self.value < other diff --git a/BAC0/core/devices/Virtuals.py b/BAC0/core/devices/Virtuals.py index 091adb9f..9715e815 100644 --- a/BAC0/core/devices/Virtuals.py +++ b/BAC0/core/devices/Virtuals.py @@ -133,7 +133,9 @@ def chart(self, remove=False): self._log.warning("Use bacnet.add_trend(point) instead") def _set(self, value): - if self._history_fn is None: + if value == "auto": + pass + elif self._history_fn is None: self.fake_pv = value self._trend(value) else: @@ -202,15 +204,17 @@ def history(self): his_table.datatype = self.properties.type return his_table - def match_value(self, value, *, delay=5): + def match_value(self, value, *, delay=5, use_last_value=False): """ This allow functions like : device['point'].match('value') A sensor will follow a calculation... """ - if self._match_task.task is None: - self._match_task.task = Match_Value(value=value, point=self, delay=delay) + if self._match_task.task is None or not self._match_task.running: + self._match_task.task = Match_Value( + value=value, point=self, delay=delay, use_last_value=use_last_value + ) self._match_task.task.start() self._match_task.running = True @@ -219,7 +223,9 @@ def match_value(self, value, *, delay=5): self._match_task.running = False time.sleep(1) - self._match_task.task = Match_Value(value=value, point=self, delay=delay) + self._match_task.task = Match_Value( + value=value, point=self, delay=delay, use_last_value=use_last_value + ) self._match_task.task.start() self._match_task.running = True @@ -234,22 +240,32 @@ def __repr__(self): return "{}/{} : {:.2f} {}".format( self.properties.device.properties.name, self.properties.name, - self.value, + float(self.value), self.properties.units_state, ) def __add__(self, other): return self.value + other + __radd__ = __add__ + def __sub__(self, other): return self.value - other + def __rsub__(self, other): + return other - self.value + def __mul__(self, other): return self.value * other + __rmul__ = __mul__ + def __truediv__(self, other): return self.value / other + def __rtruediv__(self, other): + return other / self.value + def __lt__(self, other): return self.value < other diff --git a/BAC0/tasks/Poll.py b/BAC0/tasks/Poll.py index 384daf60..4ae30a47 100644 --- a/BAC0/tasks/Poll.py +++ b/BAC0/tasks/Poll.py @@ -81,7 +81,7 @@ def device(self): def task(self): try: self.device.read_multiple( - list(self.device.points_name), points_per_request=25 + list(self.device.pollable_points_name), points_per_request=25 ) self._counter += 1 if self._counter == self.device.properties.auto_save: diff --git a/BAC0/tasks/TaskManager.py b/BAC0/tasks/TaskManager.py index e760a85b..63ca8411 100644 --- a/BAC0/tasks/TaskManager.py +++ b/BAC0/tasks/TaskManager.py @@ -125,6 +125,7 @@ def number_of_tasks(cls): @note_and_log class Task(object): _tasks = [] + high_latency = 60 def __init__(self, fn=None, name=None, delay=0): if not Manager.enable: @@ -177,7 +178,7 @@ def execute(self): self.average_execution_delay = self.delay # self._log.info('Stat for task {}'.format(self)) - if self.average_latency > 5: + if self.average_latency > Task.high_latency: self._log.warning("High latency for {}".format(self.name)) self._log.warning("Stats : {}".format(self)) From 60e6c5f02fece3092b98bbe2783c18e2a1a55d99 Mon Sep 17 00:00:00 2001 From: "Christian Tremblay, ing" Date: Fri, 19 Feb 2021 19:11:57 -0500 Subject: [PATCH 09/11] Added delay in test to bypass cache --- tests/test_Write.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_Write.py b/tests/test_Write.py index cc997494..e4bcff9b 100644 --- a/tests/test_Write.py +++ b/tests/test_Write.py @@ -16,7 +16,7 @@ def test_WriteAV(network_and_devices): test_device = network_and_devices.test_device old_value = test_device["AV"].value test_device["AV"] = 11.2 - # time.sleep(1) + time.sleep(1.5) # or cache will play a trick on you new_value = test_device["AV"].value assert (new_value - 11.2) < 0.01 From da82688bb07956b56abccf22274b8948bdffb20b Mon Sep 17 00:00:00 2001 From: "Christian Tremblay, ing" Date: Thu, 25 Feb 2021 10:09:28 -0500 Subject: [PATCH 10/11] Sourcery suggestion --- BAC0/core/io/Read.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/BAC0/core/io/Read.py b/BAC0/core/io/Read.py index 24114cfa..2298fa0a 100644 --- a/BAC0/core/io/Read.py +++ b/BAC0/core/io/Read.py @@ -167,17 +167,16 @@ def read( self._log.debug("{:<20} {:<20}".format("value", "datatype")) self._log.debug("{!r:<20} {!r:<20}".format(value, datatype)) - if prop_id_required: - try: - int(apdu.propertyIdentifier) - prop_id = "@prop_{}".format(apdu.propertyIdentifier) - value = list(value.items())[0][1] - except ValueError: - prop_id = apdu.propertyIdentifier - return (value, prop_id) - else: + if not prop_id_required: return value + try: + int(apdu.propertyIdentifier) + prop_id = "@prop_{}".format(apdu.propertyIdentifier) + value = list(value.items())[0][1] + except ValueError: + prop_id = apdu.propertyIdentifier + return (value, prop_id) if iocb.ioError: # unsuccessful: error/reject/abort apdu = iocb.ioError reason = find_reason(apdu) @@ -863,7 +862,6 @@ def validate_property_id(obj_type, prop_id): ) elif "@prop_" in prop_id: return int(prop_id.split("_")[1]) - # elif "@obj_" in prop_id: else: raise ValueError("{} is an invalid property for {}".format(prop_id, obj_type)) From e59c385c491b023ff2f9bbb150a995ebeb0c71c4 Mon Sep 17 00:00:00 2001 From: "Christian Tremblay, ing" Date: Thu, 25 Feb 2021 10:13:06 -0500 Subject: [PATCH 11/11] Bumping version --- BAC0/infos.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BAC0/infos.py b/BAC0/infos.py index a1285fe4..a343715b 100644 --- a/BAC0/infos.py +++ b/BAC0/infos.py @@ -12,5 +12,5 @@ __email__ = "christian.tremblay@servisys.com" __url__ = "https://github.com/ChristianTremblay/BAC0" __download_url__ = "https://github.com/ChristianTremblay/BAC0/archive/master.zip" -__version__ = "21.02.11dev" +__version__ = "21.02.25" __license__ = "LGPLv3"