From 39d33c97ffa1e66644e3154c6ccb8db65379d591 Mon Sep 17 00:00:00 2001 From: Alan Fischer Date: Wed, 6 Dec 2017 23:47:19 -0700 Subject: [PATCH 001/100] Added Vera scenes (#10424) * Added Vera scenes * Fixed flake8 issues * Fixed comments * Moved vera to use hass.data * Made requested changes --- .../components/binary_sensor/vera.py | 4 +- homeassistant/components/climate/vera.py | 4 +- homeassistant/components/cover/vera.py | 4 +- homeassistant/components/light/vera.py | 3 +- homeassistant/components/lock/vera.py | 4 +- homeassistant/components/scene/vera.py | 60 +++++++++++++++++++ homeassistant/components/sensor/vera.py | 4 +- homeassistant/components/switch/vera.py | 4 +- homeassistant/components/vera.py | 29 ++++++--- requirements_all.txt | 2 +- 10 files changed, 95 insertions(+), 23 deletions(-) create mode 100644 homeassistant/components/scene/vera.py diff --git a/homeassistant/components/binary_sensor/vera.py b/homeassistant/components/binary_sensor/vera.py index e16f4e17fa0..e87886376bc 100644 --- a/homeassistant/components/binary_sensor/vera.py +++ b/homeassistant/components/binary_sensor/vera.py @@ -19,8 +19,8 @@ _LOGGER = logging.getLogger(__name__) def setup_platform(hass, config, add_devices, discovery_info=None): """Perform the setup for Vera controller devices.""" add_devices( - VeraBinarySensor(device, VERA_CONTROLLER) - for device in VERA_DEVICES['binary_sensor']) + VeraBinarySensor(device, hass.data[VERA_CONTROLLER]) + for device in hass.data[VERA_DEVICES]['binary_sensor']) class VeraBinarySensor(VeraDevice, BinarySensorDevice): diff --git a/homeassistant/components/climate/vera.py b/homeassistant/components/climate/vera.py index 4644f86cba2..c9d22e41d81 100644 --- a/homeassistant/components/climate/vera.py +++ b/homeassistant/components/climate/vera.py @@ -32,8 +32,8 @@ SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE | def setup_platform(hass, config, add_devices_callback, discovery_info=None): """Set up of Vera thermostats.""" add_devices_callback( - VeraThermostat(device, VERA_CONTROLLER) for - device in VERA_DEVICES['climate']) + VeraThermostat(device, hass.data[VERA_CONTROLLER]) for + device in hass.data[VERA_DEVICES]['climate']) class VeraThermostat(VeraDevice, ClimateDevice): diff --git a/homeassistant/components/cover/vera.py b/homeassistant/components/cover/vera.py index 05be125ec6f..6cf269b75b3 100644 --- a/homeassistant/components/cover/vera.py +++ b/homeassistant/components/cover/vera.py @@ -18,8 +18,8 @@ _LOGGER = logging.getLogger(__name__) def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Vera covers.""" add_devices( - VeraCover(device, VERA_CONTROLLER) for - device in VERA_DEVICES['cover']) + VeraCover(device, hass.data[VERA_CONTROLLER]) for + device in hass.data[VERA_DEVICES]['cover']) class VeraCover(VeraDevice, CoverDevice): diff --git a/homeassistant/components/light/vera.py b/homeassistant/components/light/vera.py index b3be93d82e2..102ca814882 100644 --- a/homeassistant/components/light/vera.py +++ b/homeassistant/components/light/vera.py @@ -21,7 +21,8 @@ DEPENDENCIES = ['vera'] def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Vera lights.""" add_devices( - VeraLight(device, VERA_CONTROLLER) for device in VERA_DEVICES['light']) + VeraLight(device, hass.data[VERA_CONTROLLER]) for + device in hass.data[VERA_DEVICES]['light']) class VeraLight(VeraDevice, Light): diff --git a/homeassistant/components/lock/vera.py b/homeassistant/components/lock/vera.py index 04962566821..b3aae5e159f 100644 --- a/homeassistant/components/lock/vera.py +++ b/homeassistant/components/lock/vera.py @@ -19,8 +19,8 @@ DEPENDENCIES = ['vera'] def setup_platform(hass, config, add_devices, discovery_info=None): """Find and return Vera locks.""" add_devices( - VeraLock(device, VERA_CONTROLLER) for - device in VERA_DEVICES['lock']) + VeraLock(device, hass.data[VERA_CONTROLLER]) for + device in hass.data[VERA_DEVICES]['lock']) class VeraLock(VeraDevice, LockDevice): diff --git a/homeassistant/components/scene/vera.py b/homeassistant/components/scene/vera.py new file mode 100644 index 00000000000..3dbb68d214f --- /dev/null +++ b/homeassistant/components/scene/vera.py @@ -0,0 +1,60 @@ +""" +Support for Vera scenes. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/scene.vera/ +""" +import logging + +from homeassistant.util import slugify +from homeassistant.components.scene import Scene +from homeassistant.components.vera import ( + VERA_CONTROLLER, VERA_SCENES, VERA_ID_FORMAT) + +DEPENDENCIES = ['vera'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Vera scenes.""" + add_devices( + [VeraScene(scene, hass.data[VERA_CONTROLLER]) + for scene in hass.data[VERA_SCENES]], True) + + +class VeraScene(Scene): + """Representation of a Vera scene entity.""" + + def __init__(self, vera_scene, controller): + """Initialize the scene.""" + self.vera_scene = vera_scene + self.controller = controller + + self._name = self.vera_scene.name + # Append device id to prevent name clashes in HA. + self.vera_id = VERA_ID_FORMAT.format( + slugify(vera_scene.name), vera_scene.scene_id) + + def update(self): + """Update the scene status.""" + self.vera_scene.refresh() + + def activate(self, **kwargs): + """Activate the scene.""" + self.vera_scene.activate() + + @property + def name(self): + """Return the name of the scene.""" + return self._name + + @property + def device_state_attributes(self): + """Return the state attributes of the scene.""" + return {'vera_scene_id': self.vera_scene.vera_scene_id} + + @property + def should_poll(self): + """Return that polling is not necessary.""" + return False diff --git a/homeassistant/components/sensor/vera.py b/homeassistant/components/sensor/vera.py index f901bd27dca..c81c208e33e 100644 --- a/homeassistant/components/sensor/vera.py +++ b/homeassistant/components/sensor/vera.py @@ -25,8 +25,8 @@ SCAN_INTERVAL = timedelta(seconds=5) def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Vera controller devices.""" add_devices( - VeraSensor(device, VERA_CONTROLLER) - for device in VERA_DEVICES['sensor']) + VeraSensor(device, hass.data[VERA_CONTROLLER]) + for device in hass.data[VERA_DEVICES]['sensor']) class VeraSensor(VeraDevice, Entity): diff --git a/homeassistant/components/switch/vera.py b/homeassistant/components/switch/vera.py index 1e92612b9a9..d7c284e4ccf 100644 --- a/homeassistant/components/switch/vera.py +++ b/homeassistant/components/switch/vera.py @@ -19,8 +19,8 @@ _LOGGER = logging.getLogger(__name__) def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Vera switches.""" add_devices( - VeraSwitch(device, VERA_CONTROLLER) for - device in VERA_DEVICES['switch']) + VeraSwitch(device, hass.data[VERA_CONTROLLER]) for + device in hass.data[VERA_DEVICES]['switch']) class VeraSwitch(VeraDevice, SwitchDevice): diff --git a/homeassistant/components/vera.py b/homeassistant/components/vera.py index 7418ca812a1..b15c4ddabfd 100644 --- a/homeassistant/components/vera.py +++ b/homeassistant/components/vera.py @@ -19,13 +19,13 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, CONF_LIGHTS, CONF_EXCLUDE) from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pyvera==0.2.38'] +REQUIREMENTS = ['pyvera==0.2.39'] _LOGGER = logging.getLogger(__name__) DOMAIN = 'vera' -VERA_CONTROLLER = None +VERA_CONTROLLER = 'vera_controller' CONF_CONTROLLER = 'vera_controller_url' @@ -34,7 +34,8 @@ VERA_ID_FORMAT = '{}_{}' ATTR_CURRENT_POWER_W = "current_power_w" ATTR_CURRENT_ENERGY_KWH = "current_energy_kwh" -VERA_DEVICES = defaultdict(list) +VERA_DEVICES = 'vera_devices' +VERA_SCENES = 'vera_scenes' VERA_ID_LIST_SCHEMA = vol.Schema([int]) @@ -47,20 +48,20 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) VERA_COMPONENTS = [ - 'binary_sensor', 'sensor', 'light', 'switch', 'lock', 'climate', 'cover' + 'binary_sensor', 'sensor', 'light', 'switch', + 'lock', 'climate', 'cover', 'scene' ] # pylint: disable=unused-argument, too-many-function-args def setup(hass, base_config): """Set up for Vera devices.""" - global VERA_CONTROLLER import pyvera as veraApi def stop_subscription(event): """Shutdown Vera subscriptions and subscription thread on exit.""" _LOGGER.info("Shutting down subscriptions") - VERA_CONTROLLER.stop() + hass.data[VERA_CONTROLLER].stop() config = base_config.get(DOMAIN) @@ -70,11 +71,14 @@ def setup(hass, base_config): exclude_ids = config.get(CONF_EXCLUDE) # Initialize the Vera controller. - VERA_CONTROLLER, _ = veraApi.init_controller(base_url) + controller, _ = veraApi.init_controller(base_url) + hass.data[VERA_CONTROLLER] = controller hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_subscription) try: - all_devices = VERA_CONTROLLER.get_devices() + all_devices = controller.get_devices() + + all_scenes = controller.get_scenes() except RequestException: # There was a network related error connecting to the Vera controller. _LOGGER.exception("Error communicating with Vera API") @@ -84,12 +88,19 @@ def setup(hass, base_config): devices = [device for device in all_devices if device.device_id not in exclude_ids] + vera_devices = defaultdict(list) for device in devices: device_type = map_vera_device(device, light_ids) if device_type is None: continue - VERA_DEVICES[device_type].append(device) + vera_devices[device_type].append(device) + hass.data[VERA_DEVICES] = vera_devices + + vera_scenes = [] + for scene in all_scenes: + vera_scenes.append(scene) + hass.data[VERA_SCENES] = vera_scenes for component in VERA_COMPONENTS: discovery.load_platform(hass, component, DOMAIN, {}, base_config) diff --git a/requirements_all.txt b/requirements_all.txt index 840ed5a834a..b34b7b9bb50 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -925,7 +925,7 @@ pyunifi==2.13 # pyuserinput==0.1.11 # homeassistant.components.vera -pyvera==0.2.38 +pyvera==0.2.39 # homeassistant.components.media_player.vizio pyvizio==0.0.2 From f21da7cfdc6ff5917efc43421cf453d64d0cf3d5 Mon Sep 17 00:00:00 2001 From: Jeroen ter Heerdt Date: Thu, 7 Dec 2017 12:39:34 +0100 Subject: [PATCH 002/100] Fix Egardia alarm status shown as unknown after restart (#11010) --- .../components/alarm_control_panel/egardia.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/egardia.py b/homeassistant/components/alarm_control_panel/egardia.py index 7719ab884bc..82c26c98104 100644 --- a/homeassistant/components/alarm_control_panel/egardia.py +++ b/homeassistant/components/alarm_control_panel/egardia.py @@ -116,12 +116,20 @@ class EgardiaAlarm(alarm.AlarmControlPanel): """Return the state of the device.""" return self._status + @property + def should_poll(self): + """Poll if no report server is enabled.""" + if not self._rs_enabled: + return True + return False + def handle_system_status_event(self, event): """Handle egardia_system_status_event.""" if event.data.get('status') is not None: statuscode = event.data.get('status') status = self.lookupstatusfromcode(statuscode) self.parsestatus(status) + self.schedule_update_ha_state() def listen_to_system_status(self): """Subscribe to egardia_system_status event.""" @@ -161,9 +169,8 @@ class EgardiaAlarm(alarm.AlarmControlPanel): def update(self): """Update the alarm status.""" - if not self._rs_enabled: - status = self._egardiasystem.getstate() - self.parsestatus(status) + status = self._egardiasystem.getstate() + self.parsestatus(status) def alarm_disarm(self, code=None): """Send disarm command.""" From 3c1f8cd882d16912f9c972ca96be460fbba5717e Mon Sep 17 00:00:00 2001 From: Lewis Juggins Date: Thu, 7 Dec 2017 16:30:51 +0000 Subject: [PATCH 003/100] Handle OSError when forcibly turning off media_player.samsungtv (#10997) --- homeassistant/components/media_player/samsungtv.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/media_player/samsungtv.py b/homeassistant/components/media_player/samsungtv.py index 721b095c083..d42bd9ea012 100644 --- a/homeassistant/components/media_player/samsungtv.py +++ b/homeassistant/components/media_player/samsungtv.py @@ -190,7 +190,10 @@ class SamsungTVDevice(MediaPlayerDevice): else: self.send_key('KEY_POWEROFF') # Force closing of remote session to provide instant UI feedback - self.get_remote().close() + try: + self.get_remote().close() + except OSError: + _LOGGER.debug("Could not establish connection.") def volume_up(self): """Volume up the media player.""" From 929d49ed6fec0eb77de9e0087445e322ec4172e5 Mon Sep 17 00:00:00 2001 From: Marcus Schmidt Date: Thu, 7 Dec 2017 20:44:06 +0100 Subject: [PATCH 004/100] Shuffle support in Sonos (#10875) * initial commit of shuffle option for sonos * added test * Small adjustments to adhere to review requests * Removed unnessesary setting of variable. Use shuffle state from soco instead --- .../components/media_player/sonos.py | 24 +++++++++++++++---- tests/components/media_player/test_sonos.py | 14 +++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py index 47786e793ca..61c2773df05 100644 --- a/homeassistant/components/media_player/sonos.py +++ b/homeassistant/components/media_player/sonos.py @@ -19,7 +19,7 @@ from homeassistant.components.media_player import ( SUPPORT_PAUSE, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_CLEAR_PLAYLIST, SUPPORT_SELECT_SOURCE, MediaPlayerDevice, PLATFORM_SCHEMA, SUPPORT_STOP, - SUPPORT_PLAY) + SUPPORT_PLAY, SUPPORT_SHUFFLE_SET) from homeassistant.const import ( STATE_IDLE, STATE_PAUSED, STATE_PLAYING, STATE_OFF, ATTR_ENTITY_ID, CONF_HOSTS, ATTR_TIME) @@ -43,7 +43,7 @@ _REQUESTS_LOGGER.setLevel(logging.ERROR) SUPPORT_SONOS = SUPPORT_STOP | SUPPORT_PAUSE | SUPPORT_VOLUME_SET |\ SUPPORT_VOLUME_MUTE | SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK |\ SUPPORT_PLAY_MEDIA | SUPPORT_SEEK | SUPPORT_CLEAR_PLAYLIST |\ - SUPPORT_SELECT_SOURCE | SUPPORT_PLAY + SUPPORT_SELECT_SOURCE | SUPPORT_PLAY | SUPPORT_SHUFFLE_SET SERVICE_JOIN = 'sonos_join' SERVICE_UNJOIN = 'sonos_unjoin' @@ -331,6 +331,7 @@ class SonosDevice(MediaPlayerDevice): self._support_previous_track = False self._support_next_track = False self._support_play = False + self._support_shuffle_set = True self._support_stop = False self._support_pause = False self._current_track_uri = None @@ -450,6 +451,7 @@ class SonosDevice(MediaPlayerDevice): self._support_previous_track = False self._support_next_track = False self._support_play = False + self._support_shuffle_set = False self._support_stop = False self._support_pause = False self._is_playing_tv = False @@ -536,6 +538,7 @@ class SonosDevice(MediaPlayerDevice): support_play = False support_stop = True support_pause = False + support_shuffle_set = False if is_playing_tv: media_artist = SUPPORT_SOURCE_TV @@ -558,6 +561,7 @@ class SonosDevice(MediaPlayerDevice): support_play = True support_stop = True support_pause = False + support_shuffle_set = False source_name = 'Radio' # Check if currently playing radio station is in favorites @@ -622,6 +626,7 @@ class SonosDevice(MediaPlayerDevice): support_play = True support_stop = True support_pause = True + support_shuffle_set = True position_info = self._player.avTransport.GetPositionInfo( [('InstanceID', 0), @@ -694,6 +699,7 @@ class SonosDevice(MediaPlayerDevice): self._support_previous_track = support_previous_track self._support_next_track = support_next_track self._support_play = support_play + self._support_shuffle_set = support_shuffle_set self._support_stop = support_stop self._support_pause = support_pause self._is_playing_tv = is_playing_tv @@ -762,6 +768,11 @@ class SonosDevice(MediaPlayerDevice): """Return true if volume is muted.""" return self._player_volume_muted + @property + def shuffle(self): + """Shuffling state.""" + return True if self._player.play_mode == 'SHUFFLE' else False + @property def media_content_id(self): """Content ID of current playing media.""" @@ -850,7 +861,8 @@ class SonosDevice(MediaPlayerDevice): if not self._support_play: supported = supported ^ SUPPORT_PLAY - + if not self._support_shuffle_set: + supported = supported ^ SUPPORT_SHUFFLE_SET if not self._support_stop: supported = supported ^ SUPPORT_STOP @@ -874,6 +886,11 @@ class SonosDevice(MediaPlayerDevice): """Set volume level, range 0..1.""" self._player.volume = str(int(volume * 100)) + @soco_error + def set_shuffle(self, shuffle): + """Enable/Disable shuffle mode.""" + self._player.play_mode = 'SHUFFLE' if shuffle else 'NORMAL' + @soco_error def mute_volume(self, mute): """Mute (true) or unmute (false) media player.""" @@ -932,7 +949,6 @@ class SonosDevice(MediaPlayerDevice): self._player.stop() self._player.clear_queue() - self._player.play_mode = 'NORMAL' self._player.add_to_queue(didl) @property diff --git a/tests/components/media_player/test_sonos.py b/tests/components/media_player/test_sonos.py index 8c62c6c84e9..33f7a0e882d 100644 --- a/tests/components/media_player/test_sonos.py +++ b/tests/components/media_player/test_sonos.py @@ -281,6 +281,20 @@ class TestSonosMediaPlayer(unittest.TestCase): self.assertEqual(unjoinMock.call_count, 1) self.assertEqual(unjoinMock.call_args, mock.call()) + @mock.patch('soco.SoCo', new=SoCoMock) + @mock.patch('socket.create_connection', side_effect=socket.error()) + def test_set_shuffle(self, shuffle_set_mock, *args): + """Ensuring soco methods called for sonos_snapshot 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 + + device.set_shuffle(True) + self.assertEqual(shuffle_set_mock.call_count, 1) + self.assertEqual(device._player.play_mode, 'SHUFFLE') + @mock.patch('soco.SoCo', new=SoCoMock) @mock.patch('socket.create_connection', side_effect=socket.error()) @mock.patch.object(SoCoMock, 'set_sleep_timer') From f892c3394b4a2d2a2e77e9472b91409380875cf0 Mon Sep 17 00:00:00 2001 From: Joe Lu Date: Fri, 8 Dec 2017 01:40:45 -0800 Subject: [PATCH 005/100] Add support for Canary component and platforms (#10306) * Add Canary component * Made some change to how canary data is updated and stored * Updated to use py-canary:0.1.2 * Addressed flake8 warnings * Import canary API locally * Import canary API locally again * Addressed pylint errors * Updated requirements_all.txt * Fixed incorrect unit of measurement for air quality sensor * Added tests for Canary component and sensors * Updated canary component to handle exception better when initializing * Fixed tests * Fixed tests again * Addressed review comments * Fixed houndci error * Addressed comment about camera force update * Addressed comment regarding timeout when fetching camera image * Updated to use py-canary==0.2.2 * Increased update frequency to 30 seconds * Added support for Canary alarm control panel * Address review comments * Fixed houndci error * Fixed lint errors * Updated test to only test setup component / platform * Fixed flake error * Fixed failing test * Uptake py-canary:0.2.3 * canary.alarm_control_panel DISARM is now mapped to canary PRIVACY mode * Fixed failing tests * Removed unnecessary methods * Removed polling in canary camera component and update camera info when getting camera image * Added more tests to cover Canary sensors * Address review comments * Addressed review comment in tests * Fixed pylint errors * Excluded canary alarm_control_panel and camera from coverage calculation --- .coveragerc | 2 + .../components/alarm_control_panel/canary.py | 92 +++++++++++++ homeassistant/components/camera/canary.py | 95 +++++++++++++ homeassistant/components/canary.py | 117 ++++++++++++++++ homeassistant/components/sensor/canary.py | 85 ++++++++++++ requirements_all.txt | 3 + requirements_test_all.txt | 3 + script/gen_requirements_all.py | 1 + tests/components/sensor/test_canary.py | 125 ++++++++++++++++++ tests/components/test_canary.py | 85 ++++++++++++ 10 files changed, 608 insertions(+) create mode 100644 homeassistant/components/alarm_control_panel/canary.py create mode 100644 homeassistant/components/camera/canary.py create mode 100644 homeassistant/components/canary.py create mode 100644 homeassistant/components/sensor/canary.py create mode 100644 tests/components/sensor/test_canary.py create mode 100644 tests/components/test_canary.py diff --git a/.coveragerc b/.coveragerc index e97d197ca94..9db732dfbde 100644 --- a/.coveragerc +++ b/.coveragerc @@ -264,6 +264,7 @@ omit = homeassistant/components/*/zoneminder.py homeassistant/components/alarm_control_panel/alarmdotcom.py + homeassistant/components/alarm_control_panel/canary.py homeassistant/components/alarm_control_panel/concord232.py homeassistant/components/alarm_control_panel/egardia.py homeassistant/components/alarm_control_panel/ialarm.py @@ -285,6 +286,7 @@ omit = homeassistant/components/browser.py homeassistant/components/calendar/todoist.py homeassistant/components/camera/bloomsky.py + homeassistant/components/camera/canary.py homeassistant/components/camera/ffmpeg.py homeassistant/components/camera/foscam.py homeassistant/components/camera/mjpeg.py diff --git a/homeassistant/components/alarm_control_panel/canary.py b/homeassistant/components/alarm_control_panel/canary.py new file mode 100644 index 00000000000..fb5c4c37e8d --- /dev/null +++ b/homeassistant/components/alarm_control_panel/canary.py @@ -0,0 +1,92 @@ +""" +Support for Canary alarm. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/alarm_control_panel.canary/ +""" +import logging + +from homeassistant.components.alarm_control_panel import AlarmControlPanel +from homeassistant.components.canary import DATA_CANARY +from homeassistant.const import STATE_ALARM_DISARMED, STATE_ALARM_ARMED_AWAY, \ + STATE_ALARM_ARMED_NIGHT, STATE_ALARM_ARMED_HOME + +DEPENDENCIES = ['canary'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Canary alarms.""" + data = hass.data[DATA_CANARY] + devices = [] + + for location in data.locations: + devices.append(CanaryAlarm(data, location.location_id)) + + add_devices(devices, True) + + +class CanaryAlarm(AlarmControlPanel): + """Representation of a Canary alarm control panel.""" + + def __init__(self, data, location_id): + """Initialize a Canary security camera.""" + self._data = data + self._location_id = location_id + + @property + def name(self): + """Return the name of the alarm.""" + location = self._data.get_location(self._location_id) + return location.name + + @property + def state(self): + """Return the state of the device.""" + from canary.api import LOCATION_MODE_AWAY, LOCATION_MODE_HOME, \ + LOCATION_MODE_NIGHT + + location = self._data.get_location(self._location_id) + + if location.is_private: + return STATE_ALARM_DISARMED + + mode = location.mode + if mode.name == LOCATION_MODE_AWAY: + return STATE_ALARM_ARMED_AWAY + elif mode.name == LOCATION_MODE_HOME: + return STATE_ALARM_ARMED_HOME + elif mode.name == LOCATION_MODE_NIGHT: + return STATE_ALARM_ARMED_NIGHT + else: + return None + + @property + def device_state_attributes(self): + """Return the state attributes.""" + location = self._data.get_location(self._location_id) + return { + 'private': location.is_private + } + + def alarm_disarm(self, code=None): + """Send disarm command.""" + location = self._data.get_location(self._location_id) + self._data.set_location_mode(self._location_id, location.mode.name, + True) + + def alarm_arm_home(self, code=None): + """Send arm home command.""" + from canary.api import LOCATION_MODE_HOME + self._data.set_location_mode(self._location_id, LOCATION_MODE_HOME) + + def alarm_arm_away(self, code=None): + """Send arm away command.""" + from canary.api import LOCATION_MODE_AWAY + self._data.set_location_mode(self._location_id, LOCATION_MODE_AWAY) + + def alarm_arm_night(self, code=None): + """Send arm night command.""" + from canary.api import LOCATION_MODE_NIGHT + self._data.set_location_mode(self._location_id, LOCATION_MODE_NIGHT) diff --git a/homeassistant/components/camera/canary.py b/homeassistant/components/camera/canary.py new file mode 100644 index 00000000000..302758eee94 --- /dev/null +++ b/homeassistant/components/camera/canary.py @@ -0,0 +1,95 @@ +""" +Support for Canary camera. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.canary/ +""" +import logging + +import requests + +from homeassistant.components.camera import Camera +from homeassistant.components.canary import DATA_CANARY, DEFAULT_TIMEOUT + +DEPENDENCIES = ['canary'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_MOTION_START_TIME = "motion_start_time" +ATTR_MOTION_END_TIME = "motion_end_time" + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Canary sensors.""" + data = hass.data[DATA_CANARY] + devices = [] + + for location in data.locations: + entries = data.get_motion_entries(location.location_id) + if entries: + devices.append(CanaryCamera(data, location.location_id, + DEFAULT_TIMEOUT)) + + add_devices(devices, True) + + +class CanaryCamera(Camera): + """An implementation of a Canary security camera.""" + + def __init__(self, data, location_id, timeout): + """Initialize a Canary security camera.""" + super().__init__() + self._data = data + self._location_id = location_id + self._timeout = timeout + + self._location = None + self._motion_entry = None + self._image_content = None + + def camera_image(self): + """Update the status of the camera and return bytes of camera image.""" + self.update() + return self._image_content + + @property + def name(self): + """Return the name of this device.""" + return self._location.name + + @property + def is_recording(self): + """Return true if the device is recording.""" + return self._location.is_recording + + @property + def device_state_attributes(self): + """Return device specific state attributes.""" + if self._motion_entry is None: + return None + + return { + ATTR_MOTION_START_TIME: self._motion_entry.start_time, + ATTR_MOTION_END_TIME: self._motion_entry.end_time, + } + + def update(self): + """Update the status of the camera.""" + self._data.update() + self._location = self._data.get_location(self._location_id) + + entries = self._data.get_motion_entries(self._location_id) + if entries: + current = entries[0] + previous = self._motion_entry + + if previous is None or previous.entry_id != current.entry_id: + self._motion_entry = current + self._image_content = requests.get( + current.thumbnails[0].image_url, + timeout=self._timeout).content + + @property + def motion_detection_enabled(self): + """Return the camera motion detection status.""" + return not self._location.is_recording diff --git a/homeassistant/components/canary.py b/homeassistant/components/canary.py new file mode 100644 index 00000000000..8ab7218e201 --- /dev/null +++ b/homeassistant/components/canary.py @@ -0,0 +1,117 @@ +""" +Support for Canary. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/canary/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol +from requests import ConnectTimeout, HTTPError + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, CONF_TIMEOUT +from homeassistant.helpers import discovery +from homeassistant.util import Throttle + +REQUIREMENTS = ['py-canary==0.2.3'] + +_LOGGER = logging.getLogger(__name__) + +NOTIFICATION_ID = 'canary_notification' +NOTIFICATION_TITLE = 'Canary Setup' + +DOMAIN = 'canary' +DATA_CANARY = 'canary' +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) +DEFAULT_TIMEOUT = 10 + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + }), +}, extra=vol.ALLOW_EXTRA) + +CANARY_COMPONENTS = [ + 'alarm_control_panel', 'camera', 'sensor' +] + + +def setup(hass, config): + """Set up the Canary component.""" + conf = config[DOMAIN] + username = conf.get(CONF_USERNAME) + password = conf.get(CONF_PASSWORD) + timeout = conf.get(CONF_TIMEOUT) + + try: + hass.data[DATA_CANARY] = CanaryData(username, password, timeout) + except (ConnectTimeout, HTTPError) as ex: + _LOGGER.error("Unable to connect to Canary service: %s", str(ex)) + hass.components.persistent_notification.create( + 'Error: {}
' + 'You will need to restart hass after fixing.' + ''.format(ex), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + return False + + for component in CANARY_COMPONENTS: + discovery.load_platform(hass, component, DOMAIN, {}, config) + + return True + + +class CanaryData(object): + """Get the latest data and update the states.""" + + def __init__(self, username, password, timeout): + """Init the Canary data object.""" + from canary.api import Api + self._api = Api(username, password, timeout) + + self._locations_by_id = {} + self._readings_by_device_id = {} + self._entries_by_location_id = {} + + self.update() + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self, **kwargs): + """Get the latest data from py-canary.""" + for location in self._api.get_locations(): + location_id = location.location_id + + self._locations_by_id[location_id] = location + self._entries_by_location_id[location_id] = self._api.get_entries( + location_id, entry_type="motion", limit=1) + + for device in location.devices: + if device.is_online: + self._readings_by_device_id[device.device_id] = \ + self._api.get_latest_readings(device.device_id) + + @property + def locations(self): + """Return a list of locations.""" + return self._locations_by_id.values() + + def get_motion_entries(self, location_id): + """Return a list of motion entries based on location_id.""" + return self._entries_by_location_id.get(location_id, []) + + def get_location(self, location_id): + """Return a location based on location_id.""" + return self._locations_by_id.get(location_id, []) + + def get_readings(self, device_id): + """Return a list of readings based on device_id.""" + return self._readings_by_device_id.get(device_id, []) + + def set_location_mode(self, location_id, mode_name, is_private=False): + """Set location mode.""" + self._api.set_location_mode(location_id, mode_name, is_private) + self.update(no_throttle=True) diff --git a/homeassistant/components/sensor/canary.py b/homeassistant/components/sensor/canary.py new file mode 100644 index 00000000000..b0d2c27ae5d --- /dev/null +++ b/homeassistant/components/sensor/canary.py @@ -0,0 +1,85 @@ +""" +Support for Canary sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.canary/ +""" +from homeassistant.components.canary import DATA_CANARY +from homeassistant.const import TEMP_FAHRENHEIT, TEMP_CELSIUS +from homeassistant.helpers.entity import Entity + +DEPENDENCIES = ['canary'] + +SENSOR_VALUE_PRECISION = 1 + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Canary sensors.""" + data = hass.data[DATA_CANARY] + devices = [] + + from canary.api import SensorType + for location in data.locations: + for device in location.devices: + if device.is_online: + for sensor_type in SensorType: + devices.append(CanarySensor(data, sensor_type, location, + device)) + + add_devices(devices, True) + + +class CanarySensor(Entity): + """Representation of a Canary sensor.""" + + def __init__(self, data, sensor_type, location, device): + """Initialize the sensor.""" + self._data = data + self._sensor_type = sensor_type + self._device_id = device.device_id + self._is_celsius = location.is_celsius + self._sensor_value = None + + sensor_type_name = sensor_type.value.replace("_", " ").title() + self._name = '{} {} {}'.format(location.name, + device.name, + sensor_type_name) + + @property + def name(self): + """Return the name of the Canary sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._sensor_value + + @property + def unique_id(self): + """Return the unique ID of this sensor.""" + return "sensor_canary_{}_{}".format(self._device_id, + self._sensor_type.value) + + @property + def unit_of_measurement(self): + """Return the unit of measurement this sensor expresses itself in.""" + from canary.api import SensorType + if self._sensor_type == SensorType.TEMPERATURE: + return TEMP_CELSIUS if self._is_celsius else TEMP_FAHRENHEIT + elif self._sensor_type == SensorType.HUMIDITY: + return "%" + elif self._sensor_type == SensorType.AIR_QUALITY: + return "" + return None + + def update(self): + """Get the latest state of the sensor.""" + self._data.update() + + readings = self._data.get_readings(self._device_id) + value = next(( + reading.value for reading in readings + if reading.sensor_type == self._sensor_type), None) + if value is not None: + self._sensor_value = round(float(value), SENSOR_VALUE_PRECISION) diff --git a/requirements_all.txt b/requirements_all.txt index b34b7b9bb50..c09b4f6a93b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -581,6 +581,9 @@ pushetta==1.0.15 # homeassistant.components.light.rpi_gpio_pwm pwmled==1.2.1 +# homeassistant.components.canary +py-canary==0.2.3 + # homeassistant.components.sensor.cpuspeed py-cpuinfo==3.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 72325d6305b..c932ce7ead9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -115,6 +115,9 @@ pmsensor==0.4 # homeassistant.components.prometheus prometheus_client==0.0.21 +# homeassistant.components.canary +py-canary==0.2.3 + # homeassistant.components.zwave pydispatcher==2.0.5 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index fbd60ffdadc..bdc75f3a69c 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -61,6 +61,7 @@ TEST_REQUIREMENTS = ( 'pilight', 'pmsensor', 'prometheus_client', + 'py-canary', 'pydispatcher', 'PyJWT', 'pylitejet', diff --git a/tests/components/sensor/test_canary.py b/tests/components/sensor/test_canary.py new file mode 100644 index 00000000000..99df05f36a4 --- /dev/null +++ b/tests/components/sensor/test_canary.py @@ -0,0 +1,125 @@ +"""The tests for the Canary sensor platform.""" +import copy +import unittest +from unittest.mock import patch, 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 +from tests.common import (get_test_home_assistant) +from tests.components.test_canary import mock_device, mock_reading, \ + mock_location + +VALID_CONFIG = { + "canary": { + "username": "foo@bar.org", + "password": "bar", + } +} + + +class TestCanarySensorSetup(unittest.TestCase): + """Test the Canary platform.""" + + DEVICES = [] + + def add_devices(self, devices, action): + """Mock add devices.""" + for device in devices: + self.DEVICES.append(device) + + def setUp(self): + """Initialize values for this testcase class.""" + self.hass = get_test_home_assistant() + self.config = copy.deepcopy(VALID_CONFIG) + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + @patch('homeassistant.components.canary.CanaryData') + def test_setup_sensors(self, mock_canary): + """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].locations = [ + mock_location("Home", True, devices=[online_device_at_home, + offline_device_at_home]), + mock_location("Work", True, devices=[online_device_at_work]), + ] + + canary.setup_platform(self.hass, self.config, self.add_devices, None) + + self.assertEqual(6, len(self.DEVICES)) + + def test_celsius_temperature_sensor(self): + """Test temperature sensor with celsius.""" + device = mock_device(10, "Family Room") + location = mock_location("Home", True) + + data = Mock() + data.get_readings.return_value = [ + mock_reading(SensorType.TEMPERATURE, 21.1234)] + + sensor = CanarySensor(data, SensorType.TEMPERATURE, location, device) + sensor.update() + + self.assertEqual("Home Family Room Temperature", sensor.name) + self.assertEqual("sensor_canary_10_temperature", sensor.unique_id) + self.assertEqual("°C", sensor.unit_of_measurement) + self.assertEqual(21.1, sensor.state) + + def test_fahrenheit_temperature_sensor(self): + """Test temperature sensor with fahrenheit.""" + device = mock_device(10, "Family Room") + location = mock_location("Home", False) + + data = Mock() + data.get_readings.return_value = [ + mock_reading(SensorType.TEMPERATURE, 21.1567)] + + sensor = CanarySensor(data, SensorType.TEMPERATURE, location, device) + sensor.update() + + self.assertEqual("Home Family Room Temperature", sensor.name) + self.assertEqual("°F", sensor.unit_of_measurement) + self.assertEqual(21.2, sensor.state) + + def test_humidity_sensor(self): + """Test humidity sensor.""" + device = mock_device(10, "Family Room") + location = mock_location("Home") + + data = Mock() + data.get_readings.return_value = [ + mock_reading(SensorType.HUMIDITY, 50.4567)] + + sensor = CanarySensor(data, SensorType.HUMIDITY, location, device) + sensor.update() + + self.assertEqual("Home Family Room Humidity", sensor.name) + self.assertEqual("%", sensor.unit_of_measurement) + self.assertEqual(50.5, sensor.state) + + def test_air_quality_sensor(self): + """Test air quality sensor.""" + device = mock_device(10, "Family Room") + location = mock_location("Home") + + data = Mock() + data.get_readings.return_value = [ + mock_reading(SensorType.AIR_QUALITY, 50.4567)] + + sensor = CanarySensor(data, SensorType.AIR_QUALITY, location, device) + sensor.update() + + self.assertEqual("Home Family Room Air Quality", sensor.name) + self.assertEqual("", sensor.unit_of_measurement) + self.assertEqual(50.5, sensor.state) diff --git a/tests/components/test_canary.py b/tests/components/test_canary.py new file mode 100644 index 00000000000..67122813fb7 --- /dev/null +++ b/tests/components/test_canary.py @@ -0,0 +1,85 @@ +"""The tests for the Canary component.""" +import unittest +from unittest.mock import patch, MagicMock, PropertyMock + +import homeassistant.components.canary as canary +from homeassistant import setup +from tests.common import ( + get_test_home_assistant) + + +def mock_device(device_id, name, is_online=True): + """Mock Canary Device class.""" + device = MagicMock() + type(device).device_id = PropertyMock(return_value=device_id) + type(device).name = PropertyMock(return_value=name) + type(device).is_online = PropertyMock(return_value=is_online) + return device + + +def mock_location(name, is_celsius=True, devices=[]): + """Mock Canary Location class.""" + location = MagicMock() + type(location).name = PropertyMock(return_value=name) + type(location).is_celsius = PropertyMock(return_value=is_celsius) + type(location).devices = PropertyMock(return_value=devices) + return location + + +def mock_reading(sensor_type, sensor_value): + """Mock Canary Reading class.""" + reading = MagicMock() + type(reading).sensor_type = PropertyMock(return_value=sensor_type) + type(reading).value = PropertyMock(return_value=sensor_value) + return reading + + +class TestCanary(unittest.TestCase): + """Tests the Canary component.""" + + def setUp(self): + """Initialize values for this test case class.""" + self.hass = get_test_home_assistant() + + def tearDown(self): # pylint: disable=invalid-name + """Stop everything that was started.""" + self.hass.stop() + + @patch('homeassistant.components.canary.CanaryData.update') + @patch('canary.api.Api.login') + def test_setup_with_valid_config(self, mock_login, mock_update): + """Test setup component.""" + config = { + "canary": { + "username": "foo@bar.org", + "password": "bar", + } + } + + self.assertTrue( + setup.setup_component(self.hass, canary.DOMAIN, config)) + + mock_update.assert_called_once_with() + mock_login.assert_called_once_with() + + def test_setup_with_missing_password(self): + """Test setup component.""" + config = { + "canary": { + "username": "foo@bar.org", + } + } + + self.assertFalse( + setup.setup_component(self.hass, canary.DOMAIN, config)) + + def test_setup_with_missing_username(self): + """Test setup component.""" + config = { + "canary": { + "password": "bar", + } + } + + self.assertFalse( + setup.setup_component(self.hass, canary.DOMAIN, config)) From 0a7e6ac222ccddb2b6f8121741a785fc373674d1 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Fri, 8 Dec 2017 12:01:10 +0100 Subject: [PATCH 006/100] Ignore Sonos players with unknown hostnames (#11013) --- homeassistant/components/media_player/sonos.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py index 61c2773df05..f9a18a212f5 100644 --- a/homeassistant/components/media_player/sonos.py +++ b/homeassistant/components/media_player/sonos.py @@ -140,7 +140,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): hosts = hosts.split(',') if isinstance(hosts, str) else hosts players = [] for host in hosts: - players.append(soco.SoCo(socket.gethostbyname(host))) + try: + players.append(soco.SoCo(socket.gethostbyname(host))) + except OSError: + _LOGGER.warning("Failed to initialize '%s'", host) if not players: players = soco.discover( From 4d6070e33a47bfbddee249d3656fc0023c355a48 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Fri, 8 Dec 2017 17:43:37 +0100 Subject: [PATCH 007/100] Support LIFX Mini products (#10996) * Support new LIFX products * Remove lint --- homeassistant/components/light/lifx.py | 82 +++++++++++--------------- requirements_all.txt | 2 +- 2 files changed, 36 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/light/lifx.py b/homeassistant/components/light/lifx.py index ad2cf204463..06a00954d3b 100644 --- a/homeassistant/components/light/lifx.py +++ b/homeassistant/components/light/lifx.py @@ -33,7 +33,7 @@ import homeassistant.util.color as color_util _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['aiolifx==0.6.0', 'aiolifx_effects==0.1.2'] +REQUIREMENTS = ['aiolifx==0.6.1', 'aiolifx_effects==0.1.2'] UDP_BROADCAST_PORT = 56700 @@ -157,20 +157,10 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): return True -def lifxwhite(device): - """Return whether this is a white-only bulb.""" - features = aiolifx().products.features_map.get(device.product, None) - if features: - return not features["color"] - return False - - -def lifxmultizone(device): - """Return whether this is a multizone bulb/strip.""" - features = aiolifx().products.features_map.get(device.product, None) - if features: - return features["multizone"] - return False +def lifx_features(device): + """Return a feature map for this device, or a default map if unknown.""" + return aiolifx().products.features_map.get(device.product) or \ + aiolifx().products.features_map.get(1) def find_hsbk(**kwargs): @@ -342,12 +332,12 @@ class LIFXManager(object): device.retry_count = MESSAGE_RETRIES device.unregister_timeout = UNAVAILABLE_GRACE - if lifxwhite(device): - entity = LIFXWhite(device, self.effects_conductor) - elif lifxmultizone(device): + if lifx_features(device)["multizone"]: entity = LIFXStrip(device, self.effects_conductor) - else: + elif lifx_features(device)["color"]: entity = LIFXColor(device, self.effects_conductor) + else: + entity = LIFXWhite(device, self.effects_conductor) _LOGGER.debug("%s register READY", entity.who) self.entities[device.mac_addr] = entity @@ -427,6 +417,29 @@ class LIFXLight(Light): """Return a string identifying the device.""" return "%s (%s)" % (self.device.ip_addr, self.name) + @property + def min_mireds(self): + """Return the coldest color_temp that this light supports.""" + kelvin = lifx_features(self.device)['max_kelvin'] + return math.floor(color_util.color_temperature_kelvin_to_mired(kelvin)) + + @property + def max_mireds(self): + """Return the warmest color_temp that this light supports.""" + kelvin = lifx_features(self.device)['min_kelvin'] + return math.ceil(color_util.color_temperature_kelvin_to_mired(kelvin)) + + @property + def supported_features(self): + """Flag supported features.""" + support = SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION | SUPPORT_EFFECT + + device_features = lifx_features(self.device) + if device_features['min_kelvin'] != device_features['max_kelvin']: + support |= SUPPORT_COLOR_TEMP + + return support + @property def brightness(self): """Return the brightness of this light between 0..255.""" @@ -571,22 +584,6 @@ class LIFXLight(Light): class LIFXWhite(LIFXLight): """Representation of a white-only LIFX light.""" - @property - def min_mireds(self): - """Return the coldest color_temp that this light supports.""" - return math.floor(color_util.color_temperature_kelvin_to_mired(6500)) - - @property - def max_mireds(self): - """Return the warmest color_temp that this light supports.""" - return math.ceil(color_util.color_temperature_kelvin_to_mired(2700)) - - @property - def supported_features(self): - """Flag supported features.""" - return (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_TRANSITION | - SUPPORT_EFFECT) - @property def effect_list(self): """Return the list of supported effects for this light.""" @@ -599,21 +596,12 @@ class LIFXWhite(LIFXLight): class LIFXColor(LIFXLight): """Representation of a color LIFX light.""" - @property - def min_mireds(self): - """Return the coldest color_temp that this light supports.""" - return math.floor(color_util.color_temperature_kelvin_to_mired(9000)) - - @property - def max_mireds(self): - """Return the warmest color_temp that this light supports.""" - return math.ceil(color_util.color_temperature_kelvin_to_mired(2500)) - @property def supported_features(self): """Flag supported features.""" - return (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_TRANSITION | - SUPPORT_EFFECT | SUPPORT_RGB_COLOR | SUPPORT_XY_COLOR) + support = super().supported_features + support |= SUPPORT_RGB_COLOR | SUPPORT_XY_COLOR + return support @property def effect_list(self): diff --git a/requirements_all.txt b/requirements_all.txt index c09b4f6a93b..bde26c64846 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -72,7 +72,7 @@ aiohttp_cors==0.5.3 aioimaplib==0.7.13 # homeassistant.components.light.lifx -aiolifx==0.6.0 +aiolifx==0.6.1 # homeassistant.components.light.lifx aiolifx_effects==0.1.2 From fed7bd947321badc4115ef4b18cfd041f0287d45 Mon Sep 17 00:00:00 2001 From: tschmidty69 Date: Fri, 8 Dec 2017 12:16:08 -0500 Subject: [PATCH 008/100] Update snips to listen on new mqtt topic and utilize rawValue (#11020) * Updated snips to listen on new mqtt topic and use rawValue if value not present in slot * Too late at night * Trying to make minor changes via web * Update test_snips.py * Update __init__.py * Updated wrong branch cause I'm a monkey --- homeassistant/components/snips.py | 13 +++++++++---- tests/components/test_snips.py | 2 +- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/snips.py b/homeassistant/components/snips.py index 1f64f78e9c8..a302f25bd00 100644 --- a/homeassistant/components/snips.py +++ b/homeassistant/components/snips.py @@ -15,7 +15,7 @@ DEPENDENCIES = ['mqtt'] CONF_INTENTS = 'intents' CONF_ACTION = 'action' -INTENT_TOPIC = 'hermes/nlu/intentParsed' +INTENT_TOPIC = 'hermes/intent/#' _LOGGER = logging.getLogger(__name__) @@ -32,7 +32,8 @@ INTENT_SCHEMA = vol.Schema({ vol.Required('slotName'): str, vol.Required('value'): { vol.Required('kind'): str, - vol.Required('value'): cv.match_all + vol.Optional('value'): cv.match_all, + vol.Optional('rawValue'): cv.match_all } }] }, extra=vol.ALLOW_EXTRA) @@ -59,8 +60,12 @@ def async_setup(hass, config): return intent_type = request['intent']['intentName'].split('__')[-1] - slots = {slot['slotName']: {'value': slot['value']['value']} - for slot in request.get('slots', [])} + 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']} try: yield from intent.async_handle( diff --git a/tests/components/test_snips.py b/tests/components/test_snips.py index 5e49bbd0382..a3e6fac0295 100644 --- a/tests/components/test_snips.py +++ b/tests/components/test_snips.py @@ -34,7 +34,7 @@ def test_snips_call_action(hass, mqtt_mock): intents = async_mock_intent(hass, 'Lights') - async_fire_mqtt_message(hass, 'hermes/nlu/intentParsed', + async_fire_mqtt_message(hass, 'hermes/intent/activateLights', EXAMPLE_MSG) yield from hass.async_block_till_done() assert len(intents) == 1 From f7c2ec19ef95db6474c7c20778c9a7907dc2d4ae Mon Sep 17 00:00:00 2001 From: Andrey Date: Fri, 8 Dec 2017 19:16:26 +0200 Subject: [PATCH 009/100] Change default js version to auto (#10999) --- 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 3d669ddc4d1..36270beede2 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -35,7 +35,7 @@ CONF_EXTRA_HTML_URL = 'extra_html_url' CONF_EXTRA_HTML_URL_ES5 = 'extra_html_url_es5' CONF_FRONTEND_REPO = 'development_repo' CONF_JS_VERSION = 'javascript_version' -JS_DEFAULT_OPTION = 'es5' +JS_DEFAULT_OPTION = 'auto' JS_OPTIONS = ['es5', 'latest', 'auto'] DEFAULT_THEME_COLOR = '#03A9F4' From 1f1115f631cb3fae99f1c68a3812e2ac79814e92 Mon Sep 17 00:00:00 2001 From: Matthew Treinish Date: Fri, 8 Dec 2017 09:18:52 -0800 Subject: [PATCH 010/100] Serialize mochad requests (#11029) All mochad devices are sharing a single socket interface. When multiple threads are issuing requests to the mochad daemon at the same time the write read cycle might get crossed between the threads. This is normally not an issue for 1-way X10 devices because as long as the request issued successfully and data is read over the socket then we know as much as mochad will tell us (since there is no ACK from the request for most X10 devices). However, where it does matter is on the device __init__() because we're relying on the mochad daemon's internal state to take an educated guess at the device's state to intialize things with. When there are multiple devices being initialized at the same time the wires can get crossed between and the wrong device state may be read. To address this potential issue this commit adds locking using a semaphore around all pairs of send_cmd() and read_data() (which is what pymochad.device.Device.get_status() does internally) calls to the mochad controller to ensure we're only ever dealing with a single request at a time. Fixes mtreinish/pymochad#4 --- homeassistant/components/light/mochad.py | 13 ++++++++----- homeassistant/components/mochad.py | 3 +++ homeassistant/components/switch/mochad.py | 13 ++++++++----- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/light/mochad.py b/homeassistant/components/light/mochad.py index fffaa293188..3d67edaf7cb 100644 --- a/homeassistant/components/light/mochad.py +++ b/homeassistant/components/light/mochad.py @@ -62,7 +62,8 @@ class MochadLight(Light): def _get_device_status(self): """Get the status of the light from mochad.""" - status = self.device.get_status().rstrip() + with mochad.REQ_LOCK: + status = self.device.get_status().rstrip() return status == 'on' @property @@ -88,12 +89,14 @@ class MochadLight(Light): def turn_on(self, **kwargs): """Send the command to turn the light on.""" self._brightness = kwargs.get(ATTR_BRIGHTNESS, 255) - self.device.send_cmd("xdim {}".format(self._brightness)) - self._controller.read_data() + with mochad.REQ_LOCK: + self.device.send_cmd("xdim {}".format(self._brightness)) + self._controller.read_data() self._state = True def turn_off(self, **kwargs): """Send the command to turn the light on.""" - self.device.send_cmd('off') - self._controller.read_data() + with mochad.REQ_LOCK: + self.device.send_cmd('off') + self._controller.read_data() self._state = False diff --git a/homeassistant/components/mochad.py b/homeassistant/components/mochad.py index 165c43f488f..3cc4eda7675 100644 --- a/homeassistant/components/mochad.py +++ b/homeassistant/components/mochad.py @@ -5,6 +5,7 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/mochad/ """ import logging +import threading import voluptuous as vol @@ -23,6 +24,8 @@ CONF_COMM_TYPE = 'comm_type' DOMAIN = 'mochad' +REQ_LOCK = threading.Lock() + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Optional(CONF_HOST, default='localhost'): cv.string, diff --git a/homeassistant/components/switch/mochad.py b/homeassistant/components/switch/mochad.py index a67b27a6a91..da8f96dc1f0 100644 --- a/homeassistant/components/switch/mochad.py +++ b/homeassistant/components/switch/mochad.py @@ -60,18 +60,21 @@ class MochadSwitch(SwitchDevice): def turn_on(self, **kwargs): """Turn the switch on.""" self._state = True - self.device.send_cmd('on') - self._controller.read_data() + with mochad.REQ_LOCK: + self.device.send_cmd('on') + self._controller.read_data() def turn_off(self, **kwargs): """Turn the switch off.""" self._state = False - self.device.send_cmd('off') - self._controller.read_data() + with mochad.REQ_LOCK: + self.device.send_cmd('off') + self._controller.read_data() def _get_device_status(self): """Get the status of the switch from mochad.""" - status = self.device.get_status().rstrip() + with mochad.REQ_LOCK: + status = self.device.get_status().rstrip() return status == 'on' @property From 20f1e1609f045a10ce80adb4d5a9ea19d84d728c Mon Sep 17 00:00:00 2001 From: Andrey Date: Sat, 9 Dec 2017 03:25:16 +0200 Subject: [PATCH 011/100] In dev mode expose only relevant sources (#11026) --- homeassistant/components/frontend/__init__.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 36270beede2..9d97a7439bd 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -299,8 +299,13 @@ def async_setup(hass, config): hass.data[DATA_JS_VERSION] = js_version = conf.get(CONF_JS_VERSION) if is_dev: - hass.http.register_static_path( - "/home-assistant-polymer", repo_path, False) + for subpath in ["src", "build-translations", "build-temp", "build", + "hass_frontend", "bower_components", "panels"]: + hass.http.register_static_path( + "/home-assistant-polymer/{}".format(subpath), + os.path.join(repo_path, subpath), + False) + hass.http.register_static_path( "/static/translations", os.path.join(repo_path, "build-translations/output"), False) From 44797611316f900b510e375461045b461052990e Mon Sep 17 00:00:00 2001 From: Andrey Kupreychik Date: Sat, 9 Dec 2017 14:18:45 +0700 Subject: [PATCH 012/100] Added force_update for REST sensor (#11016) * Added force_update for REST sensor * Linting error --- homeassistant/components/sensor/miflora.py | 4 +-- homeassistant/components/sensor/mqtt.py | 4 +-- homeassistant/components/sensor/rest.py | 25 ++++++++++++----- homeassistant/const.py | 1 + tests/components/sensor/test_rest.py | 31 +++++++++++++++------- 5 files changed, 45 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/sensor/miflora.py b/homeassistant/components/sensor/miflora.py index 063c4e8068e..349e55abb5d 100644 --- a/homeassistant/components/sensor/miflora.py +++ b/homeassistant/components/sensor/miflora.py @@ -12,7 +12,8 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv from homeassistant.const import ( - CONF_MONITORED_CONDITIONS, CONF_NAME, CONF_MAC) + CONF_FORCE_UPDATE, CONF_MONITORED_CONDITIONS, CONF_NAME, CONF_MAC +) REQUIREMENTS = ['miflora==0.1.16'] @@ -20,7 +21,6 @@ _LOGGER = logging.getLogger(__name__) CONF_ADAPTER = 'adapter' CONF_CACHE = 'cache_value' -CONF_FORCE_UPDATE = 'force_update' CONF_MEDIAN = 'median' CONF_RETRIES = 'retries' CONF_TIMEOUT = 'timeout' diff --git a/homeassistant/components/sensor/mqtt.py b/homeassistant/components/sensor/mqtt.py index 70b1294c13f..bf7de94b5d7 100644 --- a/homeassistant/components/sensor/mqtt.py +++ b/homeassistant/components/sensor/mqtt.py @@ -13,7 +13,8 @@ import voluptuous as vol from homeassistant.core import callback from homeassistant.components.mqtt import CONF_STATE_TOPIC, CONF_QOS from homeassistant.const import ( - CONF_NAME, CONF_VALUE_TEMPLATE, STATE_UNKNOWN, CONF_UNIT_OF_MEASUREMENT) + CONF_FORCE_UPDATE, CONF_NAME, CONF_VALUE_TEMPLATE, STATE_UNKNOWN, + CONF_UNIT_OF_MEASUREMENT) from homeassistant.helpers.entity import Entity import homeassistant.components.mqtt as mqtt import homeassistant.helpers.config_validation as cv @@ -22,7 +23,6 @@ from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) -CONF_FORCE_UPDATE = 'force_update' CONF_EXPIRE_AFTER = 'expire_after' DEFAULT_NAME = 'MQTT Sensor' diff --git a/homeassistant/components/sensor/rest.py b/homeassistant/components/sensor/rest.py index 86362e8f2d9..19f5a1c271e 100644 --- a/homeassistant/components/sensor/rest.py +++ b/homeassistant/components/sensor/rest.py @@ -13,10 +13,11 @@ from requests.auth import HTTPBasicAuth, HTTPDigestAuth from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_PAYLOAD, CONF_NAME, CONF_VALUE_TEMPLATE, CONF_METHOD, CONF_RESOURCE, - CONF_UNIT_OF_MEASUREMENT, STATE_UNKNOWN, CONF_VERIFY_SSL, CONF_USERNAME, - CONF_PASSWORD, CONF_AUTHENTICATION, HTTP_BASIC_AUTHENTICATION, - HTTP_DIGEST_AUTHENTICATION, CONF_HEADERS) + CONF_AUTHENTICATION, CONF_FORCE_UPDATE, CONF_HEADERS, CONF_NAME, + CONF_METHOD, CONF_PASSWORD, CONF_PAYLOAD, CONF_RESOURCE, + CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME, + CONF_VALUE_TEMPLATE, CONF_VERIFY_SSL, + HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION, STATE_UNKNOWN) from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv @@ -25,6 +26,7 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_METHOD = 'GET' DEFAULT_NAME = 'REST Sensor' DEFAULT_VERIFY_SSL = True +DEFAULT_FORCE_UPDATE = False CONF_JSON_ATTRS = 'json_attributes' METHODS = ['POST', 'GET'] @@ -43,6 +45,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_USERNAME): cv.string, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, + vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, }) @@ -59,6 +62,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): unit = config.get(CONF_UNIT_OF_MEASUREMENT) value_template = config.get(CONF_VALUE_TEMPLATE) json_attrs = config.get(CONF_JSON_ATTRS) + force_update = config.get(CONF_FORCE_UPDATE) if value_template is not None: value_template.hass = hass @@ -74,14 +78,15 @@ def setup_platform(hass, config, add_devices, discovery_info=None): rest.update() add_devices([RestSensor( - hass, rest, name, unit, value_template, json_attrs)], True) + hass, rest, name, unit, value_template, json_attrs, force_update + )], True) class RestSensor(Entity): """Implementation of a REST sensor.""" - def __init__(self, hass, rest, name, - unit_of_measurement, value_template, json_attrs): + def __init__(self, hass, rest, name, unit_of_measurement, + value_template, json_attrs, force_update): """Initialize the REST sensor.""" self._hass = hass self.rest = rest @@ -91,6 +96,7 @@ class RestSensor(Entity): self._value_template = value_template self._json_attrs = json_attrs self._attributes = None + self._force_update = force_update @property def name(self): @@ -112,6 +118,11 @@ class RestSensor(Entity): """Return the state of the device.""" return self._state + @property + def force_update(self): + """Force update.""" + return self._force_update + def update(self): """Get the latest data from REST API and update the state.""" self.rest.update() diff --git a/homeassistant/const.py b/homeassistant/const.py index 85047f0482e..4f075249e57 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -75,6 +75,7 @@ CONF_EXCLUDE = 'exclude' CONF_FILE_PATH = 'file_path' CONF_FILENAME = 'filename' CONF_FOR = 'for' +CONF_FORCE_UPDATE = 'force_update' CONF_FRIENDLY_NAME = 'friendly_name' CONF_HEADERS = 'headers' CONF_HOST = 'host' diff --git a/tests/components/sensor/test_rest.py b/tests/components/sensor/test_rest.py index 1bda8ab82f3..eddab8caf4d 100644 --- a/tests/components/sensor/test_rest.py +++ b/tests/components/sensor/test_rest.py @@ -132,10 +132,12 @@ class TestRestSensor(unittest.TestCase): self.unit_of_measurement = 'MB' self.value_template = template('{{ value_json.key }}') self.value_template.hass = self.hass + self.force_update = False - self.sensor = rest.RestSensor(self.hass, self.rest, self.name, - self.unit_of_measurement, - self.value_template, []) + self.sensor = rest.RestSensor( + self.hass, self.rest, self.name, self.unit_of_measurement, + self.value_template, [], self.force_update + ) def tearDown(self): """Stop everything that was started.""" @@ -154,6 +156,11 @@ class TestRestSensor(unittest.TestCase): self.assertEqual( self.unit_of_measurement, self.sensor.unit_of_measurement) + def test_force_update(self): + """Test the unit of measurement.""" + self.assertEqual( + self.force_update, self.sensor.force_update) + def test_state(self): """Test the initial state.""" self.sensor.update() @@ -182,7 +189,8 @@ class TestRestSensor(unittest.TestCase): side_effect=self.update_side_effect( 'plain_state')) self.sensor = rest.RestSensor(self.hass, self.rest, self.name, - self.unit_of_measurement, None, []) + self.unit_of_measurement, None, [], + self.force_update) self.sensor.update() self.assertEqual('plain_state', self.sensor.state) self.assertTrue(self.sensor.available) @@ -193,7 +201,8 @@ class TestRestSensor(unittest.TestCase): side_effect=self.update_side_effect( '{ "key": "some_json_value" }')) self.sensor = rest.RestSensor(self.hass, self.rest, self.name, - self.unit_of_measurement, None, ['key']) + self.unit_of_measurement, None, ['key'], + self.force_update) self.sensor.update() self.assertEqual('some_json_value', self.sensor.device_state_attributes['key']) @@ -205,7 +214,8 @@ class TestRestSensor(unittest.TestCase): side_effect=self.update_side_effect( '["list", "of", "things"]')) self.sensor = rest.RestSensor(self.hass, self.rest, self.name, - self.unit_of_measurement, None, ['key']) + self.unit_of_measurement, None, ['key'], + self.force_update) self.sensor.update() self.assertEqual({}, self.sensor.device_state_attributes) self.assertTrue(mock_logger.warning.called) @@ -217,7 +227,8 @@ class TestRestSensor(unittest.TestCase): side_effect=self.update_side_effect( 'This is text rather than JSON data.')) self.sensor = rest.RestSensor(self.hass, self.rest, self.name, - self.unit_of_measurement, None, ['key']) + self.unit_of_measurement, None, ['key'], + self.force_update) self.sensor.update() self.assertEqual({}, self.sensor.device_state_attributes) self.assertTrue(mock_logger.warning.called) @@ -230,12 +241,14 @@ class TestRestSensor(unittest.TestCase): '{ "key": "json_state_updated_value" }')) self.sensor = rest.RestSensor(self.hass, self.rest, self.name, self.unit_of_measurement, - self.value_template, ['key']) + self.value_template, ['key'], + self.force_update) self.sensor.update() self.assertEqual('json_state_updated_value', self.sensor.state) self.assertEqual('json_state_updated_value', - self.sensor.device_state_attributes['key']) + self.sensor.device_state_attributes['key'], + self.force_update) class TestRestData(unittest.TestCase): From bee80c5b797d8b80a47e193bca879199d34dd1d6 Mon Sep 17 00:00:00 2001 From: GreenTurtwig Date: Sat, 9 Dec 2017 19:01:23 +0000 Subject: [PATCH 013/100] Add support for Logitech UE Smart Radios. (#10077) * Add support for Logitech UE Smart Radios. * Removed full stops to please Hound's line limit. * Updated with requested changes. * Fix Pylint Flake8 problem. * Updated with requested changes. --- .coveragerc | 1 + .../components/media_player/ue_smart_radio.py | 207 ++++++++++++++++++ 2 files changed, 208 insertions(+) create mode 100644 homeassistant/components/media_player/ue_smart_radio.py diff --git a/.coveragerc b/.coveragerc index 9db732dfbde..c4b003708d5 100644 --- a/.coveragerc +++ b/.coveragerc @@ -427,6 +427,7 @@ omit = homeassistant/components/media_player/sonos.py homeassistant/components/media_player/spotify.py homeassistant/components/media_player/squeezebox.py + homeassistant/components/media_player/ue_smart_radio.py homeassistant/components/media_player/vizio.py homeassistant/components/media_player/vlc.py homeassistant/components/media_player/volumio.py diff --git a/homeassistant/components/media_player/ue_smart_radio.py b/homeassistant/components/media_player/ue_smart_radio.py new file mode 100644 index 00000000000..2684a819417 --- /dev/null +++ b/homeassistant/components/media_player/ue_smart_radio.py @@ -0,0 +1,207 @@ +""" +Support for Logitech UE Smart Radios. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.ue_smart_radio/ +""" + +import logging +import voluptuous as vol +import requests + +from homeassistant.components.media_player import ( + MediaPlayerDevice, MEDIA_TYPE_MUSIC, PLATFORM_SCHEMA, + SUPPORT_PLAY, SUPPORT_PAUSE, SUPPORT_STOP, SUPPORT_PREVIOUS_TRACK, + SUPPORT_NEXT_TRACK, SUPPORT_TURN_ON, SUPPORT_TURN_OFF, SUPPORT_VOLUME_SET, + SUPPORT_VOLUME_MUTE) +from homeassistant.const import ( + CONF_USERNAME, CONF_PASSWORD, STATE_OFF, STATE_IDLE, STATE_PLAYING, + STATE_PAUSED) +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +ICON = "mdi:radio" +URL = "http://decibel.logitechmusic.com/jsonrpc.js" + +SUPPORT_UE_SMART_RADIO = SUPPORT_PLAY | SUPPORT_PAUSE | SUPPORT_STOP | \ + SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_TURN_ON | \ + SUPPORT_TURN_OFF | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE + +PLAYBACK_DICT = {"play": STATE_PLAYING, + "pause": STATE_PAUSED, + "stop": STATE_IDLE} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, +}) + + +def send_request(payload, session): + """Send request to radio.""" + try: + request = requests.post(URL, + cookies={"sdi_squeezenetwork_session": + session}, + json=payload, timeout=5) + except requests.exceptions.Timeout: + _LOGGER.error("Timed out when sending request") + except requests.exceptions.ConnectionError: + _LOGGER.error("An error occurred while connecting") + else: + return request.json() + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Logitech UE Smart Radio platform.""" + email = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + + session_request = requests.post("https://www.uesmartradio.com/user/login", + data={"email": email, "password": + password}) + session = session_request.cookies["sdi_squeezenetwork_session"] + + player_request = send_request({"params": ["", ["serverstatus"]]}, session) + player_id = player_request["result"]["players_loop"][0]["playerid"] + player_name = player_request["result"]["players_loop"][0]["name"] + + add_devices([UERadioDevice(session, player_id, player_name)]) + + +class UERadioDevice(MediaPlayerDevice): + """Representation of a Logitech UE Smart Radio device.""" + + def __init__(self, session, player_id, player_name): + """Initialize the Logitech UE Smart Radio device.""" + self._session = session + self._player_id = player_id + self._name = player_name + self._state = None + self._volume = 0 + self._last_volume = 0 + self._media_title = None + self._media_artist = None + self._media_artwork_url = None + + def send_command(self, command): + """Send command to radio.""" + send_request({"method": "slim.request", "params": + [self._player_id, command]}, self._session) + + def update(self): + """Get the latest details from the device.""" + request = send_request({ + "method": "slim.request", "params": + [self._player_id, ["status", "-", 1, + "tags:cgABbehldiqtyrSuoKLN"]]}, self._session) + + if request["error"] is not None: + self._state = None + return + + if request["result"]["power"] == 0: + self._state = STATE_OFF + else: + self._state = PLAYBACK_DICT[request["result"]["mode"]] + + media_info = request["result"]["playlist_loop"][0] + + self._volume = request["result"]["mixer volume"] / 100 + self._media_artwork_url = media_info["artwork_url"] + self._media_title = media_info["title"] + if "artist" in media_info: + self._media_artist = media_info["artist"] + else: + self._media_artist = media_info.get("remote_title") + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return ICON + + @property + def is_volume_muted(self): + """Boolean if volume is currently muted.""" + return True if self._volume <= 0 else False + + @property + def volume_level(self): + """Volume level of the media player (0..1).""" + return self._volume + + @property + def supported_features(self): + """Flag of features that are supported.""" + return SUPPORT_UE_SMART_RADIO + + @property + def media_content_type(self): + """Return the media content type.""" + return MEDIA_TYPE_MUSIC + + @property + def media_image_url(self): + """Image URL of current playing media.""" + return self._media_artwork_url + + @property + def media_artist(self): + """Artist of current playing media, music track only.""" + return self._media_artist + + @property + def media_title(self): + """Title of current playing media.""" + return self._media_title + + def turn_on(self): + """Turn on specified media player or all.""" + self.send_command(["power", 1]) + + def turn_off(self): + """Turn off specified media player or all.""" + self.send_command(["power", 0]) + + def media_play(self): + """Send the media player the command for play/pause.""" + self.send_command(["play"]) + + def media_pause(self): + """Send the media player the command for pause.""" + self.send_command(["pause"]) + + def media_stop(self): + """Send the media player the stop command.""" + self.send_command(["stop"]) + + def media_previous_track(self): + """Send the media player the command for prev track.""" + self.send_command(["button", "rew"]) + + def media_next_track(self): + """Send the media player the command for next track.""" + self.send_command(["button", "fwd"]) + + def mute_volume(self, mute): + """Send mute command.""" + if mute: + self._last_volume = self._volume + self.send_command(["mixer", "volume", 0]) + else: + self.send_command(["mixer", "volume", self._last_volume * 100]) + + def set_volume_level(self, volume): + """Set volume level, range 0..1.""" + self.send_command(["mixer", "volume", volume * 100]) From cb4e886a4ff24d62d74ca444c8b14333667b1b55 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 9 Dec 2017 13:14:16 -0800 Subject: [PATCH 014/100] Make notify.html5 depend on config (#11052) --- homeassistant/components/notify/html5.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/notify/html5.py b/homeassistant/components/notify/html5.py index 2314722a2ab..fb3cf0bbecd 100644 --- a/homeassistant/components/notify/html5.py +++ b/homeassistant/components/notify/html5.py @@ -29,7 +29,7 @@ from homeassistant.util import ensure_unique_string REQUIREMENTS = ['pywebpush==1.3.0', 'PyJWT==1.5.3'] -DEPENDENCIES = ['frontend'] +DEPENDENCIES = ['frontend', 'config'] _LOGGER = logging.getLogger(__name__) From 4e91e6d103a2bc3922300737775f59749eecb263 Mon Sep 17 00:00:00 2001 From: tringler Date: Sun, 10 Dec 2017 01:58:52 +0100 Subject: [PATCH 015/100] This change fixes the error `OSError: [WinError 193]` on Windows debuggers (i.e. PyCharm) (#11034) --- homeassistant/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index a8852b910c2..b7301e13bea 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -230,7 +230,7 @@ def closefds_osx(min_fd: int, max_fd: int) -> None: def cmdline() -> List[str]: """Collect path and arguments to re-execute the current hass instance.""" - if sys.argv[0].endswith(os.path.sep + '__main__.py'): + if os.path.basename(sys.argv[0]) == '__main__.py': modulepath = os.path.dirname(sys.argv[0]) os.environ['PYTHONPATH'] = os.path.dirname(modulepath) return [sys.executable] + [arg for arg in sys.argv if From 3b228c78c001c28f309e839ecdd9df6a0eb30957 Mon Sep 17 00:00:00 2001 From: perfalk <33936236+perfalk@users.noreply.github.com> Date: Sun, 10 Dec 2017 17:35:10 +0100 Subject: [PATCH 016/100] Added support for cover in tellstick (#10858) * Added support for cover in tellstick * Fixed comments from PR * Fixed comments from PR * Address comments --- homeassistant/components/cover/tellstick.py | 65 +++++++++++++++++++++ homeassistant/components/tellstick.py | 18 ++++-- 2 files changed, 77 insertions(+), 6 deletions(-) create mode 100755 homeassistant/components/cover/tellstick.py diff --git a/homeassistant/components/cover/tellstick.py b/homeassistant/components/cover/tellstick.py new file mode 100755 index 00000000000..56a5a24b409 --- /dev/null +++ b/homeassistant/components/cover/tellstick.py @@ -0,0 +1,65 @@ +""" +Support for Tellstick covers. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/cover.tellstick/ +""" + + +from homeassistant.components.cover import CoverDevice +from homeassistant.components.tellstick import ( + DEFAULT_SIGNAL_REPETITIONS, ATTR_DISCOVER_DEVICES, ATTR_DISCOVER_CONFIG, + DATA_TELLSTICK, TellstickDevice) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Tellstick covers.""" + if (discovery_info is None or + discovery_info[ATTR_DISCOVER_DEVICES] is None): + return + + signal_repetitions = discovery_info.get( + ATTR_DISCOVER_CONFIG, DEFAULT_SIGNAL_REPETITIONS) + + add_devices([TellstickCover(hass.data[DATA_TELLSTICK][tellcore_id], + signal_repetitions) + for tellcore_id in discovery_info[ATTR_DISCOVER_DEVICES]], + True) + + +class TellstickCover(TellstickDevice, CoverDevice): + """Representation of a Tellstick cover.""" + + @property + def is_closed(self): + """Return the current position of the cover is not possible.""" + return None + + @property + def assumed_state(self): + """Return True if unable to access real state of the entity.""" + return True + + def close_cover(self, **kwargs): + """Close the cover.""" + self._tellcore_device.down() + + def open_cover(self, **kwargs): + """Open the cover.""" + self._tellcore_device.up() + + def stop_cover(self, **kwargs): + """Stop the cover.""" + self._tellcore_device.stop() + + def _parse_tellcore_data(self, tellcore_data): + """Turn the value received from tellcore into something useful.""" + pass + + def _parse_ha_data(self, kwargs): + """Turn the value from HA into something useful.""" + pass + + def _update_model(self, new_state, data): + """Update the device entity state to match the arguments.""" + pass diff --git a/homeassistant/components/tellstick.py b/homeassistant/components/tellstick.py index bcef0d3fb85..9746dbf749f 100644 --- a/homeassistant/components/tellstick.py +++ b/homeassistant/components/tellstick.py @@ -67,7 +67,7 @@ def _discover(hass, config, component_name, found_tellcore_devices): def setup(hass, config): """Set up the Tellstick component.""" - from tellcore.constants import TELLSTICK_DIM + from tellcore.constants import (TELLSTICK_DIM, TELLSTICK_UP) from tellcore.telldus import AsyncioCallbackDispatcher from tellcore.telldus import TelldusCore from tellcorenet import TellCoreClient @@ -102,16 +102,22 @@ def setup(hass, config): hass.data[DATA_TELLSTICK] = {device.id: device for device in tellcore_devices} - # Discover the switches - _discover(hass, config, 'switch', - [device.id for device in tellcore_devices - if not device.methods(TELLSTICK_DIM)]) - # Discover the lights _discover(hass, config, 'light', [device.id for device in tellcore_devices if device.methods(TELLSTICK_DIM)]) + # Discover the cover + _discover(hass, config, 'cover', + [device.id for device in tellcore_devices + if device.methods(TELLSTICK_UP)]) + + # Discover the switches + _discover(hass, config, 'switch', + [device.id for device in tellcore_devices + if (not device.methods(TELLSTICK_UP) and + not device.methods(TELLSTICK_DIM))]) + @callback def async_handle_callback(tellcore_id, tellcore_command, tellcore_data, cid): From 04cb893d10c623d9d6d1864f677debbae4adeb68 Mon Sep 17 00:00:00 2001 From: maxlaverse Date: Sun, 10 Dec 2017 17:44:28 +0100 Subject: [PATCH 017/100] Add a caldav calendar component (#10842) * Add caldav component * Code review - 1 * Code review - 2 * Sort imports --- .coveragerc | 1 + homeassistant/components/calendar/caldav.py | 230 +++++++++++++++ requirements_all.txt | 3 + requirements_test_all.txt | 3 + script/gen_requirements_all.py | 1 + tests/components/calendar/test_caldav.py | 302 ++++++++++++++++++++ 6 files changed, 540 insertions(+) create mode 100644 homeassistant/components/calendar/caldav.py create mode 100644 tests/components/calendar/test_caldav.py diff --git a/.coveragerc b/.coveragerc index c4b003708d5..b73d847f431 100644 --- a/.coveragerc +++ b/.coveragerc @@ -284,6 +284,7 @@ omit = homeassistant/components/binary_sensor/rest.py homeassistant/components/binary_sensor/tapsaff.py homeassistant/components/browser.py + homeassistant/components/calendar/caldav.py homeassistant/components/calendar/todoist.py homeassistant/components/camera/bloomsky.py homeassistant/components/camera/canary.py diff --git a/homeassistant/components/calendar/caldav.py b/homeassistant/components/calendar/caldav.py new file mode 100644 index 00000000000..1647b9522b8 --- /dev/null +++ b/homeassistant/components/calendar/caldav.py @@ -0,0 +1,230 @@ +""" +Support for WebDav Calendar. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/calendar.caldav/ +""" +import logging +import re +from datetime import datetime, timedelta + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.calendar import ( + CalendarEventDevice, PLATFORM_SCHEMA) +from homeassistant.const import ( + CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAME) +from homeassistant.util import dt, Throttle + +REQUIREMENTS = ['caldav==0.5.0'] + +_LOGGER = logging.getLogger(__name__) + +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({ + vol.Required(CONF_URL): vol.Url, + vol.Optional(CONF_CALENDARS, default=[]): + vol.All(cv.ensure_list, vol.Schema([ + cv.string + ])), + vol.Inclusive(CONF_USERNAME, 'authentication'): cv.string, + vol.Inclusive(CONF_PASSWORD, 'authentication'): cv.string, + vol.Optional(CONF_CUSTOM_CALENDARS, default=[]): + vol.All(cv.ensure_list, vol.Schema([ + vol.Schema({ + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_CALENDAR): cv.string, + vol.Required(CONF_SEARCH): cv.string + }) + ])) +}) + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) + + +def setup_platform(hass, config, add_devices, disc_info=None): + """Set up the WebDav Calendar platform.""" + import caldav + + client = caldav.DAVClient(config.get(CONF_URL), + None, + config.get(CONF_USERNAME), + config.get(CONF_PASSWORD)) + + # Retrieve all the remote calendars + calendars = client.principal().calendars() + + calendar_devices = [] + for calendar in list(calendars): + # If a calendar name was given in the configuration, + # ignore all the others + if (config.get(CONF_CALENDARS) + and calendar.name not in config.get(CONF_CALENDARS)): + _LOGGER.debug("Ignoring calendar '%s'", calendar.name) + continue + + # Create additional calendars based on custom filtering + # rules + for cust_calendar in config.get(CONF_CUSTOM_CALENDARS): + # Check that the base calendar matches + if cust_calendar.get(CONF_CALENDAR) != calendar.name: + continue + + device_data = { + CONF_NAME: cust_calendar.get(CONF_NAME), + CONF_DEVICE_ID: "{} {}".format( + cust_calendar.get(CONF_CALENDAR), + cust_calendar.get(CONF_NAME)), + } + + calendar_devices.append( + WebDavCalendarEventDevice(hass, + device_data, + calendar, + cust_calendar.get(CONF_ALL_DAY), + cust_calendar.get(CONF_SEARCH)) + ) + + # Create a default calendar if there was no custom one + if not config.get(CONF_CUSTOM_CALENDARS): + device_data = { + CONF_NAME: calendar.name, + CONF_DEVICE_ID: calendar.name + } + calendar_devices.append( + WebDavCalendarEventDevice(hass, device_data, calendar) + ) + + # Finally add all the calendars we've created + add_devices(calendar_devices) + + +class WebDavCalendarEventDevice(CalendarEventDevice): + """A device for getting the next Task from a WebDav Calendar.""" + + def __init__(self, + hass, + device_data, + calendar, + all_day=False, + search=None): + """Create the WebDav Calendar Event Device.""" + self.data = WebDavCalendarData(calendar, all_day, search) + super().__init__(hass, device_data) + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + if self.data.event is None: + # No tasks, we don't REALLY need to show anything. + return {} + + attributes = super().device_state_attributes + return attributes + + +class WebDavCalendarData(object): + """Class to utilize the calendar dav client object to get next event.""" + + def __init__(self, calendar, include_all_day, search): + """Set up how we are going to search the WebDav calendar.""" + self.calendar = calendar + self.include_all_day = include_all_day + self.search = search + self.event = None + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data.""" + # We have to retrieve the results for the whole day as the server + # won't return events that have already started + results = self.calendar.date_search( + dt.start_of_local_day(), + dt.start_of_local_day() + timedelta(days=1) + ) + + # dtstart can be a date or datetime depending if the event lasts a + # whole day. Convert everything to datetime to be able to sort it + results.sort(key=lambda x: self.to_datetime( + x.instance.vevent.dtstart.value + )) + + vevent = next(( + event.instance.vevent for event in results + if (self.is_matching(event.instance.vevent, self.search) + and (not self.is_all_day(event.instance.vevent) + or self.include_all_day) + and not self.is_over(event.instance.vevent))), None) + + # If no matching event could be found + if vevent is None: + _LOGGER.debug( + "No matching event found in the %d results for %s", + len(results), + self.calendar.name, + ) + self.event = None + return True + + # Populate the entity attributes with the event values + self.event = { + "summary": vevent.summary.value, + "start": self.get_hass_date(vevent.dtstart.value), + "end": self.get_hass_date(vevent.dtend.value), + "location": self.get_attr_value(vevent, "location"), + "description": self.get_attr_value(vevent, "description") + } + return True + + @staticmethod + def is_matching(vevent, search): + """Return if the event matches the filter critera.""" + if search is None: + return True + + pattern = re.compile(search) + return (hasattr(vevent, "summary") + and pattern.match(vevent.summary.value) + or hasattr(vevent, "location") + and pattern.match(vevent.location.value) + or hasattr(vevent, "description") + and pattern.match(vevent.description.value)) + + @staticmethod + def is_all_day(vevent): + """Return if the event last the whole day.""" + return not isinstance(vevent.dtstart.value, datetime) + + @staticmethod + def is_over(vevent): + """Return if the event is over.""" + return dt.now() > WebDavCalendarData.to_datetime(vevent.dtend.value) + + @staticmethod + def get_hass_date(obj): + """Return if the event matches.""" + if isinstance(obj, datetime): + return {"dateTime": obj.isoformat()} + + return {"date": obj.isoformat()} + + @staticmethod + def to_datetime(obj): + """Return a datetime.""" + if isinstance(obj, datetime): + return obj + return dt.as_local(dt.dt.datetime.combine(obj, dt.dt.time.min)) + + @staticmethod + def get_attr_value(obj, attribute): + """Return the value of the attribute if defined.""" + if hasattr(obj, attribute): + return getattr(obj, attribute).value + return None diff --git a/requirements_all.txt b/requirements_all.txt index bde26c64846..f6655d06baa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -160,6 +160,9 @@ broadlink==0.5 # homeassistant.components.weather.buienradar buienradar==0.9 +# homeassistant.components.calendar.caldav +caldav==0.5.0 + # homeassistant.components.notify.ciscospark ciscosparkapi==0.4.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c932ce7ead9..6e1b617ef66 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -36,6 +36,9 @@ aiohttp_cors==0.5.3 # homeassistant.components.notify.apns apns2==0.3.0 +# homeassistant.components.calendar.caldav +caldav==0.5.0 + # homeassistant.components.sensor.coinmarketcap coinmarketcap==4.1.1 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index bdc75f3a69c..0bfb5f9e607 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -38,6 +38,7 @@ TEST_REQUIREMENTS = ( 'aioautomatic', 'aiohttp_cors', 'apns2', + 'caldav', 'coinmarketcap', 'defusedxml', 'dsmr_parser', diff --git a/tests/components/calendar/test_caldav.py b/tests/components/calendar/test_caldav.py new file mode 100644 index 00000000000..8a44f96fe87 --- /dev/null +++ b/tests/components/calendar/test_caldav.py @@ -0,0 +1,302 @@ +"""The tests for the webdav calendar component.""" +# pylint: disable=protected-access +import datetime +import logging +import unittest +from unittest.mock import (patch, Mock, MagicMock) + +import homeassistant.components.calendar as calendar_base +import homeassistant.components.calendar.caldav as caldav +from caldav.objects import Event +from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON +from homeassistant.util import dt +from tests.common import get_test_home_assistant + +TEST_PLATFORM = {calendar_base.DOMAIN: {CONF_PLATFORM: 'test'}} + +_LOGGER = logging.getLogger(__name__) + + +DEVICE_DATA = { + "name": "Private Calendar", + "device_id": "Private Calendar" +} + +EVENTS = [ + """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//E-Corp.//CalDAV Client//EN +BEGIN:VEVENT +UID:1 +DTSTAMP:20171125T000000Z +DTSTART:20171127T170000Z +DTEND:20171127T180000Z +SUMMARY:This is a normal event +LOCATION:Hamburg +DESCRIPTION:Surprisingly rainy +END:VEVENT +END:VCALENDAR +""", + """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Global Dynamics.//CalDAV Client//EN +BEGIN:VEVENT +UID:2 +DTSTAMP:20171125T000000Z +DTSTART:20171127T100000Z +DTEND:20171127T110000Z +SUMMARY:This is an offset event !!-02:00 +LOCATION:Hamburg +DESCRIPTION:Surprisingly shiny +END:VEVENT +END:VCALENDAR +""", + """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Global Corp.//CalDAV Client//EN +BEGIN:VEVENT +UID:3 +DTSTAMP:20171125T000000Z +DTSTART:20171127 +DTEND:20171128 +SUMMARY:This is an all day event +LOCATION:Hamburg +DESCRIPTION:What a beautiful day +END:VEVENT +END:VCALENDAR +""" +] + + +def _local_datetime(hours, minutes): + """Build a datetime object for testing in the correct timezone.""" + return dt.as_local(datetime.datetime(2017, 11, 27, hours, minutes, 0)) + + +def _mocked_dav_client(*args, **kwargs): + """Mock requests.get invocations.""" + calendars = [ + _mock_calendar("First"), + _mock_calendar("Second") + ] + principal = Mock() + principal.calendars = MagicMock(return_value=calendars) + + client = Mock() + client.principal = MagicMock(return_value=principal) + return client + + +def _mock_calendar(name): + events = [] + for idx, event in enumerate(EVENTS): + events.append(Event(None, "%d.ics" % idx, event, None, str(idx))) + + calendar = Mock() + calendar.date_search = MagicMock(return_value=events) + calendar.name = name + return calendar + + +class TestComponentsWebDavCalendar(unittest.TestCase): + """Test the WebDav calendar.""" + + hass = None # HomeAssistant + + # pylint: disable=invalid-name + def setUp(self): + """Set up things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.calendar = _mock_calendar("Private") + + # pylint: disable=invalid-name + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + @patch('caldav.DAVClient', side_effect=_mocked_dav_client) + def test_setup_component(self, req_mock): + """Test setup component with calendars.""" + def _add_device(devices): + assert len(devices) == 2 + assert devices[0].name == "First" + assert devices[0].dev_id == "First" + assert devices[1].name == "Second" + assert devices[1].dev_id == "Second" + + caldav.setup_platform(self.hass, + { + "url": "http://test.local", + "custom_calendars": [] + }, + _add_device) + + @patch('caldav.DAVClient', side_effect=_mocked_dav_client) + def test_setup_component_with_no_calendar_matching(self, req_mock): + """Test setup component with wrong calendar.""" + def _add_device(devices): + assert not devices + + caldav.setup_platform(self.hass, + { + "url": "http://test.local", + "calendars": ["none"], + "custom_calendars": [] + }, + _add_device) + + @patch('caldav.DAVClient', side_effect=_mocked_dav_client) + def test_setup_component_with_a_calendar_match(self, req_mock): + """Test setup component with right calendar.""" + def _add_device(devices): + assert len(devices) == 1 + assert devices[0].name == "Second" + + caldav.setup_platform(self.hass, + { + "url": "http://test.local", + "calendars": ["Second"], + "custom_calendars": [] + }, + _add_device) + + @patch('caldav.DAVClient', side_effect=_mocked_dav_client) + def test_setup_component_with_one_custom_calendar(self, req_mock): + """Test setup component with custom calendars.""" + def _add_device(devices): + assert len(devices) == 1 + assert devices[0].name == "HomeOffice" + assert devices[0].dev_id == "Second HomeOffice" + + caldav.setup_platform(self.hass, + { + "url": "http://test.local", + "custom_calendars": [ + { + "name": "HomeOffice", + "calendar": "Second", + "filter": "HomeOffice" + }] + }, + _add_device) + + @patch('homeassistant.util.dt.now', return_value=_local_datetime(17, 30)) + def test_ongoing_event(self, mock_now): + """Test that the ongoing event is returned.""" + cal = caldav.WebDavCalendarEventDevice(self.hass, + DEVICE_DATA, + self.calendar) + + self.assertEqual(cal.name, DEVICE_DATA["name"]) + self.assertEqual(cal.state, STATE_ON) + self.assertEqual(cal.device_state_attributes, { + "message": "This is a normal event", + "all_day": False, + "offset_reached": False, + "start_time": "2017-11-27 17:00:00", + "end_time": "2017-11-27 18:00:00", + "location": "Hamburg", + "description": "Surprisingly rainy" + }) + + @patch('homeassistant.util.dt.now', return_value=_local_datetime(8, 30)) + def test_ongoing_event_with_offset(self, mock_now): + """Test that the offset is taken into account.""" + cal = caldav.WebDavCalendarEventDevice(self.hass, + DEVICE_DATA, + self.calendar) + + self.assertEqual(cal.state, STATE_OFF) + self.assertEqual(cal.device_state_attributes, { + "message": "This is an offset event", + "all_day": False, + "offset_reached": True, + "start_time": "2017-11-27 10:00:00", + "end_time": "2017-11-27 11:00:00", + "location": "Hamburg", + "description": "Surprisingly shiny" + }) + + @patch('homeassistant.util.dt.now', return_value=_local_datetime(12, 00)) + def test_matching_filter(self, mock_now): + """Test that the matching event is returned.""" + cal = caldav.WebDavCalendarEventDevice(self.hass, + DEVICE_DATA, + self.calendar, + False, + "This is a normal event") + + self.assertEqual(cal.state, STATE_OFF) + self.assertFalse(cal.offset_reached()) + self.assertEqual(cal.device_state_attributes, { + "message": "This is a normal event", + "all_day": False, + "offset_reached": False, + "start_time": "2017-11-27 17:00:00", + "end_time": "2017-11-27 18:00:00", + "location": "Hamburg", + "description": "Surprisingly rainy" + }) + + @patch('homeassistant.util.dt.now', return_value=_local_datetime(12, 00)) + def test_matching_filter_real_regexp(self, mock_now): + """Test that the event matching the regexp is returned.""" + cal = caldav.WebDavCalendarEventDevice(self.hass, + DEVICE_DATA, + self.calendar, + False, + "^This.*event") + + self.assertEqual(cal.state, STATE_OFF) + self.assertFalse(cal.offset_reached()) + self.assertEqual(cal.device_state_attributes, { + "message": "This is a normal event", + "all_day": False, + "offset_reached": False, + "start_time": "2017-11-27 17:00:00", + "end_time": "2017-11-27 18:00:00", + "location": "Hamburg", + "description": "Surprisingly rainy" + }) + + @patch('homeassistant.util.dt.now', return_value=_local_datetime(20, 00)) + def test_filter_matching_past_event(self, mock_now): + """Test that the matching past event is not returned.""" + cal = caldav.WebDavCalendarEventDevice(self.hass, + DEVICE_DATA, + self.calendar, + False, + "This is a normal event") + + self.assertEqual(cal.data.event, None) + + @patch('homeassistant.util.dt.now', return_value=_local_datetime(12, 00)) + def test_no_result_with_filtering(self, mock_now): + """Test that nothing is returned since nothing matches.""" + cal = caldav.WebDavCalendarEventDevice(self.hass, + DEVICE_DATA, + self.calendar, + False, + "This is a non-existing event") + + self.assertEqual(cal.data.event, None) + + @patch('homeassistant.util.dt.now', return_value=_local_datetime(17, 30)) + def test_all_day_event_returned(self, mock_now): + """Test that the event lasting the whole day is returned.""" + cal = caldav.WebDavCalendarEventDevice(self.hass, + DEVICE_DATA, + self.calendar, + True) + + self.assertEqual(cal.name, DEVICE_DATA["name"]) + self.assertEqual(cal.state, STATE_ON) + self.assertEqual(cal.device_state_attributes, { + "message": "This is an all day event", + "all_day": True, + "offset_reached": False, + "start_time": "2017-11-27 00:00:00", + "end_time": "2017-11-28 00:00:00", + "location": "Hamburg", + "description": "What a beautiful day" + }) From b2c5a9f5feea5a129dac2c5b05b4a2c11956dcc6 Mon Sep 17 00:00:00 2001 From: Adde Lovein Date: Sun, 10 Dec 2017 18:47:14 +0100 Subject: [PATCH 018/100] Add GPS coords to meraki (#10998) --- .../components/device_tracker/meraki.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/homeassistant/components/device_tracker/meraki.py b/homeassistant/components/device_tracker/meraki.py index 319c19d7b73..9437486a0aa 100644 --- a/homeassistant/components/device_tracker/meraki.py +++ b/homeassistant/components/device_tracker/meraki.py @@ -94,8 +94,26 @@ class MerakiView(HomeAssistantView): def _handle(self, hass, data): for i in data["data"]["observations"]: data["data"]["secret"] = "hidden" + + lat = i["location"]["lat"] + lng = i["location"]["lng"] + try: + accuracy = int(float(i["location"]["unc"])) + except ValueError: + accuracy = 0 + mac = i["clientMac"] _LOGGER.debug("clientMac: %s", mac) + + if lat == "NaN" or lng == "NaN": + _LOGGER.debug( + "No coordinates received, skipping location for: " + mac + ) + gps_location = None + accuracy = None + else: + gps_location = (lat, lng) + attrs = {} if i.get('os', False): attrs['os'] = i['os'] @@ -110,7 +128,9 @@ class MerakiView(HomeAssistantView): if i.get('ssid', False): attrs['ssid'] = i['ssid'] hass.async_add_job(self.async_see( + gps=gps_location, mac=mac, source_type=SOURCE_TYPE_ROUTER, + gps_accuracy=accuracy, attributes=attrs )) From 81974885ee49e4376272f8cee665831544275797 Mon Sep 17 00:00:00 2001 From: Andrea Campi Date: Sun, 10 Dec 2017 18:15:01 +0000 Subject: [PATCH 019/100] Refactor hue to split bridge support from light platform (#10691) * Introduce a new Hue component that knows how to talk to a Hue bridge, but doesn't actually set up lights. * Refactor the hue lights platform to use the HueBridge class from the hue component. * Reimplement support for multiple bridges * Auto discover bridges. * Provide some migration support by showing a persistent notification. * Address most feedback from code review. * Call load_platform from inside HueBridge.setup passing the bridge id. Not only this looks nicer, but it also nicely solves additional bridges being added after initial setup (e.g. pairing a second bridge should work now, I believe it required a restart before). * Add a unit test for hue_activate_scene * Address feedback from code review. * After feedback from @andrey-git I was able to find a way to not import phue in tests, yay! * Inject a mock phue in a couple of places --- homeassistant/components/discovery.py | 3 +- homeassistant/components/hue.py | 241 +++++++++++++ homeassistant/components/light/hue.py | 358 ++++++++----------- requirements_all.txt | 2 +- tests/components/light/test_hue.py | 479 ++++++++++++++++++++++++++ tests/components/test_hue.py | 402 +++++++++++++++++++++ 6 files changed, 1269 insertions(+), 216 deletions(-) create mode 100644 homeassistant/components/hue.py create mode 100644 tests/components/light/test_hue.py create mode 100644 tests/components/test_hue.py diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 5d362f21cef..dde33aa10a2 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -36,6 +36,7 @@ SERVICE_APPLE_TV = 'apple_tv' SERVICE_WINK = 'wink' SERVICE_XIAOMI_GW = 'xiaomi_gw' SERVICE_TELLDUSLIVE = 'tellstick' +SERVICE_HUE = 'philips_hue' SERVICE_HANDLERS = { SERVICE_HASS_IOS_APP: ('ios', None), @@ -48,7 +49,7 @@ SERVICE_HANDLERS = { SERVICE_WINK: ('wink', None), SERVICE_XIAOMI_GW: ('xiaomi_aqara', None), SERVICE_TELLDUSLIVE: ('tellduslive', None), - 'philips_hue': ('light', 'hue'), + SERVICE_HUE: ('hue', None), 'google_cast': ('media_player', 'cast'), 'panasonic_viera': ('media_player', 'panasonic_viera'), 'plex_mediaserver': ('media_player', 'plex'), diff --git a/homeassistant/components/hue.py b/homeassistant/components/hue.py new file mode 100644 index 00000000000..778dcc8dfab --- /dev/null +++ b/homeassistant/components/hue.py @@ -0,0 +1,241 @@ +""" +This component provides basic support for the Philips Hue system. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/hue/ +""" +import json +import logging +import os +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 + +REQUIREMENTS = ['phue==1.0'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "hue" +SERVICE_HUE_SCENE = "hue_activate_scene" + +CONF_BRIDGES = "bridges" + +CONF_ALLOW_UNREACHABLE = 'allow_unreachable' +DEFAULT_ALLOW_UNREACHABLE = False + +PHUE_CONFIG_FILE = 'phue.conf' + +CONF_ALLOW_IN_EMULATED_HUE = "allow_in_emulated_hue" +DEFAULT_ALLOW_IN_EMULATED_HUE = True + +CONF_ALLOW_HUE_GROUPS = "allow_hue_groups" +DEFAULT_ALLOW_HUE_GROUPS = True + +BRIDGE_CONFIG_SCHEMA = vol.Schema([{ + vol.Optional(CONF_HOST): cv.string, + vol.Optional(CONF_FILENAME, default=PHUE_CONFIG_FILE): cv.string, + vol.Optional(CONF_ALLOW_UNREACHABLE, + default=DEFAULT_ALLOW_UNREACHABLE): cv.boolean, + vol.Optional(CONF_ALLOW_IN_EMULATED_HUE, + default=DEFAULT_ALLOW_IN_EMULATED_HUE): cv.boolean, + vol.Optional(CONF_ALLOW_HUE_GROUPS, + default=DEFAULT_ALLOW_HUE_GROUPS): cv.boolean, +}]) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_BRIDGES, default=[]): BRIDGE_CONFIG_SCHEMA, + }), +}, extra=vol.ALLOW_EXTRA) + +ATTR_GROUP_NAME = "group_name" +ATTR_SCENE_NAME = "scene_name" +SCENE_SCHEMA = vol.Schema({ + vol.Required(ATTR_GROUP_NAME): cv.string, + vol.Required(ATTR_SCENE_NAME): cv.string, +}) + +CONFIG_INSTRUCTIONS = """ +Press the button on the bridge to register Philips Hue with Home Assistant. + +![Location of button on bridge](/static/images/config_philips_hue.jpg) +""" + + +def setup(hass, config): + """Set up the Hue platform.""" + config = config.get(DOMAIN) + if config is None: + config = {} + + if DOMAIN not in hass.data: + hass.data[DOMAIN] = {} + + discovery.listen( + hass, + SERVICE_HUE, + lambda service, discovery_info: + bridge_discovered(hass, service, discovery_info)) + + bridges = config.get(CONF_BRIDGES, []) + for bridge in bridges: + filename = bridge.get(CONF_FILENAME) + allow_unreachable = bridge.get(CONF_ALLOW_UNREACHABLE) + allow_in_emulated_hue = bridge.get(CONF_ALLOW_IN_EMULATED_HUE) + allow_hue_groups = bridge.get(CONF_ALLOW_HUE_GROUPS) + + host = bridge.get(CONF_HOST) + + if host is None: + host = _find_host_from_config(hass, filename) + + if host is None: + _LOGGER.error("No host found in configuration") + return False + + setup_bridge(host, hass, filename, allow_unreachable, + allow_in_emulated_hue, allow_hue_groups) + + return True + + +def bridge_discovered(hass, service, discovery_info): + """Dispatcher for Hue discovery events.""" + host = discovery_info.get('host') + serial = discovery_info.get('serial') + + filename = 'phue-{}.conf'.format(serial) + setup_bridge(host, hass, filename) + + +def setup_bridge(host, hass, filename=None, allow_unreachable=False, + allow_in_emulated_hue=True, allow_hue_groups=True): + """Set up a given Hue bridge.""" + # Only register a device once + if socket.gethostbyname(host) in hass.data[DOMAIN]: + return + + bridge = HueBridge(host, hass, filename, allow_unreachable, + allow_in_emulated_hue, allow_hue_groups) + bridge.setup() + + +def _find_host_from_config(hass, filename=PHUE_CONFIG_FILE): + """Attempt to detect host based on existing configuration.""" + path = hass.config.path(filename) + + if not os.path.isfile(path): + return None + + try: + with open(path) as inp: + return next(iter(json.load(inp).keys())) + except (ValueError, AttributeError, StopIteration): + # ValueError if can't parse as JSON + # AttributeError if JSON value is not a dict + # StopIteration if no keys + return None + + +class HueBridge(object): + """Manages a single Hue bridge.""" + + def __init__(self, host, hass, filename, allow_unreachable=False, + allow_in_emulated_hue=True, allow_hue_groups=True): + """Initialize the system.""" + self.host = host + self.hass = hass + self.filename = filename + self.allow_unreachable = allow_unreachable + self.allow_in_emulated_hue = allow_in_emulated_hue + self.allow_hue_groups = allow_hue_groups + + self.bridge = None + + self.configured = False + self.config_request_id = None + + hass.data[DOMAIN][socket.gethostbyname(host)] = self + + def setup(self): + """Set up a phue bridge based on host parameter.""" + import phue + + try: + self.bridge = phue.Bridge( + self.host, + config_file_path=self.hass.config.path(self.filename)) + except ConnectionRefusedError: # Wrong host was given + _LOGGER.error("Error connecting to the Hue bridge at %s", + self.host) + return + except phue.PhueRegistrationException: + _LOGGER.warning("Connected to Hue at %s but not registered.", + self.host) + self.request_configuration() + return + + # If we came here and configuring this host, mark as done + if self.config_request_id: + request_id = self.config_request_id + self.config_request_id = None + configurator = self.hass.components.configurator + configurator.request_done(request_id) + + self.configured = True + + discovery.load_platform( + self.hass, 'light', DOMAIN, + {'bridge_id': socket.gethostbyname(self.host)}) + + # create a service for calling run_scene directly on the bridge, + # used to simplify automation rules. + def hue_activate_scene(call): + """Service to call directly into bridge to set scenes.""" + group_name = call.data[ATTR_GROUP_NAME] + 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): + """Request configuration steps from the user.""" + configurator = self.hass.components.configurator + + # We got an error if this method is called while we are configuring + if self.config_request_id: + configurator.notify_errors( + self.config_request_id, + "Failed to register, please try again.") + return + + self.config_request_id = configurator.request_config( + "Philips Hue", + lambda data: self.setup(), + description=CONFIG_INSTRUCTIONS, + entity_picture="/static/images/logo_philips_hue.png", + submit_caption="I have pressed the button" + ) + + def get_api(self): + """Return the full api dictionary from phue.""" + return self.bridge.get_api() + + def set_light(self, light_id, command): + """Adjust properties of one or more lights. See phue for details.""" + return self.bridge.set_light(light_id, command) + + def set_group(self, light_id, command): + """Change light settings for a group. See phue for detail.""" + return self.bridge.set_group(light_id, command) diff --git a/homeassistant/components/light/hue.py b/homeassistant/components/light/hue.py index fe7dd765d01..a454143bcd2 100644 --- a/homeassistant/components/light/hue.py +++ b/homeassistant/components/light/hue.py @@ -1,19 +1,21 @@ """ -Support for Hue lights. +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 json -import logging -import os -import random -import socket from datetime import timedelta +import logging +import random +import re +import socket 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, @@ -21,30 +23,21 @@ from homeassistant.components.light import ( FLASH_LONG, FLASH_SHORT, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_EFFECT, SUPPORT_FLASH, SUPPORT_RGB_COLOR, SUPPORT_TRANSITION, SUPPORT_XY_COLOR, Light, PLATFORM_SCHEMA) -from homeassistant.config import load_yaml_config_file -from homeassistant.const import (CONF_FILENAME, CONF_HOST, DEVICE_DEFAULT_NAME) +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 -REQUIREMENTS = ['phue==1.0'] +DEPENDENCIES = ['hue'] -# Track previously setup bridges -_CONFIGURED_BRIDGES = {} -# Map ip to request id for configuring -_CONFIGURING = {} _LOGGER = logging.getLogger(__name__) -CONF_ALLOW_UNREACHABLE = 'allow_unreachable' - -DEFAULT_ALLOW_UNREACHABLE = False -DOMAIN = "light" -SERVICE_HUE_SCENE = "hue_activate_scene" +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) -PHUE_CONFIG_FILE = 'phue.conf' - SUPPORT_HUE_ON_OFF = (SUPPORT_FLASH | SUPPORT_TRANSITION) SUPPORT_HUE_DIMMABLE = (SUPPORT_HUE_ON_OFF | SUPPORT_BRIGHTNESS) SUPPORT_HUE_COLOR_TEMP = (SUPPORT_HUE_DIMMABLE | SUPPORT_COLOR_TEMP) @@ -60,10 +53,14 @@ SUPPORT_HUE = { 'Color temperature light': SUPPORT_HUE_COLOR_TEMP } -CONF_ALLOW_IN_EMULATED_HUE = "allow_in_emulated_hue" -DEFAULT_ALLOW_IN_EMULATED_HUE = True +ATTR_IS_HUE_GROUP = 'is_hue_group' -CONF_ALLOW_HUE_GROUPS = "allow_hue_groups" +# Legacy configuration, will be removed in 0.60 +CONF_ALLOW_UNREACHABLE = 'allow_unreachable' +DEFAULT_ALLOW_UNREACHABLE = False +CONF_ALLOW_IN_EMULATED_HUE = 'allow_in_emulated_hue' +DEFAULT_ALLOW_IN_EMULATED_HUE = True +CONF_ALLOW_HUE_GROUPS = 'allow_hue_groups' DEFAULT_ALLOW_HUE_GROUPS = True PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -75,236 +72,168 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ default=DEFAULT_ALLOW_HUE_GROUPS): cv.boolean, }) -ATTR_GROUP_NAME = "group_name" -ATTR_SCENE_NAME = "scene_name" -SCENE_SCHEMA = vol.Schema({ - vol.Required(ATTR_GROUP_NAME): cv.string, - vol.Required(ATTR_SCENE_NAME): cv.string, -}) +MIGRATION_ID = 'light_hue_config_migration' +MIGRATION_TITLE = 'Philips Hue Configuration Migration' +MIGRATION_INSTRUCTIONS = """ +Configuration for the Philips Hue component has changed; action required. -ATTR_IS_HUE_GROUP = "is_hue_group" +You have configured at least one bridge: -CONFIG_INSTRUCTIONS = """ -Press the button on the bridge to register Philips Hue with Home Assistant. + hue: +{config} -![Location of button on bridge](/static/images/config_philips_hue.jpg) +This configuration is deprecated, please check the +[Hue component](https://home-assistant.io/components/hue/) page for more +information. """ -def _find_host_from_config(hass, filename=PHUE_CONFIG_FILE): - """Attempt to detect host based on existing configuration.""" - path = hass.config.path(filename) - - if not os.path.isfile(path): - return None - - try: - with open(path) as inp: - return next(json.loads(''.join(inp)).keys().__iter__()) - except (ValueError, AttributeError, StopIteration): - # ValueError if can't parse as JSON - # AttributeError if JSON value is not a dict - # StopIteration if no keys - return None - - def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Hue lights.""" - # Default needed in case of discovery - filename = config.get(CONF_FILENAME, PHUE_CONFIG_FILE) - allow_unreachable = config.get(CONF_ALLOW_UNREACHABLE, - DEFAULT_ALLOW_UNREACHABLE) - allow_in_emulated_hue = config.get(CONF_ALLOW_IN_EMULATED_HUE, - DEFAULT_ALLOW_IN_EMULATED_HUE) - allow_hue_groups = config.get(CONF_ALLOW_HUE_GROUPS) - - if discovery_info is not None: - if "HASS Bridge" in discovery_info.get('name', ''): - _LOGGER.info("Emulated hue found, will not add") - return False - - host = discovery_info.get('host') - else: - host = config.get(CONF_HOST, None) - - if host is None: - host = _find_host_from_config(hass, filename) - - if host is None: - _LOGGER.error("No host found in configuration") - return False - - # Only act if we are not already configuring this host - if host in _CONFIGURING or \ - socket.gethostbyname(host) in _CONFIGURED_BRIDGES: + if discovery_info is None or 'bridge_id' not in discovery_info: return - setup_bridge(host, hass, add_devices, filename, allow_unreachable, - allow_in_emulated_hue, allow_hue_groups) + 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]) + # Indent so it renders in a fixed-width font + config_str = re.sub('(?m)^', ' ', config_str) + hass.components.persistent_notification.async_create( + MIGRATION_INSTRUCTIONS.format(config=config_str), + title=MIGRATION_TITLE, + notification_id=MIGRATION_ID) + + bridge_id = discovery_info['bridge_id'] + bridge = hass.data[hue.DOMAIN][bridge_id] + unthrottled_update_lights(hass, bridge, add_devices) -def setup_bridge(host, hass, add_devices, filename, allow_unreachable, - allow_in_emulated_hue, allow_hue_groups): - """Set up a phue bridge based on host parameter.""" +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.""" + return unthrottled_update_lights(hass, bridge, add_devices) + + +def unthrottled_update_lights(hass, bridge, add_devices): + """Internal version of update_lights.""" import phue + if not bridge.configured: + return + try: - bridge = phue.Bridge( - host, - config_file_path=hass.config.path(filename)) - except ConnectionRefusedError: # Wrong host was given - _LOGGER.error("Error connecting to the Hue bridge at %s", host) - + api = bridge.get_api() + except phue.PhueRequestTimeout: + _LOGGER.warning('Timeout trying to reach the bridge') + return + except ConnectionRefusedError: + _LOGGER.error('The bridge refused the connection') + return + except socket.error: + # socket.error when we cannot reach Hue + _LOGGER.exception('Cannot reach the bridge') return - except phue.PhueRegistrationException: - _LOGGER.warning("Connected to Hue at %s but not registered.", host) + bridge_type = get_bridge_type(api) - request_configuration(host, hass, add_devices, filename, - allow_unreachable, allow_in_emulated_hue, - allow_hue_groups) + new_lights = process_lights( + hass, api, bridge, bridge_type, + lambda **kw: update_lights(hass, bridge, add_devices, **kw)) + if bridge.allow_hue_groups: + new_lightgroups = process_groups( + hass, api, bridge, bridge_type, + lambda **kw: update_lights(hass, bridge, add_devices, **kw)) + new_lights.extend(new_lightgroups) - return + if new_lights: + add_devices(new_lights) - # If we came here and configuring this host, mark as done - if host in _CONFIGURING: - request_id = _CONFIGURING.pop(host) - configurator = hass.components.configurator - configurator.request_done(request_id) - lights = {} - lightgroups = {} - skip_groups = not allow_hue_groups +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' - @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS) - def update_lights(): - """Update the Hue light objects with latest info from the bridge.""" - nonlocal skip_groups - try: - api = bridge.get_api() - except phue.PhueRequestTimeout: - _LOGGER.warning("Timeout trying to reach the bridge") - return - except ConnectionRefusedError: - _LOGGER.error("The bridge refused the connection") - return - except socket.error: - # socket.error when we cannot reach Hue - _LOGGER.exception("Cannot reach the bridge") - return +def process_lights(hass, api, bridge, bridge_type, update_lights_cb): + """Set up HueLight objects for all lights.""" + api_lights = api.get('lights') - api_lights = api.get('lights') + if not isinstance(api_lights, dict): + _LOGGER.error('Got unexpected result from Hue API') + return [] - if not isinstance(api_lights, dict): - _LOGGER.error("Got unexpected result from Hue API") - return + new_lights = [] - if skip_groups: - api_groups = {} + 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( + int(light_id), info, bridge, + update_lights_cb, + bridge_type, bridge.allow_unreachable, + bridge.allow_in_emulated_hue) + new_lights.append(lights[light_id]) else: - api_groups = api.get('groups') + lights[light_id].info = info + lights[light_id].schedule_update_ha_state() - if not isinstance(api_groups, dict): - _LOGGER.error("Got unexpected result from Hue API") - return + return new_lights - new_lights = [] - api_name = api.get('config').get('name') - if api_name in ('RaspBee-GW', 'deCONZ-GW'): - bridge_type = 'deconz' +def process_groups(hass, api, bridge, bridge_type, update_lights_cb): + """Set up HueLight objects for all groups.""" + api_groups = api.get('groups') + + if not isinstance(api_groups, dict): + _LOGGER.error('Got unexpected result from Hue API') + return [] + + 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( + 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]) else: - bridge_type = 'hue' + groups[lightgroup_id].info = info + groups[lightgroup_id].schedule_update_ha_state() - for light_id, info in api_lights.items(): - if light_id not in lights: - lights[light_id] = HueLight(int(light_id), info, - bridge, update_lights, - bridge_type, allow_unreachable, - allow_in_emulated_hue) - new_lights.append(lights[light_id]) - else: - lights[light_id].info = info - lights[light_id].schedule_update_ha_state() - - 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.") - skip_groups = True - break - - if lightgroup_id not in lightgroups: - lightgroups[lightgroup_id] = HueLight( - int(lightgroup_id), info, bridge, update_lights, - bridge_type, allow_unreachable, allow_in_emulated_hue, - True) - new_lights.append(lightgroups[lightgroup_id]) - else: - lightgroups[lightgroup_id].info = info - lightgroups[lightgroup_id].schedule_update_ha_state() - - if new_lights: - add_devices(new_lights) - - _CONFIGURED_BRIDGES[socket.gethostbyname(host)] = True - - # create a service for calling run_scene directly on the bridge, - # used to simplify automation rules. - def hue_activate_scene(call): - """Service to call directly into bridge to set scenes.""" - group_name = call.data[ATTR_GROUP_NAME] - scene_name = call.data[ATTR_SCENE_NAME] - bridge.run_scene(group_name, scene_name) - - descriptions = load_yaml_config_file( - os.path.join(os.path.dirname(__file__), 'services.yaml')) - hass.services.register(DOMAIN, SERVICE_HUE_SCENE, hue_activate_scene, - descriptions.get(SERVICE_HUE_SCENE), - schema=SCENE_SCHEMA) - - update_lights() - - -def request_configuration(host, hass, add_devices, filename, - allow_unreachable, allow_in_emulated_hue, - allow_hue_groups): - """Request configuration steps from the user.""" - configurator = hass.components.configurator - - # We got an error if this method is called while we are configuring - if host in _CONFIGURING: - configurator.notify_errors( - _CONFIGURING[host], "Failed to register, please try again.") - - return - - # pylint: disable=unused-argument - def hue_configuration_callback(data): - """Set up actions to do when our configuration callback is called.""" - setup_bridge(host, hass, add_devices, filename, allow_unreachable, - allow_in_emulated_hue, allow_hue_groups) - - _CONFIGURING[host] = configurator.request_config( - "Philips Hue", hue_configuration_callback, - description=CONFIG_INSTRUCTIONS, - entity_picture="/static/images/logo_philips_hue.png", - submit_caption="I have pressed the button" - ) + return new_lights class HueLight(Light): """Representation of a Hue light.""" - def __init__(self, light_id, info, bridge, update_lights, + def __init__(self, light_id, info, bridge, update_lights_cb, bridge_type, 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 + self.update_lights = update_lights_cb self.bridge_type = bridge_type self.allow_unreachable = allow_unreachable self.is_group = is_group @@ -381,14 +310,15 @@ class HueLight(Light): command['transitiontime'] = int(kwargs[ATTR_TRANSITION] * 10) if ATTR_XY_COLOR in kwargs: - if self.info.get('manufacturername') == "OSRAM": - hue, sat = color_util.color_xy_to_hs(*kwargs[ATTR_XY_COLOR]) - command['hue'] = hue + if self.info.get('manufacturername') == 'OSRAM': + color_hue, sat = color_util.color_xy_to_hs( + *kwargs[ATTR_XY_COLOR]) + command['hue'] = color_hue command['sat'] = sat else: command['xy'] = kwargs[ATTR_XY_COLOR] elif ATTR_RGB_COLOR in kwargs: - if self.info.get('manufacturername') == "OSRAM": + if self.info.get('manufacturername') == 'OSRAM': hsv = color_util.color_RGB_to_hsv( *(int(val) for val in kwargs[ATTR_RGB_COLOR])) command['hue'] = hsv[0] diff --git a/requirements_all.txt b/requirements_all.txt index f6655d06baa..7349d7dbd35 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -533,7 +533,7 @@ pdunehd==1.3 # homeassistant.components.media_player.pandora pexpect==4.0.1 -# homeassistant.components.light.hue +# homeassistant.components.hue phue==1.0 # homeassistant.components.rpi_pfio diff --git a/tests/components/light/test_hue.py b/tests/components/light/test_hue.py new file mode 100644 index 00000000000..5e5bd4f6c7f --- /dev/null +++ b/tests/components/light/test_hue.py @@ -0,0 +1,479 @@ +"""Philips Hue lights platform tests.""" + +import logging +import unittest +import unittest.mock as mock +from unittest.mock import call, MagicMock, patch + +from homeassistant.components import hue +import homeassistant.components.light.hue as hue_light + +from tests.common import get_test_home_assistant, MockDependency + +_LOGGER = logging.getLogger(__name__) + + +class TestSetup(unittest.TestCase): + """Test the Hue light platform.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.skip_teardown_stop = False + + def tearDown(self): + """Stop everything that was started.""" + if not self.skip_teardown_stop: + self.hass.stop() + + def setup_mocks_for_update_lights(self): + """Set up all mocks for update_lights tests.""" + self.mock_bridge = MagicMock() + 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() + 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_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.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 test_setup_platform_no_discovery_info(self): + """Test setup_platform without discovery info.""" + self.hass.data[hue.DOMAIN] = {} + mock_add_devices = MagicMock() + + hue_light.setup_platform(self.hass, {}, mock_add_devices) + + mock_add_devices.assert_not_called() + + def test_setup_platform_no_bridge_id(self): + """Test setup_platform without a bridge.""" + self.hass.data[hue.DOMAIN] = {} + mock_add_devices = MagicMock() + + hue_light.setup_platform(self.hass, {}, mock_add_devices, {}) + + mock_add_devices.assert_not_called() + + def test_setup_platform_one_bridge(self): + """Test setup_platform with one bridge.""" + mock_bridge = MagicMock() + 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: + hue_light.setup_platform( + self.hass, {}, mock_add_devices, + {'bridge_id': '10.0.0.1'}) + mock_update_lights.assert_called_once_with( + self.hass, mock_bridge, mock_add_devices) + + def test_setup_platform_multiple_bridges(self): + """Test setup_platform wuth multiple bridges.""" + mock_bridge = MagicMock() + mock_bridge2 = MagicMock() + self.hass.data[hue.DOMAIN] = { + '10.0.0.1': mock_bridge, + '192.168.0.10': mock_bridge2, + } + mock_add_devices = MagicMock() + + with patch('homeassistant.components.light.hue.' + + 'unthrottled_update_lights') as mock_update_lights: + hue_light.setup_platform( + self.hass, {}, mock_add_devices, + {'bridge_id': '10.0.0.1'}) + hue_light.setup_platform( + self.hass, {}, mock_add_devices, + {'bridge_id': '192.168.0.10'}) + + mock_update_lights.assert_has_calls([ + call(self.hass, mock_bridge, mock_add_devices), + call(self.hass, mock_bridge2, mock_add_devices), + ]) + + @MockDependency('phue') + def test_update_lights_with_no_lights(self, mock_phue): + """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) + + 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() + + @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'] + + 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) + + 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) + + @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'] + + 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) + + 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) + + @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'] + + 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) + + 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) + + def test_process_lights_api_error(self): + """Test the process_lights function when the bridge errors out.""" + self.setup_mocks_for_process_lights() + 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.assertEquals([], ret) + self.assertEquals( + {}, + self.hass.data[hue_light.DATA_KEY][hue_light.DATA_LIGHTS]) + + def test_process_lights_no_lights(self): + """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, self.mock_bridge_type, + None) + + self.assertEquals([], ret) + self.assertEquals( + {}, + self.hass.data[hue_light.DATA_KEY][hue_light.DATA_LIGHTS]) + + @patch('homeassistant.components.light.hue.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, self.mock_bridge_type, + 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_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_in_emulated_hue), + ]) + self.assertEquals( + len(self.hass.data[hue_light.DATA_KEY][hue_light.DATA_LIGHTS]), + 2) + + @patch('homeassistant.components.light.hue.HueLight') + def test_process_lights_new_light(self, mock_hue_light): + """ + Test the process_lights function with new groups. + + Test what happens when we already have a light and a new one shows up. + """ + 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() + + ret = hue_light.process_lights( + self.hass, self.mock_api, self.mock_bridge, self.mock_bridge_type, + 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_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) + + def test_process_groups_api_error(self): + """Test the process_groups function when the bridge errors out.""" + self.setup_mocks_for_process_groups() + 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.assertEquals([], ret) + self.assertEquals( + {}, + self.hass.data[hue_light.DATA_KEY][hue_light.DATA_LIGHTGROUPS]) + + def test_process_groups_no_state(self): + """Test the process_groups function when bridge returns no status.""" + 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, self.mock_bridge_type, + None) + + self.assertEquals([], ret) + self.assertEquals( + {}, + self.hass.data[hue_light.DATA_KEY][hue_light.DATA_LIGHTGROUPS]) + + @patch('homeassistant.components.light.hue.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, self.mock_bridge_type, + 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_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_in_emulated_hue, True), + ]) + self.assertEquals( + len(self.hass.data[ + hue_light.DATA_KEY][hue_light.DATA_LIGHTGROUPS]), + 2) + + @patch('homeassistant.components.light.hue.HueLight') + def test_process_groups_new_group(self, mock_hue_light): + """ + Test the process_groups function with new groups. + + Test what happens when we already have a light and a new one shows up. + """ + 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() + + ret = hue_light.process_groups( + self.hass, self.mock_api, self.mock_bridge, self.mock_bridge_type, + 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_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) + + +class TestHueLight(unittest.TestCase): + """Test the HueLight class.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.skip_teardown_stop = False + + self.light_id = 42 + 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() + self.mock_is_group = False + + def tearDown(self): + """Stop everything that was started.""" + if not self.skip_teardown_stop: + self.hass.stop() + + def buildLight( + self, light_id=None, info=None, update_lights=None, is_group=None): + """Helper to build a HueLight object with minimal fuss.""" + 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, + self.mock_bridge, + (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) + + def test_unique_id_for_light(self): + """Test the unique_id method with lights.""" + class_name = "" + + light = self.buildLight(info={'uniqueid': 'foobar'}) + self.assertEquals( + class_name+'.foobar', + light.unique_id) + + light = self.buildLight(info={}) + self.assertEquals( + class_name+'.Unnamed Device.Light.42', + light.unique_id) + + light = self.buildLight(info={'name': 'my-name'}) + self.assertEquals( + class_name+'.my-name.Light.42', + light.unique_id) + + light = self.buildLight(info={'type': 'my-type'}) + self.assertEquals( + class_name+'.Unnamed Device.my-type.42', + light.unique_id) + + light = self.buildLight(info={'name': 'a name', 'type': 'my-type'}) + self.assertEquals( + class_name+'.a name.my-type.42', + light.unique_id) + + def test_unique_id_for_group(self): + """Test the unique_id method with groups.""" + class_name = "" + + light = self.buildLight(info={'uniqueid': 'foobar'}, is_group=True) + self.assertEquals( + class_name+'.foobar', + light.unique_id) + + light = self.buildLight(info={}, is_group=True) + self.assertEquals( + class_name+'.Unnamed Device.Group.42', + light.unique_id) + + light = self.buildLight(info={'name': 'my-name'}, is_group=True) + self.assertEquals( + class_name+'.my-name.Group.42', + light.unique_id) + + light = self.buildLight(info={'type': 'my-type'}, is_group=True) + self.assertEquals( + class_name+'.Unnamed Device.my-type.42', + light.unique_id) + + light = self.buildLight( + info={'name': 'a name', 'type': 'my-type'}, + is_group=True) + self.assertEquals( + class_name+'.a name.my-type.42', + light.unique_id) diff --git a/tests/components/test_hue.py b/tests/components/test_hue.py new file mode 100644 index 00000000000..227295594db --- /dev/null +++ b/tests/components/test_hue.py @@ -0,0 +1,402 @@ +"""Generic Philips Hue component tests.""" + +import logging +import unittest +from unittest.mock import call, MagicMock, patch + +from homeassistant.components import configurator, hue +from homeassistant.const import CONF_FILENAME, CONF_HOST +from homeassistant.setup import setup_component + +from tests.common import ( + assert_setup_component, get_test_home_assistant, get_test_config_dir, + MockDependency +) + +_LOGGER = logging.getLogger(__name__) + + +class TestSetup(unittest.TestCase): + """Test the Hue component.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.skip_teardown_stop = False + + def tearDown(self): + """Stop everything that was started.""" + if not self.skip_teardown_stop: + self.hass.stop() + + @MockDependency('phue') + def test_setup_no_domain(self, mock_phue): + """If it's not in the config we won't even try.""" + with assert_setup_component(0): + self.assertTrue(setup_component( + self.hass, hue.DOMAIN, {})) + mock_phue.Bridge.assert_not_called() + self.assertEquals({}, self.hass.data[hue.DOMAIN]) + + @MockDependency('phue') + def test_setup_no_host(self, mock_phue): + """No host specified in any way.""" + with assert_setup_component(1): + self.assertTrue(setup_component( + self.hass, hue.DOMAIN, {hue.DOMAIN: {}})) + mock_phue.Bridge.assert_not_called() + self.assertEquals({}, self.hass.data[hue.DOMAIN]) + + @MockDependency('phue') + def test_setup_with_host(self, mock_phue): + """Host specified in the config file.""" + mock_bridge = mock_phue.Bridge + + with assert_setup_component(1): + with patch('homeassistant.helpers.discovery.load_platform') \ + as mock_load: + self.assertTrue(setup_component( + self.hass, hue.DOMAIN, + {hue.DOMAIN: {hue.CONF_BRIDGES: [ + {CONF_HOST: 'localhost'}]}})) + + mock_bridge.assert_called_once_with( + 'localhost', + config_file_path=get_test_config_dir(hue.PHUE_CONFIG_FILE)) + mock_load.assert_called_once_with( + self.hass, 'light', hue.DOMAIN, + {'bridge_id': '127.0.0.1'}) + + self.assertTrue(hue.DOMAIN in self.hass.data) + self.assertEquals(1, len(self.hass.data[hue.DOMAIN])) + + @MockDependency('phue') + def test_setup_with_phue_conf(self, mock_phue): + """No host in the config file, but one is cached in phue.conf.""" + mock_bridge = mock_phue.Bridge + + with assert_setup_component(1): + with patch( + 'homeassistant.components.hue._find_host_from_config', + return_value='localhost'): + with patch('homeassistant.helpers.discovery.load_platform') \ + as mock_load: + self.assertTrue(setup_component( + self.hass, hue.DOMAIN, + {hue.DOMAIN: {hue.CONF_BRIDGES: [ + {CONF_FILENAME: 'phue.conf'}]}})) + + mock_bridge.assert_called_once_with( + 'localhost', + config_file_path=get_test_config_dir( + hue.PHUE_CONFIG_FILE)) + mock_load.assert_called_once_with( + self.hass, 'light', hue.DOMAIN, + {'bridge_id': '127.0.0.1'}) + + self.assertTrue(hue.DOMAIN in self.hass.data) + self.assertEquals(1, len(self.hass.data[hue.DOMAIN])) + + @MockDependency('phue') + def test_setup_with_multiple_hosts(self, mock_phue): + """Multiple hosts specified in the config file.""" + mock_bridge = mock_phue.Bridge + + with assert_setup_component(1): + with patch('homeassistant.helpers.discovery.load_platform') \ + as mock_load: + self.assertTrue(setup_component( + self.hass, hue.DOMAIN, + {hue.DOMAIN: {hue.CONF_BRIDGES: [ + {CONF_HOST: 'localhost'}, + {CONF_HOST: '192.168.0.1'}]}})) + + mock_bridge.assert_has_calls([ + call( + 'localhost', + config_file_path=get_test_config_dir( + hue.PHUE_CONFIG_FILE)), + call( + '192.168.0.1', + config_file_path=get_test_config_dir( + hue.PHUE_CONFIG_FILE))]) + mock_load.mock_bridge.assert_not_called() + mock_load.assert_has_calls([ + call( + self.hass, 'light', hue.DOMAIN, + {'bridge_id': '127.0.0.1'}), + call( + self.hass, 'light', hue.DOMAIN, + {'bridge_id': '192.168.0.1'}), + ], any_order=True) + + self.assertTrue(hue.DOMAIN in self.hass.data) + self.assertEquals(2, len(self.hass.data[hue.DOMAIN])) + + @MockDependency('phue') + def test_bridge_discovered(self, mock_phue): + """Bridge discovery.""" + mock_bridge = mock_phue.Bridge + mock_service = MagicMock() + discovery_info = {'host': '192.168.0.10', 'serial': 'foobar'} + + with patch('homeassistant.helpers.discovery.load_platform') \ + as mock_load: + self.assertTrue(setup_component( + self.hass, hue.DOMAIN, {})) + hue.bridge_discovered(self.hass, mock_service, discovery_info) + + mock_bridge.assert_called_once_with( + '192.168.0.10', + config_file_path=get_test_config_dir('phue-foobar.conf')) + mock_load.assert_called_once_with( + self.hass, 'light', hue.DOMAIN, + {'bridge_id': '192.168.0.10'}) + + self.assertTrue(hue.DOMAIN in self.hass.data) + self.assertEquals(1, len(self.hass.data[hue.DOMAIN])) + + @MockDependency('phue') + def test_bridge_configure_and_discovered(self, mock_phue): + """Bridge is in the config file, then we discover it.""" + mock_bridge = mock_phue.Bridge + mock_service = MagicMock() + discovery_info = {'host': '192.168.1.10', 'serial': 'foobar'} + + with assert_setup_component(1): + with patch('homeassistant.helpers.discovery.load_platform') \ + as mock_load: + # First we set up the component from config + self.assertTrue(setup_component( + self.hass, hue.DOMAIN, + {hue.DOMAIN: {hue.CONF_BRIDGES: [ + {CONF_HOST: '192.168.1.10'}]}})) + + mock_bridge.assert_called_once_with( + '192.168.1.10', + config_file_path=get_test_config_dir( + hue.PHUE_CONFIG_FILE)) + calls_to_mock_load = [ + call( + self.hass, 'light', hue.DOMAIN, + {'bridge_id': '192.168.1.10'}), + ] + mock_load.assert_has_calls(calls_to_mock_load) + + self.assertTrue(hue.DOMAIN in self.hass.data) + self.assertEquals(1, len(self.hass.data[hue.DOMAIN])) + + # Then we discover the same bridge + hue.bridge_discovered(self.hass, mock_service, discovery_info) + + # No additional calls + mock_bridge.assert_called_once_with( + '192.168.1.10', + config_file_path=get_test_config_dir( + hue.PHUE_CONFIG_FILE)) + mock_load.assert_has_calls(calls_to_mock_load) + + # Still only one + self.assertTrue(hue.DOMAIN in self.hass.data) + self.assertEquals(1, len(self.hass.data[hue.DOMAIN])) + + +class TestHueBridge(unittest.TestCase): + """Test the HueBridge class.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.hass.data[hue.DOMAIN] = {} + self.skip_teardown_stop = False + + def tearDown(self): + """Stop everything that was started.""" + if not self.skip_teardown_stop: + self.hass.stop() + + @MockDependency('phue') + def test_setup_bridge_connection_refused(self, mock_phue): + """Test a registration failed with a connection refused exception.""" + mock_bridge = mock_phue.Bridge + mock_bridge.side_effect = ConnectionRefusedError() + + bridge = hue.HueBridge('localhost', self.hass, hue.PHUE_CONFIG_FILE) + bridge.setup() + self.assertFalse(bridge.configured) + self.assertTrue(bridge.config_request_id is None) + + mock_bridge.assert_called_once_with( + 'localhost', + config_file_path=get_test_config_dir(hue.PHUE_CONFIG_FILE)) + + @MockDependency('phue') + def test_setup_bridge_registration_exception(self, mock_phue): + """Test a registration failed with an exception.""" + mock_bridge = mock_phue.Bridge + mock_phue.PhueRegistrationException = Exception + mock_bridge.side_effect = mock_phue.PhueRegistrationException(1, 2) + + bridge = hue.HueBridge('localhost', self.hass, hue.PHUE_CONFIG_FILE) + bridge.setup() + self.assertFalse(bridge.configured) + self.assertFalse(bridge.config_request_id is None) + self.assertTrue(isinstance(bridge.config_request_id, str)) + + mock_bridge.assert_called_once_with( + 'localhost', + config_file_path=get_test_config_dir(hue.PHUE_CONFIG_FILE)) + + @MockDependency('phue') + def test_setup_bridge_registration_succeeds(self, mock_phue): + """Test a registration success sequence.""" + mock_bridge = mock_phue.Bridge + mock_phue.PhueRegistrationException = Exception + mock_bridge.side_effect = [ + # First call, raise because not registered + mock_phue.PhueRegistrationException(1, 2), + # Second call, registration is done + None, + ] + + bridge = hue.HueBridge('localhost', self.hass, hue.PHUE_CONFIG_FILE) + bridge.setup() + self.assertFalse(bridge.configured) + self.assertFalse(bridge.config_request_id is None) + + # Simulate the user confirming the registration + self.hass.services.call( + configurator.DOMAIN, configurator.SERVICE_CONFIGURE, + {configurator.ATTR_CONFIGURE_ID: bridge.config_request_id}) + + self.hass.block_till_done() + self.assertTrue(bridge.configured) + self.assertTrue(bridge.config_request_id is None) + + # We should see a total of two identical calls + args = call( + 'localhost', + config_file_path=get_test_config_dir(hue.PHUE_CONFIG_FILE)) + mock_bridge.assert_has_calls([args, args]) + + # Make sure the request is done + self.assertEqual(1, len(self.hass.states.all())) + self.assertEqual('configured', self.hass.states.all()[0].state) + + @MockDependency('phue') + def test_setup_bridge_registration_fails(self, mock_phue): + """ + Test a registration failure sequence. + + This may happen when we start the registration process, the user + responds to the request but the bridge has become unreachable. + """ + mock_bridge = mock_phue.Bridge + mock_phue.PhueRegistrationException = Exception + mock_bridge.side_effect = [ + # First call, raise because not registered + mock_phue.PhueRegistrationException(1, 2), + # Second call, the bridge has gone away + ConnectionRefusedError(), + ] + + bridge = hue.HueBridge('localhost', self.hass, hue.PHUE_CONFIG_FILE) + bridge.setup() + self.assertFalse(bridge.configured) + self.assertFalse(bridge.config_request_id is None) + + # Simulate the user confirming the registration + self.hass.services.call( + configurator.DOMAIN, configurator.SERVICE_CONFIGURE, + {configurator.ATTR_CONFIGURE_ID: bridge.config_request_id}) + + self.hass.block_till_done() + self.assertFalse(bridge.configured) + self.assertFalse(bridge.config_request_id is None) + + # We should see a total of two identical calls + args = call( + 'localhost', + config_file_path=get_test_config_dir(hue.PHUE_CONFIG_FILE)) + mock_bridge.assert_has_calls([args, args]) + + # The request should still be pending + self.assertEqual(1, len(self.hass.states.all())) + self.assertEqual('configure', self.hass.states.all()[0].state) + + @MockDependency('phue') + def test_setup_bridge_registration_retry(self, mock_phue): + """ + Test a registration retry sequence. + + This may happen when we start the registration process, the user + responds to the request but we fail to confirm it with the bridge. + """ + mock_bridge = mock_phue.Bridge + mock_phue.PhueRegistrationException = Exception + mock_bridge.side_effect = [ + # First call, raise because not registered + mock_phue.PhueRegistrationException(1, 2), + # Second call, for whatever reason authentication fails + mock_phue.PhueRegistrationException(1, 2), + ] + + bridge = hue.HueBridge('localhost', self.hass, hue.PHUE_CONFIG_FILE) + bridge.setup() + self.assertFalse(bridge.configured) + self.assertFalse(bridge.config_request_id is None) + + # Simulate the user confirming the registration + self.hass.services.call( + configurator.DOMAIN, configurator.SERVICE_CONFIGURE, + {configurator.ATTR_CONFIGURE_ID: bridge.config_request_id}) + + self.hass.block_till_done() + self.assertFalse(bridge.configured) + self.assertFalse(bridge.config_request_id is None) + + # We should see a total of two identical calls + args = call( + 'localhost', + config_file_path=get_test_config_dir(hue.PHUE_CONFIG_FILE)) + mock_bridge.assert_has_calls([args, args]) + + # Make sure the request is done + self.assertEqual(1, len(self.hass.states.all())) + self.assertEqual('configure', self.hass.states.all()[0].state) + self.assertEqual( + 'Failed to register, please try again.', + self.hass.states.all()[0].attributes.get(configurator.ATTR_ERRORS)) + + @MockDependency('phue') + def test_hue_activate_scene(self, mock_phue): + """Test the hue_activate_scene service.""" + with patch('homeassistant.helpers.discovery.load_platform'): + bridge = hue.HueBridge('localhost', self.hass, + hue.PHUE_CONFIG_FILE) + bridge.setup() + + # No args + self.hass.services.call(hue.DOMAIN, hue.SERVICE_HUE_SCENE, + blocking=True) + bridge.bridge.run_scene.assert_not_called() + + # Only one arg + self.hass.services.call( + hue.DOMAIN, hue.SERVICE_HUE_SCENE, + {hue.ATTR_GROUP_NAME: 'group'}, + blocking=True) + bridge.bridge.run_scene.assert_not_called() + + self.hass.services.call( + hue.DOMAIN, hue.SERVICE_HUE_SCENE, + {hue.ATTR_SCENE_NAME: 'scene'}, + blocking=True) + bridge.bridge.run_scene.assert_not_called() + + # Both required args + self.hass.services.call( + hue.DOMAIN, hue.SERVICE_HUE_SCENE, + {hue.ATTR_GROUP_NAME: 'group', hue.ATTR_SCENE_NAME: 'scene'}, + blocking=True) + bridge.bridge.run_scene.assert_called_once_with('group', 'scene') From b078f6c3422ee36692a6cd047c6d92c35119a300 Mon Sep 17 00:00:00 2001 From: uchagani Date: Sun, 10 Dec 2017 16:02:12 -0500 Subject: [PATCH 020/100] add custom bypass status to total connect (#11042) * add custom bypass status to total connect * remove logger line --- .../components/alarm_control_panel/totalconnect.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/alarm_control_panel/totalconnect.py b/homeassistant/components/alarm_control_panel/totalconnect.py index 6f22d6a358c..5c1323989d4 100644 --- a/homeassistant/components/alarm_control_panel/totalconnect.py +++ b/homeassistant/components/alarm_control_panel/totalconnect.py @@ -14,7 +14,9 @@ from homeassistant.components.alarm_control_panel import PLATFORM_SCHEMA from homeassistant.const import ( CONF_PASSWORD, CONF_USERNAME, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, STATE_ALARM_DISARMED, - STATE_ALARM_ARMING, STATE_ALARM_DISARMING, STATE_UNKNOWN, CONF_NAME) + STATE_ALARM_ARMING, STATE_ALARM_DISARMING, STATE_UNKNOWN, CONF_NAME, + STATE_ALARM_ARMED_CUSTOM_BYPASS) + REQUIREMENTS = ['total_connect_client==0.16'] @@ -76,6 +78,8 @@ class TotalConnect(alarm.AlarmControlPanel): state = STATE_ALARM_ARMED_AWAY elif status == self._client.ARMED_STAY_NIGHT: state = STATE_ALARM_ARMED_NIGHT + elif status == self._client.ARMED_CUSTOM_BYPASS: + state = STATE_ALARM_ARMED_CUSTOM_BYPASS elif status == self._client.ARMING: state = STATE_ALARM_ARMING elif status == self._client.DISARMING: From a4214afddbc10730c4fcbd33815a1b533b3ca359 Mon Sep 17 00:00:00 2001 From: Erik Eriksson Date: Sun, 10 Dec 2017 22:57:44 +0100 Subject: [PATCH 021/100] Volvo on call: Optional use of Scandinavian miles. Also add average fuel consumption property (#11051) --- .../components/sensor/volvooncall.py | 33 ++++++++++++++++--- homeassistant/components/volvooncall.py | 10 ++++-- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/sensor/volvooncall.py b/homeassistant/components/sensor/volvooncall.py index 622261941d6..32b228ca1f9 100644 --- a/homeassistant/components/sensor/volvooncall.py +++ b/homeassistant/components/sensor/volvooncall.py @@ -6,8 +6,10 @@ https://home-assistant.io/components/sensor.volvooncall/ """ import logging +from math import floor -from homeassistant.components.volvooncall import VolvoEntity, RESOURCES +from homeassistant.components.volvooncall import ( + VolvoEntity, RESOURCES, CONF_SCANDINAVIAN_MILES) _LOGGER = logging.getLogger(__name__) @@ -26,14 +28,37 @@ class VolvoSensor(VolvoEntity): def state(self): """Return the state of the sensor.""" val = getattr(self.vehicle, self._attribute) + + if val is None: + return val + if self._attribute == 'odometer': - return round(val / 1000) # km - return val + val /= 1000 # m -> km + + if 'mil' in self.unit_of_measurement: + val /= 10 # km -> mil + + if self._attribute == 'average_fuel_consumption': + val /= 10 # L/1000km -> L/100km + if 'mil' in self.unit_of_measurement: + return round(val, 2) + else: + return round(val, 1) + elif self._attribute == 'distance_to_empty': + return int(floor(val)) + else: + return int(round(val)) @property def unit_of_measurement(self): """Return the unit of measurement.""" - return RESOURCES[self._attribute][3] + unit = RESOURCES[self._attribute][3] + if self._state.config[CONF_SCANDINAVIAN_MILES] and 'km' in unit: + if self._attribute == 'average_fuel_consumption': + return 'L/mil' + else: + return unit.replace('km', 'mil') + return unit @property def icon(self): diff --git a/homeassistant/components/volvooncall.py b/homeassistant/components/volvooncall.py index 4cee6ea2139..dcd4ed518d0 100644 --- a/homeassistant/components/volvooncall.py +++ b/homeassistant/components/volvooncall.py @@ -26,11 +26,13 @@ REQUIREMENTS = ['volvooncall==0.4.0'] _LOGGER = logging.getLogger(__name__) -CONF_UPDATE_INTERVAL = 'update_interval' MIN_UPDATE_INTERVAL = timedelta(minutes=1) DEFAULT_UPDATE_INTERVAL = timedelta(minutes=1) + +CONF_UPDATE_INTERVAL = 'update_interval' CONF_REGION = 'region' CONF_SERVICE_URL = 'service_url' +CONF_SCANDINAVIAN_MILES = 'scandinavian_miles' SIGNAL_VEHICLE_SEEN = '{}.vehicle_seen'.format(DOMAIN) @@ -41,6 +43,8 @@ RESOURCES = {'position': ('device_tracker',), 'fuel_amount': ('sensor', 'Fuel amount', 'mdi:gas-station', 'L'), 'fuel_amount_level': ( 'sensor', 'Fuel level', 'mdi:water-percent', '%'), + 'average_fuel_consumption': ( + 'sensor', 'Fuel consumption', 'mdi:gas-station', 'L/100 km'), 'distance_to_empty': ('sensor', 'Range', 'mdi:ruler', 'km'), 'washer_fluid_level': ('binary_sensor', 'Washer fluid'), 'brake_fluid': ('binary_sensor', 'Brake Fluid'), @@ -61,6 +65,7 @@ CONFIG_SCHEMA = vol.Schema({ cv.ensure_list, [vol.In(RESOURCES)]), vol.Optional(CONF_REGION): cv.string, vol.Optional(CONF_SERVICE_URL): cv.string, + vol.Optional(CONF_SCANDINAVIAN_MILES, default=False): cv.boolean, }), }, extra=vol.ALLOW_EXTRA) @@ -123,7 +128,8 @@ class VolvoData: """Initialize the component state.""" self.entities = {} self.vehicles = {} - self.names = config[DOMAIN].get(CONF_NAME) + self.config = config[DOMAIN] + self.names = self.config.get(CONF_NAME) def vehicle_name(self, vehicle): """Provide a friendly name for a vehicle.""" From 7259cc878e9ab4643c7a6e3d23b0dd478511fac8 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Mon, 11 Dec 2017 11:34:48 +0100 Subject: [PATCH 022/100] Allow tradfri to read the available state of the device (#11056) * Allow tradfri to read the available state of the device * Update tradfri.py --- homeassistant/components/light/tradfri.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/homeassistant/components/light/tradfri.py b/homeassistant/components/light/tradfri.py index dc8e7f4c996..bb2fa44c15c 100644 --- a/homeassistant/components/light/tradfri.py +++ b/homeassistant/components/light/tradfri.py @@ -160,6 +160,7 @@ class TradfriLight(Light): self._rgb_color = None self._features = SUPPORTED_FEATURES self._temp_supported = False + self._available = True self._refresh(light) @@ -196,6 +197,11 @@ class TradfriLight(Light): """Start thread when added to hass.""" self._async_start_observe() + @property + def available(self): + """Return True if entity is available.""" + return self._available + @property def should_poll(self): """No polling needed for tradfri light.""" @@ -299,6 +305,7 @@ class TradfriLight(Light): self._light = light # Caching of LightControl and light object + self._available = light.reachable self._light_control = light.light_control self._light_data = light.light_control.lights[0] self._name = light.name From 7777d5811f4d4e22ffc735718e7432ec1e97d4e3 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 11 Dec 2017 13:50:55 +0100 Subject: [PATCH 023/100] Upgrade aiohttp to 2.3.6 (#11079) --- 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 2e7acb212e2..aac86d17a5b 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.5 +aiohttp==2.3.6 yarl==0.15.0 async_timeout==2.0.0 chardet==3.0.4 diff --git a/requirements_all.txt b/requirements_all.txt index 7349d7dbd35..49bf3338fec 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.5 +aiohttp==2.3.6 yarl==0.15.0 async_timeout==2.0.0 chardet==3.0.4 diff --git a/setup.py b/setup.py index d79f11732ad..49e3358cb72 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.5', # If updated, check if yarl also needs an update! + 'aiohttp==2.3.6', # If updated, check if yarl also needs an update! 'yarl==0.15.0', 'async_timeout==2.0.0', 'chardet==3.0.4', From 02451896705d9b65cce9d0b75b00920d4477929d Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 11 Dec 2017 13:52:22 +0100 Subject: [PATCH 024/100] Upgrade yarl to 0.16.0 (#11078) --- 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 aac86d17a5b..3080160dfce 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.6 -yarl==0.15.0 +yarl==0.16.0 async_timeout==2.0.0 chardet==3.0.4 astral==1.4 diff --git a/requirements_all.txt b/requirements_all.txt index 49bf3338fec..70136737aea 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.6 -yarl==0.15.0 +yarl==0.16.0 async_timeout==2.0.0 chardet==3.0.4 astral==1.4 diff --git a/setup.py b/setup.py index 49e3358cb72..fe60a15e32e 100755 --- a/setup.py +++ b/setup.py @@ -54,7 +54,7 @@ REQUIRES = [ 'voluptuous==0.10.5', 'typing>=3,<4', 'aiohttp==2.3.6', # If updated, check if yarl also needs an update! - 'yarl==0.15.0', + 'yarl==0.16.0', 'async_timeout==2.0.0', 'chardet==3.0.4', 'astral==1.4', From 0cfff13be1d2f1cc8de964530c2643ade061eaba Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 11 Dec 2017 13:52:43 +0100 Subject: [PATCH 025/100] Upgrade psutil to 5.4.2 (#11083) --- 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 324d3029c99..8e6f7b404fd 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.1'] +REQUIREMENTS = ['psutil==5.4.2'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 70136737aea..663b3311088 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -569,7 +569,7 @@ proliphix==0.4.1 prometheus_client==0.0.21 # homeassistant.components.sensor.systemmonitor -psutil==5.4.1 +psutil==5.4.2 # homeassistant.components.wink pubnubsub-handler==1.0.2 From c461a7c7e2d3824f5a25204b4c41abc7735e4816 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 11 Dec 2017 13:53:01 +0100 Subject: [PATCH 026/100] Upgrade youtube_dl to 2017.12.10 (#11080) --- 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 9d5e88282ae..669390b3b90 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.11.26'] +REQUIREMENTS = ['youtube_dl==2017.12.10'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 663b3311088..b606131d0ad 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1184,7 +1184,7 @@ yeelight==0.3.3 yeelightsunflower==0.0.8 # homeassistant.components.media_extractor -youtube_dl==2017.11.26 +youtube_dl==2017.12.10 # homeassistant.components.light.zengge zengge==0.2 From 1b3963299d65fb53e17b940bf7b2bd735c2f5971 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 11 Dec 2017 16:44:15 +0100 Subject: [PATCH 027/100] Upgrade shodan to 1.7.7 (#11084) --- homeassistant/components/sensor/shodan.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/shodan.py b/homeassistant/components/sensor/shodan.py index 3d86d940f4d..720158e1029 100644 --- a/homeassistant/components/sensor/shodan.py +++ b/homeassistant/components/sensor/shodan.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ATTR_ATTRIBUTION, CONF_API_KEY, CONF_NAME from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['shodan==1.7.5'] +REQUIREMENTS = ['shodan==1.7.7'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index b606131d0ad..5cddd076014 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1016,7 +1016,7 @@ sense-hat==2.2.0 sharp_aquos_rc==0.3.2 # homeassistant.components.sensor.shodan -shodan==1.7.5 +shodan==1.7.7 # homeassistant.components.notify.simplepush simplepush==1.1.4 From 6bf23f9167ae025d8efaee92a3ab22b1d29c582b Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Mon, 11 Dec 2017 18:32:48 +0100 Subject: [PATCH 028/100] Update tellcore-net to 0.4 (#11087) * Update tellcore-net to 0.4 * Update requirements_all.txt --- homeassistant/components/tellstick.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tellstick.py b/homeassistant/components/tellstick.py index 9746dbf749f..0eef2c4ece1 100644 --- a/homeassistant/components/tellstick.py +++ b/homeassistant/components/tellstick.py @@ -17,7 +17,7 @@ from homeassistant.const import ( from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['tellcore-py==1.1.2', 'tellcore-net==0.3'] +REQUIREMENTS = ['tellcore-py==1.1.2', 'tellcore-net==0.4'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 5cddd076014..1b606774859 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1075,7 +1075,7 @@ tank_utility==1.4.0 tapsaff==0.1.3 # homeassistant.components.tellstick -tellcore-net==0.3 +tellcore-net==0.4 # homeassistant.components.tellstick tellcore-py==1.1.2 From a79c7ee217af548af6da943dd0e597d6c966268a Mon Sep 17 00:00:00 2001 From: Jan Almeroth Date: Mon, 11 Dec 2017 22:29:52 +0100 Subject: [PATCH 029/100] Bump pymusiccast to version 0.1.6 (#11091) --- homeassistant/components/media_player/yamaha_musiccast.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/media_player/yamaha_musiccast.py b/homeassistant/components/media_player/yamaha_musiccast.py index bfcffff6bb4..b42a5ae474c 100644 --- a/homeassistant/components/media_player/yamaha_musiccast.py +++ b/homeassistant/components/media_player/yamaha_musiccast.py @@ -36,7 +36,7 @@ SUPPORTED_FEATURES = ( KNOWN_HOSTS_KEY = 'data_yamaha_musiccast' INTERVAL_SECONDS = 'interval_seconds' -REQUIREMENTS = ['pymusiccast==0.1.5'] +REQUIREMENTS = ['pymusiccast==0.1.6'] DEFAULT_PORT = 5005 DEFAULT_INTERVAL = 480 diff --git a/requirements_all.txt b/requirements_all.txt index 1b606774859..fc8a4037b85 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -753,7 +753,7 @@ pymodbus==1.3.1 pymonoprice==0.2 # homeassistant.components.media_player.yamaha_musiccast -pymusiccast==0.1.5 +pymusiccast==0.1.6 # homeassistant.components.cover.myq pymyq==0.0.8 From ed06b8cead707c6ee4d4aa8f7a8cdc38898b6550 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Tue, 12 Dec 2017 08:09:47 +0100 Subject: [PATCH 030/100] Use luftdaten module (#10970) * Use luftdaten module * Refactoring * Check meta data * Make name consistent * Remove try block --- homeassistant/components/sensor/luftdaten.py | 121 ++++++++++--------- requirements_all.txt | 3 + 2 files changed, 69 insertions(+), 55 deletions(-) diff --git a/homeassistant/components/sensor/luftdaten.py b/homeassistant/components/sensor/luftdaten.py index e317e89030f..8c5fcc15ec2 100644 --- a/homeassistant/components/sensor/luftdaten.py +++ b/homeassistant/components/sensor/luftdaten.py @@ -5,85 +5,94 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.luftdaten/ """ import asyncio -import json -import logging from datetime import timedelta +import logging -import requests import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_NAME, CONF_RESOURCE, CONF_VERIFY_SSL, CONF_MONITORED_CONDITIONS, - TEMP_CELSIUS) -from homeassistant.helpers.entity import Entity + ATTR_ATTRIBUTION, CONF_MONITORED_CONDITIONS, CONF_NAME, TEMP_CELSIUS) +from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +REQUIREMENTS = ['luftdaten==0.1.1'] _LOGGER = logging.getLogger(__name__) +ATTR_SENSOR_ID = 'sensor_id' + +CONF_ATTRIBUTION = "Data provided by luftdaten.info" + + VOLUME_MICROGRAMS_PER_CUBIC_METER = 'µg/m3' SENSOR_TEMPERATURE = 'temperature' SENSOR_HUMIDITY = 'humidity' SENSOR_PM10 = 'P1' SENSOR_PM2_5 = 'P2' +SENSOR_PRESSURE = 'pressure' SENSOR_TYPES = { SENSOR_TEMPERATURE: ['Temperature', TEMP_CELSIUS], SENSOR_HUMIDITY: ['Humidity', '%'], + SENSOR_PRESSURE: ['Pressure', 'Pa'], SENSOR_PM10: ['PM10', VOLUME_MICROGRAMS_PER_CUBIC_METER], SENSOR_PM2_5: ['PM2.5', VOLUME_MICROGRAMS_PER_CUBIC_METER] } -DEFAULT_NAME = 'Luftdaten Sensor' -DEFAULT_RESOURCE = 'https://api.luftdaten.info/v1/sensor/' -DEFAULT_VERIFY_SSL = True +DEFAULT_NAME = 'Luftdaten' CONF_SENSORID = 'sensorid' -SCAN_INTERVAL = timedelta(minutes=3) +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_SENSORID): cv.positive_int, vol.Required(CONF_MONITORED_CONDITIONS): vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_RESOURCE, default=DEFAULT_RESOURCE): cv.string, - vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean }) @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the Luftdaten sensor.""" + from luftdaten import Luftdaten + name = config.get(CONF_NAME) - sensorid = config.get(CONF_SENSORID) - verify_ssl = config.get(CONF_VERIFY_SSL) + sensor_id = config.get(CONF_SENSORID) - resource = '{}{}/'.format(config.get(CONF_RESOURCE), sensorid) + session = async_get_clientsession(hass) + luftdaten = LuftdatenData(Luftdaten(sensor_id, hass.loop, session)) - rest_client = LuftdatenData(resource, verify_ssl) - rest_client.update() + yield from luftdaten.async_update() - if rest_client.data is None: - _LOGGER.error("Unable to fetch Luftdaten data") - return False + if luftdaten.data is None: + _LOGGER.error("Sensor is not available: %s", sensor_id) + return devices = [] for variable in config[CONF_MONITORED_CONDITIONS]: - devices.append(LuftdatenSensor(rest_client, name, variable)) + if luftdaten.data.values[variable] is None: + _LOGGER.warning("It might be that sensor %s is not providing " + "measurements for %s", sensor_id, variable) + devices.append(LuftdatenSensor(luftdaten, name, variable, sensor_id)) - async_add_devices(devices, True) + async_add_devices(devices) class LuftdatenSensor(Entity): - """Implementation of a LuftdatenSensor sensor.""" + """Implementation of a Luftdaten sensor.""" - def __init__(self, rest_client, name, sensor_type): - """Initialize the LuftdatenSensor sensor.""" - self.rest_client = rest_client + def __init__(self, luftdaten, name, sensor_type, sensor_id): + """Initialize the Luftdaten sensor.""" + self.luftdaten = luftdaten self._name = name self._state = None + self._sensor_id = sensor_id self.sensor_type = sensor_type self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] @@ -95,48 +104,50 @@ class LuftdatenSensor(Entity): @property def state(self): """Return the state of the device.""" - return self._state + return self.luftdaten.data.values[self.sensor_type] @property def unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement - def update(self): - """Get the latest data from REST API and update the state.""" - self.rest_client.update() - value = self.rest_client.data + @property + def device_state_attributes(self): + """Return the state attributes.""" + if self.luftdaten.data.meta is None: + return - if value is None: - self._state = None - else: - parsed_json = json.loads(value) + 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 - log_entries_count = len(parsed_json) - 1 - latest_log_entry = parsed_json[log_entries_count] - sensordata_values = latest_log_entry['sensordatavalues'] - for sensordata_value in sensordata_values: - if sensordata_value['value_type'] == self.sensor_type: - self._state = sensordata_value['value'] + @asyncio.coroutine + def async_update(self): + """Get the latest data from luftdaten.info and update the state.""" + try: + yield from self.luftdaten.async_update() + except TypeError: + pass class LuftdatenData(object): """Class for handling the data retrieval.""" - def __init__(self, resource, verify_ssl): + def __init__(self, data): """Initialize the data object.""" - self._request = requests.Request('GET', resource).prepare() - self._verify_ssl = verify_ssl - self.data = None + self.data = data + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + @asyncio.coroutine + def async_update(self): + """Get the latest data from luftdaten.info.""" + from luftdaten.exceptions import LuftdatenError - def update(self): - """Get the latest data from Luftdaten service.""" try: - with requests.Session() as sess: - response = sess.send( - self._request, timeout=10, verify=self._verify_ssl) - - self.data = response.text - except requests.exceptions.RequestException: - _LOGGER.error("Error fetching data: %s", self._request) - self.data = None + yield from self.data.async_get_data() + except LuftdatenError: + _LOGGER.error("Unable to retrieve data from luftdaten.info") diff --git a/requirements_all.txt b/requirements_all.txt index fc8a4037b85..a5d2936ec36 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -444,6 +444,9 @@ liveboxplaytv==2.0.0 # homeassistant.components.notify.lametric lmnotify==0.0.4 +# homeassistant.components.sensor.luftdaten +luftdaten==0.1.1 + # homeassistant.components.sensor.lyft lyft_rides==0.2 From c7e327ea87d231c50a9e03837377ef581c8b5d66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pierre=20St=C3=A5hl?= Date: Tue, 12 Dec 2017 16:52:39 +0100 Subject: [PATCH 031/100] Bump pyatv to 0.3.9 (#11104) --- homeassistant/components/apple_tv.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/apple_tv.py b/homeassistant/components/apple_tv.py index c8eb1841c0d..bb6bfa0e9db 100644 --- a/homeassistant/components/apple_tv.py +++ b/homeassistant/components/apple_tv.py @@ -18,7 +18,7 @@ from homeassistant.helpers import discovery from homeassistant.components.discovery import SERVICE_APPLE_TV import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pyatv==0.3.8'] +REQUIREMENTS = ['pyatv==0.3.9'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index a5d2936ec36..8f40709d187 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -631,7 +631,7 @@ pyasn1-modules==0.1.5 pyasn1==0.3.7 # homeassistant.components.apple_tv -pyatv==0.3.8 +pyatv==0.3.9 # homeassistant.components.device_tracker.bbox # homeassistant.components.sensor.bbox From aeba81e193bb3d6b2f5431d286c9d0bb746698ed Mon Sep 17 00:00:00 2001 From: Dan Nixon Date: Tue, 12 Dec 2017 16:18:46 +0000 Subject: [PATCH 032/100] Report availability for TP-Link smart bulbs (#10976) --- homeassistant/components/light/tplink.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/light/tplink.py b/homeassistant/components/light/tplink.py index f6a544950c0..692a5fb86ec 100644 --- a/homeassistant/components/light/tplink.py +++ b/homeassistant/components/light/tplink.py @@ -72,6 +72,7 @@ class TPLinkSmartBulb(Light): if name is not None: self._name = name self._state = None + self._available = True self._color_temp = None self._brightness = None self._rgb = None @@ -83,6 +84,11 @@ class TPLinkSmartBulb(Light): """Return the name of the Smart Bulb, if any.""" return self._name + @property + def available(self) -> bool: + """Return if bulb is available.""" + return self._available + @property def device_state_attributes(self): """Return the state attributes of the device.""" @@ -132,6 +138,7 @@ class TPLinkSmartBulb(Light): """Update the TP-Link Bulb's state.""" from pyHS100 import SmartDeviceException try: + self._available = True if self._supported_features == 0: self.get_features() self._state = ( @@ -163,8 +170,10 @@ class TPLinkSmartBulb(Light): except KeyError: # device returned no daily/monthly history pass + except (SmartDeviceException, OSError) as ex: - _LOGGER.warning('Could not read state for %s: %s', self._name, ex) + _LOGGER.warning("Could not read state for %s: %s", self._name, ex) + self._available = False @property def supported_features(self): From 95cd2035b69b614af01d8bd611904d96069c36a3 Mon Sep 17 00:00:00 2001 From: Eitan Mosenkis Date: Wed, 13 Dec 2017 01:04:42 +0200 Subject: [PATCH 033/100] Fix incorrect comment. (#11111) --- homeassistant/helpers/discovery.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/helpers/discovery.py b/homeassistant/helpers/discovery.py index 46eeef45f14..6a527021c77 100644 --- a/homeassistant/helpers/discovery.py +++ b/homeassistant/helpers/discovery.py @@ -149,7 +149,7 @@ def async_load_platform(hass, component, platform, discovered=None, Use `listen_platform` to register a callback for these events. Warning: Do not yield from this inside a setup method to avoid a dead lock. - Use `hass.loop.async_add_job(async_load_platform(..))` instead. + Use `hass.async_add_job(async_load_platform(..))` instead. This method is a coroutine. """ From 168065b9bc79e6ad7b7028a91a90828b02d70fec Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 12 Dec 2017 21:12:41 -0800 Subject: [PATCH 034/100] Update Warrant (#11101) * Update Warrant * Lint --- homeassistant/components/cloud/__init__.py | 2 +- homeassistant/components/cloud/auth_api.py | 7 +++++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 9bd91d22beb..2844b0c88f3 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -16,7 +16,7 @@ from homeassistant.components.alexa import smart_home from . import http_api, iot from .const import CONFIG_DIR, DOMAIN, SERVERS -REQUIREMENTS = ['warrant==0.5.0'] +REQUIREMENTS = ['warrant==0.6.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/cloud/auth_api.py b/homeassistant/components/cloud/auth_api.py index 95bf5596835..9cad3ec77f3 100644 --- a/homeassistant/components/cloud/auth_api.py +++ b/homeassistant/components/cloud/auth_api.py @@ -68,11 +68,14 @@ def register(cloud, email, password): from botocore.exceptions import ClientError cognito = _cognito(cloud) + # Workaround for bug in Warrant. PR with fix: + # https://github.com/capless/warrant/pull/82 + cognito.add_base_attributes() try: if cloud.cognito_email_based: - cognito.register(email, password, email=email) + cognito.register(email, password) else: - cognito.register(_generate_username(email), password, email=email) + cognito.register(_generate_username(email), password) except ClientError as err: raise _map_aws_exception(err) diff --git a/requirements_all.txt b/requirements_all.txt index 8f40709d187..d21b58bc82a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1149,7 +1149,7 @@ wakeonlan==0.2.2 waqiasync==1.0.0 # homeassistant.components.cloud -warrant==0.5.0 +warrant==0.6.1 # homeassistant.components.media_player.gpmdp websocket-client==0.37.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6e1b617ef66..363f561de0d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -182,7 +182,7 @@ vultr==0.1.2 wakeonlan==0.2.2 # homeassistant.components.cloud -warrant==0.5.0 +warrant==0.6.1 # homeassistant.components.sensor.yahoo_finance yahoo-finance==1.4.0 From 37efd5a5cd385fcab607668320a8c21cea7245cd Mon Sep 17 00:00:00 2001 From: Ryan McLean Date: Wed, 13 Dec 2017 09:17:12 +0000 Subject: [PATCH 035/100] Fixed typo in automation.py (#11116) --- homeassistant/components/config/automation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/config/automation.py b/homeassistant/components/config/automation.py index 64eccfaa2b8..6ede91e9b66 100644 --- a/homeassistant/components/config/automation.py +++ b/homeassistant/components/config/automation.py @@ -1,4 +1,4 @@ -"""Provide configuration end points for Z-Wave.""" +"""Provide configuration end points for Automations.""" import asyncio from homeassistant.components.config import EditIdBasedConfigView From 638dd25affb797037988adec94d7c0fbe4b90e92 Mon Sep 17 00:00:00 2001 From: Philipp Schmitt Date: Wed, 13 Dec 2017 10:58:49 +0100 Subject: [PATCH 036/100] Add media position properties (#10076) * Add media progress information * Remove unnecessary comments * Remove datetime import * Remove pysonyavr dependency * Fix doc syntax (D205) * Lint fix: no-else-return * Don't attempt to set media progress info if program is None * Fix Python 3.4 compatibility * Explicitely depend on pyteleloisirs * Only update remaining play time when it changed * Fix floot state table --- .../components/media_player/liveboxplaytv.py | 42 ++++++++++++++++--- requirements_all.txt | 5 ++- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/media_player/liveboxplaytv.py b/homeassistant/components/media_player/liveboxplaytv.py index 15698ec5022..8093f0d3dbe 100644 --- a/homeassistant/components/media_player/liveboxplaytv.py +++ b/homeassistant/components/media_player/liveboxplaytv.py @@ -20,8 +20,9 @@ from homeassistant.const import ( CONF_HOST, CONF_PORT, STATE_ON, STATE_OFF, STATE_PLAYING, STATE_PAUSED, CONF_NAME) import homeassistant.helpers.config_validation as cv +import homeassistant.util.dt as dt_util -REQUIREMENTS = ['liveboxplaytv==2.0.0'] +REQUIREMENTS = ['liveboxplaytv==2.0.2', 'pyteleloisirs==3.3'] _LOGGER = logging.getLogger(__name__) @@ -76,19 +77,32 @@ class LiveboxPlayTvDevice(MediaPlayerDevice): self._channel_list = {} self._current_channel = None self._current_program = None + self._media_duration = None + self._media_remaining_time = None self._media_image_url = None + self._media_last_updated = None @asyncio.coroutine def async_update(self): """Retrieve the latest data.""" + import pyteleloisirs try: self._state = self.refresh_state() # Update current channel channel = self._client.channel if channel is not None: - self._current_program = yield from \ - self._client.async_get_current_program_name() self._current_channel = channel + program = yield from \ + self._client.async_get_current_program() + if program and self._current_program != program.get('name'): + self._current_program = program.get('name') + # Media progress info + self._media_duration = \ + pyteleloisirs.get_program_duration(program) + rtime = pyteleloisirs.get_remaining_time(program) + if rtime != self._media_remaining_time: + self._media_remaining_time = rtime + self._media_last_updated = dt_util.utcnow() # Set media image to current program if a thumbnail is # available. Otherwise we'll use the channel's image. img_size = 800 @@ -100,7 +114,6 @@ class LiveboxPlayTvDevice(MediaPlayerDevice): chan_img_url = \ self._client.get_current_channel_image(img_size) self._media_image_url = chan_img_url - self.refresh_channel_list() except requests.ConnectionError: self._state = None @@ -149,8 +162,25 @@ class LiveboxPlayTvDevice(MediaPlayerDevice): if self._current_program: return '{}: {}'.format(self._current_channel, self._current_program) - else: - return self._current_channel + return self._current_channel + + @property + def media_duration(self): + """Duration of current playing media in seconds.""" + return self._media_duration + + @property + def media_position(self): + """Position of current playing media in seconds.""" + return self._media_remaining_time + + @property + def media_position_updated_at(self): + """When was the position of the current playing media valid. + + Returns value from homeassistant.util.dt.utcnow(). + """ + return self._media_last_updated @property def supported_features(self): diff --git a/requirements_all.txt b/requirements_all.txt index d21b58bc82a..bfdf3ba601e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -438,7 +438,7 @@ limitlessled==1.0.8 linode-api==4.1.4b2 # homeassistant.components.media_player.liveboxplaytv -liveboxplaytv==2.0.0 +liveboxplaytv==2.0.2 # homeassistant.components.lametric # homeassistant.components.notify.lametric @@ -816,6 +816,9 @@ pysma==0.1.3 # homeassistant.components.switch.snmp pysnmp==4.4.2 +# homeassistant.components.media_player.liveboxplaytv +pyteleloisirs==3.3 + # homeassistant.components.sensor.thinkingcleaner # homeassistant.components.switch.thinkingcleaner pythinkingcleaner==0.0.3 From 4ec3289f9c7e626a36e491010a11cf0ade2633e2 Mon Sep 17 00:00:00 2001 From: Nolan Gilley Date: Wed, 13 Dec 2017 15:21:14 -0500 Subject: [PATCH 037/100] update pyripple (#11122) --- homeassistant/components/sensor/ripple.py | 6 ++++-- requirements_all.txt | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensor/ripple.py b/homeassistant/components/sensor/ripple.py index 6f92a1a3390..d516706fdc0 100644 --- a/homeassistant/components/sensor/ripple.py +++ b/homeassistant/components/sensor/ripple.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import (CONF_NAME, ATTR_ATTRIBUTION) from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['python-ripple-api==0.0.2'] +REQUIREMENTS = ['python-ripple-api==0.0.3'] CONF_ADDRESS = 'address' CONF_ATTRIBUTION = "Data provided by ripple.com" @@ -71,4 +71,6 @@ class RippleSensor(Entity): def update(self): """Get the latest state of the sensor.""" from pyripple import get_balance - self._state = get_balance(self.address) + balance = get_balance(self.address) + if balance is not None: + self._state = balance diff --git a/requirements_all.txt b/requirements_all.txt index bfdf3ba601e..6431d526359 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -883,7 +883,7 @@ python-nmap==0.6.1 python-pushover==0.3 # homeassistant.components.sensor.ripple -python-ripple-api==0.0.2 +python-ripple-api==0.0.3 # homeassistant.components.media_player.roku python-roku==3.1.3 From d547345f90ffac51525d467f7bfa301d7ac44692 Mon Sep 17 00:00:00 2001 From: Andrea Campi Date: Thu, 14 Dec 2017 04:00:30 +0000 Subject: [PATCH 038/100] Skip HASS emulated Hue bridges from detection. (#11128) When refactoring the Hue support we lost a check for HASS bridges; restore that. --- homeassistant/components/hue.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/hue.py b/homeassistant/components/hue.py index 778dcc8dfab..3dad4429b53 100644 --- a/homeassistant/components/hue.py +++ b/homeassistant/components/hue.py @@ -107,6 +107,9 @@ def setup(hass, config): def bridge_discovered(hass, service, discovery_info): """Dispatcher for Hue discovery events.""" + if "HASS Bridge" in discovery_info.get('name', ''): + return + host = discovery_info.get('host') serial = discovery_info.get('serial') From e62754447980f89594f00fb0e07496679d16bb9a Mon Sep 17 00:00:00 2001 From: Andrea Campi Date: Thu, 14 Dec 2017 04:01:59 +0000 Subject: [PATCH 039/100] Always consume the no_throttle keyword argument. (#11126) The current code relies on the assumption that the first invocation will never specify no_throttle=True. However that puts us in a pickle when writing unit tests: if we had a fictitious: def setup_platform(): update() @Throttle(MIN_TIME_BETWEEN_SCANS) def update(): pass Then given multiple tests, the second and some of subsequent tests would be throttled (depending on timing). But we also can't change that code to call `update(no_throttle=True)' because that's not currently accepted. This diff shouldn't change the visibile behavior of any component, but allows this extra flexibility. --- homeassistant/util/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/util/__init__.py b/homeassistant/util/__init__.py index 646edcf1c35..cb3ebeb7ee6 100644 --- a/homeassistant/util/__init__.py +++ b/homeassistant/util/__init__.py @@ -299,7 +299,7 @@ class Throttle(object): return None # Check if method is never called or no_throttle is given - force = not throttle[1] or kwargs.pop('no_throttle', False) + force = kwargs.pop('no_throttle', False) or not throttle[1] try: if force or utcnow() - throttle[1] > self.min_time: From b5d3a4736b3f8efa2c5356f71d9f01d1c98d83c7 Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Wed, 13 Dec 2017 23:02:24 -0500 Subject: [PATCH 040/100] Add problem device class (#11130) --- homeassistant/components/binary_sensor/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/binary_sensor/__init__.py b/homeassistant/components/binary_sensor/__init__.py index 9e48a30d04a..a0c141914ed 100644 --- a/homeassistant/components/binary_sensor/__init__.py +++ b/homeassistant/components/binary_sensor/__init__.py @@ -34,6 +34,7 @@ DEVICE_CLASSES = [ 'plug', # On means plugged in, Off means unplugged 'power', # Power, over-current, etc '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 From 2cced1dac35dfe8535ca37f00195b4b0c861bbf9 Mon Sep 17 00:00:00 2001 From: Michael Pollett Date: Thu, 14 Dec 2017 04:03:41 +0000 Subject: [PATCH 041/100] set default utc offset to 0 (#11114) --- homeassistant/components/sensor/efergy.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/efergy.py b/homeassistant/components/sensor/efergy.py index 0f24905c5f5..c14a33dce01 100644 --- a/homeassistant/components/sensor/efergy.py +++ b/homeassistant/components/sensor/efergy.py @@ -31,6 +31,7 @@ CONF_COST = 'cost' CONF_CURRENT_VALUES = 'current_values' DEFAULT_PERIOD = 'year' +DEFAULT_UTC_OFFSET = '0' SENSOR_TYPES = { CONF_INSTANT: ['Energy Usage', 'W'], @@ -50,7 +51,7 @@ SENSORS_SCHEMA = vol.Schema({ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_APPTOKEN): cv.string, - vol.Optional(CONF_UTC_OFFSET): cv.string, + vol.Optional(CONF_UTC_OFFSET, default=DEFAULT_UTC_OFFSET): cv.string, vol.Required(CONF_MONITORED_VARIABLES): [SENSORS_SCHEMA] }) From 3473ef63af8587c23c9a398e4403de9bc8ab0be2 Mon Sep 17 00:00:00 2001 From: BryanJacobs Date: Thu, 14 Dec 2017 15:07:23 +1100 Subject: [PATCH 042/100] Allow using more than one keyboard remote (#11061) * Allow using more than one keyboard remote This sets up one thread per keyboard remote, listening for events. * Remove enclosing block in keyboard_remote * Remove unnecessary semantic check for keyboard_remote --- homeassistant/components/keyboard_remote.py | 69 ++++++++++++++------- 1 file changed, 48 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/keyboard_remote.py b/homeassistant/components/keyboard_remote.py index 5a81f6d2a9e..d737c555873 100644 --- a/homeassistant/components/keyboard_remote.py +++ b/homeassistant/components/keyboard_remote.py @@ -21,7 +21,7 @@ REQUIREMENTS = ['evdev==0.6.1'] _LOGGER = logging.getLogger(__name__) DEVICE_DESCRIPTOR = 'device_descriptor' -DEVICE_ID_GROUP = 'Device descriptor or name' +DEVICE_ID_GROUP = 'Device description' DEVICE_NAME = 'device_name' DOMAIN = 'keyboard_remote' @@ -36,12 +36,13 @@ KEYBOARD_REMOTE_DISCONNECTED = 'keyboard_remote_disconnected' TYPE = 'type' CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - vol.Exclusive(DEVICE_DESCRIPTOR, DEVICE_ID_GROUP): cv.string, - vol.Exclusive(DEVICE_NAME, DEVICE_ID_GROUP): cv.string, - vol.Optional(TYPE, default='key_up'): - vol.All(cv.string, vol.Any('key_up', 'key_down', 'key_hold')), - }), + DOMAIN: + vol.All(cv.ensure_list, [vol.Schema({ + vol.Exclusive(DEVICE_DESCRIPTOR, DEVICE_ID_GROUP): cv.string, + vol.Exclusive(DEVICE_NAME, DEVICE_ID_GROUP): cv.string, + vol.Optional(TYPE, default='key_up'): + vol.All(cv.string, vol.Any('key_up', 'key_down', 'key_hold')) + })]) }, extra=vol.ALLOW_EXTRA) @@ -49,11 +50,6 @@ def setup(hass, config): """Set up the keyboard_remote.""" config = config.get(DOMAIN) - if not config.get(DEVICE_DESCRIPTOR) and\ - not config.get(DEVICE_NAME): - _LOGGER.error("No device_descriptor or device_name found") - return - keyboard_remote = KeyboardRemote( hass, config @@ -63,7 +59,7 @@ def setup(hass, config): keyboard_remote.run() def _stop_keyboard_remote(_event): - keyboard_remote.stopped.set() + keyboard_remote.stop() hass.bus.listen_once( EVENT_HOMEASSISTANT_START, @@ -77,19 +73,21 @@ def setup(hass, config): return True -class KeyboardRemote(threading.Thread): +class KeyboardRemoteThread(threading.Thread): """This interfaces with the inputdevice using evdev.""" - def __init__(self, hass, config): - """Construct a KeyboardRemote interface object.""" - from evdev import InputDevice, list_devices + def __init__(self, hass, device_name, device_descriptor, key_value): + """Construct a thread listening for events on one device.""" + self.hass = hass + self.device_name = device_name + self.device_descriptor = device_descriptor + self.key_value = key_value - self.device_descriptor = config.get(DEVICE_DESCRIPTOR) - self.device_name = config.get(DEVICE_NAME) if self.device_descriptor: self.device_id = self.device_descriptor else: self.device_id = self.device_name + self.dev = self._get_keyboard_device() if self.dev is not None: _LOGGER.debug("Keyboard connected, %s", self.device_id) @@ -103,6 +101,7 @@ class KeyboardRemote(threading.Thread): id_folder = '/dev/input/by-id/' if os.path.isdir(id_folder): + from evdev import InputDevice, list_devices device_names = [InputDevice(file_name).name for file_name in list_devices()] _LOGGER.debug( @@ -116,7 +115,6 @@ class KeyboardRemote(threading.Thread): threading.Thread.__init__(self) self.stopped = threading.Event() self.hass = hass - self.key_value = KEY_VALUE.get(config.get(TYPE, 'key_up')) def _get_keyboard_device(self): """Get the keyboard device.""" @@ -145,7 +143,7 @@ class KeyboardRemote(threading.Thread): while not self.stopped.isSet(): # Sleeps to ease load on processor - time.sleep(.1) + time.sleep(.05) if self.dev is None: self.dev = self._get_keyboard_device() @@ -178,3 +176,32 @@ class KeyboardRemote(threading.Thread): KEYBOARD_REMOTE_COMMAND_RECEIVED, {KEY_CODE: event.code} ) + + +class KeyboardRemote(object): + """Sets up one thread per device.""" + + def __init__(self, hass, config): + """Construct a KeyboardRemote interface object.""" + self.threads = [] + for dev_block in config: + device_descriptor = dev_block.get(DEVICE_DESCRIPTOR) + device_name = dev_block.get(DEVICE_NAME) + key_value = KEY_VALUE.get(dev_block.get(TYPE, 'key_up')) + + if device_descriptor is not None\ + or device_name is not None: + thread = KeyboardRemoteThread(hass, device_name, + device_descriptor, + key_value) + self.threads.append(thread) + + def run(self): + """Run all event listener threads.""" + for thread in self.threads: + thread.start() + + def stop(self): + """Stop all event listener threads.""" + for thread in self.threads: + thread.stopped.set() From 1c8b5838cdc6f633f5564c5a2be8fe91e90d9898 Mon Sep 17 00:00:00 2001 From: Greg Laabs Date: Wed, 13 Dec 2017 20:14:56 -0800 Subject: [PATCH 043/100] ISY994 sensor improvements (#10805) * Fire events for ISY994 control events This allows hass to react directly to Insteon button presses (on switches and remotes), including presses, double-presses, and long holds * Move change event subscription to after entity is added to hass The event handler method requires `self.hass` to exist, which doesn't have a value until the async_added_to_hass method is called. Should eliminate a race condition. * Overhaul binary sensors in ISY994 to be functional "out of the box" We now smash all of the subnodes from the ISY994 in to one Hass binary_sensor, and automatically support both paradigms of state reporting that Insteon sensors can do. Sometimes a single node's state represents the sensor's state, other times two nodes are used and only "ON" events are sent from each. The logic between the two forunately do not conflict so we can support both without knowing which mode the device is in. This also allows us to handle the heartbeat functionality that certain sensors have - we simply store the timestamp of the heartbeat as an attribute on the sensor device. It defaults to Unknown on bootup if and only if the device supports heartbeats, due to the presence of subnode 4. * Parse the binary sensor device class from the ISY's device "type" Now we automatically know which sensors are moisture, motion, and openings! (We also reverse the moisture sensor state, because Insteon reports ON for dry on the primary node.) * Code review tweaks The one material change here is that the event subscribers were moved to the `async_added_to_hass` method, as the handlers depend on things that only exist after the entity has been added. * Handle cases where a sensor's state is unknown When the ISY first boots up, if a battery-powered sensor has not reported in yet (due to heartbeat or a change in state), the state is unknown until it does. * Clean up from code review Fix coroutine await, remove unnecessary exception check, and return None when state is unknown * Unknown value from PyISY is now -inf rather than -1 * Move heartbeat handling to a separate sensor Now all heartbeat-compatible sensors will have a separate `binary_sensor` device that represents the battery state (on = dead) * Add support for Unknown state, which is being added in next PyISY PyISY will report unknown states as the number "-inf". This is implemented in the base ISY994 component, but subcomponents that override the `state` method needed some extra logic to handle it as well. * Change a couple try blocks to explicit None checks * Bump PyISY to 1.1.0, now that it has been published! * Remove -inf checking from base component The implementation of the -inf checking was improved in another branch which has been merged in to this branch already. * Restrict negative-node and heartbeat support to known compatible types Not all Insteon sensors use the same subnode IDs for the same things, so we need to use different logic depending on device type. Negative node and heartbeat support is now only used for leak sensors and open/close sensors. * Use new style string formatting * Add binary sensor detection for pre-5.x firmware Meant to do this originally; writing documentation revealed that this requirement was missed! --- .../components/binary_sensor/isy994.py | 335 +++++++++++++++++- homeassistant/components/cover/isy994.py | 5 +- homeassistant/components/isy994.py | 64 +++- homeassistant/components/lock/isy994.py | 5 +- homeassistant/components/sensor/isy994.py | 3 + homeassistant/components/switch/isy994.py | 5 +- requirements_all.txt | 2 +- 7 files changed, 396 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/binary_sensor/isy994.py b/homeassistant/components/binary_sensor/isy994.py index fd6269e3630..a5b61c9ffed 100644 --- a/homeassistant/components/binary_sensor/isy994.py +++ b/homeassistant/components/binary_sensor/isy994.py @@ -4,24 +4,31 @@ Support for ISY994 binary sensors. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/binary_sensor.isy994/ """ + +import asyncio import logging +from datetime import timedelta 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.const import STATE_ON, STATE_OFF from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) -VALUE_TO_STATE = { - False: STATE_OFF, - True: STATE_ON, -} - 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'], + 'motion': ['16.1', '16.4', '16.5', '16.3'] +} + # pylint: disable=unused-argument def setup_platform(hass, config: ConfigType, @@ -32,10 +39,46 @@ def setup_platform(hass, config: ConfigType, return False devices = [] + devices_by_nid = {} + child_nodes = [] for node in isy.filter_nodes(isy.SENSOR_NODES, units=UOM, states=STATES): - devices.append(ISYBinarySensorDevice(node)) + if node.parent_node is None: + device = ISYBinarySensorDevice(node) + devices.append(device) + devices_by_nid[node.nid] = device + else: + # We'll process the child nodes last, to ensure all parent nodes + # have been processed + child_nodes.append(node) + + for node in child_nodes: + try: + parent_device = devices_by_nid[node.parent_node.nid] + except KeyError: + _LOGGER.error("Node %s has a parent node %s, but no device " + "was created for the parent. Skipping.", + 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 + if subnode_id == 4: + # Subnode 4 is the heartbeat node, which we will represent + # as a separate binary_sensor + device = ISYBinarySensorHeartbeat(node, parent_device) + parent_device.add_heartbeat_device(device) + devices.append(device) + elif subnode_id == 2: + parent_device.add_negative_node(node) + else: + # We don't yet have any special logic for other sensor types, + # so add the nodes as individual devices + device = ISYBinarySensorDevice(node) + devices.append(device) for program in isy.PROGRAMS.get(DOMAIN, []): try: @@ -48,23 +91,281 @@ def setup_platform(hass, config: ConfigType, add_devices(devices) +def _detect_device_type(node) -> str: + try: + device_type = node.type + except AttributeError: + # The type attribute didn't exist in the ISY's API response + return None + + split_type = device_type.split('.') + for device_class, ids in ISY_DEVICE_TYPES.items(): + if '{}.{}'.format(split_type[0], split_type[1]) in ids: + return device_class + + return None + + +def _is_val_unknown(val): + """Determine if a number value represents UNKNOWN from PyISY.""" + return val == -1*float('inf') + + class ISYBinarySensorDevice(isy.ISYDevice, BinarySensorDevice): - """Representation of an ISY994 binary sensor device.""" + """Representation of an ISY994 binary sensor device. + + Often times, a single device is represented by multiple nodes in the ISY, + allowing for different nuances in how those devices report their on and + off events. This class turns those multiple nodes in to a single Hass + entity and handles both ways that ISY binary sensors can work. + """ def __init__(self, node) -> None: """Initialize the ISY994 binary sensor device.""" - isy.ISYDevice.__init__(self, node) + super().__init__(node) + self._negative_node = None + self._heartbeat_device = None + self._device_class_from_type = _detect_device_type(self._node) + # pylint: disable=protected-access + if _is_val_unknown(self._node.status._val): + self._computed_state = None + else: + self._computed_state = bool(self._node.status._val) + + @asyncio.coroutine + def async_added_to_hass(self) -> None: + """Subscribe to the node and subnode event emitters.""" + yield from super().async_added_to_hass() + + self._node.controlEvents.subscribe(self._positive_node_control_handler) + + if self._negative_node is not None: + self._negative_node.controlEvents.subscribe( + self._negative_node_control_handler) + + def add_heartbeat_device(self, device) -> None: + """Register a heartbeat device for this sensor. + + The heartbeat node beats on its own, but we can gain a little + reliability by considering any node activity for this sensor + to be a heartbeat as well. + """ + self._heartbeat_device = device + + def _heartbeat(self) -> None: + """Send a heartbeat to our heartbeat device, if we have one.""" + if self._heartbeat_device is not None: + self._heartbeat_device.heartbeat() + + def add_negative_node(self, child) -> None: + """Add a negative node to this binary sensor device. + + The negative node is a node that can receive the 'off' events + for the sensor, depending on device configuration and type. + """ + self._negative_node = child + + if not _is_val_unknown(self._negative_node): + # 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. + self._computed_state = None + + def _negative_node_control_handler(self, event: object) -> None: + """Handle an "On" control event from the "negative" node.""" + if event == 'DON': + _LOGGER.debug("Sensor %s turning Off via the Negative node " + "sending a DON command", self.name) + self._computed_state = False + self.schedule_update_ha_state() + self._heartbeat() + + def _positive_node_control_handler(self, event: object) -> None: + """Handle On and Off control event coming from the primary node. + + Depending on device configuration, sometimes only On events + will come to this node, with the negative node representing Off + events + """ + if event == 'DON': + _LOGGER.debug("Sensor %s turning On via the Primary node " + "sending a DON command", self.name) + self._computed_state = True + self.schedule_update_ha_state() + self._heartbeat() + if event == 'DOF': + _LOGGER.debug("Sensor %s turning Off via the Primary node " + "sending a DOF command", self.name) + self._computed_state = False + self.schedule_update_ha_state() + self._heartbeat() + + # pylint: disable=unused-argument + def on_update(self, event: object) -> None: + """Ignore primary node status updates. + + We listen directly to the Control events on all nodes for this + device. + """ + pass + + @property + def value(self) -> object: + """Get the current value of the device. + + Insteon leak sensors set their primary node to On when the state is + DRY, not WET, so we invert the binary state if the user indicates + that it is a moisture sensor. + """ + if self._computed_state is None: + # Do this first so we don't invert None on moisture sensors + return None + + if self.device_class == 'moisture': + return not self._computed_state + + return self._computed_state + + @property + def is_on(self) -> bool: + """Get whether the ISY994 binary sensor device is on. + + Note: This method will return false if the current state is UNKNOWN + """ + return bool(self.value) + + @property + def state(self): + """Return the state of the binary sensor.""" + if self._computed_state is None: + return None + return STATE_ON if self.is_on else STATE_OFF + + @property + def device_class(self) -> str: + """Return the class of this device. + + This was discovered by parsing the device type code during init + """ + return self._device_class_from_type + + +class ISYBinarySensorHeartbeat(isy.ISYDevice, BinarySensorDevice): + """Representation of the battery state of an ISY994 sensor.""" + + def __init__(self, node, parent_device) -> None: + """Initialize the ISY994 binary sensor device.""" + super().__init__(node) + self._computed_state = None + self._parent_device = parent_device + self._heartbeat_timer = None + + @asyncio.coroutine + def async_added_to_hass(self) -> None: + """Subscribe to the node and subnode event emitters.""" + yield from super().async_added_to_hass() + + self._node.controlEvents.subscribe( + self._heartbeat_node_control_handler) + + # Start the timer on bootup, so we can change from UNKNOWN to ON + self._restart_timer() + + def _heartbeat_node_control_handler(self, event: object) -> None: + """Update the heartbeat timestamp when an On event is sent.""" + if event == 'DON': + self.heartbeat() + + def heartbeat(self): + """Mark the device as online, and restart the 25 hour timer. + + This gets called when the heartbeat node beats, but also when the + parent sensor sends any events, as we can trust that to mean the device + is online. This mitigates the risk of false positives due to a single + missed heartbeat event. + """ + self._computed_state = False + self._restart_timer() + self.schedule_update_ha_state() + + def _restart_timer(self): + """Restart the 25 hour timer.""" + try: + self._heartbeat_timer() + self._heartbeat_timer = None + except TypeError: + # No heartbeat timer is active + pass + + # pylint: disable=unused-argument + @callback + def timer_elapsed(now) -> None: + """Heartbeat missed; set state to indicate dead battery.""" + self._computed_state = True + self._heartbeat_timer = None + self.schedule_update_ha_state() + + point_in_time = dt_util.utcnow() + timedelta(hours=25) + _LOGGER.debug("Timer starting. Now: %s Then: %s", + dt_util.utcnow(), point_in_time) + + self._heartbeat_timer = async_track_point_in_utc_time( + self.hass, timer_elapsed, point_in_time) + + # pylint: disable=unused-argument + def on_update(self, event: object) -> None: + """Ignore node status updates. + + We listen directly to the Control events for this device. + """ + pass + + @property + def value(self) -> object: + """Get the current value of this sensor.""" + return self._computed_state + + @property + def is_on(self) -> bool: + """Get whether the ISY994 binary sensor device is on. + + Note: This method will return false if the current state is UNKNOWN + """ + return bool(self.value) + + @property + def state(self): + """Return the state of the binary sensor.""" + if self._computed_state is None: + return None + return STATE_ON if self.is_on else STATE_OFF + + @property + def device_class(self) -> str: + """Get the class of this device.""" + return 'battery' + + @property + def device_state_attributes(self): + """Get the state attributes for the device.""" + attr = super().device_state_attributes + attr['parent_entity_id'] = self._parent_device.entity_id + return attr + + +class ISYBinarySensorProgram(isy.ISYDevice, BinarySensorDevice): + """Representation of an ISY994 binary sensor program. + + This does not need all of the subnode logic in the device version of binary + sensors. + """ + + def __init__(self, name, node) -> None: + """Initialize the ISY994 binary sensor program.""" + super().__init__(node) + self._name = name @property def is_on(self) -> bool: """Get whether the ISY994 binary sensor device is on.""" return bool(self.value) - - -class ISYBinarySensorProgram(ISYBinarySensorDevice): - """Representation of an ISY994 binary sensor program.""" - - def __init__(self, name, node) -> None: - """Initialize the ISY994 binary sensor program.""" - ISYBinarySensorDevice.__init__(self, node) - self._name = name diff --git a/homeassistant/components/cover/isy994.py b/homeassistant/components/cover/isy994.py index 1e83038278c..4dd1c9be364 100644 --- a/homeassistant/components/cover/isy994.py +++ b/homeassistant/components/cover/isy994.py @@ -69,7 +69,10 @@ class ISYCoverDevice(isy.ISYDevice, CoverDevice): @property def state(self) -> str: """Get the state of the ISY994 cover device.""" - return VALUE_TO_STATE.get(self.value, STATE_OPEN) + if self.is_unknown(): + return None + else: + return VALUE_TO_STATE.get(self.value, STATE_OPEN) def open_cover(self, **kwargs) -> None: """Send the open cover command to the ISY994 cover device.""" diff --git a/homeassistant/components/isy994.py b/homeassistant/components/isy994.py index 7686eb7dc7d..af1846c7bf8 100644 --- a/homeassistant/components/isy994.py +++ b/homeassistant/components/isy994.py @@ -4,6 +4,7 @@ Support the ISY-994 controllers. For configuration details please visit the documentation for this component at https://home-assistant.io/components/isy994/ """ +import asyncio from collections import namedtuple import logging from urllib.parse import urlparse @@ -17,7 +18,7 @@ from homeassistant.helpers import discovery, config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType, Dict # noqa -REQUIREMENTS = ['PyISY==1.0.8'] +REQUIREMENTS = ['PyISY==1.1.0'] _LOGGER = logging.getLogger(__name__) @@ -91,6 +92,34 @@ def filter_nodes(nodes: list, units: list=None, states: list=None) -> list: 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): + return False + + if sensor_identifier in path or sensor_identifier in node.name: + return True + + # This method is most reliable but only works on 5.x firmware + try: + if node.node_def_id == 'BinaryAlarm': + 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 @@ -106,7 +135,7 @@ def _categorize_nodes(hidden_identifier: str, sensor_identifier: str) -> None: hidden = hidden_identifier in path or hidden_identifier in node.name if hidden: node.name += hidden_identifier - if sensor_identifier in path or sensor_identifier in node.name: + if _is_node_a_sensor(node, path, sensor_identifier): SENSOR_NODES.append(node) elif isinstance(node, PYISY.Nodes.Node): NODES.append(node) @@ -227,15 +256,31 @@ class ISYDevice(Entity): def __init__(self, node) -> None: """Initialize the insteon device.""" self._node = node + self._change_handler = None + self._control_handler = None + @asyncio.coroutine + def async_added_to_hass(self) -> None: + """Subscribe to the node change events.""" self._change_handler = self._node.status.subscribe( 'changed', self.on_update) + if hasattr(self._node, 'controlEvents'): + self._control_handler = self._node.controlEvents.subscribe( + self.on_control) + # pylint: disable=unused-argument def on_update(self, event: object) -> None: """Handle the update event from the ISY994 Node.""" self.schedule_update_ha_state() + def on_control(self, event: object) -> None: + """Handle a control event from the ISY994 Node.""" + self.hass.bus.fire('isy994_control', { + 'entity_id': self.entity_id, + 'control': event + }) + @property def domain(self) -> str: """Get the domain of the device.""" @@ -270,6 +315,21 @@ class ISYDevice(Entity): # pylint: disable=protected-access return self._node.status._val + def is_unknown(self) -> bool: + """Get whether or not the value of this Entity's node is unknown. + + PyISY reports unknown values as -inf + """ + return self.value == -1 * float('inf') + + @property + def state(self): + """Return the state of the ISY device.""" + if self.is_unknown(): + return None + else: + return super().state + @property def device_state_attributes(self) -> Dict: """Get the state attributes for the device.""" diff --git a/homeassistant/components/lock/isy994.py b/homeassistant/components/lock/isy994.py index edbb8a34f24..63272b90b1f 100644 --- a/homeassistant/components/lock/isy994.py +++ b/homeassistant/components/lock/isy994.py @@ -66,7 +66,10 @@ class ISYLockDevice(isy.ISYDevice, LockDevice): @property def state(self) -> str: """Get the state of the lock.""" - return VALUE_TO_STATE.get(self.value, STATE_UNKNOWN) + if self.is_unknown(): + return None + else: + return VALUE_TO_STATE.get(self.value, STATE_UNKNOWN) def lock(self, **kwargs) -> None: """Send the lock command to the ISY994 device.""" diff --git a/homeassistant/components/sensor/isy994.py b/homeassistant/components/sensor/isy994.py index f64fa6191e2..e961c63a1b5 100644 --- a/homeassistant/components/sensor/isy994.py +++ b/homeassistant/components/sensor/isy994.py @@ -282,6 +282,9 @@ class ISYSensorDevice(isy.ISYDevice): @property def state(self) -> str: """Get the state of the ISY994 sensor device.""" + if self.is_unknown(): + return None + if len(self._node.uom) == 1: if self._node.uom[0] in UOM_TO_STATES: states = UOM_TO_STATES.get(self._node.uom[0]) diff --git a/homeassistant/components/switch/isy994.py b/homeassistant/components/switch/isy994.py index b930bedc2c7..0f1ec62eaee 100644 --- a/homeassistant/components/switch/isy994.py +++ b/homeassistant/components/switch/isy994.py @@ -69,7 +69,10 @@ class ISYSwitchDevice(isy.ISYDevice, SwitchDevice): @property def state(self) -> str: """Get the state of the ISY994 device.""" - return VALUE_TO_STATE.get(bool(self.value), STATE_UNKNOWN) + if self.is_unknown(): + return None + else: + return VALUE_TO_STATE.get(bool(self.value), STATE_UNKNOWN) def turn_off(self, **kwargs) -> None: """Send the turn on command to the ISY994 switch.""" diff --git a/requirements_all.txt b/requirements_all.txt index 6431d526359..ee1c2938195 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -23,7 +23,7 @@ certifi>=2017.4.17 DoorBirdPy==0.1.0 # homeassistant.components.isy994 -PyISY==1.0.8 +PyISY==1.1.0 # homeassistant.components.notify.html5 PyJWT==1.5.3 From 823e260c2a64a76217af472d9ec66f483cb3cad2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 14 Dec 2017 00:15:25 -0800 Subject: [PATCH 044/100] Disable html5 notify dependency (#11135) --- homeassistant/components/notify/html5.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/notify/html5.py b/homeassistant/components/notify/html5.py index fb3cf0bbecd..2314722a2ab 100644 --- a/homeassistant/components/notify/html5.py +++ b/homeassistant/components/notify/html5.py @@ -29,7 +29,7 @@ from homeassistant.util import ensure_unique_string REQUIREMENTS = ['pywebpush==1.3.0', 'PyJWT==1.5.3'] -DEPENDENCIES = ['frontend', 'config'] +DEPENDENCIES = ['frontend'] _LOGGER = logging.getLogger(__name__) From de4c8adca257e0d72aadb48b7b9c1e7b560c0932 Mon Sep 17 00:00:00 2001 From: Daniel Perna Date: Fri, 15 Dec 2017 00:34:27 +0100 Subject: [PATCH 045/100] Upgrade Homematic (#11149) * Update pyhomematic * Update pyhomematic --- homeassistant/components/homematic.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/homematic.py b/homeassistant/components/homematic.py index 5e8cd3dc58e..0ab6f01805f 100644 --- a/homeassistant/components/homematic.py +++ b/homeassistant/components/homematic.py @@ -21,7 +21,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import track_time_interval from homeassistant.config import load_yaml_config_file -REQUIREMENTS = ['pyhomematic==0.1.35'] +REQUIREMENTS = ['pyhomematic==0.1.36'] DOMAIN = 'homematic' diff --git a/requirements_all.txt b/requirements_all.txt index ee1c2938195..6466be2f651 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -695,7 +695,7 @@ pyhik==0.1.4 pyhiveapi==0.2.5 # homeassistant.components.homematic -pyhomematic==0.1.35 +pyhomematic==0.1.36 # homeassistant.components.sensor.hydroquebec pyhydroquebec==1.3.1 From ca81180e6dae7c33c308a56ad810b5ec1ac6a00e Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 15 Dec 2017 10:06:06 +0100 Subject: [PATCH 046/100] Bump release to 0.60.0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 4f075249e57..dd15e1fb75d 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 60 -PATCH_VERSION = '0.dev0' +PATCH_VERSION = '0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 4, 2) From a63658d58368d48b16d2247678ceedbafb8ab5e2 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 15 Dec 2017 21:22:36 +0100 Subject: [PATCH 047/100] 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 048/100] 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 049/100] 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 f4d7bbd0446c609939b4c1d82a2731d42e22a7ea Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 15 Dec 2017 23:35:37 -0800 Subject: [PATCH 050/100] 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 051/100] 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 052/100] 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 053/100] 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 054/100] 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 055/100] 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 056/100] 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 057/100] 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 7db8bbf38599e97560dc224d8259a9b9d2c4265d Mon Sep 17 00:00:00 2001 From: Matthew Treinish Date: Sat, 16 Dec 2017 15:52:40 -0800 Subject: [PATCH 058/100] 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 ec9638f4d153922374fd50c67a7535f5c03ee765 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 15 Dec 2017 21:22:36 +0100 Subject: [PATCH 059/100] 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 5860267410bdd23fd775991e09a5ec263715a35c Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 15 Dec 2017 22:54:54 +0100 Subject: [PATCH 060/100] 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 564ed26aebce0b01699b579643150e87a93eb5c9 Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Sat, 16 Dec 2017 03:04:27 -0500 Subject: [PATCH 061/100] 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 5ca006cc9c6f7ce190879e6185f1646bd1ba1f43 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 16 Dec 2017 00:42:25 -0800 Subject: [PATCH 062/100] 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 a46ddcf6ddaadd85710db7a829ec1fb0fda91734 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Sat, 16 Dec 2017 14:22:23 +0100 Subject: [PATCH 063/100] 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 294d8171a220841a1a24affe0172eeaed53d0c05 Mon Sep 17 00:00:00 2001 From: PhracturedBlue Date: Sat, 16 Dec 2017 15:52:59 -0800 Subject: [PATCH 064/100] 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 432304be82245c71b5e78ba2e91321b94d8ee7ec Mon Sep 17 00:00:00 2001 From: Mike Megally Date: Sat, 16 Dec 2017 13:29:40 -0800 Subject: [PATCH 065/100] 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 dfb8b5a3c1e087b21d97bf85e1dc8521a29b6b4e Mon Sep 17 00:00:00 2001 From: Brad Dixon Date: Sun, 17 Dec 2017 07:08:35 -0500 Subject: [PATCH 066/100] 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 c03d04d826d5e13efa0042b8959effc2cf696805 Mon Sep 17 00:00:00 2001 From: Brad Dixon Date: Sun, 17 Dec 2017 07:08:35 -0500 Subject: [PATCH 067/100] 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 068/100] 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 069/100] 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 070/100] 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 071/100] 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 072/100] 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 073/100] 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 074/100] 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 075/100] 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 076/100] 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 077/100] 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 078/100] 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 079/100] 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 080/100] 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 081/100] 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 082/100] 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 083/100] 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 084/100] 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 085/100] 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 086/100] 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 087/100] 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 088/100] 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 089/100] 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 090/100] 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 091/100] 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 092/100] 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 093/100] 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 094/100] 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 095/100] 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 096/100] 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 097/100] 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 098/100] 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 099/100] 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 100/100] 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