text: import os
  import sys
  import psutil
  import requests
  import json
  import time
  import traceback
  import uuid
  
  from datetime import datetime
  from datetime import timedelta
  
  from tinydb import TinyDB, Query
  
  from flask import current_app
  
  from app.core.serial import SerialConnection
  from app.core.sentinel import AlertFactory, SentinelRepository
  from app.core.controller import reset_connection, run_command, open_ports, close_ports, read_sensors
  
  class IrrigationRepository:
      def __init__(self):        
          self.settings_db = TinyDB('app/irrigation/settings.json')
          self.runs_db = TinyDB('app/irrigation/runs.json')
          self.readings_db = TinyDB('app/irrigation/readings.json')
          self.state_db = TinyDB('app/irrigation/state.json')
  
      def get_settings_version(self):
          return self.settings_db.table('version').all()[0]['version'] if len(self.settings_db.table('version').all()) > 0 else None
  
      def clear_settings(self):
          self.settings_db.table('version').truncate()
          self.settings_db.table('controllers').truncate()
          self.settings_db.table('zones').truncate()
          self.settings_db.table('waterSources').truncate()
          self.settings_db.table('sensors').truncate()
  
      def save_controller(self, controller):
          self.settings_db.table('controllers').insert(controller)
  
      def save_water_source(self, water_source):
          self.settings_db.table('waterSources').insert(water_source)
  
      def save_zone(self, zone):
          self.settings_db.table('zones').insert(zone)
  
      def save_sensor(self, sensor):
          self.settings_db.table('sensors').insert(sensor)
  
      def save_version(self, version):
          self.settings_db.table('version').insert(version)
  
      def get_controller(self):
          return self.settings_db.table('controllers').get(doc_id=1)
  
      def get_zones(self):
          return self.settings_db.table('zones').all()
  
      def get_water_sources(self):
          return self.settings_db.table('waterSources').all()
  
      def get_water_source(self, water_source_id):
          water_source = Query()        
          water_sources = self.settings_db.table('waterSources').search(water_source.id == water_source_id)
          return None if len(water_sources) == 0 else water_sources[-1]
  
      def get_sensors(self):
          return self.settings_db.table('sensors').all()
  
      def get_runs(self):
          return self.runs_db.all()
  
      def save_run(self, run):
          return self.runs_db.insert(run)
  
      def get_last_run(self, zoneId):
          run = Query()        
          runs = self.runs_db.search(run.zoneId == zoneId)
          return None if len(runs) == 0 else runs[-1]
  
      def get_sensor_readings(self, sensor_ids):
          reading = Query()        
          readings = self.readings_db.search(reading.id in sensor_ids)
          return readings
  
      def save_sensor_reading(self, reading):
          r = Query()
  
          time = str(datetime.now().replace(second=0, microsecond=0)) 
  
          item = { 
              'id': reading['sensorId'], 
              'value': reading['reading'], 
              'time': time
          }
  
          self.readings_db.upsert(item, r['sensorId'] == reading['sensorId'] and r['time'] == time);
  
      def get_state(self):
          return self.state_db.get(doc_id=len(self.state_db))
  
      def update_state(self, state):
          if len(self.state_db) == 0:
              self.state_db.insert({ 'zones': {}, 'waterSources': {}, 'sensors': {} })
          
          current_state = self.get_state()
              
          if ('zones' in state):
              for zone in state['zones']:
                  current_state['zones'][zone] = state['zones'][zone]
               
          if ('waterSources' in state):
              for water_source in state['waterSources']:
                  current_state['waterSources'][water_source] = state['waterSources'][water_source]
  
          if ('sensors' in state):
              for sensor in state['sensors']:
                  current_state['sensors'][sensor] = state['sensors'][sensor]
  
          self.state_db.truncate()
          self.state_db.insert(current_state)
  
  class IrrigationControllerConnectionProvider:
      def __init__(self):        
          self.connection = None
  
      def open(self, logger):
          if self.connection is not None:
              return self.connection
  
          repository = IrrigationRepository()
  
          controller = repository.get_controller()
          
          if controller is None:
              logger.warning("No irrigation controller is defined")
              return None
  
          logger.log("Connecting to irrigation controller ...")
  
          try:
              self.connection = SerialConnection(controller['vendorId'], controller['productId'], logger)
              return self.connection
          except:
              logger.error("An unexpected error occurred, please see logs for further details:")
              logger.error(traceback.format_exc())
              return None        
  
  class SensorReader:
      def __init__(self):  
          self.connection_provider = current_app.extensions['IRRIGATION_CONNECTION']
          self.repository = IrrigationRepository()
          self.alert_factory = AlertFactory()
          self.sentinel_repository = SentinelRepository()
  
      def read_sensors(self, logger):
          connection = self.connection_provider.open(logger)
  
          if connection is None:
              raise Exception('Could not connection to irrigation controller')
  
          readings = []
  
          sensors = self.repository.get_sensors()   
          read_instructions = self.get_read_instructions(sensors)
          current_readings = self.process_sensor_readings(sensors, read_sensors(connection, read_instructions, logger))        
          
          for i in range(len(sensors)):            
              current_status = self.get_sensor_status(sensors[i], current_readings[i])
  
              reading = { 
                  'sensorId': sensors[i]['id'],
                  'sensorName': sensors[i]['name'],
                  'reading': current_readings[i],
                  'status': current_status
              }
  
              readings.append(reading)
  
              self.repository.update_state({'sensors': { sensors[i]['id']: { 'reading': current_readings[i], 'currentStatus': current_status } } })
              self.repository.save_sensor_reading(reading)
  
              if self.should_raise_alert(sensors[i], current_status):
                  self.raise_alert(sensors[i], current_status, current_readings[i], logger)
          
          return readings
  
      def get_read_instructions(self, sensors):
          read_instructions = [];
  
          for i in range(len(sensors)):
              if sensors[i]['readMode'] == 'analog':
                  read_instructions.append('A:' + str(sensors[i]['port']))
              elif sensors[i]['readMode'] == 'ultrasonic':
                  read_instructions.append('US:' + str(sensors[i]['port']) + ',' + str(sensors[i]['secondaryPort']))
              else:
                  read_instructions.append('D:' + str(sensors[i]['port']));
  
          return read_instructions;
  
      def process_sensor_readings(self, sensors, sensor_readings):
          result = []
  
          for i in range(len(sensors)):
              if sensors[i]['type'] == 'soilMoisture':
                  water = 300.0
                  air = 820.0
                                                  
                  moisture_percentage = ((air - sensor_readings[i]) / (air - water)) * 100
  
                  if (moisture_percentage > 100):
                      moisture_percentage = 100
  
                  if (moisture_percentage < 0):
                      moisture_percentage = 0
  
                  result.append(moisture_percentage)
              elif sensors[i]['type'] == 'waterStand' and sensors[i]['readMode'] == 'ultrasonic':
                  water_source_depth = self.repository.get_water_source(sensors[i]['waterSourceId'])['depth']
                  waterstand_percentage = 100 - (float(sensor_readings[i]) / water_source_depth) * 100
  
                  if (waterstand_percentage > 100):
                      waterstand_percentage = 100
  
                  if (waterstand_percentage < 0):
                      waterstand_percentage = 0
  
                  result.append(waterstand_percentage)
              else:
                  raise Exception("Sensor type '" + sensors[i]['type'] + "' and read mode '" + sensors[i]['readMode'] + "' is not supported");
  
          return result
  
      def get_sensor_status(self, sensor, reading):
          status = 'ok';
  
          if sensor['targetUpperBound'] is not None and reading > sensor['targetUpperBound']:
              status = 'overUpperTargetBound'
  
          if sensor['alertUpperBound'] is not None and reading > sensor['alertUpperBound']:
              status = 'overUpperAlertBound'
  
          if sensor['targetLowerBound'] is not None and reading < sensor['targetLowerBound']:
              status = 'belowLowerTargetBound'
  
          if sensor['alertLowerBound'] is not None and reading < sensor['alertLowerBound']:
              status = 'belowLowerAlertBound'
  
          return status
  
      def should_raise_alert(self, sensor, current_status):
          should_raise_alert = False
  
          state = self.repository.get_state()
  
          if state is None or sensor['id'] not in state['sensors']:
              should_raise_alert = True
          else:
              last_reading = state['sensors'][sensor['id']]['reading']
              should_raise_alert = current_status == 'belowLowerAlertBound' or current_status == 'overUpperAlertBound' and current_status != last_reading['status']
  
          return should_raise_alert
  
      def raise_alert(self, sensor, status, reading, logger):
          severity = 0
          
          if 'Target' in status:
              severity = 1
          elif 'Alert' in status:
              severity = 2
          
          alert = self.alert_factory.sensor_status_changed(sensor, status, reading, severity)
          
          logger.log('Sensor ' + sensor['name'] + ' breached its limit')
          logger.log_h2('Alert raised', True, True)
  
          self.sentinel_repository.insert_alert(alert)
  
  class IrrigationCommands:
      def __init__(self):  
          self.connection_provider = current_app.extensions['IRRIGATION_CONNECTION']
          self.repository = IrrigationRepository()
          self.sensor_reader = SensorReader()
          self.alert_factory = AlertFactory()
          self.sentinel_repository = SentinelRepository()
  
      def reset_irrigation_controller(self, arguments, app, logger):
          connection = self.connection_provider.open(logger)
  
          if connection is None:
              raise Exception('Could not connect to irrigation controller')
  
          reset_connection(connection, logger)
  
      def get_irrigation_runs(self, arguments, app, logger):
          return self.repository.get_runs()
  
      def get_irrigation_sensor_readings(self, arguments, app, logger):
          return self.repository.get_sensor_readings(arguments)    
          
      def get_irrigation_health_report(self, arguments, app, logger):
          return self.repository.get_state()
      
      def set_irrigation_settings(self, arguments, app, logger):
          settings = arguments[0]
  
          if settings is None:
              return
          
          current_version = self.repository.get_settings_version()
      
          if (current_version == settings['version']):
              return 
      
          logger.log('Current irrigation settings version is behind latest', True, True)
  
          self.repository.clear_settings()
      
          if settings['version'] is None or len(settings['version']) == 0:
              logger.log('Irrigation controller removed')
              return
      
          controller = {
              'id': settings['controllerId'],
              'name': settings['controllerName'],
              'vendorId': settings['vendorId'],
              'productId': settings['productId']
          } 
          
          self.repository.save_controller(controller)    
      
          logger.log_variable('Controller', 'UPDATED')
              
          for zone in settings['zones'] or []:
              self.repository.save_zone(zone)
          
          logger.log_variable('Zones', 'UPDATED')
             
          for water_source in settings['waterSources'] or []:
              self.repository.save_water_source(water_source)
      
          logger.log_variable('Water sources', 'UPDATED')
             
          for sensor in settings['sensors'] or []:
              self.repository.save_sensor(sensor)
          
          logger.log_variable('Sensors', 'UPDATED')
          
          logger.log('Irrigation settings updated to version ' + settings['version'], True, True)
  
      def run_irrigation_command(self, arguments, app, logger): 
          connection = self.connection_provider.open(logger)
  
          if connection is None:
              raise Exception('Could not connect to irrigation controller')
  
          result = run_command(connection, arguments[0], logger)
          
          return result
  
      def read_irrigation_sensors(self, arguments, app, logger):        
          return self.sensor_reader.read_sensors(logger)
  
      def run_irrigation_programme(self, arguments, app, logger):
          success = True
          
          try:        
              logger.log_h1('Starting irrigation programme', True, True)
              
              connection = self.connection_provider.open(logger)
  
              if connection is None:
                  raise Exception('Could not connect to irrigation controller') 
                  
              sensor_readings = self.sensor_reader.read_sensors(logger)
              
              logger.log_h2('Sensor readings saved', True)
  
              for zone in self.repository.get_zones():
                  try:
                      programmeConfiguration = json.loads(zone['programme'])
  
                      programme = IrrigationProgramme(
                          int(programmeConfiguration['intervalMinutes']), 
                          int(programmeConfiguration['durationSeconds']), 
                          int(programmeConfiguration['start']) if 'start' in programmeConfiguration else (6 * 60), 
                          int(programmeConfiguration['end']) if 'end' in programmeConfiguration else (18 * 60))
  
                      programme.run(zone, connection, sensor_readings, self.repository, logger)
                  except Exception as e:
                      success = False
                      logger.log('An unexpected error occurred, please see logs for further details:')
                      logger.log(traceback.format_exc())
          except Exception as e:
              success = False
              logger.error('An unexpected error occurred, please see logs for further details:')
              logger.error(traceback.format_exc())
              
              alert = self.alert_factory.irrigation_run_failed(traceback.format_exc())
              self.sentinel_repository.insert_alert(alert)
              
              logger.error('An alert has been raised')
  
          logger.log_h1('Finished irrigation programme', True, True)
  
          if not success:
              raise Exception('An unexpected error occurred, please see logs for further details')
  
  class IrrigationProgramme:    
      def __init__(self, interval, duration, start, end):        
          self.name = 'Test programme'
          self.type = 'Interval based'
          self.interval = interval
          self.duration = duration
          self.start = start
          self.end = end
          
      def run(self, zone, serial_connection, sensor_readings, repository, logger):
          ports = []
          water_source = None
          
          try:   
              minutes_elapsed_since_midnight = self.get_minutes_since_midnight()            
              minutes_elapsed_since_last_run = self.get_minutes_since_last_run(zone, repository)
              minutes_in_a_day = 24 * 60
  
              if minutes_elapsed_since_midnight < self.start or minutes_elapsed_since_midnight > self.end or minutes_elapsed_since_last_run < self.interval:
                  minutes_to_next_start = minutes_in_a_day - minutes_elapsed_since_midnight + self.start
                  minutes_to_next_start = minutes_to_next_start - minutes_in_a_day if minutes_to_next_start > minutes_in_a_day else minutes_to_next_start
                  minutes_to_next_irrigation = max(minutes_to_next_start, (self.interval - minutes_elapsed_since_last_run))
                  logger.log_h2(zone['name'] + ' skipped: Next irrigation is due in ' + str(timedelta(minutes=minutes_to_next_irrigation)))
  
                  return
                                           
              logger.log_h2('Starting zone: ' + zone['name'])
              logger.log_h3_object('Programme', self.to_key_value_pair(), True, True)
              logger.log_h3_object('Zone', zone, True, True)            
              logger.log_h3_list('Sensors', sensor_readings, 'sensorName', 'reading', True, True)
              logger.log_h3('Irrigation run', True, True)
                          
              water_source = self.get_water_source(zone, repository, logger)
  
              if zone['port']: ports.append(zone['port'])
              if water_source['port']: ports.append(water_source['port'])
  
              self.update_state(zone, water_source, True, repository)
  
              span = self.run_irrigation(serial_connection, ports, logger)
  
              self.update_state(zone, water_source, False, repository)
  
              report = self.get_irrigation_report(zone, water_source, True, span['start'], span['end'], sensor_readings, logger)
  
              repository.save_run(report)
  
              logger.log_h3_object('Irrigation report', report, True, True)          
              logger.log_h2('Finished zone: ' + zone['name'], True)
  
          except Exception as e:
              if len(ports) > 0:
                  try:
                      close_ports(serial_connection, ports, logger)
                  except Exception as ce:
                      logger.error(traceback.format_exc())
  
              report = self.get_irrigation_report(zone, water_source, False, datetime.now(), datetime.now(), sensor_readings, logger, e)
              repository.save_run(report)
  
              self.update_state(zone, water_source, False, repository)
              
              raise e
  
      def get_minutes_since_midnight(self):
          now = datetime.now()
          midnight = now.replace(hour=0, minute=0, second=0, microsecond=0)
          return (now - midnight).total_seconds() / 60.0
                      
      def get_minutes_since_last_run(self, zone, repository):  
          last_run = repository.get_last_run(zone['id'])
          
          previous_run_time = datetime.now() + timedelta(days=-1)
              
          if last_run is not None:
              previous_run_time = datetime.strptime(last_run['end'], '%Y-%m-%d %H:%M:%S')
              
          elapsed = (datetime.now() - previous_run_time).total_seconds() / 60.0
          
          return elapsed
              
      def get_water_source(self, zone, repository, logger):  
          water_sources = repository.get_water_sources()
  
          if water_sources is None or len(water_sources) == 0:
              raise Exception("No water sources defined");
  
          result = next(water_source for water_source in water_sources if water_source['id'] == zone['waterSourceId'])
  
          return result
  
      def run_irrigation(self, serial_connection, ports, logger):
          start = datetime.now()
  
          open_ports(serial_connection, ports, logger)
                      
          logger.log_new_line()
              
          while datetime.now() < start + timedelta(seconds=self.duration):
              time.sleep(1)
              elapsed_seconds = (start + timedelta(seconds=self.duration) - datetime.now()).total_seconds()
              percentage = round((1 - (elapsed_seconds / self.duration)) * 100, 0)
                  
              if percentage > 100:
                  percentage = 100
                  
              logger.log_progress(percentage)
                              
          close_ports(serial_connection, ports, logger)
  
          end = datetime.now()
  
          return {
              'start': start,
              'end': end
          }
  
      def update_state(self, zone, water_source, is_irrigating, repository):  
          state = { 
              'zones': { str(zone['id']): { 'irrigating': is_irrigating, 'lastRun': str(datetime.now()) } }            
          }
  
          if water_source is not None:
              state['waterSources'] = { str(water_source['id']): { 'irrigating': is_irrigating } }
  
          repository.update_state(state)
          
      def get_irrigation_report(self, zone, water_source, success, start, end, sensor_readings, logger, error=None):
          report = {
              'nodeId': current_app.config['NODE_ID'],
              'zoneId': zone['id'],
              'waterSourceId': None,
              'status': 'ok' if success else 'error',
              'start': start.strftime("%Y-%m-%d %H:%M:%S"),
              'end': end.strftime("%Y-%m-%d %H:%M:%S"),
              'next': (datetime.now() + timedelta(minutes=self.interval)).strftime("%Y-%m-%d %H:%M:%S"),
              'estimatedWaterConsumption': None,
              'sensorReadings': sensor_readings,
              'error': None if error is None else (str(error) + ' Details: ' + str(traceback.format_exc()))
          }
  
          if (water_source and water_source['flowRate']):
              report['waterSourceId'] = water_source['id']
              report['estimatedWaterConsumption'] = (datetime.now() - start).total_seconds() / 60 * water_source['flowRate']
          
          return report
          
      def to_key_value_pair(self):
          return {
              'Name': self.name,  
              'Type': self.type,
              'Interval': str(self.interval) + ' minute(s)',
              'Duration': str(self.duration) + ' seconds',
          }