from builtins import staticmethod import logging from mqtt import MqttMessage, MqttConfigMessage from workers.base import BaseWorker import logger from datetime import datetime, timedelta import time REQUIREMENTS = ['python-eq3bt'] monitoredAttrs = ["low_battery", "valve_state", "target_temperature", "window_open", "locked"] _LOGGER = logger.get(__name__) STATE_AWAY = 'away' STATE_BOOST = 'boost' STATE_AUTO = 'auto' STATE_MANUAL = 'manual' STATE_ON = 'on' STATE_OFF = 'off' class Eq3BtWorker(BaseWorker): class ModesMapper(): def __init__(self): from eq3bt import Mode self._mapped_modes = { Mode.Closed: STATE_OFF, Mode.Open: STATE_ON, Mode.Auto: STATE_AUTO, Mode.Manual: STATE_MANUAL, Mode.Away: STATE_AWAY, Mode.Boost: STATE_BOOST, } self._reverse_modes = {v: k for k, v in self._mapped_modes.items()} def get_mapping(self, mode): if mode < 0: return None return self._mapped_modes[mode] def get_reverse_mapping(self, mode): return self._reverse_modes[mode] def format_topic(self, *args, separator='/'): return separator.join([*args]) def mqttHomieDevice(self,device, name, nodes): return [ MqttConfigMessage(self.format_topic(device), "$state", payload="ready"), MqttConfigMessage(self.format_topic(device), "$homie", payload="3.0"), MqttConfigMessage(self.format_topic(device), "$name", payload=name), MqttConfigMessage(self.format_topic(device), "$nodes", payload=nodes) ] def mqttHomieNode(self, device, label, name, properties): return [ MqttConfigMessage(self.format_topic(device, label), "$name", payload=name), MqttConfigMessage(self.format_topic(device, label), "$properties", payload=properties), ] def mqttHomieProperty(self, device, node, prop, name, dtype, settable = False, retained = False, unit = None, dformat = None): retval = [ MqttConfigMessage(self.format_topic(device, node, prop), "$name", payload=name), MqttConfigMessage(self.format_topic(device, node, prop), "$datatype", payload=dtype), MqttConfigMessage(self.format_topic(device, node, prop), "$settable", payload="true" if settable else "false"), MqttConfigMessage(self.format_topic(device, node, prop), "$retained", payload="true" if retained else "false") ] if unit is not None: retval.append(MqttConfigMessage(self.format_topic(device, node, prop), "$unit", payload=unit)) if dformat is not None: retval.append(MqttConfigMessage(self.format_topic(device, node, prop), "$format", payload=dformat)) return retval def _setup(self): from eq3bt import Thermostat _LOGGER.info("Adding %d %s devices", len(self.devices), repr(self)) for name, mac in self.devices.items(): _LOGGER.debug("Adding %s device '%s' (%s)", repr(self), name, mac) self.devices[name] = {"mac": mac, "thermostat": Thermostat(mac)} self._modes_mapper = self.ModesMapper() self._command_queue = [] def config(self): ret = [] for name, data in self.devices.items(): ret += self.config_device(name, data["mac"]) return ret def config_device(self, name, mac): from eq3bt import eq3btsmart _LOGGER.debug("Configuring device " + str(name)) retval = [] retval = self.mqttHomieDevice(name, "Smart Thermostat: " + str(name) + " (" + str(mac)+ ")", "status,climate") retval += self.mqttHomieNode(name, "status", "Device Status", "low_battery,window_open,locked,last_updated") retval += self.mqttHomieProperty(name, "status", "low_battery", "Low Battery", "boolean", False, True) retval += self.mqttHomieProperty(name, "status", "window_open", "Window Open", "boolean", False, True) retval += self.mqttHomieProperty(name, "status", "locked", "Lock", "boolean", True, True) retval += self.mqttHomieProperty(name, "status", "last_updated", "Last Updated", "string", False, True) retval += self.mqttHomieNode(name, "climate", "Climate Control", "mode,target_temperature,comfort_temperature,eco_temperature,temperature_offset,valve_state") retval += self.mqttHomieProperty(name, "climate", "mode", "Climate Mode", "enum", True, True, dformat = "auto,manual,boost,away,on,off") retval += self.mqttHomieProperty(name, "climate", "target_temperature", "Target Temperature", "float", True, True, dformat = str(eq3btsmart.EQ3BT_MIN_TEMP) + ":" + str(eq3btsmart.EQ3BT_MAX_TEMP)) retval += self.mqttHomieProperty(name, "climate", "comfort_temperature", "Comfort Temperature", "float", True, True, dformat = str(eq3btsmart.EQ3BT_MIN_TEMP) + ":" + str(eq3btsmart.EQ3BT_MAX_TEMP)) retval += self.mqttHomieProperty(name, "climate", "eco_temperature", "ECO Temperature", "float", True, True, dformat = str(eq3btsmart.EQ3BT_MIN_TEMP) + ":" + str(eq3btsmart.EQ3BT_MAX_TEMP)) retval += self.mqttHomieProperty(name, "climate", "temperature_offset", "Temperature Offset", "float", True, True) retval += self.mqttHomieProperty(name, "climate", "valve_state", "Valve State", "integer", False, True) return retval def run(self, mqtt): _LOGGER.info("Starting eq3bt daemon") from bluepy import btle self.time_next_update = datetime.now() commandTryCounter = 0 updateTryCounter = 0 while True: try: time_now = datetime.now() if time_now > self.time_next_update: _LOGGER.info("Updating %d %s devices", len(self.devices), repr(self)) updateTryCounter += 1 if updateTryCounter <= 3: for name, data in self.devices.items(): _LOGGER.debug("Updating %s device '%s' (%s)", repr(self), name, data["mac"]) ret = self.update_device_state(name, data["thermostat"]) mqtt.publish(ret) _LOGGER.debug("Updating %s device '%s' finished", repr(self), name) updateTryCounter -= 1 else: _LOGGER.error("Retried update too many times, dropping for now...") updateTryCounter = 0 self.time_next_update = time_now + timedelta(seconds=self.update_interval) if len(self._command_queue) > 0: _LOGGER.info("Executing command") commandTryCounter += 1 if commandTryCounter <= 5: command = self._command_queue[0] if command(): self.time_next_update = time_now del self._command_queue[0] commandTryCounter -= 1 _LOGGER.debug("Executing command finished") else: _LOGGER.error("Retried command too many times, dropping command...") del self._command_queue[0] commandTryCounter = 0 time.sleep(1.0) except btle.BTLEException as e: import traceback _LOGGER.error("Caught BTLEException: {}".format(e)) _LOGGER.error(traceback.format_exc()) import subprocess _LOGGER.error("Reseting hci0") try: subprocess.run(["/bin/hciconfig", "hci0", "down"], timeout=10) time.sleep(1.0) subprocess.run(["/bin/hciconfig", "hci0", "up"], timeout=10) except Exception as e: _LOGGER.error("Caught exception while reseting hci0: {}".format(e)) _LOGGER.error(traceback.format_exc()) time.sleep(10.0) except Exception as e: import traceback _LOGGER.error("Caught exception in Eq3BtWorker.run(): {}".format(e)) _LOGGER.error(traceback.format_exc()) time.sleep(1.0) ''' def status_update(self): from bluepy import btle ret = [] _LOGGER.info("Updating %d %s devices", len(self.devices), repr(self)) for name, data in self.devices.items(): _LOGGER.debug("Updating %s device '%s' (%s)", repr(self), name, data["mac"]) try: ret += self.update_device_state(name, data["thermostat"]) except btle.BTLEException as e: logger.log_exception(_LOGGER, "Error during update of %s device '%s' (%s): %s", repr(self), name, data["mac"], type(e).__name__, suppress=True) _LOGGER.info("Updating %d %s devices finshed", len(self.devices), repr(self)) return ret ''' def on_command(self, topic, value): retval = [] try: value = value.decode('utf-8') device_name, nodename, propname, operation = topic.split('/') _LOGGER.debug("Command \"%s\", on %s.%s.%s: %s", operation, device_name, nodename, propname, value) device = self.devices[device_name]["thermostat"] def _cmd_fun(): if propname == "locked" and operation == "set": device.locked = True elif value == "false" or value == "off": device.locked = False elif propname == "mode" and operation == "set": device.mode = self._modes_mapper.get_reverse_mapping(value) elif propname == "target_temperature" and operation == "set": device.target_temperature = float(value) elif propname == "comfort_temperature" and operation == "set": device.comfort_temperature = float(value) elif propname == "eco_temperature" and operation == "set": device.eco_temperature = float(value) elif propname == "temperature_offset" and operation == "set": device.temperature_offset = float(value) elif propname == "valve_state" and operation == "set": device.valve_state = float(value) return True self._command_queue.append(_cmd_fun) except Exception as e: import traceback _LOGGER.error("Caught exception in Eq3BtWorker.on_command(): {}".format(e)) _LOGGER.error(traceback.format_exc()) return retval def update_device_state(self, name, thermostat): from datetime import datetime thermostat.update() ret = [] ret.append(MqttMessage(topic=self.format_topic(name, 'status', 'low_battery'), payload="true" if thermostat.low_battery else "false")) ret.append(MqttMessage(topic=self.format_topic(name, 'status', 'window_open'), payload="true" if thermostat.window_open else "false")) ret.append(MqttMessage(topic=self.format_topic(name, 'status', 'locked'), payload="true" if thermostat.locked else "false")) ha_mode = self._modes_mapper.get_mapping(thermostat.mode) ret.append(MqttMessage(topic=self.format_topic(name, 'climate', 'mode'), payload=ha_mode)) ret.append(MqttMessage(topic=self.format_topic(name, 'climate', 'target_temperature'), payload=thermostat.target_temperature)) ret.append(MqttMessage(topic=self.format_topic(name, 'climate', 'comfort_temperature'), payload=thermostat.comfort_temperature)) ret.append(MqttMessage(topic=self.format_topic(name, 'climate', 'eco_temperature'), payload=thermostat.eco_temperature)) ret.append(MqttMessage(topic=self.format_topic(name, 'climate', 'temperature_offset'), payload=thermostat.temperature_offset)) ret.append(MqttMessage(topic=self.format_topic(name, 'climate', 'valve_state'), payload=thermostat.valve_state)) ret.append(MqttMessage(topic=self.format_topic(name, 'status', 'last_updated'), payload=datetime.isoformat(datetime.now()))) return ret