Discovery (#74)

* add support for Home Assistants MQTT discovery

* add format_topic a flexible separator

* add mqtt discovery for miflora

* add mqtt discovery for mithermometer

* add mqtt discovery for thermostat

* add unit to illuminance in miflora

* switch to 'away_mode_command_topic' instead of 'away_command_topic'

* revert changing the date format

* add mac to ientifiers and change the id generation

* add mac to ientifiers and change the id generation on thermostat

* add mac to ientifiers and change the id generation for mithermometer
This commit is contained in:
Marcel Hoppe
2019-06-21 12:25:27 +02:00
committed by zewelor
parent 01b30f6c4a
commit 8fbf3540bd
8 changed files with 192 additions and 35 deletions
+1
View File
@@ -0,0 +1 @@
3.7.2/envs/bt-mqtt-gateway
+3
View File
@@ -8,6 +8,9 @@ mqtt:
availability_topic: lwt_topic availability_topic: lwt_topic
manager: manager:
sensor_config:
topic: homeassistant
retain: true
topic_subscription: topic_subscription:
update_all: update_all:
topic: homeassistant/status topic: homeassistant/status
+22 -1
View File
@@ -1,3 +1,5 @@
import json
import paho.mqtt.client as mqtt import paho.mqtt.client as mqtt
import logger import logger
@@ -65,7 +67,6 @@ class MqttClient:
if self.availability_topic: if self.availability_topic:
self.publish([MqttMessage(topic=self.availability_topic, payload=LWT_ONLINE, retain=True)]) self.publish([MqttMessage(topic=self.availability_topic, payload=LWT_ONLINE, retain=True)])
def callbacks_subscription(self, callbacks): def callbacks_subscription(self, callbacks):
self.mqttc.on_connect = self.on_connect self.mqttc.on_connect = self.on_connect
@@ -86,6 +87,7 @@ class MqttClient:
def _format_topic(self, topic): def _format_topic(self, topic):
return "{}/{}".format(self.topic_prefix, topic) if self.topic_prefix else topic return "{}/{}".format(self.topic_prefix, topic) if self.topic_prefix else topic
class MqttMessage: class MqttMessage:
def __init__(self, topic=None, payload=None, retain=False): def __init__(self, topic=None, payload=None, retain=False):
self._topic = topic self._topic = topic
@@ -120,3 +122,22 @@ class MqttMessage:
def __str__(self): def __str__(self):
return self.__repr__() 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
+5 -2
View File
@@ -8,8 +8,11 @@ class BaseWorker:
def _setup(self): def _setup(self):
return return
def format_topic(self, *args): def format_id(self, *args, separator='/'):
return '/'.join([self.topic_prefix, *args]) 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): def __repr__(self):
return self.__module__.split(".")[-1] return self.__module__.split(".")[-1]
+47 -9
View File
@@ -1,6 +1,4 @@
import logging from mqtt import MqttMessage, MqttConfigMessage
from mqtt import MqttMessage
from workers.base import BaseWorker from workers.base import BaseWorker
import logger import logger
@@ -18,18 +16,58 @@ class MifloraWorker(BaseWorker):
_LOGGER.info("Adding %d %s devices", len(self.devices), repr(self)) _LOGGER.info("Adding %d %s devices", len(self.devices), repr(self))
for name, mac in self.devices.items(): for name, mac in self.devices.items():
_LOGGER.debug("Adding %s device '%s' (%s)", repr(self), name, mac) _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): def status_update(self):
from btlewrap.base import BluetoothBackendException
_LOGGER.info("Updating %d %s devices", len(self.devices), repr(self)) _LOGGER.info("Updating %d %s devices", len(self.devices), repr(self))
ret = [] ret = []
for name, poller in self.devices.items(): for name, data in self.devices.items():
_LOGGER.debug("Updating %s device '%s' (%s)", repr(self), name, poller._mac) _LOGGER.debug("Updating %s device '%s' (%s)", repr(self), name, data["mac"])
from btlewrap import BluetoothBackendException
try: try:
ret += self.update_device_state(name, poller) ret += self.update_device_state(name, data["poller"])
except BluetoothBackendException as e: 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 return ret
def update_device_state(self, name, poller): def update_device_state(self, name, poller):
+23 -8
View File
@@ -1,11 +1,11 @@
import logging import logging
from mqtt import MqttMessage from mqtt import MqttMessage, MqttConfigMessage
from workers.base import BaseWorker from workers.base import BaseWorker
import logger import logger
REQUIREMENTS = ['git+https://github.com/cybe/mithermometer.git@cd8dba297927da823fbfa8f50bd97393ea6a93c1#egg=mithermometer'] REQUIREMENTS = ['mithermometer']
monitoredAttrs = ["temperature", "humidity", "battery"] monitoredAttrs = ["temperature", "humidity", "battery"]
_LOGGER = logger.get(__name__) _LOGGER = logger.get(__name__)
@@ -18,18 +18,33 @@ class MithermometerWorker(BaseWorker):
_LOGGER.info("Adding %d %s devices", len(self.devices), repr(self)) _LOGGER.info("Adding %d %s devices", len(self.devices), repr(self))
for name, mac in self.devices.items(): for name, mac in self.devices.items():
_LOGGER.debug("Adding %s device '%s' (%s)", repr(self), name, mac) _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): def status_update(self):
from btlewrap.base import BluetoothBackendException
_LOGGER.info("Updating %d %s devices", len(self.devices), repr(self)) _LOGGER.info("Updating %d %s devices", len(self.devices), repr(self))
ret = [] ret = []
for name, poller in self.devices.items(): for name, data in self.devices.items():
_LOGGER.debug("Updating %s device '%s' (%s)", repr(self), name, poller._mac) _LOGGER.debug("Updating %s device '%s' (%s)", repr(self), name, data["mac"])
from btlewrap import BluetoothBackendException
try: try:
ret += self.update_device_state(name, poller) ret += self.update_device_state(name, data["poller"])
except BluetoothBackendException as e: 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 return ret
def update_device_state(self, name, poller): def update_device_state(self, name, poller):
+73 -15
View File
@@ -1,7 +1,7 @@
from builtins import staticmethod from builtins import staticmethod
import logging import logging
from mqtt import MqttMessage from mqtt import MqttMessage, MqttConfigMessage
from workers.base import BaseWorker from workers.base import BaseWorker
import logger import logger
@@ -13,6 +13,7 @@ _LOGGER = logger.get(__name__)
STATE_AWAY = 'away' STATE_AWAY = 'away'
STATE_ECO = 'eco' STATE_ECO = 'eco'
STATE_HEAT = 'heat' STATE_HEAT = 'heat'
STATE_AUTO = 'auto'
STATE_MANUAL = 'manual' STATE_MANUAL = 'manual'
STATE_ON = 'on' STATE_ON = 'on'
STATE_OFF = 'off' STATE_OFF = 'off'
@@ -26,10 +27,10 @@ class ThermostatWorker(BaseWorker):
self._mapped_modes = { self._mapped_modes = {
Mode.Closed: STATE_OFF, Mode.Closed: STATE_OFF,
Mode.Open: STATE_ON, Mode.Open: STATE_ON,
Mode.Auto: STATE_HEAT, Mode.Auto: STATE_AUTO,
Mode.Manual: STATE_MANUAL, Mode.Manual: STATE_MANUAL,
Mode.Away: STATE_ECO, Mode.Away: STATE_ECO,
Mode.Boost: 'boost', Mode.Boost: STATE_HEAT,
} }
self._reverse_modes = {v: k for k, v in self._mapped_modes.items()} self._reverse_modes = {v: k for k, v in self._mapped_modes.items()}
@@ -56,35 +57,92 @@ class ThermostatWorker(BaseWorker):
else: else:
return STATE_HEAT return STATE_HEAT
def _setup(self): def _setup(self):
from eq3bt import Thermostat from eq3bt import Thermostat
_LOGGER.info("Adding %d %s devices", len(self.devices), repr(self)) _LOGGER.info("Adding %d %s devices", len(self.devices), repr(self))
for name, mac in self.devices.items(): for name, mac in self.devices.items():
_LOGGER.debug("Adding %s device '%s' (%s)", repr(self), name, mac) _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() 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): def status_update(self):
from bluepy import btle from bluepy import btle
ret = [] ret = []
_LOGGER.info("Updating %d %s devices", len(self.devices), repr(self)) _LOGGER.info("Updating %d %s devices", len(self.devices), repr(self))
for name, thermostat in self.devices.items(): for name, data in self.devices.items():
_LOGGER.debug("Updating %s device '%s' (%s)", repr(self), name, thermostat._conn._mac) _LOGGER.debug("Updating %s device '%s' (%s)", repr(self), name, data["mac"])
try: try:
ret += self.update_device_state(name, thermostat) ret += self.update_device_state(name, data["thermostat"])
except btle.BTLEException as e: 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 return ret
def on_command(self, topic, value): def on_command(self, topic, value):
from bluepy import btle from bluepy import btle
_, device_name, method, _ = topic.split('/') _, device_name, method, _ = topic.split('/')
thermostat = self.devices[device_name] data = self.devices[device_name]
value = value.decode('utf-8') value = value.decode('utf-8')
@@ -98,17 +156,17 @@ class ThermostatWorker(BaseWorker):
elif method == "target_temperature": elif method == "target_temperature":
value = float(value) 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: try:
setattr(thermostat, method, value) setattr(data["thermostat"], method, value)
except btle.BTLEException as e: 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 [] return []
try: try:
return self.update_device_state(device_name, thermostat) return self.update_device_state(device_name, data["thermostat"])
except btle.BTLEException as e: 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 [] return []
def update_device_state(self, name, thermostat): def update_device_state(self, name, thermostat):
+18
View File
@@ -38,6 +38,7 @@ class WorkersManager:
def __init__(self, config): def __init__(self, config):
self._mqtt_callbacks = [] self._mqtt_callbacks = []
self._config_commands = []
self._update_commands = [] self._update_commands = []
self._scheduler = BackgroundScheduler(timezone=utc) self._scheduler = BackgroundScheduler(timezone=utc)
self._daemons = [] self._daemons = []
@@ -55,6 +56,11 @@ class WorkersManager:
command_timeout = worker_config.get('command_timeout', self._command_timeout) command_timeout = worker_config.get('command_timeout', self._command_timeout)
worker_obj = klass(command_timeout, **worker_config['args']) 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'): 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) _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, []) command = self.Command(worker_obj.status_update, worker_obj.command_timeout, [])
@@ -93,6 +99,10 @@ class WorkersManager:
def start(self, mqtt): def start(self, mqtt):
mqtt.callbacks_subscription(self._mqtt_callbacks) mqtt.callbacks_subscription(self._mqtt_callbacks)
if 'sensor_config' in self._config:
self._publish_config(mqtt)
self._scheduler.start() self._scheduler.start()
self.update_all() self.update_all()
for daemon in self._daemons: for daemon in self._daemons:
@@ -133,3 +143,11 @@ class WorkersManager:
global_topic_prefix = userdata['global_topic_prefix'] global_topic_prefix = userdata['global_topic_prefix']
topic = c.topic[len(global_topic_prefix+'/'):] if global_topic_prefix is not None else c.topic 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])) 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)