diff --git a/plantgw.yaml b/plantgw.yaml index e1de5cf..dfb4a21 100644 --- a/plantgw.yaml +++ b/plantgw.yaml @@ -6,6 +6,11 @@ mqtt: #url of your mqtt server, madatory server: my-mqtt-server + # If this is enabled, plantgateway will announce all plants via the MQTT Discovery + # feature of Home Assistant in this MQTT prefix. For details see: + # https://www.home-assistant.io/docs/mqtt/discovery/ + discovery_prefix: homeassistant + #prefix of the topic where the sensor data will be published, mandatory prefix: some/prefix/my/plants #terminate topic with a trailing slash, optional as defaults to True diff --git a/plantgw/__init__.py b/plantgw/__init__.py index 5c50d3c..5d7ac33 100644 --- a/plantgw/__init__.py +++ b/plantgw/__init__.py @@ -1,3 +1,3 @@ """Plantgateway version number.""" -__version__ = '0.5.1' +__version__ = '0.6.0' diff --git a/plantgw/plantgw.py b/plantgw/plantgw.py index 34c0f5a..fe32197 100644 --- a/plantgw/plantgw.py +++ b/plantgw/plantgw.py @@ -11,11 +11,13 @@ ############################################## +from enum import Enum import os import logging import json import time from datetime import datetime +from typing import List, Optional import yaml import paho.mqtt.client as mqtt from miflora.miflora_poller import MiFloraPoller, MI_BATTERY, MI_LIGHT, MI_CONDUCTIVITY, MI_MOISTURE, MI_TEMPERATURE @@ -24,6 +26,38 @@ from plantgw import __version__ +class MQTTAttributes(Enum): + """Attributes sent in the json dict.""" + BATTERY = 'battery' + TEMPERATURE = 'temperature' + BRIGHTNESS = 'brightness' + MOISTURE = 'moisture' + CONDUCTIVITY = 'conductivity' + TIMESTAMP = 'timestamp' + + +# unit of measurement for the different attributes +UNIT_OF_MEASUREMENT = { + MQTTAttributes.BATTERY: '%', + MQTTAttributes.TEMPERATURE: '°C', + MQTTAttributes.BRIGHTNESS: 'lux', + MQTTAttributes.MOISTURE: '%', + MQTTAttributes.CONDUCTIVITY: 'µS/cm', + MQTTAttributes.TIMESTAMP: 's', +} + + +# home assistant device classes for the different attributes +DEVICE_CLASS = { + MQTTAttributes.BATTERY: 'battery', + MQTTAttributes.TEMPERATURE: 'temperature', + MQTTAttributes.BRIGHTNESS: 'illuminance', + MQTTAttributes.MOISTURE: None, + MQTTAttributes.CONDUCTIVITY: None, + MQTTAttributes.TIMESTAMP: 'timestamp', +} + + # pylint: disable-msg=too-many-instance-attributes class Configuration: """Stores the program configuration.""" @@ -38,14 +72,15 @@ def __init__(self, config_file_path): if 'interface' in config: self.interface = config['interface'] - self.mqtt_port = 8883 - self.mqtt_user = None - self.mqtt_password = None - self.mqtt_ca_cert = None - self.mqtt_client_id = None - self.mqtt_trailing_slash = True - self.mqtt_timestamp_format = None - self.sensors = [] + self.mqtt_port = 8883 # type: int + self.mqtt_user = None # type: Optional[str] + self.mqtt_password = None # type: Optional[str] + self.mqtt_ca_cert = None # type: Optional[str] + self.mqtt_client_id = None # type: Optional[str] + self.mqtt_trailing_slash = True # type:bool + self.mqtt_timestamp_format = None # type: Optional[str] + self.mqtt_discovery_prefix = None # type: Optional[str] + self.sensors = [] # type: List[SensorConfig] if 'port' in config['mqtt']: self.mqtt_port = config['mqtt']['port'] @@ -75,6 +110,9 @@ def __init__(self, config_file_path): fail_silent = 'fail_silent' in sensor_config self.sensors.append(SensorConfig(sensor_config['mac'], sensor_config['alias'], fail_silent)) + if 'discovery_prefix' in config['mqtt']: + self.mqtt_discovery_prefix = config['mqtt']['discovery_prefix'] + @staticmethod def _configure_logging(config): timeform = '%a, %d %b %Y %H:%M:%S' @@ -93,7 +131,7 @@ def _configure_logging(config): class SensorConfig: """Stores the configuration of a sensor.""" - def __init__(self, mac, alias=None, fail_silent=False): + def __init__(self, mac: str, alias: str = None, fail_silent: bool = False): if mac is None: msg = 'mac of sensor must not be None' logging.error(msg) @@ -102,20 +140,28 @@ def __init__(self, mac, alias=None, fail_silent=False): self.alias = alias self.fail_silent = fail_silent - def get_topic(self): + def get_topic(self) -> str: """Get the topic name for the sensor.""" if self.alias is not None: return self.alias return self.mac - def __str__(self): - result = self.alias + def __str__(self) -> str: + if self.alias: + result = self.alias + else: + result = self.mac if self.fail_silent: result += ' (fail silent)' return result + @property + def short_mac(self): + """Get the sensor mac without ':' in it.""" + return self.mac.replace(':', '') + @staticmethod - def get_name_string(sensor_list): + def get_name_string(sensor_list) -> str: """Convert a list of sensor objects to a nice string.""" return ', '.join([str(sensor) for sensor in sensor_list]) @@ -123,13 +169,13 @@ def get_name_string(sensor_list): class PlantGateway: """Main class of the module.""" - def __init__(self, config_file_path='~/.plantgw.yaml'): + def __init__(self, config_file_path: str = '~/.plantgw.yaml'): config_file_path = os.path.abspath(os.path.expanduser(config_file_path)) - self.config = Configuration(config_file_path) + self.config = Configuration(config_file_path) # type: Configuration logging.info('PlantGateway version %s', __version__) logging.info('loaded config file from %s', config_file_path) self.mqtt_client = None - self.connected = False + self.connected = False # type: bool def start_client(self): """Start the mqtt client.""" @@ -159,34 +205,39 @@ def _on_connect(client, _, flags, return_code): self.mqtt_client.connect(self.config.mqtt_server, self.config.mqtt_port, 60) self.mqtt_client.loop_start() - def _publish(self, sensor_config, poller): + def _publish(self, sensor_config: SensorConfig, poller: MiFloraPoller): self.start_client() - - prefix_fmt = '{}/{}' - if self.config.mqtt_trailing_slash: - prefix_fmt += '/' - prefix = prefix_fmt.format(self.config.mqtt_prefix, sensor_config.get_topic()) + state_topic = self._get_state_topic(sensor_config) data = { - 'battery': poller.parameter_value(MI_BATTERY), - 'temperature': '{0:.1f}'.format(poller.parameter_value(MI_TEMPERATURE)), - 'brightness': poller.parameter_value(MI_LIGHT), - 'moisture': poller.parameter_value(MI_MOISTURE), - 'conductivity': poller.parameter_value(MI_CONDUCTIVITY), - 'timestamp': datetime.now().isoformat(), + MQTTAttributes.BATTERY.value: poller.parameter_value(MI_BATTERY), + MQTTAttributes.TEMPERATURE.value: '{0:.1f}'.format(poller.parameter_value(MI_TEMPERATURE)), + MQTTAttributes.BRIGHTNESS.value: poller.parameter_value(MI_LIGHT), + MQTTAttributes.MOISTURE.value: poller.parameter_value(MI_MOISTURE), + MQTTAttributes.CONDUCTIVITY.value: poller.parameter_value(MI_CONDUCTIVITY), + MQTTAttributes.TIMESTAMP.value: datetime.now().isoformat(), } for key, value in data.items(): logging.debug("%s: %s", key, value) if self.config.mqtt_timestamp_format is not None: data['timestamp'] = datetime.now().strftime(self.config.mqtt_timestamp_format) json_payload = json.dumps(data) - self.mqtt_client.publish(prefix, json_payload, qos=1, retain=True) - logging.info('sent data to topic %s', prefix) + self.mqtt_client.publish(state_topic, json_payload, qos=1, retain=True) + logging.info('sent data to topic %s', state_topic) + + def _get_state_topic(self, sensor_config: SensorConfig) -> str: + prefix_fmt = '{}/{}' + if self.config.mqtt_trailing_slash: + prefix_fmt += '/' + prefix = prefix_fmt.format(self.config.mqtt_prefix, + sensor_config.get_topic()) + return prefix - def process_mac(self, sensor_config): + def process_mac(self, sensor_config: SensorConfig): """Get data from one Sensor.""" logging.info('Getting data from sensor %s', sensor_config.get_topic()) poller = MiFloraPoller(sensor_config.mac, BluepyBackend) + self.announce_sensor(sensor_config) self._publish(sensor_config, poller) def process_all(self): @@ -224,3 +275,29 @@ def process_all(self): # return sensors that could not be processed after max_retry return next_list + + def announce_sensor(self, sensor_config: SensorConfig): + """Announce the sensor via Home Assistant MQTT Discovery. + + see https://www.home-assistant.io/docs/mqtt/discovery/ + """ + if self.config.mqtt_discovery_prefix is None: + return + self.start_client() + device_name = f'plant_{sensor_config.short_mac}' + for attribute in MQTTAttributes: + topic = f'{self.config.mqtt_discovery_prefix}/sensor/{device_name}_{attribute.value}/config' + payload = { + 'state_topic': self._get_state_topic(sensor_config), + 'unit_of_measurement': UNIT_OF_MEASUREMENT[attribute], + 'value_template': '{{value_json.'+attribute.value+'}}', + } + if sensor_config.alias is not None: + payload['name'] = f'{sensor_config.alias}_{attribute.value}' + + if DEVICE_CLASS[attribute] is not None: + payload['device_class'] = DEVICE_CLASS[attribute] + + json_payload = json.dumps(payload) + self.mqtt_client.publish(topic, json_payload, qos=1, retain=False) + logging.info('sent sensor config to topic %s', topic)