diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..5f5d5ed --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.7.2/envs/bt-mqtt-gateway diff --git a/config.yaml.example b/config.yaml.example index c4d484a..3d2f180 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -8,6 +8,9 @@ mqtt: availability_topic: lwt_topic manager: + sensor_config: + topic: homeassistant + retain: true topic_subscription: update_all: topic: homeassistant/status diff --git a/mqtt.py b/mqtt.py index 5ef35c2..4947dba 100644 --- a/mqtt.py +++ b/mqtt.py @@ -1,3 +1,5 @@ +import json + import paho.mqtt.client as mqtt import logger @@ -65,7 +67,6 @@ class MqttClient: if self.availability_topic: self.publish([MqttMessage(topic=self.availability_topic, payload=LWT_ONLINE, retain=True)]) - def callbacks_subscription(self, callbacks): self.mqttc.on_connect = self.on_connect @@ -86,6 +87,7 @@ class MqttClient: def _format_topic(self, topic): return "{}/{}".format(self.topic_prefix, topic) if self.topic_prefix else topic + class MqttMessage: def __init__(self, topic=None, payload=None, retain=False): self._topic = topic @@ -120,3 +122,22 @@ class MqttMessage: def __str__(self): return self.__repr__() + + +class MqttConfigMessage(MqttMessage): + SENSOR = 'sensor' + CLIMATE = 'climate' + BINARY_SENSOR = 'binary_sensor' + + def __init__(self, component, name, payload=None, retain=False): + if 'name' not in payload: payload['name'] = name + super().__init__("{}/{}/config".format(component, name), json.dumps(payload), retain) + + @property + def retain(self): + return self._retain + + @retain.setter + def retain(self, new_retain): + self._retain = new_retain + diff --git a/workers/base.py b/workers/base.py index 24173dd..341fd4d 100644 --- a/workers/base.py +++ b/workers/base.py @@ -8,8 +8,11 @@ class BaseWorker: def _setup(self): return - def format_topic(self, *args): - return '/'.join([self.topic_prefix, *args]) + def format_id(self, *args, separator='/'): + return separator.join(["bt-mqtt-gateway", repr(self), *args]) + + def format_topic(self, *args, separator='/'): + return separator.join([self.topic_prefix, *args]) def __repr__(self): return self.__module__.split(".")[-1] diff --git a/workers/miflora.py b/workers/miflora.py index a295df1..a2d64a7 100644 --- a/workers/miflora.py +++ b/workers/miflora.py @@ -1,6 +1,4 @@ -import logging - -from mqtt import MqttMessage +from mqtt import MqttMessage, MqttConfigMessage from workers.base import BaseWorker import logger @@ -18,18 +16,58 @@ class MifloraWorker(BaseWorker): _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] = MiFloraPoller(mac, BluepyBackend) + self.devices[name] = {"mac": mac, "poller": MiFloraPoller(mac, BluepyBackend)} + + 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): + ret = [] + device={"identifiers": [mac, self.format_id(name, separator="_")], + "manufacturer": "Xiaomi", + "model": "MiFlora", + "name": self.format_topic(name, separator=" ").title()} + for attr in monitoredAttrs: + if attr == 'light': + payload = {"unique_id": self.format_id(name, 'illuminance', separator="_"), + "state_topic": self.format_topic(name, attr), + "device_class": 'illuminance', + "unit_of_measurement": "lux", + "device": device} + elif attr == 'moisture': + payload = {"unique_id": self.format_id(name, attr, separator="_"), + "state_topic": self.format_topic(name, attr), + "icon": 'mdi:water', + "unit_of_measurement": "%", + "device": device} + elif attr == 'conductivity': + payload = {"unique_id": self.format_id(name, attr, separator="_"), + "state_topic": self.format_topic(name, attr), + "icon": 'mdi:leaf', + "unit_of_measurement": "µS/cm", + "device": device} + else: + payload = {"unique_id": self.format_id(name, attr, separator="_"), + "state_topic": self.format_topic(name, attr), + "device_class": attr, + "device": device} + ret.append(MqttConfigMessage(MqttConfigMessage.SENSOR, self.format_topic(name, attr, separator="_"), payload=payload)) + + return ret def status_update(self): - from btlewrap.base import BluetoothBackendException _LOGGER.info("Updating %d %s devices", len(self.devices), repr(self)) ret = [] - for name, poller in self.devices.items(): - _LOGGER.debug("Updating %s device '%s' (%s)", repr(self), name, poller._mac) + for name, data in self.devices.items(): + _LOGGER.debug("Updating %s device '%s' (%s)", repr(self), name, data["mac"]) + from btlewrap import BluetoothBackendException try: - ret += self.update_device_state(name, poller) + ret += self.update_device_state(name, data["poller"]) except BluetoothBackendException as e: - logger.log_exception(_LOGGER, "Error during update of %s device '%s' (%s): %s", repr(self), name, poller._mac, type(e).__name__, suppress=True) + logger.log_exception(_LOGGER, "Error during update of %s device '%s' (%s): %s", repr(self), name, data["mac"], type(e).__name__, suppress=True) return ret def update_device_state(self, name, poller): diff --git a/workers/mithermometer.py b/workers/mithermometer.py index 8de1f4f..54686a7 100644 --- a/workers/mithermometer.py +++ b/workers/mithermometer.py @@ -1,11 +1,11 @@ import logging -from mqtt import MqttMessage +from mqtt import MqttMessage, MqttConfigMessage from workers.base import BaseWorker import logger -REQUIREMENTS = ['git+https://github.com/cybe/mithermometer.git@cd8dba297927da823fbfa8f50bd97393ea6a93c1#egg=mithermometer'] +REQUIREMENTS = ['mithermometer'] monitoredAttrs = ["temperature", "humidity", "battery"] _LOGGER = logger.get(__name__) @@ -18,18 +18,33 @@ class MithermometerWorker(BaseWorker): _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] = MiThermometerPoller(mac, BluepyBackend) + self.devices[name] = {"mac":mac, "poller": MiThermometerPoller(mac, BluepyBackend)} + + 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): + ret = [] + device={"identifiers": [mac, self.format_id(name, separator="_")], "manufacturer": "Xiaomi", "model": "LYWSD(CGQ/01ZM)", "name": self.format_topic(name, separator=" ").title()} + for attr in monitoredAttrs: + payload = {"unique_id": self.format_id(name, attr, separator="_"), "state_topic": self.format_topic(name, attr), "device_class": attr, "device": device} + ret.append(MqttConfigMessage(MqttConfigMessage.SENSOR, self.format_topic(name, attr, separator="_"), payload=payload)) + + return ret def status_update(self): - from btlewrap.base import BluetoothBackendException _LOGGER.info("Updating %d %s devices", len(self.devices), repr(self)) ret = [] - for name, poller in self.devices.items(): - _LOGGER.debug("Updating %s device '%s' (%s)", repr(self), name, poller._mac) + for name, data in self.devices.items(): + _LOGGER.debug("Updating %s device '%s' (%s)", repr(self), name, data["mac"]) + from btlewrap import BluetoothBackendException try: - ret += self.update_device_state(name, poller) + ret += self.update_device_state(name, data["poller"]) except BluetoothBackendException as e: - logger.log_exception(_LOGGER, "Error during update of %s device '%s' (%s): %s", repr(self), name, poller._mac, type(e).__name__, suppress=True) + logger.log_exception(_LOGGER, "Error during update of %s device '%s' (%s): %s", repr(self), name, data["mac"], type(e).__name__, suppress=True) return ret def update_device_state(self, name, poller): diff --git a/workers/thermostat.py b/workers/thermostat.py index 0f03e39..ea26401 100644 --- a/workers/thermostat.py +++ b/workers/thermostat.py @@ -1,7 +1,7 @@ from builtins import staticmethod import logging -from mqtt import MqttMessage +from mqtt import MqttMessage, MqttConfigMessage from workers.base import BaseWorker import logger @@ -13,6 +13,7 @@ _LOGGER = logger.get(__name__) STATE_AWAY = 'away' STATE_ECO = 'eco' STATE_HEAT = 'heat' +STATE_AUTO = 'auto' STATE_MANUAL = 'manual' STATE_ON = 'on' STATE_OFF = 'off' @@ -26,10 +27,10 @@ class ThermostatWorker(BaseWorker): self._mapped_modes = { Mode.Closed: STATE_OFF, Mode.Open: STATE_ON, - Mode.Auto: STATE_HEAT, + Mode.Auto: STATE_AUTO, Mode.Manual: STATE_MANUAL, Mode.Away: STATE_ECO, - Mode.Boost: 'boost', + Mode.Boost: STATE_HEAT, } self._reverse_modes = {v: k for k, v in self._mapped_modes.items()} @@ -56,35 +57,92 @@ class ThermostatWorker(BaseWorker): else: return STATE_HEAT - 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] = Thermostat(mac) + self.devices[name] = {"mac": mac, "thermostat": Thermostat(mac)} self._modes_mapper = self.ModesMapper() + 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): + ret = [] + device={"identifiers": [mac, self.format_id(name, separator="_")], + "manufacturer": "eQ-3", + "model": "Smart Radiator Thermostat", + "name": self.format_topic(name, separator=" ").title()} + + payload = {"unique_id": self.format_id(name, 'climate', separator="_"), + "qos": 1, + "temperature_state_topic": self.format_topic(name, 'target_temperature'), + "temperature_command_topic": self.format_topic(name, 'target_temperature', 'set'), + "mode_state_topic": self.format_topic(name, 'mode'), + "mode_command_topic": self.format_topic(name, 'mode', 'set'), + "away_mode_state_topic": self.format_topic(name, 'away'), + "away_mode_command_topic": self.format_topic(name, 'away', 'set'), + "min_temp": 5.0, + "max_temp": 29.5, + "temp_step": 0.5, + "payload_on": "'on'", + "payload_off": "'off'", + "modes": ["heat", "auto", "manual", "eco", 'off'], + "device": device} + ret.append(MqttConfigMessage(MqttConfigMessage.CLIMATE, self.format_topic(name, 'climate', separator="_"), payload=payload)) + + payload = {"unique_id": self.format_id(name, 'window_open', separator="_"), + "state_topic": self.format_topic(name, 'window_open'), + "device_class": 'window', + "device": device} + ret.append(MqttConfigMessage(MqttConfigMessage.BINARY_SENSOR, self.format_topic(name, 'window_open', separator="_"), payload=payload)) + + payload = {"unique_id": self.format_id(name, 'low_battery', separator="_"), + "state_topic": self.format_topic(name, 'low_battery'), + "device_class": 'battery', + "device": device} + ret.append(MqttConfigMessage(MqttConfigMessage.BINARY_SENSOR, self.format_topic(name, 'low_battery', separator="_"), payload=payload)) + + payload = {"unique_id": self.format_id(name, 'locked', separator="_"), + "state_topic": self.format_topic(name, 'locked'), + "device_class": 'lock', + "device": device} + ret.append(MqttConfigMessage(MqttConfigMessage.BINARY_SENSOR, self.format_topic(name, 'locked', separator="_"), + payload=payload)) + + payload = {"unique_id": self.format_id(name, "valve_state", separator="_"), + "state_topic": self.format_topic(name, "valve_state"), + "unit_of_measurement": "%", + "device": device} + ret.append(MqttConfigMessage(MqttConfigMessage.SENSOR, self.format_topic(name, 'valve_state', separator="_"), payload=payload)) + + return ret + + def status_update(self): from bluepy import btle ret = [] _LOGGER.info("Updating %d %s devices", len(self.devices), repr(self)) - for name, thermostat in self.devices.items(): - _LOGGER.debug("Updating %s device '%s' (%s)", repr(self), name, thermostat._conn._mac) + 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, thermostat) + 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, thermostat._conn._mac, type(e).__name__, suppress=True) + logger.log_exception(_LOGGER, "Error during update of %s device '%s' (%s): %s", repr(self), name, data["mac"], type(e).__name__, suppress=True) return ret def on_command(self, topic, value): from bluepy import btle _, device_name, method, _ = topic.split('/') - thermostat = self.devices[device_name] + data = self.devices[device_name] value = value.decode('utf-8') @@ -98,17 +156,17 @@ class ThermostatWorker(BaseWorker): elif method == "target_temperature": value = float(value) - _LOGGER.info("Setting %s to %s on %s device '%s' (%s)", method, value, repr(self), device_name, thermostat._conn._mac) + _LOGGER.info("Setting %s to %s on %s device '%s' (%s)", method, value, repr(self), device_name, data["mac"]) try: - setattr(thermostat, method, value) + setattr(data["thermostat"], method, value) except btle.BTLEException as e: - logger.log_exception(_LOGGER, "Error setting %s to %s on %s device '%s' (%s): %s", method, value, repr(self), device_name, thermostat._conn._mac, type(e).__name__) + logger.log_exception(_LOGGER, "Error setting %s to %s on %s device '%s' (%s): %s", method, value, repr(self), device_name, data["mac"], type(e).__name__) return [] try: - return self.update_device_state(device_name, thermostat) + return self.update_device_state(device_name, data["thermostat"]) except btle.BTLEException as e: - logger.log_exception(_LOGGER, "Error during update of %s device '%s' (%s): %s", repr(self), device_name, thermostat._conn._mac, type(e).__name__, suppress=True) + logger.log_exception(_LOGGER, "Error during update of %s device '%s' (%s): %s", repr(self), device_name, data["mac"], type(e).__name__, suppress=True) return [] def update_device_state(self, name, thermostat): diff --git a/workers_manager.py b/workers_manager.py index 3803890..821dd58 100644 --- a/workers_manager.py +++ b/workers_manager.py @@ -38,6 +38,7 @@ class WorkersManager: def __init__(self, config): self._mqtt_callbacks = [] + self._config_commands = [] self._update_commands = [] self._scheduler = BackgroundScheduler(timezone=utc) self._daemons = [] @@ -55,6 +56,11 @@ class WorkersManager: command_timeout = worker_config.get('command_timeout', self._command_timeout) worker_obj = klass(command_timeout, **worker_config['args']) + if 'sensor_config' in self._config and hasattr(worker_obj, 'config'): + _LOGGER.debug("Added %s config with a %d seconds timeout", repr(worker_obj), 2) + command = self.Command(worker_obj.config, 2, []) + self._config_commands.append(command) + if hasattr(worker_obj, 'status_update'): _LOGGER.debug("Added %s worker with %d seconds interval and a %d seconds timeout", repr(worker_obj), worker_config['update_interval'], worker_obj.command_timeout) command = self.Command(worker_obj.status_update, worker_obj.command_timeout, []) @@ -93,6 +99,10 @@ class WorkersManager: def start(self, mqtt): mqtt.callbacks_subscription(self._mqtt_callbacks) + + if 'sensor_config' in self._config: + self._publish_config(mqtt) + self._scheduler.start() self.update_all() for daemon in self._daemons: @@ -133,3 +143,11 @@ class WorkersManager: global_topic_prefix = userdata['global_topic_prefix'] topic = c.topic[len(global_topic_prefix+'/'):] if global_topic_prefix is not None else c.topic self._queue_command(self.Command(worker_obj.on_command, worker_obj.command_timeout, [topic, c.payload])) + + def _publish_config(self, mqtt): + for command in self._config_commands: + messages = command.execute() + for msg in messages: + msg.topic = "{}/{}".format(self._config['sensor_config'].get('topic', 'homeassistant'), msg.topic) + msg.retain = self._config['sensor_config'].get('retain', True) + mqtt.publish(messages) \ No newline at end of file