From 3aa34deaa2ad1025a8c14a615f0c2e18df0efede Mon Sep 17 00:00:00 2001 From: Dan Smith Date: Thu, 11 Feb 2016 17:10:34 +0000 Subject: [PATCH 1/4] Add state_as_number() helper This adds state_as_number(), a helper method that tries to interpret state as a number, for cases we can predict. It's a generalization of what is copy-and-paste-ed into multiple other places. --- homeassistant/helpers/state.py | 34 +++++++++++++++++++++++++++-- tests/helpers/test_state.py | 40 ++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/state.py b/homeassistant/helpers/state.py index 9c74d59844e..1a3520d4733 100644 --- a/homeassistant/helpers/state.py +++ b/homeassistant/helpers/state.py @@ -8,8 +8,11 @@ import homeassistant.util.dt as dt_util from homeassistant.const import ( STATE_ON, STATE_OFF, SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PAUSE, - STATE_PLAYING, STATE_PAUSED, ATTR_ENTITY_ID) - + STATE_PLAYING, STATE_PAUSED, ATTR_ENTITY_ID, + STATE_LOCKED, STATE_UNLOCKED, STATE_UNKNOWN, + STATE_OPEN, STATE_CLOSED) +from homeassistant.components.sun import (STATE_ABOVE_HORIZON, + STATE_BELOW_HORIZON) from homeassistant.components.media_player import (SERVICE_PLAY_MEDIA) _LOGGER = logging.getLogger(__name__) @@ -92,3 +95,30 @@ def reproduce_state(hass, states, blocking=False): data = json.loads(service_data) data[ATTR_ENTITY_ID] = entity_ids hass.services.call(service_domain, service, data, blocking) + + +def state_as_number(state): + """Try to coerce our state to a number. + + Raises ValueError if this is not possible. + """ + + if state.state in (STATE_ON, STATE_LOCKED, STATE_ABOVE_HORIZON, + STATE_OPEN): + return 1 + elif state.state in (STATE_OFF, STATE_UNLOCKED, STATE_UNKNOWN, + STATE_BELOW_HORIZON, STATE_CLOSED): + return 0 + else: + try: + # This distinction is probably not important, + # but in case something downstream cares about + # int vs. float, try to be helpful here. + if '.' in state.state: + return float(state.state) + else: + return int(state.state) + except (ValueError, TypeError): + pass + + raise ValueError('State is not a number') diff --git a/tests/helpers/test_state.py b/tests/helpers/test_state.py index f4e28330f7a..e222e72fe4b 100644 --- a/tests/helpers/test_state.py +++ b/tests/helpers/test_state.py @@ -13,6 +13,12 @@ import homeassistant.components as core_components from homeassistant.const import SERVICE_TURN_ON from homeassistant.util import dt as dt_util from homeassistant.helpers import state +from homeassistant.const import ( + STATE_OFF, STATE_OPEN, STATE_CLOSED, + STATE_LOCKED, STATE_UNLOCKED, STATE_UNKNOWN, + STATE_ON, STATE_OFF) +from homeassistant.components.sun import (STATE_ABOVE_HORIZON, + STATE_BELOW_HORIZON) from tests.common import get_test_home_assistant, mock_service @@ -146,3 +152,37 @@ class TestStateHelpers(unittest.TestCase): self.assertEqual(['light.test1', 'light.test2'], last_call.data.get('entity_id')) self.assertEqual(95, last_call.data.get('brightness')) + + def test_as_number_states(self): + zero_states = (STATE_OFF, STATE_CLOSED, STATE_UNLOCKED, + STATE_BELOW_HORIZON) + one_states = (STATE_ON, STATE_OPEN, STATE_LOCKED, STATE_ABOVE_HORIZON) + for _state in zero_states: + self.assertEqual(0, state.state_as_number( + ha.State('domain.test', _state, {}))) + for _state in one_states: + self.assertEqual(1, state.state_as_number( + ha.State('domain.test', _state, {}))) + + def test_as_number_coercion(self): + for _state in ('0', '0.0'): + self.assertEqual( + 0.0, float(state.state_as_number( + ha.State('domain.test', _state, {})))) + for _state in ('1', '1.0'): + self.assertEqual( + 1.0, float(state.state_as_number( + ha.State('domain.test', _state, {})))) + + def test_as_number_tries_to_keep_types(self): + result = state.state_as_number(ha.State('domain.test', '1', {})) + self.assertTrue(isinstance(result, int)) + result = state.state_as_number(ha.State('domain.test', '1.0', {})) + self.assertTrue(isinstance(result, float)) + + def test_as_number_invalid_cases(self): + for _state in ('', 'foo', 'foo.bar', None, False, True, None, + object, object()): + self.assertRaises(ValueError, + state.state_as_number, + ha.State('domain.test', _state, {})) From 4a2b95649389efd61ec5479dae52e8a400e93e0d Mon Sep 17 00:00:00 2001 From: Dan Smith Date: Thu, 11 Feb 2016 17:13:57 +0000 Subject: [PATCH 2/4] Convert statsd, influx, splunk, and graphite to use state_as_number() Fixes #1205 --- homeassistant/components/graphite.py | 15 ++++++--------- homeassistant/components/influxdb.py | 19 ++++++------------- homeassistant/components/splunk.py | 19 +++++-------------- homeassistant/components/statsd.py | 23 ++++++----------------- tests/components/test_graphite.py | 9 +++++++-- 5 files changed, 30 insertions(+), 55 deletions(-) diff --git a/homeassistant/components/graphite.py b/homeassistant/components/graphite.py index e6675df2f80..5b3e4869307 100644 --- a/homeassistant/components/graphite.py +++ b/homeassistant/components/graphite.py @@ -23,8 +23,8 @@ import time from homeassistant.const import ( EVENT_STATE_CHANGED, - EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, - STATE_ON, STATE_OFF) + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) +from homeassistant.helpers import state DOMAIN = "graphite" _LOGGER = logging.getLogger(__name__) @@ -92,13 +92,10 @@ class GraphiteFeeder(threading.Thread): def _report_attributes(self, entity_id, new_state): now = time.time() things = dict(new_state.attributes) - state = new_state.state - if state in (STATE_ON, STATE_OFF): - state = float(state == STATE_ON) - else: - state = None - if state is not None: - things['state'] = state + try: + things['state'] = state.state_as_number(new_state) + except ValueError: + pass lines = ['%s.%s.%s %f %i' % (self._prefix, entity_id, key.replace(' ', '_'), value, now) diff --git a/homeassistant/components/influxdb.py b/homeassistant/components/influxdb.py index 4211b85e7d1..b23e1210063 100644 --- a/homeassistant/components/influxdb.py +++ b/homeassistant/components/influxdb.py @@ -9,10 +9,8 @@ https://home-assistant.io/components/influxdb/ import logging import homeassistant.util as util from homeassistant.helpers import validate_config -from homeassistant.const import (EVENT_STATE_CHANGED, STATE_ON, STATE_OFF, - STATE_UNLOCKED, STATE_LOCKED, STATE_UNKNOWN) -from homeassistant.components.sun import (STATE_ABOVE_HORIZON, - STATE_BELOW_HORIZON) +from homeassistant.helpers import state as state_helper +from homeassistant.const import (EVENT_STATE_CHANGED, STATE_UNKNOWN) _LOGGER = logging.getLogger(__name__) @@ -73,15 +71,10 @@ def setup(hass, config): if state is None or state.state in (STATE_UNKNOWN, ''): return - if state.state in (STATE_ON, STATE_LOCKED, STATE_ABOVE_HORIZON): - _state = 1 - elif state.state in (STATE_OFF, STATE_UNLOCKED, STATE_BELOW_HORIZON): - _state = 0 - else: - try: - _state = float(state.state) - except ValueError: - _state = state.state + try: + _state = state_helper.state_as_number(state) + except ValueError: + _state = state.state measurement = state.attributes.get('unit_of_measurement') if measurement in (None, ''): diff --git a/homeassistant/components/splunk.py b/homeassistant/components/splunk.py index f936326b734..da773cbe0f7 100644 --- a/homeassistant/components/splunk.py +++ b/homeassistant/components/splunk.py @@ -14,10 +14,8 @@ import requests import homeassistant.util as util from homeassistant.helpers import validate_config -from homeassistant.const import (EVENT_STATE_CHANGED, STATE_ON, STATE_OFF, - STATE_UNLOCKED, STATE_LOCKED, STATE_UNKNOWN) -from homeassistant.components.sun import (STATE_ABOVE_HORIZON, - STATE_BELOW_HORIZON) +from homeassistant.helpers import state as state_helper +from homeassistant.const import EVENT_STATE_CHANGED _LOGGER = logging.getLogger(__name__) @@ -64,17 +62,10 @@ def setup(hass, config): if state is None: return - if state.state in (STATE_ON, STATE_LOCKED, STATE_ABOVE_HORIZON): - _state = 1 - elif state.state in (STATE_OFF, STATE_UNLOCKED, STATE_UNKNOWN, - STATE_BELOW_HORIZON): - _state = 0 - else: + try: + _state = state_helper.state_as_number(state) + except ValueError: _state = state.state - try: - _state = float(_state) - except ValueError: - pass json_body = [ { diff --git a/homeassistant/components/statsd.py b/homeassistant/components/statsd.py index caf102ba529..640b703d5d9 100644 --- a/homeassistant/components/statsd.py +++ b/homeassistant/components/statsd.py @@ -8,10 +8,8 @@ https://home-assistant.io/components/statsd/ """ import logging import homeassistant.util as util -from homeassistant.const import (EVENT_STATE_CHANGED, STATE_ON, STATE_OFF, - STATE_UNLOCKED, STATE_LOCKED, STATE_UNKNOWN) -from homeassistant.components.sun import (STATE_ABOVE_HORIZON, - STATE_BELOW_HORIZON) +from homeassistant.const import EVENT_STATE_CHANGED +from homeassistant.helpers import state as state_helper _LOGGER = logging.getLogger(__name__) @@ -61,19 +59,10 @@ def setup(hass, config): if state is None: return - if state.state in (STATE_ON, STATE_LOCKED, STATE_ABOVE_HORIZON): - _state = 1 - elif state.state in (STATE_OFF, STATE_UNLOCKED, STATE_UNKNOWN, - STATE_BELOW_HORIZON): - _state = 0 - else: - _state = state.state - if _state == '': - return - try: - _state = float(_state) - except ValueError: - pass + try: + _state = state_helper.state_as_number(state) + except ValueError: + return if not isinstance(_state, NUM_TYPES): return diff --git a/tests/components/test_graphite.py b/tests/components/test_graphite.py index 2a3c8750d40..720da54930f 100644 --- a/tests/components/test_graphite.py +++ b/tests/components/test_graphite.py @@ -102,15 +102,20 @@ class TestGraphite(unittest.TestCase): @mock.patch('time.time') def test_report_with_string_state(self, mock_time): mock_time.return_value = 12345 + expected = [ + 'ha.entity.foo 1.000000 12345', + 'ha.entity.state 1.000000 12345', + ] state = mock.MagicMock(state='above_horizon', attributes={'foo': 1.0}) with mock.patch.object(self.gf, '_send_to_graphite') as mock_send: self.gf._report_attributes('entity', state) - mock_send.assert_called_once_with('ha.entity.foo 1.000000 12345') + actual = mock_send.call_args_list[0][0][0].split('\n') + self.assertEqual(sorted(expected), sorted(actual)) @mock.patch('time.time') def test_report_with_binary_state(self, mock_time): mock_time.return_value = 12345 - state = mock.MagicMock(state=STATE_ON, attributes={'foo': 1.0}) + state = ha.State('domain.entity', STATE_ON, {'foo': 1.0}) with mock.patch.object(self.gf, '_send_to_graphite') as mock_send: self.gf._report_attributes('entity', state) expected = ['ha.entity.foo 1.000000 12345', From 76df759f4cd76e7ff525b8fa837b3096f8754111 Mon Sep 17 00:00:00 2001 From: Dan Smith Date: Fri, 12 Feb 2016 01:04:28 +0000 Subject: [PATCH 3/4] Add simple statsd tests These are not very amazing, but at least exercise the code a little to make sure I didn't break anything. Hopefully they're useful in the future too. --- tests/components/test_statsd.py | 83 +++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 tests/components/test_statsd.py diff --git a/tests/components/test_statsd.py b/tests/components/test_statsd.py new file mode 100644 index 00000000000..72c61a22f54 --- /dev/null +++ b/tests/components/test_statsd.py @@ -0,0 +1,83 @@ +""" +tests.components.test_statsd +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Tests statsd feeder. +""" +import unittest +from unittest import mock + +import homeassistant.components.statsd as statsd +from homeassistant.const import STATE_ON, STATE_OFF, EVENT_STATE_CHANGED + + +class TestStatsd(unittest.TestCase): + @mock.patch('statsd.Connection') + @mock.patch('statsd.Gauge') + def test_statsd_setup_full(self, mock_gauge, mock_connection): + config = { + 'statsd': { + 'host': 'host', + 'port': 123, + 'sample_rate': 1, + 'prefix': 'foo', + } + } + hass = mock.MagicMock() + self.assertTrue(statsd.setup(hass, config)) + mock_connection.assert_called_once_with(host='host', port=123, + sample_rate=1, + disabled=False) + mock_gauge.assert_called_once_with('foo', + mock_connection.return_value) + self.assertTrue(hass.bus.listen.called) + self.assertEqual(EVENT_STATE_CHANGED, + hass.bus.listen.call_args_list[0][0][0]) + + @mock.patch('statsd.Connection') + @mock.patch('statsd.Gauge') + def test_statsd_setup_defaults(self, mock_gauge, mock_connection): + config = { + 'statsd': { + 'host': 'host', + } + } + hass = mock.MagicMock() + self.assertTrue(statsd.setup(hass, config)) + mock_connection.assert_called_once_with( + host='host', + port=statsd.DEFAULT_PORT, + sample_rate=statsd.DEFAULT_RATE, + disabled=False) + mock_gauge.assert_called_once_with(statsd.DEFAULT_PREFIX, + mock_connection.return_value) + self.assertTrue(hass.bus.listen.called) + + @mock.patch('statsd.Connection') + @mock.patch('statsd.Gauge') + def test_event_listener(self, mock_gauge, mock_connection): + config = { + 'statsd': { + 'host': 'host', + } + } + hass = mock.MagicMock() + statsd.setup(hass, config) + self.assertTrue(hass.bus.listen.called) + handler_method = hass.bus.listen.call_args_list[0][0][1] + + valid = {'1': 1, + '1.0': 1.0, + STATE_ON: 1, + STATE_OFF: 0} + for in_, out in valid.items(): + state = mock.MagicMock(state=in_) + handler_method(mock.MagicMock(data={'new_state': state})) + mock_gauge.return_value.send.assert_called_once_with( + state.entity_id, out) + mock_gauge.return_value.send.reset_mock() + + for invalid in ('foo', '', object): + state = mock.MagicMock(state=invalid) + handler_method(mock.MagicMock(data={'new_state': state})) + self.assertFalse(mock_gauge.return_value.send.called) From 0a904acd4dd163a7c09c5efd62b7b3dd1baebbeb Mon Sep 17 00:00:00 2001 From: Dan Smith Date: Fri, 12 Feb 2016 01:41:21 +0000 Subject: [PATCH 4/4] Add some tests for splunk This also fixes issue #1214, and I think another bug. The splunk code will just take the value of state.state and try to serialize it to json if it can't make it into a number. It did this before I generalized that code. Since json.dumps() will fail on most anything complicated, I think the right thing to do is *not* try to do that. --- homeassistant/components/splunk.py | 4 +- tests/components/test_splunk.py | 93 ++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 tests/components/test_splunk.py diff --git a/homeassistant/components/splunk.py b/homeassistant/components/splunk.py index da773cbe0f7..e9395ca3587 100644 --- a/homeassistant/components/splunk.py +++ b/homeassistant/components/splunk.py @@ -50,7 +50,7 @@ def setup(hass, config): uri_scheme = "https://" else: uri_scheme = "http://" - event_collector = uri_scheme + host + ":" + port + \ + event_collector = uri_scheme + host + ":" + str(port) + \ "/services/collector/event" headers = {'Authorization': 'Splunk ' + token} @@ -65,7 +65,7 @@ def setup(hass, config): try: _state = state_helper.state_as_number(state) except ValueError: - _state = state.state + return json_body = [ { diff --git a/tests/components/test_splunk.py b/tests/components/test_splunk.py new file mode 100644 index 00000000000..18814e9d9f7 --- /dev/null +++ b/tests/components/test_splunk.py @@ -0,0 +1,93 @@ +""" +tests.components.test_splunk +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Tests splunk component. +""" +import unittest +from unittest import mock + +import homeassistant.components.splunk as splunk +from homeassistant.const import STATE_ON, STATE_OFF, EVENT_STATE_CHANGED + + +class TestSplunk(unittest.TestCase): + def test_setup_config_full(self): + config = { + 'splunk': { + 'host': 'host', + 'port': 123, + 'token': 'secret', + 'use_ssl': 'False', + } + } + hass = mock.MagicMock() + self.assertTrue(splunk.setup(hass, config)) + self.assertTrue(hass.bus.listen.called) + self.assertEqual(EVENT_STATE_CHANGED, + hass.bus.listen.call_args_list[0][0][0]) + + def test_setup_config_defaults(self): + config = { + 'splunk': { + 'host': 'host', + 'token': 'secret', + } + } + hass = mock.MagicMock() + self.assertTrue(splunk.setup(hass, config)) + self.assertTrue(hass.bus.listen.called) + self.assertEqual(EVENT_STATE_CHANGED, + hass.bus.listen.call_args_list[0][0][0]) + + + def _setup(self, mock_requests): + self.mock_post = mock_requests.post + self.mock_request_exception = Exception + mock_requests.exceptions.RequestException = self.mock_request_exception + config = { + 'splunk': { + 'host': 'host', + 'token': 'secret', + } + } + self.hass = mock.MagicMock() + splunk.setup(self.hass, config) + self.handler_method = self.hass.bus.listen.call_args_list[0][0][1] + + @mock.patch.object(splunk, 'requests') + @mock.patch('json.dumps') + def test_event_listener(self, mock_dump, mock_requests): + mock_dump.side_effect = lambda x: x + self._setup(mock_requests) + + valid = {'1': 1, + '1.0': 1.0, + STATE_ON: 1, + STATE_OFF: 0} + for in_, out in valid.items(): + state = mock.MagicMock(state=in_, + domain='fake', + object_id='entity', + attributes={}) + event = mock.MagicMock(data={'new_state': state}, + time_fired=12345) + body = [{ + 'domain': 'fake', + 'entity_id': 'entity', + 'attributes': {}, + 'time': '12345', + 'value': out, + }] + payload = {'host': 'http://host:8088/services/collector/event', + 'event': body} + self.handler_method(event) + self.mock_post.assert_called_once_with( + payload['host'], data=payload, + headers={'Authorization': 'Splunk secret'}) + self.mock_post.reset_mock() + + for invalid in ('foo', '', object): + state = mock.MagicMock(state=invalid) + self.handler_method(mock.MagicMock(data={'new_state': state})) + self.assertFalse(self.mock_post.called)