forked from home-assistant/core
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
MQTT room presence detection (home-assistant#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 tests
- Loading branch information
Showing
3 changed files
with
247 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) |