class Arduino(PoupoolActor):

    STATE_REFRESH_DELAY = 60

    states = ["halt", "run"]

    def __init__(self, encoder, devices):
        super().__init__()
        self.__encoder = encoder
        self.__arduino = devices.get_device("arduino")
        self.__water_counter = 0
        self.__water_counter_last = None
        # Initialize the state machine
        self.__machine = PoupoolModel(model=self, states=Arduino.states, initial="halt")

        self.__machine.add_transition("run", "halt", "run")
        self.__machine.add_transition("halt", "run", "halt")

    def restore_water_counter(self, value):
        self.__water_counter = value
        logger.info("Water counter set to: %d" % self.__water_counter)

    def cover_open(self):
        self.__arduino.cover_open()

    def cover_close(self):
        self.__arduino.cover_close()

    def cover_stop(self):
        self.__arduino.cover_stop()

    def cover_position(self):
        return self.__arduino.cover_position

    def water_counter(self):
        return self.__water_counter

    def on_enter_halt(self):
        logger.info("Entering halt state")
        self.cover_stop()

    @do_repeat()
    def on_enter_run(self):
        logger.info("Entering run state")

    def do_repeat_run(self):
        # Water counter
        value = self.__arduino.water_counter
        if value is not None:
            if self.__water_counter_last is not None and self.__water_counter_last != value:
                self.__water_counter += value - self.__water_counter_last
                self.__encoder.water_counter(self.__water_counter, retain=True)
            self.__water_counter_last = value
        else:
            logger.error("Unable to read water counter. Not updating the value")
        self.do_delay(self.STATE_REFRESH_DELAY, self.do_repeat_run.__name__) class Filtration(PoupoolActor):

    STATE_REFRESH_DELAY = 10
    HEATING_DELAY_TO_ECO = int(config["heating", "delay_to_eco"])
    HEATING_DELAY_TO_OPEN = int(config["heating", "delay_to_open"])
    WINTERING_PERIOD = int(config["wintering", "period"])
    WINTERING_ONLY_BELOW = float(config["wintering", "only_below"])
    WINTERING_DURATION = int(config["wintering", "duration"])
    WINTERING_PUMP_SPEED = int(config["wintering", "pump_speed"])

    states = ["halt",
              "closing",
              {"name": "opening", "children": [
                  "standby",
                  "overflow"]},
              {"name": "eco", "initial": "compute", "children": [
                  "compute",
                  "normal",
                  "tank",
                  "waiting"]},
              {"name": "heating", "initial": "running", "children": [
                  "running",
                  {"name": "delay", "initial": "none", "children": [
                      "none",
                      "standby",
                      "overflow"]}]},
              {"name": "standby", "initial": "normal", "children": [
                  "boost",
                  "normal"]},
              {"name": "overflow", "initial": "normal", "children": [
                  "boost",
                  "normal"]},
              "comfort",
              "sweep",
              "reload",
              {"name": "wash", "initial": "backwash", "children": [
                  "backwash",
                  "rinse"]},
              {"name": "wintering", "initial": "waiting", "children": [
                  "stir",
                  "waiting"]}]

    def __init__(self, temperature, encoder, devices):
        super().__init__()
        self.__temperature = temperature
        self.__encoder = encoder
        self.__devices = devices
        # Parameters
        self.__eco_mode = EcoMode(encoder)
        self.__stir_mode = StirMode(devices)
        self.__boost_duration = timedelta(minutes=5)
        self.__cover_position_eco = 0
        self.__speed_eco = 1
        self.__speed_standby = 1
        self.__speed_overflow = 4
        self.__overflow_in_comfort = False
        self.__backwash_backwash_duration = 120
        self.__backwash_rinse_duration = 60
        self.__backwash_period = 30
        self.__backwash_last = datetime.fromtimestamp(0)
        # Initialize the state machine
        self.__machine = PoupoolModel(model=self, states=Filtration.states, initial="halt",
                                      before_state_change=[self.__before_state_change])
        # Transitions
        # Eco
        self.__machine.add_transition("eco", ["standby", "overflow", "opening"], "closing")
        # Special transition from halt to eco because we need to start the tank FSM. However, we
        # cannot start it on_exit of halt because when going from halt to wintering, the tank will
        # remain in the halt state.
        self.__machine.add_transition("eco", "halt", "eco", before=[
                                      "arduino_start", "tank_start", "heating_start"])
        self.__machine.add_transition("eco", ["reload", "wash_rinse"], "eco")
        self.__machine.add_transition("closed", "closing", "eco")
        self.__machine.add_transition("eco_normal", ["eco_compute", "eco_waiting"], "eco_normal")
        self.__machine.add_transition("eco_tank", "eco_normal", "eco_tank", unless="tank_is_low")
        self.__machine.add_transition("heat", ["eco_waiting", "eco_normal"], "heating")
        self.__machine.add_transition("heating_delay", "heating", "heating_delay_none")
        self.__machine.add_transition("heating_delayed", "heating_delay_none", "eco")
        self.__machine.add_transition(
            "eco_waiting", ["eco_compute", "eco_normal", "eco_tank"], "eco_waiting")
        self.__machine.add_transition("opened", "opening_standby", "standby_boost")
        self.__machine.add_transition("opened", "opening_overflow", "overflow_boost")
        # Standby
        self.__machine.add_transition("standby", "heating_running",
                                      "heating_delay_standby", unless="tank_is_low")
        self.__machine.add_transition("heating_delayed", "heating_delay_standby",
                                      "opening_standby", unless="tank_is_low")
        self.__machine.add_transition(
            "standby", ["eco", "closing"], "opening_standby", unless="tank_is_low")
        self.__machine.add_transition("standby", ["overflow", "sweep", "reload"], "standby")
        self.__machine.add_transition("standby", "comfort", "standby",
                                      unless="pump_stopped_in_standby")
        self.__machine.add_transition("standby", "standby_boost", "standby_normal")
        # Allow manual boost mode
        self.__machine.add_transition("standby", "standby_normal", "standby_boost")
        # Overflow
        self.__machine.add_transition("overflow", "heating_running",
                                      "heating_delay_overflow", unless="tank_is_low")
        self.__machine.add_transition("heating_delayed", "heating_delay_overflow",
                                      "opening_overflow", unless="tank_is_low")
        self.__machine.add_transition(
            "overflow", ["eco", "closing"], "opening_overflow", unless="tank_is_low")
        self.__machine.add_transition("overflow", ["standby", "comfort", "reload"], "overflow")
        self.__machine.add_transition("overflow", "overflow_boost", "overflow_normal")
        # Allow manual boost mode
        self.__machine.add_transition("overflow", "overflow_normal", "overflow_boost")
        # Comfort
        self.__machine.add_transition(
            "comfort", ["standby", "overflow"], "comfort", after=self.reload_comfort)
        self.__machine.add_transition("reload", ["comfort"], None, after=self.reload_comfort)
        # Sweep
        self.__machine.add_transition("sweep", "standby", "sweep")
        # Stop
        self.__machine.add_transition("halt",
                                      ["eco", "heating", "standby", "overflow", "comfort",
                                       "sweep", "opening", "closing", "wash", "wintering"],
                                      "halt")
        # (Back)wash
        self.__machine.add_transition(
            "wash", ["eco_normal", "eco_waiting"], "wash", conditions="tank_is_high")
        self.__machine.add_transition("rinse", "wash_backwash", "wash_rinse")
        # Wintering
        self.__machine.add_transition("wintering", "halt", "wintering")
        self.__machine.add_transition("wintering_waiting", "wintering_stir", "wintering_waiting")
        self.__machine.add_transition("wintering_stir", "wintering_waiting", "wintering_stir")
        # Hack to reload settings. Often the pumps/valves are set in the on_enter callback. In some
        # cases, we can change settings that needs to reload the same state. However, a transition
        # to the same state does not result in on_exit/on_enter being called again (sounds logic
        # since there is actually no state change). So we jump to the reload state and back to
        # workaround this.
        self.__machine.add_transition("reload", ["eco", "standby", "overflow"], "reload")
        # Export the FSM
        from transitions.extensions import HierarchicalGraphMachine
        if isinstance(self.__machine, HierarchicalGraphMachine):
            args = "-Grankdir=LR -Goverlap=false -Gsplines=true -Gratio=1 -Gsep=.3"
            self.__machine.get_graph().draw("filtration.svg", prog="dot", args=args)

    def __reload_eco(self):
        if self.is_eco(allow_substates=True):
            # Jump to the reload state so that we can jump back into the same state
            self._proxy.reload.defer()
            self._proxy.eco.defer()

    def duration(self, value):
        current_duration = self.__eco_mode.filtration.duration
        self.__eco_mode.daily = timedelta(seconds=value)
        logger.info("Duration for daily filtration set to: %s" % self.__eco_mode.daily)
        # Restore today's elapsed duration
        self.__eco_mode.filtration.duration = current_duration
        self.__reload_eco()

    def period(self, value):
        self.__eco_mode.period = value
        logger.info("Period(s) for filtration set to: %s" % self.__eco_mode.period)
        self.__reload_eco()

    def restore_duration(self, value):
        self.__eco_mode.filtration.duration = timedelta(seconds=value)
        logger.info("Elapsed duration for filtration set to: %s" %
                    self.__eco_mode.filtration.duration)

    def cover_position_eco(self, value):
        self.__cover_position_eco = value
        logger.info("Cover position during eco mode set to: %d" % self.__cover_position_eco)

    def tank_percentage(self, value):
        self.__eco_mode.tank_percentage = value
        logger.info("Percentage for tank filtration set to: %s" % self.__eco_mode.tank_percentage)
        self.__reload_eco()

    def stir_duration(self, value):
        self.__stir_mode.stir_duration(value)

    def stir_period(self, value):
        self.__stir_mode.stir_period(value)

    def reset_hour(self, value):
        self.__eco_mode.reset_hour = value
        logger.info("Hour for daily filtration reset set to: %s" % self.__eco_mode.reset_hour.hour)
        self.__reload_eco()

    def boost_duration(self, value):
        self.__boost_duration = timedelta(seconds=value)
        logger.info("Duration for boost while going out of eco set to: %s" % self.__boost_duration)

    def speed_eco(self, value):
        self.__speed_eco = value
        logger.info("Speed for eco mode set to: %d" % self.__speed_eco)
        if self.is_eco_normal():
            # Jump to the reload state so that we can jump back into standby mode
            self._proxy.reload.defer()
            self._proxy.eco.defer()

    def speed_standby(self, value):
        self.__speed_standby = value
        logger.info("Speed for standby mode set to: %d" % self.__speed_standby)
        if self.is_standby_normal():
            # Jump to the reload state so that we can jump back into standby mode
            self._proxy.reload.defer()
            self._proxy.standby.defer()

    def speed_overflow(self, value):
        self.__speed_overflow = value
        logger.info("Speed for overflow mode set to: %d" % self.__speed_overflow)
        if self.is_overflow_normal():
            # Jump to the reload state so that we can jump back into overflow mode
            self._proxy.reload.defer()
            self._proxy.overflow.defer()

    def overflow_in_comfort(self, value):
        self.__overflow_in_comfort = value
        logger.info("Overflow in comfort mode is %sable" % ("en" if value else "dis"))
        if self.is_comfort():
            # Reload settings
            self._proxy.reload.defer()

    def backwash_backwash_duration(self, value):
        self.__backwash_backwash_duration = timedelta(seconds=value)
        logger.info("Duration for backwash set to: %s" % self.__backwash_backwash_duration)

    def backwash_rinse_duration(self, value):
        self.__backwash_rinse_duration = timedelta(seconds=value)
        logger.info("Duration for rinse set to: %s" % self.__backwash_rinse_duration)

    def backwash_period(self, value):
        if value < 2:
            logger.error("We do not allow backwash everyday!!!")
        else:
            self.__backwash_period = value
            logger.info("Backwash period set to: %d" % self.__backwash_period)

    def backwash_last(self, value):
        self.__backwash_last = datetime.strptime(value, "%c")
        logger.info("Backwash last set to: %s" % self.__backwash_last)

    def tank_start(self):
        tank = self.get_actor("Tank")
        if tank.is_halt().get():
            tank.fill.defer()

    def heating_start(self):
        heating = self.get_actor("Heating")
        if heating.is_halt().get():
            heating.wait.defer()

    def arduino_start(self):
        arduino = self.get_actor("Arduino")
        if arduino.is_halt().get():
            arduino.run.defer()

    def tank_is_low(self):
        tank = self.get_actor("Tank")
        return tank.is_halt().get() or tank.is_low().get() or tank.is_fill().get()

    def tank_is_high(self):
        return self.get_actor("Tank").is_high().get()

    def pump_stopped_in_standby(self):
        return self.__speed_standby == 0

    def __start_backwash(self):
        diff = datetime.now() - self.__backwash_last
        if diff >= timedelta(self.__backwash_period):
            if self.tank_is_high():
                logger.info("Time for a backwash and tank is high")
                return True
            else:
                logger.debug("Time for a backwash but tank is NOT HIGH")
        return False

    def __before_state_change(self):
        self.__eco_mode.clear()

    def __disinfection_start(self):
        self.__actor_run("Disinfection")

    def __actor_run(self, name):
        actor = self.get_actor(name)
        if actor.is_halt().get():
            actor.run.defer()

    def __actor_halt(self, name):
        actor = self.get_actor(name)
        try:
            # We have seen situation where this creates a deadlock. We add a timeout so that we
            # eventually return from the get() and force the halt transition.
            if not actor.is_halt().get(timeout=1):
                actor.halt.defer()
        except pykka.Timeout:
            actor.halt.defer()

    def on_enter_halt(self):
        logger.info("Entering halt state")
        self.__encoder.filtration_state("halt")
        self.__actor_halt("Disinfection")
        self.__actor_halt("Tank")
        self.__actor_halt("Arduino")
        self.__actor_halt("Heating")
        self.__actor_halt("Light")
        self.__actor_halt("Swim")
        self.__devices.get_pump("variable").off()
        self.__devices.get_pump("boost").off()
        self.__devices.get_valve("gravity").off()
        self.__devices.get_valve("backwash").off()
        self.__devices.get_valve("tank").off()
        self.__devices.get_valve("drain").off()

    def on_exit_halt(self):
        logger.info("Exiting halt state")
        self.__stir_mode.clear(None)

    @do_repeat()
    def on_enter_closing(self):
        logger.info("Entering closing state")
        self.__encoder.filtration_state("closing")
        self.__actor_halt("Disinfection")
        # stop the pumps to avoid perturbation in the water while shutter is moving
        self.__devices.get_valve("gravity").on()
        self.__devices.get_pump("boost").off()
        self.__devices.get_pump("variable").off()
        # close the roller shutter
        self.get_actor("Arduino").cover_close.defer()

    def do_repeat_closing(self):
        position = self.get_actor("Arduino").cover_position().get()
        logger.debug("Cover position is %d" % position)
        self.__encoder.filtration_state("closing_%d" % (position // 10 * 10))
        if position <= self.__cover_position_eco:
            # Because of roundings, the cover might still need to move just a little more to reach
            # 0. But if we want to stop the cover somewhere in between, we exit the state directly.
            if self.__cover_position_eco == 0:
                self.do_delay(2, "closed")
            else:
                self._proxy.closed.defer()
        else:
            self.do_delay(5, self.do_repeat_closing.__name__)

    def on_exit_closing(self):
        logger.info("Exiting closing state")
        # stop the roller shutter
        self.get_actor("Arduino").cover_stop.defer()
        # Clear the stir mode, we were opened before so we likely do not need to stir immediately
        self.__stir_mode.clear(None)

    @do_repeat()
    def on_enter_opening(self):
        logger.info("Entering opening state")
        self.__encoder.filtration_state("opening")
        self.__actor_halt("Disinfection")
        # stop the pumps to avoid perturbation in the water while shutter is moving
        self.__devices.get_valve("gravity").on()
        self.__devices.get_pump("boost").off()
        self.__devices.get_pump("variable").off()
        # open the roller shutter
        self.get_actor("Arduino").cover_open.defer()

    def do_repeat_opening(self):
        position = self.get_actor("Arduino").cover_position().get()
        logger.debug("Cover position is %d" % position)
        self.__encoder.filtration_state("opening_%d" % (position // 10 * 10))
        if position == 100:
            # Because of roundings, the cover might still need to move just a little more.
            # We wait a bit more before exiting the state.
            self.do_delay(2, "opened")
        else:
            self.do_delay(5, self.do_repeat_opening.__name__)

    def on_exit_opening(self):
        logger.info("Exiting opening state")
        # TODO I'm not quite sure why this is here!!!??? Needed? Bug?
        self.get_actor("Tank").set_mode.defer("overflow")
        # stop the roller shutter
        self.get_actor("Arduino").cover_stop.defer()

    def on_enter_eco(self):
        logger.info("Entering eco state")
        self.__actor_halt("Light")
        self.__devices.get_valve("drain").off()
        self.__devices.get_valve("gravity").on()
        self.__devices.get_valve("tank").off()
        self.get_actor("Tank").set_mode.defer("eco")

    def on_enter_eco_compute(self):
        logger.info("Entering eco compute")
        self.__encoder.filtration_state("eco_compute")
        self.__eco_mode.update(datetime.now(), 0)
        self.__eco_mode.compute()
        if self.__eco_mode.off_duration.total_seconds() > 0:
            self.do_delay(5, "eco_waiting")
        elif self.__eco_mode.on_duration.total_seconds() > 0:
            self.do_delay(5, "eco_normal")
        else:
            self.do_delay(5, "eco_waiting")

    @do_repeat()
    def on_enter_eco_normal(self):
        logger.info("Entering eco_normal state")
        self.__encoder.filtration_state("eco_normal")
        self.__eco_mode.set_current(self.__eco_mode.on_duration)
        self.__disinfection_start()
        self.__devices.get_pump("variable").speed(self.__speed_eco)

    def do_repeat_eco_normal(self):
        now = datetime.now()
        if self.__start_backwash():
            self._proxy.wash.defer()
        elif self.__eco_mode.update(now):
            self.__reload_eco()
        elif self.__eco_mode.elapsed_on():
            if self.tank_is_low():
                self._proxy.eco_waiting.defer()
            elif self.__eco_mode.tank_duration > timedelta():
                self._proxy.eco_tank.defer()
        else:
            self.__stir_mode.update(now)
            self.do_delay(self.STATE_REFRESH_DELAY, self.do_repeat_eco_normal.__name__)

    @do_repeat()
    def on_enter_eco_tank(self):
        logger.info("Entering eco_tank state")
        self.__encoder.filtration_state("eco_tank")
        self.__eco_mode.set_current(self.__eco_mode.tank_duration)
        self.__actor_halt("Disinfection")
        self.__stir_mode.clear(datetime.now())
        self.__devices.get_valve("tank").on()
        # We force the speed to 1 in tank mode because otherwise the tank will be emptied
        # too quickly and the pool will overflow.
        self.__devices.get_pump("variable").speed(1)

    def do_repeat_eco_tank(self):
        if self.__eco_mode.update(datetime.now()):
            self.__reload_eco()
        elif self.__eco_mode.elapsed_on():
            self._proxy.eco_waiting.defer()
        else:
            self.do_delay(self.STATE_REFRESH_DELAY, self.do_repeat_eco_tank.__name__)

    def on_exit_eco_tank(self):
        self.__devices.get_valve("tank").off()

    @do_repeat()
    def on_enter_heating_running(self):
        logger.info("Entering heating_running state")
        self.__encoder.filtration_state("heating_running")
        self.__eco_mode.clear()
        self.__disinfection_start()
        self.__devices.get_pump("variable").speed(2)

    def do_repeat_heating_running(self):
        now = datetime.now()
        self.__eco_mode.update(now)
        self.__stir_mode.update(now)
        self.do_delay(self.STATE_REFRESH_DELAY, self.do_repeat_heating_running.__name__)

    def on_exit_heating_running(self):
        actor = self.get_actor("Heating")
        if actor.is_heating().get():
            actor.wait.defer()
        self.__stir_mode.clear(datetime.now())

    def on_enter_heating_delay(self):
        logger.info("Entering heating_delay state")
        self.__encoder.filtration_state("heating_delay")

    def on_enter_heating_delay_none(self):
        # The heat pump manual says it runs the main pump for 30 seconds after the heat pump has
        # switched off. We go for 60 seconds here to be sure (and since it is easy to do it)
        self.do_delay(Filtration.HEATING_DELAY_TO_ECO, "heating_delayed")

    def on_enter_heating_delay_standby(self):
        self.do_delay(Filtration.HEATING_DELAY_TO_OPEN, "heating_delayed")

    def on_enter_heating_delay_overflow(self):
        self.do_delay(Filtration.HEATING_DELAY_TO_OPEN, "heating_delayed")

    @do_repeat()
    def on_enter_eco_waiting(self):
        logger.info("Entering eco_waiting state")
        self.__encoder.filtration_state("eco_waiting")
        self.__eco_mode.set_current(self.__eco_mode.off_duration)
        self.__actor_halt("Disinfection")
        self.__devices.get_pump("variable").off()

    def do_repeat_eco_waiting(self):
        now = datetime.now()
        if self.__start_backwash():
            self._proxy.wash.defer()
        elif self.__eco_mode.update(now, 0):
            self.__reload_eco()
        elif self.__eco_mode.elapsed_off():
            self._proxy.eco_normal.defer()
        else:
            # Update the stir mode at the end so we do not switch the boost pumps for nothing.
            self.__stir_mode.update(now)
            self.do_delay(self.STATE_REFRESH_DELAY, self.do_repeat_eco_waiting.__name__)

    def on_enter_standby(self):
        logger.info("Entering standby state")
        self.__devices.get_valve("gravity").off()

    def on_enter_standby_boost(self):
        logger.info("Entering standby boost state")
        self.__encoder.filtration_state("standby_boost")
        self.__actor_halt("Disinfection")
        self.__devices.get_valve("tank").on()
        self.__devices.get_pump("boost").on()
        self.__devices.get_pump("variable").speed(3)
        self.do_delay(self.__boost_duration.total_seconds(), "standby")

    @do_repeat()
    def on_enter_standby_normal(self):
        logger.info("Entering standby_normal state")
        self.__encoder.filtration_state("standby")
        # No overflow in standby mode
        self.__devices.get_valve("tank").off()
        self.__devices.get_pump("boost").off()
        self.__devices.get_pump("variable").speed(self.__speed_standby)
        # If filtration is running, enable disinfection
        if self.__speed_standby > 0:
            self.__disinfection_start()
        else:
            self.__actor_halt("Disinfection")

    def do_repeat_standby_normal(self):
        factor = 1 if self.__speed_standby > 0 else 0
        self.__eco_mode.update(datetime.now(), factor)
        self.do_delay(self.STATE_REFRESH_DELAY, self.do_repeat_standby_normal.__name__)

    def on_enter_sweep(self):
        logger.info("Entering sweep state")
        self.__encoder.filtration_state("sweep")
        self.__actor_halt("Disinfection")
        self.__devices.get_valve("gravity").on()
        self.__devices.get_valve("tank").off()
        self.__devices.get_pump("variable").speed(3)
        self.__devices.get_pump("boost").off()

    def on_exit_sweep(self):
        logger.info("Exiting sweep state")

    @do_repeat()
    def on_enter_comfort(self):
        logger.info("Entering comfort state")
        self.__encoder.filtration_state("comfort")
        self.__devices.get_valve("gravity").off()
        self.__devices.get_pump("variable").speed(2)
        self.__devices.get_pump("boost").off()
        # Start disinfection
        self.__disinfection_start()

    def reload_comfort(self):
        valve = self.__devices.get_valve("tank")
        if self.__overflow_in_comfort:
            valve.on()
        else:
            valve.off()

    def do_repeat_comfort(self):
        self.__eco_mode.update(datetime.now(), 0.5)
        actor = self.get_actor("Heating")
        if not actor.is_forcing().get() and not actor.is_recovering().get():
            actor.force.defer()
        self.do_delay(self.STATE_REFRESH_DELAY, self.do_repeat_comfort.__name__)

    def on_exit_comfort(self):
        logger.info("Exiting comfort state")
        self.get_actor("Heating").wait.defer()

    def on_enter_overflow(self):
        logger.info("Entering overflow state")
        self.__devices.get_valve("gravity").off()
        self.__devices.get_valve("tank").on()

    def on_enter_overflow_boost(self):
        logger.info("Entering overflow boost state")
        self.__encoder.filtration_state("overflow_boost")
        self.__actor_halt("Disinfection")
        self.__devices.get_pump("variable").speed(3)
        self.__devices.get_pump("boost").on()
        self.do_delay(self.__boost_duration.total_seconds(), "overflow")

    @do_repeat()
    def on_enter_overflow_normal(self):
        logger.info("Entering overflow_normal state")
        self.__encoder.filtration_state("overflow")
        speed = self.__speed_overflow
        self.__devices.get_pump("variable").speed(min(speed, 3))
        if speed > 3:
            self.__devices.get_pump("boost").on()
        else:
            self.__devices.get_pump("boost").off()
        # Only start disinfection if the speed is 1 or 2 to have an appropriate
        # flow for measurement
        if 0 < speed < 3:
            self.__disinfection_start()
        else:
            self.__actor_halt("Disinfection")

    def on_exit_overflow_normal(self):
        logger.info("Exiting overflow_normal state")
        self.__actor_halt("Swim")

    def do_repeat_overflow_normal(self):
        self.__eco_mode.update(datetime.now(), 1 if self.__speed_overflow > 2 else 0.5)
        self.do_delay(self.STATE_REFRESH_DELAY, self.do_repeat_overflow_normal.__name__)

    def on_enter_wash(self):
        logger.info("Entering wash state")
        self.__actor_halt("Disinfection")
        self.__stir_mode.clear(datetime.now())

    def on_enter_wash_backwash(self):
        logger.info("Entering backwash state")
        self.__encoder.filtration_state("backwash")
        self.__devices.get_valve("tank").on()
        self.__devices.get_pump("variable").speed(3)
        time.sleep(2)
        self.__devices.get_valve("backwash").on()
        time.sleep(2)
        self.__devices.get_valve("drain").on()
        self.do_delay(self.__backwash_backwash_duration.total_seconds(), "rinse")

    def on_enter_wash_rinse(self):
        logger.info("Entering rinse state")
        self.__encoder.filtration_state("rinse")
        self.__devices.get_valve("backwash").off()
        self.do_delay(self.__backwash_rinse_duration.total_seconds(), "eco")

    def on_exit_wash_rinse(self):
        logger.info("Exiting rinse state")
        self.__devices.get_pump("variable").speed(1)
        self.__backwash_last = datetime.now()
        self.__encoder.filtration_backwash_last(self.__backwash_last.strftime("%c"), retain=True)

    def on_enter_wintering(self):
        logger.info("Entering wintering state")
        self.__encoder.filtration_remaining(str(timedelta()))
        self.get_actor("Heater").wait.defer()
        self.get_actor("Swim").wintering.defer()
        # open the roller shutter
        self.get_actor("Arduino").cover_open.defer()

    @do_repeat()
    def on_enter_wintering_waiting(self):
        logger.info("Entering wintering waiting state")
        self.__encoder.filtration_state("wintering_waiting")
        self.__devices.get_pump("variable").off()

    def do_repeat_wintering_waiting(self):
        if self.__machine.get_time_in_state() > timedelta(seconds=Filtration.WINTERING_PERIOD):
            temperature = self.__temperature.get_temperature("temperature_air").get()
            if temperature is None or temperature <= Filtration.WINTERING_ONLY_BELOW:
                self._proxy.wintering_stir.defer()
                return
        self.do_delay(2 * 60, self.do_repeat_wintering_waiting.__name__)

    def on_enter_wintering_stir(self):
        logger.info("Entering wintering stir state")
        self.__encoder.filtration_state("wintering_stir")
        self.__devices.get_pump("variable").speed(Filtration.WINTERING_PUMP_SPEED)
        self.do_delay(Filtration.WINTERING_DURATION, "wintering_waiting")

    def on_exit_wintering(self):
        logger.info("Exiting wintering state")
        self.__actor_halt("Heater")
        self.__actor_halt("Swim") class Disinfection(PoupoolActor):

    STATE_REFRESH_DELAY = 10
    START_DELAY = int(config["disinfection", "start_delay"])
    WAITING_DELAY = int(config["disinfection", "waiting_delay"])
    PH_PWM_PERIOD = int(config["disinfection", "ph_pwm_period"])
    CL_PWM_PERIOD = int(config["disinfection", "cl_pwm_period"])

    states = [
        "halt",
        "waiting",
        {"name": "running", "initial": "adjusting", "children": [
            "adjusting",
            "treating"]}]

    def __init__(self, encoder, devices, sensors_reader, sensors_writer, disable=False):
        super().__init__()
        self.__is_disabled = disable
        self.__encoder = encoder
        self.__devices = devices
        self.__sensors_reader = sensors_reader
        self.__sensors_writer = sensors_writer
        # pH
        self.__ph_enable = True
        self.__ph = PWM.start("pH", self.__devices.get_pump("ph")).proxy()
        self.__ph.period = Disinfection.PH_PWM_PERIOD
        self.__ph_controller = PController(pterm=-1.0)
        self.__ph_controller.setpoint = 7
        # ORP
        self.__orp_enable = True
        self.__orp_controller = PController(pterm=1.0, scale=0.005)
        self.__orp_controller.setpoint = 600
        # Chlorine
        self.__cl = PWM.start("cl", self.__devices.get_pump("cl")).proxy()
        self.__cl.period = Disinfection.CL_PWM_PERIOD
        # Initialize the state machine
        self.__machine = PoupoolModel(model=self, states=Disinfection.states, initial="halt")

        self.__machine.add_transition("run", "halt", "waiting", unless="is_disabled")
        self.__machine.add_transition("run", "waiting", "running")
        self.__machine.add_transition("halt", ["waiting", "running"], "halt")
        self.__machine.add_transition("adjust", "running_treating", "running_adjusting")
        self.__machine.add_transition("treat", "running_adjusting", "running_treating")

    def ph_enable(self, value):
        self.__ph_enable = value
        logger.info("pH adjustment is %sabled" % ("en" if value else "dis"))

    def orp_enable(self, value):
        self.__orp_enable = value
        logger.info("ORP adjustment is %sabled" % ("en" if value else "dis"))

    def ph_setpoint(self, value):
        self.__ph_controller.setpoint = value
        logger.info("pH setpoint set to: %.2f" % self.__ph_controller.setpoint)

    def orp_setpoint(self, value):
        self.__orp_controller.setpoint = value
        logger.info("ORP setpoint set to: %d" % self.__orp_controller.setpoint)

    def ph_pterm(self, value):
        # We assume here that we use "pH minus" chemicals, therefore inverse the term.
        self.__ph_controller.pterm = -value
        logger.info("pH pterm set to: %.2f" % self.__ph_controller.pterm)

    def orp_pterm(self, value):
        self.__orp_controller.pterm = value
        logger.info("ORP pterm set to: %.2f" % self.__orp_controller.pterm)

    def is_disabled(self):
        return self.__is_disabled

    def on_enter_halt(self):
        logger.info("Entering halt state")
        self.__encoder.disinfection_state("halt")
        self.__ph.value = 0
        self.__cl.value = 0
        self.__ph.do_cancel.defer()
        self.__cl.do_cancel.defer()
        self.__encoder.disinfection_cl_feedback(0)
        self.__encoder.disinfection_ph_feedback(0)
        self.__sensors_writer.do_cancel.defer()

    def on_enter_waiting(self):
        logger.info("Entering waiting state")
        self.__encoder.disinfection_state("waiting")
        self.do_delay(Disinfection.START_DELAY, "run")

    def on_enter_running(self):
        logger.info("Entering running state")
        self.__ph.do_run.defer()
        self.__cl.period = Disinfection.CL_PWM_PERIOD
        self.__cl.do_run.defer()
        self.__sensors_writer.do_write.defer()

    def on_enter_running_adjusting(self):
        logger.debug("Entering adjusting state")
        self.__encoder.disinfection_state("adjusting")
        # pH
        ph = self.__sensors_reader.get_ph().get()
        self.__ph_controller.current = ph
        ph_feedback = self.__ph_controller.compute() if self.__ph_enable else 0
        self.__encoder.disinfection_ph_feedback(int(round(ph_feedback * 100)))
        logger.debug("pH: %.2f feedback: %.2f" % (ph, ph_feedback))
        self.__ph.value = ph_feedback
        # ORP/Chlorine
        orp = self.__sensors_reader.get_orp().get()
        self.__orp_controller.current = orp
        cl_feedback = self.__orp_controller.compute() if self.__orp_enable else 0
        self.__encoder.disinfection_cl_feedback(int(round(cl_feedback * 100)))
        logger.debug("ORP: %d feedback: %.2f" % (orp, cl_feedback))
        self.__cl.value = cl_feedback
        self._proxy.treat.defer()

    def on_enter_running_treating(self):
        logger.debug("Entering treating state")
        self.__encoder.disinfection_state("treating")
        self.do_delay(Disinfection.WAITING_DELAY, "adjust") class Heating(PoupoolActor):

    STATE_REFRESH_DELAY = 10
    HYSTERESIS_DOWN = float(config["heating", "hysteresis_down"])
    HYSTERESIS_UP = float(config["heating", "hysteresis_up"])
    HYSTERESIS_MIN_TEMP = float(config["heating", "hysteresis_min_temp"])
    RECOVER_PERIOD = int(config["heating", "recover_period"])

    states = ["halt", "waiting", "heating", "forcing", "recovering"]

    class DurationEncoderCallback(object):

        def __init__(self, encoder):
            self.__encoder = encoder

        def __call__(self, value):
            self.__encoder.heating_total__seconds(round(value.total_seconds()), retain=True)

    def __init__(self, temperature, encoder, devices):
        super().__init__()
        self.__enable = True
        self.__temperature = temperature
        self.__encoder = encoder
        self.__total_duration = Duration("heating")
        self.__total_duration.set_callback(Heating.DurationEncoderCallback(encoder))
        self.__devices = devices
        self.__next_start = datetime.now()
        self.__next_start_hour = 0
        self.__setpoint = 26.0
        self.__min_temp = 15
        # Initialize the state machine
        self.__machine = PoupoolModel(model=self, states=Heating.states, initial="halt")

        self.__machine.add_transition("wait", "halt", "waiting")
        self.__machine.add_transition("heat", ["halt", "waiting"], "heating",
                                      conditions="filtration_allow_heating")
        self.__machine.add_transition("force", ["halt", "waiting"], "forcing")
        self.__machine.add_transition(
            "halt", ["waiting", "heating", "forcing", "recovering"], "halt")
        self.__machine.add_transition("wait", ["heating", "forcing"], "recovering")
        self.__machine.add_transition("recover_done", "recovering", "waiting")

    def __read_temperature(self, key="temperature_pool"):
        return self.__temperature.get_temperature(key).get()

    def __set_next_start(self):
        tm = datetime.now()
        self.__next_start = tm.replace(hour=self.__next_start_hour,
                                       minute=0, second=0, microsecond=0)
        self.__next_start += timedelta(days=1)

    def total_seconds(self, value):
        duration = timedelta(seconds=value)
        self.__total_duration.init(duration)
        logger.info("Total running hours set to %s" % duration)

    def enable(self, value):
        self.__enable = value
        logger.info("Heating is %sabled" % ("en" if value else "dis"))

    def setpoint(self, value):
        self.__setpoint = value
        logger.info("Setpoint set to %.1f" % self.__setpoint)
        # Hack. Restart the heating if the setpoint is changed
        if self.__next_start > datetime.now():
            self.__next_start -= timedelta(days=1)

    def start_hour(self, value):
        logger.info("Hour for heating start set to: %s" % value)
        self.__next_start_hour = value
        self.__set_next_start()
        if self.__next_start < datetime.now():
            self.__next_start -= timedelta(days=1)
        logger.info("Next heating scheduled for %s" % self.__next_start)

    def min_temp(self, value):
        self.__min_temp = value
        logger.info("Minimum temperature for heating set to %d" % self.__min_temp)

    def filtration_ready_for_heating(self):
        actor = self.get_actor("Filtration")
        return actor.is_eco_waiting().get() or actor.is_eco_normal().get()

    def filtration_allow_heating(self):
        actor = self.get_actor("Filtration")
        return actor.is_heating_running().get()

    def on_enter_halt(self):
        logger.info("Entering halt state")
        self.__encoder.heating_state("halt")
        self.__devices.get_valve("heating").off()

    @do_repeat()
    def on_enter_waiting(self):
        logger.info("Entering waiting state")
        self.__devices.get_valve("heating").off()
        self.__encoder.heating_state("waiting")

    def do_repeat_waiting(self):
        if not self.__enable:
            self.do_delay(self.STATE_REFRESH_DELAY, self.do_repeat_waiting.__name__)
            return
        # First, we check if the daily run is due
        if datetime.now() < self.__next_start:
            self.do_delay(self.STATE_REFRESH_DELAY, self.do_repeat_waiting.__name__)
            return
        # After the time constrain is fulfilled, we check if the temperature is low enough to
        # require heating. If not, then we say it's all good for today and schedule another heating
        # check for tomorrow.
        temp = self.__read_temperature()
        if temp is not None and (temp - Heating.HYSTERESIS_DOWN) >= self.__setpoint:
            # No need to heat today. Schedule for next day
            self.__set_next_start()
            logger.info("No heating needed today. Scheduled for %s" % self.__next_start)
            self.do_delay(self.STATE_REFRESH_DELAY, self.do_repeat_waiting.__name__)
            return
        # We ensure the outside temperature is high enough to get a good efficiency from the
        # heat pump
        temp = self.__read_temperature("temperature_air")
        if temp is not None and temp < self.__min_temp:
            self.do_delay(self.STATE_REFRESH_DELAY, self.do_repeat_waiting.__name__)
            return
        # Finally we check that filtration is ready to be switched to heating
        if not self.filtration_ready_for_heating():
            self.do_delay(self.STATE_REFRESH_DELAY, self.do_repeat_waiting.__name__)
            return
        # All pre-conditions ok, we can start heating now
        self.get_actor("Filtration").heat().get()
        # Ensure we are allowed to switch to the heat state. If the transition above fails,
        # we will call StopRepeatException but never land in the heating state.
        if self.filtration_allow_heating():
            self._proxy.heat.defer()
        else:
            self.do_delay(self.STATE_REFRESH_DELAY, self.do_repeat_waiting.__name__)

    @do_repeat()
    def on_enter_heating(self):
        logger.info("Entering heating state")
        self.__total_duration.start()
        self.__encoder.heating_state("heating")
        self.__devices.get_valve("heating").on()

    def do_repeat_heating(self):
        if not self.__enable:
            self._proxy.wait.defer()
            return
        # Check pool temperature
        temperature = self.__read_temperature()
        if temperature is None or temperature >= self.__setpoint + Heating.HYSTERESIS_UP:
            self._proxy.wait.defer()
            return
        # If the air temperature goes too low, we stop the heat pump otherwise the efficiency
        # will be too bad.
        temperature = self.__read_temperature("temperature_air")
        if temperature is not None and temperature < self.__min_temp - Heating.HYSTERESIS_MIN_TEMP:
            self._proxy.wait.defer()
            return
        self.do_delay(self.STATE_REFRESH_DELAY, self.do_repeat_heating.__name__)

    def on_exit_heating(self):
        logger.info("Exiting heating state")
        self.__total_duration.stop()
        self.__devices.get_valve("heating").off()
        # If the heating is aborted by the user, we also consider it as done and will heat again
        # the next day. If the heating ends normally then we are anyway done for the day.
        self.__set_next_start()
        logger.info("Heating done for today. Scheduled for %s" % self.__next_start)
        # Only change the filtration state if we are running in heating_running state
        if self.filtration_allow_heating():
            self.get_actor("Filtration").heating_delay.defer()

    def on_enter_forcing(self):
        logger.info("Entering forcing state")
        self.__total_duration.start()
        self.__encoder.heating_state("heating")
        self.__devices.get_valve("heating").on()

    def on_exit_forcing(self):
        logger.info("Exiting forcing state")
        self.__total_duration.stop()
        self.__devices.get_valve("heating").off()

    def on_enter_recovering(self):
        logger.info("Entering recovering state")
        self.__encoder.heating_state("recovering")
        self.do_delay(Heating.RECOVER_PERIOD, "recover_done") class Heater(PoupoolActor):

    STATE_REFRESH_DELAY = 10
    HYSTERESIS_DOWN = float(config["heater", "hysteresis_down"])
    HYSTERESIS_UP = float(config["heater", "hysteresis_up"])

    states = ["halt", "waiting", "heating"]

    def __init__(self, temperature, heater):
        super().__init__()
        self.__temperature = temperature
        self.__heater = heater
        self.__setpoint = 5.0
        # Initialize the state machine
        self.__machine = PoupoolModel(model=self, states=Heating.states, initial="halt")

        self.__machine.add_transition(
            "wait", ["halt", "heating"], "waiting", conditions="has_heater")
        self.__machine.add_transition("heat", "waiting", "heating")
        self.__machine.add_transition("halt", ["waiting", "heating"], "halt")

    def __read_temperature(self):
        return self.__temperature.get_temperature("temperature_local").get()

    def has_heater(self):
        return self.__heater is not None

    def setpoint(self, value):
        self.__setpoint = value
        logger.info("Setpoint set to %.1f" % self.__setpoint)

    def on_enter_halt(self):
        logger.info("Entering halt state")
        self.__heater.off()

    @do_repeat()
    def on_enter_waiting(self):
        logger.info("Entering waiting state")

    def do_repeat_waiting(self):
        temp = self.__read_temperature()
        if temp is None or temp < self.__setpoint - Heater.HYSTERESIS_DOWN:
            self._proxy.heat.defer()
        else:
            self.do_delay(self.STATE_REFRESH_DELAY, self.do_repeat_waiting.__name__)

    @do_repeat()
    def on_enter_heating(self):
        logger.info("Entering heating state")
        self.__heater.on()

    def on_exit_heating(self):
        self.__heater.off()

    def do_repeat_heating(self):
        temp = self.__read_temperature()
        if temp is not None and temp > self.__setpoint + Heater.HYSTERESIS_UP:
            self._proxy.wait.defer()
        else:
            self.do_delay(self.STATE_REFRESH_DELAY, self.do_repeat_heating.__name__) class Light(PoupoolActor):

    STATE_REFRESH_DELAY = 10

    states = ["halt", "on"]

    def __init__(self, encoder, devices):
        super().__init__()
        self.__encoder = encoder
        self.__devices = devices
        # Initialize the state machine
        self.__machine = PoupoolModel(model=self, states=Light.states, initial="halt")

        self.__machine.add_transition("on", "halt", "on")
        self.__machine.add_transition("halt", "on", "halt")

    def on_enter_halt(self):
        logger.info("Entering halt state")
        self.__encoder.light_state("halt")
        self.__devices.get_valve("light").off()

    def on_enter_on(self):
        logger.info("Entering on state")
        self.__encoder.light_state("on")
        self.__devices.get_valve("light").on() class Tank(PoupoolActor):

    STATE_REFRESH_DELAY = 10

    states = ["halt", "fill", "low", "normal", "high"]

    hysteresis = int(config["tank", "hysteresis"])
    levels_too_low = int(config["tank", "too_low"])
    levels_eco = {
        "low": int(config["tank", "eco_low"]),
        "high": int(config["tank", "eco_high"]),
    }
    levels_overflow = {
        "low": int(config["tank", "overflow_low"]),
        "high": int(config["tank", "overflow_high"]),
    }

    def __init__(self, encoder, devices):
        super().__init__()
        self.__encoder = encoder
        self.__devices = devices
        self.__force_empty = False
        self.levels = self.levels_eco
        # Initialize the state machine
        self.__machine = PoupoolModel(model=self, states=Tank.states, initial="halt")

        self.__machine.add_transition("low", ["fill", "normal"], "low")
        self.__machine.add_transition("normal", ["fill", "low", "high"], "normal")
        self.__machine.add_transition("high", ["fill", "normal"], "high")
        self.__machine.add_transition("halt", ["fill", "low", "normal", "high"], "halt")
        self.__machine.add_transition("fill", "halt", "fill", unless="is_force_empty")

    def __get_tank_height(self):
        height = self.__devices.get_sensor("tank").value
        logger.debug("Tank level: %d" % height)
        self.__encoder.tank_height(int(round(height)))
        return height

    def force_empty(self, value):
        previous = self.__force_empty
        self.__force_empty = value
        logger.info("Force empty tank is %sabled" % ("en" if value else "dis"))
        if not previous and self.__force_empty and not self.is_halt():
            # In case the user enable the settings and we are running already, we stop everything.
            # The user can continue from the halt state.
            logger.warning("The tank is not in the halt state, stopping everything")
            self.get_actor("Filtration").halt.defer()
        elif previous and not self.__force_empty and self.is_halt():
            # Deactivation of the function, we start the tank FSM.
            self._proxy.fill.defer()

    def is_force_empty(self):
        return self.__force_empty

    def set_mode(self, mode):
        logger.info("Tank level set to %s" % mode)
        self.levels = self.levels_eco if mode == "eco" else self.levels_overflow

    def on_enter_halt(self):
        logger.info("Entering halt state")
        self.__encoder.tank_state("halt")
        self.__devices.get_valve("main").off()

    @do_repeat()
    def on_enter_fill(self):
        logger.info("Entering fill state")
        self.__encoder.tank_state("fill")
        height = self.__get_tank_height()
        if height < self.levels_too_low:
            self.__devices.get_valve("main").on()
        else:
            self._proxy.normal.defer()
            raise StopRepeatException

    def do_repeat_fill(self):
        # Security feature: stop if we stay too long in this state
        if self.__machine.get_time_in_state() > datetime.timedelta(hours=2):
            logger.warning("Tank TOO LONG in fill state, stopping")
            self.get_actor("Filtration").halt.defer()
            return
        height = self.__get_tank_height()
        if height > self.levels_too_low:
            self._proxy.low.defer()
            return
        self.do_delay(self.STATE_REFRESH_DELAY / 2, self.do_repeat_fill.__name__)

    @do_repeat()
    def on_enter_low(self):
        logger.info("Entering low state")
        self.__encoder.tank_state("low")
        self.__devices.get_valve("main").on()

    def do_repeat_low(self):
        # Security feature: stop if we stay too long in this state
        if self.__machine.get_time_in_state() > datetime.timedelta(hours=6):
            logger.warning("Tank TOO LONG in low state, stopping")
            self.get_actor("Filtration").halt.defer()
            return
        height = self.__get_tank_height()
        if height >= self.levels["low"] + self.hysteresis:
            self._proxy.normal.defer()
            return
        elif height < self.levels_too_low:
            logger.warning("Tank TOO LOW, stopping: %d" % height)
            self.get_actor("Filtration").halt.defer()
            return
        self.do_delay(self.STATE_REFRESH_DELAY / 2, self.do_repeat_low.__name__)

    @do_repeat()
    def on_enter_normal(self):
        logger.info("Entering normal state")
        self.__encoder.tank_state("normal")
        self.__devices.get_valve("main").off()

    def do_repeat_normal(self):
        height = self.__get_tank_height()
        if height < self.levels["low"] - self.hysteresis:
            self._proxy.low.defer()
            return
        elif height >= self.levels["high"] + self.hysteresis:
            self._proxy.high.defer()
            return
        self.do_delay(self.STATE_REFRESH_DELAY, self.do_repeat_normal.__name__)

    @do_repeat()
    def on_enter_high(self):
        logger.info("Entering high state")
        self.__encoder.tank_state("high")
        self.__devices.get_valve("main").off()

    def do_repeat_high(self):
        height = self.__get_tank_height()
        if height < self.levels["high"] - self.hysteresis:
            self._proxy.normal.defer()
        else:
            self.do_delay(self.STATE_REFRESH_DELAY * 2, self.do_repeat_high.__name__) class Swim(PoupoolActor):

    STATE_REFRESH_DELAY = 1  # faster refresh rate because speed can change
    WINTERING_PERIOD = int(config["wintering", "swim_period"])
    WINTERING_ONLY_BELOW = float(config["wintering", "swim_only_below"])
    WINTERING_DURATION = int(config["wintering", "swim_duration"])

    states = ["halt",
              "timed",
              "continuous",
              {"name": "wintering", "initial": "waiting", "children": [
                  "stir",
                  "waiting"]}]

    def __init__(self, temperature, encoder, devices):
        super().__init__()
        self.__temperature = temperature
        self.__encoder = encoder
        self.__devices = devices
        self.__timer = Timer("swim")
        self.__speed = 50
        # Initialize the state machine
        self.__machine = PoupoolModel(model=self, states=Swim.states, initial="halt")

        self.__machine.add_transition("timed", "halt", "timed", conditions="filtration_allow_swim")
        self.__machine.add_transition("wintering", "halt", "wintering",
                                      conditions="filtration_is_wintering")
        self.__machine.add_transition("wintering_stir", "wintering_waiting", "wintering_stir")
        self.__machine.add_transition("wintering_waiting", "wintering_stir", "wintering_waiting")
        self.__machine.add_transition("timed", "continuous", "timed",
                                      conditions="filtration_allow_swim")
        self.__machine.add_transition("continuous", "halt", "continuous",
                                      conditions="filtration_allow_swim")
        self.__machine.add_transition("continuous", "timed", "continuous",
                                      conditions="filtration_allow_swim")
        self.__machine.add_transition("halt", ["timed", "continuous", "wintering"], "halt")

    def timer(self, value):
        self.__timer.delay = timedelta(minutes=value)
        logger.info("Timer for swim set to: %s" % self.__timer.delay)

    def speed(self, value):
        self.__speed = value
        logger.info("Speed for swim pump set to: %d" % self.__speed)

    def filtration_allow_swim(self):
        actor = self.get_actor("Filtration")
        is_opened = actor.is_overflow_normal().get() or actor.is_standby_normal().get()
        is_opened = is_opened or actor.is_comfort().get()
        return is_opened or self.filtration_is_wintering()

    def filtration_is_wintering(self):
        actor = self.get_actor("Filtration")
        return actor.is_wintering_waiting().get() or actor.is_wintering_stir().get()

    def on_enter_halt(self):
        logger.info("Entering halt state")
        self.__encoder.swim_state("halt")
        self.__devices.get_pump("swim").off()

    @do_repeat()
    def on_enter_timed(self):
        logger.info("Entering timed state")
        self.__encoder.swim_state("timed")
        self.__timer.reset()

    def do_repeat_timed(self):
        self.__devices.get_pump("swim").speed(self.__speed)
        self.__timer.update(datetime.now())
        if self.__timer.elapsed():
            self._proxy.halt.defer()
        else:
            self.do_delay(self.STATE_REFRESH_DELAY, self.do_repeat_timed.__name__)

    @do_repeat()
    def on_enter_continuous(self):
        logger.info("Entering continuous state")
        self.__encoder.swim_state("continuous")

    def do_repeat_continuous(self):
        self.__devices.get_pump("swim").speed(self.__speed)
        self.do_delay(self.STATE_REFRESH_DELAY, self.do_repeat_continuous.__name__)

    @do_repeat()
    def on_enter_wintering_waiting(self):
        logger.info("Entering wintering waiting state")
        self.__encoder.swim_state("wintering_waiting")
        self.__devices.get_pump("swim").off()

    def do_repeat_wintering_waiting(self):
        if self.__machine.get_time_in_state() > timedelta(seconds=Swim.WINTERING_PERIOD):
            temperature = self.__temperature.get_temperature("temperature_ncc").get()
            if temperature is None or temperature <= Swim.WINTERING_ONLY_BELOW:
                self._proxy.wintering_stir.defer()
                return
        self.do_delay(2 * 60, self.do_repeat_wintering_waiting.__name__)

    def on_enter_wintering_stir(self):
        logger.info("Entering wintering stir state")
        self.__encoder.swim_state("wintering_stir")
        self.__devices.get_pump("swim").on()
        self.do_delay(Swim.WINTERING_DURATION, "wintering_waiting") class Dispatcher(object):

    def __init__(self):
        self.__mapping = {}

    def register(self, filtration, tank, swim, light, heater, heating, disinfection, arduino):
        self.__mapping = {
            "/settings/mode": (
                filtration,
                lambda x: x in (
                    "halt",
                    "eco",
                    "standby",
                    "overflow",
                    "comfort",
                    "sweep",
                    "wash",
                    "wintering"
                ),
                lambda x: x,
                None,
                False,
            ),
            "/settings/filtration/duration": (
                filtration,
                between(1, 172800),
                lambda _: "duration",
                to_int,
                False,
            ),
            "/settings/filtration/period": (
                filtration,
                between(1, 10),
                lambda _: "period",
                to_int,
                False,
            ),
            "/settings/filtration/reset_hour": (
                filtration,
                between(0, 23),
                lambda _: "reset_hour",
                to_int,
                False,
            ),
            "/settings/filtration/tank_percentage": (
                filtration,
                between(0, 0.5),
                lambda _: "tank_percentage",
                to_float,
                False,
            ),
            "/settings/filtration/stir_duration": (
                filtration,
                between(0, 10 * 60),
                lambda _: "stir_duration",
                to_int,
                False,
            ),
            "/settings/filtration/stir_period": (
                filtration,
                between(0, 7200),
                lambda _: "stir_period",
                to_int,
                False,
            ),
            "/settings/filtration/boost_duration": (
                filtration,
                between(0, 10 * 60),
                lambda _: "boost_duration",
                to_int,
                False,
            ),
            "/settings/filtration/backwash/period": (
                filtration,
                between(0, 90),
                lambda _: "backwash_period",
                to_int,
                False,
            ),
            "/settings/filtration/backwash/backwash_duration": (
                filtration,
                between(0, 300),
                lambda _: "backwash_backwash_duration",
                to_int,
                False,
            ),
            "/settings/filtration/backwash/rinse_duration": (
                filtration,
                between(0, 300),
                lambda _: "backwash_rinse_duration",
                to_int,
                False,
            ),
            "/status/filtration/backwash/last": (
                filtration,
                lambda _: True,
                lambda _: "backwash_last",
                to_string,
                False,
            ),
            "/status/filtration/duration": (
                filtration,
                between(0, 86400),
                lambda _: "restore_duration",
                to_int,
                True,
            ),
            "/settings/filtration/speed/eco": (
                filtration,
                between(1, 3),
                lambda _: "speed_eco",
                to_int,
                False,
            ),
            "/settings/filtration/speed/standby": (
                filtration,
                between(0, 2),
                lambda _: "speed_standby",
                to_int,
                False,
            ),
            "/settings/filtration/speed/overflow": (
                filtration,
                between(1, 4),
                lambda _: "speed_overflow",
                to_int,
                False,
            ),
            "/settings/filtration/overflow_in_comfort": (
                filtration,
                lambda x: x.lower() in ("0", "1", "off", "on"),
                lambda _: "overflow_in_comfort",
                to_bool,
                False,
            ),
            "/settings/cover/position/eco": (
                filtration,
                between(0, 100),
                lambda _: "cover_position_eco",
                to_int,
                False,
            ),
            "/settings/tank/force_empty": (
                tank,
                lambda x: x.lower() in ("0", "1", "off", "on"),
                lambda _: "force_empty",
                to_bool,
                False,
            ),
            "/settings/swim/mode": (
                swim,
                lambda x: x in ("halt", "timed", "continuous"),
                lambda x: x,
                None,
                False,
            ),
            "/settings/swim/timer": (
                swim,
                between(1, 60),
                lambda _: "timer",
                to_int,
                False,
            ),
            "/settings/swim/speed": (
                swim,
                between(1, 100),
                lambda _: "speed",
                to_int,
                False,
            ),
            "/settings/light/mode": (
                light,
                lambda x: x in ("halt", "on"),
                lambda x: x,
                None,
                False,
            ),
            "/settings/heater/setpoint": (
                heater,
                between(0, 30),
                lambda _: "setpoint",
                to_float,
                False,
            ),
            "/settings/heating/enable": (
                heating,
                lambda x: x.lower() in ("0", "1", "off", "on"),
                lambda _: "enable",
                to_bool,
                False,
            ),
            "/settings/heating/setpoint": (
                heating,
                between(10, 32),
                lambda _: "setpoint",
                to_float,
                False,
            ),
            "/settings/heating/start_hour": (
                heating,
                between(0, 23),
                lambda _: "start_hour",
                to_int,
                False,
            ),
            "/settings/heating/min_temp": (
                heating,
                between(5, 25),
                lambda _: "min_temp",
                to_int,
                False,
            ),
            "/status/heating/total_seconds": (
                heating,
                lambda _: True,
                lambda _: "total_seconds",
                to_int,
                True,
            ),
            "/settings/disinfection/ph/enable": (
                disinfection,
                lambda x: x.lower() in ("0", "1", "off", "on"),
                lambda _: "ph_enable",
                to_bool,
                False,
            ),
            "/settings/disinfection/ph/setpoint": (
                disinfection,
                between(6, 8),
                lambda _: "ph_setpoint",
                to_float,
                False,
            ),
            "/settings/disinfection/ph/pterm": (
                disinfection,
                between(0, 10),
                lambda _: "ph_pterm",
                to_float,
                False,
            ),
            "/settings/disinfection/orp/enable": (
                disinfection,
                lambda x: x.lower() in ("0", "1", "off", "on"),
                lambda _: "orp_enable",
                to_bool,
                False,
            ),
            "/settings/disinfection/orp/setpoint": (
                disinfection,
                between(500, 800),
                lambda _: "orp_setpoint",
                to_int,
                False,
            ),
            "/settings/disinfection/orp/pterm": (
                disinfection,
                between(0, 10),
                lambda _: "orp_pterm",
                to_float,
                False,
            ),
            "/status/water/counter": (
                arduino,
                greater_equal(0),
                lambda _: "restore_water_counter",
                to_int,
                True,
            ),
        }

    def topics(self):
        return self.__mapping.keys()

    def dispatch(self, topic, payload):
        entry = self.__mapping.get(topic)
        if entry:
            fsm, predicate, method, value, once = entry
            try:
                data = payload.decode("utf-8")
                if predicate(data):
                    func = getattr(fsm, method(data))
                    param = value(data) if value else None
                    if param is not None:
                        func.defer(param)
                    else:
                        func.defer()
                    # Remove the entry if it should be processed only once e.g. for
                    # configuration restore at startup.
                    if once:
                        logger.debug("Removing %s, only processed once" % topic)
                        del self.__mapping[topic]
            except Exception:
                logger.exception("Unable to process data for %s: %s" % (topic, str(payload))) class Encoder(object):

    def __init__(self, mqtt, lcd):
        self.__mqtt = mqtt
        self.__lcd = lcd

    def __getattr__(self, value):
        topic = "/status/" + "/".join(value.split("_"))
        topic = topic.replace("//", "_")

        def wrapper(x, **kwargs):
            self.__mqtt.publish.defer(topic, x, **kwargs)
            self.__lcd.update.defer(value, x)

        return wrapper class Mqtt(PoupoolActor):

    def __init__(self, dispatcher):
        super().__init__()
        self.__run = True
        self.__dispatcher = dispatcher
        self.__client = mqtt.Client()
        self.__client.on_connect = self.__on_connect
        self.__client.on_message = self.__on_message
        self.__client.on_disconnect = self.__on_disconnect

    def on_stop(self):
        self.do_stop()
        self.__client.disconnect()

    def __on_connect(self, client, userdata, flags, rc):
        logger.info("MQTT client connected to broker")
        for topic in self.__dispatcher.topics():
            self.__client.subscribe(topic)

    def __on_message(self, client, userdata, message):
        self.__dispatcher.dispatch(message.topic, message.payload)

    def __on_disconnect(self, client, userdata, rc):
        logger.warning("MQTT client disconnected: %d" % rc)
        if rc != 0 and self.__run:
            self.do_connect()

    def do_connect(self):
        try:
            self.__client.connect("localhost")
        except Exception as e:
            logger.error("Unable to connect to MQTT broker: %s" % e)
            self.do_delay(5, self.do_connect.__name__)

    def do_start(self):
        self.do_connect()
        self._proxy.do_loop()

    def do_loop(self):
        self.__client.loop(timeout=0.05)
        if self.__run:
            self.do_delay(0, self.do_loop.__name__)

    def do_stop(self):
        self.__run = False

    def publish(self, topic, payload, qos=0, retain=False):
        result, _ = self.__client.publish(topic, payload, qos, retain)
        if result != 0:
            logger.error("Unable to publish topic '%s':'%s'" % (topic, str(payload)))
            return False
        return True class TemperatureReader(BaseReader):

    DELAY_SECONDS = 60
    DURATION = timedelta(minutes=30)

    def __init__(self, sensors):
        samples = int(self.DURATION.total_seconds() // self.DELAY_SECONDS)
        super().__init__(sensors, maxlen=samples)

    def get_temperature(self, name):
        return self.values[name].mean()

    def get_all_temperatures(self):
        return {k: v.mean() for k, v in self.values.items()}

    def get_temperature_slope(self, name):
        """Return the slope in degree/hour"""
        values = self.values[name].all()
        size = len(values)
        if size < 2:
            return 0
        duration = self.DELAY_SECONDS * (size - 1)
        first = values[0]
        last = values[size - 1]
        return (last - first) / duration * 3600

    def do_read(self):
        super().do_read()
        self.do_delay(self.DELAY_SECONDS, self.do_read.__name__) class TemperatureWriter(PoupoolActor):

    DELAY_SECONDS = 60

    def __init__(self, encoder, reader):
        super().__init__()
        self.__encoder = encoder
        self.__reader = reader

    def do_write(self):
        for name, value in self.__reader.get_all_temperatures().get().items():
            if value is not None:
                rounded = round(value, 1)
                logger.debug("Temperature (%s) is %.1f°C" % (name, rounded))
                f = getattr(self.__encoder, name)
                f(rounded)
            else:
                logger.warning("Temperature (%s) cannot be read!!!" % name)
        # Special handling for temperature slope for the pool
        rounded = round(self.__reader.get_temperature_slope("temperature_pool").get(), 2)
        logger.debug("Temperature slope for pool is %.2f°C/hour" % rounded)
        self.__encoder.temperature_pool__slope(rounded)
        self.do_delay(self.DELAY_SECONDS, self.do_write.__name__) class DisinfectionReader(BaseReader):

    DELAY_SECONDS = 60
    DURATION = timedelta(minutes=5)

    def __init__(self, sensors):
        samples = int(self.DURATION.total_seconds() // self.DELAY_SECONDS)
        super().__init__(sensors, samples)

    def get_ph(self):
        return self.values["ph"].mean()

    def get_orp(self):
        return self.values["orp"].mean()

    def do_read(self):
        super().do_read()
        self.do_delay(self.DELAY_SECONDS, self.do_read.__name__) class DisinfectionWriter(PoupoolActor):

    DELAY_SECONDS = 60

    def __init__(self, encoder, reader):
        super().__init__()
        self.__encoder = encoder
        self.__reader = reader

    def do_write(self):
        orp = self.__reader.get_orp().get()
        if orp:
            self.__encoder.disinfection_orp_value("%d" % orp)
        ph = self.__reader.get_ph().get()
        if ph:
            self.__encoder.disinfection_ph_value("%.2f" % ph)
        self.do_delay(self.DELAY_SECONDS, self.do_write.__name__) class DeviceRegistry(object):

    def __init__(self):
        self.__valves = {}
        self.__pumps = {}
        self.__sensors = {}
        self.__devices = {}

    def get_valves(self):
        return self.__valves.values()

    def get_pumps(self):
        return self.__pumps.values()

    def get_sensors(self):
        return self.__sensors.values()

    def get_devices(self):
        return self.__devices.values()

    def add_valve(self, device):
        assert isinstance(device, SwitchDevice)
        self.__valves[device.name] = device

    def add_pump(self, device):
        assert isinstance(device, (SwitchDevice, PumpDevice))
        self.__pumps[device.name] = device

    def add_sensor(self, device):
        assert isinstance(device, SensorDevice)
        self.__sensors[device.name] = device

    def add_device(self, device):
        assert isinstance(device, StoppableDevice)
        self.__devices[device.name] = device

    def get_valve(self, name):
        return self.__valves.get(name)

    def get_pump(self, name):
        return self.__pumps.get(name)

    def get_sensor(self, name):
        return self.__sensors.get(name)

    def get_device(self, name):
        return self.__devices.get(name) class Config:
    def __init__(self, config_files):
    def __getitem__(self, pair):
def as_list(value, type_cast=int): class Lcd(PoupoolActor):

    UPDATE_DELAY = 2

    def __init__(self, lcdbackpack):
        super().__init__()
        self.__cache = {}
        self.__lcdbackpack = lcdbackpack

    def on_stop(self):
        if self.__lcdbackpack:
            self.__lcdbackpack.clear()
            self.__lcdbackpack.set_brightness(16)
            self.__lcdbackpack.set_cursor_home()
            self.__lcdbackpack.write(" " * 20)
            self.__lcdbackpack.write("       POUPOOL      ")
            self.__lcdbackpack.write("     NOT RUNNING")
            self.__lcdbackpack.disconnect()
        super().on_stop()

    def update(self, key, value):
        self.__cache[key] = value

    def do_start(self):
        try:
            self.__lcdbackpack.connect()
            self.__lcdbackpack.set_lcd_size(20, 4)
            # Not supported in the version from pip
            # self.__lcdbackpack.set_splash_screen("Poupool", 20 * 4)
            self.__lcdbackpack.clear()
            self.__lcdbackpack.set_brightness(255)
            self.__lcdbackpack.display_on()
            # Go to our daily job
            self._proxy.do_update.defer()
        except SerialException:
            logger.exception("Unable to open LCD, ignoring the device")
            self.__lcdbackpack = None

    def do_update(self):
        self.__lcdbackpack.set_cursor_home()
        self.__lcdbackpack.write(self.get_string())
        self.do_delay(self.UPDATE_DELAY, self.do_update.__name__)

    def get_string(self):
        state = self.__cache.get("filtration_state", "--")
        s = "Mode {:>15}\n".format(state.upper())[:20]
        pool = float(self.__cache.get("temperature_pool", 0))
        air = float(self.__cache.get("temperature_air", 0))
        s += "Water {:>4.1f} Air {:>5.1f}".format(pool, air)[:20]
        try:
            ph = float(self.__cache.get("disinfection_ph_value", None))
            orp = int(self.__cache.get("disinfection_orp_value", None))
            s += "pH     {:>3.1f} ORP {:>5d}".format(ph, orp)[:20]
        except (TypeError, ValueError):
            s += "pH     -.- ORP   ---"
        next_event = self.__cache.get("filtration_next", "00:00:00")
        s += "Next event  {:>8}\n".format(next_event)[:20]
        return s

    def get_printable_string(self):
        # The display is a 4x20 LCD.
        message = self.get_string()
        return "\n".join(message[20 * i: 20 * i + 20] for i in range(4))
import os
import time
import pykka
import logging.config
import argparse
import itertools
import signal
import sys
    import RPi.GPIO as GPIO
    import board
    import busio
    import adafruit_ads1x15.ads1015 as ADS
    import adafruit_mcp4725
            import random
            import RPi.GPIO as GPIO
from controller.arduino import Arduino
from controller.filtration import Filtration
from controller.disinfection import Disinfection
from controller.heating import Heating, Heater
from controller.light import Light
from controller.tank import Tank
from controller.swim import Swim
from controller.dispatcher import Dispatcher
from controller.encoder import Encoder
from controller.mqtt import Mqtt
from controller.sensor import TemperatureReader, TemperatureWriter
from controller.sensor import DisinfectionReader, DisinfectionWriter
from controller.device import DeviceRegistry
from controller.config import config, as_list
from controller.lcd import Lcd
    from controller.device import SwitchDevice, PumpDevice
    from controller.device import SwimPumpDevice, TempSensorDevice, TankSensorDevice
    from controller.device import ArduinoDevice, EZOSensorDevice, LcdDevice
    from controller.device import SensorDevice, SwimPumpDevice, StoppableDevice
        def normalized_value(self, value):
            self.__value = value

        @property
        def value(self):
            return self.__value

        @value.setter
        def value(self, value):
            self.__value = value

    class FakeLcd(StoppableDevice):

        def __init__(self, name):
            super().__init__(name)
            self.__counter = 0

        def __getattr__(self, attr):
            # Just do nothing. We already implemented the minimum attributes for the fake
            return lambda *x: None

        def stop(self):
            pass

        def write(self, value):
            if self.__counter % 5 == 0:
                # The display is a 4x20 LCD.
                print("\n" + "\n".join(value[20 * i: 20 * i + 20] for i in range(4)) + "\n")
            self.__counter += 1

    # Relay
    GPIO = FakeGpio()
    setup_gpio(registry, GPIO)

    # ADC
    registry.add_sensor(FakeSensor("tank", 51.234))

    # DAC
    registry.add_pump(SwimPumpDevice("swim", GPIO, int(config["pins", "swim"]), FakeDAC()))

    # pH, ORP
    registry.add_sensor(FakeRandomSensor("ph", 6.5, 8))
    registry.add_sensor(FakeRandomSensor("orp", 640, 800))

    # 1-wire
    registry.add_sensor(FakeSensor("temperature_pool", 24.5))
    registry.add_sensor(FakeSensor("temperature_local", 20.6))
    registry.add_sensor(FakeSensor("temperature_air", 19.4))
    registry.add_sensor(FakeSensor("temperature_ncc", 21.3))

    # Arduino
    registry.add_device(FakeArduino("arduino"))

    # Lcd
    registry.add_device(FakeLcd("lcd"))


def toggle_test(device):
    print("Toggling %s " % device.name, end="")
    result = input("[y/N]: ")
    if result == "y":
        device.on()
        time.sleep(2)
        device.off()


def read_test(device):
    print("Read %s " % device.name, end="")
    try:
        result = int(input("[0-10000]: "))
        if 0 < result <= 10000:
            for _ in range(result):
                print(device.value)
                time.sleep(1)
    except ValueError:
        pass


def test(args, devices):
    pump = devices.get_pump("variable")
    print("Toggling %s " % pump.name, end="")
    result = input("[y/N]: ")
    if result == "y":
        for speed in reversed(range(4)):
            print("%s: speed %d" % (pump.name, speed))
            pump.speed(speed)
            time.sleep(2)
    toggle_test(devices.get_pump("boost"))
    toggle_test(devices.get_pump("swim"))
    toggle_test(devices.get_pump("ph"))
    toggle_test(devices.get_pump("cl"))

    toggle_test(devices.get_valve("gravity"))
    toggle_test(devices.get_valve("backwash"))
    toggle_test(devices.get_valve("tank"))
    toggle_test(devices.get_valve("drain"))
    toggle_test(devices.get_valve("main"))

    toggle_test(devices.get_valve("light"))

    # toggle_test(devices.get_valve("heater"))
    toggle_test(devices.get_valve("heating"))

    read_test(devices.get_sensor("temperature_pool"))
    read_test(devices.get_sensor("temperature_local"))
    read_test(devices.get_sensor("temperature_air"))
    read_test(devices.get_sensor("temperature_ncc"))
    read_test(devices.get_sensor("tank"))
    read_test(devices.get_sensor("ph"))
    read_test(devices.get_sensor("orp"))


# Main running flag
running = True


def main(args, devices):
    dispatcher = Dispatcher()

    mqtt = Mqtt.start(dispatcher).proxy()

    lcd = Lcd.start(devices.get_device("lcd")).proxy()