diff --git a/.coveragerc b/.coveragerc index 5bff9790325d24..8a7da26d8caa58 100644 --- a/.coveragerc +++ b/.coveragerc @@ -221,6 +221,7 @@ omit = homeassistant/components/sensor/imap.py homeassistant/components/sensor/lastfm.py homeassistant/components/sensor/loopenergy.py + homeassistant/components/sensor/mqtt_room.py homeassistant/components/sensor/neurio_energy.py homeassistant/components/sensor/nzbget.py homeassistant/components/sensor/ohmconnect.py diff --git a/homeassistant/components/sensor/mqtt_room.py b/homeassistant/components/sensor/mqtt_room.py new file mode 100644 index 00000000000000..6980b7e6f7b144 --- /dev/null +++ b/homeassistant/components/sensor/mqtt_room.py @@ -0,0 +1,139 @@ +""" +Support for MQTT room presence detection. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.mqtt_room/ +""" +import logging +import json + +import voluptuous as vol + +import homeassistant.components.mqtt as mqtt +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_NAME, STATE_UNKNOWN +from homeassistant.components.mqtt import CONF_STATE_TOPIC +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import dt, slugify + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['mqtt'] + +CONF_DEVICE_ID = 'device_id' +CONF_TIMEOUT = 'timeout' + +DEFAULT_TOPIC = 'room_presence' +DEFAULT_TIMEOUT = 5 +DEFAULT_NAME = 'Room Sensor' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_DEVICE_ID): cv.string, + vol.Required(CONF_STATE_TOPIC, default=DEFAULT_TOPIC): cv.string, + vol.Required(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string +}) + +MQTT_PAYLOAD = vol.Schema(vol.All(json.loads, vol.Schema({ + vol.Required('id'): cv.string, + vol.Required('distance'): vol.Coerce(float) +}, extra=vol.ALLOW_EXTRA))) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices_callback, discovery_info=None): + """Setup MQTT Sensor.""" + add_devices_callback([MQTTRoomSensor( + hass, + config.get(CONF_NAME), + config.get(CONF_STATE_TOPIC), + config.get(CONF_DEVICE_ID), + config.get(CONF_TIMEOUT) + )]) + + +# pylint: disable=too-many-arguments, too-many-instance-attributes +class MQTTRoomSensor(Entity): + """Representation of a room sensor that is updated via MQTT.""" + + def __init__(self, hass, name, state_topic, device_id, timeout): + """Initialize the sensor.""" + self._state = STATE_UNKNOWN + self._hass = hass + self._name = name + self._state_topic = state_topic + '/+' + self._device_id = slugify(device_id).upper() + self._timeout = timeout + self._distance = None + self._updated = None + + def update_state(device_id, room, distance): + """Update the sensor state.""" + self._state = room + self._distance = distance + self._updated = dt.utcnow() + + self.update_ha_state() + + def message_received(topic, payload, qos): + """A new MQTT message has been received.""" + try: + data = MQTT_PAYLOAD(payload) + except vol.MultipleInvalid as error: + _LOGGER.debug('skipping update because of malformatted ' + 'data: %s', error) + return + + device = _parse_update_data(topic, data) + if device.get('device_id') == self._device_id: + if self._distance is None or self._updated is None: + update_state(**device) + else: + # update if: + # device is in the same room OR + # device is closer to another room OR + # last update from other room was too long ago + timediff = dt.utcnow() - self._updated + if device.get('room') == self._state \ + or device.get('distance') < self._distance \ + or timediff.seconds >= self._timeout: + update_state(**device) + + mqtt.subscribe(hass, self._state_topic, message_received, 1) + + @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 device_state_attributes(self): + """Return the state attributes.""" + return { + 'distance': self._distance + } + + @property + def state(self): + """Return the current room of the entity.""" + return self._state + + +def _parse_update_data(topic, data): + """Parse the room presence update.""" + parts = topic.split('/') + room = parts[-1] + device_id = slugify(data.get('id')).upper() + distance = data.get('distance') + parsed_data = { + 'device_id': device_id, + 'room': room, + 'distance': distance + } + return parsed_data diff --git a/tests/components/sensor/test_mqtt_room.py b/tests/components/sensor/test_mqtt_room.py new file mode 100644 index 00000000000000..f5e9202234ef22 --- /dev/null +++ b/tests/components/sensor/test_mqtt_room.py @@ -0,0 +1,107 @@ +"""The tests for the MQTT room presence sensor.""" +import json +import datetime +import unittest +from unittest.mock import patch + +import homeassistant.components.sensor as sensor +from homeassistant.components.mqtt import (CONF_STATE_TOPIC, CONF_QOS, + DEFAULT_QOS) +from homeassistant.const import (CONF_NAME, CONF_PLATFORM) +from homeassistant.util import dt + +from tests.common import ( + get_test_home_assistant, mock_mqtt_component, fire_mqtt_message) + +DEVICE_ID = '123TESTMAC' +NAME = 'test_device' +BEDROOM = 'bedroom' +LIVING_ROOM = 'living_room' + +BEDROOM_TOPIC = "room_presence/{}".format(BEDROOM) +LIVING_ROOM_TOPIC = "room_presence/{}".format(LIVING_ROOM) + +SENSOR_STATE = "sensor.{}".format(NAME) + +CONF_DEVICE_ID = 'device_id' +CONF_TIMEOUT = 'timeout' + +NEAR_MESSAGE = { + 'id': DEVICE_ID, + 'name': NAME, + 'distance': 1 +} + +FAR_MESSAGE = { + 'id': DEVICE_ID, + 'name': NAME, + 'distance': 10 +} + +REALLY_FAR_MESSAGE = { + 'id': DEVICE_ID, + 'name': NAME, + 'distance': 20 +} + + +class TestMQTTRoomSensor(unittest.TestCase): + """Test the room presence sensor.""" + + def setup_method(self, method): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + mock_mqtt_component(self.hass) + self.assertTrue(sensor.setup(self.hass, { + sensor.DOMAIN: { + CONF_PLATFORM: 'mqtt_room', + CONF_NAME: NAME, + CONF_DEVICE_ID: DEVICE_ID, + CONF_STATE_TOPIC: 'room_presence', + CONF_QOS: DEFAULT_QOS, + CONF_TIMEOUT: 5 + }})) + + # Clear state between tests + self.hass.states.set(SENSOR_STATE, None) + + def teardown_method(self, method): + """Stop everything that was started.""" + self.hass.stop() + + def send_message(self, topic, message): + """Test the sending of a message.""" + fire_mqtt_message( + self.hass, topic, json.dumps(message)) + self.hass.pool.block_till_done() + + def assert_state(self, room): + """Test the assertion of a room state.""" + state = self.hass.states.get(SENSOR_STATE) + self.assertEqual(state.state, room) + + def assert_distance(self, distance): + """Test the assertion of a distance state.""" + state = self.hass.states.get(SENSOR_STATE) + self.assertEqual(state.attributes.get('distance'), distance) + + def test_room_update(self): + """Test the updating between rooms.""" + self.send_message(BEDROOM_TOPIC, FAR_MESSAGE) + self.assert_state(BEDROOM) + self.assert_distance(10) + + self.send_message(LIVING_ROOM_TOPIC, NEAR_MESSAGE) + self.assert_state(LIVING_ROOM) + self.assert_distance(1) + + self.send_message(BEDROOM_TOPIC, FAR_MESSAGE) + self.assert_state(LIVING_ROOM) + self.assert_distance(1) + + time = dt.utcnow() + datetime.timedelta(seconds=7) + with patch('homeassistant.helpers.condition.dt_util.utcnow', + return_value=time): + self.send_message(BEDROOM_TOPIC, FAR_MESSAGE) + self.assert_state(BEDROOM) + self.assert_distance(10)