diff --git a/CobraBay/bay.py b/CobraBay/bay.py index 7fcb62a..42a8817 100644 --- a/CobraBay/bay.py +++ b/CobraBay/bay.py @@ -7,13 +7,12 @@ from math import floor # from .detectors import CB_VL53L1X import logging -# from pprint import pformat, pprint from functools import wraps -# import sys -from .exceptions import SensorException -import CobraBay +from operator import attrgetter +from collections import namedtuple +from CobraBay.const import * -# Scan the detectors if we're asked for a property that needs a fresh can and we haven't scanned recently enough. +# Scan the detectors if we're asked for a property that needs a fresh and we haven't scanned recently enough. # def scan_if_stale(func): # @wraps(func) # def wrapper(self, *args, **kwargs): @@ -38,6 +37,7 @@ def log_changes(func): @wraps(func) def wrapper(self, *args, **kwargs): + print("Log changes wrapper received:\n\tArgs - {}\n\tKwargs - {}".format(args, kwargs)) # Call the function. retval = func(self, *args, **kwargs) if self._logger.level <= 20: @@ -61,13 +61,11 @@ def __init__(self, id, depth, stop_point, motion_timeout, - output_unit, - detectors, - detector_settings, - selected_range, - intercepts, + longitudinal, + lateral, + system_detectors, cbcore, - log_level="WARNING", **kwargs): + log_level="WARNING"): """ :param id: ID for the bay. Cannot have spaces. :type id: str @@ -79,20 +77,12 @@ def __init__(self, id, :type stop_point: Quantity(Distance) :param motion_timeout: During a movement, how long the bay must be still to be considered complete. :type motion_timeout: Quantity(Time) - :param output_unit: Unit to output measurements in. Should be a distance unit understood by Pint (ie: 'in', 'cm', etc) - :type output_unit: str - :param detectors: Dictionary of detector objects. - :type detectors: dict - :param detector_settings: Dictionary of detector configuration settings. - :type detector_settings: dict - :param selected_range: Of longitudinal sensors, which should be used as the default range sensor. - :type selected_range: str + :param system_detectors: Dictionary of detector objects available on the system. + :type system_detectors: dict :param longitudinal: Detectors which are arranged as longitudinal. - :type longitudinal: list + :type longitudinal: dict :param lateral: Detectors which are arranged as lateral. - :type lateral: list - :param intercepts: For lateral sensors, the raw distance from the end of the bay each lateral crosses the parking area. - :type intercepts: list + :type lateral: dict :param cbcore: Object reference to the CobraBay core. :type cbcore: object :param log_level: Log level for the bay, must be a Logging level. @@ -103,25 +93,24 @@ def __init__(self, id, self.id = id self._logger = logging.getLogger("CobraBay").getChild(self.id) - self._logger.setLevel(log_level) + self._logger.setLevel(log_level.upper()) self._logger.info("Initializing bay: {}".format(id)) - self._logger.debug("Bay received detectors: {}".format(detectors)) - + self._logger.debug("Bay received system detectors: {}".format(system_detectors)) # Save the remaining parameters. self._name = name self._depth = depth self._stop_point = stop_point self.motion_timeout = motion_timeout - self._output_unit = output_unit - self._detectors = detectors - self._detector_settings = detector_settings - self._selected_range = selected_range - self._intercepts = intercepts - self.lateral_sorted = self._sort_lateral(intercepts) + self._detectors = None self._cbcore = cbcore - # Create a logger. + # Select a longitudinal detector to be the main range detector. + # Only one supported currently. + self._selected_range = self._select_range(longitudinal) + self._logger.debug("Longitudinal detector '{}' selected for ranging".format(self._selected_range)) + # Sort the lateral detectors by intercept. + self.lateral_sorted = self._sort_lateral(lateral['detectors']) # Initialize variables. self._position = {} @@ -130,19 +119,18 @@ def __init__(self, id, self._previous_scan_ts = 0 self._state = None self._occupancy = None - self._previous = { + self._previous = {} - } # Calculate the adjusted depth. self._adjusted_depth = self._depth - self._stop_point # Create a unit registry. self._ureg = UnitRegistry - # Store the detector objects - self._detectors = detectors # Apply our configurations to the detectors. - self._setup_detectors() + self._detectors = self._setup_detectors(longitudinal=longitudinal, lateral=lateral, system_detectors=system_detectors) + + # Report configured detectors self._logger.info("Detectors configured:") for detector in self._detectors.keys(): try: @@ -152,9 +140,9 @@ def __init__(self, id, self._logger.info("\t\t{} - {}".format(detector,addr)) # Activate detectors. - self._logger.debug("Activating detectors...") - self._detector_state('ranging') - self._logger.debug("Detectors activated.") + self._logger.info("Activating detectors...") + self._detector_state(SENSTATE_RANGING) + self._logger.info("Detectors activated.") # Motion timer for the current motion. self._current_motion = { @@ -220,14 +208,6 @@ def discovery_reg_info(self): return_dict['detectors'].append(detector) return return_dict - @property - def display_reg_info(self): - return_dict = { - 'id': self.id, - 'lateral_order': self.lateral_sorted - } - return return_dict - @property def id(self): return self._id @@ -273,7 +253,6 @@ def _occupancy_score(self): # Bay properties @property - @log_changes def occupied(self): """ Occupancy state of the bay, determined based on what the sensors can hit. @@ -285,7 +264,7 @@ def occupied(self): self._logger.debug("Checking for occupancy.") occ = 'unknown' # Range detector is required to determine occupancy. If it's not ranging, return immediately. - if self._detectors[self._selected_range].state != 'ranging': + if self._detectors[self._selected_range].state != SENSTATE_RANGING: occ = 'unknown' # Only hit the range quality once. range_quality = self._detectors[self._selected_range].quality @@ -293,15 +272,17 @@ def occupied(self): # If the detector can hit the garage door, or the door is open, then clearly nothing is in the way, so # the bay is vacant. self._logger.debug("Longitudinal quality is {}, not occupied.".format(range_quality)) - return "false" - elif range_quality in ('emergency', 'back_up', 'park', 'final', 'base'): + occ = "false" + elif range_quality in (DETECTOR_QUALITY_EMERG, DETECTOR_QUALITY_BACKUP, DETECTOR_QUALITY_PARK, + DETECTOR_QUALITY_FINAL, DETECTOR_QUALITY_BASE): self._logger.debug("Matched range quality: {}".format(range_quality)) # If the detector is giving us any of the 'close enough' qualities, there's something being found that # could be a vehicle. Check the lateral sensors to be sure that's what it is, rather than somebody blocking # the sensors or whatnot lat_score = 0 - for detector in self.lateral_sorted: - if self._detectors[detector].quality in ('ok', 'warning', 'critical'): + for intercept in self.lateral_sorted: + if self._detectors[intercept.lateral].quality in (DETECTOR_QUALITY_OK, DETECTOR_QUALITY_WARN, + DETECTOR_QUALITY_CRIT): # No matter how badly parked the vehicle is, it's still *there* lat_score += 1 self._logger.debug("Achieved lateral score {} of {}".format(lat_score, self._occupancy_score)) @@ -312,7 +293,9 @@ def occupied(self): occ = 'false' else: occ = 'error' - self._logger.debug("Occupancy determined to be '{}'".format(occ)) + if occ != self._occupancy: + self._logger.info("Occupancy has changed from '{}' to '{}'".format(self._occupancy, occ)) + self._occupancy = occ return occ @property @@ -382,7 +365,6 @@ def shutdown(self): self._logger.critical("Shutdown complete. Exiting.") @property - @log_changes def state(self): """ Operating state of the bay. @@ -408,13 +390,13 @@ def state(self, m_input): if m_input == self._state: self._logger.debug("Requested state {} is also current state. No action.".format(m_input)) return - if m_input in ('docking', 'undocking') and self._state not in ('docking', 'undocking'): + if m_input in SYSSTATE_MOTION and self._state not in SYSSTATE_MOTION: self._logger.info("Entering state: {}".format(m_input)) self._logger.info("Start time: {}".format(self._current_motion['mark'])) self._logger.debug("Setting all detectors to ranging.") - self._detector_state('ranging') + self._detector_state(SENSTATE_RANGING) self._current_motion['mark'] = monotonic() - if m_input not in ('docking', 'undocking') and self._state in ('docking', 'undocking'): + if m_input not in SYSSTATE_MOTION and self._state in SYSSTATE_MOTION: self._logger.info("Entering state: {}".format(m_input)) # Reset some variables. # Make the mark none to be sure there's not a stale value in here. @@ -423,7 +405,6 @@ def state(self, m_input): self._state = m_input @property - @log_changes def vector(self): return self._detectors[self._selected_range].vector @@ -442,7 +423,7 @@ def _detector_status(self): # If the detector is actively ranging, add the values. self._logger.debug("Detector has status: {}".format(self._detectors[detector].status)) - if self._detectors[detector].status == 'ranging': + if self._detectors[detector].status == SENSTATE_RANGING: detector_message['message']['adjusted_reading'] = self._detectors[detector].value detector_message['message']['raw_reading'] = self._detectors[detector].value_raw # While ranging, always send values to MQTT, even if they haven't changed. @@ -452,72 +433,47 @@ def _detector_status(self): return_list.append(detector_message) return return_list - # Tells the detectors to update. - # Note, this does NOT trigger timer operations. - def _scan_detectors(self, filter_lateral=True): - self._logger.debug("Starting detector scan.") - self._logger.debug("Have detectors: {}".format(self._detectors)) - # Staging dicts. This makes sure we wipe any items that need to be wiped. - position = {} - quality = {} - # Check all the detectors. - for detector_name in self._detectors: - try: - position[detector_name] = self._detectors[detector_name].value - except SensorException: - # For now, pass. Need to add logic here to actually set the overall bay status. - pass - - quality[detector_name] = self._detectors[detector_name].quality - self._logger.debug("Read of detector {} returned value '{}' and quality '{}'". - format(self._detectors[detector_name].name, position[detector_name], - quality[detector_name])) - - if filter_lateral: - # Pull the raw range value once, use it to test all the intercepts. - raw_range = self._detectors[self._selected_range].value_raw - for lateral_name in self.lateral_sorted: - # If intercept range hasn't been met yet, we wipe out any value, it's meaningless. - # Have a bug where this is sometimes erroring out due to a None range value. - # Trapping and logging for now. - try: - if raw_range > self._intercepts[lateral_name]: - quality[lateral_name] = "Not Intercepted" - self._logger.debug("Sensor {} with intercept {}. Range {}, not intercepted.". - format(lateral_name, - self._intercepts[lateral_name].to('cm'), - raw_range.to('cm'))) - except ValueError: - self._logger.debug("For lateral sensor {} cannot compare intercept {} to range {}". - format(lateral_name, - self._intercepts[lateral_name], - raw_range)) - self._position = position - self._quality = quality + def _select_range(self, longitudinal): + """ + Select a primary longitudinal sensor to use for range from among those presented. + + :param longitudinal: + :return: str + """ + self._logger.debug("Detectors considered for selection: {}".format(longitudinal['detectors'])) + if len(longitudinal['detectors']) == 0: + raise ValueError("No longitudinal detectors defined. Cannot select a range!") + elif len(longitudinal['detectors']) > 1: + raise ValueError("Cannot select a range! More than one longitudinal detector not currently supported.") + else: + return longitudinal['detectors'][0]['detector'] # Calculate the ordering of the lateral sensors. - def _sort_lateral(self, intercepts): - self._logger.debug("Sorting intercepts: {}".format(intercepts)) + def _sort_lateral(self, lateral_detectors): + """ + Sort the lateral detectors by distance. + + :param lateral_detectors: List of lateral detector definition dicts. + :type lateral_detectors: list + :return: + """ + # Create the named tuple type to store both detector name and intercept distance. + Intercept = namedtuple('Intercept', ['lateral','intercept']) + + self._logger.debug("Creating sorted intercepts from laterals: {}".format(lateral_detectors)) lateral_sorted = [] - for detector_name in intercepts: - detector_intercept = intercepts[detector_name] - if len(lateral_sorted) == 0: - lateral_sorted.append(detector_name) - else: - i=0 - while i < len(lateral_sorted): - if detector_intercept < intercepts[lateral_sorted[i]]: - lateral_sorted.insert(i, detector_name) - break - i += 1 - if i == len(lateral_sorted): - lateral_sorted.append(detector_name) + # Create named tuples and put it in the list. + for item in lateral_detectors: + # Make a named tuple out of the detector's config. + this_detector = Intercept(item['detector'], item['intercept']) + lateral_sorted.append(this_detector) + lateral_sorted = sorted(lateral_sorted, key=attrgetter('intercept')) self._logger.debug("Lateral detectors sorted to order: {}".format(lateral_sorted)) return lateral_sorted # Traverse the detectors dict, activate everything that needs activating. def _detector_state(self, target_status): - if target_status in ('disabled','enabled','ranging'): + if target_status in (SENSTATE_DISABLED, SENSTATE_ENABLED, SENSTATE_RANGING): self._logger.debug("Traversing detectors to set status to '{}'".format(target_status)) # Traverse the dict looking for detectors that need activation. for detector in self._detectors: @@ -526,18 +482,44 @@ def _detector_state(self, target_status): else: raise ValueError("'{}' not a valid state for detectors.".format(target_status)) - # Apply specific config options to the detectors. - def _setup_detectors(self): - # For each detector we use, apply its properties. - self._logger.debug("Detectors: {}".format(self._detectors.keys())) - for dc in self._detector_settings.keys(): - self._logger.info("Configuring detector {}".format(dc)) - self._logger.debug("Settings: {}".format(self._detector_settings[dc])) - # Apply all the bay-specific settings to the detector. Usually these are defined in the detector-settings. - for item in self._detector_settings[dc]: - self._logger.info( - "Setting property {} to {}".format(item, self._detector_settings[dc][item])) - setattr(self._detectors[dc], item, self._detector_settings[dc][item]) - # Bay depth is a bay global. For range sensors, this also needs to get applied. - if isinstance(self._detectors[dc], CobraBay.detectors.Range): - setattr(self._detectors[dc], "bay_depth", self._depth) + # Configure system detectors for this bay. + def _setup_detectors(self, longitudinal, lateral, system_detectors): + # Output dictionary. + configured_detectors = {} + # Some debug output + self._logger.debug("Available detectors on system: {}".format(system_detectors)) + self._logger.debug("Bay Longitudinal settings: {}".format(longitudinal)) + self._logger.debug("Bay Lateral settings: {}".format(lateral)) + + for direction in ( longitudinal, lateral ): + for detector_settings in direction['detectors']: + # Merge in the defaults. + for item in direction['defaults']: + if item not in detector_settings: + detector_settings[item] = direction['defaults'][item] + detector_id = detector_settings['detector'] + del(detector_settings['detector']) + try: + configured_detectors[detector_id] = system_detectors[detector_id] + except KeyError: + self._logger.error("Bay references unconfigured detector '{}'".format(detector_id)) + else: + self._logger.info("Configuring detector '{}'".format(detector_id)) + self._logger.debug("Settings: {}".format(detector_settings)) + # Apply all the bay-specific settings to the detector. Usually these are defined in the + # detector-settings. + for item in detector_settings: + self._logger.info( + "Setting property {} to {}".format(item, detector_settings[item])) + setattr(configured_detectors[detector_id], item, detector_settings[item]) + # Bay depth is a bay global. For range sensors, this also needs to get applied. + if direction is longitudinal: + self._logger.debug("Applying bay depth '{}' to longitudinal detector.".format(self._depth)) + setattr(configured_detectors[detector_id], "bay_depth", self._depth) + elif direction is lateral: + + setattr(configured_detectors[detector_id], "attached_bay", self ) + self._logger.debug("Attaching bay object reference '{}' to lateral detector.". + format(configured_detectors[detector_id].attached_bay)) + self._logger.debug("Configured detectors: {}".format(configured_detectors)) + return configured_detectors \ No newline at end of file diff --git a/CobraBay/cli/main.py b/CobraBay/cli/main.py index b6ef1ed..b33ccf4 100644 --- a/CobraBay/cli/main.py +++ b/CobraBay/cli/main.py @@ -33,12 +33,12 @@ def main(): # Create a CobraBay config object. try: - cbconfig = CobraBay.CBConfig(config_file=arg_config, reset_sensors=True) + cbconfig = CobraBay.CBConfig(config_file=arg_config) except ValueError as e: print(e) sys.exit(1) - # Initialize the object. + # Initialize the system cb = CobraBay.CBCore(config_obj=cbconfig) # Start the main operating loop. diff --git a/CobraBay/config.py b/CobraBay/config.py index c6f8030..6d13720 100644 --- a/CobraBay/config.py +++ b/CobraBay/config.py @@ -1,172 +1,544 @@ #### -# Cobra Bay - Config Loader +# Cobra Bay - Config Manager #### import logging -import os.path -import sys +import pprint import yaml from pathlib import Path -from pint import Quantity +import pint +import cerberus +from collections import namedtuple from pprint import pformat -import importlib +import importlib.resources +# Subclass Validator to add custom rules, maybe types. +class CBValidator(cerberus.Validator): + types_mapping = cerberus.Validator.types_mapping.copy() + types_mapping['quantity'] = cerberus.TypeDefinition('quantity', (pint.Quantity,), ()) + + # # Checks to see if a value can be converted by Pint, and if it has a given dimensionality. + def _validate_dimensionality(self, constraint, field, value): + """ + {'type': 'string'} + """ + if str(value.dimensionality) != constraint: + self._error(field, "Not in proper dimension {}".format(constraint)) class CBConfig: - def __init__(self, config_file=None, reset_sensors=False, log_level="WARNING"): + """ + Class to manage a single instance of a CobraBay configuration. + Create an instance for each config version managed. + """ + SCHEMA_SENSOR_VL53L1X = { + 'i2c_bus': {'type': 'integer', 'default': 1}, + 'i2c_address': {'type': 'integer', 'required': True}, + 'enable_board': {'type': 'integer', 'required': True}, + 'enable_pin': {'type': 'integer', 'required': True}, + 'distance_mode': {'type': 'string', 'allowed': ['long','short'], 'default': 'long'}, + 'timing': {'type': 'string', 'default': '200ms'} + } + SCHEMA_SENSOR_TFMINI = { + 'port': { 'type': 'string', 'required': True }, + 'baud': { 'type': 'integer', 'default': 115200, + 'allowed': [9600, 14400, 19200, 56000, 115200, 460800, 921600] }, + 'clustering': { 'type': 'integer', 'default': 1, 'min': 1, 'max': 5 } + } + SCHEMA_MAIN = { + 'system': { + 'type': 'dict', + 'required': True, + 'schema': { + 'unit_system': {'type': 'string', 'allowed': ['metric', 'imperial'], 'default': 'metric'}, + 'system_name': {'type': 'string'}, + 'mqtt': { + 'type': 'dict', + 'schema': { + 'broker': {'type': 'string'}, + 'port': {'type': 'integer', 'default': 1883}, + 'username': {'type': 'string'}, + 'password': {'type': 'string'}, + 'accept_commands': {'type': 'boolean', 'default': True}, + 'ha_discover': {'type': 'boolean', 'default': True} + } + }, + 'interface': {'type': 'string'}, ## Define a method to determine default. + 'logging': { + 'type': 'dict', + 'required': True, + 'schema': { + 'console': {'type': 'boolean', 'required': True, 'default': False}, + 'file': {'type': 'boolean', 'required': True, 'default': True}, + 'file_path': {'type': 'string', 'default': str(Path.cwd() / 'cobrabay.log')}, + 'log_format': {'type': 'string', 'default': '%(asctime)s - %(name)s - %(levelname)s - %(message)s'}, + 'default_level': {'type': 'string', + 'allowed': ['debug', 'info', 'warning', 'error', 'critical'], + 'required': True, 'default': 'warning'}, + 'bays': {'type': 'string', + 'allowed': ['debug', 'info', 'warning', 'error', 'critical'], + 'default_setter': lambda doc: doc['default_level']}, + 'config': {'type': 'string', 'allowed': ['debug', 'info', 'warning', 'error', 'critical'], + 'default_setter': lambda doc: doc['default_level']}, + 'core': {'type': 'string', 'allowed': ['debug', 'info', 'warning', 'error', 'critical'], + 'default_setter': lambda doc: doc['default_level']}, + 'detectors': {'type': 'string', 'allowed': ['debug', 'info', 'warning', 'error', 'critical'], + 'default_setter': lambda doc: doc['default_level']}, + 'display': {'type': 'string', 'allowed': ['debug', 'info', 'warning', 'error', 'critical'], + 'default_setter': lambda doc: doc['default_level']}, + 'mqtt': {'type': 'string', 'allowed': ['debug', 'info', 'warning', 'error', 'critical', 'DISABLE'], + 'default': 'DISABLE'}, + 'network': {'type': 'string', 'allowed': ['debug', 'info', 'warning', 'error', 'critical'], + 'default_setter': lambda doc: doc['default_level']}, + 'triggers': {'type': 'string', 'allowed': ['debug', 'info', 'warning', 'error', 'critical'], + 'default_setter': lambda doc: doc['default_level']} + } # Figure out how to handle specific sensors detectors and bays. + } + } + }, + 'triggers': { + 'type': 'dict', + 'required': True, + 'keysrules': { + 'type': 'string', + 'regex': '[\w]+' + }, + 'valuesrules': { + 'type': 'dict', + 'schema': { + 'type': {'type': 'string', 'required': True, 'allowed': ['mqtt_state','syscmd','baycmd']}, + 'bay': {'type': 'string', 'required': True, 'dependencies': {'type': 'mqtt_state'}}, + 'topic': {'type': 'string', 'required': True}, + 'to': {'type': 'string', 'dependencies': {'type': 'mqtt_state'}, 'excludes': 'from'}, + 'from': {'type': 'string', 'dependencies': {'type': 'mqtt_state'}, 'excludes': 'to'}, + 'action': {'type': 'string', 'required': True, 'allowed': ['dock','undock','occupancy']} + } + } + }, + 'display': { + 'type': 'dict', + 'required': True, + 'schema': { + 'width': {'type': 'integer', 'required': True}, + 'height': {'type': 'integer', 'required': True}, + 'gpio_slowdown': {'type': 'integer', 'required': True, 'default': 4}, + 'font': {'type': 'string', + 'default_setter': + lambda doc: str(importlib.resources.files('CobraBay.data').joinpath('OpenSans-Light.ttf')) }, + 'strobe_speed': { 'type': 'quantity', 'dimensionality': '[time]', 'coerce': pint.Quantity }, + 'mqtt_image': {'type': 'boolean', 'default': True}, + 'mqtt_update_interval': {'type': 'quantity', 'dimensionality': '[time]', 'coerce': pint.Quantity} + } + }, + 'detectors': { + 'type': 'dict', + 'required': True, + 'keysrules': { + 'type': 'string', + 'allowed': ['longitudinal', 'lateral'] + }, + 'valuesrules': { + 'type': 'dict', + 'keysrules': { + 'type': 'string', + 'regex': '[\w]+' + }, + 'valuesrules': { + 'type': 'dict', + 'schema': { + 'name': {'type': 'string'}, + 'error_margin': {'type': 'quantity', 'dimensionality': '[length]', 'coerce': pint.Quantity }, + 'sensor_type': {'type': 'string', 'required': True, 'allowed': ['TFMini', 'VL53L1X']}, + # 'timing': {'type': 'quantity', 'dimensionality': '[time]', 'coerce': pint.Quantity}, + 'sensor_settings': { + 'type': 'dict', + # 'required': True, + 'oneof': [ + {'dependencies': {'sensor_type': 'TFMini'}, 'schema': SCHEMA_SENSOR_TFMINI}, + {'dependencies': {'sensor_type': 'VL53L1X'}, 'schema': SCHEMA_SENSOR_VL53L1X} + ] + } + } + } + } + }, + 'bays': { + 'type': 'dict', + 'required': True, + 'keysrules': { + 'type': 'string', + 'regex': '[\w]+' + }, + 'valuesrules': { + 'type': 'dict', + 'allow_unknown': True, + 'schema': { + 'name': { 'type': 'string' }, + 'motion_timeout': { 'type': 'quantity', 'dimensionality': '[time]', 'coerce': pint.Quantity }, + 'depth': { 'type': 'quantity', 'dimensionality': '[length]', 'coerce': pint.Quantity }, + 'stop_point': { 'type': 'quantity', 'dimensionality': '[length]', 'coerce': pint.Quantity }, + 'longitudinal': { + 'type': 'dict', + 'allow_unknown': True, + 'schema': { + 'defaults': { + 'type': 'dict', + 'schema': { + 'spread_park': { 'type': 'quantity', 'dimensionality': '[length]', + 'coerce': pint.Quantity, 'default': '2 in' }, + 'offset': { 'type': 'quantity', 'dimensionality': '[length]', + 'coerce': pint.Quantity, 'default': '0 in' }, + 'pct_warn': { 'type': 'number', 'min': 0, 'max': 100, 'default': 70 }, + 'pct_crit': { 'type': 'number', 'min': 0, 'max': 100, 'default': 90 } + } + }, + 'detectors': { + 'type': 'list', + 'schema': { + 'type': 'dict', + 'schema': { + 'spread_park': {'type': 'quantity', 'dimensionality': '[length]', + 'coerce': pint.Quantity}, + 'offset': {'type': 'quantity', 'dimensionality': '[length]', + 'coerce': pint.Quantity}, + 'pct_warn': {'type': 'number', 'min': 0, 'max': 100}, + 'pct_crit': {'type': 'number', 'min': 0, 'max': 100} + } + } + } + } + }, + 'lateral': { + 'type': 'dict', + 'allow_unknown': True, + 'schema': { + 'defaults': { + 'type': 'dict', + 'schema': { + 'offset': {'type': 'quantity', 'dimensionality': '[length]', + 'coerce': pint.Quantity, 'default': '0 in'}, + 'spread_ok': {'type': 'quantity', 'dimensionality': '[length]', + 'coerce': pint.Quantity, 'default': '1 in'}, + 'spread_warn': {'type': 'quantity', 'dimensionality': '[length]', + 'coerce': pint.Quantity, 'default': '3 in'}, + 'limit': {'type': 'quantity', 'dimensionality': '[length]', + 'coerce': pint.Quantity, 'default': '96 in'}, + 'side': {'type': 'string', 'allowed': ['L', 'R']} + } + }, + 'detectors': { + 'type': 'list', + 'schema': { + 'type': 'dict', + 'schema': { + 'detector': {'type': 'string', 'required': True}, + 'offset': {'type': 'quantity', 'dimensionality': '[length]', + 'coerce': pint.Quantity}, + 'spread_ok': {'type': 'quantity', 'dimensionality': '[length]', + 'coerce': pint.Quantity}, + 'spread_warn': {'type': 'quantity', 'dimensionality': '[length]', + 'coerce': pint.Quantity}, + 'limit': {'type': 'quantity', 'dimensionality': '[length]', + 'coerce': pint.Quantity}, + 'intercept': {'type': 'quantity', 'required': True, 'dimensionality': '[length]', + 'coerce': pint.Quantity}, + 'side': {'type': 'string', 'allowed': ['L', 'R']} + } + } + } + } + } + } + } + } + } + + + + def __init__(self, config_file=None, auto_load=True, log_level="WARNING"): + """ + Create a new config object. + + :param config_file: Config file to be attached to this object. + :type: string *OR* Path object. + :param auto_load: Load and validate config file on init + :type: bool + :param log_level: Logging level for Configuration processing + :type: str + """ self._config = None self._logger = logging.getLogger("CobraBay").getChild("Config") self._logger.setLevel(log_level) # Initialize the internal config file variable - self._config_file = None - # Default search paths. - search_paths = [ - Path('/etc/cobrabay/config.yaml'), - Path.cwd().joinpath('config.yaml') - ] - if isinstance(config_file,Path): - search_paths.insert(0, Path(config_file)) - - for path in search_paths: + self._config_path = config_file + if auto_load: + # Load the config file. This does validation as well! try: - self.config_file = path - except: - pass - if self._config_file is None: - raise ValueError("Cannot find valid config file! Attempted: {}".format([str(i) for i in search_paths])) - # Load the config file. This does validation as well! - self.load_config() + valid = self.load_config() + except BaseException as e: + raise e + else: + if not valid: + raise ValueError("Configuration not valid, cannot continue!") + # If necessary, adjust our own log level. new_loglevel = self.get_loglevel('config') if new_loglevel != log_level: self._logger.setLevel(new_loglevel) self._logger.warning("Adjusted config module logging level to '{}'".format(new_loglevel)) - def load_config(self, reset_sensors=False): - # Open the current config file and suck it into a staging variable. - staging_yaml = self._open_yaml(self._config_file) - # Do a formal validation here? Probably! + def load_config(self): + """ + Load and validate config from the specified file. + :return: + """ + self._logger.info("Loading file '{}'".format(self._config_path)) + staging_yaml = self._read_yaml(self._config_path) try: - validated_yaml = self._validator(staging_yaml) + validator_result = self._validator(staging_yaml) except KeyError as e: raise e else: - self._logger.info("Config file validated.") - # We're good, so assign the staging to the real config. - self._logger.debug("Active configuration:") - self._logger.debug(pformat(validated_yaml)) - self._config = validated_yaml + if validator_result.valid: + self._logger.info("Configuration is valid! Saving.") + self._config = validator_result.result + return True + else: + self._logger.error("Loaded configuration is not valid. Has the following errors:") + self._logger.error(pformat(validator_result.result)) + return False @property - def config_file(self): - if self._config_file is None: + def config_path(self): + if self._config_path is None: return None else: - return str(self._config_file) + return self._config_path - @config_file.setter - def config_file(self, the_input): + @config_path.setter + def config_path(self, the_input): # IF a string, convert to a path. if isinstance(the_input, str): the_input = Path(the_input) if not isinstance(the_input, Path): # If it's not a Path now, we can't use this. - raise TypeError("Config file must be either a string or a Path object.") - if not the_input.is_file(): - raise ValueError("Provided config file {} is not actually a file!".format(the_input)) + raise TypeError("Config path must be either a string or a Path object.") # If we haven't trapped yet, assign it. - self._config_file = the_input + self._config_path = the_input - # Method for opening loading a Yaml file and slurping it in. @staticmethod - def _open_yaml(config_path): - with open(config_path, 'r') as config_file_handle: - config_yaml = yaml.safe_load(config_file_handle) + def _read_yaml(file_path): + """ + Open a YAML file and return its contents. + + :param config_path: + :return: + """ + with open(file_path, 'r') as file_handle: + config_yaml = yaml.safe_load(file_handle) return config_yaml - # Main validator. - def _validator(self, staging_yaml): - # Check for the main sections. - for section in ('system', 'triggers', 'display', 'detectors', 'bays'): - if section not in staging_yaml.keys(): - raise KeyError("Required section {} not in config file.".format(section)) - staging_yaml['system'] = self._validate_system(staging_yaml['system']) - # If MQTT Commands are enabled, create that trigger config. Will be picked up by the core. - if staging_yaml['system']['mqtt_commands']: - self._logger.info("Enabling MQTT Command processors.") - try: - staging_yaml['triggers']['sys_cmd'] = { 'type': 'syscommand' } - except TypeError: - staging_yaml['triggers'] = {} - staging_yaml['triggers']['sys_cmd'] = {'type': 'syscommand'} - - for bay_id in staging_yaml['bays'].keys(): - trigger_name = bay_id + "_cmd" - staging_yaml['triggers'][trigger_name] = {'type': 'baycommand', 'bay_id': bay_id } - return staging_yaml - - # Validate the system section. - def _validate_system(self, system_config): - valid_keys = ('unit_system', 'system_name', 'mqtt', 'mqtt_commands', 'interface', 'homeassistant', 'logging') - required_keys = ('system_name', 'interface') - # Remove any key values that aren't valid. - for actual_key in system_config: - if actual_key not in valid_keys: - # Delete unknown keys. - self._logger.error("System config has unknown item '{}'. Ignoring.".format(actual_key)) - # Required keys for which we must have a value, but not a *specific* value. - for required_key in required_keys: - if required_key not in system_config: - self._logger.critical("System config requires '{}' to be set. Cannot continue.".format(required_key)) - sys.exit(1) - elif not isinstance(system_config[required_key], str): - self._logger.critical("Required system config item '{}' must be a string. Cannot continue.".format(required_key)) - sys.exit(1) - else: - # Strip spaces and we're good to go. - system_config[required_key] = system_config[required_key].replace(" ", "_") - # Specific value checks. - if system_config['unit_system'].lower() not in ('metric', 'imperial'): - self._logger.debug("Unit setting {} not valid, defaulting to metric.".format(system_config['unit_system'])) - # If not metric or imperial, default to metric. - system_config['unit_system'] = 'metric' + @staticmethod + def _write_yaml(self, file_path, contents): + """ + Write out to a YAML file. + """ + with open(file_path, 'w') as file_handle: + yaml.dump(contents, file_handle) + + def validate(self): + """ + Confirm the currently loaded configuration is valid. + + :return: + """ + if isinstance(self._config, dict): + return self._validator(self._config) + else: + raise ValueError("Cannot validate before config is loaded!") + + def _validator(self, validation_target): + """ + Validate the validation target against the CobraBay Schema. + + :param validation_target: + :return: + """ + # Create a named tuple for return to prevent type changing. Will return + CBValidation = namedtuple("CBValidation", ['valid', 'result']) + # Create the main validator + mv = CBValidator(self.SCHEMA_MAIN) - return system_config + try: + returnval = mv.validated(validation_target) + except BaseException as e: + self._logger.error("Could not validate. '{}'".format(e)) + return CBValidation(False, mv.errors) + + # Trap return failures and kick back false and the errors. + if returnval is None: + return CBValidation(False, mv.errors) + else: - def _validate_general(self): - pass + # Inject the system command handler. + returnval['triggers']['syscmd'] = { + 'type': 'syscmd', + 'topic': 'cmd', + 'topic_mode': 'suffix' + } + # Inject a bay command handler trigger for every define defined bay. + for bay_id in returnval['bays']: + returnval['triggers'][bay_id] = { + 'type': 'baycmd', + 'topic': 'cmd', + 'topic_mode': 'suffix', + 'bay_id': bay_id + } + + # Because the 'oneof' options in the schema don't normalize, we need to go back in and normalize those. + # Subvalidate detectors. + sv = CBValidator() + for direction in ('longitudinal', 'lateral'): + for detector_id in returnval['detectors'][direction]: + # print("Detector settings before subvalidation.") + # pprint.pprint(returnval['detectors'][direction][detector_id]['sensor_settings']) + # Select the correct target schema based on the sensor type. + if returnval['detectors'][direction][detector_id]['sensor_type'] == 'VL53L1X': + target_schema = self.SCHEMA_SENSOR_VL53L1X + elif returnval['detectors'][direction][detector_id]['sensor_type'] == 'TFMini': + target_schema = self.SCHEMA_SENSOR_TFMINI + else: + # Trap unknown sensor types. This should never happen! + return CBValidation(False, "Incorrect sensor type during detector normalization '{}'".format( + detector_id)) + + # Do it. + try: + validated_ds = sv.validated( + returnval['detectors'][direction][detector_id]['sensor_settings'], target_schema) + except BaseException as e: + self._logger.error("Could not validate. '{}'".format(e)) + return CBValidation(False, sv.errors) + if validated_ds is None: + return CBValidation(False, "During subvalidation of detector '{}', received errors '{}". + format(detector_id, sv.errors)) + else: + # Merge the validated/normalized sensor settings into the main config. + returnval['detectors'][direction][detector_id]['sensor_settings'] = validated_ds + + return CBValidation(True, returnval) + + # Quick fetchers. + # These allow fetching subsections of the config. + @property + def config(self): + return self._config def log_handlers(self): - # Set defaults. - config_dict = { - 'console': False, - 'file': False, - 'file_path': Path.cwd() / ( self._config['system']['system_name'] + '.log' ), - 'syslog': False, - 'format': logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') - } + include_items = ['console', 'file', 'file_path', 'log_format'] + return dict( + filter( + lambda item: item[0] in include_items, self._config['system']['logging'].items() + ) + ) - # Check for Console - try: - if self._config['system']['logging']['console']: - config_dict['console'] = True - except KeyError: - pass + def network(self): + """ Get network settings from the config.""" + the_return = { + 'unit_system': self._config['system']['unit_system'], + 'system_name': self._config['system']['system_name'], + 'interface': self._config['system']['interface'], + **self._config['system']['mqtt'], + 'log_level': self._config['system']['logging']['network'], + 'mqtt_log_level': self._config['system']['logging']['mqtt']} + return the_return - # Check for file - try: - if self._config['system']['logging']['file']: - config_dict['file'] = True - try: - config_dict['file_path'] = Path(self._config['system']['logging']['file_path']) - except KeyError: - pass - except KeyError: - pass + @property + def detectors_longitudinal(self): + """ + All defined longitudinal detectors + :return: list + """ + return list(self._config['detectors']['longitudinal'].keys()) + + @property + def detectors_lateral(self): + """ + All defined lateral detectors + :return: list + """ + return list(self._config['detectors']['lateral'].keys()) + + @property + def bays(self): + """ + All defined bays + :return: list + """ + return list(self._config['bays'].keys()) - return config_dict + @property + def triggers(self): + """ + All defined triggers + :return: list + """ + return list(self._config['triggers'].keys()) + + def detector(self, detector_id, detector_type): + """ + Retrieve configuration for a specific detector + + :param detector_id: ID of the requested detector. + :type detector_id: str + :param detector_type: Detector type, either "longitudinal" or "lateral" + :type detector_type: str + :return: dict + """ + return {'detector_id': detector_id, + **self._config['detectors'][detector_type][detector_id], + 'log_level': self._config['system']['logging']['detectors']} + + def bay(self, bay_id): + """ + Retrieve configuration for a specific bay. + :param bay_id: ID of the requested bay + :return: dict + """ + return { **self._config['bays'][bay_id], 'log_level': self._config['system']['logging']['bays']} + + def display(self): + """ + Retrieve configuration for the display + :return: dict + """ + return { **self._config['display'], + 'unit_system': self._config['system']['unit_system'], + 'log_level': self._config['system']['logging']['display']} + + def trigger(self, trigger_id): + """ + Retrieve configuration for a specific trigger. + :param trigger_id: + :return: list + """ + ### Can probably do this in Cerberus, but that's being fiddly, so this is a quick hack. + # Ensure both 'to' and 'from' are set, even if only to None. + if 'to' not in self._config['triggers'][trigger_id] and 'to_value' not in self._config['triggers'][trigger_id]: + self._config['triggers'][trigger_id]['to'] = None + if 'from' not in self._config['triggers'][trigger_id] and 'from_value' not in self._config['triggers'][trigger_id]: + self._config['triggers'][trigger_id]['from'] = None + + # Convert to _value. + self._config['triggers'][trigger_id]['to_value'] = self._config['triggers'][trigger_id]['to'] + del self._config['triggers'][trigger_id]['to'] + self._config['triggers'][trigger_id]['from_value'] = self._config['triggers'][trigger_id]['from'] + del self._config['triggers'][trigger_id]['from'] + + return { + **self._config['triggers'][trigger_id], + 'log_level': self._config['system']['logging']['triggers'] + } - # Method to let modules get their proper logging levels. def get_loglevel(self, mod_id, mod_type=None): requested_level = None if 'logging' not in self._config['system']: @@ -217,273 +589,21 @@ def get_loglevel(self, mod_id, mod_type=None): "Module {} had unknown level {}. Using WARNING.".format(mod_id, requested_level)) return "WARNING" - # Return a settings dict to be used for the Display module. - def display(self): - # Initialize the config dict. - config_dict = {} - # Bring in the unit system. - config_dict['unit_system'] = self._config['system']['unit_system'] - # Set the strobe update speed. - try: - config_dict['strobe_speed'] = Quantity(self._config['display']['strobe_speed']).to('nanosecond').magnitude - except KeyError: - # IF not defined, default to 100ms - config_dict['strobe_speed'] = Quantity("100 ms").to('nanosecond').magnitude - # Matrix settings. - config_dict['matrix_width'] = self._config['display']['matrix']['width'] - config_dict['matrix_height'] = self._config['display']['matrix']['height'] - config_dict['gpio_slowdown'] = self._config['display']['matrix']['gpio_slowdown'] - config_dict['mqtt_image'] = self._config['display']['mqtt_image'] - config_dict['mqtt_update_interval'] = Quantity(self._config['display']['mqtt_update_interval']) - # Default font is the packaged-in OpenSans Light. - with importlib.resources.path("CobraBay.data", 'OpenSans-Light.ttf') as p: - core_font_path = p - self._logger.debug("Using default font at path: {}".format(core_font_path)) - config_dict['core_font'] = str(core_font_path) - # If the font is defined and exists, use that. - if 'font' in self._config['display']: - if os.path.isfile(self._config['display']['font']): - config_dict['core_font'] = self._config['display']['font'] - return config_dict - - # Return a settings dict to be used for the Network module. - def network(self): - config_dict = { - 'unit_system': self._config['system']['unit_system'], - 'system_name': self._config['system']['system_name'], - 'log_level': self.get_loglevel('network'), - 'mqtt_log_level': self.get_loglevel('mqtt') - } - - try: - config_dict['homeassistant'] = self._config['system']['homeassistant'] - except KeyError: - config_dict['homeassistant'] = False - config_dict['interface'] = self._config['system']['interface'] - # Check for MQTT definition - if 'mqtt' not in self._config['system'].keys(): - raise ValueError("MQTT must be defined in system block.") - elif not isinstance(self._config['system']['mqtt'],dict): - raise TypeError("MQTT definition must be a dictionary of values.") - else: - required_keys = ['broker','port','username','password'] - for rk in required_keys: - if rk not in self._config['system']['mqtt']: - raise ValueError("Required key '{}' not in system MQTT definition".format(rk)) - else: - config_dict["mqtt_" + rk] = self._config['system']['mqtt'][rk] - if 'port' in self._config['system']['mqtt']: - config_dict["mqtt_port"] = self._config['system']['mqtt']['port'] - else: - config_dict["mqtt_port"] = 1883 - return config_dict - - def bay(self, bay_id): - self._logger.debug("Received config generate request for: {}".format(bay_id)) - if bay_id not in self._config['bays']: - raise KeyError("No configuration defined for {}".format(bay_id)) - # Initialize the config dict. Include the bay ID, and default to metric. - config_dict = { - 'id': bay_id, - 'output_unit': 'm', - 'selected_range': None, - 'settings': {}, - 'intercepts': {}, - 'detector_settings': {}, - 'log_level': self.get_loglevel(bay_id, mod_type='bay') - } - # If Imperial is defined, bay should output in inches. - try: - if self._config['system']['unit_system'].lower() == 'imperial': - config_dict['output_unit'] = 'in' - except KeyError: - pass - - # Set a 'friendly' name for use in HA discovery. If not defined, use the Bay ID. - try: - config_dict['name'] = self._config['bays'][bay_id]['name'] - except KeyError: - config_dict['name'] = self._config['bays'][bay_id]['id'] - - # How long there should be no motion until we consider the bay to be parked. - config_dict['motion_timeout'] = Quantity(self._config['bays'][bay_id]['motion_timeout']).to('second') - # Actual bay depth, from the range sensor to the garage door. - config_dict['depth'] = Quantity(self._config['bays'][bay_id]['depth']).to('cm') - # Stop point, the distance from the sensor to the point where the vehicle should stop. - config_dict['stop_point'] = Quantity(self._config['bays'][bay_id]['stop_point']).to('cm') - - # Create the detector configuration for the bay. - # Each bay applies bay-specific options to a detector when it initializes. - long_fallback = { 'offset': Quantity("0 cm"), 'spread_park': '2 in', 'pct_warn': 90, 'pct_crit': 95 } - long_required = ['spread_park', 'pct_warn', 'pct_crit'] - lat_fallback = { 'offset': Quantity("0 cm"), 'spread_ok': '1 in', 'spread_warn': '3 in' } - lat_required = ['side', 'intercept'] - available_long = [] # We'll save available longitudinal sensors here to select a range sensor from later. - for direction in ('longitudinal', 'lateral'): - # Pull the defaults as a base. Otherwise, it's an empty dict. - try: - # Use defaults for this direction. - direction_defaults = self._config['bays'][bay_id][direction]['defaults'] - except KeyError: - # If no defined defaults, empty list. - direction_defaults = {} - - for detector in self._config['bays'][bay_id][direction]['detectors']: - # Longitudinal check. - if direction == 'longitudinal': - # Merge in the fallback items. User-defined defaults take precedence. - dd = dict( long_fallback.items() | direction_defaults.items() ) - # Merge in the defaults with the detector specific settings. Detector-specific items take precedence. - config_dict['detector_settings'][detector['detector']] = dict( dd.items() | detector.items() ) - for setting in long_required: - if setting not in config_dict['detector_settings'][detector['detector']]: - raise ValueError("Required setting '{}' not present in configuration for detector '{}' in " - "bay '{}'. Must be set directly or have default set.". - format(setting, detector, bay_id )) - available_long.append(detector['detector']) - # Calculate the offset for this detector - # This detector will be offset by the bay's stop point, adjusted by the original offset of the detector. - config_dict['detector_settings'][detector['detector']]['offset'] = \ - Quantity(self._config['bays'][bay_id]['stop_point']) - \ - Quantity(config_dict['detector_settings'][detector['detector']]['offset']) - # Lateral check. - if direction == 'lateral': - # Merge in the fallback items. User-defined defaults take precedence. - dd = dict( lat_fallback.items() | direction_defaults.items() ) - # Merge in the defaults with the detector specific settings. Detector-specific items take precedence. - config_dict['detector_settings'][detector['detector']] = dict( dd.items() | detector.items() ) - # Check for required settings. - for setting in lat_required: - if setting not in config_dict['detector_settings'][detector['detector']]: - raise ValueError("Required setting '{}' not present in configuration for detector '{}' in " - "bay '{}'. Must be set directly or have default set.". - format(setting, detector, bay_id )) - # Add to the intercepts list. - config_dict['intercepts'][detector['detector']] = Quantity(detector['intercept']) - - # Pick a range sensor to use as 'primary'. - if config_dict['selected_range'] is None: - # If there's only one longitudinal detector, that's the one to use for range. - if len(available_long) == 0: - raise ValueError("No longitudinal sensors defined, cannot select one for range!") - elif len(available_long) == 1: - config_dict['selected_range'] = available_long[0] - else: - raise NotImplementedError("Multiple longitudinal sensors not yet supported.") - return config_dict - - # Config dict for a detector. - def detector(self, detector_id, detector_type): - self._logger.debug("Received config generate request for: {} ({})".format(detector_id,detector_type.lower())) - if detector_id not in self._config['detectors'][detector_type]: - raise KeyError("No configuration defined for detector '{}'".format(detector_id)) - # Assemble the config dict - config_dict = { - 'detector_id': detector_id, - 'name': self._config['detectors'][detector_type][detector_id]['name'], - 'sensor_type': self._config['detectors'][detector_type][detector_id]['sensor']['type'], - 'sensor_settings': self._config['detectors'][detector_type][detector_id]['sensor'], - 'log_level': self.get_loglevel(detector_id, mod_type='detector') - } - - # Add the logger to the sensor settings, so the sensor can log directly. - config_dict['sensor_settings']['logger'] = 'CobraBay.sensors' - del config_dict['sensor_settings']['type'] - - # Optional parameters if included. - if detector_type == 'longitudinal': - try: - config_dict['error_margin'] = self._config['detectors'][detector_type][detector_id]['error_margin'] - except KeyError: - config_dict['error_margin'] = Quantity("0 cm") - elif detector_type == 'lateral': - pass # Currently no optional lateral parameters. Stud for the future. - - - self._logger.debug("Returning config: {}".format(config_dict)) - return config_dict - - def trigger(self, trigger_id): - self._logger.debug("Received config generate request for trigger: {}".format(trigger_id)) - if trigger_id not in self._config['triggers']: - raise KeyError("No configuration defined for trigger: '{}'".format(trigger_id)) - config_dict = { - 'id': trigger_id, - 'log_level': self.get_loglevel(trigger_id) - } - self._logger.debug("Trigger has config: {}".format(self._config['triggers'][trigger_id])) - try: - config_dict['name'] = self._config['triggers'][trigger_id]['name'] - except KeyError: - self._logger.debug("Trigger {} has no name, using ID instead.") - config_dict['name'] = trigger_id - # Validate the type. - try: - if self._config['triggers'][trigger_id]['type'] not in ('mqtt_sensor', 'syscommand', 'baycommand', 'range'): - raise ValueError("Trigger {} has unknown type.") - else: - config_dict['type'] = self._config['triggers'][trigger_id]['type'] - except KeyError: - raise KeyError("Trigger {} does not have a defined type.") - - # Both MQTT command and MQTT sensor can have an MQTT topic defined. This will override auto-generation. - if config_dict['type'] == 'mqtt_sensor': - try: - config_dict['topic'] = self._config['triggers'][trigger_id]['topic'] - config_dict['topic_mode'] = 'full' - except KeyError: - config_dict['topic'] = trigger_id - config_dict['topic_mode'] = 'suffix' - - # Set topic and topic mode for the command handlers. These will always be 'cmd', and always a suffix. - if config_dict['type'] in ('syscommand','baycommand'): - config_dict['topic'] = 'cmd' - config_dict['topic_mode'] = 'suffix' - - # Bay command needs the Bay ID to build the topic correctly. - if config_dict['type'] == 'baycommand': - config_dict['bay_id'] = self._config['triggers'][trigger_id]['bay_id'] - - # MQTT Sensor requires some additional checks. - if config_dict['type'] == 'mqtt_sensor': - try: - config_dict['bay_id'] = self._config['triggers'][trigger_id]['bay'] - except KeyError: - raise KeyError("Trigger {} must have a bay defined and doesn't.".format(trigger_id)) - - # Make sure either to or from is defined, but not both. - if all(key in self._config['triggers'][trigger_id] for key in ('to', 'from')): - raise ValueError("Trigger {} has both 'to' and 'from' options set, can only use one.") - else: - try: - config_dict['trigger_value'] = self._config['triggers'][trigger_id]['to'] - config_dict['change_type'] = 'to' - except KeyError: - config_dict['trigger_value'] = self._config['triggers'][trigger_id]['from'] - config_dict['change_type'] = 'from' - - # Check the when_triggered options for both mqtt_sensor and range triggers. - if config_dict['type'] in ('mqtt_sensor', 'range'): - if self._config['triggers'][trigger_id]['when_triggered'] in ('dock', 'undock', 'occupancy', 'verify'): - config_dict['when_triggered'] = self._config['triggers'][trigger_id]['when_triggered'] - else: - raise ValueError("Trigger {} has unknown when_triggered setting.".format(trigger_id)) - - return config_dict - - # Properties! - @property - def bay_list(self): - return self._config['bays'].keys() - - @property - def detectors_longitudinal(self): - return self._config['detectors']['longitudinal'].keys() - - @property - def detectors_lateral(self): - return self._config['detectors']['lateral'].keys() - - @property - def trigger_list(self): - return self._config['triggers'].keys() +### Old Things +# +# def _check_path(self, config_file): +# # Default search paths. +# search_paths = [ +# Path('/etc/cobrabay/config.yaml'), +# Path.cwd().joinpath('config.yaml') +# ] +# if isinstance(config_file, Path): +# search_paths.insert(0, Path(config_file)) +# +# for path in search_paths: +# try: +# self.config_file = path +# except: +# pass +# if self._config_file is None: +# raise ValueError("Cannot find valid config file! Attempted: {}".format([str(i) for i in search_paths])) diff --git a/CobraBay/const.py b/CobraBay/const.py index 31ea16b..6dde021 100644 --- a/CobraBay/const.py +++ b/CobraBay/const.py @@ -1,27 +1,53 @@ # Various constants +## General constants. Multiple types of objects need these. +GEN_UNKNOWN = 'unknown' +GEN_UNAVAILABLE = 'unavailable' + +## Bay states +BAYSTATE_DOCKING = 'docking' +BAYSTATE_UNDOCKING = 'undocking' +BAYSTATE_READY = 'ready' +BAYSTATE_NOTREADY = 'not_ready' + +## System States +SYSSTATE_READY = 'ready' +SYSSTATE_DOCKING = 'docking' +SYSSTATE_UNDOCKING = 'undocking' +SYSSTATE_MOTION = (SYSSTATE_DOCKING, SYSSTATE_UNDOCKING) + ## Sensor states. -STATE_FAULT = "fault" -STATE_DISABLED = "disabled" -STATE_ENABLED = "enabled" -STATE_RANGING = "ranging" -STATE_NOTRANGING = "not_ranging" +SENSTATE_FAULT = 'fault' +SENSTATE_DISABLED = 'disabled' +SENSTATE_ENABLED = 'enabled' +SENSTATE_RANGING = 'ranging' +SENSTATE_NOTRANGING = 'not_ranging' # Non-Quantity values the sensor can be in without -SENSOR_VALUE_OK = "ok" -SENSOR_VALUE_WEAK = "weak" -SENSOR_VALUE_STRONG = "strong" -SENSOR_VALUE_FLOOD = "flood" +SENSOR_VALUE_OK = 'ok' +SENSOR_VALUE_WEAK = 'weak' +SENSOR_VALUE_STRONG = 'strong' +SENSOR_VALUE_FLOOD = 'flood' +SENSOR_VALUE_TOOCLOSE = 'tooclose' # Detector quality values. -DETECTOR_QUALITY_OK = "ok" -DETECTOR_QUALITY_BASE = "base" -DETECTOR_QUALITY_FINAL = "final" -DETECTOR_QUALITY_PARK = "park" -DETECTOR_QUALITY_BACKUP = "backup" -DETECTOR_QUALITY_NOOBJ = "no_object" -DETECTOR_QUALITY_EMERG = "emergency" -DETECTOR_QUALITY_DOOROPEN = "door_open" -DETECTOR_NOREADING = "no_reading" +DETECTOR_QUALITY_OK = 'ok' +DETECTOR_QUALITY_WARN = 'warning' +DETECTOR_QUALITY_CRIT = 'critical' +DETECTOR_QUALITY_BASE = 'base' +DETECTOR_QUALITY_FINAL = 'final' +DETECTOR_QUALITY_PARK = 'park' +DETECTOR_QUALITY_BACKUP = 'backup' +DETECTOR_QUALITY_NOOBJ = 'no_object' +DETECTOR_QUALITY_EMERG = 'emergency' +DETECTOR_QUALITY_DOOROPEN = 'door_open' +DETECTOR_QUALITY_BEYOND = 'beyond_range' +DETECTOR_NOREADING = 'no_reading' +DETECTOR_NOINTERCEPT = 'not_intercepted' + +# Directional values +DIR_FWD = 'forward' +DIR_REV = 'reverse' +DIR_STILL = 'still' diff --git a/CobraBay/core.py b/CobraBay/core.py index a6ef2dc..d4ae98f 100644 --- a/CobraBay/core.py +++ b/CobraBay/core.py @@ -5,12 +5,14 @@ import logging from logging.handlers import WatchedFileHandler import atexit -from pprint import pformat +from pprint import pformat, pprint import CobraBay import sys + class CBCore: def __init__(self, config_obj): + self._network = None self.system_state = 'init' # Register the exit handler. atexit.register(self.system_exit) @@ -31,54 +33,60 @@ def __init__(self, config_obj): # Initial startup message. self._logger.info("CobraBay {} initializing...".format(CobraBay.__version__)) - if not isinstance(config_obj,CobraBay.CBConfig): + if not isinstance(config_obj, CobraBay.CBConfig): raise TypeError("CobraBay core must be passed a CobraBay Config object (CBConfig).") else: # Save the passed CBConfig object. - self._cbconfig = config_obj + self._active_config = config_obj + + print("Active configuration:") + pprint(self._active_config.config) # Update the logging handlers. - self._setup_logging_handlers(self._cbconfig.log_handlers()) + self._setup_logging_handlers(**self._active_config.log_handlers()) # Reset our own level based on the configuration. - self._logger.setLevel(self._cbconfig.get_loglevel("core")) + self._logger.setLevel(self._active_config.get_loglevel("core")) # Create the object for checking hardware status. - self._logger.debug("Creating Pi hardware monitor...") + self._logger.info("Creating Pi hardware monitor...") self._pistatus = CobraBay.CBPiStatus() # Create the network object. - self._logger.debug("Creating network object...") + self._logger.info("Creating network object...") # Create Network object. - network_config = self._cbconfig.network() - self._logger.debug("Using network config:") - self._logger.debug(pformat(network_config)) + network_config = self._active_config.network() + self._logger.debug("Using network config:\n{}".format(pformat(network_config))) self._network = CobraBay.CBNetwork(**network_config, cbcore=self) + # Register the hardware monitor with the network module. self._network.register_pistatus(self._pistatus) - # Queue for outbound messages. + # # Create the outbound messages queue self._outbound_messages = [] # Queue the startup message. self._outbound_messages.append({'topic_type': 'system', 'topic': 'device_connectivity', 'message': 'Online'}) - self._logger.debug("Creating detectors...") + self._logger.info("Creating detectors...") # Create the detectors. self._detectors = self._setup_detectors() - self._logger.debug("Have detectors: {}".format(self._detectors)) + self._logger.debug("Detectors created: {}".format(pformat(self._detectors))) # Create master bay object for defined docking bay # Master list to store all the bays. self._bays = {} self._logger.info("Creating bays...") - for bay_id in self._cbconfig.bay_list: + for bay_id in self._active_config.bays: self._logger.info("Bay ID: {}".format(bay_id)) - bay_config = self._cbconfig.bay(bay_id) + bay_config = self._active_config.bay(bay_id) self._logger.debug("Bay config:") self._logger.debug(pformat(bay_config)) - self._bays[bay_id] = CobraBay.CBBay(**bay_config, detectors=self._detectors, cbcore=self) + self._bays[bay_id] = CobraBay.CBBay(id=bay_id, **bay_config, system_detectors=self._detectors, cbcore=self) self._logger.info('Creating display...') - self._display = CobraBay.CBDisplay(self._cbconfig) + display_config = self._active_config.display() + self._logger.debug("Using display config:") + self._logger.debug(pformat(display_config)) + self._display = CobraBay.CBDisplay(**display_config, cbcore=self) # Inform the network about the display. This is so the network can send display images. Nice to have, very # useful for debugging! self._network.display = self._display @@ -86,16 +94,11 @@ def __init__(self, config_obj): # Register the bay with the network and display. for bay_id in self._bays: self._network.register_bay(self._bays[bay_id]) - self._display.register_bay(self._bays[bay_id].display_reg_info) - - # # Collect messages from the bays. - # for bay_id in self._bays: - # self._outbound_messages = self._outbound_messages + self._bays[bay_id].mqtt_messages(verify=True) + self._display.register_bay(self._bays[bay_id]) # Create triggers. - self._logger.debug("About to setup triggers.") + self._logger.info("Creating triggers...") self._triggers = self._setup_triggers() - self._logger.debug("Done calling setup_triggers.") self._logger.debug("Have triggers: {}".format(self._triggers)) # Parcel trigger objects out to the right place. @@ -108,10 +111,8 @@ def __init__(self, config_obj): if isinstance(trigger_obj, CobraBay.triggers.MQTTTrigger): self._logger.debug("Registering Trigger {} with Network module.".format(trigger_id)) self._network.register_trigger(trigger_obj) - # Tell bays about their bay triggers. - #if trigger_obj.type in ('baycommand'): - # self._bays[trigger_obj.bay_id].register_trigger(trigger_obj) + # Some unused code for Range triggers. Not fully implemented yet. # elif self._triggers[trigger_id].type == 'range': # # Make sure the desired bay exists! # try: @@ -146,31 +147,28 @@ def _trigger_check(self): # We pass the caller name explicitly. There's inspect-fu that could be done, but that # may have portability issues. for trigger_id in self._triggers.keys(): - self._logger.debug("Checking trigger: {}".format(trigger_id)) trigger_obj = self._triggers[trigger_id] - # Disabling range triggers for the moment. - # Range objects need to be checked explicitly. So call it! - # if trigger_obj.type == 'range': - # trigger_obj.check() - # self._logger.debug("Has trigger value: {}".format(trigger_obj.triggered)) + # A trigger_obj.triggered returns true if it has any commands available for processing. if trigger_obj.triggered: while trigger_obj.cmd_stack: # Pop the command from the object. cmd = trigger_obj.cmd_stack.pop(0) # Route it appropriately. - if isinstance(trigger_obj,CobraBay.triggers.SysCommand): + # System commands go directly to the core command processor. + if isinstance(trigger_obj, CobraBay.triggers.SysCommand): self._core_command(cmd) - else: - if cmd in ('dock','undock'): - # Dock or undock, enter the motion routine. + # Bay commands will trigger a motion or an abort. + elif isinstance(trigger_obj, CobraBay.triggers.BayCommand): + if cmd in ('dock', 'undock'): + # On a dock or undock, call the motion method. self._motion(trigger_obj.bay_id, cmd) + # The call returns here self._logger.debug("Returned from motion method to trigger method.") break elif cmd == 'abort': # On an abort, call the bay's abort. This will set it ready and clean up. # If we're in the _motion method, this will go back to run, if not, nothing happens. self._bays[trigger_obj.bay_id].abort() - self._logger.debug("Trigger check complete.") # Main operating loop. def run(self): @@ -182,7 +180,7 @@ def run(self): # Update the network components of the system state. system_status = { 'network': network_data['online'], - 'mqtt': network_data['mqtt_status'] } + 'mqtt': network_data['mqtt_status']} # Check triggers and execute actions if needed. self._trigger_check() @@ -231,17 +229,21 @@ def undock(self): self._logger.info('CobraBay: Undock not yet implemented.') return - def system_exit(self): + def system_exit(self, unexpected=True): self.system_state = 'shutdown' + if unexpected: + self._logger.critical("Shutting down due to unexpected error.") + else: + self._logger.critical("Performing requested shutdown.") # Wipe any previous messages. They don't matter now, we're going away! self._outbound_messages = [] # Stop the ranging and close all the open sensors. try: for bay in self._bays: - self._logger.critical("Shutting down bay {}".format(bay)) + self._logger.info("Shutting down bay {}".format(bay)) self._bays[bay].shutdown() for detector in self._detectors: - self._logger.critical("Disabling detector: {}".format(detector)) + self._logger.info("Disabling detector: {}".format(detector)) self._detectors[detector].status = 'disabled' except AttributeError: # Must be exiting before bays were defined. That's okay. @@ -260,7 +262,8 @@ def system_exit(self): # Have the display show 'offline', then grab that and send it to the MQTT broker. This will be the image # remaining when we go offline. try: - self._display.show(system_status={ 'network': False, 'mqtt': False }, mode='message', message="OFFLINE", icons=False) + self._display.show(system_status={'network': False, 'mqtt': False}, mode='message', message="OFFLINE", + icons=False) # Add image to the queue. self._outbound_messages.append( {'topic_type': 'system', @@ -269,65 +272,75 @@ def system_exit(self): except AttributeError: pass # Call the network once. We'll ignore any commands we get. - self._logger.critical("Sending offline MQTT message.") - self._network_handler() + try: + self._logger.info("Sending offline MQTT message.") + self._network_handler() + except AttributeError: + pass + self._logger.critical("Terminated.") + if unexpected: + sys.exit(1) + else: + sys.exit(0) # Method to set up the detectors based on the configuration. def _setup_detectors(self): return_dict = {} # Create detectors with the right type. self._logger.debug("Creating longitudinal detectors.") - for detector_id in self._cbconfig.detectors_longitudinal: + for detector_id in self._active_config.detectors_longitudinal: self._logger.info("Creating longitudinal detector: {}".format(detector_id)) - detector_config = self._cbconfig.detector(detector_id,'longitudinal') + detector_config = self._active_config.detector(detector_id, 'longitudinal') self._logger.debug("Using settings: {}".format(detector_config)) - return_dict[detector_id] = CobraBay.detectors.Range(**detector_config) + return_dict[detector_id] = CobraBay.detectors.Longitudinal(**detector_config) - for detector_id in self._cbconfig.detectors_lateral: + for detector_id in self._active_config.detectors_lateral: self._logger.info("Creating lateral detector: {}".format(detector_id)) - detector_config = self._cbconfig.detector(detector_id,'lateral') + detector_config = self._active_config.detector(detector_id, 'lateral') self._logger.debug("Using settings: {}".format(detector_config)) return_dict[detector_id] = CobraBay.detectors.Lateral(**detector_config) self._logger.debug("VL53LX instances: {}".format(len(CobraBay.sensors.CB_VL53L1X.instances))) return return_dict def _setup_triggers(self): + # Set the logging level for the trigger group. + trigger_logger = logging.getLogger("CobraBay").getChild("Triggers") + trigger_logger.setLevel("DEBUG") + self._logger.debug("Creating triggers...") return_dict = {} - self._logger.info("Trigger list: {}".format(self._cbconfig.trigger_list)) - for trigger_id in self._cbconfig.trigger_list: - self._logger.info("Trigger ID: {}".format(trigger_id)) - trigger_config = self._cbconfig.trigger(trigger_id) - self._logger.debug(trigger_config) + self._logger.info("Trigger list: {}".format(self._active_config.triggers)) + for trigger_id in self._active_config.triggers: + self._logger.debug("Trigger ID: {}".format(trigger_id)) + trigger_config = self._active_config.trigger(trigger_id) + self._logger.debug("Has config: {}".format(trigger_config)) # Create trigger object based on type. # All triggers except the system command handler will need a reference to the bay object. - if trigger_config['type'] == "syscommand": + if trigger_config['type'] == "syscmd": return_dict[trigger_id] = CobraBay.triggers.SysCommand( - id="sys_cmd", - name="System Command Handler", + id="syscmd", topic=trigger_config['topic'], log_level=trigger_config['log_level']) else: - if trigger_config['type'] == 'mqtt_sensor': + if trigger_config['type'] == 'mqtt_state': return_dict[trigger_id] = CobraBay.triggers.MQTTSensor( - id = trigger_config['id'], - name = trigger_config['name'], - topic = trigger_config['topic'], - topic_mode = 'full', - bay_obj = self._bays[trigger_config['bay_id']], - change_type = trigger_config['change_type'], - trigger_value = trigger_config['trigger_value'], - when_triggered = trigger_config['when_triggered'], - log_level = trigger_config['log_level'] + id=trigger_id, + topic=trigger_config['topic'], + topic_mode='full', + topic_prefix=None, + bay_obj=self._bays[trigger_config['bay']], + to_value=trigger_config['to_value'], + from_value=trigger_config['from_value'], + action=trigger_config['action'], + log_level=trigger_config['log_level'] ) - elif trigger_config['type'] == 'baycommand': + elif trigger_config['type'] == 'baycmd': # Get the bay object reference. return_dict[trigger_id] = CobraBay.triggers.BayCommand( - id = trigger_config['id'], - name = trigger_config['name'], - topic = trigger_config['topic'], - bay_obj = self._bays[trigger_config['bay_id']], - log_level = trigger_config['log_level']) + id=trigger_id, + topic=trigger_config['topic'], + bay_obj=self._bays[trigger_config['bay_id']], + log_level=trigger_config['log_level']) # elif trigger_config['type'] == 'range': # # Range triggers also need the detector object. # return_dict[trigger_id] = CobraBay.triggers.Range(trigger_config, bay_obj, @@ -340,32 +353,39 @@ def _setup_triggers(self): return return_dict # Method to set up Logging handlers. - def _setup_logging_handlers(self, handler_config): + def _setup_logging_handlers(self, file=False, console=False, file_path=None, log_format=None, syslog=False): # File based handler setup. - if handler_config['file']: - fh = WatchedFileHandler(handler_config['file_path']) - fh.setFormatter(handler_config['format']) + if file: + fh = WatchedFileHandler(file_path) + fh.setFormatter(logging.Formatter(log_format)) fh.setLevel(logging.DEBUG) # Attach to the master logger. self._master_logger.addHandler(fh) - self._master_logger.info("File logging enabled.") + self._master_logger.info("File logging enabled. Writing to file: {}".format(file_path)) - if handler_config['syslog']: + if syslog: raise NotImplemented("Syslog logging not yet implemented") - # Console handling. If disabling, send a message here. - if not handler_config['console']: - self._master_logger.info("Disabling console logging.") + # Deal with the Console logger. - # Remove all console handlers. + # Send a last message through the temporary console handler. + if not console: + self._logger.info("Disabling general console logging. Will only log Critical events to the console.") + else: + self._logger.info("Console logging enabled. Passing logging to console.") + + # Remove the temporary console handler. for handler in self._master_logger.handlers: if isinstance(handler, logging.StreamHandler): self._master_logger.removeHandler(handler) - # Add the new console handler. - if handler_config['console']: - # Replace the console handler with a new one with the formatter. - ch = logging.StreamHandler() - ch.setFormatter(handler_config['format']) + # Create a new handler. + ch = logging.StreamHandler() + # Set format. + ch.setFormatter(logging.Formatter(log_format)) + # Set logging level. + if console: ch.setLevel(logging.DEBUG) - self._master_logger.addHandler(ch) + else: + ch.setLevel(logging.CRITICAL) + self._master_logger.addHandler(ch) diff --git a/CobraBay/detectors.py b/CobraBay/detectors.py index 4dc923d..71508c3 100644 --- a/CobraBay/detectors.py +++ b/CobraBay/detectors.py @@ -4,7 +4,7 @@ import pint.errors from .sensors import CB_VL53L1X, TFMini, FileSensor, I2CSensor, SerialSensor -import CobraBay.const +from CobraBay.const import * import CobraBay.exceptions from pint import UnitRegistry, Quantity, DimensionalityError from time import monotonic_ns @@ -26,6 +26,7 @@ def wrapper(self, *args, **kwargs): return self._ready = True self._when_ready() + return wrapper @@ -39,6 +40,7 @@ def wrapper(self, *args, **kwargs): "Current settings:\n{}".format(self._settings)) else: return func(self, *args, **kwargs) + return wrapper @@ -61,6 +63,7 @@ def wrapper(self, *args, **kwargs): value = self._sensor_obj.range # Send whichever value it is into the function. return func(self, value) + return wrapper @@ -73,7 +76,7 @@ def wrapper(self, *args, **kwargs): read_sensor = False # Assume we won't read the sensor. if self._sensor_obj.status != 'ranging': # If sensor object isn't ranging, return immediately with a not_ranging result. - return CobraBay.const.STATE_NOTRANGING + return SENSTATE_NOTRANGING else: if len(self._history) > 0: # Time difference between now and the most recent read. @@ -99,6 +102,7 @@ def wrapper(self, *args, **kwargs): self._history = self._history[:10] # Call the wrapped function. return func(self) + return wrapper @@ -109,7 +113,7 @@ def __init__(self, detector_id, name, offset="0 cm", log_level="WARNING"): self._name = name # Create a logger. self._logger = logging.getLogger("CobraBay").getChild("Detector").getChild(self._name) - self._logger.setLevel(log_level) + self._logger.setLevel(log_level.upper()) # A unit registry self._ureg = UnitRegistry() # Is the detector ready for use? @@ -142,8 +146,9 @@ def offset(self): @offset.setter @check_ready - def offset(self, input): - self._offset = self._convert_value(input) + def offset(self, the_input): + self._logger.info("Offset is now - {}".format(the_input)) + self._offset = self._convert_value(the_input) @property def id(self): @@ -178,21 +183,21 @@ def _when_ready(self): # Single Detectors add wrappers around a single sensor. Sensor arrays are not currently supported, but may be in the # future. class SingleDetector(Detector): - def __init__(self, detector_id, name, sensor_type, sensor_settings, log_level="DEBUG", **kwargs): + def __init__(self, detector_id, name, sensor_type, sensor_settings, log_level="WARNING", **kwargs): super().__init__(detector_id=detector_id, name=name, log_level=log_level) self._logger.debug("Creating sensor object using options: {}".format(sensor_settings)) if sensor_type == 'VL53L1X': self._logger.debug("Setting up VL53L1X with sensor settings: {}".format(sensor_settings)) - self._sensor_obj = CB_VL53L1X(**sensor_settings, log_level=log_level) + self._sensor_obj = CB_VL53L1X(**sensor_settings, parent_logger=self._logger) elif sensor_type == 'TFMini': self._logger.debug("Setting up TFMini with sensor settings: {}".format(sensor_settings)) - self._sensor_obj = TFMini(**sensor_settings, log_level=log_level) + self._sensor_obj = TFMini(**sensor_settings, parent_logger=self._logger) elif sensor_type == 'FileSensor': self._logger.debug("Setting up FileSensor with sensor settings: {}".format(sensor_settings)) - self._sensor_obj = FileSensor(**sensor_settings, sensor=detector_id, log_level=log_level) + self._sensor_obj = FileSensor(**sensor_settings, sensor=detector_id, parent_logger=self._logger) else: raise ValueError("Detector {} trying to use unknown sensor type {}".format( - self._name, sensor_settings)) + self._name, sensor_settings)) self._status = None # Allow adjustment of timing. @@ -241,6 +246,8 @@ def fault(self): :return: bool """ if self.status != self.state: + self._logger.warning("Detector is in a fault state. Operating status '{}' does not equal running state '{}'". + format(self.status, self.state)) return True else: return False @@ -260,7 +267,7 @@ def sensor_interface(self): elif isinstance(self._sensor_obj, FileSensor): iface = iface_info("file", self._sensor_obj.file) else: - iface = iface_info("unknown","unknown") + iface = iface_info("unknown", "unknown") return iface @property @@ -282,13 +289,15 @@ def value(self): def value_raw(self): raise NotImplementedError("Raw value method should be implemented on a class basis.") + # Detector that measures range progress. -class Range(SingleDetector): +class Longitudinal(SingleDetector): def __init__(self, detector_id, name, error_margin, sensor_type, sensor_settings, log_level="WARNING"): - super().__init__(detector_id, name, sensor_type, sensor_settings, log_level) + super().__init__(detector_id=detector_id, name=name, error_margin=error_margin, sensor_type=sensor_type, + sensor_settings=sensor_settings, log_level=log_level) # Required properties. These are checked by the check_ready decorator function to see if they're not None. # Once all required properties are not None, the object is set to ready. Doesn't check for values being *correct*. - self._required = ['bay_depth','spread_park','pct_warn','pct_crit'] + self._required = ['bay_depth', 'spread_park', 'pct_warn', 'pct_crit'] # Save parameters self._error_margin = error_margin @@ -301,8 +310,6 @@ def __init__(self, detector_id, name, error_margin, sensor_type, sensor_settings self._dist_warn = None self._dist_crit = None - - # Method to get the raw sensor reading. This is used to report upward for HA extended attributes. @property @read_if_stale @@ -325,34 +332,39 @@ def value_raw(self): @property @read_if_stale def quality(self): - self._logger.debug("Creating quality from latest value: {} ({})".format(self._history[0][0], type(self._history[0][0]))) + self._logger.debug( + "Creating quality from latest value: {} ({})".format(self._history[0][0], type(self._history[0][0]))) self._logger.debug("90% of bay depth is: {}".format(self.bay_depth * .9)) current_value = self._history[0][0] + self._logger.debug("Evaluating longitudinal quality for value '{}' ({})".format(current_value, type(current_value))) + self._logger.debug("Evaluation targets: Bay Depth - {}, Critical - {}, Warn - {}". + format(self.bay_depth, self._dist_crit, self._dist_warn)) + if isinstance(current_value, Quantity): # Actual reading, evaluate. if current_value < Quantity("2 in"): - qv = 'emergency' + return DETECTOR_QUALITY_EMERG elif (self.bay_depth * 0.90) <= current_value: self._logger.debug( "Reading is more than 90% of bay depth ({})".format(self.bay_depth * .9)) - qv = 'no_object' + return DETECTOR_QUALITY_NOOBJ # Now consider the adjusted values. elif current_value < 0 and abs(current_value) > self.spread_park: - return CobraBay.const.DETECTOR_QUALITY_BACKUP + return DETECTOR_QUALITY_BACKUP elif abs(current_value) < self.spread_park: - return CobraBay.const.DETECTOR_QUALITY_PARK + return DETECTOR_QUALITY_PARK elif current_value <= self._dist_crit: - return CobraBay.const.DETECTOR_QUALITY_FINAL + return DETECTOR_QUALITY_FINAL elif current_value <= self._dist_warn: - return CobraBay.const.DETECTOR_QUALITY_BASE + return DETECTOR_QUALITY_BASE else: - return CobraBay.const.DETECTOR_QUALITY_OK - elif current_value == CobraBay.const.SENSOR_VALUE_WEAK: - return CobraBay.const.DETECTOR_QUALITY_DOOROPEN - elif current_value in (CobraBay.const.SENSOR_VALUE_FLOOD, CobraBay.const.SENSOR_VALUE_STRONG): - return CobraBay.const.DETECTOR_NOREADING + return DETECTOR_QUALITY_OK + elif current_value == SENSOR_VALUE_WEAK: + return DETECTOR_QUALITY_DOOROPEN + elif current_value in (SENSOR_VALUE_FLOOD, SENSOR_VALUE_STRONG): + return DETECTOR_NOREADING else: - raise CobraBay.exceptions.SensorException + return GEN_UNKNOWN # Determine the rate of motion being measured by the detector. @property @@ -388,9 +400,8 @@ def _movement(self): def motion(self): # Grab the movement movement = self._movement - print("Movement: {}".format(movement)) - if not isinstance(movement,dict): - return "Unknown" + if not isinstance(movement, dict): + return GEN_UNKNOWN elif abs(self._movement['net_dist']) > Quantity(self._error_margin): return True else: @@ -401,23 +412,20 @@ def vector(self): # Grab the movement value. movement = self._movement - # Movement should trigger a read and get a new state value. If that returns NOTRANGING, we can't - # process the vector and both speed and direction should be flagged unknown. - if movement == CobraBay.const.STATE_NOTRANGING: - return {"speed": "unknown", "direction": "unknown"} + + # Can't determine a vector if sensor is NOT RANGING or return UNKNOWN. + if movement in (SENSTATE_NOTRANGING,GEN_UNKNOWN): + return {"speed": GEN_UNKNOWN, "direction": GEN_UNKNOWN} # Determine a direction. self._logger.debug("Have movement value: {}".format(movement)) - if movement == 'unknown': - # If movement couldn't be determined, we also can't determine vector, so this is unknown. - return {"speed": "unknown", "direction": "unknown"} # Okay, not none, so has value! if movement['net_dist'] > Quantity(self._error_margin): - return {'speed': abs(movement['speed']), 'direction': 'forward'} + return {'speed': abs(movement['speed']), 'direction': DIR_REV} elif movement['net_dist'] < (Quantity(self._error_margin) * -1): - return {'speed': abs(movement['speed']), 'direction': 'reverse'} + return {'speed': abs(movement['speed']), 'direction': DIR_FWD} else: - return {'speed': Quantity("0 kph"), 'direction': 'still'} + return {'speed': Quantity("0 kph"), 'direction': DIR_STILL} # Gets called when the rangefinder has all settings and is being made ready for use. def _when_ready(self): @@ -460,8 +468,9 @@ def pct_warn(self): @pct_warn.setter @check_ready - def pct_warn(self, input): - self._pct_warn = input / 100 + def pct_warn(self, the_input): + self._logger.info("Percent warn is - {}".format(the_input)) + self._pct_warn = the_input / 100 @property def pct_crit(self): @@ -472,18 +481,19 @@ def pct_crit(self): @pct_crit.setter @check_ready - def pct_crit(self, input): - self._pct_crit = input / 100 + def pct_crit(self, the_input): + self._logger.info("Percent critical is - {}".format(the_input)) + self._pct_crit = the_input / 100 # Pre-bake distances for warn and critical to make evaluations a little easier. def _derived_distances(self): - self._logger.debug("Calculating derived distances.") + self._logger.info("Calculating derived distances.") adjusted_distance = self.bay_depth - self._offset - self._logger.debug("Adjusted distance: {}".format(adjusted_distance)) - self._dist_warn = ( adjusted_distance.magnitude * self.pct_warn )/100 * adjusted_distance.units - self._logger.debug("Warning distance: {}".format(self._dist_warn)) - self._dist_crit = ( adjusted_distance.magnitude * self.pct_crit )/100 * adjusted_distance.units - self._logger.debug("Critical distance: {}".format(self._dist_crit)) + self._logger.info("Adjusted distance: {}".format(adjusted_distance)) + self._dist_warn = (adjusted_distance.magnitude * self.pct_warn) / 100 * adjusted_distance.units + self._logger.info("Warning distance: {}".format(self._dist_warn)) + self._dist_crit = (adjusted_distance.magnitude * self.pct_crit) / 100 * adjusted_distance.units + self._logger.info("Critical distance: {}".format(self._dist_crit)) # Reference some properties upward to the parent class. This is necessary because properties aren't directly # inherented. @@ -494,7 +504,7 @@ def offset(self): @offset.setter def offset(self, new_offset): - super(Range, self.__class__).offset.fset(self, new_offset) + super(Longitudinal, self.__class__).offset.fset(self, new_offset) @property def status(self): @@ -502,16 +512,21 @@ def status(self): @status.setter def status(self, target_status): - super(Range, self.__class__).status.fset(self, target_status) + super(Longitudinal, self.__class__).status.fset(self, target_status) + # Detector for lateral position class Lateral(SingleDetector): - def __init__(self, detector_id, name, sensor_type, sensor_settings, log_level="WARNING"): - super().__init__(detector_id, name, sensor_type, sensor_settings, log_level) + def __init__(self, detector_id, name, sensor_type, sensor_settings, log_level="WARNING" ): + super().__init__(detector_id=detector_id, name=name, sensor_type=sensor_type, sensor_settings=sensor_settings, + log_level=log_level) + self._intercept = None self._required = ['side', 'spread_ok', 'spread_warn'] self._side = None self._spread_ok = None self._spread_warn = None + self._limit = None + self._bay_obj = None # Method to get the raw sensor reading. This is used to report upward for HA extended attributes. @property @@ -524,7 +539,7 @@ def value_raw(self): raise self._history[0][0] else: # Anything else, treat it as the sensor returning no reading. - return CobraBay.const.DETECTOR_NOREADING + return DETECTOR_NOREADING @property @read_if_stale @@ -532,24 +547,34 @@ def quality(self): self._logger.debug("Assessing quality for value: {}".format(self.value)) # Process quality if we get a quantity from the Detector. if isinstance(self.value, Quantity): - self._logger.debug("Comparing to OK ({}) and WARN ({})".format( - self.spread_ok, self.spread_warn)) - if self.value > Quantity('90 in'): - # A standard vehicle width (in the US, at least) is 96 inches. If we can reach across a significant - # proportion of the bay, we're not finding a vehicle, so deem it to be no vehicle. - qv = "no_vehicle" + if self.value > self._limit: + qv = DETECTOR_QUALITY_NOOBJ elif abs(self.value) <= self.spread_ok: - qv = "ok" + qv = DETECTOR_QUALITY_OK elif abs(self.value) <= self.spread_warn: - qv = "warning" + qv = DETECTOR_QUALITY_WARN elif abs(self.value) > self.spread_warn: - qv = "critical" + qv = DETECTOR_QUALITY_CRIT else: # Total failure to return a value means the light didn't reflect off anything. That *probably* means # nothing is there, but it could be failing for other reasons. - qv = "unknown" + qv = GEN_UNKNOWN + else: + qv = GEN_UNKNOWN + # Check for interception. If this is enabled, we stomp over everything else. + if self.attached_bay is not None and self.intercept is not None: + self._logger.debug("Evaluating for interception.") + lv = self.attached_bay.range.value_raw + try: + if lv > self.intercept: + self._logger.debug("Reported range '{}' greater than intercept '{}'. Not intercepted.".format( + self.attached_bay.range.value, self.intercept + )) + qv = DETECTOR_NOINTERCEPT + except ValueError: + self._logger.warning("Cannot use longitudinal value '{}' to check for intercept".format(lv)) else: - qv = "unknown" + self._logger.debug("Cannot evaluate for interception, not configured.") self._logger.debug("Quality {}".format(qv)) return qv @@ -588,6 +613,33 @@ def side(self, m_input): else: self._side = m_input.upper() + @property + def limit(self): + return self._limit + + @limit.setter + def limit(self, new_limit): + self._limit = new_limit + + @property + def attached_bay(self): + return self._bay_obj + + @attached_bay.setter + def attached_bay(self, new_bay_obj): + self._bay_obj = new_bay_obj + + @property + def intercept(self): + return self._intercept + + @intercept.setter + def intercept(self, new_intercept): + if not isinstance(new_intercept, Quantity): + raise TypeError("Intercept must be a quantity.") + else: + self._intercept = new_intercept + # Reference some properties upward to the parent class. This is necessary because properties aren't directly # inherented. diff --git a/CobraBay/display.py b/CobraBay/display.py index 0e88dbb..0677518 100644 --- a/CobraBay/display.py +++ b/CobraBay/display.py @@ -13,25 +13,64 @@ from base64 import b64encode from io import BytesIO import math +from CobraBay.const import * ureg = UnitRegistry() - class CBDisplay: - def __init__(self, config): + def __init__(self, + width, + height, + gpio_slowdown, + font, + cbcore, + bottom_box=None, + unit_system="metric", + mqtt_image=True, + mqtt_update_interval=None, + strobe_speed=None, + log_level="WARNING"): + """ + + :param unit_system: Unit system. "imperial" or "metric", defaults to "metric" + :type unit_system: str + :param width: Width of the LED matrix, in pixels + :type width: int + :param height: Height of the LED matrix, in pixels + :type height: int + :param gpio_slowdown: GPIO pacing to prevent flicker + :type gpio_slowdown: int + :param bottom_box: Which bottom box to be. Can be "strobe", "progress" or "none" + :type bottom_box: str + :param strobe_speed: How fast the strober bugs should move. + :type strobe_speed: Quantity(ms) + :param font: Path to the font to use. Must be a TTF. + :type font: Path + :param cbcore: Reference to the Core object + """ # Get a logger! self._logger = logging.getLogger("CobraBay").getChild("Display") - self._logger.setLevel(config.get_loglevel('display')) + self._logger.setLevel(log_level.upper()) self._logger.info("Display initializing...") - # Initialize the internal settings. - self._settings = config.display() - self._logger.info("Now have settings: {}".format(self._settings)) + self._logger.info("Display unit system: {}".format(unit_system)) + + # Save parameters + self._matrix_width = width + self._matrix_height = height + self._bottom_box = bottom_box + self._strobe_speed = strobe_speed + self._unit_system = unit_system + # Based on unit system, set the target unit. + if self._unit_system.lower() == 'imperial': + self._target_unit = 'in' + else: + self._target_unit = 'm' + self._cbcore = cbcore - # Operating settings. These get reset on every start. - self._running = {} - self._running['strobe_offset'] = 0 - self._running['strobe_timer'] = monotonic_ns() + self._core_font = font + # Operating settings. These get reset on every start. + self._running = {'strobe_offset': 0, 'strobe_timer': monotonic_ns()} self._current_image = None # Layers dict. @@ -43,7 +82,7 @@ def __init__(self, config): self._setup_layers() # Set up the matrix object itself. - self._create_matrix(self._settings['matrix_width'], self._settings['matrix_height'], self._settings['gpio_slowdown']) + self._create_matrix(self._matrix_width, self._matrix_height, gpio_slowdown) # Method to set up image layers for use. This takes a command when the bay is ready so lateral zones can be prepped. def _setup_layers(self): @@ -54,41 +93,49 @@ def _setup_layers(self): self._layers['error'] = self._placard('ERROR','red') # Have a bay register. This creates layers for the bay in advance so they can be composited faster. - def register_bay(self, display_reg_info): - bay_id = display_reg_info['id'] - self._logger.debug("Registering bay ID {} to display".format(bay_id)) - self._logger.debug("Got registration input: {}".format(display_reg_info)) - # Initialize a dict for this bay_id. - self._layers[bay_id] = {} + def register_bay(self, bay_obj): + ''' + Register a bay with the display. This pre-creates all the needed images for display. + + :param bay_obj: The bay object being registered. + :type bay_obj: CBBay + :return: + ''' + self._logger.debug("Registering bay ID {} to display".format(bay_obj.id)) + self._logger.debug("Setting up for laterals: {}".format(bay_obj.lateral_sorted)) + # Initialize a dict for this bay. + self._layers[bay_obj.id] = {} # If no lateral detectors are defined, do nothing else. - if len(display_reg_info['lateral_order']) == 0: - return None + if len(bay_obj.lateral_sorted) == 0: + return # For convenient reference later. - w = self._settings['matrix_width'] - h = self._settings['matrix_height'] + w = self._matrix_width + h = self._matrix_height # Calculate the available pixels for each zones. - avail_height = self._settings['matrix_height'] - 6 # - pixel_lengths = self._parts(avail_height, len(display_reg_info['lateral_order'])) + avail_height = self._matrix_height - 6 # + pixel_lengths = self._parts(avail_height, len(bay_obj.lateral_sorted)) self._logger.debug("Split {} pixels for {} lateral zones into: {}". - format(avail_height,len(display_reg_info['lateral_order']),pixel_lengths)) + format(avail_height,len(bay_obj.lateral_sorted),pixel_lengths)) status_lookup = ( - ['ok',(0,128,0,255)], - ['warning',(255,255,0,255)], - ['critical',(255,0,0,255)] + {'status': DETECTOR_QUALITY_OK, 'border': (0,128,0,255), 'fill': (0,128,0,255)}, + {'status': DETECTOR_QUALITY_WARN, 'border': (255,255,0,255), 'fill': (255,255,0,255)}, + {'status': DETECTOR_QUALITY_CRIT,'border': (255,0,0,255), 'fill': (255,0,0,255)}, + {'status': DETECTOR_NOINTERCEPT, 'border': (255,255,255,255), 'fill': (0,0,0,0)} ) i = 0 # Add in the used height of each bar to this variable. Since they're not guaranteed to be the same, we can't # just multiply. accumulated_height = 0 - for lateral in display_reg_info['lateral_order']: + for intercept in bay_obj.lateral_sorted: + lateral = intercept.lateral self._logger.debug("Processing lateral zone: {}".format(lateral)) - self._layers[bay_id][lateral] = {} + self._layers[bay_obj.id][lateral] = {} for side in ('L','R'): - self._layers[bay_id][lateral][side] = {} + self._layers[bay_obj.id][lateral][side] = {} if side == 'L': # line_w = 5 line_w = 0 @@ -100,46 +147,41 @@ def register_bay(self, display_reg_info): # Make an image for the 'fault' status. img = Image.new('RGBA', (w, h), (0,0,0,0)) - draw = ImageDraw.Draw(img) - - j = 0 - while j <= pixel_lengths[i]: - draw.line([ - (line_w, accumulated_height + j),(line_w + 2, accumulated_height + j) - ],fill=(255,0,0,255),width=1) - j += 2 - - # Save - self._layers[bay_id][lateral][side]['fault'] = img - del(draw) + # Make a striped box for fault. + img = self._rectangle_striped( + img, + (line_w, 1 + accumulated_height), + (line_w + 2, 1 + accumulated_height + pixel_lengths[i]), + pricolor='red', + seccolor='yellow' + ) + self._layers[bay_obj.id][lateral][side]['fault'] = img del(img) - for status in status_lookup: - self._logger.debug("Creating layer for side {}, status {} with color {}.".format(side, status[0], status[1])) + for item in status_lookup: + self._logger.debug("Creating layer for side {}, status {} with border {}, fill {}." + .format(side, item['status'], item['border'], item['fill'])) # Make the image. img = Image.new('RGBA', (w, h), (0,0,0,0)) draw = ImageDraw.Draw(img) - + # Draw the rectangle draw.rectangle( - [ - (line_w,1 + accumulated_height), - (line_w+2,1 + accumulated_height + pixel_lengths[i]) - ], - fill=status[1],width=1) + [line_w,1 + accumulated_height,line_w+2,1 + accumulated_height + pixel_lengths[i]], + fill=item['fill'], + outline=item['border'], + width=1) # Put this in the right place in the lookup. - self._layers[bay_id][lateral][side][status[0]] = img + self._layers[bay_obj.id][lateral][side][item['status']] = img # Write for debugging # img.save("/tmp/CobraBay-{}-{}-{}.png".format(lateral,side,status[0]), format='PNG') del(draw) del(img) - - # Now add the height of this bar to the accumulated height, to get the correct start for the next time. accumulated_height += pixel_lengths[i] # Increment to the next zone. i += 1 - self._logger.debug("Created laterals for {}: {}".format(bay_id, self._layers[bay_id])) + self._logger.debug("Created laterals for {}: {}".format(bay_obj.id, self._layers[bay_obj.id])) # General purpose message displayer def show(self, system_status, mode, message=None, color="white", icons=True): @@ -155,7 +197,7 @@ def show(self, system_status, mode, message=None, color="white", icons=True): raise ValueError("Show did not get a valid mode.") # Make a base layer. - img = Image.new("RGBA", (self._settings['matrix_width'], self._settings['matrix_height']), (0,0,0,255)) + img = Image.new("RGBA", (self._matrix_width, self._matrix_height), (0,0,0,255)) # If enabled, put status icons at the bottom of the display. if icons: # Network status icon, shows overall network and MQTT status. @@ -174,7 +216,7 @@ def show(self, system_status, mode, message=None, color="white", icons=True): # Specific displayer for docking. def show_motion(self, direction, bay_obj): - self._logger.debug("Show Dock received bay '{}'".format(bay_obj.name)) + self._logger.debug("Show Motion received bay '{}'".format(bay_obj.name)) # Don't do motion display if the bay isn't in a motion state. if bay_obj.state not in ('docking','undocking'): @@ -182,8 +224,8 @@ def show_motion(self, direction, bay_obj): return # For easy reference. - w = self._settings['matrix_width'] - h = self._settings['matrix_height'] + w = self._matrix_width + h = self._matrix_height # Make a base image, black background. final_image = Image.new("RGBA", (w, h), (0,0,0,255)) ## Center area, the range number. @@ -194,19 +236,48 @@ def show_motion(self, direction, bay_obj): ) final_image = Image.alpha_composite(final_image, range_layer) - ## Bottom strobe box. - final_image = Image.alpha_composite(final_image, - self._strobe( - range_quality=bay_obj.range.quality, - range_pct=bay_obj.range_pct - )) - - for lateral_detector in bay_obj.lateral_sorted: - detector = bay_obj.detectors[lateral_detector] - if detector.quality in ('ok','warning','critical'): - self._logger.debug("Compositing in lateral indicator layer for {} {} {}".format(detector.name, detector.side, detector.quality)) - selected_layer = self._layers[bay_obj.id][detector.id][detector.side][detector.quality] - final_image = Image.alpha_composite(final_image, selected_layer) + # ## Bottom strobe box. + try: + if self._bottom_box.lower() == 'strobe': + + final_image = Image.alpha_composite(final_image, + self._strobe( + range_quality=bay_obj.range.quality, + range_pct=bay_obj.range_pct)) + elif self._bottom_box.lower() == 'progress': + self._logger.debug("Compositing in progress for bottom box.") + final_image = Image.alpha_composite(final_image, + self._progress_bar(range_pct=bay_obj.range_pct)) + except AttributeError: + self._logger.debug("Bottom box disabled.") + pass + + for intercept in bay_obj.lateral_sorted: + detector = bay_obj.detectors[intercept.lateral] + if detector.quality == DETECTOR_NOINTERCEPT: + # No intercept shows on both sides. + combined_layers = Image.alpha_composite( + self._layers[bay_obj.id][detector.id]['L'][detector.quality], + self._layers[bay_obj.id][detector.id]['R'][detector.quality] + ) + final_image = Image.alpha_composite(final_image, combined_layers) + elif detector.quality in (DETECTOR_QUALITY_OK, DETECTOR_QUALITY_WARN, DETECTOR_QUALITY_CRIT): + # Pick which side the vehicle is offset towards. + if detector.value == 0: + skew = ('L','R') # In the rare case the value is exactly zero, show both sides. + elif detector.side == 'R' and detector.value > 0: + skew = ('R') + elif detector.side == 'R' and detector.value < 0: + skew = ('L') + elif detector.side == 'L' and detector.value > 0: + skew = ('L') + elif detector.side == 'L' and detector.value < 0: + skew = ('R') + + self._logger.debug("Compositing in lateral indicator layer for {} {} {}".format(detector.name, skew, detector.quality)) + for item in skew: + selected_layer = self._layers[bay_obj.id][detector.id][item][detector.quality] + final_image = Image.alpha_composite(final_image, selected_layer) self._output_image(final_image) def _strobe(self, range_quality, range_pct): @@ -218,14 +289,14 @@ def _strobe(self, range_quality, range_pct): :param range_pct: Percentage of distance from garage door to the parking point. :return: ''' - w = self._settings['matrix_width'] - h = self._settings['matrix_height'] + w = self._matrix_width + h = self._matrix_height # Set up a base image to draw on. img = Image.new("RGBA", (w, h), (0,0,0,0)) draw = ImageDraw.Draw(img) # Back up and emergency distances, we flash the whole bar. if range_quality in ('back_up','emergency'): - if monotonic_ns() > self._running['strobe_timer'] + self._settings['strobe_speed']: + if monotonic_ns() > self._running['strobe_timer'] + self._strobe_speed: try: if self._running['strobe_color'] == 'red': self._running['strobe_color'] = 'black' @@ -267,7 +338,7 @@ def _strobe(self, range_quality, range_pct): # draw.line([(right_strobe_start, h - 2), (right_strobe_stop, h - 2)], fill="red") draw.rectangle([(right_strobe_start, h - 3), (right_strobe_stop, h - 1)], fill="red") # If time is up, move the strobe bug forward. - if monotonic_ns() > self._running['strobe_timer'] + self._settings['strobe_speed']: + if monotonic_ns() > self._running['strobe_timer'] + self._strobe_speed: self._running['strobe_offset'] += 1 self._running['strobe_timer'] = monotonic_ns() # Don't let the offset push the bugs out to infinity. @@ -277,8 +348,8 @@ def _strobe(self, range_quality, range_pct): # Methods to create image objects that can then be composited. def _frame_approach(self): - w = self._settings['matrix_width'] - h = self._settings['matrix_height'] + w = self._matrix_width + h = self._matrix_height img = Image.new("RGBA", (w, h), (0, 0, 0, 0)) draw = ImageDraw.Draw(img) draw.rectangle([(0, h - 3), (w - 1, h - 1)], width=1) @@ -286,8 +357,8 @@ def _frame_approach(self): def _frame_lateral(self): # Localize matrix width and height, just to save readability - w = self._settings['matrix_width'] - h = self._settings['matrix_height'] + w = self._matrix_width + h = self._matrix_height img = Image.new("RGBA", (w, h), (0, 0, 0, 0)) draw = ImageDraw.Draw(img) # Left Rectangle @@ -314,15 +385,15 @@ def _icon_network(self, net_status=False, mqtt_status=False, x_input=None, y_inp # Default to lower right placement if no alternate positions given. if x_input is None: - x_input = self._settings['matrix_width']-5 + x_input = self._matrix_width-5 if y_input is None: - y_input = self._settings['matrix_height']-5 + y_input = self._matrix_height-5 - w = self._settings['matrix_width'] - h = self._settings['matrix_height'] + w = self._matrix_width + h = self._matrix_height img = Image.new("RGBA", (w, h), (0, 0, 0, 0)) draw = ImageDraw.Draw(img) - draw.rectangle([x_input+1,y_input,x_input+3,y_input+2], outline="white", fill=mqtt_color) + draw.rectangle([x_input+1,y_input,x_input+3,y_input+2], outline=mqtt_color, fill=mqtt_color) # Base network line. draw.line([x_input,y_input+4,x_input+4,y_input+4],fill=net_color) # Network stem @@ -333,27 +404,35 @@ def _icon_network(self, net_status=False, mqtt_status=False, x_input=None, y_inp def _placard_range(self, input_range, range_quality, bay_state): self._logger.debug("Creating range placard with range {} and quality {}".format(input_range, range_quality)) range_string = "NOVAL" - # Some range quality statuses need a text output, not a distance. - if range_quality == 'back_up': - range_string = "BACK UP" - elif range_quality == 'door_open': - if bay_state == 'docking': + + + # Convert to the proper target unit. + try: + range_converted = input_range.to(self._target_unit) + except AttributeError: + # If the range isn't a Quantity, we get an attribute error. + if range_quality == DETECTOR_QUALITY_BACKUP: + range_string = "BACK UP" + elif range_quality == DETECTOR_QUALITY_DOOROPEN: + if bay_state == BAYSTATE_DOCKING: + range_string = "APPROACH" + elif bay_state == BAYSTATE_UNDOCKING: + range_string = "CLEAR!" + elif input_range == DETECTOR_QUALITY_BEYOND: range_string = "APPROACH" - elif bay_state == 'undocking': - range_string = "CLEAR!" - elif input_range == 'Beyond range': - range_string = "APPROACH" - elif self._settings['unit_system'] == 'imperial': - as_inches = input_range.to('in') - if as_inches.magnitude < 12: - range_string = "{}\"".format(round(as_inches.magnitude,1)) else: - feet = int(as_inches.to(ureg.inch).magnitude // 12) - inches = round(as_inches.to(ureg.inch).magnitude % 12) - range_string = "{}'{}\"".format(feet,inches) + range_string = "NOVAL" else: - as_meters = round(input_range.to('m').magnitude,2) - range_string = "{} m".format(as_meters) + # If we correctly converted, + if self._unit_system.lower() == 'imperial': + if range_converted.magnitude < 12: + range_string = "{}\"".format(round(range_converted.magnitude,1)) + else: + feet = int(range_converted.to(ureg.inch).magnitude // 12) + inches = round(range_converted.to(ureg.inch).magnitude % 12) + range_string = "{}'{}\"".format(feet,inches) + else: + range_string = "{} m".format(round(range_converted.magnitude,2)) # Determine a color based on quality if range_quality in ('critical','back_up'): @@ -361,7 +440,7 @@ def _placard_range(self, input_range, range_quality, bay_state): elif range_quality == 'warning': text_color = 'yellow' elif range_quality == 'door_open': - text_color = 'blue' + text_color = 'white' else: text_color = 'green' # Now we can get it formatted and return it. @@ -371,23 +450,34 @@ def _placard_range(self, input_range, range_quality, bay_state): # Generalized placard creator. Make an image for arbitrary text. def _placard(self,text,color,w_adjust=8,h_adjust=4): # Localize matrix and adjust. - w = self._settings['matrix_width'] - h = self._settings['matrix_height'] + w = self._matrix_width + h = self._matrix_height img = Image.new("RGBA", (w, h), (0, 0, 0, 0)) draw = ImageDraw.Draw(img) # Find the font size we can use. - font = ImageFont.truetype(font=self._settings['core_font'], + font = ImageFont.truetype(font=self._core_font, size=self._scale_font(text, w-w_adjust, h-h_adjust)) # Make the text. Center it in the middle of the area, using the derived font size. draw.text((w/2, (h-4)/2), text, fill=ImageColor.getrgb(color), font=font, anchor="mm") return img + def _progress_bar(self, range_pct): + img = Image.new("RGBA", (self._matrix_width, self._matrix_height), (0,0,0,0)) + draw = ImageDraw.Draw(img) + draw.rectangle((0,0,self._matrix_width-1,self._matrix_height-1),fill=None, outline='white', width=1) + self._logger.debug("Total matrix width: {}".format(self._matrix_width)) + self._logger.debug("Range percentage: {}".format(range_pct)) + progress_pixels = int((self._matrix_width-2)*range_pct) + self._logger.debug("Progress bar pixels: {}".format(progress_pixels)) + draw.line((1,self._matrix_height-2,1+progress_pixels,self._matrix_height-2),fill='green', width=1) + return img + # Utility method to find the largest font size that can fit in a space. def _scale_font(self, text, w, h): # Start at font size 1. fontsize = 1 while True: - font = ImageFont.truetype(font=self._settings['core_font'], size=fontsize) + font = ImageFont.truetype(font=self._core_font, size=fontsize) bbox = font.getbbox(text) text_width = bbox[2] - bbox[0] text_height = bbox[3] - bbox[1] @@ -397,6 +487,48 @@ def _scale_font(self, text, w, h): break return fontsize + # Create a two-color, 45 degree striped rectangle + @staticmethod + def _rectangle_striped(input_image, start, end, pricolor='red', seccolor='yellow'): + # Simple breakout of input. Replace with something better later, maybe. + x_start = start[0] + y_start = start[1] + x_end = end[0] + y_end = end[1] + + # Create a drawing object on the provided image. + draw = ImageDraw.Draw(input_image) + + # Start out with the primary color. + current_color = pricolor + # track current column. + current_x = x_start - (y_end - y_start) + current_y = y_start + while current_x <= x_end: + line_start = [current_x, y_start] + line_end = [current_x + (y_end - y_start), y_end] + # Trim the lines. + if line_start[0] < x_start: + diff = x_start - line_start[0] + # Move the X start to the right and the Y start down. + line_start = [x_start, y_start + diff] + if line_end[0] > x_end: + diff = line_end[0] - x_end + # Move the X start back to the left and the Y start up. + line_end = [x_end, y_end - diff] + draw.line([line_start[0], line_start[1], line_end[0], line_end[1]], + fill=current_color, + width=1 + ) + # Rotate the color. + if current_color == pricolor: + current_color = seccolor + else: + current_color = pricolor + # Increment the current X + current_x += 1 + return input_image + # Utility method to do all the matrix creation. def _create_matrix(self, width, height, gpio_slowdown): self._logger.info("Initializing Matrix...") diff --git a/CobraBay/network.py b/CobraBay/network.py index 89196af..6ef94bc 100644 --- a/CobraBay/network.py +++ b/CobraBay/network.py @@ -6,49 +6,37 @@ import logging from json import dumps as json_dumps -#from json import loads as json_loads +from json import loads as json_loads import time -import sys - # from getmac import get_mac_address import psutil from paho.mqtt.client import Client - from .util import Convertomatic from .version import __version__ - -# -# {'units': 'imperial', -# 'system_name': 'CobraBay1', -# 'homeassistant': True, -# 'interface': 'eth0', -# 'mqtt': -# {'broker': 'cnc.jumpbeacon.net', -# 'port': 1883, -# 'username': 'cobrabay', -# 'password': 'NbX2&38z@%H@$Cg0'}} +from CobraBay.const import * class CBNetwork: def __init__(self, unit_system, system_name, interface, - mqtt_broker, - mqtt_port, - mqtt_username, - mqtt_password, + broker, + port, + username, + password, cbcore, - homeassistant=True, + ha_discover=True, + accept_commands=True, log_level="WARNING", - mqtt_log_level="WARNING"): + mqtt_log_level="DISABLED"): # Save parameters. self._pistatus = None self._display_obj = None - self._mqtt_broker = mqtt_broker - self._mqtt_port = mqtt_port - self._mqtt_username = mqtt_username - self._mqtt_password = mqtt_password - self._use_homeassistant = homeassistant + self._mqtt_broker = broker + self._mqtt_port = port + self._mqtt_username = username + self._mqtt_password = password + self._perform_ha_discover = ha_discover self._unit_system = unit_system self._system_name = system_name self._interface = interface @@ -56,14 +44,18 @@ def __init__(self, # Set up logger. self._logger = logging.getLogger("CobraBay").getChild("Network") - self._logger.setLevel(log_level) + self._logger.setLevel(log_level.upper()) self._logger.info("Network initializing...") - # Sub-logger for just MQTT + # Create a sublogger for the MQTT client. self._logger_mqtt = logging.getLogger("CobraBay").getChild("MQTT") - self._logger_mqtt.setLevel(mqtt_log_level) - if self._logger_mqtt.level != self._logger.level: - self._logger.info("MQTT Logging level set to {}".format(self._logger_mqtt.level)) + # If MQTT logging is disabled, send it to a null logger. + if mqtt_log_level == 'DISABLE': + self._logger.info("MQTT client logging is disabled. Set 'mqtt' in logging section if you want it enabled.") + self._logger_mqtt.addHandler(logging.NullHandler()) + self._logger_mqtt.propagate = False + else: + self._logger_mqtt.setLevel(mqtt_log_level.upper()) # Create a convertomatic instance. self._cv = Convertomatic(self._unit_system) @@ -113,8 +105,9 @@ def __init__(self, username=self._mqtt_username, password=self._mqtt_password ) - # Send MQTT logging to the network logger. - # self._mqtt_client.enable_logger(self._logger) + # Send MQTT logging to the MQTT sublogger + if mqtt_log_level != 'DISABLE': + self._mqtt_client.enable_logger(self._logger_mqtt) # Connect callback. self._mqtt_client.on_connect = self._on_connect @@ -191,9 +184,14 @@ def _on_message(self, client, user, message): # Message publishing method def _pub_message(self, topic, payload, repeat): + self._logger.debug("Processing message publication on topic '{}'".format(topic)) # Set the send flag initially. If we've never seen the topic before or we're set to repeat, go ahead and send. # This skips some extra logic. - if topic not in self._topic_history or repeat: + if topic not in self._topic_history: + self._logger.debug("Topic not in history, sending...") + send = True + elif repeat: + self._logger.debug("Repeat explicitly enabled, sending...") send = True else: send = False @@ -204,29 +202,36 @@ def _pub_message(self, topic, payload, repeat): # If we're not already sending, then we've seen the topic before and should check for changes. if send is False: - previous_state = self._topic_history[topic] + previous_payload = self._topic_history[topic] # Both strings, compare and send if different - if isinstance(message, str) and isinstance(previous_state, str): - if message != previous_state: + if ( isinstance(message, str) and isinstance(previous_payload, str) ) or \ + ( isinstance(message, (int, float)) and isinstance(previous_payload, (int, float)) ): + if message != previous_payload: + self._logger.debug("Payload '{}' does not match previous payload '{}'. Publishing.".format(payload, previous_payload)) send = True else: + self._logger.debug("Payload has not changed, will not publish") return # For dictionaries, compare individual elements. This doesn't handle nested dicts, but those aren't used. - elif isinstance(message, dict) and isinstance(previous_state, dict): + elif isinstance(message, dict) and isinstance(previous_payload, dict): for item in message: - if item not in previous_state: + if item not in previous_payload: + self._logger.debug("Payload dict contains new key, publishing.") send = True break - if message[item] != previous_state[item]: + if message[item] != previous_payload[item]: + self._logger.debug("Payload dict key '{}' has changed value, publishing.".format(item)) send = True break - else: - # If type has changed, which is odd, (and it shouldn't, usually), send it. - if type(message) != type(previous_state): - send = True + # If type has changed, which is odd, (and it shouldn't, usually), send it. + elif type(message) != type(previous_payload): + self._logger.debug("Payload type has changed from '{}' to '{}'. Unusual, but publishing anyway.". + format(type(previous_payload), type(payload))) + send = True # If we're sending do it. if send: + self._logger.debug("Publishing message...") # New message becomes the previous message. self._topic_history[topic] = message # Convert the message to JSON if it's a dict, otherwise just send it. @@ -259,6 +264,7 @@ def poll(self): # Has is been 30s since the previous attempt? try: if time.monotonic() - self._reconnect_timestamp > 30: + self._logger.info("30s since previous connection attempt. Retrying...") try_reconnect = True self._reconnect_timestamp = time.monotonic() except TypeError: @@ -269,6 +275,7 @@ def poll(self): reconnect = self._connect_mqtt() # If we failed to reconnect, mark it as failure and return. if not reconnect: + self._logger.warning("Could not connect to MQTT server. Will retry in 30s.") return return_data # Network/MQTT is up, proceed. @@ -278,16 +285,16 @@ def poll(self): # that values actually show up in HA. if self._ha_repeat_override: if time.monotonic() - self._ha_timestamp <= 15: - self._logger.debug("HA discovery {}s ago, sending all".format(time.monotonic() - self._ha_timestamp)) + self._logger.info("HA discovery {}s ago, sending all".format(time.monotonic() - self._ha_timestamp)) force_repeat = True else: - self._logger.debug("Have sent all messages for 15s after HA discovery. Disabling.") + self._logger.info("Have sent all messages for 15s after HA discovery. Disabling.") self._ha_repeat_override = False force_repeat = False else: force_repeat = False for message in self._mqtt_messages(force_repeat=force_repeat): - self._logger_mqtt.debug("Publishing MQTT message: {}".format(message)) + #self._logger_mqtt.debug("Publishing MQTT message: {}".format(message)) self._pub_message(**message) # Check for any incoming commands. self._mqtt_client.loop() @@ -318,7 +325,7 @@ def _connect_mqtt(self): return False # Send a discovery message and an online notification. - if self._use_homeassistant: + if self._perform_ha_discover: self._ha_discovery() # Reset the topic history so any newly discovered entities get sent to. self._topic_history = {} @@ -430,10 +437,9 @@ def _mqtt_messages_bay(self, input_obj): outbound_messages.append({'topic': topic_base + 'state', 'payload': input_obj.state, 'repeat': False}) # Bay vector outbound_messages.append({'topic': topic_base + 'vector', 'payload': input_obj.vector, 'repeat': False}) - # Bay vector + # Bay motion timer outbound_messages.append({'topic': topic_base + 'motion_timer', 'payload': input_obj.motion_timer, 'repeat': False}) - # Bay occupancy. This value can get wonky as detectors are shutting down, so don't update during shutdown. if self._cbcore.system_state != 'shutdown': outbound_messages.append({'topic': topic_base + 'occupancy', 'payload': input_obj.occupied, 'repeat': False}) @@ -460,8 +466,10 @@ def _mqtt_messages_detector(self, input_obj, topic_base=None): outbound_messages.append({'topic': topic_base + 'status', 'payload': input_obj.status, 'repeat': False}) # Is the detector in fault? outbound_messages.append({'topic': topic_base + 'fault', 'payload': input_obj.fault, 'repeat': False}) + # The detector's current offset + outbound_messages.append({'topic': topic_base + 'offset', 'payload': input_obj.offset, 'repeat': False}) # Send value, raw value and quality if detector is ranging. - if input_obj.status == 'ranging': + if input_obj.status == SENSTATE_RANGING: # Detector Value. outbound_messages.append({'topic': topic_base + 'reading', 'payload': input_obj.value, 'repeat': False}) # Detector reading unadjusted by depth. @@ -489,14 +497,16 @@ def _ha_discovery(self, force=False): # Create HA discovery message. def _ha_discover(self, name, topic, type, entity, device_info=True, system_avail=True, avail=None, avail_mode=None, **kwargs): - + allowed_types = ('camera','binary_sensor','sensor','select') # Trap unknown types. - if type not in ('camera','binary_sensor','sensor'): - raise ValueError("Type must be 'camera','binary_sensor' or 'sensor'") + if type not in allowed_types: + raise ValueError("Type must be one of {}".format(allowed_types)) # Adjust the topic key based on the type, because the syntax varries. if type == 'camera': topic_key = 'topic' + elif type == 'select': + topic_key = 'command_topic' else: topic_key = 'state_topic' @@ -509,10 +519,14 @@ def _ha_discover(self, name, topic, type, entity, device_info=True, system_avail 'unique_id': self._client_id + '.' + entity, 'availability': [] } - # Add device info. + # Add device info if asked to. if device_info: discovery_dict['device'] = self._device_info + # This is how we handle varying requirements for different types. + # 'required' - must exist *and* be defined + # 'nullable' - must exist, may be null + # 'optional' - may be defined or undefined. if type == 'camera': required_parameters = ['image_encoding'] nullable_parameters = [] @@ -525,8 +539,12 @@ def _ha_discover(self, name, topic, type, entity, device_info=True, system_avail required_parameters = [] nullable_parameters = [] optional_parameters = ['icon','unit_of_measurement', 'value_template'] + elif type == 'select': + required_parameters = ['options'] + nullable_parameters = [] + optional_parameters = [] else: - raise + raise ValueError('Discovery is of unknown type {}'.format(type)) for param in required_parameters: try: @@ -579,7 +597,8 @@ def _ha_discover(self, name, topic, type, entity, device_info=True, system_avail discovery_topic = "homeassistant/{}/CobraBay_{}/{}/config".\ format(type,self._client_id,discovery_dict['object_id']) self._logger.info("Publishing HA discovery to topic '{}'\n\t{}".format(discovery_topic, discovery_json)) - self._mqtt_client.publish(discovery_topic, discovery_json) + # All discovery messages should be retained. + self._mqtt_client.publish(topic=discovery_topic, payload=discovery_json, retain=True) # Remove this topic from the topic history if it exists. try: self._logger.debug("Removed previous value '{}' for topic '{}'".format(self._topic_history[topic], topic)) @@ -603,7 +622,7 @@ def _ha_discovery_system(self): self._ha_discover( name="{} CPU Use".format(self._system_name), topic="CobraBay/" + self._client_id + "/cpu_pct", - type="sensor", + type='sensor', entity="{}_cpu_pct".format(self._system_name.lower()), unit_of_measurement="%", icon="mdi:chip" @@ -612,7 +631,7 @@ def _ha_discovery_system(self): self._ha_discover( name="{} CPU Temperature".format(self._system_name), topic="CobraBay/" + self._client_id + "/cpu_temp", - type="sensor", + type='sensor', entity="{}_cpu_temp".format(self._system_name.lower()), unit_of_measurement=self._uom('temp'), icon="mdi:thermometer" @@ -621,7 +640,7 @@ def _ha_discovery_system(self): self._ha_discover( name="{} Memory Free".format(self._system_name), topic="CobraBay/" + self._client_id + "/mem_info", - type="sensor", + type='sensor', entity="{}_mem_info".format(self._system_name.lower()), value_template='{{ value_json.mem_pct }}', unit_of_measurement='%', @@ -631,7 +650,7 @@ def _ha_discovery_system(self): self._ha_discover( name="{} Undervoltage".format(self._system_name), topic="CobraBay/" + self._client_id + "/undervoltage", - type="binary_sensor", + type='binary_sensor', entity="{}_undervoltage".format(self._system_name.lower()), payload_on="true", payload_off="false", @@ -641,12 +660,27 @@ def _ha_discovery_system(self): self._ha_discover( name="{} Display".format(self._system_name), topic="CobraBay/" + self._client_id + "/display", - type="camera", + type='camera', entity="{}_display".format(self._system_name.lower()), image_encoding='b64', icon="mdi:image-area" ) + # System Commands + # By this point, a syscmd trigger *should* exist. Not existing is...odd. + try: + syscmd_trigger = self._trigger_registry['syscmd'] + except KeyError: + self._logger.error("No System Command trigger defined. Cannot perform discovery on it.") + else: + self._ha_discover( + name="{} Command".format(self._system_name), + topic=syscmd_trigger.topic, + type='select', + entity='{}_cmd'.format(self._system_name.lower()), + options=["-","Rediscover","Restart","Rescan"] + ) + def _ha_discovery_bay(self, bay_id): bay_obj = self._bay_registry[bay_id] topic_base = "CobraBay/" + self._client_id + "/" + bay_obj.id + "/" @@ -655,78 +689,116 @@ def _ha_discovery_bay(self, bay_id): self._ha_discover( name="{} State".format(bay_obj.name), topic=topic_base + "state", - type="sensor", - entity="{}_state".format(bay_obj.id), + type='sensor', + entity="{}_{}_state".format(self._system_name.lower(), bay_obj.id), value_template="{{ value|capitalize }}" ) + + # Bay Select, to allow setting state manually. Mostly useful for testing. + try: + baycmd_trigger = self._trigger_registry[bay_id] + except KeyError: + self._logger.error("No Command Trigger defined for bay '{}'. Cannot perform discovery for it.".format(bay_id)) + self._logger.error("Available triggers: {}".format(self._trigger_registry.keys())) + else: + self._ha_discover( + name="{} Command".format(bay_obj.name), + topic=baycmd_trigger.topic, + type='select', + entity="{}_{}_cmd".format(self._system_name.lower(), bay_obj.id), + options=["-", "Dock", "Undock", "Abort"] + ) + # Bay Vector self._ha_discover( name="{} Speed".format(bay_obj.name), topic=topic_base + "vector", - type="sensor", - entity="{}_speed".format(bay_obj.id), + type='sensor', + entity="{}_{}_speed".format(self._system_name.lower(), bay_obj.id), value_template="{{ value_json.speed }}", unit_of_measurement=self._uom('speed') + # Bay Direction ) self._ha_discover( name="{} Direction".format(bay_obj.name), topic=topic_base + "vector", - type="sensor", - entity="{}_direction".format(bay_obj.id), + type='sensor', + entity="{}_{}_direction".format(self._system_name.lower(), bay_obj.id), value_template="{{ value_json.direction|capitalize }}", ) - # # Bay Motion Timer - # + # Bay Motion Timer + self._ha_discover( + name="{} Motion Timer".format(bay_obj.name), + topic=topic_base + "motion_timer", + type="sensor", + entity="{}_{}_motiontimer".format(self._system_name.lower(), bay_obj.id), + ) + # # Bay Occupancy self._ha_discover( name="{} Occupied".format(bay_obj.name), topic=topic_base + "occupancy", type="binary_sensor", - entity="{}_occupied".format(bay_obj.id), + entity="{}_{}_occupied".format(self._system_name.lower(), bay_obj.id), payload_on="true", payload_off="false", payload_not_available="error" ) # Discover the detectors.... - print(bay_obj.detectors) + for detector in bay_obj.detectors: det_obj = bay_obj.detectors[detector] detector_base = topic_base + "detectors/" + det_obj.id + "/" - # Detector reading. + + # Current state of the detector. self._ha_discover( name="Detector - {} State".format(det_obj.name), topic=detector_base + "state", type="sensor", - entity="{}_{}_{}_state".format(self._system_name, bay_obj.id, det_obj.id), + entity="{}_{}_{}_state".format(self._system_name.lower(), bay_obj.id, det_obj.id), value_template="{{ value|capitalize }}" ) self._ha_discover( name="Detector - {} Status".format(det_obj.name), topic=detector_base + "status", type="sensor", - entity="{}_{}_{}_status".format(self._system_name, bay_obj.id, det_obj.id), + entity="{}_{}_{}_status".format(self._system_name.lower(), bay_obj.id, det_obj.id), value_template="{{ value|capitalize }}" ) + + # Is the detector in fault? self._ha_discover( name="Detector - {} Fault".format(det_obj.name), topic=detector_base + "fault", type="binary_sensor", - entity="{}_{}_{}_state".format(self._system_name, bay_obj.id, det_obj.id), + entity="{}_{}_{}_fault".format(self._system_name.lower(), bay_obj.id, det_obj.id), payload_on = "true", payload_off = "false" ) + + # Reading. Modified by offset. self._ha_discover( name="Detector - {} Reading".format(det_obj.name), topic=detector_base + "reading", type="sensor", - entity="{}_{}_{}_reading".format(self._system_name, bay_obj.id, det_obj.id), + entity="{}_{}_{}_reading".format(self._system_name.lower(), bay_obj.id, det_obj.id), ) + + # Raw reading, unmodified by offset. self._ha_discover( name="Detector - {} Raw Reading".format(det_obj.name), topic=detector_base + "raw_reading", type="sensor", - entity="{}_{}_{}_raw_reading".format(self._system_name, bay_obj.id, det_obj.id), + entity="{}_{}_{}_raw_reading".format(self._system_name.lower(), bay_obj.id, det_obj.id), + ) + + # Quality of the detector. + self._ha_discover( + name="Detector - {} Quality".format(det_obj.name), + topic=detector_base + "quality", + type="sensor", + entity="{}_{}_{}_quality".format(self._system_name.lower(), bay_obj.id, det_obj.id), ) \ No newline at end of file diff --git a/CobraBay/sensors.py b/CobraBay/sensors.py index fa37094..62babd8 100644 --- a/CobraBay/sensors.py +++ b/CobraBay/sensors.py @@ -23,11 +23,28 @@ class BaseSensor: - def __init__(self, logger, log_level='WARNING'): + def __init__(self, sensor_name, parent_logger=None, log_level="WARNING"): + """ + Base class for Sensors. + + :param sensor_name: + :param parent_logger: Parent logger to attach to. + :type parent_logger: logger + :param log_level: If no parent logger provided, log level of the new logger to create. + :type log_level: str + """ # Create a unit registry for the object. self._ureg = UnitRegistry() + # Set up the logger. + if parent_logger is None: + # If no parent detector is given this sensor is being used in a testing capacity. Create a null logger. + self._logger = logging.getLogger(self._name) + self._logger.setLevel(log_level) + else: + self._logger = parent_logger.getChild(self._name) + # Initialize variables. self._previous_timestamp = monotonic() self._previous_reading = None @@ -41,11 +58,12 @@ def range(self): @property def state(self): raise NotImplementedError("State should be overridden by specific sensor class.") - + @property def status(self): """ - Read the sensor status. This is the requested status. It may not be the state if there have been intervening errors. + Read the sensor status. This is the requested status. It may not be the state if there have been intervening + errors. :return: str """ return self._status @@ -102,7 +120,7 @@ def status(self, target_status): self._logger.debug("Successfully completed implicit enable to allow change to ranging") try: self._start_ranging() - except TypeError as e: + except TypeError: self._logger.warning("Could not start ranging. Sensor returned value that could not be interpreted.") except BaseException as e: self._logger.error("Could not start ranging.") @@ -128,15 +146,32 @@ def _start_ranging(self): def _stop_ranging(self): raise NotImplementedError("Stop Ranging method must be implemented by core sensor class.") + class I2CSensor(BaseSensor): aw9523_boards = {} - def __init__(self, i2c_bus, i2c_address, logger, log_level='WARNING'): - super().__init__(logger, log_level) - # Check for the Base I2C Sensors - # Create a logger + + def __init__(self, i2c_bus, i2c_address, parent_logger=None, log_level="WARNING"): + """ + + :param i2c_bus: I2C Bus to use + :type i2c_bus: int + :param i2c_address: Address of the sensor. + :type i2c_address: int or str(hex) + :param parent_logger: Parent logger to attach to. + :type parent_logger: logger + :param log_level: If no parent logger provided, log level of the new logger to create. + :type log_level: str + """ + # Define our own name based on class name, bus and address. self._name = "{}-{}-{}".format(type(self).__name__, i2c_bus, hex(i2c_address)) - self._logger = logging.getLogger("CobraBay").getChild("Sensor").getChild(self._name) - self._logger.setLevel(log_level) + # Do base sensor initialization + try: + + super().__init__(sensor_name=self._name, parent_logger=parent_logger, log_level=log_level) + except ValueError: + raise + + # Create a logger self._logger.info("Initializing sensor...") # Set the I2C bus and I2C Address @@ -182,20 +217,24 @@ def i2c_address(self, i2c_address): class SerialSensor(BaseSensor): - def __init__(self, port, baud, logger, log_level='WARNING'): + def __init__(self, port, baud, parent_logger=None, log_level="WARNING"): """ + :type port: str :type baud: int - :type logger: str + :type parent_logger: str + :param parent_logger: Parent logger to attach to. + :type parent_logger: logger + :param log_level: If no parent logger provided, log level of the new logger to create. + :type log_level: str """ + # Define our own name, based on type name and port. + self._name = "{}-{}".format(type(self).__name__, port) + # To base sensor initialization. try: - super().__init__(logger, log_level) + super().__init__(sensor_name=self._name, parent_logger=parent_logger, log_level=log_level) except ValueError: raise - # Create a logger - self._name = "{}-{}".format(type(self).__name__, port) - self._logger = logging.getLogger("CobraBay").getChild("Sensors").getChild(self._name) self._logger.info("Initializing sensor...") - self._logger.setLevel(log_level) self._serial_port = None self._baud_rate = None self.serial_port = port @@ -230,25 +269,27 @@ def name(self): """ Sensor name, type-port """ return self._name + class CB_VL53L1X(I2CSensor): _i2c_address: int _i2c_bus: int instances = WeakSet() - def __init__(self, i2c_bus, i2c_address, enable_board, enable_pin, timing, logger, distance_mode ="long", - log_level="WARNING"): + def __init__(self, i2c_bus, i2c_address, enable_board, enable_pin, timing, distance_mode='long', + parent_logger=None, log_level="WARNING"): """ :type i2c_bus: int :type i2c_address: hex :type enable_board: str :type enable_pin: str - :type logger: str - :type distance_mode: str + :param parent_logger: Parent logger to attach to. + :type parent_logger: logger + :param log_level: If no parent logger provided, log level of the new logger to create. :type log_level: str """ try: - super().__init__(i2c_bus=i2c_bus, i2c_address=i2c_address, logger=logger, log_level=log_level) + super().__init__(i2c_bus=i2c_bus, i2c_address=i2c_address, parent_logger=parent_logger, log_level=log_level) except ValueError: raise @@ -264,16 +305,16 @@ def __init__(self, i2c_bus, i2c_address, enable_board, enable_pin, timing, logge # Initialize variables. self._sensor_obj = None # Sensor object from base library. self._ranging = False # Ranging flag. The library doesn't actually store this! - self._fault = False # Sensor fault state. + self._fault = False # Sensor fault state. self._status = 'disabled' # Requested state of the sensor externally. - self._distance_mode = None # Distance mode. + self._distance_mode = distance_mode # Distance mode. # Save the input parameters. self.timing_budget = timing # Timing budget self.enable_board = enable_board # Board where the enable pin is. self.enable_pin = enable_pin # Pin for enabling. self._enable_attempt_counter = 1 - + # Add self to instance list. CB_VL53L1X.instances.add(self) # In principle, will use this in the future. @@ -283,21 +324,16 @@ def __init__(self, i2c_bus, i2c_address, enable_board, enable_pin, timing, logge } # Enable the sensor. - try: - self.status = 'enabled' - except CobraBay.exceptions.SensorNotEnabledException: - # What to do if not enabled. - self._logger.error("Initialization failed. Sensor faulted until error is corrected.") - else: - # Get a test reading. - self.status = 'ranging' # Start ranging. - self.measurement_time = Quantity(timing).to('microseconds').magnitude - self.distance_mode = 'long' - self._previous_reading = self._sensor_obj.distance - self._logger.debug("Test reading: {}".format(self._previous_reading)) - self._logger.debug("Setting status back to enabled, stopping ranging.") - self.status = 'enabled' - self._logger.debug("Initialization complete.") + self.status = 'enabled' + # Get a test reading. + self.status = 'ranging' # Start ranging. + self.measurement_time = Quantity(timing).to('microseconds').magnitude + self.distance_mode = 'long' + test_range = self.range + self._logger.debug("Test reading: {} ({})".format(test_range, type(test_range))) + self._logger.debug("Setting status back to enabled, stopping ranging.") + self.status = 'enabled' + self._logger.debug("Initialization complete.") def _start_ranging(self): self._logger.debug("Starting ranging") @@ -344,7 +380,11 @@ def _enable(self): try: self._sensor_obj = af_VL53L1X(self._i2c, address=0x29) except ValueError: - self._logger.error("Sensor not found at default address '0x29'. Check for configuration and hardware errors!") + self._logger.error("Sensor not found at default address '0x29'. Check configuration!") + except OSError as e: + self._logger.error("Sensor not responsive! Marking sensor as in fault. Base error was: '{} - {}'" + .format(e.__class__.__name__, str(e))) + self._fault = True else: # Change the I2C address to the target address. self._sensor_obj.set_address(new_address=self._i2c_address) @@ -355,18 +395,21 @@ def _enable(self): self._logger.error("Device did not appear on I2C bus! Check configuration and for hardware errors.") if self._enable_attempt_counter >= 3: - self._logger.error("Could not enable sensor after {} attempts. Marking as faulty.".format(self._enable_attempt_counter)) + self._logger.error("Could not enable sensor after {} attempts. Marking as faulty.". + format(self._enable_attempt_counter)) self._fault = True raise CobraBay.exceptions.SensorException else: - self._logger.warning("Could not enable sensor on attempt {}. Disabling and retrying.".format(self._enable_attempt_counter)) + self._logger.warning("Could not enable sensor on attempt {}. Disabling and retrying.". + format(self._enable_attempt_counter)) self._enable_attempt_counter += 1 self._disable() self._enable() def _disable(self): self.enable_pin.value = False - # Also set the internal ranging variable to false, since by definition, when the board gets killed, we stop ranging. + # Also set the internal ranging variable to false, since by definition, when the board gets killed, + # we stop ranging. self._ranging = False @property @@ -418,59 +461,108 @@ def distance_mode(self, dm): def range(self): self._logger.debug("Range requsted. Sensor state is: {}".format(self.state)) if self.state != 'ranging': - return CobraBay.const.STATE_NOTRANGING + return CobraBay.const.SENSTATE_NOTRANGING elif monotonic() - self._previous_timestamp < 0.2: # Make sure to pace the readings properly, so we're not over-running the native readings. # If a request comes in before the sleep time (200ms), return the previous reading. return self._previous_reading else: - try: - reading = self._sensor_obj.distance - except OSError as e: - # Try to recover from sensor fault. - self._logger.critical("Attempt to read sensor threw error: {}".format(str(e))) - self._lifetime_faults = self._lifetime_faults + 1 - self._logger.critical("Lifetime faults are now: {}".format(self._lifetime_faults)) - hex_list = [] - for address in self._i2c.scan(): - hex_list.append(hex(address)) - self._logger.debug("Current I2C bus: {}".format(hex_list)) - # Decide on the last chance. - if self._last_chance: - self._logger.critical("This was the last chance. No more!") - if self._logger.isEnabledFor(logging.DEBUG): - self._logger.debug("I2C Bus Scan:") - hex_list = [] - for address in self._i2c.scan(): - hex_list.append(hex(address)) - self._logger.debug("Current I2C bus: {}".format(hex_list)) - self._logger.debug("Object Dump:") - self._logger.debug(pformat(dir(self._sensor_obj))) - self._logger.critical("Cannot continue. Exiting.") - sys.exit(1) - else: # Still have a last chance.... - self._last_chance = True - hex_list = [] - for address in self._i2c.scan(): - hex_list.append(hex(address)) - self._logger.debug("I2C bus after fault: {}".format(hex_list)) - self._logger.debug("Resetting sensor to recover...") - # Disable the sensor. - self._disable() - # Re-enable the sensor. This will re-enable the sensor and put it back at its correct address. - self._enable() + # If the sensor doesn't have data ready yet, return the previous reading. + if not self._sensor_obj.data_ready: + return self._previous_reading + else: + reading = self._recoverable_reading() + self._sensor_obj.clear_interrupt() + + # else: + if reading is None: + # The Adafruit VL53L1X wraps up all invalid statuses with a 'None' return. See + # https://github.com/adafruit/Adafruit_CircuitPython_VL53L1X/pull/8 for details. + self._previous_reading = CobraBay.const.SENSOR_VALUE_WEAK + elif reading <= 4: + self._logger.debug("Reading is less than 4cm. Too close to be realiable.") + return CobraBay.const.SENSOR_VALUE_TOOCLOSE + else: + self._previous_reading = Quantity(reading, 'cm') + self._previous_timestamp = monotonic() + return self._previous_reading + + def _recoverable_reading(self): + ''' + Get the distance reading from the VL53L1X sensor, and attempt auto-recovery if there's an error. + + :return: float + ''' + + try: + reading = self._sensor_obj.distance + except OSError as e: + # Try to recover from sensor fault. + self._logger.critical("Attempt to read sensor threw error: {}".format(str(e))) + self._lifetime_faults = self._lifetime_faults + 1 + self._logger.critical("Lifetime faults are now: {}".format(self._lifetime_faults)) + hex_list = [] + for address in self._i2c.scan(): + hex_list.append(hex(address)) + self._logger.debug("Current I2C bus: {}".format(hex_list)) + # Decide on the last chance. + if self._last_chance: + self._logger.critical("This was the last chance. No more!") + if self._logger.isEnabledFor(logging.DEBUG): + self._logger.debug("I2C Bus Scan:") hex_list = [] for address in self._i2c.scan(): hex_list.append(hex(address)) - self._logger.debug("I2C bus after re-enabling: {}".format(hex_list)) - else: - # A "none" means the sensor had no response. - if reading is None: - self._previous_reading = CobraBay.const.SENSOR_VALUE_WEAK - else: - self._previous_reading = Quantity(reading, 'cm') - self._previous_timestamp = monotonic() - return self._previous_reading + self._logger.debug("Current I2C bus: {}".format(hex_list)) + self._logger.debug("Object Dump:") + self._logger.debug(pformat(dir(self._sensor_obj))) + self._logger.critical("Cannot continue. Exiting.") + sys.exit(1) + else: # Still have a last chance.... + self._last_chance = True + hex_list = [] + for address in self._i2c.scan(): + hex_list.append(hex(address)) + self._logger.debug("I2C bus after fault: {}".format(hex_list)) + self._logger.debug("Resetting sensor to recover...") + # Disable the sensor. + self._disable() + # Re-enable the sensor. This will re-enable the sensor and put it back at its correct address. + self._enable() + hex_list = [] + for address in self._i2c.scan(): + hex_list.append(hex(address)) + self._logger.debug("I2C bus after re-enabling: {}".format(hex_list)) + else: + return reading + + # Not actually using this method, because it doesn't account for the sensor's timing budget. We wind up hitting the + # sensor within the same timing budget window and just get the same value back five times. + # # Method to get an average and stabilize the sensor. + # def _read_average(self): + # readings = [] + # start = monotonic() + # i = 0 + # while len(readings) < 5: + # try: + # new_reading = self._sensor_obj.distance + # except OSError as e: + # self._logger.critical("Attempt to read sensor returned error: {}".format(str(e))) + # self._lifetime_faults += 1 + # self._logger.critical("Lifetime faults are now: {}".format(self._lifetime_faults)) + # hex_list = [] + # for address in self._i2c.scan(): + # hex_list.append(hex(address)) + # self._logger.critical("I2C Bus after error: {}".format(hex_list)) + # # Do last_chance logic. + # else: + # readings.append(new_reading) + # i += 1 + # self._logger.debug("Averaging readings: {}".format(readings)) + # average = sum(readings) / 5 + # self._logger.debug("Took {} cycles in {}s to get stable reading of {}.". + # format(i, round(monotonic() - start, 2), average)) + # return average # Method to find out if an address is on the I2C bus. def _addr_on_bus(self, i2c_address): @@ -526,7 +618,15 @@ def enable_pin(self, enable_pin): format(self.i2c_bus, self.enable_board)) raise e else: - CobraBay.util.aw9523_reset(self.__class__.aw9523_boards[awkey]) + try: + CobraBay.util.aw9523_reset(self.__class__.aw9523_boards[awkey]) + except OSError as e: + self._logger.error("Error while resetting pins on AW9523 board on bus '{}', address " + "'0x{:x}'. Base error was: '{} - {}'". + format(self.i2c_bus, self.enable_board, e.__class__.__name__, str(e))) + self._logger.critical("Cannot continue!") + sys.exit(1) + # Can now create the pin self._enable_pin = self.__class__.aw9523_boards[awkey].get_pin(enable_pin) @@ -538,7 +638,7 @@ def enable_pin(self, enable_pin): # self._logger.critical("Could not access AW9523 board.") # raise e # # Get the pin from the AW9523. - #self._enable_pin = aw.get_pin(enable_pin) + # self._enable_pin = aw.get_pin(enable_pin) # Make sure this is an 'output' type pin. self._enable_pin.switch_to_output() @@ -553,25 +653,39 @@ def state(self): if self._fault is True: # Fault while enabling. self._logger.debug("Fault found.") - return CobraBay.const.STATE_FAULT + return CobraBay.const.SENSTATE_FAULT elif self.enable_pin.value is True: self._logger.debug("Enable pin is on.") if self._ranging is True: self._logger.debug("Sensor has been recorded as ranging.") - return CobraBay.const.STATE_RANGING + return CobraBay.const.SENSTATE_RANGING else: self._logger.debug("Enabled, not ranging.") - return CobraBay.const.STATE_ENABLED + return CobraBay.const.SENSTATE_ENABLED elif self.enable_pin.value is False: - self._logger.debug("Enable pin is off.") - return CobraBay.const.STATE_DISABLED + self._logger.debug("Enable pin is off.") + return CobraBay.const.SENSTATE_DISABLED else: raise CobraBay.exceptions.SensorException + class TFMini(SerialSensor): - def __init__(self, port, baud, logger, log_level): + def __init__(self, port, baud, parent_logger=None, clustering=1, log_level="WARNING"): + """ + + :param port: Serial port + :type port: str OR Path + :param baud: Bitrate for the sensor. + :type baud: int + :param parent_logger: Parent logger to attach to. + :type parent_logger: logger + :param clustering: Should we cluster read, and if so how many to cluster? + :type clustering: int + :param log_level: If no parent logger provided, log level of the new logger to create. + :type log_level: str + """ try: - super().__init__(port=port, baud=baud, logger=logger, log_level=log_level) + super().__init__(port=port, baud=baud, parent_logger=parent_logger, log_level=log_level) except ValueError: raise @@ -580,10 +694,13 @@ def __init__(self, port, baud, logger, log_level): 'min_range': Quantity('0.3m') } + # Cluster reading setting. + self._clustering = clustering + # Create the sensor object. self._logger.debug("Creating TFMini object on serial port {}".format(self.serial_port)) self._sensor_obj = TFMP(self.serial_port, self.baud_rate) - self._logger.debug("Test reading: {}".format(self.range)) + self._logger.debug("Test reading: {} ({})".format(self.range, type(self.range))) # TFMini is always ranging, so enable here is just a dummy method. @staticmethod @@ -597,29 +714,38 @@ def disable(): @property def range(self): - # TFMini is always ranging, so no need to pace it. - reading = self._clustered_read() # Do a clustered read to ensure stability. - self._logger.debug("TFmini read values: {}".format(reading)) - # Check the status to see if we got a value, or some kind of non-OK state. - if reading.status == "OK": - self._previous_reading = reading.distance - self._previous_timestamp = monotonic() - return self._previous_reading - elif reading.status == "Weak": - return CobraBay.const.SENSOR_VALUE_WEAK - elif reading.status == "Flood": - raise CobraBay.const.SENSOR_VALUE_FLOOD + try: + reading = self._clustered_read(self._clustering) + except BaseException as e: + self._logger.error("Reading received exception - '{}: {}'".format(type(e).__name__, e)) + self._state = CobraBay.const.SENSTATE_DISABLED + return else: - raise CobraBay.exceptions.SensorException + self._state = CobraBay.const.SENSTATE_RANGING + self._logger.debug("TFmini read values: {}".format(reading)) + # Check the status to see if we got a value, or some kind of non-OK state. + if reading.status == "OK": + self._previous_reading = reading.distance + self._previous_timestamp = monotonic() + return self._previous_reading + elif reading.status == "Weak": + return CobraBay.const.SENSOR_VALUE_WEAK + elif reading.status in ("Flood", "Saturation"): + return CobraBay.const.SENSOR_VALUE_FLOOD + elif reading.status == 'Strong': + return CobraBay.const.SENSOR_VALUE_STRONG + else: + raise CobraBay.exceptions.SensorException("TFMini sensor '{}' had unexpected reading '{}'". + format(self._name, reading)) - # When this was tested in I2C mode, the TFMini could return unstable answers, even at rest. Unsure if - # this is still true in serial mode, keeping this clustering method for the moment. - def _clustered_read(self): + # The clustered read method requires the sensor to be returning a consistent result to return. + # Passing '1' will require two consecutive reads of the same value. + def _clustered_read(self, reading_count): stable_count = 0 i = 0 previous_read = self._sensor_obj.data() start = monotonic() - while stable_count < 5: + while stable_count < reading_count: reading = self._sensor_obj.data() if reading.distance == previous_read.distance: stable_count += 1 @@ -636,17 +762,7 @@ def state(self): State of the sensor. :return: str """ - reading = self._sensor_obj.data() - if reading.status == 'OK': - return CobraBay.const.SENSOR_VALUE_OK - elif reading.status == 'Weak': - return CobraBay.const.SENSOR_VALUE_WEAK - elif reading.status == 'Flood': - return CobraBay.const.SENSOR_VALUE_FLOOD - elif reading.status == 'Strong': - return CobraBay.const.SENSOR_VALUE_STRONG - else: - raise CobraBay.exceptions.SensorException + return self._state @property def status(self): @@ -658,7 +774,7 @@ def status(self): :return: """ # The TFMini always ranges, so we can just return ranging. - return CobraBay.const.STATE_RANGING + return CobraBay.const.SENSTATE_RANGING @status.setter def status(self, target_status): @@ -677,10 +793,23 @@ def timing_budget(self): # This is static and assuming it hasn't been changed, so return it. return Quantity('10000000 ns') + class FileSensor(BaseSensor): - def __init__(self, csv_file, sensor, rate, direction, unit, logger, log_level='WARNING'): + def __init__(self, csv_file, sensor, rate, direction, unit, parent_logger=None, log_level='WARNING'): + """ + + :param csv_file: File to read + :type csv_file: str + :param sensor: + :param rate: + :param direction: + :param unit: + :param parent_logger: Parent logger to create a child of. + :type parent_logger: logger + :param log_level: If no parent logger provided, log level of the new logger to create. + """ try: - super().__init__(logger, log_level) + super().__init__(parent_logger=parent_logger, log_level=log_level) except ValueError: raise @@ -714,8 +843,8 @@ def name(self): @property def range(self): if self._time_mark is not None: - motion_time = Quantity(monotonic_ns() - self._time_mark,'ns').to('ms') - index = floor( motion_time / self._rate ) + motion_time = Quantity(monotonic_ns() - self._time_mark, 'ns').to('ms') + index = floor(motion_time / self._rate) else: motion_time = None index = 1 @@ -746,4 +875,4 @@ def _stop_ranging(self): @property def timing_budget(self): - return self._rate \ No newline at end of file + return self._rate diff --git a/CobraBay/tfmp.py b/CobraBay/tfmp.py index 9fbfdf4..b2bae7a 100644 --- a/CobraBay/tfmp.py +++ b/CobraBay/tfmp.py @@ -1,3 +1,8 @@ +# +# TFMini Plus Python Library +# Reworked from Bud Ryerson's TFMini-Plus_python library (https://github.com/budryerson/TFMini-Plus_python/) +# + import importlib import time import pint @@ -130,8 +135,9 @@ def data(self): status = "OK" # If we're using pint Quantities, wrap as quantities. - if self._use_pint and status == "OK": - dist = pint.Quantity(dist,"cm").to(self._unit_length) + if self._use_pint: + if status == "OK": + dist = pint.Quantity(dist,"cm").to(self._unit_length) temp = pint.Quantity(temp, "celsius").to(self._unit_temp) return_data = TFMP_data(status, dist, flux, temp) return return_data @@ -178,7 +184,7 @@ def _send_cmd(self, command, parameter): # Get reply. try: - reply = self._read_frames(reply_length) + reply = self._read_frames_cmd(reply_length) except IOError: # Failed checksum raises IO error, pass it on. raise @@ -237,7 +243,7 @@ def _read_frames(self, length, timeout = 1000): # Flush all but last frame of data from the serial buffer. while self._data_stream.inWaiting() > self.TFMP_FRAME_SIZE: self._data_stream.read() - # Reads data byte by byte from the serial buffer checking for the the two header bytes. + # Reads data byte by byte from the serial buffer checking for the two header bytes. frames = bytearray(length) # 'frame' data buffer while (frames[0] != 0x59) or (frames[1] != 0x59): if self._data_stream.inWaiting(): @@ -256,9 +262,46 @@ def _read_frames(self, length, timeout = 1000): else: return frames + def _read_frames_cmd(self, length, timeout = 1000): + ''' + Method to read frames for a command response. + + :param length: + :param timeout: + :return: + ''' + serial_timeout = time.time() + timeout + # Flush all but last frame of data from the serial buffer. + while self._data_stream.inWaiting() > self.TFMP_FRAME_SIZE: + self._data_stream.read() + # Reads data byte by byte from the serial buffer checking for the two header bytes. + frames = bytearray(length) # 'frame' data buffer + # Command replies should be '0x5A ' + print("Reading stream for reply header: 0x51 {}".format(length)) + while (frames[0] != 0x5A) or (frames[1] != length): + if self._data_stream.inWaiting(): + # Read 1 byte into the 'frame' plus one position. + frames.append(self._data_stream.read()[0]) + # Shift entire length of 'frame' one byte left. + frames = frames[1:] + frame_string = ", ".join(hex(b) for b in frames) + print("Have frames: {}".format(frame_string)) + # If no HEADER or serial data not available after timeout interval. + if time.time() > serial_timeout: + print("\n") + raise serial.SerialTimeoutException("Sensor did not return header or serial data within one second.") + print("\nComplete. Have byte array:\n{}\nChecksumming data.".format(frames)) + + # If we haven't raised an exception, checksum the data. + if not self._checksum(frames): + raise IOError("Sensor checksum error") + else: + return frames + # Destructor. def __del__(self): - self._data_stream.close() + if self._data_stream is not None: + self._data_stream.close() # Utility method to calculate checksums. @staticmethod diff --git a/CobraBay/triggers.py b/CobraBay/triggers.py index a196e86..ac57cc6 100644 --- a/CobraBay/triggers.py +++ b/CobraBay/triggers.py @@ -6,15 +6,13 @@ import logging class Trigger: - def __init__(self, id, name, log_level="WARNING", **kwargs): + def __init__(self, id, log_level="WARNING", **kwargs): """ :param id: str - :param name: str :param log_level: Log level for the bay, must be a Logging level. :param kwargs: """ self.id = id - self.name = name # Create a logger. self._logger = logging.getLogger("CobraBay").getChild("Triggers").getChild(self.id) self._logger.setLevel(log_level) @@ -45,28 +43,18 @@ def id(self): def id(self, input): self._id = input.replace(" ","_").lower() - @property - def name(self): - return self._name - - @name.setter - def name(self, input): - self._name = input - # @property # def type(self): # return self._settings['type'] # Subclass for common MQTT elements class MQTTTrigger(Trigger): - def __init__(self, id, name, topic, topic_mode="full", topic_prefix = None, log_level="WARNING"): + def __init__(self, id, topic, topic_mode="full", topic_prefix = None, log_level="WARNING"): """ General class for MQTT-based triggers. :param id: ID of this trigger. Case-insensitive, no spaces. :type id: str - :param name: Name of this trigger. - :type name: str :param topic: Topic for the trigger. If topic_mode is 'full', this will be the complete topic used. :param topic_mode: Use topic as-is or construct from elements. May be 'full' or 'suffix'. :type topic_mode: str @@ -75,11 +63,15 @@ def __init__(self, id, name, topic, topic_mode="full", topic_prefix = None, log_ :param log_level: Logging level for the trigger. Defaults to 'Warning'. :type log_level: str """ - super().__init__(id, name, log_level) + super().__init__(id, log_level.upper()) self._topic = topic self._topic_mode = topic_mode self._topic_prefix = topic_prefix + try: + self._logger.info("Trigger '{}' listening to MQTT topic '{}'".format(self.id, self.topic)) + except TypeError: + self._logger.info("Trigger '{}' initialized, MQTT prefix not yet set".format(self.id)) # This will need to be attached to a subscription. def callback(self, client, userdata, message): @@ -92,6 +84,7 @@ def topic_prefix(self): @topic_prefix.setter def topic_prefix(self, prefix): self._topic_prefix = prefix + self._logger.info("Trigger '{}' prefix updated, MQTT topic is now '{}'".format(self.id, self.topic)) @property def topic_mode(self): @@ -113,8 +106,8 @@ def topic(self): # Take System commands directly from an outside agent. class SysCommand(MQTTTrigger): - def __init__(self, id, name, topic, topic_prefix=None, log_level="WARNING"): - super().__init__(id, name, topic, topic_mode='suffix') + def __init__(self, id, topic, topic_prefix=None, log_level="WARNING"): + super().__init__(id, topic, topic_mode='suffix', topic_prefix = topic_prefix, log_level = log_level) # Outbound command queues. These are separate based on their destination. ## Core queue @@ -123,17 +116,20 @@ def __init__(self, id, name, topic, topic_prefix=None, log_level="WARNING"): self._cmd_stack_network = [] def callback(self, client, userdata, message): + # Decode the JSON. - message = str(message.payload, 'utf-8').lower() + message_text = str(message.payload, 'utf-8').lower() # Commands need to get routed to the right module. # Core commands - if message.lower() in ('reboot', 'rescan'): - self._cmd_stack_core.append(message.lower()) - elif message.lower() in ('rediscover'): - self._cmd_stack_core.append(message.lower()) + if message_text in ('restart', 'rescan'): + self._logger.info("Received command {}. Adding to core command stack.".format(message_text)) + self._cmd_stack_core.append(message_text) + elif message_text in ('rediscover'): + self._logger.info("Received command {}. Not yet implemented.".format(message_text)) + # Do a call to Network HA here... else: - self._logger.warning("Ignoring invalid command: {}".format(message.text)) + self._logger.warning("Ignoring invalid command: {}".format(message_text)) # Return the first command from the stack. @property @@ -161,8 +157,8 @@ def triggered_network(self): # Take and handle bay commands. class BayCommand(MQTTTrigger): - def __init__(self, id, name, topic, bay_obj, log_level="WARNING"): - super().__init__(id, name, topic=bay_obj.id + "/" + topic, topic_mode="suffix", log_level=log_level) + def __init__(self, id, topic, bay_obj, log_level="WARNING"): + super().__init__(id, topic=bay_obj.id + "/" + topic, topic_mode="suffix", log_level=log_level) # Store the bay object reference. self._bay_obj = bay_obj @@ -181,7 +177,7 @@ def callback(self, client, userdata, message): elif message_text in ('abort'): self._cmd_stack.append(message_text) else: - self._logger.warning("Ignoring invalid command: {}".format(message.text)) + self._logger.warning("Ignoring invalid command: {}".format(message_text)) # Get the ID of the bay object to be returned. This is used by the core to find the bay object directly. @property @@ -198,7 +194,11 @@ def bay_id(self): # State-based MQTT triggers class MQTTSensor(MQTTTrigger): - def __init__(self, id, name, topic, bay_obj, change_type, trigger_value, when_triggered, topic_mode="full", + def __init__(self, id, topic, bay_obj, + to_value=None, + from_value=None, + action=None, + topic_mode="full", topic_prefix=None, log_level="WARNING"): """ Trigger which will take action based on an MQTT value change. Defining an MQTT Sensor trigger subscribes the system @@ -206,33 +206,34 @@ def __init__(self, id, name, topic, bay_obj, change_type, trigger_value, when_tr :param id: ID of this trigger. Case-insensitive, no spaces. :type id: str - :param name: Name of this trigger. - :type name: str :param topic: Topic for the trigger. If topic_mode is 'full', this will be the complete topic used. :param topic_mode: Use topic as-is or construct from elements. May be 'full' or 'suffix'. :type topic_mode: str :param topic_prefix: If using suffix topic mode, the topic prefix to use. :type topic_prefix: str :param bay_obj: The object of the bay this trigger is attached to. - :param change_type: Type of payload change to monitor for. May be 'to' or 'from'. - :type change_type: str - :param trigger_value: Value which will activate this trigger. If change_type is 'to', trigger activates when the - topic changes to this value. If change_type is 'from', trigger activates when the topic changes to any value - other than this value. Only strings are supported currently, not more complex structures (ie: JSON) - :type trigger_value: str - :param when_triggered: Action taken when trigger is activated. May be 'dock', 'undock', or 'occupancy'. The + :param action: Action taken when trigger is activated. May be 'dock', 'undock', or 'occupancy'. The 'occupancy' setting will choose 'dock' or 'undock' contextually based on the current occupancy of the bay. If unoccupied, dock, if occupied dock. You're presumably not going to park again when there's already a car there! - :type when_triggered: str + :type action: str :param log_level: Logging level for the trigger. Defaults to 'Warning'. :type log_level: str """ - super().__init__(id, name, topic, topic_mode, topic_prefix, log_level) + super().__init__(id, topic, topic_mode, topic_prefix, log_level) # Save settings - self._change_type = change_type - self._trigger_value = trigger_value - self._when_triggered = when_triggered + if to_value is not None and from_value is not None: + raise ValueError("Cannot have both a 'to' and 'from' value set.") + + # This is arguably a hack from the old method and should be replaced eventually. + if to_value is not None: + self._change_type = 'to' + self._trigger_value = to_value + elif from_value is not None: + self._change_type = 'from' + self._trigger_value = from_value + + self._action = action self._bay_obj = bay_obj # Initialize a previous value variable. @@ -260,7 +261,7 @@ def callback(self, client, userdata, message): def _trigger_action(self): # If action is supposed to be occupancy determined, check the bay. - if self._when_triggered == 'occupancy': + if self._action == 'occupancy': if self._bay_obj.occupied == 'Occupied': # If bay is occupied, vehicle must be leaving. self._cmd_stack.append('undock') @@ -269,7 +270,7 @@ def _trigger_action(self): self._cmd_stack.append('dock') else: # otherwise drop the action through. - self._cmd_stack.append(self._when_triggered) + self._cmd_stack.append(self._action) @property def bay_id(self): diff --git a/CobraBay/util.py b/CobraBay/util.py index 7ccdcb2..7c3e88c 100644 --- a/CobraBay/util.py +++ b/CobraBay/util.py @@ -122,6 +122,7 @@ def aw9523_reset(aw9523_obj): pin_obj.switch_to_output() pin_obj.value = False + def scan_i2c(): """ Scan the I2C Bus. diff --git a/CobraBay/version.py b/CobraBay/version.py index 8bcf688..eb507a6 100644 --- a/CobraBay/version.py +++ b/CobraBay/version.py @@ -1 +1 @@ -__version__ = "0.1.0-alpha" \ No newline at end of file +__version__ = "0.2.0-alpha" \ No newline at end of file diff --git a/README.md b/README.md index 464e734..8986c3b 100644 --- a/README.md +++ b/README.md @@ -10,53 +10,44 @@ memory-management issues, it has been converted to a standard Python application Raspberry Pi OS Lite 64-bit. Any other Pi with Raspberry Pi OS should work. ### System Configuration -* Install OS +* Install OS - I use RaspberryPiOS 64 Lite * Configure network (Wifi or Ethernet, as appropriate) * Enable I2C - ### Required Libraries -* [paho-mqtt](https://github.com/eclipse/paho.mqtt.python) -* Packages not available through PIP: - * [rpi-rgb-led-matrix](https://github.com/hzeller/rpi-rgb-led-matrix) - Build per instructions. - * [TFMini-I2C-Python](https://github.com/madewhatnow/TFmini-I2C-Python) - Copy TFmini_I2C.py into the cobrabay/lib directory. +* Install a few extra packages (if you used Lite) +* ```sudo apt install gcc python3-dev git``` +* Install requirements. +* ```pip3 install -r requirements.txt``` +* Install the RGB Matrix library using the Adafruit scripts + * ```curl https://github.com/raw/adafruit/Raspberry-Pi-Installer-Scripts/main/rgb-matrix.sh >rgb-matrix.sh sudo bash rgb-matrix.sh``` + * Select "Y" to Continue + * Select "2", Matrix HAT + RTC + * Select "1" for Quality +* Update system configuration + * Add 'isolcpus=3' to the end of /boot/cmdline.txt + * Blacklist the sound module. The Adafruit installation script currently doesn't do this correctly for the latest RPiOS version ([#253](https://github.com/adafruit/Raspberry-Pi-Installer-Scripts/issues/253)) + ```sudo echo -n "blacklist snd_bcm2835" > /etc/modprobe.d/alsa-blacklist.conf``` +* Enable serial port for TFMini support + * ```raspi-config``` + * 3 Interfaces + * I6 Serial Port + * Login shell over serial -> NO + * Serial port hardware enabled -> YES + * reboot (should prompt when done) ### CobraBay + * Copy 'cobrabay' to _device_/lib/cobrabay * Copy 'code.py' to _device_/code.py -Install the following libraries: - * adafruit_aw9523 - * adafruit_bitmap_font - * adafruit_display_shapes - * adafruit_display_text - * adafruit_esp32spi - * adafruit_hcsr04 - * adafruit_register - * adafruit_vl53l1x - * paho-mqtt - -To install modules: -``` -pip3 install adafruit-circuitpython-aw9523 adafruit_circuitpython_bitmap_font \ - adafruit_circuitpython_display_shapes adafruit_circuitpython_display_text \ - adafruit_circuitpython_hcsr04 adafruit_circuitpython_vl53l1x \ - paho-mqtt -``` - -Optionally, if you want to send to remote syslog: -* [syslog_handler](https://github.com/chrisgilldc/circuitpython_syslog_handler) - -### Fonts -Place the fonts directory from the repo into _device_/fonts - ### Hardware System has been built and tested with the following hardware: -* Metro M4 Airlift +* Raspberry Pi 4 * 64x32 RGB LED Matrix * AW9523 GPIO Expander -* US-100 ultrasonic rangefinder +* TFMini * VL53L1X IR rangefinder It *may* work on other hardware configurations that are equivilent, but I haven't tested them and make no guarantees. @@ -121,6 +112,7 @@ Logging options, system-wide or for specific modules. | | No | None | Log level for a specific detector. | | display | No | None | Log level for the Display module. | | network | No | None | Log level for the Network module. | +| mqtt | No | DISABLE | Log level for MQTT client. This is disabled by default and will be **very** chatty if enabled. | #### Triggers Triggers are used to set when and how the system should take change mode. The triggers section can define a series of @@ -154,11 +146,11 @@ display has been tested. ##### Display Subsections ###### Matrix -| Options | Required? | Valid Options | Default | Description | -| --- | --- | --- | --- |---------------------------------| -| width | Yes | int | None | Width of the matrix, in pixels | -| height | Yes | int | None | Height of the matrix, in pixels | -| gpio_slowdown | Yes | int | None | GPIO Slowdown setting to prevent flicker. Check [rpi-rgb-led-matrix](https://github.com/hzeller/rpi-rgb-led-matrix) docs for recommendations. Likely requires testing. +| Options | Required? | Valid Options | Default | Description | +|---------------|-----------|---------------|---------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| width | Yes | int | None | Width of the matrix, in pixels | +| height | Yes | int | None | Height of the matrix, in pixels | +| gpio_slowdown | Yes | int | None | GPIO Slowdown setting to prevent flicker. Check [rpi-rgb-led-matrix](https://github.com/hzeller/rpi-rgb-led-matrix) docs for recommendations. Likely requires testing. | #### Detectors Detectors define sensing devices used to measure vehicle position. This is currently is a 1:1 mapping, where each @@ -213,16 +205,28 @@ future multiple bays may be possible. | lateral | Yes | dict | None | Lateral detectors for this bay. | ##### Longitudinal and Lateral assignments -Both the longitudinal and lateral dictionaries have a 'defaults' and 'detectors' key. A default will apply to all -detectors in that direction, unless a detector has a specific value assigned. +Assign detectors to either longitudinal or lateral roles and specify their configuration around the bay. + +Within each role, settings are prioritized like so: + +1. Settings from the detector-specific configuration +2. Settings from the role's configured defaults. +3. Settings from the system defaults, if available. + + + +| Options | Required? | Defaultable? | Valid Options | Default | Lat | Long | Description | +|-------------|-----------|--------------|-------------------|---------|-----|------|---------------------------------------------------------------------------------------------| +| offset | No | Yes | distance quantity | 0" | Yes | Yes | Where the zero-point for this detector should be. | +| pct_warn | No | Yes | number | 70 | No | Yes | Switch to 'warn' once this percentage of the bay distance is covered | +| pct_crit | No | Yes | number | 90 | No | Yes | Switch to 'crit' once this percentage of the bay distance is covered | +| spread_park | No | Yes | distance quantity | 2" | No | Yes | Maximum deviation from the stop point that can still be considered "OK" | +| spread_ok | No | Yes | distance quantity | 1" | Yes | No | Maximum deviation from the offset point that can still be considered "OK" | +| spread_warn | No | Yes | distance_quantity | 3" | Yes | No | Maximum deviation from the offset point that be considered a "WARN" | +| limit | No | Yes | distance_quantity | 96" | Yes | No | Reading limit of the lateral sensor. Any reading beyond this will be treated as "no_object" | +| side | Yes | Yes | L, R | None | Yes | No | Which side of the bay, looking out the garage door, the detector is mounted on. | +| intercept | Yes | No | distance_quantity | None | Yes | No | Absolute distance from the longitudinal detector where this detector crosses the bay. | -| Options | Required? | Defaultable? | Valid Options | Default | Lat | Long | Description | -| --- | --- | --- | --- | --- | --- | --- | --- | -| offset | Yes | Yes | distance quantity | None | Yes | Yes | Lateral: Distance the side of the vehicle should be from the sensor aperture when correctly parked. | -| spread_park | Yes | Yes | distance quantity | None | No | Yes | Maximum deviation from the stop point that can still be considered "OK" | -| spread_ok | Yes | Yes | distance quantity | None | Yes | No | Maximum deviation from the offset point that can still be considered "OK" | -| spread_warn | Yes | Yes | distance_quantity | None | Yes | No | Maximum deviation from the offset point that be considered a "WARN" | -| side | Yes | Yes | L, R | None | Yes | No | Which side of the bay, looking out the garage door, the detector is mounted on. | # Rewrite needed below here! @@ -320,15 +324,16 @@ A progression of sensors would look like this:* # Future Enhancements & Bug Fixes ## Enhancements: -* Better separate undock and dock modes. Currently undock uses too much of the dock behavior. +* Better separate undock and dock modes. Currently, undock uses too much of the dock behavior. * Range-based trigger. Start process based on range changes -* Replace strober with progress bar +* Replace strober with progress bar - **In progress** * Ability to save current system settings to config file * Ability to soft-reload system from config file * Ability to save current vehicle position as offsets -* Even better sensor handling. Reset sensors if they go offline. +* Even better sensor handling. Reset sensors if they go offline. - **In progress** -## Known Bugs: +## Known Issues: * Detector offsets sometimes don't apply. +* If MQTT broker is inaccessible during startup, an MQTT trigger will cause system to go into a loop.