From b9ad19acbf15ca93e1646f47720ac4ee0208dab3 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 2 Dec 2018 17:00:31 +0100 Subject: [PATCH] Add JSON attribute topic to MQTT binary sensor Add MqttAttributes mixin --- .../components/binary_sensor/mqtt.py | 14 ++-- homeassistant/components/mqtt/__init__.py | 65 +++++++++++++++++++ tests/components/binary_sensor/test_mqtt.py | 63 +++++++++++++++++- 3 files changed, 136 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/binary_sensor/mqtt.py b/homeassistant/components/binary_sensor/mqtt.py index acbad0d0419..d2a2be88172 100644 --- a/homeassistant/components/binary_sensor/mqtt.py +++ b/homeassistant/components/binary_sensor/mqtt.py @@ -16,10 +16,10 @@ from homeassistant.const import ( CONF_FORCE_UPDATE, CONF_NAME, CONF_VALUE_TEMPLATE, CONF_PAYLOAD_ON, CONF_PAYLOAD_OFF, CONF_DEVICE_CLASS, CONF_DEVICE) from homeassistant.components.mqtt import ( - ATTR_DISCOVERY_HASH, CONF_STATE_TOPIC, CONF_AVAILABILITY_TOPIC, + ATTR_DISCOVERY_HASH, CONF_AVAILABILITY_TOPIC, CONF_STATE_TOPIC, CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, - MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, - subscription) + MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, + MqttEntityDeviceInfo, subscription) from homeassistant.components.mqtt.discovery import MQTT_DISCOVERY_NEW import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -49,7 +49,8 @@ PLATFORM_SCHEMA = mqtt.MQTT_RO_PLATFORM_SCHEMA.extend({ # This is an exception because MQTT is a message transport, not a protocol vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_DEVICE): mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA, -}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) +}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema).extend( + mqtt.MQTT_JSON_ATTRS_SCHEMA.schema) async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, @@ -76,7 +77,7 @@ async def _async_setup_entity(config, async_add_entities, discovery_hash=None): async_add_entities([MqttBinarySensor(config, discovery_hash)]) -class MqttBinarySensor(MqttAvailability, MqttDiscoveryUpdate, +class MqttBinarySensor(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, BinarySensorDevice): """Representation a binary sensor that is updated by MQTT.""" @@ -94,6 +95,7 @@ class MqttBinarySensor(MqttAvailability, MqttDiscoveryUpdate, qos = config.get(CONF_QOS) device_config = config.get(CONF_DEVICE) + MqttAttributes.__init__(self, config) MqttAvailability.__init__(self, availability_topic, qos, payload_available, payload_not_available) MqttDiscoveryUpdate.__init__(self, discovery_hash, @@ -109,6 +111,7 @@ class MqttBinarySensor(MqttAvailability, MqttDiscoveryUpdate, """Handle updated discovery message.""" config = PLATFORM_SCHEMA(discovery_payload) self._config = config + await self.attributes_discovery_update(config) await self.availability_discovery_update(config) await self._subscribe_topics() self.async_schedule_update_ha_state() @@ -164,6 +167,7 @@ class MqttBinarySensor(MqttAvailability, MqttDiscoveryUpdate, async def async_will_remove_from_hass(self): """Unsubscribe when removed.""" await subscription.async_unsubscribe_topics(self.hass, self._sub_state) + await MqttAttributes.async_will_remove_from_hass(self) await MqttAvailability.async_will_remove_from_hass(self) @property diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 7ff32a79142..11b837113c5 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -6,6 +6,7 @@ https://home-assistant.io/components/mqtt/ """ import asyncio from itertools import groupby +import json import logging from operator import attrgetter import os @@ -70,6 +71,7 @@ CONF_COMMAND_TOPIC = 'command_topic' CONF_AVAILABILITY_TOPIC = 'availability_topic' CONF_PAYLOAD_AVAILABLE = 'payload_available' CONF_PAYLOAD_NOT_AVAILABLE = 'payload_not_available' +CONF_JSON_ATTRS_TOPIC = 'json_attributes_topic' CONF_QOS = 'qos' CONF_RETAIN = 'retain' @@ -224,6 +226,10 @@ MQTT_ENTITY_DEVICE_INFO_SCHEMA = vol.All(vol.Schema({ vol.Optional(CONF_SW_VERSION): cv.string, }), validate_device_has_at_least_one_identifier) +MQTT_JSON_ATTRS_SCHEMA = vol.Schema({ + vol.Optional(CONF_JSON_ATTRS_TOPIC): valid_subscribe_topic, +}) + MQTT_BASE_PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend(SCHEMA_BASE) # Sensor type platforms subscribe to MQTT events @@ -820,6 +826,65 @@ def _match_topic(subscription: str, topic: str) -> bool: return False +class MqttAttributes(Entity): + """Mixin used for platforms that support JSON attributes.""" + + def __init__(self, config: dict) -> None: + """Initialize the JSON attributes mixin.""" + self._attributes = None + self._attributes_sub_state = None + self._attributes_config = config + + async def async_added_to_hass(self) -> None: + """Subscribe MQTT events. + + This method must be run in the event loop and returns a coroutine. + """ + await self._attributes_subscribe_topics() + + async def attributes_discovery_update(self, config: dict): + """Handle updated discovery message.""" + self._attributes_config = config + await self._attributes_subscribe_topics() + + async def _attributes_subscribe_topics(self): + """(Re)Subscribe to topics.""" + from .subscription import async_subscribe_topics + + @callback + def attributes_message_received(topic: str, + payload: SubscribePayloadType, + qos: int) -> None: + try: + json_dict = json.loads(payload) + if isinstance(json_dict, dict): + self._attributes = json_dict + self.async_schedule_update_ha_state() + else: + _LOGGER.debug("JSON result was not a dictionary") + self._attributes = None + except ValueError: + _LOGGER.debug("Erroneous JSON: %s", payload) + self._attributes = None + + self._attributes_sub_state = await async_subscribe_topics( + self.hass, self._attributes_sub_state, + {'attributes_topic': { + 'topic': self._attributes_config.get(CONF_JSON_ATTRS_TOPIC), + 'msg_callback': attributes_message_received, + 'qos': self._attributes_config.get(CONF_QOS)}}) + + async def async_will_remove_from_hass(self): + """Unsubscribe when removed.""" + from .subscription import async_unsubscribe_topics + await async_unsubscribe_topics(self.hass, self._attributes_sub_state) + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self._attributes + + class MqttAvailability(Entity): """Mixin used for platforms that report availability.""" diff --git a/tests/components/binary_sensor/test_mqtt.py b/tests/components/binary_sensor/test_mqtt.py index 71d179211a2..a4357eefed8 100644 --- a/tests/components/binary_sensor/test_mqtt.py +++ b/tests/components/binary_sensor/test_mqtt.py @@ -1,7 +1,7 @@ """The tests for the MQTT binary sensor platform.""" import json import unittest -from unittest.mock import Mock +from unittest.mock import Mock, patch from datetime import timedelta import homeassistant.core as ha @@ -256,6 +256,67 @@ class TestSensorMQTT(unittest.TestCase): assert STATE_OFF == state.state assert 3 == len(events) + def test_setting_sensor_attribute_via_mqtt_json_message(self): + """Test the setting of attribute via MQTT with JSON payload.""" + mock_component(self.hass, 'mqtt') + assert setup_component(self.hass, binary_sensor.DOMAIN, { + binary_sensor.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'test-topic', + 'json_attributes_topic': 'attr-topic' + } + }) + + fire_mqtt_message(self.hass, 'attr-topic', '{ "val": "100" }') + self.hass.block_till_done() + state = self.hass.states.get('binary_sensor.test') + + assert '100' == \ + state.attributes.get('val') + + @patch('homeassistant.components.mqtt._LOGGER') + def test_update_with_json_attrs_not_dict(self, mock_logger): + """Test attributes get extracted from a JSON result.""" + mock_component(self.hass, 'mqtt') + assert setup_component(self.hass, binary_sensor.DOMAIN, { + binary_sensor.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'test-topic', + 'json_attributes_topic': 'attr-topic' + } + }) + + fire_mqtt_message(self.hass, 'attr-topic', '[ "list", "of", "things"]') + self.hass.block_till_done() + state = self.hass.states.get('binary_sensor.test') + + assert state.attributes.get('val') is None + mock_logger.debug.assert_called_with( + 'JSON result was not a dictionary') + + @patch('homeassistant.components.mqtt._LOGGER') + def test_update_with_json_attrs_bad_JSON(self, mock_logger): + """Test attributes get extracted from a JSON result.""" + mock_component(self.hass, 'mqtt') + assert setup_component(self.hass, binary_sensor.DOMAIN, { + binary_sensor.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'test-topic', + 'json_attributes_topic': 'attr-topic' + } + }) + + fire_mqtt_message(self.hass, 'attr-topic', 'This is not JSON') + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.test') + assert state.attributes.get('val') is None + mock_logger.debug.assert_called_with( + 'Erroneous JSON: %s', 'This is not JSON') + async def test_unique_id(hass): """Test unique id option only creates one sensor per unique_id."""