MQTT room presence detection (#2913)
* Added room presence tracker * Fixed room/device discovery bugs * Added tests for room tracker * Fixed some formatting mistakes * Fixed a tiny bug with the track new option * Converted device tracker into sensor * Removed leftover service entry * Changed name to mqtt_room * Changed payload validation to voluptuous * Fixed validation * Removed sleep from testspull/2915/merge
parent
46dcfb3d70
commit
6aa0789e38
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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)
|
Loading…
Reference in New Issue