Plant (replacement for MiGardener) (#7131)

* new implementation without mqtt

* fixed lint findings

* fixed more lint findings

* fixed final flak8 error

* added unit tests for platform "plant"

* - changed status to "OK" / "problem"
- added attribute "problem" with details on the problems
- removed unused constant
- setting icon to "?" until we have meaningful data

* reformatted code to meet line length requirements
This commit is contained in:
ChristianKuehnel 2017-04-30 21:32:32 +02:00 committed by Paulus Schoutsen
parent c14b829f27
commit 5d7403bd81
2 changed files with 315 additions and 0 deletions

View File

@ -0,0 +1,242 @@
"""Component to monitor plants.
This is meant to be used with Xiaomi Mi Plant sensors, but will
work with any sensor that provides the right parameters.
To read the sensor data and send it via MQTT,
see https://github.com/ChristianKuehnel/plantgateway
"""
import logging
import asyncio
import voluptuous as vol
from homeassistant.const import (
STATE_UNKNOWN, TEMP_CELSIUS, ATTR_TEMPERATURE, CONF_SENSORS,
ATTR_UNIT_OF_MEASUREMENT, ATTR_ICON)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.core import callback
from homeassistant.helpers.event import async_track_state_change
_LOGGER = logging.getLogger(__name__)
DEFAULT_NAME = 'plant'
READING_BATTERY = 'battery'
READING_TEMPERATURE = ATTR_TEMPERATURE
READING_MOISTURE = 'moisture'
READING_CONDUCTIVITY = 'conductivity'
READING_BRIGHTNESS = 'brightness'
ATTR_PROBLEM = 'problem'
PROBLEM_NONE = 'none'
CONF_MIN_BATTERY_LEVEL = 'min_' + READING_BATTERY
CONF_MIN_TEMPERATURE = 'min_' + READING_TEMPERATURE
CONF_MAX_TEMPERATURE = 'max_' + READING_TEMPERATURE
CONF_MIN_MOISTURE = 'min_' + READING_MOISTURE
CONF_MAX_MOISTURE = 'max_' + READING_MOISTURE
CONF_MIN_CONDUCTIVITY = 'min_' + READING_CONDUCTIVITY
CONF_MAX_CONDUCTIVITY = 'max_' + READING_CONDUCTIVITY
CONF_MIN_BRIGHTNESS = 'min_' + READING_BRIGHTNESS
CONF_MAX_BRIGHTNESS = 'max_' + READING_BRIGHTNESS
CONF_SENSOR_BATTERY_LEVEL = READING_BATTERY
CONF_SENSOR_MOISTURE = READING_MOISTURE
CONF_SENSOR_CONDUCTIVITY = READING_CONDUCTIVITY
CONF_SENSOR_TEMPERATURE = READING_TEMPERATURE
CONF_SENSOR_BRIGHTNESS = READING_BRIGHTNESS
SCHEMA_SENSORS = vol.Schema({
vol.Optional(CONF_SENSOR_BATTERY_LEVEL): cv.entity_id,
vol.Optional(CONF_SENSOR_MOISTURE): cv.entity_id,
vol.Optional(CONF_SENSOR_CONDUCTIVITY): cv.entity_id,
vol.Optional(CONF_SENSOR_TEMPERATURE): cv.entity_id,
vol.Optional(CONF_SENSOR_BRIGHTNESS): cv.entity_id,
})
PLANT_SCHEMA = vol.Schema({
vol.Required(CONF_SENSORS): vol.Schema(SCHEMA_SENSORS),
vol.Optional(CONF_MIN_BATTERY_LEVEL): cv.positive_int,
vol.Optional(CONF_MIN_TEMPERATURE): cv.small_float,
vol.Optional(CONF_MAX_TEMPERATURE): cv.small_float,
vol.Optional(CONF_MIN_MOISTURE): cv.positive_int,
vol.Optional(CONF_MAX_MOISTURE): cv.positive_int,
vol.Optional(CONF_MIN_CONDUCTIVITY): cv.positive_int,
vol.Optional(CONF_MAX_CONDUCTIVITY): cv.positive_int,
vol.Optional(CONF_MIN_BRIGHTNESS): cv.positive_int,
vol.Optional(CONF_MAX_BRIGHTNESS): cv.positive_int,
})
DOMAIN = 'plant'
CONFIG_SCHEMA = vol.Schema({
DOMAIN: {
cv.string: PLANT_SCHEMA
},
}, extra=vol.ALLOW_EXTRA)
@asyncio.coroutine
def async_setup(hass, config):
"""Set up Plant component."""
component = EntityComponent(_LOGGER, DOMAIN, hass)
entities = []
for plant_name, plant_config in config[DOMAIN].items():
_LOGGER.info('added plant %s', plant_name)
entity = Plant(plant_name, plant_config)
sensor_entity_ids = list(plant_config[CONF_SENSORS].values())
_LOGGER.debug('subscribing to entity_ids %s', sensor_entity_ids)
async_track_state_change(hass, sensor_entity_ids, entity.state_changed)
entities.append(entity)
yield from component.async_add_entities(entities)
return True
class Plant(Entity):
"""Plant monitors the well-being of a plant.
It also checks the measurements against
configurable min and max values.
"""
READINGS = {
READING_BATTERY: {
ATTR_UNIT_OF_MEASUREMENT: '%',
'min': CONF_MIN_BATTERY_LEVEL,
'icon': 'mdi:battery-outline'
},
READING_TEMPERATURE: {
ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS,
'min': CONF_MIN_TEMPERATURE,
'max': CONF_MAX_TEMPERATURE,
'icon': 'mdi:thermometer'
},
READING_MOISTURE: {
ATTR_UNIT_OF_MEASUREMENT: '%',
'min': CONF_MIN_MOISTURE,
'max': CONF_MAX_MOISTURE,
'icon': 'mdi:water'
},
READING_CONDUCTIVITY: {
ATTR_UNIT_OF_MEASUREMENT: 'µS/cm',
'min': CONF_MIN_CONDUCTIVITY,
'max': CONF_MAX_CONDUCTIVITY,
'icon': 'mdi:emoticon-poop'
},
READING_BRIGHTNESS: {
ATTR_UNIT_OF_MEASUREMENT: 'lux',
'min': CONF_MIN_BRIGHTNESS,
'max': CONF_MAX_BRIGHTNESS,
'icon': 'mdi:white-balance-sunny'
}
}
def __init__(self, name, config):
"""Default constructor."""
self._config = config
self._sensormap = dict()
for reading, entity_id in config['sensors'].items():
self._sensormap[entity_id] = reading
self._state = STATE_UNKNOWN
self._name = name
self._battery = None
self._moisture = None
self._conductivity = None
self._temperature = None
self._brightness = None
self._icon = 'mdi:help-circle'
self._problems = PROBLEM_NONE
@callback
def state_changed(self, entity_id, _, new_state):
"""Update the sensor status.
This callback is triggered, when the sensor state changes.
"""
value = new_state.state
_LOGGER.debug('received callback from %s with value %s',
entity_id, value)
if value == STATE_UNKNOWN:
return
reading = self._sensormap[entity_id]
if reading == READING_MOISTURE:
self._moisture = int(value)
elif reading == READING_BATTERY:
self._battery = int(value)
elif reading == READING_TEMPERATURE:
self._temperature = float(value)
elif reading == READING_CONDUCTIVITY:
self._conductivity = int(value)
elif reading == READING_BRIGHTNESS:
self._brightness = int(value)
else:
raise _LOGGER.error('unknown reading from sensor %s: %s',
entity_id, value)
self._update_state()
def _update_state(self):
""""Update the state of the class based sensor data."""
result = []
for sensor_name in self._sensormap.values():
params = self.READINGS[sensor_name]
value = getattr(self, '_{}'.format(sensor_name))
if value is not None:
if 'min' in params and params['min'] in self._config:
min_value = self._config[params['min']]
if value < min_value:
result.append('{} low'.format(sensor_name))
self._icon = params['icon']
if 'max' in params and params['max'] in self._config:
max_value = self._config[params['max']]
if value > max_value:
result.append('{} high'.format(sensor_name))
self._icon = params['icon']
if len(result) == 0:
self._state = 'ok'
self._icon = 'mdi:thumb-up'
self._problems = PROBLEM_NONE
else:
self._state = 'problem'
self._problems = ','.join(result)
_LOGGER.debug('new data processed')
self.hass.async_add_job(self.async_update_ha_state())
@property
def should_poll(self):
"""No polling needed."""
return False
@property
def name(self):
"""Return the name of the sensor."""
return self._name
@property
def state(self):
"""Return the state of the entity."""
return self._state
@property
def state_attributes(self):
"""Return the attributes of the entity.
Provide the individual measurements from the
sensor in the attributes of the device.
"""
attrib = {
ATTR_ICON: self._icon,
ATTR_PROBLEM: self._problems,
}
for reading in self._sensormap.values():
attrib[reading] = getattr(self, '_{}'.format(reading))
return attrib

View File

@ -0,0 +1,73 @@
"""Unit tests for platform/plant.py."""
import unittest
from tests.common import get_test_home_assistant
import homeassistant.components.plant as plant
class TestPlant(unittest.TestCase):
"""test the processing of data."""
GOOD_DATA = {
'moisture': 50,
'battery': 90,
'temperature': 23.4,
'conductivity': 777,
'brightness': 987,
}
GOOD_CONFIG = {
'sensors': {
'moisture': 'sensor.mqtt_plant_moisture',
'battery': 'sensor.mqtt_plant_battery',
'temperature': 'sensor.mqtt_plant_temperature',
'conductivity': 'sensor.mqtt_plant_conductivity',
'brightness': 'sensor.mqtt_plant_brightness',
},
'min_moisture': 20,
'max_moisture': 60,
'min_battery': 17,
'min_conductivity': 500,
'min_temperature': 15,
}
class _MockState(object):
def __init__(self, state=None):
self.state = state
def setUp(self):
"""Setup things to be run when tests are started."""
self.hass = get_test_home_assistant()
def tearDown(self):
"""Stop everything that was started."""
self.hass.stop()
def test_valid_data(self):
"""Test processing valid data."""
self.sensor = plant.Plant('my plant', self.GOOD_CONFIG)
self.sensor.hass = self.hass
for reading, value in self.GOOD_DATA.items():
self.sensor.state_changed(
self.GOOD_CONFIG['sensors'][reading], None,
TestPlant._MockState(value))
self.assertEqual(self.sensor.state, 'ok')
attrib = self.sensor.state_attributes
for reading, value in self.GOOD_DATA.items():
# battery level has a different name in
# the JSON format than in hass
self.assertEqual(attrib[reading], value)
def test_low_battery(self):
"""Test processing with low battery data and limit set."""
self.sensor = plant.Plant(self.hass, self.GOOD_CONFIG)
self.sensor.hass = self.hass
self.assertEqual(self.sensor.state_attributes['problem'], 'none')
self.sensor.state_changed('sensor.mqtt_plant_battery',
TestPlant._MockState(45),
TestPlant._MockState(10))
self.assertEqual(self.sensor.state, 'problem')
self.assertEqual(self.sensor.state_attributes['problem'],
'battery low')