Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

MQTT room presence detection #2913

Merged
merged 11 commits into from
Aug 21, 2016
1 change: 1 addition & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,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
Expand Down
139 changes: 139 additions & 0 deletions homeassistant/components/sensor/mqtt_room.py
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
107 changes: 107 additions & 0 deletions tests/components/sensor/test_mqtt_room.py
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)