From c91d52a5875818a794f6710f394ab8bd80203bdf Mon Sep 17 00:00:00 2001 From: Derek Brooks Date: Sat, 11 Nov 2017 00:22:37 -0600 Subject: [PATCH 001/238] first stab at the nuheat components --- homeassistant/components/climate/nuheat.py | 208 +++++++++++++++++++++ homeassistant/components/nuheat.py | 47 +++++ requirements_all.txt | 3 + tests/components/climate/test_nuheat.py | 187 ++++++++++++++++++ 4 files changed, 445 insertions(+) create mode 100644 homeassistant/components/climate/nuheat.py create mode 100644 homeassistant/components/nuheat.py create mode 100644 tests/components/climate/test_nuheat.py diff --git a/homeassistant/components/climate/nuheat.py b/homeassistant/components/climate/nuheat.py new file mode 100644 index 00000000000..60a253e9b7c --- /dev/null +++ b/homeassistant/components/climate/nuheat.py @@ -0,0 +1,208 @@ +""" +Support for NuHeat thermostats. + +For more details about this platform, please refer to the documentation at +""" +import logging +from datetime import timedelta + +from homeassistant.components.climate import ( + ClimateDevice, + STATE_HEAT, + STATE_IDLE) +from homeassistant.const import ( + ATTR_TEMPERATURE, + TEMP_CELSIUS, + TEMP_FAHRENHEIT) +from homeassistant.util import Throttle + +DEPENDENCIES = ["nuheat"] + +_LOGGER = logging.getLogger(__name__) + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) + +MODE_AUTO = "auto" # Run device schedule +MODE_AWAY = "away" +MODE_HOLD_TEMPERATURE = "temperature" +MODE_TEMPORARY_HOLD = "temporary_temperature" +# TODO: offline? + +OPERATION_LIST = [STATE_HEAT, STATE_IDLE] + +SCHEDULE_HOLD = 3 +SCHEDULE_RUN = 1 +SCHEDULE_TEMPORARY_HOLD = 2 + + +def setup_platform(hass, config, add_devices, discovery_info=None): + if discovery_info is None: + return + + _LOGGER.info("Loading NuHeat thermostat component") + + temperature_unit = hass.config.units.temperature_unit + _LOGGER.debug("temp_unit is %s", temperature_unit) + api, serial_numbers = hass.data[DATA_NUHEAT] + + thermostats = [ + NuHeatThermostat(api, serial_number, temperature_unit) + for serial_number in serial_numbers + ] + add_devices(thermostats, True) + + +class NuHeatThermostat(ClimateDevice): + """Representation of a NuHeat Thermostat.""" + def __init__(self, api, serial_number, temperature_unit): + self._thermostat = api.get_thermostat(serial_number) + self._temperature_unit = temperature_unit + self._force_update = False + + @property + def name(self): + """Return the name of the thermostat.""" + return self._thermostat.room + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + if self._temperature_unit == "C": + return TEMP_CELSIUS + + return TEMP_FAHRENHEIT + + @property + def current_temperature(self): + """Return the current temperature.""" + if self._temperature_unit == "C": + return self._thermostat.celsius + + return self._thermostat.fahrenheit + + @property + def current_operation(self): + """Return current operation. ie. heat, idle.""" + if self._thermostat.heating: + return STATE_HEAT + + return STATE_IDLE + + @property + def min_temp(self): + """Return the minimum supported temperature for the thermostat.""" + if self._temperature_unit == "C": + return self._thermostat.min_celsius + + return self._thermostat.min_fahrenheit + + @property + def max_temp(self): + """Return the maximum supported temperature for the thermostat.""" + if self._temperature_unit == "C": + return self._thermostat.max_celsius + + return self._thermostat.max_fahrenheit + + @property + def target_temperature(self): + """Return the currently programmed temperature.""" + if self._temperature_unit == "C": + return self._thermostat.target_celsius + + return self._thermostat.target_fahrenheit + + @property + def target_temperature_low(self): + """Return the lower bound temperature we try to reach.""" + return self.target_temperature + + @property + def target_temperature_high(self): + """Return the upper bound temperature we try to reach.""" + return self.target_temperature + + @property + def current_hold_mode(self): + """Return current hold mode.""" + if self.is_away_mode_on: + return MODE_AWAY + + schedule_mode = self._thermostat.schedule_mode + if schedule_mode == SCHEDULE_RUN: + return MODE_AUTO + + if schedule_mode == SCHEDULE_HOLD: + return MODE_HOLD_TEMPERATURE + + if schedule_mode == SCHEDULE_TEMPORARY_HOLD: + return MODE_TEMPORARY_HOLD + + return MODE_AUTO + + @property + def operation_list(self): + """Return list of possible operation modes.""" + return OPERATION_LIST + + @property + def is_away_mode_on(self): + """ + Return true if away mode is on. + + Away mode is determined by setting and HOLDing the target temperature + to the minimum temperature supported. + """ + if self._thermostat.target_celsius > self._thermostat.min_celsius: + return False + + if self._thermostat.schedule_mode != SCHEDULE_HOLD: + return False + + return True + + def turn_away_mode_on(self): + """Turn away mode on.""" + if self.is_away_mode_on: + return + + kwargs = {} + kwargs[ATTR_TEMPERATURE] = self.min_temp + + self.set_temperature(**kwargs) + self._force_update = True + + def turn_away_mode_off(self): + """Turn away mode off.""" + if not self.is_away_mode_on: + return + + self._thermostat.resume_schedule() + self._force_update = True + + def set_temperature(self, **kwargs): + """Set a new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if self._temperature_unit == "C": + self._thermostat.target_celsius = temperature + else: + self._thermostat.target_fahrenheit = temperature + + _LOGGER.info( + "Setting NuHeat thermostat temperature to %s %s", + temperature, self.temperature_unit) + + self._force_update = True + + def update(self): + """Get the latest state from the thermostat.""" + if self._force_update: + self._throttled_update(no_throttle=True) + self._force_update = False + else: + self._throttled_update() + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def _throttled_update(self): + """Get the latest state from the thermostat... but throttled!""" + self._thermostat.get_data() diff --git a/homeassistant/components/nuheat.py b/homeassistant/components/nuheat.py new file mode 100644 index 00000000000..969afe1ee48 --- /dev/null +++ b/homeassistant/components/nuheat.py @@ -0,0 +1,47 @@ +""" +Support for NuHeat thermostats. + +For more details about this platform, please refer to the documentation at +""" +import logging + +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, CONF_DEVICES +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import discovery + +REQUIREMENTS = ["nuheat==0.2.0"] + +_LOGGER = logging.getLogger(__name__) + +DATA_NUHEAT = "nuheat" + +DOMAIN = "nuheat" + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_DEVICES): vol.All(cv.ensure_list, cv.string) + }), +}, extra=vol.ALLOW_EXTRA) + +def setup(hass, config): + """Set up the NuHeat thermostat component.""" + import nuheat + + conf = config[DOMAIN] + username = conf.get(CONF_USERNAME) + password = conf.get(CONF_PASSWORD) + devices = conf.get(CONF_DEVICES) + + api = nuheat.NuHeat(username, password) + api.authenticate() + hass.data[DATA_NUHEAT] = (api, devices) + + discovery.load_platform(hass, "climate", DOMAIN, {}, config) + _LOGGER.debug("NuHeat initialized") + return True diff --git a/requirements_all.txt b/requirements_all.txt index dec8f96f39a..ef8c952f485 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -484,6 +484,9 @@ neurio==0.3.1 # homeassistant.components.sensor.nederlandse_spoorwegen nsapi==2.7.4 +# homeassistant.components.nuheat +nuheat==0.2.0 + # homeassistant.components.binary_sensor.trend # homeassistant.components.image_processing.opencv numpy==1.13.3 diff --git a/tests/components/climate/test_nuheat.py b/tests/components/climate/test_nuheat.py new file mode 100644 index 00000000000..33a1c3e02f7 --- /dev/null +++ b/tests/components/climate/test_nuheat.py @@ -0,0 +1,187 @@ +"""The test for the NuHeat thermostat module.""" +import unittest +from unittest.mock import PropertyMock, Mock, patch + +from homeassistant.components.climate import STATE_HEAT, STATE_IDLE +import homeassistant.components.climate.nuheat as nuheat +from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT + +SCHEDULE_HOLD = 3 +SCHEDULE_RUN = 1 +SCHEDULE_TEMPORARY_HOLD = 2 + + +class TestNuHeat(unittest.TestCase): + """Tests for NuHeat climate.""" + + def setUp(self): + + serial_number = "12345" + temperature_unit = "F" + + thermostat = Mock( + serial_number=serial_number, + room="Master bathroom", + online=True, + heating=True, + temperature=2222, + celsius=22, + fahrenheit=72, + max_celsius=69, + max_fahrenheit=157, + min_celsius=5, + min_fahrenheit=41, + schedule_mode=SCHEDULE_RUN, + target_celsius=22, + target_fahrenheit=72) + + api = Mock() + api.get_thermostat.return_value = thermostat + + self.thermostat = nuheat.NuHeatThermostat( + api, serial_number, temperature_unit) + + def test_name(self): + """Test name property.""" + self.assertEqual(self.thermostat.name, "Master bathroom") + + def test_temperature_unit(self): + """Test temperature unit.""" + self.assertEqual(self.thermostat.temperature_unit, TEMP_FAHRENHEIT) + + self.thermostat._temperature_unit = "C" + self.assertEqual(self.thermostat.temperature_unit, TEMP_CELSIUS) + + def test_current_temperature(self): + """Test current temperature.""" + self.assertEqual(self.thermostat.current_temperature, 72) + + self.thermostat._temperature_unit = "C" + self.assertEqual(self.thermostat.current_temperature, 22) + + def test_current_operation(self): + """Test current operation.""" + self.assertEqual(self.thermostat.current_operation, STATE_HEAT) + + self.thermostat._thermostat.heating = False + self.assertEqual(self.thermostat.current_operation, STATE_IDLE) + + def test_min_temp(self): + """Test min temp.""" + self.assertEqual(self.thermostat.min_temp, 41) + + self.thermostat._temperature_unit = "C" + self.assertEqual(self.thermostat.min_temp, 5) + + def test_max_temp(self): + """Test max temp.""" + self.assertEqual(self.thermostat.max_temp, 157) + + self.thermostat._temperature_unit = "C" + self.assertEqual(self.thermostat.max_temp, 69) + + def test_target_temperature(self): + """Test target temperature.""" + self.assertEqual(self.thermostat.target_temperature, 72) + + self.thermostat._temperature_unit = "C" + self.assertEqual(self.thermostat.target_temperature, 22) + + + def test_target_temperature_low(self): + """Test low target temperature.""" + self.assertEqual(self.thermostat.target_temperature_low, 72) + + self.thermostat._temperature_unit = "C" + self.assertEqual(self.thermostat.target_temperature_low, 22) + + def test_target_temperature_high(self): + """Test high target temperature.""" + self.assertEqual(self.thermostat.target_temperature_high, 72) + + self.thermostat._temperature_unit = "C" + self.assertEqual(self.thermostat.target_temperature_high, 22) + + @patch.object( + nuheat.NuHeatThermostat, "is_away_mode_on", new_callable=PropertyMock) + def test_current_hold_mode_away(self, is_away_mode_on): + """Test current hold mode while away.""" + is_away_mode_on.return_value = True + self.assertEqual(self.thermostat.current_hold_mode, nuheat.MODE_AWAY) + + @patch.object( + nuheat.NuHeatThermostat, "is_away_mode_on", new_callable=PropertyMock) + def test_current_hold_mode(self, is_away_mode_on): + """Test current hold mode.""" + is_away_mode_on.return_value = False + + self.thermostat._thermostat.schedule_mode = SCHEDULE_RUN + self.assertEqual(self.thermostat.current_hold_mode, nuheat.MODE_AUTO) + + self.thermostat._thermostat.schedule_mode = SCHEDULE_HOLD + self.assertEqual( + self.thermostat.current_hold_mode, nuheat.MODE_HOLD_TEMPERATURE) + + self.thermostat._thermostat.schedule_mode = SCHEDULE_TEMPORARY_HOLD + self.assertEqual( + self.thermostat.current_hold_mode, nuheat.MODE_TEMPORARY_HOLD) + + def test_is_away_mode_on(self): + """Test is away mode on.""" + _thermostat = self.thermostat._thermostat + _thermostat.target_celsius = _thermostat.min_celsius + _thermostat.schedule_mode = SCHEDULE_HOLD + self.assertTrue(self.thermostat.is_away_mode_on) + + _thermostat.target_celsius = _thermostat.min_celsius + 1 + self.assertFalse(self.thermostat.is_away_mode_on) + + _thermostat.target_celsius = _thermostat.min_celsius + _thermostat.schedule_mode = SCHEDULE_RUN + self.assertFalse(self.thermostat.is_away_mode_on) + + @patch.object( + nuheat.NuHeatThermostat, "is_away_mode_on", new_callable=PropertyMock) + @patch.object(nuheat.NuHeatThermostat, "set_temperature") + def test_turn_away_mode_on_while_home(self, set_temp, is_away_mode_on): + """Test turn away mode on when not away.""" + is_away_mode_on.return_value = False + self.thermostat.turn_away_mode_on() + set_temp.assert_called_once_with(temperature=self.thermostat.min_temp) + self.assertTrue(self.thermostat._force_update) + + @patch.object( + nuheat.NuHeatThermostat, "is_away_mode_on", new_callable=PropertyMock) + @patch.object(nuheat.NuHeatThermostat, "set_temperature") + def test_turn_away_mode_on_while_away(self, set_temp, is_away_mode_on): + """Test turn away mode on when away.""" + is_away_mode_on.return_value = True + self.thermostat.turn_away_mode_on() + set_temp.assert_not_called() + + def test_set_temperature(self): + """Test set temperature.""" + self.thermostat.set_temperature(temperature=85) + self.assertEqual(self.thermostat._thermostat.target_fahrenheit, 85) + self.assertTrue(self.thermostat._force_update) + + self.thermostat._temperature_unit = "C" + self.thermostat.set_temperature(temperature=23) + self.assertEqual(self.thermostat._thermostat.target_celsius, 23) + self.assertTrue(self.thermostat._force_update) + + @patch.object(nuheat.NuHeatThermostat, "_throttled_update") + def test_forced_update(self, throttled_update): + """Test update without throttle.""" + self.thermostat._force_update = True + self.thermostat.update() + throttled_update.assert_called_once_with(no_throttle=True) + self.assertFalse(self.thermostat._force_update) + + @patch.object(nuheat.NuHeatThermostat, "_throttled_update") + def test_throttled_update(self, throttled_update): + """Test update with throttle.""" + self.thermostat._force_update = False + self.thermostat.update() + throttled_update.assert_called_once_with() + self.assertFalse(self.thermostat._force_update) From 5fe2db228c7e8be9f9ede27b1a9743f4f1c282bc Mon Sep 17 00:00:00 2001 From: Derek Brooks Date: Sat, 11 Nov 2017 10:18:32 -0600 Subject: [PATCH 002/238] bug fixes and linting --- .coveragerc | 3 ++ homeassistant/components/climate/nuheat.py | 14 ++++------ homeassistant/components/nuheat.py | 5 ++-- tests/components/climate/test_nuheat.py | 32 +++++++++++++++------- 4 files changed, 33 insertions(+), 21 deletions(-) diff --git a/.coveragerc b/.coveragerc index 3bfd983dc30..4de7d138f71 100644 --- a/.coveragerc +++ b/.coveragerc @@ -143,6 +143,9 @@ omit = homeassistant/components/netatmo.py homeassistant/components/*/netatmo.py + homeassistant/components/nuheat.py + homeassistant/components/*/nuheat.py + homeassistant/components/octoprint.py homeassistant/components/*/octoprint.py diff --git a/homeassistant/components/climate/nuheat.py b/homeassistant/components/climate/nuheat.py index 60a253e9b7c..63369ed5769 100644 --- a/homeassistant/components/climate/nuheat.py +++ b/homeassistant/components/climate/nuheat.py @@ -10,6 +10,7 @@ from homeassistant.components.climate import ( ClimateDevice, STATE_HEAT, STATE_IDLE) +from homeassistant.components.nuheat import DATA_NUHEAT from homeassistant.const import ( ATTR_TEMPERATURE, TEMP_CELSIUS, @@ -26,7 +27,6 @@ MODE_AUTO = "auto" # Run device schedule MODE_AWAY = "away" MODE_HOLD_TEMPERATURE = "temperature" MODE_TEMPORARY_HOLD = "temporary_temperature" -# TODO: offline? OPERATION_LIST = [STATE_HEAT, STATE_IDLE] @@ -36,15 +36,13 @@ SCHEDULE_TEMPORARY_HOLD = 2 def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the NuHeat thermostat(s).""" if discovery_info is None: return - _LOGGER.info("Loading NuHeat thermostat component") - + _LOGGER.info("Loading NuHeat thermostat climate component") temperature_unit = hass.config.units.temperature_unit - _LOGGER.debug("temp_unit is %s", temperature_unit) api, serial_numbers = hass.data[DATA_NUHEAT] - thermostats = [ NuHeatThermostat(api, serial_number, temperature_unit) for serial_number in serial_numbers @@ -77,7 +75,7 @@ class NuHeatThermostat(ClimateDevice): """Return the current temperature.""" if self._temperature_unit == "C": return self._thermostat.celsius - + return self._thermostat.fahrenheit @property @@ -158,7 +156,7 @@ class NuHeatThermostat(ClimateDevice): if self._thermostat.schedule_mode != SCHEDULE_HOLD: return False - + return True def turn_away_mode_on(self): @@ -203,6 +201,6 @@ class NuHeatThermostat(ClimateDevice): self._throttled_update() @Throttle(MIN_TIME_BETWEEN_UPDATES) - def _throttled_update(self): + def _throttled_update(self, **kwargs): """Get the latest state from the thermostat... but throttled!""" self._thermostat.get_data() diff --git a/homeassistant/components/nuheat.py b/homeassistant/components/nuheat.py index 969afe1ee48..a3e110b71da 100644 --- a/homeassistant/components/nuheat.py +++ b/homeassistant/components/nuheat.py @@ -5,8 +5,6 @@ For more details about this platform, please refer to the documentation at """ import logging -from datetime import timedelta - import voluptuous as vol from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, CONF_DEVICES @@ -25,7 +23,8 @@ CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_DEVICES): vol.All(cv.ensure_list, cv.string) + vol.Required(CONF_DEVICES, default=[]): + vol.All(cv.ensure_list, [cv.string]), }), }, extra=vol.ALLOW_EXTRA) diff --git a/tests/components/climate/test_nuheat.py b/tests/components/climate/test_nuheat.py index 33a1c3e02f7..c1b86d5d9e1 100644 --- a/tests/components/climate/test_nuheat.py +++ b/tests/components/climate/test_nuheat.py @@ -13,6 +13,7 @@ SCHEDULE_TEMPORARY_HOLD = 2 class TestNuHeat(unittest.TestCase): """Tests for NuHeat climate.""" + # pylint: disable=protected-access, no-self-use def setUp(self): @@ -41,6 +42,25 @@ class TestNuHeat(unittest.TestCase): self.thermostat = nuheat.NuHeatThermostat( api, serial_number, temperature_unit) + @patch("homeassistant.components.climate.nuheat.NuHeatThermostat") + def test_setup_platform(self, mocked_thermostat): + """Test setup_platform.""" + api = Mock() + data = {"nuheat": (api, ["12345"])} + + hass = Mock() + hass.config.units.temperature_unit.return_value = "F" + hass.data = Mock() + hass.data.__getitem__ = Mock(side_effect=data.__getitem__) + + config = {} + add_devices = Mock() + discovery_info = {} + + nuheat.setup_platform(hass, config, add_devices, discovery_info) + thermostats = [mocked_thermostat(api, "12345", "F")] + add_devices.assert_called_once_with(thermostats, True) + def test_name(self): """Test name property.""" self.assertEqual(self.thermostat.name, "Master bathroom") @@ -48,42 +68,36 @@ class TestNuHeat(unittest.TestCase): def test_temperature_unit(self): """Test temperature unit.""" self.assertEqual(self.thermostat.temperature_unit, TEMP_FAHRENHEIT) - self.thermostat._temperature_unit = "C" self.assertEqual(self.thermostat.temperature_unit, TEMP_CELSIUS) def test_current_temperature(self): """Test current temperature.""" self.assertEqual(self.thermostat.current_temperature, 72) - self.thermostat._temperature_unit = "C" self.assertEqual(self.thermostat.current_temperature, 22) def test_current_operation(self): """Test current operation.""" self.assertEqual(self.thermostat.current_operation, STATE_HEAT) - self.thermostat._thermostat.heating = False self.assertEqual(self.thermostat.current_operation, STATE_IDLE) def test_min_temp(self): """Test min temp.""" self.assertEqual(self.thermostat.min_temp, 41) - self.thermostat._temperature_unit = "C" self.assertEqual(self.thermostat.min_temp, 5) def test_max_temp(self): """Test max temp.""" self.assertEqual(self.thermostat.max_temp, 157) - self.thermostat._temperature_unit = "C" self.assertEqual(self.thermostat.max_temp, 69) def test_target_temperature(self): """Test target temperature.""" self.assertEqual(self.thermostat.target_temperature, 72) - self.thermostat._temperature_unit = "C" self.assertEqual(self.thermostat.target_temperature, 22) @@ -91,14 +105,12 @@ class TestNuHeat(unittest.TestCase): def test_target_temperature_low(self): """Test low target temperature.""" self.assertEqual(self.thermostat.target_temperature_low, 72) - self.thermostat._temperature_unit = "C" self.assertEqual(self.thermostat.target_temperature_low, 22) def test_target_temperature_high(self): """Test high target temperature.""" self.assertEqual(self.thermostat.target_temperature_high, 72) - self.thermostat._temperature_unit = "C" self.assertEqual(self.thermostat.target_temperature_high, 22) @@ -143,7 +155,7 @@ class TestNuHeat(unittest.TestCase): @patch.object( nuheat.NuHeatThermostat, "is_away_mode_on", new_callable=PropertyMock) @patch.object(nuheat.NuHeatThermostat, "set_temperature") - def test_turn_away_mode_on_while_home(self, set_temp, is_away_mode_on): + def test_turn_away_mode_on_home(self, set_temp, is_away_mode_on): """Test turn away mode on when not away.""" is_away_mode_on.return_value = False self.thermostat.turn_away_mode_on() @@ -153,7 +165,7 @@ class TestNuHeat(unittest.TestCase): @patch.object( nuheat.NuHeatThermostat, "is_away_mode_on", new_callable=PropertyMock) @patch.object(nuheat.NuHeatThermostat, "set_temperature") - def test_turn_away_mode_on_while_away(self, set_temp, is_away_mode_on): + def test_turn_away_mode_on_away(self, set_temp, is_away_mode_on): """Test turn away mode on when away.""" is_away_mode_on.return_value = True self.thermostat.turn_away_mode_on() From 37be81c20c0c80a57331beb7dabb70c01339610a Mon Sep 17 00:00:00 2001 From: Derek Brooks Date: Sat, 11 Nov 2017 16:21:14 -0600 Subject: [PATCH 003/238] add ability to resume program... and add in a forgotten test --- homeassistant/components/climate/nuheat.py | 5 ++++ tests/components/climate/test_nuheat.py | 28 ++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/homeassistant/components/climate/nuheat.py b/homeassistant/components/climate/nuheat.py index 63369ed5769..ff1d1158ad1 100644 --- a/homeassistant/components/climate/nuheat.py +++ b/homeassistant/components/climate/nuheat.py @@ -175,6 +175,11 @@ class NuHeatThermostat(ClimateDevice): if not self.is_away_mode_on: return + self.resume_program() + self._force_update = True + + def resume_program(self): + """Resume the thermostat's programmed schedule.""" self._thermostat.resume_schedule() self._force_update = True diff --git a/tests/components/climate/test_nuheat.py b/tests/components/climate/test_nuheat.py index c1b86d5d9e1..83dbfac8449 100644 --- a/tests/components/climate/test_nuheat.py +++ b/tests/components/climate/test_nuheat.py @@ -36,6 +36,8 @@ class TestNuHeat(unittest.TestCase): target_celsius=22, target_fahrenheit=72) + thermostat.resume_schedule = Mock() + api = Mock() api.get_thermostat.return_value = thermostat @@ -171,6 +173,32 @@ class TestNuHeat(unittest.TestCase): self.thermostat.turn_away_mode_on() set_temp.assert_not_called() + @patch.object( + nuheat.NuHeatThermostat, "is_away_mode_on", new_callable=PropertyMock) + @patch.object(nuheat.NuHeatThermostat, "resume_program") + def test_turn_away_mode_off_home(self, resume, is_away_mode_on): + """Test turn away mode off when home.""" + is_away_mode_on.return_value = False + self.thermostat.turn_away_mode_off() + self.assertFalse(self.thermostat._force_update) + resume.assert_not_called() + + @patch.object( + nuheat.NuHeatThermostat, "is_away_mode_on", new_callable=PropertyMock) + @patch.object(nuheat.NuHeatThermostat, "resume_program") + def test_turn_away_mode_off_away(self, resume, is_away_mode_on): + """Test turn away mode off when away.""" + is_away_mode_on.return_value = True + self.thermostat.turn_away_mode_off() + self.assertTrue(self.thermostat._force_update) + resume.assert_called_once() + + def test_resume_program(self): + """Test resume schedule.""" + self.thermostat.resume_program() + self.thermostat._thermostat.resume_schedule.assert_called_once() + self.assertTrue(self.thermostat._force_update) + def test_set_temperature(self): """Test set temperature.""" self.thermostat.set_temperature(temperature=85) From 9b373901fa3217d4545a651658f587637ddaa15e Mon Sep 17 00:00:00 2001 From: Derek Brooks Date: Sat, 11 Nov 2017 16:31:35 -0600 Subject: [PATCH 004/238] add documentation links --- homeassistant/components/climate/nuheat.py | 1 + homeassistant/components/nuheat.py | 1 + 2 files changed, 2 insertions(+) diff --git a/homeassistant/components/climate/nuheat.py b/homeassistant/components/climate/nuheat.py index ff1d1158ad1..3d922edeb2c 100644 --- a/homeassistant/components/climate/nuheat.py +++ b/homeassistant/components/climate/nuheat.py @@ -2,6 +2,7 @@ Support for NuHeat thermostats. For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.nuheat/ """ import logging from datetime import timedelta diff --git a/homeassistant/components/nuheat.py b/homeassistant/components/nuheat.py index a3e110b71da..4c3bb84e6b4 100644 --- a/homeassistant/components/nuheat.py +++ b/homeassistant/components/nuheat.py @@ -2,6 +2,7 @@ Support for NuHeat thermostats. For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/nuheat/ """ import logging From 2c44e4fb1278ab2bbe1d376a31674eb8b504d8dd Mon Sep 17 00:00:00 2001 From: Derek Brooks Date: Sat, 11 Nov 2017 16:47:12 -0600 Subject: [PATCH 005/238] address initial houndbot suggestions --- homeassistant/components/nuheat.py | 1 + tests/components/climate/test_nuheat.py | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/nuheat.py b/homeassistant/components/nuheat.py index 4c3bb84e6b4..9fa555b380e 100644 --- a/homeassistant/components/nuheat.py +++ b/homeassistant/components/nuheat.py @@ -29,6 +29,7 @@ CONFIG_SCHEMA = vol.Schema({ }), }, extra=vol.ALLOW_EXTRA) + def setup(hass, config): """Set up the NuHeat thermostat component.""" import nuheat diff --git a/tests/components/climate/test_nuheat.py b/tests/components/climate/test_nuheat.py index 83dbfac8449..df4c72b59ce 100644 --- a/tests/components/climate/test_nuheat.py +++ b/tests/components/climate/test_nuheat.py @@ -103,7 +103,6 @@ class TestNuHeat(unittest.TestCase): self.thermostat._temperature_unit = "C" self.assertEqual(self.thermostat.target_temperature, 22) - def test_target_temperature_low(self): """Test low target temperature.""" self.assertEqual(self.thermostat.target_temperature_low, 72) From 7859b76429bd28abdc89829667f4cd1a9d1626ab Mon Sep 17 00:00:00 2001 From: Derek Brooks Date: Mon, 13 Nov 2017 10:35:48 -0600 Subject: [PATCH 006/238] kill target_temperature_low and high. They don't make sense here --- homeassistant/components/climate/nuheat.py | 10 ---------- tests/components/climate/test_nuheat.py | 12 ------------ 2 files changed, 22 deletions(-) diff --git a/homeassistant/components/climate/nuheat.py b/homeassistant/components/climate/nuheat.py index 3d922edeb2c..1979c0dfa00 100644 --- a/homeassistant/components/climate/nuheat.py +++ b/homeassistant/components/climate/nuheat.py @@ -111,16 +111,6 @@ class NuHeatThermostat(ClimateDevice): return self._thermostat.target_fahrenheit - @property - def target_temperature_low(self): - """Return the lower bound temperature we try to reach.""" - return self.target_temperature - - @property - def target_temperature_high(self): - """Return the upper bound temperature we try to reach.""" - return self.target_temperature - @property def current_hold_mode(self): """Return current hold mode.""" diff --git a/tests/components/climate/test_nuheat.py b/tests/components/climate/test_nuheat.py index df4c72b59ce..7a86157d469 100644 --- a/tests/components/climate/test_nuheat.py +++ b/tests/components/climate/test_nuheat.py @@ -103,18 +103,6 @@ class TestNuHeat(unittest.TestCase): self.thermostat._temperature_unit = "C" self.assertEqual(self.thermostat.target_temperature, 22) - def test_target_temperature_low(self): - """Test low target temperature.""" - self.assertEqual(self.thermostat.target_temperature_low, 72) - self.thermostat._temperature_unit = "C" - self.assertEqual(self.thermostat.target_temperature_low, 22) - - def test_target_temperature_high(self): - """Test high target temperature.""" - self.assertEqual(self.thermostat.target_temperature_high, 72) - self.thermostat._temperature_unit = "C" - self.assertEqual(self.thermostat.target_temperature_high, 22) - @patch.object( nuheat.NuHeatThermostat, "is_away_mode_on", new_callable=PropertyMock) def test_current_hold_mode_away(self, is_away_mode_on): From f21b9988e91dae8539db28fcd3c700c11c3fa858 Mon Sep 17 00:00:00 2001 From: Derek Brooks Date: Mon, 13 Nov 2017 11:00:33 -0600 Subject: [PATCH 007/238] allow for the configuring of a minimum away temperature --- homeassistant/components/climate/nuheat.py | 17 +++++++++++++---- homeassistant/components/nuheat.py | 6 +++++- tests/components/climate/test_nuheat.py | 13 ++++++++++--- 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/climate/nuheat.py b/homeassistant/components/climate/nuheat.py index 1979c0dfa00..569726fc684 100644 --- a/homeassistant/components/climate/nuheat.py +++ b/homeassistant/components/climate/nuheat.py @@ -43,9 +43,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _LOGGER.info("Loading NuHeat thermostat climate component") temperature_unit = hass.config.units.temperature_unit - api, serial_numbers = hass.data[DATA_NUHEAT] + api, serial_numbers, min_away_temp = hass.data[DATA_NUHEAT] thermostats = [ - NuHeatThermostat(api, serial_number, temperature_unit) + NuHeatThermostat(api, serial_number, min_away_temp, temperature_unit) for serial_number in serial_numbers ] add_devices(thermostats, True) @@ -53,9 +53,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class NuHeatThermostat(ClimateDevice): """Representation of a NuHeat Thermostat.""" - def __init__(self, api, serial_number, temperature_unit): + def __init__(self, api, serial_number, min_away_temp, temperature_unit): self._thermostat = api.get_thermostat(serial_number) self._temperature_unit = temperature_unit + self._min_away_temp = min_away_temp self._force_update = False @property @@ -87,6 +88,14 @@ class NuHeatThermostat(ClimateDevice): return STATE_IDLE + @property + def min_away_temp(self): + """Return the minimum target temperature to be used in away mode.""" + if self._min_away_temp: + return self._min_away_temp + + return self.min_temp + @property def min_temp(self): """Return the minimum supported temperature for the thermostat.""" @@ -156,7 +165,7 @@ class NuHeatThermostat(ClimateDevice): return kwargs = {} - kwargs[ATTR_TEMPERATURE] = self.min_temp + kwargs[ATTR_TEMPERATURE] = self.min_away_temp self.set_temperature(**kwargs) self._force_update = True diff --git a/homeassistant/components/nuheat.py b/homeassistant/components/nuheat.py index 9fa555b380e..5358932cd78 100644 --- a/homeassistant/components/nuheat.py +++ b/homeassistant/components/nuheat.py @@ -20,12 +20,15 @@ DATA_NUHEAT = "nuheat" DOMAIN = "nuheat" +CONF_MIN_AWAY_TEMP = 'min_away_temp' + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_DEVICES, default=[]): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_MIN_AWAY_TEMP): cv.string, }), }, extra=vol.ALLOW_EXTRA) @@ -38,10 +41,11 @@ def setup(hass, config): username = conf.get(CONF_USERNAME) password = conf.get(CONF_PASSWORD) devices = conf.get(CONF_DEVICES) + min_away_temp = conf.get(CONF_MIN_AWAY_TEMP) api = nuheat.NuHeat(username, password) api.authenticate() - hass.data[DATA_NUHEAT] = (api, devices) + hass.data[DATA_NUHEAT] = (api, devices, min_away_temp) discovery.load_platform(hass, "climate", DOMAIN, {}, config) _LOGGER.debug("NuHeat initialized") diff --git a/tests/components/climate/test_nuheat.py b/tests/components/climate/test_nuheat.py index 7a86157d469..bcb1b0f3312 100644 --- a/tests/components/climate/test_nuheat.py +++ b/tests/components/climate/test_nuheat.py @@ -18,6 +18,7 @@ class TestNuHeat(unittest.TestCase): def setUp(self): serial_number = "12345" + min_away_temp = None temperature_unit = "F" thermostat = Mock( @@ -42,13 +43,13 @@ class TestNuHeat(unittest.TestCase): api.get_thermostat.return_value = thermostat self.thermostat = nuheat.NuHeatThermostat( - api, serial_number, temperature_unit) + api, serial_number, min_away_temp, temperature_unit) @patch("homeassistant.components.climate.nuheat.NuHeatThermostat") def test_setup_platform(self, mocked_thermostat): """Test setup_platform.""" api = Mock() - data = {"nuheat": (api, ["12345"])} + data = {"nuheat": (api, ["12345"], 50)} hass = Mock() hass.config.units.temperature_unit.return_value = "F" @@ -60,7 +61,7 @@ class TestNuHeat(unittest.TestCase): discovery_info = {} nuheat.setup_platform(hass, config, add_devices, discovery_info) - thermostats = [mocked_thermostat(api, "12345", "F")] + thermostats = [mocked_thermostat(api, "12345", 50, "F")] add_devices.assert_called_once_with(thermostats, True) def test_name(self): @@ -85,6 +86,12 @@ class TestNuHeat(unittest.TestCase): self.thermostat._thermostat.heating = False self.assertEqual(self.thermostat.current_operation, STATE_IDLE) + def test_min_away_temp(self): + """Test the minimum target temperature to be used in away mode.""" + self.assertEqual(self.thermostat.min_away_temp, 41) + self.thermostat._min_away_temp = 60 + self.assertEqual(self.thermostat.min_away_temp, 60) + def test_min_temp(self): """Test min temp.""" self.assertEqual(self.thermostat.min_temp, 41) From ef5edb95ba59b19cfd9f9f8a473f297a3d69608c Mon Sep 17 00:00:00 2001 From: Derek Brooks Date: Mon, 13 Nov 2017 11:38:08 -0600 Subject: [PATCH 008/238] Update home/auto hold mode to be consistent with current documentation --- homeassistant/components/climate/nuheat.py | 4 +++- tests/components/climate/test_nuheat.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/climate/nuheat.py b/homeassistant/components/climate/nuheat.py index 569726fc684..fc49399c2cc 100644 --- a/homeassistant/components/climate/nuheat.py +++ b/homeassistant/components/climate/nuheat.py @@ -14,6 +14,7 @@ from homeassistant.components.climate import ( from homeassistant.components.nuheat import DATA_NUHEAT from homeassistant.const import ( ATTR_TEMPERATURE, + STATE_HOME, TEMP_CELSIUS, TEMP_FAHRENHEIT) from homeassistant.util import Throttle @@ -24,7 +25,8 @@ _LOGGER = logging.getLogger(__name__) MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) -MODE_AUTO = "auto" # Run device schedule +# Hold modes +MODE_AUTO = STATE_HOME # Run device schedule MODE_AWAY = "away" MODE_HOLD_TEMPERATURE = "temperature" MODE_TEMPORARY_HOLD = "temporary_temperature" diff --git a/tests/components/climate/test_nuheat.py b/tests/components/climate/test_nuheat.py index bcb1b0f3312..ba0f6c8a6d6 100644 --- a/tests/components/climate/test_nuheat.py +++ b/tests/components/climate/test_nuheat.py @@ -4,7 +4,7 @@ from unittest.mock import PropertyMock, Mock, patch from homeassistant.components.climate import STATE_HEAT, STATE_IDLE import homeassistant.components.climate.nuheat as nuheat -from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.const import STATE_HOME, TEMP_CELSIUS, TEMP_FAHRENHEIT SCHEDULE_HOLD = 3 SCHEDULE_RUN = 1 From 766893253a7164536c9247f2d435bdf38f80aa74 Mon Sep 17 00:00:00 2001 From: Derek Brooks Date: Mon, 13 Nov 2017 12:28:09 -0600 Subject: [PATCH 009/238] make sure is_away_mode_on supports user-defined minimum away temps --- homeassistant/components/climate/nuheat.py | 16 ++++++++++++++-- tests/components/climate/test_nuheat.py | 18 +++++++++++++++++- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/climate/nuheat.py b/homeassistant/components/climate/nuheat.py index fc49399c2cc..d49d060f032 100644 --- a/homeassistant/components/climate/nuheat.py +++ b/homeassistant/components/climate/nuheat.py @@ -151,9 +151,21 @@ class NuHeatThermostat(ClimateDevice): Return true if away mode is on. Away mode is determined by setting and HOLDing the target temperature - to the minimum temperature supported. + to the user-defined minimum away temperature or the minimum + temperature supported by the thermostat. """ - if self._thermostat.target_celsius > self._thermostat.min_celsius: + if self._min_away_temp: + if self._temperature_unit == "C": + min_target = self._min_away_temp + target = self._thermostat.target_celsius + else: + min_target = self._min_away_temp + target = self._thermostat.target_fahrenheit + + if target > min_target: + return False + + elif self._thermostat.target_celsius > self._thermostat.min_celsius: return False if self._thermostat.schedule_mode != SCHEDULE_HOLD: diff --git a/tests/components/climate/test_nuheat.py b/tests/components/climate/test_nuheat.py index ba0f6c8a6d6..558122dd366 100644 --- a/tests/components/climate/test_nuheat.py +++ b/tests/components/climate/test_nuheat.py @@ -137,13 +137,29 @@ class TestNuHeat(unittest.TestCase): def test_is_away_mode_on(self): """Test is away mode on.""" _thermostat = self.thermostat._thermostat - _thermostat.target_celsius = _thermostat.min_celsius _thermostat.schedule_mode = SCHEDULE_HOLD + + # user-defined minimum fahrenheit + self.thermostat._min_away_temp = 59 + _thermostat.target_fahrenheit = 59 self.assertTrue(self.thermostat.is_away_mode_on) + # user-defined minimum celsius + self.thermostat._temperature_unit = "C" + self.thermostat._min_away_temp = 15 + _thermostat.target_celsius = 15 + self.assertTrue(self.thermostat.is_away_mode_on) + + # thermostat's minimum supported temperature + self.thermostat._min_away_temp = None + _thermostat.target_celsius = _thermostat.min_celsius + self.assertTrue(self.thermostat.is_away_mode_on) + + # thermostat held at a temperature above the minimum _thermostat.target_celsius = _thermostat.min_celsius + 1 self.assertFalse(self.thermostat.is_away_mode_on) + # thermostat not on HOLD _thermostat.target_celsius = _thermostat.min_celsius _thermostat.schedule_mode = SCHEDULE_RUN self.assertFalse(self.thermostat.is_away_mode_on) From 6892033556968abb857c5027e592a57837209715 Mon Sep 17 00:00:00 2001 From: Derek Brooks Date: Mon, 13 Nov 2017 12:31:15 -0600 Subject: [PATCH 010/238] remove that unused constant --- tests/components/climate/test_nuheat.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/climate/test_nuheat.py b/tests/components/climate/test_nuheat.py index 558122dd366..5a9f1d95a3f 100644 --- a/tests/components/climate/test_nuheat.py +++ b/tests/components/climate/test_nuheat.py @@ -4,7 +4,7 @@ from unittest.mock import PropertyMock, Mock, patch from homeassistant.components.climate import STATE_HEAT, STATE_IDLE import homeassistant.components.climate.nuheat as nuheat -from homeassistant.const import STATE_HOME, TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT SCHEDULE_HOLD = 3 SCHEDULE_RUN = 1 From f1fe8e95baafcbc96dab12e9b45e78158fca1db6 Mon Sep 17 00:00:00 2001 From: Derek Brooks Date: Mon, 13 Nov 2017 22:40:18 -0600 Subject: [PATCH 011/238] clean up a couple away temperature settings --- homeassistant/components/climate/nuheat.py | 21 ++++++++------------- homeassistant/components/nuheat.py | 11 ++++++++++- tests/components/climate/test_nuheat.py | 6 ++++++ 3 files changed, 24 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/climate/nuheat.py b/homeassistant/components/climate/nuheat.py index d49d060f032..c5893ecdb00 100644 --- a/homeassistant/components/climate/nuheat.py +++ b/homeassistant/components/climate/nuheat.py @@ -93,8 +93,8 @@ class NuHeatThermostat(ClimateDevice): @property def min_away_temp(self): """Return the minimum target temperature to be used in away mode.""" - if self._min_away_temp: - return self._min_away_temp + if self._min_away_temp and self._min_away_temp > self.min_temp: + return int(self._min_away_temp) return self.min_temp @@ -154,18 +154,13 @@ class NuHeatThermostat(ClimateDevice): to the user-defined minimum away temperature or the minimum temperature supported by the thermostat. """ - if self._min_away_temp: - if self._temperature_unit == "C": - min_target = self._min_away_temp - target = self._thermostat.target_celsius - else: - min_target = self._min_away_temp - target = self._thermostat.target_fahrenheit + min_target = self.min_away_temp + if self._temperature_unit == "C": + target = self._thermostat.target_celsius + else: + target = self._thermostat.target_fahrenheit - if target > min_target: - return False - - elif self._thermostat.target_celsius > self._thermostat.min_celsius: + if target > min_target: return False if self._thermostat.schedule_mode != SCHEDULE_HOLD: diff --git a/homeassistant/components/nuheat.py b/homeassistant/components/nuheat.py index 5358932cd78..5a888457e40 100644 --- a/homeassistant/components/nuheat.py +++ b/homeassistant/components/nuheat.py @@ -41,7 +41,16 @@ def setup(hass, config): username = conf.get(CONF_USERNAME) password = conf.get(CONF_PASSWORD) devices = conf.get(CONF_DEVICES) - min_away_temp = conf.get(CONF_MIN_AWAY_TEMP) + + min_away_temp = None + _min_away_temp = conf.get(CONF_MIN_AWAY_TEMP) + if _min_away_temp: + try: + min_away_temp = int(_min_away_temp) + except ValueError: + _LOGGER.error( + "Configuration error. %s.%s=%s is invalid. Please provide a " + "numeric value.", DATA_NUHEAT, CONF_MIN_AWAY_TEMP, _min_away_temp) api = nuheat.NuHeat(username, password) api.authenticate() diff --git a/tests/components/climate/test_nuheat.py b/tests/components/climate/test_nuheat.py index 5a9f1d95a3f..ab5624e82f3 100644 --- a/tests/components/climate/test_nuheat.py +++ b/tests/components/climate/test_nuheat.py @@ -89,9 +89,15 @@ class TestNuHeat(unittest.TestCase): def test_min_away_temp(self): """Test the minimum target temperature to be used in away mode.""" self.assertEqual(self.thermostat.min_away_temp, 41) + + # User defined minimum self.thermostat._min_away_temp = 60 self.assertEqual(self.thermostat.min_away_temp, 60) + # User defined minimum below the thermostat's supported minimum + self.thermostat._min_away_temp = 0 + self.assertEqual(self.thermostat.min_away_temp, 41) + def test_min_temp(self): """Test min temp.""" self.assertEqual(self.thermostat.min_temp, 41) From 959f6386b47d46922ce6da4eff9834e2b1339fa3 Mon Sep 17 00:00:00 2001 From: Derek Brooks Date: Mon, 13 Nov 2017 22:43:11 -0600 Subject: [PATCH 012/238] shorten that long line --- homeassistant/components/nuheat.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/nuheat.py b/homeassistant/components/nuheat.py index 5a888457e40..69cd674eed2 100644 --- a/homeassistant/components/nuheat.py +++ b/homeassistant/components/nuheat.py @@ -50,7 +50,8 @@ def setup(hass, config): except ValueError: _LOGGER.error( "Configuration error. %s.%s=%s is invalid. Please provide a " - "numeric value.", DATA_NUHEAT, CONF_MIN_AWAY_TEMP, _min_away_temp) + "numeric value.", DATA_NUHEAT, CONF_MIN_AWAY_TEMP, + _min_away_temp) api = nuheat.NuHeat(username, password) api.authenticate() From c0c439c549ec01a9d4dd61ea7ff375746dda0df9 Mon Sep 17 00:00:00 2001 From: Derek Brooks Date: Mon, 13 Nov 2017 22:45:29 -0600 Subject: [PATCH 013/238] that int() casting was redundant --- homeassistant/components/climate/nuheat.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/climate/nuheat.py b/homeassistant/components/climate/nuheat.py index c5893ecdb00..deaf9b071b9 100644 --- a/homeassistant/components/climate/nuheat.py +++ b/homeassistant/components/climate/nuheat.py @@ -94,7 +94,7 @@ class NuHeatThermostat(ClimateDevice): def min_away_temp(self): """Return the minimum target temperature to be used in away mode.""" if self._min_away_temp and self._min_away_temp > self.min_temp: - return int(self._min_away_temp) + return self._min_away_temp return self.min_temp From afcb0b876713a382feb930288e0280a17151e94f Mon Sep 17 00:00:00 2001 From: Derek Brooks Date: Mon, 13 Nov 2017 23:13:04 -0600 Subject: [PATCH 014/238] fix up some docstrings --- homeassistant/components/climate/nuheat.py | 11 ++++++++++- tests/components/climate/test_nuheat.py | 7 ++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/climate/nuheat.py b/homeassistant/components/climate/nuheat.py index deaf9b071b9..b1779ba5e5f 100644 --- a/homeassistant/components/climate/nuheat.py +++ b/homeassistant/components/climate/nuheat.py @@ -23,6 +23,8 @@ DEPENDENCIES = ["nuheat"] _LOGGER = logging.getLogger(__name__) +ICON = "mdi:thermometer" + MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) # Hold modes @@ -55,7 +57,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class NuHeatThermostat(ClimateDevice): """Representation of a NuHeat Thermostat.""" + def __init__(self, api, serial_number, min_away_temp, temperature_unit): + """Initialize the thermostat.""" self._thermostat = api.get_thermostat(serial_number) self._temperature_unit = temperature_unit self._min_away_temp = min_away_temp @@ -66,6 +70,11 @@ class NuHeatThermostat(ClimateDevice): """Return the name of the thermostat.""" return self._thermostat.room + @property + def icon(self): + """Return the icon to use in the frontend.""" + return ICON + @property def temperature_unit(self): """Return the unit of measurement.""" @@ -216,5 +225,5 @@ class NuHeatThermostat(ClimateDevice): @Throttle(MIN_TIME_BETWEEN_UPDATES) def _throttled_update(self, **kwargs): - """Get the latest state from the thermostat... but throttled!""" + """Get the latest state from the thermostat with a throttle.""" self._thermostat.get_data() diff --git a/tests/components/climate/test_nuheat.py b/tests/components/climate/test_nuheat.py index ab5624e82f3..1d178e255fa 100644 --- a/tests/components/climate/test_nuheat.py +++ b/tests/components/climate/test_nuheat.py @@ -13,10 +13,11 @@ SCHEDULE_TEMPORARY_HOLD = 2 class TestNuHeat(unittest.TestCase): """Tests for NuHeat climate.""" + # pylint: disable=protected-access, no-self-use def setUp(self): - + """Set up test variables.""" serial_number = "12345" min_away_temp = None temperature_unit = "F" @@ -68,6 +69,10 @@ class TestNuHeat(unittest.TestCase): """Test name property.""" self.assertEqual(self.thermostat.name, "Master bathroom") + def test_icon(self): + """Test name property.""" + self.assertEqual(self.thermostat.icon, "mdi:thermometer") + def test_temperature_unit(self): """Test temperature unit.""" self.assertEqual(self.thermostat.temperature_unit, TEMP_FAHRENHEIT) From a3c6211c04110edce78687e6edef2c33a9095f97 Mon Sep 17 00:00:00 2001 From: Derek Brooks Date: Mon, 13 Nov 2017 23:20:16 -0600 Subject: [PATCH 015/238] python 3.5 seems to not like assert_called_once() --- tests/components/climate/test_nuheat.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/climate/test_nuheat.py b/tests/components/climate/test_nuheat.py index 1d178e255fa..0b3b8c91ef4 100644 --- a/tests/components/climate/test_nuheat.py +++ b/tests/components/climate/test_nuheat.py @@ -212,12 +212,12 @@ class TestNuHeat(unittest.TestCase): is_away_mode_on.return_value = True self.thermostat.turn_away_mode_off() self.assertTrue(self.thermostat._force_update) - resume.assert_called_once() + resume.assert_called_once_with() def test_resume_program(self): """Test resume schedule.""" self.thermostat.resume_program() - self.thermostat._thermostat.resume_schedule.assert_called_once() + self.thermostat._thermostat.resume_schedule.assert_called_once_with() self.assertTrue(self.thermostat._force_update) def test_set_temperature(self): From a9feafd571c400a317a78f0d1dd76d5a6aa6f885 Mon Sep 17 00:00:00 2001 From: Derek Brooks Date: Sat, 18 Nov 2017 10:26:36 -0600 Subject: [PATCH 016/238] add nuheat to coverage reports --- .coveragerc | 3 --- 1 file changed, 3 deletions(-) diff --git a/.coveragerc b/.coveragerc index 4de7d138f71..3bfd983dc30 100644 --- a/.coveragerc +++ b/.coveragerc @@ -143,9 +143,6 @@ omit = homeassistant/components/netatmo.py homeassistant/components/*/netatmo.py - homeassistant/components/nuheat.py - homeassistant/components/*/nuheat.py - homeassistant/components/octoprint.py homeassistant/components/*/octoprint.py From 36d5fff8e04f447d21b935cb9dc397908dbe091b Mon Sep 17 00:00:00 2001 From: Derek Brooks Date: Wed, 6 Dec 2017 21:52:44 -0600 Subject: [PATCH 017/238] address feedback on log lines --- homeassistant/components/climate/nuheat.py | 3 +-- homeassistant/components/nuheat.py | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/climate/nuheat.py b/homeassistant/components/climate/nuheat.py index b1779ba5e5f..696a6961cb3 100644 --- a/homeassistant/components/climate/nuheat.py +++ b/homeassistant/components/climate/nuheat.py @@ -45,7 +45,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if discovery_info is None: return - _LOGGER.info("Loading NuHeat thermostat climate component") temperature_unit = hass.config.units.temperature_unit api, serial_numbers, min_away_temp = hass.data[DATA_NUHEAT] thermostats = [ @@ -209,7 +208,7 @@ class NuHeatThermostat(ClimateDevice): else: self._thermostat.target_fahrenheit = temperature - _LOGGER.info( + _LOGGER.debug( "Setting NuHeat thermostat temperature to %s %s", temperature, self.temperature_unit) diff --git a/homeassistant/components/nuheat.py b/homeassistant/components/nuheat.py index 69cd674eed2..6a85a0dafa1 100644 --- a/homeassistant/components/nuheat.py +++ b/homeassistant/components/nuheat.py @@ -58,5 +58,4 @@ def setup(hass, config): hass.data[DATA_NUHEAT] = (api, devices, min_away_temp) discovery.load_platform(hass, "climate", DOMAIN, {}, config) - _LOGGER.debug("NuHeat initialized") return True From c262a387dc83c2813c8cdf861fcb58b05a7b5b1b Mon Sep 17 00:00:00 2001 From: Derek Brooks Date: Wed, 6 Dec 2017 22:24:54 -0600 Subject: [PATCH 018/238] add supported_features functionality --- homeassistant/components/climate/nuheat.py | 12 ++++++++++++ tests/components/climate/test_nuheat.py | 14 +++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/climate/nuheat.py b/homeassistant/components/climate/nuheat.py index 696a6961cb3..ae3aff8c673 100644 --- a/homeassistant/components/climate/nuheat.py +++ b/homeassistant/components/climate/nuheat.py @@ -9,6 +9,10 @@ from datetime import timedelta from homeassistant.components.climate import ( ClimateDevice, + SUPPORT_AWAY_MODE, + SUPPORT_HOLD_MODE, + SUPPORT_OPERATION_MODE, + SUPPORT_TARGET_TEMPERATURE, STATE_HEAT, STATE_IDLE) from homeassistant.components.nuheat import DATA_NUHEAT @@ -39,6 +43,9 @@ SCHEDULE_HOLD = 3 SCHEDULE_RUN = 1 SCHEDULE_TEMPORARY_HOLD = 2 +SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_HOLD_MODE | + SUPPORT_AWAY_MODE | SUPPORT_OPERATION_MODE) + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the NuHeat thermostat(s).""" @@ -74,6 +81,11 @@ class NuHeatThermostat(ClimateDevice): """Return the icon to use in the frontend.""" return ICON + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS + @property def temperature_unit(self): """Return the unit of measurement.""" diff --git a/tests/components/climate/test_nuheat.py b/tests/components/climate/test_nuheat.py index 0b3b8c91ef4..a9946c49bce 100644 --- a/tests/components/climate/test_nuheat.py +++ b/tests/components/climate/test_nuheat.py @@ -2,7 +2,13 @@ import unittest from unittest.mock import PropertyMock, Mock, patch -from homeassistant.components.climate import STATE_HEAT, STATE_IDLE +from homeassistant.components.climate import ( + SUPPORT_AWAY_MODE, + SUPPORT_HOLD_MODE, + SUPPORT_OPERATION_MODE, + SUPPORT_TARGET_TEMPERATURE, + STATE_HEAT, + STATE_IDLE) import homeassistant.components.climate.nuheat as nuheat from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT @@ -73,6 +79,12 @@ class TestNuHeat(unittest.TestCase): """Test name property.""" self.assertEqual(self.thermostat.icon, "mdi:thermometer") + def test_supported_features(self): + """Test name property.""" + features = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_HOLD_MODE | + SUPPORT_AWAY_MODE | SUPPORT_OPERATION_MODE) + self.assertEqual(self.thermostat.supported_features, features) + def test_temperature_unit(self): """Test temperature unit.""" self.assertEqual(self.thermostat.temperature_unit, TEMP_FAHRENHEIT) From 3193e825d5afeba7a8d1518d34403e2ed1326090 Mon Sep 17 00:00:00 2001 From: Derek Brooks Date: Wed, 6 Dec 2017 22:34:13 -0600 Subject: [PATCH 019/238] remove nuheat away functionality. :( --- homeassistant/components/climate/nuheat.py | 64 +------------ homeassistant/components/nuheat.py | 16 +--- tests/components/climate/test_nuheat.py | 106 ++------------------- 3 files changed, 11 insertions(+), 175 deletions(-) diff --git a/homeassistant/components/climate/nuheat.py b/homeassistant/components/climate/nuheat.py index ae3aff8c673..f08c2d7b7d5 100644 --- a/homeassistant/components/climate/nuheat.py +++ b/homeassistant/components/climate/nuheat.py @@ -9,7 +9,6 @@ from datetime import timedelta from homeassistant.components.climate import ( ClimateDevice, - SUPPORT_AWAY_MODE, SUPPORT_HOLD_MODE, SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE, @@ -33,7 +32,6 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) # Hold modes MODE_AUTO = STATE_HOME # Run device schedule -MODE_AWAY = "away" MODE_HOLD_TEMPERATURE = "temperature" MODE_TEMPORARY_HOLD = "temporary_temperature" @@ -44,7 +42,7 @@ SCHEDULE_RUN = 1 SCHEDULE_TEMPORARY_HOLD = 2 SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_HOLD_MODE | - SUPPORT_AWAY_MODE | SUPPORT_OPERATION_MODE) + SUPPORT_OPERATION_MODE) def setup_platform(hass, config, add_devices, discovery_info=None): @@ -53,9 +51,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): return temperature_unit = hass.config.units.temperature_unit - api, serial_numbers, min_away_temp = hass.data[DATA_NUHEAT] + api, serial_numbers = hass.data[DATA_NUHEAT] thermostats = [ - NuHeatThermostat(api, serial_number, min_away_temp, temperature_unit) + NuHeatThermostat(api, serial_number, temperature_unit) for serial_number in serial_numbers ] add_devices(thermostats, True) @@ -64,11 +62,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class NuHeatThermostat(ClimateDevice): """Representation of a NuHeat Thermostat.""" - def __init__(self, api, serial_number, min_away_temp, temperature_unit): + def __init__(self, api, serial_number, temperature_unit): """Initialize the thermostat.""" self._thermostat = api.get_thermostat(serial_number) self._temperature_unit = temperature_unit - self._min_away_temp = min_away_temp self._force_update = False @property @@ -110,14 +107,6 @@ class NuHeatThermostat(ClimateDevice): return STATE_IDLE - @property - def min_away_temp(self): - """Return the minimum target temperature to be used in away mode.""" - if self._min_away_temp and self._min_away_temp > self.min_temp: - return self._min_away_temp - - return self.min_temp - @property def min_temp(self): """Return the minimum supported temperature for the thermostat.""" @@ -145,9 +134,6 @@ class NuHeatThermostat(ClimateDevice): @property def current_hold_mode(self): """Return current hold mode.""" - if self.is_away_mode_on: - return MODE_AWAY - schedule_mode = self._thermostat.schedule_mode if schedule_mode == SCHEDULE_RUN: return MODE_AUTO @@ -165,48 +151,6 @@ class NuHeatThermostat(ClimateDevice): """Return list of possible operation modes.""" return OPERATION_LIST - @property - def is_away_mode_on(self): - """ - Return true if away mode is on. - - Away mode is determined by setting and HOLDing the target temperature - to the user-defined minimum away temperature or the minimum - temperature supported by the thermostat. - """ - min_target = self.min_away_temp - if self._temperature_unit == "C": - target = self._thermostat.target_celsius - else: - target = self._thermostat.target_fahrenheit - - if target > min_target: - return False - - if self._thermostat.schedule_mode != SCHEDULE_HOLD: - return False - - return True - - def turn_away_mode_on(self): - """Turn away mode on.""" - if self.is_away_mode_on: - return - - kwargs = {} - kwargs[ATTR_TEMPERATURE] = self.min_away_temp - - self.set_temperature(**kwargs) - self._force_update = True - - def turn_away_mode_off(self): - """Turn away mode off.""" - if not self.is_away_mode_on: - return - - self.resume_program() - self._force_update = True - def resume_program(self): """Resume the thermostat's programmed schedule.""" self._thermostat.resume_schedule() diff --git a/homeassistant/components/nuheat.py b/homeassistant/components/nuheat.py index 6a85a0dafa1..08941359dc8 100644 --- a/homeassistant/components/nuheat.py +++ b/homeassistant/components/nuheat.py @@ -20,15 +20,12 @@ DATA_NUHEAT = "nuheat" DOMAIN = "nuheat" -CONF_MIN_AWAY_TEMP = 'min_away_temp' - CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_DEVICES, default=[]): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_MIN_AWAY_TEMP): cv.string, }), }, extra=vol.ALLOW_EXTRA) @@ -42,20 +39,9 @@ def setup(hass, config): password = conf.get(CONF_PASSWORD) devices = conf.get(CONF_DEVICES) - min_away_temp = None - _min_away_temp = conf.get(CONF_MIN_AWAY_TEMP) - if _min_away_temp: - try: - min_away_temp = int(_min_away_temp) - except ValueError: - _LOGGER.error( - "Configuration error. %s.%s=%s is invalid. Please provide a " - "numeric value.", DATA_NUHEAT, CONF_MIN_AWAY_TEMP, - _min_away_temp) - api = nuheat.NuHeat(username, password) api.authenticate() - hass.data[DATA_NUHEAT] = (api, devices, min_away_temp) + hass.data[DATA_NUHEAT] = (api, devices) discovery.load_platform(hass, "climate", DOMAIN, {}, config) return True diff --git a/tests/components/climate/test_nuheat.py b/tests/components/climate/test_nuheat.py index a9946c49bce..b2b3e6cddff 100644 --- a/tests/components/climate/test_nuheat.py +++ b/tests/components/climate/test_nuheat.py @@ -1,9 +1,8 @@ """The test for the NuHeat thermostat module.""" import unittest -from unittest.mock import PropertyMock, Mock, patch +from unittest.mock import Mock, patch from homeassistant.components.climate import ( - SUPPORT_AWAY_MODE, SUPPORT_HOLD_MODE, SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE, @@ -25,7 +24,6 @@ class TestNuHeat(unittest.TestCase): def setUp(self): """Set up test variables.""" serial_number = "12345" - min_away_temp = None temperature_unit = "F" thermostat = Mock( @@ -50,13 +48,13 @@ class TestNuHeat(unittest.TestCase): api.get_thermostat.return_value = thermostat self.thermostat = nuheat.NuHeatThermostat( - api, serial_number, min_away_temp, temperature_unit) + api, serial_number, temperature_unit) @patch("homeassistant.components.climate.nuheat.NuHeatThermostat") def test_setup_platform(self, mocked_thermostat): """Test setup_platform.""" api = Mock() - data = {"nuheat": (api, ["12345"], 50)} + data = {"nuheat": (api, ["12345"])} hass = Mock() hass.config.units.temperature_unit.return_value = "F" @@ -68,7 +66,7 @@ class TestNuHeat(unittest.TestCase): discovery_info = {} nuheat.setup_platform(hass, config, add_devices, discovery_info) - thermostats = [mocked_thermostat(api, "12345", 50, "F")] + thermostats = [mocked_thermostat(api, "12345", "F")] add_devices.assert_called_once_with(thermostats, True) def test_name(self): @@ -82,7 +80,7 @@ class TestNuHeat(unittest.TestCase): def test_supported_features(self): """Test name property.""" features = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_HOLD_MODE | - SUPPORT_AWAY_MODE | SUPPORT_OPERATION_MODE) + SUPPORT_OPERATION_MODE) self.assertEqual(self.thermostat.supported_features, features) def test_temperature_unit(self): @@ -103,18 +101,6 @@ class TestNuHeat(unittest.TestCase): self.thermostat._thermostat.heating = False self.assertEqual(self.thermostat.current_operation, STATE_IDLE) - def test_min_away_temp(self): - """Test the minimum target temperature to be used in away mode.""" - self.assertEqual(self.thermostat.min_away_temp, 41) - - # User defined minimum - self.thermostat._min_away_temp = 60 - self.assertEqual(self.thermostat.min_away_temp, 60) - - # User defined minimum below the thermostat's supported minimum - self.thermostat._min_away_temp = 0 - self.assertEqual(self.thermostat.min_away_temp, 41) - def test_min_temp(self): """Test min temp.""" self.assertEqual(self.thermostat.min_temp, 41) @@ -133,19 +119,8 @@ class TestNuHeat(unittest.TestCase): self.thermostat._temperature_unit = "C" self.assertEqual(self.thermostat.target_temperature, 22) - @patch.object( - nuheat.NuHeatThermostat, "is_away_mode_on", new_callable=PropertyMock) - def test_current_hold_mode_away(self, is_away_mode_on): - """Test current hold mode while away.""" - is_away_mode_on.return_value = True - self.assertEqual(self.thermostat.current_hold_mode, nuheat.MODE_AWAY) - - @patch.object( - nuheat.NuHeatThermostat, "is_away_mode_on", new_callable=PropertyMock) - def test_current_hold_mode(self, is_away_mode_on): + def test_current_hold_mode(self): """Test current hold mode.""" - is_away_mode_on.return_value = False - self.thermostat._thermostat.schedule_mode = SCHEDULE_RUN self.assertEqual(self.thermostat.current_hold_mode, nuheat.MODE_AUTO) @@ -157,75 +132,6 @@ class TestNuHeat(unittest.TestCase): self.assertEqual( self.thermostat.current_hold_mode, nuheat.MODE_TEMPORARY_HOLD) - def test_is_away_mode_on(self): - """Test is away mode on.""" - _thermostat = self.thermostat._thermostat - _thermostat.schedule_mode = SCHEDULE_HOLD - - # user-defined minimum fahrenheit - self.thermostat._min_away_temp = 59 - _thermostat.target_fahrenheit = 59 - self.assertTrue(self.thermostat.is_away_mode_on) - - # user-defined minimum celsius - self.thermostat._temperature_unit = "C" - self.thermostat._min_away_temp = 15 - _thermostat.target_celsius = 15 - self.assertTrue(self.thermostat.is_away_mode_on) - - # thermostat's minimum supported temperature - self.thermostat._min_away_temp = None - _thermostat.target_celsius = _thermostat.min_celsius - self.assertTrue(self.thermostat.is_away_mode_on) - - # thermostat held at a temperature above the minimum - _thermostat.target_celsius = _thermostat.min_celsius + 1 - self.assertFalse(self.thermostat.is_away_mode_on) - - # thermostat not on HOLD - _thermostat.target_celsius = _thermostat.min_celsius - _thermostat.schedule_mode = SCHEDULE_RUN - self.assertFalse(self.thermostat.is_away_mode_on) - - @patch.object( - nuheat.NuHeatThermostat, "is_away_mode_on", new_callable=PropertyMock) - @patch.object(nuheat.NuHeatThermostat, "set_temperature") - def test_turn_away_mode_on_home(self, set_temp, is_away_mode_on): - """Test turn away mode on when not away.""" - is_away_mode_on.return_value = False - self.thermostat.turn_away_mode_on() - set_temp.assert_called_once_with(temperature=self.thermostat.min_temp) - self.assertTrue(self.thermostat._force_update) - - @patch.object( - nuheat.NuHeatThermostat, "is_away_mode_on", new_callable=PropertyMock) - @patch.object(nuheat.NuHeatThermostat, "set_temperature") - def test_turn_away_mode_on_away(self, set_temp, is_away_mode_on): - """Test turn away mode on when away.""" - is_away_mode_on.return_value = True - self.thermostat.turn_away_mode_on() - set_temp.assert_not_called() - - @patch.object( - nuheat.NuHeatThermostat, "is_away_mode_on", new_callable=PropertyMock) - @patch.object(nuheat.NuHeatThermostat, "resume_program") - def test_turn_away_mode_off_home(self, resume, is_away_mode_on): - """Test turn away mode off when home.""" - is_away_mode_on.return_value = False - self.thermostat.turn_away_mode_off() - self.assertFalse(self.thermostat._force_update) - resume.assert_not_called() - - @patch.object( - nuheat.NuHeatThermostat, "is_away_mode_on", new_callable=PropertyMock) - @patch.object(nuheat.NuHeatThermostat, "resume_program") - def test_turn_away_mode_off_away(self, resume, is_away_mode_on): - """Test turn away mode off when away.""" - is_away_mode_on.return_value = True - self.thermostat.turn_away_mode_off() - self.assertTrue(self.thermostat._force_update) - resume.assert_called_once_with() - def test_resume_program(self): """Test resume schedule.""" self.thermostat.resume_program() From a63658d58368d48b16d2247678ceedbafb8ab5e2 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 15 Dec 2017 21:22:36 +0100 Subject: [PATCH 020/238] Homematic next (#11156) * Cleanup logic & New gen of HomeMatic * fix lint * cleanup * fix coverage * cleanup * name consistenc * fix lint * Rename ip * cleanup wrong property * fix bug * handle callback better * fix lint * Running now --- .coveragerc | 2 +- .../{homematic.py => homematic/__init__.py} | 197 +++++++++--------- .../components/homematic/services.yaml | 52 +++++ homeassistant/components/services.yaml | 49 ----- 4 files changed, 146 insertions(+), 154 deletions(-) rename homeassistant/components/{homematic.py => homematic/__init__.py} (83%) create mode 100644 homeassistant/components/homematic/services.yaml diff --git a/.coveragerc b/.coveragerc index b73d847f431..96936655c51 100644 --- a/.coveragerc +++ b/.coveragerc @@ -88,7 +88,7 @@ omit = homeassistant/components/hive.py homeassistant/components/*/hive.py - homeassistant/components/homematic.py + homeassistant/components/homematic/__init__.py homeassistant/components/*/homematic.py homeassistant/components/insteon_local.py diff --git a/homeassistant/components/homematic.py b/homeassistant/components/homematic/__init__.py similarity index 83% rename from homeassistant/components/homematic.py rename to homeassistant/components/homematic/__init__.py index 0ab6f01805f..af3a54b861d 100644 --- a/homeassistant/components/homematic.py +++ b/homeassistant/components/homematic/__init__.py @@ -12,14 +12,13 @@ from functools import partial import voluptuous as vol -import homeassistant.helpers.config_validation as cv +from homeassistant.config import load_yaml_config_file from homeassistant.const import ( - EVENT_HOMEASSISTANT_STOP, STATE_UNKNOWN, CONF_USERNAME, CONF_PASSWORD, - CONF_PLATFORM, CONF_HOSTS, CONF_NAME, ATTR_ENTITY_ID) + EVENT_HOMEASSISTANT_STOP, CONF_USERNAME, CONF_PASSWORD, CONF_PLATFORM, + CONF_HOSTS, CONF_HOST, ATTR_ENTITY_ID, STATE_UNKNOWN) from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity -from homeassistant.helpers.event import track_time_interval -from homeassistant.config import load_yaml_config_file +import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['pyhomematic==0.1.36'] @@ -41,7 +40,7 @@ ATTR_CHANNEL = 'channel' ATTR_NAME = 'name' ATTR_ADDRESS = 'address' ATTR_VALUE = 'value' -ATTR_PROXY = 'proxy' +ATTR_INTERFACE = 'interface' ATTR_ERRORCODE = 'error' ATTR_MESSAGE = 'message' @@ -51,8 +50,8 @@ EVENT_ERROR = 'homematic.error' SERVICE_VIRTUALKEY = 'virtualkey' SERVICE_RECONNECT = 'reconnect' -SERVICE_SET_VAR_VALUE = 'set_var_value' -SERVICE_SET_DEV_VALUE = 'set_dev_value' +SERVICE_SET_VARIABLE_VALUE = 'set_variable_value' +SERVICE_SET_DEVICE_VALUE = 'set_device_value' HM_DEVICE_TYPES = { DISCOVER_SWITCHES: [ @@ -90,12 +89,14 @@ HM_ATTRIBUTE_SUPPORT = { 'RSSI_DEVICE': ['rssi', {}], 'VALVE_STATE': ['valve', {}], 'BATTERY_STATE': ['battery', {}], - 'CONTROL_MODE': ['mode', {0: 'Auto', - 1: 'Manual', - 2: 'Away', - 3: 'Boost', - 4: 'Comfort', - 5: 'Lowering'}], + 'CONTROL_MODE': ['mode', { + 0: 'Auto', + 1: 'Manual', + 2: 'Away', + 3: 'Boost', + 4: 'Comfort', + 5: 'Lowering' + }], 'POWER': ['power', {}], 'CURRENT': ['current', {}], 'VOLTAGE': ['voltage', {}], @@ -124,12 +125,12 @@ CONF_RESOLVENAMES_OPTIONS = [ ] DATA_HOMEMATIC = 'homematic' -DATA_DEVINIT = 'homematic_devinit' DATA_STORE = 'homematic_store' +DATA_CONF = 'homematic_conf' +CONF_INTERFACES = 'interfaces' CONF_LOCAL_IP = 'local_ip' CONF_LOCAL_PORT = 'local_port' -CONF_IP = 'ip' CONF_PORT = 'port' CONF_PATH = 'path' CONF_CALLBACK_IP = 'callback_ip' @@ -146,37 +147,33 @@ DEFAULT_PORT = 2001 DEFAULT_PATH = '' DEFAULT_USERNAME = 'Admin' DEFAULT_PASSWORD = '' -DEFAULT_VARIABLES = False -DEFAULT_DEVICES = True -DEFAULT_PRIMARY = False DEVICE_SCHEMA = vol.Schema({ vol.Required(CONF_PLATFORM): 'homematic', vol.Required(ATTR_NAME): cv.string, vol.Required(ATTR_ADDRESS): cv.string, - vol.Required(ATTR_PROXY): cv.string, + vol.Required(ATTR_INTERFACE): cv.string, vol.Optional(ATTR_CHANNEL, default=1): vol.Coerce(int), vol.Optional(ATTR_PARAM): cv.string, }) CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Required(CONF_HOSTS): {cv.match_all: { - vol.Required(CONF_IP): cv.string, + vol.Optional(CONF_INTERFACES, default={}): {cv.match_all: { + vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_PATH, default=DEFAULT_PATH): cv.string, - vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, - vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, - vol.Optional(CONF_VARIABLES, default=DEFAULT_VARIABLES): - cv.boolean, vol.Optional(CONF_RESOLVENAMES, default=DEFAULT_RESOLVENAMES): vol.In(CONF_RESOLVENAMES_OPTIONS), - vol.Optional(CONF_DEVICES, default=DEFAULT_DEVICES): cv.boolean, - vol.Optional(CONF_PRIMARY, default=DEFAULT_PRIMARY): cv.boolean, vol.Optional(CONF_CALLBACK_IP): cv.string, vol.Optional(CONF_CALLBACK_PORT): cv.port, }}, + vol.Optional(CONF_HOSTS, default={}): {cv.match_all: { + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, + vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, + }}, vol.Optional(CONF_LOCAL_IP, default=DEFAULT_LOCAL_IP): cv.string, vol.Optional(CONF_LOCAL_PORT, default=DEFAULT_LOCAL_PORT): cv.port, }), @@ -186,33 +183,33 @@ SCHEMA_SERVICE_VIRTUALKEY = vol.Schema({ vol.Required(ATTR_ADDRESS): vol.All(cv.string, vol.Upper), vol.Required(ATTR_CHANNEL): vol.Coerce(int), vol.Required(ATTR_PARAM): cv.string, - vol.Optional(ATTR_PROXY): cv.string, + vol.Optional(ATTR_INTERFACE): cv.string, }) -SCHEMA_SERVICE_SET_VAR_VALUE = vol.Schema({ +SCHEMA_SERVICE_SET_VARIABLE_VALUE = vol.Schema({ vol.Required(ATTR_NAME): cv.string, vol.Required(ATTR_VALUE): cv.match_all, vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, }) -SCHEMA_SERVICE_SET_DEV_VALUE = vol.Schema({ +SCHEMA_SERVICE_SET_DEVICE_VALUE = vol.Schema({ vol.Required(ATTR_ADDRESS): vol.All(cv.string, vol.Upper), vol.Required(ATTR_CHANNEL): vol.Coerce(int), vol.Required(ATTR_PARAM): vol.All(cv.string, vol.Upper), vol.Required(ATTR_VALUE): cv.match_all, - vol.Optional(ATTR_PROXY): cv.string, + vol.Optional(ATTR_INTERFACE): cv.string, }) SCHEMA_SERVICE_RECONNECT = vol.Schema({}) -def virtualkey(hass, address, channel, param, proxy=None): +def virtualkey(hass, address, channel, param, interface=None): """Send virtual keypress to homematic controlller.""" data = { ATTR_ADDRESS: address, ATTR_CHANNEL: channel, ATTR_PARAM: param, - ATTR_PROXY: proxy, + ATTR_INTERFACE: interface, } hass.services.call(DOMAIN, SERVICE_VIRTUALKEY, data) @@ -225,20 +222,20 @@ def set_var_value(hass, entity_id, value): ATTR_VALUE: value, } - hass.services.call(DOMAIN, SERVICE_SET_VAR_VALUE, data) + hass.services.call(DOMAIN, SERVICE_SET_VARIABLE_VALUE, data) -def set_dev_value(hass, address, channel, param, value, proxy=None): - """Call setValue XML-RPC method of supplied proxy.""" +def set_dev_value(hass, address, channel, param, value, interface=None): + """Call setValue XML-RPC method of supplied interface.""" data = { ATTR_ADDRESS: address, ATTR_CHANNEL: channel, ATTR_PARAM: param, ATTR_VALUE: value, - ATTR_PROXY: proxy, + ATTR_INTERFACE: interface, } - hass.services.call(DOMAIN, SERVICE_SET_DEV_VALUE, data) + hass.services.call(DOMAIN, SERVICE_SET_DEVICE_VALUE, data) def reconnect(hass): @@ -250,31 +247,32 @@ def setup(hass, config): """Set up the Homematic component.""" from pyhomematic import HMConnection - hass.data[DATA_DEVINIT] = {} + conf = config[DOMAIN] + hass.data[DATA_CONF] = remotes = {} hass.data[DATA_STORE] = set() # Create hosts-dictionary for pyhomematic - remotes = {} - hosts = {} - for rname, rconfig in config[DOMAIN][CONF_HOSTS].items(): - server = rconfig.get(CONF_IP) + for rname, rconfig in conf[CONF_INTERFACES].items(): + remotes[rname] = { + 'ip': rconfig.get(CONF_HOST), + 'port': rconfig.get(CONF_PORT), + 'path': rconfig.get(CONF_PATH), + 'resolvenames': rconfig.get(CONF_RESOLVENAMES), + 'username': rconfig.get(CONF_USERNAME), + 'password': rconfig.get(CONF_PASSWORD), + 'callbackip': rconfig.get(CONF_CALLBACK_IP), + 'callbackport': rconfig.get(CONF_CALLBACK_PORT), + 'connect': True, + } - remotes[rname] = {} - remotes[rname][CONF_IP] = server - remotes[rname][CONF_PORT] = rconfig.get(CONF_PORT) - remotes[rname][CONF_PATH] = rconfig.get(CONF_PATH) - remotes[rname][CONF_RESOLVENAMES] = rconfig.get(CONF_RESOLVENAMES) - remotes[rname][CONF_USERNAME] = rconfig.get(CONF_USERNAME) - remotes[rname][CONF_PASSWORD] = rconfig.get(CONF_PASSWORD) - remotes[rname]['callbackip'] = rconfig.get(CONF_CALLBACK_IP) - remotes[rname]['callbackport'] = rconfig.get(CONF_CALLBACK_PORT) - - if server not in hosts or rconfig.get(CONF_PRIMARY): - hosts[server] = { - CONF_VARIABLES: rconfig.get(CONF_VARIABLES), - CONF_NAME: rname, - } - hass.data[DATA_DEVINIT][rname] = rconfig.get(CONF_DEVICES) + for sname, sconfig in conf[CONF_HOSTS].items(): + remotes[sname] = { + 'ip': sconfig.get(CONF_HOST), + 'port': DEFAULT_PORT, + 'username': sconfig.get(CONF_USERNAME), + 'password': sconfig.get(CONF_PASSWORD), + 'connect': False, + } # Create server thread bound_system_callback = partial(_system_callback_handler, hass, config) @@ -295,9 +293,8 @@ def setup(hass, config): # Init homematic hubs entity_hubs = [] - for _, hub_data in hosts.items(): - entity_hubs.append(HMHub( - hass, homematic, hub_data[CONF_NAME], hub_data[CONF_VARIABLES])) + for hub_name in conf[CONF_HOSTS].keys(): + entity_hubs.append(HMHub(hass, homematic, hub_name)) # Register HomeMatic services descriptions = load_yaml_config_file( @@ -331,8 +328,7 @@ def setup(hass, config): hass.services.register( DOMAIN, SERVICE_VIRTUALKEY, _hm_service_virtualkey, - descriptions[DOMAIN][SERVICE_VIRTUALKEY], - schema=SCHEMA_SERVICE_VIRTUALKEY) + descriptions[SERVICE_VIRTUALKEY], schema=SCHEMA_SERVICE_VIRTUALKEY) def _service_handle_value(service): """Service to call setValue method for HomeMatic system variable.""" @@ -354,9 +350,9 @@ def setup(hass, config): hub.hm_set_variable(name, value) hass.services.register( - DOMAIN, SERVICE_SET_VAR_VALUE, _service_handle_value, - descriptions[DOMAIN][SERVICE_SET_VAR_VALUE], - schema=SCHEMA_SERVICE_SET_VAR_VALUE) + DOMAIN, SERVICE_SET_VARIABLE_VALUE, _service_handle_value, + descriptions[SERVICE_SET_VARIABLE_VALUE], + schema=SCHEMA_SERVICE_SET_VARIABLE_VALUE) def _service_handle_reconnect(service): """Service to reconnect all HomeMatic hubs.""" @@ -364,8 +360,7 @@ def setup(hass, config): hass.services.register( DOMAIN, SERVICE_RECONNECT, _service_handle_reconnect, - descriptions[DOMAIN][SERVICE_RECONNECT], - schema=SCHEMA_SERVICE_RECONNECT) + descriptions[SERVICE_RECONNECT], schema=SCHEMA_SERVICE_RECONNECT) def _service_handle_device(service): """Service to call setValue method for HomeMatic devices.""" @@ -383,9 +378,9 @@ def setup(hass, config): hmdevice.setValue(param, value, channel) hass.services.register( - DOMAIN, SERVICE_SET_DEV_VALUE, _service_handle_device, - descriptions[DOMAIN][SERVICE_SET_DEV_VALUE], - schema=SCHEMA_SERVICE_SET_DEV_VALUE) + DOMAIN, SERVICE_SET_DEVICE_VALUE, _service_handle_device, + descriptions[SERVICE_SET_DEVICE_VALUE], + schema=SCHEMA_SERVICE_SET_DEVICE_VALUE) return True @@ -395,10 +390,10 @@ def _system_callback_handler(hass, config, src, *args): # New devices available at hub if src == 'newDevices': (interface_id, dev_descriptions) = args - proxy = interface_id.split('-')[-1] + interface = interface_id.split('-')[-1] # Device support active? - if not hass.data[DATA_DEVINIT][proxy]: + if not hass.data[DATA_CONF][interface]['connect']: return addresses = [] @@ -410,9 +405,9 @@ def _system_callback_handler(hass, config, src, *args): # Register EVENTS # Search all devices with an EVENTNODE that includes data - bound_event_callback = partial(_hm_event_handler, hass, proxy) + bound_event_callback = partial(_hm_event_handler, hass, interface) for dev in addresses: - hmdevice = hass.data[DATA_HOMEMATIC].devices[proxy].get(dev) + hmdevice = hass.data[DATA_HOMEMATIC].devices[interface].get(dev) if hmdevice.EVENTNODE: hmdevice.setEventCallback( @@ -429,7 +424,7 @@ def _system_callback_handler(hass, config, src, *args): ('climate', DISCOVER_CLIMATE)): # Get all devices of a specific type found_devices = _get_devices( - hass, discovery_type, addresses, proxy) + hass, discovery_type, addresses, interface) # When devices of this type are found # they are setup in HASS and an discovery event is fired @@ -448,12 +443,12 @@ def _system_callback_handler(hass, config, src, *args): }) -def _get_devices(hass, discovery_type, keys, proxy): +def _get_devices(hass, discovery_type, keys, interface): """Get the HomeMatic devices for given discovery_type.""" device_arr = [] for key in keys: - device = hass.data[DATA_HOMEMATIC].devices[proxy][key] + device = hass.data[DATA_HOMEMATIC].devices[interface][key] class_name = device.__class__.__name__ metadata = {} @@ -485,7 +480,7 @@ def _get_devices(hass, discovery_type, keys, proxy): device_dict = { CONF_PLATFORM: "homematic", ATTR_ADDRESS: key, - ATTR_PROXY: proxy, + ATTR_INTERFACE: interface, ATTR_NAME: name, ATTR_CHANNEL: channel } @@ -521,12 +516,12 @@ def _create_ha_name(name, channel, param, count): return "{} {} {}".format(name, channel, param) -def _hm_event_handler(hass, proxy, device, caller, attribute, value): +def _hm_event_handler(hass, interface, device, caller, attribute, value): """Handle all pyhomematic device events.""" try: channel = int(device.split(":")[1]) address = device.split(":")[0] - hmdevice = hass.data[DATA_HOMEMATIC].devices[proxy].get(address) + hmdevice = hass.data[DATA_HOMEMATIC].devices[interface].get(address) except (TypeError, ValueError): _LOGGER.error("Event handling channel convert error!") return @@ -561,14 +556,14 @@ def _hm_event_handler(hass, proxy, device, caller, attribute, value): def _device_from_servicecall(hass, service): """Extract HomeMatic device from service call.""" address = service.data.get(ATTR_ADDRESS) - proxy = service.data.get(ATTR_PROXY) + interface = service.data.get(ATTR_INTERFACE) if address == 'BIDCOS-RF': address = 'BidCoS-RF' - if proxy: - return hass.data[DATA_HOMEMATIC].devices[proxy].get(address) + if interface: + return hass.data[DATA_HOMEMATIC].devices[interface].get(address) - for _, devices in hass.data[DATA_HOMEMATIC].devices.items(): + for devices in hass.data[DATA_HOMEMATIC].devices.values(): if address in devices: return devices[address] @@ -576,25 +571,23 @@ def _device_from_servicecall(hass, service): class HMHub(Entity): """The HomeMatic hub. (CCU2/HomeGear).""" - def __init__(self, hass, homematic, name, use_variables): + def __init__(self, hass, homematic, name): """Initialize HomeMatic hub.""" self.hass = hass self.entity_id = "{}.{}".format(DOMAIN, name.lower()) self._homematic = homematic self._variables = {} self._name = name - self._state = STATE_UNKNOWN - self._use_variables = use_variables + self._state = None # Load data - track_time_interval( - self.hass, self._update_hub, SCAN_INTERVAL_HUB) + self.hass.helpers.event.track_time_interval( + self._update_hub, SCAN_INTERVAL_HUB) self.hass.add_job(self._update_hub, None) - if self._use_variables: - track_time_interval( - self.hass, self._update_variables, SCAN_INTERVAL_VARIABLES) - self.hass.add_job(self._update_variables, None) + self.hass.helpers.event.track_time_interval( + self._update_variables, SCAN_INTERVAL_VARIABLES) + self.hass.add_job(self._update_variables, None) @property def name(self): @@ -672,7 +665,7 @@ class HMDevice(Entity): """Initialize a generic HomeMatic device.""" self._name = config.get(ATTR_NAME) self._address = config.get(ATTR_ADDRESS) - self._proxy = config.get(ATTR_PROXY) + self._interface = config.get(ATTR_INTERFACE) self._channel = config.get(ATTR_CHANNEL) self._state = config.get(ATTR_PARAM) self._data = {} @@ -700,11 +693,6 @@ class HMDevice(Entity): """Return the name of the device.""" return self._name - @property - def assumed_state(self): - """Return true if unable to access real state of the device.""" - return not self._available - @property def available(self): """Return true if device is available.""" @@ -728,7 +716,7 @@ class HMDevice(Entity): # Static attributes attr['id'] = self._hmdevice.ADDRESS - attr['proxy'] = self._proxy + attr['interface'] = self._interface return attr @@ -739,7 +727,8 @@ class HMDevice(Entity): # Initialize self._homematic = self.hass.data[DATA_HOMEMATIC] - self._hmdevice = self._homematic.devices[self._proxy][self._address] + self._hmdevice = \ + self._homematic.devices[self._interface][self._address] self._connected = True try: diff --git a/homeassistant/components/homematic/services.yaml b/homeassistant/components/homematic/services.yaml new file mode 100644 index 00000000000..76ecdbd0a4f --- /dev/null +++ b/homeassistant/components/homematic/services.yaml @@ -0,0 +1,52 @@ +# Describes the format for available component services + +virtualkey: + description: Press a virtual key from CCU/Homegear or simulate keypress. + fields: + address: + description: Address of homematic device or BidCoS-RF for virtual remote. + example: BidCoS-RF + channel: + description: Channel for calling a keypress. + example: 1 + param: + description: Event to send i.e. PRESS_LONG, PRESS_SHORT. + example: PRESS_LONG + interface: + description: (Optional) for set a hosts value. + example: Hosts name from config + +set_variable_value: + description: Set the name of a node. + fields: + entity_id: + description: Name(s) of homematic central to set value. + example: 'homematic.ccu2' + name: + description: Name of the variable to set. + example: 'testvariable' + value: + description: New value + example: 1 + +set_device_value: + description: Set a device property on RPC XML interface. + fields: + address: + description: Address of homematic device or BidCoS-RF for virtual remote + example: BidCoS-RF + channel: + description: Channel for calling a keypress + example: 1 + param: + description: Event to send i.e. PRESS_LONG, PRESS_SHORT + example: PRESS_LONG + interface: + description: (Optional) for set a hosts value + example: Hosts name from config + value: + description: New value + example: 1 + +reconnect: + description: Reconnect to all Homematic Hubs. diff --git a/homeassistant/components/services.yaml b/homeassistant/components/services.yaml index c532c0dfd20..90a1bbbc613 100644 --- a/homeassistant/components/services.yaml +++ b/homeassistant/components/services.yaml @@ -32,55 +32,6 @@ foursquare: description: Vertical accuracy of the user's location, in meters. example: 1 -homematic: - virtualkey: - description: Press a virtual key from CCU/Homegear or simulate keypress. - fields: - address: - description: Address of homematic device or BidCoS-RF for virtual remote. - example: BidCoS-RF - channel: - description: Channel for calling a keypress. - example: 1 - param: - description: Event to send i.e. PRESS_LONG, PRESS_SHORT. - example: PRESS_LONG - proxy: - description: (Optional) for set a hosts value. - example: Hosts name from config - set_var_value: - description: Set the name of a node. - fields: - entity_id: - description: Name(s) of homematic central to set value. - example: 'homematic.ccu2' - name: - description: Name of the variable to set. - example: 'testvariable' - value: - description: New value - example: 1 - set_dev_value: - description: Set a device property on RPC XML interface. - fields: - address: - description: Address of homematic device or BidCoS-RF for virtual remote - example: BidCoS-RF - channel: - description: Channel for calling a keypress - example: 1 - param: - description: Event to send i.e. PRESS_LONG, PRESS_SHORT - example: PRESS_LONG - proxy: - description: (Optional) for set a hosts value - example: Hosts name from config - value: - description: New value - example: 1 - reconnect: - description: Reconnect to all Homematic Hubs. - microsoft_face: create_group: description: Create a new person group. From a7c8e202aae115a857fbfad7fdb62b88675e34f0 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 15 Dec 2017 22:54:54 +0100 Subject: [PATCH 021/238] Resolve hostnames (#11160) --- homeassistant/components/homematic/__init__.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/homematic/__init__.py b/homeassistant/components/homematic/__init__.py index af3a54b861d..ffee6278f40 100644 --- a/homeassistant/components/homematic/__init__.py +++ b/homeassistant/components/homematic/__init__.py @@ -5,10 +5,11 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/homematic/ """ import asyncio -import os -import logging from datetime import timedelta from functools import partial +import logging +import os +import socket import voluptuous as vol @@ -254,7 +255,7 @@ def setup(hass, config): # Create hosts-dictionary for pyhomematic for rname, rconfig in conf[CONF_INTERFACES].items(): remotes[rname] = { - 'ip': rconfig.get(CONF_HOST), + 'ip': socket.gethostbyname(rconfig.get(CONF_HOST)), 'port': rconfig.get(CONF_PORT), 'path': rconfig.get(CONF_PATH), 'resolvenames': rconfig.get(CONF_RESOLVENAMES), @@ -267,7 +268,7 @@ def setup(hass, config): for sname, sconfig in conf[CONF_HOSTS].items(): remotes[sname] = { - 'ip': sconfig.get(CONF_HOST), + 'ip': socket.gethostbyname(sconfig.get(CONF_HOST)), 'port': DEFAULT_PORT, 'username': sconfig.get(CONF_USERNAME), 'password': sconfig.get(CONF_PASSWORD), From 3d5d90241f2bc5778f42f352b871171145a5acbb Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 15 Dec 2017 23:35:37 -0800 Subject: [PATCH 022/238] Update frontend --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 9d97a7439bd..cd206135dde 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -23,7 +23,7 @@ from homeassistant.const import CONF_NAME, EVENT_THEMES_UPDATED from homeassistant.core import callback from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20171206.0', 'user-agents==1.1.0'] +REQUIREMENTS = ['home-assistant-frontend==20171216.0', 'user-agents==1.1.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] diff --git a/requirements_all.txt b/requirements_all.txt index 6466be2f651..89711fe9c96 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -340,7 +340,7 @@ hipnotify==1.0.8 holidays==0.8.1 # homeassistant.components.frontend -home-assistant-frontend==20171206.0 +home-assistant-frontend==20171216.0 # homeassistant.components.camera.onvif http://github.com/tgaugry/suds-passworddigest-py3/archive/86fc50e39b4d2b8997481967d6a7fe1c57118999.zip#suds-passworddigest-py3==0.1.2a diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 363f561de0d..877e129e0ff 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -77,7 +77,7 @@ hbmqtt==0.9.1 holidays==0.8.1 # homeassistant.components.frontend -home-assistant-frontend==20171206.0 +home-assistant-frontend==20171216.0 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb From c4d71e934d557cca8ea2d03570d68e67ac77c1db Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Sat, 16 Dec 2017 03:04:27 -0500 Subject: [PATCH 023/238] Perform logbook filtering on the worker thread (#11161) --- homeassistant/components/logbook.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/logbook.py b/homeassistant/components/logbook.py index 63a271acdd5..1dc0861d737 100644 --- a/homeassistant/components/logbook.py +++ b/homeassistant/components/logbook.py @@ -135,9 +135,8 @@ class LogbookView(HomeAssistantView): hass = request.app['hass'] events = yield from hass.async_add_job( - _get_events, hass, start_day, end_day) - events = _exclude_events(events, self.config) - return self.json(humanify(events)) + _get_events, hass, self.config, start_day, end_day) + return self.json(events) class Entry(object): @@ -274,7 +273,7 @@ def humanify(events): entity_id) -def _get_events(hass, start_day, end_day): +def _get_events(hass, config, start_day, end_day): """Get events for a period of time.""" from homeassistant.components.recorder.models import Events from homeassistant.components.recorder.util import ( @@ -285,7 +284,8 @@ def _get_events(hass, start_day, end_day): Events.time_fired).filter( (Events.time_fired > start_day) & (Events.time_fired < end_day)) - return execute(query) + events = execute(query) + return humanify(_exclude_events(events, config)) def _exclude_events(events, config): From b56675a7bbeeb5a72272dff566bd5ac22652dbfa Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 16 Dec 2017 00:42:25 -0800 Subject: [PATCH 024/238] Don't connect to cloud if subscription expired (#11163) * Final touch for cloud component * Fix test --- homeassistant/components/cloud/__init__.py | 14 +++++--------- homeassistant/components/cloud/const.py | 13 ++++++------- homeassistant/config.py | 3 +++ tests/components/cloud/test_init.py | 9 --------- 4 files changed, 14 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 2844b0c88f3..58a2152f898 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -27,7 +27,7 @@ CONF_RELAYER = 'relayer' CONF_USER_POOL_ID = 'user_pool_id' MODE_DEV = 'development' -DEFAULT_MODE = MODE_DEV +DEFAULT_MODE = 'production' DEPENDENCIES = ['http'] ALEXA_SCHEMA = vol.Schema({ @@ -42,10 +42,10 @@ CONFIG_SCHEMA = vol.Schema({ vol.Optional(CONF_MODE, default=DEFAULT_MODE): vol.In([MODE_DEV] + list(SERVERS)), # Change to optional when we include real servers - vol.Required(CONF_COGNITO_CLIENT_ID): str, - vol.Required(CONF_USER_POOL_ID): str, - vol.Required(CONF_REGION): str, - vol.Required(CONF_RELAYER): str, + vol.Optional(CONF_COGNITO_CLIENT_ID): str, + vol.Optional(CONF_USER_POOL_ID): str, + vol.Optional(CONF_REGION): str, + vol.Optional(CONF_RELAYER): str, vol.Optional(CONF_ALEXA): ALEXA_SCHEMA }), }, extra=vol.ALLOW_EXTRA) @@ -117,10 +117,6 @@ class Cloud: @property def subscription_expired(self): """Return a boolen if the subscription has expired.""" - # For now, don't enforce subscriptions to exist - if 'custom:sub-exp' not in self.claims: - return False - return dt_util.utcnow() > self.expiration_date @property diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 440e4179eea..b13ec6d1e45 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -4,13 +4,12 @@ CONFIG_DIR = '.cloud' REQUEST_TIMEOUT = 10 SERVERS = { - # Example entry: - # 'production': { - # 'cognito_client_id': '', - # 'user_pool_id': '', - # 'region': '', - # 'relayer': '' - # } + 'production': { + 'cognito_client_id': '60i2uvhvbiref2mftj7rgcrt9u', + 'user_pool_id': 'us-east-1_87ll5WOP8', + 'region': 'us-east-1', + 'relayer': 'wss://cloud.hass.io:8000/websocket' + } } MESSAGE_EXPIRATION = """ diff --git a/homeassistant/config.py b/homeassistant/config.py index c4c96804fca..fee7572a2c2 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -110,6 +110,9 @@ sensor: tts: - platform: google +# Cloud +cloud: + group: !include groups.yaml automation: !include automations.yaml script: !include scripts.yaml diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index c05fdabf465..c5bb6f7fda7 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -132,15 +132,6 @@ def test_write_user_info(): } -@asyncio.coroutine -def test_subscription_not_expired_without_sub_in_claim(): - """Test that we do not enforce subscriptions yet.""" - cl = cloud.Cloud(None, cloud.MODE_DEV) - cl.id_token = jwt.encode({}, 'test') - - assert not cl.subscription_expired - - @asyncio.coroutine def test_subscription_expired(): """Test subscription being expired.""" From 39af43eb5cc32dab8b62a2e7a52feda93e40a01d Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Sat, 16 Dec 2017 14:22:23 +0100 Subject: [PATCH 025/238] Add install mode to homematic (#11164) --- .../components/homematic/__init__.py | 53 +++++++++++++++++-- .../components/homematic/services.yaml | 24 +++++++-- 2 files changed, 68 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/homematic/__init__.py b/homeassistant/components/homematic/__init__.py index ffee6278f40..a11c8c0f22c 100644 --- a/homeassistant/components/homematic/__init__.py +++ b/homeassistant/components/homematic/__init__.py @@ -20,10 +20,11 @@ from homeassistant.const import ( from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv +from homeassistant.loader import bind_hass REQUIREMENTS = ['pyhomematic==0.1.36'] - DOMAIN = 'homematic' +_LOGGER = logging.getLogger(__name__) SCAN_INTERVAL_HUB = timedelta(seconds=300) SCAN_INTERVAL_VARIABLES = timedelta(seconds=30) @@ -44,6 +45,8 @@ ATTR_VALUE = 'value' ATTR_INTERFACE = 'interface' ATTR_ERRORCODE = 'error' ATTR_MESSAGE = 'message' +ATTR_MODE = 'mode' +ATTR_TIME = 'time' EVENT_KEYPRESS = 'homematic.keypress' EVENT_IMPULSE = 'homematic.impulse' @@ -53,6 +56,7 @@ SERVICE_VIRTUALKEY = 'virtualkey' SERVICE_RECONNECT = 'reconnect' SERVICE_SET_VARIABLE_VALUE = 'set_variable_value' SERVICE_SET_DEVICE_VALUE = 'set_device_value' +SERVICE_SET_INSTALL_MODE = 'set_install_mode' HM_DEVICE_TYPES = { DISCOVER_SWITCHES: [ @@ -116,8 +120,6 @@ HM_IMPULSE_EVENTS = [ 'SEQUENCE_OK', ] -_LOGGER = logging.getLogger(__name__) - CONF_RESOLVENAMES_OPTIONS = [ 'metadata', 'json', @@ -203,7 +205,16 @@ SCHEMA_SERVICE_SET_DEVICE_VALUE = vol.Schema({ SCHEMA_SERVICE_RECONNECT = vol.Schema({}) +SCHEMA_SERVICE_SET_INSTALL_MODE = vol.Schema({ + vol.Required(ATTR_INTERFACE): cv.string, + vol.Optional(ATTR_TIME, default=60): cv.positive_int, + vol.Optional(ATTR_MODE, default=1): + vol.All(vol.Coerce(int), vol.In([1, 2])), + vol.Optional(ATTR_ADDRESS): vol.All(cv.string, vol.Upper), +}) + +@bind_hass def virtualkey(hass, address, channel, param, interface=None): """Send virtual keypress to homematic controlller.""" data = { @@ -216,7 +227,8 @@ def virtualkey(hass, address, channel, param, interface=None): hass.services.call(DOMAIN, SERVICE_VIRTUALKEY, data) -def set_var_value(hass, entity_id, value): +@bind_hass +def set_variable_value(hass, entity_id, value): """Change value of a Homematic system variable.""" data = { ATTR_ENTITY_ID: entity_id, @@ -226,7 +238,8 @@ def set_var_value(hass, entity_id, value): hass.services.call(DOMAIN, SERVICE_SET_VARIABLE_VALUE, data) -def set_dev_value(hass, address, channel, param, value, interface=None): +@bind_hass +def set_device_value(hass, address, channel, param, value, interface=None): """Call setValue XML-RPC method of supplied interface.""" data = { ATTR_ADDRESS: address, @@ -239,6 +252,22 @@ def set_dev_value(hass, address, channel, param, value, interface=None): hass.services.call(DOMAIN, SERVICE_SET_DEVICE_VALUE, data) +@bind_hass +def set_install_mode(hass, interface, mode=None, time=None, address=None): + """Call setInstallMode XML-RPC method of supplied inteface.""" + data = { + key: value for key, value in ( + (ATTR_INTERFACE, interface), + (ATTR_MODE, mode), + (ATTR_TIME, time), + (ATTR_ADDRESS, address) + ) if value + } + + hass.services.call(DOMAIN, SERVICE_SET_INSTALL_MODE, data) + + +@bind_hass def reconnect(hass): """Reconnect to CCU/Homegear.""" hass.services.call(DOMAIN, SERVICE_RECONNECT, {}) @@ -383,6 +412,20 @@ def setup(hass, config): descriptions[SERVICE_SET_DEVICE_VALUE], schema=SCHEMA_SERVICE_SET_DEVICE_VALUE) + def _service_handle_install_mode(service): + """Service to set interface into install mode.""" + interface = service.data.get(ATTR_INTERFACE) + mode = service.data.get(ATTR_MODE) + time = service.data.get(ATTR_TIME) + address = service.data.get(ATTR_ADDRESS) + + homematic.setInstallMode(interface, t=time, mode=mode, address=address) + + hass.services.register( + DOMAIN, SERVICE_SET_INSTALL_MODE, _service_handle_install_mode, + descriptions[SERVICE_SET_INSTALL_MODE], + schema=SCHEMA_SERVICE_SET_INSTALL_MODE) + return True diff --git a/homeassistant/components/homematic/services.yaml b/homeassistant/components/homematic/services.yaml index 76ecdbd0a4f..bf4d99af9e7 100644 --- a/homeassistant/components/homematic/services.yaml +++ b/homeassistant/components/homematic/services.yaml @@ -13,8 +13,8 @@ virtualkey: description: Event to send i.e. PRESS_LONG, PRESS_SHORT. example: PRESS_LONG interface: - description: (Optional) for set a hosts value. - example: Hosts name from config + description: (Optional) for set a interface value. + example: Interfaces name from config set_variable_value: description: Set the name of a node. @@ -42,11 +42,27 @@ set_device_value: description: Event to send i.e. PRESS_LONG, PRESS_SHORT example: PRESS_LONG interface: - description: (Optional) for set a hosts value - example: Hosts name from config + description: (Optional) for set a interface value + example: Interfaces name from config value: description: New value example: 1 reconnect: description: Reconnect to all Homematic Hubs. + +set_install_mode: + description: Set a RPC XML interface into installation mode. + fields: + interface: + description: Select the given interface into install mode + example: Interfaces name from config + mode: + description: (Default 1) 1= Normal mode / 2= Remove exists old links + example: 1 + time: + description: (Default 60) Time in seconds to run in install mode + example: 1 + address: + description: (Optional) Address of homematic device or BidCoS-RF to learn + example: LEQ3948571 From 793b8b8ad360efb531cc721ec1a7a809a394586e Mon Sep 17 00:00:00 2001 From: Mike Megally Date: Sat, 16 Dec 2017 13:29:40 -0800 Subject: [PATCH 026/238] Remove logging (#11173) An error was being log that seems more like debug info --- homeassistant/components/sensor/octoprint.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/sensor/octoprint.py b/homeassistant/components/sensor/octoprint.py index 85b388a1919..71b72b0a671 100644 --- a/homeassistant/components/sensor/octoprint.py +++ b/homeassistant/components/sensor/octoprint.py @@ -45,7 +45,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): name = config.get(CONF_NAME) monitored_conditions = config.get(CONF_MONITORED_CONDITIONS) tools = octoprint_api.get_tools() - _LOGGER.error(str(tools)) if "Temperatures" in monitored_conditions: if not tools: From 640d58f0a8eee733c4275f33cd3d289b007d93cd Mon Sep 17 00:00:00 2001 From: Matthew Treinish Date: Sat, 16 Dec 2017 15:52:40 -0800 Subject: [PATCH 027/238] Fix X10 commands for mochad light turn on (#11146) * Fix X10 commands for mochad light turn on This commit attempts to address issues that a lot of people are having with the x10 light component. Originally this was written to use the xdim (extended dim) X10 command. However, not every X10 dimmer device supports the xdim command. Additionally, it turns out the number of dim/brighness levels the X10 device supports is device specific and there is no way to detect this (given the mostly 1 way nature of X10) To address these issues, this commit removes the usage of xdim and instead relies on using the 'on' command and the 'dim' command. This should work on all x10 light devices. In an attempt to address the different dim/brightness levels supported by different devices this commit also adds a new optional config value, 'brightness_levels', to specify if it's either 32, 64, or 256. By default 32 levels are used as this is the normal case and what is documented by mochad. Fixes #8943 * make code more readable * fix style * fix lint * fix tests --- homeassistant/components/light/mochad.py | 45 ++++++++++++++-- tests/components/light/test_mochad.py | 68 +++++++++++++++++++++++- 2 files changed, 107 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/light/mochad.py b/homeassistant/components/light/mochad.py index 3d67edaf7cb..efc62b05434 100644 --- a/homeassistant/components/light/mochad.py +++ b/homeassistant/components/light/mochad.py @@ -12,13 +12,15 @@ import voluptuous as vol from homeassistant.components.light import ( ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light, PLATFORM_SCHEMA) from homeassistant.components import mochad -from homeassistant.const import (CONF_NAME, CONF_PLATFORM, CONF_DEVICES, - CONF_ADDRESS) +from homeassistant.const import ( + CONF_NAME, CONF_PLATFORM, CONF_DEVICES, CONF_ADDRESS) from homeassistant.helpers import config_validation as cv DEPENDENCIES = ['mochad'] _LOGGER = logging.getLogger(__name__) +CONF_BRIGHTNESS_LEVELS = 'brightness_levels' + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_PLATFORM): mochad.DOMAIN, @@ -26,6 +28,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME): cv.string, vol.Required(CONF_ADDRESS): cv.x10_address, vol.Optional(mochad.CONF_COMM_TYPE): cv.string, + vol.Optional(CONF_BRIGHTNESS_LEVELS, default=32): + vol.All(vol.Coerce(int), vol.In([32, 64, 256])), }] }) @@ -54,6 +58,7 @@ class MochadLight(Light): comm_type=self._comm_type) self._brightness = 0 self._state = self._get_device_status() + self._brightness_levels = dev.get(CONF_BRIGHTNESS_LEVELS) - 1 @property def brightness(self): @@ -86,12 +91,38 @@ class MochadLight(Light): """X10 devices are normally 1-way so we have to assume the state.""" return True + def _calculate_brightness_value(self, value): + return int(value * (float(self._brightness_levels) / 255.0)) + + def _adjust_brightness(self, brightness): + if self._brightness > brightness: + bdelta = self._brightness - brightness + mochad_brightness = self._calculate_brightness_value(bdelta) + self.device.send_cmd("dim {}".format(mochad_brightness)) + self._controller.read_data() + elif self._brightness < brightness: + bdelta = brightness - self._brightness + mochad_brightness = self._calculate_brightness_value(bdelta) + self.device.send_cmd("bright {}".format(mochad_brightness)) + self._controller.read_data() + def turn_on(self, **kwargs): """Send the command to turn the light on.""" - self._brightness = kwargs.get(ATTR_BRIGHTNESS, 255) + brightness = kwargs.get(ATTR_BRIGHTNESS, 255) with mochad.REQ_LOCK: - self.device.send_cmd("xdim {}".format(self._brightness)) - self._controller.read_data() + if self._brightness_levels > 32: + out_brightness = self._calculate_brightness_value(brightness) + self.device.send_cmd('xdim {}'.format(out_brightness)) + self._controller.read_data() + else: + self.device.send_cmd("on") + self._controller.read_data() + # There is no persistence for X10 modules so a fresh on command + # will be full brightness + if self._brightness == 0: + self._brightness = 255 + self._adjust_brightness(brightness) + self._brightness = brightness self._state = True def turn_off(self, **kwargs): @@ -99,4 +130,8 @@ class MochadLight(Light): with mochad.REQ_LOCK: self.device.send_cmd('off') self._controller.read_data() + # There is no persistence for X10 modules so we need to prepare + # to track a fresh on command will full brightness + if self._brightness_levels == 31: + self._brightness = 0 self._state = False diff --git a/tests/components/light/test_mochad.py b/tests/components/light/test_mochad.py index e69ebdb4aef..5c82ab06085 100644 --- a/tests/components/light/test_mochad.py +++ b/tests/components/light/test_mochad.py @@ -60,7 +60,8 @@ class TestMochadLight(unittest.TestCase): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() controller_mock = mock.MagicMock() - dev_dict = {'address': 'a1', 'name': 'fake_light'} + dev_dict = {'address': 'a1', 'name': 'fake_light', + 'brightness_levels': 32} self.light = mochad.MochadLight(self.hass, controller_mock, dev_dict) @@ -72,6 +73,39 @@ class TestMochadLight(unittest.TestCase): """Test the name.""" self.assertEqual('fake_light', self.light.name) + def test_turn_on_with_no_brightness(self): + """Test turn_on.""" + self.light.turn_on() + self.light.device.send_cmd.assert_called_once_with('on') + + def test_turn_on_with_brightness(self): + """Test turn_on.""" + self.light.turn_on(brightness=45) + self.light.device.send_cmd.assert_has_calls( + [mock.call('on'), mock.call('dim 25')]) + + def test_turn_off(self): + """Test turn_off.""" + self.light.turn_off() + self.light.device.send_cmd.assert_called_once_with('off') + + +class TestMochadLight256Levels(unittest.TestCase): + """Test for mochad light platform.""" + + def setUp(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + controller_mock = mock.MagicMock() + dev_dict = {'address': 'a1', 'name': 'fake_light', + 'brightness_levels': 256} + self.light = mochad.MochadLight(self.hass, controller_mock, + dev_dict) + + def teardown_method(self, method): + """Stop everything that was started.""" + self.hass.stop() + def test_turn_on_with_no_brightness(self): """Test turn_on.""" self.light.turn_on() @@ -86,3 +120,35 @@ class TestMochadLight(unittest.TestCase): """Test turn_off.""" self.light.turn_off() self.light.device.send_cmd.assert_called_once_with('off') + + +class TestMochadLight64Levels(unittest.TestCase): + """Test for mochad light platform.""" + + def setUp(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + controller_mock = mock.MagicMock() + dev_dict = {'address': 'a1', 'name': 'fake_light', + 'brightness_levels': 64} + self.light = mochad.MochadLight(self.hass, controller_mock, + dev_dict) + + def teardown_method(self, method): + """Stop everything that was started.""" + self.hass.stop() + + def test_turn_on_with_no_brightness(self): + """Test turn_on.""" + self.light.turn_on() + self.light.device.send_cmd.assert_called_once_with('xdim 63') + + def test_turn_on_with_brightness(self): + """Test turn_on.""" + self.light.turn_on(brightness=45) + self.light.device.send_cmd.assert_called_once_with('xdim 11') + + def test_turn_off(self): + """Test turn_off.""" + self.light.turn_off() + self.light.device.send_cmd.assert_called_once_with('off') From 3375261f517f220755eb17bd0b29fe2a5e22a142 Mon Sep 17 00:00:00 2001 From: PhracturedBlue Date: Sat, 16 Dec 2017 15:52:59 -0800 Subject: [PATCH 028/238] convert alarmdecoder interface from async to sync (#11168) * convert alarmdecoder interface from async to sync * Convert he rest of alarmdecoder rom async to sync * Update alarmdecoder.py * Update alarmdecoder.py * Update alarmdecoder.py * Update alarmdecoder.py * Update alarmdecoder.py * Update alarmdecoder.py * Update alarmdecoder.py * Update alarmdecoder.py * Update alarmdecoder.py * Update alarmdecoder.py * Update alarmdecoder.py * Update alarmdecoder.py --- .../alarm_control_panel/alarmdecoder.py | 52 ++++++------------- homeassistant/components/alarmdecoder.py | 51 ++++++------------ .../components/binary_sensor/alarmdecoder.py | 40 +++++--------- .../components/sensor/alarmdecoder.py | 21 +++----- 4 files changed, 54 insertions(+), 110 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/alarmdecoder.py b/homeassistant/components/alarm_control_panel/alarmdecoder.py index 3b58eb0b71d..d5fbbec5998 100644 --- a/homeassistant/components/alarm_control_panel/alarmdecoder.py +++ b/homeassistant/components/alarm_control_panel/alarmdecoder.py @@ -7,30 +7,21 @@ https://home-assistant.io/components/alarm_control_panel.alarmdecoder/ import asyncio import logging -from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect import homeassistant.components.alarm_control_panel as alarm - -from homeassistant.components.alarmdecoder import (DATA_AD, - SIGNAL_PANEL_MESSAGE) - +from homeassistant.components.alarmdecoder import ( + DATA_AD, SIGNAL_PANEL_MESSAGE) from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, - STATE_UNKNOWN, STATE_ALARM_TRIGGERED) + STATE_ALARM_TRIGGERED) _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['alarmdecoder'] -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Set up for AlarmDecoder alarm panels.""" - _LOGGER.debug("AlarmDecoderAlarmPanel: setup") - - device = AlarmDecoderAlarmPanel("Alarm Panel", hass) - - async_add_devices([device]) + add_devices([AlarmDecoderAlarmPanel()]) return True @@ -38,38 +29,35 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): class AlarmDecoderAlarmPanel(alarm.AlarmControlPanel): """Representation of an AlarmDecoder-based alarm panel.""" - def __init__(self, name, hass): + def __init__(self): """Initialize the alarm panel.""" self._display = "" - self._name = name - self._state = STATE_UNKNOWN - - _LOGGER.debug("Setting up panel") + self._name = "Alarm Panel" + self._state = None @asyncio.coroutine def async_added_to_hass(self): """Register callbacks.""" - async_dispatcher_connect( - self.hass, SIGNAL_PANEL_MESSAGE, self._message_callback) + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_PANEL_MESSAGE, self._message_callback) - @callback def _message_callback(self, message): if message.alarm_sounding or message.fire_alarm: if self._state != STATE_ALARM_TRIGGERED: self._state = STATE_ALARM_TRIGGERED - self.async_schedule_update_ha_state() + self.schedule_update_ha_state() elif message.armed_away: if self._state != STATE_ALARM_ARMED_AWAY: self._state = STATE_ALARM_ARMED_AWAY - self.async_schedule_update_ha_state() + self.schedule_update_ha_state() elif message.armed_home: if self._state != STATE_ALARM_ARMED_HOME: self._state = STATE_ALARM_ARMED_HOME - self.async_schedule_update_ha_state() + self.schedule_update_ha_state() else: if self._state != STATE_ALARM_DISARMED: self._state = STATE_ALARM_DISARMED - self.async_schedule_update_ha_state() + self.schedule_update_ha_state() @property def name(self): @@ -91,26 +79,20 @@ class AlarmDecoderAlarmPanel(alarm.AlarmControlPanel): """Return the state of the device.""" return self._state - @asyncio.coroutine - def async_alarm_disarm(self, code=None): + def alarm_disarm(self, code=None): """Send disarm command.""" - _LOGGER.debug("alarm_disarm: %s", code) if code: _LOGGER.debug("alarm_disarm: sending %s1", str(code)) self.hass.data[DATA_AD].send("{!s}1".format(code)) - @asyncio.coroutine - def async_alarm_arm_away(self, code=None): + def alarm_arm_away(self, code=None): """Send arm away command.""" - _LOGGER.debug("alarm_arm_away: %s", code) if code: _LOGGER.debug("alarm_arm_away: sending %s2", str(code)) self.hass.data[DATA_AD].send("{!s}2".format(code)) - @asyncio.coroutine - def async_alarm_arm_home(self, code=None): + def alarm_arm_home(self, code=None): """Send arm home command.""" - _LOGGER.debug("alarm_arm_home: %s", code) if code: _LOGGER.debug("alarm_arm_home: sending %s3", str(code)) self.hass.data[DATA_AD].send("{!s}3".format(code)) diff --git a/homeassistant/components/alarmdecoder.py b/homeassistant/components/alarmdecoder.py index 011cc3ad21d..6e30a83d96a 100644 --- a/homeassistant/components/alarmdecoder.py +++ b/homeassistant/components/alarmdecoder.py @@ -4,16 +4,13 @@ Support for AlarmDecoder devices. For more details about this component, please refer to the documentation at https://home-assistant.io/components/alarmdecoder/ """ -import asyncio import logging import voluptuous as vol import homeassistant.helpers.config_validation as cv -from homeassistant.core import callback from homeassistant.const import EVENT_HOMEASSISTANT_STOP -from homeassistant.helpers.discovery import async_load_platform -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.discovery import load_platform REQUIREMENTS = ['alarmdecoder==0.12.3'] @@ -71,9 +68,9 @@ ZONE_SCHEMA = vol.Schema({ CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Required(CONF_DEVICE): vol.Any(DEVICE_SOCKET_SCHEMA, - DEVICE_SERIAL_SCHEMA, - DEVICE_USB_SCHEMA), + vol.Required(CONF_DEVICE): vol.Any( + DEVICE_SOCKET_SCHEMA, DEVICE_SERIAL_SCHEMA, + DEVICE_USB_SCHEMA), vol.Optional(CONF_PANEL_DISPLAY, default=DEFAULT_PANEL_DISPLAY): cv.boolean, vol.Optional(CONF_ZONES): {vol.Coerce(int): ZONE_SCHEMA}, @@ -81,8 +78,7 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) -@asyncio.coroutine -def async_setup(hass, config): +def setup(hass, config): """Set up for the AlarmDecoder devices.""" from alarmdecoder import AlarmDecoder from alarmdecoder.devices import (SocketDevice, SerialDevice, USBDevice) @@ -99,32 +95,25 @@ def async_setup(hass, config): path = DEFAULT_DEVICE_PATH baud = DEFAULT_DEVICE_BAUD - sync_connect = asyncio.Future(loop=hass.loop) - - def handle_open(device): - """Handle the successful connection.""" - _LOGGER.info("Established a connection with the alarmdecoder") - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_alarmdecoder) - sync_connect.set_result(True) - - @callback def stop_alarmdecoder(event): """Handle the shutdown of AlarmDecoder.""" _LOGGER.debug("Shutting down alarmdecoder") controller.close() - @callback def handle_message(sender, message): """Handle message from AlarmDecoder.""" - async_dispatcher_send(hass, SIGNAL_PANEL_MESSAGE, message) + hass.helpers.dispatcher.dispatcher_send( + SIGNAL_PANEL_MESSAGE, message) def zone_fault_callback(sender, zone): """Handle zone fault from AlarmDecoder.""" - async_dispatcher_send(hass, SIGNAL_ZONE_FAULT, zone) + hass.helpers.dispatcher.dispatcher_send( + SIGNAL_ZONE_FAULT, zone) def zone_restore_callback(sender, zone): """Handle zone restore from AlarmDecoder.""" - async_dispatcher_send(hass, SIGNAL_ZONE_RESTORE, zone) + hass.helpers.dispatcher.dispatcher_send( + SIGNAL_ZONE_RESTORE, zone) controller = False if device_type == 'socket': @@ -139,7 +128,6 @@ def async_setup(hass, config): AlarmDecoder(USBDevice.find()) return False - controller.on_open += handle_open controller.on_message += handle_message controller.on_zone_fault += zone_fault_callback controller.on_zone_restore += zone_restore_callback @@ -148,21 +136,16 @@ def async_setup(hass, config): controller.open(baud) - result = yield from sync_connect + _LOGGER.debug("Established a connection with the alarmdecoder") + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_alarmdecoder) - if not result: - return False - - hass.async_add_job( - async_load_platform(hass, 'alarm_control_panel', DOMAIN, conf, - config)) + load_platform(hass, 'alarm_control_panel', DOMAIN, conf, config) if zones: - hass.async_add_job(async_load_platform( - hass, 'binary_sensor', DOMAIN, {CONF_ZONES: zones}, config)) + load_platform( + hass, 'binary_sensor', DOMAIN, {CONF_ZONES: zones}, config) if display: - hass.async_add_job(async_load_platform( - hass, 'sensor', DOMAIN, conf, config)) + load_platform(hass, 'sensor', DOMAIN, conf, config) return True diff --git a/homeassistant/components/binary_sensor/alarmdecoder.py b/homeassistant/components/binary_sensor/alarmdecoder.py index bc05e4d84d8..f42d0de4bb0 100644 --- a/homeassistant/components/binary_sensor/alarmdecoder.py +++ b/homeassistant/components/binary_sensor/alarmdecoder.py @@ -7,39 +7,29 @@ https://home-assistant.io/components/binary_sensor.alarmdecoder/ import asyncio import logging -from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.components.binary_sensor import BinarySensorDevice - -from homeassistant.components.alarmdecoder import (ZONE_SCHEMA, - CONF_ZONES, - CONF_ZONE_NAME, - CONF_ZONE_TYPE, - SIGNAL_ZONE_FAULT, - SIGNAL_ZONE_RESTORE) - +from homeassistant.components.alarmdecoder import ( + ZONE_SCHEMA, CONF_ZONES, CONF_ZONE_NAME, CONF_ZONE_TYPE, + SIGNAL_ZONE_FAULT, SIGNAL_ZONE_RESTORE) DEPENDENCIES = ['alarmdecoder'] _LOGGER = logging.getLogger(__name__) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the AlarmDecoder binary sensor devices.""" configured_zones = discovery_info[CONF_ZONES] devices = [] - for zone_num in configured_zones: device_config_data = ZONE_SCHEMA(configured_zones[zone_num]) zone_type = device_config_data[CONF_ZONE_TYPE] zone_name = device_config_data[CONF_ZONE_NAME] - device = AlarmDecoderBinarySensor( - hass, zone_num, zone_name, zone_type) + device = AlarmDecoderBinarySensor(zone_num, zone_name, zone_type) devices.append(device) - async_add_devices(devices) + add_devices(devices) return True @@ -47,7 +37,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): class AlarmDecoderBinarySensor(BinarySensorDevice): """Representation of an AlarmDecoder binary sensor.""" - def __init__(self, hass, zone_number, zone_name, zone_type): + def __init__(self, zone_number, zone_name, zone_type): """Initialize the binary_sensor.""" self._zone_number = zone_number self._zone_type = zone_type @@ -55,16 +45,14 @@ class AlarmDecoderBinarySensor(BinarySensorDevice): self._name = zone_name self._type = zone_type - _LOGGER.debug("Setup up zone: %s", self._name) - @asyncio.coroutine def async_added_to_hass(self): """Register callbacks.""" - async_dispatcher_connect( - self.hass, SIGNAL_ZONE_FAULT, self._fault_callback) + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_ZONE_FAULT, self._fault_callback) - async_dispatcher_connect( - self.hass, SIGNAL_ZONE_RESTORE, self._restore_callback) + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_ZONE_RESTORE, self._restore_callback) @property def name(self): @@ -97,16 +85,14 @@ class AlarmDecoderBinarySensor(BinarySensorDevice): """Return the class of this sensor, from DEVICE_CLASSES.""" return self._zone_type - @callback def _fault_callback(self, zone): """Update the zone's state, if needed.""" if zone is None or int(zone) == self._zone_number: self._state = 1 - self.async_schedule_update_ha_state() + self.schedule_update_ha_state() - @callback def _restore_callback(self, zone): """Update the zone's state, if needed.""" if zone is None or int(zone) == self._zone_number: self._state = 0 - self.async_schedule_update_ha_state() + self.schedule_update_ha_state() diff --git a/homeassistant/components/sensor/alarmdecoder.py b/homeassistant/components/sensor/alarmdecoder.py index 6b026298db0..ce709eee94c 100644 --- a/homeassistant/components/sensor/alarmdecoder.py +++ b/homeassistant/components/sensor/alarmdecoder.py @@ -7,25 +7,21 @@ https://home-assistant.io/components/sensor.alarmdecoder/ import asyncio import logging -from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.components.alarmdecoder import (SIGNAL_PANEL_MESSAGE) -from homeassistant.const import (STATE_UNKNOWN) _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['alarmdecoder'] -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Set up for AlarmDecoder sensor devices.""" - _LOGGER.debug("AlarmDecoderSensor: async_setup_platform") + _LOGGER.debug("AlarmDecoderSensor: setup_platform") device = AlarmDecoderSensor(hass) - async_add_devices([device]) + add_devices([device]) class AlarmDecoderSensor(Entity): @@ -34,23 +30,20 @@ class AlarmDecoderSensor(Entity): def __init__(self, hass): """Initialize the alarm panel.""" self._display = "" - self._state = STATE_UNKNOWN + self._state = None self._icon = 'mdi:alarm-check' self._name = 'Alarm Panel Display' - _LOGGER.debug("Setting up panel") - @asyncio.coroutine def async_added_to_hass(self): """Register callbacks.""" - async_dispatcher_connect( - self.hass, SIGNAL_PANEL_MESSAGE, self._message_callback) + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_PANEL_MESSAGE, self._message_callback) - @callback def _message_callback(self, message): if self._display != message.text: self._display = message.text - self.async_schedule_update_ha_state() + self.schedule_update_ha_state() @property def icon(self): From 024f1d48829c5405de09bcb15286d3c94e60452f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemek=20Wi=C4=99ch?= Date: Sun, 17 Dec 2017 12:46:47 +0100 Subject: [PATCH 029/238] Try multiple methods of getting data in asuswrt. (#11140) * Try multiple methods of getting data in asuswrt. Solves #11108 and potentially #8112. * fix style * fix lint --- .../components/device_tracker/asuswrt.py | 186 +++++++++--------- .../components/device_tracker/test_asuswrt.py | 10 +- 2 files changed, 99 insertions(+), 97 deletions(-) diff --git a/homeassistant/components/device_tracker/asuswrt.py b/homeassistant/components/device_tracker/asuswrt.py index f2d2a4c74b5..495e377077f 100644 --- a/homeassistant/components/device_tracker/asuswrt.py +++ b/homeassistant/components/device_tracker/asuswrt.py @@ -67,6 +67,15 @@ _IP_NEIGH_REGEX = re.compile( r'\s?(router)?' r'(?P(\w+))') +_ARP_CMD = 'arp -n' +_ARP_REGEX = re.compile( + r'.+\s' + + r'\((?P([0-9]{1,3}[\.]){3}[0-9]{1,3})\)\s' + + r'.+\s' + + r'(?P(([0-9a-f]{2}[:-]){5}([0-9a-f]{2})))' + + r'\s' + + r'.*') + # pylint: disable=unused-argument def get_scanner(hass, config): @@ -76,7 +85,22 @@ def get_scanner(hass, config): return scanner if scanner.success_init else None -AsusWrtResult = namedtuple('AsusWrtResult', 'neighbors leases') +def _parse_lines(lines, regex): + """Parse the lines using the given regular expression. + + If a line can't be parsed it is logged and skipped in the output. + """ + results = [] + for line in lines: + match = regex.search(line) + if not match: + _LOGGER.debug("Could not parse row: %s", line) + continue + results.append(match.groupdict()) + return results + + +Device = namedtuple('Device', ['mac', 'ip', 'name']) class AsusWrtDeviceScanner(DeviceScanner): @@ -121,16 +145,13 @@ class AsusWrtDeviceScanner(DeviceScanner): def scan_devices(self): """Scan for new devices and return a list with found device IDs.""" self._update_info() - return [client['mac'] for client in self.last_results] + return list(self.last_results.keys()) def get_device_name(self, device): """Return the name of the given device or None if we don't know.""" - if not self.last_results: + if device not in self.last_results: return None - for client in self.last_results: - if client['mac'] == device: - return client['host'] - return None + return self.last_results[device].name def _update_info(self): """Ensure the information from the ASUSWRT router is up to date. @@ -145,72 +166,71 @@ class AsusWrtDeviceScanner(DeviceScanner): if not data: return False - active_clients = [client for client in data.values() if - client['status'] == 'REACHABLE' or - client['status'] == 'DELAY' or - client['status'] == 'STALE' or - client['status'] == 'IN_ASSOCLIST'] - self.last_results = active_clients + self.last_results = data return True def get_asuswrt_data(self): - """Retrieve data from ASUSWRT and return parsed result.""" - result = self.connection.get_result() - - if not result: - return {} + """Retrieve data from ASUSWRT. + Calls various commands on the router and returns the superset of all + responses. Some commands will not work on some routers. + """ devices = {} - if self.mode == 'ap': - for lease in result.leases: - match = _WL_REGEX.search(lease.decode('utf-8')) + devices.update(self._get_wl()) + devices.update(self._get_arp()) + devices.update(self._get_neigh()) + if not self.mode == 'ap': + devices.update(self._get_leases()) + return devices - if not match: - _LOGGER.warning("Could not parse wl row: %s", lease) - continue + def _get_wl(self): + lines = self.connection.run_command(_WL_CMD) + if not lines: + return {} + result = _parse_lines(lines, _WL_REGEX) + devices = {} + for device in result: + mac = device['mac'].upper() + devices[mac] = Device(mac, None, None) + return devices + def _get_leases(self): + lines = self.connection.run_command(_LEASES_CMD) + if not lines: + return {} + lines = [line for line in lines if not line.startswith('duid ')] + result = _parse_lines(lines, _LEASES_REGEX) + devices = {} + for device in result: + # For leases where the client doesn't set a hostname, ensure it + # is blank and not '*', which breaks entity_id down the line. + host = device['host'] + if host == '*': host = '' + mac = device['mac'].upper() + devices[mac] = Device(mac, device['ip'], host) + return devices - devices[match.group('mac').upper()] = { - 'host': host, - 'status': 'IN_ASSOCLIST', - 'ip': '', - 'mac': match.group('mac').upper(), - } - - else: - for lease in result.leases: - if lease.startswith(b'duid '): - continue - match = _LEASES_REGEX.search(lease.decode('utf-8')) - - if not match: - _LOGGER.warning("Could not parse lease row: %s", lease) - continue - - # For leases where the client doesn't set a hostname, ensure it - # is blank and not '*', which breaks entity_id down the line. - host = match.group('host') - if host == '*': - host = '' - - devices[match.group('mac')] = { - 'host': host, - 'status': '', - 'ip': match.group('ip'), - 'mac': match.group('mac').upper(), - } - - for neighbor in result.neighbors: - match = _IP_NEIGH_REGEX.search(neighbor.decode('utf-8')) - if not match: - _LOGGER.warning("Could not parse neighbor row: %s", - neighbor) - continue - if match.group('mac') in devices: - devices[match.group('mac')]['status'] = ( - match.group('status')) + def _get_neigh(self): + lines = self.connection.run_command(_IP_NEIGH_CMD) + if not lines: + return {} + result = _parse_lines(lines, _IP_NEIGH_REGEX) + devices = {} + for device in result: + mac = device['mac'].upper() + devices[mac] = Device(mac, None, None) + return devices + def _get_arp(self): + lines = self.connection.run_command(_ARP_CMD) + if not lines: + return {} + result = _parse_lines(lines, _ARP_REGEX) + devices = {} + for device in result: + mac = device['mac'].upper() + devices[mac] = Device(mac, device['ip'], None) return devices @@ -247,8 +267,8 @@ class SshConnection(_Connection): self._ssh_key = ssh_key self._ap = ap - def get_result(self): - """Retrieve a single AsusWrtResult through an SSH connection. + def run_command(self, command): + """Run commands through an SSH connection. Connect to the SSH server if not currently connected, otherwise use the existing connection. @@ -258,19 +278,10 @@ class SshConnection(_Connection): try: if not self.connected: self.connect() - if self._ap: - neighbors = [''] - self._ssh.sendline(_WL_CMD) - self._ssh.prompt() - leases_result = self._ssh.before.split(b'\n')[1:-1] - else: - self._ssh.sendline(_IP_NEIGH_CMD) - self._ssh.prompt() - neighbors = self._ssh.before.split(b'\n')[1:-1] - self._ssh.sendline(_LEASES_CMD) - self._ssh.prompt() - leases_result = self._ssh.before.split(b'\n')[1:-1] - return AsusWrtResult(neighbors, leases_result) + self._ssh.sendline(command) + self._ssh.prompt() + lines = self._ssh.before.split(b'\n')[1:-1] + return [line.decode('utf-8') for line in lines] except exceptions.EOF as err: _LOGGER.error("Connection refused. SSH enabled?") self.disconnect() @@ -326,8 +337,8 @@ class TelnetConnection(_Connection): self._ap = ap self._prompt_string = None - def get_result(self): - """Retrieve a single AsusWrtResult through a Telnet connection. + def run_command(self, command): + """Run a command through a Telnet connection. Connect to the Telnet server if not currently connected, otherwise use the existing connection. @@ -336,18 +347,9 @@ class TelnetConnection(_Connection): if not self.connected: self.connect() - self._telnet.write('{}\n'.format(_IP_NEIGH_CMD).encode('ascii')) - neighbors = (self._telnet.read_until(self._prompt_string). - split(b'\n')[1:-1]) - if self._ap: - self._telnet.write('{}\n'.format(_WL_CMD).encode('ascii')) - leases_result = (self._telnet.read_until(self._prompt_string). - split(b'\n')[1:-1]) - else: - self._telnet.write('{}\n'.format(_LEASES_CMD).encode('ascii')) - leases_result = (self._telnet.read_until(self._prompt_string). - split(b'\n')[1:-1]) - return AsusWrtResult(neighbors, leases_result) + self._telnet.write('{}\n'.format(command).encode('ascii')) + return (self._telnet.read_until(self._prompt_string). + split(b'\n')[1:-1]) except EOFError: _LOGGER.error("Unexpected response from router") self.disconnect() diff --git a/tests/components/device_tracker/test_asuswrt.py b/tests/components/device_tracker/test_asuswrt.py index a6827d165cd..0159eec2eff 100644 --- a/tests/components/device_tracker/test_asuswrt.py +++ b/tests/components/device_tracker/test_asuswrt.py @@ -144,7 +144,7 @@ class TestComponentsDeviceTrackerASUSWRT(unittest.TestCase): update_mock.start() self.addCleanup(update_mock.stop) asuswrt = device_tracker.asuswrt.AsusWrtDeviceScanner(conf_dict) - asuswrt.connection.get_result() + asuswrt.connection.run_command('ls') self.assertEqual(ssh.login.call_count, 1) self.assertEqual( ssh.login.call_args, @@ -170,7 +170,7 @@ class TestComponentsDeviceTrackerASUSWRT(unittest.TestCase): update_mock.start() self.addCleanup(update_mock.stop) asuswrt = device_tracker.asuswrt.AsusWrtDeviceScanner(conf_dict) - asuswrt.connection.get_result() + asuswrt.connection.run_command('ls') self.assertEqual(ssh.login.call_count, 1) self.assertEqual( ssh.login.call_args, @@ -225,9 +225,9 @@ class TestComponentsDeviceTrackerASUSWRT(unittest.TestCase): update_mock.start() self.addCleanup(update_mock.stop) asuswrt = device_tracker.asuswrt.AsusWrtDeviceScanner(conf_dict) - asuswrt.connection.get_result() - self.assertEqual(telnet.read_until.call_count, 5) - self.assertEqual(telnet.write.call_count, 4) + asuswrt.connection.run_command('ls') + self.assertEqual(telnet.read_until.call_count, 4) + self.assertEqual(telnet.write.call_count, 3) self.assertEqual( telnet.read_until.call_args_list[0], mock.call(b'login: ') From dfb8b5a3c1e087b21d97bf85e1dc8521a29b6b4e Mon Sep 17 00:00:00 2001 From: Brad Dixon Date: Sun, 17 Dec 2017 07:08:35 -0500 Subject: [PATCH 030/238] Revbump to SoCo 0.13 and add support for Night Sound and Speech Enhancement. (#10765) Sonos Playbar and Playbase devices support Night Sound and Speech Enhancement effects when playing from sources such as a TV. Adds a new service "sonos_set_option" whichs accepts boolean options to control these audio features. --- .../components/media_player/services.yaml | 12 ++++ .../components/media_player/sonos.py | 55 ++++++++++++++++++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/media_player/test_sonos.py | 18 ++++++ 5 files changed, 85 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/media_player/services.yaml b/homeassistant/components/media_player/services.yaml index f2d7b8e07dd..b2f98d378cf 100644 --- a/homeassistant/components/media_player/services.yaml +++ b/homeassistant/components/media_player/services.yaml @@ -215,6 +215,18 @@ sonos_clear_sleep_timer: description: Name(s) of entities that will have the timer cleared. example: 'media_player.living_room_sonos' +sonos_set_option: + description: Set Sonos sound options. + fields: + entity_id: + description: Name(s) of entities that will have options set. + example: 'media_player.living_room_sonos' + night_sound: + description: Enable Night Sound mode + example: 'true' + speech_enhance: + description: Enable Speech Enhancement mode + example: 'true' soundtouch_play_everywhere: description: Play on all Bose Soundtouch devices. diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py index f9a18a212f5..3bd3a722b46 100644 --- a/homeassistant/components/media_player/sonos.py +++ b/homeassistant/components/media_player/sonos.py @@ -27,7 +27,7 @@ from homeassistant.config import load_yaml_config_file import homeassistant.helpers.config_validation as cv from homeassistant.util.dt import utcnow -REQUIREMENTS = ['SoCo==0.12'] +REQUIREMENTS = ['SoCo==0.13'] _LOGGER = logging.getLogger(__name__) @@ -52,6 +52,7 @@ SERVICE_RESTORE = 'sonos_restore' SERVICE_SET_TIMER = 'sonos_set_sleep_timer' SERVICE_CLEAR_TIMER = 'sonos_clear_sleep_timer' SERVICE_UPDATE_ALARM = 'sonos_update_alarm' +SERVICE_SET_OPTION = 'sonos_set_option' DATA_SONOS = 'sonos' @@ -69,6 +70,8 @@ ATTR_ENABLED = 'enabled' ATTR_INCLUDE_LINKED_ZONES = 'include_linked_zones' ATTR_MASTER = 'master' ATTR_WITH_GROUP = 'with_group' +ATTR_NIGHT_SOUND = 'night_sound' +ATTR_SPEECH_ENHANCE = 'speech_enhance' ATTR_IS_COORDINATOR = 'is_coordinator' @@ -105,6 +108,11 @@ SONOS_UPDATE_ALARM_SCHEMA = SONOS_SCHEMA.extend({ vol.Optional(ATTR_INCLUDE_LINKED_ZONES): cv.boolean, }) +SONOS_SET_OPTION_SCHEMA = SONOS_SCHEMA.extend({ + vol.Optional(ATTR_NIGHT_SOUND): cv.boolean, + vol.Optional(ATTR_SPEECH_ENHANCE): cv.boolean, +}) + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Sonos platform.""" @@ -192,6 +200,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): device.clear_sleep_timer() elif service.service == SERVICE_UPDATE_ALARM: device.update_alarm(**service.data) + elif service.service == SERVICE_SET_OPTION: + device.update_option(**service.data) device.schedule_update_ha_state(True) @@ -224,6 +234,11 @@ def setup_platform(hass, config, add_devices, discovery_info=None): descriptions.get(SERVICE_UPDATE_ALARM), schema=SONOS_UPDATE_ALARM_SCHEMA) + hass.services.register( + DOMAIN, SERVICE_SET_OPTION, service_handle, + descriptions.get(SERVICE_SET_OPTION), + schema=SONOS_SET_OPTION_SCHEMA) + def _parse_timespan(timespan): """Parse a time-span into number of seconds.""" @@ -337,6 +352,8 @@ class SonosDevice(MediaPlayerDevice): self._support_shuffle_set = True self._support_stop = False self._support_pause = False + self._night_sound = None + self._speech_enhance = None self._current_track_uri = None self._current_track_is_radio_stream = False self._queue = None @@ -457,6 +474,8 @@ class SonosDevice(MediaPlayerDevice): self._support_shuffle_set = False self._support_stop = False self._support_pause = False + self._night_sound = None + self._speech_enhance = None self._is_playing_tv = False self._is_playing_line_in = False self._source_name = None @@ -529,6 +548,9 @@ class SonosDevice(MediaPlayerDevice): media_position_updated_at = None source_name = None + night_sound = self._player.night_mode + speech_enhance = self._player.dialog_mode + is_radio_stream = \ current_media_uri.startswith('x-sonosapi-stream:') or \ current_media_uri.startswith('x-rincon-mp3radio:') @@ -705,6 +727,8 @@ class SonosDevice(MediaPlayerDevice): self._support_shuffle_set = support_shuffle_set self._support_stop = support_stop self._support_pause = support_pause + self._night_sound = night_sound + self._speech_enhance = speech_enhance self._is_playing_tv = is_playing_tv self._is_playing_line_in = is_playing_line_in self._source_name = source_name @@ -848,6 +872,16 @@ class SonosDevice(MediaPlayerDevice): return self._media_title + @property + def night_sound(self): + """Get status of Night Sound.""" + return self._night_sound + + @property + def speech_enhance(self): + """Get status of Speech Enhancement.""" + return self._speech_enhance + @property def supported_features(self): """Flag media player features that are supported.""" @@ -1179,7 +1213,24 @@ class SonosDevice(MediaPlayerDevice): a.include_linked_zones = data[ATTR_INCLUDE_LINKED_ZONES] a.save() + @soco_error + def update_option(self, **data): + """Modify playback options.""" + if ATTR_NIGHT_SOUND in data and self.night_sound is not None: + self.soco.night_mode = data[ATTR_NIGHT_SOUND] + + if ATTR_SPEECH_ENHANCE in data and self.speech_enhance is not None: + self.soco.dialog_mode = data[ATTR_SPEECH_ENHANCE] + @property def device_state_attributes(self): """Return device specific state attributes.""" - return {ATTR_IS_COORDINATOR: self.is_coordinator} + attributes = {ATTR_IS_COORDINATOR: self.is_coordinator} + + if self.night_sound is not None: + attributes[ATTR_NIGHT_SOUND] = self.night_sound + + if self.speech_enhance is not None: + attributes[ATTR_SPEECH_ENHANCE] = self.speech_enhance + + return attributes diff --git a/requirements_all.txt b/requirements_all.txt index 89711fe9c96..02a53b9c26e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -44,7 +44,7 @@ PyXiaomiGateway==0.6.0 RtmAPI==0.7.0 # homeassistant.components.media_player.sonos -SoCo==0.12 +SoCo==0.13 # homeassistant.components.sensor.travisci TravisPy==0.3.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 877e129e0ff..a96c3af1fd9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -24,7 +24,7 @@ freezegun>=0.3.8 PyJWT==1.5.3 # homeassistant.components.media_player.sonos -SoCo==0.12 +SoCo==0.13 # homeassistant.components.device_tracker.automatic aioautomatic==0.6.4 diff --git a/tests/components/media_player/test_sonos.py b/tests/components/media_player/test_sonos.py index 33f7a0e882d..815204e718a 100644 --- a/tests/components/media_player/test_sonos.py +++ b/tests/components/media_player/test_sonos.py @@ -389,3 +389,21 @@ class TestSonosMediaPlayer(unittest.TestCase): device.restore() self.assertEqual(restoreMock.call_count, 1) self.assertEqual(restoreMock.call_args, mock.call(False)) + + @mock.patch('soco.SoCo', new=SoCoMock) + @mock.patch('socket.create_connection', side_effect=socket.error()) + def test_sonos_set_option(self, option_mock, *args): + """Ensuring soco methods called for sonos_set_option service.""" + sonos.setup_platform(self.hass, {}, fake_add_device, { + 'host': '192.0.2.1' + }) + device = self.hass.data[sonos.DATA_SONOS][-1] + device.hass = self.hass + + option_mock.return_value = True + device._snapshot_coordinator = mock.MagicMock() + device._snapshot_coordinator.soco_device = SoCoMock('192.0.2.17') + + device.update_option(night_sound=True, speech_enhance=True) + + self.assertEqual(option_mock.call_count, 1) From 0ec1ff642d16d49cf41f6ede765b6823063e8923 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sun, 17 Dec 2017 16:29:36 +0100 Subject: [PATCH 031/238] Bump dev to 0.61.0.dev0 --- homeassistant/const.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index dd15e1fb75d..be085bd75f1 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,8 +1,8 @@ # coding: utf-8 """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 -MINOR_VERSION = 60 -PATCH_VERSION = '0' +MINOR_VERSION = 61 +PATCH_VERSION = '0.dev0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 4, 2) From 0664bf31a2ab4af419bc487466e645b8b14f035f Mon Sep 17 00:00:00 2001 From: maxlaverse Date: Sun, 17 Dec 2017 20:53:40 +0100 Subject: [PATCH 032/238] Fix webdav calendar schema (#11185) --- homeassistant/components/calendar/caldav.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/calendar/caldav.py b/homeassistant/components/calendar/caldav.py index 1647b9522b8..f1cc0f12bd8 100644 --- a/homeassistant/components/calendar/caldav.py +++ b/homeassistant/components/calendar/caldav.py @@ -29,7 +29,8 @@ CONF_ALL_DAY = 'all_day' CONF_SEARCH = 'search' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_URL): vol.Url, + # pylint: disable=no-value-for-parameter + vol.Required(CONF_URL): vol.Url(), vol.Optional(CONF_CALENDARS, default=[]): vol.All(cv.ensure_list, vol.Schema([ cv.string From 8742ce035a36b3a4a176123b620fe82e55d5a4e2 Mon Sep 17 00:00:00 2001 From: Thibault Cohen Date: Sun, 17 Dec 2017 16:11:49 -0500 Subject: [PATCH 033/238] Hydroquebec component use now asyncio (#10795) * Hydroquebec component use now asyncio * Add tests * Improve coverage * fix tests * Remove useless try/except and associated tests --- .coveragerc | 1 - .../components/sensor/hydroquebec.py | 44 ++++----- requirements_all.txt | 2 +- tests/components/sensor/test_hydroquebec.py | 89 +++++++++++++++++++ 4 files changed, 112 insertions(+), 24 deletions(-) create mode 100644 tests/components/sensor/test_hydroquebec.py diff --git a/.coveragerc b/.coveragerc index 96936655c51..2d268742a34 100644 --- a/.coveragerc +++ b/.coveragerc @@ -532,7 +532,6 @@ omit = homeassistant/components/sensor/haveibeenpwned.py homeassistant/components/sensor/hp_ilo.py homeassistant/components/sensor/htu21d.py - homeassistant/components/sensor/hydroquebec.py homeassistant/components/sensor/imap.py homeassistant/components/sensor/imap_email_content.py homeassistant/components/sensor/influxdb.py diff --git a/homeassistant/components/sensor/hydroquebec.py b/homeassistant/components/sensor/hydroquebec.py index d857ce57fce..d4dea54514a 100644 --- a/homeassistant/components/sensor/hydroquebec.py +++ b/homeassistant/components/sensor/hydroquebec.py @@ -7,10 +7,10 @@ https://www.hydroquebec.com/portail/en/group/clientele/portrait-de-consommation For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.hydroquebec/ """ +import asyncio import logging from datetime import timedelta -import requests import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA @@ -21,7 +21,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pyhydroquebec==1.3.1'] +REQUIREMENTS = ['pyhydroquebec==2.0.2'] _LOGGER = logging.getLogger(__name__) @@ -93,7 +93,8 @@ DAILY_MAP = (('yesterday_total_consumption', 'consoTotalQuot'), ('yesterday_higher_price_consumption', 'consoHautQuot')) -def setup_platform(hass, config, add_devices, discovery_info=None): +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the HydroQuebec sensor.""" # Create a data fetcher to support all of the configured sensors. Then make # the first call to init the data. @@ -102,13 +103,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): password = config.get(CONF_PASSWORD) contract = config.get(CONF_CONTRACT) - try: - hydroquebec_data = HydroquebecData(username, password, contract) - _LOGGER.info("Contract list: %s", - ", ".join(hydroquebec_data.get_contract_list())) - except requests.exceptions.HTTPError as error: - _LOGGER.error("Failt login: %s", error) - return False + hydroquebec_data = HydroquebecData(username, password, contract) + contracts = yield from hydroquebec_data.get_contract_list() + _LOGGER.info("Contract list: %s", + ", ".join(contracts)) name = config.get(CONF_NAME) @@ -116,7 +114,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): for variable in config[CONF_MONITORED_VARIABLES]: sensors.append(HydroQuebecSensor(hydroquebec_data, variable, name)) - add_devices(sensors) + async_add_devices(sensors, True) class HydroQuebecSensor(Entity): @@ -152,10 +150,11 @@ class HydroQuebecSensor(Entity): """Icon to use in the frontend, if any.""" return self._icon - def update(self): + @asyncio.coroutine + def async_update(self): """Get the latest data from Hydroquebec and update the state.""" - self.hydroquebec_data.update() - if self.type in self.hydroquebec_data.data: + yield from self.hydroquebec_data.async_update() + if self.hydroquebec_data.data.get(self.type) is not None: self._state = round(self.hydroquebec_data.data[self.type], 2) @@ -170,23 +169,24 @@ class HydroquebecData(object): self._contract = contract self.data = {} + @asyncio.coroutine def get_contract_list(self): """Return the contract list.""" # Fetch data - self._fetch_data() + yield from self._fetch_data() return self.client.get_contracts() + @asyncio.coroutine + @Throttle(MIN_TIME_BETWEEN_UPDATES) def _fetch_data(self): """Fetch latest data from HydroQuebec.""" - from pyhydroquebec.client import PyHydroQuebecError try: - self.client.fetch_data() - except PyHydroQuebecError as exp: + yield from self.client.fetch_data() + except BaseException as exp: _LOGGER.error("Error on receive last Hydroquebec data: %s", exp) - return - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): + @asyncio.coroutine + def async_update(self): """Return the latest collected data from HydroQuebec.""" - self._fetch_data() + yield from self._fetch_data() self.data = self.client.get_data(self._contract)[self._contract] diff --git a/requirements_all.txt b/requirements_all.txt index 02a53b9c26e..e5667b240c8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -698,7 +698,7 @@ pyhiveapi==0.2.5 pyhomematic==0.1.36 # homeassistant.components.sensor.hydroquebec -pyhydroquebec==1.3.1 +pyhydroquebec==2.0.2 # homeassistant.components.alarm_control_panel.ialarm pyialarm==0.2 diff --git a/tests/components/sensor/test_hydroquebec.py b/tests/components/sensor/test_hydroquebec.py new file mode 100644 index 00000000000..f2ca97313d3 --- /dev/null +++ b/tests/components/sensor/test_hydroquebec.py @@ -0,0 +1,89 @@ +"""The test for the hydroquebec sensor platform.""" +import asyncio +import logging +import sys +from unittest.mock import MagicMock + +from homeassistant.bootstrap import async_setup_component +from homeassistant.components.sensor import hydroquebec +from tests.common import assert_setup_component + + +CONTRACT = "123456789" + + +class HydroQuebecClientMock(): + """Fake Hydroquebec client.""" + + def __init__(self, username, password, contract=None): + """Fake Hydroquebec client init.""" + pass + + def get_data(self, contract): + """Return fake hydroquebec data.""" + return {CONTRACT: {"balance": 160.12}} + + def get_contracts(self): + """Return fake hydroquebec contracts.""" + return [CONTRACT] + + @asyncio.coroutine + def fetch_data(self): + """Return fake fetching data.""" + pass + + +class HydroQuebecClientMockError(HydroQuebecClientMock): + """Fake Hydroquebec client error.""" + + @asyncio.coroutine + def fetch_data(self): + """Return fake fetching data.""" + raise hydroquebec.PyHydroQuebecError("Fake Error") + + +class PyHydroQuebecErrorMock(BaseException): + """Fake PyHydroquebec Error.""" + + +@asyncio.coroutine +def test_hydroquebec_sensor(loop, hass): + """Test the Hydroquebec number sensor.""" + sys.modules['pyhydroquebec'] = MagicMock() + sys.modules['pyhydroquebec.client'] = MagicMock() + sys.modules['pyhydroquebec.client.PyHydroQuebecError'] = \ + PyHydroQuebecErrorMock + import pyhydroquebec.client + pyhydroquebec.HydroQuebecClient = HydroQuebecClientMock + pyhydroquebec.client.PyHydroQuebecError = PyHydroQuebecErrorMock + config = { + 'sensor': { + 'platform': 'hydroquebec', + 'name': 'hydro', + 'contract': CONTRACT, + 'username': 'myusername', + 'password': 'password', + 'monitored_variables': [ + 'balance', + ], + } + } + with assert_setup_component(1): + yield from async_setup_component(hass, 'sensor', config) + state = hass.states.get('sensor.hydro_balance') + assert state.state == "160.12" + assert state.attributes.get('unit_of_measurement') == "CAD" + + +@asyncio.coroutine +def test_error(hass, caplog): + """Test the Hydroquebec sensor errors.""" + caplog.set_level(logging.ERROR) + sys.modules['pyhydroquebec'] = MagicMock() + sys.modules['pyhydroquebec.client'] = MagicMock() + import pyhydroquebec.client + pyhydroquebec.HydroQuebecClient = HydroQuebecClientMockError + pyhydroquebec.client.PyHydroQuebecError = BaseException + hydro_data = hydroquebec.HydroquebecData('username', 'password') + yield from hydro_data._fetch_data() + assert "Error on receive last Hydroquebec data: " in caplog.text From 05258ea4bf9ce6ada39ae4c6e7d1d725c3317463 Mon Sep 17 00:00:00 2001 From: Khole Date: Mon, 18 Dec 2017 17:15:41 +0000 Subject: [PATCH 034/238] Hive Component Release Two (#11053) * Add boost functionality to climate devices * Update boost target temperature rounding * Update with Colour Bulb Support * colour bulb fix * Requirements Update and colorsys import * Add RGB Attribute - ATTR_RGB_COLOR * Hive release-2 * add boost support for hive climate platform * Colour Bulb - Varible update * Boost - Tox error * Convert colour to color * Correct over indentation * update version to 0.2.9 pyhiveapi * Updated pyhiveapi to version 2.10 and altertered turn_n on fuction to 1 call * Update climate doc string * Update to is_aux_heat_on * update to is_aux_heat_on --- homeassistant/components/climate/hive.py | 43 ++++++++++++++++++++++-- homeassistant/components/hive.py | 2 +- homeassistant/components/light/hive.py | 37 ++++++++++++++------ requirements_all.txt | 2 +- 4 files changed, 69 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/climate/hive.py b/homeassistant/components/climate/hive.py index 267657d56ce..8305e772869 100644 --- a/homeassistant/components/climate/hive.py +++ b/homeassistant/components/climate/hive.py @@ -6,7 +6,7 @@ https://home-assistant.io/components/climate.hive/ """ from homeassistant.components.climate import ( ClimateDevice, STATE_AUTO, STATE_HEAT, STATE_OFF, STATE_ON, - SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE) + SUPPORT_AUX_HEAT, SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE) from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS from homeassistant.components.hive import DATA_HIVE @@ -16,7 +16,9 @@ HIVE_TO_HASS_STATE = {'SCHEDULE': STATE_AUTO, 'MANUAL': STATE_HEAT, HASS_TO_HIVE_STATE = {STATE_AUTO: 'SCHEDULE', STATE_HEAT: 'MANUAL', STATE_ON: 'ON', STATE_OFF: 'OFF'} -SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE +SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | + SUPPORT_OPERATION_MODE | + SUPPORT_AUX_HEAT) def setup_platform(hass, config, add_devices, discovery_info=None): @@ -134,6 +136,43 @@ class HiveClimateEntity(ClimateDevice): for entity in self.session.entities: entity.handle_update(self.data_updatesource) + @property + def is_aux_heat_on(self): + """Return true if auxiliary heater is on.""" + boost_status = None + if self.device_type == "Heating": + boost_status = self.session.heating.get_boost(self.node_id) + elif self.device_type == "HotWater": + boost_status = self.session.hotwater.get_boost(self.node_id) + return boost_status == "ON" + + def turn_aux_heat_on(self): + """Turn auxiliary heater on.""" + target_boost_time = 30 + if self.device_type == "Heating": + curtemp = self.session.heating.current_temperature(self.node_id) + curtemp = round(curtemp * 2) / 2 + target_boost_temperature = curtemp + 0.5 + self.session.heating.turn_boost_on(self.node_id, + target_boost_time, + target_boost_temperature) + elif self.device_type == "HotWater": + self.session.hotwater.turn_boost_on(self.node_id, + target_boost_time) + + for entity in self.session.entities: + entity.handle_update(self.data_updatesource) + + def turn_aux_heat_off(self): + """Turn auxiliary heater off.""" + if self.device_type == "Heating": + self.session.heating.turn_boost_off(self.node_id) + elif self.device_type == "HotWater": + self.session.hotwater.turn_boost_off(self.node_id) + + for entity in self.session.entities: + entity.handle_update(self.data_updatesource) + def update(self): """Update all Node data frome Hive.""" self.session.core.update_data(self.node_id) diff --git a/homeassistant/components/hive.py b/homeassistant/components/hive.py index 277800502c1..bf5196d6582 100644 --- a/homeassistant/components/hive.py +++ b/homeassistant/components/hive.py @@ -12,7 +12,7 @@ from homeassistant.const import (CONF_PASSWORD, CONF_SCAN_INTERVAL, import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import load_platform -REQUIREMENTS = ['pyhiveapi==0.2.5'] +REQUIREMENTS = ['pyhiveapi==0.2.10'] _LOGGER = logging.getLogger(__name__) DOMAIN = 'hive' diff --git a/homeassistant/components/light/hive.py b/homeassistant/components/light/hive.py index 95bd0b6988d..3356d637be8 100644 --- a/homeassistant/components/light/hive.py +++ b/homeassistant/components/light/hive.py @@ -4,8 +4,10 @@ Support for the Hive devices. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.hive/ """ +import colorsys from homeassistant.components.hive import DATA_HIVE from homeassistant.components.light import (ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, + ATTR_RGB_COLOR, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_RGB_COLOR, Light) @@ -46,19 +48,24 @@ class HiveDeviceLight(Light): """Return the display name of this light.""" return self.node_name + @property + def brightness(self): + """Brightness of the light (an integer in the range 1-255).""" + return self.session.light.get_brightness(self.node_id) + @property def min_mireds(self): """Return the coldest color_temp that this light supports.""" if self.light_device_type == "tuneablelight" \ or self.light_device_type == "colourtuneablelight": - return self.session.light.get_min_colour_temp(self.node_id) + return self.session.light.get_min_color_temp(self.node_id) @property def max_mireds(self): """Return the warmest color_temp that this light supports.""" if self.light_device_type == "tuneablelight" \ or self.light_device_type == "colourtuneablelight": - return self.session.light.get_max_colour_temp(self.node_id) + return self.session.light.get_max_color_temp(self.node_id) @property def color_temp(self): @@ -68,9 +75,10 @@ class HiveDeviceLight(Light): return self.session.light.get_color_temp(self.node_id) @property - def brightness(self): - """Brightness of the light (an integer in the range 1-255).""" - return self.session.light.get_brightness(self.node_id) + def rgb_color(self) -> tuple: + """Return the RBG color value.""" + if self.light_device_type == "colourtuneablelight": + return self.session.light.get_color(self.node_id) @property def is_on(self): @@ -81,6 +89,7 @@ class HiveDeviceLight(Light): """Instruct the light to turn on.""" new_brightness = None new_color_temp = None + new_color = None if ATTR_BRIGHTNESS in kwargs: tmp_new_brightness = kwargs.get(ATTR_BRIGHTNESS) percentage_brightness = ((tmp_new_brightness / 255) * 100) @@ -90,13 +99,19 @@ class HiveDeviceLight(Light): if ATTR_COLOR_TEMP in kwargs: tmp_new_color_temp = kwargs.get(ATTR_COLOR_TEMP) new_color_temp = round(1000000 / tmp_new_color_temp) + if ATTR_RGB_COLOR in kwargs: + get_new_color = kwargs.get(ATTR_RGB_COLOR) + tmp_new_color = colorsys.rgb_to_hsv(get_new_color[0], + get_new_color[1], + get_new_color[2]) + hue = int(round(tmp_new_color[0] * 360)) + saturation = int(round(tmp_new_color[1] * 100)) + value = int(round((tmp_new_color[2] / 255) * 100)) + new_color = (hue, saturation, value) - if new_brightness is not None: - self.session.light.set_brightness(self.node_id, new_brightness) - elif new_color_temp is not None: - self.session.light.set_colour_temp(self.node_id, new_color_temp) - else: - self.session.light.turn_on(self.node_id) + self.session.light.turn_on(self.node_id, self.light_device_type, + new_brightness, new_color_temp, + new_color) for entity in self.session.entities: entity.handle_update(self.data_updatesource) diff --git a/requirements_all.txt b/requirements_all.txt index e5667b240c8..9ace9556005 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -692,7 +692,7 @@ pyharmony==1.0.18 pyhik==0.1.4 # homeassistant.components.hive -pyhiveapi==0.2.5 +pyhiveapi==0.2.10 # homeassistant.components.homematic pyhomematic==0.1.36 From 061395d2f87dcc4f96763afb6609615299d77d28 Mon Sep 17 00:00:00 2001 From: Thibault Maekelbergh Date: Mon, 18 Dec 2017 19:10:54 +0100 Subject: [PATCH 035/238] Add Discogs Sensor platform (#10957) * Add Discogs Sensor platform * Add discogs module to requirements_all * Fix wrong style var name * PR Feedback (scan interval, mod. docstring) * Added sensor.discogs to coveragerc * Use SERVER_SOFTWARE helper for UA-String * Don't setup platform if token is invalid * Fix trailing whitespace for Hound CI * Move client setup to setup() --- .coveragerc | 1 + homeassistant/components/sensor/discogs.py | 97 ++++++++++++++++++++++ requirements_all.txt | 3 + 3 files changed, 101 insertions(+) create mode 100644 homeassistant/components/sensor/discogs.py diff --git a/.coveragerc b/.coveragerc index 2d268742a34..fba75b62bfe 100644 --- a/.coveragerc +++ b/.coveragerc @@ -504,6 +504,7 @@ omit = homeassistant/components/sensor/deluge.py homeassistant/components/sensor/deutsche_bahn.py homeassistant/components/sensor/dht.py + homeassistant/components/sensor/discogs.py homeassistant/components/sensor/dnsip.py homeassistant/components/sensor/dovado.py homeassistant/components/sensor/dte_energy_bridge.py diff --git a/homeassistant/components/sensor/discogs.py b/homeassistant/components/sensor/discogs.py new file mode 100644 index 00000000000..2920dc025d7 --- /dev/null +++ b/homeassistant/components/sensor/discogs.py @@ -0,0 +1,97 @@ +""" +Show the amount of records in a user's Discogs collection. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.discogs/ +""" +import asyncio +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME, CONF_TOKEN +from homeassistant.helpers.aiohttp_client import SERVER_SOFTWARE +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +REQUIREMENTS = ['discogs_client==2.2.1'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_IDENTITY = 'identity' + +CONF_ATTRIBUTION = "Data provided by Discogs" + +DEFAULT_NAME = 'Discogs' + +ICON = 'mdi:album' + +SCAN_INTERVAL = timedelta(hours=2) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_TOKEN): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the Discogs sensor.""" + import discogs_client + + name = config.get(CONF_NAME) + token = config.get(CONF_TOKEN) + + try: + discogs = discogs_client.Client(SERVER_SOFTWARE, user_token=token) + identity = discogs.identity() + except discogs_client.exceptions.HTTPError: + _LOGGER.error("API token is not valid") + return + + async_add_devices([DiscogsSensor(identity, name)], True) + + +class DiscogsSensor(Entity): + """Get a user's number of records in collection.""" + + def __init__(self, identity, name): + """Initialize the Discogs sensor.""" + self._identity = identity + self._name = name + self._state = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return ICON + + @property + def unit_of_measurement(self): + """Return the unit this state is expressed in.""" + return 'records' + + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + return { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + ATTR_IDENTITY: self._identity.name, + } + + @asyncio.coroutine + def async_update(self): + """Set state to the amount of records in user's collection.""" + self._state = self._identity.num_collection diff --git a/requirements_all.txt b/requirements_all.txt index 9ace9556005..2a347012575 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -208,6 +208,9 @@ denonavr==0.5.5 # homeassistant.components.media_player.directv directpy==0.2 +# homeassistant.components.sensor.discogs +discogs_client==2.2.1 + # homeassistant.components.notify.discord discord.py==0.16.12 From ef22a6e18dc4dc9f8220748dc220af80dc4131ad Mon Sep 17 00:00:00 2001 From: markferry Date: Mon, 18 Dec 2017 20:21:27 +0000 Subject: [PATCH 036/238] Fix statistics sensor mean and median when only one sample is available. (#11180) * Fix statistics sensor mean and median when only one sample is available. With only one data point stddev and variance throw an exception. This would clear the (valid) mean and median calculations. Separate the try..catch blocks for one-or-more and two-or-more stats so that this doesn't happen. Test this with a new sampling_size_1 test. * test_statistics trivial whitespace fix --- homeassistant/components/sensor/statistics.py | 9 +++-- tests/components/sensor/test_statistics.py | 35 ++++++++++++++++++- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensor/statistics.py b/homeassistant/components/sensor/statistics.py index a6932e2aebb..19281d36d88 100644 --- a/homeassistant/components/sensor/statistics.py +++ b/homeassistant/components/sensor/statistics.py @@ -175,15 +175,20 @@ class StatisticsSensor(Entity): self._purge_old() if not self.is_binary: - try: + try: # require only one data point self.mean = round(statistics.mean(self.states), 2) self.median = round(statistics.median(self.states), 2) + except statistics.StatisticsError as err: + _LOGGER.error(err) + self.mean = self.median = STATE_UNKNOWN + + try: # require at least two data points self.stdev = round(statistics.stdev(self.states), 2) self.variance = round(statistics.variance(self.states), 2) except statistics.StatisticsError as err: _LOGGER.error(err) - self.mean = self.median = STATE_UNKNOWN self.stdev = self.variance = STATE_UNKNOWN + if self.states: self.total = round(sum(self.states), 2) self.min = min(self.states) diff --git a/tests/components/sensor/test_statistics.py b/tests/components/sensor/test_statistics.py index bfb8fb61f9b..48ebf720633 100644 --- a/tests/components/sensor/test_statistics.py +++ b/tests/components/sensor/test_statistics.py @@ -3,7 +3,8 @@ import unittest import statistics from homeassistant.setup import setup_component -from homeassistant.const import (ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS) +from homeassistant.const import ( + ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS, STATE_UNKNOWN) from homeassistant.util import dt as dt_util from tests.common import get_test_home_assistant from unittest.mock import patch @@ -106,6 +107,38 @@ class TestStatisticsSensor(unittest.TestCase): self.assertEqual(3.8, state.attributes.get('min_value')) self.assertEqual(14, state.attributes.get('max_value')) + def test_sampling_size_1(self): + """Test validity of stats requiring only one sample.""" + assert setup_component(self.hass, 'sensor', { + 'sensor': { + 'platform': 'statistics', + 'name': 'test', + 'entity_id': 'sensor.test_monitored', + 'sampling_size': 1, + } + }) + + for value in self.values[-3:]: # just the last 3 will do + self.hass.states.set('sensor.test_monitored', value, + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.block_till_done() + + state = self.hass.states.get('sensor.test_mean') + + # require only one data point + self.assertEqual(self.values[-1], state.attributes.get('min_value')) + self.assertEqual(self.values[-1], state.attributes.get('max_value')) + self.assertEqual(self.values[-1], state.attributes.get('mean')) + self.assertEqual(self.values[-1], state.attributes.get('median')) + self.assertEqual(self.values[-1], state.attributes.get('total')) + self.assertEqual(0, state.attributes.get('change')) + self.assertEqual(0, state.attributes.get('average_change')) + + # require at least two data points + self.assertEqual(STATE_UNKNOWN, state.attributes.get('variance')) + self.assertEqual(STATE_UNKNOWN, + state.attributes.get('standard_deviation')) + def test_max_age(self): """Test value deprecation.""" mock_data = { From 200c92708712061c9cac0689fc115652f1cf12a0 Mon Sep 17 00:00:00 2001 From: Dan Nixon Date: Tue, 19 Dec 2017 00:52:19 +0000 Subject: [PATCH 037/238] Extend Threshold binary sensor to support ranges (#11110) * Extend Threshold binary sensor to support ranges - Adds support for ranges - Threshold type (lower, upper, range) is defined by supplied thresholds (lower, upper) - Adds verbose status/position relative to threshold as attribute (position) * Minor changes (ordering, names, etc.) * Update name * Update name --- .../components/binary_sensor/threshold.py | 129 +++++++--- .../binary_sensor/test_threshold.py | 239 ++++++++++++++++-- 2 files changed, 302 insertions(+), 66 deletions(-) diff --git a/homeassistant/components/binary_sensor/threshold.py b/homeassistant/components/binary_sensor/threshold.py index 5ca037767f2..36e8868661d 100644 --- a/homeassistant/components/binary_sensor/threshold.py +++ b/homeassistant/components/binary_sensor/threshold.py @@ -9,40 +9,48 @@ import logging import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.binary_sensor import ( - BinarySensorDevice, PLATFORM_SCHEMA, DEVICE_CLASSES_SCHEMA) + DEVICE_CLASSES_SCHEMA, PLATFORM_SCHEMA, BinarySensorDevice) from homeassistant.const import ( - CONF_NAME, CONF_ENTITY_ID, CONF_TYPE, STATE_UNKNOWN, - ATTR_ENTITY_ID, CONF_DEVICE_CLASS) + ATTR_ENTITY_ID, CONF_DEVICE_CLASS, CONF_ENTITY_ID, CONF_NAME, + STATE_UNKNOWN) from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_state_change _LOGGER = logging.getLogger(__name__) ATTR_HYSTERESIS = 'hysteresis' +ATTR_LOWER = 'lower' +ATTR_POSITION = 'position' ATTR_SENSOR_VALUE = 'sensor_value' -ATTR_THRESHOLD = 'threshold' ATTR_TYPE = 'type' +ATTR_UPPER = 'upper' CONF_HYSTERESIS = 'hysteresis' CONF_LOWER = 'lower' -CONF_THRESHOLD = 'threshold' CONF_UPPER = 'upper' DEFAULT_NAME = 'Threshold' DEFAULT_HYSTERESIS = 0.0 -SENSOR_TYPES = [CONF_LOWER, CONF_UPPER] +POSITION_ABOVE = 'above' +POSITION_BELOW = 'below' +POSITION_IN_RANGE = 'in_range' +POSITION_UNKNOWN = 'unknown' + +TYPE_LOWER = 'lower' +TYPE_RANGE = 'range' +TYPE_UPPER = 'upper' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_ENTITY_ID): cv.entity_id, - vol.Required(CONF_THRESHOLD): vol.Coerce(float), - vol.Required(CONF_TYPE): vol.In(SENSOR_TYPES), - vol.Optional( - CONF_HYSTERESIS, default=DEFAULT_HYSTERESIS): vol.Coerce(float), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_HYSTERESIS, default=DEFAULT_HYSTERESIS): + vol.Coerce(float), + vol.Optional(CONF_LOWER): vol.Coerce(float), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_UPPER): vol.Coerce(float), }) @@ -51,47 +59,44 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the Threshold sensor.""" entity_id = config.get(CONF_ENTITY_ID) name = config.get(CONF_NAME) - threshold = config.get(CONF_THRESHOLD) + lower = config.get(CONF_LOWER) + upper = config.get(CONF_UPPER) hysteresis = config.get(CONF_HYSTERESIS) - limit_type = config.get(CONF_TYPE) device_class = config.get(CONF_DEVICE_CLASS) async_add_devices([ThresholdSensor( - hass, entity_id, name, threshold, - hysteresis, limit_type, device_class) - ], True) - - return True + hass, entity_id, name, lower, upper, hysteresis, device_class)], True) class ThresholdSensor(BinarySensorDevice): """Representation of a Threshold sensor.""" - def __init__(self, hass, entity_id, name, threshold, - hysteresis, limit_type, device_class): + def __init__(self, hass, entity_id, name, lower, upper, hysteresis, + device_class): """Initialize the Threshold sensor.""" self._hass = hass self._entity_id = entity_id - self.is_upper = limit_type == 'upper' self._name = name - self._threshold = threshold + self._threshold_lower = lower + self._threshold_upper = upper self._hysteresis = hysteresis self._device_class = device_class - self._state = False - self.sensor_value = 0 - @callback + self._state_position = None + self._state = False + self.sensor_value = None + # pylint: disable=invalid-name + @callback def async_threshold_sensor_state_listener( entity, old_state, new_state): """Handle sensor state changes.""" - if new_state.state == STATE_UNKNOWN: - return - try: - self.sensor_value = float(new_state.state) - except ValueError: - _LOGGER.error("State is not numerical") + self.sensor_value = None if new_state.state == STATE_UNKNOWN \ + else float(new_state.state) + except (ValueError, TypeError): + self.sensor_value = None + _LOGGER.warning("State is not numerical") hass.async_add_job(self.async_update_ha_state, True) @@ -118,23 +123,67 @@ class ThresholdSensor(BinarySensorDevice): """Return the sensor class of the sensor.""" return self._device_class + @property + def threshold_type(self): + """Return the type of threshold this sensor represents.""" + if self._threshold_lower and self._threshold_upper: + return TYPE_RANGE + elif self._threshold_lower: + return TYPE_LOWER + elif self._threshold_upper: + return TYPE_UPPER + @property def device_state_attributes(self): """Return the state attributes of the sensor.""" return { ATTR_ENTITY_ID: self._entity_id, - ATTR_SENSOR_VALUE: self.sensor_value, - ATTR_THRESHOLD: self._threshold, ATTR_HYSTERESIS: self._hysteresis, - ATTR_TYPE: CONF_UPPER if self.is_upper else CONF_LOWER, + ATTR_LOWER: self._threshold_lower, + ATTR_POSITION: self._state_position, + ATTR_SENSOR_VALUE: self.sensor_value, + ATTR_TYPE: self.threshold_type, + ATTR_UPPER: self._threshold_upper, } @asyncio.coroutine def async_update(self): """Get the latest data and updates the states.""" - if self._hysteresis == 0 and self.sensor_value == self._threshold: + def below(threshold): + """Determine if the sensor value is below a threshold.""" + return self.sensor_value < (threshold - self._hysteresis) + + def above(threshold): + """Determine if the sensor value is above a threshold.""" + return self.sensor_value > (threshold + self._hysteresis) + + if self.sensor_value is None: + self._state_position = POSITION_UNKNOWN self._state = False - elif self.sensor_value > (self._threshold + self._hysteresis): - self._state = self.is_upper - elif self.sensor_value < (self._threshold - self._hysteresis): - self._state = not self.is_upper + + elif self.threshold_type == TYPE_LOWER: + if below(self._threshold_lower): + self._state_position = POSITION_BELOW + self._state = True + elif above(self._threshold_lower): + self._state_position = POSITION_ABOVE + self._state = False + + elif self.threshold_type == TYPE_UPPER: + if above(self._threshold_upper): + self._state_position = POSITION_ABOVE + self._state = True + elif below(self._threshold_upper): + self._state_position = POSITION_BELOW + self._state = False + + elif self.threshold_type == TYPE_RANGE: + if below(self._threshold_lower): + self._state_position = POSITION_BELOW + self._state = False + if above(self._threshold_upper): + self._state_position = POSITION_ABOVE + self._state = False + elif above(self._threshold_lower) and below(self._threshold_upper): + self._state_position = POSITION_IN_RANGE + self._state = True diff --git a/tests/components/binary_sensor/test_threshold.py b/tests/components/binary_sensor/test_threshold.py index d8c49de1cc0..38573b295d3 100644 --- a/tests/components/binary_sensor/test_threshold.py +++ b/tests/components/binary_sensor/test_threshold.py @@ -2,7 +2,8 @@ import unittest from homeassistant.setup import setup_component -from homeassistant.const import (ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS) +from homeassistant.const import ( + ATTR_UNIT_OF_MEASUREMENT, STATE_UNKNOWN, TEMP_CELSIUS) from tests.common import get_test_home_assistant @@ -23,8 +24,7 @@ class TestThresholdSensor(unittest.TestCase): config = { 'binary_sensor': { 'platform': 'threshold', - 'threshold': '15', - 'type': 'upper', + 'upper': '15', 'entity_id': 'sensor.test_monitored', } } @@ -37,12 +37,14 @@ class TestThresholdSensor(unittest.TestCase): state = self.hass.states.get('binary_sensor.threshold') - self.assertEqual('upper', state.attributes.get('type')) self.assertEqual('sensor.test_monitored', state.attributes.get('entity_id')) self.assertEqual(16, state.attributes.get('sensor_value')) - self.assertEqual(float(config['binary_sensor']['threshold']), - state.attributes.get('threshold')) + self.assertEqual('above', state.attributes.get('position')) + self.assertEqual(float(config['binary_sensor']['upper']), + state.attributes.get('upper')) + self.assertEqual(0.0, state.attributes.get('hysteresis')) + self.assertEqual('upper', state.attributes.get('type')) assert state.state == 'on' @@ -65,9 +67,7 @@ class TestThresholdSensor(unittest.TestCase): config = { 'binary_sensor': { 'platform': 'threshold', - 'threshold': '15', - 'name': 'Test_threshold', - 'type': 'lower', + 'lower': '15', 'entity_id': 'sensor.test_monitored', } } @@ -77,8 +77,12 @@ class TestThresholdSensor(unittest.TestCase): self.hass.states.set('sensor.test_monitored', 16) self.hass.block_till_done() - state = self.hass.states.get('binary_sensor.test_threshold') + state = self.hass.states.get('binary_sensor.threshold') + self.assertEqual('above', state.attributes.get('position')) + self.assertEqual(float(config['binary_sensor']['lower']), + state.attributes.get('lower')) + self.assertEqual(0.0, state.attributes.get('hysteresis')) self.assertEqual('lower', state.attributes.get('type')) assert state.state == 'off' @@ -86,26 +90,17 @@ class TestThresholdSensor(unittest.TestCase): self.hass.states.set('sensor.test_monitored', 14) self.hass.block_till_done() - state = self.hass.states.get('binary_sensor.test_threshold') + state = self.hass.states.get('binary_sensor.threshold') assert state.state == 'on' - self.hass.states.set('sensor.test_monitored', 15) - self.hass.block_till_done() - - state = self.hass.states.get('binary_sensor.test_threshold') - - assert state.state == 'off' - def test_sensor_hysteresis(self): """Test if source is above threshold using hysteresis.""" config = { 'binary_sensor': { 'platform': 'threshold', - 'threshold': '15', + 'upper': '15', 'hysteresis': '2.5', - 'name': 'Test_threshold', - 'type': 'upper', 'entity_id': 'sensor.test_monitored', } } @@ -115,34 +110,226 @@ class TestThresholdSensor(unittest.TestCase): self.hass.states.set('sensor.test_monitored', 20) self.hass.block_till_done() - state = self.hass.states.get('binary_sensor.test_threshold') + state = self.hass.states.get('binary_sensor.threshold') + + self.assertEqual('above', state.attributes.get('position')) + self.assertEqual(float(config['binary_sensor']['upper']), + state.attributes.get('upper')) + self.assertEqual(2.5, state.attributes.get('hysteresis')) + self.assertEqual('upper', state.attributes.get('type')) assert state.state == 'on' self.hass.states.set('sensor.test_monitored', 13) self.hass.block_till_done() - state = self.hass.states.get('binary_sensor.test_threshold') + state = self.hass.states.get('binary_sensor.threshold') assert state.state == 'on' self.hass.states.set('sensor.test_monitored', 12) self.hass.block_till_done() - state = self.hass.states.get('binary_sensor.test_threshold') + state = self.hass.states.get('binary_sensor.threshold') assert state.state == 'off' self.hass.states.set('sensor.test_monitored', 17) self.hass.block_till_done() - state = self.hass.states.get('binary_sensor.test_threshold') + state = self.hass.states.get('binary_sensor.threshold') assert state.state == 'off' self.hass.states.set('sensor.test_monitored', 18) self.hass.block_till_done() - state = self.hass.states.get('binary_sensor.test_threshold') + state = self.hass.states.get('binary_sensor.threshold') assert state.state == 'on' + + def test_sensor_in_range_no_hysteresis(self): + """Test if source is within the range.""" + config = { + 'binary_sensor': { + 'platform': 'threshold', + 'lower': '10', + 'upper': '20', + 'entity_id': 'sensor.test_monitored', + } + } + + assert setup_component(self.hass, 'binary_sensor', config) + + self.hass.states.set('sensor.test_monitored', 16, + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.threshold') + + self.assertEqual('sensor.test_monitored', + state.attributes.get('entity_id')) + self.assertEqual(16, state.attributes.get('sensor_value')) + self.assertEqual('in_range', state.attributes.get('position')) + self.assertEqual(float(config['binary_sensor']['lower']), + state.attributes.get('lower')) + self.assertEqual(float(config['binary_sensor']['upper']), + state.attributes.get('upper')) + self.assertEqual(0.0, state.attributes.get('hysteresis')) + self.assertEqual('range', state.attributes.get('type')) + + assert state.state == 'on' + + self.hass.states.set('sensor.test_monitored', 9) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.threshold') + + self.assertEqual('below', state.attributes.get('position')) + assert state.state == 'off' + + self.hass.states.set('sensor.test_monitored', 21) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.threshold') + + self.assertEqual('above', state.attributes.get('position')) + assert state.state == 'off' + + def test_sensor_in_range_with_hysteresis(self): + """Test if source is within the range.""" + config = { + 'binary_sensor': { + 'platform': 'threshold', + 'lower': '10', + 'upper': '20', + 'hysteresis': '2', + 'entity_id': 'sensor.test_monitored', + } + } + + assert setup_component(self.hass, 'binary_sensor', config) + + self.hass.states.set('sensor.test_monitored', 16, + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.threshold') + + self.assertEqual('sensor.test_monitored', + state.attributes.get('entity_id')) + self.assertEqual(16, state.attributes.get('sensor_value')) + self.assertEqual('in_range', state.attributes.get('position')) + self.assertEqual(float(config['binary_sensor']['lower']), + state.attributes.get('lower')) + self.assertEqual(float(config['binary_sensor']['upper']), + state.attributes.get('upper')) + self.assertEqual(float(config['binary_sensor']['hysteresis']), + state.attributes.get('hysteresis')) + self.assertEqual('range', state.attributes.get('type')) + + assert state.state == 'on' + + self.hass.states.set('sensor.test_monitored', 8) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.threshold') + + self.assertEqual('in_range', state.attributes.get('position')) + assert state.state == 'on' + + self.hass.states.set('sensor.test_monitored', 7) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.threshold') + + self.assertEqual('below', state.attributes.get('position')) + assert state.state == 'off' + + self.hass.states.set('sensor.test_monitored', 12) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.threshold') + + self.assertEqual('below', state.attributes.get('position')) + assert state.state == 'off' + + self.hass.states.set('sensor.test_monitored', 13) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.threshold') + + self.assertEqual('in_range', state.attributes.get('position')) + assert state.state == 'on' + + self.hass.states.set('sensor.test_monitored', 22) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.threshold') + + self.assertEqual('in_range', state.attributes.get('position')) + assert state.state == 'on' + + self.hass.states.set('sensor.test_monitored', 23) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.threshold') + + self.assertEqual('above', state.attributes.get('position')) + assert state.state == 'off' + + self.hass.states.set('sensor.test_monitored', 18) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.threshold') + + self.assertEqual('above', state.attributes.get('position')) + assert state.state == 'off' + + self.hass.states.set('sensor.test_monitored', 17) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.threshold') + + self.assertEqual('in_range', state.attributes.get('position')) + assert state.state == 'on' + + def test_sensor_in_range_unknown_state(self): + """Test if source is within the range.""" + config = { + 'binary_sensor': { + 'platform': 'threshold', + 'lower': '10', + 'upper': '20', + 'entity_id': 'sensor.test_monitored', + } + } + + assert setup_component(self.hass, 'binary_sensor', config) + + self.hass.states.set('sensor.test_monitored', 16, + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.threshold') + + self.assertEqual('sensor.test_monitored', + state.attributes.get('entity_id')) + self.assertEqual(16, state.attributes.get('sensor_value')) + self.assertEqual('in_range', state.attributes.get('position')) + self.assertEqual(float(config['binary_sensor']['lower']), + state.attributes.get('lower')) + self.assertEqual(float(config['binary_sensor']['upper']), + state.attributes.get('upper')) + self.assertEqual(0.0, state.attributes.get('hysteresis')) + self.assertEqual('range', state.attributes.get('type')) + + assert state.state == 'on' + + self.hass.states.set('sensor.test_monitored', STATE_UNKNOWN) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.threshold') + + self.assertEqual('unknown', state.attributes.get('position')) + assert state.state == 'off' From 3d90855ca6b625828e174dc3ca5655390c04cddb Mon Sep 17 00:00:00 2001 From: Dan Chen Date: Mon, 18 Dec 2017 23:22:13 -0800 Subject: [PATCH 038/238] Bump python-miio version (#11232) --- homeassistant/components/fan/xiaomi_miio.py | 2 +- homeassistant/components/light/xiaomi_miio.py | 2 +- homeassistant/components/switch/xiaomi_miio.py | 2 +- homeassistant/components/vacuum/xiaomi_miio.py | 2 +- requirements_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/fan/xiaomi_miio.py b/homeassistant/components/fan/xiaomi_miio.py index e5430555910..7101f4a9527 100644 --- a/homeassistant/components/fan/xiaomi_miio.py +++ b/homeassistant/components/fan/xiaomi_miio.py @@ -31,7 +31,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) -REQUIREMENTS = ['python-miio==0.3.2'] +REQUIREMENTS = ['python-miio==0.3.3'] ATTR_TEMPERATURE = 'temperature' ATTR_HUMIDITY = 'humidity' diff --git a/homeassistant/components/light/xiaomi_miio.py b/homeassistant/components/light/xiaomi_miio.py index ddffed52271..b35b5a3740e 100644 --- a/homeassistant/components/light/xiaomi_miio.py +++ b/homeassistant/components/light/xiaomi_miio.py @@ -28,7 +28,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) -REQUIREMENTS = ['python-miio==0.3.2'] +REQUIREMENTS = ['python-miio==0.3.3'] # The light does not accept cct values < 1 CCT_MIN = 1 diff --git a/homeassistant/components/switch/xiaomi_miio.py b/homeassistant/components/switch/xiaomi_miio.py index 534c4ac0a32..49a400f4a23 100644 --- a/homeassistant/components/switch/xiaomi_miio.py +++ b/homeassistant/components/switch/xiaomi_miio.py @@ -25,7 +25,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) -REQUIREMENTS = ['python-miio==0.3.2'] +REQUIREMENTS = ['python-miio==0.3.3'] ATTR_POWER = 'power' ATTR_TEMPERATURE = 'temperature' diff --git a/homeassistant/components/vacuum/xiaomi_miio.py b/homeassistant/components/vacuum/xiaomi_miio.py index a2265706d87..3fc000f8027 100644 --- a/homeassistant/components/vacuum/xiaomi_miio.py +++ b/homeassistant/components/vacuum/xiaomi_miio.py @@ -21,7 +21,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, CONF_TOKEN, STATE_OFF, STATE_ON) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['python-miio==0.3.2'] +REQUIREMENTS = ['python-miio==0.3.3'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 2a347012575..514598166dc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -867,7 +867,7 @@ python-juicenet==0.0.5 # homeassistant.components.light.xiaomi_miio # homeassistant.components.switch.xiaomi_miio # homeassistant.components.vacuum.xiaomi_miio -python-miio==0.3.2 +python-miio==0.3.3 # homeassistant.components.media_player.mpd python-mpd2==0.5.5 From 90e25a6dfbf64c6e569fabe32494154e3d916979 Mon Sep 17 00:00:00 2001 From: Charles Garwood Date: Tue, 19 Dec 2017 11:55:24 -0500 Subject: [PATCH 039/238] Backup configuration files before overwriting (#11216) * Backup configuration files before overwriting * Changed timestamp format from epoch to iso8601 (minus punctuation) --- homeassistant/config.py | 47 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/homeassistant/config.py b/homeassistant/config.py index fee7572a2c2..34fd3848f6f 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -233,21 +233,68 @@ def create_default_config(config_dir, detect_location=True): config_file.write(DEFAULT_CONFIG) + timestamp = date_util.now().strftime('%Y%m%dT%H%M%S') + # Check for existing secrets file. + # If it exists, back it up before recreating it. + if os.path.isfile(secret_path): + backup_secret_path = "{}.{}.bak".format( + secret_path, + timestamp + ) + print("Found existing secrets file. Backing up and re-creating.") + os.rename(secret_path, backup_secret_path) with open(secret_path, 'wt') as secret_file: secret_file.write(DEFAULT_SECRETS) with open(version_path, 'wt') as version_file: version_file.write(__version__) + # Check for existing group file. + # If it exists, back it up before recreating it. + if os.path.isfile(group_yaml_path): + backup_group_path = "{}.{}.bak".format( + group_yaml_path, + timestamp + ) + print("Found existing group file. Backing up and re-creating.") + os.rename(group_yaml_path, backup_group_path) with open(group_yaml_path, 'wt'): pass + # Check for existing automation file. + # If it exists, back it up before recreating it. + if os.path.isfile(automation_yaml_path): + backup_automation_path = "{}.{}.bak".format( + automation_yaml_path, + timestamp + ) + print("Found existing automation file. Backing up and", + "re-creating.") + os.rename(automation_yaml_path, backup_automation_path) with open(automation_yaml_path, 'wt') as fil: fil.write('[]') + # Check for existing group file. + # If it exists, back it up before recreating it. + if os.path.isfile(script_yaml_path): + backup_script_path = "{}.{}.bak".format( + script_yaml_path, + timestamp + ) + print("Found existing script file. Backing up and re-creating.") + os.rename(script_yaml_path, backup_script_path) with open(script_yaml_path, 'wt'): pass + # Check for existing customize file. + # If it exists, back it up before recreating it. + if os.path.isfile(customize_yaml_path): + backup_customize_path = "{}.{}.bak".format( + customize_yaml_path, + timestamp + ) + print("Found existing customize file. Backing up and re-creating.") + os.rename(customize_yaml_path, backup_customize_path) with open(customize_yaml_path, 'wt'): pass From b4e2537de34b60b8fc37c6439f6c06b95d4617de Mon Sep 17 00:00:00 2001 From: Janne Grunau Date: Wed, 20 Dec 2017 00:38:59 +0100 Subject: [PATCH 040/238] homematic: add username and password to interface config schema (#11214) Fixes #11191, the json-rpc name resolving method requires user account and password. --- homeassistant/components/homematic/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/homematic/__init__.py b/homeassistant/components/homematic/__init__.py index a11c8c0f22c..409f2a76fe8 100644 --- a/homeassistant/components/homematic/__init__.py +++ b/homeassistant/components/homematic/__init__.py @@ -169,6 +169,8 @@ CONFIG_SCHEMA = vol.Schema({ vol.Optional(CONF_PATH, default=DEFAULT_PATH): cv.string, vol.Optional(CONF_RESOLVENAMES, default=DEFAULT_RESOLVENAMES): vol.In(CONF_RESOLVENAMES_OPTIONS), + vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, + vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, vol.Optional(CONF_CALLBACK_IP): cv.string, vol.Optional(CONF_CALLBACK_PORT): cv.port, }}, From 8efc4b5ba99e2c2554f335fcadbdd13831a69155 Mon Sep 17 00:00:00 2001 From: ChristianKuehnel Date: Wed, 20 Dec 2017 11:35:03 +0100 Subject: [PATCH 041/238] Upgrade to new miflora version 0.2.0 (#11250) --- homeassistant/components/sensor/miflora.py | 5 +++-- requirements_all.txt | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensor/miflora.py b/homeassistant/components/sensor/miflora.py index 349e55abb5d..77d77949ebd 100644 --- a/homeassistant/components/sensor/miflora.py +++ b/homeassistant/components/sensor/miflora.py @@ -15,7 +15,7 @@ from homeassistant.const import ( CONF_FORCE_UPDATE, CONF_MONITORED_CONDITIONS, CONF_NAME, CONF_MAC ) -REQUIREMENTS = ['miflora==0.1.16'] +REQUIREMENTS = ['miflora==0.2.0'] _LOGGER = logging.getLogger(__name__) @@ -60,11 +60,12 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the MiFlora sensor.""" from miflora import miflora_poller + from miflora.backends.gatttool import GatttoolBackend cache = config.get(CONF_CACHE) poller = miflora_poller.MiFloraPoller( config.get(CONF_MAC), cache_timeout=cache, - adapter=config.get(CONF_ADAPTER)) + adapter=config.get(CONF_ADAPTER), backend=GatttoolBackend) force_update = config.get(CONF_FORCE_UPDATE) median = config.get(CONF_MEDIAN) poller.ble_timeout = config.get(CONF_TIMEOUT) diff --git a/requirements_all.txt b/requirements_all.txt index 514598166dc..6b92762d476 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -467,7 +467,7 @@ messagebird==1.2.0 mficlient==0.3.0 # homeassistant.components.sensor.miflora -miflora==0.1.16 +miflora==0.2.0 # homeassistant.components.upnp miniupnpc==2.0.2 From 81f1a65faef069ce8eb7a3c646ce55cebf974866 Mon Sep 17 00:00:00 2001 From: Ben Randall Date: Wed, 20 Dec 2017 02:50:31 -0800 Subject: [PATCH 042/238] Add workaround for running tox on Windows platforms (#11188) * Add workaround for running tox on Windows platforms * Remove install_command override --- tox.ini | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tox.ini b/tox.ini index f3e58ce8889..32f80b95dc1 100644 --- a/tox.ini +++ b/tox.ini @@ -18,7 +18,7 @@ deps = -c{toxinidir}/homeassistant/package_constraints.txt [testenv:pylint] -basepython = python3 +basepython = {env:PYTHON3_PATH:python3} ignore_errors = True deps = -r{toxinidir}/requirements_all.txt @@ -28,7 +28,7 @@ commands = pylint homeassistant [testenv:lint] -basepython = python3 +basepython = {env:PYTHON3_PATH:python3} deps = -r{toxinidir}/requirements_test.txt commands = @@ -37,7 +37,7 @@ commands = pydocstyle homeassistant tests [testenv:typing] -basepython = python3 +basepython = {env:PYTHON3_PATH:python3} deps = -r{toxinidir}/requirements_test.txt commands = From e0682044f0587c9ab806a4f626f64ed7c841a0e4 Mon Sep 17 00:00:00 2001 From: ChristianKuehnel Date: Wed, 20 Dec 2017 12:11:56 +0100 Subject: [PATCH 043/238] added myself to become code owner for miflora and plant (#11251) --- CODEOWNERS | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CODEOWNERS b/CODEOWNERS index ac0f794482a..37a2494c182 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -53,10 +53,11 @@ homeassistant/components/light/yeelight.py @rytilahti homeassistant/components/media_player/kodi.py @armills homeassistant/components/media_player/monoprice.py @etsinko homeassistant/components/media_player/yamaha_musiccast.py @jalmeroth +homeassistant/components/plant.py @ChristianKuehnel homeassistant/components/sensor/airvisual.py @bachya homeassistant/components/sensor/gearbest.py @HerrHofrat homeassistant/components/sensor/irish_rail_transport.py @ttroy50 -homeassistant/components/sensor/miflora.py @danielhiversen +homeassistant/components/sensor/miflora.py @danielhiversen @ChristianKuehnel homeassistant/components/sensor/sytadin.py @gautric homeassistant/components/sensor/tibber.py @danielhiversen homeassistant/components/sensor/waqi.py @andrey-git From b28bfad496c736f4d58f629e20a273e5dab50b9e Mon Sep 17 00:00:00 2001 From: Greg Laabs Date: Wed, 20 Dec 2017 14:58:22 -0800 Subject: [PATCH 044/238] Fix detection of if a negative node is in use (#11255) * Fix detection of if a negative node is in use Fix a problem where every negative node gets detected as in-use. Code was not checking the correct property. * Allow protected access --- homeassistant/components/binary_sensor/isy994.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/binary_sensor/isy994.py b/homeassistant/components/binary_sensor/isy994.py index a5b61c9ffed..247ea0b231a 100644 --- a/homeassistant/components/binary_sensor/isy994.py +++ b/homeassistant/components/binary_sensor/isy994.py @@ -165,7 +165,8 @@ class ISYBinarySensorDevice(isy.ISYDevice, BinarySensorDevice): """ self._negative_node = child - if not _is_val_unknown(self._negative_node): + # pylint: disable=protected-access + if not _is_val_unknown(self._negative_node.status._val): # If the negative node has a value, it means the negative node is # in use for this device. Therefore, we cannot determine the state # of the sensor until we receive our first ON event. From 1d579587c1d6605f144fb98f23935d7a40993660 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Wed, 20 Dec 2017 23:59:11 +0100 Subject: [PATCH 045/238] Bugfix homematic available modus (#11256) --- homeassistant/components/homematic/__init__.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/homematic/__init__.py b/homeassistant/components/homematic/__init__.py index 409f2a76fe8..46f25e4e05f 100644 --- a/homeassistant/components/homematic/__init__.py +++ b/homeassistant/components/homematic/__init__.py @@ -749,10 +749,6 @@ class HMDevice(Entity): """Return device specific state attributes.""" attr = {} - # No data available - if not self.available: - return attr - # Generate a dictionary with attributes for node, data in HM_ATTRIBUTE_SUPPORT.items(): # Is an attribute and exists for this object @@ -808,6 +804,9 @@ class HMDevice(Entity): if attribute == 'UNREACH': self._available = bool(value) has_changed = True + elif not self.available: + self._available = False + has_changed = True # If it has changed data point, update HASS if has_changed: From 7faa94046c9c73f744bdde7fa0dde4749b363df1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frank=20Wickstr=C3=B6m?= Date: Thu, 21 Dec 2017 05:32:33 +0200 Subject: [PATCH 046/238] Proper Steam game names and small fixes (#11182) * Use constant for offline state * Use constant for no game name * Rename trade and play constant their proper names Trade and Play are not the correct names for the states. For instance Play might be seens as the user is actually is playing, which is not correct as there is no such state is returned from the Steam API. Just having "trade" does not say much about what is happening and might be misintepreted that the user is currently trading, which is not correct either. We instead use the names from the underlying library for naming the states [1] [1] https://github.com/Lagg/steamodd/blob/2e518ad84f3afce631d5d7eca3af0f85b5330b5b/steam/user.py#L109 * Get the proper game name if no extra info is given from the api The third `current_game` parameter that was used before hold extra information about the game that is being played. This might contain the game name, it might also be empty. The correct way to get the game name is to fetch it from the API depending on the game id that might be returned in the `current_game` attribute if the user is playing a game. To not break existing implementations we keep the functionality to first go with the extra information and only then fetch the proper game name. * Refactor getting game name to its own function This cleans up the code and removed "ugly" else statements from the sensor and makes the game fetching easier to read. * Let state constant values be lower snake case * Return None instead of 'None' when no current game exists * Initialize steam app list only once to benefit form caching * Return None as state attributes if no current game is present --- .../components/sensor/steam_online.py | 50 +++++++++++++------ 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/sensor/steam_online.py b/homeassistant/components/sensor/steam_online.py index 8645d4ee7c6..88cb786e66d 100644 --- a/homeassistant/components/sensor/steam_online.py +++ b/homeassistant/components/sensor/steam_online.py @@ -21,12 +21,13 @@ CONF_ACCOUNTS = 'accounts' ICON = 'mdi:steam' -STATE_ONLINE = 'Online' -STATE_BUSY = 'Busy' -STATE_AWAY = 'Away' -STATE_SNOOZE = 'Snooze' -STATE_TRADE = 'Trade' -STATE_PLAY = 'Play' +STATE_OFFLINE = 'offline' +STATE_ONLINE = 'online' +STATE_BUSY = 'busy' +STATE_AWAY = 'away' +STATE_SNOOZE = 'snooze' +STATE_LOOKING_TO_TRADE = 'looking_to_trade' +STATE_LOOKING_TO_PLAY = 'looking_to_play' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_API_KEY): cv.string, @@ -40,17 +41,23 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Steam platform.""" import steam as steamod steamod.api.key.set(config.get(CONF_API_KEY)) + # Initialize steammods app list before creating sensors + # to benefit from internal caching of the list. + steam_app_list = steamod.apps.app_list() add_devices( [SteamSensor(account, - steamod) for account in config.get(CONF_ACCOUNTS)], True) + steamod, + steam_app_list) + for account in config.get(CONF_ACCOUNTS)], True) class SteamSensor(Entity): """A class for the Steam account.""" - def __init__(self, account, steamod): + def __init__(self, account, steamod, steam_app_list): """Initialize the sensor.""" self._steamod = steamod + self._steam_app_list = steam_app_list self._account = account self._profile = None self._game = self._state = self._name = self._avatar = None @@ -75,28 +82,39 @@ class SteamSensor(Entity): """Update device state.""" try: self._profile = self._steamod.user.profile(self._account) - if self._profile.current_game[2] is None: - self._game = 'None' - else: - self._game = self._profile.current_game[2] + self._game = self._get_current_game() self._state = { 1: STATE_ONLINE, 2: STATE_BUSY, 3: STATE_AWAY, 4: STATE_SNOOZE, - 5: STATE_TRADE, - 6: STATE_PLAY, - }.get(self._profile.status, 'Offline') + 5: STATE_LOOKING_TO_TRADE, + 6: STATE_LOOKING_TO_PLAY, + }.get(self._profile.status, STATE_OFFLINE) self._name = self._profile.persona self._avatar = self._profile.avatar_medium except self._steamod.api.HTTPTimeoutError as error: _LOGGER.warning(error) self._game = self._state = self._name = self._avatar = None + def _get_current_game(self): + game_id = self._profile.current_game[0] + game_extra_info = self._profile.current_game[2] + + if game_extra_info: + return game_extra_info + + if game_id and game_id in self._steam_app_list: + # The app list always returns a tuple + # with the game id and the game name + return self._steam_app_list[game_id][1] + + return None + @property def device_state_attributes(self): """Return the state attributes.""" - return {'game': self._game} + return {'game': self._game} if self._game else None @property def entity_picture(self): From b866687cd794266f9b82794e3a77016c82381d53 Mon Sep 17 00:00:00 2001 From: CTLS Date: Wed, 20 Dec 2017 23:29:42 -0600 Subject: [PATCH 047/238] Fix inverted sensors on the concord232 binary sensor component (#11261) * Fix inverted sensors on the concord232 binary sensor component * Changed from == Tripped to != Normal --- homeassistant/components/binary_sensor/concord232.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/binary_sensor/concord232.py b/homeassistant/components/binary_sensor/concord232.py index 7ba88f76611..d689f030d8a 100755 --- a/homeassistant/components/binary_sensor/concord232.py +++ b/homeassistant/components/binary_sensor/concord232.py @@ -118,7 +118,7 @@ class Concord232ZoneSensor(BinarySensorDevice): def is_on(self): """Return true if the binary sensor is on.""" # True means "faulted" or "open" or "abnormal state" - return bool(self._zone['state'] == 'Normal') + return bool(self._zone['state'] != 'Normal') def update(self): """Get updated stats from API.""" From 2e4e3a42cc9c86d931e6d0e4a363fdb476403075 Mon Sep 17 00:00:00 2001 From: Zio Tibia <4745882+ziotibia81@users.noreply.github.com> Date: Thu, 21 Dec 2017 14:24:19 +0100 Subject: [PATCH 048/238] Fix handling zero values for state_on/state_off (#11264) --- homeassistant/components/switch/modbus.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/switch/modbus.py b/homeassistant/components/switch/modbus.py index c731b336dfb..211ff54d5a4 100644 --- a/homeassistant/components/switch/modbus.py +++ b/homeassistant/components/switch/modbus.py @@ -141,10 +141,17 @@ class ModbusRegisterSwitch(ModbusCoilSwitch): self._verify_register = ( verify_register if verify_register else self._register) self._register_type = register_type - self._state_on = ( - state_on if state_on else self._command_on) - self._state_off = ( - state_off if state_off else self._command_off) + + if state_on is not None: + self._state_on = state_on + else: + self._state_on = self._command_on + + if state_off is not None: + self._state_off = state_off + else: + self._state_off = self._command_off + self._is_on = None def turn_on(self, **kwargs): From 901d4b54891cea21e4f91b6ff67659e9b2c4a60b Mon Sep 17 00:00:00 2001 From: Ryan McLean Date: Thu, 21 Dec 2017 15:24:57 +0000 Subject: [PATCH 049/238] Bugfix: 10509 - http is hard coded in plex sensor (#11072) * Fix for sensor no ssl * Line length Fixes * Removed unneeded schema extensions * Removed unrequired variable * Added defaults for SSL & SSL Verify * Moved Defaults to Variables Corrected SSL Defaults to match the other Defaults style * Fixed Typo * Removed option to disable verify ssl * Removed unused import * Removed unused CONST * Fixed error handling * Cleanup of unneeded vars & logging * Fix for linting --- homeassistant/components/sensor/plex.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/sensor/plex.py b/homeassistant/components/sensor/plex.py index 0a75d0395ec..a40aeee55e5 100644 --- a/homeassistant/components/sensor/plex.py +++ b/homeassistant/components/sensor/plex.py @@ -10,7 +10,8 @@ import voluptuous as vol from homeassistant.components.switch import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_NAME, CONF_USERNAME, CONF_PASSWORD, CONF_HOST, CONF_PORT, CONF_TOKEN) + CONF_NAME, CONF_USERNAME, CONF_PASSWORD, CONF_HOST, CONF_PORT, CONF_TOKEN, + CONF_SSL) from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv @@ -24,6 +25,7 @@ CONF_SERVER = 'server' DEFAULT_HOST = 'localhost' DEFAULT_NAME = 'Plex' DEFAULT_PORT = 32400 +DEFAULT_SSL = False MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) @@ -35,6 +37,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_SERVER): cv.string, vol.Optional(CONF_USERNAME): cv.string, + vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, }) @@ -48,11 +51,20 @@ def setup_platform(hass, config, add_devices, discovery_info=None): plex_host = config.get(CONF_HOST) plex_port = config.get(CONF_PORT) plex_token = config.get(CONF_TOKEN) - plex_url = 'http://{}:{}'.format(plex_host, plex_port) - add_devices([PlexSensor( - name, plex_url, plex_user, plex_password, plex_server, - plex_token)], True) + plex_url = '{}://{}:{}'.format('https' if config.get(CONF_SSL) else 'http', + plex_host, plex_port) + + import plexapi.exceptions + + try: + add_devices([PlexSensor( + name, plex_url, plex_user, plex_password, plex_server, + plex_token)], True) + except (plexapi.exceptions.BadRequest, plexapi.exceptions.Unauthorized, + plexapi.exceptions.NotFound) as error: + _LOGGER.error(error) + return class PlexSensor(Entity): From c94cc34a8f350dcf6f3ca770af11ab772a90e317 Mon Sep 17 00:00:00 2001 From: schnoetz <34626347+schnoetz@users.noreply.github.com> Date: Thu, 21 Dec 2017 21:46:42 +0100 Subject: [PATCH 050/238] Adding MotionIP to BinarySensors for HMIP-SMI (#11268) * Adding MotionIP to BinarySensors for HMIP-SMI My HmIP-SMI (Homematic IP Motion Sensor) only shows "ILLUMINATION" and no MOTION, because the binary values are not recognized. The "old" homematic-motion detectors are working well showing motion, too. I found out that "MotionIP" was missing at the binary_sensors - after adding "Motion" and "Motion Detection Activated" are shown. * Removed trailing blanks removed trailing blanks from my previous change --- homeassistant/components/homematic/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homematic/__init__.py b/homeassistant/components/homematic/__init__.py index 46f25e4e05f..08e8455b302 100644 --- a/homeassistant/components/homematic/__init__.py +++ b/homeassistant/components/homematic/__init__.py @@ -77,9 +77,9 @@ HM_DEVICE_TYPES = { 'ThermostatGroup'], DISCOVER_BINARY_SENSORS: [ 'ShutterContact', 'Smoke', 'SmokeV2', 'Motion', 'MotionV2', - 'RemoteMotion', 'WeatherSensor', 'TiltSensor', 'IPShutterContact', - 'HMWIOSwitch', 'MaxShutterContact', 'Rain', 'WiredSensor', - 'PresenceIP'], + 'MotionIP', 'RemoteMotion', 'WeatherSensor', 'TiltSensor', + 'IPShutterContact', 'HMWIOSwitch', 'MaxShutterContact', 'Rain', + 'WiredSensor', 'PresenceIP'], DISCOVER_COVER: ['Blind', 'KeyBlind'] } From 9e0a765801de5b3d574e7778c70f309f2c346718 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Thu, 21 Dec 2017 22:33:37 +0100 Subject: [PATCH 051/238] Revert "Backup configuration files before overwriting" (#11269) * Revert "Adding MotionIP to BinarySensors for HMIP-SMI (#11268)" This reverts commit c94cc34a8f350dcf6f3ca770af11ab772a90e317. * Revert "Bugfix: 10509 - http is hard coded in plex sensor (#11072)" This reverts commit 901d4b54891cea21e4f91b6ff67659e9b2c4a60b. * Revert "Fix handling zero values for state_on/state_off (#11264)" This reverts commit 2e4e3a42cc9c86d931e6d0e4a363fdb476403075. * Revert "Fix inverted sensors on the concord232 binary sensor component (#11261)" This reverts commit b866687cd794266f9b82794e3a77016c82381d53. * Revert "Proper Steam game names and small fixes (#11182)" This reverts commit 7faa94046c9c73f744bdde7fa0dde4749b363df1. * Revert "Bugfix homematic available modus (#11256)" This reverts commit 1d579587c1d6605f144fb98f23935d7a40993660. * Revert "Fix detection of if a negative node is in use (#11255)" This reverts commit b28bfad496c736f4d58f629e20a273e5dab50b9e. * Revert "added myself to become code owner for miflora and plant (#11251)" This reverts commit e0682044f0587c9ab806a4f626f64ed7c841a0e4. * Revert "Add workaround for running tox on Windows platforms (#11188)" This reverts commit 81f1a65faef069ce8eb7a3c646ce55cebf974866. * Revert "Upgrade to new miflora version 0.2.0 (#11250)" This reverts commit 8efc4b5ba99e2c2554f335fcadbdd13831a69155. * Revert "homematic: add username and password to interface config schema (#11214)" This reverts commit b4e2537de34b60b8fc37c6439f6c06b95d4617de. * Revert "Backup configuration files before overwriting (#11216)" This reverts commit 90e25a6dfbf64c6e569fabe32494154e3d916979. --- homeassistant/config.py | 47 ----------------------------------------- 1 file changed, 47 deletions(-) diff --git a/homeassistant/config.py b/homeassistant/config.py index 34fd3848f6f..fee7572a2c2 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -233,68 +233,21 @@ def create_default_config(config_dir, detect_location=True): config_file.write(DEFAULT_CONFIG) - timestamp = date_util.now().strftime('%Y%m%dT%H%M%S') - # Check for existing secrets file. - # If it exists, back it up before recreating it. - if os.path.isfile(secret_path): - backup_secret_path = "{}.{}.bak".format( - secret_path, - timestamp - ) - print("Found existing secrets file. Backing up and re-creating.") - os.rename(secret_path, backup_secret_path) with open(secret_path, 'wt') as secret_file: secret_file.write(DEFAULT_SECRETS) with open(version_path, 'wt') as version_file: version_file.write(__version__) - # Check for existing group file. - # If it exists, back it up before recreating it. - if os.path.isfile(group_yaml_path): - backup_group_path = "{}.{}.bak".format( - group_yaml_path, - timestamp - ) - print("Found existing group file. Backing up and re-creating.") - os.rename(group_yaml_path, backup_group_path) with open(group_yaml_path, 'wt'): pass - # Check for existing automation file. - # If it exists, back it up before recreating it. - if os.path.isfile(automation_yaml_path): - backup_automation_path = "{}.{}.bak".format( - automation_yaml_path, - timestamp - ) - print("Found existing automation file. Backing up and", - "re-creating.") - os.rename(automation_yaml_path, backup_automation_path) with open(automation_yaml_path, 'wt') as fil: fil.write('[]') - # Check for existing group file. - # If it exists, back it up before recreating it. - if os.path.isfile(script_yaml_path): - backup_script_path = "{}.{}.bak".format( - script_yaml_path, - timestamp - ) - print("Found existing script file. Backing up and re-creating.") - os.rename(script_yaml_path, backup_script_path) with open(script_yaml_path, 'wt'): pass - # Check for existing customize file. - # If it exists, back it up before recreating it. - if os.path.isfile(customize_yaml_path): - backup_customize_path = "{}.{}.bak".format( - customize_yaml_path, - timestamp - ) - print("Found existing customize file. Backing up and re-creating.") - os.rename(customize_yaml_path, backup_customize_path) with open(customize_yaml_path, 'wt'): pass From eeb309aea1ab125aaa12c9a977564fd40e9bb8f1 Mon Sep 17 00:00:00 2001 From: Egor Tsinko Date: Fri, 22 Dec 2017 02:26:34 -0700 Subject: [PATCH 052/238] Functinality to save/restore snapshots for monoprice platform (#10296) * added functionality to save/restore snapshots to monoprice platform * renamed monoprice_snapshot, monoprice_restore to snapshot, restore This is to simplify refactoring of snapshot/restore functionality for monoprice, snapcast and sonos in the future --- .../components/media_player/monoprice.py | 68 ++++++- .../components/media_player/services.yaml | 14 ++ requirements_all.txt | 2 +- requirements_test_all.txt | 3 + script/gen_requirements_all.py | 1 + .../components/media_player/test_monoprice.py | 179 ++++++++++++++++-- 6 files changed, 240 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/media_player/monoprice.py b/homeassistant/components/media_player/monoprice.py index 10b4b8414d8..a2b5d91945a 100644 --- a/homeassistant/components/media_player/monoprice.py +++ b/homeassistant/components/media_player/monoprice.py @@ -5,18 +5,21 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/media_player.monoprice/ """ import logging +from os import path import voluptuous as vol -from homeassistant.const import (CONF_NAME, CONF_PORT, STATE_OFF, STATE_ON) +from homeassistant.const import (ATTR_ENTITY_ID, CONF_NAME, CONF_PORT, + STATE_OFF, STATE_ON) +from homeassistant.config import load_yaml_config_file import homeassistant.helpers.config_validation as cv from homeassistant.components.media_player import ( - MediaPlayerDevice, PLATFORM_SCHEMA, SUPPORT_VOLUME_MUTE, - SUPPORT_SELECT_SOURCE, SUPPORT_TURN_ON, SUPPORT_TURN_OFF, - SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP) + DOMAIN, MediaPlayerDevice, MEDIA_PLAYER_SCHEMA, PLATFORM_SCHEMA, + SUPPORT_VOLUME_MUTE, SUPPORT_SELECT_SOURCE, SUPPORT_TURN_ON, + SUPPORT_TURN_OFF, SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP) -REQUIREMENTS = ['pymonoprice==0.2'] +REQUIREMENTS = ['pymonoprice==0.3'] _LOGGER = logging.getLogger(__name__) @@ -35,6 +38,11 @@ SOURCE_SCHEMA = vol.Schema({ CONF_ZONES = 'zones' CONF_SOURCES = 'sources' +DATA_MONOPRICE = 'monoprice' + +SERVICE_SNAPSHOT = 'snapshot' +SERVICE_RESTORE = 'restore' + # Valid zone ids: 11-16 or 21-26 or 31-36 ZONE_IDS = vol.All(vol.Coerce(int), vol.Any(vol.Range(min=11, max=16), vol.Range(min=21, max=26), @@ -56,9 +64,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): port = config.get(CONF_PORT) from serial import SerialException - from pymonoprice import Monoprice + from pymonoprice import get_monoprice try: - monoprice = Monoprice(port) + monoprice = get_monoprice(port) except SerialException: _LOGGER.error('Error connecting to Monoprice controller.') return @@ -66,10 +74,41 @@ def setup_platform(hass, config, add_devices, discovery_info=None): sources = {source_id: extra[CONF_NAME] for source_id, extra in config[CONF_SOURCES].items()} + hass.data[DATA_MONOPRICE] = [] for zone_id, extra in config[CONF_ZONES].items(): _LOGGER.info("Adding zone %d - %s", zone_id, extra[CONF_NAME]) - add_devices([MonopriceZone(monoprice, sources, - zone_id, extra[CONF_NAME])], True) + hass.data[DATA_MONOPRICE].append(MonopriceZone(monoprice, sources, + zone_id, + extra[CONF_NAME])) + + add_devices(hass.data[DATA_MONOPRICE], True) + + descriptions = load_yaml_config_file( + path.join(path.dirname(__file__), 'services.yaml')) + + def service_handle(service): + """Handle for services.""" + entity_ids = service.data.get(ATTR_ENTITY_ID) + + if entity_ids: + devices = [device for device in hass.data[DATA_MONOPRICE] + if device.entity_id in entity_ids] + else: + devices = hass.data[DATA_MONOPRICE] + + for device in devices: + if service.service == SERVICE_SNAPSHOT: + device.snapshot() + elif service.service == SERVICE_RESTORE: + device.restore() + + hass.services.register( + DOMAIN, SERVICE_SNAPSHOT, service_handle, + descriptions.get(SERVICE_SNAPSHOT), schema=MEDIA_PLAYER_SCHEMA) + + hass.services.register( + DOMAIN, SERVICE_RESTORE, service_handle, + descriptions.get(SERVICE_RESTORE), schema=MEDIA_PLAYER_SCHEMA) class MonopriceZone(MediaPlayerDevice): @@ -90,6 +129,7 @@ class MonopriceZone(MediaPlayerDevice): self._zone_id = zone_id self._name = zone_name + self._snapshot = None self._state = None self._volume = None self._source = None @@ -152,6 +192,16 @@ class MonopriceZone(MediaPlayerDevice): """List of available input sources.""" return self._source_names + def snapshot(self): + """Save zone's current state.""" + self._snapshot = self._monoprice.zone_status(self._zone_id) + + def restore(self): + """Restore saved state.""" + if self._snapshot: + self._monoprice.restore_zone(self._snapshot) + self.schedule_update_ha_state(True) + def select_source(self, source): """Set input source.""" if source not in self._source_name_id: diff --git a/homeassistant/components/media_player/services.yaml b/homeassistant/components/media_player/services.yaml index b2f98d378cf..0ed5f9d2732 100644 --- a/homeassistant/components/media_player/services.yaml +++ b/homeassistant/components/media_player/services.yaml @@ -107,6 +107,20 @@ media_seek: description: Position to seek to. The format is platform dependent. example: 100 +monoprice_snapshot: + description: Take a snapshot of the media player zone. + fields: + entity_id: + description: Name(s) of entities that will be snapshot. Platform dependent. + example: 'media_player.living_room' + +monoprice_restore: + description: Restore a snapshot of the media player zone. + fields: + entity_id: + description: Name(s) of entities that will be restored. Platform dependent. + example: 'media_player.living_room' + play_media: description: Send the media player the command for playing media. fields: diff --git a/requirements_all.txt b/requirements_all.txt index 6b92762d476..bf5ff83ab5b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -756,7 +756,7 @@ pymochad==0.1.1 pymodbus==1.3.1 # homeassistant.components.media_player.monoprice -pymonoprice==0.2 +pymonoprice==0.3 # homeassistant.components.media_player.yamaha_musiccast pymusiccast==0.1.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a96c3af1fd9..648030ab717 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -127,6 +127,9 @@ pydispatcher==2.0.5 # homeassistant.components.litejet pylitejet==0.1 +# homeassistant.components.media_player.monoprice +pymonoprice==0.3 + # homeassistant.components.alarm_control_panel.nx584 # homeassistant.components.binary_sensor.nx584 pynx584==0.4 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 0bfb5f9e607..5f4d789fa77 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -66,6 +66,7 @@ TEST_REQUIREMENTS = ( 'pydispatcher', 'PyJWT', 'pylitejet', + 'pymonoprice', 'pynx584', 'python-forecastio', 'pyunifi', diff --git a/tests/components/media_player/test_monoprice.py b/tests/components/media_player/test_monoprice.py index 2bcd02e69aa..399cdc67ca6 100644 --- a/tests/components/media_player/test_monoprice.py +++ b/tests/components/media_player/test_monoprice.py @@ -1,27 +1,30 @@ """The tests for Monoprice Media player platform.""" import unittest +from unittest import mock import voluptuous as vol from collections import defaultdict - from homeassistant.components.media_player import ( - SUPPORT_TURN_ON, SUPPORT_TURN_OFF, SUPPORT_VOLUME_MUTE, + DOMAIN, SUPPORT_TURN_ON, SUPPORT_TURN_OFF, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP, SUPPORT_SELECT_SOURCE) from homeassistant.const import STATE_ON, STATE_OFF +import tests.common from homeassistant.components.media_player.monoprice import ( - MonopriceZone, PLATFORM_SCHEMA) + DATA_MONOPRICE, PLATFORM_SCHEMA, SERVICE_SNAPSHOT, + SERVICE_RESTORE, setup_platform) -class MockState(object): - """Mock for zone state object.""" +class AttrDict(dict): + """Helper class for mocking attributes.""" - def __init__(self): - """Init zone state.""" - self.power = True - self.volume = 0 - self.mute = True - self.source = 1 + def __setattr__(self, name, value): + """Set attribute.""" + self[name] = value + + def __getattr__(self, item): + """Get attribute.""" + return self[item] class MockMonoprice(object): @@ -29,11 +32,16 @@ class MockMonoprice(object): def __init__(self): """Init mock object.""" - self.zones = defaultdict(lambda *a: MockState()) + self.zones = defaultdict(lambda: AttrDict(power=True, + volume=0, + mute=True, + source=1)) def zone_status(self, zone_id): """Get zone status.""" - return self.zones[zone_id] + status = self.zones[zone_id] + status.zone = zone_id + return AttrDict(status) def set_source(self, zone_id, source_idx): """Set source for zone.""" @@ -51,6 +59,10 @@ class MockMonoprice(object): """Set volume for zone.""" self.zones[zone_id].volume = volume + def restore_zone(self, zone): + """Restore zone status.""" + self.zones[zone.zone] = AttrDict(zone) + class TestMonopriceSchema(unittest.TestCase): """Test Monoprice schema.""" @@ -147,11 +159,144 @@ class TestMonopriceMediaPlayer(unittest.TestCase): def setUp(self): """Set up the test case.""" self.monoprice = MockMonoprice() + self.hass = tests.common.get_test_home_assistant() + self.hass.start() # Note, source dictionary is unsorted! - self.media_player = MonopriceZone(self.monoprice, {1: 'one', - 3: 'three', - 2: 'two'}, - 12, 'Zone name') + with mock.patch('pymonoprice.get_monoprice', + new=lambda *a: self.monoprice): + setup_platform(self.hass, { + 'platform': 'monoprice', + 'port': '/dev/ttyS0', + 'name': 'Name', + 'zones': {12: {'name': 'Zone name'}}, + 'sources': {1: {'name': 'one'}, + 3: {'name': 'three'}, + 2: {'name': 'two'}}, + }, lambda *args, **kwargs: None, {}) + self.hass.block_till_done() + self.media_player = self.hass.data[DATA_MONOPRICE][0] + self.media_player.hass = self.hass + self.media_player.entity_id = 'media_player.zone_1' + + def tearDown(self): + """Tear down the test case.""" + self.hass.stop() + + def test_setup_platform(self, *args): + """Test setting up platform.""" + # Two services must be registered + self.assertTrue(self.hass.services.has_service(DOMAIN, + SERVICE_RESTORE)) + self.assertTrue(self.hass.services.has_service(DOMAIN, + SERVICE_SNAPSHOT)) + self.assertEqual(len(self.hass.data[DATA_MONOPRICE]), 1) + self.assertEqual(self.hass.data[DATA_MONOPRICE][0].name, 'Zone name') + + def test_service_calls_with_entity_id(self): + """Test snapshot save/restore service calls.""" + self.media_player.update() + self.assertEqual('Zone name', self.media_player.name) + self.assertEqual(STATE_ON, self.media_player.state) + self.assertEqual(0.0, self.media_player.volume_level, 0.0001) + self.assertTrue(self.media_player.is_volume_muted) + self.assertEqual('one', self.media_player.source) + + # Saving default values + self.hass.services.call(DOMAIN, SERVICE_SNAPSHOT, + {'entity_id': 'media_player.zone_1'}, + blocking=True) + # self.hass.block_till_done() + + # Changing media player to new state + self.media_player.set_volume_level(1) + self.media_player.select_source('two') + self.media_player.mute_volume(False) + self.media_player.turn_off() + + # Checking that values were indeed changed + self.media_player.update() + self.assertEqual('Zone name', self.media_player.name) + self.assertEqual(STATE_OFF, self.media_player.state) + self.assertEqual(1.0, self.media_player.volume_level, 0.0001) + self.assertFalse(self.media_player.is_volume_muted) + self.assertEqual('two', self.media_player.source) + + # Restoring wrong media player to its previous state + # Nothing should be done + self.hass.services.call(DOMAIN, SERVICE_RESTORE, + {'entity_id': 'not_existing'}, + blocking=True) + # self.hass.block_till_done() + + # Checking that values were not (!) restored + self.media_player.update() + self.assertEqual('Zone name', self.media_player.name) + self.assertEqual(STATE_OFF, self.media_player.state) + self.assertEqual(1.0, self.media_player.volume_level, 0.0001) + self.assertFalse(self.media_player.is_volume_muted) + self.assertEqual('two', self.media_player.source) + + # Restoring media player to its previous state + self.hass.services.call(DOMAIN, SERVICE_RESTORE, + {'entity_id': 'media_player.zone_1'}, + blocking=True) + self.hass.block_till_done() + + # Checking that values were restored + self.assertEqual('Zone name', self.media_player.name) + self.assertEqual(STATE_ON, self.media_player.state) + self.assertEqual(0.0, self.media_player.volume_level, 0.0001) + self.assertTrue(self.media_player.is_volume_muted) + self.assertEqual('one', self.media_player.source) + + def test_service_calls_without_entity_id(self): + """Test snapshot save/restore service calls.""" + self.media_player.update() + self.assertEqual('Zone name', self.media_player.name) + self.assertEqual(STATE_ON, self.media_player.state) + self.assertEqual(0.0, self.media_player.volume_level, 0.0001) + self.assertTrue(self.media_player.is_volume_muted) + self.assertEqual('one', self.media_player.source) + + # Restoring media player + # since there is no snapshot, nothing should be done + self.hass.services.call(DOMAIN, SERVICE_RESTORE, blocking=True) + self.hass.block_till_done() + self.media_player.update() + self.assertEqual('Zone name', self.media_player.name) + self.assertEqual(STATE_ON, self.media_player.state) + self.assertEqual(0.0, self.media_player.volume_level, 0.0001) + self.assertTrue(self.media_player.is_volume_muted) + self.assertEqual('one', self.media_player.source) + + # Saving default values + self.hass.services.call(DOMAIN, SERVICE_SNAPSHOT, blocking=True) + self.hass.block_till_done() + + # Changing media player to new state + self.media_player.set_volume_level(1) + self.media_player.select_source('two') + self.media_player.mute_volume(False) + self.media_player.turn_off() + + # Checking that values were indeed changed + self.media_player.update() + self.assertEqual('Zone name', self.media_player.name) + self.assertEqual(STATE_OFF, self.media_player.state) + self.assertEqual(1.0, self.media_player.volume_level, 0.0001) + self.assertFalse(self.media_player.is_volume_muted) + self.assertEqual('two', self.media_player.source) + + # Restoring media player to its previous state + self.hass.services.call(DOMAIN, SERVICE_RESTORE, blocking=True) + self.hass.block_till_done() + + # Checking that values were restored + self.assertEqual('Zone name', self.media_player.name) + self.assertEqual(STATE_ON, self.media_player.state) + self.assertEqual(0.0, self.media_player.volume_level, 0.0001) + self.assertTrue(self.media_player.is_volume_muted) + self.assertEqual('one', self.media_player.source) def test_update(self): """Test updating values from monoprice.""" From 295caeb065770ecd40de0692093ee07ef78db5c6 Mon Sep 17 00:00:00 2001 From: Ben Randall Date: Fri, 22 Dec 2017 01:28:51 -0800 Subject: [PATCH 053/238] Fix async IO in Sesame lock component. (#11054) * Call update on Sesame devices to cache initial state * Switch to using async_add_devices * Fix line length * Fix Lint errors * Fix more Lint errors * Cache pysesame properties * Updates from CR feedback --- homeassistant/components/lock/sesame.py | 36 ++++++++++++++++--------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/lock/sesame.py b/homeassistant/components/lock/sesame.py index 02b049618d2..5bc40435486 100644 --- a/homeassistant/components/lock/sesame.py +++ b/homeassistant/components/lock/sesame.py @@ -25,46 +25,53 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ # pylint: disable=unused-argument -def setup_platform(hass, config: ConfigType, - add_devices: Callable[[list], None], discovery_info=None): +def setup_platform( + hass, config: ConfigType, + add_devices: Callable[[list], None], discovery_info=None): """Set up the Sesame platform.""" import pysesame email = config.get(CONF_EMAIL) password = config.get(CONF_PASSWORD) - add_devices([SesameDevice(sesame) for - sesame in pysesame.get_sesames(email, password)]) + add_devices([SesameDevice(sesame) for sesame in + pysesame.get_sesames(email, password)], + update_before_add=True) class SesameDevice(LockDevice): """Representation of a Sesame device.""" - _sesame = None - def __init__(self, sesame: object) -> None: """Initialize the Sesame device.""" self._sesame = sesame + # Cached properties from pysesame object. + self._device_id = None + self._nickname = None + self._is_unlocked = False + self._api_enabled = False + self._battery = -1 + @property def name(self) -> str: """Return the name of the device.""" - return self._sesame.nickname + return self._nickname @property def available(self) -> bool: """Return True if entity is available.""" - return self._sesame.api_enabled + return self._api_enabled @property def is_locked(self) -> bool: """Return True if the device is currently locked, else False.""" - return not self._sesame.is_unlocked + return not self._is_unlocked @property def state(self) -> str: """Get the state of the device.""" - if self._sesame.is_unlocked: + if self._is_unlocked: return STATE_UNLOCKED return STATE_LOCKED @@ -79,11 +86,16 @@ class SesameDevice(LockDevice): def update(self) -> None: """Update the internal state of the device.""" self._sesame.update_state() + self._nickname = self._sesame.nickname + self._api_enabled = self._sesame.api_enabled + self._is_unlocked = self._sesame.is_unlocked + self._device_id = self._sesame.device_id + self._battery = self._sesame.battery @property def device_state_attributes(self) -> dict: """Return the state attributes.""" attributes = {} - attributes[ATTR_DEVICE_ID] = self._sesame.device_id - attributes[ATTR_BATTERY_LEVEL] = self._sesame.battery + attributes[ATTR_DEVICE_ID] = self._device_id + attributes[ATTR_BATTERY_LEVEL] = self._battery return attributes From 46df91ff4591c208bfe700de1730c016add5c4eb Mon Sep 17 00:00:00 2001 From: maxlaverse Date: Fri, 22 Dec 2017 14:08:34 +0100 Subject: [PATCH 054/238] Fix allday events in custom_calendars (#11272) --- homeassistant/components/calendar/caldav.py | 3 +-- tests/components/calendar/test_caldav.py | 3 +++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/calendar/caldav.py b/homeassistant/components/calendar/caldav.py index f1cc0f12bd8..36894dcab61 100644 --- a/homeassistant/components/calendar/caldav.py +++ b/homeassistant/components/calendar/caldav.py @@ -25,7 +25,6 @@ CONF_DEVICE_ID = 'device_id' CONF_CALENDARS = 'calendars' CONF_CUSTOM_CALENDARS = 'custom_calendars' CONF_CALENDAR = 'calendar' -CONF_ALL_DAY = 'all_day' CONF_SEARCH = 'search' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -89,7 +88,7 @@ def setup_platform(hass, config, add_devices, disc_info=None): WebDavCalendarEventDevice(hass, device_data, calendar, - cust_calendar.get(CONF_ALL_DAY), + True, cust_calendar.get(CONF_SEARCH)) ) diff --git a/tests/components/calendar/test_caldav.py b/tests/components/calendar/test_caldav.py index 8a44f96fe87..7234d40c410 100644 --- a/tests/components/calendar/test_caldav.py +++ b/tests/components/calendar/test_caldav.py @@ -121,8 +121,10 @@ class TestComponentsWebDavCalendar(unittest.TestCase): assert len(devices) == 2 assert devices[0].name == "First" assert devices[0].dev_id == "First" + self.assertFalse(devices[0].data.include_all_day) assert devices[1].name == "Second" assert devices[1].dev_id == "Second" + self.assertFalse(devices[1].data.include_all_day) caldav.setup_platform(self.hass, { @@ -167,6 +169,7 @@ class TestComponentsWebDavCalendar(unittest.TestCase): assert len(devices) == 1 assert devices[0].name == "HomeOffice" assert devices[0].dev_id == "Second HomeOffice" + self.assertTrue(devices[0].data.include_all_day) caldav.setup_platform(self.hass, { From 353bb62687effe62aeffa86df6069bbdc8c6658c Mon Sep 17 00:00:00 2001 From: Daniel Watkins Date: Fri, 22 Dec 2017 12:38:00 -0500 Subject: [PATCH 055/238] Fix webostv select source (#11227) * Fix reuse of variable name This should fix #11224. * Add tests for LgWebOSDevice.select_source --- .../components/media_player/webostv.py | 18 +++--- tests/components/media_player/test_webostv.py | 60 +++++++++++++++++++ 2 files changed, 69 insertions(+), 9 deletions(-) create mode 100644 tests/components/media_player/test_webostv.py diff --git a/homeassistant/components/media_player/webostv.py b/homeassistant/components/media_player/webostv.py index 0abdb90e67a..9d3e0b90fa4 100644 --- a/homeassistant/components/media_player/webostv.py +++ b/homeassistant/components/media_player/webostv.py @@ -322,17 +322,17 @@ class LgWebOSDevice(MediaPlayerDevice): def select_source(self, source): """Select input source.""" - source = self._source_list.get(source) - if source is None: + source_dict = self._source_list.get(source) + if source_dict is None: _LOGGER.warning("Source %s not found for %s", source, self.name) return - self._current_source_id = self._source_list[source]['id'] - if source.get('title'): - self._current_source = self._source_list[source]['title'] - self._client.launch_app(self._source_list[source]['id']) - elif source.get('label'): - self._current_source = self._source_list[source]['label'] - self._client.set_input(self._source_list[source]['id']) + self._current_source_id = source_dict['id'] + if source_dict.get('title'): + self._current_source = source_dict['title'] + self._client.launch_app(source_dict['id']) + elif source_dict.get('label'): + self._current_source = source_dict['label'] + self._client.set_input(source_dict['id']) def media_play(self): """Send play command.""" diff --git a/tests/components/media_player/test_webostv.py b/tests/components/media_player/test_webostv.py new file mode 100644 index 00000000000..8017ad6cd54 --- /dev/null +++ b/tests/components/media_player/test_webostv.py @@ -0,0 +1,60 @@ +"""The tests for the LG webOS media player platform.""" +import unittest +from unittest import mock + +from homeassistant.components.media_player import webostv + + +class FakeLgWebOSDevice(webostv.LgWebOSDevice): + """A fake device without the client setup required for the real one.""" + + def __init__(self, *args, **kwargs): + """Initialise parameters needed for tests with fake values.""" + self._source_list = {} + self._client = mock.MagicMock() + self._name = 'fake_device' + self._current_source = None + + +class TestLgWebOSDevice(unittest.TestCase): + """Test the LgWebOSDevice class.""" + + def setUp(self): + """Configure a fake device for each test.""" + self.device = FakeLgWebOSDevice() + + def test_select_source_with_empty_source_list(self): + """Ensure we don't call client methods when we don't have sources.""" + self.device.select_source('nonexistent') + assert 0 == self.device._client.launch_app.call_count + assert 0 == self.device._client.set_input.call_count + + def test_select_source_with_titled_entry(self): + """Test that a titled source is treated as an app.""" + self.device._source_list = { + 'existent': { + 'id': 'existent_id', + 'title': 'existent_title', + }, + } + + self.device.select_source('existent') + + assert 'existent_title' == self.device._current_source + assert [mock.call('existent_id')] == ( + self.device._client.launch_app.call_args_list) + + def test_select_source_with_labelled_entry(self): + """Test that a labelled source is treated as an input source.""" + self.device._source_list = { + 'existent': { + 'id': 'existent_id', + 'label': 'existent_label', + }, + } + + self.device.select_source('existent') + + assert 'existent_label' == self.device._current_source + assert [mock.call('existent_id')] == ( + self.device._client.set_input.call_args_list) From 240098dd7e3e59ecd0c7f569d1c22e2b326120e2 Mon Sep 17 00:00:00 2001 From: Andrey Date: Sat, 23 Dec 2017 07:05:15 +0200 Subject: [PATCH 056/238] Change manifest path to /states as this is the path / actually sets. (#11274) --- homeassistant/components/frontend/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index cd206135dde..24cbc2ae85a 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -49,7 +49,7 @@ MANIFEST_JSON = { 'lang': 'en-US', 'name': 'Home Assistant', 'short_name': 'Assistant', - 'start_url': '/', + 'start_url': '/states', 'theme_color': DEFAULT_THEME_COLOR } From 6e2bfcfe651b334482d4014cfa161c928a1e972a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 22 Dec 2017 21:31:31 -0800 Subject: [PATCH 057/238] Update frontend to 20171223.0 --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 24cbc2ae85a..21900e2265f 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -23,7 +23,7 @@ from homeassistant.const import CONF_NAME, EVENT_THEMES_UPDATED from homeassistant.core import callback from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20171216.0', 'user-agents==1.1.0'] +REQUIREMENTS = ['home-assistant-frontend==20171223.0', 'user-agents==1.1.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] diff --git a/requirements_all.txt b/requirements_all.txt index bf5ff83ab5b..0c9f63b327d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -343,7 +343,7 @@ hipnotify==1.0.8 holidays==0.8.1 # homeassistant.components.frontend -home-assistant-frontend==20171216.0 +home-assistant-frontend==20171223.0 # homeassistant.components.camera.onvif http://github.com/tgaugry/suds-passworddigest-py3/archive/86fc50e39b4d2b8997481967d6a7fe1c57118999.zip#suds-passworddigest-py3==0.1.2a diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 648030ab717..ad9fae671cc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -77,7 +77,7 @@ hbmqtt==0.9.1 holidays==0.8.1 # homeassistant.components.frontend -home-assistant-frontend==20171216.0 +home-assistant-frontend==20171223.0 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb From ab9ffc4f0526e9173fc997d3157ed3f372d01349 Mon Sep 17 00:00:00 2001 From: Andrey Date: Sat, 23 Dec 2017 12:10:54 +0200 Subject: [PATCH 058/238] Report Sensibo as off when it is off (#11281) --- homeassistant/components/climate/sensibo.py | 32 +++++++++++++++------ 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/climate/sensibo.py b/homeassistant/components/climate/sensibo.py index 624729249aa..ed23d91587c 100644 --- a/homeassistant/components/climate/sensibo.py +++ b/homeassistant/components/climate/sensibo.py @@ -13,11 +13,12 @@ import async_timeout import voluptuous as vol from homeassistant.const import ( - ATTR_TEMPERATURE, CONF_API_KEY, CONF_ID, TEMP_CELSIUS, TEMP_FAHRENHEIT) + ATTR_TEMPERATURE, CONF_API_KEY, CONF_ID, STATE_OFF, TEMP_CELSIUS, + TEMP_FAHRENHEIT) from homeassistant.components.climate import ( ATTR_CURRENT_HUMIDITY, ClimateDevice, PLATFORM_SCHEMA, SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE, - SUPPORT_FAN_MODE, SUPPORT_AWAY_MODE, SUPPORT_SWING_MODE, + SUPPORT_FAN_MODE, SUPPORT_SWING_MODE, SUPPORT_AUX_HEAT) from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv @@ -41,9 +42,13 @@ _FETCH_FIELDS = ','.join([ 'acState', 'connectionStatus{isAlive}', 'temperatureUnit']) _INITIAL_FETCH_FIELDS = 'id,' + _FETCH_FIELDS -SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE | - SUPPORT_FAN_MODE | SUPPORT_AWAY_MODE | SUPPORT_SWING_MODE | - SUPPORT_AUX_HEAT) +FIELD_TO_FLAG = { + 'fanLevel': SUPPORT_FAN_MODE, + 'mode': SUPPORT_OPERATION_MODE, + 'swing': SUPPORT_SWING_MODE, + 'targetTemperature': SUPPORT_TARGET_TEMPERATURE, + 'on': SUPPORT_AUX_HEAT, +} @asyncio.coroutine @@ -85,7 +90,14 @@ class SensiboClimate(ClimateDevice): @property def supported_features(self): """Return the list of supported features.""" - return SUPPORT_FLAGS + return self._supported_features + + @property + def state(self): + """Return the current state.""" + if not self.is_aux_heat_on: + return STATE_OFF + return super().state def _do_update(self, data): self._name = data['room']['name'] @@ -106,6 +118,10 @@ class SensiboClimate(ClimateDevice): else: self._temperature_unit = self.unit_of_measurement self._temperatures_list = [] + self._supported_features = 0 + for key in self._ac_states: + if key in FIELD_TO_FLAG: + self._supported_features |= FIELD_TO_FLAG[key] @property def device_state_attributes(self): @@ -196,13 +212,13 @@ class SensiboClimate(ClimateDevice): def min_temp(self): """Return the minimum temperature.""" return self._temperatures_list[0] \ - if len(self._temperatures_list) else super.min_temp() + if len(self._temperatures_list) else super().min_temp() @property def max_temp(self): """Return the maximum temperature.""" return self._temperatures_list[-1] \ - if len(self._temperatures_list) else super.max_temp() + if len(self._temperatures_list) else super().max_temp() @asyncio.coroutine def async_set_temperature(self, **kwargs): From 4f5d7cea11da3780caa66b78e70c5da48ca864fe Mon Sep 17 00:00:00 2001 From: Andrey Kupreychik Date: Sun, 24 Dec 2017 06:15:06 +0700 Subject: [PATCH 059/238] Added password for GPS logger endpoint (#11245) * Added password for GPS logger endpoint * Fixed lint error * Update gpslogger.py * fix lint * fix import --- .../components/device_tracker/gpslogger.py | 65 +++++++++++++------ 1 file changed, 45 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/device_tracker/gpslogger.py b/homeassistant/components/device_tracker/gpslogger.py index b88245ac9a5..1952e6d676d 100644 --- a/homeassistant/components/device_tracker/gpslogger.py +++ b/homeassistant/components/device_tracker/gpslogger.py @@ -5,23 +5,37 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/device_tracker.gpslogger/ """ import asyncio -from functools import partial import logging +from hmac import compare_digest -from homeassistant.const import HTTP_UNPROCESSABLE_ENTITY -from homeassistant.components.http import HomeAssistantView +from aiohttp.web import Request, HTTPUnauthorized # NOQA +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + CONF_PASSWORD, HTTP_UNPROCESSABLE_ENTITY +) +from homeassistant.components.http import ( + CONF_API_PASSWORD, HomeAssistantView +) # pylint: disable=unused-import from homeassistant.components.device_tracker import ( # NOQA - DOMAIN, PLATFORM_SCHEMA) + DOMAIN, PLATFORM_SCHEMA +) _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['http'] +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_PASSWORD): cv.string, +}) -def setup_scanner(hass, config, see, discovery_info=None): + +@asyncio.coroutine +def async_setup_scanner(hass, config, async_see, discovery_info=None): """Set up an endpoint for the GPSLogger application.""" - hass.http.register_view(GPSLoggerView(see)) + hass.http.register_view(GPSLoggerView(async_see, config)) return True @@ -32,26 +46,36 @@ class GPSLoggerView(HomeAssistantView): url = '/api/gpslogger' name = 'api:gpslogger' - def __init__(self, see): + def __init__(self, async_see, config): """Initialize GPSLogger url endpoints.""" - self.see = see + self.async_see = async_see + self._password = config.get(CONF_PASSWORD) + # this component does not require external authentication if + # password is set + self.requires_auth = self._password is None @asyncio.coroutine - def get(self, request): + def get(self, request: Request): """Handle for GPSLogger message received as GET.""" - res = yield from self._handle(request.app['hass'], request.query) - return res + hass = request.app['hass'] + data = request.query + + if self._password is not None: + authenticated = CONF_API_PASSWORD in data and compare_digest( + self._password, + data[CONF_API_PASSWORD] + ) + if not authenticated: + raise HTTPUnauthorized() - @asyncio.coroutine - def _handle(self, hass, data): - """Handle GPSLogger requests.""" if 'latitude' not in data or 'longitude' not in data: return ('Latitude and longitude not specified.', HTTP_UNPROCESSABLE_ENTITY) if 'device' not in data: _LOGGER.error("Device id not specified") - return ('Device id not specified.', HTTP_UNPROCESSABLE_ENTITY) + return ('Device id not specified.', + HTTP_UNPROCESSABLE_ENTITY) device = data['device'].replace('-', '') gps_location = (data['latitude'], data['longitude']) @@ -75,10 +99,11 @@ class GPSLoggerView(HomeAssistantView): if 'activity' in data: attrs['activity'] = data['activity'] - yield from hass.async_add_job( - partial(self.see, dev_id=device, - gps=gps_location, battery=battery, - gps_accuracy=accuracy, - attributes=attrs)) + hass.async_add_job(self.async_see( + dev_id=device, + gps=gps_location, battery=battery, + gps_accuracy=accuracy, + attributes=attrs + )) return 'Setting location for {}'.format(device) From 8683d75aa18de5a917761c85474372fd9fd3f3fc Mon Sep 17 00:00:00 2001 From: David Fiel Date: Sat, 23 Dec 2017 19:11:45 -0500 Subject: [PATCH 060/238] Greenwave Reality (TCP Connected) Lighting Component (#11282) * Create greenwave.py * Update .coveragerc * Update requirements_all.txt * Update greenwave.py Line too long * Update greenwave.py * Update requirements_all.txt * Update greenwave.py * Update greenwave.py * fix style --- .coveragerc | 1 + homeassistant/components/light/greenwave.py | 112 ++++++++++++++++++++ requirements_all.txt | 3 + 3 files changed, 116 insertions(+) create mode 100644 homeassistant/components/light/greenwave.py diff --git a/.coveragerc b/.coveragerc index fba75b62bfe..4751ddce219 100644 --- a/.coveragerc +++ b/.coveragerc @@ -365,6 +365,7 @@ omit = homeassistant/components/light/decora.py homeassistant/components/light/decora_wifi.py homeassistant/components/light/flux_led.py + homeassistant/components/light/greenwave.py homeassistant/components/light/hue.py homeassistant/components/light/hyperion.py homeassistant/components/light/lifx.py diff --git a/homeassistant/components/light/greenwave.py b/homeassistant/components/light/greenwave.py new file mode 100644 index 00000000000..0e99a49eaa9 --- /dev/null +++ b/homeassistant/components/light/greenwave.py @@ -0,0 +1,112 @@ +""" +Support for Greenwave Reality (TCP Connected) lights. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.greenwave/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, Light, PLATFORM_SCHEMA, SUPPORT_BRIGHTNESS) +from homeassistant.const import CONF_HOST +import homeassistant.helpers.config_validation as cv + +SUPPORTED_FEATURES = (SUPPORT_BRIGHTNESS) + +REQUIREMENTS = ['greenwavereality==0.2.9'] +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required("version"): cv.positive_int, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup Greenwave Reality Platform.""" + import greenwavereality as greenwave + import os + host = config.get(CONF_HOST) + tokenfile = hass.config.path('.greenwave') + if config.get("version") == 3: + if os.path.exists(tokenfile): + tokenfile = open(tokenfile) + token = tokenfile.read() + tokenfile.close() + else: + token = greenwave.grab_token(host, 'hass', 'homeassistant') + tokenfile = open(tokenfile, "w+") + tokenfile.write(token) + tokenfile.close() + else: + token = None + doc = greenwave.grab_xml(host, token) + add_devices(GreenwaveLight(device, host, token) for device in doc) + + +class GreenwaveLight(Light): + """Representation of an Greenwave Reality Light.""" + + def __init__(self, light, host, token): + """Initialize a Greenwave Reality Light.""" + import greenwavereality as greenwave + self._did = light['did'] + self._name = light['name'] + self._state = int(light['state']) + self._brightness = greenwave.hass_brightness(light) + self._host = host + self._online = greenwave.check_online(light) + self.token = token + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORTED_FEATURES + + @property + def available(self): + """Return True if entity is available.""" + return self._online + + @property + def name(self): + """Return the display name of this light.""" + return self._name + + @property + def brightness(self): + """Return the brightness of the light.""" + return self._brightness + + @property + def is_on(self): + """Return true if light is on.""" + return self._state + + def turn_on(self, **kwargs): + """Instruct the light to turn on.""" + import greenwavereality as greenwave + temp_brightness = int((kwargs.get(ATTR_BRIGHTNESS, 255) + / 255) * 100) + greenwave.set_brightness(self._host, self._did, + temp_brightness, self.token) + greenwave.turn_on(self._host, self._did, self.token) + + def turn_off(self, **kwargs): + """Instruct the light to turn off.""" + import greenwavereality as greenwave + greenwave.turn_off(self._host, self._did, self.token) + + def update(self): + """Fetch new state data for this light.""" + import greenwavereality as greenwave + doc = greenwave.grab_xml(self._host, self.token) + + for device in doc: + if device['did'] == self._did: + self._state = int(device['state']) + self._brightness = greenwave.hass_brightness(device) + self._online = greenwave.check_online(device) + self._name = device['name'] diff --git a/requirements_all.txt b/requirements_all.txt index 0c9f63b327d..5dc034b9989 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -315,6 +315,9 @@ googlemaps==2.5.1 # homeassistant.components.sensor.gpsd gps3==0.33.3 +# homeassistant.components.light.greenwave +greenwavereality==0.2.9 + # homeassistant.components.media_player.gstreamer gstreamer-player==1.1.0 From 8c303bf48c7191901ebbb983cfacbbd6cd65418d Mon Sep 17 00:00:00 2001 From: Andrea Campi Date: Sun, 24 Dec 2017 00:12:54 +0000 Subject: [PATCH 061/238] Support multiple Hue bridges with lights of the same id (#11259) * Improve support for multiple Hue bridges with lights that have the same id. The old code pre-refactoring kept a per-bridge list of lights in a closure; my refactoring moved that to hass.data, which is convenient but caused them to conflict with each other. Fixes #11183 * Update test_hue.py --- homeassistant/components/hue.py | 2 + homeassistant/components/light/hue.py | 34 ++----- tests/components/light/test_hue.py | 140 +++++++++++++++++++------- 3 files changed, 113 insertions(+), 63 deletions(-) diff --git a/homeassistant/components/hue.py b/homeassistant/components/hue.py index 3dad4429b53..6147f706658 100644 --- a/homeassistant/components/hue.py +++ b/homeassistant/components/hue.py @@ -160,6 +160,8 @@ class HueBridge(object): self.allow_hue_groups = allow_hue_groups self.bridge = None + self.lights = {} + self.lightgroups = {} self.configured = False self.config_request_id = None diff --git a/homeassistant/components/light/hue.py b/homeassistant/components/light/hue.py index a454143bcd2..f5c910ea116 100644 --- a/homeassistant/components/light/hue.py +++ b/homeassistant/components/light/hue.py @@ -31,10 +31,6 @@ DEPENDENCIES = ['hue'] _LOGGER = logging.getLogger(__name__) -DATA_KEY = 'hue_lights' -DATA_LIGHTS = 'lights' -DATA_LIGHTGROUPS = 'lightgroups' - MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100) @@ -93,8 +89,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if discovery_info is None or 'bridge_id' not in discovery_info: return - setup_data(hass) - if config is not None and len(config) > 0: # Legacy configuration, will be removed in 0.60 config_str = yaml.dump([config]) @@ -110,12 +104,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): unthrottled_update_lights(hass, bridge, add_devices) -def setup_data(hass): - """Initialize internal data. Useful from tests.""" - if DATA_KEY not in hass.data: - hass.data[DATA_KEY] = {DATA_LIGHTS: {}, DATA_LIGHTGROUPS: {}} - - @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS) def update_lights(hass, bridge, add_devices): """Update the Hue light objects with latest info from the bridge.""" @@ -176,18 +164,17 @@ def process_lights(hass, api, bridge, bridge_type, update_lights_cb): new_lights = [] - lights = hass.data[DATA_KEY][DATA_LIGHTS] for light_id, info in api_lights.items(): - if light_id not in lights: - lights[light_id] = HueLight( + if light_id not in bridge.lights: + bridge.lights[light_id] = HueLight( int(light_id), info, bridge, update_lights_cb, bridge_type, bridge.allow_unreachable, bridge.allow_in_emulated_hue) - new_lights.append(lights[light_id]) + new_lights.append(bridge.lights[light_id]) else: - lights[light_id].info = info - lights[light_id].schedule_update_ha_state() + bridge.lights[light_id].info = info + bridge.lights[light_id].schedule_update_ha_state() return new_lights @@ -202,23 +189,22 @@ def process_groups(hass, api, bridge, bridge_type, update_lights_cb): new_lights = [] - groups = hass.data[DATA_KEY][DATA_LIGHTGROUPS] for lightgroup_id, info in api_groups.items(): if 'state' not in info: _LOGGER.warning('Group info does not contain state. ' 'Please update your hub.') return [] - if lightgroup_id not in groups: - groups[lightgroup_id] = HueLight( + if lightgroup_id not in bridge.lightgroups: + bridge.lightgroups[lightgroup_id] = HueLight( int(lightgroup_id), info, bridge, update_lights_cb, bridge_type, bridge.allow_unreachable, bridge.allow_in_emulated_hue, True) - new_lights.append(groups[lightgroup_id]) + new_lights.append(bridge.lightgroups[lightgroup_id]) else: - groups[lightgroup_id].info = info - groups[lightgroup_id].schedule_update_ha_state() + bridge.lightgroups[lightgroup_id].info = info + bridge.lightgroups[lightgroup_id].schedule_update_ha_state() return new_lights diff --git a/tests/components/light/test_hue.py b/tests/components/light/test_hue.py index 5e5bd4f6c7f..7955cecba04 100644 --- a/tests/components/light/test_hue.py +++ b/tests/components/light/test_hue.py @@ -36,27 +36,45 @@ class TestSetup(unittest.TestCase): self.mock_lights = [] self.mock_groups = [] self.mock_add_devices = MagicMock() - hue_light.setup_data(self.hass) def setup_mocks_for_process_lights(self): """Set up all mocks for process_lights tests.""" - self.mock_bridge = MagicMock() + self.mock_bridge = self.create_mock_bridge('host') self.mock_api = MagicMock() self.mock_api.get.return_value = {} self.mock_bridge.get_api.return_value = self.mock_api self.mock_bridge_type = MagicMock() - hue_light.setup_data(self.hass) def setup_mocks_for_process_groups(self): """Set up all mocks for process_groups tests.""" - self.mock_bridge = MagicMock() + self.mock_bridge = self.create_mock_bridge('host') self.mock_bridge.get_group.return_value = { 'name': 'Group 0', 'state': {'any_on': True}} + self.mock_api = MagicMock() self.mock_api.get.return_value = {} self.mock_bridge.get_api.return_value = self.mock_api + self.mock_bridge_type = MagicMock() - hue_light.setup_data(self.hass) + + def create_mock_bridge(self, host, allow_hue_groups=True): + """Return a mock HueBridge with reasonable defaults.""" + mock_bridge = MagicMock() + mock_bridge.host = host + mock_bridge.allow_hue_groups = allow_hue_groups + mock_bridge.lights = {} + mock_bridge.lightgroups = {} + return mock_bridge + + def create_mock_lights(self, lights): + """Return a dict suitable for mocking api.get('lights').""" + mock_bridge_lights = lights + + for light_id, info in mock_bridge_lights.items(): + if 'state' not in info: + info['state'] = {'on': False} + + return mock_bridge_lights def test_setup_platform_no_discovery_info(self): """Test setup_platform without discovery info.""" @@ -211,6 +229,70 @@ class TestSetup(unittest.TestCase): self.mock_add_devices.assert_called_once_with( self.mock_lights) + @MockDependency('phue') + def test_update_lights_with_two_bridges(self, mock_phue): + """Test the update_lights function with two bridges.""" + self.setup_mocks_for_update_lights() + + mock_bridge_one = self.create_mock_bridge('one', False) + mock_bridge_one_lights = self.create_mock_lights( + {1: {'name': 'b1l1'}, 2: {'name': 'b1l2'}}) + + mock_bridge_two = self.create_mock_bridge('two', False) + mock_bridge_two_lights = self.create_mock_lights( + {1: {'name': 'b2l1'}, 3: {'name': 'b2l3'}}) + + with patch('homeassistant.components.light.hue.get_bridge_type', + return_value=self.mock_bridge_type): + with patch('homeassistant.components.light.hue.HueLight.' + 'schedule_update_ha_state'): + mock_api = MagicMock() + mock_api.get.return_value = mock_bridge_one_lights + with patch.object(mock_bridge_one, 'get_api', + return_value=mock_api): + hue_light.unthrottled_update_lights( + self.hass, mock_bridge_one, self.mock_add_devices) + + mock_api = MagicMock() + mock_api.get.return_value = mock_bridge_two_lights + with patch.object(mock_bridge_two, 'get_api', + return_value=mock_api): + hue_light.unthrottled_update_lights( + self.hass, mock_bridge_two, self.mock_add_devices) + + self.assertEquals(sorted(mock_bridge_one.lights.keys()), [1, 2]) + self.assertEquals(sorted(mock_bridge_two.lights.keys()), [1, 3]) + + self.assertEquals(len(self.mock_add_devices.mock_calls), 2) + + # first call + name, args, kwargs = self.mock_add_devices.mock_calls[0] + self.assertEquals(len(args), 1) + self.assertEquals(len(kwargs), 0) + + # one argument, a list of lights in bridge one; each of them is an + # object of type HueLight so we can't straight up compare them + lights = args[0] + self.assertEquals( + lights[0].unique_id, + '{}.b1l1.Light.1'.format(hue_light.HueLight)) + self.assertEquals( + lights[1].unique_id, + '{}.b1l2.Light.2'.format(hue_light.HueLight)) + + # second call works the same + name, args, kwargs = self.mock_add_devices.mock_calls[1] + self.assertEquals(len(args), 1) + self.assertEquals(len(kwargs), 0) + + lights = args[0] + self.assertEquals( + lights[0].unique_id, + '{}.b2l1.Light.1'.format(hue_light.HueLight)) + self.assertEquals( + lights[1].unique_id, + '{}.b2l3.Light.3'.format(hue_light.HueLight)) + def test_process_lights_api_error(self): """Test the process_lights function when the bridge errors out.""" self.setup_mocks_for_process_lights() @@ -221,9 +303,7 @@ class TestSetup(unittest.TestCase): None) self.assertEquals([], ret) - self.assertEquals( - {}, - self.hass.data[hue_light.DATA_KEY][hue_light.DATA_LIGHTS]) + self.assertEquals(self.mock_bridge.lights, {}) def test_process_lights_no_lights(self): """Test the process_lights function when bridge returns no lights.""" @@ -234,9 +314,7 @@ class TestSetup(unittest.TestCase): None) self.assertEquals([], ret) - self.assertEquals( - {}, - self.hass.data[hue_light.DATA_KEY][hue_light.DATA_LIGHTS]) + self.assertEquals(self.mock_bridge.lights, {}) @patch('homeassistant.components.light.hue.HueLight') def test_process_lights_some_lights(self, mock_hue_light): @@ -260,9 +338,7 @@ class TestSetup(unittest.TestCase): self.mock_bridge_type, self.mock_bridge.allow_unreachable, self.mock_bridge.allow_in_emulated_hue), ]) - self.assertEquals( - len(self.hass.data[hue_light.DATA_KEY][hue_light.DATA_LIGHTS]), - 2) + self.assertEquals(len(self.mock_bridge.lights), 2) @patch('homeassistant.components.light.hue.HueLight') def test_process_lights_new_light(self, mock_hue_light): @@ -274,8 +350,7 @@ class TestSetup(unittest.TestCase): self.setup_mocks_for_process_lights() self.mock_api.get.return_value = { 1: {'state': 'on'}, 2: {'state': 'off'}} - self.hass.data[ - hue_light.DATA_KEY][hue_light.DATA_LIGHTS][1] = MagicMock() + self.mock_bridge.lights = {1: MagicMock()} ret = hue_light.process_lights( self.hass, self.mock_api, self.mock_bridge, self.mock_bridge_type, @@ -288,11 +363,9 @@ class TestSetup(unittest.TestCase): self.mock_bridge_type, self.mock_bridge.allow_unreachable, self.mock_bridge.allow_in_emulated_hue), ]) - self.hass.data[hue_light.DATA_KEY][hue_light.DATA_LIGHTS][ - 1].schedule_update_ha_state.assert_called_once_with() - self.assertEquals( - len(self.hass.data[hue_light.DATA_KEY][hue_light.DATA_LIGHTS]), - 2) + self.assertEquals(len(self.mock_bridge.lights), 2) + self.mock_bridge.lights[1]\ + .schedule_update_ha_state.assert_called_once_with() def test_process_groups_api_error(self): """Test the process_groups function when the bridge errors out.""" @@ -304,9 +377,7 @@ class TestSetup(unittest.TestCase): None) self.assertEquals([], ret) - self.assertEquals( - {}, - self.hass.data[hue_light.DATA_KEY][hue_light.DATA_LIGHTGROUPS]) + self.assertEquals(self.mock_bridge.lightgroups, {}) def test_process_groups_no_state(self): """Test the process_groups function when bridge returns no status.""" @@ -318,9 +389,7 @@ class TestSetup(unittest.TestCase): None) self.assertEquals([], ret) - self.assertEquals( - {}, - self.hass.data[hue_light.DATA_KEY][hue_light.DATA_LIGHTGROUPS]) + self.assertEquals(self.mock_bridge.lightgroups, {}) @patch('homeassistant.components.light.hue.HueLight') def test_process_groups_some_groups(self, mock_hue_light): @@ -344,10 +413,7 @@ class TestSetup(unittest.TestCase): self.mock_bridge_type, self.mock_bridge.allow_unreachable, self.mock_bridge.allow_in_emulated_hue, True), ]) - self.assertEquals( - len(self.hass.data[ - hue_light.DATA_KEY][hue_light.DATA_LIGHTGROUPS]), - 2) + self.assertEquals(len(self.mock_bridge.lightgroups), 2) @patch('homeassistant.components.light.hue.HueLight') def test_process_groups_new_group(self, mock_hue_light): @@ -359,8 +425,7 @@ class TestSetup(unittest.TestCase): self.setup_mocks_for_process_groups() self.mock_api.get.return_value = { 1: {'state': 'on'}, 2: {'state': 'off'}} - self.hass.data[ - hue_light.DATA_KEY][hue_light.DATA_LIGHTGROUPS][1] = MagicMock() + self.mock_bridge.lightgroups = {1: MagicMock()} ret = hue_light.process_groups( self.hass, self.mock_api, self.mock_bridge, self.mock_bridge_type, @@ -373,12 +438,9 @@ class TestSetup(unittest.TestCase): self.mock_bridge_type, self.mock_bridge.allow_unreachable, self.mock_bridge.allow_in_emulated_hue, True), ]) - self.hass.data[hue_light.DATA_KEY][hue_light.DATA_LIGHTGROUPS][ - 1].schedule_update_ha_state.assert_called_once_with() - self.assertEquals( - len(self.hass.data[ - hue_light.DATA_KEY][hue_light.DATA_LIGHTGROUPS]), - 2) + self.assertEquals(len(self.mock_bridge.lightgroups), 2) + self.mock_bridge.lightgroups[1]\ + .schedule_update_ha_state.assert_called_once_with() class TestHueLight(unittest.TestCase): From 5566ea8c81824f9b644756f5c6fa509ce693ee0f Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Sat, 23 Dec 2017 17:19:04 -0700 Subject: [PATCH 062/238] Adds support for disabled Tiles and automatic session renewal (#11172) * Adds support for disabled Tiles and automatic session renewal * Updated requirements * Collaborator-requested changes * Collaborator-requested changes --- .../components/device_tracker/tile.py | 38 +++++++++---------- requirements_all.txt | 2 +- 2 files changed, 19 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/device_tracker/tile.py b/homeassistant/components/device_tracker/tile.py index f27a950a49f..377686b6905 100644 --- a/homeassistant/components/device_tracker/tile.py +++ b/homeassistant/components/device_tracker/tile.py @@ -19,7 +19,7 @@ from homeassistant.util.json import load_json, save_json _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['pytile==1.0.0'] +REQUIREMENTS = ['pytile==1.1.0'] CLIENT_UUID_CONFIG_FILE = '.tile.conf' DEFAULT_ICON = 'mdi:bluetooth' @@ -29,14 +29,15 @@ ATTR_ALTITUDE = 'altitude' ATTR_CONNECTION_STATE = 'connection_state' ATTR_IS_DEAD = 'is_dead' ATTR_IS_LOST = 'is_lost' -ATTR_LAST_SEEN = 'last_seen' -ATTR_LAST_UPDATED = 'last_updated' ATTR_RING_STATE = 'ring_state' ATTR_VOIP_STATE = 'voip_state' +CONF_SHOW_INACTIVE = 'show_inactive' + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_SHOW_INACTIVE, default=False): cv.boolean, vol.Optional(CONF_MONITORED_VARIABLES): vol.All(cv.ensure_list, [vol.In(DEVICE_TYPES)]), }) @@ -79,6 +80,7 @@ class TileDeviceScanner(DeviceScanner): _LOGGER.debug('Client UUID: %s', self._client.client_uuid) _LOGGER.debug('User UUID: %s', self._client.user_uuid) + self._show_inactive = config.get(CONF_SHOW_INACTIVE) self._types = config.get(CONF_MONITORED_VARIABLES) self.devices = {} @@ -91,29 +93,25 @@ class TileDeviceScanner(DeviceScanner): def _update_info(self, now=None) -> None: """Update the device info.""" - device_data = self._client.get_tiles(type_whitelist=self._types) + self.devices = self._client.get_tiles( + type_whitelist=self._types, show_inactive=self._show_inactive) - try: - self.devices = device_data['result'] - except KeyError: + if not self.devices: _LOGGER.warning('No Tiles found') - _LOGGER.debug(device_data) return - for info in self.devices.values(): - dev_id = 'tile_{0}'.format(slugify(info['name'])) - lat = info['tileState']['latitude'] - lon = info['tileState']['longitude'] + for dev in self.devices: + dev_id = 'tile_{0}'.format(slugify(dev['name'])) + lat = dev['tileState']['latitude'] + lon = dev['tileState']['longitude'] attrs = { - ATTR_ALTITUDE: info['tileState']['altitude'], - ATTR_CONNECTION_STATE: info['tileState']['connection_state'], - ATTR_IS_DEAD: info['is_dead'], - ATTR_IS_LOST: info['tileState']['is_lost'], - ATTR_LAST_SEEN: info['tileState']['timestamp'], - ATTR_LAST_UPDATED: device_data['timestamp_ms'], - ATTR_RING_STATE: info['tileState']['ring_state'], - ATTR_VOIP_STATE: info['tileState']['voip_state'], + ATTR_ALTITUDE: dev['tileState']['altitude'], + ATTR_CONNECTION_STATE: dev['tileState']['connection_state'], + ATTR_IS_DEAD: dev['is_dead'], + ATTR_IS_LOST: dev['tileState']['is_lost'], + ATTR_RING_STATE: dev['tileState']['ring_state'], + ATTR_VOIP_STATE: dev['tileState']['voip_state'], } self.see( diff --git a/requirements_all.txt b/requirements_all.txt index 5dc034b9989..e3870c2c934 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -928,7 +928,7 @@ pythonegardia==1.0.22 pythonwhois==2.4.3 # homeassistant.components.device_tracker.tile -pytile==1.0.0 +pytile==1.1.0 # homeassistant.components.device_tracker.trackr pytrackr==0.0.5 From 3fa45375d96577fee91b90a8d39e07aa14383003 Mon Sep 17 00:00:00 2001 From: Ryan McLean Date: Sun, 24 Dec 2017 16:18:31 +0000 Subject: [PATCH 063/238] Plex refactor (#11235) * Cleaned up '_clear_media()' * Moved media Type to new method * renamed "clear_media()' to ' clear_media_details()' reset 'app_name' (Library Name) in clear_media_details moved thumbs to '_set_media_image()' * Moved playback info into setmedia type as it was just used for the next anyway * Moved library name & image download to only happen if session and player active as else no point anyway * Fixed Linting issue * Some tweaks to clean up unintended complexity * Removed redundant declarations * Fixed whitespace * Revert "Fixed whitespace" This reverts commit 0985445c478f42090d0ea9945b8ebc974ab983dc. * Revert "Removed redundant declarations" This reverts commit 6f9d5a85b03efffb4bca44613bcc45c3a71677cc. --- homeassistant/components/media_player/plex.py | 130 ++++++++++-------- 1 file changed, 69 insertions(+), 61 deletions(-) diff --git a/homeassistant/components/media_player/plex.py b/homeassistant/components/media_player/plex.py index 9b984813ff6..c6f3042f2ba 100644 --- a/homeassistant/components/media_player/plex.py +++ b/homeassistant/components/media_player/plex.py @@ -227,7 +227,7 @@ def request_configuration(host, hass, config, add_devices_callback): _CONFIGURING[host] = configurator.request_config( 'Plex Media Server', plex_configuration_callback, - description=('Enter the X-Plex-Token'), + description='Enter the X-Plex-Token', entity_picture='/static/images/logo_plex_mediaserver.png', submit_caption='Confirm', fields=[{ @@ -273,8 +273,23 @@ class PlexClient(MediaPlayerDevice): self.plex_sessions = plex_sessions self.update_devices = update_devices self.update_sessions = update_sessions - - self._clear_media() + # General + self._media_content_id = None + self._media_content_rating = None + self._media_content_type = None + self._media_duration = None + self._media_image_url = None + self._media_title = None + self._media_position = None + # Music + self._media_album_artist = None + self._media_album_name = None + self._media_artist = None + self._media_track = None + # TV Show + self._media_episode = None + self._media_season = None + self._media_series_title = None self.refresh(device, session) @@ -296,7 +311,7 @@ class PlexClient(MediaPlayerDevice): 'media_player', prefix, self.name.lower().replace('-', '_')) - def _clear_media(self): + def _clear_media_details(self): """Set all Media Items to None.""" # General self._media_content_id = None @@ -316,10 +331,13 @@ class PlexClient(MediaPlayerDevice): self._media_season = None self._media_series_title = None + # Clear library Name + self._app_name = '' + def refresh(self, device, session): """Refresh key device data.""" # new data refresh - self._clear_media() + self._clear_media_details() if session: # Not being triggered by Chrome or FireTablet Plex App self._session = session @@ -355,6 +373,36 @@ class PlexClient(MediaPlayerDevice): self._media_content_id = self._session.ratingKey self._media_content_rating = self._session.contentRating + self._set_player_state() + + if self._is_player_active and self._session is not None: + self._session_type = self._session.type + self._media_duration = self._session.duration + # title (movie name, tv episode name, music song name) + self._media_title = self._session.title + # media type + self._set_media_type() + self._app_name = self._session.section().title \ + if self._session.section() is not None else '' + self._set_media_image() + else: + self._session_type = None + + def _set_media_image(self): + thumb_url = self._session.thumbUrl + if (self.media_content_type is MEDIA_TYPE_TVSHOW + and not self.config.get(CONF_USE_EPISODE_ART)): + thumb_url = self._server.url( + self._session.grandparentThumb) + + if thumb_url is None: + _LOGGER.debug("Using media art because media thumb " + "was not found: %s", self.entity_id) + thumb_url = self._server.url(self._session.art) + + self._media_image_url = thumb_url + + def _set_player_state(self): if self._player_state == 'playing': self._is_player_active = True self._state = STATE_PLAYING @@ -368,35 +416,10 @@ class PlexClient(MediaPlayerDevice): self._is_player_active = False self._state = STATE_OFF - if self._is_player_active and self._session is not None: - self._session_type = self._session.type - self._media_duration = self._session.duration - else: - self._session_type = None - - # media type - if self._session_type == 'clip': - _LOGGER.debug("Clip content type detected, compatibility may " - "vary: %s", self.entity_id) + def _set_media_type(self): + if self._session_type in ['clip', 'episode']: self._media_content_type = MEDIA_TYPE_TVSHOW - elif self._session_type == 'episode': - self._media_content_type = MEDIA_TYPE_TVSHOW - elif self._session_type == 'movie': - self._media_content_type = MEDIA_TYPE_VIDEO - elif self._session_type == 'track': - self._media_content_type = MEDIA_TYPE_MUSIC - # title (movie name, tv episode name, music song name) - if self._session and self._is_player_active: - self._media_title = self._session.title - - # Movies - if (self.media_content_type == MEDIA_TYPE_VIDEO and - self._session.year is not None): - self._media_title += ' (' + str(self._session.year) + ')' - - # TV Show - if self._media_content_type is MEDIA_TYPE_TVSHOW: # season number (00) if callable(self._session.seasons): self._media_season = self._session.seasons()[0].index.zfill(2) @@ -410,8 +433,14 @@ class PlexClient(MediaPlayerDevice): if self._session.index is not None: self._media_episode = str(self._session.index).zfill(2) - # Music - if self._media_content_type == MEDIA_TYPE_MUSIC: + elif self._session_type == 'movie': + self._media_content_type = MEDIA_TYPE_VIDEO + if self._session.year is not None and \ + self._media_title is not None: + self._media_title += ' (' + str(self._session.year) + ')' + + elif self._session_type == 'track': + self._media_content_type = MEDIA_TYPE_MUSIC self._media_album_name = self._session.parentTitle self._media_album_artist = self._session.grandparentTitle self._media_track = self._session.index @@ -422,33 +451,11 @@ class PlexClient(MediaPlayerDevice): "was not found: %s", self.entity_id) self._media_artist = self._media_album_artist - # set app name to library name - if (self._session is not None - and self._session.section() is not None): - self._app_name = self._session.section().title - else: - self._app_name = '' - - # media image url - if self._session is not None: - thumb_url = self._session.thumbUrl - if (self.media_content_type is MEDIA_TYPE_TVSHOW - and not self.config.get(CONF_USE_EPISODE_ART)): - thumb_url = self._server.url( - self._session.grandparentThumb) - - if thumb_url is None: - _LOGGER.debug("Using media art because media thumb " - "was not found: %s", self.entity_id) - thumb_url = self._server.url(self._session.art) - - self._media_image_url = thumb_url - def force_idle(self): """Force client to idle.""" self._state = STATE_IDLE self._session = None - self._clear_media() + self._clear_media_details() @property def unique_id(self): @@ -792,9 +799,10 @@ class PlexClient(MediaPlayerDevice): @property def device_state_attributes(self): """Return the scene state attributes.""" - attr = {} - attr['media_content_rating'] = self._media_content_rating - attr['session_username'] = self._session_username - attr['media_library_name'] = self._app_name + attr = { + 'media_content_rating': self._media_content_rating, + 'session_username': self._session_username, + 'media_library_name': self._app_name + } return attr From 419ec7f7a7df5dc7d8fd1bfb40a2ca09cfc66ce4 Mon Sep 17 00:00:00 2001 From: Derek Brooks Date: Sun, 24 Dec 2017 09:43:56 -0700 Subject: [PATCH 064/238] bump to python-nuheat 0.3.0 --- homeassistant/components/nuheat.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nuheat.py b/homeassistant/components/nuheat.py index 08941359dc8..41db3e51842 100644 --- a/homeassistant/components/nuheat.py +++ b/homeassistant/components/nuheat.py @@ -12,7 +12,7 @@ from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, CONF_DEVICES from homeassistant.helpers import config_validation as cv from homeassistant.helpers import discovery -REQUIREMENTS = ["nuheat==0.2.0"] +REQUIREMENTS = ["nuheat==0.3.0"] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 66f5abffd09..e7d0f348e42 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -501,7 +501,7 @@ neurio==0.3.1 nsapi==2.7.4 # homeassistant.components.nuheat -nuheat==0.2.0 +nuheat==0.3.0 # homeassistant.components.binary_sensor.trend # homeassistant.components.image_processing.opencv From fb90dab471d5078936693030888a64ebc53ec2f3 Mon Sep 17 00:00:00 2001 From: Derek Brooks Date: Sun, 24 Dec 2017 10:09:27 -0700 Subject: [PATCH 065/238] add ability to change the Nuheat thermostat hold mode --- homeassistant/components/climate/nuheat.py | 18 ++++++++++++++++-- tests/components/climate/test_nuheat.py | 14 ++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/climate/nuheat.py b/homeassistant/components/climate/nuheat.py index f08c2d7b7d5..c1cb4651c6c 100644 --- a/homeassistant/components/climate/nuheat.py +++ b/homeassistant/components/climate/nuheat.py @@ -12,12 +12,12 @@ from homeassistant.components.climate import ( SUPPORT_HOLD_MODE, SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE, + STATE_AUTO, STATE_HEAT, STATE_IDLE) from homeassistant.components.nuheat import DATA_NUHEAT from homeassistant.const import ( ATTR_TEMPERATURE, - STATE_HOME, TEMP_CELSIUS, TEMP_FAHRENHEIT) from homeassistant.util import Throttle @@ -31,7 +31,7 @@ ICON = "mdi:thermometer" MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) # Hold modes -MODE_AUTO = STATE_HOME # Run device schedule +MODE_AUTO = STATE_AUTO # Run device schedule MODE_HOLD_TEMPERATURE = "temperature" MODE_TEMPORARY_HOLD = "temporary_temperature" @@ -156,6 +156,20 @@ class NuHeatThermostat(ClimateDevice): self._thermostat.resume_schedule() self._force_update = True + def set_hold_mode(self, hold_mode, **kwargs): + """Update the hold mode of the thermostat.""" + if hold_mode == MODE_AUTO: + schedule_mode = SCHEDULE_RUN + + if hold_mode == MODE_HOLD_TEMPERATURE: + schedule_mode = SCHEDULE_HOLD + + if hold_mode == MODE_TEMPORARY_HOLD: + schedule_mode = SCHEDULE_TEMPORARY_HOLD + + self._thermostat.schedule_mode = schedule_mode + self._force_update = True + def set_temperature(self, **kwargs): """Set a new target temperature.""" temperature = kwargs.get(ATTR_TEMPERATURE) diff --git a/tests/components/climate/test_nuheat.py b/tests/components/climate/test_nuheat.py index b2b3e6cddff..3e30e7caaf7 100644 --- a/tests/components/climate/test_nuheat.py +++ b/tests/components/climate/test_nuheat.py @@ -138,6 +138,20 @@ class TestNuHeat(unittest.TestCase): self.thermostat._thermostat.resume_schedule.assert_called_once_with() self.assertTrue(self.thermostat._force_update) + def test_set_hold_mode(self): + """Test set hold mode.""" + self.thermostat.set_hold_mode("temperature") + self.assertEqual(self.thermostat._thermostat.schedule_mode, SCHEDULE_HOLD) + self.assertTrue(self.thermostat._force_update) + + self.thermostat.set_hold_mode("temporary_temperature") + self.assertEqual(self.thermostat._thermostat.schedule_mode, SCHEDULE_TEMPORARY_HOLD) + self.assertTrue(self.thermostat._force_update) + + self.thermostat.set_hold_mode("auto") + self.assertEqual(self.thermostat._thermostat.schedule_mode, SCHEDULE_RUN) + self.assertTrue(self.thermostat._force_update) + def test_set_temperature(self): """Test set temperature.""" self.thermostat.set_temperature(temperature=85) From 7de3c62846e8367e64d9c43647c380b8ac2145bd Mon Sep 17 00:00:00 2001 From: Derek Brooks Date: Sun, 24 Dec 2017 11:10:22 -0700 Subject: [PATCH 066/238] register nuheat_resume_program service --- homeassistant/components/climate/nuheat.py | 37 +++++++++++++++++++ .../components/climate/services.yaml | 7 ++++ 2 files changed, 44 insertions(+) diff --git a/homeassistant/components/climate/nuheat.py b/homeassistant/components/climate/nuheat.py index c1cb4651c6c..226c92a6e65 100644 --- a/homeassistant/components/climate/nuheat.py +++ b/homeassistant/components/climate/nuheat.py @@ -6,9 +6,13 @@ https://home-assistant.io/components/climate.nuheat/ """ import logging from datetime import timedelta +from os import path + +import voluptuous as vol from homeassistant.components.climate import ( ClimateDevice, + DOMAIN, SUPPORT_HOLD_MODE, SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE, @@ -16,10 +20,13 @@ from homeassistant.components.climate import ( STATE_HEAT, STATE_IDLE) from homeassistant.components.nuheat import DATA_NUHEAT +from homeassistant.config import load_yaml_config_file from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT) +import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle DEPENDENCIES = ["nuheat"] @@ -41,6 +48,12 @@ SCHEDULE_HOLD = 3 SCHEDULE_RUN = 1 SCHEDULE_TEMPORARY_HOLD = 2 +SERVICE_RESUME_PROGRAM = "nuheat_resume_program" + +RESUME_PROGRAM_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids +}) + SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_HOLD_MODE | SUPPORT_OPERATION_MODE) @@ -58,6 +71,30 @@ def setup_platform(hass, config, add_devices, discovery_info=None): ] add_devices(thermostats, True) + def resume_program_set_service(service): + """Resume the program on the target thermostats.""" + entity_id = service.data.get(ATTR_ENTITY_ID) + + if entity_id: + target_thermostats = [device for device in thermostats + if device.entity_id in entity_id] + else: + target_thermostats = thermostats + + for thermostat in target_thermostats: + thermostat.resume_program() + + thermostat.schedule_update_ha_state(True) + + descriptions = load_yaml_config_file( + path.join(path.dirname(__file__), "services.yaml")) + + hass.services.register( + DOMAIN, SERVICE_RESUME_PROGRAM, resume_program_set_service, + descriptions.get(SERVICE_RESUME_PROGRAM), + schema=RESUME_PROGRAM_SCHEMA) + + class NuHeatThermostat(ClimateDevice): """Representation of a NuHeat Thermostat.""" diff --git a/homeassistant/components/climate/services.yaml b/homeassistant/components/climate/services.yaml index 193c5107575..5d7f30d252d 100644 --- a/homeassistant/components/climate/services.yaml +++ b/homeassistant/components/climate/services.yaml @@ -100,3 +100,10 @@ ecobee_resume_program: resume_all: description: Resume all events and return to the scheduled program. This default to false which removes only the top event. example: true + +nuheat_resume_program: + description: Resume the programmed schedule. + fields: + entity_id: + description: Name(s) of entities to change. + example: 'climate.kitchen' From 8ef8dbc8688495d74b30dad990c9f84e8e2a24f6 Mon Sep 17 00:00:00 2001 From: Derek Brooks Date: Sun, 24 Dec 2017 11:15:50 -0700 Subject: [PATCH 067/238] pleasin the hound --- homeassistant/components/climate/nuheat.py | 1 - tests/components/climate/test_nuheat.py | 9 ++++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/climate/nuheat.py b/homeassistant/components/climate/nuheat.py index 226c92a6e65..a4d1cad68a4 100644 --- a/homeassistant/components/climate/nuheat.py +++ b/homeassistant/components/climate/nuheat.py @@ -95,7 +95,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): schema=RESUME_PROGRAM_SCHEMA) - class NuHeatThermostat(ClimateDevice): """Representation of a NuHeat Thermostat.""" diff --git a/tests/components/climate/test_nuheat.py b/tests/components/climate/test_nuheat.py index 3e30e7caaf7..aedb925277e 100644 --- a/tests/components/climate/test_nuheat.py +++ b/tests/components/climate/test_nuheat.py @@ -141,15 +141,18 @@ class TestNuHeat(unittest.TestCase): def test_set_hold_mode(self): """Test set hold mode.""" self.thermostat.set_hold_mode("temperature") - self.assertEqual(self.thermostat._thermostat.schedule_mode, SCHEDULE_HOLD) + self.assertEqual( + self.thermostat._thermostat.schedule_mode, SCHEDULE_HOLD) self.assertTrue(self.thermostat._force_update) self.thermostat.set_hold_mode("temporary_temperature") - self.assertEqual(self.thermostat._thermostat.schedule_mode, SCHEDULE_TEMPORARY_HOLD) + self.assertEqual( + self.thermostat._thermostat.schedule_mode, SCHEDULE_TEMPORARY_HOLD) self.assertTrue(self.thermostat._force_update) self.thermostat.set_hold_mode("auto") - self.assertEqual(self.thermostat._thermostat.schedule_mode, SCHEDULE_RUN) + self.assertEqual( + self.thermostat._thermostat.schedule_mode, SCHEDULE_RUN) self.assertTrue(self.thermostat._force_update) def test_set_temperature(self): From 94ac0b5ed8449b9471fe39909aeb8328ec6eeaaf Mon Sep 17 00:00:00 2001 From: Phil Kates Date: Sun, 24 Dec 2017 15:05:56 -0800 Subject: [PATCH 068/238] alexa: Add handling for covers (#11242) * alexa: Add handling for covers Covers don't support either cover.turn_on or homeassistant.turn_on so use cover.[open|close]_cover. * alexa: Add tests for covers --- homeassistant/components/alexa/smart_home.py | 12 ++++++++++-- tests/components/alexa/test_smart_home.py | 14 ++++++++++---- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index 3c8e9f5d21c..2443e52b766 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -243,7 +243,11 @@ def async_api_turn_on(hass, config, request, entity): if entity.domain == group.DOMAIN: domain = ha.DOMAIN - yield from hass.services.async_call(domain, SERVICE_TURN_ON, { + service = SERVICE_TURN_ON + if entity.domain == cover.DOMAIN: + service = cover.SERVICE_OPEN_COVER + + yield from hass.services.async_call(domain, service, { ATTR_ENTITY_ID: entity.entity_id }, blocking=True) @@ -259,7 +263,11 @@ def async_api_turn_off(hass, config, request, entity): if entity.domain == group.DOMAIN: domain = ha.DOMAIN - yield from hass.services.async_call(domain, SERVICE_TURN_OFF, { + service = SERVICE_TURN_OFF + if entity.domain == cover.DOMAIN: + service = cover.SERVICE_CLOSE_COVER + + yield from hass.services.async_call(domain, service, { ATTR_ENTITY_ID: entity.entity_id }, blocking=True) diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 55a412af1fd..924931ec21c 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -419,7 +419,7 @@ def test_api_function_not_implemented(hass): @asyncio.coroutine -@pytest.mark.parametrize("domain", ['alert', 'automation', 'group', +@pytest.mark.parametrize("domain", ['alert', 'automation', 'cover', 'group', 'input_boolean', 'light', 'script', 'switch']) def test_api_turn_on(hass, domain): @@ -438,7 +438,10 @@ def test_api_turn_on(hass, domain): if domain == 'group': call_domain = 'homeassistant' - call = async_mock_service(hass, call_domain, 'turn_on') + if domain == 'cover': + call = async_mock_service(hass, call_domain, 'open_cover') + else: + call = async_mock_service(hass, call_domain, 'turn_on') msg = yield from smart_home.async_handle_message( hass, DEFAULT_CONFIG, request) @@ -452,7 +455,7 @@ def test_api_turn_on(hass, domain): @asyncio.coroutine -@pytest.mark.parametrize("domain", ['alert', 'automation', 'group', +@pytest.mark.parametrize("domain", ['alert', 'automation', 'cover', 'group', 'input_boolean', 'light', 'script', 'switch']) def test_api_turn_off(hass, domain): @@ -471,7 +474,10 @@ def test_api_turn_off(hass, domain): if domain == 'group': call_domain = 'homeassistant' - call = async_mock_service(hass, call_domain, 'turn_off') + if domain == 'cover': + call = async_mock_service(hass, call_domain, 'close_cover') + else: + call = async_mock_service(hass, call_domain, 'turn_off') msg = yield from smart_home.async_handle_message( hass, DEFAULT_CONFIG, request) From 7269070d97a9710a985c6719facb924a4ea6139e Mon Sep 17 00:00:00 2001 From: Jordy <31309880+jbarrancos@users.noreply.github.com> Date: Mon, 25 Dec 2017 10:07:18 +0100 Subject: [PATCH 069/238] Added rainsensor (#11023) * Added rainsensor Added rainsensor * Added to coverage ignore * Fixed issues * script\gen_requirements_all.py script\gen_requirements_all.py * Gen requirements * requirements * requirements * Fix docstring * Fix log message * Revert change --- .coveragerc | 2 + homeassistant/components/rainbird.py | 47 ++++++++++++ homeassistant/components/sensor/rainbird.py | 80 +++++++++++++++++++++ homeassistant/components/switch/rainbird.py | 28 ++------ requirements_all.txt | 4 +- 5 files changed, 137 insertions(+), 24 deletions(-) create mode 100644 homeassistant/components/rainbird.py create mode 100644 homeassistant/components/sensor/rainbird.py diff --git a/.coveragerc b/.coveragerc index 4751ddce219..83f11983806 100644 --- a/.coveragerc +++ b/.coveragerc @@ -477,6 +477,7 @@ omit = homeassistant/components/notify/yessssms.py homeassistant/components/nuimo_controller.py homeassistant/components/prometheus.py + homeassistant/components/rainbird.py homeassistant/components/remember_the_milk/__init__.py homeassistant/components/remote/harmony.py homeassistant/components/remote/itach.py @@ -571,6 +572,7 @@ omit = homeassistant/components/sensor/pyload.py homeassistant/components/sensor/qnap.py homeassistant/components/sensor/radarr.py + homeassistant/components/sensor/rainbird.py homeassistant/components/sensor/ripple.py homeassistant/components/sensor/sabnzbd.py homeassistant/components/sensor/scrape.py diff --git a/homeassistant/components/rainbird.py b/homeassistant/components/rainbird.py new file mode 100644 index 00000000000..882731d4f2c --- /dev/null +++ b/homeassistant/components/rainbird.py @@ -0,0 +1,47 @@ +""" +Support for Rain Bird Irrigation system LNK WiFi Module. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/rainbird/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import (CONF_HOST, CONF_PASSWORD) + +REQUIREMENTS = ['pyrainbird==0.1.3'] + +_LOGGER = logging.getLogger(__name__) + +DATA_RAINBIRD = 'rainbird' +DOMAIN = 'rainbird' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the Rain Bird componenent.""" + conf = config[DOMAIN] + server = conf.get(CONF_HOST) + password = conf.get(CONF_PASSWORD) + + from pyrainbird import RainbirdController + controller = RainbirdController() + controller.setConfig(server, password) + + _LOGGER.debug("Rain Bird Controller set to: %s", server) + + initialstatus = controller.currentIrrigation() + if initialstatus == -1: + _LOGGER.error("Error getting state. Possible configuration issues") + return False + + hass.data[DATA_RAINBIRD] = controller + return True diff --git a/homeassistant/components/sensor/rainbird.py b/homeassistant/components/sensor/rainbird.py new file mode 100644 index 00000000000..875e9c37bd3 --- /dev/null +++ b/homeassistant/components/sensor/rainbird.py @@ -0,0 +1,80 @@ +""" +Support for Rain Bird Irrigation system LNK WiFi Module. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/sensor.rainbird/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.rainbird import DATA_RAINBIRD +import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_MONITORED_CONDITIONS +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.helpers.entity import Entity + +DEPENDENCIES = ['rainbird'] + +_LOGGER = logging.getLogger(__name__) + +# sensor_type [ description, unit, icon ] +SENSOR_TYPES = { + 'rainsensor': ['Rainsensor', None, 'mdi:water'] +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up a Rain Bird sensor.""" + controller = hass.data[DATA_RAINBIRD] + + sensors = [] + for sensor_type in config.get(CONF_MONITORED_CONDITIONS): + sensors.append( + RainBirdSensor(controller, sensor_type)) + + add_devices(sensors, True) + + +class RainBirdSensor(Entity): + """A sensor implementation for Rain Bird device.""" + + def __init__(self, controller, sensor_type): + """Initialize the Rain Bird sensor.""" + self._sensor_type = sensor_type + self._controller = controller + self._name = SENSOR_TYPES[self._sensor_type][0] + self._icon = SENSOR_TYPES[self._sensor_type][2] + self._unit_of_measurement = SENSOR_TYPES[self._sensor_type][1] + self._state = None + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + def update(self): + """Get the latest data and updates the states.""" + _LOGGER.debug("Updating sensor: %s", self._name) + if self._sensor_type == 'rainsensor': + self._state = self._controller.currentRainSensorState() + + @property + def name(self): + """Return the name of this camera.""" + return self._name + + @property + def unit_of_measurement(self): + """Return the units of measurement.""" + return self._unit_of_measurement + + @property + def icon(self): + """Return icon.""" + return self._icon diff --git a/homeassistant/components/switch/rainbird.py b/homeassistant/components/switch/rainbird.py index c1dbfbc4e72..ee283b3c269 100644 --- a/homeassistant/components/switch/rainbird.py +++ b/homeassistant/components/switch/rainbird.py @@ -2,29 +2,26 @@ Support for Rain Bird Irrigation system LNK WiFi Module. For more details about this component, please refer to the documentation at -https://home-assistant.io/components/rainbird/ +https://home-assistant.io/components/switch.rainbird/ """ import logging import voluptuous as vol +from homeassistant.components.rainbird import DATA_RAINBIRD from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) -from homeassistant.const import (CONF_PLATFORM, CONF_SWITCHES, CONF_ZONE, +from homeassistant.const import (CONF_SWITCHES, CONF_ZONE, CONF_FRIENDLY_NAME, CONF_TRIGGER_TIME, - CONF_SCAN_INTERVAL, CONF_HOST, CONF_PASSWORD) + CONF_SCAN_INTERVAL) from homeassistant.helpers import config_validation as cv -from homeassistant.exceptions import PlatformNotReady -REQUIREMENTS = ['pyrainbird==0.1.0'] +DEPENDENCIES = ['rainbird'] DOMAIN = 'rainbird' _LOGGER = logging.getLogger(__name__) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_PLATFORM): DOMAIN, - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_SWITCHES, default={}): vol.Schema({ cv.string: { vol.Optional(CONF_FRIENDLY_NAME): cv.string, @@ -38,20 +35,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up Rain Bird switches over a Rain Bird controller.""" - server = config.get(CONF_HOST) - password = config.get(CONF_PASSWORD) - - from pyrainbird import RainbirdController - controller = RainbirdController(_LOGGER) - controller.setConfig(server, password) - - _LOGGER.debug("Rain Bird Controller set to " + str(server)) - - if controller.currentIrrigation() == -1: - _LOGGER.error("Error getting state. Possible configuration issues") - raise PlatformNotReady - else: - _LOGGER.debug("Initialized Rain Bird Controller") + controller = hass.data[DATA_RAINBIRD] devices = [] for dev_id, switch in config.get(CONF_SWITCHES).items(): diff --git a/requirements_all.txt b/requirements_all.txt index e3870c2c934..5eb4361a436 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -799,8 +799,8 @@ pyowm==2.7.1 # homeassistant.components.qwikswitch pyqwikswitch==0.4 -# homeassistant.components.switch.rainbird -pyrainbird==0.1.0 +# homeassistant.components.rainbird +pyrainbird==0.1.3 # homeassistant.components.climate.sensibo pysensibo==1.0.1 From b280a791a6ef5ac3732a839f7dc652fdb9cb5044 Mon Sep 17 00:00:00 2001 From: PhracturedBlue Date: Mon, 25 Dec 2017 01:52:33 -0800 Subject: [PATCH 070/238] Store raw state of RF sensors from alarmdecoder (#10841) * Store raw state of RF sensors from alarmdecoder * Fix resync. Fix issue with RFID not being truly optional * Breakdown RF attributes per bit * Preserve import style --- homeassistant/components/alarmdecoder.py | 11 ++++- .../components/binary_sensor/alarmdecoder.py | 46 +++++++++++++++++-- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/alarmdecoder.py b/homeassistant/components/alarmdecoder.py index 6e30a83d96a..c5321b918b9 100644 --- a/homeassistant/components/alarmdecoder.py +++ b/homeassistant/components/alarmdecoder.py @@ -29,6 +29,7 @@ CONF_DEVICE_TYPE = 'type' CONF_PANEL_DISPLAY = 'panel_display' CONF_ZONE_NAME = 'name' CONF_ZONE_TYPE = 'type' +CONF_ZONE_RFID = 'rfid' CONF_ZONES = 'zones' DEFAULT_DEVICE_TYPE = 'socket' @@ -48,6 +49,7 @@ SIGNAL_PANEL_DISARM = 'alarmdecoder.panel_disarm' SIGNAL_ZONE_FAULT = 'alarmdecoder.zone_fault' SIGNAL_ZONE_RESTORE = 'alarmdecoder.zone_restore' +SIGNAL_RFX_MESSAGE = 'alarmdecoder.rfx_message' DEVICE_SOCKET_SCHEMA = vol.Schema({ vol.Required(CONF_DEVICE_TYPE): 'socket', @@ -64,7 +66,8 @@ DEVICE_USB_SCHEMA = vol.Schema({ ZONE_SCHEMA = vol.Schema({ vol.Required(CONF_ZONE_NAME): cv.string, - vol.Optional(CONF_ZONE_TYPE, default=DEFAULT_ZONE_TYPE): cv.string}) + vol.Optional(CONF_ZONE_TYPE, default=DEFAULT_ZONE_TYPE): cv.string, + vol.Optional(CONF_ZONE_RFID): cv.string}) CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ @@ -105,6 +108,11 @@ def setup(hass, config): hass.helpers.dispatcher.dispatcher_send( SIGNAL_PANEL_MESSAGE, message) + def handle_rfx_message(sender, message): + """Handle RFX message from AlarmDecoder.""" + hass.helpers.dispatcher.dispatcher_send( + SIGNAL_RFX_MESSAGE, message) + def zone_fault_callback(sender, zone): """Handle zone fault from AlarmDecoder.""" hass.helpers.dispatcher.dispatcher_send( @@ -129,6 +137,7 @@ def setup(hass, config): return False controller.on_message += handle_message + controller.on_rfx_message += handle_rfx_message controller.on_zone_fault += zone_fault_callback controller.on_zone_restore += zone_restore_callback diff --git a/homeassistant/components/binary_sensor/alarmdecoder.py b/homeassistant/components/binary_sensor/alarmdecoder.py index f42d0de4bb0..1b8c8070d10 100644 --- a/homeassistant/components/binary_sensor/alarmdecoder.py +++ b/homeassistant/components/binary_sensor/alarmdecoder.py @@ -10,12 +10,22 @@ import logging from homeassistant.components.binary_sensor import BinarySensorDevice from homeassistant.components.alarmdecoder import ( ZONE_SCHEMA, CONF_ZONES, CONF_ZONE_NAME, CONF_ZONE_TYPE, - SIGNAL_ZONE_FAULT, SIGNAL_ZONE_RESTORE) + CONF_ZONE_RFID, SIGNAL_ZONE_FAULT, SIGNAL_ZONE_RESTORE, + SIGNAL_RFX_MESSAGE) DEPENDENCIES = ['alarmdecoder'] _LOGGER = logging.getLogger(__name__) +ATTR_RF_BIT0 = 'rf_bit0' +ATTR_RF_LOW_BAT = 'rf_low_battery' +ATTR_RF_SUPERVISED = 'rf_supervised' +ATTR_RF_BIT3 = 'rf_bit3' +ATTR_RF_LOOP3 = 'rf_loop3' +ATTR_RF_LOOP2 = 'rf_loop2' +ATTR_RF_LOOP4 = 'rf_loop4' +ATTR_RF_LOOP1 = 'rf_loop1' + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the AlarmDecoder binary sensor devices.""" @@ -26,7 +36,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): device_config_data = ZONE_SCHEMA(configured_zones[zone_num]) zone_type = device_config_data[CONF_ZONE_TYPE] zone_name = device_config_data[CONF_ZONE_NAME] - device = AlarmDecoderBinarySensor(zone_num, zone_name, zone_type) + zone_rfid = device_config_data.get(CONF_ZONE_RFID) + device = AlarmDecoderBinarySensor( + zone_num, zone_name, zone_type, zone_rfid) devices.append(device) add_devices(devices) @@ -37,13 +49,15 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class AlarmDecoderBinarySensor(BinarySensorDevice): """Representation of an AlarmDecoder binary sensor.""" - def __init__(self, zone_number, zone_name, zone_type): + def __init__(self, zone_number, zone_name, zone_type, zone_rfid): """Initialize the binary_sensor.""" self._zone_number = zone_number self._zone_type = zone_type - self._state = 0 + self._state = None self._name = zone_name self._type = zone_type + self._rfid = zone_rfid + self._rfstate = None @asyncio.coroutine def async_added_to_hass(self): @@ -54,6 +68,9 @@ class AlarmDecoderBinarySensor(BinarySensorDevice): self.hass.helpers.dispatcher.async_dispatcher_connect( SIGNAL_ZONE_RESTORE, self._restore_callback) + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_RFX_MESSAGE, self._rfx_message_callback) + @property def name(self): """Return the name of the entity.""" @@ -75,6 +92,21 @@ class AlarmDecoderBinarySensor(BinarySensorDevice): """No polling needed.""" return False + @property + def device_state_attributes(self): + """Return the state attributes.""" + attr = {} + if self._rfid and self._rfstate is not None: + attr[ATTR_RF_BIT0] = True if self._rfstate & 0x01 else False + attr[ATTR_RF_LOW_BAT] = True if self._rfstate & 0x02 else False + attr[ATTR_RF_SUPERVISED] = True if self._rfstate & 0x04 else False + attr[ATTR_RF_BIT3] = True if self._rfstate & 0x08 else False + attr[ATTR_RF_LOOP3] = True if self._rfstate & 0x10 else False + attr[ATTR_RF_LOOP2] = True if self._rfstate & 0x20 else False + attr[ATTR_RF_LOOP4] = True if self._rfstate & 0x40 else False + attr[ATTR_RF_LOOP1] = True if self._rfstate & 0x80 else False + return attr + @property def is_on(self): """Return true if sensor is on.""" @@ -96,3 +128,9 @@ class AlarmDecoderBinarySensor(BinarySensorDevice): if zone is None or int(zone) == self._zone_number: self._state = 0 self.schedule_update_ha_state() + + def _rfx_message_callback(self, message): + """Update RF state.""" + if self._rfid and message and message.serial_number == self._rfid: + self._rfstate = message.value + self.schedule_update_ha_state() From a44181fd35d0c8107c1135fced4ca571df50ac8c Mon Sep 17 00:00:00 2001 From: Michael Irigoyen Date: Mon, 25 Dec 2017 05:34:07 -0500 Subject: [PATCH 071/238] Add Chime status and control to Alarm Decoder component (#11271) * Enable more alarm decoder attributes, including chime status and ready status * Expose chime service in the alarm decoder component * Fix line length linting issue * Fix spacing lint issue * Update PR based on reviewer requests * Update based on linting catches * Fix descriptions include from async to sync --- .../alarm_control_panel/alarmdecoder.py | 90 +++++++++++++++---- .../alarm_control_panel/services.yaml | 10 +++ 2 files changed, 81 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/alarmdecoder.py b/homeassistant/components/alarm_control_panel/alarmdecoder.py index d5fbbec5998..2e4255493d4 100644 --- a/homeassistant/components/alarm_control_panel/alarmdecoder.py +++ b/homeassistant/components/alarm_control_panel/alarmdecoder.py @@ -6,24 +6,46 @@ https://home-assistant.io/components/alarm_control_panel.alarmdecoder/ """ import asyncio import logging +from os import path + +import voluptuous as vol import homeassistant.components.alarm_control_panel as alarm +import homeassistant.helpers.config_validation as cv +from homeassistant.config import load_yaml_config_file from homeassistant.components.alarmdecoder import ( DATA_AD, SIGNAL_PANEL_MESSAGE) from homeassistant.const import ( - STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, - STATE_ALARM_TRIGGERED) + ATTR_CODE, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, + STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED) _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['alarmdecoder'] +SERVICE_ALARM_TOGGLE_CHIME = 'alarmdecoder_alarm_toggle_chime' +ALARM_TOGGLE_CHIME_SCHEMA = vol.Schema({ + vol.Required(ATTR_CODE): cv.string, +}) + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up for AlarmDecoder alarm panels.""" - add_devices([AlarmDecoderAlarmPanel()]) + device = AlarmDecoderAlarmPanel() + add_devices([device]) - return True + def alarm_toggle_chime_handler(service): + """Register toggle chime handler.""" + code = service.data.get(ATTR_CODE) + device.alarm_toggle_chime(code) + + descriptions = load_yaml_config_file( + path.join(path.dirname(__file__), 'services.yaml')) + + hass.services.register( + alarm.DOMAIN, SERVICE_ALARM_TOGGLE_CHIME, alarm_toggle_chime_handler, + descriptions.get(SERVICE_ALARM_TOGGLE_CHIME), + schema=ALARM_TOGGLE_CHIME_SCHEMA) class AlarmDecoderAlarmPanel(alarm.AlarmControlPanel): @@ -34,6 +56,15 @@ class AlarmDecoderAlarmPanel(alarm.AlarmControlPanel): self._display = "" self._name = "Alarm Panel" self._state = None + self._ac_power = None + self._backlight_on = None + self._battery_low = None + self._check_zone = None + self._chime = None + self._entry_delay_off = None + self._programming_mode = None + self._ready = None + self._zone_bypassed = None @asyncio.coroutine def async_added_to_hass(self): @@ -43,21 +74,25 @@ class AlarmDecoderAlarmPanel(alarm.AlarmControlPanel): def _message_callback(self, message): if message.alarm_sounding or message.fire_alarm: - if self._state != STATE_ALARM_TRIGGERED: - self._state = STATE_ALARM_TRIGGERED - self.schedule_update_ha_state() + self._state = STATE_ALARM_TRIGGERED elif message.armed_away: - if self._state != STATE_ALARM_ARMED_AWAY: - self._state = STATE_ALARM_ARMED_AWAY - self.schedule_update_ha_state() + self._state = STATE_ALARM_ARMED_AWAY elif message.armed_home: - if self._state != STATE_ALARM_ARMED_HOME: - self._state = STATE_ALARM_ARMED_HOME - self.schedule_update_ha_state() + self._state = STATE_ALARM_ARMED_HOME else: - if self._state != STATE_ALARM_DISARMED: - self._state = STATE_ALARM_DISARMED - self.schedule_update_ha_state() + self._state = STATE_ALARM_DISARMED + + self._ac_power = message.ac_power + self._backlight_on = message.backlight_on + self._battery_low = message.battery_low + self._check_zone = message.check_zone + self._chime = message.chime_on + self._entry_delay_off = message.entry_delay_off + self._programming_mode = message.programming_mode + self._ready = message.ready + self._zone_bypassed = message.zone_bypassed + + self.schedule_update_ha_state() @property def name(self): @@ -79,20 +114,37 @@ class AlarmDecoderAlarmPanel(alarm.AlarmControlPanel): """Return the state of the device.""" return self._state + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + 'ac_power': self._ac_power, + 'backlight_on': self._backlight_on, + 'battery_low': self._battery_low, + 'check_zone': self._check_zone, + 'chime': self._chime, + 'entry_delay_off': self._entry_delay_off, + 'programming_mode': self._programming_mode, + 'ready': self._ready, + 'zone_bypassed': self._zone_bypassed + } + def alarm_disarm(self, code=None): """Send disarm command.""" if code: - _LOGGER.debug("alarm_disarm: sending %s1", str(code)) self.hass.data[DATA_AD].send("{!s}1".format(code)) def alarm_arm_away(self, code=None): """Send arm away command.""" if code: - _LOGGER.debug("alarm_arm_away: sending %s2", str(code)) self.hass.data[DATA_AD].send("{!s}2".format(code)) def alarm_arm_home(self, code=None): """Send arm home command.""" if code: - _LOGGER.debug("alarm_arm_home: sending %s3", str(code)) self.hass.data[DATA_AD].send("{!s}3".format(code)) + + def alarm_toggle_chime(self, code=None): + """Send toggle chime command.""" + if code: + self.hass.data[DATA_AD].send("{!s}9".format(code)) diff --git a/homeassistant/components/alarm_control_panel/services.yaml b/homeassistant/components/alarm_control_panel/services.yaml index 21378876d9b..bfd38c902d0 100644 --- a/homeassistant/components/alarm_control_panel/services.yaml +++ b/homeassistant/components/alarm_control_panel/services.yaml @@ -59,3 +59,13 @@ envisalink_alarm_keypress: keypress: description: 'String to send to the alarm panel (1-6 characters).' example: '*71' + +alarmdecoder_alarm_toggle_chime: + description: Send the alarm the toggle chime command. + fields: + entity_id: + description: Name of the alarm control panel to trigger. + example: 'alarm_control_panel.downstairs' + code: + description: A required code to toggle the alarm control panel chime with. + example: 1234 From 802a95eac5c25c879e9dc780eee719ff8e83f37a Mon Sep 17 00:00:00 2001 From: Bob Anderson Date: Mon, 25 Dec 2017 04:26:22 -0800 Subject: [PATCH 072/238] Fix unpredictable entity names in concord232 binary_sensor (#11292) --- homeassistant/components/binary_sensor/concord232.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/homeassistant/components/binary_sensor/concord232.py b/homeassistant/components/binary_sensor/concord232.py index d689f030d8a..73cf77f2b93 100755 --- a/homeassistant/components/binary_sensor/concord232.py +++ b/homeassistant/components/binary_sensor/concord232.py @@ -62,6 +62,13 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _LOGGER.error("Unable to connect to Concord232: %s", str(ex)) return False + # The order of zones returned by client.list_zones() can vary. + # When the zones are not named, this can result in the same entity + # name mapping to different sensors in an unpredictable way. Sort + # the zones by zone number to prevent this. + + client.zones.sort(key=lambda zone: zone['number']) + for zone in client.zones: _LOGGER.info("Loading Zone found: %s", zone['name']) if zone['number'] not in exclude: From 14919082a3724557057c6060c1d96681e002bac0 Mon Sep 17 00:00:00 2001 From: Frantz Date: Mon, 25 Dec 2017 18:46:42 +0200 Subject: [PATCH 073/238] Better error handling (#11297) * Better error handling * Fixed hound --- homeassistant/components/sensor/transmission.py | 11 +++++++---- homeassistant/components/switch/transmission.py | 11 +++++++---- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/sensor/transmission.py b/homeassistant/components/sensor/transmission.py index 1eda9cb58fd..678d9afb81d 100644 --- a/homeassistant/components/sensor/transmission.py +++ b/homeassistant/components/sensor/transmission.py @@ -55,12 +55,15 @@ def setup_platform(hass, config, add_devices, discovery_info=None): password = config.get(CONF_PASSWORD) port = config.get(CONF_PORT) - transmission_api = transmissionrpc.Client( - host, port=port, user=username, password=password) try: + transmission_api = transmissionrpc.Client( + host, port=port, user=username, password=password) transmission_api.session_stats() - except TransmissionError: - _LOGGER.exception("Connection to Transmission API failed") + except TransmissionError as error: + _LOGGER.error( + "Connection to Transmission API failed on %s:%s with message %s", + host, port, error.original + ) return False # pylint: disable=global-statement diff --git a/homeassistant/components/switch/transmission.py b/homeassistant/components/switch/transmission.py index 656a6227358..840fdae44d9 100644 --- a/homeassistant/components/switch/transmission.py +++ b/homeassistant/components/switch/transmission.py @@ -43,12 +43,15 @@ def setup_platform(hass, config, add_devices, discovery_info=None): password = config.get(CONF_PASSWORD) port = config.get(CONF_PORT) - transmission_api = transmissionrpc.Client( - host, port=port, user=username, password=password) try: + transmission_api = transmissionrpc.Client( + host, port=port, user=username, password=password) transmission_api.session_stats() - except TransmissionError: - _LOGGING.error("Connection to Transmission API failed") + except TransmissionError as error: + _LOGGING.error( + "Connection to Transmission API failed on %s:%s with message %s", + host, port, error.original + ) return False add_devices([TransmissionSwitch(transmission_api, name)]) From a59b02b6b4491c49b7c1c61dfe7b0decffacbba2 Mon Sep 17 00:00:00 2001 From: Mike Megally Date: Mon, 25 Dec 2017 11:49:02 -0800 Subject: [PATCH 074/238] Removed error log used as debug (#11301) An error was being logged to display debug info. Removed it --- homeassistant/components/octoprint.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/octoprint.py b/homeassistant/components/octoprint.py index 086242ab070..f3e3ecc29b2 100644 --- a/homeassistant/components/octoprint.py +++ b/homeassistant/components/octoprint.py @@ -69,7 +69,6 @@ class OctoPrintAPI(object): self.job_error_logged = False self.bed = bed self.number_of_tools = number_of_tools - _LOGGER.error(str(bed) + " " + str(number_of_tools)) def get_tools(self): """Get the list of tools that temperature is monitored on.""" From d687bc073e7e3b5b3f7f2460a2625f2b2c651d7b Mon Sep 17 00:00:00 2001 From: Greg Laabs Date: Tue, 26 Dec 2017 00:26:37 -0800 Subject: [PATCH 075/238] Huge ISY994 platform cleanup, fixes support for 5.0.10 firmware (#11243) * Huge ISY994 platform cleanup, fixes support for 5.0.10 firmware # * No more globals - store on hass.data # * Parent ISY994 component handles categorizing nodes in to Hass components, rather than each individual domain filtering all nodes themselves # * Remove hidden string, replace with ignore string. Hidden should be done via the customize block; ignore fully prevents the node from getting a Hass entity # * Removed a few unused methods in the ISYDevice class # * Cleaned up the hostname parsing # * Removed broken logic in the fan Program component. It was setting properties that have no setters # * Added the missing SUPPORTED_FEATURES to the fan component to indicate that it can set speed # * Added better error handling and a log warning when an ISY994 program entity fails to initialize # * Cleaned up a few instances of unecessarily complicated logic paths, and other cases of unnecessary logic that is already handled by base classes * Use `super()` instead of explicit base class calls * Move `hass` argument to first position * Use str.format instead of string addition * Move program structure building and validation to component Removes the need for a bunch of duplicate exception handling in each individual platform * Fix climate nodes, fix climate names, add config to disable climate Sensor platform was crashing when the ISY reported climate nodes. Logic has been fixed. Also added a config option to prevent climate sensors from getting imported from the ISY. Also replace the underscore from climate node names with spaces so they default to friendly names. * Space missing in error message * Fix string comparison to use `==` * Explicitly check for attributes rather than catch AttributeError Also removes two stray debug lines * Remove null checks on hass.data, as they are always null at this point --- .../components/binary_sensor/isy994.py | 28 +- homeassistant/components/cover/isy994.py | 36 +- homeassistant/components/fan/isy994.py | 58 +-- homeassistant/components/isy994.py | 449 +++++++++++------- homeassistant/components/light/isy994.py | 22 +- homeassistant/components/lock/isy994.py | 31 +- homeassistant/components/sensor/isy994.py | 32 +- homeassistant/components/switch/isy994.py | 50 +- 8 files changed, 358 insertions(+), 348 deletions(-) diff --git a/homeassistant/components/binary_sensor/isy994.py b/homeassistant/components/binary_sensor/isy994.py index 247ea0b231a..14168907224 100644 --- a/homeassistant/components/binary_sensor/isy994.py +++ b/homeassistant/components/binary_sensor/isy994.py @@ -12,7 +12,8 @@ from typing import Callable # noqa from homeassistant.core import callback from homeassistant.components.binary_sensor import BinarySensorDevice, DOMAIN -import homeassistant.components.isy994 as isy +from homeassistant.components.isy994 import (ISY994_NODES, ISY994_PROGRAMS, + ISYDevice) from homeassistant.const import STATE_ON, STATE_OFF from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.event import async_track_point_in_utc_time @@ -20,9 +21,6 @@ from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) -UOM = ['2', '78'] -STATES = [STATE_OFF, STATE_ON, 'true', 'false'] - ISY_DEVICE_TYPES = { 'moisture': ['16.8', '16.13', '16.14'], 'opening': ['16.9', '16.6', '16.7', '16.2', '16.17', '16.20', '16.21'], @@ -34,16 +32,11 @@ ISY_DEVICE_TYPES = { def setup_platform(hass, config: ConfigType, add_devices: Callable[[list], None], discovery_info=None): """Set up the ISY994 binary sensor platform.""" - if isy.ISY is None or not isy.ISY.connected: - _LOGGER.error("A connection has not been made to the ISY controller") - return False - devices = [] devices_by_nid = {} child_nodes = [] - for node in isy.filter_nodes(isy.SENSOR_NODES, units=UOM, - states=STATES): + for node in hass.data[ISY994_NODES][DOMAIN]: if node.parent_node is None: device = ISYBinarySensorDevice(node) devices.append(device) @@ -80,13 +73,8 @@ def setup_platform(hass, config: ConfigType, device = ISYBinarySensorDevice(node) devices.append(device) - for program in isy.PROGRAMS.get(DOMAIN, []): - try: - status = program[isy.KEY_STATUS] - except (KeyError, AssertionError): - pass - else: - devices.append(ISYBinarySensorProgram(program.name, status)) + for name, status, _ in hass.data[ISY994_PROGRAMS][DOMAIN]: + devices.append(ISYBinarySensorProgram(name, status)) add_devices(devices) @@ -111,7 +99,7 @@ def _is_val_unknown(val): return val == -1*float('inf') -class ISYBinarySensorDevice(isy.ISYDevice, BinarySensorDevice): +class ISYBinarySensorDevice(ISYDevice, BinarySensorDevice): """Representation of an ISY994 binary sensor device. Often times, a single device is represented by multiple nodes in the ISY, @@ -251,7 +239,7 @@ class ISYBinarySensorDevice(isy.ISYDevice, BinarySensorDevice): return self._device_class_from_type -class ISYBinarySensorHeartbeat(isy.ISYDevice, BinarySensorDevice): +class ISYBinarySensorHeartbeat(ISYDevice, BinarySensorDevice): """Representation of the battery state of an ISY994 sensor.""" def __init__(self, node, parent_device) -> None: @@ -354,7 +342,7 @@ class ISYBinarySensorHeartbeat(isy.ISYDevice, BinarySensorDevice): return attr -class ISYBinarySensorProgram(isy.ISYDevice, BinarySensorDevice): +class ISYBinarySensorProgram(ISYDevice, BinarySensorDevice): """Representation of an ISY994 binary sensor program. This does not need all of the subnode logic in the device version of binary diff --git a/homeassistant/components/cover/isy994.py b/homeassistant/components/cover/isy994.py index 4dd1c9be364..b187b8409c2 100644 --- a/homeassistant/components/cover/isy994.py +++ b/homeassistant/components/cover/isy994.py @@ -8,8 +8,10 @@ import logging from typing import Callable # noqa from homeassistant.components.cover import CoverDevice, DOMAIN -import homeassistant.components.isy994 as isy -from homeassistant.const import STATE_OPEN, STATE_CLOSED, STATE_UNKNOWN +from homeassistant.components.isy994 import (ISY994_NODES, ISY994_PROGRAMS, + ISYDevice) +from homeassistant.const import ( + STATE_OPEN, STATE_CLOSED, STATE_OPENING, STATE_CLOSING, STATE_UNKNOWN) from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) @@ -17,44 +19,32 @@ _LOGGER = logging.getLogger(__name__) VALUE_TO_STATE = { 0: STATE_CLOSED, 101: STATE_UNKNOWN, + 102: 'stopped', + 103: STATE_CLOSING, + 104: STATE_OPENING } -UOM = ['97'] -STATES = [STATE_OPEN, STATE_CLOSED, 'closing', 'opening', 'stopped'] - # pylint: disable=unused-argument def setup_platform(hass, config: ConfigType, add_devices: Callable[[list], None], discovery_info=None): """Set up the ISY994 cover platform.""" - if isy.ISY is None or not isy.ISY.connected: - _LOGGER.error("A connection has not been made to the ISY controller") - return False - devices = [] - - for node in isy.filter_nodes(isy.NODES, units=UOM, states=STATES): + for node in hass.data[ISY994_NODES][DOMAIN]: devices.append(ISYCoverDevice(node)) - for program in isy.PROGRAMS.get(DOMAIN, []): - try: - status = program[isy.KEY_STATUS] - actions = program[isy.KEY_ACTIONS] - assert actions.dtype == 'program', 'Not a program' - except (KeyError, AssertionError): - pass - else: - devices.append(ISYCoverProgram(program.name, status, actions)) + for name, status, actions in hass.data[ISY994_PROGRAMS][DOMAIN]: + devices.append(ISYCoverProgram(name, status, actions)) add_devices(devices) -class ISYCoverDevice(isy.ISYDevice, CoverDevice): +class ISYCoverDevice(ISYDevice, CoverDevice): """Representation of an ISY994 cover device.""" def __init__(self, node: object): """Initialize the ISY994 cover device.""" - isy.ISYDevice.__init__(self, node) + super().__init__(node) @property def current_cover_position(self) -> int: @@ -90,7 +80,7 @@ class ISYCoverProgram(ISYCoverDevice): def __init__(self, name: str, node: object, actions: object) -> None: """Initialize the ISY994 cover program.""" - ISYCoverDevice.__init__(self, node) + super().__init__(node) self._name = name self._actions = actions diff --git a/homeassistant/components/fan/isy994.py b/homeassistant/components/fan/isy994.py index a49952569a8..137bc400d0d 100644 --- a/homeassistant/components/fan/isy994.py +++ b/homeassistant/components/fan/isy994.py @@ -9,18 +9,13 @@ from typing import Callable from homeassistant.components.fan import (FanEntity, DOMAIN, SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, - SPEED_HIGH) -import homeassistant.components.isy994 as isy -from homeassistant.const import STATE_ON, STATE_OFF + SPEED_HIGH, SUPPORT_SET_SPEED) +from homeassistant.components.isy994 import (ISY994_NODES, ISY994_PROGRAMS, + ISYDevice) from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) -# Define term used for medium speed. This must be set as the fan component uses -# 'medium' which the ISY does not understand -ISY_SPEED_MEDIUM = 'med' - - VALUE_TO_STATE = { 0: SPEED_OFF, 63: SPEED_LOW, @@ -34,41 +29,28 @@ STATE_TO_VALUE = {} for key in VALUE_TO_STATE: STATE_TO_VALUE[VALUE_TO_STATE[key]] = key -STATES = [SPEED_OFF, SPEED_LOW, ISY_SPEED_MEDIUM, SPEED_HIGH] - # pylint: disable=unused-argument def setup_platform(hass, config: ConfigType, add_devices: Callable[[list], None], discovery_info=None): """Set up the ISY994 fan platform.""" - if isy.ISY is None or not isy.ISY.connected: - _LOGGER.error("A connection has not been made to the ISY controller") - return False - devices = [] - for node in isy.filter_nodes(isy.NODES, states=STATES): + for node in hass.data[ISY994_NODES][DOMAIN]: devices.append(ISYFanDevice(node)) - for program in isy.PROGRAMS.get(DOMAIN, []): - try: - status = program[isy.KEY_STATUS] - actions = program[isy.KEY_ACTIONS] - assert actions.dtype == 'program', 'Not a program' - except (KeyError, AssertionError): - pass - else: - devices.append(ISYFanProgram(program.name, status, actions)) + for name, status, actions in hass.data[ISY994_PROGRAMS][DOMAIN]: + devices.append(ISYFanProgram(name, status, actions)) add_devices(devices) -class ISYFanDevice(isy.ISYDevice, FanEntity): +class ISYFanDevice(ISYDevice, FanEntity): """Representation of an ISY994 fan device.""" def __init__(self, node) -> None: """Initialize the ISY994 fan device.""" - isy.ISYDevice.__init__(self, node) + super().__init__(node) @property def speed(self) -> str: @@ -76,7 +58,7 @@ class ISYFanDevice(isy.ISYDevice, FanEntity): return VALUE_TO_STATE.get(self.value) @property - def is_on(self) -> str: + def is_on(self) -> bool: """Get if the fan is on.""" return self.value != 0 @@ -97,32 +79,32 @@ class ISYFanDevice(isy.ISYDevice, FanEntity): """Get the list of available speeds.""" return [SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] + @property + def supported_features(self) -> int: + """Flag supported features.""" + return SUPPORT_SET_SPEED + class ISYFanProgram(ISYFanDevice): """Representation of an ISY994 fan program.""" def __init__(self, name: str, node, actions) -> None: """Initialize the ISY994 fan program.""" - ISYFanDevice.__init__(self, node) + super().__init__(node) self._name = name self._actions = actions - self.speed = STATE_ON if self.is_on else STATE_OFF - - @property - def state(self) -> str: - """Get the state of the ISY994 fan program.""" - return STATE_ON if bool(self.value) else STATE_OFF def turn_off(self, **kwargs) -> None: """Send the turn on command to ISY994 fan program.""" if not self._actions.runThen(): _LOGGER.error("Unable to turn off the fan") - else: - self.speed = STATE_ON if self.is_on else STATE_OFF def turn_on(self, **kwargs) -> None: """Send the turn off command to ISY994 fan program.""" if not self._actions.runElse(): _LOGGER.error("Unable to turn on the fan") - else: - self.speed = STATE_ON if self.is_on else STATE_OFF + + @property + def supported_features(self) -> int: + """Flag supported features.""" + return 0 diff --git a/homeassistant/components/isy994.py b/homeassistant/components/isy994.py index af1846c7bf8..28cfac39154 100644 --- a/homeassistant/components/isy994.py +++ b/homeassistant/components/isy994.py @@ -24,15 +24,14 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = 'isy994' -CONF_HIDDEN_STRING = 'hidden_string' +CONF_IGNORE_STRING = 'ignore_string' CONF_SENSOR_STRING = 'sensor_string' +CONF_ENABLE_CLIMATE = 'enable_climate' CONF_TLS_VER = 'tls' -DEFAULT_HIDDEN_STRING = '{HIDE ME}' +DEFAULT_IGNORE_STRING = '{IGNORE ME}' DEFAULT_SENSOR_STRING = 'sensor' -ISY = None - KEY_ACTIONS = 'actions' KEY_FOLDER = 'folder' KEY_MY_PROGRAMS = 'My Programs' @@ -44,190 +43,344 @@ CONFIG_SCHEMA = vol.Schema({ vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Optional(CONF_TLS_VER): vol.Coerce(float), - vol.Optional(CONF_HIDDEN_STRING, - default=DEFAULT_HIDDEN_STRING): cv.string, + vol.Optional(CONF_IGNORE_STRING, + default=DEFAULT_IGNORE_STRING): cv.string, vol.Optional(CONF_SENSOR_STRING, - default=DEFAULT_SENSOR_STRING): cv.string + default=DEFAULT_SENSOR_STRING): cv.string, + vol.Optional(CONF_ENABLE_CLIMATE, + default=True): cv.boolean }) }, extra=vol.ALLOW_EXTRA) -SENSOR_NODES = [] -WEATHER_NODES = [] -NODES = [] -GROUPS = [] -PROGRAMS = {} +# Do not use the Hass consts for the states here - we're matching exact API +# responses, not using them for Hass states +NODE_FILTERS = { + 'binary_sensor': { + 'uom': [], + 'states': [], + 'node_def_id': ['BinaryAlarm'], + 'insteon_type': ['16.'] # Does a startswith() match; include the dot + }, + 'sensor': { + # This is just a more-readable way of including MOST uoms between 1-100 + # (Remember that range() is non-inclusive of the stop value) + 'uom': (['1'] + + list(map(str, range(3, 11))) + + list(map(str, range(12, 51))) + + list(map(str, range(52, 66))) + + list(map(str, range(69, 78))) + + ['79'] + + list(map(str, range(82, 97)))), + 'states': [], + 'node_def_id': ['IMETER_SOLO'], + 'insteon_type': ['9.0.', '9.7.'] + }, + 'lock': { + 'uom': ['11'], + 'states': ['locked', 'unlocked'], + 'node_def_id': ['DoorLock'], + 'insteon_type': ['15.'] + }, + 'fan': { + 'uom': [], + 'states': ['on', 'off', 'low', 'medium', 'high'], + 'node_def_id': ['FanLincMotor'], + 'insteon_type': ['1.46.'] + }, + 'cover': { + 'uom': ['97'], + 'states': ['open', 'closed', 'closing', 'opening', 'stopped'], + 'node_def_id': [], + 'insteon_type': [] + }, + 'light': { + 'uom': ['51'], + 'states': ['on', 'off', '%'], + 'node_def_id': ['DimmerLampSwitch', 'DimmerLampSwitch_ADV', + 'DimmerSwitchOnly', 'DimmerSwitchOnly_ADV', + 'DimmerLampOnly', 'BallastRelayLampSwitch', + 'BallastRelayLampSwitch_ADV', 'RelayLampSwitch', + 'RemoteLinc2', 'RemoteLinc2_ADV'], + 'insteon_type': ['1.'] + }, + 'switch': { + 'uom': ['2', '78'], + 'states': ['on', 'off'], + 'node_def_id': ['OnOffControl', 'RelayLampSwitch', + 'RelayLampSwitch_ADV', 'RelaySwitchOnlyPlusQuery', + 'RelaySwitchOnlyPlusQuery_ADV', 'RelayLampOnly', + 'RelayLampOnly_ADV', 'KeypadButton', + 'KeypadButton_ADV', 'EZRAIN_Input', 'EZRAIN_Output', + 'EZIO2x4_Input', 'EZIO2x4_Input_ADV', 'BinaryControl', + 'BinaryControl_ADV', 'AlertModuleSiren', + 'AlertModuleSiren_ADV', 'AlertModuleArmed', 'Siren', + 'Siren_ADV'], + 'insteon_type': ['2.', '9.10.', '9.11.'] + } +} -PYISY = None +SUPPORTED_DOMAINS = ['binary_sensor', 'sensor', 'lock', 'fan', 'cover', + 'light', 'switch'] +SUPPORTED_PROGRAM_DOMAINS = ['binary_sensor', 'lock', 'fan', 'cover', 'switch'] -HIDDEN_STRING = DEFAULT_HIDDEN_STRING - -SUPPORTED_DOMAINS = ['binary_sensor', 'cover', 'fan', 'light', 'lock', - 'sensor', 'switch'] +# ISY Scenes are more like Swithes than Hass Scenes +# (they can turn off, and report their state) +SCENE_DOMAIN = 'switch' +ISY994_NODES = "isy994_nodes" +ISY994_WEATHER = "isy994_weather" +ISY994_PROGRAMS = "isy994_programs" WeatherNode = namedtuple('WeatherNode', ('status', 'name', 'uom')) -def filter_nodes(nodes: list, units: list=None, states: list=None) -> list: - """Filter a list of ISY nodes based on the units and states provided.""" - filtered_nodes = [] - units = units if units else [] - states = states if states else [] - for node in nodes: - match_unit = False - match_state = True - for uom in node.uom: - if uom in units: - match_unit = True - continue - elif uom not in states: - match_state = False +def _check_for_node_def(hass: HomeAssistant, node, + single_domain: str=None) -> bool: + """Check if the node matches the node_def_id for any domains. - if match_unit: - continue - - if match_unit or match_state: - filtered_nodes.append(node) - - return filtered_nodes - - -def _is_node_a_sensor(node, path: str, sensor_identifier: str) -> bool: - """Determine if the given node is a sensor.""" - if not isinstance(node, PYISY.Nodes.Node): + This is only present on the 5.0 ISY firmware, and is the most reliable + way to determine a device's type. + """ + if not hasattr(node, 'node_def_id') or node.node_def_id is None: + # Node doesn't have a node_def (pre 5.0 firmware most likely) return False - if sensor_identifier in path or sensor_identifier in node.name: - return True + node_def_id = node.node_def_id - # This method is most reliable but only works on 5.x firmware - try: - if node.node_def_id == 'BinaryAlarm': + domains = SUPPORTED_DOMAINS if not single_domain else [single_domain] + for domain in domains: + if node_def_id in NODE_FILTERS[domain]['node_def_id']: + hass.data[ISY994_NODES][domain].append(node) return True - except AttributeError: - pass - - # This method works on all firmwares, but only for Insteon devices - try: - device_type = node.type - except AttributeError: - # Node has no type; most likely not an Insteon device - pass - else: - split_type = device_type.split('.') - return split_type[0] == '16' # 16 represents Insteon binary sensors return False -def _categorize_nodes(hidden_identifier: str, sensor_identifier: str) -> None: - """Categorize the ISY994 nodes.""" - global SENSOR_NODES - global NODES - global GROUPS +def _check_for_insteon_type(hass: HomeAssistant, node, + single_domain: str=None) -> bool: + """Check if the node matches the Insteon type for any domains. - SENSOR_NODES = [] - NODES = [] - GROUPS = [] + This is for (presumably) every version of the ISY firmware, but only + works for Insteon device. "Node Server" (v5+) and Z-Wave and others will + not have a type. + """ + if not hasattr(node, 'type') or node.type is None: + # Node doesn't have a type (non-Insteon device most likely) + return False + device_type = node.type + domains = SUPPORTED_DOMAINS if not single_domain else [single_domain] + for domain in domains: + if any([device_type.startswith(t) for t in + set(NODE_FILTERS[domain]['insteon_type'])]): + hass.data[ISY994_NODES][domain].append(node) + return True + + return False + + +def _check_for_uom_id(hass: HomeAssistant, node, + single_domain: str=None, uom_list: list=None) -> bool: + """Check if a node's uom matches any of the domains uom filter. + + This is used for versions of the ISY firmware that report uoms as a single + ID. We can often infer what type of device it is by that ID. + """ + if not hasattr(node, 'uom') or node.uom is None: + # Node doesn't have a uom (Scenes for example) + return False + + node_uom = set(map(str.lower, node.uom)) + + if uom_list: + if node_uom.intersection(NODE_FILTERS[single_domain]['uom']): + hass.data[ISY994_NODES][single_domain].append(node) + return True + else: + domains = SUPPORTED_DOMAINS if not single_domain else [single_domain] + for domain in domains: + if node_uom.intersection(NODE_FILTERS[domain]['uom']): + hass.data[ISY994_NODES][domain].append(node) + return True + + return False + + +def _check_for_states_in_uom(hass: HomeAssistant, node, + single_domain: str=None, + states_list: list=None) -> bool: + """Check if a list of uoms matches two possible filters. + + This is for versions of the ISY firmware that report uoms as a list of all + possible "human readable" states. This filter passes if all of the possible + states fit inside the given filter. + """ + if not hasattr(node, 'uom') or node.uom is None: + # Node doesn't have a uom (Scenes for example) + return False + + node_uom = set(map(str.lower, node.uom)) + + if states_list: + if node_uom == set(states_list): + hass.data[ISY994_NODES][single_domain].append(node) + return True + else: + domains = SUPPORTED_DOMAINS if not single_domain else [single_domain] + for domain in domains: + if node_uom == set(NODE_FILTERS[domain]['states']): + hass.data[ISY994_NODES][domain].append(node) + return True + + return False + + +def _is_sensor_a_binary_sensor(hass: HomeAssistant, node) -> bool: + """Determine if the given sensor node should be a binary_sensor.""" + if _check_for_node_def(hass, node, single_domain='binary_sensor'): + return True + if _check_for_insteon_type(hass, node, single_domain='binary_sensor'): + return True + + # For the next two checks, we're providing our own set of uoms that + # represent on/off devices. This is because we can only depend on these + # checks in the context of already knowing that this is definitely a + # sensor device. + if _check_for_uom_id(hass, node, single_domain='binary_sensor', + uom_list=['2', '78']): + return True + if _check_for_states_in_uom(hass, node, single_domain='binary_sensor', + states_list=['on', 'off']): + return True + + return False + + +def _categorize_nodes(hass: HomeAssistant, nodes, ignore_identifier: str, + sensor_identifier: str)-> None: + """Sort the nodes to their proper domains.""" # pylint: disable=no-member - for (path, node) in ISY.nodes: - hidden = hidden_identifier in path or hidden_identifier in node.name - if hidden: - node.name += hidden_identifier - if _is_node_a_sensor(node, path, sensor_identifier): - SENSOR_NODES.append(node) - elif isinstance(node, PYISY.Nodes.Node): - NODES.append(node) - elif isinstance(node, PYISY.Nodes.Group): - GROUPS.append(node) + for (path, node) in nodes: + ignored = ignore_identifier in path or ignore_identifier in node.name + if ignored: + # Don't import this node as a device at all + continue + + from PyISY.Nodes import Group + if isinstance(node, Group): + hass.data[ISY994_NODES][SCENE_DOMAIN].append(node) + continue + + if sensor_identifier in path or sensor_identifier in node.name: + # User has specified to treat this as a sensor. First we need to + # determine if it should be a binary_sensor. + if _is_sensor_a_binary_sensor(hass, node): + continue + else: + hass.data[ISY994_NODES]['sensor'].append(node) + continue + + # We have a bunch of different methods for determining the device type, + # each of which works with different ISY firmware versions or device + # family. The order here is important, from most reliable to least. + if _check_for_node_def(hass, node): + continue + if _check_for_insteon_type(hass, node): + continue + if _check_for_uom_id(hass, node): + continue + if _check_for_states_in_uom(hass, node): + continue -def _categorize_programs() -> None: +def _categorize_programs(hass: HomeAssistant, programs: dict) -> None: """Categorize the ISY994 programs.""" - global PROGRAMS - - PROGRAMS = {} - - for component in SUPPORTED_DOMAINS: + for domain in SUPPORTED_PROGRAM_DOMAINS: try: - folder = ISY.programs[KEY_MY_PROGRAMS]['HA.' + component] + folder = programs[KEY_MY_PROGRAMS]['HA.{}'.format(domain)] except KeyError: pass else: for dtype, _, node_id in folder.children: - if dtype is KEY_FOLDER: - program = folder[node_id] + if dtype == KEY_FOLDER: + entity_folder = folder[node_id] try: - node = program[KEY_STATUS].leaf - assert node.dtype == 'program', 'Not a program' - except (KeyError, AssertionError): - pass - else: - if component not in PROGRAMS: - PROGRAMS[component] = [] - PROGRAMS[component].append(program) + status = entity_folder[KEY_STATUS] + assert status.dtype == 'program', 'Not a program' + if domain != 'binary_sensor': + actions = entity_folder[KEY_ACTIONS] + assert actions.dtype == 'program', 'Not a program' + else: + actions = None + except (AttributeError, KeyError, AssertionError): + _LOGGER.warning("Program entity '%s' not loaded due " + "to invalid folder structure.", + entity_folder.name) + continue + + entity = (entity_folder.name, status, actions) + hass.data[ISY994_PROGRAMS][domain].append(entity) -def _categorize_weather() -> None: +def _categorize_weather(hass: HomeAssistant, climate) -> None: """Categorize the ISY994 weather data.""" - global WEATHER_NODES - - climate_attrs = dir(ISY.climate) - WEATHER_NODES = [WeatherNode(getattr(ISY.climate, attr), attr, - getattr(ISY.climate, attr + '_units')) + climate_attrs = dir(climate) + weather_nodes = [WeatherNode(getattr(climate, attr), + attr.replace('_', ' '), + getattr(climate, '{}_units'.format(attr))) for attr in climate_attrs - if attr + '_units' in climate_attrs] + if '{}_units'.format(attr) in climate_attrs] + hass.data[ISY994_WEATHER].extend(weather_nodes) def setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the ISY 994 platform.""" + hass.data[ISY994_NODES] = {} + for domain in SUPPORTED_DOMAINS: + hass.data[ISY994_NODES][domain] = [] + + hass.data[ISY994_WEATHER] = [] + + hass.data[ISY994_PROGRAMS] = {} + for domain in SUPPORTED_DOMAINS: + hass.data[ISY994_PROGRAMS][domain] = [] + isy_config = config.get(DOMAIN) user = isy_config.get(CONF_USERNAME) password = isy_config.get(CONF_PASSWORD) tls_version = isy_config.get(CONF_TLS_VER) host = urlparse(isy_config.get(CONF_HOST)) - port = host.port - addr = host.geturl() - hidden_identifier = isy_config.get( - CONF_HIDDEN_STRING, DEFAULT_HIDDEN_STRING) - sensor_identifier = isy_config.get( - CONF_SENSOR_STRING, DEFAULT_SENSOR_STRING) - - global HIDDEN_STRING - HIDDEN_STRING = hidden_identifier + ignore_identifier = isy_config.get(CONF_IGNORE_STRING) + sensor_identifier = isy_config.get(CONF_SENSOR_STRING) + enable_climate = isy_config.get(CONF_ENABLE_CLIMATE) if host.scheme == 'http': - addr = addr.replace('http://', '') https = False + port = host.port or 80 elif host.scheme == 'https': - addr = addr.replace('https://', '') https = True + port = host.port or 443 else: _LOGGER.error("isy994 host value in configuration is invalid") return False - addr = addr.replace(':{}'.format(port), '') - import PyISY - - global PYISY - PYISY = PyISY - # Connect to ISY controller. - global ISY - ISY = PyISY.ISY(addr, port, username=user, password=password, + isy = PyISY.ISY(host.hostname, port, username=user, password=password, use_https=https, tls_ver=tls_version, log=_LOGGER) - if not ISY.connected: + if not isy.connected: return False - _categorize_nodes(hidden_identifier, sensor_identifier) + _categorize_nodes(hass, isy.nodes, ignore_identifier, sensor_identifier) + _categorize_programs(hass, isy.programs) - _categorize_programs() + if enable_climate and isy.configuration.get('Weather Information'): + _categorize_weather(hass, isy.climate) - if ISY.configuration.get('Weather Information'): - _categorize_weather() + def stop(event: object) -> None: + """Stop ISY auto updates.""" + isy.auto_update = False # Listen for HA stop to disconnect. hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop) @@ -236,21 +389,14 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: for component in SUPPORTED_DOMAINS: discovery.load_platform(hass, component, DOMAIN, {}, config) - ISY.auto_update = True + isy.auto_update = True return True -# pylint: disable=unused-argument -def stop(event: object) -> None: - """Stop ISY auto updates.""" - ISY.auto_update = False - - class ISYDevice(Entity): """Representation of an ISY994 device.""" _attrs = {} - _domain = None # type: str _name = None # type: str def __init__(self, node) -> None: @@ -281,28 +427,16 @@ class ISYDevice(Entity): 'control': event }) - @property - def domain(self) -> str: - """Get the domain of the device.""" - return self._domain - @property def unique_id(self) -> str: """Get the unique identifier of the device.""" # pylint: disable=protected-access return self._node._id - @property - def raw_name(self) -> str: - """Get the raw name of the device.""" - return str(self._name) \ - if self._name is not None else str(self._node.name) - @property def name(self) -> str: """Get the name of the device.""" - return self.raw_name.replace(HIDDEN_STRING, '').strip() \ - .replace('_', ' ') + return self._name or str(self._node.name) @property def should_poll(self) -> bool: @@ -310,7 +444,7 @@ class ISYDevice(Entity): return False @property - def value(self) -> object: + def value(self) -> int: """Get the current value of the device.""" # pylint: disable=protected-access return self._node.status._val @@ -338,22 +472,3 @@ class ISYDevice(Entity): for name, val in self._node.aux_properties.items(): attr[name] = '{} {}'.format(val.get('value'), val.get('uom')) return attr - - @property - def hidden(self) -> bool: - """Get whether the device should be hidden from the UI.""" - return HIDDEN_STRING in self.raw_name - - @property - def unit_of_measurement(self) -> str: - """Get the device unit of measure.""" - return None - - def _attr_filter(self, attr: str) -> str: - """Filter the attribute.""" - # pylint: disable=no-self-use - return attr - - def update(self) -> None: - """Perform an update for the device.""" - pass diff --git a/homeassistant/components/light/isy994.py b/homeassistant/components/light/isy994.py index 78b92fbd145..a6191b05c7c 100644 --- a/homeassistant/components/light/isy994.py +++ b/homeassistant/components/light/isy994.py @@ -8,40 +8,30 @@ import logging from typing import Callable from homeassistant.components.light import ( - Light, SUPPORT_BRIGHTNESS) -import homeassistant.components.isy994 as isy -from homeassistant.const import STATE_ON, STATE_OFF + Light, SUPPORT_BRIGHTNESS, DOMAIN) +from homeassistant.components.isy994 import ISY994_NODES, ISYDevice from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) -UOM = ['2', '51', '78'] -STATES = [STATE_OFF, STATE_ON, 'true', 'false', '%'] - # pylint: disable=unused-argument def setup_platform(hass, config: ConfigType, add_devices: Callable[[list], None], discovery_info=None): """Set up the ISY994 light platform.""" - if isy.ISY is None or not isy.ISY.connected: - _LOGGER.error("A connection has not been made to the ISY controller") - return False - devices = [] - - for node in isy.filter_nodes(isy.NODES, units=UOM, states=STATES): - if node.dimmable or '51' in node.uom: - devices.append(ISYLightDevice(node)) + for node in hass.data[ISY994_NODES][DOMAIN]: + devices.append(ISYLightDevice(node)) add_devices(devices) -class ISYLightDevice(isy.ISYDevice, Light): +class ISYLightDevice(ISYDevice, Light): """Representation of an ISY994 light devie.""" def __init__(self, node: object) -> None: """Initialize the ISY994 light device.""" - isy.ISYDevice.__init__(self, node) + super().__init__(node) @property def is_on(self) -> bool: diff --git a/homeassistant/components/lock/isy994.py b/homeassistant/components/lock/isy994.py index 63272b90b1f..33e2a0bea25 100644 --- a/homeassistant/components/lock/isy994.py +++ b/homeassistant/components/lock/isy994.py @@ -8,7 +8,8 @@ import logging from typing import Callable # noqa from homeassistant.components.lock import LockDevice, DOMAIN -import homeassistant.components.isy994 as isy +from homeassistant.components.isy994 import (ISY994_NODES, ISY994_PROGRAMS, + ISYDevice) from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED, STATE_UNKNOWN from homeassistant.helpers.typing import ConfigType @@ -19,43 +20,27 @@ VALUE_TO_STATE = { 100: STATE_LOCKED } -UOM = ['11'] -STATES = [STATE_LOCKED, STATE_UNLOCKED] - # pylint: disable=unused-argument def setup_platform(hass, config: ConfigType, add_devices: Callable[[list], None], discovery_info=None): """Set up the ISY994 lock platform.""" - if isy.ISY is None or not isy.ISY.connected: - _LOGGER.error("A connection has not been made to the ISY controller") - return False - devices = [] - - for node in isy.filter_nodes(isy.NODES, units=UOM, - states=STATES): + for node in hass.data[ISY994_NODES][DOMAIN]: devices.append(ISYLockDevice(node)) - for program in isy.PROGRAMS.get(DOMAIN, []): - try: - status = program[isy.KEY_STATUS] - actions = program[isy.KEY_ACTIONS] - assert actions.dtype == 'program', 'Not a program' - except (KeyError, AssertionError): - pass - else: - devices.append(ISYLockProgram(program.name, status, actions)) + for name, status, actions in hass.data[ISY994_PROGRAMS][DOMAIN]: + devices.append(ISYLockProgram(name, status, actions)) add_devices(devices) -class ISYLockDevice(isy.ISYDevice, LockDevice): +class ISYLockDevice(ISYDevice, LockDevice): """Representation of an ISY994 lock device.""" def __init__(self, node) -> None: """Initialize the ISY994 lock device.""" - isy.ISYDevice.__init__(self, node) + super().__init__(node) self._conn = node.parent.parent.conn @property @@ -101,7 +86,7 @@ class ISYLockProgram(ISYLockDevice): def __init__(self, name: str, node, actions) -> None: """Initialize the lock.""" - ISYLockDevice.__init__(self, node) + super().__init__(node) self._name = name self._actions = actions diff --git a/homeassistant/components/sensor/isy994.py b/homeassistant/components/sensor/isy994.py index e961c63a1b5..76f026bba10 100644 --- a/homeassistant/components/sensor/isy994.py +++ b/homeassistant/components/sensor/isy994.py @@ -7,9 +7,11 @@ https://home-assistant.io/components/sensor.isy994/ import logging from typing import Callable # noqa -import homeassistant.components.isy994 as isy +from homeassistant.components.sensor import DOMAIN +from homeassistant.components.isy994 import (ISY994_NODES, ISY994_WEATHER, + ISYDevice) from homeassistant.const import ( - TEMP_CELSIUS, TEMP_FAHRENHEIT, STATE_OFF, STATE_ON, UNIT_UV_INDEX) + TEMP_CELSIUS, TEMP_FAHRENHEIT, UNIT_UV_INDEX) from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) @@ -232,37 +234,29 @@ UOM_TO_STATES = { } } -BINARY_UOM = ['2', '78'] - # pylint: disable=unused-argument def setup_platform(hass, config: ConfigType, add_devices: Callable[[list], None], discovery_info=None): """Set up the ISY994 sensor platform.""" - if isy.ISY is None or not isy.ISY.connected: - _LOGGER.error("A connection has not been made to the ISY controller") - return False - devices = [] - for node in isy.SENSOR_NODES: - if (not node.uom or node.uom[0] not in BINARY_UOM) and \ - STATE_OFF not in node.uom and STATE_ON not in node.uom: - _LOGGER.debug("Loading %s", node.name) - devices.append(ISYSensorDevice(node)) + for node in hass.data[ISY994_NODES][DOMAIN]: + _LOGGER.debug("Loading %s", node.name) + devices.append(ISYSensorDevice(node)) - for node in isy.WEATHER_NODES: + for node in hass.data[ISY994_WEATHER]: devices.append(ISYWeatherDevice(node)) add_devices(devices) -class ISYSensorDevice(isy.ISYDevice): +class ISYSensorDevice(ISYDevice): """Representation of an ISY994 sensor device.""" def __init__(self, node) -> None: """Initialize the ISY994 sensor device.""" - isy.ISYDevice.__init__(self, node) + super().__init__(node) @property def raw_unit_of_measurement(self) -> str: @@ -316,14 +310,12 @@ class ISYSensorDevice(isy.ISYDevice): return raw_units -class ISYWeatherDevice(isy.ISYDevice): +class ISYWeatherDevice(ISYDevice): """Representation of an ISY994 weather device.""" - _domain = 'sensor' - def __init__(self, node) -> None: """Initialize the ISY994 weather device.""" - isy.ISYDevice.__init__(self, node) + super().__init__(node) @property def unique_id(self) -> str: diff --git a/homeassistant/components/switch/isy994.py b/homeassistant/components/switch/isy994.py index 0f1ec62eaee..f0fd397710e 100644 --- a/homeassistant/components/switch/isy994.py +++ b/homeassistant/components/switch/isy994.py @@ -8,71 +8,39 @@ import logging from typing import Callable # noqa from homeassistant.components.switch import SwitchDevice, DOMAIN -import homeassistant.components.isy994 as isy -from homeassistant.const import STATE_ON, STATE_OFF, STATE_UNKNOWN +from homeassistant.components.isy994 import (ISY994_NODES, ISY994_PROGRAMS, + ISYDevice) from homeassistant.helpers.typing import ConfigType # noqa _LOGGER = logging.getLogger(__name__) -VALUE_TO_STATE = { - False: STATE_OFF, - True: STATE_ON, -} - -UOM = ['2', '78'] -STATES = [STATE_OFF, STATE_ON, 'true', 'false'] - # pylint: disable=unused-argument def setup_platform(hass, config: ConfigType, add_devices: Callable[[list], None], discovery_info=None): """Set up the ISY994 switch platform.""" - if isy.ISY is None or not isy.ISY.connected: - _LOGGER.error('A connection has not been made to the ISY controller.') - return False - devices = [] - - for node in isy.filter_nodes(isy.NODES, units=UOM, - states=STATES): + for node in hass.data[ISY994_NODES][DOMAIN]: if not node.dimmable: devices.append(ISYSwitchDevice(node)) - for node in isy.GROUPS: - devices.append(ISYSwitchDevice(node)) - - for program in isy.PROGRAMS.get(DOMAIN, []): - try: - status = program[isy.KEY_STATUS] - actions = program[isy.KEY_ACTIONS] - assert actions.dtype == 'program', 'Not a program' - except (KeyError, AssertionError): - pass - else: - devices.append(ISYSwitchProgram(program.name, status, actions)) + for name, status, actions in hass.data[ISY994_PROGRAMS][DOMAIN]: + devices.append(ISYSwitchProgram(name, status, actions)) add_devices(devices) -class ISYSwitchDevice(isy.ISYDevice, SwitchDevice): +class ISYSwitchDevice(ISYDevice, SwitchDevice): """Representation of an ISY994 switch device.""" def __init__(self, node) -> None: """Initialize the ISY994 switch device.""" - isy.ISYDevice.__init__(self, node) + super().__init__(node) @property def is_on(self) -> bool: """Get whether the ISY994 device is in the on state.""" - return self.state == STATE_ON - - @property - def state(self) -> str: - """Get the state of the ISY994 device.""" - if self.is_unknown(): - return None - else: - return VALUE_TO_STATE.get(bool(self.value), STATE_UNKNOWN) + return bool(self.value) def turn_off(self, **kwargs) -> None: """Send the turn on command to the ISY994 switch.""" @@ -90,7 +58,7 @@ class ISYSwitchProgram(ISYSwitchDevice): def __init__(self, name: str, node, actions) -> None: """Initialize the ISY994 switch program.""" - ISYSwitchDevice.__init__(self, node) + super().__init__(node) self._name = name self._actions = actions From 7a600ea0641911e42853240cda148057b89426b2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 26 Dec 2017 01:59:41 -0800 Subject: [PATCH 076/238] Add heartbeat to websocket connections (#11298) --- homeassistant/components/cloud/iot.py | 2 +- homeassistant/components/websocket_api.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/cloud/iot.py b/homeassistant/components/cloud/iot.py index 9c67c98cabf..5c9c54afd14 100644 --- a/homeassistant/components/cloud/iot.py +++ b/homeassistant/components/cloud/iot.py @@ -78,7 +78,7 @@ class CloudIoT: yield from hass.async_add_job(auth_api.check_token, self.cloud) self.client = client = yield from session.ws_connect( - self.cloud.relayer, headers={ + self.cloud.relayer, heartbeat=55, headers={ hdrs.AUTHORIZATION: 'Bearer {}'.format(self.cloud.id_token) }) diff --git a/homeassistant/components/websocket_api.py b/homeassistant/components/websocket_api.py index a1fb0ca9cac..f76bcaca2f8 100644 --- a/homeassistant/components/websocket_api.py +++ b/homeassistant/components/websocket_api.py @@ -263,7 +263,7 @@ class ActiveConnection: def handle(self): """Handle the websocket connection.""" request = self.request - wsock = self.wsock = web.WebSocketResponse() + wsock = self.wsock = web.WebSocketResponse(heartbeat=55) yield from wsock.prepare(request) self.debug("Connected") From f0244d7982b11e98a4f3d4f2fcd9270243187f65 Mon Sep 17 00:00:00 2001 From: Derek Brooks Date: Tue, 26 Dec 2017 11:12:28 -0800 Subject: [PATCH 077/238] add a bit more test coverage --- homeassistant/components/nuheat.py | 4 +-- tests/components/climate/test_nuheat.py | 21 ++++++++++-- tests/components/test_nuheat.py | 44 +++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 5 deletions(-) create mode 100644 tests/components/test_nuheat.py diff --git a/homeassistant/components/nuheat.py b/homeassistant/components/nuheat.py index 41db3e51842..fb14f119dbd 100644 --- a/homeassistant/components/nuheat.py +++ b/homeassistant/components/nuheat.py @@ -16,8 +16,6 @@ REQUIREMENTS = ["nuheat==0.3.0"] _LOGGER = logging.getLogger(__name__) -DATA_NUHEAT = "nuheat" - DOMAIN = "nuheat" CONFIG_SCHEMA = vol.Schema({ @@ -41,7 +39,7 @@ def setup(hass, config): api = nuheat.NuHeat(username, password) api.authenticate() - hass.data[DATA_NUHEAT] = (api, devices) + hass.data[DOMAIN] = (api, devices) discovery.load_platform(hass, "climate", DOMAIN, {}, config) return True diff --git a/tests/components/climate/test_nuheat.py b/tests/components/climate/test_nuheat.py index aedb925277e..b2ad57731ba 100644 --- a/tests/components/climate/test_nuheat.py +++ b/tests/components/climate/test_nuheat.py @@ -42,6 +42,7 @@ class TestNuHeat(unittest.TestCase): target_celsius=22, target_fahrenheit=72) + thermostat.get_data = Mock() thermostat.resume_schedule = Mock() api = Mock() @@ -132,6 +133,17 @@ class TestNuHeat(unittest.TestCase): self.assertEqual( self.thermostat.current_hold_mode, nuheat.MODE_TEMPORARY_HOLD) + self.thermostat._thermostat.schedule_mode = None + self.assertEqual( + self.thermostat.current_hold_mode, nuheat.MODE_AUTO) + + def test_operation_list(self): + """Test the operation list.""" + self.assertEqual( + self.thermostat.operation_list, + [STATE_HEAT, STATE_IDLE] + ) + def test_resume_program(self): """Test resume schedule.""" self.thermostat.resume_program() @@ -167,7 +179,7 @@ class TestNuHeat(unittest.TestCase): self.assertTrue(self.thermostat._force_update) @patch.object(nuheat.NuHeatThermostat, "_throttled_update") - def test_forced_update(self, throttled_update): + def test_update_without_throttle(self, throttled_update): """Test update without throttle.""" self.thermostat._force_update = True self.thermostat.update() @@ -175,9 +187,14 @@ class TestNuHeat(unittest.TestCase): self.assertFalse(self.thermostat._force_update) @patch.object(nuheat.NuHeatThermostat, "_throttled_update") - def test_throttled_update(self, throttled_update): + def test_update_with_throttle(self, throttled_update): """Test update with throttle.""" self.thermostat._force_update = False self.thermostat.update() throttled_update.assert_called_once_with() self.assertFalse(self.thermostat._force_update) + + def test_throttled_update(self): + """Test update with throttle.""" + self.thermostat._throttled_update() + self.thermostat._thermostat.get_data.assert_called_once_with() diff --git a/tests/components/test_nuheat.py b/tests/components/test_nuheat.py new file mode 100644 index 00000000000..6b091b8df35 --- /dev/null +++ b/tests/components/test_nuheat.py @@ -0,0 +1,44 @@ +"""NuHeat component tests.""" +import unittest + +from unittest.mock import patch +from tests.common import get_test_home_assistant, MockDependency + +from homeassistant.components import nuheat + +VALID_CONFIG = { + "nuheat": { + "username": "warm", + "password": "feet", + "devices": "thermostat123" + } +} + + +class TestNuHeat(unittest.TestCase): + """Test the NuHeat component.""" + + def setUp(self): + """Initialize the values for this test class.""" + self.hass = get_test_home_assistant() + self.config = VALID_CONFIG + + def tearDown(self): + """Teardown this test class. Stop hass.""" + self.hass.stop() + + @MockDependency("nuheat") + @patch("homeassistant.helpers.discovery.load_platform") + def test_setup(self, mocked_nuheat, mocked_load): + """Test setting up the NuHeat component.""" + nuheat.setup(self.hass, self.config) + + mocked_nuheat.NuHeat.assert_called_with("warm", "feet") + self.assertIn(nuheat.DOMAIN, self.hass.data) + self.assertEquals(2, len(self.hass.data[nuheat.DOMAIN])) + self.assertEquals(self.hass.data[nuheat.DOMAIN][0], "thermostat123") + self.assertEquals(self.hass.data[nuheat.DOMAIN][1], "thermostat123") + + mocked_load.assert_called_with( + self.hass, "climate", nuheat.DOMAIN, {}, self.config + ) From 54b414253058b15cce14190cc9537deb1da58707 Mon Sep 17 00:00:00 2001 From: awkwardDuck <34869622+awkwardDuck@users.noreply.github.com> Date: Tue, 26 Dec 2017 16:02:59 -0500 Subject: [PATCH 078/238] Fix typo in bitcoin.py component for mined blocks. (#11318) --- homeassistant/components/sensor/bitcoin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/bitcoin.py b/homeassistant/components/sensor/bitcoin.py index 31c6c1809b3..8bed72a67c2 100644 --- a/homeassistant/components/sensor/bitcoin.py +++ b/homeassistant/components/sensor/bitcoin.py @@ -38,7 +38,7 @@ OPTION_TYPES = { 'number_of_transactions': ['No. of Transactions', None], 'hash_rate': ['Hash rate', 'PH/s'], 'timestamp': ['Timestamp', None], - 'mined_blocks': ['Minded Blocks', None], + 'mined_blocks': ['Mined Blocks', None], 'blocks_size': ['Block size', None], 'total_fees_btc': ['Total fees', 'BTC'], 'total_btc_sent': ['Total sent', 'BTC'], From 05926b1994f2c068d45547b338fc48714f1ff98f Mon Sep 17 00:00:00 2001 From: Conrad Juhl Andersen Date: Tue, 26 Dec 2017 23:35:48 +0100 Subject: [PATCH 079/238] xiaomi_aqara: Fix covers never being closed (#11319) Bug in equality testing --- homeassistant/components/cover/xiaomi_aqara.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/cover/xiaomi_aqara.py b/homeassistant/components/cover/xiaomi_aqara.py index 17d056a5010..5b51371346b 100644 --- a/homeassistant/components/cover/xiaomi_aqara.py +++ b/homeassistant/components/cover/xiaomi_aqara.py @@ -41,7 +41,7 @@ class XiaomiGenericCover(XiaomiDevice, CoverDevice): @property def is_closed(self): """Return if the cover is closed.""" - return self.current_cover_position < 0 + return self.current_cover_position <= 0 def close_cover(self, **kwargs): """Close the cover.""" From 169459b57f10783ab306ff5bcc987ac191811fbb Mon Sep 17 00:00:00 2001 From: Mitko Masarliev Date: Wed, 27 Dec 2017 00:49:24 +0200 Subject: [PATCH 080/238] Fix for track_new_devices BC (#11202) * BC fix * more tests * inline if change * inline if change --- .../components/device_tracker/__init__.py | 10 +++++--- tests/components/device_tracker/test_init.py | 24 +++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index 28505900f14..8c563fda34c 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -88,7 +88,7 @@ NEW_DEVICE_DEFAULTS_SCHEMA = vol.Any(None, vol.Schema({ })) PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend({ vol.Optional(CONF_SCAN_INTERVAL): cv.time_period, - vol.Optional(CONF_TRACK_NEW, default=DEFAULT_TRACK_NEW): cv.boolean, + vol.Optional(CONF_TRACK_NEW): cv.boolean, vol.Optional(CONF_CONSIDER_HOME, default=DEFAULT_CONSIDER_HOME): vol.All( cv.time_period, cv.positive_timedelta), @@ -131,8 +131,11 @@ def async_setup(hass: HomeAssistantType, config: ConfigType): conf = config.get(DOMAIN, []) conf = conf[0] if conf else {} consider_home = conf.get(CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME) - track_new = conf.get(CONF_TRACK_NEW, DEFAULT_TRACK_NEW) + defaults = conf.get(CONF_NEW_DEVICE_DEFAULTS, {}) + track_new = conf.get(CONF_TRACK_NEW) + if track_new is None: + track_new = defaults.get(CONF_TRACK_NEW, DEFAULT_TRACK_NEW) devices = yield from async_load_config(yaml_path, hass, consider_home) tracker = DeviceTracker( @@ -227,7 +230,8 @@ class DeviceTracker(object): self.devices = {dev.dev_id: dev for dev in devices} self.mac_to_dev = {dev.mac: dev for dev in devices if dev.mac} self.consider_home = consider_home - self.track_new = defaults.get(CONF_TRACK_NEW, track_new) + self.track_new = track_new if track_new is not None \ + else defaults.get(CONF_TRACK_NEW, DEFAULT_TRACK_NEW) self.defaults = defaults self.group = None self._is_updating = asyncio.Lock(loop=hass.loop) diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py index 34c7ecf465d..2d0764ec585 100644 --- a/tests/components/device_tracker/test_init.py +++ b/tests/components/device_tracker/test_init.py @@ -675,6 +675,30 @@ class TestComponentsDeviceTracker(unittest.TestCase): assert len(config) == 1 self.assertTrue(config[0].hidden) + def test_backward_compatibility_for_track_new(self): + """Test backward compatibility for track new.""" + tracker = device_tracker.DeviceTracker( + self.hass, timedelta(seconds=60), False, + {device_tracker.CONF_TRACK_NEW: True}, []) + tracker.see(dev_id=13) + self.hass.block_till_done() + config = device_tracker.load_config(self.yaml_devices, self.hass, + timedelta(seconds=0)) + assert len(config) == 1 + self.assertFalse(config[0].track) + + def test_old_style_track_new_is_skipped(self): + """Test old style config is skipped.""" + tracker = device_tracker.DeviceTracker( + self.hass, timedelta(seconds=60), None, + {device_tracker.CONF_TRACK_NEW: False}, []) + tracker.see(dev_id=14) + self.hass.block_till_done() + config = device_tracker.load_config(self.yaml_devices, self.hass, + timedelta(seconds=0)) + assert len(config) == 1 + self.assertFalse(config[0].track) + @asyncio.coroutine def test_async_added_to_hass(hass): From 5be949f00f185d29cdf70a2a2732976d633308cc Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Wed, 27 Dec 2017 09:17:03 +0100 Subject: [PATCH 081/238] Upgrade aiohttp_cors to 0.6.0 (#11310) --- homeassistant/components/http/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 17ceccfd218..33f97395945 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -36,7 +36,7 @@ from .static import ( CachingFileResponse, CachingStaticResource, staticresource_middleware) from .util import get_real_ip -REQUIREMENTS = ['aiohttp_cors==0.5.3'] +REQUIREMENTS = ['aiohttp_cors==0.6.0'] ALLOWED_CORS_HEADERS = [ ORIGIN, ACCEPT, HTTP_HEADER_X_REQUESTED_WITH, CONTENT_TYPE, diff --git a/requirements_all.txt b/requirements_all.txt index 5eb4361a436..4b54030edd4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -66,7 +66,7 @@ aiodns==1.1.1 # homeassistant.components.emulated_hue # homeassistant.components.http -aiohttp_cors==0.5.3 +aiohttp_cors==0.6.0 # homeassistant.components.sensor.imap aioimaplib==0.7.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ad9fae671cc..b9e027b1103 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -31,7 +31,7 @@ aioautomatic==0.6.4 # homeassistant.components.emulated_hue # homeassistant.components.http -aiohttp_cors==0.5.3 +aiohttp_cors==0.6.0 # homeassistant.components.notify.apns apns2==0.3.0 From af6c39f4d16148a01280db6e9ec30cc69ea43571 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Wed, 27 Dec 2017 09:17:43 +0100 Subject: [PATCH 082/238] Upgrade pysnmp to 4.4.3 (#11317) --- homeassistant/components/device_tracker/snmp.py | 2 +- homeassistant/components/sensor/snmp.py | 2 +- homeassistant/components/switch/snmp.py | 2 +- requirements_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/device_tracker/snmp.py b/homeassistant/components/device_tracker/snmp.py index add027e1823..49dfc81112f 100644 --- a/homeassistant/components/device_tracker/snmp.py +++ b/homeassistant/components/device_tracker/snmp.py @@ -14,7 +14,7 @@ from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import CONF_HOST -REQUIREMENTS = ['pysnmp==4.4.2'] +REQUIREMENTS = ['pysnmp==4.4.3'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/snmp.py b/homeassistant/components/sensor/snmp.py index 982e7d9559b..b2318564d16 100644 --- a/homeassistant/components/sensor/snmp.py +++ b/homeassistant/components/sensor/snmp.py @@ -16,7 +16,7 @@ from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_PORT, CONF_UNIT_OF_MEASUREMENT, STATE_UNKNOWN, CONF_VALUE_TEMPLATE) -REQUIREMENTS = ['pysnmp==4.4.2'] +REQUIREMENTS = ['pysnmp==4.4.3'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/switch/snmp.py b/homeassistant/components/switch/snmp.py index 99ba9d8cd54..f2d536c5961 100644 --- a/homeassistant/components/switch/snmp.py +++ b/homeassistant/components/switch/snmp.py @@ -13,7 +13,7 @@ from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_PORT, CONF_PAYLOAD_ON, CONF_PAYLOAD_OFF) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pysnmp==4.4.2'] +REQUIREMENTS = ['pysnmp==4.4.3'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 4b54030edd4..e34558bf001 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -820,7 +820,7 @@ pysma==0.1.3 # homeassistant.components.device_tracker.snmp # homeassistant.components.sensor.snmp # homeassistant.components.switch.snmp -pysnmp==4.4.2 +pysnmp==4.4.3 # homeassistant.components.media_player.liveboxplaytv pyteleloisirs==3.3 From 40e1d35268b1fc7bc96521409a2d937122c847e7 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Wed, 27 Dec 2017 09:19:02 +0100 Subject: [PATCH 083/238] Upgrade luftdaten to 0.1.3 (#11316) --- homeassistant/components/sensor/luftdaten.py | 20 ++++++++++---------- requirements_all.txt | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/sensor/luftdaten.py b/homeassistant/components/sensor/luftdaten.py index 8c5fcc15ec2..ac977e52fce 100644 --- a/homeassistant/components/sensor/luftdaten.py +++ b/homeassistant/components/sensor/luftdaten.py @@ -18,7 +18,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['luftdaten==0.1.1'] +REQUIREMENTS = ['luftdaten==0.1.3'] _LOGGER = logging.getLogger(__name__) @@ -114,17 +114,17 @@ class LuftdatenSensor(Entity): @property def device_state_attributes(self): """Return the state attributes.""" - if self.luftdaten.data.meta is None: + try: + attr = { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + ATTR_SENSOR_ID: self._sensor_id, + 'lat': self.luftdaten.data.meta['latitude'], + 'long': self.luftdaten.data.meta['longitude'], + } + return attr + except KeyError: return - attr = { - ATTR_ATTRIBUTION: CONF_ATTRIBUTION, - ATTR_SENSOR_ID: self._sensor_id, - 'lat': self.luftdaten.data.meta['latitude'], - 'long': self.luftdaten.data.meta['longitude'], - } - return attr - @asyncio.coroutine def async_update(self): """Get the latest data from luftdaten.info and update the state.""" diff --git a/requirements_all.txt b/requirements_all.txt index e34558bf001..19fce515321 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -451,7 +451,7 @@ liveboxplaytv==2.0.2 lmnotify==0.0.4 # homeassistant.components.sensor.luftdaten -luftdaten==0.1.1 +luftdaten==0.1.3 # homeassistant.components.sensor.lyft lyft_rides==0.2 From e92e43380503643a5175c0641fe6326baed9835e Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Wed, 27 Dec 2017 09:19:46 +0100 Subject: [PATCH 084/238] Upgrade yahooweather to 0.10 (#11309) --- homeassistant/components/sensor/yweather.py | 2 +- homeassistant/components/weather/yweather.py | 2 +- requirements_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensor/yweather.py b/homeassistant/components/sensor/yweather.py index 846b221d5e3..e066e38fb1e 100644 --- a/homeassistant/components/sensor/yweather.py +++ b/homeassistant/components/sensor/yweather.py @@ -17,7 +17,7 @@ from homeassistant.const import ( from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['yahooweather==0.9'] +REQUIREMENTS = ['yahooweather==0.10'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/weather/yweather.py b/homeassistant/components/weather/yweather.py index a043f3c2212..0ef0aba2d1b 100644 --- a/homeassistant/components/weather/yweather.py +++ b/homeassistant/components/weather/yweather.py @@ -15,7 +15,7 @@ from homeassistant.components.weather import ( ATTR_FORECAST_TEMP, ATTR_FORECAST_TIME) from homeassistant.const import (TEMP_CELSIUS, CONF_NAME, STATE_UNKNOWN) -REQUIREMENTS = ["yahooweather==0.9"] +REQUIREMENTS = ["yahooweather==0.10"] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 19fce515321..75b5e076ef2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1187,7 +1187,7 @@ yahoo-finance==1.4.0 # homeassistant.components.sensor.yweather # homeassistant.components.weather.yweather -yahooweather==0.9 +yahooweather==0.10 # homeassistant.components.light.yeelight yeelight==0.3.3 From e91d47db967f1465ba85879aee3c0078dcbe6995 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Wed, 27 Dec 2017 09:20:08 +0100 Subject: [PATCH 085/238] Upgrade distro to 1.2.0 (#11312) --- homeassistant/components/updater.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/updater.py b/homeassistant/components/updater.py index c67beee62dd..f1f5b7dd1fd 100644 --- a/homeassistant/components/updater.py +++ b/homeassistant/components/updater.py @@ -25,7 +25,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util -REQUIREMENTS = ['distro==1.1.0'] +REQUIREMENTS = ['distro==1.2.0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 75b5e076ef2..5c9158448fc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -215,7 +215,7 @@ discogs_client==2.2.1 discord.py==0.16.12 # homeassistant.components.updater -distro==1.1.0 +distro==1.2.0 # homeassistant.components.switch.digitalloggers dlipower==0.7.165 From 7826b9aa72c53c8dfdceac2e9d622ae2916ba859 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Wed, 27 Dec 2017 09:20:44 +0100 Subject: [PATCH 086/238] Upgrade python-digitalocean to 1.13.2 (#11311) --- homeassistant/components/digital_ocean.py | 12 +++++++++--- requirements_all.txt | 2 +- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/digital_ocean.py b/homeassistant/components/digital_ocean.py index 6ba2c824859..bd03fb01975 100644 --- a/homeassistant/components/digital_ocean.py +++ b/homeassistant/components/digital_ocean.py @@ -13,7 +13,7 @@ from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['python-digitalocean==1.12'] +REQUIREMENTS = ['python-digitalocean==1.13.2'] _LOGGER = logging.getLogger(__name__) @@ -44,13 +44,19 @@ CONFIG_SCHEMA = vol.Schema({ def setup(hass, config): """Set up the Digital Ocean component.""" + import digitalocean + conf = config[DOMAIN] access_token = conf.get(CONF_ACCESS_TOKEN) digital = DigitalOcean(access_token) - if not digital.manager.get_account(): - _LOGGER.error("No Digital Ocean account found for the given API Token") + try: + if not digital.manager.get_account(): + _LOGGER.error("No account found for the given API token") + return False + except digitalocean.baseapi.DataReadError: + _LOGGER.error("API token not valid for authentication") return False hass.data[DATA_DIGITAL_OCEAN] = digital diff --git a/requirements_all.txt b/requirements_all.txt index 5c9158448fc..f7b11f7a338 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -836,7 +836,7 @@ python-blockchain-api==0.0.2 python-clementine-remote==1.0.1 # homeassistant.components.digital_ocean -python-digitalocean==1.12 +python-digitalocean==1.13.2 # homeassistant.components.ecobee python-ecobee-api==0.0.14 From 8d32e883bdcbef970e1f502747951a9d242e5486 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Wed, 27 Dec 2017 09:21:07 +0100 Subject: [PATCH 087/238] Upgrade youtube_dl to 2017.12.23 (#11308) --- homeassistant/components/media_extractor.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/media_extractor.py b/homeassistant/components/media_extractor.py index 669390b3b90..d10dd955a93 100644 --- a/homeassistant/components/media_extractor.py +++ b/homeassistant/components/media_extractor.py @@ -16,7 +16,7 @@ from homeassistant.components.media_player import ( from homeassistant.config import load_yaml_config_file from homeassistant.helpers import config_validation as cv -REQUIREMENTS = ['youtube_dl==2017.12.10'] +REQUIREMENTS = ['youtube_dl==2017.12.23'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index f7b11f7a338..e3f2fbc441a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1196,7 +1196,7 @@ yeelight==0.3.3 yeelightsunflower==0.0.8 # homeassistant.components.media_extractor -youtube_dl==2017.12.10 +youtube_dl==2017.12.23 # homeassistant.components.light.zengge zengge==0.2 From d68d4d112937716cd5168f977e4d80e925fe1c85 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Wed, 27 Dec 2017 09:21:28 +0100 Subject: [PATCH 088/238] Upgrade alpha_vantage to 1.6.0 (#11307) --- homeassistant/components/sensor/alpha_vantage.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/alpha_vantage.py b/homeassistant/components/sensor/alpha_vantage.py index 88ead3301b6..e56b0c31d2a 100644 --- a/homeassistant/components/sensor/alpha_vantage.py +++ b/homeassistant/components/sensor/alpha_vantage.py @@ -14,7 +14,7 @@ from homeassistant.const import ATTR_ATTRIBUTION, CONF_API_KEY import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['alpha_vantage==1.3.6'] +REQUIREMENTS = ['alpha_vantage==1.6.0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index e3f2fbc441a..86bb34a5ce1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -84,7 +84,7 @@ aiopvapi==1.5.4 alarmdecoder==0.12.3 # homeassistant.components.sensor.alpha_vantage -alpha_vantage==1.3.6 +alpha_vantage==1.6.0 # homeassistant.components.amcrest amcrest==1.2.1 From aa8db784d5f0bfd4b3e28ce549a61d79b589c91d Mon Sep 17 00:00:00 2001 From: Greg Laabs Date: Wed, 27 Dec 2017 00:23:21 -0800 Subject: [PATCH 089/238] Fix leak sensors always showing Unknown until Wet (#11313) Leak sensors were using the "wet" node as a negative node, which prevented them from ever gettng a Dry status unless the user pressed the button on the hardware after every Hass reboot. This change ignores the Wet node, as it is just always the exact inverse of the Dry node. We don't need to watch both. --- homeassistant/components/binary_sensor/isy994.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/binary_sensor/isy994.py b/homeassistant/components/binary_sensor/isy994.py index 14168907224..89d9b7e5c8f 100644 --- a/homeassistant/components/binary_sensor/isy994.py +++ b/homeassistant/components/binary_sensor/isy994.py @@ -55,10 +55,9 @@ def setup_platform(hass, config: ConfigType, node.nid, node.parent_nid) else: device_type = _detect_device_type(node) - if device_type in ['moisture', 'opening']: - subnode_id = int(node.nid[-1]) - # Leak and door/window sensors work the same way with negative - # nodes and heartbeat nodes + subnode_id = int(node.nid[-1]) + if device_type == 'opening': + # Door/window sensors use an optional "negative" node if subnode_id == 4: # Subnode 4 is the heartbeat node, which we will represent # as a separate binary_sensor @@ -67,6 +66,14 @@ def setup_platform(hass, config: ConfigType, devices.append(device) elif subnode_id == 2: parent_device.add_negative_node(node) + elif device_type == 'moisture': + # Moisure nodes have a subnode 2, but we ignore it because it's + # just the inverse of the primary node. + if subnode_id == 4: + # Heartbeat node + device = ISYBinarySensorHeartbeat(node, parent_device) + parent_device.add_heartbeat_device(device) + devices.append(device) else: # We don't yet have any special logic for other sensor types, # so add the nodes as individual devices From e5cc5a58e1d08b06b4b539dcb115d315c9584611 Mon Sep 17 00:00:00 2001 From: Rene Nulsch <33263735+ReneNulschDE@users.noreply.github.com> Date: Wed, 27 Dec 2017 09:24:37 +0100 Subject: [PATCH 090/238] Bugfix for HA Issue 7292, 9412 - switch to gamertag to receive ssl image url (#11315) --- homeassistant/components/sensor/xbox_live.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensor/xbox_live.py b/homeassistant/components/sensor/xbox_live.py index 3b59f06be31..c3c8cde0177 100644 --- a/homeassistant/components/sensor/xbox_live.py +++ b/homeassistant/components/sensor/xbox_live.py @@ -57,12 +57,12 @@ class XboxSensor(Entity): self._api = api # get profile info - profile = self._api.get_user_profile(self._xuid) + profile = self._api.get_user_gamercard(self._xuid) if profile.get('success', True) and profile.get('code', 0) != 28: self.success_init = True - self._gamertag = profile.get('Gamertag') - self._picture = profile.get('GameDisplayPicRaw') + self._gamertag = profile.get('gamertag') + self._picture = profile.get('gamerpicSmallSslImagePath') else: self.success_init = False From cb23549af632916f7b7f9cd5d1ba0c713e0d8a70 Mon Sep 17 00:00:00 2001 From: Bob Igo Date: Wed, 27 Dec 2017 13:49:06 -0500 Subject: [PATCH 091/238] closes #11314 by not restricting the voice to anything but a string (#11326) --- homeassistant/components/tts/marytts.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/tts/marytts.py b/homeassistant/components/tts/marytts.py index d7db09856a6..072ea0e76e7 100644 --- a/homeassistant/components/tts/marytts.py +++ b/homeassistant/components/tts/marytts.py @@ -23,10 +23,6 @@ SUPPORT_LANGUAGES = [ 'de', 'en-GB', 'en-US', 'fr', 'it', 'lb', 'ru', 'sv', 'te', 'tr' ] -SUPPORT_VOICES = [ - 'cmu-slt-hsmm' -] - SUPPORT_CODEC = [ 'aiff', 'au', 'wav' ] @@ -44,7 +40,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_LANG, default=DEFAULT_LANG): vol.In(SUPPORT_LANGUAGES), - vol.Optional(CONF_VOICE, default=DEFAULT_VOICE): vol.In(SUPPORT_VOICES), + vol.Optional(CONF_VOICE, default=DEFAULT_VOICE): cv.string, vol.Optional(CONF_CODEC, default=DEFAULT_CODEC): vol.In(SUPPORT_CODEC) }) From 63d9bd4a9cb034501d855f691eb167704536b8e1 Mon Sep 17 00:00:00 2001 From: Derek Brooks Date: Wed, 27 Dec 2017 12:42:56 -0800 Subject: [PATCH 092/238] test resume program service --- homeassistant/components/climate/nuheat.py | 7 +-- tests/components/climate/test_nuheat.py | 56 +++++++++++++++++----- tests/components/test_nuheat.py | 4 +- 3 files changed, 50 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/climate/nuheat.py b/homeassistant/components/climate/nuheat.py index a4d1cad68a4..67540a2347d 100644 --- a/homeassistant/components/climate/nuheat.py +++ b/homeassistant/components/climate/nuheat.py @@ -19,7 +19,7 @@ from homeassistant.components.climate import ( STATE_AUTO, STATE_HEAT, STATE_IDLE) -from homeassistant.components.nuheat import DATA_NUHEAT +from homeassistant.components.nuheat import DOMAIN as NUHEAT_DOMAIN from homeassistant.config import load_yaml_config_file from homeassistant.const import ( ATTR_ENTITY_ID, @@ -64,7 +64,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): return temperature_unit = hass.config.units.temperature_unit - api, serial_numbers = hass.data[DATA_NUHEAT] + api, serial_numbers = hass.data[NUHEAT_DOMAIN] thermostats = [ NuHeatThermostat(api, serial_number, temperature_unit) for serial_number in serial_numbers @@ -74,7 +74,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): def resume_program_set_service(service): """Resume the program on the target thermostats.""" entity_id = service.data.get(ATTR_ENTITY_ID) - if entity_id: target_thermostats = [device for device in thermostats if device.entity_id in entity_id] @@ -94,6 +93,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): descriptions.get(SERVICE_RESUME_PROGRAM), schema=RESUME_PROGRAM_SCHEMA) + return True + class NuHeatThermostat(ClimateDevice): """Representation of a NuHeat Thermostat.""" diff --git a/tests/components/climate/test_nuheat.py b/tests/components/climate/test_nuheat.py index b2ad57731ba..6ec63646bec 100644 --- a/tests/components/climate/test_nuheat.py +++ b/tests/components/climate/test_nuheat.py @@ -1,6 +1,7 @@ """The test for the NuHeat thermostat module.""" import unittest from unittest.mock import Mock, patch +from tests.common import get_test_home_assistant from homeassistant.components.climate import ( SUPPORT_HOLD_MODE, @@ -21,7 +22,7 @@ class TestNuHeat(unittest.TestCase): # pylint: disable=protected-access, no-self-use - def setUp(self): + def setUp(self): # pylint: disable=invalid-name """Set up test variables.""" serial_number = "12345" temperature_unit = "F" @@ -45,31 +46,62 @@ class TestNuHeat(unittest.TestCase): thermostat.get_data = Mock() thermostat.resume_schedule = Mock() - api = Mock() - api.get_thermostat.return_value = thermostat + self.api = Mock() + self.api.get_thermostat.return_value = thermostat + self.hass = get_test_home_assistant() self.thermostat = nuheat.NuHeatThermostat( - api, serial_number, temperature_unit) + self.api, serial_number, temperature_unit) + + def tearDown(self): # pylint: disable=invalid-name + """Stop hass.""" + self.hass.stop() @patch("homeassistant.components.climate.nuheat.NuHeatThermostat") def test_setup_platform(self, mocked_thermostat): """Test setup_platform.""" - api = Mock() - data = {"nuheat": (api, ["12345"])} + mocked_thermostat.return_value = self.thermostat + thermostat = mocked_thermostat(self.api, "12345", "F") + thermostats = [thermostat] - hass = Mock() - hass.config.units.temperature_unit.return_value = "F" - hass.data = Mock() - hass.data.__getitem__ = Mock(side_effect=data.__getitem__) + self.hass.data[nuheat.NUHEAT_DOMAIN] = (self.api, ["12345"]) config = {} add_devices = Mock() discovery_info = {} - nuheat.setup_platform(hass, config, add_devices, discovery_info) - thermostats = [mocked_thermostat(api, "12345", "F")] + nuheat.setup_platform(self.hass, config, add_devices, discovery_info) add_devices.assert_called_once_with(thermostats, True) + @patch("homeassistant.components.climate.nuheat.NuHeatThermostat") + def test_resume_program_service(self, mocked_thermostat): + """Test resume program service.""" + mocked_thermostat.return_value = self.thermostat + thermostat = mocked_thermostat(self.api, "12345", "F") + thermostat.resume_program = Mock() + thermostat.schedule_update_ha_state = Mock() + thermostat.entity_id = "climate.master_bathroom" + + self.hass.data[nuheat.NUHEAT_DOMAIN] = (self.api, ["12345"]) + nuheat.setup_platform(self.hass, {}, Mock(), {}) + + # Explicit entity + self.hass.services.call(nuheat.DOMAIN, nuheat.SERVICE_RESUME_PROGRAM, + {"entity_id": "climate.master_bathroom"}, True) + + thermostat.resume_program.assert_called_with() + thermostat.schedule_update_ha_state.assert_called_with(True) + + thermostat.resume_program.reset_mock() + thermostat.schedule_update_ha_state.reset_mock() + + # All entities + self.hass.services.call( + nuheat.DOMAIN, nuheat.SERVICE_RESUME_PROGRAM, {}, True) + + thermostat.resume_program.assert_called_with() + thermostat.schedule_update_ha_state.assert_called_with(True) + def test_name(self): """Test name property.""" self.assertEqual(self.thermostat.name, "Master bathroom") diff --git a/tests/components/test_nuheat.py b/tests/components/test_nuheat.py index 6b091b8df35..6b486c6afcc 100644 --- a/tests/components/test_nuheat.py +++ b/tests/components/test_nuheat.py @@ -18,12 +18,12 @@ VALID_CONFIG = { class TestNuHeat(unittest.TestCase): """Test the NuHeat component.""" - def setUp(self): + def setUp(self): # pylint: disable=invalid-name """Initialize the values for this test class.""" self.hass = get_test_home_assistant() self.config = VALID_CONFIG - def tearDown(self): + def tearDown(self): # pylint: disable=invalid-name """Teardown this test class. Stop hass.""" self.hass.stop() From 29c26e0015e2f6172366cc1b0f0a7a001f269f57 Mon Sep 17 00:00:00 2001 From: Derek Brooks Date: Wed, 27 Dec 2017 13:06:04 -0800 Subject: [PATCH 093/238] fix bad nuheat component test --- tests/components/test_nuheat.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/components/test_nuheat.py b/tests/components/test_nuheat.py index 6b486c6afcc..91a8b326bf9 100644 --- a/tests/components/test_nuheat.py +++ b/tests/components/test_nuheat.py @@ -36,7 +36,9 @@ class TestNuHeat(unittest.TestCase): mocked_nuheat.NuHeat.assert_called_with("warm", "feet") self.assertIn(nuheat.DOMAIN, self.hass.data) self.assertEquals(2, len(self.hass.data[nuheat.DOMAIN])) - self.assertEquals(self.hass.data[nuheat.DOMAIN][0], "thermostat123") + self.assertIsInstance( + self.hass.data[nuheat.DOMAIN][0], type(mocked_nuheat.NuHeat()) + ) self.assertEquals(self.hass.data[nuheat.DOMAIN][1], "thermostat123") mocked_load.assert_called_with( From 00352d41a7bc4f3e9c1d7eb275f7a62994827a2d Mon Sep 17 00:00:00 2001 From: Derek Brooks Date: Wed, 27 Dec 2017 18:20:12 -0800 Subject: [PATCH 094/238] remove return value as requested --- homeassistant/components/climate/nuheat.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/climate/nuheat.py b/homeassistant/components/climate/nuheat.py index 67540a2347d..a62a684299d 100644 --- a/homeassistant/components/climate/nuheat.py +++ b/homeassistant/components/climate/nuheat.py @@ -93,8 +93,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): descriptions.get(SERVICE_RESUME_PROGRAM), schema=RESUME_PROGRAM_SCHEMA) - return True - class NuHeatThermostat(ClimateDevice): """Representation of a NuHeat Thermostat.""" From b2e9dc5c8fcc33931f09f9d1e4f3879511fdf2a4 Mon Sep 17 00:00:00 2001 From: Matt Schmitt Date: Thu, 28 Dec 2017 12:55:22 -0500 Subject: [PATCH 095/238] Additional device classes for binary sensors (#11280) * Add additional device classes for binary sensor --- .../components/binary_sensor/__init__.py | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/binary_sensor/__init__.py b/homeassistant/components/binary_sensor/__init__.py index a0c141914ed..df271a7ebac 100644 --- a/homeassistant/components/binary_sensor/__init__.py +++ b/homeassistant/components/binary_sensor/__init__.py @@ -21,24 +21,27 @@ SCAN_INTERVAL = timedelta(seconds=30) ENTITY_ID_FORMAT = DOMAIN + '.{}' DEVICE_CLASSES = [ 'battery', # On means low, Off means normal - 'cold', # On means cold (or too cold) - 'connectivity', # On means connection present, Off = no connection - 'gas', # CO, CO2, etc. - 'heat', # On means hot (or too hot) - 'light', # Lightness threshold - 'moisture', # Specifically a wetness sensor - 'motion', # Motion sensor - 'moving', # On means moving, Off means stopped - 'occupancy', # On means occupied, Off means not occupied - 'opening', # Door, window, etc. + 'cold', # On means cold, Off means normal + 'connectivity', # On means connected, Off means disconnected + 'door', # On means open, Off means closed + 'garage_door', # On means open, Off means closed + 'gas', # On means gas detected, Off means no gas (clear) + 'heat', # On means hot, Off means normal + 'light', # On means light detected, Off means no light + 'moisture', # On means wet, Off means dry + 'motion', # On means motion detected, Off means no motion (clear) + 'moving', # On means moving, Off means not moving (stopped) + 'occupancy', # On means occupied, Off means not occupied (clear) + 'opening', # On means open, Off means closed 'plug', # On means plugged in, Off means unplugged - 'power', # Power, over-current, etc + 'power', # On means power detected, Off means no power 'presence', # On means home, Off means away - 'problem', # On means there is a problem, Off means the status is OK - 'safety', # Generic on=unsafe, off=safe - 'smoke', # Smoke detector - 'sound', # On means sound detected, Off means no sound + 'problem', # On means problem detected, Off means no problem (OK) + 'safety', # On means unsafe, Off means safe + 'smoke', # On means smoke detected, Off means no smoke (clear) + 'sound', # On means sound detected, Off means no sound (clear) 'vibration', # On means vibration detected, Off means no vibration + 'window', # On means open, Off means closed ] DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.In(DEVICE_CLASSES)) From 966ab20f262128fa7194cc0e286249a6328a8410 Mon Sep 17 00:00:00 2001 From: Jeroen ter Heerdt Date: Thu, 28 Dec 2017 20:20:44 +0100 Subject: [PATCH 096/238] Remove need for separate EgardiaServer setup (#11344) * Removing need for separate Egardiaserver setup * Fixing linting errors * Updating egardia component based on review * Updating egardia component based on review * Updating egardia component based on review * Removed return False twice based on review --- .../components/alarm_control_panel/egardia.py | 68 ++++++++++++------- requirements_all.txt | 2 +- 2 files changed, 45 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/egardia.py b/homeassistant/components/alarm_control_panel/egardia.py index 82c26c98104..36a4bdb1310 100644 --- a/homeassistant/components/alarm_control_panel/egardia.py +++ b/homeassistant/components/alarm_control_panel/egardia.py @@ -16,9 +16,9 @@ from homeassistant.components.alarm_control_panel import PLATFORM_SCHEMA from homeassistant.const import ( CONF_PORT, CONF_HOST, CONF_PASSWORD, CONF_USERNAME, STATE_UNKNOWN, CONF_NAME, STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME, - STATE_ALARM_ARMED_AWAY, STATE_ALARM_TRIGGERED) + STATE_ALARM_ARMED_AWAY, STATE_ALARM_TRIGGERED, EVENT_HOMEASSISTANT_STOP) -REQUIREMENTS = ['pythonegardia==1.0.22'] +REQUIREMENTS = ['pythonegardia==1.0.25'] _LOGGER = logging.getLogger(__name__) @@ -26,13 +26,15 @@ CONF_REPORT_SERVER_CODES = 'report_server_codes' CONF_REPORT_SERVER_ENABLED = 'report_server_enabled' CONF_REPORT_SERVER_PORT = 'report_server_port' CONF_REPORT_SERVER_CODES_IGNORE = 'ignore' +CONF_VERSION = 'version' DEFAULT_NAME = 'Egardia' DEFAULT_PORT = 80 DEFAULT_REPORT_SERVER_ENABLED = False DEFAULT_REPORT_SERVER_PORT = 52010 +DEFAULT_VERSION = 'GATE-01' DOMAIN = 'egardia' - +D_EGARDIASRV = 'egardiaserver' NOTIFICATION_ID = 'egardia_notification' NOTIFICATION_TITLE = 'Egardia' @@ -49,6 +51,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_USERNAME): cv.string, + vol.Optional(CONF_VERSION, default=DEFAULT_VERSION): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_REPORT_SERVER_CODES): vol.All(cv.ensure_list), @@ -62,6 +65,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Egardia platform.""" from pythonegardia import egardiadevice + from pythonegardia import egardiaserver name = config.get(CONF_NAME) username = config.get(CONF_USERNAME) @@ -71,41 +75,62 @@ def setup_platform(hass, config, add_devices, discovery_info=None): rs_enabled = config.get(CONF_REPORT_SERVER_ENABLED) rs_port = config.get(CONF_REPORT_SERVER_PORT) rs_codes = config.get(CONF_REPORT_SERVER_CODES) + version = config.get(CONF_VERSION) try: egardiasystem = egardiadevice.EgardiaDevice( - host, port, username, password, '') + host, port, username, password, '', version) except requests.exceptions.RequestException: raise exc.PlatformNotReady() except egardiadevice.UnauthorizedError: _LOGGER.error("Unable to authorize. Wrong password or username") - return False + return - add_devices([EgardiaAlarm( - name, egardiasystem, hass, rs_enabled, rs_port, rs_codes)], True) + eg_dev = EgardiaAlarm( + name, egardiasystem, rs_enabled, rs_codes) + + if rs_enabled: + # Set up the egardia server + _LOGGER.info("Setting up EgardiaServer") + try: + if D_EGARDIASRV not in hass.data: + server = egardiaserver.EgardiaServer('', rs_port) + bound = server.bind() + if not bound: + raise IOError("Binding error occurred while " + + "starting EgardiaServer") + hass.data[D_EGARDIASRV] = server + server.start() + except IOError: + return + hass.data[D_EGARDIASRV].register_callback(eg_dev.handle_status_event) + + def handle_stop_event(event): + """Callback function for HA stop event.""" + hass.data[D_EGARDIASRV].stop() + + # listen to home assistant stop event + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, handle_stop_event) + + # add egardia alarm device + add_devices([eg_dev], True) class EgardiaAlarm(alarm.AlarmControlPanel): """Representation of a Egardia alarm.""" - def __init__(self, name, egardiasystem, hass, rs_enabled=False, - rs_port=None, rs_codes=None): + def __init__(self, name, egardiasystem, + rs_enabled=False, rs_codes=None): """Initialize object.""" self._name = name self._egardiasystem = egardiasystem - self._status = STATE_UNKNOWN + self._status = None self._rs_enabled = rs_enabled - self._rs_port = rs_port - self._hass = hass - if rs_codes is not None: self._rs_codes = rs_codes[0] else: self._rs_codes = rs_codes - if self._rs_enabled: - self.listen_to_system_status() - @property def name(self): """Return the name of the device.""" @@ -123,19 +148,14 @@ class EgardiaAlarm(alarm.AlarmControlPanel): return True return False - def handle_system_status_event(self, event): + def handle_status_event(self, event): """Handle egardia_system_status_event.""" - if event.data.get('status') is not None: - statuscode = event.data.get('status') + statuscode = event.get('status') + if statuscode is not None: status = self.lookupstatusfromcode(statuscode) self.parsestatus(status) self.schedule_update_ha_state() - def listen_to_system_status(self): - """Subscribe to egardia_system_status event.""" - self._hass.bus.listen( - 'egardia_system_status', self.handle_system_status_event) - def lookupstatusfromcode(self, statuscode): """Look at the rs_codes and returns the status from the code.""" status = 'UNKNOWN' diff --git a/requirements_all.txt b/requirements_all.txt index 1f8a0cb9de4..a993afcdf24 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -925,7 +925,7 @@ python_opendata_transport==0.0.3 python_openzwave==0.4.0.35 # homeassistant.components.alarm_control_panel.egardia -pythonegardia==1.0.22 +pythonegardia==1.0.25 # homeassistant.components.sensor.whois pythonwhois==2.4.3 From a6c7fe04da76cc94ed9095f6da93c1c2a98711e6 Mon Sep 17 00:00:00 2001 From: Dan Nixon Date: Thu, 28 Dec 2017 20:22:46 +0000 Subject: [PATCH 097/238] Add default names and validation for TP-Link devices (#11346) Adds missing platform schema for TP-Link smart sockets and adds default names for smart sockets and bulbs. --- homeassistant/components/light/tplink.py | 12 +++++++++++- homeassistant/components/switch/tplink.py | 7 +++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/light/tplink.py b/homeassistant/components/light/tplink.py index 692a5fb86ec..3b49e3fb0f7 100644 --- a/homeassistant/components/light/tplink.py +++ b/homeassistant/components/light/tplink.py @@ -8,10 +8,13 @@ import logging import colorsys import time +import voluptuous as vol + from homeassistant.const import (CONF_HOST, CONF_NAME) from homeassistant.components.light import ( Light, ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_KELVIN, ATTR_RGB_COLOR, - SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_RGB_COLOR) + SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_RGB_COLOR, PLATFORM_SCHEMA) +import homeassistant.helpers.config_validation as cv from homeassistant.util.color import \ color_temperature_mired_to_kelvin as mired_to_kelvin from homeassistant.util.color import ( @@ -27,6 +30,13 @@ ATTR_CURRENT_CONSUMPTION = 'current_consumption' ATTR_DAILY_CONSUMPTION = 'daily_consumption' ATTR_MONTHLY_CONSUMPTION = 'monthly_consumption' +DEFAULT_NAME = 'TP-Link Light' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string +}) + def setup_platform(hass, config, add_devices, discovery_info=None): """Initialise pyLB100 SmartBulb.""" diff --git a/homeassistant/components/switch/tplink.py b/homeassistant/components/switch/tplink.py index 0772cc9277c..a03e30821b3 100644 --- a/homeassistant/components/switch/tplink.py +++ b/homeassistant/components/switch/tplink.py @@ -24,10 +24,13 @@ ATTR_CURRENT = 'current' CONF_LEDS = 'enable_leds' +DEFAULT_NAME = 'TP-Link Switch' +DEFAULT_LEDS = True + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_LEDS, default=True): cv.boolean, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_LEDS, default=DEFAULT_LEDS): cv.boolean, }) From 5d38eec37dfeba5e14da9f3342ea824bbdaf7cea Mon Sep 17 00:00:00 2001 From: Gregory Benner Date: Thu, 28 Dec 2017 15:39:24 -0500 Subject: [PATCH 098/238] Sochain cryptocurrency sensor (#11335) * add required files * add sochain sensor * add missing schema * end first sentence with a period to make travis happy * upgrade to python-sochain-api 0.0.2 and use asyncio * add missing _LOGGER and fix long line * move object setup to async_setup_platform * rename chainSo variable to chainso --- .coveragerc | 1 + homeassistant/components/sensor/sochain.py | 87 ++++++++++++++++++++++ requirements_all.txt | 3 + 3 files changed, 91 insertions(+) create mode 100644 homeassistant/components/sensor/sochain.py diff --git a/.coveragerc b/.coveragerc index 83f11983806..6d2997a8274 100644 --- a/.coveragerc +++ b/.coveragerc @@ -583,6 +583,7 @@ omit = homeassistant/components/sensor/skybeacon.py homeassistant/components/sensor/sma.py homeassistant/components/sensor/snmp.py + homeassistant/components/sensor/sochain.py homeassistant/components/sensor/sonarr.py homeassistant/components/sensor/speedtest.py homeassistant/components/sensor/steam_online.py diff --git a/homeassistant/components/sensor/sochain.py b/homeassistant/components/sensor/sochain.py new file mode 100644 index 00000000000..572d0f52921 --- /dev/null +++ b/homeassistant/components/sensor/sochain.py @@ -0,0 +1,87 @@ +""" +Support for watching multiple cryptocurrencies. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.sochain/ +""" +import asyncio +import logging +from datetime import timedelta + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import (CONF_NAME, ATTR_ATTRIBUTION) +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +REQUIREMENTS = ['python-sochain-api==0.0.2'] + +_LOGGER = logging.getLogger(__name__) + +CONF_ADDRESS = 'address' +CONF_NETWORK = 'network' +CONF_ATTRIBUTION = "Data provided by chain.so" + +DEFAULT_NAME = 'Crypto Balance' + +SCAN_INTERVAL = timedelta(minutes=5) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_ADDRESS): cv.string, + vol.Required(CONF_NETWORK): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the sochain sensors.""" + from pysochain import ChainSo + address = config.get(CONF_ADDRESS) + network = config.get(CONF_NETWORK) + name = config.get(CONF_NAME) + + session = async_get_clientsession(hass) + chainso = ChainSo(network, address, hass.loop, session) + + async_add_devices([SochainSensor(name, network.upper(), chainso)], True) + + +class SochainSensor(Entity): + """Representation of a Sochain sensor.""" + + def __init__(self, name, unit_of_measurement, chainso): + """Initialize the sensor.""" + self._name = name + self._unit_of_measurement = unit_of_measurement + self.chainso = chainso + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self.chainso.data.get("confirmed_balance") \ + if self.chainso is not None else None + + @property + def unit_of_measurement(self): + """Return the unit of measurement this sensor expresses itself in.""" + return self._unit_of_measurement + + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + return { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + } + + @asyncio.coroutine + def async_update(self): + """Get the latest state of the sensor.""" + yield from self.chainso.async_get_data() diff --git a/requirements_all.txt b/requirements_all.txt index a993afcdf24..f3807d2e656 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -897,6 +897,9 @@ python-ripple-api==0.0.3 # homeassistant.components.media_player.roku python-roku==3.1.3 +# homeassistant.components.sensor.sochain +python-sochain-api==0.0.2 + # homeassistant.components.sensor.synologydsm python-synology==0.1.0 From 2e582a4597a4c08aebd78c8cf48c90a788c640ba Mon Sep 17 00:00:00 2001 From: Thom Troy Date: Thu, 28 Dec 2017 21:37:51 +0000 Subject: [PATCH 099/238] pass stops_at to get_station_by_name (#11304) --- homeassistant/components/sensor/irish_rail_transport.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/irish_rail_transport.py b/homeassistant/components/sensor/irish_rail_transport.py index ad2a312ce63..0c34a5f6ce8 100644 --- a/homeassistant/components/sensor/irish_rail_transport.py +++ b/homeassistant/components/sensor/irish_rail_transport.py @@ -148,7 +148,8 @@ class IrishRailTransportData(object): """Get the latest data from irishrail.""" trains = self._ir_api.get_station_by_name(self.station, direction=self.direction, - destination=self.destination) + destination=self.destination, + stops_at=self.stops_at) stops_at = self.stops_at if self.stops_at else '' self.info = [] for train in trains: From 3203849b60ce0d73bc75e17ee0eac6ce9267a369 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 29 Dec 2017 09:03:03 +0100 Subject: [PATCH 100/238] Move data instance to setup (#11350) --- .../sensor/swiss_public_transport.py | 35 +++++++++---------- 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/sensor/swiss_public_transport.py b/homeassistant/components/sensor/swiss_public_transport.py index 40b77d278af..a489adf6776 100644 --- a/homeassistant/components/sensor/swiss_public_transport.py +++ b/homeassistant/components/sensor/swiss_public_transport.py @@ -5,17 +5,17 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.swiss_public_transport/ """ import asyncio -import logging from datetime import timedelta +import logging import voluptuous as vol -import homeassistant.helpers.config_validation as cv -import homeassistant.util.dt as dt_util from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_NAME, ATTR_ATTRIBUTION -from homeassistant.helpers.entity import Entity +from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +import homeassistant.util.dt as dt_util REQUIREMENTS = ['python_opendata_transport==0.0.3'] @@ -51,36 +51,36 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the Swiss public transport sensor.""" + from opendata_transport import OpendataTransport, exceptions + name = config.get(CONF_NAME) start = config.get(CONF_START) destination = config.get(CONF_DESTINATION) - connection = SwissPublicTransportSensor(hass, start, destination, name) - yield from connection.async_update() + session = async_get_clientsession(hass) + opendata = OpendataTransport(start, destination, hass.loop, session) - if connection.state is None: + try: + yield from opendata.async_get_data() + except exceptions.OpendataTransportError: _LOGGER.error( "Check at http://transport.opendata.ch/examples/stationboard.html " "if your station names are valid") - return False + return - async_add_devices([connection]) + async_add_devices( + [SwissPublicTransportSensor(opendata, start, destination, name)]) class SwissPublicTransportSensor(Entity): """Implementation of an Swiss public transport sensor.""" - def __init__(self, hass, start, destination, name): + def __init__(self, opendata, start, destination, name): """Initialize the sensor.""" - from opendata_transport import OpendataTransport - - self.hass = hass + self._opendata = opendata self._name = name self._from = start self._to = destination - self._websession = async_get_clientsession(self.hass) - self._opendata = OpendataTransport( - self._from, self._to, self.hass.loop, self._websession) @property def name(self): @@ -131,4 +131,3 @@ class SwissPublicTransportSensor(Entity): yield from self._opendata.async_get_data() except OpendataTransportError: _LOGGER.error("Unable to retrieve data from transport.opendata.ch") - self._opendata = None From 9a34e7174cdab95e7112899f018ec49deff69ac5 Mon Sep 17 00:00:00 2001 From: goldminenine <34691572+goldminenine@users.noreply.github.com> Date: Fri, 29 Dec 2017 09:19:34 +0100 Subject: [PATCH 101/238] Update modbus.py (#11238) Support of MODBUS RTU over TCP ethernet mode. See more description here: https://www.eltima.com/modbus-over-ethernet/ --- homeassistant/components/modbus.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/modbus.py b/homeassistant/components/modbus.py index 001c8d1188a..293e86b014e 100644 --- a/homeassistant/components/modbus.py +++ b/homeassistant/components/modbus.py @@ -40,7 +40,7 @@ SERIAL_SCHEMA = { ETHERNET_SCHEMA = { vol.Required(CONF_HOST): cv.string, vol.Required(CONF_PORT): cv.positive_int, - vol.Required(CONF_TYPE): vol.Any('tcp', 'udp'), + vol.Required(CONF_TYPE): vol.Any('tcp', 'udp', 'rtuovertcp'), vol.Optional(CONF_TIMEOUT, default=3): cv.socket_timeout, } @@ -92,6 +92,13 @@ def setup(hass, config): bytesize=config[DOMAIN][CONF_BYTESIZE], parity=config[DOMAIN][CONF_PARITY], timeout=config[DOMAIN][CONF_TIMEOUT]) + elif client_type == 'rtuovertcp': + from pymodbus.client.sync import ModbusTcpClient as ModbusClient + from pymodbus.transaction import ModbusRtuFramer as ModbusFramer + client = ModbusClient(host=config[DOMAIN][CONF_HOST], + port=config[DOMAIN][CONF_PORT], + framer=ModbusFramer, + timeout=config[DOMAIN][CONF_TIMEOUT]) elif client_type == 'tcp': from pymodbus.client.sync import ModbusTcpClient as ModbusClient client = ModbusClient(host=config[DOMAIN][CONF_HOST], From 6b586c268a94f4a2357b875fb08b1111d39f0ba3 Mon Sep 17 00:00:00 2001 From: Andy Castille Date: Fri, 29 Dec 2017 03:05:45 -0600 Subject: [PATCH 102/238] DoorBird feature update (#11193) * Allow disabling the DoorBird camera live view * Support for push notifications from DoorBird devices * use DoorBirdPy 0.1.1 instead of 0.1.0 * Fix lint errors in DoorBird binary sensor * Change DoorBird push notifications from binary sensor to event * Remove DoorBird camera options * use DoorBirdPy 0.1.2 to fix history image urls * clean up doorbird event code and remove unused doorbird camera imports * use async for doorbird doorbell events * Minor changes * Update file header * Fix my mess * Fix docstring --- .../components/binary_sensor/doorbird.py | 60 ------------------- homeassistant/components/camera/doorbird.py | 40 +++++-------- homeassistant/components/doorbird.py | 52 ++++++++++++++-- requirements_all.txt | 2 +- 4 files changed, 62 insertions(+), 92 deletions(-) delete mode 100644 homeassistant/components/binary_sensor/doorbird.py diff --git a/homeassistant/components/binary_sensor/doorbird.py b/homeassistant/components/binary_sensor/doorbird.py deleted file mode 100644 index 9a13687fc54..00000000000 --- a/homeassistant/components/binary_sensor/doorbird.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Support for reading binary states from a DoorBird video doorbell.""" -from datetime import timedelta -import logging - -from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.components.doorbird import DOMAIN as DOORBIRD_DOMAIN -from homeassistant.util import Throttle - -DEPENDENCIES = ['doorbird'] - -_LOGGER = logging.getLogger(__name__) -_MIN_UPDATE_INTERVAL = timedelta(milliseconds=250) - -SENSOR_TYPES = { - "doorbell": { - "name": "Doorbell Ringing", - "icon": { - True: "bell-ring", - False: "bell", - None: "bell-outline" - } - } -} - - -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the DoorBird binary sensor component.""" - device = hass.data.get(DOORBIRD_DOMAIN) - add_devices([DoorBirdBinarySensor(device, "doorbell")], True) - - -class DoorBirdBinarySensor(BinarySensorDevice): - """A binary sensor of a DoorBird device.""" - - def __init__(self, device, sensor_type): - """Initialize a binary sensor on a DoorBird device.""" - self._device = device - self._sensor_type = sensor_type - self._state = None - - @property - def name(self): - """Get the name of the sensor.""" - return SENSOR_TYPES[self._sensor_type]["name"] - - @property - def icon(self): - """Get an icon to display.""" - state_icon = SENSOR_TYPES[self._sensor_type]["icon"][self._state] - return "mdi:{}".format(state_icon) - - @property - def is_on(self): - """Get the state of the binary sensor.""" - return self._state - - @Throttle(_MIN_UPDATE_INTERVAL) - def update(self): - """Pull the latest value from the device.""" - self._state = self._device.doorbell_state() diff --git a/homeassistant/components/camera/doorbird.py b/homeassistant/components/camera/doorbird.py index cf6b6b2871f..2ca962a8450 100644 --- a/homeassistant/components/camera/doorbird.py +++ b/homeassistant/components/camera/doorbird.py @@ -1,51 +1,40 @@ -"""Support for viewing the camera feed from a DoorBird video doorbell.""" +""" +Support for viewing the camera feed from a DoorBird video doorbell. +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.doorbird/ +""" import asyncio import datetime import logging -import voluptuous as vol import aiohttp import async_timeout -from homeassistant.components.camera import PLATFORM_SCHEMA, Camera +from homeassistant.components.camera import Camera from homeassistant.components.doorbird import DOMAIN as DOORBIRD_DOMAIN -from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession DEPENDENCIES = ['doorbird'] -_CAMERA_LIVE = "DoorBird Live" _CAMERA_LAST_VISITOR = "DoorBird Last Ring" -_LIVE_INTERVAL = datetime.timedelta(seconds=1) +_CAMERA_LIVE = "DoorBird Live" _LAST_VISITOR_INTERVAL = datetime.timedelta(minutes=1) +_LIVE_INTERVAL = datetime.timedelta(seconds=1) _LOGGER = logging.getLogger(__name__) _TIMEOUT = 10 # seconds -CONF_SHOW_LAST_VISITOR = 'last_visitor' - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_SHOW_LAST_VISITOR, default=False): cv.boolean -}) - @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the DoorBird camera platform.""" device = hass.data.get(DOORBIRD_DOMAIN) - - _LOGGER.debug("Adding DoorBird camera %s", _CAMERA_LIVE) - entities = [DoorBirdCamera(device.live_image_url, _CAMERA_LIVE, - _LIVE_INTERVAL)] - - if config.get(CONF_SHOW_LAST_VISITOR): - _LOGGER.debug("Adding DoorBird camera %s", _CAMERA_LAST_VISITOR) - entities.append(DoorBirdCamera(device.history_image_url(1), - _CAMERA_LAST_VISITOR, - _LAST_VISITOR_INTERVAL)) - - async_add_devices(entities) - _LOGGER.info("Added DoorBird camera(s)") + async_add_devices([ + DoorBirdCamera(device.live_image_url, _CAMERA_LIVE, _LIVE_INTERVAL), + DoorBirdCamera( + device.history_image_url(1, 'doorbell'), _CAMERA_LAST_VISITOR, + _LAST_VISITOR_INTERVAL), + ]) class DoorBirdCamera(Camera): @@ -75,7 +64,6 @@ class DoorBirdCamera(Camera): try: websession = async_get_clientsession(self.hass) - with async_timeout.timeout(_TIMEOUT, loop=self.hass.loop): response = yield from websession.get(self._url) diff --git a/homeassistant/components/doorbird.py b/homeassistant/components/doorbird.py index dcf99fe2933..56933d198f2 100644 --- a/homeassistant/components/doorbird.py +++ b/homeassistant/components/doorbird.py @@ -1,40 +1,54 @@ -"""Support for a DoorBird video doorbell.""" +""" +Support for DoorBird device. +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/doorbird/ +""" +import asyncio import logging + import voluptuous as vol from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD +from homeassistant.components.http import HomeAssistantView import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['DoorBirdPy==0.1.0'] +REQUIREMENTS = ['DoorBirdPy==0.1.2'] _LOGGER = logging.getLogger(__name__) DOMAIN = 'doorbird' +API_URL = '/api/{}'.format(DOMAIN) + +CONF_DOORBELL_EVENTS = 'doorbell_events' + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_DOORBELL_EVENTS): cv.boolean, }) }, extra=vol.ALLOW_EXTRA) +SENSOR_DOORBELL = 'doorbell' + def setup(hass, config): """Set up the DoorBird component.""" + from doorbirdpy import DoorBird + device_ip = config[DOMAIN].get(CONF_HOST) username = config[DOMAIN].get(CONF_USERNAME) password = config[DOMAIN].get(CONF_PASSWORD) - from doorbirdpy import DoorBird device = DoorBird(device_ip, username, password) status = device.ready() if status[0]: _LOGGER.info("Connected to DoorBird at %s as %s", device_ip, username) hass.data[DOMAIN] = device - return True elif status[1] == 401: _LOGGER.error("Authorization rejected by DoorBird at %s", device_ip) return False @@ -42,3 +56,31 @@ def setup(hass, config): _LOGGER.error("Could not connect to DoorBird at %s: Error %s", device_ip, str(status[1])) return False + + if config[DOMAIN].get(CONF_DOORBELL_EVENTS): + # Provide an endpoint for the device to call to trigger events + hass.http.register_view(DoorbirdRequestView()) + + # This will make HA the only service that gets doorbell events + url = '{}{}/{}'.format( + hass.config.api.base_url, API_URL, SENSOR_DOORBELL) + device.reset_notifications() + device.subscribe_notification(SENSOR_DOORBELL, url) + + return True + + +class DoorbirdRequestView(HomeAssistantView): + """Provide a page for the device to call.""" + + url = API_URL + name = API_URL[1:].replace('/', ':') + extra_urls = [API_URL + '/{sensor}'] + + # pylint: disable=no-self-use + @asyncio.coroutine + def get(self, request, sensor): + """Respond to requests from the device.""" + hass = request.app['hass'] + hass.bus.async_fire('{}_{}'.format(DOMAIN, sensor)) + return 'OK' diff --git a/requirements_all.txt b/requirements_all.txt index f3807d2e656..ece92a73cca 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -20,7 +20,7 @@ certifi>=2017.4.17 # Adafruit_BBIO==1.0.0 # homeassistant.components.doorbird -DoorBirdPy==0.1.0 +DoorBirdPy==0.1.2 # homeassistant.components.isy994 PyISY==1.1.0 From b98e03b5bc55cc22abbae26d6ac2258b15c06715 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 29 Dec 2017 10:06:25 +0100 Subject: [PATCH 103/238] Upgrade aiohttp to 2.3.7 (#11329) --- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 3080160dfce..d6fd579ae18 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -5,7 +5,7 @@ pip>=8.0.3 jinja2>=2.9.6 voluptuous==0.10.5 typing>=3,<4 -aiohttp==2.3.6 +aiohttp==2.3.7 yarl==0.16.0 async_timeout==2.0.0 chardet==3.0.4 diff --git a/requirements_all.txt b/requirements_all.txt index ece92a73cca..2c00fc9954d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -6,7 +6,7 @@ pip>=8.0.3 jinja2>=2.9.6 voluptuous==0.10.5 typing>=3,<4 -aiohttp==2.3.6 +aiohttp==2.3.7 yarl==0.16.0 async_timeout==2.0.0 chardet==3.0.4 diff --git a/setup.py b/setup.py index fe60a15e32e..56396c598ef 100755 --- a/setup.py +++ b/setup.py @@ -53,7 +53,7 @@ REQUIRES = [ 'jinja2>=2.9.6', 'voluptuous==0.10.5', 'typing>=3,<4', - 'aiohttp==2.3.6', # If updated, check if yarl also needs an update! + 'aiohttp==2.3.7', # If updated, check if yarl also needs an update! 'yarl==0.16.0', 'async_timeout==2.0.0', 'chardet==3.0.4', From 391a8901c8104de68cccb976a370077f7bef3399 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 29 Dec 2017 10:06:39 +0100 Subject: [PATCH 104/238] Upgrade fuzzywuzzy to 0.16.0 (#11331) --- homeassistant/components/conversation.py | 23 ++++++++++++----------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/conversation.py b/homeassistant/components/conversation.py index 064428c010c..5187b4782ef 100644 --- a/homeassistant/components/conversation.py +++ b/homeassistant/components/conversation.py @@ -12,20 +12,27 @@ import warnings import voluptuous as vol from homeassistant import core -from homeassistant.loader import bind_hass +from homeassistant.components import http from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON) -from homeassistant.helpers import intent, config_validation as cv -from homeassistant.components import http +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import intent +from homeassistant.loader import bind_hass +REQUIREMENTS = ['fuzzywuzzy==0.16.0'] -REQUIREMENTS = ['fuzzywuzzy==0.15.1'] -DEPENDENCIES = ['http'] +_LOGGER = logging.getLogger(__name__) ATTR_TEXT = 'text' + +DEPENDENCIES = ['http'] DOMAIN = 'conversation' +INTENT_TURN_OFF = 'HassTurnOff' +INTENT_TURN_ON = 'HassTurnOn' + REGEX_TURN_COMMAND = re.compile(r'turn (?P(?: |\w)+) (?P\w+)') +REGEX_TYPE = type(re.compile('')) SERVICE_PROCESS = 'process' @@ -39,12 +46,6 @@ CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({ }) })}, extra=vol.ALLOW_EXTRA) -INTENT_TURN_ON = 'HassTurnOn' -INTENT_TURN_OFF = 'HassTurnOff' -REGEX_TYPE = type(re.compile('')) - -_LOGGER = logging.getLogger(__name__) - @core.callback @bind_hass diff --git a/requirements_all.txt b/requirements_all.txt index 2c00fc9954d..ccfc6e79118 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -289,7 +289,7 @@ fritzhome==1.0.3 fsapi==0.0.7 # homeassistant.components.conversation -fuzzywuzzy==0.15.1 +fuzzywuzzy==0.16.0 # homeassistant.components.tts.google gTTS-token==1.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b9e027b1103..018e91f2b0a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -59,7 +59,7 @@ evohomeclient==0.2.5 feedparser==5.2.1 # homeassistant.components.conversation -fuzzywuzzy==0.15.1 +fuzzywuzzy==0.16.0 # homeassistant.components.tts.google gTTS-token==1.1.1 From d7e52d8014c3a8a55c2eeb1d7d9d35a229dc1248 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 29 Dec 2017 10:06:52 +0100 Subject: [PATCH 105/238] Upgrade pyowm to 2.8.0 (#11332) --- .../components/sensor/openweathermap.py | 12 ++++++------ .../components/weather/openweathermap.py | 16 +++++++++------- requirements_all.txt | 2 +- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/sensor/openweathermap.py b/homeassistant/components/sensor/openweathermap.py index 2072251c205..43c7d1ec2df 100755 --- a/homeassistant/components/sensor/openweathermap.py +++ b/homeassistant/components/sensor/openweathermap.py @@ -4,20 +4,20 @@ Support for the OpenWeatherMap (OWM) service. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.openweathermap/ """ -import logging from datetime import timedelta +import logging import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_API_KEY, CONF_NAME, TEMP_CELSIUS, TEMP_FAHRENHEIT, - CONF_MONITORED_CONDITIONS, ATTR_ATTRIBUTION) + ATTR_ATTRIBUTION, CONF_API_KEY, CONF_MONITORED_CONDITIONS, CONF_NAME, + TEMP_CELSIUS, TEMP_FAHRENHEIT) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['pyowm==2.7.1'] +REQUIREMENTS = ['pyowm==2.8.0'] _LOGGER = logging.getLogger(__name__) @@ -53,12 +53,12 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the OpenWeatherMap sensor.""" + from pyowm import OWM + if None in (hass.config.latitude, hass.config.longitude): _LOGGER.error("Latitude or longitude not set in Home Assistant config") return False - from pyowm import OWM - SENSOR_TYPES['temperature'][1] = hass.config.units.temperature_unit name = config.get(CONF_NAME) diff --git a/homeassistant/components/weather/openweathermap.py b/homeassistant/components/weather/openweathermap.py index a50e160cddb..1ff5eeaa535 100644 --- a/homeassistant/components/weather/openweathermap.py +++ b/homeassistant/components/weather/openweathermap.py @@ -4,27 +4,29 @@ Support for the OpenWeatherMap (OWM) service. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/weather.openweathermap/ """ -import logging from datetime import timedelta +import logging import voluptuous as vol from homeassistant.components.weather import ( - WeatherEntity, PLATFORM_SCHEMA, ATTR_FORECAST_TEMP, ATTR_FORECAST_TIME) -from homeassistant.const import (CONF_API_KEY, CONF_NAME, CONF_LATITUDE, - CONF_LONGITUDE, STATE_UNKNOWN, TEMP_CELSIUS) + ATTR_FORECAST_TEMP, ATTR_FORECAST_TIME, PLATFORM_SCHEMA, WeatherEntity) +from homeassistant.const import ( + CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, STATE_UNKNOWN, + TEMP_CELSIUS) import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle -REQUIREMENTS = ['pyowm==2.7.1'] +REQUIREMENTS = ['pyowm==2.8.0'] _LOGGER = logging.getLogger(__name__) -DEFAULT_NAME = 'OpenWeatherMap' ATTRIBUTION = 'Data provided by OpenWeatherMap' -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10) +DEFAULT_NAME = 'OpenWeatherMap' + MIN_TIME_BETWEEN_FORECAST_UPDATES = timedelta(minutes=30) +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10) CONDITION_CLASSES = { 'cloudy': [804], diff --git a/requirements_all.txt b/requirements_all.txt index ccfc6e79118..7e112202424 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -797,7 +797,7 @@ pyotp==2.2.6 # homeassistant.components.sensor.openweathermap # homeassistant.components.weather.openweathermap -pyowm==2.7.1 +pyowm==2.8.0 # homeassistant.components.qwikswitch pyqwikswitch==0.4 From 5a4bca978088554d4e92f16a4420713af7864983 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 29 Dec 2017 10:07:04 +0100 Subject: [PATCH 106/238] Upgrade sqlalchemy to 1.2.0 (#11333) --- homeassistant/components/recorder/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index f8ae9e9d0be..4dc38971e9f 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -36,7 +36,7 @@ from . import purge, migration from .const import DATA_INSTANCE from .util import session_scope -REQUIREMENTS = ['sqlalchemy==1.1.15'] +REQUIREMENTS = ['sqlalchemy==1.2.0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 7e112202424..059079e7ad7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1072,7 +1072,7 @@ speedtest-cli==1.0.7 # homeassistant.components.recorder # homeassistant.scripts.db_migrator -sqlalchemy==1.1.15 +sqlalchemy==1.2.0 # homeassistant.components.statsd statsd==3.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 018e91f2b0a..e1db9106fa4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -166,7 +166,7 @@ somecomfort==0.5.0 # homeassistant.components.recorder # homeassistant.scripts.db_migrator -sqlalchemy==1.1.15 +sqlalchemy==1.2.0 # homeassistant.components.statsd statsd==3.2.1 From 1f8acb49bc625916700e2c9eee80666072a8a93d Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 29 Dec 2017 10:07:25 +0100 Subject: [PATCH 107/238] Upgrade mypy to 0.560 (#11334) --- requirements_test.txt | 2 +- requirements_test_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements_test.txt b/requirements_test.txt index 3edfa168f79..f224a6f5594 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -3,7 +3,7 @@ # new version flake8==3.3 pylint==1.6.5 -mypy==0.550 +mypy==0.560 pydocstyle==1.1.1 coveralls>=1.1 pytest>=2.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e1db9106fa4..543ba3f00a7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -4,7 +4,7 @@ # new version flake8==3.3 pylint==1.6.5 -mypy==0.550 +mypy==0.560 pydocstyle==1.1.1 coveralls>=1.1 pytest>=2.9.2 From a7ebba6863158c28e6e6ecec49baddfc63ff1d8d Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 29 Dec 2017 10:08:14 +0100 Subject: [PATCH 108/238] Upgrade python-telegram-bot to 9.0.0 (#11341) --- homeassistant/components/telegram_bot/__init__.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index dc9389b1144..1e4d1d27042 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -24,7 +24,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.exceptions import TemplateError from homeassistant.setup import async_prepare_setup_platform -REQUIREMENTS = ['python-telegram-bot==8.1.1'] +REQUIREMENTS = ['python-telegram-bot==9.0.0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 059079e7ad7..3ad1dbb8c3e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -907,7 +907,7 @@ python-synology==0.1.0 python-tado==0.2.2 # homeassistant.components.telegram_bot -python-telegram-bot==8.1.1 +python-telegram-bot==9.0.0 # homeassistant.components.sensor.twitch python-twitch==1.3.0 From b635637541ae2c6969199b840d8a943625b46632 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 29 Dec 2017 10:16:18 +0100 Subject: [PATCH 109/238] Upgrade youtube_dl to 2017.12.28 (#11357) --- homeassistant/components/media_extractor.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/media_extractor.py b/homeassistant/components/media_extractor.py index d10dd955a93..4b8522c62b3 100644 --- a/homeassistant/components/media_extractor.py +++ b/homeassistant/components/media_extractor.py @@ -16,7 +16,7 @@ from homeassistant.components.media_player import ( from homeassistant.config import load_yaml_config_file from homeassistant.helpers import config_validation as cv -REQUIREMENTS = ['youtube_dl==2017.12.23'] +REQUIREMENTS = ['youtube_dl==2017.12.28'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 3ad1dbb8c3e..78a8e6537b9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1202,7 +1202,7 @@ yeelight==0.3.3 yeelightsunflower==0.0.8 # homeassistant.components.media_extractor -youtube_dl==2017.12.23 +youtube_dl==2017.12.28 # homeassistant.components.light.zengge zengge==0.2 From 2a2e6b633474f85baf597824dcd83b556582f653 Mon Sep 17 00:00:00 2001 From: Dan Nixon Date: Fri, 29 Dec 2017 12:13:15 +0000 Subject: [PATCH 110/238] Correct units used in TP-Link energy monioring (#10979) * Correct units used in TP-Link energy monioring - Energy is measured in kWh for swtches - Power is reported in mW for bulbs - Energy is reported in Wh for bulbs * TP-Ling energy: store units in attribute names Stores the unit in the attrbute names for TP-Link devices that support energy monitoring. --- homeassistant/components/light/tplink.py | 22 +++++++++--------- homeassistant/components/switch/tplink.py | 28 +++++++++++------------ 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/light/tplink.py b/homeassistant/components/light/tplink.py index 3b49e3fb0f7..8f513f73f1e 100644 --- a/homeassistant/components/light/tplink.py +++ b/homeassistant/components/light/tplink.py @@ -26,9 +26,9 @@ REQUIREMENTS = ['pyHS100==0.3.0'] _LOGGER = logging.getLogger(__name__) -ATTR_CURRENT_CONSUMPTION = 'current_consumption' -ATTR_DAILY_CONSUMPTION = 'daily_consumption' -ATTR_MONTHLY_CONSUMPTION = 'monthly_consumption' +ATTR_CURRENT_POWER_W = 'current_power_w' +ATTR_DAILY_ENERGY_KWH = 'daily_energy_kwh' +ATTR_MONTHLY_ENERGY_KWH = 'monthly_energy_kwh' DEFAULT_NAME = 'TP-Link Light' @@ -166,17 +166,17 @@ class TPLinkSmartBulb(Light): if self._supported_features & SUPPORT_RGB_COLOR: self._rgb = hsv_to_rgb(self.smartbulb.hsv) if self.smartbulb.has_emeter: - self._emeter_params[ATTR_CURRENT_CONSUMPTION] \ - = "%.1f W" % self.smartbulb.current_consumption() + self._emeter_params[ATTR_CURRENT_POWER_W] = '{:.1f}'.format( + self.smartbulb.current_consumption() / 1e3) daily_statistics = self.smartbulb.get_emeter_daily() monthly_statistics = self.smartbulb.get_emeter_monthly() try: - self._emeter_params[ATTR_DAILY_CONSUMPTION] \ - = "%.2f kW" % daily_statistics[int( - time.strftime("%d"))] - self._emeter_params[ATTR_MONTHLY_CONSUMPTION] \ - = "%.2f kW" % monthly_statistics[int( - time.strftime("%m"))] + self._emeter_params[ATTR_DAILY_ENERGY_KWH] \ + = "{:.3f}".format( + daily_statistics[int(time.strftime("%d"))] / 1e3) + self._emeter_params[ATTR_MONTHLY_ENERGY_KWH] \ + = "{:.3f}".format( + monthly_statistics[int(time.strftime("%m"))] / 1e3) except KeyError: # device returned no daily/monthly history pass diff --git a/homeassistant/components/switch/tplink.py b/homeassistant/components/switch/tplink.py index a03e30821b3..f43d434a259 100644 --- a/homeassistant/components/switch/tplink.py +++ b/homeassistant/components/switch/tplink.py @@ -9,7 +9,8 @@ import time import voluptuous as vol -from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) +from homeassistant.components.switch import ( + SwitchDevice, PLATFORM_SCHEMA, ATTR_CURRENT_POWER_W, ATTR_TODAY_ENERGY_KWH) from homeassistant.const import (CONF_HOST, CONF_NAME, ATTR_VOLTAGE) import homeassistant.helpers.config_validation as cv @@ -17,10 +18,8 @@ REQUIREMENTS = ['pyHS100==0.3.0'] _LOGGER = logging.getLogger(__name__) -ATTR_CURRENT_CONSUMPTION = 'current_consumption' -ATTR_TOTAL_CONSUMPTION = 'total_consumption' -ATTR_DAILY_CONSUMPTION = 'daily_consumption' -ATTR_CURRENT = 'current' +ATTR_TOTAL_ENERGY_KWH = 'total_energy_kwh' +ATTR_CURRENT_A = 'current_a' CONF_LEDS = 'enable_leds' @@ -102,19 +101,20 @@ class SmartPlugSwitch(SwitchDevice): if self.smartplug.has_emeter: emeter_readings = self.smartplug.get_emeter_realtime() - self._emeter_params[ATTR_CURRENT_CONSUMPTION] \ - = "%.1f W" % emeter_readings["power"] - self._emeter_params[ATTR_TOTAL_CONSUMPTION] \ - = "%.2f kW" % emeter_readings["total"] + self._emeter_params[ATTR_CURRENT_POWER_W] \ + = "{:.2f}".format(emeter_readings["power"]) + self._emeter_params[ATTR_TOTAL_ENERGY_KWH] \ + = "{:.3f}".format(emeter_readings["total"]) self._emeter_params[ATTR_VOLTAGE] \ - = "%.2f V" % emeter_readings["voltage"] - self._emeter_params[ATTR_CURRENT] \ - = "%.1f A" % emeter_readings["current"] + = "{:.1f}".format(emeter_readings["voltage"]) + self._emeter_params[ATTR_CURRENT_A] \ + = "{:.2f}".format(emeter_readings["current"]) emeter_statics = self.smartplug.get_emeter_daily() try: - self._emeter_params[ATTR_DAILY_CONSUMPTION] \ - = "%.2f kW" % emeter_statics[int(time.strftime("%e"))] + self._emeter_params[ATTR_TODAY_ENERGY_KWH] \ + = "{:.3f}".format( + emeter_statics[int(time.strftime("%e"))]) except KeyError: # Device returned no daily history pass From cfd78f7b029977771df6df44d12381feb11f00e5 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 29 Dec 2017 05:46:10 -0800 Subject: [PATCH 111/238] Add HTTP endpoint for resending email confirmation (#11354) --- homeassistant/components/cloud/auth_api.py | 15 ++++++++ homeassistant/components/cloud/http_api.py | 24 +++++++++++++ tests/components/cloud/test_auth_api.py | 16 +++++++++ tests/components/cloud/test_http_api.py | 42 ++++++++++++++++++++++ 4 files changed, 97 insertions(+) diff --git a/homeassistant/components/cloud/auth_api.py b/homeassistant/components/cloud/auth_api.py index 9cad3ec77f3..0ca0451e565 100644 --- a/homeassistant/components/cloud/auth_api.py +++ b/homeassistant/components/cloud/auth_api.py @@ -95,6 +95,21 @@ def confirm_register(cloud, confirmation_code, email): raise _map_aws_exception(err) +def resend_email_confirm(cloud, email): + """Resend email confirmation.""" + from botocore.exceptions import ClientError + + cognito = _cognito(cloud, username=email) + + try: + cognito.client.resend_confirmation_code( + Username=email, + ClientId=cognito.client_id + ) + except ClientError as err: + raise _map_aws_exception(err) + + def forgot_password(cloud, email): """Initiate forgotten password flow.""" from botocore.exceptions import ClientError diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 27fd6f604c0..25873ba158c 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -23,6 +23,7 @@ def async_setup(hass): hass.http.register_view(CloudAccountView) hass.http.register_view(CloudRegisterView) hass.http.register_view(CloudConfirmRegisterView) + hass.http.register_view(CloudResendConfirmView) hass.http.register_view(CloudForgotPasswordView) hass.http.register_view(CloudConfirmForgotPasswordView) @@ -172,6 +173,29 @@ class CloudConfirmRegisterView(HomeAssistantView): return self.json_message('ok') +class CloudResendConfirmView(HomeAssistantView): + """Resend email confirmation code.""" + + url = '/api/cloud/resend_confirm' + name = 'api:cloud:resend_confirm' + + @_handle_cloud_errors + @RequestDataValidator(vol.Schema({ + vol.Required('email'): str, + })) + @asyncio.coroutine + def post(self, request, data): + """Handle resending confirm email code request.""" + hass = request.app['hass'] + cloud = hass.data[DOMAIN] + + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + yield from hass.async_add_job( + auth_api.resend_email_confirm, cloud, data['email']) + + return self.json_message('ok') + + class CloudForgotPasswordView(HomeAssistantView): """View to start Forgot Password flow..""" diff --git a/tests/components/cloud/test_auth_api.py b/tests/components/cloud/test_auth_api.py index f94c2691cd7..bb28dfc50e3 100644 --- a/tests/components/cloud/test_auth_api.py +++ b/tests/components/cloud/test_auth_api.py @@ -119,6 +119,22 @@ def test_confirm_register_fails(mock_cognito): auth_api.confirm_register(cloud, '123456', 'email@home-assistant.io') +def test_resend_email_confirm(mock_cognito): + """Test starting forgot password flow.""" + cloud = MagicMock() + auth_api.resend_email_confirm(cloud, 'email@home-assistant.io') + assert len(mock_cognito.client.resend_confirmation_code.mock_calls) == 1 + + +def test_resend_email_confirm_fails(mock_cognito): + """Test failure when starting forgot password flow.""" + cloud = MagicMock() + mock_cognito.client.resend_confirmation_code.side_effect = \ + aws_error('SomeError') + with pytest.raises(auth_api.CloudError): + auth_api.resend_email_confirm(cloud, 'email@home-assistant.io') + + def test_forgot_password(mock_cognito): """Test starting forgot password flow.""" cloud = MagicMock() diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 423ca1092eb..2c71f504c50 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -315,6 +315,48 @@ def test_forgot_password_view_unknown_error(mock_cognito, cloud_client): assert req.status == 502 +@asyncio.coroutine +def test_resend_confirm_view(mock_cognito, cloud_client): + """Test logging out.""" + req = yield from cloud_client.post('/api/cloud/resend_confirm', json={ + 'email': 'hello@bla.com', + }) + assert req.status == 200 + assert len(mock_cognito.client.resend_confirmation_code.mock_calls) == 1 + + +@asyncio.coroutine +def test_resend_confirm_view_bad_data(mock_cognito, cloud_client): + """Test logging out.""" + req = yield from cloud_client.post('/api/cloud/resend_confirm', json={ + 'not_email': 'hello@bla.com', + }) + assert req.status == 400 + assert len(mock_cognito.client.resend_confirmation_code.mock_calls) == 0 + + +@asyncio.coroutine +def test_resend_confirm_view_request_timeout(mock_cognito, cloud_client): + """Test timeout while logging out.""" + mock_cognito.client.resend_confirmation_code.side_effect = \ + asyncio.TimeoutError + req = yield from cloud_client.post('/api/cloud/resend_confirm', json={ + 'email': 'hello@bla.com', + }) + assert req.status == 502 + + +@asyncio.coroutine +def test_resend_confirm_view_unknown_error(mock_cognito, cloud_client): + """Test unknown error while logging out.""" + mock_cognito.client.resend_confirmation_code.side_effect = \ + auth_api.UnknownError + req = yield from cloud_client.post('/api/cloud/resend_confirm', json={ + 'email': 'hello@bla.com', + }) + assert req.status == 502 + + @asyncio.coroutine def test_confirm_forgot_password_view(mock_cognito, cloud_client): """Test logging out.""" From f07a4684e0cd7d74650a2f42d432abcd52a58eb9 Mon Sep 17 00:00:00 2001 From: Dan Nixon Date: Fri, 29 Dec 2017 14:28:20 +0000 Subject: [PATCH 112/238] Fix RGB template ordering in MQTT Light (#11362) * Use different colour channel intensities in tests Uses a different value for each colour channel in MQTT light tests to properly differentiate between colour channels. * Correct ordering of RGB channels in MQTT light --- homeassistant/components/light/mqtt.py | 2 +- tests/components/light/test_mqtt.py | 6 +++--- tests/components/light/test_mqtt_template.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/light/mqtt.py b/homeassistant/components/light/mqtt.py index 1e5c0f743bb..0348af664a5 100644 --- a/homeassistant/components/light/mqtt.py +++ b/homeassistant/components/light/mqtt.py @@ -424,7 +424,7 @@ class MqttLight(Light): tpl = self._templates[CONF_RGB_COMMAND_TEMPLATE] if tpl: - colors = {'red', 'green', 'blue'} + colors = ('red', 'green', 'blue') variables = {key: val for key, val in zip(colors, kwargs[ATTR_RGB_COLOR])} rgb_color_str = tpl.async_render(variables) diff --git a/tests/components/light/test_mqtt.py b/tests/components/light/test_mqtt.py index db7c35107d8..b074c5d84d8 100644 --- a/tests/components/light/test_mqtt.py +++ b/tests/components/light/test_mqtt.py @@ -546,17 +546,17 @@ class TestLightMQTT(unittest.TestCase): state = self.hass.states.get('light.test') self.assertEqual(STATE_OFF, state.state) - light.turn_on(self.hass, 'light.test', rgb_color=[255, 255, 255]) + light.turn_on(self.hass, 'light.test', rgb_color=[255, 128, 64]) self.hass.block_till_done() self.mock_publish().async_publish.assert_has_calls([ mock.call('test_light_rgb/set', 'on', 0, False), - mock.call('test_light_rgb/rgb/set', '#ffffff', 0, False), + mock.call('test_light_rgb/rgb/set', '#ff8040', 0, False), ], any_order=True) state = self.hass.states.get('light.test') self.assertEqual(STATE_ON, state.state) - self.assertEqual((255, 255, 255), state.attributes['rgb_color']) + self.assertEqual((255, 128, 64), state.attributes['rgb_color']) def test_show_brightness_if_only_command_topic(self): """Test the brightness if only a command topic is present.""" diff --git a/tests/components/light/test_mqtt_template.py b/tests/components/light/test_mqtt_template.py index a28d862bf53..4cda6fc64de 100755 --- a/tests/components/light/test_mqtt_template.py +++ b/tests/components/light/test_mqtt_template.py @@ -145,12 +145,12 @@ class TestLightMQTTTemplate(unittest.TestCase): # turn on the light, full white fire_mqtt_message(self.hass, 'test_light_rgb', - 'on,255,145,123,255-255-255,') + 'on,255,145,123,255-128-64,') self.hass.block_till_done() state = self.hass.states.get('light.test') self.assertEqual(STATE_ON, state.state) - self.assertEqual([255, 255, 255], state.attributes.get('rgb_color')) + self.assertEqual([255, 128, 64], state.attributes.get('rgb_color')) self.assertEqual(255, state.attributes.get('brightness')) self.assertEqual(145, state.attributes.get('color_temp')) self.assertEqual(123, state.attributes.get('white_value')) From 4914ad1dd9453e88b6290f0879a9c16408394443 Mon Sep 17 00:00:00 2001 From: tschmidty69 Date: Fri, 29 Dec 2017 10:18:39 -0500 Subject: [PATCH 113/238] Ping device tracker now respects interval_seconds (#11348) * Ping device tracker now respects interval_seconds --- .../components/device_tracker/ping.py | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/device_tracker/ping.py b/homeassistant/components/device_tracker/ping.py index 36f1ea06fd6..6a0cb18d55e 100644 --- a/homeassistant/components/device_tracker/ping.py +++ b/homeassistant/components/device_tracker/ping.py @@ -13,8 +13,8 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.device_tracker import ( - PLATFORM_SCHEMA, DEFAULT_SCAN_INTERVAL, SOURCE_TYPE_ROUTER) -from homeassistant.helpers.event import track_point_in_utc_time + PLATFORM_SCHEMA, CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL, + SOURCE_TYPE_ROUTER) from homeassistant import util from homeassistant import const @@ -70,16 +70,21 @@ def setup_scanner(hass, config, see, discovery_info=None): """Set up the Host objects and return the update function.""" hosts = [Host(ip, dev_id, hass, config) for (dev_id, ip) in config[const.CONF_HOSTS].items()] - interval = timedelta(seconds=len(hosts) * config[CONF_PING_COUNT]) + \ - DEFAULT_SCAN_INTERVAL - _LOGGER.info("Started ping tracker with interval=%s on hosts: %s", - interval, ",".join([host.ip_address for host in hosts])) + interval = config.get(CONF_SCAN_INTERVAL, + timedelta(seconds=len(hosts) * + config[CONF_PING_COUNT]) + + DEFAULT_SCAN_INTERVAL) + _LOGGER.debug("Started ping tracker with interval=%s on hosts: %s", + interval, ",".join([host.ip_address for host in hosts])) - def update(now): + def update_interval(now): """Update all the hosts on every interval time.""" - for host in hosts: - host.update(see) - track_point_in_utc_time(hass, update, util.dt.utcnow() + interval) - return True + try: + for host in hosts: + host.update(see) + finally: + hass.helpers.event.track_point_in_utc_time( + update_interval, util.dt.utcnow() + interval) - return update(util.dt.utcnow()) + update_interval(None) + return True From ba0f7a41010da7ce4372c3631b2753fb64108caa Mon Sep 17 00:00:00 2001 From: Thibault Cohen Date: Fri, 29 Dec 2017 12:33:11 -0500 Subject: [PATCH 114/238] Fido component use now asyncio (#11244) * Fido component use now asyncio * Fix comments * Fix comments 2 * Fix assertion for test error * Update to pyfido 2.1.0 --- .coveragerc | 1 - homeassistant/components/sensor/fido.py | 42 ++++----- requirements_all.txt | 2 +- tests/components/sensor/test_fido.py | 109 ++++++++++++++++++++++++ 4 files changed, 133 insertions(+), 21 deletions(-) create mode 100644 tests/components/sensor/test_fido.py diff --git a/.coveragerc b/.coveragerc index 6d2997a8274..dede973976e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -520,7 +520,6 @@ omit = homeassistant/components/sensor/etherscan.py homeassistant/components/sensor/fastdotcom.py homeassistant/components/sensor/fedex.py - homeassistant/components/sensor/fido.py homeassistant/components/sensor/fitbit.py homeassistant/components/sensor/fixer.py homeassistant/components/sensor/fritzbox_callmonitor.py diff --git a/homeassistant/components/sensor/fido.py b/homeassistant/components/sensor/fido.py index c4f4217616f..07c085cd18d 100644 --- a/homeassistant/components/sensor/fido.py +++ b/homeassistant/components/sensor/fido.py @@ -7,10 +7,10 @@ https://www.fido.ca/pages/#/my-account/wireless For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.fido/ """ +import asyncio import logging from datetime import timedelta -import requests import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA @@ -21,7 +21,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pyfido==1.0.1'] +REQUIREMENTS = ['pyfido==2.1.0'] _LOGGER = logging.getLogger(__name__) @@ -70,17 +70,17 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -def setup_platform(hass, config, add_devices, discovery_info=None): +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the Fido sensor.""" username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) - try: - fido_data = FidoData(username, password) - fido_data.update() - except requests.exceptions.HTTPError as error: - _LOGGER.error("Failt login: %s", error) - return False + httpsession = hass.helpers.aiohttp_client.async_get_clientsession() + fido_data = FidoData(username, password, httpsession) + ret = yield from fido_data.async_update() + if ret is False: + return name = config.get(CONF_NAME) @@ -89,7 +89,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): for variable in config[CONF_MONITORED_VARIABLES]: sensors.append(FidoSensor(fido_data, variable, name, number)) - add_devices(sensors, True) + async_add_devices(sensors, True) class FidoSensor(Entity): @@ -133,9 +133,10 @@ class FidoSensor(Entity): 'number': self._number, } - def update(self): + @asyncio.coroutine + def async_update(self): """Get the latest data from Fido and update the state.""" - self.fido_data.update() + yield from self.fido_data.async_update() if self.type == 'balance': if self.fido_data.data.get(self.type) is not None: self._state = round(self.fido_data.data[self.type], 2) @@ -149,20 +150,23 @@ class FidoSensor(Entity): class FidoData(object): """Get data from Fido.""" - def __init__(self, username, password): + def __init__(self, username, password, httpsession): """Initialize the data object.""" from pyfido import FidoClient - self.client = FidoClient(username, password, REQUESTS_TIMEOUT) + self.client = FidoClient(username, password, + REQUESTS_TIMEOUT, httpsession) self.data = {} + @asyncio.coroutine @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): + def async_update(self): """Get the latest data from Fido.""" from pyfido.client import PyFidoError try: - self.client.fetch_data() - except PyFidoError as err: - _LOGGER.error("Error on receive last Fido data: %s", err) - return + yield from self.client.fetch_data() + except PyFidoError as exp: + _LOGGER.error("Error on receive last Fido data: %s", exp) + return False # Update data self.data = self.client.get_data() + return True diff --git a/requirements_all.txt b/requirements_all.txt index 78a8e6537b9..c1e7bccdd3f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -686,7 +686,7 @@ pyenvisalink==2.2 pyephember==0.1.1 # homeassistant.components.sensor.fido -pyfido==1.0.1 +pyfido==2.1.0 # homeassistant.components.climate.flexit pyflexit==0.3 diff --git a/tests/components/sensor/test_fido.py b/tests/components/sensor/test_fido.py new file mode 100644 index 00000000000..1eca7be7544 --- /dev/null +++ b/tests/components/sensor/test_fido.py @@ -0,0 +1,109 @@ +"""The test for the fido sensor platform.""" +import asyncio +import logging +import sys +from unittest.mock import MagicMock + +from homeassistant.bootstrap import async_setup_component +from homeassistant.components.sensor import fido +from tests.common import assert_setup_component + + +CONTRACT = "123456789" + + +class FidoClientMock(): + """Fake Fido client.""" + + def __init__(self, username, password, timeout=None, httpsession=None): + """Fake Fido client init.""" + pass + + def get_phone_numbers(self): + """Return Phone numbers.""" + return ["1112223344"] + + def get_data(self): + """Return fake fido data.""" + return {"balance": 160.12, + "1112223344": {"data_remaining": 100.33}} + + @asyncio.coroutine + def fetch_data(self): + """Return fake fetching data.""" + pass + + +class FidoClientMockError(FidoClientMock): + """Fake Fido client error.""" + + @asyncio.coroutine + def fetch_data(self): + """Return fake fetching data.""" + raise PyFidoErrorMock("Fake Error") + + +class PyFidoErrorMock(Exception): + """Fake PyFido Error.""" + + +class PyFidoClientFakeModule(): + """Fake pyfido.client module.""" + + PyFidoError = PyFidoErrorMock + + +class PyFidoFakeModule(): + """Fake pyfido module.""" + + FidoClient = FidoClientMockError + + +def fake_async_add_devices(component, update_before_add=False): + """Fake async_add_devices function.""" + pass + + +@asyncio.coroutine +def test_fido_sensor(loop, hass): + """Test the Fido number sensor.""" + sys.modules['pyfido'] = MagicMock() + sys.modules['pyfido.client'] = MagicMock() + sys.modules['pyfido.client.PyFidoError'] = \ + PyFidoErrorMock + import pyfido.client + pyfido.FidoClient = FidoClientMock + pyfido.client.PyFidoError = PyFidoErrorMock + config = { + 'sensor': { + 'platform': 'fido', + 'name': 'fido', + 'username': 'myusername', + 'password': 'password', + 'monitored_variables': [ + 'balance', + 'data_remaining', + ], + } + } + with assert_setup_component(1): + yield from async_setup_component(hass, 'sensor', config) + state = hass.states.get('sensor.fido_1112223344_balance') + assert state.state == "160.12" + assert state.attributes.get('number') == "1112223344" + state = hass.states.get('sensor.fido_1112223344_data_remaining') + assert state.state == "100.33" + + +@asyncio.coroutine +def test_error(hass, caplog): + """Test the Fido sensor errors.""" + caplog.set_level(logging.ERROR) + sys.modules['pyfido'] = PyFidoFakeModule() + sys.modules['pyfido.client'] = PyFidoClientFakeModule() + + config = {} + fake_async_add_devices = MagicMock() + yield from fido.async_setup_platform(hass, config, + fake_async_add_devices) + assert fake_async_add_devices.called is False From 49bc95549ba6f14238f47f52a5f448de4522f97f Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 29 Dec 2017 18:44:06 +0100 Subject: [PATCH 115/238] Don't block on sevice call for alexa (#11358) * Don't block on sevice call for alexa * fix tests --- homeassistant/components/alexa/smart_home.py | 76 ++++++++++---------- tests/components/alexa/test_smart_home.py | 26 +++++++ 2 files changed, 66 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index 2443e52b766..d303ca57704 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -249,7 +249,7 @@ def async_api_turn_on(hass, config, request, entity): yield from hass.services.async_call(domain, service, { ATTR_ENTITY_ID: entity.entity_id - }, blocking=True) + }, blocking=False) return api_message(request) @@ -269,7 +269,7 @@ def async_api_turn_off(hass, config, request, entity): yield from hass.services.async_call(domain, service, { ATTR_ENTITY_ID: entity.entity_id - }, blocking=True) + }, blocking=False) return api_message(request) @@ -284,7 +284,7 @@ def async_api_set_brightness(hass, config, request, entity): yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, { ATTR_ENTITY_ID: entity.entity_id, light.ATTR_BRIGHTNESS_PCT: brightness, - }, blocking=True) + }, blocking=False) return api_message(request) @@ -308,7 +308,7 @@ def async_api_adjust_brightness(hass, config, request, entity): yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, { ATTR_ENTITY_ID: entity.entity_id, light.ATTR_BRIGHTNESS_PCT: brightness, - }, blocking=True) + }, blocking=False) return api_message(request) @@ -329,14 +329,14 @@ def async_api_set_color(hass, config, request, entity): yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, { ATTR_ENTITY_ID: entity.entity_id, light.ATTR_RGB_COLOR: rgb, - }, blocking=True) + }, blocking=False) else: xyz = color_util.color_RGB_to_xy(*rgb) yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, { ATTR_ENTITY_ID: entity.entity_id, light.ATTR_XY_COLOR: (xyz[0], xyz[1]), light.ATTR_BRIGHTNESS: xyz[2], - }, blocking=True) + }, blocking=False) return api_message(request) @@ -351,7 +351,7 @@ def async_api_set_color_temperature(hass, config, request, entity): yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, { ATTR_ENTITY_ID: entity.entity_id, light.ATTR_KELVIN: kelvin, - }, blocking=True) + }, blocking=False) return api_message(request) @@ -369,7 +369,7 @@ def async_api_decrease_color_temp(hass, config, request, entity): yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, { ATTR_ENTITY_ID: entity.entity_id, light.ATTR_COLOR_TEMP: value, - }, blocking=True) + }, blocking=False) return api_message(request) @@ -387,7 +387,7 @@ def async_api_increase_color_temp(hass, config, request, entity): yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, { ATTR_ENTITY_ID: entity.entity_id, light.ATTR_COLOR_TEMP: value, - }, blocking=True) + }, blocking=False) return api_message(request) @@ -399,7 +399,7 @@ def async_api_activate(hass, config, request, entity): """Process a activate request.""" yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, { ATTR_ENTITY_ID: entity.entity_id - }, blocking=True) + }, blocking=False) return api_message(request) @@ -429,8 +429,8 @@ def async_api_set_percentage(hass, config, request, entity): service = SERVICE_SET_COVER_POSITION data[cover.ATTR_POSITION] = percentage - yield from hass.services.async_call(entity.domain, service, - data, blocking=True) + yield from hass.services.async_call( + entity.domain, service, data, blocking=False) return api_message(request) @@ -477,8 +477,8 @@ def async_api_adjust_percentage(hass, config, request, entity): data[cover.ATTR_POSITION] = max(0, percentage_delta + current) - yield from hass.services.async_call(entity.domain, service, - data, blocking=True) + yield from hass.services.async_call( + entity.domain, service, data, blocking=False) return api_message(request) @@ -490,7 +490,7 @@ def async_api_lock(hass, config, request, entity): """Process a lock request.""" yield from hass.services.async_call(entity.domain, SERVICE_LOCK, { ATTR_ENTITY_ID: entity.entity_id - }, blocking=True) + }, blocking=False) return api_message(request) @@ -503,7 +503,7 @@ def async_api_unlock(hass, config, request, entity): """Process a unlock request.""" yield from hass.services.async_call(entity.domain, SERVICE_UNLOCK, { ATTR_ENTITY_ID: entity.entity_id - }, blocking=True) + }, blocking=False) return api_message(request) @@ -520,8 +520,9 @@ def async_api_set_volume(hass, config, request, entity): media_player.ATTR_MEDIA_VOLUME_LEVEL: volume, } - yield from hass.services.async_call(entity.domain, SERVICE_VOLUME_SET, - data, blocking=True) + yield from hass.services.async_call( + entity.domain, SERVICE_VOLUME_SET, + data, blocking=False) return api_message(request) @@ -548,9 +549,9 @@ def async_api_adjust_volume(hass, config, request, entity): media_player.ATTR_MEDIA_VOLUME_LEVEL: volume, } - yield from hass.services.async_call(entity.domain, - media_player.SERVICE_VOLUME_SET, - data, blocking=True) + yield from hass.services.async_call( + entity.domain, media_player.SERVICE_VOLUME_SET, + data, blocking=False) return api_message(request) @@ -567,9 +568,9 @@ def async_api_set_mute(hass, config, request, entity): media_player.ATTR_MEDIA_VOLUME_MUTED: mute, } - yield from hass.services.async_call(entity.domain, - media_player.SERVICE_VOLUME_MUTE, - data, blocking=True) + yield from hass.services.async_call( + entity.domain, media_player.SERVICE_VOLUME_MUTE, + data, blocking=False) return api_message(request) @@ -583,8 +584,9 @@ def async_api_play(hass, config, request, entity): ATTR_ENTITY_ID: entity.entity_id } - yield from hass.services.async_call(entity.domain, SERVICE_MEDIA_PLAY, - data, blocking=True) + yield from hass.services.async_call( + entity.domain, SERVICE_MEDIA_PLAY, + data, blocking=False) return api_message(request) @@ -598,8 +600,9 @@ def async_api_pause(hass, config, request, entity): ATTR_ENTITY_ID: entity.entity_id } - yield from hass.services.async_call(entity.domain, SERVICE_MEDIA_PAUSE, - data, blocking=True) + yield from hass.services.async_call( + entity.domain, SERVICE_MEDIA_PAUSE, + data, blocking=False) return api_message(request) @@ -613,8 +616,9 @@ def async_api_stop(hass, config, request, entity): ATTR_ENTITY_ID: entity.entity_id } - yield from hass.services.async_call(entity.domain, SERVICE_MEDIA_STOP, - data, blocking=True) + yield from hass.services.async_call( + entity.domain, SERVICE_MEDIA_STOP, + data, blocking=False) return api_message(request) @@ -628,9 +632,9 @@ def async_api_next(hass, config, request, entity): ATTR_ENTITY_ID: entity.entity_id } - yield from hass.services.async_call(entity.domain, - SERVICE_MEDIA_NEXT_TRACK, - data, blocking=True) + yield from hass.services.async_call( + entity.domain, SERVICE_MEDIA_NEXT_TRACK, + data, blocking=False) return api_message(request) @@ -644,8 +648,8 @@ def async_api_previous(hass, config, request, entity): ATTR_ENTITY_ID: entity.entity_id } - yield from hass.services.async_call(entity.domain, - SERVICE_MEDIA_PREVIOUS_TRACK, - data, blocking=True) + yield from hass.services.async_call( + entity.domain, SERVICE_MEDIA_PREVIOUS_TRACK, + data, blocking=False) return api_message(request) diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 924931ec21c..baa05ed0994 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -346,6 +346,7 @@ def test_exclude_filters(hass): )) msg = yield from smart_home.async_handle_message(hass, config, request) + yield from hass.async_block_till_done() msg = msg['event'] @@ -378,6 +379,7 @@ def test_include_filters(hass): )) msg = yield from smart_home.async_handle_message(hass, config, request) + yield from hass.async_block_till_done() msg = msg['event'] @@ -393,6 +395,7 @@ def test_api_entity_not_exists(hass): msg = yield from smart_home.async_handle_message( hass, DEFAULT_CONFIG, request) + yield from hass.async_block_till_done() assert 'event' in msg msg = msg['event'] @@ -445,6 +448,7 @@ def test_api_turn_on(hass, domain): msg = yield from smart_home.async_handle_message( hass, DEFAULT_CONFIG, request) + yield from hass.async_block_till_done() assert 'event' in msg msg = msg['event'] @@ -481,6 +485,7 @@ def test_api_turn_off(hass, domain): msg = yield from smart_home.async_handle_message( hass, DEFAULT_CONFIG, request) + yield from hass.async_block_till_done() assert 'event' in msg msg = msg['event'] @@ -507,6 +512,7 @@ def test_api_set_brightness(hass): msg = yield from smart_home.async_handle_message( hass, DEFAULT_CONFIG, request) + yield from hass.async_block_till_done() assert 'event' in msg msg = msg['event'] @@ -538,6 +544,7 @@ def test_api_adjust_brightness(hass, result, adjust): msg = yield from smart_home.async_handle_message( hass, DEFAULT_CONFIG, request) + yield from hass.async_block_till_done() assert 'event' in msg msg = msg['event'] @@ -572,6 +579,7 @@ def test_api_set_color_rgb(hass): msg = yield from smart_home.async_handle_message( hass, DEFAULT_CONFIG, request) + yield from hass.async_block_till_done() assert 'event' in msg msg = msg['event'] @@ -606,6 +614,7 @@ def test_api_set_color_xy(hass): msg = yield from smart_home.async_handle_message( hass, DEFAULT_CONFIG, request) + yield from hass.async_block_till_done() assert 'event' in msg msg = msg['event'] @@ -635,6 +644,7 @@ def test_api_set_color_temperature(hass): msg = yield from smart_home.async_handle_message( hass, DEFAULT_CONFIG, request) + yield from hass.async_block_till_done() assert 'event' in msg msg = msg['event'] @@ -664,6 +674,7 @@ def test_api_decrease_color_temp(hass, result, initial): msg = yield from smart_home.async_handle_message( hass, DEFAULT_CONFIG, request) + yield from hass.async_block_till_done() assert 'event' in msg msg = msg['event'] @@ -693,6 +704,7 @@ def test_api_increase_color_temp(hass, result, initial): msg = yield from smart_home.async_handle_message( hass, DEFAULT_CONFIG, request) + yield from hass.async_block_till_done() assert 'event' in msg msg = msg['event'] @@ -720,6 +732,7 @@ def test_api_activate(hass, domain): msg = yield from smart_home.async_handle_message( hass, DEFAULT_CONFIG, request) + yield from hass.async_block_till_done() assert 'event' in msg msg = msg['event'] @@ -746,6 +759,7 @@ def test_api_set_percentage_fan(hass): msg = yield from smart_home.async_handle_message( hass, DEFAULT_CONFIG, request) + yield from hass.async_block_till_done() assert 'event' in msg msg = msg['event'] @@ -775,6 +789,7 @@ def test_api_set_percentage_cover(hass): msg = yield from smart_home.async_handle_message( hass, DEFAULT_CONFIG, request) + yield from hass.async_block_till_done() assert 'event' in msg msg = msg['event'] @@ -806,6 +821,7 @@ def test_api_adjust_percentage_fan(hass, result, adjust): msg = yield from smart_home.async_handle_message( hass, DEFAULT_CONFIG, request) + yield from hass.async_block_till_done() assert 'event' in msg msg = msg['event'] @@ -838,6 +854,7 @@ def test_api_adjust_percentage_cover(hass, result, adjust): msg = yield from smart_home.async_handle_message( hass, DEFAULT_CONFIG, request) + yield from hass.async_block_till_done() assert 'event' in msg msg = msg['event'] @@ -865,6 +882,7 @@ def test_api_lock(hass, domain): msg = yield from smart_home.async_handle_message( hass, DEFAULT_CONFIG, request) + yield from hass.async_block_till_done() assert 'event' in msg msg = msg['event'] @@ -891,6 +909,7 @@ def test_api_play(hass, domain): msg = yield from smart_home.async_handle_message( hass, DEFAULT_CONFIG, request) + yield from hass.async_block_till_done() assert 'event' in msg msg = msg['event'] @@ -917,6 +936,7 @@ def test_api_pause(hass, domain): msg = yield from smart_home.async_handle_message( hass, DEFAULT_CONFIG, request) + yield from hass.async_block_till_done() assert 'event' in msg msg = msg['event'] @@ -943,6 +963,7 @@ def test_api_stop(hass, domain): msg = yield from smart_home.async_handle_message( hass, DEFAULT_CONFIG, request) + yield from hass.async_block_till_done() assert 'event' in msg msg = msg['event'] @@ -969,6 +990,7 @@ def test_api_next(hass, domain): msg = yield from smart_home.async_handle_message( hass, DEFAULT_CONFIG, request) + yield from hass.async_block_till_done() assert 'event' in msg msg = msg['event'] @@ -995,6 +1017,7 @@ def test_api_previous(hass, domain): msg = yield from smart_home.async_handle_message( hass, DEFAULT_CONFIG, request) + yield from hass.async_block_till_done() assert 'event' in msg msg = msg['event'] @@ -1023,6 +1046,7 @@ def test_api_set_volume(hass): msg = yield from smart_home.async_handle_message( hass, DEFAULT_CONFIG, request) + yield from hass.async_block_till_done() assert 'event' in msg msg = msg['event'] @@ -1054,6 +1078,7 @@ def test_api_adjust_volume(hass, result, adjust): msg = yield from smart_home.async_handle_message( hass, DEFAULT_CONFIG, request) + yield from hass.async_block_till_done() assert 'event' in msg msg = msg['event'] @@ -1083,6 +1108,7 @@ def test_api_mute(hass, domain): msg = yield from smart_home.async_handle_message( hass, DEFAULT_CONFIG, request) + yield from hass.async_block_till_done() assert 'event' in msg msg = msg['event'] From 3fd620198eb8ee61d67b7dbd983cbbae5d7afbb8 Mon Sep 17 00:00:00 2001 From: William Scanlon Date: Fri, 29 Dec 2017 13:05:58 -0500 Subject: [PATCH 116/238] Support for EcoNet water heaters (#11260) * Support for EcoNet water heaters. * Fixed requested changes. * Added logging when temp or operation mode are None * More fixes from PR review. * Updated pyeconet version to fix natural gas water heater error. Last PR review fix. --- .coveragerc | 1 + homeassistant/components/climate/econet.py | 235 ++++++++++++++++++ .../components/climate/services.yaml | 20 ++ requirements_all.txt | 3 + 4 files changed, 259 insertions(+) create mode 100644 homeassistant/components/climate/econet.py diff --git a/.coveragerc b/.coveragerc index dede973976e..0876aa0d7b7 100644 --- a/.coveragerc +++ b/.coveragerc @@ -296,6 +296,7 @@ omit = homeassistant/components/camera/rpi_camera.py homeassistant/components/camera/synology.py homeassistant/components/camera/yi.py + homeassistant/components/climate/econet.py homeassistant/components/climate/ephember.py homeassistant/components/climate/eq3btsmart.py homeassistant/components/climate/flexit.py diff --git a/homeassistant/components/climate/econet.py b/homeassistant/components/climate/econet.py new file mode 100644 index 00000000000..7cafcd816cb --- /dev/null +++ b/homeassistant/components/climate/econet.py @@ -0,0 +1,235 @@ +""" +Support for Rheem EcoNet water heaters. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.econet/ +""" +import datetime +import logging +from os import path + +import voluptuous as vol + +from homeassistant.components.climate import ( + DOMAIN, + PLATFORM_SCHEMA, + STATE_ECO, STATE_GAS, STATE_ELECTRIC, + STATE_HEAT_PUMP, STATE_HIGH_DEMAND, + STATE_OFF, SUPPORT_TARGET_TEMPERATURE, + SUPPORT_OPERATION_MODE, + ClimateDevice) +from homeassistant.config import load_yaml_config_file +from homeassistant.const import (ATTR_ENTITY_ID, + CONF_PASSWORD, CONF_USERNAME, TEMP_FAHRENHEIT, + ATTR_TEMPERATURE) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['pyeconet==0.0.4'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_VACATION_START = 'next_vacation_start_date' +ATTR_VACATION_END = 'next_vacation_end_date' +ATTR_ON_VACATION = 'on_vacation' +ATTR_TODAYS_ENERGY_USAGE = 'todays_energy_usage' +ATTR_IN_USE = 'in_use' + +ATTR_START_DATE = 'start_date' +ATTR_END_DATE = 'end_date' + +SUPPORT_FLAGS_HEATER = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE) + +SERVICE_ADD_VACATION = 'econet_add_vacation' +SERVICE_DELETE_VACATION = 'econet_delete_vacation' + +ADD_VACATION_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Optional(ATTR_START_DATE): cv.positive_int, + vol.Required(ATTR_END_DATE): cv.positive_int, +}) + +DELETE_VACATION_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, +}) + +ECONET_DATA = 'econet' + +HA_STATE_TO_ECONET = { + STATE_ECO: 'Energy Saver', + STATE_ELECTRIC: 'Electric', + STATE_HEAT_PUMP: 'Heat Pump', + STATE_GAS: 'gas', + STATE_HIGH_DEMAND: 'High Demand', + STATE_OFF: 'Off', +} + +ECONET_STATE_TO_HA = {value: key for key, value in HA_STATE_TO_ECONET.items()} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the EcoNet water heaters.""" + from pyeconet.api import PyEcoNet + + hass.data[ECONET_DATA] = {} + hass.data[ECONET_DATA]['water_heaters'] = [] + + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + + econet = PyEcoNet(username, password) + water_heaters = econet.get_water_heaters() + hass_water_heaters = [ + EcoNetWaterHeater(water_heater) for water_heater in water_heaters] + add_devices(hass_water_heaters) + hass.data[ECONET_DATA]['water_heaters'].extend(hass_water_heaters) + + def service_handle(service): + """Handler for services.""" + entity_ids = service.data.get('entity_id') + all_heaters = hass.data[ECONET_DATA]['water_heaters'] + _heaters = [ + x for x in all_heaters + if not entity_ids or x.entity_id in entity_ids] + + for _water_heater in _heaters: + if service.service == SERVICE_ADD_VACATION: + start = service.data.get(ATTR_START_DATE) + end = service.data.get(ATTR_END_DATE) + _water_heater.add_vacation(start, end) + if service.service == SERVICE_DELETE_VACATION: + for vacation in _water_heater.water_heater.vacations: + vacation.delete() + + _water_heater.schedule_update_ha_state(True) + + descriptions = load_yaml_config_file( + path.join(path.dirname(__file__), 'services.yaml')) + + hass.services.register(DOMAIN, SERVICE_ADD_VACATION, + service_handle, + descriptions.get(SERVICE_ADD_VACATION), + schema=ADD_VACATION_SCHEMA) + + hass.services.register(DOMAIN, SERVICE_DELETE_VACATION, + service_handle, + descriptions.get(SERVICE_DELETE_VACATION), + schema=DELETE_VACATION_SCHEMA) + + +class EcoNetWaterHeater(ClimateDevice): + """Representation of an EcoNet water heater.""" + + def __init__(self, water_heater): + """Initialize the water heater.""" + self.water_heater = water_heater + + @property + def name(self): + """Return the device name.""" + return self.water_heater.name + + @property + def available(self): + """Return if the the device is online or not.""" + return self.water_heater.is_connected + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_FAHRENHEIT + + @property + def device_state_attributes(self): + """Return the optional state attributes.""" + data = {} + vacations = self.water_heater.get_vacations() + if vacations: + data[ATTR_VACATION_START] = vacations[0].start_date + data[ATTR_VACATION_END] = vacations[0].end_date + data[ATTR_ON_VACATION] = self.water_heater.is_on_vacation + todays_usage = self.water_heater.total_usage_for_today + if todays_usage: + data[ATTR_TODAYS_ENERGY_USAGE] = todays_usage + data[ATTR_IN_USE] = self.water_heater.in_use + + return data + + @property + def current_operation(self): + """ + Return current operation as one of the following. + + ["eco", "heat_pump", + "high_demand", "electric_only"] + """ + current_op = ECONET_STATE_TO_HA.get(self.water_heater.mode) + return current_op + + @property + def operation_list(self): + """List of available operation modes.""" + op_list = [] + modes = self.water_heater.supported_modes + for mode in modes: + ha_mode = ECONET_STATE_TO_HA.get(mode) + if ha_mode is not None: + op_list.append(ha_mode) + else: + error = "Invalid operation mode mapping. " + mode + \ + " doesn't map. Please report this." + _LOGGER.error(error) + return op_list + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS_HEATER + + def set_temperature(self, **kwargs): + """Set new target temperature.""" + target_temp = kwargs.get(ATTR_TEMPERATURE) + if target_temp is not None: + self.water_heater.set_target_set_point(target_temp) + else: + _LOGGER.error("A target temperature must be provided.") + + def set_operation_mode(self, operation_mode): + """Set operation mode.""" + op_mode_to_set = HA_STATE_TO_ECONET.get(operation_mode) + if op_mode_to_set is not None: + self.water_heater.set_mode(op_mode_to_set) + else: + _LOGGER.error("An operation mode must be provided.") + + def add_vacation(self, start, end): + """Add a vacation to this water heater.""" + if not start: + start = datetime.datetime.now() + else: + start = datetime.datetime.fromtimestamp(start) + end = datetime.datetime.fromtimestamp(end) + self.water_heater.set_vacation_mode(start, end) + + def update(self): + """Get the latest date.""" + self.water_heater.update_state() + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self.water_heater.set_point + + @property + def min_temp(self): + """Return the minimum temperature.""" + return self.water_heater.min_set_point + + @property + def max_temp(self): + """Return the maximum temperature.""" + return self.water_heater.max_set_point diff --git a/homeassistant/components/climate/services.yaml b/homeassistant/components/climate/services.yaml index 5d7f30d252d..5edbf438328 100644 --- a/homeassistant/components/climate/services.yaml +++ b/homeassistant/components/climate/services.yaml @@ -107,3 +107,23 @@ nuheat_resume_program: entity_id: description: Name(s) of entities to change. example: 'climate.kitchen' + +econet_add_vacation: + description: Add a vacation to your water heater. + fields: + entity_id: + description: Name(s) of entities to change. + example: 'climate.water_heater' + start_date: + description: The timestamp of when the vacation should start. (Optional, defaults to now) + example: 1513186320 + end_date: + description: The timestamp of when the vacation should end. + example: 1513445520 + +econet_delete_vacation: + description: Delete your existing vacation from your water heater. + fields: + entity_id: + description: Name(s) of entities to change. + example: 'climate.water_heater' diff --git a/requirements_all.txt b/requirements_all.txt index c1e7bccdd3f..8fd24eeeb1c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -673,6 +673,9 @@ pydroid-ipcam==0.8 # homeassistant.components.sensor.ebox pyebox==0.1.0 +# homeassistant.components.climate.econet +pyeconet==0.0.4 + # homeassistant.components.eight_sleep pyeight==0.0.7 From 7759ab6919373bf1a2dd89b2780bbc31c0163210 Mon Sep 17 00:00:00 2001 From: ChristianKuehnel Date: Fri, 29 Dec 2017 19:20:36 +0100 Subject: [PATCH 117/238] Remember the Milk - updating and completing tasks (#11069) * Remember the Milk - updating and completing tasks Added new feature so that tasks can be updated and completed. For this feature a task id must be set when creating the task. * fixed hould complaints * fixed review comments by @MartinHjelmare * removed unnecessary check as proposed by @MartinHjelmare --- .../components/remember_the_milk/__init__.py | 116 ++++++++++++++++-- .../remember_the_milk/services.yaml | 19 ++- tests/components/test_remember_the_milk.py | 38 +++++- 3 files changed, 161 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/remember_the_milk/__init__.py b/homeassistant/components/remember_the_milk/__init__.py index 4a788297c60..aa3ca4b4543 100644 --- a/homeassistant/components/remember_the_milk/__init__.py +++ b/homeassistant/components/remember_the_milk/__init__.py @@ -15,7 +15,8 @@ import json import voluptuous as vol from homeassistant.config import load_yaml_config_file -from homeassistant.const import (CONF_API_KEY, STATE_OK, CONF_TOKEN, CONF_NAME) +from homeassistant.const import (CONF_API_KEY, STATE_OK, CONF_TOKEN, + CONF_NAME, CONF_ID) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent @@ -31,6 +32,10 @@ DEFAULT_NAME = DOMAIN GROUP_NAME_RTM = 'remember the milk accounts' CONF_SHARED_SECRET = 'shared_secret' +CONF_ID_MAP = 'id_map' +CONF_LIST_ID = 'list_id' +CONF_TIMESERIES_ID = 'timeseries_id' +CONF_TASK_ID = 'task_id' RTM_SCHEMA = vol.Schema({ vol.Required(CONF_NAME): cv.string, @@ -44,9 +49,15 @@ CONFIG_SCHEMA = vol.Schema({ CONFIG_FILE_NAME = '.remember_the_milk.conf' SERVICE_CREATE_TASK = 'create_task' +SERVICE_COMPLETE_TASK = 'complete_task' SERVICE_SCHEMA_CREATE_TASK = vol.Schema({ vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_ID): cv.string, +}) + +SERVICE_SCHEMA_COMPLETE_TASK = vol.Schema({ + vol.Required(CONF_ID): cv.string, }) @@ -84,10 +95,14 @@ def _create_instance(hass, account_name, api_key, shared_secret, entity = RememberTheMilk(account_name, api_key, shared_secret, token, stored_rtm_config) component.add_entity(entity) - hass.services.async_register( + hass.services.register( DOMAIN, '{}_create_task'.format(account_name), entity.create_task, description=descriptions.get(SERVICE_CREATE_TASK), schema=SERVICE_SCHEMA_CREATE_TASK) + hass.services.register( + DOMAIN, '{}_complete_task'.format(account_name), entity.complete_task, + description=descriptions.get(SERVICE_COMPLETE_TASK), + schema=SERVICE_SCHEMA_COMPLETE_TASK) def _register_new_account(hass, account_name, api_key, shared_secret, @@ -168,8 +183,7 @@ class RememberTheMilkConfiguration(object): def set_token(self, profile_name, token): """Store a new server token for a profile.""" - if profile_name not in self._config: - self._config[profile_name] = dict() + self._initialize_profile(profile_name) self._config[profile_name][CONF_TOKEN] = token self.save_config() @@ -181,6 +195,44 @@ class RememberTheMilkConfiguration(object): self._config.pop(profile_name, None) self.save_config() + def _initialize_profile(self, profile_name): + """Initialize the data structures for a profile.""" + if profile_name not in self._config: + self._config[profile_name] = dict() + if CONF_ID_MAP not in self._config[profile_name]: + self._config[profile_name][CONF_ID_MAP] = dict() + + def get_rtm_id(self, profile_name, hass_id): + """Get the rtm ids for a home assistant task id. + + The id of a rtm tasks consists of the tuple: + list id, timeseries id and the task id. + """ + self._initialize_profile(profile_name) + ids = self._config[profile_name][CONF_ID_MAP].get(hass_id) + if ids is None: + return None + return ids[CONF_LIST_ID], ids[CONF_TIMESERIES_ID], ids[CONF_TASK_ID] + + def set_rtm_id(self, profile_name, hass_id, list_id, time_series_id, + rtm_task_id): + """Add/Update the rtm task id for a home assistant task id.""" + self._initialize_profile(profile_name) + id_tuple = { + CONF_LIST_ID: list_id, + CONF_TIMESERIES_ID: time_series_id, + CONF_TASK_ID: rtm_task_id, + } + self._config[profile_name][CONF_ID_MAP][hass_id] = id_tuple + self.save_config() + + def delete_rtm_id(self, profile_name, hass_id): + """Delete a key mapping.""" + self._initialize_profile(profile_name) + if hass_id in self._config[profile_name][CONF_ID_MAP]: + del self._config[profile_name][CONF_ID_MAP][hass_id] + self.save_config() + class RememberTheMilk(Entity): """MVP implementation of an interface to Remember The Milk.""" @@ -225,19 +277,65 @@ class RememberTheMilk(Entity): import rtmapi try: - task_name = call.data.get('name') + task_name = call.data.get(CONF_NAME) + hass_id = call.data.get(CONF_ID) + rtm_id = None + if hass_id is not None: + rtm_id = self._rtm_config.get_rtm_id(self._name, hass_id) result = self._rtm_api.rtm.timelines.create() timeline = result.timeline.value - self._rtm_api.rtm.tasks.add( - timeline=timeline, name=task_name, parse='1') - _LOGGER.debug('created new task "%s" in account %s', - task_name, self.name) + + if hass_id is None or rtm_id is None: + result = self._rtm_api.rtm.tasks.add( + timeline=timeline, name=task_name, parse='1') + _LOGGER.debug('created new task "%s" in account %s', + task_name, self.name) + self._rtm_config.set_rtm_id(self._name, + hass_id, + result.list.id, + result.list.taskseries.id, + result.list.taskseries.task.id) + else: + self._rtm_api.rtm.tasks.setName(name=task_name, + list_id=rtm_id[0], + taskseries_id=rtm_id[1], + task_id=rtm_id[2], + timeline=timeline) + _LOGGER.debug('updated task with id "%s" in account ' + '%s to name %s', + hass_id, self.name, task_name) except rtmapi.RtmRequestFailedException as rtm_exception: _LOGGER.error('Error creating new Remember The Milk task for ' 'account %s: %s', self._name, rtm_exception) return False return True + def complete_task(self, call): + """Complete a task that was previously created by this component.""" + import rtmapi + + hass_id = call.data.get(CONF_ID) + rtm_id = self._rtm_config.get_rtm_id(self._name, hass_id) + if rtm_id is None: + _LOGGER.error('Could not find task with id %s in account %s. ' + 'So task could not be closed.', + hass_id, self._name) + return False + try: + result = self._rtm_api.rtm.timelines.create() + timeline = result.timeline.value + self._rtm_api.rtm.tasks.complete(list_id=rtm_id[0], + taskseries_id=rtm_id[1], + task_id=rtm_id[2], + timeline=timeline) + self._rtm_config.delete_rtm_id(self._name, hass_id) + _LOGGER.debug('Completed task with id %s in account %s', + hass_id, self._name) + except rtmapi.RtmRequestFailedException as rtm_exception: + _LOGGER.error('Error creating new Remember The Milk task for ' + 'account %s: %s', self._name, rtm_exception) + return True + @property def name(self): """Return the name of the device.""" diff --git a/homeassistant/components/remember_the_milk/services.yaml b/homeassistant/components/remember_the_milk/services.yaml index ebf242013f1..74a2c3a4d4f 100644 --- a/homeassistant/components/remember_the_milk/services.yaml +++ b/homeassistant/components/remember_the_milk/services.yaml @@ -1,9 +1,24 @@ # Describes the format for available Remember The Milk services create_task: - description: Create a new task in your Remember The Milk account + description: > + Create (or update) a new task in your Remember The Milk account. If you want to update a task + later on, you have to set an "id" when creating the task. + Note: Updating a tasks does not support the smart syntax. fields: name: description: name of the new task, you can use the smart syntax here - example: 'do this ^today #from_hass' \ No newline at end of file + example: 'do this ^today #from_hass' + + id: + description: (optional) identifier for the task you're creating, can be used to update or complete the task later on + example: myid + +complete_task: + description: Complete a tasks that was privously created. + + fields: + id: + description: identifier that was defined when creating the task + example: myid \ No newline at end of file diff --git a/tests/components/test_remember_the_milk.py b/tests/components/test_remember_the_milk.py index b59c840d765..65e7cd73c1f 100644 --- a/tests/components/test_remember_the_milk.py +++ b/tests/components/test_remember_the_milk.py @@ -1,6 +1,7 @@ """Tests for the Remember The Milk component.""" import logging +import json import unittest from unittest.mock import patch, mock_open, Mock @@ -19,7 +20,16 @@ class TestConfiguration(unittest.TestCase): self.hass = get_test_home_assistant() self.profile = "myprofile" self.token = "mytoken" - self.json_string = '{"myprofile": {"token": "mytoken"}}' + self.json_string = json.dumps( + {"myprofile": { + "token": "mytoken", + "id_map": {"1234": { + "list_id": "0", + "timeseries_id": "1", + "task_id": "2" + }} + } + }) def tearDown(self): """Exit home assistant.""" @@ -47,3 +57,29 @@ class TestConfiguration(unittest.TestCase): patch("os.path.isfile", Mock(return_value=True)): config = rtm.RememberTheMilkConfiguration(self.hass) self.assertIsNotNone(config) + + def test_id_map(self): + """Test the hass to rtm task is mapping.""" + hass_id = "hass-id-1234" + list_id = "mylist" + timeseries_id = "my_timeseries" + rtm_id = "rtm-id-4567" + with patch("builtins.open", mock_open()), \ + patch("os.path.isfile", Mock(return_value=False)): + config = rtm.RememberTheMilkConfiguration(self.hass) + + self.assertEqual(None, config.get_rtm_id(self.profile, hass_id)) + config.set_rtm_id(self.profile, hass_id, list_id, timeseries_id, + rtm_id) + self.assertEqual((list_id, timeseries_id, rtm_id), + config.get_rtm_id(self.profile, hass_id)) + config.delete_rtm_id(self.profile, hass_id) + self.assertEqual(None, config.get_rtm_id(self.profile, hass_id)) + + def test_load_key_map(self): + """Test loading an existing key map from the file.""" + with patch("builtins.open", mock_open(read_data=self.json_string)), \ + patch("os.path.isfile", Mock(return_value=True)): + config = rtm.RememberTheMilkConfiguration(self.hass) + self.assertEqual(('0', '1', '2',), + config.get_rtm_id(self.profile, "1234")) From fcbf7abdaa00bfdf2dbb76f1890be790991f17b1 Mon Sep 17 00:00:00 2001 From: Dan Nixon Date: Sun, 31 Dec 2017 14:58:22 +0000 Subject: [PATCH 118/238] Reverts unit conversions in TP-Link bulb (#11381) Reverts energy and power unit conversions added in #10979 as they break early devices. A proper fix should be implemented in the pyhs100 library which should return common units across all devices/firmwares. --- homeassistant/components/light/tplink.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/light/tplink.py b/homeassistant/components/light/tplink.py index 8f513f73f1e..87004f45ea0 100644 --- a/homeassistant/components/light/tplink.py +++ b/homeassistant/components/light/tplink.py @@ -167,16 +167,16 @@ class TPLinkSmartBulb(Light): self._rgb = hsv_to_rgb(self.smartbulb.hsv) if self.smartbulb.has_emeter: self._emeter_params[ATTR_CURRENT_POWER_W] = '{:.1f}'.format( - self.smartbulb.current_consumption() / 1e3) + self.smartbulb.current_consumption()) daily_statistics = self.smartbulb.get_emeter_daily() monthly_statistics = self.smartbulb.get_emeter_monthly() try: self._emeter_params[ATTR_DAILY_ENERGY_KWH] \ = "{:.3f}".format( - daily_statistics[int(time.strftime("%d"))] / 1e3) + daily_statistics[int(time.strftime("%d"))]) self._emeter_params[ATTR_MONTHLY_ENERGY_KWH] \ = "{:.3f}".format( - monthly_statistics[int(time.strftime("%m"))] / 1e3) + monthly_statistics[int(time.strftime("%m"))]) except KeyError: # device returned no daily/monthly history pass From fc8b25a71f51ed18f2ddba911a597d7bd293fc61 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 31 Dec 2017 15:04:49 -0800 Subject: [PATCH 119/238] Clean up Google Assistant (#11375) * Clean up Google Assistant * Fix tests --- .../components/google_assistant/__init__.py | 17 +- .../components/google_assistant/http.py | 157 ++++-------------- .../components/google_assistant/smart_home.py | 103 +++++++++++- .../google_assistant/test_google_assistant.py | 53 +++--- .../google_assistant/test_smart_home.py | 10 -- tests/test_util/aiohttp.py | 8 + 6 files changed, 165 insertions(+), 183 deletions(-) diff --git a/homeassistant/components/google_assistant/__init__.py b/homeassistant/components/google_assistant/__init__.py index 2db36d8829f..800b05b3b0f 100644 --- a/homeassistant/components/google_assistant/__init__.py +++ b/homeassistant/components/google_assistant/__init__.py @@ -15,7 +15,6 @@ import voluptuous as vol # Typing imports # pylint: disable=using-constant-test,unused-import,ungrouped-imports -# if False: from homeassistant.core import HomeAssistant # NOQA from typing import Dict, Any # NOQA @@ -26,12 +25,12 @@ from homeassistant.loader import bind_hass from .const import ( DOMAIN, CONF_PROJECT_ID, CONF_CLIENT_ID, CONF_ACCESS_TOKEN, - CONF_EXPOSE_BY_DEFAULT, CONF_EXPOSED_DOMAINS, - CONF_AGENT_USER_ID, CONF_API_KEY, + CONF_EXPOSE_BY_DEFAULT, DEFAULT_EXPOSE_BY_DEFAULT, CONF_EXPOSED_DOMAINS, + DEFAULT_EXPOSED_DOMAINS, CONF_AGENT_USER_ID, CONF_API_KEY, SERVICE_REQUEST_SYNC, REQUEST_SYNC_BASE_URL ) from .auth import GoogleAssistantAuthView -from .http import GoogleAssistantView +from .http import async_register_http _LOGGER = logging.getLogger(__name__) @@ -45,8 +44,10 @@ CONFIG_SCHEMA = vol.Schema( vol.Required(CONF_PROJECT_ID): cv.string, vol.Required(CONF_CLIENT_ID): cv.string, vol.Required(CONF_ACCESS_TOKEN): cv.string, - vol.Optional(CONF_EXPOSE_BY_DEFAULT): cv.boolean, - vol.Optional(CONF_EXPOSED_DOMAINS): cv.ensure_list, + vol.Optional(CONF_EXPOSE_BY_DEFAULT, + default=DEFAULT_EXPOSE_BY_DEFAULT): cv.boolean, + vol.Optional(CONF_EXPOSED_DOMAINS, + default=DEFAULT_EXPOSED_DOMAINS): cv.ensure_list, vol.Optional(CONF_AGENT_USER_ID, default=DEFAULT_AGENT_USER_ID): cv.string, vol.Optional(CONF_API_KEY): cv.string @@ -73,7 +74,7 @@ def async_setup(hass: HomeAssistant, yaml_config: Dict[str, Any]): os.path.dirname(__file__), 'services.yaml') ) hass.http.register_view(GoogleAssistantAuthView(hass, config)) - hass.http.register_view(GoogleAssistantView(hass, config)) + async_register_http(hass, config) @asyncio.coroutine def request_sync_service_handler(call): @@ -94,7 +95,7 @@ def async_setup(hass: HomeAssistant, yaml_config: Dict[str, Any]): except (asyncio.TimeoutError, aiohttp.ClientError): _LOGGER.error("Could not contact Google for request_sync") -# Register service only if api key is provided + # Register service only if api key is provided if api_key is not None: hass.services.async_register( DOMAIN, SERVICE_REQUEST_SYNC, request_sync_service_handler, diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index a9512404b1e..93c5b3d4f8e 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -7,53 +7,39 @@ https://home-assistant.io/components/google_assistant/ import asyncio import logging -from typing import Any, Dict # NOQA - from aiohttp.hdrs import AUTHORIZATION from aiohttp.web import Request, Response # NOQA +from homeassistant.const import HTTP_UNAUTHORIZED + # Typing imports # pylint: disable=using-constant-test,unused-import,ungrouped-imports -# if False: from homeassistant.components.http import HomeAssistantView -from homeassistant.const import HTTP_BAD_REQUEST, HTTP_UNAUTHORIZED -from homeassistant.core import HomeAssistant # NOQA +from homeassistant.core import HomeAssistant, callback # NOQA from homeassistant.helpers.entity import Entity # NOQA from .const import ( GOOGLE_ASSISTANT_API_ENDPOINT, CONF_ACCESS_TOKEN, - DEFAULT_EXPOSE_BY_DEFAULT, - DEFAULT_EXPOSED_DOMAINS, CONF_EXPOSE_BY_DEFAULT, CONF_EXPOSED_DOMAINS, ATTR_GOOGLE_ASSISTANT, CONF_AGENT_USER_ID ) -from .smart_home import entity_to_device, query_device, determine_service +from .smart_home import async_handle_message, Config _LOGGER = logging.getLogger(__name__) -class GoogleAssistantView(HomeAssistantView): - """Handle Google Assistant requests.""" +@callback +def async_register_http(hass, cfg): + """Register HTTP views for Google Assistant.""" + access_token = cfg.get(CONF_ACCESS_TOKEN) + expose_by_default = cfg.get(CONF_EXPOSE_BY_DEFAULT) + exposed_domains = cfg.get(CONF_EXPOSED_DOMAINS) + agent_user_id = cfg.get(CONF_AGENT_USER_ID) - url = GOOGLE_ASSISTANT_API_ENDPOINT - name = 'api:google_assistant' - requires_auth = False # Uses access token from oauth flow - - def __init__(self, hass: HomeAssistant, cfg: Dict[str, Any]) -> None: - """Initialize Google Assistant view.""" - super().__init__() - - self.access_token = cfg.get(CONF_ACCESS_TOKEN) - self.expose_by_default = cfg.get(CONF_EXPOSE_BY_DEFAULT, - DEFAULT_EXPOSE_BY_DEFAULT) - self.exposed_domains = cfg.get(CONF_EXPOSED_DOMAINS, - DEFAULT_EXPOSED_DOMAINS) - self.agent_user_id = cfg.get(CONF_AGENT_USER_ID) - - def is_entity_exposed(self, entity) -> bool: + def is_exposed(entity) -> bool: """Determine if an entity should be exposed to Google Assistant.""" if entity.attributes.get('view') is not None: # Ignore entities that are views @@ -63,7 +49,7 @@ class GoogleAssistantView(HomeAssistantView): explicit_expose = entity.attributes.get(ATTR_GOOGLE_ASSISTANT, None) domain_exposed_by_default = \ - self.expose_by_default and domain in self.exposed_domains + expose_by_default and domain in exposed_domains # Expose an entity if the entity's domain is exposed by default and # the configuration doesn't explicitly exclude it from being @@ -73,79 +59,22 @@ class GoogleAssistantView(HomeAssistantView): return is_default_exposed or explicit_expose - @asyncio.coroutine - def handle_sync(self, hass: HomeAssistant, request_id: str): - """Handle SYNC action.""" - devices = [] - for entity in hass.states.async_all(): - if not self.is_entity_exposed(entity): - continue + gass_config = Config(is_exposed, agent_user_id) + hass.http.register_view( + GoogleAssistantView(access_token, gass_config)) - device = entity_to_device(entity, hass.config.units) - if device is None: - _LOGGER.warning("No mapping for %s domain", entity.domain) - continue - devices.append(device) +class GoogleAssistantView(HomeAssistantView): + """Handle Google Assistant requests.""" - return self.json( - _make_actions_response(request_id, - {'agentUserId': self.agent_user_id, - 'devices': devices})) + url = GOOGLE_ASSISTANT_API_ENDPOINT + name = 'api:google_assistant' + requires_auth = False # Uses access token from oauth flow - @asyncio.coroutine - def handle_query(self, - hass: HomeAssistant, - request_id: str, - requested_devices: list): - """Handle the QUERY action.""" - devices = {} - for device in requested_devices: - devid = device.get('id') - # In theory this should never happpen - if not devid: - _LOGGER.error('Device missing ID: %s', device) - continue - - state = hass.states.get(devid) - if not state: - # If we can't find a state, the device is offline - devices[devid] = {'online': False} - - devices[devid] = query_device(state, hass.config.units) - - return self.json( - _make_actions_response(request_id, {'devices': devices})) - - @asyncio.coroutine - def handle_execute(self, - hass: HomeAssistant, - request_id: str, - requested_commands: list): - """Handle the EXECUTE action.""" - commands = [] - for command in requested_commands: - ent_ids = [ent.get('id') for ent in command.get('devices', [])] - for execution in command.get('execution'): - for eid in ent_ids: - success = False - domain = eid.split('.')[0] - (service, service_data) = determine_service( - eid, execution.get('command'), execution.get('params'), - hass.config.units) - if domain == "group": - domain = "homeassistant" - success = yield from hass.services.async_call( - domain, service, service_data, blocking=True) - result = {"ids": [eid], "states": {}} - if success: - result['status'] = 'SUCCESS' - else: - result['status'] = 'ERROR' - commands.append(result) - - return self.json( - _make_actions_response(request_id, {'commands': commands})) + def __init__(self, access_token, gass_config): + """Initialize the Google Assistant request handler.""" + self.access_token = access_token + self.gass_config = gass_config @asyncio.coroutine def post(self, request: Request) -> Response: @@ -155,35 +84,7 @@ class GoogleAssistantView(HomeAssistantView): return self.json_message( "missing authorization", status_code=HTTP_UNAUTHORIZED) - data = yield from request.json() # type: dict - - inputs = data.get('inputs') # type: list - if len(inputs) != 1: - _LOGGER.error('Too many inputs in request %d', len(inputs)) - return self.json_message( - "too many inputs", status_code=HTTP_BAD_REQUEST) - - request_id = data.get('requestId') # type: str - intent = inputs[0].get('intent') - payload = inputs[0].get('payload') - - hass = request.app['hass'] # type: HomeAssistant - res = None - if intent == 'action.devices.SYNC': - res = yield from self.handle_sync(hass, request_id) - elif intent == 'action.devices.QUERY': - res = yield from self.handle_query(hass, request_id, - payload.get('devices', [])) - elif intent == 'action.devices.EXECUTE': - res = yield from self.handle_execute(hass, request_id, - payload.get('commands', [])) - - if res: - return res - - return self.json_message( - "invalid intent", status_code=HTTP_BAD_REQUEST) - - -def _make_actions_response(request_id: str, payload: dict) -> dict: - return {'requestId': request_id, 'payload': payload} + message = yield from request.json() # type: dict + result = yield from async_handle_message( + request.app['hass'], self.gass_config, message) + return self.json(result) diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index 23876a068f9..9ba77434c47 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -1,4 +1,6 @@ """Support for Google Assistant Smart Home API.""" +import asyncio +from collections import namedtuple import logging # Typing imports @@ -10,6 +12,7 @@ from homeassistant.helpers.entity import Entity # NOQA from homeassistant.core import HomeAssistant # NOQA from homeassistant.util import color from homeassistant.util.unit_system import UnitSystem # NOQA +from homeassistant.util.decorator import Registry from homeassistant.const import ( ATTR_SUPPORTED_FEATURES, ATTR_ENTITY_ID, @@ -34,6 +37,7 @@ from .const import ( CONF_ALIASES, CLIMATE_SUPPORTED_MODES ) +HANDLERS = Registry() _LOGGER = logging.getLogger(__name__) # Mapping is [actions schema, primary trait, optional features] @@ -65,9 +69,7 @@ MAPPING_COMPONENT = { } # type: Dict[str, list] -def make_actions_response(request_id: str, payload: dict) -> dict: - """Make response message.""" - return {'requestId': request_id, 'payload': payload} +Config = namedtuple('GoogleAssistantConfig', 'should_expose,agent_user_id') def entity_to_device(entity: Entity, units: UnitSystem): @@ -286,3 +288,98 @@ def determine_service( return (SERVICE_TURN_OFF, service_data) return (None, service_data) + + +@asyncio.coroutine +def async_handle_message(hass, config, message): + """Handle incoming API messages.""" + request_id = message.get('requestId') # type: str + inputs = message.get('inputs') # type: list + + if len(inputs) > 1: + _LOGGER.warning('Got unexpected more than 1 input. %s', message) + + # Only use first input + intent = inputs[0].get('intent') + payload = inputs[0].get('payload') + + handler = HANDLERS.get(intent) + + if handler: + result = yield from handler(hass, config, payload) + else: + result = {'errorCode': 'protocolError'} + + return {'requestId': request_id, 'payload': result} + + +@HANDLERS.register('action.devices.SYNC') +@asyncio.coroutine +def async_devices_sync(hass, config, payload): + """Handle action.devices.SYNC request.""" + devices = [] + for entity in hass.states.async_all(): + if not config.should_expose(entity): + continue + + device = entity_to_device(entity, hass.config.units) + if device is None: + _LOGGER.warning("No mapping for %s domain", entity.domain) + continue + + devices.append(device) + + return { + 'agentUserId': config.agent_user_id, + 'devices': devices, + } + + +@HANDLERS.register('action.devices.QUERY') +@asyncio.coroutine +def async_devices_query(hass, config, payload): + """Handle action.devices.QUERY request.""" + devices = {} + for device in payload.get('devices', []): + devid = device.get('id') + # In theory this should never happpen + if not devid: + _LOGGER.error('Device missing ID: %s', device) + continue + + state = hass.states.get(devid) + if not state: + # If we can't find a state, the device is offline + devices[devid] = {'online': False} + + devices[devid] = query_device(state, hass.config.units) + + return {'devices': devices} + + +@HANDLERS.register('action.devices.EXECUTE') +@asyncio.coroutine +def handle_devices_execute(hass, config, payload): + """Handle action.devices.EXECUTE request.""" + commands = [] + for command in payload.get('commands', []): + ent_ids = [ent.get('id') for ent in command.get('devices', [])] + for execution in command.get('execution'): + for eid in ent_ids: + success = False + domain = eid.split('.')[0] + (service, service_data) = determine_service( + eid, execution.get('command'), execution.get('params'), + hass.config.units) + if domain == "group": + domain = "homeassistant" + success = yield from hass.services.async_call( + domain, service, service_data, blocking=True) + result = {"ids": [eid], "states": {}} + if success: + result['status'] = 'SUCCESS' + else: + result['status'] = 'ERROR' + commands.append(result) + + return {'commands': commands} diff --git a/tests/components/google_assistant/test_google_assistant.py b/tests/components/google_assistant/test_google_assistant.py index 05178649c88..ff6f53cf1a0 100644 --- a/tests/components/google_assistant/test_google_assistant.py +++ b/tests/components/google_assistant/test_google_assistant.py @@ -5,43 +5,41 @@ import json from aiohttp.hdrs import CONTENT_TYPE, AUTHORIZATION import pytest -from tests.common import get_test_instance_port from homeassistant import core, const, setup from homeassistant.components import ( - fan, http, cover, light, switch, climate, async_setup, media_player) + fan, cover, light, switch, climate, async_setup, media_player) from homeassistant.components import google_assistant as ga from homeassistant.util.unit_system import IMPERIAL_SYSTEM from . import DEMO_DEVICES API_PASSWORD = "test1234" -SERVER_PORT = get_test_instance_port() -BASE_API_URL = "http://127.0.0.1:{}".format(SERVER_PORT) HA_HEADERS = { const.HTTP_HEADER_HA_AUTH: API_PASSWORD, CONTENT_TYPE: const.CONTENT_TYPE_JSON, } -AUTHCFG = { - 'project_id': 'hasstest-1234', - 'client_id': 'helloworld', - 'access_token': 'superdoublesecret' -} -AUTH_HEADER = {AUTHORIZATION: 'Bearer {}'.format(AUTHCFG['access_token'])} +PROJECT_ID = 'hasstest-1234' +CLIENT_ID = 'helloworld' +ACCESS_TOKEN = 'superdoublesecret' +AUTH_HEADER = {AUTHORIZATION: 'Bearer {}'.format(ACCESS_TOKEN)} @pytest.fixture -def assistant_client(loop, hass_fixture, test_client): +def assistant_client(loop, hass, test_client): """Create web client for the Google Assistant API.""" - hass = hass_fixture - web_app = hass.http.app + loop.run_until_complete( + setup.async_setup_component(hass, 'google_assistant', { + 'google_assistant': { + 'project_id': PROJECT_ID, + 'client_id': CLIENT_ID, + 'access_token': ACCESS_TOKEN, + } + })) - ga.http.GoogleAssistantView(hass, AUTHCFG).register(web_app.router) - ga.auth.GoogleAssistantAuthView(hass, AUTHCFG).register(web_app.router) - - return loop.run_until_complete(test_client(web_app)) + return loop.run_until_complete(test_client(hass.http.app)) @pytest.fixture @@ -50,13 +48,6 @@ def hass_fixture(loop, hass): # We need to do this to get access to homeassistant/turn_(on,off) loop.run_until_complete(async_setup(hass, {core.DOMAIN: {}})) - loop.run_until_complete( - setup.async_setup_component(hass, http.DOMAIN, { - http.DOMAIN: { - http.CONF_SERVER_PORT: SERVER_PORT - } - })) - loop.run_until_complete( setup.async_setup_component(hass, light.DOMAIN, { 'light': [{ @@ -121,20 +112,20 @@ def hass_fixture(loop, hass): @asyncio.coroutine -def test_auth(hass_fixture, assistant_client): +def test_auth(assistant_client): """Test the auth process.""" result = yield from assistant_client.get( ga.const.GOOGLE_ASSISTANT_API_ENDPOINT + '/auth', params={ 'redirect_uri': - 'http://testurl/r/{}'.format(AUTHCFG['project_id']), - 'client_id': AUTHCFG['client_id'], + 'http://testurl/r/{}'.format(PROJECT_ID), + 'client_id': CLIENT_ID, 'state': 'random1234', }, allow_redirects=False) assert result.status == 301 loc = result.headers.get('Location') - assert AUTHCFG['access_token'] in loc + assert ACCESS_TOKEN in loc @asyncio.coroutine @@ -167,9 +158,6 @@ def test_sync_request(hass_fixture, assistant_client): @asyncio.coroutine def test_query_request(hass_fixture, assistant_client): """Test a query request.""" - # hass.states.set("light.bedroom", "on") - # hass.states.set("switch.outside", "off") - # res = _sync_req() reqid = '5711642932632160984' data = { 'requestId': @@ -301,9 +289,6 @@ def test_query_climate_request_f(hass_fixture, assistant_client): @asyncio.coroutine def test_execute_request(hass_fixture, assistant_client): """Test a execute request.""" - # hass.states.set("light.bedroom", "on") - # hass.states.set("switch.outside", "off") - # res = _sync_req() reqid = '5711642932632160985' data = { 'requestId': diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index 2668c0cecfc..bb8f1b706e6 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -179,16 +179,6 @@ DETERMINE_SERVICE_TESTS = [{ # Test light brightness }] -@asyncio.coroutine -def test_make_actions_response(): - """Test make response helper.""" - reqid = 1234 - payload = 'hello' - result = ga.smart_home.make_actions_response(reqid, payload) - assert result['requestId'] == reqid - assert result['payload'] == payload - - @asyncio.coroutine def test_determine_service(): """Test all branches of determine service.""" diff --git a/tests/test_util/aiohttp.py b/tests/test_util/aiohttp.py index ccd71e55d16..f1380bdf56f 100644 --- a/tests/test_util/aiohttp.py +++ b/tests/test_util/aiohttp.py @@ -7,6 +7,8 @@ from unittest import mock from urllib.parse import urlparse, parse_qs import yarl +from aiohttp.client_exceptions import ClientResponseError + class AiohttpClientMocker: """Mock Aiohttp client requests.""" @@ -189,6 +191,12 @@ class AiohttpClientMockResponse: """Mock release.""" pass + def raise_for_status(self): + """Raise error if status is 400 or higher.""" + if self.status >= 400: + raise ClientResponseError( + None, None, code=self.status, headers=self.headers) + def close(self): """Mock close.""" pass From 976a0fe38c252861f2c608ddef0c2e59b662fe9e Mon Sep 17 00:00:00 2001 From: Jeroen ter Heerdt Date: Mon, 1 Jan 2018 02:10:52 +0100 Subject: [PATCH 120/238] Adding support for Egardia / Woonveilig version GATE-03 (#11397) --- homeassistant/components/alarm_control_panel/egardia.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/egardia.py b/homeassistant/components/alarm_control_panel/egardia.py index 36a4bdb1310..cb3da95e03a 100644 --- a/homeassistant/components/alarm_control_panel/egardia.py +++ b/homeassistant/components/alarm_control_panel/egardia.py @@ -18,7 +18,7 @@ from homeassistant.const import ( CONF_NAME, STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_AWAY, STATE_ALARM_TRIGGERED, EVENT_HOMEASSISTANT_STOP) -REQUIREMENTS = ['pythonegardia==1.0.25'] +REQUIREMENTS = ['pythonegardia==1.0.26'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 8fd24eeeb1c..0b7b804d277 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -931,7 +931,7 @@ python_opendata_transport==0.0.3 python_openzwave==0.4.0.35 # homeassistant.components.alarm_control_panel.egardia -pythonegardia==1.0.25 +pythonegardia==1.0.26 # homeassistant.components.sensor.whois pythonwhois==2.4.3 From b9c852392c9830f5a965e4279c724d8ab5f61286 Mon Sep 17 00:00:00 2001 From: Kane610 Date: Mon, 1 Jan 2018 17:08:13 +0100 Subject: [PATCH 121/238] Add deCONZ component (#10321) * Base implementation of component, no sensors yet * Added senor files * First fully working chain of sensors and binary sensors going from hardware in to hass * Clean up * Clean up * Added light platform * Turning lights on and off and set brightness now works * Pydeconz is now a proper pypi package Stop sessions when Home Assistant is shutting down Use a simpler websocket client * Updated pydocstrings Followed recommendations from pylint and flake8 * Clean up * Updated requirements_all.txt * Updated Codeowners to include deconz.py Also re-added the Axis component since it had gotten removed * Bump requirement * Bumped to v2 Reran script/gen_requirements * Removed global DECONZ since it wasn't relevant any more * Username and password is only relevant in the context of getting a API key * Add support for additional sensors * Added support for groups * Moved import of component library to inside of methods * Moved the need for device id to library * Bump pydeconz to v5 * Add support for colored lights * Pylint and flake8 import improvements * DATA_DECONZ TO DECONZ_DATA * Add support for transition time * Add support for flash * Bump to v7 * ZHASwitch devices will now only generate events by default, instead of being a sensor entity * Clean up * Add battery sensor when device signals through an event * Third-party library communicates with service * Add support for effect colorloop * Bump to pydeconz v8 * Same domain everywhere * Clean up * Updated requirements_all * Generated API key will now be stored in a config file * Change battery sensor to register to callback since library now supports multiple callbacks Move DeconzEvent to hub Bump to v9 * Improve entity attributes * Change end of battery name to battery level No need for static icon variable when using battery level helper * Bump requirement to v10 * Improve pydocstring for DeconzEvent Rename TYPE_AS_EVENT to CONF_TYPE_AS_EVENT * Allow separate brightness to override RGB brightness * Expose device.reachable in entity available property * Bump requirement to 11 (it goes up to 11!) * Pylint comment * Binary sensors don't have unit of measurement * Removed service to generate API key in favor of just generating it as a last resort of no API key is specified in configuration.yaml or deconz.conf * Replace clear text to attribute definitions * Use more constants * Bump requirements to v12 * Color temp requires xy color support * Only ZHASwitch should be an event * Bump requirements to v13 * Added effect_list property * Add attribute to battery sensor to easy find event id * Bump requirements to v14 * Fix hound comment * Bumped requirements_all information to v14 * Add service to configure devices on deCONZ * Add initial support for scenes * Bump requirements to v15 * Fix review comments * Python doc string improvement * Improve setup and error handling during setup * Changed how to evaluate light features * Remove 'ghost' events by not triggering updates if the signal originates from a config event Bump requirement to v17 * Fix pylint issue by moving scene ownership in to groups in requirement pydeconz Bump requirement to v18 * Added configurator option to register to deCONZ when unlocking gateway through settings Bump requirement to v20 * Improve async configurator * No user interaction for deconz.conf * No file management in event loop * Improve readability of load platform * Fewer entity attributes * Use values() instead of items() for dicts where applicable * Do one add devices per platform * Clean up of unused attributes * Make sure that discovery info is not None * Only register configure service and shutdown service when deconz has been setup properly * Move description * Fix lines longer than 80 * Moved deconz services to a separate file and moved hub to deconz/__init__.py * Remove option to configure switch as entity * Moved DeconzEvent to sensor since it is only Switch buttonpress that will be sent as event * Added support for automatic discovery of deconz Thanks to Kroimon for adding support to netdisco * Use markup for configuration description * Fix coveragerc * Remove deCONZ support from Hue component * Improved docstrings and readability * Remove unnecessary extra name for storing in hass.data, using domain instead * Improve readability by renaming all async methods Bump to v21 - improved async naming on methods * Fix first line not being in imperative mood * Added logo to configurator Let deconz.conf be visible since it will be the main config for the component after initial setup * Removed bridge_type from new unit tests as part of removing deconz support from hue component * Capitalize first letters of Battery Level * Properly update state of sensor as well as reachable and battery Bump dependency to v22 * Fix flake8 Multi-line docstring closing quotes should be on a separate line * Fix martinhjelmares comments Bump dependency to v23 Use only HASS aiohttp session Change when to use 'deconz' or domain or deconz data Clean up unused logger defines Remove unnecessary return values Fix faulty references to component documentation Move callback registration to after entity has been initialized by HASS Less inception style on pydocs ;) Simplify loading platforms by using a for loop Added voluptous schema for service Yaml file is for deconz only, no need to have the domain present Remove domain constraint when creating event title --- .coveragerc | 3 + CODEOWNERS | 2 + .../components/binary_sensor/deconz.py | 97 +++++++++ homeassistant/components/deconz/__init__.py | 176 ++++++++++++++++ homeassistant/components/deconz/services.yaml | 10 + homeassistant/components/discovery.py | 2 + homeassistant/components/light/deconz.py | 172 ++++++++++++++++ homeassistant/components/light/hue.py | 34 +--- homeassistant/components/scene/deconz.py | 45 ++++ homeassistant/components/sensor/deconz.py | 192 ++++++++++++++++++ requirements_all.txt | 3 + tests/components/light/test_hue.py | 184 +++++++---------- 12 files changed, 789 insertions(+), 131 deletions(-) create mode 100644 homeassistant/components/binary_sensor/deconz.py create mode 100644 homeassistant/components/deconz/__init__.py create mode 100644 homeassistant/components/deconz/services.yaml create mode 100644 homeassistant/components/light/deconz.py create mode 100644 homeassistant/components/scene/deconz.py create mode 100644 homeassistant/components/sensor/deconz.py diff --git a/.coveragerc b/.coveragerc index 0876aa0d7b7..a7c961d5a09 100644 --- a/.coveragerc +++ b/.coveragerc @@ -53,6 +53,9 @@ omit = homeassistant/components/comfoconnect.py homeassistant/components/*/comfoconnect.py + homeassistant/components/deconz/* + homeassistant/components/*/deconz.py + homeassistant/components/digital_ocean.py homeassistant/components/*/digital_ocean.py diff --git a/CODEOWNERS b/CODEOWNERS index 37a2494c182..99c103b1298 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -65,9 +65,11 @@ homeassistant/components/switch/rainmachine.py @bachya homeassistant/components/switch/tplink.py @rytilahti homeassistant/components/xiaomi_aqara.py @danielhiversen @syssi +homeassistant/components/*/axis.py @kane610 homeassistant/components/*/broadlink.py @danielhiversen homeassistant/components/hive.py @Rendili @KJonline homeassistant/components/*/hive.py @Rendili @KJonline +homeassistant/components/*/deconz.py @kane610 homeassistant/components/*/rfxtrx.py @danielhiversen homeassistant/components/velux.py @Julius2342 homeassistant/components/*/velux.py @Julius2342 diff --git a/homeassistant/components/binary_sensor/deconz.py b/homeassistant/components/binary_sensor/deconz.py new file mode 100644 index 00000000000..97f78ff21d0 --- /dev/null +++ b/homeassistant/components/binary_sensor/deconz.py @@ -0,0 +1,97 @@ +""" +Support for deCONZ binary sensor. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.deconz/ +""" + +import asyncio + +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.deconz import DOMAIN as DECONZ_DATA +from homeassistant.const import ATTR_BATTERY_LEVEL +from homeassistant.core import callback + +DEPENDENCIES = ['deconz'] + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Setup binary sensor for deCONZ component.""" + if discovery_info is None: + return + + from pydeconz.sensor import DECONZ_BINARY_SENSOR + sensors = hass.data[DECONZ_DATA].sensors + entities = [] + + for sensor in sensors.values(): + if sensor.type in DECONZ_BINARY_SENSOR: + entities.append(DeconzBinarySensor(sensor)) + async_add_devices(entities, True) + + +class DeconzBinarySensor(BinarySensorDevice): + """Representation of a binary sensor.""" + + def __init__(self, sensor): + """Setup sensor and add update callback to get data from websocket.""" + self._sensor = sensor + + @asyncio.coroutine + def async_added_to_hass(self): + """Subscribe sensors events.""" + self._sensor.register_async_callback(self.async_update_callback) + + @callback + def async_update_callback(self, reason): + """Update the sensor's state. + + If reason is that state is updated, + or reachable has changed or battery has changed. + """ + if reason['state'] or \ + 'reachable' in reason['attr'] or \ + 'battery' in reason['attr']: + self.async_schedule_update_ha_state() + + @property + def is_on(self): + """Return true if sensor is on.""" + return self._sensor.is_tripped + + @property + def name(self): + """Return the name of the sensor.""" + return self._sensor.name + + @property + def device_class(self): + """Class of the sensor.""" + return self._sensor.sensor_class + + @property + def icon(self): + """Return the icon to use in the frontend.""" + return self._sensor.sensor_icon + + @property + def available(self): + """Return True if sensor is available.""" + return self._sensor.reachable + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + from pydeconz.sensor import PRESENCE + attr = { + ATTR_BATTERY_LEVEL: self._sensor.battery, + } + if self._sensor.type == PRESENCE: + attr['dark'] = self._sensor.dark + return attr diff --git a/homeassistant/components/deconz/__init__.py b/homeassistant/components/deconz/__init__.py new file mode 100644 index 00000000000..4b89594c62e --- /dev/null +++ b/homeassistant/components/deconz/__init__.py @@ -0,0 +1,176 @@ +""" +Support for deCONZ devices. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/deconz/ +""" + +import asyncio +import logging +import os +import voluptuous as vol + +from homeassistant.config import load_yaml_config_file +from homeassistant.const import ( + CONF_API_KEY, CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP) +from homeassistant.components.discovery import SERVICE_DECONZ +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import discovery +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.util.json import load_json, save_json + +REQUIREMENTS = ['pydeconz==23'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'deconz' + +CONFIG_FILE = 'deconz.conf' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_HOST): cv.string, + vol.Optional(CONF_API_KEY): cv.string, + vol.Optional(CONF_PORT, default=80): cv.port, + }) +}, extra=vol.ALLOW_EXTRA) + +SERVICE_FIELD = 'field' +SERVICE_DATA = 'data' + +SERVICE_SCHEMA = vol.Schema({ + vol.Required(SERVICE_FIELD): cv.string, + vol.Required(SERVICE_DATA): cv.string, +}) + +CONFIG_INSTRUCTIONS = """ +Unlock your deCONZ gateway to register with Home Assistant. + +1. [Go to deCONZ system settings](http://{}:{}/edit_system.html) +2. Press "Unlock Gateway" button + +[deCONZ platform documentation](https://home-assistant.io/components/deconz/) +""" + + +@asyncio.coroutine +def async_setup(hass, config): + """Setup services and configuration for deCONZ component.""" + result = False + config_file = yield from hass.async_add_job( + load_json, hass.config.path(CONFIG_FILE)) + + @asyncio.coroutine + def async_deconz_discovered(service, discovery_info): + """Called when deCONZ gateway has been found.""" + deconz_config = {} + deconz_config[CONF_HOST] = discovery_info.get(CONF_HOST) + deconz_config[CONF_PORT] = discovery_info.get(CONF_PORT) + yield from async_request_configuration(hass, config, deconz_config) + + if config_file: + result = yield from async_setup_deconz(hass, config, config_file) + + if not result and DOMAIN in config and CONF_HOST in config[DOMAIN]: + deconz_config = config[DOMAIN] + if CONF_API_KEY in deconz_config: + result = yield from async_setup_deconz(hass, config, deconz_config) + else: + yield from async_request_configuration(hass, config, deconz_config) + return True + + if not result: + discovery.async_listen(hass, SERVICE_DECONZ, async_deconz_discovered) + + return True + + +@asyncio.coroutine +def async_setup_deconz(hass, config, deconz_config): + """Setup deCONZ session. + + Load config, group, light and sensor data for server information. + Start websocket for push notification of state changes from deCONZ. + """ + from pydeconz import DeconzSession + websession = async_get_clientsession(hass) + deconz = DeconzSession(hass.loop, websession, **deconz_config) + result = yield from deconz.async_load_parameters() + if result is False: + _LOGGER.error("Failed to communicate with deCONZ.") + return False + + hass.data[DOMAIN] = deconz + + for component in ['binary_sensor', 'light', 'scene', 'sensor']: + hass.async_add_job(discovery.async_load_platform( + hass, component, DOMAIN, {}, config)) + deconz.start() + + descriptions = yield from hass.async_add_job( + load_yaml_config_file, + os.path.join(os.path.dirname(__file__), 'services.yaml')) + + @asyncio.coroutine + def async_configure(call): + """Set attribute of device in deCONZ. + + Field is a string representing a specific device in deCONZ + e.g. field='/lights/1/state'. + Data is a json object with what data you want to alter + e.g. data={'on': true}. + { + "field": "/lights/1/state", + "data": {"on": true} + } + See Dresden Elektroniks REST API documentation for details: + http://dresden-elektronik.github.io/deconz-rest-doc/rest/ + """ + deconz = hass.data[DOMAIN] + field = call.data.get(SERVICE_FIELD) + data = call.data.get(SERVICE_DATA) + yield from deconz.async_put_state(field, data) + hass.services.async_register( + DOMAIN, 'configure', async_configure, + descriptions['configure'], schema=SERVICE_SCHEMA) + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, deconz.close) + return True + + +@asyncio.coroutine +def async_request_configuration(hass, config, deconz_config): + """Request configuration steps from the user.""" + configurator = hass.components.configurator + + @asyncio.coroutine + def async_configuration_callback(data): + """Set up actions to do when our configuration callback is called.""" + from pydeconz.utils import async_get_api_key + api_key = yield from async_get_api_key(hass.loop, **deconz_config) + if api_key: + deconz_config[CONF_API_KEY] = api_key + result = yield from async_setup_deconz(hass, config, deconz_config) + if result: + yield from hass.async_add_job(save_json, + hass.config.path(CONFIG_FILE), + deconz_config) + configurator.async_request_done(request_id) + return + else: + configurator.async_notify_errors( + request_id, "Couldn't load configuration.") + else: + configurator.async_notify_errors( + request_id, "Couldn't get an API key.") + return + + instructions = CONFIG_INSTRUCTIONS.format( + deconz_config[CONF_HOST], deconz_config[CONF_PORT]) + + request_id = configurator.async_request_config( + "deCONZ", async_configuration_callback, + description=instructions, + entity_picture="/static/images/logo_deconz.jpeg", + submit_caption="I have unlocked the gateway", + ) diff --git a/homeassistant/components/deconz/services.yaml b/homeassistant/components/deconz/services.yaml new file mode 100644 index 00000000000..2e6593c6ea0 --- /dev/null +++ b/homeassistant/components/deconz/services.yaml @@ -0,0 +1,10 @@ + +configure: + description: Set attribute of device in Deconz. See Dresden Elektroniks REST API documentation for details http://dresden-elektronik.github.io/deconz-rest-doc/rest/ + fields: + field: + description: Field is a string representing a specific device in Deconz. + example: '/lights/1/state' + data: + description: Data is a json object with what data you want to alter. + example: '{"on": true}' diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index dde33aa10a2..b6578dd70fe 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -37,6 +37,7 @@ SERVICE_WINK = 'wink' SERVICE_XIAOMI_GW = 'xiaomi_gw' SERVICE_TELLDUSLIVE = 'tellstick' SERVICE_HUE = 'philips_hue' +SERVICE_DECONZ = 'deconz' SERVICE_HANDLERS = { SERVICE_HASS_IOS_APP: ('ios', None), @@ -50,6 +51,7 @@ SERVICE_HANDLERS = { SERVICE_XIAOMI_GW: ('xiaomi_aqara', None), SERVICE_TELLDUSLIVE: ('tellduslive', None), SERVICE_HUE: ('hue', None), + SERVICE_DECONZ: ('deconz', None), 'google_cast': ('media_player', 'cast'), 'panasonic_viera': ('media_player', 'panasonic_viera'), 'plex_mediaserver': ('media_player', 'plex'), diff --git a/homeassistant/components/light/deconz.py b/homeassistant/components/light/deconz.py new file mode 100644 index 00000000000..a1c43ad4cbc --- /dev/null +++ b/homeassistant/components/light/deconz.py @@ -0,0 +1,172 @@ +""" +Support for deCONZ light. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/light.deconz/ +""" + +import asyncio + +from homeassistant.components.deconz import DOMAIN as DECONZ_DATA +from homeassistant.components.light import ( + Light, ATTR_BRIGHTNESS, ATTR_FLASH, ATTR_COLOR_TEMP, ATTR_EFFECT, + ATTR_RGB_COLOR, ATTR_TRANSITION, EFFECT_COLORLOOP, FLASH_LONG, FLASH_SHORT, + SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_EFFECT, SUPPORT_FLASH, + SUPPORT_RGB_COLOR, SUPPORT_TRANSITION, SUPPORT_XY_COLOR) +from homeassistant.core import callback +from homeassistant.util.color import color_RGB_to_xy + +DEPENDENCIES = ['deconz'] + +ATTR_LIGHT_GROUP = 'LightGroup' + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Setup light for deCONZ component.""" + if discovery_info is None: + return + + lights = hass.data[DECONZ_DATA].lights + groups = hass.data[DECONZ_DATA].groups + entities = [] + + for light in lights.values(): + entities.append(DeconzLight(light)) + + for group in groups.values(): + if group.lights: # Don't create entity for group not containing light + entities.append(DeconzLight(group)) + async_add_devices(entities, True) + + +class DeconzLight(Light): + """Representation of a deCONZ light.""" + + def __init__(self, light): + """Setup light and add update callback to get data from websocket.""" + self._light = light + + self._features = SUPPORT_BRIGHTNESS + self._features |= SUPPORT_FLASH + self._features |= SUPPORT_TRANSITION + + if self._light.ct is not None: + self._features |= SUPPORT_COLOR_TEMP + + if self._light.xy is not None: + self._features |= SUPPORT_RGB_COLOR + self._features |= SUPPORT_XY_COLOR + + if self._light.effect is not None: + self._features |= SUPPORT_EFFECT + + @asyncio.coroutine + def async_added_to_hass(self): + """Subscribe to lights events.""" + self._light.register_async_callback(self.async_update_callback) + + @callback + def async_update_callback(self, reason): + """Update the light's state.""" + self.async_schedule_update_ha_state() + + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + return self._light.brightness + + @property + def effect_list(self): + """Return the list of supported effects.""" + return [EFFECT_COLORLOOP] + + @property + def color_temp(self): + """Return the CT color value.""" + return self._light.ct + + @property + def xy_color(self): + """Return the XY color value.""" + return self._light.xy + + @property + def is_on(self): + """Return true if light is on.""" + return self._light.state + + @property + def name(self): + """Return the name of the light.""" + return self._light.name + + @property + def supported_features(self): + """Flag supported features.""" + return self._features + + @property + def available(self): + """Return True if light is available.""" + return self._light.reachable + + @property + def should_poll(self): + """No polling needed.""" + return False + + @asyncio.coroutine + def async_turn_on(self, **kwargs): + """Turn on light.""" + data = {'on': True} + + if ATTR_COLOR_TEMP in kwargs: + data['ct'] = kwargs[ATTR_COLOR_TEMP] + + if ATTR_RGB_COLOR in kwargs: + xyb = color_RGB_to_xy( + *(int(val) for val in kwargs[ATTR_RGB_COLOR])) + data['xy'] = xyb[0], xyb[1] + data['bri'] = xyb[2] + + if ATTR_BRIGHTNESS in kwargs: + data['bri'] = kwargs[ATTR_BRIGHTNESS] + + if ATTR_TRANSITION in kwargs: + data['transitiontime'] = int(kwargs[ATTR_TRANSITION]) * 10 + + if ATTR_FLASH in kwargs: + if kwargs[ATTR_FLASH] == FLASH_SHORT: + data['alert'] = 'select' + del data['on'] + elif kwargs[ATTR_FLASH] == FLASH_LONG: + data['alert'] = 'lselect' + del data['on'] + + if ATTR_EFFECT in kwargs: + if kwargs[ATTR_EFFECT] == EFFECT_COLORLOOP: + data['effect'] = 'colorloop' + else: + data['effect'] = 'none' + + yield from self._light.async_set_state(data) + + @asyncio.coroutine + def async_turn_off(self, **kwargs): + """Turn off light.""" + data = {'on': False} + + if ATTR_TRANSITION in kwargs: + data = {'bri': 0} + data['transitiontime'] = int(kwargs[ATTR_TRANSITION]) * 10 + + if ATTR_FLASH in kwargs: + if kwargs[ATTR_FLASH] == FLASH_SHORT: + data['alert'] = 'select' + del data['on'] + elif kwargs[ATTR_FLASH] == FLASH_LONG: + data['alert'] = 'lselect' + del data['on'] + + yield from self._light.async_set_state(data) diff --git a/homeassistant/components/light/hue.py b/homeassistant/components/light/hue.py index f5c910ea116..64e5dff0d26 100644 --- a/homeassistant/components/light/hue.py +++ b/homeassistant/components/light/hue.py @@ -130,14 +130,12 @@ def unthrottled_update_lights(hass, bridge, add_devices): _LOGGER.exception('Cannot reach the bridge') return - bridge_type = get_bridge_type(api) - new_lights = process_lights( - hass, api, bridge, bridge_type, + hass, api, bridge, lambda **kw: update_lights(hass, bridge, add_devices, **kw)) if bridge.allow_hue_groups: new_lightgroups = process_groups( - hass, api, bridge, bridge_type, + hass, api, bridge, lambda **kw: update_lights(hass, bridge, add_devices, **kw)) new_lights.extend(new_lightgroups) @@ -145,16 +143,7 @@ def unthrottled_update_lights(hass, bridge, add_devices): add_devices(new_lights) -def get_bridge_type(api): - """Return the bridge type.""" - api_name = api.get('config').get('name') - if api_name in ('RaspBee-GW', 'deCONZ-GW'): - return 'deconz' - else: - return 'hue' - - -def process_lights(hass, api, bridge, bridge_type, update_lights_cb): +def process_lights(hass, api, bridge, update_lights_cb): """Set up HueLight objects for all lights.""" api_lights = api.get('lights') @@ -169,7 +158,7 @@ def process_lights(hass, api, bridge, bridge_type, update_lights_cb): bridge.lights[light_id] = HueLight( int(light_id), info, bridge, update_lights_cb, - bridge_type, bridge.allow_unreachable, + bridge.allow_unreachable, bridge.allow_in_emulated_hue) new_lights.append(bridge.lights[light_id]) else: @@ -179,7 +168,7 @@ def process_lights(hass, api, bridge, bridge_type, update_lights_cb): return new_lights -def process_groups(hass, api, bridge, bridge_type, update_lights_cb): +def process_groups(hass, api, bridge, update_lights_cb): """Set up HueLight objects for all groups.""" api_groups = api.get('groups') @@ -199,7 +188,7 @@ def process_groups(hass, api, bridge, bridge_type, update_lights_cb): bridge.lightgroups[lightgroup_id] = HueLight( int(lightgroup_id), info, bridge, update_lights_cb, - bridge_type, bridge.allow_unreachable, + bridge.allow_unreachable, bridge.allow_in_emulated_hue, True) new_lights.append(bridge.lightgroups[lightgroup_id]) else: @@ -213,14 +202,12 @@ class HueLight(Light): """Representation of a Hue light.""" def __init__(self, light_id, info, bridge, update_lights_cb, - bridge_type, allow_unreachable, allow_in_emulated_hue, - is_group=False): + allow_unreachable, allow_in_emulated_hue, is_group=False): """Initialize the light.""" self.light_id = light_id self.info = info self.bridge = bridge self.update_lights = update_lights_cb - self.bridge_type = bridge_type self.allow_unreachable = allow_unreachable self.is_group = is_group self.allow_in_emulated_hue = allow_in_emulated_hue @@ -330,7 +317,7 @@ class HueLight(Light): elif flash == FLASH_SHORT: command['alert'] = 'select' del command['on'] - elif self.bridge_type == 'hue': + else: command['alert'] = 'none' effect = kwargs.get(ATTR_EFFECT) @@ -340,8 +327,7 @@ class HueLight(Light): elif effect == EFFECT_RANDOM: command['hue'] = random.randrange(0, 65535) command['sat'] = random.randrange(150, 254) - elif (self.bridge_type == 'hue' and - self.info.get('manufacturername') == 'Philips'): + elif self.info.get('manufacturername') == 'Philips': command['effect'] = 'none' self._command_func(self.light_id, command) @@ -361,7 +347,7 @@ class HueLight(Light): elif flash == FLASH_SHORT: command['alert'] = 'select' del command['on'] - elif self.bridge_type == 'hue': + else: command['alert'] = 'none' self._command_func(self.light_id, command) diff --git a/homeassistant/components/scene/deconz.py b/homeassistant/components/scene/deconz.py new file mode 100644 index 00000000000..f035ae3128e --- /dev/null +++ b/homeassistant/components/scene/deconz.py @@ -0,0 +1,45 @@ +""" +Support for deCONZ scenes. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/scene.deconz/ +""" + +import asyncio + +from homeassistant.components.deconz import DOMAIN as DECONZ_DATA +from homeassistant.components.scene import Scene + +DEPENDENCIES = ['deconz'] + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up scenes for deCONZ component.""" + if discovery_info is None: + return + + scenes = hass.data[DECONZ_DATA].scenes + entities = [] + + for scene in scenes.values(): + entities.append(DeconzScene(scene)) + async_add_devices(entities) + + +class DeconzScene(Scene): + """Representation of a deCONZ scene.""" + + def __init__(self, scene): + """Setup scene.""" + self._scene = scene + + @asyncio.coroutine + def async_activate(self, **kwargs): + """Activate the scene.""" + yield from self._scene.async_set_state({}) + + @property + def name(self): + """Return the name of the scene.""" + return self._scene.full_name diff --git a/homeassistant/components/sensor/deconz.py b/homeassistant/components/sensor/deconz.py new file mode 100644 index 00000000000..c01483169cb --- /dev/null +++ b/homeassistant/components/sensor/deconz.py @@ -0,0 +1,192 @@ +""" +Support for deCONZ sensor. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/sensor.deconz/ +""" + +import asyncio + +from homeassistant.components.deconz import DOMAIN as DECONZ_DATA +from homeassistant.const import ATTR_BATTERY_LEVEL, CONF_EVENT, CONF_ID +from homeassistant.core import callback, EventOrigin +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.icon import icon_for_battery_level +from homeassistant.util import slugify + +DEPENDENCIES = ['deconz'] + +ATTR_EVENT_ID = 'event_id' +ATTR_ZHASWITCH = 'ZHASwitch' + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Setup sensor for deCONZ component.""" + if discovery_info is None: + return + + from pydeconz.sensor import DECONZ_SENSOR + sensors = hass.data[DECONZ_DATA].sensors + entities = [] + + for sensor in sensors.values(): + if sensor.type in DECONZ_SENSOR: + if sensor.type == ATTR_ZHASWITCH: + DeconzEvent(hass, sensor) + if sensor.battery: + entities.append(DeconzBattery(sensor)) + else: + entities.append(DeconzSensor(sensor)) + async_add_devices(entities, True) + + +class DeconzSensor(Entity): + """Representation of a sensor.""" + + def __init__(self, sensor): + """Setup sensor and add update callback to get data from websocket.""" + self._sensor = sensor + + @asyncio.coroutine + def async_added_to_hass(self): + """Subscribe to sensors events.""" + self._sensor.register_async_callback(self.async_update_callback) + + @callback + def async_update_callback(self, reason): + """Update the sensor's state. + + If reason is that state is updated, + or reachable has changed or battery has changed. + """ + if reason['state'] or \ + 'reachable' in reason['attr'] or \ + 'battery' in reason['attr']: + self.async_schedule_update_ha_state() + + @property + def state(self): + """Return the state of the sensor.""" + return self._sensor.state + + @property + def name(self): + """Return the name of the sensor.""" + return self._sensor.name + + @property + def device_class(self): + """Class of the sensor.""" + return self._sensor.sensor_class + + @property + def icon(self): + """Return the icon to use in the frontend.""" + return self._sensor.sensor_icon + + @property + def unit_of_measurement(self): + """Unit of measurement of this sensor.""" + return self._sensor.sensor_unit + + @property + def available(self): + """Return True if sensor is available.""" + return self._sensor.reachable + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + attr = { + ATTR_BATTERY_LEVEL: self._sensor.battery, + } + return attr + + +class DeconzBattery(Entity): + """Battery class for when a device is only represented as an event.""" + + def __init__(self, device): + """Register dispatcher callback for update of battery state.""" + self._device = device + self._name = self._device.name + ' Battery Level' + self._device_class = 'battery' + self._unit_of_measurement = "%" + + @asyncio.coroutine + def async_added_to_hass(self): + """Subscribe to sensors events.""" + self._device.register_async_callback(self.async_update_callback) + + @callback + def async_update_callback(self, reason): + """Update the battery's state, if needed.""" + if 'battery' in reason['attr']: + self.async_schedule_update_ha_state() + + @property + def state(self): + """Return the state of the battery.""" + return self._device.battery + + @property + def name(self): + """Return the name of the battery.""" + return self._name + + @property + def device_class(self): + """Class of the sensor.""" + return self._device_class + + @property + def icon(self): + """Return the icon to use in the frontend.""" + return icon_for_battery_level(int(self.state)) + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity.""" + return self._unit_of_measurement + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def device_state_attributes(self): + """Return the state attributes of the battery.""" + attr = { + ATTR_EVENT_ID: slugify(self._device.name), + } + return attr + + +class DeconzEvent(object): + """When you want signals instead of entities. + + Stateless sensors such as remotes are expected to generate an event + instead of a sensor entity in hass. + """ + + def __init__(self, hass, device): + """Register callback that will be used for signals.""" + self._hass = hass + self._device = device + self._device.register_async_callback(self.async_update_callback) + self._event = 'deconz_{}'.format(CONF_EVENT) + self._id = slugify(self._device.name) + + @callback + def async_update_callback(self, reason): + """Fire the event if reason is that state is updated.""" + if reason['state']: + data = {CONF_ID: self._id, CONF_EVENT: self._device.state} + self._hass.bus.async_fire(self._event, data, EventOrigin.remote) diff --git a/requirements_all.txt b/requirements_all.txt index 0b7b804d277..9a8741aa4cb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -664,6 +664,9 @@ pycsspeechtts==1.0.2 # homeassistant.components.sensor.cups # pycups==1.9.73 +# homeassistant.components.deconz +pydeconz==23 + # homeassistant.components.zwave pydispatcher==2.0.5 diff --git a/tests/components/light/test_hue.py b/tests/components/light/test_hue.py index 7955cecba04..b612fa15931 100644 --- a/tests/components/light/test_hue.py +++ b/tests/components/light/test_hue.py @@ -32,7 +32,6 @@ class TestSetup(unittest.TestCase): self.mock_bridge.allow_hue_groups = False self.mock_api = MagicMock() self.mock_bridge.get_api.return_value = self.mock_api - self.mock_bridge_type = MagicMock() self.mock_lights = [] self.mock_groups = [] self.mock_add_devices = MagicMock() @@ -43,7 +42,6 @@ class TestSetup(unittest.TestCase): self.mock_api = MagicMock() self.mock_api.get.return_value = {} self.mock_bridge.get_api.return_value = self.mock_api - self.mock_bridge_type = MagicMock() def setup_mocks_for_process_groups(self): """Set up all mocks for process_groups tests.""" @@ -55,8 +53,6 @@ class TestSetup(unittest.TestCase): self.mock_api.get.return_value = {} self.mock_bridge.get_api.return_value = self.mock_api - self.mock_bridge_type = MagicMock() - def create_mock_bridge(self, host, allow_hue_groups=True): """Return a mock HueBridge with reasonable defaults.""" mock_bridge = MagicMock() @@ -137,21 +133,18 @@ class TestSetup(unittest.TestCase): """Test the update_lights function when no lights are found.""" self.setup_mocks_for_update_lights() - with patch('homeassistant.components.light.hue.get_bridge_type', - return_value=self.mock_bridge_type): - with patch('homeassistant.components.light.hue.process_lights', - return_value=[]) as mock_process_lights: - with patch('homeassistant.components.light.hue.process_groups', - return_value=self.mock_groups) \ - as mock_process_groups: - hue_light.unthrottled_update_lights( - self.hass, self.mock_bridge, self.mock_add_devices) + with patch('homeassistant.components.light.hue.process_lights', + return_value=[]) as mock_process_lights: + with patch('homeassistant.components.light.hue.process_groups', + return_value=self.mock_groups) \ + as mock_process_groups: + hue_light.unthrottled_update_lights( + self.hass, self.mock_bridge, self.mock_add_devices) - mock_process_lights.assert_called_once_with( - self.hass, self.mock_api, self.mock_bridge, - self.mock_bridge_type, mock.ANY) - mock_process_groups.assert_not_called() - self.mock_add_devices.assert_not_called() + mock_process_lights.assert_called_once_with( + self.hass, self.mock_api, self.mock_bridge, mock.ANY) + mock_process_groups.assert_not_called() + self.mock_add_devices.assert_not_called() @MockDependency('phue') def test_update_lights_with_some_lights(self, mock_phue): @@ -159,22 +152,19 @@ class TestSetup(unittest.TestCase): self.setup_mocks_for_update_lights() self.mock_lights = ['some', 'light'] - with patch('homeassistant.components.light.hue.get_bridge_type', - return_value=self.mock_bridge_type): - with patch('homeassistant.components.light.hue.process_lights', - return_value=self.mock_lights) as mock_process_lights: - with patch('homeassistant.components.light.hue.process_groups', - return_value=self.mock_groups) \ - as mock_process_groups: - hue_light.unthrottled_update_lights( - self.hass, self.mock_bridge, self.mock_add_devices) + with patch('homeassistant.components.light.hue.process_lights', + return_value=self.mock_lights) as mock_process_lights: + with patch('homeassistant.components.light.hue.process_groups', + return_value=self.mock_groups) \ + as mock_process_groups: + hue_light.unthrottled_update_lights( + self.hass, self.mock_bridge, self.mock_add_devices) - mock_process_lights.assert_called_once_with( - self.hass, self.mock_api, self.mock_bridge, - self.mock_bridge_type, mock.ANY) - mock_process_groups.assert_not_called() - self.mock_add_devices.assert_called_once_with( - self.mock_lights) + mock_process_lights.assert_called_once_with( + self.hass, self.mock_api, self.mock_bridge, mock.ANY) + mock_process_groups.assert_not_called() + self.mock_add_devices.assert_called_once_with( + self.mock_lights) @MockDependency('phue') def test_update_lights_no_groups(self, mock_phue): @@ -183,24 +173,20 @@ class TestSetup(unittest.TestCase): self.mock_bridge.allow_hue_groups = True self.mock_lights = ['some', 'light'] - with patch('homeassistant.components.light.hue.get_bridge_type', - return_value=self.mock_bridge_type): - with patch('homeassistant.components.light.hue.process_lights', - return_value=self.mock_lights) as mock_process_lights: - with patch('homeassistant.components.light.hue.process_groups', - return_value=self.mock_groups) \ - as mock_process_groups: - hue_light.unthrottled_update_lights( - self.hass, self.mock_bridge, self.mock_add_devices) + with patch('homeassistant.components.light.hue.process_lights', + return_value=self.mock_lights) as mock_process_lights: + with patch('homeassistant.components.light.hue.process_groups', + return_value=self.mock_groups) \ + as mock_process_groups: + hue_light.unthrottled_update_lights( + self.hass, self.mock_bridge, self.mock_add_devices) - mock_process_lights.assert_called_once_with( - self.hass, self.mock_api, self.mock_bridge, - self.mock_bridge_type, mock.ANY) - mock_process_groups.assert_called_once_with( - self.hass, self.mock_api, self.mock_bridge, - self.mock_bridge_type, mock.ANY) - self.mock_add_devices.assert_called_once_with( - self.mock_lights) + mock_process_lights.assert_called_once_with( + self.hass, self.mock_api, self.mock_bridge, mock.ANY) + mock_process_groups.assert_called_once_with( + self.hass, self.mock_api, self.mock_bridge, mock.ANY) + self.mock_add_devices.assert_called_once_with( + self.mock_lights) @MockDependency('phue') def test_update_lights_with_lights_and_groups(self, mock_phue): @@ -210,24 +196,20 @@ class TestSetup(unittest.TestCase): self.mock_lights = ['some', 'light'] self.mock_groups = ['and', 'groups'] - with patch('homeassistant.components.light.hue.get_bridge_type', - return_value=self.mock_bridge_type): - with patch('homeassistant.components.light.hue.process_lights', - return_value=self.mock_lights) as mock_process_lights: - with patch('homeassistant.components.light.hue.process_groups', - return_value=self.mock_groups) \ - as mock_process_groups: - hue_light.unthrottled_update_lights( - self.hass, self.mock_bridge, self.mock_add_devices) + with patch('homeassistant.components.light.hue.process_lights', + return_value=self.mock_lights) as mock_process_lights: + with patch('homeassistant.components.light.hue.process_groups', + return_value=self.mock_groups) \ + as mock_process_groups: + hue_light.unthrottled_update_lights( + self.hass, self.mock_bridge, self.mock_add_devices) - mock_process_lights.assert_called_once_with( - self.hass, self.mock_api, self.mock_bridge, - self.mock_bridge_type, mock.ANY) - mock_process_groups.assert_called_once_with( - self.hass, self.mock_api, self.mock_bridge, - self.mock_bridge_type, mock.ANY) - self.mock_add_devices.assert_called_once_with( - self.mock_lights) + mock_process_lights.assert_called_once_with( + self.hass, self.mock_api, self.mock_bridge, mock.ANY) + mock_process_groups.assert_called_once_with( + self.hass, self.mock_api, self.mock_bridge, mock.ANY) + self.mock_add_devices.assert_called_once_with( + self.mock_lights) @MockDependency('phue') def test_update_lights_with_two_bridges(self, mock_phue): @@ -242,23 +224,21 @@ class TestSetup(unittest.TestCase): mock_bridge_two_lights = self.create_mock_lights( {1: {'name': 'b2l1'}, 3: {'name': 'b2l3'}}) - with patch('homeassistant.components.light.hue.get_bridge_type', - return_value=self.mock_bridge_type): - with patch('homeassistant.components.light.hue.HueLight.' - 'schedule_update_ha_state'): - mock_api = MagicMock() - mock_api.get.return_value = mock_bridge_one_lights - with patch.object(mock_bridge_one, 'get_api', - return_value=mock_api): - hue_light.unthrottled_update_lights( - self.hass, mock_bridge_one, self.mock_add_devices) + with patch('homeassistant.components.light.hue.HueLight.' + 'schedule_update_ha_state'): + mock_api = MagicMock() + mock_api.get.return_value = mock_bridge_one_lights + with patch.object(mock_bridge_one, 'get_api', + return_value=mock_api): + hue_light.unthrottled_update_lights( + self.hass, mock_bridge_one, self.mock_add_devices) - mock_api = MagicMock() - mock_api.get.return_value = mock_bridge_two_lights - with patch.object(mock_bridge_two, 'get_api', - return_value=mock_api): - hue_light.unthrottled_update_lights( - self.hass, mock_bridge_two, self.mock_add_devices) + mock_api = MagicMock() + mock_api.get.return_value = mock_bridge_two_lights + with patch.object(mock_bridge_two, 'get_api', + return_value=mock_api): + hue_light.unthrottled_update_lights( + self.hass, mock_bridge_two, self.mock_add_devices) self.assertEquals(sorted(mock_bridge_one.lights.keys()), [1, 2]) self.assertEquals(sorted(mock_bridge_two.lights.keys()), [1, 3]) @@ -299,8 +279,7 @@ class TestSetup(unittest.TestCase): self.mock_api.get.return_value = None ret = hue_light.process_lights( - self.hass, self.mock_api, self.mock_bridge, self.mock_bridge_type, - None) + self.hass, self.mock_api, self.mock_bridge, None) self.assertEquals([], ret) self.assertEquals(self.mock_bridge.lights, {}) @@ -310,8 +289,7 @@ class TestSetup(unittest.TestCase): self.setup_mocks_for_process_lights() ret = hue_light.process_lights( - self.hass, self.mock_api, self.mock_bridge, self.mock_bridge_type, - None) + self.hass, self.mock_api, self.mock_bridge, None) self.assertEquals([], ret) self.assertEquals(self.mock_bridge.lights, {}) @@ -324,18 +302,17 @@ class TestSetup(unittest.TestCase): 1: {'state': 'on'}, 2: {'state': 'off'}} ret = hue_light.process_lights( - self.hass, self.mock_api, self.mock_bridge, self.mock_bridge_type, - None) + self.hass, self.mock_api, self.mock_bridge, None) self.assertEquals(len(ret), 2) mock_hue_light.assert_has_calls([ call( 1, {'state': 'on'}, self.mock_bridge, mock.ANY, - self.mock_bridge_type, self.mock_bridge.allow_unreachable, + self.mock_bridge.allow_unreachable, self.mock_bridge.allow_in_emulated_hue), call( 2, {'state': 'off'}, self.mock_bridge, mock.ANY, - self.mock_bridge_type, self.mock_bridge.allow_unreachable, + self.mock_bridge.allow_unreachable, self.mock_bridge.allow_in_emulated_hue), ]) self.assertEquals(len(self.mock_bridge.lights), 2) @@ -353,14 +330,13 @@ class TestSetup(unittest.TestCase): self.mock_bridge.lights = {1: MagicMock()} ret = hue_light.process_lights( - self.hass, self.mock_api, self.mock_bridge, self.mock_bridge_type, - None) + self.hass, self.mock_api, self.mock_bridge, None) self.assertEquals(len(ret), 1) mock_hue_light.assert_has_calls([ call( 2, {'state': 'off'}, self.mock_bridge, mock.ANY, - self.mock_bridge_type, self.mock_bridge.allow_unreachable, + self.mock_bridge.allow_unreachable, self.mock_bridge.allow_in_emulated_hue), ]) self.assertEquals(len(self.mock_bridge.lights), 2) @@ -373,8 +349,7 @@ class TestSetup(unittest.TestCase): self.mock_api.get.return_value = None ret = hue_light.process_groups( - self.hass, self.mock_api, self.mock_bridge, self.mock_bridge_type, - None) + self.hass, self.mock_api, self.mock_bridge, None) self.assertEquals([], ret) self.assertEquals(self.mock_bridge.lightgroups, {}) @@ -385,8 +360,7 @@ class TestSetup(unittest.TestCase): self.mock_bridge.get_group.return_value = {'name': 'Group 0'} ret = hue_light.process_groups( - self.hass, self.mock_api, self.mock_bridge, self.mock_bridge_type, - None) + self.hass, self.mock_api, self.mock_bridge, None) self.assertEquals([], ret) self.assertEquals(self.mock_bridge.lightgroups, {}) @@ -399,18 +373,17 @@ class TestSetup(unittest.TestCase): 1: {'state': 'on'}, 2: {'state': 'off'}} ret = hue_light.process_groups( - self.hass, self.mock_api, self.mock_bridge, self.mock_bridge_type, - None) + self.hass, self.mock_api, self.mock_bridge, None) self.assertEquals(len(ret), 2) mock_hue_light.assert_has_calls([ call( 1, {'state': 'on'}, self.mock_bridge, mock.ANY, - self.mock_bridge_type, self.mock_bridge.allow_unreachable, + self.mock_bridge.allow_unreachable, self.mock_bridge.allow_in_emulated_hue, True), call( 2, {'state': 'off'}, self.mock_bridge, mock.ANY, - self.mock_bridge_type, self.mock_bridge.allow_unreachable, + self.mock_bridge.allow_unreachable, self.mock_bridge.allow_in_emulated_hue, True), ]) self.assertEquals(len(self.mock_bridge.lightgroups), 2) @@ -428,14 +401,13 @@ class TestSetup(unittest.TestCase): self.mock_bridge.lightgroups = {1: MagicMock()} ret = hue_light.process_groups( - self.hass, self.mock_api, self.mock_bridge, self.mock_bridge_type, - None) + self.hass, self.mock_api, self.mock_bridge, None) self.assertEquals(len(ret), 1) mock_hue_light.assert_has_calls([ call( 2, {'state': 'off'}, self.mock_bridge, mock.ANY, - self.mock_bridge_type, self.mock_bridge.allow_unreachable, + self.mock_bridge.allow_unreachable, self.mock_bridge.allow_in_emulated_hue, True), ]) self.assertEquals(len(self.mock_bridge.lightgroups), 2) @@ -455,7 +427,6 @@ class TestHueLight(unittest.TestCase): self.mock_info = MagicMock() self.mock_bridge = MagicMock() self.mock_update_lights = MagicMock() - self.mock_bridge_type = MagicMock() self.mock_allow_unreachable = MagicMock() self.mock_is_group = MagicMock() self.mock_allow_in_emulated_hue = MagicMock() @@ -476,7 +447,6 @@ class TestHueLight(unittest.TestCase): (update_lights if update_lights is not None else self.mock_update_lights), - self.mock_bridge_type, self.mock_allow_unreachable, self.mock_allow_in_emulated_hue, is_group if is_group is not None else self.mock_is_group) From 6e63a4ed8a47c29bbace7c4fb8797565609f9e36 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 1 Jan 2018 14:30:09 -0800 Subject: [PATCH 122/238] Fix broken tests (#11395) * Do not leave remember the milk config file behind * Fix exception in service causing service timeout * Change max service timeout to 9 to catch services timing out * Fix Google Sync service test * Update and pin test requirements --- requirements_test.txt | 21 ++++++++++----------- requirements_test_all.txt | 21 ++++++++++----------- tests/components/test_remember_the_milk.py | 20 +++++++++++--------- tests/components/test_shell_command.py | 1 + tox.ini | 2 +- 5 files changed, 33 insertions(+), 32 deletions(-) diff --git a/requirements_test.txt b/requirements_test.txt index f224a6f5594..94258f4ffe4 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -5,15 +5,14 @@ flake8==3.3 pylint==1.6.5 mypy==0.560 pydocstyle==1.1.1 -coveralls>=1.1 -pytest>=2.9.2 -pytest-aiohttp>=0.1.3 -pytest-cov>=2.3.1 -pytest-timeout>=1.2.0 -pytest-catchlog>=1.2.2 -pytest-sugar>=0.7.1 -requests_mock>=1.0 -mock-open>=1.3.1 +coveralls==1.2.0 +pytest==3.3.1 +pytest-aiohttp==0.3.0 +pytest-cov==2.5.1 +pytest-timeout>=1.2.1 +pytest-sugar==0.9.0 +requests_mock==1.4 +mock-open==1.3.1 flake8-docstrings==1.0.2 -asynctest>=0.8.0 -freezegun>=0.3.8 +asynctest>=0.11.1 +freezegun==0.3.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 543ba3f00a7..97f2821d07e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -6,18 +6,17 @@ flake8==3.3 pylint==1.6.5 mypy==0.560 pydocstyle==1.1.1 -coveralls>=1.1 -pytest>=2.9.2 -pytest-aiohttp>=0.1.3 -pytest-cov>=2.3.1 -pytest-timeout>=1.2.0 -pytest-catchlog>=1.2.2 -pytest-sugar>=0.7.1 -requests_mock>=1.0 -mock-open>=1.3.1 +coveralls==1.2.0 +pytest==3.3.1 +pytest-aiohttp==0.3.0 +pytest-cov==2.5.1 +pytest-timeout>=1.2.1 +pytest-sugar==0.9.0 +requests_mock==1.4 +mock-open==1.3.1 flake8-docstrings==1.0.2 -asynctest>=0.8.0 -freezegun>=0.3.8 +asynctest>=0.11.1 +freezegun==0.3.9 # homeassistant.components.notify.html5 diff --git a/tests/components/test_remember_the_milk.py b/tests/components/test_remember_the_milk.py index 65e7cd73c1f..1b6619aca9c 100644 --- a/tests/components/test_remember_the_milk.py +++ b/tests/components/test_remember_the_milk.py @@ -38,7 +38,8 @@ class TestConfiguration(unittest.TestCase): def test_create_new(self): """Test creating a new config file.""" with patch("builtins.open", mock_open()), \ - patch("os.path.isfile", Mock(return_value=False)): + patch("os.path.isfile", Mock(return_value=False)), \ + patch.object(rtm.RememberTheMilkConfiguration, 'save_config'): config = rtm.RememberTheMilkConfiguration(self.hass) config.set_token(self.profile, self.token) self.assertEqual(config.get_token(self.profile), self.token) @@ -65,16 +66,17 @@ class TestConfiguration(unittest.TestCase): timeseries_id = "my_timeseries" rtm_id = "rtm-id-4567" with patch("builtins.open", mock_open()), \ - patch("os.path.isfile", Mock(return_value=False)): + patch("os.path.isfile", Mock(return_value=False)), \ + patch.object(rtm.RememberTheMilkConfiguration, 'save_config'): config = rtm.RememberTheMilkConfiguration(self.hass) - self.assertEqual(None, config.get_rtm_id(self.profile, hass_id)) - config.set_rtm_id(self.profile, hass_id, list_id, timeseries_id, - rtm_id) - self.assertEqual((list_id, timeseries_id, rtm_id), - config.get_rtm_id(self.profile, hass_id)) - config.delete_rtm_id(self.profile, hass_id) - self.assertEqual(None, config.get_rtm_id(self.profile, hass_id)) + self.assertEqual(None, config.get_rtm_id(self.profile, hass_id)) + config.set_rtm_id(self.profile, hass_id, list_id, timeseries_id, + rtm_id) + self.assertEqual((list_id, timeseries_id, rtm_id), + config.get_rtm_id(self.profile, hass_id)) + config.delete_rtm_id(self.profile, hass_id) + self.assertEqual(None, config.get_rtm_id(self.profile, hass_id)) def test_load_key_map(self): """Test loading an existing key map from the file.""" diff --git a/tests/components/test_shell_command.py b/tests/components/test_shell_command.py index 3bdb6896394..6f993732c38 100644 --- a/tests/components/test_shell_command.py +++ b/tests/components/test_shell_command.py @@ -109,6 +109,7 @@ class TestShellCommand(unittest.TestCase): def test_template_render(self, mock_call): """Ensure shell_commands with templates get rendered properly.""" self.hass.states.set('sensor.test_state', 'Works') + mock_call.return_value = mock_process_creator(error=False) self.assertTrue( setup_component(self.hass, shell_command.DOMAIN, { shell_command.DOMAIN: { diff --git a/tox.ini b/tox.ini index 32f80b95dc1..70612658715 100644 --- a/tox.ini +++ b/tox.ini @@ -12,7 +12,7 @@ setenv = whitelist_externals = /usr/bin/env install_command = /usr/bin/env LANG=C.UTF-8 pip install {opts} {packages} commands = - py.test --timeout=15 --duration=10 --cov --cov-report= {posargs} + py.test --timeout=9 --duration=10 --cov --cov-report= {posargs} deps = -r{toxinidir}/requirements_test_all.txt -c{toxinidir}/homeassistant/package_constraints.txt From b362017206176d80c99592741166e7edc49fd935 Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Mon, 1 Jan 2018 23:52:36 +0100 Subject: [PATCH 123/238] Upgrade pychromecast to 1.0.3 (#11410) --- homeassistant/components/media_player/cast.py | 4 +--- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/media_player/cast.py b/homeassistant/components/media_player/cast.py index 6ae44495e3e..4cf8f72f074 100644 --- a/homeassistant/components/media_player/cast.py +++ b/homeassistant/components/media_player/cast.py @@ -20,9 +20,7 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util -# Do not upgrade to 1.0.2, it breaks a bunch of stuff -# https://github.com/home-assistant/home-assistant/issues/10926 -REQUIREMENTS = ['pychromecast==0.8.2'] +REQUIREMENTS = ['pychromecast==1.0.3'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 9a8741aa4cb..494fee77630 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -650,7 +650,7 @@ pybbox==0.0.5-alpha # pybluez==0.22 # homeassistant.components.media_player.cast -pychromecast==0.8.2 +pychromecast==1.0.3 # homeassistant.components.media_player.cmus pycmus==0.1.0 From cb899a946544cc17dba3a8ef044ffa7b651b1977 Mon Sep 17 00:00:00 2001 From: Greg Dowling Date: Mon, 1 Jan 2018 23:32:26 +0000 Subject: [PATCH 124/238] Bump pywemo to fix request include problems. (#11401) --- homeassistant/components/wemo.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/wemo.py b/homeassistant/components/wemo.py index 0592ad4c124..aaeccaf6eba 100644 --- a/homeassistant/components/wemo.py +++ b/homeassistant/components/wemo.py @@ -14,7 +14,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.const import EVENT_HOMEASSISTANT_STOP -REQUIREMENTS = ['pywemo==0.4.20'] +REQUIREMENTS = ['pywemo==0.4.25'] DOMAIN = 'wemo' diff --git a/requirements_all.txt b/requirements_all.txt index 494fee77630..71a0ffc8339 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -967,7 +967,7 @@ pyvlx==0.1.3 pywebpush==1.3.0 # homeassistant.components.wemo -pywemo==0.4.20 +pywemo==0.4.25 # homeassistant.components.zabbix pyzabbix==0.7.4 From 1b4be0460c0d04aef77faa606f69e6f1be89f29f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 1 Jan 2018 15:32:39 -0800 Subject: [PATCH 125/238] Log exceptions that happen during service call (#11394) * Log exceptions that happen during service call * Lint --- homeassistant/core.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index 30be92af153..7c2e718d43c 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -1024,19 +1024,22 @@ class ServiceRegistry(object): service_call = ServiceCall(domain, service, service_data, call_id) - if service_handler.is_callback: - service_handler.func(service_call) - fire_service_executed() - elif service_handler.is_coroutinefunction: - yield from service_handler.func(service_call) - fire_service_executed() - else: - def execute_service(): - """Execute a service and fires a SERVICE_EXECUTED event.""" + try: + if service_handler.is_callback: service_handler.func(service_call) fire_service_executed() + elif service_handler.is_coroutinefunction: + yield from service_handler.func(service_call) + fire_service_executed() + else: + def execute_service(): + """Execute a service and fires a SERVICE_EXECUTED event.""" + service_handler.func(service_call) + fire_service_executed() - self._hass.async_add_job(execute_service) + yield from self._hass.async_add_job(execute_service) + except Exception: # pylint: disable=broad-except + _LOGGER.exception('Error executing service %s', service_call) class Config(object): From 0f914b4c20db655ceeb3f9e1ab89eb60677a1453 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 1 Jan 2018 17:20:27 -0800 Subject: [PATCH 126/238] Update frontend to 20180102.0 --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 21900e2265f..8376a569e8b 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -23,7 +23,7 @@ from homeassistant.const import CONF_NAME, EVENT_THEMES_UPDATED from homeassistant.core import callback from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20171223.0', 'user-agents==1.1.0'] +REQUIREMENTS = ['home-assistant-frontend==20180102.0', 'user-agents==1.1.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] diff --git a/requirements_all.txt b/requirements_all.txt index 71a0ffc8339..d9dfb16b4b1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -346,7 +346,7 @@ hipnotify==1.0.8 holidays==0.8.1 # homeassistant.components.frontend -home-assistant-frontend==20171223.0 +home-assistant-frontend==20180102.0 # homeassistant.components.camera.onvif http://github.com/tgaugry/suds-passworddigest-py3/archive/86fc50e39b4d2b8997481967d6a7fe1c57118999.zip#suds-passworddigest-py3==0.1.2a diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 97f2821d07e..081523e709f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -76,7 +76,7 @@ hbmqtt==0.9.1 holidays==0.8.1 # homeassistant.components.frontend -home-assistant-frontend==20171223.0 +home-assistant-frontend==20180102.0 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb From e2cec9b3aef7d1cac16200f8bf3b67575a079119 Mon Sep 17 00:00:00 2001 From: NotoriousBDG Date: Mon, 1 Jan 2018 21:09:40 -0500 Subject: [PATCH 127/238] Move IMAP Email Content body to an attribute (#11096) * Move IMAP Email Content body to an attribute * Fix variable names --- .../components/sensor/imap_email_content.py | 10 ++++--- .../sensor/test_imap_email_content.py | 26 ++++++++++++------- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/sensor/imap_email_content.py b/homeassistant/components/sensor/imap_email_content.py index f4d4db201e5..1f04cd606d6 100644 --- a/homeassistant/components/sensor/imap_email_content.py +++ b/homeassistant/components/sensor/imap_email_content.py @@ -219,17 +219,19 @@ class EmailContentSensor(Entity): return if self.sender_allowed(email_message): - message_body = EmailContentSensor.get_msg_text(email_message) + message = EmailContentSensor.get_msg_subject(email_message) if self._value_template is not None: - message_body = self.render_template(email_message) + message = self.render_template(email_message) - self._message = message_body + self._message = message self._state_attributes = { ATTR_FROM: EmailContentSensor.get_msg_sender(email_message), ATTR_SUBJECT: EmailContentSensor.get_msg_subject(email_message), ATTR_DATE: - email_message['Date'] + email_message['Date'], + ATTR_BODY: + EmailContentSensor.get_msg_text(email_message) } diff --git a/tests/components/sensor/test_imap_email_content.py b/tests/components/sensor/test_imap_email_content.py index 0bba3647c6c..cd5c079a431 100644 --- a/tests/components/sensor/test_imap_email_content.py +++ b/tests/components/sensor/test_imap_email_content.py @@ -60,7 +60,9 @@ class EmailContentSensor(unittest.TestCase): sensor.entity_id = 'sensor.emailtest' sensor.schedule_update_ha_state(True) self.hass.block_till_done() - self.assertEqual("Test Message", sensor.state) + self.assertEqual('Test', sensor.state) + self.assertEqual("Test Message", + sensor.device_state_attributes['body']) self.assertEqual('sender@test.com', sensor.device_state_attributes['from']) self.assertEqual('Test', sensor.device_state_attributes['subject']) @@ -89,13 +91,15 @@ class EmailContentSensor(unittest.TestCase): sensor.entity_id = "sensor.emailtest" sensor.schedule_update_ha_state(True) self.hass.block_till_done() - self.assertEqual("Test Message", sensor.state) + self.assertEqual('Link', sensor.state) + self.assertEqual("Test Message", + sensor.device_state_attributes['body']) def test_multi_part_only_html(self): """Test multi part emails with only HTML.""" msg = MIMEMultipart('alternative') - msg['Subject'] = "Link" - msg['From'] = "sender@test.com" + msg['Subject'] = 'Link' + msg['From'] = 'sender@test.com' html = "Test Message" @@ -113,9 +117,10 @@ class EmailContentSensor(unittest.TestCase): sensor.entity_id = 'sensor.emailtest' sensor.schedule_update_ha_state(True) self.hass.block_till_done() + self.assertEqual('Link', sensor.state) self.assertEqual( "Test Message", - sensor.state) + sensor.device_state_attributes['body']) def test_multi_part_only_other_text(self): """Test multi part emails with only other text.""" @@ -136,7 +141,9 @@ class EmailContentSensor(unittest.TestCase): sensor.entity_id = 'sensor.emailtest' sensor.schedule_update_ha_state(True) self.hass.block_till_done() - self.assertEqual("Test Message", sensor.state) + self.assertEqual('Link', sensor.state) + self.assertEqual("Test Message", + sensor.device_state_attributes['body']) def test_multiple_emails(self): """Test multiple emails.""" @@ -172,10 +179,11 @@ class EmailContentSensor(unittest.TestCase): sensor.schedule_update_ha_state(True) self.hass.block_till_done() - self.assertEqual("Test Message", states[0].state) - self.assertEqual("Test Message 2", states[1].state) + self.assertEqual("Test", states[0].state) + self.assertEqual("Test 2", states[1].state) - self.assertEqual("Test Message 2", sensor.state) + self.assertEqual("Test Message 2", + sensor.device_state_attributes['body']) def test_sender_not_allowed(self): """Test not whitelisted emails.""" From add89d69737698b383b5ba4d6b57f6512f7fb1be Mon Sep 17 00:00:00 2001 From: Mitko Masarliev Date: Tue, 2 Jan 2018 04:19:49 +0200 Subject: [PATCH 128/238] Notify webos timeout error fix (#11027) * use smaller icon * add timeout option * remove default icon, remove timeout option * add image again --- homeassistant/components/notify/webostv.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/notify/webostv.py b/homeassistant/components/notify/webostv.py index c70b198a333..78c43c5f0ad 100644 --- a/homeassistant/components/notify/webostv.py +++ b/homeassistant/components/notify/webostv.py @@ -5,7 +5,6 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/notify.webostv/ """ import logging -import os import voluptuous as vol @@ -19,14 +18,11 @@ REQUIREMENTS = ['pylgtv==0.1.7'] _LOGGER = logging.getLogger(__name__) WEBOSTV_CONFIG_FILE = 'webostv.conf' -HOME_ASSISTANT_ICON_PATH = os.path.join(os.path.dirname(__file__), '..', - 'frontend', 'www_static', 'icons', - 'favicon-1024x1024.png') PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_FILENAME, default=WEBOSTV_CONFIG_FILE): cv.string, - vol.Optional(CONF_ICON, default=HOME_ASSISTANT_ICON_PATH): cv.string + vol.Optional(CONF_ICON): cv.string }) @@ -36,7 +32,8 @@ def get_service(hass, config, discovery_info=None): from pylgtv import PyLGTVPairException path = hass.config.path(config.get(CONF_FILENAME)) - client = WebOsClient(config.get(CONF_HOST), key_file_path=path) + client = WebOsClient(config.get(CONF_HOST), key_file_path=path, + timeout_connect=8) if not client.is_registered(): try: From 541707c3e70e476cc45fc9af4ac693491101bee0 Mon Sep 17 00:00:00 2001 From: Marius Date: Tue, 2 Jan 2018 04:22:27 +0200 Subject: [PATCH 129/238] Removed status block to allow https://github.com/home-assistant/home-assistant-polymer/pull/766 with no impact (#11345) --- homeassistant/components/climate/netatmo.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/homeassistant/components/climate/netatmo.py b/homeassistant/components/climate/netatmo.py index 2166070a572..7155aaf5924 100755 --- a/homeassistant/components/climate/netatmo.py +++ b/homeassistant/components/climate/netatmo.py @@ -79,11 +79,6 @@ class NetatmoThermostat(ClimateDevice): """Return the name of the sensor.""" return self._name - @property - def state(self): - """Return the state of the device.""" - return self._target_temperature - @property def temperature_unit(self): """Return the unit of measurement.""" From f0bf7b0def5b8d781b1b27dd07994b82448b7d92 Mon Sep 17 00:00:00 2001 From: Dan Nixon Date: Tue, 2 Jan 2018 02:32:29 +0000 Subject: [PATCH 130/238] More support for availability reporting on MQTT components (#11336) * Abstract MQTT availability from individual components - Moved availability topic and payloads to MQTT base schema. - Updated components that already report availability: - Switch - Binary sensor - Cover * Add availability reporting to additional MQTT components - Light - JSON light - Template light - Lock - Fan - HVAC - Sensor - Vacuum - Alarm control panel * Annotate MQTT platform coroutines --- .../components/alarm_control_panel/mqtt.py | 24 ++++--- .../components/binary_sensor/mqtt.py | 49 +++----------- homeassistant/components/camera/mqtt.py | 6 +- homeassistant/components/climate/mqtt.py | 22 +++++-- homeassistant/components/cover/mqtt.py | 34 +++------- homeassistant/components/fan/mqtt.py | 21 ++++-- homeassistant/components/light/mqtt.py | 21 ++++-- homeassistant/components/light/mqtt_json.py | 23 ++++--- .../components/light/mqtt_template.py | 23 ++++--- homeassistant/components/lock/mqtt.py | 21 ++++-- homeassistant/components/mqtt/__init__.py | 51 +++++++++++++++ homeassistant/components/sensor/mqtt.py | 24 ++++--- homeassistant/components/sensor/mqtt_room.py | 6 +- homeassistant/components/switch/mqtt.py | 49 +++----------- homeassistant/components/vacuum/mqtt.py | 23 +++++-- .../alarm_control_panel/test_mqtt.py | 33 +++++++++- tests/components/climate/test_mqtt.py | 26 +++++++- tests/components/fan/test_mqtt.py | 64 +++++++++++++++++++ tests/components/light/test_mqtt.py | 33 +++++++++- tests/components/light/test_mqtt_json.py | 32 +++++++++- tests/components/light/test_mqtt_template.py | 33 +++++++++- tests/components/lock/test_mqtt.py | 35 +++++++++- tests/components/sensor/test_mqtt.py | 30 ++++++++- tests/components/switch/test_mqtt.py | 10 +-- tests/components/vacuum/test_mqtt.py | 30 ++++++++- 25 files changed, 530 insertions(+), 193 deletions(-) create mode 100644 tests/components/fan/test_mqtt.py diff --git a/homeassistant/components/alarm_control_panel/mqtt.py b/homeassistant/components/alarm_control_panel/mqtt.py index fca935388c1..a4559160e3b 100644 --- a/homeassistant/components/alarm_control_panel/mqtt.py +++ b/homeassistant/components/alarm_control_panel/mqtt.py @@ -17,7 +17,9 @@ from homeassistant.const import ( STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED, STATE_UNKNOWN, CONF_NAME, CONF_CODE) from homeassistant.components.mqtt import ( - CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_QOS) + CONF_AVAILABILITY_TOPIC, CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, + CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, + MqttAvailability) import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -54,15 +56,21 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): config.get(CONF_PAYLOAD_DISARM), config.get(CONF_PAYLOAD_ARM_HOME), config.get(CONF_PAYLOAD_ARM_AWAY), - config.get(CONF_CODE))]) + config.get(CONF_CODE), + config.get(CONF_AVAILABILITY_TOPIC), + config.get(CONF_PAYLOAD_AVAILABLE), + config.get(CONF_PAYLOAD_NOT_AVAILABLE))]) -class MqttAlarm(alarm.AlarmControlPanel): +class MqttAlarm(MqttAvailability, alarm.AlarmControlPanel): """Representation of a MQTT alarm status.""" def __init__(self, name, state_topic, command_topic, qos, payload_disarm, - payload_arm_home, payload_arm_away, code): + payload_arm_home, payload_arm_away, code, availability_topic, + payload_available, payload_not_available): """Init the MQTT Alarm Control Panel.""" + super().__init__(availability_topic, qos, payload_available, + payload_not_available) self._state = STATE_UNKNOWN self._name = name self._state_topic = state_topic @@ -73,11 +81,11 @@ class MqttAlarm(alarm.AlarmControlPanel): self._payload_arm_away = payload_arm_away self._code = code + @asyncio.coroutine def async_added_to_hass(self): - """Subscribe mqtt events. + """Subscribe mqtt events.""" + yield from super().async_added_to_hass() - This method must be run in the event loop and returns a coroutine. - """ @callback def message_received(topic, payload, qos): """Run when new MQTT message has been received.""" @@ -89,7 +97,7 @@ class MqttAlarm(alarm.AlarmControlPanel): self._state = payload self.async_schedule_update_ha_state() - return mqtt.async_subscribe( + yield from mqtt.async_subscribe( self.hass, self._state_topic, message_received, self._qos) @property diff --git a/homeassistant/components/binary_sensor/mqtt.py b/homeassistant/components/binary_sensor/mqtt.py index c5fba72bde0..983c879338d 100644 --- a/homeassistant/components/binary_sensor/mqtt.py +++ b/homeassistant/components/binary_sensor/mqtt.py @@ -17,19 +17,15 @@ from homeassistant.const import ( CONF_NAME, CONF_VALUE_TEMPLATE, CONF_PAYLOAD_ON, CONF_PAYLOAD_OFF, CONF_DEVICE_CLASS) from homeassistant.components.mqtt import ( - CONF_STATE_TOPIC, CONF_AVAILABILITY_TOPIC, CONF_QOS, valid_subscribe_topic) + CONF_STATE_TOPIC, CONF_AVAILABILITY_TOPIC, CONF_PAYLOAD_AVAILABLE, + CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, MqttAvailability) import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -CONF_PAYLOAD_AVAILABLE = 'payload_available' -CONF_PAYLOAD_NOT_AVAILABLE = 'payload_not_available' - DEFAULT_NAME = 'MQTT Binary sensor' DEFAULT_PAYLOAD_OFF = 'OFF' DEFAULT_PAYLOAD_ON = 'ON' -DEFAULT_PAYLOAD_AVAILABLE = 'online' -DEFAULT_PAYLOAD_NOT_AVAILABLE = 'offline' DEPENDENCIES = ['mqtt'] @@ -38,12 +34,7 @@ PLATFORM_SCHEMA = mqtt.MQTT_RO_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_PAYLOAD_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string, vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_AVAILABILITY_TOPIC): valid_subscribe_topic, - vol.Optional(CONF_PAYLOAD_AVAILABLE, - default=DEFAULT_PAYLOAD_AVAILABLE): cv.string, - vol.Optional(CONF_PAYLOAD_NOT_AVAILABLE, - default=DEFAULT_PAYLOAD_NOT_AVAILABLE): cv.string, -}) +}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) @asyncio.coroutine @@ -70,31 +61,29 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): )]) -class MqttBinarySensor(BinarySensorDevice): +class MqttBinarySensor(MqttAvailability, BinarySensorDevice): """Representation a binary sensor that is updated by MQTT.""" def __init__(self, name, state_topic, availability_topic, device_class, qos, payload_on, payload_off, payload_available, payload_not_available, value_template): """Initialize the MQTT binary sensor.""" + super().__init__(availability_topic, qos, payload_available, + payload_not_available) self._name = name self._state = None self._state_topic = state_topic - self._availability_topic = availability_topic - self._available = True if availability_topic is None else False self._device_class = device_class self._payload_on = payload_on self._payload_off = payload_off - self._payload_available = payload_available - self._payload_not_available = payload_not_available self._qos = qos self._template = value_template + @asyncio.coroutine def async_added_to_hass(self): - """Subscribe mqtt events. + """Subscribe mqtt events.""" + yield from super().async_added_to_hass() - This method must be run in the event loop and returns a coroutine. - """ @callback def state_message_received(topic, payload, qos): """Handle a new received MQTT state message.""" @@ -111,21 +100,6 @@ class MqttBinarySensor(BinarySensorDevice): yield from mqtt.async_subscribe( self.hass, self._state_topic, state_message_received, self._qos) - @callback - def availability_message_received(topic, payload, qos): - """Handle a new received MQTT availability message.""" - if payload == self._payload_available: - self._available = True - elif payload == self._payload_not_available: - self._available = False - - self.async_schedule_update_ha_state() - - if self._availability_topic is not None: - yield from mqtt.async_subscribe( - self.hass, self._availability_topic, - availability_message_received, self._qos) - @property def should_poll(self): """Return the polling state.""" @@ -136,11 +110,6 @@ class MqttBinarySensor(BinarySensorDevice): """Return the name of the binary sensor.""" return self._name - @property - def available(self) -> bool: - """Return if the binary sensor is available.""" - return self._available - @property def is_on(self): """Return true if the binary sensor is on.""" diff --git a/homeassistant/components/camera/mqtt.py b/homeassistant/components/camera/mqtt.py index 8d72ec35a28..b7a7510e0eb 100755 --- a/homeassistant/components/camera/mqtt.py +++ b/homeassistant/components/camera/mqtt.py @@ -60,11 +60,9 @@ class MqttCamera(Camera): """Return the name of this camera.""" return self._name + @asyncio.coroutine def async_added_to_hass(self): - """Subscribe MQTT events. - - This method must be run in the event loop and returns a coroutine. - """ + """Subscribe MQTT events.""" @callback def message_received(topic, payload, qos): """Handle new MQTT messages.""" diff --git a/homeassistant/components/climate/mqtt.py b/homeassistant/components/climate/mqtt.py index d571ebd39e4..ae71e5a48dc 100644 --- a/homeassistant/components/climate/mqtt.py +++ b/homeassistant/components/climate/mqtt.py @@ -20,8 +20,9 @@ from homeassistant.components.climate import ( SUPPORT_AUX_HEAT) from homeassistant.const import ( STATE_ON, STATE_OFF, ATTR_TEMPERATURE, CONF_NAME) -from homeassistant.components.mqtt import (CONF_QOS, CONF_RETAIN, - MQTT_BASE_PLATFORM_SCHEMA) +from homeassistant.components.mqtt import ( + CONF_AVAILABILITY_TOPIC, CONF_QOS, CONF_RETAIN, CONF_PAYLOAD_AVAILABLE, + CONF_PAYLOAD_NOT_AVAILABLE, MQTT_BASE_PLATFORM_SCHEMA, MqttAvailability) import homeassistant.helpers.config_validation as cv from homeassistant.components.fan import (SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH) @@ -93,7 +94,7 @@ PLATFORM_SCHEMA = SCHEMA_BASE.extend({ vol.Optional(CONF_SEND_IF_OFF, default=True): cv.boolean, vol.Optional(CONF_PAYLOAD_ON, default="ON"): cv.string, vol.Optional(CONF_PAYLOAD_OFF, default="OFF"): cv.string, -}) +}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) @asyncio.coroutine @@ -134,19 +135,25 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): STATE_OFF, STATE_OFF, False, config.get(CONF_SEND_IF_OFF), config.get(CONF_PAYLOAD_ON), - config.get(CONF_PAYLOAD_OFF)) + config.get(CONF_PAYLOAD_OFF), + config.get(CONF_AVAILABILITY_TOPIC), + config.get(CONF_PAYLOAD_AVAILABLE), + config.get(CONF_PAYLOAD_NOT_AVAILABLE)) ]) -class MqttClimate(ClimateDevice): +class MqttClimate(MqttAvailability, ClimateDevice): """Representation of a demo climate device.""" def __init__(self, hass, name, topic, qos, retain, mode_list, fan_mode_list, swing_mode_list, target_temperature, away, hold, current_fan_mode, current_swing_mode, current_operation, aux, send_if_off, payload_on, - payload_off): + payload_off, availability_topic, payload_available, + payload_not_available): """Initialize the climate device.""" + super().__init__(availability_topic, qos, payload_available, + payload_not_available) self.hass = hass self._name = name self._topic = topic @@ -169,8 +176,11 @@ class MqttClimate(ClimateDevice): self._payload_on = payload_on self._payload_off = payload_off + @asyncio.coroutine def async_added_to_hass(self): """Handle being added to home assistant.""" + yield from super().async_added_to_hass() + @callback def handle_current_temp_received(topic, payload, qos): """Handle current temperature coming via MQTT.""" diff --git a/homeassistant/components/cover/mqtt.py b/homeassistant/components/cover/mqtt.py index 0a49679b9c4..9b75f03c232 100644 --- a/homeassistant/components/cover/mqtt.py +++ b/homeassistant/components/cover/mqtt.py @@ -21,8 +21,9 @@ from homeassistant.const import ( CONF_NAME, CONF_VALUE_TEMPLATE, CONF_OPTIMISTIC, STATE_OPEN, STATE_CLOSED, STATE_UNKNOWN) from homeassistant.components.mqtt import ( - CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_AVAILABILITY_TOPIC, - CONF_QOS, CONF_RETAIN, valid_publish_topic, valid_subscribe_topic) + CONF_AVAILABILITY_TOPIC, CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, + CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, CONF_RETAIN, + valid_publish_topic, valid_subscribe_topic, MqttAvailability) import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -37,8 +38,6 @@ CONF_SET_POSITION_TEMPLATE = 'set_position_template' CONF_PAYLOAD_OPEN = 'payload_open' CONF_PAYLOAD_CLOSE = 'payload_close' CONF_PAYLOAD_STOP = 'payload_stop' -CONF_PAYLOAD_AVAILABLE = 'payload_available' -CONF_PAYLOAD_NOT_AVAILABLE = 'payload_not_available' CONF_STATE_OPEN = 'state_open' CONF_STATE_CLOSED = 'state_closed' CONF_TILT_CLOSED_POSITION = 'tilt_closed_value' @@ -52,8 +51,6 @@ DEFAULT_NAME = 'MQTT Cover' DEFAULT_PAYLOAD_OPEN = 'OPEN' DEFAULT_PAYLOAD_CLOSE = 'CLOSE' DEFAULT_PAYLOAD_STOP = 'STOP' -DEFAULT_PAYLOAD_AVAILABLE = 'online' -DEFAULT_PAYLOAD_NOT_AVAILABLE = 'offline' DEFAULT_OPTIMISTIC = False DEFAULT_RETAIN = False DEFAULT_TILT_CLOSED_POSITION = 0 @@ -73,16 +70,11 @@ PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_SET_POSITION_TEMPLATE, default=None): cv.template, vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, vol.Optional(CONF_STATE_TOPIC): valid_subscribe_topic, - vol.Optional(CONF_AVAILABILITY_TOPIC, default=None): valid_subscribe_topic, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PAYLOAD_OPEN, default=DEFAULT_PAYLOAD_OPEN): cv.string, vol.Optional(CONF_PAYLOAD_CLOSE, default=DEFAULT_PAYLOAD_CLOSE): cv.string, vol.Optional(CONF_PAYLOAD_STOP, default=DEFAULT_PAYLOAD_STOP): cv.string, - vol.Optional(CONF_PAYLOAD_AVAILABLE, - default=DEFAULT_PAYLOAD_AVAILABLE): cv.string, - vol.Optional(CONF_PAYLOAD_NOT_AVAILABLE, - default=DEFAULT_PAYLOAD_NOT_AVAILABLE): cv.string, vol.Optional(CONF_STATE_OPEN, default=STATE_OPEN): cv.string, vol.Optional(CONF_STATE_CLOSED, default=STATE_CLOSED): cv.string, vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, @@ -98,7 +90,7 @@ PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ default=DEFAULT_TILT_OPTIMISTIC): cv.boolean, vol.Optional(CONF_TILT_INVERT_STATE, default=DEFAULT_TILT_INVERT_STATE): cv.boolean, -}) +}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) @asyncio.coroutine @@ -143,7 +135,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): )]) -class MqttCover(CoverDevice): +class MqttCover(MqttAvailability, CoverDevice): """Representation of a cover that can be controlled using MQTT.""" def __init__(self, name, state_topic, command_topic, availability_topic, @@ -154,21 +146,19 @@ class MqttCover(CoverDevice): tilt_closed_position, tilt_min, tilt_max, tilt_optimistic, tilt_invert, position_topic, set_position_template): """Initialize the cover.""" + super().__init__(availability_topic, qos, payload_available, + payload_not_available) self._position = None self._state = None self._name = name self._state_topic = state_topic self._command_topic = command_topic - self._availability_topic = availability_topic - self._available = True if availability_topic is None else False self._tilt_command_topic = tilt_command_topic self._tilt_status_topic = tilt_status_topic self._qos = qos self._payload_open = payload_open self._payload_close = payload_close self._payload_stop = payload_stop - self._payload_available = payload_available - self._payload_not_available = payload_not_available self._state_open = state_open self._state_closed = state_closed self._retain = retain @@ -186,10 +176,9 @@ class MqttCover(CoverDevice): @asyncio.coroutine def async_added_to_hass(self): - """Subscribe MQTT events. + """Subscribe MQTT events.""" + yield from super().async_added_to_hass() - This method is a coroutine. - """ @callback def tilt_updated(topic, payload, qos): """Handle tilt updates.""" @@ -266,11 +255,6 @@ class MqttCover(CoverDevice): """Return the name of the cover.""" return self._name - @property - def available(self) -> bool: - """Return if cover is available.""" - return self._available - @property def is_closed(self): """Return if the cover is closed.""" diff --git a/homeassistant/components/fan/mqtt.py b/homeassistant/components/fan/mqtt.py index eed6cf898c1..1ecbb12bcb4 100644 --- a/homeassistant/components/fan/mqtt.py +++ b/homeassistant/components/fan/mqtt.py @@ -15,7 +15,9 @@ from homeassistant.const import ( CONF_NAME, CONF_OPTIMISTIC, CONF_STATE, STATE_ON, STATE_OFF, CONF_PAYLOAD_OFF, CONF_PAYLOAD_ON) from homeassistant.components.mqtt import ( - CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN) + CONF_AVAILABILITY_TOPIC, CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, + CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, CONF_RETAIN, + MqttAvailability) import homeassistant.helpers.config_validation as cv from homeassistant.components.fan import (SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH, FanEntity, @@ -72,7 +74,7 @@ PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ default=[SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH]): cv.ensure_list, vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, -}) +}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) @asyncio.coroutine @@ -111,15 +113,21 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): }, config.get(CONF_SPEED_LIST), config.get(CONF_OPTIMISTIC), + config.get(CONF_AVAILABILITY_TOPIC), + config.get(CONF_PAYLOAD_AVAILABLE), + config.get(CONF_PAYLOAD_NOT_AVAILABLE), )]) -class MqttFan(FanEntity): +class MqttFan(MqttAvailability, FanEntity): """A MQTT fan component.""" def __init__(self, name, topic, templates, qos, retain, payload, - speed_list, optimistic): + speed_list, optimistic, availability_topic, payload_available, + payload_not_available): """Initialize the MQTT fan.""" + super().__init__(availability_topic, qos, payload_available, + payload_not_available) self._name = name self._topic = topic self._qos = qos @@ -143,10 +151,9 @@ class MqttFan(FanEntity): @asyncio.coroutine def async_added_to_hass(self): - """Subscribe to MQTT events. + """Subscribe to MQTT events.""" + yield from super().async_added_to_hass() - This method is a coroutine. - """ templates = {} for key, tpl in list(self._templates.items()): if tpl is None: diff --git a/homeassistant/components/light/mqtt.py b/homeassistant/components/light/mqtt.py index 0348af664a5..f97e37127b1 100644 --- a/homeassistant/components/light/mqtt.py +++ b/homeassistant/components/light/mqtt.py @@ -21,7 +21,9 @@ from homeassistant.const import ( CONF_OPTIMISTIC, CONF_PAYLOAD_OFF, CONF_PAYLOAD_ON, CONF_RGB, CONF_STATE, CONF_VALUE_TEMPLATE, CONF_WHITE_VALUE, CONF_XY) from homeassistant.components.mqtt import ( - CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN, CONF_STATE_TOPIC) + CONF_AVAILABILITY_TOPIC, CONF_COMMAND_TOPIC, CONF_PAYLOAD_AVAILABLE, + CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, CONF_RETAIN, CONF_STATE_TOPIC, + MqttAvailability) import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -95,7 +97,7 @@ PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_XY_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_ON_COMMAND_TYPE, default=DEFAULT_ON_COMMAND_TYPE): vol.In(VALUES_ON_COMMAND_TYPE), -}) +}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) @asyncio.coroutine @@ -148,16 +150,22 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): config.get(CONF_BRIGHTNESS_SCALE), config.get(CONF_WHITE_VALUE_SCALE), config.get(CONF_ON_COMMAND_TYPE), + config.get(CONF_AVAILABILITY_TOPIC), + config.get(CONF_PAYLOAD_AVAILABLE), + config.get(CONF_PAYLOAD_NOT_AVAILABLE), )]) -class MqttLight(Light): +class MqttLight(MqttAvailability, Light): """Representation of a MQTT light.""" def __init__(self, name, effect_list, topic, templates, qos, retain, payload, optimistic, brightness_scale, - white_value_scale, on_command_type): + white_value_scale, on_command_type, availability_topic, + payload_available, payload_not_available): """Initialize MQTT light.""" + super().__init__(availability_topic, qos, payload_available, + payload_not_available) self._name = name self._effect_list = effect_list self._topic = topic @@ -208,10 +216,9 @@ class MqttLight(Light): @asyncio.coroutine def async_added_to_hass(self): - """Subscribe to MQTT events. + """Subscribe to MQTT events.""" + yield from super().async_added_to_hass() - This method is a coroutine. - """ templates = {} for key, tpl in list(self._templates.items()): if tpl is None: diff --git a/homeassistant/components/light/mqtt_json.py b/homeassistant/components/light/mqtt_json.py index e3e3f7dafde..3646de977cf 100755 --- a/homeassistant/components/light/mqtt_json.py +++ b/homeassistant/components/light/mqtt_json.py @@ -21,7 +21,9 @@ from homeassistant.const import ( CONF_BRIGHTNESS, CONF_COLOR_TEMP, CONF_EFFECT, CONF_NAME, CONF_OPTIMISTIC, CONF_RGB, CONF_WHITE_VALUE, CONF_XY) from homeassistant.components.mqtt import ( - CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN) + CONF_AVAILABILITY_TOPIC, CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, + CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, CONF_RETAIN, + MqttAvailability) import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -66,7 +68,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_WHITE_VALUE, default=DEFAULT_WHITE_VALUE): cv.boolean, vol.Optional(CONF_XY, default=DEFAULT_XY): cv.boolean, vol.Required(CONF_COMMAND_TOPIC): mqtt.valid_publish_topic, -}) +}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) @asyncio.coroutine @@ -97,17 +99,23 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): CONF_FLASH_TIME_SHORT, CONF_FLASH_TIME_LONG ) - } + }, + config.get(CONF_AVAILABILITY_TOPIC), + config.get(CONF_PAYLOAD_AVAILABLE), + config.get(CONF_PAYLOAD_NOT_AVAILABLE) )]) -class MqttJson(Light): +class MqttJson(MqttAvailability, Light): """Representation of a MQTT JSON light.""" def __init__(self, name, effect_list, topic, qos, retain, optimistic, brightness, color_temp, effect, rgb, white_value, xy, - flash_times): + flash_times, availability_topic, payload_available, + payload_not_available): """Initialize MQTT JSON light.""" + super().__init__(availability_topic, qos, payload_available, + payload_not_available) self._name = name self._effect_list = effect_list self._topic = topic @@ -157,10 +165,9 @@ class MqttJson(Light): @asyncio.coroutine def async_added_to_hass(self): - """Subscribe to MQTT events. + """Subscribe to MQTT events.""" + yield from super().async_added_to_hass() - This method is a coroutine. - """ @callback def state_received(topic, payload, qos): """Handle new MQTT messages.""" diff --git a/homeassistant/components/light/mqtt_template.py b/homeassistant/components/light/mqtt_template.py index 6dabedbd444..de0f6d934c6 100755 --- a/homeassistant/components/light/mqtt_template.py +++ b/homeassistant/components/light/mqtt_template.py @@ -17,7 +17,9 @@ from homeassistant.components.light import ( SUPPORT_RGB_COLOR, SUPPORT_TRANSITION, SUPPORT_WHITE_VALUE) from homeassistant.const import CONF_NAME, CONF_OPTIMISTIC, STATE_ON, STATE_OFF from homeassistant.components.mqtt import ( - CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN) + CONF_AVAILABILITY_TOPIC, CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, + CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, CONF_RETAIN, + MqttAvailability) import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -60,7 +62,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_COMMAND_TOPIC): mqtt.valid_publish_topic, vol.Optional(CONF_QOS, default=mqtt.DEFAULT_QOS): vol.All(vol.Coerce(int), vol.In([0, 1, 2])), -}) +}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) @asyncio.coroutine @@ -95,16 +97,22 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): }, config.get(CONF_OPTIMISTIC), config.get(CONF_QOS), - config.get(CONF_RETAIN) + config.get(CONF_RETAIN), + config.get(CONF_AVAILABILITY_TOPIC), + config.get(CONF_PAYLOAD_AVAILABLE), + config.get(CONF_PAYLOAD_NOT_AVAILABLE), )]) -class MqttTemplate(Light): +class MqttTemplate(MqttAvailability, Light): """Representation of a MQTT Template light.""" def __init__(self, hass, name, effect_list, topics, templates, optimistic, - qos, retain): + qos, retain, availability_topic, payload_available, + payload_not_available): """Initialize a MQTT Template light.""" + super().__init__(availability_topic, qos, payload_available, + payload_not_available) self._name = name self._effect_list = effect_list self._topics = topics @@ -145,10 +153,9 @@ class MqttTemplate(Light): @asyncio.coroutine def async_added_to_hass(self): - """Subscribe to MQTT events. + """Subscribe to MQTT events.""" + yield from super().async_added_to_hass() - This method is a coroutine. - """ @callback def state_received(topic, payload, qos): """Handle new MQTT messages.""" diff --git a/homeassistant/components/lock/mqtt.py b/homeassistant/components/lock/mqtt.py index b2533145a20..e73e35a9900 100644 --- a/homeassistant/components/lock/mqtt.py +++ b/homeassistant/components/lock/mqtt.py @@ -12,7 +12,9 @@ import voluptuous as vol from homeassistant.core import callback from homeassistant.components.lock import LockDevice from homeassistant.components.mqtt import ( - CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN) + CONF_AVAILABILITY_TOPIC, CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, + CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, CONF_RETAIN, + MqttAvailability) from homeassistant.const import ( CONF_NAME, CONF_OPTIMISTIC, CONF_VALUE_TEMPLATE) import homeassistant.components.mqtt as mqtt @@ -36,7 +38,7 @@ PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_PAYLOAD_UNLOCK, default=DEFAULT_PAYLOAD_UNLOCK): cv.string, vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, -}) +}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) @asyncio.coroutine @@ -56,15 +58,21 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): config.get(CONF_PAYLOAD_UNLOCK), config.get(CONF_OPTIMISTIC), value_template, + config.get(CONF_AVAILABILITY_TOPIC), + config.get(CONF_PAYLOAD_AVAILABLE), + config.get(CONF_PAYLOAD_NOT_AVAILABLE) )]) -class MqttLock(LockDevice): +class MqttLock(MqttAvailability, LockDevice): """Representation of a lock that can be toggled using MQTT.""" def __init__(self, name, state_topic, command_topic, qos, retain, - payload_lock, payload_unlock, optimistic, value_template): + payload_lock, payload_unlock, optimistic, value_template, + availability_topic, payload_available, payload_not_available): """Initialize the lock.""" + super().__init__(availability_topic, qos, payload_available, + payload_not_available) self._state = False self._name = name self._state_topic = state_topic @@ -78,10 +86,9 @@ class MqttLock(LockDevice): @asyncio.coroutine def async_added_to_hass(self): - """Subscribe to MQTT events. + """Subscribe to MQTT events.""" + yield from super().async_added_to_hass() - This method is a coroutine. - """ @callback def message_received(topic, payload, qos): """Handle new MQTT messages.""" diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 3a6abec0ddf..def89603b28 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -23,6 +23,7 @@ from homeassistant.loader import bind_hass from homeassistant.helpers import template, config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, dispatcher_send) +from homeassistant.helpers.entity import Entity from homeassistant.util.async import ( run_coroutine_threadsafe, run_callback_threadsafe) from homeassistant.const import ( @@ -59,6 +60,8 @@ CONF_WILL_MESSAGE = 'will_message' CONF_STATE_TOPIC = 'state_topic' CONF_COMMAND_TOPIC = 'command_topic' CONF_AVAILABILITY_TOPIC = 'availability_topic' +CONF_PAYLOAD_AVAILABLE = 'payload_available' +CONF_PAYLOAD_NOT_AVAILABLE = 'payload_not_available' CONF_QOS = 'qos' CONF_RETAIN = 'retain' @@ -73,6 +76,8 @@ DEFAULT_PROTOCOL = PROTOCOL_311 DEFAULT_DISCOVERY = False DEFAULT_DISCOVERY_PREFIX = 'homeassistant' DEFAULT_TLS_PROTOCOL = 'auto' +DEFAULT_PAYLOAD_AVAILABLE = 'online' +DEFAULT_PAYLOAD_NOT_AVAILABLE = 'offline' ATTR_TOPIC = 'topic' ATTR_PAYLOAD = 'payload' @@ -145,6 +150,14 @@ SCHEMA_BASE = { vol.Optional(CONF_QOS, default=DEFAULT_QOS): _VALID_QOS_SCHEMA, } +MQTT_AVAILABILITY_SCHEMA = vol.Schema({ + vol.Optional(CONF_AVAILABILITY_TOPIC): valid_subscribe_topic, + vol.Optional(CONF_PAYLOAD_AVAILABLE, + default=DEFAULT_PAYLOAD_AVAILABLE): cv.string, + vol.Optional(CONF_PAYLOAD_NOT_AVAILABLE, + default=DEFAULT_PAYLOAD_NOT_AVAILABLE): cv.string, +}) + MQTT_BASE_PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend(SCHEMA_BASE) # Sensor type platforms subscribe to MQTT events @@ -653,3 +666,41 @@ def _match_topic(subscription, topic): reg = re.compile(reg_ex) return reg.match(topic) is not None + + +class MqttAvailability(Entity): + """Mixin used for platforms that report availability.""" + + def __init__(self, availability_topic, qos, payload_available, + payload_not_available): + """Initialize the availability mixin.""" + self._availability_topic = availability_topic + self._availability_qos = qos + self._available = availability_topic is None + self._payload_available = payload_available + self._payload_not_available = payload_not_available + + def async_added_to_hass(self): + """Subscribe mqtt events. + + This method must be run in the event loop and returns a coroutine. + """ + @callback + def availability_message_received(topic, payload, qos): + """Handle a new received MQTT availability message.""" + if payload == self._payload_available: + self._available = True + elif payload == self._payload_not_available: + self._available = False + + self.async_schedule_update_ha_state() + + if self._availability_topic is not None: + yield from async_subscribe( + self.hass, self._availability_topic, + availability_message_received, self. _availability_qos) + + @property + def available(self) -> bool: + """Return if the device is available.""" + return self._available diff --git a/homeassistant/components/sensor/mqtt.py b/homeassistant/components/sensor/mqtt.py index bf7de94b5d7..f82c87c9ef5 100644 --- a/homeassistant/components/sensor/mqtt.py +++ b/homeassistant/components/sensor/mqtt.py @@ -11,7 +11,9 @@ from datetime import timedelta import voluptuous as vol from homeassistant.core import callback -from homeassistant.components.mqtt import CONF_STATE_TOPIC, CONF_QOS +from homeassistant.components.mqtt import ( + CONF_AVAILABILITY_TOPIC, CONF_STATE_TOPIC, CONF_PAYLOAD_AVAILABLE, + CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, MqttAvailability) from homeassistant.const import ( CONF_FORCE_UPDATE, CONF_NAME, CONF_VALUE_TEMPLATE, STATE_UNKNOWN, CONF_UNIT_OF_MEASUREMENT) @@ -34,7 +36,7 @@ PLATFORM_SCHEMA = mqtt.MQTT_RO_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, vol.Optional(CONF_EXPIRE_AFTER): cv.positive_int, vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, -}) +}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) @asyncio.coroutine @@ -55,15 +57,21 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): config.get(CONF_FORCE_UPDATE), config.get(CONF_EXPIRE_AFTER), value_template, + config.get(CONF_AVAILABILITY_TOPIC), + config.get(CONF_PAYLOAD_AVAILABLE), + config.get(CONF_PAYLOAD_NOT_AVAILABLE), )]) -class MqttSensor(Entity): +class MqttSensor(MqttAvailability, Entity): """Representation of a sensor that can be updated using MQTT.""" def __init__(self, name, state_topic, qos, unit_of_measurement, - force_update, expire_after, value_template): + force_update, expire_after, value_template, + availability_topic, payload_available, payload_not_available): """Initialize the sensor.""" + super().__init__(availability_topic, qos, payload_available, + payload_not_available) self._state = STATE_UNKNOWN self._name = name self._state_topic = state_topic @@ -74,11 +82,11 @@ class MqttSensor(Entity): self._expire_after = expire_after self._expiration_trigger = None + @asyncio.coroutine def async_added_to_hass(self): - """Subscribe to MQTT events. + """Subscribe to MQTT events.""" + yield from super().async_added_to_hass() - This method must be run in the event loop and returns a coroutine. - """ @callback def message_received(topic, payload, qos): """Handle new MQTT messages.""" @@ -102,7 +110,7 @@ class MqttSensor(Entity): self._state = payload self.async_schedule_update_ha_state() - return mqtt.async_subscribe( + yield from mqtt.async_subscribe( self.hass, self._state_topic, message_received, self._qos) @callback diff --git a/homeassistant/components/sensor/mqtt_room.py b/homeassistant/components/sensor/mqtt_room.py index 40c6ce7458c..2c0f8eb4d5a 100644 --- a/homeassistant/components/sensor/mqtt_room.py +++ b/homeassistant/components/sensor/mqtt_room.py @@ -80,11 +80,9 @@ class MQTTRoomSensor(Entity): self._distance = None self._updated = None + @asyncio.coroutine def async_added_to_hass(self): - """Subscribe to MQTT events. - - This method must be run in the event loop and returns a coroutine. - """ + """Subscribe to MQTT events.""" @callback def update_state(device_id, room, distance): """Update the sensor state.""" diff --git a/homeassistant/components/switch/mqtt.py b/homeassistant/components/switch/mqtt.py index 21820b4a015..a4aea1ded9f 100644 --- a/homeassistant/components/switch/mqtt.py +++ b/homeassistant/components/switch/mqtt.py @@ -11,8 +11,9 @@ import voluptuous as vol from homeassistant.core import callback from homeassistant.components.mqtt import ( - CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_AVAILABILITY_TOPIC, CONF_QOS, - CONF_RETAIN) + CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_AVAILABILITY_TOPIC, + CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, CONF_RETAIN, + MqttAvailability) from homeassistant.components.switch import SwitchDevice from homeassistant.const import ( CONF_NAME, CONF_OPTIMISTIC, CONF_VALUE_TEMPLATE, CONF_PAYLOAD_OFF, @@ -24,26 +25,17 @@ _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['mqtt'] -CONF_PAYLOAD_AVAILABLE = 'payload_available' -CONF_PAYLOAD_NOT_AVAILABLE = 'payload_not_available' - DEFAULT_NAME = 'MQTT Switch' DEFAULT_PAYLOAD_ON = 'ON' DEFAULT_PAYLOAD_OFF = 'OFF' DEFAULT_OPTIMISTIC = False -DEFAULT_PAYLOAD_AVAILABLE = 'ON' -DEFAULT_PAYLOAD_NOT_AVAILABLE = 'OFF' PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string, vol.Optional(CONF_PAYLOAD_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string, vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, - vol.Optional(CONF_PAYLOAD_AVAILABLE, - default=DEFAULT_PAYLOAD_AVAILABLE): cv.string, - vol.Optional(CONF_PAYLOAD_NOT_AVAILABLE, - default=DEFAULT_PAYLOAD_NOT_AVAILABLE): cv.string, -}) +}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) @asyncio.coroutine @@ -72,34 +64,31 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): )]) -class MqttSwitch(SwitchDevice): +class MqttSwitch(MqttAvailability, SwitchDevice): """Representation of a switch that can be toggled using MQTT.""" def __init__(self, name, state_topic, command_topic, availability_topic, qos, retain, payload_on, payload_off, optimistic, payload_available, payload_not_available, value_template): """Initialize the MQTT switch.""" + super().__init__(availability_topic, qos, payload_available, + payload_not_available) self._state = False self._name = name self._state_topic = state_topic self._command_topic = command_topic - self._availability_topic = availability_topic - self._available = True if availability_topic is None else False self._qos = qos self._retain = retain self._payload_on = payload_on self._payload_off = payload_off self._optimistic = optimistic self._template = value_template - self._payload_available = payload_available - self._payload_not_available = payload_not_available @asyncio.coroutine def async_added_to_hass(self): - """Subscribe to MQTT events. + """Subscribe to MQTT events.""" + yield from super().async_added_to_hass() - This method is a coroutine. - """ @callback def state_message_received(topic, payload, qos): """Handle new MQTT state messages.""" @@ -113,16 +102,6 @@ class MqttSwitch(SwitchDevice): self.async_schedule_update_ha_state() - @callback - def availability_message_received(topic, payload, qos): - """Handle new MQTT availability messages.""" - if payload == self._payload_available: - self._available = True - elif payload == self._payload_not_available: - self._available = False - - self.async_schedule_update_ha_state() - if self._state_topic is None: # Force into optimistic mode. self._optimistic = True @@ -131,11 +110,6 @@ class MqttSwitch(SwitchDevice): self.hass, self._state_topic, state_message_received, self._qos) - if self._availability_topic is not None: - yield from mqtt.async_subscribe( - self.hass, self._availability_topic, - availability_message_received, self._qos) - @property def should_poll(self): """Return the polling state.""" @@ -146,11 +120,6 @@ class MqttSwitch(SwitchDevice): """Return the name of the switch.""" return self._name - @property - def available(self) -> bool: - """Return if switch is available.""" - return self._available - @property def is_on(self): """Return true if device is on.""" diff --git a/homeassistant/components/vacuum/mqtt.py b/homeassistant/components/vacuum/mqtt.py index 9929ae46e09..54aea793a22 100644 --- a/homeassistant/components/vacuum/mqtt.py +++ b/homeassistant/components/vacuum/mqtt.py @@ -11,6 +11,7 @@ import voluptuous as vol import homeassistant.components.mqtt as mqtt import homeassistant.helpers.config_validation as cv +from homeassistant.components.mqtt import MqttAvailability from homeassistant.components.vacuum import ( DEFAULT_ICON, SUPPORT_BATTERY, SUPPORT_CLEAN_SPOT, SUPPORT_FAN_SPEED, SUPPORT_LOCATE, SUPPORT_PAUSE, SUPPORT_RETURN_HOME, SUPPORT_SEND_COMMAND, @@ -135,7 +136,7 @@ PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_FAN_SPEED_LIST, default=[]): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_SEND_COMMAND_TOPIC): mqtt.valid_publish_topic, -}) +}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) @asyncio.coroutine @@ -187,6 +188,10 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): send_command_topic = config.get(CONF_SEND_COMMAND_TOPIC) + availability_topic = config.get(mqtt.CONF_AVAILABILITY_TOPIC) + payload_available = config.get(mqtt.CONF_PAYLOAD_AVAILABLE) + payload_not_available = config.get(mqtt.CONF_PAYLOAD_NOT_AVAILABLE) + async_add_devices([ MqttVacuum( name, supported_features, qos, retain, command_topic, @@ -196,12 +201,13 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): charging_topic, charging_template, cleaning_topic, cleaning_template, docked_topic, docked_template, fan_speed_topic, fan_speed_template, set_fan_speed_topic, fan_speed_list, - send_command_topic + send_command_topic, availability_topic, payload_available, + payload_not_available ), ]) -class MqttVacuum(VacuumDevice): +class MqttVacuum(MqttAvailability, VacuumDevice): """Representation of a MQTT-controlled vacuum.""" # pylint: disable=no-self-use @@ -213,8 +219,12 @@ class MqttVacuum(VacuumDevice): charging_topic, charging_template, cleaning_topic, cleaning_template, docked_topic, docked_template, fan_speed_topic, fan_speed_template, set_fan_speed_topic, fan_speed_list, - send_command_topic): + send_command_topic, availability_topic, payload_available, + payload_not_available): """Initialize the vacuum.""" + super().__init__(availability_topic, qos, payload_available, + payload_not_available) + self._name = name self._supported_features = supported_features self._qos = qos @@ -257,10 +267,9 @@ class MqttVacuum(VacuumDevice): @asyncio.coroutine def async_added_to_hass(self): - """Subscribe MQTT events. + """Subscribe MQTT events.""" + yield from super().async_added_to_hass() - This method is a coroutine. - """ @callback def message_received(topic, payload, qos): """Handle new MQTT message.""" diff --git a/tests/components/alarm_control_panel/test_mqtt.py b/tests/components/alarm_control_panel/test_mqtt.py index 368a43e6113..200978ea1a0 100644 --- a/tests/components/alarm_control_panel/test_mqtt.py +++ b/tests/components/alarm_control_panel/test_mqtt.py @@ -4,7 +4,8 @@ import unittest from homeassistant.setup import setup_component from homeassistant.const import ( STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_AWAY, - STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED, STATE_UNKNOWN) + STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED, STATE_UNAVAILABLE, + STATE_UNKNOWN) from homeassistant.components import alarm_control_panel from tests.common import ( @@ -190,3 +191,33 @@ class TestAlarmControlPanelMQTT(unittest.TestCase): alarm_control_panel.alarm_disarm(self.hass, 'abcd') self.hass.block_till_done() self.assertEqual(call_count, self.mock_publish.call_count) + + def test_custom_availability_payload(self): + """Test availability by custom payload with defined topic.""" + assert setup_component(self.hass, alarm_control_panel.DOMAIN, { + alarm_control_panel.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'alarm/state', + 'command_topic': 'alarm/command', + 'code': '1234', + 'availability_topic': 'availability-topic', + 'payload_available': 'good', + 'payload_not_available': 'nogood' + } + }) + + state = self.hass.states.get('alarm_control_panel.test') + self.assertEqual(STATE_UNAVAILABLE, state.state) + + fire_mqtt_message(self.hass, 'availability-topic', 'good') + self.hass.block_till_done() + + state = self.hass.states.get('alarm_control_panel.test') + self.assertNotEqual(STATE_UNAVAILABLE, state.state) + + fire_mqtt_message(self.hass, 'availability-topic', 'nogood') + self.hass.block_till_done() + + state = self.hass.states.get('alarm_control_panel.test') + self.assertEqual(STATE_UNAVAILABLE, state.state) diff --git a/tests/components/climate/test_mqtt.py b/tests/components/climate/test_mqtt.py index 43f90eeee20..4c179fa8042 100644 --- a/tests/components/climate/test_mqtt.py +++ b/tests/components/climate/test_mqtt.py @@ -7,7 +7,7 @@ from homeassistant.util.unit_system import ( ) from homeassistant.setup import setup_component from homeassistant.components import climate -from homeassistant.const import STATE_OFF +from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE from homeassistant.components.climate import ( SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE, SUPPORT_FAN_MODE, SUPPORT_SWING_MODE, SUPPORT_HOLD_MODE, @@ -432,3 +432,27 @@ class TestMQTTClimate(unittest.TestCase): self.mock_publish.mock_calls[-2][1]) state = self.hass.states.get(ENTITY_CLIMATE) self.assertEqual('off', state.attributes.get('aux_heat')) + + def test_custom_availability_payload(self): + """Test availability by custom payload with defined topic.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config['climate']['availability_topic'] = 'availability-topic' + config['climate']['payload_available'] = 'good' + config['climate']['payload_not_available'] = 'nogood' + + assert setup_component(self.hass, climate.DOMAIN, config) + + state = self.hass.states.get('climate.test') + self.assertEqual(STATE_UNAVAILABLE, state.state) + + fire_mqtt_message(self.hass, 'availability-topic', 'good') + self.hass.block_till_done() + + state = self.hass.states.get('climate.test') + self.assertNotEqual(STATE_UNAVAILABLE, state.state) + + fire_mqtt_message(self.hass, 'availability-topic', 'nogood') + self.hass.block_till_done() + + state = self.hass.states.get('climate.test') + self.assertEqual(STATE_UNAVAILABLE, state.state) diff --git a/tests/components/fan/test_mqtt.py b/tests/components/fan/test_mqtt.py new file mode 100644 index 00000000000..3846887f21c --- /dev/null +++ b/tests/components/fan/test_mqtt.py @@ -0,0 +1,64 @@ +"""Test MQTT fans.""" +import unittest + +from homeassistant.setup import setup_component +from homeassistant.components import fan +from homeassistant.const import ATTR_ASSUMED_STATE, STATE_UNAVAILABLE + +from tests.common import ( + mock_mqtt_component, fire_mqtt_message, get_test_home_assistant) + + +class TestMqttFan(unittest.TestCase): + """Test the MQTT fan platform.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.mock_publish = mock_mqtt_component(self.hass) + + def tearDown(self): # pylint: disable=invalid-name + """"Stop everything that was started.""" + self.hass.stop() + + def test_custom_availability_payload(self): + """Test the availability payload.""" + assert setup_component(self.hass, fan.DOMAIN, { + fan.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'state-topic', + 'command_topic': 'command-topic', + 'availability_topic': 'availability_topic', + 'payload_available': 'good', + 'payload_not_available': 'nogood' + } + }) + + state = self.hass.states.get('fan.test') + self.assertEqual(STATE_UNAVAILABLE, state.state) + + fire_mqtt_message(self.hass, 'availability_topic', 'good') + self.hass.block_till_done() + + state = self.hass.states.get('fan.test') + self.assertNotEqual(STATE_UNAVAILABLE, state.state) + self.assertFalse(state.attributes.get(ATTR_ASSUMED_STATE)) + + fire_mqtt_message(self.hass, 'availability_topic', 'nogood') + self.hass.block_till_done() + + state = self.hass.states.get('fan.test') + self.assertEqual(STATE_UNAVAILABLE, state.state) + + fire_mqtt_message(self.hass, 'state-topic', '1') + self.hass.block_till_done() + + state = self.hass.states.get('fan.test') + self.assertEqual(STATE_UNAVAILABLE, state.state) + + fire_mqtt_message(self.hass, 'availability_topic', 'good') + self.hass.block_till_done() + + state = self.hass.states.get('fan.test') + self.assertNotEqual(STATE_UNAVAILABLE, state.state) diff --git a/tests/components/light/test_mqtt.py b/tests/components/light/test_mqtt.py index b074c5d84d8..d6dabaf9a4f 100644 --- a/tests/components/light/test_mqtt.py +++ b/tests/components/light/test_mqtt.py @@ -142,7 +142,8 @@ import unittest from unittest import mock from homeassistant.setup import setup_component -from homeassistant.const import STATE_ON, STATE_OFF, ATTR_ASSUMED_STATE +from homeassistant.const import ( + STATE_ON, STATE_OFF, STATE_UNAVAILABLE, ATTR_ASSUMED_STATE) import homeassistant.components.light as light from tests.common import ( assert_setup_component, get_test_home_assistant, mock_mqtt_component, @@ -794,3 +795,33 @@ class TestLightMQTT(unittest.TestCase): self.mock_publish.mock_calls[-4][1]) self.assertEqual(('test_light/bright', 50, 0, False), self.mock_publish.mock_calls[-2][1]) + + def test_custom_availability_payload(self): + """Test availability by custom payload with defined topic.""" + self.assertTrue(setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'command_topic': 'test_light/set', + 'brightness_command_topic': 'test_light/bright', + 'rgb_command_topic': "test_light/rgb", + 'availability_topic': 'availability-topic', + 'payload_available': 'good', + 'payload_not_available': 'nogood' + } + })) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_UNAVAILABLE, state.state) + + fire_mqtt_message(self.hass, 'availability-topic', 'good') + self.hass.block_till_done() + + state = self.hass.states.get('light.test') + self.assertNotEqual(STATE_UNAVAILABLE, state.state) + + fire_mqtt_message(self.hass, 'availability-topic', 'nogood') + self.hass.block_till_done() + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_UNAVAILABLE, state.state) diff --git a/tests/components/light/test_mqtt_json.py b/tests/components/light/test_mqtt_json.py index 10bb3f030e9..6bf24f595ac 100755 --- a/tests/components/light/test_mqtt_json.py +++ b/tests/components/light/test_mqtt_json.py @@ -82,7 +82,8 @@ import unittest from homeassistant.setup import setup_component from homeassistant.const import ( - STATE_ON, STATE_OFF, ATTR_ASSUMED_STATE, ATTR_SUPPORTED_FEATURES) + STATE_ON, STATE_OFF, STATE_UNAVAILABLE, ATTR_ASSUMED_STATE, + ATTR_SUPPORTED_FEATURES) import homeassistant.components.light as light from tests.common import ( get_test_home_assistant, mock_mqtt_component, fire_mqtt_message, @@ -472,3 +473,32 @@ class TestLightMQTTJSON(unittest.TestCase): state = self.hass.states.get('light.test') self.assertEqual(STATE_ON, state.state) self.assertEqual(255, state.attributes.get('white_value')) + + def test_custom_availability_payload(self): + """Test availability by custom payload with defined topic.""" + self.assertTrue(setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_json', + 'name': 'test', + 'state_topic': 'test_light_rgb', + 'command_topic': 'test_light_rgb/set', + 'availability_topic': 'availability-topic', + 'payload_available': 'good', + 'payload_not_available': 'nogood' + } + })) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_UNAVAILABLE, state.state) + + fire_mqtt_message(self.hass, 'availability-topic', 'good') + self.hass.block_till_done() + + state = self.hass.states.get('light.test') + self.assertNotEqual(STATE_UNAVAILABLE, state.state) + + fire_mqtt_message(self.hass, 'availability-topic', 'nogood') + self.hass.block_till_done() + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_UNAVAILABLE, state.state) diff --git a/tests/components/light/test_mqtt_template.py b/tests/components/light/test_mqtt_template.py index 4cda6fc64de..fddb75880cc 100755 --- a/tests/components/light/test_mqtt_template.py +++ b/tests/components/light/test_mqtt_template.py @@ -29,7 +29,8 @@ If your light doesn't support RGB feature, omit `(red|green|blue)_template`. import unittest from homeassistant.setup import setup_component -from homeassistant.const import STATE_ON, STATE_OFF, ATTR_ASSUMED_STATE +from homeassistant.const import ( + STATE_ON, STATE_OFF, STATE_UNAVAILABLE, ATTR_ASSUMED_STATE) import homeassistant.components.light as light from tests.common import ( get_test_home_assistant, mock_mqtt_component, fire_mqtt_message, @@ -463,3 +464,33 @@ class TestLightMQTTTemplate(unittest.TestCase): # effect should not have changed state = self.hass.states.get('light.test') self.assertEqual('rainbow', state.attributes.get('effect')) + + def test_custom_availability_payload(self): + """Test availability by custom payload with defined topic.""" + self.assertTrue(setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_template', + 'name': 'test', + 'command_topic': 'test_light_rgb/set', + 'command_on_template': 'on,{{ transition }}', + 'command_off_template': 'off,{{ transition|d }}', + 'availability_topic': 'availability-topic', + 'payload_available': 'good', + 'payload_not_available': 'nogood' + } + })) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_UNAVAILABLE, state.state) + + fire_mqtt_message(self.hass, 'availability-topic', 'good') + self.hass.block_till_done() + + state = self.hass.states.get('light.test') + self.assertNotEqual(STATE_UNAVAILABLE, state.state) + + fire_mqtt_message(self.hass, 'availability-topic', 'nogood') + self.hass.block_till_done() + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_UNAVAILABLE, state.state) diff --git a/tests/components/lock/test_mqtt.py b/tests/components/lock/test_mqtt.py index c66ed5f2b26..667908e13fa 100644 --- a/tests/components/lock/test_mqtt.py +++ b/tests/components/lock/test_mqtt.py @@ -2,8 +2,8 @@ import unittest from homeassistant.setup import setup_component -from homeassistant.const import (STATE_LOCKED, STATE_UNLOCKED, - ATTR_ASSUMED_STATE) +from homeassistant.const import ( + STATE_LOCKED, STATE_UNLOCKED, STATE_UNAVAILABLE, ATTR_ASSUMED_STATE) import homeassistant.components.lock as lock from tests.common import ( mock_mqtt_component, fire_mqtt_message, get_test_home_assistant) @@ -111,3 +111,34 @@ class TestLockMQTT(unittest.TestCase): state = self.hass.states.get('lock.test') self.assertEqual(STATE_UNLOCKED, state.state) + + def test_custom_availability_payload(self): + """Test availability by custom payload with defined topic.""" + self.assertTrue(setup_component(self.hass, lock.DOMAIN, { + lock.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'state-topic', + 'command_topic': 'command-topic', + 'payload_lock': 'LOCK', + 'payload_unlock': 'UNLOCK', + 'availability_topic': 'availability-topic', + 'payload_available': 'good', + 'payload_not_available': 'nogood' + } + })) + + state = self.hass.states.get('lock.test') + self.assertEqual(STATE_UNAVAILABLE, state.state) + + fire_mqtt_message(self.hass, 'availability-topic', 'good') + self.hass.block_till_done() + + state = self.hass.states.get('lock.test') + self.assertNotEqual(STATE_UNAVAILABLE, state.state) + + fire_mqtt_message(self.hass, 'availability-topic', 'nogood') + self.hass.block_till_done() + + state = self.hass.states.get('lock.test') + self.assertEqual(STATE_UNAVAILABLE, state.state) diff --git a/tests/components/sensor/test_mqtt.py b/tests/components/sensor/test_mqtt.py index 42136966e13..4f9161a5b7f 100644 --- a/tests/components/sensor/test_mqtt.py +++ b/tests/components/sensor/test_mqtt.py @@ -7,7 +7,7 @@ from unittest.mock import patch import homeassistant.core as ha from homeassistant.setup import setup_component import homeassistant.components.sensor as sensor -from homeassistant.const import EVENT_STATE_CHANGED +from homeassistant.const import EVENT_STATE_CHANGED, STATE_UNAVAILABLE import homeassistant.util.dt as dt_util from tests.common import mock_mqtt_component, fire_mqtt_message @@ -185,6 +185,34 @@ class TestSensorMQTT(unittest.TestCase): self.hass.block_till_done() self.assertEqual(2, len(events)) + def test_custom_availability_payload(self): + """Test availability by custom payload with defined topic.""" + self.assertTrue(setup_component(self.hass, sensor.DOMAIN, { + sensor.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'test-topic', + 'availability_topic': 'availability-topic', + 'payload_available': 'good', + 'payload_not_available': 'nogood' + } + })) + + state = self.hass.states.get('sensor.test') + self.assertEqual(STATE_UNAVAILABLE, state.state) + + fire_mqtt_message(self.hass, 'availability-topic', 'good') + self.hass.block_till_done() + + state = self.hass.states.get('sensor.test') + self.assertNotEqual(STATE_UNAVAILABLE, state.state) + + fire_mqtt_message(self.hass, 'availability-topic', 'nogood') + self.hass.block_till_done() + + state = self.hass.states.get('sensor.test') + self.assertEqual(STATE_UNAVAILABLE, state.state) + def _send_time_changed(self, now): """Send a time changed event.""" self.hass.bus.fire(ha.EVENT_TIME_CHANGED, {ha.ATTR_NOW: now}) diff --git a/tests/components/switch/test_mqtt.py b/tests/components/switch/test_mqtt.py index 21ab1dd31f2..a3118f8ebf0 100644 --- a/tests/components/switch/test_mqtt.py +++ b/tests/components/switch/test_mqtt.py @@ -167,22 +167,22 @@ class TestSwitchMQTT(unittest.TestCase): 'availability_topic': 'availability_topic', 'payload_on': 1, 'payload_off': 0, - 'payload_available': 'online', - 'payload_not_available': 'offline' + 'payload_available': 'good', + 'payload_not_available': 'nogood' } }) state = self.hass.states.get('switch.test') self.assertEqual(STATE_UNAVAILABLE, state.state) - fire_mqtt_message(self.hass, 'availability_topic', 'online') + fire_mqtt_message(self.hass, 'availability_topic', 'good') self.hass.block_till_done() state = self.hass.states.get('switch.test') self.assertEqual(STATE_OFF, state.state) self.assertFalse(state.attributes.get(ATTR_ASSUMED_STATE)) - fire_mqtt_message(self.hass, 'availability_topic', 'offline') + fire_mqtt_message(self.hass, 'availability_topic', 'nogood') self.hass.block_till_done() state = self.hass.states.get('switch.test') @@ -194,7 +194,7 @@ class TestSwitchMQTT(unittest.TestCase): state = self.hass.states.get('switch.test') self.assertEqual(STATE_UNAVAILABLE, state.state) - fire_mqtt_message(self.hass, 'availability_topic', 'online') + fire_mqtt_message(self.hass, 'availability_topic', 'good') self.hass.block_till_done() state = self.hass.states.get('switch.test') diff --git a/tests/components/vacuum/test_mqtt.py b/tests/components/vacuum/test_mqtt.py index f4c63d63708..f81a5c849ec 100644 --- a/tests/components/vacuum/test_mqtt.py +++ b/tests/components/vacuum/test_mqtt.py @@ -6,7 +6,8 @@ from homeassistant.components.vacuum import ( ATTR_BATTERY_LEVEL, ATTR_BATTERY_ICON, ATTR_STATUS, ATTR_FAN_SPEED, mqtt) from homeassistant.components.mqtt import CONF_COMMAND_TOPIC -from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON, CONF_NAME +from homeassistant.const import ( + CONF_PLATFORM, STATE_OFF, STATE_ON, STATE_UNAVAILABLE, CONF_NAME) from homeassistant.setup import setup_component from tests.common import ( fire_mqtt_message, get_test_home_assistant, mock_mqtt_component) @@ -197,3 +198,30 @@ class TestVacuumMQTT(unittest.TestCase): state = self.hass.states.get('vacuum.mqtttest') self.assertEqual(STATE_OFF, state.state) self.assertEqual("Stopped", state.attributes.get(ATTR_STATUS)) + + def test_custom_availability_payload(self): + """Test availability by custom payload with defined topic.""" + self.default_config.update({ + 'availability_topic': 'availability-topic', + 'payload_available': 'good', + 'payload_not_available': 'nogood' + }) + + self.assertTrue(setup_component(self.hass, vacuum.DOMAIN, { + vacuum.DOMAIN: self.default_config, + })) + + state = self.hass.states.get('vacuum.mqtttest') + self.assertEqual(STATE_UNAVAILABLE, state.state) + + fire_mqtt_message(self.hass, 'availability-topic', 'good') + self.hass.block_till_done() + + state = self.hass.states.get('vacuum.mqtttest') + self.assertNotEqual(STATE_UNAVAILABLE, state.state) + + fire_mqtt_message(self.hass, 'availability-topic', 'nogood') + self.hass.block_till_done() + + state = self.hass.states.get('vacuum.mqtttest') + self.assertEqual(STATE_UNAVAILABLE, state.state) From 909f613324127abe1e0b58f9f8885c26acca4097 Mon Sep 17 00:00:00 2001 From: Lukas Barth Date: Tue, 2 Jan 2018 03:43:10 +0100 Subject: [PATCH 131/238] Do not purge the most recent state for an entity (#11039) * Protect states that are the most recent states of their entity * Also protect events * Some documentation * Fix SQL --- homeassistant/components/recorder/purge.py | 31 ++++++++- tests/components/recorder/test_purge.py | 74 ++++++++++++++++------ 2 files changed, 85 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index 719f65abb47..328bbe68dcb 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -12,17 +12,44 @@ _LOGGER = logging.getLogger(__name__) def purge_old_data(instance, purge_days): """Purge events and states older than purge_days ago.""" from .models import States, Events + from sqlalchemy import func + purge_before = dt_util.utcnow() - timedelta(days=purge_days) with session_scope(session=instance.get_session()) as session: + # For each entity, the most recent state is protected from deletion + # s.t. we can properly restore state even if the entity has not been + # updated in a long time + protected_states = session.query(States.state_id, States.event_id, + func.max(States.last_updated)) \ + .group_by(States.entity_id).subquery() + + protected_state_ids = session.query(States.state_id).join( + protected_states, States.state_id == protected_states.c.state_id)\ + .subquery() + deleted_rows = session.query(States) \ .filter((States.last_updated < purge_before)) \ + .filter(~States.state_id.in_( + protected_state_ids)) \ .delete(synchronize_session=False) _LOGGER.debug("Deleted %s states", deleted_rows) + # We also need to protect the events belonging to the protected states. + # Otherwise, if the SQL server has "ON DELETE CASCADE" as default, it + # will delete the protected state when deleting its associated + # event. Also, we would be producing NULLed foreign keys otherwise. + + protected_event_ids = session.query(States.event_id).join( + protected_states, States.state_id == protected_states.c.state_id)\ + .filter(~States.event_id is not None).subquery() + deleted_rows = session.query(Events) \ - .filter((Events.time_fired < purge_before)) \ - .delete(synchronize_session=False) + .filter((Events.time_fired < purge_before)) \ + .filter(~Events.event_id.in_( + protected_event_ids + )) \ + .delete(synchronize_session=False) _LOGGER.debug("Deleted %s events", deleted_rows) # Execute sqlite vacuum command to free up space on disk diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index 5db710882d9..bbb87fb5016 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -55,6 +55,23 @@ class TestRecorderPurge(unittest.TestCase): event_id=event_id + 1000 )) + # if self._add_test_events was called, we added a special event + # that should be protected from deletion, too + protected_event_id = getattr(self, "_protected_event_id", 2000) + + # add a state that is old but the only state of its entity and + # should be protected + session.add(States( + entity_id='test.rarely_updated_entity', + domain='sensor', + state='iamprotected', + attributes=json.dumps(attributes), + last_changed=five_days_ago, + last_updated=five_days_ago, + created=five_days_ago, + event_id=protected_event_id + )) + def _add_test_events(self): """Add a few events for testing.""" now = datetime.now() @@ -81,19 +98,32 @@ class TestRecorderPurge(unittest.TestCase): time_fired=timestamp, )) + # Add an event for the protected state + protected_event = Events( + event_type='EVENT_TEST_FOR_PROTECTED', + event_data=json.dumps(event_data), + origin='LOCAL', + created=five_days_ago, + time_fired=five_days_ago, + ) + session.add(protected_event) + session.flush() + + self._protected_event_id = protected_event.event_id + def test_purge_old_states(self): """Test deleting old states.""" self._add_test_states() - # make sure we start with 5 states + # make sure we start with 6 states with session_scope(hass=self.hass) as session: states = session.query(States) - self.assertEqual(states.count(), 5) + self.assertEqual(states.count(), 6) # run purge_old_data() purge_old_data(self.hass.data[DATA_INSTANCE], 4) - # we should only have 2 states left after purging - self.assertEqual(states.count(), 2) + # we should only have 3 states left after purging + self.assertEqual(states.count(), 3) def test_purge_old_events(self): """Test deleting old events.""" @@ -102,7 +132,7 @@ class TestRecorderPurge(unittest.TestCase): with session_scope(hass=self.hass) as session: events = session.query(Events).filter( Events.event_type.like("EVENT_TEST%")) - self.assertEqual(events.count(), 5) + self.assertEqual(events.count(), 6) # run purge_old_data() purge_old_data(self.hass.data[DATA_INSTANCE], 4) @@ -113,17 +143,17 @@ class TestRecorderPurge(unittest.TestCase): def test_purge_method(self): """Test purge method.""" service_data = {'keep_days': 4} - self._add_test_states() self._add_test_events() + self._add_test_states() - # make sure we start with 5 states + # make sure we start with 6 states with session_scope(hass=self.hass) as session: states = session.query(States) - self.assertEqual(states.count(), 5) + self.assertEqual(states.count(), 6) events = session.query(Events).filter( Events.event_type.like("EVENT_TEST%")) - self.assertEqual(events.count(), 5) + self.assertEqual(events.count(), 6) self.hass.data[DATA_INSTANCE].block_till_done() @@ -134,11 +164,9 @@ class TestRecorderPurge(unittest.TestCase): # Small wait for recorder thread sleep(0.1) - # we should only have 2 states left after purging - self.assertEqual(states.count(), 5) - - # now we should only have 3 events left - self.assertEqual(events.count(), 5) + # we should still have everything from before + self.assertEqual(states.count(), 6) + self.assertEqual(events.count(), 6) # run purge method - correct service data self.hass.services.call('recorder', 'purge', @@ -148,8 +176,18 @@ class TestRecorderPurge(unittest.TestCase): # Small wait for recorder thread sleep(0.1) - # we should only have 2 states left after purging - self.assertEqual(states.count(), 2) + # we should only have 3 states left after purging + self.assertEqual(states.count(), 3) - # now we should only have 3 events left - self.assertEqual(events.count(), 3) + # the protected state is among them + self.assertTrue('iamprotected' in ( + state.state for state in states)) + + # now we should only have 4 events left + self.assertEqual(events.count(), 4) + + # and the protected event is among them + self.assertTrue('EVENT_TEST_FOR_PROTECTED' in ( + event.event_type for event in events.all())) + self.assertFalse('EVENT_TEST_PURGE' in ( + event.event_type for event in events.all())) From feb70b47df70a6d616ee49a63ed07e1379b9fc7a Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Tue, 2 Jan 2018 19:31:33 +0100 Subject: [PATCH 132/238] Performance optimization of TP-Link switch (#11416) --- homeassistant/components/switch/tplink.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/switch/tplink.py b/homeassistant/components/switch/tplink.py index f43d434a259..aa2e70e0020 100644 --- a/homeassistant/components/switch/tplink.py +++ b/homeassistant/components/switch/tplink.py @@ -24,12 +24,11 @@ ATTR_CURRENT_A = 'current_a' CONF_LEDS = 'enable_leds' DEFAULT_NAME = 'TP-Link Switch' -DEFAULT_LEDS = True PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_LEDS, default=DEFAULT_LEDS): cv.boolean, + vol.Optional(CONF_LEDS): cv.boolean, }) @@ -51,7 +50,8 @@ class SmartPlugSwitch(SwitchDevice): """Initialize the switch.""" self.smartplug = smartplug self._name = name - self._leds_on = leds_on + if leds_on is not None: + self.smartplug.led = leds_on self._state = None self._available = True # Set up emeter cache @@ -96,8 +96,6 @@ class SmartPlugSwitch(SwitchDevice): if self._name is None: self._name = self.smartplug.alias - self.smartplug.led = self._leds_on - if self.smartplug.has_emeter: emeter_readings = self.smartplug.get_emeter_realtime() From 0a67195529d1abd67c002508f0fc93422eceb289 Mon Sep 17 00:00:00 2001 From: tomaszduda23 <35012788+tomaszduda23@users.noreply.github.com> Date: Tue, 2 Jan 2018 20:54:06 +0100 Subject: [PATCH 133/238] Fixing OpenWeatherMap Sensor. Current weather is 'unknown' if forecast: false. It was reported as #8640. (#11417) --- homeassistant/components/sensor/openweathermap.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/openweathermap.py b/homeassistant/components/sensor/openweathermap.py index 43c7d1ec2df..d7443039e57 100755 --- a/homeassistant/components/sensor/openweathermap.py +++ b/homeassistant/components/sensor/openweathermap.py @@ -136,7 +136,7 @@ class OpenWeatherMapSensor(Entity): data = self.owa_client.data fc_data = self.owa_client.fc_data - if data is None or fc_data is None: + if data is None: return if self.type == 'weather': @@ -174,6 +174,8 @@ class OpenWeatherMapSensor(Entity): self._state = 'not snowing' self._unit_of_measurement = '' elif self.type == 'forecast': + if fc_data is None: + return self._state = fc_data.get_weathers()[0].get_status() From 02c3ea1917b9bfd4c3c6e9b350676e249351e10b Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Tue, 2 Jan 2018 21:16:32 +0100 Subject: [PATCH 134/238] Silence redundant warnings about slow setup (#11352) * Downgrade slow domain setup warning * Revert "Downgrade slow domain setup warning" This reverts commit 64472c006bb553c6bb75a024384361adad50d565. * Remove warning for entity components * Remove lint * Fix original test with extra call --- homeassistant/setup.py | 15 +++++++++++---- tests/test_setup.py | 16 +++++++++++++++- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 05a8ee1e2f1..12a39e80517 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -181,9 +181,15 @@ def _async_setup_component(hass: core.HomeAssistant, start = timer() _LOGGER.info("Setting up %s", domain) - warn_task = hass.loop.call_later( - SLOW_SETUP_WARNING, _LOGGER.warning, - "Setup of %s is taking over %s seconds.", domain, SLOW_SETUP_WARNING) + + if hasattr(component, 'PLATFORM_SCHEMA'): + # Entity components have their own warning + warn_task = None + else: + warn_task = hass.loop.call_later( + SLOW_SETUP_WARNING, _LOGGER.warning, + "Setup of %s is taking over %s seconds.", + domain, SLOW_SETUP_WARNING) try: if async_comp: @@ -197,7 +203,8 @@ def _async_setup_component(hass: core.HomeAssistant, return False finally: end = timer() - warn_task.cancel() + if warn_task: + warn_task.cancel() _LOGGER.info("Setup of domain %s took %.1f seconds.", domain, end - start) if result is False: diff --git a/tests/test_setup.py b/tests/test_setup.py index 9a0f85874ad..afea30ddcd1 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -456,7 +456,7 @@ def test_component_warn_slow_setup(hass): hass, 'test_component1', {}) assert result assert mock_call.called - assert len(mock_call.mock_calls) == 2 + assert len(mock_call.mock_calls) == 3 timeout, logger_method = mock_call.mock_calls[0][1][:2] @@ -464,3 +464,17 @@ def test_component_warn_slow_setup(hass): assert logger_method == setup._LOGGER.warning assert mock_call().cancel.called + + +@asyncio.coroutine +def test_platform_no_warn_slow(hass): + """Do not warn for long entity setup time.""" + loader.set_component( + 'test_component1', + MockModule('test_component1', platform_schema=PLATFORM_SCHEMA)) + with mock.patch.object(hass.loop, 'call_later', mock.MagicMock()) \ + as mock_call: + result = yield from setup.async_setup_component( + hass, 'test_component1', {}) + assert result + assert not mock_call.called From 86e1d0f9522cd3dd55d295008684a24d330b6564 Mon Sep 17 00:00:00 2001 From: Trevor Joynson Date: Tue, 2 Jan 2018 16:42:41 -0800 Subject: [PATCH 135/238] Account for User-Agent being non-existent, causing a TypeError (#11064) * Account for User-Agent being non-existent, causing a TypeError * Actually fix case of no user-agent with last resort * Return es5 as last resort * Update __init__.py * Update __init__.py --- homeassistant/components/frontend/__init__.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 8376a569e8b..e6292c7de82 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -579,8 +579,12 @@ def _is_latest(js_option, request): if js_option != 'auto': return js_option == 'latest' + useragent = request.headers.get('User-Agent') + if not useragent: + return False + from user_agents import parse - useragent = parse(request.headers.get('User-Agent')) + useragent = parse(useragent) # on iOS every browser is a Safari which we support from version 10. if useragent.os.family == 'iOS': From f314b6cb6ca531db6bd7bf4759d70d65fdf0acab Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 3 Jan 2018 10:16:59 -0800 Subject: [PATCH 136/238] Cloud Updates (#11404) * Verify stored keys on startup * Handle Google Assistant messages * Fix tests * Don't verify expiration when getting claims * Remove email based check * Lint * Lint * Lint --- homeassistant/components/cloud/__init__.py | 160 ++++++++++++++++----- homeassistant/components/cloud/auth_api.py | 27 +--- homeassistant/components/cloud/http_api.py | 2 +- homeassistant/components/cloud/iot.py | 18 ++- tests/components/cloud/test_auth_api.py | 15 +- tests/components/cloud/test_http_api.py | 5 +- tests/components/cloud/test_init.py | 59 ++++---- 7 files changed, 178 insertions(+), 108 deletions(-) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 58a2152f898..7f998311a6b 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -5,13 +5,17 @@ import json import logging import os +import aiohttp +import async_timeout import voluptuous as vol from homeassistant.const import ( EVENT_HOMEASSISTANT_START, CONF_REGION, CONF_MODE) from homeassistant.helpers import entityfilter +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util import dt as dt_util -from homeassistant.components.alexa import smart_home +from homeassistant.components.alexa import smart_home as alexa_sh +from homeassistant.components.google_assistant import smart_home as ga_sh from . import http_api, iot from .const import CONFIG_DIR, DOMAIN, SERVERS @@ -21,7 +25,8 @@ REQUIREMENTS = ['warrant==0.6.1'] _LOGGER = logging.getLogger(__name__) CONF_ALEXA = 'alexa' -CONF_ALEXA_FILTER = 'filter' +CONF_GOOGLE_ASSISTANT = 'google_assistant' +CONF_FILTER = 'filter' CONF_COGNITO_CLIENT_ID = 'cognito_client_id' CONF_RELAYER = 'relayer' CONF_USER_POOL_ID = 'user_pool_id' @@ -30,9 +35,9 @@ MODE_DEV = 'development' DEFAULT_MODE = 'production' DEPENDENCIES = ['http'] -ALEXA_SCHEMA = vol.Schema({ +ASSISTANT_SCHEMA = vol.Schema({ vol.Optional( - CONF_ALEXA_FILTER, + CONF_FILTER, default=lambda: entityfilter.generate_filter([], [], [], []) ): entityfilter.FILTER_SCHEMA, }) @@ -46,7 +51,8 @@ CONFIG_SCHEMA = vol.Schema({ vol.Optional(CONF_USER_POOL_ID): str, vol.Optional(CONF_REGION): str, vol.Optional(CONF_RELAYER): str, - vol.Optional(CONF_ALEXA): ALEXA_SCHEMA + vol.Optional(CONF_ALEXA): ASSISTANT_SCHEMA, + vol.Optional(CONF_GOOGLE_ASSISTANT): ASSISTANT_SCHEMA, }), }, extra=vol.ALLOW_EXTRA) @@ -60,17 +66,19 @@ def async_setup(hass, config): kwargs = {CONF_MODE: DEFAULT_MODE} if CONF_ALEXA not in kwargs: - kwargs[CONF_ALEXA] = ALEXA_SCHEMA({}) + kwargs[CONF_ALEXA] = ASSISTANT_SCHEMA({}) - kwargs[CONF_ALEXA] = smart_home.Config(**kwargs[CONF_ALEXA]) + if CONF_GOOGLE_ASSISTANT not in kwargs: + kwargs[CONF_GOOGLE_ASSISTANT] = ASSISTANT_SCHEMA({}) + + kwargs[CONF_ALEXA] = alexa_sh.Config(**kwargs[CONF_ALEXA]) + kwargs['gass_should_expose'] = kwargs.pop(CONF_GOOGLE_ASSISTANT) cloud = hass.data[DOMAIN] = Cloud(hass, **kwargs) - @asyncio.coroutine - def init_cloud(event): - """Initialize connection.""" - yield from cloud.initialize() + success = yield from cloud.initialize() - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, init_cloud) + if not success: + return False yield from http_api.async_setup(hass) return True @@ -79,12 +87,16 @@ def async_setup(hass, config): class Cloud: """Store the configuration of the cloud connection.""" - def __init__(self, hass, mode, cognito_client_id=None, user_pool_id=None, - region=None, relayer=None, alexa=None): + def __init__(self, hass, mode, alexa, gass_should_expose, + cognito_client_id=None, user_pool_id=None, region=None, + relayer=None): """Create an instance of Cloud.""" self.hass = hass self.mode = mode self.alexa_config = alexa + self._gass_should_expose = gass_should_expose + self._gass_config = None + self.jwt_keyset = None self.id_token = None self.access_token = None self.refresh_token = None @@ -104,11 +116,6 @@ class Cloud: self.region = info['region'] self.relayer = info['relayer'] - @property - def cognito_email_based(self): - """Return if cognito is email based.""" - return not self.user_pool_id.endswith('GmV') - @property def is_logged_in(self): """Get if cloud is logged in.""" @@ -128,37 +135,37 @@ class Cloud: @property def claims(self): - """Get the claims from the id token.""" - from jose import jwt - return jwt.get_unverified_claims(self.id_token) + """Return the claims from the id token.""" + return self._decode_claims(self.id_token) @property def user_info_path(self): """Get path to the stored auth.""" return self.path('{}_auth.json'.format(self.mode)) + @property + def gass_config(self): + """Return the Google Assistant config.""" + if self._gass_config is None: + self._gass_config = ga_sh.Config( + should_expose=self._gass_should_expose, + agent_user_id=self.claims['cognito:username'] + ) + + return self._gass_config + @asyncio.coroutine def initialize(self): """Initialize and load cloud info.""" - def load_config(): - """Load the configuration.""" - # Ensure config dir exists - path = self.hass.config.path(CONFIG_DIR) - if not os.path.isdir(path): - os.mkdir(path) + jwt_success = yield from self._fetch_jwt_keyset() - user_info = self.user_info_path - if os.path.isfile(user_info): - with open(user_info, 'rt') as file: - info = json.loads(file.read()) - self.id_token = info['id_token'] - self.access_token = info['access_token'] - self.refresh_token = info['refresh_token'] + if not jwt_success: + return False - yield from self.hass.async_add_job(load_config) + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, + self._start_cloud) - if self.id_token is not None: - yield from self.iot.connect() + return True def path(self, *parts): """Get config path inside cloud dir. @@ -175,6 +182,7 @@ class Cloud: self.id_token = None self.access_token = None self.refresh_token = None + self._gass_config = None yield from self.hass.async_add_job( lambda: os.remove(self.user_info_path)) @@ -187,3 +195,79 @@ class Cloud: 'access_token': self.access_token, 'refresh_token': self.refresh_token, }, indent=4)) + + def _start_cloud(self, event): + """Start the cloud component.""" + # Ensure config dir exists + path = self.hass.config.path(CONFIG_DIR) + if not os.path.isdir(path): + os.mkdir(path) + + user_info = self.user_info_path + if not os.path.isfile(user_info): + return + + with open(user_info, 'rt') as file: + info = json.loads(file.read()) + + # Validate tokens + try: + for token in 'id_token', 'access_token': + self._decode_claims(info[token]) + except ValueError as err: # Raised when token is invalid + _LOGGER.warning('Found invalid token %s: %s', token, err) + return + + self.id_token = info['id_token'] + self.access_token = info['access_token'] + self.refresh_token = info['refresh_token'] + + self.hass.add_job(self.iot.connect()) + + @asyncio.coroutine + def _fetch_jwt_keyset(self): + """Fetch the JWT keyset for the Cognito instance.""" + session = async_get_clientsession(self.hass) + url = ("https://cognito-idp.us-east-1.amazonaws.com/" + "{}/.well-known/jwks.json".format(self.user_pool_id)) + + try: + with async_timeout.timeout(10, loop=self.hass.loop): + req = yield from session.get(url) + self.jwt_keyset = yield from req.json() + + return True + + except (asyncio.TimeoutError, aiohttp.ClientError) as err: + _LOGGER.error("Error fetching Cognito keyset: %s", err) + return False + + def _decode_claims(self, token): + """Decode the claims in a token.""" + from jose import jwt, exceptions as jose_exceptions + try: + header = jwt.get_unverified_header(token) + except jose_exceptions.JWTError as err: + raise ValueError(str(err)) from None + kid = header.get("kid") + + if kid is None: + raise ValueError('No kid in header') + + # Locate the key for this kid + key = None + for key_dict in self.jwt_keyset["keys"]: + if key_dict["kid"] == kid: + key = key_dict + break + if not key: + raise ValueError( + "Unable to locate kid ({}) in keyset".format(kid)) + + try: + return jwt.decode( + token, key, audience=self.cognito_client_id, options={ + 'verify_exp': False, + }) + except jose_exceptions.JWTError as err: + raise ValueError(str(err)) from None diff --git a/homeassistant/components/cloud/auth_api.py b/homeassistant/components/cloud/auth_api.py index 0ca0451e565..500ff062a0f 100644 --- a/homeassistant/components/cloud/auth_api.py +++ b/homeassistant/components/cloud/auth_api.py @@ -1,5 +1,4 @@ """Package to communicate with the authentication API.""" -import hashlib import logging @@ -58,11 +57,6 @@ def _map_aws_exception(err): return ex(err.response['Error']['Message']) -def _generate_username(email): - """Generate a username from an email address.""" - return hashlib.sha512(email.encode('utf-8')).hexdigest() - - def register(cloud, email, password): """Register a new account.""" from botocore.exceptions import ClientError @@ -72,10 +66,7 @@ def register(cloud, email, password): # https://github.com/capless/warrant/pull/82 cognito.add_base_attributes() try: - if cloud.cognito_email_based: - cognito.register(email, password) - else: - cognito.register(_generate_username(email), password) + cognito.register(email, password) except ClientError as err: raise _map_aws_exception(err) @@ -86,11 +77,7 @@ def confirm_register(cloud, confirmation_code, email): cognito = _cognito(cloud) try: - if cloud.cognito_email_based: - cognito.confirm_sign_up(confirmation_code, email) - else: - cognito.confirm_sign_up(confirmation_code, - _generate_username(email)) + cognito.confirm_sign_up(confirmation_code, email) except ClientError as err: raise _map_aws_exception(err) @@ -114,10 +101,7 @@ def forgot_password(cloud, email): """Initiate forgotten password flow.""" from botocore.exceptions import ClientError - if cloud.cognito_email_based: - cognito = _cognito(cloud, username=email) - else: - cognito = _cognito(cloud, username=_generate_username(email)) + cognito = _cognito(cloud, username=email) try: cognito.initiate_forgot_password() @@ -129,10 +113,7 @@ def confirm_forgot_password(cloud, confirmation_code, email, new_password): """Confirm forgotten password code and change password.""" from botocore.exceptions import ClientError - if cloud.cognito_email_based: - cognito = _cognito(cloud, username=email) - else: - cognito = _cognito(cloud, username=_generate_username(email)) + cognito = _cognito(cloud, username=email) try: cognito.confirm_forgot_password(confirmation_code, new_password) diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 25873ba158c..338e004ce52 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -252,6 +252,6 @@ def _account_data(cloud): return { 'email': claims['email'], - 'sub_exp': claims.get('custom:sub-exp'), + 'sub_exp': claims['custom:sub-exp'], 'cloud': cloud.iot.state, } diff --git a/homeassistant/components/cloud/iot.py b/homeassistant/components/cloud/iot.py index 5c9c54afd14..560d9a61aef 100644 --- a/homeassistant/components/cloud/iot.py +++ b/homeassistant/components/cloud/iot.py @@ -5,7 +5,8 @@ import logging from aiohttp import hdrs, client_exceptions, WSMsgType from homeassistant.const import EVENT_HOMEASSISTANT_STOP -from homeassistant.components.alexa import smart_home +from homeassistant.components.alexa import smart_home as alexa +from homeassistant.components.google_assistant import smart_home as ga from homeassistant.util.decorator import Registry from homeassistant.helpers.aiohttp_client import async_get_clientsession from . import auth_api @@ -204,9 +205,18 @@ def async_handle_message(hass, cloud, handler_name, payload): @asyncio.coroutine def async_handle_alexa(hass, cloud, payload): """Handle an incoming IoT message for Alexa.""" - return (yield from smart_home.async_handle_message(hass, - cloud.alexa_config, - payload)) + result = yield from alexa.async_handle_message(hass, cloud.alexa_config, + payload) + return result + + +@HANDLERS.register('google_assistant') +@asyncio.coroutine +def async_handle_google_assistant(hass, cloud, payload): + """Handle an incoming IoT message for Google Assistant.""" + result = yield from ga.async_handle_message(hass, cloud.gass_config, + payload) + return result @HANDLERS.register('cloud') diff --git a/tests/components/cloud/test_auth_api.py b/tests/components/cloud/test_auth_api.py index bb28dfc50e3..70cd5d83f41 100644 --- a/tests/components/cloud/test_auth_api.py +++ b/tests/components/cloud/test_auth_api.py @@ -78,21 +78,17 @@ def test_login(mock_cognito): def test_register(mock_cognito): """Test registering an account.""" cloud = MagicMock() - cloud.cognito_email_based = False cloud = MagicMock() - cloud.cognito_email_based = False auth_api.register(cloud, 'email@home-assistant.io', 'password') assert len(mock_cognito.register.mock_calls) == 1 result_user, result_password = mock_cognito.register.mock_calls[0][1] - assert result_user == \ - auth_api._generate_username('email@home-assistant.io') + assert result_user == 'email@home-assistant.io' assert result_password == 'password' def test_register_fails(mock_cognito): """Test registering an account.""" cloud = MagicMock() - cloud.cognito_email_based = False mock_cognito.register.side_effect = aws_error('SomeError') with pytest.raises(auth_api.CloudError): auth_api.register(cloud, 'email@home-assistant.io', 'password') @@ -101,19 +97,16 @@ def test_register_fails(mock_cognito): def test_confirm_register(mock_cognito): """Test confirming a registration of an account.""" cloud = MagicMock() - cloud.cognito_email_based = False auth_api.confirm_register(cloud, '123456', 'email@home-assistant.io') assert len(mock_cognito.confirm_sign_up.mock_calls) == 1 result_code, result_user = mock_cognito.confirm_sign_up.mock_calls[0][1] - assert result_user == \ - auth_api._generate_username('email@home-assistant.io') + assert result_user == 'email@home-assistant.io' assert result_code == '123456' def test_confirm_register_fails(mock_cognito): """Test an error during confirmation of an account.""" cloud = MagicMock() - cloud.cognito_email_based = False mock_cognito.confirm_sign_up.side_effect = aws_error('SomeError') with pytest.raises(auth_api.CloudError): auth_api.confirm_register(cloud, '123456', 'email@home-assistant.io') @@ -138,7 +131,6 @@ def test_resend_email_confirm_fails(mock_cognito): def test_forgot_password(mock_cognito): """Test starting forgot password flow.""" cloud = MagicMock() - cloud.cognito_email_based = False auth_api.forgot_password(cloud, 'email@home-assistant.io') assert len(mock_cognito.initiate_forgot_password.mock_calls) == 1 @@ -146,7 +138,6 @@ def test_forgot_password(mock_cognito): def test_forgot_password_fails(mock_cognito): """Test failure when starting forgot password flow.""" cloud = MagicMock() - cloud.cognito_email_based = False mock_cognito.initiate_forgot_password.side_effect = aws_error('SomeError') with pytest.raises(auth_api.CloudError): auth_api.forgot_password(cloud, 'email@home-assistant.io') @@ -155,7 +146,6 @@ def test_forgot_password_fails(mock_cognito): def test_confirm_forgot_password(mock_cognito): """Test confirming forgot password.""" cloud = MagicMock() - cloud.cognito_email_based = False auth_api.confirm_forgot_password( cloud, '123456', 'email@home-assistant.io', 'new password') assert len(mock_cognito.confirm_forgot_password.mock_calls) == 1 @@ -168,7 +158,6 @@ def test_confirm_forgot_password(mock_cognito): def test_confirm_forgot_password_fails(mock_cognito): """Test failure when confirming forgot password.""" cloud = MagicMock() - cloud.cognito_email_based = False mock_cognito.confirm_forgot_password.side_effect = aws_error('SomeError') with pytest.raises(auth_api.CloudError): auth_api.confirm_forgot_password( diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 2c71f504c50..7623b25d401 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -14,7 +14,8 @@ from tests.common import mock_coro @pytest.fixture def cloud_client(hass, test_client): """Fixture that can fetch from the cloud client.""" - with patch('homeassistant.components.cloud.Cloud.initialize'): + with patch('homeassistant.components.cloud.Cloud.initialize', + return_value=mock_coro(True)): hass.loop.run_until_complete(async_setup_component(hass, 'cloud', { 'cloud': { 'mode': 'development', @@ -24,6 +25,8 @@ def cloud_client(hass, test_client): 'relayer': 'relayer', } })) + hass.data['cloud']._decode_claims = \ + lambda token: jwt.get_unverified_claims(token) with patch('homeassistant.components.cloud.Cloud.write_user_info'): yield hass.loop.run_until_complete(test_client(hass.http.app)) diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index c5bb6f7fda7..7d23d9faad4 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -3,7 +3,6 @@ import asyncio import json from unittest.mock import patch, MagicMock, mock_open -from jose import jwt import pytest from homeassistant.components import cloud @@ -31,7 +30,8 @@ def test_constructor_loads_info_from_constant(): 'region': 'test-region', 'relayer': 'test-relayer', } - }): + }), patch('homeassistant.components.cloud.Cloud._fetch_jwt_keyset', + return_value=mock_coro(True)): result = yield from cloud.async_setup(hass, { 'cloud': {cloud.CONF_MODE: 'beer'} }) @@ -50,15 +50,17 @@ def test_constructor_loads_info_from_config(): """Test non-dev mode loads info from SERVERS constant.""" hass = MagicMock(data={}) - result = yield from cloud.async_setup(hass, { - 'cloud': { - cloud.CONF_MODE: cloud.MODE_DEV, - 'cognito_client_id': 'test-cognito_client_id', - 'user_pool_id': 'test-user_pool_id', - 'region': 'test-region', - 'relayer': 'test-relayer', - } - }) + with patch('homeassistant.components.cloud.Cloud._fetch_jwt_keyset', + return_value=mock_coro(True)): + result = yield from cloud.async_setup(hass, { + 'cloud': { + cloud.CONF_MODE: cloud.MODE_DEV, + 'cognito_client_id': 'test-cognito_client_id', + 'user_pool_id': 'test-user_pool_id', + 'region': 'test-region', + 'relayer': 'test-relayer', + } + }) assert result cl = hass.data['cloud'] @@ -79,12 +81,13 @@ def test_initialize_loads_info(mock_os, hass): 'refresh_token': 'test-refresh-token', })) - cl = cloud.Cloud(hass, cloud.MODE_DEV) + cl = cloud.Cloud(hass, cloud.MODE_DEV, None, None) cl.iot = MagicMock() cl.iot.connect.return_value = mock_coro() - with patch('homeassistant.components.cloud.open', mopen, create=True): - yield from cl.initialize() + with patch('homeassistant.components.cloud.open', mopen, create=True), \ + patch('homeassistant.components.cloud.Cloud._decode_claims'): + cl._start_cloud(None) assert cl.id_token == 'test-id-token' assert cl.access_token == 'test-access-token' @@ -95,7 +98,7 @@ def test_initialize_loads_info(mock_os, hass): @asyncio.coroutine def test_logout_clears_info(mock_os, hass): """Test logging out disconnects and removes info.""" - cl = cloud.Cloud(hass, cloud.MODE_DEV) + cl = cloud.Cloud(hass, cloud.MODE_DEV, None, None) cl.iot = MagicMock() cl.iot.disconnect.return_value = mock_coro() @@ -113,7 +116,7 @@ def test_write_user_info(): """Test writing user info works.""" mopen = mock_open() - cl = cloud.Cloud(MagicMock(), cloud.MODE_DEV) + cl = cloud.Cloud(MagicMock(), cloud.MODE_DEV, None, None) cl.id_token = 'test-id-token' cl.access_token = 'test-access-token' cl.refresh_token = 'test-refresh-token' @@ -135,24 +138,24 @@ def test_write_user_info(): @asyncio.coroutine def test_subscription_expired(): """Test subscription being expired.""" - cl = cloud.Cloud(None, cloud.MODE_DEV) - cl.id_token = jwt.encode({ + cl = cloud.Cloud(None, cloud.MODE_DEV, None, None) + token_val = { 'custom:sub-exp': '2017-11-13' - }, 'test') - - with patch('homeassistant.util.dt.utcnow', - return_value=utcnow().replace(year=2018)): + } + with patch.object(cl, '_decode_claims', return_value=token_val), \ + patch('homeassistant.util.dt.utcnow', + return_value=utcnow().replace(year=2018)): assert cl.subscription_expired @asyncio.coroutine def test_subscription_not_expired(): """Test subscription not being expired.""" - cl = cloud.Cloud(None, cloud.MODE_DEV) - cl.id_token = jwt.encode({ + cl = cloud.Cloud(None, cloud.MODE_DEV, None, None) + token_val = { 'custom:sub-exp': '2017-11-13' - }, 'test') - - with patch('homeassistant.util.dt.utcnow', - return_value=utcnow().replace(year=2017, month=11, day=9)): + } + with patch.object(cl, '_decode_claims', return_value=token_val), \ + patch('homeassistant.util.dt.utcnow', + return_value=utcnow().replace(year=2017, month=11, day=9)): assert not cl.subscription_expired From bb2191b2b0aced006e1d3a9ab0a81964f01b04a1 Mon Sep 17 00:00:00 2001 From: Dave Finlay Date: Wed, 3 Jan 2018 10:25:16 -0800 Subject: [PATCH 137/238] Add support for the renaming of Yamaha Receiver Zones via configuration file. Added a test to cover the change, plus previously untested options. (#11402) --- .../components/media_player/yamaha.py | 15 +++++--- tests/components/media_player/test_yamaha.py | 35 +++++++++++++++++++ 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/media_player/yamaha.py b/homeassistant/components/media_player/yamaha.py index 861e75ac144..10f7adccae0 100644 --- a/homeassistant/components/media_player/yamaha.py +++ b/homeassistant/components/media_player/yamaha.py @@ -27,6 +27,7 @@ SUPPORT_YAMAHA = SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ CONF_SOURCE_NAMES = 'source_names' CONF_SOURCE_IGNORE = 'source_ignore' +CONF_ZONE_NAMES = 'zone_names' CONF_ZONE_IGNORE = 'zone_ignore' DEFAULT_NAME = 'Yamaha Receiver' @@ -40,6 +41,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_ZONE_IGNORE, default=[]): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_SOURCE_NAMES, default={}): {cv.string: cv.string}, + vol.Optional(CONF_ZONE_NAMES, default={}): {cv.string: cv.string}, }) @@ -57,6 +59,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): source_ignore = config.get(CONF_SOURCE_IGNORE) source_names = config.get(CONF_SOURCE_NAMES) zone_ignore = config.get(CONF_ZONE_IGNORE) + zone_names = config.get(CONF_ZONE_NAMES) if discovery_info is not None: name = discovery_info.get('name') @@ -84,14 +87,16 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if receiver.zone not in zone_ignore: hass.data[KNOWN].add(receiver.ctrl_url) add_devices([ - YamahaDevice(name, receiver, source_ignore, source_names) + YamahaDevice(name, receiver, source_ignore, + source_names, zone_names) ], True) class YamahaDevice(MediaPlayerDevice): """Representation of a Yamaha device.""" - def __init__(self, name, receiver, source_ignore, source_names): + def __init__(self, name, receiver, source_ignore, + source_names, zone_names): """Initialize the Yamaha Receiver.""" self._receiver = receiver self._muted = False @@ -101,6 +106,7 @@ class YamahaDevice(MediaPlayerDevice): self._source_list = None self._source_ignore = source_ignore or [] self._source_names = source_names or {} + self._zone_names = zone_names or {} self._reverse_mapping = None self._playback_support = None self._is_playback_supported = False @@ -148,9 +154,10 @@ class YamahaDevice(MediaPlayerDevice): def name(self): """Return the name of the device.""" name = self._name - if self._zone != "Main_Zone": + zone_name = self._zone_names.get(self._zone, self._zone) + if zone_name != "Main_Zone": # Zone will be one of Main_Zone, Zone_2, Zone_3 - name += " " + self._zone.replace('_', ' ') + name += " " + zone_name.replace('_', ' ') return name @property diff --git a/tests/components/media_player/test_yamaha.py b/tests/components/media_player/test_yamaha.py index ad443fadebb..176cf7c5bf2 100644 --- a/tests/components/media_player/test_yamaha.py +++ b/tests/components/media_player/test_yamaha.py @@ -4,6 +4,15 @@ import xml.etree.ElementTree as ET import rxv +import homeassistant.components.media_player.yamaha as yamaha + +TEST_CONFIG = { + 'name': "Test Receiver", + 'source_ignore': ['HDMI5'], + 'source_names': {'HDMI1': 'Laserdisc'}, + 'zone_names': {'Main_Zone': "Laser Dome"} +} + def sample_content(name): """Read content into a string from a file.""" @@ -12,6 +21,14 @@ def sample_content(name): return content.read() +def yamaha_player(receiver): + """Create a YamahaDevice from a given receiver, presumably a Mock.""" + zone_controller = receiver.zone_controllers()[0] + player = yamaha.YamahaDevice(receiver=zone_controller, **TEST_CONFIG) + player.build_source_list() + return player + + class FakeYamaha(rxv.rxv.RXV): """Fake Yamaha receiver. @@ -74,6 +91,7 @@ class TestYamaha(unittest.TestCase): """Setup things to be run when tests are started.""" super(TestYamaha, self).setUp() self.rec = FakeYamaha("http://10.0.0.0:80/YamahaRemoteControl/ctrl") + self.player = yamaha_player(self.rec) def test_get_playback_support(self): """Test the playback.""" @@ -92,3 +110,20 @@ class TestYamaha(unittest.TestCase): self.assertTrue(support.stop) self.assertFalse(support.skip_f) self.assertFalse(support.skip_r) + + def test_configuration_options(self): + """Test configuration options.""" + rec_name = TEST_CONFIG['name'] + src_zone = 'Main_Zone' + src_zone_alt = src_zone.replace('_', ' ') + renamed_zone = TEST_CONFIG['zone_names'][src_zone] + ignored_src = TEST_CONFIG['source_ignore'][0] + renamed_src = 'HDMI1' + new_src = TEST_CONFIG['source_names'][renamed_src] + self.assertFalse(self.player.name == rec_name + ' ' + src_zone) + self.assertFalse(self.player.name == rec_name + ' ' + src_zone_alt) + self.assertTrue(self.player.name == rec_name + ' ' + renamed_zone) + + self.assertFalse(ignored_src in self.player.source_list) + self.assertFalse(renamed_src in self.player.source_list) + self.assertTrue(new_src in self.player.source_list) From 36c7fbe06af199c817d9aa6320fd77a768c4e8ce Mon Sep 17 00:00:00 2001 From: Ryan McLean Date: Wed, 3 Jan 2018 18:28:43 +0000 Subject: [PATCH 138/238] Plex api update (#11423) * Updated plexapi to 3.0.5 * Removed removed server obj that was being used for the media URL generation as .url() is available in self._session seasons() no longer exists -> renamed to season() season() returns obj not list so corrected season.index returns int, cast to str to zerofill * Fixed Linting --- homeassistant/components/media_player/plex.py | 19 +++++++++---------- homeassistant/components/sensor/plex.py | 2 +- requirements_all.txt | 2 +- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/media_player/plex.py b/homeassistant/components/media_player/plex.py index c6f3042f2ba..c96b0f3c2ae 100644 --- a/homeassistant/components/media_player/plex.py +++ b/homeassistant/components/media_player/plex.py @@ -23,7 +23,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import track_utc_time_change from homeassistant.util.json import load_json, save_json -REQUIREMENTS = ['plexapi==3.0.3'] +REQUIREMENTS = ['plexapi==3.0.5'] _CONFIGURING = {} _LOGGER = logging.getLogger(__name__) @@ -156,7 +156,7 @@ def setup_plexserver( if device.machineIdentifier not in plex_clients: new_client = PlexClient(config, device, None, plex_sessions, update_devices, - update_sessions, plexserver) + update_sessions) plex_clients[device.machineIdentifier] = new_client new_plex_clients.append(new_client) else: @@ -169,7 +169,7 @@ def setup_plexserver( and machine_identifier is not None): new_client = PlexClient(config, None, session, plex_sessions, update_devices, - update_sessions, plexserver) + update_sessions) plex_clients[machine_identifier] = new_client new_plex_clients.append(new_client) else: @@ -249,10 +249,9 @@ class PlexClient(MediaPlayerDevice): """Representation of a Plex device.""" def __init__(self, config, device, session, plex_sessions, - update_devices, update_sessions, plex_server): + update_devices, update_sessions): """Initialize the Plex device.""" self._app_name = '' - self._server = plex_server self._device = None self._device_protocol_capabilities = None self._is_player_active = False @@ -392,13 +391,12 @@ class PlexClient(MediaPlayerDevice): thumb_url = self._session.thumbUrl if (self.media_content_type is MEDIA_TYPE_TVSHOW and not self.config.get(CONF_USE_EPISODE_ART)): - thumb_url = self._server.url( - self._session.grandparentThumb) + thumb_url = self._session.url(self._session.grandparentThumb) if thumb_url is None: _LOGGER.debug("Using media art because media thumb " "was not found: %s", self.entity_id) - thumb_url = self._server.url(self._session.art) + thumb_url = self.session.url(self._session.art) self._media_image_url = thumb_url @@ -421,8 +419,9 @@ class PlexClient(MediaPlayerDevice): self._media_content_type = MEDIA_TYPE_TVSHOW # season number (00) - if callable(self._session.seasons): - self._media_season = self._session.seasons()[0].index.zfill(2) + if callable(self._session.season): + self._media_season = str( + (self._session.season()).index).zfill(2) elif self._session.parentIndex is not None: self._media_season = self._session.parentIndex.zfill(2) else: diff --git a/homeassistant/components/sensor/plex.py b/homeassistant/components/sensor/plex.py index a40aeee55e5..b0c40e8f007 100644 --- a/homeassistant/components/sensor/plex.py +++ b/homeassistant/components/sensor/plex.py @@ -16,7 +16,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['plexapi==3.0.3'] +REQUIREMENTS = ['plexapi==3.0.5'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index d9dfb16b4b1..abcbe7fd127 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -565,7 +565,7 @@ pizzapi==0.0.3 # homeassistant.components.media_player.plex # homeassistant.components.sensor.plex -plexapi==3.0.3 +plexapi==3.0.5 # homeassistant.components.sensor.mhz19 # homeassistant.components.sensor.serial_pm From 75a39352ffb8c5e7b24fe10a9a66d0839224d3e9 Mon Sep 17 00:00:00 2001 From: Daniel Claes Date: Wed, 3 Jan 2018 19:36:25 +0100 Subject: [PATCH 139/238] fix: hmip-etrv-2 now working with homeassistant (#11175) * fix: hmip-etrv-2 now working with homeassistant (see also pull request at pyhomematic) * fix linting issue and typo * address comment @pvizeli * Only use cached data in current operation mode * fix linting issue --- homeassistant/components/climate/homematic.py | 32 +++++++++++++++---- .../components/homematic/__init__.py | 2 ++ 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/climate/homematic.py b/homeassistant/components/climate/homematic.py index 33a63b35530..b8fb7a984fa 100644 --- a/homeassistant/components/climate/homematic.py +++ b/homeassistant/components/climate/homematic.py @@ -8,7 +8,8 @@ import logging from homeassistant.components.climate import ( ClimateDevice, STATE_AUTO, SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE) -from homeassistant.components.homematic import HMDevice, ATTR_DISCOVER_DEVICES +from homeassistant.components.homematic import ( + HMDevice, ATTR_DISCOVER_DEVICES, HM_ATTRIBUTE_SUPPORT) from homeassistant.const import TEMP_CELSIUS, STATE_UNKNOWN, ATTR_TEMPERATURE DEPENDENCIES = ['homematic'] @@ -39,6 +40,7 @@ HM_HUMI_MAP = [ ] HM_CONTROL_MODE = 'CONTROL_MODE' +HM_IP_CONTROL_MODE = 'SET_POINT_MODE' SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE @@ -75,11 +77,25 @@ class HMThermostat(HMDevice, ClimateDevice): if HM_CONTROL_MODE not in self._data: return None - # read state and search - for mode, state in HM_STATE_MAP.items(): - code = getattr(self._hmdevice, mode, 0) - if self._data.get('CONTROL_MODE') == code: - return state + set_point_mode = self._data.get('SET_POINT_MODE', -1) + control_mode = self._data.get('CONTROL_MODE', -1) + boost_mode = self._data.get('BOOST_MODE', False) + + # boost mode is active + if boost_mode: + return STATE_BOOST + + # HM ip etrv 2 uses the set_point_mode to say if its + # auto or manual + elif not set_point_mode == -1: + code = set_point_mode + # Other devices use the control_mode + else: + code = control_mode + + # get the name of the mode + name = HM_ATTRIBUTE_SUPPORT[HM_CONTROL_MODE][1][code] + return name.lower() @property def operation_list(self): @@ -125,6 +141,7 @@ class HMThermostat(HMDevice, ClimateDevice): if state == operation_mode: code = getattr(self._hmdevice, mode, 0) self._hmdevice.MODE = code + return @property def min_temp(self): @@ -141,7 +158,8 @@ class HMThermostat(HMDevice, ClimateDevice): self._state = next(iter(self._hmdevice.WRITENODE.keys())) self._data[self._state] = STATE_UNKNOWN - if HM_CONTROL_MODE in self._hmdevice.ATTRIBUTENODE: + if HM_CONTROL_MODE in self._hmdevice.ATTRIBUTENODE or \ + HM_IP_CONTROL_MODE in self._hmdevice.ATTRIBUTENODE: self._data[HM_CONTROL_MODE] = STATE_UNKNOWN for node in self._hmdevice.SENSORNODE.keys(): diff --git a/homeassistant/components/homematic/__init__.py b/homeassistant/components/homematic/__init__.py index 08e8455b302..117d04c88f6 100644 --- a/homeassistant/components/homematic/__init__.py +++ b/homeassistant/components/homematic/__init__.py @@ -90,6 +90,7 @@ HM_IGNORE_DISCOVERY_NODE = [ HM_ATTRIBUTE_SUPPORT = { 'LOWBAT': ['battery', {0: 'High', 1: 'Low'}], + 'LOW_BAT': ['battery', {0: 'High', 1: 'Low'}], 'ERROR': ['sabotage', {0: 'No', 1: 'Yes'}], 'RSSI_DEVICE': ['rssi', {}], 'VALVE_STATE': ['valve', {}], @@ -105,6 +106,7 @@ HM_ATTRIBUTE_SUPPORT = { 'POWER': ['power', {}], 'CURRENT': ['current', {}], 'VOLTAGE': ['voltage', {}], + 'OPERATING_VOLTAGE': ['voltage', {}], 'WORKING': ['working', {0: 'No', 1: 'Yes'}], } From ec700c2cd6e60b255570a8ba7a3d655df68d3bcc Mon Sep 17 00:00:00 2001 From: Steve Easley Date: Wed, 3 Jan 2018 15:48:36 -0500 Subject: [PATCH 140/238] Fix errors in zigbee push state (#11386) * Fix errors in zigbee push state * Fix formatting * Added comment --- homeassistant/components/zigbee.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) mode change 100644 => 100755 homeassistant/components/zigbee.py diff --git a/homeassistant/components/zigbee.py b/homeassistant/components/zigbee.py old mode 100644 new mode 100755 index 95b0971373d..3a84e963841 --- a/homeassistant/components/zigbee.py +++ b/homeassistant/components/zigbee.py @@ -288,12 +288,15 @@ class ZigBeeDigitalIn(Entity): """ if not frame_is_relevant(self, frame): return - sample = frame['samples'].pop() + sample = next(iter(frame['samples'])) pin_name = DIGITAL_PINS[self._config.pin] if pin_name not in sample: # Doesn't contain information about our pin return - self._state = self._config.state2bool[sample[pin_name]] + # Set state to the value of sample, respecting any inversion + # logic from the on_state config variable. + self._state = self._config.state2bool[ + self._config.bool2state[sample[pin_name]]] self.schedule_update_ha_state() async_dispatcher_connect( From eb00e54eba70673a15191dce2fecd6b6a0273bc3 Mon Sep 17 00:00:00 2001 From: Andrey Date: Thu, 4 Jan 2018 00:10:54 +0200 Subject: [PATCH 141/238] Add on/off supported feature to climate (#11379) * Add on/off supported feature to climate * Lint --- homeassistant/components/climate/__init__.py | 68 ++++++++++++++++++- homeassistant/components/climate/demo.py | 21 +++++- homeassistant/components/climate/sensibo.py | 19 +++--- .../components/climate/services.yaml | 17 ++++- tests/components/climate/test_demo.py | 17 +++++ 5 files changed, 125 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index f9ffe4faec9..a5e95ecc36b 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -21,9 +21,9 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa import homeassistant.helpers.config_validation as cv from homeassistant.const import ( - ATTR_ENTITY_ID, ATTR_TEMPERATURE, STATE_ON, STATE_OFF, STATE_UNKNOWN, - TEMP_CELSIUS, PRECISION_WHOLE, PRECISION_TENTHS) - + ATTR_ENTITY_ID, ATTR_TEMPERATURE, SERVICE_TURN_ON, SERVICE_TURN_OFF, + STATE_ON, STATE_OFF, STATE_UNKNOWN, TEMP_CELSIUS, PRECISION_WHOLE, + PRECISION_TENTHS, ) DOMAIN = 'climate' ENTITY_ID_FORMAT = DOMAIN + '.{}' @@ -63,6 +63,7 @@ SUPPORT_HOLD_MODE = 256 SUPPORT_SWING_MODE = 512 SUPPORT_AWAY_MODE = 1024 SUPPORT_AUX_HEAT = 2048 +SUPPORT_ON_OFF = 4096 ATTR_CURRENT_TEMPERATURE = 'current_temperature' ATTR_MAX_TEMP = 'max_temp' @@ -92,6 +93,10 @@ CONVERTIBLE_ATTRIBUTE = [ _LOGGER = logging.getLogger(__name__) +ON_OFF_SERVICE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, +}) + SET_AWAY_MODE_SCHEMA = vol.Schema({ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, vol.Required(ATTR_AWAY_MODE): cv.boolean, @@ -439,6 +444,32 @@ def async_setup(hass, config): descriptions.get(SERVICE_SET_SWING_MODE), schema=SET_SWING_MODE_SCHEMA) + @asyncio.coroutine + def async_on_off_service(service): + """Handle on/off calls.""" + target_climate = component.async_extract_from_service(service) + + update_tasks = [] + for climate in target_climate: + if service.service == SERVICE_TURN_ON: + yield from climate.async_turn_on() + elif service.service == SERVICE_TURN_OFF: + yield from climate.async_turn_off() + + if not climate.should_poll: + continue + update_tasks.append(climate.async_update_ha_state(True)) + + if update_tasks: + yield from asyncio.wait(update_tasks, loop=hass.loop) + + hass.services.async_register( + DOMAIN, SERVICE_TURN_OFF, async_on_off_service, + descriptions.get(SERVICE_TURN_OFF), schema=ON_OFF_SERVICE_SCHEMA) + hass.services.async_register( + DOMAIN, SERVICE_TURN_ON, async_on_off_service, + descriptions.get(SERVICE_TURN_ON), schema=ON_OFF_SERVICE_SCHEMA) + return True @@ -449,8 +480,12 @@ class ClimateDevice(Entity): @property def state(self): """Return the current state.""" + if self.is_on is False: + return STATE_OFF if self.current_operation: return self.current_operation + if self.is_on: + return STATE_ON return STATE_UNKNOWN @property @@ -594,6 +629,11 @@ class ClimateDevice(Entity): """Return the current hold mode, e.g., home, away, temp.""" return None + @property + def is_on(self): + """Return true if on.""" + return None + @property def is_aux_heat_on(self): """Return true if aux heater.""" @@ -730,6 +770,28 @@ class ClimateDevice(Entity): """ return self.hass.async_add_job(self.turn_aux_heat_off) + def turn_on(self): + """Turn device on.""" + raise NotImplementedError() + + def async_turn_on(self): + """Turn device on. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_job(self.turn_on) + + def turn_off(self): + """Turn device off.""" + raise NotImplementedError() + + def async_turn_off(self): + """Turn device off. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_job(self.turn_off) + @property def supported_features(self): """Return the list of supported features.""" diff --git a/homeassistant/components/climate/demo.py b/homeassistant/components/climate/demo.py index 4c4b57d42a3..981c551d69b 100644 --- a/homeassistant/components/climate/demo.py +++ b/homeassistant/components/climate/demo.py @@ -9,14 +9,15 @@ from homeassistant.components.climate import ( SUPPORT_TARGET_TEMPERATURE, SUPPORT_TARGET_HUMIDITY, SUPPORT_AWAY_MODE, SUPPORT_HOLD_MODE, SUPPORT_FAN_MODE, SUPPORT_OPERATION_MODE, SUPPORT_AUX_HEAT, SUPPORT_SWING_MODE, - SUPPORT_TARGET_TEMPERATURE_HIGH, SUPPORT_TARGET_TEMPERATURE_LOW) + SUPPORT_TARGET_TEMPERATURE_HIGH, SUPPORT_TARGET_TEMPERATURE_LOW, + SUPPORT_ON_OFF) from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_TEMPERATURE SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_TARGET_HUMIDITY | SUPPORT_AWAY_MODE | SUPPORT_HOLD_MODE | SUPPORT_FAN_MODE | SUPPORT_OPERATION_MODE | SUPPORT_AUX_HEAT | SUPPORT_SWING_MODE | SUPPORT_TARGET_TEMPERATURE_HIGH | - SUPPORT_TARGET_TEMPERATURE_LOW) + SUPPORT_TARGET_TEMPERATURE_LOW, SUPPORT_ON_OFF) def setup_platform(hass, config, add_devices, discovery_info=None): @@ -56,6 +57,7 @@ class DemoClimate(ClimateDevice): self._swing_list = ['Auto', '1', '2', '3', 'Off'] self._target_temperature_high = target_temp_high self._target_temperature_low = target_temp_low + self._on = True @property def supported_features(self): @@ -132,6 +134,11 @@ class DemoClimate(ClimateDevice): """Return true if aux heat is on.""" return self._aux + @property + def is_on(self): + """Return true if the device is on.""" + return self._on + @property def current_fan_mode(self): """Return the fan setting.""" @@ -206,3 +213,13 @@ class DemoClimate(ClimateDevice): """Turn auxiliary heater off.""" self._aux = False self.schedule_update_ha_state() + + def turn_on(self): + """Turn on.""" + self._on = True + self.schedule_update_ha_state() + + def turn_off(self): + """Turn off.""" + self._on = False + self.schedule_update_ha_state() diff --git a/homeassistant/components/climate/sensibo.py b/homeassistant/components/climate/sensibo.py index ed23d91587c..32a5a998d87 100644 --- a/homeassistant/components/climate/sensibo.py +++ b/homeassistant/components/climate/sensibo.py @@ -13,13 +13,12 @@ import async_timeout import voluptuous as vol from homeassistant.const import ( - ATTR_TEMPERATURE, CONF_API_KEY, CONF_ID, STATE_OFF, TEMP_CELSIUS, - TEMP_FAHRENHEIT) + ATTR_TEMPERATURE, CONF_API_KEY, CONF_ID, TEMP_CELSIUS, TEMP_FAHRENHEIT) from homeassistant.components.climate import ( ATTR_CURRENT_HUMIDITY, ClimateDevice, PLATFORM_SCHEMA, SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE, SUPPORT_FAN_MODE, SUPPORT_SWING_MODE, - SUPPORT_AUX_HEAT) + SUPPORT_AUX_HEAT, SUPPORT_ON_OFF) from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -47,7 +46,7 @@ FIELD_TO_FLAG = { 'mode': SUPPORT_OPERATION_MODE, 'swing': SUPPORT_SWING_MODE, 'targetTemperature': SUPPORT_TARGET_TEMPERATURE, - 'on': SUPPORT_AUX_HEAT, + 'on': SUPPORT_AUX_HEAT | SUPPORT_ON_OFF, } @@ -92,13 +91,6 @@ class SensiboClimate(ClimateDevice): """Return the list of supported features.""" return self._supported_features - @property - def state(self): - """Return the current state.""" - if not self.is_aux_heat_on: - return STATE_OFF - return super().state - def _do_update(self, data): self._name = data['room']['name'] self._measurements = data['measurements'] @@ -208,6 +200,8 @@ class SensiboClimate(ClimateDevice): """Return true if AC is on.""" return self._ac_states['on'] + is_on = is_aux_heat_on + @property def min_temp(self): """Return the minimum temperature.""" @@ -279,6 +273,9 @@ class SensiboClimate(ClimateDevice): yield from self._client.async_set_ac_state_property( self._id, 'on', False) + async_on = async_turn_aux_heat_on + async_off = async_turn_aux_heat_off + @asyncio.coroutine def async_update(self): """Retrieve latest state.""" diff --git a/homeassistant/components/climate/services.yaml b/homeassistant/components/climate/services.yaml index 5edbf438328..cb3db926b85 100644 --- a/homeassistant/components/climate/services.yaml +++ b/homeassistant/components/climate/services.yaml @@ -80,7 +80,22 @@ set_swing_mode: example: 'climate.nest' swing_mode: description: New value of swing mode. - example: 1 + example: + +turn_on: + description: Turn climate device on. + fields: + entity_id: + description: Name(s) of entities to change. + example: 'climate.kitchen' + +turn_off: + description: Turn climate device off. + fields: + entity_id: + description: Name(s) of entities to change. + example: 'climate.kitchen' + ecobee_set_fan_min_on_time: description: Set the minimum fan on time. fields: diff --git a/tests/components/climate/test_demo.py b/tests/components/climate/test_demo.py index d15249d61f3..9098494bf48 100644 --- a/tests/components/climate/test_demo.py +++ b/tests/components/climate/test_demo.py @@ -250,3 +250,20 @@ class TestDemoClimate(unittest.TestCase): self.hass.block_till_done() state = self.hass.states.get(ENTITY_CLIMATE) self.assertEqual('off', state.attributes.get('aux_heat')) + + def test_set_on_off(self): + """Test on/off service.""" + state = self.hass.states.get(ENTITY_ECOBEE) + self.assertEqual('auto', state.state) + + self.hass.services.call(climate.DOMAIN, climate.SERVICE_TURN_OFF, + {climate.ATTR_ENTITY_ID: ENTITY_ECOBEE}) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_ECOBEE) + self.assertEqual('off', state.state) + + self.hass.services.call(climate.DOMAIN, climate.SERVICE_TURN_ON, + {climate.ATTR_ENTITY_ID: ENTITY_ECOBEE}) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_ECOBEE) + self.assertEqual('auto', state.state) From 65df6f00130713a7bb15135dc56a6e2a1fce1236 Mon Sep 17 00:00:00 2001 From: cdce8p <30130371+cdce8p@users.noreply.github.com> Date: Wed, 3 Jan 2018 23:52:36 +0100 Subject: [PATCH 142/238] Fix CONF_FRIENDLY_NAME (#11438) --- homeassistant/components/cover/template.py | 2 +- homeassistant/components/light/template.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/cover/template.py b/homeassistant/components/cover/template.py index 34aa636185e..f4728a12a3b 100644 --- a/homeassistant/components/cover/template.py +++ b/homeassistant/components/cover/template.py @@ -63,7 +63,7 @@ COVER_SCHEMA = vol.Schema({ vol.Optional(CONF_TILT_OPTIMISTIC): cv.boolean, vol.Optional(POSITION_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(TILT_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_FRIENDLY_NAME, default=None): cv.string, + vol.Optional(CONF_FRIENDLY_NAME): cv.string, vol.Optional(CONF_ENTITY_ID): cv.entity_ids }) diff --git a/homeassistant/components/light/template.py b/homeassistant/components/light/template.py index 465e84fae90..d4f2b93e6b5 100644 --- a/homeassistant/components/light/template.py +++ b/homeassistant/components/light/template.py @@ -40,7 +40,7 @@ LIGHT_SCHEMA = vol.Schema({ vol.Optional(CONF_ENTITY_PICTURE_TEMPLATE, default=None): cv.template, vol.Optional(CONF_LEVEL_ACTION, default=None): cv.SCRIPT_SCHEMA, vol.Optional(CONF_LEVEL_TEMPLATE, default=None): cv.template, - vol.Optional(CONF_FRIENDLY_NAME, default=None): cv.string, + vol.Optional(CONF_FRIENDLY_NAME): cv.string, vol.Optional(CONF_ENTITY_ID): cv.entity_ids }) From 4512a972d12c5381780b2fc1c5b6f58d65c46885 Mon Sep 17 00:00:00 2001 From: Andrey Date: Thu, 4 Jan 2018 02:26:11 +0200 Subject: [PATCH 143/238] Climate: fix missing "|" (#11441) * Add on/off supported feature to climate * Lint * Fix missing | * lint --- homeassistant/components/climate/demo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/climate/demo.py b/homeassistant/components/climate/demo.py index 981c551d69b..2fe6ba0c874 100644 --- a/homeassistant/components/climate/demo.py +++ b/homeassistant/components/climate/demo.py @@ -17,7 +17,7 @@ SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_TARGET_HUMIDITY | SUPPORT_AWAY_MODE | SUPPORT_HOLD_MODE | SUPPORT_FAN_MODE | SUPPORT_OPERATION_MODE | SUPPORT_AUX_HEAT | SUPPORT_SWING_MODE | SUPPORT_TARGET_TEMPERATURE_HIGH | - SUPPORT_TARGET_TEMPERATURE_LOW, SUPPORT_ON_OFF) + SUPPORT_TARGET_TEMPERATURE_LOW | SUPPORT_ON_OFF) def setup_platform(hass, config, add_devices, discovery_info=None): From 2cf5acdfd22c195c1ea565d27586f2cc84b64e1a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 3 Jan 2018 17:26:58 -0800 Subject: [PATCH 144/238] Google Assistant -> Google Actions (#11442) --- homeassistant/components/cloud/iot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/cloud/iot.py b/homeassistant/components/cloud/iot.py index 560d9a61aef..789ffae2fed 100644 --- a/homeassistant/components/cloud/iot.py +++ b/homeassistant/components/cloud/iot.py @@ -210,7 +210,7 @@ def async_handle_alexa(hass, cloud, payload): return result -@HANDLERS.register('google_assistant') +@HANDLERS.register('google_actions') @asyncio.coroutine def async_handle_google_assistant(hass, cloud, payload): """Handle an incoming IoT message for Google Assistant.""" From 04de22613c57e98c75315e6323833dc361399b53 Mon Sep 17 00:00:00 2001 From: Frantz Date: Thu, 4 Jan 2018 12:05:27 +0200 Subject: [PATCH 145/238] Added new climate component from Daikin (#10983) * Added Daikin climate component * Fixed tox & hound * Place up the REQUIREMENTS var * Update .coveragerc * Removed unused customization * Prevent setting invalid operation state * Fixed hound * Small refactor according to code review * Fixed latest code review comments * Used host instead of ip_address * No real change * No real change * Fixed lint errors * More pylint fixes * Shush Hound * Applied suggested changes for temperature & humidity settings * Fixed hound * Fixed upper case texts * Fixed hound * Fixed hound * Fixed hound * Removed humidity since even the device has the feature it cant be set from API * Code review requested changes * Fixed hound * Fixed hound * Trigger update after adding device * Added Daikin sensors * Fixed hound * Fixed hound * Fixed travis * Fixed hound * Fixed hound * Fixed travis * Fixed coverage decrease issue * Do less API calls and fixed Travis failures * Distributed code from platform to climate and sensor componenets * Rename sensor state to device_attribute * Fixed hound * Updated requirements * Simplified code * Implemented requested changes * Forgot one change * Don't allow customizing temperature unit and take it from hass (FOR NOW) * Additional code review changes applied * Condensed import even more * Simplify condition check * Reordered imports * Disabled autodiscovery FOR NOW :( * Give more suggestive names to sensors --- .coveragerc | 3 + homeassistant/components/climate/daikin.py | 257 +++++++++++++++++++++ homeassistant/components/daikin.py | 138 +++++++++++ homeassistant/components/discovery.py | 1 + homeassistant/components/sensor/daikin.py | 124 ++++++++++ requirements_all.txt | 4 + 6 files changed, 527 insertions(+) create mode 100644 homeassistant/components/climate/daikin.py create mode 100644 homeassistant/components/daikin.py create mode 100644 homeassistant/components/sensor/daikin.py diff --git a/.coveragerc b/.coveragerc index a7c961d5a09..70a0597e6b7 100644 --- a/.coveragerc +++ b/.coveragerc @@ -266,6 +266,9 @@ omit = homeassistant/components/zoneminder.py homeassistant/components/*/zoneminder.py + homeassistant/components/daikin.py + homeassistant/components/*/daikin.py + homeassistant/components/alarm_control_panel/alarmdotcom.py homeassistant/components/alarm_control_panel/canary.py homeassistant/components/alarm_control_panel/concord232.py diff --git a/homeassistant/components/climate/daikin.py b/homeassistant/components/climate/daikin.py new file mode 100644 index 00000000000..8f6df034b89 --- /dev/null +++ b/homeassistant/components/climate/daikin.py @@ -0,0 +1,257 @@ +""" +Support for the Daikin HVAC. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.daikin/ +""" +import logging +import re + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.climate import ( + ATTR_OPERATION_MODE, ATTR_FAN_MODE, ATTR_SWING_MODE, + ATTR_CURRENT_TEMPERATURE, ClimateDevice, PLATFORM_SCHEMA, + SUPPORT_TARGET_TEMPERATURE, SUPPORT_FAN_MODE, SUPPORT_OPERATION_MODE, + SUPPORT_SWING_MODE, STATE_OFF, STATE_AUTO, STATE_HEAT, STATE_COOL, + STATE_DRY, STATE_FAN_ONLY +) +from homeassistant.components.daikin import ( + daikin_api_setup, + ATTR_TARGET_TEMPERATURE, + ATTR_INSIDE_TEMPERATURE, + ATTR_OUTSIDE_TEMPERATURE +) +from homeassistant.const import ( + CONF_HOST, CONF_NAME, + TEMP_CELSIUS, + ATTR_TEMPERATURE +) + +REQUIREMENTS = ['pydaikin==0.4'] + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | + SUPPORT_FAN_MODE | + SUPPORT_OPERATION_MODE | + SUPPORT_SWING_MODE) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=None): cv.string, +}) + +HA_STATE_TO_DAIKIN = { + STATE_FAN_ONLY: 'fan', + STATE_DRY: 'dry', + STATE_COOL: 'cool', + STATE_HEAT: 'hot', + STATE_AUTO: 'auto', + STATE_OFF: 'off', +} + +HA_ATTR_TO_DAIKIN = { + ATTR_OPERATION_MODE: 'mode', + ATTR_FAN_MODE: 'f_rate', + ATTR_SWING_MODE: 'f_dir', +} + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Daikin HVAC platform.""" + if discovery_info is not None: + host = discovery_info.get('ip') + name = None + _LOGGER.info("Discovered a Daikin AC on %s", host) + else: + host = config.get(CONF_HOST) + name = config.get(CONF_NAME) + _LOGGER.info("Added Daikin AC on %s", host) + + api = daikin_api_setup(hass, host, name) + add_devices([DaikinClimate(api)], True) + + +class DaikinClimate(ClimateDevice): + """Representation of a Daikin HVAC.""" + + def __init__(self, api): + """Initialize the climate device.""" + from pydaikin import appliance + + self._api = api + self._force_refresh = False + self._list = { + ATTR_OPERATION_MODE: list( + map(str.title, set(HA_STATE_TO_DAIKIN.values())) + ), + ATTR_FAN_MODE: list( + map( + str.title, + appliance.daikin_values(HA_ATTR_TO_DAIKIN[ATTR_FAN_MODE]) + ) + ), + ATTR_SWING_MODE: list( + map( + str.title, + appliance.daikin_values(HA_ATTR_TO_DAIKIN[ATTR_SWING_MODE]) + ) + ), + } + + def get(self, key): + """Retrieve device settings from API library cache.""" + value = None + cast_to_float = False + + if key in [ATTR_TEMPERATURE, ATTR_INSIDE_TEMPERATURE, + ATTR_CURRENT_TEMPERATURE]: + value = self._api.device.values.get('htemp') + cast_to_float = True + if key == ATTR_TARGET_TEMPERATURE: + value = self._api.device.values.get('stemp') + cast_to_float = True + elif key == ATTR_OUTSIDE_TEMPERATURE: + value = self._api.device.values.get('otemp') + cast_to_float = True + elif key == ATTR_FAN_MODE: + value = self._api.device.represent('f_rate')[1].title() + elif key == ATTR_SWING_MODE: + value = self._api.device.represent('f_dir')[1].title() + elif key == ATTR_OPERATION_MODE: + # Daikin can return also internal states auto-1 or auto-7 + # and we need to translate them as AUTO + value = re.sub( + '[^a-z]', + '', + self._api.device.represent('mode')[1] + ).title() + + if value is None: + _LOGGER.warning("Invalid value requested for key %s", key) + else: + if value == "-" or value == "--": + value = None + elif cast_to_float: + try: + value = float(value) + except ValueError: + value = None + + return value + + def set(self, settings): + """Set device settings using API.""" + values = {} + + for attr in [ATTR_TEMPERATURE, ATTR_FAN_MODE, ATTR_SWING_MODE, + ATTR_OPERATION_MODE]: + value = settings.get(attr) + if value is None: + continue + + daikin_attr = HA_ATTR_TO_DAIKIN.get(attr) + if daikin_attr is not None: + if value.title() in self._list[attr]: + values[daikin_attr] = value.lower() + else: + _LOGGER.error("Invalid value %s for %s", attr, value) + + # temperature + elif attr == ATTR_TEMPERATURE: + try: + values['stemp'] = str(int(value)) + except ValueError: + _LOGGER.error("Invalid temperature %s", value) + + if values: + self._force_refresh = True + self._api.device.set(values) + + @property + def unique_id(self): + """Return the ID of this AC.""" + return "{}.{}".format(self.__class__, self._api.ip_address) + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS + + @property + def name(self): + """Return the name of the thermostat, if any.""" + return self._api.name + + @property + def temperature_unit(self): + """Return the unit of measurement which this thermostat uses.""" + return TEMP_CELSIUS + + @property + def current_temperature(self): + """Return the current temperature.""" + return self.get(ATTR_CURRENT_TEMPERATURE) + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self.get(ATTR_TARGET_TEMPERATURE) + + @property + def target_temperature_step(self): + """Return the supported step of target temperature.""" + return 1 + + def set_temperature(self, **kwargs): + """Set new target temperature.""" + self.set(kwargs) + + @property + def current_operation(self): + """Return current operation ie. heat, cool, idle.""" + return self.get(ATTR_OPERATION_MODE) + + @property + def operation_list(self): + """Return the list of available operation modes.""" + return self._list.get(ATTR_OPERATION_MODE) + + def set_operation_mode(self, operation_mode): + """Set HVAC mode.""" + self.set({ATTR_OPERATION_MODE: operation_mode}) + + @property + def current_fan_mode(self): + """Return the fan setting.""" + return self.get(ATTR_FAN_MODE) + + def set_fan_mode(self, fan): + """Set fan mode.""" + self.set({ATTR_FAN_MODE: fan}) + + @property + def fan_list(self): + """List of available fan modes.""" + return self._list.get(ATTR_FAN_MODE) + + @property + def current_swing_mode(self): + """Return the fan setting.""" + return self.get(ATTR_SWING_MODE) + + def set_swing_mode(self, swing_mode): + """Set new target temperature.""" + self.set({ATTR_SWING_MODE: swing_mode}) + + @property + def swing_list(self): + """List of available swing modes.""" + return self._list.get(ATTR_SWING_MODE) + + def update(self): + """Retrieve latest state.""" + self._api.update(no_throttle=self._force_refresh) + self._force_refresh = False diff --git a/homeassistant/components/daikin.py b/homeassistant/components/daikin.py new file mode 100644 index 00000000000..5808528ca5a --- /dev/null +++ b/homeassistant/components/daikin.py @@ -0,0 +1,138 @@ +""" +Platform for the Daikin AC. + +For more details about this component, please refer to the documentation +https://home-assistant.io/components/daikin/ +""" +import logging +from datetime import timedelta +from socket import timeout + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.discovery import SERVICE_DAIKIN +from homeassistant.const import ( + CONF_HOSTS, CONF_ICON, CONF_MONITORED_CONDITIONS, CONF_NAME, CONF_TYPE +) +from homeassistant.helpers import discovery +from homeassistant.helpers.discovery import load_platform +from homeassistant.util import Throttle + +REQUIREMENTS = ['pydaikin==0.4'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'daikin' +HTTP_RESOURCES = ['aircon/get_sensor_info', 'aircon/get_control_info'] + +ATTR_TARGET_TEMPERATURE = 'target_temperature' +ATTR_INSIDE_TEMPERATURE = 'inside_temperature' +ATTR_OUTSIDE_TEMPERATURE = 'outside_temperature' + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) + +COMPONENT_TYPES = ['climate', 'sensor'] + +SENSOR_TYPE_TEMPERATURE = 'temperature' + +SENSOR_TYPES = { + ATTR_INSIDE_TEMPERATURE: { + CONF_NAME: 'Inside Temperature', + CONF_ICON: 'mdi:thermometer', + CONF_TYPE: SENSOR_TYPE_TEMPERATURE + }, + ATTR_OUTSIDE_TEMPERATURE: { + CONF_NAME: 'Outside Temperature', + CONF_ICON: 'mdi:thermometer', + CONF_TYPE: SENSOR_TYPE_TEMPERATURE + } + +} + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional( + CONF_HOSTS, default=[] + ): vol.All(cv.ensure_list, [cv.string]), + vol.Optional( + CONF_MONITORED_CONDITIONS, + default=list(SENSOR_TYPES.keys()) + ): vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]) + }) +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Establish connection with Daikin.""" + def discovery_dispatch(service, discovery_info): + """Dispatcher for Daikin discovery events.""" + host = discovery_info.get('ip') + + if daikin_api_setup(hass, host) is None: + return + + for component in COMPONENT_TYPES: + load_platform(hass, component, DOMAIN, discovery_info, + config) + + discovery.listen(hass, SERVICE_DAIKIN, discovery_dispatch) + + for host in config.get(DOMAIN, {}).get(CONF_HOSTS, []): + if daikin_api_setup(hass, host) is None: + continue + + discovery_info = { + 'ip': host, + CONF_MONITORED_CONDITIONS: + config[DOMAIN][CONF_MONITORED_CONDITIONS] + } + load_platform(hass, 'sensor', DOMAIN, discovery_info, config) + + return True + + +def daikin_api_setup(hass, host, name=None): + """Create a Daikin instance only once.""" + if DOMAIN not in hass.data: + hass.data[DOMAIN] = {} + + api = hass.data[DOMAIN].get(host) + if api is None: + from pydaikin import appliance + + try: + device = appliance.Appliance(host) + except timeout: + _LOGGER.error("Connection to Daikin could not be established") + return False + + if name is None: + name = device.values['name'] + + api = DaikinApi(device, name) + + return api + + +class DaikinApi(object): + """Keep the Daikin instance in one place and centralize the update.""" + + def __init__(self, device, name): + """Initialize the Daikin Handle.""" + self.device = device + self.name = name + self.ip_address = device.ip + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self, **kwargs): + """Pull the latest data from Daikin.""" + try: + for resource in HTTP_RESOURCES: + self.device.values.update( + self.device.get_resource(resource) + ) + except timeout: + _LOGGER.warning( + "Connection failed for %s", self.ip_address + ) diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index b6578dd70fe..0c3152db3d6 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -38,6 +38,7 @@ SERVICE_XIAOMI_GW = 'xiaomi_gw' SERVICE_TELLDUSLIVE = 'tellstick' SERVICE_HUE = 'philips_hue' SERVICE_DECONZ = 'deconz' +SERVICE_DAIKIN = 'daikin' SERVICE_HANDLERS = { SERVICE_HASS_IOS_APP: ('ios', None), diff --git a/homeassistant/components/sensor/daikin.py b/homeassistant/components/sensor/daikin.py new file mode 100644 index 00000000000..ad571110e88 --- /dev/null +++ b/homeassistant/components/sensor/daikin.py @@ -0,0 +1,124 @@ +""" +Support for Daikin AC Sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.daikin/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.daikin import ( + SENSOR_TYPES, SENSOR_TYPE_TEMPERATURE, + ATTR_INSIDE_TEMPERATURE, ATTR_OUTSIDE_TEMPERATURE, + daikin_api_setup +) +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_HOST, CONF_ICON, CONF_NAME, CONF_MONITORED_CONDITIONS, CONF_TYPE +) +from homeassistant.helpers.entity import Entity +from homeassistant.util.unit_system import UnitSystem + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=None): cv.string, + vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_TYPES.keys()): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), +}) + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Daikin sensors.""" + if discovery_info is not None: + host = discovery_info.get('ip') + name = None + monitored_conditions = discovery_info.get( + CONF_MONITORED_CONDITIONS, list(SENSOR_TYPES.keys()) + ) + else: + host = config[CONF_HOST] + name = config.get(CONF_NAME) + monitored_conditions = config[CONF_MONITORED_CONDITIONS] + _LOGGER.info("Added Daikin AC sensor on %s", host) + + api = daikin_api_setup(hass, host, name) + units = hass.config.units + sensors = [] + for monitored_state in monitored_conditions: + sensors.append(DaikinClimateSensor(api, monitored_state, units, name)) + + add_devices(sensors, True) + + +class DaikinClimateSensor(Entity): + """Representation of a Sensor.""" + + def __init__(self, api, monitored_state, units: UnitSystem, name=None): + """Initialize the sensor.""" + self._api = api + self._sensor = SENSOR_TYPES.get(monitored_state) + if name is None: + name = "{} {}".format(self._sensor[CONF_NAME], api.name) + + self._name = "{} {}".format(name, monitored_state.replace("_", " ")) + self._device_attribute = monitored_state + + if self._sensor[CONF_TYPE] == SENSOR_TYPE_TEMPERATURE: + self._unit_of_measurement = units.temperature_unit + + def get(self, key): + """Retrieve device settings from API library cache.""" + value = None + cast_to_float = False + + if key == ATTR_INSIDE_TEMPERATURE: + value = self._api.device.values.get('htemp') + cast_to_float = True + elif key == ATTR_OUTSIDE_TEMPERATURE: + value = self._api.device.values.get('otemp') + + if value is None: + _LOGGER.warning("Invalid value requested for key %s", key) + else: + if value == "-" or value == "--": + value = None + elif cast_to_float: + try: + value = float(value) + except ValueError: + value = None + + return value + + @property + def unique_id(self): + """Return the ID of this AC.""" + return "{}.{}".format(self.__class__, self._api.ip_address) + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return self._sensor[CONF_ICON] + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self.get(self._device_attribute) + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit_of_measurement + + def update(self): + """Retrieve latest state.""" + self._api.update() diff --git a/requirements_all.txt b/requirements_all.txt index abcbe7fd127..c08ac5b703c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -664,6 +664,10 @@ pycsspeechtts==1.0.2 # homeassistant.components.sensor.cups # pycups==1.9.73 +# homeassistant.components.daikin +# homeassistant.components.climate.daikin +pydaikin==0.4 + # homeassistant.components.deconz pydeconz==23 From 3436676de2bcd2dd5a7d24ffbe2991856cce8fe1 Mon Sep 17 00:00:00 2001 From: Marius Date: Thu, 4 Jan 2018 20:05:11 +0200 Subject: [PATCH 146/238] Updated generic thermostat to respect operation_mode and added away mode (#11445) * Updated generic thermostat to respect operation_mode and added away mode * Updated tests to include away mode and corrected init problem for sensor state Added more tests to improve coverage and corrected again lint errors Fixed new test by moving to correct package Fixed bug not restoring away mode on restart * Added support for idle on interface through state * Added back initial_operation_mode and modified away_temp to be only one for now * Fixed houndci-bot errors * Added back check for None on restore temperature * Fixed failing tests as well * Removed unused definitions from tests * Added use case for no initial temperature and no previously saved temperature --- .../components/climate/generic_thermostat.py | 109 +++++++++++++----- .../climate/test_generic_thermostat.py | 97 +++++++++++----- 2 files changed, 149 insertions(+), 57 deletions(-) diff --git a/homeassistant/components/climate/generic_thermostat.py b/homeassistant/components/climate/generic_thermostat.py index 6574a4d5396..fdfe56ca62c 100644 --- a/homeassistant/components/climate/generic_thermostat.py +++ b/homeassistant/components/climate/generic_thermostat.py @@ -12,9 +12,9 @@ import voluptuous as vol from homeassistant.core import callback from homeassistant.core import DOMAIN as HA_DOMAIN from homeassistant.components.climate import ( - STATE_HEAT, STATE_COOL, STATE_IDLE, ClimateDevice, PLATFORM_SCHEMA, - STATE_AUTO, ATTR_OPERATION_MODE, SUPPORT_OPERATION_MODE, - SUPPORT_TARGET_TEMPERATURE) + STATE_HEAT, STATE_COOL, STATE_IDLE, STATE_AUTO, ClimateDevice, + ATTR_OPERATION_MODE, ATTR_AWAY_MODE, SUPPORT_OPERATION_MODE, + SUPPORT_AWAY_MODE, SUPPORT_TARGET_TEMPERATURE, PLATFORM_SCHEMA) from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, STATE_ON, STATE_OFF, ATTR_TEMPERATURE, CONF_NAME, ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF) @@ -30,6 +30,7 @@ DEPENDENCIES = ['switch', 'sensor'] DEFAULT_TOLERANCE = 0.3 DEFAULT_NAME = 'Generic Thermostat' +DEFAULT_AWAY_TEMP = 16 CONF_HEATER = 'heater' CONF_SENSOR = 'target_sensor' @@ -42,7 +43,9 @@ CONF_COLD_TOLERANCE = 'cold_tolerance' CONF_HOT_TOLERANCE = 'hot_tolerance' CONF_KEEP_ALIVE = 'keep_alive' CONF_INITIAL_OPERATION_MODE = 'initial_operation_mode' -SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE +CONF_AWAY_TEMP = 'away_temp' +SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_AWAY_MODE | + SUPPORT_OPERATION_MODE) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HEATER): cv.entity_id, @@ -60,7 +63,9 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_KEEP_ALIVE): vol.All( cv.time_period, cv.positive_timedelta), vol.Optional(CONF_INITIAL_OPERATION_MODE): - vol.In([STATE_AUTO, STATE_OFF]) + vol.In([STATE_AUTO, STATE_OFF]), + vol.Optional(CONF_AWAY_TEMP, + default=DEFAULT_AWAY_TEMP): vol.Coerce(float) }) @@ -79,11 +84,12 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): hot_tolerance = config.get(CONF_HOT_TOLERANCE) keep_alive = config.get(CONF_KEEP_ALIVE) initial_operation_mode = config.get(CONF_INITIAL_OPERATION_MODE) + away_temp = config.get(CONF_AWAY_TEMP) async_add_devices([GenericThermostat( hass, name, heater_entity_id, sensor_entity_id, min_temp, max_temp, target_temp, ac_mode, min_cycle_duration, cold_tolerance, - hot_tolerance, keep_alive, initial_operation_mode)]) + hot_tolerance, keep_alive, initial_operation_mode, away_temp)]) class GenericThermostat(ClimateDevice): @@ -92,7 +98,7 @@ class GenericThermostat(ClimateDevice): def __init__(self, hass, name, heater_entity_id, sensor_entity_id, min_temp, max_temp, target_temp, ac_mode, min_cycle_duration, cold_tolerance, hot_tolerance, keep_alive, - initial_operation_mode): + initial_operation_mode, away_temp): """Initialize the thermostat.""" self.hass = hass self._name = name @@ -103,17 +109,26 @@ class GenericThermostat(ClimateDevice): self._hot_tolerance = hot_tolerance self._keep_alive = keep_alive self._initial_operation_mode = initial_operation_mode + self._saved_target_temp = target_temp if target_temp is not None \ + else away_temp + if self.ac_mode: + self._current_operation = STATE_COOL + self._operation_list = [STATE_COOL, STATE_OFF] + else: + self._current_operation = STATE_HEAT + self._operation_list = [STATE_HEAT, STATE_OFF] if initial_operation_mode == STATE_OFF: self._enabled = False else: self._enabled = True - self._active = False self._cur_temp = None self._min_temp = min_temp self._max_temp = max_temp self._target_temp = target_temp self._unit = hass.config.units.temperature_unit + self._away_temp = away_temp + self._is_away = False async_track_state_change( hass, sensor_entity_id, self._async_sensor_changed) @@ -124,10 +139,6 @@ class GenericThermostat(ClimateDevice): async_track_time_interval( hass, self._async_keep_alive, self._keep_alive) - sensor_state = hass.states.get(sensor_entity_id) - if sensor_state: - self._async_update_temp(sensor_state) - @asyncio.coroutine def async_added_to_hass(self): """Run when entity about to be added.""" @@ -137,14 +148,37 @@ class GenericThermostat(ClimateDevice): if old_state is not None: # If we have no initial temperature, restore if self._target_temp is None: - self._target_temp = float( - old_state.attributes[ATTR_TEMPERATURE]) - - # If we have no initial operation mode, restore + # If we have a previously saved temperature + if old_state.attributes[ATTR_TEMPERATURE] is None: + if self.ac_mode: + self._target_temp = self.max_temp + else: + self._target_temp = self.min_temp + _LOGGER.warning('Undefined target temperature, \ + falling back to %s', self._target_temp) + else: + self._target_temp = float( + old_state.attributes[ATTR_TEMPERATURE]) + self._is_away = True if str( + old_state.attributes[ATTR_AWAY_MODE]) == STATE_ON else False + if old_state.attributes[ATTR_OPERATION_MODE] == STATE_OFF: + self._current_operation = STATE_OFF + self._enabled = False if self._initial_operation_mode is None: if old_state.attributes[ATTR_OPERATION_MODE] == STATE_OFF: self._enabled = False + @property + def state(self): + """Return the current state.""" + if self._is_device_active: + return self.current_operation + else: + if self._enabled: + return STATE_IDLE + else: + return STATE_OFF + @property def should_poll(self): """Return the polling state.""" @@ -167,15 +201,8 @@ class GenericThermostat(ClimateDevice): @property def current_operation(self): - """Return current operation ie. heat, cool, idle.""" - if not self._enabled: - return STATE_OFF - if self.ac_mode: - cooling = self._active and self._is_device_active - return STATE_COOL if cooling else STATE_IDLE - - heating = self._active and self._is_device_active - return STATE_HEAT if heating else STATE_IDLE + """Return current operation.""" + return self._current_operation @property def target_temperature(self): @@ -185,14 +212,20 @@ class GenericThermostat(ClimateDevice): @property def operation_list(self): """List of available operation modes.""" - return [STATE_AUTO, STATE_OFF] + return self._operation_list def set_operation_mode(self, operation_mode): """Set operation mode.""" - if operation_mode == STATE_AUTO: + if operation_mode == STATE_HEAT: + self._current_operation = STATE_HEAT + self._enabled = True + self._async_control_heating() + elif operation_mode == STATE_COOL: + self._current_operation = STATE_COOL self._enabled = True self._async_control_heating() elif operation_mode == STATE_OFF: + self._current_operation = STATE_OFF self._enabled = False if self._is_device_active: self._heater_turn_off() @@ -252,7 +285,7 @@ class GenericThermostat(ClimateDevice): @callback def _async_keep_alive(self, time): """Call at constant intervals for keep-alive purposes.""" - if self.current_operation in [STATE_COOL, STATE_HEAT]: + if self._is_device_active: self._heater_turn_on() else: self._heater_turn_off() @@ -347,3 +380,23 @@ class GenericThermostat(ClimateDevice): data = {ATTR_ENTITY_ID: self.heater_entity_id} self.hass.async_add_job( self.hass.services.async_call(HA_DOMAIN, SERVICE_TURN_OFF, data)) + + @property + def is_away_mode_on(self): + """Return true if away mode is on.""" + return self._is_away + + def turn_away_mode_on(self): + """Turn away mode on by setting it on away hold indefinitely.""" + self._is_away = True + self._saved_target_temp = self._target_temp + self._target_temp = self._away_temp + self._async_control_heating() + self.schedule_update_ha_state() + + def turn_away_mode_off(self): + """Turn away off.""" + self._is_away = False + self._target_temp = self._saved_target_temp + self._async_control_heating() + self.schedule_update_ha_state() diff --git a/tests/components/climate/test_generic_thermostat.py b/tests/components/climate/test_generic_thermostat.py index 63bbce2e7c6..776e79a6827 100644 --- a/tests/components/climate/test_generic_thermostat.py +++ b/tests/components/climate/test_generic_thermostat.py @@ -29,6 +29,7 @@ from tests.common import (assert_setup_component, get_test_home_assistant, ENTITY = 'climate.test' ENT_SENSOR = 'sensor.test' ENT_SWITCH = 'switch.test' +ATTR_AWAY_MODE = 'away_mode' MIN_TEMP = 3.0 MAX_TEMP = 65.0 TARGET_TEMP = 42.0 @@ -69,22 +70,6 @@ class TestSetupClimateGenericThermostat(unittest.TestCase): }}) ) - def test_setup_with_sensor(self): - """Test set up heat_control with sensor to trigger update at init.""" - self.hass.states.set(ENT_SENSOR, 22.0, { - ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS - }) - assert setup_component(self.hass, climate.DOMAIN, {'climate': { - 'platform': 'generic_thermostat', - 'name': 'test', - 'heater': ENT_SWITCH, - 'target_sensor': ENT_SENSOR - }}) - state = self.hass.states.get(ENTITY) - self.assertEqual( - TEMP_CELSIUS, state.attributes.get('unit_of_measurement')) - self.assertEqual(22.0, state.attributes.get('current_temperature')) - class TestGenericThermostatHeaterSwitching(unittest.TestCase): """Test the Generic thermostat heater switching. @@ -197,7 +182,7 @@ class TestClimateGenericThermostat(unittest.TestCase): """Test that the operation list returns the correct modes.""" state = self.hass.states.get(ENTITY) modes = state.attributes.get('operation_list') - self.assertEqual([climate.STATE_AUTO, STATE_OFF], modes) + self.assertEqual([climate.STATE_HEAT, STATE_OFF], modes) def test_set_target_temp(self): """Test the setting of the target temperature.""" @@ -210,6 +195,31 @@ class TestClimateGenericThermostat(unittest.TestCase): state = self.hass.states.get(ENTITY) self.assertEqual(30.0, state.attributes.get('temperature')) + def test_set_away_mode(self): + """Test the setting away mode.""" + climate.set_temperature(self.hass, 23) + self.hass.block_till_done() + climate.set_away_mode(self.hass, True) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY) + self.assertEqual(16, state.attributes.get('temperature')) + + def test_set_away_mode_and_restore_prev_temp(self): + """Test the setting and removing away mode. + + Verify original temperature is restored. + """ + climate.set_temperature(self.hass, 23) + self.hass.block_till_done() + climate.set_away_mode(self.hass, True) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY) + self.assertEqual(16, state.attributes.get('temperature')) + climate.set_away_mode(self.hass, False) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY) + self.assertEqual(23, state.attributes.get('temperature')) + def test_sensor_bad_unit(self): """Test sensor that have bad unit.""" state = self.hass.states.get(ENTITY) @@ -337,8 +347,8 @@ class TestClimateGenericThermostat(unittest.TestCase): self.hass.block_till_done() self.assertEqual(log_mock.call_count, 1) - def test_operating_mode_auto(self): - """Test change mode from OFF to AUTO. + def test_operating_mode_heat(self): + """Test change mode from OFF to HEAT. Switch turns on when temp below setpoint and mode changes. """ @@ -347,7 +357,7 @@ class TestClimateGenericThermostat(unittest.TestCase): self._setup_sensor(25) self.hass.block_till_done() self._setup_switch(False) - climate.set_operation_mode(self.hass, climate.STATE_AUTO) + climate.set_operation_mode(self.hass, climate.STATE_HEAT) self.hass.block_till_done() self.assertEqual(1, len(self.calls)) call = self.calls[0] @@ -387,6 +397,7 @@ class TestClimateGenericThermostatACMode(unittest.TestCase): 'name': 'test', 'cold_tolerance': 2, 'hot_tolerance': 4, + 'away_temp': 30, 'heater': ENT_SWITCH, 'target_sensor': ENT_SENSOR, 'ac_mode': True @@ -409,6 +420,35 @@ class TestClimateGenericThermostatACMode(unittest.TestCase): self.assertEqual(SERVICE_TURN_OFF, call.service) self.assertEqual(ENT_SWITCH, call.data['entity_id']) + def test_turn_away_mode_on_cooling(self): + """Test the setting away mode when cooling.""" + self._setup_sensor(25) + self.hass.block_till_done() + climate.set_temperature(self.hass, 19) + self.hass.block_till_done() + climate.set_away_mode(self.hass, True) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY) + self.assertEqual(30, state.attributes.get('temperature')) + + def test_operating_mode_cool(self): + """Test change mode from OFF to COOL. + + Switch turns on when temp below setpoint and mode changes. + """ + climate.set_operation_mode(self.hass, STATE_OFF) + climate.set_temperature(self.hass, 25) + self._setup_sensor(30) + self.hass.block_till_done() + self._setup_switch(False) + climate.set_operation_mode(self.hass, climate.STATE_COOL) + self.hass.block_till_done() + self.assertEqual(1, len(self.calls)) + call = self.calls[0] + self.assertEqual('homeassistant', call.domain) + self.assertEqual(SERVICE_TURN_ON, call.service) + self.assertEqual(ENT_SWITCH, call.data['entity_id']) + def test_set_target_temp_ac_on(self): """Test if target temperature turn ac on.""" self._setup_switch(False) @@ -891,15 +931,13 @@ def test_custom_setup_params(hass): 'target_sensor': ENT_SENSOR, 'min_temp': MIN_TEMP, 'max_temp': MAX_TEMP, - 'target_temp': TARGET_TEMP, - 'initial_operation_mode': STATE_OFF, + 'target_temp': TARGET_TEMP }}) assert result state = hass.states.get(ENTITY) assert state.attributes.get('min_temp') == MIN_TEMP assert state.attributes.get('max_temp') == MAX_TEMP assert state.attributes.get('temperature') == TARGET_TEMP - assert state.attributes.get(climate.ATTR_OPERATION_MODE) == STATE_OFF @asyncio.coroutine @@ -907,7 +945,7 @@ def test_restore_state(hass): """Ensure states are restored on startup.""" mock_restore_cache(hass, ( State('climate.test_thermostat', '0', {ATTR_TEMPERATURE: "20", - climate.ATTR_OPERATION_MODE: "off"}), + climate.ATTR_OPERATION_MODE: "off", ATTR_AWAY_MODE: "on"}), )) hass.state = CoreState.starting @@ -927,10 +965,13 @@ def test_restore_state(hass): @asyncio.coroutine def test_no_restore_state(hass): - """Ensure states are not restored on startup if not needed.""" + """Ensure states are restored on startup if they exist. + + Allows for graceful reboot. + """ mock_restore_cache(hass, ( State('climate.test_thermostat', '0', {ATTR_TEMPERATURE: "20", - climate.ATTR_OPERATION_MODE: "off"}), + climate.ATTR_OPERATION_MODE: "off", ATTR_AWAY_MODE: "on"}), )) hass.state = CoreState.starting @@ -941,10 +982,8 @@ def test_no_restore_state(hass): 'name': 'test_thermostat', 'heater': ENT_SWITCH, 'target_sensor': ENT_SENSOR, - 'target_temp': 22, - 'initial_operation_mode': 'auto', + 'target_temp': 22 }}) state = hass.states.get('climate.test_thermostat') assert(state.attributes[ATTR_TEMPERATURE] == 22) - assert(state.attributes[climate.ATTR_OPERATION_MODE] != "off") From 6cb02128b4dd646444a66a03019b24e97616e23d Mon Sep 17 00:00:00 2001 From: PhracturedBlue Date: Thu, 4 Jan 2018 11:10:56 -0800 Subject: [PATCH 147/238] Reconnect to alarmdecoder on disconnect (#11383) * Reconnect to alarmdecoder on disconnect * Use track_point_in_time instead of call_later * use alarmdecoder 1.13.2 which has a more robust reconnection fix --- homeassistant/components/alarmdecoder.py | 34 +++++++++++++++++++++--- requirements_all.txt | 2 +- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/alarmdecoder.py b/homeassistant/components/alarmdecoder.py index c5321b918b9..120925dab6e 100644 --- a/homeassistant/components/alarmdecoder.py +++ b/homeassistant/components/alarmdecoder.py @@ -6,13 +6,15 @@ https://home-assistant.io/components/alarmdecoder/ """ import logging +from datetime import timedelta import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.helpers.discovery import load_platform +from homeassistant.util import dt as dt_util -REQUIREMENTS = ['alarmdecoder==0.12.3'] +REQUIREMENTS = ['alarmdecoder==1.13.2'] _LOGGER = logging.getLogger(__name__) @@ -88,6 +90,7 @@ def setup(hass, config): conf = config.get(DOMAIN) + restart = False device = conf.get(CONF_DEVICE) display = conf.get(CONF_PANEL_DISPLAY) zones = conf.get(CONF_ZONES) @@ -101,8 +104,33 @@ def setup(hass, config): def stop_alarmdecoder(event): """Handle the shutdown of AlarmDecoder.""" _LOGGER.debug("Shutting down alarmdecoder") + nonlocal restart + restart = False controller.close() + def open_connection(now=None): + """Open a connection to AlarmDecoder.""" + from alarmdecoder.util import NoDeviceError + nonlocal restart + try: + controller.open(baud) + except NoDeviceError: + _LOGGER.debug("Failed to connect. Retrying in 5 seconds") + hass.helpers.event.track_point_in_time( + open_connection, dt_util.utcnow() + timedelta(seconds=5)) + return + _LOGGER.debug("Established a connection with the alarmdecoder") + restart = True + + def handle_closed_connection(event): + """Restart after unexpected loss of connection.""" + nonlocal restart + if not restart: + return + restart = False + _LOGGER.warning("AlarmDecoder unexpectedly lost connection.") + hass.add_job(open_connection) + def handle_message(sender, message): """Handle message from AlarmDecoder.""" hass.helpers.dispatcher.dispatcher_send( @@ -140,12 +168,12 @@ def setup(hass, config): controller.on_rfx_message += handle_rfx_message controller.on_zone_fault += zone_fault_callback controller.on_zone_restore += zone_restore_callback + controller.on_close += handle_closed_connection hass.data[DATA_AD] = controller - controller.open(baud) + open_connection() - _LOGGER.debug("Established a connection with the alarmdecoder") hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_alarmdecoder) load_platform(hass, 'alarm_control_panel', DOMAIN, conf, config) diff --git a/requirements_all.txt b/requirements_all.txt index c08ac5b703c..7084efae27b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -81,7 +81,7 @@ aiolifx_effects==0.1.2 aiopvapi==1.5.4 # homeassistant.components.alarmdecoder -alarmdecoder==0.12.3 +alarmdecoder==1.13.2 # homeassistant.components.sensor.alpha_vantage alpha_vantage==1.6.0 From 35f35050ff158b423dc437c66bdea5ebcbabc009 Mon Sep 17 00:00:00 2001 From: Thijs de Jong Date: Fri, 5 Jan 2018 01:02:31 +0100 Subject: [PATCH 148/238] Set tahoma cover scan interval to 60 seconds (#11447) * Increase tahoma scan interval to 60 * import timedelta * fix lint --- homeassistant/components/cover/tahoma.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/cover/tahoma.py b/homeassistant/components/cover/tahoma.py index ce668cfe876..2f0362535ca 100644 --- a/homeassistant/components/cover/tahoma.py +++ b/homeassistant/components/cover/tahoma.py @@ -5,6 +5,7 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/cover.tahoma/ """ import logging +from datetime import timedelta from homeassistant.components.cover import CoverDevice, ENTITY_ID_FORMAT from homeassistant.components.tahoma import ( @@ -14,6 +15,8 @@ DEPENDENCIES = ['tahoma'] _LOGGER = logging.getLogger(__name__) +SCAN_INTERVAL = timedelta(seconds=60) + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up Tahoma covers.""" From b7c70418734b079213afdf8f3ba6ab716598b425 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 4 Jan 2018 21:40:18 -0800 Subject: [PATCH 149/238] Add some tests to the cloud component (#11460) --- homeassistant/components/cloud/__init__.py | 2 +- homeassistant/components/cloud/iot.py | 4 +- tests/components/cloud/test_iot.py | 59 ++++++++++++++++++++++ 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 7f998311a6b..a3cedd38cd9 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -72,7 +72,7 @@ def async_setup(hass, config): kwargs[CONF_GOOGLE_ASSISTANT] = ASSISTANT_SCHEMA({}) kwargs[CONF_ALEXA] = alexa_sh.Config(**kwargs[CONF_ALEXA]) - kwargs['gass_should_expose'] = kwargs.pop(CONF_GOOGLE_ASSISTANT) + kwargs['gass_should_expose'] = kwargs.pop(CONF_GOOGLE_ASSISTANT)['filter'] cloud = hass.data[DOMAIN] = Cloud(hass, **kwargs) success = yield from cloud.initialize() diff --git a/homeassistant/components/cloud/iot.py b/homeassistant/components/cloud/iot.py index 789ffae2fed..a7ff30025c7 100644 --- a/homeassistant/components/cloud/iot.py +++ b/homeassistant/components/cloud/iot.py @@ -212,8 +212,8 @@ def async_handle_alexa(hass, cloud, payload): @HANDLERS.register('google_actions') @asyncio.coroutine -def async_handle_google_assistant(hass, cloud, payload): - """Handle an incoming IoT message for Google Assistant.""" +def async_handle_google_actions(hass, cloud, payload): + """Handle an incoming IoT message for Google Actions.""" result = yield from ga.async_handle_message(hass, cloud.gass_config, payload) return result diff --git a/tests/components/cloud/test_iot.py b/tests/components/cloud/test_iot.py index be5a93c9e47..e74d89c744d 100644 --- a/tests/components/cloud/test_iot.py +++ b/tests/components/cloud/test_iot.py @@ -5,7 +5,9 @@ from unittest.mock import patch, MagicMock, PropertyMock from aiohttp import WSMsgType, client_exceptions import pytest +from homeassistant.setup import async_setup_component from homeassistant.components.cloud import iot, auth_api +from tests.components.alexa import test_smart_home as test_alexa from tests.common import mock_coro @@ -36,6 +38,16 @@ def mock_cloud(): return MagicMock(subscription_expired=False) +@pytest.fixture +def cloud_instance(loop, hass): + """Instance of an initialized cloud class.""" + with patch('homeassistant.components.cloud.Cloud.initialize', + return_value=mock_coro(True)): + loop.run_until_complete(async_setup_component(hass, 'cloud', {})) + + yield hass.data['cloud'] + + @asyncio.coroutine def test_cloud_calling_handler(mock_client, mock_handle_message, mock_cloud): """Test we call handle message with correct info.""" @@ -254,3 +266,50 @@ def test_refresh_token_before_expiration_fails(hass, mock_cloud): assert len(mock_check_token.mock_calls) == 1 assert len(mock_create.mock_calls) == 1 + + +@asyncio.coroutine +def test_handler_alexa(hass, cloud_instance): + """Test handler Alexa.""" + hass.states.async_set( + 'switch.test', 'on', {'friendly_name': "Test switch"}) + + resp = yield from iot.async_handle_alexa( + hass, cloud_instance, + test_alexa.get_new_request('Alexa.Discovery', 'Discover')) + + endpoints = resp['event']['payload']['endpoints'] + + assert len(endpoints) == 1 + device = endpoints[0] + + assert device['description'] == 'switch.test' + assert device['friendlyName'] == 'Test switch' + assert device['manufacturerName'] == 'Home Assistant' + + +@asyncio.coroutine +def test_handler_google_actions(hass, cloud_instance): + """Test handler Google Actions.""" + hass.states.async_set( + 'switch.test', 'on', {'friendly_name': "Test switch"}) + + reqid = '5711642932632160983' + data = {'requestId': reqid, 'inputs': [{'intent': 'action.devices.SYNC'}]} + + with patch('homeassistant.components.cloud.Cloud._decode_claims', + return_value={'cognito:username': 'myUserName'}): + resp = yield from iot.async_handle_google_actions( + hass, cloud_instance, data) + + assert resp['requestId'] == reqid + payload = resp['payload'] + + assert payload['agentUserId'] == 'myUserName' + + devices = payload['devices'] + assert len(devices) == 1 + + device = devices[0] + assert device['id'] == 'switch.test' + assert device['name']['name'] == 'Test switch' From 1e9e6927ebbb20ee66451e60979507de1b656e61 Mon Sep 17 00:00:00 2001 From: cdce8p <30130371+cdce8p@users.noreply.github.com> Date: Fri, 5 Jan 2018 09:02:10 +0100 Subject: [PATCH 150/238] Input Select - Added service description (#11456) --- homeassistant/components/input_select.py | 11 ++++++++ homeassistant/components/services.yaml | 32 ++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/homeassistant/components/input_select.py b/homeassistant/components/input_select.py index f16b029c1d7..5df26a83089 100644 --- a/homeassistant/components/input_select.py +++ b/homeassistant/components/input_select.py @@ -6,12 +6,14 @@ at https://home-assistant.io/components/input_select/ """ import asyncio import logging +import os import voluptuous as vol from homeassistant.const import ATTR_ENTITY_ID, CONF_ICON, CONF_NAME from homeassistant.loader import bind_hass import homeassistant.helpers.config_validation as cv +from homeassistant.config import load_yaml_config_file from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import async_get_last_state @@ -129,6 +131,11 @@ def async_setup(hass, config): if not entities: return False + descriptions = yield from hass.async_add_job( + load_yaml_config_file, os.path.join( + os.path.dirname(__file__), 'services.yaml') + ) + @asyncio.coroutine def async_select_option_service(call): """Handle a calls to the input select option service.""" @@ -141,6 +148,7 @@ def async_setup(hass, config): hass.services.async_register( DOMAIN, SERVICE_SELECT_OPTION, async_select_option_service, + descriptions[DOMAIN][SERVICE_SELECT_OPTION], schema=SERVICE_SELECT_OPTION_SCHEMA) @asyncio.coroutine @@ -155,6 +163,7 @@ def async_setup(hass, config): hass.services.async_register( DOMAIN, SERVICE_SELECT_NEXT, async_select_next_service, + descriptions[DOMAIN][SERVICE_SELECT_NEXT], schema=SERVICE_SELECT_NEXT_SCHEMA) @asyncio.coroutine @@ -169,6 +178,7 @@ def async_setup(hass, config): hass.services.async_register( DOMAIN, SERVICE_SELECT_PREVIOUS, async_select_previous_service, + descriptions[DOMAIN][SERVICE_SELECT_PREVIOUS], schema=SERVICE_SELECT_PREVIOUS_SCHEMA) @asyncio.coroutine @@ -183,6 +193,7 @@ def async_setup(hass, config): hass.services.async_register( DOMAIN, SERVICE_SET_OPTIONS, async_set_options_service, + descriptions[DOMAIN][SERVICE_SET_OPTIONS], schema=SERVICE_SET_OPTIONS_SCHEMA) yield from component.async_add_entities(entities) diff --git a/homeassistant/components/services.yaml b/homeassistant/components/services.yaml index 90a1bbbc613..03c1d24184a 100644 --- a/homeassistant/components/services.yaml +++ b/homeassistant/components/services.yaml @@ -418,6 +418,38 @@ input_number: description: Entity id of the input number the should be decremented. example: 'input_number.threshold' +input_select: + select_option: + description: Select an option of an input select entity. + fields: + entity_id: + description: Entity id of the input select to select the value. + example: 'input_select.my_select' + option: + description: Option to be selected. + example: '"Item A"' + set_options: + description: Set the options of an input select entity. + fields: + entity_id: + description: Entity id of the input select to set the new options for. + example: 'input_select.my_select' + options: + description: Options for the input select entity. + example: '["Item A", "Item B", "Item C"]' + select_previous: + description: Select the previous options of an input select entity. + fields: + entity_id: + description: Entity id of the input select to select the previous value for. + example: 'input_select.my_select' + select_next: + description: Select the next options of an input select entity. + fields: + entity_id: + description: Entity id of the input select to select the next value for. + example: 'input_select.my_select' + homeassistant: check_config: description: Check the Home Assistant configuration files for errors. Errors will be displayed in the Home Assistant log. From 642e9c53baaa7cf856117ecf43a82cdb51c351da Mon Sep 17 00:00:00 2001 From: cdce8p <30130371+cdce8p@users.noreply.github.com> Date: Fri, 5 Jan 2018 09:03:00 +0100 Subject: [PATCH 151/238] Input Boolean - Deleted 'DEFAULT_INITIAL' (#11453) --- homeassistant/components/input_boolean.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/input_boolean.py b/homeassistant/components/input_boolean.py index e60f44e8ea0..9f5c18f05f0 100644 --- a/homeassistant/components/input_boolean.py +++ b/homeassistant/components/input_boolean.py @@ -27,7 +27,6 @@ ENTITY_ID_FORMAT = DOMAIN + '.{}' _LOGGER = logging.getLogger(__name__) CONF_INITIAL = 'initial' -DEFAULT_INITIAL = False SERVICE_SCHEMA = vol.Schema({ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, From 1490ccf7fb9cd3205232fccd3fc1de174fbd3777 Mon Sep 17 00:00:00 2001 From: cdce8p <30130371+cdce8p@users.noreply.github.com> Date: Fri, 5 Jan 2018 09:03:32 +0100 Subject: [PATCH 152/238] Updated gitignore file (#11452) --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index e01de1b49b8..c8a6fed2ddf 100644 --- a/.gitignore +++ b/.gitignore @@ -74,6 +74,7 @@ pip-selfcheck.json venv .venv Pipfile* +share/* # vimmy stuff *.swp From ebbdce1f424ad850a250e051c161f081c840d58f Mon Sep 17 00:00:00 2001 From: Thibault Cohen Date: Fri, 5 Jan 2018 04:22:40 -0500 Subject: [PATCH 153/238] Update hydroquebec component to use hass httpsession (#11412) * Update hydroquebec component to use hass httpsession * Remove blank line --- .../components/sensor/hydroquebec.py | 23 ++++++++---- requirements_all.txt | 2 +- tests/components/sensor/test_hydroquebec.py | 36 +++++++++++++------ 3 files changed, 43 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/sensor/hydroquebec.py b/homeassistant/components/sensor/hydroquebec.py index d4dea54514a..e10abc14ff1 100644 --- a/homeassistant/components/sensor/hydroquebec.py +++ b/homeassistant/components/sensor/hydroquebec.py @@ -21,7 +21,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pyhydroquebec==2.0.2'] +REQUIREMENTS = ['pyhydroquebec==2.1.0'] _LOGGER = logging.getLogger(__name__) @@ -103,8 +103,12 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): password = config.get(CONF_PASSWORD) contract = config.get(CONF_CONTRACT) - hydroquebec_data = HydroquebecData(username, password, contract) + httpsession = hass.helpers.aiohttp_client.async_get_clientsession() + hydroquebec_data = HydroquebecData(username, password, httpsession, + contract) contracts = yield from hydroquebec_data.get_contract_list() + if not contracts: + return _LOGGER.info("Contract list: %s", ", ".join(contracts)) @@ -161,11 +165,11 @@ class HydroQuebecSensor(Entity): class HydroquebecData(object): """Get data from HydroQuebec.""" - def __init__(self, username, password, contract=None): + def __init__(self, username, password, httpsession, contract=None): """Initialize the data object.""" from pyhydroquebec import HydroQuebecClient self.client = HydroQuebecClient( - username, password, REQUESTS_TIMEOUT) + username, password, REQUESTS_TIMEOUT, httpsession) self._contract = contract self.data = {} @@ -173,17 +177,22 @@ class HydroquebecData(object): def get_contract_list(self): """Return the contract list.""" # Fetch data - yield from self._fetch_data() - return self.client.get_contracts() + ret = yield from self._fetch_data() + if ret: + return self.client.get_contracts() + return [] @asyncio.coroutine @Throttle(MIN_TIME_BETWEEN_UPDATES) def _fetch_data(self): """Fetch latest data from HydroQuebec.""" + from pyhydroquebec.client import PyHydroQuebecError try: yield from self.client.fetch_data() - except BaseException as exp: + except PyHydroQuebecError as exp: _LOGGER.error("Error on receive last Hydroquebec data: %s", exp) + return False + return True @asyncio.coroutine def async_update(self): diff --git a/requirements_all.txt b/requirements_all.txt index 7084efae27b..0dccf542fd9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -717,7 +717,7 @@ pyhiveapi==0.2.10 pyhomematic==0.1.36 # homeassistant.components.sensor.hydroquebec -pyhydroquebec==2.0.2 +pyhydroquebec==2.1.0 # homeassistant.components.alarm_control_panel.ialarm pyialarm==0.2 diff --git a/tests/components/sensor/test_hydroquebec.py b/tests/components/sensor/test_hydroquebec.py index f2ca97313d3..debd6ef6167 100644 --- a/tests/components/sensor/test_hydroquebec.py +++ b/tests/components/sensor/test_hydroquebec.py @@ -15,7 +15,7 @@ CONTRACT = "123456789" class HydroQuebecClientMock(): """Fake Hydroquebec client.""" - def __init__(self, username, password, contract=None): + def __init__(self, username, password, contract=None, httpsession=None): """Fake Hydroquebec client init.""" pass @@ -36,16 +36,32 @@ class HydroQuebecClientMock(): class HydroQuebecClientMockError(HydroQuebecClientMock): """Fake Hydroquebec client error.""" + def get_contracts(self): + """Return fake hydroquebec contracts.""" + return [] + @asyncio.coroutine def fetch_data(self): """Return fake fetching data.""" - raise hydroquebec.PyHydroQuebecError("Fake Error") + raise PyHydroQuebecErrorMock("Fake Error") class PyHydroQuebecErrorMock(BaseException): """Fake PyHydroquebec Error.""" +class PyHydroQuebecClientFakeModule(): + """Fake pyfido.client module.""" + + PyHydroQuebecError = PyHydroQuebecErrorMock + + +class PyHydroQuebecFakeModule(): + """Fake pyfido module.""" + + HydroQuebecClient = HydroQuebecClientMockError + + @asyncio.coroutine def test_hydroquebec_sensor(loop, hass): """Test the Hydroquebec number sensor.""" @@ -79,11 +95,11 @@ def test_hydroquebec_sensor(loop, hass): def test_error(hass, caplog): """Test the Hydroquebec sensor errors.""" caplog.set_level(logging.ERROR) - sys.modules['pyhydroquebec'] = MagicMock() - sys.modules['pyhydroquebec.client'] = MagicMock() - import pyhydroquebec.client - pyhydroquebec.HydroQuebecClient = HydroQuebecClientMockError - pyhydroquebec.client.PyHydroQuebecError = BaseException - hydro_data = hydroquebec.HydroquebecData('username', 'password') - yield from hydro_data._fetch_data() - assert "Error on receive last Hydroquebec data: " in caplog.text + sys.modules['pyhydroquebec'] = PyHydroQuebecFakeModule() + sys.modules['pyhydroquebec.client'] = PyHydroQuebecClientFakeModule() + + config = {} + fake_async_add_devices = MagicMock() + yield from hydroquebec.async_setup_platform(hass, config, + fake_async_add_devices) + assert fake_async_add_devices.called is False From 5236b720ab2e09faebe9c768b67504d4285bb10f Mon Sep 17 00:00:00 2001 From: William Scanlon Date: Fri, 5 Jan 2018 12:07:09 -0500 Subject: [PATCH 154/238] Catch everything when calling to OctoPrint API to fix #10557 (#11457) --- homeassistant/components/octoprint.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/octoprint.py b/homeassistant/components/octoprint.py index f3e3ecc29b2..5caaa1b372d 100644 --- a/homeassistant/components/octoprint.py +++ b/homeassistant/components/octoprint.py @@ -117,9 +117,7 @@ class OctoPrintAPI(object): self.job_error_logged = False self.printer_error_logged = False return response.json() - except (requests.exceptions.ConnectionError, - requests.exceptions.HTTPError, - requests.exceptions.ReadTimeout) as conn_exc: + except Exception as conn_exc: # pylint: disable=broad-except log_string = "Failed to update OctoPrint status. " + \ " Error: %s" % (conn_exc) # Only log the first failure From 65d841c3a60c613ca3a5ce12e4994656b0fd0a96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Fri, 5 Jan 2018 18:31:41 +0100 Subject: [PATCH 155/238] Update PULL_REQUEST_TEMPLATE.md (#11465) * Update PULL_REQUEST_TEMPLATE.md * Add period --- .github/PULL_REQUEST_TEMPLATE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index dd030c73d1a..43e1c399671 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -11,6 +11,7 @@ ``` ## Checklist: + - [ ] The code change is tested and works locally. If user exposed functionality or configuration variables are added/changed: - [ ] Documentation added/updated in [home-assistant.github.io](https://github.com/home-assistant/home-assistant.github.io) From 71fb7a6ef68675ba8b671646058eb25207425f55 Mon Sep 17 00:00:00 2001 From: Matt N Date: Fri, 5 Jan 2018 14:28:03 -0500 Subject: [PATCH 156/238] iOS 10 should be served javascript_version:es5 (#11387) * iOS 10 should be served javascript_version:es5 Fixes #11234 * Update min Safari version to 12 --- homeassistant/components/frontend/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index e6292c7de82..aa74866aeab 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -586,9 +586,10 @@ def _is_latest(js_option, request): from user_agents import parse useragent = parse(useragent) - # on iOS every browser is a Safari which we support from version 10. + # on iOS every browser is a Safari which we support from version 11. if useragent.os.family == 'iOS': - return useragent.os.version[0] >= 10 + # Was >= 10, temp setting it to 12 to work around issue #11387 + return useragent.os.version[0] >= 12 family_min_version = { 'Chrome': 50, # Probably can reduce this From 8b57777ce99284b0bf5dd02b9271142172ecad5b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 5 Jan 2018 12:33:22 -0800 Subject: [PATCH 157/238] Alexa to not use customize for entity config (#11461) * Alexa to not use customize for entity config * Test Alexa entity config * Improve tests * Fix test --- homeassistant/components/alexa/smart_home.py | 34 +++++------ homeassistant/components/cloud/__init__.py | 58 ++++++++++++------- homeassistant/components/cloud/iot.py | 2 +- tests/components/alexa/test_smart_home.py | 43 +++++++++++++- tests/components/cloud/test_iot.py | 60 ++++++++++++++------ 5 files changed, 140 insertions(+), 57 deletions(-) diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index d303ca57704..3c14826037c 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -1,6 +1,5 @@ """Support for alexa Smart Home Skill API.""" import asyncio -from collections import namedtuple import logging import math from uuid import uuid4 @@ -27,10 +26,9 @@ API_EVENT = 'event' API_HEADER = 'header' API_PAYLOAD = 'payload' -ATTR_ALEXA_DESCRIPTION = 'alexa_description' -ATTR_ALEXA_DISPLAY_CATEGORIES = 'alexa_display_categories' -ATTR_ALEXA_HIDDEN = 'alexa_hidden' -ATTR_ALEXA_NAME = 'alexa_name' +CONF_DESCRIPTION = 'description' +CONF_DISPLAY_CATEGORIES = 'display_categories' +CONF_NAME = 'name' MAPPING_COMPONENT = { @@ -73,7 +71,13 @@ MAPPING_COMPONENT = { } -Config = namedtuple('AlexaConfig', 'filter') +class Config: + """Hold the configuration for Alexa.""" + + def __init__(self, should_expose, entity_config=None): + """Initialize the configuration.""" + self.should_expose = should_expose + self.entity_config = entity_config or {} @asyncio.coroutine @@ -150,32 +154,28 @@ def async_api_discovery(hass, config, request): discovery_endpoints = [] for entity in hass.states.async_all(): - if not config.filter(entity.entity_id): + if not config.should_expose(entity.entity_id): _LOGGER.debug("Not exposing %s because filtered by config", entity.entity_id) continue - if entity.attributes.get(ATTR_ALEXA_HIDDEN, False): - _LOGGER.debug("Not exposing %s because alexa_hidden is true", - entity.entity_id) - continue - class_data = MAPPING_COMPONENT.get(entity.domain) if not class_data: continue - friendly_name = entity.attributes.get(ATTR_ALEXA_NAME, entity.name) - description = entity.attributes.get(ATTR_ALEXA_DESCRIPTION, - entity.entity_id) + entity_conf = config.entity_config.get(entity.entity_id, {}) + + friendly_name = entity_conf.get(CONF_NAME, entity.name) + description = entity_conf.get(CONF_DESCRIPTION, entity.entity_id) # Required description as per Amazon Scene docs if entity.domain == scene.DOMAIN: scene_fmt = '{} (Scene connected via Home Assistant)' description = scene_fmt.format(description) - cat_key = ATTR_ALEXA_DISPLAY_CATEGORIES - display_categories = entity.attributes.get(cat_key, class_data[0]) + display_categories = entity_conf.get(CONF_DISPLAY_CATEGORIES, + class_data[0]) endpoint = { 'displayCategories': [display_categories], diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index a3cedd38cd9..e93eb086fd0 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -12,6 +12,7 @@ import voluptuous as vol from homeassistant.const import ( EVENT_HOMEASSISTANT_START, CONF_REGION, CONF_MODE) from homeassistant.helpers import entityfilter +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util import dt as dt_util from homeassistant.components.alexa import smart_home as alexa_sh @@ -25,7 +26,7 @@ REQUIREMENTS = ['warrant==0.6.1'] _LOGGER = logging.getLogger(__name__) CONF_ALEXA = 'alexa' -CONF_GOOGLE_ASSISTANT = 'google_assistant' +CONF_GOOGLE_ACTIONS = 'google_actions' CONF_FILTER = 'filter' CONF_COGNITO_CLIENT_ID = 'cognito_client_id' CONF_RELAYER = 'relayer' @@ -35,6 +36,14 @@ MODE_DEV = 'development' DEFAULT_MODE = 'production' DEPENDENCIES = ['http'] +CONF_ENTITY_CONFIG = 'entity_config' + +ALEXA_ENTITY_SCHEMA = vol.Schema({ + vol.Optional(alexa_sh.CONF_DESCRIPTION): cv.string, + vol.Optional(alexa_sh.CONF_DISPLAY_CATEGORIES): cv.string, + vol.Optional(alexa_sh.CONF_NAME): cv.string, +}) + ASSISTANT_SCHEMA = vol.Schema({ vol.Optional( CONF_FILTER, @@ -42,6 +51,10 @@ ASSISTANT_SCHEMA = vol.Schema({ ): entityfilter.FILTER_SCHEMA, }) +ALEXA_SCHEMA = ASSISTANT_SCHEMA.extend({ + vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: ALEXA_ENTITY_SCHEMA} +}) + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Optional(CONF_MODE, default=DEFAULT_MODE): @@ -51,8 +64,8 @@ CONFIG_SCHEMA = vol.Schema({ vol.Optional(CONF_USER_POOL_ID): str, vol.Optional(CONF_REGION): str, vol.Optional(CONF_RELAYER): str, - vol.Optional(CONF_ALEXA): ASSISTANT_SCHEMA, - vol.Optional(CONF_GOOGLE_ASSISTANT): ASSISTANT_SCHEMA, + vol.Optional(CONF_ALEXA): ALEXA_SCHEMA, + vol.Optional(CONF_GOOGLE_ACTIONS): ASSISTANT_SCHEMA, }), }, extra=vol.ALLOW_EXTRA) @@ -61,18 +74,19 @@ CONFIG_SCHEMA = vol.Schema({ def async_setup(hass, config): """Initialize the Home Assistant cloud.""" if DOMAIN in config: - kwargs = config[DOMAIN] + kwargs = dict(config[DOMAIN]) else: kwargs = {CONF_MODE: DEFAULT_MODE} - if CONF_ALEXA not in kwargs: - kwargs[CONF_ALEXA] = ASSISTANT_SCHEMA({}) + alexa_conf = kwargs.pop(CONF_ALEXA, None) or ALEXA_SCHEMA({}) + gactions_conf = (kwargs.pop(CONF_GOOGLE_ACTIONS, None) or + ASSISTANT_SCHEMA({})) - if CONF_GOOGLE_ASSISTANT not in kwargs: - kwargs[CONF_GOOGLE_ASSISTANT] = ASSISTANT_SCHEMA({}) - - kwargs[CONF_ALEXA] = alexa_sh.Config(**kwargs[CONF_ALEXA]) - kwargs['gass_should_expose'] = kwargs.pop(CONF_GOOGLE_ASSISTANT)['filter'] + kwargs[CONF_ALEXA] = alexa_sh.Config( + should_expose=alexa_conf[CONF_FILTER], + entity_config=alexa_conf.get(CONF_ENTITY_CONFIG), + ) + kwargs['gactions_should_expose'] = gactions_conf[CONF_FILTER] cloud = hass.data[DOMAIN] = Cloud(hass, **kwargs) success = yield from cloud.initialize() @@ -87,15 +101,15 @@ def async_setup(hass, config): class Cloud: """Store the configuration of the cloud connection.""" - def __init__(self, hass, mode, alexa, gass_should_expose, + def __init__(self, hass, mode, alexa, gactions_should_expose, cognito_client_id=None, user_pool_id=None, region=None, relayer=None): """Create an instance of Cloud.""" self.hass = hass self.mode = mode self.alexa_config = alexa - self._gass_should_expose = gass_should_expose - self._gass_config = None + self._gactions_should_expose = gactions_should_expose + self._gactions_config = None self.jwt_keyset = None self.id_token = None self.access_token = None @@ -144,15 +158,19 @@ class Cloud: return self.path('{}_auth.json'.format(self.mode)) @property - def gass_config(self): + def gactions_config(self): """Return the Google Assistant config.""" - if self._gass_config is None: - self._gass_config = ga_sh.Config( - should_expose=self._gass_should_expose, + if self._gactions_config is None: + def should_expose(entity): + """If an entity should be exposed.""" + return self._gactions_should_expose(entity.entity_id) + + self._gactions_config = ga_sh.Config( + should_expose=should_expose, agent_user_id=self.claims['cognito:username'] ) - return self._gass_config + return self._gactions_config @asyncio.coroutine def initialize(self): @@ -182,7 +200,7 @@ class Cloud: self.id_token = None self.access_token = None self.refresh_token = None - self._gass_config = None + self._gactions_config = None yield from self.hass.async_add_job( lambda: os.remove(self.user_info_path)) diff --git a/homeassistant/components/cloud/iot.py b/homeassistant/components/cloud/iot.py index a7ff30025c7..ffe68c3c877 100644 --- a/homeassistant/components/cloud/iot.py +++ b/homeassistant/components/cloud/iot.py @@ -214,7 +214,7 @@ def async_handle_alexa(hass, cloud, payload): @asyncio.coroutine def async_handle_google_actions(hass, cloud, payload): """Handle an incoming IoT message for Google Actions.""" - result = yield from ga.async_handle_message(hass, cloud.gass_config, + result = yield from ga.async_handle_message(hass, cloud.gactions_config, payload) return result diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index baa05ed0994..6ac56bc10a3 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -9,7 +9,7 @@ from homeassistant.helpers import entityfilter from tests.common import async_mock_service -DEFAULT_CONFIG = smart_home.Config(filter=lambda entity_id: True) +DEFAULT_CONFIG = smart_home.Config(should_expose=lambda entity_id: True) def get_new_request(namespace, name, endpoint=None): @@ -338,7 +338,7 @@ def test_exclude_filters(hass): hass.states.async_set( 'cover.deny', 'off', {'friendly_name': "Blocked cover"}) - config = smart_home.Config(filter=entityfilter.generate_filter( + config = smart_home.Config(should_expose=entityfilter.generate_filter( include_domains=[], include_entities=[], exclude_domains=['script'], @@ -371,7 +371,7 @@ def test_include_filters(hass): hass.states.async_set( 'group.allow', 'off', {'friendly_name': "Allowed group"}) - config = smart_home.Config(filter=entityfilter.generate_filter( + config = smart_home.Config(should_expose=entityfilter.generate_filter( include_domains=['automation', 'group'], include_entities=['script.deny'], exclude_domains=[], @@ -1116,3 +1116,40 @@ def test_api_mute(hass, domain): assert len(call) == 1 assert call[0].data['entity_id'] == '{}.test'.format(domain) assert msg['header']['name'] == 'Response' + + +@asyncio.coroutine +def test_entity_config(hass): + """Test that we can configure things via entity config.""" + request = get_new_request('Alexa.Discovery', 'Discover') + + hass.states.async_set( + 'light.test_1', 'on', {'friendly_name': "Test light 1"}) + + config = smart_home.Config( + should_expose=lambda entity_id: True, + entity_config={ + 'light.test_1': { + 'name': 'Config name', + 'display_categories': 'SWITCH', + 'description': 'Config description' + } + } + ) + + msg = yield from smart_home.async_handle_message( + hass, config, request) + + assert 'event' in msg + msg = msg['event'] + + assert len(msg['payload']['endpoints']) == 1 + + appliance = msg['payload']['endpoints'][0] + assert appliance['endpointId'] == 'light#test_1' + assert appliance['displayCategories'][0] == "SWITCH" + assert appliance['friendlyName'] == "Config name" + assert appliance['description'] == "Config description" + assert len(appliance['capabilities']) == 1 + assert appliance['capabilities'][-1]['interface'] == \ + 'Alexa.PowerController' diff --git a/tests/components/cloud/test_iot.py b/tests/components/cloud/test_iot.py index e74d89c744d..d829134eb21 100644 --- a/tests/components/cloud/test_iot.py +++ b/tests/components/cloud/test_iot.py @@ -38,16 +38,6 @@ def mock_cloud(): return MagicMock(subscription_expired=False) -@pytest.fixture -def cloud_instance(loop, hass): - """Instance of an initialized cloud class.""" - with patch('homeassistant.components.cloud.Cloud.initialize', - return_value=mock_coro(True)): - loop.run_until_complete(async_setup_component(hass, 'cloud', {})) - - yield hass.data['cloud'] - - @asyncio.coroutine def test_cloud_calling_handler(mock_client, mock_handle_message, mock_cloud): """Test we call handle message with correct info.""" @@ -269,13 +259,35 @@ def test_refresh_token_before_expiration_fails(hass, mock_cloud): @asyncio.coroutine -def test_handler_alexa(hass, cloud_instance): +def test_handler_alexa(hass): """Test handler Alexa.""" hass.states.async_set( 'switch.test', 'on', {'friendly_name': "Test switch"}) + hass.states.async_set( + 'switch.test2', 'on', {'friendly_name': "Test switch 2"}) + + with patch('homeassistant.components.cloud.Cloud.initialize', + return_value=mock_coro(True)): + setup = yield from async_setup_component(hass, 'cloud', { + 'cloud': { + 'alexa': { + 'filter': { + 'exclude_entities': 'switch.test2' + }, + 'entity_config': { + 'switch.test': { + 'name': 'Config name', + 'description': 'Config description', + 'display_categories': 'LIGHT' + } + } + } + } + }) + assert setup resp = yield from iot.async_handle_alexa( - hass, cloud_instance, + hass, hass.data['cloud'], test_alexa.get_new_request('Alexa.Discovery', 'Discover')) endpoints = resp['event']['payload']['endpoints'] @@ -283,16 +295,32 @@ def test_handler_alexa(hass, cloud_instance): assert len(endpoints) == 1 device = endpoints[0] - assert device['description'] == 'switch.test' - assert device['friendlyName'] == 'Test switch' + assert device['description'] == 'Config description' + assert device['friendlyName'] == 'Config name' + assert device['displayCategories'] == ['LIGHT'] assert device['manufacturerName'] == 'Home Assistant' @asyncio.coroutine -def test_handler_google_actions(hass, cloud_instance): +def test_handler_google_actions(hass): """Test handler Google Actions.""" hass.states.async_set( 'switch.test', 'on', {'friendly_name': "Test switch"}) + hass.states.async_set( + 'switch.test2', 'on', {'friendly_name': "Test switch 2"}) + + with patch('homeassistant.components.cloud.Cloud.initialize', + return_value=mock_coro(True)): + setup = yield from async_setup_component(hass, 'cloud', { + 'cloud': { + 'google_actions': { + 'filter': { + 'exclude_entities': 'switch.test2' + }, + } + } + }) + assert setup reqid = '5711642932632160983' data = {'requestId': reqid, 'inputs': [{'intent': 'action.devices.SYNC'}]} @@ -300,7 +328,7 @@ def test_handler_google_actions(hass, cloud_instance): with patch('homeassistant.components.cloud.Cloud._decode_claims', return_value={'cognito:username': 'myUserName'}): resp = yield from iot.async_handle_google_actions( - hass, cloud_instance, data) + hass, hass.data['cloud'], data) assert resp['requestId'] == reqid payload = resp['payload'] From 455c629f478bb966dd9a8ebae7f7562a4fc83fdf Mon Sep 17 00:00:00 2001 From: Christopher Viel Date: Fri, 5 Jan 2018 17:29:27 -0500 Subject: [PATCH 158/238] Don't duplicate html5 registrations (#11451) * Don't duplicate html5 registrations If a registration is posted and another registration with the same endpoint URL exists, update that one instead. That way, we preserve the device name that has been configured. The previous behavior used to append 'unnamed device' registrations over and over, leading to multiple copies of the same registration. The endpoint URL is unique per service worker so it is safe to update matching registrations. * Refactor html5 registration view to not write json in the event loop --- homeassistant/components/notify/html5.py | 33 +++- tests/components/notify/test_html5.py | 212 +++++++++++++++-------- 2 files changed, 172 insertions(+), 73 deletions(-) diff --git a/homeassistant/components/notify/html5.py b/homeassistant/components/notify/html5.py index 2314722a2ab..a979ab5fb2f 100644 --- a/homeassistant/components/notify/html5.py +++ b/homeassistant/components/notify/html5.py @@ -169,15 +169,35 @@ class HTML5PushRegistrationView(HomeAssistantView): return self.json_message( humanize_error(data, ex), HTTP_BAD_REQUEST) - name = ensure_unique_string('unnamed device', self.registrations) + name = self.find_registration_name(data) + previous_registration = self.registrations.get(name) self.registrations[name] = data - if not save_json(self.json_path, self.registrations): + try: + hass = request.app['hass'] + + yield from hass.async_add_job(save_json, self.json_path, + self.registrations) + return self.json_message( + 'Push notification subscriber registered.') + except HomeAssistantError: + if previous_registration is not None: + self.registrations[name] = previous_registration + else: + self.registrations.pop(name) + return self.json_message( 'Error saving registration.', HTTP_INTERNAL_SERVER_ERROR) - return self.json_message('Push notification subscriber registered.') + def find_registration_name(self, data): + """Find a registration name matching data or generate a unique one.""" + endpoint = data.get(ATTR_SUBSCRIPTION).get(ATTR_ENDPOINT) + for key, registration in self.registrations.items(): + subscription = registration.get(ATTR_SUBSCRIPTION) + if subscription.get(ATTR_ENDPOINT) == endpoint: + return key + return ensure_unique_string('unnamed device', self.registrations) @asyncio.coroutine def delete(self, request): @@ -202,7 +222,12 @@ class HTML5PushRegistrationView(HomeAssistantView): reg = self.registrations.pop(found) - if not save_json(self.json_path, self.registrations): + try: + hass = request.app['hass'] + + yield from hass.async_add_job(save_json, self.json_path, + self.registrations) + except HomeAssistantError: self.registrations[found] = reg return self.json_message( 'Error saving registration.', HTTP_INTERNAL_SERVER_ERROR) diff --git a/tests/components/notify/test_html5.py b/tests/components/notify/test_html5.py index c3998b6db64..6fb2e6454de 100644 --- a/tests/components/notify/test_html5.py +++ b/tests/components/notify/test_html5.py @@ -4,10 +4,14 @@ import json from unittest.mock import patch, MagicMock, mock_open from aiohttp.hdrs import AUTHORIZATION +from homeassistant.exceptions import HomeAssistantError +from homeassistant.util.json import save_json from homeassistant.components.notify import html5 from tests.common import mock_http_component_app +CONFIG_FILE = 'file.conf' + SUBSCRIPTION_1 = { 'browser': 'chrome', 'subscription': { @@ -108,36 +112,30 @@ class TestHtml5Notify(object): 'unnamed device': SUBSCRIPTION_1, } - m = mock_open() - with patch( - 'homeassistant.util.json.open', - m, create=True - ): - hass.config.path.return_value = 'file.conf' - service = html5.get_service(hass, {}) + hass.config.path.return_value = CONFIG_FILE + service = html5.get_service(hass, {}) - assert service is not None + assert service is not None - # assert hass.called - assert len(hass.mock_calls) == 3 + assert len(hass.mock_calls) == 3 - view = hass.mock_calls[1][1][0] - assert view.json_path == hass.config.path.return_value - assert view.registrations == {} + view = hass.mock_calls[1][1][0] + assert view.json_path == hass.config.path.return_value + assert view.registrations == {} - hass.loop = loop - app = mock_http_component_app(hass) - view.register(app.router) - client = yield from test_client(app) - hass.http.is_banned_ip.return_value = False - resp = yield from client.post(REGISTER_URL, - data=json.dumps(SUBSCRIPTION_1)) + hass.loop = loop + app = mock_http_component_app(hass) + view.register(app.router) + client = yield from test_client(app) + hass.http.is_banned_ip.return_value = False + resp = yield from client.post(REGISTER_URL, + data=json.dumps(SUBSCRIPTION_1)) - content = yield from resp.text() - assert resp.status == 200, content - assert view.registrations == expected - handle = m() - assert json.loads(handle.write.call_args[0][0]) == expected + content = yield from resp.text() + assert resp.status == 200, content + assert view.registrations == expected + + hass.async_add_job.assert_called_with(save_json, CONFIG_FILE, expected) @asyncio.coroutine def test_registering_new_device_expiration_view(self, loop, test_client): @@ -147,36 +145,114 @@ class TestHtml5Notify(object): 'unnamed device': SUBSCRIPTION_4, } - m = mock_open() - with patch( - 'homeassistant.util.json.open', - m, create=True - ): - hass.config.path.return_value = 'file.conf' - service = html5.get_service(hass, {}) + hass.config.path.return_value = CONFIG_FILE + service = html5.get_service(hass, {}) - assert service is not None + assert service is not None - # assert hass.called - assert len(hass.mock_calls) == 3 + # assert hass.called + assert len(hass.mock_calls) == 3 - view = hass.mock_calls[1][1][0] - assert view.json_path == hass.config.path.return_value - assert view.registrations == {} + view = hass.mock_calls[1][1][0] + assert view.json_path == hass.config.path.return_value + assert view.registrations == {} - hass.loop = loop - app = mock_http_component_app(hass) - view.register(app.router) - client = yield from test_client(app) - hass.http.is_banned_ip.return_value = False - resp = yield from client.post(REGISTER_URL, - data=json.dumps(SUBSCRIPTION_4)) + hass.loop = loop + app = mock_http_component_app(hass) + view.register(app.router) + client = yield from test_client(app) + hass.http.is_banned_ip.return_value = False + resp = yield from client.post(REGISTER_URL, + data=json.dumps(SUBSCRIPTION_4)) - content = yield from resp.text() - assert resp.status == 200, content - assert view.registrations == expected - handle = m() - assert json.loads(handle.write.call_args[0][0]) == expected + content = yield from resp.text() + assert resp.status == 200, content + assert view.registrations == expected + + hass.async_add_job.assert_called_with(save_json, CONFIG_FILE, expected) + + @asyncio.coroutine + def test_registering_new_device_fails_view(self, loop, test_client): + """Test subs. are not altered when registering a new device fails.""" + hass = MagicMock() + expected = {} + + hass.config.path.return_value = CONFIG_FILE + html5.get_service(hass, {}) + view = hass.mock_calls[1][1][0] + + hass.loop = loop + app = mock_http_component_app(hass) + view.register(app.router) + client = yield from test_client(app) + hass.http.is_banned_ip.return_value = False + + hass.async_add_job.side_effect = HomeAssistantError() + + resp = yield from client.post(REGISTER_URL, + data=json.dumps(SUBSCRIPTION_1)) + + content = yield from resp.text() + assert resp.status == 500, content + assert view.registrations == expected + + @asyncio.coroutine + def test_registering_existing_device_view(self, loop, test_client): + """Test subscription is updated when registering existing device.""" + hass = MagicMock() + expected = { + 'unnamed device': SUBSCRIPTION_4, + } + + hass.config.path.return_value = CONFIG_FILE + html5.get_service(hass, {}) + view = hass.mock_calls[1][1][0] + + hass.loop = loop + app = mock_http_component_app(hass) + view.register(app.router) + client = yield from test_client(app) + hass.http.is_banned_ip.return_value = False + + yield from client.post(REGISTER_URL, + data=json.dumps(SUBSCRIPTION_1)) + resp = yield from client.post(REGISTER_URL, + data=json.dumps(SUBSCRIPTION_4)) + + content = yield from resp.text() + assert resp.status == 200, content + assert view.registrations == expected + + hass.async_add_job.assert_called_with(save_json, CONFIG_FILE, expected) + + @asyncio.coroutine + def test_registering_existing_device_fails_view(self, loop, test_client): + """Test sub. is not updated when registering existing device fails.""" + hass = MagicMock() + expected = { + 'unnamed device': SUBSCRIPTION_1, + } + + hass.config.path.return_value = CONFIG_FILE + html5.get_service(hass, {}) + view = hass.mock_calls[1][1][0] + + hass.loop = loop + app = mock_http_component_app(hass) + view.register(app.router) + client = yield from test_client(app) + hass.http.is_banned_ip.return_value = False + + yield from client.post(REGISTER_URL, + data=json.dumps(SUBSCRIPTION_1)) + + hass.async_add_job.side_effect = HomeAssistantError() + resp = yield from client.post(REGISTER_URL, + data=json.dumps(SUBSCRIPTION_4)) + + content = yield from resp.text() + assert resp.status == 500, content + assert view.registrations == expected @asyncio.coroutine def test_registering_new_device_validation(self, loop, test_client): @@ -188,7 +264,7 @@ class TestHtml5Notify(object): 'homeassistant.util.json.open', m, create=True ): - hass.config.path.return_value = 'file.conf' + hass.config.path.return_value = CONFIG_FILE service = html5.get_service(hass, {}) assert service is not None @@ -240,7 +316,7 @@ class TestHtml5Notify(object): 'homeassistant.util.json.open', m, create=True ): - hass.config.path.return_value = 'file.conf' + hass.config.path.return_value = CONFIG_FILE service = html5.get_service(hass, {}) assert service is not None @@ -266,8 +342,9 @@ class TestHtml5Notify(object): assert resp.status == 200, resp.response assert view.registrations == config - handle = m() - assert json.loads(handle.write.call_args[0][0]) == config + + hass.async_add_job.assert_called_with(save_json, CONFIG_FILE, + config) @asyncio.coroutine def test_unregister_device_view_handle_unknown_subscription( @@ -285,7 +362,7 @@ class TestHtml5Notify(object): 'homeassistant.util.json.open', m, create=True ): - hass.config.path.return_value = 'file.conf' + hass.config.path.return_value = CONFIG_FILE service = html5.get_service(hass, {}) assert service is not None @@ -309,13 +386,13 @@ class TestHtml5Notify(object): assert resp.status == 200, resp.response assert view.registrations == config - handle = m() - assert handle.write.call_count == 0 + + hass.async_add_job.assert_not_called() @asyncio.coroutine - def test_unregistering_device_view_handles_json_safe_error( + def test_unregistering_device_view_handles_save_error( self, loop, test_client): - """Test that the HTML unregister view handles JSON write errors.""" + """Test that the HTML unregister view handles save errors.""" hass = MagicMock() config = { @@ -328,7 +405,7 @@ class TestHtml5Notify(object): 'homeassistant.util.json.open', m, create=True ): - hass.config.path.return_value = 'file.conf' + hass.config.path.return_value = CONFIG_FILE service = html5.get_service(hass, {}) assert service is not None @@ -346,16 +423,13 @@ class TestHtml5Notify(object): client = yield from test_client(app) hass.http.is_banned_ip.return_value = False - with patch('homeassistant.components.notify.html5.save_json', - return_value=False): - resp = yield from client.delete(REGISTER_URL, data=json.dumps({ - 'subscription': SUBSCRIPTION_1['subscription'], - })) + hass.async_add_job.side_effect = HomeAssistantError() + resp = yield from client.delete(REGISTER_URL, data=json.dumps({ + 'subscription': SUBSCRIPTION_1['subscription'], + })) assert resp.status == 500, resp.response assert view.registrations == config - handle = m() - assert handle.write.call_count == 0 @asyncio.coroutine def test_callback_view_no_jwt(self, loop, test_client): @@ -367,7 +441,7 @@ class TestHtml5Notify(object): 'homeassistant.util.json.open', m, create=True ): - hass.config.path.return_value = 'file.conf' + hass.config.path.return_value = CONFIG_FILE service = html5.get_service(hass, {}) assert service is not None @@ -404,7 +478,7 @@ class TestHtml5Notify(object): 'homeassistant.util.json.open', m, create=True ): - hass.config.path.return_value = 'file.conf' + hass.config.path.return_value = CONFIG_FILE service = html5.get_service(hass, {'gcm_sender_id': '100'}) assert service is not None From 417c193c0d742cceb6b5ed704f8f3442aacea04e Mon Sep 17 00:00:00 2001 From: hawk259 Date: Fri, 5 Jan 2018 17:29:57 -0500 Subject: [PATCH 159/238] AlarmDecoder remove icon function as BinarySensorDevice handles it correctly now (#11467) * remove icon function as BinarySensorDevice handles it correctly now * removing _type, not used --- .../components/binary_sensor/alarmdecoder.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/homeassistant/components/binary_sensor/alarmdecoder.py b/homeassistant/components/binary_sensor/alarmdecoder.py index 1b8c8070d10..f0c8ec2d97c 100644 --- a/homeassistant/components/binary_sensor/alarmdecoder.py +++ b/homeassistant/components/binary_sensor/alarmdecoder.py @@ -55,7 +55,6 @@ class AlarmDecoderBinarySensor(BinarySensorDevice): self._zone_type = zone_type self._state = None self._name = zone_name - self._type = zone_type self._rfid = zone_rfid self._rfstate = None @@ -76,17 +75,6 @@ class AlarmDecoderBinarySensor(BinarySensorDevice): """Return the name of the entity.""" return self._name - @property - def icon(self): - """Icon for device by its type.""" - if "window" in self._name.lower(): - return "mdi:window-open" if self.is_on else "mdi:window-closed" - - if self._type == 'smoke': - return "mdi:fire" - - return None - @property def should_poll(self): """No polling needed.""" From f21914d1f37bf5bd8b9f0ced61528306489b2701 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 5 Jan 2018 23:30:12 +0100 Subject: [PATCH 160/238] Upgrade psutil to 5.4.3 (#11468) --- homeassistant/components/sensor/systemmonitor.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/systemmonitor.py b/homeassistant/components/sensor/systemmonitor.py index 8e6f7b404fd..57e03cf153f 100755 --- a/homeassistant/components/sensor/systemmonitor.py +++ b/homeassistant/components/sensor/systemmonitor.py @@ -16,7 +16,7 @@ from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util -REQUIREMENTS = ['psutil==5.4.2'] +REQUIREMENTS = ['psutil==5.4.3'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 0dccf542fd9..b39f83a3a7c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -581,7 +581,7 @@ proliphix==0.4.1 prometheus_client==0.0.21 # homeassistant.components.sensor.systemmonitor -psutil==5.4.2 +psutil==5.4.3 # homeassistant.components.wink pubnubsub-handler==1.0.2 From c7146583841763c3ef86fd605e8d3007b9fea8b3 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sat, 6 Jan 2018 01:05:51 +0100 Subject: [PATCH 161/238] Upgrade alpha_vantage to 1.8.0 (#11476) --- homeassistant/components/sensor/alpha_vantage.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/alpha_vantage.py b/homeassistant/components/sensor/alpha_vantage.py index e56b0c31d2a..90957077c27 100644 --- a/homeassistant/components/sensor/alpha_vantage.py +++ b/homeassistant/components/sensor/alpha_vantage.py @@ -14,7 +14,7 @@ from homeassistant.const import ATTR_ATTRIBUTION, CONF_API_KEY import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['alpha_vantage==1.6.0'] +REQUIREMENTS = ['alpha_vantage==1.8.0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index b39f83a3a7c..6a5c85a7a5d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -84,7 +84,7 @@ aiopvapi==1.5.4 alarmdecoder==1.13.2 # homeassistant.components.sensor.alpha_vantage -alpha_vantage==1.6.0 +alpha_vantage==1.8.0 # homeassistant.components.amcrest amcrest==1.2.1 From ff9688bb7a135d7d50208908d930f1be61add773 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 5 Jan 2018 16:34:03 -0800 Subject: [PATCH 162/238] Fix vultr tests (#11477) --- tests/components/binary_sensor/test_vultr.py | 30 ++++----- tests/components/sensor/test_vultr.py | 27 ++++---- tests/components/switch/test_vultr.py | 68 ++++++++++---------- tests/components/test_vultr.py | 18 +++--- 4 files changed, 73 insertions(+), 70 deletions(-) diff --git a/tests/components/binary_sensor/test_vultr.py b/tests/components/binary_sensor/test_vultr.py index 2bcb220233b..91d5da34901 100644 --- a/tests/components/binary_sensor/test_vultr.py +++ b/tests/components/binary_sensor/test_vultr.py @@ -1,5 +1,8 @@ """Test the Vultr binary sensor platform.""" +import json import unittest +from unittest.mock import patch + import requests_mock import pytest import voluptuous as vol @@ -50,10 +53,6 @@ class TestVultrBinarySensorSetup(unittest.TestCase): """Stop our started services.""" self.hass.stop() - def test_failed_hub(self): - """Test a hub setup failure.""" - base_vultr.setup(self.hass, VALID_CONFIG) - @requests_mock.Mocker() def test_binary_sensor(self, mock): """Test successful instance.""" @@ -61,12 +60,12 @@ class TestVultrBinarySensorSetup(unittest.TestCase): 'https://api.vultr.com/v1/account/info?api_key=ABCDEFG1234567', text=load_fixture('vultr_account_info.json')) - mock.get( - 'https://api.vultr.com/v1/server/list?api_key=ABCDEFG1234567', - text=load_fixture('vultr_server_list.json')) - - # Setup hub - base_vultr.setup(self.hass, VALID_CONFIG) + with patch( + 'vultr.Vultr.server_list', + return_value=json.loads( + load_fixture('vultr_server_list.json'))): + # Setup hub + base_vultr.setup(self.hass, VALID_CONFIG) # Setup each of our test configs for config in self.configs: @@ -137,11 +136,12 @@ class TestVultrBinarySensorSetup(unittest.TestCase): 'https://api.vultr.com/v1/account/info?api_key=ABCDEFG1234567', text=load_fixture('vultr_account_info.json')) - mock.get( - 'https://api.vultr.com/v1/server/list?api_key=ABCDEFG1234567', - text=load_fixture('vultr_server_list.json')) - - base_vultr.setup(self.hass, VALID_CONFIG) + with patch( + 'vultr.Vultr.server_list', + return_value=json.loads( + load_fixture('vultr_server_list.json'))): + # Setup hub + base_vultr.setup(self.hass, VALID_CONFIG) bad_conf = {} # No subscription diff --git a/tests/components/sensor/test_vultr.py b/tests/components/sensor/test_vultr.py index a4e5edc5800..c5222ab5543 100644 --- a/tests/components/sensor/test_vultr.py +++ b/tests/components/sensor/test_vultr.py @@ -1,6 +1,9 @@ """The tests for the Vultr sensor platform.""" -import pytest +import json import unittest +from unittest.mock import patch + +import pytest import requests_mock import voluptuous as vol @@ -59,11 +62,12 @@ class TestVultrSensorSetup(unittest.TestCase): 'https://api.vultr.com/v1/account/info?api_key=ABCDEFG1234567', text=load_fixture('vultr_account_info.json')) - mock.get( - 'https://api.vultr.com/v1/server/list?api_key=ABCDEFG1234567', - text=load_fixture('vultr_server_list.json')) - - base_vultr.setup(self.hass, VALID_CONFIG) + with patch( + 'vultr.Vultr.server_list', + return_value=json.loads( + load_fixture('vultr_server_list.json'))): + # Setup hub + base_vultr.setup(self.hass, VALID_CONFIG) for config in self.configs: setup = vultr.setup_platform(self.hass, @@ -146,11 +150,12 @@ class TestVultrSensorSetup(unittest.TestCase): 'https://api.vultr.com/v1/account/info?api_key=ABCDEFG1234567', text=load_fixture('vultr_account_info.json')) - mock.get( - 'https://api.vultr.com/v1/server/list?api_key=ABCDEFG1234567', - text=load_fixture('vultr_server_list.json')) - - base_vultr.setup(self.hass, VALID_CONFIG) + with patch( + 'vultr.Vultr.server_list', + return_value=json.loads( + load_fixture('vultr_server_list.json'))): + # Setup hub + base_vultr.setup(self.hass, VALID_CONFIG) bad_conf = { CONF_MONITORED_CONDITIONS: vultr.MONITORED_CONDITIONS, diff --git a/tests/components/switch/test_vultr.py b/tests/components/switch/test_vultr.py index 53bf6fbec85..222a044a523 100644 --- a/tests/components/switch/test_vultr.py +++ b/tests/components/switch/test_vultr.py @@ -1,5 +1,8 @@ """Test the Vultr switch platform.""" +import json import unittest +from unittest.mock import patch + import requests_mock import pytest import voluptuous as vol @@ -57,12 +60,12 @@ class TestVultrSwitchSetup(unittest.TestCase): 'https://api.vultr.com/v1/account/info?api_key=ABCDEFG1234567', text=load_fixture('vultr_account_info.json')) - mock.get( - 'https://api.vultr.com/v1/server/list?api_key=ABCDEFG1234567', - text=load_fixture('vultr_server_list.json')) - - # Setup hub - base_vultr.setup(self.hass, VALID_CONFIG) + with patch( + 'vultr.Vultr.server_list', + return_value=json.loads( + load_fixture('vultr_server_list.json'))): + # Setup hub + base_vultr.setup(self.hass, VALID_CONFIG) # Setup each of our test configs for config in self.configs: @@ -128,36 +131,30 @@ class TestVultrSwitchSetup(unittest.TestCase): @requests_mock.Mocker() def test_turn_on(self, mock): """Test turning a subscription on.""" - mock.get( - 'https://api.vultr.com/v1/server/list?api_key=ABCDEFG1234567', - text=load_fixture('vultr_server_list.json')) + with patch( + 'vultr.Vultr.server_list', + return_value=json.loads(load_fixture('vultr_server_list.json'))), \ + patch('vultr.Vultr.server_start') as mock_start: + for device in self.DEVICES: + if device.name == 'Failed Server': + device.turn_on() - mock.post( - 'https://api.vultr.com/v1/server/start?api_key=ABCDEFG1234567') - - for device in self.DEVICES: - if device.name == 'Failed Server': - device.turn_on() - - # Turn on, force date update - self.assertEqual(2, mock.call_count) + # Turn on + self.assertEqual(1, mock_start.call_count) @requests_mock.Mocker() def test_turn_off(self, mock): """Test turning a subscription off.""" - mock.get( - 'https://api.vultr.com/v1/server/list?api_key=ABCDEFG1234567', - text=load_fixture('vultr_server_list.json')) + with patch( + 'vultr.Vultr.server_list', + return_value=json.loads(load_fixture('vultr_server_list.json'))), \ + patch('vultr.Vultr.server_halt') as mock_halt: + for device in self.DEVICES: + if device.name == 'A Server': + device.turn_off() - mock.post( - 'https://api.vultr.com/v1/server/halt?api_key=ABCDEFG1234567') - - for device in self.DEVICES: - if device.name == 'A Server': - device.turn_off() - - # Turn off, force update - self.assertEqual(2, mock.call_count) + # Turn off + self.assertEqual(1, mock_halt.call_count) def test_invalid_switch_config(self): """Test config type failures.""" @@ -173,11 +170,12 @@ class TestVultrSwitchSetup(unittest.TestCase): 'https://api.vultr.com/v1/account/info?api_key=ABCDEFG1234567', text=load_fixture('vultr_account_info.json')) - mock.get( - 'https://api.vultr.com/v1/server/list?api_key=ABCDEFG1234567', - text=load_fixture('vultr_server_list.json')) - - base_vultr.setup(self.hass, VALID_CONFIG) + with patch( + 'vultr.Vultr.server_list', + return_value=json.loads( + load_fixture('vultr_server_list.json'))): + # Setup hub + base_vultr.setup(self.hass, VALID_CONFIG) bad_conf = {} # No subscription diff --git a/tests/components/test_vultr.py b/tests/components/test_vultr.py index b504c320dc8..725768f938b 100644 --- a/tests/components/test_vultr.py +++ b/tests/components/test_vultr.py @@ -1,8 +1,11 @@ """The tests for the Vultr component.""" +from copy import deepcopy +import json import unittest +from unittest.mock import patch + import requests_mock -from copy import deepcopy from homeassistant import setup import homeassistant.components.vultr as vultr @@ -31,14 +34,11 @@ class TestVultr(unittest.TestCase): @requests_mock.Mocker() def test_setup(self, mock): """Test successful setup.""" - mock.get( - 'https://api.vultr.com/v1/account/info?api_key=ABCDEFG1234567', - text=load_fixture('vultr_account_info.json')) - mock.get( - 'https://api.vultr.com/v1/server/list?api_key=ABCDEFG1234567', - text=load_fixture('vultr_server_list.json')) - - response = vultr.setup(self.hass, self.config) + with patch( + 'vultr.Vultr.server_list', + return_value=json.loads( + load_fixture('vultr_server_list.json'))): + response = vultr.setup(self.hass, self.config) self.assertTrue(response) def test_setup_no_api_key(self): From f6307a15236a85af54e6ce29d1ef929edae9ae18 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sat, 6 Jan 2018 09:42:09 +0100 Subject: [PATCH 163/238] Upgrade yarl to 0.17.0 (#11478) --- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index d6fd579ae18..8ec1c648c35 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -6,7 +6,7 @@ jinja2>=2.9.6 voluptuous==0.10.5 typing>=3,<4 aiohttp==2.3.7 -yarl==0.16.0 +yarl==0.17.0 async_timeout==2.0.0 chardet==3.0.4 astral==1.4 diff --git a/requirements_all.txt b/requirements_all.txt index 6a5c85a7a5d..2548977186e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -7,7 +7,7 @@ jinja2>=2.9.6 voluptuous==0.10.5 typing>=3,<4 aiohttp==2.3.7 -yarl==0.16.0 +yarl==0.17.0 async_timeout==2.0.0 chardet==3.0.4 astral==1.4 diff --git a/setup.py b/setup.py index 56396c598ef..0d7c746d564 100755 --- a/setup.py +++ b/setup.py @@ -54,7 +54,7 @@ REQUIRES = [ 'voluptuous==0.10.5', 'typing>=3,<4', 'aiohttp==2.3.7', # If updated, check if yarl also needs an update! - 'yarl==0.16.0', + 'yarl==0.17.0', 'async_timeout==2.0.0', 'chardet==3.0.4', 'astral==1.4', From a0df165011868becda39e51b62671f0448550e60 Mon Sep 17 00:00:00 2001 From: abondoe Date: Sat, 6 Jan 2018 11:23:24 +0100 Subject: [PATCH 164/238] Add Touchline climate platform (#10547) * Add toucline platform * Fix bugs suggested by houndci-bot * Fix bugs suggested by houndci-bot * Fix style based on comments and lint * Remove target temperature * Fix unexpected EOF * Fix unexpected EOF * Fix wrongfully named numberOfDevices variable * Add target temperature * Update requirements_all.txt * Change after review * Add supported features * Remove update in constructor * Set member variables to none in constructor * Minor changes --- .coveragerc | 1 + homeassistant/components/climate/touchline.py | 90 +++++++++++++++++++ requirements_all.txt | 3 + 3 files changed, 94 insertions(+) create mode 100644 homeassistant/components/climate/touchline.py diff --git a/.coveragerc b/.coveragerc index 70a0597e6b7..93af851a736 100644 --- a/.coveragerc +++ b/.coveragerc @@ -314,6 +314,7 @@ omit = homeassistant/components/climate/proliphix.py homeassistant/components/climate/radiotherm.py homeassistant/components/climate/sensibo.py + homeassistant/components/climate/touchline.py homeassistant/components/cover/garadget.py homeassistant/components/cover/homematic.py homeassistant/components/cover/knx.py diff --git a/homeassistant/components/climate/touchline.py b/homeassistant/components/climate/touchline.py new file mode 100644 index 00000000000..cc45e26a1cf --- /dev/null +++ b/homeassistant/components/climate/touchline.py @@ -0,0 +1,90 @@ +""" +Platform for Roth Touchline heat pump controller. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/climate.touchline/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.climate import ( + ClimateDevice, PLATFORM_SCHEMA, SUPPORT_TARGET_TEMPERATURE) +from homeassistant.const import CONF_HOST, TEMP_CELSIUS, ATTR_TEMPERATURE +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['pytouchline==0.6'] + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Touchline devices.""" + from pytouchline import PyTouchline + host = config[CONF_HOST] + py_touchline = PyTouchline() + number_of_devices = int(py_touchline.get_number_of_devices(host)) + devices = [] + for device_id in range(0, number_of_devices): + devices.append(Touchline(PyTouchline(device_id))) + add_devices(devices, True) + + +class Touchline(ClimateDevice): + """Representation of a Touchline device.""" + + def __init__(self, touchline_thermostat): + """Initialize the climate device.""" + self.unit = touchline_thermostat + self._name = None + self._current_temperature = None + self._target_temperature = None + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS + + def update(self): + """Update unit attributes.""" + self.unit.update() + self._name = self.unit.get_name() + self._current_temperature = self.unit.get_current_temperature() + self._target_temperature = self.unit.get_target_temperature() + + @property + def should_poll(self): + """Return the polling state.""" + return True + + @property + def name(self): + """Return the name of the climate device.""" + return self._name + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._current_temperature + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._target_temperature + + def set_temperature(self, **kwargs): + """Set new target temperature.""" + if kwargs.get(ATTR_TEMPERATURE) is not None: + self._target_temperature = kwargs.get(ATTR_TEMPERATURE) + self.unit.set_target_temperature(self._target_temperature) diff --git a/requirements_all.txt b/requirements_all.txt index 2548977186e..1c90d0ef813 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -946,6 +946,9 @@ pythonwhois==2.4.3 # homeassistant.components.device_tracker.tile pytile==1.1.0 +# homeassistant.components.climate.touchline +pytouchline==0.6 + # homeassistant.components.device_tracker.trackr pytrackr==0.0.5 From b22a26891efc62ba15df23c018bb55689dd6bd43 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sat, 6 Jan 2018 19:54:15 +0100 Subject: [PATCH 165/238] Upgrade pysnmp to 4.4.4 (#11485) --- homeassistant/components/device_tracker/snmp.py | 2 +- homeassistant/components/sensor/snmp.py | 2 +- homeassistant/components/switch/snmp.py | 2 +- requirements_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/device_tracker/snmp.py b/homeassistant/components/device_tracker/snmp.py index 49dfc81112f..c9c27fb2bfa 100644 --- a/homeassistant/components/device_tracker/snmp.py +++ b/homeassistant/components/device_tracker/snmp.py @@ -14,7 +14,7 @@ from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import CONF_HOST -REQUIREMENTS = ['pysnmp==4.4.3'] +REQUIREMENTS = ['pysnmp==4.4.4'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/snmp.py b/homeassistant/components/sensor/snmp.py index b2318564d16..95bf207acf8 100644 --- a/homeassistant/components/sensor/snmp.py +++ b/homeassistant/components/sensor/snmp.py @@ -16,7 +16,7 @@ from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_PORT, CONF_UNIT_OF_MEASUREMENT, STATE_UNKNOWN, CONF_VALUE_TEMPLATE) -REQUIREMENTS = ['pysnmp==4.4.3'] +REQUIREMENTS = ['pysnmp==4.4.4'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/switch/snmp.py b/homeassistant/components/switch/snmp.py index f2d536c5961..115e31cb733 100644 --- a/homeassistant/components/switch/snmp.py +++ b/homeassistant/components/switch/snmp.py @@ -13,7 +13,7 @@ from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_PORT, CONF_PAYLOAD_ON, CONF_PAYLOAD_OFF) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pysnmp==4.4.3'] +REQUIREMENTS = ['pysnmp==4.4.4'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 1c90d0ef813..739d2a3cb24 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -833,7 +833,7 @@ pysma==0.1.3 # homeassistant.components.device_tracker.snmp # homeassistant.components.sensor.snmp # homeassistant.components.switch.snmp -pysnmp==4.4.3 +pysnmp==4.4.4 # homeassistant.components.media_player.liveboxplaytv pyteleloisirs==3.3 From 3fbf09e7d91620bf26200fbe1b13f308905441d0 Mon Sep 17 00:00:00 2001 From: Jesse Hills Date: Sun, 7 Jan 2018 09:52:31 +1300 Subject: [PATCH 166/238] Add new iGlo component (#11171) * Add new iGlo component * Missing comma Add extra blank lines * Dont change state in turn_on Remove unused variables Update before add * Fixing some lint issues --- .coveragerc | 1 + homeassistant/components/light/iglo.py | 124 +++++++++++++++++++++++++ requirements_all.txt | 3 + 3 files changed, 128 insertions(+) create mode 100644 homeassistant/components/light/iglo.py diff --git a/.coveragerc b/.coveragerc index 93af851a736..c81186c3165 100644 --- a/.coveragerc +++ b/.coveragerc @@ -376,6 +376,7 @@ omit = homeassistant/components/light/greenwave.py homeassistant/components/light/hue.py homeassistant/components/light/hyperion.py + homeassistant/components/light/iglo.py homeassistant/components/light/lifx.py homeassistant/components/light/lifx_legacy.py homeassistant/components/light/limitlessled.py diff --git a/homeassistant/components/light/iglo.py b/homeassistant/components/light/iglo.py new file mode 100644 index 00000000000..eaf783b13ca --- /dev/null +++ b/homeassistant/components/light/iglo.py @@ -0,0 +1,124 @@ +""" +Support for lights under the iGlo brand. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.iglo/ +""" +import logging + +import voluptuous as vol + +from homeassistant.const import (CONF_HOST, CONF_NAME, CONF_PORT) +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, ATTR_RGB_COLOR, ATTR_COLOR_TEMP, + SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_RGB_COLOR, + Light, PLATFORM_SCHEMA +) + +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['iglo==1.0.0'] + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'iGlo Light' +DEFAULT_PORT = 8080 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.string, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the iGlo lighs.""" + host = config.get(CONF_HOST) + name = config.get(CONF_NAME) + port = config.get(CONF_PORT) + add_devices([IGloLamp(name, host, port)], True) + + +class IGloLamp(Light): + """Representation of an iGlo light.""" + + def __init__(self, name, host, port): + """Initialize the light.""" + from iglo import Lamp + self._name = name + self._lamp = Lamp(0, host, port) + self._on = True + self._brightness = 255 + self._rgb = (0, 0, 0) + self._color_temp = 0 + + @property + def name(self): + """Return the name of the light.""" + return self._name + + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + return int((self._brightness / 200.0) * 255) + + @property + def color_temp(self): + """Return the color temperature.""" + return self._color_temp + + @property + def min_mireds(self): + """Return the coldest color_temp that this light supports.""" + return 1 + + @property + def max_mireds(self): + """Return the warmest color_temp that this light supports.""" + return 255 + + @property + def rgb_color(self): + """Return the RGB value.""" + return self._rgb + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_RGB_COLOR + + @property + def is_on(self): + """Return true if light is on.""" + return self._on + + def turn_on(self, **kwargs): + """Turn the light on.""" + if not self._on: + self._lamp.switch(True) + if ATTR_BRIGHTNESS in kwargs: + brightness = int((kwargs[ATTR_BRIGHTNESS] / 255.0) * 200.0) + self._lamp.brightness(brightness) + return + + if ATTR_RGB_COLOR in kwargs: + rgb = kwargs[ATTR_RGB_COLOR] + self._lamp.rgb(*rgb) + return + + if ATTR_COLOR_TEMP in kwargs: + color_temp = 255 - kwargs[ATTR_COLOR_TEMP] + self._lamp.white(color_temp) + return + + def turn_off(self, **kwargs): + """Turn the light off.""" + self._lamp.switch(False) + + def update(self): + """Update light status.""" + state = self._lamp.state() + self._on = state['on'] + self._brightness = state['brightness'] + self._rgb = state['rgb'] + self._color_temp = 255 - state['white'] diff --git a/requirements_all.txt b/requirements_all.txt index 739d2a3cb24..748f8f21d28 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -392,6 +392,9 @@ https://github.com/wokar/pylgnetcast/archive/v0.2.0.zip#pylgnetcast==0.2.0 # homeassistant.components.sensor.htu21d # i2csense==0.0.4 +# homeassistant.components.light.iglo +iglo==1.0.0 + # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb influxdb==4.1.1 From c613585100b3f016ae3b77295979fa6706b54456 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sat, 6 Jan 2018 21:53:14 +0100 Subject: [PATCH 167/238] Add missing configuration variables (#11390) * Add missing configuration variables * Minor changes * Sync platforms and other minor updates --- homeassistant/components/sensor/metoffice.py | 89 +++++++++++-------- homeassistant/components/weather/metoffice.py | 48 +++++----- 2 files changed, 76 insertions(+), 61 deletions(-) diff --git a/homeassistant/components/sensor/metoffice.py b/homeassistant/components/sensor/metoffice.py index 25516eda5b1..43290d21e11 100644 --- a/homeassistant/components/sensor/metoffice.py +++ b/homeassistant/components/sensor/metoffice.py @@ -4,23 +4,28 @@ Support for UK Met Office weather service. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.metoffice/ """ -import logging from datetime import timedelta +import logging import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_MONITORED_CONDITIONS, TEMP_CELSIUS, STATE_UNKNOWN, CONF_NAME, - ATTR_ATTRIBUTION, CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE) + ATTR_ATTRIBUTION, CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, + CONF_MONITORED_CONDITIONS, CONF_NAME, TEMP_CELSIUS) +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) REQUIREMENTS = ['datapoint==0.4.3'] +ATTR_LAST_UPDATE = 'last_update' +ATTR_SENSOR_ID = 'sensor_id' +ATTR_SITE_ID = 'site_id' +ATTR_SITE_NAME = 'site_name' + CONF_ATTRIBUTION = "Data provided by the Met Office" CONDITION_CLASSES = { @@ -40,6 +45,8 @@ CONDITION_CLASSES = { 'exceptional': [], } +DEFAULT_NAME = "Met Office" + VISIBILTY_CLASSES = { 'VP': '<1', 'PO': '1-4', @@ -49,7 +56,7 @@ VISIBILTY_CLASSES = { 'EX': '>40' } -SCAN_INTERVAL = timedelta(minutes=35) +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=35) # Sensor types are defined like: Name, units SENSOR_TYPES = { @@ -68,77 +75,83 @@ SENSOR_TYPES = { } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_NAME, default=None): cv.string, vol.Required(CONF_API_KEY): cv.string, vol.Required(CONF_MONITORED_CONDITIONS, default=[]): vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Inclusive(CONF_LATITUDE, 'coordinates', + 'Latitude and longitude must exist together'): cv.latitude, + vol.Inclusive(CONF_LONGITUDE, 'coordinates', + 'Latitude and longitude must exist together'): cv.longitude, }) def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the Metoffice sensor platform.""" + """Set up the Met Office sensor platform.""" import datapoint as dp - datapoint = dp.connection(api_key=config.get(CONF_API_KEY)) + api_key = config.get(CONF_API_KEY) latitude = config.get(CONF_LATITUDE, hass.config.latitude) longitude = config.get(CONF_LONGITUDE, hass.config.longitude) + name = config.get(CONF_NAME) + + datapoint = dp.connection(api_key=api_key) if None in (latitude, longitude): _LOGGER.error("Latitude or longitude not set in Home Assistant config") - return False + return try: - site = datapoint.get_nearest_site(latitude=latitude, - longitude=longitude) + site = datapoint.get_nearest_site( + latitude=latitude, longitude=longitude) except dp.exceptions.APIException as err: _LOGGER.error("Received error from Met Office Datapoint: %s", err) - return False + return if not site: _LOGGER.error("Unable to get nearest Met Office forecast site") - return False + return - # Get data data = MetOfficeCurrentData(hass, datapoint, site) - try: - data.update() - except (ValueError, dp.exceptions.APIException) as err: - _LOGGER.error("Received error from Met Office Datapoint: %s", err) - return False + data.update() + if data.data is None: + return - # Add - add_devices([MetOfficeCurrentSensor(site, data, variable) - for variable in config[CONF_MONITORED_CONDITIONS]]) - return True + sensors = [] + for variable in config[CONF_MONITORED_CONDITIONS]: + sensors.append(MetOfficeCurrentSensor(site, data, variable, name)) + + add_devices(sensors, True) class MetOfficeCurrentSensor(Entity): """Implementation of a Met Office current sensor.""" - def __init__(self, site, data, condition): + def __init__(self, site, data, condition, name): """Initialize the sensor.""" - self.site = site - self.data = data self._condition = condition + self.data = data + self._name = name + self.site = site @property def name(self): """Return the name of the sensor.""" - return 'Met Office {}'.format(SENSOR_TYPES[self._condition][0]) + return '{} {}'.format(self._name, SENSOR_TYPES[self._condition][0]) @property def state(self): """Return the state of the sensor.""" if (self._condition == 'visibility_distance' and - 'visibility' in self.data.data.__dict__.keys()): + hasattr(self.data.data, 'visibility')): return VISIBILTY_CLASSES.get(self.data.data.visibility.value) - if self._condition in self.data.data.__dict__.keys(): + if hasattr(self.data.data, self._condition): variable = getattr(self.data.data, self._condition) - if self._condition == "weather": + if self._condition == 'weather': return [k for k, v in CONDITION_CLASSES.items() if self.data.data.weather.value in v][0] return variable.value - return STATE_UNKNOWN + return None @property def unit_of_measurement(self): @@ -149,11 +162,11 @@ class MetOfficeCurrentSensor(Entity): def device_state_attributes(self): """Return the state attributes of the device.""" attr = {} - attr['Sensor Id'] = self._condition - attr['Site Id'] = self.site.id - attr['Site Name'] = self.site.name - attr['Last Update'] = self.data.data.date attr[ATTR_ATTRIBUTION] = CONF_ATTRIBUTION + attr[ATTR_LAST_UPDATE] = self.data.data.date + attr[ATTR_SENSOR_ID] = self._condition + attr[ATTR_SITE_ID] = self.site.id + attr[ATTR_SITE_NAME] = self.site.name return attr def update(self): @@ -166,21 +179,19 @@ class MetOfficeCurrentData(object): def __init__(self, hass, datapoint, site): """Initialize the data object.""" - self._hass = hass self._datapoint = datapoint self._site = site self.data = None - @Throttle(SCAN_INTERVAL) + @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Get the latest data from Datapoint.""" import datapoint as dp try: forecast = self._datapoint.get_forecast_for_site( - self._site.id, "3hourly") + self._site.id, '3hourly') self.data = forecast.now() except (ValueError, dp.exceptions.APIException) as err: _LOGGER.error("Check Met Office %s", err.args) self.data = None - raise diff --git a/homeassistant/components/weather/metoffice.py b/homeassistant/components/weather/metoffice.py index 50bbb84faa7..d43d1d3c996 100644 --- a/homeassistant/components/weather/metoffice.py +++ b/homeassistant/components/weather/metoffice.py @@ -8,27 +8,34 @@ import logging import voluptuous as vol -from homeassistant.components.weather import WeatherEntity, PLATFORM_SCHEMA +from homeassistant.components.sensor.metoffice import ( + CONDITION_CLASSES, CONF_ATTRIBUTION, MetOfficeCurrentData) +from homeassistant.components.weather import PLATFORM_SCHEMA, WeatherEntity from homeassistant.const import ( - CONF_NAME, TEMP_CELSIUS, CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE) + CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, TEMP_CELSIUS) from homeassistant.helpers import config_validation as cv -# Reuse data and API logic from the sensor implementation -from homeassistant.components.sensor.metoffice import \ - MetOfficeCurrentData, CONF_ATTRIBUTION, CONDITION_CLASSES - -_LOGGER = logging.getLogger(__name__) REQUIREMENTS = ['datapoint==0.4.3'] +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = "Met Office" + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_NAME): cv.string, vol.Required(CONF_API_KEY): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Inclusive(CONF_LATITUDE, 'coordinates', + 'Latitude and longitude must exist together'): cv.latitude, + vol.Inclusive(CONF_LONGITUDE, 'coordinates', + 'Latitude and longitude must exist together'): cv.longitude, }) def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Met Office weather platform.""" import datapoint as dp + + name = config.get(CONF_NAME) datapoint = dp.connection(api_key=config.get(CONF_API_KEY)) latitude = config.get(CONF_LATITUDE, hass.config.latitude) @@ -36,36 +43,35 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if None in (latitude, longitude): _LOGGER.error("Latitude or longitude not set in Home Assistant config") - return False + return try: - site = datapoint.get_nearest_site(latitude=latitude, - longitude=longitude) + site = datapoint.get_nearest_site( + latitude=latitude, longitude=longitude) except dp.exceptions.APIException as err: _LOGGER.error("Received error from Met Office Datapoint: %s", err) - return False + return if not site: _LOGGER.error("Unable to get nearest Met Office forecast site") - return False + return - # Get data data = MetOfficeCurrentData(hass, datapoint, site) try: data.update() except (ValueError, dp.exceptions.APIException) as err: _LOGGER.error("Received error from Met Office Datapoint: %s", err) - return False - add_devices([MetOfficeWeather(site, data, config.get(CONF_NAME))], - True) - return True + return + + add_devices([MetOfficeWeather(site, data, name)], True) class MetOfficeWeather(WeatherEntity): """Implementation of a Met Office weather condition.""" - def __init__(self, site, data, config): + def __init__(self, site, data, name): """Initialise the platform with a data instance and site.""" + self._name = name self.data = data self.site = site @@ -76,7 +82,7 @@ class MetOfficeWeather(WeatherEntity): @property def name(self): """Return the name of the sensor.""" - return 'Met Office ({})'.format(self.site.name) + return '{} {}'.format(self._name, self.site.name) @property def condition(self): @@ -84,8 +90,6 @@ class MetOfficeWeather(WeatherEntity): return [k for k, v in CONDITION_CLASSES.items() if self.data.data.weather.value in v][0] - # Now implement the WeatherEntity interface - @property def temperature(self): """Return the platform temperature.""" From 5c2cecde706a3ca82740ceec7f552e2f27d88556 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 6 Jan 2018 16:39:32 -0800 Subject: [PATCH 168/238] Clean up Alexa.intent and DialogFlow.intent (#11492) * Clean up Alexa.intent * Restructure dialogflow too * Lint * Lint --- homeassistant/components/alexa/intent.py | 160 +++++++++++++++-------- homeassistant/components/dialogflow.py | 104 +++++++++------ homeassistant/helpers/intent.py | 8 +- 3 files changed, 169 insertions(+), 103 deletions(-) diff --git a/homeassistant/components/alexa/intent.py b/homeassistant/components/alexa/intent.py index 3ade199aabb..8283b563591 100644 --- a/homeassistant/components/alexa/intent.py +++ b/homeassistant/components/alexa/intent.py @@ -9,15 +9,16 @@ import asyncio import enum import logging +from homeassistant.exceptions import HomeAssistantError from homeassistant.core import callback -from homeassistant.const import HTTP_BAD_REQUEST from homeassistant.helpers import intent from homeassistant.components import http +from homeassistant.util.decorator import Registry from .const import DOMAIN, SYN_RESOLUTION_MATCH INTENTS_API_ENDPOINT = '/api/alexa' - +HANDLERS = Registry() _LOGGER = logging.getLogger(__name__) @@ -47,6 +48,10 @@ def async_setup(hass): hass.http.register_view(AlexaIntentsView) +class UnknownRequest(HomeAssistantError): + """When an unknown Alexa request is passed in.""" + + class AlexaIntentsView(http.HomeAssistantView): """Handle Alexa requests.""" @@ -57,71 +62,112 @@ class AlexaIntentsView(http.HomeAssistantView): def post(self, request): """Handle Alexa.""" hass = request.app['hass'] - data = yield from request.json() + message = yield from request.json() - _LOGGER.debug('Received Alexa request: %s', data) - - req = data.get('request') - - if req is None: - _LOGGER.error('Received invalid data from Alexa: %s', data) - return self.json_message('Expected request value not received', - HTTP_BAD_REQUEST) - - req_type = req['type'] - - if req_type == 'SessionEndedRequest': - return None - - alexa_intent_info = req.get('intent') - alexa_response = AlexaResponse(hass, alexa_intent_info) - - if req_type != 'IntentRequest' and req_type != 'LaunchRequest': - _LOGGER.warning('Received unsupported request: %s', req_type) - return self.json_message( - 'Received unsupported request: {}'.format(req_type), - HTTP_BAD_REQUEST) - - if req_type == 'LaunchRequest': - intent_name = data.get('session', {}) \ - .get('application', {}) \ - .get('applicationId') - else: - intent_name = alexa_intent_info['name'] + _LOGGER.debug('Received Alexa request: %s', message) try: - intent_response = yield from intent.async_handle( - hass, DOMAIN, intent_name, - {key: {'value': value} for key, value - in alexa_response.variables.items()}) + response = yield from async_handle_message(hass, message) + return b'' if response is None else self.json(response) + except UnknownRequest as err: + _LOGGER.warning(str(err)) + return self.json(intent_error_response( + hass, message, str(err))) + except intent.UnknownIntent as err: - _LOGGER.warning('Received unknown intent %s', intent_name) - alexa_response.add_speech( - SpeechType.plaintext, - "This intent is not yet configured within Home Assistant.") - return self.json(alexa_response) + _LOGGER.warning(str(err)) + return self.json(intent_error_response( + hass, message, + "This intent is not yet configured within Home Assistant.")) except intent.InvalidSlotInfo as err: _LOGGER.error('Received invalid slot data from Alexa: %s', err) - return self.json_message('Invalid slot data received', - HTTP_BAD_REQUEST) - except intent.IntentError: - _LOGGER.exception('Error handling request for %s', intent_name) - return self.json_message('Error handling intent', HTTP_BAD_REQUEST) + return self.json(intent_error_response( + hass, message, + "Invalid slot information received for this intent.")) - for intent_speech, alexa_speech in SPEECH_MAPPINGS.items(): - if intent_speech in intent_response.speech: - alexa_response.add_speech( - alexa_speech, - intent_response.speech[intent_speech]['speech']) - break + except intent.IntentError as err: + _LOGGER.exception(str(err)) + return self.json(intent_error_response( + hass, message, "Error handling intent.")) - if 'simple' in intent_response.card: - alexa_response.add_card( - CardType.simple, intent_response.card['simple']['title'], - intent_response.card['simple']['content']) - return self.json(alexa_response) +def intent_error_response(hass, message, error): + """Return an Alexa response that will speak the error message.""" + alexa_intent_info = message.get('request').get('intent') + alexa_response = AlexaResponse(hass, alexa_intent_info) + alexa_response.add_speech(SpeechType.plaintext, error) + return alexa_response.as_dict() + + +@asyncio.coroutine +def async_handle_message(hass, message): + """Handle an Alexa intent. + + Raises: + - UnknownRequest + - intent.UnknownIntent + - intent.InvalidSlotInfo + - intent.IntentError + """ + req = message.get('request') + req_type = req['type'] + + handler = HANDLERS.get(req_type) + + if not handler: + raise UnknownRequest('Received unknown request {}'.format(req_type)) + + return (yield from handler(hass, message)) + + +@HANDLERS.register('SessionEndedRequest') +@asyncio.coroutine +def async_handle_session_end(hass, message): + """Handle a session end request.""" + return None + + +@HANDLERS.register('IntentRequest') +@HANDLERS.register('LaunchRequest') +@asyncio.coroutine +def async_handle_intent(hass, message): + """Handle an intent request. + + Raises: + - intent.UnknownIntent + - intent.InvalidSlotInfo + - intent.IntentError + """ + req = message.get('request') + alexa_intent_info = req.get('intent') + alexa_response = AlexaResponse(hass, alexa_intent_info) + + if req['type'] == 'LaunchRequest': + intent_name = message.get('session', {}) \ + .get('application', {}) \ + .get('applicationId') + else: + intent_name = alexa_intent_info['name'] + + intent_response = yield from intent.async_handle( + hass, DOMAIN, intent_name, + {key: {'value': value} for key, value + in alexa_response.variables.items()}) + + for intent_speech, alexa_speech in SPEECH_MAPPINGS.items(): + if intent_speech in intent_response.speech: + alexa_response.add_speech( + alexa_speech, + intent_response.speech[intent_speech]['speech']) + break + + if 'simple' in intent_response.card: + alexa_response.add_card( + CardType.simple, intent_response.card['simple']['title'], + intent_response.card['simple']['content']) + + return alexa_response.as_dict() def resolve_slot_synonyms(key, request): diff --git a/homeassistant/components/dialogflow.py b/homeassistant/components/dialogflow.py index 726b8d99e01..63205c5479c 100644 --- a/homeassistant/components/dialogflow.py +++ b/homeassistant/components/dialogflow.py @@ -9,7 +9,7 @@ import logging import voluptuous as vol -from homeassistant.const import HTTP_BAD_REQUEST +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import intent, template from homeassistant.components.http import HomeAssistantView @@ -33,6 +33,10 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) +class DialogFlowError(HomeAssistantError): + """Raised when a DialogFlow error happens.""" + + @asyncio.coroutine def async_setup(hass, config): """Set up Dialogflow component.""" @@ -51,57 +55,71 @@ class DialogflowIntentsView(HomeAssistantView): def post(self, request): """Handle Dialogflow.""" hass = request.app['hass'] - data = yield from request.json() + message = yield from request.json() - _LOGGER.debug("Received Dialogflow request: %s", data) - - req = data.get('result') - - if req is None: - _LOGGER.error("Received invalid data from Dialogflow: %s", data) - return self.json_message( - "Expected result value not received", HTTP_BAD_REQUEST) - - action_incomplete = req['actionIncomplete'] - - if action_incomplete: - return None - - action = req.get('action') - parameters = req.get('parameters') - dialogflow_response = DialogflowResponse(parameters) - - if action == "": - _LOGGER.warning("Received intent with empty action") - dialogflow_response.add_speech( - "You have not defined an action in your Dialogflow intent.") - return self.json(dialogflow_response) + _LOGGER.debug("Received Dialogflow request: %s", message) try: - intent_response = yield from intent.async_handle( - hass, DOMAIN, action, - {key: {'value': value} for key, value - in parameters.items()}) + response = yield from async_handle_message(hass, message) + return b'' if response is None else self.json(response) + + except DialogFlowError as err: + _LOGGER.warning(str(err)) + return self.json(dialogflow_error_response( + hass, message, str(err))) except intent.UnknownIntent as err: - _LOGGER.warning("Received unknown intent %s", action) - dialogflow_response.add_speech( - "This intent is not yet configured within Home Assistant.") - return self.json(dialogflow_response) + _LOGGER.warning(str(err)) + return self.json(dialogflow_error_response( + hass, message, + "This intent is not yet configured within Home Assistant.")) except intent.InvalidSlotInfo as err: - _LOGGER.error("Received invalid slot data: %s", err) - return self.json_message('Invalid slot data received', - HTTP_BAD_REQUEST) - except intent.IntentError: - _LOGGER.exception("Error handling request for %s", action) - return self.json_message('Error handling intent', HTTP_BAD_REQUEST) + _LOGGER.warning(str(err)) + return self.json(dialogflow_error_response( + hass, message, + "Invalid slot information received for this intent.")) - if 'plain' in intent_response.speech: - dialogflow_response.add_speech( - intent_response.speech['plain']['speech']) + except intent.IntentError as err: + _LOGGER.warning(str(err)) + return self.json(dialogflow_error_response( + hass, message, "Error handling intent.")) - return self.json(dialogflow_response) + +def dialogflow_error_response(hass, message, error): + """Return a response saying the error message.""" + dialogflow_response = DialogflowResponse(message['result']['parameters']) + dialogflow_response.add_speech(error) + return dialogflow_response.as_dict() + + +@asyncio.coroutine +def async_handle_message(hass, message): + """Handle a DialogFlow message.""" + req = message.get('result') + action_incomplete = req['actionIncomplete'] + + if action_incomplete: + return None + + action = req.get('action', '') + parameters = req.get('parameters') + dialogflow_response = DialogflowResponse(parameters) + + if action == "": + raise DialogFlowError( + "You have not defined an action in your Dialogflow intent.") + + intent_response = yield from intent.async_handle( + hass, DOMAIN, action, + {key: {'value': value} for key, value + in parameters.items()}) + + if 'plain' in intent_response.speech: + dialogflow_response.add_speech( + intent_response.speech['plain']['speech']) + + return dialogflow_response.as_dict() class DialogflowResponse(object): diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index c5aad3ababc..6268b3cb9f7 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -41,7 +41,7 @@ def async_handle(hass, platform, intent_type, slots=None, text_input=None): handler = hass.data.get(DATA_KEY, {}).get(intent_type) if handler is None: - raise UnknownIntent() + raise UnknownIntent('Unknown intent {}'.format(intent_type)) intent = Intent(hass, platform, intent_type, slots or {}, text_input) @@ -50,9 +50,11 @@ def async_handle(hass, platform, intent_type, slots=None, text_input=None): result = yield from handler.async_handle(intent) return result except vol.Invalid as err: - raise InvalidSlotInfo from err + raise InvalidSlotInfo( + 'Received invalid slot info for {}'.format(intent_type)) from err except Exception as err: - raise IntentHandleError from err + raise IntentHandleError( + 'Error handling {}'.format(intent_type)) from err class IntentError(HomeAssistantError): From 939d1b5ff6a2649956d1186a087fa5752357916a Mon Sep 17 00:00:00 2001 From: Tom Waters Date: Sun, 7 Jan 2018 00:50:55 +0000 Subject: [PATCH 169/238] Fix time functions would throw errors in python scripts (#11414) * Fix time functions would throw errors in python scripts * Added unit test for time.strptime, change variable name to satisfy lint * Added docstring for time attribute wrapper method to satisfy lint * Fixed line too long lint problem --- homeassistant/components/python_script.py | 9 ++++++++- tests/components/test_python_script.py | 3 +++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/python_script.py b/homeassistant/components/python_script.py index 85f12a18afd..a56b40f3064 100644 --- a/homeassistant/components/python_script.py +++ b/homeassistant/components/python_script.py @@ -202,4 +202,11 @@ class TimeWrapper: def __getattr__(self, attr): """Fetch an attribute from Time module.""" - return getattr(time, attr) + attribute = getattr(time, attr) + if callable(attribute): + def wrapper(*args, **kw): + """Wrapper to return callable method if callable.""" + return attribute(*args, **kw) + return wrapper + else: + return attribute diff --git a/tests/components/test_python_script.py b/tests/components/test_python_script.py index 8a7f94d7dcd..c0b7df158c5 100644 --- a/tests/components/test_python_script.py +++ b/tests/components/test_python_script.py @@ -236,6 +236,8 @@ def test_exposed_modules(hass, caplog): caplog.set_level(logging.ERROR) source = """ hass.states.set('module.time', time.strftime('%Y', time.gmtime(521276400))) +hass.states.set('module.time_strptime', + time.strftime('%H:%M', time.strptime('12:34', '%H:%M'))) hass.states.set('module.datetime', datetime.timedelta(minutes=1).total_seconds()) """ @@ -244,6 +246,7 @@ hass.states.set('module.datetime', yield from hass.async_block_till_done() assert hass.states.is_state('module.time', '1986') + assert hass.states.is_state('module.time_strptime', '12:34') assert hass.states.is_state('module.datetime', '60.0') # No errors logged = good From e42c4859c2e02b26cd36278316f77798bea6613b Mon Sep 17 00:00:00 2001 From: Christopher Viel Date: Sun, 7 Jan 2018 00:08:11 -0500 Subject: [PATCH 170/238] Upgrade pywebpush to 1.5.0 (#11497) This version includes a fix for the serialization errors that occurred when updating push subscriptions. Changes since version 1.3.0: https://github.com/web-push-libs/pywebpush/compare/1.3.0...28d2b55f --- homeassistant/components/notify/html5.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/notify/html5.py b/homeassistant/components/notify/html5.py index a979ab5fb2f..f2611cf65d3 100644 --- a/homeassistant/components/notify/html5.py +++ b/homeassistant/components/notify/html5.py @@ -27,7 +27,7 @@ from homeassistant.const import ( from homeassistant.helpers import config_validation as cv from homeassistant.util import ensure_unique_string -REQUIREMENTS = ['pywebpush==1.3.0', 'PyJWT==1.5.3'] +REQUIREMENTS = ['pywebpush==1.5.0', 'PyJWT==1.5.3'] DEPENDENCIES = ['frontend'] diff --git a/requirements_all.txt b/requirements_all.txt index 748f8f21d28..c3d1861289c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -974,7 +974,7 @@ pyvizio==0.0.2 pyvlx==0.1.3 # homeassistant.components.notify.html5 -pywebpush==1.3.0 +pywebpush==1.5.0 # homeassistant.components.wemo pywemo==0.4.25 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 081523e709f..f243ccb2b54 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -143,7 +143,7 @@ pythonwhois==2.4.3 pyunifi==2.13 # homeassistant.components.notify.html5 -pywebpush==1.3.0 +pywebpush==1.5.0 # homeassistant.components.python_script restrictedpython==4.0b2 From 4496ee5af009285853e1446b9cb04c2aa514204c Mon Sep 17 00:00:00 2001 From: Julian Kahnert Date: Sun, 7 Jan 2018 14:57:26 +0100 Subject: [PATCH 171/238] upgrade schiene to 0.20 (#11504) --- homeassistant/components/sensor/deutsche_bahn.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/deutsche_bahn.py b/homeassistant/components/sensor/deutsche_bahn.py index e07730b53e8..c13fc930ed1 100644 --- a/homeassistant/components/sensor/deutsche_bahn.py +++ b/homeassistant/components/sensor/deutsche_bahn.py @@ -14,7 +14,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity import homeassistant.util.dt as dt_util -REQUIREMENTS = ['schiene==0.19'] +REQUIREMENTS = ['schiene==0.20'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index c3d1861289c..38cff1464da 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1034,7 +1034,7 @@ samsungctl==0.6.0 satel_integra==0.1.0 # homeassistant.components.sensor.deutsche_bahn -schiene==0.19 +schiene==0.20 # homeassistant.components.scsgate scsgate==0.1.0 From c20324793e7e59cc7b6c353fe573860cd818be54 Mon Sep 17 00:00:00 2001 From: Julio Guerra Date: Sun, 7 Jan 2018 22:37:19 +0100 Subject: [PATCH 172/238] timer: include the remaining time in the state attributes (#11510) Add the amount of remaining time before a timer is finished in its state attributes, so that it is received when fetching a timer state. In my particular case, it allows me to correctly implement a UI for a timer when it is paused and when the UI didn't received the pause state change (which happens when the UI is started after the pause). In this case, it is impossible to show the remaining amount of time before the timer ends. --- homeassistant/components/timer/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py index b2f5db88b5f..ec3429e0498 100644 --- a/homeassistant/components/timer/__init__.py +++ b/homeassistant/components/timer/__init__.py @@ -29,6 +29,7 @@ ENTITY_ID_FORMAT = DOMAIN + '.{}' DEFAULT_DURATION = 0 ATTR_DURATION = 'duration' +ATTR_REMAINING = 'remaining' CONF_DURATION = 'duration' STATUS_IDLE = 'idle' @@ -227,6 +228,7 @@ class Timer(Entity): """Return the state attributes.""" return { ATTR_DURATION: str(self._duration), + ATTR_REMAINING: str(self._remaining) } @asyncio.coroutine From efb83dde19f061ac217745737446dc3a396fc9ec Mon Sep 17 00:00:00 2001 From: Julius Mittenzwei Date: Sun, 7 Jan 2018 22:39:14 +0100 Subject: [PATCH 173/238] More tolerant KNX component if gateway cant be connected (#11511) * Issue #11432: Do not stop initializing KNX when tunelling device cant be reached * Issue #11432: Mark devices as unavailable if gateway cant be connected --- homeassistant/components/binary_sensor/knx.py | 5 +++++ homeassistant/components/climate/knx.py | 5 +++++ homeassistant/components/cover/knx.py | 5 +++++ homeassistant/components/knx.py | 12 ++++++++---- homeassistant/components/light/knx.py | 5 +++++ homeassistant/components/sensor/knx.py | 5 +++++ homeassistant/components/switch/knx.py | 5 +++++ 7 files changed, 38 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/binary_sensor/knx.py b/homeassistant/components/binary_sensor/knx.py index 406f60f99bb..9e5ddf5cac4 100644 --- a/homeassistant/components/binary_sensor/knx.py +++ b/homeassistant/components/binary_sensor/knx.py @@ -129,6 +129,11 @@ class KNXBinarySensor(BinarySensorDevice): """Return the name of the KNX device.""" return self.device.name + @property + def available(self): + """Return True if entity is available.""" + return self.hass.data[DATA_KNX].connected + @property def should_poll(self): """No polling needed within KNX.""" diff --git a/homeassistant/components/climate/knx.py b/homeassistant/components/climate/knx.py index fb0de1e2de0..97bd3e9503c 100644 --- a/homeassistant/components/climate/knx.py +++ b/homeassistant/components/climate/knx.py @@ -159,6 +159,11 @@ class KNXClimate(ClimateDevice): """Return the name of the KNX device.""" return self.device.name + @property + def available(self): + """Return True if entity is available.""" + return self.hass.data[DATA_KNX].connected + @property def should_poll(self): """No polling needed within KNX.""" diff --git a/homeassistant/components/cover/knx.py b/homeassistant/components/cover/knx.py index b840c780645..d8313caeb5f 100644 --- a/homeassistant/components/cover/knx.py +++ b/homeassistant/components/cover/knx.py @@ -124,6 +124,11 @@ class KNXCover(CoverDevice): """Return the name of the KNX device.""" return self.device.name + @property + def available(self): + """Return True if entity is available.""" + return self.hass.data[DATA_KNX].connected + @property def should_poll(self): """No polling needed within KNX.""" diff --git a/homeassistant/components/knx.py b/homeassistant/components/knx.py index 3966b490f52..f9747351bdd 100644 --- a/homeassistant/components/knx.py +++ b/homeassistant/components/knx.py @@ -80,8 +80,11 @@ def async_setup(hass, config): yield from hass.data[DATA_KNX].start() except XKNXException as ex: - _LOGGER.exception("Can't connect to KNX interface: %s", ex) - return False + _LOGGER.warning("Can't connect to KNX interface: %s", ex) + hass.components.persistent_notification.async_create( + "Can't connect to KNX interface:
" + "{0}".format(ex), + title="KNX") for component, discovery_type in ( ('switch', 'Switch'), @@ -120,7 +123,8 @@ class KNXModule(object): """Initialization of KNXModule.""" self.hass = hass self.config = config - self.initialized = False + self.connected = False + self.initialized = True self.init_xknx() self.register_callbacks() @@ -139,7 +143,7 @@ class KNXModule(object): state_updater=self.config[DOMAIN][CONF_KNX_STATE_UPDATER], connection_config=connection_config) self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.stop) - self.initialized = True + self.connected = True @asyncio.coroutine def stop(self, event): diff --git a/homeassistant/components/light/knx.py b/homeassistant/components/light/knx.py index 3688cafdd25..c1caf91db45 100644 --- a/homeassistant/components/light/knx.py +++ b/homeassistant/components/light/knx.py @@ -97,6 +97,11 @@ class KNXLight(Light): """Return the name of the KNX device.""" return self.device.name + @property + def available(self): + """Return True if entity is available.""" + return self.hass.data[DATA_KNX].connected + @property def should_poll(self): """No polling needed within KNX.""" diff --git a/homeassistant/components/sensor/knx.py b/homeassistant/components/sensor/knx.py index 7abc986bdd7..f803f406e1e 100644 --- a/homeassistant/components/sensor/knx.py +++ b/homeassistant/components/sensor/knx.py @@ -90,6 +90,11 @@ class KNXSensor(Entity): """Return the name of the KNX device.""" return self.device.name + @property + def available(self): + """Return True if entity is available.""" + return self.hass.data[DATA_KNX].connected + @property def should_poll(self): """No polling needed within KNX.""" diff --git a/homeassistant/components/switch/knx.py b/homeassistant/components/switch/knx.py index b340bf5f43a..d1c6d717945 100644 --- a/homeassistant/components/switch/knx.py +++ b/homeassistant/components/switch/knx.py @@ -89,6 +89,11 @@ class KNXSwitch(SwitchDevice): """Return the name of the KNX device.""" return self.device.name + @property + def available(self): + """Return True if entity is available.""" + return self.hass.data[DATA_KNX].connected + @property def should_poll(self): """No polling needed within KNX.""" From 4a6b5ba02ba6356eea13ea5a143e10d42fd00db2 Mon Sep 17 00:00:00 2001 From: tschmidty69 Date: Sun, 7 Jan 2018 16:42:08 -0500 Subject: [PATCH 174/238] Snips (new) added speech response, parse snips/duration (#11513) * Snips changed to using :intentName instead of user_IDSTRING__intentName * added response to snips * blank line * added unittests * houndy * sdtuff * more stuff * Update test_snips.py * Update snips.py * Split log tests to avoid dict ordering in py34 * Update test_snips.py * Update test_snips.py * still broken * fixed tests * fixed tests * removed fix submitted in another PR --- homeassistant/components/snips.py | 43 ++++++- tests/components/test_snips.py | 187 ++++++++++++++++++++++++++---- 2 files changed, 202 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/snips.py b/homeassistant/components/snips.py index a302f25bd00..a430c53bbc7 100644 --- a/homeassistant/components/snips.py +++ b/homeassistant/components/snips.py @@ -7,8 +7,10 @@ https://home-assistant.io/components/snips/ import asyncio import json import logging +from datetime import timedelta import voluptuous as vol from homeassistant.helpers import intent, config_validation as cv +import homeassistant.components.mqtt as mqtt DOMAIN = 'snips' DEPENDENCIES = ['mqtt'] @@ -59,21 +61,52 @@ def async_setup(hass, config): _LOGGER.error('Intent has invalid schema: %s. %s', err, request) return + snips_response = None + intent_type = request['intent']['intentName'].split('__')[-1] slots = {} for slot in request.get('slots', []): - if 'value' in slot['value']: - slots[slot['slotName']] = {'value': slot['value']['value']} - else: - slots[slot['slotName']] = {'value': slot['rawValue']} + slots[slot['slotName']] = {'value': resolve_slot_values(slot)} try: - yield from intent.async_handle( + intent_response = yield from intent.async_handle( hass, DOMAIN, intent_type, slots, request['input']) + if 'plain' in intent_response.speech: + snips_response = intent_response.speech['plain']['speech'] + except intent.UnknownIntent as err: + _LOGGER.warning("Received unknown intent %s", + request['intent']['intentName']) + snips_response = "Unknown Intent" except intent.IntentError: _LOGGER.exception("Error while handling intent: %s.", intent_type) + snips_response = "Error while handling intent" + + notification = {'sessionId': request.get('sessionId', 'default'), + 'text': snips_response} + + _LOGGER.debug("send_response %s", json.dumps(notification)) + mqtt.async_publish(hass, 'hermes/dialogueManager/endSession', + json.dumps(notification)) yield from hass.components.mqtt.async_subscribe( INTENT_TOPIC, message_received) return True + + +def resolve_slot_values(slot): + """Convert snips builtin types to useable values.""" + if 'value' in slot['value']: + value = slot['value']['value'] + else: + value = slot['rawValue'] + + if slot.get('entity') == "snips/duration": + delta = timedelta(weeks=slot['value']['weeks'], + days=slot['value']['days'], + hours=slot['value']['hours'], + minutes=slot['value']['minutes'], + seconds=slot['value']['seconds']) + value = delta.seconds + + return value diff --git a/tests/components/test_snips.py b/tests/components/test_snips.py index a3e6fac0295..b554d4785ad 100644 --- a/tests/components/test_snips.py +++ b/tests/components/test_snips.py @@ -1,41 +1,42 @@ """Test the Snips component.""" import asyncio +import json +from homeassistant.core import callback from homeassistant.bootstrap import async_setup_component from tests.common import async_fire_mqtt_message, async_mock_intent -EXAMPLE_MSG = """ -{ - "input": "turn the lights green", - "intent": { - "intentName": "Lights", - "probability": 1 - }, - "slots": [ - { - "slotName": "light_color", - "value": { - "kind": "Custom", - "value": "green" - } - } - ] -} -""" - @asyncio.coroutine -def test_snips_call_action(hass, mqtt_mock): - """Test calling action via Snips.""" +def test_snips_intent(hass, mqtt_mock): + """Test intent via Snips.""" result = yield from async_setup_component(hass, "snips", { "snips": {}, }) assert result + payload = """ + { + "input": "turn the lights green", + "intent": { + "intentName": "Lights", + "probability": 1 + }, + "slots": [ + { + "slotName": "light_color", + "value": { + "kind": "Custom", + "value": "green" + } + } + ] + } + """ intents = async_mock_intent(hass, 'Lights') - async_fire_mqtt_message(hass, 'hermes/intent/activateLights', - EXAMPLE_MSG) + async_fire_mqtt_message(hass, 'hermes/intent/Lights', + payload) yield from hass.async_block_till_done() assert len(intents) == 1 intent = intents[0] @@ -43,3 +44,143 @@ def test_snips_call_action(hass, mqtt_mock): assert intent.intent_type == 'Lights' assert intent.slots == {'light_color': {'value': 'green'}} assert intent.text_input == 'turn the lights green' + + +@asyncio.coroutine +def test_snips_intent_with_snips_duration(hass, mqtt_mock): + """Test intent with Snips duration.""" + result = yield from async_setup_component(hass, "snips", { + "snips": {}, + }) + assert result + payload = """ + { + "input": "set a timer of five minutes", + "intent": { + "intentName": "SetTimer" + }, + "slots": [ + { + "rawValue": "five minutes", + "value": { + "kind": "Duration", + "years": 0, + "quarters": 0, + "months": 0, + "weeks": 0, + "days": 0, + "hours": 0, + "minutes": 5, + "seconds": 0, + "precision": "Exact" + }, + "range": { + "start": 15, + "end": 27 + }, + "entity": "snips/duration", + "slotName": "timer_duration" + } + ] + } + """ + intents = async_mock_intent(hass, 'SetTimer') + + async_fire_mqtt_message(hass, 'hermes/intent/SetTimer', + payload) + yield from hass.async_block_till_done() + assert len(intents) == 1 + intent = intents[0] + assert intent.platform == 'snips' + assert intent.intent_type == 'SetTimer' + assert intent.slots == {'timer_duration': {'value': 300}} + + +@asyncio.coroutine +def test_intent_speech_response(hass, mqtt_mock): + """Test intent speech response via Snips.""" + event = 'call_service' + events = [] + + @callback + def record_event(event): + """Add recorded event to set.""" + events.append(event) + + result = yield from async_setup_component(hass, "snips", { + "snips": {}, + }) + assert result + result = yield from async_setup_component(hass, "intent_script", { + "intent_script": { + "spokenIntent": { + "speech": { + "type": "plain", + "text": "I am speaking to you" + } + } + } + }) + assert result + payload = """ + { + "input": "speak to me", + "sessionId": "abcdef0123456789", + "intent": { + "intentName": "spokenIntent" + }, + "slots": [] + } + """ + hass.bus.async_listen(event, record_event) + async_fire_mqtt_message(hass, 'hermes/intent/spokenIntent', + payload) + yield from hass.async_block_till_done() + + assert len(events) == 1 + assert events[0].data['domain'] == 'mqtt' + assert events[0].data['service'] == 'publish' + payload = json.loads(events[0].data['service_data']['payload']) + topic = events[0].data['service_data']['topic'] + assert payload['sessionId'] == 'abcdef0123456789' + assert payload['text'] == 'I am speaking to you' + assert topic == 'hermes/dialogueManager/endSession' + + +@asyncio.coroutine +def test_snips_unknown_intent(hass, mqtt_mock): + """Test calling unknown Intent via Snips.""" + event = 'call_service' + events = [] + + @callback + def record_event(event): + """Add recorded event to set.""" + events.append(event) + result = yield from async_setup_component(hass, "snips", { + "snips": {}, + }) + assert result + payload = """ + { + "input": "what to do", + "intent": { + "intentName": "unknownIntent" + }, + "slots": [] + } + """ + intents = async_mock_intent(hass, 'knownIntent') + hass.bus.async_listen(event, record_event) + async_fire_mqtt_message(hass, 'hermes/intent/unknownIntent', + payload) + yield from hass.async_block_till_done() + + assert len(intents) == 0 + assert len(events) == 1 + assert events[0].data['domain'] == 'mqtt' + assert events[0].data['service'] == 'publish' + payload = json.loads(events[0].data['service_data']['payload']) + topic = events[0].data['service_data']['topic'] + assert payload['text'] == 'Unknown Intent' + assert topic == 'hermes/dialogueManager/endSession' From 3cbd77f6ac7f0eb70285d44bc9138e608c35a530 Mon Sep 17 00:00:00 2001 From: Dan Nixon Date: Sun, 7 Jan 2018 21:59:32 +0000 Subject: [PATCH 175/238] Add Dark Sky weather component (#11435) --- .coveragerc | 1 + homeassistant/components/weather/darksky.py | 189 ++++++++++++++++++++ requirements_all.txt | 1 + requirements_test_all.txt | 1 + tests/components/weather/test_darksky.py | 51 ++++++ 5 files changed, 243 insertions(+) create mode 100644 homeassistant/components/weather/darksky.py create mode 100644 tests/components/weather/test_darksky.py diff --git a/.coveragerc b/.coveragerc index c81186c3165..4ab2bb25637 100644 --- a/.coveragerc +++ b/.coveragerc @@ -660,6 +660,7 @@ omit = homeassistant/components/vacuum/xiaomi_miio.py homeassistant/components/weather/bom.py homeassistant/components/weather/buienradar.py + homeassistant/components/weather/darksky.py homeassistant/components/weather/metoffice.py homeassistant/components/weather/openweathermap.py homeassistant/components/weather/yweather.py diff --git a/homeassistant/components/weather/darksky.py b/homeassistant/components/weather/darksky.py new file mode 100644 index 00000000000..4c7512969f6 --- /dev/null +++ b/homeassistant/components/weather/darksky.py @@ -0,0 +1,189 @@ +""" +Patform for retrieving meteorological data from Dark Sky. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/ +""" +from datetime import datetime, timedelta +import logging + +from requests.exceptions import ( + ConnectionError as ConnectError, HTTPError, Timeout) +import voluptuous as vol + +from homeassistant.components.weather import ( + ATTR_FORECAST_TEMP, ATTR_FORECAST_TIME, PLATFORM_SCHEMA, WeatherEntity) +from homeassistant.const import ( + CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, TEMP_CELSIUS, + TEMP_FAHRENHEIT) +import homeassistant.helpers.config_validation as cv +from homeassistant.util import Throttle + +REQUIREMENTS = ['python-forecastio==1.3.5'] + +_LOGGER = logging.getLogger(__name__) + +ATTRIBUTION = "Powered by Dark Sky" + +ATTR_DAILY_FORECAST_SUMMARY = 'daily_forecast_summary' +ATTR_HOURLY_FORECAST_SUMMARY = 'hourly_forecast_summary' + +CONF_UNITS = 'units' + +DEFAULT_NAME = 'Dark Sky' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): cv.string, + vol.Optional(CONF_LATITUDE): cv.latitude, + vol.Optional(CONF_LONGITUDE): cv.longitude, + vol.Optional(CONF_UNITS): vol.In(['auto', 'si', 'us', 'ca', 'uk', 'uk2']), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=3) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Dark Sky weather.""" + latitude = config.get(CONF_LATITUDE, hass.config.latitude) + longitude = config.get(CONF_LONGITUDE, hass.config.longitude) + name = config.get(CONF_NAME) + + units = config.get(CONF_UNITS) + if not units: + units = 'si' if hass.config.units.is_metric else 'us' + + dark_sky = DarkSkyData( + config.get(CONF_API_KEY), latitude, longitude, units) + + add_devices([DarkSkyWeather(name, dark_sky)], True) + + +class DarkSkyWeather(WeatherEntity): + """Representation of a weather condition.""" + + def __init__(self, name, dark_sky): + """Initialize Dark Sky weather.""" + self._name = name + self._dark_sky = dark_sky + + self._ds_data = None + self._ds_currently = None + self._ds_hourly = None + self._ds_daily = None + + @property + def attribution(self): + """Return the attribution.""" + return ATTRIBUTION + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def temperature(self): + """Return the temperature.""" + return self._ds_currently.get('temperature') + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_FAHRENHEIT if 'us' in self._dark_sky.units \ + else TEMP_CELSIUS + + @property + def humidity(self): + """Return the humidity.""" + return self._ds_currently.get('humidity') * 100.0 + + @property + def wind_speed(self): + """Return the wind speed.""" + return self._ds_currently.get('windSpeed') + + @property + def pressure(self): + """Return the pressure.""" + return self._ds_currently.get('pressure') + + @property + def condition(self): + """Return the weather condition.""" + return self._ds_currently.get('summary') + + @property + def forecast(self): + """Return the forecast array.""" + return [{ + ATTR_FORECAST_TIME: + datetime.fromtimestamp(entry.d.get('time')).isoformat(), + ATTR_FORECAST_TEMP: entry.d.get('temperature')} + for entry in self._ds_hourly.data] + + @property + def hourly_forecast_summary(self): + """Return a summary of the hourly forecast.""" + return self._ds_hourly.summary + + @property + def daily_forecast_summary(self): + """Return a summary of the daily forecast.""" + return self._ds_daily.summary + + @property + def state_attributes(self): + """Return the state attributes.""" + attrs = super().state_attributes + attrs.update({ + ATTR_DAILY_FORECAST_SUMMARY: self.daily_forecast_summary, + ATTR_HOURLY_FORECAST_SUMMARY: self.hourly_forecast_summary + }) + return attrs + + def update(self): + """Get the latest data from Dark Sky.""" + self._dark_sky.update() + + self._ds_data = self._dark_sky.data + self._ds_currently = self._dark_sky.currently.d + self._ds_hourly = self._dark_sky.hourly + self._ds_daily = self._dark_sky.daily + + +class DarkSkyData(object): + """Get the latest data from Dark Sky.""" + + def __init__(self, api_key, latitude, longitude, units): + """Initialize the data object.""" + self._api_key = api_key + self.latitude = latitude + self.longitude = longitude + self.requested_units = units + + self.data = None + self.currently = None + self.hourly = None + self.daily = None + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data from Dark Sky.""" + import forecastio + + try: + self.data = forecastio.load_forecast( + self._api_key, self.latitude, self.longitude, + units=self.requested_units) + self.currently = self.data.currently() + self.hourly = self.data.hourly() + self.daily = self.data.daily() + except (ConnectError, HTTPError, Timeout, ValueError) as error: + _LOGGER.error("Unable to connect to Dark Sky. %s", error) + self.data = None + + @property + def units(self): + """Get the unit system of returned data.""" + return self.data.json.get('flags').get('units') diff --git a/requirements_all.txt b/requirements_all.txt index 38cff1464da..bc37b9de96e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -864,6 +864,7 @@ python-ecobee-api==0.0.14 python-etherscan-api==0.0.1 # homeassistant.components.sensor.darksky +# homeassistant.components.weather.darksky python-forecastio==1.3.5 # homeassistant.components.gc100 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f243ccb2b54..4ab838211a5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -134,6 +134,7 @@ pymonoprice==0.3 pynx584==0.4 # homeassistant.components.sensor.darksky +# homeassistant.components.weather.darksky python-forecastio==1.3.5 # homeassistant.components.sensor.whois diff --git a/tests/components/weather/test_darksky.py b/tests/components/weather/test_darksky.py new file mode 100644 index 00000000000..7faa033e0a8 --- /dev/null +++ b/tests/components/weather/test_darksky.py @@ -0,0 +1,51 @@ +"""The tests for the Dark Sky weather component.""" +import re +import unittest +from unittest.mock import patch + +import forecastio +import requests_mock + +from homeassistant.components import weather +from homeassistant.util.unit_system import METRIC_SYSTEM +from homeassistant.setup import setup_component + +from tests.common import load_fixture, get_test_home_assistant + + +class TestDarkSky(unittest.TestCase): + """Test the Dark Sky weather component.""" + + def setUp(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.hass.config.units = METRIC_SYSTEM + self.lat = self.hass.config.latitude = 37.8267 + self.lon = self.hass.config.longitude = -122.423 + + def tearDown(self): + """Stop down everything that was started.""" + self.hass.stop() + + @requests_mock.Mocker() + @patch('forecastio.api.get_forecast', wraps=forecastio.api.get_forecast) + def test_setup(self, mock_req, mock_get_forecast): + """Test for successfully setting up the forecast.io platform.""" + uri = (r'https://api.(darksky.net|forecast.io)\/forecast\/(\w+)\/' + r'(-?\d+\.?\d*),(-?\d+\.?\d*)') + mock_req.get(re.compile(uri), + text=load_fixture('darksky.json')) + + self.assertTrue(setup_component(self.hass, weather.DOMAIN, { + 'weather': { + 'name': 'test', + 'platform': 'darksky', + 'api_key': 'foo', + } + })) + + self.assertTrue(mock_get_forecast.called) + self.assertEqual(mock_get_forecast.call_count, 1) + + state = self.hass.states.get('weather.test') + self.assertEqual(state.state, 'Clear') From 8267a21bfe74520c7c8e0a8e94114476106261d9 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Sun, 7 Jan 2018 23:54:16 +0100 Subject: [PATCH 176/238] Lazy loading of service descriptions (#11479) * Lazy loading of service descriptions * Fix tests * Load YAML in executor * Return a copy of available services to allow mutations * Remove lint * Add zha/services.yaml * Only cache descriptions for known services * Remove lint * Remove description loading during service registration * Remove description parameter from async_register * Test async_get_all_descriptions * Remove lint * Fix typos from multi-edit * Remove unused arguments * Remove unused import os * Remove unused import os, part 2 * Remove unneeded coroutine decorator * Only use executor for loading files * Cleanups suggested in review * Increase test coverage * Fix races in existing tests --- homeassistant/components/__init__.py | 27 ++----- homeassistant/components/abode.py | 8 -- homeassistant/components/ads/__init__.py | 7 -- .../alarm_control_panel/__init__.py | 8 +- .../alarm_control_panel/alarmdecoder.py | 6 -- .../alarm_control_panel/envisalink.py | 9 +-- homeassistant/components/alert.py | 14 +--- homeassistant/components/api.py | 10 ++- homeassistant/components/apple_tv.py | 8 -- .../components/automation/__init__.py | 15 +--- homeassistant/components/axis.py | 7 -- homeassistant/components/calendar/todoist.py | 7 -- homeassistant/components/camera/__init__.py | 11 +-- homeassistant/components/climate/__init__.py | 18 +---- homeassistant/components/climate/ecobee.py | 7 -- homeassistant/components/climate/econet.py | 7 -- homeassistant/components/climate/nuheat.py | 6 -- homeassistant/components/counter/__init__.py | 16 +--- homeassistant/components/cover/__init__.py | 8 +- homeassistant/components/deconz/__init__.py | 8 +- .../components/device_tracker/__init__.py | 8 +- homeassistant/components/eight_sleep.py | 7 -- homeassistant/components/fan/__init__.py | 10 +-- homeassistant/components/fan/dyson.py | 6 -- homeassistant/components/fan/xiaomi_miio.py | 9 +-- homeassistant/components/ffmpeg.py | 11 +-- homeassistant/components/foursquare.py | 6 -- homeassistant/components/frontend/__init__.py | 16 +--- homeassistant/components/google.py | 7 +- .../components/google_assistant/__init__.py | 10 +-- homeassistant/components/group/__init__.py | 15 +--- homeassistant/components/hdmi_cec.py | 11 +-- .../components/homematic/__init__.py | 13 +-- homeassistant/components/hue.py | 4 - .../components/image_processing/__init__.py | 8 +- homeassistant/components/input_boolean.py | 10 --- homeassistant/components/input_number.py | 9 +-- homeassistant/components/input_select.py | 11 --- homeassistant/components/input_text.py | 7 -- homeassistant/components/light/__init__.py | 11 +-- homeassistant/components/light/lifx.py | 17 +--- homeassistant/components/lock/__init__.py | 10 +-- homeassistant/components/lock/nuki.py | 9 +-- homeassistant/components/lock/wink.py | 11 --- homeassistant/components/lock/zwave.py | 10 +-- homeassistant/components/logger.py | 7 -- homeassistant/components/media_extractor.py | 11 +-- .../components/media_player/__init__.py | 8 +- homeassistant/components/media_player/kodi.py | 8 +- .../components/media_player/monoprice.py | 9 +-- .../components/media_player/snapcast.py | 8 +- .../components/media_player/sonos.py | 19 ++--- .../components/media_player/soundtouch.py | 9 --- homeassistant/components/microsoft_face.py | 12 --- homeassistant/components/modbus.py | 7 -- homeassistant/components/mqtt/__init__.py | 7 +- homeassistant/components/notify/__init__.py | 9 +-- homeassistant/components/notify/apns.py | 5 +- .../persistent_notification/__init__.py | 9 --- homeassistant/components/recorder/__init__.py | 7 -- .../components/remember_the_milk/__init__.py | 16 ++-- homeassistant/components/remote/__init__.py | 9 --- homeassistant/components/remote/harmony.py | 7 +- homeassistant/components/rflink.py | 9 +-- homeassistant/components/scene/services.yaml | 8 ++ homeassistant/components/switch/__init__.py | 12 +-- homeassistant/components/switch/mysensors.py | 7 -- .../components/system_log/__init__.py | 7 -- .../components/telegram_bot/__init__.py | 7 +- homeassistant/components/timer/__init__.py | 15 +--- homeassistant/components/tts/__init__.py | 8 +- homeassistant/components/vacuum/__init__.py | 8 +- .../components/vacuum/xiaomi_miio.py | 8 +- homeassistant/components/verisure.py | 6 -- homeassistant/components/wake_on_lan.py | 7 -- homeassistant/components/websocket_api.py | 12 ++- homeassistant/components/wink/__init__.py | 21 +---- homeassistant/components/zha/__init__.py | 14 +--- homeassistant/components/zha/services.yaml | 8 ++ homeassistant/components/zwave/__init__.py | 57 +++---------- homeassistant/core.py | 36 ++------- homeassistant/helpers/service.py | 79 ++++++++++++++++++- tests/components/device_tracker/test_init.py | 2 + tests/helpers/test_service.py | 26 ++++++ tests/test_core.py | 5 +- 85 files changed, 253 insertions(+), 729 deletions(-) create mode 100644 homeassistant/components/scene/services.yaml create mode 100644 homeassistant/components/zha/services.yaml diff --git a/homeassistant/components/__init__.py b/homeassistant/components/__init__.py index b5ac57080d1..6db147a5f59 100644 --- a/homeassistant/components/__init__.py +++ b/homeassistant/components/__init__.py @@ -10,7 +10,6 @@ Component design guidelines: import asyncio import itertools as it import logging -import os import homeassistant.core as ha import homeassistant.config as conf_util @@ -111,11 +110,6 @@ def async_reload_core_config(hass): @asyncio.coroutine def async_setup(hass, config): """Set up general services related to Home Assistant.""" - descriptions = yield from hass.async_add_job( - conf_util.load_yaml_config_file, os.path.join( - os.path.dirname(__file__), 'services.yaml') - ) - @asyncio.coroutine def async_handle_turn_service(service): """Handle calls to homeassistant.turn_on/off.""" @@ -155,14 +149,11 @@ def async_setup(hass, config): yield from asyncio.wait(tasks, loop=hass.loop) hass.services.async_register( - ha.DOMAIN, SERVICE_TURN_OFF, async_handle_turn_service, - descriptions[ha.DOMAIN][SERVICE_TURN_OFF]) + ha.DOMAIN, SERVICE_TURN_OFF, async_handle_turn_service) hass.services.async_register( - ha.DOMAIN, SERVICE_TURN_ON, async_handle_turn_service, - descriptions[ha.DOMAIN][SERVICE_TURN_ON]) + ha.DOMAIN, SERVICE_TURN_ON, async_handle_turn_service) hass.services.async_register( - ha.DOMAIN, SERVICE_TOGGLE, async_handle_turn_service, - descriptions[ha.DOMAIN][SERVICE_TOGGLE]) + ha.DOMAIN, SERVICE_TOGGLE, async_handle_turn_service) @asyncio.coroutine def async_handle_core_service(call): @@ -187,14 +178,11 @@ def async_setup(hass, config): hass.async_add_job(hass.async_stop(RESTART_EXIT_CODE)) hass.services.async_register( - ha.DOMAIN, SERVICE_HOMEASSISTANT_STOP, async_handle_core_service, - descriptions[ha.DOMAIN][SERVICE_HOMEASSISTANT_STOP]) + ha.DOMAIN, SERVICE_HOMEASSISTANT_STOP, async_handle_core_service) hass.services.async_register( - ha.DOMAIN, SERVICE_HOMEASSISTANT_RESTART, async_handle_core_service, - descriptions[ha.DOMAIN][SERVICE_HOMEASSISTANT_RESTART]) + ha.DOMAIN, SERVICE_HOMEASSISTANT_RESTART, async_handle_core_service) hass.services.async_register( - ha.DOMAIN, SERVICE_CHECK_CONFIG, async_handle_core_service, - descriptions[ha.DOMAIN][SERVICE_CHECK_CONFIG]) + ha.DOMAIN, SERVICE_CHECK_CONFIG, async_handle_core_service) @asyncio.coroutine def async_handle_reload_config(call): @@ -209,7 +197,6 @@ def async_setup(hass, config): hass, conf.get(ha.DOMAIN) or {}) hass.services.async_register( - ha.DOMAIN, SERVICE_RELOAD_CORE_CONFIG, async_handle_reload_config, - descriptions[ha.DOMAIN][SERVICE_RELOAD_CORE_CONFIG]) + ha.DOMAIN, SERVICE_RELOAD_CORE_CONFIG, async_handle_reload_config) return True diff --git a/homeassistant/components/abode.py b/homeassistant/components/abode.py index b4c6adcc887..cbfee2ae215 100644 --- a/homeassistant/components/abode.py +++ b/homeassistant/components/abode.py @@ -7,11 +7,9 @@ https://home-assistant.io/components/abode/ import asyncio import logging from functools import partial -from os import path import voluptuous as vol -from homeassistant.config import load_yaml_config_file from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_DATE, ATTR_TIME, ATTR_ENTITY_ID, CONF_USERNAME, CONF_PASSWORD, CONF_EXCLUDE, CONF_NAME, CONF_LIGHTS, @@ -188,22 +186,16 @@ def setup_hass_services(hass): for device in target_devices: device.trigger() - descriptions = load_yaml_config_file( - path.join(path.dirname(__file__), 'services.yaml'))[DOMAIN] - hass.services.register( DOMAIN, SERVICE_SETTINGS, change_setting, - descriptions.get(SERVICE_SETTINGS), schema=CHANGE_SETTING_SCHEMA) hass.services.register( DOMAIN, SERVICE_CAPTURE_IMAGE, capture_image, - descriptions.get(SERVICE_CAPTURE_IMAGE), schema=CAPTURE_IMAGE_SCHEMA) hass.services.register( DOMAIN, SERVICE_TRIGGER, trigger_quick_action, - descriptions.get(SERVICE_TRIGGER), schema=TRIGGER_SCHEMA) diff --git a/homeassistant/components/ads/__init__.py b/homeassistant/components/ads/__init__.py index 3d9de28ded3..20a4489da90 100644 --- a/homeassistant/components/ads/__init__.py +++ b/homeassistant/components/ads/__init__.py @@ -5,7 +5,6 @@ For more details about this component, please refer to the documentation. https://home-assistant.io/components/ads/ """ -import os import threading import struct import logging @@ -14,7 +13,6 @@ from collections import namedtuple import voluptuous as vol from homeassistant.const import CONF_DEVICE, CONF_PORT, CONF_IP_ADDRESS, \ EVENT_HOMEASSISTANT_STOP -from homeassistant.config import load_yaml_config_file import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['pyads==2.2.6'] @@ -107,13 +105,8 @@ def setup(hass, config): except pyads.ADSError as err: _LOGGER.error(err) - # load descriptions from services.yaml - descriptions = load_yaml_config_file( - os.path.join(os.path.dirname(__file__), 'services.yaml')) - hass.services.register( DOMAIN, SERVICE_WRITE_DATA_BY_NAME, handle_write_data_by_name, - descriptions[SERVICE_WRITE_DATA_BY_NAME], schema=SCHEMA_SERVICE_WRITE_DATA_BY_NAME ) diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py index f6fd3f3bea9..25e303cbe85 100644 --- a/homeassistant/components/alarm_control_panel/__init__.py +++ b/homeassistant/components/alarm_control_panel/__init__.py @@ -7,7 +7,6 @@ https://home-assistant.io/components/alarm_control_panel/ import asyncio from datetime import timedelta import logging -import os import voluptuous as vol @@ -15,7 +14,6 @@ from homeassistant.const import ( ATTR_CODE, ATTR_CODE_FORMAT, ATTR_ENTITY_ID, SERVICE_ALARM_TRIGGER, SERVICE_ALARM_DISARM, SERVICE_ALARM_ARM_HOME, SERVICE_ALARM_ARM_AWAY, SERVICE_ALARM_ARM_NIGHT, SERVICE_ALARM_ARM_CUSTOM_BYPASS) -from homeassistant.config import load_yaml_config_file from homeassistant.loader import bind_hass from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa import homeassistant.helpers.config_validation as cv @@ -148,14 +146,10 @@ def async_setup(hass, config): if update_tasks: yield from asyncio.wait(update_tasks, loop=hass.loop) - descriptions = yield from hass.async_add_job( - load_yaml_config_file, os.path.join( - os.path.dirname(__file__), 'services.yaml')) - for service in SERVICE_TO_METHOD: hass.services.async_register( DOMAIN, service, async_alarm_service_handler, - descriptions.get(service), schema=ALARM_SERVICE_SCHEMA) + schema=ALARM_SERVICE_SCHEMA) return True diff --git a/homeassistant/components/alarm_control_panel/alarmdecoder.py b/homeassistant/components/alarm_control_panel/alarmdecoder.py index 2e4255493d4..7126aa6f703 100644 --- a/homeassistant/components/alarm_control_panel/alarmdecoder.py +++ b/homeassistant/components/alarm_control_panel/alarmdecoder.py @@ -6,13 +6,11 @@ https://home-assistant.io/components/alarm_control_panel.alarmdecoder/ """ import asyncio import logging -from os import path import voluptuous as vol import homeassistant.components.alarm_control_panel as alarm import homeassistant.helpers.config_validation as cv -from homeassistant.config import load_yaml_config_file from homeassistant.components.alarmdecoder import ( DATA_AD, SIGNAL_PANEL_MESSAGE) from homeassistant.const import ( @@ -39,12 +37,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): code = service.data.get(ATTR_CODE) device.alarm_toggle_chime(code) - descriptions = load_yaml_config_file( - path.join(path.dirname(__file__), 'services.yaml')) - hass.services.register( alarm.DOMAIN, SERVICE_ALARM_TOGGLE_CHIME, alarm_toggle_chime_handler, - descriptions.get(SERVICE_ALARM_TOGGLE_CHIME), schema=ALARM_TOGGLE_CHIME_SCHEMA) diff --git a/homeassistant/components/alarm_control_panel/envisalink.py b/homeassistant/components/alarm_control_panel/envisalink.py index 026d2324ed3..e5003f1ba1d 100644 --- a/homeassistant/components/alarm_control_panel/envisalink.py +++ b/homeassistant/components/alarm_control_panel/envisalink.py @@ -6,7 +6,6 @@ https://home-assistant.io/components/alarm_control_panel.envisalink/ """ import asyncio import logging -import os import voluptuous as vol @@ -14,7 +13,6 @@ from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect import homeassistant.components.alarm_control_panel as alarm import homeassistant.helpers.config_validation as cv -from homeassistant.config import load_yaml_config_file from homeassistant.components.envisalink import ( DATA_EVL, EnvisalinkDevice, PARTITION_SCHEMA, CONF_CODE, CONF_PANIC, CONF_PARTITIONNAME, SIGNAL_KEYPAD_UPDATE, SIGNAL_PARTITION_UPDATE) @@ -69,14 +67,9 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): for device in target_devices: device.async_alarm_keypress(keypress) - # Register Envisalink specific services - descriptions = yield from hass.async_add_job( - load_yaml_config_file, os.path.join( - os.path.dirname(__file__), 'services.yaml')) - hass.services.async_register( alarm.DOMAIN, SERVICE_ALARM_KEYPRESS, alarm_keypress_handler, - descriptions.get(SERVICE_ALARM_KEYPRESS), schema=ALARM_KEYPRESS_SCHEMA) + schema=ALARM_KEYPRESS_SCHEMA) return True diff --git a/homeassistant/components/alert.py b/homeassistant/components/alert.py index 6356f429bed..27d1625fd6b 100644 --- a/homeassistant/components/alert.py +++ b/homeassistant/components/alert.py @@ -7,12 +7,10 @@ https://home-assistant.io/components/alert/ import asyncio from datetime import datetime, timedelta import logging -import os import voluptuous as vol from homeassistant.core import callback -from homeassistant.config import load_yaml_config_file from homeassistant.const import ( CONF_ENTITY_ID, STATE_IDLE, CONF_NAME, CONF_STATE, STATE_ON, STATE_OFF, SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE, ATTR_ENTITY_ID) @@ -129,22 +127,16 @@ def async_setup(hass, config): alert[CONF_NOTIFIERS], alert[CONF_CAN_ACK]) all_alerts[entity.entity_id] = entity - # Read descriptions - descriptions = yield from hass.async_add_job( - load_yaml_config_file, os.path.join( - os.path.dirname(__file__), 'services.yaml')) - descriptions = descriptions.get(DOMAIN, {}) - # Setup service calls hass.services.async_register( DOMAIN, SERVICE_TURN_OFF, async_handle_alert_service, - descriptions.get(SERVICE_TURN_OFF), schema=ALERT_SERVICE_SCHEMA) + schema=ALERT_SERVICE_SCHEMA) hass.services.async_register( DOMAIN, SERVICE_TURN_ON, async_handle_alert_service, - descriptions.get(SERVICE_TURN_ON), schema=ALERT_SERVICE_SCHEMA) + schema=ALERT_SERVICE_SCHEMA) hass.services.async_register( DOMAIN, SERVICE_TOGGLE, async_handle_alert_service, - descriptions.get(SERVICE_TOGGLE), schema=ALERT_SERVICE_SCHEMA) + schema=ALERT_SERVICE_SCHEMA) tasks = [alert.async_update_ha_state() for alert in all_alerts.values()] if tasks: diff --git a/homeassistant/components/api.py b/homeassistant/components/api.py index ecdc31c8bd7..f25b0cc130c 100644 --- a/homeassistant/components/api.py +++ b/homeassistant/components/api.py @@ -24,6 +24,7 @@ from homeassistant.const import ( __version__) from homeassistant.exceptions import TemplateError from homeassistant.helpers.state import AsyncTrackStates +from homeassistant.helpers.service import async_get_all_descriptions from homeassistant.helpers import template from homeassistant.components.http import HomeAssistantView @@ -293,10 +294,11 @@ class APIServicesView(HomeAssistantView): url = URL_API_SERVICES name = "api:services" - @ha.callback + @asyncio.coroutine def get(self, request): """Get registered services.""" - return self.json(async_services_json(request.app['hass'])) + services = yield from async_services_json(request.app['hass']) + return self.json(services) class APIDomainServicesView(HomeAssistantView): @@ -355,10 +357,12 @@ class APITemplateView(HomeAssistantView): HTTP_BAD_REQUEST) +@asyncio.coroutine def async_services_json(hass): """Generate services data to JSONify.""" + descriptions = yield from async_get_all_descriptions(hass) return [{"domain": key, "services": value} - for key, value in hass.services.async_services().items()] + for key, value in descriptions.items()] def async_events_json(hass): diff --git a/homeassistant/components/apple_tv.py b/homeassistant/components/apple_tv.py index bb6bfa0e9db..beacb3840ef 100644 --- a/homeassistant/components/apple_tv.py +++ b/homeassistant/components/apple_tv.py @@ -4,7 +4,6 @@ Support for Apple TV. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/apple_tv/ """ -import os import asyncio import logging @@ -12,7 +11,6 @@ import voluptuous as vol from typing import Union, TypeVar, Sequence from homeassistant.const import (CONF_HOST, CONF_NAME, ATTR_ENTITY_ID) -from homeassistant.config import load_yaml_config_file from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers import discovery from homeassistant.components.discovery import SERVICE_APPLE_TV @@ -183,18 +181,12 @@ def async_setup(hass, config): if tasks: yield from asyncio.wait(tasks, loop=hass.loop) - descriptions = yield from hass.async_add_job( - load_yaml_config_file, os.path.join( - os.path.dirname(__file__), 'services.yaml')) - hass.services.async_register( DOMAIN, SERVICE_SCAN, async_service_handler, - descriptions.get(SERVICE_SCAN), schema=APPLE_TV_SCAN_SCHEMA) hass.services.async_register( DOMAIN, SERVICE_AUTHENTICATE, async_service_handler, - descriptions.get(SERVICE_AUTHENTICATE), schema=APPLE_TV_AUTHENTICATE_SCHEMA) return True diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 58c86ff0c6d..bc3c17e41da 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -7,14 +7,12 @@ https://home-assistant.io/components/automation/ import asyncio from functools import partial import logging -import os import voluptuous as vol from homeassistant.setup import async_prepare_setup_platform from homeassistant.core import CoreState from homeassistant.loader import bind_hass -from homeassistant import config as conf_util from homeassistant.const import ( ATTR_ENTITY_ID, CONF_PLATFORM, STATE_ON, SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE, SERVICE_RELOAD, EVENT_HOMEASSISTANT_START, CONF_ID) @@ -166,11 +164,6 @@ def async_setup(hass, config): yield from _async_process_config(hass, config, component) - descriptions = yield from hass.async_add_job( - conf_util.load_yaml_config_file, os.path.join( - os.path.dirname(__file__), 'services.yaml') - ) - @asyncio.coroutine def trigger_service_handler(service_call): """Handle automation triggers.""" @@ -216,20 +209,20 @@ def async_setup(hass, config): hass.services.async_register( DOMAIN, SERVICE_TRIGGER, trigger_service_handler, - descriptions.get(SERVICE_TRIGGER), schema=TRIGGER_SERVICE_SCHEMA) + schema=TRIGGER_SERVICE_SCHEMA) hass.services.async_register( DOMAIN, SERVICE_RELOAD, reload_service_handler, - descriptions.get(SERVICE_RELOAD), schema=RELOAD_SERVICE_SCHEMA) + schema=RELOAD_SERVICE_SCHEMA) hass.services.async_register( DOMAIN, SERVICE_TOGGLE, toggle_service_handler, - descriptions.get(SERVICE_TOGGLE), schema=SERVICE_SCHEMA) + schema=SERVICE_SCHEMA) for service in (SERVICE_TURN_ON, SERVICE_TURN_OFF): hass.services.async_register( DOMAIN, service, turn_onoff_service_handler, - descriptions.get(service), schema=SERVICE_SCHEMA) + schema=SERVICE_SCHEMA) return True diff --git a/homeassistant/components/axis.py b/homeassistant/components/axis.py index a7c820f23c7..23dbe052d1c 100644 --- a/homeassistant/components/axis.py +++ b/homeassistant/components/axis.py @@ -6,12 +6,10 @@ https://home-assistant.io/components/axis/ """ import logging -import os import voluptuous as vol from homeassistant.components.discovery import SERVICE_AXIS -from homeassistant.config import load_yaml_config_file from homeassistant.const import (ATTR_LOCATION, ATTR_TRIPPED, CONF_EVENT, CONF_HOST, CONF_INCLUDE, CONF_NAME, CONF_PASSWORD, CONF_PORT, @@ -195,10 +193,6 @@ def setup(hass, config): if not setup_device(hass, config, device_config): _LOGGER.error("Couldn\'t set up %s", device_config[CONF_NAME]) - # Services to communicate with device. - descriptions = load_yaml_config_file( - os.path.join(os.path.dirname(__file__), 'services.yaml')) - def vapix_service(call): """Service to send a message.""" for _, device in AXIS_DEVICES.items(): @@ -216,7 +210,6 @@ def setup(hass, config): hass.services.register(DOMAIN, SERVICE_VAPIX_CALL, vapix_service, - descriptions[DOMAIN][SERVICE_VAPIX_CALL], schema=SERVICE_SCHEMA) return True diff --git a/homeassistant/components/calendar/todoist.py b/homeassistant/components/calendar/todoist.py index eb9f0a2677e..ceab1e98dd4 100644 --- a/homeassistant/components/calendar/todoist.py +++ b/homeassistant/components/calendar/todoist.py @@ -9,7 +9,6 @@ https://home-assistant.io/components/calendar.todoist/ from datetime import datetime from datetime import timedelta import logging -import os import voluptuous as vol @@ -17,7 +16,6 @@ from homeassistant.components.calendar import ( CalendarEventDevice, PLATFORM_SCHEMA) from homeassistant.components.google import ( CONF_DEVICE_ID) -from homeassistant.config import load_yaml_config_file from homeassistant.const import ( CONF_ID, CONF_NAME, CONF_TOKEN) import homeassistant.helpers.config_validation as cv @@ -178,10 +176,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): add_devices(project_devices) - # Services: - descriptions = load_yaml_config_file( - os.path.join(os.path.dirname(__file__), 'services.yaml')) - def handle_new_task(call): """Called when a user creates a new Todoist Task from HASS.""" project_name = call.data[PROJECT_NAME] @@ -215,7 +209,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _LOGGER.debug("Created Todoist task: %s", call.data[CONTENT]) hass.services.register(DOMAIN, SERVICE_NEW_TASK, handle_new_task, - descriptions[DOMAIN][SERVICE_NEW_TASK], schema=NEW_TASK_SERVICE_SCHEMA) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 110f9a11852..6839c2c3b9c 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -12,7 +12,6 @@ from datetime import timedelta import logging import hashlib from random import SystemRandom -import os import aiohttp from aiohttp import web @@ -21,7 +20,6 @@ import voluptuous as vol from homeassistant.core import callback from homeassistant.const import (ATTR_ENTITY_ID, ATTR_ENTITY_PICTURE) -from homeassistant.config import load_yaml_config_file from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import bind_hass from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -190,19 +188,14 @@ def async_setup(hass, config): except OSError as err: _LOGGER.error("Can't write image to file: %s", err) - descriptions = yield from hass.async_add_job( - load_yaml_config_file, os.path.join( - os.path.dirname(__file__), 'services.yaml')) - hass.services.async_register( DOMAIN, SERVICE_ENABLE_MOTION, async_handle_camera_service, - descriptions.get(SERVICE_ENABLE_MOTION), schema=CAMERA_SERVICE_SCHEMA) + schema=CAMERA_SERVICE_SCHEMA) hass.services.async_register( DOMAIN, SERVICE_DISABLE_MOTION, async_handle_camera_service, - descriptions.get(SERVICE_DISABLE_MOTION), schema=CAMERA_SERVICE_SCHEMA) + schema=CAMERA_SERVICE_SCHEMA) hass.services.async_register( DOMAIN, SERVICE_SNAPSHOT, async_handle_snapshot_service, - descriptions.get(SERVICE_SNAPSHOT), schema=CAMERA_SERVICE_SNAPSHOT) return True diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index a5e95ecc36b..bb714ad5f81 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -7,12 +7,10 @@ https://home-assistant.io/components/climate/ import asyncio from datetime import timedelta import logging -import os import functools as ft import voluptuous as vol -from homeassistant.config import load_yaml_config_file from homeassistant.loader import bind_hass from homeassistant.helpers.temperature import display_temp as show_temp from homeassistant.util.temperature import convert as convert_temperature @@ -245,10 +243,6 @@ def async_setup(hass, config): component = EntityComponent(_LOGGER, DOMAIN, hass, SCAN_INTERVAL) yield from component.async_setup(config) - descriptions = yield from hass.async_add_job( - load_yaml_config_file, - os.path.join(os.path.dirname(__file__), 'services.yaml')) - @asyncio.coroutine def async_away_mode_set_service(service): """Set away mode on target climate devices.""" @@ -272,7 +266,6 @@ def async_setup(hass, config): hass.services.async_register( DOMAIN, SERVICE_SET_AWAY_MODE, async_away_mode_set_service, - descriptions.get(SERVICE_SET_AWAY_MODE), schema=SET_AWAY_MODE_SCHEMA) @asyncio.coroutine @@ -295,7 +288,6 @@ def async_setup(hass, config): hass.services.async_register( DOMAIN, SERVICE_SET_HOLD_MODE, async_hold_mode_set_service, - descriptions.get(SERVICE_SET_HOLD_MODE), schema=SET_HOLD_MODE_SCHEMA) @asyncio.coroutine @@ -321,7 +313,6 @@ def async_setup(hass, config): hass.services.async_register( DOMAIN, SERVICE_SET_AUX_HEAT, async_aux_heat_set_service, - descriptions.get(SERVICE_SET_AUX_HEAT), schema=SET_AUX_HEAT_SCHEMA) @asyncio.coroutine @@ -353,7 +344,6 @@ def async_setup(hass, config): hass.services.async_register( DOMAIN, SERVICE_SET_TEMPERATURE, async_temperature_set_service, - descriptions.get(SERVICE_SET_TEMPERATURE), schema=SET_TEMPERATURE_SCHEMA) @asyncio.coroutine @@ -375,7 +365,6 @@ def async_setup(hass, config): hass.services.async_register( DOMAIN, SERVICE_SET_HUMIDITY, async_humidity_set_service, - descriptions.get(SERVICE_SET_HUMIDITY), schema=SET_HUMIDITY_SCHEMA) @asyncio.coroutine @@ -397,7 +386,6 @@ def async_setup(hass, config): hass.services.async_register( DOMAIN, SERVICE_SET_FAN_MODE, async_fan_mode_set_service, - descriptions.get(SERVICE_SET_FAN_MODE), schema=SET_FAN_MODE_SCHEMA) @asyncio.coroutine @@ -419,7 +407,6 @@ def async_setup(hass, config): hass.services.async_register( DOMAIN, SERVICE_SET_OPERATION_MODE, async_operation_set_service, - descriptions.get(SERVICE_SET_OPERATION_MODE), schema=SET_OPERATION_MODE_SCHEMA) @asyncio.coroutine @@ -441,7 +428,6 @@ def async_setup(hass, config): hass.services.async_register( DOMAIN, SERVICE_SET_SWING_MODE, async_swing_set_service, - descriptions.get(SERVICE_SET_SWING_MODE), schema=SET_SWING_MODE_SCHEMA) @asyncio.coroutine @@ -465,10 +451,10 @@ def async_setup(hass, config): hass.services.async_register( DOMAIN, SERVICE_TURN_OFF, async_on_off_service, - descriptions.get(SERVICE_TURN_OFF), schema=ON_OFF_SERVICE_SCHEMA) + schema=ON_OFF_SERVICE_SCHEMA) hass.services.async_register( DOMAIN, SERVICE_TURN_ON, async_on_off_service, - descriptions.get(SERVICE_TURN_ON), schema=ON_OFF_SERVICE_SCHEMA) + schema=ON_OFF_SERVICE_SCHEMA) return True diff --git a/homeassistant/components/climate/ecobee.py b/homeassistant/components/climate/ecobee.py index aae70a4f1f7..b0685b337be 100644 --- a/homeassistant/components/climate/ecobee.py +++ b/homeassistant/components/climate/ecobee.py @@ -5,7 +5,6 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/climate.ecobee/ """ import logging -from os import path import voluptuous as vol @@ -17,7 +16,6 @@ from homeassistant.components.climate import ( SUPPORT_TARGET_HUMIDITY_LOW, SUPPORT_TARGET_HUMIDITY_HIGH) from homeassistant.const import ( ATTR_ENTITY_ID, STATE_OFF, STATE_ON, ATTR_TEMPERATURE, TEMP_FAHRENHEIT) -from homeassistant.config import load_yaml_config_file import homeassistant.helpers.config_validation as cv _CONFIGURING = {} @@ -96,17 +94,12 @@ def setup_platform(hass, config, add_devices, discovery_info=None): thermostat.schedule_update_ha_state(True) - descriptions = load_yaml_config_file( - path.join(path.dirname(__file__), 'services.yaml')) - hass.services.register( DOMAIN, SERVICE_SET_FAN_MIN_ON_TIME, fan_min_on_time_set_service, - descriptions.get(SERVICE_SET_FAN_MIN_ON_TIME), schema=SET_FAN_MIN_ON_TIME_SCHEMA) hass.services.register( DOMAIN, SERVICE_RESUME_PROGRAM, resume_program_set_service, - descriptions.get(SERVICE_RESUME_PROGRAM), schema=RESUME_PROGRAM_SCHEMA) diff --git a/homeassistant/components/climate/econet.py b/homeassistant/components/climate/econet.py index 7cafcd816cb..5620bcbfa11 100644 --- a/homeassistant/components/climate/econet.py +++ b/homeassistant/components/climate/econet.py @@ -6,7 +6,6 @@ https://home-assistant.io/components/climate.econet/ """ import datetime import logging -from os import path import voluptuous as vol @@ -18,7 +17,6 @@ from homeassistant.components.climate import ( STATE_OFF, SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE, ClimateDevice) -from homeassistant.config import load_yaml_config_file from homeassistant.const import (ATTR_ENTITY_ID, CONF_PASSWORD, CONF_USERNAME, TEMP_FAHRENHEIT, ATTR_TEMPERATURE) @@ -107,17 +105,12 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _water_heater.schedule_update_ha_state(True) - descriptions = load_yaml_config_file( - path.join(path.dirname(__file__), 'services.yaml')) - hass.services.register(DOMAIN, SERVICE_ADD_VACATION, service_handle, - descriptions.get(SERVICE_ADD_VACATION), schema=ADD_VACATION_SCHEMA) hass.services.register(DOMAIN, SERVICE_DELETE_VACATION, service_handle, - descriptions.get(SERVICE_DELETE_VACATION), schema=DELETE_VACATION_SCHEMA) diff --git a/homeassistant/components/climate/nuheat.py b/homeassistant/components/climate/nuheat.py index a62a684299d..f41812dbaae 100644 --- a/homeassistant/components/climate/nuheat.py +++ b/homeassistant/components/climate/nuheat.py @@ -6,7 +6,6 @@ https://home-assistant.io/components/climate.nuheat/ """ import logging from datetime import timedelta -from os import path import voluptuous as vol @@ -20,7 +19,6 @@ from homeassistant.components.climate import ( STATE_HEAT, STATE_IDLE) from homeassistant.components.nuheat import DOMAIN as NUHEAT_DOMAIN -from homeassistant.config import load_yaml_config_file from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_TEMPERATURE, @@ -85,12 +83,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): thermostat.schedule_update_ha_state(True) - descriptions = load_yaml_config_file( - path.join(path.dirname(__file__), "services.yaml")) - hass.services.register( DOMAIN, SERVICE_RESUME_PROGRAM, resume_program_set_service, - descriptions.get(SERVICE_RESUME_PROGRAM), schema=RESUME_PROGRAM_SCHEMA) diff --git a/homeassistant/components/counter/__init__.py b/homeassistant/components/counter/__init__.py index aee94c069f6..2df17a4e50a 100644 --- a/homeassistant/components/counter/__init__.py +++ b/homeassistant/components/counter/__init__.py @@ -6,12 +6,10 @@ at https://home-assistant.io/components/counter/ """ import asyncio import logging -import os import voluptuous as vol import homeassistant.helpers.config_validation as cv -from homeassistant.config import load_yaml_config_file from homeassistant.const import (ATTR_ENTITY_ID, CONF_ICON, CONF_NAME) from homeassistant.core import callback from homeassistant.helpers.entity import Entity @@ -133,20 +131,12 @@ def async_setup(hass, config): if tasks: yield from asyncio.wait(tasks, loop=hass.loop) - descriptions = yield from hass.async_add_job( - load_yaml_config_file, os.path.join( - os.path.dirname(__file__), 'services.yaml') - ) - hass.services.async_register( - DOMAIN, SERVICE_INCREMENT, async_handler_service, - descriptions[SERVICE_INCREMENT], SERVICE_SCHEMA) + DOMAIN, SERVICE_INCREMENT, async_handler_service) hass.services.async_register( - DOMAIN, SERVICE_DECREMENT, async_handler_service, - descriptions[SERVICE_DECREMENT], SERVICE_SCHEMA) + DOMAIN, SERVICE_DECREMENT, async_handler_service) hass.services.async_register( - DOMAIN, SERVICE_RESET, async_handler_service, - descriptions[SERVICE_RESET], SERVICE_SCHEMA) + DOMAIN, SERVICE_RESET, async_handler_service) yield from component.async_add_entities(entities) return True diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index ba60382ae64..1dfa0028ab8 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -8,11 +8,9 @@ import asyncio from datetime import timedelta import functools as ft import logging -import os import voluptuous as vol -from homeassistant.config import load_yaml_config_file from homeassistant.loader import bind_hass from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity import Entity @@ -179,16 +177,12 @@ def async_setup(hass, config): if update_tasks: yield from asyncio.wait(update_tasks, loop=hass.loop) - descriptions = yield from hass.async_add_job( - load_yaml_config_file, os.path.join( - os.path.dirname(__file__), 'services.yaml')) - for service_name in SERVICE_TO_METHOD: schema = SERVICE_TO_METHOD[service_name].get( 'schema', COVER_SERVICE_SCHEMA) hass.services.async_register( DOMAIN, service_name, async_handle_cover_service, - descriptions.get(service_name), schema=schema) + schema=schema) return True diff --git a/homeassistant/components/deconz/__init__.py b/homeassistant/components/deconz/__init__.py index 4b89594c62e..021febdc07c 100644 --- a/homeassistant/components/deconz/__init__.py +++ b/homeassistant/components/deconz/__init__.py @@ -7,10 +7,8 @@ https://home-assistant.io/components/deconz/ import asyncio import logging -import os import voluptuous as vol -from homeassistant.config import load_yaml_config_file from homeassistant.const import ( CONF_API_KEY, CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP) from homeassistant.components.discovery import SERVICE_DECONZ @@ -107,10 +105,6 @@ def async_setup_deconz(hass, config, deconz_config): hass, component, DOMAIN, {}, config)) deconz.start() - descriptions = yield from hass.async_add_job( - load_yaml_config_file, - os.path.join(os.path.dirname(__file__), 'services.yaml')) - @asyncio.coroutine def async_configure(call): """Set attribute of device in deCONZ. @@ -132,7 +126,7 @@ def async_setup_deconz(hass, config, deconz_config): yield from deconz.async_put_state(field, data) hass.services.async_register( DOMAIN, 'configure', async_configure, - descriptions['configure'], schema=SERVICE_SCHEMA) + schema=SERVICE_SCHEMA) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, deconz.close) return True diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index 8c563fda34c..3ae5bf82007 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -7,7 +7,6 @@ https://home-assistant.io/components/device_tracker/ import asyncio from datetime import timedelta import logging -import os from typing import Any, List, Sequence, Callable import aiohttp @@ -207,12 +206,7 @@ def async_setup(hass: HomeAssistantType, config: ConfigType): ATTR_GPS, ATTR_GPS_ACCURACY, ATTR_BATTERY, ATTR_ATTRIBUTES)} yield from tracker.async_see(**args) - descriptions = yield from hass.async_add_job( - load_yaml_config_file, - os.path.join(os.path.dirname(__file__), 'services.yaml') - ) - hass.services.async_register( - DOMAIN, SERVICE_SEE, async_see_service, descriptions.get(SERVICE_SEE)) + hass.services.async_register(DOMAIN, SERVICE_SEE, async_see_service) # restore yield from tracker.async_setup_tracked_device() diff --git a/homeassistant/components/eight_sleep.py b/homeassistant/components/eight_sleep.py index dda556ba6a4..88cbf1bd57b 100644 --- a/homeassistant/components/eight_sleep.py +++ b/homeassistant/components/eight_sleep.py @@ -6,13 +6,11 @@ https://home-assistant.io/components/eight_sleep/ """ import asyncio import logging -import os from datetime import timedelta import voluptuous as vol from homeassistant.core import callback -from homeassistant.config import load_yaml_config_file from homeassistant.const import ( CONF_USERNAME, CONF_PASSWORD, CONF_SENSORS, CONF_BINARY_SENSORS, ATTR_ENTITY_ID, EVENT_HOMEASSISTANT_STOP) @@ -159,10 +157,6 @@ def async_setup(hass, config): CONF_BINARY_SENSORS: binary_sensors, }, config)) - descriptions = yield from hass.async_add_job( - load_yaml_config_file, - os.path.join(os.path.dirname(__file__), 'services.yaml')) - @asyncio.coroutine def async_service_handler(service): """Handle eight sleep service calls.""" @@ -183,7 +177,6 @@ def async_setup(hass, config): # Register services hass.services.async_register( DOMAIN, SERVICE_HEAT_SET, async_service_handler, - descriptions[DOMAIN].get(SERVICE_HEAT_SET), schema=SERVICE_EIGHT_SCHEMA) @asyncio.coroutine diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index 7710040ae99..eccc800319c 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -8,12 +8,10 @@ import asyncio from datetime import timedelta import functools as ft import logging -import os import voluptuous as vol from homeassistant.components import group -from homeassistant.config import load_yaml_config_file from homeassistant.const import (SERVICE_TURN_ON, SERVICE_TOGGLE, SERVICE_TURN_OFF, ATTR_ENTITY_ID, STATE_UNKNOWN) @@ -225,16 +223,10 @@ def async_setup(hass, config: dict): if update_tasks: yield from asyncio.wait(update_tasks, loop=hass.loop) - # Listen for fan service calls. - descriptions = yield from hass.async_add_job( - load_yaml_config_file, os.path.join( - os.path.dirname(__file__), 'services.yaml')) - for service_name in SERVICE_TO_METHOD: schema = SERVICE_TO_METHOD[service_name].get('schema') hass.services.async_register( - DOMAIN, service_name, async_handle_fan_service, - descriptions.get(service_name), schema=schema) + DOMAIN, service_name, async_handle_fan_service, schema=schema) return True diff --git a/homeassistant/components/fan/dyson.py b/homeassistant/components/fan/dyson.py index 0e0e3fdfaf3..f2630aa98d2 100644 --- a/homeassistant/components/fan/dyson.py +++ b/homeassistant/components/fan/dyson.py @@ -5,7 +5,6 @@ https://home-assistant.io/components/fan.dyson/ """ import logging import asyncio -from os import path import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.fan import (FanEntity, SUPPORT_OSCILLATE, @@ -13,7 +12,6 @@ from homeassistant.components.fan import (FanEntity, SUPPORT_OSCILLATE, DOMAIN) from homeassistant.helpers.entity import ToggleEntity from homeassistant.components.dyson import DYSON_DEVICES -from homeassistant.config import load_yaml_config_file DEPENDENCIES = ['dyson'] @@ -44,9 +42,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): add_devices(hass.data[DYSON_FAN_DEVICES]) - descriptions = load_yaml_config_file( - path.join(path.dirname(__file__), 'services.yaml')) - def service_handle(service): """Handle dyson services.""" entity_id = service.data.get('entity_id') @@ -64,7 +59,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): # Register dyson service(s) hass.services.register(DOMAIN, SERVICE_SET_NIGHT_MODE, service_handle, - descriptions.get(SERVICE_SET_NIGHT_MODE), schema=DYSON_SET_NIGHT_MODE_SCHEMA) diff --git a/homeassistant/components/fan/xiaomi_miio.py b/homeassistant/components/fan/xiaomi_miio.py index 7101f4a9527..9f21fda408d 100644 --- a/homeassistant/components/fan/xiaomi_miio.py +++ b/homeassistant/components/fan/xiaomi_miio.py @@ -7,14 +7,12 @@ https://home-assistant.io/components/fan.xiaomi_miio/ import asyncio from functools import partial import logging -import os import voluptuous as vol from homeassistant.helpers.entity import ToggleEntity from homeassistant.components.fan import (FanEntity, PLATFORM_SCHEMA, SUPPORT_SET_SPEED, DOMAIN) -from homeassistant.config import load_yaml_config_file from homeassistant.const import (CONF_NAME, CONF_HOST, CONF_TOKEN, ATTR_ENTITY_ID, ) from homeassistant.exceptions import PlatformNotReady @@ -131,16 +129,11 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): if update_tasks: yield from asyncio.wait(update_tasks, loop=hass.loop) - descriptions = yield from hass.async_add_job( - load_yaml_config_file, os.path.join( - os.path.dirname(__file__), 'xiaomi_miio_services.yaml')) - for air_purifier_service in SERVICE_TO_METHOD: schema = SERVICE_TO_METHOD[air_purifier_service].get( 'schema', AIRPURIFIER_SERVICE_SCHEMA) hass.services.async_register( - DOMAIN, air_purifier_service, async_service_handler, - description=descriptions.get(air_purifier_service), schema=schema) + DOMAIN, air_purifier_service, async_service_handler, schema=schema) class XiaomiAirPurifier(FanEntity): diff --git a/homeassistant/components/ffmpeg.py b/homeassistant/components/ffmpeg.py index dc0439b8b32..e083affe92b 100644 --- a/homeassistant/components/ffmpeg.py +++ b/homeassistant/components/ffmpeg.py @@ -6,14 +6,12 @@ https://home-assistant.io/components/ffmpeg/ """ import asyncio import logging -import os import voluptuous as vol from homeassistant.core import callback from homeassistant.const import ( ATTR_ENTITY_ID, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) -from homeassistant.config import load_yaml_config_file from homeassistant.helpers.dispatcher import ( async_dispatcher_send, async_dispatcher_connect) import homeassistant.helpers.config_validation as cv @@ -89,10 +87,6 @@ def async_setup(hass, config): conf.get(CONF_RUN_TEST, DEFAULT_RUN_TEST) ) - descriptions = yield from hass.async_add_job( - load_yaml_config_file, - os.path.join(os.path.dirname(__file__), 'services.yaml')) - # Register service @asyncio.coroutine def async_service_handle(service): @@ -108,15 +102,14 @@ def async_setup(hass, config): hass.services.async_register( DOMAIN, SERVICE_START, async_service_handle, - descriptions[DOMAIN].get(SERVICE_START), schema=SERVICE_FFMPEG_SCHEMA) + schema=SERVICE_FFMPEG_SCHEMA) hass.services.async_register( DOMAIN, SERVICE_STOP, async_service_handle, - descriptions[DOMAIN].get(SERVICE_STOP), schema=SERVICE_FFMPEG_SCHEMA) + schema=SERVICE_FFMPEG_SCHEMA) hass.services.async_register( DOMAIN, SERVICE_RESTART, async_service_handle, - descriptions[DOMAIN].get(SERVICE_RESTART), schema=SERVICE_FFMPEG_SCHEMA) hass.data[DATA_FFMPEG] = manager diff --git a/homeassistant/components/foursquare.py b/homeassistant/components/foursquare.py index 61c5e9b1da6..2c10df327f4 100644 --- a/homeassistant/components/foursquare.py +++ b/homeassistant/components/foursquare.py @@ -6,14 +6,12 @@ https://home-assistant.io/components/foursquare/ """ import asyncio import logging -import os import requests import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.const import CONF_ACCESS_TOKEN, HTTP_BAD_REQUEST -from homeassistant.config import load_yaml_config_file from homeassistant.components.http import HomeAssistantView _LOGGER = logging.getLogger(__name__) @@ -50,9 +48,6 @@ CONFIG_SCHEMA = vol.Schema({ def setup(hass, config): """Set up the Foursquare component.""" - descriptions = load_yaml_config_file( - os.path.join(os.path.dirname(__file__), 'services.yaml')) - config = config[DOMAIN] def checkin_user(call): @@ -72,7 +67,6 @@ def setup(hass, config): # Register our service with Home Assistant. hass.services.register(DOMAIN, 'checkin', checkin_user, - descriptions[DOMAIN][SERVICE_CHECKIN], schema=CHECKIN_SERVICE_SCHEMA) hass.http.register_view(FoursquarePushReceiver(config[CONF_PUSH_SECRET])) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index aa74866aeab..a7b897ed5b5 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -376,12 +376,11 @@ def async_setup(hass, config): for url in conf.get(CONF_EXTRA_HTML_URL_ES5, []): add_extra_html_url(hass, url, True) - yield from async_setup_themes(hass, conf.get(CONF_THEMES)) + async_setup_themes(hass, conf.get(CONF_THEMES)) return True -@asyncio.coroutine def async_setup_themes(hass, themes): """Set up themes data and services.""" hass.http.register_view(ThemesView) @@ -428,16 +427,9 @@ def async_setup_themes(hass, themes): hass.data[DATA_DEFAULT_THEME] = DEFAULT_THEME update_theme_and_fire_event() - descriptions = yield from hass.async_add_job( - load_yaml_config_file, - os.path.join(os.path.dirname(__file__), 'services.yaml')) - - hass.services.async_register(DOMAIN, SERVICE_SET_THEME, - set_theme, - descriptions[SERVICE_SET_THEME], - SERVICE_SET_THEME_SCHEMA) - hass.services.async_register(DOMAIN, SERVICE_RELOAD_THEMES, reload_themes, - descriptions[SERVICE_RELOAD_THEMES]) + hass.services.async_register( + DOMAIN, SERVICE_SET_THEME, set_theme, schema=SERVICE_SET_THEME_SCHEMA) + hass.services.async_register(DOMAIN, SERVICE_RELOAD_THEMES, reload_themes) class IndexView(HomeAssistantView): diff --git a/homeassistant/components/google.py b/homeassistant/components/google.py index efb2b12bfca..f7923067270 100644 --- a/homeassistant/components/google.py +++ b/homeassistant/components/google.py @@ -190,8 +190,7 @@ def setup_services(hass, track_new_found_calendars, calendar_service): hass.data[DATA_INDEX][calendar[CONF_CAL_ID]]) hass.services.register( - DOMAIN, SERVICE_FOUND_CALENDARS, _found_calendar, - None, schema=None) + DOMAIN, SERVICE_FOUND_CALENDARS, _found_calendar) def _scan_for_calendars(service): """Scan for new calendars.""" @@ -204,9 +203,7 @@ def setup_services(hass, track_new_found_calendars, calendar_service): calendar) hass.services.register( - DOMAIN, SERVICE_SCAN_CALENDARS, - _scan_for_calendars, - None, schema=None) + DOMAIN, SERVICE_SCAN_CALENDARS, _scan_for_calendars) return True diff --git a/homeassistant/components/google_assistant/__init__.py b/homeassistant/components/google_assistant/__init__.py index 800b05b3b0f..0f9bd858d7e 100644 --- a/homeassistant/components/google_assistant/__init__.py +++ b/homeassistant/components/google_assistant/__init__.py @@ -4,7 +4,6 @@ Support for Actions on Google Assistant Smart Home Control. For more details about this component, please refer to the documentation at https://home-assistant.io/components/google_assistant/ """ -import os import asyncio import logging @@ -18,7 +17,6 @@ import voluptuous as vol from homeassistant.core import HomeAssistant # NOQA from typing import Dict, Any # NOQA -from homeassistant import config as conf_util from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.loader import bind_hass @@ -68,11 +66,6 @@ def async_setup(hass: HomeAssistant, yaml_config: Dict[str, Any]): config = yaml_config.get(DOMAIN, {}) agent_user_id = config.get(CONF_AGENT_USER_ID) api_key = config.get(CONF_API_KEY) - if api_key is not None: - descriptions = yield from hass.async_add_job( - conf_util.load_yaml_config_file, os.path.join( - os.path.dirname(__file__), 'services.yaml') - ) hass.http.register_view(GoogleAssistantAuthView(hass, config)) async_register_http(hass, config) @@ -98,7 +91,6 @@ def async_setup(hass: HomeAssistant, yaml_config: Dict[str, Any]): # Register service only if api key is provided if api_key is not None: hass.services.async_register( - DOMAIN, SERVICE_REQUEST_SYNC, request_sync_service_handler, - descriptions.get(SERVICE_REQUEST_SYNC)) + DOMAIN, SERVICE_REQUEST_SYNC, request_sync_service_handler) return True diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index 0bc1fa46c4c..518eb4fc54c 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -6,11 +6,10 @@ https://home-assistant.io/components/group/ """ import asyncio import logging -import os import voluptuous as vol -from homeassistant import config as conf_util, core as ha +from homeassistant import core as ha from homeassistant.const import ( ATTR_ENTITY_ID, CONF_ICON, CONF_NAME, STATE_CLOSED, STATE_HOME, STATE_NOT_HOME, STATE_OFF, STATE_ON, STATE_OPEN, STATE_LOCKED, @@ -254,11 +253,6 @@ def async_setup(hass, config): yield from _async_process_config(hass, config, component) - descriptions = yield from hass.async_add_job( - conf_util.load_yaml_config_file, os.path.join( - os.path.dirname(__file__), 'services.yaml') - ) - @asyncio.coroutine def reload_service_handler(service): """Remove all groups and load new ones from config.""" @@ -269,7 +263,7 @@ def async_setup(hass, config): hass.services.async_register( DOMAIN, SERVICE_RELOAD, reload_service_handler, - descriptions[SERVICE_RELOAD], schema=RELOAD_SERVICE_SCHEMA) + schema=RELOAD_SERVICE_SCHEMA) @asyncio.coroutine def groups_service_handler(service): @@ -346,11 +340,11 @@ def async_setup(hass, config): hass.services.async_register( DOMAIN, SERVICE_SET, groups_service_handler, - descriptions[SERVICE_SET], schema=SET_SERVICE_SCHEMA) + schema=SET_SERVICE_SCHEMA) hass.services.async_register( DOMAIN, SERVICE_REMOVE, groups_service_handler, - descriptions[SERVICE_REMOVE], schema=REMOVE_SERVICE_SCHEMA) + schema=REMOVE_SERVICE_SCHEMA) @asyncio.coroutine def visibility_service_handler(service): @@ -368,7 +362,6 @@ def async_setup(hass, config): hass.services.async_register( DOMAIN, SERVICE_SET_VISIBILITY, visibility_service_handler, - descriptions[SERVICE_SET_VISIBILITY], schema=SET_VISIBILITY_SERVICE_SCHEMA) return True diff --git a/homeassistant/components/hdmi_cec.py b/homeassistant/components/hdmi_cec.py index b4233f1ac82..f94dd8816a7 100644 --- a/homeassistant/components/hdmi_cec.py +++ b/homeassistant/components/hdmi_cec.py @@ -6,7 +6,6 @@ https://home-assistant.io/components/hdmi_cec/ """ import logging import multiprocessing -import os from collections import defaultdict from functools import reduce @@ -16,7 +15,6 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers import discovery from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER from homeassistant.components.switch import DOMAIN as SWITCH -from homeassistant.config import load_yaml_config_file from homeassistant.const import (EVENT_HOMEASSISTANT_START, STATE_UNKNOWN, EVENT_HOMEASSISTANT_STOP, STATE_ON, STATE_OFF, CONF_DEVICES, CONF_PLATFORM, @@ -301,17 +299,12 @@ def setup(hass: HomeAssistant, base_config): def _start_cec(event): """Register services and start HDMI network to watch for devices.""" - descriptions = load_yaml_config_file( - os.path.join(os.path.dirname(__file__), 'services.yaml'))[DOMAIN] hass.services.register(DOMAIN, SERVICE_SEND_COMMAND, _tx, - descriptions[SERVICE_SEND_COMMAND], SERVICE_SEND_COMMAND_SCHEMA) hass.services.register(DOMAIN, SERVICE_VOLUME, _volume, - descriptions[SERVICE_VOLUME], - SERVICE_VOLUME_SCHEMA) + schema=SERVICE_VOLUME_SCHEMA) hass.services.register(DOMAIN, SERVICE_UPDATE_DEVICES, _update, - descriptions[SERVICE_UPDATE_DEVICES], - SERVICE_UPDATE_DEVICES_SCHEMA) + schema=SERVICE_UPDATE_DEVICES_SCHEMA) hass.services.register(DOMAIN, SERVICE_POWER_ON, _power_on) hass.services.register(DOMAIN, SERVICE_STANDBY, _standby) hass.services.register(DOMAIN, SERVICE_SELECT_DEVICE, _select_device) diff --git a/homeassistant/components/homematic/__init__.py b/homeassistant/components/homematic/__init__.py index 117d04c88f6..9f0fcdb9874 100644 --- a/homeassistant/components/homematic/__init__.py +++ b/homeassistant/components/homematic/__init__.py @@ -8,12 +8,10 @@ import asyncio from datetime import timedelta from functools import partial import logging -import os import socket import voluptuous as vol -from homeassistant.config import load_yaml_config_file from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, CONF_USERNAME, CONF_PASSWORD, CONF_PLATFORM, CONF_HOSTS, CONF_HOST, ATTR_ENTITY_ID, STATE_UNKNOWN) @@ -330,10 +328,6 @@ def setup(hass, config): for hub_name in conf[CONF_HOSTS].keys(): entity_hubs.append(HMHub(hass, homematic, hub_name)) - # Register HomeMatic services - descriptions = load_yaml_config_file( - os.path.join(os.path.dirname(__file__), 'services.yaml')) - def _hm_service_virtualkey(service): """Service to handle virtualkey servicecalls.""" address = service.data.get(ATTR_ADDRESS) @@ -362,7 +356,7 @@ def setup(hass, config): hass.services.register( DOMAIN, SERVICE_VIRTUALKEY, _hm_service_virtualkey, - descriptions[SERVICE_VIRTUALKEY], schema=SCHEMA_SERVICE_VIRTUALKEY) + schema=SCHEMA_SERVICE_VIRTUALKEY) def _service_handle_value(service): """Service to call setValue method for HomeMatic system variable.""" @@ -385,7 +379,6 @@ def setup(hass, config): hass.services.register( DOMAIN, SERVICE_SET_VARIABLE_VALUE, _service_handle_value, - descriptions[SERVICE_SET_VARIABLE_VALUE], schema=SCHEMA_SERVICE_SET_VARIABLE_VALUE) def _service_handle_reconnect(service): @@ -394,7 +387,7 @@ def setup(hass, config): hass.services.register( DOMAIN, SERVICE_RECONNECT, _service_handle_reconnect, - descriptions[SERVICE_RECONNECT], schema=SCHEMA_SERVICE_RECONNECT) + schema=SCHEMA_SERVICE_RECONNECT) def _service_handle_device(service): """Service to call setValue method for HomeMatic devices.""" @@ -413,7 +406,6 @@ def setup(hass, config): hass.services.register( DOMAIN, SERVICE_SET_DEVICE_VALUE, _service_handle_device, - descriptions[SERVICE_SET_DEVICE_VALUE], schema=SCHEMA_SERVICE_SET_DEVICE_VALUE) def _service_handle_install_mode(service): @@ -427,7 +419,6 @@ def setup(hass, config): hass.services.register( DOMAIN, SERVICE_SET_INSTALL_MODE, _service_handle_install_mode, - descriptions[SERVICE_SET_INSTALL_MODE], schema=SCHEMA_SERVICE_SET_INSTALL_MODE) return True diff --git a/homeassistant/components/hue.py b/homeassistant/components/hue.py index 6147f706658..302c8be7598 100644 --- a/homeassistant/components/hue.py +++ b/homeassistant/components/hue.py @@ -12,7 +12,6 @@ import socket import voluptuous as vol from homeassistant.components.discovery import SERVICE_HUE -from homeassistant.config import load_yaml_config_file from homeassistant.const import CONF_FILENAME, CONF_HOST import homeassistant.helpers.config_validation as cv from homeassistant.helpers import discovery @@ -207,11 +206,8 @@ class HueBridge(object): scene_name = call.data[ATTR_SCENE_NAME] self.bridge.run_scene(group_name, scene_name) - descriptions = load_yaml_config_file( - os.path.join(os.path.dirname(__file__), 'services.yaml')) self.hass.services.register( DOMAIN, SERVICE_HUE_SCENE, hue_activate_scene, - descriptions.get(SERVICE_HUE_SCENE), schema=SCENE_SCHEMA) def request_configuration(self): diff --git a/homeassistant/components/image_processing/__init__.py b/homeassistant/components/image_processing/__init__.py index e6979087b6f..646bfcf421f 100644 --- a/homeassistant/components/image_processing/__init__.py +++ b/homeassistant/components/image_processing/__init__.py @@ -7,12 +7,10 @@ https://home-assistant.io/components/image_processing/ import asyncio from datetime import timedelta import logging -import os import voluptuous as vol import homeassistant.helpers.config_validation as cv -from homeassistant.config import load_yaml_config_file from homeassistant.const import ( ATTR_ENTITY_ID, CONF_NAME, CONF_ENTITY_ID) from homeassistant.exceptions import HomeAssistantError @@ -74,10 +72,6 @@ def async_setup(hass, config): yield from component.async_setup(config) - descriptions = yield from hass.async_add_job( - load_yaml_config_file, - os.path.join(os.path.dirname(__file__), 'services.yaml')) - @asyncio.coroutine def async_scan_service(service): """Service handler for scan.""" @@ -90,7 +84,7 @@ def async_setup(hass, config): hass.services.async_register( DOMAIN, SERVICE_SCAN, async_scan_service, - descriptions.get(SERVICE_SCAN), schema=SERVICE_SCAN_SCHEMA) + schema=SERVICE_SCAN_SCHEMA) return True diff --git a/homeassistant/components/input_boolean.py b/homeassistant/components/input_boolean.py index 9f5c18f05f0..43feeb8c4f4 100644 --- a/homeassistant/components/input_boolean.py +++ b/homeassistant/components/input_boolean.py @@ -6,7 +6,6 @@ at https://home-assistant.io/components/input_boolean/ """ import asyncio import logging -import os import voluptuous as vol @@ -15,7 +14,6 @@ from homeassistant.const import ( SERVICE_TOGGLE, STATE_ON) from homeassistant.loader import bind_hass import homeassistant.helpers.config_validation as cv -from homeassistant.config import load_yaml_config_file from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import async_get_last_state @@ -103,22 +101,14 @@ def async_setup(hass, config): if tasks: yield from asyncio.wait(tasks, loop=hass.loop) - descriptions = yield from hass.async_add_job( - load_yaml_config_file, os.path.join( - os.path.dirname(__file__), 'services.yaml') - ) - hass.services.async_register( DOMAIN, SERVICE_TURN_OFF, async_handler_service, - descriptions[DOMAIN][SERVICE_TURN_OFF], schema=SERVICE_SCHEMA) hass.services.async_register( DOMAIN, SERVICE_TURN_ON, async_handler_service, - descriptions[DOMAIN][SERVICE_TURN_ON], schema=SERVICE_SCHEMA) hass.services.async_register( DOMAIN, SERVICE_TOGGLE, async_handler_service, - descriptions[DOMAIN][SERVICE_TOGGLE], schema=SERVICE_SCHEMA) yield from component.async_add_entities(entities) diff --git a/homeassistant/components/input_number.py b/homeassistant/components/input_number.py index 856cdac1e4b..e18169fca73 100644 --- a/homeassistant/components/input_number.py +++ b/homeassistant/components/input_number.py @@ -4,14 +4,12 @@ Component to offer a way to set a numeric value from a slider or text box. For more details about this component, please refer to the documentation at https://home-assistant.io/components/input_number/ """ -import os import asyncio import logging import voluptuous as vol import homeassistant.helpers.config_validation as cv -from homeassistant.config import load_yaml_config_file from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, CONF_ICON, CONF_NAME, CONF_MODE) from homeassistant.helpers.entity import Entity @@ -165,14 +163,9 @@ def async_setup(hass, config): if update_tasks: yield from asyncio.wait(update_tasks, loop=hass.loop) - descriptions = yield from hass.async_add_job( - load_yaml_config_file, os.path.join( - os.path.dirname(__file__), 'services.yaml')) - for service, data in SERVICE_TO_METHOD.items(): hass.services.async_register( - DOMAIN, service, async_handle_service, - description=descriptions[DOMAIN][service], schema=data['schema']) + DOMAIN, service, async_handle_service, schema=data['schema']) yield from component.async_add_entities(entities) return True diff --git a/homeassistant/components/input_select.py b/homeassistant/components/input_select.py index 5df26a83089..f16b029c1d7 100644 --- a/homeassistant/components/input_select.py +++ b/homeassistant/components/input_select.py @@ -6,14 +6,12 @@ at https://home-assistant.io/components/input_select/ """ import asyncio import logging -import os import voluptuous as vol from homeassistant.const import ATTR_ENTITY_ID, CONF_ICON, CONF_NAME from homeassistant.loader import bind_hass import homeassistant.helpers.config_validation as cv -from homeassistant.config import load_yaml_config_file from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import async_get_last_state @@ -131,11 +129,6 @@ def async_setup(hass, config): if not entities: return False - descriptions = yield from hass.async_add_job( - load_yaml_config_file, os.path.join( - os.path.dirname(__file__), 'services.yaml') - ) - @asyncio.coroutine def async_select_option_service(call): """Handle a calls to the input select option service.""" @@ -148,7 +141,6 @@ def async_setup(hass, config): hass.services.async_register( DOMAIN, SERVICE_SELECT_OPTION, async_select_option_service, - descriptions[DOMAIN][SERVICE_SELECT_OPTION], schema=SERVICE_SELECT_OPTION_SCHEMA) @asyncio.coroutine @@ -163,7 +155,6 @@ def async_setup(hass, config): hass.services.async_register( DOMAIN, SERVICE_SELECT_NEXT, async_select_next_service, - descriptions[DOMAIN][SERVICE_SELECT_NEXT], schema=SERVICE_SELECT_NEXT_SCHEMA) @asyncio.coroutine @@ -178,7 +169,6 @@ def async_setup(hass, config): hass.services.async_register( DOMAIN, SERVICE_SELECT_PREVIOUS, async_select_previous_service, - descriptions[DOMAIN][SERVICE_SELECT_PREVIOUS], schema=SERVICE_SELECT_PREVIOUS_SCHEMA) @asyncio.coroutine @@ -193,7 +183,6 @@ def async_setup(hass, config): hass.services.async_register( DOMAIN, SERVICE_SET_OPTIONS, async_set_options_service, - descriptions[DOMAIN][SERVICE_SET_OPTIONS], schema=SERVICE_SET_OPTIONS_SCHEMA) yield from component.async_add_entities(entities) diff --git a/homeassistant/components/input_text.py b/homeassistant/components/input_text.py index a9df7c15ea3..583181fe453 100755 --- a/homeassistant/components/input_text.py +++ b/homeassistant/components/input_text.py @@ -4,14 +4,12 @@ Component to offer a way to enter a value into a text box. For more details about this component, please refer to the documentation at https://home-assistant.io/components/input_text/ """ -import os import asyncio import logging import voluptuous as vol import homeassistant.helpers.config_validation as cv -from homeassistant.config import load_yaml_config_file from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, CONF_ICON, CONF_NAME) from homeassistant.loader import bind_hass @@ -112,13 +110,8 @@ def async_setup(hass, config): if tasks: yield from asyncio.wait(tasks, loop=hass.loop) - descriptions = yield from hass.async_add_job( - load_yaml_config_file, os.path.join( - os.path.dirname(__file__), 'services.yaml')) - hass.services.async_register( DOMAIN, SERVICE_SET_VALUE, async_set_value_service, - description=descriptions[DOMAIN][SERVICE_SET_VALUE], schema=SERVICE_SET_VALUE_SCHEMA) yield from component.async_add_entities(entities) diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index e4fb4542205..3d333e229fa 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -15,7 +15,6 @@ import voluptuous as vol from homeassistant.core import callback from homeassistant.loader import bind_hass from homeassistant.components import group -from homeassistant.config import load_yaml_config_file from homeassistant.const import ( STATE_ON, SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE, ATTR_ENTITY_ID) @@ -282,21 +281,17 @@ def async_setup(hass, config): yield from asyncio.wait(update_tasks, loop=hass.loop) # Listen for light on and light off service calls. - descriptions = yield from hass.async_add_job( - load_yaml_config_file, os.path.join( - os.path.dirname(__file__), 'services.yaml')) - hass.services.async_register( DOMAIN, SERVICE_TURN_ON, async_handle_light_service, - descriptions.get(SERVICE_TURN_ON), schema=LIGHT_TURN_ON_SCHEMA) + schema=LIGHT_TURN_ON_SCHEMA) hass.services.async_register( DOMAIN, SERVICE_TURN_OFF, async_handle_light_service, - descriptions.get(SERVICE_TURN_OFF), schema=LIGHT_TURN_OFF_SCHEMA) + schema=LIGHT_TURN_OFF_SCHEMA) hass.services.async_register( DOMAIN, SERVICE_TOGGLE, async_handle_light_service, - descriptions.get(SERVICE_TOGGLE), schema=LIGHT_TOGGLE_SCHEMA) + schema=LIGHT_TOGGLE_SCHEMA) return True diff --git a/homeassistant/components/light/lifx.py b/homeassistant/components/light/lifx.py index 06a00954d3b..22ec58f65cd 100644 --- a/homeassistant/components/light/lifx.py +++ b/homeassistant/components/light/lifx.py @@ -8,7 +8,6 @@ import logging import asyncio import sys import math -from os import path from functools import partial from datetime import timedelta @@ -22,7 +21,6 @@ from homeassistant.components.light import ( SUPPORT_XY_COLOR, SUPPORT_TRANSITION, SUPPORT_EFFECT, VALID_BRIGHTNESS, VALID_BRIGHTNESS_PCT, preprocess_turn_on_alternatives) -from homeassistant.config import load_yaml_config_file from homeassistant.const import ATTR_ENTITY_ID, EVENT_HOMEASSISTANT_STOP from homeassistant import util from homeassistant.core import callback @@ -210,13 +208,10 @@ class LIFXManager(object): self.async_add_devices = async_add_devices self.effects_conductor = aiolifx_effects().Conductor(loop=hass.loop) - descriptions = load_yaml_config_file( - path.join(path.dirname(__file__), 'services.yaml')) + self.register_set_state() + self.register_effects() - self.register_set_state(descriptions) - self.register_effects(descriptions) - - def register_set_state(self, descriptions): + def register_set_state(self): """Register the LIFX set_state service call.""" @asyncio.coroutine def async_service_handle(service): @@ -231,10 +226,9 @@ class LIFXManager(object): self.hass.services.async_register( DOMAIN, SERVICE_LIFX_SET_STATE, async_service_handle, - descriptions.get(SERVICE_LIFX_SET_STATE), schema=LIFX_SET_STATE_SCHEMA) - def register_effects(self, descriptions): + def register_effects(self): """Register the LIFX effects as hass service calls.""" @asyncio.coroutine def async_service_handle(service): @@ -246,17 +240,14 @@ class LIFXManager(object): self.hass.services.async_register( DOMAIN, SERVICE_EFFECT_PULSE, async_service_handle, - descriptions.get(SERVICE_EFFECT_PULSE), schema=LIFX_EFFECT_PULSE_SCHEMA) self.hass.services.async_register( DOMAIN, SERVICE_EFFECT_COLORLOOP, async_service_handle, - descriptions.get(SERVICE_EFFECT_COLORLOOP), schema=LIFX_EFFECT_COLORLOOP_SCHEMA) self.hass.services.async_register( DOMAIN, SERVICE_EFFECT_STOP, async_service_handle, - descriptions.get(SERVICE_EFFECT_STOP), schema=LIFX_EFFECT_STOP_SCHEMA) @asyncio.coroutine diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py index a1ad3a83b50..80abce4ec3e 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -8,11 +8,9 @@ import asyncio from datetime import timedelta import functools as ft import logging -import os import voluptuous as vol -from homeassistant.config import load_yaml_config_file from homeassistant.loader import bind_hass from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity import Entity @@ -104,16 +102,12 @@ def async_setup(hass, config): if update_tasks: yield from asyncio.wait(update_tasks, loop=hass.loop) - descriptions = yield from hass.async_add_job( - load_yaml_config_file, os.path.join( - os.path.dirname(__file__), 'services.yaml')) - hass.services.async_register( DOMAIN, SERVICE_UNLOCK, async_handle_lock_service, - descriptions.get(SERVICE_UNLOCK), schema=LOCK_SERVICE_SCHEMA) + schema=LOCK_SERVICE_SCHEMA) hass.services.async_register( DOMAIN, SERVICE_LOCK, async_handle_lock_service, - descriptions.get(SERVICE_LOCK), schema=LOCK_SERVICE_SCHEMA) + schema=LOCK_SERVICE_SCHEMA) return True diff --git a/homeassistant/components/lock/nuki.py b/homeassistant/components/lock/nuki.py index b47305fa227..6efa3dcb80c 100644 --- a/homeassistant/components/lock/nuki.py +++ b/homeassistant/components/lock/nuki.py @@ -7,13 +7,11 @@ https://home-assistant.io/components/lock.nuki/ import asyncio from datetime import timedelta import logging -from os import path import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.lock import (DOMAIN, LockDevice, PLATFORM_SCHEMA) -from homeassistant.config import load_yaml_config_file from homeassistant.const import ( ATTR_ENTITY_ID, CONF_HOST, CONF_PORT, CONF_TOKEN) from homeassistant.helpers.service import extract_entity_ids @@ -75,15 +73,12 @@ def setup_platform(hass, config, add_devices, discovery_info=None): elif service.service == SERVICE_UNLATCH: lock.unlatch() - descriptions = load_yaml_config_file( - path.join(path.dirname(__file__), 'services.yaml')) - hass.services.register( DOMAIN, SERVICE_LOCK_N_GO, service_handler, - descriptions.get(SERVICE_LOCK_N_GO), schema=LOCK_N_GO_SERVICE_SCHEMA) + schema=LOCK_N_GO_SERVICE_SCHEMA) hass.services.register( DOMAIN, SERVICE_UNLATCH, service_handler, - descriptions.get(SERVICE_UNLATCH), schema=UNLATCH_SERVICE_SCHEMA) + schema=UNLATCH_SERVICE_SCHEMA) class NukiLock(LockDevice): diff --git a/homeassistant/components/lock/wink.py b/homeassistant/components/lock/wink.py index 502592ac6f3..118a8d8f664 100644 --- a/homeassistant/components/lock/wink.py +++ b/homeassistant/components/lock/wink.py @@ -6,7 +6,6 @@ https://home-assistant.io/components/lock.wink/ """ import asyncio import logging -from os import path import voluptuous as vol @@ -14,7 +13,6 @@ from homeassistant.components.lock import LockDevice from homeassistant.components.wink import WinkDevice, DOMAIN import homeassistant.helpers.config_validation as cv from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, ATTR_CODE -from homeassistant.config import load_yaml_config_file DEPENDENCIES = ['wink'] @@ -99,37 +97,28 @@ def setup_platform(hass, config, add_devices, discovery_info=None): code = service.data.get(ATTR_CODE) lock.add_new_key(code, name) - descriptions = load_yaml_config_file( - path.join(path.dirname(__file__), 'services.yaml')) - hass.services.register(DOMAIN, SERVICE_SET_VACATION_MODE, service_handle, - descriptions.get(SERVICE_SET_VACATION_MODE), schema=SET_ENABLED_SCHEMA) hass.services.register(DOMAIN, SERVICE_SET_ALARM_STATE, service_handle, - descriptions.get(SERVICE_SET_ALARM_STATE), schema=SET_ENABLED_SCHEMA) hass.services.register(DOMAIN, SERVICE_SET_BEEPER_STATE, service_handle, - descriptions.get(SERVICE_SET_BEEPER_STATE), schema=SET_ENABLED_SCHEMA) hass.services.register(DOMAIN, SERVICE_SET_ALARM_MODE, service_handle, - descriptions.get(SERVICE_SET_ALARM_MODE), schema=SET_ALARM_MODES_SCHEMA) hass.services.register(DOMAIN, SERVICE_SET_ALARM_SENSITIVITY, service_handle, - descriptions.get(SERVICE_SET_ALARM_SENSITIVITY), schema=SET_SENSITIVITY_SCHEMA) hass.services.register(DOMAIN, SERVICE_ADD_KEY, service_handle, - descriptions.get(SERVICE_ADD_KEY), schema=ADD_KEY_SCHEMA) diff --git a/homeassistant/components/lock/zwave.py b/homeassistant/components/lock/zwave.py index 009d4cf1069..c0560722966 100644 --- a/homeassistant/components/lock/zwave.py +++ b/homeassistant/components/lock/zwave.py @@ -8,13 +8,11 @@ https://home-assistant.io/components/lock.zwave/ # pylint: disable=import-error import asyncio import logging -from os import path import voluptuous as vol from homeassistant.components.lock import DOMAIN, LockDevice from homeassistant.components import zwave -from homeassistant.config import load_yaml_config_file import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -126,8 +124,6 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): yield from zwave.async_setup_platform( hass, config, async_add_devices, discovery_info) - descriptions = load_yaml_config_file( - path.join(path.dirname(__file__), 'services.yaml')) network = hass.data[zwave.const.DATA_NETWORK] def set_usercode(service): @@ -184,13 +180,13 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): hass.services.async_register( DOMAIN, SERVICE_SET_USERCODE, set_usercode, - descriptions.get(SERVICE_SET_USERCODE), schema=SET_USERCODE_SCHEMA) + schema=SET_USERCODE_SCHEMA) hass.services.async_register( DOMAIN, SERVICE_GET_USERCODE, get_usercode, - descriptions.get(SERVICE_GET_USERCODE), schema=GET_USERCODE_SCHEMA) + schema=GET_USERCODE_SCHEMA) hass.services.async_register( DOMAIN, SERVICE_CLEAR_USERCODE, clear_usercode, - descriptions.get(SERVICE_CLEAR_USERCODE), schema=CLEAR_USERCODE_SCHEMA) + schema=CLEAR_USERCODE_SCHEMA) def get_device(node, values, **kwargs): diff --git a/homeassistant/components/logger.py b/homeassistant/components/logger.py index 6b79bd40987..21898f7b16d 100644 --- a/homeassistant/components/logger.py +++ b/homeassistant/components/logger.py @@ -6,12 +6,10 @@ https://home-assistant.io/components/logger/ """ import asyncio import logging -import os from collections import OrderedDict import voluptuous as vol -from homeassistant.config import load_yaml_config_file import homeassistant.helpers.config_validation as cv DOMAIN = 'logger' @@ -123,13 +121,8 @@ def async_setup(hass, config): """Handle logger services.""" set_log_levels(service.data) - descriptions = yield from hass.async_add_job( - load_yaml_config_file, os.path.join( - os.path.dirname(__file__), 'services.yaml')) - hass.services.async_register( DOMAIN, SERVICE_SET_LEVEL, async_service_handler, - descriptions[DOMAIN].get(SERVICE_SET_LEVEL), schema=SERVICE_SET_LEVEL_SCHEMA) return True diff --git a/homeassistant/components/media_extractor.py b/homeassistant/components/media_extractor.py index 4b8522c62b3..645a418cf8c 100644 --- a/homeassistant/components/media_extractor.py +++ b/homeassistant/components/media_extractor.py @@ -5,7 +5,6 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/media_extractor/ """ import logging -import os import voluptuous as vol @@ -13,7 +12,6 @@ from homeassistant.components.media_player import ( ATTR_ENTITY_ID, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, DOMAIN as MEDIA_PLAYER_DOMAIN, MEDIA_PLAYER_PLAY_MEDIA_SCHEMA, SERVICE_PLAY_MEDIA) -from homeassistant.config import load_yaml_config_file from homeassistant.helpers import config_validation as cv REQUIREMENTS = ['youtube_dl==2017.12.28'] @@ -38,18 +36,11 @@ CONFIG_SCHEMA = vol.Schema({ def setup(hass, config): """Set up the media extractor service.""" - descriptions = load_yaml_config_file( - os.path.join(os.path.dirname(__file__), - 'media_player', 'services.yaml')) - def play_media(call): """Get stream URL and send it to the play_media service.""" MediaExtractor(hass, config[DOMAIN], call.data).extract_and_send() - hass.services.register(DOMAIN, - SERVICE_PLAY_MEDIA, - play_media, - description=descriptions[SERVICE_PLAY_MEDIA], + hass.services.register(DOMAIN, SERVICE_PLAY_MEDIA, play_media, schema=MEDIA_PLAYER_PLAY_MEDIA_SCHEMA) return True diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 89686c312bd..44e6810fd5d 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -10,7 +10,6 @@ import functools as ft import collections import hashlib import logging -import os from random import SystemRandom from aiohttp import web @@ -19,7 +18,6 @@ import async_timeout import voluptuous as vol from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView -from homeassistant.config import load_yaml_config_file from homeassistant.const import ( STATE_OFF, STATE_IDLE, STATE_PLAYING, STATE_UNKNOWN, ATTR_ENTITY_ID, SERVICE_TOGGLE, SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_VOLUME_UP, @@ -372,10 +370,6 @@ def async_setup(hass, config): yield from component.async_setup(config) - descriptions = yield from hass.async_add_job( - load_yaml_config_file, os.path.join( - os.path.dirname(__file__), 'services.yaml')) - @asyncio.coroutine def async_service_handler(service): """Map services to methods on MediaPlayerDevice.""" @@ -418,7 +412,7 @@ def async_setup(hass, config): 'schema', MEDIA_PLAYER_SCHEMA) hass.services.async_register( DOMAIN, service, async_service_handler, - descriptions.get(service), schema=schema) + schema=schema) return True diff --git a/homeassistant/components/media_player/kodi.py b/homeassistant/components/media_player/kodi.py index 00dd90938c8..bab6c88ec7e 100644 --- a/homeassistant/components/media_player/kodi.py +++ b/homeassistant/components/media_player/kodi.py @@ -10,12 +10,10 @@ from functools import wraps import logging import urllib import re -import os import aiohttp import voluptuous as vol -from homeassistant.config import load_yaml_config_file from homeassistant.components.media_player import ( SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, SUPPORT_PLAY_MEDIA, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_STOP, @@ -207,15 +205,11 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): if hass.services.has_service(DOMAIN, SERVICE_ADD_MEDIA): return - descriptions = yield from hass.async_add_job( - load_yaml_config_file, os.path.join( - os.path.dirname(__file__), 'services.yaml')) - for service in SERVICE_TO_METHOD: schema = SERVICE_TO_METHOD[service]['schema'] hass.services.async_register( DOMAIN, service, async_service_handler, - description=descriptions.get(service), schema=schema) + schema=schema) def cmd(func): diff --git a/homeassistant/components/media_player/monoprice.py b/homeassistant/components/media_player/monoprice.py index a2b5d91945a..c95ddcab97e 100644 --- a/homeassistant/components/media_player/monoprice.py +++ b/homeassistant/components/media_player/monoprice.py @@ -5,13 +5,11 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/media_player.monoprice/ """ import logging -from os import path import voluptuous as vol from homeassistant.const import (ATTR_ENTITY_ID, CONF_NAME, CONF_PORT, STATE_OFF, STATE_ON) -from homeassistant.config import load_yaml_config_file import homeassistant.helpers.config_validation as cv from homeassistant.components.media_player import ( DOMAIN, MediaPlayerDevice, MEDIA_PLAYER_SCHEMA, PLATFORM_SCHEMA, @@ -83,9 +81,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): add_devices(hass.data[DATA_MONOPRICE], True) - descriptions = load_yaml_config_file( - path.join(path.dirname(__file__), 'services.yaml')) - def service_handle(service): """Handle for services.""" entity_ids = service.data.get(ATTR_ENTITY_ID) @@ -104,11 +99,11 @@ def setup_platform(hass, config, add_devices, discovery_info=None): hass.services.register( DOMAIN, SERVICE_SNAPSHOT, service_handle, - descriptions.get(SERVICE_SNAPSHOT), schema=MEDIA_PLAYER_SCHEMA) + schema=MEDIA_PLAYER_SCHEMA) hass.services.register( DOMAIN, SERVICE_RESTORE, service_handle, - descriptions.get(SERVICE_RESTORE), schema=MEDIA_PLAYER_SCHEMA) + schema=MEDIA_PLAYER_SCHEMA) class MonopriceZone(MediaPlayerDevice): diff --git a/homeassistant/components/media_player/snapcast.py b/homeassistant/components/media_player/snapcast.py index 54015bec277..220f1691c52 100644 --- a/homeassistant/components/media_player/snapcast.py +++ b/homeassistant/components/media_player/snapcast.py @@ -6,7 +6,6 @@ https://home-assistant.io/components/media_player.snapcast/ """ import asyncio import logging -from os import path import socket import voluptuous as vol @@ -18,7 +17,6 @@ from homeassistant.const import ( STATE_ON, STATE_OFF, STATE_IDLE, STATE_PLAYING, STATE_UNKNOWN, CONF_HOST, CONF_PORT, ATTR_ENTITY_ID) import homeassistant.helpers.config_validation as cv -from homeassistant.config import load_yaml_config_file REQUIREMENTS = ['snapcast==2.0.8'] @@ -69,14 +67,12 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): elif service.service == SERVICE_RESTORE: yield from device.async_restore() - descriptions = load_yaml_config_file( - path.join(path.dirname(__file__), 'services.yaml')) hass.services.async_register( DOMAIN, SERVICE_SNAPSHOT, _handle_service, - descriptions.get(SERVICE_SNAPSHOT), schema=SERVICE_SCHEMA) + schema=SERVICE_SCHEMA) hass.services.async_register( DOMAIN, SERVICE_RESTORE, _handle_service, - descriptions.get(SERVICE_RESTORE), schema=SERVICE_SCHEMA) + schema=SERVICE_SCHEMA) try: server = yield from snapcast.control.create_server( diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py index 3bd3a722b46..0c6d380e81e 100644 --- a/homeassistant/components/media_player/sonos.py +++ b/homeassistant/components/media_player/sonos.py @@ -8,7 +8,6 @@ import asyncio import datetime import functools as ft import logging -from os import path import socket import urllib @@ -23,7 +22,6 @@ from homeassistant.components.media_player import ( from homeassistant.const import ( STATE_IDLE, STATE_PAUSED, STATE_PLAYING, STATE_OFF, ATTR_ENTITY_ID, CONF_HOSTS, ATTR_TIME) -from homeassistant.config import load_yaml_config_file import homeassistant.helpers.config_validation as cv from homeassistant.util.dt import utcnow @@ -171,9 +169,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): add_devices(slaves, True) _LOGGER.info("Added %s Sonos speakers", len(players)) - descriptions = load_yaml_config_file( - path.join(path.dirname(__file__), 'services.yaml')) - def service_handle(service): """Handle for services.""" entity_ids = service.data.get('entity_id') @@ -207,36 +202,34 @@ def setup_platform(hass, config, add_devices, discovery_info=None): hass.services.register( DOMAIN, SERVICE_JOIN, service_handle, - descriptions.get(SERVICE_JOIN), schema=SONOS_JOIN_SCHEMA) + schema=SONOS_JOIN_SCHEMA) hass.services.register( DOMAIN, SERVICE_UNJOIN, service_handle, - descriptions.get(SERVICE_UNJOIN), schema=SONOS_SCHEMA) + schema=SONOS_SCHEMA) hass.services.register( DOMAIN, SERVICE_SNAPSHOT, service_handle, - descriptions.get(SERVICE_SNAPSHOT), schema=SONOS_STATES_SCHEMA) + schema=SONOS_STATES_SCHEMA) hass.services.register( DOMAIN, SERVICE_RESTORE, service_handle, - descriptions.get(SERVICE_RESTORE), schema=SONOS_STATES_SCHEMA) + schema=SONOS_STATES_SCHEMA) hass.services.register( DOMAIN, SERVICE_SET_TIMER, service_handle, - descriptions.get(SERVICE_SET_TIMER), schema=SONOS_SET_TIMER_SCHEMA) + schema=SONOS_SET_TIMER_SCHEMA) hass.services.register( DOMAIN, SERVICE_CLEAR_TIMER, service_handle, - descriptions.get(SERVICE_CLEAR_TIMER), schema=SONOS_SCHEMA) + schema=SONOS_SCHEMA) hass.services.register( DOMAIN, SERVICE_UPDATE_ALARM, service_handle, - descriptions.get(SERVICE_UPDATE_ALARM), schema=SONOS_UPDATE_ALARM_SCHEMA) hass.services.register( DOMAIN, SERVICE_SET_OPTION, service_handle, - descriptions.get(SERVICE_SET_OPTION), schema=SONOS_SET_OPTION_SCHEMA) diff --git a/homeassistant/components/media_player/soundtouch.py b/homeassistant/components/media_player/soundtouch.py index c04d3b4d77f..790ad8b8e29 100644 --- a/homeassistant/components/media_player/soundtouch.py +++ b/homeassistant/components/media_player/soundtouch.py @@ -6,7 +6,6 @@ https://home-assistant.io/components/media_player.soundtouch/ """ import logging -from os import path import re import voluptuous as vol @@ -16,7 +15,6 @@ from homeassistant.components.media_player import ( SUPPORT_TURN_OFF, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, SUPPORT_VOLUME_SET, SUPPORT_TURN_ON, SUPPORT_PLAY, MediaPlayerDevice, PLATFORM_SCHEMA) -from homeassistant.config import load_yaml_config_file from homeassistant.const import (CONF_HOST, CONF_NAME, STATE_OFF, CONF_PORT, STATE_PAUSED, STATE_PLAYING, STATE_UNAVAILABLE) @@ -107,9 +105,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): hass.data[DATA_SOUNDTOUCH].append(soundtouch_device) add_devices([soundtouch_device]) - descriptions = load_yaml_config_file( - path.join(path.dirname(__file__), 'services.yaml')) - def service_handle(service): """Handle the applying of a service.""" master_device_id = service.data.get('master') @@ -140,19 +135,15 @@ def setup_platform(hass, config, add_devices, discovery_info=None): hass.services.register(DOMAIN, SERVICE_PLAY_EVERYWHERE, service_handle, - descriptions.get(SERVICE_PLAY_EVERYWHERE), schema=SOUNDTOUCH_PLAY_EVERYWHERE) hass.services.register(DOMAIN, SERVICE_CREATE_ZONE, service_handle, - descriptions.get(SERVICE_CREATE_ZONE), schema=SOUNDTOUCH_CREATE_ZONE_SCHEMA) hass.services.register(DOMAIN, SERVICE_REMOVE_ZONE_SLAVE, service_handle, - descriptions.get(SERVICE_REMOVE_ZONE_SLAVE), schema=SOUNDTOUCH_REMOVE_ZONE_SCHEMA) hass.services.register(DOMAIN, SERVICE_ADD_ZONE_SLAVE, service_handle, - descriptions.get(SERVICE_ADD_ZONE_SLAVE), schema=SOUNDTOUCH_ADD_ZONE_SCHEMA) diff --git a/homeassistant/components/microsoft_face.py b/homeassistant/components/microsoft_face.py index 49d79ccaea0..829c1124363 100644 --- a/homeassistant/components/microsoft_face.py +++ b/homeassistant/components/microsoft_face.py @@ -7,7 +7,6 @@ https://home-assistant.io/components/microsoft_face/ import asyncio import json import logging -import os import aiohttp from aiohttp.hdrs import CONTENT_TYPE @@ -15,7 +14,6 @@ import async_timeout import voluptuous as vol from homeassistant.const import CONF_API_KEY, CONF_TIMEOUT -from homeassistant.config import load_yaml_config_file from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -133,10 +131,6 @@ def async_setup(hass, config): hass.data[DATA_MICROSOFT_FACE] = face - descriptions = yield from hass.async_add_job( - load_yaml_config_file, - os.path.join(os.path.dirname(__file__), 'services.yaml')) - @asyncio.coroutine def async_create_group(service): """Create a new person group.""" @@ -155,7 +149,6 @@ def async_setup(hass, config): hass.services.async_register( DOMAIN, SERVICE_CREATE_GROUP, async_create_group, - descriptions[DOMAIN].get(SERVICE_CREATE_GROUP), schema=SCHEMA_GROUP_SERVICE) @asyncio.coroutine @@ -174,7 +167,6 @@ def async_setup(hass, config): hass.services.async_register( DOMAIN, SERVICE_DELETE_GROUP, async_delete_group, - descriptions[DOMAIN].get(SERVICE_DELETE_GROUP), schema=SCHEMA_GROUP_SERVICE) @asyncio.coroutine @@ -190,7 +182,6 @@ def async_setup(hass, config): hass.services.async_register( DOMAIN, SERVICE_TRAIN_GROUP, async_train_group, - descriptions[DOMAIN].get(SERVICE_TRAIN_GROUP), schema=SCHEMA_TRAIN_SERVICE) @asyncio.coroutine @@ -211,7 +202,6 @@ def async_setup(hass, config): hass.services.async_register( DOMAIN, SERVICE_CREATE_PERSON, async_create_person, - descriptions[DOMAIN].get(SERVICE_CREATE_PERSON), schema=SCHEMA_PERSON_SERVICE) @asyncio.coroutine @@ -232,7 +222,6 @@ def async_setup(hass, config): hass.services.async_register( DOMAIN, SERVICE_DELETE_PERSON, async_delete_person, - descriptions[DOMAIN].get(SERVICE_DELETE_PERSON), schema=SCHEMA_PERSON_SERVICE) @asyncio.coroutine @@ -259,7 +248,6 @@ def async_setup(hass, config): hass.services.async_register( DOMAIN, SERVICE_FACE_PERSON, async_face_person, - descriptions[DOMAIN].get(SERVICE_FACE_PERSON), schema=SCHEMA_FACE_SERVICE) return True diff --git a/homeassistant/components/modbus.py b/homeassistant/components/modbus.py index 293e86b014e..a928c0d3aca 100644 --- a/homeassistant/components/modbus.py +++ b/homeassistant/components/modbus.py @@ -6,12 +6,10 @@ https://home-assistant.io/components/modbus/ """ import logging import threading -import os import voluptuous as vol import homeassistant.helpers.config_validation as cv -from homeassistant.config import load_yaml_config_file from homeassistant.const import ( EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, CONF_HOST, CONF_METHOD, CONF_PORT, CONF_TYPE, CONF_TIMEOUT, ATTR_STATE) @@ -124,17 +122,12 @@ def setup(hass, config): HUB.connect() hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_modbus) - descriptions = load_yaml_config_file(os.path.join( - os.path.dirname(__file__), 'services.yaml')).get(DOMAIN) - # Register services for modbus hass.services.register( DOMAIN, SERVICE_WRITE_REGISTER, write_register, - descriptions.get(SERVICE_WRITE_REGISTER), schema=SERVICE_WRITE_REGISTER_SCHEMA) hass.services.register( DOMAIN, SERVICE_WRITE_COIL, write_coil, - descriptions.get(SERVICE_WRITE_COIL), schema=SERVICE_WRITE_COIL_SCHEMA) def write_register(service): diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index def89603b28..cdf59b92606 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -17,7 +17,6 @@ import voluptuous as vol from homeassistant.core import callback from homeassistant.setup import async_prepare_setup_platform -from homeassistant.config import load_yaml_config_file from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import bind_hass from homeassistant.helpers import template, config_validation as cv @@ -423,13 +422,9 @@ def async_setup(hass, config): yield from hass.data[DATA_MQTT].async_publish( msg_topic, payload, qos, retain) - descriptions = yield from hass.async_add_job( - load_yaml_config_file, os.path.join( - os.path.dirname(__file__), 'services.yaml')) - hass.services.async_register( DOMAIN, SERVICE_PUBLISH, async_publish_service, - descriptions.get(SERVICE_PUBLISH), schema=MQTT_PUBLISH_SCHEMA) + schema=MQTT_PUBLISH_SCHEMA) if conf.get(CONF_DISCOVERY): yield from _async_setup_discovery(hass, config) diff --git a/homeassistant/components/notify/__init__.py b/homeassistant/components/notify/__init__.py index 9496ff1d596..41198d1f296 100644 --- a/homeassistant/components/notify/__init__.py +++ b/homeassistant/components/notify/__init__.py @@ -6,7 +6,6 @@ https://home-assistant.io/components/notify/ """ import asyncio import logging -import os from functools import partial import voluptuous as vol @@ -15,7 +14,6 @@ from homeassistant.setup import async_prepare_setup_platform from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import bind_hass import homeassistant.helpers.config_validation as cv -from homeassistant.config import load_yaml_config_file from homeassistant.const import CONF_NAME, CONF_PLATFORM from homeassistant.helpers import config_per_platform, discovery from homeassistant.util import slugify @@ -71,10 +69,6 @@ def send_message(hass, message, title=None, data=None): @asyncio.coroutine def async_setup(hass, config): """Set up the notify services.""" - descriptions = yield from hass.async_add_job( - load_yaml_config_file, - os.path.join(os.path.dirname(__file__), 'services.yaml')) - targets = {} @asyncio.coroutine @@ -151,7 +145,6 @@ def async_setup(hass, config): targets[target_name] = target hass.services.async_register( DOMAIN, target_name, async_notify_message, - descriptions.get(SERVICE_NOTIFY), schema=NOTIFY_SERVICE_SCHEMA) platform_name = ( @@ -161,7 +154,7 @@ def async_setup(hass, config): hass.services.async_register( DOMAIN, platform_name_slug, async_notify_message, - descriptions.get(SERVICE_NOTIFY), schema=NOTIFY_SERVICE_SCHEMA) + schema=NOTIFY_SERVICE_SCHEMA) return True diff --git a/homeassistant/components/notify/apns.py b/homeassistant/components/notify/apns.py index f6f7cc71f14..6ef758b7bb5 100644 --- a/homeassistant/components/notify/apns.py +++ b/homeassistant/components/notify/apns.py @@ -45,9 +45,6 @@ REGISTER_SERVICE_SCHEMA = vol.Schema({ def get_service(hass, config, discovery_info=None): """Return push service.""" - descriptions = load_yaml_config_file( - os.path.join(os.path.dirname(__file__), 'services.yaml')) - name = config.get(CONF_NAME) cert_file = config.get(CONF_CERTFILE) topic = config.get(CONF_TOPIC) @@ -56,7 +53,7 @@ def get_service(hass, config, discovery_info=None): service = ApnsNotificationService(hass, name, topic, sandbox, cert_file) hass.services.register( DOMAIN, 'apns_{}'.format(name), service.register, - descriptions.get(SERVICE_REGISTER), schema=REGISTER_SERVICE_SCHEMA) + schema=REGISTER_SERVICE_SCHEMA) return service diff --git a/homeassistant/components/persistent_notification/__init__.py b/homeassistant/components/persistent_notification/__init__.py index aaba6e42de3..cce3550d35c 100644 --- a/homeassistant/components/persistent_notification/__init__.py +++ b/homeassistant/components/persistent_notification/__init__.py @@ -5,7 +5,6 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/persistent_notification/ """ import asyncio -import os import logging import voluptuous as vol @@ -16,7 +15,6 @@ from homeassistant.loader import bind_hass from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.util import slugify -from homeassistant.config import load_yaml_config_file ATTR_MESSAGE = 'message' ATTR_NOTIFICATION_ID = 'notification_id' @@ -127,17 +125,10 @@ def async_setup(hass, config): hass.states.async_remove(entity_id) - descriptions = yield from hass.async_add_job( - load_yaml_config_file, os.path.join( - os.path.dirname(__file__), 'services.yaml') - ) - hass.services.async_register(DOMAIN, SERVICE_CREATE, create_service, - descriptions[SERVICE_CREATE], SCHEMA_SERVICE_CREATE) hass.services.async_register(DOMAIN, SERVICE_DISMISS, dismiss_service, - descriptions[SERVICE_DISMISS], SCHEMA_SERVICE_DISMISS) return True diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 4dc38971e9f..51da2d470ea 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -10,7 +10,6 @@ https://home-assistant.io/components/recorder/ import asyncio import concurrent.futures import logging -from os import path import queue import threading import time @@ -30,7 +29,6 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entityfilter import generate_filter from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util -from homeassistant import config as conf_util from . import purge, migration from .const import DATA_INSTANCE @@ -142,13 +140,8 @@ def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Handle calls to the purge service.""" instance.do_adhoc_purge(service.data[ATTR_KEEP_DAYS]) - descriptions = yield from hass.async_add_job( - conf_util.load_yaml_config_file, path.join( - path.dirname(__file__), 'services.yaml')) - hass.services.async_register(DOMAIN, SERVICE_PURGE, async_handle_purge_service, - descriptions.get(SERVICE_PURGE), schema=SERVICE_PURGE_SCHEMA) return (yield from instance.async_db_ready) diff --git a/homeassistant/components/remember_the_milk/__init__.py b/homeassistant/components/remember_the_milk/__init__.py index aa3ca4b4543..08c371fcf0a 100644 --- a/homeassistant/components/remember_the_milk/__init__.py +++ b/homeassistant/components/remember_the_milk/__init__.py @@ -14,7 +14,6 @@ import os import json import voluptuous as vol -from homeassistant.config import load_yaml_config_file from homeassistant.const import (CONF_API_KEY, STATE_OK, CONF_TOKEN, CONF_NAME, CONF_ID) import homeassistant.helpers.config_validation as cv @@ -66,9 +65,6 @@ def setup(hass, config): component = EntityComponent(_LOGGER, DOMAIN, hass, group_name=GROUP_NAME_RTM) - descriptions = load_yaml_config_file( - os.path.join(os.path.dirname(__file__), 'services.yaml')) - stored_rtm_config = RememberTheMilkConfiguration(hass) for rtm_config in config[DOMAIN]: account_name = rtm_config[CONF_NAME] @@ -80,33 +76,31 @@ def setup(hass, config): _LOGGER.debug("found token for account %s", account_name) _create_instance( hass, account_name, api_key, shared_secret, token, - stored_rtm_config, component, descriptions) + stored_rtm_config, component) else: _register_new_account( hass, account_name, api_key, shared_secret, - stored_rtm_config, component, descriptions) + stored_rtm_config, component) _LOGGER.debug("Finished adding all Remember the milk accounts") return True def _create_instance(hass, account_name, api_key, shared_secret, - token, stored_rtm_config, component, descriptions): + token, stored_rtm_config, component): entity = RememberTheMilk(account_name, api_key, shared_secret, token, stored_rtm_config) component.add_entity(entity) hass.services.register( DOMAIN, '{}_create_task'.format(account_name), entity.create_task, - description=descriptions.get(SERVICE_CREATE_TASK), schema=SERVICE_SCHEMA_CREATE_TASK) hass.services.register( DOMAIN, '{}_complete_task'.format(account_name), entity.complete_task, - description=descriptions.get(SERVICE_COMPLETE_TASK), schema=SERVICE_SCHEMA_COMPLETE_TASK) def _register_new_account(hass, account_name, api_key, shared_secret, - stored_rtm_config, component, descriptions): + stored_rtm_config, component): from rtmapi import Rtm request_id = None @@ -131,7 +125,7 @@ def _register_new_account(hass, account_name, api_key, shared_secret, _create_instance( hass, account_name, api_key, shared_secret, token, - stored_rtm_config, component, descriptions) + stored_rtm_config, component) configurator.request_done(request_id) diff --git a/homeassistant/components/remote/__init__.py b/homeassistant/components/remote/__init__.py index 3f1086c46c7..ddae36b92a7 100755 --- a/homeassistant/components/remote/__init__.py +++ b/homeassistant/components/remote/__init__.py @@ -8,11 +8,9 @@ import asyncio from datetime import timedelta import functools as ft import logging -import os import voluptuous as vol -from homeassistant.config import load_yaml_config_file from homeassistant.loader import bind_hass from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity import ToggleEntity @@ -160,24 +158,17 @@ def async_setup(hass, config): if update_tasks: yield from asyncio.wait(update_tasks, loop=hass.loop) - descriptions = yield from hass.async_add_job( - load_yaml_config_file, os.path.join( - os.path.dirname(__file__), 'services.yaml')) hass.services.async_register( DOMAIN, SERVICE_TURN_OFF, async_handle_remote_service, - descriptions.get(SERVICE_TURN_OFF), schema=REMOTE_SERVICE_ACTIVITY_SCHEMA) hass.services.async_register( DOMAIN, SERVICE_TURN_ON, async_handle_remote_service, - descriptions.get(SERVICE_TURN_ON), schema=REMOTE_SERVICE_ACTIVITY_SCHEMA) hass.services.async_register( DOMAIN, SERVICE_TOGGLE, async_handle_remote_service, - descriptions.get(SERVICE_TOGGLE), schema=REMOTE_SERVICE_ACTIVITY_SCHEMA) hass.services.async_register( DOMAIN, SERVICE_SEND_COMMAND, async_handle_remote_service, - descriptions.get(SERVICE_SEND_COMMAND), schema=REMOTE_SERVICE_SEND_COMMAND_SCHEMA) return True diff --git a/homeassistant/components/remote/harmony.py b/homeassistant/components/remote/harmony.py index 40536a83602..4d241ed5913 100755 --- a/homeassistant/components/remote/harmony.py +++ b/homeassistant/components/remote/harmony.py @@ -6,7 +6,6 @@ https://home-assistant.io/components/remote.harmony/ """ import logging import asyncio -from os import path import time import voluptuous as vol @@ -19,7 +18,6 @@ from homeassistant.components.remote import ( PLATFORM_SCHEMA, DOMAIN, ATTR_DEVICE, ATTR_ACTIVITY, ATTR_NUM_REPEATS, ATTR_DELAY_SECS, DEFAULT_DELAY_SECS) from homeassistant.util import slugify -from homeassistant.config import load_yaml_config_file REQUIREMENTS = ['pyharmony==1.0.18'] @@ -105,11 +103,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): def register_services(hass): """Register all services for harmony devices.""" - descriptions = load_yaml_config_file( - path.join(path.dirname(__file__), 'services.yaml')) - hass.services.register( - DOMAIN, SERVICE_SYNC, _sync_service, descriptions.get(SERVICE_SYNC), + DOMAIN, SERVICE_SYNC, _sync_service, schema=HARMONY_SYNC_SCHEMA) diff --git a/homeassistant/components/rflink.py b/homeassistant/components/rflink.py index 5045017790e..73922d56040 100644 --- a/homeassistant/components/rflink.py +++ b/homeassistant/components/rflink.py @@ -8,11 +8,9 @@ import asyncio from collections import defaultdict import functools as ft import logging -import os import async_timeout -from homeassistant.config import load_yaml_config_file from homeassistant.const import ( ATTR_ENTITY_ID, CONF_COMMAND, CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP, STATE_UNKNOWN) @@ -132,14 +130,9 @@ def async_setup(hass, config): call.data.get(CONF_COMMAND))): _LOGGER.error('Failed Rflink command for %s', str(call.data)) - descriptions = yield from hass.async_add_job( - load_yaml_config_file, os.path.join( - os.path.dirname(__file__), 'services.yaml') - ) - hass.services.async_register( DOMAIN, SERVICE_SEND_COMMAND, async_send_command, - descriptions[DOMAIN][SERVICE_SEND_COMMAND], SEND_COMMAND_SCHEMA) + schema=SEND_COMMAND_SCHEMA) @callback def event_callback(event): diff --git a/homeassistant/components/scene/services.yaml b/homeassistant/components/scene/services.yaml new file mode 100644 index 00000000000..ee255affe44 --- /dev/null +++ b/homeassistant/components/scene/services.yaml @@ -0,0 +1,8 @@ +# Describes the format for available scene services + +turn_on: + description: Activate a scene. + fields: + entity_id: + description: Name(s) of scenes to turn on + example: 'scene.romantic' diff --git a/homeassistant/components/switch/__init__.py b/homeassistant/components/switch/__init__.py index 5bfea4eff0e..66a416c5bea 100644 --- a/homeassistant/components/switch/__init__.py +++ b/homeassistant/components/switch/__init__.py @@ -7,12 +7,10 @@ at https://home-assistant.io/components/switch/ import asyncio from datetime import timedelta import logging -import os import voluptuous as vol from homeassistant.core import callback -from homeassistant.config import load_yaml_config_file from homeassistant.loader import bind_hass from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity import ToggleEntity @@ -123,19 +121,15 @@ def async_setup(hass, config): if update_tasks: yield from asyncio.wait(update_tasks, loop=hass.loop) - descriptions = yield from hass.async_add_job( - load_yaml_config_file, os.path.join( - os.path.dirname(__file__), 'services.yaml')) - hass.services.async_register( DOMAIN, SERVICE_TURN_OFF, async_handle_switch_service, - descriptions.get(SERVICE_TURN_OFF), schema=SWITCH_SERVICE_SCHEMA) + schema=SWITCH_SERVICE_SCHEMA) hass.services.async_register( DOMAIN, SERVICE_TURN_ON, async_handle_switch_service, - descriptions.get(SERVICE_TURN_ON), schema=SWITCH_SERVICE_SCHEMA) + schema=SWITCH_SERVICE_SCHEMA) hass.services.async_register( DOMAIN, SERVICE_TOGGLE, async_handle_switch_service, - descriptions.get(SERVICE_TOGGLE), schema=SWITCH_SERVICE_SCHEMA) + schema=SWITCH_SERVICE_SCHEMA) return True diff --git a/homeassistant/components/switch/mysensors.py b/homeassistant/components/switch/mysensors.py index 131ec58ae67..51184859fc6 100644 --- a/homeassistant/components/switch/mysensors.py +++ b/homeassistant/components/switch/mysensors.py @@ -4,14 +4,11 @@ Support for MySensors switches. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/switch.mysensors/ """ -import os - import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components import mysensors from homeassistant.components.switch import DOMAIN, SwitchDevice -from homeassistant.config import load_yaml_config_file from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON ATTR_IR_CODE = 'V_IR_SEND' @@ -62,12 +59,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): for device in _devices: device.turn_on(**kwargs) - descriptions = load_yaml_config_file( - os.path.join(os.path.dirname(__file__), 'services.yaml')) - hass.services.register(DOMAIN, SERVICE_SEND_IR_CODE, send_ir_code_service, - descriptions.get(SERVICE_SEND_IR_CODE), schema=SEND_IR_CODE_SERVICE_SCHEMA) diff --git a/homeassistant/components/system_log/__init__.py b/homeassistant/components/system_log/__init__.py index 60f707b1e33..d25f32eacc7 100644 --- a/homeassistant/components/system_log/__init__.py +++ b/homeassistant/components/system_log/__init__.py @@ -4,7 +4,6 @@ Support for system log. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/system_log/ """ -import os import re import asyncio import logging @@ -15,7 +14,6 @@ from collections import deque import voluptuous as vol from homeassistant import __path__ as HOMEASSISTANT_PATH -from homeassistant.config import load_yaml_config_file import homeassistant.helpers.config_validation as cv from homeassistant.components.http import HomeAssistantView @@ -84,13 +82,8 @@ def async_setup(hass, config): # Only one service so far handler.records.clear() - descriptions = yield from hass.async_add_job( - load_yaml_config_file, os.path.join( - os.path.dirname(__file__), 'services.yaml')) - hass.services.async_register( DOMAIN, SERVICE_CLEAR, async_service_handler, - descriptions[DOMAIN].get(SERVICE_CLEAR), schema=SERVICE_CLEAR_SCHEMA) return True diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 1e4d1d27042..cb314c4a2b4 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -8,7 +8,6 @@ import asyncio import io from functools import partial import logging -import os import requests from requests.auth import HTTPBasicAuth, HTTPDigestAuth @@ -16,7 +15,6 @@ import voluptuous as vol from homeassistant.components.notify import ( ATTR_DATA, ATTR_MESSAGE, ATTR_TITLE) -from homeassistant.config import load_yaml_config_file from homeassistant.const import ( ATTR_COMMAND, ATTR_LATITUDE, ATTR_LONGITUDE, CONF_API_KEY, CONF_PLATFORM, CONF_TIMEOUT, HTTP_DIGEST_AUTHENTICATION) @@ -216,9 +214,6 @@ def async_setup(hass, config): return False p_config = config[DOMAIN][0] - descriptions = yield from hass.async_add_job( - load_yaml_config_file, - os.path.join(os.path.dirname(__file__), 'services.yaml')) p_type = p_config.get(CONF_PLATFORM) @@ -301,7 +296,7 @@ def async_setup(hass, config): for service_notif, schema in SERVICE_MAP.items(): hass.services.async_register( DOMAIN, service_notif, async_send_telegram_message, - descriptions.get(service_notif), schema=schema) + schema=schema) return True diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py index ec3429e0498..84d2d3f349d 100644 --- a/homeassistant/components/timer/__init__.py +++ b/homeassistant/components/timer/__init__.py @@ -6,14 +6,12 @@ at https://home-assistant.io/components/timer/ """ import asyncio import logging -import os from datetime import timedelta import voluptuous as vol import homeassistant.util.dt as dt_util import homeassistant.helpers.config_validation as cv -from homeassistant.config import load_yaml_config_file from homeassistant.const import (ATTR_ENTITY_ID, CONF_ICON, CONF_NAME) from homeassistant.core import callback from homeassistant.helpers.entity import Entity @@ -166,23 +164,18 @@ def async_setup(hass, config): if tasks: yield from asyncio.wait(tasks, loop=hass.loop) - descriptions = yield from hass.async_add_job( - load_yaml_config_file, - os.path.join(os.path.dirname(__file__), 'services.yaml') - ) - hass.services.async_register( DOMAIN, SERVICE_START, async_handler_service, - descriptions[SERVICE_START], SERVICE_SCHEMA_DURATION) + schema=SERVICE_SCHEMA_DURATION) hass.services.async_register( DOMAIN, SERVICE_PAUSE, async_handler_service, - descriptions[SERVICE_PAUSE], SERVICE_SCHEMA) + schema=SERVICE_SCHEMA) hass.services.async_register( DOMAIN, SERVICE_CANCEL, async_handler_service, - descriptions[SERVICE_CANCEL], SERVICE_SCHEMA) + schema=SERVICE_SCHEMA) hass.services.async_register( DOMAIN, SERVICE_FINISH, async_handler_service, - descriptions[SERVICE_FINISH], SERVICE_SCHEMA) + schema=SERVICE_SCHEMA) yield from component.async_add_entities(entities) return True diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index a7416bba117..d85b7d189c5 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -22,7 +22,6 @@ from homeassistant.components.media_player import ( ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, MEDIA_TYPE_MUSIC, SERVICE_PLAY_MEDIA) from homeassistant.components.media_player import DOMAIN as DOMAIN_MP -from homeassistant.config import load_yaml_config_file from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError @@ -96,10 +95,6 @@ def async_setup(hass, config): hass.http.register_view(TextToSpeechView(tts)) - descriptions = yield from hass.async_add_job( - load_yaml_config_file, - os.path.join(os.path.dirname(__file__), 'services.yaml')) - @asyncio.coroutine def async_setup_platform(p_type, p_config, disc_info=None): """Set up a TTS platform.""" @@ -156,7 +151,7 @@ def async_setup(hass, config): hass.services.async_register( DOMAIN, "{}_{}".format(p_type, SERVICE_SAY), async_say_handle, - descriptions.get(SERVICE_SAY), schema=SCHEMA_SERVICE_SAY) + schema=SCHEMA_SERVICE_SAY) setup_tasks = [async_setup_platform(p_type, p_config) for p_type, p_config in config_per_platform(config, DOMAIN)] @@ -171,7 +166,6 @@ def async_setup(hass, config): hass.services.async_register( DOMAIN, SERVICE_CLEAR_CACHE, async_clear_cache_handle, - descriptions.get(SERVICE_CLEAR_CACHE), schema=SCHEMA_SERVICE_CLEAR_CACHE) return True diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index 32839c08115..095e8bfb124 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -8,12 +8,10 @@ import asyncio from datetime import timedelta from functools import partial import logging -import os import voluptuous as vol from homeassistant.components import group -from homeassistant.config import load_yaml_config_file from homeassistant.const import ( ATTR_BATTERY_LEVEL, ATTR_COMMAND, ATTR_ENTITY_ID, SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_ON) @@ -183,10 +181,6 @@ def async_setup(hass, config): yield from component.async_setup(config) - descriptions = yield from hass.async_add_job( - load_yaml_config_file, os.path.join( - os.path.dirname(__file__), 'services.yaml')) - @asyncio.coroutine def async_handle_vacuum_service(service): """Map services to methods on VacuumDevice.""" @@ -210,7 +204,7 @@ def async_setup(hass, config): 'schema', VACUUM_SERVICE_SCHEMA) hass.services.async_register( DOMAIN, service, async_handle_vacuum_service, - descriptions.get(service), schema=schema) + schema=schema) return True diff --git a/homeassistant/components/vacuum/xiaomi_miio.py b/homeassistant/components/vacuum/xiaomi_miio.py index 3fc000f8027..294d4db9900 100644 --- a/homeassistant/components/vacuum/xiaomi_miio.py +++ b/homeassistant/components/vacuum/xiaomi_miio.py @@ -7,7 +7,6 @@ https://home-assistant.io/components/vacuum.xiaomi_miio/ import asyncio from functools import partial import logging -import os import voluptuous as vol @@ -16,7 +15,6 @@ from homeassistant.components.vacuum import ( SUPPORT_CLEAN_SPOT, SUPPORT_FAN_SPEED, SUPPORT_LOCATE, SUPPORT_PAUSE, SUPPORT_RETURN_HOME, SUPPORT_SEND_COMMAND, SUPPORT_STATUS, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, VACUUM_SERVICE_SCHEMA, VacuumDevice) -from homeassistant.config import load_yaml_config_file from homeassistant.const import ( ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, CONF_TOKEN, STATE_OFF, STATE_ON) import homeassistant.helpers.config_validation as cv @@ -130,16 +128,12 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): if update_tasks: yield from asyncio.wait(update_tasks, loop=hass.loop) - descriptions = yield from hass.async_add_job( - load_yaml_config_file, os.path.join( - os.path.dirname(__file__), 'services.yaml')) - for vacuum_service in SERVICE_TO_METHOD: schema = SERVICE_TO_METHOD[vacuum_service].get( 'schema', VACUUM_SERVICE_SCHEMA) hass.services.async_register( DOMAIN, vacuum_service, async_service_handler, - description=descriptions.get(vacuum_service), schema=schema) + schema=schema) class MiroboVacuum(VacuumDevice): diff --git a/homeassistant/components/verisure.py b/homeassistant/components/verisure.py index 94f712896cc..b367752c247 100644 --- a/homeassistant/components/verisure.py +++ b/homeassistant/components/verisure.py @@ -6,7 +6,6 @@ https://home-assistant.io/components/verisure/ """ import logging import threading -import os.path from datetime import timedelta import voluptuous as vol @@ -15,7 +14,6 @@ from homeassistant.const import (CONF_PASSWORD, CONF_USERNAME, EVENT_HOMEASSISTANT_STOP) from homeassistant.helpers import discovery from homeassistant.util import Throttle -import homeassistant.config as conf_util import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['vsure==1.3.7', 'jsonpath==0.75'] @@ -78,9 +76,6 @@ def setup(hass, config): 'camera', 'binary_sensor'): discovery.load_platform(hass, component, DOMAIN, {}, config) - descriptions = conf_util.load_yaml_config_file( - os.path.join(os.path.dirname(__file__), 'services.yaml')) - def capture_smartcam(service): """Capture a new picture from a smartcam.""" device_id = service.data.get(ATTR_DEVICE_SERIAL) @@ -89,7 +84,6 @@ def setup(hass, config): hass.services.register(DOMAIN, SERVICE_CAPTURE_SMARTCAM, capture_smartcam, - descriptions[DOMAIN][SERVICE_CAPTURE_SMARTCAM], schema=CAPTURE_IMAGE_SCHEMA) return True diff --git a/homeassistant/components/wake_on_lan.py b/homeassistant/components/wake_on_lan.py index ab72aa989d7..7da0f3054f3 100644 --- a/homeassistant/components/wake_on_lan.py +++ b/homeassistant/components/wake_on_lan.py @@ -7,11 +7,9 @@ https://home-assistant.io/components/wake_on_lan/ import asyncio from functools import partial import logging -import os import voluptuous as vol -from homeassistant.config import load_yaml_config_file from homeassistant.const import CONF_MAC import homeassistant.helpers.config_validation as cv @@ -50,13 +48,8 @@ def async_setup(hass, config): yield from hass.async_add_job( partial(wol.send_magic_packet, mac_address)) - descriptions = yield from hass.async_add_job( - load_yaml_config_file, os.path.join( - os.path.dirname(__file__), 'services.yaml')) - hass.services.async_register( DOMAIN, SERVICE_SEND_MAGIC_PACKET, send_magic_packet, - description=descriptions.get(DOMAIN).get(SERVICE_SEND_MAGIC_PACKET), schema=WAKE_ON_LAN_SEND_MAGIC_PACKET_SCHEMA) return True diff --git a/homeassistant/components/websocket_api.py b/homeassistant/components/websocket_api.py index f76bcaca2f8..a4bfc46bf83 100644 --- a/homeassistant/components/websocket_api.py +++ b/homeassistant/components/websocket_api.py @@ -21,6 +21,7 @@ from homeassistant.components import frontend from homeassistant.core import callback from homeassistant.remote import JSONEncoder from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.service import async_get_all_descriptions from homeassistant.components.http import HomeAssistantView from homeassistant.components.http.auth import validate_password from homeassistant.components.http.const import KEY_AUTHENTICATED @@ -436,7 +437,7 @@ class ActiveConnection: def handle_call_service(self, msg): """Handle call service command. - This is a coroutine. + Async friendly. """ msg = CALL_SERVICE_MESSAGE_SCHEMA(msg) @@ -466,8 +467,13 @@ class ActiveConnection: """ msg = GET_SERVICES_MESSAGE_SCHEMA(msg) - self.to_write.put_nowait(result_message( - msg['id'], self.hass.services.async_services())) + @asyncio.coroutine + def get_services_helper(msg): + """Get available services and fire complete message.""" + descriptions = yield from async_get_all_descriptions(self.hass) + self.send_message_outside(result_message(msg['id'], descriptions)) + + self.hass.async_add_job(get_services_helper(msg)) def handle_get_config(self, msg): """Handle get config command. diff --git a/homeassistant/components/wink/__init__.py b/homeassistant/components/wink/__init__.py index 18e14b2e912..c903b5a0ddf 100644 --- a/homeassistant/components/wink/__init__.py +++ b/homeassistant/components/wink/__init__.py @@ -25,7 +25,6 @@ from homeassistant.const import ( from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent import homeassistant.helpers.config_validation as cv -from homeassistant.config import load_yaml_config_file from homeassistant.util.json import load_json, save_json REQUIREMENTS = ['python-wink==1.7.1', 'pubnubsub-handler==1.0.2'] @@ -232,9 +231,6 @@ def setup(hass, config): import pywink from pubnubsubhandler import PubNubSubscriptionHandler - descriptions = load_yaml_config_file( - os.path.join(os.path.dirname(__file__), 'services.yaml')) - if hass.data.get(DOMAIN) is None: hass.data[DOMAIN] = { 'unique_ids': [], @@ -374,8 +370,7 @@ def setup(hass, config): time.sleep(1) entity.schedule_update_ha_state(True) - hass.services.register(DOMAIN, SERVICE_REFRESH_STATES, force_update, - descriptions.get(SERVICE_REFRESH_STATES)) + hass.services.register(DOMAIN, SERVICE_REFRESH_STATES, force_update) def pull_new_devices(call): """Pull new devices added to users Wink account since startup.""" @@ -383,8 +378,7 @@ def setup(hass, config): for _component in WINK_COMPONENTS: discovery.load_platform(hass, _component, DOMAIN, {}, config) - hass.services.register(DOMAIN, SERVICE_ADD_NEW_DEVICES, pull_new_devices, - descriptions.get(SERVICE_ADD_NEW_DEVICES)) + hass.services.register(DOMAIN, SERVICE_ADD_NEW_DEVICES, pull_new_devices) def set_pairing_mode(call): """Put the hub in provided pairing mode.""" @@ -412,7 +406,6 @@ def setup(hass, config): found_device.wink.set_name(name) hass.services.register(DOMAIN, SERVICE_RENAME_DEVICE, rename_device, - descriptions.get(SERVICE_RENAME_DEVICE), schema=RENAME_DEVICE_SCHEMA) def delete_device(call): @@ -430,7 +423,6 @@ def setup(hass, config): found_device.wink.remove_device() hass.services.register(DOMAIN, SERVICE_DELETE_DEVICE, delete_device, - descriptions.get(SERVICE_DELETE_DEVICE), schema=DELETE_DEVICE_SCHEMA) hubs = pywink.get_hubs() @@ -441,7 +433,6 @@ def setup(hass, config): if WINK_HUBS: hass.services.register( DOMAIN, SERVICE_SET_PAIRING_MODE, set_pairing_mode, - descriptions.get(SERVICE_SET_PAIRING_MODE), schema=SET_PAIRING_MODE_SCHEMA) def service_handle(service): @@ -508,44 +499,36 @@ def setup(hass, config): hass.services.register(DOMAIN, SERVICE_SET_AUTO_SHUTOFF, service_handle, - descriptions.get(SERVICE_SET_AUTO_SHUTOFF), schema=SET_AUTO_SHUTOFF_SCHEMA) hass.services.register(DOMAIN, SERVICE_ENABLE_SIREN, service_handle, - descriptions.get(SERVICE_ENABLE_SIREN), schema=ENABLED_SIREN_SCHEMA) if has_dome_or_wink_siren: hass.services.register(DOMAIN, SERVICE_SET_SIREN_TONE, service_handle, - descriptions.get(SERVICE_SET_SIREN_TONE), schema=SET_SIREN_TONE_SCHEMA) hass.services.register(DOMAIN, SERVICE_ENABLE_CHIME, service_handle, - descriptions.get(SERVICE_ENABLE_CHIME), schema=SET_CHIME_MODE_SCHEMA) hass.services.register(DOMAIN, SERVICE_SET_SIREN_VOLUME, service_handle, - descriptions.get(SERVICE_SET_SIREN_VOLUME), schema=SET_VOLUME_SCHEMA) hass.services.register(DOMAIN, SERVICE_SET_CHIME_VOLUME, service_handle, - descriptions.get(SERVICE_SET_CHIME_VOLUME), schema=SET_VOLUME_SCHEMA) hass.services.register(DOMAIN, SERVICE_SIREN_STROBE_ENABLED, service_handle, - descriptions.get(SERVICE_SIREN_STROBE_ENABLED), schema=SET_STROBE_ENABLED_SCHEMA) hass.services.register(DOMAIN, SERVICE_CHIME_STROBE_ENABLED, service_handle, - descriptions.get(SERVICE_CHIME_STROBE_ENABLED), schema=SET_STROBE_ENABLED_SCHEMA) component.add_entities(sirens) diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 3cd9446dc4f..a361fca9832 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -41,17 +41,6 @@ CONFIG_SCHEMA = vol.Schema({ ATTR_DURATION = 'duration' SERVICE_PERMIT = 'permit' -SERVICE_DESCRIPTIONS = { - SERVICE_PERMIT: { - "description": "Allow nodes to join the ZigBee network", - "fields": { - ATTR_DURATION: { - "description": "Time to permit joins, in seconds", - "example": "60", - }, - }, - }, -} SERVICE_SCHEMAS = { SERVICE_PERMIT: vol.Schema({ vol.Optional(ATTR_DURATION, default=60): @@ -103,8 +92,7 @@ def async_setup(hass, config): yield from APPLICATION_CONTROLLER.permit(duration) hass.services.async_register(DOMAIN, SERVICE_PERMIT, permit, - SERVICE_DESCRIPTIONS[SERVICE_PERMIT], - SERVICE_SCHEMAS[SERVICE_PERMIT]) + schema=SERVICE_SCHEMAS[SERVICE_PERMIT]) return True diff --git a/homeassistant/components/zha/services.yaml b/homeassistant/components/zha/services.yaml new file mode 100644 index 00000000000..a9ad0e7a1ca --- /dev/null +++ b/homeassistant/components/zha/services.yaml @@ -0,0 +1,8 @@ +# Describes the format for available zha services + +permit: + description: Allow nodes to join the ZigBee network. + fields: + duration: + description: Time to permit joins, in seconds + example: 60 diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index 2faeccde154..cacdb4873e6 100755 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -7,7 +7,6 @@ https://home-assistant.io/components/zwave/ import asyncio import copy import logging -import os.path import time from pprint import pprint @@ -23,7 +22,6 @@ from homeassistant.const import ( from homeassistant.helpers.entity_values import EntityValues from homeassistant.helpers.event import track_time_change from homeassistant.util import convert, slugify -import homeassistant.config as conf_util import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send) @@ -249,9 +247,6 @@ def setup(hass, config): Will automatically load components to support devices found on the network. """ - descriptions = conf_util.load_yaml_config_file( - os.path.join(os.path.dirname(__file__), 'services.yaml')) - from pydispatch import dispatcher # pylint: disable=import-error from openzwave.option import ZWaveOption @@ -627,99 +622,65 @@ def setup(hass, config): hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_network) # Register node services for Z-Wave network - hass.services.register(DOMAIN, const.SERVICE_ADD_NODE, add_node, - descriptions[const.SERVICE_ADD_NODE]) + hass.services.register(DOMAIN, const.SERVICE_ADD_NODE, add_node) hass.services.register(DOMAIN, const.SERVICE_ADD_NODE_SECURE, - add_node_secure, - descriptions[const.SERVICE_ADD_NODE_SECURE]) - hass.services.register(DOMAIN, const.SERVICE_REMOVE_NODE, remove_node, - descriptions[const.SERVICE_REMOVE_NODE]) + add_node_secure) + hass.services.register(DOMAIN, const.SERVICE_REMOVE_NODE, remove_node) hass.services.register(DOMAIN, const.SERVICE_CANCEL_COMMAND, - cancel_command, - descriptions[const.SERVICE_CANCEL_COMMAND]) + cancel_command) hass.services.register(DOMAIN, const.SERVICE_HEAL_NETWORK, - heal_network, - descriptions[const.SERVICE_HEAL_NETWORK]) - hass.services.register(DOMAIN, const.SERVICE_SOFT_RESET, soft_reset, - descriptions[const.SERVICE_SOFT_RESET]) + heal_network) + hass.services.register(DOMAIN, const.SERVICE_SOFT_RESET, soft_reset) hass.services.register(DOMAIN, const.SERVICE_TEST_NETWORK, - test_network, - descriptions[const.SERVICE_TEST_NETWORK]) + test_network) hass.services.register(DOMAIN, const.SERVICE_STOP_NETWORK, - stop_network, - descriptions[const.SERVICE_STOP_NETWORK]) + stop_network) hass.services.register(DOMAIN, const.SERVICE_START_NETWORK, - start_zwave, - descriptions[const.SERVICE_START_NETWORK]) + start_zwave) hass.services.register(DOMAIN, const.SERVICE_RENAME_NODE, rename_node, - descriptions[const.SERVICE_RENAME_NODE], schema=RENAME_NODE_SCHEMA) hass.services.register(DOMAIN, const.SERVICE_RENAME_VALUE, rename_value, - descriptions[const.SERVICE_RENAME_VALUE], schema=RENAME_VALUE_SCHEMA) hass.services.register(DOMAIN, const.SERVICE_SET_CONFIG_PARAMETER, set_config_parameter, - descriptions[ - const.SERVICE_SET_CONFIG_PARAMETER], schema=SET_CONFIG_PARAMETER_SCHEMA) hass.services.register(DOMAIN, const.SERVICE_PRINT_CONFIG_PARAMETER, print_config_parameter, - descriptions[ - const.SERVICE_PRINT_CONFIG_PARAMETER], schema=PRINT_CONFIG_PARAMETER_SCHEMA) hass.services.register(DOMAIN, const.SERVICE_REMOVE_FAILED_NODE, remove_failed_node, - descriptions[const.SERVICE_REMOVE_FAILED_NODE], schema=NODE_SERVICE_SCHEMA) hass.services.register(DOMAIN, const.SERVICE_REPLACE_FAILED_NODE, replace_failed_node, - descriptions[const.SERVICE_REPLACE_FAILED_NODE], schema=NODE_SERVICE_SCHEMA) hass.services.register(DOMAIN, const.SERVICE_CHANGE_ASSOCIATION, change_association, - descriptions[ - const.SERVICE_CHANGE_ASSOCIATION], schema=CHANGE_ASSOCIATION_SCHEMA) hass.services.register(DOMAIN, const.SERVICE_SET_WAKEUP, set_wakeup, - descriptions[ - const.SERVICE_SET_WAKEUP], schema=SET_WAKEUP_SCHEMA) hass.services.register(DOMAIN, const.SERVICE_PRINT_NODE, print_node, - descriptions[ - const.SERVICE_PRINT_NODE], schema=NODE_SERVICE_SCHEMA) hass.services.register(DOMAIN, const.SERVICE_REFRESH_ENTITY, async_refresh_entity, - descriptions[ - const.SERVICE_REFRESH_ENTITY], schema=REFRESH_ENTITY_SCHEMA) hass.services.register(DOMAIN, const.SERVICE_REFRESH_NODE, refresh_node, - descriptions[ - const.SERVICE_REFRESH_NODE], schema=NODE_SERVICE_SCHEMA) hass.services.register(DOMAIN, const.SERVICE_RESET_NODE_METERS, reset_node_meters, - descriptions[ - const.SERVICE_RESET_NODE_METERS], schema=RESET_NODE_METERS_SCHEMA) hass.services.register(DOMAIN, const.SERVICE_SET_POLL_INTENSITY, set_poll_intensity, - descriptions[const.SERVICE_SET_POLL_INTENSITY], schema=SET_POLL_INTENSITY_SCHEMA) hass.services.register(DOMAIN, const.SERVICE_HEAL_NODE, heal_node, - descriptions[ - const.SERVICE_HEAL_NODE], schema=HEAL_NODE_SCHEMA) hass.services.register(DOMAIN, const.SERVICE_TEST_NODE, test_node, - descriptions[ - const.SERVICE_TEST_NODE], schema=TEST_NODE_SCHEMA) # Setup autoheal diff --git a/homeassistant/core.py b/homeassistant/core.py index 7c2e718d43c..18cf40d3854 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -754,25 +754,15 @@ class StateMachine(object): class Service(object): """Representation of a callable service.""" - __slots__ = ['func', 'description', 'fields', 'schema', - 'is_callback', 'is_coroutinefunction'] + __slots__ = ['func', 'schema', 'is_callback', 'is_coroutinefunction'] - def __init__(self, func, description, fields, schema): + def __init__(self, func, schema): """Initialize a service.""" self.func = func - self.description = description or '' - self.fields = fields or {} self.schema = schema self.is_callback = is_callback(func) self.is_coroutinefunction = asyncio.iscoroutinefunction(func) - def as_dict(self): - """Return dictionary representation of this service.""" - return { - 'description': self.description, - 'fields': self.fields, - } - class ServiceCall(object): """Representation of a call to a service.""" @@ -826,8 +816,7 @@ class ServiceRegistry(object): This method must be run in the event loop. """ - return {domain: {key: value.as_dict() for key, value - in self._services[domain].items()} + return {domain: self._services[domain].copy() for domain in self._services} def has_service(self, domain, service): @@ -837,40 +826,29 @@ class ServiceRegistry(object): """ return service.lower() in self._services.get(domain.lower(), []) - def register(self, domain, service, service_func, description=None, - schema=None): + def register(self, domain, service, service_func, schema=None): """ Register a service. - Description is a dict containing key 'description' to describe - the service and a key 'fields' to describe the fields. - Schema is called to coerce and validate the service data. """ run_callback_threadsafe( self._hass.loop, - self.async_register, domain, service, service_func, description, - schema + self.async_register, domain, service, service_func, schema ).result() @callback - def async_register(self, domain, service, service_func, description=None, - schema=None): + def async_register(self, domain, service, service_func, schema=None): """ Register a service. - Description is a dict containing key 'description' to describe - the service and a key 'fields' to describe the fields. - Schema is called to coerce and validate the service data. This method must be run in the event loop. """ domain = domain.lower() service = service.lower() - description = description or {} - service_obj = Service(service_func, description.get('description'), - description.get('fields', {}), schema) + service_obj = Service(service_func, schema) if domain in self._services: self._services[domain][service] = service_obj diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 98cd704144e..83df8b48ab6 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -3,13 +3,15 @@ import asyncio import logging # pylint: disable=unused-import from typing import Optional # NOQA +from os import path import voluptuous as vol from homeassistant.const import ATTR_ENTITY_ID -from homeassistant.core import HomeAssistant # NOQA +import homeassistant.core as ha from homeassistant.exceptions import TemplateError from homeassistant.loader import get_component, bind_hass +from homeassistant.util.yaml import load_yaml import homeassistant.helpers.config_validation as cv from homeassistant.util.async import run_coroutine_threadsafe @@ -21,6 +23,8 @@ CONF_SERVICE_DATA_TEMPLATE = 'data_template' _LOGGER = logging.getLogger(__name__) +SERVICE_DESCRIPTION_CACHE = 'service_description_cache' + @bind_hass def call_from_config(hass, config, blocking=False, variables=None, @@ -112,3 +116,76 @@ def extract_entity_ids(hass, service_call, expand_group=True): return [service_ent_id] return service_ent_id + + +@asyncio.coroutine +@bind_hass +def async_get_all_descriptions(hass): + """Return descriptions (i.e. user documentation) for all service calls.""" + if SERVICE_DESCRIPTION_CACHE not in hass.data: + hass.data[SERVICE_DESCRIPTION_CACHE] = {} + description_cache = hass.data[SERVICE_DESCRIPTION_CACHE] + + format_cache_key = '{}.{}'.format + + def domain_yaml_file(domain): + """Return the services.yaml location for a domain.""" + if domain == ha.DOMAIN: + import homeassistant.components as components + component_path = path.dirname(components.__file__) + else: + component_path = path.dirname(get_component(domain).__file__) + return path.join(component_path, 'services.yaml') + + def load_services_file(yaml_file): + """Load and cache a services.yaml file.""" + try: + yaml_cache[yaml_file] = load_yaml(yaml_file) + except FileNotFoundError: + pass + + services = hass.services.async_services() + + # Load missing files + yaml_cache = {} + loading_tasks = [] + for domain in services: + yaml_file = domain_yaml_file(domain) + + for service in services[domain]: + if format_cache_key(domain, service) not in description_cache: + if yaml_file not in yaml_cache: + yaml_cache[yaml_file] = {} + task = hass.async_add_job(load_services_file, yaml_file) + loading_tasks.append(task) + + if loading_tasks: + yield from asyncio.wait(loading_tasks, loop=hass.loop) + + # Build response + catch_all_yaml_file = domain_yaml_file(ha.DOMAIN) + descriptions = {} + for domain in services: + descriptions[domain] = {} + yaml_file = domain_yaml_file(domain) + + for service in services[domain]: + cache_key = format_cache_key(domain, service) + description = description_cache.get(cache_key) + + # Cache missing descriptions + if description is None: + if yaml_file == catch_all_yaml_file: + yaml_services = yaml_cache[yaml_file].get(domain, {}) + else: + yaml_services = yaml_cache[yaml_file] + yaml_description = yaml_services.get(service, {}) + + description = description_cache[cache_key] = { + 'description': yaml_description.get('description', ''), + 'fields': yaml_description.get('fields', {}) + } + + descriptions[domain][service] = description + + return descriptions diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py index 2d0764ec585..78813d9ff0b 100644 --- a/tests/components/device_tracker/test_init.py +++ b/tests/components/device_tracker/test_init.py @@ -345,6 +345,7 @@ class TestComponentsDeviceTracker(unittest.TestCase): CONF_PLATFORM: 'test', device_tracker.CONF_CONSIDER_HOME: 59, }}) + self.hass.block_till_done() self.assertEqual(STATE_HOME, self.hass.states.get('device_tracker.dev1').state) @@ -586,6 +587,7 @@ class TestComponentsDeviceTracker(unittest.TestCase): CONF_PLATFORM: 'test', device_tracker.CONF_CONSIDER_HOME: 59, }}) + self.hass.block_till_done() state = self.hass.states.get('device_tracker.dev1') attrs = state.attributes diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index a5aa093bcd5..31d98633ef8 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -1,4 +1,5 @@ """Test service helpers.""" +import asyncio from copy import deepcopy import unittest from unittest.mock import patch @@ -8,6 +9,7 @@ import homeassistant.components # noqa from homeassistant import core as ha, loader from homeassistant.const import STATE_ON, STATE_OFF, ATTR_ENTITY_ID from homeassistant.helpers import service, template +from homeassistant.setup import async_setup_component import homeassistant.helpers.config_validation as cv from tests.common import get_test_home_assistant, mock_service @@ -135,3 +137,27 @@ class TestServiceHelpers(unittest.TestCase): self.assertEqual(['group.test'], service.extract_entity_ids( self.hass, call, expand_group=False)) + + +@asyncio.coroutine +def test_async_get_all_descriptions(hass): + """Test async_get_all_descriptions.""" + group = loader.get_component('group') + group_config = {group.DOMAIN: {}} + yield from async_setup_component(hass, group.DOMAIN, group_config) + descriptions = yield from service.async_get_all_descriptions(hass) + + assert len(descriptions) == 1 + + assert 'description' in descriptions['group']['reload'] + assert 'fields' in descriptions['group']['reload'] + + logger = loader.get_component('logger') + logger_config = {logger.DOMAIN: {}} + yield from async_setup_component(hass, logger.DOMAIN, logger_config) + descriptions = yield from service.async_get_all_descriptions(hass) + + assert len(descriptions) == 2 + + assert 'description' in descriptions[logger.DOMAIN]['set_level'] + assert 'fields' in descriptions[logger.DOMAIN]['set_level'] diff --git a/tests/test_core.py b/tests/test_core.py index 09ddf721628..67ae849b022 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -640,10 +640,7 @@ class TestServiceRegistry(unittest.TestCase): def test_services(self): """Test services.""" - expected = { - 'test_domain': {'test_service': {'description': '', 'fields': {}}} - } - self.assertEqual(expected, self.services.services) + assert len(self.services.services) == 1 def test_call_with_blocking_done_in_time(self): """Test call with blocking.""" From bccd88039568546e281c14b87d11e57dfcd60228 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 7 Jan 2018 15:05:19 -0800 Subject: [PATCH 177/238] Fix canary flaky test (#11519) --- tests/components/sensor/test_canary.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/tests/components/sensor/test_canary.py b/tests/components/sensor/test_canary.py index 99df05f36a4..b35b5630d60 100644 --- a/tests/components/sensor/test_canary.py +++ b/tests/components/sensor/test_canary.py @@ -1,10 +1,9 @@ """The tests for the Canary sensor platform.""" import copy import unittest -from unittest.mock import patch, Mock +from unittest.mock import Mock from canary.api import SensorType -from homeassistant.components import canary as base_canary from homeassistant.components.canary import DATA_CANARY from homeassistant.components.sensor import canary from homeassistant.components.sensor.canary import CanarySensor @@ -39,16 +38,13 @@ class TestCanarySensorSetup(unittest.TestCase): """Stop everything that was started.""" self.hass.stop() - @patch('homeassistant.components.canary.CanaryData') - def test_setup_sensors(self, mock_canary): + def test_setup_sensors(self): """Test the sensor setup.""" - base_canary.setup(self.hass, self.config) - online_device_at_home = mock_device(20, "Dining Room", True) offline_device_at_home = mock_device(21, "Front Yard", False) online_device_at_work = mock_device(22, "Office", True) - self.hass.data[DATA_CANARY] = mock_canary() + self.hass.data[DATA_CANARY] = Mock() self.hass.data[DATA_CANARY].locations = [ mock_location("Home", True, devices=[online_device_at_home, offline_device_at_home]), From c53fc94e845884d13f22a50e985b3dbd4535bc43 Mon Sep 17 00:00:00 2001 From: Dan Nixon Date: Mon, 8 Jan 2018 03:54:27 +0000 Subject: [PATCH 178/238] Address missed review comments for Dark Sky weather (#11520) See #11435 --- homeassistant/components/weather/darksky.py | 9 ++++----- tests/components/weather/test_darksky.py | 3 +++ 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/weather/darksky.py b/homeassistant/components/weather/darksky.py index 4c7512969f6..0566cc03662 100644 --- a/homeassistant/components/weather/darksky.py +++ b/homeassistant/components/weather/darksky.py @@ -2,7 +2,7 @@ Patform for retrieving meteorological data from Dark Sky. For more details about this platform, please refer to the documentation -https://home-assistant.io/components/ +https://home-assistant.io/components/weather.darksky/ """ from datetime import datetime, timedelta import logging @@ -133,13 +133,12 @@ class DarkSkyWeather(WeatherEntity): return self._ds_daily.summary @property - def state_attributes(self): + def device_state_attributes(self): """Return the state attributes.""" - attrs = super().state_attributes - attrs.update({ + attrs = { ATTR_DAILY_FORECAST_SUMMARY: self.daily_forecast_summary, ATTR_HOURLY_FORECAST_SUMMARY: self.hourly_forecast_summary - }) + } return attrs def update(self): diff --git a/tests/components/weather/test_darksky.py b/tests/components/weather/test_darksky.py index 7faa033e0a8..787aca2ca17 100644 --- a/tests/components/weather/test_darksky.py +++ b/tests/components/weather/test_darksky.py @@ -49,3 +49,6 @@ class TestDarkSky(unittest.TestCase): state = self.hass.states.get('weather.test') self.assertEqual(state.state, 'Clear') + self.assertEqual(state.attributes['daily_forecast_summary'], + 'No precipitation throughout the week, with ' + 'temperatures falling to 66°F on Thursday.') From 8c0035c5b33b35a20d01e4e0f30ac1af0f74158d Mon Sep 17 00:00:00 2001 From: Chris Cowart Date: Sun, 7 Jan 2018 23:16:45 -0800 Subject: [PATCH 179/238] New features for Owntracks device_tracker (#11480) * New features for Owntracks device_tracker - Supporting a mapping of region names in OT to zones in HA, allowing separate namespaces in both applications. This is especially helpful if using one OT instance to update geofences for multiple homes. - Creating a setting to ignore all location updates, allowing users to rely completely on enter and leave events. I have personally always used OT integrations with home automation this way and find it the most reliable. - Allowing the OT topic to be overridden in configuration * Fixing configuration of MQTT topic, related tests * Tests for Owntracks events_only feature * Tests for customizing mqtt topic, region mapping * Fixing _parse and http for owntracks custom topic * Making tests more thorough and cleaning up lint --- .../components/device_tracker/owntracks.py | 63 ++++++++++---- .../device_tracker/owntracks_http.py | 6 +- .../device_tracker/test_owntracks.py | 87 ++++++++++++++++++- 3 files changed, 139 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/device_tracker/owntracks.py b/homeassistant/components/device_tracker/owntracks.py index 0c869dd4b57..32d677a59db 100644 --- a/homeassistant/components/device_tracker/owntracks.py +++ b/homeassistant/components/device_tracker/owntracks.py @@ -32,19 +32,27 @@ CONF_MAX_GPS_ACCURACY = 'max_gps_accuracy' CONF_SECRET = 'secret' CONF_WAYPOINT_IMPORT = 'waypoints' CONF_WAYPOINT_WHITELIST = 'waypoint_whitelist' +CONF_MQTT_TOPIC = 'mqtt_topic' +CONF_REGION_MAPPING = 'region_mapping' +CONF_EVENTS_ONLY = 'events_only' DEPENDENCIES = ['mqtt'] -OWNTRACKS_TOPIC = 'owntracks/#' +DEFAULT_OWNTRACKS_TOPIC = 'owntracks/#' +REGION_MAPPING = {} PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_MAX_GPS_ACCURACY): vol.Coerce(float), vol.Optional(CONF_WAYPOINT_IMPORT, default=True): cv.boolean, + vol.Optional(CONF_EVENTS_ONLY, default=False): cv.boolean, + vol.Optional(CONF_MQTT_TOPIC, default=DEFAULT_OWNTRACKS_TOPIC): + mqtt.valid_subscribe_topic, vol.Optional(CONF_WAYPOINT_WHITELIST): vol.All( cv.ensure_list, [cv.string]), vol.Optional(CONF_SECRET): vol.Any( vol.Schema({vol.Optional(cv.string): cv.string}), - cv.string) + cv.string), + vol.Optional(CONF_REGION_MAPPING, default=REGION_MAPPING): dict }) @@ -82,31 +90,39 @@ def async_setup_scanner(hass, config, async_see, discovery_info=None): yield from async_handle_message(hass, context, message) yield from mqtt.async_subscribe( - hass, OWNTRACKS_TOPIC, async_handle_mqtt_message, 1) + hass, context.mqtt_topic, async_handle_mqtt_message, 1) return True -def _parse_topic(topic): - """Parse an MQTT topic owntracks/user/dev, return (user, dev) tuple. +def _parse_topic(topic, subscribe_topic): + """Parse an MQTT topic {sub_topic}/user/dev, return (user, dev) tuple. Async friendly. """ + subscription = subscribe_topic.split('/') try: - _, user, device, *_ = topic.split('/', 3) + user_index = subscription.index('#') except ValueError: + _LOGGER.error("Can't parse subscription topic: '%s'", subscribe_topic) + raise + + topic_list = topic.split('/') + try: + user, device = topic_list[user_index], topic_list[user_index + 1] + except IndexError: _LOGGER.error("Can't parse topic: '%s'", topic) raise return user, device -def _parse_see_args(message): +def _parse_see_args(message, subscribe_topic): """Parse the OwnTracks location parameters, into the format see expects. Async friendly. """ - user, device = _parse_topic(message['topic']) + user, device = _parse_topic(message['topic'], subscribe_topic) dev_id = slugify('{}_{}'.format(user, device)) kwargs = { 'dev_id': dev_id, @@ -185,16 +201,20 @@ def context_from_config(async_see, config): waypoint_import = config.get(CONF_WAYPOINT_IMPORT) waypoint_whitelist = config.get(CONF_WAYPOINT_WHITELIST) secret = config.get(CONF_SECRET) + region_mapping = config.get(CONF_REGION_MAPPING) + events_only = config.get(CONF_EVENTS_ONLY) + mqtt_topic = config.get(CONF_MQTT_TOPIC) return OwnTracksContext(async_see, secret, max_gps_accuracy, - waypoint_import, waypoint_whitelist) + waypoint_import, waypoint_whitelist, + region_mapping, events_only, mqtt_topic) class OwnTracksContext: """Hold the current OwnTracks context.""" def __init__(self, async_see, secret, max_gps_accuracy, import_waypoints, - waypoint_whitelist): + waypoint_whitelist, region_mapping, events_only, mqtt_topic): """Initialize an OwnTracks context.""" self.async_see = async_see self.secret = secret @@ -203,6 +223,9 @@ class OwnTracksContext: self.regions_entered = defaultdict(list) self.import_waypoints = import_waypoints self.waypoint_whitelist = waypoint_whitelist + self.region_mapping = region_mapping + self.events_only = events_only + self.mqtt_topic = mqtt_topic @callback def async_valid_accuracy(self, message): @@ -267,7 +290,11 @@ def async_handle_location_message(hass, context, message): if not context.async_valid_accuracy(message): return - dev_id, kwargs = _parse_see_args(message) + if context.events_only: + _LOGGER.debug("Location update ignored due to events_only setting") + return + + dev_id, kwargs = _parse_see_args(message, context.mqtt_topic) if context.regions_entered[dev_id]: _LOGGER.debug( @@ -283,7 +310,7 @@ def async_handle_location_message(hass, context, message): def _async_transition_message_enter(hass, context, message, location): """Execute enter event.""" zone = hass.states.get("zone.{}".format(slugify(location))) - dev_id, kwargs = _parse_see_args(message) + dev_id, kwargs = _parse_see_args(message, context.mqtt_topic) if zone is None and message.get('t') == 'b': # Not a HA zone, and a beacon so mobile beacon. @@ -309,7 +336,7 @@ def _async_transition_message_enter(hass, context, message, location): @asyncio.coroutine def _async_transition_message_leave(hass, context, message, location): """Execute leave event.""" - dev_id, kwargs = _parse_see_args(message) + dev_id, kwargs = _parse_see_args(message, context.mqtt_topic) regions = context.regions_entered[dev_id] if location in regions: @@ -352,6 +379,12 @@ def async_handle_transition_message(hass, context, message): # OwnTracks uses - at the start of a beacon zone # to switch on 'hold mode' - ignore this location = message['desc'].lstrip("-") + + # Create a layer of indirection for Owntracks instances that may name + # regions differently than their HA names + if location in context.region_mapping: + location = context.region_mapping[location] + if location.lower() == 'home': location = STATE_HOME @@ -398,7 +431,7 @@ def async_handle_waypoints_message(hass, context, message): return if context.waypoint_whitelist is not None: - user = _parse_topic(message['topic'])[0] + user = _parse_topic(message['topic'], context.mqtt_topic)[0] if user not in context.waypoint_whitelist: return @@ -410,7 +443,7 @@ def async_handle_waypoints_message(hass, context, message): _LOGGER.info("Got %d waypoints from %s", len(wayps), message['topic']) - name_base = ' '.join(_parse_topic(message['topic'])) + name_base = ' '.join(_parse_topic(message['topic'], context.mqtt_topic)) for wayp in wayps: yield from async_handle_waypoint(hass, name_base, wayp) diff --git a/homeassistant/components/device_tracker/owntracks_http.py b/homeassistant/components/device_tracker/owntracks_http.py index dcc3300cc12..d74e1fc6d95 100644 --- a/homeassistant/components/device_tracker/owntracks_http.py +++ b/homeassistant/components/device_tracker/owntracks_http.py @@ -5,6 +5,7 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/device_tracker.owntracks_http/ """ import asyncio +import re from aiohttp.web_exceptions import HTTPInternalServerError @@ -43,8 +44,11 @@ class OwnTracksView(HomeAssistantView): """Handle an OwnTracks message.""" hass = request.app['hass'] + subscription = self.context.mqtt_topic + topic = re.sub('/#$', '', subscription) + message = yield from request.json() - message['topic'] = 'owntracks/{}/{}'.format(user, device) + message['topic'] = '{}/{}/{}'.format(topic, user, device) try: yield from async_handle_message(hass, self.context, message) diff --git a/tests/components/device_tracker/test_owntracks.py b/tests/components/device_tracker/test_owntracks.py index 4f5efb9d09d..5f1f29e7697 100644 --- a/tests/components/device_tracker/test_owntracks.py +++ b/tests/components/device_tracker/test_owntracks.py @@ -35,6 +35,9 @@ CONF_MAX_GPS_ACCURACY = 'max_gps_accuracy' CONF_WAYPOINT_IMPORT = owntracks.CONF_WAYPOINT_IMPORT CONF_WAYPOINT_WHITELIST = owntracks.CONF_WAYPOINT_WHITELIST CONF_SECRET = owntracks.CONF_SECRET +CONF_MQTT_TOPIC = owntracks.CONF_MQTT_TOPIC +CONF_EVENTS_ONLY = owntracks.CONF_EVENTS_ONLY +CONF_REGION_MAPPING = owntracks.CONF_REGION_MAPPING TEST_ZONE_LAT = 45.0 TEST_ZONE_LON = 90.0 @@ -179,6 +182,13 @@ REGION_GPS_LEAVE_MESSAGE_OUTER = build_message( 'event': 'leave'}, DEFAULT_TRANSITION_MESSAGE) +REGION_GPS_ENTER_MESSAGE_OUTER = build_message( + {'lon': OUTER_ZONE['longitude'], + 'lat': OUTER_ZONE['latitude'], + 'desc': 'outer', + 'event': 'enter'}, + DEFAULT_TRANSITION_MESSAGE) + # Region Beacon messages REGION_BEACON_ENTER_MESSAGE = DEFAULT_BEACON_TRANSITION_MESSAGE @@ -616,6 +626,46 @@ class TestDeviceTrackerOwnTracks(BaseMQTT): self.send_message(EVENT_TOPIC, message) self.assert_location_state('inner') + def test_events_only_on(self): + """Test events_only config suppresses location updates.""" + # Sending a location message that is not home + self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) + self.assert_location_state(STATE_NOT_HOME) + + self.context.events_only = True + + # Enter and Leave messages + self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE_OUTER) + self.assert_location_state('outer') + self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_OUTER) + self.assert_location_state(STATE_NOT_HOME) + + # Sending a location message that is inside outer zone + self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) + + # Ignored location update. Location remains at previous. + self.assert_location_state(STATE_NOT_HOME) + + def test_events_only_off(self): + """Test when events_only is False.""" + # Sending a location message that is not home + self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) + self.assert_location_state(STATE_NOT_HOME) + + self.context.events_only = False + + # Enter and Leave messages + self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE_OUTER) + self.assert_location_state('outer') + self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_OUTER) + self.assert_location_state(STATE_NOT_HOME) + + # Sending a location message that is inside outer zone + self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) + + # Location update processed + self.assert_location_state('outer') + # Region Beacon based event entry / exit testing def test_event_region_entry_exit(self): @@ -1111,7 +1161,8 @@ class TestDeviceTrackerOwnTracks(BaseMQTT): test_config = { CONF_PLATFORM: 'owntracks', CONF_MAX_GPS_ACCURACY: 200, - CONF_WAYPOINT_IMPORT: True + CONF_WAYPOINT_IMPORT: True, + CONF_MQTT_TOPIC: 'owntracks/#', } run_coroutine_threadsafe(owntracks.async_setup_scanner( self.hass, test_config, mock_see), self.hass.loop).result() @@ -1353,3 +1404,37 @@ class TestDeviceTrackerOwnTrackConfigs(BaseMQTT): self.send_message(LOCATION_TOPIC, ENCRYPTED_LOCATION_MESSAGE) self.assert_location_latitude(LOCATION_MESSAGE['lat']) + + def test_customized_mqtt_topic(self): + """Test subscribing to a custom mqtt topic.""" + with assert_setup_component(1, device_tracker.DOMAIN): + assert setup_component(self.hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'owntracks', + CONF_MQTT_TOPIC: 'mytracks/#', + }}) + + topic = 'mytracks/{}/{}'.format(USER, DEVICE) + + self.send_message(topic, LOCATION_MESSAGE) + self.assert_location_latitude(LOCATION_MESSAGE['lat']) + + def test_region_mapping(self): + """Test region to zone mapping.""" + with assert_setup_component(1, device_tracker.DOMAIN): + assert setup_component(self.hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'owntracks', + CONF_REGION_MAPPING: { + 'foo': 'inner' + }, + }}) + + self.hass.states.set( + 'zone.inner', 'zoning', INNER_ZONE) + + message = build_message({'desc': 'foo'}, REGION_GPS_ENTER_MESSAGE) + self.assertEqual(message['desc'], 'foo') + + self.send_message(EVENT_TOPIC, message) + self.assert_location_state('inner') From 2ba9d825a074f50bd9a12185633805b12cb64c4c Mon Sep 17 00:00:00 2001 From: Alex Osadchyy <21959540+aosadchyy@users.noreply.github.com> Date: Sun, 7 Jan 2018 23:32:24 -0800 Subject: [PATCH 180/238] Reconnect before mochad switch send command (#11296) * Connection to mochad occasionally stalls on RPi and CM19A. Reconnect one switch send command. * Formatting and exception hanling fixes * Moved import inside the method. Logging outside the try-catch. * Tailing whitespaces. * MockDependency on pymochad in unit tests to resolve exceptions * patch pymochad MochadException in unit tests to resolve exceptions * patch pymochad MochadException in unit tests to resolve exceptions * cleaned unused import * lint issue fixed * pylint issue fixed --- homeassistant/components/switch/mochad.py | 39 +++++++++++++++++++---- tests/components/switch/test_mochad.py | 1 + 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/switch/mochad.py b/homeassistant/components/switch/mochad.py index da8f96dc1f0..f80784271c2 100644 --- a/homeassistant/components/switch/mochad.py +++ b/homeassistant/components/switch/mochad.py @@ -50,7 +50,12 @@ class MochadSwitch(SwitchDevice): self._comm_type = dev.get(mochad.CONF_COMM_TYPE, 'pl') self.device = device.Device(ctrl, self._address, comm_type=self._comm_type) - self._state = self._get_device_status() + # Init with false to avoid locking HA for long on CM19A (goes from rf + # to pl via TM751, but not other way around) + if self._comm_type == 'pl': + self._state = self._get_device_status() + else: + self._state = False @property def name(self): @@ -59,17 +64,37 @@ class MochadSwitch(SwitchDevice): def turn_on(self, **kwargs): """Turn the switch on.""" - self._state = True + from pymochad.exceptions import MochadException + _LOGGER.debug("Reconnect %s:%s", self._controller.server, + self._controller.port) with mochad.REQ_LOCK: - self.device.send_cmd('on') - self._controller.read_data() + try: + # Recycle socket on new command to recover mochad connection + self._controller.reconnect() + self.device.send_cmd('on') + # No read data on CM19A which is rf only + if self._comm_type == 'pl': + self._controller.read_data() + self._state = True + except (MochadException, OSError) as exc: + _LOGGER.error("Error with mochad communication: %s", exc) def turn_off(self, **kwargs): """Turn the switch off.""" - self._state = False + from pymochad.exceptions import MochadException + _LOGGER.debug("Reconnect %s:%s", self._controller.server, + self._controller.port) with mochad.REQ_LOCK: - self.device.send_cmd('off') - self._controller.read_data() + try: + # Recycle socket on new command to recover mochad connection + self._controller.reconnect() + self.device.send_cmd('off') + # No read data on CM19A which is rf only + if self._comm_type == 'pl': + self._controller.read_data() + self._state = False + except (MochadException, OSError) as exc: + _LOGGER.error("Error with mochad communication: %s", exc) def _get_device_status(self): """Get the status of the switch from mochad.""" diff --git a/tests/components/switch/test_mochad.py b/tests/components/switch/test_mochad.py index 8011d85860e..a5e6e2c9ae6 100644 --- a/tests/components/switch/test_mochad.py +++ b/tests/components/switch/test_mochad.py @@ -16,6 +16,7 @@ def pymochad_mock(): """Mock pymochad.""" with mock.patch.dict('sys.modules', { 'pymochad': mock.MagicMock(), + 'pymochad.exceptions': mock.MagicMock(), }): yield From 46ad1940976391b181271d7b241155852f02ae02 Mon Sep 17 00:00:00 2001 From: florianj1 <35061897+florianj1@users.noreply.github.com> Date: Mon, 8 Jan 2018 16:57:50 +0100 Subject: [PATCH 181/238] Fix Kodi channels media type (#11505) * Update kodi.py * Update kodi.py Fix code style --- homeassistant/components/media_player/kodi.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/media_player/kodi.py b/homeassistant/components/media_player/kodi.py index bab6c88ec7e..2c428c6b833 100644 --- a/homeassistant/components/media_player/kodi.py +++ b/homeassistant/components/media_player/kodi.py @@ -19,8 +19,8 @@ from homeassistant.components.media_player import ( SUPPORT_PLAY_MEDIA, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_PLAY, SUPPORT_VOLUME_STEP, SUPPORT_SHUFFLE_SET, MediaPlayerDevice, PLATFORM_SCHEMA, MEDIA_TYPE_MUSIC, MEDIA_TYPE_TVSHOW, - MEDIA_TYPE_VIDEO, MEDIA_TYPE_PLAYLIST, MEDIA_PLAYER_SCHEMA, DOMAIN, - SUPPORT_TURN_ON) + MEDIA_TYPE_VIDEO, MEDIA_TYPE_CHANNEL, MEDIA_TYPE_PLAYLIST, + MEDIA_PLAYER_SCHEMA, DOMAIN, SUPPORT_TURN_ON) from homeassistant.const import ( STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING, CONF_HOST, CONF_NAME, CONF_PORT, CONF_PROXY_SSL, CONF_USERNAME, CONF_PASSWORD, @@ -71,6 +71,8 @@ MEDIA_TYPES = { 'tvshow': MEDIA_TYPE_TVSHOW, 'season': MEDIA_TYPE_TVSHOW, 'episode': MEDIA_TYPE_TVSHOW, + # Type 'channel' is used for radio or tv streams from pvr + 'channel': MEDIA_TYPE_CHANNEL, } SUPPORT_KODI = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ From dff36b808783820f11cd43ca14926b8a6046fbd1 Mon Sep 17 00:00:00 2001 From: ChristianKuehnel Date: Mon, 8 Jan 2018 16:58:34 +0100 Subject: [PATCH 182/238] Extension sensor alpha_vantage (#11427) * Extended sensor alpha_vantage * Improved error handling if symbol is not found. Now we add the symbols that were found, * Added option to give names and currencies to symbols. * Added support to read foreign exchange information. * Icons are selected based on the currency (where MDI has a matching icon) * added missing line at the end of the file... * renamed variable as required by pylint * Fix typos, ordering, and style --- .../components/sensor/alpha_vantage.py | 128 ++++++++++++++++-- 1 file changed, 116 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/sensor/alpha_vantage.py b/homeassistant/components/sensor/alpha_vantage.py index 90957077c27..7987de7caf3 100644 --- a/homeassistant/components/sensor/alpha_vantage.py +++ b/homeassistant/components/sensor/alpha_vantage.py @@ -10,7 +10,8 @@ import logging import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ATTR_ATTRIBUTION, CONF_API_KEY +from homeassistant.const import ( + ATTR_ATTRIBUTION, CONF_API_KEY, CONF_CURRENCY, CONF_NAME) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -23,25 +24,62 @@ ATTR_HIGH = 'high' ATTR_LOW = 'low' ATTR_VOLUME = 'volume' -CONF_ATTRIBUTION = "Stock market information provided by Alpha Vantage." +CONF_ATTRIBUTION = "Stock market information provided by Alpha Vantage" +CONF_FOREIGN_EXCHANGE = 'foreign_exchange' +CONF_FROM = 'from' +CONF_SYMBOL = 'symbol' CONF_SYMBOLS = 'symbols' +CONF_TO = 'to' -DEFAULT_SYMBOL = 'GOOGL' +DEFAULT_SYMBOL = { + CONF_CURRENCY: 'USD', + CONF_NAME: 'Google', + CONF_SYMBOL: 'GOOGL', +} -ICON = 'mdi:currency-usd' +DEFAULT_CURRENCY = { + CONF_FROM: 'BTC', + CONF_NAME: 'Bitcon', + CONF_TO: 'USD', +} + +ICONS = { + 'BTC': 'mdi:currency-btc', + 'EUR': 'mdi:currency-eur', + 'GBP': 'mdi:currency-gbp', + 'INR': 'mdi:currency-inr', + 'RUB': 'mdi:currency-rub', + 'TRY': 'mdi: currency-try', + 'USD': 'mdi:currency-usd', +} SCAN_INTERVAL = timedelta(minutes=5) +SYMBOL_SCHEMA = vol.Schema({ + vol.Required(CONF_SYMBOL): cv.string, + vol.Optional(CONF_CURRENCY): cv.string, + vol.Optional(CONF_NAME): cv.string, +}) + +CURRENCY_SCHEMA = vol.Schema({ + vol.Required(CONF_FROM): cv.string, + vol.Required(CONF_TO): cv.string, + vol.Optional(CONF_NAME): cv.string, +}) + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_API_KEY): cv.string, + vol.Optional(CONF_FOREIGN_EXCHANGE, default=[DEFAULT_CURRENCY]): + vol.All(cv.ensure_list, [CURRENCY_SCHEMA]), vol.Optional(CONF_SYMBOLS, default=[DEFAULT_SYMBOL]): - vol.All(cv.ensure_list, [cv.string]), + vol.All(cv.ensure_list, [SYMBOL_SCHEMA]), }) def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Alpha Vantage sensor.""" from alpha_vantage.timeseries import TimeSeries + from alpha_vantage.foreignexchange import ForeignExchange api_key = config.get(CONF_API_KEY) symbols = config.get(CONF_SYMBOLS) @@ -51,13 +89,26 @@ def setup_platform(hass, config, add_devices, discovery_info=None): dev = [] for symbol in symbols: try: - timeseries.get_intraday(symbol) + timeseries.get_intraday(symbol[CONF_SYMBOL]) except ValueError: _LOGGER.error( "API Key is not valid or symbol '%s' not known", symbol) - return dev.append(AlphaVantageSensor(timeseries, symbol)) + forex = ForeignExchange(key=api_key) + for conversion in config.get(CONF_FOREIGN_EXCHANGE): + from_cur = conversion.get(CONF_FROM) + to_cur = conversion.get(CONF_TO) + try: + forex.get_currency_exchange_rate( + from_currency=from_cur, to_currency=to_cur) + except ValueError as error: + _LOGGER.error( + "API Key is not valid or currencies '%s'/'%s' not known", + from_cur, to_cur) + _LOGGER.debug(str(error)) + dev.append(AlphaVantageForeignExchange(forex, conversion)) + add_devices(dev, True) @@ -66,11 +117,12 @@ class AlphaVantageSensor(Entity): def __init__(self, timeseries, symbol): """Initialize the sensor.""" - self._name = symbol + self._symbol = symbol[CONF_SYMBOL] + self._name = symbol.get(CONF_NAME, self._symbol) self._timeseries = timeseries - self._symbol = symbol self.values = None - self._unit_of_measurement = None + self._unit_of_measurement = symbol.get(CONF_CURRENCY, self._symbol) + self._icon = ICONS.get(symbol.get(CONF_CURRENCY, 'USD')) @property def name(self): @@ -80,7 +132,7 @@ class AlphaVantageSensor(Entity): @property def unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" - return self._symbol + return self._unit_of_measurement @property def state(self): @@ -102,9 +154,61 @@ class AlphaVantageSensor(Entity): @property def icon(self): """Return the icon to use in the frontend, if any.""" - return ICON + return self._icon def update(self): """Get the latest data and updates the states.""" all_values, _ = self._timeseries.get_intraday(self._symbol) self.values = next(iter(all_values.values())) + + +class AlphaVantageForeignExchange(Entity): + """Sensor for foreign exchange rates.""" + + def __init__(self, foreign_exchange, config): + """Initialize the sensor.""" + self._foreign_exchange = foreign_exchange + self._from_currency = config.get(CONF_FROM) + self._to_currency = config.get(CONF_TO) + if CONF_NAME in config: + self._name = config.get(CONF_NAME) + else: + self._name = '{}/{}'.format(self._to_currency, self._from_currency) + self._unit_of_measurement = self._to_currency + self._icon = ICONS.get(self._from_currency, 'USD') + self.values = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._unit_of_measurement + + @property + def state(self): + """Return the state of the sensor.""" + return self.values['5. Exchange Rate'] + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return self._icon + + @property + def device_state_attributes(self): + """Return the state attributes.""" + if self.values is not None: + return { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + CONF_FROM: self._from_currency, + CONF_TO: self._to_currency, + } + + def update(self): + """Get the latest data and updates the states.""" + self.values, _ = self._foreign_exchange.get_currency_exchange_rate( + from_currency=self._from_currency, to_currency=self._to_currency) From b1b0a2589e2de7c92a2d6f53fb3cffe70751ef81 Mon Sep 17 00:00:00 2001 From: timstanley1985 Date: Mon, 8 Jan 2018 16:07:39 +0000 Subject: [PATCH 183/238] MQTT json attributes (#11439) * MQTT json attributes * Fix lint * Amends following comments * Fix lint * Fix lint * Add test * Fix typo * Amends following comments * New tests * Fix lint * Fix tests --- homeassistant/components/sensor/mqtt.py | 28 +++++++- tests/components/sensor/test_mqtt.py | 87 +++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/mqtt.py b/homeassistant/components/sensor/mqtt.py index f82c87c9ef5..b19f5721e4f 100644 --- a/homeassistant/components/sensor/mqtt.py +++ b/homeassistant/components/sensor/mqtt.py @@ -6,6 +6,7 @@ https://home-assistant.io/components/sensor.mqtt/ """ import asyncio import logging +import json from datetime import timedelta import voluptuous as vol @@ -26,6 +27,7 @@ from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) CONF_EXPIRE_AFTER = 'expire_after' +CONF_JSON_ATTRS = 'json_attributes' DEFAULT_NAME = 'MQTT Sensor' DEFAULT_FORCE_UPDATE = False @@ -34,6 +36,7 @@ DEPENDENCIES = ['mqtt'] PLATFORM_SCHEMA = mqtt.MQTT_RO_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + vol.Optional(CONF_JSON_ATTRS, default=[]): cv.ensure_list_csv, vol.Optional(CONF_EXPIRE_AFTER): cv.positive_int, vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, }).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) @@ -57,6 +60,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): config.get(CONF_FORCE_UPDATE), config.get(CONF_EXPIRE_AFTER), value_template, + config.get(CONF_JSON_ATTRS), config.get(CONF_AVAILABILITY_TOPIC), config.get(CONF_PAYLOAD_AVAILABLE), config.get(CONF_PAYLOAD_NOT_AVAILABLE), @@ -68,7 +72,8 @@ class MqttSensor(MqttAvailability, Entity): def __init__(self, name, state_topic, qos, unit_of_measurement, force_update, expire_after, value_template, - availability_topic, payload_available, payload_not_available): + json_attributes, availability_topic, payload_available, + payload_not_available): """Initialize the sensor.""" super().__init__(availability_topic, qos, payload_available, payload_not_available) @@ -81,6 +86,8 @@ class MqttSensor(MqttAvailability, Entity): self._template = value_template self._expire_after = expire_after self._expiration_trigger = None + self._json_attributes = set(json_attributes) + self._attributes = None @asyncio.coroutine def async_added_to_hass(self): @@ -104,6 +111,20 @@ class MqttSensor(MqttAvailability, Entity): self._expiration_trigger = async_track_point_in_utc_time( self.hass, self.value_is_expired, expiration_at) + if self._json_attributes: + self._attributes = {} + try: + json_dict = json.loads(payload) + if isinstance(json_dict, dict): + attrs = {k: json_dict[k] for k in + self._json_attributes & json_dict.keys()} + self._attributes = attrs + else: + _LOGGER.warning("JSON result was not a dictionary") + except ValueError: + _LOGGER.warning("MQTT payload could not be parsed as JSON") + _LOGGER.debug("Erroneous JSON: %s", payload) + if self._template is not None: payload = self._template.async_render_with_possible_json_value( payload, self._state) @@ -144,3 +165,8 @@ class MqttSensor(MqttAvailability, Entity): def state(self): """Return the state of the entity.""" return self._state + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self._attributes diff --git a/tests/components/sensor/test_mqtt.py b/tests/components/sensor/test_mqtt.py index 4f9161a5b7f..d5cfad407d5 100644 --- a/tests/components/sensor/test_mqtt.py +++ b/tests/components/sensor/test_mqtt.py @@ -216,3 +216,90 @@ class TestSensorMQTT(unittest.TestCase): def _send_time_changed(self, now): """Send a time changed event.""" self.hass.bus.fire(ha.EVENT_TIME_CHANGED, {ha.ATTR_NOW: now}) + + def test_setting_sensor_attribute_via_mqtt_json_message(self): + """Test the setting of attribute via MQTT with JSON playload.""" + mock_component(self.hass, 'mqtt') + assert setup_component(self.hass, sensor.DOMAIN, { + sensor.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'test-topic', + 'unit_of_measurement': 'fav unit', + 'json_attributes': 'val' + } + }) + + fire_mqtt_message(self.hass, 'test-topic', '{ "val": "100" }') + self.hass.block_till_done() + state = self.hass.states.get('sensor.test') + + self.assertEqual('100', + state.attributes.get('val')) + + @patch('homeassistant.components.sensor.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, sensor.DOMAIN, { + sensor.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'test-topic', + 'unit_of_measurement': 'fav unit', + 'json_attributes': 'val' + } + }) + + fire_mqtt_message(self.hass, 'test-topic', '[ "list", "of", "things"]') + self.hass.block_till_done() + state = self.hass.states.get('sensor.test') + + self.assertEqual(None, + state.attributes.get('val')) + self.assertTrue(mock_logger.warning.called) + + @patch('homeassistant.components.sensor.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, sensor.DOMAIN, { + sensor.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'test-topic', + 'unit_of_measurement': 'fav unit', + 'json_attributes': 'val' + } + }) + + fire_mqtt_message(self.hass, 'test-topic', 'This is not JSON') + self.hass.block_till_done() + + state = self.hass.states.get('sensor.test') + self.assertEqual(None, + state.attributes.get('val')) + self.assertTrue(mock_logger.warning.called) + self.assertTrue(mock_logger.debug.called) + + def test_update_with_json_attrs_and_template(self): + """Test attributes get extracted from a JSON result.""" + mock_component(self.hass, 'mqtt') + assert setup_component(self.hass, sensor.DOMAIN, { + sensor.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'test-topic', + 'unit_of_measurement': 'fav unit', + 'value_template': '{{ value_json.val }}', + 'json_attributes': 'val' + } + }) + + fire_mqtt_message(self.hass, 'test-topic', '{ "val": "100" }') + self.hass.block_till_done() + state = self.hass.states.get('sensor.test') + + self.assertEqual('100', + state.attributes.get('val')) + self.assertEqual('100', state.state) From 0e710099e066e7fab69e1cc2decf6c98fb077833 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Kut=C3=BD?= <6du1ro.n@gmail.com> Date: Mon, 8 Jan 2018 17:11:45 +0100 Subject: [PATCH 184/238] Support pushing all sensors and fix wrong metrics. (#11159) For example all metrics with unit % match humidity. This generate correct metrics like this # HELP nut_ups_battery_charge sensor.nut_ups_battery_charge # TYPE nut_ups_battery_charge gauge nut_ups_battery_charge{entity="sensor.nut_ups_battery_charge",friendly_name="NUT UPS Battery Charge"} 98.0 nut_ups_battery_charge{entity="sensor.nut_ups_battery_charge_2",friendly_name="NUT UPS Battery Charge"} 97.0 --- homeassistant/components/prometheus.py | 69 +++++++------------------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 21 insertions(+), 52 deletions(-) diff --git a/homeassistant/components/prometheus.py b/homeassistant/components/prometheus.py index 0ecfa50ee63..f9629ca726a 100644 --- a/homeassistant/components/prometheus.py +++ b/homeassistant/components/prometheus.py @@ -13,14 +13,14 @@ from aiohttp import web from homeassistant.components.http import HomeAssistantView from homeassistant.components import recorder from homeassistant.const import ( - CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, CONF_INCLUDE, TEMP_CELSIUS, + CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, CONF_INCLUDE, EVENT_STATE_CHANGED, TEMP_FAHRENHEIT, CONTENT_TYPE_TEXT_PLAIN, ATTR_TEMPERATURE, ATTR_UNIT_OF_MEASUREMENT) from homeassistant import core as hacore from homeassistant.helpers import state as state_helper from homeassistant.util.temperature import fahrenheit_to_celsius -REQUIREMENTS = ['prometheus_client==0.0.21'] +REQUIREMENTS = ['prometheus_client==0.1.0'] _LOGGER = logging.getLogger(__name__) @@ -181,57 +181,26 @@ class Metrics(object): pass def _handle_sensor(self, state): - _sensor_types = { - TEMP_CELSIUS: ( - 'temperature_c', self.prometheus_client.Gauge, - 'Temperature in degrees Celsius', - ), - TEMP_FAHRENHEIT: ( - 'temperature_c', self.prometheus_client.Gauge, - 'Temperature in degrees Celsius', - ), - '%': ( - 'relative_humidity', self.prometheus_client.Gauge, - 'Relative humidity (0..100)', - ), - 'lux': ( - 'light_lux', self.prometheus_client.Gauge, - 'Light level in lux', - ), - 'kWh': ( - 'electricity_used_kwh', self.prometheus_client.Gauge, - 'Electricity used by this device in KWh', - ), - 'V': ( - 'voltage', self.prometheus_client.Gauge, - 'Currently reported voltage in Volts', - ), - 'W': ( - 'electricity_usage_w', self.prometheus_client.Gauge, - 'Currently reported electricity draw in Watts', - ), - 'min': ( - 'sensor_min', self.prometheus_client.Gauge, - 'Time in minutes reported by a sensor' - ), - 'Events': ( - 'sensor_event_count', self.prometheus_client.Gauge, - 'Number of events for a sensor' - ), - } unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - metric = _sensor_types.get(unit) + metric = state.entity_id.split(".")[1] - if metric is not None: - metric = self._metric(*metric) - try: - value = state_helper.state_as_number(state) - if unit == TEMP_FAHRENHEIT: - value = fahrenheit_to_celsius(value) - metric.labels(**self._labels(state)).set(value) - except ValueError: - pass + try: + int(metric.split("_")[-1]) + metric = "_".join(metric.split("_")[:-1]) + except ValueError: + pass + + _metric = self._metric(metric, self.prometheus_client.Gauge, + state.entity_id) + + try: + value = state_helper.state_as_number(state) + if unit == TEMP_FAHRENHEIT: + value = fahrenheit_to_celsius(value) + _metric.labels(**self._labels(state)).set(value) + except ValueError: + pass self._battery(state) diff --git a/requirements_all.txt b/requirements_all.txt index bc37b9de96e..4b076b1e1f2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -581,7 +581,7 @@ pocketcasts==0.1 proliphix==0.4.1 # homeassistant.components.prometheus -prometheus_client==0.0.21 +prometheus_client==0.1.0 # homeassistant.components.sensor.systemmonitor psutil==5.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4ab838211a5..2cb3dc22821 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -115,7 +115,7 @@ pilight==0.1.1 pmsensor==0.4 # homeassistant.components.prometheus -prometheus_client==0.0.21 +prometheus_client==0.1.0 # homeassistant.components.canary py-canary==0.2.3 From e0e2f739bab4c74aa9660fe1cd53d2276d7bbf7b Mon Sep 17 00:00:00 2001 From: Yien Xu Date: Tue, 9 Jan 2018 00:22:50 +0800 Subject: [PATCH 185/238] Add options feature to Baidu TTS. (#11462) * Add options feature to Baidu TTS. Add options feature: supported_options() and default_options() added, get_tts_audio updated to accommodate options. * Add options feature to Baidu TTS. Add options feature: supported_options() and default_options() added, get_tts_audio updated to accommodate options. * Fix style * Fix style Change the order of content of lists and dictionaries. * Fix style Changed order of imports, and fixed grammar errors. --- homeassistant/components/tts/baidu.py | 94 +++++++++++++++++++-------- 1 file changed, 67 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/tts/baidu.py b/homeassistant/components/tts/baidu.py index 6f86a42bbc5..609f38454fe 100644 --- a/homeassistant/components/tts/baidu.py +++ b/homeassistant/components/tts/baidu.py @@ -1,5 +1,5 @@ """ -Support for the baidu speech service. +Support for Baidu speech service. For more details about this component, please refer to the documentation at https://home-assistant.io/components/tts.baidu/ @@ -8,22 +8,17 @@ https://home-assistant.io/components/tts.baidu/ import logging import voluptuous as vol +from homeassistant.components.tts import Provider, CONF_LANG, PLATFORM_SCHEMA from homeassistant.const import CONF_API_KEY -from homeassistant.components.tts import Provider, PLATFORM_SCHEMA, CONF_LANG import homeassistant.helpers.config_validation as cv - REQUIREMENTS = ["baidu-aip==1.6.6"] _LOGGER = logging.getLogger(__name__) - -SUPPORT_LANGUAGES = [ - 'zh', -] +SUPPORTED_LANGUAGES = ['zh'] DEFAULT_LANG = 'zh' - CONF_APP_ID = 'app_id' CONF_SECRET_KEY = 'secret_key' CONF_SPEED = 'speed' @@ -32,20 +27,39 @@ CONF_VOLUME = 'volume' CONF_PERSON = 'person' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_LANG, default=DEFAULT_LANG): vol.In(SUPPORT_LANGUAGES), + vol.Optional(CONF_LANG, default=DEFAULT_LANG): vol.In(SUPPORTED_LANGUAGES), vol.Required(CONF_APP_ID): cv.string, vol.Required(CONF_API_KEY): cv.string, vol.Required(CONF_SECRET_KEY): cv.string, vol.Optional(CONF_SPEED, default=5): vol.All( - vol.Coerce(int), vol.Range(min=0, max=9)), + vol.Coerce(int), vol.Range(min=0, max=9) + ), vol.Optional(CONF_PITCH, default=5): vol.All( - vol.Coerce(int), vol.Range(min=0, max=9)), + vol.Coerce(int), vol.Range(min=0, max=9) + ), vol.Optional(CONF_VOLUME, default=5): vol.All( - vol.Coerce(int), vol.Range(min=0, max=15)), + vol.Coerce(int), vol.Range(min=0, max=15) + ), vol.Optional(CONF_PERSON, default=0): vol.All( - vol.Coerce(int), vol.Range(min=0, max=4)), + vol.Coerce(int), vol.Range(min=0, max=4) + ), }) +# Keys are options in the config file, and Values are options +# required by Baidu TTS API. +_OPTIONS = { + CONF_PERSON: 'per', + CONF_PITCH: 'pit', + CONF_SPEED: 'spd', + CONF_VOLUME: 'vol', +} +SUPPORTED_OPTIONS = [ + CONF_PERSON, + CONF_PITCH, + CONF_SPEED, + CONF_VOLUME, +] + def get_engine(hass, config): """Set up Baidu TTS component.""" @@ -66,14 +80,14 @@ class BaiduTTSProvider(Provider): 'appid': conf.get(CONF_APP_ID), 'apikey': conf.get(CONF_API_KEY), 'secretkey': conf.get(CONF_SECRET_KEY), - } + } self._speech_conf_data = { - 'spd': conf.get(CONF_SPEED), - 'pit': conf.get(CONF_PITCH), - 'vol': conf.get(CONF_VOLUME), - 'per': conf.get(CONF_PERSON), - } + _OPTIONS[CONF_PERSON]: conf.get(CONF_PERSON), + _OPTIONS[CONF_PITCH]: conf.get(CONF_PITCH), + _OPTIONS[CONF_SPEED]: conf.get(CONF_SPEED), + _OPTIONS[CONF_VOLUME]: conf.get(CONF_VOLUME), + } @property def default_language(self): @@ -82,8 +96,23 @@ class BaiduTTSProvider(Provider): @property def supported_languages(self): - """Return list of supported languages.""" - return SUPPORT_LANGUAGES + """Return a list of supported languages.""" + return SUPPORTED_LANGUAGES + + @property + def default_options(self): + """Return a dict including default options.""" + return { + CONF_PERSON: self._speech_conf_data[_OPTIONS[CONF_PERSON]], + CONF_PITCH: self._speech_conf_data[_OPTIONS[CONF_PITCH]], + CONF_SPEED: self._speech_conf_data[_OPTIONS[CONF_SPEED]], + CONF_VOLUME: self._speech_conf_data[_OPTIONS[CONF_VOLUME]], + } + + @property + def supported_options(self): + """Return a list of supported options.""" + return SUPPORTED_OPTIONS def get_tts_audio(self, message, language, options=None): """Load TTS from BaiduTTS.""" @@ -92,17 +121,28 @@ class BaiduTTSProvider(Provider): self._app_data['appid'], self._app_data['apikey'], self._app_data['secretkey'] - ) + ) - result = aip_speech.synthesis( - message, language, 1, self._speech_conf_data) + if options is None: + result = aip_speech.synthesis( + message, language, 1, self._speech_conf_data + ) + else: + speech_data = self._speech_conf_data.copy() + for key, value in options.items(): + speech_data[_OPTIONS[key]] = value + + result = aip_speech.synthesis( + message, language, 1, speech_data + ) if isinstance(result, dict): _LOGGER.error( "Baidu TTS error-- err_no:%d; err_msg:%s; err_detail:%s", result['err_no'], result['err_msg'], - result['err_detail']) - return (None, None) + result['err_detail'] + ) + return None, None - return (self._codec, result) + return self._codec, result From 903cda08b153400849409c253cbc8051af53a503 Mon Sep 17 00:00:00 2001 From: Cameron Llewellyn Date: Mon, 8 Jan 2018 11:18:10 -0600 Subject: [PATCH 186/238] Insteon local update (#11088) * trying to rework device discovery. now the main component will do the getlinked and pass it to the sub-components. no longer any config needed other than what is needed to connect to the hub. device names are no longer stored. core team told us to stop using configurator to ask for names. there should be a way to set names in hass...possibly this https://home-assistant.io/docs/configuration/customizing-devices/ * fix device types * make device names just be the isnteon device id * revert some config changes * Update insteon_local.py * Update insteon_local.py * Update insteon_local.py * Update insteon_local.py * Update insteon_local.py * Update insteon_local.py * Update insteon_local.py * update insteon client * linting fixes * Error Clean up * Update to make requested changes * more changes * Finish requested changes to components * Fixing Rebase Conflicts * fix device types * make device names just be the isnteon device id * revert some config changes * Update insteon_local.py * Update insteon_local.py * Update insteon_local.py * Update insteon_local.py * Update insteon_local.py * Update insteon_local.py * Update insteon_local.py * update insteon client * linting fixes * Error Clean up * Update to make requested changes * more changes * Finish requested changes to components * Update Insteon_Local for performance improvements * Fix errors from get_linked * Fix typo * Requested changes * Fix spacing * Clean up * Requested Changes --- homeassistant/components/fan/insteon_local.py | 80 +++---------------- homeassistant/components/insteon_local.py | 39 ++++++--- .../components/light/insteon_local.py | 79 +++--------------- .../components/switch/insteon_local.py | 77 +++--------------- requirements_all.txt | 2 +- 5 files changed, 64 insertions(+), 213 deletions(-) diff --git a/homeassistant/components/fan/insteon_local.py b/homeassistant/components/fan/insteon_local.py index 58c8caa331b..85e603c8c81 100644 --- a/homeassistant/components/fan/insteon_local.py +++ b/homeassistant/components/fan/insteon_local.py @@ -12,7 +12,6 @@ from homeassistant.components.fan import ( SUPPORT_SET_SPEED, FanEntity) from homeassistant.helpers.entity import ToggleEntity import homeassistant.util as util -from homeassistant.util.json import load_json, save_json _CONFIGURING = {} _LOGGER = logging.getLogger(__name__) @@ -20,8 +19,6 @@ _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['insteon_local'] DOMAIN = 'fan' -INSTEON_LOCAL_FANS_CONF = 'insteon_local_fans.conf' - MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100) MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) @@ -31,85 +28,34 @@ SUPPORT_INSTEON_LOCAL = SUPPORT_SET_SPEED def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Insteon local fan platform.""" insteonhub = hass.data['insteon_local'] - - conf_fans = load_json(hass.config.path(INSTEON_LOCAL_FANS_CONF)) - if conf_fans: - for device_id in conf_fans: - setup_fan(device_id, conf_fans[device_id], insteonhub, hass, - add_devices) - - else: - linked = insteonhub.get_linked() - - for device_id in linked: - if (linked[device_id]['cat_type'] == 'dimmer' and - linked[device_id]['sku'] == '2475F' and - device_id not in conf_fans): - request_configuration(device_id, - insteonhub, - linked[device_id]['model_name'] + ' ' + - linked[device_id]['sku'], - hass, add_devices) - - -def request_configuration(device_id, insteonhub, model, hass, - add_devices_callback): - """Request configuration steps from the user.""" - configurator = hass.components.configurator - - # We got an error if this method is called while we are configuring - if device_id in _CONFIGURING: - configurator.notify_errors( - _CONFIGURING[device_id], 'Failed to register, please try again.') - + if discovery_info is None: return - def insteon_fan_config_callback(data): - """The actions to do when our configuration callback is called.""" - setup_fan(device_id, data.get('name'), insteonhub, hass, - add_devices_callback) + linked = discovery_info['linked'] + device_list = [] + for device_id in linked: + if (linked[device_id]['cat_type'] == 'dimmer' and + linked[device_id]['sku'] == '2475F'): + device = insteonhub.fan(device_id) + device_list.append( + InsteonLocalFanDevice(device) + ) - _CONFIGURING[device_id] = configurator.request_config( - 'Insteon ' + model + ' addr: ' + device_id, - insteon_fan_config_callback, - description=('Enter a name for ' + model + ' Fan addr: ' + device_id), - entity_picture='/static/images/config_insteon.png', - submit_caption='Confirm', - fields=[{'id': 'name', 'name': 'Name', 'type': ''}] - ) - - -def setup_fan(device_id, name, insteonhub, hass, add_devices_callback): - """Set up the fan.""" - if device_id in _CONFIGURING: - request_id = _CONFIGURING.pop(device_id) - configurator = hass.components.configurator - configurator.request_done(request_id) - _LOGGER.info("Device configuration done!") - - conf_fans = load_json(hass.config.path(INSTEON_LOCAL_FANS_CONF)) - if device_id not in conf_fans: - conf_fans[device_id] = name - - save_json(hass.config.path(INSTEON_LOCAL_FANS_CONF), conf_fans) - - device = insteonhub.fan(device_id) - add_devices_callback([InsteonLocalFanDevice(device, name)]) + add_devices(device_list) class InsteonLocalFanDevice(FanEntity): """An abstract Class for an Insteon node.""" - def __init__(self, node, name): + def __init__(self, node): """Initialize the device.""" self.node = node - self.node.deviceName = name self._speed = SPEED_OFF @property def name(self): """Return the name of the node.""" - return self.node.deviceName + return self.node.device_id @property def unique_id(self): diff --git a/homeassistant/components/insteon_local.py b/homeassistant/components/insteon_local.py index 711dafb6b73..dbe8597be3d 100644 --- a/homeassistant/components/insteon_local.py +++ b/homeassistant/components/insteon_local.py @@ -13,8 +13,9 @@ import voluptuous as vol from homeassistant.const import ( CONF_PASSWORD, CONF_USERNAME, CONF_HOST, CONF_PORT, CONF_TIMEOUT) import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.discovery import load_platform -REQUIREMENTS = ['insteonlocal==0.52'] +REQUIREMENTS = ['insteonlocal==0.53'] _LOGGER = logging.getLogger(__name__) @@ -22,6 +23,14 @@ DEFAULT_PORT = 25105 DEFAULT_TIMEOUT = 10 DOMAIN = 'insteon_local' +INSTEON_CACHE = '.insteon_local_cache' + +INSTEON_PLATFORMS = [ + 'light', + 'switch', + 'fan', +] + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Required(CONF_HOST): cv.string, @@ -34,12 +43,8 @@ CONFIG_SCHEMA = vol.Schema({ def setup(hass, config): - """Set up the Insteon Hub component. - - This will automatically import associated lights. - """ + """Setup insteon hub.""" from insteonlocal.Hub import Hub - conf = config[DOMAIN] username = conf.get(CONF_USERNAME) password = conf.get(CONF_PASSWORD) @@ -48,21 +53,23 @@ def setup(hass, config): timeout = conf.get(CONF_TIMEOUT) try: - if not os.path.exists(hass.config.path('.insteon_cache')): - os.makedirs(hass.config.path('.insteon_cache')) + if not os.path.exists(hass.config.path(INSTEON_CACHE)): + os.makedirs(hass.config.path(INSTEON_CACHE)) insteonhub = Hub(host, username, password, port, timeout, _LOGGER, - hass.config.path('.insteon_cache')) + hass.config.path(INSTEON_CACHE)) # Check for successful connection insteonhub.get_buffer_status() except requests.exceptions.ConnectTimeout: - _LOGGER.error("Error on insteon_local." - "Could not connect. Check config", exc_info=True) + _LOGGER.error( + "Could not connect. Check config", + exc_info=True) return False except requests.exceptions.ConnectionError: - _LOGGER.error("Error on insteon_local. Could not connect." - "Check config", exc_info=True) + _LOGGER.error( + "Could not connect. Check config", + exc_info=True) return False except requests.exceptions.RequestException: if insteonhub.http_code == 401: @@ -71,6 +78,12 @@ def setup(hass, config): _LOGGER.error("Error on insteon_local hub check", exc_info=True) return False + linked = insteonhub.get_linked() + hass.data['insteon_local'] = insteonhub + for insteon_platform in INSTEON_PLATFORMS: + load_platform(hass, insteon_platform, DOMAIN, {'linked': linked}, + config) + return True diff --git a/homeassistant/components/light/insteon_local.py b/homeassistant/components/light/insteon_local.py index 9d704327a1d..88d621d4060 100644 --- a/homeassistant/components/light/insteon_local.py +++ b/homeassistant/components/light/insteon_local.py @@ -10,8 +10,6 @@ from datetime import timedelta from homeassistant.components.light import ( ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light) import homeassistant.util as util -from homeassistant.util.json import load_json, save_json - _CONFIGURING = {} _LOGGER = logging.getLogger(__name__) @@ -19,8 +17,6 @@ _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['insteon_local'] DOMAIN = 'light' -INSTEON_LOCAL_LIGHTS_CONF = 'insteon_local_lights.conf' - MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100) MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) @@ -30,84 +26,33 @@ SUPPORT_INSTEON_LOCAL = SUPPORT_BRIGHTNESS def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Insteon local light platform.""" insteonhub = hass.data['insteon_local'] - - conf_lights = load_json(hass.config.path(INSTEON_LOCAL_LIGHTS_CONF)) - if conf_lights: - for device_id in conf_lights: - setup_light(device_id, conf_lights[device_id], insteonhub, hass, - add_devices) - - else: - linked = insteonhub.get_linked() - - for device_id in linked: - if (linked[device_id]['cat_type'] == 'dimmer' and - device_id not in conf_lights): - request_configuration(device_id, - insteonhub, - linked[device_id]['model_name'] + ' ' + - linked[device_id]['sku'], - hass, add_devices) - - -def request_configuration(device_id, insteonhub, model, hass, - add_devices_callback): - """Request configuration steps from the user.""" - configurator = hass.components.configurator - - # We got an error if this method is called while we are configuring - if device_id in _CONFIGURING: - configurator.notify_errors( - _CONFIGURING[device_id], 'Failed to register, please try again.') - + if discovery_info is None: return - def insteon_light_config_callback(data): - """Set up actions to do when our configuration callback is called.""" - setup_light(device_id, data.get('name'), insteonhub, hass, - add_devices_callback) + linked = discovery_info['linked'] + device_list = [] + for device_id in linked: + if linked[device_id]['cat_type'] == 'dimmer': + device = insteonhub.dimmer(device_id) + device_list.append( + InsteonLocalDimmerDevice(device) + ) - _CONFIGURING[device_id] = configurator.request_config( - 'Insteon ' + model + ' addr: ' + device_id, - insteon_light_config_callback, - description=('Enter a name for ' + model + ' addr: ' + device_id), - entity_picture='/static/images/config_insteon.png', - submit_caption='Confirm', - fields=[{'id': 'name', 'name': 'Name', 'type': ''}] - ) - - -def setup_light(device_id, name, insteonhub, hass, add_devices_callback): - """Set up the light.""" - if device_id in _CONFIGURING: - request_id = _CONFIGURING.pop(device_id) - configurator = hass.components.configurator - configurator.request_done(request_id) - _LOGGER.debug("Device configuration done") - - conf_lights = load_json(hass.config.path(INSTEON_LOCAL_LIGHTS_CONF)) - if device_id not in conf_lights: - conf_lights[device_id] = name - - save_json(hass.config.path(INSTEON_LOCAL_LIGHTS_CONF), conf_lights) - - device = insteonhub.dimmer(device_id) - add_devices_callback([InsteonLocalDimmerDevice(device, name)]) + add_devices(device_list) class InsteonLocalDimmerDevice(Light): """An abstract Class for an Insteon node.""" - def __init__(self, node, name): + def __init__(self, node): """Initialize the device.""" self.node = node - self.node.deviceName = name self._value = 0 @property def name(self): """Return the name of the node.""" - return self.node.deviceName + return self.node.device_id @property def unique_id(self): diff --git a/homeassistant/components/switch/insteon_local.py b/homeassistant/components/switch/insteon_local.py index 5fd37c84986..c20a638c00f 100644 --- a/homeassistant/components/switch/insteon_local.py +++ b/homeassistant/components/switch/insteon_local.py @@ -9,7 +9,6 @@ from datetime import timedelta from homeassistant.components.switch import SwitchDevice import homeassistant.util as util -from homeassistant.util.json import load_json, save_json _CONFIGURING = {} _LOGGER = logging.getLogger(__name__) @@ -17,8 +16,6 @@ _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['insteon_local'] DOMAIN = 'switch' -INSTEON_LOCAL_SWITCH_CONF = 'insteon_local_switch.conf' - MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100) MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) @@ -26,83 +23,33 @@ MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Insteon local switch platform.""" insteonhub = hass.data['insteon_local'] - - conf_switches = load_json(hass.config.path(INSTEON_LOCAL_SWITCH_CONF)) - if conf_switches: - for device_id in conf_switches: - setup_switch( - device_id, conf_switches[device_id], insteonhub, hass, - add_devices) - else: - linked = insteonhub.get_linked() - - for device_id in linked: - if linked[device_id]['cat_type'] == 'switch'\ - and device_id not in conf_switches: - request_configuration(device_id, insteonhub, - linked[device_id]['model_name'] + ' ' + - linked[device_id]['sku'], - hass, add_devices) - - -def request_configuration( - device_id, insteonhub, model, hass, add_devices_callback): - """Request configuration steps from the user.""" - configurator = hass.components.configurator - - # We got an error if this method is called while we are configuring - if device_id in _CONFIGURING: - configurator.notify_errors( - _CONFIGURING[device_id], 'Failed to register, please try again.') - + if discovery_info is None: return - def insteon_switch_config_callback(data): - """Handle configuration changes.""" - setup_switch(device_id, data.get('name'), insteonhub, hass, - add_devices_callback) + linked = discovery_info['linked'] + device_list = [] + for device_id in linked: + if linked[device_id]['cat_type'] == 'switch': + device = insteonhub.switch(device_id) + device_list.append( + InsteonLocalSwitchDevice(device) + ) - _CONFIGURING[device_id] = configurator.request_config( - 'Insteon Switch ' + model + ' addr: ' + device_id, - insteon_switch_config_callback, - description=('Enter a name for ' + model + ' addr: ' + device_id), - entity_picture='/static/images/config_insteon.png', - submit_caption='Confirm', - fields=[{'id': 'name', 'name': 'Name', 'type': ''}] - ) - - -def setup_switch(device_id, name, insteonhub, hass, add_devices_callback): - """Set up the switch.""" - if device_id in _CONFIGURING: - request_id = _CONFIGURING.pop(device_id) - configurator = hass.components.configurator - configurator.request_done(request_id) - _LOGGER.info("Device configuration done") - - conf_switch = load_json(hass.config.path(INSTEON_LOCAL_SWITCH_CONF)) - if device_id not in conf_switch: - conf_switch[device_id] = name - - save_json(hass.config.path(INSTEON_LOCAL_SWITCH_CONF), conf_switch) - - device = insteonhub.switch(device_id) - add_devices_callback([InsteonLocalSwitchDevice(device, name)]) + add_devices(device_list) class InsteonLocalSwitchDevice(SwitchDevice): """An abstract Class for an Insteon node.""" - def __init__(self, node, name): + def __init__(self, node): """Initialize the device.""" self.node = node - self.node.deviceName = name self._state = False @property def name(self): """Return the name of the node.""" - return self.node.deviceName + return self.node.device_id @property def unique_id(self): diff --git a/requirements_all.txt b/requirements_all.txt index 4b076b1e1f2..68670fe7a1b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -400,7 +400,7 @@ iglo==1.0.0 influxdb==4.1.1 # homeassistant.components.insteon_local -insteonlocal==0.52 +insteonlocal==0.53 # homeassistant.components.insteon_plm insteonplm==0.7.5 From 13effee679b57154ddc5b859a5ffdcf9332bff79 Mon Sep 17 00:00:00 2001 From: tschmidty69 Date: Mon, 8 Jan 2018 16:00:21 -0500 Subject: [PATCH 187/238] Snips: (fix) support new intentName format (#11509) * support new intentName format * Added tests for new and old format names * pylint warning fixes --- homeassistant/components/snips.py | 6 ++-- tests/components/test_snips.py | 58 +++++++++++++++++++++++++++++-- 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/snips.py b/homeassistant/components/snips.py index a430c53bbc7..ae387f7ab4c 100644 --- a/homeassistant/components/snips.py +++ b/homeassistant/components/snips.py @@ -61,9 +61,11 @@ def async_setup(hass, config): _LOGGER.error('Intent has invalid schema: %s. %s', err, request) return + if request['intent']['intentName'].startswith('user_'): + intent_type = request['intent']['intentName'].split('__')[-1] + else: + intent_type = request['intent']['intentName'].split(':')[-1] snips_response = None - - intent_type = request['intent']['intentName'].split('__')[-1] slots = {} for slot in request.get('slots', []): slots[slot['slotName']] = {'value': resolve_slot_values(slot)} diff --git a/tests/components/test_snips.py b/tests/components/test_snips.py index b554d4785ad..9ee500bb4c7 100644 --- a/tests/components/test_snips.py +++ b/tests/components/test_snips.py @@ -47,7 +47,7 @@ def test_snips_intent(hass, mqtt_mock): @asyncio.coroutine -def test_snips_intent_with_snips_duration(hass, mqtt_mock): +def test_snips_intent_with_duration(hass, mqtt_mock): """Test intent with Snips duration.""" result = yield from async_setup_component(hass, "snips", { "snips": {}, @@ -176,7 +176,7 @@ def test_snips_unknown_intent(hass, mqtt_mock): payload) yield from hass.async_block_till_done() - assert len(intents) == 0 + assert not intents assert len(events) == 1 assert events[0].data['domain'] == 'mqtt' assert events[0].data['service'] == 'publish' @@ -184,3 +184,57 @@ def test_snips_unknown_intent(hass, mqtt_mock): topic = events[0].data['service_data']['topic'] assert payload['text'] == 'Unknown Intent' assert topic == 'hermes/dialogueManager/endSession' + + +@asyncio.coroutine +def test_snips_intent_user(hass, mqtt_mock): + """Test intentName format user_XXX__intentName.""" + result = yield from async_setup_component(hass, "snips", { + "snips": {}, + }) + assert result + payload = """ + { + "input": "what to do", + "intent": { + "intentName": "user_ABCDEF123__Lights" + }, + "slots": [] + } + """ + intents = async_mock_intent(hass, 'Lights') + async_fire_mqtt_message(hass, 'hermes/intent/user_ABCDEF123__Lights', + payload) + yield from hass.async_block_till_done() + + assert len(intents) == 1 + intent = intents[0] + assert intent.platform == 'snips' + assert intent.intent_type == 'Lights' + + +@asyncio.coroutine +def test_snips_intent_username(hass, mqtt_mock): + """Test intentName format username:intentName.""" + result = yield from async_setup_component(hass, "snips", { + "snips": {}, + }) + assert result + payload = """ + { + "input": "what to do", + "intent": { + "intentName": "username:Lights" + }, + "slots": [] + } + """ + intents = async_mock_intent(hass, 'Lights') + async_fire_mqtt_message(hass, 'hermes/intent/username:Lights', + payload) + yield from hass.async_block_till_done() + + assert len(intents) == 1 + intent = intents[0] + assert intent.platform == 'snips' + assert intent.intent_type == 'Lights' From 8b267e3fafaefc07b0788a588dec11e19fc30170 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Tue, 9 Jan 2018 15:30:00 +0100 Subject: [PATCH 188/238] Upgrade numpy to 1.14.0 (#11542) --- .../components/binary_sensor/trend.py | 26 ++++++++--------- .../components/image_processing/opencv.py | 28 ++++++++----------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 26 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/binary_sensor/trend.py b/homeassistant/components/binary_sensor/trend.py index 8b5660f54c5..031e0aa42e5 100644 --- a/homeassistant/components/binary_sensor/trend.py +++ b/homeassistant/components/binary_sensor/trend.py @@ -11,21 +11,19 @@ import math import voluptuous as vol +from homeassistant.components.binary_sensor import ( + DEVICE_CLASSES_SCHEMA, ENTITY_ID_FORMAT, PLATFORM_SCHEMA, + BinarySensorDevice) +from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, CONF_DEVICE_CLASS, CONF_ENTITY_ID, + CONF_FRIENDLY_NAME, STATE_UNKNOWN) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv - -from homeassistant.components.binary_sensor import ( - BinarySensorDevice, ENTITY_ID_FORMAT, PLATFORM_SCHEMA, - DEVICE_CLASSES_SCHEMA) -from homeassistant.const import ( - ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, - CONF_DEVICE_CLASS, CONF_ENTITY_ID, CONF_FRIENDLY_NAME, - STATE_UNKNOWN) from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.event import async_track_state_change from homeassistant.util import utcnow -REQUIREMENTS = ['numpy==1.13.3'] +REQUIREMENTS = ['numpy==1.14.0'] _LOGGER = logging.getLogger(__name__) @@ -36,21 +34,21 @@ ATTR_INVERT = 'invert' ATTR_SAMPLE_DURATION = 'sample_duration' ATTR_SAMPLE_COUNT = 'sample_count' -CONF_SENSORS = 'sensors' CONF_ATTRIBUTE = 'attribute' +CONF_INVERT = 'invert' CONF_MAX_SAMPLES = 'max_samples' CONF_MIN_GRADIENT = 'min_gradient' -CONF_INVERT = 'invert' CONF_SAMPLE_DURATION = 'sample_duration' +CONF_SENSORS = 'sensors' SENSOR_SCHEMA = vol.Schema({ vol.Required(CONF_ENTITY_ID): cv.entity_id, vol.Optional(CONF_ATTRIBUTE): cv.string, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_FRIENDLY_NAME): cv.string, + vol.Optional(CONF_INVERT, default=False): cv.boolean, vol.Optional(CONF_MAX_SAMPLES, default=2): cv.positive_int, vol.Optional(CONF_MIN_GRADIENT, default=0.0): vol.Coerce(float), - vol.Optional(CONF_INVERT, default=False): cv.boolean, vol.Optional(CONF_SAMPLE_DURATION, default=0): cv.positive_int, }) @@ -129,11 +127,11 @@ class SensorTrend(BinarySensorDevice): return { ATTR_ENTITY_ID: self._entity_id, ATTR_FRIENDLY_NAME: self._name, - ATTR_INVERT: self._invert, ATTR_GRADIENT: self._gradient, + ATTR_INVERT: self._invert, ATTR_MIN_GRADIENT: self._min_gradient, - ATTR_SAMPLE_DURATION: self._sample_duration, ATTR_SAMPLE_COUNT: len(self.samples), + ATTR_SAMPLE_DURATION: self._sample_duration, } @property diff --git a/homeassistant/components/image_processing/opencv.py b/homeassistant/components/image_processing/opencv.py index 56a4ac50bd7..0abc449afba 100644 --- a/homeassistant/components/image_processing/opencv.py +++ b/homeassistant/components/image_processing/opencv.py @@ -8,16 +8,15 @@ from datetime import timedelta import logging import requests - import voluptuous as vol -from homeassistant.core import split_entity_id from homeassistant.components.image_processing import ( - CONF_SOURCE, CONF_ENTITY_ID, CONF_NAME, PLATFORM_SCHEMA, + CONF_ENTITY_ID, CONF_NAME, CONF_SOURCE, PLATFORM_SCHEMA, ImageProcessingEntity) +from homeassistant.core import split_entity_id import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['numpy==1.13.3'] +REQUIREMENTS = ['numpy==1.14.0'] _LOGGER = logging.getLogger(__name__) @@ -73,7 +72,7 @@ def _create_processor_from_config(hass, camera_entity, config): def _get_default_classifier(dest_path): """Download the default OpenCV classifier.""" - _LOGGER.info('Downloading default classifier') + _LOGGER.info("Downloading default classifier") req = requests.get(CASCADE_URL, stream=True) with open(dest_path, 'wb') as fil: for chunk in req.iter_content(chunk_size=1024): @@ -84,14 +83,13 @@ def _get_default_classifier(dest_path): def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the OpenCV image processing platform.""" try: - # Verify opencv python package is preinstalled + # Verify that the OpenCV python package is pre-installed # pylint: disable=unused-import,unused-variable import cv2 # noqa except ImportError: - _LOGGER.error("No opencv library found! " + - "Install or compile for your system " + - "following instructions here: " + - "http://opencv.org/releases.html") + _LOGGER.error( + "No OpenCV library found! Install or compile for your system " + "following instructions here: http://opencv.org/releases.html") return entities = [] @@ -105,8 +103,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): for camera in config[CONF_SOURCE]: entities.append(OpenCVImageProcessor( hass, camera[CONF_ENTITY_ID], camera.get(CONF_NAME), - config[CONF_CLASSIFIER] - )) + config[CONF_CLASSIFIER])) add_devices(entities) @@ -121,8 +118,7 @@ class OpenCVImageProcessor(ImageProcessingEntity): if name: self._name = name else: - self._name = "OpenCV {0}".format( - split_entity_id(camera_entity)[1]) + self._name = "OpenCV {0}".format(split_entity_id(camera_entity)[1]) self._classifiers = classifiers self._matches = {} self._total_matches = 0 @@ -157,8 +153,8 @@ class OpenCVImageProcessor(ImageProcessingEntity): import numpy # pylint: disable=no-member - cv_image = cv2.imdecode(numpy.asarray(bytearray(image)), - cv2.IMREAD_UNCHANGED) + cv_image = cv2.imdecode( + numpy.asarray(bytearray(image)), cv2.IMREAD_UNCHANGED) for name, classifier in self._classifiers.items(): scale = DEFAULT_SCALE diff --git a/requirements_all.txt b/requirements_all.txt index 68670fe7a1b..f8124df7e87 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -508,7 +508,7 @@ nuheat==0.3.0 # homeassistant.components.binary_sensor.trend # homeassistant.components.image_processing.opencv -numpy==1.13.3 +numpy==1.14.0 # homeassistant.components.google oauth2client==4.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2cb3dc22821..b8adba02639 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -94,7 +94,7 @@ mficlient==0.3.0 # homeassistant.components.binary_sensor.trend # homeassistant.components.image_processing.opencv -numpy==1.13.3 +numpy==1.14.0 # homeassistant.components.mqtt # homeassistant.components.shiftr From 10f48fbf6b1ace1c384b166b04192baf88fc9fb3 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Tue, 9 Jan 2018 15:30:36 +0100 Subject: [PATCH 189/238] Upgrade python-etherscan-api to 0.0.2 (#11535) --- homeassistant/components/sensor/etherscan.py | 6 +++--- requirements_all.txt | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sensor/etherscan.py b/homeassistant/components/sensor/etherscan.py index 5c9a8839dc9..36513805882 100644 --- a/homeassistant/components/sensor/etherscan.py +++ b/homeassistant/components/sensor/etherscan.py @@ -8,12 +8,12 @@ from datetime import timedelta import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import (CONF_NAME, ATTR_ATTRIBUTION) +from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['python-etherscan-api==0.0.1'] +REQUIREMENTS = ['python-etherscan-api==0.0.2'] CONF_ADDRESS = 'address' CONF_ATTRIBUTION = "Data provided by etherscan.io" diff --git a/requirements_all.txt b/requirements_all.txt index f8124df7e87..42b32259361 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -861,7 +861,7 @@ python-ecobee-api==0.0.14 # python-eq3bt==0.1.6 # homeassistant.components.sensor.etherscan -python-etherscan-api==0.0.1 +python-etherscan-api==0.0.2 # homeassistant.components.sensor.darksky # homeassistant.components.weather.darksky From 85858885c8c977ca20819ed75e89faa5029f2875 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Tue, 9 Jan 2018 15:30:54 +0100 Subject: [PATCH 190/238] Upgrade Sphinx to 1.6.6 (#11534) --- requirements_docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_docs.txt b/requirements_docs.txt index 68fbec8cf97..04ebb074e03 100644 --- a/requirements_docs.txt +++ b/requirements_docs.txt @@ -1,3 +1,3 @@ -Sphinx==1.6.5 +Sphinx==1.6.6 sphinx-autodoc-typehints==1.2.3 sphinx-autodoc-annotation==1.0.post1 From 13042d5557b0df1702018ada1b59d8fdb229375f Mon Sep 17 00:00:00 2001 From: Sean Wilson Date: Tue, 9 Jan 2018 15:58:26 -0500 Subject: [PATCH 191/238] ZoneMinder event sensor updates (#11369) * Switch the ZoneMinder event sensor over to use the consoleEvents API, and add monitored_conditions for 'hour', 'day', 'week', 'month' and 'all'. 'all' is enabled by default to provide backward compatibility with the old behaviour. * - Switch to new string formatting - Remove redundant validator * De-lint --- homeassistant/components/sensor/zoneminder.py | 48 ++++++++++++++----- 1 file changed, 37 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/sensor/zoneminder.py b/homeassistant/components/sensor/zoneminder.py index b31b942f486..1189a53bb09 100644 --- a/homeassistant/components/sensor/zoneminder.py +++ b/homeassistant/components/sensor/zoneminder.py @@ -10,6 +10,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import STATE_UNKNOWN +from homeassistant.const import CONF_MONITORED_CONDITIONS from homeassistant.helpers.entity import Entity import homeassistant.components.zoneminder as zoneminder import homeassistant.helpers.config_validation as cv @@ -22,9 +23,19 @@ CONF_INCLUDE_ARCHIVED = "include_archived" DEFAULT_INCLUDE_ARCHIVED = False +SENSOR_TYPES = { + 'all': ['Events'], + 'hour': ['Events Last Hour'], + 'day': ['Events Last Day'], + 'week': ['Events Last Week'], + 'month': ['Events Last Month'], +} + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_INCLUDE_ARCHIVED, default=DEFAULT_INCLUDE_ARCHIVED): cv.boolean, + vol.Optional(CONF_MONITORED_CONDITIONS, default=['all']): + vol.All(cv.ensure_list, [vol.In(list(SENSOR_TYPES))]), }) @@ -39,10 +50,13 @@ def setup_platform(hass, config, add_devices, discovery_info=None): sensors.append( ZMSensorMonitors(int(i['Monitor']['Id']), i['Monitor']['Name']) ) - sensors.append( - ZMSensorEvents(int(i['Monitor']['Id']), i['Monitor']['Name'], - include_archived) - ) + + for sensor in config[CONF_MONITORED_CONDITIONS]: + sensors.append( + ZMSensorEvents(int(i['Monitor']['Id']), + i['Monitor']['Name'], + include_archived, sensor) + ) add_devices(sensors) @@ -69,7 +83,7 @@ class ZMSensorMonitors(Entity): def update(self): """Update the sensor.""" monitor = zoneminder.get_state( - 'api/monitors/%i.json' % self._monitor_id + 'api/monitors/{}.json'.format(self._monitor_id) ) if monitor['monitor']['Monitor']['Function'] is None: self._state = STATE_UNKNOWN @@ -80,17 +94,20 @@ class ZMSensorMonitors(Entity): class ZMSensorEvents(Entity): """Get the number of events for each monitor.""" - def __init__(self, monitor_id, monitor_name, include_archived): + def __init__(self, monitor_id, monitor_name, include_archived, + sensor_type): """Initialize event sensor.""" self._monitor_id = monitor_id self._monitor_name = monitor_name self._include_archived = include_archived + self._type = sensor_type + self._name = SENSOR_TYPES[sensor_type][0] self._state = None @property def name(self): """Return the name of the sensor.""" - return '{} Events'.format(self._monitor_name) + return '{} {}'.format(self._monitor_name, self._name) @property def unit_of_measurement(self): @@ -104,13 +121,22 @@ class ZMSensorEvents(Entity): def update(self): """Update the sensor.""" - archived_filter = '/Archived:0' + date_filter = '1%20{}'.format(self._type) + if self._type == 'all': + # The consoleEvents API uses DATE_SUB, so give it + # something large + date_filter = '100%20year' + + archived_filter = '/Archived=:0' if self._include_archived: archived_filter = '' event = zoneminder.get_state( - 'api/events/index/MonitorId:%i%s.json' % (self._monitor_id, - archived_filter) + 'api/events/consoleEvents/{}{}.json'.format(date_filter, + archived_filter) ) - self._state = event['pagination']['count'] + try: + self._state = event['results'][str(self._monitor_id)] + except (TypeError, KeyError): + self._state = '0' From 8313225b401a0327f0c132de34d92042c21b1c43 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 9 Jan 2018 15:14:56 -0800 Subject: [PATCH 192/238] Move Google Assistant entity config out of customize (#11499) * Move Google Assistant entity config out of customize * CONF_ALIAS -> CONF_ALIASES * Lint --- homeassistant/components/cloud/__init__.py | 33 ++++++++++++----- .../components/google_assistant/__init__.py | 15 ++++++-- .../components/google_assistant/auth.py | 2 +- .../components/google_assistant/const.py | 6 ++-- .../components/google_assistant/http.py | 14 ++++---- .../components/google_assistant/smart_home.py | 36 ++++++++++--------- tests/components/cloud/test_iot.py | 11 +++++- .../google_assistant/test_google_assistant.py | 29 +++++---------- 8 files changed, 86 insertions(+), 60 deletions(-) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index e93eb086fd0..e497f4677e4 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -10,7 +10,7 @@ import async_timeout import voluptuous as vol from homeassistant.const import ( - EVENT_HOMEASSISTANT_START, CONF_REGION, CONF_MODE) + EVENT_HOMEASSISTANT_START, CONF_REGION, CONF_MODE, CONF_NAME, CONF_TYPE) from homeassistant.helpers import entityfilter from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -31,6 +31,7 @@ CONF_FILTER = 'filter' CONF_COGNITO_CLIENT_ID = 'cognito_client_id' CONF_RELAYER = 'relayer' CONF_USER_POOL_ID = 'user_pool_id' +CONF_ALIASES = 'aliases' MODE_DEV = 'development' DEFAULT_MODE = 'production' @@ -44,6 +45,12 @@ ALEXA_ENTITY_SCHEMA = vol.Schema({ vol.Optional(alexa_sh.CONF_NAME): cv.string, }) +GOOGLE_ENTITY_SCHEMA = vol.Schema({ + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_TYPE): vol.In(ga_sh.MAPPING_COMPONENT), + vol.Optional(CONF_ALIASES): vol.All(cv.ensure_list, [cv.string]) +}) + ASSISTANT_SCHEMA = vol.Schema({ vol.Optional( CONF_FILTER, @@ -55,6 +62,10 @@ ALEXA_SCHEMA = ASSISTANT_SCHEMA.extend({ vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: ALEXA_ENTITY_SCHEMA} }) +GACTIONS_SCHEMA = ASSISTANT_SCHEMA.extend({ + vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: GOOGLE_ENTITY_SCHEMA} +}) + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Optional(CONF_MODE, default=DEFAULT_MODE): @@ -65,7 +76,7 @@ CONFIG_SCHEMA = vol.Schema({ vol.Optional(CONF_REGION): str, vol.Optional(CONF_RELAYER): str, vol.Optional(CONF_ALEXA): ALEXA_SCHEMA, - vol.Optional(CONF_GOOGLE_ACTIONS): ASSISTANT_SCHEMA, + vol.Optional(CONF_GOOGLE_ACTIONS): GACTIONS_SCHEMA, }), }, extra=vol.ALLOW_EXTRA) @@ -79,14 +90,15 @@ def async_setup(hass, config): kwargs = {CONF_MODE: DEFAULT_MODE} alexa_conf = kwargs.pop(CONF_ALEXA, None) or ALEXA_SCHEMA({}) - gactions_conf = (kwargs.pop(CONF_GOOGLE_ACTIONS, None) or - ASSISTANT_SCHEMA({})) + + if CONF_GOOGLE_ACTIONS not in kwargs: + kwargs[CONF_GOOGLE_ACTIONS] = GACTIONS_SCHEMA({}) kwargs[CONF_ALEXA] = alexa_sh.Config( should_expose=alexa_conf[CONF_FILTER], entity_config=alexa_conf.get(CONF_ENTITY_CONFIG), ) - kwargs['gactions_should_expose'] = gactions_conf[CONF_FILTER] + cloud = hass.data[DOMAIN] = Cloud(hass, **kwargs) success = yield from cloud.initialize() @@ -101,14 +113,14 @@ def async_setup(hass, config): class Cloud: """Store the configuration of the cloud connection.""" - def __init__(self, hass, mode, alexa, gactions_should_expose, + def __init__(self, hass, mode, alexa, google_actions, cognito_client_id=None, user_pool_id=None, region=None, relayer=None): """Create an instance of Cloud.""" self.hass = hass self.mode = mode self.alexa_config = alexa - self._gactions_should_expose = gactions_should_expose + self._google_actions = google_actions self._gactions_config = None self.jwt_keyset = None self.id_token = None @@ -161,13 +173,16 @@ class Cloud: def gactions_config(self): """Return the Google Assistant config.""" if self._gactions_config is None: + conf = self._google_actions + def should_expose(entity): """If an entity should be exposed.""" - return self._gactions_should_expose(entity.entity_id) + return conf['filter'](entity.entity_id) self._gactions_config = ga_sh.Config( should_expose=should_expose, - agent_user_id=self.claims['cognito:username'] + agent_user_id=self.claims['cognito:username'], + entity_config=conf.get(CONF_ENTITY_CONFIG), ) return self._gactions_config diff --git a/homeassistant/components/google_assistant/__init__.py b/homeassistant/components/google_assistant/__init__.py index 0f9bd858d7e..aac258b4e93 100644 --- a/homeassistant/components/google_assistant/__init__.py +++ b/homeassistant/components/google_assistant/__init__.py @@ -17,6 +17,7 @@ import voluptuous as vol from homeassistant.core import HomeAssistant # NOQA from typing import Dict, Any # NOQA +from homeassistant.const import CONF_NAME, CONF_TYPE from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.loader import bind_hass @@ -25,10 +26,12 @@ from .const import ( DOMAIN, CONF_PROJECT_ID, CONF_CLIENT_ID, CONF_ACCESS_TOKEN, CONF_EXPOSE_BY_DEFAULT, DEFAULT_EXPOSE_BY_DEFAULT, CONF_EXPOSED_DOMAINS, DEFAULT_EXPOSED_DOMAINS, CONF_AGENT_USER_ID, CONF_API_KEY, - SERVICE_REQUEST_SYNC, REQUEST_SYNC_BASE_URL + SERVICE_REQUEST_SYNC, REQUEST_SYNC_BASE_URL, CONF_ENTITY_CONFIG, + CONF_EXPOSE, CONF_ALIASES ) from .auth import GoogleAssistantAuthView from .http import async_register_http +from .smart_home import MAPPING_COMPONENT _LOGGER = logging.getLogger(__name__) @@ -36,6 +39,13 @@ DEPENDENCIES = ['http'] DEFAULT_AGENT_USER_ID = 'home-assistant' +ENTITY_SCHEMA = vol.Schema({ + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_TYPE): vol.In(MAPPING_COMPONENT), + vol.Optional(CONF_EXPOSE): cv.boolean, + vol.Optional(CONF_ALIASES): vol.All(cv.ensure_list, [cv.string]) +}) + CONFIG_SCHEMA = vol.Schema( { DOMAIN: { @@ -48,7 +58,8 @@ CONFIG_SCHEMA = vol.Schema( default=DEFAULT_EXPOSED_DOMAINS): cv.ensure_list, vol.Optional(CONF_AGENT_USER_ID, default=DEFAULT_AGENT_USER_ID): cv.string, - vol.Optional(CONF_API_KEY): cv.string + vol.Optional(CONF_API_KEY): cv.string, + vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: ENTITY_SCHEMA} } }, extra=vol.ALLOW_EXTRA) diff --git a/homeassistant/components/google_assistant/auth.py b/homeassistant/components/google_assistant/auth.py index 4ef30ff53c8..1ed27403797 100644 --- a/homeassistant/components/google_assistant/auth.py +++ b/homeassistant/components/google_assistant/auth.py @@ -6,10 +6,10 @@ import logging # Typing imports # pylint: disable=using-constant-test,unused-import,ungrouped-imports # if False: -from homeassistant.core import HomeAssistant # NOQA from aiohttp.web import Request, Response # NOQA from typing import Dict, Any # NOQA +from homeassistant.core import HomeAssistant # NOQA from homeassistant.components.http import HomeAssistantView from homeassistant.const import ( HTTP_BAD_REQUEST, diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index c15f14bccdb..fc250c4b655 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -3,10 +3,8 @@ DOMAIN = 'google_assistant' GOOGLE_ASSISTANT_API_ENDPOINT = '/api/google_assistant' -ATTR_GOOGLE_ASSISTANT = 'google_assistant' -ATTR_GOOGLE_ASSISTANT_NAME = 'google_assistant_name' -ATTR_GOOGLE_ASSISTANT_TYPE = 'google_assistant_type' - +CONF_EXPOSE = 'expose' +CONF_ENTITY_CONFIG = 'entity_config' CONF_EXPOSE_BY_DEFAULT = 'expose_by_default' CONF_EXPOSED_DOMAINS = 'exposed_domains' CONF_PROJECT_ID = 'project_id' diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index 93c5b3d4f8e..47bdd0acb68 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -23,8 +23,9 @@ from .const import ( CONF_ACCESS_TOKEN, CONF_EXPOSE_BY_DEFAULT, CONF_EXPOSED_DOMAINS, - ATTR_GOOGLE_ASSISTANT, - CONF_AGENT_USER_ID + CONF_AGENT_USER_ID, + CONF_ENTITY_CONFIG, + CONF_EXPOSE, ) from .smart_home import async_handle_message, Config @@ -38,6 +39,7 @@ def async_register_http(hass, cfg): expose_by_default = cfg.get(CONF_EXPOSE_BY_DEFAULT) exposed_domains = cfg.get(CONF_EXPOSED_DOMAINS) agent_user_id = cfg.get(CONF_AGENT_USER_ID) + entity_config = cfg.get(CONF_ENTITY_CONFIG) def is_exposed(entity) -> bool: """Determine if an entity should be exposed to Google Assistant.""" @@ -45,11 +47,11 @@ def async_register_http(hass, cfg): # Ignore entities that are views return False - domain = entity.domain.lower() - explicit_expose = entity.attributes.get(ATTR_GOOGLE_ASSISTANT, None) + explicit_expose = \ + entity_config.get(entity.entity_id, {}).get(CONF_EXPOSE) domain_exposed_by_default = \ - expose_by_default and domain in exposed_domains + expose_by_default and entity.domain in exposed_domains # Expose an entity if the entity's domain is exposed by default and # the configuration doesn't explicitly exclude it from being @@ -59,7 +61,7 @@ def async_register_http(hass, cfg): return is_default_exposed or explicit_expose - gass_config = Config(is_exposed, agent_user_id) + gass_config = Config(is_exposed, agent_user_id, entity_config) hass.http.register_view( GoogleAssistantView(access_token, gass_config)) diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index 9ba77434c47..d6d5a4fd877 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -1,6 +1,5 @@ """Support for Google Assistant Smart Home API.""" import asyncio -from collections import namedtuple import logging # Typing imports @@ -16,9 +15,9 @@ from homeassistant.util.decorator import Registry from homeassistant.const import ( ATTR_SUPPORTED_FEATURES, ATTR_ENTITY_ID, - CONF_FRIENDLY_NAME, STATE_OFF, - SERVICE_TURN_OFF, SERVICE_TURN_ON, + STATE_OFF, SERVICE_TURN_OFF, SERVICE_TURN_ON, TEMP_FAHRENHEIT, TEMP_CELSIUS, + CONF_NAME, CONF_TYPE ) from homeassistant.components import ( switch, light, cover, media_player, group, fan, scene, script, climate @@ -26,8 +25,7 @@ from homeassistant.components import ( from homeassistant.util.unit_system import METRIC_SYSTEM from .const import ( - ATTR_GOOGLE_ASSISTANT_NAME, COMMAND_COLOR, - ATTR_GOOGLE_ASSISTANT_TYPE, + COMMAND_COLOR, COMMAND_BRIGHTNESS, COMMAND_ONOFF, COMMAND_ACTIVATESCENE, COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE, COMMAND_THERMOSTAT_SET_MODE, @@ -69,13 +67,22 @@ MAPPING_COMPONENT = { } # type: Dict[str, list] -Config = namedtuple('GoogleAssistantConfig', 'should_expose,agent_user_id') +class Config: + """Hold the configuration for Google Assistant.""" + + def __init__(self, should_expose, agent_user_id, entity_config=None): + """Initialize the configuration.""" + self.should_expose = should_expose + self.agent_user_id = agent_user_id + self.entity_config = entity_config or {} -def entity_to_device(entity: Entity, units: UnitSystem): +def entity_to_device(entity: Entity, config: Config, units: UnitSystem): """Convert a hass entity into an google actions device.""" + entity_config = config.entity_config.get(entity.entity_id, {}) class_data = MAPPING_COMPONENT.get( - entity.attributes.get(ATTR_GOOGLE_ASSISTANT_TYPE) or entity.domain) + entity_config.get(CONF_TYPE) or entity.domain) + if class_data is None: return None @@ -90,17 +97,12 @@ def entity_to_device(entity: Entity, units: UnitSystem): device['traits'].append(class_data[1]) # handle custom names - device['name']['name'] = \ - entity.attributes.get(ATTR_GOOGLE_ASSISTANT_NAME) or \ - entity.attributes.get(CONF_FRIENDLY_NAME) + device['name']['name'] = entity_config.get(CONF_NAME) or entity.name # use aliases - aliases = entity.attributes.get(CONF_ALIASES) + aliases = entity_config.get(CONF_ALIASES) if aliases: - if isinstance(aliases, list): - device['name']['nicknames'] = aliases - else: - _LOGGER.warning("%s must be a list", CONF_ALIASES) + device['name']['nicknames'] = aliases # add trait if entity supports feature if class_data[2]: @@ -322,7 +324,7 @@ def async_devices_sync(hass, config, payload): if not config.should_expose(entity): continue - device = entity_to_device(entity, hass.config.units) + device = entity_to_device(entity, config, hass.config.units) if device is None: _LOGGER.warning("No mapping for %s domain", entity.domain) continue diff --git a/tests/components/cloud/test_iot.py b/tests/components/cloud/test_iot.py index d829134eb21..529559f56af 100644 --- a/tests/components/cloud/test_iot.py +++ b/tests/components/cloud/test_iot.py @@ -317,6 +317,13 @@ def test_handler_google_actions(hass): 'filter': { 'exclude_entities': 'switch.test2' }, + 'entity_config': { + 'switch.test': { + 'name': 'Config name', + 'type': 'light', + 'aliases': 'Config alias' + } + } } } }) @@ -340,4 +347,6 @@ def test_handler_google_actions(hass): device = devices[0] assert device['id'] == 'switch.test' - assert device['name']['name'] == 'Test switch' + assert device['name']['name'] == 'Config name' + assert device['name']['nicknames'] == ['Config alias'] + assert device['type'] == 'action.devices.types.LIGHT' diff --git a/tests/components/google_assistant/test_google_assistant.py b/tests/components/google_assistant/test_google_assistant.py index ff6f53cf1a0..3b9ad7f3ef7 100644 --- a/tests/components/google_assistant/test_google_assistant.py +++ b/tests/components/google_assistant/test_google_assistant.py @@ -36,6 +36,15 @@ def assistant_client(loop, hass, test_client): 'project_id': PROJECT_ID, 'client_id': CLIENT_ID, 'access_token': ACCESS_TOKEN, + 'entity_config': { + 'light.ceiling_lights': { + 'aliases': ['top lights', 'ceiling lights'], + 'name': 'Roof Lights', + }, + 'switch.decorative_lights': { + 'type': 'light' + } + } } })) @@ -88,26 +97,6 @@ def hass_fixture(loop, hass): }] })) - # Kitchen light is explicitly excluded from being exposed - ceiling_lights_entity = hass.states.get('light.ceiling_lights') - attrs = dict(ceiling_lights_entity.attributes) - attrs[ga.const.ATTR_GOOGLE_ASSISTANT_NAME] = "Roof Lights" - attrs[ga.const.CONF_ALIASES] = ['top lights', 'ceiling lights'] - hass.states.async_set( - ceiling_lights_entity.entity_id, - ceiling_lights_entity.state, - attributes=attrs) - - # By setting the google_assistant_type = 'light' - # we can override how a device is reported to GA - switch_light = hass.states.get('switch.decorative_lights') - attrs = dict(switch_light.attributes) - attrs[ga.const.ATTR_GOOGLE_ASSISTANT_TYPE] = "light" - hass.states.async_set( - switch_light.entity_id, - switch_light.state, - attributes=attrs) - return hass From f56b3d8e9ce4a63521e36215f22c078a0e55c14c Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Wed, 10 Jan 2018 00:35:34 +0100 Subject: [PATCH 193/238] Upgrade lightify to 1.0.6.1 (#11545) --- .../components/light/osramlightify.py | 110 ++++++++---------- requirements_all.txt | 2 +- 2 files changed, 51 insertions(+), 61 deletions(-) diff --git a/homeassistant/components/light/osramlightify.py b/homeassistant/components/light/osramlightify.py index b5dbe7ebb4c..5785f0f1fc7 100644 --- a/homeassistant/components/light/osramlightify.py +++ b/homeassistant/components/light/osramlightify.py @@ -4,34 +4,36 @@ Support for Osram Lightify. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.osramlightify/ """ -import logging -import socket -import random from datetime import timedelta +import logging +import random +import socket import voluptuous as vol from homeassistant import util -from homeassistant.const import CONF_HOST from homeassistant.components.light import ( - Light, ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_RGB_COLOR, - ATTR_XY_COLOR, ATTR_TRANSITION, EFFECT_RANDOM, SUPPORT_BRIGHTNESS, - SUPPORT_EFFECT, SUPPORT_XY_COLOR, SUPPORT_COLOR_TEMP, SUPPORT_RGB_COLOR, - SUPPORT_TRANSITION, PLATFORM_SCHEMA) -from homeassistant.util.color import ( - color_temperature_mired_to_kelvin, color_temperature_kelvin_to_mired, - color_xy_brightness_to_RGB) + ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_RGB_COLOR, + ATTR_TRANSITION, ATTR_XY_COLOR, EFFECT_RANDOM, PLATFORM_SCHEMA, + SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_EFFECT, SUPPORT_RGB_COLOR, + SUPPORT_TRANSITION, SUPPORT_XY_COLOR, Light) +from homeassistant.const import CONF_HOST import homeassistant.helpers.config_validation as cv +from homeassistant.util.color import ( + color_temperature_kelvin_to_mired, color_temperature_mired_to_kelvin, + color_xy_brightness_to_RGB) -REQUIREMENTS = ['lightify==1.0.6'] +REQUIREMENTS = ['lightify==1.0.6.1'] _LOGGER = logging.getLogger(__name__) -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) -MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100) -CONF_ALLOW_LIGHTIFY_GROUPS = "allow_lightify_groups" +CONF_ALLOW_LIGHTIFY_GROUPS = 'allow_lightify_groups' + DEFAULT_ALLOW_LIGHTIFY_GROUPS = True +MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100) +MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) + SUPPORT_OSRAMLIGHTIFY = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_EFFECT | SUPPORT_RGB_COLOR | SUPPORT_TRANSITION | SUPPORT_XY_COLOR) @@ -46,20 +48,19 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Osram Lightify lights.""" import lightify + host = config.get(CONF_HOST) add_groups = config.get(CONF_ALLOW_LIGHTIFY_GROUPS) - if host: - try: - bridge = lightify.Lightify(host) - except socket.error as err: - msg = "Error connecting to bridge: {} due to: {}".format( - host, str(err)) - _LOGGER.exception(msg) - return False - setup_bridge(bridge, add_devices, add_groups) - else: - _LOGGER.error("No host found in configuration") - return False + + try: + bridge = lightify.Lightify(host) + except socket.error as err: + msg = "Error connecting to bridge: {} due to: {}".format( + host, str(err)) + _LOGGER.exception(msg) + return + + setup_bridge(bridge, add_devices, add_groups) def setup_bridge(bridge, add_devices_callback, add_groups): @@ -73,17 +74,16 @@ def setup_bridge(bridge, add_devices_callback, add_groups): bridge.update_all_light_status() bridge.update_group_list() except TimeoutError: - _LOGGER.error('Timeout during updating of lights.') + _LOGGER.error("Timeout during updating of lights") except OSError: - _LOGGER.error('OSError during updating of lights.') + _LOGGER.error("OSError during updating of lights") new_lights = [] for (light_id, light) in bridge.lights().items(): if light_id not in lights: - osram_light = OsramLightifyLight(light_id, light, - update_lights) - + osram_light = OsramLightifyLight( + light_id, light, update_lights) lights[light_id] = osram_light new_lights.append(osram_light) else: @@ -92,8 +92,8 @@ def setup_bridge(bridge, add_devices_callback, add_groups): if add_groups: for (group_name, group) in bridge.groups().items(): if group_name not in lights: - osram_group = OsramLightifyGroup(group, bridge, - update_lights) + osram_group = OsramLightifyGroup( + group, bridge, update_lights) lights[group_name] = osram_group new_lights.append(osram_group) else: @@ -106,10 +106,10 @@ def setup_bridge(bridge, add_devices_callback, add_groups): class Luminary(Light): - """ABS for Lightify Lights and Groups.""" + """Representation of Luminary Lights and Groups.""" def __init__(self, luminary, update_lights): - """Init Luminary object.""" + """Initize a Luminary light.""" self.update_lights = update_lights self._luminary = luminary self._brightness = None @@ -141,7 +141,7 @@ class Luminary(Light): @property def is_on(self): - """Update Status to True if device is on.""" + """Update status to True if device is on.""" return self._state @property @@ -170,8 +170,7 @@ class Luminary(Light): _LOGGER.debug("turn_on requested brightness for light: %s is: %s ", self._name, self._brightness) self._luminary.set_luminance( - int(self._brightness / 2.55), - transition) + int(self._brightness / 2.55), transition) else: self._luminary.set_onoff(1) @@ -187,8 +186,7 @@ class Luminary(Light): _LOGGER.debug("turn_on requested ATTR_XY_COLOR for light:" " %s is: %s,%s", self._name, x_mired, y_mired) red, green, blue = color_xy_brightness_to_RGB( - x_mired, y_mired, self._brightness - ) + x_mired, y_mired, self._brightness) self._luminary.set_rgb(red, green, blue, transition) if ATTR_COLOR_TEMP in kwargs: @@ -201,10 +199,9 @@ class Luminary(Light): if ATTR_EFFECT in kwargs: effect = kwargs.get(ATTR_EFFECT) if effect == EFFECT_RANDOM: - self._luminary.set_rgb(random.randrange(0, 255), - random.randrange(0, 255), - random.randrange(0, 255), - transition) + self._luminary.set_rgb( + random.randrange(0, 255), random.randrange(0, 255), + random.randrange(0, 255), transition) _LOGGER.debug("turn_on requested random effect for light: " "%s with transition %s", self._name, transition) @@ -212,19 +209,16 @@ class Luminary(Light): def turn_off(self, **kwargs): """Turn the device off.""" - _LOGGER.debug("turn_off Attempting to turn off light: %s ", - self._name) + _LOGGER.debug("Attempting to turn off light: %s", self._name) if ATTR_TRANSITION in kwargs: transition = int(kwargs[ATTR_TRANSITION] * 10) _LOGGER.debug("turn_off requested transition time for light:" - " %s is: %s ", - self._name, transition) + " %s is: %s ", self._name, transition) self._luminary.set_luminance(0, transition) else: transition = 0 _LOGGER.debug("turn_off requested transition time for light:" - " %s is: %s ", - self._name, transition) + " %s is: %s ", self._name, transition) self._luminary.set_onoff(0) self.schedule_update_ha_state() @@ -238,12 +232,12 @@ class OsramLightifyLight(Luminary): """Representation of an Osram Lightify Light.""" def __init__(self, light_id, light, update_lights): - """Initialize the light.""" + """Initialize the Lightify light.""" self._light_id = light_id super().__init__(light, update_lights) def update(self): - """Update status of a Light.""" + """Update status of a light.""" super().update() self._state = self._luminary.on() self._rgb = self._luminary.rgb() @@ -252,8 +246,7 @@ class OsramLightifyLight(Luminary): self._temperature = None else: self._temperature = color_temperature_kelvin_to_mired( - self._luminary.temp() - ) + self._luminary.temp()) self._brightness = int(self._luminary.lum() * 2.55) @@ -261,16 +254,13 @@ class OsramLightifyGroup(Luminary): """Representation of an Osram Lightify Group.""" def __init__(self, group, bridge, update_lights): - """Init light group.""" + """Initialize the Lightify light group.""" self._bridge = bridge self._light_ids = [] super().__init__(group, update_lights) def _get_state(self): - """Get state of group. - - The group is on, if any of the lights is on. - """ + """Get state of group.""" lights = self._bridge.lights() return any(lights[light_id].on() for light_id in self._light_ids) diff --git a/requirements_all.txt b/requirements_all.txt index 42b32259361..7a203df800c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -438,7 +438,7 @@ libsoundtouch==0.7.2 liffylights==0.9.4 # homeassistant.components.light.osramlightify -lightify==1.0.6 +lightify==1.0.6.1 # homeassistant.components.light.limitlessled limitlessled==1.0.8 From cba55402b1b4e44d5f3be2e67ede6748fc1d6a84 Mon Sep 17 00:00:00 2001 From: cdce8p <30130371+cdce8p@users.noreply.github.com> Date: Wed, 10 Jan 2018 01:00:49 +0100 Subject: [PATCH 194/238] Improved test runtime (#11553) * Remove redundent assert statements and cleanup * Added 'get_date' function * Replace 'freeze_time' with 'mock.patch' * Tox in 185s (py35) * Removed obsolete 'freeze_time' from test_updater * Tox 162s (py35) * Remove test requirement 'freezegun' * Fixed flake8 errors * Added 'mock.patch' for 'feedparser.parse' * Made 'FUNCTION_PATH' a constant * Remove debug statements. --- .../components/binary_sensor/workday.py | 9 +- requirements_test.txt | 1 - requirements_test_all.txt | 1 - .../components/binary_sensor/test_workday.py | 114 +++++++++--------- .../components/sensor/test_geo_rss_events.py | 7 +- tests/components/test_updater.py | 4 - 6 files changed, 66 insertions(+), 70 deletions(-) diff --git a/homeassistant/components/binary_sensor/workday.py b/homeassistant/components/binary_sensor/workday.py index f48525d41a8..83dc51a2e0f 100644 --- a/homeassistant/components/binary_sensor/workday.py +++ b/homeassistant/components/binary_sensor/workday.py @@ -64,7 +64,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): excludes = config.get(CONF_EXCLUDES) days_offset = config.get(CONF_OFFSET) - year = (datetime.now() + timedelta(days=days_offset)).year + year = (get_date(datetime.today()) + timedelta(days=days_offset)).year obj_holidays = getattr(holidays, country)(years=year) if province: @@ -99,6 +99,11 @@ def day_to_string(day): return None +def get_date(date): + """Return date. Needed for testing.""" + return date + + class IsWorkdaySensor(BinarySensorDevice): """Implementation of a Workday sensor.""" @@ -156,7 +161,7 @@ class IsWorkdaySensor(BinarySensorDevice): self._state = False # Get iso day of the week (1 = Monday, 7 = Sunday) - date = datetime.today() + timedelta(days=self._days_offset) + date = get_date(datetime.today()) + timedelta(days=self._days_offset) day = date.isoweekday() - 1 day_of_week = day_to_string(day) diff --git a/requirements_test.txt b/requirements_test.txt index 94258f4ffe4..22bb6623e16 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -15,4 +15,3 @@ requests_mock==1.4 mock-open==1.3.1 flake8-docstrings==1.0.2 asynctest>=0.11.1 -freezegun==0.3.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b8adba02639..65afd940267 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -16,7 +16,6 @@ requests_mock==1.4 mock-open==1.3.1 flake8-docstrings==1.0.2 asynctest>=0.11.1 -freezegun==0.3.9 # homeassistant.components.notify.html5 diff --git a/tests/components/binary_sensor/test_workday.py b/tests/components/binary_sensor/test_workday.py index 6abfa89d435..af7e856e417 100644 --- a/tests/components/binary_sensor/test_workday.py +++ b/tests/components/binary_sensor/test_workday.py @@ -1,5 +1,7 @@ """Tests the HASS workday binary sensor.""" -from freezegun import freeze_time +from datetime import date +from unittest.mock import patch + from homeassistant.components.binary_sensor.workday import day_to_string from homeassistant.setup import setup_component @@ -7,6 +9,9 @@ from tests.common import ( get_test_home_assistant, assert_setup_component) +FUNCTION_PATH = 'homeassistant.components.binary_sensor.workday.get_date' + + class TestWorkdaySetup(object): """Test class for workday sensor.""" @@ -94,46 +99,45 @@ class TestWorkdaySetup(object): def test_setup_component_province(self): """Setup workday component.""" with assert_setup_component(1, 'binary_sensor'): - setup_component(self.hass, 'binary_sensor', self.config_province) + setup_component(self.hass, 'binary_sensor', + self.config_province) - assert self.hass.states.get('binary_sensor.workday_sensor') is not None + entity = self.hass.states.get('binary_sensor.workday_sensor') + assert entity is not None - # Freeze time to a workday - @freeze_time("Mar 15th, 2017") - def test_workday_province(self): + # Freeze time to a workday - Mar 15th, 2017 + @patch(FUNCTION_PATH, return_value=date(2017, 3, 15)) + def test_workday_province(self, mock_date): """Test if workdays are reported correctly.""" with assert_setup_component(1, 'binary_sensor'): - setup_component(self.hass, 'binary_sensor', self.config_province) - - assert self.hass.states.get('binary_sensor.workday_sensor') is not None + setup_component(self.hass, 'binary_sensor', + self.config_province) self.hass.start() entity = self.hass.states.get('binary_sensor.workday_sensor') assert entity.state == 'on' - # Freeze time to a weekend - @freeze_time("Mar 12th, 2017") - def test_weekend_province(self): + # Freeze time to a weekend - Mar 12th, 2017 + @patch(FUNCTION_PATH, return_value=date(2017, 3, 12)) + def test_weekend_province(self, mock_date): """Test if weekends are reported correctly.""" with assert_setup_component(1, 'binary_sensor'): - setup_component(self.hass, 'binary_sensor', self.config_province) - - assert self.hass.states.get('binary_sensor.workday_sensor') is not None + setup_component(self.hass, 'binary_sensor', + self.config_province) self.hass.start() entity = self.hass.states.get('binary_sensor.workday_sensor') assert entity.state == 'off' - # Freeze time to a public holiday in province BW - @freeze_time("Jan 6th, 2017") - def test_public_holiday_province(self): + # Freeze time to a public holiday in province BW - Jan 6th, 2017 + @patch(FUNCTION_PATH, return_value=date(2017, 1, 6)) + def test_public_holiday_province(self, mock_date): """Test if public holidays are reported correctly.""" with assert_setup_component(1, 'binary_sensor'): - setup_component(self.hass, 'binary_sensor', self.config_province) - - assert self.hass.states.get('binary_sensor.workday_sensor') is not None + setup_component(self.hass, 'binary_sensor', + self.config_province) self.hass.start() @@ -143,47 +147,44 @@ class TestWorkdaySetup(object): def test_setup_component_noprovince(self): """Setup workday component.""" with assert_setup_component(1, 'binary_sensor'): - setup_component(self.hass, 'binary_sensor', self.config_noprovince) + setup_component(self.hass, 'binary_sensor', + self.config_noprovince) - assert self.hass.states.get('binary_sensor.workday_sensor') is not None + entity = self.hass.states.get('binary_sensor.workday_sensor') + assert entity is not None - # Freeze time to a public holiday in province BW - @freeze_time("Jan 6th, 2017") - def test_public_holiday_noprovince(self): + # Freeze time to a public holiday in province BW - Jan 6th, 2017 + @patch(FUNCTION_PATH, return_value=date(2017, 1, 6)) + def test_public_holiday_noprovince(self, mock_date): """Test if public holidays are reported correctly.""" with assert_setup_component(1, 'binary_sensor'): - setup_component(self.hass, 'binary_sensor', self.config_noprovince) - - assert self.hass.states.get('binary_sensor.workday_sensor') is not None + setup_component(self.hass, 'binary_sensor', + self.config_noprovince) self.hass.start() entity = self.hass.states.get('binary_sensor.workday_sensor') assert entity.state == 'on' - # Freeze time to a public holiday in state CA - @freeze_time("Mar 31st, 2017") - def test_public_holiday_state(self): + # Freeze time to a public holiday in state CA - Mar 31st, 2017 + @patch(FUNCTION_PATH, return_value=date(2017, 3, 31)) + def test_public_holiday_state(self, mock_date): """Test if public holidays are reported correctly.""" with assert_setup_component(1, 'binary_sensor'): setup_component(self.hass, 'binary_sensor', self.config_state) - assert self.hass.states.get('binary_sensor.workday_sensor') is not None - self.hass.start() entity = self.hass.states.get('binary_sensor.workday_sensor') assert entity.state == 'off' - # Freeze time to a public holiday in state CA - @freeze_time("Mar 31st, 2017") - def test_public_holiday_nostate(self): + # Freeze time to a public holiday in state CA - Mar 31st, 2017 + @patch(FUNCTION_PATH, return_value=date(2017, 3, 31)) + def test_public_holiday_nostate(self, mock_date): """Test if public holidays are reported correctly.""" with assert_setup_component(1, 'binary_sensor'): setup_component(self.hass, 'binary_sensor', self.config_nostate) - assert self.hass.states.get('binary_sensor.workday_sensor') is not None - self.hass.start() entity = self.hass.states.get('binary_sensor.workday_sensor') @@ -195,63 +196,56 @@ class TestWorkdaySetup(object): setup_component(self.hass, 'binary_sensor', self.config_invalidprovince) - assert self.hass.states.get('binary_sensor.workday_sensor') is None + entity = self.hass.states.get('binary_sensor.workday_sensor') + assert entity is None - # Freeze time to a public holiday in province BW - @freeze_time("Jan 6th, 2017") - def test_public_holiday_includeholiday(self): + # Freeze time to a public holiday in province BW - Jan 6th, 2017 + @patch(FUNCTION_PATH, return_value=date(2017, 1, 6)) + def test_public_holiday_includeholiday(self, mock_date): """Test if public holidays are reported correctly.""" with assert_setup_component(1, 'binary_sensor'): setup_component(self.hass, 'binary_sensor', self.config_includeholiday) - assert self.hass.states.get('binary_sensor.workday_sensor') is not None - self.hass.start() entity = self.hass.states.get('binary_sensor.workday_sensor') assert entity.state == 'on' - # Freeze time to a saturday to test offset - @freeze_time("Aug 5th, 2017") - def test_tomorrow(self): + # Freeze time to a saturday to test offset - Aug 5th, 2017 + @patch(FUNCTION_PATH, return_value=date(2017, 8, 5)) + def test_tomorrow(self, mock_date): """Test if tomorrow are reported correctly.""" with assert_setup_component(1, 'binary_sensor'): setup_component(self.hass, 'binary_sensor', self.config_tomorrow) - assert self.hass.states.get('binary_sensor.workday_sensor') is not None - self.hass.start() entity = self.hass.states.get('binary_sensor.workday_sensor') assert entity.state == 'off' - # Freeze time to a saturday to test offset - @freeze_time("Aug 5th, 2017") - def test_day_after_tomorrow(self): + # Freeze time to a saturday to test offset - Aug 5th, 2017 + @patch(FUNCTION_PATH, return_value=date(2017, 8, 5)) + def test_day_after_tomorrow(self, mock_date): """Test if the day after tomorrow are reported correctly.""" with assert_setup_component(1, 'binary_sensor'): setup_component(self.hass, 'binary_sensor', self.config_day_after_tomorrow) - assert self.hass.states.get('binary_sensor.workday_sensor') is not None - self.hass.start() entity = self.hass.states.get('binary_sensor.workday_sensor') assert entity.state == 'on' - # Freeze time to a saturday to test offset - @freeze_time("Aug 5th, 2017") - def test_yesterday(self): + # Freeze time to a saturday to test offset - Aug 5th, 2017 + @patch(FUNCTION_PATH, return_value=date(2017, 8, 5)) + def test_yesterday(self, mock_date): """Test if yesterday are reported correctly.""" with assert_setup_component(1, 'binary_sensor'): setup_component(self.hass, 'binary_sensor', self.config_yesterday) - assert self.hass.states.get('binary_sensor.workday_sensor') is not None - self.hass.start() entity = self.hass.states.get('binary_sensor.workday_sensor') diff --git a/tests/components/sensor/test_geo_rss_events.py b/tests/components/sensor/test_geo_rss_events.py index 557def8225b..f9ec83cc8be 100644 --- a/tests/components/sensor/test_geo_rss_events.py +++ b/tests/components/sensor/test_geo_rss_events.py @@ -1,6 +1,7 @@ """The test for the geo rss events sensor platform.""" import unittest from unittest import mock +import feedparser from homeassistant.setup import setup_component from tests.common import load_fixture, get_test_home_assistant @@ -33,7 +34,8 @@ class TestGeoRssServiceUpdater(unittest.TestCase): """Stop everything that was started.""" self.hass.stop() - def test_setup_with_categories(self): + @mock.patch('feedparser.parse', return_value=feedparser.parse("")) + def test_setup_with_categories(self, mock_parse): """Test the general setup of this sensor.""" self.config = VALID_CONFIG_WITH_CATEGORIES self.assertTrue( @@ -43,7 +45,8 @@ class TestGeoRssServiceUpdater(unittest.TestCase): self.assertIsNotNone( self.hass.states.get('sensor.event_service_category_2')) - def test_setup_without_categories(self): + @mock.patch('feedparser.parse', return_value=feedparser.parse("")) + def test_setup_without_categories(self, mock_parse): """Test the general setup of this sensor.""" self.assertTrue( setup_component(self.hass, 'sensor', {'sensor': self.config})) diff --git a/tests/components/test_updater.py b/tests/components/test_updater.py index d331b73849b..6d68add93a5 100644 --- a/tests/components/test_updater.py +++ b/tests/components/test_updater.py @@ -3,7 +3,6 @@ import asyncio from datetime import timedelta from unittest.mock import patch, Mock -from freezegun import freeze_time import pytest from homeassistant.setup import async_setup_component @@ -39,7 +38,6 @@ def mock_get_uuid(): @asyncio.coroutine -@freeze_time("Mar 15th, 2017") def test_new_version_shows_entity_after_hour( hass, mock_get_uuid, mock_get_newest_version): """Test if new entity is created if new version is available.""" @@ -59,7 +57,6 @@ def test_new_version_shows_entity_after_hour( @asyncio.coroutine -@freeze_time("Mar 15th, 2017") def test_same_version_not_show_entity( hass, mock_get_uuid, mock_get_newest_version): """Test if new entity is created if new version is available.""" @@ -79,7 +76,6 @@ def test_same_version_not_show_entity( @asyncio.coroutine -@freeze_time("Mar 15th, 2017") def test_disable_reporting(hass, mock_get_uuid, mock_get_newest_version): """Test if new entity is created if new version is available.""" mock_get_uuid.return_value = MOCK_HUUID From 92014bf1d14311eeffcda8fa8d9a30ebe2a9f585 Mon Sep 17 00:00:00 2001 From: Eric Pignet Date: Wed, 10 Jan 2018 14:05:21 +1100 Subject: [PATCH 195/238] Add 2 media_player services and 1 custom service to Squeezebox platform (#10969) * Add 2 media_player services and 1 custom service to Squeezebox platform * Fix pylint error * Remove apostrophe in example * Split method into command and parameters * Fix Lint error --- .../components/media_player/services.yaml | 13 +++ .../components/media_player/squeezebox.py | 93 ++++++++++++++++++- 2 files changed, 103 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/media_player/services.yaml b/homeassistant/components/media_player/services.yaml index 0ed5f9d2732..fe8280fb2ab 100644 --- a/homeassistant/components/media_player/services.yaml +++ b/homeassistant/components/media_player/services.yaml @@ -307,3 +307,16 @@ kodi_call_method: method: description: Name of the Kodi JSONRPC API method to be called. example: 'VideoLibrary.GetRecentlyAddedEpisodes' + +squeezebox_call_method: + description: 'Call a Squeezebox JSON/RPC API method.' + fields: + entity_id: + description: Name(s) of the Squeexebox entities where to run the API method. + example: 'media_player.squeezebox_radio' + command: + description: Name of the Squeezebox command. + example: 'playlist' + parameters: + description: Optional array of parameters to be appended to the command. See 'Command Line Interface' official help page from Logitech for details. + example: '["loadtracks", "track.titlesearch=highway to hell"]' diff --git a/homeassistant/components/media_player/squeezebox.py b/homeassistant/components/media_player/squeezebox.py index a4a15fbce24..b97a4525691 100644 --- a/homeassistant/components/media_player/squeezebox.py +++ b/homeassistant/components/media_player/squeezebox.py @@ -8,19 +8,22 @@ import logging import asyncio import urllib.parse import json +import os import aiohttp import async_timeout import voluptuous as vol +from homeassistant.config import load_yaml_config_file from homeassistant.components.media_player import ( ATTR_MEDIA_ENQUEUE, SUPPORT_PLAY_MEDIA, MEDIA_TYPE_MUSIC, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, PLATFORM_SCHEMA, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, - SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_PLAY, MediaPlayerDevice) + SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_PLAY, MediaPlayerDevice, + MEDIA_PLAYER_SCHEMA, DOMAIN, SUPPORT_SHUFFLE_SET, SUPPORT_CLEAR_PLAYLIST) from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_USERNAME, STATE_IDLE, STATE_OFF, - STATE_PAUSED, STATE_PLAYING, STATE_UNKNOWN, CONF_PORT) + STATE_PAUSED, STATE_PLAYING, STATE_UNKNOWN, CONF_PORT, ATTR_COMMAND) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util.dt import utcnow @@ -33,7 +36,7 @@ TIMEOUT = 10 SUPPORT_SQUEEZEBOX = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | \ SUPPORT_VOLUME_MUTE | SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | \ SUPPORT_SEEK | SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PLAY_MEDIA | \ - SUPPORT_PLAY + SUPPORT_PLAY | SUPPORT_SHUFFLE_SET | SUPPORT_CLEAR_PLAYLIST PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, @@ -42,12 +45,33 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_USERNAME): cv.string, }) +SERVICE_CALL_METHOD = 'squeezebox_call_method' + +DATA_SQUEEZEBOX = 'squeexebox' + +ATTR_PARAMETERS = 'parameters' + +SQUEEZEBOX_CALL_METHOD_SCHEMA = MEDIA_PLAYER_SCHEMA.extend({ + vol.Required(ATTR_COMMAND): cv.string, + vol.Optional(ATTR_PARAMETERS): + vol.All(cv.ensure_list, vol.Length(min=1), [cv.string]), +}) + +SERVICE_TO_METHOD = { + SERVICE_CALL_METHOD: { + 'method': 'async_call_method', + 'schema': SQUEEZEBOX_CALL_METHOD_SCHEMA}, +} + @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the squeezebox platform.""" import socket + if DATA_SQUEEZEBOX not in hass.data: + hass.data[DATA_SQUEEZEBOX] = [] + username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) @@ -74,8 +98,44 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): lms = LogitechMediaServer(hass, host, port, username, password) players = yield from lms.create_players() + + hass.data[DATA_SQUEEZEBOX].extend(players) async_add_devices(players) + @asyncio.coroutine + def async_service_handler(service): + """Map services to methods on MediaPlayerDevice.""" + method = SERVICE_TO_METHOD.get(service.service) + if not method: + return + + params = {key: value for key, value in service.data.items() + if key != 'entity_id'} + entity_ids = service.data.get('entity_id') + if entity_ids: + target_players = [player for player in hass.data[DATA_SQUEEZEBOX] + if player.entity_id in entity_ids] + else: + target_players = hass.data[DATA_SQUEEZEBOX] + + update_tasks = [] + for player in target_players: + yield from getattr(player, method['method'])(**params) + update_tasks.append(player.async_update_ha_state(True)) + + if update_tasks: + yield from asyncio.wait(update_tasks, loop=hass.loop) + + descriptions = yield from hass.async_add_job( + load_yaml_config_file, os.path.join( + os.path.dirname(__file__), 'services.yaml')) + + for service in SERVICE_TO_METHOD: + schema = SERVICE_TO_METHOD[service]['schema'] + hass.services.async_register( + DOMAIN, service, async_service_handler, + description=descriptions.get(service), schema=schema) + return True @@ -305,6 +365,12 @@ class SqueezeBoxDevice(MediaPlayerDevice): if 'album' in self._status: return self._status['album'] + @property + def shuffle(self): + """Boolean if shuffle is enabled.""" + if 'playlist_shuffle' in self._status: + return self._status['playlist_shuffle'] == 1 + @property def supported_features(self): """Flag media player features that are supported.""" @@ -415,3 +481,24 @@ class SqueezeBoxDevice(MediaPlayerDevice): def _add_uri_to_playlist(self, media_id): """Add a items to the existing playlist.""" return self.async_query('playlist', 'add', media_id) + + def async_set_shuffle(self, shuffle): + """Enable/disable shuffle mode.""" + return self.async_query('playlist', 'shuffle', int(shuffle)) + + def async_clear_playlist(self): + """Send the media player the command for clear playlist.""" + return self.async_query('playlist', 'clear') + + def async_call_method(self, command, parameters=None): + """ + Call Squeezebox JSON/RPC method. + + Escaped optional parameters are added to the command to form the list + of positional parameters (p0, p1..., pN) passed to JSON/RPC server. + """ + all_params = [command] + if parameters: + for parameter in parameters: + all_params.append(urllib.parse.quote(parameter, safe=':=')) + return self.async_query(*all_params) From cf04a81f70d57e8734c06fe3f491e8c3ddfc3795 Mon Sep 17 00:00:00 2001 From: Phil Kates Date: Tue, 9 Jan 2018 19:47:24 -0800 Subject: [PATCH 196/238] Fix error on entity_config missing (#11561) If the `google_assistant` key exists in the config but has no `entity_config` key under it you'll get an error. ``` File "/Users/pkates/src/home-assistant/homeassistant/components/google_assistant/http.py", line 51, in is_exposed entity_config.get(entity.entity_id, {}).get(CONF_EXPOSE) AttributeError: 'NoneType' object has no attribute 'get' ``` --- homeassistant/components/google_assistant/http.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index 47bdd0acb68..f376435d2ef 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -39,7 +39,7 @@ def async_register_http(hass, cfg): expose_by_default = cfg.get(CONF_EXPOSE_BY_DEFAULT) exposed_domains = cfg.get(CONF_EXPOSED_DOMAINS) agent_user_id = cfg.get(CONF_AGENT_USER_ID) - entity_config = cfg.get(CONF_ENTITY_CONFIG) + entity_config = cfg.get(CONF_ENTITY_CONFIG) or {} def is_exposed(entity) -> bool: """Determine if an entity should be exposed to Google Assistant.""" From c4bc42d527cf3d7fd3e432c626518c8e9ff15e8f Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Wed, 10 Jan 2018 04:51:35 +0100 Subject: [PATCH 197/238] Upgrade keyring to 10.3.2 (#11531) --- homeassistant/scripts/keyring.py | 10 +++++----- requirements_all.txt | 5 ++++- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/homeassistant/scripts/keyring.py b/homeassistant/scripts/keyring.py index e9eedeaa300..922bd9c7fe1 100644 --- a/homeassistant/scripts/keyring.py +++ b/homeassistant/scripts/keyring.py @@ -1,11 +1,11 @@ -"""Script to get, set, and delete secrets stored in the keyring.""" -import os +"""Script to get, set and delete secrets stored in the keyring.""" import argparse import getpass +import os from homeassistant.util.yaml import _SECRET_NAMESPACE -REQUIREMENTS = ['keyring>=9.3,<10.0'] +REQUIREMENTS = ['keyring==10.3.2', 'keyrings.alt==2.3'] def run(args): @@ -39,8 +39,8 @@ def run(args): return 1 if args.action == 'set': - the_secret = getpass.getpass('Please enter the secret for {}: ' - .format(args.name)) + the_secret = getpass.getpass( + 'Please enter the secret for {}: '.format(args.name)) keyring.set_password(_SECRET_NAMESPACE, args.name, the_secret) print('Secret {} set successfully'.format(args.name)) elif args.action == 'get': diff --git a/requirements_all.txt b/requirements_all.txt index 7a203df800c..e868cf68d58 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -416,7 +416,10 @@ jsonrpc-async==0.6 jsonrpc-websocket==0.5 # homeassistant.scripts.keyring -keyring>=9.3,<10.0 +keyring==10.3.2 + +# homeassistant.scripts.keyring +keyrings.alt==2.3 # homeassistant.components.device_tracker.owntracks # homeassistant.components.device_tracker.owntracks_http From 3cba09c6f62a63d6245b82f2bc498640e618f6d9 Mon Sep 17 00:00:00 2001 From: Nolan Gilley Date: Wed, 10 Jan 2018 01:47:22 -0500 Subject: [PATCH 198/238] Coinbase.com sensor platform (#11036) * coinbase sensors use hass.data, load_platform * add exchange rate sensors dont pass complex object over event bus * check for auth error --- .coveragerc | 3 + homeassistant/components/coinbase.py | 90 +++++++++++++ homeassistant/components/sensor/coinbase.py | 141 ++++++++++++++++++++ requirements_all.txt | 3 + 4 files changed, 237 insertions(+) create mode 100755 homeassistant/components/coinbase.py create mode 100755 homeassistant/components/sensor/coinbase.py diff --git a/.coveragerc b/.coveragerc index 4ab2bb25637..d0026245d03 100644 --- a/.coveragerc +++ b/.coveragerc @@ -50,6 +50,9 @@ omit = homeassistant/components/bloomsky.py homeassistant/components/*/bloomsky.py + homeassistant/components/coinbase.py + homeassistant/components/sensor/coinbase.py + homeassistant/components/comfoconnect.py homeassistant/components/*/comfoconnect.py diff --git a/homeassistant/components/coinbase.py b/homeassistant/components/coinbase.py new file mode 100755 index 00000000000..bdb091325cf --- /dev/null +++ b/homeassistant/components/coinbase.py @@ -0,0 +1,90 @@ +""" +Support for Coinbase. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/coinbase/ +""" +from datetime import timedelta + +import logging +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_API_KEY +from homeassistant.util import Throttle +from homeassistant.helpers.discovery import load_platform + +REQUIREMENTS = ['coinbase==2.0.6'] +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'coinbase' + +CONF_API_SECRET = 'api_secret' +CONF_EXCHANGE_CURRENCIES = 'exchange_rate_currencies' + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) + +DATA_COINBASE = 'coinbase_cache' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_API_SECRET): cv.string, + vol.Optional(CONF_EXCHANGE_CURRENCIES, default=[]): + vol.All(cv.ensure_list, [cv.string]) + }) +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the Coinbase component. + + Will automatically setup sensors to support + wallets discovered on the network. + """ + api_key = config[DOMAIN].get(CONF_API_KEY) + api_secret = config[DOMAIN].get(CONF_API_SECRET) + exchange_currencies = config[DOMAIN].get(CONF_EXCHANGE_CURRENCIES) + + hass.data[DATA_COINBASE] = coinbase_data = CoinbaseData(api_key, + api_secret) + + if not hasattr(coinbase_data, 'accounts'): + return False + for account in coinbase_data.accounts.data: + load_platform(hass, 'sensor', DOMAIN, + {'account': account}, config) + for currency in exchange_currencies: + if currency not in coinbase_data.exchange_rates.rates: + _LOGGER.warning("Currency %s not found", currency) + continue + native = coinbase_data.exchange_rates.currency + load_platform(hass, + 'sensor', + DOMAIN, + {'native_currency': native, + 'exchange_currency': currency}, + config) + + return True + + +class CoinbaseData(object): + """Get the latest data and update the states.""" + + def __init__(self, api_key, api_secret): + """Init the coinbase data object.""" + from coinbase.wallet.client import Client + self.client = Client(api_key, api_secret) + self.update() + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data from coinbase.""" + from coinbase.wallet.error import AuthenticationError + try: + self.accounts = self.client.get_accounts() + self.exchange_rates = self.client.get_exchange_rates() + except AuthenticationError as coinbase_error: + _LOGGER.error("Authentication error connecting" + " to coinbase: %s", coinbase_error) diff --git a/homeassistant/components/sensor/coinbase.py b/homeassistant/components/sensor/coinbase.py new file mode 100755 index 00000000000..d66c7d4e4b6 --- /dev/null +++ b/homeassistant/components/sensor/coinbase.py @@ -0,0 +1,141 @@ +""" +Support for Coinbase sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.coinbase/ +""" +from homeassistant.helpers.entity import Entity +from homeassistant.const import ATTR_ATTRIBUTION + + +DEPENDENCIES = ['coinbase'] + +DATA_COINBASE = 'coinbase_cache' + +CONF_ATTRIBUTION = "Data provided by coinbase.com" +ATTR_NATIVE_BALANCE = "Balance in native currency" + +BTC_ICON = 'mdi:currency-btc' +ETH_ICON = 'mdi:currency-eth' +COIN_ICON = 'mdi:coin' + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Coinbase sensors.""" + if discovery_info is None: + return + if 'account' in discovery_info: + account = discovery_info['account'] + sensor = AccountSensor(hass.data[DATA_COINBASE], + account['name'], + account['balance']['currency']) + if 'exchange_currency' in discovery_info: + sensor = ExchangeRateSensor(hass.data[DATA_COINBASE], + discovery_info['exchange_currency'], + discovery_info['native_currency']) + + add_devices([sensor], True) + + +class AccountSensor(Entity): + """Representation of a Coinbase.com sensor.""" + + def __init__(self, coinbase_data, name, currency): + """Initialize the sensor.""" + self._coinbase_data = coinbase_data + self._name = "Coinbase {}".format(name) + self._state = None + self._unit_of_measurement = currency + self._native_balance = None + self._native_currency = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement this sensor expresses itself in.""" + return self._unit_of_measurement + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + if self._name == "Coinbase BTC Wallet": + return BTC_ICON + if self._name == "Coinbase ETH Wallet": + return ETH_ICON + return COIN_ICON + + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + return { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + ATTR_NATIVE_BALANCE: "{} {}".format(self._native_balance, + self._native_currency) + } + + def update(self): + """Get the latest state of the sensor.""" + self._coinbase_data.update() + for account in self._coinbase_data.accounts['data']: + if self._name == "Coinbase {}".format(account['name']): + self._state = account['balance']['amount'] + self._native_balance = account['native_balance']['amount'] + self._native_currency = account['native_balance']['currency'] + + +class ExchangeRateSensor(Entity): + """Representation of a Coinbase.com sensor.""" + + def __init__(self, coinbase_data, exchange_currency, native_currency): + """Initialize the sensor.""" + self._coinbase_data = coinbase_data + self.currency = exchange_currency + self._name = "{} Exchange Rate".format(exchange_currency) + self._state = None + self._unit_of_measurement = native_currency + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement this sensor expresses itself in.""" + return self._unit_of_measurement + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + if self._name == "BTC Exchange Rate": + return BTC_ICON + if self._name == "ETH Exchange Rate": + return ETH_ICON + return COIN_ICON + + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + return { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION + } + + def update(self): + """Get the latest state of the sensor.""" + self._coinbase_data.update() + rate = self._coinbase_data.exchange_rates.rates[self.currency] + self._state = round(1 / float(rate), 2) diff --git a/requirements_all.txt b/requirements_all.txt index e868cf68d58..bc28ddd3b69 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -166,6 +166,9 @@ caldav==0.5.0 # homeassistant.components.notify.ciscospark ciscosparkapi==0.4.2 +# homeassistant.components.coinbase +coinbase==2.0.6 + # homeassistant.components.sensor.coinmarketcap coinmarketcap==4.1.1 From 4dda842b16646b9f3863366a279d4dc65f262ece Mon Sep 17 00:00:00 2001 From: Andrea Campi Date: Wed, 10 Jan 2018 07:05:04 +0000 Subject: [PATCH 199/238] Try to fix crashes after Hue refactoring (#11270) * Try to fix crashes after Hue refactoring Refs #11183 * Fix usage of dispatcher_send via helper. * Address review feedback. --- homeassistant/components/hue.py | 5 +- homeassistant/components/light/hue.py | 29 ++- tests/components/light/test_hue.py | 298 +++++++++++++++----------- 3 files changed, 202 insertions(+), 130 deletions(-) diff --git a/homeassistant/components/hue.py b/homeassistant/components/hue.py index 302c8be7598..a83b55e84e5 100644 --- a/homeassistant/components/hue.py +++ b/homeassistant/components/hue.py @@ -152,6 +152,7 @@ class HueBridge(object): allow_in_emulated_hue=True, allow_hue_groups=True): """Initialize the system.""" self.host = host + self.bridge_id = socket.gethostbyname(host) self.hass = hass self.filename = filename self.allow_unreachable = allow_unreachable @@ -165,7 +166,7 @@ class HueBridge(object): self.configured = False self.config_request_id = None - hass.data[DOMAIN][socket.gethostbyname(host)] = self + hass.data[DOMAIN][self.bridge_id] = self def setup(self): """Set up a phue bridge based on host parameter.""" @@ -196,7 +197,7 @@ class HueBridge(object): discovery.load_platform( self.hass, 'light', DOMAIN, - {'bridge_id': socket.gethostbyname(self.host)}) + {'bridge_id': self.bridge_id}) # create a service for calling run_scene directly on the bridge, # used to simplify automation rules. diff --git a/homeassistant/components/light/hue.py b/homeassistant/components/light/hue.py index 64e5dff0d26..f4ea04240f1 100644 --- a/homeassistant/components/light/hue.py +++ b/homeassistant/components/light/hue.py @@ -4,6 +4,7 @@ This component provides light support for the Philips Hue system. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.hue/ """ +import asyncio from datetime import timedelta import logging import random @@ -14,9 +15,6 @@ import voluptuous as vol import homeassistant.components.hue as hue -import homeassistant.util as util -from homeassistant.util import yaml -import homeassistant.util.color as color_util from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_FLASH, ATTR_RGB_COLOR, ATTR_TRANSITION, ATTR_XY_COLOR, EFFECT_COLORLOOP, EFFECT_RANDOM, @@ -24,8 +22,10 @@ from homeassistant.components.light import ( SUPPORT_EFFECT, SUPPORT_FLASH, SUPPORT_RGB_COLOR, SUPPORT_TRANSITION, SUPPORT_XY_COLOR, Light, PLATFORM_SCHEMA) from homeassistant.const import CONF_FILENAME, CONF_HOST, DEVICE_DEFAULT_NAME -from homeassistant.components.emulated_hue import ATTR_EMULATED_HUE_HIDDEN import homeassistant.helpers.config_validation as cv +import homeassistant.util as util +from homeassistant.util import yaml +import homeassistant.util.color as color_util DEPENDENCIES = ['hue'] @@ -49,6 +49,7 @@ SUPPORT_HUE = { 'Color temperature light': SUPPORT_HUE_COLOR_TEMP } +ATTR_EMULATED_HUE_HIDDEN = 'emulated_hue_hidden' ATTR_IS_HUE_GROUP = 'is_hue_group' # Legacy configuration, will be removed in 0.60 @@ -83,6 +84,8 @@ This configuration is deprecated, please check the information. """ +SIGNAL_CALLBACK = 'hue_light_callback_{}_{}' + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Hue lights.""" @@ -163,7 +166,10 @@ def process_lights(hass, api, bridge, update_lights_cb): new_lights.append(bridge.lights[light_id]) else: bridge.lights[light_id].info = info - bridge.lights[light_id].schedule_update_ha_state() + hass.helpers.dispatcher.dispatcher_send( + SIGNAL_CALLBACK.format( + bridge.bridge_id, + bridge.lights[light_id].light_id)) return new_lights @@ -193,7 +199,10 @@ def process_groups(hass, api, bridge, update_lights_cb): new_lights.append(bridge.lightgroups[lightgroup_id]) else: bridge.lightgroups[lightgroup_id].info = info - bridge.lightgroups[lightgroup_id].schedule_update_ha_state() + hass.helpers.dispatcher.dispatcher_send( + SIGNAL_CALLBACK.format( + bridge.bridge_id, + bridge.lightgroups[lightgroup_id].light_id)) return new_lights @@ -366,3 +375,11 @@ class HueLight(Light): if self.is_group: attributes[ATTR_IS_HUE_GROUP] = self.is_group return attributes + + @asyncio.coroutine + def async_added_to_hass(self): + """Register update callback.""" + dev_id = self.bridge.bridge_id, self.light_id + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_CALLBACK.format(*dev_id), + self.async_schedule_update_ha_state) diff --git a/tests/components/light/test_hue.py b/tests/components/light/test_hue.py index b612fa15931..611f1240d45 100644 --- a/tests/components/light/test_hue.py +++ b/tests/components/light/test_hue.py @@ -12,6 +12,8 @@ from tests.common import get_test_home_assistant, MockDependency _LOGGER = logging.getLogger(__name__) +HUE_LIGHT_NS = 'homeassistant.components.light.hue.' + class TestSetup(unittest.TestCase): """Test the Hue light platform.""" @@ -29,11 +31,10 @@ class TestSetup(unittest.TestCase): def setup_mocks_for_update_lights(self): """Set up all mocks for update_lights tests.""" self.mock_bridge = MagicMock() + self.mock_bridge.bridge_id = 'bridge-id' self.mock_bridge.allow_hue_groups = False self.mock_api = MagicMock() self.mock_bridge.get_api.return_value = self.mock_api - self.mock_lights = [] - self.mock_groups = [] self.mock_add_devices = MagicMock() def setup_mocks_for_process_lights(self): @@ -56,6 +57,7 @@ class TestSetup(unittest.TestCase): def create_mock_bridge(self, host, allow_hue_groups=True): """Return a mock HueBridge with reasonable defaults.""" mock_bridge = MagicMock() + mock_bridge.bridge_id = 'bridge-id' mock_bridge.host = host mock_bridge.allow_hue_groups = allow_hue_groups mock_bridge.lights = {} @@ -72,6 +74,14 @@ class TestSetup(unittest.TestCase): return mock_bridge_lights + def build_mock_light(self, bridge, light_id, name): + """Return a mock HueLight.""" + light = MagicMock() + light.bridge = bridge + light.light_id = light_id + light.name = name + return light + def test_setup_platform_no_discovery_info(self): """Test setup_platform without discovery info.""" self.hass.data[hue.DOMAIN] = {} @@ -96,8 +106,8 @@ class TestSetup(unittest.TestCase): self.hass.data[hue.DOMAIN] = {'10.0.0.1': mock_bridge} mock_add_devices = MagicMock() - with patch('homeassistant.components.light.hue.' + - 'unthrottled_update_lights') as mock_update_lights: + with patch(HUE_LIGHT_NS + 'unthrottled_update_lights') \ + as mock_update_lights: hue_light.setup_platform( self.hass, {}, mock_add_devices, {'bridge_id': '10.0.0.1'}) @@ -114,8 +124,8 @@ class TestSetup(unittest.TestCase): } mock_add_devices = MagicMock() - with patch('homeassistant.components.light.hue.' + - 'unthrottled_update_lights') as mock_update_lights: + with patch(HUE_LIGHT_NS + 'unthrottled_update_lights') \ + as mock_update_lights: hue_light.setup_platform( self.hass, {}, mock_add_devices, {'bridge_id': '10.0.0.1'}) @@ -133,83 +143,105 @@ class TestSetup(unittest.TestCase): """Test the update_lights function when no lights are found.""" self.setup_mocks_for_update_lights() - with patch('homeassistant.components.light.hue.process_lights', - return_value=[]) as mock_process_lights: - with patch('homeassistant.components.light.hue.process_groups', - return_value=self.mock_groups) \ + with patch(HUE_LIGHT_NS + 'process_lights', return_value=[]) \ + as mock_process_lights: + with patch(HUE_LIGHT_NS + 'process_groups', return_value=[]) \ as mock_process_groups: - hue_light.unthrottled_update_lights( - self.hass, self.mock_bridge, self.mock_add_devices) + with patch.object(self.hass.helpers.dispatcher, + 'dispatcher_send') as dispatcher_send: + hue_light.unthrottled_update_lights( + self.hass, self.mock_bridge, self.mock_add_devices) - mock_process_lights.assert_called_once_with( - self.hass, self.mock_api, self.mock_bridge, mock.ANY) - mock_process_groups.assert_not_called() - self.mock_add_devices.assert_not_called() + mock_process_lights.assert_called_once_with( + self.hass, self.mock_api, self.mock_bridge, mock.ANY) + mock_process_groups.assert_not_called() + self.mock_add_devices.assert_not_called() + dispatcher_send.assert_not_called() @MockDependency('phue') def test_update_lights_with_some_lights(self, mock_phue): """Test the update_lights function with some lights.""" self.setup_mocks_for_update_lights() - self.mock_lights = ['some', 'light'] + mock_lights = [ + self.build_mock_light(self.mock_bridge, 42, 'some'), + self.build_mock_light(self.mock_bridge, 84, 'light'), + ] - with patch('homeassistant.components.light.hue.process_lights', - return_value=self.mock_lights) as mock_process_lights: - with patch('homeassistant.components.light.hue.process_groups', - return_value=self.mock_groups) \ + with patch(HUE_LIGHT_NS + 'process_lights', + return_value=mock_lights) as mock_process_lights: + with patch(HUE_LIGHT_NS + 'process_groups', return_value=[]) \ as mock_process_groups: - hue_light.unthrottled_update_lights( - self.hass, self.mock_bridge, self.mock_add_devices) + with patch.object(self.hass.helpers.dispatcher, + 'dispatcher_send') as dispatcher_send: + hue_light.unthrottled_update_lights( + self.hass, self.mock_bridge, self.mock_add_devices) - mock_process_lights.assert_called_once_with( - self.hass, self.mock_api, self.mock_bridge, mock.ANY) - mock_process_groups.assert_not_called() - self.mock_add_devices.assert_called_once_with( - self.mock_lights) + mock_process_lights.assert_called_once_with( + self.hass, self.mock_api, self.mock_bridge, mock.ANY) + mock_process_groups.assert_not_called() + self.mock_add_devices.assert_called_once_with( + mock_lights) + dispatcher_send.assert_not_called() @MockDependency('phue') def test_update_lights_no_groups(self, mock_phue): """Test the update_lights function when no groups are found.""" self.setup_mocks_for_update_lights() self.mock_bridge.allow_hue_groups = True - self.mock_lights = ['some', 'light'] + mock_lights = [ + self.build_mock_light(self.mock_bridge, 42, 'some'), + self.build_mock_light(self.mock_bridge, 84, 'light'), + ] - with patch('homeassistant.components.light.hue.process_lights', - return_value=self.mock_lights) as mock_process_lights: - with patch('homeassistant.components.light.hue.process_groups', - return_value=self.mock_groups) \ + with patch(HUE_LIGHT_NS + 'process_lights', + return_value=mock_lights) as mock_process_lights: + with patch(HUE_LIGHT_NS + 'process_groups', return_value=[]) \ as mock_process_groups: - hue_light.unthrottled_update_lights( - self.hass, self.mock_bridge, self.mock_add_devices) + with patch.object(self.hass.helpers.dispatcher, + 'dispatcher_send') as dispatcher_send: + hue_light.unthrottled_update_lights( + self.hass, self.mock_bridge, self.mock_add_devices) - mock_process_lights.assert_called_once_with( - self.hass, self.mock_api, self.mock_bridge, mock.ANY) - mock_process_groups.assert_called_once_with( - self.hass, self.mock_api, self.mock_bridge, mock.ANY) - self.mock_add_devices.assert_called_once_with( - self.mock_lights) + mock_process_lights.assert_called_once_with( + self.hass, self.mock_api, self.mock_bridge, mock.ANY) + mock_process_groups.assert_called_once_with( + self.hass, self.mock_api, self.mock_bridge, mock.ANY) + self.mock_add_devices.assert_called_once_with( + mock_lights) + dispatcher_send.assert_not_called() @MockDependency('phue') def test_update_lights_with_lights_and_groups(self, mock_phue): """Test the update_lights function with both lights and groups.""" self.setup_mocks_for_update_lights() self.mock_bridge.allow_hue_groups = True - self.mock_lights = ['some', 'light'] - self.mock_groups = ['and', 'groups'] + mock_lights = [ + self.build_mock_light(self.mock_bridge, 42, 'some'), + self.build_mock_light(self.mock_bridge, 84, 'light'), + ] + mock_groups = [ + self.build_mock_light(self.mock_bridge, 15, 'and'), + self.build_mock_light(self.mock_bridge, 72, 'groups'), + ] - with patch('homeassistant.components.light.hue.process_lights', - return_value=self.mock_lights) as mock_process_lights: - with patch('homeassistant.components.light.hue.process_groups', - return_value=self.mock_groups) \ - as mock_process_groups: - hue_light.unthrottled_update_lights( - self.hass, self.mock_bridge, self.mock_add_devices) + with patch(HUE_LIGHT_NS + 'process_lights', + return_value=mock_lights) as mock_process_lights: + with patch(HUE_LIGHT_NS + 'process_groups', + return_value=mock_groups) as mock_process_groups: + with patch.object(self.hass.helpers.dispatcher, + 'dispatcher_send') as dispatcher_send: + hue_light.unthrottled_update_lights( + self.hass, self.mock_bridge, self.mock_add_devices) - mock_process_lights.assert_called_once_with( - self.hass, self.mock_api, self.mock_bridge, mock.ANY) - mock_process_groups.assert_called_once_with( - self.hass, self.mock_api, self.mock_bridge, mock.ANY) - self.mock_add_devices.assert_called_once_with( - self.mock_lights) + mock_process_lights.assert_called_once_with( + self.hass, self.mock_api, self.mock_bridge, mock.ANY) + mock_process_groups.assert_called_once_with( + self.hass, self.mock_api, self.mock_bridge, mock.ANY) + # note that mock_lights has been modified in place and + # now contains both lights and groups + self.mock_add_devices.assert_called_once_with( + mock_lights) + dispatcher_send.assert_not_called() @MockDependency('phue') def test_update_lights_with_two_bridges(self, mock_phue): @@ -288,36 +320,42 @@ class TestSetup(unittest.TestCase): """Test the process_lights function when bridge returns no lights.""" self.setup_mocks_for_process_lights() - ret = hue_light.process_lights( - self.hass, self.mock_api, self.mock_bridge, None) + with patch.object(self.hass.helpers.dispatcher, 'dispatcher_send') \ + as mock_dispatcher_send: + ret = hue_light.process_lights( + self.hass, self.mock_api, self.mock_bridge, None) - self.assertEquals([], ret) - self.assertEquals(self.mock_bridge.lights, {}) + self.assertEquals([], ret) + mock_dispatcher_send.assert_not_called() + self.assertEquals(self.mock_bridge.lights, {}) - @patch('homeassistant.components.light.hue.HueLight') + @patch(HUE_LIGHT_NS + 'HueLight') def test_process_lights_some_lights(self, mock_hue_light): """Test the process_lights function with multiple groups.""" self.setup_mocks_for_process_lights() self.mock_api.get.return_value = { 1: {'state': 'on'}, 2: {'state': 'off'}} - ret = hue_light.process_lights( - self.hass, self.mock_api, self.mock_bridge, None) + with patch.object(self.hass.helpers.dispatcher, 'dispatcher_send') \ + as mock_dispatcher_send: + ret = hue_light.process_lights( + self.hass, self.mock_api, self.mock_bridge, None) - self.assertEquals(len(ret), 2) - mock_hue_light.assert_has_calls([ - call( - 1, {'state': 'on'}, self.mock_bridge, mock.ANY, - self.mock_bridge.allow_unreachable, - self.mock_bridge.allow_in_emulated_hue), - call( - 2, {'state': 'off'}, self.mock_bridge, mock.ANY, - self.mock_bridge.allow_unreachable, - self.mock_bridge.allow_in_emulated_hue), - ]) - self.assertEquals(len(self.mock_bridge.lights), 2) + self.assertEquals(len(ret), 2) + mock_hue_light.assert_has_calls([ + call( + 1, {'state': 'on'}, self.mock_bridge, mock.ANY, + self.mock_bridge.allow_unreachable, + self.mock_bridge.allow_in_emulated_hue), + call( + 2, {'state': 'off'}, self.mock_bridge, mock.ANY, + self.mock_bridge.allow_unreachable, + self.mock_bridge.allow_in_emulated_hue), + ]) + mock_dispatcher_send.assert_not_called() + self.assertEquals(len(self.mock_bridge.lights), 2) - @patch('homeassistant.components.light.hue.HueLight') + @patch(HUE_LIGHT_NS + 'HueLight') def test_process_lights_new_light(self, mock_hue_light): """ Test the process_lights function with new groups. @@ -327,21 +365,24 @@ class TestSetup(unittest.TestCase): self.setup_mocks_for_process_lights() self.mock_api.get.return_value = { 1: {'state': 'on'}, 2: {'state': 'off'}} - self.mock_bridge.lights = {1: MagicMock()} + self.mock_bridge.lights = { + 1: self.build_mock_light(self.mock_bridge, 1, 'foo')} - ret = hue_light.process_lights( - self.hass, self.mock_api, self.mock_bridge, None) + with patch.object(self.hass.helpers.dispatcher, 'dispatcher_send') \ + as mock_dispatcher_send: + ret = hue_light.process_lights( + self.hass, self.mock_api, self.mock_bridge, None) - self.assertEquals(len(ret), 1) - mock_hue_light.assert_has_calls([ - call( - 2, {'state': 'off'}, self.mock_bridge, mock.ANY, - self.mock_bridge.allow_unreachable, - self.mock_bridge.allow_in_emulated_hue), - ]) - self.assertEquals(len(self.mock_bridge.lights), 2) - self.mock_bridge.lights[1]\ - .schedule_update_ha_state.assert_called_once_with() + self.assertEquals(len(ret), 1) + mock_hue_light.assert_has_calls([ + call( + 2, {'state': 'off'}, self.mock_bridge, mock.ANY, + self.mock_bridge.allow_unreachable, + self.mock_bridge.allow_in_emulated_hue), + ]) + mock_dispatcher_send.assert_called_once_with( + 'hue_light_callback_bridge-id_1') + self.assertEquals(len(self.mock_bridge.lights), 2) def test_process_groups_api_error(self): """Test the process_groups function when the bridge errors out.""" @@ -359,36 +400,42 @@ class TestSetup(unittest.TestCase): self.setup_mocks_for_process_groups() self.mock_bridge.get_group.return_value = {'name': 'Group 0'} - ret = hue_light.process_groups( - self.hass, self.mock_api, self.mock_bridge, None) + with patch.object(self.hass.helpers.dispatcher, 'dispatcher_send') \ + as mock_dispatcher_send: + ret = hue_light.process_groups( + self.hass, self.mock_api, self.mock_bridge, None) - self.assertEquals([], ret) - self.assertEquals(self.mock_bridge.lightgroups, {}) + self.assertEquals([], ret) + mock_dispatcher_send.assert_not_called() + self.assertEquals(self.mock_bridge.lightgroups, {}) - @patch('homeassistant.components.light.hue.HueLight') + @patch(HUE_LIGHT_NS + 'HueLight') def test_process_groups_some_groups(self, mock_hue_light): """Test the process_groups function with multiple groups.""" self.setup_mocks_for_process_groups() self.mock_api.get.return_value = { 1: {'state': 'on'}, 2: {'state': 'off'}} - ret = hue_light.process_groups( - self.hass, self.mock_api, self.mock_bridge, None) + with patch.object(self.hass.helpers.dispatcher, 'dispatcher_send') \ + as mock_dispatcher_send: + ret = hue_light.process_groups( + self.hass, self.mock_api, self.mock_bridge, None) - self.assertEquals(len(ret), 2) - mock_hue_light.assert_has_calls([ - call( - 1, {'state': 'on'}, self.mock_bridge, mock.ANY, - self.mock_bridge.allow_unreachable, - self.mock_bridge.allow_in_emulated_hue, True), - call( - 2, {'state': 'off'}, self.mock_bridge, mock.ANY, - self.mock_bridge.allow_unreachable, - self.mock_bridge.allow_in_emulated_hue, True), - ]) - self.assertEquals(len(self.mock_bridge.lightgroups), 2) + self.assertEquals(len(ret), 2) + mock_hue_light.assert_has_calls([ + call( + 1, {'state': 'on'}, self.mock_bridge, mock.ANY, + self.mock_bridge.allow_unreachable, + self.mock_bridge.allow_in_emulated_hue, True), + call( + 2, {'state': 'off'}, self.mock_bridge, mock.ANY, + self.mock_bridge.allow_unreachable, + self.mock_bridge.allow_in_emulated_hue, True), + ]) + mock_dispatcher_send.assert_not_called() + self.assertEquals(len(self.mock_bridge.lightgroups), 2) - @patch('homeassistant.components.light.hue.HueLight') + @patch(HUE_LIGHT_NS + 'HueLight') def test_process_groups_new_group(self, mock_hue_light): """ Test the process_groups function with new groups. @@ -398,21 +445,24 @@ class TestSetup(unittest.TestCase): self.setup_mocks_for_process_groups() self.mock_api.get.return_value = { 1: {'state': 'on'}, 2: {'state': 'off'}} - self.mock_bridge.lightgroups = {1: MagicMock()} + self.mock_bridge.lightgroups = { + 1: self.build_mock_light(self.mock_bridge, 1, 'foo')} - ret = hue_light.process_groups( - self.hass, self.mock_api, self.mock_bridge, None) + with patch.object(self.hass.helpers.dispatcher, 'dispatcher_send') \ + as mock_dispatcher_send: + ret = hue_light.process_groups( + self.hass, self.mock_api, self.mock_bridge, None) - self.assertEquals(len(ret), 1) - mock_hue_light.assert_has_calls([ - call( - 2, {'state': 'off'}, self.mock_bridge, mock.ANY, - self.mock_bridge.allow_unreachable, - self.mock_bridge.allow_in_emulated_hue, True), - ]) - self.assertEquals(len(self.mock_bridge.lightgroups), 2) - self.mock_bridge.lightgroups[1]\ - .schedule_update_ha_state.assert_called_once_with() + self.assertEquals(len(ret), 1) + mock_hue_light.assert_has_calls([ + call( + 2, {'state': 'off'}, self.mock_bridge, mock.ANY, + self.mock_bridge.allow_unreachable, + self.mock_bridge.allow_in_emulated_hue, True), + ]) + mock_dispatcher_send.assert_called_once_with( + 'hue_light_callback_bridge-id_1') + self.assertEquals(len(self.mock_bridge.lightgroups), 2) class TestHueLight(unittest.TestCase): @@ -440,6 +490,10 @@ class TestHueLight(unittest.TestCase): def buildLight( self, light_id=None, info=None, update_lights=None, is_group=None): """Helper to build a HueLight object with minimal fuss.""" + if 'state' not in info: + on_key = 'any_on' if is_group is not None else 'on' + info['state'] = {on_key: False} + return hue_light.HueLight( light_id if light_id is not None else self.light_id, info if info is not None else self.mock_info, From 88b70e964c0b5d558984a5fe1d03f930fbb1678e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 9 Jan 2018 23:55:14 -0800 Subject: [PATCH 200/238] Remove execution file perm (#11563) --- homeassistant/components/alarm_control_panel/concord232.py | 0 homeassistant/components/binary_sensor/concord232.py | 0 homeassistant/components/calendar/demo.py | 0 homeassistant/components/camera/mqtt.py | 0 homeassistant/components/climate/mysensors.py | 0 homeassistant/components/climate/netatmo.py | 0 homeassistant/components/climate/zwave.py | 0 homeassistant/components/coinbase.py | 0 homeassistant/components/cover/tellstick.py | 0 homeassistant/components/device_tracker/geofency.py | 0 homeassistant/components/device_tracker/tplink.py | 0 homeassistant/components/input_text.py | 0 homeassistant/components/light/mqtt_json.py | 0 homeassistant/components/light/mqtt_template.py | 0 homeassistant/components/light/xiaomi_aqara.py | 0 homeassistant/components/media_player/denon.py | 0 homeassistant/components/media_player/volumio.py | 0 homeassistant/components/remote/__init__.py | 0 homeassistant/components/remote/harmony.py | 0 homeassistant/components/remote/kira.py | 0 homeassistant/components/sensor/buienradar.py | 0 homeassistant/components/sensor/coinbase.py | 0 homeassistant/components/sensor/gearbest.py | 0 homeassistant/components/sensor/lacrosse.py | 0 homeassistant/components/sensor/openweathermap.py | 0 homeassistant/components/sensor/systemmonitor.py | 0 homeassistant/components/switch/digitalloggers.py | 0 homeassistant/components/weather/buienradar.py | 0 homeassistant/components/zigbee.py | 0 homeassistant/components/zoneminder.py | 0 homeassistant/components/zwave/__init__.py | 0 tests/components/calendar/test_google.py | 0 tests/components/emulated_hue/test_init.py | 0 tests/components/light/test_mqtt_json.py | 0 tests/components/light/test_mqtt_template.py | 0 tests/components/remote/__init__.py | 0 tests/components/remote/test_demo.py | 0 tests/components/remote/test_init.py | 0 tests/components/test_input_text.py | 0 39 files changed, 0 insertions(+), 0 deletions(-) mode change 100755 => 100644 homeassistant/components/alarm_control_panel/concord232.py mode change 100755 => 100644 homeassistant/components/binary_sensor/concord232.py mode change 100755 => 100644 homeassistant/components/calendar/demo.py mode change 100755 => 100644 homeassistant/components/camera/mqtt.py mode change 100755 => 100644 homeassistant/components/climate/mysensors.py mode change 100755 => 100644 homeassistant/components/climate/netatmo.py mode change 100755 => 100644 homeassistant/components/climate/zwave.py mode change 100755 => 100644 homeassistant/components/coinbase.py mode change 100755 => 100644 homeassistant/components/cover/tellstick.py mode change 100755 => 100644 homeassistant/components/device_tracker/geofency.py mode change 100755 => 100644 homeassistant/components/device_tracker/tplink.py mode change 100755 => 100644 homeassistant/components/input_text.py mode change 100755 => 100644 homeassistant/components/light/mqtt_json.py mode change 100755 => 100644 homeassistant/components/light/mqtt_template.py mode change 100755 => 100644 homeassistant/components/light/xiaomi_aqara.py mode change 100755 => 100644 homeassistant/components/media_player/denon.py mode change 100755 => 100644 homeassistant/components/media_player/volumio.py mode change 100755 => 100644 homeassistant/components/remote/__init__.py mode change 100755 => 100644 homeassistant/components/remote/harmony.py mode change 100755 => 100644 homeassistant/components/remote/kira.py mode change 100755 => 100644 homeassistant/components/sensor/buienradar.py mode change 100755 => 100644 homeassistant/components/sensor/coinbase.py mode change 100755 => 100644 homeassistant/components/sensor/gearbest.py mode change 100755 => 100644 homeassistant/components/sensor/lacrosse.py mode change 100755 => 100644 homeassistant/components/sensor/openweathermap.py mode change 100755 => 100644 homeassistant/components/sensor/systemmonitor.py mode change 100755 => 100644 homeassistant/components/switch/digitalloggers.py mode change 100755 => 100644 homeassistant/components/weather/buienradar.py mode change 100755 => 100644 homeassistant/components/zigbee.py mode change 100755 => 100644 homeassistant/components/zoneminder.py mode change 100755 => 100644 homeassistant/components/zwave/__init__.py mode change 100755 => 100644 tests/components/calendar/test_google.py mode change 100755 => 100644 tests/components/emulated_hue/test_init.py mode change 100755 => 100644 tests/components/light/test_mqtt_json.py mode change 100755 => 100644 tests/components/light/test_mqtt_template.py mode change 100755 => 100644 tests/components/remote/__init__.py mode change 100755 => 100644 tests/components/remote/test_demo.py mode change 100755 => 100644 tests/components/remote/test_init.py mode change 100755 => 100644 tests/components/test_input_text.py diff --git a/homeassistant/components/alarm_control_panel/concord232.py b/homeassistant/components/alarm_control_panel/concord232.py old mode 100755 new mode 100644 diff --git a/homeassistant/components/binary_sensor/concord232.py b/homeassistant/components/binary_sensor/concord232.py old mode 100755 new mode 100644 diff --git a/homeassistant/components/calendar/demo.py b/homeassistant/components/calendar/demo.py old mode 100755 new mode 100644 diff --git a/homeassistant/components/camera/mqtt.py b/homeassistant/components/camera/mqtt.py old mode 100755 new mode 100644 diff --git a/homeassistant/components/climate/mysensors.py b/homeassistant/components/climate/mysensors.py old mode 100755 new mode 100644 diff --git a/homeassistant/components/climate/netatmo.py b/homeassistant/components/climate/netatmo.py old mode 100755 new mode 100644 diff --git a/homeassistant/components/climate/zwave.py b/homeassistant/components/climate/zwave.py old mode 100755 new mode 100644 diff --git a/homeassistant/components/coinbase.py b/homeassistant/components/coinbase.py old mode 100755 new mode 100644 diff --git a/homeassistant/components/cover/tellstick.py b/homeassistant/components/cover/tellstick.py old mode 100755 new mode 100644 diff --git a/homeassistant/components/device_tracker/geofency.py b/homeassistant/components/device_tracker/geofency.py old mode 100755 new mode 100644 diff --git a/homeassistant/components/device_tracker/tplink.py b/homeassistant/components/device_tracker/tplink.py old mode 100755 new mode 100644 diff --git a/homeassistant/components/input_text.py b/homeassistant/components/input_text.py old mode 100755 new mode 100644 diff --git a/homeassistant/components/light/mqtt_json.py b/homeassistant/components/light/mqtt_json.py old mode 100755 new mode 100644 diff --git a/homeassistant/components/light/mqtt_template.py b/homeassistant/components/light/mqtt_template.py old mode 100755 new mode 100644 diff --git a/homeassistant/components/light/xiaomi_aqara.py b/homeassistant/components/light/xiaomi_aqara.py old mode 100755 new mode 100644 diff --git a/homeassistant/components/media_player/denon.py b/homeassistant/components/media_player/denon.py old mode 100755 new mode 100644 diff --git a/homeassistant/components/media_player/volumio.py b/homeassistant/components/media_player/volumio.py old mode 100755 new mode 100644 diff --git a/homeassistant/components/remote/__init__.py b/homeassistant/components/remote/__init__.py old mode 100755 new mode 100644 diff --git a/homeassistant/components/remote/harmony.py b/homeassistant/components/remote/harmony.py old mode 100755 new mode 100644 diff --git a/homeassistant/components/remote/kira.py b/homeassistant/components/remote/kira.py old mode 100755 new mode 100644 diff --git a/homeassistant/components/sensor/buienradar.py b/homeassistant/components/sensor/buienradar.py old mode 100755 new mode 100644 diff --git a/homeassistant/components/sensor/coinbase.py b/homeassistant/components/sensor/coinbase.py old mode 100755 new mode 100644 diff --git a/homeassistant/components/sensor/gearbest.py b/homeassistant/components/sensor/gearbest.py old mode 100755 new mode 100644 diff --git a/homeassistant/components/sensor/lacrosse.py b/homeassistant/components/sensor/lacrosse.py old mode 100755 new mode 100644 diff --git a/homeassistant/components/sensor/openweathermap.py b/homeassistant/components/sensor/openweathermap.py old mode 100755 new mode 100644 diff --git a/homeassistant/components/sensor/systemmonitor.py b/homeassistant/components/sensor/systemmonitor.py old mode 100755 new mode 100644 diff --git a/homeassistant/components/switch/digitalloggers.py b/homeassistant/components/switch/digitalloggers.py old mode 100755 new mode 100644 diff --git a/homeassistant/components/weather/buienradar.py b/homeassistant/components/weather/buienradar.py old mode 100755 new mode 100644 diff --git a/homeassistant/components/zigbee.py b/homeassistant/components/zigbee.py old mode 100755 new mode 100644 diff --git a/homeassistant/components/zoneminder.py b/homeassistant/components/zoneminder.py old mode 100755 new mode 100644 diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py old mode 100755 new mode 100644 diff --git a/tests/components/calendar/test_google.py b/tests/components/calendar/test_google.py old mode 100755 new mode 100644 diff --git a/tests/components/emulated_hue/test_init.py b/tests/components/emulated_hue/test_init.py old mode 100755 new mode 100644 diff --git a/tests/components/light/test_mqtt_json.py b/tests/components/light/test_mqtt_json.py old mode 100755 new mode 100644 diff --git a/tests/components/light/test_mqtt_template.py b/tests/components/light/test_mqtt_template.py old mode 100755 new mode 100644 diff --git a/tests/components/remote/__init__.py b/tests/components/remote/__init__.py old mode 100755 new mode 100644 diff --git a/tests/components/remote/test_demo.py b/tests/components/remote/test_demo.py old mode 100755 new mode 100644 diff --git a/tests/components/remote/test_init.py b/tests/components/remote/test_init.py old mode 100755 new mode 100644 diff --git a/tests/components/test_input_text.py b/tests/components/test_input_text.py old mode 100755 new mode 100644 From 9e0ca719ed857f410c3e7d3528fb132a0f3f34db Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Wed, 10 Jan 2018 09:06:26 +0100 Subject: [PATCH 201/238] Deprecate explicit entity_id in template platforms (#11123) * Deprecate explicit entity_id in template platforms * Use config validator for deprecation * Fix copy/paste typos * Also print the config value * Add test for config validator * Assert the module name that logged the message --- .../components/binary_sensor/template.py | 5 +++++ homeassistant/components/cover/template.py | 5 +++++ homeassistant/components/light/template.py | 5 +++++ homeassistant/components/sensor/template.py | 5 +++++ homeassistant/components/switch/template.py | 5 +++++ homeassistant/helpers/config_validation.py | 18 ++++++++++++++++ tests/helpers/test_config_validation.py | 21 +++++++++++++++++++ 7 files changed, 64 insertions(+) diff --git a/homeassistant/components/binary_sensor/template.py b/homeassistant/components/binary_sensor/template.py index 16167a93b82..92213a9b590 100644 --- a/homeassistant/components/binary_sensor/template.py +++ b/homeassistant/components/binary_sensor/template.py @@ -38,6 +38,11 @@ SENSOR_SCHEMA = vol.Schema({ vol.All(cv.time_period, cv.positive_timedelta), }) +SENSOR_SCHEMA = vol.All( + cv.deprecated(ATTR_ENTITY_ID), + SENSOR_SCHEMA, +) + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_SENSORS): vol.Schema({cv.slug: SENSOR_SCHEMA}), }) diff --git a/homeassistant/components/cover/template.py b/homeassistant/components/cover/template.py index f4728a12a3b..a7db472f191 100644 --- a/homeassistant/components/cover/template.py +++ b/homeassistant/components/cover/template.py @@ -67,6 +67,11 @@ COVER_SCHEMA = vol.Schema({ vol.Optional(CONF_ENTITY_ID): cv.entity_ids }) +COVER_SCHEMA = vol.All( + cv.deprecated(CONF_ENTITY_ID), + COVER_SCHEMA, +) + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_COVERS): vol.Schema({cv.slug: COVER_SCHEMA}), }) diff --git a/homeassistant/components/light/template.py b/homeassistant/components/light/template.py index d4f2b93e6b5..ed7ba1978cc 100644 --- a/homeassistant/components/light/template.py +++ b/homeassistant/components/light/template.py @@ -44,6 +44,11 @@ LIGHT_SCHEMA = vol.Schema({ vol.Optional(CONF_ENTITY_ID): cv.entity_ids }) +LIGHT_SCHEMA = vol.All( + cv.deprecated(CONF_ENTITY_ID), + LIGHT_SCHEMA, +) + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_LIGHTS): vol.Schema({cv.slug: LIGHT_SCHEMA}), }) diff --git a/homeassistant/components/sensor/template.py b/homeassistant/components/sensor/template.py index b347439e08d..1d9bf0b7a9a 100644 --- a/homeassistant/components/sensor/template.py +++ b/homeassistant/components/sensor/template.py @@ -31,6 +31,11 @@ SENSOR_SCHEMA = vol.Schema({ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids }) +SENSOR_SCHEMA = vol.All( + cv.deprecated(ATTR_ENTITY_ID), + SENSOR_SCHEMA, +) + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_SENSORS): vol.Schema({cv.slug: SENSOR_SCHEMA}), }) diff --git a/homeassistant/components/switch/template.py b/homeassistant/components/switch/template.py index 93ebf98e9ac..64dafdcadef 100644 --- a/homeassistant/components/switch/template.py +++ b/homeassistant/components/switch/template.py @@ -38,6 +38,11 @@ SWITCH_SCHEMA = vol.Schema({ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids }) +SWITCH_SCHEMA = vol.All( + cv.deprecated(ATTR_ENTITY_ID), + SWITCH_SCHEMA, +) + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_SWITCHES): vol.Schema({cv.slug: SWITCH_SCHEMA}), }) diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index e5d0a34f76e..afb4483647d 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -5,6 +5,8 @@ import os import re from urllib.parse import urlparse from socket import _GLOBAL_DEFAULT_TIMEOUT +import logging +import inspect from typing import Any, Union, TypeVar, Callable, Sequence, Dict @@ -430,6 +432,22 @@ def ensure_list_csv(value: Any) -> Sequence: return ensure_list(value) +def deprecated(key): + """Log key as deprecated.""" + module_name = inspect.getmodule(inspect.stack()[1][0]).__name__ + + def validator(config): + """Check if key is in config and log warning.""" + if key in config: + logging.getLogger(module_name).warning( + "The '%s' option (with value '%s') is deprecated, please " + "remove it from your configuration.", key, config[key]) + + return config + + return validator + + # Validator helpers def key_dependency(key, dependency): diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index 5a940742e75..26262f50ac4 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -441,6 +441,27 @@ def test_datetime(): schema('2016-11-23T18:59:08') +def test_deprecated(caplog): + """Test deprecation log.""" + schema = vol.Schema({ + 'venus': cv.boolean, + 'mars': cv.boolean + }) + deprecated_schema = vol.All( + cv.deprecated('mars'), + schema + ) + + deprecated_schema({'venus': True}) + assert len(caplog.records) == 0 + + deprecated_schema({'mars': True}) + assert len(caplog.records) == 1 + assert caplog.records[0].name == __name__ + assert ("The 'mars' option (with value 'True') is deprecated, " + "please remove it from your configuration.") in caplog.text + + def test_key_dependency(): """Test key_dependency validator.""" schema = vol.Schema(cv.key_dependency('beer', 'soda')) From 382a62346b3fc68a6abeb3556b5c558473bf87fd Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 10 Jan 2018 00:52:12 -0800 Subject: [PATCH 202/238] Update frontend to 20180110.0 --- homeassistant/components/frontend/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index a7b897ed5b5..6d58ceebfd1 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -23,7 +23,7 @@ from homeassistant.const import CONF_NAME, EVENT_THEMES_UPDATED from homeassistant.core import callback from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20180102.0', 'user-agents==1.1.0'] +REQUIREMENTS = ['home-assistant-frontend==20180110.0', 'user-agents==1.1.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] From d793cfeb687ef59b617d5b63b55bc19340fcb9e3 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 10 Jan 2018 00:52:35 -0800 Subject: [PATCH 203/238] Update frontend to 20180110.0 --- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements_all.txt b/requirements_all.txt index bc28ddd3b69..bc90f75b02b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -349,7 +349,7 @@ hipnotify==1.0.8 holidays==0.8.1 # homeassistant.components.frontend -home-assistant-frontend==20180102.0 +home-assistant-frontend==20180110.0 # homeassistant.components.camera.onvif http://github.com/tgaugry/suds-passworddigest-py3/archive/86fc50e39b4d2b8997481967d6a7fe1c57118999.zip#suds-passworddigest-py3==0.1.2a diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 65afd940267..58d7634fe86 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -75,7 +75,7 @@ hbmqtt==0.9.1 holidays==0.8.1 # homeassistant.components.frontend -home-assistant-frontend==20180102.0 +home-assistant-frontend==20180110.0 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb From 0d06e8c1c9b99a5f6b640064f05596081474e449 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 10 Jan 2018 01:48:17 -0800 Subject: [PATCH 204/238] Test tweaks (#11560) * Fix is_allowed_path on OS X * Move APNS2 inside func in test --- tests/components/notify/test_apns.py | 3 ++- tests/test_core.py | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/components/notify/test_apns.py b/tests/components/notify/test_apns.py index 0bd0333a6fb..7715ff168be 100644 --- a/tests/components/notify/test_apns.py +++ b/tests/components/notify/test_apns.py @@ -3,7 +3,6 @@ import io import unittest from unittest.mock import Mock, patch, mock_open -from apns2.errors import Unregistered import yaml import homeassistant.components.notify as notify @@ -359,6 +358,8 @@ class TestApns(unittest.TestCase): @patch('homeassistant.components.notify.apns._write_device') def test_disable_when_unregistered(self, mock_write, mock_client): """Test disabling a device when it is unregistered.""" + from apns2.errors import Unregistered + send = mock_client.return_value.send_notification send.side_effect = Unregistered() diff --git a/tests/test_core.py b/tests/test_core.py index 67ae849b022..ea952a7c073 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -797,8 +797,10 @@ class TestConfig(unittest.TestCase): def test_is_allowed_path(self): """Test is_allowed_path method.""" with TemporaryDirectory() as tmp_dir: + # The created dir is in /tmp. This is a symlink on OS X + # causing this test to fail unless we resolve path first. self.config.whitelist_external_dirs = set(( - tmp_dir, + os.path.realpath(tmp_dir), )) test_file = os.path.join(tmp_dir, "test.jpg") From 02979db3d6efa9a8443dd428ced8e636fcff3c17 Mon Sep 17 00:00:00 2001 From: Thijs de Jong Date: Wed, 10 Jan 2018 15:41:16 +0100 Subject: [PATCH 205/238] Add Velux Windows to Tahoma (#11538) * Add Velux Windows to Tahoma * fix linit * add supported Tahoma devices by @glpatcern * hound * lint * fix logging * lint * lint * remove blank line after docstring * changes based on notes by @armills * fix logging --- homeassistant/components/cover/tahoma.py | 7 +++++++ homeassistant/components/tahoma.py | 18 ++++++++++++------ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/cover/tahoma.py b/homeassistant/components/cover/tahoma.py index 2f0362535ca..d492ad50866 100644 --- a/homeassistant/components/cover/tahoma.py +++ b/homeassistant/components/cover/tahoma.py @@ -74,3 +74,10 @@ class TahomaCover(TahomaDevice, CoverDevice): def stop_cover(self, **kwargs): """Stop the cover.""" self.apply_action('stopIdentify') + + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + if self.tahoma_device.type == 'io:WindowOpenerVeluxIOComponent': + return 'window' + else: + return None diff --git a/homeassistant/components/tahoma.py b/homeassistant/components/tahoma.py index 129c6506ac1..c2453d75493 100644 --- a/homeassistant/components/tahoma.py +++ b/homeassistant/components/tahoma.py @@ -36,6 +36,14 @@ TAHOMA_COMPONENTS = [ 'sensor', 'cover' ] +TAHOMA_TYPES = { + 'rts:RollerShutterRTSComponent': 'cover', + 'rts:CurtainRTSComponent': 'cover', + 'io:RollerShutterWithLowSpeedManagementIOComponent': 'cover', + 'io:WindowOpenerVeluxIOComponent': 'cover', + 'io:LightIOSystemSensor': 'sensor', +} + def setup(hass, config): """Activate Tahoma component.""" @@ -68,6 +76,8 @@ def setup(hass, config): if all(ext not in _device.type for ext in exclude): device_type = map_tahoma_device(_device) if device_type is None: + _LOGGER.warning('Unsupported type %s for Tahoma device %s', + _device.type, _device.label) continue hass.data[DOMAIN]['devices'][device_type].append(_device) @@ -78,12 +88,8 @@ def setup(hass, config): def map_tahoma_device(tahoma_device): - """Map tahoma classes to Home Assistant types.""" - if tahoma_device.type.lower().find("shutter") != -1: - return 'cover' - elif tahoma_device.type == 'io:LightIOSystemSensor': - return 'sensor' - return None + """Map Tahoma device types to Home Assistant components.""" + return TAHOMA_TYPES.get(tahoma_device.type) class TahomaDevice(Entity): From c5d5d57e9b41e2c8f17ba5b55d535919fafe8b36 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Wed, 10 Jan 2018 19:48:31 +0100 Subject: [PATCH 206/238] Extend hass.io services / updater (#11549) * Extend hass.io services * Add warning for carfuly options with hass.io * update tests * finish tests * remove update calls * address comments * address comments p2 * fix tests * fix tests * Use token also for proxy * Add test for server_host * Fix test * Fix tests * Add test for version * Address comments --- homeassistant/components/hassio.py | 133 ++++++++++++++++++++++++----- tests/components/test_hassio.py | 129 ++++++++++++++++++++++++---- tests/test_util/aiohttp.py | 2 +- 3 files changed, 228 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/hassio.py b/homeassistant/components/hassio.py index 048a7d531f4..8bd1b11cf0d 100644 --- a/homeassistant/components/hassio.py +++ b/homeassistant/components/hassio.py @@ -5,6 +5,7 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/hassio/ """ import asyncio +from datetime import timedelta import logging import os import re @@ -21,23 +22,38 @@ from homeassistant.const import ( CONTENT_TYPE_TEXT_PLAIN, SERVER_PORT, CONF_TIME_ZONE) from homeassistant.components.http import ( HomeAssistantView, KEY_AUTHENTICATED, CONF_API_PASSWORD, CONF_SERVER_PORT, - CONF_SSL_CERTIFICATE) -from homeassistant.helpers.aiohttp_client import async_get_clientsession + CONF_SERVER_HOST, CONF_SSL_CERTIFICATE) +from homeassistant.loader import bind_hass +from homeassistant.util.dt import utcnow _LOGGER = logging.getLogger(__name__) DOMAIN = 'hassio' DEPENDENCIES = ['http'] +X_HASSIO = 'X-HASSIO-KEY' + +DATA_HOMEASSISTANT_VERSION = 'hassio_hass_version' +HASSIO_UPDATE_INTERVAL = timedelta(hours=1) + SERVICE_ADDON_START = 'addon_start' SERVICE_ADDON_STOP = 'addon_stop' SERVICE_ADDON_RESTART = 'addon_restart' SERVICE_ADDON_STDIN = 'addon_stdin' SERVICE_HOST_SHUTDOWN = 'host_shutdown' SERVICE_HOST_REBOOT = 'host_reboot' +SERVICE_SNAPSHOT_FULL = 'snapshot_full' +SERVICE_SNAPSHOT_PARTIAL = 'snapshot_partial' +SERVICE_RESTORE_FULL = 'restore_full' +SERVICE_RESTORE_PARTIAL = 'restore_partial' ATTR_ADDON = 'addon' ATTR_INPUT = 'input' +ATTR_SNAPSHOT = 'snapshot' +ATTR_ADDONS = 'addons' +ATTR_FOLDERS = 'folders' +ATTR_HOMEASSISTANT = 'homeassistant' +ATTR_NAME = 'name' NO_TIMEOUT = { re.compile(r'^homeassistant/update$'), @@ -45,13 +61,17 @@ NO_TIMEOUT = { re.compile(r'^supervisor/update$'), re.compile(r'^addons/[^/]*/update$'), re.compile(r'^addons/[^/]*/install$'), - re.compile(r'^addons/[^/]*/rebuild$') + re.compile(r'^addons/[^/]*/rebuild$'), + re.compile(r'^snapshots/.*/full$'), + re.compile(r'^snapshots/.*/partial$'), } NO_AUTH = { re.compile(r'^panel_(es5|latest)$'), re.compile(r'^addons/[^/]*/logo$') } +SCHEMA_NO_DATA = vol.Schema({}) + SCHEMA_ADDON = vol.Schema({ vol.Required(ATTR_ADDON): cv.slug, }) @@ -60,16 +80,52 @@ SCHEMA_ADDON_STDIN = SCHEMA_ADDON.extend({ vol.Required(ATTR_INPUT): vol.Any(dict, cv.string) }) +SCHEMA_SNAPSHOT_FULL = vol.Schema({ + vol.Optional(ATTR_NAME): cv.string, +}) + +SCHEMA_SNAPSHOT_PARTIAL = SCHEMA_SNAPSHOT_FULL.extend({ + vol.Optional(ATTR_FOLDERS): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(ATTR_ADDONS): vol.All(cv.ensure_list, [cv.string]), +}) + +SCHEMA_RESTORE_FULL = vol.Schema({ + vol.Required(ATTR_SNAPSHOT): cv.slug, +}) + +SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend({ + vol.Optional(ATTR_HOMEASSISTANT): cv.boolean, + vol.Optional(ATTR_FOLDERS): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(ATTR_ADDONS): vol.All(cv.ensure_list, [cv.string]), +}) + MAP_SERVICE_API = { - SERVICE_ADDON_START: ('/addons/{addon}/start', SCHEMA_ADDON), - SERVICE_ADDON_STOP: ('/addons/{addon}/stop', SCHEMA_ADDON), - SERVICE_ADDON_RESTART: ('/addons/{addon}/restart', SCHEMA_ADDON), - SERVICE_ADDON_STDIN: ('/addons/{addon}/stdin', SCHEMA_ADDON_STDIN), - SERVICE_HOST_SHUTDOWN: ('/host/shutdown', None), - SERVICE_HOST_REBOOT: ('/host/reboot', None), + SERVICE_ADDON_START: ('/addons/{addon}/start', SCHEMA_ADDON, 60, False), + SERVICE_ADDON_STOP: ('/addons/{addon}/stop', SCHEMA_ADDON, 60, False), + SERVICE_ADDON_RESTART: + ('/addons/{addon}/restart', SCHEMA_ADDON, 60, False), + SERVICE_ADDON_STDIN: + ('/addons/{addon}/stdin', SCHEMA_ADDON_STDIN, 60, False), + SERVICE_HOST_SHUTDOWN: ('/host/shutdown', SCHEMA_NO_DATA, 60, False), + SERVICE_HOST_REBOOT: ('/host/reboot', SCHEMA_NO_DATA, 60, False), + SERVICE_SNAPSHOT_FULL: + ('/snapshots/new/full', SCHEMA_SNAPSHOT_FULL, 300, True), + SERVICE_SNAPSHOT_PARTIAL: + ('/snapshots/new/partial', SCHEMA_SNAPSHOT_PARTIAL, 300, True), + SERVICE_RESTORE_FULL: + ('/snapshots/{snapshot}/restore/full', SCHEMA_RESTORE_FULL, 300, True), + SERVICE_RESTORE_PARTIAL: + ('/snapshots/{snapshot}/restore/partial', SCHEMA_RESTORE_PARTIAL, 300, + True), } +@bind_hass +def get_homeassistant_version(hass): + """Return last available HomeAssistant version.""" + return hass.data.get(DATA_HOMEASSISTANT_VERSION) + + @asyncio.coroutine def async_setup(hass, config): """Set up the HASSio component.""" @@ -79,7 +135,7 @@ def async_setup(hass, config): _LOGGER.error("No HassIO supervisor detect!") return False - websession = async_get_clientsession(hass) + websession = hass.helpers.aiohttp_client.async_get_clientsession() hassio = HassIO(hass.loop, websession, host) if not (yield from hassio.is_connected()): @@ -102,16 +158,41 @@ def async_setup(hass, config): def async_service_handler(service): """Handle service calls for HassIO.""" api_command = MAP_SERVICE_API[service.service][0] - addon = service.data.get(ATTR_ADDON) - data = service.data[ATTR_INPUT] if ATTR_INPUT in service.data else None + data = service.data.copy() + addon = data.pop(ATTR_ADDON, None) + snapshot = data.pop(ATTR_SNAPSHOT, None) + payload = None + # Pass data to hass.io API + if service.service == SERVICE_ADDON_STDIN: + payload = data[ATTR_INPUT] + elif MAP_SERVICE_API[service.service][3]: + payload = data + + # Call API yield from hassio.send_command( - api_command.format(addon=addon), payload=data, timeout=60) + api_command.format(addon=addon, snapshot=snapshot), + payload=payload, timeout=MAP_SERVICE_API[service.service][2] + ) for service, settings in MAP_SERVICE_API.items(): hass.services.async_register( DOMAIN, service, async_service_handler, schema=settings[1]) + @asyncio.coroutine + def update_homeassistant_version(now): + """Update last available HomeAssistant version.""" + data = yield from hassio.get_homeassistant_info() + if data: + hass.data[DATA_HOMEASSISTANT_VERSION] = \ + data['data']['last_version'] + + hass.helpers.event.async_track_point_in_utc_time( + update_homeassistant_version, utcnow() + HASSIO_UPDATE_INTERVAL) + + # Fetch last version + yield from update_homeassistant_version(None) + return True @@ -131,6 +212,13 @@ class HassIO(object): """ return self.send_command("/supervisor/ping", method="get") + def get_homeassistant_info(self): + """Return data for HomeAssistant. + + This method return a coroutine. + """ + return self.send_command("/homeassistant/info", method="get") + def update_hass_api(self, http_config): """Update Home-Assistant API data on HassIO. @@ -141,8 +229,13 @@ class HassIO(object): 'ssl': CONF_SSL_CERTIFICATE in http_config, 'port': port, 'password': http_config.get(CONF_API_PASSWORD), + 'watchdog': True, } + if CONF_SERVER_HOST in http_config: + options['watchdog'] = False + _LOGGER.warning("Don't use 'server_host' options with Hass.io!") + return self.send_command("/homeassistant/options", payload=options) def update_hass_timezone(self, core_config): @@ -164,15 +257,17 @@ class HassIO(object): with async_timeout.timeout(timeout, loop=self.loop): request = yield from self.websession.request( method, "http://{}{}".format(self._ip, command), - json=payload) + json=payload, headers={ + X_HASSIO: os.environ.get('HASSIO_TOKEN') + }) if request.status != 200: _LOGGER.error( "%s return code %d.", command, request.status) - return False + return None answer = yield from request.json() - return answer and answer['result'] == 'ok' + return answer except asyncio.TimeoutError: _LOGGER.error("Timeout on %s request", command) @@ -180,7 +275,7 @@ class HassIO(object): except aiohttp.ClientError as err: _LOGGER.error("Client error on %s request %s", command, err) - return False + return None @asyncio.coroutine def command_proxy(self, path, request): @@ -192,11 +287,11 @@ class HassIO(object): try: data = None - headers = None + headers = {X_HASSIO: os.environ.get('HASSIO_TOKEN')} with async_timeout.timeout(10, loop=self.loop): data = yield from request.read() if data: - headers = {CONTENT_TYPE: request.content_type} + headers[CONTENT_TYPE] = request.content_type else: data = None diff --git a/tests/components/test_hassio.py b/tests/components/test_hassio.py index 3704c486a2a..b6be6f5a6a1 100644 --- a/tests/components/test_hassio.py +++ b/tests/components/test_hassio.py @@ -18,7 +18,12 @@ def hassio_env(): """Fixture to inject hassio env.""" with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}), \ patch('homeassistant.components.hassio.HassIO.is_connected', - Mock(return_value=mock_coro(True))): + Mock(return_value=mock_coro( + {"result": "ok", "data": {}}))), \ + patch.dict(os.environ, {'HASSIO_TOKEN': "123456"}), \ + patch('homeassistant.components.hassio.HassIO.' + 'get_homeassistant_info', + Mock(return_value=mock_coro(None))): yield @@ -26,7 +31,10 @@ def hassio_env(): def hassio_client(hassio_env, hass, test_client): """Create mock hassio http client.""" with patch('homeassistant.components.hassio.HassIO.update_hass_api', - Mock(return_value=mock_coro(True))): + Mock(return_value=mock_coro({"result": "ok"}))), \ + patch('homeassistant.components.hassio.HassIO.' + 'get_homeassistant_info', + Mock(return_value=mock_coro(None))): hass.loop.run_until_complete(async_setup_component(hass, 'hassio', { 'http': { 'api_password': API_PASSWORD @@ -48,7 +56,7 @@ def test_fail_setup_cannot_connect(hass): """Fail setup if cannot connect.""" with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}), \ patch('homeassistant.components.hassio.HassIO.is_connected', - Mock(return_value=mock_coro(False))): + Mock(return_value=mock_coro(None))): result = yield from async_setup_component(hass, 'hassio', {}) assert not result @@ -58,12 +66,16 @@ def test_setup_api_ping(hass, aioclient_mock): """Test setup with API ping.""" aioclient_mock.get( "http://127.0.0.1/supervisor/ping", json={'result': 'ok'}) + aioclient_mock.get( + "http://127.0.0.1/homeassistant/info", json={ + 'result': 'ok', 'data': {'last_version': '10.0'}}) with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}): result = yield from async_setup_component(hass, 'hassio', {}) assert result - assert aioclient_mock.call_count == 1 + assert aioclient_mock.call_count == 2 + assert hass.data['hassio_hass_version'] == "10.0" @asyncio.coroutine @@ -71,6 +83,9 @@ def test_setup_api_push_api_data(hass, aioclient_mock): """Test setup with API push.""" aioclient_mock.get( "http://127.0.0.1/supervisor/ping", json={'result': 'ok'}) + aioclient_mock.get( + "http://127.0.0.1/homeassistant/info", json={ + 'result': 'ok', 'data': {'last_version': '10.0'}}) aioclient_mock.post( "http://127.0.0.1/homeassistant/options", json={'result': 'ok'}) @@ -84,10 +99,40 @@ def test_setup_api_push_api_data(hass, aioclient_mock): }) assert result - assert aioclient_mock.call_count == 2 - assert not aioclient_mock.mock_calls[-1][2]['ssl'] - assert aioclient_mock.mock_calls[-1][2]['password'] == "123456" - assert aioclient_mock.mock_calls[-1][2]['port'] == 9999 + assert aioclient_mock.call_count == 3 + assert not aioclient_mock.mock_calls[1][2]['ssl'] + assert aioclient_mock.mock_calls[1][2]['password'] == "123456" + assert aioclient_mock.mock_calls[1][2]['port'] == 9999 + assert aioclient_mock.mock_calls[1][2]['watchdog'] + + +@asyncio.coroutine +def test_setup_api_push_api_data_server_host(hass, aioclient_mock): + """Test setup with API push with active server host.""" + aioclient_mock.get( + "http://127.0.0.1/supervisor/ping", json={'result': 'ok'}) + aioclient_mock.get( + "http://127.0.0.1/homeassistant/info", json={ + 'result': 'ok', 'data': {'last_version': '10.0'}}) + aioclient_mock.post( + "http://127.0.0.1/homeassistant/options", json={'result': 'ok'}) + + with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}): + result = yield from async_setup_component(hass, 'hassio', { + 'http': { + 'api_password': "123456", + 'server_port': 9999, + 'server_host': "127.0.0.1" + }, + 'hassio': {} + }) + assert result + + assert aioclient_mock.call_count == 3 + assert not aioclient_mock.mock_calls[1][2]['ssl'] + assert aioclient_mock.mock_calls[1][2]['password'] == "123456" + assert aioclient_mock.mock_calls[1][2]['port'] == 9999 + assert not aioclient_mock.mock_calls[1][2]['watchdog'] @asyncio.coroutine @@ -95,6 +140,9 @@ def test_setup_api_push_api_data_default(hass, aioclient_mock): """Test setup with API push default data.""" aioclient_mock.get( "http://127.0.0.1/supervisor/ping", json={'result': 'ok'}) + aioclient_mock.get( + "http://127.0.0.1/homeassistant/info", json={ + 'result': 'ok', 'data': {'last_version': '10.0'}}) aioclient_mock.post( "http://127.0.0.1/homeassistant/options", json={'result': 'ok'}) @@ -105,10 +153,10 @@ def test_setup_api_push_api_data_default(hass, aioclient_mock): }) assert result - assert aioclient_mock.call_count == 2 - assert not aioclient_mock.mock_calls[-1][2]['ssl'] - assert aioclient_mock.mock_calls[-1][2]['password'] is None - assert aioclient_mock.mock_calls[-1][2]['port'] == 8123 + assert aioclient_mock.call_count == 3 + assert not aioclient_mock.mock_calls[1][2]['ssl'] + assert aioclient_mock.mock_calls[1][2]['password'] is None + assert aioclient_mock.mock_calls[1][2]['port'] == 8123 @asyncio.coroutine @@ -116,6 +164,9 @@ def test_setup_core_push_timezone(hass, aioclient_mock): """Test setup with API push default data.""" aioclient_mock.get( "http://127.0.0.1/supervisor/ping", json={'result': 'ok'}) + aioclient_mock.get( + "http://127.0.0.1/homeassistant/info", json={ + 'result': 'ok', 'data': {'last_version': '10.0'}}) aioclient_mock.post( "http://127.0.0.1/supervisor/options", json={'result': 'ok'}) @@ -128,8 +179,8 @@ def test_setup_core_push_timezone(hass, aioclient_mock): }) assert result - assert aioclient_mock.call_count == 2 - assert aioclient_mock.mock_calls[-1][2]['timezone'] == "testzone" + assert aioclient_mock.call_count == 3 + assert aioclient_mock.mock_calls[1][2]['timezone'] == "testzone" @asyncio.coroutine @@ -137,14 +188,21 @@ def test_setup_hassio_no_additional_data(hass, aioclient_mock): """Test setup with API push default data.""" aioclient_mock.get( "http://127.0.0.1/supervisor/ping", json={'result': 'ok'}) + aioclient_mock.get( + "http://127.0.0.1/homeassistant/info", json={ + 'result': 'ok', 'data': {'last_version': '10.0'}}) + aioclient_mock.get( + "http://127.0.0.1/homeassistant/info", json={'result': 'ok'}) - with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}): + with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}), \ + patch.dict(os.environ, {'HASSIO_TOKEN': "123456"}): result = yield from async_setup_component(hass, 'hassio', { 'hassio': {}, }) assert result - assert aioclient_mock.call_count == 1 + assert aioclient_mock.call_count == 2 + assert aioclient_mock.mock_calls[-1][3]['X-HASSIO-KEY'] == "123456" @asyncio.coroutine @@ -157,6 +215,10 @@ def test_service_register(hassio_env, hass): assert hass.services.has_service('hassio', 'addon_stdin') assert hass.services.has_service('hassio', 'host_shutdown') assert hass.services.has_service('hassio', 'host_reboot') + assert hass.services.has_service('hassio', 'snapshot_full') + assert hass.services.has_service('hassio', 'snapshot_partial') + assert hass.services.has_service('hassio', 'restore_full') + assert hass.services.has_service('hassio', 'restore_partial') @asyncio.coroutine @@ -176,6 +238,15 @@ def test_service_calls(hassio_env, hass, aioclient_mock): "http://127.0.0.1/host/shutdown", json={'result': 'ok'}) aioclient_mock.post( "http://127.0.0.1/host/reboot", json={'result': 'ok'}) + aioclient_mock.post( + "http://127.0.0.1/snapshots/new/full", json={'result': 'ok'}) + aioclient_mock.post( + "http://127.0.0.1/snapshots/new/partial", json={'result': 'ok'}) + aioclient_mock.post( + "http://127.0.0.1/snapshots/test/restore/full", json={'result': 'ok'}) + aioclient_mock.post( + "http://127.0.0.1/snapshots/test/restore/partial", + json={'result': 'ok'}) yield from hass.services.async_call( 'hassio', 'addon_start', {'addon': 'test'}) @@ -196,6 +267,32 @@ def test_service_calls(hassio_env, hass, aioclient_mock): assert aioclient_mock.call_count == 6 + yield from hass.services.async_call('hassio', 'snapshot_full', {}) + yield from hass.services.async_call('hassio', 'snapshot_partial', { + 'addons': ['test'], + 'folders': ['ssl'], + }) + yield from hass.async_block_till_done() + + assert aioclient_mock.call_count == 8 + assert aioclient_mock.mock_calls[-1][2] == { + 'addons': ['test'], 'folders': ['ssl']} + + yield from hass.services.async_call('hassio', 'restore_full', { + 'snapshot': 'test', + }) + yield from hass.services.async_call('hassio', 'restore_partial', { + 'snapshot': 'test', + 'homeassistant': False, + 'addons': ['test'], + 'folders': ['ssl'], + }) + yield from hass.async_block_till_done() + + assert aioclient_mock.call_count == 10 + assert aioclient_mock.mock_calls[-1][2] == { + 'addons': ['test'], 'folders': ['ssl'], 'homeassistant': False} + @asyncio.coroutine def test_forward_request(hassio_client): diff --git a/tests/test_util/aiohttp.py b/tests/test_util/aiohttp.py index f1380bdf56f..d11a71d541f 100644 --- a/tests/test_util/aiohttp.py +++ b/tests/test_util/aiohttp.py @@ -83,7 +83,7 @@ class AiohttpClientMocker: data = data or json for response in self._mocks: if response.match_request(method, url, params): - self.mock_calls.append((method, url, data)) + self.mock_calls.append((method, url, data, headers)) if response.exc: raise response.exc From 6cc285aea5ca8e321815d12e4bf378c4091608b1 Mon Sep 17 00:00:00 2001 From: Andrey Date: Thu, 11 Jan 2018 00:04:35 +0200 Subject: [PATCH 207/238] Add sensibo_assume_state service to Sensibo climate (#11567) --- homeassistant/components/climate/sensibo.py | 74 ++++++++++++++++--- .../components/climate/services.yaml | 10 +++ requirements_all.txt | 2 +- 3 files changed, 76 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/climate/sensibo.py b/homeassistant/components/climate/sensibo.py index 32a5a998d87..c38f62bc60e 100644 --- a/homeassistant/components/climate/sensibo.py +++ b/homeassistant/components/climate/sensibo.py @@ -13,9 +13,10 @@ import async_timeout import voluptuous as vol from homeassistant.const import ( - ATTR_TEMPERATURE, CONF_API_KEY, CONF_ID, TEMP_CELSIUS, TEMP_FAHRENHEIT) + ATTR_ENTITY_ID, ATTR_STATE, ATTR_TEMPERATURE, CONF_API_KEY, CONF_ID, + STATE_ON, STATE_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT) from homeassistant.components.climate import ( - ATTR_CURRENT_HUMIDITY, ClimateDevice, PLATFORM_SCHEMA, + ATTR_CURRENT_HUMIDITY, ClimateDevice, DOMAIN, PLATFORM_SCHEMA, SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE, SUPPORT_FAN_MODE, SUPPORT_SWING_MODE, SUPPORT_AUX_HEAT, SUPPORT_ON_OFF) @@ -24,18 +25,25 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util.temperature import convert as convert_temperature -REQUIREMENTS = ['pysensibo==1.0.1'] +REQUIREMENTS = ['pysensibo==1.0.2'] _LOGGER = logging.getLogger(__name__) ALL = 'all' TIMEOUT = 10 +SERVICE_ASSUME_STATE = 'sensibo_assume_state' + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_API_KEY): cv.string, vol.Optional(CONF_ID, default=ALL): vol.All(cv.ensure_list, [cv.string]), }) +ASSUME_STATE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_STATE): cv.string, +}) + _FETCH_FIELDS = ','.join([ 'room{name}', 'measurements', 'remoteCapabilities', 'acState', 'connectionStatus{isAlive}', 'temperatureUnit']) @@ -72,6 +80,28 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): if devices: async_add_devices(devices) + @asyncio.coroutine + def async_assume_state(service): + """Set state according to external service call..""" + entity_ids = service.data.get(ATTR_ENTITY_ID) + if entity_ids: + target_climate = [device for device in devices + if device.entity_id in entity_ids] + else: + target_climate = devices + + update_tasks = [] + for climate in target_climate: + yield from climate.async_assume_state( + service.data.get(ATTR_STATE)) + update_tasks.append(climate.async_update_ha_state(True)) + + if update_tasks: + yield from asyncio.wait(update_tasks, loop=hass.loop) + hass.services.async_register( + DOMAIN, SERVICE_ASSUME_STATE, async_assume_state, + schema=ASSUME_STATE_SCHEMA) + class SensiboClimate(ClimateDevice): """Representation of a Sensibo device.""" @@ -84,6 +114,7 @@ class SensiboClimate(ClimateDevice): """ self._client = client self._id = data['id'] + self._external_state = None self._do_update(data) @property @@ -115,6 +146,11 @@ class SensiboClimate(ClimateDevice): if key in FIELD_TO_FLAG: self._supported_features |= FIELD_TO_FLAG[key] + @property + def state(self): + """Return the current state.""" + return self._external_state or super().state + @property def device_state_attributes(self): """Return the state attributes.""" @@ -236,46 +272,66 @@ class SensiboClimate(ClimateDevice): with async_timeout.timeout(TIMEOUT): yield from self._client.async_set_ac_state_property( - self._id, 'targetTemperature', temperature) + self._id, 'targetTemperature', temperature, self._ac_states) @asyncio.coroutine def async_set_fan_mode(self, fan): """Set new target fan mode.""" with async_timeout.timeout(TIMEOUT): yield from self._client.async_set_ac_state_property( - self._id, 'fanLevel', fan) + self._id, 'fanLevel', fan, self._ac_states) @asyncio.coroutine def async_set_operation_mode(self, operation_mode): """Set new target operation mode.""" with async_timeout.timeout(TIMEOUT): yield from self._client.async_set_ac_state_property( - self._id, 'mode', operation_mode) + self._id, 'mode', operation_mode, self._ac_states) @asyncio.coroutine def async_set_swing_mode(self, swing_mode): """Set new target swing operation.""" with async_timeout.timeout(TIMEOUT): yield from self._client.async_set_ac_state_property( - self._id, 'swing', swing_mode) + self._id, 'swing', swing_mode, self._ac_states) @asyncio.coroutine def async_turn_aux_heat_on(self): """Turn Sensibo unit on.""" with async_timeout.timeout(TIMEOUT): yield from self._client.async_set_ac_state_property( - self._id, 'on', True) + self._id, 'on', True, self._ac_states) @asyncio.coroutine def async_turn_aux_heat_off(self): """Turn Sensibo unit on.""" with async_timeout.timeout(TIMEOUT): yield from self._client.async_set_ac_state_property( - self._id, 'on', False) + self._id, 'on', False, self._ac_states) async_on = async_turn_aux_heat_on async_off = async_turn_aux_heat_off + @asyncio.coroutine + def async_assume_state(self, state): + """Set external state.""" + change_needed = (state != STATE_OFF and not self.is_on) \ + or (state == STATE_OFF and self.is_on) + if change_needed: + with async_timeout.timeout(TIMEOUT): + yield from self._client.async_set_ac_state_property( + self._id, + 'on', + state != STATE_OFF, # value + self._ac_states, + True # assumed_state + ) + + if state in [STATE_ON, STATE_OFF]: + self._external_state = None + else: + self._external_state = state + @asyncio.coroutine def async_update(self): """Retrieve latest state.""" diff --git a/homeassistant/components/climate/services.yaml b/homeassistant/components/climate/services.yaml index cb3db926b85..fbb21962c6e 100644 --- a/homeassistant/components/climate/services.yaml +++ b/homeassistant/components/climate/services.yaml @@ -142,3 +142,13 @@ econet_delete_vacation: entity_id: description: Name(s) of entities to change. example: 'climate.water_heater' + +sensibo_assume_state: + description: Set Sensibo device to external state. + fields: + entity_id: + description: Name(s) of entities to change. + example: 'climate.kitchen' + state: + description: State to set. + example: 'idle' diff --git a/requirements_all.txt b/requirements_all.txt index bc90f75b02b..9884c56454e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -825,7 +825,7 @@ pyqwikswitch==0.4 pyrainbird==0.1.3 # homeassistant.components.climate.sensibo -pysensibo==1.0.1 +pysensibo==1.0.2 # homeassistant.components.sensor.serial pyserial-asyncio==0.4 From 60ce2b343dd5023cf87146c5e180cdbc664488f5 Mon Sep 17 00:00:00 2001 From: randellhodges Date: Wed, 10 Jan 2018 16:13:22 -0600 Subject: [PATCH 208/238] Tracking all groups to allow changing of existing groups (#11444) * Tracking all groups to allow changing of existing groups * Unit tests * Fix flake8 warnings in test --- homeassistant/components/group/__init__.py | 17 ++++++++++----- tests/components/group/test_init.py | 25 +++++++++++++++++++++- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index 518eb4fc54c..8b1e05e3122 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -42,6 +42,8 @@ ATTR_ORDER = 'order' ATTR_VIEW = 'view' ATTR_VISIBLE = 'visible' +DATA_ALL_GROUPS = 'data_all_groups' + SERVICE_SET_VISIBILITY = 'set_visibility' SERVICE_SET = 'set' SERVICE_REMOVE = 'remove' @@ -145,7 +147,7 @@ def set_visibility(hass, entity_id=None, visible=True): @bind_hass def set_group(hass, object_id, name=None, entity_ids=None, visible=None, icon=None, view=None, control=None, add=None): - """Create a new user group.""" + """Create/Update a group.""" hass.add_job( async_set_group, hass, object_id, name, entity_ids, visible, icon, view, control, add) @@ -155,7 +157,7 @@ def set_group(hass, object_id, name=None, entity_ids=None, visible=None, @bind_hass def async_set_group(hass, object_id, name=None, entity_ids=None, visible=None, icon=None, view=None, control=None, add=None): - """Create a new user group.""" + """Create/Update a group.""" data = { key: value for key, value in [ (ATTR_OBJECT_ID, object_id), @@ -249,7 +251,7 @@ def get_entity_ids(hass, entity_id, domain_filter=None): def async_setup(hass, config): """Set up all groups found definded in the configuration.""" component = EntityComponent(_LOGGER, DOMAIN, hass) - service_groups = {} + hass.data[DATA_ALL_GROUPS] = {} yield from _async_process_config(hass, config, component) @@ -269,6 +271,7 @@ def async_setup(hass, config): def groups_service_handler(service): """Handle dynamic group service functions.""" object_id = service.data[ATTR_OBJECT_ID] + service_groups = hass.data[DATA_ALL_GROUPS] # new group if service.service == SERVICE_SET and object_id not in service_groups: @@ -279,7 +282,7 @@ def async_setup(hass, config): ATTR_VISIBLE, ATTR_ICON, ATTR_VIEW, ATTR_CONTROL ) if service.data.get(attr) is not None} - new_group = yield from Group.async_create_group( + yield from Group.async_create_group( hass, service.data.get(ATTR_NAME, object_id), object_id=object_id, entity_ids=entity_ids, @@ -287,7 +290,6 @@ def async_setup(hass, config): **extra_arg ) - service_groups[object_id] = new_group return # update group @@ -449,6 +451,11 @@ class Group(Entity): else: yield from group.async_update_ha_state(True) + # If called before the platform async_setup is called (test cases) + if DATA_ALL_GROUPS not in hass.data: + hass.data[DATA_ALL_GROUPS] = {} + + hass.data[DATA_ALL_GROUPS][object_id] = group return group @property diff --git a/tests/components/group/test_init.py b/tests/components/group/test_init.py index 7371ecf6e56..07dda7ff3b2 100644 --- a/tests/components/group/test_init.py +++ b/tests/components/group/test_init.py @@ -8,7 +8,7 @@ from unittest.mock import patch from homeassistant.setup import setup_component, async_setup_component from homeassistant.const import ( STATE_ON, STATE_OFF, STATE_HOME, STATE_UNKNOWN, ATTR_ICON, ATTR_HIDDEN, - ATTR_ASSUMED_STATE, STATE_NOT_HOME) + ATTR_ASSUMED_STATE, STATE_NOT_HOME, ATTR_FRIENDLY_NAME) import homeassistant.components.group as group from tests.common import get_test_home_assistant, assert_setup_component @@ -395,6 +395,29 @@ class TestComponentsGroup(unittest.TestCase): group_state = self.hass.states.get(group_entity_id) self.assertIsNone(group_state.attributes.get(ATTR_HIDDEN)) + def test_modify_group(self): + """Test modifying a group.""" + group_conf = OrderedDict() + group_conf['modify_group'] = { + 'name': 'friendly_name', + 'icon': 'mdi:work' + } + + assert setup_component(self.hass, 'group', {'group': group_conf}) + + # The old way would create a new group modify_group1 because + # internally it didn't know anything about those created in the config + group.set_group(self.hass, 'modify_group', icon="mdi:play") + self.hass.block_till_done() + + group_state = self.hass.states.get( + group.ENTITY_ID_FORMAT.format('modify_group')) + + assert self.hass.states.entity_ids() == ['group.modify_group'] + assert group_state.attributes.get(ATTR_ICON) == 'mdi:play' + assert group_state.attributes.get(ATTR_FRIENDLY_NAME) == \ + 'friendly_name' + @asyncio.coroutine def test_service_group_services(hass): From 5fda78cf91bb29749e0f6626123ba03a43ea5d67 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Thu, 11 Jan 2018 10:15:59 +0100 Subject: [PATCH 209/238] Fix new squeezebox service descriptions for lazy loading (#11574) --- homeassistant/components/media_player/squeezebox.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/homeassistant/components/media_player/squeezebox.py b/homeassistant/components/media_player/squeezebox.py index b97a4525691..13f05cc59f7 100644 --- a/homeassistant/components/media_player/squeezebox.py +++ b/homeassistant/components/media_player/squeezebox.py @@ -8,13 +8,11 @@ import logging import asyncio import urllib.parse import json -import os import aiohttp import async_timeout import voluptuous as vol -from homeassistant.config import load_yaml_config_file from homeassistant.components.media_player import ( ATTR_MEDIA_ENQUEUE, SUPPORT_PLAY_MEDIA, MEDIA_TYPE_MUSIC, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, PLATFORM_SCHEMA, @@ -126,15 +124,11 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): if update_tasks: yield from asyncio.wait(update_tasks, loop=hass.loop) - descriptions = yield from hass.async_add_job( - load_yaml_config_file, os.path.join( - os.path.dirname(__file__), 'services.yaml')) - for service in SERVICE_TO_METHOD: schema = SERVICE_TO_METHOD[service]['schema'] hass.services.async_register( DOMAIN, service, async_service_handler, - description=descriptions.get(service), schema=schema) + schema=schema) return True From 3972d1d4c60f1dcdc39783dc3be67c2f1d477732 Mon Sep 17 00:00:00 2001 From: Dan Nixon Date: Thu, 11 Jan 2018 09:48:15 +0000 Subject: [PATCH 210/238] Mark REST binary_sensor unavailable if request fails (#11506) * Mark REST binary_sensor unavailable if request fails * Add test suite for RESTful binary sensor --- .../components/binary_sensor/rest.py | 5 + tests/components/binary_sensor/test_rest.py | 192 ++++++++++++++++++ 2 files changed, 197 insertions(+) create mode 100644 tests/components/binary_sensor/test_rest.py diff --git a/homeassistant/components/binary_sensor/rest.py b/homeassistant/components/binary_sensor/rest.py index 1f8d0ebe2f7..e9cb40f6747 100644 --- a/homeassistant/components/binary_sensor/rest.py +++ b/homeassistant/components/binary_sensor/rest.py @@ -98,6 +98,11 @@ class RestBinarySensor(BinarySensorDevice): """Return the class of this sensor.""" return self._device_class + @property + def available(self): + """Return the availability of this sensor.""" + return self.rest.data is not None + @property def is_on(self): """Return true if the binary sensor is on.""" diff --git a/tests/components/binary_sensor/test_rest.py b/tests/components/binary_sensor/test_rest.py new file mode 100644 index 00000000000..d0670bf5154 --- /dev/null +++ b/tests/components/binary_sensor/test_rest.py @@ -0,0 +1,192 @@ +"""The tests for the REST binary sensor platform.""" +import unittest +from unittest.mock import patch, Mock + +import requests +from requests.exceptions import Timeout, MissingSchema +import requests_mock + +from homeassistant.setup import setup_component +import homeassistant.components.binary_sensor as binary_sensor +import homeassistant.components.binary_sensor.rest as rest +from homeassistant.const import STATE_ON, STATE_OFF +from homeassistant.helpers import template + +from tests.common import get_test_home_assistant, assert_setup_component + + +class TestRestBinarySensorSetup(unittest.TestCase): + """Tests for setting up the REST binary sensor platform.""" + + def setUp(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + def test_setup_missing_config(self): + """Test setup with configuration missing required entries.""" + with assert_setup_component(0): + assert setup_component(self.hass, binary_sensor.DOMAIN, { + 'binary_sensor': {'platform': 'rest'}}) + + def test_setup_missing_schema(self): + """Test setup with resource missing schema.""" + with self.assertRaises(MissingSchema): + rest.setup_platform(self.hass, { + 'platform': 'rest', + 'resource': 'localhost', + 'method': 'GET' + }, None) + + @patch('requests.Session.send', + side_effect=requests.exceptions.ConnectionError()) + def test_setup_failed_connect(self, mock_req): + """Test setup when connection error occurs.""" + self.assertFalse(rest.setup_platform(self.hass, { + 'platform': 'rest', + 'resource': 'http://localhost', + }, lambda devices, update=True: None)) + + @patch('requests.Session.send', side_effect=Timeout()) + def test_setup_timeout(self, mock_req): + """Test setup when connection timeout occurs.""" + self.assertFalse(rest.setup_platform(self.hass, { + 'platform': 'rest', + 'resource': 'http://localhost', + }, lambda devices, update=True: None)) + + @requests_mock.Mocker() + def test_setup_minimum(self, mock_req): + """Test setup with minimum configuration.""" + mock_req.get('http://localhost', status_code=200) + self.assertTrue(setup_component(self.hass, 'binary_sensor', { + 'binary_sensor': { + 'platform': 'rest', + 'resource': 'http://localhost' + } + })) + self.assertEqual(2, mock_req.call_count) + assert_setup_component(1, 'switch') + + @requests_mock.Mocker() + def test_setup_get(self, mock_req): + """Test setup with valid configuration.""" + mock_req.get('http://localhost', status_code=200) + self.assertTrue(setup_component(self.hass, 'binary_sensor', { + 'binary_sensor': { + 'platform': 'rest', + 'resource': 'http://localhost', + 'method': 'GET', + 'value_template': '{{ value_json.key }}', + 'name': 'foo', + 'unit_of_measurement': 'MB', + 'verify_ssl': 'true', + 'authentication': 'basic', + 'username': 'my username', + 'password': 'my password', + 'headers': {'Accept': 'application/json'} + } + })) + self.assertEqual(2, mock_req.call_count) + assert_setup_component(1, 'binary_sensor') + + @requests_mock.Mocker() + def test_setup_post(self, mock_req): + """Test setup with valid configuration.""" + mock_req.post('http://localhost', status_code=200) + self.assertTrue(setup_component(self.hass, 'binary_sensor', { + 'binary_sensor': { + 'platform': 'rest', + 'resource': 'http://localhost', + 'method': 'POST', + 'value_template': '{{ value_json.key }}', + 'payload': '{ "device": "toaster"}', + 'name': 'foo', + 'unit_of_measurement': 'MB', + 'verify_ssl': 'true', + 'authentication': 'basic', + 'username': 'my username', + 'password': 'my password', + 'headers': {'Accept': 'application/json'} + } + })) + self.assertEqual(2, mock_req.call_count) + assert_setup_component(1, 'binary_sensor') + + +class TestRestBinarySensor(unittest.TestCase): + """Tests for REST binary sensor platform.""" + + def setUp(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.rest = Mock('RestData') + self.rest.update = Mock('RestData.update', + side_effect=self.update_side_effect( + '{ "key": false }')) + self.name = 'foo' + self.device_class = 'light' + self.value_template = \ + template.Template('{{ value_json.key }}', self.hass) + + self.binary_sensor = rest.RestBinarySensor( + self.hass, self.rest, self.name, self.device_class, + self.value_template) + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + def update_side_effect(self, data): + """Side effect function for mocking RestData.update().""" + self.rest.data = data + + def test_name(self): + """Test the name.""" + self.assertEqual(self.name, self.binary_sensor.name) + + def test_device_class(self): + """Test the device class.""" + self.assertEqual(self.device_class, self.binary_sensor.device_class) + + def test_initial_state(self): + """Test the initial state.""" + self.binary_sensor.update() + self.assertEqual(STATE_OFF, self.binary_sensor.state) + + def test_update_when_value_is_none(self): + """Test state gets updated to unknown when sensor returns no data.""" + self.rest.update = Mock( + 'RestData.update', + side_effect=self.update_side_effect(None)) + self.binary_sensor.update() + self.assertFalse(self.binary_sensor.available) + + def test_update_when_value_changed(self): + """Test state gets updated when sensor returns a new status.""" + self.rest.update = Mock('rest.RestData.update', + side_effect=self.update_side_effect( + '{ "key": true }')) + self.binary_sensor.update() + self.assertEqual(STATE_ON, self.binary_sensor.state) + self.assertTrue(self.binary_sensor.available) + + def test_update_when_failed_request(self): + """Test state gets updated when sensor returns a new status.""" + self.rest.update = Mock('rest.RestData.update', + side_effect=self.update_side_effect(None)) + self.binary_sensor.update() + self.assertFalse(self.binary_sensor.available) + + def test_update_with_no_template(self): + """Test update when there is no value template.""" + self.rest.update = Mock('rest.RestData.update', + side_effect=self.update_side_effect('true')) + self.binary_sensor = rest.RestBinarySensor( + self.hass, self.rest, self.name, self.device_class, None) + self.binary_sensor.update() + self.assertEqual(STATE_ON, self.binary_sensor.state) + self.assertTrue(self.binary_sensor.available) From d2b666088193a65f4f9e3664344aa34241af06ab Mon Sep 17 00:00:00 2001 From: Josh Anderson Date: Thu, 11 Jan 2018 09:49:41 +0000 Subject: [PATCH 211/238] Tado improvements - hot water zone sensors and climate precision (#11521) * Add tado hot water zone sensors * Set precision to match tado app/website --- homeassistant/components/climate/tado.py | 7 ++++++- homeassistant/components/sensor/tado.py | 14 +++++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/climate/tado.py b/homeassistant/components/climate/tado.py index a8054b838ef..25492cb0895 100644 --- a/homeassistant/components/climate/tado.py +++ b/homeassistant/components/climate/tado.py @@ -6,7 +6,7 @@ https://home-assistant.io/components/climate.tado/ """ import logging -from homeassistant.const import TEMP_CELSIUS +from homeassistant.const import (PRECISION_TENTHS, TEMP_CELSIUS) from homeassistant.components.climate import ( ClimateDevice, SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE) from homeassistant.const import ATTR_TEMPERATURE @@ -192,6 +192,11 @@ class TadoClimate(ClimateDevice): """Return true if away mode is on.""" return self._is_away + @property + def target_temperature_step(self): + """Return the supported step of target temperature.""" + return PRECISION_TENTHS + @property def target_temperature(self): """Return the temperature we try to reach.""" diff --git a/homeassistant/components/sensor/tado.py b/homeassistant/components/sensor/tado.py index 781f2e006d9..8c7259ff800 100644 --- a/homeassistant/components/sensor/tado.py +++ b/homeassistant/components/sensor/tado.py @@ -18,8 +18,10 @@ ATTR_DEVICE = 'device' ATTR_NAME = 'name' ATTR_ZONE = 'zone' -SENSOR_TYPES = ['temperature', 'humidity', 'power', - 'link', 'heating', 'tado mode', 'overlay'] +CLIMATE_SENSOR_TYPES = ['temperature', 'humidity', 'power', + 'link', 'heating', 'tado mode', 'overlay'] + +HOT_WATER_SENSOR_TYPES = ['power', 'link', 'tado mode', 'overlay'] def setup_platform(hass, config, add_devices, discovery_info=None): @@ -35,10 +37,16 @@ def setup_platform(hass, config, add_devices, discovery_info=None): sensor_items = [] for zone in zones: if zone['type'] == 'HEATING': - for variable in SENSOR_TYPES: + for variable in CLIMATE_SENSOR_TYPES: sensor_items.append(create_zone_sensor( tado, zone, zone['name'], zone['id'], variable)) + elif zone['type'] == 'HOT_WATER': + for variable in HOT_WATER_SENSOR_TYPES: + sensor_items.append(create_zone_sensor( + tado, zone, zone['name'], zone['id'], + variable + )) me_data = tado.get_me() sensor_items.append(create_device_sensor( From cf612c3d5bf6c47f8a1da7b26316549996f92f06 Mon Sep 17 00:00:00 2001 From: Ulrich Dobramysl <1979498+ulido@users.noreply.github.com> Date: Thu, 11 Jan 2018 12:47:05 +0000 Subject: [PATCH 212/238] Make the rpi_rf component thread-safe using an RLock (#11487) * Make the rpi_rf component thread-safe The previous implementation suffered from race conditions when two rpi_rf switches are triggered at the same time. This implementation uses an RLock to give one thread at a time exclusive access to the rfdevice object. * cleanup * fix lint --- homeassistant/components/switch/rpi_rf.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/switch/rpi_rf.py b/homeassistant/components/switch/rpi_rf.py index e48ac1a4d7d..94a61314d1d 100644 --- a/homeassistant/components/switch/rpi_rf.py +++ b/homeassistant/components/switch/rpi_rf.py @@ -48,18 +48,20 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): """Find and return switches controlled by a generic RF device via GPIO.""" import rpi_rf + from threading import RLock gpio = config.get(CONF_GPIO) rfdevice = rpi_rf.RFDevice(gpio) + rfdevice_lock = RLock() switches = config.get(CONF_SWITCHES) devices = [] for dev_name, properties in switches.items(): devices.append( RPiRFSwitch( - hass, properties.get(CONF_NAME, dev_name), rfdevice, + rfdevice_lock, properties.get(CONF_PROTOCOL), properties.get(CONF_PULSELENGTH), properties.get(CONF_SIGNAL_REPETITIONS), @@ -79,13 +81,13 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class RPiRFSwitch(SwitchDevice): """Representation of a GPIO RF switch.""" - def __init__(self, hass, name, rfdevice, protocol, pulselength, + def __init__(self, name, rfdevice, lock, protocol, pulselength, signal_repetitions, code_on, code_off): """Initialize the switch.""" - self._hass = hass self._name = name self._state = False self._rfdevice = rfdevice + self._lock = lock self._protocol = protocol self._pulselength = pulselength self._code_on = code_on @@ -109,9 +111,10 @@ class RPiRFSwitch(SwitchDevice): def _send_code(self, code_list, protocol, pulselength): """Send the code(s) with a specified pulselength.""" - _LOGGER.info("Sending code(s): %s", code_list) - for code in code_list: - self._rfdevice.tx_code(code, protocol, pulselength) + with self._lock: + _LOGGER.info("Sending code(s): %s", code_list) + for code in code_list: + self._rfdevice.tx_code(code, protocol, pulselength) return True def turn_on(self): From 7723db9e621f9bfba92d3a011f2984de7cfc827b Mon Sep 17 00:00:00 2001 From: Daniel Perna Date: Thu, 11 Jan 2018 20:08:09 +0100 Subject: [PATCH 213/238] Update pyhomematic, support new devices (#11578) --- homeassistant/components/homematic/__init__.py | 4 ++-- requirements_all.txt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homematic/__init__.py b/homeassistant/components/homematic/__init__.py index 9f0fcdb9874..b2f6384d467 100644 --- a/homeassistant/components/homematic/__init__.py +++ b/homeassistant/components/homematic/__init__.py @@ -20,7 +20,7 @@ from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv from homeassistant.loader import bind_hass -REQUIREMENTS = ['pyhomematic==0.1.36'] +REQUIREMENTS = ['pyhomematic==0.1.37'] DOMAIN = 'homematic' _LOGGER = logging.getLogger(__name__) @@ -78,7 +78,7 @@ HM_DEVICE_TYPES = { 'MotionIP', 'RemoteMotion', 'WeatherSensor', 'TiltSensor', 'IPShutterContact', 'HMWIOSwitch', 'MaxShutterContact', 'Rain', 'WiredSensor', 'PresenceIP'], - DISCOVER_COVER: ['Blind', 'KeyBlind'] + DISCOVER_COVER: ['Blind', 'KeyBlind', 'IPKeyBlind', 'IPKeyBlindTilt'] } HM_IGNORE_DISCOVERY_NODE = [ diff --git a/requirements_all.txt b/requirements_all.txt index 9884c56454e..c5345a5a698 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -723,7 +723,7 @@ pyhik==0.1.4 pyhiveapi==0.2.10 # homeassistant.components.homematic -pyhomematic==0.1.36 +pyhomematic==0.1.37 # homeassistant.components.sensor.hydroquebec pyhydroquebec==2.1.0 From dc5b610ee53a776627626ea26456705a045b936a Mon Sep 17 00:00:00 2001 From: hawk259 Date: Thu, 11 Jan 2018 16:53:14 -0500 Subject: [PATCH 214/238] Alarmdecoder add validation of the zone types (#11488) * Alarmdecoder add validation of the zone types * fix line length --- homeassistant/components/alarmdecoder.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/alarmdecoder.py b/homeassistant/components/alarmdecoder.py index 120925dab6e..bc7f1910803 100644 --- a/homeassistant/components/alarmdecoder.py +++ b/homeassistant/components/alarmdecoder.py @@ -13,6 +13,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.helpers.discovery import load_platform from homeassistant.util import dt as dt_util +from homeassistant.components.binary_sensor import DEVICE_CLASSES_SCHEMA REQUIREMENTS = ['alarmdecoder==1.13.2'] @@ -68,7 +69,8 @@ DEVICE_USB_SCHEMA = vol.Schema({ ZONE_SCHEMA = vol.Schema({ vol.Required(CONF_ZONE_NAME): cv.string, - vol.Optional(CONF_ZONE_TYPE, default=DEFAULT_ZONE_TYPE): cv.string, + vol.Optional(CONF_ZONE_TYPE, + default=DEFAULT_ZONE_TYPE): vol.Any(DEVICE_CLASSES_SCHEMA), vol.Optional(CONF_ZONE_RFID): cv.string}) CONFIG_SCHEMA = vol.Schema({ From 3bc6a7da3200402af4c13bd1d3fb528107624ed9 Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Thu, 11 Jan 2018 16:56:00 -0500 Subject: [PATCH 215/238] Support OSRAM lights on ZHA (#11522) --- homeassistant/components/light/zha.py | 56 +++++++++++++++++---------- 1 file changed, 35 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/light/zha.py b/homeassistant/components/light/zha.py index a18fdc9dec6..2aad486a894 100644 --- a/homeassistant/components/light/zha.py +++ b/homeassistant/components/light/zha.py @@ -17,6 +17,11 @@ DEPENDENCIES = ['zha'] DEFAULT_DURATION = 0.5 +CAPABILITIES_COLOR_XY = 0x08 +CAPABILITIES_COLOR_TEMP = 0x10 + +UNSUPPORTED_ATTRIBUTE = 0x86 + @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): @@ -32,9 +37,36 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): except (AttributeError, KeyError): pass + if discovery_info.get('color_capabilities') is None: + # ZCL Version 4 devices don't support the color_capabilities attribute. + # In this version XY support is mandatory, but we need to probe to + # determine if the device supports color temperature. + discovery_info['color_capabilities'] = CAPABILITIES_COLOR_XY + result = yield from safe_read( + endpoint.light_color, ['color_temperature']) + if result.get('color_temperature') is not UNSUPPORTED_ATTRIBUTE: + discovery_info['color_capabilities'] |= CAPABILITIES_COLOR_TEMP + async_add_devices([Light(**discovery_info)], update_before_add=True) +@asyncio.coroutine +def safe_read(cluster, attributes): + """Swallow all exceptions from network read. + + If we throw during initialization, setup fails. Rather have an + entity that exists, but is in a maybe wrong state, than no entity. + """ + try: + result, _ = yield from cluster.read_attributes( + attributes, + allow_cache=False, + ) + return result + except Exception: # pylint: disable=broad-except + return {} + + class Light(zha.Entity, light.Light): """Representation of a ZHA or ZLL light.""" @@ -54,11 +86,11 @@ class Light(zha.Entity, light.Light): self._supported_features |= light.SUPPORT_TRANSITION self._brightness = 0 if zcl_clusters.lighting.Color.cluster_id in self._in_clusters: - color_capabilities = kwargs.get('color_capabilities', 0x10) - if color_capabilities & 0x10: + color_capabilities = kwargs['color_capabilities'] + if color_capabilities & CAPABILITIES_COLOR_TEMP: self._supported_features |= light.SUPPORT_COLOR_TEMP - if color_capabilities & 0x08: + if color_capabilities & CAPABILITIES_COLOR_XY: self._supported_features |= light.SUPPORT_XY_COLOR self._supported_features |= light.SUPPORT_RGB_COLOR self._xy_color = (1.0, 1.0) @@ -142,24 +174,6 @@ class Light(zha.Entity, light.Light): @asyncio.coroutine def async_update(self): """Retrieve latest state.""" - _LOGGER.debug("%s async_update", self.entity_id) - - @asyncio.coroutine - def safe_read(cluster, attributes): - """Swallow all exceptions from network read. - - If we throw during initialization, setup fails. Rather have an - entity that exists, but is in a maybe wrong state, than no entity. - """ - try: - result, _ = yield from cluster.read_attributes( - attributes, - allow_cache=False, - ) - return result - except Exception: # pylint: disable=broad-except - return {} - result = yield from safe_read(self._endpoint.on_off, ['on_off']) self._state = result.get('on_off', self._state) From a23f60315fbeec0cac0f2487c2e998cae13cab5b Mon Sep 17 00:00:00 2001 From: Sean Wilson Date: Thu, 11 Jan 2018 17:10:47 -0500 Subject: [PATCH 216/238] Fix bluetooth tracker source (#11469) * Add new sources for bluetooth and bluetooth_le trackers (rather than just using the generic 'gps' catch-all). * Fix lint issues --- homeassistant/components/device_tracker/__init__.py | 2 ++ .../components/device_tracker/bluetooth_le_tracker.py | 5 +++-- homeassistant/components/device_tracker/bluetooth_tracker.py | 5 +++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index 3ae5bf82007..2adee1e2330 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -80,6 +80,8 @@ ATTR_VENDOR = 'vendor' SOURCE_TYPE_GPS = 'gps' SOURCE_TYPE_ROUTER = 'router' +SOURCE_TYPE_BLUETOOTH = 'bluetooth' +SOURCE_TYPE_BLUETOOTH_LE = 'bluetooth_le' NEW_DEVICE_DEFAULTS_SCHEMA = vol.Any(None, vol.Schema({ vol.Optional(CONF_TRACK_NEW, default=DEFAULT_TRACK_NEW): cv.boolean, diff --git a/homeassistant/components/device_tracker/bluetooth_le_tracker.py b/homeassistant/components/device_tracker/bluetooth_le_tracker.py index 22713cdc18e..19582822913 100644 --- a/homeassistant/components/device_tracker/bluetooth_le_tracker.py +++ b/homeassistant/components/device_tracker/bluetooth_le_tracker.py @@ -10,7 +10,7 @@ import voluptuous as vol from homeassistant.helpers.event import track_point_in_utc_time from homeassistant.components.device_tracker import ( YAML_DEVICES, CONF_TRACK_NEW, CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL, - PLATFORM_SCHEMA, load_config + PLATFORM_SCHEMA, load_config, SOURCE_TYPE_BLUETOOTH_LE ) import homeassistant.util.dt as dt_util import homeassistant.helpers.config_validation as cv @@ -54,7 +54,8 @@ def setup_scanner(hass, config, see, discovery_info=None): new_devices[address] = 1 return - see(mac=BLE_PREFIX + address, host_name=name.strip("\x00")) + see(mac=BLE_PREFIX + address, host_name=name.strip("\x00"), + source_type=SOURCE_TYPE_BLUETOOTH_LE) def discover_ble_devices(): """Discover Bluetooth LE devices.""" diff --git a/homeassistant/components/device_tracker/bluetooth_tracker.py b/homeassistant/components/device_tracker/bluetooth_tracker.py index 9e0957e363f..a535d87105e 100644 --- a/homeassistant/components/device_tracker/bluetooth_tracker.py +++ b/homeassistant/components/device_tracker/bluetooth_tracker.py @@ -12,7 +12,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import track_point_in_utc_time from homeassistant.components.device_tracker import ( YAML_DEVICES, CONF_TRACK_NEW, CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL, - load_config, PLATFORM_SCHEMA, DEFAULT_TRACK_NEW) + load_config, PLATFORM_SCHEMA, DEFAULT_TRACK_NEW, SOURCE_TYPE_BLUETOOTH) import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -33,7 +33,8 @@ def setup_scanner(hass, config, see, discovery_info=None): def see_device(device): """Mark a device as seen.""" - see(mac=BT_PREFIX + device[0], host_name=device[1]) + see(mac=BT_PREFIX + device[0], host_name=device[1], + source_type=SOURCE_TYPE_BLUETOOTH) def discover_devices(): """Discover Bluetooth devices.""" From 6b103cc9b94b0aa3606cdd48d60a18eca47e5517 Mon Sep 17 00:00:00 2001 From: kennedyshead Date: Thu, 11 Jan 2018 23:12:27 +0100 Subject: [PATCH 217/238] Fix for asuswrt, telnet not working and presence-detection for router mode (#11422) * Fix for telnet mode Fix for presence detection closes #11349 #10231 * Optimisation when device is disconnected --- .../components/device_tracker/asuswrt.py | 44 +++++++++++++------ 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/device_tracker/asuswrt.py b/homeassistant/components/device_tracker/asuswrt.py index 495e377077f..f49f54b3622 100644 --- a/homeassistant/components/device_tracker/asuswrt.py +++ b/homeassistant/components/device_tracker/asuswrt.py @@ -177,10 +177,10 @@ class AsusWrtDeviceScanner(DeviceScanner): """ devices = {} devices.update(self._get_wl()) - devices.update(self._get_arp()) - devices.update(self._get_neigh()) + devices = self._get_arp(devices) + devices = self._get_neigh(devices) if not self.mode == 'ap': - devices.update(self._get_leases()) + devices.update(self._get_leases(devices)) return devices def _get_wl(self): @@ -194,7 +194,7 @@ class AsusWrtDeviceScanner(DeviceScanner): devices[mac] = Device(mac, None, None) return devices - def _get_leases(self): + def _get_leases(self, cur_devices): lines = self.connection.run_command(_LEASES_CMD) if not lines: return {} @@ -208,30 +208,45 @@ class AsusWrtDeviceScanner(DeviceScanner): if host == '*': host = '' mac = device['mac'].upper() - devices[mac] = Device(mac, device['ip'], host) + if mac in cur_devices: + devices[mac] = Device(mac, device['ip'], host) return devices - def _get_neigh(self): + def _get_neigh(self, cur_devices): lines = self.connection.run_command(_IP_NEIGH_CMD) if not lines: return {} result = _parse_lines(lines, _IP_NEIGH_REGEX) devices = {} for device in result: - mac = device['mac'].upper() - devices[mac] = Device(mac, None, None) - return devices + if device['mac']: + mac = device['mac'].upper() + devices[mac] = Device(mac, None, None) + else: + cur_devices = { + k: v for k, v in + cur_devices.items() if v.ip != device['ip'] + } + cur_devices.update(devices) + return cur_devices - def _get_arp(self): + def _get_arp(self, cur_devices): lines = self.connection.run_command(_ARP_CMD) if not lines: return {} result = _parse_lines(lines, _ARP_REGEX) devices = {} for device in result: - mac = device['mac'].upper() - devices[mac] = Device(mac, device['ip'], None) - return devices + if device['mac']: + mac = device['mac'].upper() + devices[mac] = Device(mac, device['ip'], None) + else: + cur_devices = { + k: v for k, v in + cur_devices.items() if v.ip != device['ip'] + } + cur_devices.update(devices) + return cur_devices class _Connection: @@ -348,8 +363,9 @@ class TelnetConnection(_Connection): self.connect() self._telnet.write('{}\n'.format(command).encode('ascii')) - return (self._telnet.read_until(self._prompt_string). + data = (self._telnet.read_until(self._prompt_string). split(b'\n')[1:-1]) + return [line.decode('utf-8') for line in data] except EOFError: _LOGGER.error("Unexpected response from router") self.disconnect() From a35d05ac7e7a5ceb11aa26919e5049cbfb52df36 Mon Sep 17 00:00:00 2001 From: Andrey Date: Fri, 12 Jan 2018 00:25:15 +0200 Subject: [PATCH 218/238] Remove aux_heat support from Sensibo now that UI supports on/off (#11579) --- homeassistant/components/climate/sensibo.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/climate/sensibo.py b/homeassistant/components/climate/sensibo.py index c38f62bc60e..870e2db6b42 100644 --- a/homeassistant/components/climate/sensibo.py +++ b/homeassistant/components/climate/sensibo.py @@ -19,7 +19,7 @@ from homeassistant.components.climate import ( ATTR_CURRENT_HUMIDITY, ClimateDevice, DOMAIN, PLATFORM_SCHEMA, SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE, SUPPORT_FAN_MODE, SUPPORT_SWING_MODE, - SUPPORT_AUX_HEAT, SUPPORT_ON_OFF) + SUPPORT_ON_OFF) from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -54,7 +54,7 @@ FIELD_TO_FLAG = { 'mode': SUPPORT_OPERATION_MODE, 'swing': SUPPORT_SWING_MODE, 'targetTemperature': SUPPORT_TARGET_TEMPERATURE, - 'on': SUPPORT_AUX_HEAT | SUPPORT_ON_OFF, + 'on': SUPPORT_ON_OFF, } @@ -232,12 +232,10 @@ class SensiboClimate(ClimateDevice): return self._name @property - def is_aux_heat_on(self): + def is_on(self): """Return true if AC is on.""" return self._ac_states['on'] - is_on = is_aux_heat_on - @property def min_temp(self): """Return the minimum temperature.""" @@ -296,22 +294,19 @@ class SensiboClimate(ClimateDevice): self._id, 'swing', swing_mode, self._ac_states) @asyncio.coroutine - def async_turn_aux_heat_on(self): + def async_on(self): """Turn Sensibo unit on.""" with async_timeout.timeout(TIMEOUT): yield from self._client.async_set_ac_state_property( self._id, 'on', True, self._ac_states) @asyncio.coroutine - def async_turn_aux_heat_off(self): + def async_off(self): """Turn Sensibo unit on.""" with async_timeout.timeout(TIMEOUT): yield from self._client.async_set_ac_state_property( self._id, 'on', False, self._ac_states) - async_on = async_turn_aux_heat_on - async_off = async_turn_aux_heat_off - @asyncio.coroutine def async_assume_state(self, state): """Set external state.""" From 1235dae5b8b9caa7342e4f92391697422c1e6ae9 Mon Sep 17 00:00:00 2001 From: Laqoore Date: Fri, 12 Jan 2018 00:01:38 +0100 Subject: [PATCH 219/238] Changed device type of media player and cover to switch (#11483) * Changed device type of media player and cover to switch Covers and media players should not be of device type 'light'. Example: If user requests all lights to switch to off, covers are closed and media players are affected too. * Fix test --- .../components/google_assistant/smart_home.py | 4 ++-- tests/components/google_assistant/__init__.py | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index d6d5a4fd877..0faa9bdc484 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -54,12 +54,12 @@ MAPPING_COMPONENT = { } ], cover.DOMAIN: [ - TYPE_LIGHT, TRAIT_ONOFF, { + TYPE_SWITCH, TRAIT_ONOFF, { cover.SUPPORT_SET_POSITION: TRAIT_BRIGHTNESS } ], media_player.DOMAIN: [ - TYPE_LIGHT, TRAIT_ONOFF, { + TYPE_SWITCH, TRAIT_ONOFF, { media_player.SUPPORT_VOLUME_SET: TRAIT_BRIGHTNESS } ], diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py index bcb12c70b58..eb8d17a83aa 100644 --- a/tests/components/google_assistant/__init__.py +++ b/tests/components/google_assistant/__init__.py @@ -95,7 +95,7 @@ DEMO_DEVICES = [{ 'traits': ['action.devices.traits.OnOff', 'action.devices.traits.Brightness'], 'type': - 'action.devices.types.LIGHT', + 'action.devices.types.SWITCH', 'willReportState': False }, { @@ -107,7 +107,7 @@ DEMO_DEVICES = [{ 'traits': ['action.devices.traits.OnOff', 'action.devices.traits.Brightness'], 'type': - 'action.devices.types.LIGHT', + 'action.devices.types.SWITCH', 'willReportState': False }, { @@ -116,7 +116,7 @@ DEMO_DEVICES = [{ 'name': 'Garage Door' }, 'traits': ['action.devices.traits.OnOff'], - 'type': 'action.devices.types.LIGHT', + 'type': 'action.devices.types.SWITCH', 'willReportState': False }, { 'id': 'cover.kitchen_window', @@ -124,7 +124,7 @@ DEMO_DEVICES = [{ 'name': 'Kitchen Window' }, 'traits': ['action.devices.traits.OnOff'], - 'type': 'action.devices.types.LIGHT', + 'type': 'action.devices.types.SWITCH', 'willReportState': False }, { 'id': 'group.all_covers', @@ -143,7 +143,7 @@ DEMO_DEVICES = [{ 'traits': ['action.devices.traits.OnOff', 'action.devices.traits.Brightness'], 'type': - 'action.devices.types.LIGHT', + 'action.devices.types.SWITCH', 'willReportState': False }, { @@ -155,7 +155,7 @@ DEMO_DEVICES = [{ 'traits': ['action.devices.traits.OnOff', 'action.devices.traits.Brightness'], 'type': - 'action.devices.types.LIGHT', + 'action.devices.types.SWITCH', 'willReportState': False }, { @@ -164,7 +164,7 @@ DEMO_DEVICES = [{ 'name': 'Lounge room' }, 'traits': ['action.devices.traits.OnOff'], - 'type': 'action.devices.types.LIGHT', + 'type': 'action.devices.types.SWITCH', 'willReportState': False }, { 'id': @@ -175,7 +175,7 @@ DEMO_DEVICES = [{ 'traits': ['action.devices.traits.OnOff', 'action.devices.traits.Brightness'], 'type': - 'action.devices.types.LIGHT', + 'action.devices.types.SWITCH', 'willReportState': False }, { From 6af42b43725322c3dd69b837444e1ab10339cb65 Mon Sep 17 00:00:00 2001 From: Bob Anderson Date: Thu, 11 Jan 2018 15:21:23 -0800 Subject: [PATCH 220/238] Control ordering of display in history component (#11340) * Make the order shown in the history component match the ordering given in the configuration of included entities (if any) * return the sorted result * optimize sorting. since entities only appear once, we can break from a search on first find, and no copy of the list is needed --- homeassistant/components/history.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/history.py b/homeassistant/components/history.py index 55858dbe765..8f96d95521d 100644 --- a/homeassistant/components/history.py +++ b/homeassistant/components/history.py @@ -304,7 +304,20 @@ class HistoryPeriodView(HomeAssistantView): elapsed = time.perf_counter() - timer_start _LOGGER.debug( 'Extracted %d states in %fs', sum(map(len, result)), elapsed) - return self.json(result) + + # Reorder the result to respect the ordering given by any + # entities explicitly included in the configuration. + + sorted_result = [] + for order_entity in self.filters.included_entities: + for state_list in result: + if state_list[0].entity_id == order_entity: + sorted_result.append(state_list) + result.remove(state_list) + break + sorted_result.extend(result) + + return self.json(sorted_result) class Filters(object): From 9384e2c96344e77865e8900bf91333e4540bb5a1 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 11 Jan 2018 15:26:48 -0800 Subject: [PATCH 221/238] Pr/11430 (#11587) * Fix error when name is non-latin script When the name is non-latin script (e.g. Japanese), slugify returns empty string and causes failure in a later stage. This commit fixes the issue by using default name. * Add test --- homeassistant/helpers/entity.py | 4 ++-- tests/helpers/test_entity.py | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 78db0890ab1..61569b7cf53 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -35,10 +35,10 @@ def generate_entity_id(entity_id_format: str, name: Optional[str], current_ids, hass ).result() - name = (name or DEVICE_DEFAULT_NAME).lower() + name = (slugify(name) or slugify(DEVICE_DEFAULT_NAME)).lower() return ensure_unique_string( - entity_id_format.format(slugify(name)), current_ids) + entity_id_format.format(name), current_ids) @callback diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index a4c8b03daa0..637644ca5b3 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -31,6 +31,14 @@ def test_generate_entity_id_given_keys(): 'test.another_entity']) == 'test.overwrite_hidden_true' +def test_generate_entity_id_with_nonlatin_name(): + """Test generate_entity_id given a name containing non-latin characters.""" + fmt = 'test.{}' + assert entity.generate_entity_id( + fmt, 'ホームアシスタント', current_ids=[] + ) == 'test.unnamed_device' + + def test_async_update_support(hass): """Test async update getting called.""" sync_update = [] From ed66c21035b8110a3a3f6a58b171941d36c7c74b Mon Sep 17 00:00:00 2001 From: cgtobi Date: Fri, 12 Jan 2018 00:42:51 +0100 Subject: [PATCH 222/238] Cast return values and add unit tests for the yahoo weather component. (#10699) * Add unittest for yahoo weather component. * Add requitements for tests. * Update requirements. * Revert "Update requirements." This reverts commit 024b5cf8bf0d1bdfe927a117f5befb53c5a5cff4. * Update requirements. * Randomize sample data. * Remove unnecessary methods. * Remove dependency and replace with MockDependency. * Add temporary fix for yahoo weather API issue. --- .coveragerc | 1 - homeassistant/components/weather/yweather.py | 11 +- tests/components/weather/test_yweather.py | 169 +++++++++++++++++++ 3 files changed, 175 insertions(+), 6 deletions(-) create mode 100644 tests/components/weather/test_yweather.py diff --git a/.coveragerc b/.coveragerc index d0026245d03..a264bde79a2 100644 --- a/.coveragerc +++ b/.coveragerc @@ -666,7 +666,6 @@ omit = homeassistant/components/weather/darksky.py homeassistant/components/weather/metoffice.py homeassistant/components/weather/openweathermap.py - homeassistant/components/weather/yweather.py homeassistant/components/weather/zamg.py homeassistant/components/zeroconf.py homeassistant/components/zwave/util.py diff --git a/homeassistant/components/weather/yweather.py b/homeassistant/components/weather/yweather.py index 0ef0aba2d1b..20ed97ec249 100644 --- a/homeassistant/components/weather/yweather.py +++ b/homeassistant/components/weather/yweather.py @@ -125,27 +125,28 @@ class YahooWeatherWeather(WeatherEntity): @property def pressure(self): """Return the pressure.""" - return self._data.yahoo.Atmosphere['pressure'] + return round(float(self._data.yahoo.Atmosphere['pressure'])/33.8637526, + 2) @property def humidity(self): """Return the humidity.""" - return self._data.yahoo.Atmosphere['humidity'] + return int(self._data.yahoo.Atmosphere['humidity']) @property def visibility(self): """Return the visibility.""" - return self._data.yahoo.Atmosphere['visibility'] + return round(float(self._data.yahoo.Atmosphere['visibility'])/1.61, 2) @property def wind_speed(self): """Return the wind speed.""" - return self._data.yahoo.Wind['speed'] + return round(float(self._data.yahoo.Wind['speed'])/1.61, 2) @property def wind_bearing(self): """Return the wind direction.""" - return self._data.yahoo.Wind['direction'] + return int(self._data.yahoo.Wind['direction']) @property def attribution(self): diff --git a/tests/components/weather/test_yweather.py b/tests/components/weather/test_yweather.py new file mode 100644 index 00000000000..3e5eff9dae7 --- /dev/null +++ b/tests/components/weather/test_yweather.py @@ -0,0 +1,169 @@ +"""The tests for the Yahoo weather component.""" +import json + +import unittest +from unittest.mock import patch + +from homeassistant.components.weather import ( + ATTR_WEATHER_HUMIDITY, ATTR_WEATHER_PRESSURE, ATTR_WEATHER_TEMPERATURE, + ATTR_WEATHER_WIND_BEARING, ATTR_WEATHER_WIND_SPEED) +from homeassistant.util.unit_system import METRIC_SYSTEM +from homeassistant.setup import setup_component + +from tests.common import (get_test_home_assistant, load_fixture, + MockDependency) + + +def _yql_queryMock(yql): # pylint: disable=invalid-name + """Mock yahoo query language query.""" + return ('{"query": {"count": 1, "created": "2017-11-17T13:40:47Z", ' + '"lang": "en-US", "results": {"place": {"woeid": "23511632"}}}}') + + +def get_woeidMock(lat, lon): # pylint: disable=invalid-name + """Mock get woeid Where On Earth Identifiers.""" + return '23511632' + + +class YahooWeatherMock(): + """Mock class for the YahooWeather object.""" + + def __init__(self, woeid, temp_unit): + """Initialize Telnet object.""" + self.woeid = woeid + self.temp_unit = temp_unit + self._data = json.loads(load_fixture('yahooweather.json')) + + # pylint: disable=no-self-use + def updateWeather(self): # pylint: disable=invalid-name + """Return sample values.""" + return True + + @property + def RawData(self): # pylint: disable=invalid-name + """Raw Data.""" + if self.woeid == '12345': + return json.loads('[]') + return self._data + + @property + def Now(self): # pylint: disable=invalid-name + """Current weather data.""" + if self.woeid == '111': + raise ValueError + return self._data['query']['results']['channel']['item']['condition'] + + @property + def Atmosphere(self): # pylint: disable=invalid-name + """Atmosphere weather data.""" + return self._data['query']['results']['channel']['atmosphere'] + + @property + def Wind(self): # pylint: disable=invalid-name + """Wind weather data.""" + return self._data['query']['results']['channel']['wind'] + + @property + def Forecast(self): # pylint: disable=invalid-name + """Forecast data 0-5 Days.""" + if self.woeid == '123123': + raise ValueError + return self._data['query']['results']['channel']['item']['forecast'] + + +class TestWeather(unittest.TestCase): + """Test the Yahoo weather component.""" + + DEVICES = [] + + def add_devices(self, devices): + """Mock add devices.""" + for device in devices: + device.update() + self.DEVICES.append(device) + + def setUp(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.hass.config.units = METRIC_SYSTEM + + def tearDown(self): + """Stop down everything that was started.""" + self.hass.stop() + + @MockDependency('yahooweather') + @patch('yahooweather._yql_query', new=_yql_queryMock) + @patch('yahooweather.get_woeid', new=get_woeidMock) + @patch('yahooweather.YahooWeather', new=YahooWeatherMock) + def test_setup(self, mock_yahooweather): + """Test for typical weather data attributes.""" + self.assertTrue( + setup_component(self.hass, 'weather', { + 'weather': { + 'platform': 'yweather', + } + })) + + state = self.hass.states.get('weather.yweather') + assert state is not None + + assert state.state == 'cloudy' + + data = state.attributes + self.assertEqual(data.get(ATTR_WEATHER_TEMPERATURE), 18.0) + self.assertEqual(data.get(ATTR_WEATHER_HUMIDITY), 71) + self.assertEqual(data.get(ATTR_WEATHER_PRESSURE), 1000.0) + self.assertEqual(data.get(ATTR_WEATHER_WIND_SPEED), 3.94) + self.assertEqual(data.get(ATTR_WEATHER_WIND_BEARING), 0) + self.assertEqual(state.attributes.get('friendly_name'), 'Yweather') + + @MockDependency('yahooweather') + @patch('yahooweather._yql_query', new=_yql_queryMock) + @patch('yahooweather.get_woeid', new=get_woeidMock) + @patch('yahooweather.YahooWeather', new=YahooWeatherMock) + def test_setup_no_data(self, mock_yahooweather): + """Test for note receiving data.""" + self.assertTrue( + setup_component(self.hass, 'weather', { + 'weather': { + 'platform': 'yweather', + 'woeid': '12345', + } + })) + + state = self.hass.states.get('weather.yweather') + assert state is not None + + @MockDependency('yahooweather') + @patch('yahooweather._yql_query', new=_yql_queryMock) + @patch('yahooweather.get_woeid', new=get_woeidMock) + @patch('yahooweather.YahooWeather', new=YahooWeatherMock) + def test_setup_bad_data(self, mock_yahooweather): + """Test for bad forecast data.""" + self.assertTrue( + setup_component(self.hass, 'weather', { + 'weather': { + 'platform': 'yweather', + 'woeid': '123123', + } + })) + + state = self.hass.states.get('weather.yweather') + assert state is None + + @MockDependency('yahooweather') + @patch('yahooweather._yql_query', new=_yql_queryMock) + @patch('yahooweather.get_woeid', new=get_woeidMock) + @patch('yahooweather.YahooWeather', new=YahooWeatherMock) + def test_setup_condition_error(self, mock_yahooweather): + """Test for bad forecast data.""" + self.assertTrue( + setup_component(self.hass, 'weather', { + 'weather': { + 'platform': 'yweather', + 'woeid': '111', + } + })) + + state = self.hass.states.get('weather.yweather') + assert state is None From 2224d056076f56c591bec95528d929c275ed399b Mon Sep 17 00:00:00 2001 From: Thijs de Jong Date: Fri, 12 Jan 2018 00:43:31 +0100 Subject: [PATCH 223/238] add velux roller shutter to tahoma (#11586) With #11538 I forgot to add another type of Roller shutters that should be supported. --- homeassistant/components/tahoma.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/tahoma.py b/homeassistant/components/tahoma.py index c2453d75493..aebe1e0d88e 100644 --- a/homeassistant/components/tahoma.py +++ b/homeassistant/components/tahoma.py @@ -40,6 +40,7 @@ TAHOMA_TYPES = { 'rts:RollerShutterRTSComponent': 'cover', 'rts:CurtainRTSComponent': 'cover', 'io:RollerShutterWithLowSpeedManagementIOComponent': 'cover', + 'io:RollerShutterVeluxIOComponent': 'cover', 'io:WindowOpenerVeluxIOComponent': 'cover', 'io:LightIOSystemSensor': 'sensor', } From 965bd8a2e06a67753d79e751229ec6d4fc681daa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Bj=C3=B6rshammar?= Date: Fri, 12 Jan 2018 00:44:23 +0100 Subject: [PATCH 224/238] Added support for enable/disable motion detection (#11583) * Added support for enable/disable motion detection * Changed name of variable for exception, lint error * Moved motion_detection_enabled property to the other properties --- homeassistant/components/camera/uvc.py | 30 ++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/homeassistant/components/camera/uvc.py b/homeassistant/components/camera/uvc.py index 685b6d64364..8d79fa04a9a 100644 --- a/homeassistant/components/camera/uvc.py +++ b/homeassistant/components/camera/uvc.py @@ -82,6 +82,7 @@ class UnifiVideoCamera(Camera): self.is_streaming = False self._connect_addr = None self._camera = None + self._motion_status = False @property def name(self): @@ -94,6 +95,12 @@ class UnifiVideoCamera(Camera): caminfo = self._nvr.get_camera(self._uuid) return caminfo['recordingSettings']['fullTimeRecordEnabled'] + @property + def motion_detection_enabled(self): + """Camera Motion Detection Status.""" + caminfo = self._nvr.get_camera(self._uuid) + return caminfo['recordingSettings']['motionRecordEnabled'] + @property def brand(self): """Return the brand of this camera.""" @@ -165,3 +172,26 @@ class UnifiVideoCamera(Camera): raise return _get_image() + + def set_motion_detection(self, mode): + """Set motion detection on or off.""" + from uvcclient.nvr import NvrError + if mode is True: + set_mode = 'motion' + else: + set_mode = 'none' + + try: + self._nvr.set_recordmode(self._uuid, set_mode) + self._motion_status = mode + except NvrError as err: + _LOGGER.error("Unable to set recordmode to " + set_mode) + _LOGGER.debug(err) + + def enable_motion_detection(self): + """Enable motion detection in camera.""" + self.set_motion_detection(True) + + def disable_motion_detection(self): + """Disable motion detection in camera.""" + self.set_motion_detection(False) From 4883036789603064b1301ef6ea918d1fc27b79c5 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 11 Jan 2018 15:50:50 -0800 Subject: [PATCH 225/238] Update frontend --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 6d58ceebfd1..7d19ed46cd9 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -23,7 +23,7 @@ from homeassistant.const import CONF_NAME, EVENT_THEMES_UPDATED from homeassistant.core import callback from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20180110.0', 'user-agents==1.1.0'] +REQUIREMENTS = ['home-assistant-frontend==20180112.0', 'user-agents==1.1.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] diff --git a/requirements_all.txt b/requirements_all.txt index c5345a5a698..813e4e4dcf1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -349,7 +349,7 @@ hipnotify==1.0.8 holidays==0.8.1 # homeassistant.components.frontend -home-assistant-frontend==20180110.0 +home-assistant-frontend==20180112.0 # homeassistant.components.camera.onvif http://github.com/tgaugry/suds-passworddigest-py3/archive/86fc50e39b4d2b8997481967d6a7fe1c57118999.zip#suds-passworddigest-py3==0.1.2a diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 58d7634fe86..300f5496a16 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -75,7 +75,7 @@ hbmqtt==0.9.1 holidays==0.8.1 # homeassistant.components.frontend -home-assistant-frontend==20180110.0 +home-assistant-frontend==20180112.0 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb From 033e06868ec9a54619616879a6c50c122cf48aaf Mon Sep 17 00:00:00 2001 From: Jack Fan Date: Thu, 11 Jan 2018 23:14:37 -0500 Subject: [PATCH 226/238] Avoid returning empty media_image_url string (#11557) This relates to issue https://github.com/home-assistant/home-assistant/issues/11556 --- homeassistant/components/media_player/cast.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/media_player/cast.py b/homeassistant/components/media_player/cast.py index 4cf8f72f074..2aaff646885 100644 --- a/homeassistant/components/media_player/cast.py +++ b/homeassistant/components/media_player/cast.py @@ -186,7 +186,7 @@ class CastDevice(MediaPlayerDevice): images = self.media_status.images - return images[0].url if images else None + return images[0].url if images and images[0].url else None @property def media_title(self): From f2cc00cc647f0bc429f17742a29a1de0105deb9f Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 12 Jan 2018 15:29:58 +0100 Subject: [PATCH 227/238] Core support for hass.io calls & Bugfix check_config (#11571) * Initial overwrites * Add check_config function. * Update hassio.py * Address comments * add hassio support * add more tests * revert core changes * Address check_config * Address comment with api_bool * Bugfix check_config * Update core.py * Update test_core.py * Update config.py * Update hassio.py * Update config.py * Update test_config.py --- homeassistant/components/hassio.py | 86 ++++++++++++++++++++++++++--- homeassistant/components/updater.py | 7 +++ homeassistant/config.py | 18 ++++-- tests/components/test_hassio.py | 58 ++++++++++++++++++- tests/components/test_updater.py | 23 +++++++- tests/test_config.py | 4 +- 6 files changed, 179 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/hassio.py b/homeassistant/components/hassio.py index 8bd1b11cf0d..cc6db5fbab3 100644 --- a/homeassistant/components/hassio.py +++ b/homeassistant/components/hassio.py @@ -17,13 +17,16 @@ from aiohttp.hdrs import CONTENT_TYPE import async_timeout import voluptuous as vol -import homeassistant.helpers.config_validation as cv +from homeassistant.core import callback, DOMAIN as HASS_DOMAIN from homeassistant.const import ( - CONTENT_TYPE_TEXT_PLAIN, SERVER_PORT, CONF_TIME_ZONE) + CONTENT_TYPE_TEXT_PLAIN, SERVER_PORT, CONF_TIME_ZONE, + SERVICE_HOMEASSISTANT_STOP, SERVICE_HOMEASSISTANT_RESTART) +from homeassistant.components import SERVICE_CHECK_CONFIG from homeassistant.components.http import ( HomeAssistantView, KEY_AUTHENTICATED, CONF_API_PASSWORD, CONF_SERVER_PORT, CONF_SERVER_HOST, CONF_SSL_CERTIFICATE) from homeassistant.loader import bind_hass +import homeassistant.helpers.config_validation as cv from homeassistant.util.dt import utcnow _LOGGER = logging.getLogger(__name__) @@ -34,7 +37,7 @@ DEPENDENCIES = ['http'] X_HASSIO = 'X-HASSIO-KEY' DATA_HOMEASSISTANT_VERSION = 'hassio_hass_version' -HASSIO_UPDATE_INTERVAL = timedelta(hours=1) +HASSIO_UPDATE_INTERVAL = timedelta(minutes=55) SERVICE_ADDON_START = 'addon_start' SERVICE_ADDON_STOP = 'addon_stop' @@ -120,12 +123,40 @@ MAP_SERVICE_API = { } +@callback @bind_hass def get_homeassistant_version(hass): - """Return last available HomeAssistant version.""" + """Return latest available HomeAssistant version. + + Async friendly. + """ return hass.data.get(DATA_HOMEASSISTANT_VERSION) +@callback +@bind_hass +def is_hassio(hass): + """Return True if hass.io is loaded. + + Async friendly. + """ + return DOMAIN in hass.config.components + + +@bind_hass +@asyncio.coroutine +def async_check_config(hass): + """Check config over Hass.io API.""" + result = yield from hass.data[DOMAIN].send_command( + '/homeassistant/check', timeout=300) + + if not result: + return "Hass.io config check API error" + elif result['result'] == "error": + return result['message'] + return None + + @asyncio.coroutine def async_setup(hass, config): """Set up the HASSio component.""" @@ -136,7 +167,7 @@ def async_setup(hass, config): return False websession = hass.helpers.aiohttp_client.async_get_clientsession() - hassio = HassIO(hass.loop, websession, host) + hass.data[DOMAIN] = hassio = HassIO(hass.loop, websession, host) if not (yield from hassio.is_connected()): _LOGGER.error("Not connected with HassIO!") @@ -170,11 +201,14 @@ def async_setup(hass, config): payload = data # Call API - yield from hassio.send_command( + ret = yield from hassio.send_command( api_command.format(addon=addon, snapshot=snapshot), payload=payload, timeout=MAP_SERVICE_API[service.service][2] ) + if not ret or ret['result'] != "ok": + _LOGGER.error("Error on Hass.io API: %s", ret['message']) + for service, settings in MAP_SERVICE_API.items(): hass.services.async_register( DOMAIN, service, async_service_handler, schema=settings[1]) @@ -193,9 +227,44 @@ def async_setup(hass, config): # Fetch last version yield from update_homeassistant_version(None) + @asyncio.coroutine + def async_handle_core_service(call): + """Service handler for handling core services.""" + if call.service == SERVICE_HOMEASSISTANT_STOP: + yield from hassio.send_command('/homeassistant/stop') + return + + error = yield from async_check_config(hass) + if error: + _LOGGER.error(error) + hass.components.persistent_notification.async_create( + "Config error. See dev-info panel for details.", + "Config validating", "{0}.check_config".format(HASS_DOMAIN)) + return + + if call.service == SERVICE_HOMEASSISTANT_RESTART: + yield from hassio.send_command('/homeassistant/restart') + + # Mock core services + for service in (SERVICE_HOMEASSISTANT_STOP, SERVICE_HOMEASSISTANT_RESTART, + SERVICE_CHECK_CONFIG): + hass.services.async_register( + HASS_DOMAIN, service, async_handle_core_service) + return True +def _api_bool(funct): + """API wrapper to return Boolean.""" + @asyncio.coroutine + def _wrapper(*argv, **kwargs): + """Wrapper function.""" + data = yield from funct(*argv, **kwargs) + return data and data['result'] == "ok" + + return _wrapper + + class HassIO(object): """Small API wrapper for HassIO.""" @@ -205,6 +274,7 @@ class HassIO(object): self.websession = websession self._ip = ip + @_api_bool def is_connected(self): """Return True if it connected to HassIO supervisor. @@ -219,6 +289,7 @@ class HassIO(object): """ return self.send_command("/homeassistant/info", method="get") + @_api_bool def update_hass_api(self, http_config): """Update Home-Assistant API data on HassIO. @@ -238,6 +309,7 @@ class HassIO(object): return self.send_command("/homeassistant/options", payload=options) + @_api_bool def update_hass_timezone(self, core_config): """Update Home-Assistant timezone data on HassIO. @@ -261,7 +333,7 @@ class HassIO(object): X_HASSIO: os.environ.get('HASSIO_TOKEN') }) - if request.status != 200: + if request.status not in (200, 400): _LOGGER.error( "%s return code %d.", command, request.status) return None diff --git a/homeassistant/components/updater.py b/homeassistant/components/updater.py index f1f5b7dd1fd..f7bf9774e42 100644 --- a/homeassistant/components/updater.py +++ b/homeassistant/components/updater.py @@ -97,9 +97,15 @@ def async_setup(hass, config): newest, releasenotes = result + # Skip on dev if newest is None or 'dev' in current_version: return + # Load data from supervisor on hass.io + if hass.components.hassio.is_hassio(): + newest = hass.components.hassio.get_homeassistant_version() + + # Validate version if StrictVersion(newest) > StrictVersion(current_version): _LOGGER.info("The latest available version is %s", newest) hass.states.async_set( @@ -131,6 +137,7 @@ def get_system_info(hass, include_components): 'timezone': dt_util.DEFAULT_TIME_ZONE.zone, 'version': current_version, 'virtualenv': os.environ.get('VIRTUAL_ENV') is not None, + 'hassio': hass.components.hassio.is_hassio(), } if include_components: diff --git a/homeassistant/config.py b/homeassistant/config.py index fee7572a2c2..3f4c4c174d7 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -33,6 +33,8 @@ from homeassistant.helpers import config_per_platform, extract_domain_configs _LOGGER = logging.getLogger(__name__) DATA_PERSISTENT_ERRORS = 'bootstrap_persistent_errors' +RE_YAML_ERROR = re.compile(r"homeassistant\.util\.yaml") +RE_ASCII = re.compile(r"\033\[[^m]*m") HA_COMPONENT_URL = '[{}](https://home-assistant.io/components/{}/)' YAML_CONFIG_FILE = 'configuration.yaml' VERSION_FILE = '.HA_VERSION' @@ -655,15 +657,19 @@ def async_check_ha_config_file(hass): proc = yield from asyncio.create_subprocess_exec( sys.executable, '-m', 'homeassistant', '--script', 'check_config', '--config', hass.config.config_dir, - stdout=asyncio.subprocess.PIPE, loop=hass.loop) + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, loop=hass.loop) + # Wait for the subprocess exit - stdout_data, dummy = yield from proc.communicate() - result = yield from proc.wait() + log, _ = yield from proc.communicate() + exit_code = yield from proc.wait() - if not result: - return None + # Convert to ASCII + log = RE_ASCII.sub('', log.decode()) - return re.sub(r'\033\[[^m]*m', '', str(stdout_data, 'utf-8')) + if exit_code != 0 or RE_YAML_ERROR.search(log): + return log + return None @callback diff --git a/tests/components/test_hassio.py b/tests/components/test_hassio.py index b6be6f5a6a1..48443658fc4 100644 --- a/tests/components/test_hassio.py +++ b/tests/components/test_hassio.py @@ -7,6 +7,7 @@ import pytest from homeassistant.const import HTTP_HEADER_HA_AUTH from homeassistant.setup import async_setup_component +from homeassistant.components.hassio import async_check_config from tests.common import mock_coro @@ -60,6 +61,8 @@ def test_fail_setup_cannot_connect(hass): result = yield from async_setup_component(hass, 'hassio', {}) assert not result + assert not hass.components.hassio.is_hassio() + @asyncio.coroutine def test_setup_api_ping(hass, aioclient_mock): @@ -75,7 +78,8 @@ def test_setup_api_ping(hass, aioclient_mock): assert result assert aioclient_mock.call_count == 2 - assert hass.data['hassio_hass_version'] == "10.0" + assert hass.components.hassio.get_homeassistant_version() == "10.0" + assert hass.components.hassio.is_hassio() @asyncio.coroutine @@ -215,6 +219,7 @@ def test_service_register(hassio_env, hass): assert hass.services.has_service('hassio', 'addon_stdin') assert hass.services.has_service('hassio', 'host_shutdown') assert hass.services.has_service('hassio', 'host_reboot') + assert hass.services.has_service('hassio', 'host_reboot') assert hass.services.has_service('hassio', 'snapshot_full') assert hass.services.has_service('hassio', 'snapshot_partial') assert hass.services.has_service('hassio', 'restore_full') @@ -294,6 +299,57 @@ def test_service_calls(hassio_env, hass, aioclient_mock): 'addons': ['test'], 'folders': ['ssl'], 'homeassistant': False} +@asyncio.coroutine +def test_service_calls_core(hassio_env, hass, aioclient_mock): + """Call core service and check the API calls behind that.""" + assert (yield from async_setup_component(hass, 'hassio', {})) + + aioclient_mock.post( + "http://127.0.0.1/homeassistant/restart", json={'result': 'ok'}) + aioclient_mock.post( + "http://127.0.0.1/homeassistant/stop", json={'result': 'ok'}) + aioclient_mock.post( + "http://127.0.0.1/homeassistant/check", json={'result': 'ok'}) + + yield from hass.services.async_call('homeassistant', 'stop') + yield from hass.async_block_till_done() + + assert aioclient_mock.call_count == 1 + + yield from hass.services.async_call('homeassistant', 'check_config') + yield from hass.async_block_till_done() + + assert aioclient_mock.call_count == 2 + + yield from hass.services.async_call('homeassistant', 'restart') + yield from hass.async_block_till_done() + + assert aioclient_mock.call_count == 4 + + +@asyncio.coroutine +def test_check_config_ok(hassio_env, hass, aioclient_mock): + """Check Config that is okay.""" + assert (yield from async_setup_component(hass, 'hassio', {})) + + aioclient_mock.post( + "http://127.0.0.1/homeassistant/check", json={'result': 'ok'}) + + assert (yield from async_check_config(hass)) is None + + +@asyncio.coroutine +def test_check_config_fail(hassio_env, hass, aioclient_mock): + """Check Config that is wrong.""" + assert (yield from async_setup_component(hass, 'hassio', {})) + + aioclient_mock.post( + "http://127.0.0.1/homeassistant/check", json={ + 'result': 'error', 'message': "Error"}) + + assert (yield from async_check_config(hass)) == "Error" + + @asyncio.coroutine def test_forward_request(hassio_client): """Test fetching normal path.""" diff --git a/tests/components/test_updater.py b/tests/components/test_updater.py index 6d68add93a5..28ffcac2b13 100644 --- a/tests/components/test_updater.py +++ b/tests/components/test_updater.py @@ -8,7 +8,7 @@ import pytest from homeassistant.setup import async_setup_component from homeassistant.components import updater import homeassistant.util.dt as dt_util -from tests.common import async_fire_time_changed, mock_coro +from tests.common import async_fire_time_changed, mock_coro, mock_component NEW_VERSION = '10000.0' MOCK_VERSION = '10.0' @@ -174,3 +174,24 @@ def test_error_fetching_new_version_invalid_response(hass, aioclient_mock): Mock(return_value=mock_coro({'fake': 'bla'}))): res = yield from updater.get_newest_version(hass, MOCK_HUUID, False) assert res is None + + +@asyncio.coroutine +def test_new_version_shows_entity_after_hour_hassio( + hass, mock_get_uuid, mock_get_newest_version): + """Test if new entity is created if new version is available / hass.io.""" + mock_get_uuid.return_value = MOCK_HUUID + mock_get_newest_version.return_value = mock_coro((NEW_VERSION, '')) + mock_component(hass, 'hassio') + hass.data['hassio_hass_version'] = "999.0" + + res = yield from async_setup_component( + hass, updater.DOMAIN, {updater.DOMAIN: {}}) + assert res, 'Updater failed to setup' + + with patch('homeassistant.components.updater.current_version', + MOCK_VERSION): + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(hours=1)) + yield from hass.async_block_till_done() + + assert hass.states.is_state(updater.ENTITY_ID, "999.0") diff --git a/tests/test_config.py b/tests/test_config.py index 2c8edc32f82..377c650e91f 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -531,7 +531,7 @@ class TestConfig(unittest.TestCase): """Check that restart propagates to stop.""" process_mock = mock.MagicMock() attrs = { - 'communicate.return_value': mock_coro(('output', 'error')), + 'communicate.return_value': mock_coro((b'output', None)), 'wait.return_value': mock_coro(0)} process_mock.configure_mock(**attrs) mock_create.return_value = mock_coro(process_mock) @@ -546,7 +546,7 @@ class TestConfig(unittest.TestCase): process_mock = mock.MagicMock() attrs = { 'communicate.return_value': - mock_coro(('\033[34mhello'.encode('utf-8'), 'error')), + mock_coro(('\033[34mhello'.encode('utf-8'), None)), 'wait.return_value': mock_coro(1)} process_mock.configure_mock(**attrs) mock_create.return_value = mock_coro(process_mock) From 1802c0a922670084d398de05fa9461b0c4c7309f Mon Sep 17 00:00:00 2001 From: Thijs de Jong Date: Fri, 12 Jan 2018 16:04:44 +0100 Subject: [PATCH 228/238] Fix Tahoma stop command for 2 types of shutters (#11588) * add working stop command This fixes the stop command for 2 types of roller shutters * fix line too long * fix indentation * fix indentation --- homeassistant/components/cover/tahoma.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/cover/tahoma.py b/homeassistant/components/cover/tahoma.py index d492ad50866..7ec09c781d2 100644 --- a/homeassistant/components/cover/tahoma.py +++ b/homeassistant/components/cover/tahoma.py @@ -15,6 +15,11 @@ DEPENDENCIES = ['tahoma'] _LOGGER = logging.getLogger(__name__) +TAHOMA_STOP_COMMAND = { + 'io:RollerShutterWithLowSpeedManagementIOComponent': 'my', + 'io:RollerShutterVeluxIOComponent': 'my', +} + SCAN_INTERVAL = timedelta(seconds=60) @@ -73,7 +78,8 @@ class TahomaCover(TahomaDevice, CoverDevice): def stop_cover(self, **kwargs): """Stop the cover.""" - self.apply_action('stopIdentify') + self.apply_action(TAHOMA_STOP_COMMAND.get(self.tahoma_device.type, + 'stopIdentify')) def device_class(self): """Return the class of this device, from component DEVICE_CLASSES.""" From 92bec562aba89ac5388561c3e2f2c0be94a10c6a Mon Sep 17 00:00:00 2001 From: tschmidty69 Date: Fri, 12 Jan 2018 14:06:42 -0500 Subject: [PATCH 229/238] Pushbullet email support (fix) (#11590) * Simplified push calls * Cleaned up and added unittests * Fixed email parameter * Fixed email parameter --- homeassistant/components/notify/pushbullet.py | 29 ++++++------- tests/components/notify/test_pushbullet.py | 42 +++++++++++++++++++ 2 files changed, 57 insertions(+), 14 deletions(-) create mode 100644 tests/components/notify/test_pushbullet.py diff --git a/homeassistant/components/notify/pushbullet.py b/homeassistant/components/notify/pushbullet.py index 0e846ebaf84..359810bb6bc 100644 --- a/homeassistant/components/notify/pushbullet.py +++ b/homeassistant/components/notify/pushbullet.py @@ -22,6 +22,7 @@ _LOGGER = logging.getLogger(__name__) ATTR_URL = 'url' ATTR_FILE = 'file' ATTR_FILE_URL = 'file_url' +ATTR_LIST = 'list' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_API_KEY): cv.string, @@ -99,7 +100,7 @@ class PushBulletNotificationService(BaseNotificationService): continue # Target is email, send directly, don't use a target object. - # This also seems works to send to all devices in own account. + # This also seems to work to send to all devices in own account. if ttype == 'email': self._push_data(message, title, data, self.pushbullet, tname) _LOGGER.info("Sent notification to email %s", tname) @@ -127,20 +128,18 @@ class PushBulletNotificationService(BaseNotificationService): _LOGGER.error("No such target: %s/%s", ttype, tname) continue - def _push_data(self, message, title, data, pusher, tname=None): + def _push_data(self, message, title, data, pusher, email=None): """Helper for creating the message content.""" from pushbullet import PushError if data is None: data = {} + data_list = data.get(ATTR_LIST) url = data.get(ATTR_URL) filepath = data.get(ATTR_FILE) file_url = data.get(ATTR_FILE_URL) try: if url: - if tname: - pusher.push_link(title, url, body=message, email=tname) - else: - pusher.push_link(title, url, body=message) + pusher.push_link(title, url, body=message, email=email) elif filepath: if not self.hass.config.is_allowed_path(filepath): _LOGGER.error("Filepath is not valid or allowed") @@ -150,18 +149,20 @@ class PushBulletNotificationService(BaseNotificationService): if filedata.get('file_type') == 'application/x-empty': _LOGGER.error("Can not send an empty file") return - pusher.push_file(title=title, body=message, **filedata) + + pusher.push_file(title=title, body=message, + email=email, **filedata) elif file_url: if not file_url.startswith('http'): _LOGGER.error("URL should start with http or https") return - pusher.push_file(title=title, body=message, file_name=file_url, - file_url=file_url, - file_type=mimetypes.guess_type(file_url)[0]) + pusher.push_file(title=title, body=message, email=email, + file_name=file_url, file_url=file_url, + file_type=(mimetypes + .guess_type(file_url)[0])) + elif data_list: + pusher.push_note(title, data_list, email=email) else: - if tname: - pusher.push_note(title, message, email=tname) - else: - pusher.push_note(title, message) + pusher.push_note(title, message, email=email) except PushError as err: _LOGGER.error("Notify failed: %s", err) diff --git a/tests/components/notify/test_pushbullet.py b/tests/components/notify/test_pushbullet.py new file mode 100644 index 00000000000..ba3046e8fd7 --- /dev/null +++ b/tests/components/notify/test_pushbullet.py @@ -0,0 +1,42 @@ +"""The tests for the pushbullet notification platform.""" + +import unittest + +from homeassistant.setup import setup_component +import homeassistant.components.notify as notify +from tests.common import assert_setup_component, get_test_home_assistant + + +class TestPushbullet(unittest.TestCase): + """Test the pushbullet notifications.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + def tearDown(self): # pylint: disable=invalid-name + """Stop down everything that was started.""" + self.hass.stop() + + def test_setup(self): + """Test setup.""" + with assert_setup_component(1) as handle_config: + assert setup_component(self.hass, 'notify', { + 'notify': { + 'name': 'test', + 'platform': 'pushbullet', + 'api_key': 'MYFAKEKEY', } + }) + assert handle_config[notify.DOMAIN] + + def test_bad_config(self): + """Test set up the platform with bad/missing configuration.""" + config = { + notify.DOMAIN: { + 'name': 'test', + 'platform': 'pushbullet', + } + } + with assert_setup_component(0) as handle_config: + assert setup_component(self.hass, notify.DOMAIN, config) + assert not handle_config[notify.DOMAIN] From b8dfa4c3d27ca7e5ccb9a04eab18b40b869c780b Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Fri, 12 Jan 2018 01:06:09 -0500 Subject: [PATCH 230/238] Fix state for trigger with forced updates (#11595) --- homeassistant/components/automation/state.py | 2 +- tests/components/automation/test_state.py | 34 ++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/automation/state.py b/homeassistant/components/automation/state.py index e4d096d35fd..9243f960850 100644 --- a/homeassistant/components/automation/state.py +++ b/homeassistant/components/automation/state.py @@ -55,7 +55,7 @@ def async_trigger(hass, config, action): # Ignore changes to state attributes if from/to is in use if (not match_all and from_s is not None and to_s is not None and - from_s.last_changed == to_s.last_changed): + from_s.state == to_s.state): return if not time_delta: diff --git a/tests/components/automation/test_state.py b/tests/components/automation/test_state.py index b1ee0841e2d..bf54d24492a 100644 --- a/tests/components/automation/test_state.py +++ b/tests/components/automation/test_state.py @@ -409,6 +409,40 @@ class TestAutomationState(unittest.TestCase): self.hass.block_till_done() self.assertEqual(1, len(self.calls)) + def test_if_fires_on_entity_change_with_for_multiple_force_update(self): + """Test for firing on entity change with for and force update.""" + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'state', + 'entity_id': 'test.force_entity', + 'to': 'world', + 'for': { + 'seconds': 5 + }, + }, + 'action': { + 'service': 'test.automation' + } + } + }) + + utcnow = dt_util.utcnow() + with patch('homeassistant.core.dt_util.utcnow') as mock_utcnow: + mock_utcnow.return_value = utcnow + self.hass.states.set('test.force_entity', 'world', None, True) + self.hass.block_till_done() + for _ in range(0, 4): + mock_utcnow.return_value += timedelta(seconds=1) + fire_time_changed(self.hass, mock_utcnow.return_value) + self.hass.states.set('test.force_entity', 'world', None, True) + self.hass.block_till_done() + self.assertEqual(0, len(self.calls)) + mock_utcnow.return_value += timedelta(seconds=4) + fire_time_changed(self.hass, mock_utcnow.return_value) + self.hass.block_till_done() + self.assertEqual(1, len(self.calls)) + def test_if_fires_on_entity_change_with_for(self): """Test for firing on entity change with for.""" assert setup_component(self.hass, automation.DOMAIN, { From 179d99381d334a77ca52f00b5a1794c6d68c1533 Mon Sep 17 00:00:00 2001 From: tschmidty69 Date: Fri, 12 Jan 2018 13:19:43 -0500 Subject: [PATCH 231/238] Snips add say and say_actions services (new) (#11596) * Added snips.say and snips.say_action services * Added snips.say and snips.say_action services * Merged services.yaml changes I missed * added tests for new service configs * Woof * Woof Woof * Changed attribute names to follow hass standards. * updated test_snips with new attribute names --- homeassistant/components/services.yaml | 32 ++++++++++++ homeassistant/components/snips.py | 59 ++++++++++++++++++++++ tests/components/test_snips.py | 68 +++++++++++++++++++++++++- 3 files changed, 158 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/services.yaml b/homeassistant/components/services.yaml index 03c1d24184a..522939a213a 100644 --- a/homeassistant/components/services.yaml +++ b/homeassistant/components/services.yaml @@ -364,6 +364,38 @@ abode: description: Entity id of the quick action to trigger. example: 'binary_sensor.home_quick_action' +snips: + say: + description: Send a TTS message to Snips. + fields: + text: + description: Text to say. + example: My name is snips + site_id: + description: Site to use to start session, defaults to default (optional) + example: bedroom + custom_data: + description: custom data that will be included with all messages in this session + example: user=UserName + say_action: + description: Send a TTS message to Snips to listen for a response. + fields: + text: + description: Text to say + example: My name is snips + site_id: + description: Site to use to start session, defaults to default (optional) + example: bedroom + custom_data: + description: custom data that will be included with all messages in this session + example: user=UserName + can_be_enqueued: + description: If True, session waits for an open session to end, if False session is dropped if one is running + example: True + intent_filter: + description: Optional Array of Strings - A list of intents names to restrict the NLU resolution to on the first query. + example: turnOnLights, turnOffLights + input_boolean: toggle: description: Toggles an input boolean. diff --git a/homeassistant/components/snips.py b/homeassistant/components/snips.py index ae387f7ab4c..d221c8512c6 100644 --- a/homeassistant/components/snips.py +++ b/homeassistant/components/snips.py @@ -8,17 +8,29 @@ import asyncio import json import logging from datetime import timedelta + import voluptuous as vol + from homeassistant.helpers import intent, config_validation as cv import homeassistant.components.mqtt as mqtt DOMAIN = 'snips' DEPENDENCIES = ['mqtt'] + CONF_INTENTS = 'intents' CONF_ACTION = 'action' +SERVICE_SAY = 'say' +SERVICE_SAY_ACTION = 'say_action' + INTENT_TOPIC = 'hermes/intent/#' +ATTR_TEXT = 'text' +ATTR_SITE_ID = 'site_id' +ATTR_CUSTOM_DATA = 'custom_data' +ATTR_CAN_BE_ENQUEUED = 'can_be_enqueued' +ATTR_INTENT_FILTER = 'intent_filter' + _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = vol.Schema({ @@ -40,6 +52,20 @@ INTENT_SCHEMA = vol.Schema({ }] }, extra=vol.ALLOW_EXTRA) +SERVICE_SCHEMA_SAY = vol.Schema({ + vol.Required(ATTR_TEXT): str, + vol.Optional(ATTR_SITE_ID, default='default'): str, + vol.Optional(ATTR_CUSTOM_DATA, default=''): str +}) + +SERVICE_SCHEMA_SAY_ACTION = vol.Schema({ + vol.Required(ATTR_TEXT): str, + vol.Optional(ATTR_SITE_ID, default='default'): str, + vol.Optional(ATTR_CUSTOM_DATA, default=''): str, + vol.Optional(ATTR_CAN_BE_ENQUEUED, default=True): cv.boolean, + vol.Optional(ATTR_INTENT_FILTER): vol.All(cv.ensure_list), +}) + @asyncio.coroutine def async_setup(hass, config): @@ -93,6 +119,39 @@ def async_setup(hass, config): yield from hass.components.mqtt.async_subscribe( INTENT_TOPIC, message_received) + @asyncio.coroutine + def snips_say(call): + """Send a Snips notification message.""" + notification = {'siteId': call.data.get(ATTR_SITE_ID, 'default'), + 'customData': call.data.get(ATTR_CUSTOM_DATA, ''), + 'init': {'type': 'notification', + 'text': call.data.get(ATTR_TEXT)}} + mqtt.async_publish(hass, 'hermes/dialogueManager/startSession', + json.dumps(notification)) + return + + @asyncio.coroutine + def snips_say_action(call): + """Send a Snips action message.""" + notification = {'siteId': call.data.get(ATTR_SITE_ID, 'default'), + 'customData': call.data.get(ATTR_CUSTOM_DATA, ''), + 'init': {'type': 'action', + 'text': call.data.get(ATTR_TEXT), + 'canBeEnqueued': call.data.get( + ATTR_CAN_BE_ENQUEUED, True), + 'intentFilter': + call.data.get(ATTR_INTENT_FILTER, [])}} + mqtt.async_publish(hass, 'hermes/dialogueManager/startSession', + json.dumps(notification)) + return + + hass.services.async_register( + DOMAIN, SERVICE_SAY, snips_say, + schema=SERVICE_SCHEMA_SAY) + hass.services.async_register( + DOMAIN, SERVICE_SAY_ACTION, snips_say_action, + schema=SERVICE_SCHEMA_SAY_ACTION) + return True diff --git a/tests/components/test_snips.py b/tests/components/test_snips.py index 9ee500bb4c7..711d13dc341 100644 --- a/tests/components/test_snips.py +++ b/tests/components/test_snips.py @@ -4,7 +4,10 @@ import json from homeassistant.core import callback from homeassistant.bootstrap import async_setup_component -from tests.common import async_fire_mqtt_message, async_mock_intent +from tests.common import (async_fire_mqtt_message, async_mock_intent, + async_mock_service) +from homeassistant.components.snips import (SERVICE_SCHEMA_SAY, + SERVICE_SCHEMA_SAY_ACTION) @asyncio.coroutine @@ -238,3 +241,66 @@ def test_snips_intent_username(hass, mqtt_mock): intent = intents[0] assert intent.platform == 'snips' assert intent.intent_type == 'Lights' + + +@asyncio.coroutine +def test_snips_say(hass, caplog): + """Test snips say with invalid config.""" + calls = async_mock_service(hass, 'snips', 'say', + SERVICE_SCHEMA_SAY) + + data = {'text': 'Hello'} + yield from hass.services.async_call('snips', 'say', data) + yield from hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].domain == 'snips' + assert calls[0].service == 'say' + assert calls[0].data['text'] == 'Hello' + + +@asyncio.coroutine +def test_snips_say_action(hass, caplog): + """Test snips say_action with invalid config.""" + calls = async_mock_service(hass, 'snips', 'say_action', + SERVICE_SCHEMA_SAY_ACTION) + + data = {'text': 'Hello', 'intent_filter': ['myIntent']} + yield from hass.services.async_call('snips', 'say_action', data) + yield from hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].domain == 'snips' + assert calls[0].service == 'say_action' + assert calls[0].data['text'] == 'Hello' + assert calls[0].data['intent_filter'] == ['myIntent'] + + +@asyncio.coroutine +def test_snips_say_invalid_config(hass, caplog): + """Test snips say with invalid config.""" + calls = async_mock_service(hass, 'snips', 'say', + SERVICE_SCHEMA_SAY) + + data = {'text': 'Hello', 'badKey': 'boo'} + yield from hass.services.async_call('snips', 'say', data) + yield from hass.async_block_till_done() + + assert len(calls) == 0 + assert 'ERROR' in caplog.text + assert 'Invalid service data' in caplog.text + + +@asyncio.coroutine +def test_snips_say_action_invalid_config(hass, caplog): + """Test snips say_action with invalid config.""" + calls = async_mock_service(hass, 'snips', 'say_action', + SERVICE_SCHEMA_SAY_ACTION) + + data = {'text': 'Hello', 'can_be_enqueued': 'notabool'} + yield from hass.services.async_call('snips', 'say_action', data) + yield from hass.async_block_till_done() + + assert len(calls) == 0 + assert 'ERROR' in caplog.text + assert 'Invalid service data' in caplog.text From 8d19192c9ca0dc8b17fea842454c262a104c3694 Mon Sep 17 00:00:00 2001 From: Bob Anderson Date: Thu, 11 Jan 2018 23:45:01 -0800 Subject: [PATCH 232/238] Concord232 alarm arm away fix (#11597) * fix arming away cmd for concord232 client * bump required version of concord232 to 0.15 --- homeassistant/components/alarm_control_panel/concord232.py | 4 ++-- homeassistant/components/binary_sensor/concord232.py | 2 +- requirements_all.txt | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/concord232.py b/homeassistant/components/alarm_control_panel/concord232.py index 291d4bc80b5..af91bc78e67 100644 --- a/homeassistant/components/alarm_control_panel/concord232.py +++ b/homeassistant/components/alarm_control_panel/concord232.py @@ -18,7 +18,7 @@ from homeassistant.const import ( STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, STATE_UNKNOWN) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['concord232==0.14'] +REQUIREMENTS = ['concord232==0.15'] _LOGGER = logging.getLogger(__name__) @@ -121,4 +121,4 @@ class Concord232Alarm(alarm.AlarmControlPanel): def alarm_arm_away(self, code=None): """Send arm away command.""" - self._alarm.arm('auto') + self._alarm.arm('away') diff --git a/homeassistant/components/binary_sensor/concord232.py b/homeassistant/components/binary_sensor/concord232.py index 73cf77f2b93..c8442491b29 100644 --- a/homeassistant/components/binary_sensor/concord232.py +++ b/homeassistant/components/binary_sensor/concord232.py @@ -15,7 +15,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.const import (CONF_HOST, CONF_PORT) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['concord232==0.14'] +REQUIREMENTS = ['concord232==0.15'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 813e4e4dcf1..31da31d56a9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -177,7 +177,7 @@ colorlog==3.0.1 # homeassistant.components.alarm_control_panel.concord232 # homeassistant.components.binary_sensor.concord232 -concord232==0.14 +concord232==0.15 # homeassistant.scripts.credstash # credstash==1.14.0 From 218e97d965964613351903ad0a9efc6601792ea7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Fri, 12 Jan 2018 20:52:53 +0100 Subject: [PATCH 233/238] Bugfix and cleanup for Rfxtrx (#11600) * rfxtrx clean up * rfxtrx clean up * rfxtrx clean up --- .../components/binary_sensor/rfxtrx.py | 76 +++++---- homeassistant/components/cover/rfxtrx.py | 21 ++- homeassistant/components/light/rfxtrx.py | 23 ++- homeassistant/components/rfxtrx.py | 156 +++++------------- homeassistant/components/sensor/rfxtrx.py | 25 ++- homeassistant/components/switch/rfxtrx.py | 21 ++- requirements_all.txt | 2 +- 7 files changed, 156 insertions(+), 168 deletions(-) diff --git a/homeassistant/components/binary_sensor/rfxtrx.py b/homeassistant/components/binary_sensor/rfxtrx.py index edaee574232..4073cb9eac1 100644 --- a/homeassistant/components/binary_sensor/rfxtrx.py +++ b/homeassistant/components/binary_sensor/rfxtrx.py @@ -7,30 +7,40 @@ tested. Other types may need some work. """ import logging + import voluptuous as vol + +from homeassistant.const import ( + CONF_DEVICE_CLASS, CONF_COMMAND_ON, CONF_COMMAND_OFF, CONF_NAME) from homeassistant.components import rfxtrx +from homeassistant.helpers import event as evt +from homeassistant.helpers import config_validation as cv +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, PLATFORM_SCHEMA) +from homeassistant.components.rfxtrx import ( + ATTR_NAME, ATTR_DATA_BITS, ATTR_OFF_DELAY, ATTR_FIRE_EVENT, + CONF_AUTOMATIC_ADD, CONF_FIRE_EVENT, + CONF_DATA_BITS, CONF_DEVICES) from homeassistant.util import slugify from homeassistant.util import dt as dt_util -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers import event as evt -from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.components.rfxtrx import ( - ATTR_AUTOMATIC_ADD, ATTR_NAME, ATTR_OFF_DELAY, ATTR_FIREEVENT, - ATTR_DATA_BITS, CONF_DEVICES -) -from homeassistant.const import ( - CONF_DEVICE_CLASS, CONF_COMMAND_ON, CONF_COMMAND_OFF -) + DEPENDENCIES = ["rfxtrx"] _LOGGER = logging.getLogger(__name__) -PLATFORM_SCHEMA = vol.Schema({ - vol.Required("platform"): rfxtrx.DOMAIN, - vol.Optional(CONF_DEVICES, default={}): vol.All( - dict, rfxtrx.valid_binary_sensor), - vol.Optional(ATTR_AUTOMATIC_ADD, default=False): cv.boolean, +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_DEVICES, default={}): { + cv.string: vol.Schema({ + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_DEVICE_CLASS): cv.string, + vol.Optional(CONF_FIRE_EVENT, default=False): cv.boolean, + vol.Optional(CONF_DATA_BITS): cv.positive_int, + vol.Optional(CONF_COMMAND_ON): cv.byte, + vol.Optional(CONF_COMMAND_OFF): cv.byte + }) + }, + vol.Optional(CONF_AUTOMATIC_ADD, default=False): cv.boolean, }, extra=vol.ALLOW_EXTRA) @@ -46,17 +56,17 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): if device_id in rfxtrx.RFX_DEVICES: continue - if entity[ATTR_DATA_BITS] is not None: - _LOGGER.info("Masked device id: %s", - rfxtrx.get_pt2262_deviceid(device_id, - entity[ATTR_DATA_BITS])) + if entity[CONF_DATA_BITS] is not None: + _LOGGER.debug("Masked device id: %s", + rfxtrx.get_pt2262_deviceid(device_id, + entity[ATTR_DATA_BITS])) - _LOGGER.info("Add %s rfxtrx.binary_sensor (class %s)", - entity[ATTR_NAME], entity[CONF_DEVICE_CLASS]) + _LOGGER.debug("Add %s rfxtrx.binary_sensor (class %s)", + entity[ATTR_NAME], entity[CONF_DEVICE_CLASS]) device = RfxtrxBinarySensor(event, entity[ATTR_NAME], entity[CONF_DEVICE_CLASS], - entity[ATTR_FIREEVENT], + entity[ATTR_FIRE_EVENT], entity[ATTR_OFF_DELAY], entity[ATTR_DATA_BITS], entity[CONF_COMMAND_ON], @@ -82,15 +92,15 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): if sensor is None: # Add the entity if not exists and automatic_add is True - if not config[ATTR_AUTOMATIC_ADD]: + if not config[CONF_AUTOMATIC_ADD]: return if event.device.packettype == 0x13: poss_dev = rfxtrx.find_possible_pt2262_device(device_id) if poss_dev is not None: poss_id = slugify(poss_dev.event.device.id_string.lower()) - _LOGGER.info("Found possible matching deviceid %s.", - poss_id) + _LOGGER.debug("Found possible matching deviceid %s.", + poss_id) pkt_id = "".join("{0:02x}".format(x) for x in event.data) sensor = RfxtrxBinarySensor(event, pkt_id) @@ -107,11 +117,11 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): elif not isinstance(sensor, RfxtrxBinarySensor): return else: - _LOGGER.info("Binary sensor update " - "(Device_id: %s Class: %s Sub: %s)", - slugify(event.device.id_string.lower()), - event.device.__class__.__name__, - event.device.subtype) + _LOGGER.debug("Binary sensor update " + "(Device_id: %s Class: %s Sub: %s)", + slugify(event.device.id_string.lower()), + event.device.__class__.__name__, + event.device.subtype) if sensor.is_lighting4: if sensor.data_bits is not None: @@ -163,10 +173,8 @@ class RfxtrxBinarySensor(BinarySensorDevice): self._masked_id = rfxtrx.get_pt2262_deviceid( event.device.id_string.lower(), data_bits) - - def __str__(self): - """Return the name of the sensor.""" - return self._name + else: + self._masked_id = None @property def name(self): diff --git a/homeassistant/components/cover/rfxtrx.py b/homeassistant/components/cover/rfxtrx.py index 0e28d3ef701..66f2fde52f4 100644 --- a/homeassistant/components/cover/rfxtrx.py +++ b/homeassistant/components/cover/rfxtrx.py @@ -4,12 +4,29 @@ Support for RFXtrx cover components. For more details about this platform, please refer to the documentation https://home-assistant.io/components/cover.rfxtrx/ """ +import voluptuous as vol + import homeassistant.components.rfxtrx as rfxtrx -from homeassistant.components.cover import CoverDevice +from homeassistant.components.cover import CoverDevice, PLATFORM_SCHEMA +from homeassistant.const import CONF_NAME +from homeassistant.components.rfxtrx import ( + CONF_AUTOMATIC_ADD, CONF_FIRE_EVENT, DEFAULT_SIGNAL_REPETITIONS, + CONF_SIGNAL_REPETITIONS, CONF_DEVICES) +from homeassistant.helpers import config_validation as cv DEPENDENCIES = ['rfxtrx'] -PLATFORM_SCHEMA = rfxtrx.DEFAULT_SCHEMA +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_DEVICES, default={}): { + cv.string: vol.Schema({ + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_FIRE_EVENT, default=False): cv.boolean + }) + }, + vol.Optional(CONF_AUTOMATIC_ADD, default=False): cv.boolean, + vol.Optional(CONF_SIGNAL_REPETITIONS, default=DEFAULT_SIGNAL_REPETITIONS): + vol.Coerce(int), +}) def setup_platform(hass, config, add_devices_callback, discovery_info=None): diff --git a/homeassistant/components/light/rfxtrx.py b/homeassistant/components/light/rfxtrx.py index 9248b0131f1..cdfe2fe5671 100644 --- a/homeassistant/components/light/rfxtrx.py +++ b/homeassistant/components/light/rfxtrx.py @@ -6,15 +6,32 @@ https://home-assistant.io/components/light.rfxtrx/ """ import logging +import voluptuous as vol + import homeassistant.components.rfxtrx as rfxtrx -from homeassistant.components.light import (ATTR_BRIGHTNESS, - SUPPORT_BRIGHTNESS, Light) +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light, PLATFORM_SCHEMA) +from homeassistant.const import CONF_NAME +from homeassistant.components.rfxtrx import ( + CONF_AUTOMATIC_ADD, CONF_FIRE_EVENT, DEFAULT_SIGNAL_REPETITIONS, + CONF_SIGNAL_REPETITIONS, CONF_DEVICES) +from homeassistant.helpers import config_validation as cv DEPENDENCIES = ['rfxtrx'] _LOGGER = logging.getLogger(__name__) -PLATFORM_SCHEMA = rfxtrx.DEFAULT_SCHEMA +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_DEVICES, default={}): { + cv.string: vol.Schema({ + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_FIRE_EVENT, default=False): cv.boolean + }) + }, + vol.Optional(CONF_AUTOMATIC_ADD, default=False): cv.boolean, + vol.Optional(CONF_SIGNAL_REPETITIONS, default=DEFAULT_SIGNAL_REPETITIONS): + vol.Coerce(int), +}) SUPPORT_RFXTRX = SUPPORT_BRIGHTNESS diff --git a/homeassistant/components/rfxtrx.py b/homeassistant/components/rfxtrx.py index 8b730bf97f2..f28a9aafb19 100644 --- a/homeassistant/components/rfxtrx.py +++ b/homeassistant/components/rfxtrx.py @@ -16,11 +16,11 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, ATTR_ENTITY_ID, TEMP_CELSIUS, - CONF_DEVICE_CLASS, CONF_COMMAND_ON, CONF_COMMAND_OFF + CONF_DEVICES ) from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pyRFXtrx==0.20.1'] +REQUIREMENTS = ['pyRFXtrx==0.21.1'] DOMAIN = 'rfxtrx' @@ -31,13 +31,19 @@ ATTR_DEVICE = 'device' ATTR_DEBUG = 'debug' ATTR_STATE = 'state' ATTR_NAME = 'name' -ATTR_FIREEVENT = 'fire_event' +ATTR_FIRE_EVENT = 'fire_event' ATTR_DATA_TYPE = 'data_type' ATTR_DATA_BITS = 'data_bits' ATTR_DUMMY = 'dummy' ATTR_OFF_DELAY = 'off_delay' +CONF_AUTOMATIC_ADD = 'automatic_add' +CONF_DATA_TYPE = 'data_type' CONF_SIGNAL_REPETITIONS = 'signal_repetitions' -CONF_DEVICES = 'devices' +CONF_FIRE_EVENT = 'fire_event' +CONF_DATA_BITS = 'data_bits' +CONF_DUMMY = 'dummy' +CONF_DEVICE = 'device' +CONF_DEBUG = 'debug' EVENT_BUTTON_PRESSED = 'button_pressed' DATA_TYPES = OrderedDict([ @@ -57,93 +63,13 @@ DATA_TYPES = OrderedDict([ RECEIVED_EVT_SUBSCRIBERS = [] RFX_DEVICES = {} _LOGGER = logging.getLogger(__name__) -RFXOBJECT = 'rfxobject' - - -def _valid_device(value, device_type): - """Validate a dictionary of devices definitions.""" - config = OrderedDict() - for key, device in value.items(): - - # Still accept old configuration - if 'packetid' in device.keys(): - msg = 'You are using an outdated configuration of the rfxtrx ' +\ - 'device, {}.'.format(key) +\ - ' Your new config should be:\n {}: \n name: {}'\ - .format(device.get('packetid'), - device.get(ATTR_NAME, 'deivce_name')) - _LOGGER.warning(msg) - key = device.get('packetid') - device.pop('packetid') - - key = str(key) - if not len(key) % 2 == 0: - key = '0' + key - - if device_type == 'sensor': - config[key] = DEVICE_SCHEMA_SENSOR(device) - elif device_type == 'binary_sensor': - config[key] = DEVICE_SCHEMA_BINARYSENSOR(device) - elif device_type == 'light_switch': - config[key] = DEVICE_SCHEMA(device) - else: - raise vol.Invalid('Rfxtrx device is invalid') - - if not config[key][ATTR_NAME]: - config[key][ATTR_NAME] = key - return config - - -def valid_sensor(value): - """Validate sensor configuration.""" - return _valid_device(value, "sensor") - - -def valid_binary_sensor(value): - """Validate binary sensor configuration.""" - return _valid_device(value, "binary_sensor") - - -def _valid_light_switch(value): - return _valid_device(value, "light_switch") - - -DEVICE_SCHEMA = vol.Schema({ - vol.Required(ATTR_NAME): cv.string, - vol.Optional(ATTR_FIREEVENT, default=False): cv.boolean, -}) - -DEVICE_SCHEMA_SENSOR = vol.Schema({ - vol.Optional(ATTR_NAME, default=None): cv.string, - vol.Optional(ATTR_FIREEVENT, default=False): cv.boolean, - vol.Optional(ATTR_DATA_TYPE, default=[]): - vol.All(cv.ensure_list, [vol.In(DATA_TYPES.keys())]), -}) - -DEVICE_SCHEMA_BINARYSENSOR = vol.Schema({ - vol.Optional(ATTR_NAME, default=None): cv.string, - vol.Optional(CONF_DEVICE_CLASS, default=None): cv.string, - vol.Optional(ATTR_FIREEVENT, default=False): cv.boolean, - vol.Optional(ATTR_OFF_DELAY, default=None): - vol.Any(cv.time_period, cv.positive_timedelta), - vol.Optional(ATTR_DATA_BITS, default=None): cv.positive_int, - vol.Optional(CONF_COMMAND_ON, default=None): cv.byte, - vol.Optional(CONF_COMMAND_OFF, default=None): cv.byte -}) - -DEFAULT_SCHEMA = vol.Schema({ - vol.Required("platform"): DOMAIN, - vol.Optional(CONF_DEVICES, default={}): vol.All(dict, _valid_light_switch), - vol.Optional(ATTR_AUTOMATIC_ADD, default=False): cv.boolean, - vol.Optional(CONF_SIGNAL_REPETITIONS, default=DEFAULT_SIGNAL_REPETITIONS): - vol.Coerce(int), -}) +DATA_RFXOBJECT = 'rfxobject' CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Required(ATTR_DEVICE): cv.string, - vol.Optional(ATTR_DEBUG, default=False): cv.boolean, - vol.Optional(ATTR_DUMMY, default=False): cv.boolean, + vol.Required(CONF_DEVICE): cv.string, + vol.Optional(CONF_DEBUG, default=False): cv.boolean, + vol.Optional(CONF_DUMMY, default=False): cv.boolean, }), }, extra=vol.ALLOW_EXTRA) @@ -152,7 +78,7 @@ def setup(hass, config): """Set up the RFXtrx component.""" # Declare the Handle event def handle_receive(event): - """Handle revieved messgaes from RFXtrx gateway.""" + """Handle revieved messages from RFXtrx gateway.""" # Log RFXCOM event if not event.device.id_string: return @@ -175,21 +101,22 @@ def setup(hass, config): dummy_connection = config[DOMAIN][ATTR_DUMMY] if dummy_connection: - hass.data[RFXOBJECT] =\ - rfxtrxmod.Connect(device, None, debug=debug, - transport_protocol=rfxtrxmod.DummyTransport2) + rfx_object = rfxtrxmod.Connect( + device, None, debug=debug, + transport_protocol=rfxtrxmod.DummyTransport2) else: - hass.data[RFXOBJECT] = rfxtrxmod.Connect(device, None, debug=debug) + rfx_object = rfxtrxmod.Connect(device, None, debug=debug) def _start_rfxtrx(event): - hass.data[RFXOBJECT].event_callback = handle_receive + rfx_object.event_callback = handle_receive hass.bus.listen_once(EVENT_HOMEASSISTANT_START, _start_rfxtrx) def _shutdown_rfxtrx(event): """Close connection with RFXtrx.""" - hass.data[RFXOBJECT].close_connection() + rfx_object.close_connection() hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown_rfxtrx) + hass.data[DATA_RFXOBJECT] = rfx_object return True @@ -248,9 +175,9 @@ def get_pt2262_device(device_id): if (hasattr(device, 'is_lighting4') and device.masked_id == get_pt2262_deviceid(device_id, device.data_bits)): - _LOGGER.info("rfxtrx: found matching device %s for %s", - device_id, - device.masked_id) + _LOGGER.debug("rfxtrx: found matching device %s for %s", + device_id, + device.masked_id) return device return None @@ -295,11 +222,11 @@ def get_devices_from_config(config, device): device_id = slugify(event.device.id_string.lower()) if device_id in RFX_DEVICES: continue - _LOGGER.info("Add %s rfxtrx", entity_info[ATTR_NAME]) + _LOGGER.debug("Add %s rfxtrx", entity_info[ATTR_NAME]) # Check if i must fire event - fire_event = entity_info[ATTR_FIREEVENT] - datas = {ATTR_STATE: False, ATTR_FIREEVENT: fire_event} + fire_event = entity_info[ATTR_FIRE_EVENT] + datas = {ATTR_STATE: False, ATTR_FIRE_EVENT: fire_event} new_device = device(entity_info[ATTR_NAME], event, datas, signal_repetitions) @@ -318,14 +245,14 @@ def get_new_device(event, config, device): return pkt_id = "".join("{0:02x}".format(x) for x in event.data) - _LOGGER.info( + _LOGGER.debug( "Automatic add %s rfxtrx device (Class: %s Sub: %s Packet_id: %s)", device_id, event.device.__class__.__name__, event.device.subtype, pkt_id ) - datas = {ATTR_STATE: False, ATTR_FIREEVENT: False} + datas = {ATTR_STATE: False, ATTR_FIRE_EVENT: False} signal_repetitions = config[CONF_SIGNAL_REPETITIONS] new_device = device(pkt_id, event, datas, signal_repetitions) @@ -370,7 +297,7 @@ def apply_received_command(event): ATTR_STATE: event.values['Command'].lower() } ) - _LOGGER.info( + _LOGGER.debug( "Rfxtrx fired event: (event_type: %s, %s: %s, %s: %s)", EVENT_BUTTON_PRESSED, ATTR_ENTITY_ID, @@ -392,7 +319,7 @@ class RfxtrxDevice(Entity): self._name = name self._event = event self._state = datas[ATTR_STATE] - self._should_fire_event = datas[ATTR_FIREEVENT] + self._should_fire_event = datas[ATTR_FIRE_EVENT] self._brightness = 0 self.added_to_hass = False @@ -440,40 +367,35 @@ class RfxtrxDevice(Entity): def _send_command(self, command, brightness=0): if not self._event: return + rfx_object = self.hass.data[DATA_RFXOBJECT] if command == "turn_on": for _ in range(self.signal_repetitions): - self._event.device.send_on(self.hass.data[RFXOBJECT] - .transport) + self._event.device.send_on(rfx_object.transport) self._state = True elif command == "dim": for _ in range(self.signal_repetitions): - self._event.device.send_dim(self.hass.data[RFXOBJECT] - .transport, brightness) + self._event.device.send_dim(rfx_object.transport, brightness) self._state = True elif command == 'turn_off': for _ in range(self.signal_repetitions): - self._event.device.send_off(self.hass.data[RFXOBJECT] - .transport) + self._event.device.send_off(rfx_object.transport) self._state = False self._brightness = 0 elif command == "roll_up": for _ in range(self.signal_repetitions): - self._event.device.send_open(self.hass.data[RFXOBJECT] - .transport) + self._event.device.send_open(rfx_object.transport) elif command == "roll_down": for _ in range(self.signal_repetitions): - self._event.device.send_close(self.hass.data[RFXOBJECT] - .transport) + self._event.device.send_close(rfx_object.transport) elif command == "stop_roll": for _ in range(self.signal_repetitions): - self._event.device.send_stop(self.hass.data[RFXOBJECT] - .transport) + self._event.device.send_stop(rfx_object.transport) if self.added_to_hass: self.schedule_update_ha_state() diff --git a/homeassistant/components/sensor/rfxtrx.py b/homeassistant/components/sensor/rfxtrx.py index e01dbc83422..1c09bc01909 100644 --- a/homeassistant/components/sensor/rfxtrx.py +++ b/homeassistant/components/sensor/rfxtrx.py @@ -10,21 +10,28 @@ import voluptuous as vol import homeassistant.components.rfxtrx as rfxtrx import homeassistant.helpers.config_validation as cv -from homeassistant.const import CONF_PLATFORM +from homeassistant.const import ATTR_ENTITY_ID, CONF_NAME from homeassistant.helpers.entity import Entity from homeassistant.util import slugify +from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.components.rfxtrx import ( - ATTR_AUTOMATIC_ADD, ATTR_NAME, ATTR_FIREEVENT, CONF_DEVICES, DATA_TYPES, - ATTR_DATA_TYPE, ATTR_ENTITY_ID) + ATTR_NAME, ATTR_FIRE_EVENT, ATTR_DATA_TYPE, CONF_AUTOMATIC_ADD, + CONF_FIRE_EVENT, CONF_DEVICES, DATA_TYPES, CONF_DATA_TYPE) DEPENDENCIES = ['rfxtrx'] _LOGGER = logging.getLogger(__name__) -PLATFORM_SCHEMA = vol.Schema({ - vol.Required(CONF_PLATFORM): rfxtrx.DOMAIN, - vol.Optional(CONF_DEVICES, default={}): vol.All(dict, rfxtrx.valid_sensor), - vol.Optional(ATTR_AUTOMATIC_ADD, default=False): cv.boolean, +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_DEVICES, default={}): { + cv.string: vol.Schema({ + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_FIRE_EVENT, default=False): cv.boolean, + vol.Optional(CONF_DATA_TYPE, default=[]): + vol.All(cv.ensure_list, [vol.In(DATA_TYPES.keys())]), + }) + }, + vol.Optional(CONF_AUTOMATIC_ADD, default=False): cv.boolean, }, extra=vol.ALLOW_EXTRA) @@ -49,7 +56,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): break for _data_type in data_types: new_sensor = RfxtrxSensor(None, entity_info[ATTR_NAME], - _data_type, entity_info[ATTR_FIREEVENT]) + _data_type, entity_info[ATTR_FIRE_EVENT]) sensors.append(new_sensor) sub_sensors[_data_type] = new_sensor rfxtrx.RFX_DEVICES[device_id] = sub_sensors @@ -78,7 +85,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): return # Add entity if not exist and the automatic_add is True - if not config[ATTR_AUTOMATIC_ADD]: + if not config[CONF_AUTOMATIC_ADD]: return pkt_id = "".join("{0:02x}".format(x) for x in event.data) diff --git a/homeassistant/components/switch/rfxtrx.py b/homeassistant/components/switch/rfxtrx.py index 1361d22de18..7dd1d25ad94 100644 --- a/homeassistant/components/switch/rfxtrx.py +++ b/homeassistant/components/switch/rfxtrx.py @@ -6,14 +6,31 @@ https://home-assistant.io/components/switch.rfxtrx/ """ import logging +import voluptuous as vol + import homeassistant.components.rfxtrx as rfxtrx -from homeassistant.components.switch import SwitchDevice +from homeassistant.components.switch import SwitchDevice, PLATFORM_SCHEMA +from homeassistant.const import CONF_NAME +from homeassistant.components.rfxtrx import ( + CONF_AUTOMATIC_ADD, CONF_FIRE_EVENT, DEFAULT_SIGNAL_REPETITIONS, + CONF_SIGNAL_REPETITIONS, CONF_DEVICES) +from homeassistant.helpers import config_validation as cv DEPENDENCIES = ['rfxtrx'] _LOGGER = logging.getLogger(__name__) -PLATFORM_SCHEMA = rfxtrx.DEFAULT_SCHEMA +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_DEVICES, default={}): { + cv.string: vol.Schema({ + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_FIRE_EVENT, default=False): cv.boolean + }) + }, + vol.Optional(CONF_AUTOMATIC_ADD, default=False): cv.boolean, + vol.Optional(CONF_SIGNAL_REPETITIONS, default=DEFAULT_SIGNAL_REPETITIONS): + vol.Coerce(int), +}) def setup_platform(hass, config, add_devices_callback, discovery_info=None): diff --git a/requirements_all.txt b/requirements_all.txt index 31da31d56a9..b17c22a5f4f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -622,7 +622,7 @@ pyCEC==0.4.13 pyHS100==0.3.0 # homeassistant.components.rfxtrx -pyRFXtrx==0.20.1 +pyRFXtrx==0.21.1 # homeassistant.components.sensor.tibber pyTibber==0.2.1 From 3afa4726bf09852f5a13e36f6b1b4e2d21d8a723 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Fri, 12 Jan 2018 22:35:32 +0100 Subject: [PATCH 234/238] Xiaomi lib upgrade (#11603) * upgrade xiaomi lib * xiaomi lib --- homeassistant/components/xiaomi_aqara.py | 6 +++--- requirements_all.txt | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/xiaomi_aqara.py b/homeassistant/components/xiaomi_aqara.py index 678ead981c1..e059d3d8772 100644 --- a/homeassistant/components/xiaomi_aqara.py +++ b/homeassistant/components/xiaomi_aqara.py @@ -9,7 +9,7 @@ from homeassistant.components.discovery import SERVICE_XIAOMI_GW from homeassistant.const import (ATTR_BATTERY_LEVEL, EVENT_HOMEASSISTANT_STOP, CONF_MAC, CONF_HOST, CONF_PORT) -REQUIREMENTS = ['PyXiaomiGateway==0.6.0'] +REQUIREMENTS = ['PyXiaomiGateway==0.7.0'] ATTR_GW_MAC = 'gw_mac' ATTR_RINGTONE_ID = 'ringtone_id' @@ -105,8 +105,8 @@ def setup(hass, config): discovery.listen(hass, SERVICE_XIAOMI_GW, xiaomi_gw_discovered) - from PyXiaomiGateway import PyXiaomiGateway - xiaomi = hass.data[PY_XIAOMI_GATEWAY] = PyXiaomiGateway( + from xiaomi_gateway import XiaomiGatewayDiscovery + xiaomi = hass.data[PY_XIAOMI_GATEWAY] = XiaomiGatewayDiscovery( hass.add_job, gateways, interface) _LOGGER.debug("Expecting %s gateways", len(gateways)) diff --git a/requirements_all.txt b/requirements_all.txt index b17c22a5f4f..d06c1b4a539 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -35,7 +35,7 @@ PyMVGLive==1.1.4 PyMata==2.14 # homeassistant.components.xiaomi_aqara -PyXiaomiGateway==0.6.0 +PyXiaomiGateway==0.7.0 # homeassistant.components.rpi_gpio # RPi.GPIO==0.6.1 From 7a50450b922fd313be7dc048fb15a0a61bd89cac Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sat, 13 Jan 2018 09:01:05 +0100 Subject: [PATCH 235/238] Upgrade yarl to 0.18.0 (#11609) --- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 8ec1c648c35..243c6d418df 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -6,7 +6,7 @@ jinja2>=2.9.6 voluptuous==0.10.5 typing>=3,<4 aiohttp==2.3.7 -yarl==0.17.0 +yarl==0.18.0 async_timeout==2.0.0 chardet==3.0.4 astral==1.4 diff --git a/requirements_all.txt b/requirements_all.txt index d06c1b4a539..c949847ec86 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -7,7 +7,7 @@ jinja2>=2.9.6 voluptuous==0.10.5 typing>=3,<4 aiohttp==2.3.7 -yarl==0.17.0 +yarl==0.18.0 async_timeout==2.0.0 chardet==3.0.4 astral==1.4 diff --git a/setup.py b/setup.py index 0d7c746d564..4b19e47fb2c 100755 --- a/setup.py +++ b/setup.py @@ -54,7 +54,7 @@ REQUIRES = [ 'voluptuous==0.10.5', 'typing>=3,<4', 'aiohttp==2.3.7', # If updated, check if yarl also needs an update! - 'yarl==0.17.0', + 'yarl==0.18.0', 'async_timeout==2.0.0', 'chardet==3.0.4', 'astral==1.4', From d88edb0661b375c35ade21984c7ec70a9e2e37b7 Mon Sep 17 00:00:00 2001 From: Thijs de Jong Date: Sat, 13 Jan 2018 09:06:37 +0100 Subject: [PATCH 236/238] patch stop command (#11612) --- homeassistant/components/cover/tahoma.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/cover/tahoma.py b/homeassistant/components/cover/tahoma.py index 7ec09c781d2..9968e3d6503 100644 --- a/homeassistant/components/cover/tahoma.py +++ b/homeassistant/components/cover/tahoma.py @@ -15,11 +15,6 @@ DEPENDENCIES = ['tahoma'] _LOGGER = logging.getLogger(__name__) -TAHOMA_STOP_COMMAND = { - 'io:RollerShutterWithLowSpeedManagementIOComponent': 'my', - 'io:RollerShutterVeluxIOComponent': 'my', -} - SCAN_INTERVAL = timedelta(seconds=60) @@ -78,8 +73,11 @@ class TahomaCover(TahomaDevice, CoverDevice): def stop_cover(self, **kwargs): """Stop the cover.""" - self.apply_action(TAHOMA_STOP_COMMAND.get(self.tahoma_device.type, - 'stopIdentify')) + if self.tahoma_device.type == \ + 'io:RollerShutterWithLowSpeedManagementIOComponent': + self.apply_action('setPosition', 'secured') + else: + self.apply_action('stopIdentify') def device_class(self): """Return the class of this device, from component DEVICE_CLASSES.""" From 7837b4893ff98e5782a57b7bfd6176ed88f1af63 Mon Sep 17 00:00:00 2001 From: Jesse Hills Date: Sun, 14 Jan 2018 08:07:39 +1300 Subject: [PATCH 237/238] Use kelvin/mireds correctly for setting iglo white (#11622) * Use kelvin/mireds correctly for setting iglo white * Update requirements_all.txt * Fix line lengths --- homeassistant/components/light/iglo.py | 19 ++++++++++++------- requirements_all.txt | 2 +- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/light/iglo.py b/homeassistant/components/light/iglo.py index eaf783b13ca..11366ffc45c 100644 --- a/homeassistant/components/light/iglo.py +++ b/homeassistant/components/light/iglo.py @@ -5,6 +5,7 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.iglo/ """ import logging +import math import voluptuous as vol @@ -16,8 +17,9 @@ from homeassistant.components.light import ( ) import homeassistant.helpers.config_validation as cv +import homeassistant.util.color as color_util -REQUIREMENTS = ['iglo==1.0.0'] +REQUIREMENTS = ['iglo==1.1.3'] _LOGGER = logging.getLogger(__name__) @@ -65,17 +67,19 @@ class IGloLamp(Light): @property def color_temp(self): """Return the color temperature.""" - return self._color_temp + return color_util.color_temperature_kelvin_to_mired(self._color_temp) @property def min_mireds(self): """Return the coldest color_temp that this light supports.""" - return 1 + return math.ceil(color_util.color_temperature_kelvin_to_mired( + self._lamp.max_kelvin)) @property def max_mireds(self): """Return the warmest color_temp that this light supports.""" - return 255 + return math.ceil(color_util.color_temperature_kelvin_to_mired( + self._lamp.min_kelvin)) @property def rgb_color(self): @@ -107,8 +111,9 @@ class IGloLamp(Light): return if ATTR_COLOR_TEMP in kwargs: - color_temp = 255 - kwargs[ATTR_COLOR_TEMP] - self._lamp.white(color_temp) + kelvin = int(color_util.color_temperature_mired_to_kelvin( + kwargs[ATTR_COLOR_TEMP])) + self._lamp.white(kelvin) return def turn_off(self, **kwargs): @@ -121,4 +126,4 @@ class IGloLamp(Light): self._on = state['on'] self._brightness = state['brightness'] self._rgb = state['rgb'] - self._color_temp = 255 - state['white'] + self._color_temp = state['white'] diff --git a/requirements_all.txt b/requirements_all.txt index c949847ec86..2ef38c37aa7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -396,7 +396,7 @@ https://github.com/wokar/pylgnetcast/archive/v0.2.0.zip#pylgnetcast==0.2.0 # i2csense==0.0.4 # homeassistant.components.light.iglo -iglo==1.0.0 +iglo==1.1.3 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb From 9b15cfa5a5fd113dc855e21f8b09aa9ae7eadc51 Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Sat, 13 Jan 2018 14:00:04 -0500 Subject: [PATCH 238/238] Update Pyarlo to 0.1.2 (#11626) --- homeassistant/components/arlo.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/arlo.py b/homeassistant/components/arlo.py index a78b334de0b..a928ed108c9 100644 --- a/homeassistant/components/arlo.py +++ b/homeassistant/components/arlo.py @@ -12,7 +12,7 @@ from requests.exceptions import HTTPError, ConnectTimeout from homeassistant.helpers import config_validation as cv from homeassistant.const import CONF_USERNAME, CONF_PASSWORD -REQUIREMENTS = ['pyarlo==0.1.0'] +REQUIREMENTS = ['pyarlo==0.1.2'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 2ef38c37aa7..820d3894a39 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -640,7 +640,7 @@ pyairvisual==1.0.0 pyalarmdotcom==0.3.0 # homeassistant.components.arlo -pyarlo==0.1.0 +pyarlo==0.1.2 # homeassistant.components.notify.xmpp pyasn1-modules==0.1.5