def test_cooling_power_off():
    state = False
    request = 21.0
    threshold = 1.0
    hysteresis = 0.5

    power = Power(threshold, hysteresis)

    #  below request, below threshold, below hysteresis => power off
    power.state = state
    power.calculate(request, request - threshold - hysteresis - 1, 'cool')
    assert power.state == False

    #  below request, below threshold, above hysteresis => power off
    power.state = state
    power.calculate(request, request - threshold - (hysteresis / 2), 'cool')
    assert power.state == False

    #  below request, above threshold => power on
    power.state = state
    power.calculate(request, request - (threshold / 2), 'cool')
    assert power.state == True
def test_heating_power_off():
    state = False
    request = 21.0
    threshold = 1.0
    hysteresis = 0.5

    power = Power(threshold, hysteresis)

    #  above request, above threshold, above hysteresis => power off
    power.state = state
    power.calculate(request, request + threshold + hysteresis + 1, 'heat')
    assert power.state == False

    #  above request, above threshold, below hysteresis => power off
    power.state = state
    power.calculate(request, request + threshold + (hysteresis / 2), 'heat')
    assert power.state == False

    #  above request, below threshold => power on
    power.state = state
    power.calculate(request, request + (threshold / 2), 'heat')
    assert power.state == True
class HVACPIDController(object):
    logger = None
    mqtt = None
    temp = None
    fan = None
    power = None
    config = None
    state = None

    temp_outdoors = 0

    mode = 'auto'
    manual = False
    control_enable = False
    hvac_state = {}
    next_iteration = None

    def __init__(self):
        self.logger = logging.getLogger('hvac-pid')
        self.logger.info('Starting hvac-pid')

        self.config = Config()
        self.util = Util()

        # PID options
        pid_options = self.config.getPIDOptions(self.mode)
        temp_options = self.config.getTempOptions(self.mode)

        # Temp
        self.temp = Temp(**{**temp_options, **pid_options})

        # Fan
        self.fan = Fan()

        # Power
        self.power = Power()

        # Occupancy state
        self.state = State(**self.config.getStateOptions())

        # MQTT
        self.topic_prefix = os.getenv('MQTT_PID_TOPIC_PREFIX')
        self.mqtt = MQTTClient(os.getenv('MQTT_CLIENT_ID'),
                               os.getenv('MQTT_BROKER_HOST'))
        self.mqtt.connect()

        # subscribe
        self.mqtt.subscribe(os.getenv('MQTT_TEMP_TOPIC'), 0,
                            self.temp_update_callback)
        self.mqtt.subscribe(os.getenv('MQTT_TEMP_OUTDOORS_TOPIC'), 0,
                            self.temp_outdoors_update_callback)
        self.mqtt.subscribe(os.getenv('MQTT_HVAC_STATE_TOPIC'), 0,
                            self.hvac_callback)
        self.mqtt.subscribe(self.topic_prefix + '/mode/set', 0, self.set_mode)
        self.mqtt.subscribe(self.topic_prefix + '/temperature/set', 0,
                            self.set_temp)
        self.mqtt.subscribe(self.topic_prefix + '/fan/set', 0, self.set_fan)
        self.mqtt.subscribe(os.getenv('MQTT_HVAC_OCCUPANCY_STATE_TOPIC'), 0,
                            self.set_occupancy_state)

        self.logger.info('MQTT connected')

        self.publish_temp()
        self.publish_mode()
        self.publish_fan()

        self.next_iteration = datetime.now() + timedelta(minutes=2)

        # wait a bit before enabling control
        time.sleep(5)
        self.control_enable = True

    def iterate(self):
        if self.manual:
            self.logger.info('Manual mode, skipping PID iteration')
        else:
            compensated_request_temp = self.state.compensateRequestTemp(
                self.temp.temp_request, self.temp_outdoors)
            max_set_temp = ceil(self.temp.temp_absolute) + 3

            # temp hax
            # limit min temp when outdoors is < -10
            if self.temp_outdoors < -10:
                self.temp.setLimits(
                    floor(compensated_request_temp) - 1, max_set_temp)
                self.logger.debug(
                    'Limiting min temp to %g when outdoor temp is %g',
                    self.temp.temp_min, self.temp_outdoors)
            else:
                self.temp.setLimits(self.config.getSetTempMin(), max_set_temp)

            self.temp.iteratePID(compensated_request_temp)
            self.fan.calculate(self.temp.pid_offset, self.mode)
            self.power.calculate(self.temp.temp_request,
                                 self.temp.temp_measure, self.mode,
                                 self.temp_outdoors)
            if not self.power.state:
                self.temp.reset()
            self.publish_state()

    def temp_update_callback(self, client, userdata, message):
        payload_json = json.loads(message.payload.decode('utf-8'))

        if 'temperature' in payload_json:
            temp = payload_json['temperature']
        else:
            temp = payload_json['tempc']

        if 'humidity' in payload_json:
            humidity = payload_json['humidity']
        else:
            humidity = payload_json['hum']

        if self.mode == 'cool':
            dew_point = self.util.dewPoint(temp, humidity)
            self.temp.setMeasurement(round(dew_point, 2), temp)
        else:
            self.temp.setMeasurement(temp, temp)

    def temp_outdoors_update_callback(self, client, userdata, message):
        payload_json = json.loads(message.payload.decode('utf-8'))
        self.temp_outdoors = float(payload_json['temperature'])

    def hvac_callback(self, client, userdata, message):
        payload_json = json.loads(message.payload.decode('utf-8'))
        self.logger.info('Received hvac state change %s', payload_json)
        self.hvac_state = payload_json

    def setHVAC(self):
        if self.control_enable:
            topic = os.getenv('MQTT_HVAC_TOPIC')
            new_state = {
                'power': self.power.state,
                'mode': self.mode.upper(),
                'temperature': self.temp.temp_set,
                'fan': self.fan.speed,
            }

            is_state_changed = (new_state['power']
                                and self.hvac_state != new_state)
            is_power_state_changed = (
                self.hvac_state
                and new_state['power'] != self.hvac_state['power'])
            old_state_doesnt_exists = (not self.hvac_state)

            if is_state_changed or is_power_state_changed or old_state_doesnt_exists:
                message = json.dumps(new_state)

                self.logger.debug('Controlling HVAC with command %s', message)
                self.mqtt.publish(topic, message, 1)
            else:
                self.logger.debug('HVAC state unchanged %s', self.hvac_state)
        else:
            self.logger.debug('Controlling HVAC disabled')

    def set_mode(self, client, userdata, message):
        mode = message.payload.decode('utf-8')
        previous_mode = self.mode

        # reset PID if switching between modes
        if previous_mode != mode:
            pid_options = self.config.getPIDOptions(mode)
            temp_options = self.config.getTempOptions(mode)
            self.temp = Temp(**{**temp_options, **pid_options})

        if mode == 'off':
            self.manual = True
            self.mode = 'auto'
            self.power.state = False
            self.logger.info('Set mode to off')
        if mode == 'auto':
            self.manual = True
            self.power.state = True
            self.mode = 'auto'
            self.temp.temp_set = self.temp.temp_request
            self.logger.info('Set mode to manual')
        elif mode == 'heat':
            self.manual = False
            self.mode = mode
            self.logger.info('Set mode to %s', self.mode)
        elif mode == 'cool':
            self.manual = False
            self.mode = mode
            self.temp.temp_set = self.temp.temp_absolute
            self.logger.info('Set mode to %s', self.mode)

        self.state.setMode(mode)
        self.publish_mode()
        self.setHVAC()
        self.set_next_iteration(2)

    def publish_mode(self):
        if not self.control_enable:
            return

        topic = self.topic_prefix + '/mode/state'

        if self.manual:
            if self.power.state == False:
                mode = 'off'
            else:
                mode = 'manual'
        elif self.mode == 'auto':
            mode = 'manual'
        else:
            mode = self.mode

        self.mqtt.publish(topic, mode, 1, True)

    def set_temp(self, client, userdata, message):
        temp = round(float(message.payload.decode('utf-8')), 2)

        if temp >= float(os.getenv('REQUEST_MIN_TEMP', 0)) and temp <= float(
                os.getenv('REQUEST_MAX_TEMP', 100)):
            self.temp.setRequest(temp)

            if self.manual:
                self.temp.temp_set = self.temp.temp_request
            else:
                self.temp.reset()

            self.publish_temp()
            self.setHVAC()

    def publish_temp(self):
        if not self.control_enable:
            return

        self.mqtt.publish(self.topic_prefix + '/temperature/state',
                          self.temp.temp_request, 1, True)
        self.mqtt.publish(self.topic_prefix + '/measured_temperature',
                          self.temp.temp_measure, 1, True)

    def set_fan(self, client, userdata, message):
        fan = message.payload.decode('utf-8')

        if fan != "auto":
            fan_int = int(fan)

            if self.manual and fan_int >= 0 and fan_int <= 5:
                self.fan.speed = fan_int
                self.publish_fan()
                self.setHVAC()
                self.logger.info('Manually set fan speed to %s/5',
                                 self.fan.speed)

    def publish_fan(self):
        if not self.control_enable:
            return

        topic = self.topic_prefix + '/fan/state'

        if self.manual:
            fan = self.fan.speed
        else:
            fan = 'auto'

        self.mqtt.publish(topic, fan, 1, True)

    def publish_state(self):
        if not self.control_enable:
            return

        topic = os.getenv('MQTT_PID_TOPIC_PREFIX') + '/state'
        message = json.dumps({
            'mode':
            self.mode,
            'manual':
            self.manual,
            'temperature_request':
            float(self.temp.temp_request),
            'temperature_set':
            float(self.temp.temp_set),
            'temperature_measure':
            float(self.temp.temp_measure),
            'temperature_error':
            float(self.temp.pid.previous_error),
            'set_temperature_lower_limit':
            float(self.temp.temp_min),
            'set_temperature_upper_limit':
            float(self.temp.temp_max),
            'fan':
            int(self.fan.speed if self.power.state else 0),
            'power':
            self.power.state,
            'Kp':
            float(self.temp.pid.Kp),
            'Ki':
            float(self.temp.pid.Ki),
            'Kd':
            float(self.temp.pid.Kd),
            'integral':
            float(self.temp.pid.integral),
            'integral_max':
            float(self.temp.pid.integral_max),
            'pid_offset':
            float(self.temp.pid_offset),
            'pid_result':
            float(self.temp.pid_result),
        })
        self.mqtt.publish(topic, message, 1)

    def set_occupancy_state(self, client, userdata, message):
        state = message.payload.decode('utf-8')
        prev_state = self.state.state
        self.state.setState(state)
        self.logger.info('Setting occupancy state to %s', self.state.state)

        # only reset if going or returning away state
        if prev_state == 'away' or self.state.state == 'away':
            self.temp.reset()
            self.set_next_iteration(2)

    def set_next_iteration(self, interval):
        self.next_iteration = datetime.now() + timedelta(minutes=interval)
        self.logger.info('Next iteration at %s', self.next_iteration)