Beispiel #1
0
class EventSender(object):

    @Inject
    def __init__(self, cloud_api_client=INJECTED):  # type: (CloudAPIClient) -> None
        self._queue = deque()  # type: deque
        self._stopped = True
        self._cloud_client = cloud_api_client
        self._event_enabled_cache = {}  # type: Dict[int, bool]
        self._events_queue = deque()  # type: deque
        self._events_thread = DaemonThread(name='eventsender',
                                           target=self._send_events_loop,
                                           interval=0.1, delay=0.2)

    def start(self):
        # type: () -> None
        self._events_thread.start()

    def stop(self):
        # type: () -> None
        self._events_thread.stop()

    def enqueue_event(self, event):
        if Config.get_entry('cloud_enabled', False) is False:
            return
        if event.type == GatewayEvent.Types.CONFIG_CHANGE:
            if event.data.get('type') == 'input':
                self._event_enabled_cache = {}
        if self._should_send_event(event):
            event.data['timestamp'] = time.time()
            self._queue.appendleft(event)

    def _should_send_event(self, event):
        if event.type != GatewayEvent.Types.INPUT_CHANGE:
            return True
        input_id = event.data['id']
        enabled = self._event_enabled_cache.get(input_id)
        if enabled is not None:
            return enabled
        self._event_enabled_cache = InputController.load_inputs_event_enabled()
        return self._event_enabled_cache.get(input_id, False)

    def _send_events_loop(self):
        # type: () -> None
        try:
            if not self._batch_send_events():
                raise DaemonThreadWait
        except APIException as ex:
            logger.error('Error sending events to the cloud: {}'.format(str(ex)))

    def _batch_send_events(self):
        events = []
        while len(events) < 25:
            try:
                events.append(self._queue.pop())
            except IndexError:
                break
        if len(events) > 0:
            self._cloud_client.send_events(events)
            return True
        return False
Beispiel #2
0
 def start(self):
     self._sync_orm_thread = DaemonThread(name='{0}sync'.format(
         self.__class__.__name__.lower()[:10]),
                                          target=self._sync_orm,
                                          interval=self._sync_orm_interval,
                                          delay=300)
     self._sync_orm_thread.start()
Beispiel #3
0
 def start(self):
     super(FrontpanelCoreController, self).start()
     # Start polling/writing threads
     self._check_buttons_thread = DaemonThread(name='buttonchecker',
                                               target=self._check_buttons,
                                               interval=0.25)
     self._check_buttons_thread.start()
    def __init__(self,
                 output_controller=INJECTED,
                 master_controller=INJECTED,
                 pubsub=INJECTED):
        # type: (OutputController, MasterClassicController, PubSub) -> None
        super(ThermostatControllerMaster, self).__init__(output_controller)
        self._master_controller = master_controller  # classic only
        self._pubsub = pubsub

        self._monitor_thread = DaemonThread(name='thermostatctl',
                                            target=self._monitor,
                                            interval=30,
                                            delay=10)

        self._thermostat_status = ThermostatStatusMaster(
            on_thermostat_change=self._thermostat_changed,
            on_thermostat_group_change=self._thermostat_group_changed)
        self._thermostats_original_interval = 30
        self._thermostats_interval = self._thermostats_original_interval
        self._thermostats_last_updated = 0.0
        self._thermostats_restore = 0
        self._thermostats_config = {}  # type: Dict[int, ThermostatDTO]

        self._pubsub.subscribe_master_events(PubSub.MasterTopics.EEPROM,
                                             self._handle_master_event)
Beispiel #5
0
 def start(self,
           custom_interval=30
           ):  # Adding custom interval to pass the tests faster
     self._stop = False
     self._processor = DaemonThread(target=self._process,
                                    name='schedulingctl',
                                    interval=custom_interval)
     self._processor.start()
Beispiel #6
0
 def __init__(self, name, collector, interval=5):
     self._data = None
     self._data_lock = Lock()
     self._interval = interval
     self._collector_function = collector
     self._collector_thread = DaemonThread(name='{0}coll'.format(name),
                                           target=self._collect,
                                           interval=interval)
 def start(self):
     # type: () -> None
     super(OutputController, self).start()
     self._sync_state_thread = DaemonThread(name='outputsyncstate',
                                            target=self._sync_state,
                                            interval=600,
                                            delay=10)
     self._sync_state_thread.start()
Beispiel #8
0
 def start(self):
     # type: () -> None
     if self._thread is None:
         logger.info('Starting master heartbeat')
         self._thread = DaemonThread(name='masterheartbeat',
                                     target=self._heartbeat,
                                     interval=30,
                                     delay=5)
         self._thread.start()
Beispiel #9
0
 def __init__(self, cloud_api_client=INJECTED):  # type: (CloudAPIClient) -> None
     self._queue = deque()  # type: deque
     self._stopped = True
     self._cloud_client = cloud_api_client
     self._event_enabled_cache = {}  # type: Dict[int, bool]
     self._events_queue = deque()  # type: deque
     self._events_thread = DaemonThread(name='eventsender',
                                        target=self._send_events_loop,
                                        interval=0.1, delay=0.2)
Beispiel #10
0
 def start(self):
     # type: () -> None
     if self._watchdog_thread is None:
         self.start_time = time.time()
         self._watchdog_thread = DaemonThread(name='watchdog',
                                              target=self._watch,
                                              interval=60,
                                              delay=10)
         self._watchdog_thread.start()
Beispiel #11
0
 def __init__(self, master_communicator=INJECTED):
     # type: (MasterCommunicator) -> None
     self._master_communicator = master_communicator
     self._failures = -1  # Start "offline"
     self._backoff = 60
     self._last_restart = 0.0
     self._min_threshold = 2
     self._thread = DaemonThread(name='masterheartbeat',
                                 target=self._heartbeat,
                                 interval=30,
                                 delay=5)
Beispiel #12
0
 def __init__(self, message_client=INJECTED):
     self._configuration = {}
     self._intervals = {}
     self._vpn_open = False
     self._message_client = message_client
     self._vpn_controller = VpnController()
     self._tasks = deque()
     self._previous_amount_of_tasks = 0
     self._executor = DaemonThread(name='taskexecutor',
                                   target=self._execute_tasks,
                                   interval=300)
Beispiel #13
0
 def start(self):
     # type: () -> None
     """ Start the background thread of the TimeKeeper. """
     if self.__thread is None:
         logger.info("Starting TimeKeeper")
         self.__stop = False
         self.__thread = DaemonThread(name='timekeeper',
                                      target=self.__run,
                                      interval=self.__period)
         self.__thread.start()
     else:
         raise Exception("TimeKeeper thread already running.")
 def start(self):
     super(FrontpanelClassicController, self).start()
     # Enable power led
     self._enabled_leds[FrontpanelController.Leds.POWER] = True
     # Start polling/writing threads
     self._poll_button_thread = DaemonThread(name='buttonpoller',
                                             target=self._poll_button,
                                             interval=0.25)
     self._poll_button_thread.start()
     self._write_leds_thread = DaemonThread(name='ledwriter',
                                            target=self._write_leds,
                                            interval=0.25)
     self._write_leds_thread.start()
    def __init__(self, pubsub=INJECTED):
        # type: (PubSub) -> None
        self._pubsub = pubsub
        self._status = {}  # type: Dict[int, VentilationStatusDTO]
        self.check_connected_runner = DaemonThread(
            'check_connected_thread',
            self._check_connected_timeout,
            interval=30,
            delay=15)

        self.periodic_event_update_runner = DaemonThread(
            'periodic_update',
            self._periodic_event_update,
            interval=900,
            delay=90)
Beispiel #16
0
 def __init__(self):
     # type: () -> None
     self._gateway_topics = defaultdict(
         list
     )  # type: Dict[GATEWAY_TOPIC,List[Callable[[GatewayEvent],None]]]
     self._master_topics = defaultdict(
         list
     )  # type: Dict[MASTER_TOPIC,List[Callable[[MasterEvent],None]]]
     self._master_events = Queue(
     )  # type: Queue  # Queue[Tuple[str, MasterEvent]]
     self._gateway_events = Queue(
     )  # type: Queue  # Queue[Tuple[str, GatewayEvent]]
     self._pub_thread = DaemonThread(name='pubsub',
                                     target=self._publisher_loop,
                                     interval=0.1,
                                     delay=0.2)
     self._is_running = False
Beispiel #17
0
    def start(self):  # type: () -> None
        logger.info('Starting gateway thermostatcontroller...')
        if not self._running:
            self._running = True

            self.refresh_config_from_db()
            self._pid_loop_thread = DaemonThread(
                name='thermostatpid',
                target=self._pid_tick,
                interval=self.THERMOSTAT_PID_UPDATE_INTERVAL)
            self._pid_loop_thread.start()

            self._update_pumps_thread = DaemonThread(
                name='thermostatpumps',
                target=self._update_pumps,
                interval=self.PUMP_UPDATE_INTERVAL)
            self._update_pumps_thread.start()

            self._periodic_sync_thread = DaemonThread(
                name='thermostatsync',
                target=self._periodic_sync,
                interval=self.SYNC_CONFIG_INTERVAL)
            self._periodic_sync_thread.start()

            self._scheduler.start()
            logger.info('Starting gateway thermostatcontroller... Done')
        else:
            raise RuntimeError(
                'GatewayThermostatController already running. Please stop it first.'
            )
Beispiel #18
0
class DataCollector(object):
    def __init__(self, name, collector, interval=5):
        self._data = None
        self._data_lock = Lock()
        self._interval = interval
        self._collector_function = collector
        self._collector_thread = DaemonThread(name='{0}coll'.format(name),
                                              target=self._collect,
                                              interval=interval)

    def start(self):
        self._collector_thread.start()

    def _collect(self):
        data = self._collector_function()
        with self._data_lock:
            self._data = data

    @property
    def data(self):
        with self._data_lock:
            data = self._data
            self._data = None
        return data
Beispiel #19
0
 def start(self):
     self._refresh_cloud_interval()
     self._collector_plugins = DaemonThread(name='metricplugincoll',
                                            target=self._collect_plugins,
                                            interval=1)
     self._collector_plugins.start()
     self._collector_openmotics = DaemonThread(name='metricplugindist',
                                               target=self._collect_openmotics,
                                               interval=1)
     self._collector_openmotics.start()
     self._distributor_plugins = DaemonThread(name='metricplugindist',
                                              target=self._distribute_plugins,
                                              interval=0, delay=0.1)
     self._distributor_plugins.start()
     self._distributor_openmotics = DaemonThread(name='metricomdist',
                                                 target=self._distribute_openmotics,
                                                 interval=0, delay=0.1)
     self._distributor_openmotics.start()
Beispiel #20
0
 def __init__(self):
     self.vpn_connected = False
     self._vpn_tester = DaemonThread(name='vpnctl',
                                     target=self._vpn_connected,
                                     interval=5)
Beispiel #21
0
class VpnController(object):
    """ Contains methods to check the vpn status, start and stop the vpn. """
    if System.get_operating_system().get('ID') == System.OS.BUILDROOT:
        vpn_binary = 'openvpn'
        config_location = '/etc/openvpn/client/'
        start_cmd = 'cd {} ; {} --suppress-timestamps --nobind --config vpn.conf > /dev/null'.format(
            config_location, vpn_binary)
        stop_cmd = 'killall {} > /dev/null'.format(vpn_binary)
        check_cmd = 'ps -a | grep {} | grep -v "grep" > /dev/null'.format(
            vpn_binary)
    else:
        vpn_service = System.get_vpn_service()
        start_cmd = 'systemctl start {0} > /dev/null'.format(vpn_service)
        stop_cmd = 'systemctl stop {0} > /dev/null'.format(vpn_service)
        check_cmd = 'systemctl is-active {0} > /dev/null'.format(vpn_service)

    def __init__(self):
        self.vpn_connected = False
        self._vpn_tester = DaemonThread(name='vpnctl',
                                        target=self._vpn_connected,
                                        interval=5)

    def start(self):
        self._vpn_tester.start()

    @staticmethod
    def start_vpn():
        """ Start openvpn """
        logger.info('Starting VPN')
        return subprocess.call(VpnController.start_cmd, shell=True) == 0

    @staticmethod
    def stop_vpn():
        """ Stop openvpn """
        logger.info('Stopping VPN')
        return subprocess.call(VpnController.stop_cmd, shell=True) == 0

    @staticmethod
    def check_vpn():
        """ Check if openvpn is running """
        return subprocess.call(VpnController.check_cmd, shell=True) == 0

    def _vpn_connected(self):
        """ Checks if the VPN tunnel is connected """
        try:
            routes = subprocess.check_output(
                'ip r | grep tun | grep via || true', shell=True).strip()
            # example output:
            # 10.0.0.0/24 via 10.37.0.5 dev tun0\n
            # 10.37.0.1 via 10.37.0.5 dev tun0
            result = False
            if routes:
                if not isinstance(
                        routes, str):  # to ensure python 2 and 3 compatibility
                    routes = routes.decode()

                vpn_servers = [
                    route.split(' ')[0] for route in routes.split('\n')
                    if '/' not in route
                ]
                for vpn_server in vpn_servers:
                    if TaskExecutor._ping(vpn_server, verbose=False):
                        result = True
                        break
            self.vpn_connected = result
        except Exception as ex:
            logger.info(
                'Exception occured during vpn connectivity test: {0}'.format(
                    ex))
            self.vpn_connected = False
Beispiel #22
0
class TaskExecutor(object):
    @Inject
    def __init__(self, message_client=INJECTED):
        self._configuration = {}
        self._intervals = {}
        self._vpn_open = False
        self._message_client = message_client
        self._vpn_controller = VpnController()
        self._tasks = deque()
        self._previous_amount_of_tasks = 0
        self._executor = DaemonThread(name='taskexecutor',
                                      target=self._execute_tasks,
                                      interval=300)

    def start(self):
        self._vpn_controller.start()
        self._executor.start()

    def set_new_tasks(self, task_data):
        self._tasks.appendleft(task_data)
        self._executor.request_single_run()

    @property
    def vpn_open(self):
        return self._vpn_open

    def _execute_tasks(self):
        while True:
            try:
                task_data = self._tasks.pop()
            except IndexError:
                return

            amount_of_tasks = len(task_data)
            if self._previous_amount_of_tasks != amount_of_tasks:
                logger.info('Processing {0} tasks...'.format(amount_of_tasks))

            if 'configuration' in task_data:
                self._process_configuration_data(task_data['configuration'])
            if 'intervals' in task_data:
                self._process_interval_data(task_data['intervals'])
            if 'open_vpn' in task_data:
                self._open_vpn(task_data['open_vpn'])
            if 'events' in task_data and self._message_client is not None:
                for event in task_data['events']:
                    try:
                        self._message_client.send_event(event[0], event[1])
                    except Exception as ex:
                        logger.error(
                            'Could not send event {0}({1}): {2}'.format(
                                event[0], event[1], ex))
            if 'connectivity' in task_data:
                self._check_connectivity(task_data['connectivity'])

            if self._previous_amount_of_tasks != amount_of_tasks:
                logger.info(
                    'Processing {0} tasks... Done'.format(amount_of_tasks))
                self._previous_amount_of_tasks = amount_of_tasks

    def _process_configuration_data(self, configuration):
        try:
            configuration_changed = self._configuration != configuration
            if configuration_changed:
                for setting, value in configuration.items():
                    Config.set_entry(setting, value)
                logger.info('Configuration changed: {0}'.format(configuration))
            self._configuration = configuration
        except Exception:
            logger.exception(
                'Unexpected exception processing configuration data')

    def _process_interval_data(self, intervals):
        try:
            intervals_changed = self._intervals != intervals
            if intervals_changed and self._message_client is not None:
                self._message_client.send_event(
                    OMBusEvents.METRICS_INTERVAL_CHANGE, intervals)
                logger.info('Intervals changed: {0}'.format(intervals))
            self._intervals = intervals
        except Exception:
            logger.exception('Unexpected exception processing interval data')

    def _open_vpn(self, should_open):
        try:
            is_running = VpnController.check_vpn()
            if should_open and not is_running:
                logger.info('Opening vpn...')
                VpnController.start_vpn()
                logger.info('Opening vpn... Done')
            elif not should_open and is_running:
                logger.info('Closing vpn...')
                VpnController.stop_vpn()
                logger.info('Closing vpn... Done')
            is_running = VpnController.check_vpn()
            self._vpn_open = is_running and self._vpn_controller.vpn_connected
            if self._message_client is not None:
                self._message_client.send_event(OMBusEvents.VPN_OPEN,
                                                self._vpn_open)
        except Exception:
            logger.exception('Unexpected exception opening/closing VPN')

    def _check_connectivity(self, last_successful_heartbeat):
        try:
            if last_successful_heartbeat > time.time(
            ) - CHECK_CONNECTIVITY_TIMEOUT:
                if self._message_client is not None:
                    self._message_client.send_event(OMBusEvents.CONNECTIVITY,
                                                    True)
            else:
                connectivity = TaskExecutor._has_connectivity()
                if self._message_client is not None:
                    self._message_client.send_event(OMBusEvents.CONNECTIVITY,
                                                    connectivity)
                if not connectivity and last_successful_heartbeat < time.time(
                ) - REBOOT_TIMEOUT:
                    subprocess.call('sync && reboot', shell=True)
        except Exception:
            logger.exception('Unexpected exception checking connectivity')

    @staticmethod
    def _ping(target, verbose=True):
        """ Check if the target can be pinged. Returns True if at least 1/4 pings was successful. """
        if target is None:
            return False

        # The popen_timeout has been added as a workaround for the hanging subprocess
        # If NTP date changes the time during a execution of a sub process this hangs forever.
        def popen_timeout(command, timeout):
            ping_process = subprocess.Popen(command,
                                            stdout=subprocess.PIPE,
                                            stderr=subprocess.PIPE,
                                            close_fds=True)
            for _ in range(timeout):
                time.sleep(1)
                if ping_process.poll() is not None:
                    stdout_data, stderr_data = ping_process.communicate()
                    if ping_process.returncode == 0:
                        return True
                    raise Exception(
                        'Non-zero exit code. Stdout: {0}, stderr: {1}'.format(
                            stdout_data, stderr_data))
            logger.warning(
                'Got timeout during ping to {0}. Killing'.format(target))
            ping_process.kill()
            del ping_process  # Make sure to clean up everything (or make it cleanable by the GC)
            logger.info('Ping to {0} killed'.format(target))
            return False

        if verbose is True:
            logger.info('Testing ping to {0}'.format(target))
        try:
            # Ping returns status code 0 if at least 1 ping is successful
            return popen_timeout(['ping', '-c', '3', target], 10)
        except Exception as ex:
            logger.error('Error during ping: {0}'.format(ex))
            return False

    @staticmethod
    def _has_connectivity():
        # Check connectivity by using ping to recover from a messed up network stack on the BeagleBone
        # Prefer using OpenMotics infrastructure first

        if TaskExecutor._ping('cloud.openmotics.com'):
            # OpenMotics infrastructure can be pinged
            # > Connectivity
            return True
        can_ping_internet_by_fqdn = TaskExecutor._ping(
            'example.com') or TaskExecutor._ping('google.com')
        if can_ping_internet_by_fqdn:
            # Public internet servers can be pinged by FQDN
            # > Assume maintenance on OpenMotics infrastructure. Sufficient connectivity
            return True
        can_ping_internet_by_ip = TaskExecutor._ping(
            '8.8.8.8') or TaskExecutor._ping('1.1.1.1')
        if can_ping_internet_by_ip:
            # Public internet servers can be pinged by IP, but not by FQDN
            # > Assume DNS resolving issues. Insufficient connectivity
            return False
        # Public internet servers cannot be pinged by IP, nor by FQDN
        can_ping_default_gateway = TaskExecutor._ping(
            TaskExecutor._get_default_gateway())
        if can_ping_default_gateway:
            # > Assume ISP outage. Sufficient connectivity
            return True
        # > Assume broken TCP stack. No connectivity
        return False

    @staticmethod
    def _get_default_gateway():
        """ Get the default gateway. """
        try:
            return subprocess.check_output(
                "ip r | grep '^default via' | awk '{ print $3; }'", shell=True)
        except Exception as ex:
            logger.error('Error during get_gateway: {0}'.format(ex))
            return
Beispiel #23
0
class TimeKeeper(object):
    """ The TimeKeeper keeps track of time and sets the day or night mode on the power modules. """

    def __init__(self, power_communicator, power_controller, period):
        # type: (Any, Any, int) -> None
        self.__power_communicator = power_communicator
        self.__power_controller = power_controller
        self.__period = period

        self.__mode = {}  # type: Dict[str, List[int]]

        self.__thread = None  # type: Optional[DaemonThread]
        self.__stop = False

    def start(self):
        # type: () -> None
        """ Start the background thread of the TimeKeeper. """
        if self.__thread is None:
            logger.info("Starting TimeKeeper")
            self.__stop = False
            self.__thread = DaemonThread(name='timekeeper',
                                         target=self.__run,
                                         interval=self.__period)
            self.__thread.start()
        else:
            raise Exception("TimeKeeper thread already running.")

    def stop(self):
        # type: () -> None
        """ Stop the background thread in the TimeKeeper. """
        if self.__thread is not None:
            self.__thread.stop()
            self.__thread = None
        else:
            raise Exception("TimeKeeper thread not running.")

    def __run(self):
        # type: () -> None
        """ One run of the background thread. """
        date = datetime.now()
        for module in self.__power_controller.get_power_modules().values():
            version = module['version']
            if version == power_api.P1_CONCENTRATOR:
                continue
            daynight = []
            for i in range(power_api.NUM_PORTS[version]):
                if self.is_day_time(module['times%d' % i], date):
                    daynight.append(power_api.DAY)
                else:
                    daynight.append(power_api.NIGHT)

            self.__set_mode(version, module['address'], daynight)

    @staticmethod
    def is_day_time(_times, date):
        # type: (Optional[str], datetime) -> bool
        """ Check if a date is in day time. """
        if _times is None:
            times = [0 for _ in range(14)]  # type: List[int]
        else:
            times = [int(t.replace(":", "")) for t in _times.split(",")]

        day_of_week = date.weekday()  # 0 = Monday, 6 = Sunday
        current_time = date.hour * 100 + date.minute

        start = times[day_of_week * 2]
        stop = times[day_of_week * 2 + 1]

        return stop > current_time >= start

    def __set_mode(self, version, address, bytes):
        # type: (int, str, List[int]) -> None
        """ Set the power modules mode. """
        if address not in self.__mode or self.__mode[address] != bytes:
            logger.info("Setting day/night mode to " + str(bytes))
            self.__power_communicator.do_command(address, power_api.set_day_night(version), *bytes)
            self.__mode[address] = bytes
 def start(self):
     self._check_network_activity_thread = DaemonThread(
         name='frontpanel', target=self._do_frontpanel_tasks, interval=0.5)
     self._check_network_activity_thread.start()
class ThermostatControllerMaster(ThermostatController):
    @Inject
    def __init__(self,
                 output_controller=INJECTED,
                 master_controller=INJECTED,
                 pubsub=INJECTED):
        # type: (OutputController, MasterClassicController, PubSub) -> None
        super(ThermostatControllerMaster, self).__init__(output_controller)
        self._master_controller = master_controller  # classic only
        self._pubsub = pubsub

        self._monitor_thread = DaemonThread(name='thermostatctl',
                                            target=self._monitor,
                                            interval=30,
                                            delay=10)

        self._thermostat_status = ThermostatStatusMaster(
            on_thermostat_change=self._thermostat_changed,
            on_thermostat_group_change=self._thermostat_group_changed)
        self._thermostats_original_interval = 30
        self._thermostats_interval = self._thermostats_original_interval
        self._thermostats_last_updated = 0.0
        self._thermostats_restore = 0
        self._thermostats_config = {}  # type: Dict[int, ThermostatDTO]

        self._pubsub.subscribe_master_events(PubSub.MasterTopics.EEPROM,
                                             self._handle_master_event)

    def start(self):
        # type: () -> None
        self._monitor_thread.start()

    def stop(self):
        # type: () -> None
        self._monitor_thread.stop()

    def _handle_master_event(self, master_event):
        # type: (MasterEvent) -> None
        if master_event.type == MasterEvent.Types.EEPROM_CHANGE:
            self.invalidate_cache(THERMOSTATS)

    def _thermostat_changed(self, thermostat_id, status):
        # type: (int, Dict[str,Any]) -> None
        """ Executed by the Thermostat Status tracker when an output changed state """
        location = {
            'room_id':
            Toolbox.denonify(self._thermostats_config[thermostat_id].room, 255)
        }
        gateway_event = GatewayEvent(
            GatewayEvent.Types.THERMOSTAT_CHANGE, {
                'id': thermostat_id,
                'status': {
                    'preset': status['preset'],
                    'current_setpoint': status['current_setpoint'],
                    'actual_temperature': status['actual_temperature'],
                    'output_0': status['output_0'],
                    'output_1': status['output_1']
                },
                'location': location
            })
        self._pubsub.publish_gateway_event(PubSub.GatewayTopics.STATE,
                                           gateway_event)

    def _thermostat_group_changed(self, status):
        # type: (Dict[str,Any]) -> None
        gateway_event = GatewayEvent(
            GatewayEvent.Types.THERMOSTAT_GROUP_CHANGE, {
                'id': 0,
                'status': {
                    'state': status['state'],
                    'mode': status['mode']
                },
                'location': {}
            })
        self._pubsub.publish_gateway_event(PubSub.GatewayTopics.STATE,
                                           gateway_event)

    @staticmethod
    def check_basic_action(ret_dict):
        """ Checks if the response is 'OK', throws a ValueError otherwise. """
        if ret_dict['resp'] != 'OK':
            raise ValueError('Basic action did not return OK.')

    def increase_interval(self, object_type, interval, window):
        """ Increases a certain interval to a new setting for a given amount of time """
        if object_type == THERMOSTATS:
            self._thermostats_interval = interval
            self._thermostats_restore = time.time() + window

    def invalidate_cache(self, object_type=None):
        """
        Triggered when an external service knows certain settings might be changed in the background.
        For example: maintenance mode or module discovery
        """
        if object_type is None or object_type == THERMOSTATS:
            self._thermostats_last_updated = 0

    ################################
    # New API
    ################################

    def get_current_preset(self, thermostat_number):
        raise NotImplementedError()

    def set_current_preset(self, thermostat_number, preset_type):
        raise NotImplementedError()

    ################################
    # Legacy API
    ################################

    def load_heating_thermostat(self,
                                thermostat_id):  # type: (int) -> ThermostatDTO
        return self._master_controller.load_heating_thermostat(thermostat_id)

    def load_heating_thermostats(self):  # type: () -> List[ThermostatDTO]
        return self._master_controller.load_heating_thermostats()

    def save_heating_thermostats(
            self, thermostats
    ):  # type: (List[Tuple[ThermostatDTO, List[str]]]) -> None
        self._master_controller.save_heating_thermostats(thermostats)
        self.invalidate_cache(THERMOSTATS)

    def load_cooling_thermostat(self,
                                thermostat_id):  # type: (int) -> ThermostatDTO
        return self._master_controller.load_cooling_thermostat(thermostat_id)

    def load_cooling_thermostats(self):  # type: () -> List[ThermostatDTO]
        return self._master_controller.load_cooling_thermostats()

    def save_cooling_thermostats(
            self, thermostats
    ):  # type: (List[Tuple[ThermostatDTO, List[str]]]) -> None
        self._master_controller.save_cooling_thermostats(thermostats)
        self.invalidate_cache(THERMOSTATS)

    def load_cooling_pump_group(self,
                                pump_group_id):  # type: (int) -> PumpGroupDTO
        return self._master_controller.load_cooling_pump_group(pump_group_id)

    def load_cooling_pump_groups(self):  # type: () -> List[PumpGroupDTO]
        return self._master_controller.load_cooling_pump_groups()

    def save_cooling_pump_groups(
            self, pump_groups
    ):  # type: (List[Tuple[PumpGroupDTO, List[str]]]) -> None
        self._master_controller.save_cooling_pump_groups(pump_groups)

    def load_global_rtd10(self):  # type: () -> GlobalRTD10DTO
        return self._master_controller.load_global_rtd10()

    def save_global_rtd10(
            self,
            global_rtd10):  # type: (Tuple[GlobalRTD10DTO, List[str]]) -> None
        self._master_controller.save_global_rtd10(global_rtd10)

    def load_heating_rtd10(self, rtd10_id):  # type: (int) -> RTD10DTO
        return self._master_controller.load_heating_rtd10(rtd10_id)

    def load_heating_rtd10s(self):  # type: () -> List[RTD10DTO]
        return self._master_controller.load_heating_rtd10s()

    def save_heating_rtd10s(
            self, rtd10s):  # type: (List[Tuple[RTD10DTO, List[str]]]) -> None
        self._master_controller.save_heating_rtd10s(rtd10s)

    def load_cooling_rtd10(self, rtd10_id):  # type: (int) -> RTD10DTO
        return self._master_controller.load_cooling_rtd10(rtd10_id)

    def load_cooling_rtd10s(self):  # type: () -> List[RTD10DTO]
        return self._master_controller.load_cooling_rtd10s()

    def save_cooling_rtd10s(
            self, rtd10s):  # type: (List[Tuple[RTD10DTO, List[str]]]) -> None
        self._master_controller.save_cooling_rtd10s(rtd10s)

    def load_thermostat_group(self):
        # type: () -> ThermostatGroupDTO
        return self._master_controller.load_thermostat_group()

    def save_thermostat_group(self, thermostat_group):
        # type: (Tuple[ThermostatGroupDTO, List[str]]) -> None
        self._master_controller.save_thermostat_group(thermostat_group)
        self.invalidate_cache(THERMOSTATS)

    def load_heating_pump_group(self,
                                pump_group_id):  # type: (int) -> PumpGroupDTO
        return self._master_controller.load_heating_pump_group(pump_group_id)

    def load_heating_pump_groups(self):  # type: () -> List[PumpGroupDTO]
        return self._master_controller.load_heating_pump_groups()

    def save_heating_pump_groups(
            self, pump_groups
    ):  # type: (List[Tuple[PumpGroupDTO, List[str]]]) -> None
        self._master_controller.save_heating_pump_groups(pump_groups)

    def set_thermostat_mode(self,
                            thermostat_on,
                            cooling_mode=False,
                            cooling_on=False,
                            automatic=None,
                            setpoint=None):
        # type: (bool, bool, bool, Optional[bool], Optional[int]) -> None
        """ Set the mode of the thermostats. """
        _ = thermostat_on  # Still accept `thermostat_on` for backwards compatibility

        # Figure out whether the system should be on or off
        set_on = False
        if cooling_mode is True and cooling_on is True:
            set_on = True
        if cooling_mode is False:
            # Heating means threshold based
            thermostat_group = self.load_thermostat_group()
            outside_sensor = Toolbox.denonify(
                thermostat_group.outside_sensor_id, 255)
            current_temperatures = self._master_controller.get_sensors_temperature(
            )[:32]
            if len(current_temperatures) < 32:
                current_temperatures += [None
                                         ] * (32 - len(current_temperatures))
            if len(current_temperatures) > outside_sensor:
                current_temperature = current_temperatures[outside_sensor]
                set_on = thermostat_group.threshold_temperature > current_temperature
            else:
                set_on = True

        # Calculate and set the global mode
        mode = 0
        mode |= (1 if set_on is True else 0) << 7
        mode |= 1 << 6  # multi-tenant mode
        mode |= (1 if cooling_mode else 0) << 4
        if automatic is not None:
            mode |= (1 if automatic else 0) << 3
        self._master_controller.set_thermostat_mode(mode)

        # Caclulate and set the cooling/heating mode
        cooling_heating_mode = 0
        if cooling_mode is True:
            cooling_heating_mode = 1 if cooling_on is False else 2
        self._master_controller.set_thermostat_cooling_heating(
            cooling_heating_mode)

        # Then, set manual/auto
        if automatic is not None:
            action_number = 1 if automatic is True else 0
            self._master_controller.set_thermostat_automatic(action_number)

        # If manual, set the setpoint if appropriate
        if automatic is False and setpoint is not None and 3 <= setpoint <= 5:
            self._master_controller.set_thermostat_all_setpoints(setpoint)

        self.invalidate_cache(THERMOSTATS)
        self.increase_interval(THERMOSTATS, interval=2, window=10)

    def set_per_thermostat_mode(self, thermostat_id, automatic, setpoint):
        # type: (int, bool, int) -> None
        """ Set the setpoint/mode for a certain thermostat. """
        if thermostat_id < 0 or thermostat_id > 31:
            raise ValueError('Thermostat_id not in [0, 31]: %d' %
                             thermostat_id)

        if setpoint < 0 or setpoint > 5:
            raise ValueError('Setpoint not in [0, 5]: %d' % setpoint)

        if automatic:
            self._master_controller.set_thermostat_tenant_auto(thermostat_id)
        else:
            self._master_controller.set_thermostat_tenant_manual(thermostat_id)
            self._master_controller.set_thermostat_setpoint(
                thermostat_id, setpoint)

        self.invalidate_cache(THERMOSTATS)
        self.increase_interval(THERMOSTATS, interval=2, window=10)

    def set_airco_status(self, thermostat_id, airco_on):
        # type: (int, bool) -> None
        """ Set the mode of the airco attached to a given thermostat. """
        if thermostat_id < 0 or thermostat_id > 31:
            raise ValueError(
                'Thermostat id not in [0, 31]: {0}'.format(thermostat_id))
        self._master_controller.set_airco_status(thermostat_id, airco_on)

    def load_airco_status(self):
        # type: () -> ThermostatAircoStatusDTO
        """ Get the mode of the airco attached to a all thermostats. """
        return self._master_controller.load_airco_status()

    @staticmethod
    def __check_thermostat(thermostat):
        """ :raises ValueError if thermostat not in range [0, 32]. """
        if thermostat not in range(0, 32):
            raise ValueError('Thermostat not in [0,32]: %d' % thermostat)

    def set_current_setpoint(self,
                             thermostat_number,
                             temperature=None,
                             heating_temperature=None,
                             cooling_temperature=None):
        # type: (int, Optional[float], Optional[float], Optional[float]) -> None
        """ Set the current setpoint of a thermostat. """
        if temperature is None:
            temperature = heating_temperature
        if temperature is None:
            temperature = cooling_temperature

        self.__check_thermostat(thermostat_number)
        self._master_controller.write_thermostat_setpoint(
            thermostat_number, temperature)

        self.invalidate_cache(THERMOSTATS)
        self.increase_interval(THERMOSTATS, interval=2, window=10)

    def _monitor(self):
        # type: () -> None
        """ Monitors certain system states to detect changes without events """
        try:
            # Refresh if required
            if self._thermostats_last_updated + self._thermostats_interval < time.time(
            ):
                self._refresh_thermostats()
            # Restore interval if required
            if self._thermostats_restore < time.time():
                self._thermostats_interval = self._thermostats_original_interval
        except CommunicationTimedOutException:
            logger.error(
                'Got communication timeout during thermostat monitoring, waiting 10 seconds.'
            )
            raise DaemonThreadWait

    def _refresh_thermostats(self):
        # type: () -> None
        """
        Get basic information about all thermostats and pushes it in to the Thermostat Status tracker
        """
        def get_automatic_setpoint(_mode):
            _automatic = bool(_mode & 1 << 3)
            return _automatic, 0 if _automatic else (_mode & 0b00000111)

        try:
            thermostat_info = self._master_controller.get_thermostats()
            thermostat_mode = self._master_controller.get_thermostat_modes()
            aircos = self._master_controller.load_airco_status()
        except CommunicationFailure:
            return

        status = {
            state.id: state
            for state in self._output_controller.get_output_statuses()
        }  # type: Dict[int,OutputStateDTO]

        mode = thermostat_info['mode']
        thermostats_on = bool(mode & 1 << 7)
        cooling = bool(mode & 1 << 4)
        automatic, setpoint = get_automatic_setpoint(thermostat_mode['mode0'])

        try:
            if cooling:
                self._thermostats_config = {
                    thermostat.id: thermostat
                    for thermostat in self.load_cooling_thermostats()
                }
            else:
                self._thermostats_config = {
                    thermostat.id: thermostat
                    for thermostat in self.load_heating_thermostats()
                }
        except CommunicationFailure:
            return

        thermostats = []
        for thermostat_id in range(32):
            thermostat_dto = self._thermostats_config[
                thermostat_id]  # type: ThermostatDTO
            if thermostat_dto.in_use:
                t_mode = thermostat_mode['mode{0}'.format(thermostat_id)]
                t_automatic, t_setpoint = get_automatic_setpoint(t_mode)
                thermostat = {
                    'id':
                    thermostat_id,
                    'act':
                    thermostat_info['tmp{0}'.format(
                        thermostat_id)].get_temperature(),
                    'csetp':
                    thermostat_info['setp{0}'.format(
                        thermostat_id)].get_temperature(),
                    'outside':
                    thermostat_info['outside'].get_temperature(),
                    'mode':
                    t_mode,
                    'automatic':
                    t_automatic,
                    'setpoint':
                    t_setpoint,
                    'name':
                    thermostat_dto.name,
                    'sensor_nr':
                    thermostat_dto.sensor,
                    'airco':
                    1 if aircos.status[thermostat_id] else 0
                }
                for output in [0, 1]:
                    output_id = getattr(thermostat_dto,
                                        'output{0}'.format(output))
                    output_state_dto = status.get(output_id)
                    if output_id is not None and output_state_dto is not None and output_state_dto.status:
                        thermostat['output{0}'.format(
                            output)] = output_state_dto.dimmer
                    else:
                        thermostat['output{0}'.format(output)] = 0
                thermostats.append(thermostat)

        self._thermostat_status.full_update({
            'thermostats_on': thermostats_on,
            'automatic': automatic,
            'setpoint': setpoint,
            'cooling': cooling,
            'status': thermostats
        })
        self._thermostats_last_updated = time.time()

    def get_thermostat_status(self):
        # type: () -> ThermostatGroupStatusDTO
        """ Returns thermostat information """
        self._refresh_thermostats()  # Always return the latest information
        master_status = self._thermostat_status.get_thermostats()
        return ThermostatGroupStatusDTO(
            id=0,
            on=master_status['thermostats_on'],
            automatic=master_status['automatic'],
            setpoint=master_status['setpoint'],
            cooling=master_status['cooling'],
            statusses=[
                ThermostatStatusDTO(id=thermostat['id'],
                                    actual_temperature=thermostat['act'],
                                    setpoint_temperature=thermostat['csetp'],
                                    outside_temperature=thermostat['outside'],
                                    mode=thermostat['mode'],
                                    automatic=thermostat['automatic'],
                                    setpoint=thermostat['setpoint'],
                                    name=thermostat['name'],
                                    sensor_id=thermostat['sensor_nr'],
                                    airco=thermostat['airco'],
                                    output_0_level=thermostat['output0'],
                                    output_1_level=thermostat['output1'])
                for thermostat in master_status['status']
            ])
        return self._thermostat_status.get_thermostats()
Beispiel #26
0
class MasterHeartbeat(object):
    """
    Monitors the status of the master communication.
    """
    @Inject
    def __init__(self, master_communicator=INJECTED):
        # type: (MasterCommunicator) -> None
        self._master_communicator = master_communicator
        self._failures = -1  # Start "offline"
        self._backoff = 60
        self._last_restart = 0.0
        self._min_threshold = 2
        self._thread = DaemonThread(name='masterheartbeat',
                                    target=self._heartbeat,
                                    interval=30,
                                    delay=5)

    def start(self):
        # type: () -> None
        logger.info('Starting master heartbeat')
        self._thread.start()

    def stop(self):
        # type: () -> None
        self._thread.stop()

    def is_online(self):
        # type: () -> bool
        if self._failures == -1:
            self._thread.request_single_run()
            time.sleep(2)
        return self._failures == 0

    def set_offline(self):
        # type: () -> None
        self._failures += 1

    def get_communicator_health(self):
        # type: () -> HEALTH
        if self._failures > self._min_threshold:
            stats = self._check_stats()
            if stats is None:
                return CommunicationStatus.UNSTABLE
            elif stats:
                return CommunicationStatus.SUCCESS
            else:
                return CommunicationStatus.FAILURE
        else:
            return CommunicationStatus.SUCCESS

    def _heartbeat(self):
        # type: () -> None
        if self._failures > self._min_threshold and self._last_restart < time.time(
        ) - self._backoff:
            logger.error('Master heartbeat failure, restarting communication')
            try:
                self._master_communicator.stop()
            finally:
                self._master_communicator.start()
            self._last_restart = time.time()
            self._backoff = self._backoff * 2
        try:
            self._master_communicator.do_command(master_api.status())
            if self._failures > 0:
                logger.info('Master heartbeat recovered after %s failures',
                            self._failures)
            self._failures = 0
        except CommunicationTimedOutException:
            self._failures += 1
            logger.error('Master heartbeat %s failures', self._failures)
            raise DaemonThreadWait()
        except Exception:
            logger.error('Master heartbeat unhandled exception')
            raise

    def _check_stats(self):
        # type: () -> Optional[bool]
        """
        """
        stats = self._master_communicator.get_communication_statistics()
        calls_timedout = [call for call in stats['calls_timedout']]
        calls_succeeded = [call for call in stats['calls_succeeded']]
        all_calls = sorted(calls_timedout + calls_succeeded)

        if len(calls_timedout) == 0:
            # If there are no timeouts at all
            return True
        elif len(all_calls) <= 10:
            # Not enough calls made to have a decent view on what's going on
            logger.warning(
                'Observed master communication failures, but not enough calls')
            return None
        elif not any(t in calls_timedout for t in all_calls[-10:]):
            logger.warning(
                'Observed master communication failures, but recent calls recovered'
            )
            # The last X calls are successfull
            return None

        calls_last_x_minutes = [t for t in all_calls if t > time.time() - 180]
        if len(calls_last_x_minutes) <= 5:
            logger.warning(
                'Observed master communication failures, but not recent enough'
            )
            # Not enough recent calls
            return None

        ratio = len([t for t in calls_last_x_minutes if t in calls_timedout
                     ]) / float(len(calls_last_x_minutes))
        if ratio < 0.25:
            # Less than 25% of the calls fail, let's assume everything is just "fine"
            logger.warning(
                'Observed master communication failures, but there\'s only a failure ratio of {:.2f}%'
                .format(ratio * 100))
            return None
        else:
            return False
Beispiel #27
0
class BaseController(object):

    SYNC_STRUCTURES = None  # type: Optional[List[SyncStructure]]

    @Inject
    def __init__(self,
                 master_controller,
                 maintenance_controller=INJECTED,
                 pubsub=INJECTED,
                 sync_interval=900):
        # type: (MasterController, MaintenanceController, PubSub, float) -> None
        self._master_controller = master_controller
        self._maintenance_controller = maintenance_controller
        self._pubsub = pubsub
        self._sync_orm_thread = None  # type: Optional[DaemonThread]
        self._sync_orm_interval = sync_interval
        self._sync_dirty = True  # Always sync after restart.
        self._sync_running = False

        self._pubsub.subscribe_master_events(PubSub.MasterTopics.EEPROM,
                                             self._handle_master_event)
        self._pubsub.subscribe_master_events(PubSub.MasterTopics.MODULE,
                                             self._handle_master_event)

    def _handle_master_event(self, master_event):
        # type: (MasterEvent) -> None
        if master_event.type in [
                MasterEvent.Types.EEPROM_CHANGE,
                MasterEvent.Types.MODULE_DISCOVERY
        ]:
            self._sync_dirty = True
            self.request_sync_orm()

    def start(self):
        self._sync_orm_thread = DaemonThread(name='{0}sync'.format(
            self.__class__.__name__.lower()[:10]),
                                             target=self._sync_orm,
                                             interval=self._sync_orm_interval,
                                             delay=300)
        self._sync_orm_thread.start()

    def stop(self):
        if self._sync_orm_thread is not None:
            self._sync_orm_thread.stop()

    def request_sync_orm(self):
        if self._sync_orm_thread is not None:
            self._sync_orm_thread.request_single_run()

    def run_sync_orm(self):
        self._sync_orm()

    def _sync_orm(self):
        # type: () -> bool
        if self.SYNC_STRUCTURES is None:
            return False

        if self._sync_running:
            for structure in self.SYNC_STRUCTURES:
                orm_model = structure.orm_model
                logger.info('ORM sync ({0}): Already running'.format(
                    orm_model.__name__))
            return False
        self._sync_running = True

        try:
            for structure in self.SYNC_STRUCTURES:
                orm_model = structure.orm_model
                try:
                    name = structure.name
                    skip = structure.skip

                    start = time.time()
                    logger.info('ORM sync ({0})'.format(orm_model.__name__))

                    ids = []
                    for dto in getattr(self._master_controller,
                                       'load_{0}s'.format(name))():
                        if skip is not None and skip(dto):
                            continue
                        id_ = dto.id
                        ids.append(id_)
                        if not orm_model.select().where(
                                orm_model.number == id_).exists():
                            orm_model.create(number=id_)
                    orm_model.delete().where(
                        orm_model.number.not_in(ids)).execute()

                    duration = time.time() - start
                    logger.info(
                        'ORM sync ({0}): completed after {1:.1f}s'.format(
                            orm_model.__name__, duration))
                except CommunicationTimedOutException as ex:
                    logger.error('ORM sync ({0}): Failed: {1}'.format(
                        orm_model.__name__, ex))
                except Exception:
                    logger.exception('ORM sync ({0}): Failed'.format(
                        orm_model.__name__))

                if self._sync_dirty:
                    type_name = orm_model.__name__.lower()
                    gateway_event = GatewayEvent(
                        GatewayEvent.Types.CONFIG_CHANGE, {'type': type_name})
                    self._pubsub.publish_gateway_event(
                        PubSub.GatewayTopics.CONFIG, gateway_event)
            self._sync_dirty = False
        finally:
            self._sync_running = False
        return True
Beispiel #28
0
class ThermostatControllerGateway(ThermostatController):

    # TODO: At this moment, a pump group strictly speaking is not related to any thermostat,
    #  nor to cooling/heating. Yet in the `classic` implementation there is. This means that
    #  changing a pump group could influence another pump group, since their `number` is shared.

    THERMOSTAT_PID_UPDATE_INTERVAL = 60
    PUMP_UPDATE_INTERVAL = 30
    SYNC_CONFIG_INTERVAL = 900

    @Inject
    def __init__(self,
                 gateway_api=INJECTED,
                 output_controller=INJECTED,
                 pubsub=INJECTED):
        # type: (GatewayApi, OutputController, PubSub) -> None
        super(ThermostatControllerGateway, self).__init__(output_controller)
        self._gateway_api = gateway_api
        self._pubsub = pubsub
        self._running = False
        self._pid_loop_thread = None  # type: Optional[DaemonThread]
        self._update_pumps_thread = None  # type: Optional[DaemonThread]
        self._periodic_sync_thread = None  # type: Optional[DaemonThread]
        self.thermostat_pids = {}  # type: Dict[int, ThermostatPid]
        self._pump_valve_controller = PumpValveController()

        timezone = gateway_api.get_timezone()

        # we could also use an in-memory store, but this allows us to detect 'missed' transitions
        # e.g. in case when gateway was rebooting during a scheduled transition
        db_filename = constants.get_thermostats_scheduler_database_file()
        jobstores = {
            'default':
            SQLAlchemyJobStore(url='sqlite:///{})'.format(db_filename))
        }
        self._scheduler = BackgroundScheduler(jobstores=jobstores,
                                              timezone=timezone)

    def start(self):  # type: () -> None
        logger.info('Starting gateway thermostatcontroller...')
        if not self._running:
            self._running = True

            self.refresh_config_from_db()
            self._pid_loop_thread = DaemonThread(
                name='thermostatpid',
                target=self._pid_tick,
                interval=self.THERMOSTAT_PID_UPDATE_INTERVAL)
            self._pid_loop_thread.start()

            self._update_pumps_thread = DaemonThread(
                name='thermostatpumps',
                target=self._update_pumps,
                interval=self.PUMP_UPDATE_INTERVAL)
            self._update_pumps_thread.start()

            self._periodic_sync_thread = DaemonThread(
                name='thermostatsync',
                target=self._periodic_sync,
                interval=self.SYNC_CONFIG_INTERVAL)
            self._periodic_sync_thread.start()

            self._scheduler.start()
            logger.info('Starting gateway thermostatcontroller... Done')
        else:
            raise RuntimeError(
                'GatewayThermostatController already running. Please stop it first.'
            )

    def stop(self):  # type: () -> None
        if not self._running:
            logger.warning(
                'Stopping an already stopped GatewayThermostatController.')
        self._running = False
        self._scheduler.shutdown(wait=False)
        if self._pid_loop_thread is not None:
            self._pid_loop_thread.stop()
        if self._update_pumps_thread is not None:
            self._update_pumps_thread.stop()
        if self._periodic_sync_thread is not None:
            self._periodic_sync_thread.stop()

    def _pid_tick(self):  # type: () -> None
        for thermostat_number, thermostat_pid in self.thermostat_pids.items():
            try:
                thermostat_pid.tick()
            except Exception:
                logger.exception(
                    'There was a problem with calculating thermostat PID {}'.
                    format(thermostat_pid))

    def refresh_config_from_db(self):  # type: () -> None
        self.refresh_thermostats_from_db()
        self._pump_valve_controller.refresh_from_db()

    def refresh_thermostats_from_db(self):  # type: () -> None
        for thermostat in Thermostat.select():
            thermostat_pid = self.thermostat_pids.get(thermostat.number)
            if thermostat_pid is None:
                thermostat_pid = ThermostatPid(thermostat,
                                               self._pump_valve_controller)
                thermostat_pid.subscribe_state_changes(
                    self._thermostat_changed)
                self.thermostat_pids[thermostat.number] = thermostat_pid
            thermostat_pid.update_thermostat(thermostat)
            thermostat_pid.tick()
            # TODO: Delete stale/removed thermostats

    def _update_pumps(self):  # type: () -> None
        try:
            self._pump_valve_controller.steer()
        except Exception:
            logger.exception('Could not update pumps.')

    def _periodic_sync(self):  # type: () -> None
        try:
            self.refresh_config_from_db()
        except Exception:
            logger.exception('Could not get thermostat config.')

    def _sync_scheduler(self):  # type: () -> None
        self._scheduler.remove_all_jobs(
        )  # TODO: This might have to be more efficient, as this generates I/O
        for thermostat_number, thermostat_pid in self.thermostat_pids.items():
            start_date = datetime.datetime.utcfromtimestamp(
                float(thermostat_pid.thermostat.start))
            day_schedules = thermostat_pid.thermostat.day_schedules
            schedule_length = len(day_schedules)
            for schedule in day_schedules:
                for seconds_of_day, new_setpoint in schedule.schedule_data.items(
                ):
                    m, s = divmod(int(seconds_of_day), 60)
                    h, m = divmod(m, 60)
                    if schedule.mode == 'heating':
                        args = [thermostat_number, new_setpoint, None]
                    else:
                        args = [thermostat_number, None, new_setpoint]
                    if schedule_length % 7 == 0:
                        self._scheduler.add_job(ThermostatControllerGateway.
                                                set_setpoint_from_scheduler,
                                                'cron',
                                                start_date=start_date,
                                                day_of_week=schedule.index,
                                                hour=h,
                                                minute=m,
                                                second=s,
                                                args=args,
                                                name='T{}: {} ({}) {}'.format(
                                                    thermostat_number,
                                                    new_setpoint,
                                                    schedule.mode,
                                                    seconds_of_day))
                    else:
                        # calendarinterval trigger is only supported in a future release of apscheduler
                        # https://apscheduler.readthedocs.io/en/latest/modules/triggers/calendarinterval.html#module-apscheduler.triggers.calendarinterval
                        day_start_date = start_date + datetime.timedelta(
                            days=schedule.index)
                        self._scheduler.add_job(ThermostatControllerGateway.
                                                set_setpoint_from_scheduler,
                                                'calendarinterval',
                                                start_date=day_start_date,
                                                days=schedule_length,
                                                hour=h,
                                                minute=m,
                                                second=s,
                                                args=args,
                                                name='T{}: {} ({}) {}'.format(
                                                    thermostat_number,
                                                    new_setpoint,
                                                    schedule.mode,
                                                    seconds_of_day))

    def set_current_setpoint(self,
                             thermostat_number,
                             temperature=None,
                             heating_temperature=None,
                             cooling_temperature=None):
        # type: (int, Optional[float], Optional[float], Optional[float]) -> None
        if temperature is None and heating_temperature is None and cooling_temperature is None:
            return

        thermostat = Thermostat.get(number=thermostat_number)
        # When setting a setpoint manually, switch to manual preset except for when we are in scheduled mode
        # scheduled mode will override the setpoint when the next edge in the schedule is triggered
        active_preset = thermostat.active_preset
        if active_preset.type not in [
                Preset.Types.SCHEDULE, Preset.Types.MANUAL
        ]:
            active_preset = thermostat.get_preset(Preset.Types.MANUAL)
            thermostat.active_preset = active_preset

        if heating_temperature is None:
            heating_temperature = temperature
        if heating_temperature is not None:
            active_preset.heating_setpoint = float(heating_temperature)

        if cooling_temperature is None:
            cooling_temperature = temperature
        if cooling_temperature is not None:
            active_preset.cooling_setpoint = float(cooling_temperature)
        active_preset.save()

        thermostat_pid = self.thermostat_pids[thermostat_number]
        thermostat_pid.update_thermostat(thermostat)
        thermostat_pid.tick()

    def get_current_preset(self, thermostat_number):  # type: (int) -> Preset
        thermostat = Thermostat.get(number=thermostat_number)
        return thermostat.active_preset

    def set_current_preset(self, thermostat_number,
                           preset_type):  # type: (int, str) -> None
        thermostat = Thermostat.get(
            number=thermostat_number)  # type: Thermostat
        preset = thermostat.get_preset(preset_type)
        thermostat.active_preset = preset
        thermostat.save()

        thermostat_pid = self.thermostat_pids[thermostat_number]
        thermostat_pid.update_thermostat(thermostat)
        thermostat_pid.tick()

    @classmethod
    @Inject
    def set_setpoint_from_scheduler(cls,
                                    thermostat_number,
                                    heating_temperature=None,
                                    cooling_temperature=None,
                                    thermostat_controller=INJECTED):
        # type: (int, Optional[float], Optional[float], ThermostatControllerGateway) -> None
        logger.info(
            'Setting setpoint from scheduler for thermostat {}: H{} C{}'.
            format(thermostat_number, heating_temperature,
                   cooling_temperature))
        thermostat = Thermostat.get(number=thermostat_number)
        active_preset = thermostat.active_preset

        # Only update when not in preset mode like away, party, ...
        if active_preset.type == Preset.Types.SCHEDULE:
            thermostat_controller.set_current_setpoint(
                thermostat_number=thermostat_number,
                heating_temperature=heating_temperature,
                cooling_temperature=cooling_temperature)
        else:
            logger.info(
                'Thermostat is currently in preset mode, skipping update setpoint from scheduler.'
            )

    def get_thermostat_status(self):  # type: () -> ThermostatGroupStatusDTO
        def get_output_level(output_number):
            if output_number is None:
                return 0  # we are returning 0 if outputs are not configured
            try:
                output = self._output_controller.get_output_status(
                    output_number)
            except ValueError:
                logger.info(
                    'Output {0} state not yet available'.format(output_number))
                return 0  # Output state is not yet cached (during startup)
            if output.dimmer is None:
                status_ = output.status
                output_level = 0 if status_ is None else int(status_) * 100
            else:
                output_level = output.dimmer
            return output_level

        global_thermostat = ThermostatGroup.get(number=0)
        if global_thermostat is None:
            raise RuntimeError('Global thermostat not found!')
        group_status = ThermostatGroupStatusDTO(
            id=0,
            on=global_thermostat.on,
            automatic=True,  # Default, will be updated below
            setpoint=0,  # Default, will be updated below
            cooling=global_thermostat.mode == ThermostatMode.COOLING)

        for thermostat in global_thermostat.thermostats:
            valves = thermostat.cooling_valves if global_thermostat.mode == 'cooling' else thermostat.heating_valves
            db_outputs = [valve.output.number for valve in valves]

            number_of_outputs = len(db_outputs)
            if number_of_outputs > 2:
                logger.warning(
                    'Only 2 outputs are supported in the old format. Total: {0} outputs.'
                    .format(number_of_outputs))

            output0 = db_outputs[0] if number_of_outputs > 0 else None
            output1 = db_outputs[1] if number_of_outputs > 1 else None

            active_preset = thermostat.active_preset
            if global_thermostat.mode == ThermostatMode.COOLING:
                setpoint_temperature = active_preset.cooling_setpoint
            else:
                setpoint_temperature = active_preset.heating_setpoint

            group_status.statusses.append(
                ThermostatStatusDTO(
                    id=thermostat.number,
                    actual_temperature=self._gateway_api.
                    get_sensor_temperature_status(thermostat.sensor),
                    setpoint_temperature=setpoint_temperature,
                    outside_temperature=self._gateway_api.
                    get_sensor_temperature_status(global_thermostat.sensor),
                    mode=0,  # TODO: Need to be fixed
                    automatic=active_preset.type == Preset.Types.SCHEDULE,
                    setpoint=Preset.TYPE_TO_SETPOINT.get(
                        active_preset.type, 0),
                    name=thermostat.name,
                    sensor_id=thermostat.sensor.number,
                    airco=0,  # TODO: Check if still used
                    output_0_level=get_output_level(output0),
                    output_1_level=get_output_level(output1)))

        # Update global references
        group_status.automatic = all(status.automatic
                                     for status in group_status.statusses)
        used_setpoints = set(status.setpoint
                             for status in group_status.statusses)
        group_status.setpoint = next(iter(used_setpoints)) if len(
            used_setpoints) == 1 else 0  # 0 is a fallback

        return group_status

    def set_thermostat_mode(self,
                            thermostat_on,
                            cooling_mode=False,
                            cooling_on=False,
                            automatic=None,
                            setpoint=None):
        # type: (bool, bool, bool, Optional[bool], Optional[int]) -> None
        mode = ThermostatMode.COOLING if cooling_mode else ThermostatMode.HEATING  # type: Literal['cooling', 'heating']
        global_thermosat = ThermostatGroup.get(number=0)
        global_thermosat.on = thermostat_on
        global_thermosat.mode = mode
        global_thermosat.save()

        for thermostat_number, thermostat_pid in self.thermostat_pids.items():
            thermostat = Thermostat.get(number=thermostat_number)
            if thermostat is not None:
                if automatic is False and setpoint is not None and 3 <= setpoint <= 5:
                    preset = thermostat.get_preset(
                        preset_type=Preset.SETPOINT_TO_TYPE.get(
                            setpoint, Preset.Types.SCHEDULE))
                    thermostat.active_preset = preset
                else:
                    thermostat.active_preset = thermostat.get_preset(
                        preset_type=Preset.Types.SCHEDULE)
                thermostat_pid.update_thermostat(thermostat)
                thermostat_pid.tick()

    def load_heating_thermostat(self,
                                thermostat_id):  # type: (int) -> ThermostatDTO
        thermostat = Thermostat.get(number=thermostat_id)
        return ThermostatMapper.orm_to_dto(thermostat, 'heating')

    def load_heating_thermostats(self):  # type: () -> List[ThermostatDTO]
        return [
            ThermostatMapper.orm_to_dto(thermostat, 'heating')
            for thermostat in Thermostat.select()
        ]

    def save_heating_thermostats(
            self, thermostats
    ):  # type: (List[Tuple[ThermostatDTO, List[str]]]) -> None
        for thermostat_dto, fields in thermostats:
            thermostat = ThermostatMapper.dto_to_orm(thermostat_dto, fields,
                                                     'heating')
            self.refresh_set_configuration(thermostat)

    def load_cooling_thermostat(self,
                                thermostat_id):  # type: (int) -> ThermostatDTO
        thermostat = Thermostat.get(number=thermostat_id)
        return ThermostatMapper.orm_to_dto(thermostat, 'cooling')

    def load_cooling_thermostats(self):  # type: () -> List[ThermostatDTO]
        return [
            ThermostatMapper.orm_to_dto(thermostat, 'cooling')
            for thermostat in Thermostat.select()
        ]

    def save_cooling_thermostats(
            self, thermostats
    ):  # type: (List[Tuple[ThermostatDTO, List[str]]]) -> None
        for thermostat_dto, fields in thermostats:
            thermostat = ThermostatMapper.dto_to_orm(thermostat_dto, fields,
                                                     'cooling')
            self.refresh_set_configuration(thermostat)

    def set_per_thermostat_mode(self, thermostat_number, automatic, setpoint):
        # type: (int, bool, float) -> None
        thermostat_pid = self.thermostat_pids.get(thermostat_number)
        if thermostat_pid is not None:
            thermostat = thermostat_pid.thermostat
            thermostat.automatic = automatic
            thermostat.save()
            preset = thermostat.active_preset
            if thermostat.thermostat_group.mode == ThermostatGroup.Modes.HEATING:
                preset.heating_setpoint = setpoint
            else:
                preset.cooling_setpoint = setpoint
            preset.save()
            thermostat_pid.update_thermostat(thermostat)
            thermostat_pid.tick()

    def load_thermostat_group(self):
        # type: () -> ThermostatGroupDTO
        thermostat_group = ThermostatGroup.get(number=0)
        pump_delay = None
        for thermostat in thermostat_group.thermostats:
            for valve in thermostat.valves:
                pump_delay = valve.delay
                break
        sensor_number = None if thermostat_group.sensor is None else thermostat_group.sensor.number
        thermostat_group_dto = ThermostatGroupDTO(
            id=0,
            outside_sensor_id=sensor_number,
            threshold_temperature=thermostat_group.threshold_temperature,
            pump_delay=pump_delay)
        for link in OutputToThermostatGroup.select(OutputToThermostatGroup, Output) \
                                           .join_from(OutputToThermostatGroup, Output) \
                                           .where(OutputToThermostatGroup.thermostat_group == thermostat_group):
            if link.index > 3 or link.output is None:
                continue
            field = 'switch_to_{0}_{1}'.format(link.mode, link.index)
            setattr(thermostat_group_dto, field,
                    (link.output.number, link.value))
        return thermostat_group_dto

    def save_thermostat_group(self, thermostat_group):
        # type: (Tuple[ThermostatGroupDTO, List[str]]) -> None
        thermostat_group_dto, fields = thermostat_group

        # Update thermostat group configuration
        orm_object = ThermostatGroup.get(number=0)  # type: ThermostatGroup
        if 'outside_sensor_id' in fields:
            orm_object.sensor = Sensor.get(
                number=thermostat_group_dto.outside_sensor_id)
        if 'threshold_temperature' in fields:
            orm_object.threshold_temperature = thermostat_group_dto.threshold_temperature  # type: ignore
        orm_object.save()

        # Link configuration outputs to global thermostat config
        for mode in ['cooling', 'heating']:
            links = {
                link.index: link
                for link in OutputToThermostatGroup.select().where(
                    (OutputToThermostatGroup.thermostat_group == orm_object)
                    & (OutputToThermostatGroup.mode == mode))
            }
            for i in range(4):
                field = 'switch_to_{0}_{1}'.format(mode, i)
                if field not in fields:
                    continue

                link = links.get(i)
                data = getattr(thermostat_group_dto, field)
                if data is None:
                    if link is not None:
                        link.delete_instance()
                else:
                    output_number, value = data
                    output = Output.get(number=output_number)
                    if link is None:
                        OutputToThermostatGroup.create(
                            output=output,
                            thermostat_group=orm_object,
                            mode=mode,
                            index=i,
                            value=value)
                    else:
                        link.output = output
                        link.value = value
                        link.save()

        if 'pump_delay' in fields:
            # Set valve delay for all valves in this group
            for thermostat in orm_object.thermostats:
                for valve in thermostat.valves:
                    valve.delay = thermostat_group_dto.pump_delay  # type: ignore
                    valve.save()

    def load_heating_pump_group(self,
                                pump_group_id):  # type: (int) -> PumpGroupDTO
        pump = Pump.get(number=pump_group_id)
        return PumpGroupDTO(id=pump_group_id,
                            pump_output_id=pump.output.number,
                            valve_output_ids=[
                                valve.output.number
                                for valve in pump.heating_valves
                            ],
                            room_id=None)

    def load_heating_pump_groups(self):  # type: () -> List[PumpGroupDTO]
        pump_groups = []
        for pump in Pump.select():
            pump_groups.append(
                PumpGroupDTO(id=pump.id,
                             pump_output_id=pump.output.number,
                             valve_output_ids=[
                                 valve.output.number
                                 for valve in pump.heating_valves
                             ],
                             room_id=None))
        return pump_groups

    def save_heating_pump_groups(
            self, pump_groups
    ):  # type: (List[Tuple[PumpGroupDTO, List[str]]]) -> None
        return ThermostatControllerGateway._save_pump_groups(
            ThermostatGroup.Modes.HEATING, pump_groups)

    def load_cooling_pump_group(self,
                                pump_group_id):  # type: (int) -> PumpGroupDTO
        pump = Pump.get(number=pump_group_id)
        return PumpGroupDTO(id=pump_group_id,
                            pump_output_id=pump.output.number,
                            valve_output_ids=[
                                valve.output.number
                                for valve in pump.cooling_valves
                            ],
                            room_id=None)

    def load_cooling_pump_groups(self):  # type: () -> List[PumpGroupDTO]
        pump_groups = []
        for pump in Pump.select():
            pump_groups.append(
                PumpGroupDTO(id=pump.id,
                             pump_output_id=pump.output.number,
                             valve_output_ids=[
                                 valve.output.number
                                 for valve in pump.cooling_valves
                             ],
                             room_id=None))
        return pump_groups

    def save_cooling_pump_groups(
            self, pump_groups
    ):  # type: (List[Tuple[PumpGroupDTO, List[str]]]) -> None
        return ThermostatControllerGateway._save_pump_groups(
            ThermostatGroup.Modes.COOLING, pump_groups)

    @staticmethod
    def _save_pump_groups(
        mode, pump_groups
    ):  # type: (str, List[Tuple[PumpGroupDTO, List[str]]]) -> None
        for pump_group_dto, fields in pump_groups:
            if 'pump_output_id' in fields and 'valve_output_ids' in fields:
                valve_output_ids = pump_group_dto.valve_output_ids
                pump = Pump.get(id=pump_group_dto.id)  # type: Pump
                pump.output = Output.get(number=pump_group_dto.pump_output_id)

                links = {
                    pump_to_valve.valve.output.number: pump_to_valve
                    for pump_to_valve in PumpToValve.select(
                        PumpToValve, Pump, Valve, Output).join_from(
                            PumpToValve, Valve).join_from(
                                PumpToValve, Pump).join_from(Valve, Output).
                    join_from(Valve, ValveToThermostat).where((
                        ValveToThermostat.mode == mode) & (Pump.id == pump.id))
                }
                for output_id in list(links.keys()):
                    if output_id not in valve_output_ids:
                        pump_to_valve = links.pop(
                            output_id)  # type: PumpToValve
                        pump_to_valve.delete_instance()
                    else:
                        valve_output_ids.remove(output_id)
                for output_id in valve_output_ids:
                    output = Output.get(number=output_id)
                    valve = Valve.get_or_none(output=output)
                    if valve is None:
                        valve = Valve(name=output.name, output=output)
                        valve.save()
                    PumpToValve.create(pump=pump, valve=valve)

    def load_global_rtd10(self):  # type: () -> GlobalRTD10DTO
        raise UnsupportedException()

    def refresh_set_configuration(self,
                                  thermostat):  # type: (Thermostat) -> None
        thermostat_pid = self.thermostat_pids.get(thermostat.number)
        if thermostat_pid is not None:
            thermostat_pid.update_thermostat(thermostat)
        else:
            thermostat_pid = ThermostatPid(thermostat,
                                           self._pump_valve_controller)
            self.thermostat_pids[thermostat.number] = thermostat_pid
        self._sync_scheduler()
        thermostat_pid.tick()

    def _thermostat_changed(self, thermostat_number, active_preset,
                            current_setpoint, actual_temperature, percentages,
                            room):
        # type: (int, str, float, Optional[float], List[float], int) -> None
        location = {'room_id': room}
        gateway_event = GatewayEvent(
            GatewayEvent.Types.THERMOSTAT_CHANGE, {
                'id': thermostat_number,
                'status': {
                    'preset': active_preset,
                    'current_setpoint': current_setpoint,
                    'actual_temperature': actual_temperature,
                    'output_0':
                    percentages[0] if len(percentages) >= 1 else None,
                    'output_1':
                    percentages[1] if len(percentages) >= 2 else None
                },
                'location': location
            })
        self._pubsub.publish_gateway_event(PubSub.GatewayTopics.STATE,
                                           gateway_event)

    def _thermostat_group_changed(self, thermostat_group):
        # type: (ThermostatGroup) -> None
        gateway_event = GatewayEvent(
            GatewayEvent.Types.THERMOSTAT_GROUP_CHANGE, {
                'id': 0,
                'status': {
                    'state':
                    'ON' if thermostat_group.on else 'OFF',
                    'mode':
                    'COOLING'
                    if thermostat_group.mode == 'cooling' else 'HEATING'
                },
                'location': {}
            })
        self._pubsub.publish_gateway_event(PubSub.GatewayTopics.STATE,
                                           gateway_event)

    # Obsolete unsupported calls

    def save_global_rtd10(
            self, rtd10):  # type: (Tuple[GlobalRTD10DTO, List[str]]) -> None
        raise UnsupportedException()

    def load_heating_rtd10(self, rtd10_id):  # type: (int) -> RTD10DTO
        raise UnsupportedException()

    def load_heating_rtd10s(self):  # type: () -> List[RTD10DTO]
        raise UnsupportedException()

    def save_heating_rtd10s(
            self, rtd10s):  # type: (List[Tuple[RTD10DTO, List[str]]]) -> None
        raise UnsupportedException()

    def load_cooling_rtd10(self, rtd10_id):  # type: (int) -> RTD10DTO
        raise UnsupportedException()

    def load_cooling_rtd10s(self):  # type: () -> List[RTD10DTO]
        raise UnsupportedException()

    def save_cooling_rtd10s(
            self, rtd10s):  # type: (List[Tuple[RTD10DTO, List[str]]]) -> None
        raise UnsupportedException()

    def set_airco_status(self, thermostat_id, airco_on):
        raise UnsupportedException()

    def load_airco_status(self):
        raise UnsupportedException()
class FrontpanelController(object):

    INDICATE_TIMEOUT = 30
    AUTH_MODE_PRESS_DURATION = 5
    AUTH_MODE_TIMEOUT = 60
    BOARD_TYPE = Hardware.get_board_type()
    MAIN_INTERFACE = Hardware.get_main_interface()

    class Leds(object):
        EXPANSION = 'EXPANSION'
        STATUS_GREEN = 'STATUS_GREEN'
        STATUS_RED = 'STATUS_RED'
        CAN_STATUS_GREEN = 'CAN_STATUS_GREEN'
        CAN_STATUS_RED = 'CAN_STATUS_RED'
        CAN_COMMUNICATION = 'CAN_COMMUNICATION'
        P1 = 'P1'
        LAN_GREEN = 'LAN_GREEN'
        LAN_RED = 'LAN_RED'
        CLOUD = 'CLOUD'
        SETUP = 'SETUP'
        RELAYS_1_8 = 'RELAYS_1_8'
        RELAYS_9_16 = 'RELAYS_9_16'
        OUTPUTS_DIG_1_4 = 'OUTPUTS_DIG_1_4'
        OUTPUTS_DIG_5_7 = 'OUTPUTS_DIG_5_7'
        OUTPUTS_ANA_1_4 = 'OUTPUTS_ANA_1_4'
        INPUTS = 'INPUTS'
        POWER = 'POWER'
        ALIVE = 'ALIVE'
        VPN = 'VPN'
        COMMUNICATION_1 = 'COMMUNICATION_1'
        COMMUNICATION_2 = 'COMMUNICATION_2'

    class LedStates(object):
        OFF = 'OFF'
        BLINKING_25 = 'BLINKING_25'
        BLINKING_50 = 'BLINKING_50'
        BLINKING_75 = 'BLINKING_75'
        SOLID = 'SOLID'

    class Buttons(object):
        SELECT = 'SELECT'
        SETUP = 'SETUP'
        ACTION = 'ACTION'
        CAN_POWER = 'CAN_POWER'

    class ButtonStates(object):
        PRESSED = 'PRESSED'
        RELEASED = 'RELEASED'

    class SerialPorts(object):
        MASTER_API = 'MASTER_API'
        ENERGY = 'ENERGY'
        P1 = 'P1'

    @Inject
    def __init__(self,
                 master_controller=INJECTED,
                 power_communicator=INJECTED):
        # type: (MasterController, PowerCommunicator) -> None
        self._master_controller = master_controller
        self._power_communicator = power_communicator
        self._network_carrier = None
        self._network_activity = None
        self._network_activity_scan_counter = 0
        self._network_bytes = 0
        self._check_network_activity_thread = None
        self._authorized_mode = False
        self._authorized_mode_timeout = 0
        self._indicate = False
        self._indicate_timeout = 0
        self._master_stats = 0, 0
        self._power_stats = 0, 0

    @property
    def authorized_mode(self):
        # return Platform.get_platform() == Platform.Type.CORE_PLUS or self._authorized_mode  # Needed to validate Brain+ with no front panel attached
        return self._authorized_mode

    def event_receiver(self, event, payload):
        if event == OMBusEvents.CLOUD_REACHABLE:
            self._report_cloud_reachable(payload)
        elif event == OMBusEvents.VPN_OPEN:
            self._report_vpn_open(payload)
        elif event == OMBusEvents.CONNECTIVITY:
            self._report_connectivity(payload)

    def start(self):
        self._check_network_activity_thread = DaemonThread(
            name='frontpanel', target=self._do_frontpanel_tasks, interval=0.5)
        self._check_network_activity_thread.start()

    def stop(self):
        if self._check_network_activity_thread is not None:
            self._check_network_activity_thread.stop()

    def _report_carrier(self, carrier):
        # type: (bool) -> None
        raise NotImplementedError()

    def _report_connectivity(self, connectivity):
        # type: (bool) -> None
        raise NotImplementedError()

    def _report_network_activity(self, activity):
        # type: (bool) -> None
        raise NotImplementedError()

    def _report_serial_activity(self, serial_port, activity):
        # type: (str, Optional[bool]) -> None
        raise NotImplementedError()

    def _report_cloud_reachable(self, reachable):
        # type: (bool) -> None
        raise NotImplementedError()

    def _report_vpn_open(self, vpn_open):
        # type: (bool) -> None
        raise NotImplementedError()

    def indicate(self):
        self._indicate = True
        self._indicate_timeout = time.time(
        ) + FrontpanelController.INDICATE_TIMEOUT

    def _do_frontpanel_tasks(self):
        # Check network activity
        try:
            with open(
                    '/sys/class/net/{0}/carrier'.format(
                        FrontpanelController.MAIN_INTERFACE), 'r') as fh_up:
                line = fh_up.read()
            carrier = int(line) == 1
            carrier_changed = self._network_carrier != carrier
            if carrier_changed:
                self._network_carrier = carrier
                self._report_carrier(carrier)

            # Check network activity every second, or if the carrier changed
            if self._network_activity_scan_counter >= 9 or carrier_changed:
                self._network_activity_scan_counter = 0
                network_activity = False
                if self._network_carrier:  # There's no activity when there's no carrier
                    with open('/proc/net/dev', 'r') as fh_stat:
                        for line in fh_stat.readlines():
                            if FrontpanelController.MAIN_INTERFACE not in line:
                                continue
                            received, transmitted = 0, 0
                            parts = line.split()
                            if len(parts) == 17:
                                received = parts[1]
                                transmitted = parts[9]
                            elif len(parts) == 16:
                                (_, received) = tuple(parts[0].split(':'))
                                transmitted = parts[8]
                            new_bytes = received + transmitted
                            if self._network_bytes != new_bytes:
                                self._network_bytes = new_bytes
                                network_activity = True
                            else:
                                network_activity = False
                if self._network_activity != network_activity:
                    self._report_network_activity(network_activity)
                self._network_activity = network_activity
            self._network_activity_scan_counter += 1
        except Exception as exception:
            logger.error(
                'Error while checking network activity: {0}'.format(exception))

        # Monitor serial activity
        try:
            stats = self._master_controller.get_communication_statistics()
            new_master_stats = (stats['bytes_read'], stats['bytes_written'])
            activity = self._master_stats[0] != new_master_stats[
                0] or self._master_stats[1] != new_master_stats[1]
            self._report_serial_activity(
                FrontpanelController.SerialPorts.MASTER_API, activity)
            self._master_stats = new_master_stats

            if self._power_communicator is None:
                new_power_stats = 0, 0
            else:
                stats = self._power_communicator.get_communication_statistics()
                new_power_stats = (stats['bytes_read'], stats['bytes_written'])
            activity = self._power_stats[0] != new_power_stats[
                0] or self._power_stats[1] != new_power_stats[1]
            self._report_serial_activity(
                FrontpanelController.SerialPorts.ENERGY, activity)
            self._power_stats = new_power_stats

            activity = None  # type: Optional[bool]  # TODO: Load P1/RS232 activity
            self._report_serial_activity(FrontpanelController.SerialPorts.P1,
                                         activity)
        except Exception as exception:
            logger.error(
                'Error while checking serial activity: {0}'.format(exception))

        # Clear indicate timeout
        if time.time() > self._indicate_timeout:
            self._indicate = False
class OutputController(BaseController):

    SYNC_STRUCTURES = [SyncStructure(Output, 'output')]

    @Inject
    def __init__(self, master_controller=INJECTED):
        # type: (MasterController) -> None
        super(OutputController, self).__init__(master_controller)
        self._cache = OutputStateCache()
        self._sync_state_thread = None  # type: Optional[DaemonThread]

        self._pubsub.subscribe_master_events(PubSub.MasterTopics.OUTPUT,
                                             self._handle_master_event)

    def start(self):
        # type: () -> None
        super(OutputController, self).start()
        self._sync_state_thread = DaemonThread(name='outputsyncstate',
                                               target=self._sync_state,
                                               interval=600,
                                               delay=10)
        self._sync_state_thread.start()

    def stop(self):
        # type: () -> None
        super(OutputController, self).stop()
        if self._sync_state_thread:
            self._sync_state_thread.stop()
            self._sync_state_thread = None

    def _handle_master_event(self, master_event):
        # type: (MasterEvent) -> None
        super(OutputController, self)._handle_master_event(master_event)
        if master_event.type == MasterEvent.Types.MODULE_DISCOVERY:
            if self._sync_state_thread:
                self._sync_state_thread.request_single_run()
        if master_event.type == MasterEvent.Types.OUTPUT_STATUS:
            self._handle_output_status(master_event.data['state'])
        if master_event.type == MasterEvent.Types.EXECUTE_GATEWAY_API:
            if master_event.data['type'] == MasterEvent.APITypes.SET_LIGHTS:
                action = master_event.data['data'][
                    'action']  # type: Literal['ON', 'OFF', 'TOGGLE']
                floor_id = master_event.data['data'][
                    'floor_id']  # type: Optional[int]
                self.set_all_lights(action=action, floor_id=floor_id)

    def _handle_output_status(self, state_dto):
        # type: (OutputStateDTO) -> None
        changed, output_dto = self._cache.handle_change(state_dto)
        if changed and output_dto is not None:
            self._publish_output_change(output_dto)

    def _sync_state(self):
        try:
            self.load_outputs()
            for state_dto in self._master_controller.load_output_status():
                _, output_dto = self._cache.handle_change(state_dto)
                if output_dto is not None:
                    # Always send events on the background sync
                    self._publish_output_change(output_dto)
        except CommunicationTimedOutException:
            logger.error(
                'Got communication timeout during synchronization, waiting 10 seconds.'
            )
            raise DaemonThreadWait
        except CommunicationFailure:
            # This is an expected situation
            raise DaemonThreadWait

    def _publish_output_change(self, output_dto):
        # type: (OutputDTO) -> None
        event_status = {
            'on': output_dto.state.status,
            'locked': output_dto.state.locked
        }
        if output_dto.module_type in ['d', 'D']:
            event_status['value'] = output_dto.state.dimmer
        event_data = {
            'id': output_dto.id,
            'status': event_status,
            'location': {
                'room_id': Toolbox.denonify(output_dto.room, 255)
            }
        }
        gateway_event = GatewayEvent(GatewayEvent.Types.OUTPUT_CHANGE,
                                     event_data)
        self._pubsub.publish_gateway_event(PubSub.GatewayTopics.STATE,
                                           gateway_event)

    def get_output_status(self, output_id):
        # type: (int) -> OutputStateDTO
        # TODO also support plugins
        output_state_dto = self._cache.get_state().get(output_id)
        if output_state_dto is None:
            raise ValueError(
                'Output with id {} does not exist'.format(output_id))
        return output_state_dto

    def get_output_statuses(self):
        # type: () -> List[OutputStateDTO]
        # TODO also support plugins
        return list(self._cache.get_state().values())

    def load_output(self, output_id):  # type: (int) -> OutputDTO
        output = Output.select(Room) \
                       .join_from(Output, Room, join_type=JOIN.LEFT_OUTER) \
                       .where(Output.number == output_id) \
                       .get()  # type: Output  # TODO: Load dict
        output_dto = self._master_controller.load_output(output_id=output_id)
        output_dto.room = output.room.number if output.room is not None else None
        return output_dto

    def load_outputs(self):  # type: () -> List[OutputDTO]
        output_dtos = []
        for output in list(
                Output.select(Output, Room).join_from(
                    Output, Room,
                    join_type=JOIN.LEFT_OUTER)):  # TODO: Load dicts
            output_dto = self._master_controller.load_output(
                output_id=output.number)
            output_dto.room = output.room.number if output.room is not None else None
            output_dtos.append(output_dto)
        self._cache.update_outputs(output_dtos)
        return output_dtos

    def save_outputs(self, outputs):  # type: (List[OutputDTO]) -> None
        outputs_to_save = []
        for output_dto in outputs:
            output = Output.get_or_none(number=output_dto.id)  # type: Output
            if output is None:
                logger.info('Ignored saving non-existing Output {0}'.format(
                    output_dto.id))
            if 'room' in output_dto.loaded_fields:
                if output_dto.room is None:
                    output.room = None
                elif 0 <= output_dto.room <= 100:
                    output.room, _ = Room.get_or_create(number=output_dto.room)
                output.save()
            outputs_to_save.append(output_dto)
        self._master_controller.save_outputs(outputs_to_save)

    def set_all_lights(
        self,
        action,
        floor_id=None
    ):  # type: (Literal['ON', 'OFF', 'TOGGLE'], Optional[int]) -> None
        # TODO: Also include other sources (e.g. plugins) once implemented
        if floor_id is None:
            self._master_controller.set_all_lights(action=action)
            return

        # TODO: Filter on output type "light" once available
        query = Output.select(Output.number) \
                      .join_from(Output, Room, join_type=JOIN.INNER) \
                      .join_from(Room, Floor, join_type=JOIN.INNER) \
                      .where(Floor.number == floor_id)
        output_ids = [output['number'] for output in query.dicts()]

        # It is unknown whether `floor` is known to the Master implementation. So pass both the floor_id
        # and the list of Output ids to the MasterController
        self._master_controller.set_all_lights(action=action,
                                               floor_id=floor_id,
                                               output_ids=output_ids)

    def set_output_status(self, output_id, is_on, dimmer=None, timer=None):
        # type: (int, bool, Optional[int], Optional[int]) -> None
        self._master_controller.set_output(output_id=output_id,
                                           state=is_on,
                                           dimmer=dimmer,
                                           timer=timer)

    # Global (led) feedback

    def load_global_feedback(
            self, global_feedback_id):  # type: (int) -> GlobalFeedbackDTO
        return self._master_controller.load_global_feedback(
            global_feedback_id=global_feedback_id)

    def load_global_feedbacks(self):  # type: () -> List[GlobalFeedbackDTO]
        return self._master_controller.load_global_feedbacks()

    def save_global_feedbacks(
            self, global_feedbacks):  # type: (List[GlobalFeedbackDTO]) -> None
        self._master_controller.save_global_feedbacks(global_feedbacks)