def output_on_off(form_output): action = '{action} {controller}'.format( action=gettext("Actuate"), controller=TRANSLATIONS['output']['title']) error = [] try: control = DaemonControl() output = Output.query.filter_by( unique_id=form_output.output_id.data).first() if output.output_type == 'wired' and int( form_output.output_pin.data) == 0: error.append(gettext("Cannot modulate output with a GPIO of 0")) elif form_output.on_submit.data: if output.output_type in ['wired', 'wireless_rpi_rf', 'command']: if float(form_output.sec_on.data) <= 0: error.append(gettext("Value must be greater than 0")) else: return_value = control.output_on( form_output.output_id.data, amount=float(form_output.sec_on.data)) flash( gettext( "Output turned on for %(sec)s seconds: %(rvalue)s", sec=form_output.sec_on.data, rvalue=return_value), "success") if output.output_type == 'pwm': if int(form_output.output_pin.data) == 0: error.append(gettext("Invalid pin")) if output.pwm_hertz <= 0: error.append(gettext("PWM Hertz must be a positive value")) if float(form_output.pwm_duty_cycle_on.data) <= 0: error.append( gettext("PWM duty cycle must be a positive value")) if not error: return_value = control.output_on( form_output.output_id.data, duty_cycle=float(form_output.pwm_duty_cycle_on.data)) flash( gettext( "PWM set to %(dc)s %% at %(hertz)s Hz: %(rvalue)s", dc=float(form_output.pwm_duty_cycle_on.data), hertz=output.pwm_hertz, rvalue=return_value), "success") elif form_output.turn_on.data: return_value = control.output_on(form_output.output_id.data, 0) flash(gettext("Output turned on: %(rvalue)s", rvalue=return_value), "success") elif form_output.turn_off.data: return_value = control.output_off(form_output.output_id.data) flash( gettext("Output turned off: %(rvalue)s", rvalue=return_value), "success") except ValueError as except_msg: error.append('{err}: {msg}'.format(err=gettext("Invalid value"), msg=except_msg)) except Exception as except_msg: error.append(except_msg) flash_success_errors(error, action, url_for('routes_page.page_output'))
def output_on_off(form_output): action = '{action} {controller}'.format( action=gettext("Actuate"), controller=TRANSLATIONS['output']['title']) error = [] try: control = DaemonControl() output = Output.query.filter_by(unique_id=form_output.output_id.data).first() if output.output_type == 'wired' and int(form_output.output_pin.data) == 0: error.append(gettext("Cannot modulate output with a GPIO of 0")) elif form_output.on_submit.data: if output.output_type in ['wired', 'wireless_rpi_rf', 'command']: if float(form_output.sec_on.data) <= 0: error.append(gettext("Value must be greater than 0")) else: return_value = control.output_on(form_output.output_id.data, duration=float(form_output.sec_on.data)) flash(gettext("Output turned on for %(sec)s seconds: %(rvalue)s", sec=form_output.sec_on.data, rvalue=return_value), "success") if output.output_type == 'pwm': if int(form_output.output_pin.data) == 0: error.append(gettext("Invalid pin")) if output.pwm_hertz <= 0: error.append(gettext("PWM Hertz must be a positive value")) if float(form_output.pwm_duty_cycle_on.data) <= 0: error.append(gettext("PWM duty cycle must be a positive value")) if not error: return_value = control.output_on( form_output.output_id.data, duty_cycle=float(form_output.pwm_duty_cycle_on.data)) flash(gettext("PWM set to %(dc)s %% at %(hertz)s Hz: %(rvalue)s", dc=float(form_output.pwm_duty_cycle_on.data), hertz=output.pwm_hertz, rvalue=return_value), "success") elif form_output.turn_on.data: return_value = control.output_on(form_output.output_id.data, 0) flash(gettext("Output turned on: %(rvalue)s", rvalue=return_value), "success") elif form_output.turn_off.data: return_value = control.output_off(form_output.output_id.data) flash(gettext("Output turned off: %(rvalue)s", rvalue=return_value), "success") except ValueError as except_msg: error.append('{err}: {msg}'.format( err=gettext("Invalid value"), msg=except_msg)) except Exception as except_msg: error.append(except_msg) flash_success_errors(error, action, url_for('routes_page.page_output'))
def output_mod(output_id, state, out_type, amount): """ Manipulate output (using non-unique ID) """ if not utils_general.user_has_permission('edit_controllers'): return 'Insufficient user permissions to manipulate outputs' daemon = DaemonControl() if (state in ['on', 'off'] and out_type == 'sec' and (str_is_float(amount) and float(amount) >= 0)): return daemon.output_on_off(output_id, state, float(amount)) elif (state == 'on' and out_type in ['pwm', 'command_pwm'] and (str_is_float(amount) and float(amount) >= 0)): return daemon.output_on(output_id, duty_cycle=float(amount))
def output_mod(output_id, state, out_type, amount): """ Manipulate output (using non-unique ID) """ if not utils_general.user_has_permission('edit_controllers'): return 'Insufficient user permissions to manipulate outputs' daemon = DaemonControl() if (state in ['on', 'off'] and out_type == 'sec' and (str_is_float(amount) and float(amount) >= 0)): return daemon.output_on_off(output_id, state, float(amount)) elif (state == 'on' and out_type in ['pwm', 'command_pwm'] and (str_is_float(amount) and float(amount) >= 0)): return daemon.output_on(output_id, state, duty_cycle=float(amount))
def output_mod(output_id, state, out_type, amount): """ Manipulate output (using non-unique ID) """ if not utils_general.user_has_permission('edit_controllers'): return 'Insufficient user permissions to manipulate outputs' daemon = DaemonControl() if (state in ['on', 'off'] and out_type == 'sec' and (str_is_float(amount) and float(amount) >= 0)): out_status = daemon.output_on_off(output_id, state, float(amount)) if out_status[0]: return 'ERROR: {}'.format(out_status[1]) else: return 'SUCCESS: {}'.format(out_status[1]) elif (state == 'on' and out_type in OUTPUTS_PWM and (str_is_float(amount) and float(amount) >= 0)): out_status = daemon.output_on(output_id, duty_cycle=float(amount)) if out_status[0]: return 'ERROR: {}'.format(out_status[1]) else: return 'SUCCESS: {}'.format(out_status[1])
def camera_record(record_type, unique_id, duration_sec=None, tmp_filename=None): """ Record still image from cameras :param record_type: :param unique_id: :param duration_sec: :param tmp_filename: :return: """ daemon_control = None settings = db_retrieve_table_daemon(Camera, unique_id=unique_id) timestamp = datetime.datetime.now().strftime('%Y-%m-%d_%H-%M-%S') assure_path_exists(PATH_CAMERAS) camera_path = assure_path_exists( os.path.join(PATH_CAMERAS, '{uid}'.format(uid=settings.unique_id))) if record_type == 'photo': if settings.path_still: save_path = settings.path_still else: save_path = assure_path_exists(os.path.join(camera_path, 'still')) filename = 'Still-{cam_id}-{cam}-{ts}.jpg'.format( cam_id=settings.id, cam=settings.name, ts=timestamp).replace(" ", "_") elif record_type == 'timelapse': if settings.path_timelapse: save_path = settings.path_timelapse else: save_path = assure_path_exists( os.path.join(camera_path, 'timelapse')) start = datetime.datetime.fromtimestamp( settings.timelapse_start_time).strftime("%Y-%m-%d_%H-%M-%S") filename = 'Timelapse-{cam_id}-{cam}-{st}-img-{cn:05d}.jpg'.format( cam_id=settings.id, cam=settings.name, st=start, cn=settings.timelapse_capture_number).replace(" ", "_") elif record_type == 'video': if settings.path_video: save_path = settings.path_video else: save_path = assure_path_exists(os.path.join(camera_path, 'video')) filename = 'Video-{cam}-{ts}.h264'.format(cam=settings.name, ts=timestamp).replace( " ", "_") else: return assure_path_exists(save_path) if tmp_filename: filename = tmp_filename path_file = os.path.join(save_path, filename) # Turn on output, if configured output_already_on = False output_id = None output_channel_id = None output_channel = None if settings.output_id and ',' in settings.output_id: output_id = settings.output_id.split(",")[0] output_channel_id = settings.output_id.split(",")[1] output_channel = db_retrieve_table_daemon(OutputChannel, unique_id=output_channel_id) if output_id and output_channel: daemon_control = DaemonControl() if daemon_control.output_state( output_id, output_channel=output_channel.channel) == "on": output_already_on = True else: daemon_control.output_on(output_id, output_channel=output_channel.channel) # Pause while the output remains on for the specified duration. # Used for instance to allow fluorescent lights to fully turn on before # capturing an image. if settings.output_duration: time.sleep(settings.output_duration) if settings.library == 'picamera': import picamera # Try 5 times to access the pi camera (in case another process is accessing it) for _ in range(5): try: with picamera.PiCamera() as camera: camera.resolution = (settings.width, settings.height) camera.hflip = settings.hflip camera.vflip = settings.vflip camera.rotation = settings.rotation camera.brightness = int(settings.brightness) camera.contrast = int(settings.contrast) camera.exposure_compensation = int(settings.exposure) camera.saturation = int(settings.saturation) camera.shutter_speed = settings.picamera_shutter_speed camera.sharpness = settings.picamera_sharpness camera.iso = settings.picamera_iso camera.awb_mode = settings.picamera_awb if settings.picamera_awb == 'off': camera.awb_gains = (settings.picamera_awb_gain_red, settings.picamera_awb_gain_blue) camera.exposure_mode = settings.picamera_exposure_mode camera.meter_mode = settings.picamera_meter_mode camera.image_effect = settings.picamera_image_effect camera.start_preview() time.sleep(2) # Camera warm-up time if record_type in ['photo', 'timelapse']: camera.capture(path_file, use_video_port=False) elif record_type == 'video': camera.start_recording(path_file, format='h264', quality=20) camera.wait_recording(duration_sec) camera.stop_recording() else: return break except picamera.exc.PiCameraMMALError: logger.error( "The camera is already open by picamera. Retrying 4 times." ) time.sleep(1) elif settings.library == 'fswebcam': cmd = "/usr/bin/fswebcam --device {dev} --resolution {w}x{h} --set brightness={bt}% " \ "--no-banner --save {file}".format(dev=settings.device, w=settings.width, h=settings.height, bt=settings.brightness, file=path_file) if settings.hflip: cmd += " --flip h" if settings.vflip: cmd += " --flip h" if settings.rotation: cmd += " --rotate {angle}".format(angle=settings.rotation) if settings.custom_options: cmd += " {}".format(settings.custom_options) out, err, status = cmd_output(cmd, stdout_pipe=False, user='******') logger.debug("Camera debug message: " "cmd: {}; out: {}; error: {}; status: {}".format( cmd, out, err, status)) elif settings.library == 'opencv': import cv2 import imutils cap = cv2.VideoCapture(settings.opencv_device) cap.set(cv2.CAP_PROP_FRAME_WIDTH, settings.width) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, settings.height) cap.set(cv2.CAP_PROP_EXPOSURE, settings.exposure) cap.set(cv2.CAP_PROP_GAIN, settings.gain) cap.set(cv2.CAP_PROP_BRIGHTNESS, settings.brightness) cap.set(cv2.CAP_PROP_CONTRAST, settings.contrast) cap.set(cv2.CAP_PROP_HUE, settings.hue) cap.set(cv2.CAP_PROP_SATURATION, settings.saturation) # Check if image can be read status, _ = cap.read() if not status: logger.error("Cannot detect USB camera with device '{dev}'".format( dev=settings.opencv_device)) return # Discard a few frames to allow camera to adjust to settings for _ in range(2): cap.read() if record_type in ['photo', 'timelapse']: edited = False status, img_orig = cap.read() cap.release() if not status: logger.error("Could not acquire image") return img_edited = img_orig.copy() if any((settings.hflip, settings.vflip, settings.rotation)): edited = True if settings.hflip and settings.vflip: img_edited = cv2.flip(img_orig, -1) elif settings.hflip: img_edited = cv2.flip(img_orig, 1) elif settings.vflip: img_edited = cv2.flip(img_orig, 0) if settings.rotation: img_edited = imutils.rotate_bound(img_orig, settings.rotation) if edited: cv2.imwrite(path_file, img_edited) else: cv2.imwrite(path_file, img_orig) elif record_type == 'video': # TODO: opencv video recording is currently not working. No idea why. Try to fix later. try: cap = cv2.VideoCapture(settings.opencv_device) fourcc = cv2.CV_FOURCC('X', 'V', 'I', 'D') resolution = (settings.width, settings.height) out = cv2.VideoWriter(path_file, fourcc, 20.0, resolution) time_end = time.time() + duration_sec while cap.isOpened() and time.time() < time_end: ret, frame = cap.read() if ret: # write the frame out.write(frame) if cv2.waitKey(1) & 0xFF == ord('q'): break else: break cap.release() out.release() cv2.destroyAllWindows() except Exception as e: logger.exception("Exception raised while recording video: " "{err}".format(err=e)) else: return elif settings.library == 'http_address': import cv2 import imutils from urllib.error import HTTPError from urllib.parse import urlparse from urllib.request import urlretrieve if record_type in ['photo', 'timelapse']: path_tmp = "/tmp/tmpimg.jpg" # Get filename and extension, if available a = urlparse(settings.url_still) filename = os.path.basename(a.path) if filename: path_tmp = "/tmp/{}".format(filename) try: os.remove(path_tmp) except FileNotFoundError: pass try: urlretrieve(settings.url_still, path_tmp) except HTTPError as err: logger.error(err) except Exception as err: logger.exception(err) try: img_orig = cv2.imread(path_tmp) if img_orig is not None and img_orig.shape is not None: if any( (settings.hflip, settings.vflip, settings.rotation)): img_edited = None if settings.hflip and settings.vflip: img_edited = cv2.flip(img_orig, -1) elif settings.hflip: img_edited = cv2.flip(img_orig, 1) elif settings.vflip: img_edited = cv2.flip(img_orig, 0) if settings.rotation: img_edited = imutils.rotate_bound( img_orig, settings.rotation) if img_edited: cv2.imwrite(path_file, img_edited) else: cv2.imwrite(path_file, img_orig) else: os.rename(path_tmp, path_file) except Exception as err: logger.error( "Could not convert, rotate, or invert image: {}".format( err)) try: os.rename(path_tmp, path_file) except FileNotFoundError: logger.error("Camera image not found") elif record_type == 'video': pass # No video (yet) elif settings.library == 'http_address_requests': import cv2 import imutils import requests if record_type in ['photo', 'timelapse']: path_tmp = "/tmp/tmpimg.jpg" try: os.remove(path_tmp) except FileNotFoundError: pass try: r = requests.get(settings.url_still) if r.status_code == 200: open(path_tmp, 'wb').write(r.content) else: logger.error( "Could not download image. Status code: {}".format( r.status_code)) except requests.HTTPError as err: logger.error("HTTPError: {}".format(err)) except Exception as err: logger.exception(err) try: img_orig = cv2.imread(path_tmp) if img_orig is not None and img_orig.shape is not None: if any( (settings.hflip, settings.vflip, settings.rotation)): if settings.hflip and settings.vflip: img_edited = cv2.flip(img_orig, -1) elif settings.hflip: img_edited = cv2.flip(img_orig, 1) elif settings.vflip: img_edited = cv2.flip(img_orig, 0) if settings.rotation: img_edited = imutils.rotate_bound( img_orig, settings.rotation) cv2.imwrite(path_file, img_edited) else: cv2.imwrite(path_file, img_orig) else: os.rename(path_tmp, path_file) except Exception as err: logger.error( "Could not convert, rotate, or invert image: {}".format( err)) try: os.rename(path_tmp, path_file) except FileNotFoundError: logger.error("Camera image not found") elif record_type == 'video': pass # No video (yet) try: set_user_grp(path_file, 'mycodo', 'mycodo') except Exception as e: logger.exception( "Exception raised in 'camera_record' when setting user grp: " "{err}".format(err=e)) # Turn off output, if configured if output_id and output_channel and daemon_control and not output_already_on: daemon_control.output_off(output_id, output_channel=output_channel.channel) try: set_user_grp(path_file, 'mycodo', 'mycodo') return save_path, filename except Exception as e: logger.exception( "Exception raised in 'camera_record' when setting user grp: " "{err}".format(err=e))
class AM2315Sensor(AbstractInput): """ A sensor support class that measures the AM2315's humidity and temperature and calculates the dew point """ def __init__(self, input_dev, testing=False): super(AM2315Sensor, self).__init__() self.logger = logging.getLogger('mycodo.inputs.am2315') self._dew_point = None self._humidity = None self._temperature = None self.powered = False self.am = None if not testing: from mycodo.mycodo_client import DaemonControl self.logger = logging.getLogger( 'mycodo.inputs.am2315_{id}'.format(id=input_dev.id)) self.i2c_bus = input_dev.i2c_bus self.power_output_id = input_dev.power_output_id self.convert_to_unit = input_dev.convert_to_unit self.control = DaemonControl() self.start_sensor() self.am = AM2315(self.i2c_bus) def __repr__(self): """ Representation of object """ return "<{cls}(dewpoint={dpt})(humidity={hum})(temperature={temp})>".format( cls=type(self).__name__, dpt="{0:.2f}".format(self._dew_point), hum="{0:.2f}".format(self._humidity), temp="{0:.2f}".format(self._temperature)) def __str__(self): """ Return measurement information """ return "Dew Point: {dpt}, Humidity: {hum}, Temperature: {temp}".format( dpt="{0:.2f}".format(self._dew_point), hum="{0:.2f}".format(self._humidity), temp="{0:.2f}".format(self._temperature)) def __iter__(self): # must return an iterator """ AM2315Sensor iterates through live measurement readings """ return self def next(self): """ Get next measurement reading """ if self.read(): # raised an error raise StopIteration # required return dict(dewpoint=float('{0:.2f}'.format(self._dew_point)), humidity=float('{0:.2f}'.format(self._humidity)), temperature=float('{0:.2f}'.format(self._temperature))) @property def dew_point(self): """ AM2315 dew point in Celsius """ if self._dew_point is None: # update if needed self.read() return self._dew_point @property def humidity(self): """ AM2315 relative humidity in percent """ if self._humidity is None: # update if needed self.read() return self._humidity @property def temperature(self): """ AM2315 temperature in Celsius """ if self._temperature is None: # update if needed self.read() return self._temperature def get_measurement(self): """ Gets the humidity and temperature """ self._dew_point = None self._humidity = None self._temperature = None # Ensure if the power pin turns off, it is turned back on if (self.power_output_id and db_retrieve_table_daemon( Output, unique_id=self.power_output_id) and self.control.output_state(self.power_output_id) == 'off'): self.logger.error( 'Sensor power output {rel} detected as being off. ' 'Turning on.'.format(rel=self.power_output_id)) self.start_sensor() time.sleep(2) # Try twice to get measurement. This prevents an anomaly where # the first measurement fails if the sensor has just been powered # for the first time. for _ in range(2): dew_point, humidity, temperature = self.return_measurements() if dew_point is not None: dew_point = convert_units('dewpoint', 'celsius', self.convert_to_unit, dew_point) temperature = convert_units('temperature', 'celsius', self.convert_to_unit, temperature) return dew_point, humidity, temperature # success - no errors time.sleep(2) # Measurement failure, power cycle the sensor (if enabled) # Then try two more times to get a measurement if self.power_output_id: self.stop_sensor() time.sleep(2) self.start_sensor() for _ in range(2): dew_point, humidity, temperature = self.return_measurements() if dew_point is not None: dew_point = convert_units('dewpoint', 'celsius', self.convert_to_unit, dew_point) temperature = convert_units('temperature', 'celsius', self.convert_to_unit, temperature) return dew_point, humidity, temperature # success time.sleep(2) self.logger.debug("Could not acquire a measurement") return None, None, None def return_measurements(self): # Retry measurement if CRC fails for num_measure in range(3): humidity, temperature = self.am.data() if humidity is None: self.logger.debug( "Measurement {num} returned failed CRC".format( num=num_measure)) pass else: dew_pt = dewpoint(temperature, humidity) return dew_pt, humidity, temperature time.sleep(2) self.logger.error("All measurements returned failed CRC") return None, None, None def read(self): """ Takes a reading from the AM2315 and updates the self.dew_point, self._humidity, and self._temperature values :returns: None on success or 1 on error """ try: (self._dew_point, self._humidity, self._temperature) = self.get_measurement() if self._dew_point is not None: return # success - no errors except Exception as e: self.logger.exception( "{cls} raised an exception when taking a reading: " "{err}".format(cls=type(self).__name__, err=e)) return 1 def start_sensor(self): """ Turn the sensor on """ if self.power_output_id: self.logger.info("Turning on sensor") self.control.output_on(self.power_output_id, 0) time.sleep(2) self.powered = True def stop_sensor(self): """ Turn the sensor off """ if self.power_output_id: self.logger.info("Turning off sensor") self.control.output_off(self.power_output_id) self.powered = False
class CustomModule(AbstractController, threading.Thread): """ Class to operate custom controller """ def __init__(self, ready, unique_id, testing=False): threading.Thread.__init__(self) super(CustomModule, self).__init__(ready, unique_id=unique_id, name=__name__) self.unique_id = unique_id self.log_level_debug = None self.control = DaemonControl() # Initialize custom options self.start_offset = None self.period = None self.input_temperature_condenser_device_id = None self.input_temperature_condenser_measurement_id = None self.input_temperature_condenser_max_age = None self.input_temperature_room_device_id = None self.input_temperature_room_measurement_id = None self.input_temperature_room_max_age = None self.output_ac_id = None self.output_ac_sensor_heater_id = None self.setpoint_temperature = None # Set custom options custom_controller = db_retrieve_table_daemon(CustomController, unique_id=unique_id) self.setup_custom_options(CONTROLLER_INFORMATION['custom_options'], custom_controller) if not testing: pass # import controller-specific modules here def get_ac_condenser_temperature(self): """Get condenser temperature""" last_measurement = self.get_last_measurement( self.input_temperature_condenser_device_id, self.input_temperature_condenser_measurement_id, max_age=self.input_temperature_condenser_max_age) if last_measurement: self.logger.debug( "Most recent timestamp and measurement for " "input_temperature_condenser: {timestamp}, {meas}".format( timestamp=last_measurement[0], meas=last_measurement[1])) return last_measurement else: self.logger.debug( "Could not find a measurement in the database for " "input_temperature_condenser device ID {} and measurement " "ID {}".format( self.input_temperature_condenser_device_id, self.input_temperature_condenser_measurement_id)) def get_room_temperature(self): """Get condenser temperature""" last_measurement = self.get_last_measurement( self.input_temperature_room_device_id, self.input_temperature_room_measurement_id, max_age=self.input_temperature_room_max_age) if last_measurement: self.logger.debug( "Most recent timestamp and measurement for " "input_temperature_room: {timestamp}, {meas}".format( timestamp=last_measurement[0], meas=last_measurement[1])) return last_measurement else: self.logger.debug( "Could not find a measurement in the database for " "input_temperature_room device ID {} and measurement " "ID {}".format(self.input_temperature_room_device_id, self.input_temperature_room_measurement_id)) def run(self): try: self.logger.info("Activated in {:.1f} ms".format( (timeit.default_timer() - self.thread_startup_timer) * 1000)) self.ready.set() self.running = True start_offset_timer = time.time() + self.start_offset while self.running and time.time() < start_offset_timer: time.sleep(1) if not self.running: return # try to get measurement from sensor temperature_condenser = self.get_ac_condenser_temperature() temperature_room = self.get_room_temperature() if not temperature_condenser or not temperature_room: return # Turn Output output_ac on self.logger.debug("Turning output_ac with ID {} on".format( self.output_ac_id)) self.control.output_on(self.output_ac_id) # Start a loop while self.running: temperature_condenser = self.get_ac_condenser_temperature() temperature_room = self.get_room_temperature() if temperature_room > self.setpoint_temperature and temperature_condenser > 0: # Turn Output output_ac_sensor_heater on self.logger.debug( "Turning output_ac_sensor_heater with ID {} on".format( self.output_ac_sensor_heater_id)) self.control.output_on(self.output_ac_sensor_heater_id) else: # Turn Output output_ac_sensor_heater off self.logger.debug( "Turning output_ac_sensor_heater with ID {} off". format(self.output_ac_sensor_heater_id)) self.control.output_off(self.output_ac_sensor_heater_id) time.sleep(self.period) except: self.logger.exception("Run Error") finally: self.run_finally() self.running = False if self.thread_shutdown_timer: self.logger.info("Deactivated in {:.1f} ms".format( (timeit.default_timer() - self.thread_shutdown_timer) * 1000)) else: self.logger.error("Deactivated unexpectedly") def loop(self): pass def initialize_variables(self): controller = db_retrieve_table_daemon(CustomController, unique_id=self.unique_id) self.log_level_debug = controller.log_level_debug self.set_log_level_debug(self.log_level_debug) def run_finally(self): # Turn Output output_ac off self.logger.debug("Turning output_ac with ID {} off".format( self.output_ac_id)) self.control.output_off(self.output_ac_id) # Turn Output output_ac_sensor_heater off self.logger.debug( "Turning output_ac_sensor_heater with ID {} off".format( self.output_ac_sensor_heater_id)) self.control.output_off(self.output_ac_sensor_heater_id)
class ConditionalController(threading.Thread): """ Class to operate Conditional controller Conditionals are conditional statements that can either be True or False When a conditional is True, one or more actions associated with that conditional are executed. The main loop in this class will continually check if the timers for Measurement Conditionals have elapsed, then check if any of the conditionals are True with the check_conditionals() function. If any are True, trigger_conditional_actions() will be ran to execute all actions associated with that particular conditional. Edge and Output conditionals are triggered from the Input and Output controllers, respectively, and the trigger_conditional_actions() function in this class will be ran. """ def __init__(self, ready, cond_id): threading.Thread.__init__(self) self.logger = logging.getLogger( "mycodo.conditional_{id}".format(id=cond_id.split('-')[0])) self.cond_id = cond_id self.running = False self.thread_startup_timer = timeit.default_timer() self.thread_shutdown_timer = 0 self.pause_loop = False self.verify_pause_loop = True self.ready = ready self.control = DaemonControl() self.sample_rate = db_retrieve_table_daemon( Misc, entry='first').sample_rate_controller_conditional self.conditional_type = None self.is_activated = None self.smtp_max_count = None self.email_count = None self.allowed_to_send_notice = None self.smtp_wait_timer = None self.timer_period = None self.period = None self.refractory_period = None self.timer_refractory_period = None self.smtp_wait_timer = None self.timer_period = None self.timer_start_time = None self.timer_end_time = None self.unique_id_1 = None self.unique_id_2 = None self.trigger_actions_at_period = None self.trigger_actions_at_start = None self.method_start_time = None self.method_end_time = None self.method_start_act = None self.setup_settings() def run(self): try: self.running = True self.logger.info( "Conditional controller activated in {:.1f} ms".format( (timeit.default_timer() - self.thread_startup_timer) * 1000)) self.ready.set() while self.running: # Pause loop to modify conditional statements. # Prevents execution of conditional while variables are # being modified. if self.pause_loop: self.verify_pause_loop = True while self.pause_loop: time.sleep(0.1) if (self.is_activated and self.timer_period and self.timer_period < time.time()): check_approved = False # Check if the conditional period has elapsed if ((self.conditional_type == 'conditional_measurement' and self.timer_refractory_period < time.time()) or self.conditional_type in [ 'conditional_sunrise_sunset', 'conditional_run_pwm_method' ]): while self.timer_period < time.time(): self.timer_period += self.period if self.conditional_type == 'conditional_run_pwm_method': # Only execute conditional actions when started # Now only set PWM output pwm_duty_cycle, ended = self.get_method_output( self.unique_id_1) if not ended: self.set_output_duty_cycle( self.unique_id_2, pwm_duty_cycle) if self.trigger_actions_at_period: self.trigger_conditional_actions( duty_cycle=pwm_duty_cycle) else: check_approved = True elif (self.conditional_type in [ 'conditional_timer_daily_time_point', 'conditional_timer_daily_time_span', 'conditional_timer_duration' ]): if self.conditional_type == 'conditional_timer_daily_time_point': self.timer_period = epoch_of_next_time( '{hm}:00'.format(hm=self.timer_start_time)) elif self.conditional_type in [ 'conditional_timer_duration', 'conditional_timer_daily_time_span' ]: while self.timer_period < time.time(): self.timer_period += self.period check_approved = True if check_approved: self.check_conditionals() time.sleep(self.sample_rate) self.running = False self.logger.info( "Conditional controller deactivated in {:.1f} ms".format( (timeit.default_timer() - self.thread_shutdown_timer) * 1000)) except Exception as except_msg: self.logger.exception("Run Error: {err}".format(err=except_msg)) def refresh_settings(self): """ Signal to pause the main loop and wait for verification, the refresh settings """ self.pause_loop = True while not self.verify_pause_loop: time.sleep(0.1) self.logger.info("Refreshing conditional settings") self.setup_settings() self.pause_loop = False self.verify_pause_loop = False return "Conditional settings successfully refreshed" def setup_settings(self): """ Define all settings """ cond = db_retrieve_table_daemon(Conditional, unique_id=self.cond_id) self.conditional_type = cond.conditional_type self.is_activated = cond.is_activated self.smtp_max_count = db_retrieve_table_daemon( SMTP, entry='first').hourly_max self.email_count = 0 self.allowed_to_send_notice = True now = time.time() self.smtp_wait_timer = now + 3600 self.timer_period = None # Set up measurement conditional if self.conditional_type == 'conditional_measurement': self.period = cond.period self.refractory_period = cond.refractory_period self.timer_refractory_period = 0 self.smtp_wait_timer = now + 3600 self.timer_period = now + self.period # Set up conditional timer (daily time point) elif self.conditional_type == 'conditional_timer_daily_time_point': self.timer_start_time = cond.timer_start_time self.timer_period = epoch_of_next_time( '{hm}:00'.format(hm=cond.timer_start_time)) # Set up conditional timer (daily time span) elif self.conditional_type == 'conditional_timer_daily_time_span': self.timer_start_time = cond.timer_start_time self.timer_end_time = cond.timer_end_time self.period = cond.period self.timer_period = now # Set up conditional timer (duration) elif self.conditional_type == 'conditional_timer_duration': self.period = cond.period if cond.timer_start_offset: self.timer_period = now + cond.timer_start_offset else: self.timer_period = now # Set up Run PWM Method conditional elif self.conditional_type == 'conditional_run_pwm_method': self.unique_id_1 = cond.unique_id_1 self.unique_id_2 = cond.unique_id_2 self.period = cond.period self.trigger_actions_at_period = cond.trigger_actions_at_period self.trigger_actions_at_start = cond.trigger_actions_at_start self.method_start_time = cond.method_start_time self.method_end_time = cond.method_end_time if self.is_activated: self.start_method(cond.unique_id_1) if self.trigger_actions_at_start: self.timer_period = now + cond.period if self.is_activated: pwm_duty_cycle = self.get_method_output(cond.unique_id_1) self.set_output_duty_cycle(cond.unique_id_2, pwm_duty_cycle) self.trigger_conditional_actions(cond.unique_id, duty_cycle=pwm_duty_cycle) else: self.timer_period = now # Set up sunrise/sunset conditional elif self.conditional_type == 'conditional_sunrise_sunset': self.timer_refractory_period = 0 self.period = 1000 # Set the next trigger at the specified sunrise/sunset time (+-offsets) self.timer_period = self.calculate_sunrise_sunset_epoch(cond) def start_method(self, method_id): """ Instruct a method to start running """ if method_id: method = db_retrieve_table_daemon(Method, unique_id=method_id) method_data = db_retrieve_table_daemon(MethodData) method_data = method_data.filter(MethodData.method_id == method_id) method_data_repeat = method_data.filter( MethodData.duration_sec == 0).first() self.method_start_act = self.method_start_time self.method_start_time = None self.method_end_time = None if method.method_type == 'Duration': if self.method_start_act == 'Ended': with session_scope(MYCODO_DB_PATH) as db_session: mod_conditional = db_session.query(Conditional) mod_conditional = mod_conditional.filter( Conditional.unique_id == self.cond_id).first() mod_conditional.is_activated = False db_session.commit() self.stop_controller() self.logger.warning( "Method has ended. " "Activate the Conditional controller to start it again." ) elif (self.method_start_act == 'Ready' or self.method_start_act is None): # Method has been instructed to begin now = datetime.datetime.now() self.method_start_time = now if method_data_repeat and method_data_repeat.duration_end: self.method_end_time = now + datetime.timedelta( seconds=float(method_data_repeat.duration_end)) with session_scope(MYCODO_DB_PATH) as db_session: mod_conditional = db_session.query(Conditional) mod_conditional = mod_conditional.filter( Conditional.unique_id == self.cond_id).first() mod_conditional.method_start_time = self.method_start_time mod_conditional.method_end_time = self.method_end_time db_session.commit() def get_method_output(self, method_id): """ Get output variable from method """ this_controller = db_retrieve_table_daemon(Conditional, unique_id=self.cond_id) setpoint, ended = calculate_method_setpoint(method_id, Conditional, this_controller, Method, MethodData, self.logger) if setpoint is not None: if setpoint > 100: setpoint = 100 elif setpoint < 0: setpoint = 0 if ended: with session_scope(MYCODO_DB_PATH) as db_session: mod_conditional = db_session.query(Conditional) mod_conditional = mod_conditional.filter( Conditional.unique_id == self.cond_id).first() mod_conditional.is_activated = False db_session.commit() self.is_activated = False self.stop_controller() return setpoint, ended def set_output_duty_cycle(self, output_id, duty_cycle): """ Set PWM Output duty cycle """ self.control.output_on(output_id, duty_cycle=duty_cycle) def check_conditionals(self): """ Check if any Conditionals are activated and execute their actions if the Conditional is true. For example, if measured temperature is above 30C, notify [email protected] "if measured temperature is above 30C" is the Conditional to check. "notify [email protected]" is the Conditional Action to execute if the Conditional is True. """ last_measurement = None gpio_state = None logger_cond = logging.getLogger( "mycodo.conditional_{id}".format(id=self.cond_id)) cond = db_retrieve_table_daemon(Conditional, unique_id=self.cond_id, entry='first') now = time.time() timestamp = datetime.datetime.fromtimestamp(now).strftime( '%Y-%m-%d %H:%M:%S') message = "{ts}\n[Conditional {id} ({name})]".format(ts=timestamp, name=cond.name, id=self.cond_id) device_id = cond.measurement.split(',')[0] if len(cond.measurement.split(',')) > 1: device_measurement = cond.measurement.split(',')[1] else: device_measurement = None direction = cond.direction setpoint = cond.setpoint max_age = cond.max_age device = None input_dev = db_retrieve_table_daemon(Input, unique_id=device_id, entry='first') if input_dev: device = input_dev math = db_retrieve_table_daemon(Math, unique_id=device_id, entry='first') if math: device = math output = db_retrieve_table_daemon(Output, unique_id=device_id, entry='first') if output: device = output pid = db_retrieve_table_daemon(PID, unique_id=device_id, entry='first') if pid: device = pid if not device: message += " Error: Controller not Input, Math, Output, or PID" logger_cond.error(message) return # Check Measurement Conditionals if (cond.conditional_type == 'conditional_measurement' and direction and device_id and device_measurement): # Check if there hasn't been a measurement in the last set number # of seconds. If not, trigger conditional if direction == 'none_found': last_measurement = self.get_last_measurement( device_id, device_measurement, max_age) if last_measurement is None: message += " Measurement {meas} for device ID {id} not found in the past" \ " {value} seconds.".format( meas=device_measurement, id=device_id, value=max_age) else: return # Check if last measurement is greater or less than the set value else: last_measurement = self.get_last_measurement( device_id, device_measurement, max_age) if last_measurement is None: logger_cond.debug("Last measurement not found") return elif ((direction == 'above' and last_measurement > setpoint) or (direction == 'below' and last_measurement < setpoint)): message += " Measurement {meas}: {value} ".format( meas=device_measurement, value=last_measurement) if direction == 'above': message += ">" elif direction == 'below': message += "<" message += " {sp} (set value).".format(sp=setpoint) else: return # Not triggered # If the edge detection variable is set, calling this function will # trigger an edge detection event. This will merely produce the correct # message based on the edge detection settings. elif cond.conditional_type == 'conditional_edge': try: GPIO.setmode(GPIO.BCM) GPIO.setup(int(input_dev.pin), GPIO.IN) gpio_state = GPIO.input(int(input_dev.pin)) except: gpio_state = None logger_cond.error("Exception reading the GPIO pin") if (input_dev and input_dev.location and gpio_state is not None and gpio_state == cond.if_sensor_gpio_state): message += " GPIO State Detected (state = {state}).".format( state=cond.if_sensor_gpio_state) else: logger_cond.error( "GPIO not configured correctly or GPIO state not verified") return # Calculate the sunrise/sunset times and find the next time this conditional should trigger elif cond.conditional_type == 'conditional_sunrise_sunset': # Since the check time is the trigger time, we will only calculate and set the next trigger time self.timer_period = self.calculate_sunrise_sunset_epoch(cond) # Set the refractory period if cond.conditional_type == 'conditional_measurement': self.timer_refractory_period = time.time() + self.refractory_period # Check if the current time is between the start and end time if cond.conditional_type == 'conditional_timer_daily_time_span': if not time_between_range(self.timer_start_time, self.timer_end_time): return # If the code hasn't returned by now, the conditional has been triggered # and the actions for that conditional should be executed self.trigger_conditional_actions(message=message, last_measurement=last_measurement, device_id=device_id, device_measurement=device_measurement, edge=gpio_state) def trigger_conditional_actions(self, message='', last_measurement=None, device_id=None, device_measurement=None, edge=None, output_state=None, on_duration=None, duty_cycle=None): """ If a Conditional has been triggered, this function will execute the Conditional Actions :param self: self from the Controller class :param device_id: The unique ID associated with the device_measurement :param message: The message generated from the conditional check :param last_measurement: The last measurement value :param device_measurement: The measurement (i.e. "temperature") :param edge: If edge conditional, rise/on (1) or fall/off (0) :param output_state: If output conditional, the output state (on/off) to trigger the action :param on_duration: If output conditional, the ON duration :param duty_cycle: If output conditional, the duty cycle :return: """ logger_cond = logging.getLogger( "mycodo.conditional_{id}".format(id=self.cond_id)) # List of all email notification recipients # List is appended with TO email addresses when an email Action is # encountered. An email is sent to all recipients after all actions # have been executed. email_recipients = [] attachment_file = False attachment_type = False input_dev = None output = None device = None cond_actions = db_retrieve_table_daemon(ConditionalActions) cond_actions = cond_actions.filter( ConditionalActions.conditional_id == self.cond_id).all() if device_id: input_dev = db_retrieve_table_daemon(Input, unique_id=device_id, entry='first') if input_dev: device = input_dev math = db_retrieve_table_daemon(Math, unique_id=device_id, entry='first') if math: device = math output = db_retrieve_table_daemon(Output, unique_id=device_id, entry='first') if output: device = output pid = db_retrieve_table_daemon(PID, unique_id=device_id, entry='first') if pid: device = pid for cond_action in cond_actions: message += "\n[Conditional Action {id}]:".format( id=cond_action.id, do_action=cond_action.do_action) # Actuate output (duration) if (cond_action.do_action == 'output' and cond_action.do_unique_id and cond_action.do_output_state in ['on', 'off']): this_output = db_retrieve_table_daemon( Output, unique_id=cond_action.do_unique_id, entry='first') message += " Turn output {unique_id} ({id}, {name}) {state}".format( unique_id=cond_action.do_unique_id, id=this_output.id, name=this_output.name, state=cond_action.do_output_state) if (cond_action.do_output_state == 'on' and cond_action.do_output_duration): message += " for {sec} seconds".format( sec=cond_action.do_output_duration) message += "." output_on_off = threading.Thread( target=self.control.output_on_off, args=( cond_action.do_unique_id, cond_action.do_output_state, ), kwargs={'duration': cond_action.do_output_duration}) output_on_off.start() # Actuate output (PWM) elif (cond_action.do_action == 'output_pwm' and cond_action.do_unique_id and cond_action.do_output_pwm): this_output = db_retrieve_table_daemon( Output, unique_id=cond_action.do_unique_id, entry='first') message += " Turn output {unique_id} ({id}, {name}) duty cycle to {duty_cycle}%.".format( unique_id=cond_action.do_unique_id, id=this_output.id, name=this_output.name, duty_cycle=cond_action.do_output_pwm) output_on = threading.Thread( target=self.control.output_on, args=(cond_action.do_unique_id, ), kwargs={'duty_cycle': cond_action.do_output_pwm}) output_on.start() # Execute command in shell elif cond_action.do_action == 'command': # Replace string variables with actual values command_str = cond_action.do_action_string # Replace measurement variables if last_measurement: command_str = command_str.replace( "((measure_{var}))".format(var=device_measurement), str(last_measurement)) if device and device.period: command_str = command_str.replace("((measure_period))", str(device.period)) if input_dev: command_str = command_str.replace("((measure_location))", str(input_dev.location)) if input_dev and device_measurement == input_dev.cmd_measurement: command_str = command_str.replace( "((measure_linux_command))", str(input_dev.location)) # Replace output variables if output: if output.pin: command_str = command_str.replace( "((output_pin))", str(output.pin)) if output_state: command_str = command_str.replace( "((output_action))", str(output_state)) if on_duration: command_str = command_str.replace( "((output_duration))", str(on_duration)) if duty_cycle: command_str = command_str.replace( "((output_pwm))", str(duty_cycle)) # Replace edge variables if edge: command_str = command_str.replace("((edge_state))", str(edge)) message += " Execute '{com}' ".format(com=command_str) _, _, cmd_status = cmd_output(command_str) message += "(return status: {stat}).".format(stat=cmd_status) # Capture photo elif cond_action.do_action in ['photo', 'photo_email']: this_camera = db_retrieve_table_daemon( Camera, unique_id=cond_action.do_unique_id, entry='first') message += " Capturing photo with camera {unique_id} ({id}, {name}).".format( unique_id=cond_action.do_unique_id, id=this_camera.id, name=this_camera.name) camera_still = db_retrieve_table_daemon( Camera, unique_id=cond_action.do_unique_id) attachment_file = camera_record('photo', camera_still.unique_id) # Capture video elif cond_action.do_action in ['video', 'video_email']: this_camera = db_retrieve_table_daemon( Camera, unique_id=cond_action.do_unique_id, entry='first') message += " Capturing video with camera {unique_id} ({id}, {name}).".format( unique_id=cond_action.do_unique_id, id=this_camera.id, name=this_camera.name) camera_stream = db_retrieve_table_daemon( Camera, unique_id=cond_action.do_unique_id) attachment_file = camera_record( 'video', camera_stream.unique_id, duration_sec=cond_action.do_camera_duration) # Activate Controller elif cond_action.do_action == 'activate_controller': (controller_type, controller_object, controller_entry) = self.which_controller( cond_action.do_unique_id) message += " Activate Controller {unique_id} ({id}, {name}).".format( unique_id=cond_action.do_unique_id, id=controller_entry.id, name=controller_entry.name) if controller_entry.is_activated: message += " Notice: Controller is already active!" else: # If controller is Conditional and is # conditional_run_pwm_method, activate method start is_conditional = db_retrieve_table_daemon( Conditional, unique_id=cond_action.do_unique_id, entry='first') if (is_conditional and is_conditional.conditional_type == 'conditional_run_pwm_method'): with session_scope(MYCODO_DB_PATH) as new_session: mod_cont_ready = new_session.query( Conditional).filter( Conditional.unique_id == cond_action.do_unique_id).first() mod_cont_ready.method_start_time = 'Ready' new_session.commit() with session_scope(MYCODO_DB_PATH) as new_session: mod_cont = new_session.query(controller_object).filter( controller_object.unique_id == cond_action.do_unique_id).first() mod_cont.is_activated = True new_session.commit() activate_controller = threading.Thread( target=self.control.controller_activate, args=( controller_type, cond_action.do_unique_id, )) activate_controller.start() # Deactivate Controller elif cond_action.do_action == 'deactivate_controller': (controller_type, controller_object, controller_entry) = self.which_controller( cond_action.do_unique_id) message += " Deactivate Controller {unique_id} ({id}, {name}).".format( unique_id=cond_action.do_unique_id, id=controller_entry.id, name=controller_entry.name) if not controller_entry.is_activated: message += " Notice: Controller is already inactive!" else: with session_scope(MYCODO_DB_PATH) as new_session: mod_cont = new_session.query(controller_object).filter( controller_object.unique_id == cond_action.do_unique_id).first() mod_cont.is_activated = False new_session.commit() deactivate_controller = threading.Thread( target=self.control.controller_deactivate, args=( controller_type, cond_action.do_unique_id, )) deactivate_controller.start() # Resume PID controller elif cond_action.do_action == 'resume_pid': pid = db_retrieve_table_daemon( PID, unique_id=cond_action.do_unique_id, entry='first') message += " Resume PID {unique_id} ({id}, {name}).".format( unique_id=cond_action.do_unique_id, id=pid.id, name=pid.name) if not pid.is_paused: message += " Notice: PID is not paused!" elif pid.is_activated: with session_scope(MYCODO_DB_PATH) as new_session: mod_pid = new_session.query(PID).filter( PID.unique_id == cond_action.do_unique_id).first() mod_pid.is_paused = False new_session.commit() resume_pid = threading.Thread( target=self.control.pid_resume, args=(cond_action.do_unique_id, )) resume_pid.start() # Pause PID controller elif cond_action.do_action == 'pause_pid': pid = db_retrieve_table_daemon( PID, unique_id=cond_action.do_unique_id, entry='first') message += " Pause PID {unique_id} ({id}, {name}).".format( unique_id=cond_action.do_unique_id, id=pid.id, name=pid.name) if pid.is_paused: message += " Notice: PID is already paused!" elif pid.is_activated: with session_scope(MYCODO_DB_PATH) as new_session: mod_pid = new_session.query(PID).filter( PID.unique_id == cond_action.do_unique_id).first() mod_pid.is_paused = True new_session.commit() pause_pid = threading.Thread( target=self.control.pid_pause, args=(cond_action.do_unique_id, )) pause_pid.start() # Set PID Setpoint elif cond_action.do_action == 'setpoint_pid': pid = db_retrieve_table_daemon( PID, unique_id=cond_action.do_unique_id, entry='first') message += " Set Setpoint of PID {unique_id} ({id}, {name}).".format( unique_id=cond_action.do_unique_id, id=pid.id, name=pid.name) if pid.is_activated: setpoint_pid = threading.Thread( target=self.control.pid_set, args=( pid.unique_id, 'setpoint', float(cond_action.do_action_string), )) setpoint_pid.start() else: with session_scope(MYCODO_DB_PATH) as new_session: mod_pid = new_session.query(PID).filter( PID.unique_id == cond_action.do_unique_id).first() mod_pid.setpoint = cond_action.do_action_string new_session.commit() # Set PID Method and start method from beginning elif cond_action.do_action == 'method_pid': pid = db_retrieve_table_daemon( PID, unique_id=cond_action.do_unique_id, entry='first') message += " Set Method of PID {unique_id} ({id}, {name}).".format( unique_id=cond_action.do_unique_id, id=pid.id, name=pid.name) # Instruct method to start with session_scope(MYCODO_DB_PATH) as new_session: mod_pid = new_session.query(PID).filter( PID.unique_id == cond_action.do_unique_id).first() mod_pid.method_start_time = 'Ready' new_session.commit() pid = db_retrieve_table_daemon( PID, unique_id=cond_action.do_unique_id, entry='first') if pid.is_activated: method_pid = threading.Thread( target=self.control.pid_set, args=( pid.unique_id, 'method', cond_action.do_action_string, )) method_pid.start() else: with session_scope(MYCODO_DB_PATH) as new_session: mod_pid = new_session.query(PID).filter( PID.unique_id == cond_action.do_unique_id).first() mod_pid.method_id = cond_action.do_action_string new_session.commit() # Email the Conditional message. Optionally capture a photo or # video and attach to the email. elif cond_action.do_action in [ 'email', 'photo_email', 'video_email' ]: if (self.email_count >= self.smtp_max_count and time.time() < self.smtp_wait_timer): self.allowed_to_send_notice = False else: if time.time() > self.smtp_wait_timer: self.email_count = 0 self.smtp_wait_timer = time.time() + 3600 self.allowed_to_send_notice = True self.email_count += 1 # If the emails per hour limit has not been exceeded if self.allowed_to_send_notice: email_recipients.append(cond_action.do_action_string) message += " Notify {email}.".format( email=cond_action.do_action_string) # attachment_type != False indicates to # attach a photo or video if cond_action.do_action == 'photo_email': message += " Photo attached to email." attachment_type = 'still' elif cond_action.do_action == 'video_email': message += " Video attached to email." attachment_type = 'video' else: logger_cond.error( "Wait {sec:.0f} seconds to email again.".format( sec=self.smtp_wait_timer - time.time())) elif cond_action.do_action == 'flash_lcd_on': lcd = db_retrieve_table_daemon( LCD, unique_id=cond_action.do_unique_id) message += " LCD {unique_id} ({id}, {name}) Flash On.".format( unique_id=cond_action.do_unique_id, id=lcd.id, name=lcd.name) start_flashing = threading.Thread( target=self.control.lcd_flash, args=( cond_action.do_unique_id, True, )) start_flashing.start() elif cond_action.do_action == 'flash_lcd_off': lcd = db_retrieve_table_daemon( LCD, unique_id=cond_action.do_unique_id) message += " LCD {unique_id} ({id}, {name}) Flash Off.".format( unique_id=cond_action.do_unique_id, id=lcd.id, name=lcd.name) start_flashing = threading.Thread( target=self.control.lcd_flash, args=( cond_action.do_unique_id, False, )) start_flashing.start() elif cond_action.do_action == 'lcd_backlight_off': lcd = db_retrieve_table_daemon( LCD, unique_id=cond_action.do_unique_id) message += " LCD {unique_id} ({id}, {name}) Backlight Off.".format( unique_id=cond_action.do_unique_id, id=lcd.id, name=lcd.name) start_flashing = threading.Thread( target=self.control.lcd_backlight, args=( cond_action.do_unique_id, False, )) start_flashing.start() elif cond_action.do_action == 'lcd_backlight_on': lcd = db_retrieve_table_daemon( LCD, unique_id=cond_action.do_unique_id) message += " LCD {unique_id} ({id}, {name}) Backlight On.".format( unique_id=cond_action.do_unique_id, id=lcd.id, name=lcd.name) start_flashing = threading.Thread( target=self.control.lcd_backlight, args=( cond_action.do_unique_id, True, )) start_flashing.start() # Send email after all conditional actions have been checked # In order to append all action messages to send in the email # send_email_at_end will be None or the TO email address if email_recipients: smtp = db_retrieve_table_daemon(SMTP, entry='first') send_email(smtp.host, smtp.ssl, smtp.port, smtp.user, smtp.passw, smtp.email_from, email_recipients, message, attachment_file, attachment_type) logger_cond.debug(message) @staticmethod def which_controller(unique_id): controller_type = None controller_object = None controller_entry = None if db_retrieve_table_daemon(Conditional, unique_id=unique_id): controller_type = 'Conditional' controller_object = Conditional controller_entry = db_retrieve_table_daemon(Conditional, unique_id=unique_id) elif db_retrieve_table_daemon(Input, unique_id=unique_id): controller_type = 'Input' controller_object = Input controller_entry = db_retrieve_table_daemon(Input, unique_id=unique_id) elif db_retrieve_table_daemon(LCD, unique_id=unique_id): controller_type = 'LCD' controller_object = LCD controller_entry = db_retrieve_table_daemon(LCD, unique_id=unique_id) elif db_retrieve_table_daemon(Math, unique_id=unique_id): controller_type = 'Math' controller_object = Math controller_entry = db_retrieve_table_daemon(Math, unique_id=unique_id) elif db_retrieve_table_daemon(PID, unique_id=unique_id): controller_type = 'PID' controller_object = PID controller_entry = db_retrieve_table_daemon(PID, unique_id=unique_id) return controller_type, controller_object, controller_entry @staticmethod def calculate_sunrise_sunset_epoch(cond): try: # Adjust for date offset now = datetime.datetime.now() new_date = now + datetime.timedelta(days=cond.date_offset_days) sun = Sun(latitude=cond.latitude, longitude=cond.longitude, zenith=cond.zenith, day=new_date.day, month=new_date.month, year=new_date.year) sunrise = sun.get_sunrise_time() sunset = sun.get_sunset_time() # Adjust for time offset new_sunrise = sunrise['time_local'] + datetime.timedelta( minutes=cond.time_offset_minutes) new_sunset = sunset['time_local'] + datetime.timedelta( minutes=cond.time_offset_minutes) if cond.rise_or_set == 'sunrise': # If the sunrise is in the past, add a day if float(new_sunrise.strftime('%s')) < time.time(): tomorrow_sunrise = new_sunrise + datetime.timedelta(days=1) return float(tomorrow_sunrise.strftime('%s')) else: return float(new_sunrise.strftime('%s')) elif cond.rise_or_set == 'sunset': # If the sunrise is in the past, add a day if float(new_sunset.strftime('%s')) < time.time(): tomorrow_sunset = new_sunset + datetime.timedelta(days=1) return float(tomorrow_sunset.strftime('%s')) else: return float(new_sunset.strftime('%s')) except: return None @staticmethod def get_last_measurement(unique_id, measurement, duration_sec): """ Retrieve the latest input measurement :return: The latest input value or None if no data available :rtype: float or None :param unique_id: ID of controller :type unique_id: str :param measurement: Environmental condition of a input (e.g. temperature, humidity, pressure, etc.) :type measurement: str :param duration_sec: number of seconds to check for a measurement in the past. :type duration_sec: int """ last_measurement = read_last_influxdb(unique_id, measurement, duration_sec=duration_sec) if last_measurement is not None: last_value = last_measurement[1] return last_value def is_running(self): return self.running def stop_controller(self): self.thread_shutdown_timer = timeit.default_timer() self.running = False
class PIDController(threading.Thread): """ Class to operate discrete PID controller """ def __init__(self, ready, pid_id): threading.Thread.__init__(self) self.logger = logging.getLogger( "mycodo.pid_{id}".format(id=pid_id.split('-')[0])) self.running = False self.thread_startup_timer = timeit.default_timer() self.thread_shutdown_timer = 0 self.ready = ready self.pid_id = pid_id self.control = DaemonControl() self.sample_rate = db_retrieve_table_daemon( Misc, entry='first').sample_rate_controller_pid self.control_variable = 0.0 self.derivator = 0.0 self.integrator = 0.0 self.error = 0.0 self.P_value = None self.I_value = None self.D_value = None self.lower_seconds_on = 0.0 self.raise_seconds_on = 0.0 self.lower_duty_cycle = 0.0 self.raise_duty_cycle = 0.0 self.last_time = None self.last_measurement = None self.last_measurement_success = False self.is_activated = None self.is_held = None self.is_paused = None self.measurement = None self.method_id = None self.direction = None self.raise_output_id = None self.raise_min_duration = None self.raise_max_duration = None self.raise_min_off_duration = None self.lower_output_id = None self.lower_min_duration = None self.lower_max_duration = None self.lower_min_off_duration = None self.Kp = None self.Ki = None self.Kd = None self.integrator_min = None self.integrator_max = None self.period = None self.max_measure_age = None self.default_setpoint = None self.setpoint = None self.store_lower_as_negative = None # Hysteresis options self.band = None self.allow_raising = False self.allow_lowering = False self.dev_unique_id = None self.input_duration = None self.raise_output_type = None self.lower_output_type = None self.first_start = True self.timer = 0 self.initialize_values() # Check if a method is set for this PID self.method_start_act = None if self.method_id != '': self.setup_method(self.method_id) def run(self): try: self.running = True startup_str = "Activated in {:.1f} ms".format( (timeit.default_timer() - self.thread_startup_timer) * 1000) if self.is_paused: startup_str += ", started Paused" elif self.is_held: startup_str += ", started Held" self.logger.info(startup_str) self.ready.set() while self.running: if (self.method_start_act == 'Ended' and self.method_type == 'Duration'): self.stop_controller(ended_normally=False, deactivate_pid=True) self.logger.warning( "Method has ended. " "Activate the PID controller to start it again.") elif time.time() > self.timer: self.check_pid() time.sleep(self.sample_rate) except Exception as except_msg: self.logger.exception("Run Error: {err}".format(err=except_msg)) finally: # Turn off output used in PID when the controller is deactivated if self.raise_output_id and self.direction in ['raise', 'both']: self.control.output_off(self.raise_output_id, trigger_conditionals=True) if self.lower_output_id and self.direction in ['lower', 'both']: self.control.output_off(self.lower_output_id, trigger_conditionals=True) self.running = False self.logger.info("Deactivated in {:.1f} ms".format( (timeit.default_timer() - self.thread_shutdown_timer) * 1000)) def initialize_values(self): """Set PID parameters""" pid = db_retrieve_table_daemon(PID, unique_id=self.pid_id) self.is_activated = pid.is_activated self.is_held = pid.is_held self.is_paused = pid.is_paused self.method_id = pid.method_id self.direction = pid.direction self.raise_output_id = pid.raise_output_id self.raise_min_duration = pid.raise_min_duration self.raise_max_duration = pid.raise_max_duration self.raise_min_off_duration = pid.raise_min_off_duration self.lower_output_id = pid.lower_output_id self.lower_min_duration = pid.lower_min_duration self.lower_max_duration = pid.lower_max_duration self.lower_min_off_duration = pid.lower_min_off_duration self.Kp = pid.p self.Ki = pid.i self.Kd = pid.d self.integrator_min = pid.integrator_min self.integrator_max = pid.integrator_max self.period = pid.period self.max_measure_age = pid.max_measure_age self.default_setpoint = pid.setpoint self.setpoint = pid.setpoint self.band = pid.band self.store_lower_as_negative = pid.store_lower_as_negative dev_unique_id = pid.measurement.split(',')[0] self.measurement = pid.measurement.split(',')[1] input_dev = db_retrieve_table_daemon(Input, unique_id=dev_unique_id) math = db_retrieve_table_daemon(Math, unique_id=dev_unique_id) if input_dev: self.dev_unique_id = input_dev.unique_id self.input_duration = input_dev.period elif math: self.dev_unique_id = math.unique_id self.input_duration = math.period try: self.raise_output_type = db_retrieve_table_daemon( Output, unique_id=self.raise_output_id).output_type except AttributeError: self.raise_output_type = None try: self.lower_output_type = db_retrieve_table_daemon( Output, unique_id=self.lower_output_id).output_type except AttributeError: self.lower_output_type = None return "success" def check_pid(self): """ Get measurement and apply to PID controller """ # Ensure the timer ends in the future while time.time() > self.timer: self.timer = self.timer + self.period # If PID is active, retrieve measurement and update # the control variable. # A PID on hold will sustain the current output and # not update the control variable. if self.is_activated and (not self.is_paused or not self.is_held): self.get_last_measurement() if self.last_measurement_success: if self.method_id != '': # Update setpoint using a method this_pid = db_retrieve_table_daemon(PID, unique_id=self.pid_id) setpoint, ended = calculate_method_setpoint( self.method_id, PID, this_pid, Method, MethodData, self.logger) if ended: self.method_start_act = 'Ended' if setpoint is not None: self.setpoint = setpoint else: self.setpoint = self.default_setpoint self.write_setpoint_band() # Write variables to database # Calculate new control variable self.control_variable = self.update_pid_output( self.last_measurement) self.write_pid_values() # Write variables to database # Is PID in a state that allows manipulation of outputs if self.is_activated and (not self.is_paused or self.is_held): self.manipulate_output() def setup_method(self, method_id): """ Initialize method variables to start running a method """ self.method_id = '' method = db_retrieve_table_daemon(Method, unique_id=method_id) method_data = db_retrieve_table_daemon(MethodData) method_data = method_data.filter(MethodData.method_id == method_id) method_data_repeat = method_data.filter( MethodData.duration_sec == 0).first() pid = db_retrieve_table_daemon(PID, unique_id=self.pid_id) self.method_type = method.method_type self.method_start_act = pid.method_start_time self.method_start_time = None self.method_end_time = None if self.method_type == 'Duration': if self.method_start_act == 'Ended': # Method has ended and hasn't been instructed to begin again pass elif (self.method_start_act == 'Ready' or self.method_start_act is None): # Method has been instructed to begin now = datetime.datetime.now() self.method_start_time = now if method_data_repeat and method_data_repeat.duration_end: self.method_end_time = now + datetime.timedelta( seconds=float(method_data_repeat.duration_end)) with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.pid_id).first() mod_pid.method_start_time = self.method_start_time mod_pid.method_end_time = self.method_end_time db_session.commit() else: # Method neither instructed to begin or not to # Likely there was a daemon restart ot power failure # Resume method with saved start_time self.method_start_time = datetime.datetime.strptime( str(pid.method_start_time), '%Y-%m-%d %H:%M:%S.%f') if method_data_repeat and method_data_repeat.duration_end: self.method_end_time = datetime.datetime.strptime( str(pid.method_end_time), '%Y-%m-%d %H:%M:%S.%f') if self.method_end_time > datetime.datetime.now(): self.logger.warning( "Resuming method {id}: started {start}, " "ends {end}".format(id=method_id, start=self.method_start_time, end=self.method_end_time)) else: self.method_start_act = 'Ended' else: self.method_start_act = 'Ended' self.method_id = method_id def write_setpoint_band(self): """ Write setpoint and band values to measurement database """ write_setpoint_db = threading.Thread(target=write_influxdb_value, args=( self.pid_id, 'setpoint', self.setpoint, )) write_setpoint_db.start() if self.band: band_min = self.setpoint - self.band write_setpoint_db = threading.Thread(target=write_influxdb_value, args=( self.pid_id, 'setpoint_band_min', band_min, )) write_setpoint_db.start() band_max = self.setpoint + self.band write_setpoint_db = threading.Thread(target=write_influxdb_value, args=( self.pid_id, 'setpoint_band_max', band_max, )) write_setpoint_db.start() def write_pid_values(self): """ Write p, i, and d values to the measurement database """ write_setpoint_db = threading.Thread(target=write_influxdb_value, args=( self.pid_id, 'pid_p_value', self.P_value, )) write_setpoint_db.start() write_setpoint_db = threading.Thread(target=write_influxdb_value, args=( self.pid_id, 'pid_i_value', self.I_value, )) write_setpoint_db.start() write_setpoint_db = threading.Thread(target=write_influxdb_value, args=( self.pid_id, 'pid_d_value', self.D_value, )) write_setpoint_db.start() def update_pid_output(self, current_value): """ Calculate PID output value from reference input and feedback :return: Manipulated, or control, variable. This is the PID output. :rtype: float :param current_value: The input, or process, variable (the actual measured condition by the input) :type current_value: float """ # Determine if hysteresis is enabled and if the PID should be applied setpoint = self.check_hysteresis(current_value) if setpoint is None: # Prevent PID variables form being manipulated and # restrict PID from operating. return 0 self.error = setpoint - current_value # Calculate P-value self.P_value = self.Kp * self.error # Calculate I-value self.integrator += self.error # First method for managing integrator if self.integrator > self.integrator_max: self.integrator = self.integrator_max elif self.integrator < self.integrator_min: self.integrator = self.integrator_min # Second method for regulating integrator # if self.period is not None: # if self.integrator * self.Ki > self.period: # self.integrator = self.period / self.Ki # elif self.integrator * self.Ki < -self.period: # self.integrator = -self.period / self.Ki self.I_value = self.integrator * self.Ki # Prevent large initial D-value if self.first_start: self.derivator = self.error self.first_start = False # Calculate D-value self.D_value = self.Kd * (self.error - self.derivator) self.derivator = self.error # Produce output form P, I, and D values pid_value = self.P_value + self.I_value + self.D_value return pid_value def check_hysteresis(self, measure): """ Determine if hysteresis is enabled and if the PID should be applied :return: float if the setpoint if the PID should be applied, None to restrict the PID :rtype: float or None :param measure: The PID input (or process) variable :type measure: float """ if self.band == 0: # If band is disabled, return setpoint return self.setpoint band_min = self.setpoint - self.band band_max = self.setpoint + self.band if self.direction == 'raise': if (measure < band_min or (band_min < measure < band_max and self.allow_raising)): self.allow_raising = True setpoint = band_max # New setpoint return setpoint # Apply the PID elif measure > band_max: self.allow_raising = False return None # Restrict the PID elif self.direction == 'lower': if (measure > band_max or (band_min < measure < band_max and self.allow_lowering)): self.allow_lowering = True setpoint = band_min # New setpoint return setpoint # Apply the PID elif measure < band_min: self.allow_lowering = False return None # Restrict the PID elif self.direction == 'both': if measure < band_min: setpoint = band_min # New setpoint if not self.allow_raising: # Reset integrator and derivator upon direction switch self.integrator = 0.0 self.derivator = 0.0 self.allow_raising = True self.allow_lowering = False elif measure > band_max: setpoint = band_max # New setpoint if not self.allow_lowering: # Reset integrator and derivator upon direction switch self.integrator = 0.0 self.derivator = 0.0 self.allow_raising = False self.allow_lowering = True else: return None # Restrict the PID return setpoint # Apply the PID def get_last_measurement(self): """ Retrieve the latest input measurement from InfluxDB :rtype: None """ self.last_measurement_success = False # Get latest measurement from influxdb try: self.last_measurement = read_last_influxdb( self.dev_unique_id, self.measurement, int(self.max_measure_age)) if self.last_measurement: self.last_time = self.last_measurement[0] self.last_measurement = self.last_measurement[1] utc_dt = datetime.datetime.strptime( self.last_time.split(".")[0], '%Y-%m-%dT%H:%M:%S') utc_timestamp = calendar.timegm(utc_dt.timetuple()) local_timestamp = str( datetime.datetime.fromtimestamp(utc_timestamp)) self.logger.debug("Latest {meas}: {last} @ {ts}".format( meas=self.measurement, last=self.last_measurement, ts=local_timestamp)) if calendar.timegm( time.gmtime()) - utc_timestamp > self.max_measure_age: self.logger.error( "Last measurement was {last_sec} seconds ago, however" " the maximum measurement age is set to {max_sec}" " seconds.".format( last_sec=calendar.timegm(time.gmtime()) - utc_timestamp, max_sec=self.max_measure_age)) self.last_measurement_success = True else: self.logger.warning("No data returned from influxdb") except requests.ConnectionError: self.logger.error("Failed to read measurement from the " "influxdb database: Could not connect.") except Exception as except_msg: self.logger.exception( "Exception while reading measurement from the influxdb " "database: {err}".format(err=except_msg)) def manipulate_output(self): """ Activate output based on PID control variable and whether the manipulation directive is to raise, lower, or both. :rtype: None """ # If the last measurement was able to be retrieved and was entered within the past minute if self.last_measurement_success: # # PID control variable is positive, indicating a desire to raise # the environmental condition # if self.direction in ['raise', 'both'] and self.raise_output_id: if self.control_variable > 0: # Determine if the output should be PWM or a duration if self.raise_output_type in ['pwm', 'command_pwm']: self.raise_duty_cycle = float("{0:.1f}".format( self.control_var_to_duty_cycle( self.control_variable))) # Ensure the duty cycle doesn't exceed the min/max if (self.raise_max_duration and self.raise_duty_cycle > self.raise_max_duration): self.raise_duty_cycle = self.raise_max_duration elif (self.raise_min_duration and self.raise_duty_cycle < self.raise_min_duration): self.raise_duty_cycle = self.raise_min_duration self.logger.debug( "Setpoint: {sp}, Control Variable: {cv}, Output: PWM output " "{id} to {dc:.1f}%".format( sp=self.setpoint, cv=self.control_variable, id=self.raise_output_id, dc=self.raise_duty_cycle)) # Activate pwm with calculated duty cycle self.control.output_on( self.raise_output_id, duty_cycle=self.raise_duty_cycle) self.write_pid_output_influxdb( 'duty_cycle', self.control_var_to_duty_cycle( self.control_variable)) elif self.raise_output_type in [ 'command', 'wired', 'wireless_433MHz_pi_switch' ]: # Ensure the output on duration doesn't exceed the set maximum if (self.raise_max_duration and self.control_variable > self.raise_max_duration): self.raise_seconds_on = self.raise_max_duration else: self.raise_seconds_on = float("{0:.2f}".format( self.control_variable)) if self.raise_seconds_on > self.raise_min_duration: # Activate raise_output for a duration self.logger.debug( "Setpoint: {sp} Output: {cv} to output " "{id}".format(sp=self.setpoint, cv=self.control_variable, id=self.raise_output_id)) self.control.output_on( self.raise_output_id, duration=self.raise_seconds_on, min_off=self.raise_min_off_duration) self.write_pid_output_influxdb('duration_sec', self.control_variable) else: if self.raise_output_type in ['pwm', 'command_pwm']: self.control.output_on(self.raise_output_id, duty_cycle=0) # # PID control variable is negative, indicating a desire to lower # the environmental condition # if self.direction in ['lower', 'both'] and self.lower_output_id: if self.control_variable < 0: # Determine if the output should be PWM or a duration if self.lower_output_type in ['pwm', 'command_pwm']: self.lower_duty_cycle = float("{0:.1f}".format( self.control_var_to_duty_cycle( abs(self.control_variable)))) # Ensure the duty cycle doesn't exceed the min/max if (self.lower_max_duration and self.lower_duty_cycle > self.lower_max_duration): self.lower_duty_cycle = self.lower_max_duration elif (self.lower_min_duration and self.lower_duty_cycle < self.lower_min_duration): self.lower_duty_cycle = self.lower_min_duration self.logger.debug( "Setpoint: {sp}, Control Variable: {cv}, " "Output: PWM output {id} to {dc:.1f}%".format( sp=self.setpoint, cv=self.control_variable, id=self.lower_output_id, dc=self.lower_duty_cycle)) if self.store_lower_as_negative: stored_duty_cycle = -abs(self.lower_duty_cycle) stored_control_variable = -self.control_var_to_duty_cycle( abs(self.control_variable)) else: stored_duty_cycle = abs(self.lower_duty_cycle) stored_control_variable = self.control_var_to_duty_cycle( abs(self.control_variable)) # Activate pwm with calculated duty cycle self.control.output_on(self.lower_output_id, duty_cycle=stored_duty_cycle) self.write_pid_output_influxdb( 'duty_cycle', stored_control_variable) elif self.lower_output_type in [ 'command', 'wired', 'wireless_433MHz_pi_switch' ]: # Ensure the output on duration doesn't exceed the set maximum if (self.lower_max_duration and abs(self.control_variable) > self.lower_max_duration): self.lower_seconds_on = self.lower_max_duration else: self.lower_seconds_on = float("{0:.2f}".format( abs(self.control_variable))) if self.store_lower_as_negative: stored_seconds_on = -abs(self.lower_seconds_on) stored_control_variable = -abs( self.control_variable) else: stored_seconds_on = abs(self.lower_seconds_on) stored_control_variable = abs( self.control_variable) if self.lower_seconds_on > self.lower_min_duration: # Activate lower_output for a duration self.logger.debug("Setpoint: {sp} Output: {cv} to " "output {id}".format( sp=self.setpoint, cv=self.control_variable, id=self.lower_output_id)) self.control.output_on( self.lower_output_id, duration=stored_seconds_on, min_off=self.lower_min_off_duration) self.write_pid_output_influxdb( 'duration_sec', stored_control_variable) else: if self.lower_output_type in ['pwm', 'command_pwm']: self.control.output_on(self.lower_output_id, duty_cycle=0) else: if self.direction in ['raise', 'both'] and self.raise_output_id: self.control.output_off(self.raise_output_id) if self.direction in ['lower', 'both'] and self.lower_output_id: self.control.output_off(self.lower_output_id) def control_var_to_duty_cycle(self, control_variable): # Convert control variable to duty cycle if control_variable > self.period: return 100.0 else: return float((control_variable / self.period) * 100) def write_pid_output_influxdb(self, pid_entry_type, pid_entry_value): write_pid_out_db = threading.Thread(target=write_influxdb_value, args=( self.pid_id, pid_entry_type, pid_entry_value, )) write_pid_out_db.start() def pid_mod(self): if self.initialize_values(): return "success" else: return "error" def pid_hold(self): self.is_held = True self.logger.info("Hold") return "success" def pid_pause(self): self.is_paused = True self.logger.info("Pause") return "success" def pid_resume(self): self.is_activated = True self.is_held = False self.is_paused = False self.logger.info("Resume") return "success" def set_setpoint(self, setpoint): """ Set the setpoint of PID """ self.setpoint = float(setpoint) with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.pid_id).first() mod_pid.setpoint = setpoint db_session.commit() return "Setpoint set to {sp}".format(sp=setpoint) def set_method(self, method_id): """ Set the method of PID """ with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.pid_id).first() mod_pid.method_id = method_id if method_id == '': self.method_id = '' db_session.commit() else: mod_pid.method_start_time = 'Ready' mod_pid.method_end_time = None db_session.commit() self.setup_method(method_id) return "Method set to {me}".format(me=method_id) def set_integrator(self, integrator): """ Set the integrator of the controller """ self.integrator = float(integrator) return "Integrator set to {i}".format(i=self.integrator) def set_derivator(self, derivator): """ Set the derivator of the controller """ self.derivator = float(derivator) return "Derivator set to {d}".format(d=self.derivator) def set_kp(self, p): """ Set Kp gain of the controller """ self.Kp = float(p) with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.pid_id).first() mod_pid.p = p db_session.commit() return "Kp set to {kp}".format(kp=self.Kp) def set_ki(self, i): """ Set Ki gain of the controller """ self.Ki = float(i) with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.pid_id).first() mod_pid.i = i db_session.commit() return "Ki set to {ki}".format(ki=self.Ki) def set_kd(self, d): """ Set Kd gain of the controller """ self.Kd = float(d) with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.pid_id).first() mod_pid.d = d db_session.commit() return "Kd set to {kd}".format(kd=self.Kd) def get_setpoint(self): return self.setpoint def get_error(self): return self.error def get_integrator(self): return self.integrator def get_derivator(self): return self.derivator def get_kp(self): return self.Kp def get_ki(self): return self.Ki def get_kd(self): return self.Kd def is_running(self): return self.running def stop_controller(self, ended_normally=True, deactivate_pid=False): self.thread_shutdown_timer = timeit.default_timer() self.running = False # Unset method start time if self.method_id != '' and ended_normally: with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.pid_id).first() mod_pid.method_start_time = 'Ended' mod_pid.method_end_time = None db_session.commit() if deactivate_pid: with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.pid_id).first() mod_pid.is_activated = False db_session.commit()
class CustomModule(AbstractFunction): """ Class to operate custom controller """ def __init__(self, function, testing=False): super(CustomModule, self).__init__(function, testing=testing, name=__name__) self.control = DaemonControl() # # Initialize what you defined in custom_options, above # # Standard custom options inherit the name you defined in the "id" key self.text_1 = None self.integer_1 = None self.float_1 = None self.bool_1 = None self.select_1 = None # Custom options of type "select_measurement" require creating two variables and adding "_device_id" # and "_measurement_id" after the name self.select_measurement_1_device_id = None self.select_measurement_1_measurement_id = None # Custom options of type "select_measurement_channel" require three variables and adding # "device_id", "measurement_id", and "channel_id" after the name self.output_1_device_id = None self.output_1_measurement_id = None self.output_1_channel_id = None # Custom options of type "select_device" require adding "_id" after the name self.select_device_1_id = None self.select_device_2_id = None # # Set custom options # custom_function = db_retrieve_table_daemon(CustomController, unique_id=self.unique_id) self.setup_custom_options(FUNCTION_INFORMATION['custom_options'], custom_function) # Get selected output channel number self.output_1_channel = self.get_output_channel_from_channel_id( self.output_1_channel_id) if not testing: self.initialize_variables() def initialize_variables(self): # import controller-specific modules here # You may import something you defined in dependencies_module pass def run(self): try: self.running = True # This log line will appear in the Daemon log under Config -> Mycodo Logs self.logger.info("Function running") # Make sure the option "Log Level: Debug" is enabled for these debug # log lines to appear in the Daemon log. self.logger.debug( "Custom controller started with options: " "{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}".format( self.text_1, self.integer_1, self.float_1, self.bool_1, self.select_1, self.select_measurement_1_device_id, self.select_measurement_1_measurement_id, self.output_1_device_id, self.output_1_measurement_id, self.output_1_channel_id, self.select_device_1_id)) # You can specify different log levels to indicate things such as errors self.logger.error( "This is an error line that will appear in the log") # And Warnings self.logger.warning( "This is a warning line that will appear in the log") # Get last measurement for select_measurement_1 last_measurement = self.get_last_measurement( self.select_measurement_1_device_id, self.select_measurement_1_measurement_id) if last_measurement: self.logger.debug( "Most recent timestamp and measurement for " "select_measurement_1: {timestamp}, {meas}".format( timestamp=last_measurement[0], meas=last_measurement[1])) else: self.logger.debug( "Could not find a measurement in the database for " "select_measurement_1 device ID {} and measurement " "ID {}".format(self.select_measurement_1_device_id, self.select_measurement_1_measurement_id)) # Turn Output select_device_1 on for 15 seconds self.logger.debug( "Turning select_device_1 with ID {} on for 15 seconds...". format(self.select_device_1_id)) self.control.output_on(self.select_device_1_id, output_type='sec', output_channel=self.output_1_channel, amount=15) # Deactivate controller in the SQL database self.logger.debug( "Deactivating (SQL) Custom controller select_device_2 with ID {}" .format(self.select_device_2_id)) from mycodo.databases.utils import session_scope from mycodo.config import SQL_DATABASE_MYCODO MYCODO_DB_PATH = 'sqlite:///' + SQL_DATABASE_MYCODO with session_scope(MYCODO_DB_PATH) as new_session: mod_cont = new_session.query(CustomController).filter( CustomController.unique_id == self.select_device_2_id).first() mod_cont.is_activated = False new_session.commit() # Deactivate select_device_1_id in the dameon # Since we're deactivating this controller (itself), we need to thread this command # Note: this command will only deactivate the controller in the Daemon. It will still # be activated in the database, so the next restart of the daemon, this controller # will start back up again. This is why the previous action deactivated the controller # in the database prior to deactivating it in the daemon. self.logger.debug( "Deactivating (Daemon) Custom controller select_device_2 with" " ID {} ...".format(self.select_device_2_id)) deactivate_controller = threading.Thread( target=self.control.controller_deactivate, args=(self.select_device_2_id, )) deactivate_controller.start() # Start a loop while self.running: time.sleep(1) except: self.logger.exception("Run Error") finally: self.running = False self.logger.error("Deactivated unexpectedly") def loop(self): pass def button_one(self, args_dict): self.logger.error("Button One Pressed!: {}".format( int(args_dict['button_one_value']))) return "Here return message will be seen in the web UI. " \ "This only works when 'wait_for_return' is set True." def button_two(self, args_dict): self.logger.error("Button Two Pressed!: {}".format( int(args_dict['button_two_value']))) return "This message will never be seen in the web UI because this process is threaded"
class InputModule(AbstractInput): """ A sensor support class that measures the AM2315's humidity and temperature and calculates the dew point """ def __init__(self, input_dev, testing=False): super(InputModule, self).__init__() self.setup_logger(testing=testing, name=__name__, input_dev=input_dev) self.powered = False self.am = None if not testing: from mycodo.mycodo_client import DaemonControl self.device_measurements = db_retrieve_table_daemon( DeviceMeasurements).filter( DeviceMeasurements.device_id == input_dev.unique_id) self.i2c_bus = input_dev.i2c_bus self.power_output_id = input_dev.power_output_id self.control = DaemonControl() self.start_sensor() self.am = AM2315(self.i2c_bus) def get_measurement(self): """ Gets the humidity and temperature """ self.return_dict = measurements_dict.copy() temperature = None humidity = None dew_point = None measurements_success = False # Ensure if the power pin turns off, it is turned back on if (self.power_output_id and db_retrieve_table_daemon( Output, unique_id=self.power_output_id) and self.control.output_state(self.power_output_id) == 'off'): self.logger.error( 'Sensor power output {rel} detected as being off. ' 'Turning on.'.format(rel=self.power_output_id)) self.start_sensor() time.sleep(2) # Try twice to get measurement. This prevents an anomaly where # the first measurement fails if the sensor has just been powered # for the first time. for _ in range(2): dew_point, humidity, temperature = self.return_measurements() if dew_point is not None: measurements_success = True break time.sleep(2) # Measurement failure, power cycle the sensor (if enabled) # Then try two more times to get a measurement if self.power_output_id and not measurements_success: self.stop_sensor() time.sleep(2) self.start_sensor() for _ in range(2): dew_point, humidity, temperature = self.return_measurements() if dew_point is not None: measurements_success = True break time.sleep(2) if measurements_success: if self.is_enabled(0): self.set_value(0, temperature) if self.is_enabled(1): self.set_value(1, humidity) if (self.is_enabled(2) and self.is_enabled(0) and self.is_enabled(1)): self.set_value( 2, calculate_dewpoint(self.get_value(0), self.get_value(1))) if (self.is_enabled(3) and self.is_enabled(0) and self.is_enabled(1)): self.set_value( 3, calculate_vapor_pressure_deficit(self.get_value(0), self.get_value(1))) return self.return_dict else: self.logger.debug("Could not acquire a measurement") def return_measurements(self): # Retry measurement if CRC fails for num_measure in range(3): humidity, temperature = self.am.data() if humidity is None: self.logger.debug( "Measurement {num} returned failed CRC".format( num=num_measure)) pass else: dew_pt = calculate_dewpoint(temperature, humidity) return dew_pt, humidity, temperature time.sleep(2) self.logger.error("All measurements returned failed CRC") return None, None, None def start_sensor(self): """ Turn the sensor on """ if self.power_output_id: self.logger.info("Turning on sensor") self.control.output_on(self.power_output_id, 0) time.sleep(2) self.powered = True def stop_sensor(self): """ Turn the sensor off """ if self.power_output_id: self.logger.info("Turning off sensor") self.control.output_off(self.power_output_id) self.powered = False
class InputModule(AbstractInput): """ A sensor support class that measures the DHT11's humidity and temperature and calculates the dew point The DHT11 class is a stripped version of the DHT22 sensor code by joan2937. You can find the initial implementation here: - https://github.com/srounet/pigpio/tree/master/EXAMPLES/Python/DHT22_AM2302_SENSOR """ def __init__(self, input_dev, testing=False): """ :param gpio: gpio pin number :type gpio: int :param power: Power pin number :type power: int Instantiate with the Pi and gpio to which the DHT11 output pin is connected. Optionally a gpio used to power the sensor may be specified. This gpio will be set high to power the sensor. """ super(InputModule, self).__init__(input_dev, testing=testing, name=__name__) self.pi = None self.pigpio = None self.control = None self.temp_temperature = 0 self.temp_humidity = 0 self.temp_dew_point = None self.temp_vpd = None self.power_output_id = None self.powered = False if not testing: self.initialize_input() def initialize_input(self): import pigpio from mycodo.mycodo_client import DaemonControl self.gpio = int(self.input_dev.gpio_location) self.power_output_id = self.input_dev.power_output_id self.control = DaemonControl() self.pigpio = pigpio self.pi = self.pigpio.pi() self.high_tick = None self.bit = None self.either_edge_cb = None self.start_input() def get_measurement(self): """ Gets the humidity and temperature """ if not self.pi.connected: # Check if pigpiod is running self.logger.error("Could not connect to pigpiod. Ensure it is running and try again.") return None self.return_dict = copy.deepcopy(measurements_dict) import pigpio self.pigpio = pigpio # Ensure if the power pin turns off, it is turned back on if (self.power_output_id and db_retrieve_table_daemon(Output, unique_id=self.power_output_id) and self.control.output_state(self.power_output_id) == 'off'): self.logger.error( 'Sensor power output {rel} detected as being off. Turning on.'.format(rel=self.power_output_id)) self.start_input() time.sleep(2) # Try twice to get measurement. This prevents an anomaly where # the first measurement fails if the sensor has just been powered # for the first time. for _ in range(2): self.measure_sensor() if self.temp_dew_point is not None: self.value_set(0, self.temp_temperature) self.value_set(1, self.temp_humidity) self.value_set(2, self.temp_dew_point) self.value_set(3, self.temp_vpd) return self.return_dict # success - no errors time.sleep(2) # Measurement failure, power cycle the sensor (if enabled) # Then try two more times to get a measurement if self.power_output_id is not None and self.running: self.stop_input() time.sleep(2) self.start_input() for _ in range(2): self.measure_sensor() if self.temp_dew_point is not None: self.value_set(0, self.temp_temperature) self.value_set(1, self.temp_humidity) self.value_set(2, self.temp_dew_point) self.value_set(3, self.temp_vpd) return self.return_dict # success - no errors time.sleep(2) self.logger.error("Could not acquire a measurement") return None def measure_sensor(self): self.temp_temperature = 0 self.temp_humidity = 0 self.temp_dew_point = None self.temp_vpd = None try: try: self.setup() except Exception as except_msg: self.logger.error( 'Could not initialize sensor. Check if gpiod is running. Error: {msg}'.format(msg=except_msg)) self.pi.write(self.gpio, self.pigpio.LOW) time.sleep(0.017) # 17 ms self.pi.set_mode(self.gpio, self.pigpio.INPUT) self.pi.set_watchdog(self.gpio, 200) time.sleep(0.2) if self.temp_humidity != 0: self.temp_dew_point = calculate_dewpoint(self.temp_temperature, self.temp_humidity) self.temp_vpd = calculate_vapor_pressure_deficit(self.temp_temperature, self.temp_humidity) except Exception as e: self.logger.error("Exception raised when taking a reading: {err}".format(err=e)) finally: self.close() return (self.temp_dew_point, self.temp_humidity, self.temp_temperature) def setup(self): """ Clears the internal gpio pull-up/down resistor. Kills any watchdogs. Setup callbacks """ self.high_tick = 0 self.bit = 40 self.either_edge_cb = None self.pi.set_pull_up_down(self.gpio, self.pigpio.PUD_OFF) self.pi.set_watchdog(self.gpio, 0) self.register_callbacks() def register_callbacks(self): """ Monitors RISING_EDGE changes using callback """ self.either_edge_cb = self.pi.callback( self.gpio, self.pigpio.EITHER_EDGE, self.either_edge_callback) def either_edge_callback(self, gpio, level, tick): """ Either Edge callbacks, called each time the gpio edge changes. Accumulate the 40 data bits from the DHT11 sensor. """ level_handlers = { self.pigpio.FALLING_EDGE: self._edge_fall, self.pigpio.RISING_EDGE: self._edge_rise, self.pigpio.EITHER_EDGE: self._edge_either } handler = level_handlers[level] diff = self.pigpio.tickDiff(self.high_tick, tick) handler(tick, diff) def _edge_rise(self, tick, diff): """ Handle Rise signal """ val = 0 if diff >= 50: val = 1 if diff >= 200: # Bad bit? self.checksum = 256 # Force bad checksum if self.bit >= 40: # Message complete self.bit = 40 elif self.bit >= 32: # In checksum byte self.checksum = (self.checksum << 1) + val if self.bit == 39: # 40th bit received self.pi.set_watchdog(self.gpio, 0) total = self.temp_humidity + self.temp_temperature # is checksum ok ? if not (total & 255) == self.checksum: # For some reason the port from python 2 to python 3 causes # this bad checksum error to happen during every read # TODO: Investigate how to properly check the checksum in python 3 self.logger.debug("Exception raised when taking a reading: Bad Checksum.") elif 16 <= self.bit < 24: # in temperature byte self.temp_temperature = (self.temp_temperature << 1) + val elif 0 <= self.bit < 8: # in humidity byte self.temp_humidity = (self.temp_humidity << 1) + val self.bit += 1 def _edge_fall(self, tick, diff): """ Handle Fall signal """ self.high_tick = tick if diff <= 250000: return self.bit = -2 self.checksum = 0 self.temp_temperature = 0 self.temp_humidity = 0 def _edge_either(self, tick, diff): """ Handle Either signal """ self.pi.set_watchdog(self.gpio, 0) def close(self): """ Stop reading sensor, remove callbacks """ self.pi.set_watchdog(self.gpio, 0) if self.either_edge_cb: self.either_edge_cb.cancel() self.either_edge_cb = None def start_input(self): """ Power the sensor """ if self.power_output_id: self.logger.info("Turning on sensor") self.control.output_on( self.power_output_id, 0) time.sleep(2) self.powered = True def stop_input(self): """ Depower the sensor """ if self.power_output_id: self.logger.info("Turning off sensor") self.control.output_off(self.power_output_id) self.powered = False
class PIDController(AbstractController, threading.Thread): """ Class to operate discrete PID controller in Mycodo """ def __init__(self, ready, unique_id): threading.Thread.__init__(self) super(PIDController, self).__init__(ready, unique_id=unique_id, name=__name__) self.unique_id = unique_id self.sample_rate = None self.control = DaemonControl() self.device_measurements = None self.device_id = None self.measurement_id = None self.raise_output_type = None self.lower_output_type = None self.log_level_debug = None self.PID_Controller = None self.control_variable = 0.0 self.derivator = 0.0 self.integrator = 0.0 self.error = 0.0 self.P_value = None self.I_value = None self.D_value = None self.lower_seconds_on = 0.0 self.raise_seconds_on = 0.0 self.lower_duty_cycle = 0.0 self.raise_duty_cycle = 0.0 self.last_time = None self.last_measurement = None self.last_measurement_success = False self.is_activated = None self.is_held = None self.is_paused = None self.measurement = None self.method_id = None self.direction = None self.raise_output_id = None self.raise_min_duration = None self.raise_max_duration = None self.raise_min_off_duration = None self.lower_output_id = None self.lower_min_duration = None self.lower_max_duration = None self.lower_min_off_duration = None self.Kp = 0 self.Ki = 0 self.Kd = 0 self.integrator_min = None self.integrator_max = None self.period = 0 self.start_offset = 0 self.max_measure_age = None self.default_setpoint = None self.setpoint = 0 self.store_lower_as_negative = None self.first_start = None self.timer = None # Hysteresis options self.band = None self.allow_raising = False self.allow_lowering = False # PID Autotune self.autotune = None self.autotune_activated = False self.autotune_debug = False self.autotune_noiseband = 0 self.autotune_outstep = 0 self.autotune_timestamp = None # Check if a method is set for this PID self.method_type = None self.method_start_act = None self.method_start_time = None self.method_end_time = None def loop(self): if (self.method_start_act == 'Ended' and self.method_type == 'Duration'): self.stop_controller(ended_normally=False, deactivate_pid=True) self.logger.warning( "Method has ended. " "Activate the PID controller to start it again.") elif time.time() > self.timer: while time.time() > self.timer: self.timer = self.timer + self.period self.attempt_execute(self.check_pid) def run_finally(self): # Turn off output used in PID when the controller is deactivated if self.raise_output_id and self.direction in ['raise', 'both']: self.control.output_off(self.raise_output_id, trigger_conditionals=True) if self.lower_output_id and self.direction in ['lower', 'both']: self.control.output_off(self.lower_output_id, trigger_conditionals=True) def initialize_variables(self): """Set PID parameters""" self.sample_rate = db_retrieve_table_daemon( Misc, entry='first').sample_rate_controller_pid self.device_measurements = db_retrieve_table_daemon(DeviceMeasurements) pid = db_retrieve_table_daemon(PID, unique_id=self.unique_id) self.device_id = pid.measurement.split(',')[0] self.measurement_id = pid.measurement.split(',')[1] self.is_activated = pid.is_activated self.is_held = pid.is_held self.is_paused = pid.is_paused self.log_level_debug = pid.log_level_debug self.method_id = pid.method_id self.direction = pid.direction self.raise_output_id = pid.raise_output_id self.raise_min_duration = pid.raise_min_duration self.raise_max_duration = pid.raise_max_duration self.raise_min_off_duration = pid.raise_min_off_duration self.lower_output_id = pid.lower_output_id self.lower_min_duration = pid.lower_min_duration self.lower_max_duration = pid.lower_max_duration self.lower_min_off_duration = pid.lower_min_off_duration self.Kp = pid.p self.Ki = pid.i self.Kd = pid.d self.integrator_min = pid.integrator_min self.integrator_max = pid.integrator_max self.period = pid.period self.start_offset = pid.start_offset self.max_measure_age = pid.max_measure_age self.default_setpoint = pid.setpoint self.setpoint = pid.setpoint self.band = pid.band self.store_lower_as_negative = pid.store_lower_as_negative self.first_start = True self.timer = time.time() + self.start_offset # Autotune self.autotune_activated = pid.autotune_activated self.autotune_noiseband = pid.autotune_noiseband self.autotune_outstep = pid.autotune_outstep self.set_log_level_debug(self.log_level_debug) try: self.raise_output_type = db_retrieve_table_daemon( Output, unique_id=self.raise_output_id).output_type except AttributeError: self.raise_output_type = None try: self.lower_output_type = db_retrieve_table_daemon( Output, unique_id=self.lower_output_id).output_type except AttributeError: self.lower_output_type = None # Initialize PID Controller self.PID_Controller = PIDControl(self.period, self.Kp, self.Ki, self.Kd, integrator_min=self.integrator_min, integrator_max=self.integrator_max) # If activated, initialize PID Autotune if self.autotune_activated: self.autotune_timestamp = time.time() try: self.autotune = PIDAutotune(self.setpoint, out_step=self.autotune_outstep, sampletime=self.period, out_min=0, out_max=self.period, noiseband=self.autotune_noiseband) except Exception as msg: self.logger.error(msg) self.stop_controller(deactivate_pid=True) if self.method_id != '': self.setup_method(self.method_id) if self.is_paused: self.logger.info("Starting Paused") elif self.is_held: self.logger.info("Starting Held") self.logger.info("PID Settings: {}".format(self.pid_parameters_str())) return "success" def check_pid(self): """ Get measurement and apply to PID controller """ # If PID is active, retrieve measurement and update # the control variable. # A PID on hold will sustain the current output and # not update the control variable. if self.is_activated and (not self.is_paused or not self.is_held): self.get_last_measurement() if self.last_measurement_success: if self.method_id != '': # Update setpoint using a method this_pid = db_retrieve_table_daemon( PID, unique_id=self.unique_id) setpoint, ended = calculate_method_setpoint( self.method_id, PID, this_pid, Method, MethodData, self.logger) if ended: self.method_start_act = 'Ended' if setpoint is not None: self.setpoint = setpoint else: self.setpoint = self.default_setpoint # If autotune activated, determine control variable (output) from autotune if self.autotune_activated: if not self.autotune.run(self.last_measurement): self.control_variable = self.autotune.output if self.autotune_debug: self.logger.info('') self.logger.info("state: {}".format( self.autotune.state)) self.logger.info("output: {}".format( self.autotune.output)) else: # Autotune has finished timestamp = time.time() - self.autotune_timestamp self.logger.info('') self.logger.info('time: {0} min'.format( round(timestamp / 60))) self.logger.info('state: {0}'.format( self.autotune.state)) if self.autotune.state == PIDAutotune.STATE_SUCCEEDED: for rule in self.autotune.tuning_rules: params = self.autotune.get_pid_parameters(rule) self.logger.info('') self.logger.info('rule: {0}'.format(rule)) self.logger.info('Kp: {0}'.format(params.Kp)) self.logger.info('Ki: {0}'.format(params.Ki)) self.logger.info('Kd: {0}'.format(params.Kd)) self.stop_controller(deactivate_pid=True) else: # Calculate new control variable (output) from PID Controller # Original PID method self.control_variable = self.update_pid_output( self.last_measurement) # New PID method (untested) # self.control_variable = self.PID_Controller.calc( # self.last_measurement, self.setpoint) self.write_pid_values() # Write variables to database # Is PID in a state that allows manipulation of outputs if self.is_activated and (not self.is_paused or self.is_held): self.manipulate_output() def setup_method(self, method_id): """ Initialize method variables to start running a method """ self.method_id = '' method = db_retrieve_table_daemon(Method, unique_id=method_id) method_data = db_retrieve_table_daemon(MethodData) method_data = method_data.filter(MethodData.method_id == method_id) method_data_repeat = method_data.filter( MethodData.duration_sec == 0).first() pid = db_retrieve_table_daemon(PID, unique_id=self.unique_id) self.method_type = method.method_type self.method_start_act = pid.method_start_time self.method_start_time = None self.method_end_time = None if self.method_type == 'Duration': if self.method_start_act == 'Ended': # Method has ended and hasn't been instructed to begin again pass elif (self.method_start_act == 'Ready' or self.method_start_act is None): # Method has been instructed to begin now = datetime.datetime.now() self.method_start_time = now if method_data_repeat and method_data_repeat.duration_end: self.method_end_time = now + datetime.timedelta( seconds=float(method_data_repeat.duration_end)) with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.unique_id).first() mod_pid.method_start_time = self.method_start_time mod_pid.method_end_time = self.method_end_time db_session.commit() else: # Method neither instructed to begin or not to # Likely there was a daemon restart ot power failure # Resume method with saved start_time self.method_start_time = datetime.datetime.strptime( str(pid.method_start_time), '%Y-%m-%d %H:%M:%S.%f') if method_data_repeat and method_data_repeat.duration_end: self.method_end_time = datetime.datetime.strptime( str(pid.method_end_time), '%Y-%m-%d %H:%M:%S.%f') if self.method_end_time > datetime.datetime.now(): self.logger.warning( "Resuming method {id}: started {start}, " "ends {end}".format(id=method_id, start=self.method_start_time, end=self.method_end_time)) else: self.method_start_act = 'Ended' else: self.method_start_act = 'Ended' self.method_id = method_id self.logger.debug("Method enabled: {id}".format(id=self.method_id)) def write_pid_values(self): """ Write PID values to the measurement database """ if self.band: setpoint_band_lower = self.setpoint - self.band setpoint_band_upper = self.setpoint + self.band else: setpoint_band_lower = None setpoint_band_upper = None list_measurements = [ self.setpoint, setpoint_band_lower, setpoint_band_upper, self.P_value, self.I_value, self.D_value ] measurement_dict = {} measurements = self.device_measurements.filter( DeviceMeasurements.device_id == self.unique_id).all() for each_channel, each_measurement in enumerate(measurements): if (each_measurement.channel not in measurement_dict and each_measurement.channel < len(list_measurements)): # If setpoint, get unit from PID measurement if each_measurement.measurement_type == 'setpoint': setpoint_pid = db_retrieve_table_daemon( PID, unique_id=each_measurement.device_id) if setpoint_pid and ',' in setpoint_pid.measurement: pid_measurement = setpoint_pid.measurement.split( ',')[1] setpoint_measurement = db_retrieve_table_daemon( DeviceMeasurements, unique_id=pid_measurement) if setpoint_measurement: conversion = db_retrieve_table_daemon( Conversion, unique_id=setpoint_measurement.conversion_id) _, unit, _ = return_measurement_info( setpoint_measurement, conversion) measurement_dict[each_channel] = { 'measurement': each_measurement.measurement, 'unit': unit, 'value': list_measurements[each_channel] } else: measurement_dict[each_channel] = { 'measurement': each_measurement.measurement, 'unit': each_measurement.unit, 'value': list_measurements[each_channel] } add_measurements_influxdb(self.unique_id, measurement_dict) def update_pid_output(self, current_value): """ Calculate PID output value from reference input and feedback :return: Manipulated, or control, variable. This is the PID output. :rtype: float :param current_value: The input, or process, variable (the actual measured condition by the input) :type current_value: float """ # Determine if hysteresis is enabled and if the PID should be applied setpoint = self.check_hysteresis(current_value) if setpoint is None: # Prevent PID variables form being manipulated and # restrict PID from operating. return 0 self.error = setpoint - current_value # Calculate P-value self.P_value = self.Kp * self.error # Calculate I-value self.integrator += self.error # First method for managing integrator if self.integrator > self.integrator_max: self.integrator = self.integrator_max elif self.integrator < self.integrator_min: self.integrator = self.integrator_min # Second method for regulating integrator # if self.period is not None: # if self.integrator * self.Ki > self.period: # self.integrator = self.period / self.Ki # elif self.integrator * self.Ki < -self.period: # self.integrator = -self.period / self.Ki self.I_value = self.integrator * self.Ki # Prevent large initial D-value if self.first_start: self.derivator = self.error self.first_start = False # Calculate D-value self.D_value = self.Kd * (self.error - self.derivator) self.derivator = self.error # Produce output form P, I, and D values pid_value = self.P_value + self.I_value + self.D_value self.logger.debug("PID: Input: {inp}," "Output: P: {p}, I: {i}, D: {d}, Out: {o}".format( inp=current_value, p=self.P_value, i=self.I_value, d=self.D_value, o=pid_value)) return pid_value def check_hysteresis(self, measure): """ Determine if hysteresis is enabled and if the PID should be applied :return: float if the setpoint if the PID should be applied, None to restrict the PID :rtype: float or None :param measure: The PID input (or process) variable :type measure: float """ if self.band == 0: # If band is disabled, return setpoint return self.setpoint band_min = self.setpoint - self.band band_max = self.setpoint + self.band if self.direction == 'raise': if (measure < band_min or (band_min < measure < band_max and self.allow_raising)): self.allow_raising = True setpoint = band_max # New setpoint return setpoint # Apply the PID elif measure > band_max: self.allow_raising = False return None # Restrict the PID elif self.direction == 'lower': if (measure > band_max or (band_min < measure < band_max and self.allow_lowering)): self.allow_lowering = True setpoint = band_min # New setpoint return setpoint # Apply the PID elif measure < band_min: self.allow_lowering = False return None # Restrict the PID elif self.direction == 'both': if measure < band_min: setpoint = band_min # New setpoint if not self.allow_raising: # Reset integrator and derivator upon direction switch self.integrator = 0.0 self.derivator = 0.0 self.allow_raising = True self.allow_lowering = False elif measure > band_max: setpoint = band_max # New setpoint if not self.allow_lowering: # Reset integrator and derivator upon direction switch self.integrator = 0.0 self.derivator = 0.0 self.allow_raising = False self.allow_lowering = True else: return None # Restrict the PID return setpoint # Apply the PID def get_last_measurement(self): """ Retrieve the latest input measurement from InfluxDB :rtype: None """ self.last_measurement_success = False # Get latest measurement from influxdb try: device_measurement = get_measurement(self.measurement_id) if device_measurement: conversion = db_retrieve_table_daemon( Conversion, unique_id=device_measurement.conversion_id) else: conversion = None channel, unit, measurement = return_measurement_info( device_measurement, conversion) self.last_measurement = read_last_influxdb( self.device_id, unit, measurement, channel, int(self.max_measure_age)) if self.last_measurement: self.last_time = self.last_measurement[0] self.last_measurement = self.last_measurement[1] utc_dt = datetime.datetime.strptime( self.last_time.split(".")[0], '%Y-%m-%dT%H:%M:%S') utc_timestamp = calendar.timegm(utc_dt.timetuple()) local_timestamp = str( datetime.datetime.fromtimestamp(utc_timestamp)) self.logger.debug( "Latest (CH{ch}, Unit: {unit}): {last} @ {ts}".format( ch=channel, unit=unit, last=self.last_measurement, ts=local_timestamp)) if calendar.timegm( time.gmtime()) - utc_timestamp > self.max_measure_age: self.logger.error( "Last measurement was {last_sec} seconds ago, however" " the maximum measurement age is set to {max_sec}" " seconds.".format( last_sec=calendar.timegm(time.gmtime()) - utc_timestamp, max_sec=self.max_measure_age)) self.last_measurement_success = True else: self.logger.warning("No data returned from influxdb") except requests.ConnectionError: self.logger.error("Failed to read measurement from the " "influxdb database: Could not connect.") except Exception as except_msg: self.logger.exception( "Exception while reading measurement from the influxdb " "database: {err}".format(err=except_msg)) def manipulate_output(self): """ Activate output based on PID control variable and whether the manipulation directive is to raise, lower, or both. :rtype: None """ # If the last measurement was able to be retrieved and was entered within the past minute if self.last_measurement_success: # # PID control variable is positive, indicating a desire to raise # the environmental condition # if self.direction in ['raise', 'both'] and self.raise_output_id: if self.control_variable > 0: # Determine if the output should be PWM or a duration if self.raise_output_type in [ 'pwm', 'command_pwm', 'python_pwm' ]: self.raise_duty_cycle = float("{0:.1f}".format( self.control_var_to_duty_cycle( self.control_variable))) # Ensure the duty cycle doesn't exceed the min/max if (self.raise_max_duration and self.raise_duty_cycle > self.raise_max_duration): self.raise_duty_cycle = self.raise_max_duration elif (self.raise_min_duration and self.raise_duty_cycle < self.raise_min_duration): self.raise_duty_cycle = self.raise_min_duration self.logger.debug( "Setpoint: {sp}, Control Variable: {cv}, Output: PWM output " "{id} to {dc:.1f}%".format( sp=self.setpoint, cv=self.control_variable, id=self.raise_output_id, dc=self.raise_duty_cycle)) # Activate pwm with calculated duty cycle self.control.output_on( self.raise_output_id, duty_cycle=self.raise_duty_cycle) self.write_pid_output_influxdb( 'percent', 'duty_cycle', 7, self.control_var_to_duty_cycle( self.control_variable)) elif self.raise_output_type in [ 'command', 'python', 'wired', 'wireless_rpi_rf' ]: # Ensure the output on duration doesn't exceed the set maximum if (self.raise_max_duration and self.control_variable > self.raise_max_duration): self.raise_seconds_on = self.raise_max_duration else: self.raise_seconds_on = float("{0:.2f}".format( self.control_variable)) if self.raise_seconds_on > self.raise_min_duration: # Activate raise_output for a duration self.logger.debug( "Setpoint: {sp} Output: {cv} to output " "{id}".format(sp=self.setpoint, cv=self.control_variable, id=self.raise_output_id)) self.control.output_on( self.raise_output_id, duration=self.raise_seconds_on, min_off=self.raise_min_off_duration) self.write_pid_output_influxdb('s', 'duration_time', 6, self.control_variable) else: if self.raise_output_type in [ 'pwm', 'command_pwm', 'python_pwm' ]: self.control.output_on(self.raise_output_id, duty_cycle=0) # # PID control variable is negative, indicating a desire to lower # the environmental condition # if self.direction in ['lower', 'both'] and self.lower_output_id: if self.control_variable < 0: # Determine if the output should be PWM or a duration if self.lower_output_type in [ 'pwm', 'command_pwm', 'python_pwm' ]: self.lower_duty_cycle = float("{0:.1f}".format( self.control_var_to_duty_cycle( abs(self.control_variable)))) # Ensure the duty cycle doesn't exceed the min/max if (self.lower_max_duration and self.lower_duty_cycle > self.lower_max_duration): self.lower_duty_cycle = self.lower_max_duration elif (self.lower_min_duration and self.lower_duty_cycle < self.lower_min_duration): self.lower_duty_cycle = self.lower_min_duration self.logger.debug( "Setpoint: {sp}, Control Variable: {cv}, " "Output: PWM output {id} to {dc:.1f}%".format( sp=self.setpoint, cv=self.control_variable, id=self.lower_output_id, dc=self.lower_duty_cycle)) if self.store_lower_as_negative: stored_duty_cycle = -abs(self.lower_duty_cycle) stored_control_variable = -self.control_var_to_duty_cycle( abs(self.control_variable)) else: stored_duty_cycle = abs(self.lower_duty_cycle) stored_control_variable = self.control_var_to_duty_cycle( abs(self.control_variable)) # Activate pwm with calculated duty cycle self.control.output_on(self.lower_output_id, duty_cycle=stored_duty_cycle) self.write_pid_output_influxdb( 'percent', 'duty_cycle', 7, stored_control_variable) elif self.lower_output_type in [ 'command', 'python', 'wired', 'wireless_rpi_rf' ]: # Ensure the output on duration doesn't exceed the set maximum if (self.lower_max_duration and abs(self.control_variable) > self.lower_max_duration): self.lower_seconds_on = self.lower_max_duration else: self.lower_seconds_on = float("{0:.2f}".format( abs(self.control_variable))) if self.store_lower_as_negative: stored_seconds_on = -abs(self.lower_seconds_on) stored_control_variable = -abs( self.control_variable) else: stored_seconds_on = abs(self.lower_seconds_on) stored_control_variable = abs( self.control_variable) if self.lower_seconds_on > self.lower_min_duration: # Activate lower_output for a duration self.logger.debug("Setpoint: {sp} Output: {cv} to " "output {id}".format( sp=self.setpoint, cv=self.control_variable, id=self.lower_output_id)) self.control.output_on( self.lower_output_id, duration=stored_seconds_on, min_off=self.lower_min_off_duration) self.write_pid_output_influxdb( 's', 'duration_time', 6, stored_control_variable) else: if self.lower_output_type in [ 'pwm', 'command_pwm', 'python_pwm' ]: self.control.output_on(self.lower_output_id, duty_cycle=0) else: self.logger.debug( "Last measurement unsuccessful. Turning outputs off.") if self.direction in ['raise', 'both'] and self.raise_output_id: self.control.output_off(self.raise_output_id) if self.direction in ['lower', 'both'] and self.lower_output_id: self.control.output_off(self.lower_output_id) def pid_parameters_str(self): return "Device ID: {did}, " \ "Measurement ID: {mid}, " \ "Direction: {dir}, " \ "Period: {per}, " \ "Setpoint: {sp}, " \ "Band: {band}, " \ "Kp: {kp}, " \ "Ki: {ki}, " \ "Kd: {kd}, " \ "Integrator Min: {imn}, " \ "Integrator Max {imx}, " \ "Output Raise: {opr}, " \ "Output Raise Min On: {oprmnon}, " \ "Output Raise Max On: {oprmxon}, " \ "Output Raise Min Off: {oprmnoff}, " \ "Output Lower: {opl}, " \ "Output Lower Min On: {oplmnon}, " \ "Output Lower Max On: {oplmxon}, " \ "Output Lower Min Off: {oplmnoff}, " \ "Setpoint Tracking: {spt}".format( did=self.device_id, mid=self.measurement_id, dir=self.direction, per=self.period, sp=self.setpoint, band=self.band, kp=self.Kp, ki=self.Ki, kd=self.Kd, imn=self.integrator_min, imx=self.integrator_max, opr=self.raise_output_id, oprmnon=self.raise_min_duration, oprmxon=self.raise_max_duration, oprmnoff=self.raise_min_off_duration, opl=self.lower_output_id, oplmnon=self.lower_min_duration, oplmxon=self.lower_max_duration, oplmnoff=self.lower_min_off_duration, spt=self.method_id) def control_var_to_duty_cycle(self, control_variable): # Convert control variable to duty cycle if control_variable > self.period: return 100.0 else: return float((control_variable / self.period) * 100) def write_pid_output_influxdb(self, unit, measurement, channel, value): write_pid_out_db = threading.Thread(target=write_influxdb_value, args=( self.unique_id, unit, value, ), kwargs={ 'measure': measurement, 'channel': channel }) write_pid_out_db.start() def pid_mod(self): if self.initialize_variables(): return "success" else: return "error" def pid_hold(self): self.is_held = True self.logger.info("Hold") return "success" def pid_pause(self): self.is_paused = True self.logger.info("Pause") return "success" def pid_resume(self): self.is_activated = True self.is_held = False self.is_paused = False self.logger.info("Resume") return "success" def set_setpoint(self, setpoint): """ Set the setpoint of PID """ self.setpoint = float(setpoint) with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.unique_id).first() mod_pid.setpoint = setpoint db_session.commit() return "Setpoint set to {sp}".format(sp=setpoint) def set_method(self, method_id): """ Set the method of PID """ with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.unique_id).first() mod_pid.method_id = method_id if method_id == '': self.method_id = '' db_session.commit() else: mod_pid.method_start_time = 'Ready' mod_pid.method_end_time = None db_session.commit() self.setup_method(method_id) return "Method set to {me}".format(me=method_id) def set_integrator(self, integrator): """ Set the integrator of the controller """ self.integrator = float(integrator) return "Integrator set to {i}".format(i=self.integrator) def set_derivator(self, derivator): """ Set the derivator of the controller """ self.derivator = float(derivator) return "Derivator set to {d}".format(d=self.derivator) def set_kp(self, p): """ Set Kp gain of the controller """ self.Kp = float(p) with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.unique_id).first() mod_pid.p = p db_session.commit() return "Kp set to {kp}".format(kp=self.Kp) def set_ki(self, i): """ Set Ki gain of the controller """ self.Ki = float(i) with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.unique_id).first() mod_pid.i = i db_session.commit() return "Ki set to {ki}".format(ki=self.Ki) def set_kd(self, d): """ Set Kd gain of the controller """ self.Kd = float(d) with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.unique_id).first() mod_pid.d = d db_session.commit() return "Kd set to {kd}".format(kd=self.Kd) def get_setpoint(self): return self.setpoint def get_error(self): return self.error def get_integrator(self): return self.integrator def get_derivator(self): return self.derivator def get_kp(self): return self.Kp def get_ki(self): return self.Ki def get_kd(self): return self.Kd def stop_controller(self, ended_normally=True, deactivate_pid=False): self.thread_shutdown_timer = timeit.default_timer() self.running = False # Unset method start time if self.method_id != '' and ended_normally: with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.unique_id).first() mod_pid.method_start_time = 'Ended' mod_pid.method_end_time = None db_session.commit() # Deactivate PID and Autotune if deactivate_pid: with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.unique_id).first() mod_pid.is_activated = False mod_pid.autotune_activated = False db_session.commit()
class InputModule(AbstractInput): """ A sensor support class that measures the AM2315's humidity and temperature and calculates the dew point """ def __init__(self, input_dev, testing=False): super(InputModule, self).__init__() self.logger = logging.getLogger('mycodo.inputs.am2315') self.powered = False self.am = None if not testing: from mycodo.mycodo_client import DaemonControl self.logger = logging.getLogger( 'mycodo.am2315_{id}'.format(id=input_dev.unique_id.split('-')[0])) self.device_measurements = db_retrieve_table_daemon( DeviceMeasurements).filter( DeviceMeasurements.device_id == input_dev.unique_id) self.i2c_bus = input_dev.i2c_bus self.power_output_id = input_dev.power_output_id self.control = DaemonControl() self.start_sensor() self.am = AM2315(self.i2c_bus) def get_measurement(self): """ Gets the humidity and temperature """ return_dict = measurements_dict.copy() temperature = None humidity = None dew_point = None measurements_success = False # Ensure if the power pin turns off, it is turned back on if (self.power_output_id and db_retrieve_table_daemon(Output, unique_id=self.power_output_id) and self.control.output_state(self.power_output_id) == 'off'): self.logger.error( 'Sensor power output {rel} detected as being off. ' 'Turning on.'.format(rel=self.power_output_id)) self.start_sensor() time.sleep(2) # Try twice to get measurement. This prevents an anomaly where # the first measurement fails if the sensor has just been powered # for the first time. for _ in range(2): dew_point, humidity, temperature = self.return_measurements() if dew_point is not None: measurements_success = True break time.sleep(2) # Measurement failure, power cycle the sensor (if enabled) # Then try two more times to get a measurement if self.power_output_id and not measurements_success: self.stop_sensor() time.sleep(2) self.start_sensor() for _ in range(2): dew_point, humidity, temperature = self.return_measurements() if dew_point is not None: measurements_success = True break time.sleep(2) if measurements_success: if self.is_enabled(0): return_dict[0]['value'] = temperature if self.is_enabled(1): return_dict[1]['value'] = humidity if (self.is_enabled(2) and self.is_enabled(0) and self.is_enabled(1)): return_dict[2]['value'] = calculate_dewpoint( return_dict[0]['value'], return_dict[1]['value']) if (self.is_enabled(3) and self.is_enabled(0) and self.is_enabled(1)): return_dict[3]['value'] = calculate_vapor_pressure_deficit( return_dict[0]['value'], return_dict[1]['value']) return return_dict else: self.logger.debug("Could not acquire a measurement") def return_measurements(self): # Retry measurement if CRC fails for num_measure in range(3): humidity, temperature = self.am.data() if humidity is None: self.logger.debug( "Measurement {num} returned failed CRC".format( num=num_measure)) pass else: dew_pt = calculate_dewpoint(temperature, humidity) return dew_pt, humidity, temperature time.sleep(2) self.logger.error("All measurements returned failed CRC") return None, None, None def start_sensor(self): """ Turn the sensor on """ if self.power_output_id: self.logger.info("Turning on sensor") self.control.output_on(self.power_output_id, 0) time.sleep(2) self.powered = True def stop_sensor(self): """ Turn the sensor off """ if self.power_output_id: self.logger.info("Turning off sensor") self.control.output_off(self.power_output_id) self.powered = False
class CustomModule(AbstractFunction): """ Class to operate custom controller """ def __init__(self, function, testing=False): super(CustomModule, self).__init__(function, testing=testing, name=__name__) self.control_variable = None self.timestamp = None self.control = DaemonControl() self.outputIsOn = False self.timer_loop = time.time() # Initialize custom options self.measurement_device_id = None self.measurement_measurement_id = None self.output_device_id = None self.output_measurement_id = None self.output_channel_id = None self.setpoint = None self.hysteresis = None self.direction = None self.output_channel = None self.update_period = None # Set custom options custom_function = db_retrieve_table_daemon(CustomController, unique_id=self.unique_id) self.setup_custom_options(FUNCTION_INFORMATION['custom_options'], custom_function) self.output_channel = self.get_output_channel_from_channel_id( self.output_channel_id) if not testing: self.initialize_variables() def initialize_variables(self): self.timestamp = time.time() self.logger.info( "Bang-Bang controller started with options: " "Measurement Device: {}, Measurement: {}, Output: {}, " "Output_Channel: {}, Setpoint: {}, Hysteresis: {}, " "Direction: {}, Period: {}".format( self.measurement_device_id, self.measurement_measurement_id, self.output_device_id, self.output_channel, self.setpoint, self.hysteresis, self.direction, self.update_period)) def loop(self): if self.output_channel is None: self.logger.error( "Cannot start bang-bang controller: Could not find output channel." ) self.deactivate_self() return if self.timer_loop > time.time(): return while self.timer_loop < time.time(): self.timer_loop += self.update_period last_measurement = self.get_last_measurement( self.measurement_device_id, self.measurement_measurement_id)[1] outputState = self.control.output_state(self.output_device_id, self.output_channel) self.logger.info("Input: {}, output: {}, target: {}, hyst: {}".format( last_measurement, outputState, self.setpoint, self.hysteresis)) if self.direction == 'raise': if outputState == 'on': # looking to turn output off if last_measurement > (self.setpoint + self.hysteresis): self.control.output_off(self.output_device_id, output_channel=self.output_channel) else: # looking to turn output on if last_measurement < (self.setpoint - self.hysteresis): self.control.output_on(self.output_device_id, output_channel=self.output_channel) elif self.direction == 'lower': if outputState == 'on': # looking to turn output off if last_measurement < (self.setpoint - self.hysteresis): self.control.output_off(self.output_device_id, output_channel=self.output_channel) else: # looking to turn output on if last_measurement > (self.setpoint + self.hysteresis): self.control.output_on(self.output_device_id, output_channel=self.output_channel) else: self.logger.info("Unknown controller direction: {}".format( self.direction)) def deactivate_self(self): self.logger.info("Deactivating bang-bang controller") with session_scope(MYCODO_DB_PATH) as new_session: mod_cont = new_session.query(CustomController).filter( CustomController.unique_id == self.unique_id).first() mod_cont.is_activated = False new_session.commit() deactivate_controller = threading.Thread( target=self.control.controller_deactivate, args=(self.unique_id, )) deactivate_controller.start() def stop_function(self): self.control.output_off(self.output_device_id, self.output_channel)
class InputModule(AbstractInput): """ A sensor support class that measures the DHT11's humidity and temperature and calculates the dew point The DHT11 class is a stripped version of the DHT22 sensor code by joan2937. You can find the initial implementation here: - https://github.com/srounet/pigpio/tree/master/EXAMPLES/Python/DHT22_AM2302_SENSOR """ def __init__(self, input_dev, testing=False): """ :param gpio: gpio pin number :type gpio: int :param power: Power pin number :type power: int Instantiate with the Pi and gpio to which the DHT11 output pin is connected. Optionally a gpio used to power the sensor may be specified. This gpio will be set high to power the sensor. """ super(InputModule, self).__init__() self.logger = logging.getLogger('mycodo.inputs.dht11') self._dew_point = None self._humidity = None self._temperature = None self.temp_temperature = 0 self.temp_humidity = 0 self.temp_dew_point = None self.power_output_id = None self.powered = False if not testing: import pigpio from mycodo.mycodo_client import DaemonControl self.logger = logging.getLogger('mycodo.dht11_{id}'.format( id=input_dev.unique_id.split('-')[0])) self.convert_to_unit = input_dev.convert_to_unit self.gpio = int(input_dev.gpio_location) self.power_output_id = input_dev.power_output_id self.control = DaemonControl() self.pigpio = pigpio self.pi = self.pigpio.pi() self.high_tick = None self.bit = None self.either_edge_cb = None self.start_sensor() def __repr__(self): """ Representation of object """ return "<{cls}(dewpoint={dpt})(humidity={hum})(temperature={temp})>".format( cls=type(self).__name__, dpt="{0:.2f}".format(self._dew_point), hum="{0:.2f}".format(float(self._humidity)), temp="{0:.2f}".format(float(self._temperature))) def __str__(self): """ Return measurement information """ return "Dew Point: {dpt}, Humidity: {hum}, Temperature: {temp}".format( dpt="{0:.2f}".format(self._dew_point), hum="{0:.2f}".format(float(self._humidity)), temp="{0:.2f}".format(float(self._temperature))) def __iter__(self): # must return an iterator """ DHT11Sensor iterates through live measurement readings """ return self def next(self): """ Get next measurement reading """ if self.read(): # raised an error raise StopIteration # required return dict(dewpoint=float('{0:.2f}'.format(self._dew_point)), humidity=float("{0:.2f}".format(float(self._humidity))), temperature=float("{0:.2f}".format(float( self._temperature)))) @property def dew_point(self): """ DHT11 dew point in Celsius """ if self._dew_point is None: # update if needed self.read() return self._dew_point @property def humidity(self): """ DHT11 relative humidity in percent """ if self._humidity is None: # update if needed self.read() return self._humidity @property def temperature(self): """ DHT11 temperature in Celsius """ if self._temperature is None: # update if needed self.read() return self._temperature def get_measurement(self): """ Gets the humidity and temperature """ self._dew_point = None self._humidity = None self._temperature = None if not self.pi.connected: # Check if pigpiod is running self.logger.error("Could not connect to pigpiod." "Ensure it is running and try again.") return None, None, None import pigpio self.pigpio = pigpio # Ensure if the power pin turns off, it is turned back on if (self.power_output_id and db_retrieve_table_daemon( Output, unique_id=self.power_output_id) and self.control.output_state(self.power_output_id) == 'off'): self.logger.error( 'Sensor power output {rel} detected as being off. ' 'Turning on.'.format(rel=self.power_output_id)) self.start_sensor() time.sleep(2) # Try twice to get measurement. This prevents an anomaly where # the first measurement fails if the sensor has just been powered # for the first time. for _ in range(2): self.measure_sensor() if self.temp_dew_point is not None: self.temp_dew_point = convert_units('dewpoint', 'C', self.convert_to_unit, self.temp_dew_point) self.temp_temperature = convert_units('temperature', 'C', self.convert_to_unit, self.temp_temperature) self.temp_humidity = convert_units('humidity', 'percent', self.convert_to_unit, self.temp_humidity) return (self.temp_dew_point, self.temp_humidity, self.temp_temperature) # success - no errors time.sleep(2) # Measurement failure, power cycle the sensor (if enabled) # Then try two more times to get a measurement if self.power_output_id is not None and self.running: self.stop_sensor() time.sleep(2) self.start_sensor() for _ in range(2): self.measure_sensor() if self.temp_dew_point is not None: self.temp_dew_point = convert_units( 'dewpoint', 'C', self.convert_to_unit, self.temp_dew_point) self.temp_temperature = convert_units( 'temperature', 'C', self.convert_to_unit, self.temp_temperature) self.temp_humidity = convert_units('humidity', 'percent', self.convert_to_unit, self.temp_humidity) return (self.temp_dew_point, self.temp_humidity, self.temp_temperature) # success - no errors time.sleep(2) self.logger.error("Could not acquire a measurement") return None, None, None def read(self): """ Takes a reading from the DHT11 and updates the self.dew_point, self._humidity, and self._temperature values :returns: None on success or 1 on error """ try: (self._dew_point, self._humidity, self._temperature) = self.get_measurement() if self._dew_point is not None: return # success - no errors except Exception as e: self.logger.exception( "{cls} raised an exception when taking a reading: " "{err}".format(cls=type(self).__name__, err=e)) return 1 def measure_sensor(self): self.temp_temperature = 0 self.temp_humidity = 0 self.temp_dew_point = None try: try: self.setup() except Exception as except_msg: self.logger.error( 'Could not initialize sensor. Check if gpiod is running. ' 'Error: {msg}'.format(msg=except_msg)) self.pi.write(self.gpio, self.pigpio.LOW) time.sleep(0.017) # 17 ms self.pi.set_mode(self.gpio, self.pigpio.INPUT) self.pi.set_watchdog(self.gpio, 200) time.sleep(0.2) if self.temp_humidity != 0: self.temp_dew_point = calculate_dewpoint( self.temp_temperature, self.temp_humidity) except Exception as e: self.logger.error( "Exception raised when taking a reading: {err}".format(err=e)) finally: self.close() return (self.temp_dew_point, self.temp_humidity, self.temp_temperature) def setup(self): """ Clears the internal gpio pull-up/down resistor. Kills any watchdogs. Setup callbacks """ self._humidity = 0 self._temperature = 0 self.high_tick = 0 self.bit = 40 self.either_edge_cb = None self.pi.set_pull_up_down(self.gpio, self.pigpio.PUD_OFF) self.pi.set_watchdog(self.gpio, 0) self.register_callbacks() def register_callbacks(self): """ Monitors RISING_EDGE changes using callback """ self.either_edge_cb = self.pi.callback(self.gpio, self.pigpio.EITHER_EDGE, self.either_edge_callback) def either_edge_callback(self, gpio, level, tick): """ Either Edge callbacks, called each time the gpio edge changes. Accumulate the 40 data bits from the DHT11 sensor. """ level_handlers = { self.pigpio.FALLING_EDGE: self._edge_fall, self.pigpio.RISING_EDGE: self._edge_rise, self.pigpio.EITHER_EDGE: self._edge_either } handler = level_handlers[level] diff = self.pigpio.tickDiff(self.high_tick, tick) handler(tick, diff) def _edge_rise(self, tick, diff): """ Handle Rise signal """ val = 0 if diff >= 50: val = 1 if diff >= 200: # Bad bit? self.checksum = 256 # Force bad checksum if self.bit >= 40: # Message complete self.bit = 40 elif self.bit >= 32: # In checksum byte self.checksum = (self.checksum << 1) + val if self.bit == 39: # 40th bit received self.pi.set_watchdog(self.gpio, 0) total = self.temp_humidity + self.temp_temperature # is checksum ok ? if not (total & 255) == self.checksum: # For some reason the port from python 2 to python 3 causes # this bad checksum error to happen during every read # TODO: Investigate how to properly check the checksum in python 3 self.logger.debug( "Exception raised when taking a reading: " "Bad Checksum.") elif 16 <= self.bit < 24: # in temperature byte self.temp_temperature = (self.temp_temperature << 1) + val elif 0 <= self.bit < 8: # in humidity byte self.temp_humidity = (self.temp_humidity << 1) + val self.bit += 1 def _edge_fall(self, tick, diff): """ Handle Fall signal """ self.high_tick = tick if diff <= 250000: return self.bit = -2 self.checksum = 0 self.temp_temperature = 0 self.temp_humidity = 0 def _edge_either(self, tick, diff): """ Handle Either signal """ self.pi.set_watchdog(self.gpio, 0) def close(self): """ Stop reading sensor, remove callbacks """ self.pi.set_watchdog(self.gpio, 0) if self.either_edge_cb: self.either_edge_cb.cancel() self.either_edge_cb = None def start_sensor(self): """ Power the sensor """ if self.power_output_id: self.logger.info("Turning on sensor") self.control.output_on(self.power_output_id, 0) time.sleep(2) self.powered = True def stop_sensor(self): """ Depower the sensor """ if self.power_output_id: self.logger.info("Turning off sensor") self.control.output_off(self.power_output_id) self.powered = False
def camera_record(record_type, unique_id, duration_sec=None, tmp_filename=None): """ Record still image from cameras :param record_type: :param unique_id: :param duration_sec: :param tmp_filename: :return: """ daemon_control = None settings = db_retrieve_table_daemon(Camera, unique_id=unique_id) timestamp = datetime.datetime.now().strftime('%Y-%m-%d_%H-%M-%S') root_path = os.path.abspath(os.path.join(INSTALL_DIRECTORY, 'cameras')) assure_path_exists(root_path) camera_path = assure_path_exists( os.path.join(root_path, '{uid}'.format(uid=settings.unique_id))) if record_type == 'photo': save_path = assure_path_exists(os.path.join(camera_path, 'still')) filename = 'Still-{cam_id}-{cam}-{ts}.jpg'.format( cam_id=settings.id, cam=settings.name, ts=timestamp).replace(" ", "_") elif record_type == 'timelapse': save_path = assure_path_exists(os.path.join(camera_path, 'timelapse')) start = datetime.datetime.fromtimestamp( settings.timelapse_start_time).strftime("%Y-%m-%d_%H-%M-%S") filename = 'Timelapse-{cam_id}-{cam}-{st}-img-{cn:05d}.jpg'.format( cam_id=settings.id, cam=settings.name, st=start, cn=settings.timelapse_capture_number).replace(" ", "_") elif record_type == 'video': save_path = assure_path_exists(os.path.join(camera_path, 'video')) filename = 'Video-{cam}-{ts}.h264'.format(cam=settings.name, ts=timestamp).replace( " ", "_") else: return if tmp_filename: filename = tmp_filename path_file = os.path.join(save_path, filename) # Turn on output, if configured if settings.output_id: daemon_control = DaemonControl() daemon_control.output_on(settings.output_id) if settings.library == 'picamera': # Try 5 times to access the pi camera (in case another process is accessing it) for _ in range(5): try: with picamera.PiCamera() as camera: camera.resolution = (settings.width, settings.height) camera.hflip = settings.hflip camera.vflip = settings.vflip camera.rotation = settings.rotation camera.brightness = int(settings.brightness) camera.contrast = int(settings.contrast) camera.exposure_compensation = int(settings.exposure) camera.saturation = int(settings.saturation) camera.start_preview() time.sleep(2) # Camera warm-up time if record_type in ['photo', 'timelapse']: camera.capture(path_file, use_video_port=False) elif record_type == 'video': camera.start_recording(path_file, format='h264', quality=20) camera.wait_recording(duration_sec) camera.stop_recording() else: return break except picamera.exc.PiCameraMMALError: logger.error( "The camera is already open by picamera. Retrying 4 times." ) time.sleep(1) elif settings.library == 'fswebcam': cmd = "/usr/bin/fswebcam --device {dev} --resolution {w}x{h} --set brightness={bt}% " \ "--no-banner --save {file}".format(dev=settings.device, w=settings.width, h=settings.height, bt=settings.brightness, file=path_file) if settings.hflip: cmd += " --flip h" if settings.vflip: cmd += " --flip h" if settings.rotation: cmd += " --rotate {angle}".format(angle=settings.rotation) if settings.custom_options: cmd += " " + settings.custom_options out, err, status = cmd_output(cmd, stdout_pipe=False) # logger.error("TEST01: {}; {}; {}; {}".format(cmd, out, err, status)) # Turn off output, if configured if settings.output_id and daemon_control: daemon_control.output_off(settings.output_id) try: set_user_grp(path_file, 'mycodo', 'mycodo') return save_path, filename except Exception as e: logger.exception( "Exception raised in 'camera_record' when setting user grp: " "{err}".format(err=e))
class TriggerController(AbstractController, threading.Thread): """ Class to operate Trigger controller Triggers are events that are used to signal when a set of actions should be executed. The main loop in this class will continually check if any timer Triggers have elapsed. If any have, trigger_all_actions() will be ran to execute all actions associated with that particular trigger. Edge and Output conditionals are triggered from the Input and Output controllers, respectively, and the trigger_all_actions() function in this class will be ran. """ def __init__(self, ready, unique_id): threading.Thread.__init__(self) super().__init__(ready, unique_id=unique_id, name=__name__) self.unique_id = unique_id self.sample_rate = None self.control = DaemonControl() self.pause_loop = False self.verify_pause_loop = True self.trigger = None self.trigger_type = None self.trigger_name = None self.is_activated = None self.log_level_debug = None self.smtp_max_count = None self.email_count = None self.allowed_to_send_notice = None self.smtp_wait_timer = None self.timer_period = None self.period = None self.smtp_wait_timer = None self.timer_start_time = None self.timer_end_time = None self.unique_id_1 = None self.unique_id_2 = None self.unique_id_3 = None self.trigger_actions_at_period = None self.trigger_actions_at_start = None self.method_start_time = None self.method_end_time = None def loop(self): # Pause loop to modify trigger. # Prevents execution of trigger while variables are # being modified. if self.pause_loop: self.verify_pause_loop = True while self.pause_loop: time.sleep(0.1) elif (self.is_activated and self.timer_period and self.timer_period < time.time()): check_approved = False # Check if the trigger period has elapsed if self.trigger_type == 'trigger_sunrise_sunset': while self.running and self.timer_period < time.time(): self.timer_period = suntime_calculate_next_sunrise_sunset_epoch( self.trigger.latitude, self.trigger.longitude, self.trigger.date_offset_days, self.trigger.time_offset_minutes, self.trigger.rise_or_set) check_approved = True elif self.trigger_type == 'trigger_run_pwm_method': # Only execute trigger actions when started # Now only set PWM output pwm_duty_cycle, ended = self.get_method_output( self.trigger.unique_id_1) self.timer_period += self.trigger.period self.set_output_duty_cycle(pwm_duty_cycle) actions = parse_action_information() if self.trigger_actions_at_period: trigger_controller_actions(actions, self.unique_id, debug=self.log_level_debug) check_approved = True if ended: self.stop_method() elif (self.trigger_type in [ 'trigger_timer_daily_time_point', 'trigger_timer_daily_time_span', 'trigger_timer_duration' ]): if self.trigger_type == 'trigger_timer_daily_time_point': self.timer_period = epoch_of_next_time( f'{self.timer_start_time}:00') elif self.trigger_type in [ 'trigger_timer_duration', 'trigger_timer_daily_time_span' ]: while self.running and self.timer_period < time.time(): self.timer_period += self.period check_approved = True if check_approved: self.attempt_execute(self.check_triggers) def run_finally(self): pass def refresh_settings(self): """Signal to pause the main loop and wait for verification, the refresh settings.""" self.pause_loop = True while not self.verify_pause_loop: time.sleep(0.1) self.logger.info("Refreshing trigger settings") self.initialize_variables() self.pause_loop = False self.verify_pause_loop = False return "Trigger settings successfully refreshed" def initialize_variables(self): """Define all settings.""" self.email_count = 0 self.allowed_to_send_notice = True self.sample_rate = db_retrieve_table_daemon( Misc, entry='first').sample_rate_controller_conditional self.smtp_max_count = db_retrieve_table_daemon( SMTP, entry='first').hourly_max self.trigger = db_retrieve_table_daemon(Trigger, unique_id=self.unique_id) self.trigger_type = self.trigger.trigger_type self.trigger_name = self.trigger.name self.is_activated = self.trigger.is_activated self.log_level_debug = self.trigger.log_level_debug self.set_log_level_debug(self.log_level_debug) now = time.time() self.smtp_wait_timer = now + 3600 self.timer_period = None # Set up trigger timer (daily time point) if self.trigger_type == 'trigger_timer_daily_time_point': self.timer_start_time = self.trigger.timer_start_time self.timer_period = epoch_of_next_time( f'{self.trigger.timer_start_time}:00') # Set up trigger timer (daily time span) elif self.trigger_type == 'trigger_timer_daily_time_span': self.timer_start_time = self.trigger.timer_start_time self.timer_end_time = self.trigger.timer_end_time self.period = self.trigger.period self.timer_period = now # Set up trigger timer (duration) elif self.trigger_type == 'trigger_timer_duration': self.period = self.trigger.period if self.trigger.timer_start_offset: self.timer_period = now + self.trigger.timer_start_offset else: self.timer_period = now # Set up trigger Run PWM Method elif self.trigger_type == 'trigger_run_pwm_method': self.unique_id_1 = self.trigger.unique_id_1 self.unique_id_2 = self.trigger.unique_id_2 self.unique_id_3 = self.trigger.unique_id_3 self.period = self.trigger.period self.trigger_actions_at_period = self.trigger.trigger_actions_at_period self.trigger_actions_at_start = self.trigger.trigger_actions_at_start self.method_start_time = self.trigger.method_start_time self.method_end_time = self.trigger.method_end_time if self.is_activated: self.start_method(self.trigger.unique_id_1) if self.trigger_actions_at_start: self.timer_period = now - self.trigger.period if self.is_activated: self.loop() else: self.timer_period = now # Set up trigger sunrise/sunset elif self.trigger_type == 'trigger_sunrise_sunset': self.period = 60 # Set the next trigger at the specified sunrise/sunset time (+-offsets) self.timer_period = suntime_calculate_next_sunrise_sunset_epoch( self.trigger.latitude, self.trigger.longitude, self.trigger.date_offset_days, self.trigger.time_offset_minutes, self.trigger.rise_or_set) self.ready.set() self.running = True def start_method(self, method_id): """Instruct a method to start running.""" if method_id: this_controller = db_retrieve_table_daemon( Trigger, unique_id=self.unique_id) method = load_method_handler(method_id, self.logger) if parse_db_time(this_controller.method_start_time) is None: self.method_start_time = datetime.datetime.now() self.method_end_time = method.determine_end_time( self.method_start_time) self.logger.info( f"Starting method {self.method_start_time} {self.method_end_time}" ) with session_scope(MYCODO_DB_PATH) as db_session: this_controller = db_session.query(Trigger) this_controller = this_controller.filter( Trigger.unique_id == self.unique_id).first() this_controller.method_start_time = self.method_start_time this_controller.method_end_time = self.method_end_time db_session.commit() else: # already running, potentially the daemon has been restarted self.method_start_time = this_controller.method_start_time self.method_end_time = this_controller.method_end_time def stop_method(self): self.method_start_time = None self.method_end_time = None with session_scope(MYCODO_DB_PATH) as db_session: this_controller = db_session.query(Trigger) this_controller = this_controller.filter( Trigger.unique_id == self.unique_id).first() this_controller.is_activated = False this_controller.method_start_time = None this_controller.method_end_time = None db_session.commit() self.stop_controller() self.is_activated = False self.logger.warning( "Method has ended. " "Activate the Trigger controller to start it again.") def get_method_output(self, method_id): """Get output variable from method.""" this_controller = db_retrieve_table_daemon(Trigger, unique_id=self.unique_id) if this_controller.method_start_time is None: return now = datetime.datetime.now() method = load_method_handler(method_id, self.logger) setpoint, ended = method.calculate_setpoint( now, this_controller.method_start_time) if setpoint is not None: if setpoint > 100: setpoint = 100 elif setpoint < 0: setpoint = 0 return setpoint, ended def set_output_duty_cycle(self, duty_cycle): """Set PWM Output duty cycle.""" output_channel = db_retrieve_table_daemon(OutputChannel).filter( OutputChannel.unique_id == self.trigger.unique_id_3).first() output_channel = output_channel.channel if output_channel else 0 self.logger.debug(f"Set output duty cycle to {duty_cycle}") self.control.output_on(self.trigger.unique_id_2, output_type='pwm', amount=duty_cycle, output_channel=output_channel) def check_triggers(self): """ Check if any Triggers are activated and execute their actions if so. For example, if measured temperature is above 30C, notify [email protected] "if measured temperature is above 30C" is the Trigger to check. "notify [email protected]" is the Trigger Action to execute if the Trigger is True. """ now = time.time() timestamp = datetime.datetime.fromtimestamp(now).strftime( '%Y-%m-%d %H:%M:%S') message = f"{timestamp}\n[Trigger {self.unique_id} ({self.trigger_name})]" trigger = db_retrieve_table_daemon(Trigger, unique_id=self.unique_id, entry='first') device_id = trigger.measurement.split(',')[0] device = None input_dev = db_retrieve_table_daemon(Input, unique_id=device_id, entry='first') if input_dev: device = input_dev function = db_retrieve_table_daemon(CustomController, unique_id=device_id, entry='first') if function: device = CustomController output = db_retrieve_table_daemon(Output, unique_id=device_id, entry='first') if output: device = output pid = db_retrieve_table_daemon(PID, unique_id=device_id, entry='first') if pid: device = pid if not device: message += " Error: Controller not Input, Function, Output, or PID" self.logger.error(message) return # If the edge detection variable is set, calling this function will # trigger an edge detection event. This will merely produce the correct # message based on the edge detection settings. elif trigger.trigger_type == 'trigger_edge': try: import RPi.GPIO as GPIO GPIO.setmode(GPIO.BCM) GPIO.setup(int(input_dev.pin), GPIO.IN) gpio_state = GPIO.input(int(input_dev.pin)) except Exception as e: gpio_state = None self.logger.error(f"Exception reading the GPIO pin: {e}") if (gpio_state is not None and gpio_state == trigger.if_sensor_gpio_state): message += f" GPIO State Detected (state = {trigger.if_sensor_gpio_state})." else: self.logger.error( "GPIO not configured correctly or GPIO state not verified") return # Calculate the sunrise/sunset times and find the next time this trigger should trigger elif trigger.trigger_type == 'trigger_sunrise_sunset': # Since the check time is the trigger time, we will only calculate and set the next trigger time self.timer_period = suntime_calculate_next_sunrise_sunset_epoch( trigger.latitude, trigger.longitude, trigger.date_offset_days, trigger.time_offset_minutes, trigger.rise_or_set) # Check if the current time is between the start and end time elif trigger.trigger_type == 'trigger_timer_daily_time_span': if not time_between_range(self.timer_start_time, self.timer_end_time): return # If the code hasn't returned by now, action should be executed actions = parse_action_information() trigger_controller_actions(actions, self.unique_id, message=message, debug=self.log_level_debug)
class TriggerController(threading.Thread): """ Class to operate Trigger controller Triggers are events that are used to signal when a set of actions should be executed. The main loop in this class will continually check if any timer Triggers have elapsed. If any have, trigger_all_actions() will be ran to execute all actions associated with that particular trigger. Edge and Output conditionals are triggered from the Input and Output controllers, respectively, and the trigger_all_actions() function in this class will be ran. """ def __init__(self, ready, function_id): threading.Thread.__init__(self) self.logger = logging.getLogger( "mycodo.trigger_{id}".format(id=function_id.split('-')[0])) self.function_id = function_id self.running = False self.thread_startup_timer = timeit.default_timer() self.thread_shutdown_timer = 0 self.pause_loop = False self.verify_pause_loop = True self.ready = ready self.control = DaemonControl() self.sample_rate = db_retrieve_table_daemon( Misc, entry='first').sample_rate_controller_conditional self.trigger_type = None self.is_activated = None self.smtp_max_count = None self.email_count = None self.allowed_to_send_notice = None self.smtp_wait_timer = None self.timer_period = None self.period = None self.smtp_wait_timer = None self.timer_start_time = None self.timer_end_time = None self.unique_id_1 = None self.unique_id_2 = None self.trigger_actions_at_period = None self.trigger_actions_at_start = None self.method_start_time = None self.method_end_time = None self.method_start_act = None self.setup_settings() def run(self): try: self.running = True self.logger.info( "Activated in {:.1f} ms".format( (timeit.default_timer() - self.thread_startup_timer) * 1000)) self.ready.set() while self.running: # Pause loop to modify trigger. # Prevents execution of trigger while variables are # being modified. if self.pause_loop: self.verify_pause_loop = True while self.pause_loop: time.sleep(0.1) if (self.is_activated and self.timer_period and self.timer_period < time.time()): check_approved = False # Check if the trigger period has elapsed if self.trigger_type in ['trigger_sunrise_sunset', 'trigger_run_pwm_method']: while self.running and self.timer_period < time.time(): self.timer_period += self.period if self.trigger_type == 'trigger_run_pwm_method': # Only execute trigger actions when started # Now only set PWM output pwm_duty_cycle, ended = self.get_method_output( self.unique_id_1) if not ended: self.set_output_duty_cycle( self.unique_id_2, pwm_duty_cycle) if self.trigger_actions_at_period: trigger_function_actions(self.function_id) else: check_approved = True elif (self.trigger_type in [ 'trigger_timer_daily_time_point', 'trigger_timer_daily_time_span', 'trigger_timer_duration']): if self.trigger_type == 'trigger_timer_daily_time_point': self.timer_period = epoch_of_next_time( '{hm}:00'.format(hm=self.timer_start_time)) elif self.trigger_type in ['trigger_timer_duration', 'trigger_timer_daily_time_span']: while self.running and self.timer_period < time.time(): self.timer_period += self.period check_approved = True if check_approved: self.check_triggers() time.sleep(self.sample_rate) self.running = False self.logger.info( "Deactivated in {:.1f} ms".format( (timeit.default_timer() - self.thread_shutdown_timer) * 1000)) except Exception as except_msg: self.logger.exception("Run Error: {err}".format( err=except_msg)) def refresh_settings(self): """ Signal to pause the main loop and wait for verification, the refresh settings """ self.pause_loop = True while not self.verify_pause_loop: time.sleep(0.1) self.logger.info("Refreshing trigger settings") self.setup_settings() self.pause_loop = False self.verify_pause_loop = False return "Trigger settings successfully refreshed" def setup_settings(self): """ Define all settings """ trigger = db_retrieve_table_daemon( Trigger, unique_id=self.function_id) self.trigger_type = trigger.trigger_type self.is_activated = trigger.is_activated self.smtp_max_count = db_retrieve_table_daemon( SMTP, entry='first').hourly_max self.email_count = 0 self.allowed_to_send_notice = True now = time.time() self.smtp_wait_timer = now + 3600 self.timer_period = None # Set up trigger timer (daily time point) if self.trigger_type == 'trigger_timer_daily_time_point': self.timer_start_time = trigger.timer_start_time self.timer_period = epoch_of_next_time( '{hm}:00'.format(hm=trigger.timer_start_time)) # Set up trigger timer (daily time span) elif self.trigger_type == 'trigger_timer_daily_time_span': self.timer_start_time = trigger.timer_start_time self.timer_end_time = trigger.timer_end_time self.period = trigger.period self.timer_period = now # Set up trigger timer (duration) elif self.trigger_type == 'trigger_timer_duration': self.period = trigger.period if trigger.timer_start_offset: self.timer_period = now + trigger.timer_start_offset else: self.timer_period = now # Set up trigger Run PWM Method elif self.trigger_type == 'trigger_run_pwm_method': self.unique_id_1 = trigger.unique_id_1 self.unique_id_2 = trigger.unique_id_2 self.period = trigger.period self.trigger_actions_at_period = trigger.trigger_actions_at_period self.trigger_actions_at_start = trigger.trigger_actions_at_start self.method_start_time = trigger.method_start_time self.method_end_time = trigger.method_end_time if self.is_activated: self.start_method(trigger.unique_id_1) if self.trigger_actions_at_start: self.timer_period = now + trigger.period if self.is_activated: pwm_duty_cycle = self.get_method_output( trigger.unique_id_1) self.set_output_duty_cycle( trigger.unique_id_2, pwm_duty_cycle) trigger_function_actions(self.function_id) else: self.timer_period = now # Set up trigger sunrise/sunset elif self.trigger_type == 'trigger_sunrise_sunset': self.period = 60 # Set the next trigger at the specified sunrise/sunset time (+-offsets) self.timer_period = calculate_sunrise_sunset_epoch(trigger) def start_method(self, method_id): """ Instruct a method to start running """ if method_id: method = db_retrieve_table_daemon(Method, unique_id=method_id) method_data = db_retrieve_table_daemon(MethodData) method_data = method_data.filter(MethodData.method_id == method_id) method_data_repeat = method_data.filter(MethodData.duration_sec == 0).first() self.method_start_act = self.method_start_time self.method_start_time = None self.method_end_time = None if method.method_type == 'Duration': if self.method_start_act == 'Ended': with session_scope(MYCODO_DB_PATH) as db_session: mod_conditional = db_session.query(Trigger) mod_conditional = mod_conditional.filter( Trigger.unique_id == self.function_id).first() mod_conditional.is_activated = False db_session.commit() self.stop_controller() self.logger.warning( "Method has ended. " "Activate the Trigger controller to start it again.") elif (self.method_start_act == 'Ready' or self.method_start_act is None): # Method has been instructed to begin now = datetime.datetime.now() self.method_start_time = now if method_data_repeat and method_data_repeat.duration_end: self.method_end_time = now + datetime.timedelta( seconds=float(method_data_repeat.duration_end)) with session_scope(MYCODO_DB_PATH) as db_session: mod_conditional = db_session.query(Trigger) mod_conditional = mod_conditional.filter( Trigger.unique_id == self.function_id).first() mod_conditional.method_start_time = self.method_start_time mod_conditional.method_end_time = self.method_end_time db_session.commit() def get_method_output(self, method_id): """ Get output variable from method """ this_controller = db_retrieve_table_daemon( Trigger, unique_id=self.function_id) setpoint, ended = calculate_method_setpoint( method_id, Trigger, this_controller, Method, MethodData, self.logger) if setpoint is not None: if setpoint > 100: setpoint = 100 elif setpoint < 0: setpoint = 0 if ended: with session_scope(MYCODO_DB_PATH) as db_session: mod_conditional = db_session.query(Trigger) mod_conditional = mod_conditional.filter( Trigger.unique_id == self.function_id).first() mod_conditional.is_activated = False db_session.commit() self.is_activated = False self.stop_controller() return setpoint, ended def set_output_duty_cycle(self, output_id, duty_cycle): """ Set PWM Output duty cycle """ self.control.output_on(output_id, duty_cycle=duty_cycle) def check_triggers(self): """ Check if any Triggers are activated and execute their actions if so. For example, if measured temperature is above 30C, notify [email protected] "if measured temperature is above 30C" is the Trigger to check. "notify [email protected]" is the Trigger Action to execute if the Trigger is True. """ last_measurement = None gpio_state = None logger_cond = logging.getLogger("mycodo.conditional_{id}".format( id=self.function_id)) trigger = db_retrieve_table_daemon( Trigger, unique_id=self.function_id, entry='first') now = time.time() timestamp = datetime.datetime.fromtimestamp(now).strftime('%Y-%m-%d %H:%M:%S') message = "{ts}\n[Trigger {id} ({name})]".format( ts=timestamp, name=trigger.name, id=self.function_id) device_id = trigger.measurement.split(',')[0] if len(trigger.measurement.split(',')) > 1: device_measurement = trigger.measurement.split(',')[1] else: device_measurement = None device = None input_dev = db_retrieve_table_daemon( Input, unique_id=device_id, entry='first') if input_dev: device = input_dev math = db_retrieve_table_daemon( Math, unique_id=device_id, entry='first') if math: device = math output = db_retrieve_table_daemon( Output, unique_id=device_id, entry='first') if output: device = output pid = db_retrieve_table_daemon( PID, unique_id=device_id, entry='first') if pid: device = pid if not device: message += " Error: Controller not Input, Math, Output, or PID" logger_cond.error(message) return # If the edge detection variable is set, calling this function will # trigger an edge detection event. This will merely produce the correct # message based on the edge detection settings. elif trigger.trigger_type == 'trigger_edge': try: GPIO.setmode(GPIO.BCM) GPIO.setup(int(input_dev.pin), GPIO.IN) gpio_state = GPIO.input(int(input_dev.pin)) except: gpio_state = None logger_cond.error("Exception reading the GPIO pin") if (gpio_state is not None and gpio_state == trigger.if_sensor_gpio_state): message += " GPIO State Detected (state = {state}).".format( state=trigger.if_sensor_gpio_state) else: logger_cond.error("GPIO not configured correctly or GPIO state not verified") return # Calculate the sunrise/sunset times and find the next time this trigger should trigger elif trigger.trigger_type == 'trigger_sunrise_sunset': # Since the check time is the trigger time, we will only calculate and set the next trigger time self.timer_period = calculate_sunrise_sunset_epoch(trigger) # Check if the current time is between the start and end time if trigger.trigger_type == 'trigger_timer_daily_time_span': if not time_between_range(self.timer_start_time, self.timer_end_time): return # If the code hasn't returned by now, action should be executed trigger_function_actions(self.function_id, message=message) def is_running(self): return self.running def stop_controller(self): self.thread_shutdown_timer = timeit.default_timer() self.running = False
class PIDController(AbstractController, threading.Thread): """ Class to operate discrete PID controller in Mycodo """ def __init__(self, ready, unique_id): threading.Thread.__init__(self) super(PIDController, self).__init__(ready, unique_id=unique_id, name=__name__) self.unique_id = unique_id self.sample_rate = None self.dict_outputs = None self.control = DaemonControl() self.PID_Controller = None self.setpoint = None self.device_measurements = None self.device_id = None self.measurement_id = None self.log_level_debug = None self.last_time = None self.last_measurement = None self.last_measurement_success = False self.is_activated = None self.is_held = None self.is_paused = None self.measurement = None self.setpoint_tracking_type = None self.setpoint_tracking_id = None self.setpoint_tracking_max_age = None self.raise_output_id = None self.raise_output_channel_id = None self.raise_output_channel = None self.raise_output_type = None self.raise_min_duration = None self.raise_max_duration = None self.raise_min_off_duration = None self.raise_always_min_pwm = None self.lower_output_id = None self.lower_output_channel_id = None self.lower_output_channel = None self.lower_output_type = None self.lower_min_duration = None self.lower_max_duration = None self.lower_min_off_duration = None self.lower_always_min_pwm = None self.period = 0 self.start_offset = 0 self.max_measure_age = None self.send_lower_as_negative = None self.store_lower_as_negative = None self.timer = 0 # Check if a method is set for this PID self.method_type = None self.method_start_time = None self.method_end_time = None def loop(self): if time.time() > self.timer: while time.time() > self.timer: self.timer = self.timer + self.period self.attempt_execute(self.check_pid) def run_finally(self): # Turn off output used in PID when the controller is deactivated if self.raise_output_id and self.PID_Controller.direction in [ 'raise', 'both' ]: self.control.output_off(self.raise_output_id, output_channel=self.raise_output_channel, trigger_conditionals=True) if self.lower_output_id and self.PID_Controller.direction in [ 'lower', 'both' ]: self.control.output_off(self.lower_output_id, output_channel=self.lower_output_channel, trigger_conditionals=True) def initialize_variables(self): """Set PID parameters""" self.dict_outputs = parse_output_information() self.sample_rate = db_retrieve_table_daemon( Misc, entry='first').sample_rate_controller_pid self.device_measurements = db_retrieve_table_daemon(DeviceMeasurements) pid = db_retrieve_table_daemon(PID, unique_id=self.unique_id) self.log_level_debug = pid.log_level_debug self.set_log_level_debug(self.log_level_debug) self.device_id = pid.measurement.split(',')[0] self.measurement_id = pid.measurement.split(',')[1] self.is_activated = pid.is_activated self.is_held = pid.is_held self.is_paused = pid.is_paused self.setpoint_tracking_type = pid.setpoint_tracking_type self.setpoint_tracking_id = pid.setpoint_tracking_id self.setpoint_tracking_max_age = pid.setpoint_tracking_max_age if pid.raise_output_id and "," in pid.raise_output_id: self.raise_output_id = pid.raise_output_id.split(",")[0] self.raise_output_channel_id = pid.raise_output_id.split(",")[1] output_channel = db_retrieve_table_daemon( OutputChannel, unique_id=self.raise_output_channel_id) self.raise_output_channel = output_channel.channel self.raise_output_type = pid.raise_output_type self.raise_min_duration = pid.raise_min_duration self.raise_max_duration = pid.raise_max_duration self.raise_min_off_duration = pid.raise_min_off_duration self.raise_always_min_pwm = pid.raise_always_min_pwm if pid.lower_output_id and "," in pid.lower_output_id: self.lower_output_id = pid.lower_output_id.split(",")[0] self.lower_output_channel_id = pid.lower_output_id.split(",")[1] output_channel = db_retrieve_table_daemon( OutputChannel, unique_id=self.lower_output_channel_id) self.lower_output_channel = output_channel.channel self.lower_output_type = pid.lower_output_type self.lower_min_duration = pid.lower_min_duration self.lower_max_duration = pid.lower_max_duration self.lower_min_off_duration = pid.lower_min_off_duration self.lower_always_min_pwm = pid.lower_always_min_pwm self.period = pid.period self.start_offset = pid.start_offset self.max_measure_age = pid.max_measure_age self.send_lower_as_negative = pid.send_lower_as_negative self.store_lower_as_negative = pid.store_lower_as_negative self.timer = time.time() + self.start_offset self.setpoint = pid.setpoint # Initialize PID Controller if self.PID_Controller is None: self.PID_Controller = PIDControl(self.logger, pid.setpoint, pid.p, pid.i, pid.d, pid.direction, pid.band, pid.integrator_min, pid.integrator_max) else: # Set PID options self.PID_Controller.setpoint = pid.setpoint self.PID_Controller.Kp = pid.p self.PID_Controller.Ki = pid.i self.PID_Controller.Kd = pid.d self.PID_Controller.direction = pid.direction self.PID_Controller.band = pid.band self.PID_Controller.integrator_min = pid.integrator_min self.PID_Controller.integrator_max = pid.integrator_max self.PID_Controller.first_start = True if self.setpoint_tracking_type == 'method' and self.setpoint_tracking_id != '': self.setup_method(self.setpoint_tracking_id) if self.is_paused: self.logger.info("Starting Paused") elif self.is_held: self.logger.info("Starting Held") self.logger.info("PID Settings: {}".format(self.pid_parameters_str())) return "success" def check_pid(self): """ Get measurement and apply to PID controller """ # If PID is active, retrieve measurement and update # the control variable. # A PID on hold will sustain the current output and # not update the control variable. if self.is_activated and (not self.is_paused or not self.is_held): self.get_last_measurement_pid() if self.last_measurement_success: if self.setpoint_tracking_type == 'method' and self.setpoint_tracking_id != '': # Update setpoint using a method this_pid = db_retrieve_table_daemon( PID, unique_id=self.unique_id) now = datetime.datetime.now() method = load_method_handler(self.setpoint_tracking_id, self.logger) new_setpoint, ended = method.calculate_setpoint( now, this_pid.method_start_time) self.logger.debug("Method {} {} {} {}".format( self.setpoint_tracking_id, method, now, this_pid.method_start_time)) if ended: # point in time is out of method range with session_scope(MYCODO_DB_PATH) as db_session: # Overwrite this_controller with committable connection this_pid = db_session.query(PID).filter( PID.unique_id == self.unique_id).first() self.logger.debug("Ended") # Duration method has ended, reset method_start_time locally and in DB this_pid.method_start_time = None this_pid.method_end_time = None this_pid.is_activated = False db_session.commit() self.is_activated = False self.stop_controller() db_session.commit() if new_setpoint is not None: self.logger.debug("New setpoint = {} {}".format( new_setpoint, ended)) self.PID_Controller.setpoint = new_setpoint else: self.logger.debug( "New setpoint = default {} {}".format( self.setpoint, ended)) self.PID_Controller.setpoint = self.setpoint if self.setpoint_tracking_type == 'input-math' and self.setpoint_tracking_id != '': # Update setpoint using an Input or Math device_id = self.setpoint_tracking_id.split(',')[0] measurement_id = self.setpoint_tracking_id.split(',')[1] measurement = get_measurement(measurement_id) if not measurement: return False, None conversion = db_retrieve_table_daemon( Conversion, unique_id=measurement.conversion_id) channel, unit, measurement = return_measurement_info( measurement, conversion) last_measurement = read_last_influxdb( device_id, unit, channel, measure=measurement, duration_sec=self.setpoint_tracking_max_age) if last_measurement[1] is not None: self.PID_Controller.setpoint = last_measurement[1] else: self.logger.debug( "Could not find measurement for Setpoint " "Tracking. Max Age of {} exceeded for measuring " "device ID {} (measurement {})".format( self.setpoint_tracking_max_age, device_id, measurement_id)) self.PID_Controller.setpoint = None # Calculate new control variable (output) from PID Controller self.PID_Controller.update_pid_output(self.last_measurement) self.write_pid_values() # Write variables to database # Is PID in a state that allows manipulation of outputs if (self.is_activated and self.PID_Controller.setpoint is not None and (not self.is_paused or self.is_held)): self.manipulate_output() def setup_method(self, method_id): """ Initialize method variables to start running a method """ self.setpoint_tracking_id = '' method = load_method_handler(method_id, self.logger) this_controller = db_retrieve_table_daemon(PID, unique_id=self.unique_id) self.method_type = method.method_type if parse_db_time(this_controller.method_start_time) is None: self.method_start_time = datetime.datetime.now() self.method_end_time = method.determine_end_time( self.method_start_time) self.logger.info("Starting method {} {}".format( self.method_start_time, self.method_end_time)) with session_scope(MYCODO_DB_PATH) as db_session: this_controller = db_session.query(PID) this_controller = this_controller.filter( PID.unique_id == self.unique_id).first() this_controller.method_start_time = self.method_start_time this_controller.method_end_time = self.method_end_time db_session.commit() else: # already running, potentially the daemon has been restarted self.method_start_time = this_controller.method_start_time self.method_end_time = this_controller.method_end_time self.setpoint_tracking_id = method_id self.logger.debug( "Method enabled: {id}".format(id=self.setpoint_tracking_id)) def stop_method(self): self.method_start_time = None self.method_end_time = None with session_scope(MYCODO_DB_PATH) as db_session: this_controller = db_session.query(PID) this_controller = this_controller.filter( PID.unique_id == self.unique_id).first() this_controller.is_activated = False this_controller.method_start_time = None this_controller.method_end_time = None db_session.commit() self.stop_controller() self.is_activated = False self.logger.warning( "Method has ended. " "Activate the Trigger controller to start it again.") def write_pid_values(self): """ Write PID values to the measurement database """ if self.PID_Controller.band: setpoint_band_lower = self.PID_Controller.setpoint - self.PID_Controller.band setpoint_band_upper = self.PID_Controller.setpoint + self.PID_Controller.band else: setpoint_band_lower = None setpoint_band_upper = None list_measurements = [ self.PID_Controller.setpoint, setpoint_band_lower, setpoint_band_upper, self.PID_Controller.P_value, self.PID_Controller.I_value, self.PID_Controller.D_value ] measurement_dict = {} measurements = self.device_measurements.filter( DeviceMeasurements.device_id == self.unique_id).all() for each_channel, each_measurement in enumerate(measurements): if (each_measurement.channel not in measurement_dict and each_measurement.channel < len(list_measurements)): # If setpoint, get unit from PID measurement if each_measurement.measurement_type == 'setpoint': setpoint_pid = db_retrieve_table_daemon( PID, unique_id=each_measurement.device_id) if setpoint_pid and ',' in setpoint_pid.measurement: pid_measurement = setpoint_pid.measurement.split( ',')[1] setpoint_measurement = db_retrieve_table_daemon( DeviceMeasurements, unique_id=pid_measurement) if setpoint_measurement: conversion = db_retrieve_table_daemon( Conversion, unique_id=setpoint_measurement.conversion_id) _, unit, _ = return_measurement_info( setpoint_measurement, conversion) measurement_dict[each_channel] = { 'measurement': each_measurement.measurement, 'unit': unit, 'value': list_measurements[each_channel] } else: measurement_dict[each_channel] = { 'measurement': each_measurement.measurement, 'unit': each_measurement.unit, 'value': list_measurements[each_channel] } add_measurements_influxdb(self.unique_id, measurement_dict) def get_last_measurement_pid(self): """ Retrieve the latest input measurement from InfluxDB :rtype: None """ self.last_measurement_success = False # Get latest measurement from influxdb try: device_measurement = get_measurement(self.measurement_id) if device_measurement: conversion = db_retrieve_table_daemon( Conversion, unique_id=device_measurement.conversion_id) else: conversion = None channel, unit, measurement = return_measurement_info( device_measurement, conversion) self.last_measurement = read_last_influxdb( self.device_id, unit, channel, measure=measurement, duration_sec=int(self.max_measure_age)) if self.last_measurement: self.last_time = self.last_measurement[0] self.last_measurement = self.last_measurement[1] utc_dt = datetime.datetime.strptime( self.last_time.split(".")[0], '%Y-%m-%dT%H:%M:%S') utc_timestamp = calendar.timegm(utc_dt.timetuple()) local_timestamp = str( datetime.datetime.fromtimestamp(utc_timestamp)) self.logger.debug( "Latest (CH{ch}, Unit: {unit}): {last} @ {ts}".format( ch=channel, unit=unit, last=self.last_measurement, ts=local_timestamp)) if calendar.timegm( time.gmtime()) - utc_timestamp > self.max_measure_age: self.logger.error( "Last measurement was {last_sec} seconds ago, however" " the maximum measurement age is set to {max_sec}" " seconds.".format( last_sec=calendar.timegm(time.gmtime()) - utc_timestamp, max_sec=self.max_measure_age)) self.last_measurement_success = True else: self.logger.warning("No data returned from influxdb") except requests.ConnectionError: self.logger.error( "Failed to read measurement from the influxdb database: Could not connect." ) except Exception as except_msg: self.logger.exception( "Exception while reading measurement from the influxdb database: {err}" .format(err=except_msg)) def manipulate_output(self): """ Activate output based on PID control variable and whether the manipulation directive is to raise, lower, or both. :rtype: None """ # If the last measurement was able to be retrieved and was entered within the past minute if self.last_measurement_success: # # PID control variable is positive, indicating a desire to raise # the environmental condition # if self.PID_Controller.direction in ['raise', 'both' ] and self.raise_output_id: if self.PID_Controller.control_variable > 0: if self.raise_output_type == 'pwm': raise_duty_cycle = self.control_var_to_duty_cycle( self.PID_Controller.control_variable) # Ensure the duty cycle doesn't exceed the min/max if (self.raise_max_duration and raise_duty_cycle > self.raise_max_duration): raise_duty_cycle = self.raise_max_duration elif (self.raise_min_duration and raise_duty_cycle < self.raise_min_duration): raise_duty_cycle = self.raise_min_duration self.logger.debug( "Setpoint: {sp}, Control Variable: {cv}, Output: PWM output {id} CH{ch} to {dc:.1f}%" .format(sp=self.PID_Controller.setpoint, cv=self.PID_Controller.control_variable, id=self.raise_output_id, ch=self.raise_output_channel, dc=raise_duty_cycle)) # Activate pwm with calculated duty cycle self.control.output_on( self.raise_output_id, output_type='pwm', amount=raise_duty_cycle, output_channel=self.raise_output_channel) self.write_pid_output_influxdb( 'percent', 'duty_cycle', 7, self.control_var_to_duty_cycle( self.PID_Controller.control_variable)) elif self.raise_output_type == 'on_off': raise_seconds_on = self.PID_Controller.control_variable # Ensure the output on duration doesn't exceed the set maximum if (self.raise_max_duration and raise_seconds_on > self.raise_max_duration): raise_seconds_on = self.raise_max_duration if raise_seconds_on >= self.raise_min_duration: # Activate raise_output for a duration self.logger.debug( "Setpoint: {sp} Output: {cv} sec to output {id} CH{ch}" .format( sp=self.PID_Controller.setpoint, cv=self.PID_Controller.control_variable, id=self.raise_output_id, ch=self.raise_output_channel)) self.control.output_on( self.raise_output_id, output_type='sec', amount=raise_seconds_on, min_off=self.raise_min_off_duration, output_channel=self.raise_output_channel) self.write_pid_output_influxdb( 's', 'duration_time', 6, self.PID_Controller.control_variable) elif self.raise_output_type == 'value': raise_value = self.PID_Controller.control_variable # Ensure the duty cycle doesn't exceed the min/max if (self.raise_max_duration and raise_value > self.raise_max_duration): raise_value = self.raise_max_duration if raise_value >= self.raise_min_duration: # Activate raise_output for a value self.logger.debug( "Setpoint: {sp} Output: {cv} to output {id} CH{ch}" .format( sp=self.PID_Controller.setpoint, cv=self.PID_Controller.control_variable, id=self.raise_output_id, ch=self.raise_output_channel)) self.control.output_on( self.raise_output_id, output_type='value', amount=raise_value, min_off=self.raise_min_off_duration, output_channel=self.raise_output_channel) self.write_pid_output_influxdb('none', 'unitless', 9, raise_value) elif self.raise_output_type == 'volume': raise_volume = self.PID_Controller.control_variable # Ensure the duty cycle doesn't exceed the min/max if (self.raise_max_duration and raise_volume > self.raise_max_duration): raise_volume = self.raise_max_duration if raise_volume >= self.raise_min_duration: # Activate raise_output for a volume (ml) self.logger.debug( "Setpoint: {sp} Output: {cv} ml to output {id} CH{ch}" .format( sp=self.PID_Controller.setpoint, cv=self.PID_Controller.control_variable, id=self.raise_output_id, ch=self.raise_output_channel)) self.control.output_on( self.raise_output_id, output_type='vol', amount=raise_volume, min_off=self.raise_min_off_duration, output_channel=self.raise_output_channel) self.write_pid_output_influxdb( 'ml', 'volume', 8, self.PID_Controller.control_variable) elif self.raise_output_type == 'pwm' and not self.raise_always_min_pwm: # Turn PWM Off if PWM Output and not instructed to always be at least min self.control.output_on( self.raise_output_id, output_type='pwm', amount=0, output_channel=self.raise_output_channel) # # PID control variable is negative, indicating a desire to lower # the environmental condition # if self.PID_Controller.direction in ['lower', 'both' ] and self.lower_output_id: if self.PID_Controller.control_variable < 0: if self.lower_output_type == 'pwm': lower_duty_cycle = self.control_var_to_duty_cycle( abs(self.PID_Controller.control_variable)) # Ensure the duty cycle doesn't exceed the min/max if (self.lower_max_duration and lower_duty_cycle > self.lower_max_duration): lower_duty_cycle = self.lower_max_duration elif (self.lower_min_duration and lower_duty_cycle < self.lower_min_duration): lower_duty_cycle = self.lower_min_duration self.logger.debug( "Setpoint: {sp}, Control Variable: {cv}, Output: PWM output {id} CH{ch} to {dc:.1f}%" .format(sp=self.PID_Controller.setpoint, cv=self.PID_Controller.control_variable, id=self.lower_output_id, ch=self.lower_output_channel, dc=lower_duty_cycle)) if self.store_lower_as_negative: store_duty_cycle = -self.control_var_to_duty_cycle( abs(self.PID_Controller.control_variable)) else: store_duty_cycle = self.control_var_to_duty_cycle( abs(self.PID_Controller.control_variable)) if self.send_lower_as_negative: send_duty_cycle = -abs(lower_duty_cycle) else: send_duty_cycle = abs(lower_duty_cycle) # Activate pwm with calculated duty cycle self.control.output_on( self.lower_output_id, output_type='pwm', amount=send_duty_cycle, output_channel=self.lower_output_channel) self.write_pid_output_influxdb('percent', 'duty_cycle', 7, store_duty_cycle) elif self.lower_output_type == 'on_off': lower_seconds_on = abs( self.PID_Controller.control_variable) # Ensure the output on duration doesn't exceed the set maximum if (self.lower_max_duration and lower_seconds_on > self.lower_max_duration): lower_seconds_on = self.lower_max_duration if self.store_lower_as_negative: store_amount_on = -abs( self.PID_Controller.control_variable) else: store_amount_on = abs( self.PID_Controller.control_variable) if self.send_lower_as_negative: send_amount_on = -lower_seconds_on else: send_amount_on = lower_seconds_on if lower_seconds_on >= self.lower_min_duration: # Activate lower_output for a duration self.logger.debug( "Setpoint: {sp} Output: {cv} sec to output {id} CH{ch}" .format( sp=self.PID_Controller.setpoint, cv=self.PID_Controller.control_variable, id=self.lower_output_id, ch=self.lower_output_channel)) self.control.output_on( self.lower_output_id, output_type='sec', amount=send_amount_on, min_off=self.lower_min_off_duration, output_channel=self.lower_output_channel) self.write_pid_output_influxdb('s', 'duration_time', 6, store_amount_on) elif self.lower_output_type == 'value': lower_value = abs(self.PID_Controller.control_variable) # Ensure the output value doesn't exceed the set maximum if (self.lower_max_duration and lower_value > self.lower_max_duration): lower_value = self.lower_max_duration if self.store_lower_as_negative: store_value = -abs( self.PID_Controller.control_variable) else: store_value = abs( self.PID_Controller.control_variable) if self.send_lower_as_negative: send_value = -lower_value else: send_value = lower_value if lower_value >= self.lower_min_duration: # Activate lower_output for a value self.logger.debug( "Setpoint: {sp} Output: {cv} to output {id} CH{ch}" .format( sp=self.PID_Controller.setpoint, cv=self.PID_Controller.control_variable, id=self.lower_output_id, ch=self.lower_output_channel)) self.control.output_on( self.lower_output_id, output_type='value', amount=send_value, min_off=self.lower_min_off_duration, output_channel=self.lower_output_channel) self.write_pid_output_influxdb('none', 'unitless', 9, store_value) elif self.lower_output_type == 'volume': lower_volume = abs( self.PID_Controller.control_variable) # Ensure the output volume doesn't exceed the set maximum if (self.lower_max_duration and lower_volume > self.lower_max_duration): lower_volume = self.lower_max_duration if self.store_lower_as_negative: store_volume = -abs( self.PID_Controller.control_variable) else: store_volume = abs( self.PID_Controller.control_variable) if self.send_lower_as_negative: send_volume = -lower_volume else: send_volume = lower_volume if lower_volume >= self.lower_min_duration: # Activate lower_output for a volume (ml) self.logger.debug( "Setpoint: {sp} Output: {cv} ml to output {id} CH{ch}" .format( sp=self.PID_Controller.setpoint, cv=self.PID_Controller.control_variable, id=self.lower_output_id, ch=self.lower_output_channel)) self.control.output_on( self.lower_output_id, output_type='vol', amount=send_volume, min_off=self.lower_min_off_duration, output_channel=self.lower_output_channel) self.write_pid_output_influxdb('ml', 'volume', 8, store_volume) elif self.lower_output_type == 'pwm' and not self.lower_always_min_pwm: # Turn PWM Off if PWM Output and not instructed to always be at least min self.control.output_on( self.lower_output_id, output_type='pwm', amount=0, output_channel=self.lower_output_channel) else: self.logger.debug( "Last measurement unsuccessful. Turning outputs off.") if self.PID_Controller.direction in ['raise', 'both' ] and self.raise_output_id: self.control.output_off( self.raise_output_id, output_channel=self.raise_output_channel) if self.PID_Controller.direction in ['lower', 'both' ] and self.lower_output_id: self.control.output_off( self.lower_output_id, output_channel=self.lower_output_channel) def pid_parameters_str(self): return "Device ID: {did}, " \ "Measurement ID: {mid}, " \ "Direction: {dir}, " \ "Period: {per}, " \ "Setpoint: {sp}, " \ "Band: {band}, " \ "Kp: {kp}, " \ "Ki: {ki}, " \ "Kd: {kd}, " \ "Integrator Min: {imn}, " \ "Integrator Max {imx}, " \ "Output Raise: {opr}, " \ "Output Raise Channel: {oprc}, " \ "Output Raise Type: {oprt}, " \ "Output Raise Min On: {oprmnon}, " \ "Output Raise Max On: {oprmxon}, " \ "Output Raise Min Off: {oprmnoff}, " \ "Output Raise Always Min: {opramn}, " \ "Output Lower: {opl}, " \ "Output Lower Channel: {oplc}, " \ "Output Lower Type: {oplt}, " \ "Output Lower Min On: {oplmnon}, " \ "Output Lower Max On: {oplmxon}, " \ "Output Lower Min Off: {oplmnoff}, " \ "Output Lower Always Min: {oplamn}, " \ "Setpoint Tracking Type: {sptt}, " \ "Setpoint Tracking ID: {spt}".format( did=self.device_id, mid=self.measurement_id, dir=self.PID_Controller.direction, per=self.period, sp=self.PID_Controller.setpoint, band=self.PID_Controller.band, kp=self.PID_Controller.Kp, ki=self.PID_Controller.Ki, kd=self.PID_Controller.Kd, imn=self.PID_Controller.integrator_min, imx=self.PID_Controller.integrator_max, opr=self.raise_output_id, oprc=self.raise_output_channel, oprt=self.raise_output_type, oprmnon=self.raise_min_duration, oprmxon=self.raise_max_duration, oprmnoff=self.raise_min_off_duration, opramn=self.raise_always_min_pwm, opl=self.lower_output_id, oplc=self.lower_output_channel, oplt=self.lower_output_type, oplmnon=self.lower_min_duration, oplmxon=self.lower_max_duration, oplmnoff=self.lower_min_off_duration, oplamn=self.lower_always_min_pwm, sptt=self.setpoint_tracking_type, spt=self.setpoint_tracking_id) def control_var_to_duty_cycle(self, control_variable): # Convert control variable to duty cycle if control_variable > self.period: return 100.0 else: return float((control_variable / self.period) * 100) @staticmethod def return_output_channel(output_channel_id): output_channel = db_retrieve_table_daemon(OutputChannel, unique_id=output_channel_id) if output_channel and output_channel.channel is not None: return output_channel.channel def write_pid_output_influxdb(self, unit, measurement, channel, value): write_pid_out_db = threading.Thread(target=write_influxdb_value, args=( self.unique_id, unit, value, ), kwargs={ 'measure': measurement, 'channel': channel }) write_pid_out_db.start() def pid_mod(self): if self.initialize_variables(): return "success" else: return "error" def pid_hold(self): self.is_held = True self.logger.info("Hold") return "success" def pid_pause(self): self.is_paused = True self.logger.info("Pause") return "success" def pid_resume(self): self.is_activated = True self.is_held = False self.is_paused = False self.logger.info("Resume") return "success" def set_setpoint(self, setpoint): """ Set the setpoint of PID """ self.PID_Controller.setpoint = float(setpoint) with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.unique_id).first() mod_pid.setpoint = setpoint db_session.commit() return "Setpoint set to {sp}".format(sp=setpoint) def set_method(self, method_id): """ Set the method of PID """ with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.unique_id).first() mod_pid.setpoint_tracking_id = method_id if method_id == '': self.setpoint_tracking_id = '' db_session.commit() else: mod_pid.method_start_time = None mod_pid.method_end_time = None db_session.commit() self.setup_method(method_id) return "Method set to {me}".format(me=method_id) def set_integrator(self, integrator): """ Set the integrator of the controller """ self.PID_Controller.integrator = float(integrator) return "Integrator set to {i}".format(i=self.PID_Controller.integrator) def set_derivator(self, derivator): """ Set the derivator of the controller """ self.PID_Controller.derivator = float(derivator) return "Derivator set to {d}".format(d=self.PID_Controller.derivator) def set_kp(self, p): """ Set Kp gain of the controller """ self.PID_Controller.Kp = float(p) with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.unique_id).first() mod_pid.p = p db_session.commit() return "Kp set to {kp}".format(kp=self.PID_Controller.Kp) def set_ki(self, i): """ Set Ki gain of the controller """ self.PID_Controller.Ki = float(i) with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.unique_id).first() mod_pid.i = i db_session.commit() return "Ki set to {ki}".format(ki=self.PID_Controller.Ki) def set_kd(self, d): """ Set Kd gain of the controller """ self.PID_Controller.Kd = float(d) with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.unique_id).first() mod_pid.d = d db_session.commit() return "Kd set to {kd}".format(kd=self.PID_Controller.Kd) def get_setpoint(self): return self.PID_Controller.setpoint def get_setpoint_band(self): return self.PID_Controller.setpoint_band def get_error(self): return self.PID_Controller.error def get_integrator(self): return self.PID_Controller.integrator def get_derivator(self): return self.PID_Controller.derivator def get_kp(self): return self.PID_Controller.Kp def get_ki(self): return self.PID_Controller.Ki def get_kd(self): return self.PID_Controller.Kd def stop_controller(self, ended_normally=True, deactivate_pid=False): self.thread_shutdown_timer = timeit.default_timer() self.running = False # Unset method start time if (self.setpoint_tracking_type == 'method' and self.setpoint_tracking_id != '' and ended_normally): with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.unique_id).first() mod_pid.method_start_time = None mod_pid.method_end_time = None db_session.commit() # Deactivate PID and Autotune if deactivate_pid: with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.unique_id).first() mod_pid.is_activated = False mod_pid.autotune_activated = False db_session.commit()
class CustomModule(AbstractController, threading.Thread): """ Class to operate custom controller """ def __init__(self, ready, unique_id, testing=False): threading.Thread.__init__(self) super(CustomModule, self).__init__(ready, unique_id=unique_id, name=__name__) self.unique_id = unique_id self.log_level_debug = None self.control = DaemonControl() # Initialize custom options self.text_1 = None self.integer_1 = None self.float_1 = None self.bool_1 = None self.select_1 = None self.select_measurement_1_device_id = None self.select_measurement_1_measurement_id = None self.output_1_device_id = None self.output_1_measurement_id = None self.output_1_channel_id = None self.select_device_1_id = None self.select_device_2_id = None # Set custom options custom_function = db_retrieve_table_daemon(CustomController, unique_id=unique_id) self.setup_custom_options(FUNCTION_INFORMATION['custom_options'], custom_function) # Get selected output channel number self.output_1_channel = self.get_output_channel_from_channel_id( self.output_1_channel_id) if not testing: pass # import controller-specific modules here def run(self): try: self.logger.info("Activated in {:.1f} ms".format( (timeit.default_timer() - self.thread_startup_timer) * 1000)) self.ready.set() self.running = True # Make sure the option "Log Level: Debug" is enabled for these # messages to appear in the daemon log. self.logger.debug( "Custom controller started with options: " "{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}".format( self.text_1, self.integer_1, self.float_1, self.bool_1, self.select_1, self.select_measurement_1_device_id, self.select_measurement_1_measurement_id, self.output_1_device_id, self.output_1_measurement_id, self.output_1_channel_id, self.select_device_1_id)) # Get last measurement for select_measurement_1 last_measurement = self.get_last_measurement( self.select_measurement_1_device_id, self.select_measurement_1_measurement_id) if last_measurement: self.logger.debug( "Most recent timestamp and measurement for " "select_measurement_1: {timestamp}, {meas}".format( timestamp=last_measurement[0], meas=last_measurement[1])) else: self.logger.debug( "Could not find a measurement in the database for " "select_measurement_1 device ID {} and measurement " "ID {}".format(self.select_measurement_1_device_id, self.select_measurement_1_measurement_id)) # Turn Output select_device_1 on for 15 seconds self.logger.debug( "Turning select_device_1 with ID {} on for 15 seconds...". format(self.select_device_1_id)) self.control.output_on(self.select_device_1_id, output_type='sec', output_channel=self.output_1_channel, amount=15) # Deactivate controller in the SQL database self.logger.debug( "Deactivating (SQL) Custom controller select_device_2 with ID {}" .format(self.select_device_2_id)) from mycodo.databases.utils import session_scope from mycodo.config import SQL_DATABASE_MYCODO MYCODO_DB_PATH = 'sqlite:///' + SQL_DATABASE_MYCODO with session_scope(MYCODO_DB_PATH) as new_session: mod_cont = new_session.query(CustomController).filter( CustomController.unique_id == self.select_device_2_id).first() mod_cont.is_activated = False new_session.commit() # Deactivate select_device_1_id in the dameon # Since we're deactivating this controller (itself), we need to thread this command # Note: this command will only deactivate the controller in the Daemon. It will still # be activated in the database, so the next restart of the daemon, this controller # will start back up again. This is why the previous action deactivated the controller # in the database prior to deactivating it in the daemon. self.logger.debug( "Deactivating (Daemon) Custom controller select_device_2 with" " ID {} ...".format(self.select_device_2_id)) deactivate_controller = threading.Thread( target=self.control.controller_deactivate, args=(self.select_device_2_id, )) deactivate_controller.start() # Start a loop while self.running: time.sleep(1) except: self.logger.exception("Run Error") finally: self.run_finally() self.running = False if self.thread_shutdown_timer: self.logger.info("Deactivated in {:.1f} ms".format( (timeit.default_timer() - self.thread_shutdown_timer) * 1000)) else: self.logger.error("Deactivated unexpectedly") def loop(self): pass def initialize_variables(self): controller = db_retrieve_table_daemon(CustomController, unique_id=self.unique_id) self.log_level_debug = controller.log_level_debug self.set_log_level_debug(self.log_level_debug)
class CustomModule(AbstractFunction): """ Class to operate custom controller """ def __init__(self, function, testing=False): super().__init__(function, testing=testing, name=__name__) self.control_variable = None self.timestamp = None self.control = DaemonControl() self.outputIsOn = False self.timer_loop = time.time() # Initialize custom options self.measurement_device_id = None self.measurement_measurement_id = None self.output_device_id = None self.output_measurement_id = None self.output_channel_id = None self.setpoint = None self.hysteresis = None self.direction = None self.output_channel = None self.update_period = None self.duty_cycle_increase = None self.duty_cycle_maintain = None self.duty_cycle_decrease = None self.duty_cycle_shutdown = None # Set custom options custom_function = db_retrieve_table_daemon(CustomController, unique_id=self.unique_id) self.setup_custom_options(FUNCTION_INFORMATION['custom_options'], custom_function) if not testing: self.try_initialize() def initialize(self): self.timestamp = time.time() self.output_channel = self.get_output_channel_from_channel_id( self.output_channel_id) self.logger.info( "Bang-Bang controller started with options: " "Measurement Device: {}, Measurement: {}, Output: {}, " "Output_Channel: {}, Setpoint: {}, Hysteresis: {}, " "Direction: {}, Increase: {}%, Maintain: {}%, Decrease: {}%, " "Shutdown: {}%, Period: {}".format( self.measurement_device_id, self.measurement_measurement_id, self.output_device_id, self.output_channel, self.setpoint, self.hysteresis, self.direction, self.duty_cycle_increase, self.duty_cycle_maintain, self.duty_cycle_decrease, self.duty_cycle_shutdown, self.update_period)) def loop(self): if self.timer_loop > time.time(): return while self.timer_loop < time.time(): self.timer_loop += self.update_period if self.output_channel is None: self.logger.error( "Cannot run bang-bang controller: Check output channel.") return last_measurement = self.get_last_measurement( self.measurement_device_id, self.measurement_measurement_id)[1] outputState = self.control.output_state(self.output_device_id, self.output_channel) self.logger.info("Input: {}, output: {}, target: {}, hyst: {}".format( last_measurement, outputState, self.setpoint, self.hysteresis)) if self.direction == 'raise': if last_measurement < (self.setpoint - self.hysteresis): self.control.output_on(self.output_device_id, output_type='pwm', amount=self.duty_cycle_increase, output_channel=self.output_channel) else: self.control.output_on(self.output_device_id, output_type='pwm', amount=self.duty_cycle_maintain, output_channel=self.output_channel) elif self.direction == 'lower': if last_measurement > (self.setpoint + self.hysteresis): self.control.output_on(self.output_device_id, output_type='pwm', amount=self.duty_cycle_decrease, output_channel=self.output_channel) else: self.control.output_on(self.output_device_id, output_type='pwm', amount=self.duty_cycle_maintain, output_channel=self.output_channel) elif self.direction == 'both': if last_measurement < (self.setpoint - self.hysteresis): self.control.output_on(self.output_device_id, output_type='pwm', amount=self.duty_cycle_increase, output_channel=self.output_channel) elif last_measurement > (self.setpoint + self.hysteresis): self.control.output_on(self.output_device_id, output_type='pwm', amount=self.duty_cycle_decrease, output_channel=self.output_channel) else: self.control.output_on(self.output_device_id, output_type='pwm', amount=self.duty_cycle_maintain, output_channel=self.output_channel) else: self.logger.info("Unknown controller direction: '{}'".format( self.direction)) def stop_function(self): self.control.output_on(self.output_device_id, output_type='pwm', amount=self.duty_cycle_shutdown, output_channel=self.output_channel)
class TriggerController(AbstractController, threading.Thread): """ Class to operate Trigger controller Triggers are events that are used to signal when a set of actions should be executed. The main loop in this class will continually check if any timer Triggers have elapsed. If any have, trigger_all_actions() will be ran to execute all actions associated with that particular trigger. Edge and Output conditionals are triggered from the Input and Output controllers, respectively, and the trigger_all_actions() function in this class will be ran. """ def __init__(self, ready, unique_id): threading.Thread.__init__(self) super(TriggerController, self).__init__(ready, unique_id=unique_id, name=__name__) self.unique_id = unique_id self.sample_rate = None self.control = DaemonControl() self.pause_loop = False self.verify_pause_loop = True self.trigger = None self.trigger_type = None self.trigger_name = None self.is_activated = None self.log_level_debug = None self.smtp_max_count = None self.email_count = None self.allowed_to_send_notice = None self.smtp_wait_timer = None self.timer_period = None self.period = None self.smtp_wait_timer = None self.timer_start_time = None self.timer_end_time = None self.unique_id_1 = None self.unique_id_2 = None self.trigger_actions_at_period = None self.trigger_actions_at_start = None self.method_start_time = None self.method_end_time = None self.method_start_act = None # Infrared remote input self.lirc = None self.program = None self.word = None def loop(self): # Pause loop to modify trigger. # Prevents execution of trigger while variables are # being modified. if self.pause_loop: self.verify_pause_loop = True while self.pause_loop: time.sleep(0.1) if self.trigger_type == 'trigger_infrared_remote_input': self.infrared_remote_input() elif (self.is_activated and self.timer_period and self.timer_period < time.time()): check_approved = False # Check if the trigger period has elapsed if self.trigger_type in ['trigger_sunrise_sunset', 'trigger_run_pwm_method']: while self.running and self.timer_period < time.time(): self.timer_period = calculate_sunrise_sunset_epoch(self.trigger) if self.trigger_type == 'trigger_run_pwm_method': # Only execute trigger actions when started # Now only set PWM output pwm_duty_cycle, ended = self.get_method_output( self.unique_id_1) if not ended: self.set_output_duty_cycle( self.unique_id_2, pwm_duty_cycle) if self.trigger_actions_at_period: trigger_function_actions( self.unique_id, debug=self.log_level_debug) else: check_approved = True elif (self.trigger_type in [ 'trigger_timer_daily_time_point', 'trigger_timer_daily_time_span', 'trigger_timer_duration']): if self.trigger_type == 'trigger_timer_daily_time_point': self.timer_period = epoch_of_next_time( '{hm}:00'.format(hm=self.timer_start_time)) elif self.trigger_type in ['trigger_timer_duration', 'trigger_timer_daily_time_span']: while self.running and self.timer_period < time.time(): self.timer_period += self.period check_approved = True if check_approved: self.attempt_execute(self.check_triggers) def run_finally(self): pass def refresh_settings(self): """ Signal to pause the main loop and wait for verification, the refresh settings """ self.pause_loop = True while not self.verify_pause_loop: time.sleep(0.1) self.logger.info("Refreshing trigger settings") self.initialize_variables() self.pause_loop = False self.verify_pause_loop = False return "Trigger settings successfully refreshed" def initialize_variables(self): """ Define all settings """ self.email_count = 0 self.allowed_to_send_notice = True self.sample_rate = db_retrieve_table_daemon( Misc, entry='first').sample_rate_controller_conditional self.smtp_max_count = db_retrieve_table_daemon( SMTP, entry='first').hourly_max self.trigger = db_retrieve_table_daemon( Trigger, unique_id=self.unique_id) self.trigger_type = self.trigger.trigger_type self.trigger_name = self.trigger.name self.is_activated = self.trigger.is_activated self.log_level_debug = self.trigger.log_level_debug self.set_log_level_debug(self.log_level_debug) now = time.time() self.smtp_wait_timer = now + 3600 self.timer_period = None # Set up trigger timer (daily time point) if self.trigger_type == 'trigger_timer_daily_time_point': self.timer_start_time = self.trigger.timer_start_time self.timer_period = epoch_of_next_time( '{hm}:00'.format(hm=self.trigger.timer_start_time)) # Set up trigger timer (daily time span) elif self.trigger_type == 'trigger_timer_daily_time_span': self.timer_start_time = self.trigger.timer_start_time self.timer_end_time = self.trigger.timer_end_time self.period = self.trigger.period self.timer_period = now # Set up trigger timer (duration) elif self.trigger_type == 'trigger_timer_duration': self.period = self.trigger.period if self.trigger.timer_start_offset: self.timer_period = now + self.trigger.timer_start_offset else: self.timer_period = now # Set up trigger Run PWM Method elif self.trigger_type == 'trigger_run_pwm_method': self.unique_id_1 = self.trigger.unique_id_1 self.unique_id_2 = self.trigger.unique_id_2 self.period = self.trigger.period self.trigger_actions_at_period = self.trigger.trigger_actions_at_period self.trigger_actions_at_start = self.trigger.trigger_actions_at_start self.method_start_time = self.trigger.method_start_time self.method_end_time = self.trigger.method_end_time if self.is_activated: self.start_method(self.trigger.unique_id_1) if self.trigger_actions_at_start: self.timer_period = now + self.trigger.period if self.is_activated: pwm_duty_cycle = self.get_method_output( self.trigger.unique_id_1) self.set_output_duty_cycle( self.trigger.unique_id_2, pwm_duty_cycle) trigger_function_actions(self.unique_id, debug=self.log_level_debug) else: self.timer_period = now elif self.trigger_type == 'trigger_infrared_remote_input': import lirc self.lirc = lirc self.program = self.trigger.program self.word = self.trigger.word lirc.init(self.program, config_filename='/home/pi/.lircrc', blocking=False) # Set up trigger sunrise/sunset elif self.trigger_type == 'trigger_sunrise_sunset': self.period = 60 # Set the next trigger at the specified sunrise/sunset time (+-offsets) self.timer_period = calculate_sunrise_sunset_epoch(self.trigger) def start_method(self, method_id): """ Instruct a method to start running """ if method_id: method = db_retrieve_table_daemon(Method, unique_id=method_id) method_data = db_retrieve_table_daemon(MethodData) method_data = method_data.filter(MethodData.method_id == method_id) method_data_repeat = method_data.filter( MethodData.duration_sec == 0).first() self.method_start_act = self.method_start_time self.method_start_time = None self.method_end_time = None if method.method_type == 'Duration': if self.method_start_act == 'Ended': with session_scope(MYCODO_DB_PATH) as db_session: mod_conditional = db_session.query(Trigger) mod_conditional = mod_conditional.filter( Trigger.unique_id == self.unique_id).first() mod_conditional.is_activated = False db_session.commit() self.stop_controller() self.logger.warning( "Method has ended. " "Activate the Trigger controller to start it again.") elif (self.method_start_act == 'Ready' or self.method_start_act is None): # Method has been instructed to begin now = datetime.datetime.now() self.method_start_time = now if method_data_repeat and method_data_repeat.duration_end: self.method_end_time = now + datetime.timedelta( seconds=float(method_data_repeat.duration_end)) with session_scope(MYCODO_DB_PATH) as db_session: mod_conditional = db_session.query(Trigger) mod_conditional = mod_conditional.filter( Trigger.unique_id == self.unique_id).first() mod_conditional.method_start_time = self.method_start_time mod_conditional.method_end_time = self.method_end_time db_session.commit() def get_method_output(self, method_id): """ Get output variable from method """ this_controller = db_retrieve_table_daemon( Trigger, unique_id=self.unique_id) setpoint, ended = calculate_method_setpoint( method_id, Trigger, this_controller, Method, MethodData, self.logger) if setpoint is not None: if setpoint > 100: setpoint = 100 elif setpoint < 0: setpoint = 0 if ended: with session_scope(MYCODO_DB_PATH) as db_session: mod_conditional = db_session.query(Trigger) mod_conditional = mod_conditional.filter( Trigger.unique_id == self.unique_id).first() mod_conditional.is_activated = False db_session.commit() self.is_activated = False self.stop_controller() return setpoint, ended def set_output_duty_cycle(self, output_id, duty_cycle): """ Set PWM Output duty cycle """ self.control.output_on(output_id, duty_cycle=duty_cycle) def check_triggers(self): """ Check if any Triggers are activated and execute their actions if so. For example, if measured temperature is above 30C, notify [email protected] "if measured temperature is above 30C" is the Trigger to check. "notify [email protected]" is the Trigger Action to execute if the Trigger is True. """ now = time.time() timestamp = datetime.datetime.fromtimestamp(now).strftime( '%Y-%m-%d %H:%M:%S') message = "{ts}\n[Trigger {id} ({name})]".format( ts=timestamp, name=self.trigger_name, id=self.unique_id) trigger = db_retrieve_table_daemon( Trigger, unique_id=self.unique_id, entry='first') device_id = trigger.measurement.split(',')[0] # if len(trigger.measurement.split(',')) > 1: # device_measurement = trigger.measurement.split(',')[1] # else: # device_measurement = None device = None input_dev = db_retrieve_table_daemon( Input, unique_id=device_id, entry='first') if input_dev: device = input_dev math = db_retrieve_table_daemon( Math, unique_id=device_id, entry='first') if math: device = math output = db_retrieve_table_daemon( Output, unique_id=device_id, entry='first') if output: device = output pid = db_retrieve_table_daemon( PID, unique_id=device_id, entry='first') if pid: device = pid if not device: message += " Error: Controller not Input, Math, Output, or PID" self.logger.error(message) return # If the edge detection variable is set, calling this function will # trigger an edge detection event. This will merely produce the correct # message based on the edge detection settings. elif trigger.trigger_type == 'trigger_edge': try: GPIO.setmode(GPIO.BCM) GPIO.setup(int(input_dev.pin), GPIO.IN) gpio_state = GPIO.input(int(input_dev.pin)) except: gpio_state = None self.logger.error("Exception reading the GPIO pin") if (gpio_state is not None and gpio_state == trigger.if_sensor_gpio_state): message += " GPIO State Detected (state = {state}).".format( state=trigger.if_sensor_gpio_state) else: self.logger.error("GPIO not configured correctly or GPIO state not verified") return # Calculate the sunrise/sunset times and find the next time this trigger should trigger elif trigger.trigger_type == 'trigger_sunrise_sunset': # Since the check time is the trigger time, we will only calculate and set the next trigger time self.timer_period = calculate_sunrise_sunset_epoch(trigger) # Check if the current time is between the start and end time elif trigger.trigger_type == 'trigger_timer_daily_time_span': if not time_between_range(self.timer_start_time, self.timer_end_time): return # If the code hasn't returned by now, action should be executed trigger_function_actions( self.unique_id, message=message, debug=self.log_level_debug) def infrared_remote_input(self): """ Wait for an infrared input signal Because only one thread will capture the button press, the thread that catches it will send a broadcast of the codes to all trigger threads. """ code = self.lirc.nextcode() if code: self.control.send_infrared_code_broadcast(code) def receive_infrared_code_broadcast(self, code): if self.word in code: timestamp = datetime.datetime.fromtimestamp( time.time()).strftime('%Y-%m-%d %H:%M:%S') message = "{ts}\n[Trigger {id} ({name})]".format( ts=timestamp, name=self.trigger_name, id=self.unique_id) message += "\nInfrared Remote Input detected " \ "'{word}' on program '{prog}'".format( word=self.word, prog=self.program) trigger_function_actions(self.unique_id, message=message, debug=self.log_level_debug)
class CustomModule(AbstractController, threading.Thread): """ Class to operate custom controller """ def __init__(self, ready, unique_id, testing=False): threading.Thread.__init__(self) super(CustomModule, self).__init__(ready, unique_id=unique_id, name=__name__) self.unique_id = unique_id self.log_level_debug = None self.autotune = None self.autotune_active = None self.control_variable = None self.timestamp = None self.timer = None self.control = DaemonControl() # Initialize custom options self.measurement_device_id = None self.measurement_measurement_id = None self.output_device_id = None self.output_measurement_id = None self.output_channel_id = None self.setpoint = None self.period = None self.noiseband = None self.outstep = None self.direction = None self.output_channel = None # Set custom options custom_function = db_retrieve_table_daemon(CustomController, unique_id=unique_id) self.setup_custom_options(FUNCTION_INFORMATION['custom_options'], custom_function) self.output_channel = self.get_output_channel_from_channel_id( self.output_channel_id) self.initialize_variables() def initialize_variables(self): controller = db_retrieve_table_daemon(CustomController, unique_id=self.unique_id) self.log_level_debug = controller.log_level_debug self.set_log_level_debug(self.log_level_debug) self.timestamp = time.time() self.autotune = PIDAutotune(self.setpoint, out_step=self.outstep, sampletime=self.period, out_min=0, out_max=self.period, noiseband=self.noiseband) def run(self): try: if self.output_channel is None: self.logger.error( "Cannot start PID Autotune: Could not find output channel." ) self.deactivate_self() return self.logger.info("Activated in {:.1f} ms".format( (timeit.default_timer() - self.thread_startup_timer) * 1000)) self.ready.set() self.running = True self.autotune_active = True self.timer = time.time() self.logger.info( "PID Autotune started with options: " "Measurement Device: {}, Measurement: {}, Output: {}, Output_Channel: {}, Setpoint: {}, " "Period: {}, Noise Band: {}, Outstep: {}, DIrection: {}". format(self.measurement_device_id, self.measurement_measurement_id, self.output_device_id, self.output_channel, self.setpoint, self.period, self.noiseband, self.outstep, self.direction)) # Start a loop while self.running: self.loop() time.sleep(0.1) except: self.logger.exception("Run Error") finally: self.run_finally() self.running = False if self.thread_shutdown_timer: self.logger.info("Deactivated in {:.1f} ms".format( (timeit.default_timer() - self.thread_shutdown_timer) * 1000)) else: self.logger.error("Deactivated unexpectedly") def loop(self): if time.time() > self.timer and self.autotune_active: while time.time() > self.timer: self.timer = self.timer + self.period last_measurement = self.get_last_measurement( self.measurement_device_id, self.measurement_measurement_id) if not self.autotune.run(last_measurement[1]): self.control_variable = self.autotune.output self.logger.info('') self.logger.info("state: {}".format(self.autotune.state)) self.logger.info("output: {}".format(self.autotune.output)) else: # Autotune has finished timestamp = time.time() - self.timestamp self.autotune_active = False self.logger.info('Autotune has finished') self.logger.info('time: {0} min'.format(round(timestamp / 60))) self.logger.info('state: {0}'.format(self.autotune.state)) if self.autotune.state == PIDAutotune.STATE_SUCCEEDED: self.logger.info('Autotube was successful') for rule in self.autotune.tuning_rules: params = self.autotune.get_pid_parameters(rule) self.logger.info('') self.logger.info('rule: {0}'.format(rule)) self.logger.info('Kp: {0}'.format(params.Kp)) self.logger.info('Ki: {0}'.format(params.Ki)) self.logger.info('Kd: {0}'.format(params.Kd)) else: self.logger.info('Autotune was not successful') # Finally, deactivate controller self.deactivate_self() return self.control.output_on(self.output_device_id, output_type='sec', output_channel=self.output_channel, amount=self.control_variable) def deactivate_self(self): self.logger.info("Deactivating Autotune Function") with session_scope(MYCODO_DB_PATH) as new_session: mod_cont = new_session.query(CustomController).filter( CustomController.unique_id == self.unique_id).first() mod_cont.is_activated = False new_session.commit() deactivate_controller = threading.Thread( target=self.control.controller_deactivate, args=(self.unique_id, )) deactivate_controller.start()
class CustomModule(AbstractController, threading.Thread): """ Class to operate custom controller """ def __init__(self, ready, unique_id, testing=False): threading.Thread.__init__(self) super(CustomModule, self).__init__(ready, unique_id=unique_id, name=__name__) self.unique_id = unique_id self.log_level_debug = None self.control_variable = None self.timestamp = None self.timer = None self.control = DaemonControl() self.outputIsOn = False # Initialize custom options self.measurement_device_id = None self.measurement_measurement_id = None self.output_device_id = None self.output_measurement_id = None self.output_channel_id = None self.setpoint = None self.hysteresis = None self.direction = None self.output_channel = None self.update_period = None # Set custom options custom_function = db_retrieve_table_daemon(CustomController, unique_id=unique_id) self.setup_custom_options(FUNCTION_INFORMATION['custom_options'], custom_function) self.output_channel = self.get_output_channel_from_channel_id( self.output_channel_id) self.initialize_variables() def initialize_variables(self): controller = db_retrieve_table_daemon(CustomController, unique_id=self.unique_id) self.log_level_debug = controller.log_level_debug self.set_log_level_debug(self.log_level_debug) self.timestamp = time.time() def run(self): try: if self.output_channel is None: self.logger.error( "Cannot start bang-bang controller: Could not find output channel." ) self.deactivate_self() return self.logger.info("Activated in {:.1f} ms".format( (timeit.default_timer() - self.thread_startup_timer) * 1000)) self.ready.set() self.running = True self.timer = time.time() self.logger.info( "Bang-Bang controller started with options: " "Measurement Device: {}, Measurement: {}, Output: {}, " "Output_Channel: {}, Setpoint: {}, Hysteresis: {}, " "Direction: {}, Period: {}".format( self.measurement_device_id, self.measurement_measurement_id, self.output_device_id, self.output_channel, self.setpoint, self.hysteresis, self.direction, self.update_period)) # Start a loop while self.running: self.loop() time.sleep(self.update_period) except: self.logger.exception("Run Error") finally: self.run_finally() self.running = False if self.thread_shutdown_timer: self.logger.info("Deactivated in {:.1f} ms".format( (timeit.default_timer() - self.thread_shutdown_timer) * 1000)) else: self.logger.error("Deactivated unexpectedly") def loop(self): last_measurement = self.get_last_measurement( self.measurement_device_id, self.measurement_measurement_id)[1] outputState = self.control.output_state(self.output_device_id, self.output_channel) self.logger.info("Input: {}, output: {}, target: {}, hyst: {}".format( last_measurement, outputState, self.setpoint, self.hysteresis)) if self.direction == 'raise': if outputState == 'on': # looking to turn output off if last_measurement > (self.setpoint + self.hysteresis): self.control.output_off( self.output_device_id, output_channel=self.output_channel, ) else: # looking to turn output on if last_measurement < (self.setpoint - self.hysteresis): self.control.output_on(self.output_device_id, output_channel=self.output_channel) elif self.direction == 'lower': if outputState == 'on': # looking to turn output off if last_measurement < (self.setpoint - self.hysteresis): self.control.output_off( self.output_device_id, output_channel=self.output_channel, ) else: # looking to turn output on if last_measurement > (self.setpoint + self.hysteresis): self.control.output_on(self.output_device_id, output_channel=self.output_channel) else: self.logger.info("Unknown controller direction: {}".format( self.direction)) def deactivate_self(self): self.logger.info("Deactivating bang-bang controller") with session_scope(MYCODO_DB_PATH) as new_session: mod_cont = new_session.query(CustomController).filter( CustomController.unique_id == self.unique_id).first() mod_cont.is_activated = False new_session.commit() deactivate_controller = threading.Thread( target=self.control.controller_deactivate, args=(self.unique_id, )) deactivate_controller.start() def pre_stop(self): self.control.output_off(self.output_device_id, self.output_channel)
def camera_record(record_type, unique_id, duration_sec=None, tmp_filename=None): """ Record still image from cameras :param record_type: :param unique_id: :param duration_sec: :param tmp_filename: :return: """ daemon_control = None settings = db_retrieve_table_daemon(Camera, unique_id=unique_id) timestamp = datetime.datetime.now().strftime('%Y-%m-%d_%H-%M-%S') assure_path_exists(PATH_CAMERAS) camera_path = assure_path_exists( os.path.join(PATH_CAMERAS, '{uid}'.format(uid=settings.unique_id))) if record_type == 'photo': if settings.path_still != '': save_path = settings.path_still else: save_path = assure_path_exists(os.path.join(camera_path, 'still')) filename = 'Still-{cam_id}-{cam}-{ts}.jpg'.format( cam_id=settings.id, cam=settings.name, ts=timestamp).replace(" ", "_") elif record_type == 'timelapse': if settings.path_timelapse != '': save_path = settings.path_timelapse else: save_path = assure_path_exists(os.path.join(camera_path, 'timelapse')) start = datetime.datetime.fromtimestamp( settings.timelapse_start_time).strftime("%Y-%m-%d_%H-%M-%S") filename = 'Timelapse-{cam_id}-{cam}-{st}-img-{cn:05d}.jpg'.format( cam_id=settings.id, cam=settings.name, st=start, cn=settings.timelapse_capture_number).replace(" ", "_") elif record_type == 'video': if settings.path_video != '': save_path = settings.path_video else: save_path = assure_path_exists(os.path.join(camera_path, 'video')) filename = 'Video-{cam}-{ts}.h264'.format( cam=settings.name, ts=timestamp).replace(" ", "_") else: return assure_path_exists(save_path) if tmp_filename: filename = tmp_filename path_file = os.path.join(save_path, filename) # Turn on output, if configured if settings.output_id: daemon_control = DaemonControl() daemon_control.output_on(settings.output_id) # Pause while the output remains on for the specified duration. # Used for instance to allow fluorescent lights to fully turn on before # capturing an image. if settings.output_duration: time.sleep(settings.output_duration) if settings.library == 'picamera': # Try 5 times to access the pi camera (in case another process is accessing it) for _ in range(5): try: with picamera.PiCamera() as camera: camera.resolution = (settings.width, settings.height) camera.hflip = settings.hflip camera.vflip = settings.vflip camera.rotation = settings.rotation camera.brightness = int(settings.brightness) camera.contrast = int(settings.contrast) camera.exposure_compensation = int(settings.exposure) camera.saturation = int(settings.saturation) camera.start_preview() time.sleep(2) # Camera warm-up time if record_type in ['photo', 'timelapse']: camera.capture(path_file, use_video_port=False) elif record_type == 'video': camera.start_recording(path_file, format='h264', quality=20) camera.wait_recording(duration_sec) camera.stop_recording() else: return break except picamera.exc.PiCameraMMALError: logger.error("The camera is already open by picamera. Retrying 4 times.") time.sleep(1) elif settings.library == 'fswebcam': cmd = "/usr/bin/fswebcam --device {dev} --resolution {w}x{h} --set brightness={bt}% " \ "--no-banner --save {file}".format(dev=settings.device, w=settings.width, h=settings.height, bt=settings.brightness, file=path_file) if settings.hflip: cmd += " --flip h" if settings.vflip: cmd += " --flip h" if settings.rotation: cmd += " --rotate {angle}".format(angle=settings.rotation) if settings.custom_options: cmd += " " + settings.custom_options out, err, status = cmd_output(cmd, stdout_pipe=False) # logger.error("TEST01: {}; {}; {}; {}".format(cmd, out, err, status)) # Turn off output, if configured if settings.output_id and daemon_control: daemon_control.output_off(settings.output_id) try: set_user_grp(path_file, 'mycodo', 'mycodo') return save_path, filename except Exception as e: logger.exception( "Exception raised in 'camera_record' when setting user grp: " "{err}".format(err=e))
class InputModule(AbstractInput): """ A sensor support class that measures the DHT22's humidity and temperature and calculates the dew point An adaptation of DHT22 code from https://github.com/joan2937/pigpio The sensor is also known as the AM2302. The sensor can be powered from the Pi 3.3-volt or 5-volt rail. Powering from the 3.3-volt rail is simpler and safer. You may need to power from 5 if the sensor is connected via a long cable. For 3.3-volt operation connect pin 1 to 3.3 volts and pin 4 to ground. Connect pin 2 to a gpio. For 5-volt operation connect pin 1 to the 5 volts and pin 4 to ground. The following pin 2 connection works for me. Use at YOUR OWN RISK. 5V--5K_resistor--+--10K_resistor--Ground | DHT22 pin 2 -----+ | gpio ------------+ """ def __init__(self, input_dev, testing=False): """ Instantiate with the Pi and gpio to which the DHT22 output pin is connected. Optionally a gpio used to power the sensor may be specified. This gpio will be set high to power the sensor. If the sensor locks it will be power cycled to restart the readings. Taking readings more often than about once every two seconds will eventually cause the DHT22 to hang. A 3 second interval seems OK. """ super(InputModule, self).__init__(input_dev, testing=testing, name=__name__) self.temp_temperature = None self.temp_humidity = None self.temp_dew_point = None self.temp_vpd = None self.power_output_id = None self.powered = False self.pi = None if not testing: import pigpio from mycodo.mycodo_client import DaemonControl self.power_output_id = input_dev.power_output_id self.control = DaemonControl() self.pigpio = pigpio self.pi = self.pigpio.pi() self.gpio = int(input_dev.gpio_location) self.bad_CS = 0 # Bad checksum count self.bad_SM = 0 # Short message count self.bad_MM = 0 # Missing message count self.bad_SR = 0 # Sensor reset count # Power cycle if timeout > MAX_NO_RESPONSE self.MAX_NO_RESPONSE = 3 self.no_response = None self.tov = None self.high_tick = None self.bit = None self.either_edge_cb = None self.start_input() def get_measurement(self): """ Gets the humidity and temperature """ self.return_dict = measurements_dict.copy() if not self.pi.connected: # Check if pigpiod is running self.logger.error('Could not connect to pigpiod. ' 'Ensure it is running and try again.') return None, None, None # Ensure if the power pin turns off, it is turned back on if (self.power_output_id and db_retrieve_table_daemon(Output, unique_id=self.power_output_id) and self.control.output_state(self.power_output_id) == 'off'): self.logger.error( 'Sensor power output {rel} detected as being off. ' 'Turning on.'.format(rel=self.power_output_id)) self.start_input() time.sleep(2) # Try twice to get measurement. This prevents an anomaly where # the first measurement fails if the sensor has just been powered # for the first time. for _ in range(4): self.measure_sensor() if self.temp_dew_point is not None: if self.is_enabled(0): self.value_set(0, self.temp_temperature) if self.is_enabled(1): self.value_set(1, self.temp_humidity) if (self.is_enabled(2) and self.is_enabled(0) and self.is_enabled(1)): self.value_set(2, self.temp_dew_point) if (self.is_enabled(3) and self.is_enabled(0) and self.is_enabled(1)): self.value_set(3, self.temp_vpd) return self.return_dict # success - no errors time.sleep(2) # Measurement failure, power cycle the sensor (if enabled) # Then try two more times to get a measurement if self.power_output_id is not None and self.running: self.stop_input() time.sleep(3) self.start_input() for _ in range(2): self.measure_sensor() if self.temp_dew_point is not None: if self.is_enabled(0): self.value_set(0, self.temp_temperature) if self.is_enabled(1): self.value_set(1, self.temp_humidity) if (self.is_enabled(2) and self.is_enabled(0) and self.is_enabled(1)): self.value_set(2, self.temp_dew_point) if (self.is_enabled(3) and self.is_enabled(0) and self.is_enabled(1)): self.value_set(3, self.temp_vpd) return self.return_dict # success - no errors time.sleep(2) self.logger.debug("Could not acquire a measurement") return None def measure_sensor(self): self.temp_temperature = None self.temp_humidity = None self.temp_dew_point = None self.temp_vpd = None initialized = False try: self.close() time.sleep(0.2) self.setup() time.sleep(0.2) initialized = True except Exception as except_msg: self.logger.error( "Could not initialize sensor. Check if it's connected " "properly and pigpiod is running. Error: {msg}".format( msg=except_msg)) if initialized: try: self.pi.write(self.gpio, self.pigpio.LOW) time.sleep(0.017) # 17 ms self.pi.set_mode(self.gpio, self.pigpio.INPUT) self.pi.set_watchdog(self.gpio, 200) time.sleep(0.2) if (self.temp_humidity is not None and self.temp_temperature is not None): self.temp_dew_point = calculate_dewpoint( self.temp_temperature, self.temp_humidity) self.temp_vpd = calculate_vapor_pressure_deficit( self.temp_temperature, self.temp_humidity) except Exception as e: self.logger.exception( "Exception when taking a reading: {err}".format( err=e)) finally: self.close() def setup(self): """ Clears the internal gpio pull-up/down resistor. Kills any watchdogs. Setup callbacks """ self.no_response = 0 self.tov = None self.high_tick = 0 self.bit = 40 self.either_edge_cb = None self.pi.set_pull_up_down(self.gpio, self.pigpio.PUD_OFF) self.pi.set_watchdog(self.gpio, 0) # Kill any watchdogs self.register_callbacks() def register_callbacks(self): """ Monitors RISING_EDGE changes using callback """ self.either_edge_cb = self.pi.callback(self.gpio, self.pigpio.EITHER_EDGE, self.either_edge_callback) def either_edge_callback(self, gpio, level, tick): """ Either Edge callbacks, called each time the gpio edge changes. Accumulate the 40 data bits from the DHT22 sensor. Format into 5 bytes, humidity high, humidity low, temperature high, temperature low, checksum. """ level_handlers = { self.pigpio.FALLING_EDGE: self._edge_fall, self.pigpio.RISING_EDGE: self._edge_rise, self.pigpio.EITHER_EDGE: self._edge_either } handler = level_handlers[level] diff = self.pigpio.tickDiff(self.high_tick, tick) handler(tick, diff) def _edge_rise(self, tick, diff): """ Handle Rise signal """ # Edge length determines if bit is 1 or 0. if diff >= 50: val = 1 if diff >= 200: # Bad bit? self.CS = 256 # Force bad checksum. else: val = 0 if self.bit >= 40: # Message complete. self.bit = 40 elif self.bit >= 32: # In checksum byte. self.CS = (self.CS << 1) + val if self.bit == 39: # 40th bit received. self.pi.set_watchdog(self.gpio, 0) self.no_response = 0 total = self.hH + self.hL + self.tH + self.tL if (total & 255) == self.CS: # Is checksum ok? self.temp_humidity = ((self.hH << 8) + self.hL) * 0.1 if self.tH & 128: # Negative temperature. mult = -0.1 self.tH &= 127 else: mult = 0.1 self.temp_temperature = ((self.tH << 8) + self.tL) * mult self.tov = time.time() else: self.bad_CS += 1 elif self.bit >= 24: # in temp low byte self.tL = (self.tL << 1) + val elif self.bit >= 16: # in temp high byte self.tH = (self.tH << 1) + val elif self.bit >= 8: # in humidity low byte self.hL = (self.hL << 1) + val elif self.bit >= 0: # in humidity high byte self.hH = (self.hH << 1) + val self.bit += 1 def _edge_fall(self, tick, diff): """ Handle Fall signal """ # Edge length determines if bit is 1 or 0. self.high_tick = tick if diff <= 250000: return self.bit = -2 self.hH = 0 self.hL = 0 self.tH = 0 self.tL = 0 self.CS = 0 def _edge_either(self, tick, diff): """ Handle Either signal or Timeout """ self.pi.set_watchdog(self.gpio, 0) if self.bit < 8: # Too few data bits received. self.bad_MM += 1 # Bump missing message count. self.no_response += 1 if self.no_response > self.MAX_NO_RESPONSE: self.no_response = 0 self.bad_SR += 1 # Bump sensor reset count. if self.power_output_id is not None: self.logger.error( "Invalid data, power cycling sensor.") self.stop_input() time.sleep(2) self.start_input() elif self.bit < 39: # Short message received. self.bad_SM += 1 # Bump short message count. self.no_response = 0 else: # Full message received. self.no_response = 0 def staleness(self): """ Return time since measurement made """ if self.tov is not None: return time.time() - self.tov else: return -999 def bad_checksum(self): """ Return count of messages received with bad checksums """ return self.bad_CS def short_message(self): """ Return count of short messages """ return self.bad_SM def missing_message(self): """ Return count of missing messages """ return self.bad_MM def sensor_resets(self): """ Return count of power cycles because of sensor hangs """ return self.bad_SR def close(self): """ Stop reading sensor, remove callbacks """ self.pi.set_watchdog(self.gpio, 0) if self.either_edge_cb: self.either_edge_cb.cancel() self.either_edge_cb = None def start_input(self): """ Turn the sensor on """ if self.power_output_id: self.logger.info("Turning on sensor") self.control.output_on(self.power_output_id, 0) time.sleep(2) self.powered = True def stop_input(self): """ Turn the sensor off """ if self.power_output_id: self.logger.info("Turning off sensor") self.control.output_off(self.power_output_id) self.powered = False
class InputModule(AbstractInput): """ A sensor support class that measures the DHT22's humidity and temperature and calculates the dew point An adaptation of DHT22 code from https://github.com/joan2937/pigpio The sensor is also known as the AM2302. The sensor can be powered from the Pi 3.3-volt or 5-volt rail. Powering from the 3.3-volt rail is simpler and safer. You may need to power from 5 if the sensor is connected via a long cable. For 3.3-volt operation connect pin 1 to 3.3 volts and pin 4 to ground. Connect pin 2 to a gpio. For 5-volt operation connect pin 1 to the 5 volts and pin 4 to ground. The following pin 2 connection works for me. Use at YOUR OWN RISK. 5V--5K_resistor--+--10K_resistor--Ground | DHT22 pin 2 -----+ | gpio ------------+ """ def __init__(self, input_dev, testing=False): """ Instantiate with the Pi and gpio to which the DHT22 output pin is connected. Optionally a gpio used to power the sensor may be specified. This gpio will be set high to power the sensor. If the sensor locks it will be power cycled to restart the readings. Taking readings more often than about once every two seconds will eventually cause the DHT22 to hang. A 3 second interval seems OK. """ super(InputModule, self).__init__() self.logger = logging.getLogger('mycodo.inputs.dht22') self.temp_temperature = None self.temp_humidity = None self.temp_dew_point = None self.temp_vpd = None self.power_output_id = None self.powered = False self.pi = None if not testing: import pigpio from mycodo.mycodo_client import DaemonControl self.logger = logging.getLogger( 'mycodo.dht22_{id}'.format(id=input_dev.unique_id.split('-')[0])) self.device_measurements = db_retrieve_table_daemon( DeviceMeasurements).filter( DeviceMeasurements.device_id == input_dev.unique_id) self.power_output_id = input_dev.power_output_id self.control = DaemonControl() self.pigpio = pigpio self.pi = self.pigpio.pi() self.gpio = int(input_dev.gpio_location) self.bad_CS = 0 # Bad checksum count self.bad_SM = 0 # Short message count self.bad_MM = 0 # Missing message count self.bad_SR = 0 # Sensor reset count # Power cycle if timeout > MAX_NO_RESPONSE self.MAX_NO_RESPONSE = 3 self.no_response = None self.tov = None self.high_tick = None self.bit = None self.either_edge_cb = None self.start_sensor() def get_measurement(self): """ Gets the humidity and temperature """ return_dict = measurements_dict.copy() if not self.pi.connected: # Check if pigpiod is running self.logger.error('Could not connect to pigpiod. ' 'Ensure it is running and try again.') return None, None, None # Ensure if the power pin turns off, it is turned back on if (self.power_output_id and db_retrieve_table_daemon(Output, unique_id=self.power_output_id) and self.control.output_state(self.power_output_id) == 'off'): self.logger.error( 'Sensor power output {rel} detected as being off. ' 'Turning on.'.format(rel=self.power_output_id)) self.start_sensor() time.sleep(2) # Try twice to get measurement. This prevents an anomaly where # the first measurement fails if the sensor has just been powered # for the first time. for _ in range(4): self.measure_sensor() if self.temp_dew_point is not None: if self.is_enabled(0): return_dict[0]['value'] = self.temp_temperature if self.is_enabled(1): return_dict[1]['value'] = self.temp_humidity if (self.is_enabled(2) and self.is_enabled(0) and self.is_enabled(1)): return_dict[2]['value'] = self.temp_dew_point if (self.is_enabled(3) and self.is_enabled(0) and self.is_enabled(1)): return_dict[3]['value'] = self.temp_vpd return return_dict # success - no errors time.sleep(2) # Measurement failure, power cycle the sensor (if enabled) # Then try two more times to get a measurement if self.power_output_id is not None and self.running: self.stop_sensor() time.sleep(3) self.start_sensor() for _ in range(2): self.measure_sensor() if self.temp_dew_point is not None: if self.is_enabled(0): return_dict[0]['value'] = self.temp_temperature if self.is_enabled(1): return_dict[1]['value'] = self.temp_humidity if (self.is_enabled(2) and self.is_enabled(0) and self.is_enabled(1)): return_dict[2]['value'] = self.temp_dew_point if (self.is_enabled(3) and self.is_enabled(0) and self.is_enabled(1)): return_dict[3]['value'] = self.temp_vpd return return_dict # success - no errors time.sleep(2) self.logger.debug("Could not acquire a measurement") return None def measure_sensor(self): self.temp_temperature = None self.temp_humidity = None self.temp_dew_point = None self.temp_vpd = None initialized = False try: self.close() time.sleep(0.2) self.setup() time.sleep(0.2) initialized = True except Exception as except_msg: self.logger.error( "Could not initialize sensor. Check if it's connected " "properly and pigpiod is running. Error: {msg}".format( msg=except_msg)) if initialized: try: self.pi.write(self.gpio, self.pigpio.LOW) time.sleep(0.017) # 17 ms self.pi.set_mode(self.gpio, self.pigpio.INPUT) self.pi.set_watchdog(self.gpio, 200) time.sleep(0.2) if (self.temp_humidity is not None and self.temp_temperature is not None): self.temp_dew_point = calculate_dewpoint( self.temp_temperature, self.temp_humidity) self.temp_vpd = calculate_vapor_pressure_deficit( self.temp_temperature, self.temp_humidity) except Exception as e: self.logger.exception( "Exception when taking a reading: {err}".format( err=e)) finally: self.close() def setup(self): """ Clears the internal gpio pull-up/down resistor. Kills any watchdogs. Setup callbacks """ self.no_response = 0 self.tov = None self.high_tick = 0 self.bit = 40 self.either_edge_cb = None self.pi.set_pull_up_down(self.gpio, self.pigpio.PUD_OFF) self.pi.set_watchdog(self.gpio, 0) # Kill any watchdogs self.register_callbacks() def register_callbacks(self): """ Monitors RISING_EDGE changes using callback """ self.either_edge_cb = self.pi.callback(self.gpio, self.pigpio.EITHER_EDGE, self.either_edge_callback) def either_edge_callback(self, gpio, level, tick): """ Either Edge callbacks, called each time the gpio edge changes. Accumulate the 40 data bits from the DHT22 sensor. Format into 5 bytes, humidity high, humidity low, temperature high, temperature low, checksum. """ level_handlers = { self.pigpio.FALLING_EDGE: self._edge_fall, self.pigpio.RISING_EDGE: self._edge_rise, self.pigpio.EITHER_EDGE: self._edge_either } handler = level_handlers[level] diff = self.pigpio.tickDiff(self.high_tick, tick) handler(tick, diff) def _edge_rise(self, tick, diff): """ Handle Rise signal """ # Edge length determines if bit is 1 or 0. if diff >= 50: val = 1 if diff >= 200: # Bad bit? self.CS = 256 # Force bad checksum. else: val = 0 if self.bit >= 40: # Message complete. self.bit = 40 elif self.bit >= 32: # In checksum byte. self.CS = (self.CS << 1) + val if self.bit == 39: # 40th bit received. self.pi.set_watchdog(self.gpio, 0) self.no_response = 0 total = self.hH + self.hL + self.tH + self.tL if (total & 255) == self.CS: # Is checksum ok? self.temp_humidity = ((self.hH << 8) + self.hL) * 0.1 if self.tH & 128: # Negative temperature. mult = -0.1 self.tH &= 127 else: mult = 0.1 self.temp_temperature = ((self.tH << 8) + self.tL) * mult self.tov = time.time() else: self.bad_CS += 1 elif self.bit >= 24: # in temp low byte self.tL = (self.tL << 1) + val elif self.bit >= 16: # in temp high byte self.tH = (self.tH << 1) + val elif self.bit >= 8: # in humidity low byte self.hL = (self.hL << 1) + val elif self.bit >= 0: # in humidity high byte self.hH = (self.hH << 1) + val self.bit += 1 def _edge_fall(self, tick, diff): """ Handle Fall signal """ # Edge length determines if bit is 1 or 0. self.high_tick = tick if diff <= 250000: return self.bit = -2 self.hH = 0 self.hL = 0 self.tH = 0 self.tL = 0 self.CS = 0 def _edge_either(self, tick, diff): """ Handle Either signal or Timeout """ self.pi.set_watchdog(self.gpio, 0) if self.bit < 8: # Too few data bits received. self.bad_MM += 1 # Bump missing message count. self.no_response += 1 if self.no_response > self.MAX_NO_RESPONSE: self.no_response = 0 self.bad_SR += 1 # Bump sensor reset count. if self.power_output_id is not None: self.logger.error( "Invalid data, power cycling sensor.") self.stop_sensor() time.sleep(2) self.start_sensor() elif self.bit < 39: # Short message received. self.bad_SM += 1 # Bump short message count. self.no_response = 0 else: # Full message received. self.no_response = 0 def staleness(self): """ Return time since measurement made """ if self.tov is not None: return time.time() - self.tov else: return -999 def bad_checksum(self): """ Return count of messages received with bad checksums """ return self.bad_CS def short_message(self): """ Return count of short messages """ return self.bad_SM def missing_message(self): """ Return count of missing messages """ return self.bad_MM def sensor_resets(self): """ Return count of power cycles because of sensor hangs """ return self.bad_SR def close(self): """ Stop reading sensor, remove callbacks """ self.pi.set_watchdog(self.gpio, 0) if self.either_edge_cb: self.either_edge_cb.cancel() self.either_edge_cb = None def start_sensor(self): """ Turn the sensor on """ if self.power_output_id: self.logger.info("Turning on sensor") self.control.output_on(self.power_output_id, 0) time.sleep(2) self.powered = True def stop_sensor(self): """ Turn the sensor off """ if self.power_output_id: self.logger.info("Turning off sensor") self.control.output_off(self.power_output_id) self.powered = False
def post(self, unique_id): """Change the state of an output""" if not utils_general.user_has_permission('edit_controllers'): abort(403) control = DaemonControl() state = None duration = None duty_cycle = None if ns_output.payload: if 'state' in ns_output.payload: state = ns_output.payload["state"] if state is not None: try: state = bool(state) except Exception: abort(422, message='state must represent a bool value') if 'duration' in ns_output.payload: duration = ns_output.payload["duration"] if duration is not None: try: duration = float(duration) except Exception: abort(422, message='duration does not represent a number') else: duration = 0 if 'duty_cycle' in ns_output.payload: duty_cycle = ns_output.payload["duty_cycle"] if duty_cycle is not None: try: duty_cycle = float(duty_cycle) if duty_cycle < 0 or duty_cycle > 100: abort(422, message='Required: 0 <= duty_cycle <= 100') except Exception: abort( 422, message='duty_cycle does not represent float value' ) try: if state is not None and duration is not None: return_ = control.output_on_off(unique_id, state, amount=duration) elif state is not None: return_ = control.output_on_off(unique_id, state) elif duty_cycle is not None: return_ = control.output_on(unique_id, duty_cycle=duty_cycle) else: return {'message': 'Insufficient payload'}, 460 return return_handler(return_) except Exception: abort(500, message='An exception occurred', error=traceback.format_exc())
def camera_record(record_type, unique_id, duration_sec=None, tmp_filename=None): """ Record still image from cameras :param record_type: :param unique_id: :param duration_sec: :param tmp_filename: :return: """ daemon_control = None settings = db_retrieve_table_daemon(Camera, unique_id=unique_id) timestamp = datetime.datetime.now().strftime('%Y-%m-%d_%H-%M-%S') assure_path_exists(PATH_CAMERAS) camera_path = assure_path_exists( os.path.join(PATH_CAMERAS, '{uid}'.format(uid=settings.unique_id))) if record_type == 'photo': if settings.path_still != '': save_path = settings.path_still else: save_path = assure_path_exists(os.path.join(camera_path, 'still')) filename = 'Still-{cam_id}-{cam}-{ts}.jpg'.format( cam_id=settings.id, cam=settings.name, ts=timestamp).replace(" ", "_") elif record_type == 'timelapse': if settings.path_timelapse != '': save_path = settings.path_timelapse else: save_path = assure_path_exists( os.path.join(camera_path, 'timelapse')) start = datetime.datetime.fromtimestamp( settings.timelapse_start_time).strftime("%Y-%m-%d_%H-%M-%S") filename = 'Timelapse-{cam_id}-{cam}-{st}-img-{cn:05d}.jpg'.format( cam_id=settings.id, cam=settings.name, st=start, cn=settings.timelapse_capture_number).replace(" ", "_") elif record_type == 'video': if settings.path_video != '': save_path = settings.path_video else: save_path = assure_path_exists(os.path.join(camera_path, 'video')) filename = 'Video-{cam}-{ts}.h264'.format(cam=settings.name, ts=timestamp).replace( " ", "_") else: return assure_path_exists(save_path) if tmp_filename: filename = tmp_filename path_file = os.path.join(save_path, filename) # Turn on output, if configured if settings.output_id: daemon_control = DaemonControl() daemon_control.output_on(settings.output_id) # Pause while the output remains on for the specified duration. # Used for instance to allow fluorescent lights to fully turn on before # capturing an image. if settings.output_duration: time.sleep(settings.output_duration) if settings.library == 'picamera': # Try 5 times to access the pi camera (in case another process is accessing it) for _ in range(5): try: with picamera.PiCamera() as camera: camera.resolution = (settings.width, settings.height) camera.hflip = settings.hflip camera.vflip = settings.vflip camera.rotation = settings.rotation camera.brightness = int(settings.brightness) camera.contrast = int(settings.contrast) camera.exposure_compensation = int(settings.exposure) camera.saturation = int(settings.saturation) camera.shutter_speed = settings.picamera_shutter_speed camera.sharpness = settings.picamera_sharpness camera.iso = settings.picamera_iso camera.awb_mode = settings.picamera_awb if settings.picamera_awb == 'off': camera.awb_gains = (settings.picamera_awb_gain_red, settings.picamera_awb_gain_blue) camera.exposure_mode = settings.picamera_exposure_mode camera.meter_mode = settings.picamera_meter_mode camera.image_effect = settings.picamera_image_effect camera.start_preview() time.sleep(2) # Camera warm-up time if record_type in ['photo', 'timelapse']: camera.capture(path_file, use_video_port=False) elif record_type == 'video': camera.start_recording(path_file, format='h264', quality=20) camera.wait_recording(duration_sec) camera.stop_recording() else: return break except picamera.exc.PiCameraMMALError: logger.error( "The camera is already open by picamera. Retrying 4 times." ) time.sleep(1) elif settings.library == 'fswebcam': cmd = "/usr/bin/fswebcam --device {dev} --resolution {w}x{h} --set brightness={bt}% " \ "--no-banner --save {file}".format(dev=settings.device, w=settings.width, h=settings.height, bt=settings.brightness, file=path_file) if settings.hflip: cmd += " --flip h" if settings.vflip: cmd += " --flip h" if settings.rotation: cmd += " --rotate {angle}".format(angle=settings.rotation) if settings.custom_options: cmd += " {}".format(settings.custom_options) out, err, status = cmd_output(cmd, stdout_pipe=False, user='******') logger.debug("Camera debug message: " "cmd: {}; out: {}; error: {}; status: {}".format( cmd, out, err, status)) # Turn off output, if configured if settings.output_id and daemon_control: daemon_control.output_off(settings.output_id) try: set_user_grp(path_file, 'mycodo', 'mycodo') return save_path, filename except Exception as e: logger.exception( "Exception raised in 'camera_record' when setting user grp: " "{err}".format(err=e))
class CustomModule(AbstractFunction): """ Class to operate custom controller """ def __init__(self, function, testing=False): super(CustomModule, self).__init__(function, testing=testing, name=__name__) self.control_variable = None self.timestamp = None self.control = DaemonControl() self.timer_loop = time.time() # Initialize custom options self.measurement_device_id = None self.measurement_measurement_id = None self.output_raise_device_id = None self.output_raise_measurement_id = None self.output_raise_channel_id = None self.output_lower_device_id = None self.output_lower_measurement_id = None self.output_lower_channel_id = None self.setpoint = None self.hysteresis = None self.direction = None self.output_raise_channel = None self.output_lower_channel = None self.update_period = None # Set custom options custom_function = db_retrieve_table_daemon(CustomController, unique_id=self.unique_id) self.setup_custom_options(FUNCTION_INFORMATION['custom_options'], custom_function) if not testing: self.initialize_variables() def initialize_variables(self): self.timestamp = time.time() self.output_raise_channel = self.get_output_channel_from_channel_id( self.output_raise_channel_id) self.output_lower_channel = self.get_output_channel_from_channel_id( self.output_lower_channel_id) self.logger.info( "Bang-Bang controller started with options: " "Measurement Device: {}, Measurement: {}," "Output Raise: {}, Output_Raise_Channel: {}," "Output Lower: {}, Output_Lower_Channel: {}," "Setpoint: {}, Hysteresis: {}, " "Direction: {}, Period: {}".format( self.measurement_device_id, self.measurement_measurement_id, self.output_raise_device_id, self.output_raise_channel, self.output_lower_device_id, self.output_lower_channel, self.setpoint, self.hysteresis, self.direction, self.update_period)) def loop(self): if self.timer_loop > time.time(): return while self.timer_loop < time.time(): self.timer_loop += self.update_period if ((self.direction == 'raise' and self.output_raise_channel is None) or (self.direction == 'lower' and self.output_lower_channel is None) or self.direction == 'both' and None in [self.output_raise_channel, self.output_lower_channel]): self.logger.error( "Cannot start bang-bang controller: Check output channel(s).") return last_measurement = self.get_last_measurement( self.measurement_device_id, self.measurement_measurement_id)[1] output_raise_state = self.control.output_state( self.output_raise_device_id, self.output_raise_channel) output_lower_state = self.control.output_state( self.output_lower_device_id, self.output_raise_channel) self.logger.info( "Input: {}, output_raise: {}, output_lower: {}, target: {}, hyst: {}" .format(last_measurement, output_raise_state, output_lower_state, self.setpoint, self.hysteresis)) if self.direction == 'raise': if last_measurement > (self.setpoint + self.hysteresis): if output_raise_state == 'on': self.control.output_off( self.output_raise_device_id, output_channel=self.output_raise_channel) elif last_measurement < (self.setpoint - self.hysteresis): self.control.output_on( self.output_raise_device_id, output_channel=self.output_raise_channel) elif self.direction == 'lower': if last_measurement < (self.setpoint - self.hysteresis): if output_lower_state == 'on': self.control.output_off( self.output_lower_device_id, output_channel=self.output_lower_channel) elif last_measurement > (self.setpoint + self.hysteresis): self.control.output_on( self.output_lower_device_id, output_channel=self.output_lower_channel) elif self.direction == 'both': if (last_measurement > (self.setpoint - self.hysteresis) or last_measurement < (self.setpoint + self.hysteresis)): if output_raise_state == 'on': self.control.output_off( self.output_raise_device_id, output_channel=self.output_raise_channel) if output_lower_state == 'on': self.control.output_off( self.output_lower_device_id, output_channel=self.output_lower_channel) elif last_measurement > (self.setpoint + self.hysteresis): self.control.output_on( self.output_lower_device_id, output_channel=self.output_lower_channel) elif last_measurement < (self.setpoint - self.hysteresis): self.control.output_on( self.output_raise_device_id, output_channel=self.output_raise_channel) else: self.logger.info("Unknown controller direction: '{}'".format( self.direction)) def stop_function(self): self.control.output_off(self.output_raise_device_id, self.output_raise_channel) self.control.output_off(self.output_lower_device_id, self.output_lower_channel)
class PIDController(threading.Thread): """ Class to operate discrete PID controller in Mycodo """ def __init__(self, ready, pid_id): threading.Thread.__init__(self) self.logger = logging.getLogger("mycodo.pid_{id}".format( id=pid_id.split('-')[0])) self.running = False self.thread_startup_timer = timeit.default_timer() self.thread_shutdown_timer = 0 self.ready = ready self.pid_id = pid_id self.control = DaemonControl() self.sample_rate = db_retrieve_table_daemon( Misc, entry='first').sample_rate_controller_pid self.device_measurements = db_retrieve_table_daemon(DeviceMeasurements) self.PID_Controller = None self.control_variable = 0.0 self.derivator = 0.0 self.integrator = 0.0 self.error = 0.0 self.P_value = None self.I_value = None self.D_value = None self.lower_seconds_on = 0.0 self.raise_seconds_on = 0.0 self.lower_duty_cycle = 0.0 self.raise_duty_cycle = 0.0 self.last_time = None self.last_measurement = None self.last_measurement_success = False self.is_activated = None self.is_held = None self.is_paused = None self.measurement = None self.method_id = None self.direction = None self.raise_output_id = None self.raise_min_duration = None self.raise_max_duration = None self.raise_min_off_duration = None self.lower_output_id = None self.lower_min_duration = None self.lower_max_duration = None self.lower_min_off_duration = None self.Kp = None self.Ki = None self.Kd = None self.integrator_min = None self.integrator_max = None self.period = None self.start_offset = None self.max_measure_age = None self.default_setpoint = None self.setpoint = None self.store_lower_as_negative = None # Hysteresis options self.band = None self.allow_raising = False self.allow_lowering = False # PID Autotune self.autotune = None self.autotune_activated = False self.autotune_debug = False self.autotune_noiseband = None self.autotune_outstep = None self.autotune_timestamp = None self.device_id = None self.measurement_id = None self.input_duration = None self.raise_output_type = None self.lower_output_type = None self.first_start = True self.initialize_values() self.timer = time.time() + self.start_offset # Check if a method is set for this PID self.method_type = None self.method_start_act = None self.method_start_time = None self.method_end_time = None if self.method_id != '': self.setup_method(self.method_id) def run(self): try: self.running = True startup_str = "Activated in {time:.1f} ms".format( time=(timeit.default_timer() - self.thread_startup_timer) * 1000) if self.is_paused: startup_str += ", started Paused" elif self.is_held: startup_str += ", started Held" self.logger.info(startup_str) # Initialize PID Controller self.PID_Controller = PIDControl( self.period, self.Kp, self.Ki, self.Kd, integrator_min=self.integrator_min, integrator_max=self.integrator_max) # If activated, initialize PID Autotune if self.autotune_activated: self.autotune_timestamp = time.time() try: self.autotune = PIDAutotune( self.setpoint, out_step=self.autotune_outstep, sampletime=self.period, out_min=0, out_max=self.period, noiseband=self.autotune_noiseband) except Exception as msg: self.logger.error(msg) self.stop_controller(deactivate_pid=True) self.ready.set() while self.running: if (self.method_start_act == 'Ended' and self.method_type == 'Duration'): self.stop_controller(ended_normally=False, deactivate_pid=True) self.logger.warning( "Method has ended. " "Activate the PID controller to start it again.") elif time.time() > self.timer: self.check_pid() time.sleep(self.sample_rate) except Exception as except_msg: self.logger.exception("Run Error: {err}".format( err=except_msg)) finally: # Turn off output used in PID when the controller is deactivated if self.raise_output_id and self.direction in ['raise', 'both']: self.control.output_off(self.raise_output_id, trigger_conditionals=True) if self.lower_output_id and self.direction in ['lower', 'both']: self.control.output_off(self.lower_output_id, trigger_conditionals=True) self.running = False self.logger.info("Deactivated in {:.1f} ms".format( (timeit.default_timer() - self.thread_shutdown_timer) * 1000)) def initialize_values(self): """Set PID parameters""" pid = db_retrieve_table_daemon(PID, unique_id=self.pid_id) self.is_activated = pid.is_activated self.is_held = pid.is_held self.is_paused = pid.is_paused self.method_id = pid.method_id self.direction = pid.direction self.raise_output_id = pid.raise_output_id self.raise_min_duration = pid.raise_min_duration self.raise_max_duration = pid.raise_max_duration self.raise_min_off_duration = pid.raise_min_off_duration self.lower_output_id = pid.lower_output_id self.lower_min_duration = pid.lower_min_duration self.lower_max_duration = pid.lower_max_duration self.lower_min_off_duration = pid.lower_min_off_duration self.Kp = pid.p self.Ki = pid.i self.Kd = pid.d self.integrator_min = pid.integrator_min self.integrator_max = pid.integrator_max self.period = pid.period self.start_offset = pid.start_offset self.max_measure_age = pid.max_measure_age self.default_setpoint = pid.setpoint self.setpoint = pid.setpoint self.band = pid.band self.store_lower_as_negative = pid.store_lower_as_negative # Autotune self.autotune_activated = pid.autotune_activated self.autotune_noiseband = pid.autotune_noiseband self.autotune_outstep = pid.autotune_outstep self.device_id = pid.measurement.split(',')[0] self.measurement_id = pid.measurement.split(',')[1] input_dev = db_retrieve_table_daemon(Input, unique_id=self.device_id) math = db_retrieve_table_daemon(Math, unique_id=self.device_id) if input_dev: self.input_duration = input_dev.period elif math: self.input_duration = math.period try: self.raise_output_type = db_retrieve_table_daemon( Output, unique_id=self.raise_output_id).output_type except AttributeError: self.raise_output_type = None try: self.lower_output_type = db_retrieve_table_daemon( Output, unique_id=self.lower_output_id).output_type except AttributeError: self.lower_output_type = None self.logger.info("PID Settings: {}".format(self.pid_parameters_str())) return "success" def check_pid(self): """ Get measurement and apply to PID controller """ # Ensure the timer ends in the future while time.time() > self.timer: self.timer = self.timer + self.period # If PID is active, retrieve measurement and update # the control variable. # A PID on hold will sustain the current output and # not update the control variable. if self.is_activated and (not self.is_paused or not self.is_held): self.get_last_measurement() if self.last_measurement_success: if self.method_id != '': # Update setpoint using a method this_pid = db_retrieve_table_daemon( PID, unique_id=self.pid_id) setpoint, ended = calculate_method_setpoint( self.method_id, PID, this_pid, Method, MethodData, self.logger) if ended: self.method_start_act = 'Ended' if setpoint is not None: self.setpoint = setpoint else: self.setpoint = self.default_setpoint # If autotune activated, determine control variable (output) from autotune if self.autotune_activated: if not self.autotune.run(self.last_measurement): self.control_variable = self.autotune.output if self.autotune_debug: self.logger.info('') self.logger.info("state: {}".format(self.autotune.state)) self.logger.info("output: {}".format(self.autotune.output)) else: # Autotune has finished timestamp = time.time() - self.autotune_timestamp self.logger.info('') self.logger.info('time: {0} min'.format(round(timestamp / 60))) self.logger.info('state: {0}'.format(self.autotune.state)) if self.autotune.state == PIDAutotune.STATE_SUCCEEDED: for rule in self.autotune.tuning_rules: params = self.autotune.get_pid_parameters(rule) self.logger.info('') self.logger.info('rule: {0}'.format(rule)) self.logger.info('Kp: {0}'.format(params.Kp)) self.logger.info('Ki: {0}'.format(params.Ki)) self.logger.info('Kd: {0}'.format(params.Kd)) self.stop_controller(deactivate_pid=True) else: # Calculate new control variable (output) from PID Controller # Original PID method self.control_variable = self.update_pid_output( self.last_measurement) # New PID method (untested) # self.control_variable = self.PID_Controller.calc( # self.last_measurement, self.setpoint) self.write_pid_values() # Write variables to database # Is PID in a state that allows manipulation of outputs if self.is_activated and (not self.is_paused or self.is_held): self.manipulate_output() def setup_method(self, method_id): """ Initialize method variables to start running a method """ self.method_id = '' method = db_retrieve_table_daemon(Method, unique_id=method_id) method_data = db_retrieve_table_daemon(MethodData) method_data = method_data.filter(MethodData.method_id == method_id) method_data_repeat = method_data.filter(MethodData.duration_sec == 0).first() pid = db_retrieve_table_daemon(PID, unique_id=self.pid_id) self.method_type = method.method_type self.method_start_act = pid.method_start_time self.method_start_time = None self.method_end_time = None if self.method_type == 'Duration': if self.method_start_act == 'Ended': # Method has ended and hasn't been instructed to begin again pass elif (self.method_start_act == 'Ready' or self.method_start_act is None): # Method has been instructed to begin now = datetime.datetime.now() self.method_start_time = now if method_data_repeat and method_data_repeat.duration_end: self.method_end_time = now + datetime.timedelta( seconds=float(method_data_repeat.duration_end)) with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.pid_id).first() mod_pid.method_start_time = self.method_start_time mod_pid.method_end_time = self.method_end_time db_session.commit() else: # Method neither instructed to begin or not to # Likely there was a daemon restart ot power failure # Resume method with saved start_time self.method_start_time = datetime.datetime.strptime( str(pid.method_start_time), '%Y-%m-%d %H:%M:%S.%f') if method_data_repeat and method_data_repeat.duration_end: self.method_end_time = datetime.datetime.strptime( str(pid.method_end_time), '%Y-%m-%d %H:%M:%S.%f') if self.method_end_time > datetime.datetime.now(): self.logger.warning( "Resuming method {id}: started {start}, " "ends {end}".format( id=method_id, start=self.method_start_time, end=self.method_end_time)) else: self.method_start_act = 'Ended' else: self.method_start_act = 'Ended' self.method_id = method_id def write_pid_values(self): """ Write PID values to the measurement database """ if self.band: setpoint_band_lower = self.setpoint - self.band setpoint_band_upper = self.setpoint + self.band else: setpoint_band_lower = None setpoint_band_upper = None list_measurements = [ self.setpoint, setpoint_band_lower, setpoint_band_upper, self.P_value, self.I_value, self.D_value ] measurement_dict = {} measurements = self.device_measurements.filter( DeviceMeasurements.device_id == self.pid_id).all() for each_channel, each_measurement in enumerate(measurements): if (each_measurement.channel not in measurement_dict and each_measurement.channel < len(list_measurements)): # If setpoint, get unit from PID measurement if each_measurement.measurement_type == 'setpoint': setpoint_pid = db_retrieve_table_daemon( PID, unique_id=each_measurement.device_id) if setpoint_pid and ',' in setpoint_pid.measurement: pid_measurement = setpoint_pid.measurement.split(',')[1] setpoint_measurement = db_retrieve_table_daemon( DeviceMeasurements, unique_id=pid_measurement) if setpoint_measurement: conversion = db_retrieve_table_daemon( Conversion, unique_id=setpoint_measurement.conversion_id) _, unit, _ = return_measurement_info( setpoint_measurement, conversion) measurement_dict[each_channel] = { 'measurement': each_measurement.measurement, 'unit': unit, 'value': list_measurements[each_channel] } else: measurement_dict[each_channel] = { 'measurement': each_measurement.measurement, 'unit': each_measurement.unit, 'value': list_measurements[each_channel] } add_measurements_influxdb(self.pid_id, measurement_dict) def update_pid_output(self, current_value): """ Calculate PID output value from reference input and feedback :return: Manipulated, or control, variable. This is the PID output. :rtype: float :param current_value: The input, or process, variable (the actual measured condition by the input) :type current_value: float """ # Determine if hysteresis is enabled and if the PID should be applied setpoint = self.check_hysteresis(current_value) if setpoint is None: # Prevent PID variables form being manipulated and # restrict PID from operating. return 0 self.error = setpoint - current_value # Calculate P-value self.P_value = self.Kp * self.error # Calculate I-value self.integrator += self.error # First method for managing integrator if self.integrator > self.integrator_max: self.integrator = self.integrator_max elif self.integrator < self.integrator_min: self.integrator = self.integrator_min # Second method for regulating integrator # if self.period is not None: # if self.integrator * self.Ki > self.period: # self.integrator = self.period / self.Ki # elif self.integrator * self.Ki < -self.period: # self.integrator = -self.period / self.Ki self.I_value = self.integrator * self.Ki # Prevent large initial D-value if self.first_start: self.derivator = self.error self.first_start = False # Calculate D-value self.D_value = self.Kd * (self.error - self.derivator) self.derivator = self.error # Produce output form P, I, and D values pid_value = self.P_value + self.I_value + self.D_value return pid_value def check_hysteresis(self, measure): """ Determine if hysteresis is enabled and if the PID should be applied :return: float if the setpoint if the PID should be applied, None to restrict the PID :rtype: float or None :param measure: The PID input (or process) variable :type measure: float """ if self.band == 0: # If band is disabled, return setpoint return self.setpoint band_min = self.setpoint - self.band band_max = self.setpoint + self.band if self.direction == 'raise': if (measure < band_min or (band_min < measure < band_max and self.allow_raising)): self.allow_raising = True setpoint = band_max # New setpoint return setpoint # Apply the PID elif measure > band_max: self.allow_raising = False return None # Restrict the PID elif self.direction == 'lower': if (measure > band_max or (band_min < measure < band_max and self.allow_lowering)): self.allow_lowering = True setpoint = band_min # New setpoint return setpoint # Apply the PID elif measure < band_min: self.allow_lowering = False return None # Restrict the PID elif self.direction == 'both': if measure < band_min: setpoint = band_min # New setpoint if not self.allow_raising: # Reset integrator and derivator upon direction switch self.integrator = 0.0 self.derivator = 0.0 self.allow_raising = True self.allow_lowering = False elif measure > band_max: setpoint = band_max # New setpoint if not self.allow_lowering: # Reset integrator and derivator upon direction switch self.integrator = 0.0 self.derivator = 0.0 self.allow_raising = False self.allow_lowering = True else: return None # Restrict the PID return setpoint # Apply the PID def get_last_measurement(self): """ Retrieve the latest input measurement from InfluxDB :rtype: None """ self.last_measurement_success = False # Get latest measurement from influxdb try: device_measurement = get_measurement(self.measurement_id) if device_measurement: conversion = db_retrieve_table_daemon( Conversion, unique_id=device_measurement.conversion_id) else: conversion = None channel, unit, measurement = return_measurement_info( device_measurement, conversion) self.last_measurement = read_last_influxdb( self.device_id, unit, measurement, channel, int(self.max_measure_age)) if self.last_measurement: self.last_time = self.last_measurement[0] self.last_measurement = self.last_measurement[1] utc_dt = datetime.datetime.strptime( self.last_time.split(".")[0], '%Y-%m-%dT%H:%M:%S') utc_timestamp = calendar.timegm(utc_dt.timetuple()) local_timestamp = str(datetime.datetime.fromtimestamp(utc_timestamp)) self.logger.debug("Latest (CH{ch}, Unit: {unit}): {last} @ {ts}".format( ch=channel, unit=unit, last=self.last_measurement, ts=local_timestamp)) if calendar.timegm(time.gmtime()) - utc_timestamp > self.max_measure_age: self.logger.error( "Last measurement was {last_sec} seconds ago, however" " the maximum measurement age is set to {max_sec}" " seconds.".format( last_sec=calendar.timegm(time.gmtime()) - utc_timestamp, max_sec=self.max_measure_age )) self.last_measurement_success = True else: self.logger.warning("No data returned from influxdb") except requests.ConnectionError: self.logger.error("Failed to read measurement from the " "influxdb database: Could not connect.") except Exception as except_msg: self.logger.exception( "Exception while reading measurement from the influxdb " "database: {err}".format(err=except_msg)) def manipulate_output(self): """ Activate output based on PID control variable and whether the manipulation directive is to raise, lower, or both. :rtype: None """ # If the last measurement was able to be retrieved and was entered within the past minute if self.last_measurement_success: # # PID control variable is positive, indicating a desire to raise # the environmental condition # if self.direction in ['raise', 'both'] and self.raise_output_id: if self.control_variable > 0: # Determine if the output should be PWM or a duration if self.raise_output_type in ['pwm', 'command_pwm', 'python_pwm']: self.raise_duty_cycle = float("{0:.1f}".format( self.control_var_to_duty_cycle(self.control_variable))) # Ensure the duty cycle doesn't exceed the min/max if (self.raise_max_duration and self.raise_duty_cycle > self.raise_max_duration): self.raise_duty_cycle = self.raise_max_duration elif (self.raise_min_duration and self.raise_duty_cycle < self.raise_min_duration): self.raise_duty_cycle = self.raise_min_duration self.logger.debug( "Setpoint: {sp}, Control Variable: {cv}, Output: PWM output " "{id} to {dc:.1f}%".format( sp=self.setpoint, cv=self.control_variable, id=self.raise_output_id, dc=self.raise_duty_cycle)) # Activate pwm with calculated duty cycle self.control.output_on(self.raise_output_id, duty_cycle=self.raise_duty_cycle) self.write_pid_output_influxdb( 'percent', 'duty_cycle', 7, self.control_var_to_duty_cycle(self.control_variable)) elif self.raise_output_type in ['command', 'python', 'wired', 'wireless_rpi_rf']: # Ensure the output on duration doesn't exceed the set maximum if (self.raise_max_duration and self.control_variable > self.raise_max_duration): self.raise_seconds_on = self.raise_max_duration else: self.raise_seconds_on = float("{0:.2f}".format( self.control_variable)) if self.raise_seconds_on > self.raise_min_duration: # Activate raise_output for a duration self.logger.debug( "Setpoint: {sp} Output: {cv} to output " "{id}".format( sp=self.setpoint, cv=self.control_variable, id=self.raise_output_id)) self.control.output_on( self.raise_output_id, duration=self.raise_seconds_on, min_off=self.raise_min_off_duration) self.write_pid_output_influxdb( 's', 'duration_time', 6, self.control_variable) else: if self.raise_output_type in ['pwm', 'command_pwm', 'python_pwm']: self.control.output_on(self.raise_output_id, duty_cycle=0) # # PID control variable is negative, indicating a desire to lower # the environmental condition # if self.direction in ['lower', 'both'] and self.lower_output_id: if self.control_variable < 0: # Determine if the output should be PWM or a duration if self.lower_output_type in ['pwm', 'command_pwm', 'python_pwm']: self.lower_duty_cycle = float("{0:.1f}".format( self.control_var_to_duty_cycle(abs(self.control_variable)))) # Ensure the duty cycle doesn't exceed the min/max if (self.lower_max_duration and self.lower_duty_cycle > self.lower_max_duration): self.lower_duty_cycle = self.lower_max_duration elif (self.lower_min_duration and self.lower_duty_cycle < self.lower_min_duration): self.lower_duty_cycle = self.lower_min_duration self.logger.debug( "Setpoint: {sp}, Control Variable: {cv}, " "Output: PWM output {id} to {dc:.1f}%".format( sp=self.setpoint, cv=self.control_variable, id=self.lower_output_id, dc=self.lower_duty_cycle)) if self.store_lower_as_negative: stored_duty_cycle = -abs(self.lower_duty_cycle) stored_control_variable = -self.control_var_to_duty_cycle(abs(self.control_variable)) else: stored_duty_cycle = abs(self.lower_duty_cycle) stored_control_variable = self.control_var_to_duty_cycle(abs(self.control_variable)) # Activate pwm with calculated duty cycle self.control.output_on( self.lower_output_id, duty_cycle=stored_duty_cycle) self.write_pid_output_influxdb( 'percent', 'duty_cycle', 7, stored_control_variable) elif self.lower_output_type in ['command', 'python', 'wired', 'wireless_rpi_rf']: # Ensure the output on duration doesn't exceed the set maximum if (self.lower_max_duration and abs(self.control_variable) > self.lower_max_duration): self.lower_seconds_on = self.lower_max_duration else: self.lower_seconds_on = float("{0:.2f}".format( abs(self.control_variable))) if self.store_lower_as_negative: stored_seconds_on = -abs(self.lower_seconds_on) stored_control_variable = -abs(self.control_variable) else: stored_seconds_on = abs(self.lower_seconds_on) stored_control_variable = abs(self.control_variable) if self.lower_seconds_on > self.lower_min_duration: # Activate lower_output for a duration self.logger.debug("Setpoint: {sp} Output: {cv} to " "output {id}".format( sp=self.setpoint, cv=self.control_variable, id=self.lower_output_id)) self.control.output_on( self.lower_output_id, duration=stored_seconds_on, min_off=self.lower_min_off_duration) self.write_pid_output_influxdb( 's', 'duration_time', 6, stored_control_variable) else: if self.lower_output_type in ['pwm', 'command_pwm', 'python_pwm']: self.control.output_on(self.lower_output_id, duty_cycle=0) else: if self.direction in ['raise', 'both'] and self.raise_output_id: self.control.output_off(self.raise_output_id) if self.direction in ['lower', 'both'] and self.lower_output_id: self.control.output_off(self.lower_output_id) def pid_parameters_str(self): return "Device ID: {did}, " \ "Measurement ID: {mid}, " \ "Direction: {dir}, " \ "Period: {per}, " \ "Setpoint: {sp}, " \ "Band: {band}, " \ "Kp: {kp}, " \ "Ki: {ki}, " \ "Kd: {kd}, " \ "Integrator Min: {imn}, " \ "Integrator Max {imx}, " \ "Output Raise: {opr}, " \ "Output Raise Min On: {oprmnon}, " \ "Output Raise Max On: {oprmxon}, " \ "Output Raise Min Off: {oprmnoff}, " \ "Output Lower: {opl}, " \ "Output Lower Min On: {oplmnon}, " \ "Output Lower Max On: {oplmxon}, " \ "Output Lower Min Off: {oplmnoff}, " \ "Setpoint Tracking: {spt}".format( did=self.device_id, mid=self.measurement_id, dir=self.direction, per=self.period, sp=self.setpoint, band=self.band, kp=self.Kp, ki=self.Ki, kd=self.Kd, imn=self.integrator_min, imx=self.integrator_max, opr=self.raise_output_id, oprmnon=self.raise_min_duration, oprmxon=self.raise_max_duration, oprmnoff=self.raise_min_off_duration, opl=self.lower_output_id, oplmnon=self.lower_min_duration, oplmxon=self.lower_max_duration, oplmnoff=self.lower_min_off_duration, spt=self.method_id) def control_var_to_duty_cycle(self, control_variable): # Convert control variable to duty cycle if control_variable > self.period: return 100.0 else: return float((control_variable / self.period) * 100) def write_pid_output_influxdb(self, unit, measurement, channel, value): write_pid_out_db = threading.Thread( target=write_influxdb_value, args=(self.pid_id, unit, value,), kwargs={'measure': measurement, 'channel': channel}) write_pid_out_db.start() def pid_mod(self): if self.initialize_values(): return "success" else: return "error" def pid_hold(self): self.is_held = True self.logger.info("Hold") return "success" def pid_pause(self): self.is_paused = True self.logger.info("Pause") return "success" def pid_resume(self): self.is_activated = True self.is_held = False self.is_paused = False self.logger.info("Resume") return "success" def set_setpoint(self, setpoint): """ Set the setpoint of PID """ self.setpoint = float(setpoint) with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.pid_id).first() mod_pid.setpoint = setpoint db_session.commit() return "Setpoint set to {sp}".format(sp=setpoint) def set_method(self, method_id): """ Set the method of PID """ with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.pid_id).first() mod_pid.method_id = method_id if method_id == '': self.method_id = '' db_session.commit() else: mod_pid.method_start_time = 'Ready' mod_pid.method_end_time = None db_session.commit() self.setup_method(method_id) return "Method set to {me}".format(me=method_id) def set_integrator(self, integrator): """ Set the integrator of the controller """ self.integrator = float(integrator) return "Integrator set to {i}".format(i=self.integrator) def set_derivator(self, derivator): """ Set the derivator of the controller """ self.derivator = float(derivator) return "Derivator set to {d}".format(d=self.derivator) def set_kp(self, p): """ Set Kp gain of the controller """ self.Kp = float(p) with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.pid_id).first() mod_pid.p = p db_session.commit() return "Kp set to {kp}".format(kp=self.Kp) def set_ki(self, i): """ Set Ki gain of the controller """ self.Ki = float(i) with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.pid_id).first() mod_pid.i = i db_session.commit() return "Ki set to {ki}".format(ki=self.Ki) def set_kd(self, d): """ Set Kd gain of the controller """ self.Kd = float(d) with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.pid_id).first() mod_pid.d = d db_session.commit() return "Kd set to {kd}".format(kd=self.Kd) def get_setpoint(self): return self.setpoint def get_error(self): return self.error def get_integrator(self): return self.integrator def get_derivator(self): return self.derivator def get_kp(self): return self.Kp def get_ki(self): return self.Ki def get_kd(self): return self.Kd def is_running(self): return self.running def stop_controller(self, ended_normally=True, deactivate_pid=False): self.thread_shutdown_timer = timeit.default_timer() self.running = False # Unset method start time if self.method_id != '' and ended_normally: with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.pid_id).first() mod_pid.method_start_time = 'Ended' mod_pid.method_end_time = None db_session.commit() # Deactivate PID and Autotune if deactivate_pid: with session_scope(MYCODO_DB_PATH) as db_session: mod_pid = db_session.query(PID).filter( PID.unique_id == self.pid_id).first() mod_pid.is_activated = False mod_pid.autotune_activated = False db_session.commit()