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
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 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)
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()
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()
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()
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 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()
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 __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): # 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)
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
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.' )
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
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()
def __init__(self): self.vpn_connected = False self._vpn_tester = DaemonThread(name='vpnctl', target=self._vpn_connected, interval=5)
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
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
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()
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
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
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)